openvibers/openvibe.actor Commits

One agent system: the person's runs and steps in agents.run@1 and agents.step@1 (#451)

OP OpenVibers committed 21 h ago · cd0bf89ba6 · 6 files, +375 −0

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex aca02b1..8e6ac5f 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,21 @@
What changed in OpenVibe.Actor, newest first. Each site also publishes its patch notes at /updates.
++## Unreleased — the same run shape as Codes and Run (`agents.run@1`, `agents.step@1`)
++
++- **`GET /api/v1/runs`** lists the person's own runs newest first (`limit`, `before`, `status`, `agent`) in the one
++ agent system's `agents.run@1` shape: owner, agent and product, parent run, budget, status, timestamps, result
++ summary and cost. A task is the run; nothing new is stored.
++- **`GET /api/v1/runs/:id/steps?after=`** reads the task's own event log as `agents.step@1`, one entry per output
++ event, with the task event's seq as the cursor. `GET /api/v1/runs/:id` answers the same run shape when the reader
++ asks for it with `Accept: application/vnd.openvibe.agents.run+json` (what `openvibe-sdk/agents` sends) or
++ `?shape=agents.run@1`; the existing `agent.run@1` snapshot stays the default for its callers.
++- Read-only and behind the existing `actor.task.read`/`actor.task.list`: only the person's own Network token or
++ session, and another person's run is the same 404 as one that does not exist. Actor's tables are unchanged.
++- The field map and what does not fit (per-step cost, Actor's `needs_you`, a tool call and its result as two log
++ entries) are in OpenVibe.Contracts `docs/agents-runs.md`; the routes are in [docs/agent-runs.md](docs/agent-runs.md).
++ Tests: `test/runs-api.test.js`.
++
## Unreleased — See it work (W1): the desk, the viewer and the replay
- **The desk tile** in a running task's card: the desk's private Live stream (started with the task's desk through Link's
ddiff --git a/docs/agent-runs.md b/docs/agent-runs.md
iindex 551c43d..c1e8d41 100644
---- a/docs/agent-runs.md
++++ b/docs/agent-runs.md
@@@ -20,3 +20,25 @@ with titles. Historical clipped outputs cannot be recovered. Approvals emit ov.a
Grouping, one-line summaries and step status are carried in `metadata["ov.step"]`; the reserved `metadata.ov`
contains the contract's title, kind, sources, attachments, cost, needs_you, product and ref fields.
++
++## The one agent system's shape (`agents.run@1`, `agents.step@1`)
++
++The same run also answers in the Contracts shape every agent product uses (OpenVibe.Contracts `docs/agents-runs.md`
++maps Actor's `tasks`/`task_events` field by field):
++
++- `GET /api/v1/runs` — the person's own runs, newest first (`limit`, `before`, `status`, `agent`), `{ runs, next }`.
++ A run is `agents.run@1`: `id`, `owner`, `agent { id, name?, product: "actor" }`, `parent_run`, `budget` (USD and
++ the timeout in seconds), `status` (`planning|running|waiting|done|failed|stopped`), `created_at`, `updated_at`,
++ `result_summary` and `cost`.
++- `GET /api/v1/runs/:id` — the older `agent.run@1` snapshot stays the default. A reader asks for the new shape with
++ `Accept: application/vnd.openvibe.agents.run+json` (what `openvibe-sdk/agents` `runs.get` sends) or
++ `?shape=agents.run@1`.
++- `GET /api/v1/runs/:id/steps?after=N` — the task's own event log as `agents.step@1`, one entry per output event
++ (`run_id`, `seq`, `kind think|tool|message|child|file`, `text`, `tool_call`, `result`, `started_at`, `ended_at`),
++ `{ run_id, steps, next }`. `seq` is the task event's own and is never renumbered, so `after` is the same cursor a
++ reconnect uses.
++
++All three are read-only and use the task routes' `actor.task.read`/`actor.task.list`; only the person's own Network
++token (or this site's session) is accepted, and anyone else — including an app's own token — gets the same 404 as a
++run that does not exist. Actor's tables are unchanged. The event log splits a tool call and its result into two
++entries; both become `kind: tool`, the call's entry has `tool_call`, the result's has `result`.
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 01cb13d..d8d6fce 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -28,6 +28,7 @@
const express = require('express');
const contracts = require('openvibe-contracts');
const { asyncRouter } = require('./router');
++const runsApi = require('./runs-api');
const store = require('../tasks/store');
const tickets = require('../task-view/tickets');
const catalog = require('../agents/catalog');
@@@ -58,6 +59,11 @@ function createApi(ctx) {
const { config, s, engine, stream, principal, limits, webhooks, triggers } = ctx;
const r = asyncRouter();
const problem = (req, res, status, code, detail, extra) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov, extra });
++ // The one agent system's run shape (agents.run@1) and the older run card's agent.run@1 share GET /runs/:id:
++ // a reader asks for the new shape with this media type (the SDK's runs.get does) or ?shape=agents.run@1, and
++ // every existing caller keeps the snapshot it knows.
++ const wantsAgentsRun = (req) => String(req.query.shape || req.query.contract || '') === 'agents.run@1'
++ || /application\/vnd\.openvibe\.agents\.run\+json/i.test(String(req.get('accept') || ''));
// A trigger belongs to the person in the request, and its Watch calls use that person's own token: the
// Bearer the API was called with, or this site's session token. A service acting for a person is not it.
const watchToken = (req) => {
@@@ -116,6 +122,8 @@ function createApi(ctx) {
});
r.use('/me/actor', require('../day/cors').cors(ctx.config));
r.use(principal.middleware);
++ // GET /runs and /runs/:id/steps: the one agent system's read-only view (agents.run@1, agents.step@1).
++ r.use('/runs', runsApi.createRunsRoutes(ctx));
r.use('/me/actor', require('../day/routes').router(ctx));
r.use('/chat-apps', require('../chat-apps/routes').router(ctx));
@@@ -973,6 +981,12 @@ function createApi(ctx) {
if (match) req.params.id = match[1];
const task = await load(req, res);
if (!task) return;
++ if (wantsAgentsRun(req)) {
++ if (!req.principal || req.principal.kind !== 'user') return problem(req, res, 404, 'agents.run.not_found', 'No such run.');
++ const run = await runsApi.loadRunRow(s, task.id, req.principal.requester);
++ if (!run) return problem(req, res, 404, 'agents.run.not_found', 'No such run.');
++ return res.json(await runsApi.viewOf(s, run, s.now()));
++ }
const row = await agentRuns.resolve(task, start);
if (!row) return problem(req, res, 404, 'actor.task.not_found', 'No such run.');
if (!(config.agentUiSetting ? config.agentUiSetting() : config.agentUi)) return problem(req, res, 404, 'actor.runs_disabled', 'Run streams are disabled.');
ddiff --git a/server/http/runs-api.js b/server/http/runs-api.js
nnew file mode 100644
iindex 0000000..51d2b54
---- /dev/null
++++ b/server/http/runs-api.js
@@@ -0,0 +1,213 @@
++'use strict';
++
++/**
++ * GET /api/v1/runs — the one agent system's read-only view of Actor's runs, in the Contracts `agents.run@1` and
++ * `agents.step@1` shapes (docs/agents-runs.md in OpenVibe.Contracts). Actor's task is the run: the person's own
++ * tasks are the runs they may read, and a run's task events are its steps.
++ *
++ * GET /api/v1/runs actor.task.list the person's runs, newest first (limit, before, status, agent)
++ * GET /api/v1/runs/:id actor.task.read one run
++ * GET /api/v1/runs/:id/steps actor.task.read its steps after ?after= (an append-only log, never renumbered)
++ *
++ * A person's Network token (or this site's session) only: an app's token gets the same 404 as another person's run,
++ * so nothing confirms that a run exists. Read-only: creating, stopping and answering an approval are the engine's
++ * later routes and are not here. No table changes.
++ */
++const contracts = require('openvibe-contracts');
++const { asyncRouter } = require('./router');
++const store = require('../tasks/store');
++const catalog = require('../agents/catalog');
++
++const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
++const STATUS = { queued: 'planning', running: 'running', verifying: 'running', needs_you: 'waiting', succeeded: 'done', failed: 'failed', cancelled: 'stopped' };
++const BY_STATUS = {
++ planning: ['queued'],
++ running: ['running', 'verifying'],
++ waiting: ['needs_you'],
++ done: ['succeeded'],
++ failed: ['failed'],
++ stopped: ['cancelled'],
++};
++const STEP_KIND = {
++ plan: 'think',
++ check: 'think',
++ text: 'message',
++ tool_call: 'tool',
++ tool_result: 'tool',
++ handoff: 'message',
++ trigger: 'message',
++ 'trigger-run': 'message',
++};
++
++const parse = (v) => {
++ if (v == null) return null;
++ if (typeof v !== 'string') return v;
++ try { return JSON.parse(v); } catch { return v; }
++};
++const iso = (v) => {
++ if (v == null || v === '') return null;
++ const t = typeof v === 'number' ? v : Date.parse(String(v));
++ return Number.isFinite(t) ? new Date(t).toISOString() : String(v);
++};
++const clip = (v, n) => (typeof v === 'string' ? (v.length > n ? `${v.slice(0, n - 1)}…` : v) : null);
++const num = (v) => (v == null || !Number.isFinite(Number(v)) ? null : Number(v));
++const round = (v, places = 6) => (v == null ? null : Math.round(Number(v) * 10 ** places) / 10 ** places);
++
++/** What a run card reads for a result: the answer, the summary, or why it did not finish. */
++function summaryOf(row) {
++ const result = parse(row.result);
++ if (row.state === 'succeeded' && result) {
++ if (typeof result === 'string') return clip(result.trim(), 8000);
++ if (result.answer != null) return clip(typeof result.answer === 'string' ? result.answer.trim() : JSON.stringify(result.answer), 8000);
++ if (result.summary != null) return clip(typeof result.summary === 'string' ? result.summary.trim() : JSON.stringify(result.summary), 8000);
++ if (result.text != null) return clip(String(result.text).trim(), 8000);
++ }
++ if (row.error_detail) return clip(String(row.error_detail).trim(), 8000);
++ if (result && result.error) return clip(String(result.error).trim(), 8000);
++ return null;
++}
++
++function elapsedSeconds(row, now) {
++ const start = Date.parse(String(row.created_at || ''));
++ if (!Number.isFinite(start)) return null;
++ const end = row.finished_at ? Date.parse(String(row.finished_at)) : now;
++ if (!Number.isFinite(end)) return null;
++ return Math.max(0, Math.round(((end - start) / 1000) * 1000) / 1000);
++}
++
++/** A task row → agents.run@1. `names` maps a contact id to its name; `now` is the store's clock. */
++function runOf(row, names, now) {
++ const agentId = row.agent_id || row.agent || 'openvibe-runtime';
++ const listed = !row.agent_id && catalog.byId(row.agent || '');
++ const agent = { id: String(agentId), product: 'actor' };
++ const name = (row.agent_id && names.get(row.agent_id)) || (listed && listed.name);
++ if (name) agent.name = String(name);
++ const budget = { usd: num(row.budget_task) };
++ if (row.timeout_ms != null) budget.seconds = Number(row.timeout_ms) / 1000;
++ const cost = {};
++ const usd = num(row.cost_usd);
++ if (usd != null) cost.usd = round(usd);
++ if (row.step_count != null) cost.steps = Number(row.step_count);
++ const seconds = elapsedSeconds(row, now);
++ if (seconds != null) cost.seconds = seconds;
++ const out = {
++ id: row.id,
++ owner: store.subjectRef(row.requester),
++ agent,
++ parent_run: row.parent_id || null,
++ budget,
++ status: STATUS[row.state] || 'running',
++ created_at: iso(row.created_at),
++ updated_at: iso(row.last_at || row.finished_at || row.created_at),
++ result_summary: summaryOf(row),
++ cost: Object.keys(cost).length ? cost : null,
++ };
++ return out;
++}
++
++/** A task event → agents.step@1. One event is one step: Actor writes a tool call and its result as two entries. */
++function stepOf(runId, seq, body) {
++ const b = body && typeof body === 'object' ? body : {};
++ const step = {
++ run_id: b.task_id || runId,
++ seq: Number(b.seq == null ? seq : b.seq),
++ kind: STEP_KIND[b.step] || 'message',
++ text: b.text == null ? null : clip(String(b.text), 65536),
++ started_at: iso(b.at) || iso(new Date().toISOString()),
++ ended_at: null,
++ cost: null,
++ child_run: null,
++ };
++ if (b.step === 'tool_call' && b.tool) step.tool_call = { name: String(b.tool).slice(0, 160), input: b.input == null ? null : b.input };
++ if (b.step === 'tool_result') {
++ step.result = b.text == null ? null : b.text;
++ step.ended_at = step.started_at;
++ } else if (b.step !== 'tool_call') {
++ // A point event: it happened at `at`, so the step is complete when it is written.
++ step.ended_at = step.started_at;
++ }
++ if (b.step === 'trigger' || b.step === 'trigger-run') {
++ step.result = b.trigger || b.run || null;
++ }
++ return step;
++}
++
++const RUN_ROW_SELECT = `SELECT t.*,
++ (SELECT e.body->>'at' FROM task_events e WHERE e.task_id = t.id ORDER BY e.seq DESC LIMIT 1) AS last_at,
++ (SELECT count(*) FROM task_events e WHERE e.task_id = t.id AND e.body->>'kind' = 'output') AS step_count
++ FROM tasks t`;
++
++async function namesFor(s, requester, rows) {
++ const ids = [...new Set(rows.map((row) => row.agent_id).filter((id) => id && /^agt_/.test(String(id))))];
++ const names = new Map();
++ if (!ids.length) return names;
++ const found = await s.db.many('SELECT id, name FROM actor_agents WHERE owner = $1 AND id = ANY($2::text[])', [requester, ids]);
++ for (const row of found) names.set(row.id, row.name);
++ return names;
++}
++
++/** One run row with the newest event's time and its step count, for the one run's read. */
++const loadRunRow = (s, id, requester) => s.db.maybe(`${RUN_ROW_SELECT} WHERE t.id = $1 AND t.requester = $2`, [id, requester]);
++
++/** A row from loadRunRow → the agents.run@1 body, with the contact's name when the run names one. */
++async function viewOf(s, row, now) {
++ return runOf(row, await namesFor(s, row.requester, [row]), now);
++}
++
++function createRunsRoutes(ctx) {
++ const { s, principal, limits } = ctx;
++ const r = asyncRouter();
++ const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
++ const notFound = (req, res) => problem(req, res, 404, 'agents.run.not_found', 'No such run.');
++ // The one agent system's run view is the person's own: a service acting for them, an app's own token and another
++ // person all get the same 404, never a 401 that would confirm a run.
++ const own = (req, res, next) => (req.principal && req.principal.kind === 'user' && /^user:usr_/.test(String(req.principal.requester)) ? next() : notFound(req, res));
++
++ const RUN_SELECT = RUN_ROW_SELECT;
++
++ r.get('/', principal.requireCapability('actor.task.list'), own, limits.reads('actor.task.list'), async (req, res) => {
++ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 20));
++ const before = TASK_ID.test(String(req.query.before || '')) ? String(req.query.before) : null;
++ let states = null;
++ if (req.query.status != null && req.query.status !== '') {
++ states = BY_STATUS[String(req.query.status)];
++ if (!states) return problem(req, res, 400, 'agents.run.status', 'status must be one of planning, running, waiting, done, failed, stopped.');
++ }
++ const agent = typeof req.query.agent === 'string' && req.query.agent.trim() ? req.query.agent.trim().slice(0, 160) : null;
++ const where = ['t.requester = $1'];
++ const args = [req.principal.requester];
++ if (before) { args.push(before); where.push(`t.id < $${args.length}`); }
++ if (states) { args.push(states); where.push(`t.state = ANY($${args.length}::text[])`); }
++ if (agent) { args.push(agent); where.push(`(t.agent_id = $${args.length} OR t.agent = $${args.length})`); }
++ args.push(limit + 1);
++ const rows = await s.db.many(`${RUN_SELECT} WHERE ${where.join(' AND ')} ORDER BY t.id DESC LIMIT $${args.length}`, args);
++ const page = rows.slice(0, limit);
++ const names = await namesFor(s, req.principal.requester, page);
++ const now = s.now();
++ res.json({ runs: page.map((row) => runOf(row, names, now)), next: rows.length > limit ? page[page.length - 1].id : null });
++ });
++
++ async function load(req, res) {
++ if (!TASK_ID.test(String(req.params.id || ''))) { notFound(req, res); return null; }
++ const row = await loadRunRow(s, req.params.id, req.principal.requester);
++ if (!row) { notFound(req, res); return null; }
++ return row;
++ }
++
++ r.get('/:id/steps', principal.requireCapability('actor.task.read'), own, limits.reads('actor.task.read'), async (req, res) => {
++ const row = await load(req, res);
++ if (!row) return;
++ const after = Math.max(0, parseInt(req.query.after, 10) || 0);
++ const limit = Math.min(500, Math.max(1, parseInt(req.query.limit, 10) || 200));
++ const rows = await s.db.many(`SELECT e.seq, e.body FROM task_events e
++ WHERE e.task_id = $1 AND e.seq > $2 AND e.body->>'kind' = 'output'
++ ORDER BY e.seq LIMIT $3`, [row.id, after, limit + 1]);
++ const page = rows.slice(0, limit);
++ const steps = page.map((entry) => stepOf(row.id, entry.seq, parse(entry.body)));
++ res.json({ run_id: row.id, steps, next: rows.length > limit ? Number(page[page.length - 1].seq) : null });
++ });
++
++ return r;
++}
++
++module.exports = { createRunsRoutes, runOf, stepOf, viewOf, loadRunRow, STATUS, STEP_KIND };
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
iindex e1801bb..0efe570 100644
---- a/test/contracts.test.js
++++ b/test/contracts.test.js
@@@ -20,7 +20,9 @@ const INDEX_ROUTES = ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn'];
// answer names and aggregate numbers only (never a person or a task), so they need no grant; a later release may add
// actor.stats.read.
const ROUTE_EXTENSIONS = new Map([
++ ['GET /api/v1/runs', 'actor.task.list'],
['GET /api/v1/runs/:id', 'actor.task.read'],
++ ['GET /api/v1/runs/:id/steps', 'actor.task.read'],
['GET /api/v1/runs/:id/events', 'actor.task.read'],
['GET /api/v1/tasks/:id/trace', 'actor.task.read'],
['GET /api/v1/stats/tools', null],
@@@ -72,6 +74,7 @@ const API_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'ap
const A2A_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'a2a-pages.js'), 'utf8');
const INDEX_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'registry', 'resource-index.js'), 'utf8');
const CONTROL_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'registry', 'resource-control.js'), 'utf8');
++const RUNS_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'runs-api.js'), 'utf8');
/** `METHOD /api/v1/path` for every API and resource index route, with its capability guard (null: public). */
function codeRoutes() {
@@@ -83,6 +86,12 @@ function codeRoutes() {
out.set(`${m[1].toUpperCase()} ${prefix}${m[2] === '/' ? '' : m[2]}`, { guard: guard ? guard[1] : null, limit: read ? read[1] : null });
}
}
++ // The runs router is mounted at /api/v1/runs (the one agent system's read-only shape).
++ for (const m of RUNS_SRC.matchAll(/^\s*r\.(get|post|put|patch|delete)\('([^']+)'(.*)$/gm)) {
++ const guard = m[3].match(/requireCapability\('([a-z0-9._]+)'\)/);
++ const read = m[3].match(/limits\.reads\('([a-z0-9._]+)'\)/);
++ out.set(`${m[1].toUpperCase()} /api/v1/runs${m[2] === '/' ? '' : m[2]}`, { guard: guard ? guard[1] : null, limit: read ? read[1] : null });
++ }
return out;
}
ddiff --git a/test/runs-api.test.js b/test/runs-api.test.js
nnew file mode 100644
iindex 0000000..3894d83
---- /dev/null
++++ b/test/runs-api.test.js
@@@ -0,0 +1,102 @@
++'use strict';
++/**
++ * The one agent system's read-only view of Actor's runs (agents.run@1, agents.step@1; OpenVibe.Contracts
++ * docs/agents-runs.md). Actor's task is the run: the person's own tasks are listed and read, their task events are
++ * the steps, and nobody else sees them. The older agent.run@1 snapshot stays at GET /runs/:id unless the reader asks
++ * for the new shape with the media type or ?shape=agents.run@1.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const AGENTS = 'application/vnd.openvibe.agents.run+json';
++const STATUS = new Set(['planning', 'running', 'waiting', 'done', 'failed', 'stopped']);
++const KIND = new Set(['think', 'tool', 'message', 'approval', 'child', 'file']);
++
++(async () => {
++ const t = await boot();
++ const kim = t.network.addUser('kim');
++ const lee = t.network.addUser('lee');
++ const create = async (user, task) => {
++ t.providers.reset();
++ t.providers.state.script = [{ content: 'The answer is 42.' }];
++ const r = await t.get('/api/v1/tasks', { as: user, json: { task }, headers: { 'sec-fetch-site': 'same-origin' } });
++ assert.strictEqual(r.status, 201, r.text);
++ const id = r.json().id;
++ await t.waitFor(id);
++ return id;
++ };
++
++ try {
++ const kimTask = await create(kim, 'What is the answer?');
++ const leeTask = await create(lee, 'What is my answer?');
++
++ await check('GET /api/v1/runs lists only the person\'s own runs in the agents.run@1 shape', async () => {
++ const r = await t.get('/api/v1/runs', { as: kim });
++ assert.strictEqual(r.status, 200, r.text);
++ const body = r.json();
++ assert.ok(Array.isArray(body.runs) && body.runs.length === 1, JSON.stringify(body));
++ const run = body.runs[0];
++ assert.strictEqual(run.id, kimTask);
++ assert.strictEqual(run.owner.type, 'user');
++ assert.strictEqual(run.owner.id, kim.subject);
++ assert.strictEqual(run.agent.product, 'actor');
++ assert.ok(run.agent.id);
++ assert.ok(STATUS.has(run.status), run.status);
++ assert.ok(run.created_at && run.updated_at);
++ assert.ok(run.budget && Number.isFinite(run.budget.usd));
++ assert.ok(run.cost && Number.isFinite(run.cost.usd));
++ assert.ok(!body.runs.some((x) => x.id === leeTask), 'another person\'s run is not listed');
++ assert.strictEqual(body.next, null);
++ });
++
++ await check('GET /api/v1/runs/:id returns agents.run@1 when asked, scoped to its owner', async () => {
++ const next = await t.get(`/api/v1/runs/${kimTask}`, { as: kim, headers: { accept: AGENTS } });
++ assert.strictEqual(next.status, 200, next.text);
++ const run = next.json();
++ assert.strictEqual(run.id, kimTask);
++ assert.strictEqual(run.owner.id, kim.subject);
++ assert.ok(STATUS.has(run.status));
++ const query = await t.get(`/api/v1/runs/${kimTask}?shape=agents.run@1`, { as: kim });
++ assert.strictEqual(query.status, 200, query.text);
++ assert.strictEqual(query.json().id, kimTask);
++ const elsewhere = await t.get(`/api/v1/runs/${leeTask}`, { as: kim, headers: { accept: AGENTS } });
++ assert.strictEqual(elsewhere.status, 404);
++ const stranger = await t.get(`/api/v1/runs/${kimTask}`, { as: lee, headers: { accept: AGENTS } });
++ assert.strictEqual(stranger.status, 404);
++ });
++
++ await check('GET /api/v1/runs/:id/steps reads the task event log as agents.step@1', async () => {
++ const r = await t.get(`/api/v1/runs/${kimTask}/steps`, { as: kim });
++ assert.strictEqual(r.status, 200, r.text);
++ const body = r.json();
++ assert.strictEqual(body.run_id, kimTask);
++ assert.ok(Array.isArray(body.steps) && body.steps.length > 0, JSON.stringify(body));
++ let previous = 0;
++ for (const step of body.steps) {
++ assert.strictEqual(step.run_id, kimTask);
++ assert.ok(Number.isInteger(step.seq) && step.seq > previous, `seq ${step.seq} after ${previous}`);
++ assert.ok(KIND.has(step.kind), step.kind);
++ assert.ok(step.started_at);
++ previous = step.seq;
++ }
++ assert.ok(body.steps.some((s) => s.kind === 'message'), 'the answer is a message step');
++ const after = await t.get(`/api/v1/runs/${kimTask}/steps?after=${previous}`, { as: kim });
++ assert.deepStrictEqual(after.json().steps, []);
++ const stranger = await t.get(`/api/v1/runs/${kimTask}/steps`, { as: lee });
++ assert.strictEqual(stranger.status, 404);
++ });
++
++ await check('the run read is behind the task grants and refuses a bad status filter', async () => {
++ assert.strictEqual((await t.get('/api/v1/runs')).status, 401);
++ const bad = await t.get('/api/v1/runs?status=almost', { as: kim });
++ assert.strictEqual(bad.status, 400);
++ assert.strictEqual(bad.json().code, 'agents.run.status');
++ const doneRuns = await t.get('/api/v1/runs?status=done', { as: kim });
++ assert.strictEqual(doneRuns.status, 200);
++ assert.ok(doneRuns.json().runs.some((x) => x.id === kimTask));
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();