bus, harness: PM task reads that fit a session (#1583)
Row 89, round 1. The broker's `tasks` read takes optional filter and page arguments and returns a short form. The harness adds `list_tasks` arguments (open tasks by default), a `get_task` tool, and compact JSON replies capped below the 50000-character threshold where Claude Code 2.1.296 saves a tool result to a file outside the workspace, which was the PM's fault in Jason's test. The bundle's tools line names the harness's own tools. Candidate manifest b8c5bb3a (agents/dewey/work/queue-89/), basea3f6e9e6, no drift on the 12 paths. Builder Dewey. Approved by Darkwing (comment 27940). Sage's gate onf04185fcplus the patch 2026-10-12T22:00:55Z-22:03:38Z: bus 108, harness 65, webui 86, cli 115, control-board 124, every scripts/test-*.sh green. Live at the next bus restart (broker) and role session launch (harness), both Mos's call (decision 109); Mos had the notice. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
+16
-2
@@ -125,7 +125,8 @@ an operator inspect content and delivery evidence after an uncertain result.
|
|||||||
| `message.read` | `{id}`; recipient holder's acknowledgement; no new holder can acknowledge old holder's delivery. |
|
| `message.read` | `{id}`; recipient holder's acknowledgement; no new holder can acknowledge old holder's delivery. |
|
||||||
| `event.emit` | `{kind:'action.allowed',body:{action:'routine',target?},subject?}`; routine observations only. |
|
| `event.emit` | `{kind:'action.allowed',body:{action:'routine',target?},subject?}`; routine observations only. |
|
||||||
| `launch.revoke`, `launch.restore` | No args; outside-agent human CLI only. |
|
| `launch.revoke`, `launch.restore` | No args; outside-agent human CLI only. |
|
||||||
| `inbox`, `tasks` | No args; plain JSON projections scoped to capability's business. |
|
| `inbox` | No args; plain JSON projection scoped to capability's business. |
|
||||||
|
| `tasks` | Optional args, scoped to capability's business (row 89, #1583). None: every row of the tasks projection (below). `{task_ref}` alone: that one row, or `task-not-found`. Any of `{done,assignee,label,requirement,limit,after}`: `{tasks,total,next}`, the short form (below). An unknown arg or bad value refuses `invalid-request`. |
|
||||||
| `agents` | No args; each held role's latest claim row in the capability's business. Human and reader sessions also get `state`, `working` and `inputNeeded` (Agent state, below); a role session gets the claim rows only. |
|
| `agents` | No args; each held role's latest claim row in the capability's business. Human and reader sessions also get `state`, `working` and `inputNeeded` (Agent state, below); a role session gets the claim rows only. |
|
||||||
| `trail` | `{subject}`: task ref, decision UUID or message UUID. Linked records in broker write order. |
|
| `trail` | `{subject}`: task ref, decision UUID or message UUID. Linked records in broker write order. |
|
||||||
| `decisions` | `{list}`: `roles` or `recent`; human and reader sessions only. Open decisions routed to a role, or the last 50 closed. |
|
| `decisions` | `{list}`: `roles` or `recent`; human and reader sessions only. Open decisions routed to a role, or the last 50 closed. |
|
||||||
@@ -295,7 +296,20 @@ must not pretend a missing/inaccessible task is open or completed. Each row adds
|
|||||||
computed at each read and never stored. `priority` is the current row's `fields.priority`, null
|
computed at each read and never stored. `priority` is the current row's `fields.priority`, null
|
||||||
for a tombstone or a read with none (Vikunja's 0 stays 0). `requirement` is the requirement of the
|
for a tombstone or a read with none (Vikunja's 0 stays 0). `requirement` is the requirement of the
|
||||||
first `task.created` for the same Vikunja task id in the business, so a task moved to another
|
first `task.created` for the same Vikunja task id in the business, so a task moved to another
|
||||||
project keeps it; null for a task the stack didn't create. Trail joins
|
project keeps it; null for a task the stack didn't create.
|
||||||
|
|
||||||
|
A `tasks` read with filters (row 89, #1583) is what a session's `list_tasks` makes; the full
|
||||||
|
projection is too large for a tool reply on a real board. It leaves out tombstones and pages in
|
||||||
|
`task_ref` order (as text). Each task is `{task_ref,title,done,bucket,assignees,labels,priority,
|
||||||
|
requirement,changed_at}`: no description; `assignees` are the roles whose tracker bot is
|
||||||
|
assigned, sorted, so a person's assignment shows as none; `changed_at` is the row's `at`, as the
|
||||||
|
Console shows it. Filters: `done` (boolean), `assignee` (a role of the business), `label` (a
|
||||||
|
label id), `requirement` (`REQ-[A-Z]+-[1-9][0-9]*`). `limit` is 1 to `TASK_PAGE_MAX` (200),
|
||||||
|
`TASK_PAGE` (50) by default. `after` is a task ref, which needn't be on the board; the page holds
|
||||||
|
the matches past it. `total` counts every match, `next` is the `after` for the following page,
|
||||||
|
null on the last.
|
||||||
|
|
||||||
|
Trail joins
|
||||||
human request, messages/deliveries, decisions/resolutions, task snapshots and
|
human request, messages/deliveries, decisions/resolutions, task snapshots and
|
||||||
session/claim evidence. Broker timestamps advance monotonically across all
|
session/claim evidence. Broker timestamps advance monotonically across all
|
||||||
its writes, including after reopen; per-table `seq` stays the primary table
|
its writes, including after reopen; per-table `seq` stays the primary table
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ const CLOSED = "('resolved','withdrawn','expired')";
|
|||||||
export const RECENT = 50;
|
export const RECENT = 50;
|
||||||
// How many replies `replies` returns, oldest first.
|
// How many replies `replies` returns, oldest first.
|
||||||
export const REPLIES = 50;
|
export const REPLIES = 50;
|
||||||
|
// Row 89 (#1583): the arguments `tasks` takes, and its page size by default and at most.
|
||||||
|
const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', 'task_ref'];
|
||||||
|
export const TASK_PAGE = 50;
|
||||||
|
export const TASK_PAGE_MAX = 200;
|
||||||
export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task.')));
|
export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task.')));
|
||||||
const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
|
const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
|
||||||
const POLL_KINDS = new Set([
|
const POLL_KINDS = new Set([
|
||||||
@@ -328,6 +332,59 @@ export class Broker {
|
|||||||
return { ...r, fields, priority, requirement: requirement.get(taskId(r.task_ref)) ?? null };
|
return { ...r, fields, priority, requirement: requirement.get(taskId(r.task_ref)) ?? null };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Row 89 (#1583): with no arguments, every row as #taskRows gives it, which the Console and
|
||||||
|
// `mosaic tasks` read. `task_ref`, alone, gives that one row. Any other argument filters and pages
|
||||||
|
// the short form in task_ref order: no description, and the assignees that are a role's tracker
|
||||||
|
// bot, by role. A tombstone isn't on the board, so a filtered read leaves it out. `total` counts
|
||||||
|
// every task the filters match; `next` is the `after` for the next page, null on the last.
|
||||||
|
#tasks(business, a) {
|
||||||
|
const rows = this.#taskRows(business);
|
||||||
|
if (!Object.keys(a).length) return rows;
|
||||||
|
if ('task_ref' in a) {
|
||||||
|
if (Object.keys(a).length !== 1 || !taskRef(a.task_ref)) fail('invalid-request');
|
||||||
|
return rows.find((r) => r.task_ref === a.task_ref) ?? fail('task-not-found');
|
||||||
|
}
|
||||||
|
const roles = this.#business(business).roles ?? {};
|
||||||
|
const positive = (x, max = Number.MAX_SAFE_INTEGER) => Number.isSafeInteger(x) && x >= 1 && x <= max;
|
||||||
|
if (
|
||||||
|
('done' in a && typeof a.done !== 'boolean') ||
|
||||||
|
('assignee' in a && !(typeof a.assignee === 'string' && Object.hasOwn(roles, a.assignee))) ||
|
||||||
|
('label' in a && !positive(a.label)) ||
|
||||||
|
('requirement' in a && !(typeof a.requirement === 'string' && /^REQ-[A-Z]+-[1-9][0-9]*$/.test(a.requirement))) ||
|
||||||
|
('limit' in a && !positive(a.limit, TASK_PAGE_MAX)) ||
|
||||||
|
('after' in a && !taskRef(a.after))
|
||||||
|
)
|
||||||
|
fail('invalid-request');
|
||||||
|
const bots = new Map(
|
||||||
|
Object.entries(roles)
|
||||||
|
.filter(([, r]) => r.tracker?.botId !== undefined)
|
||||||
|
.map(([role, r]) => [r.tracker.botId, role]),
|
||||||
|
);
|
||||||
|
const matched = rows
|
||||||
|
.filter((r) => !r.fields.gone)
|
||||||
|
.map(({ task_ref, fields: f, priority, requirement, at }) => ({
|
||||||
|
task_ref,
|
||||||
|
title: f.title ?? null,
|
||||||
|
done: Boolean(f.done),
|
||||||
|
bucket: f.bucket ?? null,
|
||||||
|
assignees: (f.assignees ?? []).filter((u) => bots.has(u)).map((u) => bots.get(u)).sort(),
|
||||||
|
labels: f.labels ?? [],
|
||||||
|
priority,
|
||||||
|
requirement,
|
||||||
|
changed_at: at,
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
(t) =>
|
||||||
|
(!('done' in a) || t.done === a.done) &&
|
||||||
|
(!('assignee' in a) || t.assignees.includes(a.assignee)) &&
|
||||||
|
(!('label' in a) || t.labels.includes(a.label)) &&
|
||||||
|
(!('requirement' in a) || t.requirement === a.requirement),
|
||||||
|
);
|
||||||
|
const rest = 'after' in a ? matched.filter((t) => t.task_ref > a.after) : matched;
|
||||||
|
const limit = a.limit ?? TASK_PAGE;
|
||||||
|
const tasks = rest.slice(0, limit);
|
||||||
|
return { tasks, total: matched.length, next: rest.length > limit ? tasks.at(-1).task_ref : null };
|
||||||
|
}
|
||||||
#checkAuthority(s, action, { decision = null, target = null } = {}) {
|
#checkAuthority(s, action, { decision = null, target = null } = {}) {
|
||||||
this.#agent(s);
|
this.#agent(s);
|
||||||
if (
|
if (
|
||||||
@@ -841,7 +898,8 @@ export class Broker {
|
|||||||
case 'agents':
|
case 'agents':
|
||||||
case 'tasks':
|
case 'tasks':
|
||||||
case 'trail': {
|
case 'trail': {
|
||||||
keys(a, verb === 'trail' ? ['subject'] : [], verb === 'trail' ? ['subject'] : []);
|
const allowed = verb === 'trail' ? ['subject'] : verb === 'tasks' ? TASK_ARGS : [];
|
||||||
|
keys(a, allowed, verb === 'trail' ? ['subject'] : []);
|
||||||
if (!s.human && !s.reader) this.#agent(s);
|
if (!s.human && !s.reader) this.#agent(s);
|
||||||
if (verb === 'inbox')
|
if (verb === 'inbox')
|
||||||
return this.#store
|
return this.#store
|
||||||
@@ -859,7 +917,7 @@ export class Broker {
|
|||||||
// A role session gets the claims only: another role's decisions aren't its to read.
|
// A role session gets the claims only: another role's decisions aren't its to read.
|
||||||
return s.human || s.reader ? claims.map((c) => this.#agentState(c)) : claims;
|
return s.human || s.reader ? claims.map((c) => this.#agentState(c)) : claims;
|
||||||
}
|
}
|
||||||
if (verb === 'tasks') return this.#taskRows(s.business);
|
if (verb === 'tasks') return this.#tasks(s.business, a);
|
||||||
return this.#trail(s, identifier(a.subject));
|
return this.#trail(s, identifier(a.subject));
|
||||||
}
|
}
|
||||||
// Row 57 (#1546): the Inbox's "Routed to roles" and "Recent" lists, for human and
|
// Row 57 (#1546): the Inbox's "Routed to roles" and "Recent" lists, for human and
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { Store } from '../src/store.mjs';
|
||||||
|
import { Broker, TASK_PAGE, TASK_PAGE_MAX } from '../src/broker.mjs';
|
||||||
|
import { REPLY_CAP } from '../../harness/src/tools.mjs';
|
||||||
|
// Row 89 (#1583): `tasks` with arguments filters and pages a short form a session can read; with
|
||||||
|
// none it is the whole board, as the Console and `mosaic tasks` read it.
|
||||||
|
const authority = { withinRole: ['message.send', 'task.create'], crossRole: [] };
|
||||||
|
const roles = {
|
||||||
|
pm: { authority, tracker: { bot: 'bot-pm', botId: 101 } },
|
||||||
|
coder: { authority, tracker: { bot: 'bot-coder', botId: 102 } },
|
||||||
|
reviewer: { authority, tracker: null },
|
||||||
|
};
|
||||||
|
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles } };
|
||||||
|
const at = '2026-10-12T12:00:00.000Z';
|
||||||
|
const digest = 'd'.repeat(64);
|
||||||
|
const N = 500;
|
||||||
|
const words = 'the board holds this task so a session can page through it without the description '.repeat(30);
|
||||||
|
const ref = (i) => `vikunja:3/${i}`;
|
||||||
|
// Task i: done when i % 5 == 0; the pm's bot on i % 3 == 0, the coder's on i % 4 == 0, a person (7)
|
||||||
|
// on i % 2 == 0; label 9 on i % 7 == 0; tombstones at 13 and 26.
|
||||||
|
const fieldsOf = (i) => ({
|
||||||
|
project_id: 3,
|
||||||
|
title: `Task ${i}: a title of ordinary length for a tracker card`,
|
||||||
|
description: `<p>${i} ${words}</p>`,
|
||||||
|
done: i % 5 === 0,
|
||||||
|
due_date: null,
|
||||||
|
priority: i % 4,
|
||||||
|
percent_done: 0,
|
||||||
|
bucket: i % 5 === 0 ? 12 : 11,
|
||||||
|
labels: i % 7 === 0 ? [9, 4] : [4],
|
||||||
|
assignees: [...(i % 3 === 0 ? [101] : []), ...(i % 4 === 0 ? [102] : []), ...(i % 2 === 0 ? [7] : [])],
|
||||||
|
});
|
||||||
|
const GONE = new Set([13, 26]);
|
||||||
|
|
||||||
|
function setup(t) {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'bus-task-page-'));
|
||||||
|
const store = new Store(root);
|
||||||
|
const b = new Broker({ store, businesses });
|
||||||
|
t.after(() => {
|
||||||
|
store.close();
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||||
|
const pm = b.bindLaunch({ business: 'demo', role: 'pm', run: 'demo-pm', harness: 'claude-code' });
|
||||||
|
b.request(pm, { verb: 'role.claim' });
|
||||||
|
b.recordTask({
|
||||||
|
business: 'demo',
|
||||||
|
snapshots: Array.from({ length: N }, (_, k) => k + 1).map((i) => ({
|
||||||
|
task_ref: ref(i),
|
||||||
|
updated: at,
|
||||||
|
digest,
|
||||||
|
fields: GONE.has(i) ? { gone: 'no-access' } : fieldsOf(i),
|
||||||
|
via: GONE.has(i) ? 'task' : 'board',
|
||||||
|
read_at: at,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
// Task 2 and task 6 were created by the stack, under two requirements.
|
||||||
|
for (const [i, requirement] of [[2, 'REQ-SLICE-1'], [6, 'REQ-SLICE-2']]) {
|
||||||
|
const { request } = b.request(human, { verb: 'message.send', args: { to: 'pm', body: 'please' } });
|
||||||
|
b.recordTask({
|
||||||
|
cap: pm,
|
||||||
|
business: 'demo',
|
||||||
|
snapshots: [{ task_ref: ref(i), updated: at, digest, fields: fieldsOf(i) }],
|
||||||
|
events: [{ kind: 'task.created', subject: ref(i), body: { request, requirement } }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const tasks = (args, cap = pm) => b.request(cap, { verb: 'tasks', args });
|
||||||
|
return { b, human, pm, reader: b.bindReader({ business: 'demo' }), tasks };
|
||||||
|
}
|
||||||
|
const refusesWith = (code) => (e) => e.code === code;
|
||||||
|
// Every task, in the broker's order (task_ref, as text).
|
||||||
|
const ALL = Array.from({ length: N }, (_, k) => k + 1).sort((x, y) => (ref(x) < ref(y) ? -1 : 1));
|
||||||
|
const live = (keep) => ALL.filter((i) => !GONE.has(i) && keep(i)).map(ref);
|
||||||
|
// Every page of a read, by following next.
|
||||||
|
function pages(tasks, args) {
|
||||||
|
const out = [];
|
||||||
|
let after;
|
||||||
|
for (;;) {
|
||||||
|
const page = tasks(after === undefined ? args : { ...args, after });
|
||||||
|
out.push(page);
|
||||||
|
if (page.next === null) return out;
|
||||||
|
after = page.next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the session default fits under the reply cap and pages to the end with next', (t) => {
|
||||||
|
const { tasks } = setup(t);
|
||||||
|
const whole = tasks({});
|
||||||
|
assert.equal(whole.length, N);
|
||||||
|
assert.ok(JSON.stringify(whole).length > 20 * REPLY_CAP, 'the whole board is far over the cap');
|
||||||
|
const read = pages(tasks, { done: false });
|
||||||
|
const open = live((i) => i % 5 !== 0);
|
||||||
|
assert.equal(read.length, Math.ceil(open.length / TASK_PAGE));
|
||||||
|
for (const page of read) {
|
||||||
|
assert.ok(JSON.stringify(page).length < REPLY_CAP, `${JSON.stringify(page).length}`);
|
||||||
|
assert.equal(page.total, open.length);
|
||||||
|
assert.ok(page.tasks.length <= TASK_PAGE);
|
||||||
|
}
|
||||||
|
assert.deepEqual(read.flatMap((p) => p.tasks.map((x) => x.task_ref)), open);
|
||||||
|
assert.deepEqual(read.slice(0, -1).map((p) => p.next), read.slice(0, -1).map((p) => p.tasks.at(-1).task_ref));
|
||||||
|
assert.equal(read.at(-1).next, null);
|
||||||
|
// limit at its most pages the same tasks in fewer pages.
|
||||||
|
const big = pages(tasks, { done: false, limit: TASK_PAGE_MAX });
|
||||||
|
assert.equal(big.length, Math.ceil(open.length / TASK_PAGE_MAX));
|
||||||
|
assert.deepEqual(big.flatMap((p) => p.tasks.map((x) => x.task_ref)), open);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the short form: no description, assignees by role, every listed key', (t) => {
|
||||||
|
const { tasks } = setup(t);
|
||||||
|
const whole = new Map(tasks({}).map((r) => [r.task_ref, r]));
|
||||||
|
const page = tasks({ limit: TASK_PAGE_MAX });
|
||||||
|
for (const x of page.tasks) {
|
||||||
|
const row = whole.get(x.task_ref);
|
||||||
|
const i = Number(x.task_ref.split('/')[1]);
|
||||||
|
assert.deepEqual(x, {
|
||||||
|
task_ref: row.task_ref,
|
||||||
|
title: row.fields.title,
|
||||||
|
done: row.fields.done,
|
||||||
|
bucket: row.fields.bucket,
|
||||||
|
assignees: [...(i % 4 === 0 ? ['coder'] : []), ...(i % 3 === 0 ? ['pm'] : [])],
|
||||||
|
labels: row.fields.labels,
|
||||||
|
priority: row.priority,
|
||||||
|
requirement: row.requirement,
|
||||||
|
changed_at: row.at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const two = page.tasks.find((x) => x.task_ref === ref(2));
|
||||||
|
assert.equal(two.requirement, 'REQ-SLICE-1');
|
||||||
|
assert.equal(JSON.stringify(page).includes('description'), false);
|
||||||
|
// A tombstone is on the whole board, never in a filtered read.
|
||||||
|
assert.deepEqual(whole.get(ref(13)).fields, { gone: 'no-access' });
|
||||||
|
const every = pages(tasks, { limit: TASK_PAGE_MAX }).flatMap((p) => p.tasks.map((x) => x.task_ref));
|
||||||
|
assert.deepEqual(every, live(() => true));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each filter, alone and together, and total counts every match', (t) => {
|
||||||
|
const { tasks } = setup(t);
|
||||||
|
const refs = (args) => pages(tasks, { ...args, limit: TASK_PAGE_MAX }).flatMap((p) => p.tasks.map((x) => x.task_ref));
|
||||||
|
assert.deepEqual(refs({ done: true }), live((i) => i % 5 === 0));
|
||||||
|
assert.deepEqual(refs({ done: false }), live((i) => i % 5 !== 0));
|
||||||
|
assert.deepEqual(refs({ assignee: 'pm' }), live((i) => i % 3 === 0));
|
||||||
|
assert.deepEqual(refs({ assignee: 'coder' }), live((i) => i % 4 === 0));
|
||||||
|
// A role with no tracker bot holds no task; a person's assignment is no role's.
|
||||||
|
assert.deepEqual(refs({ assignee: 'reviewer' }), []);
|
||||||
|
assert.deepEqual(refs({ label: 9 }), live((i) => i % 7 === 0));
|
||||||
|
assert.deepEqual(refs({ label: 4 }), live(() => true));
|
||||||
|
assert.deepEqual(refs({ label: 5 }), []);
|
||||||
|
assert.deepEqual(refs({ requirement: 'REQ-SLICE-2' }), [ref(6)]);
|
||||||
|
assert.deepEqual(refs({ requirement: 'REQ-NONE-1' }), []);
|
||||||
|
assert.deepEqual(refs({ done: false, assignee: 'pm', label: 9 }), live((i) => i % 5 !== 0 && i % 3 === 0 && i % 7 === 0));
|
||||||
|
const page = tasks({ done: true, assignee: 'coder', limit: 3 });
|
||||||
|
assert.equal(page.total, live((i) => i % 5 === 0 && i % 4 === 0).length);
|
||||||
|
assert.equal(page.tasks.length, 3);
|
||||||
|
assert.equal(page.next, page.tasks[2].task_ref);
|
||||||
|
// after needn't be a task on the board, or one the filters match: the page starts past it, in
|
||||||
|
// text order (3/500 < 3/501 < 3/51), and total still counts every match.
|
||||||
|
const past = tasks({ after: 'vikunja:3/501', limit: 2 });
|
||||||
|
assert.deepEqual(past.tasks.map((x) => x.task_ref), [ref(51), ref(52)]);
|
||||||
|
assert.equal(past.total, live(() => true).length);
|
||||||
|
assert.deepEqual(tasks({ done: true, after: ref(51), limit: 1 }).tasks.map((x) => x.task_ref), [ref(55)]);
|
||||||
|
const last = tasks({ after: 'vikunja:9/1' });
|
||||||
|
assert.deepEqual(last, { tasks: [], total: live(() => true).length, next: null });
|
||||||
|
// A page that ends exactly on the last match has no next.
|
||||||
|
const exact = tasks({ requirement: 'REQ-SLICE-1', limit: 1 });
|
||||||
|
assert.deepEqual([exact.tasks.length, exact.next], [1, null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('task_ref reads one task in full, as the whole board has it', (t) => {
|
||||||
|
const { tasks, human, reader } = setup(t);
|
||||||
|
const whole = tasks({});
|
||||||
|
for (const r of [ref(2), ref(13), ref(500)]) assert.deepEqual(tasks({ task_ref: r }), whole.find((x) => x.task_ref === r));
|
||||||
|
assert.ok(tasks({ task_ref: ref(2) }).fields.description.length > 2000);
|
||||||
|
assert.deepEqual(tasks({ task_ref: ref(2) }, human), tasks({ task_ref: ref(2) }, reader));
|
||||||
|
assert.throws(() => tasks({ task_ref: 'vikunja:3/501' }), refusesWith('task-not-found'));
|
||||||
|
assert.throws(() => tasks({ task_ref: 'vikunja:4/2' }), refusesWith('task-not-found'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no arguments is the read the Console and mosaic make, unchanged', (t) => {
|
||||||
|
const { b, tasks, human, reader } = setup(t);
|
||||||
|
const rows = b.taskView('demo', 'current');
|
||||||
|
const whole = tasks({});
|
||||||
|
assert.deepEqual(whole.map(({ priority, requirement, ...r }) => r), rows);
|
||||||
|
assert.deepEqual(tasks({}, human), whole);
|
||||||
|
assert.deepEqual(tasks({}, reader), whole);
|
||||||
|
assert.deepEqual(b.request(reader, { verb: 'tasks' }), whole);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown argument or a bad value refuses invalid-request', (t) => {
|
||||||
|
const { tasks, reader } = setup(t);
|
||||||
|
const bad = [
|
||||||
|
{ status: 'open' },
|
||||||
|
{ description: 'x' },
|
||||||
|
{ done: 'false' },
|
||||||
|
{ done: 0 },
|
||||||
|
{ done: null },
|
||||||
|
{ assignee: 'cto' },
|
||||||
|
{ assignee: 'toString' },
|
||||||
|
{ assignee: 101 },
|
||||||
|
{ label: 0 },
|
||||||
|
{ label: -1 },
|
||||||
|
{ label: 1.5 },
|
||||||
|
{ label: '9' },
|
||||||
|
{ requirement: 'REQ-slice-1' },
|
||||||
|
{ requirement: 'REQ-SLICE-0' },
|
||||||
|
{ requirement: 7 },
|
||||||
|
{ limit: 0 },
|
||||||
|
{ limit: TASK_PAGE_MAX + 1 },
|
||||||
|
{ limit: 2.5 },
|
||||||
|
{ limit: '50' },
|
||||||
|
{ after: 'vikunja:3' },
|
||||||
|
{ after: 3 },
|
||||||
|
{ task_ref: 'vikunja:3/x' },
|
||||||
|
{ task_ref: ref(2), done: false },
|
||||||
|
{ task_ref: ref(2), limit: 1 },
|
||||||
|
];
|
||||||
|
for (const args of bad) {
|
||||||
|
assert.throws(() => tasks(args), refusesWith('invalid-request'), JSON.stringify(args));
|
||||||
|
assert.throws(() => tasks(args, reader), refusesWith('invalid-request'), JSON.stringify(args));
|
||||||
|
}
|
||||||
|
assert.deepEqual([TASK_PAGE, TASK_PAGE_MAX], [50, 200]);
|
||||||
|
});
|
||||||
@@ -41,7 +41,10 @@ writes the bundle once into `dir` (0700; every file 0600, created with `wx`,
|
|||||||
so an existing bundle is never overwritten):
|
so an existing bundle is never overwritten):
|
||||||
|
|
||||||
- `prompt.md`: the role contract, then a generated "This session" section
|
- `prompt.md`: the role contract, then a generated "This session" section
|
||||||
(business, instance, run, who launched it, workspace, typed tools).
|
(business, instance, run, who launched it, workspace, built-in and typed
|
||||||
|
tools). The built-in tools are named as the harness names them, as the
|
||||||
|
manifest lists them: `limits.tools` for Pi, `claudeBuiltins` for Claude
|
||||||
|
Code (Read, Write, Edit, Grep, Glob).
|
||||||
- `policy.json`: harness, workspace, built-in tools (`limits.tools`), typed
|
- `policy.json`: harness, workspace, built-in tools (`limits.tools`), typed
|
||||||
tool names, network, the services the role needs, and the credential
|
tool names, network, the services the role needs, and the credential
|
||||||
status the host read from the broker (metadata only, never a token).
|
status the host read from the broker (metadata only, never a token).
|
||||||
@@ -71,6 +74,20 @@ The harness never holds the capability. Tools call the runner's tool socket
|
|||||||
against `tools.json` and forwards the call with its own capability. A
|
against `tools.json` and forwards the call with its own capability. A
|
||||||
refusal comes back to the model as `refused: <code>`.
|
refusal comes back to the model as `refused: <code>`.
|
||||||
|
|
||||||
|
A reply goes to the model as compact JSON, from the MCP server and the Pi
|
||||||
|
extension alike (`replyText` in `tools.mjs`). Over `REPLY_CAP`, 40000
|
||||||
|
characters, the tool returns an error that names the reply's size and the
|
||||||
|
cap and asks the session to narrow the query, never a cut-off reply. The
|
||||||
|
pinned Claude CLI (2.1.296) saves an MCP result over 50000 characters to a
|
||||||
|
file outside the workspace, which the gate refuses to read (row 89, #1583;
|
||||||
|
`agents/dewey/work/queue-89/build.md` has how that size was found).
|
||||||
|
|
||||||
|
`list_tasks` (tracker holders) takes the broker's `tasks` filters, `done`,
|
||||||
|
`assignee`, `label`, `requirement`, `limit` and `after`, and sends
|
||||||
|
`done: false` when the session leaves `done` out, so its default is open
|
||||||
|
tasks, 50 at a time, without descriptions. `get_task` takes a `task_ref`
|
||||||
|
and returns that task in full. `packages/bus/README.md` has the reply.
|
||||||
|
|
||||||
## The gate
|
## The gate
|
||||||
|
|
||||||
`gate.mjs` `decide(policy, tool, input)` answers one tool call: the
|
`gate.mjs` `decide(policy, tool, input)` answers one tool call: the
|
||||||
@@ -188,6 +205,13 @@ These are limits, not bugs, and nothing in this package claims otherwise.
|
|||||||
- **Resolution** runs without the project layer, there is no skills source
|
- **Resolution** runs without the project layer, there is no skills source
|
||||||
(bundles list none), and the resolved `thinking` level is recorded in the
|
(bundles list none), and the resolved `thinking` level is recorded in the
|
||||||
manifest but not applied to either harness.
|
manifest but not applied to either harness.
|
||||||
|
- **The reply cap is per reply.** Claude Code also persists the largest
|
||||||
|
results of one message when their total passes 200000 characters, so
|
||||||
|
six or more replies near the cap in one turn can still be saved to a
|
||||||
|
file. A single task whose full row is over the cap (a description near
|
||||||
|
40000 characters) can't be read through `get_task`. The cap matches
|
||||||
|
Claude CLI 2.1.296; the binary the bus unit names updates itself, so a
|
||||||
|
later version can move the threshold.
|
||||||
- **`--restricted`** (Claude Code) is not one of the S0 lines. It is what
|
- **`--restricted`** (Claude Code) is not one of the S0 lines. It is what
|
||||||
keeps `CLAUDE.md` files and auto-memory out of the prompt (above).
|
keeps `CLAUDE.md` files and auto-memory out of the prompt (above).
|
||||||
- **Other spellings.** A read is refused when a spelling Pi might open
|
- **Other spellings.** A read is refused when a spelling Pi might open
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ function prompt({ contract, resolved, session, tools, untyped }) {
|
|||||||
`- Business: ${resolved.business}. Role instance: ${resolved.instance} (definition ${resolved.definition}). Run: ${session.run}.`,
|
`- Business: ${resolved.business}. Role instance: ${resolved.instance} (definition ${resolved.definition}). Run: ${session.run}.`,
|
||||||
`- Launched by: ${session.launchedBy}. Harness: ${session.harness}, model ${session.model}.`,
|
`- Launched by: ${session.launchedBy}. Harness: ${session.harness}, model ${session.model}.`,
|
||||||
`- Workspace: ${session.workspace}. File tools work inside it only.`,
|
`- Workspace: ${session.workspace}. File tools work inside it only.`,
|
||||||
`- Built-in tools: ${resolved.limits.tools.length ? resolved.limits.tools.join(", ") : "none"}.`,
|
// Row 89 (#1583): the names the harness gives its built-ins, as the manifest lists them.
|
||||||
|
`- Built-in tools: ${(session.harness === "pi" ? resolved.limits.tools : claudeBuiltins({ tools: resolved.limits.tools })).join(", ") || "none"}.`,
|
||||||
`- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal names its reason.`,
|
`- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal names its reason.`,
|
||||||
"- Each message to you arrives as one turn. Your final answer goes back to the sender as the reply, so end each turn with the answer itself.",
|
"- Each message to you arrives as one turn. Your final answer goes back to the sender as the reply, so end each turn with the answer itself.",
|
||||||
"- You never hold a token. You act on the tracker, the forge and other roles only through the bus tools.",
|
"- You never hold a token. You act on the tracker, the forge and other roles only through the bus tools.",
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// Minimal stdio MCP server that gives a Claude Code session its typed
|
// Minimal stdio MCP server that gives a Claude Code session its typed
|
||||||
// tools: `node mcp-server.mjs <tools.json> <tool socket>`. Newline-delimited
|
// tools: `node mcp-server.mjs <tools.json> <tool socket>`. Newline-delimited
|
||||||
// JSON-RPC 2.0 with initialize, tools/list, tools/call and ping. Each call
|
// JSON-RPC 2.0 with initialize, tools/list, tools/call and ping. Each call
|
||||||
// goes to the runner's tool socket; the server holds no capability.
|
// goes to the runner's tool socket; the server holds no capability. A
|
||||||
|
// reply over REPLY_CAP is a tool error (tools.mjs, replyText).
|
||||||
|
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { createInterface } from "node:readline";
|
import { createInterface } from "node:readline";
|
||||||
import { callTool } from "./tools.mjs";
|
import { callTool, replyText } from "./tools.mjs";
|
||||||
|
|
||||||
const [toolsFile, socket] = process.argv.slice(2);
|
const [toolsFile, socket] = process.argv.slice(2);
|
||||||
if (!toolsFile || !socket) {
|
if (!toolsFile || !socket) {
|
||||||
@@ -37,7 +38,7 @@ async function handle(m) {
|
|||||||
if (!byName.has(name)) return error(id, -32602, `unknown tool: ${name}`);
|
if (!byName.has(name)) return error(id, -32602, `unknown tool: ${name}`);
|
||||||
try {
|
try {
|
||||||
const result = await callTool(socket, name, m.params?.arguments ?? {});
|
const result = await callTool(socket, name, m.params?.arguments ?? {});
|
||||||
return reply(id, { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] });
|
return reply(id, { content: [{ type: "text", text: replyText(result) }] });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return reply(id, { content: [{ type: "text", text: e.message }], isError: true });
|
return reply(id, { content: [{ type: "text", text: e.message }], isError: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
import { readFileSync, writeFileSync } from "node:fs";
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
import { decide } from "./gate.mjs";
|
import { decide } from "./gate.mjs";
|
||||||
import { callTool } from "./tools.mjs";
|
import { callTool, replyText } from "./tools.mjs";
|
||||||
|
|
||||||
function need(name) {
|
function need(name) {
|
||||||
const value = process.env[name];
|
const value = process.env[name];
|
||||||
@@ -40,9 +40,9 @@ export default function (pi) {
|
|||||||
description: t.description,
|
description: t.description,
|
||||||
parameters: t.parameters,
|
parameters: t.parameters,
|
||||||
async execute(_toolCallId, params) {
|
async execute(_toolCallId, params) {
|
||||||
// A refusal throws, which pi reports to the model as a tool error.
|
// A refusal, or a reply over REPLY_CAP, throws, which pi reports to the model as a tool error.
|
||||||
const result = await callTool(socket, t.name, params ?? {});
|
const result = await callTool(socket, t.name, params ?? {});
|
||||||
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: {} };
|
return { content: [{ type: "text", text: replyText(result) }], details: {} };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ const str = (description, extra = {}) => ({ type: "string", description, ...extr
|
|||||||
const obj = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
|
const obj = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
|
||||||
|
|
||||||
const TASK_REF = str("Task reference, as list_tasks shows it");
|
const TASK_REF = str("Task reference, as list_tasks shows it");
|
||||||
|
// The broker's page size for `tasks`, by default and at most (TASK_PAGE and TASK_PAGE_MAX in
|
||||||
|
// packages/bus/src/broker.mjs; a test holds them equal). Kept here so the session's tool server
|
||||||
|
// doesn't load the broker.
|
||||||
|
const TASK_PAGE = 50;
|
||||||
|
const TASK_PAGE_MAX = 200;
|
||||||
const COMMON = {
|
const COMMON = {
|
||||||
task_ref: TASK_REF,
|
task_ref: TASK_REF,
|
||||||
decision: str("Id of a resolved decision that approves this call; needed when the action is cross-role or gated for you"),
|
decision: str("Id of a resolved decision that approves this call; needed when the action is cross-role or gated for you"),
|
||||||
@@ -113,10 +118,39 @@ export function typedTools({ resolved, arbiter = false, tracker = false }) {
|
|||||||
add("list_agents", "agents", null, "List the role instances that hold a claim now.", obj({}));
|
add("list_agents", "agents", null, "List the role instances that hold a claim now.", obj({}));
|
||||||
add("inbox", "inbox", null, "List the open decisions routed to you.", obj({}));
|
add("inbox", "inbox", null, "List the open decisions routed to you.", obj({}));
|
||||||
add("trail", "trail", null, "Show the evidence trail for a subject: a run, message, decision or task.", obj({ subject: str("Subject id") }, ["subject"]));
|
add("trail", "trail", null, "Show the evidence trail for a subject: a run, message, decision or task.", obj({ subject: str("Subject id") }, ["subject"]));
|
||||||
if (tracker) add("list_tasks", "tasks", null, "List the business's tracker tasks as the broker last saw them.", obj({}));
|
if (tracker) {
|
||||||
|
add("list_tasks", "tasks", null, `List the business's tracker tasks as the broker last saw them, in short form with no description. By default open tasks (done false), ${TASK_PAGE} at a time; pass the reply's next as after for the next page. get_task reads one task in full.`, obj({
|
||||||
|
done: { type: "boolean", description: "true for closed tasks, false (the default) for open ones" },
|
||||||
|
assignee: str("Only tasks assigned to this role"),
|
||||||
|
label: { type: "integer", minimum: 1, description: "Only tasks with this label id" },
|
||||||
|
requirement: str("Only tasks created for this requirement id", { pattern: "^REQ-[A-Z]+-[1-9][0-9]*$" }),
|
||||||
|
limit: { type: "integer", minimum: 1, maximum: TASK_PAGE_MAX, description: `Tasks per page, ${TASK_PAGE} by default` },
|
||||||
|
after: str("The next value of the previous page"),
|
||||||
|
}));
|
||||||
|
add("get_task", "tasks", null, "Read one tracker task in full, description included.", obj({ task_ref: TASK_REF }, ["task_ref"]));
|
||||||
|
}
|
||||||
return tools;
|
return tools;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Arguments a tool sends when the session leaves them out. list_tasks always filters: the
|
||||||
|
// broker's `tasks` with no arguments is the whole board, which is the Console's read.
|
||||||
|
const ARG_DEFAULTS = { list_tasks: { done: false } };
|
||||||
|
|
||||||
|
// The most characters a tool reply may hold. The pinned Claude CLI (2.1.296, decision 91) saves
|
||||||
|
// an MCP result over 50000 characters to a file outside the workspace, which the run's file guard
|
||||||
|
// refuses to read (#1583; agents/dewey/work/queue-89/build.md has how that was found). The cap keeps
|
||||||
|
// 20% under it, counts as JavaScript does (UTF-16 units, as the CLI does), and holds for pi too.
|
||||||
|
export const REPLY_CAP = 40_000;
|
||||||
|
|
||||||
|
// The text a tool returns to the model: compact JSON, or an error over the cap. A reply is never
|
||||||
|
// cut short.
|
||||||
|
export function replyText(result) {
|
||||||
|
const text = JSON.stringify(result) ?? "null";
|
||||||
|
if (text.length > REPLY_CAP)
|
||||||
|
throw new Error(`reply too large: ${text.length} characters, over the ${REPLY_CAP}-character cap. Narrow the query: a filter, a smaller limit, or get_task for one task.`);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
// Held actions that have no typed tool. The manifest lists them so a
|
// Held actions that have no typed tool. The manifest lists them so a
|
||||||
// reviewer sees what the session can't do through the bus.
|
// reviewer sees what the session can't do through the bus.
|
||||||
export function untypedActions(resolved, tools) {
|
export function untypedActions(resolved, tools) {
|
||||||
@@ -137,7 +171,7 @@ export function callTool(path, tool, args = {}, { timeout = 60_000 } = {}) {
|
|||||||
error ? reject(error) : resolve(value);
|
error ? reject(error) : resolve(value);
|
||||||
};
|
};
|
||||||
socket.setTimeout(timeout, () => end(new Error("tool call timed out")));
|
socket.setTimeout(timeout, () => end(new Error("tool call timed out")));
|
||||||
socket.on("connect", () => socket.write(JSON.stringify({ tool, args }) + "\n"));
|
socket.on("connect", () => socket.write(JSON.stringify({ tool, args: { ...ARG_DEFAULTS[tool], ...args } }) + "\n"));
|
||||||
socket.on("error", (e) => end(new Error(`tool socket: ${e.code ?? e.message}`)));
|
socket.on("error", (e) => end(new Error(`tool socket: ${e.code ?? e.message}`)));
|
||||||
socket.on("end", () => end(new Error("tool socket closed without a reply")));
|
socket.on("end", () => end(new Error("tool socket closed without a reply")));
|
||||||
socket.on("data", (b) => {
|
socket.on("data", (b) => {
|
||||||
|
|||||||
@@ -87,6 +87,20 @@ test("a claude-code bundle adds the wrapped gate hook and the MCP config", (t) =
|
|||||||
assert.deepEqual(Object.keys(m.sources).sort(), ["claudeGate", "gate", "mcpServer", "tools"]);
|
assert.deepEqual(Object.keys(m.sources).sort(), ["claudeGate", "gate", "mcpServer", "tools"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Row 89 (#1583): the prompt names the built-ins as the session's harness names them.
|
||||||
|
test("the prompt's built-in tools line is the harness's own names, as the manifest lists them", (t) => {
|
||||||
|
const line = (b) => readFileSync(b.files.prompt, "utf8").split("\n").find((l) => l.startsWith("- Built-in tools: "));
|
||||||
|
const pi = build(t, resolvedFor(t, "pm"));
|
||||||
|
assert.equal(line(pi), "- Built-in tools: read, write, edit, grep, find, ls.");
|
||||||
|
const claude = build(t, resolvedFor(t, "pm", (d) => (d.roles.pm.vars = { ...d.roles.pm.vars, harness: "claude-code", model: "claude-opus-5-5" })));
|
||||||
|
assert.equal(line(claude), "- Built-in tools: Read, Write, Edit, Grep, Glob.");
|
||||||
|
for (const b of [pi, claude]) assert.equal(line(b), `- Built-in tools: ${b.manifest.builtins.join(", ")}.`);
|
||||||
|
for (const harness of ["pi", "claude-code"]) {
|
||||||
|
const none = build(t, resolvedFor(t, "pm", (d) => (d.roles.pm.vars = { ...d.roles.pm.vars, harness, model: "claude-opus-5-5", "limits.tools": [] })));
|
||||||
|
assert.equal(line(none), "- Built-in tools: none.", harness);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("a bundle is written once: an existing file refuses", (t) => {
|
test("a bundle is written once: an existing file refuses", (t) => {
|
||||||
const dir = scratch(t);
|
const dir = scratch(t);
|
||||||
mkdirSync(join(dir, "bundle"));
|
mkdirSync(join(dir, "bundle"));
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { writeFileSync } from "node:fs";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { createInterface } from "node:readline";
|
import { createInterface } from "node:readline";
|
||||||
import { SOURCES } from "../src/bundle.mjs";
|
import { SOURCES } from "../src/bundle.mjs";
|
||||||
import { typedTools } from "../src/tools.mjs";
|
import { REPLY_CAP, typedTools } from "../src/tools.mjs";
|
||||||
import { fakeToolSocket, resolvedFor, scratch } from "./helpers.mjs";
|
import { fakeToolSocket, resolvedFor, scratch } from "./helpers.mjs";
|
||||||
|
|
||||||
// The server over stdio: send(message) resolves with the reply to its id.
|
// The server over stdio: send(message) resolves with the reply to its id.
|
||||||
@@ -43,6 +43,8 @@ async function setup(t) {
|
|||||||
writeFileSync(toolsFile, JSON.stringify(tools));
|
writeFileSync(toolsFile, JSON.stringify(tools));
|
||||||
const sock = await fakeToolSocket(t, dir, (tool, args) => {
|
const sock = await fakeToolSocket(t, dir, (tool, args) => {
|
||||||
if (tool === "launch") throw new Error("capacity-full");
|
if (tool === "launch") throw new Error("capacity-full");
|
||||||
|
// trail answers a string `size` characters long once JSON-encoded, for the reply cap.
|
||||||
|
if (tool === "trail" && /^size:/.test(args.subject)) return "x".repeat(Number(args.subject.slice(5)) - 2);
|
||||||
return { tool, args };
|
return { tool, args };
|
||||||
});
|
});
|
||||||
return { dir, tools, sock, server: serve(t, toolsFile, sock.path) };
|
return { dir, tools, sock, server: serve(t, toolsFile, sock.path) };
|
||||||
@@ -70,6 +72,25 @@ test("tools/call goes through the tool socket; a refusal is an isError result",
|
|||||||
assert.deepEqual(sock.calls.map((c) => c.tool), ["list_agents", "launch"]);
|
assert.deepEqual(sock.calls.map((c) => c.tool), ["list_agents", "launch"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("replies are compact JSON; over the cap a tool error names the size and the cap", async (t) => {
|
||||||
|
const { sock, server } = await setup(t);
|
||||||
|
const call = async (name, args) => (await server.send("tools/call", { name, arguments: args })).result;
|
||||||
|
const ok = await call("get_task", { task_ref: "vikunja:3/7" });
|
||||||
|
assert.equal(ok.content[0].text, '{"tool":"get_task","args":{"task_ref":"vikunja:3/7"}}');
|
||||||
|
// list_tasks with no arguments asks the broker for open tasks.
|
||||||
|
assert.equal((await call("list_tasks", {})).content[0].text, '{"tool":"list_tasks","args":{"done":false}}');
|
||||||
|
const fits = await call("trail", { subject: `size:${REPLY_CAP}` });
|
||||||
|
assert.equal(fits.isError, undefined);
|
||||||
|
assert.equal(fits.content[0].text.length, REPLY_CAP);
|
||||||
|
const over = await call("trail", { subject: `size:${REPLY_CAP + 1}` });
|
||||||
|
assert.equal(over.isError, true);
|
||||||
|
assert.deepEqual(over.content, [{
|
||||||
|
type: "text",
|
||||||
|
text: `reply too large: ${REPLY_CAP + 1} characters, over the ${REPLY_CAP}-character cap. Narrow the query: a filter, a smaller limit, or get_task for one task.`,
|
||||||
|
}]);
|
||||||
|
assert.deepEqual(sock.calls.map((c) => c.tool), ["get_task", "list_tasks", "trail", "trail"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("unknown tools and methods are JSON-RPC errors and never reach the socket", async (t) => {
|
test("unknown tools and methods are JSON-RPC errors and never reach the socket", async (t) => {
|
||||||
const { sock, server } = await setup(t);
|
const { sock, server } = await setup(t);
|
||||||
const unknown = await server.send("tools/call", { name: "resolve_decision", arguments: {} });
|
const unknown = await server.send("tools/call", { name: "resolve_decision", arguments: {} });
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { existsSync, mkdirSync, readdirSync, symlinkSync, writeFileSync } from "
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { buildBundle } from "../src/bundle.mjs";
|
import { buildBundle } from "../src/bundle.mjs";
|
||||||
import { adapterEnv } from "../src/runner.mjs";
|
import { adapterEnv } from "../src/runner.mjs";
|
||||||
|
import { REPLY_CAP } from "../src/tools.mjs";
|
||||||
import { fakeToolSocket, mockAnthropic, REPO, resolvedFor, scratch } from "./helpers.mjs";
|
import { fakeToolSocket, mockAnthropic, REPO, resolvedFor, scratch } from "./helpers.mjs";
|
||||||
|
|
||||||
const ADAPTER = join(REPO, "adapters", "pi", "adapter.sh");
|
const ADAPTER = join(REPO, "adapters", "pi", "adapter.sh");
|
||||||
@@ -25,6 +26,8 @@ async function session(t, script, place = (dir) => join(dir, "ws")) {
|
|||||||
const resolved = resolvedFor(t, "pm", (d) => (d.roles.pm.vars = { ...d.roles.pm.vars, model: "probe-model" }));
|
const resolved = resolvedFor(t, "pm", (d) => (d.roles.pm.vars = { ...d.roles.pm.vars, model: "probe-model" }));
|
||||||
const sock = await fakeToolSocket(t, dir, (tool, args) => {
|
const sock = await fakeToolSocket(t, dir, (tool, args) => {
|
||||||
if (tool === "launch") throw new Error("capacity-full");
|
if (tool === "launch") throw new Error("capacity-full");
|
||||||
|
// trail answers a string `size` characters long once JSON-encoded, for the reply cap.
|
||||||
|
if (tool === "trail" && /^size:/.test(args.subject)) return "x".repeat(Number(args.subject.slice(5)) - 2);
|
||||||
return { tool, args };
|
return { tool, args };
|
||||||
});
|
});
|
||||||
const s = {
|
const s = {
|
||||||
@@ -78,7 +81,7 @@ test("pi: typed tools reach the socket, the gate blocks, agent_end writes the ma
|
|||||||
assert.equal(results.length, 5);
|
assert.equal(results.length, 5);
|
||||||
// list_agents went through the socket; launch was refused by it.
|
// list_agents went through the socket; launch was refused by it.
|
||||||
assert.equal(results[0][0], false);
|
assert.equal(results[0][0], false);
|
||||||
assert.match(results[0][1], /"tool": "list_agents"/);
|
assert.match(results[0][1], /^\{"tool":"list_agents"/);
|
||||||
assert.equal(results[1][0], true);
|
assert.equal(results[1][0], true);
|
||||||
assert.match(results[1][1], /refused: capacity-full/);
|
assert.match(results[1][1], /refused: capacity-full/);
|
||||||
// read inside passes; outside is gated; bash isn't in the PM's tools.
|
// read inside passes; outside is gated; bash isn't in the PM's tools.
|
||||||
@@ -96,6 +99,30 @@ test("pi: typed tools reach the socket, the gate blocks, agent_end writes the ma
|
|||||||
assert.ok(readdirSync(s.sessionDir).length > 0);
|
assert.ok(readdirSync(s.sessionDir).length > 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("pi: a reply over the cap is a tool error naming the size and the cap; list_tasks asks for open tasks", async (t) => {
|
||||||
|
const { sock, s, env } = await session(t, [
|
||||||
|
{ name: "trail", input: { subject: `size:${REPLY_CAP}` } },
|
||||||
|
{ name: "trail", input: { subject: `size:${REPLY_CAP + 1}` } },
|
||||||
|
{ name: "list_tasks", input: {} },
|
||||||
|
{ name: "get_task", input: { task_ref: "vikunja:3/7" } },
|
||||||
|
]);
|
||||||
|
const r = await turn(env, "Message 1 from jason, class REQUEST:\n\nshow the board", s.workspace);
|
||||||
|
assert.equal(r.code, 0, r.stderr);
|
||||||
|
const results = JSON.parse(r.stdout.trim().slice("ANSWER ".length));
|
||||||
|
assert.equal(results.length, 4);
|
||||||
|
assert.deepEqual(results[0], [false, `"${"x".repeat(79)}`]);
|
||||||
|
assert.equal(results[1][0], true);
|
||||||
|
assert.match(results[1][1], new RegExp(`^reply too large: ${REPLY_CAP + 1} characters, over the ${REPLY_CAP}-character cap`));
|
||||||
|
assert.deepEqual(results[2], [false, '{"tool":"list_tasks","args":{"done":false}}']);
|
||||||
|
assert.deepEqual(results[3], [false, '{"tool":"get_task","args":{"task_ref":"vikunja:3/7"}}']);
|
||||||
|
assert.deepEqual(sock.calls.map((c) => [c.tool, c.args]), [
|
||||||
|
["trail", { subject: `size:${REPLY_CAP}` }],
|
||||||
|
["trail", { subject: `size:${REPLY_CAP + 1}` }],
|
||||||
|
["list_tasks", { done: false }],
|
||||||
|
["get_task", { task_ref: "vikunja:3/7" }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("pi: a write through a dangling symlink is blocked, and nothing appears outside", async (t) => {
|
test("pi: a write through a dangling symlink is blocked, and nothing appears outside", async (t) => {
|
||||||
const { dir, workspace, s, env } = await session(t, [
|
const { dir, workspace, s, env } = await session(t, [
|
||||||
{ name: "write", input: { path: "notes.md", content: "planted\n" } },
|
{ name: "write", input: { path: "notes.md", content: "planted\n" } },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { callTool, typedTools, untypedActions } from "../src/tools.mjs";
|
import { callTool, REPLY_CAP, replyText, typedTools, untypedActions } from "../src/tools.mjs";
|
||||||
|
import { TASK_PAGE, TASK_PAGE_MAX } from "../../bus/src/broker.mjs";
|
||||||
import { fakeToolSocket, resolvedFor, scratch } from "./helpers.mjs";
|
import { fakeToolSocket, resolvedFor, scratch } from "./helpers.mjs";
|
||||||
|
|
||||||
const names = (tools) => tools.map((t) => t.name);
|
const names = (tools) => tools.map((t) => t.name);
|
||||||
@@ -12,7 +13,7 @@ test("the PM gets launch, its task verbs and the reads", (t) => {
|
|||||||
"send_message", "launch",
|
"send_message", "launch",
|
||||||
"task_create", "task_assign", "task_reassign", "task_schedule", "task_priority_change", "task_scope_change",
|
"task_create", "task_assign", "task_reassign", "task_schedule", "task_priority_change", "task_scope_change",
|
||||||
"task_update_assigned", "task_close",
|
"task_update_assigned", "task_close",
|
||||||
"raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks",
|
"raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task",
|
||||||
]);
|
]);
|
||||||
const launch = tools.find((x) => x.name === "launch");
|
const launch = tools.find((x) => x.name === "launch");
|
||||||
assert.equal(launch.verb, "launch");
|
assert.equal(launch.verb, "launch");
|
||||||
@@ -37,7 +38,7 @@ test("a coder with a tracker gets its own task tools and no task_reassign", (t)
|
|||||||
const coder = resolvedFor(t, "coder");
|
const coder = resolvedFor(t, "coder");
|
||||||
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true })), [
|
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true })), [
|
||||||
"send_message", "task_scope_change", "task_update_assigned",
|
"send_message", "task_scope_change", "task_update_assigned",
|
||||||
"raise_decision", "list_agents", "inbox", "trail", "list_tasks",
|
"raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ test("launch only when the business's launch block names the instance as launche
|
|||||||
|
|
||||||
test("an action outside the instance's authority has no tool", (t) => {
|
test("an action outside the instance's authority has no tool", (t) => {
|
||||||
const coder = resolvedFor(t, "coder", (d) => (d.roles.coder.vars["limits.authority"] = ["review.request"]));
|
const coder = resolvedFor(t, "coder", (d) => (d.roles.coder.vars["limits.authority"] = ["review.request"]));
|
||||||
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true })), ["raise_decision", "list_agents", "inbox", "trail", "list_tasks"]);
|
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true })), ["raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("callTool: one JSON line out, the result back, a refusal rejects", async (t) => {
|
test("callTool: one JSON line out, the result back, a refusal rejects", async (t) => {
|
||||||
@@ -63,3 +64,54 @@ test("callTool: one JSON line out, the result back, a refusal rejects", async (t
|
|||||||
await assert.rejects(callTool(`${dir}/missing.sock`, "x"), /tool socket: ENOENT/);
|
await assert.rejects(callTool(`${dir}/missing.sock`, "x"), /tool socket: ENOENT/);
|
||||||
assert.deepEqual(sock.calls.map((c) => c.tool), ["list_agents", "launch"]);
|
assert.deepEqual(sock.calls.map((c) => c.tool), ["list_agents", "launch"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Row 89 (#1583): task reads a session can page, and replies that fit.
|
||||||
|
test("list_tasks takes the broker's filters and pages; get_task takes a task_ref", (t) => {
|
||||||
|
const tools = typedTools({ resolved: resolvedFor(t, "pm"), tracker: true });
|
||||||
|
const list = tools.find((x) => x.name === "list_tasks");
|
||||||
|
const get = tools.find((x) => x.name === "get_task");
|
||||||
|
assert.deepEqual([list.verb, list.action, get.verb, get.action], ["tasks", null, "tasks", null]);
|
||||||
|
const p = list.parameters;
|
||||||
|
assert.deepEqual(Object.keys(p.properties), ["done", "assignee", "label", "requirement", "limit", "after"]);
|
||||||
|
assert.deepEqual(p.required, []);
|
||||||
|
assert.equal(p.properties.done.type, "boolean");
|
||||||
|
assert.equal(p.properties.assignee.type, "string");
|
||||||
|
assert.deepEqual([p.properties.label.type, p.properties.label.minimum], ["integer", 1]);
|
||||||
|
assert.equal(p.properties.requirement.pattern, "^REQ-[A-Z]+-[1-9][0-9]*$");
|
||||||
|
assert.equal(p.properties.after.type, "string");
|
||||||
|
// The page sizes are the broker's own.
|
||||||
|
assert.deepEqual([p.properties.limit.type, p.properties.limit.minimum, p.properties.limit.maximum], ["integer", 1, TASK_PAGE_MAX]);
|
||||||
|
assert.match(p.properties.limit.description, new RegExp(`\\b${TASK_PAGE} by default`));
|
||||||
|
assert.match(list.description, new RegExp(`open tasks \\(done false\\), ${TASK_PAGE} at a time`));
|
||||||
|
assert.match(list.description, /next as after/);
|
||||||
|
assert.match(list.description, /get_task/);
|
||||||
|
assert.deepEqual(get.parameters.properties, { task_ref: tools.find((x) => x.name === "task_close").parameters.properties.task_ref });
|
||||||
|
assert.deepEqual(get.parameters.required, ["task_ref"]);
|
||||||
|
// Neither without a tracker.
|
||||||
|
assert.deepEqual(names(typedTools({ resolved: resolvedFor(t, "pm") })).filter((n) => n.endsWith("_tasks") || n === "get_task"), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("callTool sends list_tasks done false unless the session names done; other tools as given", async (t) => {
|
||||||
|
const sock = await fakeToolSocket(t, scratch(t), (tool, args) => args);
|
||||||
|
assert.deepEqual(await callTool(sock.path, "list_tasks"), { done: false });
|
||||||
|
assert.deepEqual(await callTool(sock.path, "list_tasks", { limit: 5 }), { done: false, limit: 5 });
|
||||||
|
assert.deepEqual(await callTool(sock.path, "list_tasks", { done: true }), { done: true });
|
||||||
|
assert.deepEqual(await callTool(sock.path, "get_task", { task_ref: "vikunja:3/7" }), { task_ref: "vikunja:3/7" });
|
||||||
|
assert.deepEqual(await callTool(sock.path, "list_agents"), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("replyText: compact JSON up to the cap, an error naming the size and the cap over it", () => {
|
||||||
|
assert.equal(REPLY_CAP, 40_000);
|
||||||
|
assert.equal(replyText({ a: [1, { b: "c" }] }), '{"a":[1,{"b":"c"}]}');
|
||||||
|
assert.equal(replyText(null), "null");
|
||||||
|
assert.equal(replyText(undefined), "null");
|
||||||
|
// A JSON string adds its two quotes; length counts UTF-16 units, as the CLI does.
|
||||||
|
assert.equal(replyText("x".repeat(REPLY_CAP - 2)).length, REPLY_CAP);
|
||||||
|
assert.throws(() => replyText("x".repeat(REPLY_CAP - 1)), (e) => {
|
||||||
|
assert.equal(e.message, `reply too large: ${REPLY_CAP + 1} characters, over the ${REPLY_CAP}-character cap. Narrow the query: a filter, a smaller limit, or get_task for one task.`);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
assert.throws(() => replyText("\u{1F600}".repeat(REPLY_CAP / 2)), /reply too large: 40002 characters/);
|
||||||
|
// 40000 units fit, though they are 80002 bytes: the cap isn't a byte count.
|
||||||
|
assert.equal(replyText("\u{1F600}".repeat(REPLY_CAP / 2 - 1)).length, REPLY_CAP);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user