From 6cd23a1fe49cee461b3b5d6c35fdf5ff37bac031 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Thu, 22 Oct 2026 18:58:56 +0800 Subject: [PATCH] Turn the timeline window into a run report a person can paste The window already carries per-Bot turns, failures, busy time, tokens, tools and unanswered requests, but nothing turns it into a report (#357, #401). `timeline/digest` returns the window as short Markdown and the panel gains a Copy report button that copies it. The digest is pure and reads only what the window carries: `usage`, `toolUse` and `stuck` when present, nothing invented when they are absent. Window hours are local, the same the panel header prints. The endpoint is pinned through the real host, not just the pure builder. Fixes #582 --- changelog.d/582-timeline-digest.added.md | 1 + docs/subsystems/timeline.md | 6 +- plugins/timeline/client.js | 36 +++++- plugins/timeline/src/digest.js | 129 +++++++++++++++++++++ plugins/timeline/src/timeline.js | 8 +- plugins/timeline/test/digest.test.js | 137 +++++++++++++++++++++++ plugins/timeline/test/panel.test.js | 63 +++++++++++ 7 files changed, 375 insertions(+), 5 deletions(-) create mode 100644 changelog.d/582-timeline-digest.added.md create mode 100644 plugins/timeline/src/digest.js create mode 100644 plugins/timeline/test/digest.test.js diff --git a/changelog.d/582-timeline-digest.added.md b/changelog.d/582-timeline-digest.added.md new file mode 100644 index 000000000..7d589d20b --- /dev/null +++ b/changelog.d/582-timeline-digest.added.md @@ -0,0 +1 @@ +A **Copy report** button on the timeline turns the window into a short Markdown run report — per Bot its turns and failures, busy share, tokens, top tools and unanswered requests, then the team rollup and the longest open wait — and copies it with one click, in the same hours the panel shows ([#582](https://github.com/ThinkFlowLab/FlyBot/issues/582)). diff --git a/docs/subsystems/timeline.md b/docs/subsystems/timeline.md index c20276c92..438442773 100644 --- a/docs/subsystems/timeline.md +++ b/docs/subsystems/timeline.md @@ -28,8 +28,9 @@ Everything in `plugins/timeline/`: the recorder, the trace file, the read model | `plugins/timeline/src/store.js` | The trace: single writer, write-tmp + fsync + rename, capped segments. | | `plugins/timeline/src/window.js` | Pure read model: records to lanes, turns, tools, arrows, edges, utilization, the token spend per Bot (#357), per-Bot outcomes and recent failures (#471), the open requests waiting longest (#469) and the tools each Bot used (#359). | | `plugins/timeline/src/health.js` | Team health (#357): pure `buildHealth(window)` turning the window into per-Bot signals and flags. | +| `plugins/timeline/src/digest.js` | Team run report (#582): pure `buildDigest(window)` turning the window into short Markdown, with the count and duration formatting (`formatCount`, `formatMs`). | | `plugins/timeline/src/alerts.js` | Durable alerts (#584): pure `createAlertTracker()` folding a window across reads, so a condition opens one alert and resolves when it clears. | -| `plugins/timeline/client.js` | Browser entry (`@flybot/timeline`): the 2 s poller exposed as `ctx.flybot.timeline`, the geometry functions (`layoutSwimlanes`, `layoutMap`, `waitRows`, `utilizationRows`) and the panel (swimlanes, who waits on whom, utilization), opened from its `flybot.sidebar.section` row, the only entry (not in the + menu, #269). | +| `plugins/timeline/client.js` | Browser entry (`@flybot/timeline`): the 2 s poller exposed as `ctx.flybot.timeline`, the geometry functions (`layoutSwimlanes`, `layoutMap`, `waitRows`, `utilizationRows`) and the panel (swimlanes, who waits on whom, utilization, and **Copy report**), opened from its `flybot.sidebar.section` row, the only entry (not in the + menu, #269). | | `plugins/timeline/src/sanitize.js` | The cleaners for ids, labels, codes, times, counts. | | `plugins/timeline/src/otel.js` | OTLP export: maps trace records to OpenTelemetry GenAI spans (`spansFrom`, `otlpPayload`) and a cursor exporter (`createExporter`). Off unless `config.exporter` is set. | @@ -40,6 +41,7 @@ Everything in `plugins/timeline/`: the recorder, the trace file, the read model - Endpoint `timeline/export` (#357, read-only): the current trace mapped to one OTLP/HTTP JSON payload (`resourceSpans`), the same mapping the timer exporter uses. Ids only unless the body sets `names: true` (#483); `serviceName` overrides the default. The panel or an automation can pull it on demand instead of waiting for the next flush. - Endpoint `timeline/health` (#357, read-only): the same window reduced to per-Bot signals and flags (`errors`, `busy`, `idle`, `waiting`) and a team `ok`; the action view over the numbers the panel draws. Body `{ windowMs?, to? }` like `timeline/window`. - Endpoint `timeline/alerts` (#584, read-only): the same window folded across reads into durable alerts — `errors` (a Bot fails most of its turns), `waiting` (its request is unanswered) and `stuck` (a handoff waited past the threshold) — answered as `{ open, resolved, changed, stale }`. Body `{ windowMs?, to? }` like `timeline/window`. A condition opens ONE alert with its `since` and resolves when it clears; a resolved alert stays readable for a minute after it cleared. +- Endpoint `timeline/digest` (#582, read-only): the same window as a short Markdown run report a person can paste into a chat or a standup — per Bot its turns and failures, busy share, tokens (in/out/cached), top tools and unanswered requests, then the team rollup (turns, failures, tokens, calls) and the longest still-open wait. Body `{ windowMs?, to?, title? }`. It reads only what the window carries: `usage`, `toolUse` and `stuck` when present, and nothing is invented when they are absent (`buildDigest`/`formatCount`/`formatMs` in `src/digest.js`, pure). Window hours are the reader's own clock, the same hours the panel header shows, so what a person copies matches what they see above the button. The panel's **Copy report** button asks for it and copies the Markdown; a page that refuses the clipboard says so instead of reporting a copy. ## Invariants @@ -58,7 +60,7 @@ Everything in `plugins/timeline/`: the recorder, the trace file, the read model ## Tests -`plugins/timeline/test/`: panel (geometry, a small hook renderer for open/close, tabs and poll hold, hostile-name negatives), sanitizers, trace store (rotation, retention, lock, crash-safe replace), recorder (negative tests), read model, OTLP export (`otel.test.js`: record→span mapping, payload shape, the cursor and its retry), durable alerts (`alerts.test.js`: one alert per condition, resolution and its grace window, the stuck threshold, the stale look-back, the endpoint and its removal on dispose), and a host test on the real `ctx.flybot` with a mini agent loop on `core/dev/mock-llm.mjs`. Run `npm test` in `plugins/timeline` (CI job `plugins (timeline)`). +`plugins/timeline/test/`: panel (geometry, a small hook renderer for open/close, tabs and poll hold, hostile-name negatives), sanitizers, trace store (rotation, retention, lock, crash-safe replace), recorder (negative tests), read model, OTLP export (`otel.test.js`: record→span mapping, payload shape, the cursor and its retry), durable alerts (`alerts.test.js`: one alert per condition, resolution and its grace window, the stuck threshold, the stale look-back, the endpoint and its removal on dispose), the run report (`digest.test.js`: the pure report, and `timeline/digest` through a real host, read-only and gone after dispose), and a host test on the real `ctx.flybot` with a mini agent loop on `core/dev/mock-llm.mjs`. Run `npm test` in `plugins/timeline` (CI job `plugins (timeline)`). ## Common mistakes diff --git a/plugins/timeline/client.js b/plugins/timeline/client.js index f21a99947..eec33d41d 100644 --- a/plugins/timeline/client.js +++ b/plugins/timeline/client.js @@ -17,6 +17,7 @@ window.__ModuleLoader__.load({ factory: (require) => { const { createElement: h, useEffect, useState, useCallback } = require('react') const ENDPOINT = 'timeline/window' + const DIGEST_ENDPOINT = 'timeline/digest' const VERSION = 1 const POLL_MS = 2000 const MAX_BACKOFF_MS = 30_000 @@ -124,10 +125,29 @@ window.__ModuleLoader__.load({ restart() }, refresh: restart, + // The team run report (#582): read-only and on demand — the button asks for it, the poller never + // does, so a report costs one request when a person wants one. + async digest({ title } = {}) { + const asked = { windowMs: state.windowMs, ...(state.to === undefined ? {} : { to: state.to }) } + if (typeof title === 'string' && title.trim()) asked.title = title.trim().slice(0, 80) + const value = await flybot.call(DIGEST_ENDPOINT, asked) + const markdown = typeof value?.markdown === 'string' ? value.markdown : undefined + if (markdown === undefined) throw new Error('the host sent no report') + return { markdown, line: typeof value.line === 'string' ? value.line : undefined } + }, dispose() { disposed = true; timers.clearTimeout(timer); stopWatching?.(); stopWatching = undefined; listeners.clear() }, } } + // ------------------------------------------------------------------ clipboard + // The report is copied on demand, never on a poll. A page can refuse the clipboard (no secure + // context, permission denied): the panel says so rather than pretending it copied. + async function copyText(text) { + const clip = globalThis.navigator?.clipboard + if (!clip || typeof clip.writeText !== 'function') throw new Error('this page has no clipboard') + await clip.writeText(String(text ?? '')) + } + // ------------------------------------------------------------------ formatting // The host cleaned every name already; this is the second line, for a host that did not. const safe = (value, max = 60) => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069<>]/g, '').trim().slice(0, max) @@ -597,6 +617,17 @@ window.__ModuleLoader__.load({ useStyles() const [tab, setTab] = useState('lanes') const [compress, setCompress] = useState(false) + const [copy, setCopy] = useState({ status: 'idle' }) + const copyReport = useCallback(async () => { + setCopy({ status: 'busy' }) + try { + const report = await api.digest() + await copyText(report.markdown) + setCopy({ status: 'copied' }) + } catch (error) { + setCopy({ status: 'error', error: safe(error?.message ?? error, 120) }) + } + }, [api]) const [snap, setSnap] = useState(api.state) useEffect(() => { const off = api.subscribe(setSnap) @@ -624,12 +655,13 @@ window.__ModuleLoader__.load({ h('select', { className: 'tl-btn', 'aria-label': 'Window', value: String(snap.windowMs), onChange: event => api.setWindow({ windowMs: Number(event.target.value) }) }, ...WINDOWS.map(([label, ms]) => h('option', { key: ms, value: String(ms) }, `Last ${label}`))), h('button', { className: 'tl-btn', onClick: () => api.setWindow({ windowMs: snap.windowMs, to: (win ? win.to : Date.now()) - snap.windowMs / 2 }) }, 'Earlier'), + h('button', { className: 'tl-btn', 'aria-label': 'Copy report', title: 'Copy this window as a Markdown report', onClick: () => { void copyReport() } }, copy.status === 'copied' ? 'Copied' : copy.status === 'busy' ? 'Copying…' : 'Copy report'), h('button', { className: 'tl-btn live', 'aria-pressed': snap.to === undefined, title: snap.to === undefined ? 'Following the latest activity' : 'Back to the latest activity', onClick: () => { if (snap.to !== undefined) api.setWindow({ windowMs: snap.windowMs }) } }, h('i', { className: 'tl-dot', 'aria-hidden': 'true' }), 'Live'), h('button', { className: 'tl-btn', 'aria-label': 'Close', onClick: onClose }, '✕')), win && h('div', { className: 'tl-range', 'data-flybot': 'timeline-range' }, h('span', { className: 'tl-range-text' }, fmtRange(win.from, win.to)), h('span', { className: 'tl-range-live' }, win.live ? 'live, up to the latest activity' : 'earlier window, not live')), - h('div', { className: 'tl-body' }, ...notesOf(snap).map((note, i) => h('p', { key: `n${i}`, className: note.err ? 'tl-note err' : 'tl-note', role: note.err ? 'alert' : undefined }, note.text)), body), + h('div', { className: 'tl-body' }, ...notesOf(snap).map((note, i) => h('p', { key: `n${i}`, className: note.err ? 'tl-note err' : 'tl-note', role: note.err ? 'alert' : undefined }, note.text)), copy.status === 'error' && h('p', { className: 'tl-note err', role: 'alert' }, `Could not copy the report: ${copy.error}`), body), h('div', { className: 'tl-foot' }, h('span', null, 'Delivered: in the other Bot\'s log, not read.'), h('span', null, 'Model time: turn time outside tools.')))) @@ -675,7 +707,7 @@ window.__ModuleLoader__.load({ return off } return { - name: 'flybot-timeline', inject, apply, createStore, checkWindow, ENDPOINT, VERSION, POLL_MS, WINDOWS, CSS, + name: 'flybot-timeline', inject, apply, createStore, checkWindow, ENDPOINT, DIGEST_ENDPOINT, VERSION, POLL_MS, WINDOWS, CSS, copyText, safe, fmtDur, fmtClock, fmtRange, makeScale, niceTicks, layoutSwimlanes, layoutMap, waitRows, utilizationRows, createPanelState, Swimlanes, CommMap, Utilization, TimelinePanel, makeSection, notesOf, } diff --git a/plugins/timeline/src/digest.js b/plugins/timeline/src/digest.js new file mode 100644 index 000000000..8fa15e875 --- /dev/null +++ b/plugins/timeline/src/digest.js @@ -0,0 +1,129 @@ +// A team run report (#357/#401, #582): the window's per-Bot numbers and the team's rollup, as one +// short Markdown digest a person reads in a standup or a routine posts into a chat. Pure: a window +// in, text out. It reads what the window carries and quietly skips a field an older answer does not +// have (tools, waits), so it stays useful as the timeline grows. Read-only: nothing is written and +// no Session is touched. +const K = 1_000 +const M = 1_000_000 +const BUSY_MIN = 0.9 +const ERROR_RATE_MIN = 0.5 +const ERROR_TURNS_MIN = 3 +const TOP_TOOLS = 5 + +/** A count a person reads at a glance: `840`, `12.3k`, `1.2M`. */ +export function formatCount(value) { + const n = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0 + if (n >= M) return `${(n / M).toFixed(1)}M` + if (n >= K) return `${(n / K).toFixed(1)}k` + return String(n) +} + +/** A duration a person reads at a glance: `40s`, `12m`, `1h05m`. */ +export function formatMs(ms) { + const value = Number.isFinite(ms) ? Math.max(0, Math.round(ms)) : 0 + if (value < 60_000) return `${Math.round(value / 1000)}s` + if (value < 3_600_000) return `${Math.round(value / 60_000)}m` + const minutes = Math.round((value % 3_600_000) / 60_000) + return `${Math.floor(value / 3_600_000)}h${String(minutes).padStart(2, '0')}m` +} + +const percent = (part) => `${Math.round(part * 100)}%` +const pad = n => String(n).padStart(2, '0') +// The reader's own clock, the hours the panel header prints (`fmtClock` in client.js): a report +// copied from the panel must not name a different time than the header above the button. +const clock = (ms) => { + const at = new Date(ms) + return Number.isFinite(at.getTime()) ? `${pad(at.getHours())}:${pad(at.getMinutes())}` : '' +} + +/** + * `window` is a `timeline/window` answer. Resolves `{ title, range, live, bots, team, markdown, + * line }`. `bots` is the per-Bot view in the order the window lists it (the Main Bot first, as the + * panel draws it), `team` the rollup, `markdown` the report, `line` a one-line summary a reminder + * can post. + */ +export function buildDigest(window = {}, { title = 'Team report', now = Date.now() } = {}) { + const lanes = new Map((window.lanes ?? []).map(lane => [lane.id, lane])) + const usageBy = new Map((window.usage ?? []).map(use => [use.lane, use])) + const toolUseBy = new Map((window.toolUse ?? []).map(use => [use.lane, use.tools ?? []])) + const waitingBy = new Map() + for (const edge of window.edges ?? []) { + if (edge?.unanswered > 0 && typeof edge.from === 'string') waitingBy.set(edge.from, (waitingBy.get(edge.from) ?? 0) + edge.unanswered) + } + + const bots = (window.utilization ?? []).map((use) => { + const lane = lanes.get(use.lane) + const usage = usageBy.get(use.lane) ?? {} + const turns = use.turns ?? 0 + const errors = use.errors ?? 0 + const waiting = waitingBy.get(use.lane) ?? 0 + const errorRate = turns > 0 ? errors / turns : 0 + const busyShare = use.windowMs > 0 ? use.busyMs / use.windowMs : 0 + const flags = [] + if (turns >= ERROR_TURNS_MIN && errorRate >= ERROR_RATE_MIN) flags.push(`${errors} of ${turns} turns failed`) + if (turns > 0 && busyShare >= BUSY_MIN) flags.push(`busy ${percent(busyShare)} of the window`) + if (turns === 0) flags.push('no turns') + if (waiting > 0) flags.push(`${waiting} request(s) unanswered`) + return { + id: use.lane, + name: lane?.name ?? use.lane, + main: lane?.main === true, + turns, errors, errorRate, busyShare, + running: use.running === true, + tokens: { input: usage.input ?? 0, output: usage.output ?? 0, cacheRead: usage.cacheRead ?? 0, cacheWrite: usage.cacheWrite ?? 0, calls: usage.calls ?? 0 }, + tools: (toolUseBy.get(use.lane) ?? []).slice(0, TOP_TOOLS), + waiting, flags, + } + }) + + // The longest still-open request: `stuck` already carries the wait, worst first (#469); an older + // host has none, so the waiting exchanges are measured against `now` instead. + const openWaits = window.stuck ?? (window.exchanges ?? []) + .filter(arrow => arrow.waiting === true) + .map(arrow => ({ id: arrow.id, from: arrow.from, to: arrow.to, waitedMs: Math.max(0, now - (arrow.t ?? now)) })) + const longest = openWaits.slice().sort((a, b) => (b.waitedMs ?? 0) - (a.waitedMs ?? 0))[0] + + const sumTokens = (key) => bots.reduce((total, bot) => total + bot.tokens[key], 0) + const team = { + turns: bots.reduce((sum, bot) => sum + bot.turns, 0), + errors: bots.reduce((sum, bot) => sum + bot.errors, 0), + tokens: { input: sumTokens('input'), output: sumTokens('output'), cacheRead: sumTokens('cacheRead'), cacheWrite: sumTokens('cacheWrite'), calls: sumTokens('calls') }, + longestWait: longest === undefined ? null : { id: longest.id, from: longest.from, to: longest.to, waitedMs: longest.waitedMs ?? 0 }, + unhealthy: bots.filter(bot => bot.turns >= ERROR_TURNS_MIN && bot.errorRate >= ERROR_RATE_MIN).map(bot => bot.id), + } + + const from = clock(window.from) + const to = clock(window.to) + const range = from && to ? `${from}–${to}` : 'the window' + const lines = [`# ${title}`, `Window: ${range}${window.live === true ? ' · live' : ''}`] + + if (bots.length === 0) { + lines.push('', 'No activity in the window.') + } else { + for (const bot of bots) { + lines.push('', `## ${bot.name}${bot.main ? ' (main)' : ''}${bot.running ? ' · running' : ''}`) + if (bot.turns > 0) { + const failed = bot.errors > 0 ? `, ${bot.errors} failed (${percent(bot.errorRate)})` : '' + lines.push(`- ${bot.turns} turns${failed}`) + lines.push(`- busy ${percent(bot.busyShare)} of the window`) + lines.push(`- tokens: ${formatCount(bot.tokens.input)} in / ${formatCount(bot.tokens.output)} out / ${formatCount(bot.tokens.cacheRead)} cached`) + } else { + lines.push('- no turns in the window') + } + if (bot.waiting > 0) lines.push(`- ${bot.waiting} request(s) unanswered`) + if (bot.tools.length > 0) lines.push(`- tools: ${bot.tools.map(tool => `${tool.name} ×${tool.calls}${tool.failed > 0 ? ` (${tool.failed} failed)` : ''}`).join(', ')}`) + const look = bot.flags.filter(flag => flag !== 'no turns') + if (look.length > 0) lines.push(`- look: ${look.join('; ')}`) + } + } + + lines.push('', '## Team', `- ${team.turns} turns, ${team.errors} failed`) + lines.push(`- tokens: ${formatCount(team.tokens.input)} in / ${formatCount(team.tokens.output)} out (${team.tokens.calls} calls)`) + if (team.longestWait !== null) lines.push(`- longest wait: ${team.longestWait.from} → ${team.longestWait.to}, ${formatMs(team.longestWait.waitedMs)}`) + if (team.unhealthy.length > 0) lines.push(`- needs a look: ${team.unhealthy.join(', ')}`) + + const line = team.turns === 0 + ? `No Bot activity in ${range}.` + : `${team.turns} turns, ${team.errors} failed, ${formatCount(team.tokens.input)} tokens in over ${range}.` + return { title, range, live: window.live === true, bots, team, markdown: lines.join('\n'), line } +} diff --git a/plugins/timeline/src/timeline.js b/plugins/timeline/src/timeline.js index 6d04232bf..3dbcd975a 100644 --- a/plugins/timeline/src/timeline.js +++ b/plugins/timeline/src/timeline.js @@ -5,11 +5,13 @@ import { buildWindow, clampWindow } from './window.js' import { createExporter, otlpPayload, spansFrom } from './otel.js' import { buildHealth } from './health.js' import { createAlertTracker } from './alerts.js' +import { buildDigest } from './digest.js' export const ENDPOINT = 'timeline/window' export const EXPORT_ENDPOINT = 'timeline/export' export const HEALTH_ENDPOINT = 'timeline/health' export const ALERTS_ENDPOINT = 'timeline/alerts' +export const DIGEST_ENDPOINT = 'timeline/digest' export function createTimeline(adapter, { dir, persist = true, now = Date.now, exporter, ...limits } = {}) { let trace @@ -61,14 +63,18 @@ export function createTimeline(adapter, { dir, persist = true, now = Date.now, e const alerts = createAlertTracker() const alertView = (body = {}) => alerts.fold(window(body)) const offAlerts = adapter.handle(ALERTS_ENDPOINT, alertView) + // The team run report (#582): the same window, as short Markdown a person can paste. Read-only. + const digest = (body = {}) => buildDigest(window(body), { ...(typeof body?.title === 'string' && body.title.trim() ? { title: body.title.trim().slice(0, 80) } : {}), now: now() }) + const offDigest = adapter.handle(DIGEST_ENDPOINT, digest) return { - trace, recorder, window, exportNow, exportWindow, health, alerts, alertView, + trace, recorder, window, exportNow, exportWindow, health, alerts, alertView, digest, dispose() { if (exportTimer !== undefined) clearInterval(exportTimer) offEndpoint() offExport() offHealth() offAlerts() + offDigest() off() trace.close() }, diff --git a/plugins/timeline/test/digest.test.js b/plugins/timeline/test/digest.test.js new file mode 100644 index 000000000..a036ef91e --- /dev/null +++ b/plugins/timeline/test/digest.test.js @@ -0,0 +1,137 @@ +// The report prints the reader's local clock (#582), so this file runs in a fixed zone ahead of +// UTC: a formatter that quietly prints UTC cannot pass here just because the CI runner is on UTC. +process.env.TZ = 'Asia/Shanghai' + +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { buildDigest, formatCount, formatMs } from '../src/digest.js' +import { createTimeline, DIGEST_ENDPOINT } from '../src/timeline.js' +import { mockAdapter } from './mock-adapter.js' + +const make = (extra = {}) => ({ + version: 1, now: 100_000, from: 0, to: 100_000, windowMs: 100_000, live: true, + lanes: [{ id: 'user', kind: 'user', name: 'You' }, { id: 'a', kind: 'bot', name: 'Chief', main: true }, { id: 'b', kind: 'bot', name: 'Writer' }], + utilization: [ + { lane: 'a', windowMs: 100_000, busyMs: 62_000, turns: 4, errors: 1, running: false }, + { lane: 'b', windowMs: 100_000, busyMs: 0, turns: 0, errors: 0, running: false }, + ], + usage: [{ lane: 'a', calls: 4, input: 12_345, output: 1_234, cacheRead: 8_000, cacheWrite: 0 }], + toolUse: [{ lane: 'a', tools: [{ name: 'web_search', calls: 6, failed: 1 }, { name: 'read', calls: 2, failed: 0 }] }], + edges: [{ from: 'b', to: 'a', unanswered: 1 }], + stuck: [{ id: 'x9', from: 'b', to: 'a', role: 'request', waitedMs: 720_000 }], + exchanges: [{ id: 'x9', from: 'b', to: 'a', waiting: true, t: 100_000 - 720_000 }], + ...extra, +}) + +test('formatCount and formatMs read at a glance', () => { + assert.equal(formatCount(840), '840') + assert.equal(formatCount(12_345), '12.3k') + assert.equal(formatCount(1_234_567), '1.2M') + assert.equal(formatCount(-3), '0') + assert.equal(formatCount(undefined), '0') + assert.equal(formatMs(40_000), '40s') + assert.equal(formatMs(720_000), '12m') + assert.equal(formatMs(3_900_000), '1h05m') + assert.equal(formatMs(undefined), '0s') +}) + +test('#582: per-Bot sections, the team rollup and the longest wait', () => { + const digest = buildDigest(make(), { now: 100_000 }) + assert.equal(digest.bots[0].name, 'Chief') + assert.equal(digest.bots[0].main, true) + assert.equal(digest.bots[0].turns, 4) + assert.equal(digest.bots[0].waiting, 0) + assert.equal(digest.bots[1].waiting, 1) + assert.deepEqual(digest.bots[0].tools.map(tool => tool.name), ['web_search', 'read']) + assert.equal(digest.team.turns, 4) + assert.equal(digest.team.errors, 1) + assert.equal(digest.team.tokens.input, 12_345) + assert.deepEqual(digest.team.longestWait, { id: 'x9', from: 'b', to: 'a', waitedMs: 720_000 }) + assert.match(digest.markdown, /^# Team report\nWindow: 08:00–08:01 · live/) + assert.match(digest.markdown, /## Chief \(main\)\n- 4 turns, 1 failed \(25%\)/) + assert.match(digest.markdown, /- busy 62% of the window/) + assert.match(digest.markdown, /- tokens: 12.3k in \/ 1.2k out \/ 8.0k cached/) + assert.match(digest.markdown, /- 1 request\(s\) unanswered/) + assert.match(digest.markdown, /- tools: web_search ×6 \(1 failed\), read ×2/) + assert.match(digest.markdown, /## Writer\n- no turns in the window/) + assert.match(digest.markdown, /## Team\n- 4 turns, 1 failed/) + assert.match(digest.markdown, /- longest wait: b → a, 12m/) + assert.match(digest.line, /4 turns, 1 failed, 12.3k tokens in over 08:00–08:01\./) +}) + +// The endpoint must work before the richer fields land (#582): a window from an older host carries +// no `stuck` and no `toolUse`, and the report is shorter rather than broken. +test('#582: a window without the newer fields still builds, and says less', () => { + const digest = buildDigest(make({ toolUse: undefined, stuck: undefined, exchanges: [] }), { now: 100_000 }) + assert.doesNotMatch(digest.markdown, /tools:/) + assert.doesNotMatch(digest.markdown, /longest wait/) + assert.equal(digest.team.longestWait, null) + // Without `stuck` the waiting exchange is measured against `now` instead of being dropped. + const old = buildDigest(make({ stuck: undefined }), { now: 820_000 }) + assert.equal(old.team.longestWait.waitedMs, 1_440_000) +}) + +test('#582: an empty window is a short honest report', () => { + const digest = buildDigest({}) + assert.equal(digest.bots.length, 0) + assert.match(digest.markdown, /No activity in the window\./) + assert.match(digest.line, /No Bot activity/) + assert.equal(digest.markdown.includes('undefined'), false) +}) + +test('#582: a Bot that fails most turns is named as needing a look', () => { + const digest = buildDigest(make({ utilization: [{ lane: 'a', windowMs: 100_000, busyMs: 1000, turns: 4, errors: 3, running: false }] }), { now: 100_000 }) + assert.deepEqual(digest.team.unhealthy, ['a']) + assert.match(digest.markdown, /- needs a look: a/) + assert.match(digest.markdown, /- look: 3 of 4 turns failed/) +}) + +// The rollup is the sum of the per-Bot numbers, never a separate count: a report a person reads +// must not disagree with the lines above it. +test('#582: the team rollup equals the Bot sections', () => { + const digest = buildDigest(make({ + utilization: [ + { lane: 'a', windowMs: 100_000, busyMs: 10_000, turns: 2, errors: 0 }, + { lane: 'b', windowMs: 100_000, busyMs: 5_000, turns: 3, errors: 2 }, + ], + usage: [{ lane: 'a', calls: 2, input: 10, output: 4, cacheRead: 1 }, { lane: 'b', calls: 3, input: 20, output: 5, cacheRead: 2 }], + }), { now: 100_000 }) + for (const key of ['input', 'output', 'cacheRead', 'cacheWrite', 'calls']) { + assert.equal(digest.team.tokens[key], digest.bots.reduce((sum, bot) => sum + bot.tokens[key], 0), key) + } + assert.equal(digest.team.turns, digest.bots.reduce((sum, bot) => sum + bot.turns, 0)) + assert.equal(digest.team.errors, digest.bots.reduce((sum, bot) => sum + bot.errors, 0)) +}) + +// A report copied at 17:05 says 17:05 — the hour the panel header above the button shows, not the +// UTC hour, and with no zone the reader has to guess (#582). +test('#582: the report names local hours, like the panel header', () => { + const from = Date.UTC(2026, 9, 22, 9, 5) + const to = Date.UTC(2026, 9, 22, 9, 20) + const digest = buildDigest(make({ from, to, live: false }), { now: to }) + assert.equal(digest.range, '17:05–17:20') + assert.match(digest.markdown, /^# Team report\nWindow: 17:05–17:20\n/) +}) + +// The endpoint is the feature, not the pure builder: the suite stayed green with +// `adapter.handle(DIGEST_ENDPOINT, digest)` and its `offDigest()` deleted (#582), so this goes +// through the real host, the way test/host.test.js does for `timeline/window`. +test('#582: the endpoint is registered read-only and removed on dispose', () => { + const adapter = mockAdapter() + const timeline = createTimeline(adapter, { persist: false, now: () => 100_000 }) + try { + assert.equal(DIGEST_ENDPOINT, 'timeline/digest') + assert.ok(adapter.endpoints.has(DIGEST_ENDPOINT), 'createTimeline registers timeline/digest') + const report = adapter.endpoints.get(DIGEST_ENDPOINT)({ windowMs: 300_000, title: ' Standup ' }) + assert.equal(report.title, 'Standup') + assert.match(report.markdown, /^# Standup\nWindow: \d\d:\d\d–\d\d:\d\d · live\n/) + // The host hangs the endpoint on the real window: every Bot it knows, with no turns yet. + assert.match(report.markdown, /## Chief \(main\)\n- no turns in the window/) + assert.match(report.markdown, /## Team\n- 0 turns, 0 failed/) + // Read-only: answering again changes nothing and keeps the default title. + assert.equal(adapter.endpoints.get(DIGEST_ENDPOINT)({}).title, 'Team report') + } finally { + timeline.dispose() + } + assert.equal(adapter.endpoints.has(DIGEST_ENDPOINT), false, 'dispose removes the endpoint') +}) diff --git a/plugins/timeline/test/panel.test.js b/plugins/timeline/test/panel.test.js index 98ecd8cc2..c0813fdf7 100644 --- a/plugins/timeline/test/panel.test.js +++ b/plugins/timeline/test/panel.test.js @@ -488,3 +488,66 @@ test('a turn bar\'s tooltip names the model the turn ran on (#366)', () => { assert.match(bar('a#1').title, /^Chief: .* · deepseek-v4-pro$/) assert.doesNotMatch(bar('a#2').title, /deepseek/) }) + +// ---- the run report (#582) -------------------------------------------------------------- +// The report is fetched on demand, never by the poller, and the clipboard is the browser's: the test +// installs one, as a page would have, and reads what the button copied. +const withClipboard = (fn) => { + const written = [] + const had = Object.getOwnPropertyDescriptor(globalThis, 'navigator') + Object.defineProperty(globalThis, 'navigator', { value: { clipboard: { writeText: async (text) => { written.push(text) } } }, configurable: true, writable: true }) + return Promise.resolve(fn(written)).finally(() => { + if (had) Object.defineProperty(globalThis, 'navigator', had) + else delete globalThis.navigator + }) +} + +test('#582: the data layer asks for the report at the window the panel shows', async () => { + const asked = [] + const flybot = { call: async (endpoint, body) => { asked.push([endpoint, body]); return { markdown: '# Team report', line: '4 turns' } } } + const store = client.createStore(flybot, { windowMs: 300_000 }) + const report = await store.digest() + assert.deepEqual(asked, [['timeline/digest', { windowMs: 300_000 }]]) + assert.equal(report.markdown, '# Team report') + assert.equal(report.line, '4 turns') + store.setWindow({ windowMs: 60_000 }) + await store.digest({ title: ' Standup ' }) + assert.deepEqual(asked.at(-1), ['timeline/digest', { windowMs: 60_000, title: 'Standup' }]) + // A host that answers nothing usable is an error, not an empty report. + const silent = client.createStore({ call: async () => ({}) }, {}) + await assert.rejects(() => silent.digest(), /no report/) + store.dispose() + silent.dispose() +}) + +test('#582: Copy report copies the Markdown and says so', async () => { + await withClipboard(async (written) => { + const api = fakeApi(okState()) + api.digest = async () => ({ markdown: '# Team report\n\n## Chief\n- 4 turns' }) + const view = mount(client.TimelinePanel, { api, onClose() {} }) + assert.ok(button(view.tree, 'Copy report')) + button(view.tree, 'Copy report').props.onClick() + await new Promise(resolve => setTimeout(resolve, 0)) + assert.deepEqual(written, ['# Team report\n\n## Chief\n- 4 turns']) + assert.ok(button(view.tree, 'Copied')) + view.unmount() + }) +}) + +test('#582: a refused clipboard is reported, not swallowed', async () => { + const api = fakeApi(okState()) + api.digest = async () => ({ markdown: '# Team report' }) + const had = Object.getOwnPropertyDescriptor(globalThis, 'navigator') + Object.defineProperty(globalThis, 'navigator', { value: {}, configurable: true, writable: true }) + try { + const view = mount(client.TimelinePanel, { api, onClose() {} }) + button(view.tree, 'Copy report').props.onClick() + await new Promise(resolve => setTimeout(resolve, 0)) + assert.match(textOf(findAll(view.tree, node => node.props.role === 'alert')[0]), /Could not copy the report: this page has no clipboard/) + assert.ok(button(view.tree, 'Copy report')) // the button stays available + view.unmount() + } finally { + if (had) Object.defineProperty(globalThis, 'navigator', had) + else delete globalThis.navigator + } +})