Files
stack/packages/bus/tests/task-page.test.mjs
T
jason.woltjeandClaude Opus 5.5 ce104b27cc 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/), base
a3f6e9e6, no drift on the 12 paths. Builder Dewey. Approved by
Darkwing (comment 27940). Sage's gate on f04185fc plus 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]>
2026-10-12 17:04:15 -05:00

226 lines
10 KiB
JavaScript

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]);
});