mirror of
https://github.com/Narcooo/inkos.git
synced 2026-08-28 14:57:58 +08:00
fix(agent): run multi-chapter writes as one sequential task
This commit is contained in:
@@ -680,6 +680,39 @@ describe("agent deterministic writing tools", () => {
|
||||
expect(pipeline.writeNextChapter).toHaveBeenCalledWith("harbor", 2600);
|
||||
});
|
||||
|
||||
it("runs a requested chapter batch through one writer operation", async () => {
|
||||
const pipeline = {
|
||||
writeNextChapter: vi.fn(),
|
||||
writeChapters: vi.fn(async () => [
|
||||
{ chapterNumber: 4, title: "第四章", wordCount: 2600, status: "ready-for-review" },
|
||||
{ chapterNumber: 5, title: "第五章", wordCount: 2550, status: "ready-for-review" },
|
||||
{ chapterNumber: 6, title: "第六章", wordCount: 2490, status: "audit-failed" },
|
||||
]),
|
||||
};
|
||||
const tool = createSubAgentTool(pipeline as never, "harbor");
|
||||
|
||||
const result = await tool.execute("tool-writer-batch", {
|
||||
agent: "writer",
|
||||
bookId: "harbor",
|
||||
chapterCount: 5,
|
||||
chapterWordCount: 2600,
|
||||
instruction: "连续写五章",
|
||||
} as any);
|
||||
|
||||
expect(pipeline.writeChapters).toHaveBeenCalledWith(
|
||||
"harbor",
|
||||
5,
|
||||
expect.objectContaining({ wordCount: 2600 }),
|
||||
);
|
||||
expect(pipeline.writeNextChapter).not.toHaveBeenCalled();
|
||||
expect(result.details).toMatchObject({
|
||||
kind: "chapters_written",
|
||||
requestedCount: 5,
|
||||
completedCount: 3,
|
||||
stoppedStatus: "audit-failed",
|
||||
});
|
||||
});
|
||||
|
||||
it("runs the writer pipeline inside the tool AbortSignal scope", async () => {
|
||||
const controller = new AbortController();
|
||||
const pipeline = {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { PipelineRunner, type ChapterPipelineResult } from "../pipeline/runner.js";
|
||||
|
||||
function chapter(
|
||||
chapterNumber: number,
|
||||
status: ChapterPipelineResult["status"] = "ready-for-review",
|
||||
): ChapterPipelineResult {
|
||||
return {
|
||||
chapterNumber,
|
||||
title: `Chapter ${chapterNumber}`,
|
||||
wordCount: 1800,
|
||||
auditResult: {
|
||||
passed: status === "ready-for-review",
|
||||
issues: [],
|
||||
summary: status,
|
||||
overallScore: status === "ready-for-review" ? 90 : 50,
|
||||
tokenUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
||||
},
|
||||
revised: false,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PipelineRunner.writeChapters", () => {
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it("holds one book lock while writing sequential chapters", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "inkos-batch-"));
|
||||
roots.push(root);
|
||||
const runner = new PipelineRunner({
|
||||
client: {} as never,
|
||||
model: "test-model",
|
||||
projectRoot: root,
|
||||
});
|
||||
const release = vi.fn(async () => undefined);
|
||||
const acquireBookLock = vi.fn(async () => release);
|
||||
const writeLocked = vi.fn()
|
||||
.mockResolvedValueOnce(chapter(3))
|
||||
.mockResolvedValueOnce(chapter(4))
|
||||
.mockResolvedValueOnce(chapter(5));
|
||||
const onChapterComplete = vi.fn();
|
||||
const internals = runner as unknown as {
|
||||
state: { acquireBookLock: typeof acquireBookLock };
|
||||
_writeNextChapterLocked: typeof writeLocked;
|
||||
};
|
||||
internals.state = { acquireBookLock };
|
||||
internals._writeNextChapterLocked = writeLocked;
|
||||
|
||||
const results = await runner.writeChapters("demo-book", 3, { onChapterComplete });
|
||||
|
||||
expect(results.map((result) => result.chapterNumber)).toEqual([3, 4, 5]);
|
||||
expect(acquireBookLock).toHaveBeenCalledOnce();
|
||||
expect(acquireBookLock).toHaveBeenCalledWith("demo-book");
|
||||
expect(writeLocked).toHaveBeenCalledTimes(3);
|
||||
expect(onChapterComplete).toHaveBeenNthCalledWith(1, chapter(3), 1, 3);
|
||||
expect(onChapterComplete).toHaveBeenNthCalledWith(3, chapter(5), 3, 3);
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("stops the batch after the first chapter that needs review", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "inkos-batch-"));
|
||||
roots.push(root);
|
||||
const runner = new PipelineRunner({
|
||||
client: {} as never,
|
||||
model: "test-model",
|
||||
projectRoot: root,
|
||||
});
|
||||
const release = vi.fn(async () => undefined);
|
||||
const writeLocked = vi.fn()
|
||||
.mockResolvedValueOnce(chapter(8, "audit-failed"))
|
||||
.mockResolvedValueOnce(chapter(9));
|
||||
const internals = runner as unknown as {
|
||||
state: { acquireBookLock: () => Promise<typeof release> };
|
||||
_writeNextChapterLocked: typeof writeLocked;
|
||||
};
|
||||
internals.state = { acquireBookLock: async () => release };
|
||||
internals._writeNextChapterLocked = writeLocked;
|
||||
|
||||
const results = await runner.writeChapters("demo-book", 5);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.status).toBe("audit-failed");
|
||||
expect(writeLocked).toHaveBeenCalledOnce();
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects invalid batch sizes before taking the lock", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "inkos-batch-"));
|
||||
roots.push(root);
|
||||
const runner = new PipelineRunner({
|
||||
client: {} as never,
|
||||
model: "test-model",
|
||||
projectRoot: root,
|
||||
});
|
||||
|
||||
await expect(runner.writeChapters("demo-book", 0)).rejects.toThrow(/chapterCount/i);
|
||||
await expect(runner.writeChapters("demo-book", 21)).rejects.toThrow(/chapterCount/i);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AutomationModeSchema,
|
||||
ActionPayloadSchema,
|
||||
ActionSourceSchema,
|
||||
BookCreationDraftSchema,
|
||||
InteractionIntentTypeSchema,
|
||||
@@ -67,6 +68,15 @@ describe("interaction models", () => {
|
||||
expect(normalizeRequestedIntent("")).toBeUndefined();
|
||||
expect(normalizePlayMode("open")).toBe("open");
|
||||
expect(normalizePlayMode(null)).toBeUndefined();
|
||||
|
||||
expect(ActionPayloadSchema.parse({
|
||||
writeNext: { chapterCount: 5 },
|
||||
})).toEqual({
|
||||
writeNext: { chapterCount: 5 },
|
||||
});
|
||||
expect(ActionPayloadSchema.safeParse({
|
||||
writeNext: { chapterCount: 21 },
|
||||
}).success).toBe(false);
|
||||
});
|
||||
|
||||
it("validates structured script and storyboard creation payloads", () => {
|
||||
@@ -118,6 +128,9 @@ describe("interaction models", () => {
|
||||
expect(isExplicitWriteChapterCommand("开始写第一章。")).toBe(true);
|
||||
expect(isExplicitWriteChapterCommand("请写下一章,写完后落盘。")).toBe(true);
|
||||
expect(isExplicitWriteChapterCommand("write chapter 1")).toBe(true);
|
||||
expect(isExplicitWriteChapterCommand("连续写5章")).toBe(false);
|
||||
expect(isExplicitWriteChapterCommand("write 5 chapters")).toBe(false);
|
||||
expect(isExplicitWriteChapterCommand("写第5章")).toBe(false);
|
||||
expect(isExplicitWriteChapterCommand("继续")).toBe(false);
|
||||
expect(isExplicitWriteChapterCommand("我们讨论一下要不要写下一章")).toBe(false);
|
||||
expect(isExplicitWriteChapterCommand("我觉得第一章应该怎么写?")).toBe(false);
|
||||
|
||||
@@ -515,7 +515,7 @@ function buildBookPrompt(bookId: string, isZh: boolean): string {
|
||||
## 可用工具
|
||||
|
||||
- sub_agent:委托子智能体执行当前书重操作:
|
||||
- agent="writer" 续写下一章,永远接着最后一章往下写,不能指定章节号。参数:chapterWordCount。
|
||||
- agent="writer" 从最后一章继续顺序写,不能指定任意章节号。参数:chapterCount(连续写几章,1-20,默认 1)、chapterWordCount。
|
||||
- agent="auditor" 审计已有章节。参数:chapterNumber 指定第几章;不传则审最新章。
|
||||
- agent="reviser" 修改已有章节。必须传 chapterNumber。参数:chapterNumber, mode: spot-fix/polish/rewrite/rework/anti-detect。
|
||||
- agent="exporter" 导出书籍。参数:format: txt/md/epub, approvedOnly: true/false。
|
||||
@@ -539,6 +539,7 @@ function buildBookPrompt(bookId: string, isZh: boolean): string {
|
||||
- 用户要求续写、写下一章、继续正文时,必须调用 sub_agent(agent="writer");不要先 read/ls 再自己写正文。
|
||||
- sub_agent 成功返回后,本轮直接结束。不要继续调用 read、ls、patch_chapter_text,也不要再补写正文。
|
||||
- 用户说“写下一章 / 继续写 / 再来一章” → sub_agent(agent="writer")。
|
||||
- 用户说“连续写 N 章 / 再写 N 章” → 只调用一次 sub_agent(agent="writer", chapterCount=N),不要重复或并发调用 writer。
|
||||
- 用户说“审第 N 章 / 看看这一章问题” → sub_agent(agent="auditor", chapterNumber=N)。
|
||||
- 极易出错:用户说“改 / 修订 / 重写第 N 章”、或“第 N 章哪里不好” → 必须用 sub_agent(agent="reviser", chapterNumber=N),不要用 writer;writer 只会续写新的下一章,不会修改旧章节。
|
||||
- 极易出错:用户说“写下一章 / 继续写 / 再来一章” → 才用 sub_agent(agent="writer"),不要把它理解成 reviser。
|
||||
@@ -575,7 +576,7 @@ ${commonOutputRules(true)}`
|
||||
## Available Tools
|
||||
|
||||
- sub_agent: delegate active-book heavy operations:
|
||||
- agent="writer" writes the next chapter, always appending after the latest chapter. It cannot target a specific chapter number. Params: chapterWordCount.
|
||||
- agent="writer" writes forward from the latest chapter. It cannot target an arbitrary chapter number. Params: chapterCount (1-20 consecutive chapters, default 1), chapterWordCount.
|
||||
- agent="auditor" audits an existing chapter. Params: chapterNumber; omit for latest.
|
||||
- agent="reviser" revises an existing chapter. chapterNumber is required. Params: chapterNumber, mode: spot-fix/polish/rewrite/rework/anti-detect.
|
||||
- agent="exporter" exports the book. Params: format: txt/md/epub, approvedOnly: true/false.
|
||||
@@ -599,6 +600,7 @@ ${commonOutputRules(true)}`
|
||||
- When the user asks to continue or write the next chapter, you must call sub_agent(agent="writer"); do not read/list files first and then write prose yourself.
|
||||
- After a successful sub_agent result, end the current turn immediately. Do not keep calling read, ls, patch_chapter_text, or add extra prose.
|
||||
- "write next / continue / one more chapter" → sub_agent(agent="writer").
|
||||
- "write N consecutive chapters / write N more chapters" → call sub_agent once with agent="writer", chapterCount=N; never repeat or parallelize writer calls.
|
||||
- "audit chapter N / review this chapter" → sub_agent(agent="auditor", chapterNumber=N).
|
||||
- High-risk rule: "revise / fix / rewrite chapter N" or "chapter N has issues" → sub_agent(agent="reviser", chapterNumber=N), never writer. writer only appends a new next chapter; it does not edit an old chapter.
|
||||
- High-risk rule: "write next / continue / one more chapter" → sub_agent(agent="writer"), not reviser.
|
||||
|
||||
@@ -566,6 +566,11 @@ const SubAgentParams = Type.Object({
|
||||
description: "Optional book ID. In active-book sessions, omit it to use the current active book; if provided, it must match the current active book. For architect creation, this optionally sets the new book ID.",
|
||||
})),
|
||||
chapterNumber: Type.Optional(Type.Number({ description: "auditor/reviser: target chapter number. Omit to use the latest chapter." })),
|
||||
chapterCount: Type.Optional(Type.Integer({
|
||||
minimum: 1,
|
||||
maximum: 20,
|
||||
description: "writer only: number of consecutive new chapters to write in this operation. Default: 1. InkOS writes them sequentially under one book lock.",
|
||||
})),
|
||||
// -- architect params --
|
||||
title: Type.Optional(Type.String({ description: "architect only: explicit book title. Required when creating a book." })),
|
||||
genre: Type.Optional(Type.String({ description: "architect only: genre (xuanhuan, urban, mystery, romance, scifi, fantasy, wuxia, general, etc.)" })),
|
||||
@@ -687,7 +692,7 @@ export function createSubAgentTool(
|
||||
_signal?: AbortSignal,
|
||||
onUpdate?: AgentToolUpdateCallback,
|
||||
): Promise<AgentToolResult<unknown>> {
|
||||
const { agent, instruction, bookId, title, chapterNumber, genre, platform, language, targetChapters, chapterWordCount, revise, feedback, mode, format, approvedOnly } = params;
|
||||
const { agent, instruction, bookId, title, chapterNumber, chapterCount, genre, platform, language, targetChapters, chapterWordCount, revise, feedback, mode, format, approvedOnly } = params;
|
||||
|
||||
const progress = (msg: string) => {
|
||||
onUpdate?.(textResult(msg));
|
||||
@@ -770,6 +775,40 @@ export function createSubAgentTool(
|
||||
|
||||
case "writer": {
|
||||
const targetBookId = resolveToolBookId("writer", bookId, activeBookId);
|
||||
const requestedCount = chapterCount ?? 1;
|
||||
if (requestedCount > 1) {
|
||||
progress(`Writing ${requestedCount} consecutive chapters for "${targetBookId}"...`);
|
||||
const results = await runPipelineWithAbortSignal(
|
||||
pipeline,
|
||||
_signal,
|
||||
() => pipeline.writeChapters(targetBookId, requestedCount, {
|
||||
wordCount: chapterWordCount,
|
||||
onChapterComplete(result, completedCount, totalCount) {
|
||||
progress(`Writer finished chapter ${result.chapterNumber} (${completedCount}/${totalCount}) for "${targetBookId}".`);
|
||||
},
|
||||
}),
|
||||
);
|
||||
const last = results.at(-1);
|
||||
const stoppedStatus = last?.status !== "ready-for-review" ? last?.status : undefined;
|
||||
return textResult(
|
||||
stoppedStatus
|
||||
? `Writer completed ${results.length} of ${requestedCount} requested chapters for "${targetBookId}" and stopped because chapter ${last?.chapterNumber} ended with status "${stoppedStatus}".`
|
||||
: `Writer completed ${results.length} consecutive chapters for "${targetBookId}".`,
|
||||
{
|
||||
kind: "chapters_written",
|
||||
bookId: targetBookId,
|
||||
requestedCount,
|
||||
completedCount: results.length,
|
||||
chapters: results.map((result) => ({
|
||||
chapterNumber: result.chapterNumber,
|
||||
title: result.title,
|
||||
wordCount: result.wordCount,
|
||||
status: result.status,
|
||||
})),
|
||||
...(stoppedStatus ? { stoppedStatus } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
progress(`Writing next chapter for "${targetBookId}"...`);
|
||||
const result = await runPipelineWithAbortSignal(
|
||||
pipeline,
|
||||
|
||||
@@ -219,6 +219,7 @@ export {
|
||||
ScriptTargetFormatSchema,
|
||||
ShortRunActionPayloadSchema,
|
||||
StoryboardCreateActionPayloadSchema,
|
||||
WriteNextActionPayloadSchema,
|
||||
type ActionSource,
|
||||
type ActionPayload,
|
||||
type RequestedIntent,
|
||||
@@ -534,7 +535,7 @@ export { arbitrateRuntimeStateDeltaHooks, type HookArbiterDecision } from "./uti
|
||||
export { analyzeHookHealth } from "./utils/hook-health.js";
|
||||
|
||||
// Pipeline
|
||||
export { PipelineRunner, type PipelineConfig, type ChapterPipelineResult, type DraftResult, type PlanChapterResult, type ComposeChapterResult, type ReviseResult, type TruthFiles, type BookStatusInfo, type ImportChaptersInput, type ImportChaptersResult, type TokenUsageSummary } from "./pipeline/runner.js";
|
||||
export { PipelineRunner, type PipelineConfig, type ChapterPipelineResult, type WriteChaptersOptions, type DraftResult, type PlanChapterResult, type ComposeChapterResult, type ReviseResult, type TruthFiles, type BookStatusInfo, type ImportChaptersInput, type ImportChaptersResult, type TokenUsageSummary } from "./pipeline/runner.js";
|
||||
export { Scheduler, type SchedulerConfig } from "./pipeline/scheduler.js";
|
||||
export { detectChapter, detectAndRewrite, loadDetectionHistory, type DetectChapterResult, type DetectAndRewriteResult } from "./pipeline/detection-runner.js";
|
||||
export { runScriptCreation, runStoryboardCreation, runInteractiveFilmCreation, createStoryboardAssetsManifest, type ScriptCreationRunOptions, type ScriptCreationRunResult, type StoryboardAssetsManifest, type StoryboardCreationRunOptions, type StoryboardCreationRunResult, type InteractiveFilmCreationRunOptions, type InteractiveFilmCreationRunResult, type StoryboardImageAsset, type StoryboardImageAssetVariant } from "./pipeline/script-storyboard-runner.js";
|
||||
|
||||
@@ -47,6 +47,10 @@ export const CreateBookActionPayloadSchema = z.object({
|
||||
chapterWordCount: z.number().int().min(1).optional(),
|
||||
}).strict();
|
||||
|
||||
export const WriteNextActionPayloadSchema = z.object({
|
||||
chapterCount: z.number().int().min(1).max(20).default(1),
|
||||
}).strict();
|
||||
|
||||
// charsPerChapter 的单位随语言变化:zh 是每章汉字数(900-1200),en 是每章英文单词数(600-800)。
|
||||
// 这两个区间与 short-fiction-runner 的执行层校验共用同一组常量,保证确认卡和执行层不再各说各话。
|
||||
export function shortRunCharsPerChapterRange(language: "zh" | "en"): {
|
||||
@@ -167,6 +171,7 @@ export const TranslationCreateActionPayloadSchema = z.object({
|
||||
|
||||
export const ActionPayloadSchema = z.object({
|
||||
createBook: CreateBookActionPayloadSchema.optional(),
|
||||
writeNext: WriteNextActionPayloadSchema.optional(),
|
||||
shortRun: ShortRunActionPayloadSchema.optional(),
|
||||
playStart: PlayStartActionPayloadSchema.optional(),
|
||||
generateCover: GenerateCoverActionPayloadSchema.optional(),
|
||||
@@ -251,8 +256,8 @@ export function isExplicitWriteChapterCommand(instruction: string): boolean {
|
||||
if (!trimmed) return false;
|
||||
|
||||
const zhWriteChapter =
|
||||
/^(?:请|帮我|麻烦|现在|直接|开始|继续|接着|再)?\s*(?:写|续写|创作|生成)(?:出|一下)?\s*(?:第?\s*[一二三四五六七八九十百千万\d]+\s*章|下一章|一章|正文|章节)(?:\s|[,。,.!!??;;::]|$)/.test(trimmed);
|
||||
/^(?:请|帮我|麻烦|现在|直接|开始|继续|接着|再)?\s*(?:写|续写|创作|生成)(?:出|一下)?\s*(?:第?\s*一\s*章|第?\s*1\s*章|下一章|一章|正文|章节)(?:\s|[,。,.!!??;;::]|$)/.test(trimmed);
|
||||
if (zhWriteChapter) return true;
|
||||
|
||||
return /^(?:please\s+)?(?:write|continue|draft|generate)\s+(?:the\s+)?(?:next\s+)?chapter(?:\s+\d+|\s+one)?\b/i.test(trimmed);
|
||||
return /^(?:please\s+)?(?:write|continue|draft|generate)\s+(?:(?:the\s+)?next\s+chapter|chapter(?:\s+(?:1|one))?)\b/i.test(trimmed);
|
||||
}
|
||||
|
||||
@@ -303,6 +303,16 @@ export interface ChapterPipelineResult {
|
||||
readonly tokenUsage?: TokenUsageSummary;
|
||||
}
|
||||
|
||||
export interface WriteChaptersOptions {
|
||||
readonly wordCount?: number;
|
||||
readonly temperatureOverride?: number;
|
||||
readonly onChapterComplete?: (
|
||||
result: ChapterPipelineResult,
|
||||
completedCount: number,
|
||||
requestedCount: number,
|
||||
) => void;
|
||||
}
|
||||
|
||||
// Atomic operation results
|
||||
export interface DraftResult {
|
||||
readonly chapterNumber: number;
|
||||
@@ -1672,6 +1682,37 @@ export class PipelineRunner {
|
||||
}
|
||||
}
|
||||
|
||||
async writeChapters(
|
||||
bookId: string,
|
||||
chapterCount: number,
|
||||
options: WriteChaptersOptions = {},
|
||||
): Promise<ReadonlyArray<ChapterPipelineResult>> {
|
||||
if (!Number.isInteger(chapterCount) || chapterCount < 1 || chapterCount > 20) {
|
||||
throw new Error(`chapterCount must be an integer between 1 and 20; received ${chapterCount}.`);
|
||||
}
|
||||
|
||||
this.throwIfOperationAborted();
|
||||
const releaseLock = await this.state.acquireBookLock(bookId);
|
||||
try {
|
||||
const results: ChapterPipelineResult[] = [];
|
||||
for (let index = 0; index < chapterCount; index += 1) {
|
||||
this.throwIfOperationAborted();
|
||||
const result = await this._writeNextChapterLocked(
|
||||
bookId,
|
||||
options.wordCount,
|
||||
options.temperatureOverride,
|
||||
this.config.externalContext,
|
||||
);
|
||||
results.push(result);
|
||||
options.onChapterComplete?.(result, results.length, chapterCount);
|
||||
if (result.status !== "ready-for-review") break;
|
||||
}
|
||||
return results;
|
||||
} finally {
|
||||
await releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
async repairChapterState(bookId: string, chapterNumber?: number): Promise<ChapterPipelineResult> {
|
||||
const releaseLock = await this.state.acquireBookLock(bookId);
|
||||
try {
|
||||
|
||||
@@ -18,6 +18,7 @@ const evaluateBookQualityMock = vi.fn();
|
||||
const reviseDraftMock = vi.fn();
|
||||
const resyncChapterArtifactsMock = vi.fn();
|
||||
const writeNextChapterMock = vi.fn();
|
||||
const writeChaptersMock = vi.fn();
|
||||
const rollbackToChapterMock = vi.fn();
|
||||
const saveChapterIndexMock = vi.fn();
|
||||
const loadChapterIndexMock = vi.fn();
|
||||
@@ -217,6 +218,7 @@ vi.mock("@actalk/inkos-core", async (importOriginal) => {
|
||||
reviseDraft = reviseDraftMock;
|
||||
resyncChapterArtifacts = resyncChapterArtifactsMock;
|
||||
writeNextChapter = writeNextChapterMock;
|
||||
writeChapters = writeChaptersMock;
|
||||
}
|
||||
|
||||
class MockConsolidatorAgent {
|
||||
@@ -412,6 +414,7 @@ describe("createStudioServer daemon lifecycle", () => {
|
||||
reviseDraftMock.mockReset();
|
||||
resyncChapterArtifactsMock.mockReset();
|
||||
writeNextChapterMock.mockReset();
|
||||
writeChaptersMock.mockReset();
|
||||
rollbackToChapterMock.mockReset();
|
||||
saveChapterIndexMock.mockReset();
|
||||
loadChapterIndexMock.mockReset();
|
||||
@@ -473,6 +476,16 @@ describe("createStudioServer daemon lifecycle", () => {
|
||||
status: "ready-for-review",
|
||||
auditResult: { passed: true, issues: [], summary: "rewritten" },
|
||||
});
|
||||
writeChaptersMock.mockResolvedValue([
|
||||
{
|
||||
chapterNumber: 3,
|
||||
title: "Rewritten Chapter",
|
||||
wordCount: 1800,
|
||||
revised: false,
|
||||
status: "ready-for-review",
|
||||
auditResult: { passed: true, issues: [], summary: "rewritten" },
|
||||
},
|
||||
]);
|
||||
createLLMClientMock.mockReset();
|
||||
createLLMClientMock.mockReturnValue({});
|
||||
createLLMTranslationModelMock.mockReset();
|
||||
@@ -4184,6 +4197,53 @@ describe("createStudioServer daemon lifecycle", () => {
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
it("runs a confirmed multi-chapter write sequentially through the existing write_next intent", async () => {
|
||||
writeChaptersMock.mockResolvedValueOnce([
|
||||
{
|
||||
chapterNumber: 3,
|
||||
title: "第三章",
|
||||
wordCount: 1800,
|
||||
revised: false,
|
||||
status: "ready-for-review",
|
||||
auditResult: { passed: true, issues: [], summary: "ok" },
|
||||
},
|
||||
{
|
||||
chapterNumber: 4,
|
||||
title: "第四章",
|
||||
wordCount: 1750,
|
||||
revised: false,
|
||||
status: "ready-for-review",
|
||||
auditResult: { passed: true, issues: [], summary: "ok" },
|
||||
},
|
||||
]);
|
||||
const { createStudioServer } = await import("./server.js");
|
||||
const app = createStudioServer(cloneProjectConfig() as never, root);
|
||||
|
||||
const response = await app.request("http://localhost/api/v1/agent", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
instruction: "连续写两章",
|
||||
activeBookId: "demo-book",
|
||||
sessionId: "agent-session-1",
|
||||
sessionKind: "book",
|
||||
actionSource: "button",
|
||||
requestedIntent: "write_next",
|
||||
actionPayload: { writeNext: { chapterCount: 2 } },
|
||||
}),
|
||||
});
|
||||
|
||||
const body = await response.json();
|
||||
expect(response.status, JSON.stringify(body)).toBe(200);
|
||||
expect(body.response).toContain("已连续完成 2 章");
|
||||
expect(writeChaptersMock).toHaveBeenCalledWith(
|
||||
"demo-book",
|
||||
2,
|
||||
expect.objectContaining({ onChapterComplete: expect.any(Function) }),
|
||||
);
|
||||
expect(writeNextChapterMock).not.toHaveBeenCalled();
|
||||
}, 60_000);
|
||||
|
||||
it("does not present audit-failed direct write-next as completed", async () => {
|
||||
writeNextChapterMock.mockResolvedValueOnce({
|
||||
chapterNumber: 3,
|
||||
|
||||
@@ -1430,12 +1430,21 @@ interface WriteNextChapterToolResult {
|
||||
readonly isError?: boolean;
|
||||
readonly content: ReadonlyArray<{ readonly type: "text"; readonly text: string }>;
|
||||
readonly details: {
|
||||
readonly kind: "chapter_written";
|
||||
readonly kind: "chapter_written" | "chapters_written";
|
||||
readonly bookId: string;
|
||||
readonly chapterNumber: number;
|
||||
readonly chapterNumber?: number;
|
||||
readonly title?: string;
|
||||
readonly wordCount: number;
|
||||
readonly wordCount?: number;
|
||||
readonly status?: string;
|
||||
readonly requestedCount?: number;
|
||||
readonly completedCount?: number;
|
||||
readonly stoppedStatus?: string;
|
||||
readonly chapters?: ReadonlyArray<{
|
||||
readonly chapterNumber: number;
|
||||
readonly title?: string;
|
||||
readonly wordCount: number;
|
||||
readonly status?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1471,6 +1480,7 @@ function createWriteNextChapterTool(
|
||||
pipeline: PipelineRunner,
|
||||
bookId: string,
|
||||
lang: StudioLanguage,
|
||||
chapterCount = 1,
|
||||
): {
|
||||
readonly name: "sub_agent";
|
||||
readonly execute: (
|
||||
@@ -1483,6 +1493,65 @@ function createWriteNextChapterTool(
|
||||
return {
|
||||
name: "sub_agent",
|
||||
async execute(_toolCallId, _params, signal, onUpdate) {
|
||||
if (chapterCount > 1) {
|
||||
onUpdate?.({
|
||||
content: [{
|
||||
type: "text",
|
||||
text: pick(
|
||||
lang,
|
||||
`正在为 ${bookId} 连续写 ${chapterCount} 章…`,
|
||||
`Writing ${chapterCount} consecutive chapters for ${bookId}...`,
|
||||
),
|
||||
}],
|
||||
});
|
||||
const results = await pipeline.runWithAbortSignal(
|
||||
signal,
|
||||
() => pipeline.writeChapters(bookId, chapterCount, {
|
||||
onChapterComplete(result, completedCount, requestedCount) {
|
||||
onUpdate?.({
|
||||
content: [{
|
||||
type: "text",
|
||||
text: pick(
|
||||
lang,
|
||||
`第 ${completedCount}/${requestedCount} 章已落盘:第 ${result.chapterNumber} 章《${result.title}》。`,
|
||||
`${completedCount}/${requestedCount} persisted: chapter ${result.chapterNumber} "${result.title}".`,
|
||||
),
|
||||
}],
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
const last = results.at(-1);
|
||||
const stoppedStatus = last?.status !== "ready-for-review" ? last?.status : undefined;
|
||||
const responseText = stoppedStatus
|
||||
? pick(
|
||||
lang,
|
||||
`已完成 ${results.length}/${chapterCount} 章;第 ${last?.chapterNumber} 章状态为 ${stoppedStatus},批量写作已停止,请复核后再继续。`,
|
||||
`Completed ${results.length}/${chapterCount} chapters. Chapter ${last?.chapterNumber} ended with ${stoppedStatus}, so the batch stopped for review.`,
|
||||
)
|
||||
: pick(
|
||||
lang,
|
||||
`已连续完成 ${results.length} 章(第 ${results[0]?.chapterNumber} 章至第 ${last?.chapterNumber} 章)。`,
|
||||
`Completed ${results.length} consecutive chapters (chapters ${results[0]?.chapterNumber}-${last?.chapterNumber}).`,
|
||||
);
|
||||
return {
|
||||
...(stoppedStatus ? { isError: true } : {}),
|
||||
content: [{ type: "text", text: responseText }],
|
||||
details: {
|
||||
kind: "chapters_written",
|
||||
bookId,
|
||||
requestedCount: chapterCount,
|
||||
completedCount: results.length,
|
||||
chapters: results.map((result) => ({
|
||||
chapterNumber: result.chapterNumber,
|
||||
title: result.title,
|
||||
wordCount: result.wordCount,
|
||||
status: result.status,
|
||||
})),
|
||||
...(stoppedStatus ? { stoppedStatus } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
onUpdate?.({
|
||||
content: [{
|
||||
type: "text",
|
||||
@@ -1572,7 +1641,8 @@ async function executeConfirmedProductionAction(args: {
|
||||
if (!args.bookId) {
|
||||
throw new ApiError(400, "BOOK_ID_REQUIRED", pick(lang, "写下一章需要先打开一本书。", "Writing the next chapter requires an active book."));
|
||||
}
|
||||
tool = createWriteNextChapterTool(args.pipeline, args.bookId, lang);
|
||||
const chapterCount = actionPayload?.writeNext?.chapterCount ?? 1;
|
||||
tool = createWriteNextChapterTool(args.pipeline, args.bookId, lang, chapterCount);
|
||||
agent = "writer";
|
||||
params = { agent: "writer", bookId: args.bookId };
|
||||
} else if (args.requestedIntent === "generate_cover") {
|
||||
|
||||
Reference in New Issue
Block a user