From 9ffaf5980c2d376e0f65cb3aa1954e345df9f787 Mon Sep 17 00:00:00 2001 From: Fini Date: Sat, 13 Jun 2026 00:35:34 +0800 Subject: [PATCH] fix(ai): admit painted childless frames as blank-check content A childless Frame with explicit stroke or fill is pixel-equivalent to a childless Rectangle, which the blank check already admits. otp_input's empty stroked digit slots made perfect manifests read as blank scaffolding -> retry ladder -> raw fallback degradation (ark emitted flawless otp manifests twice and got rejected both times). Also serialize the OPENPENCIL_MANIFEST env tests behind a shared lock -- parallel set/remove races only surfaced in full-suite runs. --- crates/op-orchestrator/src/subagent.rs | 55 +++++++++++++++++++++- crates/op-orchestrator/src/test_support.rs | 4 ++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/op-orchestrator/src/subagent.rs b/crates/op-orchestrator/src/subagent.rs index d2982c544..78633396c 100644 --- a/crates/op-orchestrator/src/subagent.rs +++ b/crates/op-orchestrator/src/subagent.rs @@ -155,7 +155,22 @@ fn has_content_node(node: &PenNode) -> bool { // Frame/Group because it carries ContainerProps, which made // every skeleton-screen design read as "blank" (ab-v9: the // mobile-loading-skeleton prompt failed on all four models). - _ => !node.is_container() || matches!(node, PenNode::Rectangle(_)), + _ => match node { + PenNode::Rectangle(_) => true, + // A childless frame with explicit paint renders exactly like + // that rectangle — same pixels, different spelling. The + // otp_input builder's await-input slots (stroked empty + // boxes) made the whole manifest read as blank scaffolding, + // forcing a retry into the hand-rolled raw fallback. + PenNode::Frame(f) => { + f.container.stroke.is_some() + || f.container + .fill + .as_ref() + .is_some_and(|fills| !fills.is_empty()) + } + _ => !node.is_container(), + }, } } @@ -264,6 +279,9 @@ mod tests { /// `parse_manifest` 返回 `None` 后照常回落 `parse_nodes`。 #[test] fn run_subtask_manifest_mode_builds_elements_end_to_end() { + let _env = crate::test_support::MANIFEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); std::env::set_var("OPENPENCIL_MANIFEST", "1"); let manifest = concat!( "{\"el\":\"section\",\"gap\":16,\"role\":\"stats\"}\n", @@ -300,6 +318,41 @@ mod tests { ); } + /// ab-v9.2 现场:`{"el":"otp_input"}` 全空槽位 → builder 产出一排 + /// 带描边的无子 frame。带显式 paint 的无子 frame 与无子矩形像素 + /// 等价,必须算内容 —— 此前整树被判"空白容器",manifest 被拒后 + /// 重试降级到手搓 raw 路径。 + #[test] + fn manifest_element_of_stroked_empty_frames_is_content_not_blank() { + let _env = crate::test_support::MANIFEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + std::env::set_var("OPENPENCIL_MANIFEST", "1"); + let manifest = concat!( + "{\"el\":\"section\",\"direction\":\"horizontal\",\"gap\":12}\n", + "{\"el\":\"otp_input\",\"in\":1,\"length\":6,\"focused_index\":0}", + ); + let llm = ScriptedLlm::new(vec![ScriptResponse::Text(manifest.into())]); + let mut sink = VecDocSink::new(); + let outcome = block_on(run_subtask( + &subtask(), + &plan(), + &req(), + &llm, + &mut sink, + &AbortFlag::new(), + false, + false, + )); + std::env::remove_var("OPENPENCIL_MANIFEST"); + + assert!(outcome.error.is_none(), "{:?}", outcome.error); + assert_eq!( + outcome.node_count, 1, + "one section root with the otp element" + ); + } + #[test] fn run_subtask_zero_node_on_garbage() { let llm = ScriptedLlm::new(vec![ScriptResponse::Text("the model refused".into())]); diff --git a/crates/op-orchestrator/src/test_support.rs b/crates/op-orchestrator/src/test_support.rs index 74fb9f2c4..ef0386c53 100644 --- a/crates/op-orchestrator/src/test_support.rs +++ b/crates/op-orchestrator/src/test_support.rs @@ -215,3 +215,7 @@ impl LlmClient for CountingLlm { pub(crate) use crate::stub_providers::{ SkippedPreValidator, SkippedScreenshotProvider, SkippedVisionLlmClient, }; + +/// 串行化会改 `OPENPENCIL_MANIFEST` 的测试 —— env 是进程全局,两个 +/// set/remove 测试并行会互相拆台(一边刚 set 另一边 remove)。 +pub(crate) static MANIFEST_ENV_LOCK: Mutex<()> = Mutex::new(());