fix(FN-XXX): unify codex auth and chat fallback

This commit is contained in:
gsxdsm
2026-05-03 23:08:05 -07:00
parent 7fca3e6628
commit bacc103e4e
41 changed files with 2340 additions and 325 deletions

View File

@@ -8,6 +8,7 @@ import {
ChatManager,
__setBuildAgentChatPrompt,
__setCreateFnAgent,
__setCreateResolvedAgentSession,
__resetChatState,
chatStreamManager,
__getChatDiagnostics,
@@ -62,8 +63,23 @@ const mockAgentStore = {
listAgents: vi.fn(),
};
function createChatManager(): ChatManager {
return new ChatManager(mockChatStore as any, "/tmp/test", mockAgentStore as any);
function createChatManager(pluginRunner?: Record<string, unknown>): ChatManager {
return new ChatManager(mockChatStore as any, "/tmp/test", mockAgentStore as any, pluginRunner as any);
}
function createChatManagerWithSettings(settings: {
fallbackProvider?: string;
fallbackModelId?: string;
defaultProvider?: string;
defaultModelId?: string;
}): ChatManager {
return new ChatManager(
mockChatStore as any,
"/tmp/test",
mockAgentStore as any,
undefined,
async () => settings,
);
}
function createChatManagerWithoutAgentStore(): ChatManager {
@@ -99,6 +115,7 @@ describe("ChatManager.sendMessage", () => {
soul: "Be calm and precise.",
memory: "Remember to keep test coverage high.",
instructionsText: "Keep replies focused.",
runtimeConfig: {},
});
mockAgentStore.listAgents.mockResolvedValue([
{
@@ -530,6 +547,214 @@ describe("ChatManager.sendMessage", () => {
expect(assistantCalls).toHaveLength(0);
});
it("surfaces provider errors stored on session.state.errorMessage instead of persisting a blank assistant reply", async () => {
const events: Array<{ type: string; data: unknown }> = [];
const unsubscribe = chatStreamManager.subscribe("chat-001", (event) => {
events.push(event);
});
__setCreateFnAgent(async () => {
const session = {
prompt: vi.fn().mockImplementation(async function (this: any) {
this.state.errorMessage = "Codex error: provider request failed";
}),
dispose: vi.fn(),
state: { messages: [] as unknown[], errorMessage: undefined as string | undefined },
};
return { session };
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Hello");
unsubscribe();
const assistantCalls = mockChatStore.addMessage.mock.calls.filter((call) => call[1].role === "assistant");
expect(assistantCalls).toHaveLength(0);
expect(events).toContainEqual({ type: "error", data: "Codex error: provider request failed" });
});
it("uses the agent runtime path when the agent has a runtimeHint configured", async () => {
const createResolvedSession = vi.fn(async () => ({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [{ role: "assistant", content: "Runtime response" }],
},
},
}));
__setCreateResolvedAgentSession(createResolvedSession as any);
mockAgentStore.getAgent.mockResolvedValue({
id: "agent-001",
name: "Avery",
role: "executor",
soul: "Be calm and precise.",
memory: "Remember to keep test coverage high.",
instructionsText: "Keep replies focused.",
runtimeConfig: {
runtimeHint: "openclaw",
},
});
const pluginRunner = {
getRuntimeById: vi.fn(),
createRuntimeContext: vi.fn(),
};
const chatManager = createChatManager(pluginRunner);
await chatManager.sendMessage("chat-001", "Hello");
expect(createResolvedSession).toHaveBeenCalledWith(expect.objectContaining({
sessionPurpose: "executor",
runtimeHint: "openclaw",
pluginRunner,
}));
});
it("uses the assigned built-in pi agent model when the chat session has no explicit model override", async () => {
let createOptions: any;
__setCreateFnAgent(async (options: any) => {
createOptions = options;
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [{ role: "assistant", content: "Model response" }],
},
},
};
});
mockAgentStore.getAgent.mockResolvedValue({
id: "agent-001",
name: "Avery",
role: "executor",
soul: "Be calm and precise.",
memory: "Remember to keep test coverage high.",
instructionsText: "Keep replies focused.",
runtimeConfig: {
model: "minimax/MiniMax-M2.7-highspeed",
},
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Hello");
expect(createOptions.defaultProvider).toBe("minimax");
expect(createOptions.defaultModelId).toBe("MiniMax-M2.7-highspeed");
});
it("allows fallback for default-model chat and persists the fallback metadata", async () => {
const events: Array<{ type: string; data: unknown }> = [];
const unsubscribe = chatStreamManager.subscribe("chat-001", (event) => {
events.push(event);
});
mockChatStore.getSession.mockReturnValue({
id: "chat-001",
agentId: "agent-001",
status: "active",
title: "Default Codex Chat",
modelProvider: "openai-codex",
modelId: "gpt-5.3-codex",
});
let createOptions: any;
__setCreateFnAgent(async (options: any) => {
createOptions = options;
return {
session: {
prompt: vi.fn().mockImplementation(async function (this: any) {
await options.onFallbackModelUsed?.({
primaryModel: "openai-codex/gpt-5.3-codex",
fallbackModel: "zai/glm-5.1",
triggerPoint: "prompt-time",
});
this.state.messages = [{ role: "assistant", content: "Fallback reply" }];
}),
dispose: vi.fn(),
state: { messages: [] as Array<{ role: string; content: string }> },
},
};
});
const chatManager = createChatManagerWithSettings({
defaultProvider: "openai-codex",
defaultModelId: "gpt-5.3-codex",
fallbackProvider: "zai",
fallbackModelId: "glm-5.1",
});
await chatManager.sendMessage("chat-001", "Hello");
unsubscribe();
expect(createOptions.fallbackProvider).toBe("zai");
expect(createOptions.fallbackModelId).toBe("glm-5.1");
expect(mockChatStore.updateSession).toHaveBeenCalledWith("chat-001", {
modelProvider: "zai",
modelId: "glm-5.1",
});
expect(events).toContainEqual({
type: "fallback",
data: {
primaryModel: "openai-codex/gpt-5.3-codex",
fallbackModel: "zai/glm-5.1",
triggerPoint: "prompt-time",
},
});
const assistantCall = mockChatStore.addMessage.mock.calls.find((call) => call[1].role === "assistant");
expect(assistantCall?.[1]).toEqual(expect.objectContaining({
metadata: {
fallback: {
primaryModel: "openai-codex/gpt-5.3-codex",
fallbackModel: "zai/glm-5.1",
triggerPoint: "prompt-time",
},
},
}));
});
it("does not allow fallback when the chat session has a specific non-default model selected", async () => {
let createOptions: any;
mockChatStore.getSession.mockReturnValue({
id: "chat-001",
agentId: "agent-001",
status: "active",
title: "Explicit Model Chat",
modelProvider: "openai-codex",
modelId: "gpt-5.3-codex",
});
__setCreateFnAgent(async (options: any) => {
createOptions = options;
return {
session: {
prompt: vi.fn().mockImplementation(async function (this: any) {
this.state.messages = [{ role: "assistant", content: "Primary reply" }];
}),
dispose: vi.fn(),
state: { messages: [] as Array<{ role: string; content: string }> },
},
};
});
const chatManager = createChatManagerWithSettings({
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
fallbackProvider: "zai",
fallbackModelId: "glm-5.1",
});
await chatManager.sendMessage("chat-001", "Hello");
expect(createOptions.fallbackProvider).toBeUndefined();
expect(createOptions.fallbackModelId).toBeUndefined();
});
it("persists thinking output even when no text was generated", async () => {
__setCreateFnAgent(async (options: any) => {
return {

View File

@@ -520,6 +520,7 @@ function createMockAuthStorage(overrides: Partial<AuthStorageLike> = {}): AuthSt
{ id: "anthropic", name: "Anthropic" },
]),
hasAuth: vi.fn().mockReturnValue(false),
get: vi.fn().mockReturnValue(undefined),
login: vi.fn().mockImplementation((_provider: string, callbacks: any) => {
// Simulate onAuth callback with a URL, then resolve
callbacks.onAuth({ url: "https://auth.example.com/login", instructions: "Open in browser" });
@@ -605,6 +606,21 @@ describe("GET /auth/status", () => {
expect(res.body.providers[0].authenticated).toBe(false);
});
it("treats expired oauth credentials as unauthenticated", async () => {
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockReturnValue(true);
(authStorage.get as ReturnType<typeof vi.fn>).mockImplementation((provider: string) =>
provider === "anthropic"
? { type: "oauth", access: "token", refresh: "refresh", expires: Date.now() - 1_000 }
: undefined,
);
const res = await GET(buildApp(), "/api/auth/status");
expect(res.status).toBe(200);
const anthropic = res.body.providers.find((p: any) => p.id === "anthropic");
expect(anthropic.authenticated).toBe(false);
});
it("reports loginInProgress for oauth providers with active logins", async () => {
let releaseLogin: (() => void) | undefined;
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation(
@@ -1048,6 +1064,35 @@ describe("POST /auth/login", () => {
expect(res.status).toBe(200);
expect(res.body.url).toBe(unchangedUrl);
});
it("does not rewrite redirect_uri for openai-codex even on non-localhost origins", async () => {
const unchangedUrl =
"https://auth.openai.com/oauth/authorize?state=test-state&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback";
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "openai-codex", name: "OpenAI Codex" },
]);
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation((_provider: string, callbacks: any) => {
callbacks.onAuth({ url: unchangedUrl });
return Promise.resolve();
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/auth/login",
JSON.stringify({ provider: "openai-codex", origin: "https://my-host.example.com" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body.url).toBe(unchangedUrl);
expect(res.body.manualCode).toEqual({
prompt: "Paste the final redirect URL or authorization code",
placeholder: "http://localhost:1455/auth/callback?code=...&state=... or just the code",
helpText: "After sign-in, OpenAI may redirect to a localhost callback that cannot open from this dashboard host. Copy the full browser URL from the address bar and paste it here.",
});
});
it("returns 400 when provider is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({}), {
"Content-Type": "application/json",
@@ -1180,6 +1225,83 @@ describe("POST /auth/cancel", () => {
});
});
describe("POST /auth/manual-code", () => {
let store: TaskStore;
let authStorage: AuthStorageLike;
beforeEach(() => {
store = createMockStore();
authStorage = createMockAuthStorage({
getOAuthProviders: vi.fn().mockReturnValue([{ id: "openai-codex", name: "OpenAI Codex" }]),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { authStorage }));
return app;
}
it("submits pasted manual code into an active login", async () => {
let submittedCode: string | undefined;
let releaseLogin: (() => void) | undefined;
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation(
async (_provider: string, callbacks: {
onAuth: (info: { url: string; instructions?: string }) => void;
onManualCodeInput?: () => Promise<string>;
}) => {
callbacks.onAuth({
url: "https://auth.openai.com/oauth/authorize?state=test-state&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback",
});
submittedCode = await callbacks.onManualCodeInput?.();
releaseLogin?.();
},
);
const app = buildApp();
const loginRes = await REQUEST(
app,
"POST",
"/api/auth/login",
JSON.stringify({ provider: "openai-codex", origin: "https://remote.example.com" }),
{ "Content-Type": "application/json" },
);
expect(loginRes.status).toBe(200);
const submitRes = await REQUEST(
app,
"POST",
"/api/auth/manual-code",
JSON.stringify({
provider: "openai-codex",
code: "http://localhost:1455/auth/callback?code=test-code&state=test-state",
}),
{ "Content-Type": "application/json" },
);
expect(submitRes.status).toBe(200);
expect(submitRes.body).toEqual({ success: true, submitted: true });
await vi.waitFor(() => {
expect(submittedCode).toBe("http://localhost:1455/auth/callback?code=test-code&state=test-state");
});
});
it("returns 409 when no login is in progress", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/auth/manual-code",
JSON.stringify({ provider: "openai-codex", code: "test-code" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(409);
expect(res.body.error).toBe("No login in progress for openai-codex");
});
});
describe("GET /auth/oauth-callback", () => {
let store: TaskStore;
let authStorage: AuthStorageLike;
@@ -2989,4 +3111,3 @@ describe("Pause/Unpause endpoints", () => {
});
// --- GitHub Import route tests ---