Files
fusion/packages/engine/src/proactive-status.ts
gsxdsm 049c36c9ae fix(chat): narrate task-chat step numbers 1-based to match the card's step count
The proactive status narration printed the internal 0-based step index, so the
final step of a 13-step task announced "Starting Step 12" next to a card
showing "12/13". Display now uses index + 1 in both the engine builders and
the store-side updateStep narration; the 0-based tool/PROMPT.md contract is
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:43:58 -07:00

137 lines
6.0 KiB
TypeScript

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;
}
/*
FNXC:ProactiveChatStatus 2026-07-23-10:30:
Step indices are 0-based everywhere in the agent-facing contract (fn_task_update, PROMPT.md
"### Step 0:" headings, run-audit rows), but the task card counts steps 1-based ("12/13").
Chat narration is read next to the card, so it must display stepIndex + 1 or the final step of a
13-step task announces itself as "Step 12" while the card shows 13 steps. Only the display number
converts; callers keep passing the 0-based index, and a fallback name of "Step <0-based index>"
(produced by callers that default the name from the index) is treated as "unnamed".
*/
function stepDisplayNumber(stepIndex: number): number {
return stepIndex + 1;
}
function stepDisplayName(stepIndex: number, stepName?: string): string | undefined {
const name = stepName?.trim();
return name && name !== `Step ${stepIndex}` ? name : undefined;
}
export function buildStepStartMessage(stepIndex: number, stepName?: string): string {
const display = stepDisplayNumber(stepIndex);
return `Starting Step ${display}: ${stepDisplayName(stepIndex, stepName) ?? `Step ${display}`}`;
}
export function buildStepSuccessMessage(stepIndex: number, stepName?: string): string {
const name = stepDisplayName(stepIndex, stepName);
const display = stepDisplayNumber(stepIndex);
return name ? `Step ${display} finished — ${name}.` : `Step ${display} 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 = stepDisplayName(stepIndex, stepName);
const display = stepDisplayNumber(stepIndex);
return name ? `Step ${display} was skipped — ${name}.` : `Step ${display} was skipped.`;
}
export function buildStepFailureMessage(stepIndex: number, stepName: string | undefined, safeReason: string): string {
const name = stepDisplayName(stepIndex, stepName);
const display = stepDisplayNumber(stepIndex);
const prefix = name ? `Step ${display} (${name})` : `Step ${display}`;
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" | "getSettingsFast">,
taskId: string,
message: string | null | undefined,
role: AgentRole,
detail?: string,
): Promise<void> {
if (!message) return;
try {
if ((await store.getSettingsFast()).proactiveTaskChatEnabled !== true) return;
await store.appendAgentLog(taskId, message, "status", detail, role);
} catch {
// Proactive narration is strictly observational and must not affect execution.
}
}