From 3850604c2bd0d8c4781460f3855bd4efa933e6c2 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 9 Jun 2026 22:53:42 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=A2=9E=E5=8A=A0=20runs=20events=20?= =?UTF-8?q?=E4=BD=8E=E5=99=AA=E5=A3=B0=20summary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/spec-v01-cli.md | 7 +- scripts/src/cli.ts | 180 +++++++++++++++++++- src/selftest/cases/15-cli-events-summary.ts | 34 ++++ 3 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 src/selftest/cases/15-cli-events-summary.ts diff --git a/docs/reference/spec-v01-cli.md b/docs/reference/spec-v01-cli.md index f1092e5..d3ff71b 100644 --- a/docs/reference/spec-v01-cli.md +++ b/docs/reference/spec-v01-cli.md @@ -36,7 +36,7 @@ CLI 官方 TypeScript 入口固定为 `scripts/agentrun-cli.ts`。在 G14 非交 ```bash ./scripts/agentrun runs create --json-file ./scripts/agentrun runs show -./scripts/agentrun runs events --after-seq --limit +./scripts/agentrun runs events --after-seq --limit [--summary|--tail-summary] [--tail ] [--summary-chars ] [--format json|tsv] ./scripts/agentrun runs result [--command-id ] ./scripts/agentrun runs cancel [--reason ] ./scripts/agentrun commands create --type turn|steer|interrupt --json-file @@ -84,7 +84,10 @@ CLI 官方 TypeScript 入口固定为 `scripts/agentrun-cli.ts`。在 G14 非交 - `runner start` 返回 attemptId、job/process identity、logPath 和后续 status/events 命令。 - `runner jobs` / `runner job-status` 返回 manager 持久化的 runner Job 最小状态摘要,包括 attemptId、runnerId、namespace、jobName、phase、terminalStatus、logPath、retention 和 redacted Kubernetes identity;业务方不需要直连 Kubernetes 才能定位当前 attempt。 - 查询类命令返回当前 state、terminal_status、failureKind、event cursor 或 logPath。 -- `events` 默认分页且有界,必须支持 `afterSeq` 和 `limit`。 +- `events` 默认分页且有界,必须支持 `afterSeq` 和 `limit`;默认输出保持 manager raw JSON。 +- `events --summary` 返回低噪声 JSON summary,单条至少包含 `seq`、`type`、`method`、`status`、`command`、`text`、`exitCode`、`durationMs`、`outputTruncated`、`outputBytes`、`outputSummary` 和 `summary`;summary 文本必须压缩换行并继续沿用 redaction,不能泄漏 Secret/env value。 +- `events --tail ` 与 `--after-seq`/`--limit` 组合时,只显示本次分页结果最后 `n` 条 summary;`--tail-summary` 是默认取最多 20 条 tail 的低噪声快捷入口。 +- `events --format tsv` 只在显式请求时输出 TSV 文本,用于人工现场跟踪和上层 trace 压缩;默认 JSON envelope 行为不得改变。 - `server start` 默认以本地后台进程启动 manager,立刻返回 pid、pidFile、logPath、baseUrl 和后续 `status/stop` 命令;只有显式 `--foreground` 才允许占用当前终端。启动前必须检查 pidFile 与端口占用,避免同一端口上堆叠临时 manager。 - `server status` 必须同时返回本地 pid/port/logPath 状态和 `/health/readiness` 结果;即使 readiness 失败,也要输出结构化 JSON 和 failure details。 - `server logs` 必须返回有界日志尾部、bytes、truncated 和 logPath;找不到日志文件时也必须返回非空 JSON。 diff --git a/scripts/src/cli.ts b/scripts/src/cli.ts index c2cdeef..815673c 100644 --- a/scripts/src/cli.ts +++ b/scripts/src/cli.ts @@ -14,15 +14,30 @@ import type { BackendProfile, CommandRecord, JsonRecord, JsonValue, RunRecord, S import { AgentRunError, errorToJson } from "../../src/common/errors.js"; import type { RunnerOnceOptions } from "../../src/runner/run-once.js"; import { backendProfileSpec, isBackendProfile } from "../../src/common/backend-profiles.js"; +import { outputBytesFromPayload, outputTruncatedFromPayload } from "../../src/common/output.js"; +import { redactText } from "../../src/common/redaction.js"; interface ParsedArgs { positional: string[]; flags: Map; } +const plainTextOutputMarker = Symbol("agentrun.plainTextOutput"); + +interface PlainTextOutput { + [plainTextOutputMarker]: true; + text: string; +} + +type CliResult = JsonValue | PlainTextOutput; + export async function runCli(argv: string[]): Promise { try { const result = await dispatch(parseArgs(argv)); + if (isPlainTextOutput(result)) { + process.stdout.write(result.text.endsWith("\n") ? result.text : `${result.text}\n`); + return; + } print({ ok: true, data: result }); } catch (error) { const status = error instanceof AgentRunError ? error.httpStatus : 1; @@ -31,7 +46,7 @@ export async function runCli(argv: string[]): Promise { } } -async function dispatch(args: ParsedArgs): Promise { +async function dispatch(args: ParsedArgs): Promise { const [group, command, id] = args.positional; if (!group || group === "help" || group === "--help") return help(args); if (args.flags.get("help") === true) return help(args, group); @@ -74,7 +89,7 @@ async function dispatch(args: ParsedArgs): Promise { if (group === "queue" && command === "refresh" && id) return client(args).post(`/api/v1/queue/tasks/${encodeURIComponent(id)}/refresh`, {}); if (group === "runs" && command === "create") return client(args).post("/api/v1/runs", await jsonFile(args)); if (group === "runs" && command === "show" && id) return client(args).get(`/api/v1/runs/${encodeURIComponent(id)}`); - if (group === "runs" && command === "events" && id) return client(args).get(`/api/v1/runs/${encodeURIComponent(id)}/events?afterSeq=${flag(args, "after-seq", "0")}&limit=${flag(args, "limit", "100")}`); + if (group === "runs" && command === "events" && id) return runEvents(args, id); if (group === "runs" && command === "result" && id) { const commandId = optionalFlag(args, "command-id"); return client(args).get(`/api/v1/runs/${encodeURIComponent(id)}/result${commandId ? `?commandId=${encodeURIComponent(commandId)}` : ""}`); @@ -136,6 +151,165 @@ async function listRunnerJobs(args: ParsedArgs): Promise { return client(args).get(`/api/v1/runs/${encodeURIComponent(runId)}/runner-jobs${commandId ? `?commandId=${encodeURIComponent(commandId)}` : ""}`); } +async function runEvents(args: ParsedArgs, runId: string): Promise { + const afterSeq = integerFlag(args, "after-seq", 0, { min: 0 }); + const limit = integerFlag(args, "limit", 100, { min: 1, max: 500 }); + const page = await client(args).get(`/api/v1/runs/${encodeURIComponent(runId)}/events?afterSeq=${afterSeq}&limit=${limit}`); + const format = optionalFlag(args, "format") ?? "json"; + if (format !== "json" && format !== "tsv") throw new AgentRunError("schema-invalid", "runs events --format must be json or tsv", { httpStatus: 2 }); + + const tail = tailFlag(args, limit); + const wantsSummary = args.flags.get("summary") === true || args.flags.get("tail-summary") === true || tail !== null || format === "tsv"; + if (!wantsSummary) return page; + + const summary = summarizeRunEventPage(page, { + runId, + afterSeq, + limit, + tail: tail ?? (args.flags.get("tail-summary") === true ? Math.min(limit, 20) : null), + summaryChars: integerFlag(args, "summary-chars", 900, { min: 1, max: 4_000 }), + }); + if (format === "tsv") return plainTextOutput(renderRunEventSummaryTsv(summary)); + return summary; +} + +interface RunEventSummaryOptions { + runId: string; + afterSeq: number; + limit: number; + tail: number | null; + summaryChars: number; +} + +export function summarizeRunEventPage(page: JsonValue, options: RunEventSummaryOptions): JsonRecord { + const events = eventPageItems(page); + const selected = options.tail === null ? events : events.slice(-options.tail); + const items = selected.map((event) => summarizeRunEvent(event, options.summaryChars)); + const lastSeq = items.length > 0 ? items[items.length - 1]?.seq ?? null : null; + return { + action: "runs-events-summary", + runId: options.runId, + afterSeq: options.afterSeq, + limit: options.limit, + tail: options.tail, + sourceCount: events.length, + count: items.length, + lastSeq, + nextAfterSeq: lastSeq, + valuesPrinted: false, + items, + }; +} + +function summarizeRunEvent(event: JsonRecord, summaryChars: number): JsonRecord { + const payload = jsonRecordValue(event.payload) ?? {}; + const command = boundedSummaryString(stringValue(payload.command), summaryChars); + const text = boundedSummaryString(stringValue(payload.text), summaryChars); + const outputSummary = boundedSummaryString(stringValue(payload.outputSummary) ?? nestedSummaryText(payload), summaryChars); + const message = boundedSummaryString(stringValue(payload.failureMessage) ?? stringValue(payload.message), summaryChars); + const summary = text ?? command ?? outputSummary ?? message ?? ""; + const summaryTruncated = [command, text, outputSummary, message].some((value) => value?.endsWith("...") === true); + return { + seq: numberValue(event.seq) ?? 0, + type: stringValue(event.type) ?? "unknown", + method: stringValue(payload.method), + status: stringValue(payload.status) ?? stringValue(payload.terminalStatus) ?? stringValue(payload.phase), + command, + text, + exitCode: numberValue(payload.exitCode), + durationMs: numberValue(payload.durationMs), + outputTruncated: outputTruncatedFromPayload(payload) || summaryTruncated, + outputBytes: outputBytesFromPayload(payload), + outputSummary, + summary, + }; +} + +export function renderRunEventSummaryTsv(summary: JsonRecord): string { + const items = Array.isArray(summary.items) ? summary.items.filter((item): item is JsonRecord => jsonRecordValue(item) !== null) : []; + const rows = items.map((item) => [ + item.seq, + item.type, + item.method, + item.status, + item.exitCode, + item.durationMs, + item.outputTruncated, + item.outputBytes, + item.summary, + ].map(tsvCell).join("\t")); + return ["seq\ttype\tmethod\tstatus\texitCode\tdurationMs\toutputTruncated\toutputBytes\tsummary", ...rows].join("\n"); +} + +function eventPageItems(page: JsonValue): JsonRecord[] { + const record = jsonRecordValue(page); + if (!record) throw new AgentRunError("schema-invalid", "runs events response must be an object", { httpStatus: 2 }); + if (!Array.isArray(record.items)) throw new AgentRunError("schema-invalid", "runs events response.items must be an array", { httpStatus: 2 }); + return record.items.map((item) => { + const event = jsonRecordValue(item); + if (!event) throw new AgentRunError("schema-invalid", "runs events item must be an object", { httpStatus: 2 }); + return event; + }); +} + +function nestedSummaryText(payload: JsonRecord): string | null { + const summary = payload.summary; + if (typeof summary === "string") return summary; + const summaryRecord = jsonRecordValue(summary); + return summaryRecord ? stringValue(summaryRecord.text) : null; +} + +function boundedSummaryString(value: string | null, limit: number): string | null { + if (!value) return null; + const normalized = redactText(value).replace(/\s+/gu, " ").trim(); + if (normalized.length === 0) return null; + return normalized.length > limit ? `${normalized.slice(0, Math.max(0, limit - 3))}...` : normalized; +} + +function integerFlag(args: ParsedArgs, name: string, fallback: number, options: { min: number; max?: number }): number { + const raw = optionalFlag(args, name); + const value = raw === null ? fallback : Number(raw); + if (!Number.isInteger(value) || value < options.min || (options.max !== undefined && value > options.max)) { + const maxText = options.max === undefined ? "" : ` and <= ${options.max}`; + throw new AgentRunError("schema-invalid", `--${name} must be an integer >= ${options.min}${maxText}`, { httpStatus: 2 }); + } + return value; +} + +function tailFlag(args: ParsedArgs, limit: number): number | null { + const value = args.flags.get("tail"); + if (value === undefined) return null; + if (value === true) return Math.min(limit, 20); + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > limit) throw new AgentRunError("schema-invalid", `--tail must be an integer between 1 and --limit (${limit})`, { httpStatus: 2 }); + return parsed; +} + +function stringValue(value: JsonValue | undefined): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function numberValue(value: JsonValue | undefined): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function jsonRecordValue(value: unknown): JsonRecord | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as JsonRecord : null; +} + +function tsvCell(value: JsonValue | undefined): string { + if (value === null || value === undefined) return ""; + return String(value).replace(/[\t\r\n]+/gu, " "); +} + +function plainTextOutput(text: string): PlainTextOutput { + return { [plainTextOutputMarker]: true, text }; +} + +function isPlainTextOutput(value: CliResult): value is PlainTextOutput { + return typeof value === "object" && value !== null && plainTextOutputMarker in value; +} + async function listSessions(args: ParsedArgs): Promise { const params = new URLSearchParams(); const state = optionalFlag(args, "state") ?? (args.flags.get("running") === true ? "running" : args.flags.get("unread") === true ? "unread" : args.flags.get("all") === true ? "all" : null); @@ -814,7 +988,7 @@ function help(args: ParsedArgs, group?: string): JsonRecord { const commands = [ "runs create --json-file ", "runs show ", - "runs events --after-seq --limit ", + "runs events --after-seq --limit [--summary|--tail-summary] [--tail ] [--summary-chars ] [--format json|tsv]", "runs result [--command-id ]", "runs cancel [--reason ]", "sessions ps [--state default|running|unread|terminal|idle|all] [--profile codex|deepseek|minimax-m3|dsflash-go||M3] [--reader-id ]", diff --git a/src/selftest/cases/15-cli-events-summary.ts b/src/selftest/cases/15-cli-events-summary.ts new file mode 100644 index 0000000..faacc40 --- /dev/null +++ b/src/selftest/cases/15-cli-events-summary.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import type { JsonRecord } from "../../common/types.js"; +import { assertNoSecretLeak, type SelfTestContext, type SelfTestResult } from "../harness.js"; +import { renderRunEventSummaryTsv, summarizeRunEventPage } from "../../../scripts/src/cli.js"; + +export default function selfTest(_context: SelfTestContext): SelfTestResult { + const summary = summarizeRunEventPage({ items: [ + { seq: 11, type: "backend_status", payload: { phase: "run-claimed", summary: { text: "runner claimed" } } }, + { seq: 12, type: "tool_call", payload: { method: "shell", status: "completed", command: "curl -H 'Authorization: Bearer test-token-material' https://example.invalid", exitCode: 0, durationMs: 321, outputSummary: "downloaded archive\nnext line", outputBytes: 4096, outputTruncated: true } }, + { seq: 13, type: "assistant_message", payload: { text: "final assistant text that is intentionally longer than the selected summary limit" } }, + ] }, { runId: "run_selftest", afterSeq: 10, limit: 3, tail: 2, summaryChars: 40 }); + + assert.equal(summary.action, "runs-events-summary"); + assert.equal(summary.sourceCount, 3); + assert.equal(summary.count, 2); + const items = summary.items as JsonRecord[]; + assert.deepEqual(items.map((item) => item.seq), [12, 13]); + assert.equal(items[0]?.method, "shell"); + assert.equal(items[0]?.status, "completed"); + assert.equal(items[0]?.exitCode, 0); + assert.equal(items[0]?.durationMs, 321); + assert.equal(items[0]?.outputTruncated, true); + assert.equal(String(items[0]?.command).includes("test-token-material"), false); + assert.equal(String(items[0]?.outputSummary).includes("\n"), false); + assert.equal(String(items[1]?.summary).endsWith("..."), true); + assertNoSecretLeak(summary); + + const tsv = renderRunEventSummaryTsv(summary); + assert.match(tsv, /^seq\ttype\tmethod\tstatus\texitCode\tdurationMs\toutputTruncated\toutputBytes\tsummary\n/u); + assert.equal(tsv.includes("\n12\ttool_call\tshell\tcompleted\t0\t321\ttrue\t4096"), true); + assert.equal(tsv.includes("test-token-material"), false); + + return { name: "15-cli-events-summary", tests: ["runs-events-summary-tail", "runs-events-summary-tsv-redaction"] }; +}