test(agent): enhance timeline event tracking for commands and file changes
This commit is contained in:
@@ -4,7 +4,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||||
import type { AgentSessionConfig, AgentStreamEvent } from "../agent-sdk-types.js";
|
import type { AgentSessionConfig, AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js";
|
||||||
|
|
||||||
function tmpCwd(): string {
|
function tmpCwd(): string {
|
||||||
const dir = mkdtempSync(path.join(os.tmpdir(), "claude-agent-e2e-"));
|
const dir = mkdtempSync(path.join(os.tmpdir(), "claude-agent-e2e-"));
|
||||||
@@ -112,9 +112,22 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toolEvent = timeline.find((item) => item.type === "mcp_tool");
|
const toolEvent = timeline.find((item) => item.type === "mcp_tool");
|
||||||
|
const commandEvents = timeline.filter(
|
||||||
|
(item): item is Extract<AgentTimelineItem, { type: "command" }> =>
|
||||||
|
item.type === "command" && typeof item.command === "string" && !item.command.startsWith("permission:")
|
||||||
|
);
|
||||||
|
const fileChangeEvent = timeline.find(
|
||||||
|
(item) =>
|
||||||
|
item.type === "file_change" &&
|
||||||
|
item.files.some((file) => file.path.includes("tool-test.txt"))
|
||||||
|
);
|
||||||
|
|
||||||
|
const sawPwdCommand = commandEvents.some((item) => item.command.toLowerCase().includes("pwd") && item.status === "completed");
|
||||||
|
|
||||||
expect(completed).toBe(true);
|
expect(completed).toBe(true);
|
||||||
expect(toolEvent).toBeTruthy();
|
expect(toolEvent).toBeTruthy();
|
||||||
|
expect(sawPwdCommand).toBe(true);
|
||||||
|
expect(fileChangeEvent).toBeTruthy();
|
||||||
|
|
||||||
const filePath = path.join(cwd, "tool-test.txt");
|
const filePath = path.join(cwd, "tool-test.txt");
|
||||||
expect(existsSync(filePath)).toBe(true);
|
expect(existsSync(filePath)).toBe(true);
|
||||||
|
|||||||
@@ -80,6 +80,18 @@ type PendingPermission = {
|
|||||||
cleanup?: () => void;
|
cleanup?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ToolUseClassification = "generic" | "command" | "file_change";
|
||||||
|
|
||||||
|
type ToolUseCacheEntry = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
server: string;
|
||||||
|
classification: ToolUseClassification;
|
||||||
|
started: boolean;
|
||||||
|
commandText?: string;
|
||||||
|
files?: { path: string; kind: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_PERMISSION_TIMEOUT_MS = 120_000;
|
const DEFAULT_PERMISSION_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
export class ClaudeAgentClient implements AgentClient {
|
export class ClaudeAgentClient implements AgentClient {
|
||||||
@@ -128,7 +140,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private persistence: AgentPersistenceHandle | null;
|
private persistence: AgentPersistenceHandle | null;
|
||||||
private currentMode: PermissionMode;
|
private currentMode: PermissionMode;
|
||||||
private availableModes: AgentMode[] = DEFAULT_MODES;
|
private availableModes: AgentMode[] = DEFAULT_MODES;
|
||||||
private toolUseCache = new Map<string, { name: string; server: string }>();
|
private toolUseCache = new Map<string, ToolUseCacheEntry>();
|
||||||
private pendingPermissions = new Map<string, PendingPermission>();
|
private pendingPermissions = new Map<string, PendingPermission>();
|
||||||
private eventQueue: Pushable<AgentStreamEvent> | null = null;
|
private eventQueue: Pushable<AgentStreamEvent> | null = null;
|
||||||
private persistedHistory: AgentTimelineItem[] = [];
|
private persistedHistory: AgentTimelineItem[] = [];
|
||||||
@@ -439,6 +451,16 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.handleSystemMessage(message as SDKSystemMessage);
|
this.handleSystemMessage(message as SDKSystemMessage);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case "user": {
|
||||||
|
const content = (message as SDKUserMessage).message?.content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
const timelineItems = this.mapBlocksToTimeline(content);
|
||||||
|
for (const item of timelineItems) {
|
||||||
|
events.push({ type: "timeline", item, provider: "claude" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "assistant": {
|
case "assistant": {
|
||||||
const timelineItems = this.mapBlocksToTimeline(message.message.content);
|
const timelineItems = this.mapBlocksToTimeline(message.message.content);
|
||||||
for (const item of timelineItems) {
|
for (const item of timelineItems) {
|
||||||
@@ -717,22 +739,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
case "tool_use":
|
case "tool_use":
|
||||||
case "server_tool_use":
|
case "server_tool_use":
|
||||||
case "mcp_tool_use": {
|
case "mcp_tool_use": {
|
||||||
const name = block.name ?? "tool";
|
this.handleToolUseStart(block, items);
|
||||||
const server = block.server ?? name;
|
|
||||||
this.toolUseCache.set(block.id, { name, server });
|
|
||||||
this.enqueueTimeline({
|
|
||||||
type: "command",
|
|
||||||
command: `permission:${name}`,
|
|
||||||
status: "granted",
|
|
||||||
raw: block,
|
|
||||||
});
|
|
||||||
items.push({
|
|
||||||
type: "mcp_tool",
|
|
||||||
server,
|
|
||||||
tool: name,
|
|
||||||
status: "pending",
|
|
||||||
raw: block,
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "tool_result":
|
case "tool_result":
|
||||||
@@ -742,21 +749,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
case "code_execution_tool_result":
|
case "code_execution_tool_result":
|
||||||
case "bash_code_execution_tool_result":
|
case "bash_code_execution_tool_result":
|
||||||
case "text_editor_code_execution_tool_result": {
|
case "text_editor_code_execution_tool_result": {
|
||||||
const cached = block.tool_use_id
|
this.handleToolResult(block, items);
|
||||||
? this.toolUseCache.get(block.tool_use_id)
|
|
||||||
: undefined;
|
|
||||||
const server = cached?.server ?? block.server ?? "tool";
|
|
||||||
const tool = cached?.name ?? block.tool_name ?? "tool";
|
|
||||||
items.push({
|
|
||||||
type: "mcp_tool",
|
|
||||||
server,
|
|
||||||
tool,
|
|
||||||
status: block.is_error ? "failed" : "completed",
|
|
||||||
raw: block,
|
|
||||||
});
|
|
||||||
if (!block.is_error && block.tool_use_id) {
|
|
||||||
this.toolUseCache.delete(block.tool_use_id);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -766,6 +759,73 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleToolUseStart(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
|
||||||
|
const entry = this.upsertToolUseEntry(block);
|
||||||
|
if (!entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entry.started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entry.started = true;
|
||||||
|
this.toolUseCache.set(entry.id, entry);
|
||||||
|
this.enqueueTimeline({
|
||||||
|
type: "command",
|
||||||
|
command: `permission:${entry.name}`,
|
||||||
|
status: "granted",
|
||||||
|
raw: block,
|
||||||
|
});
|
||||||
|
if (entry.classification === "command") {
|
||||||
|
const commandText = entry.commandText ?? entry.name;
|
||||||
|
items.push({
|
||||||
|
type: "command",
|
||||||
|
command: commandText,
|
||||||
|
status: "running",
|
||||||
|
raw: block,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
type: "mcp_tool",
|
||||||
|
server: entry.server,
|
||||||
|
tool: entry.name,
|
||||||
|
status: "pending",
|
||||||
|
raw: block,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
|
||||||
|
const entry = typeof block.tool_use_id === "string" ? this.toolUseCache.get(block.tool_use_id) : undefined;
|
||||||
|
const server = entry?.server ?? block.server ?? "tool";
|
||||||
|
const tool = entry?.name ?? block.tool_name ?? "tool";
|
||||||
|
const status = block.is_error ? "failed" : "completed";
|
||||||
|
items.push({
|
||||||
|
type: "mcp_tool",
|
||||||
|
server,
|
||||||
|
tool,
|
||||||
|
status,
|
||||||
|
raw: block,
|
||||||
|
});
|
||||||
|
if (entry?.classification === "command") {
|
||||||
|
const commandText = entry.commandText ?? entry.name;
|
||||||
|
items.push({
|
||||||
|
type: "command",
|
||||||
|
command: commandText,
|
||||||
|
status,
|
||||||
|
raw: block,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!block.is_error && entry?.classification === "file_change" && entry.files?.length) {
|
||||||
|
items.push({
|
||||||
|
type: "file_change",
|
||||||
|
files: entry.files,
|
||||||
|
raw: { block, files: entry.files },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof block.tool_use_id === "string") {
|
||||||
|
this.toolUseCache.delete(block.tool_use_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private mapPartialEvent(event: SDKPartialAssistantMessage["event"]): AgentTimelineItem[] {
|
private mapPartialEvent(event: SDKPartialAssistantMessage["event"]): AgentTimelineItem[] {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "content_block_start":
|
case "content_block_start":
|
||||||
@@ -776,6 +836,165 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private upsertToolUseEntry(block: ClaudeContentChunk): ToolUseCacheEntry | null {
|
||||||
|
const id = typeof block.id === "string" ? block.id : undefined;
|
||||||
|
if (!id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const existing =
|
||||||
|
this.toolUseCache.get(id) ??
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
name: typeof block.name === "string" && block.name.length > 0 ? block.name : "tool",
|
||||||
|
server:
|
||||||
|
typeof block.server === "string" && block.server.length > 0
|
||||||
|
? block.server
|
||||||
|
: typeof block.name === "string" && block.name.length > 0
|
||||||
|
? block.name
|
||||||
|
: "tool",
|
||||||
|
classification: "generic",
|
||||||
|
started: false,
|
||||||
|
} satisfies ToolUseCacheEntry);
|
||||||
|
|
||||||
|
if (typeof block.name === "string" && block.name.length > 0) {
|
||||||
|
existing.name = block.name;
|
||||||
|
}
|
||||||
|
if (typeof block.server === "string" && block.server.length > 0) {
|
||||||
|
existing.server = block.server;
|
||||||
|
} else if (!existing.server) {
|
||||||
|
existing.server = existing.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === "tool_use") {
|
||||||
|
const input = this.normalizeToolInput(block.input);
|
||||||
|
if (input) {
|
||||||
|
if (this.isCommandTool(existing.name, input)) {
|
||||||
|
existing.classification = "command";
|
||||||
|
existing.commandText = this.extractCommandText(input) ?? existing.commandText;
|
||||||
|
} else {
|
||||||
|
const files = this.extractFileChanges(input);
|
||||||
|
if (files?.length) {
|
||||||
|
existing.classification = "file_change";
|
||||||
|
existing.files = files;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.toolUseCache.set(id, existing);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeToolInput(input: unknown): Record<string, unknown> | null {
|
||||||
|
if (!input || typeof input !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return input as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCommandTool(name: string, input: Record<string, unknown>): boolean {
|
||||||
|
const normalized = name.toLowerCase();
|
||||||
|
if (normalized.includes("bash") || normalized.includes("shell") || normalized.includes("terminal") || normalized.includes("command")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof input.command === "string" || Array.isArray(input.command)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractCommandText(input: Record<string, unknown>): string | undefined {
|
||||||
|
const command = input.command;
|
||||||
|
if (typeof command === "string" && command.length > 0) {
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
if (Array.isArray(command)) {
|
||||||
|
const tokens = command.filter((value): value is string => typeof value === "string");
|
||||||
|
if (tokens.length > 0) {
|
||||||
|
return tokens.join(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof input.description === "string" && input.description.length > 0) {
|
||||||
|
return input.description;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractFileChanges(input: Record<string, unknown>): { path: string; kind: string }[] | undefined {
|
||||||
|
if (typeof input.file_path === "string" && input.file_path.length > 0) {
|
||||||
|
const relative = this.relativizePath(input.file_path);
|
||||||
|
if (relative) {
|
||||||
|
return [{ path: relative, kind: this.detectFileKind(input.file_path) }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof input.patch === "string" && input.patch.length > 0) {
|
||||||
|
const files = this.parsePatchFileList(input.patch);
|
||||||
|
if (files.length > 0) {
|
||||||
|
return files.map((entry) => ({
|
||||||
|
path: this.relativizePath(entry.path) ?? entry.path,
|
||||||
|
kind: entry.kind,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(input.files)) {
|
||||||
|
const files: { path: string; kind: string }[] = [];
|
||||||
|
for (const value of input.files) {
|
||||||
|
if (typeof value === "string" && value.length > 0) {
|
||||||
|
files.push({ path: this.relativizePath(value) ?? value, kind: this.detectFileKind(value) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (files.length > 0) {
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private detectFileKind(filePath: string): string {
|
||||||
|
try {
|
||||||
|
return fs.existsSync(filePath) ? "update" : "add";
|
||||||
|
} catch {
|
||||||
|
return "update";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private relativizePath(target?: string): string | undefined {
|
||||||
|
if (!target) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const cwd = this.config.cwd;
|
||||||
|
if (cwd && target.startsWith(cwd)) {
|
||||||
|
const relative = path.relative(cwd, target);
|
||||||
|
return relative.length > 0 ? relative : path.basename(target);
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePatchFileList(patch: string): { path: string; kind: string }[] {
|
||||||
|
const files: { path: string; kind: string }[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const line of patch.split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
let kind: string | null = null;
|
||||||
|
let parsedPath: string | null = null;
|
||||||
|
if (trimmed.startsWith("*** Add File:")) {
|
||||||
|
kind = "add";
|
||||||
|
parsedPath = trimmed.replace("*** Add File:", "").trim();
|
||||||
|
} else if (trimmed.startsWith("*** Delete File:")) {
|
||||||
|
kind = "delete";
|
||||||
|
parsedPath = trimmed.replace("*** Delete File:", "").trim();
|
||||||
|
} else if (trimmed.startsWith("*** Update File:")) {
|
||||||
|
kind = "update";
|
||||||
|
parsedPath = trimmed.replace("*** Update File:", "").trim();
|
||||||
|
}
|
||||||
|
if (kind && parsedPath && !seen.has(`${kind}:${parsedPath}`)) {
|
||||||
|
seen.add(`${kind}:${parsedPath}`);
|
||||||
|
files.push({ path: parsedPath, kind });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Pushable<T> implements AsyncIterable<T> {
|
class Pushable<T> implements AsyncIterable<T> {
|
||||||
|
|||||||
Reference in New Issue
Block a user