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
7 changes: 4 additions & 3 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2216,9 +2216,10 @@ export async function createHub(config: HubConfig) {
// A connection completing through ANY door below — OAuth callback,
// pasted key, MCP OAuth, keyless MCP preset — settles every room
// waiting on that connector: the room's `connections/pending` entry
// clears (flipping the in-room connect card via `chat.settings`), and
// the host agent is woken via `dispatchTurn` without a forged
// signed-in-user timeline row.
// clears (flipping the in-room connect card via `chat.settings`).
// Rooms with `connections/pending` still wake the host agent via
// `dispatchTurn` without a forged signed-in-user timeline row;
// code-review template rooms are not woken on PAT settle.
//
// An inference provider's credential landing also re-checks every
// live participant's deployed inference chain (CL-6687): a rotated
Expand Down
22 changes: 11 additions & 11 deletions packages/chat-ui/src/inference-failure.test.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,23 @@
import { describe, expect, test } from "bun:test";

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

describe("consumerFacingInferenceText", () => {
test("keeps the vendor preamble and drops [HTTP …] plus the raw provider message", () => {
test("maps a classified credential failure to one recovery sentence", () => {
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",
);
).toBe(CONSUMER_INFERENCE_FAILURE_NOTICE);
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",
);
).toBe(CONSUMER_INFERENCE_FAILURE_NOTICE);
});

test("a forced [HTTP 401] dump is not consumer copy", () => {
Expand All @@ -47,12 +44,15 @@ describe("isClassifiedInferenceFailureText", () => {
).toBe(true);
});

test("matches a credential_failure reply after HTTP/raw is stripped", () => {
test("a stripped credential_failure reply is recovery copy, not the classified preamble", () => {
const facing = consumerFacingInferenceText(
"This agent could not complete your request due to a credential error [HTTP 401]: invalid api key",
);
expect(facing).toBe(CONSUMER_INFERENCE_FAILURE_NOTICE);
expect(facing.toLowerCase()).not.toContain("credential error");
expect(
isClassifiedInferenceFailureText(
consumerFacingInferenceText(
"This agent could not complete your request due to a credential error [HTTP 401]: invalid api key",
),
"This agent could not complete your request due to a credential error [HTTP 401]: invalid api key",
),
).toBe(true);
});
Expand Down
5 changes: 4 additions & 1 deletion packages/chat-ui/src/inference-failure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ 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 {
CONSUMER_INFERENCE_FAILURE_NOTICE,
consumerFacingInferenceText,
} from "@corbits/chat/consumer-inference-text";

export function isClassifiedInferenceFailureText(text: string): boolean {
return CLASSIFIED_INFERENCE_FAILURE_PREAMBLES.some((preamble) =>
Expand Down
10 changes: 8 additions & 2 deletions packages/chat-ui/src/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import type { ConnectGithubActions } from "./blocks/connect-github-actions";
import type { ConnectServiceActions } from "./blocks/connect-service-actions";
import { BlockPartView } from "./blocks/registry";
import {
CONSUMER_INFERENCE_FAILURE_NOTICE,
consumerFacingInferenceText,
isClassifiedInferenceFailureText,
} from "./inference-failure";
Expand Down Expand Up @@ -551,7 +552,8 @@ function TextBubble({
<Markdown text={consumerText} />
</div>
{onFixConnection !== undefined &&
isClassifiedInferenceFailureText(consumerText) && (
(isClassifiedInferenceFailureText(text) ||
consumerText === CONSUMER_INFERENCE_FAILURE_NOTICE) && (
<Button
type="button"
variant="outline"
Expand Down Expand Up @@ -1066,7 +1068,11 @@ export function offersMessageSocialChrome(item: MessageItem): boolean {
if (part.kind === "event") return true;
if (part.kind === "text" && part.turnFailed === true) return true;
if (part.kind === "text" && part.turnCancelled === true) return true;
if (part.kind === "text" && isClassifiedInferenceFailureText(part.text)) {
if (
part.kind === "text" &&
(isClassifiedInferenceFailureText(part.text) ||
part.text === CONSUMER_INFERENCE_FAILURE_NOTICE)
) {
return true;
}
if (part.kind === "block") {
Expand Down
3 changes: 2 additions & 1 deletion packages/chat-ui/test/components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -508,8 +508,9 @@ describe("WorkbenchTimeline", () => {
expect(markup).not.toMatch(/\[HTTP/);
expect(markup).not.toContain("401");
expect(markup).not.toContain("invalid api key");
expect(markup).not.toMatch(/credential error/i);
expect(markup).toContain(
"This agent could not complete your request due to a credential error",
"This didn&#x27;t go through. Try again, or check the connection in Settings.",
);
expect(markup).toContain("Fix this connection");
});
Expand Down
3 changes: 2 additions & 1 deletion packages/chat-ui/test/failed-turn-strip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,9 @@ describe("the failed-turn notice renders through PrFailedTurnStrip", () => {

const detail = container.querySelector(".chat-turn-failed-detail");
expect(detail?.textContent).toBe(
"This agent could not complete your request due to a credential error",
"This didn't go through. Try again, or check the connection in Settings.",
);
expect(detail?.textContent).not.toMatch(/credential error/i);
expect(detail?.textContent).not.toMatch(/\[HTTP/);
expect(detail?.textContent).not.toContain("401");
expect(detail?.textContent).not.toContain("API key is invalid");
Expand Down
18 changes: 12 additions & 6 deletions packages/chat/src/connect-pending.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,12 +193,18 @@ export async function settleConnectedService(
// A room matched only through the template key never wakes an agent:
// its walkthrough was posted by the product, not asked for by an
// agent mid-turn, and the room's first agent participant may be a
// reviewer whose prompt only speaks JSON. A room whose own
// `connections/pending` names the connector did have an agent ask
// for it, so that agent still gets woken.
const agentAddress = matchedPending
? hostAgentAddress(updated.settings, input.principalId)
: undefined;
// reviewer whose prompt only speaks JSON. A code-review template
// room never wakes on GitHub settle even when `connections/pending`
// also names it (CL-6764): reviewers introduce themselves from
// canned copy after start-reviewing, and dispatching the first
// participant here is a credential-error bubble after a successful
// Connect. A non-template room whose own `connections/pending`
// names the connector did have an agent ask for it, so that agent
// still gets woken.
const agentAddress =
matchedPending && row.settings["template/id"] !== "code-review"
? hostAgentAddress(updated.settings, input.principalId)
: undefined;
// CL-6741: event-only system row — never a signed-in user text bubble.
// Sender is the woken agent when there is one; otherwise a synthetic
// system address so the row never attributes to the connecting person.
Expand Down
29 changes: 23 additions & 6 deletions packages/chat/src/consumer-inference-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,31 @@ describe("consumerFacingInferenceText", () => {
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(
test("a classified credential failure is one recovery sentence, not a credential-error bubble", () => {
const text = consumerFacingInferenceText(
"This agent could not complete your request due to a credential error [HTTP 401]: API key is invalid.",
);
expect(text).toBe(CONSUMER_INFERENCE_FAILURE_NOTICE);
expect(text.toLowerCase()).not.toContain("credential error");
expect(text).not.toMatch(/\[HTTP/i);
expect(text).not.toMatch(/401/);
expect(text.toLowerCase()).not.toContain("api key is invalid");
});

test("a classified credential preamble without an HTTP dump is still recovery copy", () => {
const text = consumerFacingInferenceText(
"This agent could not complete your request due to a credential error",
);
expect(text).toBe(CONSUMER_INFERENCE_FAILURE_NOTICE);
expect(text.toLowerCase()).not.toContain("credential error");
});

test("a classified quota failure is the same recovery sentence", () => {
const text = consumerFacingInferenceText(
"This agent could not complete your request because the API quota has been exhausted [HTTP 429]: rate limited",
);
expect(text).toBe(CONSUMER_INFERENCE_FAILURE_NOTICE);
expect(text).not.toMatch(/\[HTTP/i);
});

test("a forced HTTP dump is not consumer copy", () => {
Expand Down
17 changes: 11 additions & 6 deletions packages/chat/src/consumer-inference-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,19 @@ 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. */
/** Drop HTTP status, raw provider text, and classified failure preambles. */
export function consumerFacingInferenceText(raw: string): string {
if (!needsSanitization(raw)) return raw;
if (!needsSanitization(raw) && !isClassifiedInferenceFailureText(raw)) {
return raw;
}
const stripped = raw.replace(TRAILING_HTTP_DUMP, "").trim();
if (stripped.length > 0 && !needsSanitization(stripped)) return stripped;
if (
stripped.length > 0 &&
!needsSanitization(stripped) &&
!isClassifiedInferenceFailureText(stripped)
) {
return stripped;
}
return CONSUMER_INFERENCE_FAILURE_NOTICE;
}

Expand All @@ -54,8 +62,5 @@ export function isClassifiedInferenceFailureText(text: string): boolean {
*/
export function activityPreviewText(raw: string): string {
const facing = consumerFacingInferenceText(raw);
if (isClassifiedInferenceFailureText(facing)) {
return CONSUMER_INFERENCE_FAILURE_NOTICE;
}
return facing;
}
2 changes: 1 addition & 1 deletion packages/chat/src/room-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ describe("postRoomMessage", () => {
expect(posted.parts).toEqual([
{
kind: "text",
text: "This agent could not complete your request due to a credential error",
text: CONSUMER_INFERENCE_FAILURE_NOTICE,
},
]);
const listed = await roomMessages.listMessages({
Expand Down
4 changes: 2 additions & 2 deletions packages/chat/src/room-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
activityPreviewText,
CONSUMER_INFERENCE_FAILURE_NOTICE,
consumerFacingInferenceText,
isClassifiedInferenceFailureText,
} from "./consumer-inference-text";

import { ChatMessageEventData } from "./stream-events";
Expand Down Expand Up @@ -174,7 +173,8 @@ function isFailurePreviewParts(parts: readonly Part[]): boolean {
.replace(/\s+/g, " ")
.trim();
if (joined.length === 0) return false;
return isClassifiedInferenceFailureText(consumerFacingInferenceText(joined));
const facing = consumerFacingInferenceText(joined);
return facing === CONSUMER_INFERENCE_FAILURE_NOTICE;
}

/**
Expand Down
85 changes: 85 additions & 0 deletions packages/chat/test/connect-pending.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,91 @@ test("settles a room whose GitHub card is pending under the code-review template
expect(turns).toHaveLength(0);
});

test("a code-review template room never wakes an agent when GitHub connects — even if connections/pending also names it", async () => {
const { store, roomMessages, platform, agentTurns, deps } = buildDeps();
await store.createWorkbenchSettings({
tenantId: TENANT.id,
workbenchId: "chan_review",
settings: {
"chat/kind": "workbench",
"chat/participants": [
{ address: HUMAN_ADDRESS, handle: "owner" },
{
address: "ins_architecture-reviewer@acme.example",
handle: "architecture-reviewer",
},
{
address: "ins_correctness-reviewer@acme.example",
handle: "correctness-reviewer",
},
],
"template/id": "code-review",
"connections/pending": ["github"],
"template/pendingConnections": ["github"],
},
updatedBy: "prn_owner",
});

await settleConnectedService(deps, {
tenantId: TENANT.id,
principalId: "prn_owner",
connectorId: "github",
displayName: "GitHub",
});

const settled = await store.getWorkbenchSettings(TENANT.id, "chan_review");
expect(settled?.settings["connections/pending"]).toEqual([]);
expect(settled?.settings["template/pendingConnections"]).toEqual([]);

const listed = await roomMessages.listMessages({
tenantId: TENANT.id,
workbenchId: "chan_review",
});
expectEventOnlySettleNotice(listed.items, "GitHub");
expect(listed.items[0]?.sender.address).toBe("system@chan_review");
expect(platform.sentMail).toHaveLength(0);
const turns = await agentTurns.listTurns({
tenantId: TENANT.id,
workbenchId: "chan_review",
});
expect(turns).toHaveLength(0);
});

test("a code-review room that only has connections/pending for GitHub still does not dispatch a reviewer", async () => {
const { store, platform, agentTurns, deps } = buildDeps();
await store.createWorkbenchSettings({
tenantId: TENANT.id,
workbenchId: "chan_review_pending",
settings: {
"chat/kind": "workbench",
"chat/participants": [
{ address: HUMAN_ADDRESS, handle: "owner" },
{
address: "ins_architecture-reviewer@acme.example",
handle: "architecture-reviewer",
},
],
"template/id": "code-review",
"connections/pending": ["github"],
},
updatedBy: "prn_owner",
});

await settleConnectedService(deps, {
tenantId: TENANT.id,
principalId: "prn_owner",
connectorId: "github",
displayName: "GitHub",
});

expect(platform.sentMail).toHaveLength(0);
const turns = await agentTurns.listTurns({
tenantId: TENANT.id,
workbenchId: "chan_review_pending",
});
expect(turns).toHaveLength(0);
});

test("a room pending under both keys still wakes its host agent", async () => {
const { store, roomMessages, platform, agentTurns, deps } = buildDeps();
await store.createWorkbenchSettings({
Expand Down
Loading