Files
next-tjwater-agent/tests/integration/query-mcp-smoke.ts
T
jiang 6a49d2209e feat(agent): unify MCP tools and adopt Responses delivery
Move project and host queries into native MCP with host-owned OAuth/context,
while retaining memory, skills, actions and publication plugins. Add nearby-model
queries and explicit chat/task/learning model defaults.

Adopt Responses HTTP/SSE and native steer/queue inbox delivery, preserving scoped
session ownership, pending input edits and investigation communication.

Disable local-provider discovery: Server /health allowed native vLLM to probe
/v1/models on port 8000. A native before/after regression prevents recurrence.

Validation: 170 focused Bun tests; service/plugin typechecks; native discovery,
provider-discovery and input-inbox smoke checks; no paid model inference.
2026-10-19 18:46:58 +08:00

130 lines
10 KiB
TypeScript

import assert from "node:assert/strict";
import express from "express";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { startOpencode2 } from "../../src/runtime/opencode2-server.js";
import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js";
import { assertWebSearchReady } from "../../src/runtime/web-search-check.js";
import { QUERY_MCP_TOOL_IDS, LOCAL_QUERY_TOOL_IDS } from "../../src/runtime/product-mcp-tools.js";
import { writeQueryMcpConnection } from "../../src/runtime/query-mcp-connection.js";
import { startQueryMcpServer } from "../../src/mcp/query-server.js";
import { registerMcpPolicyBridge } from "../../src/routes/mcpRoutes.js";
import { setRuntimeSessionContext, removeRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
// Native discovery/execution only. Its fixture context hook stops all inference.
const root = await mkdtemp(join(tmpdir(), "tjwater-native-query-mcp-"));
const previousPath = process.env.TJWATER_AGENT_MCP_CONNECTION_PATH;
const longCalculation = process.argv.includes("--long-calculation");
const pluginDirectory = join(root, "fixture-plugin");
await mkdir(pluginDirectory);
await writeFile(join(pluginDirectory, "package.json"), JSON.stringify({ name: "tjwater-query-fixture", type: "module", exports: "./index.ts" }));
await writeFile(join(pluginDirectory, "index.ts"), `export { default } from ${JSON.stringify(resolve(".opencode/test-fixtures/mcp-call-plugin.ts"))};\n`);
const app = express(); app.use(express.json());
registerMcpPolicyBridge(app, { internalToken: "MCP_TEST_ONLY", runtime: { mcpReadonlyTools: () => QUERY_MCP_TOOL_IDS } });
const bridge = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => bridge.once("listening", resolve));
let native: Awaited<ReturnType<typeof startOpencode2>> | undefined;
let sessionId: string | undefined;
const receivedSessions: string[] = [];
const query = startQueryMcpServer({ internalToken: "MCP_TEST_ONLY",
productClient: longCalculation ? { query: async (_id, _owner, name, args) => {
assert.equal(name, "run_simulation");
assert.equal(args.timeout_seconds, 90);
await Bun.sleep(65_000);
return { content: [{ type: "text", text: "isolated long calculation completed" }] };
} } : null,
productTools: longCalculation ? [{ name: "run_simulation", inputSchema: { type: "object", properties: {
timeout_seconds: { type: "integer", minimum: 1, maximum: 120, default: 120 },
}, additionalProperties: false } }] : [], resolveReader: async () => null,
executeLocal: async (name, args, context, signal) => {
receivedSessions.push(context.sessionId);
if (name === "todoread") {
return (await native!.client.rpc.call({ rpcID: "tjwater.tools", method: "read_todos", input: { session_id: context.sessionId }, location: { directory: context.workspaceDirectory! } }, { signal })).output;
}
return { query: args.query, actor: context.actorKey };
},
});
writeQueryMcpConnection(join(root, "sessions"), query.url, query.executionTimeoutMs);
try {
const source = JSON.parse(await readFile("opencode.json", "utf8"));
if (longCalculation) source.permissions = [...(source.permissions ?? []), {
action: "mcp_tjwater_run_simulation", resource: "*", effect: "allow",
}];
native = await startOpencode2({ command: process.env.OPENCODE_COMMAND ?? "opencode2", hostname: "127.0.0.1", port: 0, timeout: 30_000,
config: serviceOpencodeConfig({ ...source, plugins: [pluginDirectory], mcp: { servers: {} }, websearch: { provider: "fixture-search" } }),
env: { AGENT_INTERNAL_TOKEN: "MCP_TEST_ONLY", TJWATER_AGENT_INTERNAL_BASE_URL: `http://127.0.0.1:${(bridge.address() as { port: number }).port}`,
TJWATER_AGENT_MCP_CONNECTION_PATH: process.env.TJWATER_AGENT_MCP_CONNECTION_PATH!, TJWATER_AGENT_WORKSPACE_ROOT: join(root, "workspaces"),
OPENCODE_DB: join(root, "data", "opencode.db"), XDG_DATA_HOME: join(root, "data"), XDG_CONFIG_HOME: join(root, "config"), XDG_CACHE_HOME: join(root, "cache"), XDG_STATE_HOME: join(root, "state") },
});
const session = await native.client.session.create({ location: { directory: process.cwd() }, agent: "instruction" });
sessionId = session.id;
setRuntimeSessionContext({ sessionId, clientSessionId: sessionId, actorKey: "fixture-actor", projectKey: "personal-fixture", traceId: "test", purpose: "ordinary_chat", workspaceDirectory: session.location.directory });
await native.client.session.prompt({ sessionID: sessionId, text: "Materialize fixture tools; stop before inference." });
await native.client.session.wait({ sessionID: sessionId }, { signal: AbortSignal.timeout(20_000) });
const deadline = Date.now() + 10_000;
let tools: { id: string; direct: boolean }[] = [];
do {
tools = (await native.client.rpc.call({ rpcID: "tjwater.tools", method: "list", input: {}, location: session.location })).output as typeof tools;
if (LOCAL_QUERY_TOOL_IDS.every((id) => tools.some((tool) => tool.id === id && tool.direct))) break;
await new Promise((resolve) => setTimeout(resolve, 100));
} while (Date.now() < deadline);
assert(LOCAL_QUERY_TOOL_IDS.every((id) => tools.some((tool) => tool.id === id && tool.direct)), "native MCP catalog must expose every independent query");
assert(tools.some((tool) => tool.id === "websearch"), "native websearch must remain registered");
await assertWebSearchReady(native.client, session.location.directory);
assert(!tools.some((tool) => tool.id.startsWith("browser_")), "unused native browser automation tools must not be registered");
const plugins = await native.client.plugin.list({ location: session.location });
assert(!plugins.data.some((plugin) => plugin.id === "opencode.browser" && plugin.state.status === "active"), "native browser plugin must be disabled");
for (const action of ["browser", "browser_click"]) {
assert.equal((await native.client.permission.create({ sessionID: sessionId, action, resources: ["*"], save: [] })).effect, "deny");
}
for (const action of ["present_answer", "show_chart"]) {
assert.equal((await native.client.permission.create({ sessionID: sessionId, action, resources: ["*"], save: [] })).effect, "allow");
}
for (const id of ["opencode_models", "opencode_session_move", "opencode_session_rename",
"opencode_worktree_create", "opencode_worktree_list", "opencode_worktree_remove"]) {
assert(tools.some((tool) => tool.id === id), `${id} must remain registered`);
}
assert(!tools.some((tool) => tool.id === "mcp_tjwater_web_search"), "web search must not have a duplicate MCP tool");
for (const id of ["query_project_data", "query_operational_task_data", "get_operational_task_context", "session_search", "web_search", "geocode", "todoread", "list_investigation_tasks", "get_investigation_task", "read_agent_messages"]) assert(!tools.some((tool) => tool.id === id), `${id} must not remain a standalone plugin query`);
for (const id of ["memory_manager", "skill_manager", "send_task_message", "reply_agent_message", "todowrite",
"present_answer", "final_answer", "locate_features", "zoom_to_map", "show_chart", "view_history",
"render_scada_analysis", "clear_scada_analysis", "publish_execution_plan", "publish_task_report",
"publish_task_progress", "publish_task_presentation", "propose_task_action", "add_task_human_evidence",
"request_task_data_access"]) assert(tools.some((tool) => tool.id === id), `${id} must remain a plugin tool`);
for (const tool of ["mcp_tjwater_session_search", "mcp_tjwater_todoread"]) {
const result = await native.client.rpc.call({ rpcID: "tjwater.mcp-fixture", method: "call", input: { session_id: sessionId, tool, arguments: tool.endsWith("session_search") ? { query: "native-mcp" } : {} }, location: session.location }, { signal: AbortSignal.timeout(10_000) });
assert.equal((result.output as { error?: string }).error, undefined, JSON.stringify(result.output));
assert((result.output as { evaluated: string[] }).evaluated.includes(tool));
}
assert.deepEqual(receivedSessions, [sessionId, sessionId], "OpenCode must supply the invoking session in native MCP metadata");
const search = await native.client.rpc.call({ rpcID: "tjwater.mcp-fixture", method: "call", input: {
session_id: sessionId, tool: "websearch", arguments: { query: "native-search-fixture" },
}, location: session.location }, { signal: AbortSignal.timeout(10_000) });
assert.equal((search.output as { error?: string }).error, undefined, JSON.stringify(search.output));
assert(JSON.stringify(search.output).includes("https://example.test/search-result"), "native websearch must execute through its selected provider");
if (longCalculation) {
setRuntimeSessionContext({ sessionId, clientSessionId: sessionId, actorKey: "fixture-actor", projectKey: "fixture-project", projectId: "fixture-project", traceId: "test", purpose: "ordinary_chat", workspaceDirectory: session.location.directory });
const started = performance.now();
console.log("Starting isolated native MCP delay: requested budget 90 seconds, fixture delay 65 seconds.");
const result = await native.client.rpc.call({ rpcID: "tjwater.mcp-fixture", method: "call", input: {
session_id: sessionId, tool: "mcp_tjwater_run_simulation", arguments: { timeout_seconds: 90 },
}, location: session.location }, { signal: AbortSignal.timeout(110_000) });
const output = result.output as { error?: string; content: unknown };
assert(!output.error, output.error);
assert(JSON.stringify(output.content).includes("isolated long calculation completed"));
const elapsed = performance.now() - started;
assert(elapsed >= 60_000, `Long-call fixture returned too early (${Math.round(elapsed)}ms)`);
console.log(`Native MCP long calculation passed after ${Math.round(elapsed)}ms: a 90-second Agent budget crosses the old 30/35/60-second limits; no real simulation or model inference.`);
}
console.log("Native query MCP smoke passed: browser automation absent; business UI and managers retained; discovery, permissions, session metadata, checklist RPC and native websearch verified; no model inference or external search.");
} finally {
if (sessionId) { removeRuntimeSessionContext(sessionId); await native?.client.session.remove({ sessionID: sessionId }).catch(() => {}); }
native?.close();
if (native) await native.exited;
await query.close();
await new Promise<void>((resolve) => bridge.close(() => resolve()));
if (previousPath === undefined) delete process.env.TJWATER_AGENT_MCP_CONNECTION_PATH; else process.env.TJWATER_AGENT_MCP_CONNECTION_PATH = previousPath;
await rm(root, { recursive: true, force: true });
}