feat(agent): 创作助手 - 完成第一期交互与执行基线
This commit is contained in:
36
web/test/creative-agent-contract.test.ts
Normal file
36
web/test/creative-agent-contract.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { applyCreativeAnswers, creativeBatchBarrier, normalizeCreativeQuestions, validateCreativeAnswers, type CreativeBrief } from "../src/lib/creation/creative-agent-contract";
|
||||
|
||||
const identity = { interactionId: "trusted", revision: 2 };
|
||||
const question = (field: string) => ({ id: "model-id", field, title: field, type: "single", required: true, options: [{ id: "model-option", label: "选项" }] });
|
||||
describe("创作交互合同", () => {
|
||||
test("跳过明确答案、未知字段和重复字段,最多三题且覆盖模型身份", () => {
|
||||
const brief: CreativeBrief = { genre: { value: "悬疑", source: "user", status: "confirmed" } };
|
||||
const request = normalizeCreativeQuestions([question("genre"), question("unknown"), question("seconds"), question("seconds"), question("style"), question("episodes"), question("scenes")], "short-film", brief, identity);
|
||||
expect(request.questions.map((q) => q.field)).toEqual(["seconds", "style", "episodes"]);
|
||||
expect(request.questions[0].id).toBe("trusted:2:1");
|
||||
expect(request.questions[0].options[0].id).not.toBe("model-option");
|
||||
});
|
||||
test("未解析和冲突不是已知事实,营销不询问短片字段", () => {
|
||||
const request = normalizeCreativeQuestions([question("assets"), question("style"), question("episodes")], "marketing", { assets: { value: "文件", source: "asset", status: "unparsed" }, style: { value: "写实", source: "user", status: "conflict" } }, identity);
|
||||
expect(request.questions.map((q) => q.field)).toEqual(["assets", "style"]);
|
||||
});
|
||||
test("必填答案与素材归属校验,成功才合并 brief", () => {
|
||||
const assets = [{ id: "real-asset", label: "真实商品图" }];
|
||||
const request = normalizeCreativeQuestions([{ ...question("assets"), type: "asset", options: [{ id: "invented", label: "模型虚构图" }] }], "general", {}, identity, assets);
|
||||
expect(request.questions[0].options).toEqual(assets);
|
||||
const id = request.questions[0].id;
|
||||
expect(validateCreativeAnswers(request, {}, assets)?.questionId).toBe(id);
|
||||
expect(() => applyCreativeAnswers(request, identity, { [id]: { selected: ["invented"], custom: "" } }, {}, assets)).toThrow("不可用");
|
||||
expect(applyCreativeAnswers(request, identity, { [id]: { selected: ["real-asset"], custom: "" } }, {}, assets).assets).toEqual({ value: "real-asset", source: "user", status: "confirmed" });
|
||||
});
|
||||
test("过期、已提交交互不能再次提交", () => {
|
||||
const request = normalizeCreativeQuestions([], "general", {}, identity);
|
||||
expect(() => applyCreativeAnswers(request, { ...identity, revision: 1 }, {}, {})).toThrow("过期");
|
||||
expect(() => applyCreativeAnswers({ ...request, status: "submitted" }, identity, {}, {})).toThrow("过期");
|
||||
});
|
||||
test("等待屏障保留前置读取与提问,取消后续写调用", () => {
|
||||
expect(creativeBatchBarrier(["read", "question", "create", "generate"], (call) => call === "question")).toEqual({ executable: ["read", "question"], cancelled: ["create", "generate"], waiting: true });
|
||||
expect(creativeBatchBarrier(["read"], () => false)).toEqual({ executable: ["read"], cancelled: [], waiting: false });
|
||||
});
|
||||
});
|
||||
86
web/test/creative-agent-controller.test.ts
Normal file
86
web/test/creative-agent-controller.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { CreativeAgentController, type CreativeControllerView } from "../src/services/creative-agent-controller";
|
||||
import { initialCreativeState, type CreativeAgentState } from "../src/lib/creation/creative-agent-state";
|
||||
import { creationRuns, type CreationRun, type CreationSubmission } from "../src/services/api/creation-runs";
|
||||
import { applyCanvasAgentOps, type CanvasAgentSnapshot } from "../src/lib/canvas/canvas-agent-ops";
|
||||
import { defaultConfig } from "../src/stores/use-config-store";
|
||||
import type { GenerationTask } from "../src/services/api/task-center";
|
||||
import { CanvasNodeType } from "../src/types/canvas";
|
||||
|
||||
function harness(state: CreativeAgentState, status: CreationRun["status"] = "paused", submissions: CreationSubmission[] = [], waitTask?: ConstructorParameters<typeof CreativeAgentController>[0]["waitTask"]) {
|
||||
let run: CreationRun = { id: "run", userId: "user", canvasId: "canvas", revision: 1, executionEpoch: 0, executionOwner: "", status, state: structuredClone(state) as unknown as Record<string, unknown>, approvedProposalVersion: state.proposal?.version, createdAt: "", updatedAt: "" };
|
||||
let snapshot: CanvasAgentSnapshot = { projectId: "canvas", title: "canvas", nodes: state.media.map((media) => ({ id: media.nodeId, type: CanvasNodeType.Image, title: media.ref, position: { x: 0, y: 0 }, width: 100, height: 100, metadata: {} })), connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
|
||||
let view: CreativeControllerView | undefined;
|
||||
let commits = 0, prepares = 0, executions = 0;
|
||||
const api = { ...creationRuns,
|
||||
get: async () => ({ run: structuredClone(run), submissions: structuredClone(submissions) }),
|
||||
claim: async (_id: string, input: { owner: string }) => (run = { ...run, executionEpoch: run.executionEpoch + 1, executionOwner: input.owner }),
|
||||
save: async (_id: string, input: { revision: number; status: CreationRun["status"]; state: Record<string, unknown> }) => { if (input.revision !== run.revision) throw new Error("stale"); return run = { ...run, revision: run.revision + 1, state: structuredClone(input.state), status: input.status }; },
|
||||
release: async () => ({ released: true }),
|
||||
prepare: async () => { prepares++; throw new Error("不应准备新任务"); },
|
||||
execute: async () => { executions++; throw new Error("不应执行新任务"); },
|
||||
canvas: async () => ({ run, canvasId: "canvas" }),
|
||||
canvasSnapshot: async () => ({ document: { id: "canvas", title: "canvas", nodes: snapshot.nodes, connections: snapshot.connections, chatSessions: [], activeChatId: null, viewport: snapshot.viewport, createdAt: "", updatedAt: "", directorScenes: [] }, snapshotHash: "hash" }),
|
||||
commitCanvas: async () => { commits++; return { snapshotHash: "saved" }; },
|
||||
} as typeof creationRuns;
|
||||
const controller = new CreativeAgentController({ config: () => defaultConfig, canvas: () => ({ canvasId: "canvas", read: () => snapshot, apply: async (ops) => snapshot = applyCanvasAgentOps(snapshot, ops) }), onChange: (next) => { view = next; }, onOpenCanvas: () => undefined, api, waitTask, ensureAsset: async () => ({ assetId: "asset", created: false, linkedToProject: false }) });
|
||||
return { controller, api, view: () => view!, counters: () => ({ commits, prepares, executions }), snapshot: () => snapshot };
|
||||
}
|
||||
const proposal = { id: "p", version: 1, title: "方案", summary: "摘要", markdown: "内容", deliverables: [], workflow: { nodes: [], edges: [], autoRun: false as const }, generationItems: [] };
|
||||
const submission = (id: string, itemKey: string, taskId?: string): CreationSubmission => ({ id, runId: "run", itemKey, requestHash: "hash", taskId, quote: { model: "model", billingMode: "fixed_request", quantity: 1, amountMicrocredits: 1, estimated: false, expiresAt: "2099-01-01", quoteHash: "quote" } });
|
||||
|
||||
describe("创作控制器恢复", () => {
|
||||
test("更新过期报价替换分析关联并等待新批准,不执行模型", async () => {
|
||||
const old = submission("old", "planning:key");
|
||||
const fresh = submission("fresh", "requote:old");
|
||||
const h = harness({ ...initialCreativeState(), planning: { itemKey: old.itemKey, submissionId: old.id, protocol: [] }, pendingPayment: [old.id] }, "waiting_payment", [old]);
|
||||
h.api.refreshQuote = async () => fresh;
|
||||
try {
|
||||
await h.controller.load("run"); await h.controller.refreshQuotes();
|
||||
expect(h.view().state.planning?.itemKey).toBe("requote:old");
|
||||
expect(h.view().state.pendingPayment).toEqual(["fresh"]);
|
||||
expect(h.view().run?.status).toBe("waiting_payment"); expect(h.counters().executions).toBe(0);
|
||||
} finally { h.controller.dispose(); }
|
||||
});
|
||||
test("加载报价不执行;批准后只展示首个问答,忽略同批后续方案", async () => {
|
||||
const quote = submission("plan", "planning:key");
|
||||
const completed = { id: "task", status: "succeeded", resultJson: JSON.stringify({ toolCalls: [
|
||||
{ id: "first", type: "function", function: { name: "creative_respond", arguments: JSON.stringify({ message: "请补充目标", questions: [{ field: "goal", title: "想做什么?", type: "text", required: true, allowCustom: true }] }) } },
|
||||
{ id: "second", type: "function", function: { name: "creative_respond", arguments: JSON.stringify({ proposal: { title: "不应执行" } }) } },
|
||||
] }) } as GenerationTask;
|
||||
const h = harness({ ...initialCreativeState(), planning: { itemKey: "planning:key", submissionId: "plan", protocol: [] }, pendingPayment: ["plan"] }, "waiting_payment", [quote], async () => completed);
|
||||
let approved = false, executed = 0;
|
||||
h.api.approve = async () => { approved = true; return { submissions: [{ ...quote, approvedAt: "2026-01-01" }] }; };
|
||||
h.api.execute = async () => { expect(approved).toBe(true); executed++; return completed; };
|
||||
try {
|
||||
await h.controller.load("run"); expect(executed).toBe(0);
|
||||
await h.controller.approvePayment();
|
||||
expect(executed).toBe(1); expect(h.view().run?.status).toBe("waiting_answer");
|
||||
expect(h.view().state.questions?.questions).toHaveLength(1);
|
||||
expect(h.view().state.proposal).toBeUndefined(); expect(h.counters().commits).toBe(0);
|
||||
} finally { h.controller.dispose(); }
|
||||
});
|
||||
test("暂停于已批准画布接续时仍可创建节点并完成,不提交模型任务", async () => {
|
||||
const h = harness({ ...initialCreativeState(), proposal, operations: [{ type: "add_node", id: "outline", nodeType: CanvasNodeType.Text, title: "大纲", metadata: { content: "内容" } }], canvasApplied: false });
|
||||
try { await h.controller.load("run"); await h.controller.resume(); expect(h.snapshot().nodes).toHaveLength(1); expect(h.view().run?.status).toBe("completed"); expect(h.counters()).toEqual({ commits: 1, prepares: 0, executions: 0 }); } finally { h.controller.dispose(); }
|
||||
});
|
||||
test("prepare回执丢失后按itemKey恢复原报价,不新增调用", async () => {
|
||||
const h = harness({ ...initialCreativeState(), planning: { itemKey: "planning:key", protocol: [], model: "model" } }, "running", [submission("existing", "planning:key")]);
|
||||
try { await h.controller.load("run"); await h.controller.resume(); expect(h.view().state.planning?.submissionId).toBe("existing"); expect(h.view().quote).toBeDefined(); expect(h.counters().prepares).toBe(0); } finally { h.controller.dispose(); }
|
||||
});
|
||||
test("第一项失败不阻止同批成功结果回写", async () => {
|
||||
const media = ["a", "b"].map((ref) => ({ ref, nodeId: ref, attempt: 1, submissionId: ref, taskId: ref, status: "queued" as const }));
|
||||
const h = harness({ ...initialCreativeState(), proposal, canvasApplied: true, media }, "paused", [submission("a", "a", "a"), submission("b", "b", "b")], async (id) => { if (id === "a") throw new Error("A生成失败"); return { id, status: "succeeded", resultJson: JSON.stringify({ images: [{ storageKey: "resource:output", dataUrl: "/api/resources/output/file" }] }) } as GenerationTask; });
|
||||
try { await h.controller.load("run"); await expect(h.controller.resume()).rejects.toThrow("A生成失败"); expect(h.view().state.media.map((item) => item.status)).toEqual(["failed", "ready"]); expect(h.counters().executions).toBe(0); expect(h.counters().commits).toBe(1); } finally { h.controller.dispose(); }
|
||||
});
|
||||
test("暂停后迟到的任务回调不能写画布", async () => {
|
||||
let resolveTask!: (task: GenerationTask) => void;
|
||||
let waiting!: () => void; const started = new Promise<void>((resolve) => { waiting = resolve; });
|
||||
const h = harness({ ...initialCreativeState(), proposal, canvasApplied: true, media: [{ ref: "a", nodeId: "a", attempt: 1, submissionId: "a", taskId: "a", status: "queued" }] }, "waiting_task", [submission("a", "a", "a")], () => { waiting(); return new Promise((resolve) => { resolveTask = resolve; }); });
|
||||
try {
|
||||
await h.controller.load("run"); const resume = h.controller.resume(); await started; await h.controller.pause();
|
||||
resolveTask({ id: "a", status: "succeeded", resultJson: JSON.stringify({ images: [{ storageKey: "resource:a" }] }) } as GenerationTask);
|
||||
await expect(resume).rejects.toThrow(); expect(h.counters().commits).toBe(0); expect(h.view().hasControl).toBe(false);
|
||||
} finally { h.controller.dispose(); }
|
||||
});
|
||||
});
|
||||
68
web/test/creative-agent-redo.test.ts
Normal file
68
web/test/creative-agent-redo.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { CreativeAgentController } from "../src/services/creative-agent-controller";
|
||||
import { assertCreativeBriefSpecifications, initialCreativeState, type CreativeAgentState } from "../src/lib/creation/creative-agent-state";
|
||||
import type { CreativeProposal } from "../src/lib/creation/creative-agent-contract";
|
||||
import { creationRuns, type CreationRun, type CreationSubmission } from "../src/services/api/creation-runs";
|
||||
import { createModelChannel, defaultConfig } from "../src/stores/use-config-store";
|
||||
import type { CanvasAgentSnapshot } from "../src/lib/canvas/canvas-agent-ops";
|
||||
import { CanvasNodeType } from "../src/types/canvas";
|
||||
import type { GenerationTask } from "../src/services/api/task-center";
|
||||
|
||||
const channel = createModelChannel({ id: "system-test", name: "测试", scope: "system", models: ["gpt-image-1"], apiFormat: "openai", interfaceType: "openai-images", apiKey: "system", baseUrl: "/api/test" });
|
||||
const model = "system-test::gpt-image-1";
|
||||
const config = { ...defaultConfig, channels: [channel], model };
|
||||
const proposal: CreativeProposal = { id: "p", version: 1, title: "双图", summary: "摘要", markdown: "正文", deliverables: [], workflow: { nodes: [{ ref: "a", kind: "image", title: "参考", prompt: "参考" }, { ref: "b", kind: "image", title: "结果", prompt: "结果", referenceRefs: ["a"] }], edges: [], autoRun: false }, generationItems: [{ ref: "a", mode: "image", model, size: "1024x1024" }, { ref: "b", mode: "image", model, size: "1024x1024", referenceRefs: ["a"] }], extra: { nodeIds: { a: "original-a", b: "original-b" } } };
|
||||
|
||||
describe("创作规格与局部重做", () => {
|
||||
test("像素尺寸按实际比例比较,错误比例和未明确规格仍拒绝", () => {
|
||||
const brief = { aspectRatio: { value: "1:1", source: "user" as const, status: "confirmed" as const } };
|
||||
expect(() => assertCreativeBriefSpecifications(proposal, brief)).not.toThrow();
|
||||
for (const size of ["1536x1024", "auto", undefined]) expect(() => assertCreativeBriefSpecifications({ ...proposal, generationItems: [{ ...proposal.generationItems[0], size }] }, brief)).toThrow("比例");
|
||||
expect(() => assertCreativeBriefSpecifications({ ...proposal, generationItems: [{ ...proposal.generationItems[0], size: "1536 × 1024" }] }, { aspectRatio: { ...brief.aspectRatio, value: "3:2" } })).not.toThrow();
|
||||
});
|
||||
|
||||
for (const approvedBeforeDisconnect of [false, true]) test(`重做批准${approvedBeforeDisconnect ? "已落盘但回执丢失" : "尚未落盘"},重新装载后仅准备原目标并保留参考节点`, async () => {
|
||||
const state: CreativeAgentState = { ...initialCreativeState(), proposal: structuredClone(proposal), canvasApplied: true, media: ["a", "b"].map((ref) => ({ ref, nodeId: `original-${ref}`, attempt: 1, status: "ready", taskId: `old-${ref}`, storageKey: `resource:${ref}` })) };
|
||||
let run: CreationRun = { id: "run", userId: "user", canvasId: "canvas", revision: 1, executionEpoch: 0, executionOwner: "", status: "completed", state: structuredClone(state) as unknown as Record<string, unknown>, approvedProposalVersion: 1, approvedProposalHash: "old", createdAt: "", updatedAt: "" };
|
||||
const snapshot: CanvasAgentSnapshot = { projectId: "canvas", title: "画布", nodes: ["a", "b"].map((ref) => ({ id: `original-${ref}`, type: CanvasNodeType.Image, title: ref, position: { x: 0, y: 0 }, width: 100, height: 100, metadata: { prompt: ref === "a" ? "参考" : "结果", model, size: "1024x1024", status: "success", storageKey: `resource:${ref}`, ...(ref === "b" ? { referenceNodeIds: ["original-a"] } : {}) } })), connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
|
||||
const submissions: CreationSubmission[] = [];
|
||||
let disconnect = true, approvals = 0, executions = 0;
|
||||
const prepared: Parameters<typeof creationRuns.prepare>[1][] = [];
|
||||
const api: typeof creationRuns = { ...creationRuns,
|
||||
get: async () => ({ run: structuredClone(run), submissions: structuredClone(submissions) }),
|
||||
claim: async (_id, input) => (run = { ...run, executionEpoch: run.executionEpoch + 1, executionOwner: input.owner }),
|
||||
release: async () => ({ released: true }),
|
||||
save: async (_id, input) => (run = { ...run, revision: run.revision + 1, state: structuredClone(input.state), status: input.status }),
|
||||
approveProposal: async (_id, input) => {
|
||||
approvals++;
|
||||
if (!disconnect || approvedBeforeDisconnect) run = { ...run, revision: run.revision + 1, approvedProposalVersion: input.proposalVersion, approvedProposalHash: "redo" };
|
||||
if (disconnect) throw new Error("模拟批准断线");
|
||||
return run;
|
||||
},
|
||||
prepare: async (_id, input) => {
|
||||
prepared.push(input);
|
||||
const submission: CreationSubmission = { id: "new-b", runId: "run", itemKey: input.itemKey, requestHash: "hash", quote: { model, billingMode: "fixed_request", quantity: 1, amountMicrocredits: 1, estimated: false, expiresAt: "2099-01-01", quoteHash: "quote" } };
|
||||
submissions.push(submission); return submission;
|
||||
},
|
||||
execute: async () => { executions++; throw new Error("恢复不得自动收费"); },
|
||||
};
|
||||
const create = () => new CreativeAgentController({ config: () => config, canvas: () => ({ canvasId: "canvas", read: () => snapshot, apply: async () => { throw new Error("重做不得新建节点"); } }), onChange: () => undefined, onOpenCanvas: () => undefined, api, queryTask: async () => ({ status: "succeeded" } as GenerationTask) });
|
||||
const original = create();
|
||||
try { await original.load("run"); await expect(original.redo("b")).rejects.toThrow("模拟批准断线"); } finally { original.dispose(); }
|
||||
expect((run.state as unknown as CreativeAgentState).pendingRedo).toEqual({ ref: "b", attempt: 2, proposalVersion: 2 });
|
||||
disconnect = false;
|
||||
const restored = create();
|
||||
try { await restored.load("run"); await restored.resume(); await restored.resume(); } finally { restored.dispose(); }
|
||||
const saved = run.state as unknown as CreativeAgentState;
|
||||
expect(approvals).toBe(approvedBeforeDisconnect ? 1 : 2);
|
||||
expect(prepared).toHaveLength(1);
|
||||
expect(prepared[0].itemKey).toBe("media:v2:b:2");
|
||||
expect(prepared[0].request.input.nodeId).toBe("original-b");
|
||||
expect((prepared[0].request.input.referenceImages as { id: string }[]).map((reference) => reference.id)).toEqual(["original-a"]);
|
||||
expect(saved.pendingRedo).toBeUndefined();
|
||||
expect(saved.media[0]).toEqual(state.media[0]);
|
||||
expect(saved.media[1].attempt).toBe(2);
|
||||
expect(run.status).toBe("waiting_payment");
|
||||
expect(executions).toBe(0);
|
||||
});
|
||||
});
|
||||
53
web/test/creative-agent-state.test.ts
Normal file
53
web/test/creative-agent-state.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { defaultConfig, type AiConfig } from "../src/stores/use-config-store";
|
||||
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
|
||||
import { assertCreativeBriefSpecifications, creativeProposalOps, creativeVideoSpecificationError, mergeCreativeBrief, normalizeCreativeProposal } from "../src/lib/creation/creative-agent-state";
|
||||
import { applyCanvasAgentOps, type CanvasAgentSnapshot } from "../src/lib/canvas/canvas-agent-ops";
|
||||
|
||||
const image = defaultModelCapabilityConfig("openai-image", "image-model");
|
||||
const video = defaultModelCapabilityConfig("volcengine-ark-video", "video-model");
|
||||
const config: AiConfig = { ...defaultConfig, model: "managed::image-model", imageModel: "managed::image-model", videoModel: "managed::video-model", models: ["managed::image-model", "managed::video-model"], imageModels: ["managed::image-model"], videoModels: ["managed::video-model"], channels: [{ ...defaultConfig.channels[0]!, id: "managed", models: ["image-model", "video-model"], modelCosts: [{ model: "image-model", capability: "image", capabilityConfig: image }, { model: "video-model", capability: "video", capabilityConfig: video }] }] };
|
||||
const empty: CanvasAgentSnapshot = { projectId: "canvas", title: "test", nodes: [], connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
|
||||
function shortFilm() {
|
||||
return { title: "悬疑短片", summary: "一个电梯故事", markdown: "完整故事大纲", deliverables: ["15 秒竖屏短片"], workflow: { nodes: [{ ref: "outline", kind: "text", title: "大纲", content: "叙事节奏" }, ...["character", "scene", "prop"].map((ref) => ({ ref, kind: "image", title: ref, prompt: ref })), { ref: "film", kind: "video", title: "短片", prompt: "电梯内发生异常", referenceRefs: ["character", "scene", "prop"] }], edges: ["character", "scene", "prop"].map((ref) => ({ from: "outline", to: ref })) }, generationItems: [...["character", "scene", "prop"].map((ref) => ({ ref, mode: "image", model: "managed::image-model" })), { ref: "film", mode: "video", model: "managed::video-model", seconds: 15, size: "9:16", referenceRefs: ["character", "scene", "prop"] }] };
|
||||
}
|
||||
describe("创作方案执行合同", () => {
|
||||
test("实际视频元数据不符不能按方案宣称成功", () => {
|
||||
const item = { ref: "film", mode: "video" as const, model: "video", seconds: 15, size: "9:16" };
|
||||
expect(creativeVideoSpecificationError(item, { durationMs: 6000, width: 720, height: 1280 })).toContain("6.00 秒");
|
||||
expect(creativeVideoSpecificationError(item, { durationMs: 15000, width: 1280, height: 720 })).toContain("比例不符");
|
||||
expect(creativeVideoSpecificationError(item, { durationMs: 15033, width: 720, height: 1280 })).toBeUndefined();
|
||||
});
|
||||
test("单段短片为5节点6真实连线,参考图并排分层,ID跨恢复稳定", () => {
|
||||
const proposal = normalizeCreativeProposal(shortFilm(), "p", 1, config);
|
||||
const ops = creativeProposalOps("run", proposal, empty, config);
|
||||
const next = applyCanvasAgentOps(empty, ops);
|
||||
expect(next.nodes).toHaveLength(5); expect(next.connections).toHaveLength(6);
|
||||
expect(ops.some((op) => op.type === "run_generation")).toBe(false);
|
||||
expect(next.nodes.filter((node) => node.type === "video")).toHaveLength(1);
|
||||
const refs = next.nodes.filter((node) => node.type === "image");
|
||||
expect(new Set(refs.map((node) => node.position.x)).size).toBe(1);
|
||||
expect(new Set(refs.map((node) => node.position.y)).size).toBe(3);
|
||||
expect(creativeProposalOps("run", proposal, empty, config)).toEqual(ops);
|
||||
});
|
||||
test("不能静默缩短已确认时长,也不能忽略模型输入上限", () => {
|
||||
const raw = shortFilm(); raw.generationItems[3]!.seconds = 10;
|
||||
const proposal = normalizeCreativeProposal(raw, "p", 1, config);
|
||||
expect(() => assertCreativeBriefSpecifications(proposal, { seconds: { value: 15, source: "user", status: "confirmed" } })).toThrow("15 秒");
|
||||
const limited = structuredClone(config); limited.channels[0]!.modelCosts![1]!.capabilityConfig!.video!.references.maxImages = 1;
|
||||
expect(() => normalizeCreativeProposal(shortFilm(), "p", 1, limited)).toThrow("参考图");
|
||||
});
|
||||
test("已有商品图进入正式引用节点但不进入收费生成项", () => {
|
||||
const proposal = normalizeCreativeProposal({ title: "海报", summary: "产品海报", markdown: "两版卖点设计", deliverables: ["营销图"], workflow: { nodes: [{ ref: "product", kind: "image", title: "商品", assetId: "asset" }, { ref: "poster", kind: "image", title: "海报", prompt: "保持商品实物", referenceRefs: ["product"] }], edges: [] }, generationItems: [{ ref: "poster", mode: "image", model: "managed::image-model", referenceRefs: ["product"] }] }, "p", 1, config, [{ id: "asset", assetId: "asset", title: "商品实物", kind: "image", storageKey: "resource:product" }]);
|
||||
expect(proposal.generationItems).toHaveLength(1);
|
||||
const nodes = applyCanvasAgentOps(empty, creativeProposalOps("run", proposal, empty, config)).nodes;
|
||||
expect(nodes[0]!.metadata?.assetId).toBe("asset"); expect(nodes[0]!.metadata?.status).toBe("success");
|
||||
expect(nodes[1]!.metadata?.referenceNodeIds).toEqual([nodes[0]!.id]);
|
||||
});
|
||||
test("模型推断不能降低程序已确认字段的状态", () => {
|
||||
const known = { seconds: { value: 15, source: "user" as const, status: "confirmed" as const } };
|
||||
expect(mergeCreativeBrief(known, [{ field: "seconds", value: 15, source: "user", status: "inferred" }], "short-film")).toEqual(known);
|
||||
expect(mergeCreativeBrief(known, [{ field: "seconds", value: 6, source: "user", status: "confirmed" }], "short-film", "请继续")).toEqual(known);
|
||||
expect(mergeCreativeBrief(known, [{ field: "seconds", value: 30, source: "user", status: "confirmed", evidence: "改为30秒" }], "short-film", "时长改为30秒").seconds?.value).toBe(30);
|
||||
});
|
||||
});
|
||||
@@ -17,15 +17,13 @@ test("managed canvas agent task keeps tool definitions and tool results", () =>
|
||||
"required",
|
||||
);
|
||||
|
||||
assert.equal(requests.responses.tool_choice, "required");
|
||||
assert.deepEqual(requests.responses.input, [
|
||||
assert.deepEqual(Object.keys(requests), ["canonical"]);
|
||||
assert.equal(requests.canonical.toolChoice, "required");
|
||||
assert.equal(requests.canonical.systemPrompt, "");
|
||||
assert.deepEqual(requests.canonical.tools, [{ type: "function", function: { name: "canvas_get_state", parameters: { type: "object" } } }]);
|
||||
assert.deepEqual(requests.canonical.messages, [
|
||||
{ role: "user", content: "读取画布" },
|
||||
{ type: "function_call", call_id: "call-1", name: "canvas_get_state", arguments: "{}" },
|
||||
{ type: "function_call_output", call_id: "call-1", output: '{"nodes":[]}' },
|
||||
]);
|
||||
assert.deepEqual(requests.chatCompletion.messages, [
|
||||
{ role: "user", content: "读取画布" },
|
||||
{ role: "assistant", content: null, tool_calls: [{ id: "call-1", type: "function", function: { name: "canvas_get_state", arguments: "{}" } }] },
|
||||
{ role: "tool", tool_call_id: "call-1", content: '{"nodes":[]}' },
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user