589 lines
25 KiB
JavaScript
589 lines
25 KiB
JavaScript
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
|||
|
|
import freebuff from "../../src/lib/oauth/providers/freebuff.js";
|
||
|
|
|
||
|
|
// Mock proxyAwareFetch so session/run/chat flows never hit the network.
|
||
|
|
const fetchMock = vi.fn();
|
||
|
|
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||
|
|
proxyAwareFetch: (...args) => fetchMock(...args),
|
||
|
|
}));
|
||
|
|
|
||
|
|
import { FreebuffExecutor, __test__ } from "../../open-sse/executors/freebuff.js";
|
||
|
|
|
||
|
|
const {
|
||
|
|
ensureSession,
|
||
|
|
requestSession,
|
||
|
|
startRun,
|
||
|
|
resetSessionCache,
|
||
|
|
rootAgentIdForModel,
|
||
|
|
injectFreebuffMarker,
|
||
|
|
FREEBUFF_SYSTEM_MARKER,
|
||
|
|
} = __test__;
|
||
|
|
|
||
|
|
const CONFIG = {
|
||
|
|
baseUrl: "https://freebuff.com",
|
||
|
|
loginCodePath: "/api/auth/cli/code",
|
||
|
|
loginStatusPath: "/api/auth/cli/status",
|
||
|
|
};
|
||
|
|
|
||
|
|
function jsonResponse(data, { ok = true, status = 200 } = {}) {
|
||
|
|
return {
|
||
|
|
ok,
|
||
|
|
status,
|
||
|
|
json: async () => data,
|
||
|
|
text: async () => JSON.stringify(data),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
vi.unstubAllGlobals();
|
||
|
|
});
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
fetchMock.mockReset();
|
||
|
|
resetSessionCache();
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("freebuff oauth flow", () => {
|
||
|
|
it("requestDeviceCode posts a fingerprint to the freebuff.com login host and surfaces the login URL", async () => {
|
||
|
|
const loginUrl = "https://freebuff.com/login?auth_code=AbCd-123";
|
||
|
|
vi.stubGlobal(
|
||
|
|
"fetch",
|
||
|
|
vi.fn().mockResolvedValue({
|
||
|
|
ok: true,
|
||
|
|
status: 200,
|
||
|
|
json: async () => ({
|
||
|
|
fingerprintId: "fp-1",
|
||
|
|
fingerprintHash: "hash-1",
|
||
|
|
loginUrl,
|
||
|
|
expiresAt: Date.now() + 60000,
|
||
|
|
}),
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
const out = await freebuff.requestDeviceCode(CONFIG);
|
||
|
|
expect(out.verification_uri_complete).toBe(loginUrl);
|
||
|
|
expect(out.user_code).toBe("AbCd-123");
|
||
|
|
expect(out.interval).toBe(5);
|
||
|
|
expect(out.expires_in).toBe(60);
|
||
|
|
// The server echoes the request host into loginUrl — it must be freebuff.com,
|
||
|
|
// not www.codebuff.com, to match the official CLI's login link.
|
||
|
|
const [url] = global.fetch.mock.calls[0];
|
||
|
|
expect(url.startsWith("https://freebuff.com/api/auth/cli/code")).toBe(true);
|
||
|
|
const payload = JSON.parse(out.device_code);
|
||
|
|
expect(payload.fingerprintId).toBe("fp-1");
|
||
|
|
expect(payload.fingerprintHash).toBe("hash-1");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("requestDeviceCode falls back to oauthTimeoutMs when the server omits expiresAt", async () => {
|
||
|
|
vi.stubGlobal(
|
||
|
|
"fetch",
|
||
|
|
vi.fn().mockResolvedValue({
|
||
|
|
ok: true,
|
||
|
|
status: 200,
|
||
|
|
json: async () => ({
|
||
|
|
fingerprintId: "fp-1",
|
||
|
|
fingerprintHash: "hash-1",
|
||
|
|
loginUrl: "https://freebuff.com/login?auth_code=Ab",
|
||
|
|
// no expiresAt — must NOT collapse to the 60s floor
|
||
|
|
}),
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
const out = await freebuff.requestDeviceCode(CONFIG);
|
||
|
|
expect(out.expires_in).toBe(300);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("requestDeviceCode leaves user_code empty when loginUrl has no auth_code", async () => {
|
||
|
|
vi.stubGlobal(
|
||
|
|
"fetch",
|
||
|
|
vi.fn().mockResolvedValue({
|
||
|
|
ok: true,
|
||
|
|
status: 200,
|
||
|
|
json: async () => ({
|
||
|
|
fingerprintId: "fp-1",
|
||
|
|
fingerprintHash: "hash-1",
|
||
|
|
loginUrl: "https://freebuff.com/login",
|
||
|
|
expiresAt: Date.now() + 60000,
|
||
|
|
}),
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
const out = await freebuff.requestDeviceCode(CONFIG);
|
||
|
|
expect(out.user_code).toBe("");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("requestDeviceCode clamps expires_in to oauthTimeoutMs", async () => {
|
||
|
|
vi.stubGlobal(
|
||
|
|
"fetch",
|
||
|
|
vi.fn().mockResolvedValue({
|
||
|
|
ok: true,
|
||
|
|
status: 200,
|
||
|
|
json: async () => ({
|
||
|
|
fingerprintId: "fp-1",
|
||
|
|
fingerprintHash: "hash-1",
|
||
|
|
loginUrl: "https://freebuff.com/login?auth_code=Ab",
|
||
|
|
// server-side codes live ~1h; the modal deadline must stay at 5 min
|
||
|
|
expiresAt: Date.now() + 3600000,
|
||
|
|
}),
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
const out = await freebuff.requestDeviceCode(CONFIG);
|
||
|
|
expect(out.expires_in).toBe(300);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("pollToken keeps polling on 401 pending (GET with query params)", async () => {
|
||
|
|
vi.stubGlobal(
|
||
|
|
"fetch",
|
||
|
|
vi.fn().mockResolvedValue({
|
||
|
|
ok: false,
|
||
|
|
status: 401,
|
||
|
|
json: async () => ({ error: "Authentication failed" }),
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
const res = await freebuff.pollToken(
|
||
|
|
CONFIG,
|
||
|
|
JSON.stringify({ fingerprintId: "fp-1", fingerprintHash: "h", expiresAt: 123 }),
|
||
|
|
);
|
||
|
|
expect(res.ok).toBe(true);
|
||
|
|
expect(res.data.error).toBe("authorization_pending");
|
||
|
|
const [url, opts] = global.fetch.mock.calls[0];
|
||
|
|
expect(url).toContain("https://freebuff.com/api/auth/cli/status?");
|
||
|
|
expect(url).toContain("fingerprintId=fp-1");
|
||
|
|
expect(opts.method).toBe("GET");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("pollToken returns the authToken on success", async () => {
|
||
|
|
vi.stubGlobal(
|
||
|
|
"fetch",
|
||
|
|
vi.fn().mockResolvedValue({
|
||
|
|
ok: true,
|
||
|
|
status: 200,
|
||
|
|
json: async () => ({
|
||
|
|
user: { id: "u1", email: "a@b.c", name: "A", authToken: "tok-123", fingerprintId: "fp-1" },
|
||
|
|
}),
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
const res = await freebuff.pollToken(
|
||
|
|
CONFIG,
|
||
|
|
JSON.stringify({ fingerprintId: "fp-1", fingerprintHash: "h", expiresAt: 123 }),
|
||
|
|
);
|
||
|
|
expect(res.data.access_token).toBe("tok-123");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("mapTokens stores accessToken + identity", () => {
|
||
|
|
const t = freebuff.mapTokens({
|
||
|
|
access_token: "tok",
|
||
|
|
email: "a@b.c",
|
||
|
|
name: "A",
|
||
|
|
id: "u1",
|
||
|
|
fingerprintId: "fp",
|
||
|
|
});
|
||
|
|
expect(t.accessToken).toBe("tok");
|
||
|
|
expect(t.email).toBe("a@b.c");
|
||
|
|
expect(t.displayName).toBe("A");
|
||
|
|
expect(t.refreshToken).toBeNull();
|
||
|
|
expect(t.providerSpecificData.fingerprintId).toBe("fp");
|
||
|
|
expect(t.providerSpecificData.authMethod).toBe("device_code");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("freebuff executor wire shape", () => {
|
||
|
|
it("injects codebuff_metadata at TOP LEVEL (mirrors the CLI, not nested under codebuff)", () => {
|
||
|
|
const ex = new FreebuffExecutor();
|
||
|
|
const body = { model: "deepseek/deepseek-v4-flash", messages: [{ role: "user", content: "hi" }] };
|
||
|
|
const out = ex.transformRequest(body.model, body, true, {
|
||
|
|
providerSpecificData: { fingerprintId: "fp-1" },
|
||
|
|
});
|
||
|
|
// Top-level keys — the backend rejects the nested shape with
|
||
|
|
// "No runId found in request body".
|
||
|
|
expect(out.codebuff_metadata.cost_mode).toBe("free");
|
||
|
|
expect(out.codebuff_metadata.client_id).toBe("fp-1");
|
||
|
|
// run_id is the registered runId and is attached by execute(), not here.
|
||
|
|
expect(out.codebuff_metadata.run_id).toBeUndefined();
|
||
|
|
expect(out.codebuff).toBeUndefined();
|
||
|
|
expect(out.provider.allow_fallbacks).toBe(false);
|
||
|
|
// Free-tier marker is prepended so the first message opens with the CLI root prompt.
|
||
|
|
expect(out.messages[0].content).toBe(FREEBUFF_SYSTEM_MARKER);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("buildUrl targets the Codebuff chat completions endpoint (www.codebuff.com)", () => {
|
||
|
|
const ex = new FreebuffExecutor();
|
||
|
|
expect(ex.buildUrl()).toBe("https://www.codebuff.com/api/v1/chat/completions");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("freebuff session pre-flight", () => {
|
||
|
|
it("claims a session via POST /session with x-freebuff-model and caches it per token+model", async () => {
|
||
|
|
fetchMock.mockResolvedValue(
|
||
|
|
jsonResponse({
|
||
|
|
status: "active",
|
||
|
|
instanceId: "inst-1",
|
||
|
|
model: "deepseek/deepseek-v4-flash",
|
||
|
|
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
const first = await ensureSession("tok-1", "deepseek/deepseek-v4-flash", null);
|
||
|
|
expect(first).toEqual({ instanceId: "inst-1", status: "active" });
|
||
|
|
|
||
|
|
const [url, opts] = fetchMock.mock.calls[0];
|
||
|
|
expect(url).toBe("https://www.codebuff.com/api/v1/freebuff/session");
|
||
|
|
expect(opts.method).toBe("POST");
|
||
|
|
expect(opts.headers["x-freebuff-model"]).toBe("deepseek/deepseek-v4-flash");
|
||
|
|
expect(opts.headers.Authorization).toBe("Bearer tok-1");
|
||
|
|
|
||
|
|
// Second call for the same token+model hits the cache — no new claim.
|
||
|
|
await ensureSession("tok-1", "deepseek/deepseek-v4-flash", null);
|
||
|
|
expect(fetchMock.mock.calls.length).toBe(1);
|
||
|
|
|
||
|
|
// Different model → separate claim.
|
||
|
|
fetchMock.mockResolvedValue(
|
||
|
|
jsonResponse({ status: "active", instanceId: "inst-2", expiresAt: new Date(Date.now() + 3600000).toISOString() }),
|
||
|
|
);
|
||
|
|
await ensureSession("tok-1", "minimax/minimax-m3", null);
|
||
|
|
expect(fetchMock.mock.calls.length).toBe(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("treats status none as no-session-needed (instanceId null)", async () => {
|
||
|
|
fetchMock.mockResolvedValue(jsonResponse({ status: "none", accessTier: "full" }));
|
||
|
|
const res = await ensureSession("tok-1", "deepseek/deepseek-v4-flash", null);
|
||
|
|
expect(res).toEqual({ instanceId: null, status: "none" });
|
||
|
|
});
|
||
|
|
|
||
|
|
it("throws a friendly error on rate_limited", async () => {
|
||
|
|
fetchMock.mockResolvedValue(
|
||
|
|
jsonResponse({ status: "rate_limited", message: "4 of 6 sessions used today" }),
|
||
|
|
);
|
||
|
|
await expect(ensureSession("tok-1", "deepseek/deepseek-v4-flash", null)).rejects.toThrow(
|
||
|
|
/session limit reached/i,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("throws a friendly error on country_blocked", async () => {
|
||
|
|
fetchMock.mockResolvedValue(jsonResponse({ status: "country_blocked" }));
|
||
|
|
await expect(ensureSession("tok-1", "deepseek/deepseek-v4-flash", null)).rejects.toThrow(
|
||
|
|
/not available in your region/i,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("throws a 401 re-login error when the session endpoint rejects the token", async () => {
|
||
|
|
fetchMock.mockResolvedValue(jsonResponse({ error: "unauthorized" }, { status: 401, ok: false }));
|
||
|
|
await expect(requestSession("tok-expired", "deepseek/deepseek-v4-flash", null)).rejects.toThrow(/re-login/i);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("freebuff free-tier system marker", () => {
|
||
|
|
it("prepends the canonical marker when the first message is a system prompt", () => {
|
||
|
|
const out = injectFreebuffMarker({
|
||
|
|
messages: [{ role: "system", content: "You are a helpful assistant." }, { role: "user", content: "hi" }],
|
||
|
|
});
|
||
|
|
expect(out.messages[0].content).toBe(`${FREEBUFF_SYSTEM_MARKER}\n\nYou are a helpful assistant.`);
|
||
|
|
expect(out.messages[1].role).toBe("user");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("inserts a marker system message when the first message is not a system prompt", () => {
|
||
|
|
const out = injectFreebuffMarker({ messages: [{ role: "user", content: "hi" }] });
|
||
|
|
expect(out.messages[0]).toEqual({ role: "system", content: FREEBUFF_SYSTEM_MARKER });
|
||
|
|
expect(out.messages[1]).toEqual({ role: "user", content: "hi" });
|
||
|
|
});
|
||
|
|
|
||
|
|
it("is idempotent when the first system message already opens with the marker", () => {
|
||
|
|
const messages = [{ role: "system", content: FREEBUFF_SYSTEM_MARKER }];
|
||
|
|
const out = injectFreebuffMarker({ messages });
|
||
|
|
expect(out.messages).toBe(messages);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("inserts a marker system message when the first system content is a block array", () => {
|
||
|
|
const out = injectFreebuffMarker({
|
||
|
|
messages: [{ role: "system", content: [{ type: "text", text: "hi" }] }, { role: "user", content: "x" }],
|
||
|
|
});
|
||
|
|
expect(out.messages[0]).toEqual({ role: "system", content: FREEBUFF_SYSTEM_MARKER });
|
||
|
|
expect(out.messages[1].content).toEqual([{ type: "text", text: "hi" }]);
|
||
|
|
expect(out.messages[2].content).toBe("x");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("freebuff run registration", () => {
|
||
|
|
it("maps freebuff models to their root free agent ids", () => {
|
||
|
|
expect(rootAgentIdForModel("deepseek/deepseek-v4-flash")).toBe("base2-free-deepseek-flash");
|
||
|
|
expect(rootAgentIdForModel("deepseek/deepseek-v4-pro")).toBe("base2-free-deepseek");
|
||
|
|
expect(rootAgentIdForModel("mimo/mimo-v2.5")).toBe("base2-free-mimo");
|
||
|
|
expect(rootAgentIdForModel("minimax/minimax-m3")).toBe("base2-free-minimax-m3");
|
||
|
|
expect(rootAgentIdForModel("openai/gpt-5.6-luna")).toBe("base2-free-luna");
|
||
|
|
expect(rootAgentIdForModel("some/unknown-model")).toBe("base2-free");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("registers a run via POST /agent-runs and returns the runId", async () => {
|
||
|
|
fetchMock.mockResolvedValue(jsonResponse({ runId: "run-abc" }));
|
||
|
|
const runId = await startRun("tok-1", "deepseek/deepseek-v4-flash", null);
|
||
|
|
expect(runId).toBe("run-abc");
|
||
|
|
|
||
|
|
const [url, opts] = fetchMock.mock.calls[0];
|
||
|
|
expect(url).toBe("https://www.codebuff.com/api/v1/agent-runs");
|
||
|
|
expect(opts.method).toBe("POST");
|
||
|
|
expect(opts.headers.Authorization).toBe("Bearer tok-1");
|
||
|
|
const payload = JSON.parse(opts.body);
|
||
|
|
expect(payload.action).toBe("START");
|
||
|
|
expect(payload.agentId).toBe("base2-free-deepseek-flash");
|
||
|
|
expect(payload.ancestorRunIds).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("throws when the run start fails", async () => {
|
||
|
|
fetchMock.mockResolvedValue(jsonResponse({ error: "bad" }, { status: 500, ok: false }));
|
||
|
|
await expect(startRun("tok-1", "deepseek/deepseek-v4-flash", null)).rejects.toThrow(/run start failed/i);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("retries transient network errors on run registration", async () => {
|
||
|
|
fetchMock.mockImplementation(async (url) => {
|
||
|
|
if (url.includes("/agent-runs")) {
|
||
|
|
const calls = fetchMock.mock.calls.filter(([u]) => u.includes("/agent-runs")).length;
|
||
|
|
if (calls === 1) throw new Error("fetch failed (cause: ECONNRESET)");
|
||
|
|
return jsonResponse({ runId: "run-retried" });
|
||
|
|
}
|
||
|
|
throw new Error("unexpected url");
|
||
|
|
});
|
||
|
|
const runId = await startRun("tok-1", "deepseek/deepseek-v4-flash", null);
|
||
|
|
expect(runId).toBe("run-retried");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("freebuff executor execute", () => {
|
||
|
|
const CHAT_URL = "https://www.codebuff.com/api/v1/chat/completions";
|
||
|
|
const SESSION_URL = "https://www.codebuff.com/api/v1/freebuff/session";
|
||
|
|
const RUN_URL = "https://www.codebuff.com/api/v1/agent-runs";
|
||
|
|
const MODEL = "deepseek/deepseek-v4-flash";
|
||
|
|
const credentials = { accessToken: "tok-1", providerSpecificData: { fingerprintId: "fp-1" } };
|
||
|
|
|
||
|
|
// Default happy-path backend: session active, run registered, chat 200.
|
||
|
|
const happyPath = () => {
|
||
|
|
fetchMock.mockImplementation(async (url) => {
|
||
|
|
if (url === SESSION_URL) {
|
||
|
|
return jsonResponse({ status: "active", instanceId: "inst-1", expiresAt: new Date(Date.now() + 3600000).toISOString() });
|
||
|
|
}
|
||
|
|
if (url === RUN_URL) {
|
||
|
|
return jsonResponse({ runId: "run-1" });
|
||
|
|
}
|
||
|
|
return jsonResponse({ choices: [{ message: { content: "hi" } }] });
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
it("sends the registered runId + session instance id on the chat request", async () => {
|
||
|
|
happyPath();
|
||
|
|
|
||
|
|
const ex = new FreebuffExecutor();
|
||
|
|
const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] };
|
||
|
|
const { response } = await ex.execute({ model: MODEL, body, stream: false, credentials, log: null });
|
||
|
|
|
||
|
|
expect(response.status).toBe(200);
|
||
|
|
const chatCall = fetchMock.mock.calls.find(([u]) => u === CHAT_URL);
|
||
|
|
expect(chatCall).toBeTruthy();
|
||
|
|
const sent = JSON.parse(chatCall[1].body);
|
||
|
|
expect(sent.codebuff_metadata.run_id).toBe("run-1");
|
||
|
|
expect(sent.codebuff_metadata.freebuff_instance_id).toBe("inst-1");
|
||
|
|
expect(sent.codebuff_metadata.trace_session_id).toMatch(
|
||
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||
|
|
);
|
||
|
|
expect(sent.codebuff_metadata.cost_mode).toBe("free");
|
||
|
|
expect(sent.codebuff_metadata.client_id).toBe("fp-1");
|
||
|
|
expect(sent.codebuff).toBeUndefined();
|
||
|
|
// Free-tier marker present at position 0 of the request body.
|
||
|
|
expect(sent.messages[0].content.startsWith("You are Buffy,")).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("retries exactly once on 428 with a fresh session AND a fresh run", async () => {
|
||
|
|
let chatHits = 0;
|
||
|
|
fetchMock.mockImplementation(async (url) => {
|
||
|
|
if (url === SESSION_URL) {
|
||
|
|
return jsonResponse({ status: "active", instanceId: "inst-2", expiresAt: new Date(Date.now() + 3600000).toISOString() });
|
||
|
|
}
|
||
|
|
if (url === RUN_URL) {
|
||
|
|
return jsonResponse({ runId: "run-2" });
|
||
|
|
}
|
||
|
|
chatHits += 1;
|
||
|
|
if (chatHits === 1) return jsonResponse({ error: "waiting_room_required" }, { status: 428, ok: false });
|
||
|
|
return jsonResponse({ choices: [{ message: { content: "hi" } }] });
|
||
|
|
});
|
||
|
|
|
||
|
|
const ex = new FreebuffExecutor();
|
||
|
|
const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] };
|
||
|
|
const { response } = await ex.execute({ model: MODEL, body, stream: false, credentials, log: null });
|
||
|
|
|
||
|
|
expect(response.status).toBe(200);
|
||
|
|
expect(chatHits).toBe(2);
|
||
|
|
// Session was claimed twice (initial + forced re-claim).
|
||
|
|
expect(fetchMock.mock.calls.filter(([u]) => u === SESSION_URL).length).toBe(2);
|
||
|
|
// Runs: START #1, FINISH(cancelled) #1 (abandoned on 428), START #2,
|
||
|
|
// FINISH(completed) #2.
|
||
|
|
const runCalls = fetchMock.mock.calls.filter(([u]) => u === RUN_URL);
|
||
|
|
expect(runCalls.length).toBe(4);
|
||
|
|
const runActions = runCalls.map((c) => JSON.parse(c[1].body).action);
|
||
|
|
expect(runActions.filter((a) => a === "START").length).toBe(2);
|
||
|
|
expect(runActions.filter((a) => a === "FINISH").length).toBe(2);
|
||
|
|
const finishPayload = JSON.parse(runCalls[runCalls.length - 1][1].body);
|
||
|
|
expect(finishPayload.status).toBe("completed");
|
||
|
|
// The retried chat request carried the re-claimed session + fresh run.
|
||
|
|
const lastChat = fetchMock.mock.calls.filter(([u]) => u === CHAT_URL).pop();
|
||
|
|
const sent = JSON.parse(lastChat[1].body);
|
||
|
|
expect(sent.codebuff_metadata.run_id).toBe("run-2");
|
||
|
|
expect(sent.codebuff_metadata.freebuff_instance_id).toBe("inst-2");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("re-claims the session on 409 session_superseded and retries once", async () => {
|
||
|
|
let chatHits = 0;
|
||
|
|
fetchMock.mockImplementation(async (url) => {
|
||
|
|
if (url === SESSION_URL) return jsonResponse({ status: "active", instanceId: "inst-2", expiresAt: new Date(Date.now() + 3600000).toISOString() });
|
||
|
|
if (url === RUN_URL) return jsonResponse({ runId: "run-2" });
|
||
|
|
chatHits += 1;
|
||
|
|
if (chatHits === 1) {
|
||
|
|
return jsonResponse({ error: "session_superseded", message: "Another instance took over" }, { status: 409, ok: false });
|
||
|
|
}
|
||
|
|
return jsonResponse({ choices: [{ message: { content: "hi" } }] });
|
||
|
|
});
|
||
|
|
|
||
|
|
const ex = new FreebuffExecutor();
|
||
|
|
const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] };
|
||
|
|
const { response } = await ex.execute({ model: MODEL, body, stream: false, credentials, log: null });
|
||
|
|
|
||
|
|
expect(response.status).toBe(200);
|
||
|
|
expect(chatHits).toBe(2);
|
||
|
|
// Session re-claimed (initial + forced) and runs restarted.
|
||
|
|
expect(fetchMock.mock.calls.filter(([u]) => u === SESSION_URL).length).toBe(2);
|
||
|
|
const runCalls = fetchMock.mock.calls.filter(([u]) => u === RUN_URL);
|
||
|
|
expect(runCalls.filter((c) => JSON.parse(c[1].body).action === "START").length).toBe(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("re-claims the session on 410 session_expired and retries once", async () => {
|
||
|
|
let chatHits = 0;
|
||
|
|
fetchMock.mockImplementation(async (url) => {
|
||
|
|
if (url === SESSION_URL) return jsonResponse({ status: "active", instanceId: "inst-2", expiresAt: new Date(Date.now() + 3600000).toISOString() });
|
||
|
|
if (url === RUN_URL) return jsonResponse({ runId: "run-2" });
|
||
|
|
chatHits += 1;
|
||
|
|
if (chatHits === 1) return jsonResponse({ error: "session_expired" }, { status: 410, ok: false });
|
||
|
|
return jsonResponse({ choices: [{ message: { content: "hi" } }] });
|
||
|
|
});
|
||
|
|
|
||
|
|
const ex = new FreebuffExecutor();
|
||
|
|
const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] };
|
||
|
|
const { response } = await ex.execute({ model: MODEL, body, stream: false, credentials, log: null });
|
||
|
|
expect(response.status).toBe(200);
|
||
|
|
expect(chatHits).toBe(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("throws a 401 re-login error when the chat endpoint rejects the token", async () => {
|
||
|
|
fetchMock.mockImplementation(async (url) => {
|
||
|
|
if (url === SESSION_URL) return jsonResponse({ status: "active", instanceId: "inst-1", expiresAt: new Date(Date.now() + 3600000).toISOString() });
|
||
|
|
if (url === RUN_URL) return jsonResponse({ runId: "run-1" });
|
||
|
|
return jsonResponse({ error: "unauthorized" }, { status: 401, ok: false });
|
||
|
|
});
|
||
|
|
|
||
|
|
const ex = new FreebuffExecutor();
|
||
|
|
const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] };
|
||
|
|
await expect(
|
||
|
|
ex.execute({ model: MODEL, body, stream: false, credentials, log: null }),
|
||
|
|
).rejects.toThrow(/re-login/i);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("throws when no access token is present", async () => {
|
||
|
|
const ex = new FreebuffExecutor();
|
||
|
|
await expect(
|
||
|
|
ex.execute({ model: MODEL, body: { messages: [] }, stream: false, credentials: {}, log: null }),
|
||
|
|
).rejects.toThrow(/no access token/i);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("finishes the run as failed when the chat upstream errors", async () => {
|
||
|
|
// 400 (not in the 429/502/503 retry set) so the test stays fast and mirrors
|
||
|
|
// the real upstream rejection.
|
||
|
|
fetchMock.mockImplementation(async (url) => {
|
||
|
|
if (url === SESSION_URL) return jsonResponse({ status: "active", instanceId: "inst-1", expiresAt: new Date(Date.now() + 3600000).toISOString() });
|
||
|
|
if (url === RUN_URL) return jsonResponse({ runId: "run-1" });
|
||
|
|
return jsonResponse({ error: "upstream boom" }, { status: 400, ok: false });
|
||
|
|
});
|
||
|
|
|
||
|
|
const ex = new FreebuffExecutor();
|
||
|
|
const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] };
|
||
|
|
const { response } = await ex.execute({ model: MODEL, body, stream: false, credentials, log: null });
|
||
|
|
|
||
|
|
expect(response.status).toBe(400);
|
||
|
|
const runCalls = fetchMock.mock.calls.filter(([u]) => u === RUN_URL);
|
||
|
|
expect(runCalls.length).toBe(2); // START + FINISH
|
||
|
|
const finishPayload = JSON.parse(runCalls[1][1].body);
|
||
|
|
expect(finishPayload.action).toBe("FINISH");
|
||
|
|
expect(finishPayload.status).toBe("failed");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("finishes the run as failed when execute throws mid-flight", async () => {
|
||
|
|
// AbortError (caller/stream abort) is never retried, keeping this test fast.
|
||
|
|
fetchMock.mockImplementation(async (url) => {
|
||
|
|
if (url === SESSION_URL) return jsonResponse({ status: "active", instanceId: "inst-1", expiresAt: new Date(Date.now() + 3600000).toISOString() });
|
||
|
|
if (url === RUN_URL) return jsonResponse({ runId: "run-1" });
|
||
|
|
throw Object.assign(new Error("aborted"), { name: "AbortError" });
|
||
|
|
});
|
||
|
|
|
||
|
|
const ex = new FreebuffExecutor();
|
||
|
|
const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] };
|
||
|
|
await expect(
|
||
|
|
ex.execute({ model: MODEL, body, stream: false, credentials, log: null }),
|
||
|
|
).rejects.toThrow(/aborted/);
|
||
|
|
|
||
|
|
const runCalls = fetchMock.mock.calls.filter(([u]) => u === RUN_URL);
|
||
|
|
expect(runCalls.length).toBe(2); // START + FINISH(failed) from the finally block
|
||
|
|
const finishPayload = JSON.parse(runCalls[1][1].body);
|
||
|
|
expect(finishPayload.action).toBe("FINISH");
|
||
|
|
expect(finishPayload.status).toBe("failed");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("retries the chat POST on a transient fetch-level network error", async () => {
|
||
|
|
let chatHits = 0;
|
||
|
|
fetchMock.mockImplementation(async (url) => {
|
||
|
|
if (url === SESSION_URL) return jsonResponse({ status: "active", instanceId: "inst-1", expiresAt: new Date(Date.now() + 3600000).toISOString() });
|
||
|
|
if (url === RUN_URL) return jsonResponse({ runId: "run-1" });
|
||
|
|
chatHits += 1;
|
||
|
|
if (chatHits === 1) throw new Error("fetch failed (cause: ECONNRESET)");
|
||
|
|
return jsonResponse({ choices: [{ message: { content: "hi" } }] });
|
||
|
|
});
|
||
|
|
|
||
|
|
const ex = new FreebuffExecutor();
|
||
|
|
const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] };
|
||
|
|
const { response } = await ex.execute({ model: MODEL, body, stream: false, credentials, log: null });
|
||
|
|
|
||
|
|
expect(response.status).toBe(200);
|
||
|
|
expect(chatHits).toBe(2);
|
||
|
|
// Run FINISHed exactly once (completed) — no double-FINISH from the retry.
|
||
|
|
const runCalls = fetchMock.mock.calls.filter(([u]) => u === RUN_URL);
|
||
|
|
expect(runCalls.length).toBe(2); // START + FINISH(completed)
|
||
|
|
expect(JSON.parse(runCalls[1][1].body).status).toBe("completed");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("does not double-FINISH the abandoned run when the re-claim fails", async () => {
|
||
|
|
let chatHits = 0;
|
||
|
|
let runStartCount = 0;
|
||
|
|
fetchMock.mockImplementation(async (url) => {
|
||
|
|
if (url === SESSION_URL) return jsonResponse({ status: "active", instanceId: "inst-2", expiresAt: new Date(Date.now() + 3600000).toISOString() });
|
||
|
|
if (url === RUN_URL) {
|
||
|
|
// First START succeeds (run-1). Every later call fails with the
|
||
|
|
// transient ECONNRESET: the fire-and-forget FINISH swallows it, and
|
||
|
|
// the re-claim START propagates after its 3 network-retry attempts.
|
||
|
|
if (runStartCount === 0) {
|
||
|
|
runStartCount += 1;
|
||
|
|
return jsonResponse({ runId: "run-1" });
|
||
|
|
}
|
||
|
|
throw new Error("fetch failed (cause: ECONNRESET)");
|
||
|
|
}
|
||
|
|
chatHits += 1;
|
||
|
|
if (chatHits === 1) return jsonResponse({ error: "session_superseded" }, { status: 409, ok: false });
|
||
|
|
return jsonResponse({ choices: [{ message: { content: "hi" } }] });
|
||
|
|
});
|
||
|
|
|
||
|
|
const ex = new FreebuffExecutor();
|
||
|
|
const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] };
|
||
|
|
// The re-claim failure rethrows the raw upstream error (the log line above
|
||
|
|
// it carries the "session re-claim failed" context).
|
||
|
|
await expect(
|
||
|
|
ex.execute({ model: MODEL, body, stream: false, credentials, log: null }),
|
||
|
|
).rejects.toThrow(/fetch failed \(cause: ECONNRESET\)/);
|
||
|
|
|
||
|
|
// run-1 was FINISH'd exactly once, as "cancelled" — the failed re-claim
|
||
|
|
// must NOT trigger a second (rejected by the server) FINISH.
|
||
|
|
const runCalls = fetchMock.mock.calls.filter(([u]) => u === RUN_URL);
|
||
|
|
const finishes = runCalls.filter(([, o]) => JSON.parse(o.body).action === "FINISH");
|
||
|
|
expect(finishes.length).toBe(1);
|
||
|
|
expect(JSON.parse(finishes[0][1].body).status).toBe("cancelled");
|
||
|
|
});
|
||
|
|
});
|