From 990041d221a3d3dec9a2875f3bdd2984389f37df Mon Sep 17 00:00:00 2001 From: Franklin-Zhang0 Date: Thu, 9 Apr 2026 17:01:02 +0800 Subject: [PATCH] fold codex tool calls in chat --- client/__tests__/AgentChat.test.ts | 34 ++++++++ client/src/pages/AgentChat.tsx | 78 +++++++++++++++++-- .../__tests__/AgentManager.codexTools.test.ts | 67 ++++++++++++++++ server/src/services/AgentManager.ts | 16 +++- 4 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 client/__tests__/AgentChat.test.ts create mode 100644 server/__tests__/AgentManager.codexTools.test.ts diff --git a/client/__tests__/AgentChat.test.ts b/client/__tests__/AgentChat.test.ts new file mode 100644 index 0000000..9c0186c --- /dev/null +++ b/client/__tests__/AgentChat.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { getToolMessageDetails } from '../src/pages/AgentChat'; + +describe('getToolMessageDetails', () => { + it('treats legacy codex content-only tool messages as foldable', () => { + const details = getToolMessageDetails({ + id: 'tool-1', + role: 'tool', + content: 'Command: pwd\nOutput: /tmp/project\n(exit: 0)', + timestamp: 1, + }); + + expect(details).not.toBeNull(); + expect(details?.title).toBe('Command: pwd'); + expect(details?.details).toContain('Output: /tmp/project'); + }); + + it('prefers structured tool fields when present', () => { + const details = getToolMessageDetails({ + id: 'tool-2', + role: 'tool', + content: 'Command: pwd', + toolName: 'command', + toolInput: 'pwd', + toolResult: '/tmp/project\n[exit code] 0', + timestamp: 1, + }); + + expect(details).not.toBeNull(); + expect(details?.title).toBe('Command: pwd'); + expect(details?.input).toBe('pwd'); + expect(details?.output).toContain('[exit code] 0'); + }); +}); diff --git a/client/src/pages/AgentChat.tsx b/client/src/pages/AgentChat.tsx index 9543971..ed9b1d7 100644 --- a/client/src/pages/AgentChat.tsx +++ b/client/src/pages/AgentChat.tsx @@ -15,6 +15,65 @@ import { type ReasoningEffortSelection, } from '../lib/reasoningEffort'; +type ChatMessage = Agent['messages'][number]; +type ToolMessageDetails = { + title: string; + input?: string; + output?: string; + details?: string; +}; + +function normalizeToolField(value?: string): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function getToolMessageDetails(msg: ChatMessage): ToolMessageDetails | null { + if (msg.role !== 'tool') return null; + + const toolInput = normalizeToolField(msg.toolInput); + const toolResult = normalizeToolField(msg.toolResult); + const content = normalizeToolField(msg.content); + const lines = content?.split('\n') || []; + const firstLine = lines[0]; + const remaining = lines.slice(1).join('\n').trim(); + const genericToolNames = new Set(['tool', 'command', 'command_execution', 'tool_call', 'function_call']); + const normalizedToolName = normalizeToolField(msg.toolName); + + let title = (normalizedToolName && !genericToolNames.has(normalizedToolName)) + ? normalizedToolName + : (firstLine || normalizedToolName || 'Tool'); + let details: string | undefined; + + if (toolInput || toolResult) { + if (content) { + const normalizedTitle = title.trim(); + const normalizedContent = content.trim(); + if (normalizedContent !== normalizedTitle && normalizedContent !== `Using tool: ${normalizedTitle}`) { + details = normalizedContent; + } + } + } else if (content) { + if (firstLine?.startsWith('Command:')) { + title = firstLine; + details = remaining || content; + } else if (firstLine?.startsWith('Tool:') || firstLine?.startsWith('Using tool:')) { + title = firstLine; + details = remaining || content; + } else { + title = firstLine || title; + details = remaining || content; + } + } + + return { + title, + input: toolInput, + output: toolResult, + details, + }; +} + function toggleTheme() { const current = document.documentElement.getAttribute('data-theme') || 'dark'; const next = current === 'dark' ? 'light' : 'dark'; @@ -930,7 +989,8 @@ export function AgentChat() { {id && }
{agent.messages.map((msg) => { - const isToolMsg = msg.role === 'tool' && (msg.toolInput || msg.toolResult); + const toolDetails = getToolMessageDetails(msg); + const isToolMsg = !!toolDetails; const isExpanded = expandedTools.has(msg.id); return (
@@ -946,20 +1006,26 @@ export function AgentChat() { })} > {isExpanded ? '\u25BC' : '\u25B6'} - {msg.toolName || msg.content} + {toolDetails.title}
{isExpanded && (
- {msg.toolInput && ( + {toolDetails.input && (
Input
-
{msg.toolInput}
+
{toolDetails.input}
)} - {msg.toolResult && ( + {toolDetails.output && (
Output
-
{msg.toolResult}
+
{toolDetails.output}
+
+ )} + {toolDetails.details && ( +
+
Details
+
{toolDetails.details}
)}
diff --git a/server/__tests__/AgentManager.codexTools.test.ts b/server/__tests__/AgentManager.codexTools.test.ts new file mode 100644 index 0000000..6e6f259 --- /dev/null +++ b/server/__tests__/AgentManager.codexTools.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { AgentStore } from '../src/store/AgentStore.js'; +import { AgentManager } from '../src/services/AgentManager.js'; +import type { Agent } from '../src/models/Agent.js'; + +describe('AgentManager codex tool messages', () => { + let tmpDir: string; + let store: AgentStore; + let manager: AgentManager; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-codex-tools-test-')); + store = new AgentStore(tmpDir); + manager = new AgentManager(store); + }); + + afterEach(() => { + const stuckCheckInterval = (manager as unknown as { stuckCheckInterval?: ReturnType | null }).stuckCheckInterval; + if (stuckCheckInterval) clearInterval(stuckCheckInterval); + vi.restoreAllMocks(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('stores live codex command execution messages with foldable fields', () => { + const agent: Agent = { + id: 'agent-codex-tools', + name: 'Codex Tool Test', + status: 'running', + config: { + provider: 'codex', + directory: tmpDir, + prompt: 'run a command', + flags: {}, + }, + messages: [], + lastActivity: 1, + createdAt: 1, + }; + store.saveAgent(agent); + + (manager as unknown as { + handleStreamMessage: (agentId: string, msg: Record, provider: string) => void; + }).handleStreamMessage(agent.id, { + type: 'item.completed', + item: { + type: 'command_execution', + command: 'pwd', + aggregated_output: `${tmpDir}\n`, + exit_code: 0, + }, + }, 'codex'); + + const saved = store.getAgent(agent.id); + expect(saved?.messages).toHaveLength(1); + expect(saved?.messages[0]).toMatchObject({ + role: 'tool', + content: 'Command: pwd', + toolName: 'command', + toolInput: 'pwd', + }); + expect(saved?.messages[0].toolResult).toContain(tmpDir); + expect(saved?.messages[0].toolResult).toContain('[exit code] 0'); + }); +}); diff --git a/server/src/services/AgentManager.ts b/server/src/services/AgentManager.ts index 2d313ad..85746ae 100644 --- a/server/src/services/AgentManager.ts +++ b/server/src/services/AgentManager.ts @@ -530,13 +530,23 @@ export class AgentManager extends EventEmitter { this.store.saveAgent(agent); } else if (msg.item.type === 'command_execution' || msg.item.type === 'tool_call' || msg.item.type === 'function_call') { const item = msg.item as { type?: string; command?: string; aggregated_output?: string; exit_code?: number; text?: string }; - const content = item.command - ? `Command: ${item.command}${item.aggregated_output ? `\nOutput: ${item.aggregated_output}` : ''}${item.exit_code !== undefined ? ` (exit: ${item.exit_code})` : ''}` + const toolSummary = item.command + ? `Command: ${item.command}` : `Tool: ${item.text || JSON.stringify(msg.item)}`; + const toolResultParts: string[] = []; + if (item.aggregated_output) { + toolResultParts.push(item.aggregated_output); + } + if (item.exit_code !== undefined) { + toolResultParts.push(`[exit code] ${item.exit_code}`); + } agent.messages.push({ id: uuid(), role: 'tool', - content, + content: toolSummary, + toolName: item.command ? 'command' : (item.type || 'tool'), + toolInput: item.command || item.text || undefined, + toolResult: toolResultParts.length > 0 ? toolResultParts.join('\n') : undefined, timestamp: Date.now(), }); agent.lastActivity = Date.now();