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"