feat(shared): ACP conformance suite — golden cases, mock agent, SDK versions
This commit is contained in:
parent
1867673ce7
commit
04daf36161
22 changed files with 466 additions and 0 deletions
1
shared/acp/ACP_VERSION
Normal file
1
shared/acp/ACP_VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
1
|
||||
4
shared/acp/SDK_VERSIONS
Normal file
4
shared/acp/SDK_VERSIONS
Normal 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
|
||||
48
shared/acp/conformance/README.md
Normal file
48
shared/acp/conformance/README.md
Normal 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).
|
||||
122
shared/acp/conformance/interop-driver.mjs
Normal file
122
shared/acp/conformance/interop-driver.mjs
Normal 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(() => {})]);
|
||||
}
|
||||
28
shared/acp/conformance/interop-rust.sh
Executable file
28
shared/acp/conformance/interop-rust.sh
Executable 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"
|
||||
21
shared/acp/conformance/interop.sh
Executable file
21
shared/acp/conformance/interop.sh
Executable 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
121
shared/acp/conformance/run.sh
Executable 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
|
||||
1
shared/acp/golden/agent/initialize.req.ndjson
Normal file
1
shared/acp/golden/agent/initialize.req.ndjson
Normal 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"}}}
|
||||
1
shared/acp/golden/agent/initialize.resp.ndjson
Normal file
1
shared/acp/golden/agent/initialize.resp.ndjson
Normal 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}}}}
|
||||
1
shared/acp/golden/agent/session_cancel.note.ndjson
Normal file
1
shared/acp/golden/agent/session_cancel.note.ndjson
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"golden-session-1"}}
|
||||
1
shared/acp/golden/agent/session_new.req.ndjson
Normal file
1
shared/acp/golden/agent/session_new.req.ndjson
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/tmp/workspace","mcpServers":[]}}
|
||||
1
shared/acp/golden/agent/session_new.resp.ndjson
Normal file
1
shared/acp/golden/agent/session_new.resp.ndjson
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"golden-session-1"}}
|
||||
1
shared/acp/golden/agent/session_prompt.req.ndjson
Normal file
1
shared/acp/golden/agent/session_prompt.req.ndjson
Normal 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"}]}}
|
||||
2
shared/acp/golden/agent/session_prompt.resp.ndjson
Normal file
2
shared/acp/golden/agent/session_prompt.resp.ndjson
Normal 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"}}
|
||||
1
shared/acp/golden/client/fs_read.req.ndjson
Normal file
1
shared/acp/golden/client/fs_read.req.ndjson
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"jsonrpc":"2.0","id":11,"method":"fs/read_text_file","params":{"sessionId":"golden-session-1","path":"/tmp/workspace/README.md"}}
|
||||
1
shared/acp/golden/client/fs_read.resp.ndjson
Normal file
1
shared/acp/golden/client/fs_read.resp.ndjson
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"jsonrpc":"2.0","id":11,"result":{"content":"# hello\n"}}
|
||||
1
shared/acp/golden/client/request_permission.req.ndjson
Normal file
1
shared/acp/golden/client/request_permission.req.ndjson
Normal 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"}]}}
|
||||
1
shared/acp/golden/client/request_permission.resp.ndjson
Normal file
1
shared/acp/golden/client/request_permission.resp.ndjson
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"jsonrpc":"2.0","id":10,"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}
|
||||
47
shared/acp/mock-agent/mock-agent.mjs
Executable file
47
shared/acp/mock-agent/mock-agent.mjs
Executable 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
37
shared/acp/mock-agent/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
shared/acp/mock-agent/package.json
Normal file
10
shared/acp/mock-agent/package.json
Normal 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
15
shared/acp/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue