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:
+59
-1
@@ -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
@@ -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
@@ -85,7 +85,7 @@ export interface EventFrame {
|
||||
delta?: string;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
args?: string;
|
||||
args?: unknown;
|
||||
partial?: string;
|
||||
isError?: boolean;
|
||||
resultPreview?: string;
|
||||
|
||||
Reference in New Issue
Block a user