refactor: simplify tool call display and path handling
- Remove displayName from tool calls, use name directly with icon - Add medium font weight for tool call badges - Strip workspace root from file paths at parsing level using Zod schema - Add accentBorder to tool call badges and git diff file blocks - Fix user message alignment and vertical spacing - Deduplicate stripCwdPrefix into tool-call-parsers.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
4f3b2317ab
commit
66881511b6
@@ -283,15 +283,11 @@ export function AgentStreamView({
|
|||||||
case "tool_call": {
|
case "tool_call": {
|
||||||
const { payload } = item;
|
const { payload } = item;
|
||||||
|
|
||||||
console.log("[TOOL_CALL_DEBUG]", JSON.stringify(payload, null, 2));
|
|
||||||
|
|
||||||
if (payload.source === "agent") {
|
if (payload.source === "agent") {
|
||||||
const data = payload.data;
|
const data = payload.data;
|
||||||
const toolLabel = data.displayName ?? `${data.server}/${data.tool}`;
|
|
||||||
content = (
|
content = (
|
||||||
<ToolCall
|
<ToolCall
|
||||||
toolName={toolLabel}
|
toolName={data.name}
|
||||||
kind={data.kind}
|
|
||||||
args={data.input}
|
args={data.input}
|
||||||
result={data.result}
|
result={data.result}
|
||||||
error={data.error}
|
error={data.error}
|
||||||
@@ -299,6 +295,7 @@ export function AgentStreamView({
|
|||||||
parsedEditEntries={data.parsedEdits}
|
parsedEditEntries={data.parsedEdits}
|
||||||
parsedReadEntries={data.parsedReads}
|
parsedReadEntries={data.parsedReads}
|
||||||
parsedCommandDetails={data.parsedCommand ?? null}
|
parsedCommandDetails={data.parsedCommand ?? null}
|
||||||
|
cwd={agent.cwd}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
backgroundColor: theme.colors.muted,
|
backgroundColor: theme.colors.muted,
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.accentBorder,
|
||||||
marginBottom: theme.spacing[2],
|
marginBottom: theme.spacing[2],
|
||||||
},
|
},
|
||||||
fileHeader: {
|
fileHeader: {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { Colors } from "@/constants/theme";
|
|||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import type { TodoEntry, ThoughtStatus } from "@/types/stream";
|
import type { TodoEntry, ThoughtStatus } from "@/types/stream";
|
||||||
import type { CommandDetails, EditEntry, ReadEntry } from "@/utils/tool-call-parsers";
|
import type { CommandDetails, EditEntry, ReadEntry } from "@/utils/tool-call-parsers";
|
||||||
|
import { extractPrincipalParam } from "@/utils/tool-call-parsers";
|
||||||
import { resolveToolCallPreview } from "./tool-call-preview";
|
import { resolveToolCallPreview } from "./tool-call-preview";
|
||||||
import { useToolCallSheet } from "./tool-call-sheet";
|
import { useToolCallSheet } from "./tool-call-sheet";
|
||||||
|
|
||||||
@@ -41,15 +42,16 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
container: {
|
container: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
marginBottom: theme.spacing[3],
|
marginBottom: theme.spacing[4],
|
||||||
paddingHorizontal: theme.spacing[4],
|
marginTop: theme.spacing[4],
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
},
|
},
|
||||||
bubble: {
|
bubble: {
|
||||||
backgroundColor: theme.colors.muted,
|
backgroundColor: theme.colors.muted,
|
||||||
borderRadius: theme.borderRadius["2xl"],
|
borderRadius: theme.borderRadius["2xl"],
|
||||||
borderTopRightRadius: theme.borderRadius.sm,
|
borderTopRightRadius: theme.borderRadius.sm,
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
paddingVertical: theme.spacing[3],
|
paddingVertical: theme.spacing[4],
|
||||||
maxWidth: "80%",
|
maxWidth: "80%",
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
@@ -187,7 +189,7 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
|||||||
pressable: {
|
pressable: {
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.accentBorder,
|
||||||
backgroundColor: theme.colors.secondary,
|
backgroundColor: theme.colors.secondary,
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
@@ -209,13 +211,24 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
secondaryLabel: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
color: theme.colors.mutedForeground,
|
color: theme.colors.mutedForeground,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
marginLeft: theme.spacing[2],
|
||||||
|
},
|
||||||
|
spacer: {
|
||||||
|
flex: 1,
|
||||||
},
|
},
|
||||||
chevron: {
|
chevron: {
|
||||||
marginLeft: theme.spacing[1],
|
marginLeft: theme.spacing[1],
|
||||||
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
detailWrapper: {
|
detailWrapper: {
|
||||||
marginTop: theme.spacing[2],
|
marginTop: theme.spacing[2],
|
||||||
@@ -829,6 +842,7 @@ interface AgentThoughtMessageProps {
|
|||||||
|
|
||||||
interface ExpandableBadgeProps {
|
interface ExpandableBadgeProps {
|
||||||
label: string;
|
label: string;
|
||||||
|
secondaryLabel?: string;
|
||||||
icon?: ComponentType<{ size?: number; color?: string }>;
|
icon?: ComponentType<{ size?: number; color?: string }>;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
@@ -839,6 +853,7 @@ interface ExpandableBadgeProps {
|
|||||||
|
|
||||||
const ExpandableBadge = memo(function ExpandableBadge({
|
const ExpandableBadge = memo(function ExpandableBadge({
|
||||||
label,
|
label,
|
||||||
|
secondaryLabel,
|
||||||
icon,
|
icon,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
@@ -914,6 +929,13 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
<Text style={expandableBadgeStylesheet.label} numberOfLines={1}>
|
<Text style={expandableBadgeStylesheet.label} numberOfLines={1}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
|
{secondaryLabel ? (
|
||||||
|
<Text style={expandableBadgeStylesheet.secondaryLabel} numberOfLines={1}>
|
||||||
|
{secondaryLabel}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<View style={expandableBadgeStylesheet.spacer} />
|
||||||
|
)}
|
||||||
{hasDetails ? (
|
{hasDetails ? (
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
size={14}
|
size={14}
|
||||||
@@ -1084,7 +1106,6 @@ export function AgentThoughtMessage({ message, status = "ready" }: AgentThoughtM
|
|||||||
|
|
||||||
interface ToolCallProps {
|
interface ToolCallProps {
|
||||||
toolName: string;
|
toolName: string;
|
||||||
kind?: string; // Optional kind for ACP tool calls
|
|
||||||
args: any;
|
args: any;
|
||||||
result?: any;
|
result?: any;
|
||||||
error?: any;
|
error?: any;
|
||||||
@@ -1092,6 +1113,7 @@ interface ToolCallProps {
|
|||||||
parsedEditEntries?: EditEntry[];
|
parsedEditEntries?: EditEntry[];
|
||||||
parsedReadEntries?: ReadEntry[];
|
parsedReadEntries?: ReadEntry[];
|
||||||
parsedCommandDetails?: CommandDetails | null;
|
parsedCommandDetails?: CommandDetails | null;
|
||||||
|
cwd?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Icon mapping for tool kinds
|
// Icon mapping for tool kinds
|
||||||
@@ -1102,9 +1124,19 @@ const toolKindIcons: Record<string, any> = {
|
|||||||
search: Search,
|
search: Search,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Derive tool kind from tool name for icon selection
|
||||||
|
function getToolKindFromName(toolName: string): string {
|
||||||
|
const lower = toolName.toLowerCase();
|
||||||
|
if (lower === "read" || lower === "read_file" || lower.startsWith("read")) return "read";
|
||||||
|
if (lower === "edit" || lower === "write" || lower === "apply_patch") return "edit";
|
||||||
|
if (lower === "bash" || lower === "shell") return "execute";
|
||||||
|
if (lower === "grep" || lower === "glob" || lower === "web_search") return "search";
|
||||||
|
return "tool";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const ToolCall = memo(function ToolCall({
|
export const ToolCall = memo(function ToolCall({
|
||||||
toolName,
|
toolName,
|
||||||
kind,
|
|
||||||
args,
|
args,
|
||||||
result,
|
result,
|
||||||
error,
|
error,
|
||||||
@@ -1112,12 +1144,18 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
parsedEditEntries,
|
parsedEditEntries,
|
||||||
parsedReadEntries,
|
parsedReadEntries,
|
||||||
parsedCommandDetails,
|
parsedCommandDetails,
|
||||||
|
cwd,
|
||||||
}: ToolCallProps) {
|
}: ToolCallProps) {
|
||||||
const { openToolCall } = useToolCallSheet();
|
const { openToolCall } = useToolCallSheet();
|
||||||
|
|
||||||
const IconComponent = kind
|
const kind = getToolKindFromName(toolName);
|
||||||
? toolKindIcons[kind.toLowerCase()] || Wrench
|
const IconComponent = toolKindIcons[kind] || Wrench;
|
||||||
: Wrench;
|
|
||||||
|
// Extract principal param for secondary label (memoized)
|
||||||
|
const principalParam = useMemo(
|
||||||
|
() => extractPrincipalParam(args, cwd),
|
||||||
|
[args, cwd]
|
||||||
|
);
|
||||||
|
|
||||||
// Check if there's any content to display in the sheet
|
// Check if there's any content to display in the sheet
|
||||||
const hasDetails = args !== undefined || result !== undefined || error !== undefined;
|
const hasDetails = args !== undefined || result !== undefined || error !== undefined;
|
||||||
@@ -1133,8 +1171,9 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
parsedEditEntries,
|
parsedEditEntries,
|
||||||
parsedReadEntries,
|
parsedReadEntries,
|
||||||
parsedCommandDetails,
|
parsedCommandDetails,
|
||||||
|
cwd,
|
||||||
});
|
});
|
||||||
}, [openToolCall, toolName, kind, status, args, result, error, parsedEditEntries, parsedReadEntries, parsedCommandDetails]);
|
}, [openToolCall, toolName, kind, status, args, result, error, parsedEditEntries, parsedReadEntries, parsedCommandDetails, cwd]);
|
||||||
|
|
||||||
// Dummy renderDetails to make badge tappable - actual content is rendered in the sheet
|
// Dummy renderDetails to make badge tappable - actual content is rendered in the sheet
|
||||||
const dummyRenderDetails = useCallback(() => null, []);
|
const dummyRenderDetails = useCallback(() => null, []);
|
||||||
@@ -1142,6 +1181,7 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
return (
|
return (
|
||||||
<ExpandableBadge
|
<ExpandableBadge
|
||||||
label={toolName}
|
label={toolName}
|
||||||
|
secondaryLabel={principalParam}
|
||||||
icon={IconComponent}
|
icon={IconComponent}
|
||||||
isExpanded={false}
|
isExpanded={false}
|
||||||
onToggle={handleOpenSheet}
|
onToggle={handleOpenSheet}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Pencil, Eye, SquareTerminal, Search, Wrench, X } from "lucide-react-nat
|
|||||||
import { DiffViewer } from "./diff-viewer";
|
import { DiffViewer } from "./diff-viewer";
|
||||||
import {
|
import {
|
||||||
extractKeyValuePairs,
|
extractKeyValuePairs,
|
||||||
|
stripCwdPrefix,
|
||||||
type EditEntry,
|
type EditEntry,
|
||||||
type ReadEntry,
|
type ReadEntry,
|
||||||
type CommandDetails,
|
type CommandDetails,
|
||||||
@@ -36,6 +37,7 @@ export interface ToolCallSheetData {
|
|||||||
parsedEditEntries?: EditEntry[];
|
parsedEditEntries?: EditEntry[];
|
||||||
parsedReadEntries?: ReadEntry[];
|
parsedReadEntries?: ReadEntry[];
|
||||||
parsedCommandDetails?: CommandDetails | null;
|
parsedCommandDetails?: CommandDetails | null;
|
||||||
|
cwd?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToolCallSheetContextValue {
|
interface ToolCallSheetContextValue {
|
||||||
@@ -234,6 +236,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
|||||||
parsedEditEntries,
|
parsedEditEntries,
|
||||||
parsedReadEntries,
|
parsedReadEntries,
|
||||||
parsedCommandDetails,
|
parsedCommandDetails,
|
||||||
|
cwd,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
const IconComponent = kind
|
const IconComponent = kind
|
||||||
@@ -453,7 +456,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
|||||||
<Text style={styles.sectionTitle}>Diff</Text>
|
<Text style={styles.sectionTitle}>Diff</Text>
|
||||||
{entry.filePath ? (
|
{entry.filePath ? (
|
||||||
<View style={styles.fileBadge}>
|
<View style={styles.fileBadge}>
|
||||||
<Text style={styles.fileBadgeText}>{entry.filePath}</Text>
|
<Text style={styles.fileBadgeText}>{stripCwdPrefix(entry.filePath, cwd)}</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
<View style={styles.diffContainer}>
|
<View style={styles.diffContainer}>
|
||||||
@@ -478,7 +481,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
|||||||
<Text style={styles.sectionTitle}>Read Result</Text>
|
<Text style={styles.sectionTitle}>Read Result</Text>
|
||||||
{entry.filePath ? (
|
{entry.filePath ? (
|
||||||
<View style={styles.fileBadge}>
|
<View style={styles.fileBadge}>
|
||||||
<Text style={styles.fileBadgeText}>{entry.filePath}</Text>
|
<Text style={styles.fileBadgeText}>{stripCwdPrefix(entry.filePath, cwd)}</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ const commonTheme = {
|
|||||||
|
|
||||||
fontWeight: {
|
fontWeight: {
|
||||||
normal: "normal" as const,
|
normal: "normal" as const,
|
||||||
|
medium: "500" as const,
|
||||||
semibold: "600" as const,
|
semibold: "600" as const,
|
||||||
bold: "bold" as const,
|
bold: "bold" as const,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ const toolCallEvent = (): AgentStreamEventPayload => ({
|
|||||||
provider: "codex",
|
provider: "codex",
|
||||||
item: {
|
item: {
|
||||||
type: "tool_call",
|
type: "tool_call",
|
||||||
server: "local",
|
name: "run",
|
||||||
tool: "run",
|
|
||||||
status: "executing",
|
status: "executing",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ const STREAM_HARNESS_LIVE: HarnessUpdate[] = [
|
|||||||
{
|
{
|
||||||
event: buildToolStartEvent({
|
event: buildToolStartEvent({
|
||||||
callId: HARNESS_CALL_IDS.edit,
|
callId: HARNESS_CALL_IDS.edit,
|
||||||
server: "editor",
|
name: "apply_patch",
|
||||||
tool: "apply_patch",
|
|
||||||
input: {
|
input: {
|
||||||
file_path: "README.md",
|
file_path: "README.md",
|
||||||
patch: "*** Begin Patch\n*** Update File: README.md\n@@\n-Old line\n+New line\n*** End Patch",
|
patch: "*** Begin Patch\n*** Update File: README.md\n@@\n-Old line\n+New line\n*** End Patch",
|
||||||
@@ -45,8 +44,7 @@ const STREAM_HARNESS_LIVE: HarnessUpdate[] = [
|
|||||||
{
|
{
|
||||||
event: buildToolResultEvent({
|
event: buildToolResultEvent({
|
||||||
callId: HARNESS_CALL_IDS.edit,
|
callId: HARNESS_CALL_IDS.edit,
|
||||||
server: "editor",
|
name: "apply_patch",
|
||||||
tool: "apply_patch",
|
|
||||||
output: {
|
output: {
|
||||||
changes: [
|
changes: [
|
||||||
{
|
{
|
||||||
@@ -62,8 +60,7 @@ const STREAM_HARNESS_LIVE: HarnessUpdate[] = [
|
|||||||
{
|
{
|
||||||
event: buildToolStartEvent({
|
event: buildToolStartEvent({
|
||||||
callId: HARNESS_CALL_IDS.read,
|
callId: HARNESS_CALL_IDS.read,
|
||||||
server: "editor",
|
name: "read_file",
|
||||||
tool: "read_file",
|
|
||||||
input: { file_path: "README.md" },
|
input: { file_path: "README.md" },
|
||||||
}),
|
}),
|
||||||
timestamp: new Date("2025-02-01T10:00:03Z"),
|
timestamp: new Date("2025-02-01T10:00:03Z"),
|
||||||
@@ -71,8 +68,7 @@ const STREAM_HARNESS_LIVE: HarnessUpdate[] = [
|
|||||||
{
|
{
|
||||||
event: buildToolResultEvent({
|
event: buildToolResultEvent({
|
||||||
callId: HARNESS_CALL_IDS.read,
|
callId: HARNESS_CALL_IDS.read,
|
||||||
server: "editor",
|
name: "read_file",
|
||||||
tool: "read_file",
|
|
||||||
output: { content: "# README\nNew line\n" },
|
output: { content: "# README\nNew line\n" },
|
||||||
}),
|
}),
|
||||||
timestamp: new Date("2025-02-01T10:00:04Z"),
|
timestamp: new Date("2025-02-01T10:00:04Z"),
|
||||||
@@ -80,9 +76,7 @@ const STREAM_HARNESS_LIVE: HarnessUpdate[] = [
|
|||||||
{
|
{
|
||||||
event: buildToolStartEvent({
|
event: buildToolStartEvent({
|
||||||
callId: HARNESS_CALL_IDS.command,
|
callId: HARNESS_CALL_IDS.command,
|
||||||
server: "command",
|
name: "shell",
|
||||||
tool: "shell",
|
|
||||||
kind: "execute",
|
|
||||||
input: { command: "ls" },
|
input: { command: "ls" },
|
||||||
}),
|
}),
|
||||||
timestamp: new Date("2025-02-01T10:00:05Z"),
|
timestamp: new Date("2025-02-01T10:00:05Z"),
|
||||||
@@ -90,8 +84,7 @@ const STREAM_HARNESS_LIVE: HarnessUpdate[] = [
|
|||||||
{
|
{
|
||||||
event: buildToolResultEvent({
|
event: buildToolResultEvent({
|
||||||
callId: HARNESS_CALL_IDS.command,
|
callId: HARNESS_CALL_IDS.command,
|
||||||
server: "command",
|
name: "shell",
|
||||||
tool: "shell",
|
|
||||||
output: {
|
output: {
|
||||||
result: {
|
result: {
|
||||||
command: "ls",
|
command: "ls",
|
||||||
@@ -121,8 +114,7 @@ const STREAM_HARNESS_HYDRATED: HarnessUpdate[] = [
|
|||||||
{
|
{
|
||||||
event: buildToolStartEvent({
|
event: buildToolStartEvent({
|
||||||
callId: HARNESS_CALL_IDS.edit,
|
callId: HARNESS_CALL_IDS.edit,
|
||||||
server: "editor",
|
name: "apply_patch",
|
||||||
tool: "apply_patch",
|
|
||||||
status: "completed",
|
status: "completed",
|
||||||
}),
|
}),
|
||||||
timestamp: new Date("2025-02-01T10:05:01Z"),
|
timestamp: new Date("2025-02-01T10:05:01Z"),
|
||||||
@@ -130,8 +122,7 @@ const STREAM_HARNESS_HYDRATED: HarnessUpdate[] = [
|
|||||||
{
|
{
|
||||||
event: buildToolStartEvent({
|
event: buildToolStartEvent({
|
||||||
callId: HARNESS_CALL_IDS.read,
|
callId: HARNESS_CALL_IDS.read,
|
||||||
server: "editor",
|
name: "read_file",
|
||||||
tool: "read_file",
|
|
||||||
status: "completed",
|
status: "completed",
|
||||||
}),
|
}),
|
||||||
timestamp: new Date("2025-02-01T10:05:02Z"),
|
timestamp: new Date("2025-02-01T10:05:02Z"),
|
||||||
@@ -139,9 +130,7 @@ const STREAM_HARNESS_HYDRATED: HarnessUpdate[] = [
|
|||||||
{
|
{
|
||||||
event: buildToolStartEvent({
|
event: buildToolStartEvent({
|
||||||
callId: HARNESS_CALL_IDS.command,
|
callId: HARNESS_CALL_IDS.command,
|
||||||
server: "command",
|
name: "shell",
|
||||||
tool: "shell",
|
|
||||||
kind: "execute",
|
|
||||||
status: "completed",
|
status: "completed",
|
||||||
}),
|
}),
|
||||||
timestamp: new Date("2025-02-01T10:05:03Z"),
|
timestamp: new Date("2025-02-01T10:05:03Z"),
|
||||||
@@ -173,17 +162,13 @@ describe("stream harness captures hydrated regression", () => {
|
|||||||
|
|
||||||
function buildToolStartEvent({
|
function buildToolStartEvent({
|
||||||
callId,
|
callId,
|
||||||
server,
|
name,
|
||||||
tool,
|
|
||||||
input,
|
input,
|
||||||
kind,
|
|
||||||
status = "executing",
|
status = "executing",
|
||||||
}: {
|
}: {
|
||||||
callId: string;
|
callId: string;
|
||||||
server: string;
|
name: string;
|
||||||
tool: string;
|
|
||||||
input?: Record<string, unknown>;
|
input?: Record<string, unknown>;
|
||||||
kind?: string;
|
|
||||||
status?: ToolCallStatus;
|
status?: ToolCallStatus;
|
||||||
}): AgentStreamEventPayload {
|
}): AgentStreamEventPayload {
|
||||||
return {
|
return {
|
||||||
@@ -191,12 +176,9 @@ function buildToolStartEvent({
|
|||||||
provider: "claude",
|
provider: "claude",
|
||||||
item: {
|
item: {
|
||||||
type: "tool_call",
|
type: "tool_call",
|
||||||
server,
|
name,
|
||||||
tool,
|
|
||||||
status,
|
status,
|
||||||
callId,
|
callId,
|
||||||
displayName: tool,
|
|
||||||
kind,
|
|
||||||
input,
|
input,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -204,13 +186,11 @@ function buildToolStartEvent({
|
|||||||
|
|
||||||
function buildToolResultEvent({
|
function buildToolResultEvent({
|
||||||
callId,
|
callId,
|
||||||
server,
|
name,
|
||||||
tool,
|
|
||||||
output,
|
output,
|
||||||
}: {
|
}: {
|
||||||
callId: string;
|
callId: string;
|
||||||
server: string;
|
name: string;
|
||||||
tool: string;
|
|
||||||
output?: Record<string, unknown>;
|
output?: Record<string, unknown>;
|
||||||
}): AgentStreamEventPayload {
|
}): AgentStreamEventPayload {
|
||||||
return {
|
return {
|
||||||
@@ -218,10 +198,8 @@ function buildToolResultEvent({
|
|||||||
provider: "claude",
|
provider: "claude",
|
||||||
item: {
|
item: {
|
||||||
type: "tool_call",
|
type: "tool_call",
|
||||||
server,
|
name,
|
||||||
tool,
|
|
||||||
callId,
|
callId,
|
||||||
displayName: tool,
|
|
||||||
output,
|
output,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ function reasoningTimeline(text: string, provider: TestAgentProvider = "claude")
|
|||||||
function toolTimeline(
|
function toolTimeline(
|
||||||
id: string,
|
id: string,
|
||||||
status: string,
|
status: string,
|
||||||
raw?: unknown,
|
_raw?: unknown,
|
||||||
options?: { callId?: string | null; provider?: "claude" | "codex"; server?: string; tool?: string; displayName?: string; kind?: string }
|
options?: { callId?: string | null; provider?: "claude" | "codex"; name?: string }
|
||||||
): AgentStreamEventPayload {
|
): AgentStreamEventPayload {
|
||||||
const explicitCallIdProvided =
|
const explicitCallIdProvided =
|
||||||
options && Object.prototype.hasOwnProperty.call(options, "callId");
|
options && Object.prototype.hasOwnProperty.call(options, "callId");
|
||||||
@@ -52,17 +52,15 @@ function toolTimeline(
|
|||||||
: options?.callId
|
: options?.callId
|
||||||
: id;
|
: id;
|
||||||
const provider = options?.provider ?? "claude";
|
const provider = options?.provider ?? "claude";
|
||||||
|
const name = options?.name ?? id;
|
||||||
return {
|
return {
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider,
|
provider,
|
||||||
item: {
|
item: {
|
||||||
type: "tool_call",
|
type: "tool_call",
|
||||||
server: options?.server ?? "terminal",
|
name,
|
||||||
tool: options?.tool ?? id,
|
|
||||||
status,
|
status,
|
||||||
callId: callIdValue,
|
callId: callIdValue,
|
||||||
displayName: options?.displayName ?? id,
|
|
||||||
kind: options?.kind ?? "execute",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -73,12 +71,9 @@ function permissionTimeline(id: string, status: string): AgentStreamEventPayload
|
|||||||
provider: "claude",
|
provider: "claude",
|
||||||
item: {
|
item: {
|
||||||
type: "tool_call",
|
type: "tool_call",
|
||||||
server: "permission",
|
name: "permission_request",
|
||||||
tool: "permission_request",
|
|
||||||
status,
|
status,
|
||||||
callId: id,
|
callId: id,
|
||||||
displayName: "Permission",
|
|
||||||
kind: "permission",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -188,8 +183,7 @@ function testToolCallStatusInference() {
|
|||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
item: {
|
item: {
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
server: 'editor',
|
name: 'read',
|
||||||
tool: 'read',
|
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
callId: toolCallId,
|
callId: toolCallId,
|
||||||
},
|
},
|
||||||
@@ -200,8 +194,7 @@ function testToolCallStatusInference() {
|
|||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
item: {
|
item: {
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
server: 'editor',
|
name: 'read',
|
||||||
tool: 'read',
|
|
||||||
callId: toolCallId,
|
callId: toolCallId,
|
||||||
output: { content: 'Hello world' },
|
output: { content: 'Hello world' },
|
||||||
},
|
},
|
||||||
@@ -234,8 +227,7 @@ function testToolCallStatusInferenceFromRawOnly() {
|
|||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
item: {
|
item: {
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
server: 'command',
|
name: 'shell',
|
||||||
tool: 'shell',
|
|
||||||
callId: toolCallId,
|
callId: toolCallId,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
output: { metadata: { exit_code: 0 } },
|
output: { metadata: { exit_code: 0 } },
|
||||||
@@ -257,8 +249,7 @@ function testToolCallFailureInferenceFromError() {
|
|||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
item: {
|
item: {
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
server: 'command',
|
name: 'shell',
|
||||||
tool: 'shell',
|
|
||||||
callId: toolCallId,
|
callId: toolCallId,
|
||||||
error: { message: 'Command failed' },
|
error: { message: 'Command failed' },
|
||||||
},
|
},
|
||||||
@@ -304,8 +295,7 @@ function testToolCallParsedPayloadHydration() {
|
|||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
item: {
|
item: {
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
server: 'editor',
|
name: 'read_file',
|
||||||
tool: 'read_file',
|
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
callId: readCallId,
|
callId: readCallId,
|
||||||
input: { file_path: 'README.md' },
|
input: { file_path: 'README.md' },
|
||||||
@@ -319,8 +309,7 @@ function testToolCallParsedPayloadHydration() {
|
|||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
item: {
|
item: {
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
server: 'editor',
|
name: 'read_file',
|
||||||
tool: 'read_file',
|
|
||||||
callId: readCallId,
|
callId: readCallId,
|
||||||
output: { content: 'Hello world' },
|
output: { content: 'Hello world' },
|
||||||
},
|
},
|
||||||
@@ -333,12 +322,10 @@ function testToolCallParsedPayloadHydration() {
|
|||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
item: {
|
item: {
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
server: 'command',
|
name: 'shell',
|
||||||
tool: 'shell',
|
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
callId: commandCallId,
|
callId: commandCallId,
|
||||||
input: { command: 'pwd' },
|
input: { command: 'pwd' },
|
||||||
kind: 'execute',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
timestamp: timestampStart,
|
timestamp: timestampStart,
|
||||||
@@ -349,8 +336,7 @@ function testToolCallParsedPayloadHydration() {
|
|||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
item: {
|
item: {
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
server: 'command',
|
name: 'shell',
|
||||||
tool: 'shell',
|
|
||||||
callId: commandCallId,
|
callId: commandCallId,
|
||||||
output: {
|
output: {
|
||||||
result: {
|
result: {
|
||||||
@@ -449,8 +435,7 @@ function testClaudeHydratedToolBodies() {
|
|||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
item: {
|
item: {
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
server: 'editor',
|
name: 'apply_patch',
|
||||||
tool: 'apply_patch',
|
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
callId: editCallId,
|
callId: editCallId,
|
||||||
input: {
|
input: {
|
||||||
@@ -467,8 +452,7 @@ function testClaudeHydratedToolBodies() {
|
|||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
item: {
|
item: {
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
server: 'editor',
|
name: 'apply_patch',
|
||||||
tool: 'apply_patch',
|
|
||||||
callId: editCallId,
|
callId: editCallId,
|
||||||
output: {
|
output: {
|
||||||
changes: [
|
changes: [
|
||||||
@@ -489,8 +473,7 @@ function testClaudeHydratedToolBodies() {
|
|||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
item: {
|
item: {
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
server: 'editor',
|
name: 'read_file',
|
||||||
tool: 'read_file',
|
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
callId: readCallId,
|
callId: readCallId,
|
||||||
input: { file_path: 'README.md' },
|
input: { file_path: 'README.md' },
|
||||||
@@ -504,8 +487,7 @@ function testClaudeHydratedToolBodies() {
|
|||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
item: {
|
item: {
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
server: 'editor',
|
name: 'read_file',
|
||||||
tool: 'read_file',
|
|
||||||
callId: readCallId,
|
callId: readCallId,
|
||||||
output: { content: '# Hydrated test file\nHello Claude!' },
|
output: { content: '# Hydrated test file\nHello Claude!' },
|
||||||
},
|
},
|
||||||
@@ -518,12 +500,10 @@ function testClaudeHydratedToolBodies() {
|
|||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
item: {
|
item: {
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
server: 'command',
|
name: 'shell',
|
||||||
tool: 'shell',
|
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
callId: commandCallId,
|
callId: commandCallId,
|
||||||
input: { command: 'ls' },
|
input: { command: 'ls' },
|
||||||
kind: 'execute',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
timestamp: timestampStart,
|
timestamp: timestampStart,
|
||||||
@@ -534,8 +514,7 @@ function testClaudeHydratedToolBodies() {
|
|||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
item: {
|
item: {
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
server: 'command',
|
name: 'shell',
|
||||||
tool: 'shell',
|
|
||||||
callId: commandCallId,
|
callId: commandCallId,
|
||||||
output: {
|
output: {
|
||||||
result: {
|
result: {
|
||||||
@@ -814,10 +793,7 @@ function buildConcurrentToolCallUpdates(provider: ToolCallProvider) {
|
|||||||
|
|
||||||
const baseOptions = {
|
const baseOptions = {
|
||||||
provider,
|
provider,
|
||||||
server: 'command',
|
name: 'shell',
|
||||||
tool: 'shell',
|
|
||||||
displayName: 'Run command',
|
|
||||||
kind: 'execute',
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -924,7 +900,7 @@ function buildOutOfOrderToolCallSequence(provider: ToolCallProvider) {
|
|||||||
'shell',
|
'shell',
|
||||||
'completed',
|
'completed',
|
||||||
{ type: 'tool_result', provider, tool_call_id: callId },
|
{ type: 'tool_result', provider, tool_call_id: callId },
|
||||||
{ provider, server: 'command', tool: 'shell', callId }
|
{ provider, name: 'shell', callId }
|
||||||
),
|
),
|
||||||
timestamp: timestamps[0],
|
timestamp: timestamps[0],
|
||||||
},
|
},
|
||||||
@@ -933,7 +909,7 @@ function buildOutOfOrderToolCallSequence(provider: ToolCallProvider) {
|
|||||||
'shell',
|
'shell',
|
||||||
'executing',
|
'executing',
|
||||||
{ type: 'tool_use', provider },
|
{ type: 'tool_use', provider },
|
||||||
{ provider, server: 'command', tool: 'shell', callId: null }
|
{ provider, name: 'shell', callId: null }
|
||||||
),
|
),
|
||||||
timestamp: timestamps[1],
|
timestamp: timestamps[1],
|
||||||
},
|
},
|
||||||
@@ -976,7 +952,7 @@ function buildMetadataReplaySequence(provider: ToolCallProvider) {
|
|||||||
'shell',
|
'shell',
|
||||||
'completed',
|
'completed',
|
||||||
{ type: 'tool_result', provider, tool_call_id: firstCallId },
|
{ type: 'tool_result', provider, tool_call_id: firstCallId },
|
||||||
{ provider, server: 'command', tool: 'shell', callId: firstCallId, displayName: 'Run first' }
|
{ provider, name: 'shell', callId: firstCallId }
|
||||||
),
|
),
|
||||||
timestamp: timestamps[0],
|
timestamp: timestamps[0],
|
||||||
},
|
},
|
||||||
@@ -985,7 +961,7 @@ function buildMetadataReplaySequence(provider: ToolCallProvider) {
|
|||||||
'shell',
|
'shell',
|
||||||
'completed',
|
'completed',
|
||||||
{ type: 'tool_result', provider, tool_call_id: secondCallId },
|
{ type: 'tool_result', provider, tool_call_id: secondCallId },
|
||||||
{ provider, server: 'command', tool: 'shell', callId: secondCallId, displayName: 'Run second' }
|
{ provider, name: 'shell', callId: secondCallId }
|
||||||
),
|
),
|
||||||
timestamp: timestamps[1],
|
timestamp: timestamps[1],
|
||||||
},
|
},
|
||||||
@@ -994,7 +970,7 @@ function buildMetadataReplaySequence(provider: ToolCallProvider) {
|
|||||||
'shell',
|
'shell',
|
||||||
'executing',
|
'executing',
|
||||||
{ type: 'tool_use', provider },
|
{ type: 'tool_use', provider },
|
||||||
{ provider, server: 'command', tool: 'shell', callId: null, displayName: 'Run first', kind: 'execute' }
|
{ provider, name: 'shell', callId: null }
|
||||||
),
|
),
|
||||||
timestamp: timestamps[2],
|
timestamp: timestamps[2],
|
||||||
},
|
},
|
||||||
@@ -1041,15 +1017,14 @@ function testMetadataReplayDeduplicationHydrated() {
|
|||||||
|
|
||||||
function testFallbackToolCallIdsStayUnique() {
|
function testFallbackToolCallIdsStayUnique() {
|
||||||
const timestamp = new Date('2025-01-01T14:05:00Z');
|
const timestamp = new Date('2025-01-01T14:05:00Z');
|
||||||
// Tool calls need different server/tool to remain distinct when lacking callIds
|
// Tool calls need different name to remain distinct when lacking callIds
|
||||||
// (displayName alone is not sufficient for differentiation)
|
|
||||||
const updates = [
|
const updates = [
|
||||||
{
|
{
|
||||||
event: toolTimeline(
|
event: toolTimeline(
|
||||||
'fallback-read',
|
'fallback-read',
|
||||||
'completed',
|
'completed',
|
||||||
undefined,
|
undefined,
|
||||||
{ callId: null, server: 'editor', tool: 'read_file', displayName: 'Read file' }
|
{ callId: null, name: 'read_file' }
|
||||||
),
|
),
|
||||||
timestamp,
|
timestamp,
|
||||||
},
|
},
|
||||||
@@ -1058,7 +1033,7 @@ function testFallbackToolCallIdsStayUnique() {
|
|||||||
'fallback-shell',
|
'fallback-shell',
|
||||||
'completed',
|
'completed',
|
||||||
undefined,
|
undefined,
|
||||||
{ callId: null, server: 'command', tool: 'shell', displayName: 'Run shell' }
|
{ callId: null, name: 'shell' }
|
||||||
),
|
),
|
||||||
timestamp,
|
timestamp,
|
||||||
},
|
},
|
||||||
@@ -1072,7 +1047,7 @@ function testFallbackToolCallIdsStayUnique() {
|
|||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
new Set(ids).size,
|
new Set(ids).size,
|
||||||
ids.length,
|
ids.length,
|
||||||
'Fallback-generated tool ids must be unique when server/tool differs'
|
'Fallback-generated tool ids must be unique when name differs'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,12 +106,9 @@ interface OrchestratorToolCallData {
|
|||||||
|
|
||||||
export interface AgentToolCallData {
|
export interface AgentToolCallData {
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
server: string;
|
name: string;
|
||||||
tool: string;
|
|
||||||
status?: ToolCallStatus;
|
status?: ToolCallStatus;
|
||||||
callId?: string;
|
callId?: string;
|
||||||
displayName?: string;
|
|
||||||
kind?: string;
|
|
||||||
input?: unknown;
|
input?: unknown;
|
||||||
result?: unknown;
|
result?: unknown;
|
||||||
error?: unknown;
|
error?: unknown;
|
||||||
@@ -355,8 +352,7 @@ function findExistingAgentToolCallIndex(
|
|||||||
const payload = entry.payload.data;
|
const payload = entry.payload.data;
|
||||||
const providerMatches =
|
const providerMatches =
|
||||||
payload.provider === data.provider &&
|
payload.provider === data.provider &&
|
||||||
payload.server === data.server &&
|
payload.name === data.name;
|
||||||
payload.tool === data.tool;
|
|
||||||
if (providerMatches) {
|
if (providerMatches) {
|
||||||
metadataMatches.push({ index: i, item: entry as AgentToolCallItem });
|
metadataMatches.push({ index: i, item: entry as AgentToolCallItem });
|
||||||
}
|
}
|
||||||
@@ -371,49 +367,13 @@ function findExistingAgentToolCallIndex(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedDisplayName = normalizeComparableString(data.displayName);
|
|
||||||
const normalizedKind = normalizeComparableString(data.kind);
|
|
||||||
|
|
||||||
const filterByComparableField = (
|
|
||||||
candidates: Array<{ index: number; item: AgentToolCallItem }>,
|
|
||||||
selector: (entry: AgentToolCallItem) => string | null,
|
|
||||||
value: string | null
|
|
||||||
): Array<{ index: number; item: AgentToolCallItem }> => {
|
|
||||||
if (!value) {
|
|
||||||
return candidates;
|
|
||||||
}
|
|
||||||
const matches = candidates.filter((candidate) => selector(candidate.item) === value);
|
|
||||||
if (matches.length === 1) {
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
return matches.length > 0 ? matches : candidates;
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectCandidate = (
|
|
||||||
candidates: Array<{ index: number; item: AgentToolCallItem }>
|
|
||||||
): number => {
|
|
||||||
const byDisplayName = filterByComparableField(
|
|
||||||
candidates,
|
|
||||||
(entry) => normalizeComparableString(entry.payload.data.displayName),
|
|
||||||
normalizedDisplayName
|
|
||||||
);
|
|
||||||
|
|
||||||
const byKind = filterByComparableField(
|
|
||||||
byDisplayName,
|
|
||||||
(entry) => normalizeComparableString(entry.payload.data.kind),
|
|
||||||
normalizedKind
|
|
||||||
);
|
|
||||||
|
|
||||||
return byKind[0]?.index ?? -1;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (fallbackCandidates.length) {
|
if (fallbackCandidates.length) {
|
||||||
return selectCandidate(fallbackCandidates);
|
return fallbackCandidates[0]?.index ?? -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If this update still lacks a call id, fall back to metadata matches (e.g. replayed hydration events)
|
// If this update still lacks a call id, fall back to metadata matches (e.g. replayed hydration events)
|
||||||
if (!normalizedCallId && metadataMatches.length) {
|
if (!normalizedCallId && metadataMatches.length) {
|
||||||
return selectCandidate(metadataMatches);
|
return metadataMatches[0]?.index ?? -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return -1;
|
return -1;
|
||||||
@@ -466,8 +426,6 @@ function appendAgentToolCall(
|
|||||||
status: mergedStatus,
|
status: mergedStatus,
|
||||||
result: mergedResult,
|
result: mergedResult,
|
||||||
error: mergedError,
|
error: mergedError,
|
||||||
displayName: payloadData.displayName ?? existing.payload.data.displayName,
|
|
||||||
kind: payloadData.kind ?? existing.payload.data.kind,
|
|
||||||
callId: payloadData.callId ?? existing.payload.data.callId,
|
callId: payloadData.callId ?? existing.payload.data.callId,
|
||||||
parsedEdits: parsed.parsedEdits ?? existing.payload.data.parsedEdits,
|
parsedEdits: parsed.parsedEdits ?? existing.payload.data.parsedEdits,
|
||||||
parsedReads: parsed.parsedReads ?? existing.payload.data.parsedReads,
|
parsedReads: parsed.parsedReads ?? existing.payload.data.parsedReads,
|
||||||
@@ -483,7 +441,7 @@ function appendAgentToolCall(
|
|||||||
: createUniqueTimelineId(
|
: createUniqueTimelineId(
|
||||||
state,
|
state,
|
||||||
"tool",
|
"tool",
|
||||||
`${data.provider}:${data.server}:${data.tool}`,
|
`${data.provider}:${data.name}`,
|
||||||
timestamp
|
timestamp
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -507,8 +465,8 @@ function isPermissionToolCall(raw: unknown): boolean {
|
|||||||
if (!raw || typeof raw !== "object") {
|
if (!raw || typeof raw !== "object") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const candidate = raw as { server?: string; kind?: string };
|
const candidate = raw as { name?: string };
|
||||||
return candidate.server === "permission" || candidate.kind === "permission";
|
return candidate.name === "permission_request";
|
||||||
}
|
}
|
||||||
|
|
||||||
const FAILED_STATUS_PATTERN = /fail|error|deny|reject|cancel|abort|exception|refus/;
|
const FAILED_STATUS_PATTERN = /fail|error|deny|reject|cancel|abort|exception|refus/;
|
||||||
@@ -779,12 +737,9 @@ export function reduceStreamUpdate(
|
|||||||
state,
|
state,
|
||||||
{
|
{
|
||||||
provider: event.provider,
|
provider: event.provider,
|
||||||
server: item.server,
|
name: item.name,
|
||||||
tool: item.tool,
|
|
||||||
status: normalizeStatusString(item.status) ?? "executing",
|
status: normalizeStatusString(item.status) ?? "executing",
|
||||||
callId: item.callId,
|
callId: item.callId,
|
||||||
displayName: item.displayName,
|
|
||||||
kind: item.kind,
|
|
||||||
input: item.input,
|
input: item.input,
|
||||||
result: item.output,
|
result: item.output,
|
||||||
error: item.error,
|
error: item.error,
|
||||||
|
|||||||
@@ -752,3 +752,41 @@ export function extractKeyValuePairs(result: unknown): KeyValuePair[] {
|
|||||||
value: stringifyValue(value),
|
value: stringifyValue(value),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Principal Parameter Extraction ----
|
||||||
|
|
||||||
|
const PrincipalParamSchema = z.union([
|
||||||
|
z.object({ file_path: z.string() }).transform((d) => ({ type: "path" as const, value: d.file_path })),
|
||||||
|
z.object({ filePath: z.string() }).transform((d) => ({ type: "path" as const, value: d.filePath })),
|
||||||
|
z.object({ path: z.string() }).transform((d) => ({ type: "path" as const, value: d.path })),
|
||||||
|
z.object({ command: z.string() }).transform((d) => ({ type: "text" as const, value: d.command })),
|
||||||
|
z.object({ pattern: z.string() }).transform((d) => ({ type: "text" as const, value: d.pattern })),
|
||||||
|
z.object({ query: z.string() }).transform((d) => ({ type: "text" as const, value: d.query })),
|
||||||
|
z.object({ url: z.string() }).transform((d) => ({ type: "text" as const, value: d.url })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractPrincipalParam(args: unknown, cwd?: string): string | undefined {
|
||||||
|
const parsed = PrincipalParamSchema.safeParse(args);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, value } = parsed.data;
|
||||||
|
return type === "path" ? stripCwdPrefix(value, cwd) : value;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
import type { AgentTimelineItem, ToolCallKind } from "./agent-sdk-types.js";
|
||||||
|
|
||||||
const DEFAULT_MAX_ITEMS = 40;
|
const DEFAULT_MAX_ITEMS = 40;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive tool kind from the tool name for rendering purposes.
|
||||||
|
*/
|
||||||
|
function getToolKind(name: string): ToolCallKind {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
if (lower === "read" || lower === "read_file") return "read";
|
||||||
|
if (lower === "edit" || lower === "write" || lower === "apply_patch") return "edit";
|
||||||
|
if (lower === "bash" || lower === "shell") return "execute";
|
||||||
|
if (lower === "grep" || lower === "glob" || lower === "web_search") return "search";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
function appendText(buffer: string, text: string): string {
|
function appendText(buffer: string, text: string): string {
|
||||||
const normalized = text.trim();
|
const normalized = text.trim();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -50,6 +62,13 @@ function extractWebQuery(value: unknown): string {
|
|||||||
return value.query;
|
return value.query;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractCommand(value: unknown): string {
|
||||||
|
if (!isObject(value) || typeof value.command !== "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value.command;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert normalized agent timeline items into a concise text summary.
|
* Convert normalized agent timeline items into a concise text summary.
|
||||||
*/
|
*/
|
||||||
@@ -84,13 +103,12 @@ export function curateAgentActivity(
|
|||||||
break;
|
break;
|
||||||
case "tool_call": {
|
case "tool_call": {
|
||||||
flushBuffers(lines, buffers);
|
flushBuffers(lines, buffers);
|
||||||
const label =
|
|
||||||
item.displayName ??
|
|
||||||
(item.server && item.tool ? `${item.server}.${item.tool}` : item.tool ?? item.server ?? "Tool");
|
|
||||||
const status = item.status ? ` ${item.status}` : "";
|
const status = item.status ? ` ${item.status}` : "";
|
||||||
if (item.kind === "execute" || item.server === "command") {
|
const kind = getToolKind(item.name);
|
||||||
lines.push(`[Command: ${label}]${status}`);
|
if (kind === "execute") {
|
||||||
} else if (item.kind === "edit" || item.server === "file_change") {
|
const command = extractCommand(item.input);
|
||||||
|
lines.push(`[Command: ${command || item.name}]${status}`);
|
||||||
|
} else if (kind === "edit") {
|
||||||
const files = extractFileChanges(item.output);
|
const files = extractFileChanges(item.output);
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
lines.push("[File Changes]");
|
lines.push("[File Changes]");
|
||||||
@@ -98,13 +116,13 @@ export function curateAgentActivity(
|
|||||||
lines.push(`- (${file.kind}) ${file.path}`);
|
lines.push(`- (${file.kind}) ${file.path}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
lines.push(`[Edit] ${label}${status}`);
|
lines.push(`[Edit] ${item.name}${status}`);
|
||||||
}
|
}
|
||||||
} else if (item.kind === "search" || item.server === "web_search") {
|
} else if (kind === "search") {
|
||||||
const query = extractWebQuery(item.input);
|
const query = extractWebQuery(item.input);
|
||||||
lines.push(`[Web Search] ${query || label}`);
|
lines.push(`[Web Search] ${query || item.name}`);
|
||||||
} else {
|
} else {
|
||||||
lines.push(`[Tool ${item.server}.${item.tool}]${status}`);
|
lines.push(`[Tool ${item.name}]${status}`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,22 +55,34 @@ export type AgentUsage = {
|
|||||||
totalCostUsd?: number;
|
totalCostUsd?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool call kind categories for UI rendering hints.
|
||||||
|
* Derived from the tool name, not sent over the wire.
|
||||||
|
*/
|
||||||
|
export type ToolCallKind = "read" | "edit" | "execute" | "search" | "other";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean tool call structure.
|
||||||
|
* - `name`: Tool identifier (e.g., "Read", "Bash", "Edit", "shell", "read_file", "apply_patch")
|
||||||
|
* - `input`: Tool input parameters
|
||||||
|
* - `output`: Tool result
|
||||||
|
* - `error`: Error if tool failed
|
||||||
|
*/
|
||||||
|
export interface ToolCallTimelineItem {
|
||||||
|
type: "tool_call";
|
||||||
|
name: string;
|
||||||
|
callId?: string;
|
||||||
|
status?: string;
|
||||||
|
input?: unknown;
|
||||||
|
output?: unknown;
|
||||||
|
error?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export type AgentTimelineItem =
|
export type AgentTimelineItem =
|
||||||
| { type: "user_message"; text: string; messageId?: string }
|
| { type: "user_message"; text: string; messageId?: string }
|
||||||
| { type: "assistant_message"; text: string }
|
| { type: "assistant_message"; text: string }
|
||||||
| { type: "reasoning"; text: string }
|
| { type: "reasoning"; text: string }
|
||||||
| {
|
| ToolCallTimelineItem
|
||||||
type: "tool_call";
|
|
||||||
server: string;
|
|
||||||
tool: string;
|
|
||||||
status?: string;
|
|
||||||
callId?: string;
|
|
||||||
displayName?: string;
|
|
||||||
kind?: string;
|
|
||||||
input?: unknown;
|
|
||||||
output?: unknown;
|
|
||||||
error?: unknown;
|
|
||||||
}
|
|
||||||
| { type: "todo"; items: { text: string; completed: boolean }[] }
|
| { type: "todo"; items: { text: string; completed: boolean }[] }
|
||||||
| { type: "error"; message: string };
|
| { type: "error"; message: string };
|
||||||
|
|
||||||
|
|||||||
@@ -88,21 +88,16 @@ function extractCommandText(input: unknown): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isSleepCommandToolCall(item: ToolCallItem): boolean {
|
function isSleepCommandToolCall(item: ToolCallItem): boolean {
|
||||||
const display = typeof item.displayName === "string" ? item.displayName.toLowerCase() : "";
|
|
||||||
if (display.includes("sleep 60")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const inputCommand = extractCommandText(item.input)?.toLowerCase() ?? "";
|
const inputCommand = extractCommandText(item.input)?.toLowerCase() ?? "";
|
||||||
return inputCommand.includes("sleep 60");
|
return inputCommand.includes("sleep 60");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPermissionCommandToolCall(item: ToolCallItem): boolean {
|
function isPermissionCommandToolCall(item: ToolCallItem): boolean {
|
||||||
if (item.server === "permission") {
|
if (item.name === "permission_request") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const display = typeof item.displayName === "string" ? item.displayName.toLowerCase() : "";
|
|
||||||
const inputCommand = extractCommandText(item.input)?.toLowerCase() ?? "";
|
const inputCommand = extractCommandText(item.input)?.toLowerCase() ?? "";
|
||||||
return display.includes("permission.txt") || inputCommand.includes("permission.txt");
|
return inputCommand.includes("permission.txt");
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentMcpServerHandle = {
|
type AgentMcpServerHandle = {
|
||||||
@@ -354,7 +349,7 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
|||||||
const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
|
const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
|
||||||
const session = await client.createSession(config);
|
const session = await client.createSession(config);
|
||||||
|
|
||||||
let pendingDisplay: string | null = null;
|
let pendingCommand: string | null = null;
|
||||||
const events = session.stream("Run the exact command `pwd` via Bash and stop.");
|
const events = session.stream("Run the exact command `pwd` via Bash and stop.");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -363,10 +358,10 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
|||||||
if (
|
if (
|
||||||
event.type === "timeline" &&
|
event.type === "timeline" &&
|
||||||
event.item.type === "tool_call" &&
|
event.item.type === "tool_call" &&
|
||||||
event.item.server.toLowerCase().includes("bash") &&
|
event.item.name.toLowerCase().includes("bash") &&
|
||||||
event.item.status === "pending"
|
event.item.status === "pending"
|
||||||
) {
|
) {
|
||||||
pendingDisplay = event.item.displayName ?? null;
|
pendingCommand = extractCommandText(event.item.input);
|
||||||
}
|
}
|
||||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||||
break;
|
break;
|
||||||
@@ -377,8 +372,8 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
|||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(pendingDisplay).toBeTruthy();
|
expect(pendingCommand).toBeTruthy();
|
||||||
expect(pendingDisplay?.toLowerCase()).toContain("pwd");
|
expect(pendingCommand?.toLowerCase()).toContain("pwd");
|
||||||
},
|
},
|
||||||
150_000
|
150_000
|
||||||
);
|
);
|
||||||
@@ -418,9 +413,8 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
|||||||
);
|
);
|
||||||
const commandEvents = toolCalls.filter(
|
const commandEvents = toolCalls.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
(item.kind === "execute" || item.server === "command") &&
|
item.name.toLowerCase().includes("bash") &&
|
||||||
typeof item.displayName === "string" &&
|
item.name !== "permission_request"
|
||||||
!item.displayName.startsWith("permission:")
|
|
||||||
);
|
);
|
||||||
const fileChangeEvent = toolCalls.find((item) => {
|
const fileChangeEvent = toolCalls.find((item) => {
|
||||||
// Check for file changes in structured output.files array
|
// Check for file changes in structured output.files array
|
||||||
@@ -444,7 +438,7 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const sawPwdCommand = commandEvents.some(
|
const sawPwdCommand = commandEvents.some(
|
||||||
(item) => (item.displayName ?? "").toLowerCase().includes("pwd") && item.status === "completed"
|
(item) => (extractCommandText(item.input) ?? "").toLowerCase().includes("pwd") && item.status === "completed"
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(completed).toBe(true);
|
expect(completed).toBe(true);
|
||||||
@@ -906,7 +900,8 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
|||||||
const liveState = hydrateStreamState(liveTimelineUpdates);
|
const liveState = hydrateStreamState(liveTimelineUpdates);
|
||||||
const liveSnapshots = extractAgentToolSnapshots(liveState);
|
const liveSnapshots = extractAgentToolSnapshots(liveState);
|
||||||
const commandTool = liveSnapshots.find((snapshot) =>
|
const commandTool = liveSnapshots.find((snapshot) =>
|
||||||
(snapshot.data.displayName ?? "").toLowerCase().includes("pwd")
|
snapshot.data.name.toLowerCase().includes("bash") &&
|
||||||
|
(extractCommandText(snapshot.data.input) ?? "").toLowerCase().includes("pwd")
|
||||||
);
|
);
|
||||||
const editTool = liveSnapshots.find((snapshot) =>
|
const editTool = liveSnapshots.find((snapshot) =>
|
||||||
rawContainsText(snapshot.data.result, "hydrate-proof.txt")
|
rawContainsText(snapshot.data.result, "hydrate-proof.txt")
|
||||||
@@ -953,8 +948,8 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
|||||||
({ live, hydrated }) => {
|
({ live, hydrated }) => {
|
||||||
expect(rawContainsText(live.result, cwd)).toBe(true);
|
expect(rawContainsText(live.result, cwd)).toBe(true);
|
||||||
expect(rawContainsText(hydrated.result, cwd)).toBe(true);
|
expect(rawContainsText(hydrated.result, cwd)).toBe(true);
|
||||||
expect((live.displayName ?? "").toLowerCase()).toContain("pwd");
|
expect((extractCommandText(live.input) ?? "").toLowerCase()).toContain("pwd");
|
||||||
expect((hydrated.displayName ?? "").toLowerCase()).toContain("pwd");
|
expect((extractCommandText(hydrated.input) ?? "").toLowerCase()).toContain("pwd");
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assertHydratedReplica(
|
assertHydratedReplica(
|
||||||
@@ -1165,8 +1160,7 @@ function buildToolSnapshotKey(data: AgentToolCallData, fallbackId: string): stri
|
|||||||
if (normalized) {
|
if (normalized) {
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
const display = typeof data.displayName === "string" && data.displayName.trim().length > 0 ? data.displayName.trim() : fallbackId;
|
return `${data.provider}:${data.name}:${fallbackId}`;
|
||||||
return `${data.server}:${data.tool}:${display}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertHydratedReplica(
|
function assertHydratedReplica(
|
||||||
@@ -1179,9 +1173,7 @@ function assertHydratedReplica(
|
|||||||
const hydrated = hydratedMap.get(liveSnapshot.key);
|
const hydrated = hydratedMap.get(liveSnapshot.key);
|
||||||
expect(hydrated).toBeTruthy();
|
expect(hydrated).toBeTruthy();
|
||||||
expect(hydrated?.status).toBe(liveSnapshot.data.status);
|
expect(hydrated?.status).toBe(liveSnapshot.data.status);
|
||||||
expect(hydrated?.server).toBe(liveSnapshot.data.server);
|
expect(hydrated?.name).toBe(liveSnapshot.data.name);
|
||||||
expect(hydrated?.tool).toBe(liveSnapshot.data.tool);
|
|
||||||
expect(hydrated?.displayName).toBe(liveSnapshot.data.displayName);
|
|
||||||
expect(predicate(hydrated!)).toBe(true);
|
expect(predicate(hydrated!)).toBe(true);
|
||||||
if (hydrated && extraAssertions) {
|
if (hydrated && extraAssertions) {
|
||||||
extraAssertions({ live: liveSnapshot.data, hydrated });
|
extraAssertions({ live: liveSnapshot.data, hydrated });
|
||||||
|
|||||||
@@ -619,12 +619,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
if (pending.request.kind === "plan") {
|
if (pending.request.kind === "plan") {
|
||||||
await this.setMode("acceptEdits");
|
await this.setMode("acceptEdits");
|
||||||
this.pushToolCall({
|
this.pushToolCall({
|
||||||
server: "plan",
|
name: "plan_approval",
|
||||||
tool: "plan_approval",
|
|
||||||
status: "granted",
|
status: "granted",
|
||||||
callId: pending.request.id,
|
callId: pending.request.id,
|
||||||
displayName: "Plan approved",
|
|
||||||
kind: "plan",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const result: PermissionResult = {
|
const result: PermissionResult = {
|
||||||
@@ -634,12 +631,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
};
|
};
|
||||||
pending.resolve(result);
|
pending.resolve(result);
|
||||||
this.pushToolCall({
|
this.pushToolCall({
|
||||||
server: "permission",
|
name: pending.request.name,
|
||||||
tool: pending.request.name,
|
|
||||||
status: "granted",
|
status: "granted",
|
||||||
callId: pending.request.id,
|
callId: pending.request.id,
|
||||||
displayName: pending.request.title ?? pending.request.name,
|
|
||||||
kind: "permission",
|
|
||||||
input: pending.request.input,
|
input: pending.request.input,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -650,12 +644,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
};
|
};
|
||||||
pending.resolve(result);
|
pending.resolve(result);
|
||||||
this.pushToolCall({
|
this.pushToolCall({
|
||||||
server: "permission",
|
name: pending.request.name,
|
||||||
tool: pending.request.name,
|
|
||||||
status: "denied",
|
status: "denied",
|
||||||
callId: pending.request.id,
|
callId: pending.request.id,
|
||||||
displayName: pending.request.title ?? pending.request.name,
|
|
||||||
kind: "permission",
|
|
||||||
input: pending.request.input,
|
input: pending.request.input,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1039,12 +1030,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.pushToolCall({
|
this.pushToolCall({
|
||||||
server: "permission",
|
name: toolName,
|
||||||
tool: toolName,
|
|
||||||
status: "requested",
|
status: "requested",
|
||||||
callId: requestId,
|
callId: requestId,
|
||||||
displayName: request.title ?? toolName,
|
|
||||||
kind: "permission",
|
|
||||||
input,
|
input,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1067,12 +1055,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
cleanup();
|
cleanup();
|
||||||
const error = new Error("Permission request timed out");
|
const error = new Error("Permission request timed out");
|
||||||
this.pushToolCall({
|
this.pushToolCall({
|
||||||
server: "permission",
|
name: toolName,
|
||||||
tool: toolName,
|
|
||||||
status: "denied",
|
status: "denied",
|
||||||
callId: requestId,
|
callId: requestId,
|
||||||
displayName: request.title ?? toolName,
|
|
||||||
kind: "permission",
|
|
||||||
input,
|
input,
|
||||||
});
|
});
|
||||||
this.pushEvent({
|
this.pushEvent({
|
||||||
@@ -1138,25 +1123,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.enqueueTimeline(item);
|
this.enqueueTimeline(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getToolKind(classification?: ToolUseClassification): string | undefined {
|
|
||||||
switch (classification) {
|
|
||||||
case "command":
|
|
||||||
return "execute";
|
|
||||||
case "file_change":
|
|
||||||
return "edit";
|
|
||||||
case "generic":
|
|
||||||
default:
|
|
||||||
return "tool";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildToolDisplayName(entry?: ToolUseCacheEntry): string | undefined {
|
|
||||||
if (!entry) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return entry.commandText ?? entry.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
private pushEvent(event: AgentStreamEvent) {
|
private pushEvent(event: AgentStreamEvent) {
|
||||||
if (this.eventQueue) {
|
if (this.eventQueue) {
|
||||||
this.eventQueue.push(event);
|
this.eventQueue.push(event);
|
||||||
@@ -1315,12 +1281,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.toolUseCache.set(entry.id, entry);
|
this.toolUseCache.set(entry.id, entry);
|
||||||
this.pushToolCall(
|
this.pushToolCall(
|
||||||
{
|
{
|
||||||
server: entry.server,
|
name: entry.name,
|
||||||
tool: entry.name,
|
|
||||||
status: "pending",
|
status: "pending",
|
||||||
callId: entry.id,
|
callId: entry.id,
|
||||||
displayName: this.buildToolDisplayName(entry),
|
|
||||||
kind: this.getToolKind(entry.classification),
|
|
||||||
input: entry.input ?? this.normalizeToolInput(block.input),
|
input: entry.input ?? this.normalizeToolInput(block.input),
|
||||||
},
|
},
|
||||||
items
|
items
|
||||||
@@ -1329,8 +1292,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
|
|
||||||
private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
|
private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
|
||||||
const entry = typeof block.tool_use_id === "string" ? this.toolUseCache.get(block.tool_use_id) : undefined;
|
const entry = typeof block.tool_use_id === "string" ? this.toolUseCache.get(block.tool_use_id) : undefined;
|
||||||
const server = entry?.server ?? block.server ?? "tool";
|
const toolName = entry?.name ?? block.tool_name ?? "tool";
|
||||||
const tool = entry?.name ?? block.tool_name ?? "tool";
|
|
||||||
const status = block.is_error ? "failed" : "completed";
|
const status = block.is_error ? "failed" : "completed";
|
||||||
|
|
||||||
// Extract output from block.content (SDK always returns content in string form)
|
// Extract output from block.content (SDK always returns content in string form)
|
||||||
@@ -1338,12 +1300,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
|
|
||||||
this.pushToolCall(
|
this.pushToolCall(
|
||||||
{
|
{
|
||||||
server,
|
name: toolName,
|
||||||
tool,
|
|
||||||
status,
|
status,
|
||||||
callId: typeof block.tool_use_id === "string" ? block.tool_use_id : undefined,
|
callId: typeof block.tool_use_id === "string" ? block.tool_use_id : undefined,
|
||||||
displayName: this.buildToolDisplayName(entry),
|
|
||||||
kind: this.getToolKind(entry?.classification),
|
|
||||||
input: entry?.input,
|
input: entry?.input,
|
||||||
output,
|
output,
|
||||||
error: block.is_error ? block : undefined,
|
error: block.is_error ? block : undefined,
|
||||||
@@ -1577,12 +1536,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.applyToolInput(entry, normalized);
|
this.applyToolInput(entry, normalized);
|
||||||
this.toolUseCache.set(toolId, entry);
|
this.toolUseCache.set(toolId, entry);
|
||||||
this.pushToolCall({
|
this.pushToolCall({
|
||||||
server: entry.server,
|
name: entry.name,
|
||||||
tool: entry.name,
|
|
||||||
status: "pending",
|
status: "pending",
|
||||||
callId: toolId,
|
callId: toolId,
|
||||||
displayName: this.buildToolDisplayName(entry),
|
|
||||||
kind: this.getToolKind(entry.classification),
|
|
||||||
input: normalized,
|
input: normalized,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,10 +250,6 @@ function stringifyUnknown(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isSleepCommandToolCall(item: ToolCallItem): boolean {
|
function isSleepCommandToolCall(item: ToolCallItem): boolean {
|
||||||
const display = typeof item.displayName === "string" ? item.displayName.toLowerCase() : "";
|
|
||||||
if (display.includes("sleep 60")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const inputText = commandTextFromInput(item.input);
|
const inputText = commandTextFromInput(item.input);
|
||||||
if (!inputText) {
|
if (!inputText) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -1954,13 +1954,6 @@ function shouldReportCommandError(input: {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFileChangeSummary(files: { path: string; kind: string }[]): string {
|
|
||||||
if (files.length === 1) {
|
|
||||||
return `${files[0].kind}: ${files[0].path}`;
|
|
||||||
}
|
|
||||||
return `${files.length} file changes`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractContentText(content: unknown): string | null {
|
function extractContentText(content: unknown): string | null {
|
||||||
if (!Array.isArray(content)) {
|
if (!Array.isArray(content)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -3105,12 +3098,9 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: CODEX_PROVIDER,
|
provider: CODEX_PROVIDER,
|
||||||
item: createToolCallTimelineItem({
|
item: createToolCallTimelineItem({
|
||||||
server: "permission",
|
name: pending.request.name,
|
||||||
tool: pending.request.name,
|
|
||||||
status,
|
status,
|
||||||
callId: pending.request.id,
|
callId: pending.request.id,
|
||||||
displayName: pending.request.title ? pending.request.title : pending.request.name,
|
|
||||||
kind: "permission",
|
|
||||||
input: pending.request.input,
|
input: pending.request.input,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -3415,12 +3405,9 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: CODEX_PROVIDER,
|
provider: CODEX_PROVIDER,
|
||||||
item: createToolCallTimelineItem({
|
item: createToolCallTimelineItem({
|
||||||
server: "permission",
|
name: request.name,
|
||||||
tool: request.name,
|
|
||||||
status: "requested",
|
status: "requested",
|
||||||
callId: request.id,
|
callId: request.id,
|
||||||
displayName: request.title ? request.title : request.name,
|
|
||||||
kind: "permission",
|
|
||||||
input: request.input,
|
input: request.input,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -3535,12 +3522,9 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: CODEX_PROVIDER,
|
provider: CODEX_PROVIDER,
|
||||||
item: createToolCallTimelineItem({
|
item: createToolCallTimelineItem({
|
||||||
server: "file_change",
|
name: "apply_patch",
|
||||||
tool: "apply_patch",
|
|
||||||
status: success ? "completed" : "failed",
|
status: success ? "completed" : "failed",
|
||||||
callId,
|
callId,
|
||||||
displayName: buildFileChangeSummary(summaryFiles),
|
|
||||||
kind: "edit",
|
|
||||||
input: { files: summaryFiles },
|
input: { files: summaryFiles },
|
||||||
output: { files: pendingChanges, message: parsedOutputText, success },
|
output: { files: pendingChanges, message: parsedOutputText, success },
|
||||||
}),
|
}),
|
||||||
@@ -3607,19 +3591,15 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
if (!callId) {
|
if (!callId) {
|
||||||
throw new Error("exec_command_begin missing call_id");
|
throw new Error("exec_command_begin missing call_id");
|
||||||
}
|
}
|
||||||
const commandText = normalizeCommand(parsedEvent.command);
|
|
||||||
const fileRead = extractFileReadFromParsedCmd(parsedEvent.parsedCmd);
|
const fileRead = extractFileReadFromParsedCmd(parsedEvent.parsedCmd);
|
||||||
if (fileRead) {
|
if (fileRead) {
|
||||||
this.emitEvent({
|
this.emitEvent({
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: CODEX_PROVIDER,
|
provider: CODEX_PROVIDER,
|
||||||
item: createToolCallTimelineItem({
|
item: createToolCallTimelineItem({
|
||||||
server: "file",
|
name: "read_file",
|
||||||
tool: "read_file",
|
|
||||||
status: "running",
|
status: "running",
|
||||||
callId,
|
callId,
|
||||||
displayName: `Read: ${fileRead.name}`,
|
|
||||||
kind: "read",
|
|
||||||
input: { path: fileRead.path },
|
input: { path: fileRead.path },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -3628,12 +3608,9 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: CODEX_PROVIDER,
|
provider: CODEX_PROVIDER,
|
||||||
item: createToolCallTimelineItem({
|
item: createToolCallTimelineItem({
|
||||||
server: "command",
|
name: "shell",
|
||||||
tool: "shell",
|
|
||||||
status: "running",
|
status: "running",
|
||||||
callId,
|
callId,
|
||||||
displayName: commandText,
|
|
||||||
kind: "execute",
|
|
||||||
input: { command: parsedEvent.command, cwd: parsedEvent.cwd },
|
input: { command: parsedEvent.command, cwd: parsedEvent.cwd },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -3709,12 +3686,9 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: CODEX_PROVIDER,
|
provider: CODEX_PROVIDER,
|
||||||
item: createToolCallTimelineItem({
|
item: createToolCallTimelineItem({
|
||||||
server: "file",
|
name: "read_file",
|
||||||
tool: "read_file",
|
|
||||||
status: failed ? "failed" : "completed",
|
status: failed ? "failed" : "completed",
|
||||||
callId,
|
callId,
|
||||||
displayName: `Read: ${fileRead.name}`,
|
|
||||||
kind: "read",
|
|
||||||
input: { path: fileRead.path },
|
input: { path: fileRead.path },
|
||||||
output: {
|
output: {
|
||||||
type: "read_file",
|
type: "read_file",
|
||||||
@@ -3749,12 +3723,9 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: CODEX_PROVIDER,
|
provider: CODEX_PROVIDER,
|
||||||
item: createToolCallTimelineItem({
|
item: createToolCallTimelineItem({
|
||||||
server: "command",
|
name: "shell",
|
||||||
tool: "shell",
|
|
||||||
status: failed ? "failed" : "completed",
|
status: failed ? "failed" : "completed",
|
||||||
callId,
|
callId,
|
||||||
displayName: commandText,
|
|
||||||
kind: "execute",
|
|
||||||
input: { command: parsedEvent.command, cwd: parsedEvent.cwd },
|
input: { command: parsedEvent.command, cwd: parsedEvent.cwd },
|
||||||
output: structuredOutput,
|
output: structuredOutput,
|
||||||
}),
|
}),
|
||||||
@@ -3791,12 +3762,9 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: CODEX_PROVIDER,
|
provider: CODEX_PROVIDER,
|
||||||
item: createToolCallTimelineItem({
|
item: createToolCallTimelineItem({
|
||||||
server: "file_change",
|
name: "apply_patch",
|
||||||
tool: "apply_patch",
|
|
||||||
status: "running",
|
status: "running",
|
||||||
callId,
|
callId,
|
||||||
displayName: buildFileChangeSummary(files),
|
|
||||||
kind: "edit",
|
|
||||||
input: { changes: parsedEvent.changes, files },
|
input: { changes: parsedEvent.changes, files },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -3852,12 +3820,9 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: CODEX_PROVIDER,
|
provider: CODEX_PROVIDER,
|
||||||
item: createToolCallTimelineItem({
|
item: createToolCallTimelineItem({
|
||||||
server: "file_change",
|
name: "apply_patch",
|
||||||
tool: "apply_patch",
|
|
||||||
status: parsedEvent.success ? "completed" : "failed",
|
status: parsedEvent.success ? "completed" : "failed",
|
||||||
callId,
|
callId,
|
||||||
displayName: buildFileChangeSummary(summaryFiles),
|
|
||||||
kind: "edit",
|
|
||||||
input: { files: summaryFiles },
|
input: { files: summaryFiles },
|
||||||
output,
|
output,
|
||||||
}),
|
}),
|
||||||
@@ -3881,12 +3846,9 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: CODEX_PROVIDER,
|
provider: CODEX_PROVIDER,
|
||||||
item: createToolCallTimelineItem({
|
item: createToolCallTimelineItem({
|
||||||
server: parsedEvent.server,
|
name: `${parsedEvent.server}.${parsedEvent.tool}`,
|
||||||
tool: parsedEvent.tool,
|
|
||||||
status: success ? "completed" : "failed",
|
status: success ? "completed" : "failed",
|
||||||
callId: parsedEvent.callId,
|
callId: parsedEvent.callId,
|
||||||
displayName: `${parsedEvent.server}.${parsedEvent.tool}`,
|
|
||||||
kind: "tool",
|
|
||||||
input,
|
input,
|
||||||
output: normalizedOutput,
|
output: normalizedOutput,
|
||||||
}),
|
}),
|
||||||
@@ -4059,12 +4021,9 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
commandOutput.exitCode = resolvedExitCode;
|
commandOutput.exitCode = resolvedExitCode;
|
||||||
}
|
}
|
||||||
return createToolCallTimelineItem({
|
return createToolCallTimelineItem({
|
||||||
server: "command",
|
name: "shell",
|
||||||
tool: "shell",
|
|
||||||
status: item.status,
|
status: item.status,
|
||||||
callId: item.callId,
|
callId: item.callId,
|
||||||
displayName: command,
|
|
||||||
kind: "execute",
|
|
||||||
input: { command: item.command, cwd: item.cwd },
|
input: { command: item.command, cwd: item.cwd },
|
||||||
output: commandOutput,
|
output: commandOutput,
|
||||||
error: item.error,
|
error: item.error,
|
||||||
@@ -4089,12 +4048,9 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
? "running"
|
? "running"
|
||||||
: "completed";
|
: "completed";
|
||||||
return createToolCallTimelineItem({
|
return createToolCallTimelineItem({
|
||||||
server: "file_change",
|
name: "apply_patch",
|
||||||
tool: "apply_patch",
|
|
||||||
status,
|
status,
|
||||||
callId: item.callId,
|
callId: item.callId,
|
||||||
displayName: buildFileChangeSummary(summaryFiles),
|
|
||||||
kind: "edit",
|
|
||||||
input: { files: summaryFiles },
|
input: { files: summaryFiles },
|
||||||
output: { files: changes },
|
output: { files: changes },
|
||||||
});
|
});
|
||||||
@@ -4104,16 +4060,12 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const readItem = item as ReadFileThreadItem;
|
const readItem = item as ReadFileThreadItem;
|
||||||
const displayName = `Read ${readItem.path}`;
|
|
||||||
const output =
|
const output =
|
||||||
readItem.content !== undefined ? readItem.content : readItem.output;
|
readItem.content !== undefined ? readItem.content : readItem.output;
|
||||||
return createToolCallTimelineItem({
|
return createToolCallTimelineItem({
|
||||||
server: "file_read",
|
name: "read_file",
|
||||||
tool: "read_file",
|
|
||||||
status: readItem.status,
|
status: readItem.status,
|
||||||
callId: readItem.callId,
|
callId: readItem.callId,
|
||||||
displayName,
|
|
||||||
kind: "read",
|
|
||||||
input: readItem.input ? readItem.input : { path: readItem.path },
|
input: readItem.input ? readItem.input : { path: readItem.path },
|
||||||
output,
|
output,
|
||||||
});
|
});
|
||||||
@@ -4123,12 +4075,9 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return createToolCallTimelineItem({
|
return createToolCallTimelineItem({
|
||||||
server: item.server,
|
name: `${item.server}.${item.tool}`,
|
||||||
tool: item.tool,
|
|
||||||
status: item.status,
|
status: item.status,
|
||||||
callId: item.callId,
|
callId: item.callId,
|
||||||
displayName: `${item.server}.${item.tool}`,
|
|
||||||
kind: "tool",
|
|
||||||
input: item.input,
|
input: item.input,
|
||||||
output: item.output,
|
output: item.output,
|
||||||
});
|
});
|
||||||
@@ -4137,16 +4086,12 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
if (eventType && eventType !== "item.completed") {
|
if (eventType && eventType !== "item.completed") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const displayName = `Web search: ${item.query}`;
|
|
||||||
const output =
|
const output =
|
||||||
item.results !== undefined ? item.results : item.output;
|
item.results !== undefined ? item.results : item.output;
|
||||||
return createToolCallTimelineItem({
|
return createToolCallTimelineItem({
|
||||||
server: "web_search",
|
name: "web_search",
|
||||||
tool: "web_search",
|
|
||||||
status: item.status,
|
status: item.status,
|
||||||
callId: item.callId,
|
callId: item.callId,
|
||||||
displayName,
|
|
||||||
kind: "search",
|
|
||||||
input: item.input ? item.input : { query: item.query },
|
input: item.input ? item.input : { query: item.query },
|
||||||
output,
|
output,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
useTempClaudeConfigDir,
|
useTempClaudeConfigDir,
|
||||||
} from "./test-utils/index.js";
|
} from "./test-utils/index.js";
|
||||||
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
|
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
|
||||||
import type { AgentSnapshotPayload } from "./messages.js";
|
import type { AgentSnapshotPayload, SessionOutboundMessage } from "./messages.js";
|
||||||
|
|
||||||
function tmpCwd(): string {
|
function tmpCwd(): string {
|
||||||
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||||
@@ -3117,82 +3117,81 @@ describe("daemon E2E", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("tool call structure", () => {
|
describe("tool call structure", () => {
|
||||||
|
// Helper to extract and dedupe tool calls by callId, keeping the last (most complete) version
|
||||||
|
function extractToolCalls(
|
||||||
|
queue: SessionOutboundMessage[],
|
||||||
|
agentId: string
|
||||||
|
): AgentTimelineItem[] {
|
||||||
|
const byCallId = new Map<string, AgentTimelineItem>();
|
||||||
|
const noCallId: AgentTimelineItem[] = [];
|
||||||
|
|
||||||
|
for (const m of queue) {
|
||||||
|
if (
|
||||||
|
m.type === "agent_stream" &&
|
||||||
|
m.payload.agentId === agentId &&
|
||||||
|
m.payload.event.type === "timeline" &&
|
||||||
|
m.payload.event.item.type === "tool_call"
|
||||||
|
) {
|
||||||
|
const tc = m.payload.event.item;
|
||||||
|
if (tc.callId) {
|
||||||
|
byCallId.set(tc.callId, tc);
|
||||||
|
} else {
|
||||||
|
noCallId.push(tc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byCallId.values(), ...noCallId];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to log tool call structure in a consistent format
|
||||||
|
function logToolCall(prefix: string, tc: AgentTimelineItem): void {
|
||||||
|
if (tc.type !== "tool_call") return;
|
||||||
|
console.log(
|
||||||
|
`[${prefix}]`,
|
||||||
|
JSON.stringify({
|
||||||
|
name: tc.name,
|
||||||
|
callId: tc.callId,
|
||||||
|
status: tc.status,
|
||||||
|
hasInput: tc.input !== undefined,
|
||||||
|
hasOutput: tc.output !== undefined,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
test(
|
test(
|
||||||
"Claude agent tool calls have expected structure",
|
"Claude agent: Read tool",
|
||||||
async () => {
|
async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
// Create Claude agent with bypass permissions
|
|
||||||
const agent = await ctx.client.createAgent({
|
const agent = await ctx.client.createAgent({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd,
|
cwd,
|
||||||
title: "Tool Structure Test - Claude",
|
title: "Claude Read Test",
|
||||||
modeId: "bypassPermissions",
|
modeId: "bypassPermissions",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(agent.provider).toBe("claude");
|
|
||||||
|
|
||||||
ctx.client.clearMessageQueue();
|
ctx.client.clearMessageQueue();
|
||||||
|
|
||||||
// Prompt that triggers a Read tool call
|
|
||||||
await ctx.client.sendMessage(
|
await ctx.client.sendMessage(
|
||||||
agent.id,
|
agent.id,
|
||||||
"Read the file /etc/hosts and tell me how many lines it has. Be brief."
|
"Read the file /etc/hosts and tell me how many lines it has. Be brief."
|
||||||
);
|
);
|
||||||
|
|
||||||
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
|
await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
expect(finalState.status).toBe("idle");
|
|
||||||
|
|
||||||
// Extract tool_call timeline items
|
|
||||||
const queue = ctx.client.getMessageQueue();
|
|
||||||
const toolCalls: AgentTimelineItem[] = [];
|
|
||||||
for (const m of queue) {
|
|
||||||
if (
|
|
||||||
m.type === "agent_stream" &&
|
|
||||||
m.payload.agentId === agent.id &&
|
|
||||||
m.payload.event.type === "timeline" &&
|
|
||||||
m.payload.event.item.type === "tool_call"
|
|
||||||
) {
|
|
||||||
toolCalls.push(m.payload.event.item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
|
||||||
expect(toolCalls.length).toBeGreaterThan(0);
|
expect(toolCalls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Log and verify structure for each tool call
|
|
||||||
for (const tc of toolCalls) {
|
for (const tc of toolCalls) {
|
||||||
expect(tc.type).toBe("tool_call");
|
logToolCall("CLAUDE_READ", tc);
|
||||||
|
|
||||||
// Current structure has: server, tool, displayName, kind
|
|
||||||
expect(typeof tc.server).toBe("string");
|
|
||||||
expect(typeof tc.tool).toBe("string");
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
"[CLAUDE TOOL_CALL]",
|
|
||||||
JSON.stringify({
|
|
||||||
server: tc.server,
|
|
||||||
tool: tc.tool,
|
|
||||||
displayName: tc.displayName,
|
|
||||||
kind: tc.kind,
|
|
||||||
callId: tc.callId,
|
|
||||||
status: tc.status,
|
|
||||||
hasInput: tc.input !== undefined,
|
|
||||||
hasOutput: tc.output !== undefined,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find a Read tool call specifically
|
const readCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "Read");
|
||||||
const readCall = toolCalls.find(
|
|
||||||
(tc) =>
|
|
||||||
tc.server === "Read" ||
|
|
||||||
tc.tool === "Read" ||
|
|
||||||
tc.displayName === "Read"
|
|
||||||
);
|
|
||||||
expect(readCall).toBeDefined();
|
expect(readCall).toBeDefined();
|
||||||
|
expect(readCall?.name).toBe("Read");
|
||||||
expect(readCall?.input).toBeDefined();
|
expect(readCall?.input).toBeDefined();
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
await ctx.client.deleteAgent(agent.id);
|
await ctx.client.deleteAgent(agent.id);
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
},
|
},
|
||||||
@@ -3200,80 +3199,209 @@ describe("daemon E2E", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
"Codex agent tool calls have expected structure",
|
"Claude agent: Bash tool",
|
||||||
async () => {
|
async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
// Create Codex agent with full access
|
|
||||||
const agent = await ctx.client.createAgent({
|
const agent = await ctx.client.createAgent({
|
||||||
provider: "codex",
|
provider: "claude",
|
||||||
cwd,
|
cwd,
|
||||||
title: "Tool Structure Test - Codex",
|
title: "Claude Bash Test",
|
||||||
modeId: "full-access",
|
modeId: "bypassPermissions",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(agent.provider).toBe("codex");
|
|
||||||
|
|
||||||
ctx.client.clearMessageQueue();
|
ctx.client.clearMessageQueue();
|
||||||
|
|
||||||
// Prompt that triggers a shell command
|
|
||||||
await ctx.client.sendMessage(
|
await ctx.client.sendMessage(
|
||||||
agent.id,
|
agent.id,
|
||||||
"Run `echo hello` and tell me what it outputs. Be brief."
|
"Run `echo hello` and tell me what it outputs. Be brief."
|
||||||
);
|
);
|
||||||
|
|
||||||
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
|
await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
expect(finalState.status).toBe("idle");
|
|
||||||
|
|
||||||
// Extract tool_call timeline items
|
|
||||||
const queue = ctx.client.getMessageQueue();
|
|
||||||
const toolCalls: AgentTimelineItem[] = [];
|
|
||||||
for (const m of queue) {
|
|
||||||
if (
|
|
||||||
m.type === "agent_stream" &&
|
|
||||||
m.payload.agentId === agent.id &&
|
|
||||||
m.payload.event.type === "timeline" &&
|
|
||||||
m.payload.event.item.type === "tool_call"
|
|
||||||
) {
|
|
||||||
toolCalls.push(m.payload.event.item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
|
||||||
expect(toolCalls.length).toBeGreaterThan(0);
|
expect(toolCalls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Log and verify structure for each tool call
|
|
||||||
for (const tc of toolCalls) {
|
for (const tc of toolCalls) {
|
||||||
expect(tc.type).toBe("tool_call");
|
logToolCall("CLAUDE_BASH", tc);
|
||||||
|
|
||||||
// Current structure has: server, tool, displayName, kind
|
|
||||||
expect(typeof tc.server).toBe("string");
|
|
||||||
expect(typeof tc.tool).toBe("string");
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
"[CODEX TOOL_CALL]",
|
|
||||||
JSON.stringify({
|
|
||||||
server: tc.server,
|
|
||||||
tool: tc.tool,
|
|
||||||
displayName: tc.displayName,
|
|
||||||
kind: tc.kind,
|
|
||||||
callId: tc.callId,
|
|
||||||
status: tc.status,
|
|
||||||
hasInput: tc.input !== undefined,
|
|
||||||
hasOutput: tc.output !== undefined,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find a shell/execute tool call
|
const bashCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "Bash");
|
||||||
const shellCall = toolCalls.find(
|
expect(bashCall).toBeDefined();
|
||||||
(tc) =>
|
expect(bashCall?.name).toBe("Bash");
|
||||||
tc.kind === "execute" ||
|
expect(bashCall?.input).toBeDefined();
|
||||||
tc.server?.includes("shell") ||
|
// Command text should be in input.command
|
||||||
tc.displayName?.includes("echo")
|
const bashInput = bashCall?.input as { command?: string } | undefined;
|
||||||
|
expect(bashInput?.command).toContain("echo");
|
||||||
|
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
180000
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"Claude agent: Edit tool",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const testFile = path.join(cwd, "test.txt");
|
||||||
|
writeFileSync(testFile, "hello world\n");
|
||||||
|
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "claude",
|
||||||
|
cwd,
|
||||||
|
title: "Claude Edit Test",
|
||||||
|
modeId: "bypassPermissions",
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
`Edit the file ${testFile} and change "hello" to "goodbye". Be brief.`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
|
||||||
|
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
|
||||||
|
expect(toolCalls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const tc of toolCalls) {
|
||||||
|
logToolCall("CLAUDE_EDIT", tc);
|
||||||
|
}
|
||||||
|
|
||||||
|
const editCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "Edit");
|
||||||
|
expect(editCall).toBeDefined();
|
||||||
|
expect(editCall?.input).toBeDefined();
|
||||||
|
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
180000
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"Codex agent: shell command",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd,
|
||||||
|
title: "Codex Shell Test",
|
||||||
|
modeId: "full-access",
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
"Run `echo hello` and tell me what it outputs. Be brief."
|
||||||
|
);
|
||||||
|
|
||||||
|
await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
|
||||||
|
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
|
||||||
|
expect(toolCalls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const tc of toolCalls) {
|
||||||
|
logToolCall("CODEX_SHELL", tc);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shellCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "shell");
|
||||||
expect(shellCall).toBeDefined();
|
expect(shellCall).toBeDefined();
|
||||||
|
expect(shellCall?.name).toBe("shell");
|
||||||
|
expect(shellCall?.input).toBeDefined();
|
||||||
|
// Command text should be in input.command
|
||||||
|
const shellInput = shellCall?.input as { command?: string } | undefined;
|
||||||
|
expect(shellInput?.command).toContain("echo");
|
||||||
|
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
180000
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"Codex agent: file read",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd,
|
||||||
|
title: "Codex Read Test",
|
||||||
|
modeId: "full-access",
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
"Read the file /etc/hosts and tell me how many lines it has. Be brief."
|
||||||
|
);
|
||||||
|
|
||||||
|
await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
|
||||||
|
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
|
||||||
|
expect(toolCalls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const tc of toolCalls) {
|
||||||
|
logToolCall("CODEX_READ", tc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codex may use shell cat or file read
|
||||||
|
const readCall = toolCalls.find(
|
||||||
|
(tc) => tc.type === "tool_call" && tc.name === "read_file"
|
||||||
|
);
|
||||||
|
if (readCall) {
|
||||||
|
expect(readCall.name).toBe("read_file");
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
180000
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"Codex agent: file edit",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const testFile = path.join(cwd, "test.txt");
|
||||||
|
writeFileSync(testFile, "hello world\n");
|
||||||
|
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd,
|
||||||
|
title: "Codex Edit Test",
|
||||||
|
modeId: "full-access",
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
`Edit the file ${testFile} and change "hello" to "goodbye". Be brief.`
|
||||||
|
);
|
||||||
|
|
||||||
|
await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
|
||||||
|
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
|
||||||
|
expect(toolCalls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const tc of toolCalls) {
|
||||||
|
logToolCall("CODEX_EDIT", tc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codex uses apply_patch for edits
|
||||||
|
const editCall = toolCalls.find(
|
||||||
|
(tc) => tc.type === "tool_call" && tc.name === "apply_patch"
|
||||||
|
);
|
||||||
|
if (editCall) {
|
||||||
|
expect(editCall.name).toBe("apply_patch");
|
||||||
|
}
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
await ctx.client.deleteAgent(agent.id);
|
await ctx.client.deleteAgent(agent.id);
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -142,12 +142,9 @@ export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem> =
|
|||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal("tool_call"),
|
type: z.literal("tool_call"),
|
||||||
server: z.string(),
|
name: z.string(),
|
||||||
tool: z.string(),
|
|
||||||
status: z.string().optional(),
|
|
||||||
callId: z.string().optional(),
|
callId: z.string().optional(),
|
||||||
displayName: z.string().optional(),
|
status: z.string().optional(),
|
||||||
kind: z.string().optional(),
|
|
||||||
input: z.unknown().optional(),
|
input: z.unknown().optional(),
|
||||||
output: z.unknown().optional(),
|
output: z.unknown().optional(),
|
||||||
error: z.unknown().optional(),
|
error: z.unknown().optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user