fix(FN-XXX): unify codex auth and chat fallback
This commit is contained in:
27
packages/cli/src/__tests__/dev-with-memory-lib.test.ts
Normal file
27
packages/cli/src/__tests__/dev-with-memory-lib.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
101
packages/core/src/__tests__/oauth-credential-interop.test.ts
Normal file
101
packages/core/src/__tests__/oauth-credential-interop.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
243
packages/core/src/oauth-credential-interop.ts
Normal file
243
packages/core/src/oauth-credential-interop.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
@@ -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.`,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
39
packages/dashboard/app/components/OAuthManualCodeForm.css
Normal file
39
packages/dashboard/app/components/OAuthManualCodeForm.css
Normal 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);
|
||||
}
|
||||
51
packages/dashboard/app/components/OAuthManualCodeForm.tsx
Normal file
51
packages/dashboard/app/components/OAuthManualCodeForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ChatManager,
|
||||
__setBuildAgentChatPrompt,
|
||||
__setCreateFnAgent,
|
||||
__setCreateResolvedAgentSession,
|
||||
__resetChatState,
|
||||
chatStreamManager,
|
||||
__getChatDiagnostics,
|
||||
@@ -62,8 +63,23 @@ const mockAgentStore = {
|
||||
listAgents: vi.fn(),
|
||||
};
|
||||
|
||||
function createChatManager(): ChatManager {
|
||||
return new ChatManager(mockChatStore as any, "/tmp/test", mockAgentStore as any);
|
||||
function createChatManager(pluginRunner?: Record<string, unknown>): ChatManager {
|
||||
return new ChatManager(mockChatStore as any, "/tmp/test", mockAgentStore as any, pluginRunner as any);
|
||||
}
|
||||
|
||||
function createChatManagerWithSettings(settings: {
|
||||
fallbackProvider?: string;
|
||||
fallbackModelId?: string;
|
||||
defaultProvider?: string;
|
||||
defaultModelId?: string;
|
||||
}): ChatManager {
|
||||
return new ChatManager(
|
||||
mockChatStore as any,
|
||||
"/tmp/test",
|
||||
mockAgentStore as any,
|
||||
undefined,
|
||||
async () => settings,
|
||||
);
|
||||
}
|
||||
|
||||
function createChatManagerWithoutAgentStore(): ChatManager {
|
||||
@@ -99,6 +115,7 @@ describe("ChatManager.sendMessage", () => {
|
||||
soul: "Be calm and precise.",
|
||||
memory: "Remember to keep test coverage high.",
|
||||
instructionsText: "Keep replies focused.",
|
||||
runtimeConfig: {},
|
||||
});
|
||||
mockAgentStore.listAgents.mockResolvedValue([
|
||||
{
|
||||
@@ -530,6 +547,214 @@ describe("ChatManager.sendMessage", () => {
|
||||
expect(assistantCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("surfaces provider errors stored on session.state.errorMessage instead of persisting a blank assistant reply", async () => {
|
||||
const events: Array<{ type: string; data: unknown }> = [];
|
||||
const unsubscribe = chatStreamManager.subscribe("chat-001", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
__setCreateFnAgent(async () => {
|
||||
const session = {
|
||||
prompt: vi.fn().mockImplementation(async function (this: any) {
|
||||
this.state.errorMessage = "Codex error: provider request failed";
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
state: { messages: [] as unknown[], errorMessage: undefined as string | undefined },
|
||||
};
|
||||
return { session };
|
||||
});
|
||||
|
||||
const chatManager = createChatManager();
|
||||
await chatManager.sendMessage("chat-001", "Hello");
|
||||
unsubscribe();
|
||||
|
||||
const assistantCalls = mockChatStore.addMessage.mock.calls.filter((call) => call[1].role === "assistant");
|
||||
expect(assistantCalls).toHaveLength(0);
|
||||
expect(events).toContainEqual({ type: "error", data: "Codex error: provider request failed" });
|
||||
});
|
||||
|
||||
it("uses the agent runtime path when the agent has a runtimeHint configured", async () => {
|
||||
const createResolvedSession = vi.fn(async () => ({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [{ role: "assistant", content: "Runtime response" }],
|
||||
},
|
||||
},
|
||||
}));
|
||||
__setCreateResolvedAgentSession(createResolvedSession as any);
|
||||
|
||||
mockAgentStore.getAgent.mockResolvedValue({
|
||||
id: "agent-001",
|
||||
name: "Avery",
|
||||
role: "executor",
|
||||
soul: "Be calm and precise.",
|
||||
memory: "Remember to keep test coverage high.",
|
||||
instructionsText: "Keep replies focused.",
|
||||
runtimeConfig: {
|
||||
runtimeHint: "openclaw",
|
||||
},
|
||||
});
|
||||
|
||||
const pluginRunner = {
|
||||
getRuntimeById: vi.fn(),
|
||||
createRuntimeContext: vi.fn(),
|
||||
};
|
||||
const chatManager = createChatManager(pluginRunner);
|
||||
|
||||
await chatManager.sendMessage("chat-001", "Hello");
|
||||
|
||||
expect(createResolvedSession).toHaveBeenCalledWith(expect.objectContaining({
|
||||
sessionPurpose: "executor",
|
||||
runtimeHint: "openclaw",
|
||||
pluginRunner,
|
||||
}));
|
||||
});
|
||||
|
||||
it("uses the assigned built-in pi agent model when the chat session has no explicit model override", async () => {
|
||||
let createOptions: any;
|
||||
|
||||
__setCreateFnAgent(async (options: any) => {
|
||||
createOptions = options;
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [{ role: "assistant", content: "Model response" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
mockAgentStore.getAgent.mockResolvedValue({
|
||||
id: "agent-001",
|
||||
name: "Avery",
|
||||
role: "executor",
|
||||
soul: "Be calm and precise.",
|
||||
memory: "Remember to keep test coverage high.",
|
||||
instructionsText: "Keep replies focused.",
|
||||
runtimeConfig: {
|
||||
model: "minimax/MiniMax-M2.7-highspeed",
|
||||
},
|
||||
});
|
||||
|
||||
const chatManager = createChatManager();
|
||||
await chatManager.sendMessage("chat-001", "Hello");
|
||||
|
||||
expect(createOptions.defaultProvider).toBe("minimax");
|
||||
expect(createOptions.defaultModelId).toBe("MiniMax-M2.7-highspeed");
|
||||
});
|
||||
|
||||
it("allows fallback for default-model chat and persists the fallback metadata", async () => {
|
||||
const events: Array<{ type: string; data: unknown }> = [];
|
||||
const unsubscribe = chatStreamManager.subscribe("chat-001", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
mockChatStore.getSession.mockReturnValue({
|
||||
id: "chat-001",
|
||||
agentId: "agent-001",
|
||||
status: "active",
|
||||
title: "Default Codex Chat",
|
||||
modelProvider: "openai-codex",
|
||||
modelId: "gpt-5.3-codex",
|
||||
});
|
||||
|
||||
let createOptions: any;
|
||||
__setCreateFnAgent(async (options: any) => {
|
||||
createOptions = options;
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockImplementation(async function (this: any) {
|
||||
await options.onFallbackModelUsed?.({
|
||||
primaryModel: "openai-codex/gpt-5.3-codex",
|
||||
fallbackModel: "zai/glm-5.1",
|
||||
triggerPoint: "prompt-time",
|
||||
});
|
||||
this.state.messages = [{ role: "assistant", content: "Fallback reply" }];
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
state: { messages: [] as Array<{ role: string; content: string }> },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const chatManager = createChatManagerWithSettings({
|
||||
defaultProvider: "openai-codex",
|
||||
defaultModelId: "gpt-5.3-codex",
|
||||
fallbackProvider: "zai",
|
||||
fallbackModelId: "glm-5.1",
|
||||
});
|
||||
|
||||
await chatManager.sendMessage("chat-001", "Hello");
|
||||
unsubscribe();
|
||||
|
||||
expect(createOptions.fallbackProvider).toBe("zai");
|
||||
expect(createOptions.fallbackModelId).toBe("glm-5.1");
|
||||
expect(mockChatStore.updateSession).toHaveBeenCalledWith("chat-001", {
|
||||
modelProvider: "zai",
|
||||
modelId: "glm-5.1",
|
||||
});
|
||||
expect(events).toContainEqual({
|
||||
type: "fallback",
|
||||
data: {
|
||||
primaryModel: "openai-codex/gpt-5.3-codex",
|
||||
fallbackModel: "zai/glm-5.1",
|
||||
triggerPoint: "prompt-time",
|
||||
},
|
||||
});
|
||||
|
||||
const assistantCall = mockChatStore.addMessage.mock.calls.find((call) => call[1].role === "assistant");
|
||||
expect(assistantCall?.[1]).toEqual(expect.objectContaining({
|
||||
metadata: {
|
||||
fallback: {
|
||||
primaryModel: "openai-codex/gpt-5.3-codex",
|
||||
fallbackModel: "zai/glm-5.1",
|
||||
triggerPoint: "prompt-time",
|
||||
},
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it("does not allow fallback when the chat session has a specific non-default model selected", async () => {
|
||||
let createOptions: any;
|
||||
mockChatStore.getSession.mockReturnValue({
|
||||
id: "chat-001",
|
||||
agentId: "agent-001",
|
||||
status: "active",
|
||||
title: "Explicit Model Chat",
|
||||
modelProvider: "openai-codex",
|
||||
modelId: "gpt-5.3-codex",
|
||||
});
|
||||
|
||||
__setCreateFnAgent(async (options: any) => {
|
||||
createOptions = options;
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockImplementation(async function (this: any) {
|
||||
this.state.messages = [{ role: "assistant", content: "Primary reply" }];
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
state: { messages: [] as Array<{ role: string; content: string }> },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const chatManager = createChatManagerWithSettings({
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-sonnet-4-5",
|
||||
fallbackProvider: "zai",
|
||||
fallbackModelId: "glm-5.1",
|
||||
});
|
||||
|
||||
await chatManager.sendMessage("chat-001", "Hello");
|
||||
|
||||
expect(createOptions.fallbackProvider).toBeUndefined();
|
||||
expect(createOptions.fallbackModelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("persists thinking output even when no text was generated", async () => {
|
||||
__setCreateFnAgent(async (options: any) => {
|
||||
return {
|
||||
|
||||
@@ -520,6 +520,7 @@ function createMockAuthStorage(overrides: Partial<AuthStorageLike> = {}): AuthSt
|
||||
{ id: "anthropic", name: "Anthropic" },
|
||||
]),
|
||||
hasAuth: vi.fn().mockReturnValue(false),
|
||||
get: vi.fn().mockReturnValue(undefined),
|
||||
login: vi.fn().mockImplementation((_provider: string, callbacks: any) => {
|
||||
// Simulate onAuth callback with a URL, then resolve
|
||||
callbacks.onAuth({ url: "https://auth.example.com/login", instructions: "Open in browser" });
|
||||
@@ -605,6 +606,21 @@ describe("GET /auth/status", () => {
|
||||
expect(res.body.providers[0].authenticated).toBe(false);
|
||||
});
|
||||
|
||||
it("treats expired oauth credentials as unauthenticated", async () => {
|
||||
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
(authStorage.get as ReturnType<typeof vi.fn>).mockImplementation((provider: string) =>
|
||||
provider === "anthropic"
|
||||
? { type: "oauth", access: "token", refresh: "refresh", expires: Date.now() - 1_000 }
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const res = await GET(buildApp(), "/api/auth/status");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const anthropic = res.body.providers.find((p: any) => p.id === "anthropic");
|
||||
expect(anthropic.authenticated).toBe(false);
|
||||
});
|
||||
|
||||
it("reports loginInProgress for oauth providers with active logins", async () => {
|
||||
let releaseLogin: (() => void) | undefined;
|
||||
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
@@ -1048,6 +1064,35 @@ describe("POST /auth/login", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.url).toBe(unchangedUrl);
|
||||
});
|
||||
|
||||
it("does not rewrite redirect_uri for openai-codex even on non-localhost origins", async () => {
|
||||
const unchangedUrl =
|
||||
"https://auth.openai.com/oauth/authorize?state=test-state&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback";
|
||||
|
||||
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ id: "openai-codex", name: "OpenAI Codex" },
|
||||
]);
|
||||
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation((_provider: string, callbacks: any) => {
|
||||
callbacks.onAuth({ url: unchangedUrl });
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/auth/login",
|
||||
JSON.stringify({ provider: "openai-codex", origin: "https://my-host.example.com" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.url).toBe(unchangedUrl);
|
||||
expect(res.body.manualCode).toEqual({
|
||||
prompt: "Paste the final redirect URL or authorization code",
|
||||
placeholder: "http://localhost:1455/auth/callback?code=...&state=... or just the code",
|
||||
helpText: "After sign-in, OpenAI may redirect to a localhost callback that cannot open from this dashboard host. Copy the full browser URL from the address bar and paste it here.",
|
||||
});
|
||||
});
|
||||
it("returns 400 when provider is missing", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
@@ -1180,6 +1225,83 @@ describe("POST /auth/cancel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /auth/manual-code", () => {
|
||||
let store: TaskStore;
|
||||
let authStorage: AuthStorageLike;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
authStorage = createMockAuthStorage({
|
||||
getOAuthProviders: vi.fn().mockReturnValue([{ id: "openai-codex", name: "OpenAI Codex" }]),
|
||||
});
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { authStorage }));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("submits pasted manual code into an active login", async () => {
|
||||
let submittedCode: string | undefined;
|
||||
let releaseLogin: (() => void) | undefined;
|
||||
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
async (_provider: string, callbacks: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
}) => {
|
||||
callbacks.onAuth({
|
||||
url: "https://auth.openai.com/oauth/authorize?state=test-state&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback",
|
||||
});
|
||||
submittedCode = await callbacks.onManualCodeInput?.();
|
||||
releaseLogin?.();
|
||||
},
|
||||
);
|
||||
|
||||
const app = buildApp();
|
||||
const loginRes = await REQUEST(
|
||||
app,
|
||||
"POST",
|
||||
"/api/auth/login",
|
||||
JSON.stringify({ provider: "openai-codex", origin: "https://remote.example.com" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(loginRes.status).toBe(200);
|
||||
|
||||
const submitRes = await REQUEST(
|
||||
app,
|
||||
"POST",
|
||||
"/api/auth/manual-code",
|
||||
JSON.stringify({
|
||||
provider: "openai-codex",
|
||||
code: "http://localhost:1455/auth/callback?code=test-code&state=test-state",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(submitRes.status).toBe(200);
|
||||
expect(submitRes.body).toEqual({ success: true, submitted: true });
|
||||
await vi.waitFor(() => {
|
||||
expect(submittedCode).toBe("http://localhost:1455/auth/callback?code=test-code&state=test-state");
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 409 when no login is in progress", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/auth/manual-code",
|
||||
JSON.stringify({ provider: "openai-codex", code: "test-code" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toBe("No login in progress for openai-codex");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /auth/oauth-callback", () => {
|
||||
let store: TaskStore;
|
||||
let authStorage: AuthStorageLike;
|
||||
@@ -2989,4 +3111,3 @@ describe("Pause/Unpause endpoints", () => {
|
||||
});
|
||||
|
||||
// --- GitHub Import route tests ---
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
52
packages/engine/src/fallback-model-observer.ts
Normal file
52
packages/engine/src/fallback-model-observer.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -139,6 +139,8 @@ export {
|
||||
createResolvedAgentSession,
|
||||
promptWithAutoRetry,
|
||||
describeAgentModel,
|
||||
extractRuntimeHint,
|
||||
extractRuntimeModel,
|
||||
type ResolvedSessionOptions,
|
||||
type ResolvedSessionResult,
|
||||
} from "./agent-session-helpers.js";
|
||||
|
||||
@@ -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)" : ""}`);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user