refactor: tighten canonical tool-call typing and display mapping
This commit is contained in:
@@ -46,13 +46,6 @@ import {
|
||||
Check,
|
||||
CheckSquare,
|
||||
X,
|
||||
Wrench,
|
||||
Pencil,
|
||||
Eye,
|
||||
SquareTerminal,
|
||||
Search,
|
||||
Brain,
|
||||
Bot,
|
||||
Copy,
|
||||
TriangleAlertIcon,
|
||||
Scissors,
|
||||
@@ -72,9 +65,8 @@ import type { TodoEntry } from "@/types/stream";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import {
|
||||
buildToolCallDisplayModel,
|
||||
formatToolCallError,
|
||||
type ToolCallKind,
|
||||
} from "@/utils/tool-call-display";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
|
||||
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
|
||||
export type { InlinePathTarget } from "@/utils/inline-path";
|
||||
@@ -110,29 +102,6 @@ function useDisableOuterSpacing(disableOuterSpacing: boolean | undefined) {
|
||||
return disableOuterSpacing ?? contextValue;
|
||||
}
|
||||
|
||||
function hexToRgba(hexColor: string, alpha: number) {
|
||||
const clampedAlpha = Math.max(0, Math.min(1, alpha));
|
||||
const hex = hexColor.replace("#", "");
|
||||
const normalized =
|
||||
hex.length === 3
|
||||
? hex
|
||||
.split("")
|
||||
.map((char) => `${char}${char}`)
|
||||
.join("")
|
||||
: hex;
|
||||
|
||||
if (!/^[\da-fA-F]{6}$/.test(normalized)) {
|
||||
return `rgba(255, 255, 255, ${clampedAlpha})`;
|
||||
}
|
||||
|
||||
const intValue = Number.parseInt(normalized, 16);
|
||||
const red = (intValue >> 16) & 255;
|
||||
const green = (intValue >> 8) & 255;
|
||||
const blue = intValue & 255;
|
||||
|
||||
return `rgba(${red}, ${green}, ${blue}, ${clampedAlpha})`;
|
||||
}
|
||||
|
||||
const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
@@ -445,9 +414,8 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
shimmerOverlay: {
|
||||
position: "absolute",
|
||||
top: 3,
|
||||
bottom: 3,
|
||||
borderRadius: 999,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1044,7 +1012,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
if (isLoading) {
|
||||
shimmer.value = -1;
|
||||
shimmer.value = withRepeat(
|
||||
withTiming(1, { duration: 3200, easing: Easing.linear }),
|
||||
withTiming(1, { duration: 2400, easing: Easing.bezier(0.4, 0, 0.6, 1) }),
|
||||
-1
|
||||
);
|
||||
} else {
|
||||
@@ -1053,15 +1021,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
const shimmerBandWidth = 14;
|
||||
const shimmerGradientColors = useMemo(
|
||||
() => ({
|
||||
edge: hexToRgba(theme.colors.foreground, 0),
|
||||
soft: hexToRgba(theme.colors.foreground, 0.08),
|
||||
core: hexToRgba(theme.colors.foreground, 0.24),
|
||||
}),
|
||||
[theme.colors.foreground]
|
||||
);
|
||||
const shimmerBandWidth = 18;
|
||||
const shimmerStyle = useAnimatedStyle(() => {
|
||||
const travel = badgeWidth + shimmerBandWidth;
|
||||
return {
|
||||
@@ -1148,13 +1108,13 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
x2="1"
|
||||
y2="0"
|
||||
>
|
||||
<Stop offset="0" stopColor={shimmerGradientColors.edge} />
|
||||
<Stop offset="0.38" stopColor={shimmerGradientColors.edge} />
|
||||
<Stop offset="0.48" stopColor={shimmerGradientColors.soft} />
|
||||
<Stop offset="0.5" stopColor={shimmerGradientColors.core} />
|
||||
<Stop offset="0.52" stopColor={shimmerGradientColors.soft} />
|
||||
<Stop offset="0.62" stopColor={shimmerGradientColors.edge} />
|
||||
<Stop offset="1" stopColor={shimmerGradientColors.edge} />
|
||||
<Stop offset="0" stopColor={theme.colors.surface1} stopOpacity="0" />
|
||||
<Stop offset="0.42" stopColor={theme.colors.surface1} stopOpacity="0" />
|
||||
<Stop offset="0.48" stopColor={theme.colors.surface1} stopOpacity="0.35" />
|
||||
<Stop offset="0.5" stopColor={theme.colors.surface1} stopOpacity="1" />
|
||||
<Stop offset="0.52" stopColor={theme.colors.surface1} stopOpacity="0.35" />
|
||||
<Stop offset="0.58" stopColor={theme.colors.surface1} stopOpacity="0" />
|
||||
<Stop offset="1" stopColor={theme.colors.surface1} stopOpacity="0" />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
<Rect width="100%" height="100%" fill="url(#shimmerGrad)" />
|
||||
@@ -1191,9 +1151,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
|
||||
interface ToolCallProps {
|
||||
toolName: string;
|
||||
args: any;
|
||||
result?: any;
|
||||
error?: any;
|
||||
args: unknown | null;
|
||||
result?: unknown | null;
|
||||
error?: unknown | null;
|
||||
status: "executing" | "running" | "completed" | "failed" | "canceled";
|
||||
detail?: ToolCallDetail;
|
||||
cwd?: string;
|
||||
@@ -1204,15 +1164,6 @@ interface ToolCallProps {
|
||||
onInlineDetailsExpandedChange?: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
// Icon mapping for tool kinds
|
||||
const toolKindIcons: Record<string, any> = {
|
||||
edit: Pencil,
|
||||
read: Eye,
|
||||
execute: SquareTerminal,
|
||||
search: Search,
|
||||
thinking: Brain,
|
||||
agent: Bot,
|
||||
};
|
||||
const TOOL_CALL_LOG_TAG = "[ToolCall]";
|
||||
const TOOL_CALL_COMMIT_THRESHOLD_MS = 16;
|
||||
|
||||
@@ -1241,14 +1192,24 @@ export const ToolCall = memo(function ToolCall({
|
||||
UnistylesRuntime.breakpoint === "sm";
|
||||
|
||||
const displayModel = useMemo(
|
||||
() => buildToolCallDisplayModel({ name: toolName, detail, metadata, cwd }),
|
||||
[toolName, detail, metadata, cwd]
|
||||
() =>
|
||||
buildToolCallDisplayModel({
|
||||
name: toolName,
|
||||
status: status === "executing" ? "running" : status,
|
||||
input: args ?? null,
|
||||
output: result ?? null,
|
||||
error: error ?? null,
|
||||
detail,
|
||||
metadata,
|
||||
cwd,
|
||||
}),
|
||||
[toolName, status, args, result, error, detail, metadata, cwd]
|
||||
);
|
||||
const displayName = displayModel.displayName;
|
||||
const kind: ToolCallKind = displayModel.kind;
|
||||
const summary = displayModel.summary;
|
||||
const errorText = useMemo(() => formatToolCallError(error), [error]);
|
||||
const IconComponent = toolKindIcons[kind] || Wrench;
|
||||
const errorText = displayModel.errorText;
|
||||
const iconCategory = detail?.type ?? toolName.trim().toLowerCase();
|
||||
const IconComponent = resolveToolCallIcon(toolName, detail);
|
||||
|
||||
// Check if there's any content to display
|
||||
const hasDetails =
|
||||
@@ -1260,7 +1221,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
}
|
||||
if (isMobile) {
|
||||
openToolCall({
|
||||
kind,
|
||||
toolName,
|
||||
displayName,
|
||||
summary,
|
||||
detail,
|
||||
@@ -1271,7 +1232,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
} else {
|
||||
setIsExpanded((prev) => !prev);
|
||||
}
|
||||
}, [isMobile, openToolCall, kind, displayName, summary, detail, args, result, errorText]);
|
||||
}, [isMobile, openToolCall, toolName, displayName, summary, detail, args, result, errorText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile || !isPerfLoggingEnabled()) {
|
||||
@@ -1288,7 +1249,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
perfLog(TOOL_CALL_LOG_TAG, {
|
||||
event: isExpanded ? "expand_commit" : "collapse_commit",
|
||||
toolName,
|
||||
kind,
|
||||
iconCategory,
|
||||
durationMs: Math.round(durationMs),
|
||||
});
|
||||
}
|
||||
@@ -1298,7 +1259,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
} else {
|
||||
logCommit();
|
||||
}
|
||||
}, [isExpanded, isMobile, toolName, kind]);
|
||||
}, [isExpanded, isMobile, toolName, iconCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onInlineDetailsHoverChange || isMobile || isExpanded) {
|
||||
|
||||
@@ -15,15 +15,15 @@ import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { Pencil, Eye, SquareTerminal, Search, Bot, Wrench, X } from "lucide-react-native";
|
||||
import { X } from "lucide-react-native";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import type { ToolCallKind } from "@/utils/tool-call-display";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
|
||||
// ----- Types -----
|
||||
|
||||
export type ToolCallSheetData = {
|
||||
kind: ToolCallKind;
|
||||
toolName: string;
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
detail?: ToolCallDetail;
|
||||
@@ -49,16 +49,6 @@ export function useToolCallSheet(): ToolCallSheetContextValue {
|
||||
return context;
|
||||
}
|
||||
|
||||
// ----- Icon Mapping -----
|
||||
|
||||
const toolKindIcons: Record<string, React.ComponentType<{ size?: number; color?: string }>> = {
|
||||
edit: Pencil,
|
||||
read: Eye,
|
||||
execute: SquareTerminal,
|
||||
search: Search,
|
||||
agent: Bot,
|
||||
};
|
||||
|
||||
// ----- Custom Background Component -----
|
||||
|
||||
function CustomSheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
@@ -142,9 +132,9 @@ interface ToolCallSheetContentProps {
|
||||
}
|
||||
|
||||
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
const { kind, displayName, detail, input, output, errorText } = data;
|
||||
const { toolName, displayName, detail, input, output, errorText } = data;
|
||||
|
||||
const IconComponent = toolKindIcons[kind] || Wrench;
|
||||
const IconComponent = resolveToolCallIcon(toolName, detail);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
|
||||
@@ -175,6 +175,10 @@ describe("stream reducer canonical tool calls", () => {
|
||||
|
||||
const summary = buildToolCallDisplayModel({
|
||||
name: tool.payload.data.name,
|
||||
status: tool.payload.data.status,
|
||||
input: tool.payload.data.input,
|
||||
output: tool.payload.data.result,
|
||||
error: tool.payload.data.error,
|
||||
detail: tool.payload.data.detail,
|
||||
}).summary;
|
||||
assert.strictEqual(summary, "npm test");
|
||||
@@ -204,12 +208,47 @@ describe("stream reducer canonical tool calls", () => {
|
||||
|
||||
const summary = buildToolCallDisplayModel({
|
||||
name: tool.payload.data.name,
|
||||
status: tool.payload.data.status,
|
||||
input: tool.payload.data.input,
|
||||
output: tool.payload.data.result,
|
||||
error: tool.payload.data.error,
|
||||
detail: tool.payload.data.detail,
|
||||
cwd: "/tmp/repo",
|
||||
}).summary;
|
||||
assert.strictEqual(summary, "README.md");
|
||||
});
|
||||
|
||||
it("does not infer command summary when detail is absent", () => {
|
||||
const callId = "running-summary-shell-input-only";
|
||||
const state = hydrateStreamState([
|
||||
{
|
||||
event: canonicalToolTimeline({
|
||||
provider: "codex",
|
||||
callId,
|
||||
name: "exec_command",
|
||||
status: "running",
|
||||
input: { command: "npm run lint" },
|
||||
output: null,
|
||||
}),
|
||||
timestamp: new Date("2025-01-01T10:17:00Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const tool = findToolByCallId(state, callId);
|
||||
assert.ok(tool);
|
||||
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: tool.payload.data.name,
|
||||
status: tool.payload.data.status,
|
||||
input: tool.payload.data.input,
|
||||
output: tool.payload.data.result,
|
||||
error: tool.payload.data.error,
|
||||
detail: tool.payload.data.detail,
|
||||
});
|
||||
assert.strictEqual(display.summary, undefined);
|
||||
assert.strictEqual(display.displayName, "Exec Command");
|
||||
});
|
||||
|
||||
it("preserves early input when later updates contain null input", () => {
|
||||
const callId = "null-input-preserve";
|
||||
const updates = [
|
||||
|
||||
@@ -158,6 +158,10 @@ export interface TodoListItem {
|
||||
items: TodoEntry[];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeChunk(text: string): { chunk: string; hasContent: boolean } {
|
||||
if (!text) {
|
||||
return { chunk: "", hasContent: false };
|
||||
@@ -169,6 +173,16 @@ function normalizeChunk(text: string): { chunk: string; hasContent: boolean } {
|
||||
return { chunk, hasContent: /\S/.test(chunk) };
|
||||
}
|
||||
|
||||
function markThoughtReady(item: ThoughtItem): ThoughtItem {
|
||||
if (item.status === "ready") {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
status: "ready",
|
||||
};
|
||||
}
|
||||
|
||||
function appendUserMessage(
|
||||
state: StreamItem[],
|
||||
text: string,
|
||||
@@ -228,7 +242,7 @@ function appendAssistantMessage(
|
||||
const secondLast = state[state.length - 2];
|
||||
if (last?.kind === "user_message" && secondLast?.kind === "assistant_message") {
|
||||
const updated: AssistantMessageItem = {
|
||||
...(secondLast as AssistantMessageItem),
|
||||
...secondLast,
|
||||
text: `${secondLast.text}${chunk}`,
|
||||
timestamp,
|
||||
};
|
||||
@@ -290,7 +304,7 @@ function finalizeActiveThoughts(state: StreamItem[]): StreamItem[] {
|
||||
const nextState = state.map((entry) => {
|
||||
if (entry.kind === "thought" && entry.status !== "ready") {
|
||||
mutated = true;
|
||||
return { ...entry, status: "ready" as ThoughtStatus };
|
||||
return markThoughtReady(entry);
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
@@ -311,12 +325,7 @@ function findExistingAgentToolCallIndex(
|
||||
}
|
||||
|
||||
function hasNonEmptyObject(value: unknown): boolean {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value as Record<string, unknown>).length > 0
|
||||
);
|
||||
return isRecord(value) && Object.keys(value).length > 0;
|
||||
}
|
||||
|
||||
function mergeCanonicalValue(
|
||||
@@ -362,7 +371,10 @@ function appendAgentToolCall(
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const next = [...state];
|
||||
const existing = next[existingIndex] as AgentToolCallItem;
|
||||
const existing = next[existingIndex];
|
||||
if (!existing || !isAgentToolCallItem(existing)) {
|
||||
return state;
|
||||
}
|
||||
const mergedInput = mergeCanonicalValue(existing.payload.data.input, data.input);
|
||||
const mergedResult = mergeCanonicalValue(existing.payload.data.result, data.result);
|
||||
const mergedStatus = mergeAgentToolCallStatus(
|
||||
@@ -570,7 +582,10 @@ export function reduceStreamUpdate(
|
||||
// Avoid rendering legacy plan-mode todo timeline items as Tasks.
|
||||
break;
|
||||
}
|
||||
const items = (item.items ?? []) as TodoEntry[];
|
||||
const items: TodoEntry[] = (item.items ?? []).map((todo) => ({
|
||||
text: todo.text,
|
||||
completed: Boolean(todo.completed),
|
||||
}));
|
||||
nextState = appendTodoList(state, event.provider, items, timestamp);
|
||||
break;
|
||||
}
|
||||
@@ -591,7 +606,10 @@ export function reduceStreamUpdate(
|
||||
(s) => s.kind === "compaction" && s.status === "loading"
|
||||
);
|
||||
if (loadingIdx >= 0) {
|
||||
const existing = state[loadingIdx] as CompactionItem;
|
||||
const existing = state[loadingIdx];
|
||||
if (!existing || existing.kind !== "compaction") {
|
||||
break;
|
||||
}
|
||||
const updated: CompactionItem = {
|
||||
...existing,
|
||||
status: "completed",
|
||||
@@ -704,7 +722,7 @@ function getEventItemKind(
|
||||
function finalizeHeadItems(head: StreamItem[]): StreamItem[] {
|
||||
return head.map((item) => {
|
||||
if (item.kind === "thought" && item.status !== "ready") {
|
||||
return { ...item, status: "ready" as ThoughtStatus };
|
||||
return markThoughtReady(item);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
@@ -881,7 +899,7 @@ export function applyStreamEventWithBuffer(params: {
|
||||
const head: StreamItem[] = params.buffer
|
||||
? [
|
||||
{
|
||||
kind: "assistant_message" as const,
|
||||
kind: "assistant_message",
|
||||
id: params.buffer.id,
|
||||
text: params.buffer.text,
|
||||
timestamp: params.buffer.timestamp,
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildToolCallDisplayModel, formatToolCallError } from "./tool-call-display";
|
||||
import { buildToolCallDisplayModel } from "./tool-call-display";
|
||||
|
||||
describe("tool-call-display", () => {
|
||||
it("builds display model from canonical shell detail", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "shell",
|
||||
status: "running",
|
||||
input: { command: "npm test" },
|
||||
output: null,
|
||||
error: null,
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "npm test",
|
||||
@@ -13,7 +17,6 @@ describe("tool-call-display", () => {
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
kind: "execute",
|
||||
displayName: "Shell",
|
||||
summary: "npm test",
|
||||
});
|
||||
@@ -22,6 +25,10 @@ describe("tool-call-display", () => {
|
||||
it("builds display model from canonical read detail", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "read_file",
|
||||
status: "completed",
|
||||
input: { path: "/tmp/repo/src/index.ts" },
|
||||
output: { content: "hello" },
|
||||
error: null,
|
||||
detail: {
|
||||
type: "read",
|
||||
filePath: "/tmp/repo/src/index.ts",
|
||||
@@ -30,7 +37,6 @@ describe("tool-call-display", () => {
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
kind: "read",
|
||||
displayName: "Read",
|
||||
summary: "src/index.ts",
|
||||
});
|
||||
@@ -39,13 +45,16 @@ describe("tool-call-display", () => {
|
||||
it("uses metadata summary for task tool calls", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "task",
|
||||
status: "running",
|
||||
input: null,
|
||||
output: null,
|
||||
error: null,
|
||||
metadata: {
|
||||
subAgentActivity: "Running tests",
|
||||
},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
kind: "agent",
|
||||
displayName: "Task",
|
||||
summary: "Running tests",
|
||||
});
|
||||
@@ -54,15 +63,40 @@ describe("tool-call-display", () => {
|
||||
it("falls back to humanized tool name for unknown tools", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "custom_tool_name",
|
||||
status: "completed",
|
||||
input: null,
|
||||
output: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
kind: "tool",
|
||||
displayName: "Custom Tool Name",
|
||||
});
|
||||
});
|
||||
|
||||
it("formats non-string errors", () => {
|
||||
expect(formatToolCallError({ message: "boom" })).toBe('{\n "message": "boom"\n}');
|
||||
it("does not derive command summary from raw input when detail is missing", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "exec_command",
|
||||
status: "running",
|
||||
input: { command: "npm run test" },
|
||||
output: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
displayName: "Exec Command",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns formatted errorText from the same display pipeline", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "shell",
|
||||
status: "failed",
|
||||
input: { command: "false" },
|
||||
output: null,
|
||||
error: { message: "boom" },
|
||||
});
|
||||
|
||||
expect(display.errorText).toBe('{\n "message": "boom"\n}');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,211 +1,5 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
export type ToolCallKind =
|
||||
| "read"
|
||||
| "edit"
|
||||
| "write"
|
||||
| "execute"
|
||||
| "search"
|
||||
| "agent"
|
||||
| "tool"
|
||||
| "thinking";
|
||||
|
||||
type SummaryParams = {
|
||||
name: string;
|
||||
detail?: ToolCallDetail;
|
||||
metadata?: Record<string, unknown>;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
export type ToolCallDisplayModel = {
|
||||
kind: ToolCallKind;
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
};
|
||||
|
||||
const TOOL_CALL_DETAIL_SCHEMA: z.ZodType<ToolCallDetail> = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("shell"),
|
||||
command: z.string(),
|
||||
cwd: z.string().optional(),
|
||||
output: z.string().optional(),
|
||||
exitCode: z.number().nullable().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("read"),
|
||||
filePath: z.string(),
|
||||
content: z.string().optional(),
|
||||
offset: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("edit"),
|
||||
filePath: z.string(),
|
||||
oldString: z.string().optional(),
|
||||
newString: z.string().optional(),
|
||||
unifiedDiff: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("write"),
|
||||
filePath: z.string(),
|
||||
content: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("search"),
|
||||
query: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const TOOL_CALL_DISPLAY_INPUT_SCHEMA = z.object({
|
||||
name: z.string().min(1),
|
||||
detail: TOOL_CALL_DETAIL_SCHEMA.optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
cwd: z.string().optional(),
|
||||
});
|
||||
|
||||
function toTitleCase(words: string): string {
|
||||
return words
|
||||
.split(" ")
|
||||
.filter((segment) => segment.length > 0)
|
||||
.map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function humanizeToolName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
return name;
|
||||
}
|
||||
return toTitleCase(trimmed.replace(/[._-]+/g, " "));
|
||||
}
|
||||
|
||||
export 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 buildDisplayFromDetail(detail: ToolCallDetail, cwd?: string): ToolCallDisplayModel {
|
||||
switch (detail.type) {
|
||||
case "shell":
|
||||
return {
|
||||
kind: "execute",
|
||||
displayName: "Shell",
|
||||
summary: detail.command,
|
||||
};
|
||||
case "read":
|
||||
return {
|
||||
kind: "read",
|
||||
displayName: "Read",
|
||||
summary: stripCwdPrefix(detail.filePath, cwd),
|
||||
};
|
||||
case "edit":
|
||||
return {
|
||||
kind: "edit",
|
||||
displayName: "Edit",
|
||||
summary: stripCwdPrefix(detail.filePath, cwd),
|
||||
};
|
||||
case "write":
|
||||
return {
|
||||
kind: "write",
|
||||
displayName: "Write",
|
||||
summary: stripCwdPrefix(detail.filePath, cwd),
|
||||
};
|
||||
case "search":
|
||||
return {
|
||||
kind: "search",
|
||||
displayName: "Search",
|
||||
summary: detail.query,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
kind: "tool",
|
||||
displayName: "Tool",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildDisplayWithoutDetail(params: {
|
||||
toolNameLower: string;
|
||||
rawName: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): ToolCallDisplayModel {
|
||||
if (params.toolNameLower === "task") {
|
||||
const summary = params.metadata?.subAgentActivity;
|
||||
return {
|
||||
kind: "agent",
|
||||
displayName: "Task",
|
||||
summary: typeof summary === "string" && summary.trim().length > 0 ? summary : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (params.toolNameLower === "thinking") {
|
||||
return {
|
||||
kind: "thinking",
|
||||
displayName: "Thinking",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "tool",
|
||||
displayName: humanizeToolName(params.rawName),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildToolCallDisplayModel(params: SummaryParams): ToolCallDisplayModel {
|
||||
const parsed = TOOL_CALL_DISPLAY_INPUT_SCHEMA.parse(params);
|
||||
if (parsed.detail) {
|
||||
return buildDisplayFromDetail(parsed.detail, parsed.cwd);
|
||||
}
|
||||
|
||||
return buildDisplayWithoutDetail({
|
||||
toolNameLower: parsed.name.trim().toLowerCase(),
|
||||
rawName: parsed.name,
|
||||
metadata: parsed.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveToolCallDisplayName(name: string, detail?: ToolCallDetail): string {
|
||||
return buildToolCallDisplayModel({ name, detail }).displayName;
|
||||
}
|
||||
|
||||
export function resolveToolCallKind(name: string, detail?: ToolCallDetail): ToolCallKind {
|
||||
return buildToolCallDisplayModel({ name, detail }).kind;
|
||||
}
|
||||
|
||||
export function resolveToolCallSummary(params: SummaryParams): string | undefined {
|
||||
return buildToolCallDisplayModel(params).summary;
|
||||
}
|
||||
|
||||
export function formatToolCallError(error: unknown): string | undefined {
|
||||
if (error === null || error === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
"content" in (error as Record<string, unknown>) &&
|
||||
typeof (error as Record<string, unknown>).content === "string"
|
||||
) {
|
||||
return (error as Record<string, unknown>).content as string;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error, null, 2);
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
export {
|
||||
buildToolCallDisplayModel,
|
||||
type ToolCallDisplayInput,
|
||||
type ToolCallDisplayModel,
|
||||
} from "@server/shared/tool-call-display";
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { Bot, Brain, Eye, Pencil, Search, SquareTerminal, Wrench } from "lucide-react-native";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
export type ToolCallIconComponent = ComponentType<{ size?: number; color?: string }>;
|
||||
|
||||
const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> = {
|
||||
shell: SquareTerminal,
|
||||
read: Eye,
|
||||
edit: Pencil,
|
||||
write: Pencil,
|
||||
search: Search,
|
||||
};
|
||||
|
||||
export function resolveToolCallIcon(toolName: string, detail?: ToolCallDetail): ToolCallIconComponent {
|
||||
if (detail) {
|
||||
return TOOL_DETAIL_ICONS[detail.type];
|
||||
}
|
||||
|
||||
const lowerName = toolName.trim().toLowerCase();
|
||||
if (lowerName === "task") {
|
||||
return Bot;
|
||||
}
|
||||
if (lowerName === "thinking") {
|
||||
return Brain;
|
||||
}
|
||||
return Wrench;
|
||||
}
|
||||
@@ -179,13 +179,14 @@ describe("DaemonClient", () => {
|
||||
await expect(promise).rejects.toThrow("boom");
|
||||
|
||||
// Ensure we didn't leave a waiter behind that will reject later.
|
||||
expect((client as any).waiters.size).toBe(0);
|
||||
const internal = client as unknown as { waiters: Set<unknown> };
|
||||
expect(internal.waiters.size).toBe(0);
|
||||
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("parses agent_stream tool_call payloads (including legacy inProgress) without crashing", async () => {
|
||||
test("parses canonical agent_stream tool_call payloads without crashing", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -219,8 +220,10 @@ describe("DaemonClient", () => {
|
||||
type: "tool_call",
|
||||
callId: "call_cli_stream",
|
||||
name: "shell",
|
||||
status: "inProgress",
|
||||
status: "running",
|
||||
input: { command: "pwd" },
|
||||
output: null,
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -250,7 +253,55 @@ describe("DaemonClient", () => {
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("parses agent_stream_snapshot tool_call payloads without crashing", async () => {
|
||||
test("drops legacy agent_stream tool_call payloads and logs validation warning", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const received: unknown[] = [];
|
||||
const unsubscribe = client.on("agent_stream", (msg) => {
|
||||
received.push(msg);
|
||||
});
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "agent_stream",
|
||||
payload: {
|
||||
agentId: "agent_cli",
|
||||
timestamp: "2026-02-08T20:20:00.000Z",
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call_cli_stream_legacy",
|
||||
name: "shell",
|
||||
status: "inProgress",
|
||||
input: { command: "pwd" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
unsubscribe();
|
||||
|
||||
expect(received).toHaveLength(0);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("parses canonical agent_stream_snapshot tool_call payloads without crashing", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -271,8 +322,32 @@ describe("DaemonClient", () => {
|
||||
received.push(msg);
|
||||
});
|
||||
|
||||
const snapshot = loadLegacySnapshotFixture();
|
||||
mock.triggerMessage(wrapSessionMessage(snapshot));
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "agent_stream_snapshot",
|
||||
payload: {
|
||||
agentId: "agent_cli",
|
||||
events: [
|
||||
{
|
||||
timestamp: "2026-02-08T20:20:00.000Z",
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call_cli_snapshot",
|
||||
name: "shell",
|
||||
status: "running",
|
||||
input: { command: "pwd" },
|
||||
output: null,
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
unsubscribe();
|
||||
|
||||
@@ -302,4 +377,34 @@ describe("DaemonClient", () => {
|
||||
}
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("drops legacy agent_stream_snapshot tool_call payloads and logs validation warning", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const received: unknown[] = [];
|
||||
const unsubscribe = client.on("agent_stream_snapshot", (msg) => {
|
||||
received.push(msg);
|
||||
});
|
||||
|
||||
const snapshot = loadLegacySnapshotFixture();
|
||||
mock.triggerMessage(wrapSessionMessage(snapshot));
|
||||
|
||||
unsubscribe();
|
||||
|
||||
expect(received).toHaveLength(0);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +70,38 @@ describe("curateAgentActivity", () => {
|
||||
expect(result).toContain("[Shell] npm test");
|
||||
});
|
||||
|
||||
it("does not infer summary from raw input when detail is missing", () => {
|
||||
const timeline: AgentTimelineItem[] = [
|
||||
toolCallItem({
|
||||
callId: "shell-no-detail",
|
||||
name: "exec_command",
|
||||
status: "running",
|
||||
input: { command: "npm run lint" },
|
||||
}),
|
||||
toolCallItem({
|
||||
callId: "read-no-detail",
|
||||
name: "read_file",
|
||||
status: "running",
|
||||
input: { path: "src/index.ts" },
|
||||
}),
|
||||
toolCallItem({
|
||||
callId: "search-no-detail",
|
||||
name: "web_search",
|
||||
status: "running",
|
||||
input: { query: "zod union" },
|
||||
}),
|
||||
];
|
||||
|
||||
const result = curateAgentActivity(timeline);
|
||||
|
||||
expect(result).toContain("[Exec Command]");
|
||||
expect(result).toContain("[Read File]");
|
||||
expect(result).toContain("[Web Search]");
|
||||
expect(result).not.toContain("npm run lint");
|
||||
expect(result).not.toContain("src/index.ts");
|
||||
expect(result).not.toContain("zod union");
|
||||
});
|
||||
|
||||
it("falls back to input json for likely external tools", () => {
|
||||
const timeline: AgentTimelineItem[] = [
|
||||
toolCallItem({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import { isLikelyExternalToolName } from "./tool-name-normalization.js";
|
||||
import { buildToolCallDisplayModel } from "../../shared/tool-call-display.js";
|
||||
|
||||
const DEFAULT_MAX_ITEMS = 40;
|
||||
const MAX_TOOL_INPUT_CHARS = 400;
|
||||
@@ -44,50 +45,6 @@ function formatToolInputJson(input: unknown): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveToolDisplayName(item: Extract<AgentTimelineItem, { type: "tool_call" }>): string {
|
||||
switch (item.detail?.type) {
|
||||
case "shell":
|
||||
return "Shell";
|
||||
case "read":
|
||||
return "Read";
|
||||
case "edit":
|
||||
return "Edit";
|
||||
case "write":
|
||||
return "Write";
|
||||
case "search":
|
||||
return "Search";
|
||||
default:
|
||||
return item.name;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveToolSummary(
|
||||
item: Extract<AgentTimelineItem, { type: "tool_call" }>
|
||||
): string | undefined {
|
||||
if (item.name.trim().toLowerCase() === "task") {
|
||||
const metadata = item.metadata as { subAgentActivity?: unknown } | undefined;
|
||||
if (typeof metadata?.subAgentActivity === "string") {
|
||||
const summary = metadata.subAgentActivity.trim();
|
||||
if (summary.length > 0) {
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (item.detail?.type) {
|
||||
case "shell":
|
||||
return item.detail.command;
|
||||
case "read":
|
||||
case "edit":
|
||||
case "write":
|
||||
return item.detail.filePath;
|
||||
case "search":
|
||||
return item.detail.query;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse timeline items:
|
||||
* - Dedupe tool calls by callId (pending/completed -> single)
|
||||
@@ -211,8 +168,17 @@ export function curateAgentActivity(
|
||||
case "tool_call": {
|
||||
flushBuffers(lines, buffers);
|
||||
const inputJson = formatToolInputJson(item.input);
|
||||
const displayName = resolveToolDisplayName(item);
|
||||
const summary = resolveToolSummary(item);
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: item.name,
|
||||
status: item.status,
|
||||
input: item.input,
|
||||
output: item.output,
|
||||
error: item.error,
|
||||
detail: item.detail,
|
||||
metadata: item.metadata,
|
||||
});
|
||||
const displayName = display.displayName;
|
||||
const summary = display.summary;
|
||||
if (isLikelyExternalToolName(item.name) && inputJson) {
|
||||
lines.push(`[${displayName}] ${inputJson}`);
|
||||
break;
|
||||
|
||||
@@ -28,6 +28,52 @@ describe("claude tool-call mapper", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("maps running known tool variants with detail for early summaries", () => {
|
||||
const readItem = mapClaudeRunningToolCall({
|
||||
callId: "claude-running-read",
|
||||
name: "read_file",
|
||||
input: { file_path: "README.md" },
|
||||
output: null,
|
||||
});
|
||||
expect(readItem.detail).toEqual({
|
||||
type: "read",
|
||||
filePath: "README.md",
|
||||
});
|
||||
|
||||
const writeItem = mapClaudeRunningToolCall({
|
||||
callId: "claude-running-write",
|
||||
name: "write_file",
|
||||
input: { file_path: "src/new.ts" },
|
||||
output: null,
|
||||
});
|
||||
expect(writeItem.detail).toEqual({
|
||||
type: "write",
|
||||
filePath: "src/new.ts",
|
||||
});
|
||||
|
||||
const editItem = mapClaudeRunningToolCall({
|
||||
callId: "claude-running-edit",
|
||||
name: "apply_patch",
|
||||
input: { file_path: "src/index.ts" },
|
||||
output: null,
|
||||
});
|
||||
expect(editItem.detail).toEqual({
|
||||
type: "edit",
|
||||
filePath: "src/index.ts",
|
||||
});
|
||||
|
||||
const searchItem = mapClaudeRunningToolCall({
|
||||
callId: "claude-running-search",
|
||||
name: "web_search",
|
||||
input: { query: "tool call mapping" },
|
||||
output: null,
|
||||
});
|
||||
expect(searchItem.detail).toEqual({
|
||||
type: "search",
|
||||
query: "tool call mapping",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps completed read calls with detail enrichment", () => {
|
||||
const item = mapClaudeCompletedToolCall({
|
||||
callId: "claude-call-2",
|
||||
@@ -48,6 +94,41 @@ describe("claude tool-call mapper", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves read content from array/object output variants", () => {
|
||||
const arrayContent = mapClaudeCompletedToolCall({
|
||||
callId: "claude-read-array",
|
||||
name: "read_file",
|
||||
input: { file_path: "README.md" },
|
||||
output: {
|
||||
content: [
|
||||
{ type: "output_text", text: "alpha" },
|
||||
{ type: "output_text", content: "beta" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(arrayContent.detail?.type).toBe("read");
|
||||
if (arrayContent.detail?.type === "read") {
|
||||
expect(arrayContent.detail.content).toBe("alpha\nbeta");
|
||||
}
|
||||
|
||||
const objectContent = mapClaudeCompletedToolCall({
|
||||
callId: "claude-read-object",
|
||||
name: "read_file",
|
||||
input: { file_path: "README.md" },
|
||||
output: {
|
||||
structured_content: {
|
||||
content: { type: "output_text", text: "gamma" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(objectContent.detail?.type).toBe("read");
|
||||
if (objectContent.detail?.type === "read") {
|
||||
expect(objectContent.detail.content).toBe("gamma");
|
||||
}
|
||||
});
|
||||
|
||||
it("maps failed calls with required error", () => {
|
||||
const item = mapClaudeFailedToolCall({
|
||||
callId: "claude-call-3",
|
||||
@@ -64,6 +145,42 @@ describe("claude tool-call mapper", () => {
|
||||
expect(item.output).toBeNull();
|
||||
});
|
||||
|
||||
it("maps write/edit/search known shapes with distinct detail types", () => {
|
||||
const writeItem = mapClaudeCompletedToolCall({
|
||||
callId: "claude-write-1",
|
||||
name: "write_file",
|
||||
input: { file_path: "src/new.ts", content: "export const x = 1;" },
|
||||
output: null,
|
||||
});
|
||||
expect(writeItem.detail?.type).toBe("write");
|
||||
if (writeItem.detail?.type === "write") {
|
||||
expect(writeItem.detail.filePath).toBe("src/new.ts");
|
||||
}
|
||||
|
||||
const editItem = mapClaudeCompletedToolCall({
|
||||
callId: "claude-edit-1",
|
||||
name: "apply_patch",
|
||||
input: { file_path: "src/index.ts", patch: "@@\\n-old\\n+new\\n" },
|
||||
output: null,
|
||||
});
|
||||
expect(editItem.detail?.type).toBe("edit");
|
||||
if (editItem.detail?.type === "edit") {
|
||||
expect(editItem.detail.filePath).toBe("src/index.ts");
|
||||
expect(editItem.detail.unifiedDiff).toContain("@@");
|
||||
}
|
||||
|
||||
const searchItem = mapClaudeCompletedToolCall({
|
||||
callId: "claude-search-1",
|
||||
name: "web_search",
|
||||
input: { query: "tool call mapping" },
|
||||
output: null,
|
||||
});
|
||||
expect(searchItem.detail).toEqual({
|
||||
type: "search",
|
||||
query: "tool call mapping",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unknown tools canonical without detail", () => {
|
||||
const item = mapClaudeCompletedToolCall({
|
||||
callId: "claude-call-4",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,68 @@ describe("codex tool-call mapper", () => {
|
||||
expect(item?.input).toEqual({ command: "pwd", cwd: "/tmp/repo" });
|
||||
});
|
||||
|
||||
it("maps running known tool variants with detail for early summaries", () => {
|
||||
const readItem = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
type: "mcpToolCall",
|
||||
id: "codex-running-read",
|
||||
status: "running",
|
||||
tool: "read_file",
|
||||
arguments: { path: "/tmp/repo/README.md" },
|
||||
result: null,
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
);
|
||||
expect(readItem?.detail).toEqual({
|
||||
type: "read",
|
||||
filePath: "README.md",
|
||||
});
|
||||
|
||||
const writeItem = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
type: "mcpToolCall",
|
||||
id: "codex-running-write",
|
||||
status: "running",
|
||||
tool: "write_file",
|
||||
arguments: { file_path: "/tmp/repo/src/new.ts" },
|
||||
result: null,
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
);
|
||||
expect(writeItem?.detail).toEqual({
|
||||
type: "write",
|
||||
filePath: "src/new.ts",
|
||||
});
|
||||
|
||||
const editItem = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
type: "mcpToolCall",
|
||||
id: "codex-running-edit",
|
||||
status: "running",
|
||||
tool: "apply_patch",
|
||||
arguments: { file_path: "/tmp/repo/src/index.ts" },
|
||||
result: null,
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
);
|
||||
expect(editItem?.detail).toEqual({
|
||||
type: "edit",
|
||||
filePath: "src/index.ts",
|
||||
});
|
||||
|
||||
const searchItem = mapCodexToolCallFromThreadItem({
|
||||
type: "webSearch",
|
||||
id: "codex-running-search",
|
||||
status: "running",
|
||||
query: "codex timeline",
|
||||
action: null,
|
||||
});
|
||||
expect(searchItem?.detail).toEqual({
|
||||
type: "search",
|
||||
query: "codex timeline",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps mcp read_file completion with detail", () => {
|
||||
const item = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
@@ -48,6 +110,96 @@ describe("codex tool-call mapper", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("retains read_file content when provider returns content array objects", () => {
|
||||
const item = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
type: "mcpToolCall",
|
||||
id: "codex-read-array",
|
||||
status: "completed",
|
||||
tool: "read_file",
|
||||
arguments: { path: "/tmp/repo/README.md" },
|
||||
result: {
|
||||
content: [{ type: "text", text: "line one" }, { type: "text", text: "line two" }],
|
||||
},
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
);
|
||||
|
||||
expect(item).toBeTruthy();
|
||||
expect(item?.detail?.type).toBe("read");
|
||||
if (item?.detail?.type === "read") {
|
||||
expect(item.detail.filePath).toBe("README.md");
|
||||
expect(item.detail.content).toBe("line one\nline two");
|
||||
}
|
||||
});
|
||||
|
||||
it("truncates large diff payloads deterministically in canonical output", () => {
|
||||
const hugeDiff = `@@\\n-${"a".repeat(14_000)}\\n+${"b".repeat(14_000)}\\n`;
|
||||
const item = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
type: "fileChange",
|
||||
id: "codex-diff-1",
|
||||
status: "completed",
|
||||
changes: [{ path: "/tmp/repo/src/index.ts", kind: "modify", diff: hugeDiff }],
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
);
|
||||
|
||||
expect(item).toBeTruthy();
|
||||
expect(item?.status).toBe("completed");
|
||||
expect(item?.output).toBeTruthy();
|
||||
const output = item?.output as { files?: Array<{ patch?: string }> };
|
||||
expect(output.files?.[0]?.patch).toBeDefined();
|
||||
expect(output.files?.[0]?.patch?.includes("...[truncated ")).toBe(true);
|
||||
expect((output.files?.[0]?.patch?.length ?? 0) < hugeDiff.length).toBe(true);
|
||||
});
|
||||
|
||||
it("maps write/edit/search known variants with distinct detail types", () => {
|
||||
const writeItem = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
type: "mcpToolCall",
|
||||
id: "codex-write-1",
|
||||
status: "completed",
|
||||
tool: "write_file",
|
||||
arguments: { file_path: "/tmp/repo/src/new.ts", content: "export {}" },
|
||||
result: null,
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
);
|
||||
expect(writeItem?.detail?.type).toBe("write");
|
||||
if (writeItem?.detail?.type === "write") {
|
||||
expect(writeItem.detail.filePath).toBe("src/new.ts");
|
||||
}
|
||||
|
||||
const editItem = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
type: "mcpToolCall",
|
||||
id: "codex-edit-1",
|
||||
status: "completed",
|
||||
tool: "apply_patch",
|
||||
arguments: { file_path: "/tmp/repo/src/index.ts", patch: "@@\\n-a\\n+b\\n" },
|
||||
result: null,
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
);
|
||||
expect(editItem?.detail?.type).toBe("edit");
|
||||
if (editItem?.detail?.type === "edit") {
|
||||
expect(editItem.detail.filePath).toBe("src/index.ts");
|
||||
}
|
||||
|
||||
const searchItem = mapCodexToolCallFromThreadItem({
|
||||
type: "webSearch",
|
||||
id: "codex-search-1",
|
||||
status: "completed",
|
||||
query: "codex timeline",
|
||||
action: { results: [] },
|
||||
});
|
||||
expect(searchItem?.detail).toEqual({
|
||||
type: "search",
|
||||
query: "codex timeline",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps failed tool calls with required error", () => {
|
||||
const item = mapCodexToolCallFromThreadItem({
|
||||
type: "mcpToolCall",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,56 @@ describe("opencode tool-call mapper", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("maps running known tool variants with detail for early summaries", () => {
|
||||
const readItem = mapOpencodeToolCall({
|
||||
toolName: "read_file",
|
||||
callId: "opencode-running-read",
|
||||
status: "running",
|
||||
input: { file_path: "README.md" },
|
||||
output: null,
|
||||
});
|
||||
expect(readItem.detail).toEqual({
|
||||
type: "read",
|
||||
filePath: "README.md",
|
||||
});
|
||||
|
||||
const writeItem = mapOpencodeToolCall({
|
||||
toolName: "write_file",
|
||||
callId: "opencode-running-write",
|
||||
status: "running",
|
||||
input: { file_path: "src/new.ts" },
|
||||
output: null,
|
||||
});
|
||||
expect(writeItem.detail).toEqual({
|
||||
type: "write",
|
||||
filePath: "src/new.ts",
|
||||
});
|
||||
|
||||
const editItem = mapOpencodeToolCall({
|
||||
toolName: "apply_patch",
|
||||
callId: "opencode-running-edit",
|
||||
status: "running",
|
||||
input: { file_path: "src/index.ts" },
|
||||
output: null,
|
||||
});
|
||||
expect(editItem.detail).toEqual({
|
||||
type: "edit",
|
||||
filePath: "src/index.ts",
|
||||
});
|
||||
|
||||
const searchItem = mapOpencodeToolCall({
|
||||
toolName: "web_search",
|
||||
callId: "opencode-running-search",
|
||||
status: "running",
|
||||
input: { query: "opencode mapper" },
|
||||
output: null,
|
||||
});
|
||||
expect(searchItem.detail).toEqual({
|
||||
type: "search",
|
||||
query: "opencode mapper",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps completed read calls", () => {
|
||||
const item = mapOpencodeToolCall({
|
||||
toolName: "read_file",
|
||||
@@ -40,6 +90,43 @@ describe("opencode tool-call mapper", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves read content from array/object output variants", () => {
|
||||
const arrayContent = mapOpencodeToolCall({
|
||||
toolName: "read_file",
|
||||
callId: "opencode-read-array",
|
||||
status: "completed",
|
||||
input: { file_path: "README.md" },
|
||||
output: {
|
||||
content: [
|
||||
{ type: "output_text", text: "alpha" },
|
||||
{ type: "output_text", output: "beta" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(arrayContent.detail?.type).toBe("read");
|
||||
if (arrayContent.detail?.type === "read") {
|
||||
expect(arrayContent.detail.content).toBe("alpha\nbeta");
|
||||
}
|
||||
|
||||
const objectContent = mapOpencodeToolCall({
|
||||
toolName: "read_file",
|
||||
callId: "opencode-read-object",
|
||||
status: "completed",
|
||||
input: { file_path: "README.md" },
|
||||
output: {
|
||||
data: {
|
||||
content: { type: "output_text", text: "gamma" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(objectContent.detail?.type).toBe("read");
|
||||
if (objectContent.detail?.type === "read") {
|
||||
expect(objectContent.detail.content).toBe("gamma");
|
||||
}
|
||||
});
|
||||
|
||||
it("maps failed calls with required error", () => {
|
||||
const item = mapOpencodeToolCall({
|
||||
toolName: "shell",
|
||||
@@ -55,6 +142,45 @@ describe("opencode tool-call mapper", () => {
|
||||
expect(item.callId).toBe("opencode-call-3");
|
||||
});
|
||||
|
||||
it("maps write/edit/search known variants into canonical detail", () => {
|
||||
const writeItem = mapOpencodeToolCall({
|
||||
toolName: "write_file",
|
||||
callId: "opencode-write-1",
|
||||
status: "completed",
|
||||
input: { file_path: "src/new.ts", content: "const x = 1;" },
|
||||
output: null,
|
||||
});
|
||||
expect(writeItem.detail?.type).toBe("write");
|
||||
if (writeItem.detail?.type === "write") {
|
||||
expect(writeItem.detail.filePath).toBe("src/new.ts");
|
||||
}
|
||||
|
||||
const editItem = mapOpencodeToolCall({
|
||||
toolName: "apply_patch",
|
||||
callId: "opencode-edit-1",
|
||||
status: "completed",
|
||||
input: { file_path: "src/index.ts", diff: "@@\\n-old\\n+new\\n" },
|
||||
output: null,
|
||||
});
|
||||
expect(editItem.detail?.type).toBe("edit");
|
||||
if (editItem.detail?.type === "edit") {
|
||||
expect(editItem.detail.filePath).toBe("src/index.ts");
|
||||
expect(editItem.detail.unifiedDiff).toContain("@@");
|
||||
}
|
||||
|
||||
const searchItem = mapOpencodeToolCall({
|
||||
toolName: "web_search",
|
||||
callId: "opencode-search-1",
|
||||
status: "completed",
|
||||
input: { query: "opencode mapper" },
|
||||
output: null,
|
||||
});
|
||||
expect(searchItem.detail).toEqual({
|
||||
type: "search",
|
||||
query: "opencode mapper",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unknown tools canonical without detail", () => {
|
||||
const item = mapOpencodeToolCall({
|
||||
toolName: "my_custom_tool",
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ToolCallDetail, ToolCallTimelineItem } from "../../agent-sdk-types.js";
|
||||
import {
|
||||
coerceToolCallId,
|
||||
commandFromValue,
|
||||
flattenReadContent as flattenToolReadContent,
|
||||
nonEmptyString,
|
||||
truncateDiffText,
|
||||
} from "../tool-call-mapper-utils.js";
|
||||
|
||||
type OpencodeToolCallParams = {
|
||||
toolName: string;
|
||||
@@ -12,6 +19,8 @@ type OpencodeToolCallParams = {
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const MAX_DIFF_TEXT_CHARS = 12_000;
|
||||
|
||||
const FAILED_STATUSES = new Set(["error", "failed", "failure"]);
|
||||
const CANCELED_STATUSES = new Set(["canceled", "cancelled", "aborted", "interrupted"]);
|
||||
const COMPLETED_STATUSES = new Set(["complete", "completed", "success", "succeeded", "done"]);
|
||||
@@ -28,64 +37,32 @@ const OpencodeToolCallParamsSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const OpencodeShellToolNameSchema = z.union([
|
||||
z.literal("shell"),
|
||||
z.literal("bash"),
|
||||
z.literal("exec_command"),
|
||||
]);
|
||||
|
||||
const OpencodeReadToolNameSchema = z.union([
|
||||
z.literal("read"),
|
||||
z.literal("read_file"),
|
||||
]);
|
||||
|
||||
const OpencodeWriteToolNameSchema = z.union([
|
||||
z.literal("write"),
|
||||
z.literal("write_file"),
|
||||
z.literal("create_file"),
|
||||
]);
|
||||
|
||||
const OpencodeEditToolNameSchema = z.union([
|
||||
z.literal("edit"),
|
||||
z.literal("apply_patch"),
|
||||
z.literal("apply_diff"),
|
||||
]);
|
||||
|
||||
const OpencodeSearchToolNameSchema = z.union([
|
||||
z.literal("search"),
|
||||
z.literal("web_search"),
|
||||
]);
|
||||
|
||||
const OpencodeFileReferenceSchema = z
|
||||
.object({
|
||||
file_path: z.string().optional(),
|
||||
filePath: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
target_path: z.string().optional(),
|
||||
targetPath: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const OpencodeFileReferenceCollectionSchema = OpencodeFileReferenceSchema.extend({
|
||||
files: z.array(OpencodeFileReferenceSchema).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const OpencodeTextLikeSchema = z
|
||||
.object({
|
||||
output: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const CommandValueSchema = z.union([z.string(), z.array(z.string())]);
|
||||
|
||||
const OpencodeShellInputSchema = z
|
||||
.object({
|
||||
command: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
cmd: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
cwd: z.string().optional(),
|
||||
directory: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
.union([
|
||||
z
|
||||
.object({
|
||||
command: CommandValueSchema,
|
||||
cwd: z.string().optional(),
|
||||
directory: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
z
|
||||
.object({
|
||||
cmd: CommandValueSchema,
|
||||
cwd: z.string().optional(),
|
||||
directory: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
])
|
||||
.transform((value) => {
|
||||
const commandValue = "command" in value ? value.command : value.cmd;
|
||||
return {
|
||||
command: commandFromValue(commandValue),
|
||||
cwd: nonEmptyString(value.cwd) ?? nonEmptyString(value.directory),
|
||||
};
|
||||
});
|
||||
|
||||
const OpencodeShellOutputObjectSchema = z
|
||||
.object({
|
||||
@@ -93,192 +70,409 @@ const OpencodeShellOutputObjectSchema = z
|
||||
output: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
exitCode: z.number().nullable().optional(),
|
||||
exit_code: z.number().nullable().optional(),
|
||||
exitCode: z.number().finite().nullable().optional(),
|
||||
exit_code: z.number().finite().nullable().optional(),
|
||||
metadata: z
|
||||
.object({
|
||||
exitCode: z.number().nullable().optional(),
|
||||
exit_code: z.number().nullable().optional(),
|
||||
exitCode: z.number().finite().nullable().optional(),
|
||||
exit_code: z.number().finite().nullable().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
structuredContent: z
|
||||
.object({
|
||||
output: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
structured_content: z
|
||||
.object({
|
||||
output: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
result: z
|
||||
.object({
|
||||
command: z.string().optional(),
|
||||
output: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
structuredContent: OpencodeTextLikeSchema.optional(),
|
||||
structured_content: OpencodeTextLikeSchema.optional(),
|
||||
result: OpencodeTextLikeSchema.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const OpencodeShellOutputSchema = z.union([z.string(), OpencodeShellOutputObjectSchema]);
|
||||
const OpencodeShellOutputSchema = z.union([
|
||||
z.string().transform((value) => ({
|
||||
command: undefined,
|
||||
output: nonEmptyString(value),
|
||||
exitCode: undefined,
|
||||
})),
|
||||
OpencodeShellOutputObjectSchema.transform((value) => ({
|
||||
command: nonEmptyString(value.command) ?? nonEmptyString(value.result?.command),
|
||||
output:
|
||||
nonEmptyString(value.output) ??
|
||||
nonEmptyString(value.text) ??
|
||||
nonEmptyString(value.content) ??
|
||||
nonEmptyString(value.structuredContent?.output) ??
|
||||
nonEmptyString(value.structuredContent?.text) ??
|
||||
nonEmptyString(value.structuredContent?.content) ??
|
||||
nonEmptyString(value.structured_content?.output) ??
|
||||
nonEmptyString(value.structured_content?.text) ??
|
||||
nonEmptyString(value.structured_content?.content) ??
|
||||
nonEmptyString(value.result?.output) ??
|
||||
nonEmptyString(value.result?.text) ??
|
||||
nonEmptyString(value.result?.content),
|
||||
exitCode:
|
||||
value.exitCode ??
|
||||
value.exit_code ??
|
||||
value.metadata?.exitCode ??
|
||||
value.metadata?.exit_code ??
|
||||
undefined,
|
||||
})),
|
||||
]);
|
||||
|
||||
const OpencodeReadInputSchema = OpencodeFileReferenceCollectionSchema.extend({
|
||||
offset: z.number().finite().optional(),
|
||||
limit: z.number().finite().optional(),
|
||||
}).passthrough();
|
||||
const OpencodeReadPathInputSchema = z.union([
|
||||
z
|
||||
.object({
|
||||
file_path: z.string(),
|
||||
offset: z.number().finite().optional(),
|
||||
limit: z.number().finite().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.transform((value) => ({
|
||||
filePath: value.file_path,
|
||||
offset: value.offset,
|
||||
limit: value.limit,
|
||||
})),
|
||||
z
|
||||
.object({
|
||||
path: z.string(),
|
||||
offset: z.number().finite().optional(),
|
||||
limit: z.number().finite().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.transform((value) => ({
|
||||
filePath: value.path,
|
||||
offset: value.offset,
|
||||
limit: value.limit,
|
||||
})),
|
||||
z
|
||||
.object({
|
||||
filePath: z.string(),
|
||||
offset: z.number().finite().optional(),
|
||||
limit: z.number().finite().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.transform((value) => ({
|
||||
filePath: value.filePath,
|
||||
offset: value.offset,
|
||||
limit: value.limit,
|
||||
})),
|
||||
]);
|
||||
|
||||
const OpencodeReadChunkSchema = z.union([
|
||||
z
|
||||
.object({
|
||||
text: z.string(),
|
||||
content: z.string().optional(),
|
||||
output: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
z
|
||||
.object({
|
||||
text: z.string().optional(),
|
||||
content: z.string(),
|
||||
output: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
z
|
||||
.object({
|
||||
text: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
output: z.string(),
|
||||
})
|
||||
.passthrough(),
|
||||
]);
|
||||
|
||||
const OpencodeReadContentSchema = z.union([
|
||||
z.string(),
|
||||
OpencodeReadChunkSchema,
|
||||
z.array(OpencodeReadChunkSchema),
|
||||
]);
|
||||
|
||||
const OpencodeReadPayloadSchema = z.union([
|
||||
z
|
||||
.object({
|
||||
content: OpencodeReadContentSchema,
|
||||
text: OpencodeReadContentSchema.optional(),
|
||||
output: OpencodeReadContentSchema.optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
z
|
||||
.object({
|
||||
content: OpencodeReadContentSchema.optional(),
|
||||
text: OpencodeReadContentSchema,
|
||||
output: OpencodeReadContentSchema.optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
z
|
||||
.object({
|
||||
content: OpencodeReadContentSchema.optional(),
|
||||
text: OpencodeReadContentSchema.optional(),
|
||||
output: OpencodeReadContentSchema,
|
||||
})
|
||||
.passthrough(),
|
||||
]);
|
||||
|
||||
const OpencodeReadOutputSchema = z.union([
|
||||
z.string(),
|
||||
OpencodeFileReferenceCollectionSchema.extend({
|
||||
content: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
output: z.string().optional(),
|
||||
structuredContent: OpencodeTextLikeSchema.optional(),
|
||||
structured_content: OpencodeTextLikeSchema.optional(),
|
||||
data: OpencodeTextLikeSchema.optional(),
|
||||
}).passthrough(),
|
||||
z.string().transform((value) => ({ content: nonEmptyString(value) })),
|
||||
OpencodeReadChunkSchema.transform((value) => ({ content: flattenReadContent(value) })),
|
||||
z.array(OpencodeReadChunkSchema).transform((value) => ({ content: flattenReadContent(value) })),
|
||||
OpencodeReadPayloadSchema.transform((value) => ({
|
||||
content:
|
||||
flattenReadContent(value.content) ??
|
||||
flattenReadContent(value.text) ??
|
||||
flattenReadContent(value.output),
|
||||
})),
|
||||
z
|
||||
.object({ data: OpencodeReadPayloadSchema })
|
||||
.passthrough()
|
||||
.transform((value) => ({
|
||||
content:
|
||||
flattenReadContent(value.data.content) ??
|
||||
flattenReadContent(value.data.text) ??
|
||||
flattenReadContent(value.data.output),
|
||||
})),
|
||||
z
|
||||
.object({ structuredContent: OpencodeReadPayloadSchema })
|
||||
.passthrough()
|
||||
.transform((value) => ({
|
||||
content:
|
||||
flattenReadContent(value.structuredContent.content) ??
|
||||
flattenReadContent(value.structuredContent.text) ??
|
||||
flattenReadContent(value.structuredContent.output),
|
||||
})),
|
||||
z
|
||||
.object({ structured_content: OpencodeReadPayloadSchema })
|
||||
.passthrough()
|
||||
.transform((value) => ({
|
||||
content:
|
||||
flattenReadContent(value.structured_content.content) ??
|
||||
flattenReadContent(value.structured_content.text) ??
|
||||
flattenReadContent(value.structured_content.output),
|
||||
})),
|
||||
]);
|
||||
|
||||
const OpencodeWriteInputSchema = OpencodeFileReferenceCollectionSchema.extend({
|
||||
content: z.string().optional(),
|
||||
newContent: z.string().optional(),
|
||||
new_content: z.string().optional(),
|
||||
}).passthrough();
|
||||
const OpencodeWritePathInputSchema = z.union([
|
||||
z.object({ file_path: z.string() }).passthrough().transform((value) => ({ filePath: value.file_path })),
|
||||
z.object({ path: z.string() }).passthrough().transform((value) => ({ filePath: value.path })),
|
||||
z.object({ filePath: z.string() }).passthrough().transform((value) => ({ filePath: value.filePath })),
|
||||
]);
|
||||
|
||||
const OpencodeWriteOutputSchema = OpencodeFileReferenceCollectionSchema.extend({
|
||||
content: z.string().optional(),
|
||||
newContent: z.string().optional(),
|
||||
new_content: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const OpencodeEditInputSchema = OpencodeFileReferenceCollectionSchema.extend({
|
||||
old_string: z.string().optional(),
|
||||
old_str: z.string().optional(),
|
||||
oldContent: z.string().optional(),
|
||||
old_content: z.string().optional(),
|
||||
new_string: z.string().optional(),
|
||||
new_str: z.string().optional(),
|
||||
newContent: z.string().optional(),
|
||||
new_content: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
patch: z.string().optional(),
|
||||
diff: z.string().optional(),
|
||||
unified_diff: z.string().optional(),
|
||||
unifiedDiff: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const OpencodeEditOutputSchema = OpencodeFileReferenceCollectionSchema.extend({
|
||||
patch: z.string().optional(),
|
||||
diff: z.string().optional(),
|
||||
unified_diff: z.string().optional(),
|
||||
unifiedDiff: z.string().optional(),
|
||||
files: z
|
||||
.array(
|
||||
OpencodeFileReferenceSchema.extend({
|
||||
patch: z.string().optional(),
|
||||
diff: z.string().optional(),
|
||||
unified_diff: z.string().optional(),
|
||||
unifiedDiff: z.string().optional(),
|
||||
}).passthrough()
|
||||
)
|
||||
.optional(),
|
||||
}).passthrough();
|
||||
|
||||
const OpencodeSearchInputSchema = z
|
||||
const OpencodeWriteContentSchema = z
|
||||
.object({
|
||||
query: z.string().optional(),
|
||||
q: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
new_content: z.string().optional(),
|
||||
newContent: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const OpencodeShellDetailCandidateSchema = z
|
||||
.object({
|
||||
toolName: OpencodeShellToolNameSchema,
|
||||
input: z.unknown().nullable(),
|
||||
output: z.unknown().nullable(),
|
||||
})
|
||||
.transform(({ input, output }) => resolveShellDetail(input, output));
|
||||
const OpencodeWriteInputSchema = z
|
||||
.intersection(OpencodeWritePathInputSchema, OpencodeWriteContentSchema)
|
||||
.transform((value) => ({
|
||||
filePath: value.filePath,
|
||||
content:
|
||||
nonEmptyString(value.content) ??
|
||||
nonEmptyString(value.new_content) ??
|
||||
nonEmptyString(value.newContent),
|
||||
}));
|
||||
|
||||
const OpencodeReadDetailCandidateSchema = z
|
||||
.object({
|
||||
toolName: OpencodeReadToolNameSchema,
|
||||
input: z.unknown().nullable(),
|
||||
output: z.unknown().nullable(),
|
||||
})
|
||||
.transform(({ input, output }) => resolveReadDetail(input, output));
|
||||
|
||||
const OpencodeWriteDetailCandidateSchema = z
|
||||
.object({
|
||||
toolName: OpencodeWriteToolNameSchema,
|
||||
input: z.unknown().nullable(),
|
||||
output: z.unknown().nullable(),
|
||||
})
|
||||
.transform(({ input, output }) => resolveWriteDetail(input, output));
|
||||
|
||||
const OpencodeEditDetailCandidateSchema = z
|
||||
.object({
|
||||
toolName: OpencodeEditToolNameSchema,
|
||||
input: z.unknown().nullable(),
|
||||
output: z.unknown().nullable(),
|
||||
})
|
||||
.transform(({ input, output }) => resolveEditDetail(input, output));
|
||||
|
||||
const OpencodeSearchDetailCandidateSchema = z
|
||||
.object({
|
||||
toolName: OpencodeSearchToolNameSchema,
|
||||
input: z.unknown().nullable(),
|
||||
output: z.unknown().nullable(),
|
||||
})
|
||||
.transform(({ input }) => resolveSearchDetail(input));
|
||||
|
||||
const OpencodeKnownToolDetailSchema = z.union([
|
||||
OpencodeShellDetailCandidateSchema,
|
||||
OpencodeReadDetailCandidateSchema,
|
||||
OpencodeWriteDetailCandidateSchema,
|
||||
OpencodeEditDetailCandidateSchema,
|
||||
OpencodeSearchDetailCandidateSchema,
|
||||
const OpencodeWriteOutputSchema = z.union([
|
||||
z
|
||||
.intersection(OpencodeWritePathInputSchema, OpencodeWriteContentSchema)
|
||||
.transform((value) => ({
|
||||
filePath: value.filePath,
|
||||
content:
|
||||
nonEmptyString(value.content) ??
|
||||
nonEmptyString(value.new_content) ??
|
||||
nonEmptyString(value.newContent),
|
||||
})),
|
||||
OpencodeWriteContentSchema.transform((value) => ({
|
||||
filePath: undefined,
|
||||
content:
|
||||
nonEmptyString(value.content) ??
|
||||
nonEmptyString(value.new_content) ??
|
||||
nonEmptyString(value.newContent),
|
||||
})),
|
||||
]);
|
||||
|
||||
function firstNonEmpty(...values: Array<string | undefined>): string | undefined {
|
||||
return values.find((value) => typeof value === "string" && value.length > 0);
|
||||
}
|
||||
const OpencodeEditTextSchema = z
|
||||
.object({
|
||||
old_string: z.string().optional(),
|
||||
old_str: z.string().optional(),
|
||||
oldContent: z.string().optional(),
|
||||
old_content: z.string().optional(),
|
||||
new_string: z.string().optional(),
|
||||
new_str: z.string().optional(),
|
||||
newContent: z.string().optional(),
|
||||
new_content: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
patch: z.string().optional(),
|
||||
diff: z.string().optional(),
|
||||
unified_diff: z.string().optional(),
|
||||
unifiedDiff: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
function commandFromValue(value: string | string[] | undefined): string | undefined {
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const tokens = value.filter((token): token is string => typeof token === "string" && token.length > 0);
|
||||
if (tokens.length > 0) {
|
||||
return tokens.join(" ");
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const OpencodeEditInputSchema = z
|
||||
.intersection(OpencodeWritePathInputSchema, OpencodeEditTextSchema)
|
||||
.transform((value) => ({
|
||||
filePath: value.filePath,
|
||||
oldString:
|
||||
nonEmptyString(value.old_string) ??
|
||||
nonEmptyString(value.old_str) ??
|
||||
nonEmptyString(value.oldContent) ??
|
||||
nonEmptyString(value.old_content),
|
||||
newString:
|
||||
nonEmptyString(value.new_string) ??
|
||||
nonEmptyString(value.new_str) ??
|
||||
nonEmptyString(value.newContent) ??
|
||||
nonEmptyString(value.new_content) ??
|
||||
nonEmptyString(value.content),
|
||||
unifiedDiff: truncateDiffText(
|
||||
nonEmptyString(value.patch) ??
|
||||
nonEmptyString(value.diff) ??
|
||||
nonEmptyString(value.unified_diff) ??
|
||||
nonEmptyString(value.unifiedDiff),
|
||||
MAX_DIFF_TEXT_CHARS
|
||||
),
|
||||
}));
|
||||
|
||||
function resolveFilePath(value: z.infer<typeof OpencodeFileReferenceCollectionSchema>): string | undefined {
|
||||
return firstNonEmpty(
|
||||
value.file_path,
|
||||
value.filePath,
|
||||
value.path,
|
||||
value.target_path,
|
||||
value.targetPath,
|
||||
value.files?.[0]?.path,
|
||||
value.files?.[0]?.filePath,
|
||||
value.files?.[0]?.file_path
|
||||
);
|
||||
}
|
||||
const OpencodeEditOutputFileSchema = z.union([
|
||||
z
|
||||
.object({
|
||||
path: z.string(),
|
||||
patch: z.string().optional(),
|
||||
diff: z.string().optional(),
|
||||
unified_diff: z.string().optional(),
|
||||
unifiedDiff: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.transform((value) => ({
|
||||
filePath: value.path,
|
||||
unifiedDiff: truncateDiffText(
|
||||
nonEmptyString(value.patch) ??
|
||||
nonEmptyString(value.diff) ??
|
||||
nonEmptyString(value.unified_diff) ??
|
||||
nonEmptyString(value.unifiedDiff),
|
||||
MAX_DIFF_TEXT_CHARS
|
||||
),
|
||||
})),
|
||||
z
|
||||
.object({
|
||||
file_path: z.string(),
|
||||
patch: z.string().optional(),
|
||||
diff: z.string().optional(),
|
||||
unified_diff: z.string().optional(),
|
||||
unifiedDiff: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.transform((value) => ({
|
||||
filePath: value.file_path,
|
||||
unifiedDiff: truncateDiffText(
|
||||
nonEmptyString(value.patch) ??
|
||||
nonEmptyString(value.diff) ??
|
||||
nonEmptyString(value.unified_diff) ??
|
||||
nonEmptyString(value.unifiedDiff),
|
||||
MAX_DIFF_TEXT_CHARS
|
||||
),
|
||||
})),
|
||||
z
|
||||
.object({
|
||||
filePath: z.string(),
|
||||
patch: z.string().optional(),
|
||||
diff: z.string().optional(),
|
||||
unified_diff: z.string().optional(),
|
||||
unifiedDiff: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.transform((value) => ({
|
||||
filePath: value.filePath,
|
||||
unifiedDiff: truncateDiffText(
|
||||
nonEmptyString(value.patch) ??
|
||||
nonEmptyString(value.diff) ??
|
||||
nonEmptyString(value.unified_diff) ??
|
||||
nonEmptyString(value.unifiedDiff),
|
||||
MAX_DIFF_TEXT_CHARS
|
||||
),
|
||||
})),
|
||||
]);
|
||||
|
||||
function hashText(value: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash = (hash << 5) - hash + value.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
const OpencodeEditOutputSchema = z.union([
|
||||
z
|
||||
.intersection(OpencodeWritePathInputSchema, OpencodeEditTextSchema)
|
||||
.transform((value) => ({
|
||||
filePath: value.filePath,
|
||||
newString:
|
||||
nonEmptyString(value.newContent) ??
|
||||
nonEmptyString(value.new_content) ??
|
||||
nonEmptyString(value.content),
|
||||
unifiedDiff: truncateDiffText(
|
||||
nonEmptyString(value.patch) ??
|
||||
nonEmptyString(value.diff) ??
|
||||
nonEmptyString(value.unified_diff) ??
|
||||
nonEmptyString(value.unifiedDiff),
|
||||
MAX_DIFF_TEXT_CHARS
|
||||
),
|
||||
})),
|
||||
z
|
||||
.object({ files: z.array(OpencodeEditOutputFileSchema).min(1) })
|
||||
.passthrough()
|
||||
.transform((value) => ({
|
||||
filePath: value.files[0]?.filePath,
|
||||
unifiedDiff: value.files[0]?.unifiedDiff,
|
||||
newString: undefined,
|
||||
})),
|
||||
OpencodeEditTextSchema.transform((value) => ({
|
||||
filePath: undefined,
|
||||
newString:
|
||||
nonEmptyString(value.newContent) ??
|
||||
nonEmptyString(value.new_content) ??
|
||||
nonEmptyString(value.content),
|
||||
unifiedDiff: truncateDiffText(
|
||||
nonEmptyString(value.patch) ??
|
||||
nonEmptyString(value.diff) ??
|
||||
nonEmptyString(value.unified_diff) ??
|
||||
nonEmptyString(value.unifiedDiff),
|
||||
MAX_DIFF_TEXT_CHARS
|
||||
),
|
||||
})),
|
||||
]);
|
||||
|
||||
const OpencodeSearchInputSchema = z.union([
|
||||
z.object({ query: z.string() }).passthrough().transform((value) => ({ query: value.query })),
|
||||
z.object({ q: z.string() }).passthrough().transform((value) => ({ query: value.q })),
|
||||
]);
|
||||
|
||||
function flattenReadContent(
|
||||
value: z.infer<typeof OpencodeReadContentSchema> | undefined
|
||||
): string | undefined {
|
||||
return flattenToolReadContent(value);
|
||||
}
|
||||
|
||||
function coerceCallId(callId: string | null | undefined, toolName: string, input: unknown): string {
|
||||
if (typeof callId === "string" && callId.trim().length > 0) {
|
||||
return callId;
|
||||
}
|
||||
|
||||
let serialized = "";
|
||||
try {
|
||||
serialized = JSON.stringify(input) ?? "";
|
||||
} catch {
|
||||
serialized = String(input);
|
||||
}
|
||||
|
||||
return `opencode-${hashText(`${toolName}:${serialized}`)}`;
|
||||
return coerceToolCallId({
|
||||
providerPrefix: "opencode",
|
||||
rawCallId: callId,
|
||||
toolName,
|
||||
input,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveStatus(rawStatus: unknown, error: unknown, output: unknown): ToolCallTimelineItem["status"] {
|
||||
@@ -305,214 +499,127 @@ function resolveStatus(rawStatus: unknown, error: unknown, output: unknown): Too
|
||||
return output !== null && output !== undefined ? "completed" : "running";
|
||||
}
|
||||
|
||||
function resolveShellDetail(input: unknown, output: unknown): ToolCallDetail | undefined {
|
||||
const parsedInput = OpencodeShellInputSchema.safeParse(input);
|
||||
const parsedOutput = OpencodeShellOutputSchema.safeParse(output);
|
||||
|
||||
const command =
|
||||
(parsedInput.success
|
||||
? commandFromValue(parsedInput.data.command) ?? commandFromValue(parsedInput.data.cmd)
|
||||
: undefined) ??
|
||||
(parsedOutput.success && typeof parsedOutput.data !== "string"
|
||||
? parsedOutput.data.command
|
||||
: undefined);
|
||||
|
||||
function toShellDetail(
|
||||
input: z.infer<typeof OpencodeShellInputSchema> | null,
|
||||
output: z.infer<typeof OpencodeShellOutputSchema> | null
|
||||
): ToolCallDetail | undefined {
|
||||
const command = input?.command ?? output?.command;
|
||||
if (!command) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const cwd = parsedInput.success
|
||||
? firstNonEmpty(parsedInput.data.cwd, parsedInput.data.directory)
|
||||
: undefined;
|
||||
|
||||
const outputText =
|
||||
parsedOutput.success
|
||||
? typeof parsedOutput.data === "string"
|
||||
? parsedOutput.data
|
||||
: firstNonEmpty(
|
||||
parsedOutput.data.output,
|
||||
parsedOutput.data.text,
|
||||
parsedOutput.data.content,
|
||||
parsedOutput.data.structuredContent?.output,
|
||||
parsedOutput.data.structuredContent?.text,
|
||||
parsedOutput.data.structuredContent?.content,
|
||||
parsedOutput.data.structured_content?.output,
|
||||
parsedOutput.data.structured_content?.text,
|
||||
parsedOutput.data.structured_content?.content,
|
||||
parsedOutput.data.result?.output,
|
||||
parsedOutput.data.result?.text,
|
||||
parsedOutput.data.result?.content
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const exitCode =
|
||||
parsedOutput.success && typeof parsedOutput.data !== "string"
|
||||
? parsedOutput.data.exitCode ?? parsedOutput.data.exit_code ?? parsedOutput.data.metadata?.exitCode ?? parsedOutput.data.metadata?.exit_code ?? null
|
||||
: null;
|
||||
|
||||
return {
|
||||
type: "shell",
|
||||
command,
|
||||
...(cwd !== undefined ? { cwd } : {}),
|
||||
...(outputText !== undefined ? { output: outputText } : {}),
|
||||
...(exitCode !== null ? { exitCode } : { exitCode: null }),
|
||||
...(input?.cwd ? { cwd: input.cwd } : {}),
|
||||
...(output?.output ? { output: output.output } : {}),
|
||||
...(output?.exitCode !== undefined ? { exitCode: output.exitCode } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveReadDetail(input: unknown, output: unknown): ToolCallDetail | undefined {
|
||||
const parsedInput = OpencodeReadInputSchema.safeParse(input);
|
||||
const parsedOutput = OpencodeReadOutputSchema.safeParse(output);
|
||||
|
||||
const filePath = firstNonEmpty(
|
||||
parsedInput.success ? resolveFilePath(parsedInput.data) : undefined,
|
||||
parsedOutput.success && typeof parsedOutput.data !== "string"
|
||||
? resolveFilePath(parsedOutput.data)
|
||||
: undefined
|
||||
);
|
||||
|
||||
if (!filePath) {
|
||||
function toReadDetail(
|
||||
input: z.infer<typeof OpencodeReadPathInputSchema> | null,
|
||||
output: z.infer<typeof OpencodeReadOutputSchema> | null
|
||||
): ToolCallDetail | undefined {
|
||||
if (!input?.filePath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const content =
|
||||
parsedOutput.success
|
||||
? typeof parsedOutput.data === "string"
|
||||
? parsedOutput.data
|
||||
: firstNonEmpty(
|
||||
parsedOutput.data.content,
|
||||
parsedOutput.data.text,
|
||||
parsedOutput.data.output,
|
||||
parsedOutput.data.structuredContent?.content,
|
||||
parsedOutput.data.structuredContent?.text,
|
||||
parsedOutput.data.structuredContent?.output,
|
||||
parsedOutput.data.structured_content?.content,
|
||||
parsedOutput.data.structured_content?.text,
|
||||
parsedOutput.data.structured_content?.output,
|
||||
parsedOutput.data.data?.content,
|
||||
parsedOutput.data.data?.text,
|
||||
parsedOutput.data.data?.output
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
type: "read",
|
||||
filePath,
|
||||
...(content !== undefined ? { content } : {}),
|
||||
...(parsedInput.success && parsedInput.data.offset !== undefined ? { offset: parsedInput.data.offset } : {}),
|
||||
...(parsedInput.success && parsedInput.data.limit !== undefined ? { limit: parsedInput.data.limit } : {}),
|
||||
filePath: input.filePath,
|
||||
...(output?.content ? { content: output.content } : {}),
|
||||
...(input.offset !== undefined ? { offset: input.offset } : {}),
|
||||
...(input.limit !== undefined ? { limit: input.limit } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveWriteDetail(input: unknown, output: unknown): ToolCallDetail | undefined {
|
||||
const parsedInput = OpencodeWriteInputSchema.safeParse(input);
|
||||
const parsedOutput = OpencodeWriteOutputSchema.safeParse(output);
|
||||
|
||||
const filePath = firstNonEmpty(
|
||||
parsedInput.success ? resolveFilePath(parsedInput.data) : undefined,
|
||||
parsedOutput.success ? resolveFilePath(parsedOutput.data) : undefined
|
||||
);
|
||||
|
||||
function toWriteDetail(
|
||||
input: z.infer<typeof OpencodeWriteInputSchema> | null,
|
||||
output: z.infer<typeof OpencodeWriteOutputSchema> | null
|
||||
): ToolCallDetail | undefined {
|
||||
const filePath = input?.filePath ?? output?.filePath;
|
||||
if (!filePath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const content = firstNonEmpty(
|
||||
parsedInput.success
|
||||
? firstNonEmpty(parsedInput.data.content, parsedInput.data.newContent, parsedInput.data.new_content)
|
||||
: undefined,
|
||||
parsedOutput.success
|
||||
? firstNonEmpty(parsedOutput.data.content, parsedOutput.data.newContent, parsedOutput.data.new_content)
|
||||
: undefined
|
||||
);
|
||||
|
||||
return {
|
||||
type: "write",
|
||||
filePath,
|
||||
...(content !== undefined ? { content } : {}),
|
||||
...(input?.content ? { content: input.content } : output?.content ? { content: output.content } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveEditDetail(input: unknown, output: unknown): ToolCallDetail | undefined {
|
||||
const parsedInput = OpencodeEditInputSchema.safeParse(input);
|
||||
const parsedOutput = OpencodeEditOutputSchema.safeParse(output);
|
||||
|
||||
const filePath = firstNonEmpty(
|
||||
parsedInput.success ? resolveFilePath(parsedInput.data) : undefined,
|
||||
parsedOutput.success ? resolveFilePath(parsedOutput.data) : undefined
|
||||
);
|
||||
|
||||
function toEditDetail(
|
||||
input: z.infer<typeof OpencodeEditInputSchema> | null,
|
||||
output: z.infer<typeof OpencodeEditOutputSchema> | null
|
||||
): ToolCallDetail | undefined {
|
||||
const filePath = input?.filePath ?? output?.filePath;
|
||||
if (!filePath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const oldString = parsedInput.success
|
||||
? firstNonEmpty(
|
||||
parsedInput.data.old_string,
|
||||
parsedInput.data.old_str,
|
||||
parsedInput.data.oldContent,
|
||||
parsedInput.data.old_content
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const newString = parsedInput.success
|
||||
? firstNonEmpty(
|
||||
parsedInput.data.new_string,
|
||||
parsedInput.data.new_str,
|
||||
parsedInput.data.newContent,
|
||||
parsedInput.data.new_content,
|
||||
parsedInput.data.content
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const unifiedDiff = firstNonEmpty(
|
||||
parsedInput.success
|
||||
? firstNonEmpty(
|
||||
parsedInput.data.patch,
|
||||
parsedInput.data.diff,
|
||||
parsedInput.data.unified_diff,
|
||||
parsedInput.data.unifiedDiff
|
||||
)
|
||||
: undefined,
|
||||
parsedOutput.success
|
||||
? firstNonEmpty(
|
||||
parsedOutput.data.patch,
|
||||
parsedOutput.data.diff,
|
||||
parsedOutput.data.unified_diff,
|
||||
parsedOutput.data.unifiedDiff,
|
||||
parsedOutput.data.files?.[0]?.patch,
|
||||
parsedOutput.data.files?.[0]?.diff,
|
||||
parsedOutput.data.files?.[0]?.unified_diff,
|
||||
parsedOutput.data.files?.[0]?.unifiedDiff
|
||||
)
|
||||
: undefined
|
||||
);
|
||||
|
||||
return {
|
||||
type: "edit",
|
||||
filePath,
|
||||
...(oldString !== undefined ? { oldString } : {}),
|
||||
...(newString !== undefined ? { newString } : {}),
|
||||
...(unifiedDiff !== undefined ? { unifiedDiff } : {}),
|
||||
...(input?.oldString ? { oldString: input.oldString } : {}),
|
||||
...(input?.newString ? { newString: input.newString } : output?.newString ? { newString: output.newString } : {}),
|
||||
...(input?.unifiedDiff
|
||||
? { unifiedDiff: input.unifiedDiff }
|
||||
: output?.unifiedDiff
|
||||
? { unifiedDiff: output.unifiedDiff }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSearchDetail(input: unknown): ToolCallDetail | undefined {
|
||||
const parsedInput = OpencodeSearchInputSchema.safeParse(input);
|
||||
if (!parsedInput.success) {
|
||||
function toSearchDetail(input: z.infer<typeof OpencodeSearchInputSchema> | null): ToolCallDetail | undefined {
|
||||
if (!input?.query) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const query = firstNonEmpty(parsedInput.data.query, parsedInput.data.q);
|
||||
if (!query) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "search",
|
||||
query,
|
||||
query: input.query,
|
||||
};
|
||||
}
|
||||
|
||||
function opencodeToolBranch<
|
||||
ToolName extends string,
|
||||
InputSchema extends z.ZodTypeAny,
|
||||
OutputSchema extends z.ZodTypeAny,
|
||||
>(
|
||||
toolName: ToolName,
|
||||
inputSchema: InputSchema,
|
||||
outputSchema: OutputSchema,
|
||||
mapper: (
|
||||
input: z.infer<InputSchema> | null,
|
||||
output: z.infer<OutputSchema> | null
|
||||
) => ToolCallDetail | undefined
|
||||
) {
|
||||
return z
|
||||
.object({
|
||||
toolName: z.literal(toolName),
|
||||
input: inputSchema.nullable(),
|
||||
output: outputSchema.nullable(),
|
||||
})
|
||||
.transform(({ input, output }) => mapper(input, output));
|
||||
}
|
||||
|
||||
const OpencodeKnownToolDetailSchema = z.union([
|
||||
opencodeToolBranch("shell", OpencodeShellInputSchema, OpencodeShellOutputSchema, toShellDetail),
|
||||
opencodeToolBranch("bash", OpencodeShellInputSchema, OpencodeShellOutputSchema, toShellDetail),
|
||||
opencodeToolBranch("exec_command", OpencodeShellInputSchema, OpencodeShellOutputSchema, toShellDetail),
|
||||
opencodeToolBranch("read", OpencodeReadPathInputSchema, OpencodeReadOutputSchema, toReadDetail),
|
||||
opencodeToolBranch("read_file", OpencodeReadPathInputSchema, OpencodeReadOutputSchema, toReadDetail),
|
||||
opencodeToolBranch("write", OpencodeWriteInputSchema, OpencodeWriteOutputSchema, toWriteDetail),
|
||||
opencodeToolBranch("write_file", OpencodeWriteInputSchema, OpencodeWriteOutputSchema, toWriteDetail),
|
||||
opencodeToolBranch("create_file", OpencodeWriteInputSchema, OpencodeWriteOutputSchema, toWriteDetail),
|
||||
opencodeToolBranch("edit", OpencodeEditInputSchema, OpencodeEditOutputSchema, toEditDetail),
|
||||
opencodeToolBranch("apply_patch", OpencodeEditInputSchema, OpencodeEditOutputSchema, toEditDetail),
|
||||
opencodeToolBranch("apply_diff", OpencodeEditInputSchema, OpencodeEditOutputSchema, toEditDetail),
|
||||
opencodeToolBranch("search", OpencodeSearchInputSchema, z.unknown(), (input) => toSearchDetail(input)),
|
||||
opencodeToolBranch("web_search", OpencodeSearchInputSchema, z.unknown(), (input) => toSearchDetail(input)),
|
||||
]);
|
||||
|
||||
function deriveDetail(toolName: string, input: unknown, output: unknown): ToolCallDetail | undefined {
|
||||
const parsed = OpencodeKnownToolDetailSchema.safeParse({
|
||||
toolName,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
type ReadChunkLike = {
|
||||
text?: string;
|
||||
content?: string;
|
||||
output?: string;
|
||||
};
|
||||
|
||||
export function nonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export function commandFromValue(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return nonEmptyString(value);
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const tokens = value.filter(
|
||||
(token): token is string => typeof token === "string" && token.length > 0
|
||||
);
|
||||
return tokens.length > 0 ? tokens.join(" ") : undefined;
|
||||
}
|
||||
|
||||
export function flattenReadContent<Chunk extends ReadChunkLike>(
|
||||
value: string | Chunk | Chunk[] | undefined
|
||||
): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return nonEmptyString(value);
|
||||
}
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const parts = value
|
||||
.map(
|
||||
(chunk) =>
|
||||
nonEmptyString(chunk.text) ??
|
||||
nonEmptyString(chunk.content) ??
|
||||
nonEmptyString(chunk.output)
|
||||
)
|
||||
.filter((part): part is string => typeof part === "string");
|
||||
return parts.length > 0 ? parts.join("\n") : undefined;
|
||||
}
|
||||
return (
|
||||
nonEmptyString(value.text) ??
|
||||
nonEmptyString(value.content) ??
|
||||
nonEmptyString(value.output)
|
||||
);
|
||||
}
|
||||
|
||||
export function truncateDiffText(
|
||||
text: string | undefined,
|
||||
maxChars: number = 12_000
|
||||
): string | undefined {
|
||||
if (typeof text !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
if (text.length <= maxChars) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const truncatedCount = text.length - maxChars;
|
||||
return `${text.slice(0, maxChars)}\n...[truncated ${truncatedCount} chars]`;
|
||||
}
|
||||
|
||||
function hashText(value: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash = (hash << 5) - hash + value.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
export function coerceToolCallId(params: {
|
||||
providerPrefix: string;
|
||||
rawCallId: string | null | undefined;
|
||||
toolName: string;
|
||||
input: unknown;
|
||||
}): string {
|
||||
if (typeof params.rawCallId === "string" && params.rawCallId.trim().length > 0) {
|
||||
return params.rawCallId;
|
||||
}
|
||||
|
||||
let serialized = "";
|
||||
try {
|
||||
serialized = JSON.stringify(params.input) ?? "";
|
||||
} catch {
|
||||
serialized = String(params.input);
|
||||
}
|
||||
|
||||
return `${params.providerPrefix}-${hashText(`${params.toolName}:${serialized}`)}`;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentStreamEvent } from "./agent/agent-sdk-types.js";
|
||||
import { serializeAgentStreamEvent } from "./messages.js";
|
||||
|
||||
describe("serializeAgentStreamEvent", () => {
|
||||
test("preserves user_message text as-is", () => {
|
||||
const event = {
|
||||
const event: AgentStreamEvent = {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
@@ -12,10 +13,56 @@ describe("serializeAgentStreamEvent", () => {
|
||||
text: "<paseo-instructions>\nX\n</paseo-instructions>\n\nHello",
|
||||
messageId: "m1",
|
||||
},
|
||||
} as any;
|
||||
};
|
||||
|
||||
const serialized = serializeAgentStreamEvent(event) as any;
|
||||
const serialized = serializeAgentStreamEvent(event);
|
||||
expect(serialized).not.toBeNull();
|
||||
if (!serialized || serialized.type !== "timeline" || serialized.item.type !== "user_message") {
|
||||
throw new Error("Expected timeline.user_message event");
|
||||
}
|
||||
expect(serialized.item.text).toBe(event.item.text);
|
||||
expect(serialized.item.messageId).toBe("m1");
|
||||
});
|
||||
|
||||
test("passes canonical tool_call payloads through unchanged", () => {
|
||||
const event: AgentStreamEvent = {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call_1",
|
||||
name: "shell",
|
||||
status: "running",
|
||||
input: { command: "pwd" },
|
||||
output: null,
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
|
||||
const serialized = serializeAgentStreamEvent(event);
|
||||
expect(serialized).not.toBeNull();
|
||||
if (!serialized || serialized.type !== "timeline" || serialized.item.type !== "tool_call") {
|
||||
throw new Error("Expected timeline.tool_call event");
|
||||
}
|
||||
expect(serialized.item.status).toBe("running");
|
||||
expect(serialized.item.output).toBeNull();
|
||||
expect(serialized.item.error).toBeNull();
|
||||
});
|
||||
|
||||
test("drops invalid legacy tool_call items", () => {
|
||||
const event = {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call_legacy",
|
||||
name: "shell",
|
||||
status: "inProgress",
|
||||
input: { command: "pwd" },
|
||||
},
|
||||
} satisfies unknown;
|
||||
|
||||
const serialized = serializeAgentStreamEvent(event as AgentStreamEvent);
|
||||
expect(serialized).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import type { ManagedAgent } from "./agent/agent-manager.js";
|
||||
import { toAgentPayload } from "./agent/agent-projections.js";
|
||||
import type { AgentStreamEvent } from "./agent/agent-sdk-types.js";
|
||||
@@ -5,8 +6,20 @@ import type {
|
||||
AgentSnapshotPayload,
|
||||
AgentStreamEventPayload,
|
||||
} from "../shared/messages.js";
|
||||
import { AgentStreamEventPayloadSchema as AgentStreamEventPayloadRuntimeSchema } from "../shared/messages.js";
|
||||
|
||||
export * from "../shared/messages.js";
|
||||
type AgentStreamEventPayloadInput = z.input<typeof AgentStreamEventPayloadRuntimeSchema>;
|
||||
|
||||
function validateStreamEventPayload(
|
||||
payload: AgentStreamEventPayloadInput
|
||||
): AgentStreamEventPayload | null {
|
||||
const parsed = AgentStreamEventPayloadRuntimeSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export function serializeAgentSnapshot(
|
||||
agent: ManagedAgent,
|
||||
@@ -17,21 +30,19 @@ export function serializeAgentSnapshot(
|
||||
|
||||
export function serializeAgentStreamEvent(
|
||||
event: AgentStreamEvent
|
||||
): AgentStreamEventPayload {
|
||||
): AgentStreamEventPayload | null {
|
||||
if (event.type === "attention_required") {
|
||||
// Providers may emit attention_required without per-client notification context.
|
||||
// The websocket server emits attention_required with shouldNotify computed per client.
|
||||
// Normalize provider events so they satisfy the shared schema.
|
||||
return {
|
||||
...(event as Omit<AgentStreamEventPayload, "shouldNotify">),
|
||||
return validateStreamEventPayload({
|
||||
type: "attention_required",
|
||||
provider: event.provider,
|
||||
reason: event.reason,
|
||||
timestamp: event.timestamp,
|
||||
shouldNotify: false,
|
||||
} as AgentStreamEventPayload;
|
||||
});
|
||||
}
|
||||
if (event.type !== "timeline") {
|
||||
return event as AgentStreamEventPayload;
|
||||
}
|
||||
if (event.item.type !== "user_message") {
|
||||
return event as AgentStreamEventPayload;
|
||||
}
|
||||
return event as AgentStreamEventPayload;
|
||||
|
||||
return validateStreamEventPayload(event as AgentStreamEventPayloadInput);
|
||||
}
|
||||
|
||||
@@ -663,9 +663,14 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
const serializedEvent = serializeAgentStreamEvent(event.event);
|
||||
if (!serializedEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
agentId: event.agentId,
|
||||
event: serializeAgentStreamEvent(event.event),
|
||||
event: serializedEvent,
|
||||
timestamp: new Date().toISOString(),
|
||||
} as const;
|
||||
|
||||
@@ -4287,14 +4292,22 @@ export class Session {
|
||||
|
||||
private emitAgentTimelineSnapshot(agent: ManagedAgent): number {
|
||||
const timeline = this.agentManager.getTimeline(agent.id);
|
||||
const events = timeline.map((item) => ({
|
||||
event: serializeAgentStreamEvent({
|
||||
const events = timeline.flatMap((item) => {
|
||||
const serializedEvent = serializeAgentStreamEvent({
|
||||
type: "timeline",
|
||||
provider: agent.provider,
|
||||
item,
|
||||
}),
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
});
|
||||
if (!serializedEvent) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
event: serializedEvent,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
this.emit({
|
||||
type: "agent_stream_snapshot",
|
||||
|
||||
@@ -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