feat(canvas): 创作助手 - 合并创作对话、能力发现与画布接续

This commit is contained in:
ddCat
2026-09-09 22:46:45 +08:00
97 changed files with 8434 additions and 326 deletions

View File

@@ -0,0 +1,48 @@
import { afterEach, expect, test } from "bun:test";
import { registerPlugin, unregisterPlugin } from "../src/lib/plugins/plugin-registry";
import { usePluginStore } from "../src/stores/use-plugin-store";
import { listAgentCapabilities, buildAgentPluginOperations } from "../src/services/agent-capabilities";
import { applyCanvasAgentOps, type CanvasAgentSnapshot } from "../src/lib/canvas/canvas-agent-ops";
import { normalizeCreativeProposal, creativeProposalOps } from "../src/lib/creation/creative-agent-state";
import { defaultConfig } from "../src/stores/use-config-store";
import { buildCanvasWorkflowOps } from "../src/lib/canvas/canvas-agent-workflow";
const snapshot: CanvasAgentSnapshot = { projectId: "test", title: "test", nodes: [], connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
const pluginId = "agent-extension-test";
afterEach(() => { unregisterPlugin(pluginId); usePluginStore.setState({ runtimeStatuses: {} }); });
test("方案与直接工作流均拒绝只有正文的分镜", () => {
const nodes = [{ ref: "shots", kind: "script" as const, title: "10秒分镜", content: "0–2秒近景,2–10秒特写" }];
expect(() => normalizeCreativeProposal({ title: "短剧", summary: "分镜", markdown: "方案", workflow: { nodes, edges: [] }, generationItems: [] }, "p", 1, defaultConfig)).toThrow("script.shots");
expect(() => buildCanvasWorkflowOps({ nodes, edges: [] }, snapshot, defaultConfig)).toThrow("shots");
});
test("专业方案创建可编辑分镜与上下文连线,不伪造画风已选择或收费任务", () => {
const proposal = normalizeCreativeProposal({ title: "短剧", summary: "搭建", markdown: "完整方案", workflow: { nodes: [
{ ref: "style", kind: "styleboard", title: "项目画风" },
{ ref: "story", kind: "story_input", title: "故事", content: "两个人重逢" },
{ ref: "shots", kind: "script", title: "分镜", shots: [{ durationSeconds: 5, videoMotionPrompt: "推近人物", dialogue: "好久不见" }] },
], edges: [{ from: "style", to: "shots" }, { from: "story", to: "shots" }] }, generationItems: [] }, "p", 1, defaultConfig);
const result = applyCanvasAgentOps(snapshot, creativeProposalOps("run", proposal, snapshot, defaultConfig));
expect(result.nodes.find((node) => node.metadata?.workflowKind === "styleboard")?.metadata?.content).toBe("");
const script = result.nodes.find((node) => node.type === "script")!;
expect(script.metadata?.storyboard?.rows[0]?.dialogue).toBe("好久不见");
expect(result.connections.every((edge) => edge.toHandleId === "storyboard:context")).toBe(true);
expect(proposal.generationItems).toHaveLength(0);
});
test("插件注册后可发现和创建真实类型,停用后立即拒绝新调用", () => {
registerPlugin({ manifest: { id: pluginId, name: "测试扩展", version: "1", apiVersion: "yingce.plugin/v1", description: "测试", surfaces: ["node"], permissions: ["canvas.write"], runtime: { web: "declarative" }, contributes: { canvasNodes: [{ id: "test-special-node", label: "专用节点", defaultTitle: "测试", defaultSize: { width: 400, height: 300 }, schema: {}, renderer: "declarative" }] } }, agentActions: [{ id: "create", description: "创建", inputSchema: {}, buildOperations: () => [{ type: "add_node", nodeType: "test-special-node" }] }] });
usePluginStore.setState({ runtimeStatuses: { [pluginId]: "enabled" } });
expect(listAgentCapabilities("专用节点").length).toBeGreaterThan(0);
const ops = buildAgentPluginOperations(pluginId, "create", {}, snapshot);
expect(applyCanvasAgentOps(snapshot, ops).nodes[0]?.type).toBe("test-special-node");
usePluginStore.setState({ runtimeStatuses: { [pluginId]: "disabled" } });
expect(listAgentCapabilities("专用节点")).toHaveLength(0);
expect(() => buildAgentPluginOperations(pluginId, "create", {}, snapshot)).toThrow();
expect(() => applyCanvasAgentOps(snapshot, ops)).toThrow();
});
test("未知节点不能静默降级成文本", () => {
expect(() => applyCanvasAgentOps(snapshot, [{ type: "add_node", nodeType: "missing-plugin-node" }])).toThrow();
});

View File

@@ -0,0 +1,89 @@
import { expect, test } from "bun:test";
import { buildAgentStoryboardDraft, buildAgentStoryboardOperations, validateAgentStoryboardCreation } from "../src/lib/canvas/canvas-agent-storyboard";
import { inspectStoryboardReadiness } from "../src/lib/canvas/canvas-storyboard-context";
import { buildCanvasAgentContext } from "../src/lib/canvas/canvas-agent-context";
import { createShortDramaPipeline } from "../src/lib/canvas/canvas-short-drama";
import { createStoryboardRow } from "../src/lib/canvas/canvas-project-domain";
import { applyCanvasAgentOps, type CanvasAgentSnapshot } from "../src/lib/canvas/canvas-agent-ops";
import { prepareAnalysisNodeAction } from "../src/lib/plugins/analysis-node-action";
function fixture() {
const pipeline = createShortDramaPipeline({ x: 0, y: 0 });
const script = pipeline.nodes.find((node) => node.type === "script")!;
script.metadata!.storyboard!.rows = [createStoryboardRow(1, { id: "row-1", videoNodeId: "video-1", imageNodeId: "image-1", dialogue: "旧对白" }), createStoryboardRow(2, { id: "row-2" })];
const snapshot: CanvasAgentSnapshot = { projectId: "test", title: "test", nodes: pipeline.nodes, connections: pipeline.connections, selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
return { snapshot, script };
}
test("待拆镜入口保存原始剧本但不伪造已完成的镜头", () => {
const { snapshot } = fixture();
const result = applyCanvasAgentOps(snapshot, buildAgentStoryboardDraft({ title: "旧机来电", prompt: "手机突然响起", x: 500, y: 100 }));
const node = result.nodes.find((item) => item.title === "旧机来电")!;
expect(node.type).toBe("script");
expect(node.metadata?.composerContent).toBe("手机突然响起");
expect(node.metadata?.storyboard?.rows).toEqual([]);
expect(node.metadata?.workflowDescription).toContain("尚未生成");
});
test("Agent 上下文提前指出未选择画风及分镜空行", () => {
const { snapshot, script } = fixture();
const readiness = buildCanvasAgentContext(snapshot).storyboardReadiness;
expect(readiness.canGenerateStoryboard).toBe(false);
expect(readiness.style.ready).toBe(false);
expect(readiness.blockingReason).toContain("画风");
expect(readiness.storyboards.find((item) => item.nodeId === script.id)?.incompleteRowIds).toEqual(["row-1", "row-2"]);
expect(readiness.nextActions.join(" ")).toContain("canvas_apply_style");
});
test("已应用画风与真实分镜写入后,返回实际总时长", () => {
const { snapshot, script } = fixture();
const style = snapshot.nodes.find((node) => node.metadata?.workflowKind === "styleboard")!;
style.metadata = { ...style.metadata, stylePresetId: "preset", content: "电影写实" };
script.metadata!.storyboard!.rows = [2, 2, 3, 2, 1].map((durationSeconds, i) => createStoryboardRow(i + 1, { durationSeconds, videoMotionPrompt: `镜头${i + 1}` }));
const readiness = inspectStoryboardReadiness(snapshot.nodes);
expect(readiness.canGenerateStoryboard).toBe(true);
expect(readiness.storyboards[0]).toMatchObject({ rowCount: 5, populated: true, totalDurationSeconds: 10 });
expect(readiness.nextActions).toEqual([]);
});
test("通用节点创建不能用正文代替真实分镜行", () => {
expect(() => validateAgentStoryboardCreation([{ type: "add_node", nodeType: "script", metadata: { content: "镜头1:0–2秒近景;镜头2:2–4秒特写" } }])).toThrow("真实镜头行");
const { script } = fixture();
expect(() => validateAgentStoryboardCreation([{ type: "add_node", nodeType: "script", metadata: script.metadata }])).toThrow();
});
test("按行修改保留其他镜头及已有生成素材关联", () => {
const { snapshot, script } = fixture();
const next = applyCanvasAgentOps(snapshot, buildAgentStoryboardOperations(snapshot, { nodeId: script.id, action: "update", rowId: "row-1", patch: { dialogue: "新对白", durationSeconds: 8 } }));
const rows = next.nodes.find((node) => node.id === script.id)!.metadata!.storyboard!.rows;
expect(rows[0]).toMatchObject({ id: "row-1", dialogue: "新对白", durationSeconds: 8, imageNodeId: "image-1", videoNodeId: "video-1" });
expect(rows[1]).toEqual(script.metadata!.storyboard!.rows[1]);
});
test("移除分镜只更新表格,保留行 ID 并重排序号", () => {
const { snapshot, script } = fixture();
const ops = buildAgentStoryboardOperations(snapshot, { nodeId: script.id, action: "remove", rowId: "row-1" });
expect(ops).toHaveLength(1);
expect(ops[0].type).toBe("update_node");
if (ops[0].type === "update_node") expect(ops[0].metadata?.storyboard?.rows[0]).toMatchObject({ id: "row-2", shotNumber: 1 });
});
test("分镜编辑拒绝伪造结果、未知行和非法时长", () => {
const { snapshot, script } = fixture();
for (const patch of [{ status: "success" }, { videoNodeId: "fake" }, { durationSeconds: -1 }]) {
expect(() => buildAgentStoryboardOperations(snapshot, { nodeId: script.id, action: "update", rowId: "row-1", patch })).toThrow();
}
expect(() => buildAgentStoryboardOperations(snapshot, { nodeId: script.id, action: "remove", rowId: "missing" })).toThrow();
});
test("分析节点准备复用相同输入且不启动模型", () => {
const { snapshot, script } = fixture();
snapshot.nodes.push({ ...script, id: "source", type: "image", metadata: { storageKey: "resource:image" } });
const action = prepareAnalysisNodeAction("test-analysis", "分析", () => ({}));
const ops = action.buildOperations({ sourceNodeId: "source" }, snapshot);
expect(ops.some((op) => op.type === "run_generation")).toBe(false);
expect(ops.some((op) => op.type === "connect_nodes")).toBe(true);
snapshot.nodes.push({ ...script, id: "analysis", type: "test-analysis" });
snapshot.connections.push({ id: "edge", fromNodeId: "source", toNodeId: "analysis" });
expect(action.buildOperations({ sourceNodeId: "source" }, snapshot)).toEqual([{ type: "select_nodes", ids: ["analysis"] }]);
});

View File

@@ -0,0 +1,145 @@
import { expect, test } from "bun:test";
import { ApprovedToolExecution, type ApprovedToolExecutionState } from "../src/services/approved-tool-execution";
import { creationRuns, type CreationRun, type CreationSubmission } from "../src/services/api/creation-runs";
import { createModelChannel, defaultConfig } from "../src/stores/use-config-store";
import type { GenerationTask } from "../src/services/api/task-center";
const identity = { kind: "art-critique", nodeId: "node", sourceFingerprint: "image", model: "model" };
const config = { ...defaultConfig, model: "system::model", channels: [createModelChannel({ id: "system", scope: "system", models: ["model"], interfaceType: "chat-completion" })] };
const request = (stage: string) => ({ config, prompt: stage, messages: [{ role: "user" as const, content: "review" }], tools: [], toolChoice: "auto" as const, metadata: { source: "art-critique", stage } });
const quote = (id: string, stage: string, taskId?: string): CreationSubmission => ({ id, runId: "run", itemKey: `stage:${stage}`, requestHash: stage, taskId, quote: { model: "model", billingMode: "fixed_request", quantity: 1, amountMicrocredits: 1_000_000, estimated: false, expiresAt: "2099-01-01", quoteHash: id } });
function harness(submissions: CreationSubmission[] = [], state = identity) {
let run: CreationRun = { id: "run", userId: "user", revision: 1, executionEpoch: 0, executionOwner: "", status: "idle", state, createdAt: "", updatedAt: "" };
let view: ApprovedToolExecutionState = { busy: false };
const listeners: Array<{ matches: (view: ApprovedToolExecutionState) => boolean; resolve: (view: ApprovedToolExecutionState) => void }> = [];
const approvals: string[][] = [], executions: string[] = [], waits: string[] = [];
let creates = 0, refreshes = 0;
const api = { ...creationRuns,
create: async () => { creates++; return { run, submissions }; },
get: async () => ({ run, submissions }),
claim: async (_id: string, input: { owner: string }) => (run = { ...run, executionEpoch: run.executionEpoch + 1, executionOwner: input.owner }),
release: async () => ({ released: true }),
prepare: async (_id: string, input: { itemKey: string }) => {
const existing = submissions.find((item) => item.itemKey === input.itemKey);
if (existing) return existing;
const created = quote(`quote-${submissions.length}`, input.itemKey.replace("stage:", "")); submissions.push(created); return created;
},
approve: async (_id: string, input: { submissionIds: string[] }) => {
approvals.push(input.submissionIds);
submissions = submissions.map((item) => input.submissionIds.includes(item.id) ? { ...item, approvedAt: "2026-01-01" } : item);
return { submissions: submissions.filter((item) => input.submissionIds.includes(item.id)) };
},
refreshQuote: async (_id: string, input: { submissionId: string }) => {
refreshes++;
const item = submissions.find((entry) => entry.id === input.submissionId)!;
item.revokedAt = "2026-01-01";
const fresh = { ...item, id: `fresh-${refreshes}`, itemKey: `requote:${item.id}`, revokedAt: undefined, approvedAt: undefined, quote: { ...item.quote, expiresAt: "2099-01-01" } };
submissions.push(fresh); return fresh;
},
execute: async (_id: string, input: { submissionId: string }) => {
const item = submissions.find((entry) => entry.id === input.submissionId)!;
expect(Boolean(item.taskId || item.approvedAt)).toBe(true);
executions.push(input.submissionId);
return { id: item.taskId || `task-${item.id}` } as GenerationTask;
},
} as typeof creationRuns;
const controller = new AbortController();
const execution = new ApprovedToolExecution({ clientKey: "test", ...(submissions.length ? { runId: "run" } : {}), identity, signal: controller.signal, onRun: () => {}, onState: (next) => {
view = next;
for (const listener of [...listeners]) if (listener.matches(view)) { listeners.splice(listeners.indexOf(listener), 1); listener.resolve(view); }
} }, api, async (id) => { waits.push(id); return { id, status: "succeeded", resultJson: JSON.stringify({ text: "report" }) } as GenerationTask; });
return { execution, controller, api, approvals, executions, waits, counters: () => ({ creates, refreshes }), view: () => view,
until: (matches: (view: ApprovedToolExecutionState) => boolean) => matches(view) ? Promise.resolve(view) : new Promise<ApprovedToolExecutionState>((resolve) => listeners.push({ matches, resolve })),
};
}
test("用户只批准费用卡已展示的项目,后来加入的阶段继续等待", async () => {
const h = harness();
try {
const first = h.execution.execute(request("first"));
const shown = await h.until((view) => view.quote?.items.length === 1);
const second = h.execution.execute(request("second"));
await h.until((view) => view.quote?.items.length === 2);
expect(h.executions).toEqual([]);
await h.execution.approve(shown.quote!.items.map((item) => item.id));
expect((await first).content).toBe("report");
expect(h.executions).toHaveLength(1);
expect(h.view().quote?.items).toHaveLength(1);
await h.execution.approve(h.view().quote!.items.map((item) => item.id)); await second;
expect(h.approvals).toEqual([["quote-0"], ["quote-1"]]);
} finally { h.execution.dispose(); }
});
test("恢复已提交阶段沿用原任务,即使原报价过期也不新增批准", async () => {
const item = quote("existing", "stage", "original-task"); item.quote.expiresAt = "2000-01-01";
const h = harness([item]);
try {
await h.execution.execute(request("stage"));
expect(h.waits).toEqual(["original-task"]); expect(h.approvals).toEqual([]);
expect(h.counters()).toEqual({ creates: 0, refreshes: 0 });
} finally { h.execution.dispose(); }
});
test("等待费用确认时取消,不提交模型;迟到按钮调用不抛未处理异常", async () => {
const h = harness();
try {
const result = h.execution.execute(request("stage")).catch((error: Error) => error);
await h.until((view) => Boolean(view.quote)); h.controller.abort();
expect((await result as Error).name).toBe("AbortError");
await h.execution.approve(["quote-0"]); await h.execution.refresh();
expect(h.executions).toEqual([]); expect(h.approvals).toEqual([]);
} finally { h.execution.dispose(); }
});
test("已批准但未提交的过期报价必须刷新并重新确认", async () => {
const item = { ...quote("expired", "stage"), approvedAt: "2000-01-01" }; item.quote.expiresAt = "2000-01-01";
const h = harness([item]);
try {
const result = h.execution.execute(request("stage"));
await h.until((view) => Boolean(view.quote));
expect(h.executions).toEqual([]); expect(h.counters().refreshes).toBe(1);
await h.execution.approve(["expired"]); expect(h.executions).toEqual([]);
await h.execution.approve(h.view().quote!.items.map((entry) => entry.id)); await result;
expect(h.executions).toEqual(["fresh-1"]);
} finally { h.execution.dispose(); }
});
test("刷新后的报价可重新打开恢复,找回未撤销的替代条目", async () => {
const old = { ...quote("old", "stage"), revokedAt: "2026-01-01" };
const fresh = { ...quote("new", "stage", "original-task"), itemKey: "requote:old" };
const h = harness([old, fresh]);
try { await h.execution.execute(request("stage")); expect(h.executions).toEqual(["new"]); expect(h.waits).toEqual(["original-task"]); }
finally { h.execution.dispose(); }
});
test("图片或模型身份改变时拒绝恢复", async () => {
const h = harness([quote("old", "stage")], { ...identity, model: "different" });
try { await expect(h.execution.execute(request("stage"))).rejects.toThrow("已变化"); expect(h.executions).toEqual([]); }
finally { h.execution.dispose(); }
});
test("并行阶段报价失败会停止其他等待中的阶段,不永久挂起", async () => {
const h = harness();
try {
const first = h.execution.execute(request("first")).catch((error: Error) => error);
await h.until((view) => Boolean(view.quote));
h.api.prepare = async () => { throw new Error("报价服务不可用"); };
await expect(h.execution.execute(request("second"))).rejects.toThrow("报价服务不可用");
expect((await first as Error).name).toBe("AbortError"); expect(h.executions).toEqual([]);
} finally { h.execution.dispose(); }
});
test("刷新费用卡后旧按钮不能批准新报价", async () => {
const h = harness();
try {
const result = h.execution.execute(request("analyze_art_scene"));
const shown = await h.until((view) => Boolean(view.quote));
await h.execution.refresh();
expect(h.view().quote?.items[0]?.label).toBe("理解画面");
await h.execution.approve(shown.quote!.items.map((item) => item.id));
expect(h.executions).toEqual([]); expect(h.view().error).toContain("报价已变化");
await h.execution.approve(h.view().quote!.items.map((item) => item.id)); await result;
expect(h.approvals).toEqual([["fresh-1"]]);
} finally { h.execution.dispose(); }
});

View File

@@ -0,0 +1,55 @@
import { expect, test } from "bun:test";
import { executeArtCritique } from "../src/services/art-critique-execution";
import { defaultConfig } from "../src/stores/use-config-store";
import { createCanvasNode } from "../src/lib/canvas/canvas-project-domain";
import { CanvasNodeType } from "../src/types/canvas";
import { runArtCritiquePipeline, type ArtCritiquePipelineOptions } from "../src/lib/art-critique/pipeline";
const source = createCanvasNode(CanvasNodeType.Image, { x: 0, y: 0 }, { storageKey: "resource:original" });
const options = () => ({ nodeId: "analysis", runId: "run", source, config: defaultConfig, signal: new AbortController().signal, onTaskCreated: (_stage: string, _id: string) => {} });
test("分析阶段使用资源引用、记录后台任务并拒绝重复阶段提交", async () => {
let submitted = 0;
const created: string[] = [];
const input = options();
input.onTaskCreated = (stage, id) => created.push(`${stage}:${id}`);
await executeArtCritique(input, {
inspectImage: async () => ({ storageKey: "resource:original", encodedBytes: 30 }),
runTask: async (request) => {
submitted++;
expect(JSON.stringify(request.messages)).toContain("resource:original");
expect(request.metadata).toMatchObject({ source: "art-critique", runId: "run", nodeId: "analysis" });
request.onTaskCreated?.({ id: "task-1" } as never);
return { content: "{}", toolCalls: [] };
},
runPipeline: async (config, image, opts: ArtCritiquePipelineOptions = {}) => {
expect(image.dataUrl).toBe("resource:original");
const stage = { config, messages: [{ role: "user" as const, content: [{ type: "image_url" as const, image_url: { url: image.dataUrl } }] }], tool: { type: "function" as const, name: "review", description: "review", parameters: {} }, toolName: "review", signal: input.signal };
await opts.requestStage!(stage);
await expect(opts.requestStage!(stage)).rejects.toThrow("重复");
return {} as never;
},
});
expect(submitted).toBe(1);
expect(created).toEqual(["review:task-1"]);
});
test("准备素材期间取消不会提交后台模型任务", async () => {
const controller = new AbortController();
let submitted = false;
await expect(executeArtCritique({ ...options(), signal: controller.signal }, {
inspectImage: async () => { controller.abort(); return { storageKey: "resource:image", encodedBytes: 30 }; },
runTask: async () => { submitted = true; return { content: "", toolCalls: [] }; },
runPipeline: async () => { throw new Error("不应开始分析"); },
})).rejects.toThrow();
expect(submitted).toBe(false);
});
test("审批执行器中止不能被管线降级为已完成报告", async () => {
await expect(runArtCritiquePipeline({ ...defaultConfig, textModel: "model" }, { dataUrl: "resource:image", title: "image", sourceFingerprint: "image" }, {
requestStage: async ({ toolName }) => {
if (toolName === "analyze_art_scene") throw new DOMException("报价服务不可用", "AbortError");
return { content: "", toolCalls: [{ id: toolName, type: "function", function: { name: toolName, arguments: JSON.stringify({ candidates: [] }) } }] };
},
})).rejects.toThrow("报价服务不可用");
});

View File

@@ -357,14 +357,15 @@ test("creation result handoff falls back by stable result order only for a compl
test("Create forwards owned result assets through one new canvas and the project persists before clearing the handoff", () => {
const workspace = readFileSync(resolve(import.meta.dir, "../src/pages/create/creation-workspace.tsx"), "utf8");
const createPage = readFileSync(resolve(import.meta.dir, "../src/pages/create/index.tsx"), "utf8");
const canvasIndex = readFileSync(resolve(import.meta.dir, "../src/pages/canvas/index.tsx"), "utf8");
const canvasProject = readFileSync(resolve(import.meta.dir, "../src/pages/canvas/project.tsx"), "utf8");
expect(workspace).toContain('import { creationCanvasHandoffPath, creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff"');
expect(workspace).toContain("const resultAssetIds = resultUrls.length ? creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls }) : [];");
expect(workspace).toContain("const canvasHandoffPath = creationCanvasHandoffPath(resultAssetIds, resultUrls.length);");
expect(workspace).toContain('const canvasPath = canvasHandoffPath || "/canvas";');
expect(workspace).toContain('<Link to={canvasPath}>{canvasHandoffPath ? "添加到画布" : "打开画布"}</Link>');
expect(workspace).toContain("onContinueCanvas(resultAssetIds)");
expect(createPage).toContain("continueCreationConversationOnCanvas(source)");
expect(createPage).toContain("if (ids.length !== item.resultUrls.length) throw new Error");
expect(createPage).toContain("await saveCreationConversations(next)");
expect(createPage.indexOf("await saveCreationConversations(next)")).toBeLessThan(createPage.indexOf("navigate(`/canvas/${result.id}?${params.toString()}`)"));
expect(canvasIndex).toContain('const handoffMode = mode === "handoff"');
expect(canvasIndex).toContain('mode !== "new" && mode !== "recent" && mode !== "handoff"');
expect(canvasProject).toContain('import { canvasAssetHandoffAttempt, finalizeCanvasAssetHandoff, uninsertedCanvasAssetHandoffPayloads } from "@/lib/canvas/canvas-asset-handoff"');

View 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 });
});
});

View File

@@ -0,0 +1,141 @@
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, approvedProposalHash: state.operations ? "approved-proposal-hash" : undefined, 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("创作控制器恢复", () => {
for (const terminal of [true, false]) test(terminal ? "任务已失败时标记生成失败,不能引导反复读取" : "仅查询断线时保留结果恢复语义,不当作需要重做", async () => {
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")], async (_id, options) => {
if (terminal) options?.onTaskUpdate?.({ id: "a", status: "failed" } as GenerationTask);
throw new Error("网络异常。");
});
try { await h.controller.load("run"); await expect(h.controller.resume()).rejects.toThrow(); expect(h.view().state.media[0].failureKind).toBe(terminal ? "generation" : "observation"); expect(h.counters().executions).toBe(0); }
finally { h.controller.dispose(); }
});
test("刷新页面可接续仍有效的旧连接,不等待旧页面租约过期", async () => {
const h = harness(initialCreativeState());
const get = h.api.get, claim = h.api.claim;
let foreign = true, claims = 0;
h.api.get = async (...args) => { const detail = await get(...args); return foreign ? { ...detail, run: { ...detail.run, executionOwner: "previous-page", leaseExpiresAt: "2099-01-01" } } : detail; };
h.api.claim = async (...args) => { claims++; foreign = false; return claim(...args); };
try { await h.controller.load("run"); expect(h.view().hasControl).toBe(true); expect(claims).toBe(1); expect(h.counters().executions).toBe(0); }
finally { h.controller.dispose(); }
});
test("普通恢复不争抢其他连接,用户重试连接可以显式接续", async () => {
const h = harness(initialCreativeState());
const get = h.api.get, claim = h.api.claim;
let foreign = false, claims = 0;
h.api.get = async (...args) => { const detail = await get(...args); return foreign ? { ...detail, run: { ...detail.run, executionOwner: "other-page", leaseExpiresAt: "2099-01-01" } } : detail; };
h.api.claim = async (...args) => { claims++; foreign = false; return claim(...args); };
try {
await h.controller.load("run"); foreign = true;
await expect(h.controller.refresh()).rejects.toThrow("重试连接"); expect(claims).toBe(1);
await h.controller.takeControl(); expect(claims).toBe(2); expect(h.view().hasControl).toBe(true); expect(h.view().error).toBeUndefined();
} finally { h.controller.dispose(); }
});
test("已提交任务即使保留待处理批次也不能再次展示费用确认", async () => {
const h = harness({ ...initialCreativeState(), proposal, canvasApplied: true, pendingPayment: ["a"], media: [{ ref: "a", nodeId: "a", attempt: 1, submissionId: "a", taskId: "task-a", status: "queued" }] }, "waiting_task", [{ ...submission("a", "a", "task-a"), approvedAt: "2026-01-01" }]);
try { await h.controller.load("run"); expect(h.view().quote).toBeUndefined(); }
finally { h.controller.dispose(); }
});
test("部分提交后费用卡只保留未提交项", async () => {
const h = harness({ ...initialCreativeState(), pendingPayment: ["a", "b"] }, "paused", [submission("a", "a", "task-a"), submission("b", "b")]);
try { await h.controller.load("run"); expect(h.view().quote?.items.map((item) => item.id)).toEqual(["b"]); }
finally { h.controller.dispose(); }
});
test("迟到的确认点击复用已提交任务,不重新批准或刷新过期费用", async () => {
const old = { ...submission("a", "a", "task-a"), approvedAt: "2000-01-01" };
old.quote.expiresAt = "2000-01-01";
const completed = { id: "task-a", status: "succeeded", resultJson: JSON.stringify({ images: [{ storageKey: "resource:output" }] }) } as GenerationTask;
const h = harness({ ...initialCreativeState(), proposal, canvasApplied: true, pendingPayment: ["a"], media: [{ ref: "a", nodeId: "a", attempt: 1, submissionId: "a", taskId: "task-a", status: "queued" }] }, "waiting_task", [old], async () => completed);
h.api.approve = async () => { throw new Error("不应重新批准已提交任务"); };
h.api.refreshQuote = async () => { throw new Error("不应刷新已提交任务报价"); };
h.api.execute = async (_id, input) => { expect(input.submissionId).toBe("a"); return completed; };
try { await h.controller.load("run"); await h.controller.approvePayment(); expect(h.view().state.media[0].status).toBe("ready"); expect(h.view().quote).toBeUndefined(); expect(h.counters().prepares).toBe(0); }
finally { h.controller.dispose(); }
});
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 h = harness({ ...initialCreativeState(), proposal, canvasApplied: false });
try { await h.controller.load("run"); await h.controller.resume(); expect(h.view().run?.status).toBe("waiting_proposal"); expect(h.snapshot().nodes).toHaveLength(0); expect(h.counters()).toEqual({ commits: 0, prepares: 0, executions: 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(); }
});
});

View 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);
});
});

View 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);
});
});

View File

@@ -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":[]}' },
]);
});

View File

@@ -54,6 +54,15 @@ function file(path: string, content: string, kind: SkillPackageFile["kind"] = "m
}
describe("skill runtime", () => {
test("可检索首屏之外的技能并分页,不暴露未加入技能", async () => {
const runtime = createSkillRuntime();
const skills = Array.from({ length: 45 }, (_, index) => skill({ skillId: `skill-${index}`, skillName: `技能 ${index}`, description: index === 44 ? "商品构图" : "通用" }));
skills.push(skill({ skillId: "hidden", description: "商品构图", isAdded: false }));
const first = await runtime.executeAgentTool("onlineAgent", "canvas_list_skills", {}, skills);
expect(first?.ok && first.data).toMatchObject({ total: 45, nextOffset: 40 });
const found = await runtime.executeAgentTool("onlineAgent", "canvas_list_skills", { query: "商品" }, skills);
expect(found?.ok && found.data).toMatchObject({ total: 1, nextOffset: null, items: [{ skillId: "skill-44" }] });
});
test("技能引用解析由统一规则同时支持稳定 token 和自然提及", () => {
const director = skill();
const storyboard = skill({ skillId: "storyboard", skillName: "小说转分镜" });

View File

@@ -0,0 +1,37 @@
import { expect, test } from "bun:test";
import { submitStoryboardTask } from "../src/services/storyboard-submission";
import { creationRuns, type CreationSubmission } from "../src/services/api/creation-runs";
import type { CreateTaskInput } from "../src/services/api/task-center";
const request = { type: "agent_storyboard_rows", projectId: "canvas", prompt: "剧本" } as CreateTaskInput;
function harness(expiresAt = "2099-01-01") {
const calls: string[] = [];
const run = { id: "run", executionEpoch: 1, executionOwner: "owner" };
const submission = { id: "quote", quote: { expiresAt } } as CreationSubmission;
const api = {
...creationRuns,
create: async () => ({ run, submissions: [] }), claim: async () => run,
prepare: async () => { calls.push("prepare"); return submission; },
approve: async () => { calls.push("approve"); return { submissions: [{ ...submission, approvedAt: "now" }] }; },
execute: async () => { calls.push("execute"); return { id: "task" }; },
release: async () => { calls.push("release"); return { released: true }; },
} as typeof creationRuns;
return { api, calls };
}
test("取消专业拆镜费用确认不会批准或提交任务", async () => {
const h = harness();
expect(await submitStoryboardTask(request, { signal: new AbortController().signal, assertCurrent() {}, confirm: async () => false }, h.api)).toBeUndefined();
expect(h.calls).toEqual(["prepare", "release"]);
});
test("确认后提交原报价对应的专业任务", async () => {
const h = harness();
expect((await submitStoryboardTask(request, { signal: new AbortController().signal, assertCurrent() {}, confirm: async () => true }, h.api))?.id).toBe("task");
expect(h.calls).toEqual(["prepare", "approve", "execute", "release"]);
});
test("过期报价或确认期间编辑分镜均阻止提交", async () => {
for (const changed of [false, true]) {
const h = harness(changed ? "2099-01-01" : "2000-01-01");
await expect(submitStoryboardTask(request, { signal: new AbortController().signal, assertCurrent() { if (changed) throw new Error("分镜已编辑"); }, confirm: async () => true }, h.api)).rejects.toThrow();
expect(h.calls).toEqual(["prepare", "release"]);
}
});