diff --git a/CHANGELOG.internal.md b/CHANGELOG.internal.md index 29f2f04c4..d163f5d30 100644 --- a/CHANGELOG.internal.md +++ b/CHANGELOG.internal.md @@ -4,6 +4,9 @@ This changelog documents internal development changes, refactors, tooling update ## [Unreleased] +### Added +- Added parallel tool group tracking to `AgentSessionManager`: new `parallelToolGroups` Map state, `extractAllToolInfo()` and `extractAllToolResultInfo()` methods to detect multiple `tool_use`/`tool_result` blocks per message (previous `extractToolInfo()` used `.find()` which only captured the first block). Added `createSessionEntryForTool()`/`createSessionEntryForToolResult()` for individual block processing. Modified `handleClaudeMessage()` assistant/user cases to detect and route multi-block messages. Modified `syncEntryToActivitySink()` to suppress individual activities for parallel groups and post unified ephemeral activities via `postParallelGroupActivity()`. Added `formatParallelToolGroup()` to `IMessageFormatter` interface in `packages/core/src/agent-runner-types.ts` and implemented in all 4 runners (Claude, Codex, Cursor, Gemini) with tree-like markdown formatting (`├─`/`└─` with `⏳`/`✅`/`❌` status icons). Added 11 tests in `AgentSessionManager.parallel-tools.test.ts`. ([CYPACK-886](https://linear.app/ceedar/issue/CYPACK-886), [#937](https://github.com/ceedaragents/cyrus/pull/937)) + ## [0.2.28] - 2026-03-04 ### Added diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e68ea01f..0692cc142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Changed +- **Parallel tool calls now show as a single unified activity** - When the agent runs multiple tools in parallel, they are displayed as one compact tree-like view with live status updates (pending/completed/error) instead of flooding the timeline with separate activities for each tool. The unified activity is ephemeral and automatically replaced when the next action occurs. ([CYPACK-886](https://linear.app/ceedar/issue/CYPACK-886), [#937](https://github.com/ceedaragents/cyrus/pull/937)) + ## [0.2.28] - 2026-03-04 ### Fixed diff --git a/packages/claude-runner/src/formatter.ts b/packages/claude-runner/src/formatter.ts index 4fb1caf67..2153bdec9 100644 --- a/packages/claude-runner/src/formatter.ts +++ b/packages/claude-runner/src/formatter.ts @@ -60,6 +60,21 @@ export interface IMessageFormatter { result: string, isError: boolean, ): string; + + /** + * Format a group of parallel tool calls as a unified view. + * Used for ephemeral activities that show all parallel tools in a tree-like structure. + * @param tools - Array of tool info with status + * @returns Formatted markdown string showing the parallel tools + */ + formatParallelToolGroup( + tools: Array<{ + name: string; + input: any; + status: "pending" | "completed"; + isError?: boolean; + }>, + ): string; } /** @@ -647,4 +662,47 @@ export class ClaudeMessageFormatter implements IMessageFormatter { return result || ""; } } + + /** + * Format a group of parallel tool calls as a unified view. + * Produces a tree-like structure similar to Claude Code's native parallel agent display. + */ + formatParallelToolGroup( + tools: Array<{ + name: string; + input: any; + status: "pending" | "completed"; + isError?: boolean; + }>, + ): string { + const totalTools = tools.length; + const completedCount = tools.filter((t) => t.status === "completed").length; + + // Determine the dominant tool type for the header + const toolNames = tools.map((t) => t.name.replace("↪ ", "")); + const uniqueNames = [...new Set(toolNames)]; + const headerToolName = + uniqueNames.length === 1 ? `${uniqueNames[0]} calls` : "parallel tools"; + + let body = `**Running ${totalTools} ${headerToolName}** (${completedCount}/${totalTools} complete)\n`; + + for (let i = 0; i < tools.length; i++) { + const tool = tools[i]!; + const isLast = i === tools.length - 1; + const prefix = isLast ? "└─" : "├─"; + + const statusIcon = + tool.status === "completed" ? (tool.isError ? "❌" : "✅") : "⏳"; + + const displayName = tool.name.replace("↪ ", ""); + const param = this.formatToolParameter(tool.name, tool.input); + // Truncate parameter for readability + const shortParam = + param.length > 80 ? `${param.substring(0, 77)}…` : param; + + body += `${prefix} ${statusIcon} **${displayName}**: ${shortParam}\n`; + } + + return body; + } } diff --git a/packages/codex-runner/src/formatter.ts b/packages/codex-runner/src/formatter.ts index 34dca380c..77d4144be 100644 --- a/packages/codex-runner/src/formatter.ts +++ b/packages/codex-runner/src/formatter.ts @@ -189,4 +189,38 @@ export class CodexMessageFormatter implements IMessageFormatter { } return normalized; } + + formatParallelToolGroup( + tools: Array<{ + name: string; + input: any; + status: "pending" | "completed"; + isError?: boolean; + }>, + ): string { + const totalTools = tools.length; + const completedCount = tools.filter((t) => t.status === "completed").length; + + const toolNames = tools.map((t) => t.name.replace("↪ ", "")); + const uniqueNames = [...new Set(toolNames)]; + const headerToolName = + uniqueNames.length === 1 ? `${uniqueNames[0]} calls` : "parallel tools"; + + let body = `**Running ${totalTools} ${headerToolName}** (${completedCount}/${totalTools} complete)\n`; + + for (let i = 0; i < tools.length; i++) { + const tool = tools[i]!; + const isLast = i === tools.length - 1; + const prefix = isLast ? "└─" : "├─"; + const statusIcon = + tool.status === "completed" ? (tool.isError ? "❌" : "✅") : "⏳"; + const displayName = tool.name.replace("↪ ", ""); + const param = this.formatToolParameter(tool.name, tool.input); + const shortParam = + param.length > 80 ? `${param.substring(0, 77)}…` : param; + body += `${prefix} ${statusIcon} **${displayName}**: ${shortParam}\n`; + } + + return body; + } } diff --git a/packages/core/src/agent-runner-types.ts b/packages/core/src/agent-runner-types.ts index bb32140ff..a4728665f 100644 --- a/packages/core/src/agent-runner-types.ts +++ b/packages/core/src/agent-runner-types.ts @@ -146,6 +146,18 @@ export interface IMessageFormatter { result: string, isError: boolean, ): string; + /** + * Format a group of parallel tool calls as a unified view. + * Used for ephemeral activities that show all parallel tools in a tree-like structure. + */ + formatParallelToolGroup( + tools: Array<{ + name: string; + input: any; + status: "pending" | "completed"; + isError?: boolean; + }>, + ): string; } /** diff --git a/packages/cursor-runner/src/formatter.ts b/packages/cursor-runner/src/formatter.ts index 40c909491..a2725d26b 100644 --- a/packages/cursor-runner/src/formatter.ts +++ b/packages/cursor-runner/src/formatter.ts @@ -189,4 +189,38 @@ export class CursorMessageFormatter implements IMessageFormatter { } return normalized; } + + formatParallelToolGroup( + tools: Array<{ + name: string; + input: any; + status: "pending" | "completed"; + isError?: boolean; + }>, + ): string { + const totalTools = tools.length; + const completedCount = tools.filter((t) => t.status === "completed").length; + + const toolNames = tools.map((t) => t.name.replace("↪ ", "")); + const uniqueNames = [...new Set(toolNames)]; + const headerToolName = + uniqueNames.length === 1 ? `${uniqueNames[0]} calls` : "parallel tools"; + + let body = `**Running ${totalTools} ${headerToolName}** (${completedCount}/${totalTools} complete)\n`; + + for (let i = 0; i < tools.length; i++) { + const tool = tools[i]!; + const isLast = i === tools.length - 1; + const prefix = isLast ? "└─" : "├─"; + const statusIcon = + tool.status === "completed" ? (tool.isError ? "❌" : "✅") : "⏳"; + const displayName = tool.name.replace("↪ ", ""); + const param = this.formatToolParameter(tool.name, tool.input); + const shortParam = + param.length > 80 ? `${param.substring(0, 77)}…` : param; + body += `${prefix} ${statusIcon} **${displayName}**: ${shortParam}\n`; + } + + return body; + } } diff --git a/packages/edge-worker/src/AgentSessionManager.ts b/packages/edge-worker/src/AgentSessionManager.ts index 611b05d60..7a0946e11 100644 --- a/packages/edge-worker/src/AgentSessionManager.ts +++ b/packages/edge-worker/src/AgentSessionManager.ts @@ -102,6 +102,21 @@ export class AgentSessionManager extends EventEmitter { private taskSubjectsById: Map = new Map(); // Cache task subjects by task ID (e.g., "1" → "Fix login bug") private activeStatusActivitiesBySession: Map = new Map(); // Maps session ID to active compacting status activity ID private stopRequestedSessions: Set = new Set(); // Sessions explicitly stopped by user signal + /** Tracks groups of parallel tool calls per session for unified ephemeral display */ + private parallelToolGroups: Map< + string, + { + tools: Map< + string, + { + name: string; + input: any; + status: "pending" | "completed"; + isError?: boolean; + } + >; + } + > = new Map(); private procedureAnalyzer?: ProcedureAnalyzer; private sharedApplicationServer?: SharedApplicationServer; private getParentSessionId?: (childSessionId: string) => string | undefined; @@ -340,6 +355,88 @@ export class AgentSessionManager extends EventEmitter { return sessionEntry; } + /** + * Create a session entry for a specific tool_use block (used for parallel tool calls). + * Unlike createSessionEntry which extracts only the first tool, this takes a specific tool. + */ + private async createSessionEntryForTool( + sessionId: string, + sdkMessage: SDKAssistantMessage, + toolInfo: { id: string; name: string; input: any }, + ): Promise { + const sdkError = sdkMessage.error; + + const session = this.sessions.get(sessionId); + const runner = session?.agentRunner; + const runnerType = + runner?.constructor.name === "GeminiRunner" + ? "gemini" + : runner?.constructor.name === "CodexRunner" + ? "codex" + : runner?.constructor.name === "CursorRunner" + ? "cursor" + : "claude"; + + return { + ...(runnerType === "gemini" + ? { geminiSessionId: sdkMessage.session_id } + : runnerType === "codex" + ? { codexSessionId: sdkMessage.session_id } + : runnerType === "cursor" + ? { cursorSessionId: sdkMessage.session_id } + : { claudeSessionId: sdkMessage.session_id }), + type: "assistant", + content: this.extractContent(sdkMessage), + metadata: { + timestamp: Date.now(), + parentToolUseId: sdkMessage.parent_tool_use_id || undefined, + toolUseId: toolInfo.id, + toolName: toolInfo.name, + toolInput: toolInfo.input, + ...(sdkError && { sdkError }), + }, + }; + } + + /** + * Create a session entry for a specific tool_result block (used for parallel tool results). + * Unlike createSessionEntry which extracts only the first result, this takes a specific result. + */ + private async createSessionEntryForToolResult( + sessionId: string, + sdkMessage: SDKUserMessage, + resultInfo: { toolUseId: string; isError: boolean; content?: string }, + ): Promise { + const session = this.sessions.get(sessionId); + const runner = session?.agentRunner; + const runnerType = + runner?.constructor.name === "GeminiRunner" + ? "gemini" + : runner?.constructor.name === "CodexRunner" + ? "codex" + : runner?.constructor.name === "CursorRunner" + ? "cursor" + : "claude"; + + return { + ...(runnerType === "gemini" + ? { geminiSessionId: sdkMessage.session_id } + : runnerType === "codex" + ? { codexSessionId: sdkMessage.session_id } + : runnerType === "cursor" + ? { cursorSessionId: sdkMessage.session_id } + : { claudeSessionId: sdkMessage.session_id }), + type: "user", + content: resultInfo.content || this.extractContent(sdkMessage), + metadata: { + timestamp: Date.now(), + parentToolUseId: sdkMessage.parent_tool_use_id || undefined, + toolUseId: resultInfo.toolUseId, + toolResultError: resultInfo.isError, + }, + }; + } + /** * Complete a session from Claude result message */ @@ -359,6 +456,9 @@ export class AgentSessionManager extends EventEmitter { // Clear any active Task when session completes this.activeTasksBySession.delete(sessionId); + // Clear parallel tool group tracking for this session + this.parallelToolGroups.delete(sessionId); + // Clear tool calls tracking for this session // Note: We should ideally track by session, but for now clearing all is safer // to prevent memory leaks @@ -876,20 +976,50 @@ export class AgentSessionManager extends EventEmitter { break; case "user": { - const userEntry = await this.createSessionEntry( - sessionId, - message as SDKUserMessage, - ); - await this.syncEntryToActivitySink(userEntry, sessionId); + const sdkUserMsg = message as SDKUserMessage; + const allToolResults = this.extractAllToolResultInfo(sdkUserMsg); + + if (allToolResults.length > 1) { + // Multiple parallel tool results in one message — process each + for (const resultInfo of allToolResults) { + const entry = await this.createSessionEntryForToolResult( + sessionId, + sdkUserMsg, + resultInfo, + ); + await this.syncEntryToActivitySink(entry, sessionId); + } + } else { + const userEntry = await this.createSessionEntry( + sessionId, + sdkUserMsg, + ); + await this.syncEntryToActivitySink(userEntry, sessionId); + } break; } case "assistant": { - const assistantEntry = await this.createSessionEntry( - sessionId, - message as SDKAssistantMessage, - ); - await this.syncEntryToActivitySink(assistantEntry, sessionId); + const sdkAssistantMsg = message as SDKAssistantMessage; + const allToolUses = this.extractAllToolInfo(sdkAssistantMsg); + + if (allToolUses.length > 1) { + // Multiple parallel tool calls in one message — process each + for (const toolInfo of allToolUses) { + const entry = await this.createSessionEntryForTool( + sessionId, + sdkAssistantMsg, + toolInfo, + ); + await this.syncEntryToActivitySink(entry, sessionId); + } + } else { + const assistantEntry = await this.createSessionEntry( + sessionId, + sdkAssistantMsg, + ); + await this.syncEntryToActivitySink(assistantEntry, sessionId); + } break; } @@ -1080,6 +1210,64 @@ export class AgentSessionManager extends EventEmitter { return null; } + /** + * Extract ALL tool_use blocks from a Claude assistant message (not just the first). + * Returns an array of tool info objects for all parallel tool calls. + */ + private extractAllToolInfo( + sdkMessage: SDKAssistantMessage, + ): Array<{ id: string; name: string; input: any }> { + const message = sdkMessage.message as APIAssistantMessage; + const tools: Array<{ id: string; name: string; input: any }> = []; + + if (Array.isArray(message.content)) { + for (const block of message.content) { + if ( + block.type === "tool_use" && + "id" in block && + "name" in block && + "input" in block + ) { + tools.push({ + id: block.id, + name: block.name, + input: block.input, + }); + } + } + } + return tools; + } + + /** + * Extract ALL tool_result blocks from a Claude user message (not just the first). + * Returns an array of tool result info objects for all parallel tool results. + */ + private extractAllToolResultInfo( + sdkMessage: SDKUserMessage, + ): Array<{ toolUseId: string; isError: boolean; content?: string }> { + const message = sdkMessage.message as APIUserMessage; + const results: Array<{ + toolUseId: string; + isError: boolean; + content?: string; + }> = []; + + if (Array.isArray(message.content)) { + for (const block of message.content) { + if (block.type === "tool_result" && "tool_use_id" in block) { + results.push({ + toolUseId: block.tool_use_id, + isError: "is_error" in block && block.is_error === true, + content: + typeof block.content === "string" ? block.content : undefined, + }); + } + } + } + return results; + } + /** * Extract tool result content and error status from session entry */ @@ -1120,6 +1308,39 @@ export class AgentSessionManager extends EventEmitter { let ephemeral = false; switch (entry.type) { case "user": { + // --- Parallel tool group handling for tool results --- + if (entry.metadata?.toolUseId) { + const group = this.parallelToolGroups.get(sessionId); + if (group?.tools.has(entry.metadata.toolUseId)) { + const isParallel = group.tools.size > 1; + + if (isParallel) { + const toolEntry = group.tools.get(entry.metadata.toolUseId)!; + toolEntry.status = "completed"; + toolEntry.isError = entry.metadata.toolResultError || false; + + // Clean up tool call tracking for parallel tools + this.toolCallsByToolUseId.delete(entry.metadata.toolUseId); + + const pendingCount = [...group.tools.values()].filter( + (t) => t.status === "pending", + ).length; + + if (pendingCount > 0) { + // Still pending tools — post updated ephemeral + await this.postParallelGroupActivity(sessionId); + } else { + // All tools completed — clear group, let next activity replace ephemeral + this.parallelToolGroups.delete(sessionId); + } + return; // Don't post individual result for grouped tools + } + + // Single tool in group — clear and fall through to normal handling + this.parallelToolGroups.delete(sessionId); + } + } + const activeTaskId = this.activeTasksBySession.get(sessionId); if (activeTaskId && activeTaskId === entry.metadata?.toolUseId) { content = { @@ -1304,6 +1525,44 @@ export class AgentSessionManager extends EventEmitter { name: storedName, input: entry.metadata.toolInput || entry.content, }); + + // --- Parallel tool group tracking --- + // Tools that have their own non-standard posting (TaskCreate, TaskUpdate, etc.) + // still participate in parallel groups for display purposes. + const toolInput = entry.metadata.toolInput || entry.content; + + // Get or create the parallel group for this session + let group = this.parallelToolGroups.get(sessionId); + if (!group) { + group = { tools: new Map() }; + this.parallelToolGroups.set(sessionId, group); + } + + // If there are completed tools from a previous batch, clear them + const allCompleted = + group.tools.size > 0 && + [...group.tools.values()].every( + (t) => t.status === "completed", + ); + if (allCompleted) { + group.tools.clear(); + } + + group.tools.set(entry.metadata.toolUseId, { + name: storedName, + input: toolInput, + status: "pending", + }); + + // Check if there are other pending tools → parallel group + const pendingTools = [...group.tools.values()].filter( + (t) => t.status === "pending", + ); + if (pendingTools.length > 1) { + // Multiple pending tools — post unified ephemeral + await this.postParallelGroupActivity(sessionId); + return; // Don't post individual activity + } } // Skip AskUserQuestion tool - it's custom handled via Linear's select signal elicitation @@ -1549,6 +1808,31 @@ export class AgentSessionManager extends EventEmitter { } } + /** + * Post a unified ephemeral activity for a group of parallel tool calls. + * Shows a tree-like view of all tools in the group with their status. + */ + private async postParallelGroupActivity(sessionId: string): Promise { + const group = this.parallelToolGroups.get(sessionId); + if (!group || group.tools.size === 0) return; + + const session = this.sessions.get(sessionId); + const formatter = session?.agentRunner?.getFormatter(); + if (!formatter) return; + + const tools = [...group.tools.values()]; + const body = formatter.formatParallelToolGroup(tools); + + await this.postActivity( + sessionId, + { + content: { type: "thought", body }, + ephemeral: true, + }, + "parallel-group", + ); + } + /** * Get session by ID */ @@ -1821,6 +2105,7 @@ export class AgentSessionManager extends EventEmitter { const log = this.sessionLog(sessionId); this.sessions.delete(sessionId); this.entries.delete(sessionId); + this.parallelToolGroups.delete(sessionId); log.debug(`Cleaned up session`); } } diff --git a/packages/edge-worker/test/AgentSessionManager.parallel-tools.test.ts b/packages/edge-worker/test/AgentSessionManager.parallel-tools.test.ts new file mode 100644 index 000000000..d4cc1caf3 --- /dev/null +++ b/packages/edge-worker/test/AgentSessionManager.parallel-tools.test.ts @@ -0,0 +1,430 @@ +import type { SDKAssistantMessage, SDKUserMessage } from "cyrus-claude-runner"; +import { ClaudeMessageFormatter } from "cyrus-claude-runner"; +import type { IAgentRunner } from "cyrus-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AgentSessionManager } from "../src/AgentSessionManager"; +import type { IActivitySink } from "../src/sinks/IActivitySink"; + +/** + * Helper to create a mock IAgentRunner with a ClaudeMessageFormatter + */ +function createMockRunner(): IAgentRunner { + const formatter = new ClaudeMessageFormatter(); + return { + getFormatter: () => formatter, + supportsStreamingInput: false, + } as unknown as IAgentRunner; +} + +/** + * Helper to create an SDK assistant message with multiple parallel tool_use blocks + */ +function createParallelAssistantMessage( + tools: Array<{ id: string; name: string; input: any }>, + sessionId = "claude-session-1", +): SDKAssistantMessage { + return { + type: "assistant", + session_id: sessionId, + message: { + role: "assistant", + content: tools.map((t) => ({ + type: "tool_use" as const, + id: t.id, + name: t.name, + input: t.input, + })), + }, + } as SDKAssistantMessage; +} + +/** + * Helper to create an SDK user message with multiple parallel tool_result blocks + */ +function createParallelUserMessage( + results: Array<{ + toolUseId: string; + content: string; + isError?: boolean; + }>, + sessionId = "claude-session-1", +): SDKUserMessage { + return { + type: "user", + session_id: sessionId, + message: { + role: "user", + content: results.map((r) => ({ + type: "tool_result" as const, + tool_use_id: r.toolUseId, + content: r.content, + is_error: r.isError || false, + })), + }, + } as SDKUserMessage; +} + +/** + * Helper to create a single-tool assistant message + */ +function createSingleAssistantMessage( + id: string, + name: string, + input: any, + sessionId = "claude-session-1", +): SDKAssistantMessage { + return createParallelAssistantMessage([{ id, name, input }], sessionId); +} + +/** + * Helper to create a single-tool user result message + */ +function createSingleUserMessage( + toolUseId: string, + content: string, + isError = false, + sessionId = "claude-session-1", +): SDKUserMessage { + return createParallelUserMessage( + [{ toolUseId, content, isError }], + sessionId, + ); +} + +describe("AgentSessionManager - Parallel Tool Activities", () => { + let manager: AgentSessionManager; + let mockActivitySink: IActivitySink; + let postActivitySpy: ReturnType; + const sessionId = "test-session-parallel"; + const issueId = "issue-parallel"; + + beforeEach(() => { + mockActivitySink = { + id: "test-workspace", + postActivity: vi.fn().mockResolvedValue({ activityId: "activity-123" }), + createAgentSession: vi.fn().mockResolvedValue("session-123"), + }; + + postActivitySpy = vi.spyOn(mockActivitySink, "postActivity"); + manager = new AgentSessionManager(mockActivitySink); + + // Create a test session with a mock runner that has a formatter + manager.createLinearAgentSession( + sessionId, + issueId, + { + id: issueId, + identifier: "TEST-PAR", + title: "Parallel tools test", + description: "", + branchName: "test-branch", + }, + { + path: "/test/workspace", + isGitWorktree: false, + }, + ); + manager.addAgentRunner(sessionId, createMockRunner()); + }); + + describe("parallel tool_use detection in single message", () => { + it("should post unified ephemeral for multiple tool_use blocks in one message", async () => { + // Send an assistant message with 3 parallel Glob tool calls + const assistantMsg = createParallelAssistantMessage([ + { + id: "tool-1", + name: "Glob", + input: { pattern: "**/*.ts", path: "/src" }, + }, + { + id: "tool-2", + name: "Glob", + input: { pattern: "**/*.js", path: "/lib" }, + }, + { + id: "tool-3", + name: "Grep", + input: { pattern: "TODO", path: "/src" }, + }, + ]); + + await manager.handleClaudeMessage(sessionId, assistantMsg); + + // Should have posted a unified ephemeral thought (not 3 individual activities) + // The first tool posts normally (ephemeral), then the second triggers the group + const calls = postActivitySpy.mock.calls; + + // Find the parallel group activity (a thought with "Running N" in the body) + const groupActivity = calls.find( + (call: any[]) => + call[1]?.type === "thought" && + typeof call[1]?.body === "string" && + call[1].body.includes("Running 3"), + ); + expect(groupActivity).toBeDefined(); + expect(groupActivity![2]).toEqual({ ephemeral: true }); + }); + + it("should include all tool names in the parallel group activity", async () => { + const assistantMsg = createParallelAssistantMessage([ + { + id: "tool-1", + name: "Read", + input: { file_path: "/src/index.ts" }, + }, + { + id: "tool-2", + name: "Grep", + input: { pattern: "import", path: "/src" }, + }, + ]); + + await manager.handleClaudeMessage(sessionId, assistantMsg); + + const calls = postActivitySpy.mock.calls; + const groupActivity = calls.find( + (call: any[]) => + call[1]?.type === "thought" && + typeof call[1]?.body === "string" && + call[1].body.includes("Running 2"), + ); + expect(groupActivity).toBeDefined(); + + const body = groupActivity![1].body; + expect(body).toContain("Read"); + expect(body).toContain("Grep"); + expect(body).toContain("⏳"); // pending status + }); + }); + + describe("parallel tool_result handling", () => { + it("should suppress individual result activities for parallel tools", async () => { + // Send parallel tool calls + const assistantMsg = createParallelAssistantMessage([ + { + id: "tool-1", + name: "Glob", + input: { pattern: "**/*.ts" }, + }, + { + id: "tool-2", + name: "Glob", + input: { pattern: "**/*.js" }, + }, + ]); + await manager.handleClaudeMessage(sessionId, assistantMsg); + postActivitySpy.mockClear(); + + // Send results for both tools + const userMsg = createParallelUserMessage([ + { + toolUseId: "tool-1", + content: "file1.ts\nfile2.ts", + }, + { + toolUseId: "tool-2", + content: "file1.js\nfile2.js", + }, + ]); + await manager.handleClaudeMessage(sessionId, userMsg); + + // Should NOT have posted individual action activities for each result + const actionCalls = postActivitySpy.mock.calls.filter( + (call: any[]) => call[1]?.type === "action", + ); + expect(actionCalls).toHaveLength(0); + }); + + it("should post updated ephemeral when partial results arrive", async () => { + // Send 3 parallel tool calls + const assistantMsg = createParallelAssistantMessage([ + { + id: "tool-1", + name: "Glob", + input: { pattern: "**/*.ts" }, + }, + { + id: "tool-2", + name: "Glob", + input: { pattern: "**/*.js" }, + }, + { + id: "tool-3", + name: "Grep", + input: { pattern: "import" }, + }, + ]); + await manager.handleClaudeMessage(sessionId, assistantMsg); + postActivitySpy.mockClear(); + + // Send result for first tool only + const partialResult = createParallelUserMessage([ + { toolUseId: "tool-1", content: "file1.ts" }, + ]); + await manager.handleClaudeMessage(sessionId, partialResult); + + // Should have posted an updated ephemeral showing 1/3 complete + const calls = postActivitySpy.mock.calls; + const groupUpdate = calls.find( + (call: any[]) => + call[1]?.type === "thought" && + typeof call[1]?.body === "string" && + call[1].body.includes("1/3 complete"), + ); + expect(groupUpdate).toBeDefined(); + expect(groupUpdate![2]).toEqual({ ephemeral: true }); + }); + }); + + describe("single tool handling (no grouping)", () => { + it("should handle single tool calls normally without grouping", async () => { + // Send a single tool call + const assistantMsg = createSingleAssistantMessage("tool-1", "Read", { + file_path: "/src/index.ts", + }); + await manager.handleClaudeMessage(sessionId, assistantMsg); + + // Should post a normal ephemeral action (not a group thought) + const calls = postActivitySpy.mock.calls; + const actionCall = calls.find( + (call: any[]) => call[1]?.type === "action", + ); + expect(actionCall).toBeDefined(); + expect(actionCall![1]?.action).toBe("Read"); + + postActivitySpy.mockClear(); + + // Send result + const userMsg = createSingleUserMessage("tool-1", "file content here"); + await manager.handleClaudeMessage(sessionId, userMsg); + + // Should post a normal action with result + const resultCalls = postActivitySpy.mock.calls; + const resultAction = resultCalls.find( + (call: any[]) => + call[1]?.type === "action" && typeof call[1]?.result === "string", + ); + expect(resultAction).toBeDefined(); + }); + }); + + describe("formatter - formatParallelToolGroup", () => { + const formatter = new ClaudeMessageFormatter(); + + it("should format parallel tools with tree structure", () => { + const result = formatter.formatParallelToolGroup([ + { + name: "Glob", + input: { pattern: "**/*.ts" }, + status: "pending", + }, + { + name: "Grep", + input: { pattern: "TODO", path: "/src" }, + status: "pending", + }, + ]); + + expect(result).toContain("Running 2 parallel tools"); + expect(result).toContain("0/2 complete"); + expect(result).toContain("├─"); + expect(result).toContain("└─"); + expect(result).toContain("⏳"); + expect(result).toContain("Glob"); + expect(result).toContain("Grep"); + }); + + it("should show completed status icons", () => { + const result = formatter.formatParallelToolGroup([ + { + name: "Glob", + input: { pattern: "**/*.ts" }, + status: "completed", + }, + { + name: "Grep", + input: { pattern: "TODO" }, + status: "pending", + }, + ]); + + expect(result).toContain("1/2 complete"); + expect(result).toContain("✅"); + expect(result).toContain("⏳"); + }); + + it("should show error icon for failed tools", () => { + const result = formatter.formatParallelToolGroup([ + { + name: "Bash", + input: { command: "npm test" }, + status: "completed", + isError: true, + }, + { + name: "Read", + input: { file_path: "/test.ts" }, + status: "completed", + }, + ]); + + expect(result).toContain("2/2 complete"); + expect(result).toContain("❌"); + expect(result).toContain("✅"); + }); + + it("should use tool name for header when all tools are same type", () => { + const result = formatter.formatParallelToolGroup([ + { + name: "Glob", + input: { pattern: "**/*.ts" }, + status: "pending", + }, + { + name: "Glob", + input: { pattern: "**/*.js" }, + status: "pending", + }, + { + name: "Glob", + input: { pattern: "**/*.py" }, + status: "pending", + }, + ]); + + expect(result).toContain("Running 3 Glob calls"); + }); + + it("should use 'parallel tools' for header when mixed tool types", () => { + const result = formatter.formatParallelToolGroup([ + { + name: "Glob", + input: { pattern: "**/*.ts" }, + status: "pending", + }, + { + name: "Grep", + input: { pattern: "TODO" }, + status: "pending", + }, + ]); + + expect(result).toContain("Running 2 parallel tools"); + }); + + it("should truncate long parameters", () => { + const result = formatter.formatParallelToolGroup([ + { + name: "Grep", + input: { + pattern: + "a very long pattern that should be truncated because it exceeds the maximum length allowed for display", + }, + status: "pending", + }, + ]); + + expect(result).toContain("…"); + }); + }); +}); diff --git a/packages/gemini-runner/src/formatter.ts b/packages/gemini-runner/src/formatter.ts index 14ede7bfa..3220c9d90 100644 --- a/packages/gemini-runner/src/formatter.ts +++ b/packages/gemini-runner/src/formatter.ts @@ -553,4 +553,38 @@ export class GeminiMessageFormatter implements IMessageFormatter { return result || ""; } } + + formatParallelToolGroup( + tools: Array<{ + name: string; + input: any; + status: "pending" | "completed"; + isError?: boolean; + }>, + ): string { + const totalTools = tools.length; + const completedCount = tools.filter((t) => t.status === "completed").length; + + const toolNames = tools.map((t) => t.name.replace("↪ ", "")); + const uniqueNames = [...new Set(toolNames)]; + const headerToolName = + uniqueNames.length === 1 ? `${uniqueNames[0]} calls` : "parallel tools"; + + let body = `**Running ${totalTools} ${headerToolName}** (${completedCount}/${totalTools} complete)\n`; + + for (let i = 0; i < tools.length; i++) { + const tool = tools[i]!; + const isLast = i === tools.length - 1; + const prefix = isLast ? "└─" : "├─"; + const statusIcon = + tool.status === "completed" ? (tool.isError ? "❌" : "✅") : "⏳"; + const displayName = tool.name.replace("↪ ", ""); + const param = this.formatToolParameter(tool.name, tool.input); + const shortParam = + param.length > 80 ? `${param.substring(0, 77)}…` : param; + body += `${prefix} ${statusIcon} **${displayName}**: ${shortParam}\n`; + } + + return body; + } }