import { describe, expect, it } from "vitest"; import { DICTATE_MODULE_READ_STATE_TAG, DICTATE_REMEDIATE_TAG, buildDictateContext, clipDictateDialogue, parseDictateModuleReadState, parseDictateRemediate, runDictateTurn, type DictateModuleReadPayload, } from "../src/dictate/index.js"; import type { LlmProvider } from "../src/llm/client.js"; import type { ModuleCatalog } from "../src/skills/creation-flow.js"; function scriptedLlm(steps: unknown[]): LlmProvider { let index = 0; return { async complete() { throw new Error("对话落盘走工具,不走纯文本完成"); }, async completeWithTools() { const body = steps[index++] ?? {}; const select = index === 1; return { content: null, toolCalls: toToolCalls(body, select), usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 }, }; }, }; } function toToolCalls(body: unknown, select: boolean) { const row = body && typeof body === "object" ? (body as Record) : {}; if (select) { return [{ id: "select", name: "select_nodes", arguments: JSON.stringify(row) }]; } const calls: Array<{ id: string; name: string; arguments: string }> = []; const products = row.products; if (Array.isArray(products)) { products.forEach((item, index) => { const rec = item && typeof item === "object" ? (item as Record) : {}; if ("tag" in rec || "position" in rec || "content" in rec || "正文" in rec) { calls.push({ id: `ins-${index}`, name: "insert", arguments: JSON.stringify({ position: rec.tag ?? rec.position, content: rec.content ?? rec.正文 ?? "", ...(rec.self_score !== undefined ? { self_score: rec.self_score } : {}), }), }); return; } Object.entries(rec).forEach(([key, value], piece) => { const content = typeof value === "string" ? value : value && typeof value === "object" ? String((value as Record).内容 ?? (value as Record).正文 ?? (value as Record).content ?? "") : ""; calls.push({ id: `ins-${index}-${piece}`, name: "insert", arguments: JSON.stringify({ position: key, content }), }); }); }); } else if (products && typeof products === "object") { Object.entries(products as Record).forEach(([key, value], index) => { const content = typeof value === "string" ? value : value && typeof value === "object" ? String((value as Record).正文 ?? (value as Record).content ?? "") : ""; calls.push({ id: `ins-${index}`, name: "insert", arguments: JSON.stringify({ position: key.startsWith("设计.") || key.startsWith("用户.") ? key : `设计.${key}`, content }), }); }); } for (const [key, value] of Object.entries(row)) { if (key === "products" || key === "reply" || key === "operations") continue; if (typeof value === "string" && (key.startsWith("设计.") || key.startsWith("用户."))) { calls.push({ id: `extra-${key}`, name: "insert", arguments: JSON.stringify({ position: key, content: value }), }); } } if (Array.isArray(row.operations)) { row.operations.forEach((item, index) => { const rec = item && typeof item === "object" ? (item as Record) : {}; const nested = rec.arguments && typeof rec.arguments === "object" ? (rec.arguments as Record) : rec; const content = String(nested.content ?? ""); if (!content.trim()) return; calls.push({ id: `op-${index}`, name: "insert", arguments: JSON.stringify({ position: nested.position ?? nested.tag, content }), }); }); } if (typeof row.reply === "string") { calls.push({ id: "reply", name: "reply_user", arguments: JSON.stringify({ text: row.reply }), }); } return calls; } const catalog = { modules: [ { id: "mood", name: "氛围", artifact: "设计.氛围", declaration: "细雨", }, ], } as ModuleCatalog; const modulePayload: DictateModuleReadPayload = { module_id: "mood", name: "氛围", artifact: "设计.氛围", version: "v-test", task: "写氛围", principles: "短句", output: "一段话", score: "", score_dimensions: [], libraries: [], constraints: [], }; describe("dictate planned round", () => { it("puts earlier dialogue before products and the last user turn at the end", () => { const built = buildDictateContext({ systemPrompt: "规则", products: [{ tag: "设计.氛围", content: "旧稿", order: 0 }], dialogue: [ { role: "user", text: "第一句" }, { role: "assistant", text: "记下了" }, { role: "user", text: "改成细雨" }, ], phase: "fill", injected: "#### task\n写氛围", tail: "方案进度", }); const body = built.messages[1]?.content ?? ""; expect(body.indexOf("必须调用 reply_user")).toBeLessThan(body.indexOf("第一句")); expect(body.indexOf("第一句")).toBeLessThan(body.indexOf("设计.氛围")); expect(body.indexOf("设计.氛围")).toBeLessThan(body.indexOf("写氛围")); expect(body.indexOf("写氛围")).toBeLessThan(body.indexOf("改成细雨")); expect(body.indexOf("方案进度")).toBeLessThan(body.indexOf("改成细雨")); expect(body.indexOf("写完下列节点后")).toBeLessThan(body.indexOf("写氛围")); expect(built.segmentChars.products).toBeGreaterThan(0); expect(built.segmentChars.rules).toBe("规则".length); const select = buildDictateContext({ systemPrompt: "规则", products: [{ tag: "设计.氛围", content: "旧稿" }], dialogue: [ { role: "user", text: "第一句" }, { role: "user", text: "改成细雨" }, ], phase: "select", tail: "方案进度", }); const selectBody = select.messages[1]?.content ?? ""; expect(selectBody).not.toContain("旧稿"); expect(selectBody).toContain("第一句"); expect(selectBody.indexOf("第一句")).toBeLessThan(selectBody.indexOf("改成细雨")); }); it("clips earlier dialogue to the configured history turn window", () => { const dialogue = [ { role: "user" as const, text: "零" }, { role: "assistant" as const, text: "零回" }, { role: "user" as const, text: "一" }, { role: "assistant" as const, text: "一回" }, { role: "user" as const, text: "二" }, { role: "assistant" as const, text: "二回" }, { role: "user" as const, text: "本轮" }, ]; expect(clipDictateDialogue(dialogue, 2).map((item) => item.text)).toEqual([ "一", "一回", "二", "二回", "本轮", ]); const built = buildDictateContext({ systemPrompt: "规则", products: [], dialogue, phase: "select", historyTurns: 2, }); const body = built.messages[1]?.content ?? ""; expect(body).not.toContain("【用户】\n零"); expect(body).toContain("一"); expect(body).toContain("本轮"); }); it("injects the spec, stamps the read credential, and writes on the single fill", async () => { const products: Array<{ tag: string; content: string }> = []; const tags = new Map(); const result = await runDictateTurn({ llm: scriptedLlm([ { clarify: [], inserts: [{ tag: "设计.氛围", module_id: "mood" }], deletes: [], }, { products: [{ tag: "设计.氛围", content: "细雨" }], reply: "已写成细雨", }, ]), dialogue: [{ role: "user", text: "写成细雨" }], moduleCatalog: catalog, moduleReadIndex: new Map([ ["mood", modulePayload], ["氛围", modulePayload], ]), handlers: { listProducts: () => products, writeProduct: (tag, content) => { const existing = products.find((item) => item.tag === tag); if (existing) existing.content = content; else products.push({ tag, content }); }, deleteProduct: () => false, readTag: (tag) => tags.get(tag), writeTag: (tag, content) => { tags.set(tag, content); }, deleteTag: () => false, clearDialogue: () => undefined, }, }); expect(result.wroteTags).toEqual(["设计.氛围"]); expect(products).toEqual([{ tag: "设计.氛围", content: "细雨" }]); expect(result.reply).toContain("已写成细雨"); expect(result.iterations).toBe(2); const state = parseDictateModuleReadState(tags.get(DICTATE_MODULE_READ_STATE_TAG)); expect(state.modules.mood).toBe("v-test"); expect(tags.get(DICTATE_REMEDIATE_TAG) ?? "").toBe(""); }); it("stops after the select step when the plan is a clarification", async () => { let writes = 0; const result = await runDictateTurn({ llm: scriptedLlm([ { clarify: ["沿用冷色", "改成暖色"], inserts: [], deletes: [], }, ]), dialogue: [{ role: "user", text: "氛围还没定" }], handlers: { listProducts: () => [], writeProduct: () => { writes += 1; }, deleteProduct: () => false, readTag: () => undefined, writeTag: () => undefined, deleteTag: () => false, clearDialogue: () => undefined, }, }); expect(writes).toBe(0); expect(result.iterations).toBe(1); expect(result.reply).toContain("沿用冷色"); expect(result.reply).toContain("改成暖色"); }); it("returns an answer without writing when the first call is reply_user", async () => { let writes = 0; let calls = 0; const result = await runDictateTurn({ llm: { async complete() { throw new Error("对话落盘走工具,不走纯文本完成"); }, async completeWithTools() { calls += 1; return { content: null, toolCalls: [ { id: "reply", name: "reply_user", arguments: JSON.stringify({ text: "下风位最适合先改顾晚棠。" }), }, ], usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 }, }; }, }, dialogue: [{ role: "user", text: "先不要落盘,告诉我该改谁" }], handlers: { listProducts: () => [], writeProduct: () => { writes += 1; }, deleteProduct: () => false, readTag: () => undefined, writeTag: () => undefined, deleteTag: () => false, clearDialogue: () => undefined, }, }); expect(writes).toBe(0); expect(calls).toBe(1); expect(result.wroteTags).toEqual([]); expect(result.iterations).toBe(1); expect(result.reply).toContain("顾晚棠"); expect(result.reply).not.toContain("没有写入产物"); }); it("returns a question without writing when the first call is reply_user", async () => { let calls = 0; const result = await runDictateTurn({ llm: { async complete() { throw new Error("对话落盘走工具,不走纯文本完成"); }, async completeWithTools() { calls += 1; return { content: null, toolCalls: [ { id: "reply", name: "reply_user", arguments: JSON.stringify({ text: "先改顾晚棠,还是先改表世界的同辈?", }), }, ], usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 }, }; }, }, dialogue: [{ role: "user", text: "先讨论,不要落盘" }], handlers: { listProducts: () => [], writeProduct: () => undefined, deleteProduct: () => false, readTag: () => undefined, writeTag: () => undefined, deleteTag: () => false, clearDialogue: () => undefined, }, }); expect(calls).toBe(1); expect(result.wroteTags).toEqual([]); expect(result.iterations).toBe(1); expect(result.reply).toContain("顾晚棠"); }); it("follows a select result with a fill request that carries specs and products", async () => { const seen: string[] = []; const steps = [ { schema: "creation-plan.v1", items: [{ module_id: "mood", tier: "必须", why: "细雨", missing: "", instances: [] }], }, { products: [{ tag: "设计.本局创作方案", content: "{\"schema\":\"creation-plan.v1\"}" }], reply: "已写方案", }, ]; let index = 0; const llm: LlmProvider = { async complete() { throw new Error("对话落盘走工具,不走纯文本完成"); }, async completeWithTools(messages) { seen.push(messages.map((message) => message.content ?? "").join("\n")); const body = steps[index++] ?? {}; return { content: null, toolCalls: toToolCalls(body, index === 1), usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 }, }; }, }; const products = [{ tag: "设计.舞台", content: "已有舞台" }]; await runDictateTurn({ llm, dialogue: [{ role: "user", text: "超级英雄的世界" }], moduleCatalog: catalog, moduleReadIndex: new Map([ [ "creation-plan", { ...modulePayload, module_id: "creation-plan", artifact: "设计.本局创作方案", task: "规格任务正文", principles: "规格原则正文", }, ], ]), handlers: { listProducts: () => products, writeProduct: (tag, content) => { const existing = products.find((item) => item.tag === tag); if (existing) existing.content = content; else products.push({ tag, content }); }, deleteProduct: () => false, readTag: () => undefined, writeTag: () => undefined, deleteTag: () => false, clearDialogue: () => undefined, }, }); expect(seen.length).toBeGreaterThanOrEqual(2); expect(seen[0]).not.toContain("已有舞台"); expect(seen[0]).not.toContain("规格任务正文"); expect(seen[1]).toContain("已有舞台"); expect(seen[1]).toContain("规格任务正文"); expect(seen[1]).toContain("规格原则正文"); expect(seen[1]).toContain("#### output"); expect(seen[1]).toContain("设计.本局创作方案"); }); it("treats operations as the selection, injects the node table, and writes every returned item", async () => { const seen: string[] = []; const steps = [ { operations: [ { name: "insert", arguments: { position: "设计.氛围", module_id: "mood" } }, ], reply: "先写氛围", }, { operations: [ { position: "设计.氛围", content: "细雨" }, { tag: "设计.旁白", content: "旁白一句" }, ], reply: "写了两份", }, ]; let index = 0; const llm: LlmProvider = { async complete() { throw new Error("对话落盘走工具,不走纯文本完成"); }, async completeWithTools(messages) { seen.push(messages.map((message) => message.content ?? "").join("\n")); const body = steps[index++] ?? {}; return { content: null, toolCalls: toToolCalls(body, index === 1), usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 }, }; }, }; const products: Array<{ tag: string; content: string }> = [ { tag: "设计.舞台", content: "已有舞台" }, ]; const tags = new Map(); const result = await runDictateTurn({ llm, dialogue: [{ role: "user", text: "写成细雨" }], moduleCatalog: catalog, moduleReadIndex: new Map([["mood", modulePayload]]), handlers: { listProducts: () => products, writeProduct: (tag, content) => { const existing = products.find((item) => item.tag === tag); if (existing) existing.content = content; else products.push({ tag, content }); }, deleteProduct: () => false, readTag: (tag) => tags.get(tag), writeTag: (tag, content) => { tags.set(tag, content); }, deleteTag: () => false, clearDialogue: () => undefined, }, }); expect(seen.length).toBeGreaterThanOrEqual(2); expect(seen[0]).not.toContain("已有舞台"); expect(seen[0]).not.toContain("写氛围"); expect(seen[1]).toContain("本轮节点表"); expect(seen[1]).toContain("写氛围"); expect(seen[1]).toContain("已有舞台"); expect(result.wroteTags).toEqual(expect.arrayContaining(["设计.氛围", "设计.旁白"])); expect(result.reply).toContain("写了两份"); }); it("persists products named by module or keyed outside the products array", async () => { const resultProducts: Array<{ tag: string; content: string }> = []; const tags = new Map(); const result = await runDictateTurn({ llm: scriptedLlm([ { inserts: [{ tag: "设计.氛围", module_id: "mood" }], deletes: [], }, { reply: "写了", products: { 氛围: { 正文: "细雨连绵" } }, "设计.旁白": "旁白一句", }, ]), dialogue: [{ role: "user", text: "写成细雨" }], moduleCatalog: catalog, moduleReadIndex: new Map([["mood", modulePayload]]), handlers: { listProducts: () => resultProducts, writeProduct: (tag, content) => { resultProducts.push({ tag, content }); }, deleteProduct: () => false, readTag: (tag) => tags.get(tag), writeTag: (tag, content) => { tags.set(tag, content); }, deleteTag: () => false, clearDialogue: () => undefined, }, }); expect(resultProducts.map((item) => item.tag).sort()).toEqual(["设计.旁白", "设计.氛围"]); expect(resultProducts.find((item) => item.tag === "设计.氛围")?.content).toBe("细雨连绵"); expect(result.reply).toContain("写了"); expect(result.reply).not.toContain("未给出"); }); it("selects node ids first, then writes them with the user text and the node specs", async () => { const seen: string[] = []; const steps = [ { nodes: [{ module_id: "mood" }] }, { products: [{ tag: "设计.氛围", content: "细雨" }], reply: "已写氛围。可能还缺主角设定。", }, ]; let index = 0; const llm: LlmProvider = { async complete() { throw new Error("对话落盘走工具,不走纯文本完成"); }, async completeWithTools(messages) { seen.push(messages.map((message) => message.content ?? "").join("\n")); const body = steps[index++] ?? {}; return { content: null, toolCalls: toToolCalls(body, index === 1), usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 }, }; }, }; const products: Array<{ tag: string; content: string }> = []; const tags = new Map(); const result = await runDictateTurn({ llm, dialogue: [{ role: "user", text: "写成细雨" }], moduleCatalog: catalog, moduleReadIndex: new Map([["mood", modulePayload]]), handlers: { listProducts: () => products, writeProduct: (tag, content) => { products.push({ tag, content }); }, deleteProduct: () => false, readTag: (tag) => tags.get(tag), writeTag: (tag, content) => { tags.set(tag, content); }, deleteTag: () => false, clearDialogue: () => undefined, }, }); expect(seen).toHaveLength(2); expect(seen[0]).toContain("只做选题"); expect(seen[0]).toContain("写成细雨"); expect(seen[0]).not.toContain("declare_variable"); expect(seen[1]).toContain("写成细雨"); expect(seen[1]).toContain("写氛围"); expect(seen[1]).toContain("本轮选定的节点:mood"); expect(seen[1]).toContain("还可能缺哪些节点"); expect(result.wroteTags).toEqual(["设计.氛围"]); expect(result.reply).toContain("可能还缺主角设定"); }); it("maps a user-prefixed tag onto the catalog artifact and reads Chinese content fields", async () => { const narration: DictateModuleReadPayload = { ...modulePayload, module_id: "narration", name: "旁白", artifact: "设计.旁白", task: "写旁白", }; const products: Array<{ tag: string; content: string }> = []; const tags = new Map(); const seen: string[] = []; const result = await runDictateTurn({ llm: { async complete() { throw new Error("对话落盘走工具,不走纯文本完成"); }, async completeWithTools(messages) { seen.push(messages.map((message) => message.content ?? "").join("\n")); const step = seen.length; const body = step === 1 ? { nodes: [{ tag: "用户.氛围" }, { module_id: "narration" }] } : { products: [{ "用户.氛围": { 内容: "细雨连绵" }, "设计.旁白": "旁白一句" }], reply: "写了两份", }; return { content: null, toolCalls: toToolCalls(body, step === 1), usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 }, }; }, }, dialogue: [{ role: "user", text: "写成细雨" }], moduleCatalog: { modules: [ ...catalog.modules, { id: "narration", name: "旁白", artifact: "设计.旁白", declaration: "旁白" }, ], } as ModuleCatalog, moduleReadIndex: new Map([ ["mood", modulePayload], ["narration", narration], ]), handlers: { listProducts: () => products, writeProduct: (tag, content) => { products.push({ tag, content }); }, deleteProduct: () => false, readTag: (tag) => tags.get(tag), writeTag: (tag, content) => { tags.set(tag, content); }, deleteTag: () => false, clearDialogue: () => undefined, }, }); expect(seen[1]).toContain("产物 tag:设计.氛围"); expect(seen[1]).toContain("产物 tag:设计.旁白"); expect(products.map((item) => item.tag).sort()).toEqual(["设计.旁白", "设计.氛围"]); expect(products.find((item) => item.tag === "设计.氛围")?.content).toBe("细雨连绵"); expect(result.reply).not.toContain("未给出"); }); it("asks for self_score dimensions before a write can land", async () => { const scored: DictateModuleReadPayload = { ...modulePayload, score_dimensions: ["短句"], }; const products: Array<{ tag: string; content: string }> = []; const tags = new Map(); const seen: string[] = []; const result = await runDictateTurn({ llm: { async complete() { throw new Error("对话落盘走工具,不走纯文本完成"); }, async completeWithTools(messages) { seen.push(messages.map((message) => message.content ?? "").join("\n")); const body = seen.length === 1 ? { nodes: [{ module_id: "mood" }] } : { products: [{ tag: "设计.氛围", content: "细雨" }], reply: "已落盘" }; return { content: null, toolCalls: toToolCalls(body, seen.length === 1), usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 }, }; }, }, dialogue: [{ role: "user", text: "写成细雨" }], moduleCatalog: catalog, moduleReadIndex: new Map([["mood", scored]]), handlers: { listProducts: () => products, writeProduct: (tag, content) => { products.push({ tag, content }); }, deleteProduct: () => false, readTag: (tag) => tags.get(tag), writeTag: (tag, content) => { tags.set(tag, content); }, deleteTag: () => false, clearDialogue: () => undefined, }, }); expect(seen).toHaveLength(2); expect(seen[1]).toContain("self_score 维度(这次写入必填,name 必须一致):短句"); expect(products).toHaveLength(0); expect(result.reply).toContain("失败待补(下轮继续)"); expect(result.reply).toContain("self_score 必填"); expect(result.reply).toContain("短句"); expect(result.reply).not.toContain("未给出"); const remediate = parseDictateRemediate(tags.get(DICTATE_REMEDIATE_TAG)); expect(remediate.items.map((item) => item.tag)).toEqual(["设计.氛围"]); }); it("defers failed tags to the next turn instead of same-turn fill-retry", async () => { const narration: DictateModuleReadPayload = { ...modulePayload, module_id: "narration", name: "旁白", artifact: "设计.旁白", task: "写旁白", score_dimensions: ["一句"], }; const writes: string[] = []; const tags = new Map(); const seen: string[] = []; const rounds = [ [{ id: "select", name: "select_nodes", arguments: JSON.stringify({ nodes: [{ module_id: "mood" }, { module_id: "narration" }] }) }], [ { id: "a", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "细雨" }) }, { id: "b", name: "insert", arguments: JSON.stringify({ position: "设计.旁白", content: "旁白" }) }, { id: "r1", name: "reply_user", arguments: JSON.stringify({ text: "氛围先落下" }) }, ], ]; let index = 0; const result = await runDictateTurn({ llm: { async complete() { throw new Error("对话落盘走工具,不走纯文本完成"); }, async completeWithTools(messages) { seen.push(messages.map((message) => message.content ?? "").join("\n")); return { content: null, toolCalls: rounds[index++] ?? [], usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 }, }; }, }, dialogue: [{ role: "user", text: "写成细雨" }], moduleCatalog: { modules: [ ...catalog.modules, { id: "narration", name: "旁白", artifact: "设计.旁白", declaration: "旁白" }, ], } as ModuleCatalog, moduleReadIndex: new Map([ ["mood", modulePayload], ["narration", narration], ]), handlers: { listProducts: () => [], writeProduct: (tag, content) => { writes.push(`${tag}:${content}`); }, deleteProduct: () => false, readTag: (tag) => tags.get(tag), writeTag: (tag, content) => { tags.set(tag, content); }, deleteTag: () => false, clearDialogue: () => undefined, }, }); expect(seen).toHaveLength(2); expect(writes).toEqual(["设计.氛围:细雨"]); expect(result.reply).toContain("失败待补(下轮继续)"); expect(result.reply).toContain("设计.旁白"); const remediate = parseDictateRemediate(tags.get(DICTATE_REMEDIATE_TAG)); expect(remediate.items).toEqual([ expect.objectContaining({ tag: "设计.旁白", kind: "insert" }), ]); }); it("merges prior失败待补 into the next fill even if select omits them", async () => { const tags = new Map([ [ DICTATE_REMEDIATE_TAG, JSON.stringify({ schema: "dictate-remediate.v1", items: [{ tag: "设计.氛围", moduleId: "mood", error: "self_score 必填", kind: "insert" }], }), ], ]); const products: Array<{ tag: string; content: string }> = []; const seen: string[] = []; const steps = [ { nodes: [{ module_id: "narration" }] }, { products: [ { tag: "设计.氛围", content: "细雨" }, { tag: "设计.旁白", content: "旁白" }, ], reply: "都补上了", }, ]; let index = 0; const result = await runDictateTurn({ llm: { async complete() { throw new Error("对话落盘走工具,不走纯文本完成"); }, async completeWithTools(messages) { seen.push(messages.map((message) => message.content ?? "").join("\n")); const body = steps[index++] ?? {}; return { content: null, toolCalls: toToolCalls(body, index === 1), usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 }, }; }, }, dialogue: [{ role: "user", text: "继续" }], moduleCatalog: { modules: [ ...catalog.modules, { id: "narration", name: "旁白", artifact: "设计.旁白", declaration: "旁白" }, ], } as ModuleCatalog, moduleReadIndex: new Map([ ["mood", modulePayload], [ "narration", { ...modulePayload, module_id: "narration", artifact: "设计.旁白", task: "写旁白", }, ], ]), handlers: { listProducts: () => products, writeProduct: (tag, content) => { products.push({ tag, content }); }, deleteProduct: () => false, readTag: (tag) => tags.get(tag), writeTag: (tag, content) => { tags.set(tag, content); }, deleteTag: (tag) => tags.delete(tag), clearDialogue: () => undefined, }, }); expect(seen[0]).toContain("失败待补"); expect(seen[1]).toContain("设计.氛围"); expect(seen[1]).toContain("写氛围"); expect(result.wroteTags).toEqual(expect.arrayContaining(["设计.氛围", "设计.旁白"])); expect(tags.has(DICTATE_REMEDIATE_TAG)).toBe(false); expect(result.reply).toContain("都补上了"); }); it("puts reply_user guidance early in the fill prompt and does not mention a fallback reply step", async () => { const seen: string[] = []; const products: Array<{ tag: string; content: string }> = []; const rounds = [ [{ id: "select", name: "select_nodes", arguments: JSON.stringify({ nodes: [{ module_id: "mood" }] }) }], [ { id: "a", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "细雨" }) }, { id: "r", name: "reply_user", arguments: JSON.stringify({ text: "细雨留下了" }) }, ], ]; let index = 0; const tags = new Map(); await runDictateTurn({ llm: { async complete() { throw new Error("对话落盘走工具,不走纯文本完成"); }, async completeWithTools(messages) { seen.push(messages.map((message) => message.content ?? "").join("\n")); return { content: null, toolCalls: rounds[index++] ?? [], usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 }, }; }, }, dialogue: [{ role: "user", text: "写成细雨" }], moduleCatalog: catalog, moduleReadIndex: new Map([["mood", modulePayload]]), handlers: { listProducts: () => products, writeProduct: (tag, content) => { products.push({ tag, content }); }, deleteProduct: () => false, readTag: (tag) => tags.get(tag), writeTag: (tag, content) => { tags.set(tag, content); }, deleteTag: () => false, clearDialogue: () => undefined, }, }); expect(seen).toHaveLength(2); const fill = seen[1] ?? ""; expect(fill.indexOf("填写收尾")).toBeLessThan(fill.indexOf("写入规则")); expect(fill.indexOf("本步收尾")).toBeLessThan(fill.indexOf("更早的对话")); expect(fill.indexOf("同一次请求必须调用 reply_user")).toBeLessThan(fill.indexOf("写氛围")); expect(fill).toContain("分数一般或还有更好写法的缺口"); expect(fill).toContain("下一步可以提及哪些"); expect(fill).not.toContain("本轮刚落盘"); expect(fill).not.toContain("你只生成给用户看的综合回复"); }); it("silently asks for reply_user with only this round's products when fill omitted it", async () => { const products: Array<{ tag: string; content: string }> = [ { tag: "设计.舞台", content: "旧舞台不应进入回复请求" }, ]; const seen: string[] = []; const choices: Array = []; let index = 0; const tags = new Map(); const result = await runDictateTurn({ llm: { async complete() { throw new Error("对话落盘走工具,不走纯文本完成"); }, async completeWithTools(messages, options) { seen.push(messages.map((message) => message.content ?? "").join("\n")); choices.push(options?.toolChoice); const step = index++; if (step === 0) { return { content: null, toolCalls: [ { id: "select", name: "select_nodes", arguments: JSON.stringify({ nodes: [{ module_id: "mood" }] }), }, ], usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 }, }; } if (step === 1) { return { content: "细雨留下了,接下来可以定主角怎么进城。", toolCalls: [ { id: "a", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "细雨", self_score: { dims: [{ name: "短句", score: 3, gap: "还缺进城方式", options: ["步行", "马车"] }], }, }), }, ], usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 }, }; } return { content: null, toolCalls: [ { id: "r", name: "reply_user", arguments: JSON.stringify({ text: "细雨已落盘,接下来可以定进城方式。" }), }, ], usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 }, }; }, }, dialogue: [{ role: "user", text: "写成细雨" }], moduleCatalog: catalog, moduleReadIndex: new Map([ ["mood", { ...modulePayload, score_dimensions: ["短句"] }], ]), handlers: { listProducts: () => products, writeProduct: (tag, content, _order, metadata) => { products.push({ tag, content, ...(metadata?.selfScore ? { selfScore: metadata.selfScore } : {}), }); }, deleteProduct: () => false, readTag: (tag) => tags.get(tag), writeTag: (tag, content) => { tags.set(tag, content); }, deleteTag: () => false, clearDialogue: () => undefined, lookupInsertFeedback: (tag) => tag === "设计.氛围" ? `# 本轮关注 · 氛围\n\n## 写入用户回复时做什么\n1. 自然回应\n3. 结合 self_score 说明最强处与关键缺口\n\n## 自评关注\n- 短句` : undefined, }, }); expect(result.reply).toBe("细雨已落盘,接下来可以定进城方式。"); expect(result.iterations).toBe(3); expect(seen).toHaveLength(3); const replyReq = seen[2] ?? ""; expect(replyReq).toContain("本轮刚落盘"); expect(replyReq).toContain("细雨"); expect(replyReq).toContain("self_score"); expect(replyReq).toContain("还缺进城方式"); expect(replyReq).toContain("本轮关注"); expect(replyReq).toContain("结合 self_score 说明最强处与关键缺口"); expect(replyReq).toContain("每个缺口写三样"); expect(replyReq).toContain("2~3 个可以接着说的选项"); expect(replyReq).not.toContain("旧舞台不应进入回复请求"); expect(replyReq).not.toContain("#### task"); expect(choices[2]).toEqual({ type: "function", function: { name: "reply_user" } }); }); it("retries a failed fill in the same turn and patches from the previous draft", async () => { const products: Array<{ tag: string; content: string }> = []; const tags = new Map(); const seen: string[] = []; const rounds = [ [{ id: "select", name: "select_nodes", arguments: JSON.stringify({ nodes: [{ module_id: "mood" }] }) }], [ { id: "a", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "细雨连绵,石板发亮", self_score: { dims: [{ name: "短句", score: 4 }] }, }), }, ], [ { id: "b", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "细雨连绵,石板发亮", self_score: { dims: [ { name: "短句", score: 4 }, { name: "画面", score: 5 }, ], }, }), }, { id: "r", name: "reply_user", arguments: JSON.stringify({ text: "氛围补上了" }) }, ], ]; let index = 0; const result = await runDictateTurn({ fillRetries: 1, llm: { async complete() { throw new Error("对话落盘走工具,不走纯文本完成"); }, async completeWithTools(messages) { seen.push(JSON.stringify(messages)); return { content: null, toolCalls: rounds[index++] ?? [], usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 }, }; }, }, dialogue: [{ role: "user", text: "写成细雨" }], moduleCatalog: catalog, moduleReadIndex: new Map([ ["mood", { ...modulePayload, score_dimensions: ["短句", "画面"] }], ]), handlers: { listProducts: () => products, writeProduct: (tag, content) => { products.push({ tag, content }); }, deleteProduct: () => false, readTag: (tag) => tags.get(tag), writeTag: (tag, content) => { tags.set(tag, content); }, deleteTag: (tag) => tags.delete(tag), clearDialogue: () => undefined, }, }); expect(seen).toHaveLength(3); expect(seen[2]).toContain("同轮补写"); expect(seen[2]).toContain("细雨连绵,石板发亮"); expect(seen[2]).toContain("只改失败点"); expect(seen[2]).toContain("已接受、请原样保留:短句"); expect(products).toEqual([{ tag: "设计.氛围", content: "细雨连绵,石板发亮" }]); expect(result.reply).toContain("氛围补上了"); expect(tags.has(DICTATE_REMEDIATE_TAG)).toBe(false); }); it("keeps accepted self_score dims when a later attempt drops them", async () => { const scores: unknown[] = []; const rounds = [ [{ id: "select", name: "select_nodes", arguments: JSON.stringify({ nodes: [{ module_id: "mood" }] }) }], [ { id: "a", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "细雨", self_score: { dims: [{ name: "短句", score: 4 }] }, }), }, ], [ { id: "b", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "细雨", self_score: { dims: [{ name: "画面", score: 5 }] }, }), }, { id: "r", name: "reply_user", arguments: JSON.stringify({ text: "补上了" }) }, ], ]; let index = 0; const tags = new Map(); await runDictateTurn({ fillRetries: 1, llm: { async complete() { throw new Error("对话落盘走工具,不走纯文本完成"); }, async completeWithTools() { return { content: null, toolCalls: rounds[index++] ?? [], usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 }, }; }, }, dialogue: [{ role: "user", text: "写成细雨" }], moduleCatalog: catalog, moduleReadIndex: new Map([ ["mood", { ...modulePayload, score_dimensions: ["短句", "画面"] }], ]), handlers: { listProducts: () => [], writeProduct: (_tag, _content, _order, metadata) => { scores.push(metadata?.selfScore); }, deleteProduct: () => false, readTag: (tag) => tags.get(tag), writeTag: (tag, content) => { tags.set(tag, content); }, deleteTag: () => false, clearDialogue: () => undefined, }, }); expect(scores).toHaveLength(1); const dims = (scores[0] as { dims: Array<{ name: string }> }).dims.map((dim) => dim.name).sort(); expect(dims).toEqual(["画面", "短句"]); }); it("stores the failed draft and feeds it to the next turn", async () => { const tags = new Map(); const seen: string[] = []; const first = [ [{ id: "select", name: "select_nodes", arguments: JSON.stringify({ nodes: [{ module_id: "mood" }] }) }], [ { id: "a", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "细雨连绵" }), }, ], ]; let index = 0; const handlers = { listProducts: () => [], writeProduct: () => undefined, deleteProduct: () => false, readTag: (tag: string) => tags.get(tag), writeTag: (tag: string, content: string) => { tags.set(tag, content); }, deleteTag: () => false, clearDialogue: () => undefined, }; const failed = await runDictateTurn({ llm: { async complete() { throw new Error("对话落盘走工具,不走纯文本完成"); }, async completeWithTools() { return { content: null, toolCalls: first[index++] ?? [], usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 }, }; }, }, dialogue: [{ role: "user", text: "写成细雨" }], moduleCatalog: catalog, moduleReadIndex: new Map([["mood", { ...modulePayload, score_dimensions: ["短句"] }]]), handlers, }); expect(failed.reply).toContain("失败待补(下轮继续)"); expect(failed.reply).not.toContain("已同轮补写"); const stored = parseDictateRemediate(tags.get(DICTATE_REMEDIATE_TAG)); expect(stored.items[0]?.draft).toBe("细雨连绵"); const second = [ [{ id: "select2", name: "select_nodes", arguments: JSON.stringify({ nodes: [] }) }], [ { id: "b", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "细雨连绵", self_score: { dims: [{ name: "短句", score: 4 }] }, }), }, { id: "r2", name: "reply_user", arguments: JSON.stringify({ text: "补上了" }) }, ], ]; index = 0; await runDictateTurn({ llm: { async complete() { throw new Error("对话落盘走工具,不走纯文本完成"); }, async completeWithTools(messages) { seen.push(messages.map((message) => message.content ?? "").join("\n")); return { content: null, toolCalls: second[index++] ?? [], usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 }, }; }, }, dialogue: [{ role: "user", text: "继续" }], moduleCatalog: catalog, moduleReadIndex: new Map([["mood", { ...modulePayload, score_dimensions: ["短句"] }]]), handlers, }); expect(seen[1]).toContain("底稿"); expect(seen[1]).toContain("细雨连绵"); expect(seen[1]).toContain("只改失败点"); }); });