From 7d4636ecff422256a064e2949998b34f8209ed89 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 28 Jan 2026 23:46:13 +0700 Subject: [PATCH] feat(cli): add agent logs command Add `paseo agent logs ` command to view agent activity/timeline. Features: - View agent timeline entries (messages, tool calls, errors) - `-f, --follow` flag for real-time streaming - `--tail ` to limit entries shown - Proper formatting of different timeline item types --- packages/cli/src/commands/agent/index.ts | 10 + packages/cli/src/commands/agent/logs.ts | 398 +++++++++++++++++++++++ packages/cli/tests/08-agent-logs.test.ts | 148 +++++++++ 3 files changed, 556 insertions(+) create mode 100644 packages/cli/src/commands/agent/logs.ts create mode 100644 packages/cli/tests/08-agent-logs.test.ts diff --git a/packages/cli/src/commands/agent/index.ts b/packages/cli/src/commands/agent/index.ts index ef9a9f486..822ed43f5 100644 --- a/packages/cli/src/commands/agent/index.ts +++ b/packages/cli/src/commands/agent/index.ts @@ -3,6 +3,7 @@ import { runPsCommand } from './ps.js' import { runRunCommand } from './run.js' import { runSendCommand } from './send.js' import { runStopCommand } from './stop.js' +import { runLogsCommand } from './logs.js' import { runModeCommand } from './mode.js' import { runInspectCommand } from './inspect.js' import { withOutput } from '../../output/index.js' @@ -58,6 +59,15 @@ export function createAgentCommand(): Command { .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runModeCommand)) + agent + .command('logs') + .description('View agent activity/timeline') + .argument('', 'Agent ID (or prefix)') + .option('-f, --follow', 'Follow log output (streaming)') + .option('--tail ', 'Show last n entries') + .option('--host ', 'Daemon host:port (default: localhost:6767)') + .action(withOutput(runLogsCommand)) + agent .command('inspect') .description('Show detailed information about an agent') diff --git a/packages/cli/src/commands/agent/logs.ts b/packages/cli/src/commands/agent/logs.ts new file mode 100644 index 000000000..00d52391a --- /dev/null +++ b/packages/cli/src/commands/agent/logs.ts @@ -0,0 +1,398 @@ +import type { Command } from 'commander' +import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js' +import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js' +import type { DaemonClientV2 } from '@paseo/server' + +/** Message type for agent_stream_snapshot */ +interface AgentStreamSnapshotMessage { + type: 'agent_stream_snapshot' + payload: { + agentId: string + events: Array<{ event: { type: string; item?: unknown }; timestamp: string }> + } +} + +/** Message type for agent_stream */ +interface AgentStreamMessage { + type: 'agent_stream' + payload: { + agentId: string + event: { type: string; item?: unknown } + timestamp: string + } +} + +/** Timeline item for display */ +export interface LogEntry { + timestamp: string + type: string + summary: string +} + +/** Schema for logs output */ +export const logsSchema: OutputSchema = { + idField: 'timestamp', + columns: [ + { header: 'TIME', field: 'timestamp', width: 12 }, + { header: 'TYPE', field: 'type', width: 15 }, + { header: 'SUMMARY', field: 'summary', width: 60 }, + ], +} + +export interface AgentLogsOptions extends CommandOptions { + follow?: boolean + tail?: string +} + +export type AgentLogsResult = ListResult + +/** Format a timeline item into a log entry */ +function formatTimelineItem(item: { + type: string + text?: string + name?: string + input?: unknown + status?: string + items?: { text: string; completed: boolean }[] + message?: string +}): LogEntry { + const now = new Date().toISOString().slice(11, 19) // HH:MM:SS + + switch (item.type) { + case 'user_message': + return { + timestamp: now, + type: 'user', + summary: truncate(item.text ?? '', 60), + } + case 'assistant_message': + return { + timestamp: now, + type: 'assistant', + summary: truncate(item.text ?? '', 60), + } + case 'reasoning': + return { + timestamp: now, + type: 'reasoning', + summary: truncate(item.text ?? '', 60), + } + case 'tool_call': { + const toolName = item.name ?? 'unknown' + const status = item.status ?? '' + let inputSummary = '' + if (item.input && typeof item.input === 'object') { + const inp = item.input as Record + // Common input fields for summarization + if (inp.command) { + inputSummary = truncate(String(inp.command), 40) + } else if (inp.file_path) { + inputSummary = truncate(String(inp.file_path), 40) + } else if (inp.pattern) { + inputSummary = truncate(String(inp.pattern), 40) + } + } + return { + timestamp: now, + type: `tool:${toolName}`, + summary: inputSummary ? `${status} ${inputSummary}`.trim() : status, + } + } + case 'todo': { + const items = item.items ?? [] + const completed = items.filter((i) => i.completed).length + return { + timestamp: now, + type: 'todo', + summary: `${completed}/${items.length} completed`, + } + } + case 'error': + return { + timestamp: now, + type: 'error', + summary: truncate(item.message ?? '', 60), + } + default: + return { + timestamp: now, + type: item.type, + summary: '', + } + } +} + +function truncate(str: string, maxLen: number): string { + const cleaned = str.replace(/\n/g, ' ').trim() + if (cleaned.length <= maxLen) return cleaned + return cleaned.slice(0, maxLen - 3) + '...' +} + +/** + * Extract timeline items from an agent_stream_snapshot message + */ +function extractTimelineFromSnapshot( + message: { type: string; payload: unknown } +): Array<{ type: string; [key: string]: unknown }> { + if (message.type !== 'agent_stream_snapshot') return [] + + const payload = message.payload as { + agentId: string + events: Array<{ event: { type: string; item?: unknown }; timestamp: string }> + } + + const items: Array<{ type: string; [key: string]: unknown }> = [] + for (const e of payload.events) { + if (e.event.type === 'timeline' && e.event.item) { + items.push(e.event.item as { type: string; [key: string]: unknown }) + } + } + return items +} + +/** + * Extract a timeline item from an agent_stream message + */ +function extractTimelineFromStream( + message: { type: string; payload: unknown } +): { type: string; [key: string]: unknown } | null { + if (message.type !== 'agent_stream') return null + + const payload = message.payload as { + agentId: string + event: { type: string; item?: unknown } + timestamp: string + } + + if (payload.event.type === 'timeline' && payload.event.item) { + return payload.event.item as { type: string; [key: string]: unknown } + } + return null +} + +export async function runLogsCommand( + id: string, + options: AgentLogsOptions, + _command: Command +): Promise { + const host = getDaemonHost({ host: options.host as string | undefined }) + + if (!id) { + const error: CommandError = { + code: 'MISSING_ARGUMENT', + message: 'Agent ID required', + details: 'Usage: paseo agent logs ', + } + throw error + } + + let client: DaemonClientV2 + try { + client = await connectToDaemon({ host: options.host as string | undefined }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + const error: CommandError = { + code: 'DAEMON_NOT_RUNNING', + message: `Cannot connect to daemon at ${host}: ${message}`, + details: 'Start the daemon with: paseo daemon start', + } + throw error + } + + try { + // Request session state to get agent information + client.requestSessionState() + + // Wait for session state to be populated + await new Promise((resolve) => setTimeout(resolve, 500)) + + const agents = client.listAgents() + const resolvedId = resolveAgentId(id, agents) + + if (!resolvedId) { + const error: CommandError = { + code: 'AGENT_NOT_FOUND', + message: `No agent found matching: ${id}`, + details: 'Use `paseo agent ps` to list available agents', + } + throw error + } + + // For follow mode, we stream events continuously + if (options.follow) { + return await runFollowMode(client, resolvedId, options) + } + + // For non-follow mode, initialize the agent to get timeline snapshot + const logEntries: LogEntry[] = [] + + // Set up handler for timeline events before initializing + const snapshotPromise = new Promise>((resolve) => { + const timeout = setTimeout(() => resolve([]), 10000) + + const unsubscribe = client.on('agent_stream_snapshot', (msg: unknown) => { + const message = msg as AgentStreamSnapshotMessage + if (message.type !== 'agent_stream_snapshot') return + const payload = message.payload + if (payload.agentId !== resolvedId) return + + clearTimeout(timeout) + unsubscribe() + resolve(extractTimelineFromSnapshot(message)) + }) + }) + + // Initialize agent to trigger timeline snapshot + try { + await client.initializeAgent(resolvedId) + } catch { + // Agent might already be initialized, continue to collect from queue + } + + // Get timeline from snapshot + const timelineItems = await snapshotPromise + + // Also check message queue for any stream events + const queue = client.getMessageQueue() + for (const msg of queue) { + if (msg.type === 'agent_stream') { + const payload = msg.payload as { agentId: string } + if (payload.agentId === resolvedId) { + const item = extractTimelineFromStream(msg) + if (item) { + timelineItems.push(item) + } + } + } + } + + // Convert to log entries + for (const item of timelineItems) { + logEntries.push(formatTimelineItem(item)) + } + + await client.close() + + // Apply tail limit + let entries = logEntries + if (options.tail) { + const tailCount = parseInt(options.tail, 10) + if (!isNaN(tailCount) && tailCount > 0) { + entries = entries.slice(-tailCount) + } + } + + return { + type: 'list', + data: entries, + schema: logsSchema, + } + } catch (err) { + await client.close().catch(() => {}) + // Re-throw if already a CommandError + if (err && typeof err === 'object' && 'code' in err) { + throw err + } + const message = err instanceof Error ? err.message : String(err) + const error: CommandError = { + code: 'LOGS_FAILED', + message: `Failed to get logs: ${message}`, + } + throw error + } +} + +/** + * Follow mode: stream logs in real-time until interrupted + */ +async function runFollowMode( + client: DaemonClientV2, + agentId: string, + options: AgentLogsOptions +): Promise { + const logEntries: LogEntry[] = [] + + // First, get existing timeline + const snapshotPromise = new Promise>((resolve) => { + const timeout = setTimeout(() => resolve([]), 10000) + + const unsubscribe = client.on('agent_stream_snapshot', (msg: unknown) => { + const message = msg as AgentStreamSnapshotMessage + if (message.type !== 'agent_stream_snapshot') return + const payload = message.payload + if (payload.agentId !== agentId) return + + clearTimeout(timeout) + unsubscribe() + resolve(extractTimelineFromSnapshot(message)) + }) + }) + + // Initialize agent to trigger timeline snapshot + try { + await client.initializeAgent(agentId) + } catch { + // Agent might already be initialized + } + + // Get existing timeline + const existingItems = await snapshotPromise + + // Apply tail to existing items + let itemsToShow = existingItems + if (options.tail) { + const tailCount = parseInt(options.tail, 10) + if (!isNaN(tailCount) && tailCount > 0) { + itemsToShow = itemsToShow.slice(-tailCount) + } + } + + // Print existing entries + for (const item of itemsToShow) { + const entry = formatTimelineItem(item) + logEntries.push(entry) + printLogEntry(entry) + } + + // Subscribe to new events + console.log('\n--- Following logs (Ctrl+C to stop) ---\n') + + const unsubscribe = client.on('agent_stream', (msg: unknown) => { + const message = msg as AgentStreamMessage + if (message.type !== 'agent_stream') return + const payload = message.payload + if (payload.agentId !== agentId) return + + if (payload.event.type === 'timeline' && payload.event.item) { + const entry = formatTimelineItem(payload.event.item as { type: string; [key: string]: unknown }) + logEntries.push(entry) + printLogEntry(entry) + } + }) + + // Wait for interrupt + await new Promise((resolve) => { + const cleanup = () => { + unsubscribe() + resolve() + } + + process.on('SIGINT', cleanup) + process.on('SIGTERM', cleanup) + }) + + await client.close() + + return { + type: 'list', + data: logEntries, + schema: logsSchema, + } +} + +function printLogEntry(entry: LogEntry): void { + // Simple format for streaming output + const typeWidth = 15 + const paddedType = entry.type.padEnd(typeWidth) + console.log(`${entry.timestamp} ${paddedType} ${entry.summary}`) +} diff --git a/packages/cli/tests/08-agent-logs.test.ts b/packages/cli/tests/08-agent-logs.test.ts new file mode 100644 index 000000000..4a2f0b794 --- /dev/null +++ b/packages/cli/tests/08-agent-logs.test.ts @@ -0,0 +1,148 @@ +#!/usr/bin/env npx tsx + +/** + * Phase 7: Agent Logs Command Tests + * + * Tests the agent logs command - viewing agent activity/timeline. + * Since daemon may not be running, we test both: + * - Help and argument parsing + * - Graceful error handling when daemon not running + * - All flags are accepted + * + * Tests: + * - agent logs --help shows options + * - agent logs requires ID argument + * - agent logs handles daemon not running + * - agent logs -f (follow) flag is accepted + * - agent logs --tail flag is accepted + */ + +import assert from 'node:assert' +import { $ } from 'zx' +import { mkdtemp, rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +$.verbose = false + +console.log('=== Agent Logs Command Tests ===\n') + +// Get random port that's definitely not in use (never 6767) +const port = 10000 + Math.floor(Math.random() * 50000) +const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-')) + +try { + // Test 1: agent logs --help shows options + { + console.log('Test 1: agent logs --help shows options') + const result = await $`npx paseo agent logs --help`.nothrow() + assert.strictEqual(result.exitCode, 0, 'agent logs --help should exit 0') + assert(result.stdout.includes('-f') || result.stdout.includes('--follow'), 'help should mention -f/--follow flag') + assert(result.stdout.includes('--tail'), 'help should mention --tail option') + assert(result.stdout.includes('--host'), 'help should mention --host option') + assert(result.stdout.includes(''), 'help should mention required id argument') + console.log('✓ agent logs --help shows options\n') + } + + // Test 2: agent logs requires ID argument + { + console.log('Test 2: agent logs requires ID argument') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent logs`.nothrow() + assert.notStrictEqual(result.exitCode, 0, 'should fail without id') + const output = result.stdout + result.stderr + const hasError = + output.toLowerCase().includes('missing') || + output.toLowerCase().includes('required') || + output.toLowerCase().includes('argument') || + output.toLowerCase().includes('id') + assert(hasError, 'error should mention missing argument') + console.log('✓ agent logs requires ID argument\n') + } + + // Test 3: agent logs handles daemon not running + { + console.log('Test 3: agent logs handles daemon not running') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent logs abc123`.nothrow() + // Should fail because daemon not running + assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running') + const output = result.stdout + result.stderr + const hasError = + output.toLowerCase().includes('daemon') || + output.toLowerCase().includes('connect') || + output.toLowerCase().includes('cannot') + assert(hasError, 'error message should mention connection issue') + console.log('✓ agent logs handles daemon not running\n') + } + + // Test 4: agent logs -f (follow) flag is accepted + { + console.log('Test 4: agent logs -f (follow) flag is accepted') + // Use timeout to avoid hanging on follow mode + const result = + await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent logs -f abc123' || true`.nothrow() + const output = result.stdout + result.stderr + assert(!output.includes('unknown option'), 'should accept -f flag') + assert(!output.includes('error: option'), 'should not have option parsing error') + console.log('✓ agent logs -f (follow) flag is accepted\n') + } + + // Test 5: agent logs --follow flag is accepted + { + console.log('Test 5: agent logs --follow flag is accepted') + const result = + await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent logs --follow abc123' || true`.nothrow() + const output = result.stdout + result.stderr + assert(!output.includes('unknown option'), 'should accept --follow flag') + assert(!output.includes('error: option'), 'should not have option parsing error') + console.log('✓ agent logs --follow flag is accepted\n') + } + + // Test 6: agent logs --tail flag is accepted + { + console.log('Test 6: agent logs --tail flag is accepted') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent logs --tail 50 abc123`.nothrow() + const output = result.stdout + result.stderr + assert(!output.includes('unknown option'), 'should accept --tail flag') + assert(!output.includes('error: option'), 'should not have option parsing error') + console.log('✓ agent logs --tail flag is accepted\n') + } + + // Test 7: agent logs with ID and --host flag is accepted + { + console.log('Test 7: agent logs with ID and --host flag is accepted') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent logs abc123 --host localhost:${port}`.nothrow() + const output = result.stdout + result.stderr + assert(!output.includes('unknown option'), 'should accept --host flag') + assert(!output.includes('error: option'), 'should not have option parsing error') + console.log('✓ agent logs with ID and --host flag is accepted\n') + } + + // Test 8: agent shows logs in subcommands + { + console.log('Test 8: agent --help shows logs subcommand') + const result = await $`npx paseo agent --help`.nothrow() + assert.strictEqual(result.exitCode, 0, 'agent --help should exit 0') + assert(result.stdout.includes('logs'), 'help should mention logs subcommand') + console.log('✓ agent --help shows logs subcommand\n') + } + + // Test 9: -q (quiet) flag is accepted with agent logs + { + console.log('Test 9: -q (quiet) flag is accepted with agent logs') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q agent logs abc123`.nothrow() + const output = result.stdout + result.stderr + assert(!output.includes('unknown option'), 'should accept -q flag') + assert(!output.includes('error: option'), 'should not have option parsing error') + console.log('✓ -q (quiet) flag is accepted with agent logs\n') + } +} finally { + // Clean up temp directory + await rm(paseoHome, { recursive: true, force: true }) +} + +console.log('=== All agent logs tests passed ===')