interaction-runtime.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  1. import { describe, expect, it, vi } from "vitest";
  2. import { InteractionSessionSchema } from "../interaction/session.js";
  3. import { runInteractionRequest } from "../interaction/runtime.js";
  4. function makeTools(overrides: Partial<Parameters<typeof runInteractionRequest>[0]["tools"]> = {}) {
  5. return {
  6. listBooks: vi.fn(async () => ["harbor"]),
  7. developBookDraft: vi.fn(),
  8. createBook: vi.fn(),
  9. exportBook: vi.fn(),
  10. writeNextChapter: vi.fn(),
  11. reviseDraft: vi.fn(),
  12. patchChapterText: vi.fn(),
  13. renameEntity: vi.fn(),
  14. updateCurrentFocus: vi.fn(),
  15. updateAuthorIntent: vi.fn(),
  16. writeTruthFile: vi.fn(),
  17. ...overrides,
  18. };
  19. }
  20. describe("interaction runtime", () => {
  21. it("routes develop_book through the shared draft tool and updates the creation draft", async () => {
  22. const developBookDraft = vi.fn(async () => ({
  23. __interaction: {
  24. responseText: "我先按港风商战悬疑收着。你更想写长篇连载,还是十来章能收住?",
  25. details: {
  26. creationDraft: {
  27. concept: "港风商战悬疑,主角从灰产洗白。",
  28. title: "夜港账本",
  29. genre: "urban",
  30. nextQuestion: "更想写长篇连载,还是十来章能收住?",
  31. missingFields: ["targetChapters"],
  32. readyToCreate: false,
  33. },
  34. },
  35. },
  36. }));
  37. const result = await runInteractionRequest({
  38. session: InteractionSessionSchema.parse({
  39. sessionId: "session-draft",
  40. projectRoot: "/tmp/project",
  41. automationMode: "semi",
  42. messages: [],
  43. events: [],
  44. }),
  45. request: {
  46. intent: "develop_book",
  47. instruction: "我想写个港风商战悬疑,主角从灰产洗白。",
  48. },
  49. tools: makeTools({
  50. developBookDraft,
  51. }),
  52. });
  53. expect(developBookDraft).toHaveBeenCalledWith("我想写个港风商战悬疑,主角从灰产洗白。", undefined);
  54. expect(result.session.creationDraft).toEqual(expect.objectContaining({
  55. title: "夜港账本",
  56. genre: "urban",
  57. }));
  58. expect(result.responseText).toContain("港风商战悬疑");
  59. });
  60. it("routes create_book through the shared create tool and binds the created book", async () => {
  61. const createBook = vi.fn(async () => ({
  62. bookId: "night-harbor",
  63. title: "Night Harbor",
  64. __interaction: {
  65. responseText: "Created Night Harbor.",
  66. },
  67. }));
  68. const result = await runInteractionRequest({
  69. session: InteractionSessionSchema.parse({
  70. sessionId: "session-create",
  71. projectRoot: "/tmp/project",
  72. automationMode: "semi",
  73. creationDraft: {
  74. concept: "港风商战悬疑,主角从灰产洗白。",
  75. title: "Night Harbor",
  76. genre: "urban",
  77. platform: "tomato",
  78. targetChapters: 120,
  79. chapterWordCount: 2800,
  80. readyToCreate: true,
  81. missingFields: [],
  82. },
  83. messages: [],
  84. events: [],
  85. }),
  86. request: {
  87. intent: "create_book",
  88. },
  89. tools: makeTools({
  90. createBook,
  91. }),
  92. });
  93. expect(createBook).toHaveBeenCalledWith({
  94. title: "Night Harbor",
  95. genre: "urban",
  96. platform: "tomato",
  97. targetChapters: 120,
  98. chapterWordCount: 2800,
  99. });
  100. expect(result.session.activeBookId).toBe("night-harbor");
  101. expect(result.session.creationDraft).toBeUndefined();
  102. expect(result.responseText).toContain("Created Night Harbor.");
  103. });
  104. it("clears the creation draft when discard_book_draft is requested", async () => {
  105. const result = await runInteractionRequest({
  106. session: InteractionSessionSchema.parse({
  107. sessionId: "session-discard",
  108. projectRoot: "/tmp/project",
  109. automationMode: "semi",
  110. creationDraft: {
  111. concept: "港风商战悬疑,主角从灰产洗白。",
  112. title: "夜港账本",
  113. readyToCreate: false,
  114. missingFields: ["genre"],
  115. },
  116. messages: [],
  117. events: [],
  118. }),
  119. request: {
  120. intent: "discard_book_draft",
  121. },
  122. tools: makeTools(),
  123. });
  124. expect(result.session.creationDraft).toBeUndefined();
  125. expect(result.responseText).toContain("已丢弃");
  126. });
  127. it("renders the current creation draft when show_book_draft is requested", async () => {
  128. const result = await runInteractionRequest({
  129. session: InteractionSessionSchema.parse({
  130. sessionId: "session-show-draft",
  131. projectRoot: "/tmp/project",
  132. automationMode: "semi",
  133. creationDraft: {
  134. concept: "港风商战悬疑,主角从灰产洗白。",
  135. title: "夜港账本",
  136. genre: "urban",
  137. worldPremise: "近未来港口城,账本牵出多方势力。",
  138. protagonist: "林砚,水货账房出身,擅长记账和看人。",
  139. conflictCore: "洗白与旧债回潮的对撞。",
  140. nextQuestion: "卷一先查账还是先砸场?",
  141. missingFields: ["targetChapters"],
  142. readyToCreate: false,
  143. },
  144. messages: [],
  145. events: [],
  146. }),
  147. request: {
  148. intent: "show_book_draft",
  149. },
  150. tools: makeTools(),
  151. });
  152. expect(result.responseText).toContain("夜港账本");
  153. expect(result.responseText).toContain("近未来港口城");
  154. expect(result.responseText).toContain("卷一先查账还是先砸场");
  155. });
  156. it("routes export_book through the shared export tool", async () => {
  157. const exportBook = vi.fn(async () => ({
  158. outputPath: "/tmp/project/exports/harbor.md",
  159. chaptersExported: 9,
  160. __interaction: {
  161. responseText: "Exported harbor to /tmp/project/exports/harbor.md",
  162. },
  163. }));
  164. const result = await runInteractionRequest({
  165. session: InteractionSessionSchema.parse({
  166. sessionId: "session-export",
  167. projectRoot: "/tmp/project",
  168. activeBookId: "harbor",
  169. automationMode: "semi",
  170. messages: [],
  171. events: [],
  172. }),
  173. request: {
  174. intent: "export_book",
  175. bookId: "harbor",
  176. format: "md",
  177. approvedOnly: true,
  178. outputPath: "/tmp/project/exports/harbor.md",
  179. },
  180. tools: makeTools({
  181. exportBook,
  182. }),
  183. });
  184. expect(exportBook).toHaveBeenCalledWith("harbor", {
  185. format: "md",
  186. approvedOnly: true,
  187. outputPath: "/tmp/project/exports/harbor.md",
  188. });
  189. expect(result.responseText).toContain("Exported harbor");
  190. });
  191. it("keeps write_next completed in auto mode", async () => {
  192. const writeNextChapter = vi.fn(async () => ({
  193. chapterNumber: 7,
  194. title: "Harbor Ledger",
  195. wordCount: 3210,
  196. revised: false,
  197. status: "ready-for-review" as const,
  198. }));
  199. const reviseDraft = vi.fn();
  200. const updateCurrentFocus = vi.fn();
  201. const updateAuthorIntent = vi.fn();
  202. const writeTruthFile = vi.fn();
  203. const session = InteractionSessionSchema.parse({
  204. sessionId: "session-1",
  205. projectRoot: "/tmp/project",
  206. activeBookId: "harbor",
  207. automationMode: "auto",
  208. messages: [],
  209. events: [],
  210. });
  211. const result = await runInteractionRequest({
  212. session,
  213. request: { intent: "write_next", bookId: "harbor" },
  214. tools: makeTools({
  215. writeNextChapter,
  216. reviseDraft,
  217. updateCurrentFocus,
  218. updateAuthorIntent,
  219. writeTruthFile,
  220. }),
  221. });
  222. expect(writeNextChapter).toHaveBeenCalledWith("harbor");
  223. expect(reviseDraft).not.toHaveBeenCalled();
  224. expect(result.session.activeBookId).toBe("harbor");
  225. expect(result.session.activeChapterNumber).toBe(7);
  226. expect(result.session.currentExecution?.status).toBe("completed");
  227. expect(result.session.pendingDecision).toBeUndefined();
  228. expect(result.session.events.map((event) => event.kind)).toEqual([
  229. "task.started",
  230. "task.completed",
  231. ]);
  232. });
  233. it("moves content-producing work into waiting_human in semi mode", async () => {
  234. const writeNextChapter = vi.fn(async () => ({
  235. chapterNumber: 7,
  236. title: "Harbor Ledger",
  237. wordCount: 3210,
  238. revised: false,
  239. status: "ready-for-review" as const,
  240. auditResult: { passed: true, issues: [], summary: "ok" },
  241. __interaction: {
  242. events: [{
  243. kind: "stage.changed",
  244. timestamp: 1710000000000,
  245. status: "writing" as const,
  246. bookId: "harbor",
  247. chapterNumber: 7,
  248. detail: "writing chapter draft",
  249. }],
  250. },
  251. }));
  252. const result = await runInteractionRequest({
  253. session: InteractionSessionSchema.parse({
  254. sessionId: "session-1b",
  255. projectRoot: "/tmp/project",
  256. activeBookId: "harbor",
  257. automationMode: "semi",
  258. messages: [],
  259. events: [],
  260. }),
  261. request: { intent: "write_next", bookId: "harbor" },
  262. tools: makeTools({
  263. writeNextChapter,
  264. }),
  265. });
  266. expect(result.session.currentExecution?.status).toBe("waiting_human");
  267. expect(result.session.pendingDecision?.kind).toBe("review-next-step");
  268. expect(result.session.pendingDecision?.chapterNumber).toBe(7);
  269. expect(result.session.events.map((event) => event.kind)).toEqual([
  270. "task.started",
  271. "stage.changed",
  272. "task.completed",
  273. ]);
  274. expect(result.responseText).toContain("等待你的下一步决定");
  275. });
  276. it("routes revise_chapter to reviseDraft with local-fix", async () => {
  277. const writeNextChapter = vi.fn();
  278. const reviseDraft = vi.fn(async () => ({
  279. chapterNumber: 3,
  280. wordCount: 2800,
  281. fixedIssues: ["tightened ending"],
  282. applied: true,
  283. status: "ready-for-review" as const,
  284. }));
  285. const session = InteractionSessionSchema.parse({
  286. sessionId: "session-2",
  287. projectRoot: "/tmp/project",
  288. activeBookId: "harbor",
  289. automationMode: "manual",
  290. messages: [],
  291. });
  292. await runInteractionRequest({
  293. session,
  294. request: { intent: "revise_chapter", bookId: "harbor", chapterNumber: 3 },
  295. tools: makeTools({
  296. writeNextChapter,
  297. reviseDraft,
  298. }),
  299. });
  300. expect(reviseDraft).toHaveBeenCalledWith("harbor", 3, "local-fix");
  301. });
  302. it("routes rewrite_chapter to reviseDraft with rewrite mode", async () => {
  303. const reviseDraft = vi.fn(async () => ({
  304. chapterNumber: 5,
  305. wordCount: 3100,
  306. fixedIssues: ["rewrote chapter"],
  307. applied: true,
  308. status: "ready-for-review" as const,
  309. }));
  310. await runInteractionRequest({
  311. session: InteractionSessionSchema.parse({
  312. sessionId: "session-3",
  313. projectRoot: "/tmp/project",
  314. activeBookId: "harbor",
  315. automationMode: "manual",
  316. messages: [],
  317. }),
  318. request: { intent: "rewrite_chapter", bookId: "harbor", chapterNumber: 5 },
  319. tools: makeTools({
  320. reviseDraft,
  321. }),
  322. });
  323. expect(reviseDraft).toHaveBeenCalledWith("harbor", 5, "rewrite");
  324. });
  325. it("routes update_focus to the focus updater", async () => {
  326. const updateCurrentFocus = vi.fn(async () => ({ ok: true }));
  327. await runInteractionRequest({
  328. session: InteractionSessionSchema.parse({
  329. sessionId: "session-4",
  330. projectRoot: "/tmp/project",
  331. activeBookId: "harbor",
  332. automationMode: "semi",
  333. messages: [],
  334. }),
  335. request: {
  336. intent: "update_focus",
  337. bookId: "harbor",
  338. instruction: "Bring the story back to the old harbor debt line.",
  339. },
  340. tools: makeTools({
  341. updateCurrentFocus,
  342. }),
  343. });
  344. expect(updateCurrentFocus).toHaveBeenCalledWith(
  345. "harbor",
  346. "Bring the story back to the old harbor debt line.",
  347. );
  348. });
  349. it("answers chat-style requests without forcing a status summary", async () => {
  350. const chat = vi.fn(async () => ({
  351. __interaction: {
  352. responseText: "你好,我在。当前没有活动书,要不要先说说你想写什么?",
  353. },
  354. }));
  355. const result = await runInteractionRequest({
  356. session: InteractionSessionSchema.parse({
  357. sessionId: "session-chat",
  358. projectRoot: "/tmp/project",
  359. activeBookId: "harbor",
  360. automationMode: "semi",
  361. messages: [],
  362. events: [],
  363. }),
  364. request: {
  365. intent: "chat",
  366. bookId: "harbor",
  367. instruction: "hi",
  368. },
  369. tools: makeTools({
  370. chat,
  371. }),
  372. });
  373. expect(chat).toHaveBeenCalledWith("hi", {
  374. bookId: "harbor",
  375. automationMode: "semi",
  376. });
  377. expect(result.responseText).toContain("你好");
  378. expect(result.responseText).not.toContain("Current status");
  379. expect(result.session.events.map((event) => event.kind)).toEqual([
  380. "task.started",
  381. "task.completed",
  382. ]);
  383. });
  384. it("routes edit_truth to the truth-file updater", async () => {
  385. const writeTruthFile = vi.fn(async () => ({ ok: true }));
  386. await runInteractionRequest({
  387. session: InteractionSessionSchema.parse({
  388. sessionId: "session-4b",
  389. projectRoot: "/tmp/project",
  390. activeBookId: "harbor",
  391. automationMode: "semi",
  392. messages: [],
  393. events: [],
  394. }),
  395. request: {
  396. intent: "edit_truth",
  397. bookId: "harbor",
  398. fileName: "current_focus.md",
  399. instruction: "# Current Focus\n\nBring the story back to the old harbor debt line.\n",
  400. },
  401. tools: makeTools({
  402. writeTruthFile,
  403. }),
  404. });
  405. expect(writeTruthFile).toHaveBeenCalledWith(
  406. "harbor",
  407. "current_focus.md",
  408. "# Current Focus\n\nBring the story back to the old harbor debt line.\n",
  409. );
  410. });
  411. it("routes rename_entity to the rename tool", async () => {
  412. const renameEntity = vi.fn(async () => ({ ok: true }));
  413. await runInteractionRequest({
  414. session: InteractionSessionSchema.parse({
  415. sessionId: "session-4c",
  416. projectRoot: "/tmp/project",
  417. activeBookId: "harbor",
  418. automationMode: "manual",
  419. messages: [],
  420. events: [],
  421. }),
  422. request: {
  423. intent: "rename_entity",
  424. bookId: "harbor",
  425. oldValue: "陆尘",
  426. newValue: "林砚",
  427. },
  428. tools: makeTools({
  429. renameEntity,
  430. }),
  431. });
  432. expect(renameEntity).toHaveBeenCalledWith("harbor", "陆尘", "林砚");
  433. });
  434. it("routes patch_chapter_text to the chapter patch tool and waits for review", async () => {
  435. const patchChapterText = vi.fn(async () => ({
  436. chapterNumber: 3,
  437. __interaction: {
  438. responseText: "Patched chapter 3 and marked it for review.",
  439. },
  440. }));
  441. const result = await runInteractionRequest({
  442. session: InteractionSessionSchema.parse({
  443. sessionId: "session-4d",
  444. projectRoot: "/tmp/project",
  445. activeBookId: "harbor",
  446. automationMode: "semi",
  447. messages: [],
  448. events: [],
  449. }),
  450. request: {
  451. intent: "patch_chapter_text",
  452. bookId: "harbor",
  453. chapterNumber: 3,
  454. targetText: "旧名字",
  455. replacementText: "新名字",
  456. },
  457. tools: makeTools({
  458. patchChapterText,
  459. }),
  460. });
  461. expect(patchChapterText).toHaveBeenCalledWith("harbor", 3, "旧名字", "新名字");
  462. expect(result.session.currentExecution?.status).toBe("waiting_human");
  463. expect(result.session.pendingDecision?.chapterNumber).toBe(3);
  464. expect(result.responseText).toContain("marked it for review");
  465. });
  466. it("updates automation mode without invoking pipeline tools", async () => {
  467. const writeNextChapter = vi.fn();
  468. const reviseDraft = vi.fn();
  469. const updateCurrentFocus = vi.fn();
  470. const updateAuthorIntent = vi.fn();
  471. const writeTruthFile = vi.fn();
  472. const result = await runInteractionRequest({
  473. session: InteractionSessionSchema.parse({
  474. sessionId: "session-5",
  475. projectRoot: "/tmp/project",
  476. activeBookId: "harbor",
  477. automationMode: "semi",
  478. messages: [],
  479. events: [],
  480. }),
  481. request: { intent: "switch_mode", mode: "auto" },
  482. tools: makeTools({
  483. writeNextChapter,
  484. reviseDraft,
  485. updateCurrentFocus,
  486. updateAuthorIntent,
  487. writeTruthFile,
  488. }),
  489. });
  490. expect(result.session.automationMode).toBe("auto");
  491. expect(writeNextChapter).not.toHaveBeenCalled();
  492. expect(reviseDraft).not.toHaveBeenCalled();
  493. expect(updateCurrentFocus).not.toHaveBeenCalled();
  494. expect(updateAuthorIntent).not.toHaveBeenCalled();
  495. expect(result.session.events.map((event) => event.kind)).toEqual([
  496. "task.started",
  497. "task.completed",
  498. ]);
  499. });
  500. it("binds the selected book without invoking pipeline tools", async () => {
  501. const result = await runInteractionRequest({
  502. session: InteractionSessionSchema.parse({
  503. sessionId: "session-5b",
  504. projectRoot: "/tmp/project",
  505. activeBookId: "harbor",
  506. automationMode: "semi",
  507. messages: [],
  508. events: [],
  509. }),
  510. request: { intent: "select_book", bookId: "beta" },
  511. tools: makeTools({
  512. listBooks: vi.fn(async () => ["harbor", "beta"]),
  513. }),
  514. });
  515. expect(result.session.activeBookId).toBe("beta");
  516. expect(result.responseText).toContain("beta");
  517. });
  518. it("lists project books through the shared runtime", async () => {
  519. const listBooks = vi.fn(async () => ["harbor", "beta"]);
  520. const result = await runInteractionRequest({
  521. session: InteractionSessionSchema.parse({
  522. sessionId: "session-5c",
  523. projectRoot: "/tmp/project",
  524. automationMode: "semi",
  525. messages: [],
  526. events: [],
  527. }),
  528. request: { intent: "list_books" },
  529. tools: makeTools({
  530. listBooks,
  531. }),
  532. });
  533. expect(listBooks).toHaveBeenCalledTimes(1);
  534. expect(result.responseText).toContain("harbor");
  535. expect(result.responseText).toContain("beta");
  536. });
  537. it("pauses the active book without invoking pipeline tools", async () => {
  538. const result = await runInteractionRequest({
  539. session: InteractionSessionSchema.parse({
  540. sessionId: "session-6",
  541. projectRoot: "/tmp/project",
  542. activeBookId: "harbor",
  543. automationMode: "auto",
  544. messages: [],
  545. events: [],
  546. }),
  547. request: { intent: "pause_book", bookId: "harbor" },
  548. tools: makeTools(),
  549. });
  550. expect(result.session.currentExecution?.status).toBe("blocked");
  551. expect(result.responseText).toContain("已暂停");
  552. expect(result.session.events.map((event) => event.kind)).toEqual([
  553. "task.started",
  554. "task.completed",
  555. ]);
  556. });
  557. it("returns a human-readable explanation for explain_status", async () => {
  558. const result = await runInteractionRequest({
  559. session: InteractionSessionSchema.parse({
  560. sessionId: "session-7",
  561. projectRoot: "/tmp/project",
  562. activeBookId: "harbor",
  563. automationMode: "semi",
  564. messages: [],
  565. events: [],
  566. currentExecution: {
  567. status: "repairing",
  568. bookId: "harbor",
  569. chapterNumber: 3,
  570. stageLabel: "repairing chapter 3",
  571. },
  572. }),
  573. request: { intent: "explain_status", bookId: "harbor", instruction: "what are you doing?" },
  574. tools: makeTools(),
  575. });
  576. expect(result.responseText).toContain("repairing");
  577. expect(result.responseText).toContain("harbor");
  578. });
  579. });