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
22 changes: 21 additions & 1 deletion packages/chat-ui/src/chat-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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`/
Expand Down Expand Up @@ -1720,6 +1739,7 @@ function ChatWorkspaceInner({
names={typingAgentNames(
streamingReply,
activeWorkbench?.participants ?? [],
addressedMessageParts,
)}
/>
)
Expand Down
67 changes: 67 additions & 0 deletions packages/chat-ui/src/failed-turn-models.test.ts
Original file line number Diff line number Diff line change
@@ -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",
]);
});
});
57 changes: 49 additions & 8 deletions packages/chat-ui/src/failed-turn-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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);
}
10 changes: 10 additions & 0 deletions packages/chat-ui/src/inference-failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down
85 changes: 85 additions & 0 deletions packages/chat-ui/src/streaming-reply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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)", () => {
Expand Down
53 changes: 39 additions & 14 deletions packages/chat-ui/src/streaming-reply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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;
}
2 changes: 2 additions & 0 deletions packages/chat-ui/src/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
Loading
Loading