Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions desktop/src/app/communityViewTransition.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 12 additions & 1 deletion desktop/src/app/communityViewTransition.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isLinuxPlatform } from "@/shared/lib/platform";

const COMMUNITY_TRANSITION_TIMEOUT_MS = 5_000;

let finishPendingTransition: (() => void) | null = null;
Expand All @@ -17,7 +19,16 @@ export async function runCommunityViewTransition(
update: () => Promise<void> | void,
options: { timeoutMs?: number } = {},
): Promise<void> {
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) {
Expand Down
106 changes: 89 additions & 17 deletions desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,27 @@ 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",
copy: RELAY_MESH_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: <body>"; 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,
Expand All @@ -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);
Expand Down Expand Up @@ -89,29 +97,57 @@ 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",
copy: RELAY_MESH_DENIED_COPY,
});
});

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",
Expand All @@ -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");
Expand All @@ -165,26 +212,45 @@ 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",
copy: RELAY_MESH_DENIED_COPY,
});
});

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",
Expand All @@ -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,
);
});
Expand Down Expand Up @@ -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,
Expand Down
38 changes: 34 additions & 4 deletions desktop/src/features/agents/lib/friendlyAgentLastError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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: {
Expand Down Expand Up @@ -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 };
Expand All @@ -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;
}
8 changes: 6 additions & 2 deletions desktop/src/features/agents/ui/ManagedAgentRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
7 changes: 6 additions & 1 deletion desktop/src/features/agents/ui/UnifiedAgentsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down