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.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
// Opt-in native inbox smoke. A deterministic localhost SSE fixture is the only
|
||||
// model endpoint; no paid inference, product writes or running service restarts.
|
||||
import assert from "node:assert/strict";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import express from "express";
|
||||
import { parseAgentModelConfig } from "../../src/chat/modelConfig.js";
|
||||
import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js";
|
||||
import { startOpencode2 } from "../../src/runtime/opencode2-server.js";
|
||||
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
||||
import { bindScopedConversationWorkspace, createConversationWorkspace } from "../../src/runtime/conversationWorkspace.js";
|
||||
import { registerTaskAgentBridge } from "../../src/routes/task-agent-bridge.js";
|
||||
import { removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||
import { startQueryMcpServer } from "../../src/mcp/query-server.js";
|
||||
import { writeQueryMcpConnection } from "../../src/runtime/query-mcp-connection.js";
|
||||
import { registerMcpPolicyBridge } from "../../src/routes/mcpRoutes.js";
|
||||
import { QUERY_MCP_TOOL_IDS } from "../../src/runtime/product-mcp-tools.js";
|
||||
import { TASK_AGENT, TASK_READER_AGENT } from "../../src/runtime/task-agent-policy.js";
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "tjwater-agent-messages-smoke-"));
|
||||
const previousConnectionPath = process.env.TJWATER_AGENT_MCP_CONNECTION_PATH;
|
||||
const workspaceRoot = join(root, "workspaces");
|
||||
const token = randomBytes(32).toString("hex");
|
||||
let native: Awaited<ReturnType<typeof startOpencode2>> | undefined;
|
||||
const ids: string[] = [];
|
||||
const modelInputs: Array<{ messages?: Array<{ role: string; content: unknown }> }> = [];
|
||||
const fixture = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) {
|
||||
if (new URL(request.url).pathname !== "/v1/chat/completions") return new Response("local fixture route not found", { status: 404 });
|
||||
modelInputs.push(await request.json());
|
||||
const chunk = (delta: object, finishReason: string | null) => JSON.stringify({ id: "chatcmpl-local-fixture", object: "chat.completion.chunk",
|
||||
created: 1, model: "fake", choices: [{ index: 0, delta, finish_reason: finishReason }] });
|
||||
return new Response(`data: ${chunk({ role: "assistant", content: "Local inbox fixture completed." }, null)}\n\ndata: ${chunk({}, "stop")}\n\ndata: [DONE]\n\n`,
|
||||
{ headers: { "Content-Type": "text/event-stream" } });
|
||||
} });
|
||||
const app = express(); app.use(express.json());
|
||||
registerTaskAgentBridge(app, { internalToken: token, getSession: (sessionID) => native!.client.session.get({ sessionID }), hasActiveRun: () => false });
|
||||
registerMcpPolicyBridge(app, { internalToken: token, runtime: { mcpReadonlyTools: () => QUERY_MCP_TOOL_IDS } });
|
||||
const queryMcp = startQueryMcpServer({ internalToken: token, productClient: null, productTools: [], resolveReader: async () => null, executeLocal: async () => ({ items: [] }) });
|
||||
writeQueryMcpConnection(join(root, "sessions"), queryMcp.url);
|
||||
const server = app.listen(0, "127.0.0.1");
|
||||
await new Promise<void>((done) => server.once("listening", done));
|
||||
try {
|
||||
const models = parseAgentModelConfig({ defaults: { chat: "local-smoke/fake", task: "local-smoke/fake", learning_gate: "local-smoke/fake" }, providers: { "local-smoke": {
|
||||
name: "Deterministic local smoke fixture", api_url: `http://127.0.0.1:${fixture.port}/v1`, api_key: "SMOKE_ONLY",
|
||||
models: { fake: { capabilities: { tools: true }, limit: { context: 32768, output: 1024 } } },
|
||||
} } });
|
||||
const input = JSON.parse(await readFile("opencode.json", "utf8"));
|
||||
native = await startOpencode2({ command: process.env.OPENCODE_COMMAND ?? "opencode2", hostname: "127.0.0.1", port: 0, timeout: 30_000,
|
||||
config: serviceOpencodeConfig(input, models, workspaceRoot),
|
||||
env: { AGENT_INTERNAL_TOKEN: token, TJWATER_AGENT_WORKSPACE_ROOT: workspaceRoot,
|
||||
TJWATER_AGENT_MCP_CONNECTION_PATH: process.env.TJWATER_AGENT_MCP_CONNECTION_PATH!,
|
||||
TJWATER_AGENT_INTERNAL_BASE_URL: `http://127.0.0.1:${(server.address() as { port: number }).port}`,
|
||||
OPENCODE_DB: join(root, "native-data", "opencode.db"), XDG_DATA_HOME: join(root, "native-data"),
|
||||
XDG_CONFIG_HOME: join(root, "native-config"), XDG_CACHE_HOME: join(root, "native-cache"), XDG_STATE_HOME: join(root, "native-state") },
|
||||
});
|
||||
const initial = await createConversationWorkspace(workspaceRoot);
|
||||
const session = await native.client.session.create({ agent: "instruction", location: { directory: initial } }); ids.push(session.id);
|
||||
const directory = await bindScopedConversationWorkspace(initial, workspaceRoot, { actorKey: "actor-smoke", projectKey: "project-smoke", sessionId: session.id });
|
||||
await native.client.session.move({ sessionID: session.id, directory });
|
||||
setRuntimeSessionContext({ sessionId: session.id, clientSessionId: session.id, actorKey: "actor-smoke", projectKey: "project-smoke",
|
||||
projectId: "test-project", traceId: "test", purpose: "ordinary_chat" });
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => native!.client }) as OpencodeRuntimeAdapter;
|
||||
const required = ["mcp_tjwater_list_investigation_tasks", "mcp_tjwater_get_investigation_task", "send_task_message", "mcp_tjwater_read_agent_messages", "reply_agent_message"];
|
||||
let discovered: Array<{ id: string; direct: boolean }> = [];
|
||||
const catalogDeadline = Date.now() + 10_000;
|
||||
do {
|
||||
discovered = (await native.client.rpc.call({ rpcID: "tjwater.tools", method: "list", input: {}, location: { directory } })).output as typeof discovered;
|
||||
if (required.every((id) => discovered.some((tool) => tool.id === id && tool.direct))) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
} while (Date.now() < catalogDeadline);
|
||||
for (const id of required) {
|
||||
assert.ok(discovered.some((tool) => tool.id === id && tool.direct), `missing direct registered tool ${id}`);
|
||||
assert.equal((await native.client.permission.create({ sessionID: session.id, agent: "instruction", action: id, resources: ["*"], save: [] })).effect, "allow");
|
||||
assert.equal((await native.client.permission.create({ sessionID: session.id, agent: TASK_AGENT, action: id, resources: ["*"], save: [] })).effect,
|
||||
["mcp_tjwater_read_agent_messages", "reply_agent_message"].includes(id) ? "allow" : "deny");
|
||||
assert.equal((await native.client.permission.create({ sessionID: session.id, agent: TASK_READER_AGENT, action: id, resources: ["*"], save: [] })).effect, "deny");
|
||||
}
|
||||
assert.ok(!discovered.some((tool) => ["message", "send_message", "session_message"].includes(tool.id)), "arbitrary-session message tool exposed");
|
||||
const nativeId = `msg_${randomBytes(16).toString("hex")}`;
|
||||
assert.equal(await runtime.agentMessageState(session.id, nativeId), "missing");
|
||||
const originalMessage = { message_id: "62000000-0000-4000-8000-000000000001", source_session_id: "investigation-source", message: "Local cooperation provenance marker" };
|
||||
await runtime.enqueueAgentMessage(session.id, nativeId, JSON.stringify({ originalMessage }), { source: "agent_message", message_id: originalMessage.message_id });
|
||||
assert.equal(await runtime.agentMessageState(session.id, nativeId), "enqueued");
|
||||
assert.equal((await native.client.session.inbox.list({ sessionID: session.id })).filter((item) => item.id === nativeId).length, 1);
|
||||
assert.ok(!(session.id in await native.client.session.active()), "resume:false unexpectedly started the idle main session");
|
||||
assert.equal(modelInputs.length, 0, "idle synthetic enqueue invoked a model");
|
||||
// Admission recovery checks state before retrying. The inbox must retain one
|
||||
// stable entry, while native duplicate admission is rejected or idempotent.
|
||||
try { await runtime.enqueueAgentMessage(session.id, nativeId, JSON.stringify({ originalMessage }), { source: "agent_message" }); }
|
||||
catch (error) { assert.ok(error instanceof Error || typeof error === "object", "invalid duplicate rejection"); }
|
||||
assert.equal((await native.client.session.inbox.list({ sessionID: session.id })).filter((item) => item.id === nativeId).length, 1);
|
||||
assert.equal(modelInputs.length, 0);
|
||||
await native.client.session.prompt({ sessionID: session.id, text: "Consume the local inbox fixture and finish." });
|
||||
await native.client.session.wait({ sessionID: session.id }, { signal: AbortSignal.timeout(30_000) });
|
||||
assert.equal(await runtime.agentMessageState(session.id, nativeId), "delivered");
|
||||
const stored = await native.client.session.message.get({ sessionID: session.id, messageID: nativeId });
|
||||
assert.equal(stored.type, "synthetic");
|
||||
assert.ok(JSON.stringify(stored).includes("originalMessage"), "native synthetic lost originalMessage provenance");
|
||||
assert.equal((await native.client.session.inbox.list({ sessionID: session.id })).filter((item) => item.id === nativeId).length, 0);
|
||||
assert.ok(modelInputs.length > 0, "local fake provider was not invoked to consume inbox");
|
||||
const fixtureText = JSON.stringify(modelInputs);
|
||||
assert.ok(fixtureText.includes("Local cooperation provenance marker"), "recipient did not receive synthetic discussion");
|
||||
assert.ok(fixtureText.includes("originalMessage"), "model context lost sender provenance");
|
||||
const receivedDiscussion = modelInputs.flatMap((input) => input.messages ?? []).filter((message) =>
|
||||
message.role !== "system" && JSON.stringify(message.content).includes("Local cooperation provenance marker"));
|
||||
assert.ok(receivedDiscussion.length > 0);
|
||||
for (const message of receivedDiscussion) assert.ok(!JSON.stringify(message.content).includes("<userinstruction>"), "synthetic discussion upgraded to userinstruction");
|
||||
assert.ok(!(session.id in await native.client.session.active()), "local fixture did not return to idle");
|
||||
console.log("Native controlled tool discovery, primary permissions, missing/enqueued/delivered state, stable-ID dedup and resume:false idle behavior passed. Only a local fake SSE provider was used.");
|
||||
} finally {
|
||||
for (const id of ids.reverse()) { removeRuntimeSessionContext(id); if (native) await native.client.session.remove({ sessionID: id }).catch(() => {}); }
|
||||
native?.close(); if (native) await native.exited;
|
||||
await new Promise<void>((done) => server.close(() => done())); fixture.stop(true);
|
||||
await queryMcp.close();
|
||||
if (previousConnectionPath === undefined) delete process.env.TJWATER_AGENT_MCP_CONNECTION_PATH; else process.env.TJWATER_AGENT_MCP_CONNECTION_PATH = previousConnectionPath;
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
Reference in New Issue
Block a user