Unify streamed tool message normalization

This commit is contained in:
Federico Jaramillo Martinez
2026-05-07 22:33:16 +02:00
parent 940765acda
commit f93abb7564
5 changed files with 40 additions and 19 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ export function appendText(messages: ChatLine[], role: ChatLine["role"], text: s
return [...messages, textMessage(role, text)]; return [...messages, textMessage(role, text)];
} }
function normalizeMessage(message: unknown): ChatLine[] { export function normalizeMessage(message: unknown): ChatLine[] {
if (getString(message, "role") === "bashExecution") return [normalizeBashExecution(message)]; if (getString(message, "role") === "bashExecution") return [normalizeBashExecution(message)];
const role = normalizeRole(getString(message, "role")); const role = normalizeRole(getString(message, "role"));
const parts = normalizeContent(getProperty(message, "content"), message); const parts = normalizeContent(getProperty(message, "content"), message);
+11 -7
View File
@@ -1,12 +1,12 @@
import { appendText, textMessage } from "./chatMessages"; import { appendText, normalizeMessage, textMessage } from "./chatMessages";
import type { ChatLine, ChatPart } from "./components/shared"; import type { ChatLine } from "./components/shared";
import { appendShellChunk, finalizeShellMessage, shellStartMessage } from "./shellMessages"; import { appendShellChunk, finalizeShellMessage, shellStartMessage } from "./shellMessages";
import type { SessionUiEvent } from "./sessionSocket"; import type { SessionUiEvent } from "./sessionSocket";
export function applyTranscriptEvent(messages: ChatLine[], event: SessionUiEvent): ChatLine[] | undefined { export function applyTranscriptEvent(messages: ChatLine[], event: SessionUiEvent): ChatLine[] | undefined {
if (event.type === "assistant.delta") return appendText(messages, "assistant", event.text); if (event.type === "assistant.delta") return appendText(messages, "assistant", event.text);
if (event.type === "tool.start") return appendPart(messages, "assistant", { type: "toolCall", toolName: event.toolName, summary: event.summary }); if (event.type === "tool.start") return appendNormalized(messages, { role: "assistant", content: [{ type: "toolCall", name: event.toolName, arguments: event.args }] });
if (event.type === "tool.end") return [...messages, { role: "tool", parts: [{ type: "toolResult", toolName: event.toolName, text: event.text, isError: event.isError }] }]; if (event.type === "tool.end") return appendNormalized(messages, { role: "toolResult", toolName: event.toolName, content: event.content ?? [{ type: "text", text: event.text }], isError: event.isError });
if (event.type === "shell.start") return [...messages, shellStartMessage(event.command, event.excludeFromContext)]; if (event.type === "shell.start") return [...messages, shellStartMessage(event.command, event.excludeFromContext)];
if (event.type === "shell.chunk") return appendShellChunk(messages, event.chunk); if (event.type === "shell.chunk") return appendShellChunk(messages, event.chunk);
if (event.type === "shell.end") return finalizeShellMessage(messages, event); if (event.type === "shell.end") return finalizeShellMessage(messages, event);
@@ -15,8 +15,12 @@ export function applyTranscriptEvent(messages: ChatLine[], event: SessionUiEvent
return undefined; return undefined;
} }
function appendPart(messages: ChatLine[], role: ChatLine["role"], part: ChatPart): ChatLine[] { function appendNormalized(messages: ChatLine[], rawMessage: unknown): ChatLine[] {
return normalizeMessage(rawMessage).reduce(appendLine, messages);
}
function appendLine(messages: ChatLine[], line: ChatLine): ChatLine[] {
const last = messages.at(-1); const last = messages.at(-1);
if (last?.role === role) return [...messages.slice(0, -1), { ...last, parts: [...last.parts, part] }]; if (last?.role === line.role) return [...messages.slice(0, -1), { ...last, parts: [...last.parts, ...line.parts] }];
return [...messages, { role, parts: [part] }]; return [...messages, line];
} }
+8 -7
View File
@@ -97,7 +97,7 @@ export class PromptEditor extends LitElement {
.map((command) => ({ .map((command) => ({
kind: "command", kind: "command",
replaceFrom: trigger.from, replaceFrom: trigger.from,
replaceTo: this.draft.length, replaceTo: trigger.to,
insertText: `/${command.name}`, insertText: `/${command.name}`,
detail: command.source, detail: command.source,
...(command.description === undefined ? {} : { description: command.description }), ...(command.description === undefined ? {} : { description: command.description }),
@@ -107,18 +107,19 @@ export class PromptEditor extends LitElement {
if (version !== this.requestVersion) return; if (version !== this.requestVersion) return;
this.completions = files this.completions = files
.slice(0, 12) .slice(0, 12)
.map((file) => ({ kind: "file", replaceFrom: trigger.from, replaceTo: this.draft.length, insertText: `@${file.path}`, detail: file.kind })); .map((file) => ({ kind: "file", replaceFrom: trigger.from, replaceTo: trigger.to, insertText: `@${file.path}`, detail: file.kind }));
} }
} }
private currentTrigger(): { kind: "command" | "file"; query: string; from: number; fileKind?: FileSuggestion["kind"] } | undefined { private currentTrigger(): { kind: "command" | "file"; query: string; from: number; to: number; fileKind?: FileSuggestion["kind"] } | undefined {
const beforeCursor = this.draft; const cursor = this.textarea?.selectionStart ?? this.draft.length;
if (beforeCursor.endsWith("@ ")) return { kind: "file", query: "", from: beforeCursor.length - 2, fileKind: "untracked" }; const beforeCursor = this.draft.slice(0, cursor);
if (beforeCursor.endsWith("@ ")) return { kind: "file", query: "", from: beforeCursor.length - 2, to: cursor, fileKind: "untracked" };
const tokenStart = Math.max(beforeCursor.lastIndexOf(" "), beforeCursor.lastIndexOf("\n")) + 1; const tokenStart = Math.max(beforeCursor.lastIndexOf(" "), beforeCursor.lastIndexOf("\n")) + 1;
const token = beforeCursor.slice(tokenStart); const token = beforeCursor.slice(tokenStart);
if (token.startsWith("/")) return { kind: "command", query: token.slice(1), from: tokenStart }; if (token.startsWith("/") && tokenStart === 0) return { kind: "command", query: token.slice(1), from: tokenStart, to: cursor };
if (token.startsWith("@")) return { kind: "file", query: token.slice(1), from: tokenStart }; if (token.startsWith("@")) return { kind: "file", query: token.slice(1), from: tokenStart, to: cursor };
return undefined; return undefined;
} }
+2 -2
View File
@@ -2,8 +2,8 @@ import { globalSessionEvents, sessionEvents, type SessionActivity, type SessionS
export type SessionUiEvent = export type SessionUiEvent =
| { type: "assistant.delta"; text: string } | { type: "assistant.delta"; text: string }
| { type: "tool.start"; toolName: string; summary: string } | { type: "tool.start"; toolName: string; summary: string; args?: unknown }
| { type: "tool.end"; toolName: string; text: string; isError: boolean } | { type: "tool.end"; toolName: string; text: string; isError: boolean; content?: unknown }
| { type: "shell.start"; command: string; excludeFromContext?: boolean } | { type: "shell.start"; command: string; excludeFromContext?: boolean }
| { type: "shell.chunk"; chunk: string } | { type: "shell.chunk"; chunk: string }
| { type: "shell.end"; output?: string; exitCode?: number | null; cancelled?: boolean; truncated?: boolean; fullOutputPath?: string; isError?: boolean } | { type: "shell.end"; output?: string; exitCode?: number | null; cancelled?: boolean; truncated?: boolean; fullOutputPath?: string; isError?: boolean }
+18 -2
View File
@@ -355,10 +355,12 @@ function toClientEvent(event: unknown): unknown {
return { type: "assistant.delta", text: getString(assistantMessageEvent, "delta") ?? "" }; return { type: "assistant.delta", text: getString(assistantMessageEvent, "delta") ?? "" };
} }
if (eventType === "tool_execution_start") { if (eventType === "tool_execution_start") {
return { type: "tool.start", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", summary: summarizeToolArgs(getProperty(event, "args")) }; const args = getProperty(event, "args");
return { type: "tool.start", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", summary: summarizeToolArgs(args), args };
} }
if (eventType === "tool_execution_end") { if (eventType === "tool_execution_end") {
return { type: "tool.end", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", text: stringifyToolResult(getProperty(event, "result")), isError: getBoolean(event, "isError") === true }; const result = getProperty(event, "result");
return { type: "tool.end", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", text: stringifyToolResult(result), content: toolResultContent(result), isError: getBoolean(event, "isError") === true };
} }
if (eventType === "agent_start") return { type: "agent.start" }; if (eventType === "agent_start") return { type: "agent.start" };
if (eventType === "agent_end") return { type: "agent.end" }; if (eventType === "agent_end") return { type: "agent.end" };
@@ -387,12 +389,26 @@ function shortToolValue(value: unknown): string {
return ""; return "";
} }
function toolResultContent(result: unknown): unknown {
if (isRecord(result)) {
const content = getProperty(result, "content");
if (content !== undefined) return content;
const text = getString(result, "text") ?? getString(result, "output");
if (text !== undefined) return [{ type: "text", text }];
}
if (typeof result === "string") return [{ type: "text", text: result }];
return result;
}
function stringifyToolResult(result: unknown): string { function stringifyToolResult(result: unknown): string {
if (typeof result === "string") return result; if (typeof result === "string") return result;
if (Array.isArray(result)) return result.map(stringifyToolResult).filter((text) => text !== "").join("\n"); if (Array.isArray(result)) return result.map(stringifyToolResult).filter((text) => text !== "").join("\n");
if (isRecord(result)) { if (isRecord(result)) {
if (getString(result, "type") === "image") return "[image]";
const text = getString(result, "text") ?? getString(result, "content") ?? getString(result, "output"); const text = getString(result, "text") ?? getString(result, "content") ?? getString(result, "output");
if (text !== undefined) return text; if (text !== undefined) return text;
const content = getProperty(result, "content");
if (Array.isArray(content)) return stringifyToolResult(content);
return JSON.stringify(result, null, 2); return JSON.stringify(result, null, 2);
} }
return stringifyPrimitive(result); return stringifyPrimitive(result);