diff --git a/packages/cli/src/commands/agent/logs-json.test.ts b/packages/cli/src/commands/agent/logs-json.test.ts new file mode 100644 index 0000000000..c60871f957 --- /dev/null +++ b/packages/cli/src/commands/agent/logs-json.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Command } from "commander"; +import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; + +const agentId = "11111111-1111-4111-8111-111111111111"; +const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; +const timeline: AgentTimelineItem[] = [ + { type: "user_message", text: "say hi" }, + { type: "reasoning", text: "Greet the user." }, + { type: "assistant_message", text: "Hi!", messageId: "msg_1" }, +]; +const timelineEntries = timeline.map((item) => ({ item, timestamp: "2026-10-19T00:00:00.000Z" })); + +vi.mock("../../utils/client.js", () => ({ + connectToDaemon: vi.fn(async () => ({ + fetchAgent: vi.fn(async () => ({ agent: { id: agentId } })), + fetchAgentTimeline: vi.fn(async () => ({ entries: timelineEntries })), + close: vi.fn(async () => undefined), + })), +})); + +import { runLogsCommand } from "./logs.js"; + +async function captureStdout(run: () => Promise): Promise { + const lines: string[] = []; + const log = vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { + lines.push(args.join(" ")); + }); + try { + await run(); + } finally { + log.mockRestore(); + } + return lines.join("\n"); +} + +describe("paseo logs --json", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("prints the timeline as JSON instead of the text transcript", async () => { + const stdout = await captureStdout(() => + runLogsCommand("1111", { daemonTarget, json: true }, {} as Command), + ); + + expect(JSON.parse(stdout)).toEqual({ agentId, items: timeline }); + }); + + it("applies --tail and --filter to the JSON items", async () => { + const stdout = await captureStdout(() => + runLogsCommand( + "1111", + { daemonTarget, json: true, tail: "1", filter: "text" }, + {} as Command, + ), + ); + + expect(JSON.parse(stdout)).toEqual({ agentId, items: [timeline[2]] }); + }); + + it("still prints the text transcript without --json", async () => { + const stdout = await captureStdout(() => + runLogsCommand("1111", { daemonTarget }, {} as Command), + ); + + expect(() => JSON.parse(stdout)).toThrow(); + expect(stdout).toContain("Hi!"); + }); +}); diff --git a/packages/cli/src/commands/agent/logs.test.ts b/packages/cli/src/commands/agent/logs.test.ts index e551aa3791..afbf97acb6 100644 --- a/packages/cli/src/commands/agent/logs.test.ts +++ b/packages/cli/src/commands/agent/logs.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; -import { createFollowTranscriptWriter, formatAgentActivityTranscript } from "./logs.js"; +import { + createFollowJsonWriter, + createFollowTranscriptWriter, + formatAgentActivityTranscript, +} from "./logs.js"; function collect(items: readonly AgentTimelineItem[], turnId?: string): string { let output = ""; @@ -149,3 +153,31 @@ describe("createFollowTranscriptWriter", () => { expect(output).toBe("# Title\n\nBody\n"); }); }); + +describe("createFollowJsonWriter", () => { + it("writes one JSON item per line and honours the filter", () => { + let output = ""; + const writer = createFollowJsonWriter("tools", (chunk) => { + output += chunk; + }); + const toolCall = { + type: "tool_call", + callId: "call_1", + name: "shell", + status: "completed", + detail: { type: "unknown", input: null, output: null }, + error: null, + } as AgentTimelineItem; + + writer.push({ type: "assistant_message", text: "Running it.", messageId: "msg_1" }); + writer.push(toolCall); + writer.end(); + + expect( + output + .trimEnd() + .split("\n") + .map((line) => JSON.parse(line)), + ).toEqual([toolCall]); + }); +}); diff --git a/packages/cli/src/commands/agent/logs.ts b/packages/cli/src/commands/agent/logs.ts index ed77d4be62..4e88f3c372 100644 --- a/packages/cli/src/commands/agent/logs.ts +++ b/packages/cli/src/commands/agent/logs.ts @@ -70,6 +70,31 @@ export function formatAgentActivityTranscript( ); } +/** + * Picks the items `--tail` keeps, the same way the transcript does: the last n + * timeline items, before any of them are dropped from the curated view. + */ +export function selectTailItems( + timelineItems: AgentTimelineItem[], + tailCount?: number, +): AgentTimelineItem[] { + if (tailCount === undefined) { + return timelineItems; + } + if (tailCount === 0) { + return []; + } + return timelineItems.slice(-tailCount); +} + +export function formatAgentLogsJson( + agentId: string, + timelineItems: AgentTimelineItem[], + tailCount?: number, +): string { + return JSON.stringify({ agentId, items: selectTailItems(timelineItems, tailCount) }, null, 2); +} + type StreamingTextType = "assistant_message" | "reasoning"; const THOUGHT_PREFIX = "[Thought] "; @@ -198,6 +223,21 @@ export function createFollowTranscriptWriter( }; } +/** Follow mode with `--json`: one JSON timeline item per line (NDJSON). */ +export function createFollowJsonWriter( + filter?: string, + write: (chunk: string) => void = (chunk) => void process.stdout.write(chunk), +): FollowTranscriptWriter { + return { + push(item) { + if (matchesFilter(item, filter)) { + write(`${JSON.stringify(item)}\n`); + } + }, + end() {}, + }; +} + function parseTailCount(raw: string | undefined): number | undefined { if (raw === undefined) return undefined; const parsed = Number.parseInt(raw, 10); @@ -306,6 +346,11 @@ export async function runLogsCommand( await client.close(); + if (options.json) { + console.log(formatAgentLogsJson(resolvedId, timelineItems, tailCount)); + return; + } + // Use curateAgentActivity to format the transcript if (tailCount === 0) { return; @@ -350,8 +395,14 @@ async function runFollowMode({ existingItems = existingItems.filter((item) => matchesFilter(item, options.filter)); } + const json = options.json === true; + // Print existing transcript (tail-like behavior) - if (tailCount > 0) { + if (json) { + for (const item of selectTailItems(existingItems, tailCount)) { + console.log(JSON.stringify(item)); + } + } else if (tailCount > 0) { const existingTranscript = formatAgentActivityTranscript(existingItems, tailCount); if (existingTranscript !== NO_ACTIVITY_MESSAGE) { console.log(existingTranscript); @@ -362,12 +413,16 @@ async function runFollowMode({ const tailLabel = tailCount === 0 ? "no history" : `last ${tailCount} entr${tailCount === 1 ? "y" : "ies"}`; - const writer = createFollowTranscriptWriter(undefined, options.filter); + const writer = json + ? createFollowJsonWriter(options.filter) + : createFollowTranscriptWriter(undefined, options.filter); + // Keep stdout parseable in JSON mode: one timeline item per line, notices on stderr. + const notice = json ? console.error : console.log; const unsubscribe = client.subscribeAgentTimeline(agentId, (message) => { if (message.type === "agent.timeline.replacement") { writer.end(); - console.log("\n[Timeline replaced; earlier output is no longer current]"); + notice("\n[Timeline replaced; earlier output is no longer current]"); return; } @@ -378,7 +433,7 @@ async function runFollowMode({ } if (message.type === "agent.timeline.subscription_restored") { writer.end(); - console.log("\n[Reconnected; live output resumed. Events may have been missed.]"); + notice("\n[Reconnected; live output resumed. Events may have been missed.]"); return; } if (message.payload.event.type === "timeline") { @@ -399,7 +454,7 @@ async function runFollowMode({ }); await unsubscribe.ready; - console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`); + notice(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`); await waitForStop(); writer.end();