diff --git a/BUILD-LOG.md b/BUILD-LOG.md index b1160d6c..fac4eb22 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -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, foundation 43, conductor 17, release 14, auth 15, discord 63, 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`. diff --git a/agents/dewey/work/return-flow-age/BASELINE.sha256 b/agents/dewey/work/return-flow-age/BASELINE.sha256 new file mode 100644 index 00000000..4ee67dbc --- /dev/null +++ b/agents/dewey/work/return-flow-age/BASELINE.sha256 @@ -0,0 +1 @@ +ce7d79a489bdfaf8216c83595d03a3d23b0f22c8c58543e21c53c40968453230 packages/webui/src/public/app.js (baseline: #1512 R1) diff --git a/agents/dewey/work/return-flow-age/CANDIDATE.sha256 b/agents/dewey/work/return-flow-age/CANDIDATE.sha256 new file mode 100644 index 00000000..f341b47d --- /dev/null +++ b/agents/dewey/work/return-flow-age/CANDIDATE.sha256 @@ -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 diff --git a/agents/dewey/work/return-flow-age/NOTES.md b/agents/dewey/work/return-flow-age/NOTES.md new file mode 100644 index 00000000..3ca7d1b9 --- /dev/null +++ b/agents/dewey/work/return-flow-age/NOTES.md @@ -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 " 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. diff --git a/agents/dewey/work/return-flow-age/candidate.patch b/agents/dewey/work/return-flow-age/candidate.patch new file mode 100644 index 00000000..68ff7335 --- /dev/null +++ b/agents/dewey/work/return-flow-age/candidate.patch @@ -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 => `${esc(state || 'unknown')}`; + const connectorStatus = r => r.connector ? `${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'} · owner ${esc(r.connector.ownerState)}` : ''; + 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 => ``; + function cards(rows) { + if (!rows.length) return '

Nothing here.

'; +- return ``; ++ return ``; + } + function table(rows) { + if (!rows.length) return '

No sessions match these filters.

'; +- return `
${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => ``).join('')}${rows.map(r => ``).join('')}
${h}
${openButton(r)}${esc(r.project)}${badge(r.state)}${connectorStatus(r)}${r.seen ? 'seen' : ''}${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}${esc(r.model || 'unknown')}${esc(r.provider)}${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}${esc(relaunchNotice(r) || r.lastActivity || 'unknown')}
`; ++ return `
${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => ``).join('')}${rows.map(r => ``).join('')}
${h}
${openButton(r)}${esc(r.project)}${badge(r.state)}${connectorStatus(r)}${r.seen ? 'seen' : ''}${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}${esc(r.model || 'unknown')}${esc(r.provider)}${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}${relaunchNotice(r) ? esc(relaunchNotice(r)) : activity(r) ? `${esc(activity(r))}${activity(r) === r.lastActivity ? '' : `${esc(r.lastActivity)}`}` : 'unknown'}
`; + } + function registered(r) { + const reg = r.registered; +@@ -55,11 +73,11 @@ + if (!selected) return; + if (!r) { $('inspection').innerHTML = '

This session is no longer in the board scan. Its draft is kept until this page closes.

'; 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 = `
${fields.map(([k, v]) => `
${k}
${esc(v)}
`).join('')}
${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}
${esc(r.lastAssistantText || 'No assistant text yet.')}
${r.lastError ? `
${r.relaunchedAt ? 'Historical last error' : 'Last error'}
${esc(r.lastError)}
` : ''}
${seenButton(r)}${canReply(r) ? `
` : `

${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}

`}${receipt ? `

${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}

` : ''}`; ++ $('inspection').innerHTML = `
${fields.map(([k, v]) => `
${k}
${esc(v)}
`).join('')}${pending(r) ? `
Reply
Waiting for a reply to your message sent ${esc(awaiting.get(selected))}. The page checks every 10 seconds${paused ? ' once you resume' : ''}.
${r.relaunchedAt ? 'Historical assistant text' : 'Previous assistant text'}, before your message
` : `
${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}
`}
${esc(r.lastAssistantText || 'No assistant text yet.')}
${r.lastError ? `
${r.relaunchedAt ? 'Historical last error' : 'Last error'}
${esc(r.lastError)}
` : ''}
${seenButton(r)}${canReply(r) ? `
` : `

${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}

`}${receipt ? `

${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}

` : ''}`; + } + // 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()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 }); } ++}); diff --git a/agents/dewey/work/return-flow-age/evidence/age-table-1440.png b/agents/dewey/work/return-flow-age/evidence/age-table-1440.png new file mode 100644 index 00000000..f7f4899a Binary files /dev/null and b/agents/dewey/work/return-flow-age/evidence/age-table-1440.png differ diff --git a/agents/dewey/work/return-flow-age/evidence/answered-1440.png b/agents/dewey/work/return-flow-age/evidence/answered-1440.png new file mode 100644 index 00000000..ab1b1154 Binary files /dev/null and b/agents/dewey/work/return-flow-age/evidence/answered-1440.png differ diff --git a/agents/dewey/work/return-flow-age/evidence/answered-320.png b/agents/dewey/work/return-flow-age/evidence/answered-320.png new file mode 100644 index 00000000..ee310747 Binary files /dev/null and b/agents/dewey/work/return-flow-age/evidence/answered-320.png differ diff --git a/agents/dewey/work/return-flow-age/evidence/pending-1440.png b/agents/dewey/work/return-flow-age/evidence/pending-1440.png new file mode 100644 index 00000000..cbd0e1db Binary files /dev/null and b/agents/dewey/work/return-flow-age/evidence/pending-1440.png differ diff --git a/agents/dewey/work/return-flow-age/evidence/pending-320-reply.png b/agents/dewey/work/return-flow-age/evidence/pending-320-reply.png new file mode 100644 index 00000000..9032d45f Binary files /dev/null and b/agents/dewey/work/return-flow-age/evidence/pending-320-reply.png differ diff --git a/agents/dewey/work/return-flow-age/evidence/pending-320.png b/agents/dewey/work/return-flow-age/evidence/pending-320.png new file mode 100644 index 00000000..4031b786 Binary files /dev/null and b/agents/dewey/work/return-flow-age/evidence/pending-320.png differ diff --git a/agents/dewey/work/return-flow-age/shots.mjs b/agents/dewey/work/return-flow-age/shots.mjs new file mode 100644 index 00000000..fad27c79 --- /dev/null +++ b/agents/dewey/work/return-flow-age/shots.mjs @@ -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()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 }); } diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js index b0e7b585..b4246cfc 100644 --- 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 => `${esc(state || 'unknown')}`; const connectorStatus = r => r.connector ? `${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'} · owner ${esc(r.connector.ownerState)}` : ''; 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 => ``; function cards(rows) { if (!rows.length) return '

Nothing here.

'; - return `
    ${rows.map(r => `
  • ${openButton(r)} ${badge(r.state)}${connectorStatus(r)}

    ${esc(r.project)} · ${esc(r.activeProject || 'unknown')}

    ${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}

    ${esc(relaunchNotice(r) || r.lastError || r.lastAssistantText || 'No assistant text yet.')}

    ${esc(relaunchNotice(r) || r.lastActivity || 'No activity')}${seenButton(r)}
  • `).join('')}
`; + return `
    ${rows.map(r => `
  • ${openButton(r)} ${badge(r.state)}${connectorStatus(r)}

    ${esc(r.project)} · ${esc(r.activeProject || 'unknown')}

    ${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}

    ${esc(relaunchNotice(r) || r.lastError || r.lastAssistantText || 'No assistant text yet.')}

    ${esc(relaunchNotice(r) || (activity(r) ? `Last activity ${activity(r)}` : 'No activity'))}${seenButton(r)}
  • `).join('')}
`; } function table(rows) { if (!rows.length) return '

No sessions match these filters.

'; - return `
${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => ``).join('')}${rows.map(r => ``).join('')}
${h}
${openButton(r)}${esc(r.project)}${badge(r.state)}${connectorStatus(r)}${r.seen ? 'seen' : ''}${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}${esc(r.model || 'unknown')}${esc(r.provider)}${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}${esc(relaunchNotice(r) || r.lastActivity || 'unknown')}
`; + return `
${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => ``).join('')}${rows.map(r => ``).join('')}
${h}
${openButton(r)}${esc(r.project)}${badge(r.state)}${connectorStatus(r)}${r.seen ? 'seen' : ''}${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}${esc(r.model || 'unknown')}${esc(r.provider)}${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}${relaunchNotice(r) ? esc(relaunchNotice(r)) : activity(r) ? `${esc(activity(r))}${activity(r) === r.lastActivity ? '' : `${esc(r.lastActivity)}`}` : 'unknown'}
`; } function registered(r) { const reg = r.registered; @@ -55,11 +73,11 @@ if (!selected) return; if (!r) { $('inspection').innerHTML = '

This session is no longer in the board scan. Its draft is kept until this page closes.

'; 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 = `
${fields.map(([k, v]) => `
${k}
${esc(v)}
`).join('')}
${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}
${esc(r.lastAssistantText || 'No assistant text yet.')}
${r.lastError ? `
${r.relaunchedAt ? 'Historical last error' : 'Last error'}
${esc(r.lastError)}
` : ''}
${seenButton(r)}${canReply(r) ? `
` : `

${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}

`}${receipt ? `

${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}

` : ''}`; + $('inspection').innerHTML = `
${fields.map(([k, v]) => `
${k}
${esc(v)}
`).join('')}${pending(r) ? `
Reply
Waiting for a reply to your message sent ${esc(awaiting.get(selected))}. The page checks every 10 seconds${paused ? ' once you resume' : ''}.
${r.relaunchedAt ? 'Historical assistant text' : 'Previous assistant text'}, before your message
` : `
${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}
`}
${esc(r.lastAssistantText || 'No assistant text yet.')}
${r.lastError ? `
${r.relaunchedAt ? 'Historical last error' : 'Last error'}
${esc(r.lastError)}
` : ''}
${seenButton(r)}${canReply(r) ? `
` : `

${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}

`}${receipt ? `

${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}

` : ''}`; } // 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(); diff --git a/packages/webui/tests/return-flow.test.mjs b/packages/webui/tests/return-flow.test.mjs new file mode 100644 index 00000000..d8dec091 --- /dev/null +++ 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()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 }); } +});