diff --git a/src/runtime/phase-machine.ts b/src/runtime/phase-machine.ts index da915b0..c09dc7f 100644 --- a/src/runtime/phase-machine.ts +++ b/src/runtime/phase-machine.ts @@ -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" || diff --git a/src/worker/executor.ts b/src/worker/executor.ts index df28c57..51a3620 100644 --- a/src/worker/executor.ts +++ b/src/worker/executor.ts @@ -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): 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) || "游玩正文" }, diff --git a/tests/phase-runtime.test.ts b/tests/phase-runtime.test.ts index 3791c31..88d2175 100644 --- a/tests/phase-runtime.test.ts +++ b/tests/phase-runtime.test.ts @@ -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(); diff --git a/tests/worker-set-sanitize.test.ts b/tests/worker-set-sanitize.test.ts index 5654c9b..0a3e0e5 100644 --- a/tests/worker-set-sanitize.test.ts +++ b/tests/worker-set-sanitize.test.ts @@ -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("(本轮场面未写完)"); + }); });