fix(cli): honor the JSON format alias in agent logs

Verify JSON output through the real CLI and isolated daemon, including the documented --format json spelling.

Co-authored-by: hadan8977 <zc-huang23@mails.tsinghua.edu.cn>
This commit is contained in:
paseo-bot[bot]
2026-11-02 18:51:58 +00:00
co-authored by hadan8977
parent 4fdd9eab4b
commit a1633baa74
2 changed files with 52 additions and 68 deletions
@@ -1,70 +1,50 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Command } from "commander";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { mkdir, mkdtemp } from "node:fs/promises";
import { resolve } from "node:path";
import { connectToDaemon } from "../../utils/client.js";
import { runPaseoCli, startTestDaemon } from "../../../tests/helpers/test-daemon.js";
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" }));
describe("paseo logs JSON output", () => {
let daemon: Awaited<ReturnType<typeof startTestDaemon>>;
let agentId: string;
let host: string;
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),
})),
}));
beforeAll(async () => {
await mkdir(".artifacts", { recursive: true });
daemon = await startTestDaemon({
paseoHome: await mkdtemp(resolve(".artifacts/logs-json-home-")),
workDir: await mkdtemp(resolve(".artifacts/logs-json-work-")),
});
host = `127.0.0.1:${daemon.port}`;
const client = await connectToDaemon({ target: { kind: "endpoint", host } });
try {
// Creating an idle agent needs no inference or external provider connection.
const agent = await client.createAgent({
provider: "claude",
cwd: daemon.workDir,
title: "JSON logs",
});
agentId = agent.id;
} finally {
await client.close();
}
}, 60_000);
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();
afterAll(async () => {
await daemon?.stop();
});
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!");
});
it.each([
{ label: "top-level --json", args: ["logs", "--json"] },
{ label: "agent logs --json", args: ["agent", "logs", "--json"] },
{ label: "--format json alias", args: ["logs", "--format", "json"] },
])(
"prints JSON through $label",
async ({ args }) => {
const result = await runPaseoCli(daemon, [...args, agentId, "--host", host]);
expect(result.exitCode, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({ agentId, items: [] });
},
30_000,
);
});
+8 -4
View File
@@ -74,7 +74,7 @@ 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(
function selectTailItems(
timelineItems: AgentTimelineItem[],
tailCount?: number,
): AgentTimelineItem[] {
@@ -87,7 +87,7 @@ export function selectTailItems(
return timelineItems.slice(-tailCount);
}
export function formatAgentLogsJson(
function formatAgentLogsJson(
agentId: string,
timelineItems: AgentTimelineItem[],
tailCount?: number,
@@ -95,6 +95,10 @@ export function formatAgentLogsJson(
return JSON.stringify({ agentId, items: selectTailItems(timelineItems, tailCount) }, null, 2);
}
function usesJsonOutput(options: AgentLogsOptions): boolean {
return options.json === true || options.format?.trim().toLowerCase() === "json";
}
type StreamingTextType = "assistant_message" | "reasoning";
const THOUGHT_PREFIX = "[Thought] ";
@@ -346,7 +350,7 @@ export async function runLogsCommand(
await client.close();
if (options.json) {
if (usesJsonOutput(options)) {
console.log(formatAgentLogsJson(resolvedId, timelineItems, tailCount));
return;
}
@@ -395,7 +399,7 @@ async function runFollowMode({
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter));
}
const json = options.json === true;
const json = usesJsonOutput(options);
// Print existing transcript (tail-like behavior)
if (json) {