Skip to content
Merged
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
48 changes: 47 additions & 1 deletion packages/chat-ui/src/inference-failure.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,42 @@
import { describe, expect, test } from "bun:test";

import { isClassifiedInferenceFailureText } from "./inference-failure";
import {
consumerFacingInferenceText,
isClassifiedInferenceFailureText,
} from "./inference-failure";

describe("consumerFacingInferenceText", () => {
test("keeps the vendor preamble and drops [HTTP …] plus the raw provider message", () => {
expect(
consumerFacingInferenceText(
"This agent could not complete your request due to a credential error [HTTP 401]: API key is invalid",
),
).toBe(
"This agent could not complete your request due to a credential error",
);
expect(
consumerFacingInferenceText(
"This agent could not complete your request because the API quota has been exhausted [HTTP 429]: rate limited",
),
).toBe(
"This agent could not complete your request because the API quota has been exhausted",
);
});

test("a forced [HTTP 401] dump is not consumer copy", () => {
const leaked = "[HTTP 401]: API key is invalid";
const facing = consumerFacingInferenceText(leaked);
expect(facing).not.toContain("[HTTP");
expect(facing).not.toContain("401");
expect(facing).not.toContain("API key is invalid");
});

test("leaves cause-aware undelivered-notice copy untouched", () => {
const notice =
"I can't reach a model right now — add or check your model key in Settings, then I'll pick this up.";
expect(consumerFacingInferenceText(notice)).toBe(notice);
});
});

describe("isClassifiedInferenceFailureText", () => {
test("matches a credential_failure reply, status code included", () => {
Expand All @@ -11,6 +47,16 @@ describe("isClassifiedInferenceFailureText", () => {
).toBe(true);
});

test("matches a credential_failure reply after HTTP/raw is stripped", () => {
expect(
isClassifiedInferenceFailureText(
consumerFacingInferenceText(
"This agent could not complete your request due to a credential error [HTTP 401]: invalid api key",
),
),
).toBe(true);
});

test("matches a quota_exhausted reply", () => {
expect(
isClassifiedInferenceFailureText(
Expand Down
2 changes: 2 additions & 0 deletions packages/chat-ui/src/inference-failure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export const CLASSIFIED_INFERENCE_FAILURE_PREAMBLES: readonly string[] = [
"This agent could not complete your request because the API quota has been exhausted",
];

export { consumerFacingInferenceText } from "@corbits/chat/consumer-inference-text";

export function isClassifiedInferenceFailureText(text: string): boolean {
return CLASSIFIED_INFERENCE_FAILURE_PREAMBLES.some((preamble) =>
text.startsWith(preamble),
Expand Down
15 changes: 11 additions & 4 deletions packages/chat-ui/src/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ import type { BlockResponseActions } from "./blocks/block-responses";
import type { ConnectGithubActions } from "./blocks/connect-github-actions";
import type { ConnectServiceActions } from "./blocks/connect-service-actions";
import { BlockPartView } from "./blocks/registry";
import { isClassifiedInferenceFailureText } from "./inference-failure";
import {
consumerFacingInferenceText,
isClassifiedInferenceFailureText,
} from "./inference-failure";
import { WorkbenchLoadingState } from "./loading-state";
import { Markdown } from "./markdown";
import type { ProfileSubject } from "./profile-subject";
Expand Down Expand Up @@ -455,6 +458,7 @@ function TextBubble({
pendingNonce?: string;
pendingActions?: PendingActions;
}) {
const consumerText = consumerFacingInferenceText(text);
const display = senderDisplay(sender, participants, currentUser);
const isOwn =
currentUser !== undefined &&
Expand Down Expand Up @@ -545,10 +549,10 @@ function TextBubble({
</>
)}
<div className="chat-bubble-text">
<Markdown text={text} />
<Markdown text={consumerText} />
</div>
{onFixConnection !== undefined &&
isClassifiedInferenceFailureText(text) && (
isClassifiedInferenceFailureText(consumerText) && (
<Button
type="button"
variant="outline"
Expand Down Expand Up @@ -710,6 +714,7 @@ function FailedTurnStrip({
}) {
const display = senderDisplay(item.sender, participants, currentUser);
const sender = display?.label ?? CHAT_STRINGS.senderFallbackMember;
const consumerDetail = consumerFacingInferenceText(detailText);
const [expanded, setExpanded] = useState(false);
// Guards the resend itself against a double-click firing two sends —
// not composer state, since Retry never touches the composer any more.
Expand Down Expand Up @@ -748,7 +753,9 @@ function FailedTurnStrip({
</button>
{expanded ? (
<span className="chat-turn-failed-detail">
{detailText.length > 0 ? detailText : CHAT_STRINGS.turnFailedSub}
{consumerDetail.length > 0
? consumerDetail
: CHAT_STRINGS.turnFailedSub}
</span>
) : null}
</div>
Expand Down
16 changes: 16 additions & 0 deletions packages/chat-ui/test/components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,22 @@ describe("WorkbenchTimeline", () => {
expect(markup).toContain("Fix this connection");
});

test("never renders HTTP status or the raw provider message on a classified failure reply", () => {
const markup = renderToStaticMarkup(
<WorkbenchTimeline
items={classifiedFailureItems}
onFixConnection={() => {}}
/>,
);
expect(markup).not.toMatch(/\[HTTP/);
expect(markup).not.toContain("401");
expect(markup).not.toContain("invalid api key");
expect(markup).toContain(
"This agent could not complete your request due to a credential error",
);
expect(markup).toContain("Fix this connection");
});

test("renders Fix this connection as a react-ui outline button, not a bare link", () => {
const markup = renderToStaticMarkup(
<WorkbenchTimeline
Expand Down
60 changes: 60 additions & 0 deletions packages/chat-ui/test/failed-turn-strip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,23 @@
// row (`.chat-turn-failed`, CL-6376) instead of an ordinary text bubble —
// or, before the CL-6376 redesign, `PrFailedTurnStrip`'s bordered banner.
import { afterEach, describe, expect, test } from "bun:test";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";

import type { MessageItem } from "../src/api";
import { WorkbenchTimeline } from "../src/timeline";

if (typeof document === "undefined") {
GlobalRegistrator.register();
}

declare global {
var IS_REACT_ACT_ENVIRONMENT: boolean;
}
globalThis.IS_REACT_ACT_ENVIRONMENT = true;

let container: HTMLDivElement | null = null;
let root: Root | null = null;

Expand Down Expand Up @@ -187,6 +197,56 @@ describe("the failed-turn notice renders through PrFailedTurnStrip", () => {
);
});

test("the expanded detail never shows HTTP status or a raw provider dump", async () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const items: MessageItem[] = [
{
id: "msg_ok",
createdAt: "2026-01-01T00:00:00.000Z",
parts: [{ kind: "text", text: "hi @echo" }],
sender: { name: null, address: "prn_alice@agents.example" },
},
{
id: "msg_notice",
createdAt: "2026-01-01T00:00:05.000Z",
parts: [
{
kind: "text",
text: "This agent could not complete your request due to a credential error [HTTP 401]: API key is invalid.",
turnFailed: true,
},
],
sender: { name: null, address: "ins_echo1@agents.example" },
},
];
await act(async () => {
root?.render(
<WorkbenchTimeline
items={items}
participants={[
{ address: "ins_echo1@agents.example", handle: "echo" },
]}
/>,
);
});

act(() => {
container
?.querySelector(".chat-turn-failed-disclosure")
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});

const detail = container.querySelector(".chat-turn-failed-detail");
expect(detail?.textContent).toBe(
"This agent could not complete your request due to a credential error",
);
expect(detail?.textContent).not.toMatch(/\[HTTP/);
expect(detail?.textContent).not.toContain("401");
expect(detail?.textContent).not.toContain("API key is invalid");
});

test("Retry auto-resends the recovered request text — no composer round trip", async () => {
container = document.createElement("div");
document.body.appendChild(container);
Expand Down
1 change: 1 addition & 0 deletions packages/chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"./stream-events": "./src/stream-events.ts",
"./blocks": "./src/blocks.ts",
"./agent-address": "./src/agent-address.ts",
"./consumer-inference-text": "./src/consumer-inference-text.ts",
"./workbench-host-naming": "./src/workbench-host-naming.ts",
"./display-name": "./src/display-name.ts",
"./id-leak-guard": "./src/id-leak-guard.ts"
Expand Down
38 changes: 38 additions & 0 deletions packages/chat/src/consumer-inference-text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test";

import {
CONSUMER_INFERENCE_FAILURE_NOTICE,
consumerFacingInferenceText,
} from "./consumer-inference-text";

describe("consumerFacingInferenceText", () => {
test("leaves ordinary replies unchanged", () => {
expect(consumerFacingInferenceText("Hello there.")).toBe("Hello there.");
});

test("keeps a classified preamble and drops a trailing HTTP dump", () => {
expect(
consumerFacingInferenceText(
"This agent could not complete your request due to a credential error [HTTP 401]: API key is invalid.",
),
).toBe(
"This agent could not complete your request due to a credential error",
);
});

test("a forced HTTP dump is not consumer copy", () => {
const text = consumerFacingInferenceText("[HTTP 401]: API key is invalid.");
expect(text).toBe(CONSUMER_INFERENCE_FAILURE_NOTICE);
expect(text).not.toMatch(/\[HTTP/i);
expect(text).not.toMatch(/401/);
expect(text.toLowerCase()).not.toContain("api key is invalid");
});

test("a JSON provider-error object is not consumer copy", () => {
const text = consumerFacingInferenceText(
'{"error":{"message":"Incorrect API key provided","type":"invalid_request_error","code":"invalid_api_key"}}',
);
expect(text).toBe(CONSUMER_INFERENCE_FAILURE_NOTICE);
expect(text.toLowerCase()).not.toContain("invalid_api_key");
});
});
29 changes: 29 additions & 0 deletions packages/chat/src/consumer-inference-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Person-facing inference copy. HTTP status, raw provider dumps, and
* JSON error objects never belong on the timeline, in a sidebar preview,
* or next to the composer — DESIGN.md Honesty is one consumer sentence.
*/

const HTTP_STATUS_MARK = /\[HTTP\s+\d+\]/i;
const TRAILING_HTTP_DUMP = /\s*\[HTTP\s+\d+\]:[\s\S]*$/i;

export const CONSUMER_INFERENCE_FAILURE_NOTICE =
"This didn't go through. Try again, or check the connection in Settings.";

function isProviderJsonDump(raw: string): boolean {
const trimmed = raw.trim();
if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) return false;
return /"error"/i.test(trimmed);
}

function needsSanitization(raw: string): boolean {
return HTTP_STATUS_MARK.test(raw) || isProviderJsonDump(raw);
}

/** Drop HTTP status and raw provider text; keep a classified preamble when present. */
export function consumerFacingInferenceText(raw: string): string {
if (!needsSanitization(raw)) return raw;
const stripped = raw.replace(TRAILING_HTTP_DUMP, "").trim();
if (stripped.length > 0 && !needsSanitization(stripped)) return stripped;
return CONSUMER_INFERENCE_FAILURE_NOTICE;
}
4 changes: 4 additions & 0 deletions packages/chat/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export type {
SettleConnectedServiceInput,
} from "./connect-pending";
export { encodeParts, decodeParts, decodeMail, senderOf } from "./codec";
export {
CONSUMER_INFERENCE_FAILURE_NOTICE,
consumerFacingInferenceText,
} from "./consumer-inference-text";
export type {
MailContent,
MailReadContent,
Expand Down
50 changes: 50 additions & 0 deletions packages/chat/src/room-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,43 @@ describe("postRoomMessage", () => {
]);
});

test("persists a consumer sentence, not HTTP status or a raw provider dump", async () => {
const roomMessages = createInMemoryRoomMessageStore();
const publisher = recordingPublisher();

const posted = await postRoomMessage(
{ roomMessages, publish: publisher.publish },
{
tenantId: TENANT,
workbenchId: WORKBENCH,
sender: { name: null, address: "run_myra@acme.example" },
runId: "run_myra",
parts: [
{
kind: "text",
text: "This agent could not complete your request due to a credential error [HTTP 401]: API key is invalid.",
},
],
},
);

expect(posted.parts).toEqual([
{
kind: "text",
text: "This agent could not complete your request due to a credential error",
},
]);
const listed = await roomMessages.listMessages({
tenantId: TENANT,
workbenchId: WORKBENCH,
});
expect(listed.items[0]?.parts).toEqual(posted.parts);
expect(JSON.stringify(publisher.published)).not.toMatch(/\[HTTP/);
expect(JSON.stringify(publisher.published)).not.toContain(
"API key is invalid",
);
});

test("an agent's message carries its run, a human's carries its principal", async () => {
const roomMessages = createInMemoryRoomMessageStore();
const publisher = recordingPublisher();
Expand Down Expand Up @@ -202,4 +239,17 @@ describe("previewOf", () => {
]),
).toBe("");
});

test("does not preview HTTP status or raw provider dumps", () => {
expect(
previewOf([
{
kind: "text",
text: "This agent could not complete your request due to a credential error [HTTP 401]: API key is invalid.",
},
]),
).toBe(
"This agent could not complete your request due to a credential error",
);
});
});
Loading
Loading