diff --git a/.changeset/fn-8064-proactive-chat.md b/.changeset/fn-8064-proactive-chat.md new file mode 100644 index 0000000000..74cb6e39df --- /dev/null +++ b/.changeset/fn-8064-proactive-chat.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Task-detail chat now proactively narrates step progress, failures, and review outcomes in real time. +category: feature +dev: Adds bounded, secret-redacted engine status narration across step-session, default execution, graph review, and legacy review paths. diff --git a/docs/architecture.md b/docs/architecture.md index 8f1f89830b..c2f14cfe96 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -746,6 +746,7 @@ Guardrails: this routine does **not** retry merges, does **not** apply to mixed/ ### Observability and reflection - `AgentLogger` (`agent-logger.ts`) — structured per-agent run logging + - FN-8064 writes best-effort proactive `status` rows to the task-detail chat for step start, completion, intentional skips, and failure in both step-session/graph callbacks and store-accepted default `fn_task_update` transitions. Graph and legacy review paths each write one reviewer row: plan/spec APPROVE uses the plan-verified message; other real verdicts use the verdict message; `UNAVAILABLE` and reviewer exceptions write a safe operational "review could not complete" status without claiming a verdict. Failure and review-summary diagnostics are nullish-safe, secret-redacted, absolute-path/stack/newline stripped, and capped at 300 characters, so narration never blocks execution or persists raw command output. - `RunAudit` (`run-audit.ts`) — mutation audit tracking (DB/git/filesystem) - FN-7214: `task:reenter-paused-aborted-workflow-node` records executor re-entry after a typed workflow graph node was interrupted by engine pause/resume. Metadata includes `nodeId`, `fromColumn`, retry `attempt`/`maxAttempts`, `abortProvenance`, whether the task was preserved in `in-review`, and the re-entry `mode`. - FN-7220: `task:classify-stale-in-review-plan-pause-abort-replay` records executor classification of a stale generic `in-review` plan-node pause/resume replay. Metadata includes `nodeId`, `fromColumn`, `abortProvenance`, whether a stale failure was cleared, `graphResumeRetryCount`, and `mode: "preserved-in-review"`. diff --git a/packages/dashboard/app/components/TaskChatTab.css b/packages/dashboard/app/components/TaskChatTab.css index 9f2efe2d0a..ba41617002 100644 --- a/packages/dashboard/app/components/TaskChatTab.css +++ b/packages/dashboard/app/components/TaskChatTab.css @@ -220,6 +220,22 @@ FN-7231 requires task-detail chat text, user, tool, and thinking blocks to keep overflow-wrap: anywhere; } +/* +FNXC:ProactiveChatStatus 2026-07-16-12:05: +FN-8064 makes engine status rows a real-time task-progress report. Give complete status +messages a tokenized warning-toned distinction and reuse the shared status-dot convention; +the existing compact padding rule keeps the affordance usable below the mobile breakpoint. +*/ +.task-chat-entry--status { + border-color: color-mix(in srgb, var(--color-warning) 45%, var(--border)); + background: color-mix(in srgb, var(--color-warning) 10%, var(--surface)); +} + +.task-chat-entry--status .task-chat-entry-label-row { + justify-content: flex-start; + gap: var(--space-xs); +} + .task-chat-entry--user { max-width: min(100%, calc(var(--space-2xl) * 18)); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx index 225b13adfa..6295b7404a 100644 --- a/packages/dashboard/app/components/TaskChatTab.tsx +++ b/packages/dashboard/app/components/TaskChatTab.tsx @@ -405,7 +405,14 @@ function TaskChatText({ entries }: { entries: AgentLogEntry[] }) { className={`task-chat-entry task-chat-entry--${firstEntry.type.replace("_", "-")}`} data-testid={`task-chat-entry-${firstEntry.type}`} > - + {firstEntry.type === "status" && ( +
+
+ )} + {firstEntry.type !== "status" && }
{entries.map((entry) => entry.text).join("")} diff --git a/packages/engine/src/__tests__/executor-prompt.test.ts b/packages/engine/src/__tests__/executor-prompt.test.ts index f2edb8e254..f49f689a46 100644 --- a/packages/engine/src/__tests__/executor-prompt.test.ts +++ b/packages/engine/src/__tests__/executor-prompt.test.ts @@ -2613,14 +2613,13 @@ describe("fn_task_update bare-call guard (P1 api-contract)", () => { // createTaskUpdateTool is a private executor method; the bare-call guard runs // before any store access, so we reach it via the lowest-cost seam: construct // a TaskExecutor over a mock store and invoke the private method with `as any`. - function makeTool() { - const store = createMockStore(); + function makeTool(store = createMockStore()) { const executor = new TaskExecutor(store, "/tmp/test"); - return (executor as any).createTaskUpdateTool("FN-001", new Map(), { current: null }, new Map()); + return { store, tool: (executor as any).createTaskUpdateTool("FN-001", new Map(), { current: null }, new Map()) }; } it("returns isError with a self-describing message when no fields are supplied", async () => { - const tool = makeTool(); + const { tool } = makeTool(); const result = await tool.execute("call-1", {}); expect(result.isError).toBe(true); const text = result.content[0]?.type === "text" ? result.content[0].text : ""; @@ -2630,13 +2629,32 @@ describe("fn_task_update bare-call guard (P1 api-contract)", () => { }); it("does not trigger the guard when a dependencies-only patch is supplied", async () => { - const tool = makeTool(); + const { tool } = makeTool(); const result = await tool.execute("call-1", { dependencies: [] }); // Reaches the dependencies path, not the bare-call guard. expect(result.isError).not.toBe(true); const text = result.content[0]?.type === "text" ? result.content[0].text : ""; expect(text).not.toContain("fn_task_update requires at least one of"); }); + + it("narrates a store-accepted skipped transition exactly once", async () => { + const { store, tool } = makeTool(); + store.updateStep.mockResolvedValue(createMockTaskDetail({ + steps: [{ name: "No code change needed", status: "skipped", dependsOn: [] }], + })); + + const result = await tool.execute("call-1", { step: 0, status: "skipped" }); + + expect(result.isError).not.toBe(true); + expect(store.appendAgentLog).toHaveBeenCalledTimes(1); + expect(store.appendAgentLog).toHaveBeenCalledWith( + "FN-001", + "Step 0 was skipped — No code change needed.", + "status", + undefined, + "executor", + ); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/engine/src/__tests__/proactive-status.test.ts b/packages/engine/src/__tests__/proactive-status.test.ts new file mode 100644 index 0000000000..bed478c56f --- /dev/null +++ b/packages/engine/src/__tests__/proactive-status.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildPlanVerifiedMessage, + buildReviewRollbackFailureMessage, + buildReviewUnavailableMessage, + buildReviewVerdictMessage, + buildStepFailureMessage, + buildStepSkippedMessage, + buildStepStartMessage, + buildStepSuccessMessage, + emitProactiveStatus, + sanitizeFailureReason, +} from "../proactive-status.js"; + +describe("proactive status narration", () => { + it("sanitizes nullish, stack, path, token and multiline diagnostics", () => { + const raw = "Error: token=super-secret\n at run (/Users/a/project/file.ts:1:2)\nBearer abcdefghijklmnopqrstuvwxyz\n" + "output\n".repeat(200); + const safe = sanitizeFailureReason(raw); + expect(safe).toContain("[REDACTED]"); + expect(safe).not.toContain("/Users/a"); + expect(safe).not.toContain("\n"); + expect(safe.length).toBeLessThanOrEqual(300); + expect(sanitizeFailureReason(undefined)).toBe("No failure reason was provided."); + expect(sanitizeFailureReason(null)).toBe("No failure reason was provided."); + expect(sanitizeFailureReason({ toString: () => { throw new Error("nope"); } })).toBe("No failure reason was provided."); + }); + + it("elides absolute paths outside the common home-directory roots", () => { + const safe = sanitizeFailureReason("Command failed reading /etc/fusion/secrets.env from /srv/fusion/current/config.yaml"); + expect(safe).toBe("Command failed reading [path] from [path]"); + expect(safe).not.toContain("/etc/"); + expect(safe).not.toContain("/srv/"); + }); + + it("builds complete status messages and safely reports unavailable reviews", () => { + expect(buildStepStartMessage(2, "Ship it")).toBe("Starting Step 2: Ship it"); + expect(buildStepSuccessMessage(2, "Ship it")).toBe("Step 2 finished — Ship it."); + expect(buildStepSkippedMessage(2, "No code change needed")).toBe("Step 2 was skipped — No code change needed."); + expect(buildStepSkippedMessage(2, "Step 2")).toBe("Step 2 was skipped."); + expect(buildStepFailureMessage(2, "Ship it", sanitizeFailureReason(undefined))).toContain("No failure reason"); + expect(buildPlanVerifiedMessage()).toBe("The plan was written and verified."); + expect(buildReviewVerdictMessage("UNAVAILABLE", "nope")).toBeNull(); + expect(buildReviewUnavailableMessage("token=secret /etc/fusion/secrets.env")).toBe("Review could not complete: token=[REDACTED] [path]"); + expect(buildReviewVerdictMessage("RETHINK", "token=secret /home/private/file")).toMatch(/\[REDACTED\]/); + expect(buildReviewRollbackFailureMessage(sanitizeFailureReason("/Users/me/private.ts"))).toBe("Review could not roll the step back: [path]"); + }); + + it("persists status narration best-effort", async () => { + const appendAgentLog = vi.fn().mockResolvedValue(undefined); + await emitProactiveStatus({ appendAgentLog } as never, "FN-1", "Finished", "executor", "safe detail"); + expect(appendAgentLog).toHaveBeenCalledWith("FN-1", "Finished", "status", "safe detail", "executor"); + await expect(emitProactiveStatus({ appendAgentLog: vi.fn().mockRejectedValue(new Error("down")) } as never, "FN-1", "Finished", "reviewer")).resolves.toBeUndefined(); + }); +}); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 371557ffbf..9b3a8e07d0 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -62,6 +62,18 @@ import { createWorkflowRuntimePrimitiveProvider } from "./workflow-runtime-primi import { WorkflowCustomNodeExecutionService } from "./workflow-custom-node-execution.js"; import { WorkflowReviewService } from "./workflow-review-service.js"; import { WorkflowPlanningService } from "./workflow-planning-service.js"; +import { + buildPlanVerifiedMessage, + buildReviewUnavailableMessage, + buildReviewRollbackFailureMessage, + buildReviewVerdictMessage, + buildStepFailureMessage, + buildStepSkippedMessage, + buildStepStartMessage, + buildStepSuccessMessage, + emitProactiveStatus, + sanitizeFailureReason, +} from "./proactive-status.js"; import { ApprovalRequestStore, buildExecutionMemoryInstructions, @@ -5035,7 +5047,14 @@ export class TaskExecutor { * (the read path threads the same instanceId through `runGraphTaskStep`). */ private graphStepActiveContext = new Map(); - /** Composite key for {@link graphStepActiveContext}: per-instance, not per-task. */ + /** + * FNXC:ProactiveChatStatus 2026-07-16-12:30: + * Keep a graph RETHINK summary until its rework reset succeeds. The status wording says the step + * was rolled back, so it must not reach the task chat before resetStepToBaseline completes. + */ + private graphRethinkNarrations = new Map(); + + /** Composite key for graph-owned per-instance state: never share parallel foreach instances. */ private graphActiveContextKey(taskId: string, instanceId: string): string { return `${taskId}:${instanceId}`; } @@ -5437,6 +5456,9 @@ export class TaskExecutor { for (const key of this.graphStepActiveContext.keys()) { if (key.startsWith(ctxPrefix)) this.graphStepActiveContext.delete(key); } + for (const key of this.graphRethinkNarrations.keys()) { + if (key.startsWith(ctxPrefix)) this.graphRethinkNarrations.delete(key); + } } } @@ -5911,30 +5933,50 @@ export class TaskExecutor { } } const liveSteps = await this.store.getTask(taskId).then((t) => t.steps).catch(() => []); - await resetStepToBaseline( - { - store: this.store, - worktreePath, - // No single session ref for graph-owned step-sessions — rewind is skipped - // when checkpointId resolves but no session is current (KTD-2 partial path). - sessionRef: { current: null }, - reviewType: "code", - // Branch-scoped RETHINK under worktree isolation makes the guard structural - // (the reset can only touch the instance's own branch); shared isolation - // keeps the defensive ancestry guard (KTD-2/KTD-11). - blastRadiusGuard: branchScoped - ? undefined - : makeAncestryBlastRadiusGuard({ - worktreePath, - task: { id: taskId, steps: liveSteps }, - stepIndex: active.stepIndex, - }), - }, - { id: taskId, steps: liveSteps }, - active.stepIndex, - active.baselineSha, - active.checkpointId, - ); + const narrationKey = this.graphActiveContextKey(taskId, active.instanceId); + const reviewSummary = this.graphRethinkNarrations.get(narrationKey); + try { + await resetStepToBaseline( + { + store: this.store, + worktreePath, + // No single session ref for graph-owned step-sessions — rewind is skipped + // when checkpointId resolves but no session is current (KTD-2 partial path). + sessionRef: { current: null }, + reviewType: "code", + // Branch-scoped RETHINK under worktree isolation makes the guard structural + // (the reset can only touch the instance's own branch); shared isolation + // keeps the defensive ancestry guard (KTD-2/KTD-11). + blastRadiusGuard: branchScoped + ? undefined + : makeAncestryBlastRadiusGuard({ + worktreePath, + task: { id: taskId, steps: liveSteps }, + stepIndex: active.stepIndex, + }), + }, + { id: taskId, steps: liveSteps }, + active.stepIndex, + active.baselineSha, + active.checkpointId, + ); + if (reviewSummary !== undefined) { + const narration = buildReviewVerdictMessage("RETHINK", reviewSummary); + void emitProactiveStatus(this.store, taskId, narration, "reviewer", sanitizeFailureReason(reviewSummary)); + } + } catch (error) { + const safeReason = sanitizeFailureReason(error); + void emitProactiveStatus( + this.store, + taskId, + buildReviewRollbackFailureMessage(safeReason), + "reviewer", + safeReason, + ); + throw error; + } finally { + this.graphRethinkNarrations.delete(narrationKey); + } } /** @@ -7063,6 +7105,8 @@ export class TaskExecutor { } catch (err) { const message = err instanceof Error ? err.message : String(err); reviewerLog.error(`${seamTask.id}: step-review failed: ${message}`); + const narration = buildReviewUnavailableMessage(err); + void emitProactiveStatus(this.store, seamTask.id, narration, "reviewer", sanitizeFailureReason(err)); return { verdict: "UNAVAILABLE", review: `reviewer error: ${message}` }; } @@ -7071,6 +7115,17 @@ export class TaskExecutor { `${config.type} step-review Step ${stepIndex}: ${review.verdict}${config.advisory ? " (advisory)" : ""}`, review.summary, ); + const narration = config.type === "plan" && review.verdict === "APPROVE" + ? buildPlanVerifiedMessage() + : review.verdict === "UNAVAILABLE" + ? buildReviewUnavailableMessage(review.summary) + : buildReviewVerdictMessage(review.verdict, review.summary); + if (review.verdict === "RETHINK") { + // RETHINK's rollback claim is emitted by applyGraphRethinkReset only after reset succeeds. + this.graphRethinkNarrations.set(this.graphActiveContextKey(seamTask.id, active.instanceId), review.summary); + } else { + void emitProactiveStatus(this.store, seamTask.id, narration, "reviewer", narration ? sanitizeFailureReason(review.summary) : undefined); + } // Single-writer rule (KTD-4): advisory (split-branch) reviews never write // the projection — they are fan-out checks that cannot clobber the @@ -10465,6 +10520,7 @@ export class TaskExecutor { this.store.updateStep(task.id, stepIndex, "in-progress", stepProjectionOptions).catch((err) => { executorLog.warn(`${task.id}: failed to update step ${stepIndex} status to in-progress: ${err}`); }); + void emitProactiveStatus(this.store, task.id, buildStepStartMessage(stepIndex, detail.steps[stepIndex]?.name), "executor"); } catch (err) { executorLog.warn(`${task.id}: failed to update step ${stepIndex} status to in-progress: ${err}`); } @@ -10475,6 +10531,12 @@ export class TaskExecutor { this.store.updateStep(task.id, stepIndex, result.success ? "done" : "skipped", stepProjectionOptions).catch((err) => { executorLog.warn(`${task.id}: failed to update step ${stepIndex} status: ${err}`); }); + const stepName = detail.steps[stepIndex]?.name; + const safeReason = result.success ? undefined : sanitizeFailureReason(result.error); + const message = result.success + ? buildStepSuccessMessage(stepIndex, stepName) + : buildStepFailureMessage(stepIndex, stepName, safeReason!); + void emitProactiveStatus(this.store, task.id, message, "executor", safeReason); } catch (err) { executorLog.warn(`${task.id}: failed to update step ${stepIndex} status: ${err}`); } @@ -13211,6 +13273,18 @@ export class TaskExecutor { }; } + // FNXC:ProactiveChatStatus 2026-07-16-12:45: + // Only store-accepted transitions narrate progress. A skipped step is terminal work too, + // so it needs its own status row rather than silently looking like an ignored no-op. + const narration = status === "in-progress" + ? buildStepStartMessage(stepIndex, stepInfo.name) + : status === "done" + ? buildStepSuccessMessage(stepIndex, stepInfo.name) + : status === "skipped" + ? buildStepSkippedMessage(stepIndex, stepInfo.name) + : null; + void emitProactiveStatus(this.store, taskId, narration, "executor"); + return { content: [{ type: "text" as const, @@ -14367,6 +14441,7 @@ export class TaskExecutor { ); } + let rollbackFailureNarrated = false; try { // Merge per-task effective workflow settings (U3, KTD-3) so the // validator model-lane reads below pick up workflow values; this tool @@ -14457,6 +14532,15 @@ export class TaskExecutor { result.summary, ); reviewerLog.log(`${taskId}: Step ${step} ${reviewType} → ${result.verdict}`); + const narration = (reviewType === "plan" || reviewType === ("spec" as typeof reviewType)) && result.verdict === "APPROVE" + ? buildPlanVerifiedMessage() + : result.verdict === "UNAVAILABLE" + ? buildReviewUnavailableMessage(result.summary) + : buildReviewVerdictMessage(result.verdict, result.summary); + // A RETHINK message asserts rollback completion, so wait for resetStepToBaseline below. + if (result.verdict !== "RETHINK") { + void emitProactiveStatus(store, taskId, narration, "reviewer", narration ? sanitizeFailureReason(result.summary) : undefined); + } stuckDetector?.recordProgress(taskId); // Track code review verdicts for enforcement. Plan reviews remain @@ -14515,19 +14599,33 @@ export class TaskExecutor { // agent-supplied baseline (KTD-2 — the guard is for graph-owned // shared-isolation resets), so behavior stays byte-identical. const checkpointId = stepCheckpoints.get(stepIndex); - await resetStepToBaseline( - { + try { + await resetStepToBaseline( + { + store, + worktreePath, + sessionRef, + reviewType: reviewType === "plan" ? "plan" : "code", + summary: result.summary, + }, + { id: taskId, steps: taskSteps }, + stepIndex, + reviewType === "code" ? baseline : undefined, + checkpointId, + ); + } catch (error) { + const safeReason = sanitizeFailureReason(error); + void emitProactiveStatus( store, - worktreePath, - sessionRef, - reviewType: reviewType === "plan" ? "plan" : "code", - summary: result.summary, - }, - { id: taskId, steps: taskSteps }, - stepIndex, - reviewType === "code" ? baseline : undefined, - checkpointId, - ); + taskId, + buildReviewRollbackFailureMessage(safeReason), + "reviewer", + safeReason, + ); + rollbackFailureNarrated = true; + throw error; + } + void emitProactiveStatus(store, taskId, narration, "reviewer", sanitizeFailureReason(result.summary)); if (reviewType === "plan") { text = `RETHINK\n\nYour plan was rejected. Here is why:\n\n${result.review}\n\nTake a different approach to planning this step. Do NOT repeat the rejected strategy.`; @@ -14573,6 +14671,10 @@ export class TaskExecutor { const errorMessage = err instanceof Error ? err.message : String(err); reviewerLog.error(`${taskId}: review failed: ${errorMessage}`); await store.logEntry(taskId, `${reviewType} review failed: ${errorMessage}`); + if (!rollbackFailureNarrated) { + const narration = buildReviewUnavailableMessage(err); + void emitProactiveStatus(store, taskId, narration, "reviewer", sanitizeFailureReason(err)); + } /* FNXC:ReviewerProviderErrors 2026-07-15-11:20: diff --git a/packages/engine/src/proactive-status.ts b/packages/engine/src/proactive-status.ts new file mode 100644 index 0000000000..bce9473c44 --- /dev/null +++ b/packages/engine/src/proactive-status.ts @@ -0,0 +1,117 @@ +import { redactSecrets, type AgentRole, type TaskStore } from "@fusion/core"; +import type { ReviewVerdict } from "./reviewer.js"; + +const GENERIC_FAILURE_REASON = "No failure reason was provided."; +const MAX_REASON_LENGTH = 300; + +/** + * FNXC:ProactiveChatStatus 2026-07-16-12:00: + * Issue #2153 requires the task-detail chat to narrate step start, success, intentional skips, safe + * failure reasons, and review/rollback outcomes as standalone status rows, making it a real-time progress report. + * Both step-session callbacks and the default fn_task_update seam use this shared wording. Failure + * and review-summary diagnostics are nullish-safe, redacted, path/stack stripped, single-line, and + * capped at 300 characters; UNAVAILABLE is not a verdict but is narrated as a safe operational failure. + */ +export function sanitizeFailureReason(rawError: unknown): string { + let candidate: string; + try { + if (rawError === null || rawError === undefined) return GENERIC_FAILURE_REASON; + if (rawError instanceof Error) candidate = rawError.message || String(rawError); + else if (typeof rawError === "string") candidate = rawError; + else candidate = String(rawError); + } catch { + return GENERIC_FAILURE_REASON; + } + if (!candidate.trim()) return GENERIC_FAILURE_REASON; + + let sanitized = redactSecrets(candidate) + // Remove conventional JavaScript stack frames before general whitespace collapse. + .replace(/\s*at\s+[^\n]+\([^\n]*:\d+:\d+\)/g, " ") + .replace(/\s*at\s+[^\n]+:\d+:\d+/g, " ") + // Any absolute filesystem path is environment-sensitive, including system/service roots. + .replace(/(?:[A-Za-z]:\\|\/)[^\s:),]+/g, "[path]") + .replace(/[\r\n\t]+/g, " ") + .replace(/\s{2,}/g, " ") + .trim(); + + if (!sanitized) return GENERIC_FAILURE_REASON; + if (sanitized.length > MAX_REASON_LENGTH) sanitized = `${sanitized.slice(0, MAX_REASON_LENGTH - 1).trimEnd()}…`; + return sanitized; +} + +function stepLabel(stepIndex: number, stepName?: string): string { + const fallback = `Step ${stepIndex}`; + return stepName?.trim() || fallback; +} + +export function buildStepStartMessage(stepIndex: number, stepName?: string): string { + return `Starting Step ${stepIndex}: ${stepLabel(stepIndex, stepName)}`; +} + +export function buildStepSuccessMessage(stepIndex: number, stepName?: string): string { + const name = stepName?.trim(); + return name && name !== `Step ${stepIndex}` ? `Step ${stepIndex} finished — ${name}.` : `Step ${stepIndex} finished.`; +} + +/** + * FNXC:ProactiveChatStatus 2026-07-16-12:45: + * Store-accepted skipped transitions are terminal task outcomes too. Narrate them distinctly so + * preflight and intentional no-op flows remain visible without fabricating a failure reason. + */ +export function buildStepSkippedMessage(stepIndex: number, stepName?: string): string { + const name = stepName?.trim(); + return name && name !== `Step ${stepIndex}` ? `Step ${stepIndex} was skipped — ${name}.` : `Step ${stepIndex} was skipped.`; +} + +export function buildStepFailureMessage(stepIndex: number, stepName: string | undefined, safeReason: string): string { + const prefix = stepName?.trim() && stepName.trim() !== `Step ${stepIndex}` ? `Step ${stepIndex} (${stepName.trim()})` : `Step ${stepIndex}`; + return `${prefix} did not complete: ${safeReason}`; +} + +export function buildReviewVerdictMessage(verdict: ReviewVerdict, summary: unknown): string | null { + const safeSummary = sanitizeFailureReason(summary); + switch (verdict) { + case "APPROVE": return `Review passed — ${safeSummary}`; + case "REVISE": return `Review requested changes: ${safeSummary}`; + case "RETHINK": return `Review rolled the step back to rethink the approach: ${safeSummary}`; + case "UNAVAILABLE": return null; + } +} + +export function buildPlanVerifiedMessage(): string { + return "The plan was written and verified."; +} + +/** + * FNXC:ProactiveChatStatus 2026-07-16-13:10: + * A reviewer outage is progress-relevant even though UNAVAILABLE is not a verdict. Report it as + * an operational status, with the same bounded diagnostic policy as failures, so the chat tells + * the operator why review did not complete without misrepresenting it as APPROVE/REVISE/RETHINK. + */ +export function buildReviewUnavailableMessage(reason: unknown): string { + return `Review could not complete: ${sanitizeFailureReason(reason)}`; +} + +/** + * FNXC:ProactiveChatStatus 2026-07-16-12:30: + * A RETHINK narration may claim that work was rolled back only after the baseline reset succeeds. + * Reset failures instead need a safe status row that tells the operator the rollback did not finish. + */ +export function buildReviewRollbackFailureMessage(safeReason: string): string { + return `Review could not roll the step back: ${safeReason}`; +} + +export async function emitProactiveStatus( + store: Pick, + taskId: string, + message: string | null | undefined, + role: AgentRole, + detail?: string, +): Promise { + if (!message) return; + try { + await store.appendAgentLog(taskId, message, "status", detail, role); + } catch { + // Proactive narration is strictly observational and must not affect execution. + } +}