diff --git a/.github/workflows/acp-conformance.yml b/.github/workflows/acp-conformance.yml new file mode 100644 index 0000000000..cd924c3b7c --- /dev/null +++ b/.github/workflows/acp-conformance.yml @@ -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 diff --git a/Makefile b/Makefile index a0ab33e519..81b08736ef 100644 --- a/Makefile +++ b/Makefile @@ -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 ./... diff --git a/tools/acp-conformance/conformance.test.mjs b/tools/acp-conformance/conformance.test.mjs new file mode 100644 index 0000000000..e8494556fb --- /dev/null +++ b/tools/acp-conformance/conformance.test.mjs @@ -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); + }); +}); diff --git a/tools/acp-conformance/fakemodel.mjs b/tools/acp-conformance/fakemodel.mjs new file mode 100644 index 0000000000..9bf37eec65 --- /dev/null +++ b/tools/acp-conformance/fakemodel.mjs @@ -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)), + }))); +} diff --git a/tools/acp-conformance/harness.mjs b/tools/acp-conformance/harness.mjs new file mode 100644 index 0000000000..d25a166472 --- /dev/null +++ b/tools/acp-conformance/harness.mjs @@ -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; +} diff --git a/tools/acp-conformance/package-lock.json b/tools/acp-conformance/package-lock.json new file mode 100644 index 0000000000..d05e445f8c --- /dev/null +++ b/tools/acp-conformance/package-lock.json @@ -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" + } + } + } +} diff --git a/tools/acp-conformance/package.json b/tools/acp-conformance/package.json new file mode 100644 index 0000000000..f6495c0af4 --- /dev/null +++ b/tools/acp-conformance/package.json @@ -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" + } +}