diff --git a/desktop/src/app/communityViewTransition.test.mjs b/desktop/src/app/communityViewTransition.test.mjs index 4e2c6db6d57..72fb58282f3 100644 --- a/desktop/src/app/communityViewTransition.test.mjs +++ b/desktop/src/app/communityViewTransition.test.mjs @@ -48,6 +48,43 @@ test("unsupported browsers execute the update and contain rejection", async () = assert.equal(error.mock.calls[0].arguments[1], expected); }); +test("linux webkit skips startViewTransition entirely (fixes #3931)", async () => { + // Even though startViewTransition is "supported" on webkitgtk, it hangs + // indefinitely on `transition.updateCallbackDone` when a destructive + // community switch removes the painted frame — freezing Linux users' + // windows. The Linux branch of `runCommunityViewTransition` must skip + // the transition API and run the update directly, regardless of + // startViewTransition capability. + const originalNavigatorDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + "navigator", + ); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { platform: "Linux x86_64", userAgent: "Buzz/0.5 webkitgtk" }, + writable: true, + }); + try { + const platformCalls = []; + const transitionCalls = []; + installBrowser((callback) => { + transitionCalls.push("startViewTransition"); + return transitionFor(callback); + }); + await runCommunityViewTransition(() => { + platformCalls.push("update"); + }); + assert.deepEqual(platformCalls, ["update"]); + assert.deepEqual(transitionCalls, []); + } finally { + if (originalNavigatorDescriptor) { + Object.defineProperty(globalThis, "navigator", originalNavigatorDescriptor); + } else { + delete globalThis.navigator; + } + } +}); + test("supported transitions wait for target readiness", async () => { let updateFinished = false; let transitionFinished = false; diff --git a/desktop/src/app/communityViewTransition.ts b/desktop/src/app/communityViewTransition.ts index 1bbc395f9d0..e256fbb1b6a 100644 --- a/desktop/src/app/communityViewTransition.ts +++ b/desktop/src/app/communityViewTransition.ts @@ -1,3 +1,5 @@ +import { isLinuxPlatform } from "@/shared/lib/platform"; + const COMMUNITY_TRANSITION_TIMEOUT_MS = 5_000; let finishPendingTransition: (() => void) | null = null; @@ -17,7 +19,16 @@ export async function runCommunityViewTransition( update: () => Promise | void, options: { timeoutMs?: number } = {}, ): Promise { - if (!document.startViewTransition) { + // Linux WebKit (webkitgtk, as shipped by the buzz AppImage on distributions + // like Linux Mint) hangs indefinitely on `transition.updateCallbackDone` + // whenever a destructive community switch removes the currently painted + // frame mid-transition — the reasoning is not the 5s timeout, which does + // resolve `targetReady` and lets `update()` finish; the WebKit layer just + // never settles the view-transition promise on frame invalidation. That + // freezes the window and forces users to SIGKILL the app (see #3931). + // The transition is purely cosmetic; on Linux we lose nothing by running + // the update directly and skipping the browser's cross-fade. + if (!document.startViewTransition || isLinuxPlatform()) { try { await update(); } catch (error) { diff --git a/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs b/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs index 597b1b9323e..f83df5eabf1 100644 --- a/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs +++ b/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs @@ -18,9 +18,11 @@ test("empty/whitespace lastError → null", () => { assert.equal(friendlyAgentLastError(" "), null); }); -test("buzz-acp wrapped auth failure → denied copy", () => { +test("buzz-acp wrapped auth failure (legacy string, mesh provider) → denied copy", () => { const result = friendlyAgentLastError( "Agent reported error: llm auth: 401 unauthorized: ...", + null, + "relay-mesh", ); assert.deepEqual(result, { severity: "denied", @@ -28,11 +30,15 @@ test("buzz-acp wrapped auth failure → denied copy", () => { }); }); -test("unwrapped buzz-agent prefix → denied copy", () => { +test("unwrapped buzz-agent prefix (legacy string, mesh provider) → denied copy", () => { // buzz-agent's AgentError::LlmAuth Display is "llm auth: "; if the // desktop ever picks that up directly (no AcpError wrapper), we should - // still recognize it as denial. - const result = friendlyAgentLastError("llm auth: 403 forbidden"); + // still recognize it as denial when the agent is a mesh agent. + const result = friendlyAgentLastError( + "llm auth: 403 forbidden", + null, + "relay-mesh", + ); assert.deepEqual(result, { severity: "denied", copy: RELAY_MESH_DENIED_COPY, @@ -50,6 +56,8 @@ test("generic harness exit message → passthrough", () => { test("trims whitespace before matching", () => { const result = friendlyAgentLastError( " Agent reported error: llm auth: nope\n", + null, + "relay-mesh", ); assert.equal(result?.severity, "denied"); assert.equal(result?.copy, RELAY_MESH_DENIED_COPY); @@ -89,18 +97,45 @@ test("code -32002 → model-not-found copy (severity: denied)", () => { }); }); -test("code -32001 → Buzz shared compute denied copy (structured path)", () => { - const result = friendlyAgentLastError("any error text", -32001); +test("code -32001 → Buzz shared compute denied copy (structured path, mesh)", () => { + const result = friendlyAgentLastError("any error text", -32001, "relay-mesh"); assert.deepEqual(result, { severity: "denied", copy: RELAY_MESH_DENIED_COPY, }); }); -test("code null falls through to legacy string matching", () => { +test("code -32001 non-mesh provider → raw message preserved", () => { + // Regression for #4205: a Local-OpenAI-compatible agent whose llama.cpp + // 401s must see the underlying message (e.g. "multi-user-authorization"), + // not "Community access denied". + const result = friendlyAgentLastError( + "Agent reported error (code -32001): llm auth: 401 Unauthorized: {\"message\":\"multi-user-authorization\"}", + -32001, + "openai", + ); + assert.deepEqual(result, { + severity: "generic", + copy: "Agent reported error (code -32001): llm auth: 401 Unauthorized: {\"message\":\"multi-user-authorization\"}", + }); +}); + +test("code -32001 null provider → raw message (conservative)", () => { + // When provider is absent (older record), never substitute the mesh copy — + // mislabeling a non-mesh 401 is worse than showing more detail to a mesh + // user. + const result = friendlyAgentLastError("any error text", -32001, null); + assert.deepEqual(result, { + severity: "generic", + copy: "any error text", + }); +}); + +test("code null falls through to legacy string matching (mesh)", () => { const result = friendlyAgentLastError( "Agent reported error: llm auth: 401 unauthorized", null, + "relay-mesh", ); assert.deepEqual(result, { severity: "denied", @@ -108,10 +143,11 @@ test("code null falls through to legacy string matching", () => { }); }); -test("code undefined falls through to legacy string matching", () => { +test("code undefined falls through to legacy string matching (mesh)", () => { const result = friendlyAgentLastError( "Agent reported error: llm auth: 403 forbidden", undefined, + "relay-mesh", ); assert.deepEqual(result, { severity: "denied", @@ -134,13 +170,24 @@ test("friendlyTurnErrorCopy: numeric code -32002 → model-not-found copy", () = ); }); -test("friendlyTurnErrorCopy: string-encoded code coerces to number", () => { +test("friendlyTurnErrorCopy: string-encoded code coerces to number (mesh)", () => { assert.equal( - friendlyTurnErrorCopy("raw error", "-32001"), + friendlyTurnErrorCopy("raw error", "-32001", "relay-mesh"), RELAY_MESH_DENIED_COPY, ); }); +test("friendlyTurnErrorCopy: -32001 non-mesh → raw text preserved", () => { + assert.equal( + friendlyTurnErrorCopy( + "llm auth: 401 Unauthorized: multi-user-authorization", + -32001, + "openai", + ), + "llm auth: 401 Unauthorized: multi-user-authorization", + ); +}); + test("friendlyTurnErrorCopy: missing code falls back to raw text", () => { assert.equal(friendlyTurnErrorCopy("raw error", undefined), "raw error"); assert.equal(friendlyTurnErrorCopy("raw error", null), "raw error"); @@ -165,19 +212,25 @@ test("unknown code prevents string-pattern cross-classification", () => { }); }); -test("NaN code param treated as absent — string path applies", () => { - // NaN is not finite; falls back to string matching. - const result = friendlyAgentLastError("llm auth: denied", NaN); +test("NaN code param treated as absent — legacy string path applies (mesh)", () => { + // NaN is not finite; falls back to string matching. Mesh provider preserves + // the historical denied-copy mapping for the legacy form. + const result = friendlyAgentLastError( + "llm auth: denied", + NaN, + "relay-mesh", + ); assert.deepEqual(result, { severity: "denied", copy: RELAY_MESH_DENIED_COPY, }); }); -test("embedded code -32001 recovered from message when code param is null", () => { +test("embedded code -32001 recovered from message when code param is null (mesh)", () => { const result = friendlyAgentLastError( "Agent reported error (code -32001): llm auth: 401", null, + "relay-mesh", ); assert.deepEqual(result, { severity: "denied", @@ -185,6 +238,19 @@ test("embedded code -32001 recovered from message when code param is null", () = }); }); +test("embedded code -32001, non-mesh provider → raw preserved (regression)", () => { + // Even the embedded-code fallback must respect the provider gate. + const result = friendlyAgentLastError( + "Agent reported error (code -32001): llm auth: 401 from api.openai.com", + null, + "openai", + ); + assert.deepEqual(result, { + severity: "generic", + copy: "Agent reported error (code -32001): llm auth: 401 from api.openai.com", + }); +}); + test("embedded code -32002 recovered from message when code param is undefined", () => { const result = friendlyAgentLastError( "Agent reported error (code -32002): llm model not found: x", @@ -207,10 +273,10 @@ test("embedded unknown code is authoritative — no cross-classification", () => }); }); -test("friendlyTurnErrorCopy: garbage string code coerces to NaN → string path", () => { +test("friendlyTurnErrorCopy: garbage string code coerces to NaN → legacy string path (mesh)", () => { // "garbage" → NaN → not finite → null → string prefix matches "llm auth:". assert.equal( - friendlyTurnErrorCopy("llm auth: denied", "garbage"), + friendlyTurnErrorCopy("llm auth: denied", "garbage", "relay-mesh"), RELAY_MESH_DENIED_COPY, ); }); @@ -307,10 +373,16 @@ test("friendlyTurnErrorCopy: code -32603 bare Internal error → cli-acp interna }); test("-32603 does not affect -32001/-32002 classification (regression)", () => { - assert.deepEqual(friendlyAgentLastError("any", -32001), { + assert.deepEqual(friendlyAgentLastError("any", -32001, "relay-mesh"), { severity: "denied", copy: RELAY_MESH_DENIED_COPY, }); + // -32001 non-mesh now passes raw through (the #4205 fix); -32002 is + // provider-independent. + assert.deepEqual(friendlyAgentLastError("any", -32001, "openai"), { + severity: "generic", + copy: "any", + }); assert.deepEqual(friendlyAgentLastError("any", -32002), { severity: "denied", copy: MODEL_NOT_FOUND_COPY, diff --git a/desktop/src/features/agents/lib/friendlyAgentLastError.ts b/desktop/src/features/agents/lib/friendlyAgentLastError.ts index 60c77bb04cc..a6e7028acc4 100644 --- a/desktop/src/features/agents/lib/friendlyAgentLastError.ts +++ b/desktop/src/features/agents/lib/friendlyAgentLastError.ts @@ -64,6 +64,7 @@ function recoverEmbeddedCode(trimmed: string): { export function friendlyAgentLastError( raw: string | null, code?: number | null, + provider?: string | null, ): FriendlyAgentLastError | null { if (raw == null) return null; const trimmed = raw.trim(); @@ -78,7 +79,18 @@ export function friendlyAgentLastError( if (effectiveCode != null) { switch (effectiveCode) { case -32001: - return { severity: "denied", copy: RELAY_MESH_DENIED_COPY }; + // `-32001` is a generic buzz-agent llm-auth failure — it fires for + // *any* LLM provider (anthropic, openai, databricks, openai-compat…), + // not only relay-mesh. Rewriting it to the mesh copy misdirects + // users debugging a non-mesh agent: they see "Community access + // denied" when the real fix is "your configured API key was rejected + // by your upstream". Only when the agent's provider is the mesh + // preset do we know the 401 came from Buzz's own admission and can + // afford the more actionable mesh copy. + if (provider != null && provider.trim() === "relay-mesh") { + return { severity: "denied", copy: RELAY_MESH_DENIED_COPY }; + } + return { severity: "generic", copy: trimmed }; case -32002: return { severity: "denied", copy: MODEL_NOT_FOUND_COPY }; case -32603: { @@ -106,11 +118,19 @@ export function friendlyAgentLastError( // Legacy string fallback for records written before codes existed. // Match either the unwrapped buzz-agent prefix or the buzz-acp v0 wrap. + // Same overbroad-rewriting concern as the structured path: "llm auth:" + // fires for any provider's 401/403, so only relay-mesh agents can safely + // be summarised as "community access denied". For everyone else, keep the + // underlying message so the actionable cause (upstream auth rejected your + // key) is preserved. if ( trimmed.startsWith("Agent reported error: llm auth:") || trimmed.startsWith("llm auth:") ) { - return { severity: "denied", copy: RELAY_MESH_DENIED_COPY }; + if (provider != null && provider.trim() === "relay-mesh") { + return { severity: "denied", copy: RELAY_MESH_DENIED_COPY }; + } + return { severity: "generic", copy: trimmed }; } return { severity: "generic", copy: trimmed }; @@ -120,9 +140,19 @@ export function friendlyAgentLastError( * Convenience for `turn_error` / `agent_panic` observer payloads: coerce the * payload's untyped `code` JSON value and return the display copy, falling * back to the raw error text when no classification applies. + * + * `provider` must be the agent's configured LLM provider (e.g. "anthropic", + * "relay-mesh"). It's threaded through to [`friendlyAgentLastError`] so the + * `-32001` → mesh-copy rewrite fires only for mesh agents; non-mesh agents + * keep their underlying llm-auth message (upstream 401 details are the + * actionable signal). */ -export function friendlyTurnErrorCopy(raw: string, code: unknown): string { +export function friendlyTurnErrorCopy( + raw: string, + code: unknown, + provider?: string | null, +): string { const numeric = code == null ? null : Number(code); const safe = Number.isFinite(numeric) ? (numeric as number) : null; - return friendlyAgentLastError(raw, safe)?.copy ?? raw; + return friendlyAgentLastError(raw, safe, provider)?.copy ?? raw; } diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 606d2b78836..a9f73a2ebbe 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -87,11 +87,15 @@ export function ManagedAgentRow({ // log tail (Max's seam in `managed_agents/storage.rs`), promote it to // user-visible copy below the process detail. Specifically renders the // friendly "Community access denied this agent — check its community membership." - // for auth failures so the user knows it's a membership thing, not a - // crash. Generic exits stay verbatim so we don't lie about other failures. + // for auth failures when the agent is a Buzz shared-compute (relay-mesh) + // agent; for non-mesh agents, the raw llm-auth message is preserved so a + // llama.cpp / OpenAI / Databricks 401 isn't mislabeled as a community + // membership problem (see #4205). Generic exits stay verbatim so we don't + // lie about other failures. const friendlyError = friendlyAgentLastError( agent.lastError, agent.lastErrorCode, + agent.provider, ); return ( diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index 19a5ef1171f..acb34259518 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -284,7 +284,11 @@ function AgentPersonaCard({ ? firstAvatarUrl(persona.avatarUrl, profileQuery.data?.avatarUrl) : persona.avatarUrl; const friendlyError = agent - ? friendlyAgentLastError(agent.lastError, agent.lastErrorCode)?.copy + ? friendlyAgentLastError( + agent.lastError, + agent.lastErrorCode, + agent.provider, + )?.copy : null; const opensRuntimeTab = Boolean(agent && friendlyError && !isActive); @@ -375,6 +379,7 @@ function StandaloneAgentCard({ const friendlyError = friendlyAgentLastError( agent.lastError, agent.lastErrorCode, + agent.provider, )?.copy; const isActive = isManagedAgentActive(agent); const opensRuntimeTab = Boolean(friendlyError && !isActive);