mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 04:46:16 +00:00
fix(ui): show failed tool results as errors (#85786)
Merged via squash.
Prepared head SHA: c0c4fb5917
Co-authored-by: chengjiew <75600865+chengjiew@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
This commit is contained in:
@@ -19,7 +19,7 @@
|
||||
|
||||
.chat-tool-card--expanded {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-tool-card:hover {
|
||||
@@ -63,10 +63,13 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1 1 auto;
|
||||
font-weight: 600;
|
||||
font-size: var(--control-ui-text-md);
|
||||
line-height: 1.2;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.chat-tool-card__icon {
|
||||
@@ -95,7 +98,9 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
color: var(--muted);
|
||||
opacity: 1;
|
||||
transition:
|
||||
@@ -161,6 +166,98 @@
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Error state: tool returned an error payload or "Tool not found" */
|
||||
.chat-tool-card__status--error,
|
||||
.chat-tool-card__action--error,
|
||||
.chat-tool-card__status-text--error {
|
||||
color: var(--destructive, var(--danger, #c0392b));
|
||||
}
|
||||
|
||||
.chat-tool-card__status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 1px 6px;
|
||||
margin-left: 6px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--destructive, var(--danger, #c0392b)) 15%, transparent);
|
||||
color: var(--destructive, var(--danger, #c0392b));
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chat-tool-card__status-badge svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.chat-tool-card--error {
|
||||
border-color: color-mix(in srgb, var(--destructive, var(--danger, #c0392b)) 35%, var(--border));
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--destructive, var(--danger, #c0392b)) 8%, transparent),
|
||||
transparent 36%
|
||||
),
|
||||
color-mix(in srgb, var(--card) 86%, var(--secondary) 14%);
|
||||
}
|
||||
|
||||
.chat-tool-card--error:hover {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--destructive, var(--danger, #c0392b)) 44%,
|
||||
var(--border-strong)
|
||||
);
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--destructive, var(--danger, #c0392b)) 11%, transparent),
|
||||
transparent 38%
|
||||
),
|
||||
color-mix(in srgb, var(--card) 78%, var(--bg-hover) 22%);
|
||||
}
|
||||
|
||||
.chat-tool-card--error .chat-tool-card__icon,
|
||||
.chat-tool-msg-summary.chat-tool-msg-summary--error .chat-tool-msg-summary__icon {
|
||||
color: var(--destructive, var(--danger, #c0392b));
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.chat-tool-msg-summary__error-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 1px 6px;
|
||||
margin-left: auto;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--destructive, var(--danger, #c0392b)) 15%, transparent);
|
||||
color: var(--destructive, var(--danger, #c0392b));
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.4;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-tool-msg-summary__error-badge svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.chat-tool-card__detail {
|
||||
font-size: var(--control-ui-text-sm);
|
||||
color: var(--muted);
|
||||
@@ -365,6 +462,12 @@
|
||||
max-height: min(520px, 60vh);
|
||||
}
|
||||
|
||||
.chat-tool-card__block-content code {
|
||||
white-space: inherit;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: inherit;
|
||||
}
|
||||
|
||||
.chat-tool-card__inline {
|
||||
margin-top: 10px;
|
||||
white-space: pre-wrap;
|
||||
@@ -585,6 +688,17 @@
|
||||
color-mix(in srgb, var(--card) 86%, var(--secondary) 14%);
|
||||
}
|
||||
|
||||
.chat-tool-msg-summary.chat-tool-msg-summary--error {
|
||||
border-color: color-mix(in srgb, var(--destructive, var(--danger, #c0392b)) 30%, var(--border));
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--destructive, var(--danger, #c0392b)) 11%, transparent),
|
||||
transparent 36%
|
||||
),
|
||||
color-mix(in srgb, var(--card) 86%, var(--secondary) 14%);
|
||||
}
|
||||
|
||||
.chat-tool-msg-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
@@ -618,6 +732,21 @@
|
||||
border-color: color-mix(in srgb, var(--border-strong) 70%, transparent);
|
||||
}
|
||||
|
||||
.chat-tool-msg-summary.chat-tool-msg-summary--error:hover {
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--destructive, var(--danger, #c0392b)) 14%, transparent),
|
||||
transparent 38%
|
||||
),
|
||||
color-mix(in srgb, var(--card) 78%, var(--bg-hover) 22%);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--destructive, var(--danger, #c0392b)) 38%,
|
||||
var(--border-strong)
|
||||
);
|
||||
}
|
||||
|
||||
.chat-tool-msg-collapse--manual.is-open > .chat-tool-msg-summary,
|
||||
.chat-tool-msg-collapse[open] > .chat-tool-msg-summary {
|
||||
border-bottom-right-radius: 0;
|
||||
@@ -790,6 +919,10 @@
|
||||
max-height: 100px;
|
||||
}
|
||||
|
||||
.chat-tool-card--expanded {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.chat-tool-card__title {
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -826,6 +959,10 @@
|
||||
max-height: 80px;
|
||||
}
|
||||
|
||||
.chat-tool-card--expanded {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.chat-tool-card__preview {
|
||||
padding: 4px 6px;
|
||||
max-height: 28px;
|
||||
|
||||
@@ -20,4 +20,14 @@ describe("chat tool card styles", () => {
|
||||
expect(css).toContain("white-space: normal;");
|
||||
expect(css).not.toContain("max-width: 42%;");
|
||||
});
|
||||
|
||||
it("keeps expanded tool cards and actions usable on narrow screens", () => {
|
||||
const css = readToolCardsCss();
|
||||
|
||||
expect(css).toContain(".chat-tool-card--expanded {");
|
||||
expect(css).toContain("max-height: none;");
|
||||
expect(css).toContain("overflow: hidden;");
|
||||
expect(css).toContain("white-space: nowrap;");
|
||||
expect(css).toContain(".chat-tool-card__block-content code");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ function requireFirstMockArg(
|
||||
if (!arg || typeof arg !== "object" || Array.isArray(arg)) {
|
||||
throw new Error(`expected ${label} payload`);
|
||||
}
|
||||
return arg as Record<string, unknown>;
|
||||
return arg;
|
||||
}
|
||||
|
||||
vi.mock("../views/agents-utils.ts", () => {
|
||||
@@ -1068,7 +1068,7 @@ describe("grouped chat rendering", () => {
|
||||
const blocks = Array.from(container.querySelectorAll(".chat-tool-card__block"));
|
||||
expect(
|
||||
blocks.map((block) => block.querySelector(".chat-tool-card__block-label")?.textContent),
|
||||
).toEqual(["Tool input", "Tool output"]);
|
||||
).toEqual(["Tool input", "Tool error"]);
|
||||
expect(blocks[0]?.querySelector("code")?.textContent).toBe(
|
||||
'{\n "mode": "session",\n "thread": true\n}',
|
||||
);
|
||||
@@ -1079,6 +1079,115 @@ describe("grouped chat rendering", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("respects explicit success on collapsed standalone tool-result summaries", () => {
|
||||
const container = document.createElement("div");
|
||||
renderMessageGroups(
|
||||
container,
|
||||
[
|
||||
createMessageGroup(
|
||||
{
|
||||
id: "tool-error-collapsed",
|
||||
role: "toolResult",
|
||||
toolCallId: "call-error-collapsed",
|
||||
toolName: "web_search",
|
||||
isError: false,
|
||||
content: JSON.stringify({
|
||||
error: "missing_brave_api_key",
|
||||
message: "BRAVE_API_KEY is not configured",
|
||||
}),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
"tool",
|
||||
),
|
||||
],
|
||||
{
|
||||
isToolMessageExpanded: () => false,
|
||||
},
|
||||
);
|
||||
|
||||
const summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement);
|
||||
expect(summary.classList.contains("chat-tool-msg-summary--error")).toBe(false);
|
||||
expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("Tool output");
|
||||
expect(summary.querySelector(".chat-tool-msg-summary__names")?.textContent).toBe("web_search");
|
||||
expect(summary.querySelector(".chat-tool-msg-summary__error-badge")).toBeNull();
|
||||
expect(container.querySelector(".chat-tool-msg-body")).toBeNull();
|
||||
});
|
||||
|
||||
it("respects explicit success on MCP-style standalone tool-result summaries", () => {
|
||||
const container = document.createElement("div");
|
||||
renderMessageGroups(
|
||||
container,
|
||||
[
|
||||
createMessageGroup(
|
||||
{
|
||||
id: "tool-error-collapsed-mcp",
|
||||
role: "toolResult",
|
||||
toolCallId: "call-error-collapsed-mcp",
|
||||
toolName: "memory_forget",
|
||||
isError: false,
|
||||
content: JSON.stringify({
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "Tool error: boom" }],
|
||||
}),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
"tool",
|
||||
),
|
||||
],
|
||||
{
|
||||
isToolMessageExpanded: () => false,
|
||||
},
|
||||
);
|
||||
|
||||
const summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement);
|
||||
expect(summary.classList.contains("chat-tool-msg-summary--error")).toBe(false);
|
||||
expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("Tool output");
|
||||
expect(summary.querySelector(".chat-tool-msg-summary__names")?.textContent).toBe(
|
||||
"memory_forget",
|
||||
);
|
||||
expect(summary.querySelector(".chat-tool-msg-summary__error-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("marks status-only standalone tool-result summaries as errors", () => {
|
||||
const container = document.createElement("div");
|
||||
const groups = [
|
||||
createMessageGroup(
|
||||
{
|
||||
id: "tool-status-error",
|
||||
role: "toolResult",
|
||||
toolCallId: "call-status-error",
|
||||
toolName: "sessions_spawn",
|
||||
content: JSON.stringify({ status: "error" }, null, 2),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
"tool",
|
||||
),
|
||||
];
|
||||
|
||||
renderMessageGroups(container, groups, {
|
||||
isToolMessageExpanded: () => false,
|
||||
});
|
||||
|
||||
let summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement);
|
||||
expect(summary.classList.contains("chat-tool-msg-summary--error")).toBe(true);
|
||||
expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("Tool error");
|
||||
expect(summary.querySelector(".chat-tool-msg-summary__names")?.textContent).toBe(
|
||||
"sessions_spawn",
|
||||
);
|
||||
expect(summary.querySelector(".chat-tool-msg-summary__error-badge")).not.toBeNull();
|
||||
|
||||
renderMessageGroups(container, groups, {
|
||||
isToolMessageExpanded: () => true,
|
||||
});
|
||||
|
||||
summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement);
|
||||
expect(summary.classList.contains("chat-tool-msg-summary--error")).toBe(true);
|
||||
expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("Tool error");
|
||||
expect(
|
||||
JSON.parse(container.querySelector(".chat-json-content code")?.textContent ?? "{}"),
|
||||
).toEqual({ status: "error" });
|
||||
});
|
||||
|
||||
it("collapses an inline tool call while keeping matching tool output visible", () => {
|
||||
const container = document.createElement("div");
|
||||
const groups = [
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
extractToolCards,
|
||||
formatCollapsedToolPreviewText,
|
||||
formatCollapsedToolSummaryText,
|
||||
isToolCardError,
|
||||
renderExpandedToolCardContent,
|
||||
renderRawOutputToggle,
|
||||
renderToolCard,
|
||||
@@ -1534,6 +1535,7 @@ function renderGroupedMessage(
|
||||
const toolMessageExpanded = opts.isToolMessageExpanded?.(toolMessageDisclosureId) ?? false;
|
||||
const toolNames = [...new Set(toolCards.map((c) => c.name))];
|
||||
const singleToolCard = toolCards.length === 1 ? toolCards[0] : null;
|
||||
const toolMessageHasError = toolCards.some(isToolCardError);
|
||||
const singleToolDisplay = singleToolCard
|
||||
? resolveToolDisplay({
|
||||
name: singleToolCard.name,
|
||||
@@ -1542,21 +1544,28 @@ function renderGroupedMessage(
|
||||
})
|
||||
: null;
|
||||
const singleToolDisplayDetail =
|
||||
singleToolCard && singleToolDisplay
|
||||
!toolMessageHasError && singleToolCard && singleToolDisplay
|
||||
? resolveCollapsedToolDetail(singleToolCard, singleToolDisplay.detail)
|
||||
: undefined;
|
||||
const toolSummaryLabelRaw = singleToolDisplayDetail
|
||||
? singleToolCard?.outputText?.trim()
|
||||
? "output"
|
||||
: undefined
|
||||
: toolNames.length <= 3
|
||||
? toolNames.join(", ")
|
||||
: `${toolNames.slice(0, 2).join(", ")} +${toolNames.length - 2} more`;
|
||||
const toolSummaryLabelRaw = toolMessageHasError
|
||||
? singleToolDisplay
|
||||
? singleToolDisplay.label
|
||||
: toolNames.length <= 3
|
||||
? toolNames.join(", ")
|
||||
: `${toolNames.slice(0, 2).join(", ")} +${toolNames.length - 2} more`
|
||||
: singleToolDisplayDetail
|
||||
? singleToolCard?.outputText?.trim()
|
||||
? "output"
|
||||
: undefined
|
||||
: toolNames.length <= 3
|
||||
? toolNames.join(", ")
|
||||
: `${toolNames.slice(0, 2).join(", ")} +${toolNames.length - 2} more`;
|
||||
const toolSummaryLabel = formatCollapsedToolSummaryText(toolSummaryLabelRaw);
|
||||
const toolPreview =
|
||||
markdown && !toolSummaryLabel ? (formatCollapsedToolPreviewText(markdown) ?? "") : "";
|
||||
const toolMessageLabelRaw =
|
||||
singleToolDisplayDetail && !markdown && !hasImages
|
||||
const toolMessageLabelRaw = toolMessageHasError
|
||||
? "Tool error"
|
||||
: singleToolDisplayDetail && !markdown && !hasImages
|
||||
? singleToolDisplayDetail
|
||||
: singleToolDisplay && !markdown && !hasImages
|
||||
? singleToolDisplay.label
|
||||
@@ -1584,7 +1593,9 @@ function renderGroupedMessage(
|
||||
: ""}"
|
||||
>
|
||||
<button
|
||||
class="chat-tool-msg-summary"
|
||||
class="chat-tool-msg-summary ${toolMessageHasError
|
||||
? "chat-tool-msg-summary--error"
|
||||
: ""}"
|
||||
type="button"
|
||||
aria-expanded=${String(toolMessageExpanded)}
|
||||
@click=${() => opts.onToggleToolMessageExpanded?.(toolMessageDisclosureId)}
|
||||
@@ -1596,6 +1607,13 @@ function renderGroupedMessage(
|
||||
: toolPreview
|
||||
? html`<span class="chat-tool-msg-summary__preview">${toolPreview}</span>`
|
||||
: nothing}
|
||||
${toolMessageHasError
|
||||
? html`<span
|
||||
class="chat-tool-msg-summary__error-badge"
|
||||
aria-label="Tool returned an error"
|
||||
>${icons.x}<span>Error</span></span
|
||||
>`
|
||||
: nothing}
|
||||
</button>
|
||||
${toolMessageExpanded
|
||||
? html`
|
||||
|
||||
@@ -163,6 +163,61 @@ describe("tool-card extraction", () => {
|
||||
expect(cards[0]?.outputText).toBe("# Heading\nfile body");
|
||||
});
|
||||
|
||||
it("preserves explicit tool error flags from tool result items and messages", () => {
|
||||
const pairedCards = extractToolCards(
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "toolcall",
|
||||
id: "call-error",
|
||||
name: "lookup",
|
||||
},
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "call-error",
|
||||
name: "lookup",
|
||||
text: "lookup failed",
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
"msg:error-item",
|
||||
);
|
||||
|
||||
expect(pairedCards[0]?.isError).toBe(true);
|
||||
|
||||
const messageFlagCards = extractToolCards(
|
||||
{
|
||||
role: "toolResult",
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "call-message-error",
|
||||
name: "lookup",
|
||||
text: "lookup failed",
|
||||
},
|
||||
],
|
||||
},
|
||||
"msg:error-message-flag",
|
||||
);
|
||||
|
||||
expect(messageFlagCards[0]?.isError).toBe(true);
|
||||
|
||||
const standaloneCards = extractToolCards(
|
||||
{
|
||||
role: "tool",
|
||||
toolName: "lookup",
|
||||
content: "lookup failed",
|
||||
isError: true,
|
||||
},
|
||||
"msg:error-message",
|
||||
);
|
||||
|
||||
expect(standaloneCards[0]?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("builds sidebar content with input and empty output status", () => {
|
||||
const [card] = extractToolCards(
|
||||
{
|
||||
@@ -193,6 +248,21 @@ with Example Deck
|
||||
*No output — tool completed successfully.*`);
|
||||
});
|
||||
|
||||
it("builds sidebar content with a failed empty-output status for explicit errors", () => {
|
||||
const sidebar = buildToolCardSidebarContent({
|
||||
id: "msg:error-empty",
|
||||
name: "lookup",
|
||||
isError: true,
|
||||
});
|
||||
|
||||
expect(sidebar).toBe(`## Lookup
|
||||
|
||||
**Tool:** \`lookup\`
|
||||
|
||||
### Tool error
|
||||
*No output — tool failed.*`);
|
||||
});
|
||||
|
||||
it("extracts canvas handle payloads into canvas previews", () => {
|
||||
const [card] = extractToolCards(
|
||||
{
|
||||
|
||||
@@ -5,11 +5,19 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
formatCollapsedToolPreviewText,
|
||||
formatCollapsedToolSummaryText,
|
||||
isToolErrorOutput,
|
||||
renderToolCard,
|
||||
renderToolCardSidebar,
|
||||
} from "./tool-cards.ts";
|
||||
|
||||
vi.mock("../icons.ts", () => ({
|
||||
icons: {},
|
||||
icons: {
|
||||
check: "✓",
|
||||
chevronDown: "",
|
||||
panelRightOpen: "",
|
||||
x: "✕",
|
||||
zap: "",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../tool-display.ts", () => ({
|
||||
@@ -40,7 +48,7 @@ function requireFirstMockArg(
|
||||
if (!arg || typeof arg !== "object" || Array.isArray(arg)) {
|
||||
throw new Error(`expected ${label} payload`);
|
||||
}
|
||||
return arg as Record<string, unknown>;
|
||||
return arg;
|
||||
}
|
||||
|
||||
describe("tool-cards", () => {
|
||||
@@ -305,4 +313,268 @@ describe("tool-cards", () => {
|
||||
expect(sidebar.docId).toBe("cv_sidebar");
|
||||
expect(sidebar.entryUrl).toBe("/__openclaw__/canvas/documents/cv_sidebar/index.html");
|
||||
});
|
||||
|
||||
describe("isToolErrorOutput", () => {
|
||||
it("flags JSON payloads that carry a top-level error string", () => {
|
||||
expect(
|
||||
isToolErrorOutput(
|
||||
JSON.stringify({
|
||||
error: "missing_brave_api_key",
|
||||
message: "BRAVE_API_KEY is not configured",
|
||||
provider: "brave",
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("flags JSON payloads that carry a top-level isError flag", () => {
|
||||
expect(
|
||||
isToolErrorOutput(
|
||||
JSON.stringify({
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "Tool error: boom" }],
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isToolErrorOutput(
|
||||
JSON.stringify({
|
||||
is_error: true,
|
||||
content: [{ type: "text", text: "Tool error: boom" }],
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("flags 'Tool not found' bodies regardless of trailing punctuation or case", () => {
|
||||
expect(isToolErrorOutput("Tool not found")).toBe(true);
|
||||
expect(isToolErrorOutput(" tool not found. ")).toBe(true);
|
||||
expect(isToolErrorOutput("TOOL NOT FOUND")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags JSON payloads with top-level failure statuses", () => {
|
||||
expect(isToolErrorOutput(JSON.stringify({ status: "error" }))).toBe(true);
|
||||
expect(isToolErrorOutput(JSON.stringify({ status: "failed" }))).toBe(true);
|
||||
expect(isToolErrorOutput(JSON.stringify({ status: "timeout" }))).toBe(true);
|
||||
expect(isToolErrorOutput(JSON.stringify({ status: "completed" }))).toBe(false);
|
||||
expect(isToolErrorOutput(JSON.stringify({ status: "ok" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag successful payloads or strings without a tool error signal", () => {
|
||||
expect(isToolErrorOutput(undefined)).toBe(false);
|
||||
expect(isToolErrorOutput("")).toBe(false);
|
||||
expect(isToolErrorOutput("Opened page")).toBe(false);
|
||||
expect(
|
||||
isToolErrorOutput(
|
||||
JSON.stringify({ isError: false, result: "ok", error: "no validation errors" }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isToolErrorOutput(JSON.stringify({ result: "ok", error: null }))).toBe(false);
|
||||
expect(isToolErrorOutput(JSON.stringify({ result: "ok", error: "" }))).toBe(false);
|
||||
expect(isToolErrorOutput(JSON.stringify({ result: "ok" }))).toBe(false);
|
||||
expect(isToolErrorOutput("{ not really json }")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a Tool error label and Error badge when output is an error JSON", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderToolCard(
|
||||
{
|
||||
id: "msg:err:1",
|
||||
name: "web_search",
|
||||
args: { query: "python stable version" },
|
||||
inputText: '{\n "query": "python stable version"\n}',
|
||||
outputText: JSON.stringify({
|
||||
error: "missing_brave_api_key",
|
||||
message: "BRAVE_API_KEY is not configured",
|
||||
}),
|
||||
},
|
||||
{ expanded: true, onToggleExpanded: vi.fn() },
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Tool error");
|
||||
expect(container.textContent).not.toMatch(/\bTool output\b/);
|
||||
const summaryButton = container.querySelector("button.chat-tool-msg-summary");
|
||||
expect(summaryButton?.classList.contains("chat-tool-msg-summary--error")).toBe(true);
|
||||
expect(container.querySelector(".chat-tool-msg-summary__error-badge")).not.toBeNull();
|
||||
const expandedCard = container.querySelector(".chat-tool-card--expanded");
|
||||
expect(expandedCard?.classList.contains("chat-tool-card--error")).toBe(true);
|
||||
expect(container.querySelector(".chat-tool-card__status-badge")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders a Tool error label when output has a status-only error payload", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderToolCard(
|
||||
{
|
||||
id: "msg:err:status-only",
|
||||
name: "sessions_spawn",
|
||||
outputText: JSON.stringify({ status: "error" }),
|
||||
},
|
||||
{ expanded: true, onToggleExpanded: vi.fn() },
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Tool error");
|
||||
expect(container.textContent).not.toMatch(/\bTool output\b/);
|
||||
expect(container.querySelector(".chat-tool-msg-summary--error")).not.toBeNull();
|
||||
expect(container.querySelector(".chat-tool-card--error")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders a Tool error label when output is the literal 'Tool not found'", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderToolCard(
|
||||
{
|
||||
id: "msg:err:2",
|
||||
name: "Unknown",
|
||||
outputText: "Tool not found",
|
||||
},
|
||||
{ expanded: false, onToggleExpanded: vi.fn() },
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Tool error");
|
||||
expect(container.textContent).not.toMatch(/\bTool output\b/);
|
||||
const summaryButton = container.querySelector("button.chat-tool-msg-summary");
|
||||
expect(summaryButton?.classList.contains("chat-tool-msg-summary--error")).toBe(true);
|
||||
expect(container.querySelector(".chat-tool-msg-summary__error-badge")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders a Tool error label when the tool card has an explicit error flag", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderToolCard(
|
||||
{
|
||||
id: "msg:err:explicit",
|
||||
name: "lookup",
|
||||
outputText: "lookup failed",
|
||||
isError: true,
|
||||
},
|
||||
{ expanded: true, onToggleExpanded: vi.fn() },
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Tool error");
|
||||
expect(container.textContent).not.toMatch(/\bTool output\b/);
|
||||
expect(container.querySelector(".chat-tool-msg-summary--error")).not.toBeNull();
|
||||
expect(container.querySelector(".chat-tool-card--error")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("respects an explicit success flag even when the payload looks like an error", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderToolCard(
|
||||
{
|
||||
id: "msg:err:status-false",
|
||||
name: "web_search",
|
||||
outputText: JSON.stringify({
|
||||
error: "missing_brave_api_key",
|
||||
}),
|
||||
isError: false,
|
||||
},
|
||||
{ expanded: false, onToggleExpanded: vi.fn() },
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Web Search");
|
||||
expect(container.textContent).not.toContain("Tool error");
|
||||
expect(container.querySelector(".chat-tool-msg-summary--error")).toBeNull();
|
||||
expect(container.querySelector(".chat-tool-msg-summary__error-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render View with a checkmark for sidebar cards whose output is an error JSON", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderToolCardSidebar(
|
||||
{
|
||||
id: "msg:err:sidebar",
|
||||
name: "web_search",
|
||||
outputText: JSON.stringify({
|
||||
error: "missing_brave_api_key",
|
||||
message: "BRAVE_API_KEY is not configured",
|
||||
}),
|
||||
},
|
||||
vi.fn(),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const card = container.querySelector(".chat-tool-card");
|
||||
const action = container.querySelector(".chat-tool-card__action");
|
||||
expect(card?.classList.contains("chat-tool-card--error")).toBe(true);
|
||||
expect(action?.classList.contains("chat-tool-card__action--error")).toBe(true);
|
||||
expect(action?.textContent).toContain("View error");
|
||||
expect(action?.textContent).toContain("✕");
|
||||
expect(action?.textContent).not.toContain("✓");
|
||||
});
|
||||
|
||||
it("marks Tool not found sidebar output as an error instead of View with a checkmark", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderToolCardSidebar(
|
||||
{
|
||||
id: "msg:err:sidebar-tool-not-found",
|
||||
name: "Unknown",
|
||||
outputText: "Tool not found",
|
||||
},
|
||||
vi.fn(),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const action = container.querySelector(".chat-tool-card__action");
|
||||
expect(container.querySelector(".chat-tool-card--error")).not.toBeNull();
|
||||
expect(action?.textContent).toContain("View error");
|
||||
expect(action?.textContent).toContain("✕");
|
||||
expect(action?.textContent).not.toContain("✓");
|
||||
});
|
||||
|
||||
it("marks status-only sidebar output as an error instead of View with a checkmark", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderToolCardSidebar(
|
||||
{
|
||||
id: "msg:err:sidebar-status",
|
||||
name: "sessions_wait",
|
||||
outputText: JSON.stringify({ status: "timeout" }),
|
||||
},
|
||||
vi.fn(),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const action = container.querySelector(".chat-tool-card__action");
|
||||
expect(container.querySelector(".chat-tool-card--error")).not.toBeNull();
|
||||
expect(action?.textContent).toContain("View error");
|
||||
expect(action?.textContent).toContain("✕");
|
||||
expect(action?.textContent).not.toContain("✓");
|
||||
});
|
||||
|
||||
it("keeps Tool output labelling for successful results", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderToolCard(
|
||||
{
|
||||
id: "msg:ok:1",
|
||||
name: "browser.open",
|
||||
outputText: "Opened page",
|
||||
},
|
||||
{ expanded: true, onToggleExpanded: vi.fn() },
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Tool output");
|
||||
expect(container.textContent).not.toContain("Tool error");
|
||||
expect(container.querySelector(".chat-tool-msg-summary--error")).toBeNull();
|
||||
expect(container.querySelector(".chat-tool-card__status-badge")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,9 @@ function normalizeContent(content: unknown): Array<Record<string, unknown>> {
|
||||
if (!Array.isArray(content)) {
|
||||
return [];
|
||||
}
|
||||
return content.filter(Boolean) as Array<Record<string, unknown>>;
|
||||
return content.filter(
|
||||
(entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object",
|
||||
);
|
||||
}
|
||||
|
||||
function coerceArgs(value: unknown): unknown {
|
||||
@@ -63,6 +65,72 @@ function extractToolText(item: Record<string, unknown>): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readToolErrorFlag(value: Record<string, unknown>): boolean | undefined {
|
||||
const raw = value.isError ?? value.is_error;
|
||||
return typeof raw === "boolean" ? raw : undefined;
|
||||
}
|
||||
|
||||
const TOOL_NOT_FOUND_PATTERN = /^tool not found\.?$/i;
|
||||
const MAX_ERROR_DETECT_CHARS = 20_000;
|
||||
const TOOL_ERROR_STATUSES = new Set(["error", "failed", "timeout"]);
|
||||
|
||||
function hasToolErrorStatus(value: unknown): boolean {
|
||||
return typeof value === "string" && TOOL_ERROR_STATUSES.has(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function isToolErrorOutput(outputText: string | undefined): boolean {
|
||||
if (!outputText) {
|
||||
return false;
|
||||
}
|
||||
const trimmed = outputText.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
if (TOOL_NOT_FOUND_PATTERN.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
if (trimmed.length > MAX_ERROR_DETECT_CHARS) {
|
||||
return false;
|
||||
}
|
||||
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
|
||||
return false;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return false;
|
||||
}
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const explicitErrorFlag = readToolErrorFlag(obj);
|
||||
if (explicitErrorFlag !== undefined) {
|
||||
return explicitErrorFlag;
|
||||
}
|
||||
if ("error" in obj) {
|
||||
const value = obj.error;
|
||||
if (typeof value === "string") {
|
||||
return value.trim().length > 0;
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return hasToolErrorStatus(obj.status);
|
||||
}
|
||||
|
||||
export function isToolCardError(card: ToolCard): boolean {
|
||||
if (card.isError !== undefined) {
|
||||
return card.isError;
|
||||
}
|
||||
return isToolErrorOutput(card.outputText);
|
||||
}
|
||||
|
||||
export function extractToolPreview(
|
||||
outputText: string | undefined,
|
||||
toolName: string | undefined,
|
||||
@@ -169,6 +237,7 @@ function findLatestCard(cards: ToolCard[], id: string, name: string): ToolCard |
|
||||
export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[] {
|
||||
const m = message as Record<string, unknown>;
|
||||
const content = normalizeContent(m.content);
|
||||
const messageIsError = readToolErrorFlag(m);
|
||||
const cards: ToolCard[] = [];
|
||||
|
||||
for (let index = 0; index < content.length; index++) {
|
||||
@@ -182,7 +251,7 @@ export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[]
|
||||
const args = coerceArgs(item.arguments ?? item.args ?? item.input);
|
||||
cards.push({
|
||||
id: resolveToolCardId(item, m, index, prefix),
|
||||
name: (item.name as string) ?? "tool",
|
||||
name: typeof item.name === "string" ? item.name : "tool",
|
||||
args,
|
||||
inputText: serializeToolInput(args),
|
||||
});
|
||||
@@ -195,15 +264,20 @@ export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[]
|
||||
const existing = findLatestCard(cards, cardId, name);
|
||||
const text = extractToolText(item);
|
||||
const preview = extractToolPreview(text, name);
|
||||
const isError = readToolErrorFlag(item) ?? messageIsError;
|
||||
if (existing) {
|
||||
existing.outputText = text;
|
||||
existing.preview = preview;
|
||||
if (isError !== undefined) {
|
||||
existing.isError = isError;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
cards.push({
|
||||
id: cardId,
|
||||
name,
|
||||
outputText: text,
|
||||
...(isError !== undefined ? { isError } : {}),
|
||||
preview,
|
||||
});
|
||||
}
|
||||
@@ -227,6 +301,7 @@ export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[]
|
||||
id: resolveToolCardId({}, m, 0, prefix),
|
||||
name,
|
||||
outputText: text,
|
||||
...(messageIsError !== undefined ? { isError: messageIsError } : {}),
|
||||
preview: extractToolPreview(text, name),
|
||||
});
|
||||
}
|
||||
@@ -237,6 +312,7 @@ export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[]
|
||||
export function buildToolCardSidebarContent(card: ToolCard): string {
|
||||
const display = resolveToolDisplay({ name: card.name, args: card.args });
|
||||
const detail = formatToolDetail(display);
|
||||
const isError = isToolCardError(card);
|
||||
const sections = [`## ${display.label}`, `**Tool:** \`${display.name}\``];
|
||||
|
||||
if (detail) {
|
||||
@@ -251,9 +327,15 @@ export function buildToolCardSidebarContent(card: ToolCard): string {
|
||||
}
|
||||
|
||||
if (card.outputText?.trim()) {
|
||||
sections.push(`### Tool output\n${formatToolOutputForSidebar(card.outputText)}`);
|
||||
sections.push(
|
||||
`### ${isError ? "Tool error" : "Tool output"}\n${formatToolOutputForSidebar(card.outputText)}`,
|
||||
);
|
||||
} else {
|
||||
sections.push(`### Tool output\n*No output — tool completed successfully.*`);
|
||||
sections.push(
|
||||
isError
|
||||
? "### Tool error\n*No output — tool failed.*"
|
||||
: "### Tool output\n*No output — tool completed successfully.*",
|
||||
);
|
||||
}
|
||||
|
||||
return sections.join("\n\n");
|
||||
@@ -412,14 +494,15 @@ function renderCollapsedToolSummary(params: {
|
||||
icon: ReturnType<typeof html> | undefined;
|
||||
name?: string;
|
||||
expanded: boolean;
|
||||
isError?: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
}) {
|
||||
const { label, icon, name, expanded, onToggleExpanded } = params;
|
||||
const { label, icon, name, expanded, isError, onToggleExpanded } = params;
|
||||
const displayLabel = formatCollapsedToolSummaryText(label) ?? label;
|
||||
const displayName = formatCollapsedToolSummaryText(name);
|
||||
return html`
|
||||
<button
|
||||
class="chat-tool-msg-summary"
|
||||
class="chat-tool-msg-summary ${isError ? "chat-tool-msg-summary--error" : ""}"
|
||||
type="button"
|
||||
aria-expanded=${String(expanded)}
|
||||
@click=${() => onToggleExpanded()}
|
||||
@@ -429,6 +512,11 @@ function renderCollapsedToolSummary(params: {
|
||||
${displayName
|
||||
? html`<span class="chat-tool-msg-summary__names">${displayName}</span>`
|
||||
: nothing}
|
||||
${isError
|
||||
? html`<span class="chat-tool-msg-summary__error-badge" aria-label="Tool returned an error"
|
||||
>${icons.x}<span>Error</span></span
|
||||
>`
|
||||
: nothing}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
@@ -458,9 +546,10 @@ export function renderToolCard(
|
||||
) {
|
||||
const hasOutput = Boolean(card.outputText?.trim());
|
||||
const display = resolveToolDisplay({ name: card.name, args: card.args, detailMode: "explain" });
|
||||
const collapsedDetail = resolveCollapsedToolDetail(card, display.detail);
|
||||
const previewLabel = collapsedDetail ?? display.label;
|
||||
const previewName = collapsedDetail && hasOutput ? "output" : undefined;
|
||||
const isError = isToolCardError(card);
|
||||
const collapsedDetail = isError ? undefined : resolveCollapsedToolDetail(card, display.detail);
|
||||
const previewLabel = isError ? "Tool error" : (collapsedDetail ?? display.label);
|
||||
const previewName = isError ? display.label : collapsedDetail && hasOutput ? "output" : undefined;
|
||||
|
||||
return html`
|
||||
<div
|
||||
@@ -473,6 +562,7 @@ export function renderToolCard(
|
||||
icon: icons[display.icon],
|
||||
name: previewName,
|
||||
expanded: opts.expanded,
|
||||
isError,
|
||||
onToggleExpanded: () => opts.onToggleExpanded(card.id),
|
||||
})}
|
||||
${opts.expanded
|
||||
@@ -503,6 +593,7 @@ export function renderExpandedToolCardContent(
|
||||
const detail = formatToolDetail(display);
|
||||
const hasOutput = Boolean(card.outputText?.trim());
|
||||
const hasInput = Boolean(card.inputText?.trim());
|
||||
const isError = isToolCardError(card);
|
||||
const canOpenSidebar = Boolean(onOpenSidebar);
|
||||
const previewSidebarContent =
|
||||
card.preview?.kind === "canvas"
|
||||
@@ -521,11 +612,16 @@ export function renderExpandedToolCardContent(
|
||||
: nothing;
|
||||
|
||||
return html`
|
||||
<div class="chat-tool-card chat-tool-card--expanded">
|
||||
<div class="chat-tool-card chat-tool-card--expanded ${isError ? "chat-tool-card--error" : ""}">
|
||||
<div class="chat-tool-card__header">
|
||||
<div class="chat-tool-card__title">
|
||||
<span class="chat-tool-card__icon">${icons[display.icon]}</span>
|
||||
<span>${display.label}</span>
|
||||
${isError
|
||||
? html`<span class="chat-tool-card__status-badge" role="status"
|
||||
>${icons.x}<span>Error</span></span
|
||||
>`
|
||||
: nothing}
|
||||
</div>
|
||||
${canOpenSidebar
|
||||
? html`
|
||||
@@ -555,7 +651,7 @@ export function renderExpandedToolCardContent(
|
||||
? card.preview
|
||||
? html`${visiblePreview} ${renderRawOutputToggle(card.outputText!)}`
|
||||
: renderToolDataBlock({
|
||||
label: "Tool output",
|
||||
label: isError ? "Tool error" : "Tool output",
|
||||
text: card.outputText!,
|
||||
expanded: true,
|
||||
})
|
||||
@@ -575,6 +671,7 @@ export function renderToolCardSidebar(
|
||||
const preview = card.preview;
|
||||
const hasText = Boolean(card.outputText?.trim());
|
||||
const hasPreview = Boolean(preview);
|
||||
const isError = isToolCardError(card);
|
||||
const sidebarContent =
|
||||
preview?.kind === "canvas"
|
||||
? buildPreviewSidebarContent(preview, card.outputText)
|
||||
@@ -586,10 +683,13 @@ export function renderToolCardSidebar(
|
||||
const showCollapsed = hasText && !hasPreview && !isShort;
|
||||
const showInline = hasText && !hasPreview && isShort;
|
||||
const isEmpty = !hasText && !hasPreview;
|
||||
const statusIcon = isError ? icons.x : icons.check;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="chat-tool-card ${canClick ? "chat-tool-card--clickable" : ""}"
|
||||
class="chat-tool-card ${canClick ? "chat-tool-card--clickable" : ""} ${isError
|
||||
? "chat-tool-card--error"
|
||||
: ""}"
|
||||
@click=${handleClick}
|
||||
role=${canClick ? "button" : nothing}
|
||||
tabindex=${canClick ? "0" : nothing}
|
||||
@@ -609,16 +709,28 @@ export function renderToolCardSidebar(
|
||||
<span>${display.label}</span>
|
||||
</div>
|
||||
${canClick
|
||||
? html`<span class="chat-tool-card__action"
|
||||
>${hasText || hasPreview ? "View" : ""} ${icons.check}</span
|
||||
? html`<span
|
||||
class="chat-tool-card__action ${isError ? "chat-tool-card__action--error" : ""}"
|
||||
>${isError ? "View error" : hasText || hasPreview ? "View" : ""} ${statusIcon}</span
|
||||
>`
|
||||
: nothing}
|
||||
${isEmpty && !canClick
|
||||
? html`<span class="chat-tool-card__status">${icons.check}</span>`
|
||||
? html`<span
|
||||
class="chat-tool-card__status ${isError ? "chat-tool-card__status--error" : ""}"
|
||||
>${statusIcon}</span
|
||||
>`
|
||||
: nothing}
|
||||
</div>
|
||||
${detail ? html`<div class="chat-tool-card__detail">${detail}</div>` : nothing}
|
||||
${isEmpty ? html`<div class="chat-tool-card__status-text muted">Completed</div>` : nothing}
|
||||
${isEmpty
|
||||
? html`<div
|
||||
class="chat-tool-card__status-text ${isError
|
||||
? "chat-tool-card__status-text--error"
|
||||
: "muted"}"
|
||||
>
|
||||
${isError ? "Failed" : "Completed"}
|
||||
</div>`
|
||||
: nothing}
|
||||
${preview
|
||||
? html`${renderToolPreview(preview, "chat_tool", {
|
||||
onOpenSidebar,
|
||||
|
||||
@@ -77,6 +77,7 @@ export type ToolCard = {
|
||||
args?: unknown;
|
||||
inputText?: string;
|
||||
outputText?: string;
|
||||
isError?: boolean;
|
||||
preview?: {
|
||||
kind: "canvas";
|
||||
surface: "assistant_message";
|
||||
|
||||
Reference in New Issue
Block a user