diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index 255cb6e4e..e8ce3a31a 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -61,12 +61,16 @@ 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 { + failedTurnModelChoices, + failedTurnToolCapableModelChoices, +} from "./failed-turn-models"; import { CHAT_STRINGS } from "./strings"; import { displayWorkbenchTitle } from "./workbench-display-title"; import { useStreamingReply, isAwaitingReply, + lastHumanMessageParts, typingAgentNames, } from "./streaming-reply"; import { useTurnActivity, TurnActivityStrip } from "./turn-activity"; @@ -1197,6 +1201,9 @@ function ChatWorkspaceInner({ } return { models: failedTurnModelChoices(catalogQuery.data ?? []), + toolCapableModels: failedTurnToolCapableModelChoices( + catalogQuery.data ?? [], + ), definitionIdByAddress, onApplyModel: async ({ definitionId, address, canonicalName }) => { if (activeWorkbenchId === null) return; @@ -1217,6 +1224,18 @@ function ChatWorkspaceInner({ activeWorkbenchId, ]); + const addressedMessageParts = useMemo( + () => + lastHumanMessageParts( + mergePendingSends( + messagesState.kind === "ready" ? messagesState.items : [], + pendingSends, + currentUser?.principalId, + ), + ), + [messagesState, pendingSends, currentUser?.principalId], + ); + // 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`/ @@ -1720,6 +1739,7 @@ function ChatWorkspaceInner({ names={typingAgentNames( streamingReply, activeWorkbench?.participants ?? [], + addressedMessageParts, )} /> ) diff --git a/packages/chat-ui/src/failed-turn-models.test.ts b/packages/chat-ui/src/failed-turn-models.test.ts new file mode 100644 index 000000000..53386a30b --- /dev/null +++ b/packages/chat-ui/src/failed-turn-models.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; +import type { ModelInfo } from "@corbits/inference-settings"; + +import { + failedTurnModelChoices, + failedTurnToolCapableModelChoices, +} from "./failed-turn-models"; + +function model( + canonicalName: string, + capabilities: ModelInfo["offerings"][number]["capabilities"], + displayName = canonicalName, +): ModelInfo { + return { + id: `model-${canonicalName}`, + canonicalName, + displayName, + offerings: [ + { + offeringId: `offering-${canonicalName}`, + providerId: "provider-a", + providerName: "anthropic", + plugin: "anthropic", + priority: 0, + deploymentTags: [], + capabilities: [...capabilities], + pricing: [], + }, + ], + }; +} + +const CHAT_ONLY = model("google/gemini-2.5-flash", ["plain-text"], "Flash"); +const TOOL_CAPABLE = model( + "anthropic/claude-sonnet", + ["plain-text", "function-calling-multi-turn"], + "Sonnet", +); +const UNCATALOGED = model("qwen3:8b", [], "qwen3:8b"); + +describe("failedTurnModelChoices", () => { + test("keeps chat-capable models, including ones without tools", () => { + const choices = failedTurnModelChoices([ + CHAT_ONLY, + TOOL_CAPABLE, + UNCATALOGED, + ]); + expect(choices.map((choice) => choice.canonicalName)).toEqual([ + "google/gemini-2.5-flash", + "anthropic/claude-sonnet", + "qwen3:8b", + ]); + }); +}); + +describe("failedTurnToolCapableModelChoices", () => { + test("keeps only models whose offerings advertise function-calling", () => { + const choices = failedTurnToolCapableModelChoices([ + CHAT_ONLY, + TOOL_CAPABLE, + UNCATALOGED, + ]); + expect(choices.map((choice) => choice.canonicalName)).toEqual([ + "anthropic/claude-sonnet", + ]); + }); +}); diff --git a/packages/chat-ui/src/failed-turn-models.ts b/packages/chat-ui/src/failed-turn-models.ts index 6b5f686f4..0503233fd 100644 --- a/packages/chat-ui/src/failed-turn-models.ts +++ b/packages/chat-ui/src/failed-turn-models.ts @@ -13,16 +13,33 @@ export type FailedTurnModelChoice = { 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( +function offeringSupportsTools(capabilities: readonly string[]): boolean { + return capabilities.some((capability) => + capability.startsWith("function-calling"), + ); +} + +function toolCapableModels(models: readonly ModelInfo[]): readonly ModelInfo[] { + const kept: ModelInfo[] = []; + for (const model of chatCapableModels(models)) { + const offerings = model.offerings.filter((offering) => + offeringSupportsTools(offering.capabilities), + ); + if (offerings.length === 0) continue; + kept.push( + offerings.length === model.offerings.length + ? model + : { ...model, offerings: [...offerings] }, + ); + } + return kept; +} + +function toChoices( models: readonly ModelInfo[], - limit = FAILED_TURN_MODEL_PICKER_LIMIT, + limit: number, ): readonly FailedTurnModelChoice[] { - return chatCapableModels(models) + return models .filter((model) => model.offerings.length > 0) .slice(0, limit) .map((model) => { @@ -38,3 +55,27 @@ export function failedTurnModelChoices( }; }); } + +/** + * 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 toChoices(chatCapableModels(models), limit); +} + +/** + * Same picker as {@link failedTurnModelChoices}, restricted to offerings + * that advertise function-calling — the recovery set when the failure + * was that the current model cannot use tools. + */ +export function failedTurnToolCapableModelChoices( + models: readonly ModelInfo[], + limit = FAILED_TURN_MODEL_PICKER_LIMIT, +): readonly FailedTurnModelChoice[] { + return toChoices(toolCapableModels(models), limit); +} diff --git a/packages/chat-ui/src/inference-failure.test.ts b/packages/chat-ui/src/inference-failure.test.ts index 5ba8784b3..370a0fc85 100644 --- a/packages/chat-ui/src/inference-failure.test.ts +++ b/packages/chat-ui/src/inference-failure.test.ts @@ -28,6 +28,16 @@ describe("consumerFacingInferenceText", () => { expect(facing).not.toContain("API key is invalid"); }); + test("a tools-unsupported dump is one honest sentence, never HTTP or registry copy", () => { + const text = consumerFacingInferenceText( + "This agent could not complete your request due to an unrecoverable inference error [HTTP 400]: 'tools' is not supported with this model.", + ); + expect(text).toBe("This agent's model can't use tools."); + expect(text).not.toMatch(/HTTP/i); + expect(text).not.toContain("function-calling"); + expect(text).not.toContain("unrecoverable inference error"); + }); + 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."; diff --git a/packages/chat-ui/src/streaming-reply.test.ts b/packages/chat-ui/src/streaming-reply.test.ts index 86318645b..52222ae94 100644 --- a/packages/chat-ui/src/streaming-reply.test.ts +++ b/packages/chat-ui/src/streaming-reply.test.ts @@ -7,10 +7,13 @@ import { nextStreamingReplyState, openPendingReply, typingAgentNames, + lastHumanMessageParts, } from "./streaming-reply"; const HUMAN = { address: "prn_sawyer", handle: "Sawyer" }; +const ADA = { address: "prn_ada@acme.example", handle: "Ada" }; const MYRA = { address: "myra@agents.example", handle: "Myra" }; +const SCOUT = { address: "scout@agents.example", handle: "Scout" }; function agentEvent(inner: unknown) { return { eventType: "chat.agent", data: inner }; @@ -598,6 +601,88 @@ describe("typingAgentNames", () => { test("no agent participant on the workbench means nobody is named", () => { expect(typingAgentNames(awaiting(""), [HUMAN])).toEqual([]); }); + + test("names the mentioned agent, not the first agent on the workbench", () => { + const jimmy = { address: "jimmy@agents.example", handle: "jimmy" }; + expect( + typingAgentNames( + awaiting(""), + [HUMAN, MYRA, jimmy], + [{ kind: "text", text: "hey @jimmy take a look" }], + ), + ).toEqual(["Jimmy"]); + }); + + test("a 1:1 with no mention still names the only agent", () => { + expect( + typingAgentNames( + awaiting(""), + [HUMAN, MYRA], + [{ kind: "text", text: "hello" }], + ), + ).toEqual(["Myra"]); + }); + + test("two agents and no mention names nobody — we do not guess Myra", () => { + const jimmy = { address: "jimmy@agents.example", handle: "jimmy" }; + expect( + typingAgentNames( + awaiting(""), + [HUMAN, MYRA, jimmy], + [{ kind: "text", text: "hello everyone" }], + ), + ).toEqual([]); + }); + + test("a principal@domain human @mention of Scout among two agents names Scout", () => { + expect( + typingAgentNames( + awaiting(""), + [ADA, MYRA, SCOUT], + lastHumanMessageParts([ + { + sender: { address: ADA.address }, + parts: [{ kind: "text", text: "hey @Scout take a look" }], + }, + { + sender: { address: SCOUT.address }, + parts: [{ kind: "text", text: "stream" }], + streaming: true, + }, + ]), + ), + ).toEqual(["Scout"]); + }); +}); + +describe("lastHumanMessageParts", () => { + test("returns the latest human message, skipping agents and streaming bubbles", () => { + expect( + lastHumanMessageParts([ + { + sender: { address: "prn_ada@acme.example" }, + parts: [{ kind: "text", text: "older" }], + }, + { + sender: { address: "prn_ada@acme.example" }, + parts: [{ kind: "text", text: "hey @Scout" }], + }, + { + sender: { address: "scout@agents.example" }, + parts: [{ kind: "text", text: "on it" }], + }, + { + sender: { address: "myra@agents.example" }, + parts: [{ kind: "text", text: "stream" }], + streaming: true, + }, + ]), + ).toEqual([{ kind: "text", text: "hey @Scout" }]); + }); + + test("empty timeline means no addressed parts", () => { + expect(lastHumanMessageParts([])).toBeUndefined(); + }); }); describe("hydrateStreamingReplyFromTurn (CL-6380: reattach snapshot)", () => { diff --git a/packages/chat-ui/src/streaming-reply.ts b/packages/chat-ui/src/streaming-reply.ts index 70e6b2cce..60ed1cea3 100644 --- a/packages/chat-ui/src/streaming-reply.ts +++ b/packages/chat-ui/src/streaming-reply.ts @@ -13,9 +13,9 @@ import { useEffect, useRef, useState } from "react"; -import { isAgentAddress } from "@corbits/chat/mentions"; +import { isAgentAddress, mentionedParticipants } from "@corbits/chat/mentions"; import { reportError } from "@corbits/error-sink"; -import type { ParticipantRecord } from "./api"; +import type { Part, ParticipantRecord } from "./api"; import { displayNameFromHandle } from "./timeline"; /** @@ -455,24 +455,49 @@ export function useStreamingReply( /** * The handle(s) to show as "typing" in the incoming-message slot while a reply is - * owed but no tokens have streamed yet — mirrors `mergeStreamingReply`'s - * attribution exactly (the - * workbench's first agent participant), since a `chat.agent` event carries no - * sender of its own. Workbenches with more than one invited agent are the - * same known approximation `mergeStreamingReply` already documents, not a - * new gap: only the streaming turn's actual agent can be named once the - * wire event carries one. + * owed but no tokens have streamed yet. Names the agent the latest human + * message addressed (`@handle`); a 1:1 with no mention still names that + * one agent. Two or more agents and no mention names nobody — guessing + * the workbench's first participant is how "Myra is typing" lied while + * Scout was the one asked. */ export function typingAgentNames( streamingReply: StreamingReplyState, participants: readonly ParticipantRecord[], + parts?: readonly Part[], ): readonly string[] { - // Only the tokenless awaiting phase shows the typing line; once text - // streams, the growing timeline bubble is the signal, and a `"replied"` - // turn shows nothing at all. if (!isPendingReply(streamingReply)) return []; - const agent = participants.find((participant) => + const agents = participants.filter((participant) => isAgentAddress(participant.address), ); - return agent === undefined ? [] : [displayNameFromHandle(agent.handle)]; + if (agents.length === 0) return []; + + const addressed = + parts === undefined ? [] : mentionedParticipants(parts, participants); + const named = + addressed.length > 0 + ? agents.filter((agent) => addressed.includes(agent.address)) + : agents.length === 1 + ? agents + : []; + return named.map((agent) => displayNameFromHandle(agent.handle)); +} + +/** The latest human (non-agent, non-streaming) message's parts — what + * `typingAgentNames` uses to see who was addressed. */ +export function lastHumanMessageParts( + items: readonly { + readonly sender: { readonly address: string }; + readonly parts: readonly Part[]; + readonly streaming?: boolean; + }[], +): readonly Part[] | undefined { + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + if (item === undefined) continue; + if (item.streaming === true) continue; + if (isAgentAddress(item.sender.address)) continue; + return item.parts; + } + return undefined; } diff --git a/packages/chat-ui/src/strings.ts b/packages/chat-ui/src/strings.ts index 0722209a0..92814e0f4 100644 --- a/packages/chat-ui/src/strings.ts +++ b/packages/chat-ui/src/strings.ts @@ -324,6 +324,8 @@ export const CHAT_STRINGS = { turnFailedTitle: (sender: string) => `${sender} didn't reply`, turnFailedModelUnavailable: (sender: string) => `${sender}'s model isn't available here.`, + turnFailedToolsUnsupported: (sender: string) => + `${sender}'s model can't use tools.`, turnFailedPickModel: "Pick a model", turnFailedMoreInSettings: "More in Settings", turnCancelledTitle: (sender: string) => `You stopped ${sender}'s turn`, diff --git a/packages/chat-ui/src/timeline.tsx b/packages/chat-ui/src/timeline.tsx index 8d21ffd2a..41b26a10c 100644 --- a/packages/chat-ui/src/timeline.tsx +++ b/packages/chat-ui/src/timeline.tsx @@ -726,6 +726,9 @@ function EventLine({ */ export type FailedTurnRecovery = { readonly models: readonly FailedTurnModelChoice[]; + /** Tool-capable subset for a tools-unsupported recovery. Empty means + * the strip still hops to Settings, with no inline picker. */ + readonly toolCapableModels?: readonly FailedTurnModelChoice[]; readonly definitionIdByAddress: Readonly>; readonly onApplyModel: (input: { readonly definitionId: string; @@ -746,7 +749,7 @@ export type FailedTurnRecovery = { * under the same left gutter every message bubble sits under: muted * danger-tinted copy, a small ghost Retry button, and "What happened" as * a subtle inline disclosure rather than a second button competing for - * attention. A model-unavailable notice (`turnFailedReason`) replaces + * attention. A named-recovery notice (`turnFailedReason`) replaces * Retry with an inline model picker and a real Settings hop. * `onRetry`/`onWhatHappened` are the host's own actions; a * host that wires neither still gets the row, just with inert controls — @@ -757,7 +760,7 @@ function FailedTurnStrip({ item, detailText, retryText, - modelUnavailable, + namedRecovery, participants, currentUser, failedTurnRecovery, @@ -777,7 +780,7 @@ function FailedTurnStrip({ * `findRetryText` — handed to `onRetryFailedTurn` so Retry has * something to resend rather than nothing. */ readonly retryText?: string; - readonly modelUnavailable?: boolean; + readonly namedRecovery?: "model_unavailable" | "tools_unsupported"; readonly participants: readonly ParticipantRecord[]; readonly currentUser: CurrentUser | undefined; readonly failedTurnRecovery?: FailedTurnRecovery; @@ -797,15 +800,21 @@ function FailedTurnStrip({ const definitionId = failedTurnRecovery?.definitionIdByAddress[item.sender.address]; - if (modelUnavailable === true) { + if (namedRecovery !== undefined) { + const recoveryTitle = + namedRecovery === "tools_unsupported" + ? CHAT_STRINGS.turnFailedToolsUnsupported(sender) + : CHAT_STRINGS.turnFailedModelUnavailable(sender); + const pickerModels = + namedRecovery === "tools_unsupported" + ? (failedTurnRecovery?.toolCapableModels ?? []) + : (failedTurnRecovery?.models ?? []); return (
- - {CHAT_STRINGS.turnFailedModelUnavailable(sender)} - + {recoveryTitle} {failedTurnRecovery !== undefined && definitionId !== undefined && - failedTurnRecovery.models.length > 0 ? ( + pickerModels.length > 0 ? (