refactor: tighten canonical tool-call typing and display mapping
This commit is contained in:
@@ -13,17 +13,10 @@ function loadFixture(name: string): unknown {
|
||||
}
|
||||
|
||||
describe("shared messages stream parsing", () => {
|
||||
it("parses legacy inProgress tool_call snapshots and normalizes status", () => {
|
||||
it("rejects legacy inProgress tool_call snapshots", () => {
|
||||
const fixture = loadFixture("legacy-agent-stream-snapshot-inProgress.json");
|
||||
const parsed = AgentStreamSnapshotMessageSchema.parse(fixture);
|
||||
|
||||
const first = parsed.payload.events[0]?.event;
|
||||
expect(first?.type).toBe("timeline");
|
||||
if (first?.type === "timeline" && first.item.type === "tool_call") {
|
||||
expect(first.item.status).toBe("running");
|
||||
expect(first.item.error).toBeNull();
|
||||
expect(first.item.output).toBeNull();
|
||||
}
|
||||
const parsed = AgentStreamSnapshotMessageSchema.safeParse(fixture);
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("parses representative agent_stream tool_call event", () => {
|
||||
@@ -61,24 +54,16 @@ describe("shared messages stream parsing", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("parses websocket envelope for agent_stream_snapshot with legacy status", () => {
|
||||
it("rejects websocket envelope for agent_stream_snapshot with legacy status", () => {
|
||||
const fixture = loadFixture("legacy-agent-stream-snapshot-inProgress.json") as {
|
||||
type: "agent_stream_snapshot";
|
||||
payload: unknown;
|
||||
};
|
||||
|
||||
const wrapped = WSOutboundMessageSchema.parse({
|
||||
const wrapped = WSOutboundMessageSchema.safeParse({
|
||||
type: "session",
|
||||
message: fixture,
|
||||
});
|
||||
|
||||
if (wrapped.type === "session" && wrapped.message.type === "agent_stream_snapshot") {
|
||||
const first = wrapped.message.payload.events[0]?.event;
|
||||
expect(first?.type).toBe("timeline");
|
||||
if (first?.type === "timeline" && first.item.type === "tool_call") {
|
||||
expect(first.item.status).toBe("running");
|
||||
expect(first.item.error).toBeNull();
|
||||
}
|
||||
}
|
||||
expect(wrapped.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,7 +69,7 @@ describe("shared messages tool_call schema", () => {
|
||||
expect(unknownStatus.success).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes recoverable legacy status/error combinations", () => {
|
||||
it("rejects legacy status/error combinations without normalization", () => {
|
||||
const completedWithError = AgentTimelineItemPayloadSchema.safeParse({
|
||||
...canonicalBase(),
|
||||
status: "completed",
|
||||
@@ -91,18 +91,15 @@ describe("shared messages tool_call schema", () => {
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(completedWithError.success).toBe(true);
|
||||
expect(failedWithoutError.success).toBe(true);
|
||||
expect(missingOutput.success).toBe(true);
|
||||
const legacyStatus = AgentTimelineItemPayloadSchema.safeParse({
|
||||
...canonicalBase(),
|
||||
status: "inProgress",
|
||||
error: null,
|
||||
});
|
||||
|
||||
if (completedWithError.success && completedWithError.data.type === "tool_call") {
|
||||
expect(completedWithError.data.error).toBeNull();
|
||||
}
|
||||
if (failedWithoutError.success && failedWithoutError.data.type === "tool_call") {
|
||||
expect(failedWithoutError.data.error).toEqual({ message: "Tool call failed" });
|
||||
}
|
||||
if (missingOutput.success && missingOutput.data.type === "tool_call") {
|
||||
expect(missingOutput.data.output).toBeNull();
|
||||
}
|
||||
expect(completedWithError.success).toBe(false);
|
||||
expect(failedWithoutError.success).toBe(false);
|
||||
expect(missingOutput.success).toBe(false);
|
||||
expect(legacyStatus.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -229,67 +229,13 @@ const ToolCallCanceledPayloadSchema = ToolCallBasePayloadSchema.extend({
|
||||
error: z.null(),
|
||||
});
|
||||
|
||||
const LEGACY_TOOL_CALL_STATUS_MAP: Record<string, ToolCallTimelineItem["status"]> = {
|
||||
inprogress: "running",
|
||||
in_progress: "running",
|
||||
started: "running",
|
||||
complete: "completed",
|
||||
done: "completed",
|
||||
success: "completed",
|
||||
errored: "failed",
|
||||
error: "failed",
|
||||
cancelled: "canceled",
|
||||
};
|
||||
|
||||
function normalizeLegacyToolCallPayload(value: unknown): unknown {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.type !== "tool_call") {
|
||||
return value;
|
||||
}
|
||||
|
||||
const normalized: Record<string, unknown> = { ...record };
|
||||
const rawStatus = typeof record.status === "string" ? record.status.trim() : "";
|
||||
|
||||
if (rawStatus.length > 0) {
|
||||
const statusKey = rawStatus.toLowerCase().replace(/[\s-]+/g, "_");
|
||||
const mappedStatus = LEGACY_TOOL_CALL_STATUS_MAP[statusKey];
|
||||
if (mappedStatus) {
|
||||
normalized.status = mappedStatus;
|
||||
}
|
||||
}
|
||||
|
||||
if (!("input" in normalized)) {
|
||||
normalized.input = null;
|
||||
}
|
||||
|
||||
if (!("output" in normalized)) {
|
||||
normalized.output = null;
|
||||
}
|
||||
|
||||
if (normalized.status === "failed") {
|
||||
if (normalized.error === undefined || normalized.error === null) {
|
||||
normalized.error = { message: "Tool call failed" };
|
||||
}
|
||||
} else if (normalized.error === undefined || normalized.error !== null) {
|
||||
normalized.error = null;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const ToolCallTimelineItemPayloadSchema: z.ZodType<ToolCallTimelineItem, z.ZodTypeDef, unknown> = z.preprocess(
|
||||
normalizeLegacyToolCallPayload,
|
||||
const ToolCallTimelineItemPayloadSchema: z.ZodType<ToolCallTimelineItem, z.ZodTypeDef, unknown> =
|
||||
z.union([
|
||||
ToolCallRunningPayloadSchema,
|
||||
ToolCallCompletedPayloadSchema,
|
||||
ToolCallFailedPayloadSchema,
|
||||
ToolCallCanceledPayloadSchema,
|
||||
])
|
||||
);
|
||||
]);
|
||||
|
||||
export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem, z.ZodTypeDef, unknown> =
|
||||
z.union([
|
||||
@@ -388,7 +334,7 @@ const AgentPersistenceHandleSchema: z.ZodType<AgentPersistenceHandle | null> =
|
||||
.object({
|
||||
provider: AgentProviderSchema,
|
||||
sessionId: z.string(),
|
||||
nativeHandle: z.any().optional(),
|
||||
nativeHandle: z.string().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
})
|
||||
.nullable();
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildToolCallDisplayModel } from "./tool-call-display.js";
|
||||
|
||||
describe("shared tool-call display mapping", () => {
|
||||
it("builds summary from canonical detail", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "read_file",
|
||||
status: "running",
|
||||
input: { path: "/tmp/repo/src/index.ts" },
|
||||
output: null,
|
||||
error: null,
|
||||
detail: {
|
||||
type: "read",
|
||||
filePath: "/tmp/repo/src/index.ts",
|
||||
},
|
||||
cwd: "/tmp/repo",
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
displayName: "Read",
|
||||
summary: "src/index.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not infer summaries from raw input when detail is missing", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "exec_command",
|
||||
status: "running",
|
||||
input: { command: "npm test" },
|
||||
output: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
displayName: "Exec Command",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps task metadata summary without detail", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "task",
|
||||
status: "running",
|
||||
input: null,
|
||||
output: null,
|
||||
error: null,
|
||||
metadata: {
|
||||
subAgentActivity: "Running tests",
|
||||
},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
displayName: "Task",
|
||||
summary: "Running tests",
|
||||
});
|
||||
});
|
||||
|
||||
it("provides errorText for failed calls", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "shell",
|
||||
status: "failed",
|
||||
input: null,
|
||||
output: null,
|
||||
error: { message: "boom" },
|
||||
});
|
||||
|
||||
expect(display.errorText).toBe('{\n "message": "boom"\n}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { ToolCallTimelineItem } from "../server/agent/agent-sdk-types.js";
|
||||
|
||||
export type ToolCallDisplayInput = Pick<
|
||||
ToolCallTimelineItem,
|
||||
"name" | "status" | "input" | "output" | "error" | "metadata" | "detail"
|
||||
> & {
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
export type ToolCallDisplayModel = {
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
errorText?: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function humanizeToolName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
return name;
|
||||
}
|
||||
if (/[:./]/.test(trimmed) || trimmed.includes("__")) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.replace(/[._-]+/g, " ")
|
||||
.split(" ")
|
||||
.filter((segment) => segment.length > 0)
|
||||
.map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function stripCwdPrefix(filePath: string, cwd?: string): string {
|
||||
if (!cwd || !filePath) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalizedPath = filePath.replace(/\\/g, "/");
|
||||
const prefix = `${normalizedCwd}/`;
|
||||
|
||||
if (normalizedPath.startsWith(prefix)) {
|
||||
return normalizedPath.slice(prefix.length);
|
||||
}
|
||||
if (normalizedPath === normalizedCwd) {
|
||||
return ".";
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function formatErrorText(error: unknown): string | undefined {
|
||||
if (error === null || error === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
if (isRecord(error) && typeof error.content === "string") {
|
||||
return error.content;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error, null, 2);
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCallDisplayModel {
|
||||
const lowerName = input.name.trim().toLowerCase();
|
||||
|
||||
let displayName = humanizeToolName(input.name);
|
||||
let summary: string | undefined;
|
||||
|
||||
if (input.detail) {
|
||||
switch (input.detail.type) {
|
||||
case "shell":
|
||||
displayName = "Shell";
|
||||
summary = input.detail.command;
|
||||
break;
|
||||
case "read":
|
||||
displayName = "Read";
|
||||
summary = stripCwdPrefix(input.detail.filePath, input.cwd);
|
||||
break;
|
||||
case "edit":
|
||||
displayName = "Edit";
|
||||
summary = stripCwdPrefix(input.detail.filePath, input.cwd);
|
||||
break;
|
||||
case "write":
|
||||
displayName = "Write";
|
||||
summary = stripCwdPrefix(input.detail.filePath, input.cwd);
|
||||
break;
|
||||
case "search":
|
||||
displayName = "Search";
|
||||
summary = input.detail.query;
|
||||
break;
|
||||
}
|
||||
} else if (lowerName === "task") {
|
||||
displayName = "Task";
|
||||
summary = isRecord(input.metadata) ? readString(input.metadata.subAgentActivity) : undefined;
|
||||
} else if (lowerName === "thinking") {
|
||||
displayName = "Thinking";
|
||||
}
|
||||
|
||||
const errorText = input.status === "failed" ? formatErrorText(input.error) : undefined;
|
||||
|
||||
return {
|
||||
displayName,
|
||||
...(summary ? { summary } : {}),
|
||||
...(errorText ? { errorText } : {}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user