- Change default task prefix from KB to FN across all packages
- Rename branch naming pattern from kb/{id} to fusion/{id}
- Update all test assertions and fixtures to use new prefixes
- Add changeset for the breaking change
- Resolve merge conflicts in TaskCard.test.tsx touch gesture tests
209 lines
6.9 KiB
TypeScript
209 lines
6.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { isUsageLimitError, UsageLimitPauser, checkSessionError } from "./usage-limit-detector.js";
|
|
|
|
// ── isUsageLimitError classification tests ───────────────────────────
|
|
|
|
describe("isUsageLimitError", () => {
|
|
describe("should match usage-limit errors", () => {
|
|
const usageLimitMessages = [
|
|
// Anthropic overloaded
|
|
"overloaded_error: Overloaded",
|
|
"API is overloaded",
|
|
// Rate limiting
|
|
"rate_limit_error: Rate limit exceeded",
|
|
"rate limit exceeded",
|
|
"Rate Limit Reached",
|
|
"Too many requests",
|
|
"too many requests, please retry after 60s",
|
|
// HTTP status codes
|
|
"Request failed with status 429",
|
|
"HTTP 429: Too Many Requests",
|
|
"529 overloaded",
|
|
"Status 529",
|
|
// Quota / billing
|
|
"quota exceeded for this billing period",
|
|
"Quota limit reached",
|
|
"billing account is inactive",
|
|
"Billing issue detected",
|
|
"insufficient credit balance",
|
|
"Insufficient credits",
|
|
"credit balance too low",
|
|
];
|
|
|
|
for (const msg of usageLimitMessages) {
|
|
it(`matches: "${msg}"`, () => {
|
|
expect(isUsageLimitError(msg)).toBe(true);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("should NOT match transient server errors", () => {
|
|
const transientMessages = [
|
|
"Internal Server Error",
|
|
"Request failed with status 500",
|
|
"HTTP 502: Bad Gateway",
|
|
"503 Service Unavailable",
|
|
"504 Gateway Timeout",
|
|
"connection refused",
|
|
"Connection reset by peer",
|
|
"ECONNREFUSED",
|
|
"timeout exceeded",
|
|
"request timed out",
|
|
"socket hang up",
|
|
"network error",
|
|
"ETIMEDOUT",
|
|
"DNS lookup failed",
|
|
"getaddrinfo ENOTFOUND",
|
|
];
|
|
|
|
for (const msg of transientMessages) {
|
|
it(`does not match: "${msg}"`, () => {
|
|
expect(isUsageLimitError(msg)).toBe(false);
|
|
});
|
|
}
|
|
});
|
|
|
|
it("returns false for empty string", () => {
|
|
expect(isUsageLimitError("")).toBe(false);
|
|
});
|
|
|
|
it("returns false for generic error messages", () => {
|
|
expect(isUsageLimitError("Something went wrong")).toBe(false);
|
|
expect(isUsageLimitError("Unexpected token in JSON")).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── checkSessionError tests ──────────────────────────────────────────
|
|
|
|
describe("checkSessionError", () => {
|
|
it("throws when session.state.error is set", () => {
|
|
const session = { state: { error: "rate_limit_error: Rate limit exceeded" } };
|
|
expect(() => checkSessionError(session)).toThrow("rate_limit_error: Rate limit exceeded");
|
|
});
|
|
|
|
it("does not throw when session.state.error is undefined", () => {
|
|
const session = { state: { error: undefined } };
|
|
expect(() => checkSessionError(session)).not.toThrow();
|
|
});
|
|
|
|
it("does not throw when session.state.error is empty string", () => {
|
|
const session = { state: { error: "" } };
|
|
expect(() => checkSessionError(session)).not.toThrow();
|
|
});
|
|
|
|
it("thrown error message matches session.state.error exactly", () => {
|
|
const errorMessage = "overloaded_error: Overloaded";
|
|
const session = { state: { error: errorMessage } };
|
|
|
|
let thrownMessage: string | undefined;
|
|
try {
|
|
checkSessionError(session);
|
|
} catch (err: any) {
|
|
thrownMessage = err.message;
|
|
}
|
|
|
|
expect(thrownMessage).toBe(errorMessage);
|
|
// Verify isUsageLimitError can classify it
|
|
expect(isUsageLimitError(thrownMessage!)).toBe(true);
|
|
});
|
|
|
|
it("thrown error message for rate limit is classifiable by isUsageLimitError", () => {
|
|
const session = { state: { error: "429 Too Many Requests" } };
|
|
|
|
let thrownMessage: string | undefined;
|
|
try {
|
|
checkSessionError(session);
|
|
} catch (err: any) {
|
|
thrownMessage = err.message;
|
|
}
|
|
|
|
expect(isUsageLimitError(thrownMessage!)).toBe(true);
|
|
});
|
|
|
|
it("does not throw when state has no error property", () => {
|
|
const session = { state: {} };
|
|
expect(() => checkSessionError(session as any)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
// ── UsageLimitPauser tests ───────────────────────────────────────────
|
|
|
|
function createMockStore(globalPause = false) {
|
|
return {
|
|
getSettings: vi.fn().mockResolvedValue({ globalPause }),
|
|
updateSettings: vi.fn().mockResolvedValue({ globalPause: true }),
|
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
|
} as any;
|
|
}
|
|
|
|
describe("UsageLimitPauser", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("calls store.updateSettings({ globalPause: true }) on usage limit hit", async () => {
|
|
const store = createMockStore();
|
|
const pauser = new UsageLimitPauser(store);
|
|
|
|
await pauser.onUsageLimitHit("executor", "FN-001", "rate_limit_error: Rate limit exceeded");
|
|
|
|
expect(store.updateSettings).toHaveBeenCalledWith({ globalPause: true });
|
|
});
|
|
|
|
it("logs the triggering error on the task via store.logEntry", async () => {
|
|
const store = createMockStore();
|
|
const pauser = new UsageLimitPauser(store);
|
|
|
|
await pauser.onUsageLimitHit("triage", "FN-002", "overloaded_error");
|
|
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-002",
|
|
"Usage limit detected (triage): overloaded_error",
|
|
);
|
|
});
|
|
|
|
it("is idempotent — calling multiple times only triggers one pause", async () => {
|
|
const store = createMockStore();
|
|
// After first call, globalPause will be true
|
|
store.getSettings.mockResolvedValue({ globalPause: true });
|
|
|
|
const pauser = new UsageLimitPauser(store);
|
|
|
|
await pauser.onUsageLimitHit("executor", "FN-001", "rate limit");
|
|
await pauser.onUsageLimitHit("triage", "FN-002", "rate limit");
|
|
await pauser.onUsageLimitHit("merger", "FN-003", "rate limit");
|
|
|
|
// updateSettings should only be called once
|
|
expect(store.updateSettings).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("re-triggers pause if globalPause was externally reset to false", async () => {
|
|
const store = createMockStore();
|
|
const pauser = new UsageLimitPauser(store);
|
|
|
|
// First hit — triggers pause
|
|
store.getSettings.mockResolvedValue({ globalPause: true });
|
|
await pauser.onUsageLimitHit("executor", "FN-001", "rate limit");
|
|
expect(store.updateSettings).toHaveBeenCalledTimes(1);
|
|
|
|
// External reset: globalPause set to false
|
|
store.getSettings.mockResolvedValue({ globalPause: false });
|
|
|
|
// Second hit — should trigger again since it was reset
|
|
await pauser.onUsageLimitHit("executor", "FN-004", "rate limit again");
|
|
expect(store.updateSettings).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("includes agent type in the log entry", async () => {
|
|
const store = createMockStore();
|
|
const pauser = new UsageLimitPauser(store);
|
|
|
|
await pauser.onUsageLimitHit("merger", "FN-005", "quota exceeded");
|
|
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-005",
|
|
expect.stringContaining("merger"),
|
|
);
|
|
});
|
|
});
|