Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions client/__tests__/AgentChat.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
78 changes: 72 additions & 6 deletions client/src/pages/AgentChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -930,7 +989,8 @@ export function AgentChat() {
{id && <TerminalView agentId={id} visible={showTerminal} resumeCommand={buildResumeCommand(agent, runtimeCapabilities)} />}
<div className="chat-messages" style={{ display: showTerminal ? 'none' : undefined }}>
{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 (
<div key={msg.id} className={`chat-message ${msg.role}`}>
Expand All @@ -946,20 +1006,26 @@ export function AgentChat() {
})}
>
<span className="tool-toggle">{isExpanded ? '\u25BC' : '\u25B6'}</span>
<span className="tool-name">{msg.toolName || msg.content}</span>
<span className="tool-name">{toolDetails.title}</span>
</div>
{isExpanded && (
<div className="tool-details">
{msg.toolInput && (
{toolDetails.input && (
<div className="tool-section">
<div className="tool-section-label">Input</div>
<pre className="tool-content">{msg.toolInput}</pre>
<pre className="tool-content">{toolDetails.input}</pre>
</div>
)}
{msg.toolResult && (
{toolDetails.output && (
<div className="tool-section">
<div className="tool-section-label">Output</div>
<pre className="tool-content">{msg.toolResult}</pre>
<pre className="tool-content">{toolDetails.output}</pre>
</div>
)}
{toolDetails.details && (
<div className="tool-section">
<div className="tool-section-label">Details</div>
<pre className="tool-content">{toolDetails.details}</pre>
</div>
)}
</div>
Expand Down
67 changes: 67 additions & 0 deletions server/__tests__/AgentManager.codexTools.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setInterval> | 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<string, unknown>, 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');
});
});
16 changes: 13 additions & 3 deletions server/src/services/AgentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down