feat(agent): add task status and client-scoped operations

This commit is contained in:
yu
2026-07-17 18:30:10 +08:00
parent bdca6b0a5c
commit 062e4569aa
13 changed files with 407 additions and 39 deletions
+170
View File
@@ -0,0 +1,170 @@
import { EventEmitter } from "node:events";
import type { ServerResponse } from "node:http";
import assert from "node:assert/strict";
import test from "node:test";
import { CanvasSession } from "./canvas-session.js";
test("MCP 读取当前激活网页的画布", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.updateState(snapshot("canvas-first"), "first");
session.updateState(snapshot("canvas-second"), "second");
session.activateClient("first");
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first");
session.activateClient("second");
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
});
test("画布写操作只发送给当前激活网页", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.updateState(snapshot("canvas-first"), "first");
session.updateState(snapshot("canvas-second"), "second");
session.activateClient("second");
const result = session.callTool("canvas_create_text_node", { text: "只写入第二个画布" });
const call = second.event("tool_call");
assert.equal(first.event("tool_call"), undefined);
assert.equal(field(call, "name"), "canvas_apply_ops");
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { ok: true } });
assert.deepEqual(await result, { ok: true });
});
test("tool result is accepted only from the request client", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.activateClient("first");
const result = session.callTool("canvas_create_text_node", { text: "first only" });
const call = first.event("tool_call");
const requestId = String(field(call, "requestId"));
assert.equal(session.resolveResult("second", { requestId, result: { client: "second" } }), false);
assert.equal(session.resolveResult("first", { requestId, result: { client: "first" } }), true);
assert.deepEqual(await result, { client: "first" });
});
test("生成状态查询由当前激活网页返回", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.activateClient("second");
const result = session.callTool("generation_get_status", { scope: "all" });
const call = second.event("tool_call");
assert.equal(first.event("tool_call"), undefined);
assert.equal(field(call, "name"), "generation_get_status");
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { total: 1, tasks: [{ id: "image-1", status: "running" }] } });
assert.deepEqual(await result, { total: 1, tasks: [{ id: "image-1", status: "running" }] });
});
test("活动网页关闭后回退到仍连接的画布", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.updateState(snapshot("canvas-first"), "first");
session.updateState(snapshot("canvas-second"), "second");
session.activateClient("second");
second.close();
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first");
});
test("closing the active client falls back to the most recently focused client", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
const third = connect(session, "third");
t.after(() => {
first.close();
second.close();
third.close();
});
session.updateState(snapshot("canvas-first"), "first");
session.updateState(snapshot("canvas-second"), "second");
session.updateState(snapshot("canvas-third"), "third");
session.activateClient("third");
session.activateClient("second");
second.close();
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-third");
});
test("closing a client rejects its pending tool requests", async () => {
const session = new CanvasSession();
const first = connect(session, "first");
const result = session.callTool("canvas_create_text_node", { text: "pending" });
const call = first.event("tool_call");
const requestId = String(field(call, "requestId"));
first.close();
const outcome = await Promise.race([
result.then(() => "resolved", (error) => error instanceof Error ? error.message : String(error)),
new Promise<string>((resolve) => setTimeout(() => resolve("pending"), 20)),
]);
if (outcome === "pending") session.resolveResult("first", { requestId, result: null });
assert.match(outcome, /断开/);
});
function connect(session: CanvasSession, clientId: string) {
const response = new FakeSseResponse();
session.openEvents(new URL(`http://127.0.0.1/events?clientId=${clientId}`), response as unknown as ServerResponse);
return response;
}
function snapshot(projectId: string) {
return { projectId, title: projectId, nodes: [], connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
}
function field(value: unknown, key: string) {
return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
}
class FakeSseResponse extends EventEmitter {
private chunks: string[] = [];
writeHead() {
return this;
}
write(chunk: string) {
this.chunks.push(chunk);
return true;
}
event(type: string) {
const chunk = this.chunks.find((item) => item.startsWith(`event: ${type}\n`));
const data = chunk?.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
return data ? (JSON.parse(data) as unknown) : undefined;
}
close() {
this.emit("close");
}
}