Revert "fix(dashboard): lazy-init useMobileKeyboard so remount has no stale render"
This reverts commit eb111f526e.
This commit is contained in:
@@ -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. If a provider/runtime rejects simultaneous `thinking` and `reasoning_effort` parameters, Fusion retries without the explicit thinking override instead of failing the run. |
|
| `defaultThinkingLevel` | `"off" \| "minimal" \| "low" \| "medium" \| "high"` | `undefined` | Default reasoning effort for AI sessions. |
|
||||||
| `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,23 +110,10 @@ 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 } {
|
||||||
// Lazy initial values: read the actual visualViewport on first render
|
const [keyboardOverlap, setKeyboardOverlap] = useState(0);
|
||||||
// so a remount (e.g. switching tabs back with the keyboard up) doesn't
|
const [viewportHeight, setViewportHeight] = useState<number | null>(null);
|
||||||
// start with keyboardOpen=false. That stale-state render briefly hid
|
const [viewportOffsetTop, setViewportOffsetTop] = useState(0);
|
||||||
// the .chat-thread compensation and unhid the executor status bar,
|
const [keyboardOpen, setKeyboardOpen] = useState(false);
|
||||||
// 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,36 +993,6 @@ 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,53 +662,11 @@ 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 swap: dispose failed"),
|
expect.stringContaining("Failed to dispose session during model fallback 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,10 +130,6 @@ 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) {
|
||||||
@@ -1287,83 +1283,21 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
|||||||
piLog.log("Fallback session created successfully");
|
piLog.log("Fallback session created successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
let activeSession = sessionResult.session;
|
const { session } = sessionResult;
|
||||||
installToolResultContentGuard(activeSession as AgentToolHookSession);
|
installToolResultContentGuard(session as AgentToolHookSession);
|
||||||
installMessageContentGuard(activeSession as AgentToolHookSession, sessionManager as unknown as SessionManagerLike);
|
installMessageContentGuard(session as AgentToolHookSession, sessionManager as unknown as SessionManagerLike);
|
||||||
(activeSession as any).__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === FN_MEMORY_APPEND_TOOL_NAME) === true;
|
(session as any).__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === FN_MEMORY_APPEND_TOOL_NAME) === true;
|
||||||
const promptableSession = activeSession as PromptableSession;
|
const promptableSession = session 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(activeSession, prompt, promptOptions);
|
await promptSessionAndCheck(session, 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(activeSession, prompt, promptOptions);
|
const promptMemoryRetry = await retryWithCompactedPromptMemory(session, prompt, promptOptions);
|
||||||
if (promptMemoryRetry.recovered) {
|
if (promptMemoryRetry.recovered) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1375,12 +1309,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(activeSession);
|
await flushMemoryBeforeSessionCompaction(session);
|
||||||
const compactResult = await compactSessionContext(activeSession);
|
const compactResult = await compactSessionContext(session);
|
||||||
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(activeSession, prompt, promptOptions);
|
await promptSessionAndCheck(session, prompt, promptOptions);
|
||||||
return;
|
return;
|
||||||
} catch (retryErr: any) {
|
} catch (retryErr: any) {
|
||||||
const retryErrorMessage = retryErr?.message || "";
|
const retryErrorMessage = retryErr?.message || "";
|
||||||
@@ -1394,21 +1328,52 @@ 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;
|
||||||
const fallbackSession = await swapPromptSession(fallbackModel);
|
try {
|
||||||
|
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 {
|
||||||
@@ -1451,11 +1416,10 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply thinking level if specified (with compatibility fallback).
|
// Apply thinking level if specified
|
||||||
applyThinkingLevelIfSupported(
|
if (options.defaultThinkingLevel) {
|
||||||
promptableSession,
|
promptableSession.setThinkingLevel(options.defaultThinkingLevel as any);
|
||||||
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