server.test.ts 50 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450
  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. type ServicePresetMock = {
  34. providerFamily: "openai" | "anthropic";
  35. baseUrl: string;
  36. modelsBaseUrl: string;
  37. knownModels: string[];
  38. };
  39. const SERVICE_PRESETS_MOCK: Record<string, ServicePresetMock> = {
  40. openai: { providerFamily: "openai", baseUrl: "https://api.openai.com/v1", modelsBaseUrl: "https://api.openai.com/v1", knownModels: [] as string[] },
  41. anthropic: { providerFamily: "anthropic", baseUrl: "https://api.anthropic.com", modelsBaseUrl: "https://api.anthropic.com", knownModels: [] as string[] },
  42. minimax: { providerFamily: "anthropic", baseUrl: "https://api.minimaxi.com/anthropic", modelsBaseUrl: "https://api.minimaxi.com/anthropic", knownModels: [] as string[] },
  43. bailian: { providerFamily: "anthropic", baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic", modelsBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", knownModels: [] as string[] },
  44. custom: { providerFamily: "openai", baseUrl: "", modelsBaseUrl: "", knownModels: [] as string[] },
  45. };
  46. const resolveServicePresetMock = vi.fn((service: string) => SERVICE_PRESETS_MOCK[service]);
  47. const resolveServiceProviderFamilyMock = vi.fn((service: string) => resolveServicePresetMock(service)?.providerFamily);
  48. const resolveServiceModelsBaseUrlMock = vi.fn((service: string) => {
  49. const preset = SERVICE_PRESETS_MOCK[service];
  50. return preset?.modelsBaseUrl ?? preset?.baseUrl;
  51. });
  52. const listModelsForServiceMock = vi.fn(async (service: string, apiKey?: string) => {
  53. const preset = resolveServicePresetMock(service);
  54. if (!preset || service === "custom") return [];
  55. if (preset.knownModels.length > 0) {
  56. return preset.knownModels.map((id) => ({ id, name: id, reasoning: false, contextWindow: 0 }));
  57. }
  58. const modelsBaseUrl = resolveServiceModelsBaseUrlMock(service);
  59. if (!apiKey || !modelsBaseUrl) return [];
  60. const res = await fetch(`${modelsBaseUrl.replace(/\/$/, "")}/models`, {
  61. headers: { Authorization: `Bearer ${apiKey}` },
  62. signal: AbortSignal.timeout(10_000),
  63. });
  64. if (!res.ok) return [];
  65. const json = await res.json() as { data?: Array<{ id: string }> };
  66. return (json.data ?? []).map((model) => ({
  67. id: model.id,
  68. name: model.id,
  69. reasoning: false,
  70. contextWindow: 0,
  71. }));
  72. });
  73. const logger = {
  74. child: () => logger,
  75. info: vi.fn(),
  76. warn: vi.fn(),
  77. error: vi.fn(),
  78. };
  79. vi.mock("@actalk/inkos-core", () => {
  80. class MockStateManager {
  81. constructor(private readonly root: string) {}
  82. async listBooks(): Promise<string[]> {
  83. return [];
  84. }
  85. async loadBookConfig(): Promise<never> {
  86. return await loadBookConfigMock() as never;
  87. }
  88. async loadChapterIndex(bookId: string): Promise<[]> {
  89. return (await loadChapterIndexMock(bookId)) as [];
  90. }
  91. async saveChapterIndex(bookId: string, index: unknown): Promise<void> {
  92. await saveChapterIndexMock(bookId, index);
  93. }
  94. async rollbackToChapter(bookId: string, chapterNumber: number): Promise<number[]> {
  95. return (await rollbackToChapterMock(bookId, chapterNumber)) as number[];
  96. }
  97. async getNextChapterNumber(): Promise<number> {
  98. return 1;
  99. }
  100. async ensureControlDocuments(): Promise<void> {
  101. // no-op in tests
  102. }
  103. bookDir(id: string): string {
  104. return join(this.root, "books", id);
  105. }
  106. }
  107. class MockPipelineRunner {
  108. constructor(config: unknown) {
  109. pipelineConfigs.push(config);
  110. }
  111. initBook = initBookMock;
  112. runRadar = runRadarMock;
  113. reviseDraft = reviseDraftMock;
  114. resyncChapterArtifacts = resyncChapterArtifactsMock;
  115. writeNextChapter = writeNextChapterMock;
  116. }
  117. class MockScheduler {
  118. private running = false;
  119. constructor(_config: unknown) {}
  120. async start(): Promise<void> {
  121. this.running = true;
  122. await schedulerStartMock();
  123. }
  124. stop(): void {
  125. this.running = false;
  126. }
  127. get isRunning(): boolean {
  128. return this.running;
  129. }
  130. }
  131. return {
  132. StateManager: MockStateManager,
  133. PipelineRunner: MockPipelineRunner,
  134. Scheduler: MockScheduler,
  135. createLLMClient: createLLMClientMock,
  136. createLogger: vi.fn(() => logger),
  137. computeAnalytics: vi.fn(() => ({})),
  138. chatCompletion: chatCompletionMock,
  139. loadProjectConfig: loadProjectConfigMock,
  140. processProjectInteractionInput: processProjectInteractionInputMock,
  141. processProjectInteractionRequest: processProjectInteractionRequestMock,
  142. createInteractionToolsFromDeps: createInteractionToolsFromDepsMock,
  143. loadProjectSession: loadProjectSessionMock,
  144. resolveSessionActiveBook: resolveSessionActiveBookMock,
  145. runAgentSession: runAgentSessionMock,
  146. buildAgentSystemPrompt: vi.fn(() => "You are helpful."),
  147. findOrCreateBookSession: findOrCreateBookSessionMock,
  148. loadBookSession: loadBookSessionMock,
  149. persistBookSession: persistBookSessionMock,
  150. appendBookSessionMessage: appendBookSessionMessageMock,
  151. resolveServicePreset: resolveServicePresetMock,
  152. resolveServiceProviderFamily: resolveServiceProviderFamilyMock,
  153. resolveServiceModelsBaseUrl: resolveServiceModelsBaseUrlMock,
  154. resolveServiceModel: resolveServiceModelMock,
  155. loadSecrets: loadSecretsMock,
  156. saveSecrets: saveSecretsMock,
  157. getServiceApiKey: getServiceApiKeyMock,
  158. listModelsForService: listModelsForServiceMock,
  159. GLOBAL_ENV_PATH: join(tmpdir(), "inkos-global.env"),
  160. };
  161. });
  162. const projectConfig = {
  163. name: "studio-test",
  164. version: "0.1.0",
  165. language: "zh",
  166. llm: {
  167. provider: "openai",
  168. baseUrl: "https://api.example.com/v1",
  169. apiKey: "sk-test",
  170. model: "gpt-5.4",
  171. temperature: 0.7,
  172. maxTokens: 4096,
  173. stream: false,
  174. },
  175. daemon: {
  176. schedule: {
  177. radarCron: "0 */6 * * *",
  178. writeCron: "*/15 * * * *",
  179. },
  180. maxConcurrentBooks: 1,
  181. chaptersPerCycle: 1,
  182. retryDelayMs: 30000,
  183. cooldownAfterChapterMs: 0,
  184. maxChaptersPerDay: 50,
  185. },
  186. modelOverrides: {},
  187. notify: [],
  188. } as const;
  189. function cloneProjectConfig() {
  190. return structuredClone(projectConfig);
  191. }
  192. describe("createStudioServer daemon lifecycle", () => {
  193. let root: string;
  194. beforeEach(async () => {
  195. root = await mkdtemp(join(tmpdir(), "inkos-studio-server-"));
  196. await writeFile(join(root, "inkos.json"), JSON.stringify(projectConfig, null, 2), "utf-8");
  197. schedulerStartMock.mockReset();
  198. initBookMock.mockReset();
  199. runRadarMock.mockReset();
  200. reviseDraftMock.mockReset();
  201. resyncChapterArtifactsMock.mockReset();
  202. writeNextChapterMock.mockReset();
  203. rollbackToChapterMock.mockReset();
  204. saveChapterIndexMock.mockReset();
  205. loadChapterIndexMock.mockReset();
  206. loadBookConfigMock.mockReset();
  207. await mkdir(join(root, "books", "demo-book", "chapters"), { recursive: true });
  208. await writeFile(join(root, "books", "demo-book", "chapters", "0003_Demo.md"), "# Demo\n\nBody", "utf-8");
  209. runRadarMock.mockResolvedValue({
  210. marketSummary: "Fresh market summary",
  211. recommendations: [],
  212. });
  213. reviseDraftMock.mockResolvedValue({
  214. chapterNumber: 3,
  215. wordCount: 1800,
  216. fixedIssues: ["focus restored"],
  217. applied: true,
  218. status: "ready-for-review",
  219. });
  220. resyncChapterArtifactsMock.mockResolvedValue({
  221. chapterNumber: 3,
  222. title: "Synced Chapter",
  223. wordCount: 1800,
  224. revised: false,
  225. status: "ready-for-review",
  226. auditResult: { passed: true, issues: [], summary: "synced" },
  227. });
  228. writeNextChapterMock.mockResolvedValue({
  229. chapterNumber: 3,
  230. title: "Rewritten Chapter",
  231. wordCount: 1800,
  232. revised: false,
  233. status: "ready-for-review",
  234. auditResult: { passed: true, issues: [], summary: "rewritten" },
  235. });
  236. createLLMClientMock.mockReset();
  237. createLLMClientMock.mockReturnValue({});
  238. chatCompletionMock.mockReset();
  239. chatCompletionMock.mockResolvedValue({
  240. content: "pong",
  241. usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
  242. });
  243. loadProjectConfigMock.mockReset();
  244. processProjectInteractionInputMock.mockReset();
  245. processProjectInteractionRequestMock.mockReset();
  246. createInteractionToolsFromDepsMock.mockReset();
  247. loadProjectSessionMock.mockReset();
  248. resolveSessionActiveBookMock.mockReset();
  249. createInteractionToolsFromDepsMock.mockReturnValue({});
  250. processProjectInteractionRequestMock.mockResolvedValue({
  251. request: { intent: "create_book" },
  252. session: {
  253. sessionId: "session-structured",
  254. projectRoot: root,
  255. activeBookId: "new-book",
  256. automationMode: "semi",
  257. messages: [],
  258. events: [],
  259. },
  260. details: {
  261. bookId: "new-book",
  262. outputPath: join(root, "books", "demo-book", "demo-book.txt"),
  263. chaptersExported: 2,
  264. },
  265. });
  266. loadProjectSessionMock.mockResolvedValue({
  267. sessionId: "session-1",
  268. projectRoot: root,
  269. automationMode: "semi",
  270. messages: [],
  271. });
  272. resolveSessionActiveBookMock.mockResolvedValue(undefined);
  273. loadProjectConfigMock.mockImplementation(async () => {
  274. const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8")) as Record<string, unknown>;
  275. return {
  276. ...cloneProjectConfig(),
  277. ...raw,
  278. llm: {
  279. ...cloneProjectConfig().llm,
  280. ...((raw.llm ?? {}) as Record<string, unknown>),
  281. },
  282. daemon: {
  283. ...cloneProjectConfig().daemon,
  284. ...((raw.daemon ?? {}) as Record<string, unknown>),
  285. },
  286. modelOverrides: (raw.modelOverrides ?? {}) as Record<string, unknown>,
  287. notify: (raw.notify ?? []) as unknown[],
  288. };
  289. });
  290. loadChapterIndexMock.mockResolvedValue([]);
  291. loadBookConfigMock.mockResolvedValue({
  292. id: "demo-book",
  293. title: "Demo Book",
  294. platform: "qidian",
  295. genre: "xuanhuan",
  296. status: "active",
  297. targetChapters: 100,
  298. chapterWordCount: 3000,
  299. createdAt: "2026-04-12T00:00:00.000Z",
  300. updatedAt: "2026-04-12T00:00:00.000Z",
  301. });
  302. saveChapterIndexMock.mockResolvedValue(undefined);
  303. rollbackToChapterMock.mockResolvedValue([]);
  304. pipelineConfigs.length = 0;
  305. runAgentSessionMock.mockReset();
  306. findOrCreateBookSessionMock.mockReset();
  307. loadBookSessionMock.mockReset();
  308. persistBookSessionMock.mockReset();
  309. appendBookSessionMessageMock.mockReset();
  310. resolveServiceModelMock.mockReset();
  311. loadSecretsMock.mockReset();
  312. saveSecretsMock.mockReset();
  313. getServiceApiKeyMock.mockReset();
  314. resolveServicePresetMock.mockClear();
  315. resolveServiceProviderFamilyMock.mockClear();
  316. resolveServiceModelsBaseUrlMock.mockClear();
  317. listModelsForServiceMock.mockClear();
  318. // Default BookSession for agent tests
  319. const defaultBookSession = {
  320. sessionId: "agent-session-1",
  321. projectRoot: root,
  322. activeBookId: "demo-book",
  323. messages: [],
  324. events: [],
  325. };
  326. findOrCreateBookSessionMock.mockResolvedValue(defaultBookSession);
  327. loadBookSessionMock.mockResolvedValue(null);
  328. persistBookSessionMock.mockResolvedValue(undefined);
  329. appendBookSessionMessageMock.mockImplementation(
  330. (session: unknown, _msg: unknown) => session,
  331. );
  332. runAgentSessionMock.mockResolvedValue({
  333. responseText: "Agent response.",
  334. messages: [],
  335. });
  336. loadSecretsMock.mockResolvedValue({ services: {} });
  337. saveSecretsMock.mockResolvedValue(undefined);
  338. getServiceApiKeyMock.mockResolvedValue(undefined);
  339. });
  340. afterEach(async () => {
  341. await rm(root, { recursive: true, force: true });
  342. await rm(join(tmpdir(), "inkos-global.env"), { force: true });
  343. });
  344. it("returns from /api/daemon/start before the first write cycle finishes", async () => {
  345. let resolveStart: (() => void) | undefined;
  346. schedulerStartMock.mockImplementation(
  347. () =>
  348. new Promise<void>((resolve) => {
  349. resolveStart = resolve;
  350. }),
  351. );
  352. const { createStudioServer } = await import("./server.js");
  353. const app = createStudioServer(cloneProjectConfig() as never, root);
  354. const responseOrTimeout = await Promise.race([
  355. app.request("http://localhost/api/v1/daemon/start", { method: "POST" }),
  356. new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 30)),
  357. ]);
  358. expect(responseOrTimeout).not.toBe("timeout");
  359. const response = responseOrTimeout as Response;
  360. expect(response.status).toBe(200);
  361. await expect(response.json()).resolves.toMatchObject({ ok: true, running: true });
  362. const status = await app.request("http://localhost/api/v1/daemon");
  363. await expect(status.json()).resolves.toEqual({ running: true });
  364. resolveStart?.();
  365. });
  366. it("rejects book routes with path traversal ids", async () => {
  367. const { createStudioServer } = await import("./server.js");
  368. const app = createStudioServer(cloneProjectConfig() as never, root);
  369. const response = await app.request("http://localhost/api/v1/books/..%2Fetc%2Fpasswd", {
  370. method: "GET",
  371. });
  372. expect(response.status).toBe(400);
  373. await expect(response.json()).resolves.toEqual({
  374. error: {
  375. code: "INVALID_BOOK_ID",
  376. message: 'Invalid book ID: "../etc/passwd"',
  377. },
  378. });
  379. });
  380. it("allows reading and updating fixed control truth files", async () => {
  381. const bookDir = join(root, "books", "demo-book");
  382. const storyDir = join(bookDir, "story");
  383. await mkdir(storyDir, { recursive: true });
  384. await Promise.all([
  385. writeFile(join(storyDir, "author_intent.md"), "# Author Intent\n\nStay cold.\n", "utf-8"),
  386. writeFile(join(storyDir, "current_focus.md"), "# Current Focus\n\nReturn to the old case.\n", "utf-8"),
  387. ]);
  388. const { createStudioServer } = await import("./server.js");
  389. const app = createStudioServer(cloneProjectConfig() as never, root);
  390. const readAuthorIntent = await app.request("http://localhost/api/v1/books/demo-book/truth/author_intent.md");
  391. expect(readAuthorIntent.status).toBe(200);
  392. await expect(readAuthorIntent.json()).resolves.toMatchObject({
  393. file: "author_intent.md",
  394. content: "# Author Intent\n\nStay cold.\n",
  395. });
  396. const updateCurrentFocus = await app.request("http://localhost/api/v1/books/demo-book/truth/current_focus.md", {
  397. method: "PUT",
  398. headers: { "Content-Type": "application/json" },
  399. body: JSON.stringify({ content: "# Current Focus\n\nPull focus back to the harbor trail.\n" }),
  400. });
  401. expect(updateCurrentFocus.status).toBe(200);
  402. await expect(readFile(join(storyDir, "current_focus.md"), "utf-8")).resolves.toBe(
  403. "# Current Focus\n\nPull focus back to the harbor trail.\n",
  404. );
  405. });
  406. it("reflects project edits immediately without restarting the studio server", async () => {
  407. const { createStudioServer } = await import("./server.js");
  408. const app = createStudioServer(cloneProjectConfig() as never, root);
  409. const save = await app.request("http://localhost/api/v1/project", {
  410. method: "PUT",
  411. headers: { "Content-Type": "application/json" },
  412. body: JSON.stringify({
  413. language: "en",
  414. temperature: 0.2,
  415. maxTokens: 2048,
  416. stream: true,
  417. }),
  418. });
  419. expect(save.status).toBe(200);
  420. const project = await app.request("http://localhost/api/v1/project");
  421. await expect(project.json()).resolves.toMatchObject({
  422. language: "en",
  423. temperature: 0.2,
  424. maxTokens: 2048,
  425. stream: true,
  426. });
  427. });
  428. it("reloads latest llm config for doctor checks without restarting the studio server", async () => {
  429. const startupConfig = {
  430. ...cloneProjectConfig(),
  431. llm: {
  432. ...cloneProjectConfig().llm,
  433. model: "stale-model",
  434. baseUrl: "https://stale.example.com/v1",
  435. },
  436. };
  437. const freshConfig = {
  438. ...cloneProjectConfig(),
  439. llm: {
  440. ...cloneProjectConfig().llm,
  441. model: "fresh-model",
  442. baseUrl: "https://fresh.example.com/v1",
  443. },
  444. };
  445. loadProjectConfigMock.mockResolvedValue(freshConfig);
  446. // Stub /models so probe doesn't hit the real OpenAI endpoint and short-circuit on 401.
  447. const fetchMock = vi.fn().mockResolvedValue({
  448. ok: false,
  449. status: 404,
  450. text: async () => "Not Found",
  451. });
  452. vi.stubGlobal("fetch", fetchMock as typeof fetch);
  453. const { createStudioServer } = await import("./server.js");
  454. const app = createStudioServer(startupConfig as never, root);
  455. const response = await app.request("http://localhost/api/v1/doctor");
  456. expect(response.status).toBe(200);
  457. expect(createLLMClientMock).toHaveBeenCalledWith(expect.objectContaining({
  458. model: "fresh-model",
  459. baseUrl: "https://fresh.example.com/v1",
  460. }));
  461. expect(chatCompletionMock).toHaveBeenCalledWith(
  462. expect.anything(),
  463. "fresh-model",
  464. expect.any(Array),
  465. expect.objectContaining({ maxTokens: expect.any(Number) }),
  466. );
  467. });
  468. it("auto-falls back to a non-stream probe in doctor checks when the first transport returns empty", async () => {
  469. const freshConfig = {
  470. ...cloneProjectConfig(),
  471. llm: {
  472. ...cloneProjectConfig().llm,
  473. model: "claude-sonnet-4-6",
  474. baseUrl: "https://timesniper.club",
  475. stream: true,
  476. apiFormat: "chat",
  477. },
  478. };
  479. loadProjectConfigMock.mockResolvedValue(freshConfig);
  480. // Stub /models so probe doesn't hit the real OpenAI endpoint and short-circuit on 401.
  481. const fetchMock = vi.fn().mockResolvedValue({
  482. ok: false,
  483. status: 404,
  484. text: async () => "Not Found",
  485. });
  486. vi.stubGlobal("fetch", fetchMock as typeof fetch);
  487. createLLMClientMock.mockImplementation(((cfg: unknown) => cfg) as any);
  488. chatCompletionMock.mockImplementation(async (client: any) => {
  489. if (client.stream === false) {
  490. return {
  491. content: "pong",
  492. usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
  493. };
  494. }
  495. throw new Error("LLM returned empty response from stream");
  496. });
  497. const { createStudioServer } = await import("./server.js");
  498. const app = createStudioServer(freshConfig as never, root);
  499. const response = await app.request("http://localhost/api/v1/doctor");
  500. expect(response.status).toBe(200);
  501. await expect(response.json()).resolves.toMatchObject({
  502. llmConnected: true,
  503. });
  504. expect(createLLMClientMock).toHaveBeenCalledWith(expect.objectContaining({
  505. stream: true,
  506. apiFormat: "chat",
  507. }));
  508. expect(createLLMClientMock).toHaveBeenCalledWith(expect.objectContaining({
  509. stream: false,
  510. apiFormat: "chat",
  511. }));
  512. });
  513. it("reloads latest llm config for radar scans without restarting the studio server", async () => {
  514. const startupConfig = {
  515. ...cloneProjectConfig(),
  516. llm: {
  517. ...cloneProjectConfig().llm,
  518. model: "stale-model",
  519. baseUrl: "https://stale.example.com/v1",
  520. },
  521. };
  522. const freshConfig = {
  523. ...cloneProjectConfig(),
  524. llm: {
  525. ...cloneProjectConfig().llm,
  526. model: "fresh-model",
  527. baseUrl: "https://fresh.example.com/v1",
  528. },
  529. };
  530. loadProjectConfigMock.mockResolvedValue(freshConfig);
  531. const { createStudioServer } = await import("./server.js");
  532. const app = createStudioServer(startupConfig as never, root);
  533. const response = await app.request("http://localhost/api/v1/radar/scan", {
  534. method: "POST",
  535. });
  536. expect(response.status).toBe(200);
  537. expect(runRadarMock).toHaveBeenCalledTimes(1);
  538. expect(pipelineConfigs.at(-1)).toMatchObject({
  539. model: "fresh-model",
  540. defaultLLMConfig: expect.objectContaining({
  541. model: "fresh-model",
  542. baseUrl: "https://fresh.example.com/v1",
  543. }),
  544. });
  545. });
  546. it("updates the first-run language immediately after the language selector saves", async () => {
  547. const { createStudioServer } = await import("./server.js");
  548. const app = createStudioServer(cloneProjectConfig() as never, root);
  549. const save = await app.request("http://localhost/api/v1/project/language", {
  550. method: "POST",
  551. headers: { "Content-Type": "application/json" },
  552. body: JSON.stringify({ language: "en" }),
  553. });
  554. expect(save.status).toBe(200);
  555. const project = await app.request("http://localhost/api/v1/project");
  556. await expect(project.json()).resolves.toMatchObject({
  557. language: "en",
  558. languageExplicit: true,
  559. });
  560. });
  561. it("merges service config patches instead of overwriting existing services", async () => {
  562. await writeFile(join(root, "inkos.json"), JSON.stringify({
  563. ...projectConfig,
  564. llm: {
  565. services: [
  566. { service: "moonshot", temperature: 1, maxTokens: 4096, apiFormat: "chat", stream: true },
  567. { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1", temperature: 0.9, apiFormat: "responses", stream: false },
  568. ],
  569. defaultModel: "kimi-k2.5",
  570. },
  571. }, null, 2), "utf-8");
  572. const { createStudioServer } = await import("./server.js");
  573. const app = createStudioServer(cloneProjectConfig() as never, root);
  574. const save = await app.request("http://localhost/api/v1/services/config", {
  575. method: "PUT",
  576. headers: { "Content-Type": "application/json" },
  577. body: JSON.stringify({
  578. services: {
  579. moonshot: {
  580. temperature: 0.5,
  581. maxTokens: 2048,
  582. apiFormat: "responses",
  583. stream: false,
  584. },
  585. },
  586. }),
  587. });
  588. expect(save.status).toBe(200);
  589. const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
  590. expect(raw.llm.services).toEqual([
  591. { service: "moonshot", temperature: 0.5, maxTokens: 2048, apiFormat: "responses", stream: false },
  592. { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1", temperature: 0.9, apiFormat: "responses", stream: false },
  593. ]);
  594. });
  595. it("reports config source and detected env overrides for Studio switching", async () => {
  596. await writeFile(join(root, ".env"), [
  597. "INKOS_LLM_PROVIDER=openai",
  598. "INKOS_LLM_BASE_URL=https://project.example.com/v1",
  599. "INKOS_LLM_MODEL=gpt-5.4",
  600. "INKOS_LLM_API_KEY=sk-project",
  601. ].join("\n"), "utf-8");
  602. await writeFile(join(tmpdir(), "inkos-global.env"), [
  603. "INKOS_LLM_PROVIDER=openai",
  604. "INKOS_LLM_BASE_URL=https://global.example.com/v1",
  605. "INKOS_LLM_MODEL=gpt-4o",
  606. "INKOS_LLM_API_KEY=sk-global",
  607. ].join("\n"), "utf-8");
  608. await writeFile(join(root, "inkos.json"), JSON.stringify({
  609. ...projectConfig,
  610. llm: {
  611. ...projectConfig.llm,
  612. configSource: "env",
  613. },
  614. }, null, 2), "utf-8");
  615. const { createStudioServer } = await import("./server.js");
  616. const app = createStudioServer(cloneProjectConfig() as never, root);
  617. const response = await app.request("http://localhost/api/v1/services/config");
  618. expect(response.status).toBe(200);
  619. await expect(response.json()).resolves.toMatchObject({
  620. configSource: "env",
  621. envConfig: {
  622. effectiveSource: "project",
  623. project: {
  624. detected: true,
  625. baseUrl: "https://project.example.com/v1",
  626. model: "gpt-5.4",
  627. hasApiKey: true,
  628. },
  629. global: {
  630. detected: true,
  631. baseUrl: "https://global.example.com/v1",
  632. model: "gpt-4o",
  633. hasApiKey: true,
  634. },
  635. },
  636. });
  637. });
  638. it("allows switching config source without overwriting services", async () => {
  639. await writeFile(join(root, "inkos.json"), JSON.stringify({
  640. ...projectConfig,
  641. llm: {
  642. services: [
  643. { service: "moonshot", temperature: 1, maxTokens: 4096 },
  644. ],
  645. defaultModel: "kimi-k2.5",
  646. configSource: "env",
  647. },
  648. }, null, 2), "utf-8");
  649. const { createStudioServer } = await import("./server.js");
  650. const app = createStudioServer(cloneProjectConfig() as never, root);
  651. const save = await app.request("http://localhost/api/v1/services/config", {
  652. method: "PUT",
  653. headers: { "Content-Type": "application/json" },
  654. body: JSON.stringify({ configSource: "studio" }),
  655. });
  656. expect(save.status).toBe(200);
  657. const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
  658. expect(raw.llm.configSource).toBe("studio");
  659. expect(raw.llm.services).toEqual([
  660. { service: "moonshot", temperature: 1, maxTokens: 4096 },
  661. ]);
  662. expect(raw.llm.defaultModel).toBe("kimi-k2.5");
  663. });
  664. it("tests and lists models for custom services using baseUrl and stored config", async () => {
  665. await writeFile(join(root, "inkos.json"), JSON.stringify({
  666. ...projectConfig,
  667. llm: {
  668. services: [
  669. { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1" },
  670. ],
  671. defaultModel: "corp-chat",
  672. },
  673. }, null, 2), "utf-8");
  674. loadSecretsMock.mockResolvedValue({
  675. services: {
  676. "custom:内网GPT": { apiKey: "sk-corp" },
  677. },
  678. });
  679. getServiceApiKeyMock.mockResolvedValue("sk-corp");
  680. const fetchMock = vi.fn()
  681. .mockResolvedValueOnce({
  682. ok: true,
  683. json: async () => ({ data: [{ id: "corp-chat" }] }),
  684. text: async () => "",
  685. })
  686. .mockResolvedValueOnce({
  687. ok: true,
  688. json: async () => ({ data: [{ id: "corp-chat" }] }),
  689. });
  690. vi.stubGlobal("fetch", fetchMock as typeof fetch);
  691. const { createStudioServer } = await import("./server.js");
  692. const app = createStudioServer(cloneProjectConfig() as never, root);
  693. const testResponse = await app.request("http://localhost/api/v1/services/custom%3A%E5%86%85%E7%BD%91GPT/test", {
  694. method: "POST",
  695. headers: { "Content-Type": "application/json" },
  696. body: JSON.stringify({ apiKey: "sk-corp", baseUrl: "https://llm.internal.corp/v1" }),
  697. });
  698. expect(testResponse.status).toBe(200);
  699. await expect(testResponse.json()).resolves.toMatchObject({
  700. ok: true,
  701. models: [{ id: "corp-chat", name: "corp-chat" }],
  702. });
  703. const modelsResponse = await app.request("http://localhost/api/v1/services/custom%3A%E5%86%85%E7%BD%91GPT/models");
  704. expect(modelsResponse.status).toBe(200);
  705. await expect(modelsResponse.json()).resolves.toMatchObject({
  706. models: [{ id: "corp-chat", name: "corp-chat" }],
  707. });
  708. });
  709. it("auto-detects a working custom combination when /models is unavailable", async () => {
  710. await writeFile(join(root, "inkos.json"), JSON.stringify({
  711. ...projectConfig,
  712. llm: {
  713. configSource: "env",
  714. services: [
  715. { service: "custom", name: "MiniMax", baseUrl: "https://api.minimax.com/v1" },
  716. ],
  717. },
  718. }, null, 2), "utf-8");
  719. await writeFile(join(root, ".env"), [
  720. "INKOS_LLM_MODEL=MiniMax-M2.7",
  721. "INKOS_LLM_BASE_URL=https://api.minimax.com/v1",
  722. "INKOS_LLM_API_KEY=sk-minimax",
  723. ].join("\n"), "utf-8");
  724. createLLMClientMock.mockImplementation(((cfg: unknown) => cfg) as any);
  725. chatCompletionMock.mockImplementation(async (client: any) => {
  726. if (client.apiFormat === "chat" && client.stream === false) {
  727. return {
  728. content: "pong",
  729. usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
  730. };
  731. }
  732. throw new Error("LLM returned empty response from stream");
  733. });
  734. const fetchMock = vi.fn().mockResolvedValue({
  735. ok: false,
  736. status: 404,
  737. text: async () => "404 page not found",
  738. });
  739. vi.stubGlobal("fetch", fetchMock as typeof fetch);
  740. const { createStudioServer } = await import("./server.js");
  741. const app = createStudioServer(cloneProjectConfig() as never, root);
  742. const response = await app.request("http://localhost/api/v1/services/custom%3AMiniMax/test", {
  743. method: "POST",
  744. headers: { "Content-Type": "application/json" },
  745. body: JSON.stringify({
  746. apiKey: "sk-minimax",
  747. baseUrl: "https://api.minimax.com/v1",
  748. apiFormat: "chat",
  749. stream: true,
  750. }),
  751. });
  752. expect(response.status).toBe(200);
  753. await expect(response.json()).resolves.toMatchObject({
  754. ok: true,
  755. selectedModel: "MiniMax-M2.7",
  756. detected: {
  757. apiFormat: "chat",
  758. stream: false,
  759. modelsSource: "fallback",
  760. },
  761. models: [],
  762. });
  763. });
  764. it("falls back to the detected/default model when custom /models is unavailable", async () => {
  765. await writeFile(join(root, "inkos.json"), JSON.stringify({
  766. ...projectConfig,
  767. llm: {
  768. defaultModel: "MiniMax-M2.7",
  769. services: [
  770. { service: "custom", name: "MiniMax", baseUrl: "https://api.minimax.com/v1", apiFormat: "chat", stream: false },
  771. ],
  772. },
  773. }, null, 2), "utf-8");
  774. getServiceApiKeyMock.mockResolvedValue("sk-minimax");
  775. const fetchMock = vi.fn().mockResolvedValue({
  776. ok: false,
  777. status: 404,
  778. text: async () => "404 page not found",
  779. });
  780. vi.stubGlobal("fetch", fetchMock as typeof fetch);
  781. createLLMClientMock.mockImplementation(((cfg: unknown) => cfg) as any);
  782. chatCompletionMock.mockResolvedValue({
  783. content: "pong",
  784. usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
  785. });
  786. const { createStudioServer } = await import("./server.js");
  787. const app = createStudioServer(cloneProjectConfig() as never, root);
  788. const response = await app.request("http://localhost/api/v1/services/custom%3AMiniMax/models");
  789. expect(response.status).toBe(200);
  790. await expect(response.json()).resolves.toMatchObject({
  791. models: [],
  792. });
  793. });
  794. it("short-circuits service probe on 401/403 from /models", async () => {
  795. const fetchMock = vi.fn().mockResolvedValue({
  796. ok: false,
  797. status: 401,
  798. text: async () => "Unauthorized",
  799. });
  800. vi.stubGlobal("fetch", fetchMock as typeof fetch);
  801. createLLMClientMock.mockImplementation(((cfg: unknown) => cfg) as any);
  802. const { createStudioServer } = await import("./server.js");
  803. const app = createStudioServer(cloneProjectConfig() as never, root);
  804. const response = await app.request("http://localhost/api/v1/services/openai/test", {
  805. method: "POST",
  806. headers: { "Content-Type": "application/json" },
  807. body: JSON.stringify({
  808. apiKey: "sk-invalid",
  809. apiFormat: "responses",
  810. stream: false,
  811. }),
  812. });
  813. expect(response.status).toBe(400);
  814. await expect(response.json()).resolves.toMatchObject({
  815. ok: false,
  816. error: expect.stringContaining("401"),
  817. });
  818. expect(chatCompletionMock).not.toHaveBeenCalled();
  819. });
  820. it("uses the MiniMax preset provider family during service probe", async () => {
  821. await writeFile(join(root, "inkos.json"), JSON.stringify({
  822. ...projectConfig,
  823. llm: {
  824. services: [
  825. { service: "minimax", apiFormat: "chat", stream: false },
  826. ],
  827. defaultModel: "MiniMax-M2.7",
  828. },
  829. }, null, 2), "utf-8");
  830. const fetchMock = vi.fn().mockResolvedValue({
  831. ok: false,
  832. status: 404,
  833. text: async () => "404 page not found",
  834. });
  835. vi.stubGlobal("fetch", fetchMock as typeof fetch);
  836. createLLMClientMock.mockImplementation(((cfg: unknown) => cfg) as any);
  837. chatCompletionMock.mockImplementation(async (client: any, model: string) => {
  838. if (client.provider === "anthropic" && client.baseUrl === "https://api.minimaxi.com/anthropic" && model === "MiniMax-M2.7") {
  839. return {
  840. content: "pong",
  841. usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
  842. };
  843. }
  844. throw new Error(`unexpected probe route: ${client.provider} ${client.baseUrl} ${model}`);
  845. });
  846. const { createStudioServer } = await import("./server.js");
  847. const app = createStudioServer(cloneProjectConfig() as never, root);
  848. const response = await app.request("http://localhost/api/v1/services/minimax/test", {
  849. method: "POST",
  850. headers: { "Content-Type": "application/json" },
  851. body: JSON.stringify({
  852. apiKey: "sk-minimax",
  853. apiFormat: "chat",
  854. stream: false,
  855. }),
  856. });
  857. expect(response.status).toBe(200);
  858. await expect(response.json()).resolves.toMatchObject({
  859. ok: true,
  860. selectedModel: "MiniMax-M2.7",
  861. detected: {
  862. apiFormat: "chat",
  863. stream: false,
  864. baseUrl: "https://api.minimaxi.com/anthropic",
  865. },
  866. });
  867. });
  868. it("uses the preset models baseUrl when listing Bailian models", async () => {
  869. await writeFile(join(root, "inkos.json"), JSON.stringify({
  870. ...projectConfig,
  871. llm: {
  872. services: [
  873. { service: "bailian", apiFormat: "chat", stream: false },
  874. ],
  875. defaultModel: "qwen-max",
  876. },
  877. }, null, 2), "utf-8");
  878. getServiceApiKeyMock.mockResolvedValue("sk-bailian");
  879. const fetchMock = vi.fn(async (input: string | URL | Request) => {
  880. const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
  881. if (url === "https://dashscope.aliyuncs.com/compatible-mode/v1/models") {
  882. return {
  883. ok: true,
  884. json: async () => ({ data: [{ id: "qwen-max" }] }),
  885. text: async (): Promise<string> => "",
  886. };
  887. }
  888. return {
  889. ok: false,
  890. status: 404,
  891. text: async () => "404 page not found",
  892. };
  893. });
  894. vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
  895. createLLMClientMock.mockImplementation(((cfg: unknown) => cfg) as any);
  896. chatCompletionMock.mockImplementation(async (client: any, model: string) => {
  897. if (client.provider === "anthropic" && client.baseUrl === "https://dashscope.aliyuncs.com/apps/anthropic" && model === "qwen-max") {
  898. return {
  899. content: "pong",
  900. usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
  901. };
  902. }
  903. throw new Error(`unexpected bailian route: ${client.provider} ${client.baseUrl} ${model}`);
  904. });
  905. const { createStudioServer } = await import("./server.js");
  906. const app = createStudioServer(cloneProjectConfig() as never, root);
  907. const response = await app.request("http://localhost/api/v1/services/bailian/models");
  908. expect(response.status).toBe(200);
  909. await expect(response.json()).resolves.toMatchObject({
  910. models: [{ id: "qwen-max", name: "qwen-max" }],
  911. });
  912. expect(fetchMock).toHaveBeenCalledWith(
  913. "https://dashscope.aliyuncs.com/compatible-mode/v1/models",
  914. expect.any(Object),
  915. );
  916. });
  917. it("returns stored service secret for detail page rehydration", async () => {
  918. loadSecretsMock.mockResolvedValue({
  919. services: {
  920. moonshot: { apiKey: "sk-moon" },
  921. },
  922. });
  923. const { createStudioServer } = await import("./server.js");
  924. const app = createStudioServer(cloneProjectConfig() as never, root);
  925. const response = await app.request("http://localhost/api/v1/services/moonshot/secret");
  926. expect(response.status).toBe(200);
  927. await expect(response.json()).resolves.toEqual({ apiKey: "sk-moon" });
  928. });
  929. it("rejects create requests when a complete book with the same id already exists", async () => {
  930. await mkdir(join(root, "books", "existing-book", "story"), { recursive: true });
  931. await writeFile(join(root, "books", "existing-book", "book.json"), JSON.stringify({ id: "existing-book" }), "utf-8");
  932. await writeFile(join(root, "books", "existing-book", "story", "story_bible.md"), "# existing", "utf-8");
  933. const { createStudioServer } = await import("./server.js");
  934. const app = createStudioServer(cloneProjectConfig() as never, root);
  935. const response = await app.request("http://localhost/api/v1/books/create", {
  936. method: "POST",
  937. headers: { "Content-Type": "application/json" },
  938. body: JSON.stringify({
  939. title: "Existing Book",
  940. genre: "xuanhuan",
  941. platform: "qidian",
  942. language: "zh",
  943. }),
  944. });
  945. expect(response.status).toBe(409);
  946. await expect(response.json()).resolves.toMatchObject({
  947. error: expect.stringContaining('Book "existing-book" already exists'),
  948. });
  949. expect(processProjectInteractionRequestMock).not.toHaveBeenCalled();
  950. await expect(access(join(root, "books", "existing-book", "story", "story_bible.md"))).resolves.toBeUndefined();
  951. });
  952. it("reports async create failures through the create-status endpoint", async () => {
  953. processProjectInteractionRequestMock.mockRejectedValueOnce(new Error("INKOS_LLM_API_KEY not set"));
  954. const { createStudioServer } = await import("./server.js");
  955. const app = createStudioServer(cloneProjectConfig() as never, root);
  956. const response = await app.request("http://localhost/api/v1/books/create", {
  957. method: "POST",
  958. headers: { "Content-Type": "application/json" },
  959. body: JSON.stringify({
  960. title: "Broken Book",
  961. genre: "xuanhuan",
  962. platform: "qidian",
  963. language: "zh",
  964. }),
  965. });
  966. expect(response.status).toBe(200);
  967. await Promise.resolve();
  968. const status = await app.request("http://localhost/api/v1/books/broken-book/create-status");
  969. expect(status.status).toBe(200);
  970. await expect(status.json()).resolves.toMatchObject({
  971. status: "error",
  972. error: "INKOS_LLM_API_KEY not set",
  973. });
  974. });
  975. it("uses rollback semantics for chapter rejection instead of only flipping status", async () => {
  976. loadChapterIndexMock.mockResolvedValue([
  977. {
  978. number: 3,
  979. title: "Broken Chapter",
  980. status: "ready-for-review",
  981. wordCount: 1800,
  982. createdAt: "2026-04-07T00:00:00.000Z",
  983. updatedAt: "2026-04-07T00:00:00.000Z",
  984. auditIssues: ["continuity"],
  985. lengthWarnings: [],
  986. },
  987. {
  988. number: 4,
  989. title: "Downstream Chapter",
  990. status: "ready-for-review",
  991. wordCount: 1900,
  992. createdAt: "2026-04-07T00:00:00.000Z",
  993. updatedAt: "2026-04-07T00:00:00.000Z",
  994. auditIssues: [],
  995. lengthWarnings: [],
  996. },
  997. ]);
  998. rollbackToChapterMock.mockResolvedValue([3, 4]);
  999. const { createStudioServer } = await import("./server.js");
  1000. const app = createStudioServer(cloneProjectConfig() as never, root);
  1001. const response = await app.request("http://localhost/api/v1/books/demo-book/chapters/3/reject", {
  1002. method: "POST",
  1003. });
  1004. expect(response.status).toBe(200);
  1005. await expect(response.json()).resolves.toEqual({
  1006. ok: true,
  1007. chapterNumber: 3,
  1008. status: "rejected",
  1009. rolledBackTo: 2,
  1010. discarded: [3, 4],
  1011. });
  1012. expect(rollbackToChapterMock).toHaveBeenCalledWith("demo-book", 2);
  1013. expect(saveChapterIndexMock).not.toHaveBeenCalled();
  1014. });
  1015. it("routes create requests through the shared structured interaction runtime", async () => {
  1016. const { createStudioServer } = await import("./server.js");
  1017. const app = createStudioServer(cloneProjectConfig() as never, root);
  1018. const response = await app.request("http://localhost/api/v1/books/create", {
  1019. method: "POST",
  1020. headers: { "Content-Type": "application/json" },
  1021. body: JSON.stringify({
  1022. title: "New Book",
  1023. genre: "urban",
  1024. platform: "qidian",
  1025. language: "zh",
  1026. chapterWordCount: 2600,
  1027. targetChapters: 88,
  1028. }),
  1029. });
  1030. expect(response.status).toBe(200);
  1031. expect(createInteractionToolsFromDepsMock).toHaveBeenCalledTimes(1);
  1032. expect(processProjectInteractionRequestMock).toHaveBeenCalledWith(expect.objectContaining({
  1033. projectRoot: root,
  1034. request: {
  1035. intent: "create_book",
  1036. title: "New Book",
  1037. genre: "urban",
  1038. language: "zh",
  1039. platform: "qidian",
  1040. chapterWordCount: 2600,
  1041. targetChapters: 88,
  1042. },
  1043. }));
  1044. });
  1045. it("passes one-off brief into revise requests through pipeline config", async () => {
  1046. const { createStudioServer } = await import("./server.js");
  1047. const app = createStudioServer(cloneProjectConfig() as never, root);
  1048. const response = await app.request("http://localhost/api/v1/books/demo-book/revise/3", {
  1049. method: "POST",
  1050. headers: { "Content-Type": "application/json" },
  1051. body: JSON.stringify({ mode: "rewrite", brief: "把注意力拉回师债主线。" }),
  1052. });
  1053. expect(response.status).toBe(200);
  1054. expect(pipelineConfigs.at(-1)).toMatchObject({ externalContext: "把注意力拉回师债主线。" });
  1055. expect(reviseDraftMock).toHaveBeenCalledWith("demo-book", 3, "rewrite");
  1056. });
  1057. it("exposes a resync endpoint for rebuilding latest chapter truth artifacts", async () => {
  1058. const { createStudioServer } = await import("./server.js");
  1059. const app = createStudioServer(cloneProjectConfig() as never, root);
  1060. const response = await app.request("http://localhost/api/v1/books/demo-book/resync/3", {
  1061. method: "POST",
  1062. headers: { "Content-Type": "application/json" },
  1063. body: JSON.stringify({ brief: "以师债线为准同步状态。" }),
  1064. });
  1065. expect(response.status).toBe(200);
  1066. expect(pipelineConfigs.at(-1)).toMatchObject({ externalContext: "以师债线为准同步状态。" });
  1067. expect(resyncChapterArtifactsMock).toHaveBeenCalledWith("demo-book", 3);
  1068. });
  1069. it("routes export-save through the shared structured interaction runtime", async () => {
  1070. const { createStudioServer } = await import("./server.js");
  1071. const app = createStudioServer(cloneProjectConfig() as never, root);
  1072. const response = await app.request("http://localhost/api/v1/books/demo-book/export-save", {
  1073. method: "POST",
  1074. headers: { "Content-Type": "application/json" },
  1075. body: JSON.stringify({ format: "md", approvedOnly: true }),
  1076. });
  1077. expect(response.status).toBe(200);
  1078. expect(processProjectInteractionRequestMock).toHaveBeenCalledWith(expect.objectContaining({
  1079. projectRoot: root,
  1080. activeBookId: "demo-book",
  1081. request: expect.objectContaining({
  1082. intent: "export_book",
  1083. bookId: "demo-book",
  1084. format: "md",
  1085. approvedOnly: true,
  1086. }),
  1087. }));
  1088. await expect(response.json()).resolves.toMatchObject({
  1089. ok: true,
  1090. chapters: 2,
  1091. });
  1092. });
  1093. it("routes /api/agent through runAgentSession and returns response + sessionId", async () => {
  1094. runAgentSessionMock.mockResolvedValueOnce({
  1095. responseText: "Completed write_next for demo-book.",
  1096. messages: [
  1097. { role: "user", content: "continue" },
  1098. { role: "assistant", content: "Completed write_next for demo-book." },
  1099. ],
  1100. });
  1101. const { createStudioServer } = await import("./server.js");
  1102. const app = createStudioServer(cloneProjectConfig() as never, root);
  1103. const response = await app.request("http://localhost/api/v1/agent", {
  1104. method: "POST",
  1105. headers: { "Content-Type": "application/json" },
  1106. body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book" }),
  1107. });
  1108. expect(response.status).toBe(200);
  1109. await expect(response.json()).resolves.toMatchObject({
  1110. response: "Completed write_next for demo-book.",
  1111. session: expect.objectContaining({
  1112. sessionId: "agent-session-1",
  1113. }),
  1114. });
  1115. expect(runAgentSessionMock).toHaveBeenCalledWith(
  1116. expect.objectContaining({
  1117. bookId: "demo-book",
  1118. projectRoot: root,
  1119. }),
  1120. "continue",
  1121. expect.any(Array),
  1122. );
  1123. });
  1124. it("allows /api/agent to use explicit service+model when Studio config has no defaultModel", async () => {
  1125. await writeFile(join(root, "inkos.json"), JSON.stringify({
  1126. ...projectConfig,
  1127. llm: {
  1128. configSource: "studio",
  1129. services: [
  1130. { service: "custom", name: "CodexForMe", baseUrl: "https://api-vip.codex-for.me/v1", apiFormat: "responses", stream: false },
  1131. ],
  1132. },
  1133. }, null, 2), "utf-8");
  1134. loadProjectConfigMock.mockImplementation(async () => {
  1135. const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8")) as Record<string, unknown>;
  1136. return {
  1137. ...cloneProjectConfig(),
  1138. ...raw,
  1139. llm: {
  1140. ...cloneProjectConfig().llm,
  1141. ...((raw.llm ?? {}) as Record<string, unknown>),
  1142. },
  1143. daemon: {
  1144. ...cloneProjectConfig().daemon,
  1145. ...((raw.daemon ?? {}) as Record<string, unknown>),
  1146. },
  1147. modelOverrides: (raw.modelOverrides ?? {}) as Record<string, unknown>,
  1148. notify: (raw.notify ?? []) as unknown[],
  1149. };
  1150. });
  1151. resolveServiceModelMock.mockResolvedValue({
  1152. model: { id: "gpt-5.4", provider: "custom", api: "openai-responses" },
  1153. apiKey: "sk-test",
  1154. });
  1155. runAgentSessionMock.mockResolvedValueOnce({
  1156. responseText: "你好,我在。",
  1157. messages: [
  1158. { role: "user", content: "nihao" },
  1159. { role: "assistant", content: "你好,我在。" },
  1160. ],
  1161. });
  1162. const { createStudioServer } = await import("./server.js");
  1163. const app = createStudioServer(cloneProjectConfig() as never, root);
  1164. const response = await app.request("http://localhost/api/v1/agent", {
  1165. method: "POST",
  1166. headers: { "Content-Type": "application/json" },
  1167. body: JSON.stringify({
  1168. instruction: "nihao",
  1169. service: "custom:CodexForMe",
  1170. model: "gpt-5.4",
  1171. }),
  1172. });
  1173. expect(response.status).toBe(200);
  1174. await expect(response.json()).resolves.toMatchObject({
  1175. response: "你好,我在。",
  1176. });
  1177. });
  1178. it("returns 500 with an error payload when the agent session fails", async () => {
  1179. runAgentSessionMock.mockRejectedValueOnce(new Error("boom"));
  1180. const { createStudioServer } = await import("./server.js");
  1181. const app = createStudioServer(cloneProjectConfig() as never, root);
  1182. const response = await app.request("http://localhost/api/v1/agent", {
  1183. method: "POST",
  1184. headers: { "Content-Type": "application/json" },
  1185. body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book" }),
  1186. });
  1187. expect(response.status).toBe(500);
  1188. await expect(response.json()).resolves.toEqual({
  1189. error: {
  1190. code: "AGENT_ERROR",
  1191. message: "boom",
  1192. },
  1193. });
  1194. });
  1195. it("probes the upstream when the agent returns empty text and surfaces the real error", async () => {
  1196. runAgentSessionMock.mockResolvedValueOnce({
  1197. responseText: "",
  1198. messages: [{ role: "user", content: "nihao" }],
  1199. });
  1200. chatCompletionMock.mockRejectedValue(new Error("quota exhausted"));
  1201. const { createStudioServer } = await import("./server.js");
  1202. const app = createStudioServer(cloneProjectConfig() as never, root);
  1203. const response = await app.request("http://localhost/api/v1/agent", {
  1204. method: "POST",
  1205. headers: { "Content-Type": "application/json" },
  1206. body: JSON.stringify({ instruction: "nihao", activeBookId: "demo-book" }),
  1207. });
  1208. expect(response.status).toBe(502);
  1209. await expect(response.json()).resolves.toEqual({
  1210. error: {
  1211. code: "AGENT_EMPTY_RESPONSE",
  1212. message: "quota exhausted",
  1213. },
  1214. response: "quota exhausted",
  1215. });
  1216. });
  1217. it("falls back to plain chat when the tool-agent returns empty text", async () => {
  1218. runAgentSessionMock.mockResolvedValueOnce({
  1219. responseText: "",
  1220. messages: [{ role: "user", content: "nihao" }],
  1221. });
  1222. chatCompletionMock.mockResolvedValueOnce({
  1223. content: "你好!",
  1224. usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
  1225. });
  1226. const { createStudioServer } = await import("./server.js");
  1227. const app = createStudioServer(cloneProjectConfig() as never, root);
  1228. const response = await app.request("http://localhost/api/v1/agent", {
  1229. method: "POST",
  1230. headers: { "Content-Type": "application/json" },
  1231. body: JSON.stringify({ instruction: "nihao", activeBookId: "demo-book" }),
  1232. });
  1233. expect(response.status).toBe(200);
  1234. await expect(response.json()).resolves.toEqual({
  1235. response: "你好!",
  1236. session: { sessionId: "agent-session-1" },
  1237. });
  1238. });
  1239. it("returns the shared interaction session state", async () => {
  1240. loadProjectSessionMock.mockResolvedValue({
  1241. sessionId: "session-2",
  1242. projectRoot: root,
  1243. activeBookId: "demo-book",
  1244. automationMode: "auto",
  1245. messages: [
  1246. { role: "user", content: "continue", timestamp: 1 },
  1247. ],
  1248. });
  1249. resolveSessionActiveBookMock.mockResolvedValue("demo-book");
  1250. const { createStudioServer } = await import("./server.js");
  1251. const app = createStudioServer(cloneProjectConfig() as never, root);
  1252. const response = await app.request("http://localhost/api/v1/interaction/session");
  1253. expect(response.status).toBe(200);
  1254. await expect(response.json()).resolves.toMatchObject({
  1255. session: expect.objectContaining({
  1256. activeBookId: "demo-book",
  1257. automationMode: "auto",
  1258. }),
  1259. activeBookId: "demo-book",
  1260. });
  1261. });
  1262. it("returns creation-draft state through the shared interaction session endpoint", async () => {
  1263. loadProjectSessionMock.mockResolvedValue({
  1264. sessionId: "session-3",
  1265. projectRoot: root,
  1266. automationMode: "semi",
  1267. creationDraft: {
  1268. concept: "港风商战悬疑,主角从灰产洗白。",
  1269. title: "夜港账本",
  1270. nextQuestion: "你更想写长篇连载,还是十来章能收住?",
  1271. missingFields: ["targetChapters"],
  1272. readyToCreate: false,
  1273. },
  1274. messages: [],
  1275. });
  1276. resolveSessionActiveBookMock.mockResolvedValue(undefined);
  1277. const { createStudioServer } = await import("./server.js");
  1278. const app = createStudioServer(cloneProjectConfig() as never, root);
  1279. const response = await app.request("http://localhost/api/v1/interaction/session");
  1280. expect(response.status).toBe(200);
  1281. await expect(response.json()).resolves.toMatchObject({
  1282. session: expect.objectContaining({
  1283. creationDraft: expect.objectContaining({
  1284. title: "夜港账本",
  1285. nextQuestion: "你更想写长篇连载,还是十来章能收住?",
  1286. }),
  1287. }),
  1288. });
  1289. });
  1290. });