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>
118 lines
5.0 KiB
TypeScript
118 lines
5.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;
|
|
}
|
|
|
|
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.
|
|
}
|
|
}
|