feat(agent): 画布 Agent - 重构工作流上下文与技能交互
This commit is contained in:
1 parent
919037e5d9
commit
326572ea03
33 files changed
+2230
-248
No files matched your search
@@ -3,10 +3,12 @@ import { EventEmitter } from "node:events";
|
||||
import http, { type Server } from "node:http";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { buildCanvasContext } from "../src/canvas-context.js";
|
||||
import { CanvasSession } from "../src/canvas-session.js";
|
||||
import { createLocalRuntimeApp } from "../src/local-runtime.js";
|
||||
import { LocalRuntimeSessionManager } from "../src/local-runtime-session.js";
|
||||
import { createCanvasAgentHttpModule } from "../src/modules/canvas-agent-http.js";
|
||||
import { toolDescriptions, toolInputSchemas, toolNames } from "../src/schemas.js";
|
||||
import type { LocalRuntimeConfig } from "../src/config.js";
|
||||
|
||||
const authority = "127.0.0.1:41743";
|
||||
@@ -14,6 +16,38 @@ const endpoint = `http://${authority}`;
|
||||
const origin = "http://127.0.0.1:3001";
|
||||
const token = "legacy-canvas-token-fixture";
|
||||
|
||||
test("MCP manifest exposes the semantic canvas read tools with schemas and descriptions", () => {
|
||||
const expected = [
|
||||
"canvas_get_context",
|
||||
"canvas_find_nodes",
|
||||
"canvas_get_node",
|
||||
"canvas_get_connection",
|
||||
"canvas_get_generation_tasks",
|
||||
"canvas_get_resources",
|
||||
"canvas_validate_ops",
|
||||
];
|
||||
for (const name of expected) {
|
||||
assert.ok(toolNames.includes(name as typeof toolNames[number]), `${name} is missing from toolNames`);
|
||||
assert.ok(toolDescriptions[name as keyof typeof toolDescriptions], `${name} is missing a description`);
|
||||
assert.ok(toolInputSchemas[name as keyof typeof toolInputSchemas], `${name} is missing an input schema`);
|
||||
}
|
||||
assert.deepEqual(
|
||||
toolNames.filter((name) => name.startsWith("canvas_")).slice(0, 10),
|
||||
[
|
||||
"canvas_get_state",
|
||||
"canvas_get_context",
|
||||
"canvas_find_nodes",
|
||||
"canvas_get_node",
|
||||
"canvas_get_connection",
|
||||
"canvas_get_generation_tasks",
|
||||
"canvas_get_resources",
|
||||
"canvas_validate_ops",
|
||||
"canvas_get_selection",
|
||||
"canvas_export_snapshot",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("Canvas module declares only Canvas scopes and constructs without CLI side effects", () => {
|
||||
const calls: string[] = [];
|
||||
const module = createCanvasAgentHttpModule(fixtureConfig(), sessionFixture(calls));
|
||||
@@ -101,6 +135,23 @@ test("CanvasSession dispose closes streams and a replaced stream cannot clear th
|
||||
second.response.emit("close");
|
||||
});
|
||||
|
||||
test("CanvasSession exposes precise node and connection reads", async () => {
|
||||
const session = new CanvasSession();
|
||||
const response = eventResponse();
|
||||
session.openEvents(new URL("http://127.0.0.1/events?clientId=precise-read"), response.response as never);
|
||||
session.updateState({
|
||||
nodes: [
|
||||
{ id: "node-a", type: "text", title: "A", position: { x: 0, y: 0 }, width: 320, height: 240 },
|
||||
{ id: "node-b", type: "image", title: "B", position: { x: 400, y: 0 }, width: 320, height: 320, metadata: { status: "success", storageKey: "resource:b" } },
|
||||
],
|
||||
connections: [{ id: "connection-1", fromNodeId: "node-a", toNodeId: "node-b" }],
|
||||
}, "precise-read");
|
||||
assert.equal((await session.callTool("canvas_get_node", { id: "node-b" }) as { found: boolean }).found, true);
|
||||
assert.equal((await session.callTool("canvas_get_connection", { id: "connection-1" }) as { found: boolean }).found, true);
|
||||
assert.equal((await session.callTool("canvas_get_node", { id: "missing" }) as { found: boolean }).found, false);
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
test("CanvasSession closes only streams owned by a revoked Runtime session", async () => {
|
||||
const session = new CanvasSession();
|
||||
const closeRuntimeSession = (session as CanvasSession & {
|
||||
@@ -159,6 +210,64 @@ test("Canvas generation tool continuation survives a browser stream reconnect un
|
||||
}
|
||||
});
|
||||
|
||||
test("CanvasSession expands a workflow into semantic nodes, non-overlapping layout, real edges, and selective generation", async () => {
|
||||
const session = new CanvasSession();
|
||||
const events = eventResponse();
|
||||
session.openEvents(new URL("http://127.0.0.1/events?clientId=agent-workflow"), events.response as never);
|
||||
session.updateState({
|
||||
nodes: [{ id: "existing-character", type: "image", title: "角色原画", position: { x: 0, y: 0 }, width: 560, height: 380, metadata: { status: "success", storageKey: "resource:character" } }],
|
||||
connections: [],
|
||||
}, "agent-workflow");
|
||||
|
||||
try {
|
||||
const pending = session.callTool("canvas_create_workflow", {
|
||||
title: "搞笑修仙小说流水线",
|
||||
nodes: [
|
||||
{ ref: "cards", kind: "character_cards", title: "角色拆分图片卡片", referenceNodeIds: ["existing-character"] },
|
||||
{ ref: "views", kind: "character_three_view", title: "角色三视图", prompt: "基于角色卡片生成正面、侧面、背面三视图", referenceRefs: ["cards"] },
|
||||
{ ref: "storyboard", kind: "storyboard_video", title: "分镜剧情视频", prompt: "基于三视图制作分镜剧情视频", referenceRefs: ["views"], runGeneration: true },
|
||||
],
|
||||
});
|
||||
const call = latestToolCall(events.writes());
|
||||
const ops = (call.input as { ops: Array<Record<string, unknown>> }).ops;
|
||||
const added = ops.filter((op) => op.type === "add_node");
|
||||
const edges = ops.filter((op) => op.type === "connect_nodes");
|
||||
const runs = ops.filter((op) => op.type === "run_generation");
|
||||
|
||||
assert.deepEqual(added.map((op) => op.nodeType), ["image", "image", "video"]);
|
||||
assert.match(String((added[0]?.metadata as Record<string, unknown>)?.prompt), /拆分主要角色/);
|
||||
assert.equal(edges.length, 3, "two workflow edges plus one existing reference edge");
|
||||
assert.equal(runs.length, 1, "runGeneration only affects the explicitly requested node");
|
||||
assert.equal(runs[0]?.nodeId, added[2]?.id);
|
||||
assert.ok(Number((added[1]?.position as { x: number }).x) > Number((added[0]?.position as { x: number }).x) + Number(added[0]?.width));
|
||||
assert.ok(Number((added[2]?.position as { x: number }).x) > Number((added[1]?.position as { x: number }).x) + Number(added[1]?.width));
|
||||
assert.ok(edges.some((op) => op.fromNodeId === "existing-character" && op.toNodeId === added[0]?.id));
|
||||
|
||||
session.resolveResult({ requestId: call.requestId, result: { accepted: true } });
|
||||
assert.deepEqual(await pending, { accepted: true });
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("CanvasSession rejects media workflow nodes without real creative content", async () => {
|
||||
const session = new CanvasSession();
|
||||
const events = eventResponse();
|
||||
session.openEvents(new URL("http://127.0.0.1/events?clientId=agent-workflow-invalid"), events.response as never);
|
||||
session.updateState({ nodes: [] }, "agent-workflow-invalid");
|
||||
try {
|
||||
await assert.rejects(
|
||||
session.callTool("canvas_create_workflow", {
|
||||
nodes: [{ ref: "empty-image", kind: "image", title: "空图片节点" }],
|
||||
}),
|
||||
/缺少 prompt\/content/,
|
||||
);
|
||||
assert.equal(events.writes().some((value) => value.includes("event: tool_call")), false);
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("Canvas Dreamina image generation preserves the shared product model and auto quality before run_generation", async () => {
|
||||
const session = new CanvasSession();
|
||||
const events = eventResponse();
|
||||
@@ -372,3 +481,56 @@ function listening(server: Server) {
|
||||
function close(server: Server) {
|
||||
return new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
|
||||
test("CanvasSession rejects stale state revisions and accepts idempotent retries", () => {
|
||||
const session = new CanvasSession();
|
||||
const first = session.updateState({ projectId: "canvas-1", nodes: [], connections: [], viewport: { x: 0, y: 0, k: 1 } }, "fixture");
|
||||
assert.equal(first.accepted, true);
|
||||
assert.equal(first.revision, 0);
|
||||
|
||||
const idempotent = session.updateState({ projectId: "canvas-1", nodes: [], connections: [], viewport: { x: 0, y: 0, k: 1 }, revision: 0 }, "fixture");
|
||||
assert.equal(idempotent.accepted, true);
|
||||
assert.equal(idempotent.idempotent, true);
|
||||
|
||||
const conflict = session.updateState({ projectId: "canvas-1", nodes: [{ id: "n-1", type: "text", position: { x: 0, y: 0 }, width: 100, height: 100 }], connections: [], viewport: { x: 0, y: 0, k: 1 }, revision: 0 }, "fixture");
|
||||
assert.equal(conflict.accepted, false);
|
||||
assert.equal(conflict.reason, "revision_conflict");
|
||||
|
||||
const next = session.updateState({ projectId: "canvas-1", nodes: [{ id: "n-1", type: "text", position: { x: 0, y: 0 }, width: 100, height: 100 }], connections: [], viewport: { x: 0, y: 0, k: 1 }, revision: 1 }, "fixture");
|
||||
assert.equal(next.accepted, true);
|
||||
assert.equal(next.revision, 1);
|
||||
|
||||
const stale = session.updateState({ projectId: "canvas-1", nodes: [], connections: [], viewport: { x: 0, y: 0, k: 1 }, revision: 0 }, "fixture");
|
||||
assert.equal(stale.accepted, false);
|
||||
assert.equal(stale.reason, "stale_revision");
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
|
||||
test("canvas_apply_ops enforces expected revision and state hash before dispatch", async () => {
|
||||
const session = new CanvasSession();
|
||||
const events = eventResponse();
|
||||
session.openEvents(new URL("http://127.0.0.1/events?clientId=guarded-write"), events.response as never);
|
||||
session.updateState({ nodes: [], connections: [], viewport: { x: 0, y: 0, k: 1 } }, "guarded-write");
|
||||
const context = buildCanvasContext({ nodes: [], connections: [], viewport: { x: 0, y: 0, k: 1 }, revision: 0 });
|
||||
try {
|
||||
const accepted = session.callTool("canvas_apply_ops", { ops: [], expectedRevision: 0, expectedStateHash: context.stateHash });
|
||||
const call = latestToolCall(events.writes());
|
||||
session.resolveResult({ requestId: call.requestId, result: { accepted: true } });
|
||||
assert.deepEqual(await accepted, { accepted: true });
|
||||
|
||||
const writeCount = events.writes().length;
|
||||
await assert.rejects(
|
||||
session.callTool("canvas_apply_ops", { ops: [], expectedRevision: 1 }),
|
||||
/revision.*重新读取 canvas_get_context/
|
||||
);
|
||||
assert.equal(events.writes().length, writeCount);
|
||||
await assert.rejects(
|
||||
session.callTool("canvas_apply_ops", { ops: [], expectedRevision: 0, expectedStateHash: "bad-hash" }),
|
||||
/画布状态已变化.*重新读取 canvas_get_context/
|
||||
);
|
||||
assert.equal(events.writes().length, writeCount);
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildCanvasContext, findCanvasNodes, getCanvasResources, validateCanvasOps } from "../src/canvas-context.js";
|
||||
import { buildCanvasContext, findCanvasNodes, getCanvasConnection, getCanvasGenerationTasks, getCanvasNode, getCanvasResources, validateCanvasOps } from "../src/canvas-context.js";
|
||||
import type { CanvasSnapshot } from "../src/types.js";
|
||||
|
||||
const state: CanvasSnapshot = {
|
||||
@@ -12,7 +12,7 @@ const state: CanvasSnapshot = {
|
||||
nodes: [
|
||||
{ id: "prompt-1", type: "text", title: "提示词", position: { x: 0, y: 0 }, width: 340, height: 240, metadata: { content: "夜雨中的城市", status: "success" } },
|
||||
{ id: "ref-1", type: "image", title: "角色参考", position: { x: 420, y: 0 }, width: 320, height: 320, metadata: { status: "success", storageKey: "resource:abc", mimeType: "image/png", assetId: "asset-1", assetCategory: "character", naturalWidth: 1024, naturalHeight: 1024 } },
|
||||
{ id: "loading-1", type: "video", title: "待生成视频", position: { x: 840, y: 0 }, width: 480, height: 270, metadata: { status: "loading" } },
|
||||
{ id: "loading-1", type: "video", title: "待生成视频", position: { x: 840, y: 0 }, width: 480, height: 270, metadata: { status: "loading", taskId: "task-1", taskStatus: "running", taskProgress: 42, taskStage: "rendering", taskProvider: "backend" } },
|
||||
],
|
||||
connections: [{ id: "c-1", fromNodeId: "prompt-1", toNodeId: "ref-1" }],
|
||||
};
|
||||
@@ -25,6 +25,9 @@ test("builds semantic canvas context without media URLs", () => {
|
||||
assert.equal(context.resources[0]?.isReady, true);
|
||||
assert.equal(context.resources[1]?.isReady, false);
|
||||
assert.equal((context.nodes[1] as { resource?: { ready?: boolean } }).resource?.ready, true);
|
||||
assert.equal((context.nodes[2] as { generation?: { taskId?: string; progress?: number } }).generation?.taskId, "task-1");
|
||||
assert.equal((context.nodes[2] as { generation?: { taskId?: string; progress?: number; stage?: string } }).generation?.progress, 42);
|
||||
assert.equal((context.nodes[2] as { generation?: { taskId?: string; progress?: number; stage?: string } }).generation?.stage, "rendering");
|
||||
assert.equal("url" in context.nodes[1], false);
|
||||
assert.match(context.stateHash, /^[a-f0-9]{16}$/);
|
||||
});
|
||||
@@ -34,6 +37,43 @@ test("finds real nodes and resources by semantic query", () => {
|
||||
assert.equal(getCanvasResources(state, { status: "loading" }).resources[0]?.nodeId, "loading-1");
|
||||
});
|
||||
|
||||
test("reads one node and its related connections without media URLs", () => {
|
||||
const result = getCanvasNode(state, { id: "ref-1" });
|
||||
assert.equal(result.found, true);
|
||||
assert.equal(result.node?.id, "ref-1");
|
||||
assert.equal(result.resource?.isReady, true);
|
||||
assert.equal(result.connections[0]?.id, "c-1");
|
||||
assert.equal("url" in (result.node || {}), false);
|
||||
assert.equal(getCanvasNode(state, { id: "missing" }).found, false);
|
||||
});
|
||||
|
||||
test("reads one connection with endpoint summaries", () => {
|
||||
const result = getCanvasConnection(state, { id: "c-1" });
|
||||
assert.equal(result.found, true);
|
||||
assert.equal(result.connection?.fromTitle, "提示词");
|
||||
assert.equal(result.toNode?.id, "ref-1");
|
||||
assert.equal(getCanvasConnection(state, { id: "missing" }).found, false);
|
||||
});
|
||||
|
||||
test("projects observable generation task state from canvas nodes", () => {
|
||||
const result = getCanvasGenerationTasks(state, { status: "running" });
|
||||
assert.equal(result.total, 1);
|
||||
assert.deepEqual(result.tasks[0], {
|
||||
taskId: "task-1",
|
||||
nodeId: "loading-1",
|
||||
nodeTitle: "待生成视频",
|
||||
mode: undefined,
|
||||
nodeStatus: "loading",
|
||||
status: "running",
|
||||
progress: 42,
|
||||
stage: "rendering",
|
||||
provider: "backend",
|
||||
errorCode: undefined,
|
||||
officialStatus: undefined,
|
||||
resourceReady: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects stale or unsafe operations before dispatch", () => {
|
||||
const result = validateCanvasOps(state, [
|
||||
{ type: "update_node", id: "missing" },
|
||||
@@ -42,3 +82,45 @@ test("rejects stale or unsafe operations before dispatch", () => {
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.issues.length, 2);
|
||||
});
|
||||
|
||||
test("validates a batch transaction against its evolving node and connection state", () => {
|
||||
const result = validateCanvasOps(state, [
|
||||
{ type: "add_node", id: "new-1", nodeType: "image", position: { x: 10, y: 20 }, width: 320, height: 240 },
|
||||
{ type: "connect_nodes", fromNodeId: "new-1", toNodeId: "ref-1", id: "new-connection" },
|
||||
{ type: "delete_node", id: "new-1" },
|
||||
{ type: "update_node", id: "new-1", patch: { title: "should fail" } },
|
||||
]);
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.issues.at(-1)?.message || "", /不在当前画布状态中/);
|
||||
});
|
||||
|
||||
test("rejects duplicate, missing, and unsafe connection or viewport operations", () => {
|
||||
const result = validateCanvasOps(state, [
|
||||
{ type: "connect_nodes", fromNodeId: "prompt-1", toNodeId: "ref-1" },
|
||||
{ type: "delete_connections", ids: ["missing-connection"] },
|
||||
{ type: "set_viewport", viewport: { x: 0, y: 0, k: 99 } },
|
||||
{ type: "add_node", nodeType: "image", width: -1, height: 0, position: { x: Number.NaN, y: 0 } },
|
||||
]);
|
||||
assert.equal(result.ok, false);
|
||||
assert.ok(result.issues.some((issue) => issue.message.includes("相同端点")));
|
||||
assert.ok(result.issues.some((issue) => issue.message.includes("不存在")));
|
||||
assert.ok(result.issues.some((issue) => issue.message.includes("0.05 到 8")));
|
||||
assert.ok(result.issues.some((issue) => issue.message.includes("正数")));
|
||||
});
|
||||
|
||||
|
||||
test("evolving connection state allows delete then recreate in one atomic batch", () => {
|
||||
const result = validateCanvasOps(state, [
|
||||
{ type: "delete_connections", id: "c-1" },
|
||||
{ type: "connect_nodes", id: "c-2", fromNodeId: "prompt-1", toNodeId: "ref-1" },
|
||||
]);
|
||||
assert.equal(result.ok, true);
|
||||
});
|
||||
|
||||
test("rejects direct media status forgery as a write error", () => {
|
||||
const result = validateCanvasOps(state, [
|
||||
{ type: "update_node", id: "ref-1", patch: { metadata: { status: "success" } } },
|
||||
]);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.issues[0]?.severity, "error");
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
import { codexInput, writeSkillFiles } from "../src/agents.js";
|
||||
import { parseAgentSkills } from "../src/modules/canvas-agent-http.js";
|
||||
|
||||
test("codexInput keeps skills as native UserInput items", () => {
|
||||
const input = codexInput("请按技能执行", ["/tmp/reference.png"], [{
|
||||
type: "skill",
|
||||
name: "canvas-shot-list",
|
||||
path: "/tmp/skill/SKILL.md",
|
||||
}]);
|
||||
|
||||
assert.deepEqual(input, [
|
||||
{ type: "text", text: "请按技能执行", text_elements: [] },
|
||||
{ type: "localImage", path: "/tmp/reference.png" },
|
||||
{ type: "skill", name: "canvas-shot-list", path: "/tmp/skill/SKILL.md" },
|
||||
]);
|
||||
assert.equal(JSON.stringify(input).includes("$canvas-shot-list"), false);
|
||||
});
|
||||
|
||||
test("writeSkillFiles creates Codex-compatible SKILL.md inputs and unique names", async () => {
|
||||
const prepared = await writeSkillFiles([
|
||||
{ skillId: "same", name: "第一个技能", description: "first", instruction: "do first" },
|
||||
{ skillId: "same", name: "第二个技能", description: "second", instruction: "do second" },
|
||||
{ skillId: "empty", name: "空技能", instruction: " " },
|
||||
]);
|
||||
|
||||
try {
|
||||
assert.equal(prepared.inputs.length, 2);
|
||||
assert.deepEqual(prepared.inputs.map((item) => item.type), ["skill", "skill"]);
|
||||
assert.deepEqual(prepared.inputs.map((item) => item.name), ["canvas-same", "canvas-same-2"]);
|
||||
for (const item of prepared.inputs) {
|
||||
assert.equal(item.path.endsWith("/SKILL.md"), true);
|
||||
const body = await fs.readFile(item.path, "utf8");
|
||||
assert.match(body, /^---\nname: canvas-same(?:-2)?\ndescription: /);
|
||||
assert.match(body, /\n---\n\n#/);
|
||||
}
|
||||
} finally {
|
||||
await Promise.all(prepared.directories.map((directory) => fs.rm(directory, { recursive: true, force: true })));
|
||||
}
|
||||
});
|
||||
|
||||
test("parseAgentSkills validates and bounds browser skill bundles", () => {
|
||||
const input = [
|
||||
{ skillId: "safe", name: " safe ", description: " desc ", instruction: " instruction " },
|
||||
{ name: "missing instruction" },
|
||||
null,
|
||||
{ name: "too long", instruction: "x".repeat(25_000) },
|
||||
];
|
||||
const parsed = parseAgentSkills(input);
|
||||
|
||||
assert.equal(parsed.length, 2);
|
||||
assert.deepEqual(parsed[0], { skillId: "safe", name: "safe", description: "desc", instruction: "instruction" });
|
||||
assert.equal(parsed[1].instruction.length, 24_000);
|
||||
});
|
||||
Reference in new issue
Block a user