- Move all co-located *.test.* files into sibling __tests__/ directories so the
layout is consistent across packages (159 renames + content-rewrite moves).
Updates relative imports, vi.mock specifiers, and __dirname/import.meta.url
path resolutions where tests read fixtures from disk.
- Drop tracked tsc-emit alongside engine .ts sources (auth-storage/logger/
skill-resolver/context-limit-detector/pi.{js,d.ts,*.map}). These were
accidentally committed in a merge and the stale pi.js was masking a real
test-mock vs source mismatch (tests imported "../pi.js" and vite preferred
the stale build over pi.ts).
- Add packages/engine/.gitignore to block future src/*.{js,d.ts,map}.
- Refactor plugin pi-module seams (openclaw/paperclip/hermes) to ESM-import
createFnAgent / promptWithFallback / describeModel from @fusion/engine
instead of require()-ing packages/engine/src/pi.js. Adds @fusion/engine to
the two plugin package.jsons that were missing it; exports describeModel
from the engine public API.
- Fix engine test mocks now that they run against current pi.ts: add
ModelRegistry.create static to mocks in pi.test.ts and pi-create-fn-agent
.test.ts; switch three boundary-result toEqual assertions to toMatchObject
so the new content/isError fields don't trip exact-match comparison.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
212 lines
7.0 KiB
TypeScript
212 lines
7.0 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, globalPauseReason: \"rate-limit\" }) 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,
|
|
globalPauseReason: "rate-limit",
|
|
});
|
|
});
|
|
|
|
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"),
|
|
);
|
|
});
|
|
});
|