diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md index feef2a6a2..f47b0ba52 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md @@ -9,7 +9,7 @@ 【转达的规则】 -- 把用户答案回灌给 `project-planning` 时,逐条列出全部已确认决定,每条格式为 `[已确认] 第N轮问的是:{question 原文} | 候选项:{option1.label} / {option2.label} / {option3.label} → 用户答:{原文}`。**问题原文和三个选项标签必须带上**:`{header}` 恒为「第N轮·关键决定」,不含任何信息量;子 Agent 每轮都是全新 run,除了这段正文什么都看不到,只给它 header 和答案,「类似B」「B · 沙盒里程碑成长」这类答案就无从解读,它只能把同一件事再问一遍。用户答案原文一字不改、不归纳、不拆分、不搬轮次;任务长度接近上限时压缩你自己的说明文字和选项描述,绝不压缩用户答案、问题原文和选项标签。 +- 把用户答案回灌给 `project-planning` 时,逐条列出全部已确认决定,每条格式为 `[已确认] 第N轮问的是:{question 原文} | 候选项:{option1.label} / {option2.label} / {option3.label} → 用户答:{原文}`。**问题原文和三个选项标签必须带上**:`{header}` 只写到「第N轮·当前要决定:{主题}」这一层,答案落在选项上;子 Agent 每轮都是全新 run,除了这段正文什么都看不到,只给它主题和答案,「类似B」「B · 沙盒里程碑成长」这类答案就无从解读,它只能把同一件事再问一遍。用户答案原文一字不改、不归纳、不拆分、不搬轮次;任务长度接近上限时压缩你自己的说明文字和选项描述,绝不压缩用户答案、问题原文和选项标签。 - 策划链路的澄清信封**恰好一题**,不是通用静态委派协议里的 1-3 题:`project-planning` 每轮只提一个主要决定,Runtime 也只接受一题,多于一题会在出卡时被拒。委派 task 里不要写“1-3 个结构化问题”。 - 上一条格式里的三个选项标签就是决策卡上的 A、B 和“需要原型验证”,必须原样转述、一个都不能省;B 是用户确认的 `confirmed/user_option`,不能转成默认建议。用户后续自由填写推翻了更早的决定时,你只负责把两轮答案的原文都原样带到,并说明后者更晚;怎么记进决定台账由 `project-planning` 判断,不要替它裁定哪条作废。 diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md index 70204da00..3a2afcd41 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md @@ -4,17 +4,17 @@ - 当前 run 固定为 `source=agent-delegate`、`profile=standard`,父 Agent 是 `project-supervisor`。不得伪造、改写或猜测这些 Runtime 身份。 - 你不能委派或调度其他 Agent,不能创建 isolated child,不能调用 MCP、命令、进程、预览、画布、素材生成、写入/补丁/删除工具,也不能改变项目版本或审批事实。 -- 你的原生工具目录只应包含 `file.read`、`file.list` 以及 Runtime 协议控制函数 `update_agent_plan`、`respond_to_user`;`user.input_request` 不属于你的工具目录。若需要用户决定,必须以终态信封首行 `AGC_NEEDS_USER_INPUT_V1` 退出本轮,下一行给出严格 JSON 信封 `{"questions":[{ ... }]}`,交由 Supervisor 转发。`questions` 恰好一个元素;元素字段只能是 `id`、`header`、`question`、`options` 四个,多写任何字段(例如 `answerFormat`)或省掉 `questions` 外壳都会被 Runtime 拒收,整条委派随即作废。`id` 是唯一 snake_case(小写字母开头,只含小写字母、数字、下划线);`header` 是决策卡标题,单行且不超过 12 字符;`question` 是决策卡正文,单行且不超过 400 字符;`options` 是 2~3 个 `{"label": ..., "description": ...}`,label 单行不超过 60 字符、description 单行不超过 240 字符。不要另起一行写答题说明或把选项复述进 `question`,作答方式由 Runtime 自己呈现。 +- 你的原生工具目录只应包含 `file.read`、`file.list` 以及 Runtime 协议控制函数 `update_agent_plan`、`respond_to_user`;`user.input_request` 不属于你的工具目录。若需要用户决定,必须以终态信封首行 `AGC_NEEDS_USER_INPUT_V1` 退出本轮,下一行给出严格 JSON 信封 `{"questions":[{ ... }]}`,交由 Supervisor 转发。`questions` 恰好一个元素;元素字段只能是 `id`、`header`、`question`、`options` 四个,多写任何字段(例如 `answerFormat`)或省掉 `questions` 外壳都会被 Runtime 拒收,整条委派随即作废。`id` 是唯一 snake_case(小写字母开头,只含小写字母、数字、下划线);`header` 是决策卡标题,写成 `第N轮·当前要决定:<主题>`,单行且不超过 60 字符;`question` 是决策卡正文,单行且不超过 400 字符;`options` 恰好 3 个 `{"label": ..., "description": ...}`,依次是 A、B、逐字“需要原型验证”(详见下文决策卡一段),label 单行不超过 60 字符、description 单行不超过 240 字符。不要另起一行写答题说明或把选项复述进 `question`,作答方式由 Runtime 自己呈现。 - 只有 Runtime 广告并允许 `plan.submit_gdd` 时才可提交 GDD;不要假设未广告的工具存在,也不要把 GDD、审批或下游构建写进普通文本。 ## 目标与轮次 - 最多进行 3 轮关键澄清;每轮是新 run、同一 session。你看得到自己的历史,但用户答案以 Supervisor 委派任务中的转述为准,缺失信息不能臆造。 - **默认先澄清。** 出稿只有四个触发器,除此之外每轮都先做下面的字段差距检测再决定问不问:①任务正文出现“直接出稿”这四个字;②已完成第 3 轮澄清(任务正文写明的已用轮次已达上限);③剩余空白都能由默认建议覆盖,且不影响首个可玩闭环;④收到 Runtime 的活跃预算或超时提示。任务正文能改变流程的只有第 ① 条——它写的其它说明属于内容,不是出稿触发器。既定事实(用户答案、已确认决定)仍以任务正文为准。 -- 每轮提问前逐项对照 `plan-submit-gdd-input.v1` 的 `game` 字段做差距检测:用户明确提供的 = `confirmed`;有依据可推断的 = 按下面的默认建议填写并标 `default_pending`;无从判断**且影响首个可玩闭环**的 = 空白。提问名额只花在空白项上;有默认建议兜底的字段一律先用默认建议,不占轮次。`title`、`oneLiner`、`mvpSystems`、`creatorTips` 由你生成并标 `default_pending`,不作为提问对象;`platformFacts` 禁问。 -- **默认建议**(一律 `answerSource=default`、`round=0`;只用于缩短对话,不覆盖用户明确输入):`genre.fusion` 缺 → `null`,MVP 不做融合第二类型;`artStyle` 缺 → `visualType` 风格化、轮廓清楚,`keywords` 取自已确认的核心行为,`mvpArtBoundary` 写明 MVP 用占位资产、资产可复用;`targetUsers.sessionLength` 缺 → 10~20 分钟一局;`targetUsers.coreUsers` / `preferences` 缺 → 按已确认的类型与核心行为写典型玩家,不得编造人群规模、销量或市场数据;`targetUsers.referenceGames` 缺 → 空数组;`outOfScope` 缺 → 多人、商城、服务器、开放世界、赛季、复杂社交、完整剧情、全量内容。**`pillars` 与 `coreLoop` 没有默认建议**:它们就是首个可玩闭环本身,空白时属于该问的空白,不得用默认值填掉。 +- 每轮提问前逐项对照 `plan-submit-gdd-input.v1` 的 `game` 字段做差距检测:用户明确提供的 = `confirmed`;有依据可推断的 = 按下面的默认建议填写并标 `default_pending`;无从判断的 = 空白。提问名额只花在**空白或存疑、且影响首个可玩闭环**的决定上;有默认建议兜底的字段优先用默认建议而不是提问——「有默认」不等于「不能问」,那条默认明显可能是错的、且选错就做不出首个可玩闭环时,它就是一个该问的存疑项。`title`、`oneLiner`、`mvpSystems`、`creatorTips` 由你生成并标 `default_pending`,不作为提问对象;`platformFacts` 禁问。 +- **默认建议**(一律 `answerSource=default`、`round=0`;只用于缩短对话,不覆盖用户明确输入):`targetUsers.sessionLength` 缺 → 10~20 分钟一局;`artStyle` 缺 → `visualType` 风格化、轮廓清楚,`keywords` 取自已确认的核心行为,`mvpArtBoundary` 写明 MVP 用占位资产、资产可复用;缺成长时 → 1 条成长线和 2~3 个选择;缺探索时 → 1 条主路线加 1 个有意义的岔路;缺构建时 → 高风险输出和稳健防御两种方向。清单之外的字段没有默认值兜底——`genre.fusion`、`targetUsers.coreUsers` / `preferences` / `referenceGames`、`outOfScope` 缺失时都算空白,该不该花一轮问它们由上面的判据决定,不要自己拍一个值填掉就当它已经定了。**`pillars` 与 `coreLoop` 没有默认建议**:它们就是首个可玩闭环本身,空白时属于该问的空白,不得用默认值填掉。 - 优先顺序:核心行为与本局目标 → 重玩动力 → 制作边界与 MVP。每轮最多问一个主要决定。**已确认决定关掉的那条轴不得重问。** 任务正文里每条 `[已确认]` 都带着当轮的问题原文和三个选项标签,先照它判断哪些轴已经关闭,本轮的问题必须落在另一条还没关闭的轴上。把已确认答案换个说法再问一遍——例如用户已经选定“自由经营、靠成就和攒钱升级推进”,你又拿“短周期经营目标 vs 沙盒里程碑成长”去问——是白烧一轮预算。所有轴都已关闭时按出稿触发器③直接出稿。 -- 决策卡的 header 固定为“第N轮·关键决定”,其中 N 是 Runtime 从委派谱系派生的当前轮号,必须精确相等,写错会被 Runtime 拒收:首轮恒为 1;之后每次续跑的任务正文都会写明已用轮次与上限,本轮该用的 N 就是“已用轮次 + 1”。正文以“当前要决定:”开头,只问尚未由平台事实或 MVP 规则排除的真实产品取舍,并说明为什么现在问;每张卡固定提供三个选项:A 是你的推荐方案(label 以 `A ·`、`A:`、`A:` 或 `A-` 开头并写明推荐、好处和代价),B 是形状不同且真实可行的平行备选(label 以 `B ·`、`B:`、`B:` 或 `B-` 开头并写明后果和代价),第三项逐字为“需要原型验证”,description 必须给出 30~90 分钟微型原型、试玩对象、观察信号和通过标准。自由输入按用户原话处理。 +- 决策卡的 header 写成“第N轮·当前要决定:<主题>”,最多 60 字符。N 是 Runtime 从委派谱系派生的当前轮号,写错会被 Runtime 拒收:首轮恒为 1;之后每次续跑的任务正文都会写明已用轮次与上限,本轮该用的 N 就是“已用轮次 + 1”。`<主题>` 是这一轮真正要定的那件事本身(例如“塔的构筑方式”“每局变化来源”),一句话说完、不带状态标记——它会原样落进决定台账的 `topic`,也是你下一轮辨认哪些轴已经关掉的唯一线索,写成“关键决定”这类空话等于把它作废。正文只问尚未由平台事实或 MVP 规则排除的真实产品取舍,并说明为什么现在问;每张卡固定提供三个选项:A 是你的推荐方案(label 以 `A ·`、`A:`、`A:` 或 `A-` 开头并写明推荐、好处和代价),B 是形状不同且真实可行的平行备选(label 以 `B ·`、`B:`、`B:` 或 `B-` 开头并写明后果和代价),第三项逐字为“需要原型验证”,description 必须给出 30~90 分钟微型原型、试玩对象、观察信号和通过标准。自由输入按用户原话处理。 ## 低幻觉与 GDD 约束 @@ -23,6 +23,7 @@ - A、B 或自由填写得到的用户决定标 `confirmed`;用户选择“需要原型验证”标 `prototype_pending`,并保留同 id 的原型验证项——这两项是用户亲手选的,不得改判。只有未提问、由你按默认建议填写的字段才标 `default_pending`,其 `answerSource=default`、`round=0`。不要把用户选择的 B 当成默认项,也不要凭空把没问过的字段标成 `confirmed`——Runtime 会拒收任何没有对应用户作答的 `confirmed`。 - 用户的自由填写没有回答你问的那道题时(他谈的是别的取舍,或者推翻了更早的决定),改这条决定的 `topic`,按他**实际说的内容**重新命名——这是你纠正错误绑定的唯一手段,Runtime 不会替你判断一句话答没答上一道题。若他对该题确实没有作出取舍,把该条降级为 `default_pending` + `answerSource=default` 并按默认建议写 `answerSummary`,再另起一条记录他实际确定下来的东西,在新条目的 `topic` 里写明与被推翻决定的关系。降级只能往这个方向;用户已作出的决定不得整条丢弃。 - `prototypeValidationItems` 是必填字段(没有就传空数组),与 `prototype_pending` 决定**一一对应**:每条 `prototype_pending` 决定必须有一个同 id 的验证项,每个验证项也必须对应一条 `prototype_pending` 决定,最多 3 项。除了用户亲选“需要原型验证”之外,你自己也可以主动标:手感、节奏、可读性、难度曲线这类你没问过、但选错就做不出首个可玩闭环的判断,标 `prototype_pending`(`answerSource=default`、`round=0`)比标 `default_pending` 诚实——那不是一个默认值,是一个没人验证过的假设。每项写清 30~90 分钟微型原型做什么、让谁试玩、观察什么信号、什么算通过。 +- 不得编造具体游戏的机制、数值、销量、人群规模、团队规模或来源。写 `targetUsers` 时按已确认的类型与核心行为描述典型玩家即可。 - 只定义一个完整可玩闭环。MVP 不含多人、商城、服务器、开放世界、赛季、复杂社交、完整剧情或全量内容,除非用户明确改变范围。 - GDD 至少覆盖:游戏名称与类型、一句话描述、2~4 条游戏支柱、核心循环、目标用户、美术方向、3~6 个最小 MVP 系统、先做/暂缓/验证/扩展条件、决定状态和审批请求。不要把 Runtime 注入的身份、时间、指纹、审批 receipt 或平台事实当作 Provider 输入字段。 - 平台事实由 Runtime 固定注入为自包含 Web、desktop/mobile 双视口、keyboard/touch 双输入、本地 HTTP 预览;不得修改、删减或向用户询问。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs index fbbb72692..b3df2714b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs @@ -872,13 +872,17 @@ mod tests { "brief 要点名这个真实踩过的坑" ); for stated in [ - format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS} 字符"), + // 策划卡的 header 走 `AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS`:它装的是 + // 这一轮要定的主题本身,不是通用问询那 12 字的标题格。钉住的仍是「brief 与 + // 解析器同一把尺子」,只是尺子换成了策划链路实际生效的那一把。 + format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS} 字符"), format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS} 字符"), format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS} 字符"), format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS} 字符"), - format!( - "{AGENT_RUNTIME_USER_INPUT_MIN_OPTIONS}~{AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS} 个" - ), + // 选项数同理:通用协议是 2-3 个,策划决策卡恒为 A / B /「需要原型验证」 + // 三项。brief 早先照通用常量写「2~3 个」,和它自己下文的「固定提供三个 + // 选项」以及 `planning_coordinator` 的硬校验三方打架。 + format!("恰好 {PLAN_CLARIFICATION_OPTION_COUNT} 个"), ] { assert!( planning.contains(&stated), @@ -985,9 +989,17 @@ mod tests { /// 这条会跟着红。 /// /// 二、`pillars` / `coreLoop` 明确排除在清单外:它们就是首个可玩闭环本身, - /// 给它们配默认值等于把最该花提问预算的那两项默认掉。原型那份清单里的 - /// 成长 / 探索 / 构建三条落到本仓库的 schema 上正好落在这两个字段上,照抄 - /// 会和「提问顺序:核心行为与本局目标 → 重玩动力」的前两顺位直接打架。 + /// 给它们配默认值等于把最该花提问预算的那两项默认掉。 + /// + /// 清单成员已按原型(`local-scripts/deisgn_agent/prompts.py:136`)那五条拉齐: + /// 局长偏好、美术、成长、探索、构建。`genre.fusion` / `targetUsers.coreUsers` + /// / `preferences` / `referenceGames` / `outOfScope` 从清单里摘掉了——它们 + /// 原型就没有默认值,进了清单就等于把第三顺位「制作边界与 MVP」整条轴默认 + /// 掉,出稿触发器③「剩余空白都能由默认建议覆盖」随之在第 3 轮恒真,3 轮预算 + /// 实际只花得出 2 轮。成长 / 探索 / 构建三条与上面那句不冲突:它们是维度级 + /// 缺省内容,不是 `pillars` / `coreLoop` 两个字段的缺省值,而且「优先用默认 + /// 建议而不是提问」是软优先级,不禁止提问——原型正是带着这三条默认,仍然把 + /// 第 1 轮花在 coreLoop、第 2 轮花在重玩动力上。 /// /// 三、出稿触发器是闭集。生产实测过 Supervisor 会把「若缺少会实质改变结果的 /// 事实才提问,否则直接提交」写进委派 task,子 Agent 照办后 0 轮出稿;这里 @@ -1003,15 +1015,27 @@ mod tests { planning.contains("**默认建议**"), "role brief 三处引用「默认建议」,清单本身必须在场" ); - for field in [ - "`genre.fusion`", - "`artStyle`", - "`targetUsers.sessionLength`", - "`targetUsers.referenceGames`", - "`outOfScope`", - ] { + for field in ["`artStyle`", "`targetUsers.sessionLength`"] { assert!(planning.contains(field), "默认建议清单缺少字段 {field}"); } + for dimension in ["缺成长时", "缺探索时", "缺构建时"] { + assert!( + planning.contains(dimension), + "默认建议清单缺少原型的维度级缺省 {dimension}" + ); + } + // 反向:这几个字段一旦回到默认清单,轴三就又被默认掉了。它们仍会在 brief 里 + // 出现(被点名为「没有默认值兜底」),所以只能钉「缺 → 」这个清单条目形状。 + for defaulted in [ + "`genre.fusion` 缺 →", + "`targetUsers.referenceGames` 缺 →", + "`outOfScope` 缺 →", + ] { + assert!( + !planning.contains(defaulted), + "{defaulted} 不得回到默认建议清单:那会让出稿触发器③在第 3 轮恒真" + ); + } assert!( planning.contains("**`pillars` 与 `coreLoop` 没有默认建议**"), "pillars / coreLoop 不得进默认建议清单" @@ -1529,12 +1553,15 @@ mod tests { /// 澄清回灌必须带上问题原文和三个选项标签,两端都要钉住。 /// - /// `header` 按信封契约恒为「第N轮·关键决定」,零信息量;而 `project-planning` - /// 每轮都是全新 run(`observations: []`),除了委派任务正文什么都看不到。只回灌 - /// `{header} → 用户答:{原文}` 时,「类似B」「B · 沙盒里程碑成长」这类答案无从 - /// 解读——生产实测的农场经营项目里,第 1 轮问「季节订单冲刺 vs 自主农场成长」, - /// 用户答了 B,第 2 轮又拿「短周期经营目标 vs 沙盒里程碑成长」问同一条轴, - /// 而且 B 选项几乎是用户原话的复述。 + /// `header` 现在带主题(「第N轮·当前要决定:{主题}」),但只到主题这一层——用户 + /// 拍的板落在**选项**上。而 `project-planning` 每轮都是全新 run(`observations: []`), + /// 除了委派任务正文什么都看不到。只回灌 `{header} → 用户答:{原文}` 时, + /// 「类似B」「B · 沙盒里程碑成长」这类答案仍然无从解读——生产实测的农场经营项目里, + /// 第 1 轮问「季节订单冲刺 vs 自主农场成长」,用户答了 B,第 2 轮又拿「短周期经营 + /// 目标 vs 沙盒里程碑成长」问同一条轴,而且 B 选项几乎是用户原话的复述。 + /// + /// 这条与 header 带不带主题正交:主题解决「问过哪些轴」,选项标签解决「答案指的是 + /// 哪一个」。两端都得钉。 #[test] fn plan_clarification_relay_carries_the_question_and_option_labels() { let plan = required_runtime_prompt_section("planSupervisorPlaybook"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index 95520de11..85516fdf2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -3932,9 +3932,9 @@ mod plan_envelope_repair_tests { use super::*; const TRUNCATED: &str = "AGC_NEEDS_USER_INPUT_V1 -{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·关键决定\",\"question\":\"当前要决定:?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"}]}"; +{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·当前要决定:核心闭环形状\",\"question\":\"?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"}]}"; const COMPLETE: &str = "AGC_NEEDS_USER_INPUT_V1 -{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·关键决定\",\"question\":\"当前要决定:?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"},{\"label\":\"B\",\"description\":\"乙\"}]}]}"; +{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·当前要决定:核心闭环形状\",\"question\":\"?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"},{\"label\":\"B\",\"description\":\"乙\"}]}]}"; /// 截断的信封必须在 run 内被认出来,否则它会随 final reply 逃逸成一条 /// needs-repair 委派,把返工额度和澄清轮次一起卷进去。 @@ -3988,7 +3988,7 @@ mod plan_envelope_repair_tests { fn a_degenerated_tail_no_longer_burns_a_repair_attempt() { let reply = concat!( "AGC_NEEDS_USER_INPUT_V1\n", - r#"{"questions":[{"id":"replay_progression","header":"第2轮·关键决定","question":"当前要决定:自由经营农场的长期目标采用哪种组合?","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施。"},{"label":"需要原型验证","description":"制作微型原型让目标玩家试玩两种目标结构。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#, + r#"{"questions":[{"id":"replay_progression","header":"第2轮·当前要决定:自由经营农场的长期目标","question":"它决定玩家为何持续规划、赚钱与重玩,也控制 MVP 的范围。","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施。"},{"label":"需要原型验证","description":"制作微型原型让目标玩家试玩两种目标结构。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#, ); assert!(game_creator_agent_runtime_plan_envelope_parse_error( GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs index 4e6bf1374..20d56b640 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs @@ -5,6 +5,13 @@ use uuid::Uuid; const PLAN_OPTION_A_PREFIX: char = 'A'; const PLAN_OPTION_B_PREFIX: char = 'B'; const PLAN_OPTION_PROTOTYPE_VALIDATION: &str = "需要原型验证"; +/// 策划决策卡恒为 A / B /「需要原型验证」三项,不是通用 `user.input_request` 协议的 +/// 2-3 个。role brief 早先照通用常量写成「2~3 个」,与本文件的硬校验和 brief 自己 +/// 下文的「固定提供三个选项」三方打架;模型照前者吐两项,整封信封在出卡时被拒、 +/// 回灌重试,白烧一个未推进回合,丢掉的还恰好是用户产生 `prototype_pending` 的唯一 +/// 入口。`project_planning_role_brief_states_the_parser_wire_shape_verbatim` 钉住 +/// brief 与这里同源。 +pub(crate) const PLAN_CLARIFICATION_OPTION_COUNT: usize = 3; const PLAN_QUESTION_PREFIX: &str = "当前要决定:"; #[derive(Clone, Debug, Eq, PartialEq)] @@ -110,14 +117,40 @@ fn exact_plan_child_identity_at( Ok(Some((binding, delivery))) } -fn plan_question_topic(question: &AgentRuntimeUserInputQuestion) -> Result { - let remainder = question - .question - .strip_prefix(PLAN_QUESTION_PREFIX) +/// 剥掉 header 的 `第{round}轮·` 前缀,返回其后的正文。 +/// +/// 轮号本身由 Runtime 从委派谱系派生,模型只是照着任务正文抄;这里逐字核对它,写错就 +/// 拒收——否则卡片标题会和 `GddApprovalCard` 那个「第 N 轮 / 共 3 轮」自相矛盾。 +fn plan_header_body(header: &str, round: u32) -> Option<&str> { + let rest = header.trim_start().strip_prefix('第')?.trim_start(); + let digits = rest + .chars() + .take_while(char::is_ascii_digit) + .collect::(); + if digits.parse::().ok()? != round { + return None; + } + let rest = rest[digits.len()..].trim_start().strip_prefix('轮')?.trim(); + // 原型模板写作 `第 N 轮 · 当前要决定:…`,中文语境下模型高频吐出 `·`/`:`/`:`/`-` + // 几种分隔符;不在集合里的后果是整封信封被拒、白吃一个未推进回合。 + let rest = rest.strip_prefix(&PLAN_OPTION_LABEL_DELIMITERS[..])?; + Some(rest.trim_start()) +} + +/// 决定台账的 `topic` 取自 header。 +/// +/// 原型(`design_agent.py:1841`)直接把整条 header 当 topic;这里只是再剥掉 `第N轮·` 和 +/// 「当前要决定:」两层固定前缀,落进台账的是主题本身。 +fn plan_question_topic( + question: &AgentRuntimeUserInputQuestion, + round: u32, +) -> Result { + let remainder = plan_header_body(&question.header, round) + .and_then(|body| body.strip_prefix(PLAN_QUESTION_PREFIX)) .ok_or_else(|| { plan_coordinator_error( "PLAN_INVALID_CLARIFICATION", - "plan question 必须以“当前要决定:”开头", + format!("plan header 必须形如“第{round}轮·当前要决定:<主题>”"), ) })?; let topic = remainder @@ -129,7 +162,12 @@ fn plan_question_topic(question: &AgentRuntimeUserInputQuestion) -> Result bool { let Some(remainder) = label.strip_prefix(prefix).map(str::trim_start) else { @@ -154,6 +192,44 @@ fn plan_option_label_has_prefix(label: &str, prefix: char) -> bool { /// `user_freeform`。两边 state 同为 `confirmed`,状态机看不出异常——被污染的恰好是第 /// 23.9 节要立起来的那个字段。`planning_clarification_option_pick_survives_untrimmed_label` /// 钉的就是这条不变量。 +#[cfg(test)] +mod option_label_delimiter_tests { + use super::*; + + /// 分隔符集合只能放宽、不能收窄,且必须覆盖原型 `_OPTION_A_PATTERN` 的那一份。 + /// + /// 锁的是「集合里每一个都被接受」这条不变量,不是某个具体标点:少一个的后果不是 + /// 「模型换个写法」,而是一封完全合法的信封被判形状错误、回灌重试,白吃一个未推进 + /// 回合——`planning_clarification_accepts_fullwidth_colon_option_labels` 记的就是 + /// 全角冒号那一次。 + /// 逐字来自原型 `design_agent.py` 的 `^A\s*[·•・::..\-]`。这里**不能**改成遍历 + /// `PLAN_OPTION_LABEL_DELIMITERS` 本身——那样从集合里删掉一个,循环也跟着少测一个, + /// 断言恒真。 + const PROTOTYPE_DELIMITERS: [char; 8] = ['·', '•', '・', ':', ':', '.', '.', '-']; + + #[test] + fn every_delimiter_in_the_set_is_accepted_on_both_option_prefixes() { + for delimiter in PROTOTYPE_DELIMITERS { + for prefix in [PLAN_OPTION_A_PREFIX, PLAN_OPTION_B_PREFIX] { + let label = format!("{prefix}{delimiter}方案短语"); + assert!( + plan_option_label_has_prefix(&label, prefix), + "分隔符 {delimiter:?} 被拒:{label}" + ); + let spaced = format!("{prefix} {delimiter} 方案短语"); + assert!( + plan_option_label_has_prefix(&spaced, prefix), + "带空格写法被拒:{spaced}" + ); + } + } + assert!( + !plan_option_label_has_prefix("A方案短语", PLAN_OPTION_A_PREFIX), + "没有分隔符不能算合法 A 选项,否则 A/B 与自由文本会混" + ); + } +} + fn plan_option_label_matches_answer(label: &str, normalized_answer: &str) -> bool { label == normalized_answer } @@ -178,14 +254,7 @@ pub(crate) fn validate_exact_plan_clarification_question( "plan questionId 必须是最多 32 个 ASCII 字符且不能映射为 initial-request", )); } - let expected_header = format!("第{round}轮·关键决定"); - if question.header != expected_header { - return Err(plan_coordinator_error( - "PLAN_INVALID_CLARIFICATION", - format!("plan question header 必须精确等于 {expected_header}"), - )); - } - let valid_shape = question.options.len() == 3 + let valid_shape = question.options.len() == PLAN_CLARIFICATION_OPTION_COUNT && plan_option_label_has_prefix(&question.options[0].label, PLAN_OPTION_A_PREFIX) && plan_option_label_has_prefix(&question.options[1].label, PLAN_OPTION_B_PREFIX) && question.options[2].label == PLAN_OPTION_PROTOTYPE_VALIDATION; @@ -195,7 +264,9 @@ pub(crate) fn validate_exact_plan_clarification_question( "plan question 必须恰好提供 A、B、需要原型验证三个选项", )); } - plan_question_topic(question)?; + // header 的定形连同轮号一起在这里兜底:`plan_question_topic` 要求它形如 + // `第{round}轮·当前要决定:<主题>`,并把主题本身取出来给决定台账。 + plan_question_topic(question, round)?; Ok(()) } @@ -210,7 +281,7 @@ fn build_plan_clarification_decision_projection( let normalized_answer = normalize_plan_text(&answer.answer, "plan answer", 1, 400) .map_err(|error| error.to_string())?; let question = &answer.question; - let topic = plan_question_topic(question)?; + let topic = plan_question_topic(question, round)?; let decision_id = question.id.replace('_', "-"); let (state, answer_source) = if plan_option_label_matches_answer(&question.options[0].label, &normalized_answer) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs index 05355ba15..1406ab813 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs @@ -256,7 +256,7 @@ impl StaticDelegateHopNote<'_> { rounds_used, rounds_limit, } => format!( - "\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮,不消耗返工深度。已用澄清轮次 {rounds_used}/{rounds_limit}。仍有会实质改变结果的空白且预算未用尽时,可以继续以 AGC_NEEDS_USER_INPUT_V1 信封退出:questions 恰好一题,header 必须精确等于「第{next_round}轮·关键决定」。预算已用尽,或剩余空白能由默认建议覆盖且不影响首个可玩闭环时,立即提交 GDD。", + "\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮,不消耗返工深度。已用澄清轮次 {rounds_used}/{rounds_limit}。仍有会实质改变结果的空白且预算未用尽时,可以继续以 AGC_NEEDS_USER_INPUT_V1 信封退出:questions 恰好一题,header 写成「第{next_round}轮·当前要决定:<主题>」,轮号必须是 {next_round},主题写这一轮真正要定的那件事。预算已用尽,或剩余空白能由默认建议覆盖且不影响首个可玩闭环时,立即提交 GDD。", next_round = rounds_used.saturating_add(1), ), } @@ -1730,7 +1730,7 @@ mod tests { "澄清续跑必须写明已用轮次与上限:{clarification}" ); assert!( - clarification.contains("第2轮·关键决定"), + clarification.contains("第2轮·当前要决定:"), "task 里的轮号必须等于 planning_coordinator 校验 header 时用的 rounds_used + 1:{clarification}" ); @@ -1753,7 +1753,7 @@ mod tests { "预算用尽时必须要求收稿,出卡侧会直接拒掉第四张卡:{exhausted}" ); assert!( - !exhausted.contains("第4轮·关键决定"), + !exhausted.contains("第4轮·当前要决定:"), "预算用尽时不得再给出下一轮 header,那是一张永远递不上去的卡:{exhausted}" ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs index 16e20945b..d335e8a06 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -2560,8 +2560,8 @@ mod tests { serde_json::json!({ "questions": [{ "id": "plan_round_1", - "header": "第1轮·关键决定", - "question": "当前要决定:影子能力在首个可玩闭环中的核心作用。它会同时决定关卡布局、操作手感与原型优先级,也决定第一批谜题按什么规则组合;现在确认可以避免把三种玩法都做浅,也避免原型做到一半再推翻核心规则。", + "header": "第1轮·当前要决定:影子能力在首个可玩闭环中的核心作用", + "question": "它会同时决定关卡布局、操作手感与原型优先级,也决定第一批谜题按什么规则组合;现在确认可以避免把三种玩法都做浅,也避免原型做到一半再推翻核心规则。", "options": [ { "label": "A · 影子化为可独立移动的暗影分身", @@ -2602,12 +2602,12 @@ mod tests { // abtest-tide2A-2:尾巴是「 马会」。 concat!( "AGC_NEEDS_USER_INPUT_V1\n", - r#"{"questions":[{"id":"replay_motivation","header":"第1轮·关键决定","question":"当前要决定:固定五岛海图的重复游玩动力采用哪种方案?现在确认它,才能锁定首个可玩闭环之外的得分与重开目标。","options":[{"label":"A · 推荐:固定布局冲榜","description":"每局地图与信件配置固定,玩家通过更优路线、潮汐 timing 和装卸顺序刷新送达数与总分;优点是实现最小、可读性强,代价是内容变化较少。"},{"label":"B · 轮换信件组合","description":"地图固定但每局从预设信件组合中轮换收件岛与期限;优点是重玩变化更明显,代价是需要额外平衡组合并降低可预测性。"},{"label":"需要原型验证","description":"用30~90分钟做可点击五岛地图与两种信件配置原型,让3名偏好轻策略的玩家各玩3局,观察是否主动重开及路线是否有差异;通过标准是多数玩家愿意重开且能说出改进路线。"}]}]} 马会"#, + r#"{"questions":[{"id":"replay_motivation","header":"第1轮·当前要决定:固定五岛海图的重复游玩动力","question":"现在确认它,才能锁定首个可玩闭环之外的得分与重开目标。","options":[{"label":"A · 推荐:固定布局冲榜","description":"每局地图与信件配置固定,玩家通过更优路线、潮汐 timing 和装卸顺序刷新送达数与总分;优点是实现最小、可读性强,代价是内容变化较少。"},{"label":"B · 轮换信件组合","description":"地图固定但每局从预设信件组合中轮换收件岛与期限;优点是重玩变化更明显,代价是需要额外平衡组合并降低可预测性。"},{"label":"需要原型验证","description":"用30~90分钟做可点击五岛地图与两种信件配置原型,让3名偏好轻策略的玩家各玩3局,观察是否主动重开及路线是否有差异;通过标准是多数玩家愿意重开且能说出改进路线。"}]}]} 马会"#, ), // verify-farm-4:尾巴是古吉拉特语字母、西里尔字母和中文垃圾词的混合物。 concat!( "AGC_NEEDS_USER_INPUT_V1\n", - r#"{"questions":[{"id":"replay_progression","header":"第2轮·关键决定","question":"当前要决定:自由经营农场的长期目标采用哪种组合?这会决定玩家为何持续规划、赚钱与重玩,并控制 MVP 的范围。","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施,同时完成可选成就;优点是目标清晰又保留自由安排,代价是需要同时做基础升级与成就追踪。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施,成就仅作展示;优点是系统更聚焦、反馈直接,代价是挑战层次和重玩目标较少。"},{"label":"需要原型验证","description":"制作 30–90 分钟微型原型,让 2–3 名目标玩家试玩两种目标结构,观察他们是否主动设定计划、理解进展并愿意继续经营;多数玩家能完成一次扩建且愿意追求第二个目标即通过。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#, + r#"{"questions":[{"id":"replay_progression","header":"第2轮·当前要决定:自由经营农场的长期目标","question":"这会决定玩家为何持续规划、赚钱与重玩,并控制 MVP 的范围。","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施,同时完成可选成就;优点是目标清晰又保留自由安排,代价是需要同时做基础升级与成就追踪。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施,成就仅作展示;优点是系统更聚焦、反馈直接,代价是挑战层次和重玩目标较少。"},{"label":"需要原型验证","description":"制作 30–90 分钟微型原型,让 2–3 名目标玩家试玩两种目标结构,观察他们是否主动设定计划、理解进展并愿意继续经营;多数玩家能完成一次扩建且愿意追求第二个目标即通过。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#, ), ]; for response in cases { @@ -2628,7 +2628,7 @@ mod tests { // verify-farm-2 现场原文,结尾是 `}]}` 而非 `}]}]}`。 let response = concat!( "AGC_NEEDS_USER_INPUT_V1\n", - r#"{"questions":[{"id":"core_loop_goal","header":"第1轮·关键决定","question":"当前要决定:这款农场经营游戏的一局,玩家主要通过什么目标获得满足?现在先定核心闭环,才能控制 MVP 范围。","options":[{"label":"A · 推荐:短周期订单经营","description":"围绕播种、收获、加工并完成限时订单推进;目标清晰、反馈快,代价是自由建造与长期规划较少。"},{"label":"B · 自主农场成长","description":"围绕规划田地、逐步扩建并达成阶段里程碑;沉浸和成长感更强,代价是前期目标反馈较慢、系统边界更难控。"},{"label":"需要原型验证","description":"制作 30~90 分钟微型原型,包含种植、收获和一种目标;让 2~3 名目标玩家试玩,观察是否理解目标、是否愿意继续一轮;通过标准是多数玩家无需讲解即可完成闭环并主动开始第二轮。"}]}"#, + r#"{"questions":[{"id":"core_loop_goal","header":"第1轮·当前要决定:一局里玩家靠什么目标获得满足","question":"现在先定核心闭环,才能控制 MVP范围。","options":[{"label":"A · 推荐:短周期订单经营","description":"围绕播种、收获、加工并完成限时订单推进;目标清晰、反馈快,代价是自由建造与长期规划较少。"},{"label":"B · 自主农场成长","description":"围绕规划田地、逐步扩建并达成阶段里程碑;沉浸和成长感更强,代价是前期目标反馈较慢、系统边界更难控。"},{"label":"需要原型验证","description":"制作 30~90 分钟微型原型,包含种植、收获和一种目标;让 2~3 名目标玩家试玩,观察是否理解目标、是否愿意继续一轮;通过标准是多数玩家无需讲解即可完成闭环并主动开始第二轮。"}]}"#, ); let error = parse_static_delegate_user_input_request(Some(response)) .expect_err("an envelope that stops short of closing must not parse"); @@ -2832,8 +2832,8 @@ mod tests { // 实测形态:option 对象里多写了一个 `id` 字段。 let response = concat!( "AGC_NEEDS_USER_INPUT_V1\n", - "{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·关键决定\",", - "\"question\":\"当前要决定:核心闭环形状。\",\"options\":[", + "{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·当前要决定:核心闭环形状\",", + "\"question\":\"它决定首个可玩闭环长什么样。\",\"options\":[", "{\"id\":\"a\",\"label\":\"A · 甲方案\",\"description\":\"甲方案的后果\"},", "{\"id\":\"b\",\"label\":\"B · 乙方案\",\"description\":\"乙方案的后果\"},", "{\"id\":\"c\",\"label\":\"需要原型验证\",\"description\":\"做个微型原型看看\"}]}]}" diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index 1f3e6abf4..f2aa6f12f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -515,8 +515,8 @@ fn planning_clarification_question_body_with_labels( let body = serde_json::json!({ "questions": [{ "id": question_id, - "header": format!("第{round}轮·关键决定"), - "question": format!("当前要决定:第{round}轮核心取舍。现在确认后才能继续收敛 Fast GDD。"), + "header": format!("第{round}轮·当前要决定:第{round}轮核心取舍"), + "question": format!("第{round}轮核心取舍现在确认后才能继续收敛 Fast GDD。"), "options": [ { "label": label_a, diff --git a/apps/ai-game-creator-shell/src-tauri/src/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/user_input.rs index 017142cfb..496fe0615 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/user_input.rs @@ -41,10 +41,42 @@ pub(crate) const AGENT_RUNTIME_USER_INPUT_MIN_OPTIONS: usize = 2; pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS: usize = 3; const AGENT_RUNTIME_USER_INPUT_MAX_ID_CHARS: usize = 64; pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS: usize = 12; +/// 立项策划澄清卡的 header 是「这一轮要定的是什么」本身,不是一个 12 字的标题格。 +/// +/// 策划子 Agent 每轮都是全新 run,跨轮只能靠委派正文里转述的既往问答认路;header 带 +/// 主题时它一眼能看出哪几条轴已经关掉。原型(`local-scripts/deisgn_agent`)就是这么 +/// 做的:header 上限 60 字、写成 `第 N 轮 · 当前要决定:…`,决定台账的 `topic` 直接取 +/// 它。本仓库把 header 压成固定 8 字的轮号计数器后这条通路就断了。 +/// +/// 放宽只对 `第{N}轮` 这一种形状生效(`plan_clarification_header_limit`)。通用问询今天 +/// 能过的 header 明天逐字照过——做游戏 / 做素材两条泳道拿到的仍是 12 字上限,这里没有 +/// 任何一条既有请求会因此改变结果。 +pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS: usize = 60; pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS: usize = 400; pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS: usize = 60; pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS: usize = 240; +/// 该 header 能用到的字符上限。 +/// +/// 判据是形状而不是身份:这个函数在通用 `user.input_request` 解析路径上,六个调用点里 +/// 有两个(工具计划校验、动作摘要)拿不到 root,问不出「这封信是不是策划链路的」。形状 +/// 判据只放宽、从不收紧——非策划 header 一律走 12 字原路,策划 header 的真正定形由 +/// `planning_coordinator::validate_exact_plan_clarification_question` 逐字兜底。 +fn plan_clarification_header_limit(header: &str) -> usize { + let Some(rest) = header.trim_start().strip_prefix('第') else { + return AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS; + }; + let rest = rest.trim_start(); + let digits = rest + .chars() + .take_while(char::is_ascii_digit) + .collect::(); + if digits.is_empty() || !rest[digits.len()..].trim_start().starts_with('轮') { + return AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS; + } + AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS +} + /// 一份 schema 合法的澄清问询在线上最多可能有多长(字符)。 /// /// 存在的意义是给中转通道一个由 schema 推导的上限,而不是让它自己拍一个数。 @@ -60,8 +92,10 @@ pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_WIRE_CHARS: usize = { let per_option = AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS + AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS + OPTION_SYNTAX_CHARS; + // 取两种 header 里宽的那个:通道窄于 schema 的后果是一封完全合法的策划信封在父 run + // 认领回执时被拒、整条委派链阻断,正是这个常量当初要防的那件事。 let per_question = AGENT_RUNTIME_USER_INPUT_MAX_ID_CHARS - + AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS + + AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS + AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS + AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS * per_option + QUESTION_SYNTAX_CHARS; @@ -314,7 +348,7 @@ fn normalize_user_input_questions( } let header = normalize_single_line_user_input_text( &question.header, - AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS, + plan_clarification_header_limit(&question.header), &format!("user.input_request question {} header", question_index + 1), )?; let question_text = normalize_single_line_user_input_text( @@ -1347,6 +1381,27 @@ mod tests { }] } + /// 放宽 header 上限只对策划澄清卡那一种形状生效,且只放宽、不收紧。 + /// + /// 两个方向都得钉:同一条 31 字的 header,带 `第N轮·` 前缀要过(策划卡装的是决定 + /// 主题本身),不带就必须照旧被 12 字挡下——否则这次改动就顺手把做游戏 / 做素材 + /// 的通用问询也放宽了,而那两条泳道本轮不该有任何行为变化。 + #[test] + fn only_the_plan_clarification_header_shape_gets_the_wider_limit() { + let long_topic = "当前要决定:一局里玩家靠什么目标获得满足"; + assert!(long_topic.chars().count() > AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS); + + let mut plan_header = valid_questions(); + plan_header[0].header = format!("第1轮·{long_topic}"); + assert!(normalize_user_input_questions(plan_header).is_ok()); + + let mut generic_header = valid_questions(); + generic_header[0].header = long_topic.to_string(); + assert!(normalize_user_input_questions(generic_header) + .expect_err("通用 header 不得因为策划分支被放宽") + .contains(&AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS.to_string())); + } + #[test] fn user_input_questions_require_unique_snake_case_ids_and_two_options() { assert!(normalize_user_input_questions(valid_questions()).is_ok()); @@ -1475,8 +1530,8 @@ mod tests { }; let question = AgentRuntimeUserInputQuestion { id: "route_choice".to_string(), - header: "第1轮·关键决定".to_string(), - question: "当前要决定:首版路线。".to_string(), + header: "第1轮·当前要决定:首版路线".to_string(), + question: "它决定第一批关卡按什么规则组合。".to_string(), options: vec![ AgentRuntimeUserInputOption { label: "接受推荐".to_string(), diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeUserInputCard.test.tsx b/apps/ai-game-creator-shell/tests/agentRuntimeUserInputCard.test.tsx index cc53b7efb..8779ec102 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeUserInputCard.test.tsx +++ b/apps/ai-game-creator-shell/tests/agentRuntimeUserInputCard.test.tsx @@ -20,7 +20,7 @@ function clarificationRequest(): AgentRuntimeUserInputRequest { questions: [ { id: 'q1', - header: '第1轮·关键决定', + header: '第1轮·当前要决定:首版路线', question: '这局游戏的重玩动力是什么?', options: [ { label: '分数驱动', description: '刷新纪录后重开' }, @@ -46,7 +46,9 @@ describe('AgentRuntimeUserInputCard 澄清输入', () => { , ); - const textarea = screen.getByLabelText('第1轮·关键决定 其他回答'); + const textarea = screen.getByLabelText( + '第1轮·当前要决定:首版路线 其他回答', + ); fireEvent.change(textarea, { target: { value: '玩家自己写的答案' } }); expect((textarea as HTMLTextAreaElement).value).toBe('玩家自己写的答案'); @@ -64,7 +66,7 @@ describe('AgentRuntimeUserInputCard 澄清输入', () => { fireEvent.click(screen.getByText('分数驱动')); const textarea = screen.getByLabelText( - '第1轮·关键决定 其他回答', + '第1轮·当前要决定:首版路线 其他回答', ) as HTMLTextAreaElement; expect(textarea.value).toBe('分数驱动');