forked from bai/curriculum-project-hub
6cefb2a938
Defaults and silo env go to 150 turns / 1800s wall clock. Run completion appends a clear Feishu notice for max-turns, timeout, and other failures (partial answer kept). Startup process-restart kills notify the bound chat. Release v0.0.38.
107 lines
3.6 KiB
TypeScript
107 lines
3.6 KiB
TypeScript
/**
|
|
* 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)}...`;
|
|
}
|