Ledger skips T3 reasoning summaries and reads issue pages with a
10-page ceiling. Candidate manifest, patch against cc48b124f, mutants
(both killed) and a passing full gate.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
340 lines
24 KiB
Diff
340 lines
24 KiB
Diff
diff --git a/packages/ledger/README.md b/packages/ledger/README.md
|
|
index 483c981e5..5fc4b1b65 100644
|
|
--- a/packages/ledger/README.md
|
|
+++ b/packages/ledger/README.md
|
|
@@ -105,7 +105,13 @@ isn't writable refuses when SQLite needs them.
|
|
- **Excluded.** Imported threads (id prefix `import:`) are partial copies of
|
|
Claude Code sessions, not T3 traffic; every T3 event marked `historyImport`
|
|
sits in one today. Deleted threads don't count; archived threads do.
|
|
- `t3.excluded` gives both thread counts.
|
|
+- **Reasoning summaries.** Since 2026-10-13, T3 stores reasoning summaries
|
|
+ as messages with role `reasoning` and an id starting `reasoning:summary:`.
|
|
+ A message in a counted thread with role `reasoning` and an id starting
|
|
+ `reasoning:` counts nowhere. The reader skips it before the text,
|
|
+ `created_at` and header checks, so its content can't refuse or change a
|
|
+ run. `t3.excluded` gives the imported and deleted thread counts and the
|
|
+ skipped reasoning messages, at any date.
|
|
- **Blind spot.** Threads in other T3 projects are not counted, even if they
|
|
worked on this repository. Live, there is a project at `/home/jwoltje` and
|
|
a deleted one at `/mnt/storage/src`.
|
|
@@ -123,7 +129,8 @@ These refuse the report with exit 1, and the ones about the database name
|
|
lock past the timeout); a symlink at `~/.t3`, `~/.t3/userdata` or
|
|
`state.sqlite` (with `--t3-db`, the file or its directory); a missing table or
|
|
column the counts need; no project or more than one for this root; a message
|
|
-in a counted thread with a role other than `user` or `assistant`, non-text
|
|
+in a counted thread with a role other than `user` or `assistant` (role
|
|
+`reasoning` with an id that doesn't start `reasoning:` included), non-text
|
|
content, or a `created_at` that doesn't parse; a header conflict. A missing Pi
|
|
directory means no Pi seats ran here; a missing T3 database means the path or
|
|
T3 changed, so it refuses instead of counting zero. Error messages name ids
|
|
@@ -193,21 +200,21 @@ text and the JSON.
|
|
|
|
### Queue issue calls
|
|
|
|
-The metric call can't answer "is this issue closed", because an issue nobody
|
|
-touched this week isn't in it. So the queue checks have their own budget, per
|
|
+The metric pages can't answer "is this issue closed", because an issue nobody
|
|
+touched this week isn't on them. So the queue checks have their own budget, per
|
|
plan 8.10:
|
|
|
|
| Calls | Purpose |
|
|
|---|---|
|
|
| 1 | `state=open`, issues only, limit 50, one page. A full page may be short. Issues missing from it are looked up, so it makes the run `incomplete` only while some issue a row closes has no known state (lead decision 40). |
|
|
-| up to 10 | `GET issues/N` for each issue in some row's `closes` that is neither on the open list nor closed on the metric page. Beyond 10, the issue's state is unknown, "over the lookup budget". |
|
|
+| up to 10 | `GET issues/N` for each issue in some row's `closes` that is neither on the open list nor closed on a metric page. Beyond 10, the issue's state is unknown, "over the lookup budget". |
|
|
|
|
-With the metric call that is at most 12 calls. `--no-issues` makes none and
|
|
+With up to 10 metric pages that is at most 21 calls. `--no-issues` makes none and
|
|
prints `queue issue checks: not run`. Closed needs positive evidence: an
|
|
-entry on the metric page with state `closed` and a `closed_at`, or a lookup
|
|
+entry on a metric page with state `closed` and a `closed_at`, or a lookup
|
|
returning a closed issue that isn't a pull request. A failed lookup, a 404, a
|
|
pull request or a mismatched number leaves the issue unknown. A failed or
|
|
-malformed open-list call exits 2, like the metric call. Each queue call runs
|
|
+malformed open-list call exits 2, like a metric page. Each queue call runs
|
|
under `timeout -s KILL 60`, which kills the helper and its curl together.
|
|
|
|
### Weekly routine
|
|
@@ -232,11 +239,20 @@ violations, or every violation remediated that day.
|
|
## Gitea calls and missing evidence
|
|
|
|
The metric client requests issues updated since the start date, all states,
|
|
-first page, limit 50. This includes issues closed in range, even if later updated. Gitea caps
|
|
-responses at 50; a full page fails rather than silently reporting partial totals.
|
|
-Use a narrower range or `--no-issues`, not hidden pagination. A commit-linked
|
|
-issue not returned by the updated-since query still has a row, with unknown
|
|
-metadata. This is the cost of the brief's one-call boundary.
|
|
+limit 50. This includes issues closed in range, even if later updated, so
|
|
+`--until` can't narrow it. Gitea caps a page at 50. The client reads pages 1,
|
|
+2, 3 and on, and stops at the first page with fewer than 50 issues. It reads
|
|
+at most 10 pages, so a run makes up to 10 metric calls. A full page 10 exits 2
|
|
+and says the 10-page ceiling was reached, rather than report partial totals;
|
|
+narrow `--since` or use `--no-issues`. Every page gets the same record checks,
|
|
+and a page with more than 50 issues refuses. An issue number seen on two
|
|
+pages exits 2, because an update during the read can move an issue onto the
|
|
+next page; rerun the report. A failed page refuses the whole read and names
|
|
+the page, never its stderr or response body. The text report prints
|
|
+`Issues: N pages read from Gitea` (`1 page` for one), and the JSON has
|
|
+`issueSource: { read: true, pages: N }`, or
|
|
+`{ read: false }` with `--no-issues`. A commit-linked issue not returned by the
|
|
+updated-since query still has a row, with unknown metadata.
|
|
|
|
Exit 0 means a report was computed. Exit 1 means bad arguments, a queue.json
|
|
the validator refuses, or unreadable git, session or T3 evidence. Malformed JSONL, including a partially written last line,
|
|
diff --git a/packages/ledger/src/cli.mjs b/packages/ledger/src/cli.mjs
|
|
index a261b8cdc..5534a3c4d 100644
|
|
--- a/packages/ledger/src/cli.mjs
|
|
+++ b/packages/ledger/src/cli.mjs
|
|
@@ -46,9 +46,9 @@ export async function main(args, root = path.resolve(path.dirname(fileURLToPath(
|
|
// default without requiring installation or reading auth material here.
|
|
const priorPath = process.env.PATH;
|
|
process.env.PATH = `${priorPath ?? ''}${path.delimiter}${path.join(root, 'scripts')}`;
|
|
- let issues, states = null;
|
|
+ let issues = null, pages, states = null;
|
|
try {
|
|
- issues = noIssues ? null : readIssues(root, range);
|
|
+ if (!noIssues) ({ issues, pages } = readIssues(root, range));
|
|
if (queue && !noIssues) states = issueStates(root, queue.rows, issues);
|
|
}
|
|
finally { if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath; }
|
|
@@ -56,6 +56,7 @@ export async function main(args, root = path.resolve(path.dirname(fileURLToPath(
|
|
const t3 = noT3 ? null : await readT3(root, range, t3Db === undefined ? { dbPath: defaultT3Path(), isDefault: true } : { dbPath: t3Db, isDefault: false });
|
|
const sessions = mergeSources(await readSessions(root, range), t3);
|
|
const report = summarize(range, commits, issues, sessions);
|
|
+ report.issueSource = noIssues ? { read: false } : { read: true, pages };
|
|
report.queue = queue ? queueChecks(queue, { issues: states, seats: readSeatsDir(), exempt, range }) : { checked: false };
|
|
// The queue section prints above the weekly table.
|
|
console.log(json ? JSON.stringify(report, null, 2) : [...formatQueue(report.queue), formatTable(report)].join('\n'));
|
|
diff --git a/packages/ledger/src/ledger.mjs b/packages/ledger/src/ledger.mjs
|
|
index 3669d1cc5..cd14d2a04 100644
|
|
--- a/packages/ledger/src/ledger.mjs
|
|
+++ b/packages/ledger/src/ledger.mjs
|
|
@@ -44,29 +44,45 @@ export function readCommits(root, range) {
|
|
});
|
|
}
|
|
|
|
-export function readIssues(root, range, tool = 'gitea-api.sh') {
|
|
+// Gitea returns at most 50 issues a page. A page with fewer ends the read.
|
|
+const PAGE_SIZE = 50, PAGE_CEILING = 10;
|
|
+function readIssuePage(root, range, page, tool) {
|
|
let output;
|
|
try {
|
|
// Updated-since includes closes in range. No until filter: later updates must
|
|
- // not hide a close in range. One page only, as required by the brief.
|
|
- output = execFileSync(tool, ['GET', `repos/mosaicstack/stack/issues?state=all&type=issues&since=${encodeURIComponent(new Date(range.start).toISOString())}&limit=50&page=1`],
|
|
+ // not hide a close in range.
|
|
+ output = execFileSync(tool, ['GET', `repos/mosaicstack/stack/issues?state=all&type=issues&since=${encodeURIComponent(new Date(range.start).toISOString())}&limit=${PAGE_SIZE}&page=${page}`],
|
|
{ cwd: root, encoding: 'utf8', timeout: 60000, maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
} catch (error) {
|
|
// Never echo an arbitrary API response or stderr that might contain secrets.
|
|
const reason = error.status === 3 ? 'credentials missing, unreadable, or invalid' :
|
|
error.code === 'ENOENT' ? 'gitea-api.sh unavailable' : 'credential or Gitea request failure';
|
|
- throw new SourceError(`Issues unavailable: ${reason}; use --no-issues for unknown issue metrics`, 2);
|
|
+ throw new SourceError(`Issues unavailable: ${reason} on page ${page}; use --no-issues for unknown issue metrics`, 2);
|
|
}
|
|
let issues;
|
|
- try { issues = JSON.parse(output); } catch { throw new SourceError('Issues unavailable: invalid Gitea JSON', 2); }
|
|
- if (!Array.isArray(issues) || issues.some(i => !i || !Number.isSafeInteger(i.number) || i.number < 1 ||
|
|
+ try { issues = JSON.parse(output); } catch { throw new SourceError(`Issues unavailable: invalid Gitea JSON on page ${page}`, 2); }
|
|
+ if (!Array.isArray(issues) || issues.length > PAGE_SIZE || issues.some(i => !i || !Number.isSafeInteger(i.number) || i.number < 1 ||
|
|
typeof i.title !== 'string' || !Number.isFinite(Date.parse(i.created_at)) ||
|
|
!(i.closed_at === null || Number.isFinite(Date.parse(i.closed_at))))) {
|
|
- throw new SourceError('Issues unavailable: invalid Gitea issue records', 2);
|
|
+ throw new SourceError(`Issues unavailable: invalid Gitea issue records on page ${page}`, 2);
|
|
+ }
|
|
+ return issues;
|
|
+}
|
|
+// Reads pages 1, 2, 3 and on until one has fewer than 50 issues, at most 10.
|
|
+// A full page 10 refuses: the totals would be partial. So does a number seen
|
|
+// twice, since an update during the read can move an issue to the next page.
|
|
+export function readIssues(root, range, tool = 'gitea-api.sh') {
|
|
+ const issues = [], seen = new Set();
|
|
+ for (let page = 1; page <= PAGE_CEILING; page++) {
|
|
+ const rows = readIssuePage(root, range, page, tool);
|
|
+ for (const issue of rows) {
|
|
+ if (seen.has(issue.number)) throw new SourceError(`Issues unavailable: duplicate issue number on page ${page}; rerun the report`, 2);
|
|
+ seen.add(issue.number);
|
|
+ issues.push(issue);
|
|
+ }
|
|
+ if (rows.length < PAGE_SIZE) return { issues: issues.filter(i => !i.pull_request), pages: page };
|
|
}
|
|
- if (issues.length >= 50) throw new SourceError('Issues unavailable: full 50-row page may be incomplete; one-call limit forbids pagination. Narrow --since or use --no-issues', 2);
|
|
- if (new Set(issues.map(i => i.number)).size !== issues.length) throw new SourceError('Issues unavailable: duplicate issue numbers', 2);
|
|
- return issues.filter(i => !i.pull_request);
|
|
+ throw new SourceError(`Issues unavailable: page ${PAGE_CEILING} is full, so the ${PAGE_CEILING}-page ceiling was reached and totals may be incomplete. Narrow --since or use --no-issues`, 2);
|
|
}
|
|
|
|
export function messageText(content) {
|
|
@@ -222,9 +238,14 @@ export function formatTable(report) {
|
|
decimal(r.hoursOpen), r.commits, r.followUps, r.seats.join(', ')].join(' | ')),
|
|
'', 'Seat | Board | Agent | Human',
|
|
...report.seats.map(r => [clean(r.seat), r.board, r.agent, r.human].join(' | ')),
|
|
- ...t3Line(report.t3),
|
|
+ ...t3Line(report.t3), ...issueLine(report.issueSource),
|
|
'', totalsLine(report.totals)].join('\n');
|
|
}
|
|
+// How many issue pages the report read, so a short read can't pass unseen.
|
|
+function issueLine(source) {
|
|
+ if (!source) return [];
|
|
+ return [source.read ? `Issues: ${source.pages} ${source.pages === 1 ? 'page' : 'pages'} read from Gitea` : 'Issues: not read (--no-issues)'];
|
|
+}
|
|
// One line when T3 was skipped or read from somewhere other than the default.
|
|
function t3Line(t3) {
|
|
if (!t3) return [];
|
|
diff --git a/packages/ledger/src/t3.mjs b/packages/ledger/src/t3.mjs
|
|
index fc9da14ee..d4425f03d 100644
|
|
--- a/packages/ledger/src/t3.mjs
|
|
+++ b/packages/ledger/src/t3.mjs
|
|
@@ -79,7 +79,7 @@ function query(db, root, range, seats) {
|
|
throw new SourceError(`T3 has ${projects.length ? 'more than one project' : 'no project'} for ${root}; a project opened through a symlink does not match; ${SKIP}`);
|
|
}
|
|
const projectId = projects[0].project_id;
|
|
- const threads = new Map(), excluded = { importedThreads: 0, deletedThreads: 0 };
|
|
+ const threads = new Map(), excluded = { importedThreads: 0, deletedThreads: 0, reasoningMessages: 0 };
|
|
for (const t of db.prepare('select thread_id, title, archived_at, deleted_at from projection_threads where project_id = ?').all(projectId)) {
|
|
if (typeof t.thread_id !== 'string' || typeof t.title !== 'string') throw new SourceError('T3 thread with a non-text id or title');
|
|
if (t.thread_id.startsWith('import:')) { excluded.importedThreads++; continue; }
|
|
@@ -93,6 +93,9 @@ function query(db, root, range, seats) {
|
|
for (const m of messages) {
|
|
const thread = threads.get(m.thread_id);
|
|
if (!thread) continue;
|
|
+ // T3 stores reasoning summaries as messages since 2026-10-13. They are
|
|
+ // skipped before any check, so their content can't refuse or change a run.
|
|
+ if (m.role === 'reasoning' && typeof m.message_id === 'string' && m.message_id.startsWith('reasoning:')) { excluded.reasoningMessages++; continue; }
|
|
const where = `T3 message ${clean(m.message_id)} in thread ${clean(m.thread_id)}`;
|
|
if (m.role !== 'user' && m.role !== 'assistant') throw new SourceError(`${where} has an unknown role`);
|
|
if (typeof m.text !== 'string') throw new SourceError(`${where} has non-text content`);
|
|
diff --git a/packages/ledger/tests/ledger.test.mjs b/packages/ledger/tests/ledger.test.mjs
|
|
index ee48246db..5bd7f42aa 100644
|
|
--- a/packages/ledger/tests/ledger.test.mjs
|
|
+++ b/packages/ledger/tests/ledger.test.mjs
|
|
@@ -37,8 +37,7 @@ function t3db(file, { root, projects, threads = [], messages = [], after = [], k
|
|
db.close();
|
|
}
|
|
let messageId = 0;
|
|
-function addMessage(db, { thread, text, role = 'user', at = '2026-09-08T12:00:00Z', origin = 'app' }) {
|
|
- const id = `m${++messageId}`;
|
|
+function addMessage(db, { thread, text, role = 'user', at = '2026-09-08T12:00:00Z', origin = 'app', id = `m${++messageId}` }) {
|
|
db.prepare('insert into projection_thread_messages values (?, ?, ?, ?, ?)').run(id, thread, role, text, at);
|
|
if (origin !== 'none') db.prepare('insert into orchestration_events (stream_id, event_type, payload_json, metadata_json) values (?, ?, ?, ?)')
|
|
.run(thread, 'thread.message-sent', JSON.stringify({ messageId: id, threadId: thread, role, text }), JSON.stringify({ origin: origin === 'app' ? { appVersion: '0.0.0' } : {} }));
|
|
@@ -66,7 +65,15 @@ function fixture(t) {
|
|
cpSync(source, path.join(root, 'packages/ledger/src'), { recursive: true });
|
|
// The queue section imports the queue validator and the seat registry.
|
|
for (const pkg of ['queue', 'seat']) cpSync(path.join(source, '../..', pkg, 'src'), path.join(root, 'packages', pkg, 'src'), { recursive: true });
|
|
- const api = put('bin/gitea-api.sh', '#!/usr/bin/env node\nconst fs=require("fs"); fs.appendFileSync(process.env.CALLS,JSON.stringify(process.argv.slice(2))+"\\n"); if(process.env.API_FAIL){console.error("secret must not escape");process.exit(Number(process.env.API_FAIL));} process.stdout.write(fs.readFileSync(process.env.ISSUES,"utf8"));\n');
|
|
+ // The fake serves ISSUES for every page, or page N of the ISSUE_PAGES array
|
|
+ // (an empty page past its end). API_FAIL fails every call, or only FAIL_PAGE.
|
|
+ const api = put('bin/gitea-api.sh', `#!/usr/bin/env node
|
|
+const fs = require('fs'), args = process.argv.slice(2), page = Number(new URL(args[1], 'http://x/').searchParams.get('page'));
|
|
+fs.appendFileSync(process.env.CALLS, JSON.stringify(args) + '\\n');
|
|
+if (process.env.API_FAIL && (!process.env.FAIL_PAGE || Number(process.env.FAIL_PAGE) === page)) { console.error('secret must not escape'); process.exit(Number(process.env.API_FAIL)); }
|
|
+if (process.env.ISSUE_PAGES) process.stdout.write(JSON.stringify(JSON.parse(fs.readFileSync(process.env.ISSUE_PAGES, 'utf8'))[page - 1] ?? []));
|
|
+else process.stdout.write(fs.readFileSync(process.env.ISSUES, 'utf8'));
|
|
+`);
|
|
execFileSync('chmod', ['+x', api]);
|
|
put('issues.json', JSON.stringify(fixtureIssues)); put('calls.jsonl', '');
|
|
const entry = (text, timestamp = '2026-09-08T12:00:00Z') => ({ type: 'message', timestamp, message: { role: 'user', content: [{ type: 'text', text }] } });
|
|
@@ -128,9 +135,55 @@ test('unique commits but per-issue links count multiple tags once each', t => {
|
|
const f = fixture(t); f.commit('both #1 #2 #2'); const r = JSON.parse(f.run(['--json']).stdout);
|
|
assert.equal(r.totals.commits, 4); assert.deepEqual(r.issues.map(x => x.commits), [2, 3]);
|
|
});
|
|
-test('page cap refuses rather than silently undercounting', t => {
|
|
- const f = fixture(t); f.put('issues.json', JSON.stringify(Array.from({ length: 50 }, (_, i) => ({ ...fixtureIssues[0], number: i + 1 }))));
|
|
- const result = f.run(); assert.equal(result.status, 2); assert.match(result.stderr, /full 50-row page/); assert.equal(result.stdout, '');
|
|
+// Issue pages. Each issue closes in range, so issuesClosed counts every one read.
|
|
+const closedIssue = number => ({ number, title: `Issue ${number}`, created_at: '2026-09-06T00:00:00Z', closed_at: '2026-09-07T00:00:00Z' });
|
|
+const issuePages = sizes => { let next = 0; return sizes.map(n => Array.from({ length: n }, () => closedIssue(++next))); };
|
|
+function paged(t, pages) {
|
|
+ const f = fixture(t), file = f.put('pages.json', JSON.stringify(pages));
|
|
+ const calls = () => readFileSync(path.join(f.root, 'calls.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l)[1]);
|
|
+ return { ...f, run: (args = [], env = {}) => f.run(args, { ISSUE_PAGES: file, ...env }), calls };
|
|
+}
|
|
+test('issue pages of 50, 50 and 7 read 107 issues in 3 pages', t => {
|
|
+ const f = paged(t, issuePages([50, 50, 7]));
|
|
+ const result = f.run(['--json']); assert.equal(result.status, 0, result.stderr);
|
|
+ const r = JSON.parse(result.stdout);
|
|
+ assert.equal(r.totals.issuesClosed, 107); assert.deepEqual(r.issueSource, { read: true, pages: 3 });
|
|
+ const calls = f.calls(); assert.equal(calls.length, 3);
|
|
+ calls.forEach((call, i) => assert.match(call, new RegExp(`^repos/mosaicstack/stack/issues\\?state=all&type=issues&since=2026-09-06T00%3A00%3A00.000Z&limit=50&page=${i + 1}$`)));
|
|
+ const text = f.run(); assert.equal(text.status, 0, text.stderr);
|
|
+ assert.match(text.stdout, /^Issues: 3 pages read from Gitea$/m); assert.match(text.stdout, /issues closed 107 /);
|
|
+});
|
|
+test('one short page reads 1 page; --no-issues reads none and says so', t => {
|
|
+ const f = fixture(t), one = f.run();
|
|
+ assert.equal(one.status, 0, one.stderr); assert.match(one.stdout, /^Issues: 1 page read from Gitea$/m);
|
|
+ assert.deepEqual(JSON.parse(f.run(['--no-issues', '--json']).stdout).issueSource, { read: false });
|
|
+ assert.match(f.run(['--no-issues']).stdout, /^Issues: not read \(--no-issues\)$/m);
|
|
+});
|
|
+test('a short page 10 is read; a full page 10 refuses at the ceiling', t => {
|
|
+ const last = paged(t, issuePages([...Array(9).fill(50), 1]));
|
|
+ const ok = last.run(['--json']); assert.equal(ok.status, 0, ok.stderr);
|
|
+ assert.deepEqual(JSON.parse(ok.stdout).issueSource, { read: true, pages: 10 }); assert.equal(JSON.parse(ok.stdout).totals.issuesClosed, 451);
|
|
+ const f = paged(t, issuePages(Array(11).fill(50)));
|
|
+ const result = f.run(); assert.equal(result.status, 2); assert.equal(result.stdout, '');
|
|
+ assert.match(result.stderr, /page 10 is full, so the 10-page ceiling was reached/);
|
|
+ assert.equal(f.calls().length, 10);
|
|
+});
|
|
+test('an issue number on two pages refuses', t => {
|
|
+ const pages = issuePages([50, 7]); pages[1][3] = closedIssue(50);
|
|
+ const f = paged(t, pages), result = f.run();
|
|
+ assert.equal(result.status, 2); assert.equal(result.stdout, ''); assert.match(result.stderr, /duplicate issue number on page 2/);
|
|
+});
|
|
+test('a failing page 2 refuses the whole read without its stderr', t => {
|
|
+ const f = paged(t, issuePages([50, 50, 7])), result = f.run([], { API_FAIL: '1', FAIL_PAGE: '2' });
|
|
+ assert.equal(result.status, 2); assert.equal(result.stdout, '');
|
|
+ assert.match(result.stderr, /credential or Gitea request failure on page 2/); assert.doesNotMatch(result.stderr, /secret must/);
|
|
+ assert.equal(f.calls().length, 2);
|
|
+});
|
|
+for (const [name, bad] of [['a malformed record', { number: 'x' }], ['more than 50 rows', null]]) test(`page 2 with ${name} refuses`, t => {
|
|
+ const pages = issuePages([50, 7]);
|
|
+ if (bad) pages[1][0] = bad; else pages[1] = issuePages([101])[0].slice(50);
|
|
+ const result = paged(t, pages).run(); assert.equal(result.status, 2); assert.equal(result.stdout, '');
|
|
+ assert.match(result.stderr, /invalid Gitea issue records on page 2/);
|
|
});
|
|
for (const payload of ['not JSON', '{}', '[{"number":1}]']) test(`bad API payload ${payload} refuses`, t => {
|
|
const f = fixture(t); f.put('issues.json', payload); const r = f.run(); assert.equal(r.status, 2); assert.equal(r.stdout, '');
|
|
@@ -254,7 +307,7 @@ test('T3: seat, archived, unmapped and Researcher threads count; imported, delet
|
|
]);
|
|
assert.deepEqual(r.t3.unmapped, { board: 0, agent: 1, human: 2, threads: [
|
|
{ id: T5, title: 'Discord Bot', archived: false }, { id: T3, title: 'Sagebrush', archived: false }] });
|
|
- assert.deepEqual(r.t3.excluded, { importedThreads: 1, deletedThreads: 1 });
|
|
+ assert.deepEqual(r.t3.excluded, { importedThreads: 1, deletedThreads: 1, reasoningMessages: 0 });
|
|
// The free-text header counts as human; only the diagnostic shows it was sent through the API.
|
|
assert.deepEqual(r.t3.diagnostic, { humanSentThroughApi: 1, humanWithoutEvent: 0 });
|
|
assert.equal(r.totals.humanMessagesPerClosedIssue, 6);
|
|
@@ -324,6 +377,7 @@ for (const [name, after, pattern] of [
|
|
});
|
|
for (const [name, message, pattern] of [
|
|
['an unknown role', { role: 'system' }, /T3 message m\d+ in thread t-alice has an unknown role/],
|
|
+ ['role reasoning without the reasoning: id prefix', { role: 'reasoning', id: 'x-1' }, /T3 message x-1 in thread t-alice has an unknown role/],
|
|
['non-text content', { text: null }, /has non-text content/],
|
|
['an unparseable created_at', { at: 'yesterday' }, /has an invalid created_at/],
|
|
]) test(`T3: a counted row with ${name} exits 1 without its text`, t => {
|
|
@@ -331,6 +385,25 @@ for (const [name, message, pattern] of [
|
|
f.write({ messages: [...f.messages, { thread: T1, text: 'secret words', ...message }] });
|
|
const r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1); assert.match(r.stderr, pattern); assert.doesNotMatch(r.stderr, /secret/);
|
|
});
|
|
+test('T3: a reasoning summary is skipped before any check and counted as excluded', t => {
|
|
+ const f = t3Fixture(t); f.write();
|
|
+ const before = JSON.parse(f.run(['--json', '--t3-db', f.db]).stdout);
|
|
+ const same = after => {
|
|
+ for (const key of ['seats', 'pi', 'issues', 'totals']) assert.deepEqual(after[key], before[key]);
|
|
+ assert.deepEqual([after.t3.seats, after.t3.unmapped, after.t3.diagnostic], [before.t3.seats, before.t3.unmapped, before.t3.diagnostic]);
|
|
+ };
|
|
+ // Null text and a bad created_at would each refuse a counted row.
|
|
+ const summary = { thread: T1, role: 'reasoning', id: 'reasoning:summary:1', text: null, at: 'yesterday', origin: 'none' };
|
|
+ f.write({ messages: [...f.messages, summary] });
|
|
+ let r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
|
|
+ assert.deepEqual(JSON.parse(r.stdout).t3.excluded, { importedThreads: 1, deletedThreads: 1, reasoningMessages: 1 });
|
|
+ same(JSON.parse(r.stdout));
|
|
+ // So would a header conflict, and a #N mention would add an issue seat.
|
|
+ f.write({ messages: [...f.messages, summary, { thread: T2, role: 'reasoning', id: 'reasoning:summary:2', text: `${header('sage', 'alice', T2)}\n#7`, origin: 'none' }] });
|
|
+ r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
|
|
+ assert.equal(JSON.parse(r.stdout).t3.excluded.reasoningMessages, 2);
|
|
+ same(JSON.parse(r.stdout));
|
|
+});
|
|
test('T3: a missing orchestration_events makes the diagnostic unknown and keeps the counts', t => {
|
|
const f = t3Fixture(t); f.write();
|
|
const before = JSON.parse(f.run(['--json', '--t3-db', f.db]).stdout);
|