Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ Runnable suites: language-agnostic YAML cases plus thin per-language harnesses.
| **Host** | launches via pipeline block only; adapter-sync every launch; host obligations; never edits agent- or system-owned files |
| **Agent** | none — deliberately. Any agent that reads files, runs a CLI, and speaks HTTP participates |

The suite is also how the TypeScript implementation proves parity, and how the safe-evolve lifecycle semantics are verified: the harness's **Safe-evolve** class drives the real `agent-app`/`a2app` CLIs over a scaffolded runnable blueprint — dev instance on a hidden port with a fresh replayed DB, live data untouched, operate traffic routed to the candidate, the validate→promote gate pass (including refusal after a post-validate edit), mandatory pre-promote backup, dev destruction on promote, and restore's capture-first contract (framework spec 7.2).
The suite is also how the TypeScript implementation proves parity, and how the safe-evolve lifecycle semantics are verified: the harness's **Safe-evolve** class drives the real `agent-app`/`a2app` CLIs over a scaffolded runnable blueprint — dev instance on a hidden port with a fresh replayed DB, live data untouched, operate traffic routed to the candidate, an operation runner's `trigger` queuing a claimable task on the booted app, the validate→promote gate pass (including refusal after a post-validate edit), mandatory pre-promote backup, dev destruction on promote, and restore's capture-first contract (framework spec 7.2).
50 changes: 46 additions & 4 deletions conformance/harness/src/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,25 @@ import type { CheckResult, SuiteResult } from "./runner.js";

const TEST_HOME = mkdtempSync(join(tmpdir(), "a2app-conf-evolve-home-"));

function run(entry: string, args: string[], cwd?: string): Promise<{ exit: number; out: string }> {
/** `out` interleaves both streams in arrival order, for messages. `stdout`
* alone is what to parse: the CLIs keep it machine-readable and put banners
* and logs on stderr. */
function run(entry: string, args: string[], cwd?: string): Promise<{ exit: number; out: string; stdout: string }> {
return new Promise((resolvePromise) => {
const child = spawn(process.execPath, [entry, ...args], {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, A2APP_HOME: TEST_HOME },
...(cwd !== undefined ? { cwd } : {}),
});
let out = "";
child.stdout.on("data", (d) => (out += d));
let stdout = "";
child.stdout.on("data", (d) => {
out += d;
stdout += d;
});
child.stderr.on("data", (d) => (out += d));
child.on("close", (code) => resolvePromise({ exit: code ?? -1, out }));
child.on("error", (e) => resolvePromise({ exit: -1, out: String(e) }));
child.on("close", (code) => resolvePromise({ exit: code ?? -1, out, stdout }));
child.on("error", (e) => resolvePromise({ exit: -1, out: String(e), stdout: "" }));
});
}

Expand Down Expand Up @@ -178,6 +185,41 @@ export async function runSafeEvolveClass(
check("operate commands target the dev instance while it is up (test data never reaches live)", failures);
}

// ── an operation runner's trigger queues a task ──────────────────────────
// The only booted path through server.mjs's runner toolbox. The wrapper reads
// the adapter handle at call time, so a broken one passes `node --check` and
// every boot, and fails only here, when a runner first fires.
{
const failures: string[] = [];
const res = await run(operateEntry, [appDir, "planning", "tasks", "task_welcome", "request-triage"]);
// Drop the "answering from the DEV instance" banner so the error itself fits.
const said = res.out.split("\n").filter((l) => !l.startsWith("ℹ")).join(" ").trim().slice(0, 300);
const queued = /"queued":\s*"([^"]+)"/.exec(res.stdout)?.[1];
if (res.exit !== 0) failures.push(`request-triage exit ${res.exit}: ${said}`);
else if (!queued) failures.push(`request-triage did not report a queued task: ${said}`);
else {
const listed = await run(operateEntry, [appDir, "tasks"]);
type Task = { id: string; request?: { capability?: string; payload?: unknown } };
let tasks: Task[] | null = null;
try {
tasks = (JSON.parse(listed.stdout) as { tasks?: Task[] }).tasks ?? [];
} catch {
failures.push(`tasks list is not JSON (exit ${listed.exit}): ${listed.out.trim().slice(0, 200)}`);
}
// An unreadable listing says nothing about the queue, so it is not also
// reported as the task missing from it.
const task = tasks?.find((t) => t.id === queued);
if (tasks !== null && !task) failures.push(`queued task ${queued} is not in the app's task queue`);
else if (task) {
if (task.request?.capability !== "triage") failures.push(`task capability is ${JSON.stringify(task.request?.capability)}, expected "triage"`);
if (JSON.stringify(task.request?.payload) !== JSON.stringify({ task: "task_welcome" })) {
failures.push(`task payload is ${JSON.stringify(task.request?.payload)}, expected the record's id`);
}
}
}
check("an operation runner's trigger(type, payload, capability) queues a claimable task", failures);
}

// ── validate with dev up records a promotable pass ───────────────────────
{
const failures: string[] = [];
Expand Down
4 changes: 2 additions & 2 deletions toolkits/blueprint-react-node/template/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -221,12 +221,12 @@ const binding = {
// `trigger` is the app→agent seam. This file is system-owned, so without it
// an author whose only seam is `a2app.schema.mjs` could never enqueue agent
// work: the adapter handle that can do so lives here and nowhere they may
// edit. `app` is assigned further down and read at call time, which is
// edit. `a2app` is declared further down and read at call time, which is
// always after boot.
return runner(args, ctx, {
store,
/** Emit a declared event, and enqueue a task when `capability` is given. */
trigger: (type, payload, capability) => app.trigger({ type, payload: payload ?? {}, ...(capability ? { capability } : {}) }),
trigger: (type, payload, capability) => a2app.trigger({ type, payload: payload ?? {}, ...(capability ? { capability } : {}) }),
});
},
};
Expand Down
9 changes: 9 additions & 0 deletions toolkits/blueprint-react-node/test/app-to-agent.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ const server = readFileSync(join(templateDir, "server.mjs"), "utf8");
ok("server.mjs hands runners a trigger", /runner\(args, ctx, \{[\s\S]*?trigger:/.test(server));
ok("…and declares the app's event types to the adapter", /events:\s*schema\.events/.test(server));

// The wrapper reads the adapter handle at call time, so a wrong name is not a
// syntax error and `node --check` passes it: it surfaces only when a runner
// first fires, as "<name> is not defined". Pin the name it calls to the binding
// `createA2App` actually returns.
const callee = /trigger:\s*\([^)]*\)\s*=>\s*([A-Za-z_$][\w$]*)\.trigger\(/.exec(server)?.[1];
const instance = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*createA2App\(/.exec(server)?.[1];
ok("server.mjs creates the adapter with createA2App", Boolean(instance));
check("…and the runner's trigger calls that same binding", callee, instance);

/* ------------------------------------------- every fired type is declared */

// Cheap and exact: the runners are source, and a literal first argument is how
Expand Down
Loading