放宽游玩正文判定,并允许错误阶段输入后恢复。

减少长稿被误丢,同时让失败会话可继续推进。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-14 01:15:36 +08:00
co-authored by Cursor
parent 51ff139454
commit 3684e0d6e2
4 changed files with 52 additions and 7 deletions
+4 -1
View File
@@ -367,8 +367,10 @@ export function getAllowedEvents(
"runtime_failed",
];
case "done":
case "error":
return [];
case "error":
// UI 提供「说明后重试」:允许用户发言拉回 running
return ["user_submitted_input"];
default:
return [];
}
@@ -393,6 +395,7 @@ export function canApplyEvent(
return reason?.kind === "skill_selection";
case "user_submitted_input":
return (
session.phase === "error" ||
reason?.kind === "intake" ||
reason?.kind === "input" ||
reason?.kind === "worker_questions" ||
+13 -6
View File
@@ -219,7 +219,7 @@ function existingOutputIsProduct(text: string | undefined): boolean {
function usablePlayFallbackText(raw?: string): string {
if (!raw?.trim()) return "";
if (isQuestionOnlyText(raw)) return "";
if (isPlayMetaAsk(raw)) return "";
const parsed = tryParseJsonDoc(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return "";
@@ -348,16 +348,26 @@ function looksLikeStructuredDraft(text: string): boolean {
return Boolean(extractJsonObjectText(t));
}
/** 几乎只有提问、没有稿。 */
/** 几乎只有提问、没有稿。对话里夹问句不算。 */
function isQuestionOnlyText(text: string): boolean {
const t = text.trim();
if (!t) return true;
if (looksLikeStructuredDraft(t)) return false;
const qs = extractQuestionsFromText(t);
if (!qs.length) return false;
const qChars = qs.join("").length;
if (t.length >= 80 && qChars <= t.length * 0.55) return false;
return looksLikeProseNotSpec(t) || /你的选择|请(?:描述|选择|确认|补充)/.test(t);
}
/** 游玩正文:只把短追问口吻当成没写完,不因场面里有「?」丢掉整稿。 */
function isPlayMetaAsk(text: string): boolean {
const t = text.trim();
if (!t) return true;
if (t.length > 280) return false;
return /可验收|追问|请再补|askUser/.test(t);
}
function canonicalizeKnownOutputs(outputs: Record<string, string>): void {
const raw = outputs[CONTEXT_ORDER_TAG];
if (!raw?.trim()) return;
@@ -408,10 +418,7 @@ function parseWorkerResponse(
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
const target = playTarget ?? productOutputTags(outputTags)[0] ?? outputTags[0];
if (playTarget) {
const body =
raw.trim() && !isQuestionOnlyText(raw)
? raw
: "";
const body = raw.trim() && !isPlayMetaAsk(raw) ? raw : "";
return finalizeParsedOutputs(
{ [playTarget]: packBody(body) },
{ summary: body.slice(0, 80) || "游玩正文" },
+12
View File
@@ -337,6 +337,18 @@ describe("phase runtime", () => {
expect(runtime.getSession().waitingReason).toBeUndefined();
});
it("submitInput recovers from error phase", async () => {
const runtime = new PhaseRuntime({ autoStubWorker: true });
await runtime.start();
await runtime.submitInput("随便写点");
await runtime.failRun("fetch failed");
expect(runtime.getSession().phase).toBe("error");
await runtime.submitInput("重试一下");
expect(runtime.getSession().phase).not.toBe("error");
expect(runtime.getSession().slots.lastUserInput).toBe("重试一下");
});
it("recoverOrphanedRun turns stuck running into node pick when DAG exists", async () => {
const runtime = new PhaseRuntime();
await runtime.start();
+23
View File
@@ -445,4 +445,27 @@ describe("sanitizeWorkerSetOutputs", () => {
const view = parsePresentPacket(result.outputs["输出.用户展示"], "prose");
expect(view.packet.shell).toBe("turn_panel");
});
it("play gm body: keeps a scene that contains dialogue questions", () => {
const scene = `神代流把耳机摘下,皱着眉。
「你在干什么?」她问。与他刚进监控室时不同,教练的吼声还在耳边。
只要玩家公开跪下认错,她就会放行。`;
const result = parseWorkerResponseForTest(scene, ["输出.用户展示"], {
preferPlainBody: true,
});
expect(result.askUser).toBeUndefined();
expect(result.outputs["输出.用户展示"]).toContain("你在干什么");
expect(result.outputs["输出.用户展示"]).not.toBe("(本轮场面未写完)");
});
it("play gm body: short ask-only text still counts as unfinished", () => {
const result = parseWorkerResponseForTest(
"这一步还没写出可验收的产物。请再补一点你最在意的体验。",
["输出.用户展示"],
{ preferPlainBody: true },
);
expect(result.outputs["输出.用户展示"]).toBe("(本轮场面未写完)");
});
});