mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-18 10:51:45 +00:00
feat(webui): redesign tool-call rendering with inline diffs, group summaries, and cheap-model purpose titles (#103748)
Kind-aware tool rows (terminal-style commands with wrapper stripping and display highlighting, file edits with inline numbered diffs and diffstat, write previews, key-value args), aggregate group summaries with live run status, and a new batched chat.toolTitles gateway RPC that titles complex calls via the configured utilityModel or the OpenAI Luna default (gated to OpenAI-primary agents, cached in the per-agent SQLite cache_entries). Also fixes two transcript pairing bugs: result blocks now inherit call id/name/details at merge time, and results pair with any open call in the current tool run so parallel calls render as single rows. Fixes #103554
This commit is contained in:
committed by
GitHub
parent
b7e7c27ef0
commit
fc2afc83da
@@ -9429,6 +9429,42 @@ public struct ChatMessageGetResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatToolTitlesParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
public let items: [[String: AnyCodable]]
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
agentid: String? = nil,
|
||||
items: [[String: AnyCodable]])
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
self.items = items
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
case items
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatToolTitlesResult: Codable, Sendable {
|
||||
public let titles: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
titles: [String: AnyCodable])
|
||||
{
|
||||
self.titles = titles
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case titles
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatSendParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
|
||||
@@ -473,6 +473,7 @@ methods. Treat this as feature discovery, not a full enumeration of
|
||||
- `sessions.get` returns the full stored session row.
|
||||
- Chat execution still uses `chat.history`, `chat.send`, `chat.abort`, and `chat.inject`. `chat.history` is display-normalized for UI clients: inline directive tags are stripped from visible text, plain-text tool-call XML payloads (`<tool_call>...</tool_call>`, `<function_call>...</function_call>`, `<tool_calls>...</tool_calls>`, `<function_calls>...</function_calls>`, and truncated tool-call blocks) and leaked ASCII/full-width model control tokens are stripped, pure silent-token assistant rows (exact `NO_REPLY` / `no_reply`) are omitted, and oversized rows can be replaced with placeholders.
|
||||
- `chat.message.get` is the additive bounded full-message reader for a single visible transcript entry. Pass `sessionKey`, optional `agentId` when session selection is agent-scoped, and a transcript `messageId` previously surfaced through `chat.history`; the gateway returns the same display-normalized projection without the lightweight history truncation cap when the stored entry is still available and not oversized.
|
||||
- `chat.toolTitles` returns short purpose titles for tool calls rendered in the Control UI (batched, max 24 items with bounded inputs). Titles come from the agent's configured `utilityModel` when set, otherwise the low-cost `openai/gpt-5.6-luna` default — and that default applies only when the agent's primary model already routes to OpenAI, so tool arguments never reach a provider the session was not already using. When no eligible cheap model is usable the gateway returns no titles and clients keep deterministic labels. Results cache in the per-agent state database keyed by tool name + input, so repeated views never re-bill the same calls.
|
||||
- `chat.send` accepts one-turn `fastMode: "auto"` to use fast mode for model calls started before the auto cutoff, then start later retry, fallback, tool-result, or continuation calls without fast mode. The cutoff defaults to 60 seconds (`DEFAULT_FAST_MODE_AUTO_ON_SECONDS`) and can be configured per model with `agents.defaults.models["<provider>/<model>"].params.fastAutoOnSeconds`. A `chat.send` caller can pass one-turn `fastAutoOnSeconds` to override the cutoff for that request.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -170,7 +170,8 @@ A **Search** field at the top of the sidebar opens the command palette (⌘K). T
|
||||
- Chat history refreshes request a bounded recent window with per-message text caps, so large sessions do not force the browser to render a full transcript payload before chat becomes usable.
|
||||
- Hovering or keyboard-focusing a public GitHub issue or pull request link shows its state, title, author, recent activity, comments, and change statistics. The connected Gateway fetches and caches public metadata without changing the link target, including when the UI uses a remote Gateway. The Gateway uses `GH_TOKEN` or `GITHUB_TOKEN` when available, after confirming the repository is public; otherwise it uses GitHub's anonymous API with a longer cache.
|
||||
- Talk through browser realtime sessions. OpenAI uses direct WebRTC, Google Live uses a constrained one-use browser token over WebSocket, and backend-only realtime voice plugins use the Gateway relay transport. Client-owned provider sessions start with `talk.client.create`; Gateway relay sessions start with `talk.session.create`. The relay keeps provider credentials on the Gateway while the browser streams microphone PCM through `talk.session.appendAudio`, forwards `openclaw_agent_consult` provider tool calls through `talk.client.toolCall` for Gateway policy and the larger configured OpenClaw model, and routes active-run voice steering through `talk.client.steer` or `talk.session.steer`.
|
||||
- Stream tool calls and live tool output cards in Chat (agent events).
|
||||
- Stream tool calls and live tool output cards in Chat (agent events). Tool activity renders as kind-aware rows: shell commands show the syntax-highlighted command with terminal-style output, file edits show an inline diff with line numbers and a `+added -removed` stat, file writes preview the created content, and consecutive calls collapse into an aggregate card such as "Ran 13 commands, read 6 files, edited 9 files". While a run is live, the newest running call names the group header.
|
||||
- Complex tool calls (long shell commands, argument-heavy plugin tools) get short AI-generated purpose titles through `chat.toolTitles`, powered by the agent's configured `utilityModel` or the low-cost `openai/gpt-5.6-luna` default (used only when the agent already runs on OpenAI, so tool arguments never reach a new provider). Titles are cached gateway-side per agent; when no cheap model is usable, rows keep their deterministic labels.
|
||||
- Start or dismiss ephemeral model-suggested follow-up tasks; accepted suggestions open a fresh managed-worktree session with the proposed prompt.
|
||||
- Activity tab with browser-local, redaction-first summaries of live tool activity from existing `session.tool` / tool event delivery.
|
||||
|
||||
|
||||
@@ -165,6 +165,10 @@ import {
|
||||
type ChatInjectParams,
|
||||
ChatInjectParamsSchema,
|
||||
ChatSendParamsSchema,
|
||||
type ChatToolTitlesParams,
|
||||
type ChatToolTitlesResult,
|
||||
ChatToolTitlesParamsSchema,
|
||||
ChatToolTitlesResultSchema,
|
||||
type ConfigApplyParams,
|
||||
ConfigApplyParamsSchema,
|
||||
type ConfigGetParams,
|
||||
@@ -1188,6 +1192,9 @@ export const validateTerminalEvent = lazyCompile<TerminalEvent>(TerminalEventSch
|
||||
export const validateChatHistoryParams = lazyCompile(ChatHistoryParamsSchema);
|
||||
export const validateChatMetadataParams = lazyCompile<ChatMetadataParams>(ChatMetadataParamsSchema);
|
||||
export const validateChatMessageGetParams = lazyCompile(ChatMessageGetParamsSchema);
|
||||
export const validateChatToolTitlesParams = lazyCompile<ChatToolTitlesParams>(
|
||||
ChatToolTitlesParamsSchema,
|
||||
);
|
||||
export const validateChatSendParams = lazyCompile(ChatSendParamsSchema);
|
||||
export const validateChatAbortParams = lazyCompile<ChatAbortParams>(ChatAbortParamsSchema);
|
||||
export const validateChatInjectParams = lazyCompile<ChatInjectParams>(ChatInjectParamsSchema);
|
||||
@@ -1549,6 +1556,8 @@ export {
|
||||
ChatMetadataParamsSchema,
|
||||
ChatSendParamsSchema,
|
||||
ChatInjectParamsSchema,
|
||||
ChatToolTitlesParamsSchema,
|
||||
ChatToolTitlesResultSchema,
|
||||
UpdateRunParamsSchema,
|
||||
TickEventSchema,
|
||||
ShutdownEventSchema,
|
||||
@@ -1695,6 +1704,8 @@ export type {
|
||||
AgentsListParams,
|
||||
AgentsListResult,
|
||||
ChatMetadataParams,
|
||||
ChatToolTitlesParams,
|
||||
ChatToolTitlesResult,
|
||||
CommandsListParams,
|
||||
CommandsListResult,
|
||||
CommandEntry,
|
||||
|
||||
@@ -46,6 +46,36 @@ export const ChatMetadataParamsSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/** Batched purpose-title request for tool calls rendered in the Control UI. */
|
||||
export const ChatToolTitlesParamsSchema = Type.Object(
|
||||
{
|
||||
sessionKey: NonEmptyString,
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
items: Type.Array(
|
||||
Type.Object(
|
||||
{
|
||||
id: NonEmptyString,
|
||||
name: Type.String({ minLength: 1, maxLength: 200 }),
|
||||
input: Type.String({ minLength: 1, maxLength: 4_000 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
{ minItems: 1, maxItems: 24 },
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/** Titles keyed by the caller-provided item id; missing ids mean no title. */
|
||||
export const ChatToolTitlesResultSchema = Type.Object(
|
||||
{
|
||||
titles: Type.Record(Type.String(), Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
/** Typed result shape for tool-title consumers. */
|
||||
export type ChatToolTitlesResult = Static<typeof ChatToolTitlesResultSchema>;
|
||||
|
||||
/** Fetches one stored chat message without forcing history callers to request huge payloads. */
|
||||
export const ChatMessageGetParamsSchema = Type.Object(
|
||||
{
|
||||
|
||||
@@ -234,6 +234,8 @@ import {
|
||||
ChatMessageGetResultSchema,
|
||||
ChatInjectParamsSchema,
|
||||
ChatSendParamsSchema,
|
||||
ChatToolTitlesParamsSchema,
|
||||
ChatToolTitlesResultSchema,
|
||||
LogsTailParamsSchema,
|
||||
LogsTailResultSchema,
|
||||
} from "./logs-chat.js";
|
||||
@@ -772,6 +774,8 @@ export const ProtocolSchemas = {
|
||||
ChatMetadataParams: ChatMetadataParamsSchema,
|
||||
ChatMessageGetParams: ChatMessageGetParamsSchema,
|
||||
ChatMessageGetResult: ChatMessageGetResultSchema,
|
||||
ChatToolTitlesParams: ChatToolTitlesParamsSchema,
|
||||
ChatToolTitlesResult: ChatToolTitlesResultSchema,
|
||||
ChatSendParams: ChatSendParamsSchema,
|
||||
ChatAbortParams: ChatAbortParamsSchema,
|
||||
ChatInjectParams: ChatInjectParamsSchema,
|
||||
|
||||
@@ -256,6 +256,7 @@ export type ModelChoice = SchemaType<"ModelChoice">;
|
||||
export type ModelsListParams = SchemaType<"ModelsListParams">;
|
||||
export type ModelsListResult = SchemaType<"ModelsListResult">;
|
||||
export type ChatMetadataParams = SchemaType<"ChatMetadataParams">;
|
||||
export type ChatToolTitlesParams = SchemaType<"ChatToolTitlesParams">;
|
||||
export type CommandEntry = SchemaType<"CommandEntry">;
|
||||
export type CommandsListParams = SchemaType<"CommandsListParams">;
|
||||
export type CommandsListResult = SchemaType<"CommandsListResult">;
|
||||
|
||||
180
src/gateway/chat-tool-titles.test.ts
Normal file
180
src/gateway/chat-tool-titles.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
// Gateway tests cover cheap-model tool-call title generation and its SQLite cache.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const completeWithPreparedSimpleCompletionModel = vi.hoisted(() => vi.fn());
|
||||
const prepareSimpleCompletionModelForAgent = vi.hoisted(() => vi.fn());
|
||||
const resolveSimpleCompletionSelectionForAgent = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../agents/simple-completion-runtime.js", () => ({
|
||||
completeWithPreparedSimpleCompletionModel,
|
||||
prepareSimpleCompletionModelForAgent,
|
||||
resolveSimpleCompletionSelectionForAgent,
|
||||
}));
|
||||
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { closeOpenClawAgentDatabases } from "../state/openclaw-agent-db.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import { generateToolCallTitles } from "./chat-tool-titles.js";
|
||||
|
||||
const AGENT_ID = "main";
|
||||
|
||||
function mockPreparedModel(): void {
|
||||
prepareSimpleCompletionModelForAgent.mockResolvedValue({
|
||||
selection: { provider: "openai", modelId: "gpt-test", agentDir: "/tmp/openclaw-agent" },
|
||||
model: { provider: "openai", id: "gpt-test", maxTokens: 8192 },
|
||||
auth: { apiKey: "k", mode: "api-key" },
|
||||
});
|
||||
}
|
||||
|
||||
function mockCompletionTitles(titles: Record<string, string>): void {
|
||||
completeWithPreparedSimpleCompletionModel.mockResolvedValue({
|
||||
stopReason: "stop",
|
||||
content: [{ type: "text", text: JSON.stringify({ titles }) }],
|
||||
});
|
||||
}
|
||||
|
||||
describe("generateToolCallTitles", () => {
|
||||
let stateDir: string;
|
||||
let previousStateDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
completeWithPreparedSimpleCompletionModel.mockReset();
|
||||
prepareSimpleCompletionModelForAgent.mockReset();
|
||||
resolveSimpleCompletionSelectionForAgent.mockReset();
|
||||
// Default: the agent's primary model already routes to OpenAI, so the
|
||||
// Luna fallback is permitted by the egress gate.
|
||||
resolveSimpleCompletionSelectionForAgent.mockReturnValue({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.5",
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
});
|
||||
// realpath: macOS tmpdir is a /var -> /private/var symlink and DB paths resolve canonically.
|
||||
stateDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-tool-titles-")));
|
||||
previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeOpenClawAgentDatabases();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("generates titles keyed by item id", async () => {
|
||||
mockPreparedModel();
|
||||
mockCompletionTitles({ "item-1": "Checked repo status", "item-2": "Listed source files" });
|
||||
|
||||
const result = await generateToolCallTitles({
|
||||
cfg: {} satisfies OpenClawConfig,
|
||||
agentId: AGENT_ID,
|
||||
items: [
|
||||
{ id: "item-1", name: "bash", input: "git status --short" },
|
||||
{ id: "item-2", name: "bash", input: "ls -la src" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
"item-1": "Checked repo status",
|
||||
"item-2": "Listed source files",
|
||||
});
|
||||
expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("serves repeated items from the SQLite cache without a second completion", async () => {
|
||||
mockPreparedModel();
|
||||
mockCompletionTitles({ "item-1": "Checked repo status" });
|
||||
const params = {
|
||||
cfg: {} satisfies OpenClawConfig,
|
||||
agentId: AGENT_ID,
|
||||
items: [{ id: "item-1", name: "bash", input: "git status --short" }],
|
||||
};
|
||||
|
||||
const first = await generateToolCallTitles(params);
|
||||
const second = await generateToolCallTitles(params);
|
||||
|
||||
expect(first).toEqual({ "item-1": "Checked repo status" });
|
||||
expect(second).toEqual(first);
|
||||
expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails closed to an empty result when model preparation errors", async () => {
|
||||
prepareSimpleCompletionModelForAgent.mockResolvedValue({
|
||||
error: 'No API key resolved for provider "openai".',
|
||||
});
|
||||
|
||||
await expect(
|
||||
generateToolCallTitles({
|
||||
cfg: {} satisfies OpenClawConfig,
|
||||
agentId: AGENT_ID,
|
||||
items: [{ id: "item-1", name: "bash", input: "git status --short" }],
|
||||
}),
|
||||
).resolves.toEqual({});
|
||||
expect(completeWithPreparedSimpleCompletionModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prepares the configured utility model when one is set", async () => {
|
||||
mockPreparedModel();
|
||||
mockCompletionTitles({ "item-1": "Checked repo status" });
|
||||
const cfg = { agents: { defaults: { utilityModel: "openai/gpt-test" } } };
|
||||
|
||||
await generateToolCallTitles({
|
||||
cfg,
|
||||
agentId: AGENT_ID,
|
||||
items: [{ id: "item-1", name: "bash", input: "git status --short" }],
|
||||
});
|
||||
|
||||
expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
agentId: AGENT_ID,
|
||||
useUtilityModel: true,
|
||||
useAsyncModelResolution: true,
|
||||
allowMissingApiKeyModes: ["aws-sdk"],
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the Luna default model ref without a utility model", async () => {
|
||||
mockPreparedModel();
|
||||
mockCompletionTitles({ "item-1": "Checked repo status" });
|
||||
const cfg = {} satisfies OpenClawConfig;
|
||||
|
||||
await generateToolCallTitles({
|
||||
cfg,
|
||||
agentId: AGENT_ID,
|
||||
items: [{ id: "item-1", name: "bash", input: "git status --short" }],
|
||||
});
|
||||
|
||||
expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
agentId: AGENT_ID,
|
||||
modelRef: "openai/gpt-5.6-luna",
|
||||
useAsyncModelResolution: true,
|
||||
allowMissingApiKeyModes: ["aws-sdk"],
|
||||
});
|
||||
});
|
||||
|
||||
it("skips the Luna default when the agent's primary provider is not OpenAI", async () => {
|
||||
resolveSimpleCompletionSelectionForAgent.mockReturnValue({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-test",
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
});
|
||||
|
||||
await expect(
|
||||
generateToolCallTitles({
|
||||
cfg: {} satisfies OpenClawConfig,
|
||||
agentId: AGENT_ID,
|
||||
items: [{ id: "item-1", name: "bash", input: "git status --short" }],
|
||||
}),
|
||||
).resolves.toEqual({});
|
||||
expect(prepareSimpleCompletionModelForAgent).not.toHaveBeenCalled();
|
||||
expect(completeWithPreparedSimpleCompletionModel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
333
src/gateway/chat-tool-titles.ts
Normal file
333
src/gateway/chat-tool-titles.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Cheap-model purpose titles for tool calls shown in the Control UI.
|
||||
*
|
||||
* Model selection is intentionally cheap-only: the configured utility model
|
||||
* when present, otherwise the OpenAI Luna fast tier — and the Luna default
|
||||
* applies only when the agent's primary model already routes to OpenAI, so
|
||||
* decorative titles never send tool arguments to a provider the session was
|
||||
* not already talking to. Titles never fall through to the (potentially
|
||||
* expensive) primary model — callers get an empty result and keep
|
||||
* deterministic labels.
|
||||
*
|
||||
* Generated titles cache in the per-agent SQLite database (`cache_entries`,
|
||||
* scope below) keyed by a digest of tool name + input, so reopening a session
|
||||
* never re-bills the same calls.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { resolveAgentConfig } from "../agents/agent-scope.js";
|
||||
import { isOpenAIProvider } from "../agents/openai-routing.js";
|
||||
import {
|
||||
completeWithPreparedSimpleCompletionModel,
|
||||
prepareSimpleCompletionModelForAgent,
|
||||
resolveSimpleCompletionSelectionForAgent,
|
||||
} from "../agents/simple-completion-runtime.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
|
||||
import type { DB as OpenClawAgentKyselyDatabase } from "../state/openclaw-agent-db.generated.js";
|
||||
import {
|
||||
openOpenClawAgentDatabase,
|
||||
runOpenClawAgentWriteTransaction,
|
||||
} from "../state/openclaw-agent-db.js";
|
||||
|
||||
const TOOL_TITLE_CACHE_SCOPE = "tool-call-titles";
|
||||
const LUNA_DEFAULT_MODEL_REF = "openai/gpt-5.6-luna";
|
||||
const TOOL_TITLES_MAX_ITEMS = 24;
|
||||
const TOOL_TITLE_INPUT_MAX_CHARS = 2_000;
|
||||
const TOOL_TITLE_MAX_CHARS = 72;
|
||||
// Reasoning models spend output tokens before the visible JSON; a small cap
|
||||
// can starve a whole batch of titles.
|
||||
const TOOL_TITLES_MAX_TOKENS = 4_096;
|
||||
const TOOL_TITLES_TIMEOUT_MS = 20_000;
|
||||
|
||||
const TOOL_TITLES_SYSTEM_PROMPT = [
|
||||
"You label tool calls in a coding agent's activity feed.",
|
||||
"For each item, write a 3-8 word title describing the call's purpose in sentence case.",
|
||||
"Start with a past-tense verb such as Checked, Inspected, Installed, Listed.",
|
||||
"No trailing period, no quotes, no markdown.",
|
||||
'Respond with JSON only: {"titles":{"<id>":"<title>"}} covering every item id.',
|
||||
].join(" ");
|
||||
|
||||
export type ToolTitleRequestItem = { id: string; name: string; input: string };
|
||||
|
||||
type AgentCacheDatabase = Pick<OpenClawAgentKyselyDatabase, "cache_entries">;
|
||||
|
||||
function cacheKeyFor(item: ToolTitleRequestItem): string {
|
||||
return createHash("sha256").update(`${item.name}\0${item.input}`).digest("hex");
|
||||
}
|
||||
|
||||
function normalizeItems(items: readonly ToolTitleRequestItem[]): ToolTitleRequestItem[] {
|
||||
const seen = new Set<string>();
|
||||
const normalized: ToolTitleRequestItem[] = [];
|
||||
for (const item of items) {
|
||||
if (normalized.length >= TOOL_TITLES_MAX_ITEMS) {
|
||||
break;
|
||||
}
|
||||
const id = item.id.trim();
|
||||
const name = item.name.trim();
|
||||
const input = item.input.slice(0, TOOL_TITLE_INPUT_MAX_CHARS);
|
||||
if (!id || !name || !input.trim() || seen.has(id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(id);
|
||||
normalized.push({ id, name, input });
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeTitle(raw: unknown): string | null {
|
||||
if (typeof raw !== "string") {
|
||||
return null;
|
||||
}
|
||||
const singleLine = raw
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/^["'`]+|["'`.]+$/g, "");
|
||||
return singleLine ? truncateUtf16Safe(singleLine, TOOL_TITLE_MAX_CHARS) : null;
|
||||
}
|
||||
|
||||
function parseTitlesResponse(text: string): Record<string, unknown> | null {
|
||||
const stripped = text
|
||||
.replace(/^\s*```(?:json)?\s*/i, "")
|
||||
.replace(/\s*```\s*$/, "")
|
||||
.trim();
|
||||
const start = stripped.indexOf("{");
|
||||
const end = stripped.lastIndexOf("}");
|
||||
if (start < 0 || end <= start) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(stripped.slice(start, end + 1)) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const titles = (parsed as { titles?: unknown }).titles;
|
||||
if (!titles || typeof titles !== "object" || Array.isArray(titles)) {
|
||||
return null;
|
||||
}
|
||||
return titles as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readCachedTitles(agentId: string, keysByItemId: Map<string, string>): Map<string, string> {
|
||||
const cached = new Map<string, string>();
|
||||
if (keysByItemId.size === 0) {
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
const database = openOpenClawAgentDatabase({ agentId });
|
||||
const kysely = getNodeSqliteKysely<AgentCacheDatabase>(database.db);
|
||||
const keys = [...new Set(keysByItemId.values())];
|
||||
const rows = executeSqliteQuerySync(
|
||||
database.db,
|
||||
kysely
|
||||
.selectFrom("cache_entries")
|
||||
.select(["key", "value_json"])
|
||||
.where("scope", "=", TOOL_TITLE_CACHE_SCOPE)
|
||||
.where("key", "in", keys),
|
||||
).rows;
|
||||
const titlesByKey = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
if (!row.value_json) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const title = normalizeTitle(JSON.parse(row.value_json));
|
||||
if (title) {
|
||||
titlesByKey.set(row.key, title);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed cache rows; they get rewritten on the next generate.
|
||||
}
|
||||
}
|
||||
for (const [itemId, key] of keysByItemId) {
|
||||
const title = titlesByKey.get(key);
|
||||
if (title) {
|
||||
cached.set(itemId, title);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(`chat-tool-titles: cache read failed: ${String(err)}`);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
function writeCachedTitles(agentId: string, entries: Map<string, string>): void {
|
||||
if (entries.size === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
runOpenClawAgentWriteTransaction(
|
||||
(database) => {
|
||||
const kysely = getNodeSqliteKysely<AgentCacheDatabase>(database.db);
|
||||
const now = Date.now();
|
||||
for (const [key, title] of entries) {
|
||||
executeSqliteQuerySync(
|
||||
database.db,
|
||||
kysely
|
||||
.insertInto("cache_entries")
|
||||
.values({
|
||||
scope: TOOL_TITLE_CACHE_SCOPE,
|
||||
key,
|
||||
value_json: JSON.stringify(title),
|
||||
blob: null,
|
||||
expires_at: null,
|
||||
updated_at: now,
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc
|
||||
.columns(["scope", "key"])
|
||||
.doUpdateSet({ value_json: JSON.stringify(title), updated_at: now }),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
{ agentId },
|
||||
);
|
||||
} catch (err) {
|
||||
logVerbose(`chat-tool-titles: cache write failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function hasConfiguredUtilityModel(cfg: OpenClawConfig, agentId: string): boolean {
|
||||
return Boolean(
|
||||
resolveAgentConfig(cfg, agentId)?.utilityModel?.trim() ||
|
||||
cfg.agents?.defaults?.utilityModel?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
function agentPrimaryUsesOpenAI(cfg: OpenClawConfig, agentId: string): boolean {
|
||||
const selection = resolveSimpleCompletionSelectionForAgent({ cfg, agentId });
|
||||
return selection ? isOpenAIProvider(selection.provider) : false;
|
||||
}
|
||||
|
||||
async function generateMissingTitles(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
items: ToolTitleRequestItem[];
|
||||
}): Promise<Map<string, string>> {
|
||||
const generated = new Map<string, string>();
|
||||
if (params.items.length === 0) {
|
||||
return generated;
|
||||
}
|
||||
// Cheap-only selection: configured utility model, else the Luna fast tier.
|
||||
// Egress contract: without an explicit utilityModel, the Luna default is
|
||||
// allowed only when the agent already sends its turns to OpenAI. Tool args
|
||||
// must not reach a new provider just for decorative titles.
|
||||
const useUtility = hasConfiguredUtilityModel(params.cfg, params.agentId);
|
||||
if (!useUtility && !agentPrimaryUsesOpenAI(params.cfg, params.agentId)) {
|
||||
logVerbose(
|
||||
"chat-tool-titles: skipping Luna default because the agent's primary provider is not OpenAI and no utilityModel is configured",
|
||||
);
|
||||
return generated;
|
||||
}
|
||||
let prepared: Awaited<ReturnType<typeof prepareSimpleCompletionModelForAgent>>;
|
||||
try {
|
||||
prepared = await prepareSimpleCompletionModelForAgent({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
...(useUtility ? { useUtilityModel: true } : { modelRef: LUNA_DEFAULT_MODEL_REF }),
|
||||
useAsyncModelResolution: true,
|
||||
allowMissingApiKeyModes: ["aws-sdk"],
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(`chat-tool-titles: model preparation failed: ${String(err)}`);
|
||||
return generated;
|
||||
}
|
||||
if ("error" in prepared) {
|
||||
logVerbose(`chat-tool-titles: ${prepared.error}`);
|
||||
return generated;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TOOL_TITLES_TIMEOUT_MS);
|
||||
try {
|
||||
const result = await completeWithPreparedSimpleCompletionModel({
|
||||
model: prepared.model,
|
||||
auth: prepared.auth,
|
||||
cfg: params.cfg,
|
||||
context: {
|
||||
systemPrompt: TOOL_TITLES_SYSTEM_PROMPT,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: JSON.stringify({
|
||||
items: params.items.map((item) => ({
|
||||
id: item.id,
|
||||
tool: item.name,
|
||||
input: item.input,
|
||||
})),
|
||||
}),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
maxTokens: Math.min(TOOL_TITLES_MAX_TOKENS, Math.floor(prepared.model.maxTokens)),
|
||||
signal: controller.signal,
|
||||
},
|
||||
});
|
||||
if (result.stopReason === "error") {
|
||||
logVerbose(`chat-tool-titles: completion failed: ${result.errorMessage ?? "unknown error"}`);
|
||||
return generated;
|
||||
}
|
||||
const text = result.content
|
||||
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("")
|
||||
.trim();
|
||||
const titles = text ? parseTitlesResponse(text) : null;
|
||||
if (!titles) {
|
||||
return generated;
|
||||
}
|
||||
for (const item of params.items) {
|
||||
const title = normalizeTitle(titles[item.id]);
|
||||
if (title) {
|
||||
generated.set(item.id, title);
|
||||
}
|
||||
}
|
||||
return generated;
|
||||
} catch (err) {
|
||||
logVerbose(`chat-tool-titles: completion failed: ${String(err)}`);
|
||||
return generated;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve purpose titles for tool calls: cache first, one batched cheap-model call for misses. */
|
||||
export async function generateToolCallTitles(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
items: readonly ToolTitleRequestItem[];
|
||||
}): Promise<Record<string, string>> {
|
||||
const items = normalizeItems(params.items);
|
||||
if (items.length === 0) {
|
||||
return {};
|
||||
}
|
||||
const keysByItemId = new Map(items.map((item) => [item.id, cacheKeyFor(item)] as const));
|
||||
const titles = readCachedTitles(params.agentId, keysByItemId);
|
||||
const missing = items.filter((item) => !titles.has(item.id));
|
||||
if (missing.length > 0) {
|
||||
const generated = await generateMissingTitles({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
items: missing,
|
||||
});
|
||||
if (generated.size > 0) {
|
||||
const cacheWrites = new Map<string, string>();
|
||||
for (const [itemId, title] of generated) {
|
||||
titles.set(itemId, title);
|
||||
const key = keysByItemId.get(itemId);
|
||||
if (key) {
|
||||
cacheWrites.set(key, title);
|
||||
}
|
||||
}
|
||||
writeCachedTitles(params.agentId, cacheWrites);
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(titles);
|
||||
}
|
||||
@@ -288,6 +288,9 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
|
||||
// Session PR chips read the session's own checkout metadata, matching the
|
||||
// sessions.files.* trusted-operator read domain.
|
||||
{ name: "controlUi.sessionPullRequests", scope: "operator.read" },
|
||||
// Spends utility-model tokens on cache misses, so it needs write scope
|
||||
// despite being a read-shaped lookup.
|
||||
{ name: "chat.toolTitles", scope: "operator.write" },
|
||||
] as const;
|
||||
|
||||
const CORE_GATEWAY_METHOD_SPEC_BY_NAME: ReadonlyMap<string, CoreGatewayMethodSpec> = new Map(
|
||||
|
||||
@@ -353,6 +353,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
"chat.startup",
|
||||
"chat.metadata",
|
||||
"chat.message.get",
|
||||
"chat.toolTitles",
|
||||
"chat.abort",
|
||||
"chat.send",
|
||||
"chat.inject",
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
validateChatMetadataParams,
|
||||
validateChatMessageGetParams,
|
||||
validateChatSendParams,
|
||||
validateChatToolTitlesParams,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { CHAT_SEND_SESSION_KEY_MAX_LENGTH } from "../../../packages/gateway-protocol/src/schema.js";
|
||||
import {
|
||||
@@ -3333,6 +3334,52 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
});
|
||||
},
|
||||
"chat.metadata": handleChatMetadataRequest,
|
||||
"chat.toolTitles": async ({ params, respond, context }) => {
|
||||
if (!validateChatToolTitlesParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid chat.toolTitles params: ${formatValidationErrors(validateChatToolTitlesParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const cfg = context.getRuntimeConfig();
|
||||
const agentIdOverride = normalizeOptionalText(params.agentId);
|
||||
const requestedAgentId = resolveRequestedChatAgentId({
|
||||
cfg,
|
||||
requestedSessionKey: params.sessionKey,
|
||||
agentId: agentIdOverride,
|
||||
});
|
||||
const selectedAgent = validateChatSelectedAgent({
|
||||
cfg,
|
||||
requestedSessionKey: params.sessionKey,
|
||||
agentId: requestedAgentId,
|
||||
});
|
||||
if (!selectedAgent.ok) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, selectedAgent.error));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Title generation pulls in the simple-completion runtime; load it lazily
|
||||
// so gateways that never render the Control UI skip that cost.
|
||||
const { generateToolCallTitles } = await import("../chat-tool-titles.js");
|
||||
const titles = await generateToolCallTitles({
|
||||
cfg,
|
||||
agentId: resolveSessionAgentId({
|
||||
sessionKey: params.sessionKey,
|
||||
config: cfg,
|
||||
agentId: selectedAgent.agentId,
|
||||
}),
|
||||
items: params.items,
|
||||
});
|
||||
respond(true, { titles });
|
||||
} catch (err) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
|
||||
}
|
||||
},
|
||||
"chat.message.get": async ({ params, respond, context }) => {
|
||||
if (!validateChatMessageGetParams(params)) {
|
||||
respond(
|
||||
|
||||
@@ -67,6 +67,14 @@ describeControlUiE2e("Control UI autonomous tool-turn outcomes", () => {
|
||||
"Tool error",
|
||||
"Tool output",
|
||||
]);
|
||||
// The earlier failure must stay visibly marked as an error even though a
|
||||
// later turn recovered; the recovered row must render neutral.
|
||||
const summaryClasses = await page
|
||||
.locator(".chat-tool-msg-summary")
|
||||
.evaluateAll((nodes) => nodes.map((node) => node.className));
|
||||
expect(summaryClasses).toHaveLength(2);
|
||||
expect(summaryClasses[0]).toContain("chat-tool-msg-summary--error");
|
||||
expect(summaryClasses[1]).not.toContain("chat-tool-msg-summary--error");
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,7 +136,13 @@ export type ToolCard = {
|
||||
args?: unknown;
|
||||
inputText?: string;
|
||||
outputText?: string;
|
||||
/** Structured tool result details (e.g. the edit tool's precomputed diff). */
|
||||
details?: unknown;
|
||||
isError?: boolean;
|
||||
/** True when the card comes from the live tool stream of the current run. */
|
||||
live?: boolean;
|
||||
/** For live cards: true once the final result event landed (partial output does not complete a call). */
|
||||
completed?: boolean;
|
||||
messageId?: string;
|
||||
preview?: {
|
||||
kind: "canvas";
|
||||
|
||||
134
ui/src/lib/chat/tool-call-diff.test.ts
Normal file
134
ui/src/lib/chat/tool-call-diff.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// Control UI tests cover inline diff parsing and computation for tool-call rows.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildWriteDiffLines,
|
||||
computeLineDiff,
|
||||
countTextLines,
|
||||
diffStat,
|
||||
joinDiffSections,
|
||||
parseDiffDetailsString,
|
||||
type DiffLine,
|
||||
} from "./tool-call-diff.ts";
|
||||
|
||||
describe("parseDiffDetailsString", () => {
|
||||
it("parses numbered add/del/ctx lines and skip markers", () => {
|
||||
const diff = [" 455 before", "-456 old line", "+456 new line", " ...", " 460 after"].join(
|
||||
"\n",
|
||||
);
|
||||
|
||||
expect(parseDiffDetailsString(diff)).toEqual([
|
||||
{ kind: "ctx", lineNo: 455, text: "before" },
|
||||
{ kind: "del", lineNo: 456, text: "old line" },
|
||||
{ kind: "add", lineNo: 456, text: "new line" },
|
||||
{ kind: "skip", text: "" },
|
||||
{ kind: "ctx", lineNo: 460, text: "after" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["empty input", ""],
|
||||
["whitespace-only input", " \n "],
|
||||
["unrecognized format", "not a numbered diff"],
|
||||
["no added or removed lines", " 1 only context\n 2 more context"],
|
||||
])("returns null for %s", (_label, diff) => {
|
||||
expect(parseDiffDetailsString(diff)).toBeNull();
|
||||
});
|
||||
|
||||
it("truncates oversized diffs with a trailing skip line", () => {
|
||||
const diff = Array.from({ length: 450 }, (_, i) => `+${i + 1} line ${i + 1}`).join("\n");
|
||||
|
||||
const lines = parseDiffDetailsString(diff);
|
||||
|
||||
expect(lines).toHaveLength(402);
|
||||
expect(lines?.at(-1)).toEqual({ kind: "skip", text: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeLineDiff", () => {
|
||||
it("diffs changed lines with surrounding context", () => {
|
||||
expect(computeLineDiff("a\nb\nc", "a\nx\nc")).toEqual([
|
||||
{ kind: "ctx", text: "a" },
|
||||
{ kind: "del", text: "b" },
|
||||
{ kind: "add", text: "x" },
|
||||
{ kind: "ctx", text: "c" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a trailing newline as no extra line", () => {
|
||||
expect(computeLineDiff("foo\n", "bar\n")).toEqual([
|
||||
{ kind: "del", text: "foo" },
|
||||
{ kind: "add", text: "bar" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes CRLF endings before diffing", () => {
|
||||
expect(computeLineDiff("a\r\nb", "a\nb")).toEqual([
|
||||
{ kind: "ctx", text: "a" },
|
||||
{ kind: "ctx", text: "b" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("caps rendered output with a trailing skip line", () => {
|
||||
const newText = Array.from({ length: 500 }, (_, i) => `line ${i}`).join("\n");
|
||||
|
||||
const lines = computeLineDiff("only old line", newText);
|
||||
|
||||
expect(lines).toHaveLength(401);
|
||||
expect(lines.at(-1)).toEqual({ kind: "skip", text: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWriteDiffLines", () => {
|
||||
it("numbers every content line as an addition from line 1", () => {
|
||||
expect(buildWriteDiffLines("one\ntwo\nthree\n")).toEqual([
|
||||
{ kind: "add", lineNo: 1, text: "one" },
|
||||
{ kind: "add", lineNo: 2, text: "two" },
|
||||
{ kind: "add", lineNo: 3, text: "three" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("truncates past maxLines with a skip marker", () => {
|
||||
expect(buildWriteDiffLines("a\nb\nc\nd", 2)).toEqual([
|
||||
{ kind: "add", lineNo: 1, text: "a" },
|
||||
{ kind: "add", lineNo: 2, text: "b" },
|
||||
{ kind: "skip", text: "" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffStat", () => {
|
||||
it("counts only added and removed lines", () => {
|
||||
const lines: DiffLine[] = [
|
||||
{ kind: "add", text: "a" },
|
||||
{ kind: "add", text: "b" },
|
||||
{ kind: "del", text: "c" },
|
||||
{ kind: "ctx", text: "d" },
|
||||
{ kind: "skip", text: "" },
|
||||
];
|
||||
|
||||
expect(diffStat(lines)).toEqual({ added: 2, removed: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("joinDiffSections", () => {
|
||||
it("separates non-empty sections with skip lines and drops empty ones", () => {
|
||||
const first: DiffLine[] = [{ kind: "del", text: "old" }];
|
||||
const second: DiffLine[] = [{ kind: "add", text: "new" }];
|
||||
|
||||
expect(joinDiffSections([first, [], second])).toEqual([
|
||||
{ kind: "del", text: "old" },
|
||||
{ kind: "skip", text: "" },
|
||||
{ kind: "add", text: "new" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countTextLines", () => {
|
||||
it.each([
|
||||
["a", 1],
|
||||
["a\nb", 2],
|
||||
["a\nb\n", 2],
|
||||
])("counts %j as %d lines", (content, expected) => {
|
||||
expect(countTextLines(content)).toBe(expected);
|
||||
});
|
||||
});
|
||||
178
ui/src/lib/chat/tool-call-diff.ts
Normal file
178
ui/src/lib/chat/tool-call-diff.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Inline diff data for tool-call rendering.
|
||||
*
|
||||
* Sources, in preference order:
|
||||
* 1. The edit tool's precomputed display diff (`details.diff`, numbered lines).
|
||||
* 2. A locally computed line diff from `oldText`/`newText`-style args when a
|
||||
* harness does not ship diff details.
|
||||
*/
|
||||
|
||||
export type DiffLineKind = "add" | "del" | "ctx" | "skip";
|
||||
|
||||
export type DiffLine = {
|
||||
kind: DiffLineKind;
|
||||
/** 1-based line number in the file (new file for adds/ctx, old file for dels). */
|
||||
lineNo?: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type DiffStat = { added: number; removed: number };
|
||||
|
||||
/** Bound diff rendering work; oversized inputs degrade to a truncation marker. */
|
||||
const MAX_DIFF_INPUT_LINES = 600;
|
||||
export const MAX_DIFF_RENDER_LINES = 400;
|
||||
|
||||
export function diffStat(lines: readonly DiffLine[]): DiffStat {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const line of lines) {
|
||||
if (line.kind === "add") {
|
||||
added += 1;
|
||||
} else if (line.kind === "del") {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
return { added, removed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the edit tool's display diff (`generateDiffString` output):
|
||||
* `+457 text`, `-455 text`, ` 456 text`, and ` ...` skip markers.
|
||||
*/
|
||||
export function parseDiffDetailsString(diff: string): DiffLine[] | null {
|
||||
const trimmed = diff.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const lines: DiffLine[] = [];
|
||||
for (const raw of diff.split("\n")) {
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
const skipMatch = raw.match(/^\s*\.\.\.\s*$/);
|
||||
if (skipMatch) {
|
||||
lines.push({ kind: "skip", text: "" });
|
||||
continue;
|
||||
}
|
||||
const match = raw.match(/^([+\- ])\s*(\d+) ?(.*)$/s);
|
||||
if (!match) {
|
||||
// Not the expected format; bail so callers fall back to raw text.
|
||||
return null;
|
||||
}
|
||||
const [, sign, lineNo, text] = match;
|
||||
lines.push({
|
||||
kind: sign === "+" ? "add" : sign === "-" ? "del" : "ctx",
|
||||
lineNo: Number.parseInt(lineNo, 10),
|
||||
text: text ?? "",
|
||||
});
|
||||
if (lines.length > MAX_DIFF_RENDER_LINES) {
|
||||
lines.push({ kind: "skip", text: "" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
return lines.some((line) => line.kind === "add" || line.kind === "del") ? lines : null;
|
||||
}
|
||||
|
||||
function splitDiffLines(text: string): string[] {
|
||||
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
// Empty snippets are zero lines: deletions (`newText: ""`) and insertions
|
||||
// from an empty old side must not produce a phantom blank row in the diff.
|
||||
if (normalized === "") {
|
||||
return [];
|
||||
}
|
||||
const lines = normalized.split("\n");
|
||||
// A trailing newline yields one empty trailing element; drop it so
|
||||
// "foo\n" diffs as one line, not two.
|
||||
if (lines.length > 1 && lines[lines.length - 1] === "") {
|
||||
lines.pop();
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a line diff between two snippets (no file line numbers available).
|
||||
* Standard LCS table; inputs are bounded so the quadratic cost stays small.
|
||||
*/
|
||||
export function computeLineDiff(oldText: string, newText: string): DiffLine[] {
|
||||
const oldLines = splitDiffLines(oldText).slice(0, MAX_DIFF_INPUT_LINES);
|
||||
const newLines = splitDiffLines(newText).slice(0, MAX_DIFF_INPUT_LINES);
|
||||
const n = oldLines.length;
|
||||
const m = newLines.length;
|
||||
// lcs[i][j] = LCS length of oldLines[i..] vs newLines[j..]
|
||||
const lcs: number[][] = Array.from({ length: n + 1 }, () =>
|
||||
Array.from({ length: m + 1 }, () => 0),
|
||||
);
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
lcs[i][j] =
|
||||
oldLines[i] === newLines[j]
|
||||
? lcs[i + 1][j + 1] + 1
|
||||
: Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
||||
}
|
||||
}
|
||||
const lines: DiffLine[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (oldLines[i] === newLines[j]) {
|
||||
lines.push({ kind: "ctx", text: oldLines[i] });
|
||||
i++;
|
||||
j++;
|
||||
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
||||
lines.push({ kind: "del", text: oldLines[i] });
|
||||
i++;
|
||||
} else {
|
||||
lines.push({ kind: "add", text: newLines[j] });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < n) {
|
||||
lines.push({ kind: "del", text: oldLines[i] });
|
||||
i++;
|
||||
}
|
||||
while (j < m) {
|
||||
lines.push({ kind: "add", text: newLines[j] });
|
||||
j++;
|
||||
}
|
||||
if (lines.length > MAX_DIFF_RENDER_LINES) {
|
||||
const kept = lines.slice(0, MAX_DIFF_RENDER_LINES);
|
||||
kept.push({ kind: "skip", text: "" });
|
||||
return kept;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** All-added preview for freshly written files, numbered from line 1. */
|
||||
export function buildWriteDiffLines(content: string, maxLines = 80): DiffLine[] {
|
||||
const sourceLines = splitDiffLines(content);
|
||||
const lines: DiffLine[] = [];
|
||||
for (let index = 0; index < sourceLines.length && index < maxLines; index++) {
|
||||
lines.push({ kind: "add", lineNo: index + 1, text: sourceLines[index] });
|
||||
}
|
||||
if (sourceLines.length > maxLines) {
|
||||
lines.push({ kind: "skip", text: "" });
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function countTextLines(content: string): number {
|
||||
return splitDiffLines(content).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate per-edit diffs with skip separators, e.g. for multi-edit calls
|
||||
* where each `edits[i]` produced its own local diff.
|
||||
*/
|
||||
export function joinDiffSections(sections: ReadonlyArray<DiffLine[]>): DiffLine[] {
|
||||
const joined: DiffLine[] = [];
|
||||
for (const section of sections) {
|
||||
if (section.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (joined.length > 0) {
|
||||
joined.push({ kind: "skip", text: "" });
|
||||
}
|
||||
joined.push(...section);
|
||||
}
|
||||
return joined;
|
||||
}
|
||||
65
ui/src/lib/chat/tool-call-grouping.test.ts
Normal file
65
ui/src/lib/chat/tool-call-grouping.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// Control UI tests cover collapsed tool-group summary labels.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { summarizeToolGroup, type ToolGroupSummaryInput } from "./tool-call-grouping.ts";
|
||||
|
||||
describe("summarizeToolGroup", () => {
|
||||
it("builds a capitalized multi-segment label with a failure suffix", () => {
|
||||
const cards: ToolGroupSummaryInput[] = [
|
||||
{ name: "bash", args: { command: "ls" } },
|
||||
{ name: "bash", args: { command: "pwd" }, isError: true },
|
||||
{ name: "read", args: { path: "/repo/a.ts" } },
|
||||
{ name: "read", args: { path: "/repo/b.ts" } },
|
||||
{ name: "edit", args: { path: "/repo/a.ts", oldText: "x", newText: "y" } },
|
||||
{ name: "edit", args: { path: "/repo/a.ts", oldText: "y", newText: "z" } },
|
||||
{ name: "write", args: { path: "/repo/new.ts", content: "hi" } },
|
||||
{ name: "grep", args: { pattern: "TODO" } },
|
||||
{ name: "web_fetch", args: { url: "https://x.dev" } },
|
||||
];
|
||||
|
||||
expect(summarizeToolGroup(cards)).toBe(
|
||||
"Ran 2 commands, read 2 files, edited a file, created a file, ran a search, fetched a page · 1 failed",
|
||||
);
|
||||
});
|
||||
|
||||
it.each<[string, ToolGroupSummaryInput[], string]>([
|
||||
["a single command", [{ name: "bash", args: { command: "ls" } }], "Ran a command"],
|
||||
[
|
||||
"distinct paths over call count",
|
||||
[
|
||||
{ name: "read", args: { path: "/repo/a.ts" } },
|
||||
{ name: "read", args: { path: "/repo/a.ts" } },
|
||||
{ name: "read", args: { path: "/repo/b.ts" } },
|
||||
],
|
||||
"Read 2 files",
|
||||
],
|
||||
[
|
||||
"call count when reads carry no paths",
|
||||
[
|
||||
{ name: "read", args: {} },
|
||||
{ name: "read", args: {} },
|
||||
],
|
||||
"Read 2 files",
|
||||
],
|
||||
[
|
||||
"multiple searches",
|
||||
[
|
||||
{ name: "grep", args: { pattern: "a" } },
|
||||
{ name: "glob", args: { pattern: "b" } },
|
||||
],
|
||||
"Ran 2 searches",
|
||||
],
|
||||
["one generic tool by name", [{ name: "mcp__linear" }], "Used mcp__linear"],
|
||||
[
|
||||
"repeat generic tool with a multiplier",
|
||||
[{ name: "mcp__linear" }, { name: "mcp__linear" }],
|
||||
"Used mcp__linear ×2",
|
||||
],
|
||||
[
|
||||
"many distinct generic tools as a count",
|
||||
[{ name: "alpha" }, { name: "beta" }, { name: "gamma" }],
|
||||
"Used 3 tools",
|
||||
],
|
||||
])("summarizes %s", (_label, cards, expected) => {
|
||||
expect(summarizeToolGroup(cards)).toBe(expected);
|
||||
});
|
||||
});
|
||||
149
ui/src/lib/chat/tool-call-grouping.ts
Normal file
149
ui/src/lib/chat/tool-call-grouping.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Aggregate summaries for a run of consecutive tool calls, e.g.
|
||||
* "Ran 13 commands, read 6 files, edited 9 files, created a file".
|
||||
*/
|
||||
|
||||
import { resolveToolCallKind, type ToolCallKind } from "./tool-call-view.ts";
|
||||
|
||||
export type ToolGroupSummaryInput = {
|
||||
name: string;
|
||||
args?: unknown;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
type GroupCounts = {
|
||||
commands: number;
|
||||
readPaths: Set<string>;
|
||||
reads: number;
|
||||
editPaths: Set<string>;
|
||||
edits: number;
|
||||
writePaths: Set<string>;
|
||||
writes: number;
|
||||
searches: number;
|
||||
fetches: number;
|
||||
otherNames: Set<string>;
|
||||
others: number;
|
||||
failed: number;
|
||||
};
|
||||
|
||||
function pathKeyFromArgs(args: unknown): string | null {
|
||||
if (!args || typeof args !== "object" || Array.isArray(args)) {
|
||||
return null;
|
||||
}
|
||||
const record = args as Record<string, unknown>;
|
||||
for (const key of ["path", "file_path", "filePath", "notebook_path"]) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function countCard(counts: GroupCounts, card: ToolGroupSummaryInput): void {
|
||||
const kind: ToolCallKind = resolveToolCallKind(card.name, card.args);
|
||||
const pathKey = pathKeyFromArgs(card.args);
|
||||
switch (kind) {
|
||||
case "command":
|
||||
counts.commands += 1;
|
||||
break;
|
||||
case "read":
|
||||
counts.reads += 1;
|
||||
if (pathKey) {
|
||||
counts.readPaths.add(pathKey);
|
||||
}
|
||||
break;
|
||||
case "edit":
|
||||
counts.edits += 1;
|
||||
if (pathKey) {
|
||||
counts.editPaths.add(pathKey);
|
||||
}
|
||||
break;
|
||||
case "write":
|
||||
counts.writes += 1;
|
||||
if (pathKey) {
|
||||
counts.writePaths.add(pathKey);
|
||||
}
|
||||
break;
|
||||
case "search":
|
||||
counts.searches += 1;
|
||||
break;
|
||||
case "fetch":
|
||||
counts.fetches += 1;
|
||||
break;
|
||||
default:
|
||||
counts.others += 1;
|
||||
counts.otherNames.add(card.name);
|
||||
}
|
||||
if (card.isError) {
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function plural(count: number, singular: string, pluralForm = `${singular}s`): string {
|
||||
return count === 1 ? `a ${singular}` : `${count} ${pluralForm}`;
|
||||
}
|
||||
|
||||
function filesPhrase(calls: number, paths: Set<string>): string {
|
||||
const files = paths.size > 0 ? paths.size : calls;
|
||||
return plural(files, "file");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the collapsed group label. The first segment carries the verb
|
||||
* ("Ran 13 commands"); later segments continue lowercase ("read 6 files").
|
||||
*/
|
||||
export function summarizeToolGroup(cards: readonly ToolGroupSummaryInput[]): string {
|
||||
const counts: GroupCounts = {
|
||||
commands: 0,
|
||||
readPaths: new Set(),
|
||||
reads: 0,
|
||||
editPaths: new Set(),
|
||||
edits: 0,
|
||||
writePaths: new Set(),
|
||||
writes: 0,
|
||||
searches: 0,
|
||||
fetches: 0,
|
||||
otherNames: new Set(),
|
||||
others: 0,
|
||||
failed: 0,
|
||||
};
|
||||
for (const card of cards) {
|
||||
countCard(counts, card);
|
||||
}
|
||||
|
||||
const segments: string[] = [];
|
||||
if (counts.commands > 0) {
|
||||
segments.push(`ran ${plural(counts.commands, "command")}`);
|
||||
}
|
||||
if (counts.reads > 0) {
|
||||
segments.push(`read ${filesPhrase(counts.reads, counts.readPaths)}`);
|
||||
}
|
||||
if (counts.edits > 0) {
|
||||
segments.push(`edited ${filesPhrase(counts.edits, counts.editPaths)}`);
|
||||
}
|
||||
if (counts.writes > 0) {
|
||||
segments.push(`created ${filesPhrase(counts.writes, counts.writePaths)}`);
|
||||
}
|
||||
if (counts.searches > 0) {
|
||||
segments.push(counts.searches === 1 ? "ran a search" : `ran ${counts.searches} searches`);
|
||||
}
|
||||
if (counts.fetches > 0) {
|
||||
segments.push(`fetched ${plural(counts.fetches, "page")}`);
|
||||
}
|
||||
if (counts.others > 0) {
|
||||
const names = [...counts.otherNames].slice(0, 2).join(", ");
|
||||
segments.push(
|
||||
counts.otherNames.size <= 2 && names
|
||||
? `used ${names}${counts.others > counts.otherNames.size ? ` ×${counts.others}` : ""}`
|
||||
: `used ${plural(counts.others, "tool")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (segments.length === 0) {
|
||||
return `Ran ${plural(cards.length, "tool call")}`;
|
||||
}
|
||||
const label = segments.join(", ");
|
||||
const capitalized = label.charAt(0).toUpperCase() + label.slice(1);
|
||||
return counts.failed > 0 ? `${capitalized} · ${counts.failed} failed` : capitalized;
|
||||
}
|
||||
280
ui/src/lib/chat/tool-call-view.test.ts
Normal file
280
ui/src/lib/chat/tool-call-view.test.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
// Control UI tests cover tool-call classification and view-model resolution.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveToolCallKind,
|
||||
resolveToolCallView,
|
||||
splitPathForDisplay,
|
||||
unwrapShellWrapperCommand,
|
||||
} from "./tool-call-view.ts";
|
||||
|
||||
describe("resolveToolCallKind", () => {
|
||||
it.each([
|
||||
["bash", undefined, "command"],
|
||||
["exec", undefined, "command"],
|
||||
["Read", undefined, "read"],
|
||||
["read_file", undefined, "read"],
|
||||
["edit", undefined, "edit"],
|
||||
["str_replace_editor", undefined, "edit"],
|
||||
["apply_patch", undefined, "edit"],
|
||||
["write", undefined, "write"],
|
||||
["create_file", undefined, "write"],
|
||||
["grep", undefined, "search"],
|
||||
["glob", undefined, "search"],
|
||||
["web_fetch", undefined, "fetch"],
|
||||
["mcp__linear__create_issue", undefined, "generic"],
|
||||
// Arg-shape fallback: unknown tool with a small command payload is a command.
|
||||
["run_shell", { command: "ls" }, "command"],
|
||||
["run_shell", { command: "ls", a: 1, b: 2, c: 3 }, "generic"],
|
||||
])("classifies %s with args %o as %s", (name, args, expected) => {
|
||||
expect(resolveToolCallKind(name, args)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitPathForDisplay", () => {
|
||||
it.each([
|
||||
["/repo/src/index.ts", { base: "index.ts", dir: "/repo/src" }],
|
||||
["index.ts", { base: "index.ts" }],
|
||||
["C:\\repo\\file.ts", { base: "file.ts", dir: "C:/repo" }],
|
||||
["/repo/dir/", { base: "dir", dir: "/repo" }],
|
||||
])("splits %s", (path, expected) => {
|
||||
expect(splitPathForDisplay(path)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unwrapShellWrapperCommand", () => {
|
||||
it.each([
|
||||
["/bin/zsh -lc 'pnpm test ui'", "pnpm test ui"],
|
||||
['/bin/bash -c "git status"', "git status"],
|
||||
["sh -lc 'echo hi'", "echo hi"],
|
||||
["pnpm test ui", "pnpm test ui"],
|
||||
["/bin/zsh -lc unquoted", "/bin/zsh -lc unquoted"],
|
||||
])("unwraps %s", (wrapped, expected) => {
|
||||
expect(unwrapShellWrapperCommand(wrapped)).toBe(expected);
|
||||
});
|
||||
|
||||
it("unwraps the shell wrapper in command views", () => {
|
||||
expect(
|
||||
resolveToolCallView({ name: "bash", args: { command: "/bin/zsh -lc 'node --version'" } }),
|
||||
).toEqual({ kind: "command", command: "node --version" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveToolCallView", () => {
|
||||
it("returns the command text for command rows", () => {
|
||||
expect(resolveToolCallView({ name: "bash", args: { command: "git status" } })).toEqual({
|
||||
kind: "command",
|
||||
command: "git status",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves read targets across path spellings", () => {
|
||||
for (const args of [
|
||||
{ path: "/repo/src/main.ts" },
|
||||
{ file_path: "/repo/src/main.ts" },
|
||||
{ filePath: "/repo/src/main.ts" },
|
||||
]) {
|
||||
expect(resolveToolCallView({ name: "read", args })).toEqual({
|
||||
kind: "read",
|
||||
target: "main.ts",
|
||||
targetDetail: "/repo/src",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("computes an edit diff from openclaw-style oldText/newText args", () => {
|
||||
const view = resolveToolCallView({
|
||||
name: "edit",
|
||||
args: { path: "/repo/a.ts", oldText: "old line", newText: "new line" },
|
||||
});
|
||||
|
||||
expect(view.kind).toBe("edit");
|
||||
expect(view.target).toBe("a.ts");
|
||||
expect(view.targetDetail).toBe("/repo");
|
||||
expect(view.diff).toEqual([
|
||||
{ kind: "del", text: "old line" },
|
||||
{ kind: "add", text: "new line" },
|
||||
]);
|
||||
expect(view.stat).toEqual({ added: 1, removed: 1 });
|
||||
});
|
||||
|
||||
it("computes an edit diff from Claude-style old_string/new_string args", () => {
|
||||
const view = resolveToolCallView({
|
||||
name: "edit",
|
||||
args: { file_path: "/repo/a.ts", old_string: "before", new_string: "after" },
|
||||
});
|
||||
|
||||
expect(view.diff).toEqual([
|
||||
{ kind: "del", text: "before" },
|
||||
{ kind: "add", text: "after" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("joins multi-edit diffs with skip separators", () => {
|
||||
const view = resolveToolCallView({
|
||||
name: "multiedit",
|
||||
args: {
|
||||
path: "/repo/a.ts",
|
||||
edits: [
|
||||
{ oldText: "one", newText: "uno" },
|
||||
{ oldText: "two", newText: "dos" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(view.diff).toEqual([
|
||||
{ kind: "del", text: "one" },
|
||||
{ kind: "add", text: "uno" },
|
||||
{ kind: "skip", text: "" },
|
||||
{ kind: "del", text: "two" },
|
||||
{ kind: "add", text: "dos" },
|
||||
]);
|
||||
expect(view.stat).toEqual({ added: 2, removed: 2 });
|
||||
});
|
||||
|
||||
it("prefers the numbered details diff over locally computed arg diffs", () => {
|
||||
const view = resolveToolCallView({
|
||||
name: "edit",
|
||||
args: { path: "/repo/a.ts", oldText: "arg old", newText: "arg new" },
|
||||
details: { diff: "-12 detail old\n+12 detail new" },
|
||||
});
|
||||
|
||||
expect(view.diff).toEqual([
|
||||
{ kind: "del", lineNo: 12, text: "detail old" },
|
||||
{ kind: "add", lineNo: 12, text: "detail new" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to arg diffs when the details diff is unparseable", () => {
|
||||
const view = resolveToolCallView({
|
||||
name: "edit",
|
||||
args: { path: "/repo/a.ts", oldText: "old", newText: "new" },
|
||||
details: { diff: "raw unnumbered text" },
|
||||
});
|
||||
|
||||
expect(view.diff).toEqual([
|
||||
{ kind: "del", text: "old" },
|
||||
{ kind: "add", text: "new" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders Codex apply_patch calls as edits with a target path", () => {
|
||||
const patch = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: src/lib/util.ts",
|
||||
"@@",
|
||||
" context line",
|
||||
"-removed line",
|
||||
"+added line",
|
||||
"*** End Patch",
|
||||
].join("\n");
|
||||
|
||||
const view = resolveToolCallView({ name: "apply_patch", args: { patch } });
|
||||
|
||||
expect(view.kind).toBe("edit");
|
||||
expect(view.target).toBe("util.ts");
|
||||
expect(view.targetDetail).toBe("src/lib");
|
||||
expect(view.diff).toContainEqual({ kind: "del", text: "removed line" });
|
||||
expect(view.diff).toContainEqual({ kind: "add", text: "added line" });
|
||||
expect(view.stat).toEqual({ added: 1, removed: 1 });
|
||||
});
|
||||
|
||||
it("caps apply_patch rows while keeping the full diffstat", () => {
|
||||
const bigPatch = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: big.ts",
|
||||
...Array.from({ length: 900 }, (_, index) => `+line ${index}`),
|
||||
"*** End Patch",
|
||||
].join("\n");
|
||||
|
||||
const view = resolveToolCallView({ name: "apply_patch", args: { patch: bigPatch } });
|
||||
|
||||
expect(view.kind).toBe("edit");
|
||||
expect(view.stat).toEqual({ added: 900, removed: 0 });
|
||||
expect(view.diff?.length).toBe(401);
|
||||
expect(view.diff?.at(-1)?.kind).toBe("skip");
|
||||
});
|
||||
|
||||
it("accepts the Codex input spelling for patch text", () => {
|
||||
const view = resolveToolCallView({
|
||||
name: "apply_patch",
|
||||
args: { input: "*** Add File: notes.md\n+hello" },
|
||||
});
|
||||
|
||||
expect(view.kind).toBe("edit");
|
||||
expect(view.target).toBe("notes.md");
|
||||
expect(view.stat).toEqual({ added: 1, removed: 0 });
|
||||
});
|
||||
|
||||
it("builds an all-added preview for write calls with content", () => {
|
||||
const view = resolveToolCallView({
|
||||
name: "write",
|
||||
args: { path: "/repo/new.ts", content: "line 1\nline 2\n" },
|
||||
});
|
||||
|
||||
expect(view).toEqual({
|
||||
kind: "write",
|
||||
target: "new.ts",
|
||||
targetDetail: "/repo",
|
||||
diff: [
|
||||
{ kind: "add", lineNo: 1, text: "line 1" },
|
||||
{ kind: "add", lineNo: 2, text: "line 2" },
|
||||
],
|
||||
stat: { added: 2, removed: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves search views from pattern plus path scope", () => {
|
||||
expect(resolveToolCallView({ name: "grep", args: { pattern: "TODO", path: "src" } })).toEqual({
|
||||
kind: "search",
|
||||
target: "TODO",
|
||||
targetDetail: "src",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves fetch views from the url arg", () => {
|
||||
expect(resolveToolCallView({ name: "web_fetch", args: { url: "https://x.dev/a" } })).toEqual({
|
||||
kind: "fetch",
|
||||
target: "https://x.dev/a",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["read without a path", { name: "read", args: {} }],
|
||||
["edit without a path", { name: "edit", args: { oldText: "a", newText: "b" } }],
|
||||
["patch without patch text", { name: "apply_patch", args: {} }],
|
||||
["fetch without a url", { name: "fetch", args: {} }],
|
||||
["unknown tool", { name: "mcp__thing", args: { foo: "bar" } }],
|
||||
])("degrades to generic for %s", (_label, source) => {
|
||||
expect(resolveToolCallView(source).kind).toBe("generic");
|
||||
});
|
||||
|
||||
it("renders pure deletions without a phantom blank added line", () => {
|
||||
const view = resolveToolCallView({
|
||||
name: "edit",
|
||||
args: { path: "/repo/a.ts", oldText: "gone-line", newText: "" },
|
||||
});
|
||||
|
||||
expect(view.stat).toEqual({ added: 0, removed: 1 });
|
||||
expect(view.diff).toEqual([{ kind: "del", text: "gone-line" }]);
|
||||
});
|
||||
|
||||
it("rebuilds the cached view when result details arrive on the same args", () => {
|
||||
const args = { path: "/repo/a.md", edits: [{ oldText: "x", newText: "y" }] };
|
||||
|
||||
const before = resolveToolCallView({ name: "edit", args });
|
||||
const after = resolveToolCallView({
|
||||
name: "edit",
|
||||
args,
|
||||
details: { diff: "+12 hello", patch: "" },
|
||||
});
|
||||
|
||||
expect(before.diff?.[0]?.lineNo).toBeUndefined();
|
||||
expect(after.diff?.[0]).toMatchObject({ kind: "add", lineNo: 12, text: "hello" });
|
||||
});
|
||||
|
||||
it("caches views per args object identity", () => {
|
||||
const source = { name: "edit", args: { path: "/repo/a.ts", oldText: "x", newText: "y" } };
|
||||
|
||||
expect(resolveToolCallView(source)).toBe(resolveToolCallView(source));
|
||||
});
|
||||
});
|
||||
345
ui/src/lib/chat/tool-call-view.ts
Normal file
345
ui/src/lib/chat/tool-call-view.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* View-model for tool-call rows.
|
||||
*
|
||||
* Classifies a tool call into a small set of presentation kinds (command,
|
||||
* read, edit, write, search, fetch, generic) across the arg spellings used by
|
||||
* the OpenClaw session tools and foreign harnesses (Claude/Codex style).
|
||||
*/
|
||||
|
||||
import {
|
||||
buildWriteDiffLines,
|
||||
computeLineDiff,
|
||||
countTextLines,
|
||||
diffStat,
|
||||
joinDiffSections,
|
||||
MAX_DIFF_RENDER_LINES,
|
||||
parseDiffDetailsString,
|
||||
type DiffLine,
|
||||
type DiffStat,
|
||||
} from "./tool-call-diff.ts";
|
||||
|
||||
export type ToolCallKind = "command" | "read" | "edit" | "write" | "search" | "fetch" | "generic";
|
||||
|
||||
export type ToolCallViewSource = {
|
||||
name: string;
|
||||
args?: unknown;
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
export type ToolCallView = {
|
||||
kind: ToolCallKind;
|
||||
/** Full command text for `command` rows (first line shown collapsed). */
|
||||
command?: string;
|
||||
/** File basename or primary target shown bold in the row. */
|
||||
target?: string;
|
||||
/** Dimmed secondary detail (directory, query scope, URL host…). */
|
||||
targetDetail?: string;
|
||||
/** Inline diff rows for edit/write calls. */
|
||||
diff?: DiffLine[];
|
||||
stat?: DiffStat;
|
||||
};
|
||||
|
||||
const COMMAND_TOOL_NAMES = new Set(["bash", "exec", "shell", "run_command", "run_terminal_cmd"]);
|
||||
const READ_TOOL_NAMES = new Set(["read", "read_file", "readfile", "notebookread", "notebook_read"]);
|
||||
const EDIT_TOOL_NAMES = new Set([
|
||||
"edit",
|
||||
"edit_file",
|
||||
"multiedit",
|
||||
"multi_edit",
|
||||
"str_replace_editor",
|
||||
"notebookedit",
|
||||
"notebook_edit",
|
||||
]);
|
||||
const WRITE_TOOL_NAMES = new Set(["write", "write_file", "create_file"]);
|
||||
const SEARCH_TOOL_NAMES = new Set(["grep", "find", "glob", "ls", "list", "codebase_search"]);
|
||||
const FETCH_TOOL_NAMES = new Set(["web_fetch", "webfetch", "fetch"]);
|
||||
const PATCH_TOOL_NAMES = new Set(["apply_patch", "applypatch", "patch"]);
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value : undefined;
|
||||
}
|
||||
|
||||
function resolvePathArg(args: Record<string, unknown> | null): string | undefined {
|
||||
if (!args) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
readString(args.path) ??
|
||||
readString(args.file_path) ??
|
||||
readString(args.filePath) ??
|
||||
readString(args.filename) ??
|
||||
readString(args.notebook_path)
|
||||
);
|
||||
}
|
||||
|
||||
export function splitPathForDisplay(path: string): { base: string; dir?: string } {
|
||||
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const slash = normalized.lastIndexOf("/");
|
||||
if (slash <= 0) {
|
||||
return { base: normalized || path };
|
||||
}
|
||||
return { base: normalized.slice(slash + 1), dir: normalized.slice(0, slash) };
|
||||
}
|
||||
|
||||
type EditPair = { oldText: string; newText: string };
|
||||
|
||||
function readEditPairs(args: Record<string, unknown>): EditPair[] {
|
||||
const pairs: EditPair[] = [];
|
||||
const push = (oldText: unknown, newText: unknown) => {
|
||||
if (typeof oldText === "string" && typeof newText === "string") {
|
||||
pairs.push({ oldText, newText });
|
||||
}
|
||||
};
|
||||
if (Array.isArray(args.edits)) {
|
||||
for (const entry of args.edits) {
|
||||
const record = asRecord(entry);
|
||||
if (record) {
|
||||
push(
|
||||
record.oldText ?? record.old_string ?? record.oldString,
|
||||
record.newText ?? record.new_string ?? record.newString,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
push(
|
||||
args.oldText ?? args.old_string ?? args.oldString,
|
||||
args.newText ?? args.new_string ?? args.newString,
|
||||
);
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
function readDetailsDiff(details: unknown): DiffLine[] | null {
|
||||
const record = asRecord(details);
|
||||
const diffText = record ? readString(record.diff) : undefined;
|
||||
if (!diffText) {
|
||||
return null;
|
||||
}
|
||||
return parseDiffDetailsString(diffText);
|
||||
}
|
||||
|
||||
function resolveEditDiff(source: ToolCallViewSource): DiffLine[] | null {
|
||||
const fromDetails = readDetailsDiff(source.details);
|
||||
if (fromDetails) {
|
||||
return fromDetails;
|
||||
}
|
||||
const args = asRecord(source.args);
|
||||
if (!args) {
|
||||
return null;
|
||||
}
|
||||
const pairs = readEditPairs(args);
|
||||
if (pairs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const sections = pairs.map((pair) => computeLineDiff(pair.oldText, pair.newText));
|
||||
const joined = joinDiffSections(sections);
|
||||
return joined.length > 0 ? joined : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal Codex-style patch reader: extracts the target path plus add/del
|
||||
* rows so patch calls render like edits. Anything unrecognized stays generic.
|
||||
*/
|
||||
function resolvePatchView(args: Record<string, unknown> | null): ToolCallView | null {
|
||||
const patchText = args
|
||||
? (readString(args.patch) ?? readString(args.input) ?? readString(args.diff))
|
||||
: undefined;
|
||||
if (!patchText) {
|
||||
return null;
|
||||
}
|
||||
let target: string | undefined;
|
||||
const lines: DiffLine[] = [];
|
||||
const stat = { added: 0, removed: 0 };
|
||||
let truncated = false;
|
||||
// Rows render on every paint, so cap them like the other diff producers;
|
||||
// the diffstat still counts the whole patch (cheap string scans only).
|
||||
const pushLine = (line: DiffLine) => {
|
||||
if (lines.length < MAX_DIFF_RENDER_LINES) {
|
||||
lines.push(line);
|
||||
} else if (!truncated) {
|
||||
truncated = true;
|
||||
lines.push({ kind: "skip", text: "" });
|
||||
}
|
||||
};
|
||||
for (const raw of patchText.split("\n")) {
|
||||
const fileMatch = raw.match(/^\*\*\* (?:Update|Add|Delete) File: (.+)$|^\+\+\+ (?:b\/)?(.+)$/);
|
||||
if (fileMatch) {
|
||||
target ??= (fileMatch[1] ?? fileMatch[2])?.trim();
|
||||
continue;
|
||||
}
|
||||
if (/^\*\*\*|^---|^@@|^index |^diff /.test(raw)) {
|
||||
continue;
|
||||
}
|
||||
if (raw.startsWith("+")) {
|
||||
stat.added += 1;
|
||||
pushLine({ kind: "add", text: raw.slice(1) });
|
||||
} else if (raw.startsWith("-")) {
|
||||
stat.removed += 1;
|
||||
pushLine({ kind: "del", text: raw.slice(1) });
|
||||
} else {
|
||||
pushLine({ kind: "ctx", text: raw.startsWith(" ") ? raw.slice(1) : raw });
|
||||
}
|
||||
}
|
||||
if (!target && stat.added === 0 && stat.removed === 0) {
|
||||
return null;
|
||||
}
|
||||
const pathParts = target ? splitPathForDisplay(target) : null;
|
||||
return {
|
||||
kind: "edit",
|
||||
target: pathParts?.base,
|
||||
targetDetail: pathParts?.dir,
|
||||
diff: lines,
|
||||
stat,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeKey(name: string): string {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function resolveToolCallKind(name: string, args?: unknown): ToolCallKind {
|
||||
const key = normalizeKey(name);
|
||||
if (COMMAND_TOOL_NAMES.has(key)) {
|
||||
return "command";
|
||||
}
|
||||
if (READ_TOOL_NAMES.has(key)) {
|
||||
return "read";
|
||||
}
|
||||
if (EDIT_TOOL_NAMES.has(key) || PATCH_TOOL_NAMES.has(key)) {
|
||||
return "edit";
|
||||
}
|
||||
if (WRITE_TOOL_NAMES.has(key)) {
|
||||
return "write";
|
||||
}
|
||||
if (SEARCH_TOOL_NAMES.has(key)) {
|
||||
return "search";
|
||||
}
|
||||
if (FETCH_TOOL_NAMES.has(key)) {
|
||||
return "fetch";
|
||||
}
|
||||
// Arg-shape fallback for harness-specific command tools.
|
||||
const record = asRecord(args);
|
||||
if (record && typeof record.command === "string" && Object.keys(record).length <= 3) {
|
||||
return "command";
|
||||
}
|
||||
return "generic";
|
||||
}
|
||||
|
||||
// Cache entries remember which details object they were built from: live tool
|
||||
// rows first render with args only and gain result `details` (e.g. the edit
|
||||
// diff) later on the same args identity, which must invalidate the cache.
|
||||
const toolCallViewCache = new WeakMap<object, { details: unknown; view: ToolCallView }>();
|
||||
|
||||
export function resolveToolCallView(source: ToolCallViewSource): ToolCallView {
|
||||
const args = asRecord(source.args);
|
||||
const cacheKey = args ?? asRecord(source.details);
|
||||
if (cacheKey) {
|
||||
const cached = toolCallViewCache.get(cacheKey);
|
||||
if (cached && cached.details === source.details) {
|
||||
return cached.view;
|
||||
}
|
||||
}
|
||||
const view = buildToolCallView(source, args);
|
||||
if (cacheKey) {
|
||||
toolCallViewCache.set(cacheKey, { details: source.details, view });
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the `sh -lc '<command>'` wrapper harnesses add around agent commands
|
||||
* so rows show the command the model actually wrote. Display-only.
|
||||
*/
|
||||
export function unwrapShellWrapperCommand(command: string): string {
|
||||
const match = command.match(
|
||||
/^\s*(?:\/(?:usr\/)?bin\/)?(?:ba|z|da)?sh\s+-l?c\s+(['"])([\s\S]+)\1\s*$/,
|
||||
);
|
||||
return match ? match[2] : command;
|
||||
}
|
||||
|
||||
function buildToolCallView(
|
||||
source: ToolCallViewSource,
|
||||
args: Record<string, unknown> | null,
|
||||
): ToolCallView {
|
||||
const kind = resolveToolCallKind(source.name, source.args);
|
||||
const key = normalizeKey(source.name);
|
||||
|
||||
if (kind === "command") {
|
||||
const command = args ? readString(args.command) : undefined;
|
||||
return { kind, command: command ? unwrapShellWrapperCommand(command) : command };
|
||||
}
|
||||
|
||||
if (kind === "read") {
|
||||
const path = resolvePathArg(args);
|
||||
if (!path) {
|
||||
return { kind: "generic" };
|
||||
}
|
||||
const { base, dir } = splitPathForDisplay(path);
|
||||
return { kind, target: base, targetDetail: dir };
|
||||
}
|
||||
|
||||
if (kind === "edit") {
|
||||
if (PATCH_TOOL_NAMES.has(key)) {
|
||||
return resolvePatchView(args) ?? { kind: "generic" };
|
||||
}
|
||||
const path = resolvePathArg(args);
|
||||
if (!path) {
|
||||
return { kind: "generic" };
|
||||
}
|
||||
const { base, dir } = splitPathForDisplay(path);
|
||||
const diff = resolveEditDiff(source);
|
||||
return {
|
||||
kind,
|
||||
target: base,
|
||||
targetDetail: dir,
|
||||
...(diff ? { diff, stat: diffStat(diff) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === "write") {
|
||||
const path = resolvePathArg(args);
|
||||
if (!path) {
|
||||
return { kind: "generic" };
|
||||
}
|
||||
const { base, dir } = splitPathForDisplay(path);
|
||||
const content = args ? readString(args.content) : undefined;
|
||||
if (!content) {
|
||||
return { kind, target: base, targetDetail: dir };
|
||||
}
|
||||
const diff = buildWriteDiffLines(content);
|
||||
return {
|
||||
kind,
|
||||
target: base,
|
||||
targetDetail: dir,
|
||||
diff,
|
||||
stat: { added: countTextLines(content), removed: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === "search") {
|
||||
const pattern = args
|
||||
? (readString(args.pattern) ?? readString(args.query) ?? readString(args.glob))
|
||||
: undefined;
|
||||
const path = resolvePathArg(args) ?? (args ? readString(args.path) : undefined);
|
||||
if (!pattern && !path) {
|
||||
return { kind: "generic" };
|
||||
}
|
||||
return { kind, target: pattern ?? path, targetDetail: pattern ? path : undefined };
|
||||
}
|
||||
|
||||
if (kind === "fetch") {
|
||||
const url = args ? readString(args.url) : undefined;
|
||||
if (!url) {
|
||||
return { kind: "generic" };
|
||||
}
|
||||
return { kind, target: url };
|
||||
}
|
||||
|
||||
return { kind: "generic" };
|
||||
}
|
||||
@@ -269,6 +269,7 @@ export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[]
|
||||
const m = message as Record<string, unknown>;
|
||||
const content = normalizeContent(m.content);
|
||||
const messageIsError = readToolErrorFlag(m);
|
||||
const isLiveToolStream = m["__openclawToolStreamLive"] === true;
|
||||
const cards: ToolCard[] = [];
|
||||
const fallbackMatchedCards = new WeakSet<ToolCard>();
|
||||
const transcriptMessageId = resolveTranscriptMessageId(m);
|
||||
@@ -288,6 +289,9 @@ export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[]
|
||||
name: resolveToolName(item, m),
|
||||
args,
|
||||
inputText: serializeToolInput(args),
|
||||
...(isLiveToolStream
|
||||
? { live: true, completed: m["__openclawToolStreamResultReceived"] === true }
|
||||
: {}),
|
||||
messageId: transcriptMessageId,
|
||||
});
|
||||
continue;
|
||||
@@ -301,11 +305,15 @@ export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[]
|
||||
const text = extractToolText(item);
|
||||
const preview = extractToolPreview(text, name);
|
||||
const isError = readToolErrorFlag(item) ?? messageIsError;
|
||||
const details = item.details ?? m.details;
|
||||
if (existing) {
|
||||
fallbackMatchedCards.add(existing);
|
||||
existing.callId ??= callId;
|
||||
existing.outputText = text;
|
||||
existing.preview = preview;
|
||||
if (details !== undefined) {
|
||||
existing.details = details;
|
||||
}
|
||||
if (isError !== undefined) {
|
||||
existing.isError = isError;
|
||||
}
|
||||
@@ -316,6 +324,7 @@ export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[]
|
||||
...(callId ? { callId } : {}),
|
||||
name,
|
||||
outputText: text,
|
||||
...(details !== undefined ? { details } : {}),
|
||||
messageId: transcriptMessageId,
|
||||
...(isError !== undefined ? { isError } : {}),
|
||||
preview,
|
||||
@@ -343,6 +352,7 @@ export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[]
|
||||
...(callId ? { callId } : {}),
|
||||
name,
|
||||
outputText: text,
|
||||
...(m.details !== undefined ? { details: m.details } : {}),
|
||||
messageId: transcriptMessageId,
|
||||
...(messageIsError !== undefined ? { isError: messageIsError } : {}),
|
||||
preview: extractToolPreview(text, name),
|
||||
|
||||
@@ -101,6 +101,7 @@ import {
|
||||
} from "./run-lifecycle.ts";
|
||||
import { scheduleChatScroll } from "./scroll.ts";
|
||||
import { clearChatMessagesFromCache } from "./session-message-cache.ts";
|
||||
import { configureToolTitleFetcher } from "./tool-titles.ts";
|
||||
|
||||
type ChatPageContext = ApplicationContext;
|
||||
type PaneSessionChangeOptions = { replace?: boolean };
|
||||
@@ -1117,6 +1118,15 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
return html`<main class="app-shell app-shell--booting" aria-busy="true"></main>`;
|
||||
}
|
||||
const currentAgentId = resolveChatAgentId(state);
|
||||
// Tool rows consult the global title store while rendering; point its
|
||||
// fetcher at this pane's connection. Requests capture session + agent at
|
||||
// schedule time, so later renders of other panes cannot re-route them.
|
||||
configureToolTitleFetcher({
|
||||
client: state.connected ? state.client : null,
|
||||
sessionKey: state.sessionKey || null,
|
||||
agentId: currentAgentId || null,
|
||||
onTitlesChanged: () => state.requestUpdate?.(),
|
||||
});
|
||||
const agentDefaultModel = this.context.agents.state.agentsList?.agents.find(
|
||||
(agent) => agent.id === currentAgentId,
|
||||
)?.model?.primary;
|
||||
|
||||
@@ -210,6 +210,71 @@ describe("buildChatItems", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("coalesces interleaved parallel call/result pairs by call id", () => {
|
||||
const groups = messageGroups({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "call-a", name: "read", input: { path: "a.ts" } }],
|
||||
timestamp: 1000,
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "call-b", name: "read", input: { path: "b.ts" } }],
|
||||
timestamp: 1001,
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call-a",
|
||||
toolName: "read",
|
||||
content: [{ type: "toolResult", text: "contents of a" }],
|
||||
timestamp: 1002,
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call-b",
|
||||
toolName: "read",
|
||||
content: [{ type: "toolResult", text: "contents of b" }],
|
||||
timestamp: 1003,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].role).toBe("tool");
|
||||
expect(groups[0].messages).toHaveLength(2);
|
||||
const cards = groups[0].messages.flatMap((entry, index) =>
|
||||
extractToolCards(entry.message, `interleaved-${index}`),
|
||||
);
|
||||
expect(cards).toHaveLength(2);
|
||||
expect(cards[0]).toMatchObject({ callId: "call-a", outputText: "contents of a" });
|
||||
expect(cards[1]).toMatchObject({ callId: "call-b", outputText: "contents of b" });
|
||||
});
|
||||
|
||||
it("does not pair results across a user message boundary", () => {
|
||||
const groups = messageGroups({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "call-x", name: "read", input: { path: "x.ts" } }],
|
||||
timestamp: 1000,
|
||||
},
|
||||
{ role: "user", content: "never mind", timestamp: 1001 },
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call-x",
|
||||
toolName: "read",
|
||||
content: [{ type: "toolResult", text: "late result" }],
|
||||
timestamp: 1002,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Call and late result stay separate items around the user turn.
|
||||
expect(groups).toHaveLength(3);
|
||||
expect(groups.map((group) => group.role)).toEqual(["tool", "user", "tool"]);
|
||||
});
|
||||
|
||||
it("coalesces provider-shaped result blocks by canonical tool-use id", () => {
|
||||
const groups = messageGroups({
|
||||
messages: [
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import {
|
||||
isToolCallContentType,
|
||||
isToolResultContentType,
|
||||
resolveToolUseId,
|
||||
} from "../../../../src/chat/tool-content.js";
|
||||
import type {
|
||||
ChatItem,
|
||||
@@ -341,14 +342,32 @@ function mergeToolCallResultPair(callItem: ChatItem, resultItem: ChatItem): Chat
|
||||
const preservedResultContent = resultOnlyContent.filter(
|
||||
(block) => asRecord(block)?.type !== "text",
|
||||
);
|
||||
// Raw transcript result blocks usually carry the call id and tool name on the
|
||||
// message, not the block. Stamp both onto the merged blocks (plus message-level
|
||||
// details) so card extraction pairs them with the call instead of rendering a
|
||||
// second bare "Tool" card.
|
||||
const resultContent = hasToolResultBlock
|
||||
? resultOnlyContent
|
||||
? resultOnlyContent.map((block) => {
|
||||
const record = asRecord(block);
|
||||
if (!record || !isToolResultContentType(record.type)) {
|
||||
return block;
|
||||
}
|
||||
const stamped: Record<string, unknown> = Object.assign({}, record);
|
||||
stamped.id = resolveToolUseId(record) ?? resultCard.callId;
|
||||
stamped.name =
|
||||
typeof record.name === "string" && record.name.trim() ? record.name : resultName;
|
||||
if (record.details === undefined && resultMessage.details !== undefined) {
|
||||
stamped.details = resultMessage.details;
|
||||
}
|
||||
return stamped;
|
||||
})
|
||||
: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: resultCard.callId,
|
||||
name: resultName,
|
||||
text: resultCard.outputText ?? "",
|
||||
...(resultCard.details !== undefined ? { details: resultCard.details } : {}),
|
||||
...(resultCard.isError !== undefined ? { isError: resultCard.isError } : {}),
|
||||
},
|
||||
...preservedResultContent,
|
||||
@@ -364,20 +383,74 @@ function mergeToolCallResultPair(callItem: ChatItem, resultItem: ChatItem): Chat
|
||||
};
|
||||
}
|
||||
|
||||
// Parallel tool calls interleave in transcripts (call, call, result, result),
|
||||
// so a result must pair with any still-open call in the current tool run, not
|
||||
// only the immediately previous item. Non-tool items end the window.
|
||||
const TOOL_CALL_MERGE_WINDOW = 16;
|
||||
|
||||
function isToolTimelineItem(item: ChatItem): boolean {
|
||||
if (item.kind !== "message") {
|
||||
return false;
|
||||
}
|
||||
const normalized = safeNormalizeMessage(item.message);
|
||||
return normalized ? normalizeRoleForGrouping(normalized.role) === "tool" : false;
|
||||
}
|
||||
|
||||
function coalesceToolActivityMessages(items: ChatItem[]): ChatItem[] {
|
||||
const coalesced: ChatItem[] = [];
|
||||
// Indexes into `coalesced` of assistant tool-call items that have no result yet.
|
||||
let openCallIndexes: number[] = [];
|
||||
for (const item of items) {
|
||||
const previous = coalesced[coalesced.length - 1];
|
||||
const merged = previous ? mergeToolCallResultPair(previous, item) : null;
|
||||
if (merged) {
|
||||
coalesced[coalesced.length - 1] = merged;
|
||||
} else {
|
||||
coalesced.push(item);
|
||||
let merged: ChatItem | null = null;
|
||||
let mergedAt = -1;
|
||||
for (let i = openCallIndexes.length - 1; i >= 0; i--) {
|
||||
const callIndex = openCallIndexes[i];
|
||||
const candidate = mergeToolCallResultPair(coalesced[callIndex], item);
|
||||
if (candidate) {
|
||||
merged = candidate;
|
||||
mergedAt = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (merged && mergedAt >= 0) {
|
||||
coalesced[openCallIndexes[mergedAt]] = merged;
|
||||
openCallIndexes.splice(mergedAt, 1);
|
||||
continue;
|
||||
}
|
||||
coalesced.push(item);
|
||||
if (isOpenToolCallItem(item)) {
|
||||
openCallIndexes.push(coalesced.length - 1);
|
||||
if (openCallIndexes.length > TOOL_CALL_MERGE_WINDOW) {
|
||||
openCallIndexes.shift();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isToolTimelineItem(item)) {
|
||||
// Orphan results keep the window open for later siblings.
|
||||
continue;
|
||||
}
|
||||
// Any other content (user text, assistant reply, dividers) closes the run.
|
||||
openCallIndexes = [];
|
||||
}
|
||||
return coalesced;
|
||||
}
|
||||
|
||||
function isOpenToolCallItem(item: ChatItem): boolean {
|
||||
if (item.kind !== "message") {
|
||||
return false;
|
||||
}
|
||||
const message = asRecord(item.message);
|
||||
if (!message || typeof message.role !== "string" || message.role.toLowerCase() !== "assistant") {
|
||||
return false;
|
||||
}
|
||||
if (!Array.isArray(message.content)) {
|
||||
return false;
|
||||
}
|
||||
const hasCall = message.content.some((block) => isToolCallContentType(asRecord(block)?.type));
|
||||
const hasResult = message.content.some((block) => isToolResultContentType(asRecord(block)?.type));
|
||||
return hasCall && !hasResult;
|
||||
}
|
||||
|
||||
function assistantGroupHasReplyText(group: MessageGroup): boolean {
|
||||
return group.messages.some(({ message }) => Boolean(extractTextCached(message)?.trim()));
|
||||
}
|
||||
|
||||
@@ -188,6 +188,7 @@ export function renderChat(props: ChatProps) {
|
||||
queue: props.queue,
|
||||
showThinking: props.showThinking,
|
||||
showToolCalls: props.showToolCalls,
|
||||
runActive: Boolean(props.canAbort),
|
||||
sessions: props.sessions,
|
||||
assistantName: props.assistantName,
|
||||
assistantAvatar: props.assistantAvatar,
|
||||
|
||||
@@ -1148,7 +1148,8 @@ describe("grouped chat rendering", () => {
|
||||
});
|
||||
|
||||
const activity = expectElement(container, ".chat-activity-group__summary", HTMLButtonElement);
|
||||
expect(activity.textContent).toContain("Activity: 2 tools");
|
||||
// Aggregate summary from summarizeToolGroup replaces the old "Activity: N tools" label.
|
||||
expect(activity.textContent).toContain("Ran a command, read a file");
|
||||
expect(activity.querySelector(".chat-activity-group__preview")).toBeNull();
|
||||
expect(activity.textContent).not.toContain("read_file");
|
||||
expect(activity.textContent).not.toContain("run_command");
|
||||
@@ -1315,7 +1316,9 @@ describe("grouped chat rendering", () => {
|
||||
const summaries = container.querySelectorAll(".chat-tool-msg-summary");
|
||||
expect(summaries).toHaveLength(2);
|
||||
expect(container.querySelector(".chat-tool-msg-summary--error")).toBeNull();
|
||||
expect(summaries[0]?.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("bash");
|
||||
expect(container.querySelector(".chat-tool-row__badge")).toBeNull();
|
||||
// Command calls render a `$ command` row instead of the tool-name label.
|
||||
expect(summaries[0]?.querySelector(".chat-tool-row__cmd")?.textContent).toBe("run fallback");
|
||||
});
|
||||
|
||||
it("hides grouped tool activity when tool calls are disabled", () => {
|
||||
@@ -1378,23 +1381,24 @@ describe("grouped chat rendering", () => {
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
renderAssistantMessage(container, message, {
|
||||
isToolMessageExpanded: () => false,
|
||||
isToolExpanded: () => false,
|
||||
});
|
||||
|
||||
expect(container.querySelector(".chat-tool-msg-body")).toBeNull();
|
||||
|
||||
renderAssistantMessage(container, message, {
|
||||
isToolMessageExpanded: () => true,
|
||||
isToolExpanded: () => true,
|
||||
});
|
||||
|
||||
// Simple object args render as key-value rows; only the output keeps a block.
|
||||
const kvRow = container.querySelector(".chat-tool-kv__row");
|
||||
expect(kvRow?.querySelector(".chat-tool-kv__key")?.textContent).toBe("url:");
|
||||
expect(kvRow?.querySelector(".chat-tool-kv__value")?.textContent).toBe("https://example.com");
|
||||
const blocks = Array.from(container.querySelectorAll(".chat-tool-card__block"));
|
||||
expect(
|
||||
blocks.map((block) => block.querySelector(".chat-tool-card__block-label")?.textContent),
|
||||
).toEqual(["Tool input", "Tool output"]);
|
||||
expect(blocks.map((block) => block.querySelector("code")?.textContent)).toEqual([
|
||||
'{\n "url": "https://example.com"\n}',
|
||||
"Opened page",
|
||||
]);
|
||||
).toEqual(["Tool output"]);
|
||||
expect(blocks[0]?.querySelector("code")?.textContent).toBe("Opened page");
|
||||
});
|
||||
|
||||
it("renders expanded standalone tool-call rows", () => {
|
||||
@@ -1414,7 +1418,7 @@ describe("grouped chat rendering", () => {
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
renderAssistantMessage(container, message, {
|
||||
isToolMessageExpanded: () => false,
|
||||
isToolExpanded: () => false,
|
||||
});
|
||||
|
||||
expectElement(container, ".chat-bubble--tool-shell", HTMLElement);
|
||||
@@ -1423,13 +1427,21 @@ describe("grouped chat rendering", () => {
|
||||
expect(container.querySelector(".chat-tool-msg-body")).toBeNull();
|
||||
|
||||
renderAssistantMessage(container, message, {
|
||||
isToolMessageExpanded: () => true,
|
||||
isToolExpanded: () => true,
|
||||
});
|
||||
|
||||
expect(container.querySelector(".chat-tool-card__block-label")?.textContent).toBe("Tool input");
|
||||
expect(container.querySelector(".chat-tool-card__block code")?.textContent).toBe(
|
||||
'{\n "mode": "session",\n "thread": true\n}',
|
||||
);
|
||||
// Simple object args render as key-value rows instead of a raw JSON block.
|
||||
expect(container.querySelector(".chat-tool-card__block")).toBeNull();
|
||||
const kvRows = Array.from(container.querySelectorAll(".chat-tool-kv__row"));
|
||||
expect(
|
||||
kvRows.map((row) => [
|
||||
row.querySelector(".chat-tool-kv__key")?.textContent,
|
||||
row.querySelector(".chat-tool-kv__value")?.textContent,
|
||||
]),
|
||||
).toEqual([
|
||||
["mode:", "session"],
|
||||
["thread:", "true"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders assistant tool content as a flat concise tool row without a top-level call id", () => {
|
||||
@@ -1454,7 +1466,8 @@ describe("grouped chat rendering", () => {
|
||||
|
||||
expectElement(container, ".chat-bubble--tool-shell", HTMLElement);
|
||||
const summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement);
|
||||
expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("bash");
|
||||
// Command calls render a `$ command` row instead of the tool-name label.
|
||||
expect(summary.querySelector(".chat-tool-row__cmd")?.textContent).toBe("bash");
|
||||
expect(summary.querySelector(".chat-tool-msg-summary__names")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1521,21 +1534,20 @@ describe("grouped chat rendering", () => {
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
renderAssistantMessage(container, message, {
|
||||
isToolMessageExpanded: () => false,
|
||||
isToolExpanded: () => false,
|
||||
});
|
||||
|
||||
// The cleaned string-arg preview is now the primary collapsed label.
|
||||
expect(container.querySelector(".chat-tool-msg-summary__label")?.textContent?.trim()).toBe(
|
||||
"presentation_create",
|
||||
);
|
||||
expect(container.querySelector(".chat-tool-msg-summary__names")?.textContent?.trim()).toBe(
|
||||
"Example Deck",
|
||||
);
|
||||
expect(container.querySelector(".chat-tool-msg-summary__names")).toBeNull();
|
||||
expect(container.querySelector(".chat-tool-msg-summary")?.textContent).not.toContain(
|
||||
"with Example Deck",
|
||||
);
|
||||
|
||||
renderAssistantMessage(container, message, {
|
||||
isToolMessageExpanded: () => true,
|
||||
isToolExpanded: () => true,
|
||||
});
|
||||
|
||||
expect(container.querySelector(".chat-tool-msg-body")?.textContent).not.toContain(
|
||||
@@ -1594,14 +1606,22 @@ describe("grouped chat rendering", () => {
|
||||
},
|
||||
);
|
||||
|
||||
// The call's simple args render as key-value rows; the error keeps a block.
|
||||
const kvRows = Array.from(container.querySelectorAll(".chat-tool-kv__row"));
|
||||
expect(
|
||||
kvRows.map((row) => [
|
||||
row.querySelector(".chat-tool-kv__key")?.textContent,
|
||||
row.querySelector(".chat-tool-kv__value")?.textContent,
|
||||
]),
|
||||
).toEqual([
|
||||
["mode:", "session"],
|
||||
["thread:", "true"],
|
||||
]);
|
||||
const blocks = Array.from(container.querySelectorAll(".chat-tool-card__block"));
|
||||
expect(
|
||||
blocks.map((block) => block.querySelector(".chat-tool-card__block-label")?.textContent),
|
||||
).toEqual(["Tool input", "Tool error"]);
|
||||
expect(blocks[0]?.querySelector("code")?.textContent).toBe(
|
||||
'{\n "mode": "session",\n "thread": true\n}',
|
||||
);
|
||||
expect(JSON.parse(blocks[1]?.querySelector("code")?.textContent ?? "{}")).toEqual({
|
||||
).toEqual(["Tool error"]);
|
||||
expect(JSON.parse(blocks[0]?.querySelector("code")?.textContent ?? "{}")).toEqual({
|
||||
status: "error",
|
||||
error: "Session mode is unavailable for this target.",
|
||||
childSessionKey: "agent:test:subagent:abc123",
|
||||
@@ -1788,24 +1808,34 @@ describe("grouped chat rendering", () => {
|
||||
),
|
||||
];
|
||||
renderMessageGroups(container, groups, {
|
||||
isToolExpanded: () => true,
|
||||
isToolMessageExpanded: () => true,
|
||||
});
|
||||
|
||||
expect(container.querySelector(".chat-tool-card__block-label")?.textContent).toBe("Tool input");
|
||||
expect(container.querySelector(".chat-tool-card__block code")?.textContent).toBe(
|
||||
'{\n "mode": "session",\n "thread": true\n}',
|
||||
);
|
||||
// The call's simple args render as key-value rows while expanded.
|
||||
const kvRows = Array.from(container.querySelectorAll(".chat-tool-kv__row"));
|
||||
expect(
|
||||
kvRows.map((row) => [
|
||||
row.querySelector(".chat-tool-kv__key")?.textContent,
|
||||
row.querySelector(".chat-tool-kv__value")?.textContent,
|
||||
]),
|
||||
).toEqual([
|
||||
["mode:", "session"],
|
||||
["thread:", "true"],
|
||||
]);
|
||||
expect(
|
||||
JSON.parse(container.querySelector(".chat-json-content code")?.textContent ?? "{}"),
|
||||
).toEqual({
|
||||
status: "error",
|
||||
});
|
||||
|
||||
// Collapsing the call card must not hide the matching tool output message.
|
||||
renderMessageGroups(container, groups, {
|
||||
isToolMessageExpanded: (messageId) => !messageId.startsWith("toolmsg:assistant:"),
|
||||
isToolExpanded: () => false,
|
||||
isToolMessageExpanded: () => true,
|
||||
});
|
||||
|
||||
expect(container.querySelector(".chat-tool-card__block")).toBeNull();
|
||||
expect(container.querySelector(".chat-tool-kv")).toBeNull();
|
||||
expect(
|
||||
JSON.parse(container.querySelector(".chat-json-content code")?.textContent ?? "{}"),
|
||||
).toEqual({
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
normalizeMessage,
|
||||
} from "../../../lib/chat/message-normalizer.ts";
|
||||
import { normalizeRoleForGrouping } from "../../../lib/chat/message-normalizer.ts";
|
||||
import { summarizeToolGroup } from "../../../lib/chat/tool-call-grouping.ts";
|
||||
import {
|
||||
extractToolCardsCached,
|
||||
formatDistinctCollapsedToolSummaryText,
|
||||
@@ -48,11 +49,13 @@ import { getSafeLocalStorage } from "../../../local-storage.ts";
|
||||
import { renderChatAvatar } from "../chat-avatar.ts";
|
||||
import type { SidebarContent } from "./chat-sidebar.ts";
|
||||
import {
|
||||
isRunningToolCard,
|
||||
renderExpandedToolCardContent,
|
||||
renderRawOutputToggle,
|
||||
renderToolCard,
|
||||
renderToolPreview,
|
||||
resolveCollapsedToolDetail,
|
||||
resolveToolRowText,
|
||||
shouldToggleSelectableDisclosure,
|
||||
} from "./chat-tool-cards.ts";
|
||||
|
||||
@@ -602,6 +605,7 @@ type RenderMessageGroupOptions = {
|
||||
agentId?: string;
|
||||
showReasoning: boolean;
|
||||
showToolCalls?: boolean;
|
||||
runActive?: boolean;
|
||||
autoExpandToolCalls?: boolean;
|
||||
isToolMessageExpanded?: (messageId: string) => boolean | undefined;
|
||||
onToggleToolMessageExpanded?: (messageId: string, expanded?: boolean) => void;
|
||||
@@ -638,6 +642,7 @@ function buildGroupedMessageRenderOptions(
|
||||
duplicateCount: item.duplicateCount ?? 1,
|
||||
showReasoning: opts.showReasoning,
|
||||
showToolCalls: opts.showToolCalls ?? true,
|
||||
runActive: opts.runActive,
|
||||
turnSucceeded: group.turnSucceeded,
|
||||
autoExpandToolCalls: opts.autoExpandToolCalls ?? false,
|
||||
isToolMessageExpanded: opts.isToolMessageExpanded,
|
||||
@@ -691,6 +696,20 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
|
||||
const cards = group.messages.flatMap((item) => extractToolCardsCached(item.message, item.key));
|
||||
const toolCount = cards.length || group.messages.length;
|
||||
const hasError = cards.some(isToolCardError) && group.turnSucceeded !== true;
|
||||
// While a run is live, the newest still-running call names the group so
|
||||
// the collapsed header reads like a status line; afterwards it aggregates.
|
||||
const runningCard = opts.runActive
|
||||
? cards.findLast((card) => isRunningToolCard(card, opts.runActive))
|
||||
: undefined;
|
||||
const groupSummaryLabel = runningCard
|
||||
? `${resolveToolRowText(runningCard)}…`
|
||||
: summarizeToolGroup(
|
||||
cards.map((card) => ({
|
||||
name: card.name,
|
||||
args: card.args,
|
||||
isError: isToolCardError(card) && group.turnSucceeded !== true,
|
||||
})),
|
||||
);
|
||||
const activityDisclosureId = `activity:${group.key}`;
|
||||
const activityExpanded = opts.isToolMessageExpanded?.(activityDisclosureId) ?? hasError;
|
||||
|
||||
@@ -727,8 +746,10 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
|
||||
}}
|
||||
>
|
||||
<span class="chat-activity-group__icon">${hasError ? icons.x : icons.activity}</span>
|
||||
<span class="chat-activity-group__label"
|
||||
>Activity: ${toolCount} tool${toolCount === 1 ? "" : "s"}</span
|
||||
<span
|
||||
class="chat-activity-group__label"
|
||||
title=${`${toolCount} tool call${toolCount === 1 ? "" : "s"}`}
|
||||
>${groupSummaryLabel}</span
|
||||
>
|
||||
<span
|
||||
class="collapse-chevron ${activityExpanded ? "" : "collapse-chevron--collapsed"}"
|
||||
@@ -1766,6 +1787,7 @@ function renderInlineToolCards(
|
||||
isToolExpanded?: (toolCardId: string) => boolean;
|
||||
onToggleToolExpanded?: (toolCardId: string) => void;
|
||||
turnSucceeded?: boolean;
|
||||
runActive?: boolean;
|
||||
canvasPluginSurfaceUrl?: string | null;
|
||||
embedSandboxMode?: EmbedSandboxMode;
|
||||
allowExternalEmbedUrls?: boolean;
|
||||
@@ -1777,6 +1799,7 @@ function renderInlineToolCards(
|
||||
renderToolCard(card, {
|
||||
expanded: opts.isToolExpanded?.(`${opts.messageKey}:toolcard:${index}`) ?? false,
|
||||
turnSucceeded: opts.turnSucceeded,
|
||||
runActive: opts.runActive,
|
||||
onToggleExpanded: opts.onToggleToolExpanded
|
||||
? () => opts.onToggleToolExpanded?.(`${opts.messageKey}:toolcard:${index}`)
|
||||
: () => undefined,
|
||||
@@ -1962,6 +1985,7 @@ function renderGroupedMessage(
|
||||
duplicateCount?: number;
|
||||
showReasoning: boolean;
|
||||
showToolCalls?: boolean;
|
||||
runActive?: boolean;
|
||||
turnSucceeded?: boolean;
|
||||
autoExpandToolCalls?: boolean;
|
||||
isToolMessageExpanded?: (messageId: string) => boolean | undefined;
|
||||
@@ -2104,6 +2128,51 @@ function renderGroupedMessage(
|
||||
|
||||
const duplicateCount = Math.max(1, Math.floor(opts.duplicateCount ?? 1));
|
||||
|
||||
// Pure tool messages (no text/images/attachments) skip the "Tool output"
|
||||
// shell and render as flat kind-aware rows, one disclosure level deep.
|
||||
const onlyToolCards =
|
||||
isStandaloneToolMessage &&
|
||||
hasToolCards &&
|
||||
!markdown &&
|
||||
!hasImages &&
|
||||
!hasPairingQrExpiryNotices &&
|
||||
visibleAttachments.length === 0 &&
|
||||
assistantViewBlocks.length === 0 &&
|
||||
!reasoningMarkdown;
|
||||
|
||||
if (onlyToolCards) {
|
||||
return html`
|
||||
<div
|
||||
class="${bubbleClasses}"
|
||||
data-message-id=${messageKey}
|
||||
data-message-text=${extractedText || nothing}
|
||||
>
|
||||
${renderReplyPill(normalizedMessage.replyTarget)}
|
||||
${renderInlineToolCards(toolCards, {
|
||||
messageKey,
|
||||
sessionKey: opts.sessionKey,
|
||||
agentId: opts.agentId,
|
||||
onOpenSidebar,
|
||||
isToolExpanded: opts.isToolExpanded,
|
||||
onToggleToolExpanded: opts.onToggleToolExpanded,
|
||||
turnSucceeded: opts.turnSucceeded,
|
||||
runActive: opts.runActive,
|
||||
canvasPluginSurfaceUrl: opts.canvasPluginSurfaceUrl,
|
||||
embedSandboxMode: opts.embedSandboxMode ?? "scripts",
|
||||
allowExternalEmbedUrls: opts.allowExternalEmbedUrls ?? false,
|
||||
})}
|
||||
${duplicateCount > 1
|
||||
? html`<div
|
||||
class="chat-duplicate-count"
|
||||
aria-label=${`${duplicateCount} consecutive identical messages collapsed`}
|
||||
>
|
||||
×${duplicateCount}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="${bubbleClasses}"
|
||||
@@ -2191,6 +2260,7 @@ function renderGroupedMessage(
|
||||
isToolExpanded: opts.isToolExpanded,
|
||||
onToggleToolExpanded: opts.onToggleToolExpanded,
|
||||
turnSucceeded: opts.turnSucceeded,
|
||||
runActive: opts.runActive,
|
||||
canvasPluginSurfaceUrl: opts.canvasPluginSurfaceUrl,
|
||||
embedSandboxMode: opts.embedSandboxMode ?? "scripts",
|
||||
allowExternalEmbedUrls: opts.allowExternalEmbedUrls ?? false,
|
||||
@@ -2238,6 +2308,7 @@ function renderGroupedMessage(
|
||||
isToolExpanded: opts.isToolExpanded,
|
||||
onToggleToolExpanded: opts.onToggleToolExpanded,
|
||||
turnSucceeded: opts.turnSucceeded,
|
||||
runActive: opts.runActive,
|
||||
canvasPluginSurfaceUrl: opts.canvasPluginSurfaceUrl,
|
||||
embedSandboxMode: opts.embedSandboxMode ?? "scripts",
|
||||
allowExternalEmbedUrls: opts.allowExternalEmbedUrls ?? false,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { DeletedMessages } from "../deleted-messages.ts";
|
||||
import { PinnedMessages } from "../pinned-messages.ts";
|
||||
import type { RealtimeTalkConversationEntry } from "../realtime-talk-conversation.ts";
|
||||
import { getOrCreateSessionCacheValue } from "../session-cache.ts";
|
||||
import { getToolTitlesVersion } from "../tool-titles.ts";
|
||||
import {
|
||||
getAssistantAttachmentAvailabilityRenderVersion,
|
||||
renderMessageGroup,
|
||||
@@ -79,6 +80,8 @@ type ChatThreadProps = {
|
||||
queue: ChatQueueItem[];
|
||||
showThinking: boolean;
|
||||
showToolCalls: boolean;
|
||||
/** True while the session has an abortable live run (marks running tool rows). */
|
||||
runActive?: boolean;
|
||||
sessions: SessionsListResult | null;
|
||||
assistantName: string;
|
||||
assistantAvatar: string | null;
|
||||
@@ -722,10 +725,12 @@ export function renderChatThread(props: ChatThreadProps) {
|
||||
deletedChatItemsSignature(deleted, chatItems),
|
||||
stableBooleanMapSignature(expandedToolCards),
|
||||
getAssistantAttachmentAvailabilityRenderVersion(),
|
||||
getToolTitlesVersion(),
|
||||
props.sessionKey,
|
||||
props.fullMessageAgentId,
|
||||
showReasoning,
|
||||
props.showToolCalls,
|
||||
Boolean(props.runActive),
|
||||
Boolean(props.autoExpandToolCalls),
|
||||
props.assistantName,
|
||||
assistantIdentity.avatar,
|
||||
@@ -796,6 +801,7 @@ export function renderChatThread(props: ChatThreadProps) {
|
||||
agentId: props.fullMessageAgentId,
|
||||
showReasoning,
|
||||
showToolCalls: props.showToolCalls,
|
||||
runActive: props.runActive,
|
||||
autoExpandToolCalls: Boolean(props.autoExpandToolCalls),
|
||||
isToolMessageExpanded: (messageId: string) => expandedToolCards.get(messageId),
|
||||
onToggleToolMessageExpanded: (messageId: string, expanded?: boolean) => {
|
||||
|
||||
@@ -527,3 +527,42 @@ describe("tool-card canvas URLs", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRunningToolCard", () => {
|
||||
it("marks only live uncompleted cards as running while a run is active", async () => {
|
||||
const { isRunningToolCard } = await import("./chat-tool-cards.ts");
|
||||
const liveCard = { id: "t:1", name: "bash", live: true } as const;
|
||||
const historicalCard = { id: "t:2", name: "bash" } as const;
|
||||
|
||||
expect(isRunningToolCard(liveCard, true)).toBe(true);
|
||||
// Partial streamed output must not end the running state; only the final
|
||||
// result event does.
|
||||
expect(isRunningToolCard({ ...liveCard, outputText: "partial…" }, true)).toBe(true);
|
||||
expect(isRunningToolCard({ ...liveCard, completed: true, outputText: "" }, true)).toBe(false);
|
||||
// Historical transcript calls without results (e.g. aborted runs) must
|
||||
// stay inert when a later run is active in the same session.
|
||||
expect(isRunningToolCard(historicalCard, true)).toBe(false);
|
||||
expect(isRunningToolCard(liveCard, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("threads live and completion markers from tool-stream messages into cards", () => {
|
||||
const running = extractToolCards({
|
||||
role: "assistant",
|
||||
toolCallId: "call-live",
|
||||
__openclawToolStreamLive: true,
|
||||
__openclawToolStreamResultReceived: false,
|
||||
content: [{ type: "toolcall", name: "bash", arguments: { command: "sleep 5" } }],
|
||||
});
|
||||
expect(running).toHaveLength(1);
|
||||
expect(running[0]).toMatchObject({ live: true, completed: false });
|
||||
|
||||
const finished = extractToolCards({
|
||||
role: "assistant",
|
||||
toolCallId: "call-live",
|
||||
__openclawToolStreamLive: true,
|
||||
__openclawToolStreamResultReceived: true,
|
||||
content: [{ type: "toolcall", name: "bash", arguments: { command: "sleep 5" } }],
|
||||
});
|
||||
expect(finished[0]).toMatchObject({ live: true, completed: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("tool-cards", () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("renders expanded cards with inline input and output sections", () => {
|
||||
it("renders expanded cards with key-value args and an output section", () => {
|
||||
const container = document.createElement("div");
|
||||
const toggle = vi.fn();
|
||||
render(
|
||||
@@ -111,14 +111,18 @@ describe("tool-cards", () => {
|
||||
container,
|
||||
);
|
||||
|
||||
// Simple object args render as key-value rows instead of a raw JSON block.
|
||||
const kvRows = Array.from(container.querySelectorAll(".chat-tool-kv__row"));
|
||||
expect(kvRows).toHaveLength(1);
|
||||
expect(kvRows[0]?.querySelector(".chat-tool-kv__key")?.textContent).toBe("url:");
|
||||
expect(kvRows[0]?.querySelector(".chat-tool-kv__value")?.textContent).toBe(
|
||||
"https://example.com",
|
||||
);
|
||||
const blocks = Array.from(container.querySelectorAll(".chat-tool-card__block"));
|
||||
expect(
|
||||
blocks.map((block) => block.querySelector(".chat-tool-card__block-label")?.textContent),
|
||||
).toEqual(["Tool input", "Tool output"]);
|
||||
expect(blocks.map((block) => block.querySelector("code")?.textContent)).toEqual([
|
||||
'{\n "url": "https://example.com"\n}',
|
||||
"Opened page",
|
||||
]);
|
||||
).toEqual(["Tool output"]);
|
||||
expect(blocks[0]?.querySelector("code")?.textContent).toBe("Opened page");
|
||||
});
|
||||
|
||||
it("does not repeat the tool identity in expanded details", () => {
|
||||
@@ -142,9 +146,11 @@ describe("tool-cards", () => {
|
||||
);
|
||||
|
||||
expect(container.textContent?.match(/Skill Workshop/g)).toHaveLength(1);
|
||||
const bodyText = container.querySelector(".chat-tool-msg-body")?.textContent ?? "";
|
||||
expect(bodyText).not.toContain("Skill Workshop");
|
||||
expect(bodyText).toContain('"action": "create"');
|
||||
const body = container.querySelector(".chat-tool-msg-body");
|
||||
expect(body?.textContent).not.toContain("Skill Workshop");
|
||||
const kvRow = body?.querySelector(".chat-tool-kv__row");
|
||||
expect(kvRow?.querySelector(".chat-tool-kv__key")?.textContent).toBe("action:");
|
||||
expect(kvRow?.querySelector(".chat-tool-kv__value")?.textContent).toBe("create");
|
||||
expect(container.querySelector(".chat-tool-card__action-btn")).toBeInstanceOf(
|
||||
HTMLButtonElement,
|
||||
);
|
||||
@@ -165,13 +171,18 @@ describe("tool-cards", () => {
|
||||
container,
|
||||
);
|
||||
|
||||
const blocks = Array.from(container.querySelectorAll(".chat-tool-card__block"));
|
||||
// No raw blocks: simple args render as key-value rows and there is no output.
|
||||
expect(container.querySelector(".chat-tool-card__block")).toBeNull();
|
||||
const kvRows = Array.from(container.querySelectorAll(".chat-tool-kv__row"));
|
||||
expect(
|
||||
blocks.map((block) => block.querySelector(".chat-tool-card__block-label")?.textContent),
|
||||
).toEqual(["Tool input"]);
|
||||
expect(blocks[0]?.querySelector("code")?.textContent).toBe(
|
||||
'{\n "mode": "session",\n "thread": true\n}',
|
||||
);
|
||||
kvRows.map((row) => [
|
||||
row.querySelector(".chat-tool-kv__key")?.textContent,
|
||||
row.querySelector(".chat-tool-kv__value")?.textContent,
|
||||
]),
|
||||
).toEqual([
|
||||
["mode:", "session"],
|
||||
["thread:", "true"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("labels collapsed tool calls with the display summary", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Control UI chat module implements tool cards behavior.
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { html, nothing } from "lit";
|
||||
import { keyed } from "lit/directives/keyed.js";
|
||||
import { icons, type IconName } from "../../../components/icons.ts";
|
||||
@@ -6,6 +7,8 @@ import { isMarkdownBlockArtText } from "../../../components/markdown.ts";
|
||||
import "../../../components/tooltip.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import type { ToolCard } from "../../../lib/chat/chat-types.ts";
|
||||
import type { DiffLine, DiffStat } from "../../../lib/chat/tool-call-diff.ts";
|
||||
import { resolveToolCallView, type ToolCallView } from "../../../lib/chat/tool-call-view.ts";
|
||||
import {
|
||||
formatDistinctCollapsedToolSummaryText,
|
||||
formatCollapsedToolPreviewText,
|
||||
@@ -20,6 +23,7 @@ import {
|
||||
resolveToolDisplay,
|
||||
type EmbedSandboxMode,
|
||||
} from "../../../lib/chat/tool-display.ts";
|
||||
import { getToolCallTitle } from "../tool-titles.ts";
|
||||
import type { SidebarContent } from "./chat-sidebar.ts";
|
||||
|
||||
type FullMessageRequest = NonNullable<SidebarContent["fullMessageRequest"]>;
|
||||
@@ -342,34 +346,286 @@ function renderToolDataBlock(params: { label: string; text: string }) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCollapsedToolSummary(params: {
|
||||
label: string;
|
||||
icon: ReturnType<typeof html> | undefined;
|
||||
name?: string;
|
||||
expanded: boolean;
|
||||
isError?: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
}) {
|
||||
const { label, icon, name, expanded, isError, onToggleExpanded } = params;
|
||||
const displayLabel = formatCollapsedToolSummaryText(label) ?? label;
|
||||
const displayName = formatDistinctCollapsedToolSummaryText(name, displayLabel);
|
||||
return html`
|
||||
<button
|
||||
class="chat-tool-msg-summary ${isError ? "chat-tool-msg-summary--error" : ""}"
|
||||
type="button"
|
||||
aria-expanded=${String(expanded)}
|
||||
@click=${(event: MouseEvent) => {
|
||||
if (shouldToggleSelectableDisclosure(event)) {
|
||||
onToggleExpanded();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="chat-tool-msg-summary__icon">${icon}</span>
|
||||
<span class="chat-tool-msg-summary__label">${displayLabel}</span>
|
||||
${displayName
|
||||
? html`<span class="chat-tool-msg-summary__names">${displayName}</span>`
|
||||
// ── Kind-aware tool rows (command / read / edit / write / search / fetch) ──
|
||||
|
||||
const TOOL_ROW_VERBS: Partial<Record<ToolCallView["kind"], string>> = {
|
||||
read: "Read",
|
||||
edit: "Edited",
|
||||
write: "Wrote",
|
||||
search: "Searched",
|
||||
fetch: "Fetched",
|
||||
};
|
||||
|
||||
const TOOL_ROW_ICONS: Partial<Record<ToolCallView["kind"], string>> = {
|
||||
command: "terminal",
|
||||
read: "fileText",
|
||||
edit: "penLine",
|
||||
write: "fileCode",
|
||||
search: "search",
|
||||
fetch: "globe",
|
||||
};
|
||||
|
||||
function firstCommandLine(command: string): string {
|
||||
const line = command.split("\n")[0]?.trim() ?? "";
|
||||
return truncateUtf16Safe(line, 120);
|
||||
}
|
||||
|
||||
export function renderDiffStatChips(stat: DiffStat) {
|
||||
if (stat.added === 0 && stat.removed === 0) {
|
||||
return nothing;
|
||||
}
|
||||
return html`<span class="chat-diffstat">
|
||||
${stat.added > 0 ? html`<span class="chat-diffstat__add">+${stat.added}</span>` : nothing}
|
||||
${stat.removed > 0 ? html`<span class="chat-diffstat__del">-${stat.removed}</span>` : nothing}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
function renderToolRowContent(card: ToolCard, view: ToolCallView, isError: boolean) {
|
||||
if (view.kind === "command" && view.command) {
|
||||
const aiTitle = getToolCallTitle(card.name, card.args);
|
||||
const commandPreview = firstCommandLine(view.command);
|
||||
if (aiTitle) {
|
||||
return html`
|
||||
<span class="chat-tool-row__title">${aiTitle}</span>
|
||||
<code class="chat-tool-row__cmd chat-tool-row__cmd--secondary">${commandPreview}</code>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<span class="chat-tool-row__prompt" aria-hidden="true">$</span>
|
||||
<code class="chat-tool-row__cmd">${renderHighlightedCommand(commandPreview)}</code>
|
||||
`;
|
||||
}
|
||||
|
||||
const verb = TOOL_ROW_VERBS[view.kind];
|
||||
if (verb && view.target) {
|
||||
return html`
|
||||
<span class="chat-tool-row__verb">${verb}</span>
|
||||
<span class="chat-tool-row__target">${view.target}</span>
|
||||
${view.stat ? renderDiffStatChips(view.stat) : nothing}
|
||||
${view.targetDetail
|
||||
? html`<span class="chat-tool-row__detail">${view.targetDetail}</span>`
|
||||
: nothing}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
// Generic tools keep the resolver-driven label + detail, with an optional
|
||||
// AI purpose title when the args are complex enough to warrant one.
|
||||
const display = resolveToolDisplay({ name: card.name, args: card.args, detailMode: "explain" });
|
||||
const aiTitle = getToolCallTitle(card.name, card.args);
|
||||
const summary = resolveCollapsedToolSummaryParts({
|
||||
card,
|
||||
displayLabel: display.label,
|
||||
displayDetail: display.detail,
|
||||
isError,
|
||||
});
|
||||
const displayLabel = formatCollapsedToolSummaryText(summary.label) ?? summary.label;
|
||||
const displayName = formatDistinctCollapsedToolSummaryText(summary.name, displayLabel);
|
||||
if (aiTitle) {
|
||||
return html`
|
||||
<span class="chat-tool-row__title">${aiTitle}</span>
|
||||
<span class="chat-tool-row__detail">${displayLabel}</span>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<span class="chat-tool-msg-summary__label">${displayLabel}</span>
|
||||
${displayName
|
||||
? html`<span class="chat-tool-msg-summary__names">${displayName}</span>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderDiffBlock(lines: readonly DiffLine[]) {
|
||||
const hasLineNumbers = lines.some((line) => line.lineNo !== undefined);
|
||||
return html`
|
||||
<div class="chat-diff" role="figure" aria-label="File changes">
|
||||
${lines.map((line) => {
|
||||
if (line.kind === "skip") {
|
||||
return html`<div class="chat-diff__row chat-diff__row--skip">
|
||||
${hasLineNumbers ? html`<span class="chat-diff__gutter"></span>` : nothing}
|
||||
<span class="chat-diff__sign"></span>
|
||||
<span class="chat-diff__text">⋯</span>
|
||||
</div>`;
|
||||
}
|
||||
const kindClass =
|
||||
line.kind === "add"
|
||||
? "chat-diff__row--add"
|
||||
: line.kind === "del"
|
||||
? "chat-diff__row--del"
|
||||
: "";
|
||||
const sign = line.kind === "add" ? "+" : line.kind === "del" ? "-" : "";
|
||||
return html`<div class="chat-diff__row ${kindClass}">
|
||||
${hasLineNumbers
|
||||
? html`<span class="chat-diff__gutter">${line.lineNo ?? ""}</span>`
|
||||
: nothing}
|
||||
<span class="chat-diff__sign">${sign}</span>
|
||||
<span class="chat-diff__text">${line.text || " "}</span>
|
||||
</div>`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Command syntax highlighting ──
|
||||
|
||||
type CommandToken = { text: string; cls: "name" | "flag" | "str" | "num" | "op" | "plain" | "ws" };
|
||||
|
||||
const COMMAND_HIGHLIGHT_MAX_CHARS = 2_000;
|
||||
const COMMAND_OP_CHARS = new Set(["|", ";", "&", "<", ">"]);
|
||||
|
||||
/** Small shell-ish tokenizer for display colors only; never used for execution. */
|
||||
function tokenizeCommand(command: string): CommandToken[] {
|
||||
const tokens: CommandToken[] = [];
|
||||
let index = 0;
|
||||
let expectName = true;
|
||||
while (index < command.length) {
|
||||
const char = command[index];
|
||||
if (/\s/.test(char)) {
|
||||
let end = index;
|
||||
while (end < command.length && /\s/.test(command[end])) {
|
||||
end++;
|
||||
}
|
||||
tokens.push({ text: command.slice(index, end), cls: "ws" });
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
if (char === "'" || char === '"') {
|
||||
let end = index + 1;
|
||||
while (end < command.length && command[end] !== char) {
|
||||
end += command[end] === "\\" ? 2 : 1;
|
||||
}
|
||||
end = Math.min(end + 1, command.length);
|
||||
tokens.push({ text: command.slice(index, end), cls: "str" });
|
||||
index = end;
|
||||
expectName = false;
|
||||
continue;
|
||||
}
|
||||
if (COMMAND_OP_CHARS.has(char)) {
|
||||
let end = index;
|
||||
while (end < command.length && COMMAND_OP_CHARS.has(command[end])) {
|
||||
end++;
|
||||
}
|
||||
tokens.push({ text: command.slice(index, end), cls: "op" });
|
||||
index = end;
|
||||
expectName = true;
|
||||
continue;
|
||||
}
|
||||
let end = index;
|
||||
while (
|
||||
end < command.length &&
|
||||
!/\s/.test(command[end]) &&
|
||||
!COMMAND_OP_CHARS.has(command[end]) &&
|
||||
command[end] !== "'" &&
|
||||
command[end] !== '"'
|
||||
) {
|
||||
end++;
|
||||
}
|
||||
const word = command.slice(index, end);
|
||||
const cls = expectName
|
||||
? "name"
|
||||
: word.startsWith("-")
|
||||
? "flag"
|
||||
: /^\d+(?:[.,]\d+)?$/.test(word)
|
||||
? "num"
|
||||
: "plain";
|
||||
tokens.push({ text: word, cls });
|
||||
index = end;
|
||||
expectName = false;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function renderHighlightedCommand(command: string) {
|
||||
if (command.length > COMMAND_HIGHLIGHT_MAX_CHARS) {
|
||||
return html`${command}`;
|
||||
}
|
||||
return html`${tokenizeCommand(command).map((token) =>
|
||||
token.cls === "ws" || token.cls === "plain"
|
||||
? html`${token.text}`
|
||||
: html`<span class="chat-cmd--${token.cls}">${token.text}</span>`,
|
||||
)}`;
|
||||
}
|
||||
|
||||
// ── Key-value args display (generic tools) ──
|
||||
|
||||
const KV_MAX_KEYS = 12;
|
||||
const KV_MAX_VALUE_CHARS = 400;
|
||||
|
||||
function formatKeyValue(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return truncateUtf16Safe(value, KV_MAX_VALUE_CHARS);
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return truncateUtf16Safe(JSON.stringify(value), KV_MAX_VALUE_CHARS);
|
||||
} catch {
|
||||
return Object.prototype.toString.call(value);
|
||||
}
|
||||
}
|
||||
|
||||
function renderArgsKeyValueList(args: Record<string, unknown>) {
|
||||
return html`
|
||||
<div class="chat-tool-kv">
|
||||
${Object.entries(args).map(
|
||||
([key, value]) => html`
|
||||
<div class="chat-tool-kv__row">
|
||||
<span class="chat-tool-kv__key">${key}:</span>
|
||||
<span class="chat-tool-kv__value">${formatKeyValue(value)}</span>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function canRenderArgsAsKeyValue(args: unknown): args is Record<string, unknown> {
|
||||
if (!args || typeof args !== "object" || Array.isArray(args)) {
|
||||
return false;
|
||||
}
|
||||
const keys = Object.keys(args as Record<string, unknown>);
|
||||
return keys.length > 0 && keys.length <= KV_MAX_KEYS;
|
||||
}
|
||||
|
||||
// Args already represented in the collapsed row / header detail for kinds that
|
||||
// summarize their primary target; everything else stays auditable on expand.
|
||||
const ROW_SUMMARIZED_ARG_KEYS: Partial<Record<ToolCallView["kind"], ReadonlySet<string>>> = {
|
||||
read: new Set(["path", "file_path", "filePath", "notebook_path", "offset", "limit"]),
|
||||
search: new Set(["pattern", "query", "glob", "path"]),
|
||||
fetch: new Set(["url"]),
|
||||
};
|
||||
|
||||
function extraArgsBeyondRowTarget(
|
||||
args: unknown,
|
||||
kind: ToolCallView["kind"],
|
||||
): Record<string, unknown> | null {
|
||||
if (!args || typeof args !== "object" || Array.isArray(args)) {
|
||||
return null;
|
||||
}
|
||||
const summarized = ROW_SUMMARIZED_ARG_KEYS[kind];
|
||||
if (!summarized) {
|
||||
return args as Record<string, unknown>;
|
||||
}
|
||||
const extras = Object.fromEntries(
|
||||
Object.entries(args as Record<string, unknown>).filter(([key]) => !summarized.has(key)),
|
||||
);
|
||||
return Object.keys(extras).length > 0 ? extras : null;
|
||||
}
|
||||
|
||||
function renderTerminalBlock(command: string, output: string | undefined, isError: boolean) {
|
||||
return html`
|
||||
<div class="chat-tool-term ${isError ? "chat-tool-term--error" : ""}">
|
||||
<div class="chat-tool-term__cmd">
|
||||
<span class="chat-tool-term__prompt">$</span
|
||||
><code>${renderHighlightedCommand(command)}</code>
|
||||
</div>
|
||||
${output?.trim()
|
||||
? html`<pre class="chat-tool-term__out"><code>${output}</code></pre>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -408,12 +664,40 @@ function resolveCollapsedToolSummaryParts(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function isRunningToolCard(card: ToolCard, runActive: boolean | undefined): boolean {
|
||||
// Only live tool-stream cards can be running; historical transcript calls
|
||||
// without results (aborted runs) must stay inert during later runs. The
|
||||
// result event ends the running state — partial streamed output does not.
|
||||
return (
|
||||
runActive === true && card.live === true && card.completed !== true && !isToolCardError(card)
|
||||
);
|
||||
}
|
||||
|
||||
/** Plain-text row label, e.g. for the group header while a tool is running. */
|
||||
export function resolveToolRowText(card: ToolCard): string {
|
||||
const view = resolveToolCallView({ name: card.name, args: card.args, details: card.details });
|
||||
if (view.kind === "command" && view.command) {
|
||||
return getToolCallTitle(card.name, card.args) ?? `$ ${firstCommandLine(view.command)}`;
|
||||
}
|
||||
const verb = TOOL_ROW_VERBS[view.kind];
|
||||
if (verb && view.target) {
|
||||
return `${verb} ${view.target}`;
|
||||
}
|
||||
const aiTitle = getToolCallTitle(card.name, card.args);
|
||||
if (aiTitle) {
|
||||
return aiTitle;
|
||||
}
|
||||
const display = resolveToolDisplay({ name: card.name, args: card.args, detailMode: "explain" });
|
||||
return display.label;
|
||||
}
|
||||
|
||||
export function renderToolCard(
|
||||
card: ToolCard,
|
||||
opts: {
|
||||
expanded: boolean;
|
||||
onToggleExpanded: (id: string) => void;
|
||||
turnSucceeded?: boolean;
|
||||
runActive?: boolean;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
onOpenSidebar?: (content: SidebarContent) => void;
|
||||
@@ -422,14 +706,11 @@ export function renderToolCard(
|
||||
allowExternalEmbedUrls?: boolean;
|
||||
},
|
||||
) {
|
||||
const view = resolveToolCallView({ name: card.name, args: card.args, details: card.details });
|
||||
const display = resolveToolDisplay({ name: card.name, args: card.args, detailMode: "explain" });
|
||||
const isError = isToolCardError(card) && opts.turnSucceeded !== true;
|
||||
const summary = resolveCollapsedToolSummaryParts({
|
||||
card,
|
||||
displayLabel: display.label,
|
||||
displayDetail: display.detail,
|
||||
isError,
|
||||
});
|
||||
const isRunning = !isError && isRunningToolCard(card, opts.runActive);
|
||||
const icon = TOOL_ROW_ICONS[view.kind] ?? display.icon;
|
||||
|
||||
return html`
|
||||
<div
|
||||
@@ -437,14 +718,25 @@ export function renderToolCard(
|
||||
? "is-open"
|
||||
: ""}"
|
||||
>
|
||||
${renderCollapsedToolSummary({
|
||||
label: summary.label,
|
||||
icon: renderToolIcon(display.icon),
|
||||
name: summary.name,
|
||||
expanded: opts.expanded,
|
||||
isError,
|
||||
onToggleExpanded: () => opts.onToggleExpanded(card.id),
|
||||
})}
|
||||
<button
|
||||
class="chat-tool-msg-summary chat-tool-row ${isError
|
||||
? "chat-tool-msg-summary--error"
|
||||
: ""} ${isRunning ? "chat-tool-row--running" : ""}"
|
||||
type="button"
|
||||
aria-expanded=${String(opts.expanded)}
|
||||
@click=${(event: MouseEvent) => {
|
||||
if (shouldToggleSelectableDisclosure(event)) {
|
||||
opts.onToggleExpanded(card.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="chat-tool-msg-summary__icon">${renderToolIcon(icon)}</span>
|
||||
${renderToolRowContent(card, view, isError)}
|
||||
${isError ? html`<span class="chat-tool-row__badge">failed</span>` : nothing}
|
||||
${isRunning
|
||||
? html`<span class="chat-tool-row__spinner" aria-label="Running"></span>`
|
||||
: nothing}
|
||||
</button>
|
||||
${opts.expanded
|
||||
? html`
|
||||
<div class="chat-tool-msg-body">
|
||||
@@ -471,8 +763,14 @@ export function renderExpandedToolCardContent(
|
||||
embedSandboxMode: EmbedSandboxMode = "scripts",
|
||||
allowExternalEmbedUrls = false,
|
||||
) {
|
||||
const view = resolveToolCallView({ name: card.name, args: card.args, details: card.details });
|
||||
const display = resolveToolDisplay({ name: card.name, args: card.args });
|
||||
const detail = formatToolDetail(display);
|
||||
// File/search rows already carry their target; the "with …" connector only
|
||||
// reads well for generic tools ("with query …"), not "with from sessions.ts".
|
||||
const detail =
|
||||
view.kind === "read" || view.kind === "search" || view.kind === "fetch"
|
||||
? display.detail
|
||||
: formatToolDetail(display);
|
||||
const hasOutput = Boolean(card.outputText?.trim());
|
||||
const hasInput = Boolean(card.inputText?.trim());
|
||||
const isError = isToolCardError(card);
|
||||
@@ -497,6 +795,76 @@ export function renderExpandedToolCardContent(
|
||||
allowExternalEmbedUrls,
|
||||
})
|
||||
: nothing;
|
||||
const sidebarAction = canOpenSidebar
|
||||
? html`
|
||||
<div class="chat-tool-card__actions">
|
||||
<openclaw-tooltip content="Open in the side panel">
|
||||
<button
|
||||
class="chat-tool-card__action-btn"
|
||||
type="button"
|
||||
@click=${() => onOpenSidebar?.(sidebarActionContent)}
|
||||
aria-label="Open tool details in side panel"
|
||||
>
|
||||
<span class="chat-tool-card__action-icon">${icons.panelRightOpen}</span>
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
</div>
|
||||
`
|
||||
: nothing;
|
||||
|
||||
// Command calls render terminal-style: `$ command` + raw output. Remaining
|
||||
// args (workdir, timeout, env…) stay visible as key-value rows so identical
|
||||
// commands in different contexts remain distinguishable in the audit trail.
|
||||
if (view.kind === "command" && view.command && !card.preview) {
|
||||
const argsRecord =
|
||||
card.args && typeof card.args === "object" && !Array.isArray(card.args)
|
||||
? (card.args as Record<string, unknown>)
|
||||
: null;
|
||||
const extraArgs = Object.fromEntries(
|
||||
Object.entries(argsRecord ?? {}).filter(([key]) => key !== "command"),
|
||||
);
|
||||
return html`
|
||||
<div class="chat-tool-card chat-tool-card--flush ${isError ? "chat-tool-card--error" : ""}">
|
||||
${sidebarAction}
|
||||
${renderTerminalBlock(
|
||||
view.command,
|
||||
card.outputText ?? (isError ? "No output — tool failed." : undefined),
|
||||
isError,
|
||||
)}
|
||||
${Object.keys(extraArgs).length > 0 ? renderArgsKeyValueList(extraArgs) : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Edits and writes with a resolvable diff render it inline; the raw tool
|
||||
// output stays reachable behind the raw-details toggle.
|
||||
if ((view.kind === "edit" || view.kind === "write") && view.diff && view.diff.length > 0) {
|
||||
return html`
|
||||
<div class="chat-tool-card ${isError ? "chat-tool-card--error" : ""}">
|
||||
<div class="chat-tool-card__header">
|
||||
<div class="chat-tool-card__detail">
|
||||
${view.targetDetail ? `${view.targetDetail}/` : ""}${view.target ?? ""}
|
||||
</div>
|
||||
${sidebarAction}
|
||||
</div>
|
||||
${renderDiffBlock(view.diff)}
|
||||
${isError && hasOutput
|
||||
? renderToolDataBlock({ label: "Tool error", text: card.outputText! })
|
||||
: hasOutput
|
||||
? renderRawOutputToggle(card.outputText!)
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// File reads and searches summarize their primary target in the row, so the
|
||||
// full args JSON is noise — but any remaining args (filters, limits, request
|
||||
// options…) stay visible as key-value rows for auditability.
|
||||
const summarizedKind = view.kind === "read" || view.kind === "search" || view.kind === "fetch";
|
||||
const inputBlockArgs = summarizedKind
|
||||
? extraArgsBeyondRowTarget(card.args, view.kind)
|
||||
: card.args;
|
||||
const showInputBlock = hasInput && (!summarizedKind || inputBlockArgs !== null);
|
||||
|
||||
return html`
|
||||
<div class="chat-tool-card ${isError ? "chat-tool-card--error" : ""}">
|
||||
@@ -504,30 +872,17 @@ export function renderExpandedToolCardContent(
|
||||
? html`
|
||||
<div class="chat-tool-card__header">
|
||||
${detail ? html`<div class="chat-tool-card__detail">${detail}</div>` : nothing}
|
||||
${canOpenSidebar
|
||||
? html`
|
||||
<div class="chat-tool-card__actions">
|
||||
<openclaw-tooltip content="Open in the side panel">
|
||||
<button
|
||||
class="chat-tool-card__action-btn"
|
||||
type="button"
|
||||
@click=${() => onOpenSidebar?.(sidebarActionContent)}
|
||||
aria-label="Open tool details in side panel"
|
||||
>
|
||||
<span class="chat-tool-card__action-icon">${icons.panelRightOpen}</span>
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${sidebarAction}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${hasInput
|
||||
? renderToolDataBlock({
|
||||
label: "Tool input",
|
||||
text: card.inputText!,
|
||||
})
|
||||
${showInputBlock
|
||||
? canRenderArgsAsKeyValue(inputBlockArgs)
|
||||
? renderArgsKeyValueList(inputBlockArgs)
|
||||
: renderToolDataBlock({
|
||||
label: "Tool input",
|
||||
text: card.inputText!,
|
||||
})
|
||||
: nothing}
|
||||
${hasOutput
|
||||
? card.preview
|
||||
|
||||
@@ -815,3 +815,33 @@ describe("app-tool-stream fallback lifecycle handling", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("app-tool-stream result blocks", () => {
|
||||
it("emits a result block for completed tools with empty output", () => {
|
||||
const host = createHost();
|
||||
|
||||
handleAgentEvent(host, {
|
||||
runId: "run-1",
|
||||
seq: 1,
|
||||
stream: "tool",
|
||||
ts: TOOL_STREAM_TEST_NOW,
|
||||
sessionKey: "main",
|
||||
data: { phase: "start", name: "bash", toolCallId: "call-1", args: { command: "true" } },
|
||||
});
|
||||
handleAgentEvent(host, {
|
||||
runId: "run-1",
|
||||
seq: 2,
|
||||
stream: "tool",
|
||||
ts: TOOL_STREAM_TEST_NOW + 1,
|
||||
sessionKey: "main",
|
||||
data: { phase: "result", name: "bash", toolCallId: "call-1", result: "" },
|
||||
});
|
||||
|
||||
const entry = host.toolStreamById.get("call-1") as ToolStreamEntry;
|
||||
expect(entry.resultReceived).toBe(true);
|
||||
const content = entry.message.content as Array<Record<string, unknown>>;
|
||||
// The empty-output result block marks the call as finished so the UI does
|
||||
// not keep it in a running state for the rest of the run.
|
||||
expect(content.some((block) => block.type === "toolresult" && block.text === "")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,11 @@ export type ToolStreamEntry = {
|
||||
name: string;
|
||||
args?: unknown;
|
||||
output?: string;
|
||||
/** Structured result details (e.g. edit diff) captured from the result event. */
|
||||
details?: unknown;
|
||||
isError?: boolean;
|
||||
/** True once a result event landed, even when the output text is empty. */
|
||||
resultReceived?: boolean;
|
||||
startedAt: number;
|
||||
updatedAt: number;
|
||||
message: Record<string, unknown>;
|
||||
@@ -248,11 +253,15 @@ function buildToolStreamMessage(entry: ToolStreamEntry): Record<string, unknown>
|
||||
name: entry.name,
|
||||
arguments: entry.args ?? {},
|
||||
});
|
||||
if (entry.output) {
|
||||
// Emit the result block whenever a result landed, even with empty output;
|
||||
// otherwise a completed no-stdout command keeps its running state in the UI.
|
||||
if (entry.output || entry.resultReceived) {
|
||||
content.push({
|
||||
type: "toolresult",
|
||||
name: entry.name,
|
||||
text: entry.output,
|
||||
text: entry.output ?? "",
|
||||
...(entry.details !== undefined ? { details: entry.details } : {}),
|
||||
...(entry.isError !== undefined ? { isError: entry.isError } : {}),
|
||||
});
|
||||
}
|
||||
return {
|
||||
@@ -261,6 +270,12 @@ function buildToolStreamMessage(entry: ToolStreamEntry): Record<string, unknown>
|
||||
runId: entry.runId,
|
||||
content,
|
||||
timestamp: entry.startedAt,
|
||||
// Running-state markers: only live tool-stream cards may show a spinner,
|
||||
// and completion comes from the result event — partial `update` output
|
||||
// must not end the running state. Transcript messages never carry these,
|
||||
// so historical output-less calls (aborted runs) stay inert.
|
||||
__openclawToolStreamLive: true,
|
||||
__openclawToolStreamResultReceived: entry.resultReceived === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -710,6 +725,9 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
|
||||
: phase === "result"
|
||||
? formatToolOutput(data.result)
|
||||
: undefined;
|
||||
const resultDetails = phase === "result" ? readRecord(data.result)?.details : undefined;
|
||||
const resultIsError =
|
||||
phase === "result" && typeof data.isError === "boolean" ? data.isError : undefined;
|
||||
if (name === "session_status" && phase === "result") {
|
||||
syncSessionStatusModelOverride(host, data);
|
||||
}
|
||||
@@ -739,6 +757,9 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
|
||||
name,
|
||||
args,
|
||||
output: output || undefined,
|
||||
...(resultDetails !== undefined ? { details: resultDetails } : {}),
|
||||
...(resultIsError !== undefined ? { isError: resultIsError } : {}),
|
||||
...(phase === "result" ? { resultReceived: true } : {}),
|
||||
startedAt: typeof payload.ts === "number" ? payload.ts : now,
|
||||
updatedAt: now,
|
||||
message: {},
|
||||
@@ -753,6 +774,15 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
|
||||
if (output !== undefined) {
|
||||
entry.output = output || undefined;
|
||||
}
|
||||
if (resultDetails !== undefined) {
|
||||
entry.details = resultDetails;
|
||||
}
|
||||
if (resultIsError !== undefined) {
|
||||
entry.isError = resultIsError;
|
||||
}
|
||||
if (phase === "result") {
|
||||
entry.resultReceived = true;
|
||||
}
|
||||
entry.updatedAt = now;
|
||||
}
|
||||
|
||||
|
||||
124
ui/src/pages/chat/tool-titles.test.ts
Normal file
124
ui/src/pages/chat/tool-titles.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
// Control UI tests cover tool-title request eligibility and the title store.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import {
|
||||
configureToolTitleFetcher,
|
||||
getToolCallTitle,
|
||||
resetToolTitlesForTest,
|
||||
resolveToolTitleRequest,
|
||||
setToolTitleForTest,
|
||||
} from "./tool-titles.ts";
|
||||
|
||||
const LONG_GENERIC_ARGS = {
|
||||
title: "Investigate flaky gateway reconnect loop on the staging cluster",
|
||||
description: "The websocket reconnect loop spins when the auth token expires mid-session.",
|
||||
};
|
||||
|
||||
describe("resolveToolTitleRequest", () => {
|
||||
afterEach(() => {
|
||||
resetToolTitlesForTest();
|
||||
});
|
||||
|
||||
it("requests titles for commands of at least 12 characters", () => {
|
||||
const request = resolveToolTitleRequest("bash", { command: "git status --short" });
|
||||
|
||||
expect(request).not.toBeNull();
|
||||
expect(request?.input).toBe("git status --short");
|
||||
expect(request?.key).toMatch(/^t/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a short command", "bash", { command: "ls -la" }],
|
||||
["a read call", "read", { path: "/repo/a-very-long-path/to/some/file.ts" }],
|
||||
["an edit call", "edit", { path: "/repo/a.ts", oldText: "x".repeat(200), newText: "y" }],
|
||||
["a write call", "write", { path: "/repo/a.ts", content: "x".repeat(200) }],
|
||||
["a search call", "grep", { pattern: "x".repeat(200) }],
|
||||
["a fetch call", "web_fetch", { url: `https://x.dev/${"a".repeat(200)}` }],
|
||||
["a generic call with short args", "mcp__linear__create_issue", { title: "Fix bug" }],
|
||||
])("does not request a title for %s", (_label, name, args) => {
|
||||
expect(resolveToolTitleRequest(name, args)).toBeNull();
|
||||
});
|
||||
|
||||
it("requests titles for generic tools with at least 120 chars of serialized args", () => {
|
||||
const request = resolveToolTitleRequest("mcp__linear__create_issue", LONG_GENERIC_ARGS);
|
||||
|
||||
expect(request).not.toBeNull();
|
||||
expect(request?.input).toBe(JSON.stringify(LONG_GENERIC_ARGS));
|
||||
});
|
||||
|
||||
it("keys equal name and args to the same digest", () => {
|
||||
const first = resolveToolTitleRequest("bash", { command: "pnpm install --frozen" });
|
||||
const second = resolveToolTitleRequest("bash", { command: "pnpm install --frozen" });
|
||||
|
||||
expect(first?.key).toBe(second?.key);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getToolCallTitle", () => {
|
||||
afterEach(() => {
|
||||
resetToolTitlesForTest();
|
||||
});
|
||||
|
||||
it("returns a stored title for the resolved request key", () => {
|
||||
const args = { command: "git log --oneline -5" };
|
||||
const request = resolveToolTitleRequest("bash", args);
|
||||
if (!request) {
|
||||
throw new Error("expected an eligible title request");
|
||||
}
|
||||
setToolTitleForTest(request.key, "Checked recent commits");
|
||||
|
||||
expect(getToolCallTitle("bash", args)).toBe("Checked recent commits");
|
||||
});
|
||||
|
||||
it("returns undefined for eligible calls without a stored title", () => {
|
||||
expect(getToolCallTitle("bash", { command: "git log --oneline -5" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for ineligible calls even when a title exists", () => {
|
||||
setToolTitleForTest("some-key", "Never shown");
|
||||
|
||||
expect(getToolCallTitle("read", { path: "/repo/a.ts" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("title fetch batching", () => {
|
||||
afterEach(() => {
|
||||
configureToolTitleFetcher({ client: null, sessionKey: null, onTitlesChanged: null });
|
||||
resetToolTitlesForTest();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("sends queued items with the session and agent captured at schedule time", async () => {
|
||||
vi.useFakeTimers();
|
||||
const requests: Array<{ sessionKey: string; agentId?: string }> = [];
|
||||
const client = {
|
||||
request: vi.fn(async (_method: string, params: unknown) => {
|
||||
requests.push(params as { sessionKey: string; agentId?: string });
|
||||
return { titles: {} };
|
||||
}),
|
||||
} as unknown as GatewayBrowserClient;
|
||||
|
||||
// Pane A schedules, then pane B re-renders (and reconfigures) before the
|
||||
// debounce fires; the request must keep pane A's session and agent.
|
||||
configureToolTitleFetcher({
|
||||
client,
|
||||
sessionKey: "global",
|
||||
agentId: "alice",
|
||||
onTitlesChanged: null,
|
||||
});
|
||||
getToolCallTitle("bash", { command: "pnpm run build --filter ui" });
|
||||
configureToolTitleFetcher({
|
||||
client,
|
||||
sessionKey: "agent:b:main",
|
||||
agentId: "b",
|
||||
onTitlesChanged: null,
|
||||
});
|
||||
getToolCallTitle("bash", { command: "pnpm test ui/src/pages/chat" });
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(requests).toEqual([
|
||||
expect.objectContaining({ sessionKey: "global", agentId: "alice" }),
|
||||
expect.objectContaining({ sessionKey: "agent:b:main", agentId: "b" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
240
ui/src/pages/chat/tool-titles.ts
Normal file
240
ui/src/pages/chat/tool-titles.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* AI-generated purpose titles for complex tool calls.
|
||||
*
|
||||
* The store is process-global and keyed by a digest of tool name + args, so a
|
||||
* title generated once (or served from the gateway cache) applies to every
|
||||
* render of the same call. Fetching is debounced and best-effort: when no
|
||||
* utility model or Luna default is usable, rows keep their deterministic
|
||||
* labels.
|
||||
*/
|
||||
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import { resolveToolCallKind, unwrapShellWrapperCommand } from "../../lib/chat/tool-call-view.ts";
|
||||
|
||||
const MAX_TITLE_INPUT_CHARS = 2_000;
|
||||
const MAX_ITEMS_PER_REQUEST = 24;
|
||||
const REQUEST_DEBOUNCE_MS = 250;
|
||||
const MIN_COMMAND_CHARS_FOR_TITLE = 12;
|
||||
const MIN_GENERIC_INPUT_CHARS_FOR_TITLE = 120;
|
||||
|
||||
const titlesByKey = new Map<string, string>();
|
||||
const pendingKeys = new Set<string>();
|
||||
const failedKeys = new Set<string>();
|
||||
// Bumped whenever titles land; chat threads include it in their lit guard()
|
||||
// dependencies so cached row subtrees repaint with the new titles.
|
||||
let titlesVersion = 0;
|
||||
|
||||
export function getToolTitlesVersion(): number {
|
||||
return titlesVersion;
|
||||
}
|
||||
|
||||
// Everything a flush needs is captured at schedule time: split panes
|
||||
// reconfigure the module globals on every render, so flush-time globals can
|
||||
// belong to a different pane than the one that queued the item.
|
||||
type PendingItem = {
|
||||
key: string;
|
||||
name: string;
|
||||
input: string;
|
||||
sessionKey: string;
|
||||
agentId: string | null;
|
||||
client: GatewayBrowserClient;
|
||||
notify: (() => void) | null;
|
||||
};
|
||||
type ToolTitlesResult = { titles?: Record<string, string> };
|
||||
|
||||
let queue = new Map<string, PendingItem>();
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let activeClient: GatewayBrowserClient | null = null;
|
||||
let activeSessionKey: string | null = null;
|
||||
let activeAgentId: string | null = null;
|
||||
let notifyUpdate: (() => void) | null = null;
|
||||
|
||||
/** FNV-1a over name + serialized args; stable across renders of one call. */
|
||||
function digest(name: string, input: string): string {
|
||||
const source = `${name}\u0000${input}`;
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
hash ^= source.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return `t${(hash >>> 0).toString(36)}${source.length.toString(36)}`;
|
||||
}
|
||||
|
||||
function serializeArgs(args: unknown): string | null {
|
||||
if (args === undefined || args === null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof args === "string") {
|
||||
return args.slice(0, MAX_TITLE_INPUT_CHARS);
|
||||
}
|
||||
try {
|
||||
const encoded = JSON.stringify(args);
|
||||
return typeof encoded === "string" ? encoded.slice(0, MAX_TITLE_INPUT_CHARS) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only calls where a purpose summary beats the deterministic label qualify:
|
||||
* shell commands and arg-heavy generic/MCP tools. File reads/edits/writes
|
||||
* already render precise labels.
|
||||
*/
|
||||
export function resolveToolTitleRequest(
|
||||
name: string,
|
||||
args: unknown,
|
||||
): { key: string; input: string } | null {
|
||||
const kind = resolveToolCallKind(name, args);
|
||||
if (kind === "command") {
|
||||
const record =
|
||||
args && typeof args === "object" && !Array.isArray(args)
|
||||
? (args as Record<string, unknown>)
|
||||
: null;
|
||||
const rawCommand = typeof record?.command === "string" ? record.command.trim() : "";
|
||||
const command = unwrapShellWrapperCommand(rawCommand).trim();
|
||||
if (command.length < MIN_COMMAND_CHARS_FOR_TITLE) {
|
||||
return null;
|
||||
}
|
||||
const input = command.slice(0, MAX_TITLE_INPUT_CHARS);
|
||||
return { key: digest("command", input), input };
|
||||
}
|
||||
if (kind !== "generic") {
|
||||
return null;
|
||||
}
|
||||
const input = serializeArgs(args);
|
||||
if (!input || input.length < MIN_GENERIC_INPUT_CHARS_FOR_TITLE) {
|
||||
return null;
|
||||
}
|
||||
return { key: digest(name.trim().toLowerCase(), input), input };
|
||||
}
|
||||
|
||||
export function getToolCallTitle(name: string, args: unknown): string | undefined {
|
||||
const request = resolveToolTitleRequest(name, args);
|
||||
if (!request) {
|
||||
return undefined;
|
||||
}
|
||||
const cached = titlesByKey.get(request.key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
scheduleTitleRequest(name, request);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function configureToolTitleFetcher(params: {
|
||||
client: GatewayBrowserClient | null;
|
||||
sessionKey: string | null;
|
||||
/** Selected agent; required for global-session keys where the gateway would otherwise resolve the default agent. */
|
||||
agentId?: string | null;
|
||||
onTitlesChanged: (() => void) | null;
|
||||
}): void {
|
||||
activeClient = params.client;
|
||||
activeSessionKey = params.sessionKey;
|
||||
activeAgentId = params.agentId ?? null;
|
||||
notifyUpdate = params.onTitlesChanged;
|
||||
}
|
||||
|
||||
function scheduleTitleRequest(name: string, request: { key: string; input: string }): void {
|
||||
if (
|
||||
!activeClient ||
|
||||
!activeSessionKey ||
|
||||
titlesByKey.has(request.key) ||
|
||||
pendingKeys.has(request.key) ||
|
||||
failedKeys.has(request.key) ||
|
||||
queue.has(request.key)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
queue.set(request.key, {
|
||||
key: request.key,
|
||||
name,
|
||||
input: request.input,
|
||||
sessionKey: activeSessionKey,
|
||||
agentId: activeAgentId,
|
||||
client: activeClient,
|
||||
notify: notifyUpdate,
|
||||
});
|
||||
flushTimer ??= setTimeout(() => {
|
||||
flushTimer = null;
|
||||
void flushTitleQueue();
|
||||
}, REQUEST_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function flushTitleQueue(): Promise<void> {
|
||||
// One request per scheduling pane (client + session + agent); other panes'
|
||||
// items stay queued for the follow-up flush.
|
||||
const head = queue.values().next().value;
|
||||
if (!head) {
|
||||
queue = new Map();
|
||||
return;
|
||||
}
|
||||
const batch: PendingItem[] = [];
|
||||
for (const item of queue.values()) {
|
||||
if (
|
||||
item.client === head.client &&
|
||||
item.sessionKey === head.sessionKey &&
|
||||
item.agentId === head.agentId &&
|
||||
batch.length < MAX_ITEMS_PER_REQUEST
|
||||
) {
|
||||
batch.push(item);
|
||||
}
|
||||
}
|
||||
for (const item of batch) {
|
||||
queue.delete(item.key);
|
||||
pendingKeys.add(item.key);
|
||||
}
|
||||
const hasBacklog = queue.size > 0;
|
||||
try {
|
||||
const result = await head.client.request<ToolTitlesResult>("chat.toolTitles", {
|
||||
sessionKey: head.sessionKey,
|
||||
...(head.agentId ? { agentId: head.agentId } : {}),
|
||||
items: batch.map((item) => ({ id: item.key, name: item.name, input: item.input })),
|
||||
});
|
||||
const titles = result?.titles ?? {};
|
||||
let changed = false;
|
||||
for (const item of batch) {
|
||||
const title = typeof titles[item.key] === "string" ? titles[item.key].trim() : "";
|
||||
if (title) {
|
||||
titlesByKey.set(item.key, title);
|
||||
changed = true;
|
||||
} else {
|
||||
failedKeys.add(item.key);
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
titlesVersion += 1;
|
||||
head.notify?.();
|
||||
}
|
||||
} catch {
|
||||
// Gateway without the method, no usable cheap model, transient errors:
|
||||
// titles are decorative, so fail closed and keep deterministic labels.
|
||||
for (const item of batch) {
|
||||
failedKeys.add(item.key);
|
||||
}
|
||||
} finally {
|
||||
for (const item of batch) {
|
||||
pendingKeys.delete(item.key);
|
||||
}
|
||||
if (hasBacklog && !flushTimer) {
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
void flushTitleQueue();
|
||||
}, REQUEST_DEBOUNCE_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resetToolTitlesForTest(): void {
|
||||
titlesByKey.clear();
|
||||
pendingKeys.clear();
|
||||
failedKeys.clear();
|
||||
queue = new Map();
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setToolTitleForTest(key: string, title: string): void {
|
||||
titlesByKey.set(key, title);
|
||||
}
|
||||
@@ -116,6 +116,322 @@
|
||||
color: var(--destructive, var(--danger, #c0392b));
|
||||
}
|
||||
|
||||
/* ── Kind-aware tool rows ── */
|
||||
.chat-tool-row__prompt {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: calc(var(--control-ui-text-sm) - 1px);
|
||||
font-weight: 600;
|
||||
color: color-mix(in srgb, var(--ok) 70%, var(--muted) 30%);
|
||||
}
|
||||
|
||||
.chat-tool-row__cmd {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--mono);
|
||||
font-size: calc(var(--control-ui-text-sm) - 1px);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-tool-row__cmd--secondary {
|
||||
flex: 0 100000 auto;
|
||||
color: color-mix(in srgb, var(--muted) 85%, var(--text) 15%);
|
||||
}
|
||||
|
||||
.chat-tool-row__title {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-tool-row__verb {
|
||||
flex-shrink: 0;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-tool-row__target {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--mono);
|
||||
font-size: calc(var(--control-ui-text-sm) - 1px);
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-tool-row__detail {
|
||||
flex: 0 100000 auto;
|
||||
min-width: 24px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--mono);
|
||||
font-size: calc(var(--control-ui-text-sm) - 1px);
|
||||
color: color-mix(in srgb, var(--muted) 88%, var(--text) 12%);
|
||||
}
|
||||
|
||||
.chat-tool-row--running .chat-tool-msg-summary__icon {
|
||||
color: var(--accent-2, #14b8a6);
|
||||
}
|
||||
|
||||
.chat-tool-row__spinner {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: var(--radius-full, 999px);
|
||||
background: var(--accent-2, #14b8a6);
|
||||
animation: chatToolRowPulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes chatToolRowPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat-tool-row__spinner {
|
||||
animation: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-tool-row__badge {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-full, 999px);
|
||||
background: color-mix(in srgb, var(--destructive, #ef4444) 14%, transparent);
|
||||
color: var(--destructive, #ef4444);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Diffstat chips (+N -M) ── */
|
||||
.chat-diffstat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: calc(var(--control-ui-text-sm) - 1px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-diffstat__add {
|
||||
color: var(--ok, #22c55e);
|
||||
}
|
||||
|
||||
.chat-diffstat__del {
|
||||
color: var(--destructive, #ef4444);
|
||||
}
|
||||
|
||||
/* ── Inline diff ── */
|
||||
.chat-diff {
|
||||
margin-top: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--secondary) 55%, transparent);
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
max-height: min(480px, 55vh);
|
||||
}
|
||||
|
||||
.chat-diff__row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
min-width: max-content;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.chat-diff__row--add {
|
||||
background: color-mix(in srgb, var(--ok, #22c55e) 13%, transparent);
|
||||
}
|
||||
|
||||
.chat-diff__row--add .chat-diff__sign {
|
||||
color: var(--ok, #22c55e);
|
||||
}
|
||||
|
||||
.chat-diff__row--del {
|
||||
background: color-mix(in srgb, var(--destructive, #ef4444) 12%, transparent);
|
||||
}
|
||||
|
||||
.chat-diff__row--del .chat-diff__sign {
|
||||
color: var(--destructive, #ef4444);
|
||||
}
|
||||
|
||||
.chat-diff__row--skip {
|
||||
color: var(--muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chat-diff__gutter {
|
||||
flex-shrink: 0;
|
||||
width: 42px;
|
||||
padding-right: 8px;
|
||||
text-align: right;
|
||||
color: color-mix(in srgb, var(--muted) 75%, transparent);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chat-diff__sign {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: transparent;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chat-diff__text {
|
||||
white-space: pre;
|
||||
tab-size: 4;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-diff__row--del .chat-diff__text {
|
||||
color: color-mix(in srgb, var(--text) 72%, var(--muted) 28%);
|
||||
}
|
||||
|
||||
/* ── Terminal-style command block ── */
|
||||
.chat-tool-term {
|
||||
margin-top: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--secondary) 82%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-tool-term__cmd {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 8px 12px 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-tool-term__cmd code {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.chat-tool-term__prompt {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
color: color-mix(in srgb, var(--ok) 70%, var(--muted) 30%);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chat-tool-term__out {
|
||||
margin: 0;
|
||||
padding: 6px 12px 10px 12px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: color-mix(in srgb, var(--text) 82%, var(--muted) 18%);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
overflow: auto;
|
||||
max-height: min(420px, 55vh);
|
||||
}
|
||||
|
||||
.chat-tool-term__cmd:last-child {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.chat-tool-term--error .chat-tool-term__out {
|
||||
color: color-mix(in srgb, var(--destructive, #ef4444) 70%, var(--text) 30%);
|
||||
}
|
||||
|
||||
/* Command/diff text reuses <code> for semantics only; kill the markdown pill. */
|
||||
.chat-tool-row__cmd,
|
||||
.chat-tool-row__cmd code,
|
||||
.chat-tool-term__cmd code,
|
||||
.chat-tool-term__out code {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ── Command token colors (display-only highlighting) ── */
|
||||
.chat-cmd--name {
|
||||
color: var(--accent-2, #14b8a6);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-cmd--str {
|
||||
color: color-mix(in srgb, var(--ok, #22c55e) 78%, var(--text) 22%);
|
||||
}
|
||||
|
||||
.chat-cmd--num {
|
||||
color: color-mix(in srgb, var(--accent, #ff5c5c) 82%, var(--text) 18%);
|
||||
}
|
||||
|
||||
.chat-cmd--flag {
|
||||
color: color-mix(in srgb, var(--muted) 70%, var(--text) 30%);
|
||||
}
|
||||
|
||||
.chat-cmd--op {
|
||||
color: color-mix(in srgb, var(--accent-2, #14b8a6) 60%, var(--muted) 40%);
|
||||
}
|
||||
|
||||
/* ── Key-value args (generic tools) ── */
|
||||
.chat-tool-kv {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-top: 6px;
|
||||
font-size: var(--control-ui-text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.chat-tool-kv__row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-tool-kv__key {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: calc(var(--control-ui-text-sm) - 1px);
|
||||
color: color-mix(in srgb, var(--muted) 90%, var(--text) 10%);
|
||||
}
|
||||
|
||||
.chat-tool-kv__value {
|
||||
min-width: 0;
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ── Expanded tool row body ── */
|
||||
.chat-tool-msg-body {
|
||||
box-sizing: border-box;
|
||||
@@ -155,6 +471,26 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Cards whose body is a single block (terminal, diff) skip the header row and
|
||||
float the side-panel action over the block instead. */
|
||||
.chat-tool-card--flush {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-tool-card--flush > .chat-tool-card__actions {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 6px;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.chat-tool-card--flush:hover > .chat-tool-card__actions,
|
||||
.chat-tool-card--flush:focus-within > .chat-tool-card__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chat-tool-card__actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -533,14 +869,33 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Left rule groups the rows without wrapping them in another card. */
|
||||
/* Rows live in one bordered card with hairline separators. */
|
||||
.chat-activity-group__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
margin: 2px 0 0 14px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid color-mix(in srgb, var(--border) 85%, transparent);
|
||||
margin: 4px 0 0 2px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
|
||||
border-radius: var(--radius-md, 10px);
|
||||
background: color-mix(in srgb, var(--card, var(--bg)) 55%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-activity-group__body > .chat-bubble {
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 62%, transparent);
|
||||
}
|
||||
|
||||
.chat-activity-group__body > .chat-bubble:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.chat-activity-group__body .chat-tool-msg-summary {
|
||||
padding: 7px 12px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.chat-activity-group__body .chat-tool-msg-body {
|
||||
padding: 0 12px 10px 12px;
|
||||
}
|
||||
|
||||
/* Reading Indicator: bubble chrome and sizing come from .chat-bubble and
|
||||
|
||||
Reference in New Issue
Block a user