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:
@@ -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
|
||||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 }); }
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
|
||||||
|
|||||||
@@ -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 }); }
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user