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
57 changes: 49 additions & 8 deletions app/src/components/channels/chat-transcript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
useMessageScroller,
} from "@/components/ui/message-scroller";
import { toVisibleChatItems } from "./chat-messages";
import { asText, forDisplay, REFUSAL_MARKER } from "@/lib/plugins/tool-result";
import { readToolName } from "@/lib/plugins/tool-name";
import type { QueuedMessage } from "./composer";
import { ToolLine } from "./tool-line";
import { ToolRenderBoundary } from "./tool-boundary";
Expand Down Expand Up @@ -467,21 +469,60 @@ const TranscriptToolCall = memo(function TranscriptToolCall({
<Arriving delay={delay}>
<ToolRenderBoundary name={name}>
{/*
* A TOOL WITH NO REGISTERED RENDERER STILL HAPPENED. `renderToolCall` draws whatever was
* registered for the name and nothing at all for anything else, which left a Bot that called
* something the app does not know about looking like a Bot that did nothing — the same
* failure `ToolRenderBoundary` exists to prevent, arriving by a different route.
* A TOOL WITH NO REGISTERED RENDERER STILL HAPPENED, and since tools moved to the server
* that is now the ordinary case rather than the exception: MCP tools execute in the runtime
* and register no renderer here at all. `renderToolCall` still draws the components the app
* registers, and everything else lands below.
*
* The fallback is a plain tool line: what was called, shimmering until its result lands. It
* is the same line the computer and MCP tools draw, so an unrecognised call reads as an
* ordinary event rather than as damage.
* What was called, shimmering until its result arrives, and then the server's own words
* drawn the way a Bot's prose is drawn.
*/}
{drawn ?? <ToolLine label={name} running={result === undefined} />}
{drawn ?? <ServerToolLine name={name} result={result} />}
</ToolRenderBoundary>
</Arriving>
);
});

/**
* A tool the runtime executed, drawn for the person watching.
*
* Named from the reader's side: what was done, against which server, with the server's own words
* behind a disclosure. The identifier the model was offered never reaches the screen.
*/
function ServerToolLine({ name, result }: { name: string; result?: string }) {
const { label, detail } = readToolName(name);
/*
* A refusal is not a result, and must not read like one.
*
* The server says which it is rather than the browser inferring it from the wording, because the
* wording is a policy message an administrator can rewrite and the first rephrasing would break
* any guess made here. See REFUSAL_MARKER in server/src/plugins/tools.ts.
*/
const answer = result === undefined ? undefined : asText(result);
const refused = answer?.startsWith(REFUSAL_MARKER) ?? false;
/*
* The marker is for this component, not for the reader. Left in, a refusal reads "Blocked" in the
* label and then "Refused." again in the first two words of the body, which is the same fact three
* times over by the end of the sentence. Stripped here rather than on the server, because the
* server's copy is what the model is told and "Refused." in front of a reason is right for it.
*/
const body = refused ? answer?.slice(REFUSAL_MARKER.length).trim() : answer;
return (
<ToolLine
{...(detail ? { detail } : {})}
label={label}
refused={refused}
running={result === undefined}
>
{body ? (
<Streamdown components={markdownComponents}>
{forDisplay(body)}
</Streamdown>
) : null}
</ToolLine>
);
}

export function ChatTranscript({
busy = false,
commandNames = "",
Expand Down
209 changes: 0 additions & 209 deletions app/src/lib/copilot/plugin-tools.tsx

This file was deleted.

3 changes: 0 additions & 3 deletions app/src/lib/copilot/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import type { ReactNode } from "react";
import { ActiveBotProvider } from "./active-bot";
import { ComputerTools } from "./computer-tools";
import { GalleryTools } from "./gallery-tools";
import { PluginTools } from "./plugin-tools";
import { SandboxedTools } from "./sandboxed-tools";

/**
Expand All @@ -28,8 +27,6 @@ export function CopilotProvider({ children }: { children: ReactNode }) {
<ComputerTools />
{/* Gallery tools are registered once; their handlers re-read the active Bot to avoid shadowing renderers. */}
<GalleryTools />
{/* MCP tools share the same active-Bot context and server-side grant checks. */}
<PluginTools />
{/* Browser-authored components use the same component grants as the compiled gallery. */}
<SandboxedTools />
{children}
Expand Down
51 changes: 51 additions & 0 deletions app/src/lib/plugins/tool-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* A tool call, named the way the person watching would name it.
*
* The model is offered `mcp__notes__search_notes`, because a tool name has to be unique across every
* server a Bot holds and has to survive two vendors both calling something `search`. None of that is
* the reader's problem, and putting it on screen tells them how the thing is built rather than what
* their Bot just did.
*
* Anything that is not a prefixed MCP name is left exactly as it is: a component the app registered
* already has a name somebody chose.
*/
export type ToolName = {
/** What was done, for the line itself. */
label: string;
/** Which server it was done against, muted beside the label. Absent for anything not MCP. */
detail?: string;
};

export function readToolName(name: string): ToolName {
const parts = name.split("__");
if (parts.length < 3 || parts[0] !== "mcp") return { label: name };

const [, server, ...rest] = parts;
const tool = rest.join("__");
const label = humanise(tool);

/*
* The server is dropped when the action already says it. Vendors name a tool after the thing it
* searches, so `mcp__notes__search_notes` would otherwise read "Search notes notes", which looks
* like a bug rather than a label.
*/
const named = label.toLowerCase().includes((server ?? "").toLowerCase());
return named ? { label } : { label, detail: server };
}

/**
* `search_notes` as "Search notes".
*
* Vendors write tool names in snake_case, camelCase or a mixture, and the only thing they agree on
* is that the first word is a verb. Splitting on both and sentence-casing the result gets a phrase
* that reads as an action without anybody maintaining a table of names.
*/
function humanise(tool: string): string {
const words = tool
.replace(/[_-]+/g, " ")
.replace(/([a-z\d])([A-Z])/g, "$1 $2")
.trim()
.toLowerCase();
if (words.length === 0) return tool;
return words.charAt(0).toUpperCase() + words.slice(1);
}
Loading