mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 20:27:56 +00:00
Ordinary greetings drew an empty Working/Done task card because card existence was a class of sticky writers: ten forceCard sites, a toolCalls>0 counter, a cardVisible latch cleared only for reusable slots, a rounds>1 history guess and a client-side ADDRESSING_ONLY_TOOLS allowlist (an open default behind a closed exception list) — never re-derived once set. The model-wait controller minted a card just to host its controls, and that shell survived a resolved wait. One predicate now decides presence (chat.js blockVisible, owner decision V1): an always-shown kind, open attention (model wait, pending stop, host-attested Stop), a child, a review group, a content row, or a terminal outcome other than Done. The completion note is not content, so a zero-tool Done turn keeps no block live, on reload or on reconnect. ensureLiveCardVisible mounts and unmounts from that predicate at every mutation; the record and its no-reopen wait ledger stay (G-D2), so a resolved wait removes the block and a stale revision cannot bring it back. Successful tool calls become visible compact rows (log_events: tool · target, then ✓ duration; failure and timeout evolve the same row, keyed by tool_call_id), so a tool-using turn shows its block from its first call; replay shows one summary row from the recorded per-tool counts (noteToolMetrics), the only replay evidence of a recovered tool failure, and block presence is the same live and on reload. A lineage-known id is minted as its parent's nested card whichever path reaches it first (#636). Deleted: taskUiStates and every forceCard/cardVisible/bufferedLiveUpdates writer, forceTaskCard, forceTaskCardVisibleChange, markTaskToolCall, markAssistantReply, markTaskComplete, scheduleTaskUiCleanup, retiredTaskIds, reanchorVisibleTaskCard, the rounds>1 guess, ADDRESSING_ONLY_TOOLS and addressingToolCallCount, the taskState parameter of replayTerminalPhase. chat.js shrinks from 201,090 to 193,100 bytes, below MAX_MODULE_BYTES, so its BYTE_DEBT row leaves the regenerated manifest. The header keeps the census verdict beside a direct block (Thinking…) and only a managed card drives Working…; any mounted unfinished block replaces the typing bubble. The three replay-evidence row tests share one owner (isReplayEvidenceRow). Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
329 lines
15 KiB
JavaScript
329 lines
15 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { readFileSync } from 'node:fs';
|
|
|
|
import {
|
|
createHistoryControls,
|
|
createHistoryResyncScheduler,
|
|
createLiveCardBound,
|
|
createTimelineAnchors,
|
|
} from '../modules/chat_render_batch.js';
|
|
import { ElementStub } from './chat_dom_fixture.js';
|
|
|
|
const chatSource = readFileSync(new URL('../modules/chat.js', import.meta.url), 'utf8');
|
|
|
|
test('the history chrome is one Load-older control; no Load-newer element is ever built', () => {
|
|
const doc = { byId: new Map(), createElement: (tag) => new ElementStub(tag, doc) };
|
|
const messages = new ElementStub('div', doc);
|
|
messages.isConnected = true;
|
|
const controls = createHistoryControls(messages);
|
|
assert.equal('newerButton' in controls, false);
|
|
const snapshot = { initialized: true, canOlder: true, canNewer: true,
|
|
olderExhausted: false, loading: '', error: null };
|
|
assert.deepEqual(controls.render(snapshot, []), { complete: false, truncated_by: [] });
|
|
assert.deepEqual(messages.children.map((node) => node.className), ['chat-load-older']);
|
|
assert.equal(messages.querySelector('.chat-load-newer'), null);
|
|
// A cache with no newer page is the only thing that ever made one appear.
|
|
const exhausted = { ...snapshot, canOlder: false, canNewer: false, olderExhausted: true };
|
|
assert.deepEqual(controls.render(exhausted, []), { complete: true, truncated_by: [] });
|
|
assert.deepEqual(messages.children.map((node) => node.className), ['chat-load-older']);
|
|
assert.equal(messages.querySelector('.chat-load-older')
|
|
.querySelector('.chat-load-older-note').textContent, 'Beginning of saved history');
|
|
assert.equal(controls.olderButton.hidden, true);
|
|
});
|
|
|
|
// ─────────────── sticky hydration / replay contracts ──────────────────────
|
|
|
|
test('sticky single-flight never swallows the post-completion resync', () => {
|
|
// [GPT#12 + Fable#1] scheduleHistorySync (700ms debounce after task
|
|
// completion) must do a REAL fetch — a lost task_done is healed only by
|
|
// refetching — so it calls syncHistory directly, never the sticky
|
|
// awaitInitialHydration shortcut.
|
|
const fn = chatSource.slice(
|
|
chatSource.indexOf('function scheduleHistorySync('),
|
|
chatSource.indexOf('function applyLiveCardState('),
|
|
);
|
|
assert.match(fn, /syncHistory\(\{ includeUser: false \}\)/);
|
|
assert.doesNotMatch(fn, /awaitInitialHydration/);
|
|
// The reconnect branch of the open handler also always refetches.
|
|
assert.match(
|
|
chatSource,
|
|
/\? syncHistory\(\{ includeUser: !historyLoaded, fromReconnect: isReconnect \}\)/,
|
|
);
|
|
// Every failed sync resets the sticky promise.
|
|
assert.ok((chatSource.match(/initialHydrationPromise = null;/g) || []).length >= 3);
|
|
});
|
|
|
|
// ──────────── replay-time resync suppression (double-fetch fix) ────────────
|
|
|
|
function makeFakeTimers() {
|
|
const pending = [];
|
|
return {
|
|
pending,
|
|
setTimer(fn, ms) {
|
|
const id = { fn, ms };
|
|
pending.push(id);
|
|
return id;
|
|
},
|
|
clearTimer(id) {
|
|
const i = pending.indexOf(id);
|
|
if (i !== -1) pending.splice(i, 1);
|
|
},
|
|
fire() {
|
|
const jobs = pending.splice(0);
|
|
for (const job of jobs) job.fn();
|
|
},
|
|
};
|
|
}
|
|
|
|
test('a finished transition during a history replay does NOT schedule the resync', () => {
|
|
const timers = makeFakeTimers();
|
|
let runs = 0;
|
|
let replayActive = true;
|
|
const scheduler = createHistoryResyncScheduler({
|
|
isReplayActive: () => replayActive,
|
|
run: () => { runs += 1; },
|
|
setTimer: timers.setTimer,
|
|
clearTimer: timers.clearTimer,
|
|
});
|
|
assert.equal(scheduler.schedule(), false);
|
|
assert.equal(timers.pending.length, 0);
|
|
timers.fire();
|
|
assert.equal(runs, 0);
|
|
// The suppression is not sticky: the same scheduler works once the replay ends.
|
|
replayActive = false;
|
|
assert.equal(scheduler.schedule(), true);
|
|
});
|
|
|
|
test('a LIVE finished transition (outside a replay) schedules a real 700ms resync', () => {
|
|
const timers = makeFakeTimers();
|
|
let runs = 0;
|
|
const scheduler = createHistoryResyncScheduler({
|
|
isReplayActive: () => false,
|
|
run: () => { runs += 1; },
|
|
setTimer: timers.setTimer,
|
|
clearTimer: timers.clearTimer,
|
|
});
|
|
assert.equal(scheduler.schedule(), true);
|
|
assert.equal(timers.pending.length, 1);
|
|
assert.equal(timers.pending[0].ms, 700);
|
|
// Re-scheduling debounces: the previous timer is replaced, not stacked.
|
|
scheduler.schedule();
|
|
assert.equal(timers.pending.length, 1);
|
|
timers.fire();
|
|
assert.equal(runs, 1);
|
|
// cancel() (instance destroy) clears a pending resync.
|
|
scheduler.schedule();
|
|
scheduler.cancel();
|
|
assert.equal(timers.pending.length, 0);
|
|
});
|
|
|
|
test('the live-card bound arms past the cap relative to the last rebuild', () => {
|
|
const bound = createLiveCardBound(200);
|
|
assert.equal(bound.isArmed(), false);
|
|
bound.observe(200);
|
|
assert.equal(bound.isArmed(), false, 'the bound is exceeded-by-one, not reached');
|
|
bound.observe(201);
|
|
assert.equal(bound.isArmed(), true);
|
|
// The rebuild consumes the arm and the population it produced becomes the floor:
|
|
// a window that itself holds more than the cap must not re-arm on the next card.
|
|
bound.settle({ rebuilt: true, size: 210 });
|
|
assert.equal(bound.isArmed(), false);
|
|
bound.observe(211);
|
|
assert.equal(bound.isArmed(), false);
|
|
bound.observe(411);
|
|
assert.equal(bound.isArmed(), true);
|
|
});
|
|
|
|
test('an arm raised while a sync is in flight is not consumed by that sync', () => {
|
|
// The window that sync fetched predates the arm, so it cannot answer for the
|
|
// cards that raised it: consuming the arm there would rebuild from a stale
|
|
// window, drop the newest cards, and leave nothing armed to replay them.
|
|
const bound = createLiveCardBound(200);
|
|
const armedAtStart = bound.begin();
|
|
assert.equal(armedAtStart, false);
|
|
bound.beginReplay();
|
|
bound.observe(201); // the window's own rows cross the cap
|
|
bound.settle({ rebuilt: false, size: 201 });
|
|
assert.equal(bound.isArmed(), true, 'the arm survives for the next sync');
|
|
const nextArmed = bound.begin();
|
|
assert.equal(nextArmed, true);
|
|
bound.settle({ rebuilt: true, size: 0 });
|
|
assert.equal(bound.isArmed(), false);
|
|
});
|
|
|
|
test('an arm raised while the fetch was in flight outlives that rebuild', () => {
|
|
// A reconnect (or first load, or Load older) fetches a window, and live cards
|
|
// cross the cap while it is in flight. That rebuild erases those cards from a
|
|
// window that never contained them, so its arm is NOT answered: the next sync
|
|
// fetches a window that does contain them.
|
|
const bound = createLiveCardBound(200);
|
|
assert.equal(bound.begin(), false);
|
|
bound.observe(201); // live frames, still in flight
|
|
bound.beginReplay(); // the synchronous replay starts here
|
|
bound.settle({ rebuilt: true, size: 0 });
|
|
assert.equal(bound.isArmed(), true, 'the older window did not answer this arm');
|
|
assert.equal(bound.begin(), true);
|
|
bound.beginReplay();
|
|
bound.settle({ rebuilt: true, size: 0 });
|
|
assert.equal(bound.isArmed(), false, 'the fresh window did');
|
|
});
|
|
|
|
test('a rebuild consumes an arm its own replay raised, and does not loop', () => {
|
|
// The bootstrap rebuild replays a window that itself holds more than the cap.
|
|
// That mints the cards, so the arm it raises is answered by the very replay that
|
|
// raised it: the new floor is that population and the next sync stays routine.
|
|
const bound = createLiveCardBound(200);
|
|
assert.equal(bound.begin(), false);
|
|
bound.beginReplay();
|
|
bound.observe(210); // the window's own rows mint these
|
|
assert.equal(bound.isArmed(), true);
|
|
bound.settle({ rebuilt: true, size: 210 });
|
|
assert.equal(bound.isArmed(), false);
|
|
bound.observe(211);
|
|
assert.equal(bound.isArmed(), false, 'no rebuild storm on a window above the cap');
|
|
});
|
|
|
|
test('while a full rebuild is armed, later completions cannot push the resync out', () => {
|
|
// The live-card bound arms the rebuild and waits for the next history sync. That
|
|
// sync is this debounced resync, and every completion used to restart its timer:
|
|
// completions arriving faster than 700ms starved it for as long as the burst
|
|
// lasted, which is precisely the busy session the bound exists for.
|
|
const timers = makeFakeTimers();
|
|
let runs = 0;
|
|
const scheduler = createHistoryResyncScheduler({
|
|
isReplayActive: () => false,
|
|
run: () => { runs += 1; },
|
|
setTimer: timers.setTimer,
|
|
clearTimer: timers.clearTimer,
|
|
});
|
|
const armed = true;
|
|
assert.equal(scheduler.schedule(armed), true);
|
|
const first = timers.pending[0];
|
|
for (let i = 0; i < 50; i += 1) scheduler.schedule(armed);
|
|
assert.equal(timers.pending.length, 1);
|
|
assert.equal(timers.pending[0], first, 'the original deadline survived the burst');
|
|
timers.fire();
|
|
assert.equal(runs, 1);
|
|
// Unarmed scheduling keeps debouncing: a quiet session still coalesces.
|
|
scheduler.schedule();
|
|
const second = timers.pending[0];
|
|
scheduler.schedule();
|
|
assert.equal(timers.pending.length, 1);
|
|
assert.notEqual(timers.pending[0], second);
|
|
});
|
|
|
|
test('chat.js hands the armed flag to the scheduler', () => {
|
|
assert.match(chatSource, /historyResyncScheduler\.schedule\(liveCardBound\.isArmed\(\)\);/);
|
|
});
|
|
|
|
test('chat.js wires the replay flag around the replay and keeps live callsites intact', () => {
|
|
// scheduleHistorySync delegates to the scheduler, whose replay gate reads
|
|
// _historyReplayActive; the flag wraps the whole replay dispatch (both the
|
|
// rebuildAll batch and the routine branch) and drops in a finally.
|
|
assert.match(chatSource, /isReplayActive: \(\) => _historyReplayActive,/);
|
|
const flagUp = chatSource.indexOf('_historyReplayActive = true;');
|
|
assert.ok(flagUp !== -1);
|
|
// (search from flagUp: the earlier `let … = false;` declaration also matches)
|
|
const replaySection = chatSource.slice(flagUp, chatSource.indexOf('_historyReplayActive = false;', flagUp));
|
|
assert.match(replaySection, /withStableViewport\(\(\) => \{/);
|
|
assert.match(replaySection, /learnSubagentLineage\(msg\)/);
|
|
assert.doesNotMatch(replaySection, /await /);
|
|
// Both finished-transition paths share settleLiveCard; the task-bound
|
|
// review lifecycle keeps its own trigger. The replay decision stays ONLY
|
|
// behind the scheduler's gate, so sharing cleanup cannot mute either path.
|
|
assert.match(chatSource, /settleLiveCard\(record, summary\.phase \|\| 'done', wasFinished\);/);
|
|
assert.match(chatSource, /settleLiveCard\(record, activePhase, wasFinished\);/);
|
|
assert.match(chatSource, /if \(!wasFinished && blockVisible\(record\)\) scheduleHistorySync\(\);/);
|
|
// The third occurrence is the scheduler re-arming when a run settles with the bound
|
|
// still armed, which is how a run that only JOINED an older in-flight fetch (and
|
|
// spent its timer on a window fetched before the arm) keeps the deadline alive.
|
|
// It is gated on !destroyed: a joined sync that settles after teardown must not
|
|
// install a fresh timer on a dead instance (the disposer invariant).
|
|
assert.equal((chatSource.match(/scheduleHistorySync\(\);/g) || []).length, 3);
|
|
assert.match(
|
|
chatSource,
|
|
/if \(!destroyed && lastHistorySyncSucceeded && liveCardBound\.isArmed\(\)\) scheduleHistorySync\(\);/,
|
|
);
|
|
assert.doesNotMatch(chatSource, /_historyReplayActive[^\n]*scheduleHistorySync/);
|
|
});
|
|
|
|
test('the Load-older control is excluded from viewport anchoring like typing', () => {
|
|
// [GPT#13] the anchor must land on the first visible TIMESTAMPED node.
|
|
// The anchor pair lives in chat_render_batch.js (extracted verbatim from
|
|
// chat.js at the byte ratchet); the contract is unchanged.
|
|
const anchorSource = readFileSync(new URL('../modules/chat_render_batch.js', import.meta.url), 'utf8');
|
|
const fn = anchorSource.slice(
|
|
anchorSource.indexOf('function captureVisibleTimelineAnchor('),
|
|
anchorSource.indexOf('function restoreVisibleTimelineAnchor('),
|
|
);
|
|
assert.match(fn, /!node\.classList\.contains\('typing-bubble'\)/);
|
|
assert.match(fn, /!node\.classList\.contains\('chat-load-older'\)/);
|
|
});
|
|
|
|
test('a reader inside Reviews stays anchored when content grows above the attempt', () => {
|
|
const box = (top, bottom) => ({ top, bottom, left: 0, right: 600, width: 600, height: bottom - top });
|
|
const makeAnchorNode = (name, bounds, classes = [], selectors = []) => {
|
|
const node = {
|
|
name,
|
|
dataset: {},
|
|
isConnected: true,
|
|
parentElement: null,
|
|
bounds,
|
|
classNames: new Set(classes),
|
|
selectors: new Set(selectors),
|
|
};
|
|
node.classList = { contains: (value) => node.classNames.has(value) };
|
|
node.getBoundingClientRect = () => node.bounds;
|
|
node.getClientRects = () => [node.bounds];
|
|
node.matches = (selector) => node.selectors.has(selector);
|
|
node.contains = (candidate) => {
|
|
for (let current = candidate; current; current = current.parentElement) {
|
|
if (current === node) return true;
|
|
}
|
|
return false;
|
|
};
|
|
node.closest = (selector) => {
|
|
for (let current = node; current; current = current.parentElement) {
|
|
if (selector === '.chat-live-card' && current.classNames?.has('chat-live-card')) {
|
|
return current;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
node.querySelectorAll = () => [];
|
|
return node;
|
|
};
|
|
|
|
const messages = makeAnchorNode('messages', box(0, 500));
|
|
messages.scrollTop = 1000;
|
|
const card = makeAnchorNode('card', box(-1000, 1200), ['chat-live-card']);
|
|
card.dataset.taskId = 'review-task';
|
|
card.parentElement = messages;
|
|
const summary = makeAnchorNode('summary', box(-1000, -900), [], ['[data-live-summary-button]']);
|
|
const timeline = makeAnchorNode('timeline', box(-800, -100), ['chat-live-line'], ['.chat-live-line']);
|
|
const reviewHost = makeAnchorNode('review-host', box(-100, 900));
|
|
const reviewSection = makeAnchorNode('review-section', box(-100, 900), [], ['[data-review-section]']);
|
|
const review = makeAnchorNode('review-attempt', box(-100, 900), [], ['[data-review-attempt]']);
|
|
summary.parentElement = card;
|
|
timeline.parentElement = card;
|
|
reviewHost.parentElement = card;
|
|
reviewSection.parentElement = reviewHost;
|
|
review.parentElement = reviewSection;
|
|
const descendants = [summary, timeline, reviewSection, review];
|
|
card.querySelectorAll = (selector) => descendants.filter(
|
|
(candidate) => [...candidate.selectors].some((token) => selector.includes(token)),
|
|
);
|
|
messages.children = [card];
|
|
messages.contains = (candidate) => candidate === card || card.contains(candidate);
|
|
|
|
const anchors = createTimelineAnchors({
|
|
messagesDiv: messages,
|
|
liveCardRecords: new Map([['review-task', { root: card }]]),
|
|
});
|
|
const anchor = anchors.captureVisibleTimelineAnchor();
|
|
review.bounds = box(20, 1020);
|
|
assert.equal(anchor.node, review);
|
|
assert.equal(anchors.restoreVisibleTimelineAnchor(anchor), true);
|
|
assert.equal(messages.scrollTop, 1120);
|
|
});
|