fix(cli): print JSON from paseo logs --json

paseo logs ignored the global --json flag and always printed the text
transcript. Print { agentId, items } for one-shot logs, and one timeline
item per line in --follow mode, with the follow notices on stderr so
stdout stays parseable.
This commit is contained in:
hadan8977
2026-11-02 18:51:58 +00:00
committed by paseo-bot[bot]
parent 71dc9f44c9
commit 4fdd9eab4b
3 changed files with 163 additions and 6 deletions
@@ -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<void>): Promise<string> {
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!");
});
});
+33 -1
View File
@@ -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]);
});
});
+60 -5
View File
@@ -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();