feat(agent): 画布 Agent - 重构工作流上下文与技能交互

This commit is contained in:
ddCat committed 2026-08-24 12:09:15 +08:00
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();
}
});
+84 -2
View File
@@ -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");
});
+57
View File
@@ -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);
});