打通 Boss 直聘落档挂载与真值→投影映射,并修正带 tools 时的推理强度。
落档自动把产物挂进 context_order;创作期用 declare_variable/declare_map 钉目录与分档;真值变更持续重投影。顺带强制 tools 请求 reasoning_effort=none,并支持消息 restart。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Blackboard } from "../src/blackboard/blackboard.js";
|
||||
import {
|
||||
buildDictateContextOrder,
|
||||
bindDictateProductsToPlaySpec,
|
||||
collectDictateBindProducts,
|
||||
isDictateStyleBindTag,
|
||||
} from "../src/dictate/play-bind.js";
|
||||
import { DICTATE_ORDER_META_KEY } from "../src/dictate/types.js";
|
||||
import { DIALOGUE_HISTORY_TAG, WORKER_PERSONA_REF } from "../src/skills/context-order.js";
|
||||
import { defaultPlaySlots } from "../src/skills/play-slots.js";
|
||||
|
||||
describe("dictate play-bind", () => {
|
||||
it("classifies style tags", () => {
|
||||
expect(isDictateStyleBindTag("设计.美学纲领")).toBe(true);
|
||||
expect(isDictateStyleBindTag("设计.正文组成")).toBe(true);
|
||||
expect(isDictateStyleBindTag("用户.需求")).toBe(false);
|
||||
});
|
||||
|
||||
it("builds gm context_order: persona → products → history → input", () => {
|
||||
const order = buildDictateContextOrder({
|
||||
products: [
|
||||
{ tag: "用户.需求", content: "雨夜", order: -40 },
|
||||
{ tag: "设计.美学纲领", content: "冷色", order: -30 },
|
||||
{ tag: "设计.正文组成", content: "{}", order: 0 },
|
||||
],
|
||||
playSlots: defaultPlaySlots(),
|
||||
});
|
||||
const gm = order.slots.find((s) => s.ref === "world-simulator");
|
||||
expect(gm).toBeTruthy();
|
||||
const refs = gm!.inserts.map((i) => i.ref);
|
||||
expect(refs[0]).toBe(WORKER_PERSONA_REF);
|
||||
expect(refs).toContain("用户.需求");
|
||||
expect(refs).toContain("设计.美学纲领");
|
||||
expect(refs).toContain("设计.正文组成");
|
||||
expect(refs).toContain(DIALOGUE_HISTORY_TAG);
|
||||
expect(refs[refs.length - 1]).toBe("用户.最新输入");
|
||||
const histIdx = refs.indexOf(DIALOGUE_HISTORY_TAG);
|
||||
expect(refs.indexOf("用户.需求")).toBeLessThan(histIdx);
|
||||
expect(refs.indexOf("设计.正文组成")).toBeLessThan(histIdx);
|
||||
});
|
||||
|
||||
it("skips opening and worker-set tags; narrator only gets style", () => {
|
||||
const board = new Blackboard();
|
||||
board.write({
|
||||
tag: "用户.需求",
|
||||
content: "要压抑",
|
||||
source: "dictate",
|
||||
metadata: { [DICTATE_ORDER_META_KEY]: -40 },
|
||||
});
|
||||
board.write({
|
||||
tag: "设计.美学纲领",
|
||||
content: "冷色短句",
|
||||
source: "dictate",
|
||||
metadata: { [DICTATE_ORDER_META_KEY]: -30 },
|
||||
});
|
||||
board.write({
|
||||
tag: "设计.开场白",
|
||||
content: "雨落在檐上。",
|
||||
source: "dictate",
|
||||
});
|
||||
board.write({
|
||||
tag: "设计.worker集",
|
||||
content: JSON.stringify({
|
||||
play_slots: { gm: true, narrator: true, auditor: false, perspective: false },
|
||||
}),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
const products = collectDictateBindProducts(board);
|
||||
expect(products.map((p) => p.tag)).toEqual(["用户.需求", "设计.美学纲领"]);
|
||||
expect(products.every((p) => p.tag !== "设计.开场白")).toBe(true);
|
||||
|
||||
const result = bindDictateProductsToPlaySpec(board);
|
||||
const narr = result.contextOrder.slots.find((s) => s.ref === "narrator");
|
||||
expect(narr).toBeTruthy();
|
||||
const narrRefs = narr!.inserts.map((i) => i.ref);
|
||||
expect(narrRefs).toContain("设计.美学纲领");
|
||||
expect(narrRefs).not.toContain("用户.需求");
|
||||
expect(narrRefs).toContain("运行.本轮.裁决");
|
||||
|
||||
const gm = result.contextOrder.slots.find((s) => s.ref === "world-simulator");
|
||||
expect(gm!.inserts.map((i) => i.ref)).toContain("用户.需求");
|
||||
expect(result.workerSetJson).toContain("context_order");
|
||||
});
|
||||
|
||||
it("does not invent variable mappings; binds declared map slots and catalog", () => {
|
||||
const board = new Blackboard();
|
||||
board.write({ tag: "用户.需求", content: "x", source: "dictate" });
|
||||
const without = bindDictateProductsToPlaySpec(board);
|
||||
const refsWithout = without.contextOrder.slots[0]!.inserts.map((i) => i.ref);
|
||||
expect(refsWithout).not.toContain("变量.当前");
|
||||
expect(refsWithout).not.toContain("上下文.角色态度");
|
||||
|
||||
board.write({
|
||||
tag: "设计.变量目录",
|
||||
content: JSON.stringify({
|
||||
schema: "variable-catalog.v1",
|
||||
fields: [{ key: "好感", type: "number", initial: 10, user_visible: true }],
|
||||
}),
|
||||
source: "test",
|
||||
});
|
||||
board.write({
|
||||
tag: "设计.变量映射",
|
||||
content: JSON.stringify({
|
||||
schema: "value-map.v1",
|
||||
maps: [
|
||||
{
|
||||
id: "aff",
|
||||
field: "好感",
|
||||
target_tag: "上下文.角色态度",
|
||||
bands: [{ min: 0, content: "冷" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
source: "test",
|
||||
});
|
||||
const withMap = bindDictateProductsToPlaySpec(board);
|
||||
const refs = withMap.contextOrder.slots[0]!.inserts.map((i) => i.ref);
|
||||
expect(refs).toContain("变量.当前");
|
||||
expect(refs).toContain("上下文.角色态度");
|
||||
expect(refs).not.toContain("设计.变量映射");
|
||||
});
|
||||
});
|
||||
@@ -71,9 +71,10 @@ describe("dictate context", () => {
|
||||
expect(prompt).toContain("设计.开场白");
|
||||
expect(prompt).toContain("输出.开场白");
|
||||
expect(prompt).toContain("insert");
|
||||
expect(prompt).toContain("declare_variable");
|
||||
expect(prompt).toContain("declare_map");
|
||||
expect(prompt).toMatch(/order/);
|
||||
expect(prompt).toContain("仅允许 toolcall");
|
||||
expect(prompt).toContain("变化越频繁");
|
||||
});
|
||||
|
||||
it("sorts products by relative order: neg then 0 then pos", () => {
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
applyRejectedGenerationField,
|
||||
classifyChatModel,
|
||||
fetchWithGenerationCompat,
|
||||
forceReasoningEffortNoneWhenTools,
|
||||
isToolsReasoningEffortConflictError,
|
||||
parseRejectedGenerationField,
|
||||
sanitizeReasoningEffort,
|
||||
sanitizeVerbosity,
|
||||
@@ -102,6 +104,58 @@ describe("applyOpenAiGeneration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("forceReasoningEffortNoneWhenTools", () => {
|
||||
it("forces none when tools are present", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
tools: [{ type: "function", function: { name: "x" } }],
|
||||
reasoning_effort: "high",
|
||||
};
|
||||
forceReasoningEffortNoneWhenTools(body);
|
||||
expect(body.reasoning_effort).toBe("none");
|
||||
});
|
||||
|
||||
it("sets none even when effort was omitted", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
tools: [{ type: "function", function: { name: "x" } }],
|
||||
};
|
||||
forceReasoningEffortNoneWhenTools(body);
|
||||
expect(body.reasoning_effort).toBe("none");
|
||||
});
|
||||
|
||||
it("leaves body alone without tools", () => {
|
||||
const body: Record<string, unknown> = { reasoning_effort: "high" };
|
||||
forceReasoningEffortNoneWhenTools(body);
|
||||
expect(body.reasoning_effort).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isToolsReasoningEffortConflictError", () => {
|
||||
it("detects gpt-5.6-luna style conflict", () => {
|
||||
const text = JSON.stringify({
|
||||
error: {
|
||||
message:
|
||||
"Provider API error: Function tools with reasoning_effort are not supported for gpt-5.6-luna in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'.",
|
||||
type: "invalid_request_error",
|
||||
},
|
||||
});
|
||||
expect(isToolsReasoningEffortConflictError(text, 400)).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores unrelated 400s", () => {
|
||||
expect(
|
||||
isToolsReasoningEffortConflictError(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: "Unsupported parameter: temperature",
|
||||
param: "temperature",
|
||||
},
|
||||
}),
|
||||
400,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRejectedGenerationField", () => {
|
||||
it("reads OpenAI unsupported parameter errors", () => {
|
||||
expect(
|
||||
@@ -151,6 +205,16 @@ describe("applyRejectedGenerationField", () => {
|
||||
expect(body.max_completion_tokens).toBeUndefined();
|
||||
expect(body.max_tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets reasoning_effort to none when tools are present", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
tools: [{ type: "function" }],
|
||||
reasoning_effort: "high",
|
||||
};
|
||||
const tried = new Set<string>();
|
||||
expect(applyRejectedGenerationField(body, "reasoning_effort", tried)).toBe(true);
|
||||
expect(body.reasoning_effort).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchWithGenerationCompat", () => {
|
||||
@@ -177,6 +241,34 @@ describe("fetchWithGenerationCompat", () => {
|
||||
expect(sent[0].temperature).toBe(1);
|
||||
expect(sent[1].temperature).toBeUndefined();
|
||||
});
|
||||
|
||||
it("retries tools+reasoning conflict by setting none", async () => {
|
||||
const sent: Array<Record<string, unknown>> = [];
|
||||
const response = await fetchWithGenerationCompat(async (body) => {
|
||||
sent.push({ ...body });
|
||||
if (body.reasoning_effort !== "none") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message:
|
||||
"Function tools with reasoning_effort are not supported for gpt-5.6-luna. Set reasoning_effort to 'none'.",
|
||||
},
|
||||
}),
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
}, {
|
||||
model: "gpt-5.6-luna",
|
||||
tools: [{ type: "function", function: { name: "run" } }],
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(sent).toHaveLength(2);
|
||||
expect(sent[0].reasoning_effort).toBeUndefined();
|
||||
expect(sent[1].reasoning_effort).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRequestBody", () => {
|
||||
@@ -234,6 +326,25 @@ describe("buildRequestBody", () => {
|
||||
expect(body.reasoning_effort).toBe("low");
|
||||
});
|
||||
|
||||
it("forces reasoning_effort none when tools are attached", () => {
|
||||
const config: LlmConfig = {
|
||||
baseUrl: "https://example.test/v1",
|
||||
apiKey: "k",
|
||||
model: "gpt-5.6-luna",
|
||||
reasoningEffort: "high",
|
||||
};
|
||||
const body = buildRequestBody(config, messages, {
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: { name: "run_worker", parameters: { type: "object" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(body.reasoning_effort).toBe("none");
|
||||
expect(body.tools).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not forward top_k from an imported ST preset", () => {
|
||||
const config: LlmConfig = {
|
||||
baseUrl: "https://example.test/v1",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Blackboard } from "../src/blackboard/blackboard.js";
|
||||
import {
|
||||
lookupValueMapContent,
|
||||
parseValueMapDoc,
|
||||
reprojectValueMaps,
|
||||
serializeValueMapDoc,
|
||||
upsertValueMapEntry,
|
||||
VALUE_MAP_TAG,
|
||||
} from "../src/skills/value-map.js";
|
||||
import {
|
||||
parseVariableCatalog,
|
||||
seedVariablesFromCatalog,
|
||||
serializeVariableCatalog,
|
||||
upsertVariableField,
|
||||
VARIABLE_CATALOG_TAG,
|
||||
} from "../src/skills/variable-catalog.js";
|
||||
import { stringifyTableDoc, createTableFromValues, parseTableDoc } from "../src/blackboard/table-cells.js";
|
||||
|
||||
describe("variable-catalog", () => {
|
||||
it("upserts fields and seeds empty 变量.当前", () => {
|
||||
let doc = null as ReturnType<typeof parseVariableCatalog>;
|
||||
const a = upsertVariableField(doc, {
|
||||
key: "好感",
|
||||
type: "number",
|
||||
initial: 10,
|
||||
user_visible: true,
|
||||
});
|
||||
expect(a.error).toBeUndefined();
|
||||
const b = upsertVariableField(a.doc, {
|
||||
key: "地点",
|
||||
type: "string",
|
||||
initial: "旧港",
|
||||
user_visible: false,
|
||||
});
|
||||
const raw = serializeVariableCatalog(b.doc);
|
||||
const parsed = parseVariableCatalog(raw);
|
||||
expect(parsed?.fields).toHaveLength(2);
|
||||
expect(parsed?.fields.find((f) => f.key === "地点")?.user_visible).toBe(false);
|
||||
|
||||
const seeded = seedVariablesFromCatalog({
|
||||
catalogRaw: raw,
|
||||
currentRaw: null,
|
||||
initialRaw: null,
|
||||
});
|
||||
expect(seeded.seededKeys).toEqual(expect.arrayContaining(["好感", "地点"]));
|
||||
expect(seeded.current).toContain("好感");
|
||||
expect(seeded.initial).toContain("旧港");
|
||||
});
|
||||
});
|
||||
|
||||
describe("value-map", () => {
|
||||
it("looks up range bands (min inclusive, max exclusive)", () => {
|
||||
const { doc } = upsertValueMapEntry(null, {
|
||||
id: "aff",
|
||||
field: "好感",
|
||||
target_tag: "上下文.角色态度",
|
||||
bands: [
|
||||
{ min: 0, max: 30, content: "冷淡" },
|
||||
{ min: 30, max: 60, content: "熟悉" },
|
||||
{ min: 60, content: "恋爱" },
|
||||
],
|
||||
});
|
||||
const entry = doc.maps[0]!;
|
||||
expect(lookupValueMapContent(entry, 0)).toBe("冷淡");
|
||||
expect(lookupValueMapContent(entry, 29)).toBe("冷淡");
|
||||
expect(lookupValueMapContent(entry, 30)).toBe("熟悉");
|
||||
expect(lookupValueMapContent(entry, 60)).toBe("恋爱");
|
||||
});
|
||||
|
||||
it("reprojects target tag when 变量.当前 changes", () => {
|
||||
const board = new Blackboard();
|
||||
const { doc } = upsertValueMapEntry(null, {
|
||||
id: "aff",
|
||||
field: "好感",
|
||||
target_tag: "上下文.角色态度",
|
||||
bands: [
|
||||
{ min: 0, max: 50, content: "档A" },
|
||||
{ min: 50, content: "档B" },
|
||||
],
|
||||
});
|
||||
board.write({
|
||||
tag: VALUE_MAP_TAG,
|
||||
content: serializeValueMapDoc(doc),
|
||||
source: "test",
|
||||
});
|
||||
const tableRaw = stringifyTableDoc(
|
||||
createTableFromValues({ ["好感"]: 20 }, "test"),
|
||||
);
|
||||
board.write({
|
||||
tag: "变量.当前",
|
||||
content: tableRaw,
|
||||
source: "test",
|
||||
});
|
||||
expect(parseTableDoc(board.getContentByTag("变量.当前"))?.rows.map((r) => r.key)).toEqual([
|
||||
"好感",
|
||||
]);
|
||||
const r1 = reprojectValueMaps({ blackboard: board, currentRaw: tableRaw });
|
||||
expect(r1.skipped, JSON.stringify(r1)).toEqual([]);
|
||||
expect(r1.written[0]?.tag).toBe("上下文.角色态度");
|
||||
expect(board.getContentByTag("上下文.角色态度")).toBe("档A");
|
||||
|
||||
const tableRaw2 = stringifyTableDoc(
|
||||
createTableFromValues({ ["好感"]: 80 }, "test"),
|
||||
);
|
||||
board.write({
|
||||
tag: "变量.当前",
|
||||
content: tableRaw2,
|
||||
source: "test",
|
||||
});
|
||||
reprojectValueMaps({ blackboard: board, currentRaw: tableRaw2 });
|
||||
expect(board.getContentByTag("上下文.角色态度")).toBe("档B");
|
||||
});
|
||||
|
||||
it("rejects bad target_tag", () => {
|
||||
const { error } = upsertValueMapEntry(null, {
|
||||
id: "x",
|
||||
field: "好感",
|
||||
target_tag: "设计.美学纲领",
|
||||
bands: [{ content: "no" }],
|
||||
});
|
||||
expect(error).toMatch(/上下文/);
|
||||
});
|
||||
|
||||
it("round-trips parse", () => {
|
||||
const raw = serializeValueMapDoc({
|
||||
schema: "value-map.v1",
|
||||
maps: [
|
||||
{
|
||||
id: "ch",
|
||||
field: "章节",
|
||||
target_tag: "大纲.当前章",
|
||||
bands: [
|
||||
{ when: "eq", value: 1, content: "第一章" },
|
||||
{ when: "default", content: "未定" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const parsed = parseValueMapDoc(raw);
|
||||
expect(parsed?.maps[0]?.bands).toHaveLength(2);
|
||||
expect(lookupValueMapContent(parsed!.maps[0]!, 1)).toBe("第一章");
|
||||
expect(lookupValueMapContent(parsed!.maps[0]!, 9)).toBe("未定");
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog tag constants", () => {
|
||||
it("uses stable design tags", () => {
|
||||
expect(VARIABLE_CATALOG_TAG).toBe("设计.变量目录");
|
||||
expect(VALUE_MAP_TAG).toBe("设计.变量映射");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user