diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 4162ca9de4..36438d2f68 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -124,7 +124,7 @@ Recent request log rows, filterable by provider, model, conversation, and status | `--conversation` | string | Restrict to one conversation id (`--conversationId` is accepted too). | | `--status` | string | An exact code (429) or a class (5xx). | | `--limit` | number | Row cap; defaults to 200. | -| `--follow` | boolean | Stream new rows as JSONL; implies --jsonl. | +| `--follow` | boolean | Poll for new rows; add --jsonl to emit JSONL. | | `--json` | boolean | Emit the server payload as JSON. | | `--jsonl` | boolean | Emit one row per line. | diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index d124af2f6e..d4ae958829 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -274,7 +274,7 @@ export const CAPABILITIES: readonly Capability[] = [ { name: "--conversation", value: "string", summary: "Restrict to one conversation id (`--conversationId` is accepted too)." }, { name: "--status", value: "string", summary: "An exact code (429) or a class (5xx)." }, { name: "--limit", value: "number", summary: "Row cap; defaults to 200." }, - { name: "--follow", value: "boolean", summary: "Stream new rows as JSONL; implies --jsonl." }, + { name: "--follow", value: "boolean", summary: "Poll for new rows; add --jsonl to emit JSONL." }, { name: "--json", value: "boolean", summary: "Emit the server payload as JSON." }, { name: "--jsonl", value: "boolean", summary: "Emit one row per line." }, ], diff --git a/src/cli/observe.ts b/src/cli/observe.ts index e89eea0de3..46e264d2a8 100644 --- a/src/cli/observe.ts +++ b/src/cli/observe.ts @@ -72,7 +72,9 @@ async function logs(argv: string[], deps: RuntimeApiDeps): Promise { const limit = takeIntegerOption(args, "--limit", { min: 1 }) ?? 200; rejectArgs(args, USAGE); if (wantsJson && wantsJsonl) throw new CliUsageError("--json and --jsonl cannot be combined", USAGE); - if (follow && wantsJson) throw new CliUsageError("--follow uses --jsonl, not --json", USAGE); + if (follow && wantsJson) { + throw new CliUsageError("--follow cannot be combined with --json; use --jsonl for streaming JSONL", USAGE); + } let seen = new Set(); do { const data = await runtimeRequest(`/api/logs${query({ provider, model, status, conversationId, limit })}`, {}, deps); diff --git a/tests/cli-capabilities.test.ts b/tests/cli-capabilities.test.ts index 262e4e6fcd..e8584cc839 100644 --- a/tests/cli-capabilities.test.ts +++ b/tests/cli-capabilities.test.ts @@ -74,6 +74,12 @@ describe("capability table is a leaf data module", () => { expect(CAPABILITIES.some(c => c.command[0] === "capabilities")).toBe(true); }); + test("logs follow does not claim to imply JSONL output", () => { + const logs = CAPABILITIES.find(c => c.command.length === 1 && c.command[0] === "logs"); + const follow = logs?.flags.find(flag => flag.name === "--follow"); + expect(follow?.summary).toBe("Poll for new rows; add --jsonl to emit JSONL."); + }); + test("the check-only Codex CLI updater is declared as a local read capability", () => { const cap = CAPABILITIES.find(c => c.command.join(" ") === "system codex-cli-update check"); expect(cap).toBeDefined(); diff --git a/tests/cli-usage-report.test.ts b/tests/cli-usage-report.test.ts index 92933843aa..d446533f0f 100644 --- a/tests/cli-usage-report.test.ts +++ b/tests/cli-usage-report.test.ts @@ -6,7 +6,7 @@ * cost the server computes was discarded before reaching the terminal. These * tests pin the cost down where a user can see it. */ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { handleObserveCommand } from "../src/cli/observe"; import { formatUsageReport } from "../src/cli/usage-report"; @@ -222,3 +222,63 @@ describe("ocx logs --conversation", () => { expect(out).not.toContain("conv="); }); }); + +describe("ocx logs --follow output contract", () => { + test("--follow --json names the conflict without implying that follow enables JSONL", async () => { + const errors: string[] = []; + const originalError = console.error; + console.error = (...args: unknown[]) => { errors.push(args.map(String).join(" ")); }; + try { + const code = await handleObserveCommand( + ["logs", "--follow", "--json"], + { baseUrl: "http://cli.test", fetchImpl: async () => new Response("[]") }, + ); + expect(code).toBe(2); + expect(errors.join("\n")) + .toContain("--follow cannot be combined with --json; use --jsonl for streaming JSONL"); + } finally { + console.error = originalError; + } + }); + + test("--follow alone keeps human-readable rows", async () => { + const rows = [{ + id: "row-1", + timestamp: "t0", + status: 200, + provider: "xai", + model: "grok-4.6", + durationMs: 12, + conversationId: "conv-7", + }]; + const lines: string[] = []; + const errors: string[] = []; + const originalLog = console.log; + const originalError = console.error; + const sleep = spyOn(Bun, "sleep").mockImplementation(async () => { + throw new Error("stop after first follow poll"); + }); + console.log = (...args: unknown[]) => { lines.push(args.map(String).join(" ")); }; + console.error = (...args: unknown[]) => { errors.push(args.map(String).join(" ")); }; + try { + const code = await handleObserveCommand( + ["logs", "--follow"], + { + baseUrl: "http://cli.test", + fetchImpl: async () => new Response(JSON.stringify(rows), { + status: 200, + headers: { "content-type": "application/json" }, + }), + }, + ); + expect(code).toBe(1); + expect(lines).toEqual(["t0 200 xai/grok-4.6 12ms conv=conv-7"]); + expect(lines[0]?.startsWith("{")).toBe(false); + expect(errors.join("\n")).toContain("stop after first follow poll"); + } finally { + console.log = originalLog; + console.error = originalError; + sleep.mockRestore(); + } + }); +});