Files
tutti/packages/agent/gui/shared/agentConversation/components/AgentMessageBlock.visibleError.spec.tsx
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

562 lines
16 KiB
TypeScript

import { fireEvent, render } from "@testing-library/react";
import type { ComponentProps } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AgentMessageBlock } from "./AgentMessageBlock";
import type {
AgentMessageContentVM,
AgentMessageRowVM
} from "../contracts/agentMessageRowVM";
import { AgentEnvPanelActionProvider } from "../../agentEnv";
import { AgentVisibleErrorPresentationProvider } from "../../visibleError/AgentVisibleErrorPresentationContext";
import type {
AgentVisibleErrorOverrides,
AgentVisibleErrorPresentationScope
} from "../../agentEnv/agentErrorPresentation";
function buildRow(
visibleError: AgentMessageContentVM["visibleError"],
body = ""
): AgentMessageRowVM {
return {
kind: "message",
id: "row-1",
turnId: "turn-1",
speaker: "assistant",
occurredAtUnixMs: 0,
thinking: [],
messages: [
{
kind: "message-content",
id: "msg-1",
turnId: "turn-1",
body,
presentationKind: "content",
occurredAtUnixMs: 0,
visibleError
}
]
};
}
function renderBlock(
row: AgentMessageRowVM,
provider?: string,
onLinkAction?: ComponentProps<typeof AgentMessageBlock>["onLinkAction"],
visibleErrorPresentationOverrides?: AgentVisibleErrorOverrides | null,
visibleErrorPresentationScope?: AgentVisibleErrorPresentationScope
) {
const onOpenAgentEnvPanel = vi.fn();
return {
...render(
<AgentEnvPanelActionProvider openPanel={onOpenAgentEnvPanel}>
<AgentVisibleErrorPresentationProvider
scope={visibleErrorPresentationScope}
value={visibleErrorPresentationOverrides}
>
<AgentMessageBlock
workspaceRoot={null}
basePath="/"
row={row}
provider={provider}
onLinkAction={onLinkAction}
thinkingLabel="thinking"
/>
</AgentVisibleErrorPresentationProvider>
</AgentEnvPanelActionProvider>
),
onOpenAgentEnvPanel
};
}
function buildFailedTextRow(body: string): AgentMessageRowVM {
return {
kind: "message",
id: "row-1",
turnId: "turn-1",
speaker: "assistant",
occurredAtUnixMs: 0,
thinking: [],
messages: [
{
kind: "message-content",
id: "msg-1",
turnId: "turn-1",
body,
presentationKind: "content",
statusKind: "failed",
occurredAtUnixMs: 0,
visibleError: null
}
]
};
}
function buildCompletedTextRow(body: string): AgentMessageRowVM {
const row = buildFailedTextRow(body);
const message = row.messages[0];
if (message) {
message.statusKind = "completed";
}
return row;
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("AgentVisibleErrorMessage", () => {
it("routes an env-fixable run failure to the matching wizard step", () => {
const { getByText, getAllByRole, onOpenAgentEnvPanel } = renderBlock(
buildRow(
{
// The real code a missing CLI surfaces as at run time.
code: "cli_not_found",
phase: "start",
provider: "codex",
detail: "spawn codex ENOENT",
retryable: false
},
// The raw body must NOT be surfaced as the card title.
"codex exited: spawn codex ENOENT"
)
);
expect(
getByText(
"Codex CLI wasn't found, so it couldn't run. Set it up to continue."
)
).toBeTruthy();
expect(() => getByText("codex exited: spawn codex ENOENT")).toThrow();
const action = getAllByRole("button").find(
(button) => button.textContent === "Connect"
);
expect(action).toBeTruthy();
fireEvent.click(action as HTMLButtonElement);
expect(onOpenAgentEnvPanel).toHaveBeenCalledWith({
provider: "codex",
focus: "install"
});
});
it("offers a self-detect escape hatch for ambiguous hard failures", () => {
const { getAllByRole, queryByText, onOpenAgentEnvPanel } = renderBlock(
buildRow(
{
code: "process_exited",
phase: "turn",
provider: "codex",
detail: "exited with code 1",
retryable: false
},
"provider process exited with secret diagnostics"
)
);
expect(
queryByText("provider process exited with secret diagnostics")
).toBeNull();
const action = getAllByRole("button").find(
(button) => button.textContent === "Open setup"
);
expect(action).toBeTruthy();
fireEvent.click(action as HTMLButtonElement);
expect(onOpenAgentEnvPanel).toHaveBeenCalledWith({
provider: "codex",
focus: "detect"
});
});
it("keeps the remediation action when the provider is unavailable", () => {
const { getAllByRole, onOpenAgentEnvPanel } = renderBlock(
buildRow({
code: "cli_not_found",
phase: "start",
provider: null,
detail: "spawn failed",
retryable: false
})
);
const action = getAllByRole("button").find(
(button) => button.textContent === "Connect"
);
expect(action).toBeTruthy();
fireEvent.click(action as HTMLButtonElement);
expect(onOpenAgentEnvPanel).toHaveBeenCalledWith({
provider: null,
focus: "install"
});
});
it("does not render raw provider payloads in the product card", () => {
const { queryByText } = renderBlock(
buildRow({
code: "cli_not_found",
phase: "start",
provider: "codex",
detail: "spawn codex ENOENT",
retryable: false
})
);
expect(queryByText("spawn codex ENOENT")).toBeNull();
expect(queryByText("Raw error")).toBeNull();
});
it("reveals shared-agent raw details only after the disclosure is opened", () => {
const { getByRole, getByText } = renderBlock(
buildRow({
code: "provider_error",
phase: "turn",
provider: "claude-code",
detail: "provider response: account is not authenticated",
detailAvailable: true,
retryable: false
})
);
const disclosure = getByRole("button", { name: "Raw error" });
expect(disclosure).toHaveAttribute("aria-expanded", "false");
fireEvent.click(disclosure);
expect(disclosure).toHaveAttribute("aria-expanded", "true");
expect(
getByText("provider response: account is not authenticated")
).toBeTruthy();
});
it("shows a sanitized provider error directly without rewriting it", () => {
const { getByText, queryByRole } = renderBlock(
buildRow({
code: "provider_unavailable",
phase: "turn",
provider: "claude-code",
origin: "provider",
detail: "relay returned HTTP 503: upstream overloaded",
detailAvailable: true,
retryable: true
})
);
expect(
getByText("relay returned HTTP 503: upstream overloaded")
).toBeTruthy();
expect(queryByRole("button", { name: "Raw error" })).toBeNull();
});
it("does not offer provider login for a relay-scoped 401", () => {
const { getByText, queryByText } = renderBlock(
buildRow({
code: "provider_error",
phase: "turn",
provider: "claude-code",
origin: "provider",
detail: "relay rejected request: HTTP 401",
retryable: false
})
);
expect(getByText("relay rejected request: HTTP 401")).toBeTruthy();
expect(queryByText("Sign in")).toBeNull();
});
it("shows accurate copy but NO wizard CTA for transient/server-side failures", () => {
const { getByText, queryByText } = renderBlock(
buildRow({
code: "request_timed_out",
phase: "turn",
provider: "codex",
detail: null,
retryable: true
})
);
expect(getByText("Codex request timed out")).toBeTruthy();
// No env-panel call-to-action — the wizard cannot fix a transient timeout.
expect(queryByText("Set up")).toBeNull();
expect(queryByText("Open setup")).toBeNull();
expect(queryByText("Sign in")).toBeNull();
});
it("fails closed without Host Commerce context", () => {
const onLinkAction = vi.fn();
const { getByText, queryByText } = renderBlock(
buildRow({
code: "insufficient_credits",
phase: "turn",
provider: "tutti-agent",
detail:
"unexpected status 402 Payment Required: pre-deduct credits failed",
retryable: false
}),
"tutti-agent",
onLinkAction
);
expect(
getByText("Tutti has insufficient credits or account balance to continue")
).toBeTruthy();
expect(queryByText("Open setup")).toBeNull();
expect(queryByText("View credit options")).toBeNull();
expect(onLinkAction).not.toHaveBeenCalled();
});
it("renders a generic Host error override without exposing Commerce state", () => {
const onLinkAction = vi.fn();
const { getByText, queryByText } = renderBlock(
buildRow({
code: "insufficient_credits",
phase: "turn",
provider: "tutti-agent",
detail: "private provider billing payload",
retryable: false
}),
"tutti-agent",
onLinkAction,
{
insufficient_credits: {
message: "Recharge credits to continue",
providers: ["tutti-agent"],
action: {
label: "Recharge credits",
url: "https://example.test/credits"
}
}
}
);
expect(getByText("Recharge credits to continue")).toBeTruthy();
expect(queryByText("private provider billing payload")).toBeNull();
fireEvent.click(getByText("Recharge credits"));
expect(onLinkAction).toHaveBeenCalledWith(
expect.objectContaining({
source: "agent-external-action",
type: "open-url",
url: "https://example.test/credits"
})
);
});
it("shows sharer guidance instead of local authentication recovery", () => {
const { getByRole, getByText, queryByText, onOpenAgentEnvPanel } =
renderBlock(
buildRow({
code: "auth_required",
phase: "turn",
provider: "codex",
detail: "owner diagnostic: local Codex login is required",
detailAvailable: true,
retryable: false
}),
"codex",
undefined,
undefined,
"shared_caller"
);
expect(
getByText("Codex needs authentication or configuration")
).toBeTruthy();
expect(getByText("auth_required")).toBeTruthy();
expect(
getByText("Contact the person who shared this Agent, then try again.")
).toBeTruthy();
expect(queryByText("Sign in")).toBeNull();
expect(onOpenAgentEnvPanel).not.toHaveBeenCalled();
fireEvent.click(getByRole("button", { name: "Raw error" }));
expect(
getByText("owner diagnostic: local Codex login is required")
).toBeTruthy();
});
it("does not expose a caller Commerce action for an Owner credit failure", () => {
const onLinkAction = vi.fn();
const { getByText, queryByText } = renderBlock(
buildRow({
code: "insufficient_credits",
phase: "turn",
provider: "tutti-agent",
detail: "owner balance is insufficient",
retryable: false
}),
"tutti-agent",
onLinkAction,
{
insufficient_credits: {
message: "Recharge credits to continue",
providers: ["tutti-agent"],
action: {
label: "Recharge credits",
url: "https://example.test/credits"
}
}
},
"shared_caller"
);
expect(
getByText("Tutti has insufficient credits or account balance to continue")
).toBeTruthy();
expect(
getByText("Contact the person who shared this Agent, then try again.")
).toBeTruthy();
expect(queryByText("Recharge credits to continue")).toBeNull();
expect(queryByText("Recharge credits")).toBeNull();
expect(onLinkAction).not.toHaveBeenCalled();
});
it("shows the stable shared-caller code when provider detail is absent or unknown", () => {
const { getByText, queryByText } = renderBlock(
buildRow({
code: "future_owner_failure",
phase: "provider_start",
provider: "codex",
detail: null,
detailAvailable: false,
retryable: null
}),
"codex",
undefined,
undefined,
"shared_caller"
);
expect(getByText("future_owner_failure")).toBeTruthy();
expect(queryByText("invocation-1")).toBeNull();
expect(queryByText("correlation-1")).toBeNull();
expect(queryByText("owner-device-1")).toBeNull();
});
it("preserves neutral transient copy while suppressing local setup actions", () => {
const { getByText, queryByText } = renderBlock(
buildRow({
code: "network_error",
phase: "turn",
provider: "codex",
detail: null,
retryable: true
}),
"codex",
undefined,
undefined,
"shared_caller"
);
expect(
getByText("Codex couldn't reach the network to complete this request.")
).toBeTruthy();
expect(
queryByText("Contact the person who shared this Agent, then try again.")
).toBeNull();
expect(queryByText("Check network")).toBeNull();
});
it("does not apply a Tutti Commerce override to another provider", () => {
const { getByText, queryByText } = renderBlock(
buildRow({
code: "insufficient_credits",
phase: "turn",
provider: "codex",
detail: "insufficient credits",
retryable: false
}),
"codex",
vi.fn(),
{
insufficient_credits: {
message: "Recharge Tutti credits",
providers: ["tutti-agent"],
action: {
label: "Recharge",
url: "https://example.test/credits"
}
}
}
);
expect(
getByText("Codex has insufficient credits or account balance to continue")
).toBeTruthy();
expect(queryByText("Recharge Tutti credits")).toBeNull();
expect(queryByText("Recharge")).toBeNull();
});
it("shows Cursor plan-limit cards as a calm warning status, not a danger alert", () => {
const { getByText, getByRole, queryByText } = renderBlock(
buildRow({
code: "quota_or_rate_limit",
phase: "turn",
provider: "cursor",
detail: "Upgrade your plan to continue",
retryable: false
})
);
expect(
getByText(
"Cursor request failed because a quota or rate limit was reached"
)
).toBeTruthy();
expect(getByRole("status")).toBeTruthy();
expect(queryByText("Open setup")).toBeNull();
expect(queryByText("Sign in")).toBeNull();
});
it("recovers a failed plain auth message into the wizard card (Claude 401)", () => {
const { getByText, getAllByRole, onOpenAgentEnvPanel } = renderBlock(
buildFailedTextRow(
"Failed to authenticate. API Error: 401 Invalid authentication credentials"
),
"claude-code"
);
// Rendered as the structured card (not dead red text), routing to the wizard.
expect(
getByText("Claude Code needs authentication or configuration")
).toBeTruthy();
const action = getAllByRole("button").find(
(button) => button.textContent === "Sign in"
);
expect(action).toBeTruthy();
fireEvent.click(action as HTMLButtonElement);
expect(onOpenAgentEnvPanel).toHaveBeenCalledWith({
provider: "claude-code",
focus: "auth"
});
});
it("recovers a failed Claude 522 response into a timeout card", () => {
const rawError =
'API Error: 522 {"title":"Error 522: Connection timed out"}';
const { getByText, queryByText } = renderBlock(
buildFailedTextRow(rawError),
"claude-code"
);
expect(getByText("Claude Code request timed out")).toBeTruthy();
expect(queryByText(rawError)).toBeNull();
});
it("recovers Claude SDK's completed login notice into the wizard card", () => {
const { getByText, queryByText } = renderBlock(
buildCompletedTextRow("Not logged in · Please run /login"),
"claude-code"
);
expect(
getByText("Claude Code needs authentication or configuration")
).toBeTruthy();
expect(getByText("Sign in")).toBeTruthy();
expect(queryByText("Not logged in · Please run /login")).toBeNull();
});
it("leaves a non-env failed message as plain text (no card)", () => {
const { queryByText } = renderBlock(
buildFailedTextRow("rate limit exceeded, try again later"),
"codex"
);
expect(queryByText("Sign in")).toBeNull();
});
});