From b64789c5792b0b5479dc9d3ebf7bd39f339e8fbf Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 3 Sep 2026 01:03:13 -0700 Subject: [PATCH 1/5] Add tests for tools-unsupported failed-turn recovery (CL-6658) Cover classifying a tools-unsupported inference dump, the failed-turn strip honest copy and tool-capable picker, and naming the addressed agent on the typing indicator. --- .../chat-ui/src/failed-turn-models.test.ts | 67 +++++++++++++ .../chat-ui/src/inference-failure.test.ts | 10 ++ packages/chat-ui/src/streaming-reply.test.ts | 63 ++++++++++++ .../chat-ui/test/failed-turn-strip.test.tsx | 99 +++++++++++++++++++ .../chat/src/consumer-inference-text.test.ts | 10 ++ packages/chat/src/tools-unsupported.test.ts | 72 ++++++++++++++ packages/chat/test/chat-orchestrator.test.ts | 99 ++++++++++++++++++- packages/chat/test/parts.test.ts | 10 ++ 8 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 packages/chat-ui/src/failed-turn-models.test.ts create mode 100644 packages/chat/src/tools-unsupported.test.ts 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/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..984b29fe5 100644 --- a/packages/chat-ui/src/streaming-reply.test.ts +++ b/packages/chat-ui/src/streaming-reply.test.ts @@ -7,6 +7,7 @@ import { nextStreamingReplyState, openPendingReply, typingAgentNames, + lastHumanMessageParts, } from "./streaming-reply"; const HUMAN = { address: "prn_sawyer", handle: "Sawyer" }; @@ -598,6 +599,68 @@ 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([]); + }); +}); + +describe("lastHumanMessageParts", () => { + test("returns the latest human message, skipping agents and streaming bubbles", () => { + expect( + lastHumanMessageParts([ + { + sender: { address: "prn_sawyer" }, + parts: [{ kind: "text", text: "older" }], + }, + { + sender: { address: "prn_sawyer" }, + parts: [{ kind: "text", text: "hey @jimmy" }], + }, + { + sender: { address: "jimmy@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 @jimmy" }]); + }); + + test("empty timeline means no addressed parts", () => { + expect(lastHumanMessageParts([])).toBeUndefined(); + }); }); describe("hydrateStreamingReplyFromTurn (CL-6380: reattach snapshot)", () => { diff --git a/packages/chat-ui/test/failed-turn-strip.test.tsx b/packages/chat-ui/test/failed-turn-strip.test.tsx index 49a9c5674..694efa907 100644 --- a/packages/chat-ui/test/failed-turn-strip.test.tsx +++ b/packages/chat-ui/test/failed-turn-strip.test.tsx @@ -416,4 +416,103 @@ describe("the failed-turn notice renders through PrFailedTurnStrip", () => { }); expect(opened).toEqual(["wfd_echo"]); }); + + test("a tools-unsupported notice shows honest copy, a tool-capable picker, and More in Settings", async () => { + const applied: string[] = []; + const retried: (string | undefined)[] = []; + const opened: string[] = []; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render( + { + applied.push(canonicalName); + }, + onOpenAgentSettings: (definitionId) => { + opened.push(definitionId); + }, + }} + onRetryFailedTurn={(_item, retryText) => { + retried.push(retryText); + }} + />, + ); + }); + + const strip = container.querySelector(".chat-turn-failed"); + expect(strip?.textContent).toContain("Jimmy's model can't use tools."); + expect(strip?.textContent).not.toContain("didn't reply"); + expect(strip?.textContent).not.toContain("isn't available here"); + expect(strip?.textContent).not.toMatch(/HTTP/); + expect(strip?.textContent).not.toContain("function-calling"); + expect(strip?.textContent).not.toContain("wfd_echo"); + expect(container.querySelector(".chat-turn-failed-retry")).toBeNull(); + + const select = container.querySelector( + ".chat-turn-failed-models", + ) as HTMLSelectElement; + expect(select).not.toBeNull(); + expect([...select.options].map((option) => option.textContent)).toEqual([ + "Pick a model", + "Sonnet", + "GPT-4.1", + ]); + expect( + [...select.options].map((option) => option.textContent), + ).not.toContain("Flash"); + + await act(async () => { + select.value = "anthropic/claude-sonnet"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(applied).toEqual(["anthropic/claude-sonnet"]); + expect(retried).toEqual(["hi @echo"]); + + const settings = container.querySelector( + ".chat-turn-failed-settings", + ) as HTMLButtonElement; + expect(settings.textContent).toBe("More in Settings"); + act(() => { + settings.click(); + }); + expect(opened).toEqual(["wfd_echo"]); + }); }); diff --git a/packages/chat/src/consumer-inference-text.test.ts b/packages/chat/src/consumer-inference-text.test.ts index 4cb7f41fb..77c74af25 100644 --- a/packages/chat/src/consumer-inference-text.test.ts +++ b/packages/chat/src/consumer-inference-text.test.ts @@ -52,4 +52,14 @@ describe("consumerFacingInferenceText", () => { expect(text).toBe(CONSUMER_INFERENCE_FAILURE_NOTICE); expect(text.toLowerCase()).not.toContain("invalid_api_key"); }); + + test("a tools-unsupported dump is the honest sentence, never the fatal preamble or HTTP", () => { + 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("unrecoverable inference error"); + expect(text).not.toContain("not supported with this model"); + }); }); diff --git a/packages/chat/src/tools-unsupported.test.ts b/packages/chat/src/tools-unsupported.test.ts new file mode 100644 index 000000000..aa8af26fc --- /dev/null +++ b/packages/chat/src/tools-unsupported.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; + +import { + TOOLS_UNSUPPORTED_CONSUMER_MESSAGE, + isToolsUnsupportedInferenceText, +} from "./tools-unsupported"; + +const FATAL_PREAMBLE = + "This agent could not complete your request due to an unrecoverable inference error"; + +describe("isToolsUnsupportedInferenceText", () => { + test("classifies a formatted inference dump whose provider message is tools-unsupported", () => { + expect( + isToolsUnsupportedInferenceText( + `${FATAL_PREAMBLE} [HTTP 400]: 'tools' is not supported with this model.`, + ), + ).toBe(true); + }); + + test("classifies a JSON provider dump that says the model cannot use tools", () => { + expect( + isToolsUnsupportedInferenceText( + `${FATAL_PREAMBLE} [HTTP 400]: {"error":{"message":"does not support tools","type":"invalid_request_error"}}`, + ), + ).toBe(true); + }); + + test("classifies a registry capability miss for function-calling", () => { + expect( + isToolsUnsupportedInferenceText( + "offering anthropic/claude-haiku is missing required capability function-calling", + ), + ).toBe(true); + }); + + test("does not classify an unrelated HTTP 400", () => { + expect( + isToolsUnsupportedInferenceText( + `${FATAL_PREAMBLE} [HTTP 400]: invalid_request_error: max_tokens must be positive`, + ), + ).toBe(false); + }); + + test("does not classify embedding/chat-capability failures", () => { + expect( + isToolsUnsupportedInferenceText("this model does not support generate"), + ).toBe(false); + expect( + isToolsUnsupportedInferenceText("this model does not support chat"), + ).toBe(false); + }); + + test("does not classify ordinary replies that mention tools", () => { + expect( + isToolsUnsupportedInferenceText( + "I can use tools to look that up if you want.", + ), + ).toBe(false); + }); +}); + +describe("TOOLS_UNSUPPORTED_CONSUMER_MESSAGE", () => { + test("is one honest sentence with no HTTP, registry, or provider dump", () => { + expect(TOOLS_UNSUPPORTED_CONSUMER_MESSAGE).toBe( + "This agent's model can't use tools.", + ); + expect(TOOLS_UNSUPPORTED_CONSUMER_MESSAGE).not.toMatch(/HTTP/i); + expect(TOOLS_UNSUPPORTED_CONSUMER_MESSAGE.toLowerCase()).not.toContain( + "function-calling", + ); + }); +}); diff --git a/packages/chat/test/chat-orchestrator.test.ts b/packages/chat/test/chat-orchestrator.test.ts index fa40d7178..65030df43 100644 --- a/packages/chat/test/chat-orchestrator.test.ts +++ b/packages/chat/test/chat-orchestrator.test.ts @@ -1291,7 +1291,7 @@ describe("createChatOrchestrator", () => { orchestrator.dispose(); }); - test("message.run.ended posts the extracted error text for a failed turn", async () => { + test("message.run.ended posts a failed-turn notice for a failed turn, not a raw dump bubble", async () => { const room = fakeRoom(); const events = createSidecarEmitter(); const orchestrator = createChatOrchestrator({ @@ -1321,7 +1321,102 @@ describe("createChatOrchestrator", () => { expect(room.posted).toHaveLength(1); expect(room.posted[0]?.parts).toEqual([ - { kind: "text", text: "provider timed out" }, + { kind: "text", text: "provider timed out", turnFailed: true }, + ]); + + orchestrator.dispose(); + }); + + test("message.run.ended classifies a tools-unsupported failure as a failed-turn notice", async () => { + const room = fakeRoom(); + const events = createSidecarEmitter(); + const orchestrator = createChatOrchestrator({ + db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, + store: { + listWorkbenchSettings: async () => [ + workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), + ], + }, + roomMessages: room.roomMessages, + publish: room.publish, + platform: fakeMail().platform, + events, + claims: fakeClaims(), + approvals: { findByCorrelationId: async () => null }, + }); + + events.emit("agent.event", { + agentAddress: "ins_echo1@ten1.workbench.test", + sessionId: "ses_1", + event: { + type: "message.run.ended", + data: { + status: "failed", + error: { + message: + "This agent could not complete your request due to an unrecoverable inference error [HTTP 400]: 'tools' is not supported with this model.", + }, + }, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(room.posted).toHaveLength(1); + expect(room.posted[0]?.parts).toEqual([ + { + kind: "text", + text: "This agent's model can't use tools.", + turnFailed: true, + turnFailedReason: "tools_unsupported", + }, + ]); + const postedText = (room.posted[0]?.parts[0] as { text: string }).text; + expect(postedText).not.toMatch(/HTTP/i); + expect(postedText).not.toContain("unrecoverable inference error"); + expect(postedText).not.toContain("not supported with this model"); + + orchestrator.dispose(); + }); + + test("a connector.reply that is a tools-unsupported dump becomes a failed-turn notice, not a bubble", async () => { + const room = fakeRoom(); + const events = createSidecarEmitter(); + const orchestrator = createChatOrchestrator({ + db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, + store: { + listWorkbenchSettings: async () => [ + workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), + ], + }, + roomMessages: room.roomMessages, + publish: room.publish, + platform: fakeMail().platform, + events, + claims: fakeClaims(), + approvals: { findByCorrelationId: async () => null }, + }); + + events.emit("agent.event", { + agentAddress: "ins_echo1@ten1.workbench.test", + sessionId: "ses_1", + event: { + type: "connector.reply", + data: { + content: + "This agent could not complete your request due to an unrecoverable inference error [HTTP 400]: 'tools' is not supported with this model.", + }, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(room.posted).toHaveLength(1); + expect(room.posted[0]?.parts).toEqual([ + { + kind: "text", + text: "This agent's model can't use tools.", + turnFailed: true, + turnFailedReason: "tools_unsupported", + }, ]); orchestrator.dispose(); diff --git a/packages/chat/test/parts.test.ts b/packages/chat/test/parts.test.ts index 64f6ec35d..7737396fb 100644 --- a/packages/chat/test/parts.test.ts +++ b/packages/chat/test/parts.test.ts @@ -27,6 +27,16 @@ describe("Part schemas", () => { expect(result instanceof type.errors).toBe(false); }); + test("TextPart accepts tools_unsupported as a failed-turn reason", () => { + const result = TextPart({ + kind: "text", + text: "This agent's model can't use tools.", + turnFailed: true, + turnFailedReason: "tools_unsupported", + }); + expect(result instanceof type.errors).toBe(false); + }); + test("ReasoningPart accepts a valid reasoning part", () => { const result = ReasoningPart({ kind: "reasoning", text: "thinking..." }); expect(result instanceof type.errors).toBe(false); From 8c6d05cbd429a9e6f2a4c6e2d6b211c7cc210a24 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 3 Sep 2026 01:03:18 -0700 Subject: [PATCH 2/5] Rewrite tools-unsupported failures as a named failed-turn notice (CL-6658) A model that cannot use tools no longer dumps HTTP or registry strings on the timeline. The strip names the agent, offers tool-capable models, and hops to Settings. The typing indicator names the addressed agent. --- packages/chat-ui/src/chat-workspace.tsx | 22 ++++++- packages/chat-ui/src/failed-turn-models.ts | 57 +++++++++++++++--- packages/chat-ui/src/streaming-reply.ts | 53 ++++++++++++----- packages/chat-ui/src/strings.ts | 2 + packages/chat-ui/src/timeline.tsx | 34 +++++++---- packages/chat/src/chat-orchestrator.ts | 62 +++++++++++++++++--- packages/chat/src/consumer-inference-text.ts | 8 +++ packages/chat/src/parts.ts | 8 +-- packages/chat/src/tools-unsupported.ts | 21 +++++++ 9 files changed, 220 insertions(+), 47 deletions(-) create mode 100644 packages/chat/src/tools-unsupported.ts 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.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/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 ? (