diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index 66522a4ac..44348185a 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -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 => + 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); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 215e6d37e..a42c897a8 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -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(); + private toolUseCache = new Map(); private pendingPermissions = new Map(); private eventQueue: Pushable | 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 | null { + if (!input || typeof input !== "object") { + return null; + } + return input as Record; + } + + private isCommandTool(name: string, input: Record): 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 | 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): { 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(); + 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 implements AsyncIterable {