- 装配只认 prompt_order.enabled;squash_system_messages 只合并相邻 system(新增 system 档,连接符按酒馆用单换行)
- 包装层:new_chat_prompt、示例对话(new_example_chat_prompt + <START> 切段 + name)、角色卡三块与 personality_format/scenario_format、{{user}}/{{char}} 取值、任务契约并进预设条目
- 工具协议:按 ToolBindings / 伪工具协议数据声明真 tools(presetToolPolicy=preset),工具描述用到的条目不再重复作为消息发出
- 正则:promptOnly 作用于对话历史(含 depth 过滤)、写入时脚本改写回复;{{//}} 注释按配平扫描
- tools/st-request-diff:真假上游抓包对比工具、三份预设的机制结论与实测报告
198 lines
6.7 KiB
TypeScript
198 lines
6.7 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||
import { mkdtempSync, rmSync } from "node:fs";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
|
||
// 这些用例只测夹心与整形,不该被本机 ~/.writing-agent/settings.json 里的
|
||
// messageSquash / presetToolPolicy 覆盖,跑的时候给一份干净的临时数据目录。
|
||
let isolatedDataDir = "";
|
||
beforeAll(() => {
|
||
isolatedDataDir = mkdtempSync(path.join(os.tmpdir(), "wa-preset-wrapper-"));
|
||
process.env.WRITING_AGENT_DATA_DIR = isolatedDataDir;
|
||
});
|
||
afterAll(() => {
|
||
delete process.env.WRITING_AGENT_DATA_DIR;
|
||
if (isolatedDataDir) rmSync(isolatedDataDir, { recursive: true, force: true });
|
||
});
|
||
import type {
|
||
ChatMessage,
|
||
CompleteResult,
|
||
LlmProvider,
|
||
} from "../src/llm/client.js";
|
||
import { wrapLlmForSession } from "../src/llm/preset-wrapper.js";
|
||
import type { LlmTrackingContext } from "../src/llm/token-tracker.js";
|
||
import type { PresetPackage } from "../src/types/preset.js";
|
||
|
||
const samplePreset: PresetPackage = {
|
||
id: "p1",
|
||
name: "test-preset",
|
||
source: "native",
|
||
prompts: [
|
||
{
|
||
id: "sys",
|
||
name: "系统",
|
||
enabled: true,
|
||
role: "system",
|
||
content: "PRESET_RULES",
|
||
marker: false,
|
||
sourceIdentifier: "sys",
|
||
},
|
||
],
|
||
promptOrder: [{ promptId: "sys", enabled: true, orderIndex: 0 }],
|
||
generation: { temperature: 0.91, maxOutputTokens: 1234, reasoningEffort: "max" },
|
||
// 这组用例测的是夹心,不是整形;整形单独有用例。
|
||
messagePolicy: { squash: "none" },
|
||
unsupported: [],
|
||
importedAt: "2026-01-01T00:00:00.000Z",
|
||
};
|
||
|
||
function recordingProvider(): {
|
||
provider: LlmProvider;
|
||
received: Array<{ messages: ChatMessage[]; generation?: unknown }>;
|
||
} {
|
||
const received: Array<{ messages: ChatMessage[]; generation?: unknown }> = [];
|
||
const provider: LlmProvider = {
|
||
async complete(messages, options): Promise<CompleteResult> {
|
||
received.push({ messages, generation: options?.generation });
|
||
return { content: "ok", model: "rec-model" };
|
||
},
|
||
async completeStream(messages, options, callbacks) {
|
||
received.push({ messages, generation: options?.generation });
|
||
callbacks?.onContentDelta?.("ok");
|
||
return { content: "ok", model: "rec-model" };
|
||
},
|
||
async completeWithTools(messages, options) {
|
||
received.push({ messages, generation: options.generation });
|
||
return { content: "ok", toolCalls: [], model: "rec-model" };
|
||
},
|
||
};
|
||
return { provider, received };
|
||
}
|
||
|
||
describe("wrapLlmForSession", () => {
|
||
it("frames task messages with preset prompt_order and merges generation", async () => {
|
||
const { provider, received } = recordingProvider();
|
||
const ctx: LlmTrackingContext = {};
|
||
const llm = wrapLlmForSession(provider, { current: ctx }, () => samplePreset);
|
||
|
||
await llm.complete(
|
||
[{ role: "user", content: "TASK" }],
|
||
{ caller: "worker:design-step" },
|
||
);
|
||
|
||
expect(received).toHaveLength(1);
|
||
expect(received[0].messages.map((m) => [m.role, m.content])).toEqual([
|
||
["system", "PRESET_RULES"],
|
||
["user", "TASK"],
|
||
]);
|
||
expect(received[0].generation).toMatchObject({
|
||
temperature: 0.91,
|
||
maxOutputTokens: 1234,
|
||
});
|
||
expect(received[0].generation).not.toHaveProperty("reasoningEffort");
|
||
expect(ctx.pendingContextTrace?.messages).toEqual(received[0].messages);
|
||
expect(ctx.pendingContextTrace?.generation).toMatchObject({
|
||
temperature: 0.91,
|
||
maxOutputTokens: 1234,
|
||
});
|
||
expect(ctx.pendingContextTrace?.caller).toBe("worker:design-step");
|
||
expect(ctx.pendingContextTrace?.model).toBe("rec-model");
|
||
});
|
||
|
||
it("puts task system inside worldBookBefore, not before the preset shell", async () => {
|
||
const { provider, received } = recordingProvider();
|
||
const llm = wrapLlmForSession(provider, undefined, () => samplePreset);
|
||
|
||
await llm.completeWithTools(
|
||
[
|
||
{ role: "system", content: "SKILL" },
|
||
{ role: "user", content: "do it" },
|
||
{
|
||
role: "assistant",
|
||
content: null,
|
||
tool_calls: [
|
||
{
|
||
id: "1",
|
||
type: "function",
|
||
function: { name: "t", arguments: "{}" },
|
||
},
|
||
],
|
||
},
|
||
{ role: "tool", content: "{}", tool_call_id: "1" },
|
||
],
|
||
{ tools: [], caller: "dictate_agent" },
|
||
);
|
||
|
||
expect(received[0].messages[0].content).toBe("PRESET_RULES");
|
||
expect(
|
||
received[0].messages.some(
|
||
(m) => m.content === "### 任务契约\n\nSKILL",
|
||
),
|
||
).toBe(true);
|
||
expect(received[0].messages.some((m) => m.content === "do it")).toBe(true);
|
||
expect(received[0].messages.map((m) => m.role).slice(-2)).toEqual([
|
||
"assistant",
|
||
"tool",
|
||
]);
|
||
});
|
||
|
||
it("completeStream also records the framed request", async () => {
|
||
const { provider, received } = recordingProvider();
|
||
const ctx: LlmTrackingContext = {};
|
||
const llm = wrapLlmForSession(provider, { current: ctx }, () => samplePreset);
|
||
const chunks: string[] = [];
|
||
|
||
await llm.completeStream!(
|
||
[{ role: "user", content: "TASK" }],
|
||
{ caller: "worker:stream" },
|
||
{ onContentDelta: (d) => chunks.push(d) },
|
||
);
|
||
|
||
expect(chunks.join("")).toBe("ok");
|
||
expect(received[0].messages.some((m) => m.content === "PRESET_RULES")).toBe(
|
||
true,
|
||
);
|
||
expect(ctx.pendingContextTrace?.messages).toEqual(received[0].messages);
|
||
});
|
||
|
||
it("uses getPersona so @玩家 stays @玩家 during creation", async () => {
|
||
const preset: PresetPackage = {
|
||
...samplePreset,
|
||
prompts: [
|
||
{
|
||
id: "sys",
|
||
name: "系统",
|
||
enabled: true,
|
||
role: "system",
|
||
content: "称呼 @玩家",
|
||
marker: false,
|
||
sourceIdentifier: "sys",
|
||
},
|
||
],
|
||
};
|
||
const { provider, received } = recordingProvider();
|
||
const llm = wrapLlmForSession(
|
||
provider,
|
||
undefined,
|
||
() => preset,
|
||
() => ({ name: "@玩家", description: "占位" }),
|
||
);
|
||
await llm.complete([{ role: "user", content: "TASK" }]);
|
||
expect(received[0].messages[0].content).toBe("称呼 @玩家");
|
||
});
|
||
|
||
it("defaults to strict squash when the preset says nothing", async () => {
|
||
const preset: PresetPackage = { ...samplePreset };
|
||
delete (preset as { messagePolicy?: unknown }).messagePolicy;
|
||
const { provider, received } = recordingProvider();
|
||
const llm = wrapLlmForSession(provider, undefined, () => preset);
|
||
|
||
await llm.complete([{ role: "user", content: "TASK" }]);
|
||
|
||
// strict:system 折进 user,首条为 user
|
||
expect(received[0].messages.map((m) => m.role)).toEqual(["user"]);
|
||
expect(received[0].messages[0]!.content).toContain("PRESET_RULES");
|
||
expect(received[0].messages[0]!.content).toContain("TASK");
|
||
});
|
||
});
|