diff --git a/extensions/workflows/completion-projection.ts b/extensions/workflows/completion-projection.ts new file mode 100644 index 00000000..61e99c7b --- /dev/null +++ b/extensions/workflows/completion-projection.ts @@ -0,0 +1,457 @@ +import { allocateResultBudgets } from "../shared/result-budget.ts"; +import { sanitizeTerminalText } from "../shared/terminal-text.ts"; +import { projectText } from "../shared/text-projection.ts"; +import { + countStates, + formatElapsed, + resultJson, + sanitizeWorkflowDisplayLine, + shortenHome, + statusWord, + type WorkflowDetails, + type WorkflowLogEntry, + type WorkflowStatus, +} from "./model.ts"; +import { safeStringify } from "./serialization.ts"; + +const MAX_DISPLAY_ENTRIES = 64; +const MAX_DISPLAY_BYTES = 64 * 1024; +const MAX_EXPANDED_ENTRY_BYTES = 48 * 1024; +const MAX_ALERTS = 16; +const MAX_FIELD_BYTES = 2 * 1024; + +export interface WorkflowCompletionSourceEntry { + deliveryId: string; + details: WorkflowDetails; + runDir: string; +} + +/** Bounded operator-facing facts. Runtime state remains in artifacts/model context. */ +export interface WorkflowCompletionDisplayEntry { + deliveryId: string; + runId: string; + status: WorkflowStatus; + summary: string; + alerts: string[]; + resultPreview?: string; + expanded: string; +} + +export interface WorkflowCompletionDisplay { + version: 1; + /** Backward-compatible single-run identity for delivery observers. */ + runId?: string; + entries: WorkflowCompletionDisplayEntry[]; + omittedEntries?: number; +} + +function boundedLine(value: string, maxBytes = MAX_FIELD_BYTES) { + return projectText(sanitizeWorkflowDisplayLine(value), { + maxBytes, + maxLines: 1, + recovery: "", + }); +} + +function labels(details: WorkflowDetails, state: "error" | "uncertain") { + return details.agents + .filter((agent) => agent.state === state) + .map((agent) => sanitizeWorkflowDisplayLine(agent.label)); +} + +function completionSummary(details: WorkflowDetails) { + const { done, failed } = countStates(details); + const elapsed = formatElapsed(details.startedAt, details.finishedAt); + return boundedLine( + `workflow ${details.name ?? details.runId} · ${done + failed}/${details.agents.length} agents · ${elapsed} · ${statusWord(details.status)}`, + ); +} + +function isDroppedWorkLog(entry: WorkflowLogEntry) { + if (entry.kind === "pipeline-drop") return true; + const text = entry.text; + const negated = + /\b(?:no|none|nothing|not|zero|0)\b.{0,48}\b(?:dropped|discarded|omitted)\b/iu.test( + text, + ) || + /(?:没有|并未|未曾|未|无|零(?:个|项)?).{0,24}(?:丢弃|丢失|遗漏)/u.test( + text, + ); + if (negated) return false; + return ( + /\b(?:dropped|discarded|omitted)\b/iu.test(text) || + /(?:被)?(?:丢弃|丢失|遗漏)/u.test(text) + ); +} + +/** Exceptional evidence that must remain visible while the report is collapsed. */ +function completionAlerts(details: WorkflowDetails) { + const alerts: string[] = []; + if (details.error) alerts.push(`Error: ${details.error}`); + + const failed = labels(details, "error"); + if (failed.length > 0) alerts.push(`Failed agents: ${failed.join(", ")}`); + const uncertain = labels(details, "uncertain"); + if (uncertain.length > 0) { + alerts.push(`Uncertain agents: ${uncertain.join(", ")}`); + } + if (details.logsDropped) { + alerts.push(`${details.logsDropped} earlier log line(s) dropped`); + } + + for (const entry of details.logs ?? []) { + if (!isDroppedWorkLog(entry)) continue; + alerts.push(`Dropped work: ${sanitizeWorkflowDisplayLine(entry.text)}`); + } + + for (const agent of details.agents) { + if (agent.worktreePath) { + const reason = agent.worktreeCleanup?.reason ?? "cleanup was unsafe"; + alerts.push( + `Retained worktree [${sanitizeWorkflowDisplayLine(agent.label)}]: ${shortenHome(agent.worktreePath)} (${sanitizeWorkflowDisplayLine(reason)})${ + agent.worktreeHandoffArtifact + ? `; handoff ${sanitizeWorkflowDisplayLine(agent.worktreeHandoffArtifact)}` + : "" + }`, + ); + continue; + } + if (!agent.worktreeHandoffArtifact) continue; + const cleanup = agent.worktreeCleanup; + const work = cleanup?.commits + ? `${cleanup.commits} commit${cleanup.commits === 1 ? "" : "s"} on ${cleanup.branch}` + : agent.worktreeBranch + ? `branch ${agent.worktreeBranch}` + : "isolated work"; + alerts.push( + `Worktree handoff [${sanitizeWorkflowDisplayLine(agent.label)}]: ${sanitizeWorkflowDisplayLine(work)}; ${sanitizeWorkflowDisplayLine(agent.worktreeHandoffArtifact)}`, + ); + } + + const unique = [...new Set(alerts)].map((alert) => + sanitizeTerminalText(boundedLine(alert, 512)), + ); + if (unique.length <= MAX_ALERTS) return unique; + return [ + ...unique.slice(0, MAX_ALERTS - 1), + `${unique.length - MAX_ALERTS + 1} more exceptional item(s); expand for evidence`, + ]; +} + +/** Short return-value preview for collapsed success cards. */ +function completionResultPreview(details: WorkflowDetails) { + if (details.result === undefined) return undefined; + if (typeof details.result === "string") return boundedLine(details.result); + const serialized = safeStringify(details.result, { maxBytes: 2 * 1024 }); + try { + return boundedLine(JSON.stringify(JSON.parse(serialized))); + } catch { + return boundedLine(resultJson(details.result)); + } +} + +/** Operator evidence, intentionally independent from the model transport report. */ +function buildOperatorReport( + details: WorkflowDetails, + runDir: string, + deliveryId: string, +) { + const { done, failed, uncertain } = countStates(details); + const elapsed = formatElapsed(details.startedAt, details.finishedAt); + const lines = [ + `Workflow ${details.name ? `"${details.name}"` : details.runId} ${details.status} — ${done}/${details.agents.length} agents ok${failed ? `, ${failed} failed` : ""}${uncertain ? `, ${uncertain} uncertain` : ""} across ${details.phases.length} phase(s) in ${elapsed}.`, + `Run dir: ${shortenHome(runDir)}`, + `Delivery id: ${deliveryId}`, + ]; + + const artifacts = [ + details.resultArtifact ? `Result: ${details.resultArtifact}` : undefined, + details.transcriptArtifact + ? `Transcripts: ${details.transcriptArtifact}` + : undefined, + ].filter((entry): entry is string => entry !== undefined); + if (artifacts.length > 0) lines.push("", "Artifacts:", ...artifacts); + + const replayed = details.agents.filter((agent) => agent.replayed).length; + if (details.resumedFrom) { + lines.push( + `Resumed from ${details.resumedFrom}: replayed ${replayed}/${details.agents.length} agent call(s), ran ${details.agents.length - replayed} for real.`, + ); + } + if (details.resumeNote) lines.push(`Resume: ${details.resumeNote}`); + if (details.error) lines.push(`Error: ${details.error}`); + + if (details.logs?.length) { + lines.push("", "Log:"); + if (details.logsDropped) { + lines.push(` (${details.logsDropped} earlier line(s) dropped)`); + } + for (const entry of details.logs) lines.push(` ${entry.text}`); + } + + const isolated = details.agents.filter( + (agent) => agent.worktreeBranch || agent.worktreePath, + ); + if (isolated.length > 0) { + lines.push("", "Isolated worktrees:"); + for (const agent of isolated) { + const cleanup = agent.worktreeCleanup; + const work = cleanup?.commits + ? `${cleanup.commits} commit${cleanup.commits === 1 ? "" : "s"} on ${cleanup.branch}` + : agent.worktreeBranch + ? `committed to branch ${agent.worktreeBranch}` + : "no commits"; + lines.push( + `- [${agent.label}] ${work}${ + agent.worktreePath + ? `; kept at ${shortenHome(agent.worktreePath)} (${cleanup?.reason ?? "uncommitted changes"})` + : cleanup?.branchDeleted + ? "; empty branch deleted" + : cleanup?.reason + ? `; cleanup warning: ${cleanup.reason}` + : "" + }${agent.worktreeHandoffArtifact ? `; handoff ${agent.worktreeHandoffArtifact}` : ""}`, + ); + } + } + + if (details.agents.length > 0) { + lines.push("", "Agents:"); + for (const agent of details.agents) { + const state = + agent.state === "done" + ? agent.replayed + ? "ok (replayed)" + : "ok" + : agent.state === "error" + ? "FAILED" + : agent.state === "uncertain" + ? "UNCERTAIN" + : "running"; + lines.push( + `- [${agent.label}]${agent.phase ? ` (${agent.phase})` : ""} ${state}` + + (agent.acceptance ? ` · acceptance ${agent.acceptance.status}` : "") + + (agent.error ? ` — ${agent.error}` : ""), + ); + } + } + if (details.result !== undefined) { + lines.push("", "Result:", resultJson(details.result)); + } + return sanitizeTerminalText(lines.join("\n")); +} + +/** Build a byte-bounded display projection without retaining runtime objects. */ +export function buildWorkflowCompletionDisplay( + sourceEntries: readonly WorkflowCompletionSourceEntry[], +): WorkflowCompletionDisplay { + const selected = sourceEntries.slice(0, MAX_DISPLAY_ENTRIES); + const fixedEntry = ({ + deliveryId, + details, + }: WorkflowCompletionSourceEntry) => ({ + deliveryId: boundedLine(deliveryId, 512), + runId: boundedLine(details.runId, 512), + status: details.status, + summary: completionSummary(details), + alerts: completionAlerts(details), + resultPreview: completionResultPreview(details), + expanded: "", + }); + let fixedEntries = selected.map(fixedEntry); + const fixedSize = () => + Buffer.byteLength( + JSON.stringify({ + version: 1, + ...(sourceEntries.length === 1 + ? { runId: fixedEntries[0]?.runId ?? "" } + : {}), + entries: fixedEntries, + ...(sourceEntries.length > selected.length + ? { omittedEntries: sourceEntries.length - selected.length } + : {}), + }), + "utf8", + ); + while (selected.length > 1 && fixedSize() > MAX_DISPLAY_BYTES / 2) { + selected.pop(); + fixedEntries.pop(); + } + const fixedBytes = fixedSize(); + const reports = selected.map(({ deliveryId, details, runDir }) => + buildOperatorReport(details, runDir, deliveryId), + ); + const allocation = allocateResultBudgets( + reports.map((report) => Buffer.byteLength(report, "utf8")), + undefined, + { + maxBatchBytes: Math.max(0, MAX_DISPLAY_BYTES - fixedBytes), + maxResultBytes: MAX_EXPANDED_ENTRY_BYTES, + minResultBytes: 512, + headroomShare: 0, + estimatedBytesPerToken: 4, + }, + ); + let budgets = [...allocation.budgets]; + const projectedEntries = () => + fixedEntries.map((entry, index) => ({ + ...entry, + expanded: projectText(reports[index]!, { + maxBytes: budgets[index] ?? 0, + maxLines: 600, + recovery: `Full workflow evidence is available in ${shortenHome(selected[index]!.runDir)}.`, + }), + })); + let entries = projectedEntries(); + const projectedSize = () => + Buffer.byteLength( + JSON.stringify({ + version: 1, + ...(sourceEntries.length === 1 + ? { runId: entries[0]?.runId ?? "" } + : {}), + entries, + ...(sourceEntries.length > selected.length + ? { omittedEntries: sourceEntries.length - selected.length } + : {}), + }), + "utf8", + ); + for (let attempt = 0; projectedSize() > MAX_DISPLAY_BYTES; attempt++) { + if (attempt >= 8) { + budgets = budgets.map(() => 0); + } else { + const expandedBytes = budgets.reduce((sum, budget) => sum + budget, 0); + const overflow = projectedSize() - MAX_DISPLAY_BYTES; + const target = Math.max( + 0, + expandedBytes - overflow - entries.length * 16, + ); + const scale = expandedBytes > 0 ? target / expandedBytes : 0; + budgets = budgets.map((budget) => Math.floor(budget * scale)); + } + entries = projectedEntries(); + } + const display: WorkflowCompletionDisplay = { + version: 1, + ...(sourceEntries.length === 1 + ? { runId: entries[0]?.runId ?? sourceEntries[0]!.details.runId } + : {}), + entries, + ...(sourceEntries.length > selected.length + ? { omittedEntries: sourceEntries.length - selected.length } + : {}), + }; + return display; +} + +function isString(value: unknown, maxBytes = MAX_FIELD_BYTES) { + return ( + typeof value === "string" && Buffer.byteLength(value, "utf8") <= maxBytes + ); +} + +function isStatus(value: unknown): value is WorkflowStatus { + return ( + value === "running" || + value === "completed" || + value === "failed" || + value === "aborted" || + value === "uncertain" + ); +} + +function hasOnlyKeys(value: object, allowed: readonly string[]) { + const keys = new Set(allowed); + return Object.keys(value).every((key) => keys.has(key)); +} + +export function isWorkflowCompletionDisplay( + value: unknown, +): value is WorkflowCompletionDisplay { + if (!value || typeof value !== "object") return false; + let bytes: number; + try { + bytes = Buffer.byteLength(JSON.stringify(value), "utf8"); + } catch { + return false; + } + if (bytes > MAX_DISPLAY_BYTES) return false; + try { + const candidate = value as Partial; + if ( + !hasOnlyKeys(candidate, [ + "version", + "runId", + "entries", + "omittedEntries", + ]) || + candidate.version !== 1 || + !Array.isArray(candidate.entries) || + candidate.entries.length > MAX_DISPLAY_ENTRIES || + (candidate.runId !== undefined && !isString(candidate.runId, 512)) || + (candidate.omittedEntries !== undefined && + (!Number.isSafeInteger(candidate.omittedEntries) || + candidate.omittedEntries <= 0)) + ) { + return false; + } + return candidate.entries.every((entry) => { + if (!entry || typeof entry !== "object") return false; + const record = entry as Partial; + return ( + hasOnlyKeys(record, [ + "deliveryId", + "runId", + "status", + "summary", + "alerts", + "resultPreview", + "expanded", + ]) && + isString(record.deliveryId, 512) && + isString(record.runId, 512) && + isStatus(record.status) && + isString(record.summary) && + Array.isArray(record.alerts) && + record.alerts.length <= MAX_ALERTS && + record.alerts.every((alert) => isString(alert, 512)) && + (record.resultPreview === undefined || + isString(record.resultPreview)) && + isString(record.expanded, MAX_EXPANDED_ENTRY_BYTES) + ); + }); + } catch { + return false; + } +} + +export function workflowCompletionSummary( + entry: WorkflowCompletionDisplayEntry, +) { + return entry.summary; +} + +export function workflowCompletionAlerts( + entry: WorkflowCompletionDisplayEntry, +) { + return entry.alerts; +} + +export function workflowCompletionResultPreview( + entry: WorkflowCompletionDisplayEntry, +) { + return entry.resultPreview; +} + +export function buildExpandedWorkflowCompletion( + display: WorkflowCompletionDisplay, +) { + const reports = display.entries.map((entry) => entry.expanded); + if (display.omittedEntries) { + reports.push( + `${display.omittedEntries} additional workflow completion(s) omitted from this display projection; their full evidence remains in workflow artifacts and model-visible delivery context.`, + ); + } + return reports.join("\n\n"); +} diff --git a/extensions/workflows/dashboard.ts b/extensions/workflows/dashboard.ts index a37152eb..0f97608f 100644 --- a/extensions/workflows/dashboard.ts +++ b/extensions/workflows/dashboard.ts @@ -20,8 +20,8 @@ import { } from "@earendil-works/pi-coding-agent"; import { type TUI, truncateToWidth } from "@earendil-works/pi-tui"; import { AgentSessionPage } from "../shared/agent-session-page.ts"; -import { contextPercent } from "../shared/context-utilization.ts"; import { fitNavigationSides } from "../shared/below-editor-navigation.ts"; +import { contextPercent } from "../shared/context-utilization.ts"; import { panelFrame, type ScreenHint, @@ -428,6 +428,9 @@ export function normalizePersistedWorkflowDetails( logs.push({ at: typeof entry.at === "number" ? entry.at : startedAt, text: sanitizeLine(entry.text, MAX_LOG_TEXT), + ...(entry.kind === "pipeline-drop" + ? { kind: "pipeline-drop" as const } + : {}), }); } diff --git a/extensions/workflows/index.ts b/extensions/workflows/index.ts index b295842f..1e83d785 100644 --- a/extensions/workflows/index.ts +++ b/extensions/workflows/index.ts @@ -52,13 +52,13 @@ import { createStatusWriter, formatActivityStatus, } from "../shared/activity-status.ts"; +import { fitNavigationSides } from "../shared/below-editor-navigation.ts"; import { waitBounded } from "../shared/child-session.ts"; import { contextPercent } from "../shared/context-utilization.ts"; import { registerEditorLayer, removeEditorLayer, } from "../shared/editor-layers.ts"; -import { fitNavigationSides } from "../shared/below-editor-navigation.ts"; import { loadSetupConfig } from "../shared/setup-config.ts"; import { SPINNER_INTERVAL_MS } from "../shared/spinner.ts"; import { @@ -90,6 +90,14 @@ import { persistWorkflowJson, persistWorkflowTerminalState, } from "./artifacts.ts"; +import { + buildExpandedWorkflowCompletion, + buildWorkflowCompletionDisplay, + isWorkflowCompletionDisplay, + workflowCompletionAlerts, + workflowCompletionResultPreview, + workflowCompletionSummary, +} from "./completion-projection.ts"; import { RunController } from "./controller.ts"; import { resolveWorkflowLaunchPolicy, @@ -125,6 +133,7 @@ import { agentContext, aggregateUsage, appendLog, + compactWorkflowToolDetails, countStates, createUsageReader, emptyUsage, @@ -159,7 +168,7 @@ import { import { buildBackgroundWorkflowFollowUp, buildBackgroundWorkflowLaunchResult, - buildProjectedWorkflowCompletionBatch, + buildProjectedWorkflowCompletionBatches, buildProjectedWorkflowResultMessage, buildWorkflowAgentPrompt, buildWorkflowResultMessage, @@ -174,15 +183,15 @@ import { WORKFLOW_STOP_TOOL_DESCRIPTION, WORKFLOW_TOOL_DESCRIPTION, } from "./prompt.ts"; -import { - createWorkflowResultDelivery, - type WorkflowCompletionEnvelope, -} from "./result-delivery.ts"; import { beginProcessReplayWorkspaceLease, createReplayIdentity, isReplaySafeAgentCall, } from "./replay-safety.ts"; +import { + createWorkflowResultDelivery, + type WorkflowCompletionEnvelope, +} from "./result-delivery.ts"; import { createWorkflowResources, runAgent, @@ -191,7 +200,7 @@ import { type WorkflowModel, } from "./runner.ts"; import { runWorkflowSandbox } from "./sandbox.ts"; -import { safeStringify, writeFileAtomic } from "./serialization.ts"; +import { writeFileAtomic } from "./serialization.ts"; import { finalizeWorktreeHandoff, prepareWorktreeHandoff, @@ -647,21 +656,6 @@ function appendArtifactPersistenceFailure( ? `${details.error}; ${persistenceFailure}` : persistenceFailure; } - -function compactToolDetails(details: WorkflowDetails): WorkflowDetails { - return { - ...details, - ...(details.result !== undefined - ? { - result: JSON.parse( - safeStringify(details.result, { maxBytes: 64 * 1024 }), - ), - } - : {}), - agents: details.agents.map((agent) => ({ ...agent, transcript: [] })), - }; -} - export interface ActiveWorkflowRunLifecycle { details: WorkflowDetails; controller: Pick; @@ -834,27 +828,28 @@ export default function workflows(pi: ExtensionAPI) { details, ), deliver: async (envelopes, wake) => { - const content = buildProjectedWorkflowCompletionBatch( - envelopes.map((envelope) => ({ - deliveryId: envelope.deliveryId, - details: envelope.details, - runDir: path.join(getAgentDir(), "workflows", envelope.runId), - })), + const sourceEntries = envelopes.map((envelope) => ({ + deliveryId: envelope.deliveryId, + details: envelope.details, + runDir: path.join(getAgentDir(), "workflows", envelope.runId), + })); + const batches = buildProjectedWorkflowCompletionBatches( + sourceEntries, lastContext?.getContextUsage?.(), ); - pi.sendMessage( - { - customType: "workflow-result", - content, - display: true, - ...(envelopes.length === 1 - ? { details: compactToolDetails(envelopes[0]!.details) } - : {}), - }, - wake - ? { deliverAs: "followUp", triggerTurn: true } - : { deliverAs: "nextTurn" }, - ); + for (const batch of batches) { + pi.sendMessage( + { + customType: "workflow-result", + content: batch.content, + display: true, + details: buildWorkflowCompletionDisplay(batch.entries), + }, + wake + ? { deliverAs: "followUp", triggerTurn: true } + : { deliverAs: "nextTurn" }, + ); + } return envelopes.map((envelope) => ({ deliveryId: envelope.deliveryId, delivered: true, @@ -1295,7 +1290,7 @@ export default function workflows(pi: ExtensionAPI) { if (background) return; onUpdate?.({ content: [{ type: "text", text: summaryLine(details) }], - details: compactToolDetails(details), + details: compactWorkflowToolDetails(details), }); }; const emit = (checkpoint = true) => { @@ -1392,9 +1387,9 @@ export default function workflows(pi: ExtensionAPI) { // The script's narrator. Unlike phase(), this is append-only progress // text, so it never mutates the phase list a run is judged against. - const logFn = (text: string) => { + const logFn = (text: string, kind?: "pipeline-drop") => { if (runSettled) return; - appendLog(details, text, Date.now()); + appendLog(details, text, Date.now(), kind); emit(); }; @@ -2250,7 +2245,7 @@ export default function workflows(pi: ExtensionAPI) { }), }, ], - details: compactToolDetails(details), + details: compactWorkflowToolDetails(details), }; } @@ -2279,7 +2274,7 @@ export default function workflows(pi: ExtensionAPI) { ), }, ], - details: compactToolDetails(details), + details: compactWorkflowToolDetails(details), }; }, @@ -2485,7 +2480,6 @@ export default function workflows(pi: ExtensionAPI) { pi.registerMessageRenderer( "workflow-result", (message, { expanded }, theme) => { - const details = message.details as WorkflowDetails | undefined; const body = typeof message.content === "string" ? message.content @@ -2493,18 +2487,73 @@ export default function workflows(pi: ExtensionAPI) { ?.map((part) => (part.type === "text" ? part.text : "")) .join("") ?? ""); const safeBody = sanitizeWorkflowDisplayText(body); - if (!details) return new Text(safeBody, 0, 0); - const headerParts = runHeader(details, theme, Date.now()); - const header = headerParts.right - ? `${headerParts.left} ${headerParts.right}` - : headerParts.left; - if (expanded) return new Text(`${header}\n\n${safeBody}`, 0, 0); - const preview = safeBody.split("\n").slice(0, 8).join("\n"); - return new Text( - `${header}\n${preview}\n${theme.fg("muted", `(${keyHint("app.tools.expand", "to expand")})`)}`, - 0, - 0, - ); + const display = isWorkflowCompletionDisplay(message.details) + ? message.details + : undefined; + const legacyDetails = isWorkflowRenderDetails(message.details) + ? message.details + : undefined; + if (!display && !legacyDetails) { + return new Text(safeBody, 0, 0); + } + if (legacyDetails) { + const headerParts = runHeader(legacyDetails, theme, Date.now()); + const header = headerParts.right + ? `${headerParts.left} ${headerParts.right}` + : headerParts.left; + if (expanded) return new Text(`${header}\n\n${safeBody}`, 0, 0); + const preview = safeBody.split("\n").slice(0, 8).join("\n"); + return new Text( + `${header}\n${preview}\n${theme.fg("muted", `(${keyHint("app.tools.expand", "to expand")})`)}`, + 0, + 0, + ); + } + if (!display) return new Text(safeBody, 0, 0); + if (expanded) { + return new Text(buildExpandedWorkflowCompletion(display), 0, 0); + } + return { + render(width: number) { + const rows: string[] = []; + for (const entry of display.entries) { + rows.push( + truncateToWidth( + `${statusGlyph(entry.status, theme, Date.now())} ${workflowCompletionSummary(entry)}`, + width, + "…", + ), + ); + for (const alert of workflowCompletionAlerts(entry)) { + rows.push( + truncateToWidth(` ${theme.fg("error", alert)}`, width, "…"), + ); + } + const result = workflowCompletionResultPreview(entry); + if (result) { + rows.push( + truncateToWidth( + ` ${theme.fg("accent", "Result:")} ${result}`, + width, + "…", + ), + ); + } + } + rows.push( + truncateToWidth( + theme.fg( + "muted", + `(${keyHint("app.tools.expand", "to expand")})`, + ), + width, + "…", + ), + ); + return rows; + }, + invalidate() {}, + }; }, ); } diff --git a/extensions/workflows/model.ts b/extensions/workflows/model.ts index 2436fbb2..13550b38 100644 --- a/extensions/workflows/model.ts +++ b/extensions/workflows/model.ts @@ -5,8 +5,8 @@ import * as os from "node:os"; import { - truncateHead, type ExtensionContext, + truncateHead, } from "@earendil-works/pi-coding-agent"; import { formatContextUtilization } from "../shared/context-utilization.ts"; import { spinnerFrame } from "../shared/spinner.ts"; @@ -141,6 +141,8 @@ export interface AgentRecord { export interface WorkflowLogEntry { at: number; text: string; + /** Runtime-authored evidence, distinct from free-form script narration. */ + kind?: "pipeline-drop"; } export interface WorkflowDetails { @@ -174,6 +176,23 @@ export interface WorkflowDetails { error?: string; } +/** Bounded tool-result projection; authoritative details remain in run artifacts. */ +export function compactWorkflowToolDetails( + details: WorkflowDetails, +): WorkflowDetails { + return { + ...details, + ...(details.result !== undefined + ? { + result: JSON.parse( + safeStringify(details.result, { maxBytes: 64 * 1024 }), + ), + } + : {}), + agents: details.agents.map((agent) => ({ ...agent, transcript: [] })), + }; +} + /** * Bound only the current-session terminal projection. Persisted workflow * records and side artifacts remain the canonical history. @@ -335,11 +354,12 @@ export function appendLog( details: WorkflowDetails, text: string, at: number, + kind?: WorkflowLogEntry["kind"], ): void { const clean = sanitizeLine(text, MAX_LOG_TEXT); if (!clean) return; const logs = (details.logs ??= []); - logs.push({ at, text: clean }); + logs.push({ at, text: clean, ...(kind ? { kind } : {}) }); const excess = logs.length - MAX_LOG_ENTRIES; if (excess > 0) { logs.splice(0, excess); diff --git a/extensions/workflows/prompt.ts b/extensions/workflows/prompt.ts index 96629ffb..e6d40555 100644 --- a/extensions/workflows/prompt.ts +++ b/extensions/workflows/prompt.ts @@ -1,8 +1,8 @@ -import { sanitizeTerminalText } from "../shared/terminal-text.ts"; import { allocateResultBudgets, type ParentContextUsage, } from "../shared/result-budget.ts"; +import { sanitizeTerminalText } from "../shared/terminal-text.ts"; import { projectText } from "../shared/text-projection.ts"; import { countStates, @@ -214,6 +214,16 @@ export function buildProjectedWorkflowCompletionBatch( }[], usage?: ParentContextUsage | null, ) { + const deliveryFacts = JSON.stringify( + entries.map(({ deliveryId, details, runDir }) => ({ + deliveryId, + runId: details.runId, + evidence: shortenHome(runDir), + })), + ); + const manifest = + "Workflow completion delivery facts (stable across retries; deduplicate by deliveryId):\n" + + deliveryFacts; const full = entries.map(({ deliveryId, details, runDir }) => buildBackgroundWorkflowFollowUp({ runId: details.runId, @@ -223,18 +233,30 @@ export function buildProjectedWorkflowCompletionBatch( }), ); const separatorBytes = Math.max(0, entries.length - 1) * 2; + const manifestSeparatorBytes = entries.length > 0 ? 2 : 0; + const fixedBytes = + Buffer.byteLength(manifest, "utf8") + + separatorBytes + + manifestSeparatorBytes; + const bodyBudget = 48 * 1024 - fixedBytes; + if (bodyBudget < 0) { + throw new Error( + "Workflow completion delivery facts exceed the transport payload limit", + ); + } const allocation = allocateResultBudgets( full.map((message) => Buffer.byteLength(message, "utf8")), usage, { - maxBatchBytes: 48 * 1024 - separatorBytes, + maxBatchBytes: bodyBudget, maxResultBytes: 48 * 1024, minResultBytes: 1024, headroomShare: 0.25, estimatedBytesPerToken: 4, + fixedBytes, }, ); - return full + const projected = full .map((message, index) => projectText(message, { maxBytes: allocation.budgets[index] ?? 1024, @@ -243,6 +265,44 @@ export function buildProjectedWorkflowCompletionBatch( }), ) .join("\n\n"); + return projected ? `${manifest}\n\n${projected}` : manifest; +} + +/** Split transport batches so every manifest and projected body fit together. */ +export function buildProjectedWorkflowCompletionBatches( + entries: readonly { + deliveryId: string; + details: WorkflowDetails; + runDir: string; + }[], + usage?: ParentContextUsage | null, +) { + const batches: Array<{ + entries: (typeof entries)[number][]; + content: string; + }> = []; + let current: (typeof entries)[number][] = []; + for (const entry of entries) { + const candidate = [...current, entry]; + try { + buildProjectedWorkflowCompletionBatch(candidate, usage); + current = candidate; + } catch (error) { + if (current.length === 0) throw error; + batches.push({ + entries: current, + content: buildProjectedWorkflowCompletionBatch(current, usage), + }); + current = [entry]; + } + } + if (current.length > 0) { + batches.push({ + entries: current, + content: buildProjectedWorkflowCompletionBatch(current, usage), + }); + } + return batches; } /** Builds the background-launch result and tells the parent model how to inspect or stop the run. */ diff --git a/extensions/workflows/sandbox-child.cjs b/extensions/workflows/sandbox-child.cjs index 30124c66..4a933c40 100644 --- a/extensions/workflows/sandbox-child.cjs +++ b/extensions/workflows/sandbox-child.cjs @@ -164,7 +164,17 @@ const BOOTSTRAP = String.raw` } return value; } catch (error) { - log("pipeline: item " + index + " dropped — " + ((error && error.message) || String(error))); + callHost( + "log", + JSON.stringify({ + text: + "pipeline: item " + + index + + " dropped — " + + ((error && error.message) || String(error)), + kind: "pipeline-drop", + }), + ); return null; } }); diff --git a/extensions/workflows/sandbox.ts b/extensions/workflows/sandbox.ts index 5aa5bfd4..095603af 100644 --- a/extensions/workflows/sandbox.ts +++ b/extensions/workflows/sandbox.ts @@ -1,5 +1,5 @@ +import { type ChildProcess, spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { spawn, type ChildProcess } from "node:child_process"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { MAX_WORKFLOW_AGENT_CALLS } from "../shared/setup-config.ts"; @@ -53,7 +53,7 @@ export interface RunWorkflowSandboxOptions { signal: AbortSignal, ) => Promise; onPhase: (title: string) => void; - onLog: (text: string) => void; + onLog: (text: string, kind?: "pipeline-drop") => void; /** * Cumulative run usage, read at send time so the child's `usage()` reflects * the agent that just settled rather than a value captured at launch. @@ -287,7 +287,10 @@ export function runWorkflowSandbox(options: RunWorkflowSandboxOptions) { if (!isRecord(payload) || typeof payload.text !== "string") { throw new Error("invalid text"); } - options.onLog(payload.text); + if (payload.kind !== undefined && payload.kind !== "pipeline-drop") { + throw new Error("invalid log kind"); + } + options.onLog(payload.text, payload.kind); } catch { finish(new Error("Workflow sandbox sent an invalid log line")); } diff --git a/tests/extensions/workflows/dashboard.test.ts b/tests/extensions/workflows/dashboard.test.ts index 9e564fb7..c13b5d6b 100644 --- a/tests/extensions/workflows/dashboard.test.ts +++ b/tests/extensions/workflows/dashboard.test.ts @@ -16,11 +16,11 @@ import { type KeybindingsManager, } from "@earendil-works/pi-coding-agent"; import type { TUI } from "@earendil-works/pi-tui"; +import { SPINNER_INTERVAL_MS } from "../../../extensions/shared/spinner.ts"; import type { Theme, WorkflowDetails, } from "../../../extensions/workflows/model.ts"; -import { SPINNER_INTERVAL_MS } from "../../../extensions/shared/spinner.ts"; import { safeStringify } from "../../../extensions/workflows/serialization.ts"; // runsDir() resolves against getAgentDir(), which reads this env var. @@ -852,7 +852,7 @@ test("narrator lines survive the disk round trip and are re-sanitized", () => { phases: [], agents: [], logs: [ - { at: 1, text: "round 1: 3 found" }, + { at: 1, text: "round 1: 3 found", kind: "pipeline-drop" }, { at: 2, text: "round 2:\u001b[31m red\u001b[0m\nsecond row" }, { at: 3 }, "not an entry", @@ -865,6 +865,7 @@ test("narrator lines survive the disk round trip and are re-sanitized", () => { )?.details; assert.equal(details?.logs?.length, 2); assert.equal(details?.logs?.[0]?.text, "round 1: 3 found"); + assert.equal(details?.logs?.[0]?.kind, "pipeline-drop"); assert.ok( !/[\u0000-\u001f\u007f-\u009f]/.test(details?.logs?.[1]?.text ?? ""), ); diff --git a/tests/extensions/workflows/execute.e2e.test.ts b/tests/extensions/workflows/execute.e2e.test.ts index 8d521e67..e3e7eeaa 100644 --- a/tests/extensions/workflows/execute.e2e.test.ts +++ b/tests/extensions/workflows/execute.e2e.test.ts @@ -724,12 +724,20 @@ test("cancelled detached delivery preserves aborted status after artifact persis ); assert.ok(delivered); const deliveredDetails = delivered.message.details as { - status?: unknown; - error?: unknown; + entries?: Array<{ status?: unknown; alerts?: unknown[] }>; }; - assert.equal(deliveredDetails.status, "aborted"); - assert.match(String(deliveredDetails.error), /Workflow was aborted/); - assert.match(String(deliveredDetails.error), /Artifact persistence failed/); + const deliveredEntry = deliveredDetails.entries?.[0]; + assert.equal(deliveredEntry?.status, "aborted"); + assert.ok( + deliveredEntry?.alerts?.some((alert) => + String(alert).includes("Workflow was aborted"), + ), + ); + assert.ok( + deliveredEntry?.alerts?.some((alert) => + String(alert).includes("Artifact persistence failed"), + ), + ); } finally { releasePrompt(); __setWorkflowTestAgentSessionFactory(undefined); diff --git a/tests/extensions/workflows/narrator.test.ts b/tests/extensions/workflows/narrator.test.ts index 158ac804..763b7438 100644 --- a/tests/extensions/workflows/narrator.test.ts +++ b/tests/extensions/workflows/narrator.test.ts @@ -1,14 +1,14 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { + type AgentRecord, appendLog, + createUsageReader, MAX_LOG_ENTRIES, MAX_LOG_TEXT, sanitizeLine, sanitizeWorkflowDisplayLine, sanitizeWorkflowDisplayText, - createUsageReader, - type AgentRecord, type WorkflowDetails, } from "../../../extensions/workflows/model.ts"; import { runWorkflowSandbox } from "../../../extensions/workflows/sandbox.ts"; @@ -313,7 +313,7 @@ test("a stage that throws says why instead of leaving a bare null", async () => // Without this, a script bug, a deliberate skip, and a genuinely failed // agent are one indistinguishable null — and the "how many dropped" count // every script is told to report becomes a guess. - const logs: string[] = []; + const logs: Array<{ text: string; kind?: "pipeline-drop" }> = []; const result = await runSandbox( ` return await pipeline( @@ -322,12 +322,13 @@ test("a stage that throws says why instead of leaving a bare null", async () => (prev) => { if (prev === "b") throw new Error("guard rejected b"); return prev; }, ); `, - { onLog: (text) => logs.push(text) }, + { onLog: (text, kind) => logs.push({ text, kind }) }, ); assert.deepEqual(result, ["a", null]); assert.equal(logs.length, 1); - assert.match(logs[0] ?? "", /item 1 dropped/); - assert.match(logs[0] ?? "", /guard rejected b/); + assert.equal(logs[0]?.kind, "pipeline-drop"); + assert.match(logs[0]?.text ?? "", /item 1 dropped/); + assert.match(logs[0]?.text ?? "", /guard rejected b/); }); test("a stage mutating its own input array cannot manufacture nulls", async () => { diff --git a/tests/extensions/workflows/prompt.test.ts b/tests/extensions/workflows/prompt.test.ts index c930f41c..53f26a12 100644 --- a/tests/extensions/workflows/prompt.test.ts +++ b/tests/extensions/workflows/prompt.test.ts @@ -1,10 +1,16 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; +import { + type AgentRecord, + emptyUsage, + type WorkflowDetails, +} from "../../../extensions/workflows/model.ts"; import { buildBackgroundWorkflowFollowUp, buildBackgroundWorkflowLaunchResult, buildProjectedWorkflowCompletionBatch, + buildProjectedWorkflowCompletionBatches, buildProjectedWorkflowResultMessage, buildWorkflowResultMessage, buildWorkflowStatusSummary, @@ -13,11 +19,6 @@ import { WORKFLOW_STOP_TOOL_DESCRIPTION, WORKFLOW_TOOL_DESCRIPTION, } from "../../../extensions/workflows/prompt.ts"; -import { - emptyUsage, - type AgentRecord, - type WorkflowDetails, -} from "../../../extensions/workflows/model.ts"; test("background follow-up uses a sentence lead-in, not the old bracket form", () => { const msg = buildBackgroundWorkflowFollowUp({ @@ -75,9 +76,136 @@ test("completion batches share one bounded fair projection budget", () => { projected, new RegExp(`wf_${index.toString(16).padStart(4, "0")}`), ); + assert.match(projected, new RegExp(`delivery-${index}(?:"|\\b)`)); } }); +test("oversized completion batches split into bounded messages without losing delivery facts", () => { + const entries = Array.from({ length: 128 }, (_, index) => { + const details: WorkflowDetails = { + runId: `wf_large_${index.toString(16).padStart(4, "0")}`, + status: "completed", + background: true, + startedAt: 1, + finishedAt: 2, + phases: [], + agents: [], + result: { evidence: "x".repeat(8_000) }, + }; + return { + deliveryId: `delivery-large-${index}-${"d".repeat(600)}`, + details, + runDir: `/tmp/${details.runId}`, + }; + }); + + const batches = buildProjectedWorkflowCompletionBatches(entries, { + tokens: 10_000, + contextWindow: 100_000, + }); + + assert.ok(batches.length > 1); + for (const batch of batches) { + assert.ok(Buffer.byteLength(batch.content, "utf8") <= 48 * 1024); + } + for (const entry of entries) { + assert.equal( + batches.filter((batch) => + batch.content.includes(JSON.stringify(entry.deliveryId)), + ).length, + 1, + ); + } +}); + +test("completion batching keeps small and large deliveries grouped", () => { + const makeEntry = (index: number, evidenceLength = 200) => { + const details: WorkflowDetails = { + runId: `wf_grouped_${index}`, + status: "completed", + background: true, + startedAt: 1, + finishedAt: 2, + phases: [], + agents: [], + result: { evidence: "x".repeat(evidenceLength) }, + }; + return { + deliveryId: `delivery-grouped-${index}`, + details, + runDir: `/tmp/${details.runId}/${"r".repeat(600)}`, + }; + }; + + const smallEntries = [0, 1, 2].map((index) => makeEntry(index)); + const smallBatches = buildProjectedWorkflowCompletionBatches(smallEntries); + assert.equal(smallBatches.length, 1); + assert.deepEqual( + smallBatches[0]?.entries.map((entry) => entry.deliveryId), + smallEntries.map((entry) => entry.deliveryId), + ); + + const largeEntries = Array.from({ length: 128 }, (_, index) => + makeEntry(index, 8_000), + ); + const largeBatches = buildProjectedWorkflowCompletionBatches(largeEntries, { + tokens: 10_000, + contextWindow: 100_000, + }); + assert.ok(largeBatches.length < largeEntries.length / 2); + for (const entry of largeEntries) { + assert.equal( + largeBatches.filter((batch) => + batch.entries.some( + (candidate) => candidate.deliveryId === entry.deliveryId, + ), + ).length, + 1, + ); + } +}); + +test("model completion payload retains durable evidence independently of the renderer", () => { + const details: WorkflowDetails = { + runId: "wf_evidence", + name: "evidence", + status: "completed", + background: true, + startedAt: 0, + finishedAt: 1_000, + phases: [{ title: "inspect" }], + agents: [ + { + index: 1, + label: "reviewer", + state: "done", + startedAt: 0, + finishedAt: 1_000, + preview: "", + usage: emptyUsage(), + transcript: [], + }, + ], + logs: [{ at: 1, text: "durable diagnostic" }], + result: { verdict: "keep this result" }, + }; + const payload = buildProjectedWorkflowCompletionBatch([ + { + deliveryId: "workflow:wf_evidence:terminal", + details, + runDir: "/tmp/wf_evidence", + }, + ]); + assert.match(payload, /^Workflow completion delivery facts/); + assert.match(payload, /Background workflow "evidence"/); + assert.match(payload, /Run dir: \/tmp\/wf_evidence/); + assert.match(payload, /^Log:$/m); + assert.match(payload, /^Agents:$/m); + assert.match(payload, /keep this result/); + assert.match(payload, /Delivery id: workflow:wf_evidence:terminal/); + assert.match(payload, /duplicate|do not repeat it verbatim/i); +}); + test("uncertain agents are never described as settled failures", () => { const details: WorkflowDetails = { runId: "wf_uncertain", @@ -291,7 +419,7 @@ test("the resident workflow prompt stays compact while the Skill carries the ful new URL("../../../skills/workflows/EXAMPLES.md", import.meta.url), "utf8", ); - assert.match(skill, /^---\nname: workflows\n/); + assert.match(skill, /^---\r?\nname: workflows\r?\n/); assert.match(skill, /Use when .*multi-phase/i); assert.match(reference, /operator/); assert.match(reference, /acceptance/); diff --git a/tests/extensions/workflows/rendering.test.ts b/tests/extensions/workflows/rendering.test.ts index c185bd91..5c540fdb 100644 --- a/tests/extensions/workflows/rendering.test.ts +++ b/tests/extensions/workflows/rendering.test.ts @@ -1,16 +1,24 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - initTheme, type AgentToolResult, type ExtensionAPI, + initTheme, type MessageRenderer, type Theme, type ToolDefinition, } from "@earendil-works/pi-coding-agent"; +import { visibleWidth } from "@earendil-works/pi-tui"; import { SPINNER_INTERVAL_MS } from "../../../extensions/shared/spinner.ts"; +import { + buildWorkflowCompletionDisplay, + isWorkflowCompletionDisplay, +} from "../../../extensions/workflows/completion-projection.ts"; import workflows from "../../../extensions/workflows/index.ts"; -import type { WorkflowDetails } from "../../../extensions/workflows/model.ts"; +import { + emptyUsage, + type WorkflowDetails, +} from "../../../extensions/workflows/model.ts"; initTheme("dark", false); @@ -36,6 +44,35 @@ function runningWorkflow(): WorkflowDetails { }; } +function finishedWorkflow( + overrides: Partial = {}, +): WorkflowDetails { + return { + runId: "wf_finished", + name: "render-check", + status: "completed", + background: true, + startedAt: 0, + finishedAt: 293_000, + phases: [{ title: "audit" }], + agents: [ + { + index: 1, + label: "reviewer", + state: "done", + startedAt: 0, + finishedAt: 293_000, + preview: "", + usage: emptyUsage(), + transcript: [], + }, + ], + logs: [{ at: 1, text: "ordinary diagnostic log" }], + result: { verdict: "ship it" }, + ...overrides, + }; +} + function captureRenderers() { const tools = new Map(); const messages = new Map(); @@ -171,3 +208,265 @@ test("running workflow result messages let the glyph carry the state", () => { assert.match(rendered, /workflow render-check/); assert.doesNotMatch(rendered, /\brunning\b/); }); + +test("single completion keeps success diagnostics behind expansion", () => { + const { message } = captureRenderers(); + const details = finishedWorkflow(); + const display = buildWorkflowCompletionDisplay([ + { + deliveryId: "workflow:wf_finished:terminal", + details, + runDir: "/tmp/wf_finished", + }, + ]); + assert.equal(display.runId, details.runId); + const component = message( + { + role: "custom", + customType: "workflow-result", + content: + 'Background workflow "render-check" (wf_finished) finished.\n\nfull model payload', + display: true, + details: display, + timestamp: Date.now(), + }, + { expanded: false, outputPad: 0 }, + theme, + ); + assert.ok(component); + const collapsed = component.render(120).join("\n"); + assert.equal((collapsed.match(/render-check/g) ?? []).length, 1); + assert.equal((collapsed.match(/1\/1 agents/g) ?? []).length, 1); + assert.equal((collapsed.match(/4m53s/g) ?? []).length, 1); + assert.match(collapsed, /Result:.*ship it/); + assert.doesNotMatch( + collapsed, + /Background workflow|Run dir:|Log:|Agents:|Delivery id:/, + ); + assert.doesNotMatch(collapsed, /ordinary diagnostic log/); + + const expanded = message( + { + role: "custom", + customType: "workflow-result", + content: "model-only transport wrapper", + display: true, + details: buildWorkflowCompletionDisplay([ + { + deliveryId: "workflow:wf_finished:terminal", + details, + runDir: "/tmp/wf_finished", + }, + ]), + timestamp: Date.now(), + }, + { expanded: true, outputPad: 0 }, + theme, + ); + assert.ok(expanded); + const full = expanded.render(120).join("\n"); + assert.match(full, /Run dir: \/tmp\/wf_finished/); + assert.match(full, /^Log: *$/m); + assert.match(full, /^Agents: *$/m); + assert.match(full, /Delivery id: workflow:wf_finished:terminal/); + assert.doesNotMatch(full, /model-only transport wrapper|Background workflow/); +}); + +test("malformed completion display fails closed to sanitized message content", () => { + const { message } = captureRenderers(); + const body = "fallback body\u001b]52;c;clipboard\u0007"; + const component = message( + { + role: "custom", + customType: "workflow-result", + content: body, + display: true, + details: { + version: 1, + entries: [ + { + deliveryId: "delivery-bad", + runDir: "/tmp/wf_bad", + details: { runId: "wf_bad", agents: [null] }, + }, + ], + }, + timestamp: Date.now(), + }, + { expanded: false, outputPad: 0 }, + theme, + ); + assert.ok(component); + const rendered = component.render(100).join("\n"); + assert.equal(rendered.trimEnd(), "fallback body"); + assert.doesNotMatch(rendered, /[\u001b\u0007]/); +}); + +test("completion display is a bounded operator projection, not runtime state", () => { + const entries = Array.from({ length: 64 }, (_, runIndex) => { + const runId = `wf_${runIndex.toString(16).padStart(4, "0")}`; + return { + deliveryId: `delivery-${runIndex}-${"d".repeat(1_000)}`, + runDir: `/tmp/${runId}`, + details: finishedWorkflow({ + runId, + name: `batch-${runIndex}-${"n".repeat(1_000)}`, + agents: Array.from({ length: 128 }, (_, agentIndex) => ({ + index: agentIndex + 1, + label: `agent-${agentIndex}-${"a".repeat(500)}`, + state: agentIndex % 17 === 0 ? ("error" as const) : ("done" as const), + startedAt: 0, + finishedAt: 293_000, + error: agentIndex % 17 === 0 ? "failed" : undefined, + preview: "p".repeat(2_000), + usage: emptyUsage(), + transcript: [ + { role: "assistant" as const, text: "t".repeat(10_000) }, + ], + })), + logs: Array.from({ length: 100 }, (_, index) => ({ + at: index, + text: `log-${index}-${"l".repeat(300)}`, + })), + result: { evidence: "r".repeat(100_000) }, + }), + }; + }); + const display = buildWorkflowCompletionDisplay(entries); + const encoded = JSON.stringify(display); + assert.ok(Buffer.byteLength(encoded, "utf8") <= 64 * 1024); + assert.equal(isWorkflowCompletionDisplay(display), true); + assert.doesNotMatch(encoded, /"details"|"agents"|"logs"|"transcript"/); + assert.ok(display.entries.every((entry) => entry.expanded.length > 0)); + assert.equal( + isWorkflowCompletionDisplay({ + ...display, + entries: display.entries.map((entry) => ({ + ...entry, + details: { agents: [null] }, + })), + }), + false, + ); +}); + +test("batch completion foregrounds abnormal evidence within width", () => { + const { message } = captureRenderers(); + const failed = finishedWorkflow({ + runId: "wf_failed", + name: "failed\u001b]52;c;clipboard\u0007", + status: "failed", + error: "top-level failure", + logs: [ + { at: 1, text: "ordinary log" }, + { at: 2, text: "coverage complete: no items dropped" }, + { + at: 3, + text: "pipeline: item 2 dropped — stage failed", + kind: "pipeline-drop", + }, + { at: 4, text: "项目被丢弃:上游结果为空" }, + ], + logsDropped: 3, + agents: [ + { + index: 1, + label: "owner-lost", + state: "uncertain", + startedAt: 0, + finishedAt: 293_000, + preview: "", + usage: emptyUsage(), + transcript: [], + }, + { + index: 2, + label: "writer", + state: "error", + startedAt: 0, + finishedAt: 293_000, + error: "failed", + preview: "", + usage: emptyUsage(), + worktreePath: "/repo/.git/pi-worktrees/writer", + worktreeHandoffArtifact: "worktrees/writer.json", + worktreeCleanup: { + removed: false, + branchDeleted: false, + branch: "pi/writer", + detached: false, + reason: "uncommitted changes", + }, + transcript: [], + }, + ], + }); + const completed = finishedWorkflow({ + runId: "wf_ok", + name: "ok-run", + startedAt: 292_000, + finishedAt: 293_000, + agents: [ + { + index: 1, + label: "writer-ok", + state: "done", + startedAt: 292_000, + finishedAt: 293_000, + preview: "", + usage: emptyUsage(), + worktreeBranch: "pi/writer-ok", + worktreeHandoffArtifact: "worktrees/writer-ok.json", + worktreeCleanup: { + removed: true, + branchDeleted: false, + branch: "pi/writer-ok", + detached: false, + commits: 1, + }, + transcript: [], + }, + ], + }); + const component = message( + { + role: "custom", + customType: "workflow-result", + content: "full batch model payload", + display: true, + details: buildWorkflowCompletionDisplay([ + { deliveryId: "delivery-failed", details: failed, runDir: "/tmp/f" }, + { deliveryId: "delivery-ok", details: completed, runDir: "/tmp/o" }, + ]), + timestamp: Date.now(), + }, + { expanded: false, outputPad: 0 }, + theme, + ); + assert.ok(component); + const rows = component.render(56); + const collapsed = rows.join("\n"); + assert.match(collapsed, /workflow failed/); + assert.match(collapsed, /Failed agents: writer/); + assert.match(collapsed, /Uncertain agents: owner-lost/); + assert.match(collapsed, /3 earlier log line\(s\) dropped/); + assert.match(collapsed, /Dropped work: pipeline: item 2 dropped/); + assert.match(collapsed, /Dropped work: 项目被丢弃/); + assert.doesNotMatch(collapsed, /Dropped work: coverage complete/); + assert.match(collapsed, /Retained worktree \[writer\]/); + assert.match(collapsed, /workflow ok-run/); + assert.match(collapsed, /Worktree handoff \[writer-ok\]/); + assert.doesNotMatch(collapsed, /ordinary log|Run dir:|Delivery id:/); + assert.doesNotMatch(collapsed, /\]52|\u0007/); + assert.ok(rows.every((row) => visibleWidth(row) <= 56)); + assert.match( + component.render(120).join("\n"), + /Worktree handoff \[writer-ok\]: 1 commit on pi\/writer-ok; worktrees\/writer-ok.json/, + ); + for (const width of [1, 2, 3, 4, 8, 12]) { + assert.ok( + component.render(width).every((row) => visibleWidth(row) <= width), + `width ${width}`, + ); + } +});