fix(webui): pending reply notice until the seat answers, relative Age (#1507)

Dewey's return-flow candidate on the #1512 R1 baseline. The inspector used to
show the previous answer while a seat worked on a reply, which looked like the
reply; it now shows a pending notice that clears on the new final answer.
Age shows a relative time beside the ISO time. Filbert approved R2, source
only; the patch reproduces the pinned hashes (app.js d1a51646,
return-flow.test.mjs a598c0d4). webui tests 9/9, all eight suites green.
Known limits are in agents/dewey/work/return-flow-age/NOTES.md. Not pushed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-26 15:00:59 -05:00
co-authored by Claude Opus 5.5
parent 0f5b7cb9be
commit 42c08d5285
14 changed files with 460 additions and 5 deletions
+27
View File
@@ -2872,3 +2872,30 @@ files lose HEAD lines only where a row was deliberately edited.
Evidence: all eight suites green on the working tree (config 24, task 90, Evidence: all eight suites green on the working tree (config 24, task 90,
foundation 43, conductor 17, release 14, auth 15, discord 63, foundation 43, conductor 17, release 14, auth 15, discord 63,
extension-package 18). Not pushed. extension-package 18). Not pushed.
## 2026-09-26: WebUI return flow and Age (#1507, Console change)
Before: after a reply from the WebUI the inspector kept a pending notice even
once the seat had answered, and the Age column read from the wrong field.
After: the pending notice clears once the seat's next answer arrives and stays
cleared, and Age follows the session's last activity. Dewey authored it on
the #1512 R1 baseline (`app.js` ce7d79a4). Filbert asked for revisions in R1
(wrong failing assertion named in NOTES, no test that the notice stays
cleared) and approved R2, source only, after a mutation check that deletes the
clearing loop fails the new test.
Evidence: `candidate.patch` applied to HEAD reproduces the pins (`app.js`
d1a51646, `tests/return-flow.test.mjs` a598c0d4); webui tests 9/9; eight
suites green. Record: `agents/dewey/work/return-flow-age/`. Limits are listed
in its NOTES.md: a send made while the seat is still working can be cleared by
the previous answer, and the card and table still show the old preview. The
fixture reproduces the misleading pending display, not Jason's live report of
no return at all. Not pushed; the live WebUI reads the file from disk.
Correction (Sage, same day): the Before and After lines above misdescribe the
change. On the baseline the inspector showed no pending notice at all; while
the seat worked on a reply it showed the previous answer as "Last assistant
text", which looked like the reply. Age showed only the ISO time. The
candidate adds a pending notice that appears after a send and clears once the
new final answer arrives, and Age shows a relative time ("1m ago") beside the
ISO time. Source: `agents/dewey/work/return-flow-age/NOTES.md`.
@@ -0,0 +1 @@
ce7d79a489bdfaf8216c83595d03a3d23b0f22c8c58543e21c53c40968453230 packages/webui/src/public/app.js (baseline: #1512 R1)
@@ -0,0 +1,11 @@
d1a516467a3197c17bdd12458d0f80c72d85bbd3ebe27a8b9dde51b4cd23bbcb packages/webui/src/public/app.js
a598c0d404549ee2ec095b9f6af095dddbed2db3309648cdc50a4830bd175e53 packages/webui/tests/return-flow.test.mjs
c640b601a516b5d6cd71221886387c6907dbeeffef4cb429777f6aea9322e297 candidate.patch
f456f83291e3e942f2b6ba379637d28a9e2559016917a30c133da8b013507fe9 NOTES.md
3f3061302c514293b5db1b0c48e5f68c98b38e00c48bd2e1df8838998ff72993 shots.mjs
949e3535ce7b301f1fd0f165e79ca85ba9459560b9047aa80f25073b0e5f0071 evidence/age-table-1440.png
babc3b9316fc5e7b7318940f8cedaf4d792bde426ac99b9e1bdaf598ac709c55 evidence/answered-1440.png
61ccc4e230acdf1b1aa12f5748c8fc7d4c9732a8eca1a1dedd0b69010574b31f evidence/answered-320.png
1a10309b44e30ce01396cdc08994d7952f6795a4f4e91356e9ebc7453485dc4c evidence/pending-1440.png
359c31049f586638414cfbea28dba6c3629f968f33d1e39e6065eb64eca0e026 evidence/pending-320.png
2ba1f6a227dec3ef54918a89016bb2ab077a7f5345a1d21bac6d3b8ac3bfbbcd evidence/pending-320-reply.png
+117
View File
@@ -0,0 +1,117 @@
# Return-flow regression and relative Age, R2 candidate (#1507, row 5)
Sage approved this on 2026-09-26 as a small Console change for Filbert to
review. Author: Dewey. It is uncommitted and nothing is published.
## Scope
Two files:
- `packages/webui/src/public/app.js`. Baseline is the #1512 R1-approved file
`ce7d79a4…`, so the change stacks on the uncommitted row 18, #1511 and
#1512 layers. `candidate.patch` is the whole diff against that baseline.
- `packages/webui/tests/return-flow.test.mjs`, which is new.
No server, board, scanner or CSS changes.
## What Jason reported and what the fixture reproduces
On 2026-09-13 Jason said: "We have the uni-directional chat flow from the
board to the agent session, but the return flow is not there. Not even in the
basic sense."
The new test replays the sequence against the real board scanner and the real
WebUI, with a captured transport. It sends from the inspector, and then the
session file gets the user line, a toolCall, a toolResult and a new final
answer.
What the baseline (`ce7d79a4…`) does with this test, as Filbert verified in
R1:
- The committed test fails first at the Age assertion
(`/^1m ago\d{4}-…/`), because the baseline shows only the ISO time.
- With the Age assertions removed, it fails at "the page says a reply is
pending". While the seat works on the reply, the inspector still shows the
previous answer as "Last assistant text", so it looks like the reply.
- With the two pending assertions also removed, it passes. The new answer
arrives through the 10-second auto-refresh, once, with the draft and caret
kept.
R1 of these notes said the committed test failed at the pending assertion.
That was wrong: it described my probe variant, not the committed file.
That means the fixture does **not** reproduce "no return at all". A live
cause outside the page is still possible: the tmux transport, the scanner
reading a different session file, or the 240-character clip. None of those
has been ruled out. The live check waits for the CHAT work and a live seat,
and those need Jason's go.
## Change
- After a delivered send, the inspector says "Waiting for a reply to your
message sent <sentAt>" and relabels the old text "Previous assistant text,
before your message". The notice holds while the seat is `working` or its
last activity is older than the send. It clears once, on the first newer
scan where the seat isn't working. Later work with no new send doesn't
bring it back; the test checks that. For a row that isn't working, an
unparsable time falls back to the plain label. The working check runs
first, so a working row would show the unparsable sentAt as written. The
board always sends `toISOString()`, so this can't happen in practice. Page
memory only; the server is unchanged.
- Relative Age comes from the board's `ageSeconds`, formatted s/m/h/d like the
POC board. The table's "Last activity" column shows `1m ago` with the ISO
time under it. The card shows `Last activity 1m ago`, and the inspector
shows `1m ago (ISO)`. With no `ageSeconds` it shows the ISO time only. With
no activity it shows `unknown` or `No activity`. The relaunch notice still
takes precedence.
## Known limits
- **Sending while the seat is already working.** The notice clears on the
first newer non-working scan. That scan can be the end of the *previous*
task, whose answer then shows as "Last assistant text" even though the
reply hasn't been answered. In pi, a queued steer is written right after
that final entry, so the window is milliseconds against a 10-second poll.
It is still not zero, and the page can't tell which task an answer belongs
to. The robust rule needs the board to expose the timestamp of the last
user entry, so the page can require a user entry at or after the send
before clearing. That is a board change and a follow-up.
- **sentAt is stamped after `agent-send` exits,** which can take up to 15
seconds. A turn that starts and finishes inside that window keeps the
notice until the seat's next write. That is negligible for a local paste.
Clock skew is not a concern, because the board stamps both sentAt and
lastActivity from the same host clock.
- **The notice is in the inspector only.** While a reply is pending, the card
and table still show the old preview, and the row stays under "Waiting on
you". A pending marker on the card is a follow-up if Jason's complaint
covers the board view.
## Evidence
- `node --test --test-concurrency=1 packages/webui/tests/`: 9/9 pass. The
first 8 are unchanged; the new test takes about 21 s because it waits on two
real 10-second polls; the clear-once step uses Refresh. Log:
`/tmp/dewey-return-flow-suite.txt`.
- No repository script runs the webui tests except `packages/webui` `npm test`.
- The baseline fails as described above. Mutation check: deleting the clearing loop in `accept()` makes the test fail at the clear-once assertion.
- Screenshots in `evidence/`, made by `shots.mjs`: table age at 1440; pending
at 1440, 320 and 320 scrolled to the notice (`pending-320-reply.png`);
answered at 1440 and 320. No horizontal overflow at 320.
- Hashes: `CANDIDATE.sha256`. Baseline: `BASELINE.sha256`.
## Not in scope
The full plan regression covers both engines, streaming, delayed results,
old-controller events and Resume. That belongs to CHAT-03 and CHAT-06, which
are held for Jason. Faster polling after a send was left out on purpose.
## R2 changes (after Filbert's R1 verdict: revise, small)
1. Corrected the baseline statement above.
2. The test now checks that the notice clears once: after NEW_ANSWER, a
working entry with no new send, then Refresh, must not bring the notice
back.
3. Documented the send-while-working limit, the sentAt ordering and the
inspector-only scope.
4. Nit: a relaunched row keeps "Historical" in the pending label.
5. Added the scrolled 320 screenshot.
@@ -0,0 +1,160 @@
--- a/packages/webui/src/public/app.js
+++ b/packages/webui/src/public/app.js
@@ -4,6 +4,8 @@
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const key = r => `${r.project}/${r.agent}`;
const drafts = new Map(), receipts = new Map(), sending = new Set();
+ // Delivered replies with no newer answer yet, by row: the board's sentAt. Page memory only.
+ const awaiting = new Map();
let data = null, project = null, selected = null, returnFocus = null, paused = false, busy = false, timer;
let board = 'the configured board', palette = 'harbor', mode = 'light';
const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' };
@@ -13,6 +15,22 @@
const badge = state => `<span class="badge ${ { working: 'badge-ok', waiting: 'badge-attn', error: 'badge-danger' }[state] || 'badge-muted'}">${esc(state || 'unknown')}</span>`;
const connectorStatus = r => r.connector ? `<span class="source">${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'} · owner ${esc(r.connector.ownerState)}</span>` : '';
const relaunchNotice = r => r.relaunchedAt ? `relaunched at ${r.relaunchedAt}, no messages since` : '';
+ // Relative age is the board's ageSeconds: time since last activity as of the scan, not session lifetime (#1507).
+ function age(s) {
+ if (typeof s !== 'number' || !(s >= 0)) return null;
+ const m = Math.floor(s / 60), h = Math.floor(m / 60);
+ return s < 60 ? `${s}s` : m < 60 ? `${m}m` : h < 24 ? `${h}h` : `${Math.floor(h / 24)}d`;
+ }
+ const activity = r => r.lastActivity ? (age(r.ageSeconds) ? `${age(r.ageSeconds)} ago` : r.lastActivity) : null;
+ // A reply is pending until the seat is no longer working and has written after the send.
+ // If either time is unparsable, the page falls back to the plain label rather than guessing.
+ function pending(r) {
+ const sent = awaiting.get(key(r));
+ if (sent === undefined) return false;
+ if (r.state === 'working') return true;
+ const s = Date.parse(sent), a = Date.parse(r.lastActivity);
+ return Number.isFinite(s) && Number.isFinite(a) && a < s;
+ }
const canReply = r => !r.connector && r.registered && r.registered.alive !== false && r.registered.tmux?.session;
const row = id => data?.sessions.find(r => key(r) === id);
const announce = text => { $('announce').textContent = text; };
@@ -37,11 +55,11 @@
const openButton = r => `<button type="button" class="session-open" data-open="${esc(key(r))}" aria-controls="inspector" aria-expanded="${selected === key(r)}">${esc(r.agent)}</button>`;
function cards(rows) {
if (!rows.length) return '<p class="muted">Nothing here.</p>';
- return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}${connectorStatus(r)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</p><p class="preview">${esc(relaunchNotice(r) || r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted">${esc(relaunchNotice(r) || r.lastActivity || 'No activity')}</span>${seenButton(r)}</div></li>`).join('')}</ul>`;
+ return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}${connectorStatus(r)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</p><p class="preview">${esc(relaunchNotice(r) || r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted">${esc(relaunchNotice(r) || (activity(r) ? `Last activity ${activity(r)}` : 'No activity'))}</span>${seenButton(r)}</div></li>`).join('')}</ul>`;
}
function table(rows) {
if (!rows.length) return '<p class="state">No sessions match these filters.</p>';
- return `<div class="table-wrap" tabindex="0" role="region" aria-label="Session table, scroll horizontally for all columns"><table class="sessions"><thead><tr>${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr data-row="${esc(key(r))}"><td>${openButton(r)}<span class="source">${esc(r.project)}</span></td><td>${badge(r.state)}${connectorStatus(r)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</td><td>${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}</td><td>${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}</td><td>${esc(r.model || 'unknown')}<span class="source">${esc(r.provider)}</span></td><td>${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}</td><td>${esc(relaunchNotice(r) || r.lastActivity || 'unknown')}</td></tr>`).join('')}</tbody></table></div>`;
+ return `<div class="table-wrap" tabindex="0" role="region" aria-label="Session table, scroll horizontally for all columns"><table class="sessions"><thead><tr>${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr data-row="${esc(key(r))}"><td>${openButton(r)}<span class="source">${esc(r.project)}</span></td><td>${badge(r.state)}${connectorStatus(r)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</td><td>${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}</td><td>${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}</td><td>${esc(r.model || 'unknown')}<span class="source">${esc(r.provider)}</span></td><td>${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}</td><td>${relaunchNotice(r) ? esc(relaunchNotice(r)) : activity(r) ? `${esc(activity(r))}${activity(r) === r.lastActivity ? '' : `<span class="source">${esc(r.lastActivity)}</span>`}` : 'unknown'}</td></tr>`).join('')}</tbody></table></div>`;
}
function registered(r) {
const reg = r.registered;
@@ -55,11 +73,11 @@
if (!selected) return;
if (!r) { $('inspection').innerHTML = '<p>This session is no longer in the board scan. Its draft is kept until this page closes.</p>'; return; }
$('inspector-title').textContent = r.agent;
- const fields = [ ['State', r.state], ['Project', r.project], ['Task', r.task || 'unknown'], ['Task source', labels[r.taskSource] || r.taskSource || 'unknown'], ['Task set by', r.taskSetBy ? `${r.taskSetBy} (as claimed by the caller, not verified)` : 'not applicable (task is not from a registration)'], ['Active project', r.activeProject || 'unknown'], ['Workspace', r.workspace || 'unknown'], ['Model', [r.provider, r.model].filter(Boolean).join('/') || 'unknown'], ['Registered', registered(r)], [r.relaunchedAt ? 'Historical last activity' : 'Last activity', r.lastActivity || 'unknown'] ];
+ const fields = [ ['State', r.state], ['Project', r.project], ['Task', r.task || 'unknown'], ['Task source', labels[r.taskSource] || r.taskSource || 'unknown'], ['Task set by', r.taskSetBy ? `${r.taskSetBy} (as claimed by the caller, not verified)` : 'not applicable (task is not from a registration)'], ['Active project', r.activeProject || 'unknown'], ['Workspace', r.workspace || 'unknown'], ['Model', [r.provider, r.model].filter(Boolean).join('/') || 'unknown'], ['Registered', registered(r)], [r.relaunchedAt ? 'Historical last activity' : 'Last activity', !r.lastActivity ? 'unknown' : activity(r) === r.lastActivity ? r.lastActivity : `${activity(r)} (${r.lastActivity})`] ];
if (r.relaunchedAt) fields.unshift(['Current activity', relaunchNotice(r)]);
if (r.connector) fields.push(['Connector', `${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'}; owner ${r.connector.ownerState}`]);
const receipt = receipts.get(selected);
- $('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt><dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
+ $('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}${pending(r) ? `<dt>Reply</dt><dd>Waiting for a reply to your message sent ${esc(awaiting.get(selected))}. The page checks every 10 seconds${paused ? ' once you resume' : ''}.</dd><dt>${r.relaunchedAt ? 'Historical assistant text' : 'Previous assistant text'}, before your message</dt>` : `<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt>`}<dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
}
// Restore by data attribute equality, never interpolate API ids into selectors.
function focusSnapshot() {
@@ -99,7 +117,9 @@
}
function accept(value) {
if (!value || !Array.isArray(value.sessions) || value.sessions.some(r => !r || typeof r.project !== 'string' || typeof r.agent !== 'string')) throw new Error('Invalid board session data');
- data = value; $('error').hidden = true; render();
+ data = value; $('error').hidden = true;
+ for (const id of awaiting.keys()) { const r = row(id); if (r && !pending(r)) awaiting.delete(id); }
+ render();
}
function error(err) { $('error').hidden = false; $('error').textContent = `${err.message} Board: ${board}. ${data ? 'Showing last known data.' : 'No board data loaded.'} Use Refresh to try again.`; }
function schedule() { clearTimeout(timer); if (!paused) timer = setTimeout(refresh, 10000); }
@@ -136,6 +156,7 @@
drafts.set(id, text); sending.add(id); render();
try {
const result = await api('/api/reply', { agent: id, text }); receipts.set(id, result);
+ if (result.delivered) awaiting.set(id, result.sentAt);
// Do not discard text typed while the request was in flight.
if (result.delivered && drafts.get(id) === text) drafts.delete(id);
if (result.delivered && !paused) await refresh();
--- a/packages/webui/tests/return-flow.test.mjs
+++ b/packages/webui/tests/return-flow.test.mjs
@@ -0,0 +1,76 @@
+// Jason's 2026-09-13 report (#1507 comment 26082): sending works, the return
+// flow does not. This replays his sequence against the real board scanner and
+// WebUI: send, then the seat appends user, toolCall, toolResult and a new final
+// answer. The answer must appear in the SAME open inspector through auto-refresh
+// alone, once, without the previous answer passing as the reply, and without
+// losing a draft typed meanwhile. Session-file polling only: the live adapters
+// and streaming belong to CHAT-03, not this test.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { startServer as startBoard } from '../../control-board/src/serve.mjs';
+import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
+import { startServer } from '../src/serve.mjs';
+import { browser } from './browser.mjs';
+import { close } from './fixture.mjs';
+
+const at = s => new Date(Date.now() + s * 1000).toISOString();
+const message = (timestamp, m) => JSON.stringify({ type: 'message', timestamp, message: m }) + '\n';
+const assistant = (timestamp, text) => message(timestamp, { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }] });
+
+test('reported return flow and relative Age: reply sent from the inspector, then the new answer appears there without manual refresh', { timeout: 90000 }, async () => {
+ const root = mkdtempSync(join(tmpdir(), 'webui-return-'));
+ let board, web, b;
+ try {
+ const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir);
+ const file = join(sessionsDir, 's.jsonl');
+ writeFileSync(file, message(at(-120), { role: 'user', content: [{ type: 'text', text: 'Start' }] }) + assistant(at(-90), 'Input needed: OLD_ANSWER'));
+ const seatsDir = join(root, 'seats'), captures = [];
+ writeRegistration(seatsDir, makeRegistration({ resolved: { seat: 'fixture', project: 'repo', sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: root }, task: 'Fixed task', tmux: { session: 'fixture', socket: null }, pid: process.pid, now: () => new Date(Date.now() - 300000) }));
+ board = await startBoard({ port: 0, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true,
+ exec: (f, args) => { captures.push(args); return { status: 0, stdout: '', stderr: '' }; } });
+ web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` });
+ b = await browser(); await b.viewport(1440, 1000);
+ const wait = (expression, ms = 20000) => b.evaluate(`(async()=>{for(const end=Date.now()+${ms};Date.now()<end;){if(${expression})return true;await new Promise(r=>setTimeout(r,100))}throw Error('timeout: '+${JSON.stringify(expression)})})()`);
+ const inspection = () => b.evaluate('document.querySelector("#inspection").textContent');
+ await b.navigate(`http://127.0.0.1:${web.address().port}`); await wait('document.querySelector("table.sessions [data-open]")');
+ // Relative Age (#1507): the board's ageSeconds, labelled as time since last activity, with the timestamp kept.
+ assert.match(await b.evaluate('document.querySelector("table.sessions tbody td:last-child").textContent'), /^1m ago\d{4}-\d\d-\d\dT/);
+ await b.evaluate('document.querySelector("table.sessions [data-open]").click()');
+ assert.match(await inspection(), /OLD_ANSWER/);
+ assert.match(await inspection(), /Last activity1m ago \(\d{4}-/);
+
+ await b.evaluate('(() => { const t = document.querySelector("#reply"); t.value = "REPLY_FROM_JASON"; t.dispatchEvent(new Event("input", { bubbles: true })); document.querySelector(".reply-form").requestSubmit(); })()');
+ await wait('document.querySelector(".receipt")?.textContent.includes("delivered")');
+ assert.ok(captures.some(args => args.some(a => a.startsWith('REPLY_FROM_JASON\n'))), 'reply reached the transport');
+ assert.equal(await b.evaluate('document.querySelector("#reply").value'), '', 'delivered draft cleared');
+
+ // The seat takes the message and starts a tool call. No answer yet.
+ appendFileSync(file, message(at(0), { role: 'user', content: [{ type: 'text', text: 'REPLY_FROM_JASON' }] })
+ + message(at(1), { role: 'assistant', stopReason: 'toolUse', content: [{ type: 'toolCall', id: 'call-1', name: 'read', arguments: { path: 'x' } }] }));
+ await wait('[...document.querySelectorAll("#inspection dt")].find(d => d.textContent === "State")?.nextElementSibling.textContent === "working"');
+ const pending = await inspection();
+ assert.match(pending, /Waiting for a reply/, 'the page says a reply is pending');
+ assert.doesNotMatch(pending, /Last assistant text/, 'the previous answer is not presented as the latest reply');
+ // Typed while the seat works; auto-refresh must keep it and the caret.
+ await b.evaluate('(() => { const t = document.querySelector("#reply"); t.focus(); t.value = "NEXT_DRAFT"; t.setSelectionRange(4, 4); t.dispatchEvent(new Event("input", { bubbles: true })); })()');
+
+ appendFileSync(file, message(at(2), { role: 'toolResult', toolCallId: 'call-1', toolName: 'read', content: [{ type: 'text', text: 'file body' }], isError: false })
+ + assistant(at(3), 'Input needed: NEW_ANSWER'));
+ await wait('document.querySelector("#inspection").textContent.includes("NEW_ANSWER")');
+ const answered = await inspection();
+ assert.equal(answered.split('NEW_ANSWER').length, 2, 'answer shown once in the inspector');
+ assert.doesNotMatch(answered, /OLD_ANSWER|Waiting for a reply/);
+ assert.equal(await b.evaluate('document.querySelector("#inspector-title").textContent'), 'fixture', 'same conversation still open');
+ assert.deepEqual(await b.evaluate('(() => { const t = document.querySelector("#reply"); return [t.value, t.selectionStart, document.activeElement === t]; })()'), ['NEXT_DRAFT', 4, true]);
+ // The notice clears once. Later work with no new send must not bring back the old sentAt (Filbert R1 item 2).
+ appendFileSync(file, message(at(4), { role: 'user', content: [{ type: 'text', text: 'typed in the seat terminal' }] }));
+ await b.evaluate('document.querySelector("#refresh").click()');
+ await wait('[...document.querySelectorAll("#inspection dt")].find(d => d.textContent === "State")?.nextElementSibling.textContent === "working"');
+ const later = await inspection();
+ assert.doesNotMatch(later, /Waiting for a reply|before your message/);
+ assert.match(later, /Last assistant text/);
+ } finally { if (b) await b.close(); if (web) await close(web); if (board) await close(board); rmSync(root, { recursive: true, force: true }); }
+});
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

@@ -0,0 +1,42 @@
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const R = '/mnt/storage/src/mosaic-stack/packages/';
const { startServer: startBoard } = await import(R + 'control-board/src/serve.mjs');
const { makeRegistration, writeRegistration } = await import(R + 'seat/src/seat.mjs');
const { startServer } = await import(R + 'webui/src/serve.mjs');
const { browser } = await import(R + 'webui/tests/browser.mjs');
const { close } = await import(R + 'webui/tests/fixture.mjs');
const out = new URL('./evidence', import.meta.url).pathname;
const at = s => new Date(Date.now() + s * 1000).toISOString();
const message = (timestamp, m) => JSON.stringify({ type: 'message', timestamp, message: m }) + '\n';
const root = mkdtempSync(join(tmpdir(), 'webui-shots-'));
const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir); const file = join(sessionsDir, 's.jsonl');
writeFileSync(file, message(at(-4000), { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text: 'Input needed: which branch should I use for the fixture?' }] }));
const seatsDir = join(root, 'seats');
writeRegistration(seatsDir, makeRegistration({ resolved: { seat: 'fixture', project: 'repo', sessionsDir, seatDir: root, launchScript: join(root, 'u.sh'), layout: 'repo', defaultWorkspace: root }, task: 'Fixed task', tmux: { session: 'fixture', socket: null }, pid: process.pid, now: () => new Date(Date.now() - 9e6) }));
const board = await startBoard({ port: 0, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true, exec: () => ({ status: 0, stdout: '', stderr: '' }) });
const web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` });
const b = await browser();
const wait = e => b.evaluate(`(async()=>{for(const end=Date.now()+20000;Date.now()<end;){if(${e})return true;await new Promise(r=>setTimeout(r,100))}throw Error('timeout')})()`);
try {
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${web.address().port}`); await wait('document.querySelector("table.sessions [data-open]")');
await b.screenshot(`${out}/age-table-1440.png`);
await b.evaluate('document.querySelector("table.sessions [data-open]").click()');
await b.evaluate('(()=>{const t=document.querySelector("#reply");t.value="Use refactor.";t.dispatchEvent(new Event("input",{bubbles:true}));document.querySelector(".reply-form").requestSubmit()})()');
await wait('document.querySelector(".receipt")');
appendFileSync(file, message(at(1), { role: 'user', content: [{ type: 'text', text: 'Use refactor.' }] }) + message(at(2), { role: 'assistant', stopReason: 'toolUse', content: [{ type: 'toolCall', id: 'c', name: 'read', arguments: {} }] }));
await wait('document.querySelector("#inspection").textContent.includes("Waiting for a reply")');
await b.screenshot(`${out}/pending-1440.png`);
await b.viewport(320, 900); await new Promise(r => setTimeout(r, 300));
await b.screenshot(`${out}/pending-320.png`);
await b.evaluate('[...document.querySelectorAll("#inspection dt")].find(d => d.textContent === "Reply").scrollIntoView({ block: "start" })');
await b.screenshot(`${out}/pending-320-reply.png`);
console.log('overflow320', await b.evaluate('document.documentElement.scrollWidth<=innerWidth'));
appendFileSync(file, message(at(3), { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text: 'Input needed: done on refactor. Shall I run the suite?' }] }));
await wait('document.querySelector("#inspection").textContent.includes("Shall I run")');
await b.screenshot(`${out}/answered-320.png`);
await b.viewport(1440, 900); await new Promise(r => setTimeout(r, 300));
await b.screenshot(`${out}/answered-1440.png`);
} finally { await b.close(); await close(web); await close(board); rmSync(root, { recursive: true, force: true }); }
+26 -5
View File
@@ -4,6 +4,8 @@
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const key = r => `${r.project}/${r.agent}`; const key = r => `${r.project}/${r.agent}`;
const drafts = new Map(), receipts = new Map(), sending = new Set(); const drafts = new Map(), receipts = new Map(), sending = new Set();
// Delivered replies with no newer answer yet, by row: the board's sentAt. Page memory only.
const awaiting = new Map();
let data = null, project = null, selected = null, returnFocus = null, paused = false, busy = false, timer; let data = null, project = null, selected = null, returnFocus = null, paused = false, busy = false, timer;
let board = 'the configured board', palette = 'harbor', mode = 'light'; let board = 'the configured board', palette = 'harbor', mode = 'light';
const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' }; const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' };
@@ -13,6 +15,22 @@
const badge = state => `<span class="badge ${ { working: 'badge-ok', waiting: 'badge-attn', error: 'badge-danger' }[state] || 'badge-muted'}">${esc(state || 'unknown')}</span>`; const badge = state => `<span class="badge ${ { working: 'badge-ok', waiting: 'badge-attn', error: 'badge-danger' }[state] || 'badge-muted'}">${esc(state || 'unknown')}</span>`;
const connectorStatus = r => r.connector ? `<span class="source">${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'} · owner ${esc(r.connector.ownerState)}</span>` : ''; const connectorStatus = r => r.connector ? `<span class="source">${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'} · owner ${esc(r.connector.ownerState)}</span>` : '';
const relaunchNotice = r => r.relaunchedAt ? `relaunched at ${r.relaunchedAt}, no messages since` : ''; const relaunchNotice = r => r.relaunchedAt ? `relaunched at ${r.relaunchedAt}, no messages since` : '';
// Relative age is the board's ageSeconds: time since last activity as of the scan, not session lifetime (#1507).
function age(s) {
if (typeof s !== 'number' || !(s >= 0)) return null;
const m = Math.floor(s / 60), h = Math.floor(m / 60);
return s < 60 ? `${s}s` : m < 60 ? `${m}m` : h < 24 ? `${h}h` : `${Math.floor(h / 24)}d`;
}
const activity = r => r.lastActivity ? (age(r.ageSeconds) ? `${age(r.ageSeconds)} ago` : r.lastActivity) : null;
// A reply is pending until the seat is no longer working and has written after the send.
// If either time is unparsable, the page falls back to the plain label rather than guessing.
function pending(r) {
const sent = awaiting.get(key(r));
if (sent === undefined) return false;
if (r.state === 'working') return true;
const s = Date.parse(sent), a = Date.parse(r.lastActivity);
return Number.isFinite(s) && Number.isFinite(a) && a < s;
}
const canReply = r => !r.connector && r.registered && r.registered.alive !== false && r.registered.tmux?.session; const canReply = r => !r.connector && r.registered && r.registered.alive !== false && r.registered.tmux?.session;
const row = id => data?.sessions.find(r => key(r) === id); const row = id => data?.sessions.find(r => key(r) === id);
const announce = text => { $('announce').textContent = text; }; const announce = text => { $('announce').textContent = text; };
@@ -37,11 +55,11 @@
const openButton = r => `<button type="button" class="session-open" data-open="${esc(key(r))}" aria-controls="inspector" aria-expanded="${selected === key(r)}">${esc(r.agent)}</button>`; const openButton = r => `<button type="button" class="session-open" data-open="${esc(key(r))}" aria-controls="inspector" aria-expanded="${selected === key(r)}">${esc(r.agent)}</button>`;
function cards(rows) { function cards(rows) {
if (!rows.length) return '<p class="muted">Nothing here.</p>'; if (!rows.length) return '<p class="muted">Nothing here.</p>';
return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}${connectorStatus(r)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</p><p class="preview">${esc(relaunchNotice(r) || r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted">${esc(relaunchNotice(r) || r.lastActivity || 'No activity')}</span>${seenButton(r)}</div></li>`).join('')}</ul>`; return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}${connectorStatus(r)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</p><p class="preview">${esc(relaunchNotice(r) || r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted">${esc(relaunchNotice(r) || (activity(r) ? `Last activity ${activity(r)}` : 'No activity'))}</span>${seenButton(r)}</div></li>`).join('')}</ul>`;
} }
function table(rows) { function table(rows) {
if (!rows.length) return '<p class="state">No sessions match these filters.</p>'; if (!rows.length) return '<p class="state">No sessions match these filters.</p>';
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Session table, scroll horizontally for all columns"><table class="sessions"><thead><tr>${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr data-row="${esc(key(r))}"><td>${openButton(r)}<span class="source">${esc(r.project)}</span></td><td>${badge(r.state)}${connectorStatus(r)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</td><td>${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}</td><td>${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}</td><td>${esc(r.model || 'unknown')}<span class="source">${esc(r.provider)}</span></td><td>${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}</td><td>${esc(relaunchNotice(r) || r.lastActivity || 'unknown')}</td></tr>`).join('')}</tbody></table></div>`; return `<div class="table-wrap" tabindex="0" role="region" aria-label="Session table, scroll horizontally for all columns"><table class="sessions"><thead><tr>${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr data-row="${esc(key(r))}"><td>${openButton(r)}<span class="source">${esc(r.project)}</span></td><td>${badge(r.state)}${connectorStatus(r)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</td><td>${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}</td><td>${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}</td><td>${esc(r.model || 'unknown')}<span class="source">${esc(r.provider)}</span></td><td>${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}</td><td>${relaunchNotice(r) ? esc(relaunchNotice(r)) : activity(r) ? `${esc(activity(r))}${activity(r) === r.lastActivity ? '' : `<span class="source">${esc(r.lastActivity)}</span>`}` : 'unknown'}</td></tr>`).join('')}</tbody></table></div>`;
} }
function registered(r) { function registered(r) {
const reg = r.registered; const reg = r.registered;
@@ -55,11 +73,11 @@
if (!selected) return; if (!selected) return;
if (!r) { $('inspection').innerHTML = '<p>This session is no longer in the board scan. Its draft is kept until this page closes.</p>'; return; } if (!r) { $('inspection').innerHTML = '<p>This session is no longer in the board scan. Its draft is kept until this page closes.</p>'; return; }
$('inspector-title').textContent = r.agent; $('inspector-title').textContent = r.agent;
const fields = [ ['State', r.state], ['Project', r.project], ['Task', r.task || 'unknown'], ['Task source', labels[r.taskSource] || r.taskSource || 'unknown'], ['Task set by', r.taskSetBy ? `${r.taskSetBy} (as claimed by the caller, not verified)` : 'not applicable (task is not from a registration)'], ['Active project', r.activeProject || 'unknown'], ['Workspace', r.workspace || 'unknown'], ['Model', [r.provider, r.model].filter(Boolean).join('/') || 'unknown'], ['Registered', registered(r)], [r.relaunchedAt ? 'Historical last activity' : 'Last activity', r.lastActivity || 'unknown'] ]; const fields = [ ['State', r.state], ['Project', r.project], ['Task', r.task || 'unknown'], ['Task source', labels[r.taskSource] || r.taskSource || 'unknown'], ['Task set by', r.taskSetBy ? `${r.taskSetBy} (as claimed by the caller, not verified)` : 'not applicable (task is not from a registration)'], ['Active project', r.activeProject || 'unknown'], ['Workspace', r.workspace || 'unknown'], ['Model', [r.provider, r.model].filter(Boolean).join('/') || 'unknown'], ['Registered', registered(r)], [r.relaunchedAt ? 'Historical last activity' : 'Last activity', !r.lastActivity ? 'unknown' : activity(r) === r.lastActivity ? r.lastActivity : `${activity(r)} (${r.lastActivity})`] ];
if (r.relaunchedAt) fields.unshift(['Current activity', relaunchNotice(r)]); if (r.relaunchedAt) fields.unshift(['Current activity', relaunchNotice(r)]);
if (r.connector) fields.push(['Connector', `${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'}; owner ${r.connector.ownerState}`]); if (r.connector) fields.push(['Connector', `${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'}; owner ${r.connector.ownerState}`]);
const receipt = receipts.get(selected); const receipt = receipts.get(selected);
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt><dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`; $('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}${pending(r) ? `<dt>Reply</dt><dd>Waiting for a reply to your message sent ${esc(awaiting.get(selected))}. The page checks every 10 seconds${paused ? ' once you resume' : ''}.</dd><dt>${r.relaunchedAt ? 'Historical assistant text' : 'Previous assistant text'}, before your message</dt>` : `<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt>`}<dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
} }
// Restore by data attribute equality, never interpolate API ids into selectors. // Restore by data attribute equality, never interpolate API ids into selectors.
function focusSnapshot() { function focusSnapshot() {
@@ -99,7 +117,9 @@
} }
function accept(value) { function accept(value) {
if (!value || !Array.isArray(value.sessions) || value.sessions.some(r => !r || typeof r.project !== 'string' || typeof r.agent !== 'string')) throw new Error('Invalid board session data'); if (!value || !Array.isArray(value.sessions) || value.sessions.some(r => !r || typeof r.project !== 'string' || typeof r.agent !== 'string')) throw new Error('Invalid board session data');
data = value; $('error').hidden = true; render(); data = value; $('error').hidden = true;
for (const id of awaiting.keys()) { const r = row(id); if (r && !pending(r)) awaiting.delete(id); }
render();
} }
function error(err) { $('error').hidden = false; $('error').textContent = `${err.message} Board: ${board}. ${data ? 'Showing last known data.' : 'No board data loaded.'} Use Refresh to try again.`; } function error(err) { $('error').hidden = false; $('error').textContent = `${err.message} Board: ${board}. ${data ? 'Showing last known data.' : 'No board data loaded.'} Use Refresh to try again.`; }
function schedule() { clearTimeout(timer); if (!paused) timer = setTimeout(refresh, 10000); } function schedule() { clearTimeout(timer); if (!paused) timer = setTimeout(refresh, 10000); }
@@ -136,6 +156,7 @@
drafts.set(id, text); sending.add(id); render(); drafts.set(id, text); sending.add(id); render();
try { try {
const result = await api('/api/reply', { agent: id, text }); receipts.set(id, result); const result = await api('/api/reply', { agent: id, text }); receipts.set(id, result);
if (result.delivered) awaiting.set(id, result.sentAt);
// Do not discard text typed while the request was in flight. // Do not discard text typed while the request was in flight.
if (result.delivered && drafts.get(id) === text) drafts.delete(id); if (result.delivered && drafts.get(id) === text) drafts.delete(id);
if (result.delivered && !paused) await refresh(); if (result.delivered && !paused) await refresh();
+76
View File
@@ -0,0 +1,76 @@
// Jason's 2026-09-13 report (#1507 comment 26082): sending works, the return
// flow does not. This replays his sequence against the real board scanner and
// WebUI: send, then the seat appends user, toolCall, toolResult and a new final
// answer. The answer must appear in the SAME open inspector through auto-refresh
// alone, once, without the previous answer passing as the reply, and without
// losing a draft typed meanwhile. Session-file polling only: the live adapters
// and streaming belong to CHAT-03, not this test.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
const at = s => new Date(Date.now() + s * 1000).toISOString();
const message = (timestamp, m) => JSON.stringify({ type: 'message', timestamp, message: m }) + '\n';
const assistant = (timestamp, text) => message(timestamp, { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }] });
test('reported return flow and relative Age: reply sent from the inspector, then the new answer appears there without manual refresh', { timeout: 90000 }, async () => {
const root = mkdtempSync(join(tmpdir(), 'webui-return-'));
let board, web, b;
try {
const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir);
const file = join(sessionsDir, 's.jsonl');
writeFileSync(file, message(at(-120), { role: 'user', content: [{ type: 'text', text: 'Start' }] }) + assistant(at(-90), 'Input needed: OLD_ANSWER'));
const seatsDir = join(root, 'seats'), captures = [];
writeRegistration(seatsDir, makeRegistration({ resolved: { seat: 'fixture', project: 'repo', sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: root }, task: 'Fixed task', tmux: { session: 'fixture', socket: null }, pid: process.pid, now: () => new Date(Date.now() - 300000) }));
board = await startBoard({ port: 0, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true,
exec: (f, args) => { captures.push(args); return { status: 0, stdout: '', stderr: '' }; } });
web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` });
b = await browser(); await b.viewport(1440, 1000);
const wait = (expression, ms = 20000) => b.evaluate(`(async()=>{for(const end=Date.now()+${ms};Date.now()<end;){if(${expression})return true;await new Promise(r=>setTimeout(r,100))}throw Error('timeout: '+${JSON.stringify(expression)})})()`);
const inspection = () => b.evaluate('document.querySelector("#inspection").textContent');
await b.navigate(`http://127.0.0.1:${web.address().port}`); await wait('document.querySelector("table.sessions [data-open]")');
// Relative Age (#1507): the board's ageSeconds, labelled as time since last activity, with the timestamp kept.
assert.match(await b.evaluate('document.querySelector("table.sessions tbody td:last-child").textContent'), /^1m ago\d{4}-\d\d-\d\dT/);
await b.evaluate('document.querySelector("table.sessions [data-open]").click()');
assert.match(await inspection(), /OLD_ANSWER/);
assert.match(await inspection(), /Last activity1m ago \(\d{4}-/);
await b.evaluate('(() => { const t = document.querySelector("#reply"); t.value = "REPLY_FROM_JASON"; t.dispatchEvent(new Event("input", { bubbles: true })); document.querySelector(".reply-form").requestSubmit(); })()');
await wait('document.querySelector(".receipt")?.textContent.includes("delivered")');
assert.ok(captures.some(args => args.some(a => a.startsWith('REPLY_FROM_JASON\n'))), 'reply reached the transport');
assert.equal(await b.evaluate('document.querySelector("#reply").value'), '', 'delivered draft cleared');
// The seat takes the message and starts a tool call. No answer yet.
appendFileSync(file, message(at(0), { role: 'user', content: [{ type: 'text', text: 'REPLY_FROM_JASON' }] })
+ message(at(1), { role: 'assistant', stopReason: 'toolUse', content: [{ type: 'toolCall', id: 'call-1', name: 'read', arguments: { path: 'x' } }] }));
await wait('[...document.querySelectorAll("#inspection dt")].find(d => d.textContent === "State")?.nextElementSibling.textContent === "working"');
const pending = await inspection();
assert.match(pending, /Waiting for a reply/, 'the page says a reply is pending');
assert.doesNotMatch(pending, /Last assistant text/, 'the previous answer is not presented as the latest reply');
// Typed while the seat works; auto-refresh must keep it and the caret.
await b.evaluate('(() => { const t = document.querySelector("#reply"); t.focus(); t.value = "NEXT_DRAFT"; t.setSelectionRange(4, 4); t.dispatchEvent(new Event("input", { bubbles: true })); })()');
appendFileSync(file, message(at(2), { role: 'toolResult', toolCallId: 'call-1', toolName: 'read', content: [{ type: 'text', text: 'file body' }], isError: false })
+ assistant(at(3), 'Input needed: NEW_ANSWER'));
await wait('document.querySelector("#inspection").textContent.includes("NEW_ANSWER")');
const answered = await inspection();
assert.equal(answered.split('NEW_ANSWER').length, 2, 'answer shown once in the inspector');
assert.doesNotMatch(answered, /OLD_ANSWER|Waiting for a reply/);
assert.equal(await b.evaluate('document.querySelector("#inspector-title").textContent'), 'fixture', 'same conversation still open');
assert.deepEqual(await b.evaluate('(() => { const t = document.querySelector("#reply"); return [t.value, t.selectionStart, document.activeElement === t]; })()'), ['NEXT_DRAFT', 4, true]);
// The notice clears once. Later work with no new send must not bring back the old sentAt (Filbert R1 item 2).
appendFileSync(file, message(at(4), { role: 'user', content: [{ type: 'text', text: 'typed in the seat terminal' }] }));
await b.evaluate('document.querySelector("#refresh").click()');
await wait('[...document.querySelectorAll("#inspection dt")].find(d => d.textContent === "State")?.nextElementSibling.textContent === "working"');
const later = await inspection();
assert.doesNotMatch(later, /Waiting for a reply|before your message/);
assert.match(later, /Last assistant text/);
} finally { if (b) await b.close(); if (web) await close(web); if (board) await close(board); rmSync(root, { recursive: true, force: true }); }
});