Files
stack/packages/webui/tests/return-flow.test.mjs
T
jason.woltjeandClaude Opus 5.5 42c08d5285 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]>
2026-09-26 15:00:59 -05:00

77 lines
6.7 KiB
JavaScript

// 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 }); }
});