feat(shared): ACP conformance suite — golden cases, mock agent, SDK versions

This commit is contained in:
fabiorafaelcoutada 2026-07-12 20:53:09 +01:00
parent 1867673ce7
commit 04daf36161
22 changed files with 466 additions and 0 deletions

1
shared/acp/ACP_VERSION Normal file
View file

@ -0,0 +1 @@
1

4
shared/acp/SDK_VERSIONS Normal file
View file

@ -0,0 +1,4 @@
@agentclientprotocol/sdk=1.2.1
agent-client-protocol(rust)=1.2.0
agent-client-protocol-schema(rust, transitive)=1.4.0
agent-client-protocol-derive(rust, transitive)=0.9.0

View file

@ -0,0 +1,48 @@
# ACP conformance
Language-agnostic contract tests for all four ACP implementations
(TS agent/client in aurelio-theia, Rust agent/client in tilth).
## Layout
```
shared/acp/
ACP_VERSION # pinned ACP protocol version (currently: 1)
SDK_VERSIONS # SDK versions the implementations build against
golden/
agent/ # methods an AGENT handles (client->agent requests,
# agent->client notifications/results)
client/ # methods a CLIENT handles (agent->client requests)
mock-agent/ # deterministic ACP agent for hermetic CI
conformance/
run.sh # NDJSON corpus driver (id-correlated diff)
```
## Why two corpus dirs
ACP is asymmetric: the agent never receives `fs/read_text_file` or
`session/request_permission` — those are client-side methods the agent *calls*.
Keeping them in `golden/client/` means an agent returning `-32601` for them is
correct, not a failure. The runner defaults to `golden/agent`.
## Run
```bash
# mock agent against the agent corpus (must be all green)
shared/acp/conformance/run.sh --agent node "$PWD/shared/acp/mock-agent/mock-agent.mjs"
# a candidate implementation
shared/acp/conformance/run.sh --agent node aurelio-backend/dist/acp/agent-entry.js
shared/acp/conformance/run.sh --agent cargo run -q -p acp-agent --
```
A case passes when the candidate's stdout, normalized to one JSON value per line
and sorted by `id`/`method`, equals the golden `.resp.ndjson`. Notification
interleaving and response reordering are tolerated.
## Adding a case
1. Capture a real NDJSON exchange (or hand-write it), scrub secrets.
2. Drop `<stem>.req.ndjson` + `<stem>.resp.ndjson` into the correct dir.
3. Re-run the mock agent; it must still be green (extend the mock if the case
is part of the supported subset).

View file

@ -0,0 +1,122 @@
#!/usr/bin/env node
// interop-driver.mjs — independent ACP client (Node stdlib only) that spawns an
// agent over stdio and runs initialize → session/new → session/prompt, collecting
// session/update notifications and the final stopReason.
//
// Purpose: prove cross-implementation wire compatibility. This client is NEITHER
// of our two stacks (not aurelio-backend's AcpClient.ts, not tilth-acp-client);
// it is a minimal third implementation. If the Rust agent (tilth-acp-agent) can
// complete a real prompt turn against it over live stdio, the protocol surface is
// genuinely compatible — not just corpus-compatible.
//
// Usage: node interop-driver.mjs -- <agent command...>
// e.g. node interop-driver.mjs -- /path/to/tilth-acp-agent
// Env: INTEROP_PROMPT (default "tilth_search"), INTEROP_CWD (default process.cwd())
// Exit: 0 on a completed turn with stopReason=end_turn and a non-empty streamed
// chunk; 1 otherwise (prints a JSON summary to stderr on failure).
import { spawn } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
const argv = process.argv.slice(2);
const sep = argv.indexOf('--');
const agentArgv = sep >= 0 ? argv.slice(sep + 1) : argv;
if (agentArgv.length === 0) {
console.error('usage: node interop-driver.mjs -- <agent command...>');
process.exit(2);
}
const [cmd, ...cmdArgs] = agentArgv;
const PROMPT = process.env.INTEROP_PROMPT || 'tilth_search';
const CWD = process.env.INTEROP_CWD || process.cwd();
const child = spawn(cmd, cmdArgs, { stdio: ['pipe', 'pipe', 'inherit'] });
let nextId = 1;
const pending = new Map(); // id -> {resolve, reject}
const updates = [];
let buf = '';
let stopReason = null;
let sawChunk = false;
child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
buf += chunk;
for (;;) {
const nl = buf.indexOf('\n');
if (nl < 0) break;
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
let msg;
try { msg = JSON.parse(line); } catch (e) { continue; }
handle(msg);
}
});
function handle(msg) {
if (msg.id != null && (msg.result !== undefined || msg.error !== undefined)) {
const p = pending.get(msg.id);
if (p) { pending.delete(msg.id); msg.error ? p.reject(new Error(JSON.stringify(msg.error))) : p.resolve(msg.result); }
return;
}
// notification: session/update
if (msg.method === 'session/update' && msg.params) {
updates.push(msg.params);
const u = msg.params.update;
if (u && (u.sessionUpdate === 'agent_message_chunk' || u.sessionUpdate === 'agentMessageChunk')) {
const text = u.content && u.content.text;
if (typeof text === 'string' && text.length > 0) sawChunk = true;
}
}
}
function request(method, params) {
const id = nextId++;
const line = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
child.stdin.write(line, (err) => { if (err) { pending.delete(id); reject(err); } });
});
}
function fail(reason, extra) {
console.error(JSON.stringify({ ok: false, reason, stopReason, sawChunk, updates: updates.length, ...extra }));
try { child.kill('SIGKILL'); } catch {}
process.exit(1);
}
// Hard wall-clock guard so a hung agent can't wedge CI.
const WALL = 25_000;
const killer = sleep(WALL).then(() => fail('wall-clock timeout', { timeoutMs: WALL }));
try {
const init = await request('initialize', { protocolVersion: 1, clientCapabilities: {} });
if (!init || init.protocolVersion !== 1) fail('bad initialize', { init });
const sess = await request('session/new', { cwd: CWD, mcpServers: [] });
const sessionId = sess && sess.sessionId;
if (!sessionId) fail('no sessionId', { sess });
const prompt = await request('session/prompt', {
sessionId,
prompt: [{ type: 'text', text: PROMPT }],
});
stopReason = prompt && (prompt.stopReason ?? prompt.stop_reason);
// Give any trailing notifications a moment to land before we read sawChunk.
await sleep(150);
if (stopReason !== 'end_turn' && stopReason !== 'endTurn') {
fail('unexpected stopReason', { prompt });
}
if (!sawChunk) fail('no non-empty agent_message_chunk streamed', { prompt, updates });
console.log(JSON.stringify({ ok: true, stopReason: 'end_turn', sawChunk, updates: updates.length, sessionId }));
try { child.stdin.end(); } catch {}
await sleep(150);
try { child.kill('SIGTERM'); } catch {}
process.exit(0);
} catch (e) {
fail('exception', { error: String(e && e.message || e) });
} finally {
// Ensure the wall-clock guard doesn't keep the loop alive after we exit.
await Promise.race([killer.catch(() => {})]);
}

View file

@ -0,0 +1,28 @@
#!/usr/bin/env bash
# interop-rust.sh — Rust↔Rust ACP interop gate.
# Builds the Rust agent + Rust client (active driver), then drives the agent
# from the client over live stdio for a full turn (initialize → session/new →
# session/prompt), asserting stopReason=end_turn AND at least one streamed
# session/update (sawChunk=true). Hermetic: the agent answers from the tilth
# index over INTEROP_CWD (defaults to the monorepo root); no model infra.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
REPO="$(cd "$HERE/../../.." && pwd)"
AGENT="$REPO/tilth/target/debug/tilth-acp-agent"
CLIENT="$REPO/tilth/target/debug/tilth-acp-client"
INTEROP_CWD="${INTEROP_CWD:-$REPO}"
INTEROP_PROMPT="${INTEROP_PROMPT:-tilth_search}"
cargo build --quiet --manifest-path "$REPO/tilth/crates/acp-agent/Cargo.toml"
cargo build --quiet --manifest-path "$REPO/tilth/crates/acp-client/Cargo.toml"
out="$(INTEROP_CWD="$INTEROP_CWD" INTEROP_PROMPT="$INTEROP_PROMPT" "$CLIENT" --drive "$AGENT")"
# Print streamed updates for the log, then validate the final summary line.
printf '%s\n' "$out"
final="$(printf '%s\n' "$out" | tail -n1)"
node -e '
const s = JSON.parse(process.argv[1]);
if (s.stopReason !== "end_turn") { console.error("bad stopReason", s); process.exit(1); }
if (s.sawChunk !== true) { console.error("no streamed session/update", s); process.exit(1); }
console.log("ok: Rust client -> Rust agent full turn; stopReason=end_turn, sawChunk=true");
' "$final"

View file

@ -0,0 +1,21 @@
#!/usr/bin/env bash
# interop.sh — cross-implementation ACP interop gate.
# Builds the Rust agent, then drives it from the independent Node-stdlib client
# (interop-driver.mjs) over live stdio for a full initialize/newSession/prompt
# turn. Asserts stopReason=end_turn + a non-empty streamed chunk. Hermetic: the
# Rust agent answers from tilth's index (no model infra required).
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ACP="$(cd "$HERE/.." && pwd)"
REPO="$(cd "$ACP/../.." && pwd)"
TILTH="$REPO/tilth"
AGENT_BIN="$TILTH/target/debug/tilth-acp-agent"
PROMPT="${INTEROP_PROMPT:-tilth_search}"
echo "== build tilth-acp-agent =="
cargo build --quiet --manifest-path "$TILTH/crates/acp-agent/Cargo.toml"
echo "== interop: independent Node client -> Rust agent (prompt=$PROMPT) =="
INTEROP_PROMPT="$PROMPT" INTEROP_CWD="$REPO" \
node "$HERE/interop-driver.mjs" -- "$AGENT_BIN"

121
shared/acp/conformance/run.sh Executable file
View file

@ -0,0 +1,121 @@
#!/usr/bin/env bash
#
# shared/acp/conformance/run.sh — language-agnostic ACP conformance driver.
#
# Feeds a NDJSON request corpus to a candidate agent's stdin and diffs stdout
# against the matching golden corpus. Normalization before diff:
# - one JSON value per line, sorted by id/method (response reorder OK)
# - volatile fields masked: agentInfo.name/version -> "<agent>",
# sessionId (any value) -> "<sessionId>" — these are implementation
# identity, not protocol contract, so the same corpus passes mock / Aurelio
# / Rust agents alike. Protocol shape (protocolVersion, capabilities,
# stopReason, error codes) is asserted verbatim.
# - SDK-default empty capability objects normalized away: the official TS
# SDK omits unset optional capabilities, while the official Rust SDK
# serializes their empty defaults (mcpCapabilities{}, sessionCapabilities{},
# auth{}, authMethods:[]). These are semantically-empty SDK defaults, not
# protocol contract, so the driver drops the empty forms to keep one corpus
# passing across TS and Rust.
#
# Usage:
# run.sh --agent <cmd...> [--corpus DIR] [--case NAME]
#
# Examples:
# run.sh --agent node shared/acp/mock-agent/mock-agent.mjs
# run.sh --agent node aurelio-backend/dist/acp/agent-entry.js --case initialize
# run.sh --agent cargo run -q -p acp-agent -- --corpus shared/acp/golden/agent
#
# Exit 0 = all cases matched; non-zero = first mismatch printed to stderr.
#
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CORPUS="$HERE/../golden/agent"
AGENT_CMD=()
CASE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--agent) shift; while [[ $# -gt 0 && "$1" != --* ]]; do AGENT_CMD+=("$1"); shift; done ;;
--corpus) shift; CORPUS="$1"; shift ;;
--case) shift; CASE="$1"; shift ;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
done
[[ ${#AGENT_CMD[@]} -eq 0 ]] && { echo "missing --agent <cmd...>" >&2; exit 2; }
# Normalize a NDJSON stream to a canonical, comparable form (Node, via stdin arg $1=file or stdin).
normalize() {
node -e '
const fs=require("fs");
const src=process.argv[1];
const b=src?fs.readFileSync(src,"utf8"):fs.readFileSync(0,"utf8");
const EMPTY_DEFAULT_KEYS=new Set(["mcpCapabilities","sessionCapabilities","auth"]);
const isEmptyObj=v=>v&&typeof v==="object"&&!Array.isArray(v)&&Object.keys(v).length===0;
const isEmptyArr=v=>Array.isArray(v)&&v.length===0;
// True when every leaf boolean in a capability sub-object is false and every
// nested sub-object is itself all-default — i.e. the Rust SDK default for an
// unadvertised capability, which the TS SDK omits entirely. Only recurses one
// level (capability sub-objects are shallow: flags + nested flag maps).
const isAllDefault=v=>{
if(!v||typeof v!=="object"||Array.isArray(v))return false;
const vals=Object.values(v);
if(vals.length===0)return true;
return vals.every(x=>
x===false||x===null||x===undefined||
(Array.isArray(x)&&x.length===0)||
(x&&typeof x==="object"&&!Array.isArray(x)&&isAllDefault(x)));
};
const mask=(v)=>{
if(v&&typeof v==="object"){
if(Array.isArray(v))return v.map(mask);
const o={};
for(const k of Object.keys(v).sort()){
if(k==="sessionId"&&typeof v[k]==="string")o[k]="<sessionId>";
else if(k==="agentInfo"&&v[k]&&typeof v[k]==="object")o[k]={name:"<agent>",version:"<agent>"};
else if(k==="authMethods"&&isEmptyArr(v[k]))continue; // TS omits; Rust emits []
else if(EMPTY_DEFAULT_KEYS.has(k)&&(isEmptyObj(v[k])||isAllDefault(v[k])))continue; // TS omits; Rust emits default {}
else o[k]=mask(v[k]);
}
return o;
}
return v;
};
const xs=b.split(/\n/).filter(Boolean).map(l=>mask(JSON.parse(l)));
xs.sort((a,c)=>(a.id??a.method??"")>(c.id??c.method??"")?1:-1);
console.log(xs.map(x=>JSON.stringify(x)).join("\n"));
' "$1"
}
run_case() {
local stem="$1"
local req="$CORPUS/$stem.req.ndjson"
local exp="$CORPUS/$stem.resp.ndjson"
[[ -f "$req" ]] || { echo "skip $stem (no req)"; return 0; }
[[ -f "$exp" ]] || { echo "FAIL $stem: missing golden resp $exp" >&2; return 1; }
local got want
got="$("${AGENT_CMD[@]}" < "$req" 2>/dev/null | normalize "")"
want="$(normalize "$exp")"
if [[ "$got" == "$want" ]]; then
echo "ok $stem"
else
echo "FAIL $stem" >&2
diff <(printf '%s\n' "$want") <(printf '%s\n' "$got") >&2 || true
return 1
fi
}
fail=0
if [[ -n "$CASE" ]]; then
run_case "$CASE" || fail=1
else
for req in "$CORPUS"/*.req.ndjson; do
stem="$(basename "$req" .req.ndjson)"
run_case "$stem" || fail=1
done
fi
exit $fail

View file

@ -0,0 +1 @@
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":true,"writeTextFile":true},"terminal":true},"clientInfo":{"name":"golden-client","version":"0.0.1"}}}

View file

@ -0,0 +1 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"mock-agent","version":"0.0.1"},"agentCapabilities":{"loadSession":false,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":true}}}}

View file

@ -0,0 +1 @@
{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"golden-session-1"}}

View file

@ -0,0 +1 @@
{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/tmp/workspace","mcpServers":[]}}

View file

@ -0,0 +1 @@
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"golden-session-1"}}

View file

@ -0,0 +1 @@
{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"golden-session-1","prompt":[{"type":"text","text":"say hi in one word"}]}}

View file

@ -0,0 +1,2 @@
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"golden-session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hi"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}

View file

@ -0,0 +1 @@
{"jsonrpc":"2.0","id":11,"method":"fs/read_text_file","params":{"sessionId":"golden-session-1","path":"/tmp/workspace/README.md"}}

View file

@ -0,0 +1 @@
{"jsonrpc":"2.0","id":11,"result":{"content":"# hello\n"}}

View file

@ -0,0 +1 @@
{"jsonrpc":"2.0","id":10,"method":"session/request_permission","params":{"sessionId":"golden-session-1","toolCall":{"toolCallId":"tc-1","title":"Write file","kind":"edit","status":"pending"},"options":[{"optionId":"allow-once","name":"Allow once","kind":"allow_once"},{"optionId":"reject-once","name":"Reject","kind":"reject_once"}]}}

View file

@ -0,0 +1 @@
{"jsonrpc":"2.0","id":10,"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}

View file

@ -0,0 +1,47 @@
#!/usr/bin/env node
/**
* shared/acp/mock-agent a tiny, deterministic ACP agent used for hermetic
* conformance testing across all four implementations (ts/rs agent+client).
*
* Speaks ACP over stdin/stdout (NDJSON). Behaviour:
* initialize -> protocolVersion=1, fixed capabilities
* session/new -> { sessionId: "golden-session-1" } (matches shared/acp/golden)
* session/prompt -> emits one agent_message_chunk ("hi") then stopReason end_turn
* (the golden corpus fixes the literal; the Aurelio agent is freeform)
* session/cancel -> accepted (notification)
* everything else -> method not found (-32601)
*
* No real model, no network. Exits when stdin closes.
*/
import { ndJsonStream, AgentSideConnection, PROTOCOL_VERSION } from '@agentclientprotocol/sdk';
import { Readable, Writable } from 'node:stream';
const SESSION_ID = 'golden-session-1';
const agent = (conn) => ({
async initialize() {
return {
protocolVersion: PROTOCOL_VERSION,
agentInfo: { name: 'mock-agent', version: '0.0.1' },
agentCapabilities: {
loadSession: false,
promptCapabilities: { image: false, audio: false, embeddedContext: true },
},
};
},
async newSession() {
return { sessionId: SESSION_ID };
},
async authenticate() {},
async prompt(params) {
await conn.sessionUpdate({
sessionId: params.sessionId,
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } },
});
return { stopReason: 'end_turn' };
},
async cancel() {},
});
const stream = ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
new AgentSideConnection(agent, stream);

37
shared/acp/mock-agent/package-lock.json generated Normal file
View file

@ -0,0 +1,37 @@
{
"name": "acp-mock-agent",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "acp-mock-agent",
"version": "0.0.1",
"dependencies": {
"@agentclientprotocol/sdk": "1.2.1"
},
"bin": {
"acp-mock-agent": "mock-agent.mjs"
}
},
"node_modules/@agentclientprotocol/sdk": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.2.1.tgz",
"integrity": "sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View file

@ -0,0 +1,10 @@
{
"name": "acp-mock-agent",
"version": "0.0.1",
"private": true,
"type": "module",
"bin": { "acp-mock-agent": "./mock-agent.mjs" },
"dependencies": {
"@agentclientprotocol/sdk": "1.2.1"
}
}

15
shared/acp/package.json Normal file
View file

@ -0,0 +1,15 @@
{
"name": "acp-conformance",
"version": "0.0.1",
"private": true,
"description": "Language-agnostic ACP conformance harness (golden NDJSON corpus + mock agent).",
"scripts": {
"test": "bash conformance/run.sh --agent node \"$PWD/mock-agent/mock-agent.mjs\"",
"test:mock": "bash conformance/run.sh --agent node \"$PWD/mock-agent/mock-agent.mjs\"",
"test:aurelio": "bash conformance/run.sh --agent node \"$PWD/../../../aurelio-theia/aurelio-backend/dist/acp/agent-entry.js\" --case initialize && bash conformance/run.sh --agent node \"$PWD/../../../aurelio-theia/aurelio-backend/dist/acp/agent-entry.js\" --case session_new",
"test:tilth": "cargo build --quiet --manifest-path \"$PWD/../../tilth/crates/acp-agent/Cargo.toml\" && bash conformance/run.sh --agent \"$PWD/../../tilth/target/debug/tilth-acp-agent\" --case initialize && bash conformance/run.sh --agent \"$PWD/../../tilth/target/debug/tilth-acp-agent\" --case session_new",
"test:tilth-client": "cargo build --quiet --manifest-path \"$PWD/../../tilth/crates/acp-client/Cargo.toml\" && mkdir -p /tmp/workspace && printf '# hello\\n' > /tmp/workspace/README.md && bash conformance/run.sh --agent \"$PWD/../../tilth/target/debug/tilth-acp-client\" --corpus golden/client --case fs_read && bash conformance/run.sh --agent \"$PWD/../../tilth/target/debug/tilth-acp-client\" --corpus golden/client --case request_permission",
"test:interop": "bash conformance/interop.sh",
"test:interop-rust": "bash conformance/interop-rust.sh"
}
}