Files
Netcatty/electron/capabilities/codegen/toolSurfaces.test.cjs
T
陈大猫 9aed2c41d4 feat(ai): unify capability registry, harness tools, and agent runtime (#1685)
* feat(ai): unify capability registry, harness tools, and agent runtime

Centralize MCP/CLI/Catty tool surfaces in the capability catalog with codegen,
move Catty-only harness tools into catalog with local execution, converge
mcpServerBridge builtin dispatch via registry, and wire AgentRuntime with
vault bridge, permission grants, and Round 2 catalog parity.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(ai): vault host tools, artifact cards, and external agent vault guidance

Unify vault host create/import with capability RPC routing, add chat artifact cards with Vault navigation, and align external MCP agents with Catty rules so host creation never falls back to Vault Notes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ai): address Bugbot findings for streaming, vault bridge, and grants

Clear Catty streaming by chat sessionId, read latest vault state via getters on agent mutations, and stack grant persisters so unmounting one AI panel does not break others.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(vault): clear pending openNoteId after chat navigation focus

NotesManager notifies VaultView once the one-shot openNoteId request is applied so the id does not stick for the rest of the session.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(vault): serialize vault agent IPC mutations to prevent lost writes

Queue vault agent bridge handlers so concurrent hosts.create or note.create tool calls cannot overwrite each other via stale read-modify-write snapshots.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(vault): keep synchronous vault snapshot for agent IPC reads

Mirror hosts, notes, and groups in a ref updated immediately on agent writes so serialized mutations see the latest state before React re-renders.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ai): sync Catty MCP sessions and defer runtime cleanup on delete

Register terminal sessions with the main process before Catty tool calls, and clear ToolOutputStore only after stopping an active turn when deleting a chat session.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ai): avoid deleting a newer abort controller on late stop

Only remove the session abort-controller entry when stop finishes if it still refers to the controller that was stopped.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ai): sync vault snapshot on render and await active turn on stop

Refresh vault agent reads when React props change without clobbering in-flight agent writes, and wait for runTurn to finish before clearing ToolOutputStore on session delete.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ai): reload permission grants from storage before auto-approve

Re-read persisted grant rules on each approval check so revocations in Settings take effect without requiring a chat hook remount.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ai): sync permission grants to main process immediately on write

Push MCP permissionGrantsSnapshot as soon as Always allow persists a rule so the next tool call in the same agent turn sees the grant without waiting for useEffect.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 00:08:39 +08:00

163 lines
6.6 KiB
JavaScript

"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { CAPABILITY_STATUS } = require("../constants.cjs");
const { ALL_CAPABILITIES } = require("../catalog/index.cjs");
const { TOOL_INPUT_FIELDS } = require("../schemas/toolInputs.cjs");
const {
listMcpTools,
listCattyToolSpecs,
CATTY_CAPABILITY_DENYLIST,
isCattyEligible,
} = require("./toolSurfaces.cjs");
const { registerMcpTools, buildZodShapeObject } = require("./mcpToolRegistry.cjs");
test("listCattyToolSpecs includes terminal long-running tools", () => {
const specs = listCattyToolSpecs();
const names = specs.map((spec) => spec.toolName);
assert.ok(names.includes("terminal_execute"));
assert.ok(names.includes("terminal_start"));
assert.ok(names.includes("terminal_poll"));
assert.ok(names.includes("terminal_stop"));
});
test("listCattyToolSpecs includes SFTP write tools and attachments", () => {
const capabilityIds = listCattyToolSpecs().map((spec) => spec.capabilityId);
assert.ok(capabilityIds.includes("attachment.list"));
assert.ok(capabilityIds.includes("attachment.read"));
assert.ok(capabilityIds.includes("sftp.write"));
assert.ok(capabilityIds.includes("sftp.mkdir"));
assert.ok(capabilityIds.includes("sftp.delete"));
assert.ok(capabilityIds.includes("sftp.rename"));
assert.ok(capabilityIds.includes("sftp.chmod"));
assert.ok(!capabilityIds.includes("meta.status"));
assert.ok(!capabilityIds.includes("session.cancel"));
});
test("listCattyToolSpecs includes vault host tools and SFTP transfer", () => {
const capabilityIds = listCattyToolSpecs().map((spec) => spec.capabilityId);
assert.ok(capabilityIds.includes("vault.host.get"));
assert.ok(capabilityIds.includes("vault.host.list"));
assert.ok(capabilityIds.includes("vault.hosts.create"));
assert.ok(capabilityIds.includes("vault.host.import"));
assert.ok(capabilityIds.includes("vault.note.create"));
assert.ok(capabilityIds.includes("vault.note.list"));
assert.ok(capabilityIds.includes("sftp.download"));
assert.ok(capabilityIds.includes("sftp.upload"));
});
test("listCattyToolSpecs binds vault note tools to global RPC methods", () => {
const specs = listCattyToolSpecs();
const noteCreate = specs.find((spec) => spec.capabilityId === "vault.note.create");
assert.equal(noteCreate?.rpcMethod, "vault/notes/create");
const noteList = specs.find((spec) => spec.capabilityId === "vault.note.list");
assert.equal(noteList?.rpcMethod, "vault/notes/list");
});
test("listCattyToolSpecs binds vault and portforward tools to global RPC methods", () => {
const specs = listCattyToolSpecs();
const hostNotesSet = specs.find((spec) => spec.capabilityId === "vault.host.notes.set");
assert.equal(hostNotesSet?.rpcMethod, "vault/host/notes/set");
const portforwardStart = specs.find((spec) => spec.capabilityId === "portforward.start");
assert.equal(portforwardStart?.rpcMethod, "portforward/start");
});
test("listAgentToolSpecs splits sidebar harness tools from shared RPC tools", () => {
const { AGENT_KINDS, listAgentToolSpecs } = require("./toolSurfaces.cjs");
const sidebarIds = listAgentToolSpecs(AGENT_KINDS.SIDEBAR).map((spec) => spec.capabilityId);
const globalIds = listAgentToolSpecs(AGENT_KINDS.GLOBAL).map((spec) => spec.capabilityId);
assert.ok(sidebarIds.includes("harness.workspace.get_info"));
assert.ok(!globalIds.includes("harness.workspace.get_info"));
assert.ok(sidebarIds.includes("terminal.execute"));
assert.ok(globalIds.includes("terminal.execute"));
assert.ok(globalIds.includes("vault.note.create"));
assert.ok(globalIds.every((id) => sidebarIds.includes(id) || id.startsWith("harness.") === false));
});
test("listCattyToolSpecs includes harness catty-only tools with local execution", () => {
const specs = listCattyToolSpecs();
assert.ok(specs.length >= 40);
const harness = specs.filter((spec) => spec.capabilityId.startsWith("harness."));
assert.equal(harness.length, 5);
for (const spec of harness) {
assert.equal(spec.localExecution, true);
assert.equal(spec.rpcMethod, null);
}
const harnessIds = harness.map((spec) => spec.capabilityId);
assert.ok(harnessIds.includes("harness.tool_output.read"));
assert.ok(harnessIds.includes("harness.workspace.get_info"));
});
test("harness capabilities are not exposed on MCP", () => {
const mcpCapabilityIds = listMcpTools().map((tool) => tool.capabilityId);
for (const capabilityId of mcpCapabilityIds) {
assert.ok(!capabilityId.startsWith("harness."));
}
assert.equal(listMcpTools().length, 35);
});
test("listMcpTools descriptions stay aligned with catalog capability ids", () => {
const mcpTools = listMcpTools();
assert.ok(mcpTools.length >= 35);
for (const tool of mcpTools) {
assert.ok(tool.capabilityId);
assert.ok(tool.mcpTool);
assert.ok(tool.description.length > 0);
assert.ok(tool.rpcMethod);
}
});
test("catty and mcp terminal tools share capability ids", () => {
const catty = listCattyToolSpecs().find((spec) => spec.toolName === "terminal_execute");
const mcp = listMcpTools().find((tool) => tool.mcpTool === "terminal_execute");
assert.equal(catty?.capabilityId, "terminal.execute");
assert.equal(mcp?.capabilityId, "terminal.execute");
});
test("implemented catalog tools with inputs are catty-eligible unless denylisted", () => {
const implemented = ALL_CAPABILITIES.filter((cap) => cap.status === CAPABILITY_STATUS.IMPLEMENTED);
for (const capability of implemented) {
const hasInputs = Object.prototype.hasOwnProperty.call(TOOL_INPUT_FIELDS, capability.id);
if (!hasInputs) continue;
if (CATTY_CAPABILITY_DENYLIST.has(capability.id)) {
assert.equal(isCattyEligible(capability), false);
continue;
}
const hasRpc = Boolean(
capability.surfaces?.builtin?.rpcMethod
|| capability.surfaces?.public?.mcpTool,
);
if (hasRpc) {
assert.equal(isCattyEligible(capability), true);
}
}
});
test("mcp registry builds zod shapes for every MCP tool", () => {
for (const tool of listMcpTools()) {
const shape = buildZodShapeObject(tool.inputShape);
assert.equal(typeof shape, "object");
}
});
test("registerMcpTools registers one handler per catalog MCP tool", () => {
const registered = [];
const fakeServer = {
tool(name, _description, _shape, handler) {
registered.push({ name, handler: typeof handler });
},
};
const count = registerMcpTools(fakeServer, {
rpcCall: async () => ({ ok: true }),
scopeParams: {},
guardWriteOperation: () => null,
catalogDescription: (_name, fallback) => fallback,
});
assert.equal(count, listMcpTools().length);
assert.equal(registered.length, listMcpTools().length);
});