/** * User-visible run termination copy for Feishu teachers. * Keep messages short, actionable, and free of stack traces. */ export interface RunOutcomeNoticeInput { readonly wallTimeExceeded: boolean; readonly interrupted: boolean; readonly resultStatus: string; readonly resultError: string | undefined; readonly maxTurns: number; readonly maxRunSeconds: number; readonly hasPartialText: boolean; } export interface RunOutcomeNotice { /** Mark the streaming card as failed (red footer). */ readonly isError: boolean; /** * Teacher-facing explanation. Appended after any partial answer text so the * cause of a stop is never silent. */ readonly notice: string | undefined; } export function teacherFacingRunOutcome(input: RunOutcomeNoticeInput): RunOutcomeNotice { if (input.wallTimeExceeded) { return { isError: true, notice: noticeLine( input.hasPartialText, `\u23F1 \u4EFB\u52A1\u8D85\u65F6\uFF1A\u5DF2\u8FBE\u5230\u5355\u6B21\u8FD0\u884C\u65F6\u95F4\u4E0A\u9650\uFF08${input.maxRunSeconds} \u79D2\uFF09\u3002\u8BF7\u62C6\u5206\u4EFB\u52A1\u540E\u91CD\u8BD5\uFF0C\u6216\u8054\u7CFB\u7BA1\u7406\u5458\u8C03\u9AD8\u8FD0\u884C\u65F6\u957F\u4E0A\u9650\u3002`, ), }; } if (input.interrupted) { return { isError: false, notice: undefined }; } if (input.resultStatus === "completed") { return { isError: false, notice: undefined }; } const err = input.resultError ?? ""; if (isMaxTurnsError(err) || input.resultStatus === "length") { return { isError: true, notice: noticeLine( input.hasPartialText, `\u26A0\uFE0F \u4EFB\u52A1\u4E2D\u65AD\uFF1A\u5DF2\u8FBE\u5230\u6700\u5927\u6B65\u9AA4\u6570\uFF08${input.maxTurns} \u8F6E\uFF09\u3002\u8BF7\u62C6\u5206\u4EFB\u52A1\u540E\u7EE7\u7EED\uFF0C\u6216\u8054\u7CFB\u7BA1\u7406\u5458\u8C03\u9AD8\u6B65\u9AA4\u4E0A\u9650\u3002`, ), }; } if (err.trim() !== "") { const brief = sanitizeErrorBrief(err); return { isError: true, notice: noticeLine( input.hasPartialText, `\u274C \u4EFB\u52A1\u5931\u8D25\uFF1A${brief}\u3002\u8BF7\u91CD\u8BD5\uFF1B\u82E5\u591A\u6B21\u51FA\u73B0\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u3002`, ), }; } return { isError: true, notice: noticeLine( input.hasPartialText, "\u274C \u4EFB\u52A1\u672A\u6B63\u5E38\u5B8C\u6210\u3002\u8BF7\u91CD\u8BD5\uFF1B\u82E5\u591A\u6B21\u51FA\u73B0\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u3002", ), }; } export function appendTeacherNotice(body: string, notice: string | undefined): string { if (notice === undefined || notice === "") return body; if (body.trim() === "") return notice; return `${body.trimEnd()}\n\n${notice}`; } export function isMaxTurnsError(error: string): boolean { const lower = error.toLowerCase(); return ( lower.includes("maximum number of turns") || lower.includes("max_turns") || lower.includes("error_max_turns") || lower.includes("result_error_max_turns") || (lower.includes("result_error_during_execution") && lower.includes("turn")) ); } function noticeLine(hasPartialText: boolean, message: string): string { if (!hasPartialText) return message; return `${message}\n\uFF08\u4E0A\u65B9\u4E3A\u5DF2\u751F\u6210\u7684\u90E8\u5206\u7ED3\u679C\u3002\uFF09`; } function sanitizeErrorBrief(error: string): string { const oneLine = error.replace(/\s+/g, " ").trim(); // Drop common SDK prefixes for readability. const stripped = oneLine .replace(/^Claude Code returned an error result:\s*/i, "") .replace(/^Error:\s*/i, ""); if (stripped.length <= 160) return stripped; return `${stripped.slice(0, 157)}...`; }