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:
Fini 2026-04-06 18:21:37 +08:00
parent b4d35bb185
commit 320ba104eb
3 changed files with 66 additions and 18 deletions

View file

@ -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);
}

View file

@ -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

View file

@ -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.
*/