Skip to content
24 changes: 24 additions & 0 deletions apps/hub/src/hub-error-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { Hono } from "hono";
import { configureSync, resetSync } from "@intx/log";
import { InferenceResolutionError } from "@corbits/folded-runs";

import { hubErrorHandler } from "./hub-error-handler";

Expand Down Expand Up @@ -84,4 +85,27 @@ describe("hubErrorHandler", () => {
expect(records).toHaveLength(1);
expect(records[0]?.properties.refId).toBe(body.error.refId);
});

test("maps InferenceResolutionError to 422 with consumer copy, not 500", async () => {
const app = new Hono();
app.onError(hubErrorHandler());
app.get("/wake", () => {
throw new InferenceResolutionError(
"the woken instance",
'No launchable inference source for model "claude-sonnet-5"',
);
});

const res = await app.request("/wake");

expect(res.status).toBe(422);
const body = (await res.json()) as {
error: { code: string; message: string; refId: string };
};
expect(body.error.code).toBe("InferenceResolutionError");
expect(body.error.message).toBe("This agent's model isn't available here.");
expect(body.error.message).not.toContain("claude-sonnet-5");
expect(body.error.message).not.toMatch(/cannot resolve an inference/);
expect(typeof body.error.refId).toBe("string");
});
});
7 changes: 3 additions & 4 deletions apps/hub/src/hub-error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,9 @@ export function hubErrorHandler() {
});

if (hasGuidance(err)) {
return c.json(
{ error: { code: err.name, message: err.message, refId } },
422,
);
const message =
err.name === "InferenceResolutionError" ? err.guidance : err.message;
return c.json({ error: { code: err.name, message, refId } }, 422);
}

return c.json(
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/pages/chat-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -261,11 +261,15 @@ export function ChatPage({
onOpenProfile={openProfile}
registerComposerInsert={registerComposerInsert}
settingsOpen={settingsOpen}
onSettingsOpenChange={(open, section) => {
onSettingsOpenChange={(open, section, entityId) => {
if (workbenchId === null) return;
navigate(
open
? workbenchSettingsPath(workbenchId, section ?? settingsSection)
? workbenchSettingsPath(
workbenchId,
section ?? settingsSection,
entityId,
)
: workbenchPath(workbenchId),
);
}}
Expand Down
7 changes: 7 additions & 0 deletions docs/CHAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,13 @@ that timeout plus a settle grace (`AGENT_TURN_STALE_MS`) on their next
read or write: the room shows a failed turn instead of typing forever,
and the reply path can never attribute a later reply to a dead row.

**A model that cannot resolve is a failed turn, not a 500.** Wake and
mint map an unresolvable inference source to a consumer 4xx and a
failed-turn strip ("Jimmy's model isn't available here.") with an
inline picker of tenant-available chat models and a hop into that
agent's Settings — never a raw HTTP 500 or the technical resolution
dump on the timeline.

Proved live end to end by `scripts/e2e/cl-6329-turn-swap-proof.ts`: two
agents replying in one room under distinct occurrences, three rapid
messages serializing into ordered turns, and a sidecar killed
Expand Down
58 changes: 56 additions & 2 deletions packages/chat-ui/src/chat-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import { isAgentAddress } from "@corbits/chat/mentions";
import { Button, EmptyState, toast } from "@corbits/react-ui";
import { reportError } from "@corbits/error-sink";
import { getResolvedCatalog } from "@corbits/inference-settings";
import {
CaretDown,
ChatCircle,
Expand All @@ -34,6 +35,9 @@ import {
inviteAgent,
listWorkbenches,
listInvitableDefinitions,
listWorkbenchAgents,
addAgentCapability,
refreshWorkbenchAgent,
pingWorkbenchPresence,
pinMessage,
toggleReaction,
Expand All @@ -57,7 +61,7 @@ import {
import type { BringInListFailure, BringInMember } from "./mentions";
import { PinnedStrip } from "./pinned-strip";
import { SLASH_COMMANDS } from "./slash-commands";

import { failedTurnModelChoices } from "./failed-turn-models";
import { CHAT_STRINGS } from "./strings";
import { displayWorkbenchTitle } from "./workbench-display-title";
import {
Expand All @@ -73,6 +77,7 @@ import {
messageDomId,
messageText,
} from "./timeline";
import type { FailedTurnRecovery } from "./timeline";
import { NoUsableModelBanner } from "./no-usable-model-banner";
import { ResumeFailedBanner } from "./resume-failed-banner";
import type {
Expand Down Expand Up @@ -618,6 +623,7 @@ function ChatWorkspaceInner({
readonly onSettingsOpenChange?: (
open: boolean,
section?: WorkbenchSettingsSectionId,
entityId?: string,
) => void;
/** Which workbench settings tab is active while the surface is open —
* host-controlled the same way `settingsOpen` is, driven from the URL
Expand Down Expand Up @@ -997,8 +1003,9 @@ function ChatWorkspaceInner({
* that lands is always the one the caller meant to open. */
function openWorkbenchSettings(
section: WorkbenchSettingsSectionId = "general",
entityId?: string,
) {
onSettingsOpenChange?.(true, section);
onSettingsOpenChange?.(true, section, entityId);
}

async function handleInvite(definitionId: string) {
Expand Down Expand Up @@ -1125,6 +1132,51 @@ function ChatWorkspaceInner({
[handleSend],
);

const catalogQuery = useQuery({
queryKey: ["tenant", tenantId, "resolved-catalog"],
queryFn: () => getResolvedCatalog(tenantId),
});
const workbenchAgentsQuery = useQuery({
queryKey: [
"tenant",
tenantId,
"chat",
"workbench-agents",
activeWorkbenchId,
],
queryFn: () =>
activeWorkbenchId !== null
? listWorkbenchAgents(tenantId, activeWorkbenchId)
: Promise.resolve([]),
enabled: activeWorkbenchId !== null,
});
const failedTurnRecovery = useMemo((): FailedTurnRecovery => {
const definitionIdByAddress: Record<string, string> = {};
for (const agent of workbenchAgentsQuery.data ?? []) {
definitionIdByAddress[agent.address] = agent.definitionId;
}
return {
models: failedTurnModelChoices(catalogQuery.data ?? []),
definitionIdByAddress,
onApplyModel: async ({ definitionId, address, canonicalName }) => {
if (activeWorkbenchId === null) return;
await addAgentCapability(tenantId, definitionId, {
kind: "model",
canonicalName,
});
await refreshWorkbenchAgent(tenantId, activeWorkbenchId, address);
},
onOpenAgentSettings: (definitionId) => {
openWorkbenchSettings("agents", definitionId);
},
};
}, [
catalogQuery.data,
workbenchAgentsQuery.data,
tenantId,
activeWorkbenchId,
]);

// The mention popover's "Bring in…" group: only a `workbench` grows its
// participants after creation (a chat's counterpart is fixed at
// creation — see `workbench-service.ts`'s `joinHumanParticipant`/
Expand Down Expand Up @@ -1605,6 +1657,7 @@ function ChatWorkspaceInner({
reactionActions={reactionActions}
pinActions={pinActions}
onRetryFailedTurn={handleRetryFailedTurn}
failedTurnRecovery={failedTurnRecovery}
pendingActions={{
onRetry: retryPendingSend,
onDiscard: discardPendingSend,
Expand Down Expand Up @@ -1753,6 +1806,7 @@ export function ChatWorkspace({
readonly onSettingsOpenChange?: (
open: boolean,
section?: WorkbenchSettingsSectionId,
entityId?: string,
) => void;
/** Which workbench settings tab is active — host-controlled from the URL
* (`/w/:id/settings/:section`). */
Expand Down
40 changes: 40 additions & 0 deletions packages/chat-ui/src/failed-turn-models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {
chatCapableModels,
providerDisplayName,
} from "@corbits/inference-settings";
import type { ModelInfo } from "@corbits/inference-settings";

import { CHAT_STRINGS } from "./strings";

export const FAILED_TURN_MODEL_PICKER_LIMIT = 4;

export type FailedTurnModelChoice = {
readonly canonicalName: string;
readonly label: string;
};

/**
* Two-to-four tenant-available chat models for the failed-turn strip
* picker — the same connected, chat-capable filter Settings' agent
* model select uses, capped so the strip stays a quiet inline row.
*/
export function failedTurnModelChoices(
models: readonly ModelInfo[],
limit = FAILED_TURN_MODEL_PICKER_LIMIT,
): readonly FailedTurnModelChoice[] {
return chatCapableModels(models)
.filter((model) => model.offerings.length > 0)
.slice(0, limit)
.map((model) => {
const topOffering = model.offerings[0];
return {
canonicalName: model.canonicalName,
label: CHAT_STRINGS.workbenchSettingsAgentDetailModelOption(
model.displayName ?? model.canonicalName,
topOffering === undefined
? ""
: providerDisplayName(topOffering.providerName),
),
};
});
}
4 changes: 4 additions & 0 deletions packages/chat-ui/src/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,10 @@ export const CHAT_STRINGS = {
`Couldn't resume the running reply — try again. (ref ${refId})`,
resumeFailedRetryAction: "Retry",
turnFailedTitle: (sender: string) => `${sender} didn't reply`,
turnFailedModelUnavailable: (sender: string) =>
`${sender}'s model isn't available here.`,
turnFailedPickModel: "Pick a model",
turnFailedMoreInSettings: "More in Settings",
turnCancelledTitle: (sender: string) => `You stopped ${sender}'s turn`,
turnFailedSub: "No reply arrived — the agent may be unavailable.",
noUsableModelBannerText:
Expand Down
22 changes: 22 additions & 0 deletions packages/chat-ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -3965,6 +3965,28 @@
font-size: 0.75rem;
}

.chat-turn-failed-models {
height: auto;
max-width: 12rem;
padding: 0.05rem 0.45rem;
font-size: 0.75rem;
}

.chat-turn-failed-settings {
border: 0;
background: transparent;
padding: 0;
font-size: 0.75rem;
color: var(--muted-foreground);
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}

.chat-turn-failed-settings:hover {
color: var(--foreground);
}

.chat-turn-failed-disclosure {
border: 0;
background: transparent;
Expand Down
Loading
Loading