fix(ai): JSONL fallback also detects duplicate ids inside the payload

Prior precheck only compared the JSONL payload's ids against the live
doc — it didn't detect duplicates within the payload itself. A model
emitting the same id twice (two roots sharing an id, a nested child
reusing a root id, etc) would still slip past: addNode appends both
copies, getNodeById returns the first match for both verifies, and a
later rollback removeNode would delete only one of the two duplicates,
leaving an orphan with the same id in the doc.

Detect internal duplicates during the same id-collection pass:
collectIds tracks `treeIds` as a Set and pushes any id seen twice into
`internalDuplicates`. If non-empty, return `failed` immediately with
the duplicate list — same shape as the existing live-doc collision
branch, no doc mutation, no side effects, retry path takes over.
This commit is contained in:
Fini 2026-05-05 01:18:24 +08:00
parent 614a3b2040
commit 78341ca23f

View file

@ -499,14 +499,38 @@ function applyBatchDesignAsJsonl(jsonl: string, ctx: DispatchContext): DispatchR
// later rollback `removeNode(id)` would delete the pre-existing node
// instead of the freshly-added duplicate. Detect collisions before
// mutating the doc so we can fail cleanly without side effects.
//
// Two collision sources to guard:
// 1. Internal: the JSONL payload itself contains the same id twice
// (two roots with the same id, a nested child reusing a root id,
// etc) — addNode would append both copies and confuse the verify
// / rollback path the same way as a live-doc collision.
// 2. External: an id in the payload already exists in the live doc.
const treeIds = new Set<string>();
const internalDuplicates: string[] = [];
const collectIds = (n: PenNode) => {
treeIds.add(n.id);
if (treeIds.has(n.id)) {
internalDuplicates.push(n.id);
} else {
treeIds.add(n.id);
}
if ('children' in n && Array.isArray((n as PenNode & { children?: PenNode[] }).children)) {
for (const child of (n as PenNode & { children: PenNode[] }).children) collectIds(child);
}
};
for (const root of tree) collectIds(root);
if (internalDuplicates.length > 0) {
const list = `${internalDuplicates.slice(0, 4).join(', ')}${internalDuplicates.length > 4 ? '…' : ''}`;
return {
status: 'failed',
route: 'batch-design-dsl',
toolName: 'batch_design',
message:
`batch_design.operations JSONL has ${internalDuplicates.length} duplicate id(s) within ` +
`the payload itself: ${list}. Refusing to insert — doc state preserved.`,
insertedNodes: [],
};
}
const collisions: string[] = [];
for (const id of treeIds) {
if (store.getNodeById(id)) collisions.push(id);