fix(dashboard): lazy-init useMobileKeyboard so remount has no stale render
When ChatView remounted (e.g. tab switch with keyboard still up), the hook started with keyboardOpen=false and corrected itself only after the effect ran. That single stale-state render briefly unhid the executor status bar, which appeared as a blank pane covering half the input box before the next state update settled it. useState initializers now call getKeyboardMetrics() lazily on first render so the very first paint already reflects the live keyboard state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/fn-3621-thinking-provider-compat.md
Normal file
5
.changeset/fn-3621-thinking-provider-compat.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix an engine compatibility bug where reviewer/triage/executor runs could fail when a provider extension rejected both `thinking` and `reasoning_effort` together. Fusion now retries without the explicit thinking-level override for that conflict instead of marking the run unavailable.
|
||||||
@@ -38,7 +38,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|
|||||||
| `defaultModelId` | `string` | `undefined` | Default AI model ID. |
|
| `defaultModelId` | `string` | `undefined` | Default AI model ID. |
|
||||||
| `fallbackProvider` | `string` | `undefined` | Fallback provider when the primary default model hits transient provider failures. |
|
| `fallbackProvider` | `string` | `undefined` | Fallback provider when the primary default model hits transient provider failures. |
|
||||||
| `fallbackModelId` | `string` | `undefined` | Fallback model ID (must pair with `fallbackProvider`). |
|
| `fallbackModelId` | `string` | `undefined` | Fallback model ID (must pair with `fallbackProvider`). |
|
||||||
| `defaultThinkingLevel` | `"off" \| "minimal" \| "low" \| "medium" \| "high"` | `undefined` | Default reasoning effort for AI sessions. |
|
| `defaultThinkingLevel` | `"off" \| "minimal" \| "low" \| "medium" \| "high"` | `undefined` | Default reasoning effort for AI sessions. If a provider/runtime rejects simultaneous `thinking` and `reasoning_effort` parameters, Fusion retries without the explicit thinking override instead of failing the run. |
|
||||||
| `ntfyEnabled` | `boolean` | `false` | Enable ntfy push notifications. |
|
| `ntfyEnabled` | `boolean` | `false` | Enable ntfy push notifications. |
|
||||||
| `ntfyTopic` | `string` | `undefined` | ntfy topic name. |
|
| `ntfyTopic` | `string` | `undefined` | ntfy topic name. |
|
||||||
| `ntfyBaseUrl` | `string` | `undefined` | Optional custom ntfy server base URL (must use `http://` or `https://`). If blank/unset, Fusion uses `https://ntfy.sh` for both runtime and test notifications. |
|
| `ntfyBaseUrl` | `string` | `undefined` | Optional custom ntfy server base URL (must use `http://` or `https://`). If blank/unset, Fusion uses `https://ntfy.sh` for both runtime and test notifications. |
|
||||||
|
|||||||
@@ -110,10 +110,23 @@ interface UseMobileKeyboardOptions {
|
|||||||
export function useMobileKeyboard(
|
export function useMobileKeyboard(
|
||||||
{ enabled = true }: UseMobileKeyboardOptions = {},
|
{ enabled = true }: UseMobileKeyboardOptions = {},
|
||||||
): { keyboardOverlap: number; viewportHeight: number | null; viewportOffsetTop: number; keyboardOpen: boolean } {
|
): { keyboardOverlap: number; viewportHeight: number | null; viewportOffsetTop: number; keyboardOpen: boolean } {
|
||||||
const [keyboardOverlap, setKeyboardOverlap] = useState(0);
|
// Lazy initial values: read the actual visualViewport on first render
|
||||||
const [viewportHeight, setViewportHeight] = useState<number | null>(null);
|
// so a remount (e.g. switching tabs back with the keyboard up) doesn't
|
||||||
const [viewportOffsetTop, setViewportOffsetTop] = useState(0);
|
// start with keyboardOpen=false. That stale-state render briefly hid
|
||||||
const [keyboardOpen, setKeyboardOpen] = useState(false);
|
// the .chat-thread compensation and unhid the executor status bar,
|
||||||
|
// which then reappeared as a blank pane covering half the composer
|
||||||
|
// before settling.
|
||||||
|
const initialMetrics = (): KeyboardMetrics => {
|
||||||
|
if (!enabled || typeof window === "undefined" || !isMobileDevice()) {
|
||||||
|
return { overlap: 0, open: false, vvHeight: null, vvOffsetTop: 0 };
|
||||||
|
}
|
||||||
|
return getKeyboardMetrics();
|
||||||
|
};
|
||||||
|
const [initial] = useState(initialMetrics);
|
||||||
|
const [keyboardOverlap, setKeyboardOverlap] = useState(initial.overlap);
|
||||||
|
const [viewportHeight, setViewportHeight] = useState<number | null>(initial.vvHeight);
|
||||||
|
const [viewportOffsetTop, setViewportOffsetTop] = useState(initial.vvOffsetTop);
|
||||||
|
const [keyboardOpen, setKeyboardOpen] = useState(initial.open);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || !isMobileDevice()) {
|
if (!enabled || !isMobileDevice()) {
|
||||||
|
|||||||
@@ -993,6 +993,36 @@ describe("createFnAgent", () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("continues session creation when setting thinking level hits reasoning conflict", async () => {
|
||||||
|
const { piLog } = await import("../logger.js");
|
||||||
|
const warnSpy = vi.spyOn(piLog, "warn").mockImplementation(() => {});
|
||||||
|
const setThinkingLevel = vi.fn(() => {
|
||||||
|
throw new Error("400 cannot specify both 'thinking' and 'reasoning_effort'");
|
||||||
|
});
|
||||||
|
|
||||||
|
createAgentSessionMock.mockResolvedValueOnce({
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn(),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
setThinkingLevel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createFnAgent } = await import("../pi.js");
|
||||||
|
|
||||||
|
await expect(createFnAgent({
|
||||||
|
cwd: "/tmp",
|
||||||
|
systemPrompt: "test",
|
||||||
|
tools: "readonly",
|
||||||
|
defaultThinkingLevel: "high",
|
||||||
|
})).resolves.toBeTruthy();
|
||||||
|
|
||||||
|
expect(setThinkingLevel).toHaveBeenCalledWith("high");
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Disabling explicit thinking level"));
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
describe("skill selection", () => {
|
describe("skill selection", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset modules to ensure fresh imports for each test
|
// Reset modules to ensure fresh imports for each test
|
||||||
|
|||||||
@@ -662,11 +662,53 @@ describe("session failure diagnostics", () => {
|
|||||||
await expect((session as any).promptWithFallback("Run task")).resolves.toBeUndefined();
|
await expect((session as any).promptWithFallback("Run task")).resolves.toBeUndefined();
|
||||||
|
|
||||||
expect(warnSpy).toHaveBeenCalledWith(
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("Failed to dispose session during model fallback swap: dispose failed"),
|
expect.stringContaining("Failed to dispose session during swap: dispose failed"),
|
||||||
);
|
);
|
||||||
|
|
||||||
warnSpy.mockRestore();
|
warnSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("retries prompt on thinking/reasoning conflict without switching fallback models", async () => {
|
||||||
|
const createAgentSessionMock = vi.mocked(createAgentSession);
|
||||||
|
|
||||||
|
const firstSession = {
|
||||||
|
model: { provider: "test", id: "primary-model" },
|
||||||
|
prompt: vi.fn().mockRejectedValue(new Error("400 cannot specify both 'thinking' and 'reasoning_effort'")),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
setThinkingLevel: vi.fn(),
|
||||||
|
sessionFile: undefined,
|
||||||
|
} as unknown as AgentSession;
|
||||||
|
|
||||||
|
const retrySession = {
|
||||||
|
model: { provider: "test", id: "primary-model" },
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
setThinkingLevel: vi.fn(),
|
||||||
|
sessionFile: undefined,
|
||||||
|
} as unknown as AgentSession;
|
||||||
|
|
||||||
|
createAgentSessionMock.mockReset();
|
||||||
|
createAgentSessionMock
|
||||||
|
.mockResolvedValueOnce({ session: firstSession } as any)
|
||||||
|
.mockResolvedValueOnce({ session: retrySession } as any);
|
||||||
|
|
||||||
|
const { session } = await createFnAgent({
|
||||||
|
cwd: "/test/project",
|
||||||
|
systemPrompt: "Test thinking compatibility",
|
||||||
|
defaultProvider: "test",
|
||||||
|
defaultModelId: "primary-model",
|
||||||
|
fallbackProvider: "test",
|
||||||
|
fallbackModelId: "fallback-model",
|
||||||
|
defaultThinkingLevel: "high",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect((session as any).promptWithFallback("Run review")).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(createAgentSessionMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect((retrySession.setThinkingLevel as any).mock.calls.length).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("piLog structured diagnostics", () => {
|
describe("piLog structured diagnostics", () => {
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ function clearSessionStateError(session: AgentSession): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isThinkingReasoningConflictError(message: string): boolean {
|
||||||
|
return /cannot specify both\s+['"]?thinking['"]?\s+and\s+['"]?reasoning_effort['"]?/i.test(message);
|
||||||
|
}
|
||||||
|
|
||||||
async function promptSessionAndCheck(session: AgentSession, prompt: string, options?: unknown): Promise<void> {
|
async function promptSessionAndCheck(session: AgentSession, prompt: string, options?: unknown): Promise<void> {
|
||||||
clearSessionStateError(session);
|
clearSessionStateError(session);
|
||||||
if (options === undefined) {
|
if (options === undefined) {
|
||||||
@@ -1283,21 +1287,83 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
|||||||
piLog.log("Fallback session created successfully");
|
piLog.log("Fallback session created successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { session } = sessionResult;
|
let activeSession = sessionResult.session;
|
||||||
installToolResultContentGuard(session as AgentToolHookSession);
|
installToolResultContentGuard(activeSession as AgentToolHookSession);
|
||||||
installMessageContentGuard(session as AgentToolHookSession, sessionManager as unknown as SessionManagerLike);
|
installMessageContentGuard(activeSession as AgentToolHookSession, sessionManager as unknown as SessionManagerLike);
|
||||||
(session as any).__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === FN_MEMORY_APPEND_TOOL_NAME) === true;
|
(activeSession as any).__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === FN_MEMORY_APPEND_TOOL_NAME) === true;
|
||||||
const promptableSession = session as PromptableSession;
|
const promptableSession = activeSession as PromptableSession;
|
||||||
|
|
||||||
|
let thinkingCompatibilityDisabled = false;
|
||||||
|
const applyThinkingLevelIfSupported = (targetSession: AgentSession, sourceModel: string): void => {
|
||||||
|
if (!options.defaultThinkingLevel || thinkingCompatibilityDisabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
(targetSession as PromptableSession).setThinkingLevel(options.defaultThinkingLevel as any);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (!isThinkingReasoningConflictError(message)) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
thinkingCompatibilityDisabled = true;
|
||||||
|
piLog.warn(`Disabling explicit thinking level for model ${sourceModel}: ${message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const wireFallbackHooks = (targetSession: PromptableSession): void => {
|
||||||
|
installToolResultContentGuard(targetSession as unknown as AgentToolHookSession);
|
||||||
|
installMessageContentGuard(
|
||||||
|
targetSession as unknown as AgentToolHookSession,
|
||||||
|
sessionManager as unknown as SessionManagerLike,
|
||||||
|
);
|
||||||
|
(targetSession as any).__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === FN_MEMORY_APPEND_TOOL_NAME) === true;
|
||||||
|
targetSession.subscribe((event) => {
|
||||||
|
if (event.type === "message_update") {
|
||||||
|
const msgEvent = event.assistantMessageEvent;
|
||||||
|
if (msgEvent.type === "text_delta") {
|
||||||
|
options.onText?.(msgEvent.delta);
|
||||||
|
} else if (msgEvent.type === "thinking_delta") {
|
||||||
|
options.onThinking?.(msgEvent.delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.type === "tool_execution_start") {
|
||||||
|
options.onToolStart?.(event.toolName, event.args as Record<string, unknown> | undefined);
|
||||||
|
}
|
||||||
|
if (event.type === "tool_execution_end") {
|
||||||
|
options.onToolEnd?.(event.toolName, event.isError, event.result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const swapPromptSession = async (modelToUse: typeof selectedModel): Promise<PromptableSession> => {
|
||||||
|
if (!modelToUse) {
|
||||||
|
throw new Error("Cannot swap session without a resolved model");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
activeSession.dispose();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
piLog.warn(`Failed to dispose session during swap: ${msg}`);
|
||||||
|
}
|
||||||
|
const next = (await createSessionWithModel(modelToUse)).session as PromptableSession;
|
||||||
|
wireFallbackHooks(next);
|
||||||
|
applyThinkingLevelIfSupported(next, `${modelToUse.provider}/${modelToUse.id}`);
|
||||||
|
Object.setPrototypeOf(promptableSession, Object.getPrototypeOf(next));
|
||||||
|
Object.assign(promptableSession, next);
|
||||||
|
promptableSession.promptWithFallback = next.promptWithFallback ?? promptableSession.promptWithFallback;
|
||||||
|
activeSession = next;
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
promptableSession.promptWithFallback = async (prompt: string, promptOptions?: unknown) => {
|
promptableSession.promptWithFallback = async (prompt: string, promptOptions?: unknown) => {
|
||||||
try {
|
try {
|
||||||
await promptSessionAndCheck(session, prompt, promptOptions);
|
await promptSessionAndCheck(activeSession, prompt, promptOptions);
|
||||||
return;
|
return;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err?.message || "";
|
const errorMessage = err?.message || "";
|
||||||
if (isContextLimitError(errorMessage)) {
|
if (isContextLimitError(errorMessage)) {
|
||||||
// Context limit error — attempt auto-compaction and retry once
|
// Context limit error — attempt auto-compaction and retry once
|
||||||
const promptMemoryRetry = await retryWithCompactedPromptMemory(session, prompt, promptOptions);
|
const promptMemoryRetry = await retryWithCompactedPromptMemory(activeSession, prompt, promptOptions);
|
||||||
if (promptMemoryRetry.recovered) {
|
if (promptMemoryRetry.recovered) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1309,12 +1375,12 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
|||||||
}
|
}
|
||||||
|
|
||||||
piLog.warn("promptWithFallback: context limit error — attempting auto-compaction");
|
piLog.warn("promptWithFallback: context limit error — attempting auto-compaction");
|
||||||
await flushMemoryBeforeSessionCompaction(session);
|
await flushMemoryBeforeSessionCompaction(activeSession);
|
||||||
const compactResult = await compactSessionContext(session);
|
const compactResult = await compactSessionContext(activeSession);
|
||||||
if (compactResult) {
|
if (compactResult) {
|
||||||
piLog.log(`promptWithFallback: compaction succeeded (${compactResult.tokensBefore} tokens) — retrying prompt`);
|
piLog.log(`promptWithFallback: compaction succeeded (${compactResult.tokensBefore} tokens) — retrying prompt`);
|
||||||
try {
|
try {
|
||||||
await promptSessionAndCheck(session, prompt, promptOptions);
|
await promptSessionAndCheck(activeSession, prompt, promptOptions);
|
||||||
return;
|
return;
|
||||||
} catch (retryErr: any) {
|
} catch (retryErr: any) {
|
||||||
const retryErrorMessage = retryErr?.message || "";
|
const retryErrorMessage = retryErr?.message || "";
|
||||||
@@ -1328,52 +1394,21 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!usingFallback && options.defaultThinkingLevel && !thinkingCompatibilityDisabled && isThinkingReasoningConflictError(errorMessage)) {
|
||||||
|
thinkingCompatibilityDisabled = true;
|
||||||
|
piLog.warn(`Prompt failed with thinking/reasoning conflict; retrying without explicit thinking level: ${errorMessage}`);
|
||||||
|
const recoveredSession = await swapPromptSession(selectedModel);
|
||||||
|
await promptSessionAndCheck(recoveredSession, prompt, promptOptions);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!fallbackModel || usingFallback || !isRetryableModelSelectionError(errorMessage)) {
|
if (!fallbackModel || usingFallback || !isRetryableModelSelectionError(errorMessage)) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
usingFallback = true;
|
usingFallback = true;
|
||||||
try {
|
const fallbackSession = await swapPromptSession(fallbackModel);
|
||||||
session.dispose();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
piLog.warn(`Failed to dispose session during model fallback swap: ${msg}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackSessionResult = await createSessionWithModel(fallbackModel);
|
|
||||||
await emitFallbackUsed("prompt-time");
|
await emitFallbackUsed("prompt-time");
|
||||||
const fallbackSession = fallbackSessionResult.session as PromptableSession;
|
|
||||||
installToolResultContentGuard(fallbackSession as unknown as AgentToolHookSession);
|
|
||||||
installMessageContentGuard(
|
|
||||||
fallbackSession as unknown as AgentToolHookSession,
|
|
||||||
sessionManager as unknown as SessionManagerLike,
|
|
||||||
);
|
|
||||||
(fallbackSession as any).__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === FN_MEMORY_APPEND_TOOL_NAME) === true;
|
|
||||||
|
|
||||||
if (options.defaultThinkingLevel) {
|
|
||||||
fallbackSession.setThinkingLevel(options.defaultThinkingLevel as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
fallbackSession.subscribe((event) => {
|
|
||||||
if (event.type === "message_update") {
|
|
||||||
const msgEvent = event.assistantMessageEvent;
|
|
||||||
if (msgEvent.type === "text_delta") {
|
|
||||||
options.onText?.(msgEvent.delta);
|
|
||||||
} else if (msgEvent.type === "thinking_delta") {
|
|
||||||
options.onThinking?.(msgEvent.delta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (event.type === "tool_execution_start") {
|
|
||||||
options.onToolStart?.(event.toolName, event.args as Record<string, unknown> | undefined);
|
|
||||||
}
|
|
||||||
if (event.type === "tool_execution_end") {
|
|
||||||
options.onToolEnd?.(event.toolName, event.isError, event.result);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.setPrototypeOf(promptableSession, Object.getPrototypeOf(fallbackSession));
|
|
||||||
Object.assign(promptableSession, fallbackSession);
|
|
||||||
promptableSession.promptWithFallback = fallbackSession.promptWithFallback ?? promptableSession.promptWithFallback;
|
|
||||||
|
|
||||||
// Retry with fallback model, also with auto-compaction support
|
// Retry with fallback model, also with auto-compaction support
|
||||||
try {
|
try {
|
||||||
@@ -1416,10 +1451,11 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply thinking level if specified
|
// Apply thinking level if specified (with compatibility fallback).
|
||||||
if (options.defaultThinkingLevel) {
|
applyThinkingLevelIfSupported(
|
||||||
promptableSession.setThinkingLevel(options.defaultThinkingLevel as any);
|
promptableSession,
|
||||||
}
|
selectedModel ? `${selectedModel.provider}/${selectedModel.id}` : describeModel(promptableSession),
|
||||||
|
);
|
||||||
|
|
||||||
// Wire up event listeners
|
// Wire up event listeners
|
||||||
promptableSession.subscribe((event) => {
|
promptableSession.subscribe((event) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user