dewey: row 89 (#1583) PM task reads packet, round 1, manifest b8c5bb3a

Candidate on base a3f6e9e6: tasks takes filters and paging, list_tasks and get_task,
a 40000-character reply cap with compact JSON, and the built-in tools line per harness.
Gate and 34 mutants in the packet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 15:45:08 -05:00
co-authored by Claude Opus 5.5
parent 57a2d7aba2
commit 4a2e18c90d
11 changed files with 1192 additions and 0 deletions
+222
View File
@@ -0,0 +1,222 @@
# Row 89 (#1583): PM task reads that fit a session, round 1
Owner: Dewey. Reviewer: Darkwing, then Sage's gate. Brief:
`docs/plans/2026-10-12_pm-task-flow.md`, section "PM task reads that fit a
session" (blob `e8a1150e`, commit `4d095b77`).
Base `a3f6e9e6` (queue rev 744). `build.patch` is
`git diff --cached --binary a3f6e9e6 --` the twelve paths in
`candidate-manifest.sha256`. Apply it with `git apply --index` to a detached
worktree of the base; the manifest then checks 12/12 OK. No commit from the
base to `refactor` HEAD `70b7b8d5` touches the twelve paths.
| File | What changed |
|---|---|
| `packages/bus/src/broker.mjs` | `tasks` takes optional arguments; `#tasks` filters and pages the short form |
| `packages/bus/tests/task-page.test.mjs` | new: six tests on a 500-task board |
| `packages/bus/README.md` | the `tasks` row and a paragraph on its arguments |
| `packages/harness/src/tools.mjs` | `list_tasks` arguments, `get_task`, the `done: false` default, `REPLY_CAP`, `replyText` |
| `packages/harness/src/mcp-server.mjs` | replies through `replyText` |
| `packages/harness/src/pi-extension.mjs` | replies through `replyText` |
| `packages/harness/src/bundle.mjs` | the built-in tools line names the harness's own tools |
| `packages/harness/tests/tools.test.mjs` | the schemas, the default, `replyText`'s boundary |
| `packages/harness/tests/mcp-server.test.mjs` | compact JSON, the default sent, the cap from the MCP server |
| `packages/harness/tests/pi-session.test.mjs` | compact JSON, the cap, `list_tasks` and `get_task` from a real pi session |
| `packages/harness/tests/bundle.test.mjs` | the tools line for each harness, and "none" |
| `packages/harness/README.md` | the tools line, compact replies, the cap, the two task tools, a limit |
## The threshold, and the cap
The bus unit runs `MOSAIC_CLAUDE_BIN=~/.local/bin/claude` (decision 91),
which is version 2.1.296 today. I found the threshold in that binary and then
confirmed it with a probe.
- **The binary.** The MCP tool's `maxResultSizeChars` is `1e5`. The function
that picks the persist threshold takes the minimum of that and
`pH=50000`. A GrowthBook flag, `tengu_velvet_ibis`, can override it per
tool. `cachedGrowthBookFeatures` is `{}` in both Claude config files on
this host, so no override applies. A result is saved to a file when the
total `.length` of its text blocks is over the threshold. The same
variable list holds `tjn=200000`, a budget per message across all tool
results. `MAX_MCP_OUTPUT_TOKENS` (25000 tokens by default) comes later
than 50000 characters for this kind of text.
- **The probe** (`probe/`). `probe-server.mjs` is a stdio MCP server whose
one tool returns a text block of exactly `n` characters. A headless
`claude -p` run of 2.1.296 (haiku, cost $0.0145) called it with 49990,
50000 and 50010 (`probe/results.txt`):
- 49990 and 50000 came back inline, at full length.
- 50010 came back as "Error: result (50,010 characters across 1 line)
exceeds maximum allowed tokens. Output has been saved to" a file under
`~/.claude/projects/`, outside the workspace. That is the PM's fault.
So the threshold is "over 50000 characters", counted as JavaScript counts
(UTF-16 units). **`REPLY_CAP` is 40000**, 20% under it. `replyText`
measures with `.length` too, so a reply of astral characters is counted the
way the CLI counts it (the tools test has a surrogate-pair case at 40002).
Over the cap, the tool throws "reply too large: N characters, over the
40000-character cap. Narrow the query: a filter, a smaller limit, or
get_task for one task." Both servers report a throw as a tool error
(`isError` on MCP, a thrown error on pi). A reply is never cut short.
Replies are compact JSON in both servers; that is the cap's input.
### Sizes on the test board
500 tasks, titles about 55 characters, descriptions over 2000:
| Read | Compact | Indented |
|---|---|---|
| `list_tasks` default (open, 50) | 11,210 | 16,618 |
| `limit: 200` | 44,698, refused by the cap | 66,303 |
| no arguments, the whole board | 1,518,073 | |
| `get_task`, one full row | 3,028 (task 2; 3,055 at most, 365 for a tombstone) | |
The default page is about a quarter of the cap. `limit: 200` with these
titles is over it, so the session gets the cap error and narrows. That's
the cap doing its job, and the reason the default is 50.
## Decisions
1. **The no-argument read is unchanged.** `tasks` with `{}` returns
`#taskRows` as before, so the Console and `mosaic tasks` read what they
did. A test compares it with `taskView` for the pm, a human and a
reader.
2. **The tool always filters.** `ARG_DEFAULTS = { list_tasks: { done: false } }`
is merged in `callTool`, under the session's own arguments. A session
can't reach the whole board through `list_tasks`, even with no
arguments; `done: true` overrides the default.
3. **`task_ref` stands alone.** With any other argument it refuses
`invalid-request`. A ref not on the board refuses `task-not-found`, a
code the broker already has. The row is today's full row, tombstones
included.
4. **Tombstones are left out of filtered reads.** A tombstone has no
title or state to show, and isn't on the board. `get_task` still
returns one by ref.
5. **Assignees are roles.** The short form lists the roles whose
`roles[*].tracker.botId` is among the task's assignees, sorted. A
person or another bot isn't a role and doesn't appear. A role whose
`tracker` is null holds no task, so `assignee` for it matches nothing.
An `assignee` that isn't a role of the business refuses (`toString`
included; the check is `Object.hasOwn`).
6. **`changed_at` is the row's `at`**, the time the broker last recorded a
change, as the Console's board shows it.
7. **Paging is in `task_ref` order as text**, the order `#taskRows`
already gives (`ORDER BY task_ref`), and `after` compares with `>` on
the same strings. `after` needn't be on the board or match the
filters; the page starts past it. `total` counts every match, not the
page. `next` is the last ref of the page, null when the page ends on
the last match.
8. **The page sizes live in two files.** `TASK_PAGE` and `TASK_PAGE_MAX`
are exported from `broker.mjs` and repeated in `tools.mjs`, so the
session's tool server doesn't load the broker. A tools test holds the
schema maximum equal to the broker's.
9. **The tools line is built in `prompt()`.** For pi it prints the role's
tools as before; for Claude Code, `claudeBuiltins({ tools })`, the
names the gate gives Claude Code. Its call site is unchanged. With no
tools, it says "none" for both.
## Limits
- **The cap is per reply.** The CLI's 200000-character budget is per
message: six or more replies near the cap in one turn could still be
saved to a file. Five at the cap total exactly 200000, which isn't over.
- **A full row over 40000 characters can't be read** through `get_task`.
The session gets the cap error. The test board's longest full row is
3,055; I haven't measured the live board's.
- **The threshold belongs to 2.1.296.** `~/.local/bin/claude` updates
itself, so a later version can move it. The harness README says so.
The probe here rechecks a new version in one run.
- **`limit: 200` can exceed the cap** on a real board with long titles,
as it does on the test board. The error tells the session to narrow.
## Gate (my run on the candidate)
Sequential, in a worktree of `a3f6e9e6` holding the candidate, `TMPDIR` in
my scratch, `test-task.sh` with `DOCKER_HOST=unix:///nonexistent`
(`gate.sh`, `gate-summary.txt`). The brief's four node suites, plus tasks
and control-board, then every `scripts/test-*.sh`.
2026-10-12T20:13:35Z to 20:18:12Z, all rc=0:
| Suite | Result |
|---|---|
| bus | 108 pass (the six in `task-page.test.mjs` among them) |
| harness | 65 pass |
| webui | 84 pass |
| cli | 115 pass |
| tasks | 74 pass |
| control-board | 124 pass |
| test-auth | 15 passed |
| test-conductor | 17 passed |
| test-config | 24 passed |
| test-discord | 66 passed |
| test-extension-package | 18 passed |
| test-foundation | 44 passed |
| test-queue | 27 passed |
| test-release | 14 passed |
| test-task | 26 passed; four Docker groups skip (adapter seam, workspace/capability, live task, live recall pair) |
After the mutants I added one assertion to `packages/harness/tests/tools.test.mjs`
(below), so the manifest's hash for that file is newer than this run. No other
suite loads that file. The harness suite on the final candidate, 20:43:56Z to
20:44:07Z: 65 pass, rc=0 (the last line of `gate-summary.txt`).
## Mutants
`mutants.py` swaps one exact string in a second worktree of the base that holds
the candidate. It runs the bus or harness node suite, restores the file and
reports killed or SURVIVED; an anchor matching anything but once is reported
and skipped (none was). 34 mutants, 34 killed in the end. The copy checked
12/12 against the manifest afterwards.
- **Run 1** (`mutants-run1.txt`, from 20:18:19Z). The first 16 were killed.
The 17th, `after ignored`, makes `pages()` in `task-page.test.mjs` follow
`next` forever. It hung until the harness's 900 s timeout, and the test
child it left behind ran on until I stopped it by PID at 20:35Z. My
harness's fault: it killed only the runner, not the runner's children.
`mutants.py` now starts each run in its own process group, kills the group
after 300 s, and reports a hang as a kill.
- **Run 2** (`mutants-run2.txt`, 20:35:08Z to 20:43:08Z), the 18 from
`after ignored` on. 17 killed, `after ignored` among them as a hang. One
survived: `cap counts bytes` (`Buffer.byteLength` for `.length`). The
surrogate-pair case at 40002 units is over the cap either way, so it can't
tell units from bytes. I added a case that fits by units and not by bytes:
19999 emoji, 40000 units and 80002 bytes, fits.
- **Run 3** (`mutants-run3.txt`, 20:43:36Z to 20:43:47Z): `cap counts bytes`
killed.
| Mutant | Site | What it undoes |
|---|---|---|
| no args filtered too | broker | the no-argument read returns the whole projection |
| tombstones kept | broker | filtered reads leave tombstones out |
| next on an exact last page | broker | `next` is null when the page ends on the last match |
| default limit ignored | broker | the page is 50 by default |
| limit max unchecked | broker | `limit` over 200 refuses |
| assignee by raw id | broker | assignees are role names |
| assignees keep persons | broker | a person's assignment shows as no role |
| task_ref with other args | broker | `task_ref` stands alone |
| missing task_ref is null | broker | a ref not on the board refuses `task-not-found` |
| done check dropped | broker | `done` must be a boolean |
| done filter dropped | broker | `done` filters |
| assignee any string | broker | `assignee` must be a role of the business |
| label check dropped | broker | `label` must be a positive integer |
| label filter dropped | broker | `label` filters |
| requirement pattern dropped | broker | `requirement` must match the id pattern |
| requirement filter dropped | broker | `requirement` filters |
| after ignored | broker | `after` starts the page (killed by hanging) |
| after inclusive | broker | the page starts past `after`, not at it |
| total counts the page | broker | `total` counts every match |
| description kept | broker | the short form has no description |
| tasks keys closed | broker | `tasks` takes its arguments at all |
| ARG_DEFAULTS removed | tools | `list_tasks` sends `done: false` |
| default overrides the caller | tools | the session's `done` wins over the default |
| cap at >= | tools | a reply of exactly 40000 fits |
| cap counts bytes | tools | the cap counts UTF-16 units, as the CLI does |
| indented JSON | tools | replies are compact |
| cap 50000 | tools | the cap is 40000 |
| mcp server uncapped | mcp-server | the MCP server applies the cap |
| pi uncapped | pi-extension | the pi extension applies the cap |
| tool limit max 500 | tools | the schema's `limit` maximum equals the broker's |
| get_task not required | tools | `get_task` requires `task_ref` |
| get_task without tracker | tools | `get_task` is for tracker holders only |
| bundle line pi names | bundle | Claude Code's line names its own tools |
| bundle none dropped | bundle | no tools says "none" |
+774
View File
@@ -0,0 +1,774 @@
diff --git a/packages/bus/README.md b/packages/bus/README.md
index 69824a30..c5ca68ad 100644
--- a/packages/bus/README.md
+++ b/packages/bus/README.md
@@ -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. |
| `event.emit` | `{kind:'action.allowed',body:{action:'routine',target?},subject?}`; routine observations 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. |
| `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. |
@@ -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
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
-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
session/claim evidence. Broker timestamps advance monotonically across all
its writes, including after reopen; per-table `seq` stays the primary table
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index 4389b353..4a3e52a4 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -42,6 +42,10 @@ const CLOSED = "('resolved','withdrawn','expired')";
export const RECENT = 50;
// How many replies `replies` returns, oldest first.
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.')));
const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
const POLL_KINDS = new Set([
@@ -328,6 +332,59 @@ export class Broker {
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 } = {}) {
this.#agent(s);
if (
@@ -841,7 +898,8 @@ export class Broker {
case 'agents':
case 'tasks':
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 (verb === 'inbox')
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.
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));
}
// Row 57 (#1546): the Inbox's "Routed to roles" and "Recent" lists, for human and
diff --git a/packages/bus/tests/task-page.test.mjs b/packages/bus/tests/task-page.test.mjs
new file mode 100644
index 00000000..0cfa0598
--- /dev/null
+++ b/packages/bus/tests/task-page.test.mjs
@@ -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]);
+});
diff --git a/packages/harness/README.md b/packages/harness/README.md
index 7598a573..1d1d7c5b 100644
--- a/packages/harness/README.md
+++ b/packages/harness/README.md
@@ -41,7 +41,10 @@ writes the bundle once into `dir` (0700; every file 0600, created with `wx`,
so an existing bundle is never overwritten):
- `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
tool names, network, the services the role needs, and the credential
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
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
`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
(bundles list none), and the resolved `thinking` level is recorded in the
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
keeps `CLAUDE.md` files and auto-memory out of the prompt (above).
- **Other spellings.** A read is refused when a spelling Pi might open
diff --git a/packages/harness/src/bundle.mjs b/packages/harness/src/bundle.mjs
index 2f16b1c8..9326be87 100644
--- a/packages/harness/src/bundle.mjs
+++ b/packages/harness/src/bundle.mjs
@@ -64,7 +64,8 @@ function prompt({ contract, resolved, session, tools, untyped }) {
`- Business: ${resolved.business}. Role instance: ${resolved.instance} (definition ${resolved.definition}). Run: ${session.run}.`,
`- Launched by: ${session.launchedBy}. Harness: ${session.harness}, model ${session.model}.`,
`- 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.`,
"- 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.",
diff --git a/packages/harness/src/mcp-server.mjs b/packages/harness/src/mcp-server.mjs
index 0f43948e..5415a55e 100644
--- a/packages/harness/src/mcp-server.mjs
+++ b/packages/harness/src/mcp-server.mjs
@@ -1,11 +1,12 @@
// Minimal stdio MCP server that gives a Claude Code session its typed
// 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
-// 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 { createInterface } from "node:readline";
-import { callTool } from "./tools.mjs";
+import { callTool, replyText } from "./tools.mjs";
const [toolsFile, socket] = process.argv.slice(2);
if (!toolsFile || !socket) {
@@ -37,7 +38,7 @@ async function handle(m) {
if (!byName.has(name)) return error(id, -32602, `unknown tool: ${name}`);
try {
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) {
return reply(id, { content: [{ type: "text", text: e.message }], isError: true });
}
diff --git a/packages/harness/src/pi-extension.mjs b/packages/harness/src/pi-extension.mjs
index 2b3b0044..5647f0f5 100644
--- a/packages/harness/src/pi-extension.mjs
+++ b/packages/harness/src/pi-extension.mjs
@@ -10,7 +10,7 @@
import { readFileSync, writeFileSync } from "node:fs";
import { decide } from "./gate.mjs";
-import { callTool } from "./tools.mjs";
+import { callTool, replyText } from "./tools.mjs";
function need(name) {
const value = process.env[name];
@@ -40,9 +40,9 @@ export default function (pi) {
description: t.description,
parameters: t.parameters,
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 ?? {});
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: {} };
+ return { content: [{ type: "text", text: replyText(result) }], details: {} };
},
});
}
diff --git a/packages/harness/src/tools.mjs b/packages/harness/src/tools.mjs
index 903f3bef..f0011d30 100644
--- a/packages/harness/src/tools.mjs
+++ b/packages/harness/src/tools.mjs
@@ -16,6 +16,11 @@ const str = (description, extra = {}) => ({ type: "string", description, ...extr
const obj = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
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 = {
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"),
@@ -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("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"]));
- 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;
}
+// 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
// reviewer sees what the session can't do through the bus.
export function untypedActions(resolved, tools) {
@@ -137,7 +171,7 @@ export function callTool(path, tool, args = {}, { timeout = 60_000 } = {}) {
error ? reject(error) : resolve(value);
};
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("end", () => end(new Error("tool socket closed without a reply")));
socket.on("data", (b) => {
diff --git a/packages/harness/tests/bundle.test.mjs b/packages/harness/tests/bundle.test.mjs
index 23deabf2..98cee8ed 100644
--- a/packages/harness/tests/bundle.test.mjs
+++ b/packages/harness/tests/bundle.test.mjs
@@ -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"]);
});
+// 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) => {
const dir = scratch(t);
mkdirSync(join(dir, "bundle"));
diff --git a/packages/harness/tests/mcp-server.test.mjs b/packages/harness/tests/mcp-server.test.mjs
index 72992dde..2219730c 100644
--- a/packages/harness/tests/mcp-server.test.mjs
+++ b/packages/harness/tests/mcp-server.test.mjs
@@ -5,7 +5,7 @@ import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { createInterface } from "node:readline";
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";
// 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));
const sock = await fakeToolSocket(t, dir, (tool, args) => {
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 { 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"]);
});
+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) => {
const { sock, server } = await setup(t);
const unknown = await server.send("tools/call", { name: "resolve_decision", arguments: {} });
diff --git a/packages/harness/tests/pi-session.test.mjs b/packages/harness/tests/pi-session.test.mjs
index 32db2aa3..95954b9d 100644
--- a/packages/harness/tests/pi-session.test.mjs
+++ b/packages/harness/tests/pi-session.test.mjs
@@ -8,6 +8,7 @@ import { existsSync, mkdirSync, readdirSync, symlinkSync, writeFileSync } from "
import { join } from "node:path";
import { buildBundle } from "../src/bundle.mjs";
import { adapterEnv } from "../src/runner.mjs";
+import { REPLY_CAP } from "../src/tools.mjs";
import { fakeToolSocket, mockAnthropic, REPO, resolvedFor, scratch } from "./helpers.mjs";
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 sock = await fakeToolSocket(t, dir, (tool, args) => {
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 };
});
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);
// list_agents went through the socket; launch was refused by it.
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.match(results[1][1], /refused: capacity-full/);
// 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);
});
+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) => {
const { dir, workspace, s, env } = await session(t, [
{ name: "write", input: { path: "notes.md", content: "planted\n" } },
diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs
index 3fe7dd1e..045f11c1 100644
--- a/packages/harness/tests/tools.test.mjs
+++ b/packages/harness/tests/tools.test.mjs
@@ -1,6 +1,7 @@
import { test } from "node:test";
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";
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",
"task_create", "task_assign", "task_reassign", "task_schedule", "task_priority_change", "task_scope_change",
"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");
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");
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true })), [
"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) => {
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) => {
@@ -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/);
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);
+});
@@ -0,0 +1,12 @@
81b985a837d1b5f837fb94faa2264684f6c1633e916e2ab99f57659bc2bd1a9f packages/bus/README.md
dbef704b063f04e0ad451cb1977ab2641a9daf37b12537d366d4f22b3299a639 packages/bus/src/broker.mjs
7a62dd8d948b576f5873e70c92eee8d51d8fbc69656ff482d30f25ba78d45e31 packages/bus/tests/task-page.test.mjs
f334d4c93ec207528775029d3d042922796e5cd950d30d3da55c14977947e525 packages/harness/README.md
dab8f6528a836cd8649d46de48b1eb0c06b16a2c0d802e0c22485cd755293812 packages/harness/src/bundle.mjs
ce0107c3b21e07925b6543b6b925da8b8768823448d4fcf30b620883cfd2d39e packages/harness/src/mcp-server.mjs
fcba4b8dfd9dde947a79f88376d880f383e2a04ee3c0a9ba2fa5af641fe61b35 packages/harness/src/pi-extension.mjs
7e8ba55bc5c00274a3c953f0afcde86801a624914f2c5babb97135f7aa561b89 packages/harness/src/tools.mjs
414254c1a60c528947ade47388d2a60b91f6964911b737cfeb310e85c97868eb packages/harness/tests/bundle.test.mjs
2a84dac2ab5330ef9c96ca1ea878936e09ce589571fe18196c4d2545309cd9d9 packages/harness/tests/mcp-server.test.mjs
d1c334a87157d8a8da7f4c592c965b75684dbaac901791341784490fa9b1c2f7 packages/harness/tests/pi-session.test.mjs
103b774d6a52f267b0c40f7a944d5a2e3732a2a36fd2c9b8a83d207ee063e1fa packages/harness/tests/tools.test.mjs
@@ -0,0 +1,18 @@
start 2026-10-12T20:13:35Z HEAD-base a3f6e9e66cc1961becb2c4a0ff87fdbdff5f5edc
node bus rc=0 ℹ tests 108 ℹ pass 108 ℹ fail 0
node harness rc=0 ℹ tests 65 ℹ pass 65 ℹ fail 0
node webui rc=0 ℹ tests 84 ℹ pass 84 ℹ fail 0
node cli rc=0 ℹ tests 115 ℹ pass 115 ℹ fail 0
node tasks rc=0 ℹ tests 74 ℹ pass 74 ℹ fail 0
node control-board rc=0 ℹ tests 124 ℹ pass 124 ℹ fail 0
test-auth rc=0 selftest: 15 passed, 0 failed
test-conductor rc=0 selftest: 17 passed, 0 failed
test-config rc=0 selftest: 24 passed, 0 failed
test-discord rc=0 discord suite: 66 passed, 0 failed
test-extension-package rc=0 extension package selftest: 18 passed, 0 failed
test-foundation rc=0 OK differential schema oracle: PASS: differential schema oracle (finite corpus; compatibility evidence, not equivalence proof) selftest: 44 passed, 0 failed
test-queue rc=0 queue suite: 27 passed, 0 failed
test-release rc=0 selftest: 14 passed, 0 failed
test-task rc=0 selftest: 26 passed, 0 failed
end 2026-10-12T20:18:12Z
rerun 2026-10-12T20:43:56Z to 2026-10-12T20:44:07Z, final candidate (tools.test.mjs gained one assertion after the mutants): node harness rc=0 ℹ tests 65 ℹ pass 65 ℹ fail 0
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# Row 89 gate: sequential, in the candidate worktree, output teed per suite.
cd $HOME/dewey-scratch/r89 || exit 9
export TMPDIR=$HOME/dewey-scratch/tmp
OUT=$HOME/dewey-scratch/r89-out/gate
mkdir -p $OUT
S=$OUT/../gate-summary.txt
echo "start $(date -u +%Y-%m-%dT%H:%M:%SZ) HEAD-base $(git rev-parse HEAD)" > $S
for p in bus harness webui cli tasks control-board; do
node --test "packages/$p/tests/*.test.mjs" > $OUT/node-$p.log 2>&1; rc=$?
echo "node $p rc=$rc $(grep -E '^ℹ (tests|pass|fail)' $OUT/node-$p.log | tr '\n' ' ')" >> $S
done
for f in scripts/test-*.sh; do
n=$(basename $f .sh)
if [ "$n" = test-task ]; then DOCKER_HOST=unix:///nonexistent bash $f > $OUT/$n.log 2>&1; else bash $f > $OUT/$n.log 2>&1; fi; rc=$?
echo "$n rc=$rc $(grep -iE '[0-9]+ (passed|failed)|pass(ed)?:|fail(ed)?:' $OUT/$n.log | tail -2 | tr '\n' ' ')" >> $S
done
echo "end $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $S
@@ -0,0 +1,33 @@
start 2026-10-12T20:18:19Z
killed no args filtered too
killed tombstones kept
killed next on an exact last page
killed default limit ignored
killed limit max unchecked
killed assignee by raw id
killed assignees keep persons
killed task_ref with other args
killed missing task_ref is null
killed done check dropped
killed done filter dropped
killed assignee any string
killed label check dropped
killed label filter dropped
killed requirement pattern dropped
killed requirement filter dropped
Traceback (most recent call last):
File "/home/jwoltje/dewey-scratch/r89-out/mutants.py", line 57, in <module>
r = subprocess.run(['node', '--test', *tests], cwd=M, env=env, capture_output=True, text=True, timeout=900)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/jwoltje/.pyenv/versions/3.12.8/lib/python3.12/subprocess.py", line 550, in run
stdout, stderr = process.communicate(input, timeout=timeout)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/jwoltje/.pyenv/versions/3.12.8/lib/python3.12/subprocess.py", line 1209, in communicate
stdout, stderr = self._communicate(input, endtime, timeout)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/jwoltje/.pyenv/versions/3.12.8/lib/python3.12/subprocess.py", line 2116, in _communicate
self._check_timeout(endtime, orig_timeout, stdout, stderr)
File "/home/jwoltje/.pyenv/versions/3.12.8/lib/python3.12/subprocess.py", line 1253, in _check_timeout
raise TimeoutExpired(
subprocess.TimeoutExpired: Command '['node', '--test', 'packages/bus/tests/*.test.mjs']' timed out after 900 seconds
end 2026-10-12T20:34:27Z
@@ -0,0 +1,20 @@
start 2026-10-12T20:35:08Z (run 2: the mutants from 'after ignored' on; run 1 hung there, mutants-run1.txt)
killed after ignored (hung: killed after 300 s)
killed after inclusive
killed total counts the page
killed description kept
killed tasks keys closed
killed ARG_DEFAULTS removed
killed default overrides the caller
killed cap at >=
SURVIVED cap counts bytes
killed indented JSON
killed cap 50000
killed mcp server uncapped
killed pi uncapped
killed tool limit max 500
killed get_task not required
killed get_task without tracker
killed bundle line pi names
killed bundle none dropped
end 2026-10-12T20:43:08Z
@@ -0,0 +1,3 @@
start 2026-10-12T20:43:36Z (run 3: the survivor, after the test added in tools.test.mjs)
killed cap counts bytes
end 2026-10-12T20:43:47Z
+69
View File
@@ -0,0 +1,69 @@
# Row 89 mutants (Dewey): each swaps one exact string in the scratch copy of the candidate, runs
# the bus or harness node tests, restores the file, and reports killed (tests fail) or SURVIVED.
import subprocess, sys, os, signal
M = os.path.expanduser('~/dewey-scratch/r89mut')
env = dict(os.environ, TMPDIR=os.path.expanduser('~/dewey-scratch/tmp'))
B = 'packages/bus/src/broker.mjs'
T = 'packages/harness/src/tools.mjs'
MCP = 'packages/harness/src/mcp-server.mjs'
PI = 'packages/harness/src/pi-extension.mjs'
BU = 'packages/harness/src/bundle.mjs'
BUS = ['packages/bus/tests/*.test.mjs']
HAR = ['packages/harness/tests/*.test.mjs']
muts = [
('no args filtered too', B, " if (!Object.keys(a).length) return rows;\n", "", BUS),
('tombstones kept', B, " .filter((r) => !r.fields.gone)\n", "", BUS),
('next on an exact last page', B, "rest.length > limit ?", "rest.length >= limit ?", BUS),
('default limit ignored', B, "const limit = a.limit ?? TASK_PAGE;", "const limit = a.limit ?? TASK_PAGE_MAX;", BUS),
('limit max unchecked', B, "!positive(a.limit, TASK_PAGE_MAX)", "!positive(a.limit)", BUS),
('assignee by raw id', B, ".filter((u) => bots.has(u)).map((u) => bots.get(u)).sort()", ".sort()", BUS),
('assignees keep persons', B, ".filter((u) => bots.has(u)).map((u) => bots.get(u))", ".map((u) => bots.get(u) ?? u)", BUS),
('task_ref with other args', B, "Object.keys(a).length !== 1 || ", "", BUS),
('missing task_ref is null', B, "?? fail('task-not-found')", "?? null", BUS),
('done check dropped', B, "('done' in a && typeof a.done !== 'boolean') ||", "", BUS),
('done filter dropped', B, "(!('done' in a) || t.done === a.done) &&", "", BUS),
('assignee any string', B, "!(typeof a.assignee === 'string' && Object.hasOwn(roles, a.assignee))", "typeof a.assignee !== 'string'", BUS),
('label check dropped', B, "('label' in a && !positive(a.label)) ||", "", BUS),
('label filter dropped', B, "(!('label' in a) || t.labels.includes(a.label)) &&", "", BUS),
('requirement pattern dropped', B, " && /^REQ-[A-Z]+-[1-9][0-9]*$/.test(a.requirement)", "", BUS),
('requirement filter dropped', B, "(!('requirement' in a) || t.requirement === a.requirement)", "true", BUS),
('after ignored', B, "matched.filter((t) => t.task_ref > a.after)", "matched", BUS),
('after inclusive', B, "t.task_ref > a.after", "t.task_ref >= a.after", BUS),
('total counts the page', B, "total: matched.length", "total: tasks.length", BUS),
('description kept', B, " title: f.title ?? null,\n", " title: f.title ?? null,\n description: f.description,\n", BUS),
('tasks keys closed', B, "verb === 'tasks' ? TASK_ARGS : []", "[]", BUS),
('ARG_DEFAULTS removed', T, "args: { ...ARG_DEFAULTS[tool], ...args }", "args", HAR),
('default overrides the caller', T, "args: { ...ARG_DEFAULTS[tool], ...args }", "args: { ...args, ...ARG_DEFAULTS[tool] }", HAR),
('cap at >=', T, "if (text.length > REPLY_CAP)", "if (text.length >= REPLY_CAP)", HAR),
('cap counts bytes', T, "if (text.length > REPLY_CAP)", "if (Buffer.byteLength(text) > REPLY_CAP)", HAR),
('indented JSON', T, "const text = JSON.stringify(result) ?? \"null\";", "const text = JSON.stringify(result, null, 2) ?? \"null\";", HAR),
('cap 50000', T, "export const REPLY_CAP = 40_000;", "export const REPLY_CAP = 50_000;", HAR + BUS),
('mcp server uncapped', MCP, "text: replyText(result)", "text: JSON.stringify(result)", HAR),
('pi uncapped', PI, "text: replyText(result)", "text: JSON.stringify(result)", HAR),
('tool limit max 500', T, "maximum: TASK_PAGE_MAX,", "maximum: 500,", HAR),
('get_task not required', T, "obj({ task_ref: TASK_REF }, [\"task_ref\"])", "obj({ task_ref: TASK_REF })", HAR),
('get_task without tracker', T, " add(\"get_task\", \"tasks\",", " }\n {\n add(\"get_task\", \"tasks\",", HAR),
('bundle line pi names', BU, "(session.harness === \"pi\" ? resolved.limits.tools : claudeBuiltins({ tools: resolved.limits.tools }))", "resolved.limits.tools", HAR),
('bundle none dropped', BU, ".join(\", \") || \"none\"}", ".join(\", \")}", HAR),
]
only = sys.argv[1:]
for name, f, old, new, tests in muts:
if only and not any(o in name for o in only): continue
p = os.path.join(M, f); src = open(p).read()
n = src.count(old)
if n != 1: print(f'BAD-ANCHOR ({n}) {name}', flush=True); continue
open(p, 'w').write(src.replace(old, new))
# Its own process group, so a hung test's children die with it (a mutant that loops forever
# never fails on its own; a hang is a kill).
proc = subprocess.Popen(['node', '--test', *tests], cwd=M, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, start_new_session=True)
try:
out, _ = proc.communicate(timeout=300)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL); proc.communicate()
out = None
finally:
open(p, 'w').write(src)
if out is None:
print(f'killed {name} (hung: killed after 300 s)', flush=True); continue
fails = [l for l in out.splitlines() if l.startswith('not ok')]
print(('killed ' if proc.returncode else 'SURVIVED') + f' {name}' + (f' ({len(fails)} failing)' if fails else ''), flush=True)
@@ -0,0 +1,16 @@
// Scratch MCP server for row 89: tool `big` returns a text block of exactly n characters.
import { createInterface } from "node:readline";
const send = (m) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...m }) + "\n");
createInterface({ input: process.stdin }).on("line", (line) => {
if (!line.trim()) return;
const m = JSON.parse(line);
if (m.method === "initialize") return send({ id: m.id, result: { protocolVersion: m.params?.protocolVersion ?? "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "probe", version: "1" } } });
if (m.method === "ping") return send({ id: m.id, result: {} });
if (m.method === "tools/list") return send({ id: m.id, result: { tools: [{ name: "big", description: "Return n characters", inputSchema: { type: "object", properties: { n: { type: "integer" } }, required: ["n"] } }] } });
if (m.method === "tools/call") {
const n = m.params.arguments.n;
const unit = "abcdefghij";
return send({ id: m.id, result: { content: [{ type: "text", text: unit.repeat(Math.ceil(n / 10)).slice(0, n) }] } });
}
if (m.id !== undefined) send({ id: m.id, error: { code: -32601, message: "nf" } });
});
@@ -0,0 +1,7 @@
# Extract of the probe run (claude -p --output-format stream-json, MCP server probe-server.mjs).
# One line per tool call: the n asked for, the length of the text the model got back, its start.
claude_code_version 2.1.296 model claude-haiku-5-5
n=49990 returned_length=49990 starts='abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghij'
n=50000 returned_length=50000 starts='abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghij'
n=50010 returned_length=1315 starts='Error: result (50,010 characters across 1 line) exceeds maximum allowed tokens. Output has been save'
total_cost_usd 0.01452308 is_error False