server.test.ts 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098
  1. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  2. import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
  3. import { join } from "node:path";
  4. import { tmpdir } from "node:os";
  5. const schedulerStartMock = vi.fn<() => Promise<void>>();
  6. const initBookMock = vi.fn();
  7. const runRadarMock = vi.fn();
  8. const reviseDraftMock = vi.fn();
  9. const resyncChapterArtifactsMock = vi.fn();
  10. const writeNextChapterMock = vi.fn();
  11. const rollbackToChapterMock = vi.fn();
  12. const saveChapterIndexMock = vi.fn();
  13. const loadChapterIndexMock = vi.fn();
  14. const loadBookConfigMock = vi.fn();
  15. const createLLMClientMock = vi.fn(() => ({}));
  16. const chatCompletionMock = vi.fn();
  17. const loadProjectConfigMock = vi.fn();
  18. const pipelineConfigs: unknown[] = [];
  19. const processProjectInteractionInputMock = vi.fn();
  20. const processProjectInteractionRequestMock = vi.fn();
  21. const createInteractionToolsFromDepsMock = vi.fn(() => ({}));
  22. const loadProjectSessionMock = vi.fn();
  23. const resolveSessionActiveBookMock = vi.fn();
  24. const runAgentSessionMock = vi.fn();
  25. const findOrCreateBookSessionMock = vi.fn();
  26. const loadBookSessionMock = vi.fn();
  27. const persistBookSessionMock = vi.fn();
  28. const appendBookSessionMessageMock = vi.fn();
  29. const resolveServiceModelMock = vi.fn();
  30. const loadSecretsMock = vi.fn();
  31. const saveSecretsMock = vi.fn();
  32. const getServiceApiKeyMock = vi.fn();
  33. const logger = {
  34. child: () => logger,
  35. info: vi.fn(),
  36. warn: vi.fn(),
  37. error: vi.fn(),
  38. };
  39. vi.mock("@actalk/inkos-core", () => {
  40. class MockStateManager {
  41. constructor(private readonly root: string) {}
  42. async listBooks(): Promise<string[]> {
  43. return [];
  44. }
  45. async loadBookConfig(): Promise<never> {
  46. return await loadBookConfigMock() as never;
  47. }
  48. async loadChapterIndex(bookId: string): Promise<[]> {
  49. return (await loadChapterIndexMock(bookId)) as [];
  50. }
  51. async saveChapterIndex(bookId: string, index: unknown): Promise<void> {
  52. await saveChapterIndexMock(bookId, index);
  53. }
  54. async rollbackToChapter(bookId: string, chapterNumber: number): Promise<number[]> {
  55. return (await rollbackToChapterMock(bookId, chapterNumber)) as number[];
  56. }
  57. async getNextChapterNumber(): Promise<number> {
  58. return 1;
  59. }
  60. async ensureControlDocuments(): Promise<void> {
  61. // no-op in tests
  62. }
  63. bookDir(id: string): string {
  64. return join(this.root, "books", id);
  65. }
  66. }
  67. class MockPipelineRunner {
  68. constructor(config: unknown) {
  69. pipelineConfigs.push(config);
  70. }
  71. initBook = initBookMock;
  72. runRadar = runRadarMock;
  73. reviseDraft = reviseDraftMock;
  74. resyncChapterArtifacts = resyncChapterArtifactsMock;
  75. writeNextChapter = writeNextChapterMock;
  76. }
  77. class MockScheduler {
  78. private running = false;
  79. constructor(_config: unknown) {}
  80. async start(): Promise<void> {
  81. this.running = true;
  82. await schedulerStartMock();
  83. }
  84. stop(): void {
  85. this.running = false;
  86. }
  87. get isRunning(): boolean {
  88. return this.running;
  89. }
  90. }
  91. return {
  92. StateManager: MockStateManager,
  93. PipelineRunner: MockPipelineRunner,
  94. Scheduler: MockScheduler,
  95. createLLMClient: createLLMClientMock,
  96. createLogger: vi.fn(() => logger),
  97. computeAnalytics: vi.fn(() => ({})),
  98. chatCompletion: chatCompletionMock,
  99. loadProjectConfig: loadProjectConfigMock,
  100. processProjectInteractionInput: processProjectInteractionInputMock,
  101. processProjectInteractionRequest: processProjectInteractionRequestMock,
  102. createInteractionToolsFromDeps: createInteractionToolsFromDepsMock,
  103. loadProjectSession: loadProjectSessionMock,
  104. resolveSessionActiveBook: resolveSessionActiveBookMock,
  105. runAgentSession: runAgentSessionMock,
  106. buildAgentSystemPrompt: vi.fn(() => "You are helpful."),
  107. findOrCreateBookSession: findOrCreateBookSessionMock,
  108. loadBookSession: loadBookSessionMock,
  109. persistBookSession: persistBookSessionMock,
  110. appendBookSessionMessage: appendBookSessionMessageMock,
  111. resolveServiceModel: resolveServiceModelMock,
  112. loadSecrets: loadSecretsMock,
  113. saveSecrets: saveSecretsMock,
  114. getServiceApiKey: getServiceApiKeyMock,
  115. GLOBAL_ENV_PATH: join(tmpdir(), "inkos-global.env"),
  116. };
  117. });
  118. const projectConfig = {
  119. name: "studio-test",
  120. version: "0.1.0",
  121. language: "zh",
  122. llm: {
  123. provider: "openai",
  124. baseUrl: "https://api.example.com/v1",
  125. apiKey: "sk-test",
  126. model: "gpt-5.4",
  127. temperature: 0.7,
  128. maxTokens: 4096,
  129. stream: false,
  130. },
  131. daemon: {
  132. schedule: {
  133. radarCron: "0 */6 * * *",
  134. writeCron: "*/15 * * * *",
  135. },
  136. maxConcurrentBooks: 1,
  137. chaptersPerCycle: 1,
  138. retryDelayMs: 30000,
  139. cooldownAfterChapterMs: 0,
  140. maxChaptersPerDay: 50,
  141. },
  142. modelOverrides: {},
  143. notify: [],
  144. } as const;
  145. function cloneProjectConfig() {
  146. return structuredClone(projectConfig);
  147. }
  148. describe("createStudioServer daemon lifecycle", () => {
  149. let root: string;
  150. beforeEach(async () => {
  151. root = await mkdtemp(join(tmpdir(), "inkos-studio-server-"));
  152. await writeFile(join(root, "inkos.json"), JSON.stringify(projectConfig, null, 2), "utf-8");
  153. schedulerStartMock.mockReset();
  154. initBookMock.mockReset();
  155. runRadarMock.mockReset();
  156. reviseDraftMock.mockReset();
  157. resyncChapterArtifactsMock.mockReset();
  158. writeNextChapterMock.mockReset();
  159. rollbackToChapterMock.mockReset();
  160. saveChapterIndexMock.mockReset();
  161. loadChapterIndexMock.mockReset();
  162. loadBookConfigMock.mockReset();
  163. await mkdir(join(root, "books", "demo-book", "chapters"), { recursive: true });
  164. await writeFile(join(root, "books", "demo-book", "chapters", "0003_Demo.md"), "# Demo\n\nBody", "utf-8");
  165. runRadarMock.mockResolvedValue({
  166. marketSummary: "Fresh market summary",
  167. recommendations: [],
  168. });
  169. reviseDraftMock.mockResolvedValue({
  170. chapterNumber: 3,
  171. wordCount: 1800,
  172. fixedIssues: ["focus restored"],
  173. applied: true,
  174. status: "ready-for-review",
  175. });
  176. resyncChapterArtifactsMock.mockResolvedValue({
  177. chapterNumber: 3,
  178. title: "Synced Chapter",
  179. wordCount: 1800,
  180. revised: false,
  181. status: "ready-for-review",
  182. auditResult: { passed: true, issues: [], summary: "synced" },
  183. });
  184. writeNextChapterMock.mockResolvedValue({
  185. chapterNumber: 3,
  186. title: "Rewritten Chapter",
  187. wordCount: 1800,
  188. revised: false,
  189. status: "ready-for-review",
  190. auditResult: { passed: true, issues: [], summary: "rewritten" },
  191. });
  192. createLLMClientMock.mockReset();
  193. createLLMClientMock.mockReturnValue({});
  194. chatCompletionMock.mockReset();
  195. chatCompletionMock.mockResolvedValue({
  196. content: "pong",
  197. usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
  198. });
  199. loadProjectConfigMock.mockReset();
  200. processProjectInteractionInputMock.mockReset();
  201. processProjectInteractionRequestMock.mockReset();
  202. createInteractionToolsFromDepsMock.mockReset();
  203. loadProjectSessionMock.mockReset();
  204. resolveSessionActiveBookMock.mockReset();
  205. createInteractionToolsFromDepsMock.mockReturnValue({});
  206. processProjectInteractionRequestMock.mockResolvedValue({
  207. request: { intent: "create_book" },
  208. session: {
  209. sessionId: "session-structured",
  210. projectRoot: root,
  211. activeBookId: "new-book",
  212. automationMode: "semi",
  213. messages: [],
  214. events: [],
  215. },
  216. details: {
  217. bookId: "new-book",
  218. outputPath: join(root, "books", "demo-book", "demo-book.txt"),
  219. chaptersExported: 2,
  220. },
  221. });
  222. loadProjectSessionMock.mockResolvedValue({
  223. sessionId: "session-1",
  224. projectRoot: root,
  225. automationMode: "semi",
  226. messages: [],
  227. });
  228. resolveSessionActiveBookMock.mockResolvedValue(undefined);
  229. loadProjectConfigMock.mockImplementation(async () => {
  230. const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8")) as Record<string, unknown>;
  231. return {
  232. ...cloneProjectConfig(),
  233. ...raw,
  234. llm: {
  235. ...cloneProjectConfig().llm,
  236. ...((raw.llm ?? {}) as Record<string, unknown>),
  237. },
  238. daemon: {
  239. ...cloneProjectConfig().daemon,
  240. ...((raw.daemon ?? {}) as Record<string, unknown>),
  241. },
  242. modelOverrides: (raw.modelOverrides ?? {}) as Record<string, unknown>,
  243. notify: (raw.notify ?? []) as unknown[],
  244. };
  245. });
  246. loadChapterIndexMock.mockResolvedValue([]);
  247. loadBookConfigMock.mockResolvedValue({
  248. id: "demo-book",
  249. title: "Demo Book",
  250. platform: "qidian",
  251. genre: "xuanhuan",
  252. status: "active",
  253. targetChapters: 100,
  254. chapterWordCount: 3000,
  255. createdAt: "2026-04-12T00:00:00.000Z",
  256. updatedAt: "2026-04-12T00:00:00.000Z",
  257. });
  258. saveChapterIndexMock.mockResolvedValue(undefined);
  259. rollbackToChapterMock.mockResolvedValue([]);
  260. pipelineConfigs.length = 0;
  261. runAgentSessionMock.mockReset();
  262. findOrCreateBookSessionMock.mockReset();
  263. loadBookSessionMock.mockReset();
  264. persistBookSessionMock.mockReset();
  265. appendBookSessionMessageMock.mockReset();
  266. resolveServiceModelMock.mockReset();
  267. loadSecretsMock.mockReset();
  268. saveSecretsMock.mockReset();
  269. getServiceApiKeyMock.mockReset();
  270. // Default BookSession for agent tests
  271. const defaultBookSession = {
  272. sessionId: "agent-session-1",
  273. projectRoot: root,
  274. activeBookId: "demo-book",
  275. messages: [],
  276. events: [],
  277. };
  278. findOrCreateBookSessionMock.mockResolvedValue(defaultBookSession);
  279. loadBookSessionMock.mockResolvedValue(null);
  280. persistBookSessionMock.mockResolvedValue(undefined);
  281. appendBookSessionMessageMock.mockImplementation(
  282. (session: unknown, _msg: unknown) => session,
  283. );
  284. runAgentSessionMock.mockResolvedValue({
  285. responseText: "Agent response.",
  286. messages: [],
  287. });
  288. loadSecretsMock.mockResolvedValue({ services: {} });
  289. saveSecretsMock.mockResolvedValue(undefined);
  290. getServiceApiKeyMock.mockResolvedValue(undefined);
  291. });
  292. afterEach(async () => {
  293. await rm(root, { recursive: true, force: true });
  294. await rm(join(tmpdir(), "inkos-global.env"), { force: true });
  295. });
  296. it("returns from /api/daemon/start before the first write cycle finishes", async () => {
  297. let resolveStart: (() => void) | undefined;
  298. schedulerStartMock.mockImplementation(
  299. () =>
  300. new Promise<void>((resolve) => {
  301. resolveStart = resolve;
  302. }),
  303. );
  304. const { createStudioServer } = await import("./server.js");
  305. const app = createStudioServer(cloneProjectConfig() as never, root);
  306. const responseOrTimeout = await Promise.race([
  307. app.request("http://localhost/api/v1/daemon/start", { method: "POST" }),
  308. new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 30)),
  309. ]);
  310. expect(responseOrTimeout).not.toBe("timeout");
  311. const response = responseOrTimeout as Response;
  312. expect(response.status).toBe(200);
  313. await expect(response.json()).resolves.toMatchObject({ ok: true, running: true });
  314. const status = await app.request("http://localhost/api/v1/daemon");
  315. await expect(status.json()).resolves.toEqual({ running: true });
  316. resolveStart?.();
  317. });
  318. it("rejects book routes with path traversal ids", async () => {
  319. const { createStudioServer } = await import("./server.js");
  320. const app = createStudioServer(cloneProjectConfig() as never, root);
  321. const response = await app.request("http://localhost/api/v1/books/..%2Fetc%2Fpasswd", {
  322. method: "GET",
  323. });
  324. expect(response.status).toBe(400);
  325. await expect(response.json()).resolves.toEqual({
  326. error: {
  327. code: "INVALID_BOOK_ID",
  328. message: 'Invalid book ID: "../etc/passwd"',
  329. },
  330. });
  331. });
  332. it("allows reading and updating fixed control truth files", async () => {
  333. const bookDir = join(root, "books", "demo-book");
  334. const storyDir = join(bookDir, "story");
  335. await mkdir(storyDir, { recursive: true });
  336. await Promise.all([
  337. writeFile(join(storyDir, "author_intent.md"), "# Author Intent\n\nStay cold.\n", "utf-8"),
  338. writeFile(join(storyDir, "current_focus.md"), "# Current Focus\n\nReturn to the old case.\n", "utf-8"),
  339. ]);
  340. const { createStudioServer } = await import("./server.js");
  341. const app = createStudioServer(cloneProjectConfig() as never, root);
  342. const readAuthorIntent = await app.request("http://localhost/api/v1/books/demo-book/truth/author_intent.md");
  343. expect(readAuthorIntent.status).toBe(200);
  344. await expect(readAuthorIntent.json()).resolves.toMatchObject({
  345. file: "author_intent.md",
  346. content: "# Author Intent\n\nStay cold.\n",
  347. });
  348. const updateCurrentFocus = await app.request("http://localhost/api/v1/books/demo-book/truth/current_focus.md", {
  349. method: "PUT",
  350. headers: { "Content-Type": "application/json" },
  351. body: JSON.stringify({ content: "# Current Focus\n\nPull focus back to the harbor trail.\n" }),
  352. });
  353. expect(updateCurrentFocus.status).toBe(200);
  354. await expect(readFile(join(storyDir, "current_focus.md"), "utf-8")).resolves.toBe(
  355. "# Current Focus\n\nPull focus back to the harbor trail.\n",
  356. );
  357. });
  358. it("reflects project edits immediately without restarting the studio server", async () => {
  359. const { createStudioServer } = await import("./server.js");
  360. const app = createStudioServer(cloneProjectConfig() as never, root);
  361. const save = await app.request("http://localhost/api/v1/project", {
  362. method: "PUT",
  363. headers: { "Content-Type": "application/json" },
  364. body: JSON.stringify({
  365. language: "en",
  366. temperature: 0.2,
  367. maxTokens: 2048,
  368. stream: true,
  369. }),
  370. });
  371. expect(save.status).toBe(200);
  372. const project = await app.request("http://localhost/api/v1/project");
  373. await expect(project.json()).resolves.toMatchObject({
  374. language: "en",
  375. temperature: 0.2,
  376. maxTokens: 2048,
  377. stream: true,
  378. });
  379. });
  380. it("reloads latest llm config for doctor checks without restarting the studio server", async () => {
  381. const startupConfig = {
  382. ...cloneProjectConfig(),
  383. llm: {
  384. ...cloneProjectConfig().llm,
  385. model: "stale-model",
  386. baseUrl: "https://stale.example.com/v1",
  387. },
  388. };
  389. const freshConfig = {
  390. ...cloneProjectConfig(),
  391. llm: {
  392. ...cloneProjectConfig().llm,
  393. model: "fresh-model",
  394. baseUrl: "https://fresh.example.com/v1",
  395. },
  396. };
  397. loadProjectConfigMock.mockResolvedValue(freshConfig);
  398. const { createStudioServer } = await import("./server.js");
  399. const app = createStudioServer(startupConfig as never, root);
  400. const response = await app.request("http://localhost/api/v1/doctor");
  401. expect(response.status).toBe(200);
  402. expect(createLLMClientMock).toHaveBeenCalledWith(expect.objectContaining({
  403. model: "fresh-model",
  404. baseUrl: "https://fresh.example.com/v1",
  405. }));
  406. expect(chatCompletionMock).toHaveBeenCalledWith(
  407. expect.anything(),
  408. "fresh-model",
  409. expect.any(Array),
  410. expect.objectContaining({ maxTokens: 5 }),
  411. );
  412. });
  413. it("reloads latest llm config for radar scans without restarting the studio server", async () => {
  414. const startupConfig = {
  415. ...cloneProjectConfig(),
  416. llm: {
  417. ...cloneProjectConfig().llm,
  418. model: "stale-model",
  419. baseUrl: "https://stale.example.com/v1",
  420. },
  421. };
  422. const freshConfig = {
  423. ...cloneProjectConfig(),
  424. llm: {
  425. ...cloneProjectConfig().llm,
  426. model: "fresh-model",
  427. baseUrl: "https://fresh.example.com/v1",
  428. },
  429. };
  430. loadProjectConfigMock.mockResolvedValue(freshConfig);
  431. const { createStudioServer } = await import("./server.js");
  432. const app = createStudioServer(startupConfig as never, root);
  433. const response = await app.request("http://localhost/api/v1/radar/scan", {
  434. method: "POST",
  435. });
  436. expect(response.status).toBe(200);
  437. expect(runRadarMock).toHaveBeenCalledTimes(1);
  438. expect(pipelineConfigs.at(-1)).toMatchObject({
  439. model: "fresh-model",
  440. defaultLLMConfig: expect.objectContaining({
  441. model: "fresh-model",
  442. baseUrl: "https://fresh.example.com/v1",
  443. }),
  444. });
  445. });
  446. it("updates the first-run language immediately after the language selector saves", async () => {
  447. const { createStudioServer } = await import("./server.js");
  448. const app = createStudioServer(cloneProjectConfig() as never, root);
  449. const save = await app.request("http://localhost/api/v1/project/language", {
  450. method: "POST",
  451. headers: { "Content-Type": "application/json" },
  452. body: JSON.stringify({ language: "en" }),
  453. });
  454. expect(save.status).toBe(200);
  455. const project = await app.request("http://localhost/api/v1/project");
  456. await expect(project.json()).resolves.toMatchObject({
  457. language: "en",
  458. languageExplicit: true,
  459. });
  460. });
  461. it("merges service config patches instead of overwriting existing services", async () => {
  462. await writeFile(join(root, "inkos.json"), JSON.stringify({
  463. ...projectConfig,
  464. llm: {
  465. services: [
  466. { service: "moonshot", temperature: 1, maxTokens: 4096, apiFormat: "chat", stream: true },
  467. { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1", temperature: 0.9, apiFormat: "responses", stream: false },
  468. ],
  469. defaultModel: "kimi-k2.5",
  470. },
  471. }, null, 2), "utf-8");
  472. const { createStudioServer } = await import("./server.js");
  473. const app = createStudioServer(cloneProjectConfig() as never, root);
  474. const save = await app.request("http://localhost/api/v1/services/config", {
  475. method: "PUT",
  476. headers: { "Content-Type": "application/json" },
  477. body: JSON.stringify({
  478. services: {
  479. moonshot: {
  480. temperature: 0.5,
  481. maxTokens: 2048,
  482. apiFormat: "responses",
  483. stream: false,
  484. },
  485. },
  486. }),
  487. });
  488. expect(save.status).toBe(200);
  489. const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
  490. expect(raw.llm.services).toEqual([
  491. { service: "moonshot", temperature: 0.5, maxTokens: 2048, apiFormat: "responses", stream: false },
  492. { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1", temperature: 0.9, apiFormat: "responses", stream: false },
  493. ]);
  494. });
  495. it("reports config source and detected env overrides for Studio switching", async () => {
  496. await writeFile(join(root, ".env"), [
  497. "INKOS_LLM_PROVIDER=openai",
  498. "INKOS_LLM_BASE_URL=https://project.example.com/v1",
  499. "INKOS_LLM_MODEL=gpt-5.4",
  500. "INKOS_LLM_API_KEY=sk-project",
  501. ].join("\n"), "utf-8");
  502. await writeFile(join(tmpdir(), "inkos-global.env"), [
  503. "INKOS_LLM_PROVIDER=openai",
  504. "INKOS_LLM_BASE_URL=https://global.example.com/v1",
  505. "INKOS_LLM_MODEL=gpt-4o",
  506. "INKOS_LLM_API_KEY=sk-global",
  507. ].join("\n"), "utf-8");
  508. await writeFile(join(root, "inkos.json"), JSON.stringify({
  509. ...projectConfig,
  510. llm: {
  511. ...projectConfig.llm,
  512. configSource: "env",
  513. },
  514. }, null, 2), "utf-8");
  515. const { createStudioServer } = await import("./server.js");
  516. const app = createStudioServer(cloneProjectConfig() as never, root);
  517. const response = await app.request("http://localhost/api/v1/services/config");
  518. expect(response.status).toBe(200);
  519. await expect(response.json()).resolves.toMatchObject({
  520. configSource: "env",
  521. envConfig: {
  522. effectiveSource: "project",
  523. project: {
  524. detected: true,
  525. baseUrl: "https://project.example.com/v1",
  526. model: "gpt-5.4",
  527. hasApiKey: true,
  528. },
  529. global: {
  530. detected: true,
  531. baseUrl: "https://global.example.com/v1",
  532. model: "gpt-4o",
  533. hasApiKey: true,
  534. },
  535. },
  536. });
  537. });
  538. it("allows switching config source without overwriting services", async () => {
  539. await writeFile(join(root, "inkos.json"), JSON.stringify({
  540. ...projectConfig,
  541. llm: {
  542. services: [
  543. { service: "moonshot", temperature: 1, maxTokens: 4096 },
  544. ],
  545. defaultModel: "kimi-k2.5",
  546. configSource: "env",
  547. },
  548. }, null, 2), "utf-8");
  549. const { createStudioServer } = await import("./server.js");
  550. const app = createStudioServer(cloneProjectConfig() as never, root);
  551. const save = await app.request("http://localhost/api/v1/services/config", {
  552. method: "PUT",
  553. headers: { "Content-Type": "application/json" },
  554. body: JSON.stringify({ configSource: "studio" }),
  555. });
  556. expect(save.status).toBe(200);
  557. const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
  558. expect(raw.llm.configSource).toBe("studio");
  559. expect(raw.llm.services).toEqual([
  560. { service: "moonshot", temperature: 1, maxTokens: 4096 },
  561. ]);
  562. expect(raw.llm.defaultModel).toBe("kimi-k2.5");
  563. });
  564. it("tests and lists models for custom services using baseUrl and stored config", async () => {
  565. await writeFile(join(root, "inkos.json"), JSON.stringify({
  566. ...projectConfig,
  567. llm: {
  568. services: [
  569. { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1" },
  570. ],
  571. defaultModel: "corp-chat",
  572. },
  573. }, null, 2), "utf-8");
  574. loadSecretsMock.mockResolvedValue({
  575. services: {
  576. "custom:内网GPT": { apiKey: "sk-corp" },
  577. },
  578. });
  579. getServiceApiKeyMock.mockResolvedValue("sk-corp");
  580. const fetchMock = vi.fn()
  581. .mockResolvedValueOnce({
  582. ok: true,
  583. json: async () => ({ data: [{ id: "corp-chat" }] }),
  584. text: async () => "",
  585. })
  586. .mockResolvedValueOnce({
  587. ok: true,
  588. json: async () => ({ data: [{ id: "corp-chat" }] }),
  589. });
  590. vi.stubGlobal("fetch", fetchMock as typeof fetch);
  591. const { createStudioServer } = await import("./server.js");
  592. const app = createStudioServer(cloneProjectConfig() as never, root);
  593. const testResponse = await app.request("http://localhost/api/v1/services/custom%3A%E5%86%85%E7%BD%91GPT/test", {
  594. method: "POST",
  595. headers: { "Content-Type": "application/json" },
  596. body: JSON.stringify({ apiKey: "sk-corp", baseUrl: "https://llm.internal.corp/v1" }),
  597. });
  598. expect(testResponse.status).toBe(200);
  599. await expect(testResponse.json()).resolves.toMatchObject({
  600. ok: true,
  601. models: [{ id: "corp-chat", name: "corp-chat" }],
  602. });
  603. const modelsResponse = await app.request("http://localhost/api/v1/services/custom%3A%E5%86%85%E7%BD%91GPT/models");
  604. expect(modelsResponse.status).toBe(200);
  605. await expect(modelsResponse.json()).resolves.toMatchObject({
  606. models: [{ id: "corp-chat", name: "corp-chat" }],
  607. });
  608. });
  609. it("rejects create requests when a complete book with the same id already exists", async () => {
  610. await mkdir(join(root, "books", "existing-book", "story"), { recursive: true });
  611. await writeFile(join(root, "books", "existing-book", "book.json"), JSON.stringify({ id: "existing-book" }), "utf-8");
  612. await writeFile(join(root, "books", "existing-book", "story", "story_bible.md"), "# existing", "utf-8");
  613. const { createStudioServer } = await import("./server.js");
  614. const app = createStudioServer(cloneProjectConfig() as never, root);
  615. const response = await app.request("http://localhost/api/v1/books/create", {
  616. method: "POST",
  617. headers: { "Content-Type": "application/json" },
  618. body: JSON.stringify({
  619. title: "Existing Book",
  620. genre: "xuanhuan",
  621. platform: "qidian",
  622. language: "zh",
  623. }),
  624. });
  625. expect(response.status).toBe(409);
  626. await expect(response.json()).resolves.toMatchObject({
  627. error: expect.stringContaining('Book "existing-book" already exists'),
  628. });
  629. expect(processProjectInteractionRequestMock).not.toHaveBeenCalled();
  630. await expect(access(join(root, "books", "existing-book", "story", "story_bible.md"))).resolves.toBeUndefined();
  631. });
  632. it("reports async create failures through the create-status endpoint", async () => {
  633. processProjectInteractionRequestMock.mockRejectedValueOnce(new Error("INKOS_LLM_API_KEY not set"));
  634. const { createStudioServer } = await import("./server.js");
  635. const app = createStudioServer(cloneProjectConfig() as never, root);
  636. const response = await app.request("http://localhost/api/v1/books/create", {
  637. method: "POST",
  638. headers: { "Content-Type": "application/json" },
  639. body: JSON.stringify({
  640. title: "Broken Book",
  641. genre: "xuanhuan",
  642. platform: "qidian",
  643. language: "zh",
  644. }),
  645. });
  646. expect(response.status).toBe(200);
  647. await Promise.resolve();
  648. const status = await app.request("http://localhost/api/v1/books/broken-book/create-status");
  649. expect(status.status).toBe(200);
  650. await expect(status.json()).resolves.toMatchObject({
  651. status: "error",
  652. error: "INKOS_LLM_API_KEY not set",
  653. });
  654. });
  655. it("uses rollback semantics for chapter rejection instead of only flipping status", async () => {
  656. loadChapterIndexMock.mockResolvedValue([
  657. {
  658. number: 3,
  659. title: "Broken Chapter",
  660. status: "ready-for-review",
  661. wordCount: 1800,
  662. createdAt: "2026-04-07T00:00:00.000Z",
  663. updatedAt: "2026-04-07T00:00:00.000Z",
  664. auditIssues: ["continuity"],
  665. lengthWarnings: [],
  666. },
  667. {
  668. number: 4,
  669. title: "Downstream Chapter",
  670. status: "ready-for-review",
  671. wordCount: 1900,
  672. createdAt: "2026-04-07T00:00:00.000Z",
  673. updatedAt: "2026-04-07T00:00:00.000Z",
  674. auditIssues: [],
  675. lengthWarnings: [],
  676. },
  677. ]);
  678. rollbackToChapterMock.mockResolvedValue([3, 4]);
  679. const { createStudioServer } = await import("./server.js");
  680. const app = createStudioServer(cloneProjectConfig() as never, root);
  681. const response = await app.request("http://localhost/api/v1/books/demo-book/chapters/3/reject", {
  682. method: "POST",
  683. });
  684. expect(response.status).toBe(200);
  685. await expect(response.json()).resolves.toEqual({
  686. ok: true,
  687. chapterNumber: 3,
  688. status: "rejected",
  689. rolledBackTo: 2,
  690. discarded: [3, 4],
  691. });
  692. expect(rollbackToChapterMock).toHaveBeenCalledWith("demo-book", 2);
  693. expect(saveChapterIndexMock).not.toHaveBeenCalled();
  694. });
  695. it("routes create requests through the shared structured interaction runtime", async () => {
  696. const { createStudioServer } = await import("./server.js");
  697. const app = createStudioServer(cloneProjectConfig() as never, root);
  698. const response = await app.request("http://localhost/api/v1/books/create", {
  699. method: "POST",
  700. headers: { "Content-Type": "application/json" },
  701. body: JSON.stringify({
  702. title: "New Book",
  703. genre: "urban",
  704. platform: "qidian",
  705. language: "zh",
  706. chapterWordCount: 2600,
  707. targetChapters: 88,
  708. }),
  709. });
  710. expect(response.status).toBe(200);
  711. expect(createInteractionToolsFromDepsMock).toHaveBeenCalledTimes(1);
  712. expect(processProjectInteractionRequestMock).toHaveBeenCalledWith(expect.objectContaining({
  713. projectRoot: root,
  714. request: {
  715. intent: "create_book",
  716. title: "New Book",
  717. genre: "urban",
  718. language: "zh",
  719. platform: "qidian",
  720. chapterWordCount: 2600,
  721. targetChapters: 88,
  722. },
  723. }));
  724. });
  725. it("passes one-off brief into revise requests through pipeline config", async () => {
  726. const { createStudioServer } = await import("./server.js");
  727. const app = createStudioServer(cloneProjectConfig() as never, root);
  728. const response = await app.request("http://localhost/api/v1/books/demo-book/revise/3", {
  729. method: "POST",
  730. headers: { "Content-Type": "application/json" },
  731. body: JSON.stringify({ mode: "rewrite", brief: "把注意力拉回师债主线。" }),
  732. });
  733. expect(response.status).toBe(200);
  734. expect(pipelineConfigs.at(-1)).toMatchObject({ externalContext: "把注意力拉回师债主线。" });
  735. expect(reviseDraftMock).toHaveBeenCalledWith("demo-book", 3, "rewrite");
  736. });
  737. it("exposes a resync endpoint for rebuilding latest chapter truth artifacts", async () => {
  738. const { createStudioServer } = await import("./server.js");
  739. const app = createStudioServer(cloneProjectConfig() as never, root);
  740. const response = await app.request("http://localhost/api/v1/books/demo-book/resync/3", {
  741. method: "POST",
  742. headers: { "Content-Type": "application/json" },
  743. body: JSON.stringify({ brief: "以师债线为准同步状态。" }),
  744. });
  745. expect(response.status).toBe(200);
  746. expect(pipelineConfigs.at(-1)).toMatchObject({ externalContext: "以师债线为准同步状态。" });
  747. expect(resyncChapterArtifactsMock).toHaveBeenCalledWith("demo-book", 3);
  748. });
  749. it("routes export-save through the shared structured interaction runtime", async () => {
  750. const { createStudioServer } = await import("./server.js");
  751. const app = createStudioServer(cloneProjectConfig() as never, root);
  752. const response = await app.request("http://localhost/api/v1/books/demo-book/export-save", {
  753. method: "POST",
  754. headers: { "Content-Type": "application/json" },
  755. body: JSON.stringify({ format: "md", approvedOnly: true }),
  756. });
  757. expect(response.status).toBe(200);
  758. expect(processProjectInteractionRequestMock).toHaveBeenCalledWith(expect.objectContaining({
  759. projectRoot: root,
  760. activeBookId: "demo-book",
  761. request: expect.objectContaining({
  762. intent: "export_book",
  763. bookId: "demo-book",
  764. format: "md",
  765. approvedOnly: true,
  766. }),
  767. }));
  768. await expect(response.json()).resolves.toMatchObject({
  769. ok: true,
  770. chapters: 2,
  771. });
  772. });
  773. it("routes /api/agent through runAgentSession and returns response + sessionId", async () => {
  774. runAgentSessionMock.mockResolvedValueOnce({
  775. responseText: "Completed write_next for demo-book.",
  776. messages: [
  777. { role: "user", content: "continue" },
  778. { role: "assistant", content: "Completed write_next for demo-book." },
  779. ],
  780. });
  781. const { createStudioServer } = await import("./server.js");
  782. const app = createStudioServer(cloneProjectConfig() as never, root);
  783. const response = await app.request("http://localhost/api/v1/agent", {
  784. method: "POST",
  785. headers: { "Content-Type": "application/json" },
  786. body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book" }),
  787. });
  788. expect(response.status).toBe(200);
  789. await expect(response.json()).resolves.toMatchObject({
  790. response: "Completed write_next for demo-book.",
  791. session: expect.objectContaining({
  792. sessionId: "agent-session-1",
  793. }),
  794. });
  795. expect(runAgentSessionMock).toHaveBeenCalledWith(
  796. expect.objectContaining({
  797. bookId: "demo-book",
  798. projectRoot: root,
  799. }),
  800. "continue",
  801. expect.any(Array),
  802. );
  803. });
  804. it("allows /api/agent to use explicit service+model when Studio config has no defaultModel", async () => {
  805. await writeFile(join(root, "inkos.json"), JSON.stringify({
  806. ...projectConfig,
  807. llm: {
  808. configSource: "studio",
  809. services: [
  810. { service: "custom", name: "CodexForMe", baseUrl: "https://api-vip.codex-for.me/v1", apiFormat: "responses", stream: false },
  811. ],
  812. },
  813. }, null, 2), "utf-8");
  814. loadProjectConfigMock.mockImplementation(async () => {
  815. const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8")) as Record<string, unknown>;
  816. return {
  817. ...cloneProjectConfig(),
  818. ...raw,
  819. llm: {
  820. ...cloneProjectConfig().llm,
  821. ...((raw.llm ?? {}) as Record<string, unknown>),
  822. },
  823. daemon: {
  824. ...cloneProjectConfig().daemon,
  825. ...((raw.daemon ?? {}) as Record<string, unknown>),
  826. },
  827. modelOverrides: (raw.modelOverrides ?? {}) as Record<string, unknown>,
  828. notify: (raw.notify ?? []) as unknown[],
  829. };
  830. });
  831. resolveServiceModelMock.mockResolvedValue({
  832. model: { id: "gpt-5.4", provider: "custom", api: "openai-responses" },
  833. apiKey: "sk-test",
  834. });
  835. runAgentSessionMock.mockResolvedValueOnce({
  836. responseText: "你好,我在。",
  837. messages: [
  838. { role: "user", content: "nihao" },
  839. { role: "assistant", content: "你好,我在。" },
  840. ],
  841. });
  842. const { createStudioServer } = await import("./server.js");
  843. const app = createStudioServer(cloneProjectConfig() as never, root);
  844. const response = await app.request("http://localhost/api/v1/agent", {
  845. method: "POST",
  846. headers: { "Content-Type": "application/json" },
  847. body: JSON.stringify({
  848. instruction: "nihao",
  849. service: "custom:CodexForMe",
  850. model: "gpt-5.4",
  851. }),
  852. });
  853. expect(response.status).toBe(200);
  854. await expect(response.json()).resolves.toMatchObject({
  855. response: "你好,我在。",
  856. });
  857. });
  858. it("returns 500 with an error payload when the agent session fails", async () => {
  859. runAgentSessionMock.mockRejectedValueOnce(new Error("boom"));
  860. const { createStudioServer } = await import("./server.js");
  861. const app = createStudioServer(cloneProjectConfig() as never, root);
  862. const response = await app.request("http://localhost/api/v1/agent", {
  863. method: "POST",
  864. headers: { "Content-Type": "application/json" },
  865. body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book" }),
  866. });
  867. expect(response.status).toBe(500);
  868. await expect(response.json()).resolves.toEqual({
  869. error: {
  870. code: "AGENT_ERROR",
  871. message: "boom",
  872. },
  873. });
  874. });
  875. it("probes the upstream when the agent returns empty text and surfaces the real error", async () => {
  876. runAgentSessionMock.mockResolvedValueOnce({
  877. responseText: "",
  878. messages: [{ role: "user", content: "nihao" }],
  879. });
  880. chatCompletionMock.mockRejectedValue(new Error("quota exhausted"));
  881. const { createStudioServer } = await import("./server.js");
  882. const app = createStudioServer(cloneProjectConfig() as never, root);
  883. const response = await app.request("http://localhost/api/v1/agent", {
  884. method: "POST",
  885. headers: { "Content-Type": "application/json" },
  886. body: JSON.stringify({ instruction: "nihao", activeBookId: "demo-book" }),
  887. });
  888. expect(response.status).toBe(502);
  889. await expect(response.json()).resolves.toEqual({
  890. error: {
  891. code: "AGENT_EMPTY_RESPONSE",
  892. message: "quota exhausted",
  893. },
  894. response: "quota exhausted",
  895. });
  896. });
  897. it("falls back to plain chat when the tool-agent returns empty text", async () => {
  898. runAgentSessionMock.mockResolvedValueOnce({
  899. responseText: "",
  900. messages: [{ role: "user", content: "nihao" }],
  901. });
  902. chatCompletionMock.mockResolvedValueOnce({
  903. content: "你好!",
  904. usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
  905. });
  906. const { createStudioServer } = await import("./server.js");
  907. const app = createStudioServer(cloneProjectConfig() as never, root);
  908. const response = await app.request("http://localhost/api/v1/agent", {
  909. method: "POST",
  910. headers: { "Content-Type": "application/json" },
  911. body: JSON.stringify({ instruction: "nihao", activeBookId: "demo-book" }),
  912. });
  913. expect(response.status).toBe(200);
  914. await expect(response.json()).resolves.toEqual({
  915. response: "你好!",
  916. session: { sessionId: "agent-session-1" },
  917. });
  918. });
  919. it("returns the shared interaction session state", async () => {
  920. loadProjectSessionMock.mockResolvedValue({
  921. sessionId: "session-2",
  922. projectRoot: root,
  923. activeBookId: "demo-book",
  924. automationMode: "auto",
  925. messages: [
  926. { role: "user", content: "continue", timestamp: 1 },
  927. ],
  928. });
  929. resolveSessionActiveBookMock.mockResolvedValue("demo-book");
  930. const { createStudioServer } = await import("./server.js");
  931. const app = createStudioServer(cloneProjectConfig() as never, root);
  932. const response = await app.request("http://localhost/api/v1/interaction/session");
  933. expect(response.status).toBe(200);
  934. await expect(response.json()).resolves.toMatchObject({
  935. session: expect.objectContaining({
  936. activeBookId: "demo-book",
  937. automationMode: "auto",
  938. }),
  939. activeBookId: "demo-book",
  940. });
  941. });
  942. it("returns creation-draft state through the shared interaction session endpoint", async () => {
  943. loadProjectSessionMock.mockResolvedValue({
  944. sessionId: "session-3",
  945. projectRoot: root,
  946. automationMode: "semi",
  947. creationDraft: {
  948. concept: "港风商战悬疑,主角从灰产洗白。",
  949. title: "夜港账本",
  950. nextQuestion: "你更想写长篇连载,还是十来章能收住?",
  951. missingFields: ["targetChapters"],
  952. readyToCreate: false,
  953. },
  954. messages: [],
  955. });
  956. resolveSessionActiveBookMock.mockResolvedValue(undefined);
  957. const { createStudioServer } = await import("./server.js");
  958. const app = createStudioServer(cloneProjectConfig() as never, root);
  959. const response = await app.request("http://localhost/api/v1/interaction/session");
  960. expect(response.status).toBe(200);
  961. await expect(response.json()).resolves.toMatchObject({
  962. session: expect.objectContaining({
  963. creationDraft: expect.objectContaining({
  964. title: "夜港账本",
  965. nextQuestion: "你更想写长篇连载,还是十来章能收住?",
  966. }),
  967. }),
  968. });
  969. });
  970. });