fix: summarize AgentRun command output by signal
This commit is contained in:
@@ -133,6 +133,30 @@ assertCondition(
|
||||
"AgentRun logs/events must render payload error, backend phase, and command id summaries by default",
|
||||
);
|
||||
|
||||
assertCondition(
|
||||
agentRunSource.includes("function commandOutputSummary(payload: Record<string, unknown>): string | null")
|
||||
&& agentRunSource.includes("function summarizeStructuredCliOutput(value: Record<string, unknown>): string | null")
|
||||
&& agentRunSource.includes("function parseJsonRecordFromText(raw: string): Record<string, unknown> | null")
|
||||
&& agentRunSource.includes('if (type === "command_output")')
|
||||
&& agentRunSource.includes("push(commandOutputSummary(payload))")
|
||||
&& agentRunSource.includes('if (type !== "command_output")'),
|
||||
"AgentRun command_output summaries must use a dedicated low-noise renderer instead of raw payload JSON",
|
||||
);
|
||||
|
||||
assertCondition(
|
||||
agentRunSource.includes("isLowSignalJsonField")
|
||||
&& agentRunSource.includes('key === "generatedAt" || key === "cli" || key === "version" || key === "valuesRedacted" || key === "secretMaterialStored"')
|
||||
&& agentRunSource.includes('firstPathText(value, ["action", "operation", "command", "kind"])')
|
||||
&& agentRunSource.includes('"traceId", "lastTraceId", "trace.traceId", "trace.id", "body.traceId", "body.trace.id", "providerTrace.traceId", "runnerTrace.traceId"')
|
||||
&& agentRunSource.includes('"sessionId", "session.sessionId", "body.sessionId", "workspace.selectedAgentSessionId", "workspace.selectedConversation.sessionId"')
|
||||
&& agentRunSource.includes('"conversationId", "session.conversationId", "body.conversationId", "workspace.selectedConversationId", "workspace.selectedConversation.conversationId"')
|
||||
&& agentRunSource.includes('"providerProfile", "backendProfile", "profile", "session.providerProfile", "workspace.providerProfile"')
|
||||
&& agentRunSource.includes('"pipelineRun", "pipelineRunName", "pipelineRun.name", "pipeline.runName", "pipeline.name"')
|
||||
&& agentRunSource.includes("function summarizePartialJsonCommandOutput(raw: string): string | null")
|
||||
&& agentRunSource.includes("if (failure !== null || isFailureLikeStatus(status))"),
|
||||
"AgentRun command_output summaries must prefer business fields and suppress metadata-only JSON headers",
|
||||
);
|
||||
|
||||
assertCondition(
|
||||
agentRunSource.includes("function rerunWithoutDryRun(command: string): string")
|
||||
&& agentRunSource.includes("options.dryRun ? [rerunWithoutDryRun(command)] : undefined"),
|
||||
@@ -157,6 +181,8 @@ console.log(JSON.stringify({
|
||||
"AgentRun resource failure output is visible in human mode",
|
||||
"AgentRun logs tail is enforced by the render-only client",
|
||||
"AgentRun logs/events expose payload error and backend phase summaries",
|
||||
"AgentRun command_output summaries use a dedicated low-noise renderer",
|
||||
"AgentRun command_output summaries prefer business fields over metadata headers",
|
||||
"AgentRun dry-run mutations keep resource-command follow-up",
|
||||
],
|
||||
}));
|
||||
|
||||
+252
-5
@@ -950,8 +950,7 @@ function agentRunEventSummary(item: Record<string, unknown>, payload: Record<str
|
||||
}
|
||||
|
||||
if (type === "command_output") {
|
||||
push(payload.summary);
|
||||
push(payload.text);
|
||||
push(commandOutputSummary(payload));
|
||||
}
|
||||
|
||||
if (type === "assistant_message") {
|
||||
@@ -962,15 +961,263 @@ function agentRunEventSummary(item: Record<string, unknown>, payload: Record<str
|
||||
push(item.summary);
|
||||
push(item.outputSummary);
|
||||
push(item.text);
|
||||
push(payload.summary);
|
||||
push(payload.outputSummary);
|
||||
push(payload.text);
|
||||
if (type !== "command_output") {
|
||||
push(payload.summary);
|
||||
push(payload.outputSummary);
|
||||
push(payload.text);
|
||||
}
|
||||
push(payload.message);
|
||||
push(payload.reason);
|
||||
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
function commandOutputSummary(payload: Record<string, unknown>): string | null {
|
||||
const raw = commandOutputText(payload);
|
||||
if (raw === null) return null;
|
||||
const structured = parseJsonRecordFromText(raw);
|
||||
if (structured !== null) {
|
||||
const summary = summarizeStructuredCliOutput(structured);
|
||||
if (summary !== null) return summary;
|
||||
}
|
||||
return summarizePartialJsonCommandOutput(raw) ?? summarizeTextCommandOutput(raw);
|
||||
}
|
||||
|
||||
function commandOutputText(payload: Record<string, unknown>): string | null {
|
||||
const summary = record(payload.summary);
|
||||
const summaryText = stringOrNull(summary.text);
|
||||
const payloadText = stringOrNull(payload.text);
|
||||
if (summary.textTruncated === true && payloadText !== null) return payloadText;
|
||||
return summaryText
|
||||
?? payloadText
|
||||
?? stringOrNull(payload.outputSummary)
|
||||
?? eventText(payload.summary);
|
||||
}
|
||||
|
||||
function parseJsonRecordFromText(raw: string): Record<string, unknown> | null {
|
||||
const trimmed = raw.trim();
|
||||
const direct = tryParseJsonRecord(trimmed);
|
||||
if (direct !== null) return direct;
|
||||
|
||||
const firstBrace = trimmed.indexOf("{");
|
||||
const lastBrace = trimmed.lastIndexOf("}");
|
||||
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
||||
const sliced = tryParseJsonRecord(trimmed.slice(firstBrace, lastBrace + 1));
|
||||
if (sliced !== null) return sliced;
|
||||
}
|
||||
|
||||
const line = trimmed
|
||||
.split(/\r?\n/u)
|
||||
.map((item) => item.trim())
|
||||
.reverse()
|
||||
.find((item) => item.startsWith("{") && item.endsWith("}"));
|
||||
return line === undefined ? null : tryParseJsonRecord(line);
|
||||
}
|
||||
|
||||
function tryParseJsonRecord(raw: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeStructuredCliOutput(value: Record<string, unknown>): string | null {
|
||||
const parts: string[] = [];
|
||||
const action = firstPathText(value, ["action", "operation", "command", "kind"]);
|
||||
const status = firstPathText(value, ["status", "state", "phase", "body.status"]);
|
||||
const failure = firstPathText(value, ["failureKind", "error.failureKind", "body.failureKind"]);
|
||||
const message = firstPathText(value, ["error.message", "error.additionalDetails", "message", "reason", "body.message", "body.error", "result.message"]);
|
||||
const primary = [
|
||||
action,
|
||||
status,
|
||||
labeledSummaryValue("http", firstPathText(value, ["httpStatus", "request.httpStatus"])),
|
||||
].filter((item): item is string => item !== null);
|
||||
if (primary.length > 0) pushSummaryPart(parts, primary.join(" "));
|
||||
|
||||
pushLabeledSummaryPart(parts, "failure", failure);
|
||||
if (failure !== null || isFailureLikeStatus(status)) pushLabeledSummaryPart(parts, "message", message);
|
||||
pushLabeledSummaryPart(parts, "trace", firstPathText(value, ["traceId", "lastTraceId", "trace.traceId", "trace.id", "body.traceId", "body.trace.id", "providerTrace.traceId", "runnerTrace.traceId"]), true);
|
||||
pushLabeledSummaryPart(parts, "session", firstPathText(value, ["sessionId", "session.sessionId", "body.sessionId", "workspace.selectedAgentSessionId", "workspace.selectedConversation.sessionId"]), true);
|
||||
pushLabeledSummaryPart(parts, "conversation", firstPathText(value, ["conversationId", "session.conversationId", "body.conversationId", "workspace.selectedConversationId", "workspace.selectedConversation.conversationId"]), true);
|
||||
pushLabeledSummaryPart(parts, "thread", firstPathText(value, ["threadId", "session.threadId", "body.threadId", "workspace.selectedConversation.threadId"]), true);
|
||||
pushLabeledSummaryPart(parts, "provider", firstPathText(value, ["providerProfile", "backendProfile", "profile", "session.providerProfile", "workspace.providerProfile"]));
|
||||
pushLabeledSummaryPart(parts, "route", commandOutputRouteSummary(value));
|
||||
pushLabeledSummaryPart(parts, "pipeline", firstPathText(value, ["pipelineRun", "pipelineRunName", "pipelineRun.name", "pipeline.runName", "pipeline.name"]), true);
|
||||
pushLabeledSummaryPart(parts, "run", firstPathText(value, ["runId", "agentRun.runId"]), true);
|
||||
pushLabeledSummaryPart(parts, "cmd", firstPathText(value, ["commandId", "agentRun.commandId", "providerTrace.commandId", "runnerTrace.commandId"]), true);
|
||||
pushLabeledSummaryPart(parts, "job", firstPathText(value, ["jobName", "agentRun.jobName", "runnerJobId"]), true);
|
||||
pushLabeledSummaryPart(parts, "lane", firstPathText(value, ["runtimeEndpoint.lane", "lane"]));
|
||||
pushLabeledSummaryPart(parts, "ns", firstPathText(value, ["runtimeEndpoint.namespace", "namespace"]));
|
||||
pushLabeledSummaryPart(parts, "actor", firstPathText(value, ["actor.username", "body.actor.username", "actor.displayName", "body.actor.displayName"]));
|
||||
pushLabeledSummaryPart(parts, "auth", firstPathText(value, ["authMethod", "auth.authMethod", "body.authMethod"]));
|
||||
pushLabeledSummaryPart(parts, "reply", firstPathText(value, ["reply.content", "result.reply.content", "body.reply.content", "answer", "content", "body.content"]));
|
||||
if (failure === null && !isFailureLikeStatus(status)) pushLabeledSummaryPart(parts, "message", message);
|
||||
|
||||
const itemsCount = arrayRecords(value.items).length;
|
||||
if (itemsCount > 0) pushSummaryPart(parts, `items=${itemsCount}`);
|
||||
const eventsCount = arrayRecords(value.events).length;
|
||||
if (eventsCount > 0) pushSummaryPart(parts, `events=${eventsCount}`);
|
||||
if (value.sessionUsable === false) pushSummaryPart(parts, "sessionUsable=false");
|
||||
if (record(value.summary).textTruncated === true || value.textTruncated === true || value.outputTruncated === true) pushSummaryPart(parts, "truncated=true");
|
||||
|
||||
return parts.length > 0 ? parts.slice(0, 10).join("; ") : null;
|
||||
}
|
||||
|
||||
function commandOutputRouteSummary(value: Record<string, unknown>): string | null {
|
||||
const route = record(value.route);
|
||||
const request = record(value.request);
|
||||
const method = stringOrNull(route.method) ?? stringOrNull(request.method);
|
||||
const path = stringOrNull(route.path) ?? stringOrNull(request.path);
|
||||
if (method !== null && path !== null) return `${method} ${path}`;
|
||||
return stringOrNull(request.url) ?? stringOrNull(value.baseUrl);
|
||||
}
|
||||
|
||||
function firstPathText(value: Record<string, unknown>, paths: string[]): string | null {
|
||||
for (const path of paths) {
|
||||
const text = summaryScalar(pathValue(value, path));
|
||||
if (text !== null) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pathValue(value: Record<string, unknown>, path: string): unknown {
|
||||
let current: unknown = value;
|
||||
for (const key of path.split(".")) {
|
||||
if (!isRecord(current)) return undefined;
|
||||
current = current[key];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function summaryScalar(value: unknown): string | null {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
if (typeof value === "boolean") return String(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
function isFailureLikeStatus(value: string | null): boolean {
|
||||
return value !== null && /(?:fail|error|timeout|cancel|interrupt|denied|rejected)/iu.test(value);
|
||||
}
|
||||
|
||||
function pushLabeledSummaryPart(parts: string[], label: string, value: string | null, shorten = false): void {
|
||||
const text = labeledSummaryValue(label, value, shorten);
|
||||
if (text !== null) pushSummaryPart(parts, text);
|
||||
}
|
||||
|
||||
function labeledSummaryValue(label: string, value: string | null, shorten = false): string | null {
|
||||
if (value === null) return null;
|
||||
const rendered = shorten ? shortId(value) : truncateOneLine(value, 80);
|
||||
return `${label}=${rendered}`;
|
||||
}
|
||||
|
||||
function pushSummaryPart(parts: string[], value: string): void {
|
||||
const text = truncateOneLine(value, 160);
|
||||
if (text.length > 0 && !parts.includes(text)) parts.push(text);
|
||||
}
|
||||
|
||||
function summarizeTextCommandOutput(raw: string): string | null {
|
||||
const lines = raw
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && !isLowSignalCommandOutputLine(line));
|
||||
const signal = lines.find((line) => /(?:trace|session|conversation|thread|status|failure|error|route|http|pipeline|provider|commit|succeeded|failed|created)/iu.test(line))
|
||||
?? lines[0]
|
||||
?? null;
|
||||
if (signal === null) return null;
|
||||
return truncateOneLine(cleanCommandOutputLine(signal), 220);
|
||||
}
|
||||
|
||||
function summarizePartialJsonCommandOutput(raw: string): string | null {
|
||||
const fields = new Map<string, string>();
|
||||
for (const line of raw.split(/\r?\n/u)) {
|
||||
const field = jsonLineField(line);
|
||||
if (field === null || isLowSignalJsonField(field.key)) continue;
|
||||
if (!fields.has(field.key) && field.value.length > 0) fields.set(field.key, field.value);
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
const action = fields.get("action") ?? fields.get("operation") ?? fields.get("command") ?? fields.get("kind") ?? null;
|
||||
const status = fields.get("status") ?? fields.get("state") ?? fields.get("phase") ?? null;
|
||||
const http = fields.get("httpStatus") ?? null;
|
||||
const failure = fields.get("failureKind") ?? null;
|
||||
const message = fields.get("message") ?? fields.get("reason") ?? fields.get("error") ?? null;
|
||||
const primary = [action, status, labeledSummaryValue("http", http)].filter((item): item is string => item !== null);
|
||||
if (primary.length > 0) pushSummaryPart(parts, primary.join(" "));
|
||||
|
||||
pushLabeledSummaryPart(parts, "failure", failure);
|
||||
if (failure !== null || isFailureLikeStatus(status)) pushLabeledSummaryPart(parts, "message", message);
|
||||
pushLabeledSummaryPart(parts, "trace", fields.get("traceId") ?? fields.get("lastTraceId") ?? null, true);
|
||||
pushLabeledSummaryPart(parts, "session", fields.get("sessionId") ?? fields.get("selectedAgentSessionId") ?? null, true);
|
||||
pushLabeledSummaryPart(parts, "conversation", fields.get("conversationId") ?? fields.get("selectedConversationId") ?? null, true);
|
||||
pushLabeledSummaryPart(parts, "thread", fields.get("threadId") ?? null, true);
|
||||
pushLabeledSummaryPart(parts, "provider", fields.get("providerProfile") ?? fields.get("backendProfile") ?? fields.get("profile") ?? null);
|
||||
pushLabeledSummaryPart(parts, "route", partialRouteSummary(fields));
|
||||
pushLabeledSummaryPart(parts, "pipeline", fields.get("pipelineRun") ?? fields.get("pipelineRunName") ?? null, true);
|
||||
pushLabeledSummaryPart(parts, "run", fields.get("runId") ?? null, true);
|
||||
pushLabeledSummaryPart(parts, "cmd", fields.get("commandId") ?? null, true);
|
||||
pushLabeledSummaryPart(parts, "job", fields.get("jobName") ?? fields.get("runnerJobId") ?? null, true);
|
||||
pushLabeledSummaryPart(parts, "lane", fields.get("lane") ?? null);
|
||||
pushLabeledSummaryPart(parts, "ns", fields.get("namespace") ?? null);
|
||||
pushLabeledSummaryPart(parts, "actor", fields.get("username") ?? fields.get("displayName") ?? null);
|
||||
pushLabeledSummaryPart(parts, "auth", fields.get("authMethod") ?? null);
|
||||
if (failure === null && !isFailureLikeStatus(status)) pushLabeledSummaryPart(parts, "message", message);
|
||||
|
||||
return parts.length > 0 ? parts.slice(0, 10).join("; ") : null;
|
||||
}
|
||||
|
||||
function partialRouteSummary(fields: Map<string, string>): string | null {
|
||||
const method = fields.get("method") ?? null;
|
||||
const path = fields.get("path") ?? null;
|
||||
if (method !== null && path !== null) return `${method} ${path}`;
|
||||
return fields.get("url") ?? fields.get("baseUrl") ?? null;
|
||||
}
|
||||
|
||||
function jsonLineField(line: string): { key: string; value: string } | null {
|
||||
const match = line.trim().replace(/,$/u, "").match(/^"([^"]+)":\s*(.+)$/u);
|
||||
if (match === null) return null;
|
||||
return { key: match[1], value: cleanJsonScalar(match[2]) };
|
||||
}
|
||||
|
||||
function cleanJsonScalar(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === "null" || trimmed === "{" || trimmed === "[" || trimmed === "}" || trimmed === "]") return "";
|
||||
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
return typeof parsed === "string" ? parsed.trim() : String(parsed);
|
||||
} catch {
|
||||
return trimmed.slice(1, -1).trim();
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function cleanCommandOutputLine(line: string): string {
|
||||
const trimmed = line.replace(/,$/u, "").trim();
|
||||
const jsonField = trimmed.match(/^"([^"]+)":\s*(.+)$/u);
|
||||
if (jsonField === null) return trimmed;
|
||||
const key = jsonField[1];
|
||||
const value = jsonField[2].replace(/^"|"$/gu, "").trim();
|
||||
return `${key}=${value}`;
|
||||
}
|
||||
|
||||
function isLowSignalCommandOutputLine(line: string): boolean {
|
||||
const trimmed = line.replace(/,$/u, "").trim();
|
||||
if (trimmed === "{" || trimmed === "}" || trimmed === "}," || trimmed === "];" || trimmed === "[") return true;
|
||||
const field = jsonLineField(trimmed);
|
||||
return field !== null && isLowSignalJsonField(field.key);
|
||||
}
|
||||
|
||||
function isLowSignalJsonField(key: string): boolean {
|
||||
return key === "generatedAt" || key === "cli" || key === "version" || key === "valuesRedacted" || key === "secretMaterialStored";
|
||||
}
|
||||
|
||||
function eventText(value: unknown): string | null {
|
||||
const direct = stringOrNull(value);
|
||||
if (direct !== null && direct.trim().length > 0) return direct.trim();
|
||||
|
||||
Reference in New Issue
Block a user