| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098 |
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
- import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
- import { join } from "node:path";
- import { tmpdir } from "node:os";
- const schedulerStartMock = vi.fn<() => Promise<void>>();
- const initBookMock = vi.fn();
- const runRadarMock = vi.fn();
- const reviseDraftMock = vi.fn();
- const resyncChapterArtifactsMock = vi.fn();
- const writeNextChapterMock = vi.fn();
- const rollbackToChapterMock = vi.fn();
- const saveChapterIndexMock = vi.fn();
- const loadChapterIndexMock = vi.fn();
- const loadBookConfigMock = vi.fn();
- const createLLMClientMock = vi.fn(() => ({}));
- const chatCompletionMock = vi.fn();
- const loadProjectConfigMock = vi.fn();
- const pipelineConfigs: unknown[] = [];
- const processProjectInteractionInputMock = vi.fn();
- const processProjectInteractionRequestMock = vi.fn();
- const createInteractionToolsFromDepsMock = vi.fn(() => ({}));
- const loadProjectSessionMock = vi.fn();
- const resolveSessionActiveBookMock = vi.fn();
- const runAgentSessionMock = vi.fn();
- const findOrCreateBookSessionMock = vi.fn();
- const loadBookSessionMock = vi.fn();
- const persistBookSessionMock = vi.fn();
- const appendBookSessionMessageMock = vi.fn();
- const resolveServiceModelMock = vi.fn();
- const loadSecretsMock = vi.fn();
- const saveSecretsMock = vi.fn();
- const getServiceApiKeyMock = vi.fn();
- const logger = {
- child: () => logger,
- info: vi.fn(),
- warn: vi.fn(),
- error: vi.fn(),
- };
- vi.mock("@actalk/inkos-core", () => {
- class MockStateManager {
- constructor(private readonly root: string) {}
- async listBooks(): Promise<string[]> {
- return [];
- }
- async loadBookConfig(): Promise<never> {
- return await loadBookConfigMock() as never;
- }
- async loadChapterIndex(bookId: string): Promise<[]> {
- return (await loadChapterIndexMock(bookId)) as [];
- }
- async saveChapterIndex(bookId: string, index: unknown): Promise<void> {
- await saveChapterIndexMock(bookId, index);
- }
- async rollbackToChapter(bookId: string, chapterNumber: number): Promise<number[]> {
- return (await rollbackToChapterMock(bookId, chapterNumber)) as number[];
- }
- async getNextChapterNumber(): Promise<number> {
- return 1;
- }
- async ensureControlDocuments(): Promise<void> {
- // no-op in tests
- }
- bookDir(id: string): string {
- return join(this.root, "books", id);
- }
- }
- class MockPipelineRunner {
- constructor(config: unknown) {
- pipelineConfigs.push(config);
- }
- initBook = initBookMock;
- runRadar = runRadarMock;
- reviseDraft = reviseDraftMock;
- resyncChapterArtifacts = resyncChapterArtifactsMock;
- writeNextChapter = writeNextChapterMock;
- }
- class MockScheduler {
- private running = false;
- constructor(_config: unknown) {}
- async start(): Promise<void> {
- this.running = true;
- await schedulerStartMock();
- }
- stop(): void {
- this.running = false;
- }
- get isRunning(): boolean {
- return this.running;
- }
- }
- return {
- StateManager: MockStateManager,
- PipelineRunner: MockPipelineRunner,
- Scheduler: MockScheduler,
- createLLMClient: createLLMClientMock,
- createLogger: vi.fn(() => logger),
- computeAnalytics: vi.fn(() => ({})),
- chatCompletion: chatCompletionMock,
- loadProjectConfig: loadProjectConfigMock,
- processProjectInteractionInput: processProjectInteractionInputMock,
- processProjectInteractionRequest: processProjectInteractionRequestMock,
- createInteractionToolsFromDeps: createInteractionToolsFromDepsMock,
- loadProjectSession: loadProjectSessionMock,
- resolveSessionActiveBook: resolveSessionActiveBookMock,
- runAgentSession: runAgentSessionMock,
- buildAgentSystemPrompt: vi.fn(() => "You are helpful."),
- findOrCreateBookSession: findOrCreateBookSessionMock,
- loadBookSession: loadBookSessionMock,
- persistBookSession: persistBookSessionMock,
- appendBookSessionMessage: appendBookSessionMessageMock,
- resolveServiceModel: resolveServiceModelMock,
- loadSecrets: loadSecretsMock,
- saveSecrets: saveSecretsMock,
- getServiceApiKey: getServiceApiKeyMock,
- GLOBAL_ENV_PATH: join(tmpdir(), "inkos-global.env"),
- };
- });
- const projectConfig = {
- name: "studio-test",
- version: "0.1.0",
- language: "zh",
- llm: {
- provider: "openai",
- baseUrl: "https://api.example.com/v1",
- apiKey: "sk-test",
- model: "gpt-5.4",
- temperature: 0.7,
- maxTokens: 4096,
- stream: false,
- },
- daemon: {
- schedule: {
- radarCron: "0 */6 * * *",
- writeCron: "*/15 * * * *",
- },
- maxConcurrentBooks: 1,
- chaptersPerCycle: 1,
- retryDelayMs: 30000,
- cooldownAfterChapterMs: 0,
- maxChaptersPerDay: 50,
- },
- modelOverrides: {},
- notify: [],
- } as const;
- function cloneProjectConfig() {
- return structuredClone(projectConfig);
- }
- describe("createStudioServer daemon lifecycle", () => {
- let root: string;
- beforeEach(async () => {
- root = await mkdtemp(join(tmpdir(), "inkos-studio-server-"));
- await writeFile(join(root, "inkos.json"), JSON.stringify(projectConfig, null, 2), "utf-8");
- schedulerStartMock.mockReset();
- initBookMock.mockReset();
- runRadarMock.mockReset();
- reviseDraftMock.mockReset();
- resyncChapterArtifactsMock.mockReset();
- writeNextChapterMock.mockReset();
- rollbackToChapterMock.mockReset();
- saveChapterIndexMock.mockReset();
- loadChapterIndexMock.mockReset();
- loadBookConfigMock.mockReset();
- await mkdir(join(root, "books", "demo-book", "chapters"), { recursive: true });
- await writeFile(join(root, "books", "demo-book", "chapters", "0003_Demo.md"), "# Demo\n\nBody", "utf-8");
- runRadarMock.mockResolvedValue({
- marketSummary: "Fresh market summary",
- recommendations: [],
- });
- reviseDraftMock.mockResolvedValue({
- chapterNumber: 3,
- wordCount: 1800,
- fixedIssues: ["focus restored"],
- applied: true,
- status: "ready-for-review",
- });
- resyncChapterArtifactsMock.mockResolvedValue({
- chapterNumber: 3,
- title: "Synced Chapter",
- wordCount: 1800,
- revised: false,
- status: "ready-for-review",
- auditResult: { passed: true, issues: [], summary: "synced" },
- });
- writeNextChapterMock.mockResolvedValue({
- chapterNumber: 3,
- title: "Rewritten Chapter",
- wordCount: 1800,
- revised: false,
- status: "ready-for-review",
- auditResult: { passed: true, issues: [], summary: "rewritten" },
- });
- createLLMClientMock.mockReset();
- createLLMClientMock.mockReturnValue({});
- chatCompletionMock.mockReset();
- chatCompletionMock.mockResolvedValue({
- content: "pong",
- usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
- });
- loadProjectConfigMock.mockReset();
- processProjectInteractionInputMock.mockReset();
- processProjectInteractionRequestMock.mockReset();
- createInteractionToolsFromDepsMock.mockReset();
- loadProjectSessionMock.mockReset();
- resolveSessionActiveBookMock.mockReset();
- createInteractionToolsFromDepsMock.mockReturnValue({});
- processProjectInteractionRequestMock.mockResolvedValue({
- request: { intent: "create_book" },
- session: {
- sessionId: "session-structured",
- projectRoot: root,
- activeBookId: "new-book",
- automationMode: "semi",
- messages: [],
- events: [],
- },
- details: {
- bookId: "new-book",
- outputPath: join(root, "books", "demo-book", "demo-book.txt"),
- chaptersExported: 2,
- },
- });
- loadProjectSessionMock.mockResolvedValue({
- sessionId: "session-1",
- projectRoot: root,
- automationMode: "semi",
- messages: [],
- });
- resolveSessionActiveBookMock.mockResolvedValue(undefined);
- loadProjectConfigMock.mockImplementation(async () => {
- const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8")) as Record<string, unknown>;
- return {
- ...cloneProjectConfig(),
- ...raw,
- llm: {
- ...cloneProjectConfig().llm,
- ...((raw.llm ?? {}) as Record<string, unknown>),
- },
- daemon: {
- ...cloneProjectConfig().daemon,
- ...((raw.daemon ?? {}) as Record<string, unknown>),
- },
- modelOverrides: (raw.modelOverrides ?? {}) as Record<string, unknown>,
- notify: (raw.notify ?? []) as unknown[],
- };
- });
- loadChapterIndexMock.mockResolvedValue([]);
- loadBookConfigMock.mockResolvedValue({
- id: "demo-book",
- title: "Demo Book",
- platform: "qidian",
- genre: "xuanhuan",
- status: "active",
- targetChapters: 100,
- chapterWordCount: 3000,
- createdAt: "2026-04-12T00:00:00.000Z",
- updatedAt: "2026-04-12T00:00:00.000Z",
- });
- saveChapterIndexMock.mockResolvedValue(undefined);
- rollbackToChapterMock.mockResolvedValue([]);
- pipelineConfigs.length = 0;
- runAgentSessionMock.mockReset();
- findOrCreateBookSessionMock.mockReset();
- loadBookSessionMock.mockReset();
- persistBookSessionMock.mockReset();
- appendBookSessionMessageMock.mockReset();
- resolveServiceModelMock.mockReset();
- loadSecretsMock.mockReset();
- saveSecretsMock.mockReset();
- getServiceApiKeyMock.mockReset();
- // Default BookSession for agent tests
- const defaultBookSession = {
- sessionId: "agent-session-1",
- projectRoot: root,
- activeBookId: "demo-book",
- messages: [],
- events: [],
- };
- findOrCreateBookSessionMock.mockResolvedValue(defaultBookSession);
- loadBookSessionMock.mockResolvedValue(null);
- persistBookSessionMock.mockResolvedValue(undefined);
- appendBookSessionMessageMock.mockImplementation(
- (session: unknown, _msg: unknown) => session,
- );
- runAgentSessionMock.mockResolvedValue({
- responseText: "Agent response.",
- messages: [],
- });
- loadSecretsMock.mockResolvedValue({ services: {} });
- saveSecretsMock.mockResolvedValue(undefined);
- getServiceApiKeyMock.mockResolvedValue(undefined);
- });
- afterEach(async () => {
- await rm(root, { recursive: true, force: true });
- await rm(join(tmpdir(), "inkos-global.env"), { force: true });
- });
- it("returns from /api/daemon/start before the first write cycle finishes", async () => {
- let resolveStart: (() => void) | undefined;
- schedulerStartMock.mockImplementation(
- () =>
- new Promise<void>((resolve) => {
- resolveStart = resolve;
- }),
- );
- const { createStudioServer } = await import("./server.js");
- const app = createStudioServer(cloneProjectConfig() as never, root);
- const responseOrTimeout = await Promise.race([
- app.request("http://localhost/api/v1/daemon/start", { method: "POST" }),
- new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 30)),
- ]);
- expect(responseOrTimeout).not.toBe("timeout");
- const response = responseOrTimeout as Response;
- expect(response.status).toBe(200);
- await expect(response.json()).resolves.toMatchObject({ ok: true, running: true });
- const status = await app.request("http://localhost/api/v1/daemon");
- await expect(status.json()).resolves.toEqual({ running: true });
- resolveStart?.();
- });
- it("rejects book routes with path traversal ids", async () => {
- const { createStudioServer } = await import("./server.js");
- const app = createStudioServer(cloneProjectConfig() as never, root);
- const response = await app.request("http://localhost/api/v1/books/..%2Fetc%2Fpasswd", {
- method: "GET",
- });
- expect(response.status).toBe(400);
- await expect(response.json()).resolves.toEqual({
- error: {
- code: "INVALID_BOOK_ID",
- message: 'Invalid book ID: "../etc/passwd"',
- },
- });
- });
- it("allows reading and updating fixed control truth files", async () => {
- const bookDir = join(root, "books", "demo-book");
- const storyDir = join(bookDir, "story");
- await mkdir(storyDir, { recursive: true });
- await Promise.all([
- writeFile(join(storyDir, "author_intent.md"), "# Author Intent\n\nStay cold.\n", "utf-8"),
- writeFile(join(storyDir, "current_focus.md"), "# Current Focus\n\nReturn to the old case.\n", "utf-8"),
- ]);
- const { createStudioServer } = await import("./server.js");
- const app = createStudioServer(cloneProjectConfig() as never, root);
- const readAuthorIntent = await app.request("http://localhost/api/v1/books/demo-book/truth/author_intent.md");
- expect(readAuthorIntent.status).toBe(200);
- await expect(readAuthorIntent.json()).resolves.toMatchObject({
- file: "author_intent.md",
- content: "# Author Intent\n\nStay cold.\n",
- });
- const updateCurrentFocus = await app.request("http://localhost/api/v1/books/demo-book/truth/current_focus.md", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ content: "# Current Focus\n\nPull focus back to the harbor trail.\n" }),
- });
- expect(updateCurrentFocus.status).toBe(200);
- await expect(readFile(join(storyDir, "current_focus.md"), "utf-8")).resolves.toBe(
- "# Current Focus\n\nPull focus back to the harbor trail.\n",
- );
- });
- it("reflects project edits immediately without restarting the studio server", async () => {
- const { createStudioServer } = await import("./server.js");
- const app = createStudioServer(cloneProjectConfig() as never, root);
- const save = await app.request("http://localhost/api/v1/project", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- language: "en",
- temperature: 0.2,
- maxTokens: 2048,
- stream: true,
- }),
- });
- expect(save.status).toBe(200);
- const project = await app.request("http://localhost/api/v1/project");
- await expect(project.json()).resolves.toMatchObject({
- language: "en",
- temperature: 0.2,
- maxTokens: 2048,
- stream: true,
- });
- });
- it("reloads latest llm config for doctor checks without restarting the studio server", async () => {
- const startupConfig = {
- ...cloneProjectConfig(),
- llm: {
- ...cloneProjectConfig().llm,
- model: "stale-model",
- baseUrl: "https://stale.example.com/v1",
- },
- };
- const freshConfig = {
- ...cloneProjectConfig(),
- llm: {
- ...cloneProjectConfig().llm,
- model: "fresh-model",
- baseUrl: "https://fresh.example.com/v1",
- },
- };
- loadProjectConfigMock.mockResolvedValue(freshConfig);
- const { createStudioServer } = await import("./server.js");
- const app = createStudioServer(startupConfig as never, root);
- const response = await app.request("http://localhost/api/v1/doctor");
- expect(response.status).toBe(200);
- expect(createLLMClientMock).toHaveBeenCalledWith(expect.objectContaining({
- model: "fresh-model",
- baseUrl: "https://fresh.example.com/v1",
- }));
- expect(chatCompletionMock).toHaveBeenCalledWith(
- expect.anything(),
- "fresh-model",
- expect.any(Array),
- expect.objectContaining({ maxTokens: 5 }),
- );
- });
- it("reloads latest llm config for radar scans without restarting the studio server", async () => {
- const startupConfig = {
- ...cloneProjectConfig(),
- llm: {
- ...cloneProjectConfig().llm,
- model: "stale-model",
- baseUrl: "https://stale.example.com/v1",
- },
- };
- const freshConfig = {
- ...cloneProjectConfig(),
- llm: {
- ...cloneProjectConfig().llm,
- model: "fresh-model",
- baseUrl: "https://fresh.example.com/v1",
- },
- };
- loadProjectConfigMock.mockResolvedValue(freshConfig);
- const { createStudioServer } = await import("./server.js");
- const app = createStudioServer(startupConfig as never, root);
- const response = await app.request("http://localhost/api/v1/radar/scan", {
- method: "POST",
- });
- expect(response.status).toBe(200);
- expect(runRadarMock).toHaveBeenCalledTimes(1);
- expect(pipelineConfigs.at(-1)).toMatchObject({
- model: "fresh-model",
- defaultLLMConfig: expect.objectContaining({
- model: "fresh-model",
- baseUrl: "https://fresh.example.com/v1",
- }),
- });
- });
- it("updates the first-run language immediately after the language selector saves", async () => {
- const { createStudioServer } = await import("./server.js");
- const app = createStudioServer(cloneProjectConfig() as never, root);
- const save = await app.request("http://localhost/api/v1/project/language", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ language: "en" }),
- });
- expect(save.status).toBe(200);
- const project = await app.request("http://localhost/api/v1/project");
- await expect(project.json()).resolves.toMatchObject({
- language: "en",
- languageExplicit: true,
- });
- });
- it("merges service config patches instead of overwriting existing services", async () => {
- await writeFile(join(root, "inkos.json"), JSON.stringify({
- ...projectConfig,
- llm: {
- services: [
- { service: "moonshot", temperature: 1, maxTokens: 4096, apiFormat: "chat", stream: true },
- { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1", temperature: 0.9, apiFormat: "responses", stream: false },
- ],
- defaultModel: "kimi-k2.5",
- },
- }, null, 2), "utf-8");
- const { createStudioServer } = await import("./server.js");
- const app = createStudioServer(cloneProjectConfig() as never, root);
- const save = await app.request("http://localhost/api/v1/services/config", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- services: {
- moonshot: {
- temperature: 0.5,
- maxTokens: 2048,
- apiFormat: "responses",
- stream: false,
- },
- },
- }),
- });
- expect(save.status).toBe(200);
- const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
- expect(raw.llm.services).toEqual([
- { service: "moonshot", temperature: 0.5, maxTokens: 2048, apiFormat: "responses", stream: false },
- { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1", temperature: 0.9, apiFormat: "responses", stream: false },
- ]);
- });
- it("reports config source and detected env overrides for Studio switching", async () => {
- await writeFile(join(root, ".env"), [
- "INKOS_LLM_PROVIDER=openai",
- "INKOS_LLM_BASE_URL=https://project.example.com/v1",
- "INKOS_LLM_MODEL=gpt-5.4",
- "INKOS_LLM_API_KEY=sk-project",
- ].join("\n"), "utf-8");
- await writeFile(join(tmpdir(), "inkos-global.env"), [
- "INKOS_LLM_PROVIDER=openai",
- "INKOS_LLM_BASE_URL=https://global.example.com/v1",
- "INKOS_LLM_MODEL=gpt-4o",
- "INKOS_LLM_API_KEY=sk-global",
- ].join("\n"), "utf-8");
- await writeFile(join(root, "inkos.json"), JSON.stringify({
- ...projectConfig,
- llm: {
- ...projectConfig.llm,
- configSource: "env",
- },
- }, null, 2), "utf-8");
- const { createStudioServer } = await import("./server.js");
- const app = createStudioServer(cloneProjectConfig() as never, root);
- const response = await app.request("http://localhost/api/v1/services/config");
- expect(response.status).toBe(200);
- await expect(response.json()).resolves.toMatchObject({
- configSource: "env",
- envConfig: {
- effectiveSource: "project",
- project: {
- detected: true,
- baseUrl: "https://project.example.com/v1",
- model: "gpt-5.4",
- hasApiKey: true,
- },
- global: {
- detected: true,
- baseUrl: "https://global.example.com/v1",
- model: "gpt-4o",
- hasApiKey: true,
- },
- },
- });
- });
- it("allows switching config source without overwriting services", async () => {
- await writeFile(join(root, "inkos.json"), JSON.stringify({
- ...projectConfig,
- llm: {
- services: [
- { service: "moonshot", temperature: 1, maxTokens: 4096 },
- ],
- defaultModel: "kimi-k2.5",
- configSource: "env",
- },
- }, null, 2), "utf-8");
- const { createStudioServer } = await import("./server.js");
- const app = createStudioServer(cloneProjectConfig() as never, root);
- const save = await app.request("http://localhost/api/v1/services/config", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ configSource: "studio" }),
- });
- expect(save.status).toBe(200);
- const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
- expect(raw.llm.configSource).toBe("studio");
- expect(raw.llm.services).toEqual([
- { service: "moonshot", temperature: 1, maxTokens: 4096 },
- ]);
- expect(raw.llm.defaultModel).toBe("kimi-k2.5");
- });
- it("tests and lists models for custom services using baseUrl and stored config", async () => {
- await writeFile(join(root, "inkos.json"), JSON.stringify({
- ...projectConfig,
- llm: {
- services: [
- { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1" },
- ],
- defaultModel: "corp-chat",
- },
- }, null, 2), "utf-8");
- loadSecretsMock.mockResolvedValue({
- services: {
- "custom:内网GPT": { apiKey: "sk-corp" },
- },
- });
- getServiceApiKeyMock.mockResolvedValue("sk-corp");
- const fetchMock = vi.fn()
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ data: [{ id: "corp-chat" }] }),
- text: async () => "",
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ data: [{ id: "corp-chat" }] }),
- });
- vi.stubGlobal("fetch", fetchMock as typeof fetch);
- const { createStudioServer } = await import("./server.js");
- const app = createStudioServer(cloneProjectConfig() as never, root);
- const testResponse = await app.request("http://localhost/api/v1/services/custom%3A%E5%86%85%E7%BD%91GPT/test", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ apiKey: "sk-corp", baseUrl: "https://llm.internal.corp/v1" }),
- });
- expect(testResponse.status).toBe(200);
- await expect(testResponse.json()).resolves.toMatchObject({
- ok: true,
- models: [{ id: "corp-chat", name: "corp-chat" }],
- });
- const modelsResponse = await app.request("http://localhost/api/v1/services/custom%3A%E5%86%85%E7%BD%91GPT/models");
- expect(modelsResponse.status).toBe(200);
- await expect(modelsResponse.json()).resolves.toMatchObject({
- models: [{ id: "corp-chat", name: "corp-chat" }],
- });
- });
- it("rejects create requests when a complete book with the same id already exists", async () => {
- await mkdir(join(root, "books", "existing-book", "story"), { recursive: true });
- await writeFile(join(root, "books", "existing-book", "book.json"), JSON.stringify({ id: "existing-book" }), "utf-8");
- await writeFile(join(root, "books", "existing-book", "story", "story_bible.md"), "# existing", "utf-8");
- const { createStudioServer } = await import("./server.js");
- const app = createStudioServer(cloneProjectConfig() as never, root);
- const response = await app.request("http://localhost/api/v1/books/create", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- title: "Existing Book",
- genre: "xuanhuan",
- platform: "qidian",
- language: "zh",
- }),
- });
- expect(response.status).toBe(409);
- await expect(response.json()).resolves.toMatchObject({
- error: expect.stringContaining('Book "existing-book" already exists'),
- });
- expect(processProjectInteractionRequestMock).not.toHaveBeenCalled();
- await expect(access(join(root, "books", "existing-book", "story", "story_bible.md"))).resolves.toBeUndefined();
- });
- it("reports async create failures through the create-status endpoint", async () => {
- processProjectInteractionRequestMock.mockRejectedValueOnce(new Error("INKOS_LLM_API_KEY not set"));
- const { createStudioServer } = await import("./server.js");
- const app = createStudioServer(cloneProjectConfig() as never, root);
- const response = await app.request("http://localhost/api/v1/books/create", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- title: "Broken Book",
- genre: "xuanhuan",
- platform: "qidian",
- language: "zh",
- }),
- });
- expect(response.status).toBe(200);
- await Promise.resolve();
- const status = await app.request("http://localhost/api/v1/books/broken-book/create-status");
- expect(status.status).toBe(200);
- await expect(status.json()).resolves.toMatchObject({
- status: "error",
- error: "INKOS_LLM_API_KEY not set",
- });
- });
- it("uses rollback semantics for chapter rejection instead of only flipping status", async () => {
- loadChapterIndexMock.mockResolvedValue([
- {
- number: 3,
- title: "Broken Chapter",
- status: "ready-for-review",
- wordCount: 1800,
- createdAt: "2026-04-07T00:00:00.000Z",
- updatedAt: "2026-04-07T00:00:00.000Z",
- auditIssues: ["continuity"],
- lengthWarnings: [],
- },
- {
- number: 4,
- title: "Downstream Chapter",
- status: "ready-for-review",
- wordCount: 1900,
- createdAt: "2026-04-07T00:00:00.000Z",
- updatedAt: "2026-04-07T00:00:00.000Z",
- auditIssues: [],
- lengthWarnings: [],
- },
- ]);
- rollbackToChapterMock.mockResolvedValue([3, 4]);
- const { createStudioServer } = await import("./server.js");
- const app = createStudioServer(cloneProjectConfig() as never, root);
- const response = await app.request("http://localhost/api/v1/books/demo-book/chapters/3/reject", {
- method: "POST",
- });
- expect(response.status).toBe(200);
- await expect(response.json()).resolves.toEqual({
- ok: true,
- chapterNumber: 3,
- status: "rejected",
- rolledBackTo: 2,
- discarded: [3, 4],
- });
- expect(rollbackToChapterMock).toHaveBeenCalledWith("demo-book", 2);
- expect(saveChapterIndexMock).not.toHaveBeenCalled();
- });
- it("routes create requests through the shared structured interaction runtime", async () => {
- const { createStudioServer } = await import("./server.js");
- const app = createStudioServer(cloneProjectConfig() as never, root);
- const response = await app.request("http://localhost/api/v1/books/create", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- title: "New Book",
- genre: "urban",
- platform: "qidian",
- language: "zh",
- chapterWordCount: 2600,
- targetChapters: 88,
- }),
- });
- expect(response.status).toBe(200);
- expect(createInteractionToolsFromDepsMock).toHaveBeenCalledTimes(1);
- expect(processProjectInteractionRequestMock).toHaveBeenCalledWith(expect.objectContaining({
- projectRoot: root,
- request: {
- intent: "create_book",
- title: "New Book",
- genre: "urban",
- language: "zh",
- platform: "qidian",
- chapterWordCount: 2600,
- targetChapters: 88,
- },
- }));
- });
- it("passes one-off brief into revise requests through pipeline config", async () => {
- const { createStudioServer } = await import("./server.js");
- const app = createStudioServer(cloneProjectConfig() as never, root);
- const response = await app.request("http://localhost/api/v1/books/demo-book/revise/3", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ mode: "rewrite", brief: "把注意力拉回师债主线。" }),
- });
- expect(response.status).toBe(200);
- expect(pipelineConfigs.at(-1)).toMatchObject({ externalContext: "把注意力拉回师债主线。" });
- expect(reviseDraftMock).toHaveBeenCalledWith("demo-book", 3, "rewrite");
- });
- it("exposes a resync endpoint for rebuilding latest chapter truth artifacts", async () => {
- const { createStudioServer } = await import("./server.js");
- const app = createStudioServer(cloneProjectConfig() as never, root);
- const response = await app.request("http://localhost/api/v1/books/demo-book/resync/3", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ brief: "以师债线为准同步状态。" }),
- });
- expect(response.status).toBe(200);
- expect(pipelineConfigs.at(-1)).toMatchObject({ externalContext: "以师债线为准同步状态。" });
- expect(resyncChapterArtifactsMock).toHaveBeenCalledWith("demo-book", 3);
- });
- it("routes export-save through the shared structured interaction runtime", async () => {
- const { createStudioServer } = await import("./server.js");
- const app = createStudioServer(cloneProjectConfig() as never, root);
- const response = await app.request("http://localhost/api/v1/books/demo-book/export-save", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ format: "md", approvedOnly: true }),
- });
- expect(response.status).toBe(200);
- expect(processProjectInteractionRequestMock).toHaveBeenCalledWith(expect.objectContaining({
- projectRoot: root,
- activeBookId: "demo-book",
- request: expect.objectContaining({
- intent: "export_book",
- bookId: "demo-book",
- format: "md",
- approvedOnly: true,
- }),
- }));
- await expect(response.json()).resolves.toMatchObject({
- ok: true,
- chapters: 2,
- });
- });
- it("routes /api/agent through runAgentSession and returns response + sessionId", async () => {
- runAgentSessionMock.mockResolvedValueOnce({
- responseText: "Completed write_next for demo-book.",
- messages: [
- { role: "user", content: "continue" },
- { role: "assistant", content: "Completed write_next for demo-book." },
- ],
- });
- 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: "continue", activeBookId: "demo-book" }),
- });
- expect(response.status).toBe(200);
- await expect(response.json()).resolves.toMatchObject({
- response: "Completed write_next for demo-book.",
- session: expect.objectContaining({
- sessionId: "agent-session-1",
- }),
- });
- expect(runAgentSessionMock).toHaveBeenCalledWith(
- expect.objectContaining({
- bookId: "demo-book",
- projectRoot: root,
- }),
- "continue",
- expect.any(Array),
- );
- });
- it("allows /api/agent to use explicit service+model when Studio config has no defaultModel", async () => {
- await writeFile(join(root, "inkos.json"), JSON.stringify({
- ...projectConfig,
- llm: {
- configSource: "studio",
- services: [
- { service: "custom", name: "CodexForMe", baseUrl: "https://api-vip.codex-for.me/v1", apiFormat: "responses", stream: false },
- ],
- },
- }, null, 2), "utf-8");
- loadProjectConfigMock.mockImplementation(async () => {
- const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8")) as Record<string, unknown>;
- return {
- ...cloneProjectConfig(),
- ...raw,
- llm: {
- ...cloneProjectConfig().llm,
- ...((raw.llm ?? {}) as Record<string, unknown>),
- },
- daemon: {
- ...cloneProjectConfig().daemon,
- ...((raw.daemon ?? {}) as Record<string, unknown>),
- },
- modelOverrides: (raw.modelOverrides ?? {}) as Record<string, unknown>,
- notify: (raw.notify ?? []) as unknown[],
- };
- });
- resolveServiceModelMock.mockResolvedValue({
- model: { id: "gpt-5.4", provider: "custom", api: "openai-responses" },
- apiKey: "sk-test",
- });
- runAgentSessionMock.mockResolvedValueOnce({
- responseText: "你好,我在。",
- messages: [
- { role: "user", content: "nihao" },
- { role: "assistant", content: "你好,我在。" },
- ],
- });
- 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: "nihao",
- service: "custom:CodexForMe",
- model: "gpt-5.4",
- }),
- });
- expect(response.status).toBe(200);
- await expect(response.json()).resolves.toMatchObject({
- response: "你好,我在。",
- });
- });
- it("returns 500 with an error payload when the agent session fails", async () => {
- runAgentSessionMock.mockRejectedValueOnce(new Error("boom"));
- 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: "continue", activeBookId: "demo-book" }),
- });
- expect(response.status).toBe(500);
- await expect(response.json()).resolves.toEqual({
- error: {
- code: "AGENT_ERROR",
- message: "boom",
- },
- });
- });
- it("probes the upstream when the agent returns empty text and surfaces the real error", async () => {
- runAgentSessionMock.mockResolvedValueOnce({
- responseText: "",
- messages: [{ role: "user", content: "nihao" }],
- });
- chatCompletionMock.mockRejectedValue(new Error("quota exhausted"));
- 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: "nihao", activeBookId: "demo-book" }),
- });
- expect(response.status).toBe(502);
- await expect(response.json()).resolves.toEqual({
- error: {
- code: "AGENT_EMPTY_RESPONSE",
- message: "quota exhausted",
- },
- response: "quota exhausted",
- });
- });
- it("falls back to plain chat when the tool-agent returns empty text", async () => {
- runAgentSessionMock.mockResolvedValueOnce({
- responseText: "",
- messages: [{ role: "user", content: "nihao" }],
- });
- chatCompletionMock.mockResolvedValueOnce({
- content: "你好!",
- usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
- });
- 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: "nihao", activeBookId: "demo-book" }),
- });
- expect(response.status).toBe(200);
- await expect(response.json()).resolves.toEqual({
- response: "你好!",
- session: { sessionId: "agent-session-1" },
- });
- });
- it("returns the shared interaction session state", async () => {
- loadProjectSessionMock.mockResolvedValue({
- sessionId: "session-2",
- projectRoot: root,
- activeBookId: "demo-book",
- automationMode: "auto",
- messages: [
- { role: "user", content: "continue", timestamp: 1 },
- ],
- });
- resolveSessionActiveBookMock.mockResolvedValue("demo-book");
- const { createStudioServer } = await import("./server.js");
- const app = createStudioServer(cloneProjectConfig() as never, root);
- const response = await app.request("http://localhost/api/v1/interaction/session");
- expect(response.status).toBe(200);
- await expect(response.json()).resolves.toMatchObject({
- session: expect.objectContaining({
- activeBookId: "demo-book",
- automationMode: "auto",
- }),
- activeBookId: "demo-book",
- });
- });
- it("returns creation-draft state through the shared interaction session endpoint", async () => {
- loadProjectSessionMock.mockResolvedValue({
- sessionId: "session-3",
- projectRoot: root,
- automationMode: "semi",
- creationDraft: {
- concept: "港风商战悬疑,主角从灰产洗白。",
- title: "夜港账本",
- nextQuestion: "你更想写长篇连载,还是十来章能收住?",
- missingFields: ["targetChapters"],
- readyToCreate: false,
- },
- messages: [],
- });
- resolveSessionActiveBookMock.mockResolvedValue(undefined);
- const { createStudioServer } = await import("./server.js");
- const app = createStudioServer(cloneProjectConfig() as never, root);
- const response = await app.request("http://localhost/api/v1/interaction/session");
- expect(response.status).toBe(200);
- await expect(response.json()).resolves.toMatchObject({
- session: expect.objectContaining({
- creationDraft: expect.objectContaining({
- title: "夜港账本",
- nextQuestion: "你更想写长篇连载,还是十来章能收住?",
- }),
- }),
- });
- });
- });
|