test(acp): drive the agent with the official SDK and hold its output to the shipped schema

This commit is contained in:
esengine committed 2026-10-18 06:44:42 -07:00
1 parent d8f84ed690
commit d73352f4a6
7 files changed
+518 -1

No files matched your search

+37
View File
@@ -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
+8 -1
View File
@@ -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 ./...
+196
View File
@@ -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);
});
});
+36
View File
@@ -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)),
})));
}
+134
View File
@@ -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;
}
+93
View File
@@ -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"
}
}
}
}
+14
View File
@@ -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"
}
}