From 1186ee200d2665b9ac0365f0572d07cc6de9a65a Mon Sep 17 00:00:00 2001 From: HXSLtim <108641211+HXSLtim@users.noreply.github.com> Date: Sat, 3 Oct 2026 06:54:20 +0800 Subject: [PATCH] fix: reject duplicate chapter card creates in one volume A runaway agent turn (repeated parallel create calls or parent/child subagents creating the same chapter) could stack several empty same-named chapter cards in the manuscript directory, because: - allocateStableId seeds ids per tool call, so every duplicate create got a fresh id and never collided; - the same-run proposal overlay did not remember chapter.create, so preflight and id allocation never saw the pending chapter card; - chapter.create/chapter.update only validated id uniqueness, never title uniqueness within a volume. Fix in two layers: - contracts: applyChapterOperation now rejects chapter.create and title-renaming chapter.update when the same volume already holds a chapter card with that title (already_exists). This guards every path: agent tool preflight, renderer structure mutations and the Core apply side. - pi-runtime-adapter: the proposal overlay now remembers chapter.create operations and projects them into the same-run index view, so a repeated create inside one run (including across parent and child agents sharing the run state) fails preflight immediately with the existing friendly already_exists guidance instead of forming another proposal card. Titles stay free across volumes, and distinct titles in one volume are unaffected. --- ...perations.chapter-title-uniqueness.test.ts | 131 ++++++++++++++++++ .../apply-chapter.ts | 42 ++++++ .../long-agent-tools.duplicate-create.test.ts | 90 ++++++++++++ .../src/long-agent-tools/proposal-overlay.ts | 17 +++ 4 files changed, 280 insertions(+) create mode 100644 packages/contracts/src/long-workspace-operations.chapter-title-uniqueness.test.ts create mode 100644 packages/pi-runtime-adapter/src/long-agent-tools.duplicate-create.test.ts diff --git a/packages/contracts/src/long-workspace-operations.chapter-title-uniqueness.test.ts b/packages/contracts/src/long-workspace-operations.chapter-title-uniqueness.test.ts new file mode 100644 index 00000000..2eac42a6 --- /dev/null +++ b/packages/contracts/src/long-workspace-operations.chapter-title-uniqueness.test.ts @@ -0,0 +1,131 @@ +import { + applyPreviewedLongWorkspaceOperations, + chapterFiles, + describe, + expect, + expectOperationError, + it, + later, + previewLongWorkspaceOperations, + workspace +} from "./long-workspace-operations.test-support"; + +function chapterCreateOperation(input?: { + id?: string; + volumeId?: string; + title?: string; +}) { + const id = input?.id ?? "chapter_duplicate"; + return { + type: "chapter.create" as const, + chapterCard: { + id, + volumeId: input?.volumeId ?? "volume_one", + primaryArcId: null, + title: input?.title ?? "雨夜来信", + narrativeOrder: 3 + }, + files: chapterFiles(id, "duplicate") + }; +} + +describe("long workspace operation engine: chapter card title uniqueness", () => { + it("rejects creating a chapter card with a duplicate title in the same volume", () => { + expectOperationError( + () => + previewLongWorkspaceOperations(workspace(), { + updatedAt: later, + operations: [chapterCreateOperation()] + }), + "already_exists" + ); + }); + + it("allows the same chapter title in another volume", () => { + const result = applyPreviewedLongWorkspaceOperations(workspace(), { + updatedAt: later, + operations: [ + { + type: "volume.create", + volume: { + id: "volume_two", + title: "第二卷", + order: 2, + summary: "" + } + }, + chapterCreateOperation({ + id: "chapter_other_volume", + volumeId: "volume_two" + }) + ] + }); + expect( + result.snapshot.plot.chapterCards.filter( + ({ title }) => title === "雨夜来信" + ) + ).toHaveLength(2); + }); + + it("allows a distinct chapter title in the same volume", () => { + const result = applyPreviewedLongWorkspaceOperations(workspace(), { + updatedAt: later, + operations: [chapterCreateOperation({ title: "码头疑云" })] + }); + expect( + result.snapshot.plot.chapterCards.find( + ({ title }) => title === "码头疑云" + ) + ).toMatchObject({ volumeId: "volume_one" }); + }); + + it("rejects renaming a chapter card to a title already used in its volume", () => { + expectOperationError( + () => + previewLongWorkspaceOperations(workspace(), { + updatedAt: later, + operations: [ + { + type: "chapter.update", + id: "chapter_one", + patch: { title: "旧钟楼" } + } + ] + }), + "already_exists" + ); + }); + + it("allows renaming a chapter card to a fresh title or keeping its own", () => { + const renamed = applyPreviewedLongWorkspaceOperations(workspace(), { + updatedAt: later, + operations: [ + { + type: "chapter.update", + id: "chapter_one", + patch: { title: "雾中回信" } + } + ] + }); + expect( + renamed.snapshot.plot.chapterCards.find(({ id }) => id === "chapter_one") + ?.title + ).toBe("雾中回信"); + + const kept = applyPreviewedLongWorkspaceOperations(workspace(), { + updatedAt: later, + operations: [ + { + type: "chapter.update", + id: "chapter_one", + patch: { title: "雨夜来信" } + } + ] + }); + expect( + kept.snapshot.plot.chapterCards.filter( + ({ title }) => title === "雨夜来信" + ) + ).toHaveLength(1); + }); +}); diff --git a/packages/contracts/src/long-workspace-operations/apply-chapter.ts b/packages/contracts/src/long-workspace-operations/apply-chapter.ts index d04484c6..bde84aa7 100644 --- a/packages/contracts/src/long-workspace-operations/apply-chapter.ts +++ b/packages/contracts/src/long-workspace-operations/apply-chapter.ts @@ -1,3 +1,4 @@ +import type { LongWorkspaceIndexSnapshot } from "../long-workspace"; import type { LongWorkspaceOperation } from "./operation-schema"; import type { MutationState } from "./state"; import { deleteChapter } from "./cascade"; @@ -19,6 +20,31 @@ import { updateOrdersById } from "./state"; +/** + * Chapter card titles must stay unique within a volume so duplicate agent + * tool calls (or repeated proposals) cannot stack same-named empty cards in + * the manuscript directory. + */ +function assertChapterTitleAvailable( + workspace: LongWorkspaceIndexSnapshot, + volumeId: string, + title: string, + exceptChapterCardId?: string +): void { + const duplicated = workspace.plot.chapterCards.some( + (candidate) => + candidate.id !== exceptChapterCardId && + candidate.volumeId === volumeId && + candidate.title === title + ); + if (duplicated) { + operationError( + "already_exists", + `Chapter card titled "${title}" already exists in this volume.` + ); + } +} + export function applyChapterOperation( state: MutationState, operation: LongWorkspaceOperation @@ -31,6 +57,11 @@ export function applyChapterOperation( operation.chapterCard.id, "Chapter card" ); + assertChapterTitleAvailable( + workspace, + operation.chapterCard.volumeId, + operation.chapterCard.title + ); if ( operation.files.chapterCardId !== operation.chapterCard.id || operation.files.commitId !== null @@ -85,6 +116,17 @@ export function applyChapterOperation( "Chapter card" ) ]!; + if ( + operation.patch.title !== undefined && + operation.patch.title !== chapter.title + ) { + assertChapterTitleAvailable( + workspace, + chapter.volumeId, + operation.patch.title, + chapter.id + ); + } Object.assign(chapter, operation.patch); markUpdated(state, chapter.id); break; diff --git a/packages/pi-runtime-adapter/src/long-agent-tools.duplicate-create.test.ts b/packages/pi-runtime-adapter/src/long-agent-tools.duplicate-create.test.ts new file mode 100644 index 00000000..cb15f5b7 --- /dev/null +++ b/packages/pi-runtime-adapter/src/long-agent-tools.duplicate-create.test.ts @@ -0,0 +1,90 @@ +import { createLongWorkspaceToolSharedState } from "./long-agent-tools"; +import { + describe, + documentExecutor, + expect, + fixtureIndex, + it, + longTools, + resultText, + toolByName +} from "./long-agent-tools.test-support"; + +const DUPLICATE_CHAPTER_PARAMS = { + kind: "chapter_card", + meta: { title: "第二章", volume_id: "volume_one" }, + summary: "新建第二章" +} as const; + +describe("unified long-form tools: duplicate chapter card creates", () => { + it("rejects a repeated same-title chapter card create within one run", async () => { + const tools = longTools({ executor: documentExecutor(fixtureIndex()) }); + const create = toolByName(tools, "create"); + + const first = await create.execute("create-chapter-first", { + ...DUPLICATE_CHAPTER_PARAMS + }); + expect(first.details).toMatchObject({ + kind: "long-mutation-proposal", + batch: { + operations: [expect.objectContaining({ type: "chapter.create" })] + } + }); + + const second = await create.execute("create-chapter-second", { + ...DUPLICATE_CHAPTER_PARAMS + }); + expect(resultText(second)).toContain("目标条目已经存在"); + expect(resultText(second)).toContain("第二章"); + expect(second.details).toMatchObject({ kind: "none" }); + + const third = await create.execute("create-chapter-third", { + ...DUPLICATE_CHAPTER_PARAMS + }); + expect(resultText(third)).toContain("目标条目已经存在"); + }); + + it("still creates distinct chapter cards with different titles", async () => { + const tools = longTools({ executor: documentExecutor(fixtureIndex()) }); + const create = toolByName(tools, "create"); + + await create.execute("create-chapter-second", DUPLICATE_CHAPTER_PARAMS); + const other = await create.execute("create-chapter-third", { + kind: "chapter_card", + meta: { title: "第三章", volume_id: "volume_one" }, + summary: "新建第三章" + }); + expect(other.details).toMatchObject({ + kind: "long-mutation-proposal", + batch: { + operations: [ + expect.objectContaining({ + type: "chapter.create", + chapterCard: expect.objectContaining({ title: "第三章" }) + }) + ] + } + }); + }); + + it("rejects duplicate chapter card creates across parent and child agents", async () => { + const executor = documentExecutor(fixtureIndex()); + const sharedState = createLongWorkspaceToolSharedState(); + const parentTools = longTools({ executor, sharedState }); + const childTools = longTools({ executor, sharedState }); + + const parentProposal = await toolByName(parentTools, "create").execute( + "parent-create-chapter", + DUPLICATE_CHAPTER_PARAMS + ); + expect(parentProposal.details).toMatchObject({ + kind: "long-mutation-proposal" + }); + + const childDuplicate = await toolByName(childTools, "create").execute( + "child-create-chapter", + DUPLICATE_CHAPTER_PARAMS + ); + expect(resultText(childDuplicate)).toContain("目标条目已经存在"); + }); +}); diff --git a/packages/pi-runtime-adapter/src/long-agent-tools/proposal-overlay.ts b/packages/pi-runtime-adapter/src/long-agent-tools/proposal-overlay.ts index 7f68c4bc..829c3a0b 100644 --- a/packages/pi-runtime-adapter/src/long-agent-tools/proposal-overlay.ts +++ b/packages/pi-runtime-adapter/src/long-agent-tools/proposal-overlay.ts @@ -18,6 +18,22 @@ function applyStructure( index: LongWorkspaceIndexSnapshot, operation: LongWorkspaceOperation ): void { + if (operation.type === "chapter.create") { + if ( + index.plot.chapterCards.some(({ id }) => id === operation.chapterCard.id) + ) { + return; + } + index.plot.chapterCards.push(structuredClone(operation.chapterCard)); + if ( + !index.chapters.some( + ({ chapterCardId }) => chapterCardId === operation.chapterCard.id + ) + ) { + index.chapters.push(structuredClone(operation.files)); + } + return; + } if (operation.type === "storyPlot.create") { if ( index.plot.storyPlots.some(({ id }) => id === operation.storyPlot.id) || @@ -100,6 +116,7 @@ export function createProposalOverlay() { ): void { for (const operation of operations) { if ( + operation.type === "chapter.create" || operation.type === "chapterContinuity.worldReveals.create" || operation.type === "chapterContinuity.character.create" || operation.type === "storyPlot.create"