From 777e302f712dc8a1f9e749a0d791cbc8dd5a0ad6 Mon Sep 17 00:00:00 2001
From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com>
Date: Fri, 2 Oct 2026 23:54:09 -0500
Subject: [PATCH 1/4] fix(ui): classify terminal tool failures
---
app/src/lib/plugins/tool-result.ts | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/app/src/lib/plugins/tool-result.ts b/app/src/lib/plugins/tool-result.ts
index d6827a512..88f628104 100644
--- a/app/src/lib/plugins/tool-result.ts
+++ b/app/src/lib/plugins/tool-result.ts
@@ -116,3 +116,19 @@ export function saidItWentAhead(result: unknown, marker: string): boolean {
if (typeof result !== "string") return false;
return asText(result).startsWith(marker);
}
+
+/**
+ * Whether a server-side tool reached a terminal failure rather than returning a result.
+ *
+ * These prefixes are written by the two server tool doors after the distinction between policy
+ * refusal and execution failure has already been made. A refusal has its own marker and must not
+ * be collapsed into this state.
+ */
+export function toolResultFailed(result: string | undefined): boolean {
+ if (result === undefined) return false;
+ const answer = asText(result);
+ return (
+ answer.startsWith("The vendor reported an error:") ||
+ answer.startsWith("That tool could not be called")
+ );
+}
From 91dbe57fe8a3ab5a04d752e399b74f038399b34b Mon Sep 17 00:00:00 2001
From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com>
Date: Fri, 2 Oct 2026 23:54:12 -0500
Subject: [PATCH 2/4] fix(ui): render failed tools as failed
---
app/src/components/channels/chat-transcript.tsx | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx
index 56d12b8c0..d6ac4cd2f 100644
--- a/app/src/components/channels/chat-transcript.tsx
+++ b/app/src/components/channels/chat-transcript.tsx
@@ -52,7 +52,12 @@ import { splitChatOrigin } from "./chat-origin";
import { markdownComponents } from "@/lib/markdown";
import { EASE_OUT, ENTRANCE_SECONDS } from "@/lib/motion";
import { readToolName } from "@/lib/plugins/tool-name";
-import { asText, forDisplay, REFUSAL_MARKER } from "@/lib/plugins/tool-result";
+import {
+ asText,
+ forDisplay,
+ REFUSAL_MARKER,
+ toolResultFailed,
+} from "@/lib/plugins/tool-result";
import { readTeamBotConsent } from "@/lib/team-bots";
import { cn } from "@/lib/utils";
import { VOICE_CHAT_ACTIVITY, type VoiceChatEntry } from "@/lib/voice/archive";
@@ -1355,6 +1360,7 @@ function ServerToolLine({ name, result }: { name: string; result?: string }) {
*/
const answer = result === undefined ? undefined : asText(result);
const refused = answer?.startsWith(REFUSAL_MARKER) ?? false;
+ const failed = !refused && toolResultFailed(result);
/*
* The marker is for this component, not for the reader. Left in, a refusal reads "Blocked" in the
* label and then "Refused." again in the first two words of the body, which is the same fact three
@@ -1370,6 +1376,7 @@ function ServerToolLine({ name, result }: { name: string; result?: string }) {
{...(detail ? { detail } : {})}
label={label}
refused={refused}
+ failed={failed}
running={result === undefined}
>
{body ? (
From c853be231fa7a0522fea9715858fa907b20d2272 Mon Sep 17 00:00:00 2001
From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com>
Date: Fri, 2 Oct 2026 23:54:14 -0500
Subject: [PATCH 3/4] fix(ui): keep tool label on refusals
---
app/src/components/channels/tool-line.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/src/components/channels/tool-line.tsx b/app/src/components/channels/tool-line.tsx
index 643907f1c..bd4498e27 100644
--- a/app/src/components/channels/tool-line.tsx
+++ b/app/src/components/channels/tool-line.tsx
@@ -43,7 +43,7 @@ export function ToolLine({
}`}
>
- {refused ? "Blocked" : failed ? `${label}, didn't work` : label}
+ {refused ? `Blocked: ${label}` : failed ? `${label}, didn't work` : label}
{detail ? {detail} : null}
From a00ef629ac34481ea13f5f50abfc1f3ab92226b5 Mon Sep 17 00:00:00 2001
From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com>
Date: Fri, 2 Oct 2026 23:54:16 -0500
Subject: [PATCH 4/4] test(ui): distinguish failed tool results
---
app/tests/tool-result.test.ts | 23 +++++++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/app/tests/tool-result.test.ts b/app/tests/tool-result.test.ts
index a700f2064..b6dbbfc52 100644
--- a/app/tests/tool-result.test.ts
+++ b/app/tests/tool-result.test.ts
@@ -4,6 +4,7 @@ import {
asText,
forDisplay,
saidItWentAhead,
+ toolResultFailed,
} from "../src/lib/plugins/tool-result";
/**
@@ -119,3 +120,25 @@ describe("the markers the server and the transcript both use", () => {
expect(saidItWentAhead(undefined, HANDED_OVER)).toBe(true);
});
});
+
+
+describe("telling a failed tool from a successful one", () => {
+ test("vendor errors are failures, encoded or not", () => {
+ const answer = "The vendor reported an error:\\npermission denied";
+ expect(toolResultFailed(answer)).toBe(true);
+ expect(toolResultFailed(JSON.stringify(answer))).toBe(true);
+ });
+
+ test("runtime/deployment call failures are failures", () => {
+ expect(toolResultFailed("That tool could not be called.")).toBe(true);
+ expect(
+ toolResultFailed("That tool could not be called: connection reset"),
+ ).toBe(true);
+ });
+
+ test("ordinary results, refusals, and running calls are not failures", () => {
+ expect(toolResultFailed("Found 12 results.")).toBe(false);
+ expect(toolResultFailed("Refused. The rule says no.")).toBe(false);
+ expect(toolResultFailed(undefined)).toBe(false);
+ });
+});