Skip to content

Commit 5aea34a

Browse files
authored
fix(webui): keep tool activity paired without model calls (#103821)
* fix(webui): make tool activity rendering deterministic * fix(protocol): remove stale tool-title models * fix(i18n): translate tool activity labels * fix(i18n): translate tool activity labels
1 parent c8ebfd8 commit 5aea34a

102 files changed

Lines changed: 4509 additions & 1484 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9429,42 +9429,6 @@ public struct ChatMessageGetResult: Codable, Sendable {
94299429
}
94309430
}
94319431

9432-
public struct ChatToolTitlesParams: Codable, Sendable {
9433-
public let sessionkey: String
9434-
public let agentid: String?
9435-
public let items: [[String: AnyCodable]]
9436-
9437-
public init(
9438-
sessionkey: String,
9439-
agentid: String? = nil,
9440-
items: [[String: AnyCodable]])
9441-
{
9442-
self.sessionkey = sessionkey
9443-
self.agentid = agentid
9444-
self.items = items
9445-
}
9446-
9447-
private enum CodingKeys: String, CodingKey {
9448-
case sessionkey = "sessionKey"
9449-
case agentid = "agentId"
9450-
case items
9451-
}
9452-
}
9453-
9454-
public struct ChatToolTitlesResult: Codable, Sendable {
9455-
public let titles: [String: AnyCodable]
9456-
9457-
public init(
9458-
titles: [String: AnyCodable])
9459-
{
9460-
self.titles = titles
9461-
}
9462-
9463-
private enum CodingKeys: String, CodingKey {
9464-
case titles
9465-
}
9466-
}
9467-
94689432
public struct ChatSendParams: Codable, Sendable {
94699433
public let sessionkey: String
94709434
public let agentid: String?

docs/gateway/protocol.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,6 @@ methods. Treat this as feature discovery, not a full enumeration of
473473
- `sessions.get` returns the full stored session row.
474474
- 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.
475475
- `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.
476-
- `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.
477476
- `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.
478477

479478
</Accordion>

docs/web/control-ui.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,7 @@ A **Search** field at the top of the sidebar opens the command palette (⌘K). T
170170
- 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.
171171
- 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.
172172
- 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`.
173-
- 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.
174-
- 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.
173+
- 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; supported edit and write calls show bounded inline diffs, line numbers when available, and `+added -removed` stats; and consecutive calls collapse into a summary such as "Ran 13 commands, read 6 files, edited 9 files". While a run is live, the newest running call names the group header. Expand a row to inspect its remaining arguments and raw output.
175174
- Start or dismiss ephemeral model-suggested follow-up tasks; accepted suggestions open a fresh managed-worktree session with the proposed prompt.
176175
- Activity tab with browser-local, redaction-first summaries of live tool activity from existing `session.tool` / tool event delivery.
177176

extensions/codex/src/app-server/event-projector.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2645,6 +2645,100 @@ describe("CodexAppServerEventProjector", () => {
26452645
expect(toolResultContentItem.content).toBe("ok");
26462646
});
26472647

2648+
it("preserves structured file-change diffs in mirrored transcript calls", async () => {
2649+
const projector = await createProjector();
2650+
const changes = [
2651+
{
2652+
path: "src/updated.ts",
2653+
kind: { type: "update", move_path: null },
2654+
diff: [
2655+
"--- a/src/updated.ts",
2656+
"+++ b/src/updated.ts",
2657+
"@@ -1 +1,2 @@",
2658+
"-old",
2659+
"+new",
2660+
"+another",
2661+
"",
2662+
].join("\n"),
2663+
},
2664+
{
2665+
path: "src/created.ts",
2666+
kind: { type: "add" },
2667+
diff: "first\nsecond\n",
2668+
},
2669+
{
2670+
path: "src/deleted.ts",
2671+
kind: { type: "delete" },
2672+
diff: "removed\n",
2673+
},
2674+
];
2675+
2676+
await projector.handleNotification(
2677+
forCurrentTurn("item/completed", {
2678+
item: {
2679+
type: "fileChange",
2680+
id: "patch-structured",
2681+
changes,
2682+
status: "completed",
2683+
},
2684+
}),
2685+
);
2686+
2687+
const result = projector.buildResult(buildEmptyToolTelemetry());
2688+
const assistant = requireRecord(result.messagesSnapshot[1], "assistant tool call message");
2689+
const assistantContent = requireArray(assistant.content, "assistant content");
2690+
const toolCall = requireRecord(assistantContent[0], "file-change tool call");
2691+
const expectedChanges = [
2692+
{ ...changes[0], stat: { added: 2, removed: 1 } },
2693+
{ ...changes[1], stat: { added: 2, removed: 0 } },
2694+
{ ...changes[2], stat: { added: 0, removed: 1 } },
2695+
];
2696+
expect(toolCall.name).toBe("apply_patch");
2697+
expect(toolCall.arguments).toEqual({ changes: expectedChanges });
2698+
expect(toolCall.input).toEqual({ changes: expectedChanges });
2699+
});
2700+
2701+
it("bounds mirrored file-change diffs without losing full stats", async () => {
2702+
const diff = [
2703+
"--- a/src/large.ts",
2704+
"+++ b/src/large.ts",
2705+
"@@ -1 +1,200 @@",
2706+
"-old",
2707+
...Array.from({ length: 200 }, (_, index) => `+${index}-${"x".repeat(96)}`),
2708+
"",
2709+
].join("\n");
2710+
const projector = await createProjector();
2711+
2712+
await projector.handleNotification(
2713+
forCurrentTurn("item/completed", {
2714+
item: {
2715+
type: "fileChange",
2716+
id: "patch-large",
2717+
changes: [{ path: "src/large.ts", kind: { type: "update" }, diff }],
2718+
status: "completed",
2719+
},
2720+
}),
2721+
);
2722+
2723+
const result = projector.buildResult(buildEmptyToolTelemetry());
2724+
const assistant = requireRecord(result.messagesSnapshot[1], "assistant tool call message");
2725+
const assistantContent = requireArray(assistant.content, "assistant content");
2726+
const toolCall = requireRecord(assistantContent[0], "file-change tool call");
2727+
const args = requireRecord(toolCall.arguments, "file-change arguments");
2728+
const projectedChanges = requireArray(args.changes, "projected file changes");
2729+
const projectedChange = requireRecord(projectedChanges[0], "projected file change");
2730+
const projectedDiff = projectedChange.diff;
2731+
expect(typeof projectedDiff).toBe("string");
2732+
if (typeof projectedDiff !== "string") {
2733+
throw new Error("Expected bounded file-change diff");
2734+
}
2735+
expect(projectedDiff.length).toBeLessThanOrEqual(12_000);
2736+
expect(projectedDiff.endsWith("\n")).toBe(true);
2737+
expect(diff.startsWith(projectedDiff)).toBe(true);
2738+
expect(projectedChange.diffTruncated).toBe(true);
2739+
expect(projectedChange.stat).toEqual({ added: 200, removed: 1 });
2740+
});
2741+
26482742
it.each([
26492743
["cancelled", "cancelled"],
26502744
[Object.assign(new Error("turn timed out"), { name: "TimeoutError" }), "timed_out"],

extensions/codex/src/app-server/event-projector.ts

Lines changed: 111 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3018,7 +3018,7 @@ function itemToolArgs(item: CodexThreadItem): Record<string, unknown> | undefine
30183018
}
30193019
if (item.type === "fileChange") {
30203020
return sanitizeCodexAgentEventRecord({
3021-
changes: itemFileChanges(item),
3021+
changes: itemFileChangesForTranscript(item),
30223022
});
30233023
}
30243024
if (item.type === "webSearch") {
@@ -3105,10 +3105,116 @@ function webSearchToolResult(item: CodexThreadItem): Record<string, unknown> {
31053105
});
31063106
}
31073107

3108-
function itemFileChanges(item: CodexThreadItem): Array<{ path: string; kind: string }> {
3109-
return Array.isArray(item.changes)
3110-
? item.changes.map((change) => ({ path: change.path, kind: change.kind }))
3111-
: [];
3108+
type CodexFileChangeSummary = {
3109+
path: string;
3110+
kind: unknown;
3111+
};
3112+
3113+
type CodexTranscriptFileChange = CodexFileChangeSummary & {
3114+
diff?: string;
3115+
diffTruncated?: true;
3116+
stat?: { added: number; removed: number };
3117+
};
3118+
3119+
function itemFileChangeRecords(item: CodexThreadItem): JsonObject[] {
3120+
const changes = (item as Record<string, unknown>).changes;
3121+
return Array.isArray(changes) ? changes.filter(isJsonObject) : [];
3122+
}
3123+
3124+
function itemFileChanges(item: CodexThreadItem): CodexFileChangeSummary[] {
3125+
return itemFileChangeRecords(item).flatMap((change) => {
3126+
const path = normalizeNonEmptyString(change.path);
3127+
if (!path || change.kind === undefined) {
3128+
return [];
3129+
}
3130+
return [{ path, kind: change.kind }];
3131+
});
3132+
}
3133+
3134+
function fileChangeKindType(kind: unknown): string | undefined {
3135+
if (typeof kind === "string") {
3136+
return kind;
3137+
}
3138+
return isJsonObject(kind) ? normalizeNonEmptyString(kind.type) : undefined;
3139+
}
3140+
3141+
function countFileContentLines(content: string): number {
3142+
if (!content) {
3143+
return 0;
3144+
}
3145+
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
3146+
if (lines.length > 1 && lines.at(-1) === "") {
3147+
lines.pop();
3148+
}
3149+
return lines.length;
3150+
}
3151+
3152+
function fileChangeDiffStat(diff: string, kind: unknown): { added: number; removed: number } {
3153+
const kindType = fileChangeKindType(kind);
3154+
if (kindType === "add") {
3155+
return { added: countFileContentLines(diff), removed: 0 };
3156+
}
3157+
if (kindType === "delete") {
3158+
return { added: 0, removed: countFileContentLines(diff) };
3159+
}
3160+
let added = 0;
3161+
let removed = 0;
3162+
let inHunk = false;
3163+
for (const line of diff.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")) {
3164+
if (line.startsWith("@@")) {
3165+
inHunk = true;
3166+
continue;
3167+
}
3168+
if (!inHunk) {
3169+
continue;
3170+
}
3171+
if (line.startsWith("+")) {
3172+
added += 1;
3173+
} else if (line.startsWith("-")) {
3174+
removed += 1;
3175+
}
3176+
}
3177+
return { added, removed };
3178+
}
3179+
3180+
function truncateFileChangeDiffAtLineBoundary(
3181+
diff: string,
3182+
maxChars: number,
3183+
): { diff?: string; diffTruncated?: true } {
3184+
if (diff.length <= maxChars) {
3185+
return { diff };
3186+
}
3187+
if (maxChars <= 0) {
3188+
return { diffTruncated: true };
3189+
}
3190+
const boundary = diff.lastIndexOf("\n", maxChars - 1);
3191+
return boundary >= 0
3192+
? { diff: diff.slice(0, boundary + 1), diffTruncated: true }
3193+
: { diffTruncated: true };
3194+
}
3195+
3196+
function itemFileChangesForTranscript(item: CodexThreadItem): CodexTranscriptFileChange[] {
3197+
let remainingDiffChars = TOOL_TRANSCRIPT_OUTPUT_MAX_CHARS;
3198+
return itemFileChangeRecords(item).flatMap((change) => {
3199+
const path = normalizeNonEmptyString(change.path);
3200+
if (!path || change.kind === undefined) {
3201+
return [];
3202+
}
3203+
const result: CodexTranscriptFileChange = { path, kind: change.kind };
3204+
if (typeof change.diff !== "string") {
3205+
return [result];
3206+
}
3207+
result.stat = fileChangeDiffStat(change.diff, change.kind);
3208+
const bounded = truncateFileChangeDiffAtLineBoundary(change.diff, remainingDiffChars);
3209+
if (bounded.diff !== undefined) {
3210+
result.diff = bounded.diff;
3211+
remainingDiffChars -= bounded.diff.length;
3212+
}
3213+
if (bounded.diffTruncated) {
3214+
result.diffTruncated = true;
3215+
}
3216+
return [result];
3217+
});
31123218
}
31133219

31143220
function itemToolError(

packages/gateway-protocol/src/index.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,6 @@ import {
165165
type ChatInjectParams,
166166
ChatInjectParamsSchema,
167167
ChatSendParamsSchema,
168-
type ChatToolTitlesParams,
169-
type ChatToolTitlesResult,
170-
ChatToolTitlesParamsSchema,
171-
ChatToolTitlesResultSchema,
172168
type ConfigApplyParams,
173169
ConfigApplyParamsSchema,
174170
type ConfigGetParams,
@@ -1192,9 +1188,6 @@ export const validateTerminalEvent = lazyCompile<TerminalEvent>(TerminalEventSch
11921188
export const validateChatHistoryParams = lazyCompile(ChatHistoryParamsSchema);
11931189
export const validateChatMetadataParams = lazyCompile<ChatMetadataParams>(ChatMetadataParamsSchema);
11941190
export const validateChatMessageGetParams = lazyCompile(ChatMessageGetParamsSchema);
1195-
export const validateChatToolTitlesParams = lazyCompile<ChatToolTitlesParams>(
1196-
ChatToolTitlesParamsSchema,
1197-
);
11981191
export const validateChatSendParams = lazyCompile(ChatSendParamsSchema);
11991192
export const validateChatAbortParams = lazyCompile<ChatAbortParams>(ChatAbortParamsSchema);
12001193
export const validateChatInjectParams = lazyCompile<ChatInjectParams>(ChatInjectParamsSchema);
@@ -1556,8 +1549,6 @@ export {
15561549
ChatMetadataParamsSchema,
15571550
ChatSendParamsSchema,
15581551
ChatInjectParamsSchema,
1559-
ChatToolTitlesParamsSchema,
1560-
ChatToolTitlesResultSchema,
15611552
UpdateRunParamsSchema,
15621553
TickEventSchema,
15631554
ShutdownEventSchema,
@@ -1704,8 +1695,6 @@ export type {
17041695
AgentsListParams,
17051696
AgentsListResult,
17061697
ChatMetadataParams,
1707-
ChatToolTitlesParams,
1708-
ChatToolTitlesResult,
17091698
CommandsListParams,
17101699
CommandsListResult,
17111700
CommandEntry,

packages/gateway-protocol/src/schema/logs-chat.ts

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -46,36 +46,6 @@ export const ChatMetadataParamsSchema = Type.Object(
4646
{ additionalProperties: false },
4747
);
4848

49-
/** Batched purpose-title request for tool calls rendered in the Control UI. */
50-
export const ChatToolTitlesParamsSchema = Type.Object(
51-
{
52-
sessionKey: NonEmptyString,
53-
agentId: Type.Optional(NonEmptyString),
54-
items: Type.Array(
55-
Type.Object(
56-
{
57-
id: NonEmptyString,
58-
name: Type.String({ minLength: 1, maxLength: 200 }),
59-
input: Type.String({ minLength: 1, maxLength: 4_000 }),
60-
},
61-
{ additionalProperties: false },
62-
),
63-
{ minItems: 1, maxItems: 24 },
64-
),
65-
},
66-
{ additionalProperties: false },
67-
);
68-
69-
/** Titles keyed by the caller-provided item id; missing ids mean no title. */
70-
export const ChatToolTitlesResultSchema = Type.Object(
71-
{
72-
titles: Type.Record(Type.String(), Type.String()),
73-
},
74-
{ additionalProperties: false },
75-
);
76-
/** Typed result shape for tool-title consumers. */
77-
export type ChatToolTitlesResult = Static<typeof ChatToolTitlesResultSchema>;
78-
7949
/** Fetches one stored chat message without forcing history callers to request huge payloads. */
8050
export const ChatMessageGetParamsSchema = Type.Object(
8151
{

packages/gateway-protocol/src/schema/protocol-schemas.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,8 +234,6 @@ import {
234234
ChatMessageGetResultSchema,
235235
ChatInjectParamsSchema,
236236
ChatSendParamsSchema,
237-
ChatToolTitlesParamsSchema,
238-
ChatToolTitlesResultSchema,
239237
LogsTailParamsSchema,
240238
LogsTailResultSchema,
241239
} from "./logs-chat.js";
@@ -774,8 +772,6 @@ export const ProtocolSchemas = {
774772
ChatMetadataParams: ChatMetadataParamsSchema,
775773
ChatMessageGetParams: ChatMessageGetParamsSchema,
776774
ChatMessageGetResult: ChatMessageGetResultSchema,
777-
ChatToolTitlesParams: ChatToolTitlesParamsSchema,
778-
ChatToolTitlesResult: ChatToolTitlesResultSchema,
779775
ChatSendParams: ChatSendParamsSchema,
780776
ChatAbortParams: ChatAbortParamsSchema,
781777
ChatInjectParams: ChatInjectParamsSchema,

packages/gateway-protocol/src/schema/types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,6 @@ export type ModelChoice = SchemaType<"ModelChoice">;
256256
export type ModelsListParams = SchemaType<"ModelsListParams">;
257257
export type ModelsListResult = SchemaType<"ModelsListResult">;
258258
export type ChatMetadataParams = SchemaType<"ChatMetadataParams">;
259-
export type ChatToolTitlesParams = SchemaType<"ChatToolTitlesParams">;
260259
export type CommandEntry = SchemaType<"CommandEntry">;
261260
export type CommandsListParams = SchemaType<"CommandsListParams">;
262261
export type CommandsListResult = SchemaType<"CommandsListResult">;

0 commit comments

Comments
 (0)