LearnNewsExamplesServices
Frontmatter
id13412
titlefix(ai): create_instance redo loses ntype/className — instantiation consumes them before reverse-capture (Pillar-2 Slice-2)
stateClosed
labels
bugaiarchitecture
assigneesneo-opus-vega
createdAtJun 16, 2026, 5:48 AM
updatedAtJun 16, 2026, 11:53 AM
githubUrlhttps://github.com/neomjs/neo/issues/13412
authorneo-opus-vega
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtJun 16, 2026, 11:53 AM

fix(ai): create_instance redo loses ntype/className — instantiation consumes them before reverse-capture (Pillar-2 Slice-2)

Closed v13.1.0/archive-v13-1-0-chunk-3 bugaiarchitecture
neo-opus-vega
neo-opus-vega commented on Jun 16, 2026, 5:48 AM

Context

Surfaced by #13306's redo e2e (test/playwright/e2e/NeuralLinkRedo.spec.mjs, branch fix/13306-nl-redo-e2e): a create_instance → undo → redo round-trip over the live bridge. Create + undo pass; redo fails:

{"redone":false,"reason":"redo-denied: create_instance: provide `className` or `ntype` to instantiate."}

So redo-of-a-created-component is non-functional: the re-dispatched forward-op has no ntype/className. (Undo works — this is redo-specific. The unit specs mock the transport, so they never exercised the live re-dispatch shape; the e2e did.)

Root cause (source-verified)

In src/ai/client/InstanceService.mjs#createInstance (~:92):

  1. :94 createConfig = buildCreateInstanceConfig(...) — folds the resolved ntype/className INTO createConfig (e.g. {id, text, ntype: 'button'}).
  2. :109 Neo.ntype(createConfig)Neo.create(className, createConfig)instance.construct(createConfig)instantiation consumes the meta keys (ntype/className) off createConfig.
  3. :126 the reverse is built from that same, now-mutated createConfig: buildCreateInstanceReverse({config: createConfig, ...}):286 forwardArgs = {config: safeSerialize(config)}.

safeSerialize preserves all object keys (it does NOT strip ntype), so the loss is upstream: createConfig no longer carries ntype/className by the time it's captured. On redo, handleRequest('create_instance', {config: {…no ntype…}, parentId})buildCreateInstanceConfig rejects with "provide className or ntype." The only mutation between the build (:94) and the capture (:126) is the instantiation at :109 — hence the cause.

The Fix

Snapshot the resolved config before instantiation consumes it, and capture from the snapshot:

const
    createConfig  = this.buildCreateInstanceConfig({className, config, ntype}),
    reverseConfig = {...createConfig};        // ntype/className intact, pre-instantiation
// …
const instance = createConfig.ntype ? Neo.ntype(createConfig) : Neo.create(createConfig);
// …
this.recordUndo(context, this.buildCreateInstanceReverse({config: reverseConfig, /* … */}));

Low-blast (capture-only; the create path itself is unchanged) and behavior-restoring (the redo contract — re-apply the undone create — is already documented; this fixes the broken implementation, so no new Contract Ledger per the contract-ledger.md bug-fix-restoring-documented-contract exclusion).

Acceptance Criteria

  • redo of a create_instance restores the component to the live tree + DOM (the NeuralLinkRedo.spec e2e goes green).
  • Unit coverage: the captured create_instance reverse forward-op retains ntype/className (a regression lock at the capture layer, not just e2e).
  • No regression in the existing InstanceServiceUndo* / capture specs.

Related

  • Blocks #13306 (the redo e2e proof — built, red on this bug; goes green with the fix).
  • Pillar-2 undo/redo: Slice-parent #9848; epic #13012; sibling Slice-1 #13221 (undo, shipped). set_instance_properties redo is unaffected (fully cyclic); this is the create-replay leg.

Release classification: post-release Pillar-2 bug-fix (not v13-release-blocking) → boardless.

Authored by Claude Opus 4.8 (Claude Code), @neo-opus-vega (Vega).

tobiu closed this issue on Jun 16, 2026, 11:53 AM