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

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

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { buildDevNodeArgs } from "../../../../scripts/dev-with-memory-lib.mjs";
describe("buildDevNodeArgs", () => {
it("enables source-condition resolution before loading the tsx runtime", () => {
const args = buildDevNodeArgs({
inspectFlags: ["--inspect=9230"],
preload: "/tmp/preflight.cjs",
loader: "/tmp/loader.mjs",
entry: "/tmp/bin.ts",
args: ["dashboard", "--host", "0.0.0.0"],
});
expect(args).toEqual([
"--inspect=9230",
"--conditions=source",
"--require",
"/tmp/preflight.cjs",
"--import",
"file:///tmp/loader.mjs",
"/tmp/bin.ts",
"dashboard",
"--host",
"0.0.0.0",
]);
});
});

View File

@@ -14,6 +14,10 @@ export function getFusionAuthPath(home = process.env.HOME || process.env.USERPRO
return join(getFusionAgentDir(home), "auth.json");
}
export function getCodexCliAuthPath(home = process.env.HOME || process.env.USERPROFILE || homedir()): string {
return join(home, ".codex", "auth.json");
}
export function getLegacyAuthPaths(home = process.env.HOME || process.env.USERPROFILE || homedir()): string[] {
return [
join(home, ".pi", "agent", "auth.json"),

View File

@@ -56,7 +56,7 @@ import {
} from "./droid-cli-extension.js";
import { resolveSelfExtension } from "./self-extension.js";
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { resolveProject } from "../project-context.js";
import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js";
@@ -415,8 +415,11 @@ export async function runDaemon(opts: DaemonOptions = {}) {
const automationStore = cwdEngine.getAutomationStore();
const authStorage = AuthStorage.create(getFusionAuthPath());
const legacyAuthStorage = createReadOnlyAuthFileStorage(getLegacyAuthPaths());
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [legacyAuthStorage]);
const supplementalAuthStorage = createReadOnlyAuthFileStorage([
...getLegacyAuthPaths(),
getCodexCliAuthPath(),
]);
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]);
const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath());
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry);

View File

@@ -36,7 +36,7 @@ import {
import { promptForPort } from "./port-prompt.js";
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { resolveProject } from "../project-context.js";
import {
ensureClaudeSkillsForAllProjectsOnStartup,
@@ -1195,8 +1195,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// Passing these to createServer enables the dashboard's Authentication
// tab (login/logout) and Model selector.
const authStorage = AuthStorage.create(getFusionAuthPath());
const legacyAuthStorage = createReadOnlyAuthFileStorage(getLegacyAuthPaths());
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [legacyAuthStorage]);
const supplementalAuthStorage = createReadOnlyAuthFileStorage([
...getLegacyAuthPaths(),
getCodexCliAuthPath(),
]);
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]);
const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath());
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry);

View File

@@ -1,12 +1,20 @@
import { existsSync, readFileSync } from "node:fs";
import type {
AuthStorage,
ModelRegistry,
AuthCredential,
} from "@mariozechner/pi-coding-agent";
import {
choosePreferredStoredCredential,
readStoredCredentialsFromAuthFile,
shouldHydrateStoredCredential,
type StoredAuthCredential,
} from "@fusion/core";
import { getOAuthProvider } from "@mariozechner/pi-ai/oauth";
import type { OAuthCredentials } from "@mariozechner/pi-ai/oauth";
export type LoginCallbacks = Parameters<AuthStorage["login"]>[1];
export type LoginCallbacks = Parameters<AuthStorage["login"]>[1] & {
onManualCodeInput?: () => Promise<string>;
};
export interface DashboardAuthStorage {
reload(): void;
@@ -31,14 +39,7 @@ interface ReadFallbackAuthStorage {
list(): string[];
}
type StoredCredential = {
type?: string;
key?: string;
access?: string;
refresh?: string;
expires?: number;
[key: string]: unknown;
};
type StoredCredential = StoredAuthCredential;
/**
* Provider IDs that should be treated as OAuth-backed by the upstream
@@ -93,7 +94,10 @@ export function wrapAuthStorageWithApiKeyProviders(
.map((provider) => ({ id: provider.id, name: provider.name })),
hasAuth: (provider) => mergedAuthStorage.hasAuth(provider),
login: (providerId, callbacks) =>
mergedAuthStorage.login(providerId as Parameters<AuthStorage["login"]>[0], callbacks),
mergedAuthStorage.login(
providerId as Parameters<AuthStorage["login"]>[0],
callbacks as Parameters<AuthStorage["login"]>[1],
),
logout: (provider) => mergedAuthStorage.logout(provider),
getApiKeyProviders: () => {
// Use the reclassified (filtered) OAuth provider list so that providers
@@ -149,14 +153,35 @@ export function mergeAuthStorageReads(
readFallbackAuthStorages: ReadFallbackAuthStorage[] = [],
): AuthStorage {
const readAuthStorages = [authStorage, ...readFallbackAuthStorages];
const getCredential = (providerId: string) => {
for (const storage of readAuthStorages) {
const credential = storage.get(providerId);
if (credential) return credential;
const selectCredential = (
providerId: string,
storages: Array<Pick<ReadFallbackAuthStorage, "get">>,
): StoredCredential | undefined => {
let best: StoredCredential | undefined;
for (const storage of storages) {
best = choosePreferredStoredCredential(best, storage.get(providerId));
}
return undefined;
return best;
};
const getCredential = (providerId: string) => selectCredential(providerId, readAuthStorages);
const syncFallbackOauthCredentials = () => {
const providerIds = new Set(readFallbackAuthStorages.flatMap((storage) => storage.list()));
for (const providerId of providerIds) {
const current = authStorage.get(providerId) as StoredCredential | undefined;
const candidate = selectCredential(providerId, readFallbackAuthStorages);
if (!shouldHydrateStoredCredential(current, candidate)) {
continue;
}
if (candidate && (candidate.type === "oauth" || candidate.type === "api_key")) {
authStorage.set(providerId, candidate as AuthCredential);
}
}
};
syncFallbackOauthCredentials();
return new Proxy(authStorage, {
get(target, prop, receiver) {
if (prop === "reload") {
@@ -164,6 +189,7 @@ export function mergeAuthStorageReads(
for (const storage of readAuthStorages) {
storage.reload();
}
syncFallbackOauthCredentials();
};
}
@@ -180,13 +206,17 @@ export function mergeAuthStorageReads(
}
if (prop === "getAll") {
return () => ({
...readFallbackAuthStorages.reduce(
(merged, storage) => ({ ...merged, ...storage.getAll() }),
{} as Record<string, { type?: string; key?: string }>,
),
...target.getAll(),
});
return () => {
const providerIds = new Set(readAuthStorages.flatMap((storage) => storage.list()));
const merged: Record<string, StoredCredential> = {};
for (const providerId of providerIds) {
const credential = getCredential(providerId);
if (credential) {
merged[providerId] = credential;
}
}
return merged;
};
}
if (prop === "list") {
@@ -243,16 +273,9 @@ export function createReadOnlyAuthFileStorage(authPaths: string[]): ReadFallback
const reload = () => {
const nextCredentials: Record<string, StoredCredential> = {};
for (const authPath of authPaths) {
if (!existsSync(authPath)) {
continue;
}
try {
const parsed = JSON.parse(readFileSync(authPath, "utf-8")) as Record<string, StoredCredential>;
for (const [provider, credential] of Object.entries(parsed)) {
nextCredentials[provider] ??= credential;
}
} catch {
// Ignore unreadable legacy auth files and continue with other candidates.
const parsed = readStoredCredentialsFromAuthFile(authPath);
for (const [provider, credential] of Object.entries(parsed)) {
nextCredentials[provider] = choosePreferredStoredCredential(nextCredentials[provider], credential) ?? credential;
}
}
credentials = nextCredentials;

View File

@@ -41,7 +41,7 @@ import {
import { promptForPort } from "./port-prompt.js";
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { resolveProject } from "../project-context.js";
import {
ensureClaudeSkillsForAllProjectsOnStartup,
@@ -478,8 +478,11 @@ export async function runServe(
const automationStore = cwdEngine.getAutomationStore();
const authStorage = AuthStorage.create(getFusionAuthPath());
const legacyAuthStorage = createReadOnlyAuthFileStorage(getLegacyAuthPaths());
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [legacyAuthStorage]);
const supplementalAuthStorage = createReadOnlyAuthFileStorage([
...getLegacyAuthPaths(),
getCodexCliAuthPath(),
]);
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]);
const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath());
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry);

View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
choosePreferredStoredCredential,
extractCodexCliStoredCredential,
readStoredCredentialsFromAuthFile,
shouldHydrateStoredCredential,
} from "../oauth-credential-interop.js";
function encodeBase64Url(value: string): string {
return Buffer.from(value, "utf-8").toString("base64url");
}
function createJwt(payload: Record<string, unknown>): string {
return [
encodeBase64Url(JSON.stringify({ alg: "none", typ: "JWT" })),
encodeBase64Url(JSON.stringify(payload)),
"signature",
].join(".");
}
describe("oauth credential interop", () => {
it("extracts Codex CLI OAuth credentials from auth.json token payload", () => {
const expiresAtSeconds = Math.floor(Date.now() / 1000) + 3600;
const accessToken = createJwt({
exp: expiresAtSeconds,
"https://api.openai.com/auth": {
chatgpt_account_id: "acct_123",
},
});
const credential = extractCodexCliStoredCredential({
tokens: {
access_token: accessToken,
refresh_token: "refresh-token",
},
});
expect(credential).toEqual({
type: "oauth",
access: accessToken,
refresh: "refresh-token",
expires: expiresAtSeconds * 1000,
accountId: "acct_123",
});
});
it("falls back to last_refresh when Codex CLI JWT has no exp claim", () => {
const accessToken = createJwt({
sub: "user-123",
});
const lastRefresh = "2026-05-03T10:00:00.000Z";
const credential = extractCodexCliStoredCredential({
last_refresh: lastRefresh,
tokens: {
access_token: accessToken,
refresh_token: "refresh-token",
account_id: "acct_from_token",
},
});
expect(credential?.type).toBe("oauth");
expect(credential?.accountId).toBe("acct_from_token");
expect(credential?.expires).toBe(Date.parse(lastRefresh) + 55 * 60 * 1000);
});
it("prefers a valid OAuth credential over an expired one and hydrates only when better", () => {
const expired = {
type: "oauth",
access: "expired-access",
refresh: "expired-refresh",
expires: Date.now() - 60_000,
} as const;
const valid = {
type: "oauth",
access: "valid-access",
refresh: "valid-refresh",
expires: Date.now() + 60_000,
} as const;
expect(choosePreferredStoredCredential(expired, valid)).toEqual(valid);
expect(shouldHydrateStoredCredential(expired, valid)).toBe(true);
expect(shouldHydrateStoredCredential({ type: "api_key", key: "sk-live" }, valid)).toBe(false);
});
it("gracefully ignores malformed auth files", () => {
const tempDir = mkdtempSync(join(tmpdir(), "fusion-oauth-interop-"));
try {
const malformedPath = join(tempDir, "auth.json");
writeFileSync(malformedPath, "{ not-json");
expect(readStoredCredentialsFromAuthFile(malformedPath)).toEqual({});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});

View File

@@ -763,6 +763,14 @@ export type {
} from "./chat-types.js";
export { ChatStore } from "./chat-store.js";
export type { ChatStoreEvents } from "./chat-store.js";
export {
choosePreferredStoredCredential,
extractCodexCliStoredCredential,
getCodexCliAuthPath,
readStoredCredentialsFromAuthFile,
shouldHydrateStoredCredential,
} from "./oauth-credential-interop.js";
export type { StoredAuthCredential } from "./oauth-credential-interop.js";
// ── Error helpers ─────────────────────────────────────────
export { getErrorMessage } from "./error-message.js";

View File

@@ -0,0 +1,243 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
export type StoredAuthCredential = {
type?: string;
key?: string;
access?: string;
refresh?: string;
expires?: number;
accountId?: string;
[key: string]: unknown;
};
const OPENAI_AUTH_CLAIM = "https://api.openai.com/auth";
const CODEX_REFRESH_FALLBACK_WINDOW_MS = 55 * 60 * 1000;
function getHomeDir(): string {
return process.env.HOME || process.env.USERPROFILE || homedir();
}
export function getCodexCliAuthPath(home = getHomeDir()): string {
return join(home, ".codex", "auth.json");
}
function parseJwtPayload(token: string): Record<string, unknown> | null {
try {
const [, payload = ""] = token.split(".", 3);
if (!payload) {
return null;
}
return JSON.parse(Buffer.from(payload, "base64url").toString("utf-8")) as Record<string, unknown>;
} catch {
return null;
}
}
function getJwtExpiryMs(token: string | undefined): number | undefined {
if (!token) {
return undefined;
}
const payload = parseJwtPayload(token);
const exp = payload?.exp;
if (typeof exp !== "number" || !Number.isFinite(exp)) {
return undefined;
}
return exp * 1000;
}
function getCodexAccountId(accessToken: string, fallbackAccountId: unknown): string | undefined {
const payload = parseJwtPayload(accessToken);
const authClaim = payload?.[OPENAI_AUTH_CLAIM];
const claimAccountId =
authClaim && typeof authClaim === "object"
? (authClaim as Record<string, unknown>).chatgpt_account_id
: undefined;
if (typeof claimAccountId === "string" && claimAccountId.trim().length > 0) {
return claimAccountId;
}
if (typeof fallbackAccountId === "string" && fallbackAccountId.trim().length > 0) {
return fallbackAccountId;
}
return undefined;
}
function getLastRefreshFallbackExpiryMs(lastRefresh: unknown): number | undefined {
if (typeof lastRefresh !== "string" || lastRefresh.trim().length === 0) {
return undefined;
}
const parsed = Date.parse(lastRefresh);
if (!Number.isFinite(parsed)) {
return undefined;
}
return parsed + CODEX_REFRESH_FALLBACK_WINDOW_MS;
}
function isStoredAuthCredential(value: unknown): value is StoredAuthCredential {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const record = value as Record<string, unknown>;
return record.type === "oauth" || record.type === "api_key";
}
function isValidOauthCredential(credential: StoredAuthCredential | undefined): boolean {
return credential?.type === "oauth"
&& typeof credential.access === "string"
&& credential.access.length > 0
&& typeof credential.refresh === "string"
&& credential.refresh.length > 0
&& typeof credential.expires === "number"
&& Number.isFinite(credential.expires)
&& Date.now() < credential.expires;
}
function isRefreshableOauthCredential(credential: StoredAuthCredential | undefined): boolean {
return credential?.type === "oauth"
&& typeof credential.refresh === "string"
&& credential.refresh.length > 0
&& typeof credential.expires === "number"
&& Number.isFinite(credential.expires);
}
function compareStoredCredentials(
left: StoredAuthCredential | undefined,
right: StoredAuthCredential | undefined,
): number {
if (!left && !right) {
return 0;
}
if (left && !right) {
return 1;
}
if (!left && right) {
return -1;
}
if (left?.type === "api_key" && right?.type !== "api_key") {
return 1;
}
if (right?.type === "api_key" && left?.type !== "api_key") {
return -1;
}
if (left?.type === "oauth" && right?.type === "oauth") {
const leftValid = isValidOauthCredential(left);
const rightValid = isValidOauthCredential(right);
if (leftValid !== rightValid) {
return leftValid ? 1 : -1;
}
const leftRefreshable = isRefreshableOauthCredential(left);
const rightRefreshable = isRefreshableOauthCredential(right);
if (leftRefreshable !== rightRefreshable) {
return leftRefreshable ? 1 : -1;
}
const leftExpiry = typeof left.expires === "number" && Number.isFinite(left.expires) ? left.expires : -Infinity;
const rightExpiry = typeof right.expires === "number" && Number.isFinite(right.expires) ? right.expires : -Infinity;
if (leftExpiry !== rightExpiry) {
return leftExpiry > rightExpiry ? 1 : -1;
}
const leftAccessLength = typeof left.access === "string" ? left.access.length : 0;
const rightAccessLength = typeof right.access === "string" ? right.access.length : 0;
if (leftAccessLength !== rightAccessLength) {
return leftAccessLength > rightAccessLength ? 1 : -1;
}
}
return 0;
}
export function choosePreferredStoredCredential(
...credentials: Array<StoredAuthCredential | undefined>
): StoredAuthCredential | undefined {
let best: StoredAuthCredential | undefined;
for (const credential of credentials) {
if (compareStoredCredentials(credential, best) > 0) {
best = credential;
}
}
return best;
}
export function shouldHydrateStoredCredential(
current: StoredAuthCredential | undefined,
candidate: StoredAuthCredential | undefined,
): boolean {
if (!candidate || candidate.type !== "oauth") {
return false;
}
if (current?.type === "api_key") {
return false;
}
return compareStoredCredentials(candidate, current) > 0;
}
export function extractCodexCliStoredCredential(raw: unknown): StoredAuthCredential | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return undefined;
}
const record = raw as Record<string, unknown>;
const tokens = record.tokens;
if (!tokens || typeof tokens !== "object" || Array.isArray(tokens)) {
return undefined;
}
const tokenRecord = tokens as Record<string, unknown>;
const access = typeof tokenRecord.access_token === "string" ? tokenRecord.access_token : undefined;
const refresh = typeof tokenRecord.refresh_token === "string" ? tokenRecord.refresh_token : undefined;
if (!access || !refresh) {
return undefined;
}
const expires =
getJwtExpiryMs(access)
?? getJwtExpiryMs(typeof tokenRecord.id_token === "string" ? tokenRecord.id_token : undefined)
?? getLastRefreshFallbackExpiryMs(record.last_refresh);
if (typeof expires !== "number" || !Number.isFinite(expires)) {
return undefined;
}
const accountId = getCodexAccountId(access, tokenRecord.account_id);
return {
type: "oauth",
access,
refresh,
expires,
...(accountId ? { accountId } : {}),
};
}
export function readStoredCredentialsFromAuthFile(authPath: string): Record<string, StoredAuthCredential> {
if (!existsSync(authPath)) {
return {};
}
try {
const parsed = JSON.parse(readFileSync(authPath, "utf-8")) as unknown;
const codexCliCredential = extractCodexCliStoredCredential(parsed);
if (codexCliCredential) {
return { "openai-codex": codexCliCredential };
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {};
}
const credentials: Record<string, StoredAuthCredential> = {};
for (const [providerId, value] of Object.entries(parsed as Record<string, unknown>)) {
if (!isStoredAuthCredential(value)) {
continue;
}
credentials[providerId] = value;
}
return credentials;
} catch {
return {};
}
}

View File

@@ -506,7 +506,14 @@ Note: Refs (@e1, @e2) are invalidated after page navigation. Re-snapshot after c
toolMode: "readonly",
prompt: `You are a UX design reviewer. Verify frontend changes maintain visual polish and consistency with existing UI patterns and design tokens.
Design System Review:
FAST-BAIL RULE (check this FIRST):
- The task harness gives you a "Diff Scope" listing the files this task actually changed.
- If that list contains NO frontend/UI files (no .tsx/.jsx/.ts/.js component files, no .css/.scss/.sass/.styl, no .html/.vue/.svelte/.astro, no design-token/theme files), respond IMMEDIATELY with a single short line such as "No UI changes in scope — approved." and STOP.
- Do NOT explore the worktree looking for related-looking UI code to critique. If this task didn't change a UI file, your review is a no-op by definition.
Otherwise, restrict your review to the UI files actually present in the diff scope.
Design System Review (only for UI files in the diff scope):
1. **Visual Hierarchy** — Check that the changes maintain consistent heading levels, content flow, and information architecture
2. **Spacing and Typography** — Verify consistent spacing (margins, padding, gaps) and typography scale usage
3. **Color and Token Consistency** — Check that CSS custom properties and design tokens are used correctly; no hardcoded color values that bypass the design system
@@ -514,15 +521,16 @@ Design System Review:
5. **Responsive Behavior** — Check that layouts adapt properly across viewport sizes and maintain usability on mobile
6. **Fit with Design Language** — Verify the visual style matches existing patterns (border radius, shadows, transitions, icon style, etc.)
Files to Review:
Files to Review (only those that appear in the Diff Scope):
- Modified UI components (React, Vue, Angular, HTML)
- CSS/SCSS/styled-component files
- Design token or theme configuration files
Output Requirements:
- If design is consistent and polished: call task_done() with success status
- If issues found: describe each finding with specific file paths and suggested corrections via task_log()
- Prioritize issues by impact: layout breaks > visual inconsistency > style preferences`,
- If design is consistent and polished (or there are no UI files in scope): respond with a brief approval line and stop.
- If issues found: start your response with "REQUEST REVISION" and describe each finding with specific file paths and suggested corrections.
- Prioritize issues by impact: layout breaks > visual inconsistency > style preferences.
- Do NOT spend time on stylistic nits when no real issues exist.`,
},
];

View File

@@ -1271,6 +1271,12 @@ export interface AuthProvider {
keyHint?: string;
}
export interface ManualOAuthCodeInfo {
prompt: string;
placeholder?: string;
helpText?: string;
}
/**
* Snapshot of the Claude-CLI-via-pi health state. Powers the
* "Anthropic — via Claude CLI" provider card.
@@ -1734,13 +1740,29 @@ export function fetchAuthStatus(): Promise<{
}
/** Initiate OAuth login for a provider. Returns the auth URL to open in a new tab. */
export function loginProvider(provider: string): Promise<{ url: string; instructions?: string }> {
return api<{ url: string; instructions?: string }>("/auth/login", {
export function loginProvider(provider: string): Promise<{
url: string;
instructions?: string;
manualCode?: ManualOAuthCodeInfo;
}> {
return api<{
url: string;
instructions?: string;
manualCode?: ManualOAuthCodeInfo;
}>("/auth/login", {
method: "POST",
body: JSON.stringify({ provider, origin: window.location.origin }),
});
}
/** Submit a pasted OAuth callback URL or authorization code for an active login. */
export function submitProviderManualCode(provider: string, code: string): Promise<{ success: boolean; submitted: boolean }> {
return api<{ success: boolean; submitted: boolean }>("/auth/manual-code", {
method: "POST",
body: JSON.stringify({ provider, code }),
});
}
/** Logout from a provider, removing stored credentials. */
export function logoutProvider(provider: string): Promise<{ success: boolean }> {
return api<{ success: boolean }>("/auth/logout", {
@@ -7808,7 +7830,7 @@ export function cancelChatResponse(
/** Send a chat message and receive the AI response via SSE streaming.
*
* The backend exposes `POST /api/chat/sessions/:id/messages` which returns an SSE
* stream (not JSON). Events: `thinking`, `text`, `done`, `error`.
* stream (not JSON). Events: `thinking`, `text`, `fallback`, `done`, `error`.
*
* Since `EventSource` only supports GET requests, this function uses `fetch()`
* with a ReadableStream to parse SSE events from the POST response body.
@@ -7823,6 +7845,7 @@ export function streamChatResponse(
onText?: (data: string) => void;
onToolStart?: (data: { toolName: string; args?: Record<string, unknown> }) => void;
onToolEnd?: (data: { toolName: string; isError: boolean; result?: unknown }) => void;
onFallback?: (data: { primaryModel: string; fallbackModel: string; triggerPoint: "session-creation" | "prompt-time" }) => void;
onDone?: (data: { messageId: string }) => void;
onError?: (data: string) => void;
onConnectionStateChange?: (state: StreamConnectionState) => void;
@@ -7872,6 +7895,13 @@ export function streamChatResponse(
// skip malformed event
}
break;
case "fallback":
try {
handlers.onFallback?.(JSON.parse(rawData));
} catch {
// skip malformed event
}
break;
case "done":
try {
handlers.onDone?.(JSON.parse(rawData));

View File

@@ -35,7 +35,7 @@ import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
export interface ChatViewProps {
projectId?: string;
addToast: (msg: string, type?: "success" | "error") => void;
addToast: (msg: string, type?: "success" | "error" | "warning") => void;
}
function formatRelativeTime(dateStr: string): string {
@@ -712,7 +712,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
searchQuery,
setSearchQuery,
filteredSessions,
} = useChat(projectId);
} = useChat(projectId, addToast);
const [showNewDialog, setShowNewDialog] = useState(false);
const [messageInput, setMessageInput] = useState("");

View File

@@ -2,13 +2,14 @@ import "./ModelOnboardingModal.css";
import { useState, useEffect, useCallback, useRef } from "react";
import { X, Loader2, CheckCircle, Key, Zap, GitPullRequest, Rocket, Plus } from "lucide-react";
import { getErrorMessage, type Task } from "@fusion/core";
import type { AuthProvider, ModelInfo, CustomProvider, CustomProviderConfig } from "../api";
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, CustomProvider, CustomProviderConfig } from "../api";
import {
fetchAuthStatus,
fetchGlobalSettings,
loginProvider,
logoutProvider,
cancelProviderLogin,
submitProviderManualCode,
saveApiKey,
clearApiKey,
fetchModels,
@@ -24,6 +25,7 @@ import { ProviderIcon } from "./ProviderIcon";
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
import { DroidCliProviderCard } from "./DroidCliProviderCard";
import { LoginInstructions } from "./LoginInstructions";
import { OAuthManualCodeForm } from "./OAuthManualCodeForm";
import { OnboardingDisclosure } from "./OnboardingDisclosure";
import { CustomProviderForm } from "./CustomProviderForm";
import { PluginSlot } from "./PluginSlot";
@@ -575,6 +577,9 @@ export function ModelOnboardingModal({
const [authLoading, setAuthLoading] = useState(true);
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
const [loginInstructions, setLoginInstructions] = useState<Record<string, string>>({});
const [manualCodeConfigs, setManualCodeConfigs] = useState<Record<string, ManualOAuthCodeInfo>>({});
const [manualCodeInputs, setManualCodeInputs] = useState<Record<string, string>>({});
const [manualCodeSubmitInProgress, setManualCodeSubmitInProgress] = useState<string | null>(null);
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
const [saving, setSaving] = useState(false);
@@ -1048,14 +1053,34 @@ export function ModelOnboardingModal({
return prev;
});
setLoginInstructions((prev) => {
if (!(providerId in prev)) {
return prev;
}
const next = { ...prev };
delete next[providerId];
return next;
});
const clearAuthLoginUiState = () => {
setLoginInstructions((prev) => {
if (!(providerId in prev)) {
return prev;
}
const next = { ...prev };
delete next[providerId];
return next;
});
setManualCodeConfigs((prev) => {
if (!(providerId in prev)) {
return prev;
}
const next = { ...prev };
delete next[providerId];
return next;
});
setManualCodeInputs((prev) => {
if (!(providerId in prev)) {
return prev;
}
const next = { ...prev };
delete next[providerId];
return next;
});
};
clearAuthLoginUiState();
// Set outcome to pending
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "pending" }));
@@ -1063,10 +1088,13 @@ export function ModelOnboardingModal({
pollCountRef.current = 0;
try {
const { url, instructions } = await loginProvider(providerId);
const { url, instructions, manualCode } = await loginProvider(providerId);
if (instructions?.trim()) {
setLoginInstructions((prev) => ({ ...prev, [providerId]: instructions }));
}
if (manualCode) {
setManualCodeConfigs((prev) => ({ ...prev, [providerId]: manualCode }));
}
window.open(appendTokenQuery(url), "_blank");
// Poll for auth completion
@@ -1081,14 +1109,7 @@ export function ModelOnboardingModal({
}
setAuthActionInProgress(null);
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "timeout" }));
setLoginInstructions((prev) => {
if (!(providerId in prev)) {
return prev;
}
const next = { ...prev };
delete next[providerId];
return next;
});
clearAuthLoginUiState();
addToast("Login timed out. Please try again.", "warning");
return;
}
@@ -1106,18 +1127,23 @@ export function ModelOnboardingModal({
}
setAuthActionInProgress(null);
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "success" }));
setLoginInstructions((prev) => {
if (!(providerId in prev)) {
return prev;
}
const next = { ...prev };
delete next[providerId];
return next;
});
clearAuthLoginUiState();
if (providerId === "github") {
setGitHubSkippedState(false);
}
addToast("Login successful", "success");
return;
}
if (!provider?.loginInProgress) {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setAuthActionInProgress(null);
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "failed" }));
clearAuthLoginUiState();
addToast("Login did not complete. Please try again.", "error");
}
} catch {
// Continue polling
@@ -1138,7 +1164,24 @@ export function ModelOnboardingModal({
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "failed" }));
}
setAuthActionInProgress(null);
setLoginInstructions((prev) => {
clearAuthLoginUiState();
}
},
[addToast, loadAuthStatus, setGitHubSkippedState],
);
const handleSubmitManualCode = useCallback(async (providerId: string) => {
const code = manualCodeInputs[providerId]?.trim();
if (!code) {
addToast("Paste the full redirect URL or authorization code first.", "warning");
return;
}
setManualCodeSubmitInProgress(providerId);
try {
const result = await submitProviderManualCode(providerId, code);
if (result.submitted) {
setManualCodeInputs((prev) => {
if (!(providerId in prev)) {
return prev;
}
@@ -1146,10 +1189,16 @@ export function ModelOnboardingModal({
delete next[providerId];
return next;
});
addToast("Authorization code received. Finishing login…", "success");
} else {
addToast("That authorization code was already submitted. Waiting for login…", "warning");
}
},
[addToast, loadAuthStatus, setGitHubSkippedState],
);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to submit authorization code", "error");
} finally {
setManualCodeSubmitInProgress(null);
}
}, [addToast, manualCodeInputs]);
// Cancellation handler for in-progress logins
const handleCancelLogin = useCallback(async (providerId: string) => {
@@ -1159,11 +1208,14 @@ export function ModelOnboardingModal({
}
setAuthActionInProgress(providerId);
pollCountRef.current = 0;
setAuthProviders((prev) => prev.map((provider) =>
provider.id === providerId ? { ...provider, loginInProgress: false } : provider,
));
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "cancelled" }));
try {
await cancelProviderLogin(providerId);
await loadAuthStatus();
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "cancelled" }));
await loadAuthStatus().catch(() => {});
addToast("Login cancelled", "success");
} catch (err) {
addToast(getErrorMessage(err) || "Failed to cancel login", "error");
@@ -1177,6 +1229,23 @@ export function ModelOnboardingModal({
delete next[providerId];
return next;
});
setManualCodeConfigs((prev) => {
if (!(providerId in prev)) {
return prev;
}
const next = { ...prev };
delete next[providerId];
return next;
});
setManualCodeInputs((prev) => {
if (!(providerId in prev)) {
return prev;
}
const next = { ...prev };
delete next[providerId];
return next;
});
setManualCodeSubmitInProgress((prev) => prev === providerId ? null : prev);
}
}, [addToast, loadAuthStatus]);
@@ -1825,6 +1894,19 @@ export function ModelOnboardingModal({
data-testid={`onboarding-login-instructions-${provider.id}`}
/>
)}
{(authActionInProgress === provider.id || provider.loginInProgress) && manualCodeConfigs[provider.id] && (
<OAuthManualCodeForm
value={manualCodeInputs[provider.id] ?? ""}
onChange={(value) => setManualCodeInputs((prev) => ({ ...prev, [provider.id]: value }))}
onSubmit={() => void handleSubmitManualCode(provider.id)}
prompt={manualCodeConfigs[provider.id].prompt}
placeholder={manualCodeConfigs[provider.id].placeholder}
helpText={manualCodeConfigs[provider.id].helpText}
disabled={manualCodeSubmitInProgress === provider.id}
submitLabel={manualCodeSubmitInProgress === provider.id ? "Submitting…" : "Submit code"}
data-testid={`onboarding-manual-code-${provider.id}`}
/>
)}
{loginOutcomes[provider.id] === "timeout" && authActionInProgress !== provider.id && (
<p className="onboarding-helper-text onboarding-inline-feedback">
Login timed out. Please try again.
@@ -2658,4 +2740,3 @@ export function ModelOnboardingModal({
</div>
);
}

View File

@@ -0,0 +1,39 @@
.oauth-manual-code {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
.oauth-manual-code__prompt,
.oauth-manual-code__help {
margin: 0;
font-size: 0.8rem;
line-height: 1.4;
}
.oauth-manual-code__prompt {
color: var(--text-muted);
}
.oauth-manual-code__help {
color: var(--text-dim);
}
.oauth-manual-code__input {
min-height: 84px;
resize: vertical;
font-family: var(--font-mono);
}
.oauth-manual-code__input:focus-visible {
outline: 2px solid var(--color-info);
outline-offset: 2px;
box-shadow: var(--focus-ring-strong);
}
.oauth-manual-code__actions {
display: flex;
align-items: center;
gap: var(--space-sm);
}

View File

@@ -0,0 +1,51 @@
import "./OAuthManualCodeForm.css";
interface OAuthManualCodeFormProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
prompt: string;
placeholder?: string;
helpText?: string;
disabled?: boolean;
submitLabel?: string;
"data-testid"?: string;
}
export function OAuthManualCodeForm({
value,
onChange,
onSubmit,
prompt,
placeholder,
helpText,
disabled = false,
submitLabel = "Submit code",
"data-testid": testId,
}: OAuthManualCodeFormProps) {
return (
<div className="oauth-manual-code" data-testid={testId}>
<p className="oauth-manual-code__prompt">{prompt}</p>
<textarea
className="form-input oauth-manual-code__input"
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
rows={3}
spellCheck={false}
disabled={disabled}
/>
<div className="oauth-manual-code__actions">
<button
type="button"
className="btn btn-sm"
onClick={onSubmit}
disabled={disabled}
>
{submitLabel}
</button>
</div>
{helpText && <p className="oauth-manual-code__help">{helpText}</p>}
</div>
);
}

View File

@@ -34,7 +34,7 @@ interface PendingAttachment {
interface QuickChatFABProps {
projectId?: string;
addToast: (msg: string, type?: "success" | "error") => void;
addToast: (msg: string, type?: "success" | "error" | "warning") => void;
/** When false, the FAB button is hidden but the panel can still be opened programmatically via the open prop */
showFAB?: boolean;
/** When true, the chat panel is open */
@@ -1011,11 +1011,21 @@ export function QuickChatFAB({
}, [isOpen]);
const resolvedModelSelection = selectedModel || configuredDefaultModelSelection;
const targetModelSelection = useMemo(
() => parseModelSelection(resolvedModelSelection),
[resolvedModelSelection],
);
const displayedModelSelection = useMemo(() => {
if (chatMode === "model" && activeSession?.modelProvider && activeSession?.modelId) {
return `${activeSession.modelProvider}/${activeSession.modelId}`;
}
return resolvedModelSelection;
}, [activeSession?.modelId, activeSession?.modelProvider, chatMode, resolvedModelSelection]);
const parsedModelSelection = useMemo(() => parseModelSelection(resolvedModelSelection), [resolvedModelSelection]);
const parsedModelSelection = useMemo(() => parseModelSelection(displayedModelSelection), [displayedModelSelection]);
const selectedModelInfo = useMemo(
() => models.find((model) => `${model.provider}/${model.id}` === resolvedModelSelection) ?? null,
[models, resolvedModelSelection],
() => models.find((model) => `${model.provider}/${model.id}` === displayedModelSelection) ?? null,
[displayedModelSelection, models],
);
const selectedModelTag = useMemo(
() => formatModelTagName(selectedModelInfo, parsedModelSelection),
@@ -1024,8 +1034,8 @@ export function QuickChatFAB({
const sessionTargetKey = useMemo(() => {
if (chatMode === "model") {
if (parsedModelSelection) {
return `${FN_AGENT_ID}::${parsedModelSelection.modelProvider}/${parsedModelSelection.modelId}`;
if (targetModelSelection) {
return `${FN_AGENT_ID}::${targetModelSelection.modelProvider}/${targetModelSelection.modelId}`;
}
return "";
}
@@ -1034,9 +1044,9 @@ export function QuickChatFAB({
return `${selectedAgentId}::`;
}
return "";
}, [chatMode, parsedModelSelection, selectedAgentId]);
}, [chatMode, selectedAgentId, targetModelSelection]);
const hasChatTarget = chatMode === "agent" ? Boolean(selectedAgentId) : Boolean(parsedModelSelection);
const hasChatTarget = chatMode === "agent" ? Boolean(selectedAgentId) : Boolean(targetModelSelection);
const inputDisabled = !hasChatTarget || !activeSession;
useEffect(() => {
@@ -1154,8 +1164,8 @@ export function QuickChatFAB({
prevSessionTargetRef.current = sessionTargetKey;
if (chatMode === "model" && parsedModelSelection) {
void startModelChat(parsedModelSelection.modelProvider, parsedModelSelection.modelId);
if (chatMode === "model" && targetModelSelection) {
void startModelChat(targetModelSelection.modelProvider, targetModelSelection.modelId);
return;
}
@@ -1165,7 +1175,7 @@ export function QuickChatFAB({
}, [
isOpen,
chatMode,
parsedModelSelection,
targetModelSelection,
selectedAgentId,
sessionTargetKey,
activeSession,

View File

@@ -10,8 +10,8 @@ import {
resolveTitleSummarizerSettingsModel,
} from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, installCloudflared, startRemoteTunnel, stopRemoteTunnel, killExternalTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse } from "../api";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, installCloudflared, startRemoteTunnel, stopRemoteTunnel, killExternalTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api";
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse } from "../api";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import type { ToastType } from "../hooks/useToast";
@@ -34,6 +34,7 @@ import { PaperclipRuntimeCard } from "./PaperclipRuntimeCard";
import { PluginSlot } from "./PluginSlot";
import { AgentPromptsManager } from "./AgentPromptsManager";
import { LoginInstructions } from "./LoginInstructions";
import { OAuthManualCodeForm } from "./OAuthManualCodeForm";
import { ProviderIcon } from "./ProviderIcon";
import { CustomProvidersSection } from "./CustomProvidersSection";
import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets";
@@ -463,6 +464,9 @@ export function SettingsModal({
const [authLoading, setAuthLoading] = useState(false);
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
const [loginInstructions, setLoginInstructions] = useState<Record<string, string>>({});
const [manualCodeConfigs, setManualCodeConfigs] = useState<Record<string, ManualOAuthCodeInfo>>({});
const [manualCodeInputs, setManualCodeInputs] = useState<Record<string, string>>({});
const [manualCodeSubmitInProgress, setManualCodeSubmitInProgress] = useState<string | null>(null);
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -911,8 +915,7 @@ export function SettingsModal({
settingsContentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, []);
const handleLogin = useCallback(async (providerId: string) => {
setAuthActionInProgress(providerId);
const clearAuthLoginUiState = useCallback((providerId: string) => {
setLoginInstructions((prev) => {
if (!(providerId in prev)) {
return prev;
@@ -921,12 +924,36 @@ export function SettingsModal({
delete next[providerId];
return next;
});
setManualCodeConfigs((prev) => {
if (!(providerId in prev)) {
return prev;
}
const next = { ...prev };
delete next[providerId];
return next;
});
setManualCodeInputs((prev) => {
if (!(providerId in prev)) {
return prev;
}
const next = { ...prev };
delete next[providerId];
return next;
});
}, []);
const handleLogin = useCallback(async (providerId: string) => {
setAuthActionInProgress(providerId);
clearAuthLoginUiState(providerId);
try {
const { url, instructions } = await loginProvider(providerId);
const { url, instructions, manualCode } = await loginProvider(providerId);
if (instructions?.trim()) {
setLoginInstructions((prev) => ({ ...prev, [providerId]: instructions }));
}
if (manualCode) {
setManualCodeConfigs((prev) => ({ ...prev, [providerId]: manualCode }));
}
window.open(appendTokenQuery(url), "_blank");
// Poll for auth completion every 2 seconds
@@ -942,16 +969,20 @@ export function SettingsModal({
pollIntervalRef.current = null;
}
setAuthActionInProgress(null);
setLoginInstructions((prev) => {
if (!(providerId in prev)) {
return prev;
}
const next = { ...prev };
delete next[providerId];
return next;
});
clearAuthLoginUiState(providerId);
addToast("Login successful", "success");
scrollSettingsToTop();
return;
}
if (!provider?.loginInProgress) {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setAuthActionInProgress(null);
clearAuthLoginUiState(providerId);
addToast("Login did not complete. Please try again.", "error");
}
} catch {
// Continue polling on transient errors
@@ -967,41 +998,61 @@ export function SettingsModal({
addToast(message, "error");
}
setAuthActionInProgress(null);
setLoginInstructions((prev) => {
if (!(providerId in prev)) {
return prev;
}
const next = { ...prev };
delete next[providerId];
return next;
});
clearAuthLoginUiState(providerId);
}
}, [addToast, loadAuthStatus, scrollSettingsToTop]);
}, [addToast, clearAuthLoginUiState, loadAuthStatus, scrollSettingsToTop]);
const handleSubmitManualCode = useCallback(async (providerId: string) => {
const code = manualCodeInputs[providerId]?.trim();
if (!code) {
addToast("Paste the full redirect URL or authorization code first.", "warning");
return;
}
setManualCodeSubmitInProgress(providerId);
try {
const result = await submitProviderManualCode(providerId, code);
if (result.submitted) {
setManualCodeInputs((prev) => {
if (!(providerId in prev)) {
return prev;
}
const next = { ...prev };
delete next[providerId];
return next;
});
addToast("Authorization code received. Finishing login…", "success");
} else {
addToast("That authorization code was already submitted. Waiting for login…", "warning");
}
} catch (err) {
addToast(getErrorMessage(err) || "Failed to submit authorization code", "error");
} finally {
setManualCodeSubmitInProgress(null);
}
}, [addToast, manualCodeInputs]);
const handleCancelLogin = useCallback(async (providerId: string) => {
setAuthActionInProgress(providerId);
setAuthProviders((prev) => prev.map((provider) =>
provider.id === providerId ? { ...provider, loginInProgress: false } : provider,
));
try {
await cancelProviderLogin(providerId);
setLoginInstructions((prev) => {
if (!(providerId in prev)) {
return prev;
}
const next = { ...prev };
delete next[providerId];
return next;
});
await loadAuthStatus();
clearAuthLoginUiState(providerId);
await loadAuthStatus().catch(() => {});
addToast("Login cancelled", "success");
} catch (err) {
addToast(getErrorMessage(err) || "Failed to cancel login", "error");
} finally {
setAuthActionInProgress(null);
setManualCodeSubmitInProgress((prev) => prev === providerId ? null : prev);
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
}
}, [addToast, loadAuthStatus]);
}, [addToast, clearAuthLoginUiState, loadAuthStatus]);
const handleLogout = useCallback(async (providerId: string) => {
setAuthActionInProgress(providerId);
@@ -5180,6 +5231,19 @@ export function SettingsModal({
data-testid={`auth-login-instructions-${provider.id}`}
/>
)}
{manualCodeConfigs[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (
<OAuthManualCodeForm
value={manualCodeInputs[provider.id] ?? ""}
onChange={(value) => setManualCodeInputs((prev) => ({ ...prev, [provider.id]: value }))}
onSubmit={() => void handleSubmitManualCode(provider.id)}
prompt={manualCodeConfigs[provider.id].prompt}
placeholder={manualCodeConfigs[provider.id].placeholder}
helpText={manualCodeConfigs[provider.id].helpText}
disabled={manualCodeSubmitInProgress === provider.id}
submitLabel={manualCodeSubmitInProgress === provider.id ? "Submitting…" : "Submit code"}
data-testid={`auth-manual-code-${provider.id}`}
/>
)}
</div>
)}
</div>

View File

@@ -456,10 +456,11 @@ describe("useChat", () => {
});
});
it("handles stream errors", async () => {
it("handles stream errors and surfaces them to the user", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
const addToast = vi.fn();
let errorHandler: ((data: string) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
@@ -467,7 +468,7 @@ describe("useChat", () => {
return { close: vi.fn(), isConnected: () => true };
});
const { result } = renderHook(() => useChat());
const { result } = renderHook(() => useChat(undefined, addToast));
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
@@ -477,6 +478,10 @@ describe("useChat", () => {
result.current.selectSession("session-001");
});
await waitFor(() => {
expect(result.current.activeSession?.id).toBe("session-001");
});
await act(async () => {
await result.current.sendMessage("Hello!");
});
@@ -489,6 +494,74 @@ describe("useChat", () => {
await waitFor(() => {
expect(result.current.isStreaming).toBe(false);
expect(result.current.messages).toHaveLength(0);
expect(addToast).toHaveBeenCalledWith("Stream connection failed", "error");
});
});
it("onFallback updates the selected session model, persists fallback metadata, and shows a warning toast", async () => {
const session = makeSession({
id: "session-001",
agentId: "agent-001",
modelProvider: "openai-codex",
modelId: "gpt-5.3-codex",
});
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
const addToast = vi.fn();
let fallbackHandler:
| ((data: { primaryModel: string; fallbackModel: string; triggerPoint: "session-creation" | "prompt-time" }) => void)
| undefined;
let textHandler: ((data: string) => void) | undefined;
let doneHandler: ((data: { messageId: string }) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
fallbackHandler = handlers.onFallback;
textHandler = handlers.onText;
doneHandler = handlers.onDone;
return { close: vi.fn(), isConnected: () => true };
});
const { result } = renderHook(() => useChat(undefined, addToast));
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
await act(async () => {
await result.current.sendMessage("Hello!");
});
act(() => {
fallbackHandler?.({
primaryModel: "openai-codex/gpt-5.3-codex",
fallbackModel: "zai/glm-5.1",
triggerPoint: "prompt-time",
});
textHandler?.("Fallback reply");
doneHandler?.({ messageId: "msg-fallback" });
});
await waitFor(() => {
expect(result.current.activeSession?.modelProvider).toBe("zai");
expect(result.current.activeSession?.modelId).toBe("glm-5.1");
expect(addToast).toHaveBeenCalledWith(
"Primary model unavailable. Switched to fallback zai/glm-5.1.",
"warning",
);
expect(result.current.messages.at(-1)).toEqual(expect.objectContaining({
id: "msg-fallback",
role: "assistant",
content: "Fallback reply",
fallbackInfo: {
primaryModel: "openai-codex/gpt-5.3-codex",
fallbackModel: "zai/glm-5.1",
triggerPoint: "prompt-time",
},
}));
});
});

View File

@@ -680,7 +680,7 @@ describe("useQuickChat", () => {
});
});
it("onError shows toast with failed response message", async () => {
it("onError shows the backend error message in the toast", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
const addToast = vi.fn();
let onErrorHandler: ((data: string) => void) | undefined;
@@ -701,11 +701,72 @@ describe("useQuickChat", () => {
act(() => {
result.current.sendMessage("Hello");
onErrorHandler?.("Connection aborted");
onErrorHandler?.("No API key for provider: openai-codex");
});
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to get response", "error");
expect(addToast).toHaveBeenCalledWith("No API key for provider: openai-codex", "error");
});
});
it("onFallback updates the active model, persists fallback metadata, and shows a warning toast", async () => {
const existingSession = makeSession({
id: "session-existing",
agentId: FN_AGENT_ID,
modelProvider: "openai-codex",
modelId: "gpt-5.3-codex",
});
const addToast = vi.fn();
let onFallbackHandler:
| ((data: { primaryModel: string; fallbackModel: string; triggerPoint: "session-creation" | "prompt-time" }) => void)
| undefined;
let onTextHandler: ((data: string) => void) | undefined;
let onDoneHandler: ((data: { messageId: string }) => void) | undefined;
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
onFallbackHandler = handlers.onFallback;
onTextHandler = handlers.onText;
onDoneHandler = handlers.onDone;
return { close: vi.fn(), isConnected: () => true };
});
const { result } = renderHook(() => useQuickChat("proj-123", addToast));
await act(async () => {
await result.current.switchSession(FN_AGENT_ID, "openai-codex", "gpt-5.3-codex");
});
act(() => {
result.current.sendMessage("Hello");
onFallbackHandler?.({
primaryModel: "openai-codex/gpt-5.3-codex",
fallbackModel: "zai/glm-5.1",
triggerPoint: "prompt-time",
});
onTextHandler?.("Fallback reply");
onDoneHandler?.({ messageId: "msg-fallback" });
});
await waitFor(() => {
expect(result.current.activeSession?.modelProvider).toBe("zai");
expect(result.current.activeSession?.modelId).toBe("glm-5.1");
expect(addToast).toHaveBeenCalledWith(
"Primary model unavailable. Switched to fallback zai/glm-5.1.",
"warning",
);
expect(result.current.messages.at(-1)).toEqual(expect.objectContaining({
id: "msg-fallback",
role: "assistant",
content: "Fallback reply",
fallbackInfo: {
primaryModel: "openai-codex/gpt-5.3-codex",
fallbackModel: "zai/glm-5.1",
triggerPoint: "prompt-time",
},
}));
});
});

View File

@@ -38,6 +38,12 @@ export interface ToolCallInfo {
status: "running" | "completed";
}
export interface FallbackInfo {
primaryModel: string;
fallbackModel: string;
triggerPoint: "session-creation" | "prompt-time";
}
export interface ChatMessageInfo {
id: string;
sessionId: string;
@@ -45,6 +51,7 @@ export interface ChatMessageInfo {
content: string;
thinkingOutput?: string | null;
toolCalls?: ToolCallInfo[];
fallbackInfo?: FallbackInfo;
attachments?: Array<{
id: string;
filename: string;
@@ -99,6 +106,18 @@ export interface UseChatReturn {
agentsMap: Map<string, Agent>;
}
function parseModelDescriptor(model: string): { modelProvider?: string; modelId?: string } {
const value = typeof model === "string" ? model.trim() : "";
const slashIndex = value.indexOf("/");
if (!value || slashIndex <= 0 || slashIndex >= value.length - 1) {
return {};
}
return {
modelProvider: value.slice(0, slashIndex),
modelId: value.slice(slashIndex + 1),
};
}
function extractCompletedToolCalls(metadata: Record<string, unknown> | null | undefined): ToolCallInfo[] | undefined {
const rawToolCalls = metadata?.toolCalls;
if (!Array.isArray(rawToolCalls)) {
@@ -132,6 +151,27 @@ function extractCompletedToolCalls(metadata: Record<string, unknown> | null | un
return parsed.length > 0 ? parsed : undefined;
}
function extractFallbackInfo(metadata: Record<string, unknown> | null | undefined): FallbackInfo | undefined {
const rawFallback = metadata?.fallback;
if (!rawFallback || typeof rawFallback !== "object") {
return undefined;
}
const record = rawFallback as Record<string, unknown>;
const primaryModel = typeof record.primaryModel === "string" ? record.primaryModel : "";
const fallbackModel = typeof record.fallbackModel === "string" ? record.fallbackModel : "";
const triggerPoint = record.triggerPoint;
if (!primaryModel || !fallbackModel || (triggerPoint !== "session-creation" && triggerPoint !== "prompt-time")) {
return undefined;
}
return {
primaryModel,
fallbackModel,
triggerPoint,
};
}
function mapChatMessageToInfo(message: ChatMessage): ChatMessageInfo {
return {
id: message.id,
@@ -140,12 +180,16 @@ function mapChatMessageToInfo(message: ChatMessage): ChatMessageInfo {
content: message.content,
thinkingOutput: message.thinkingOutput,
toolCalls: extractCompletedToolCalls(message.metadata),
fallbackInfo: extractFallbackInfo(message.metadata),
attachments: message.attachments,
createdAt: message.createdAt,
};
}
export function useChat(projectId?: string): UseChatReturn {
export function useChat(
projectId?: string,
addToast?: (msg: string, type?: "success" | "error" | "warning") => void,
): UseChatReturn {
// Session state
const [sessions, setSessions] = useState<ChatSessionInfo[]>([]);
const [activeSession, setActiveSession] = useState<ChatSessionInfo | null>(null);
@@ -489,6 +533,7 @@ export function useChat(projectId?: string): UseChatReturn {
let capturedText = "";
let capturedThinking = "";
let capturedToolCalls: ToolCallInfo[] = [];
let capturedFallbackInfo: FallbackInfo | undefined;
// Coalesce per-token state updates to one render per animation frame.
// ReactMarkdown re-parses the entire growing string on every render and
@@ -569,6 +614,25 @@ export function useChat(projectId?: string): UseChatReturn {
];
setStreamingToolCalls(capturedToolCalls);
},
onFallback: (data: FallbackInfo) => {
capturedFallbackInfo = data;
const nextModel = parseModelDescriptor(data.fallbackModel);
setSessions((prev) => prev.map((session) =>
session.id === activeSession.id
? {
...session,
...nextModel,
}
: session,
));
setActiveSession((prev) => prev && prev.id === activeSession.id
? {
...prev,
...nextModel,
}
: prev);
addToast?.(`Primary model unavailable. Switched to fallback ${data.fallbackModel}.`, "warning");
},
onDone: (data: { messageId: string }) => {
cancelStreamingFlushes();
const assistantMessage: ChatMessageInfo = {
@@ -578,6 +642,7 @@ export function useChat(projectId?: string): UseChatReturn {
content: capturedText,
thinkingOutput: capturedThinking,
toolCalls: capturedToolCalls.length > 0 ? capturedToolCalls : undefined,
fallbackInfo: capturedFallbackInfo,
createdAt: new Date().toISOString(),
};
@@ -616,6 +681,7 @@ export function useChat(projectId?: string): UseChatReturn {
setIsStreaming(false);
streamRef.current = null;
console.error("[useChat] Stream error:", data);
addToast?.(typeof data === "string" && data.trim() ? data : "Failed to get response", "error");
if (!cancelledByUserRef.current) {
const queuedMessage = pendingMessageRef.current.trim();
@@ -630,7 +696,7 @@ export function useChat(projectId?: string): UseChatReturn {
streamRef.current = streamChatResponse(activeSession.id, content, textHandlers, attachments, projectId);
},
[activeSession, isStreaming, projectId, refreshSessions],
[activeSession, isStreaming, projectId, refreshSessions, addToast],
);
// Filter sessions based on search query

View File

@@ -20,6 +20,12 @@ export interface ToolCallInfo {
status: "running" | "completed";
}
export interface FallbackInfo {
primaryModel: string;
fallbackModel: string;
triggerPoint: "session-creation" | "prompt-time";
}
export interface ChatMessageInfo {
id: string;
sessionId: string;
@@ -27,6 +33,7 @@ export interface ChatMessageInfo {
content: string;
thinkingOutput?: string | null;
toolCalls?: ToolCallInfo[];
fallbackInfo?: FallbackInfo;
createdAt: string;
}
@@ -102,6 +109,19 @@ function buildSessionKey(agentId: string, modelProvider?: string, modelId?: stri
return `${agentId}::${provider}/${id}`;
}
function parseModelDescriptor(model: string): ModelSelection {
const value = typeof model === "string" ? model.trim() : "";
const slashIndex = value.indexOf("/");
if (!value || slashIndex <= 0 || slashIndex >= value.length - 1) {
return {};
}
return {
modelProvider: value.slice(0, slashIndex),
modelId: value.slice(slashIndex + 1),
};
}
function extractCompletedToolCalls(metadata: Record<string, unknown> | null | undefined): ToolCallInfo[] | undefined {
const rawToolCalls = metadata?.toolCalls;
if (!Array.isArray(rawToolCalls)) {
@@ -135,6 +155,27 @@ function extractCompletedToolCalls(metadata: Record<string, unknown> | null | un
return parsed.length > 0 ? parsed : undefined;
}
function extractFallbackInfo(metadata: Record<string, unknown> | null | undefined): FallbackInfo | undefined {
const rawFallback = metadata?.fallback;
if (!rawFallback || typeof rawFallback !== "object") {
return undefined;
}
const record = rawFallback as Record<string, unknown>;
const primaryModel = typeof record.primaryModel === "string" ? record.primaryModel : "";
const fallbackModel = typeof record.fallbackModel === "string" ? record.fallbackModel : "";
const triggerPoint = record.triggerPoint;
if (!primaryModel || !fallbackModel || (triggerPoint !== "session-creation" && triggerPoint !== "prompt-time")) {
return undefined;
}
return {
primaryModel,
fallbackModel,
triggerPoint,
};
}
function mapChatMessageToInfo(message: ChatMessage): ChatMessageInfo {
return {
id: message.id,
@@ -143,6 +184,7 @@ function mapChatMessageToInfo(message: ChatMessage): ChatMessageInfo {
content: message.content,
thinkingOutput: message.thinkingOutput,
toolCalls: extractCompletedToolCalls(message.metadata),
fallbackInfo: extractFallbackInfo(message.metadata),
createdAt: message.createdAt,
};
}
@@ -153,7 +195,7 @@ function mapChatMessageToInfo(message: ChatMessage): ChatMessageInfo {
*/
export function useQuickChat(
projectId?: string,
addToast?: (msg: string, type?: "success" | "error") => void,
addToast?: (msg: string, type?: "success" | "error" | "warning") => void,
): UseQuickChatReturn {
// Session state
const [activeSession, setActiveSession] = useState<ChatSession | null>(null);
@@ -521,6 +563,7 @@ export function useQuickChat(
let capturedText = "";
let capturedThinking = "";
let capturedToolCalls: ToolCallInfo[] = [];
let capturedFallbackInfo: FallbackInfo | undefined;
// Coalesce per-token state updates to one render per animation frame —
// unthrottled setStreamingText pegs the main thread on long replies.
@@ -547,69 +590,89 @@ export function useQuickChat(
cancelStreamingFlushesRef.current = cancelStreamingFlushes;
const textHandlers = {
onThinking: (data: string) => {
capturedThinking += data;
if (thinkingRaf === null) {
thinkingRaf = requestAnimationFrame(flushThinking);
}
},
onText: (data: string) => {
capturedText += data;
if (textRaf === null) {
textRaf = requestAnimationFrame(flushText);
}
},
onToolStart: (data: { toolName: string; args?: Record<string, unknown> }) => {
capturedToolCalls = [
...capturedToolCalls,
{
toolName: data.toolName,
args: data.args,
isError: false,
status: "running",
},
];
setStreamingToolCalls(capturedToolCalls);
},
onToolEnd: (data: { toolName: string; isError: boolean; result?: unknown }) => {
const nextToolCalls = [...capturedToolCalls];
for (let i = nextToolCalls.length - 1; i >= 0; i--) {
const candidate = nextToolCalls[i];
if (candidate?.toolName === data.toolName && candidate.status === "running") {
nextToolCalls[i] = {
...candidate,
status: "completed",
onThinking: (data: string) => {
capturedThinking += data;
if (thinkingRaf === null) {
thinkingRaf = requestAnimationFrame(flushThinking);
}
},
onText: (data: string) => {
capturedText += data;
if (textRaf === null) {
textRaf = requestAnimationFrame(flushText);
}
},
onToolStart: (data: { toolName: string; args?: Record<string, unknown> }) => {
capturedToolCalls = [
...capturedToolCalls,
{
toolName: data.toolName,
args: data.args,
isError: false,
status: "running",
},
];
setStreamingToolCalls(capturedToolCalls);
},
onToolEnd: (data: { toolName: string; isError: boolean; result?: unknown }) => {
const nextToolCalls = [...capturedToolCalls];
for (let i = nextToolCalls.length - 1; i >= 0; i--) {
const candidate = nextToolCalls[i];
if (candidate?.toolName === data.toolName && candidate.status === "running") {
nextToolCalls[i] = {
...candidate,
status: "completed",
isError: data.isError,
result: data.result,
};
capturedToolCalls = nextToolCalls;
setStreamingToolCalls(nextToolCalls);
return;
}
}
capturedToolCalls = [
...nextToolCalls,
{
toolName: data.toolName,
isError: data.isError,
result: data.result,
};
capturedToolCalls = nextToolCalls;
setStreamingToolCalls(nextToolCalls);
return;
}
}
capturedToolCalls = [
...nextToolCalls,
{
toolName: data.toolName,
isError: data.isError,
result: data.result,
status: "completed",
},
];
setStreamingToolCalls(capturedToolCalls);
},
status: "completed",
},
];
setStreamingToolCalls(capturedToolCalls);
},
onFallback: (data: FallbackInfo) => {
capturedFallbackInfo = data;
const nextModel = parseModelDescriptor(data.fallbackModel);
setSessions((prev) => prev.map((session) =>
session.id === activeSession.id
? {
...session,
...nextModel,
}
: session,
));
setActiveSession((prev) => prev && prev.id === activeSession.id
? {
...prev,
...nextModel,
}
: prev);
addToast?.(`Primary model unavailable. Switched to fallback ${data.fallbackModel}.`, "warning");
},
onDone: (data: { messageId: string }) => {
cancelStreamingFlushes();
const assistantMessage: ChatMessageInfo = {
id: data.messageId || `msg-${Date.now()}`,
sessionId: activeSession.id,
role: "assistant",
content: capturedText,
thinkingOutput: capturedThinking || undefined,
toolCalls: capturedToolCalls.length > 0 ? capturedToolCalls : undefined,
createdAt: new Date().toISOString(),
};
cancelStreamingFlushes();
const assistantMessage: ChatMessageInfo = {
id: data.messageId || `msg-${Date.now()}`,
sessionId: activeSession.id,
role: "assistant",
content: capturedText,
thinkingOutput: capturedThinking || undefined,
toolCalls: capturedToolCalls.length > 0 ? capturedToolCalls : undefined,
fallbackInfo: capturedFallbackInfo,
createdAt: new Date().toISOString(),
};
// Preserve user message and add assistant message
setMessages((prev) => [...prev, assistantMessage]);
@@ -637,7 +700,7 @@ export function useQuickChat(
setIsStreaming(false);
streamRef.current = null;
console.error("[useQuickChat] Stream error:", data);
addToast?.("Failed to get response", "error");
addToast?.(typeof data === "string" && data.trim() ? data : "Failed to get response", "error");
sendCompletionRef.current?.reject(new Error(typeof data === "string" ? data : "Failed to get response"));
sendCompletionRef.current = null;

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 ---

View File

@@ -20,6 +20,7 @@ import type {
ChatStore,
ChatSession,
ChatSessionCreateInput,
Settings,
} from "@fusion/core";
import { summarizeTitle } from "@fusion/core";
import { EventEmitter } from "node:events";
@@ -28,7 +29,13 @@ import { join, resolve, relative } from "node:path";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import { SessionEventBuffer } from "./sse-buffer.js";
import { createFnAgent as engineCreateFnAgent } from "@fusion/engine";
import {
createFnAgent as engineCreateFnAgent,
createResolvedAgentSession as engineCreateResolvedAgentSession,
promptWithFallback as enginePromptWithFallback,
extractRuntimeHint,
extractRuntimeModel,
} from "@fusion/engine";
import * as engineModule from "@fusion/engine";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -36,6 +43,8 @@ type AgentResult = any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let createFnAgent: any = engineCreateFnAgent;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let createResolvedAgentSession: any = engineCreateResolvedAgentSession;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let buildAgentChatPromptFn: any;
/**
@@ -136,6 +145,7 @@ export type ChatStreamEvent =
| { type: "text"; data: string }
| { type: "tool_start"; data: { toolName: string; args?: Record<string, unknown> } }
| { type: "tool_end"; data: { toolName: string; isError: boolean; result?: unknown } }
| { type: "fallback"; data: { primaryModel: string; fallbackModel: string; triggerPoint: "session-creation" | "prompt-time" } }
| { type: "done"; data: { messageId: string; attachments?: ChatAttachment[] } }
| { type: "error"; data: string };
@@ -430,8 +440,63 @@ export class ChatManager {
private chatStore: ChatStore,
private rootDir: string,
private agentStore?: AgentStore,
private pluginRunner?: {
getRuntimeById?(runtimeId: string): unknown;
createRuntimeContext?(pluginId: string): Promise<unknown>;
},
private getSettings?: () => Promise<Pick<Settings, "fallbackProvider" | "fallbackModelId" | "defaultProvider" | "defaultModelId"> | undefined> | Pick<Settings, "fallbackProvider" | "fallbackModelId" | "defaultProvider" | "defaultModelId"> | undefined,
) {}
private async getChatModelSettings(): Promise<{
fallbackProvider?: string;
fallbackModelId?: string;
defaultProvider?: string;
defaultModelId?: string;
}> {
if (!this.getSettings) {
return {};
}
try {
const settings = await this.getSettings();
return {
fallbackProvider: settings?.fallbackProvider ?? undefined,
fallbackModelId: settings?.fallbackModelId ?? undefined,
defaultProvider: settings?.defaultProvider ?? undefined,
defaultModelId: settings?.defaultModelId ?? undefined,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
diagnostics.warn(`Failed to load chat fallback settings: ${message}`);
return {};
}
}
private handleFallbackModelUsed(
sessionId: string,
payload: {
primaryModel: string;
fallbackModel: string;
triggerPoint: "session-creation" | "prompt-time";
},
): void {
const slashIndex = payload.fallbackModel.indexOf("/");
if (slashIndex > 0 && slashIndex < payload.fallbackModel.length - 1) {
this.chatStore.updateSession(sessionId, {
modelProvider: payload.fallbackModel.slice(0, slashIndex),
modelId: payload.fallbackModel.slice(slashIndex + 1),
});
}
diagnostics.warn(
`[fallback] chat ${sessionId} switched from ${payload.primaryModel} to ${payload.fallbackModel} (${payload.triggerPoint})`,
);
chatStreamManager.broadcast(sessionId, {
type: "fallback",
data: payload,
});
}
/**
* Resolve the per-chat pi/Claude CLI SessionManager.
*
@@ -621,6 +686,9 @@ export class ChatManager {
};
const toolCallsAccum: ToolCallRecord[] = [];
const pendingToolStarts = new Map<string, Array<{ toolName: string; args?: Record<string, unknown> }>>();
let fallbackInfo:
| { primaryModel: string; fallbackModel: string; triggerPoint: "session-creation" | "prompt-time" }
| undefined;
try {
// Validate session exists
@@ -653,34 +721,13 @@ export class ChatManager {
}
// Use model from session if not overridden (needed for both AI response and title generation)
const effectiveModelProvider = modelProvider ?? session.modelProvider ?? undefined;
const effectiveModelId = modelId ?? session.modelId ?? undefined;
const requestedModelProvider = modelProvider ?? session.modelProvider ?? undefined;
const requestedModelId = modelId ?? session.modelId ?? undefined;
let effectiveModelProvider = requestedModelProvider;
let effectiveModelId = requestedModelId;
let hasExplicitAgentRuntimeModel = false;
// Auto-generate chat title on first message if session has no title
const needsTitle = session.title === null || session.title === undefined || session.title.trim() === "";
if (needsTitle) {
// Fire-and-forget title generation (non-blocking)
(async () => {
try {
const generated = await summarizeTitle(
content.trim(),
this.rootDir,
effectiveModelProvider,
effectiveModelId,
);
const title = generated ?? content.trim().slice(0, 60).trim();
if (title) {
this.chatStore.updateSession(sessionId, { title });
}
} catch {
// Fallback on any error
const fallback = content.trim().slice(0, 60).trim();
if (fallback) {
this.chatStore.updateSession(sessionId, { title: fallback });
}
}
})();
}
// Ensure engine is loaded
await ensureEngineReady();
@@ -718,6 +765,41 @@ export class ChatManager {
}
}
if (agent) {
const runtimeModel = extractRuntimeModel(agent.runtimeConfig);
if (runtimeModel.provider && runtimeModel.modelId) {
hasExplicitAgentRuntimeModel = true;
}
effectiveModelProvider ??= runtimeModel.provider;
effectiveModelId ??= runtimeModel.modelId;
}
// Auto-generate chat title on first message if session has no title.
// Run after the agent fetch so the title-summarizer uses the agent's model.
if (needsTitle) {
// Fire-and-forget title generation (non-blocking)
(async () => {
try {
const generated = await summarizeTitle(
content.trim(),
this.rootDir,
effectiveModelProvider,
effectiveModelId,
);
const title = generated ?? content.trim().slice(0, 60).trim();
if (title) {
this.chatStore.updateSession(sessionId, { title });
}
} catch {
// Fallback on any error
const fallback = content.trim().slice(0, 60).trim();
if (fallback) {
this.chatStore.updateSession(sessionId, { title: fallback });
}
}
})();
}
if (mentions.length > 0) {
const mentionContext = await this.buildMentionContext(mentions, mentionAgents);
if (mentionContext) {
@@ -745,12 +827,23 @@ export class ChatManager {
// first user message we create a fresh, file-backed session and persist
// its path; subsequent messages reopen the same file.
const sessionManager = this.resolveCliSessionManager(session);
const chatModelSettings = await this.getChatModelSettings();
const usesConfiguredDefaultModel =
requestedModelProvider === chatModelSettings.defaultProvider
&& requestedModelId === chatModelSettings.defaultModelId
&& !!requestedModelProvider
&& !!requestedModelId;
const allowFallback =
!hasExplicitAgentRuntimeModel
&& (
!(requestedModelProvider && requestedModelId)
|| usesConfiguredDefaultModel
);
// Create AI agent session
agentResult = await createFnAgent({
const sessionOptions = {
cwd: this.rootDir,
systemPrompt,
tools: "coding",
tools: "coding" as const,
sessionManager,
...(effectiveModelProvider && effectiveModelId
? {
@@ -758,6 +851,20 @@ export class ChatManager {
defaultModelId: effectiveModelId,
}
: {}),
...(allowFallback && chatModelSettings.fallbackProvider && chatModelSettings.fallbackModelId
? {
fallbackProvider: chatModelSettings.fallbackProvider,
fallbackModelId: chatModelSettings.fallbackModelId,
}
: {}),
onFallbackModelUsed: (payload: {
primaryModel: string;
fallbackModel: string;
triggerPoint: "session-creation" | "prompt-time";
}) => {
fallbackInfo = payload;
this.handleFallbackModelUsed(sessionId, payload);
},
onThinking: (delta: string) => {
accumulatedThinking += delta;
chatStreamManager.broadcast(sessionId, {
@@ -801,7 +908,19 @@ export class ChatManager {
data: { toolName: name, isError, result },
});
},
});
};
const agentRuntimeHint = agent ? extractRuntimeHint(agent.runtimeConfig) : undefined;
if (agentRuntimeHint) {
agentResult = await createResolvedAgentSession({
sessionPurpose: "executor",
runtimeHint: agentRuntimeHint,
pluginRunner: this.pluginRunner,
...sessionOptions,
});
} else {
agentResult = await createFnAgent(sessionOptions);
}
this.activeGenerations.set(sessionId, { abortController, agentResult });
if (abortController.signal.aborted) {
@@ -810,12 +929,25 @@ export class ChatManager {
}
// Send user message and get response
await agentResult.session.prompt(promptContent);
await enginePromptWithFallback(agentResult.session, promptContent);
if (abortController.signal.aborted) {
return;
}
// Some runtimes (e.g. plugin-backed Codex/openclaw) signal provider failures
// by setting session.state.errorMessage rather than throwing. Surface that
// as an error event instead of persisting a blank assistant reply.
const sessionErrorMessage = (agentResult.session.state as { errorMessage?: unknown }).errorMessage;
if (typeof sessionErrorMessage === "string" && sessionErrorMessage.trim().length > 0
&& !accumulatedText && !accumulatedThinking && toolCallsAccum.length === 0) {
chatStreamManager.broadcast(sessionId, {
type: "error",
data: sessionErrorMessage,
});
return;
}
// Extract response text from agent state
let responseText = "";
interface AgentMessage {
@@ -841,11 +973,18 @@ export class ChatManager {
const finalResponseText = accumulatedText || responseText;
// Persist assistant message
const assistantMetadata: Record<string, unknown> = {};
if (toolCallsAccum.length > 0) {
assistantMetadata.toolCalls = toolCallsAccum;
}
if (fallbackInfo) {
assistantMetadata.fallback = fallbackInfo;
}
const assistantMessage = this.chatStore.addMessage(sessionId, {
role: "assistant",
content: finalResponseText,
thinkingOutput: accumulatedThinking || undefined,
metadata: toolCallsAccum.length > 0 ? { toolCalls: toolCallsAccum } : undefined,
metadata: Object.keys(assistantMetadata).length > 0 ? assistantMetadata : undefined,
});
// Broadcast done event
@@ -873,6 +1012,7 @@ export class ChatManager {
thinkingOutput: accumulatedThinking || undefined,
metadata: {
interrupted: true,
...(fallbackInfo ? { fallback: fallbackInfo } : {}),
...(toolCallsAccum.length > 0 ? { toolCalls: toolCallsAccum } : {}),
},
});
@@ -948,6 +1088,13 @@ export function __setCreateFnAgent(mock: typeof createFnAgent): void {
createFnAgent = mock;
}
/**
* Inject a mock createResolvedAgentSession function. Used for testing only.
*/
export function __setCreateResolvedAgentSession(mock: typeof createResolvedAgentSession): void {
createResolvedAgentSession = mock;
}
/**
* Inject a mock buildAgentChatPrompt function. Used for testing only.
*/
@@ -962,6 +1109,8 @@ export function __resetChatState(): void {
chatStreamManager.reset();
rateLimits.clear();
buildAgentChatPromptFn = undefined;
createFnAgent = engineCreateFnAgent;
createResolvedAgentSession = engineCreateResolvedAgentSession;
// Reset diagnostics logger to default
__setChatDiagnostics(null);

View File

@@ -155,6 +155,7 @@ export interface AuthStorageLike {
callbacks: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>;
onManualCodeInput?: () => Promise<string>;
onProgress?: (message: string) => void;
signal?: AbortSignal;
},

View File

@@ -32,11 +32,35 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
return key.slice(0, 3) + "•••••" + key.slice(-4);
}
function isExpiredOauthCredential(providerId: string, storage: AuthStorageLike): boolean {
const credential = storage.get?.(providerId);
if (!credential || credential.type !== "oauth" || typeof credential.expires !== "number") {
return false;
}
return Date.now() >= credential.expires;
}
type ManualCodeConfig = {
prompt: string;
placeholder?: string;
helpText?: string;
};
type PendingLogin = {
abortController: AbortController;
inputPromise: Promise<string>;
resolveInput: (input: string) => void;
rejectInput: (error: Error) => void;
inputSubmitted: boolean;
manualCode?: ManualCodeConfig;
};
/**
* Track in-progress login flows to prevent concurrent logins for the same provider.
* Maps provider ID → AbortController for the active login.
* Maps provider ID → pending interactive login state.
*/
const loginInProgress = new Map<string, AbortController>();
const loginInProgress = new Map<string, PendingLogin>();
const OAUTH_SESSION_TTL_MS = 5 * 60 * 1000;
const oauthSessions = new Map<string, { port: number; path: string; originalRedirectUri: string; expiresAt: number }>();
@@ -107,6 +131,60 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
};
}
function shouldRewriteOauthRedirect(providerId: string, origin: string | undefined): boolean {
if (!origin || isLocalhostOrigin(origin)) {
return false;
}
// The upstream OpenAI Codex OAuth provider is hardcoded to request and
// later redeem the localhost callback URI `http://localhost:1455/auth/callback`.
// Rewriting that authorize-time redirect_uri to the dashboard proxy causes
// OpenAI auth to fail with an upstream unknown_error. Keep the original
// localhost callback for this provider.
if (providerId === "openai-codex") {
return false;
}
return true;
}
function getManualCodeConfig(providerId: string, origin: string | undefined): ManualCodeConfig | undefined {
if (providerId !== "openai-codex") {
return undefined;
}
const remoteDashboard = origin !== undefined && !isLocalhostOrigin(origin);
return {
prompt: "Paste the final redirect URL or authorization code",
placeholder: "http://localhost:1455/auth/callback?code=...&state=... or just the code",
helpText: remoteDashboard
? "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."
: "If the browser cannot finish the localhost callback automatically, copy the full browser URL from the address bar and paste it here.",
};
}
function appendManualCodeHint(
instructions: string | undefined,
providerId: string,
origin: string | undefined,
): string | undefined {
const manualCode = getManualCodeConfig(providerId, origin);
if (!manualCode) {
return instructions;
}
const hint = manualCode.helpText;
if (!hint) {
return instructions;
}
if (!instructions?.trim()) {
return hint;
}
return `${instructions.trim()} ${hint}`;
}
/**
* GET /api/auth/status
* Returns list of all providers with their authentication status and type.
@@ -131,7 +209,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
}[] = oauthProviders.map((p) => ({
id: p.id,
name: p.name,
authenticated: storage.hasAuth(p.id),
authenticated: storage.hasAuth(p.id) && !isExpiredOauthCredential(p.id, storage),
type: "oauth" as const,
loginInProgress: loginInProgress.has(p.id),
}));
@@ -472,7 +550,30 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
}
const abortController = new AbortController();
loginInProgress.set(provider, abortController);
let resolveInput: (value: string) => void = () => {};
let rejectInput: (error: Error) => void = () => {};
const inputPromise = new Promise<string>((resolve, reject) => {
resolveInput = resolve;
rejectInput = reject;
});
// Cancellation can reject this promise before the upstream provider has
// actually awaited it. Keep the rejection observed so dashboard cancel
// does not create unhandled rejection noise.
void inputPromise.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
if (message !== "cancelled") {
console.warn(`[auth/login] manual OAuth input promise rejected for ${provider}: ${message}`);
}
});
const pendingLogin: PendingLogin = {
abortController,
inputPromise,
resolveInput,
rejectInput,
inputSubmitted: false,
manualCode: getManualCodeConfig(provider, origin),
};
loginInProgress.set(provider, pendingLogin);
// We need to get the URL from the onAuth callback before responding.
// The login() call continues in the background until the user completes OAuth.
@@ -486,13 +587,16 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
// Start login flow in background — don't await the full login
const loginPromise = storage.login(provider, {
onAuth: (info) => {
authResolve({ url: info.url, instructions: info.instructions });
},
onPrompt: async (prompt) => {
// Web UI cannot interactively prompt — return empty string if allowed
if (prompt.allowEmpty) return "";
return prompt.placeholder || "";
authResolve({
url: info.url,
instructions: appendManualCodeHint(info.instructions, provider, origin),
});
},
onPrompt: async () => await pendingLogin.inputPromise,
// AuthStorage.login() forwards callbacks to provider-specific OAuth
// implementations verbatim. openai-codex supports this optional hook
// to race pasted codes against the localhost callback server.
onManualCodeInput: async () => await pendingLogin.inputPromise,
onProgress: () => {}, // no-op for web UI
signal: abortController.signal,
});
@@ -519,7 +623,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
clearTimeout(timeout);
let responseUrl = authInfo.url;
if (origin && !isLocalhostOrigin(origin)) {
if (shouldRewriteOauthRedirect(provider, origin)) {
const rewritten = rewriteAuthUrl(authInfo.url, origin);
setOauthSession(rewritten.state, {
port: rewritten.port,
@@ -529,7 +633,11 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
responseUrl = rewritten.url;
}
res.json({ url: responseUrl, instructions: authInfo.instructions });
res.json({
url: responseUrl,
instructions: authInfo.instructions,
manualCode: pendingLogin.manualCode,
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
@@ -561,7 +669,9 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
}
loginInProgress.delete(provider);
activeLogin.abort();
activeLogin.inputSubmitted = true;
activeLogin.rejectInput(new Error("cancelled"));
activeLogin.abortController.abort();
res.json({ success: true, cancelled: true });
} catch (err: unknown) {
if (err instanceof ApiError) {
@@ -571,6 +681,43 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
}
});
/**
* POST /api/auth/manual-code
* Submit a pasted OAuth callback URL or authorization code for an active login.
* Body: { provider: string, code: string }
* Response: { success: true, submitted: boolean }
*/
router.post("/auth/manual-code", (req, res) => {
try {
const { provider, code } = req.body;
if (!provider || typeof provider !== "string") {
throw badRequest("provider is required");
}
if (!code || typeof code !== "string" || !code.trim()) {
throw badRequest("code is required");
}
const activeLogin = loginInProgress.get(provider);
if (!activeLogin) {
throw conflict(`No login in progress for ${provider}`);
}
if (activeLogin.inputSubmitted) {
res.json({ success: true, submitted: false });
return;
}
activeLogin.inputSubmitted = true;
activeLogin.resolveInput(code.trim());
res.json({ success: true, submitted: true });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
router.get("/auth/oauth-callback", async (req, res) => {
try {
const error = typeof req.query.error === "string" ? req.query.error : undefined;

View File

@@ -214,6 +214,8 @@ export interface ServerOptions {
/** Optional PluginRunner for plugin hooks, routes, and lifecycle operations */
pluginRunner?: {
getPluginRoutes(): Array<{ pluginId: string; route: import("@fusion/core").PluginRouteDefinition }>;
getRuntimeById?(runtimeId: string): unknown;
createRuntimeContext?(pluginId: string): Promise<unknown>;
reloadPlugin?(pluginId: string): Promise<unknown>;
};
/** Optional ChatStore for chat session management */
@@ -902,7 +904,13 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
const chatAgentStore = new AgentStore({ rootDir: store.getFusionDir() });
// Create ChatManager for AI chat message handling
const chatManager = options?.chatManager ?? new ChatManager(chatStore, store.getRootDir(), chatAgentStore);
const chatManager = options?.chatManager ?? new ChatManager(
chatStore,
store.getRootDir(),
chatAgentStore,
options?.pluginRunner,
() => store.getSettings(),
);
const runAiSessionCleanup = (maxAgeMs: number, source: "initial" | "scheduled") => {
const result = aiSessionStore.cleanupStaleSessions(maxAgeMs);

View File

@@ -5,6 +5,18 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { createFusionAuthStorage, getFusionAuthPath } from "../auth-storage.js";
function encodeBase64Url(value: string): string {
return Buffer.from(value, "utf-8").toString("base64url");
}
function createJwt(payload: Record<string, unknown>): string {
return [
encodeBase64Url(JSON.stringify({ alg: "none", typ: "JWT" })),
encodeBase64Url(JSON.stringify(payload)),
"signature",
].join(".");
}
describe("createFusionAuthStorage", () => {
// HOME override required — createFusionAuthStorage() has no dir parameter
const originalHome = process.env.HOME;
@@ -95,6 +107,88 @@ describe("createFusionAuthStorage", () => {
expect(existsSync(join(homeDir, ".pi", "auth.json"))).toBe(false);
});
it("reads valid Codex CLI OAuth credentials from ~/.codex/auth.json", async () => {
const codexDir = join(homeDir, ".codex");
mkdirSync(codexDir, { recursive: true });
const accessToken = createJwt({
exp: Math.floor(Date.now() / 1000) + 3600,
"https://api.openai.com/auth": {
chatgpt_account_id: "acct_codex",
},
});
writeFileSync(
join(codexDir, "auth.json"),
JSON.stringify({
tokens: {
access_token: accessToken,
refresh_token: "codex-refresh-token",
},
}),
);
const authStorage = createFusionAuthStorage();
expect(await authStorage.getApiKey("openai-codex")).toBe(accessToken);
expect(authStorage.get("openai-codex")).toEqual({
type: "oauth",
access: accessToken,
refresh: "codex-refresh-token",
expires: expect.any(Number),
accountId: "acct_codex",
});
});
it("hydrates newer Codex CLI OAuth credentials into Fusion auth on reload", async () => {
const fusionAgentDir = join(homeDir, ".fusion", "agent");
const codexDir = join(homeDir, ".codex");
mkdirSync(fusionAgentDir, { recursive: true });
mkdirSync(codexDir, { recursive: true });
const olderAccessToken = createJwt({
exp: Math.floor(Date.now() / 1000) + 900,
});
writeFileSync(
getFusionAuthPath(homeDir),
JSON.stringify({
"openai-codex": {
type: "oauth",
access: olderAccessToken,
refresh: "old-refresh-token",
expires: Date.now() + 900_000,
},
}),
);
const newerAccessToken = createJwt({
exp: Math.floor(Date.now() / 1000) + 3600,
"https://api.openai.com/auth": {
chatgpt_account_id: "acct_newer",
},
});
writeFileSync(
join(codexDir, "auth.json"),
JSON.stringify({
tokens: {
access_token: newerAccessToken,
refresh_token: "new-refresh-token",
},
}),
);
const authStorage = createFusionAuthStorage();
authStorage.reload();
expect(await authStorage.getApiKey("openai-codex")).toBe(newerAccessToken);
expect(authStorage.get("openai-codex")).toEqual({
type: "oauth",
access: newerAccessToken,
refresh: "new-refresh-token",
expires: expect.any(Number),
accountId: "acct_newer",
});
});
describe("models.json API key fallback", () => {
it("returns API key from models.json when not in auth.json", async () => {
const legacyAgentDir = join(homeDir, ".pi", "agent");

View File

@@ -0,0 +1,85 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { createFallbackModelObserver } from "../fallback-model-observer.js";
import { notifyFallbackUsed } from "../notifier.js";
vi.mock("../notifier.js", () => ({
notifyFallbackUsed: vi.fn().mockResolvedValue(undefined),
}));
describe("createFallbackModelObserver", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("logs fallback activity, appends agent log, and dispatches a notification", async () => {
const store = {
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
};
const observer = createFallbackModelObserver({
agent: "Executor Agent",
label: "executor",
store,
taskId: "FN-123",
taskTitle: "Fix Codex auth",
});
await observer({
primaryModel: "openai-codex/gpt-5.3-codex",
fallbackModel: "zai/glm-5.1",
triggerPoint: "prompt-time",
timestamp: "2026-05-03T22:00:00.000Z",
});
const expectedMessage =
"[fallback] executor switched from openai-codex/gpt-5.3-codex to zai/glm-5.1 (prompt-time)";
expect(store.logEntry).toHaveBeenCalledWith("FN-123", expectedMessage);
expect(store.appendAgentLog).toHaveBeenCalledWith(
"FN-123",
expectedMessage,
"text",
undefined,
"Executor Agent",
);
expect(notifyFallbackUsed).toHaveBeenCalledWith({
primaryModel: "openai-codex/gpt-5.3-codex",
fallbackModel: "zai/glm-5.1",
triggerPoint: "prompt-time",
taskId: "FN-123",
taskTitle: "Fix Codex auth",
timestamp: "2026-05-03T22:00:00.000Z",
});
});
it("swallows logging failures and still dispatches a notification", async () => {
const store = {
logEntry: vi.fn().mockRejectedValue(new Error("log failed")),
appendAgentLog: vi.fn().mockRejectedValue(new Error("append failed")),
};
const observer = createFallbackModelObserver({
agent: "Merger Agent",
label: "merge verification",
store,
});
await expect(observer({
primaryModel: "openai-codex/gpt-5.3-codex",
fallbackModel: "zai/glm-5.1",
triggerPoint: "session-creation",
taskId: "FN-456",
taskTitle: "Merge verification",
})).resolves.toBeUndefined();
expect(notifyFallbackUsed).toHaveBeenCalledWith({
primaryModel: "openai-codex/gpt-5.3-codex",
fallbackModel: "zai/glm-5.1",
triggerPoint: "session-creation",
taskId: "FN-456",
taskTitle: "Merge verification",
timestamp: undefined,
});
});
});

View File

@@ -1,18 +1,19 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import {
choosePreferredStoredCredential,
getCodexCliAuthPath,
readStoredCredentialsFromAuthFile,
shouldHydrateStoredCredential,
type StoredAuthCredential,
} from "@fusion/core";
import { AuthStorage } from "@mariozechner/pi-coding-agent";
import type { AuthCredential } from "@mariozechner/pi-coding-agent";
import { getOAuthProvider } from "@mariozechner/pi-ai/oauth";
import type { OAuthCredentials } from "@mariozechner/pi-ai/oauth";
type StoredCredential = {
type?: string;
key?: string;
access?: string;
refresh?: string;
expires?: number;
[key: string]: unknown;
};
type StoredCredential = StoredAuthCredential;
function getHomeDir(): string {
return process.env.HOME || process.env.USERPROFILE || homedir();
@@ -33,6 +34,13 @@ function getLegacyAuthPaths(home = getHomeDir()): string[] {
];
}
function getSupplementalAuthPaths(home = getHomeDir()): string[] {
return [
...getLegacyAuthPaths(home),
getCodexCliAuthPath(home),
];
}
function getLegacyModelsPaths(home = getHomeDir()): string[] {
return [
join(home, ".pi", "agent", "models.json"),
@@ -49,20 +57,13 @@ export function getModelRegistryModelsPath(home = getHomeDir()): string {
return getLegacyModelsPaths(home).find((modelsPath) => existsSync(modelsPath)) ?? fusionModelsPath;
}
function readLegacyCredentials(authPaths = getLegacyAuthPaths()): Record<string, StoredCredential> {
function readSupplementalCredentials(authPaths = getSupplementalAuthPaths()): Record<string, StoredCredential> {
const credentials: Record<string, StoredCredential> = {};
for (const authPath of authPaths) {
if (!existsSync(authPath)) {
continue;
}
try {
const parsed = JSON.parse(readFileSync(authPath, "utf-8")) as Record<string, StoredCredential>;
for (const [provider, credential] of Object.entries(parsed)) {
credentials[provider] ??= credential;
}
} catch {
// Ignore invalid legacy auth files and continue with other candidates.
const parsed = readStoredCredentialsFromAuthFile(authPath);
for (const [provider, credential] of Object.entries(parsed)) {
credentials[provider] = choosePreferredStoredCredential(credentials[provider], credential) ?? credential;
}
}
@@ -136,10 +137,24 @@ function readModelsJsonApiKeys(home = getHomeDir()): Map<string, string> {
export function createFusionAuthStorage(): AuthStorage {
const primary = AuthStorage.create(getFusionAuthPath());
let legacyCredentials = readLegacyCredentials();
// models.json provider API keys — third fallback after primary auth and legacy auth.json
let supplementalCredentials = readSupplementalCredentials();
// models.json provider API keys — final fallback after primary auth and supplemental auth.json files
let modelsJsonApiKeys = readModelsJsonApiKeys();
const syncSupplementalOauthCredentials = () => {
for (const [provider, credential] of Object.entries(supplementalCredentials)) {
const current = primary.get(provider) as StoredCredential | undefined;
if (!shouldHydrateStoredCredential(current, credential)) {
continue;
}
if (credential.type === "oauth" || credential.type === "api_key") {
primary.set(provider, credential as AuthCredential);
}
}
};
syncSupplementalOauthCredentials();
return new Proxy(primary, {
// Forward property writes to the target so that methods like
// `setFallbackResolver` (called by ModelRegistry) correctly update the
@@ -154,29 +169,50 @@ export function createFusionAuthStorage(): AuthStorage {
if (prop === "reload") {
return () => {
target.reload();
legacyCredentials = readLegacyCredentials();
supplementalCredentials = readSupplementalCredentials();
syncSupplementalOauthCredentials();
modelsJsonApiKeys = readModelsJsonApiKeys();
};
}
if (prop === "get") {
return (provider: string) => target.get(provider) ?? legacyCredentials[provider];
return (provider: string) =>
choosePreferredStoredCredential(
target.get(provider) as StoredCredential | undefined,
supplementalCredentials[provider],
);
}
if (prop === "has") {
return (provider: string) => target.has(provider) || provider in legacyCredentials || modelsJsonApiKeys.has(provider);
return (provider: string) => target.has(provider) || provider in supplementalCredentials || modelsJsonApiKeys.has(provider);
}
if (prop === "hasAuth") {
return (provider: string) => target.hasAuth(provider) || Boolean(legacyCredentials[provider]) || modelsJsonApiKeys.has(provider);
return (provider: string) => target.hasAuth(provider) || Boolean(supplementalCredentials[provider]) || modelsJsonApiKeys.has(provider);
}
if (prop === "getAll") {
return () => ({ ...legacyCredentials, ...target.getAll() });
return () => {
const providerIds = new Set([
...Object.keys(supplementalCredentials),
...Object.keys(target.getAll() as Record<string, StoredCredential>),
]);
const merged: Record<string, StoredCredential> = {};
for (const providerId of providerIds) {
const credential = choosePreferredStoredCredential(
(target.get(providerId) as StoredCredential | undefined),
supplementalCredentials[providerId],
);
if (credential) {
merged[providerId] = credential;
}
}
return merged;
};
}
if (prop === "list") {
return () => Array.from(new Set([...Object.keys(legacyCredentials), ...target.list(), ...modelsJsonApiKeys.keys()]));
return () => Array.from(new Set([...Object.keys(supplementalCredentials), ...target.list(), ...modelsJsonApiKeys.keys()]));
}
if (prop === "getApiKey") {
@@ -185,9 +221,9 @@ export function createFusionAuthStorage(): AuthStorage {
const primaryKey = await target.getApiKey(provider);
if (primaryKey) return primaryKey;
// 2. Legacy auth.json credentials
const legacyKey = resolveStoredCredentialApiKey(provider, legacyCredentials[provider]);
if (legacyKey) return legacyKey;
// 2. Supplemental auth.json credentials (.pi + .codex)
const supplementalKey = resolveStoredCredentialApiKey(provider, supplementalCredentials[provider]);
if (supplementalKey) return supplementalKey;
// 3. models.json provider API keys (e.g., kimi-coding, lmstudio)
return modelsJsonApiKeys.get(provider);

View File

@@ -61,7 +61,7 @@ import {
import { getTaskCompletionBlockerForStore } from "./task-completion.js";
import { createFusionAuthStorage, getModelRegistryModelsPath } from "./auth-storage.js";
import { createRunVerificationTool } from "./run-verification-tool.js";
import { notifyFallbackUsed } from "./notifier.js";
import { createFallbackModelObserver } from "./fallback-model-observer.js";
// Re-export for backward compatibility (tests import from executor.ts)
export { summarizeToolArgs } from "./agent-logger.js";
@@ -2839,7 +2839,13 @@ export class TaskExecutor {
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
taskId: task.id,
taskTitle: detail.title,
onFallbackModelUsed: notifyFallbackUsed,
onFallbackModelUsed: createFallbackModelObserver({
agent: "executor",
label: "executor",
store: this.store,
taskId: task.id,
taskTitle: detail.title,
}),
});
if (isResuming) {
@@ -4731,36 +4737,36 @@ ${failureFeedback}
* Uses git diff against the stored baseCommitSha to determine what changed.
* Returns an empty array if no changes or if git commands fail.
*/
private async resolveDiffBaseRef(worktreePath: string, baseCommitSha?: string): Promise<string | undefined> {
if (baseCommitSha) return baseCommitSha;
try {
const { stdout } = await execAsync(
"git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main",
{ cwd: worktreePath, encoding: "utf-8" },
);
const ref = stdout.trim();
if (ref) return ref;
} catch (mergeBaseErr: unknown) {
const mergeBaseMsg = mergeBaseErr instanceof Error ? mergeBaseErr.message : String(mergeBaseErr);
executorLog.warn(`Failed merge-base lookup for diff base in ${worktreePath}, trying HEAD~1 fallback: ${mergeBaseMsg}`);
}
try {
const { stdout } = await execAsync("git rev-parse HEAD~1", {
cwd: worktreePath,
encoding: "utf-8",
});
return stdout.trim() || undefined;
} catch {
executorLog.log(`Could not determine base commit for diff in ${worktreePath}`);
return undefined;
}
}
private async captureModifiedFiles(worktreePath: string, baseCommitSha?: string): Promise<string[]> {
try {
// Determine the base reference for diff
// If baseCommitSha is stored, use it; otherwise fall back to merge-base with HEAD
let baseRef = baseCommitSha;
if (!baseRef) {
// Try to find merge-base with main/master as fallback
try {
const { stdout } = await execAsync("git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main", {
cwd: worktreePath,
encoding: "utf-8",
});
baseRef = stdout.trim();
} catch (mergeBaseErr: unknown) {
const mergeBaseMsg = mergeBaseErr instanceof Error ? mergeBaseErr.message : String(mergeBaseErr);
executorLog.warn(`Failed merge-base lookup for diff base in ${worktreePath}, trying HEAD~1 fallback: ${mergeBaseMsg}`);
// If merge-base fails, use HEAD~1 as last resort
try {
const { stdout } = await execAsync("git rev-parse HEAD~1", {
cwd: worktreePath,
encoding: "utf-8",
});
baseRef = stdout.trim();
} catch {
executorLog.log(`Could not determine base commit for diff in ${worktreePath}`);
return [];
}
}
}
const baseRef = await this.resolveDiffBaseRef(worktreePath, baseCommitSha);
if (!baseRef) {
return [];
}
@@ -5050,6 +5056,42 @@ ${failureFeedback}
settings: Settings,
): Promise<WorkflowStepOutcome> {
const toolMode: "coding" | "readonly" = workflowStep.toolMode || "readonly";
// Compute the diff scope so the workflow step agent reviews only what THIS
// task changed — not unrelated files it might wander into. Without this,
// open-ended review prompts (e.g. "verify visual polish") have been
// observed to spend the entire timeout budget reading pre-existing files
// that match the task description's keywords. See FN-3327 post-mortem.
const scopedFiles = await this.captureModifiedFiles(worktreePath, task.baseCommitSha);
let diffShortstat: string | undefined;
try {
const baseRef = await this.resolveDiffBaseRef(worktreePath, task.baseCommitSha);
if (baseRef) {
const { stdout } = await execAsync(`git diff --shortstat ${baseRef}..HEAD`, {
cwd: worktreePath,
encoding: "utf-8",
});
diffShortstat = stdout.trim() || undefined;
}
} catch {
// best-effort — fall through with no shortstat
}
const MAX_SCOPE_FILES = 100;
const scopeFileBlock = scopedFiles.length === 0
? "(no modified files detected for this task — review the worktree directly, but do NOT browse unrelated files)"
: scopedFiles.length > MAX_SCOPE_FILES
? `${scopedFiles.slice(0, MAX_SCOPE_FILES).map((f) => `- ${f}`).join("\n")}\n- ... (${scopedFiles.length - MAX_SCOPE_FILES} more files truncated)`
: scopedFiles.map((f) => `- ${f}`).join("\n");
const scopeBlock = `Diff Scope (files changed by THIS task vs base):
${scopeFileBlock}${diffShortstat ? `\nDiff stat: ${diffShortstat}` : ""}
CRITICAL SCOPING RULES — read before doing anything else:
- Review ONLY the files listed above. Do NOT analyze unmodified files or unrelated parts of the codebase.
- If NONE of the files in the diff scope are relevant to your review category (e.g. a UX/design reviewer with no UI/CSS/component files in scope, a security reviewer with no auth/network code in scope, an a11y reviewer with no markup changes), respond IMMEDIATELY with a single short approval line such as "No relevant changes in scope — approved." and STOP. Do not start exploring the codebase.
- Your wall-clock budget is short. Spending it browsing unmodified files will cause this step to time out and block merge.`;
const systemPrompt = `You are a workflow step agent executing: ${workflowStep.name}
Task Context:
@@ -5057,6 +5099,8 @@ Task Context:
- Task Description: ${task.description}
- Worktree: ${worktreePath}
${scopeBlock}
Your role:
- Execute this workflow step exactly as scoped.
- Prioritize high-impact correctness/risk findings over stylistic nits.

View File

@@ -0,0 +1,52 @@
import { notifyFallbackUsed } from "./notifier.js";
import type { FallbackModelUsedPayload } from "./pi.js";
type FallbackLogStore = {
logEntry?(taskId: string, action: string): Promise<unknown>;
appendAgentLog?(
taskId: string,
text: string,
type: "text" | "thinking" | "tool" | "tool_result" | "tool_error",
detail?: string,
agent?: string,
): Promise<unknown>;
};
type FallbackModelObserverOptions = {
agent: string;
label: string;
store?: FallbackLogStore;
taskId?: string;
taskTitle?: string;
};
function buildFallbackLogMessage(
label: string,
payload: FallbackModelUsedPayload,
): string {
return `[fallback] ${label} switched from ${payload.primaryModel} to ${payload.fallbackModel} (${payload.triggerPoint})`;
}
export function createFallbackModelObserver(options: FallbackModelObserverOptions) {
return async (payload: FallbackModelUsedPayload): Promise<void> => {
const taskId = options.taskId ?? payload.taskId;
const taskTitle = options.taskTitle ?? payload.taskTitle;
const message = buildFallbackLogMessage(options.label, payload);
if (taskId && options.store?.logEntry) {
await options.store.logEntry(taskId, message).catch(() => undefined);
}
if (taskId && options.store?.appendAgentLog) {
await options.store.appendAgentLog(taskId, message, "text", undefined, options.agent).catch(() => undefined);
}
await notifyFallbackUsed({
primaryModel: payload.primaryModel,
fallbackModel: payload.fallbackModel,
triggerPoint: payload.triggerPoint,
taskId,
taskTitle,
timestamp: payload.timestamp,
});
};
}

View File

@@ -139,6 +139,8 @@ export {
createResolvedAgentSession,
promptWithAutoRetry,
describeAgentModel,
extractRuntimeHint,
extractRuntimeModel,
type ResolvedSessionOptions,
type ResolvedSessionResult,
} from "./agent-session-helpers.js";

View File

@@ -49,7 +49,7 @@ import {
import { describeModel, promptWithFallback } from "./pi.js";
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
import { createResolvedAgentSession, extractRuntimeHint } from "./agent-session-helpers.js";
import { notifyFallbackUsed } from "./notifier.js";
import { createFallbackModelObserver } from "./fallback-model-observer.js";
import { buildSessionSkillContext } from "./session-skill-context.js";
import type { WorktreePool } from "./worktree-pool.js";
import { AgentLogger } from "./agent-logger.js";
@@ -570,9 +570,20 @@ Do not refactor, rename broadly, or make opportunistic improvements.
defaultModelId: settings.defaultProviderOverride && settings.defaultModelIdOverride
? settings.defaultModelIdOverride
: settings.defaultModelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
// Skill selection: use assigned agent skills if available, otherwise role fallback
...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
taskId,
taskTitle: taskForSkillContext?.title,
onFallbackModelUsed: createFallbackModelObserver({
agent: "merger",
label: "merge verification fix agent",
store,
taskId,
taskTitle: taskForSkillContext?.title,
}),
});
const runId = mergeRunContext?.runId;
@@ -1945,9 +1956,16 @@ You are assisting with a paused \`git pull --rebase\`.
defaultModelId: settings.defaultProviderOverride && settings.defaultModelIdOverride
? settings.defaultModelIdOverride
: settings.defaultModelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
taskId,
onFallbackModelUsed: notifyFallbackUsed,
onFallbackModelUsed: createFallbackModelObserver({
agent: "merger",
label: "rebase conflict resolver",
store,
taskId,
}),
});
const prompt = [
@@ -4453,9 +4471,20 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
defaultModelId: settings.defaultProviderOverride && settings.defaultModelIdOverride
? settings.defaultModelIdOverride
: settings.defaultModelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
// Skill selection: use assigned agent skills if available, otherwise role fallback
...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
taskId,
taskTitle: taskForSkillContext?.title,
onFallbackModelUsed: createFallbackModelObserver({
agent: "merger",
label: "merge agent",
store,
taskId,
taskTitle: taskForSkillContext?.title,
}),
});
options.onSession?.(session);
@@ -5041,6 +5070,13 @@ If issues are found that need attention, describe them clearly and include concr
defaultThinkingLevel: settings.defaultThinkingLevel,
// Skill selection: use assigned agent skills if available, otherwise role fallback
...(postMergeSkillContext?.skillSelectionContext ? { skillSelection: postMergeSkillContext.skillSelectionContext } : {}),
taskId,
onFallbackModelUsed: createFallbackModelObserver({
agent: "merger",
label: `post-merge workflow step '${workflowStep.name}'`,
store,
taskId,
}),
});
mergerLog.log(`${taskId}: [post-merge] workflow step '${workflowStep.name}' using model ${describeModel(session)}${useOverride ? " (workflow step override)" : ""}`);

View File

@@ -23,7 +23,7 @@ import type {
import { createFnAgent, promptWithFallback, type AgentResult } from "./pi.js";
import { createResolvedAgentSession, extractRuntimeHint } from "./agent-session-helpers.js";
import { createLogger } from "./logger.js";
import { notifyFallbackUsed } from "./notifier.js";
import { createFallbackModelObserver } from "./fallback-model-observer.js";
/** Logger for the mission execution loop subsystem. */
export const loopLog = createLogger("mission-loop");
@@ -360,7 +360,13 @@ export class MissionExecutionLoop extends EventEmitter {
},
taskId: task?.id,
taskTitle: task?.title,
onFallbackModelUsed: notifyFallbackUsed,
onFallbackModelUsed: createFallbackModelObserver({
agent: "reviewer",
label: "mission validator",
store: this.taskStore,
taskId: task?.id,
taskTitle: task?.title,
}),
});
session = { session: sessionResult.session, sessionFile: sessionResult.sessionFile };

View File

@@ -17,7 +17,7 @@ import { AgentLogger } from "./agent-logger.js";
import { reviewerLog } from "./logger.js";
import { checkSessionError } from "./usage-limit-detector.js";
import { resolveAgentInstructions, buildSystemPromptWithInstructions } from "./agent-instructions.js";
import { notifyFallbackUsed } from "./notifier.js";
import { createFallbackModelObserver } from "./fallback-model-observer.js";
import { createMemoryGetTool, createMemorySearchTool } from "./agent-tools.js";
export const REVIEWER_SYSTEM_PROMPT = `You are an independent code and plan reviewer.
@@ -493,7 +493,13 @@ export async function reviewStep(
...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
taskId: options.taskId,
taskTitle: options.taskTitle,
onFallbackModelUsed: notifyFallbackUsed,
onFallbackModelUsed: createFallbackModelObserver({
agent: "reviewer",
label: "reviewer",
store: options.store,
taskId: options.taskId,
taskTitle: options.taskTitle,
}),
beforeSpawnSession: async () => {
if (!options.store) return;
let finalSettings: Settings | undefined;

View File

@@ -30,7 +30,7 @@ import { AgentSemaphore } from "./concurrency.js";
import { StuckTaskDetector } from "./stuck-task-detector.js";
import { AgentLogger } from "./agent-logger.js";
import { createLogger } from "./logger.js";
import { notifyFallbackUsed } from "./notifier.js";
import { createFallbackModelObserver } from "./fallback-model-observer.js";
import { isContextLimitError } from "./context-limit-detector.js";
import { checkSessionError } from "./usage-limit-detector.js";
import {
@@ -1015,7 +1015,13 @@ Follow instructions precisely and avoid unrelated changes.`,
...(this.options.skillSelection ? { skillSelection: this.options.skillSelection } : {}),
taskId: taskDetail.id,
taskTitle: taskDetail.title,
onFallbackModelUsed: notifyFallbackUsed,
onFallbackModelUsed: createFallbackModelObserver({
agent: "executor",
label: "workflow step agent",
store: this.store,
taskId: taskDetail.id,
taskTitle: taskDetail.title,
}),
});
session = createResult.session;

View File

@@ -24,7 +24,7 @@ import { buildSessionSkillContext } from "./session-skill-context.js";
import { PRIORITY_SPECIFY, type AgentSemaphore } from "./concurrency.js";
import { AgentLogger } from "./agent-logger.js";
import { resolveAgentInstructions, buildSystemPromptWithInstructions } from "./agent-instructions.js";
import { notifyFallbackUsed } from "./notifier.js";
import { createFallbackModelObserver } from "./fallback-model-observer.js";
import { planLog, reviewerLog, formatError } from "./logger.js";
import {
isUsageLimitError,
@@ -1032,7 +1032,13 @@ export class TriageProcessor {
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
taskId: task.id,
taskTitle: task.title,
onFallbackModelUsed: notifyFallbackUsed,
onFallbackModelUsed: createFallbackModelObserver({
agent: "triage",
label: "triage",
store: this.store,
taskId: task.id,
taskTitle: task.title,
}),
});
const modelDesc = describeModel(session);
@@ -1232,7 +1238,13 @@ export class TriageProcessor {
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
taskId: task.id,
taskTitle: task.title,
onFallbackModelUsed: notifyFallbackUsed,
onFallbackModelUsed: createFallbackModelObserver({
agent: "triage",
label: "triage",
store: this.store,
taskId: task.id,
taskTitle: task.title,
}),
});
session = fallbackResult.session;