mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-05-31 19:04:29 +07:00
fix(ai): narrow section-fill strip scope to the true page root
Follow-up on b4d35bb. The previous wiring ran
stripRedundantSectionFills in two places it should never have touched:
- applyPostStreamingTreeHeuristics called it on both `parentOfRoot` AND
`freshRoot`. When a sub-agent emits an inner section (e.g. bottomNav-root),
freshRoot is that inner section — NOT the page root — and its direct
children are components, not page sections. The strip pass then
misidentified those components as structural sections and could erase
their intended fills.
- sanitizeNodesForInsert/sanitizeNodesForUpsert called it on every cloned
node from batch/MCP APIs. Those nodes can be anything (a card, a button,
a component, a page), so the path cannot guarantee the node is a page
root. Stripping there risks wiping a card's own dark header fill, etc.
Fixes:
- In applyPostStreamingTreeHeuristics, pick exactly ONE target: use
parentOfRoot if it exists (the sub-agent's root was inserted as a
child of a page frame), otherwise use freshRoot (the sub-agent's root
IS the page frame via replaceEmptyFrame). Never both.
- Remove the strip call from sanitizeNodesForInsert/Upsert entirely and
explain in a comment why it would be unsafe there.
- Document the scope contract on the function itself: "callers MUST only
pass the true page root frame."
- Add a regression test `is strictly non-recursive: never touches
grandchildren even when caller mis-targets a card` to bound the blast
radius if a future caller ever mis-targets the function.
This commit is contained in:
parent
b4d35bb185
commit
320ba104eb
3 changed files with 66 additions and 18 deletions
|
|
@ -588,17 +588,21 @@ export function applyPostStreamingTreeHeuristics(rootNodeId: string): void {
|
|||
// can tell section containers apart from card/button/chip components by
|
||||
// their resolved role.
|
||||
//
|
||||
// The sub-agent's root frame is itself a direct child of the OUTER page
|
||||
// root frame (DEFAULT_FRAME_ID or the generation root), so we run the
|
||||
// strip pass on that parent — that's the level where section fills live.
|
||||
// We also run it on freshRoot itself to catch sub-agents that build an
|
||||
// entire page inside a single sub-task (where the "sections" are freshRoot's
|
||||
// own children).
|
||||
// IMPORTANT: stripRedundantSectionFills must ONLY be called on the true
|
||||
// page root frame. Calling it on an arbitrary sub-agent root (or any
|
||||
// non-root nested frame) is wrong — the nested frame's direct children
|
||||
// are components, not "sections", and stripping their fills would
|
||||
// clobber intended visual styling (e.g. a card's own dark header).
|
||||
//
|
||||
// The page root is:
|
||||
// - `parentOfRoot` when the sub-agent's root was inserted as a child of
|
||||
// an existing page frame (the common case for a multi-section plan)
|
||||
// - `freshRoot` itself when the sub-agent's root IS the page frame
|
||||
// (replaceEmptyFrame remap, or a single-sub-agent page)
|
||||
// Pick exactly one — never both.
|
||||
const parentOfRoot = useDocumentStore.getState().getParentOf(rootNodeId);
|
||||
if (parentOfRoot && parentOfRoot.type === 'frame') {
|
||||
stripRedundantSectionFills(parentOfRoot);
|
||||
}
|
||||
stripRedundantSectionFills(freshRoot);
|
||||
const pageRoot = parentOfRoot && parentOfRoot.type === 'frame' ? parentOfRoot : freshRoot;
|
||||
stripRedundantSectionFills(pageRoot);
|
||||
|
||||
// Publish point. unwrap, resolveTreeRoles, and normalizeTreeLayout all
|
||||
// mutate store-owned nodes in place; resolveTreePostPass mostly goes
|
||||
|
|
@ -786,9 +790,10 @@ function sanitizeNodesForInsert(nodes: PenNode[], existingIds: Set<string>): Pen
|
|||
resolveTreeRoles(node, generationCanvasWidth);
|
||||
applyGenerationHeuristics(node);
|
||||
normalizeTreeLayout(node);
|
||||
// Drop redundant section-level fills after role resolution so cards
|
||||
// and buttons (which must keep their fill) are correctly identified.
|
||||
stripRedundantSectionFills(node);
|
||||
// Intentionally NOT calling stripRedundantSectionFills here: `cloned`
|
||||
// is an arbitrary PenNode from MCP/batch APIs (could be a card, a
|
||||
// component, or a page). strip must only run on the true page root
|
||||
// frame, which this path cannot guarantee.
|
||||
sanitizeLayoutChildPositions(node, false);
|
||||
sanitizeScreenFrameBounds(node);
|
||||
}
|
||||
|
|
@ -815,9 +820,10 @@ function sanitizeNodesForUpsert(nodes: PenNode[]): PenNode[] {
|
|||
resolveTreeRoles(node, generationCanvasWidth);
|
||||
applyGenerationHeuristics(node);
|
||||
normalizeTreeLayout(node);
|
||||
// Drop redundant section-level fills after role resolution so cards
|
||||
// and buttons (which must keep their fill) are correctly identified.
|
||||
stripRedundantSectionFills(node);
|
||||
// Intentionally NOT calling stripRedundantSectionFills here: `cloned`
|
||||
// is an arbitrary PenNode from MCP/batch APIs (could be a card, a
|
||||
// component, or a page). strip must only run on the true page root
|
||||
// frame, which this path cannot guarantee.
|
||||
sanitizeLayoutChildPositions(node, false);
|
||||
sanitizeScreenFrameBounds(node);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -201,6 +201,40 @@ describe('stripRedundantSectionFills', () => {
|
|||
expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is strictly non-recursive: never touches grandchildren even when caller mis-targets a card', () => {
|
||||
// Defensive: if a caller accidentally hands us a card frame instead of
|
||||
// the page root, we must NOT recurse into it. Only the direct children
|
||||
// of the passed node are ever considered — and a card header (no role,
|
||||
// safe-dark fill) that is a DIRECT child of a card is still fair game,
|
||||
// but anything deeper is untouched.
|
||||
const deepInner = frame({
|
||||
id: 'deep',
|
||||
// no role, safe-dark — would normally be stripped, but is two levels
|
||||
// down so must survive
|
||||
fill: solidFill('#0A0A0A'),
|
||||
});
|
||||
const cardBody = frame({ id: 'body', children: [deepInner] });
|
||||
const cardHeader = frame({
|
||||
id: 'header',
|
||||
// no role, safe-dark — direct child of the mis-targeted parent, so
|
||||
// will still be stripped (the caller is at fault)
|
||||
fill: solidFill('#0A0A0A'),
|
||||
});
|
||||
const card = frame({
|
||||
id: 'card',
|
||||
role: 'card',
|
||||
fill: solidFill('#141414'),
|
||||
children: [cardHeader, cardBody],
|
||||
});
|
||||
// Deliberately mis-target the card (not the page root). This must NOT
|
||||
// crash and must NOT recurse into cardBody's grandchildren.
|
||||
stripRedundantSectionFills(card);
|
||||
// Card itself is untouched (we never touch the passed node)
|
||||
expect((card as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#141414'));
|
||||
// deepInner survives because strip is strictly non-recursive
|
||||
expect((deepInner as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
|
||||
});
|
||||
|
||||
it('reproduces the M2.7 health-tracker case', () => {
|
||||
// Direct repro of the actual failure: root #1a1a2e, six section roots
|
||||
// all hardcoded #0A0A0A, including one real card. The six section
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import type { PenNode, PenFill, SolidFill } from '@zseven-w/pen-types';
|
||||
|
||||
/**
|
||||
* Strip redundant "section-level" fills from direct children of the page
|
||||
* root frame.
|
||||
* Strip redundant "section-level" fills from the direct children of a
|
||||
* page root frame.
|
||||
*
|
||||
* Weaker sub-agents (MiniMax M2, GLM, Kimi) often hedge by writing a
|
||||
* hardcoded dark hex (`#0A0A0A`, `#111`, etc.) on every section root they
|
||||
|
|
@ -13,6 +13,14 @@ import type { PenNode, PenFill, SolidFill } from '@zseven-w/pen-types';
|
|||
* children of the root that either have no role or have a structural
|
||||
* role).
|
||||
*
|
||||
* ⚠️ SCOPE CONTRACT — callers MUST only pass the true page root frame.
|
||||
* Calling this on an arbitrary nested frame (a card, a component, a
|
||||
* sub-agent's own root that is NOT the page root) will incorrectly treat
|
||||
* that frame's inner children as "sections" and may erase intended
|
||||
* nested fills (e.g. a card's own dark header). The function itself is
|
||||
* strictly non-recursive — it only touches direct children of the passed
|
||||
* node — so mis-targeted calls are bounded, but still wrong.
|
||||
*
|
||||
* Returns `true` when any fill was stripped, so the caller can publish a
|
||||
* store update.
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue