test(agent): enhance timeline event tracking for commands and file changes

This commit is contained in:
Mohamed Boudra
2025-11-14 19:59:34 +01:00
parent 0f3668c9ed
commit 5a2b84ee6f
2 changed files with 265 additions and 33 deletions

View File

@@ -4,7 +4,7 @@ import os from "node:os";
import path from "node:path";
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 {
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 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(toolEvent).toBeTruthy();
expect(sawPwdCommand).toBe(true);
expect(fileChangeEvent).toBeTruthy();
const filePath = path.join(cwd, "tool-test.txt");
expect(existsSync(filePath)).toBe(true);

View File

@@ -80,6 +80,18 @@ type PendingPermission = {
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;
export class ClaudeAgentClient implements AgentClient {
@@ -128,7 +140,7 @@ class ClaudeAgentSession implements AgentSession {
private persistence: AgentPersistenceHandle | null;
private currentMode: PermissionMode;
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 eventQueue: Pushable<AgentStreamEvent> | null = null;
private persistedHistory: AgentTimelineItem[] = [];
@@ -439,6 +451,16 @@ class ClaudeAgentSession implements AgentSession {
this.handleSystemMessage(message as SDKSystemMessage);
}
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": {
const timelineItems = this.mapBlocksToTimeline(message.message.content);
for (const item of timelineItems) {
@@ -717,22 +739,7 @@ class ClaudeAgentSession implements AgentSession {
case "tool_use":
case "server_tool_use":
case "mcp_tool_use": {
const name = block.name ?? "tool";
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,
});
this.handleToolUseStart(block, items);
break;
}
case "tool_result":
@@ -742,21 +749,7 @@ class ClaudeAgentSession implements AgentSession {
case "code_execution_tool_result":
case "bash_code_execution_tool_result":
case "text_editor_code_execution_tool_result": {
const cached = block.tool_use_id
? 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);
}
this.handleToolResult(block, items);
break;
}
default:
@@ -766,6 +759,73 @@ class ClaudeAgentSession implements AgentSession {
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[] {
switch (event.type) {
case "content_block_start":
@@ -776,6 +836,165 @@ class ClaudeAgentSession implements AgentSession {
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> {