mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
test(acp): drive the agent with the official SDK and hold its output to the shipped schema
This commit is contained in:
1 parent
d8f84ed690
commit
d73352f4a6
7 files changed
+518
-1
No files matched your search
@@ -0,0 +1,37 @@
|
||||
name: acp-conformance
|
||||
|
||||
# The agent is driven over stdio by the official ACP TypeScript SDK, and every
|
||||
# message it writes is held to the schema that SDK ships. Not a required check:
|
||||
# it runs when the ACP frontend or the harness changes, and on request.
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main-v2, studio]
|
||||
paths:
|
||||
- "internal/frontend/acp/**"
|
||||
- "tools/acp-conformance/**"
|
||||
- ".github/workflows/acp-conformance.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: acp-conformance-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
conformance:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
- run: make acp-conformance
|
||||
@@ -9,7 +9,7 @@ GOEXE := $(shell go env GOEXE)
|
||||
# One pin for the Makefile and the CI lint job; see .github/workflows/ci.yml.
|
||||
GOLANGCI_VERSION := $(shell cat .golangci-version)
|
||||
|
||||
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update coverage-gate coverage-gate-update pricecheck check test studio-test sdk-test sdk-test-race hooks cross clean studio
|
||||
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update coverage-gate coverage-gate-update pricecheck acp-conformance check test studio-test sdk-test sdk-test-race hooks cross clean studio
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
|
||||
@@ -87,6 +87,13 @@ lint-cross:
|
||||
test: studio-test sdk-test
|
||||
go test ./...
|
||||
|
||||
# Drives `reasonix acp` with the official ACP TypeScript SDK and holds every
|
||||
# message it writes to the schema that SDK ships. Needs node and network for
|
||||
# `npm ci`, which runs without install scripts against a lockfile.
|
||||
acp-conformance: build
|
||||
cd tools/acp-conformance && npm ci --ignore-scripts --no-audit --no-fund
|
||||
cd tools/acp-conformance && REASONIX_BIN=$(CURDIR)/bin/reasonix$(GOEXE) npm test
|
||||
|
||||
# The shell is a nested module, so the root `test` target never reaches it.
|
||||
studio-test:
|
||||
cd desktop && go test ./...
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// Drives the built `reasonix acp` with the official ACP TypeScript SDK. The SDK
|
||||
// parses what the agent sends, and harness.mjs checks each raw message against
|
||||
// the schema the SDK ships. Run with `make acp-conformance` from the repo root.
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import * as acp from "@agentclientprotocol/sdk";
|
||||
import { startAgent, wireProblems } from "./harness.mjs";
|
||||
|
||||
const live = [];
|
||||
async function agent(options) {
|
||||
const a = await startAgent(options);
|
||||
live.push(a);
|
||||
await a.conn.initialize({ protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: {} });
|
||||
return a;
|
||||
}
|
||||
afterEach(async () => { while (live.length) await live.pop().stop(); });
|
||||
|
||||
const text = (value) => [{ type: "text", text: value }];
|
||||
const conforms = (a) => assert.deepEqual(wireProblems(a.wire), [], a.stderr());
|
||||
|
||||
describe("the agent speaks ACP as the official SDK and schema define it", () => {
|
||||
it("negotiates version 1 and declares what it implements", async () => {
|
||||
const a = await startAgent();
|
||||
live.push(a);
|
||||
const init = await a.conn.initialize({ protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: {} });
|
||||
assert.equal(init.protocolVersion, 1);
|
||||
const caps = init.agentCapabilities.sessionCapabilities;
|
||||
for (const method of ["list", "resume", "close", "delete", "additionalDirectories"]) assert.ok(caps[method], `${method} is declared`);
|
||||
conforms(a);
|
||||
});
|
||||
|
||||
it("runs a turn: a streamed answer with message ids, usage, and a stop reason", async () => {
|
||||
const a = await agent();
|
||||
const { sessionId } = await a.conn.newSession({ cwd: a.work, mcpServers: [] });
|
||||
const done = await a.conn.prompt({ sessionId, prompt: text("say hi") });
|
||||
assert.equal(done.stopReason, "end_turn");
|
||||
const kinds = a.updates.map((n) => n.update.sessionUpdate);
|
||||
assert.ok(kinds.includes("agent_message_chunk"));
|
||||
assert.ok(kinds.includes("usage_update"));
|
||||
const chunks = a.updates.map((n) => n.update).filter((u) => u.sessionUpdate === "agent_message_chunk");
|
||||
assert.ok(chunks.every((c) => typeof c.messageId === "string" && c.messageId.length > 0), "every chunk carries a messageId");
|
||||
assert.equal(new Set(chunks.map((c) => c.messageId)).size, 1, "one answer is one message");
|
||||
assert.ok(a.updates.every((n) => n.sessionId === sessionId));
|
||||
conforms(a);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the session lifecycle", () => {
|
||||
it("lists, resumes, loads, closes and deletes a session", async () => {
|
||||
const a = await agent();
|
||||
const { sessionId } = await a.conn.newSession({ cwd: a.work, mcpServers: [] });
|
||||
await a.conn.prompt({ sessionId, prompt: text("first") });
|
||||
|
||||
const listed = await a.conn.listSessions({ cwd: a.work });
|
||||
assert.ok(listed.sessions.some((s) => s.sessionId === sessionId && s.cwd === a.work), "the new session is listed under its folder");
|
||||
|
||||
await a.conn.resumeSession({ sessionId, cwd: a.work, mcpServers: [] });
|
||||
assert.equal((await a.conn.prompt({ sessionId, prompt: text("second") })).stopReason, "end_turn");
|
||||
|
||||
const before = a.updates.length;
|
||||
await a.conn.loadSession({ sessionId, cwd: a.work, mcpServers: [] });
|
||||
const replayed = a.updates.slice(before).map((n) => n.update.sessionUpdate);
|
||||
assert.ok(replayed.includes("user_message_chunk") && replayed.includes("agent_message_chunk"), `load replays the history, got ${replayed}`);
|
||||
|
||||
await a.conn.closeSession({ sessionId });
|
||||
await a.conn.deleteSession({ sessionId });
|
||||
const after = await a.conn.listSessions({ cwd: a.work });
|
||||
assert.ok(!after.sessions.some((s) => s.sessionId === sessionId), "a deleted session is gone");
|
||||
conforms(a);
|
||||
});
|
||||
|
||||
it("refuses an unknown session with a JSON-RPC error and keeps serving", async () => {
|
||||
const a = await agent();
|
||||
await assert.rejects(a.conn.prompt({ sessionId: "no-such-session", prompt: text("hi") }), (e) => typeof e.code === "number");
|
||||
const { sessionId } = await a.conn.newSession({ cwd: a.work, mcpServers: [] });
|
||||
assert.equal((await a.conn.prompt({ sessionId, prompt: text("still alive") })).stopReason, "end_turn");
|
||||
conforms(a);
|
||||
});
|
||||
|
||||
it("answers an unknown method with method-not-found", async () => {
|
||||
const a = await agent();
|
||||
const answer = await a.rpc("nonsense/method", {});
|
||||
assert.equal(answer.error?.code, -32601);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session options", () => {
|
||||
it("offers the model and mode options and accepts setting them", async () => {
|
||||
const a = await agent();
|
||||
const session = await a.conn.newSession({ cwd: a.work, mcpServers: [] });
|
||||
const model = session.configOptions.find((o) => o.id === "model");
|
||||
assert.ok(model && model.type === "select" && model.options.length > 0);
|
||||
const set = await a.conn.setSessionConfigOption({ sessionId: session.sessionId, configId: "model", value: model.currentValue });
|
||||
assert.ok(Array.isArray(set.configOptions));
|
||||
await a.conn.setSessionMode({ sessionId: session.sessionId, modeId: "plan" });
|
||||
conforms(a);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancelling", () => {
|
||||
it("ends a running turn with stopReason cancelled when the client cancels the session", async () => {
|
||||
const a = await agent({ script: () => ({ text: "slow", delayMs: 5000 }) });
|
||||
const { sessionId } = await a.conn.newSession({ cwd: a.work, mcpServers: [] });
|
||||
const turn = a.conn.prompt({ sessionId, prompt: text("take your time") });
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
await a.conn.cancel({ sessionId });
|
||||
assert.equal((await turn).stopReason, "cancelled");
|
||||
conforms(a);
|
||||
});
|
||||
|
||||
it("ends only the request named by $/cancel_request", async () => {
|
||||
const a = await agent({ script: () => ({ text: "slow", delayMs: 5000 }) });
|
||||
const { sessionId } = await a.conn.newSession({ cwd: a.work, mcpServers: [] });
|
||||
const answered = a.rpc("session/prompt", { sessionId, prompt: text("take your time") }, "to-cancel");
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
a.notify("$/cancel_request", { requestId: "to-cancel" });
|
||||
const answer = await answered;
|
||||
assert.ok(answer.result?.stopReason === "cancelled" || answer.error, `the named request ended: ${JSON.stringify(answer)}`);
|
||||
conforms(a);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool calls and permission", () => {
|
||||
const writes = (n, request) => (n === 0
|
||||
? { calls: [{ id: "call-1", name: "write_file", args: { path: "note.txt", content: "from the model" } }] }
|
||||
: { text: "done" });
|
||||
|
||||
it("asks before a write, runs it when the client allows it, and reports the call's life", async () => {
|
||||
const a = await agent({ script: writes });
|
||||
const { sessionId } = await a.conn.newSession({ cwd: a.work, mcpServers: [] });
|
||||
const done = await a.conn.prompt({ sessionId, prompt: text("write a note") });
|
||||
assert.equal(done.stopReason, "end_turn");
|
||||
assert.equal(a.permissions.length, 1, `one permission request, stderr: ${a.stderr()}`);
|
||||
const request = a.permissions[0];
|
||||
assert.equal(request.sessionId, sessionId);
|
||||
assert.ok(request.options.some((o) => o.kind === "allow_once") && request.options.some((o) => o.kind === "reject_once"));
|
||||
const statuses = a.updates.map((n) => n.update).filter((u) => u.sessionUpdate === "tool_call" || u.sessionUpdate === "tool_call_update").map((u) => u.status);
|
||||
assert.ok(statuses.at(-1) === "completed", `the call completes, got ${statuses}`);
|
||||
const call = a.updates.map((n) => n.update).find((u) => u.sessionUpdate === "tool_call");
|
||||
assert.equal(typeof call.toolCallId, "string");
|
||||
assert.ok(call.title && call.name, "a tool call carries its title and its invoked name");
|
||||
conforms(a);
|
||||
});
|
||||
|
||||
it("does not run the write when the client rejects it, and still ends the turn", async () => {
|
||||
const a = await agent({ script: writes });
|
||||
a.onPermission((request) => ({ outcome: { outcome: "selected", optionId: request.options.find((o) => o.kind === "reject_once").optionId } }));
|
||||
const { sessionId } = await a.conn.newSession({ cwd: a.work, mcpServers: [] });
|
||||
const done = await a.conn.prompt({ sessionId, prompt: text("write a note") });
|
||||
assert.equal(done.stopReason, "end_turn");
|
||||
const { existsSync } = await import("node:fs");
|
||||
const { join } = await import("node:path");
|
||||
assert.ok(!existsSync(join(a.work, "note.txt")), "a rejected write leaves no file");
|
||||
conforms(a);
|
||||
});
|
||||
|
||||
it("ends the turn as cancelled when the client cancels the permission prompt", async () => {
|
||||
const a = await agent({ script: writes });
|
||||
a.onPermission(() => ({ outcome: { outcome: "cancelled" } }));
|
||||
const { sessionId } = await a.conn.newSession({ cwd: a.work, mcpServers: [] });
|
||||
const done = await a.conn.prompt({ sessionId, prompt: text("write a note") });
|
||||
assert.ok(["cancelled", "end_turn"].includes(done.stopReason), `got ${done.stopReason}`);
|
||||
conforms(a);
|
||||
});
|
||||
});
|
||||
|
||||
describe("additional workspace roots", () => {
|
||||
it("accepts a folder outside the built-in write roots and lists it with the session", async () => {
|
||||
const a = await agent();
|
||||
const { mkdtempSync, realpathSync, rmSync } = await import("node:fs");
|
||||
const { homedir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const extra = realpathSync(mkdtempSync(join(homedir(), ".acp-conformance-")));
|
||||
try {
|
||||
const { sessionId } = await a.conn.newSession({ cwd: a.work, mcpServers: [], additionalDirectories: [extra] });
|
||||
const listed = await a.conn.listSessions({ cwd: a.work });
|
||||
const entry = listed.sessions.find((s) => s.sessionId === sessionId);
|
||||
assert.ok(entry, "the session is listed");
|
||||
if (entry.additionalDirectories) assert.deepEqual(entry.additionalDirectories, [extra]);
|
||||
conforms(a);
|
||||
} finally {
|
||||
rmSync(extra, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses the whole request with invalid-params and a stable code when a folder cannot be added", async () => {
|
||||
const a = await agent();
|
||||
await assert.rejects(
|
||||
a.conn.newSession({ cwd: a.work, mcpServers: [], additionalDirectories: ["/nonexistent/dir/for/acp"] }),
|
||||
(e) => e.code === -32602 && typeof e.data?.code === "string" && e.data.code.length > 0,
|
||||
);
|
||||
const { sessionId } = await a.conn.newSession({ cwd: a.work, mcpServers: [] });
|
||||
assert.ok(sessionId, "the agent keeps serving after a refusal");
|
||||
conforms(a);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import http from "node:http";
|
||||
|
||||
// An OpenAI-compatible chat endpoint that answers from a script, so the agent
|
||||
// under test runs a real turn without a network or a key.
|
||||
export function fakeModel(script) {
|
||||
const seen = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => (body += chunk));
|
||||
req.on("end", () => {
|
||||
const request = body ? JSON.parse(body) : {};
|
||||
seen.push(request);
|
||||
const step = script(seen.length - 1, request);
|
||||
setTimeout(() => answer(step, res), step.delayMs ?? 0);
|
||||
});
|
||||
});
|
||||
const answer = (step, res) => {
|
||||
{
|
||||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||
const send = (delta, finish = null, extra = {}) =>
|
||||
res.write(`data: ${JSON.stringify({ id: "chatcmpl-fake", object: "chat.completion.chunk", created: 0, model: "m", choices: [{ index: 0, delta, finish_reason: finish }], ...extra })}\n\n`);
|
||||
send({ role: "assistant", content: "" });
|
||||
if (step.text) send({ content: step.text });
|
||||
(step.calls ?? []).forEach((call, index) =>
|
||||
send({ tool_calls: [{ index, id: call.id, type: "function", function: { name: call.name, arguments: JSON.stringify(call.args) } }] }));
|
||||
send({}, step.calls?.length ? "tool_calls" : "stop");
|
||||
res.write(`data: ${JSON.stringify({ id: "chatcmpl-fake", object: "chat.completion.chunk", created: 0, model: "m", choices: [], usage: { prompt_tokens: 100, completion_tokens: 10, total_tokens: 110 } })}\n\n`);
|
||||
res.end("data: [DONE]\n\n");
|
||||
}
|
||||
};
|
||||
return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve({
|
||||
url: `http://127.0.0.1:${server.address().port}/v1`,
|
||||
seen,
|
||||
close: () => new Promise((done) => server.close(done)),
|
||||
})));
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import * as acp from "@agentclientprotocol/sdk";
|
||||
import Ajv2020 from "ajv/dist/2020.js";
|
||||
import { fakeModel } from "./fakemodel.mjs";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const schema = JSON.parse(readFileSync(require.resolve("@agentclientprotocol/sdk/schema/schema.json"), "utf8"));
|
||||
const ajv = new Ajv2020({ strict: false, allErrors: false });
|
||||
const range = (min, max) => ({ type: "number", validate: (n) => Number.isInteger(n) && n >= min && n <= max });
|
||||
for (const [name, bounds] of Object.entries({
|
||||
int32: [-(2 ** 31), 2 ** 31 - 1], uint16: [0, 65535], uint32: [0, 2 ** 32 - 1],
|
||||
int64: [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], uint64: [0, Number.MAX_SAFE_INTEGER],
|
||||
})) ajv.addFormat(name, range(...bounds));
|
||||
ajv.addFormat("double", { type: "number", validate: Number.isFinite });
|
||||
ajv.addFormat("uri", { type: "string", validate: (u) => URL.canParse(u) });
|
||||
const check = new Map();
|
||||
function validator(def) {
|
||||
if (!check.has(def)) check.set(def, ajv.compile({ $ref: `#/$defs/${def}`, $defs: schema.$defs }));
|
||||
return check.get(def);
|
||||
}
|
||||
|
||||
// Agent responses by the request that produced them: the shipped schema names
|
||||
// one definition per method, so each answer is held to exactly its own.
|
||||
const RESPONSES = {
|
||||
initialize: "InitializeResponse",
|
||||
"session/new": "NewSessionResponse",
|
||||
"session/load": "LoadSessionResponse",
|
||||
"session/list": "ListSessionsResponse",
|
||||
"session/resume": "ResumeSessionResponse",
|
||||
"session/close": "CloseSessionResponse",
|
||||
"session/delete": "DeleteSessionResponse",
|
||||
"session/set_mode": "SetSessionModeResponse",
|
||||
"session/set_config_option": "SetSessionConfigOptionResponse",
|
||||
"session/prompt": "PromptResponse",
|
||||
};
|
||||
|
||||
export const REASONIX_BIN = process.env.REASONIX_BIN;
|
||||
if (!REASONIX_BIN) throw new Error("set REASONIX_BIN to a built reasonix binary");
|
||||
|
||||
// Starts `reasonix acp` against a scripted model and records every line it
|
||||
// writes, so a test can hold the raw wire to the official schema as well as to
|
||||
// the SDK's own parsing.
|
||||
export async function startAgent({ script = (n) => ({ text: `answer ${n}` }), config = "" } = {}) {
|
||||
const home = mkdtempSync(join(tmpdir(), "acp-home-"));
|
||||
const work = realpathSync(mkdtempSync(join(tmpdir(), "acp-work-")));
|
||||
const model = await fakeModel(script);
|
||||
writeFileSync(join(home, "config.toml"), `default_model = "fake/m"\n${config}\n[[providers]]\nname = "fake"\nkind = "openai"\nbase_url = "${model.url}"\nmodels = ["m"]\ndefault = "m"\napi_key_env = "FAKE_KEY"\n`);
|
||||
const child = spawn(REASONIX_BIN, ["acp"], {
|
||||
cwd: work,
|
||||
env: { ...process.env, REASONIX_HOME: home, FAKE_KEY: "k", REASONIX_CREDENTIALS_STORE: "file" },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (d) => (stderr += d));
|
||||
const wire = { out: [], inn: [] };
|
||||
let pending = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
pending += chunk;
|
||||
let nl;
|
||||
while ((nl = pending.indexOf("\n")) >= 0) {
|
||||
const line = pending.slice(0, nl).trim();
|
||||
pending = pending.slice(nl + 1);
|
||||
if (line) wire.out.push(JSON.parse(line));
|
||||
}
|
||||
});
|
||||
const updates = [];
|
||||
const permissions = [];
|
||||
let answerPermission = (request) => ({ outcome: { outcome: "selected", optionId: request.options[0].optionId } });
|
||||
const client = {
|
||||
async requestPermission(request) {
|
||||
permissions.push(request);
|
||||
return answerPermission(request);
|
||||
},
|
||||
async sessionUpdate(notification) {
|
||||
updates.push(notification);
|
||||
},
|
||||
};
|
||||
const write = child.stdin.write.bind(child.stdin);
|
||||
child.stdin.write = (chunk, ...rest) => {
|
||||
for (const line of Buffer.from(chunk).toString("utf8").split("\n")) if (line.trim()) wire.inn.push(JSON.parse(line));
|
||||
return write(chunk, ...rest);
|
||||
};
|
||||
const stream = acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
|
||||
const conn = new acp.ClientSideConnection(() => client, stream);
|
||||
return {
|
||||
conn, wire, updates, permissions, model, work, home,
|
||||
// A request the SDK has no method for, answered by whatever the agent writes back.
|
||||
async rpc(method, params, id = `raw-${wire.inn.length}`) {
|
||||
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
|
||||
for (let waited = 0; waited < 5000; waited += 20) {
|
||||
const found = wire.out.find((m) => m.id === id && ("result" in m || "error" in m));
|
||||
if (found) return found;
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
throw new Error(`no answer to ${method}`);
|
||||
},
|
||||
notify(method, params) {
|
||||
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
|
||||
},
|
||||
onPermission(fn) { answerPermission = fn; },
|
||||
stderr: () => stderr,
|
||||
async stop() {
|
||||
child.kill();
|
||||
await model.close();
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Every message the agent wrote must satisfy the official schema. Returns the
|
||||
// problems, so a failing test names the message and the reason.
|
||||
export function wireProblems(wire) {
|
||||
const problems = [];
|
||||
const methodOf = new Map(wire.inn.filter((m) => m.method && m.id !== undefined).map((m) => [m.id, m.method]));
|
||||
for (const message of wire.out) {
|
||||
if (message.method === "session/update") {
|
||||
if (!validator("SessionNotification")(message.params)) problems.push(`session/update: ${ajv.errorsText(validator("SessionNotification").errors)} in ${JSON.stringify(message.params).slice(0, 200)}`);
|
||||
} else if (message.method === "session/request_permission") {
|
||||
if (!validator("RequestPermissionRequest")(message.params)) problems.push(`session/request_permission: ${ajv.errorsText(validator("RequestPermissionRequest").errors)}`);
|
||||
} else if ("result" in message && methodOf.has(message.id) && RESPONSES[methodOf.get(message.id)]) {
|
||||
const def = RESPONSES[methodOf.get(message.id)];
|
||||
if (!validator(def)(message.result)) problems.push(`${methodOf.get(message.id)}: ${ajv.errorsText(validator(def).errors)} in ${JSON.stringify(message.result).slice(0, 200)}`);
|
||||
} else if ("error" in message) {
|
||||
if (typeof message.error?.code !== "number" || typeof message.error?.message !== "string") problems.push(`malformed error: ${JSON.stringify(message.error)}`);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
Generated
+93
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"name": "reasonix-acp-conformance",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "reasonix-acp-conformance",
|
||||
"devDependencies": {
|
||||
"@agentclientprotocol/sdk": "1.8.0",
|
||||
"ajv": "8.17.1",
|
||||
"zod": "4.1.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.8.0.tgz",
|
||||
"integrity": "sha512-fbP7qHZsFyTJVT0/RPrcWJ8An26qLXHEuGCHHEKDyxD1xpXV+Ejyh2LAE6vBAFePkpVjZeeohnbgvuEhZ7wLMg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
|
||||
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "reasonix-acp-conformance",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Drives the reasonix acp subprocess with the official ACP TypeScript SDK.",
|
||||
"scripts": {
|
||||
"test": "node --test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@agentclientprotocol/sdk": "1.8.0",
|
||||
"ajv": "8.17.1",
|
||||
"zod": "4.1.12"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user