Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-02-12 18:04:13 +07:00
17 changed files with 1610 additions and 872 deletions
@@ -177,7 +177,7 @@ export function ToolCallDetailsContent({
if (plainInputText !== null) {
sections.push(
<View key="unknown-plain-text" style={styles.plainTextSection}>
<Text selectable style={styles.scrollText}>{plainInputText}</Text>
<Text selectable style={styles.plainText}>{plainInputText}</Text>
</View>
);
} else {
@@ -298,6 +298,13 @@ const styles = StyleSheet.create((theme) => {
gap: theme.spacing[2],
padding: theme.spacing[3],
},
plainText: {
fontFamily: Fonts.sans,
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
lineHeight: 22,
overflowWrap: "anywhere",
},
sectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
+13 -1
View File
@@ -1,5 +1,14 @@
import type { ComponentType } from "react";
import { Bot, Brain, Eye, Pencil, Search, SquareTerminal, Wrench } from "lucide-react-native";
import {
Bot,
Brain,
Eye,
MicVocal,
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 }>;
@@ -20,6 +29,9 @@ export function resolveToolCallIcon(toolName: string, detail?: ToolCallDetail):
if (lowerName === "thinking" && (!detail || detail.type === "unknown")) {
return Brain;
}
if (lowerName === "speak") {
return MicVocal;
}
if (detail) {
return TOOL_DETAIL_ICONS[detail.type];
@@ -1300,16 +1300,21 @@ class ClaudeAgentSession implements AgentSession {
}
this.activeSidechains.set(parentToolUseId, toolName);
const toolCall = mapClaudeRunningToolCall({
name: "Task",
callId: parentToolUseId,
input: null,
output: null,
metadata: { subAgentActivity: toolName },
});
if (!toolCall) {
return [];
}
return [
{
type: "timeline",
item: mapClaudeRunningToolCall({
name: "Task",
callId: parentToolUseId,
input: null,
output: null,
metadata: { subAgentActivity: toolName },
}),
item: toolCall,
provider: "claude",
},
];
@@ -1497,14 +1502,14 @@ class ClaudeAgentSession implements AgentSession {
if (toolName === "ExitPlanMode" && typeof input.plan === "string") {
metadata.planText = input.plan;
}
const detail =
const toolDetail =
kind === "tool"
? mapClaudeRunningToolCall({
name: toolName,
callId: options.toolUseID ?? requestId,
input,
output: null,
}).detail
})?.detail
: undefined;
const request: AgentPermissionRequest = {
@@ -1513,7 +1518,7 @@ class ClaudeAgentSession implements AgentSession {
name: toolName,
kind,
input,
detail,
detail: toolDetail,
suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })),
metadata: Object.keys(metadata).length ? metadata : undefined,
};
@@ -1591,9 +1596,12 @@ class ClaudeAgentSession implements AgentSession {
}
private pushToolCall(
item: Extract<AgentTimelineItem, { type: "tool_call" }>,
item: Extract<AgentTimelineItem, { type: "tool_call" }> | null,
target?: AgentTimelineItem[]
) {
if (!item) {
return;
}
if (target) {
target.push(item);
return;
@@ -19,15 +19,58 @@ import {
toolDetailBranchByName,
} from "../tool-call-detail-primitives.js";
const ClaudeKnownToolDetailSchema = z.union([
const ClaudeToolEnvelopeSchema = z
.object({
name: z.string().min(1),
input: z.unknown().nullable(),
output: z.unknown().nullable(),
})
.passthrough();
const ClaudeSpeakToolDetailSchema = z
.object({
name: z.literal("speak"),
input: z
.union([
z.string().transform((text) => ({ text })),
z.object({ text: z.string() }).passthrough(),
])
.nullable(),
output: z.unknown().nullable(),
})
.transform(({ input }) => {
const text = input?.text?.trim() ?? "";
if (!text) {
return undefined;
}
return {
type: "unknown",
input: text,
output: null,
} satisfies ToolCallDetail;
});
const ClaudeToolDetailPass2Schema = z.union([
toolDetailBranchByName("Bash", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByName("bash", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByName("shell", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByName("exec_command", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByName("Read", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
toolDetailBranchByName("read", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
toolDetailBranchByName("read_file", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
toolDetailBranchByName("view_file", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
toolDetailBranchByName("Read", ToolReadInputSchema, z.unknown(), (input, output) => {
const parsedOutput = ToolReadOutputSchema.safeParse(output);
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
}),
toolDetailBranchByName("read", ToolReadInputSchema, z.unknown(), (input, output) => {
const parsedOutput = ToolReadOutputSchema.safeParse(output);
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
}),
toolDetailBranchByName("read_file", ToolReadInputSchema, z.unknown(), (input, output) => {
const parsedOutput = ToolReadOutputSchema.safeParse(output);
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
}),
toolDetailBranchByName("view_file", ToolReadInputSchema, z.unknown(), (input, output) => {
const parsedOutput = ToolReadOutputSchema.safeParse(output);
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
}),
toolDetailBranchByName("Write", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
toolDetailBranchByName("write", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
toolDetailBranchByName("write_file", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
@@ -38,7 +81,12 @@ const ClaudeKnownToolDetailSchema = z.union([
toolDetailBranchByName("edit", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
toolDetailBranchByName("apply_patch", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
toolDetailBranchByName("apply_diff", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
toolDetailBranchByName("str_replace_editor", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
toolDetailBranchByName(
"str_replace_editor",
ToolEditInputSchema,
ToolEditOutputSchema,
toEditToolDetail
),
toolDetailBranchByName("WebSearch", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input)
),
@@ -48,6 +96,7 @@ const ClaudeKnownToolDetailSchema = z.union([
toolDetailBranchByName("search", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input)
),
ClaudeSpeakToolDetailSchema,
]);
export function deriveClaudeToolDetail(
@@ -55,17 +104,27 @@ export function deriveClaudeToolDetail(
input: unknown,
output: unknown
): ToolCallDetail {
const parsed = ClaudeKnownToolDetailSchema.safeParse({
const pass1 = ClaudeToolEnvelopeSchema.safeParse({
name,
input,
output,
});
if (parsed.success && parsed.data) {
return parsed.data;
}
return {
type: "unknown",
input: input ?? null,
output: output ?? null,
});
if (!pass1.success) {
return {
type: "unknown",
input: input ?? null,
output: output ?? null,
};
}
const pass2 = ClaudeToolDetailPass2Schema.safeParse(pass1.data);
if (pass2.success && pass2.data) {
return pass2.data;
}
return {
type: "unknown",
input: pass1.data.input,
output: pass1.data.output,
};
}
@@ -6,14 +6,24 @@ import {
mapClaudeRunningToolCall,
} from "./tool-call-mapper.js";
function expectMapped<T>(item: T | null): T {
expect(item).toBeTruthy();
if (!item) {
throw new Error("Expected mapped tool call");
}
return item;
}
describe("claude tool-call mapper", () => {
it("maps running shell calls with canonical fields", () => {
const item = mapClaudeRunningToolCall({
callId: "claude-call-1",
name: "Bash",
input: { command: "pwd", cwd: "/tmp/repo" },
output: null,
});
const item = expectMapped(
mapClaudeRunningToolCall({
callId: "claude-call-1",
name: "Bash",
input: { command: "pwd", cwd: "/tmp/repo" },
output: null,
})
);
expect(item.type).toBe("tool_call");
expect(item.status).toBe("running");
@@ -27,45 +37,53 @@ 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,
});
const readItem = expectMapped(
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,
});
const writeItem = expectMapped(
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,
});
const editItem = expectMapped(
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,
});
const searchItem = expectMapped(
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",
@@ -73,12 +91,14 @@ describe("claude tool-call mapper", () => {
});
it("maps completed read calls with detail enrichment", () => {
const item = mapClaudeCompletedToolCall({
callId: "claude-call-2",
name: "read_file",
input: { file_path: "README.md" },
output: { content: "hello" },
});
const item = expectMapped(
mapClaudeCompletedToolCall({
callId: "claude-call-2",
name: "read_file",
input: { file_path: "README.md" },
output: { content: "hello" },
})
);
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
@@ -91,33 +111,37 @@ 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" },
],
},
});
const arrayContent = expectMapped(
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" },
const objectContent = expectMapped(
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") {
@@ -126,13 +150,15 @@ describe("claude tool-call mapper", () => {
});
it("maps failed calls with required error", () => {
const item = mapClaudeFailedToolCall({
callId: "claude-call-3",
name: "shell",
input: { command: "false" },
output: null,
error: { message: "Command failed" },
});
const item = expectMapped(
mapClaudeFailedToolCall({
callId: "claude-call-3",
name: "shell",
input: { command: "false" },
output: null,
error: { message: "Command failed" },
})
);
expect(item.status).toBe("failed");
expect(item.error).toEqual({ message: "Command failed" });
@@ -140,35 +166,41 @@ describe("claude tool-call mapper", () => {
});
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,
});
const writeItem = expectMapped(
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,
});
const editItem = expectMapped(
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,
});
const searchItem = expectMapped(
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",
@@ -176,12 +208,14 @@ describe("claude tool-call mapper", () => {
});
it("maps unknown tools to unknown detail with raw payloads", () => {
const item = mapClaudeCompletedToolCall({
callId: "claude-call-4",
name: "my_custom_tool",
input: { foo: "bar" },
output: { ok: true },
});
const item = expectMapped(
mapClaudeCompletedToolCall({
callId: "claude-call-4",
name: "my_custom_tool",
input: { foo: "bar" },
output: { ok: true },
})
);
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
@@ -191,4 +225,33 @@ describe("claude tool-call mapper", () => {
output: { ok: true },
});
});
it("normalizes claude speak tool names through schema transforms", () => {
const item = expectMapped(
mapClaudeCompletedToolCall({
callId: "claude-speak-1",
name: "mcp__paseo__speak",
input: { text: "Voice response from Claude." },
output: { ok: true },
})
);
expect(item.name).toBe("speak");
expect(item.detail).toEqual({
type: "unknown",
input: "Voice response from Claude.",
output: null,
});
});
it("drops tool calls when callId is missing", () => {
const item = mapClaudeCompletedToolCall({
callId: null,
name: "read_file",
input: { file_path: "README.md" },
output: { content: "hello" },
});
expect(item).toBeNull();
});
});
@@ -1,7 +1,6 @@
import { z } from "zod";
import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
import { coerceToolCallId } from "../tool-call-mapper-utils.js";
import { deriveClaudeToolDetail } from "./tool-call-detail-parser.js";
type MapperParams = {
@@ -12,87 +11,170 @@ type MapperParams = {
metadata?: Record<string, unknown>;
};
const ClaudeMapperParamsSchema = z
const ClaudeToolCallStatusSchema = z.enum([
"running",
"completed",
"failed",
"canceled",
]);
const ClaudeRawToolCallSchema = z
.object({
callId: z.string().optional().nullable(),
name: z.string().min(1),
input: z.unknown().optional(),
output: z.unknown().optional(),
metadata: z.record(z.unknown()).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
error: z.unknown().nullable().optional(),
status: ClaudeToolCallStatusSchema,
})
.passthrough();
const ClaudeFailedMapperParamsSchema = ClaudeMapperParamsSchema.extend({
error: z.unknown(),
const ClaudeToolCallPass1Schema = ClaudeRawToolCallSchema.transform((raw) => ({
callId:
typeof raw.callId === "string" && raw.callId.trim().length > 0
? raw.callId
: null,
name: raw.name.trim(),
input: raw.input ?? null,
output: raw.output ?? null,
metadata: raw.metadata,
error: raw.error ?? null,
status: raw.status,
}));
const ClaudeToolCallPass2BaseSchema = z.object({
callId: z.string().min(1),
name: z.string().min(1),
input: z.unknown().nullable(),
output: z.unknown().nullable(),
metadata: z.record(z.string(), z.unknown()).optional(),
error: z.unknown().nullable(),
status: ClaudeToolCallStatusSchema,
toolKind: z.enum(["speak", "other"]),
});
function coerceCallId(callId: string | null | undefined, name: string, input: unknown): string {
return coerceToolCallId({
providerPrefix: "claude",
rawCallId: callId,
toolName: name,
input,
const ClaudeToolCallPass2InputSchema = ClaudeToolCallPass2BaseSchema.omit({
toolKind: true,
});
const ClaudeToolCallPass2EnvelopeSchema = z.union([
ClaudeToolCallPass2InputSchema.extend({
name: z.literal("mcp__paseo__speak"),
}).transform((normalized) => ({
...normalized,
name: normalized.name.trim(),
toolKind: "speak" as const,
})),
ClaudeToolCallPass2InputSchema.transform((normalized) => ({
...normalized,
name: normalized.name.trim(),
toolKind: "other" as const,
})),
]);
const ClaudeToolCallPass2Schema = z.discriminatedUnion("toolKind", [
ClaudeToolCallPass2BaseSchema.extend({
toolKind: z.literal("speak"),
name: z.literal("mcp__paseo__speak"),
}).transform((normalized): ToolCallTimelineItem => {
const name = "speak" as const;
const detail = deriveClaudeToolDetail(name, normalized.input, normalized.output);
if (normalized.status === "failed") {
return {
type: "tool_call",
callId: normalized.callId,
name,
detail,
status: "failed",
error: normalized.error ?? { message: "Tool call failed" },
...(normalized.metadata ? { metadata: normalized.metadata } : {}),
};
}
return {
type: "tool_call",
callId: normalized.callId,
name,
detail,
status: normalized.status,
error: null,
...(normalized.metadata ? { metadata: normalized.metadata } : {}),
};
}),
ClaudeToolCallPass2BaseSchema.extend({
toolKind: z.literal("other"),
}).transform((normalized): ToolCallTimelineItem => {
const detail = deriveClaudeToolDetail(normalized.name, normalized.input, normalized.output);
if (normalized.status === "failed") {
return {
type: "tool_call",
callId: normalized.callId,
name: normalized.name,
detail,
status: "failed",
error: normalized.error ?? { message: "Tool call failed" },
...(normalized.metadata ? { metadata: normalized.metadata } : {}),
};
}
return {
type: "tool_call",
callId: normalized.callId,
name: normalized.name,
detail,
status: normalized.status,
error: null,
...(normalized.metadata ? { metadata: normalized.metadata } : {}),
};
}),
]);
function mapClaudeToolCall(
params: MapperParams,
status: z.infer<typeof ClaudeToolCallStatusSchema>,
error: unknown | null
): ToolCallTimelineItem | null {
const pass1 = ClaudeToolCallPass1Schema.safeParse({
...params,
status,
error,
});
if (!pass1.success) {
return null;
}
const pass2Envelope = ClaudeToolCallPass2EnvelopeSchema.safeParse(pass1.data);
if (!pass2Envelope.success) {
return null;
}
const pass2 = ClaudeToolCallPass2Schema.safeParse(pass2Envelope.data);
if (!pass2.success) {
return null;
}
return pass2.data;
}
function buildBase(params: MapperParams): {
callId: string;
name: string;
detail: Extract<ToolCallTimelineItem, { type: "tool_call" }>["detail"];
metadata?: Record<string, unknown>;
} {
const parsedParams = ClaudeMapperParamsSchema.parse(params);
const input = parsedParams.input ?? null;
const output = parsedParams.output ?? null;
const detail = deriveClaudeToolDetail(parsedParams.name, input, output);
return {
callId: coerceCallId(parsedParams.callId, parsedParams.name, input),
name: parsedParams.name,
detail,
...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
};
export function mapClaudeRunningToolCall(
params: MapperParams
): ToolCallTimelineItem | null {
return mapClaudeToolCall(params, "running", null);
}
export function mapClaudeRunningToolCall(params: MapperParams): ToolCallTimelineItem {
const base = buildBase(params);
return {
type: "tool_call",
...base,
status: "running",
error: null,
};
}
export function mapClaudeCompletedToolCall(params: MapperParams): ToolCallTimelineItem {
const base = buildBase(params);
return {
type: "tool_call",
...base,
status: "completed",
error: null,
};
export function mapClaudeCompletedToolCall(
params: MapperParams
): ToolCallTimelineItem | null {
return mapClaudeToolCall(params, "completed", null);
}
export function mapClaudeFailedToolCall(
params: MapperParams & { error: unknown }
): ToolCallTimelineItem {
const parsedParams = ClaudeFailedMapperParamsSchema.parse(params);
const base = buildBase(parsedParams);
return {
type: "tool_call",
...base,
status: "failed",
error: parsedParams.error,
};
): ToolCallTimelineItem | null {
return mapClaudeToolCall(params, "failed", params.error);
}
export function mapClaudeCanceledToolCall(params: MapperParams): ToolCallTimelineItem {
const base = buildBase(params);
return {
type: "tool_call",
...base,
status: "canceled",
error: null,
};
export function mapClaudeCanceledToolCall(
params: MapperParams
): ToolCallTimelineItem | null {
return mapClaudeToolCall(params, "canceled", null);
}
@@ -982,6 +982,9 @@ function mapCodexExecNotificationToToolCall(params: {
: null,
cwd: params.cwd ?? null,
});
if (!mapped) {
return null;
}
return params.running ? toRunningToolCall(mapped) : mapped;
}
@@ -993,7 +996,7 @@ function mapCodexPatchNotificationToToolCall(params: {
stderr?: string | null;
success?: boolean | null;
running: boolean;
}): ToolCallTimelineItem {
}): ToolCallTimelineItem | null {
const files = parseCodexPatchChanges(params.changes);
const firstPath = files[0]?.path;
const firstPatchText = files
@@ -1038,6 +1041,9 @@ function mapCodexPatchNotificationToToolCall(params: {
: { message: params.stderr?.trim() || "Patch apply failed" },
cwd: params.cwd ?? null,
});
if (!mapped) {
return null;
}
return params.running ? toRunningToolCall(mapped) : mapped;
}
@@ -2565,11 +2571,13 @@ class CodexAppServerAgentSession implements AgentSession {
cwd: this.config.cwd ?? null,
running: true,
});
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_started", {
callId: parsed.callId,
changes: parsed.changes,
});
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
if (timelineItem) {
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_started", {
callId: parsed.callId,
changes: parsed.changes,
});
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
}
return;
}
@@ -2587,12 +2595,14 @@ class CodexAppServerAgentSession implements AgentSession {
success: parsed.success,
running: false,
});
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_completed", {
callId: parsed.callId,
changes: parsed.changes,
stdout: parsed.stdout,
});
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
if (timelineItem) {
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_completed", {
callId: parsed.callId,
changes: parsed.changes,
stdout: parsed.stdout,
});
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
}
return;
}
@@ -503,14 +503,15 @@ export async function parseRolloutFile(
record.kind === "timeline"
? [record.item]
: record.kind === "call"
? [
mapCodexRolloutToolCall({
? (() => {
const mapped = mapCodexRolloutToolCall({
callId: record.callId ?? null,
name: record.name,
input: record.input ?? null,
output: record.callId ? outputsByCallId.get(record.callId) ?? null : null,
}),
]
});
return mapped ? [mapped] : [];
})()
: []
);
return dedupeMirroredTextTimelineItems(timeline);
@@ -24,23 +24,43 @@ export type CodexToolDetailContext = {
cwd?: string | null;
};
export const CODEX_BUILTIN_TOOL_NAMES = new Set([
"shell",
"bash",
"exec",
"exec_command",
"command",
"read",
"read_file",
"write",
"write_file",
"create_file",
"edit",
"apply_patch",
"apply_diff",
"web_search",
"search",
]);
const CodexToolEnvelopeSchema = z
.object({
name: z.string().min(1),
input: z.unknown().nullable(),
output: z.unknown().nullable(),
cwd: z.string().nullable().optional(),
})
.passthrough();
const CodexSpeakToolDetailSchema = z
.object({
name: z.literal("speak"),
input: z
.union([
z.string().transform((text) => ({ text })),
z.object({ text: z.string() }).passthrough(),
])
.nullable(),
output: z.unknown().nullable(),
cwd: z.string().nullable().optional(),
})
.transform(({ input }) => {
const text = input?.text?.trim() ?? "";
if (!text) {
return undefined;
}
return {
type: "unknown",
input: text,
output: null,
} satisfies ToolCallDetail;
});
const CodexLooseEditOutputSchema = z.unknown().transform((value) => {
const parsed = ToolEditOutputSchema.safeParse(value);
return parsed.success ? parsed.data : null;
});
export function normalizeCodexFilePath(
filePath: string,
@@ -60,7 +80,7 @@ function normalizePathForCwd(cwd: string | null): (filePath: string) => string |
return (filePath) => normalizeCodexFilePath(filePath, cwd);
}
const CodexKnownToolDetailSchema = z.union([
const CodexToolDetailPass2Schema = z.union([
toolDetailBranchByNameWithCwd("Bash", ToolShellInputSchema, ToolShellOutputSchema, (input, output) =>
toShellToolDetail(input, output)
),
@@ -82,14 +102,22 @@ const CodexKnownToolDetailSchema = z.union([
toolDetailBranchByNameWithCwd("command", ToolShellInputSchema, ToolShellOutputSchema, (input, output) =>
toShellToolDetail(input, output)
),
toolDetailBranchByNameWithCwd("read", ToolReadInputSchema, ToolReadOutputWithPathSchema, (input, output, cwd) =>
toReadToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
),
toolDetailBranchByNameWithCwd("read", ToolReadInputSchema, z.unknown(), (input, output, cwd) => {
const parsedOutput = ToolReadOutputWithPathSchema.safeParse(output);
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null, {
normalizePath: normalizePathForCwd(cwd),
});
}),
toolDetailBranchByNameWithCwd(
"read_file",
ToolReadInputSchema,
ToolReadOutputWithPathSchema,
(input, output, cwd) => toReadToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
z.unknown(),
(input, output, cwd) => {
const parsedOutput = ToolReadOutputWithPathSchema.safeParse(output);
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null, {
normalizePath: normalizePathForCwd(cwd),
});
}
),
toolDetailBranchByNameWithCwd("write", ToolWriteInputSchema, ToolWriteOutputSchema, (input, output, cwd) =>
toWriteToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
@@ -106,19 +134,19 @@ const CodexKnownToolDetailSchema = z.union([
ToolWriteOutputSchema,
(input, output, cwd) => toWriteToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
),
toolDetailBranchByNameWithCwd("edit", ToolEditInputSchema, ToolEditOutputSchema, (input, output, cwd) =>
toolDetailBranchByNameWithCwd("edit", ToolEditInputSchema, CodexLooseEditOutputSchema, (input, output, cwd) =>
toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
),
toolDetailBranchByNameWithCwd(
"apply_patch",
ToolEditInputSchema,
ToolEditOutputSchema,
CodexLooseEditOutputSchema,
(input, output, cwd) => toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
),
toolDetailBranchByNameWithCwd(
"apply_diff",
ToolEditInputSchema,
ToolEditOutputSchema,
CodexLooseEditOutputSchema,
(input, output, cwd) => toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
),
toolDetailBranchByNameWithCwd("search", ToolSearchInputSchema, z.unknown(), (input) =>
@@ -127,6 +155,7 @@ const CodexKnownToolDetailSchema = z.union([
toolDetailBranchByNameWithCwd("web_search", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input)
),
CodexSpeakToolDetailSchema,
]);
export function deriveCodexToolDetail(params: {
@@ -135,18 +164,28 @@ export function deriveCodexToolDetail(params: {
output: unknown;
cwd?: string | null;
}): ToolCallDetail {
const parsed = CodexKnownToolDetailSchema.safeParse({
const pass1 = CodexToolEnvelopeSchema.safeParse({
name: params.name,
input: params.input,
output: params.output,
cwd: params.cwd ?? null,
});
if (parsed.success && parsed.data) {
return parsed.data;
}
return {
type: "unknown",
input: params.input ?? null,
output: params.output ?? null,
cwd: params.cwd ?? null,
});
if (!pass1.success) {
return {
type: "unknown",
input: params.input ?? null,
output: params.output ?? null,
};
}
const pass2 = CodexToolDetailPass2Schema.safeParse(pass1.data);
if (pass2.success && pass2.data) {
return pass2.data;
}
return {
type: "unknown",
input: pass1.data.input,
output: pass1.data.output,
};
}
@@ -5,6 +5,14 @@ import {
mapCodexToolCallFromThreadItem,
} from "./tool-call-mapper.js";
function expectMapped<T>(item: T | null): T {
expect(item).toBeTruthy();
if (!item) {
throw new Error("Expected mapped tool call");
}
return item;
}
describe("codex tool-call mapper", () => {
it("maps commandExecution start into running canonical call", () => {
const item = mapCodexToolCallFromThreadItem({
@@ -301,12 +309,14 @@ describe("codex tool-call mapper", () => {
});
it("maps unknown tools to unknown detail with raw payloads", () => {
const item = mapCodexRolloutToolCall({
callId: "codex-call-4",
name: "my_custom_tool",
input: { foo: "bar" },
output: { ok: true },
});
const item = expectMapped(
mapCodexRolloutToolCall({
callId: "codex-call-4",
name: "my_custom_tool",
input: { foo: "bar" },
output: { ok: true },
})
);
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
@@ -327,13 +337,15 @@ describe("codex tool-call mapper", () => {
"+new",
"*** End Patch",
].join("\n");
const item = mapCodexRolloutToolCall({
callId: "codex-call-apply",
name: "apply_patch",
input: patch,
output: '{"output":"Success. Updated the following files:\\nM src/index.ts\\n"}',
cwd: "/tmp/repo",
});
const item = expectMapped(
mapCodexRolloutToolCall({
callId: "codex-call-apply",
name: "apply_patch",
input: patch,
output: '{"output":"Success. Updated the following files:\\nM src/index.ts\\n"}',
cwd: "/tmp/repo",
})
);
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
@@ -359,16 +371,18 @@ describe("codex tool-call mapper", () => {
"*** End Patch",
].join("\n");
const item = mapCodexRolloutToolCall({
callId: "codex-call-apply-object",
name: "apply_patch",
input: {
path: "/tmp/repo/src/object.ts",
content: patch,
},
output: null,
cwd: "/tmp/repo",
});
const item = expectMapped(
mapCodexRolloutToolCall({
callId: "codex-call-apply-object",
name: "apply_patch",
input: {
path: "/tmp/repo/src/object.ts",
content: patch,
},
output: null,
cwd: "/tmp/repo",
})
);
expect(item.detail.type).toBe("edit");
if (item.detail.type === "edit") {
@@ -412,58 +426,6 @@ describe("codex tool-call mapper", () => {
}
});
it("maps fileChange patch alias fields into edit unified diff detail", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "codex-file-change-patch-alias",
status: "completed",
changes: [
{
path: "/tmp/repo/src/from-patch-alias.ts",
kind: "modify",
patch: "@@\n-oldAlias\n+newAlias\n",
},
],
},
{ cwd: "/tmp/repo" }
);
expect(item?.detail?.type).toBe("edit");
if (item?.detail?.type === "edit") {
expect(item.detail.filePath).toBe("src/from-patch-alias.ts");
expect(item.detail.unifiedDiff).toContain("-oldAlias");
expect(item.detail.unifiedDiff).toContain("+newAlias");
expect(item.detail.newString).toBeUndefined();
}
});
it("maps fileChange unifiedDiff alias fields into edit unified diff detail", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "codex-file-change-unified-diff-alias",
status: "completed",
changes: [
{
path: "/tmp/repo/src/from-unified-diff-alias.ts",
kind: "modify",
unified_diff: "@@\n-beforeAlias\n+afterAlias\n",
},
],
},
{ cwd: "/tmp/repo" }
);
expect(item?.detail?.type).toBe("edit");
if (item?.detail?.type === "edit") {
expect(item.detail.filePath).toBe("src/from-unified-diff-alias.ts");
expect(item.detail.unifiedDiff).toContain("-beforeAlias");
expect(item.detail.unifiedDiff).toContain("+afterAlias");
expect(item.detail.newString).toBeUndefined();
}
});
it("maps path-only fileChange payloads to unknown detail instead of empty edit detail", () => {
const item = mapCodexToolCallFromThreadItem(
{
@@ -484,17 +446,80 @@ describe("codex tool-call mapper", () => {
});
it("maps path-only apply_patch rollout payloads to unknown detail instead of empty edit detail", () => {
const item = mapCodexRolloutToolCall({
callId: "codex-call-apply-path-only",
name: "apply_patch",
input: { path: "/tmp/repo/src/path-only-rollout.ts" },
output: { files: [{ path: "/tmp/repo/src/path-only-rollout.ts", kind: "modify" }] },
cwd: "/tmp/repo",
});
const item = expectMapped(
mapCodexRolloutToolCall({
callId: "codex-call-apply-path-only",
name: "apply_patch",
input: { path: "/tmp/repo/src/path-only-rollout.ts" },
output: { files: [{ path: "/tmp/repo/src/path-only-rollout.ts", kind: "modify" }] },
cwd: "/tmp/repo",
})
);
expect(item.detail.type).toBe("unknown");
if (item.detail.type === "unknown") {
expect(item.detail.input).toEqual({ path: "/tmp/repo/src/path-only-rollout.ts" });
}
});
it("normalizes codex paseo speak mcp calls and extracts spoken text", () => {
const item = mapCodexToolCallFromThreadItem({
type: "mcpToolCall",
id: "codex-speak-thread-1",
status: "completed",
server: "paseo",
tool: "speak",
arguments: { text: "Voice response from Codex." },
result: { ok: true },
});
expect(item).toBeTruthy();
expect(item?.name).toBe("speak");
expect(item?.detail).toEqual({
type: "unknown",
input: "Voice response from Codex.",
output: null,
});
});
it("normalizes codex paseo speak rollout names and extracts spoken text", () => {
const item = expectMapped(
mapCodexRolloutToolCall({
callId: "codex-speak-rollout-1",
name: "paseo.speak",
input: { text: "Rollout speech text." },
output: { ok: true },
})
);
expect(item.name).toBe("speak");
expect(item.detail).toEqual({
type: "unknown",
input: "Rollout speech text.",
output: null,
});
});
it("drops rollout tool calls when callId is missing", () => {
const item = mapCodexRolloutToolCall({
callId: null,
name: "read_file",
input: { path: "/tmp/repo/README.md" },
output: { content: "hello" },
});
expect(item).toBeNull();
});
it("drops thread mcp tool calls when id is missing", () => {
const item = mapCodexToolCallFromThreadItem({
type: "mcpToolCall",
status: "completed",
tool: "read_file",
arguments: { path: "/tmp/repo/README.md" },
result: { content: "hello" },
});
expect(item).toBeNull();
});
});
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,7 @@ import { execSync, spawn, type ChildProcess } from "node:child_process";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client";
import net from "node:net";
import type { Logger } from "pino";
import { z } from "zod";
import type {
AgentCapabilityFlags,
@@ -69,6 +70,84 @@ type OpenCodeMcpConfig =
const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const;
const OpencodeToolStateSchema = z
.object({
status: z.string().optional(),
input: z.unknown().optional(),
output: z.unknown().optional(),
error: z.unknown().optional(),
})
.passthrough();
const OpencodeToolPartBaseSchema = z
.object({
tool: z.string().trim().min(1),
state: OpencodeToolStateSchema.optional(),
})
.passthrough();
const OpencodeToolPartWithCallIdSchema = OpencodeToolPartBaseSchema.extend({
callID: z.string().trim().min(1),
id: z.string().optional(),
}).transform((part) => ({
toolName: part.tool,
callId: part.callID,
status: part.state?.status,
input: part.state?.input,
output: part.state?.output,
error: part.state?.error,
}));
const OpencodeToolPartWithIdSchema = OpencodeToolPartBaseSchema.extend({
id: z.string().trim().min(1),
callID: z.string().optional(),
}).transform((part) => ({
toolName: part.tool,
callId: part.id,
status: part.state?.status,
input: part.state?.input,
output: part.state?.output,
error: part.state?.error,
}));
const OpencodeToolPartWithoutIdSchema = OpencodeToolPartBaseSchema.extend({
id: z.string().optional(),
callID: z.string().optional(),
}).transform((part) => ({
toolName: part.tool,
callId: undefined,
status: part.state?.status,
input: part.state?.input,
output: part.state?.output,
error: part.state?.error,
}));
const OpencodeToolPartSchema = z.union([
OpencodeToolPartWithCallIdSchema,
OpencodeToolPartWithIdSchema,
OpencodeToolPartWithoutIdSchema,
]);
const OpencodeToolPartTimelineEnvelopeSchema = OpencodeToolPartSchema.transform((part) => ({
toolName: part.toolName,
callId: part.callId,
status: part.status,
input: part.input,
output: part.output,
error: part.error,
}));
const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSchema.transform((part) =>
mapOpencodeToolCall({
toolName: part.toolName,
callId: part.callId,
status: part.status,
input: part.input,
output: part.output,
error: part.error,
})
);
function resolveOpenCodeBinary(): string {
try {
const opencodePath = execSync("which opencode", { encoding: "utf8" }).trim();
@@ -650,33 +729,15 @@ class OpenCodeAgentSession implements AgentSession {
};
}
} else if (partType === "tool") {
const toolPart = part as {
id?: string;
tool?: string;
callID?: string;
state?: {
status?: string;
input?: AgentMetadata;
output?: string;
error?: string;
};
};
const toolName = toolPart.tool;
const state = toolPart.state;
if (toolName) {
yield {
type: "timeline",
provider: "opencode",
item: mapOpencodeToolCall({
toolName,
callId: toolPart.callID ?? toolPart.id,
status: state?.status,
input: state?.input,
output: state?.output,
error: state?.error,
}),
};
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
if (parsedToolPart.success) {
if (parsedToolPart.data) {
yield {
type: "timeline",
provider: "opencode",
item: parsedToolPart.data,
};
}
}
}
}
@@ -931,26 +992,15 @@ class OpenCodeAgentSession implements AgentSession {
}
} else if (partType === "tool") {
// Tool parts: { tool: string, state: { status, input, output?, error? } }
const toolName = part.tool as string | undefined;
const state = part.state as AgentMetadata | undefined;
const status = state?.status as string | undefined;
const input = state?.input as AgentMetadata | undefined;
const output = state?.output as string | undefined;
const error = state?.error as string | undefined;
if (toolName) {
events.push({
type: "timeline",
provider: "opencode",
item: mapOpencodeToolCall({
toolName,
callId: (part.callID as string | undefined) ?? (part.id as string | undefined),
status,
input,
output,
error,
}),
});
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
if (parsedToolPart.success) {
if (parsedToolPart.data) {
events.push({
type: "timeline",
provider: "opencode",
item: parsedToolPart.data,
});
}
}
} else if (partType === "step-finish") {
// Extract usage from step-finish parts
@@ -23,8 +23,14 @@ const OpencodeKnownToolDetailSchema = z.union([
toolDetailBranchByToolName("shell", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByToolName("bash", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByToolName("exec_command", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByToolName("read", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
toolDetailBranchByToolName("read_file", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
toolDetailBranchByToolName("read", ToolReadInputSchema, z.unknown(), (input, output) => {
const parsedOutput = ToolReadOutputSchema.safeParse(output);
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
}),
toolDetailBranchByToolName("read_file", ToolReadInputSchema, z.unknown(), (input, output) => {
const parsedOutput = ToolReadOutputSchema.safeParse(output);
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
}),
toolDetailBranchByToolName("write", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
toolDetailBranchByToolName("write_file", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
toolDetailBranchByToolName("create_file", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
@@ -2,15 +2,25 @@ import { describe, expect, it } from "vitest";
import { mapOpencodeToolCall } from "./tool-call-mapper.js";
function expectMapped<T>(item: T | null): T {
expect(item).toBeTruthy();
if (!item) {
throw new Error("Expected mapped tool call");
}
return item;
}
describe("opencode tool-call mapper", () => {
it("maps running shell calls", () => {
const item = mapOpencodeToolCall({
toolName: "shell",
callId: "opencode-call-1",
status: "running",
input: { command: "pwd", cwd: "/tmp/repo" },
output: null,
});
const item = expectMapped(
mapOpencodeToolCall({
toolName: "shell",
callId: "opencode-call-1",
status: "running",
input: { command: "pwd", cwd: "/tmp/repo" },
output: null,
})
);
expect(item.status).toBe("running");
expect(item.error).toBeNull();
@@ -22,49 +32,57 @@ 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,
});
const readItem = expectMapped(
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,
});
const writeItem = expectMapped(
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,
});
const editItem = expectMapped(
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,
});
const searchItem = expectMapped(
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",
@@ -72,13 +90,15 @@ describe("opencode tool-call mapper", () => {
});
it("maps completed read calls", () => {
const item = mapOpencodeToolCall({
toolName: "read_file",
callId: "opencode-call-2",
status: "complete",
input: { file_path: "README.md" },
output: { content: "hello" },
});
const item = expectMapped(
mapOpencodeToolCall({
toolName: "read_file",
callId: "opencode-call-2",
status: "complete",
input: { file_path: "README.md" },
output: { content: "hello" },
})
);
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
@@ -91,35 +111,39 @@ 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" },
],
},
});
const arrayContent = expectMapped(
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" },
const objectContent = expectMapped(
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") {
@@ -128,14 +152,16 @@ describe("opencode tool-call mapper", () => {
});
it("maps failed calls with required error", () => {
const item = mapOpencodeToolCall({
toolName: "shell",
callId: "opencode-call-3",
status: "error",
input: { command: "false" },
output: null,
error: "command failed",
});
const item = expectMapped(
mapOpencodeToolCall({
toolName: "shell",
callId: "opencode-call-3",
status: "error",
input: { command: "false" },
output: null,
error: "command failed",
})
);
expect(item.status).toBe("failed");
expect(item.error).toBe("command failed");
@@ -143,38 +169,44 @@ describe("opencode tool-call mapper", () => {
});
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,
});
const writeItem = expectMapped(
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,
});
const editItem = expectMapped(
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,
});
const searchItem = expectMapped(
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",
@@ -182,13 +214,15 @@ describe("opencode tool-call mapper", () => {
});
it("maps unknown tools to unknown detail with raw payloads", () => {
const item = mapOpencodeToolCall({
toolName: "my_custom_tool",
callId: "opencode-call-4",
status: "completed",
input: { foo: "bar" },
output: { ok: true },
});
const item = expectMapped(
mapOpencodeToolCall({
toolName: "my_custom_tool",
callId: "opencode-call-4",
status: "completed",
input: { foo: "bar" },
output: { ok: true },
})
);
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
@@ -198,4 +232,35 @@ describe("opencode tool-call mapper", () => {
output: { ok: true },
});
});
it("does not apply cross-provider speak normalization in opencode mapper", () => {
const item = expectMapped(
mapOpencodeToolCall({
toolName: "paseo_voice.speak",
callId: "opencode-call-voice-1",
status: "completed",
input: { text: "Voice response from OpenCode." },
output: { ok: true },
})
);
expect(item.name).toBe("paseo_voice.speak");
expect(item.detail).toEqual({
type: "unknown",
input: { text: "Voice response from OpenCode." },
output: { ok: true },
});
});
it("drops tool calls when callId is missing", () => {
const item = mapOpencodeToolCall({
toolName: "read_file",
callId: null,
status: "completed",
input: { file_path: "README.md" },
output: { content: "hello" },
});
expect(item).toBeNull();
});
});
@@ -1,7 +1,6 @@
import { z } from "zod";
import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
import { coerceToolCallId } from "../tool-call-mapper-utils.js";
import { deriveOpencodeToolDetail } from "./tool-call-detail-parser.js";
type OpencodeToolCallParams = {
@@ -18,7 +17,14 @@ 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"]);
const OpencodeToolCallParamsSchema = z
const OpencodeToolCallStatusSchema = z.enum([
"running",
"completed",
"failed",
"canceled",
]);
const OpencodeRawToolCallSchema = z
.object({
toolName: z.string().min(1),
callId: z.string().optional().nullable(),
@@ -26,74 +32,163 @@ const OpencodeToolCallParamsSchema = z
input: z.unknown().optional(),
output: z.unknown().optional(),
error: z.unknown().optional(),
metadata: z.record(z.unknown()).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})
.passthrough();
function coerceCallId(callId: string | null | undefined, toolName: string, input: unknown): string {
return coerceToolCallId({
providerPrefix: "opencode",
rawCallId: callId,
toolName,
input,
});
}
const OpencodeNormalizedToolCallPass1Schema = OpencodeRawToolCallSchema.transform((raw) => {
const input = raw.input ?? null;
const output = raw.output ?? null;
const error = raw.error ?? null;
const callId =
typeof raw.callId === "string" && raw.callId.trim().length > 0
? raw.callId.trim()
: null;
let status: z.infer<typeof OpencodeToolCallStatusSchema>;
function resolveStatus(
rawStatus: unknown,
error: unknown,
output: unknown
): ToolCallTimelineItem["status"] {
if (error !== null && error !== undefined) {
return "failed";
}
if (typeof rawStatus === "string") {
const normalized = rawStatus.trim().toLowerCase();
if (normalized.length > 0) {
if (FAILED_STATUSES.has(normalized)) {
return "failed";
}
if (CANCELED_STATUSES.has(normalized)) {
return "canceled";
}
if (COMPLETED_STATUSES.has(normalized)) {
return "completed";
}
return "running";
if (error !== null) {
status = "failed";
} else if (typeof raw.status === "string") {
const normalized = raw.status.trim().toLowerCase();
if (FAILED_STATUSES.has(normalized)) {
status = "failed";
} else if (CANCELED_STATUSES.has(normalized)) {
status = "canceled";
} else if (COMPLETED_STATUSES.has(normalized)) {
status = "completed";
} else {
status = "running";
}
}
return output !== null && output !== undefined ? "completed" : "running";
}
export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTimelineItem {
const parsedParams = OpencodeToolCallParamsSchema.parse(params);
const input = parsedParams.input ?? null;
const output = parsedParams.output ?? null;
const status = resolveStatus(parsedParams.status, parsedParams.error, output);
const callId = coerceCallId(parsedParams.callId, parsedParams.toolName, input);
const detail = deriveOpencodeToolDetail(parsedParams.toolName, input, output);
if (status === "failed") {
return {
type: "tool_call",
callId,
name: parsedParams.toolName,
status: "failed",
detail,
error: parsedParams.error ?? { message: "Tool call failed" },
...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
};
} else {
status = output !== null ? "completed" : "running";
}
return {
type: "tool_call",
callId,
name: parsedParams.toolName,
name: raw.toolName.trim(),
input,
output,
error,
metadata: raw.metadata,
status,
detail,
error: null,
...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
};
});
const OpencodeKnownToolNameSchema = z.union([
z.literal("shell"),
z.literal("bash"),
z.literal("exec_command"),
z.literal("read"),
z.literal("read_file"),
z.literal("write"),
z.literal("write_file"),
z.literal("create_file"),
z.literal("edit"),
z.literal("apply_patch"),
z.literal("apply_diff"),
z.literal("search"),
z.literal("web_search"),
]);
const OpencodeToolCallPass2BaseSchema = z.object({
callId: z.string().min(1),
name: z.string().min(1),
input: z.unknown().nullable(),
output: z.unknown().nullable(),
error: z.unknown().nullable(),
metadata: z.record(z.string(), z.unknown()).optional(),
status: OpencodeToolCallStatusSchema,
toolKind: z.enum(["known", "other"]),
});
const OpencodeToolCallPass2InputSchema = OpencodeToolCallPass2BaseSchema.omit({
toolKind: true,
});
const OpencodeToolCallPass2EnvelopeSchema = z.union([
OpencodeToolCallPass2InputSchema.extend({
name: OpencodeKnownToolNameSchema,
}).transform((normalized) => ({
...normalized,
name: normalized.name.trim(),
toolKind: "known" as const,
})),
OpencodeToolCallPass2InputSchema.transform((normalized) => ({
...normalized,
name: normalized.name.trim(),
toolKind: "other" as const,
})),
]);
const OpencodeToolCallPass2Schema = z.discriminatedUnion("toolKind", [
OpencodeToolCallPass2BaseSchema.extend({
toolKind: z.literal("known"),
name: OpencodeKnownToolNameSchema,
}).transform((normalized): ToolCallTimelineItem => {
const detail = deriveOpencodeToolDetail(normalized.name, normalized.input, normalized.output);
if (normalized.status === "failed") {
return {
type: "tool_call",
callId: normalized.callId,
name: normalized.name,
status: "failed",
detail,
error: normalized.error ?? { message: "Tool call failed" },
...(normalized.metadata ? { metadata: normalized.metadata } : {}),
};
}
return {
type: "tool_call",
callId: normalized.callId,
name: normalized.name,
status: normalized.status,
detail,
error: null,
...(normalized.metadata ? { metadata: normalized.metadata } : {}),
};
}),
OpencodeToolCallPass2BaseSchema.extend({
toolKind: z.literal("other"),
}).transform((normalized): ToolCallTimelineItem => {
const detail = deriveOpencodeToolDetail(normalized.name, normalized.input, normalized.output);
if (normalized.status === "failed") {
return {
type: "tool_call",
callId: normalized.callId,
name: normalized.name,
status: "failed",
detail,
error: normalized.error ?? { message: "Tool call failed" },
...(normalized.metadata ? { metadata: normalized.metadata } : {}),
};
}
return {
type: "tool_call",
callId: normalized.callId,
name: normalized.name,
status: normalized.status,
detail,
error: null,
...(normalized.metadata ? { metadata: normalized.metadata } : {}),
};
}),
]);
export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTimelineItem | null {
const pass1 = OpencodeNormalizedToolCallPass1Schema.safeParse(params);
if (!pass1.success) {
return null;
}
const pass2Envelope = OpencodeToolCallPass2EnvelopeSchema.safeParse(pass1.data);
if (!pass2Envelope.success) {
return null;
}
const pass2 = OpencodeToolCallPass2Schema.safeParse(pass2Envelope.data);
if (!pass2.success) {
return null;
}
return pass2.data;
}
@@ -2,7 +2,6 @@ import { z } from "zod";
import type { ToolCallDetail } from "../agent-sdk-types.js";
import {
commandFromValue,
extractCodexShellOutput,
flattenReadContent as flattenToolReadContent,
nonEmptyString,
@@ -29,9 +28,19 @@ export const ToolShellInputSchema = z
.passthrough(),
])
.transform((value) => {
const commandValue = "command" in value ? value.command : value.cmd;
const parsedCommand = CommandValueSchema.safeParse(
"command" in value ? value.command : value.cmd
);
const command = parsedCommand.success
? typeof parsedCommand.data === "string"
? nonEmptyString(parsedCommand.data)
: parsedCommand.data
.map((token) => token.trim())
.filter((token) => token.length > 0)
.join(" ") || undefined
: undefined;
return {
command: commandFromValue(commandValue),
command,
cwd: nonEmptyString(value.cwd) ?? nonEmptyString(value.directory),
};
});
@@ -314,17 +323,12 @@ type ToolReadOutputValue = {
content?: string;
};
export const ToolReadOutputSchema: z.ZodType<
ToolReadOutputValue,
z.ZodTypeDef,
unknown
> = ToolReadOutputContentSchema;
export const ToolReadOutputSchema = ToolReadOutputContentSchema;
export const ToolReadOutputWithPathSchema: z.ZodType<
ToolReadOutputValue,
z.ZodTypeDef,
unknown
> = z.union([ToolReadOutputContentSchema, ToolReadOutputPathSchema]);
export const ToolReadOutputWithPathSchema = z.union([
ToolReadOutputContentSchema,
ToolReadOutputPathSchema,
]);
export const ToolWriteContentSchema = z
.object({
@@ -508,8 +512,8 @@ export const ToolSearchInputSchema = z.union([
export type ParsedToolShellInput = z.infer<typeof ToolShellInputSchema>;
export type ParsedToolShellOutput = z.infer<typeof ToolShellOutputSchema>;
export type ParsedToolReadInput = z.infer<typeof ToolReadInputSchema>;
export type ParsedToolReadOutput = z.infer<typeof ToolReadOutputSchema>;
export type ParsedToolReadOutputWithPath = z.infer<typeof ToolReadOutputWithPathSchema>;
export type ParsedToolReadOutput = ToolReadOutputValue;
export type ParsedToolReadOutputWithPath = ToolReadOutputValue;
export type ParsedToolWriteInput = z.infer<typeof ToolWriteInputSchema>;
export type ParsedToolWriteOutput = z.infer<typeof ToolWriteOutputSchema>;
export type ParsedToolEditInput = z.infer<typeof ToolEditInputSchema>;
@@ -649,7 +653,13 @@ export function toolDetailBranchByName<
input: inputSchema.nullable(),
output: outputSchema.nullable(),
})
.transform(({ input, output }) => mapper(input, output));
.transform((value) => {
const parsed = value as unknown as {
input: z.infer<InputSchema> | null;
output: z.infer<OutputSchema> | null;
};
return mapper(parsed.input, parsed.output);
});
}
export function toolDetailBranchByToolName<
@@ -671,7 +681,13 @@ export function toolDetailBranchByToolName<
input: inputSchema.nullable(),
output: outputSchema.nullable(),
})
.transform(({ input, output }) => mapper(input, output));
.transform((value) => {
const parsed = value as unknown as {
input: z.infer<InputSchema> | null;
output: z.infer<OutputSchema> | null;
};
return mapper(parsed.input, parsed.output);
});
}
export function toolDetailBranchByNameWithCwd<
@@ -695,5 +711,12 @@ export function toolDetailBranchByNameWithCwd<
output: outputSchema.nullable(),
cwd: z.string().optional().nullable(),
})
.transform(({ input, output, cwd }) => mapper(input, output, cwd ?? null));
.transform((value) => {
const parsed = value as unknown as {
input: z.infer<InputSchema> | null;
output: z.infer<OutputSchema> | null;
cwd?: string | null;
};
return mapper(parsed.input, parsed.output, parsed.cwd ?? null);
});
}
@@ -8,19 +8,6 @@ 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;
}
const CODEX_SHELL_ENVELOPE_HEADER_LINES = new Set([
"chunk id:",
"wall time:",