fix(FN-XXX): unify codex auth and chat fallback
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user