FN-8064: add proactive task chat status updates

Task-detail chat now narrates engine progress and review outcomes in real time.

- Emit bounded, redacted status rows for step lifecycle and review paths.
- Present status entries with a distinct task-chat treatment.
- Cover status narration and diagnostic sanitization with engine tests.

Files changed:
 .changeset/fn-8064-proactive-chat.md               |   7 +
 docs/architecture.md                               |   1 +
 packages/dashboard/app/components/TaskChatTab.css  |  16 ++
 packages/dashboard/app/components/TaskChatTab.tsx  |   9 +-
 .../engine/src/__tests__/executor-prompt.test.ts   |  28 +++-
 .../engine/src/__tests__/proactive-status.test.ts  |  54 +++++++
 packages/engine/src/executor.ts                    | 176 ++++++++++++++++-----
 packages/engine/src/proactive-status.ts            | 117 ++++++++++++++
 8 files changed, 365 insertions(+), 43 deletions(-)

Fusion-Task-Id: FN-8064

Fusion-Task-Lineage: c6d0a9b5-0946-4bf4-8338-e982e1cbfd53

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 02:47:59 -07:00
parent 39d76fdabe
commit c2475b012d
8 changed files with 365 additions and 43 deletions

View File

@@ -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.

View File

@@ -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"`.

View File

@@ -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));

View File

@@ -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}`}
>
<TaskChatTimestampMeta timestamp={getLatestEntryTimestamp(entries)} label="Text block timestamp" />
{firstEntry.type === "status" && (
<div className="task-chat-entry-label-row">
<span className="status-dot status-dot--pending" aria-hidden="true" />
<span className="task-chat-entry-kicker">Status update</span>
<TaskChatTimestamp timestamp={getLatestEntryTimestamp(entries)} label="Status update timestamp" />
</div>
)}
{firstEntry.type !== "status" && <TaskChatTimestampMeta timestamp={getLatestEntryTimestamp(entries)} label="Text block timestamp" />}
<div className="markdown-body task-chat-markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{entries.map((entry) => entry.text).join("")}

View File

@@ -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",
);
});
});
// ---------------------------------------------------------------------------

View File

@@ -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();
});
});

View File

@@ -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<string, ForeachActiveContext>();
/** 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<string, string>();
/** 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:

View File

@@ -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<TaskStore, "appendAgentLog">,
taskId: string,
message: string | null | undefined,
role: AgentRole,
detail?: string,
): Promise<void> {
if (!message) return;
try {
await store.appendAgentLog(taskId, message, "status", detail, role);
} catch {
// Proactive narration is strictly observational and must not affect execution.
}
}