fix(web): object tool args crashed React #31 — normalize at derive boundary (argsText); contract scenario replays real-plugin frame shapes end-to-end (73/73)

This commit is contained in:
Raphael Westphal
2026-08-18 16:23:44 +02:00
parent 72c593da18
commit 7f0672f5d8
7 changed files with 239 additions and 13 deletions
+59 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { EventFrame, Message } from "./protocol";
import {
argsText,
deriveChat,
deriveTasks,
lastPersistedSeq,
@@ -29,7 +30,7 @@ function msg(m: Partial<Message>): Message {
...m,
};
}
function toolStart(id: string, name: string, args?: string): EventFrame {
function toolStart(id: string, name: string, args?: unknown): EventFrame {
return ev({
type: "tool_execution_start",
toolCallId: id,
@@ -48,6 +49,63 @@ function toolEnd(id: string, isError?: boolean, preview?: string): EventFrame {
// ---------- mergeEvents / lastPersistedSeq ----------
// Regression: the real plugin mirrors pi tool args as raw JSON objects
// (EventFrame.args typed `unknown`). Object args must never reach a React
// render as-is (React #31 crash) — deriveChat must normalize to text.
describe("object tool args from the real plugin", () => {
it("argsText normalizes every shape the plugin emits", () => {
expect(argsText({ path: "src/main.ts" })).toBe('{\n "path": "src/main.ts"\n}');
expect(argsText('"plain"')).toBe('"plain"');
expect(argsText(42)).toBe("42");
expect(argsText(null)).toBe("");
expect(argsText(undefined)).toBe("");
const circular: Record<string, unknown> = {};
circular.self = circular;
expect(argsText(circular)).toBe("[object Object]");
});
it("deriveChat renders tool cards with object args without crashing", () => {
const d = deriveChat([
toolStart("c-obj", "read", { path: "a.txt", offset: 1 }),
toolEnd("c-obj", false, "file content"),
]);
const tool = d.tools.get("c-obj");
expect(tool).toBeDefined();
expect(tool?.args).toBeTypeOf("string");
expect(tool?.args).toContain("a.txt");
});
it("deriveChat tool-call sequence with object args yields string args only", () => {
const d = deriveChat([
ev({
type: "message_end",
message: msg({
role: "assistant",
toolCalls: [{ id: "c1", name: "bash", argsJson: '{"command":"ls"}' }],
}),
}),
toolStart("c1", "bash", { command: "ls" }),
toolEnd("c1"),
]);
for (const m of d.messages) {
for (const c of m.toolCalls) expect(c.argsJson).toBeTypeOf("string");
}
for (const t of d.tools.values()) expect(t.args).toBeTypeOf("string");
});
it("deriveTasks handles object args for todo snapshots and subagents", () => {
const d = deriveTasks([
toolStart("t1", "todo", {
action: "create",
subject: "s",
}),
toolStart("t2", "subagent", { agent: "scout", task: "x" }),
]);
expect(d.subagents.length).toBe(1);
expect(d.subagents[0]?.name).toBe("scout");
});
});
describe("mergeEvents", () => {
it("merges and sorts by seq, dedupes by seq", () => {
const a = [ev({ type: "hello", seq: 5 }), ev({ type: "hello", seq: 1 })];
+19 -5
View File
@@ -44,6 +44,19 @@ export interface ChatDerivation {
busy: boolean;
}
// The plugin mirrors pi extension events verbatim: tool args arrive as a raw
// JSON object (or string in older frames). Normalizing at this boundary keeps
// the render layer string-only; regression: object args crashed React (#31).
export function argsText(args: unknown): string {
if (typeof args === "string") return args;
if (args === null || args === undefined) return "";
try {
return JSON.stringify(args, null, 2) ?? "";
} catch {
return String(args);
}
}
export function deriveChat(events: EventFrame[]): ChatDerivation {
const messages: ChatMessage[] = [];
const tools = new Map<string, ToolState>();
@@ -57,7 +70,7 @@ export function deriveChat(events: EventFrame[]): ChatDerivation {
tools.set(e.toolCallId, {
id: e.toolCallId,
name: e.toolName ?? "tool",
args: e.args ?? "",
args: argsText(e.args),
running: true,
isError: false,
preview: "",
@@ -148,10 +161,11 @@ export interface TaskDerivation {
const TODO_TOOL = "todo";
const SUBAGENT_TOOL = "subagent";
function parseJson(text: string | undefined | null): unknown {
if (typeof text !== "string" || text.length === 0) return undefined;
function parseJson(raw: unknown): unknown {
if (typeof raw === "object" && raw !== null) return raw; // already-decoded args
if (typeof raw !== "string" || raw.length === 0) return undefined;
try {
return JSON.parse(text) as unknown;
return JSON.parse(raw) as unknown;
} catch {
return undefined;
}
@@ -234,7 +248,7 @@ export function deriveTasks(events: EventFrame[]): TaskDerivation {
runningTools.push({
id: e.toolCallId,
name,
args: e.args ?? "",
args: argsText(e.args),
running: true,
isError: false,
preview: "",
+1 -1
View File
@@ -85,7 +85,7 @@ export interface EventFrame {
delta?: string;
toolCallId?: string;
toolName?: string;
args?: string;
args?: unknown;
partial?: string;
isError?: boolean;
resultPreview?: string;