Files
writing-agent/tests/dictate-round.test.ts
T
chenmoranzhiandCursor b864591afa 对话落盘同一轮填写失败后可以按次数补写。
游玩投影不再送进只给程序用的壳源码。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 08:29:38 +08:00

1281 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown>) : {};
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<string, unknown>) : {};
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<string, unknown>).内容 ?? (value as Record<string, unknown>).正文 ?? (value as Record<string, unknown>).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<string, unknown>).forEach(([key, value], index) => {
const content =
typeof value === "string"
? value
: value && typeof value === "object"
? String((value as Record<string, unknown>).正文 ?? (value as Record<string, unknown>).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<string, unknown>) : {};
const nested =
rec.arguments && typeof rec.arguments === "object"
? (rec.arguments as Record<string, unknown>)
: 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<string, string>();
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<string, string>();
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<string, string>();
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<string, string>();
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<string, string>();
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<string, string>();
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<string, string>();
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<string, string>([
[
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<string, string>();
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<unknown> = [];
let index = 0;
const tags = new Map<string, string>();
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<string, string>();
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<string, string>();
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<string, string>();
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("只改失败点");
});
});