))}
@@ -137,7 +224,10 @@ export function LiveToolActivity({
}) {
if (rows.length === 0 && !thinking && retryCount === 0) return null;
return (
-
+
{rows.map((row) => (
))}
diff --git a/packages/chat-ui/src/tool-activity.test.ts b/packages/chat-ui/src/tool-activity.test.ts
index 01627d94f..f499177d2 100644
--- a/packages/chat-ui/src/tool-activity.test.ts
+++ b/packages/chat-ui/src/tool-activity.test.ts
@@ -31,13 +31,18 @@ describe("resolveToolIdentity", () => {
server: "notion",
tool: "search_pages",
}),
- ).toEqual({ provider: "notion", words: ["search", "pages"] });
+ ).toEqual({
+ provider: "notion",
+ words: ["search", "pages"],
+ toolName: "search_pages",
+ });
});
test("splits a provider-namespaced tool on its double underscore", () => {
expect(resolveToolIdentity("slack__post_message", {})).toEqual({
provider: "slack",
words: ["post", "message"],
+ toolName: "post_message",
});
});
@@ -45,6 +50,7 @@ describe("resolveToolIdentity", () => {
expect(resolveToolIdentity("linear.save_issue", {})).toEqual({
provider: "linear",
words: ["save", "issue"],
+ toolName: "save_issue",
});
});
@@ -52,6 +58,7 @@ describe("resolveToolIdentity", () => {
expect(resolveToolIdentity("webSearch", {})).toEqual({
provider: undefined,
words: ["web", "search"],
+ toolName: "webSearch",
});
});
@@ -59,6 +66,7 @@ describe("resolveToolIdentity", () => {
expect(resolveToolIdentity("mcp_read", { server: "notion" })).toEqual({
provider: undefined,
words: ["mcp", "read"],
+ toolName: "mcp_read",
});
});
});
@@ -116,13 +124,106 @@ describe("describeToolCall", () => {
test("an unknown verb still loses its underscores rather than leaking raw", () => {
const phrase = describeToolCall("acme__frobnicate_widget", {}, "past");
- expect(phrase).toBe("Frobnicate widget in Acme");
+ expect(phrase).toBe("Frobnicate widget");
expect(phrase).not.toContain("_");
});
test("a plural object drops the article", () => {
expect(describeToolCall("list_files", {}, "past")).toBe("Listed files");
});
+
+ test("a colon-namespaced GitHub tool still names the provider, not the id", () => {
+ expect(describeToolCall("github:get_issue", {}, "past")).toBe(
+ "Retrieved an issue in GitHub",
+ );
+ expect(describeToolCall("github__get_issue", {}, "past")).toBe(
+ "Retrieved an issue in GitHub",
+ );
+ });
+
+ test("a bare search names the query and never the identifier", () => {
+ expect(describeToolCall("search", { q: "x" }, "past")).toBe(
+ 'Searched for "x"',
+ );
+ });
+
+ test("an unknown colon-namespaced verb title-cases the end name, not the package", () => {
+ expect(describeToolCall("acme:frobnicate_widget", {}, "past")).toBe(
+ "Frobnicate widget",
+ );
+ });
+
+ test("an Interchange memory search is a layman sentence, not a qualified id", () => {
+ const phrase = describeToolCall(
+ "@corbits/memory-tools/memory:memory_search",
+ { query: "outbound" },
+ "past",
+ );
+ expect(phrase).toBe('Searched memory for "outbound"');
+ expect(phrase).not.toContain("@");
+ expect(phrase).not.toContain("/");
+ expect(phrase).not.toContain(":");
+ });
+
+ test("an Interchange memory search still running has no query to name", () => {
+ expect(
+ describeToolCall(
+ "@corbits/memory-tools/memory:memory_search",
+ {},
+ "present",
+ ),
+ ).toBe("Searching memory");
+ });
+
+ test("an Interchange list-agents call pluralizes without a package path", () => {
+ expect(
+ describeToolCall(
+ "@corbits/agent-directory-tools/ad:list_agents",
+ {},
+ "past",
+ ),
+ ).toBe("Listed agents");
+ });
+
+ test("an Interchange ask-user call is a question, not the tool id", () => {
+ expect(
+ describeToolCall(
+ "@corbits/interaction-tools/ask-user:ask_user",
+ {},
+ "past",
+ ),
+ ).toBe("Asked a question");
+ });
+
+ test("a verb in the middle of the leftover name still tenses, without repeating Linear", () => {
+ const phrase = describeToolCall(
+ "@corbits/linear-tools/li:linear_list_recent_issues",
+ {},
+ "past",
+ );
+ expect(phrase).toBe("Listed recent issues in Linear");
+ expect(phrase).not.toBe("Linear list recent issues in Linear");
+ expect(
+ describeToolCall(
+ "@corbits/linear-tools/li:linear_list_recent_issues",
+ {},
+ "present",
+ ),
+ ).toBe("Listing recent issues in Linear");
+ });
+
+ test("a nameless-verb tool still tenses its query clause", () => {
+ expect(
+ describeToolCall("github_activity", { query: "bench pricing" }, "past"),
+ ).toBe('Ran github activity for "bench pricing"');
+ expect(
+ describeToolCall(
+ "github_activity",
+ { query: "bench pricing" },
+ "present",
+ ),
+ ).toBe('Running github activity for "bench pricing"');
+ });
});
describe("plainTextOfOutput", () => {
@@ -200,6 +301,99 @@ describe("toToolActivityRow", () => {
expect(row.phrase).toBe('Searching the web for "x"');
expect(row.detail).toBeUndefined();
});
+
+ test("an Interchange memory search carries the end name, no provider, and a search glyph", () => {
+ const row = toToolActivityRow(
+ trace({
+ name: "@corbits/memory-tools/memory:memory_search",
+ input: { query: "outbound" },
+ status: "success",
+ }),
+ "k",
+ );
+ expect(row.phrase).toBe('Searched memory for "outbound"');
+ expect(row.provider).toBeUndefined();
+ expect(row.toolName).toBe("memory_search");
+ expect(["search", "memory"]).toContain(row.glyph);
+ expect(row.phrase).not.toContain("@");
+ expect(row.phrase).not.toContain("/");
+ expect(row.phrase).not.toContain(":");
+ });
+
+ test("ask_user success never opens onto the model-facing instruction", () => {
+ const row = toToolActivityRow(
+ trace({
+ name: "@corbits/interaction-tools/ask-user:ask_user",
+ status: "success",
+ output:
+ "The question has been shown to the user. Do not repeat the question.",
+ }),
+ "k",
+ );
+ expect(row.phrase).toBe("Asked a question");
+ expect(row.detail).toBeUndefined();
+ expect(row.toolName).toBe("ask_user");
+ expect(row.glyph).toBe("ask");
+ });
+
+ test("a JSON items payload becomes a result count, not the JSON", () => {
+ const json = JSON.stringify({ items: [{}, {}, {}] });
+ const row = toToolActivityRow(
+ trace({
+ name: "memory_search",
+ status: "success",
+ output: json,
+ }),
+ "k",
+ );
+ expect(row.detail).toBe("3 results.");
+ expect(row.detail).not.toContain("{");
+ expect(row.detail).not.toContain("items");
+ });
+
+ test("JSON sitting inside a content-block text field is still a count, not the JSON", () => {
+ const row = toToolActivityRow(
+ trace({
+ name: "memory_search",
+ status: "success",
+ output: [
+ {
+ type: "text",
+ text: JSON.stringify({ items: [{}, {}] }),
+ },
+ ],
+ }),
+ "k",
+ );
+ expect(row.detail).toBe("2 results.");
+ expect(row.detail).not.toContain("{");
+ });
+
+ test("an unknown colon leftover is not a provider", () => {
+ const row = toToolActivityRow(
+ trace({ name: "acme:frobnicate_widget", status: "success" }),
+ "k",
+ );
+ expect(row.provider).toBeUndefined();
+ expect(row.phrase).toBe("Frobnicate widget");
+ });
+
+ test("an Interchange Linear list carries a Linear tile and a tensed sentence", () => {
+ const row = toToolActivityRow(
+ trace({
+ name: "@corbits/linear-tools/li:linear_list_recent_issues",
+ status: "success",
+ }),
+ "k",
+ );
+ expect(row.phrase).toBe("Listed recent issues in Linear");
+ expect(row.provider).toBe("linear");
+ expect(row.toolName).toBe("linear_list_recent_issues");
+ expect(providerTile(row.provider ?? "")).toEqual({
+ initials: "Li",
+ color: "#5e6ad2",
+ });
+ });
});
describe("providerTile", () => {
@@ -214,15 +408,9 @@ describe("providerTile", () => {
});
});
- test("an unrecognized provider still gets a tile, neutral and initialed", () => {
- const tile = providerTile("acme");
- expect(tile.initials).toBe("Ac");
- expect(tile.color).toBe("var(--muted-foreground)");
- });
-
- test("a bare local tool with no provider namespace still gets a tile", () => {
- const tile = providerTile(undefined);
- expect(tile.initials.length).toBeGreaterThan(0);
+ test("an unrecognized leftover is not a brand tile", () => {
+ expect(providerTile("acme")).toBeUndefined();
+ expect(providerTile("memory")).toBeUndefined();
});
});
diff --git a/packages/chat-ui/src/tool-activity.ts b/packages/chat-ui/src/tool-activity.ts
index e2641b4df..037d66300 100644
--- a/packages/chat-ui/src/tool-activity.ts
+++ b/packages/chat-ui/src/tool-activity.ts
@@ -4,11 +4,15 @@
// whatever the tool handed back (`ToolTracePart` in `@corbits/chat/parts`,
// assembled by the chat orchestrator from `inferenceDoneBlocks` /
// `toolDoneResult`). None of those three is fit to show anyone: the
-// identifier is a symbol (`slack__post_message`), the arguments are JSON,
-// and the result is usually a content-block array. This module is the one
-// place that translates all three into plain sentences — "Posted a message
-// in Slack", "Found 8 results", "Couldn't reach GitHub" — so no surface
-// downstream ever has to reach for `JSON.stringify` to say what happened.
+// identifier is a symbol (`slack__post_message`, or an Interchange qualified
+// id `@scope/package/export:tool`), the arguments are JSON, and the result
+// is usually a content-block array. This module is the one place that
+// translates all three into plain sentences — "Posted a message in Slack",
+// "Searched memory for "outbound"" — so no surface downstream ever has to
+// reach for `JSON.stringify` to say what happened. The phrase uses the
+// segment after the last colon (the end tool name); a leftover path is a
+// provider only when it maps onto a known brand, never `@corbits` or a
+// package stem like `memory-tools`.
//
// Both the live strip (`turn-activity.tsx`, mid-turn) and the persisted
// transcript (`timeline.tsx`) render through here, which is why a phrase
@@ -20,15 +24,19 @@ import type { Part, ToolTracePart } from "@corbits/chat/parts";
export type ToolActivityStatus = "pending" | "running" | "success" | "failed";
+export type ToolActivityGlyph =
+ "search" | "list" | "ask" | "memory" | "agents" | "write" | "generic";
+
/** One tool call, ready to render: no identifiers, no JSON, no tense
* mismatch with its own status. */
export type ToolActivityRow = {
readonly key: string;
- /** The raw tool identifier. Never rendered — carried so a row keeps its
- * provenance for tests and debugging. */
+ /** The end tool name (`memory_search`), used as the chip's hover title. */
readonly toolName: string;
+ /** Action glyph for a local tool. Known brand providers use a tile instead. */
+ readonly glyph: ToolActivityGlyph;
/** The provider namespace a call belongs to, e.g. `"slack"` — undefined
- * for a bare local tool. Feeds the chip's leading tile. */
+ * for a bare local tool. Feeds the chip's leading brand tile when known. */
readonly provider: string | undefined;
readonly phrase: string;
/** The on-demand detail, already plain text. Undefined when the tool
@@ -44,16 +52,18 @@ export type ToolActivityRow = {
const MAX_PHRASE_ARGUMENT = 48;
const MAX_FAILURE_DETAIL = 240;
const MAX_SUCCESS_DETAIL = 600;
+const MAX_SHORT_FIELD = 240;
type Tense = "past" | "present";
type Conjugation = { readonly past: string; readonly present: string };
-/** The verbs tool names actually start with, in the two forms a transcript
- * needs. Irregulars are why this is a table and not a suffix rule. */
+/** The verbs tool names actually contain, in the two forms a
+ * transcript needs. Irregulars are why this is a table and not a suffix rule. */
const VERBS: Record = {
add: { past: "Added", present: "Adding" },
archive: { past: "Archived", present: "Archiving" },
+ ask: { past: "Asked", present: "Asking" },
browse: { past: "Browsed", present: "Browsing" },
build: { past: "Built", present: "Building" },
call: { past: "Called", present: "Calling" },
@@ -107,7 +117,7 @@ export type ProviderTile = {
/** Brand mark for the chip's leading tile — two letters and the provider's
* own color, the way the mock's `[Li #5E6AD2]` / `[GH #24292f]` read. Only
* providers a person would recognise on sight get a fixed brand color;
- * anything else falls back to a neutral tile rather than guessing a color. */
+ * anything else is not a brand — the chip uses an action glyph. */
const PROVIDER_TILES: Record = {
github: { initials: "GH", color: "#24292f" },
gitlab: { initials: "GL", color: "#fc6d26" },
@@ -117,26 +127,19 @@ const PROVIDER_TILES: Record = {
slack: { initials: "Sl", color: "#4a154b" },
};
-const FALLBACK_TILE_COLOR = "var(--muted-foreground)";
-
-function fallbackInitials(provider: string): string {
- const word = provider.replace(/[_-]+/g, "").trim();
- if (word.length >= 2) {
- return `${word.charAt(0).toUpperCase()}${word.charAt(1).toLowerCase()}`;
- }
- return word.toUpperCase() || "?";
-}
-
-/** The tile a tool-use chip leads with. `undefined` (a bare local tool,
- * no provider namespace) reads as a neutral "-" tile rather than nothing —
- * the anatomy always has a tile. */
-export function providerTile(provider: string | undefined): ProviderTile {
- if (provider === undefined) {
- return { initials: "—", color: FALLBACK_TILE_COLOR };
- }
- const known = PROVIDER_TILES[provider.toLowerCase()];
- if (known !== undefined) return known;
- return { initials: fallbackInitials(provider), color: FALLBACK_TILE_COLOR };
+/** Path segments that look like a namespace but are not a brand provider. */
+const NOT_PROVIDERS = new Set([
+ "memory",
+ "ad",
+ "ask-user",
+ "corbits",
+ "@corbits",
+]);
+
+/** Brand mark for a known provider. Unknown leftovers are not brands —
+ * the chip uses an action glyph instead of inventing initials. */
+export function providerTile(provider: string): ProviderTile | undefined {
+ return PROVIDER_TILES[provider.toLowerCase()];
}
function asRecord(value: unknown): Record | undefined {
@@ -172,11 +175,83 @@ function providerDisplayName(provider: string): string {
return titleCase(provider.replace(/[_-]+/g, " "));
}
-type ToolIdentity = {
+export type ToolIdentity = {
readonly provider: string | undefined;
readonly words: readonly string[];
+ readonly toolName: string;
};
+function splitToolWords(name: string): readonly string[] {
+ const spaced = name.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
+ return spaced
+ .split(/[_\-.\s]+/)
+ .map((word) => word.toLowerCase())
+ .filter((word) => word !== "");
+}
+
+function packageStem(segment: string): string {
+ const id = segment.replace(/^@/, "").toLowerCase();
+ return id.endsWith("-tools") ? id.slice(0, -"-tools".length) : id;
+}
+
+function knownBrandId(candidate: string): string | undefined {
+ const stemmed = packageStem(candidate);
+ if (stemmed === "" || NOT_PROVIDERS.has(stemmed)) return undefined;
+ if (
+ PROVIDER_NAMES[stemmed] !== undefined ||
+ PROVIDER_TILES[stemmed] !== undefined
+ ) {
+ return stemmed;
+ }
+ return undefined;
+}
+
+function providerFromLeftover(
+ leftover: string | undefined,
+): string | undefined {
+ if (leftover === undefined || leftover === "") return undefined;
+ const interchange = leftover.includes("/") || leftover.startsWith("@");
+ if (interchange) {
+ const segments = leftover.split("/").filter((segment) => segment !== "");
+ for (const segment of segments) {
+ const known = knownBrandId(segment);
+ if (known !== undefined) return known;
+ }
+ return undefined;
+ }
+ const known = knownBrandId(leftover);
+ if (known !== undefined) return known;
+ return undefined;
+}
+
+function splitQualifiedName(name: string): {
+ leftover: string | undefined;
+ toolName: string;
+} {
+ const colon = name.lastIndexOf(":");
+ if (colon !== -1) {
+ return {
+ leftover: name.slice(0, colon) || undefined,
+ toolName: name.slice(colon + 1),
+ };
+ }
+ const namespaced = name.split("__");
+ if (namespaced.length > 1 && namespaced[0] !== undefined) {
+ return {
+ leftover: namespaced[0],
+ toolName: namespaced.slice(1).join("_"),
+ };
+ }
+ const dotted = name.split(".");
+ if (dotted.length > 1 && dotted[0] !== undefined) {
+ return {
+ leftover: dotted[0],
+ toolName: dotted.slice(1).join("_"),
+ };
+ }
+ return { leftover: undefined, toolName: name };
+}
+
/**
* Splits a tool identifier into the provider it belongs to and the words
* describing what it does.
@@ -186,6 +261,11 @@ type ToolIdentity = {
* one of those two names, with the tool it actually invoked sitting in its
* `{server, tool}` arguments — so those are read first, or a whole
* conversation's worth of calls would all read alike.
+ *
+ * Interchange qualified ids (`@scope/package/export:tool`) take the
+ * segment after the last `:`. A leftover path is a provider only when a
+ * segment or `-tools` stem is a known brand; `memory`, `ad`, and
+ * `ask-user` never are.
*/
export function resolveToolIdentity(
name: string,
@@ -196,32 +276,31 @@ export function resolveToolIdentity(
const server = readString(args, "server");
const tool = readString(args, "tool");
if (server !== undefined && tool !== undefined) {
- return { provider: server, words: splitToolWords(tool) };
+ return {
+ provider: providerFromLeftover(server) ?? server,
+ words: splitToolWords(tool),
+ toolName: tool,
+ };
}
}
- const namespaced = name.split("__");
- if (namespaced.length > 1 && namespaced[0] !== undefined) {
- return {
- provider: namespaced[0],
- words: splitToolWords(namespaced.slice(1).join("_")),
- };
- }
- const dotted = name.split(".");
- if (dotted.length > 1 && dotted[0] !== undefined) {
- return {
- provider: dotted[0],
- words: splitToolWords(dotted.slice(1).join("_")),
- };
- }
- return { provider: undefined, words: splitToolWords(name) };
+ const split = splitQualifiedName(name);
+ return {
+ provider: providerFromLeftover(split.leftover),
+ words: splitToolWords(split.toolName),
+ toolName: split.toolName,
+ };
}
-function splitToolWords(name: string): readonly string[] {
- const spaced = name.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
- return spaced
- .split(/[_\-.\s]+/)
- .map((word) => word.toLowerCase())
- .filter((word) => word !== "");
+export function toolActivityGlyph(words: readonly string[]): ToolActivityGlyph {
+ const has = (candidates: readonly string[]) =>
+ words.some((word) => candidates.includes(word));
+ if (has(["search", "find", "query", "grep", "glob"])) return "search";
+ if (has(["list"])) return "list";
+ if (has(["ask"])) return "ask";
+ if (has(["memory"])) return "memory";
+ if (has(["agent", "agents"])) return "agents";
+ if (has(["write", "edit", "create", "add"])) return "write";
+ return "generic";
}
/** A file path's last segment — the part a person recognises. */
@@ -291,20 +370,46 @@ function argumentClause(input: unknown): string | undefined {
return undefined;
}
-function isWebSearch(identity: ToolIdentity): boolean {
- const hasSearchVerb =
- identity.words.includes("search") || identity.words.includes("browse");
- const hasWebObject =
- identity.words.includes("web") || identity.words.includes("internet");
- return hasSearchVerb && hasWebObject;
+function pickVerb(words: readonly string[]): {
+ verb: string | undefined;
+ objectWords: readonly string[];
+} {
+ const index = words.findIndex((word) => VERBS[word] !== undefined);
+ if (index === -1) {
+ return { verb: undefined, objectWords: words };
+ }
+ return {
+ verb: words[index],
+ objectWords: [...words.slice(0, index), ...words.slice(index + 1)],
+ };
}
-function objectPhrase(words: readonly string[]): string | undefined {
+function isPluralWord(word: string): boolean {
+ return word.endsWith("s") && !word.endsWith("ss");
+}
+
+function pluralize(word: string): string {
+ if (isPluralWord(word)) return word;
+ if (word.length > 1 && word.endsWith("y")) {
+ const beforeY = word.charAt(word.length - 2);
+ if (!"aeiou".includes(beforeY)) return `${word.slice(0, -1)}ies`;
+ }
+ return `${word}s`;
+}
+
+function objectPhrase(
+ words: readonly string[],
+ verb: string | undefined,
+): string | undefined {
if (words.length === 0) return undefined;
- const joined = words.join(" ");
const lastWord = words[words.length - 1] ?? "";
- const isPlural = lastWord.endsWith("s") && !lastWord.endsWith("ss");
- if (isPlural) return joined;
+ const rendered =
+ verb === "list" && !isPluralWord(lastWord)
+ ? [...words.slice(0, -1), pluralize(lastWord)]
+ : words;
+ const joined = rendered.join(" ");
+ const renderedLast = rendered[rendered.length - 1] ?? "";
+ if (isPluralWord(renderedLast)) return joined;
const startsWithVowel = /^[aeiou]/.test(joined);
return `${startsWithVowel ? "an" : "a"} ${joined}`;
}
@@ -320,6 +425,32 @@ function buildClauseSuffix(
return ` ${clause}`;
}
+function domainHead(
+ words: readonly string[],
+ verb: string,
+ tense: Tense,
+): string | undefined {
+ const has = (word: string) => words.includes(word);
+ const conjugation = VERBS[verb];
+ if (conjugation === undefined) return undefined;
+ if (verb === "search" && (has("web") || has("internet"))) {
+ return tense === "past" ? "Searched the web" : "Searching the web";
+ }
+ if (verb === "search" && has("memory")) {
+ return tense === "past" ? "Searched memory" : "Searching memory";
+ }
+ if (verb === "list" && has("memory")) {
+ return tense === "past" ? "Listed memories" : "Listing memories";
+ }
+ if ((verb === "add" || verb === "create") && has("memory")) {
+ return tense === "past" ? "Saved a memory" : "Saving a memory";
+ }
+ if (verb === "ask" && has("user")) {
+ return tense === "past" ? "Asked a question" : "Asking a question";
+ }
+ return undefined;
+}
+
/**
* What this tool call did, as a sentence — in the tense its status calls
* for. Never contains the tool's identifier, its argument JSON, or an
@@ -332,37 +463,53 @@ export function describeToolCall(
): string {
const identity = resolveToolIdentity(name, input);
const clause = argumentClause(input);
-
- if (isWebSearch(identity)) {
- const verb = tense === "past" ? "Searched" : "Searching";
- const target = clause ?? "";
- return `${verb} the web ${target}`.trim();
- }
-
- const verbWord = identity.words[0];
- const conjugation = verbWord === undefined ? undefined : VERBS[verbWord];
+ const picked = pickVerb(identity.words);
+ const objectWords =
+ identity.provider === undefined
+ ? picked.objectWords
+ : picked.objectWords.filter((word) => word !== identity.provider);
const providerSuffix =
identity.provider === undefined
? ""
: ` in ${providerDisplayName(identity.provider)}`;
- if (conjugation === undefined) {
- const fallback = identity.words.map((word) => word).join(" ");
- const sentence = fallback === "" ? "Ran a step" : titleCase(fallback);
- return `${sentence}${providerSuffix}`;
- }
-
- const object = objectPhrase(identity.words.slice(1));
- const head =
- object === undefined
- ? conjugation[tense]
- : `${conjugation[tense]} ${object}`;
- // The provider suffix is itself an "in …" clause, so a clause that is
- // also locative ("in #general") drops its own preposition rather than
- // stuttering — "Posted a message in Slack #general", never "… in Slack
- // in #general".
+ if (picked.verb !== undefined) {
+ const domain = domainHead(identity.words, picked.verb, tense);
+ if (domain !== undefined) {
+ return `${domain}${buildClauseSuffix(clause, false)}`;
+ }
+ const conjugation = VERBS[picked.verb];
+ if (conjugation !== undefined) {
+ const object = objectPhrase(objectWords, picked.verb);
+ const head =
+ object === undefined
+ ? conjugation[tense]
+ : `${conjugation[tense]} ${object}`;
+ const clauseSuffix = buildClauseSuffix(clause, providerSuffix !== "");
+ return `${head}${providerSuffix}${clauseSuffix}`;
+ }
+ }
+
+ const fallback = identity.words.join(" ");
const clauseSuffix = buildClauseSuffix(clause, providerSuffix !== "");
- return `${head}${providerSuffix}${clauseSuffix}`;
+ if (clause !== undefined) {
+ const head = tense === "past" ? "Ran" : "Running";
+ const body = fallback === "" ? "a step" : fallback;
+ return `${head} ${body}${providerSuffix}${clauseSuffix}`;
+ }
+ const sentence = fallback === "" ? "Ran a step" : titleCase(fallback);
+ return `${sentence}${providerSuffix}`;
+}
+
+function decodeOutput(output: unknown): unknown {
+ if (typeof output !== "string") return output;
+ const trimmed = output.trim();
+ if (trimmed === "") return output;
+ try {
+ return JSON.parse(trimmed);
+ } catch {
+ return output;
+ }
}
/**
@@ -372,17 +519,18 @@ export function describeToolCall(
* returns undefined rather than stringifying the third.
*/
export function plainTextOfOutput(output: unknown): string | undefined {
- if (typeof output === "string") {
- const trimmed = output.trim();
+ const decoded = decodeOutput(output);
+ if (typeof decoded === "string") {
+ const trimmed = decoded.trim();
return trimmed === "" ? undefined : trimmed;
}
- if (Array.isArray(output)) {
- const texts = output
+ if (Array.isArray(decoded)) {
+ const texts = decoded
.map((entry) => plainTextOfOutput(entry))
.filter((text): text is string => text !== undefined);
return texts.length === 0 ? undefined : texts.join("\n");
}
- const record = asRecord(output);
+ const record = asRecord(decoded);
if (record === undefined) return undefined;
const nested = record.content;
if (nested !== undefined) {
@@ -396,11 +544,34 @@ export function plainTextOfOutput(output: unknown): string | undefined {
);
}
+function shortField(
+ record: Record,
+ key: string,
+): string | undefined {
+ const value = readString(record, key);
+ if (value === undefined) return undefined;
+ if (value.length > MAX_SHORT_FIELD) return truncate(value, MAX_SHORT_FIELD);
+ return value;
+}
+
/** How many things a result was, when it was a list of them. */
function countSummary(output: unknown): string | undefined {
- if (!Array.isArray(output)) return undefined;
- if (output.length === 0) return "Nothing found.";
- return output.length === 1 ? "1 result." : `${output.length} results.`;
+ if (Array.isArray(output)) {
+ if (output.length === 0) return "Nothing found.";
+ return output.length === 1 ? "1 result." : `${output.length} results.`;
+ }
+ const record = asRecord(output);
+ const items = record?.items;
+ if (Array.isArray(items)) return countSummary(items);
+ return undefined;
+}
+
+function looksLikeContentBlocks(output: unknown): boolean {
+ if (!Array.isArray(output) || output.length === 0) return false;
+ return output.some((entry) => {
+ const record = asRecord(entry);
+ return record !== undefined && typeof record.type === "string";
+ });
}
/**
@@ -411,16 +582,47 @@ function countSummary(output: unknown): string | undefined {
export function summarizeToolOutput(
status: ToolActivityStatus,
output: unknown,
+ toolName?: string,
): string | undefined {
- const text = plainTextOfOutput(output);
+ if (toolName === "ask_user" && status !== "failed") return undefined;
+
+ const decoded = decodeOutput(output);
+
if (status === "failed") {
+ const text = plainTextOfOutput(decoded);
if (text === undefined) return "No reason given.";
const firstLine = text.split("\n")[0] ?? text;
return truncate(firstLine, MAX_FAILURE_DETAIL);
}
if (status !== "success") return undefined;
- if (text !== undefined) return truncate(text, MAX_SUCCESS_DETAIL);
- return countSummary(output);
+
+ if (!looksLikeContentBlocks(decoded)) {
+ const counted = countSummary(decoded);
+ if (counted !== undefined) return counted;
+ }
+
+ const record = asRecord(decoded);
+ if (record !== undefined && !("content" in record)) {
+ const field = shortField(record, "text") ?? shortField(record, "message");
+ if (field !== undefined) return field;
+ if (countSummary(decoded) === undefined) return undefined;
+ }
+
+ const text = plainTextOfOutput(decoded);
+ if (text === undefined) return countSummary(decoded);
+ const nested = decodeOutput(text);
+ if (typeof nested === "string") return truncate(nested, MAX_SUCCESS_DETAIL);
+ if (!looksLikeContentBlocks(nested)) {
+ const counted = countSummary(nested);
+ if (counted !== undefined) return counted;
+ }
+ const nestedRecord = asRecord(nested);
+ if (nestedRecord !== undefined) {
+ return (
+ shortField(nestedRecord, "text") ?? shortField(nestedRecord, "message")
+ );
+ }
+ return undefined;
}
function rowStatus(part: ToolTracePart): ToolActivityStatus {
@@ -436,12 +638,14 @@ export function toToolActivityRow(
const status = rowStatus(part);
const tense: Tense =
status === "running" || status === "pending" ? "present" : "past";
+ const identity = resolveToolIdentity(part.name, part.input);
return {
key,
- toolName: part.name,
- provider: resolveToolIdentity(part.name, part.input).provider,
+ toolName: identity.toolName,
+ glyph: toolActivityGlyph(identity.words),
+ provider: identity.provider,
phrase: describeToolCall(part.name, part.input, tense),
- detail: summarizeToolOutput(status, part.output),
+ detail: summarizeToolOutput(status, part.output, identity.toolName),
status,
};
}
diff --git a/packages/chat-ui/src/turn-activity.tsx b/packages/chat-ui/src/turn-activity.tsx
index c2fa57228..97d66560b 100644
--- a/packages/chat-ui/src/turn-activity.tsx
+++ b/packages/chat-ui/src/turn-activity.tsx
@@ -18,6 +18,7 @@ import { useEffect, useState } from "react";
import {
describeToolCall,
resolveToolIdentity,
+ toolActivityGlyph,
type ToolActivityRow,
} from "./tool-activity";
import { LiveToolActivity } from "./tool-activity-view";
@@ -388,10 +389,12 @@ export function toolActivityRows(
): readonly ToolActivityRow[] {
return activity.toolCalls.map((call) => {
const isRunning = call.status === "running";
+ const identity = resolveToolIdentity(call.name, call.input);
const base = {
key: call.callId,
- toolName: call.name,
- provider: resolveToolIdentity(call.name, call.input).provider,
+ toolName: identity.toolName,
+ glyph: toolActivityGlyph(identity.words),
+ provider: identity.provider,
phrase: describeToolCall(
call.name,
call.input,
diff --git a/packages/chat-ui/test/tool-activity-view.test.tsx b/packages/chat-ui/test/tool-activity-view.test.tsx
index 8230b6c5b..2466082e1 100644
--- a/packages/chat-ui/test/tool-activity-view.test.tsx
+++ b/packages/chat-ui/test/tool-activity-view.test.tsx
@@ -2,12 +2,15 @@
// on the text a reader sees. The standing rule under test is that no
// argument bag or tool result ever reaches the DOM as JSON, in any state.
import { beforeEach, describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { ToolTracePart } from "@corbits/chat/parts";
-import { toToolActivityRow } from "../src/tool-activity";
-import { ToolActivityGroup } from "../src/tool-activity-view";
+import { toToolActivityRow, type ToolActivityRow } from "../src/tool-activity";
+import { LiveToolActivity, ToolActivityGroup } from "../src/tool-activity-view";
function trace(part: Partial): ToolTracePart {
return {
@@ -36,6 +39,39 @@ function click(element: Element | null) {
});
}
+/** Name a screen reader would get: aria-label, else text excluding aria-hidden. */
+function accessibleName(element: Element | null): string {
+ if (element === null) return "";
+ const labelled = element.getAttribute("aria-label");
+ if (labelled !== null && labelled.trim() !== "") return labelled.trim();
+ const walk = (node: Node): string => {
+ if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? "";
+ if (!(node instanceof Element)) return "";
+ if (node.getAttribute("aria-hidden") === "true") return "";
+ return Array.from(node.childNodes).map(walk).join("");
+ };
+ return walk(element).replace(/\s+/g, " ").trim();
+}
+
+function mountLive(
+ rows: readonly ToolActivityRow[],
+ extra?: { readonly thinking?: boolean; readonly retryCount?: number },
+): HTMLDivElement {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root: Root = createRoot(container);
+ act(() => {
+ root.render(
+ ,
+ );
+ });
+ return container;
+}
+
beforeEach(() => {
document.body.innerHTML = "";
});
@@ -142,7 +178,41 @@ describe("ToolActivityGroup", () => {
expect(el.textContent).toContain("Repository not found");
});
- test("every chip carries a provider tile", () => {
+ test("a failed chip's accessible name includes failure", () => {
+ const el = mount([
+ trace({
+ name: "github__get_issue",
+ input: { repo: "corbitsdev/workbench" },
+ status: "error",
+ output: [{ type: "text", text: "Repository not found" }],
+ }),
+ ]);
+ const chip = el.querySelector(".chat-tool-activity-chip");
+ const name = accessibleName(chip);
+ expect(name).toMatch(/fail|couldn't/i);
+ expect(name).toContain("Retrieved an issue in GitHub");
+ expect(el.querySelector('[data-status="failed"]')).not.toBeNull();
+ expect(
+ el
+ .querySelector(".chat-tool-activity-marker")
+ ?.getAttribute("aria-hidden"),
+ ).toBe("true");
+ });
+
+ test("a disclosure chip has an accessible name", () => {
+ const el = mount([
+ trace({
+ name: "web_search",
+ input: { query: "bench pricing" },
+ output: [{ type: "text", text: "Eight matching pages." }],
+ }),
+ ]);
+ const trigger = el.querySelector(".chat-tool-activity-trigger");
+ expect(trigger).not.toBeNull();
+ expect(accessibleName(trigger).length).toBeGreaterThan(0);
+ });
+
+ test("a known-provider chip uses brand initials, not a dash", () => {
const el = mount([
trace({ name: "slack__post_message", input: { channel: "general" } }),
]);
@@ -150,4 +220,115 @@ describe("ToolActivityGroup", () => {
"Sl",
);
});
+
+ test("a qualified memory search chip is a layman sentence, not a package path", () => {
+ const el = mount([
+ trace({
+ name: "@corbits/memory-tools/memory:memory_search",
+ input: { query: "outbound" },
+ }),
+ ]);
+ expect(el.textContent).toContain("Searched memory");
+ expect(el.textContent).not.toContain("@corbits");
+ expect(el.textContent).not.toContain("memory-tools");
+ expect(el.innerHTML).not.toContain("—");
+ expect(el.querySelector('[data-status="success"]')).not.toBeNull();
+ expect(el.querySelector(".chat-tool-activity-tile svg")).not.toBeNull();
+ });
+
+ test("a local tool still running keeps a status of running and a leading glyph", () => {
+ const el = mount([
+ trace({
+ name: "memory_search",
+ input: {},
+ status: "running",
+ }),
+ ]);
+ expect(el.querySelector('[data-status="running"]')).not.toBeNull();
+ expect(el.innerHTML).not.toContain("—");
+ expect(el.querySelector(".chat-tool-activity-tile svg")).not.toBeNull();
+ });
+
+ test("a Linear list chip uses the Linear tile and a tensed sentence, never the tautology", () => {
+ const el = mount([
+ trace({
+ name: "@corbits/linear-tools/li:linear_list_recent_issues",
+ }),
+ ]);
+ expect(el.textContent).toContain("Listed recent issues in Linear");
+ expect(el.textContent).not.toContain("Linear list recent issues");
+ expect(el.querySelector(".chat-tool-activity-tile")?.textContent).toBe(
+ "Li",
+ );
+ });
+});
+
+describe("LiveToolActivity", () => {
+ test("elapsed ticks are not inside an atomic live region", () => {
+ const el = mountLive([
+ {
+ key: "k0",
+ toolName: "web_search",
+ glyph: "search",
+ provider: undefined,
+ phrase: 'Searching the web for "x"',
+ detail: undefined,
+ status: "running",
+ meta: "3s",
+ },
+ ]);
+ const live = el.querySelector(".chat-tool-activity-live");
+ const meta = el.querySelector(".chat-tool-activity-meta");
+ expect(live).not.toBeNull();
+ expect(meta?.textContent).toBe("3s");
+ expect(meta?.closest('[role="status"]')).toBeNull();
+ expect(live?.getAttribute("role")).not.toBe("status");
+ });
+
+ test("the transcript group stays non-live", () => {
+ const el = mount([trace({ name: "web_search", input: { query: "x" } })]);
+ const group = el.querySelector(".chat-tool-activity");
+ expect(group?.classList.contains("chat-tool-activity-live")).toBe(false);
+ expect(group?.getAttribute("role")).toBeNull();
+ expect(group?.getAttribute("aria-live")).toBeNull();
+ });
+});
+
+const css = readFileSync(
+ join(dirname(fileURLToPath(import.meta.url)), "../src/styles.css"),
+ "utf8",
+);
+
+function ruleBody(selector: string): string {
+ const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const match = new RegExp(`(?:^|\\n)\\s*${escaped}\\s*\\{([^}]+)\\}`).exec(
+ css,
+ );
+ return match?.[1] ?? "";
+}
+
+describe("tool-activity chip layout", () => {
+ test("a long chip ellipsizes against the message column, not a shrink-wrapped cycle", () => {
+ const stack = ruleBody(".chat-tool-activity");
+ expect(stack).toMatch(/width:\s*100%/);
+ expect(stack).toMatch(/min-width:\s*0/);
+ expect(stack).toMatch(/max-width:\s*100%/);
+
+ const row = ruleBody(".chat-tool-activity-row");
+ expect(row).toMatch(/width:\s*100%/);
+ expect(row).toMatch(/min-width:\s*0/);
+ expect(row).toMatch(/max-width:\s*100%/);
+
+ const chip = ruleBody(".chat-tool-activity-chip");
+ expect(chip).toMatch(/width:\s*fit-content/);
+ expect(chip).toMatch(/min-width:\s*0/);
+ expect(chip).toMatch(/max-width:\s*100%/);
+ expect(chip).not.toMatch(/width:\s*max-content/);
+
+ const phrase = ruleBody(".chat-tool-activity-phrase");
+ expect(phrase).toMatch(/overflow:\s*hidden/);
+ expect(phrase).toMatch(/text-overflow:\s*ellipsis/);
+ expect(phrase).toMatch(/white-space:\s*nowrap/);
+ expect(phrase).toMatch(/min-width:\s*0/);
+ });
});