Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
});
});
42 changes: 42 additions & 0 deletions packages/contracts/src/long-workspace-operations/apply-chapter.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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("目标条目已经存在");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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) ||
Expand Down Expand Up @@ -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"
Expand Down