* fix(transparent-stream): defer settled tool-detail DOM + cap per-turn rows (#5966) Reported by @curtisszmania-cytocync. Transparent Stream built a DOM node for every activity event of every settled turn at load, and eagerly materialized each tool row's full detail body — a 15+ prompt reasoning-heavy history produced thousands of subtrees and tipped the tab into a multi-GB freeze. The #5860/#5839 lazy-worklog fix did not cover Transparent Stream (it never collapses). Two-part surgical fix in static/ui.js, mirroring #5860's proven machinery: - Row-detail deferral: a settled+collapsed transparent tool row renders header-only; its heavy .tool-card-detail body builds on first expand (_materializeTransparentToolDetail), recovering the tool call from the scene after an innerHTML cache round-trip. Same anchor-suppressed post-processing as the eager path, so an expanded row is byte-identical. - Per-turn row cap: a settled turn with >CAP+SLACK (30+10) rows renders the last 30 behind a clean 'Show earlier steps (N)' affordance that materializes the prefix in place with viewport compensation. Just-settled turn (keep-open token) and already-revealed turns are exempt; true Trace tool-count is stashed so the label stays honest; Expand-all reveals capped rows; cache-restore re-wires the affordance. Live rendering is untouched. Server-side #5858 (scene serialize-after-messages) already covers the container RSS half on the experimental channel; this is the browser-DOM half. * style(#5966): compact 'Show earlier steps' pill (hug content, left-aligned) Constrain the affordance to fit-content width + align-self:flex-start so it reads as a light inline control on the activity rail, not a full-width bar. * fix(#5966): address Codex gate findings + Fable UX fast-follows Codex SHIP-ONLY-WITH-FIXES (3 SILENT defects), all fixed + browser-verified: - F1: buildToolCard PRE-BUILDS .tool-card-detail when a tool has args/output, so the old `!detail` defer guard skipped exactly the heavy rows. Defer now fires on settled+collapsed+has-detail and STRIPS any prebuilt body (verified: 400 shell tool rows -> 0 detail bodies mounted; expand still materializes byte-identically). - F2: stamp owner rawIdx on rows + affordance; dataset recovery and cache-restore rebind resolve the scene-owning message (multi-segment turns own it on a later segment, not the first) with a scene-owning-segment fallback. - F3: persist revealed-turn state in a session/owner-keyed Set + invalidate the session HTML cache on reveal, so a rebuild/switch-away never re-caps a turn the user expanded (verified: reveal 30->400, full re-render stays 400). Fable SHIP-UX fast-follows: t()-with-fallback label (+ en locale keys), 640px touch breakpoint, stale comment fixed. Tests: 21 pass (added has-detail deferral+strip, detail-less no-defer, owner-idx recovery, persistent-reveal, i18n). * fix(#5966): address Codex re-gate findings (r2) Codex re-gate SHIP-ONLY-WITH-FIXES — 2 more SILENT defects, both fixed + browser-verified: - F1(r2): _materializeTransparentToolDetail rebuilt via the thinner _transparentToolDetailHtml(), dropping buildToolCard's richer detail (diff coloring / show-more / canonical shell-command lead). Now transplants buildToolCard(tc)'s own .tool-card-detail node so an expanded deferred row is byte-identical to the eager path (verified: canonical structure + 595-char body). - F2(r2): after an in-session renderMessages() rebuild (next send) an already-open settled tool row got re-deferred, and disclosure-restore only toggled .open → open-but-empty card. _setWorklogDetailDisclosureOpen now materializes a deferred transparent row BEFORE toggling open (verified: expand → rebuild → still open WITH content). Likely the same defect the RegressionGate caught intermittently. Tests: 22 pass (added canonical-detail + disclosure-restore-materialize). * fix(review): add show_earlier_steps i18n keys to all locales (#5966) The transparent-stream row-cap "Show N earlier steps" pill added two new i18n keys (show_earlier_steps, show_earlier_step_one) to the English block only, so the strict locale-parity tests (zh/ko/ru/zh-Hant/...) failed with missing-key assertions and CI shard 0 went red across 3.11/3.12/3.13. Add both keys to all 14 non-English locale blocks with the English string as fallback (the repo's established add-with-English-fallback convention for new UI keys). Inserted after each block's collapse_all anchor. All 197 locale tests + the #5966 suite green; node --check clean. * fix(review): extract _transparentToolRowHasDetail into the #5700 harness #5966 added a _transparentToolRowHasDetail() call into the settled transparent render (_anchorSceneTransparentNodeForRow) to decide whether to defer a collapsed tool row's detail body. The pre-existing #5700 timestamp test extracts that render fn into a node harness but didn't extract the new helper, so the extracted fn hit a ReferenceError → node exited non-zero → 3 #5700 tests failed (RED across CI shards; the PR updated its own #5966 harness but missed this one). Add `eval(extractFunc('_transparentToolRowHasDetail'))` to the harness. The helper self-guards its _toolActionKind/_toolCardAllowsDetail deps (returns true when absent), and the #5700 timestamp lives on the row HEADER (rendered even when detail is deferred), so all 20 #5700 assertions pass unchanged — confirming this was a harness gap, not a timestamp regression. * fix(#5974): hide Show-earlier-steps pill when a capped transparent turn is collapsed (Fable UX gate) Fable UX gate SHIP-WITH-UX-FIXES: the .transparent-earlier-steps pill wasn't in the .assistant-turn[data-transparent-turn-collapsed=1] hide rule, so collapsing a capped turn via the name-tag toggle left an orphan pill. Added it to the hide rule. (i18n finding was already resolved — both pill keys present in all 15 locales w/ English fallback + show_earlier_step_one is live at ui.js:12918, not dead.) * Release exp-v0.52.50: transparent-stream lazy settled DOM + per-turn cap (#5974, #5966) --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
1142 lines
45 KiB
Python
1142 lines
45 KiB
Python
"""Browserless regression for Transparent Stream event timestamps."""
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
UI_JS = ROOT / "static" / "ui.js"
|
|
NODE = shutil.which("node")
|
|
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
|
|
|
|
|
def _run_node(body):
|
|
env = os.environ.copy()
|
|
env["UI_JS_PATH"] = str(UI_JS)
|
|
script = NODE_PREFIX + body
|
|
result = subprocess.run([NODE, "-e", script], env=env, text=True, capture_output=True, check=False)
|
|
assert result.returncode == 0, result.stderr
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
NODE_PREFIX = r"""
|
|
const fs = require('fs');
|
|
const src = fs.readFileSync(process.env.UI_JS_PATH, 'utf8');
|
|
function extractFunc(name){
|
|
const marker = new RegExp('function\\s+' + name + '\\s*\\(');
|
|
const start = src.search(marker);
|
|
if(start < 0) throw new Error(name + ' not found');
|
|
let i = src.indexOf('{', start) + 1;
|
|
let depth = 1;
|
|
while(depth > 0 && i < src.length){
|
|
const ch = src[i];
|
|
if(ch === '{') depth += 1;
|
|
else if(ch === '}') depth -= 1;
|
|
i += 1;
|
|
}
|
|
return src.slice(start, i);
|
|
}
|
|
class FakeElement {
|
|
constructor(tag = 'div'){
|
|
this.tagName = String(tag).toUpperCase();
|
|
this.children = [];
|
|
this.parentNode = null;
|
|
this.attributes = Object.create(null);
|
|
this.dataset = Object.create(null);
|
|
this.style = Object.create(null);
|
|
this.hidden = false;
|
|
this.id = '';
|
|
this._textContent = '';
|
|
this._innerHTML = '';
|
|
this._classes = new Set();
|
|
const self = this;
|
|
this.classList = {
|
|
add(...names){ names.forEach(name => self._classes.add(name)); },
|
|
remove(...names){ names.forEach(name => self._classes.delete(name)); },
|
|
contains(name){ return self._classes.has(name); },
|
|
toggle(name, force){
|
|
if(force === true){ self._classes.add(name); return true; }
|
|
if(force === false){ self._classes.delete(name); return false; }
|
|
if(self._classes.has(name)){ self._classes.delete(name); return false; }
|
|
self._classes.add(name);
|
|
return true;
|
|
},
|
|
};
|
|
}
|
|
get parentElement(){ return this.parentNode; }
|
|
get firstChild(){ return this.children[0] || null; }
|
|
get nextSibling(){
|
|
if(!this.parentNode) return null;
|
|
const siblings = this.parentNode.children;
|
|
const idx = siblings.indexOf(this);
|
|
return idx >= 0 ? (siblings[idx + 1] || null) : null;
|
|
}
|
|
get className(){ return Array.from(this._classes).join(' '); }
|
|
set className(value){
|
|
this._classes = new Set(String(value).trim().split(/\s+/).filter(Boolean));
|
|
}
|
|
get textContent(){ return this._textContent; }
|
|
set textContent(value){
|
|
this._textContent = String(value ?? '');
|
|
this._innerHTML = this._textContent;
|
|
this.children = [];
|
|
}
|
|
get innerHTML(){ return this._innerHTML; }
|
|
set innerHTML(value){
|
|
this._innerHTML = String(value ?? '');
|
|
this._textContent = this._innerHTML;
|
|
this.children = [];
|
|
}
|
|
setAttribute(name, value){
|
|
const key = String(name);
|
|
const val = String(value);
|
|
this.attributes[key] = val;
|
|
if(key === 'id') this.id = val;
|
|
if(key.startsWith('data-')){
|
|
const dataKey = key.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
this.dataset[dataKey] = val;
|
|
}
|
|
if(key === 'class') this.className = val;
|
|
}
|
|
getAttribute(name){
|
|
return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null;
|
|
}
|
|
getAttributeNames(){
|
|
return Object.keys(this.attributes);
|
|
}
|
|
removeAttribute(name){
|
|
delete this.attributes[name];
|
|
if(name === 'id') this.id = '';
|
|
if(name.startsWith('data-')){
|
|
const dataKey = name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
delete this.dataset[dataKey];
|
|
}
|
|
if(name === 'class') this._classes = new Set();
|
|
}
|
|
appendChild(child){
|
|
if(child && child.parentNode){
|
|
child.remove();
|
|
}
|
|
if(!child) return null;
|
|
child.parentNode = this;
|
|
this.children.push(child);
|
|
return child;
|
|
}
|
|
insertBefore(child, refNode){
|
|
if(child && child.parentNode){
|
|
child.remove();
|
|
}
|
|
if(!child) return null;
|
|
const idx = this.children.indexOf(refNode);
|
|
child.parentNode = this;
|
|
if(idx < 0) this.children.push(child);
|
|
else this.children.splice(idx, 0, child);
|
|
return child;
|
|
}
|
|
replaceWith(...nodes){
|
|
if(!this.parentNode) return;
|
|
const parent = this.parentNode;
|
|
const siblings = parent.children;
|
|
const idx = siblings.indexOf(this);
|
|
if(idx < 0) return;
|
|
siblings.splice(idx, 1);
|
|
this.parentNode = null;
|
|
let insertAt = idx;
|
|
for(const node of nodes){
|
|
if(!node) continue;
|
|
if(node.parentNode) node.remove();
|
|
node.parentNode = parent;
|
|
siblings.splice(insertAt, 0, node);
|
|
insertAt += 1;
|
|
}
|
|
}
|
|
remove(){
|
|
if(!this.parentNode) return;
|
|
const siblings = this.parentNode.children;
|
|
const idx = siblings.indexOf(this);
|
|
if(idx >= 0) siblings.splice(idx, 1);
|
|
this.parentNode = null;
|
|
}
|
|
matches(selector){
|
|
return matchesSelector(this, selector);
|
|
}
|
|
querySelector(selector){
|
|
return this.querySelectorAll(selector)[0] || null;
|
|
}
|
|
querySelectorAll(selector){
|
|
const out = [];
|
|
const walk = (node) => {
|
|
for(const child of node.children){
|
|
if(matchesSelector(child, selector)) out.push(child);
|
|
walk(child);
|
|
}
|
|
};
|
|
walk(this);
|
|
return out;
|
|
}
|
|
closest(selector){
|
|
let node = this;
|
|
while(node){
|
|
if(matchesSelector(node, selector)) return node;
|
|
node = node.parentNode;
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
function matchesSelector(el, selector){
|
|
if(!selector) return false;
|
|
return selector.split(',').map(part => part.trim()).filter(Boolean).some(part => matchesSimple(el, part));
|
|
}
|
|
function matchesSimple(el, selector){
|
|
selector = selector.trim();
|
|
if(!selector || selector.includes(' ') || selector.includes('>')) return false;
|
|
const idMatch = selector.match(/#([A-Za-z0-9_-]+)/);
|
|
if(idMatch && el.id !== idMatch[1]) return false;
|
|
const clsMatches = selector.match(/\.([A-Za-z0-9_-]+)/g) || [];
|
|
for(const cls of clsMatches){
|
|
if(!el.classList.contains(cls.slice(1))) return false;
|
|
}
|
|
const attrMatches = selector.match(/\[([^=\]]+)(?:="([^"]*)")?\]/g) || [];
|
|
for(const attrMatch of attrMatches){
|
|
const parsed = attrMatch.match(/\[([^=\]]+)(?:="([^"]*)")?\]/);
|
|
const name = parsed[1];
|
|
const expected = parsed[2];
|
|
const value = el.getAttribute(name);
|
|
if(value === null) return false;
|
|
if(expected !== undefined && String(value) !== String(expected)) return false;
|
|
}
|
|
const stripped = selector.replace(/#[A-Za-z0-9_-]+/g, '').replace(/\.[A-Za-z0-9_-]+/g, '').replace(/\[[^\]]+\]/g, '').trim();
|
|
if(stripped && stripped !== '*' && stripped.toLowerCase() !== String(el.tagName || '').toLowerCase()) return false;
|
|
return !!(idMatch || clsMatches.length || attrMatches.length || stripped);
|
|
}
|
|
global.window = {};
|
|
global.document = {
|
|
createElement:(tag)=>new FakeElement(tag),
|
|
createTextNode:(text)=>{
|
|
const node = new FakeElement('#text');
|
|
node.textContent = text;
|
|
return node;
|
|
},
|
|
createDocumentFragment:()=>new FakeElement('#fragment'),
|
|
};
|
|
global.CSS = { escape:(value)=>String(value) };
|
|
global.requestAnimationFrame = (fn)=>fn();
|
|
global._toolShortName = (name)=>String(name || '');
|
|
global._transparentToolSummary = ()=>'summary';
|
|
global._transparentEventPreview = (text)=>String(text || '');
|
|
global._wireTransparentHeaderToggle = () => {};
|
|
global._attachCopyButton = () => {};
|
|
global._attachProgressBar = () => {};
|
|
global._transparentToolStatus = () => 'Running';
|
|
global._activityNowSeconds = () => 1700000000;
|
|
global._syncToolCallGroupSummary = () => {};
|
|
global._syncTransparentEventControls = () => {};
|
|
global._moveLiveRunStatusToTurnEnd = () => {};
|
|
global._removeEmptyLiveWorklogShells = () => {};
|
|
global.scrollIfPinned = () => {};
|
|
global.isLiveAnchorActivitySceneOwner = () => false;
|
|
global._findLiveAssistantAnchorForSegment = () => null;
|
|
global._findLatestVisibleLiveAssistantByBurst = () => null;
|
|
global._findLatestVisibleLiveAssistant = () => null;
|
|
global._toolWorklogListEl = () => null;
|
|
global._activityKeyForLiveTurn = () => 'activity-key';
|
|
global._assistantTurnBlocks = () => null;
|
|
eval(extractFunc('_activityClockLabel'));
|
|
eval(extractFunc('_activityFullClockLabel'));
|
|
eval(extractFunc('_timestampSeconds'));
|
|
eval(extractFunc('_firstValidTimestampSeconds'));
|
|
eval(extractFunc('_transparentEventTimestampSeconds'));
|
|
eval(extractFunc('_syncTransparentEventTimestamp'));
|
|
eval(extractFunc('_decorateTransparentEventRow'));
|
|
eval(extractFunc('_transparentStreamOrderedParts'));
|
|
eval(extractFunc('_transparentOrderedToolCall'));
|
|
eval(extractFunc('_transparentLiveRowAttributePairs'));
|
|
eval(extractFunc('_transparentLiveRowInteractiveState'));
|
|
eval(extractFunc('_rehydrateTransparentLiveRow'));
|
|
eval(extractFunc('_refreshTransparentThinkingLiveRow'));
|
|
eval(extractFunc('_refreshTransparentLiveRow'));
|
|
eval(extractFunc('_anchorSceneRowTimestampSeconds'));
|
|
eval(extractFunc('_anchorSceneToolCallFromRow'));
|
|
// #5966: the settled transparent render now consults _transparentToolRowHasDetail
|
|
// to decide whether to defer a collapsed tool row's detail body. It is referenced
|
|
// by _anchorSceneTransparentNodeForRow below, so extract it into the harness (it
|
|
// self-guards its own _toolActionKind/_toolCardAllowsDetail deps → returns true
|
|
// when absent, so the header — which carries the #5700 timestamp — is unaffected).
|
|
eval(extractFunc('_transparentToolRowHasDetail'));
|
|
eval(extractFunc('_anchorSceneTransparentNodeForRow'));
|
|
eval(extractFunc('appendLiveToolCard'));
|
|
function makeToolRow(ts = null, live = false){
|
|
const row = new FakeElement('div');
|
|
row.className = 'tool-card-row';
|
|
if(live) row.setAttribute('data-live-tid', '1');
|
|
row._tcData = { name: 'search' };
|
|
if(ts !== null && ts !== undefined) row._tcData.ts = ts;
|
|
const card = new FakeElement('div');
|
|
card.className = 'tool-card';
|
|
const header = new FakeElement('div');
|
|
header.className = 'tool-card-header';
|
|
const icon = new FakeElement('span');
|
|
icon.className = 'tool-card-icon';
|
|
const name = new FakeElement('span');
|
|
name.className = 'tool-card-name';
|
|
name.textContent = 'search';
|
|
const preview = new FakeElement('span');
|
|
preview.className = 'tool-card-preview';
|
|
preview.textContent = 'preview';
|
|
const status = new FakeElement('span');
|
|
status.className = 'transparent-event-status';
|
|
status.textContent = 'Running';
|
|
const toggle = new FakeElement('span');
|
|
toggle.className = 'tool-card-toggle';
|
|
toggle.textContent = '>';
|
|
const detail = new FakeElement('div');
|
|
detail.className = 'tool-card-detail';
|
|
header.appendChild(icon);
|
|
header.appendChild(name);
|
|
header.appendChild(preview);
|
|
header.appendChild(status);
|
|
header.appendChild(toggle);
|
|
card.appendChild(header);
|
|
card.appendChild(detail);
|
|
row.appendChild(card);
|
|
return row;
|
|
}
|
|
function makeThinkingRow(live = false){
|
|
const row = new FakeElement('div');
|
|
row.className = 'agent-activity-thinking';
|
|
if(live){
|
|
row.setAttribute('data-live-thinking', '1');
|
|
row.setAttribute('data-live-thinking-key', 'turn');
|
|
}
|
|
const card = new FakeElement('div');
|
|
card.className = 'thinking-card';
|
|
const header = new FakeElement('div');
|
|
header.className = 'thinking-card-header';
|
|
const icon = new FakeElement('span');
|
|
icon.className = 'thinking-card-icon';
|
|
const label = new FakeElement('span');
|
|
label.className = 'thinking-card-label';
|
|
label.textContent = 'Thinking';
|
|
const preview = new FakeElement('span');
|
|
preview.className = 'transparent-event-preview transparent-event-thinking-preview';
|
|
preview.textContent = 'quiet trace';
|
|
const btnRow = new FakeElement('span');
|
|
btnRow.className = 'thinking-card-btn-row';
|
|
const copy = new FakeElement('span');
|
|
copy.className = 'transparent-event-copy';
|
|
const toggle = new FakeElement('span');
|
|
toggle.className = 'thinking-card-toggle';
|
|
btnRow.appendChild(copy);
|
|
btnRow.appendChild(toggle);
|
|
header.appendChild(icon);
|
|
header.appendChild(label);
|
|
header.appendChild(preview);
|
|
header.appendChild(btnRow);
|
|
card.appendChild(header);
|
|
row.appendChild(card);
|
|
return row;
|
|
}
|
|
"""
|
|
|
|
|
|
def test_settled_tool_row_with_timestamp_sets_one_timestamp_node_and_updates_in_place():
|
|
data = _run_node(
|
|
"""
|
|
const ts1 = 1700000000;
|
|
const ts2 = 1700000300;
|
|
const row = makeToolRow(null, false);
|
|
_decorateTransparentEventRow(row, { type: 'tool', toolCall: row._tcData, ts: ts1, status: 'Running' });
|
|
const header = row.querySelector('.tool-card-header');
|
|
const timeBefore = header.querySelector('.transparent-event-time');
|
|
_decorateTransparentEventRow(row, { type: 'tool', toolCall: row._tcData, ts: ts2, status: 'Completed' });
|
|
const timeAfter = header.querySelector('.transparent-event-time');
|
|
const statusNode = header.querySelector('.transparent-event-status');
|
|
const toggleNode = header.querySelector('.tool-card-toggle');
|
|
process.stdout.write(JSON.stringify({
|
|
count: header.querySelectorAll('.transparent-event-time').length,
|
|
timeBeforeSameAsAfter: timeBefore === timeAfter,
|
|
timeText: timeAfter && timeAfter.textContent,
|
|
rowEventAt: row.getAttribute('data-event-at'),
|
|
timeEventAt: timeAfter && timeAfter.getAttribute('data-event-at'),
|
|
timeBeforeIndex: header.children.indexOf(timeAfter),
|
|
statusIndex: header.children.indexOf(statusNode),
|
|
toggleIndex: header.children.indexOf(toggleNode),
|
|
}));
|
|
"""
|
|
)
|
|
|
|
assert data["count"] == 1
|
|
assert data["timeBeforeSameAsAfter"] is True
|
|
assert data["timeText"] == _run_node("process.stdout.write(JSON.stringify({_clock:_activityClockLabel(1700000300)}));")["_clock"]
|
|
assert data["rowEventAt"] == "1700000300"
|
|
assert data["timeEventAt"] == "1700000300"
|
|
assert data["timeBeforeIndex"] < data["statusIndex"] < data["toggleIndex"]
|
|
|
|
|
|
def test_live_anchor_scene_thinking_row_uses_live_timestamp_before_stream_owner_flag():
|
|
data = _run_node(
|
|
"""
|
|
const liveStamp = 1700000777;
|
|
global._activityNowSeconds = () => liveStamp;
|
|
global._thinkingActivityNode = (text) => makeThinkingRow(false);
|
|
const node = _anchorSceneTransparentNodeForRow({ role: 'thinking', row_id: 'live-think', text: 'quiet trace' }, {
|
|
live: true,
|
|
settled: false,
|
|
streamId: 'stream-1',
|
|
sessionId: 'session-1',
|
|
});
|
|
const header = node && node.querySelector('.thinking-card-header');
|
|
const timeNode = header && header.querySelector('.transparent-event-time');
|
|
process.stdout.write(JSON.stringify({
|
|
hasNode: !!node,
|
|
timeText: timeNode && timeNode.textContent,
|
|
rowEventAt: node && node.getAttribute('data-event-at'),
|
|
liveOwned: node && node.getAttribute('data-live-stream-owned'),
|
|
}));
|
|
"""
|
|
)
|
|
|
|
assert data["hasNode"] is True
|
|
assert data["timeText"] == _run_node("process.stdout.write(JSON.stringify({_clock:_activityClockLabel(1700000777)}));")["_clock"]
|
|
assert data["rowEventAt"] == "1700000777"
|
|
assert data["liveOwned"] == "1"
|
|
|
|
|
|
def test_anchor_scene_row_timestamp_seconds_accepts_seconds_milliseconds_and_iso_strings():
|
|
data = _run_node(
|
|
"""
|
|
const iso = '2026-06-11T00:00:01Z';
|
|
const isoStamp = Date.parse(iso) / 1000;
|
|
process.stdout.write(JSON.stringify({
|
|
seconds: _anchorSceneRowTimestampSeconds({ created_at: 1700000901 }),
|
|
millis: _anchorSceneRowTimestampSeconds({ created_at: 1700000901000 }),
|
|
iso: _anchorSceneRowTimestampSeconds({ created_at: iso }),
|
|
invalid: _anchorSceneRowTimestampSeconds({ created_at: 'not-a-date' }),
|
|
zero: _anchorSceneRowTimestampSeconds({ created_at: 0 }),
|
|
negative: _anchorSceneRowTimestampSeconds({ created_at: -1 }),
|
|
nil: _anchorSceneRowTimestampSeconds({}),
|
|
isoStamp,
|
|
}));
|
|
"""
|
|
)
|
|
|
|
assert data["seconds"] == 1700000901
|
|
assert data["millis"] == 1700000901
|
|
assert data["iso"] == data["isoStamp"]
|
|
assert data["invalid"] is None
|
|
assert data["zero"] is None
|
|
assert data["negative"] is None
|
|
assert data["nil"] is None
|
|
|
|
|
|
def test_timestamp_seconds_rejects_out_of_range_epochs_so_invalid_date_never_renders():
|
|
# A garbage huge numeric timestamp (e.g. 1e20) passes the finite/>0 checks but
|
|
# is far past JavaScript's max representable Date (±8.64e15 ms), so without a
|
|
# range guard new Date(stamp*1000) yields "Invalid Date" and would render
|
|
# literally in the worklog time label. (#5739 gate finding.)
|
|
data = _run_node(
|
|
"""
|
|
process.stdout.write(JSON.stringify({
|
|
huge: _timestampSeconds(1e20),
|
|
hugeMs: _timestampSeconds(1e20 * 1000),
|
|
normal: _timestampSeconds(1700000901),
|
|
normalMs: _timestampSeconds(1700000901000),
|
|
hugeLabel: _activityClockLabel(_timestampSeconds(1e20) || undefined),
|
|
normalLabel: _activityClockLabel(_timestampSeconds(1700000901)),
|
|
}));
|
|
"""
|
|
)
|
|
|
|
# garbage huge epochs (whether given as seconds or ms) resolve to null
|
|
assert data["huge"] is None
|
|
assert data["hugeMs"] is None
|
|
# a normal recent epoch still resolves, in both seconds and ms forms
|
|
assert data["normal"] == 1700000901
|
|
assert data["normalMs"] == 1700000901
|
|
# the clock label for a rejected timestamp must never be the literal "Invalid Date";
|
|
# a valid one produces a real time string.
|
|
assert "Invalid Date" not in (data["hugeLabel"] or "")
|
|
assert "Invalid Date" not in (data["normalLabel"] or "")
|
|
assert data["normalLabel"]
|
|
|
|
|
|
def test_full_clock_label_tooltip_includes_date_and_rejects_bad_epochs():
|
|
# The worklog time label carries a full date+time tooltip (title attr) so a
|
|
# settled session reviewed days later (or a run crossing midnight) is not
|
|
# date-ambiguous. Bad epochs must yield '' (never 'Invalid Date'). (#5739 Fable.)
|
|
data = _run_node(
|
|
"""
|
|
process.stdout.write(JSON.stringify({
|
|
normal: _activityFullClockLabel(1700000901),
|
|
huge: _activityFullClockLabel(1e20),
|
|
zero: _activityFullClockLabel(0),
|
|
negative: _activityFullClockLabel(-1),
|
|
}));
|
|
"""
|
|
)
|
|
|
|
# a normal epoch yields a non-empty full label with the year present, no "Invalid Date"
|
|
assert data["normal"]
|
|
assert "Invalid Date" not in data["normal"]
|
|
assert "2023" in data["normal"] or "2024" in data["normal"] or "2025" in data["normal"] or "2026" in data["normal"]
|
|
# out-of-range / non-positive epochs yield empty string (no tooltip)
|
|
assert data["huge"] == ""
|
|
assert data["zero"] == ""
|
|
assert data["negative"] == ""
|
|
|
|
|
|
def test_settled_anchor_scene_rows_use_row_created_at_timestamps():
|
|
data = _run_node(
|
|
"""
|
|
const toolTs = 1700000901;
|
|
const thinkTs = 1700000902;
|
|
global.buildToolCard = (tc) => {
|
|
const row = makeToolRow(null, false);
|
|
row._tcData = tc;
|
|
const tid = tc && (tc.tid || tc.id || tc.tool_call_id || tc.tool_use_id || tc.call_id || '');
|
|
if(tid) row.setAttribute('data-live-tid', tid);
|
|
return row;
|
|
};
|
|
global._thinkingActivityNode = (text) => makeThinkingRow(false);
|
|
const toolNode = _anchorSceneTransparentNodeForRow({
|
|
role: 'tool',
|
|
row_id: 'scene-tool',
|
|
created_at: toolTs,
|
|
tool: { name: 'lookup' },
|
|
}, { settled: true, finalAnswer: '' });
|
|
const toolHeader = toolNode && toolNode.querySelector('.tool-card-header');
|
|
const toolTime = toolHeader && toolHeader.querySelector('.transparent-event-time');
|
|
const thinkNode = _anchorSceneTransparentNodeForRow({
|
|
role: 'thinking',
|
|
row_id: 'scene-think',
|
|
created_at: thinkTs,
|
|
text: 'quiet trace',
|
|
}, { settled: true, finalAnswer: '' });
|
|
const thinkHeader = thinkNode && thinkNode.querySelector('.thinking-card-header');
|
|
const thinkTime = thinkHeader && thinkHeader.querySelector('.transparent-event-time');
|
|
const missingNode = _anchorSceneTransparentNodeForRow({
|
|
role: 'thinking',
|
|
row_id: 'scene-missing',
|
|
text: 'quiet trace',
|
|
}, { settled: true, finalAnswer: '' });
|
|
const missingHeader = missingNode && missingNode.querySelector('.thinking-card-header');
|
|
process.stdout.write(JSON.stringify({
|
|
toolCount: toolHeader && toolHeader.querySelectorAll('.transparent-event-time').length,
|
|
toolText: toolTime && toolTime.textContent,
|
|
toolEventAt: toolNode && toolNode.getAttribute('data-event-at'),
|
|
thinkCount: thinkHeader && thinkHeader.querySelectorAll('.transparent-event-time').length,
|
|
thinkText: thinkTime && thinkTime.textContent,
|
|
thinkEventAt: thinkNode && thinkNode.getAttribute('data-event-at'),
|
|
missingCount: missingHeader && missingHeader.querySelectorAll('.transparent-event-time').length,
|
|
missingEventAt: missingNode && missingNode.getAttribute('data-event-at'),
|
|
}));
|
|
"""
|
|
)
|
|
|
|
assert data["toolCount"] == 1
|
|
assert data["toolText"] == _run_node("process.stdout.write(JSON.stringify({_clock:_activityClockLabel(1700000901)}));")["_clock"]
|
|
assert data["toolEventAt"] == "1700000901"
|
|
assert data["thinkCount"] == 1
|
|
assert data["thinkText"] == _run_node("process.stdout.write(JSON.stringify({_clock:_activityClockLabel(1700000902)}));")["_clock"]
|
|
assert data["thinkEventAt"] == "1700000902"
|
|
assert data["missingCount"] == 0
|
|
assert data["missingEventAt"] is None
|
|
|
|
|
|
def test_settled_anchor_scene_rows_use_iso_created_at_timestamps():
|
|
data = _run_node(
|
|
"""
|
|
const toolIso = '2026-06-11T00:00:01Z';
|
|
const thinkIso = '2026-06-11T00:00:02Z';
|
|
const toolStamp = Date.parse(toolIso) / 1000;
|
|
const thinkStamp = Date.parse(thinkIso) / 1000;
|
|
global.buildToolCard = (tc) => {
|
|
const row = makeToolRow(null, false);
|
|
row._tcData = tc;
|
|
const tid = tc && (tc.tid || tc.id || tc.tool_call_id || tc.tool_use_id || tc.call_id || '');
|
|
if(tid) row.setAttribute('data-live-tid', tid);
|
|
return row;
|
|
};
|
|
global._thinkingActivityNode = (text) => makeThinkingRow(false);
|
|
const toolNode = _anchorSceneTransparentNodeForRow({
|
|
role: 'tool',
|
|
row_id: 'scene-tool',
|
|
created_at: toolIso,
|
|
tool: { name: 'lookup' },
|
|
}, { settled: true, finalAnswer: '' });
|
|
const toolHeader = toolNode && toolNode.querySelector('.tool-card-header');
|
|
const toolTime = toolHeader && toolHeader.querySelector('.transparent-event-time');
|
|
const thinkNode = _anchorSceneTransparentNodeForRow({
|
|
role: 'thinking',
|
|
row_id: 'scene-think',
|
|
created_at: thinkIso,
|
|
text: 'quiet trace',
|
|
}, { settled: true, finalAnswer: '' });
|
|
const thinkHeader = thinkNode && thinkNode.querySelector('.thinking-card-header');
|
|
const thinkTime = thinkHeader && thinkHeader.querySelector('.transparent-event-time');
|
|
process.stdout.write(JSON.stringify({
|
|
toolCount: toolHeader && toolHeader.querySelectorAll('.transparent-event-time').length,
|
|
toolText: toolTime && toolTime.textContent,
|
|
toolEventAt: toolNode && toolNode.getAttribute('data-event-at'),
|
|
toolStamp,
|
|
thinkCount: thinkHeader && thinkHeader.querySelectorAll('.transparent-event-time').length,
|
|
thinkText: thinkTime && thinkTime.textContent,
|
|
thinkEventAt: thinkNode && thinkNode.getAttribute('data-event-at'),
|
|
thinkStamp,
|
|
}));
|
|
"""
|
|
)
|
|
|
|
assert data["toolCount"] == 1
|
|
assert data["toolText"] == _run_node("process.stdout.write(JSON.stringify({_clock:_activityClockLabel(Date.parse('2026-06-11T00:00:01Z')/1000)}));")["_clock"]
|
|
assert data["toolEventAt"] == str(int(data["toolStamp"]))
|
|
assert data["thinkCount"] == 1
|
|
assert data["thinkText"] == _run_node("process.stdout.write(JSON.stringify({_clock:_activityClockLabel(Date.parse('2026-06-11T00:00:02Z')/1000)}));")["_clock"]
|
|
assert data["thinkEventAt"] == str(int(data["thinkStamp"]))
|
|
|
|
|
|
def test_settled_thinking_row_without_timestamp_omits_timestamp_node():
|
|
data = _run_node(
|
|
"""
|
|
const row = makeThinkingRow();
|
|
_decorateTransparentEventRow(row, { type: 'thinking', text: 'quiet trace', preview: 'quiet trace' });
|
|
const header = row.querySelector('.thinking-card-header');
|
|
process.stdout.write(JSON.stringify({
|
|
count: header.querySelectorAll('.transparent-event-time').length,
|
|
rowEventAt: row.getAttribute('data-event-at'),
|
|
hasPreview: !!header.querySelector('.transparent-event-thinking-preview'),
|
|
}));
|
|
"""
|
|
)
|
|
|
|
assert data["count"] == 0
|
|
assert data["rowEventAt"] is None
|
|
assert data["hasPreview"] is True
|
|
|
|
|
|
def test_settled_thinking_row_with_timestamp_sets_one_timestamp_node():
|
|
data = _run_node(
|
|
"""
|
|
const ts = 1700000066;
|
|
const row = makeThinkingRow();
|
|
_decorateTransparentEventRow(row, { type: 'thinking', text: 'quiet trace', preview: 'quiet trace', ts });
|
|
const header = row.querySelector('.thinking-card-header');
|
|
const timeNode = header.querySelector('.transparent-event-time');
|
|
process.stdout.write(JSON.stringify({
|
|
count: header.querySelectorAll('.transparent-event-time').length,
|
|
timeText: timeNode && timeNode.textContent,
|
|
rowEventAt: row.getAttribute('data-event-at'),
|
|
timeEventAt: timeNode && timeNode.getAttribute('data-event-at'),
|
|
}));
|
|
"""
|
|
)
|
|
|
|
assert data["count"] == 1
|
|
assert data["timeText"] == _run_node("process.stdout.write(JSON.stringify({_clock:_activityClockLabel(1700000066)}));")["_clock"]
|
|
assert data["rowEventAt"] == "1700000066"
|
|
assert data["timeEventAt"] == "1700000066"
|
|
|
|
|
|
def test_live_tool_row_without_timestamp_uses_observed_live_time():
|
|
data = _run_node(
|
|
"""
|
|
const liveStamp = 1700000123;
|
|
global._activityNowSeconds = () => liveStamp;
|
|
const row = makeToolRow(null, true);
|
|
_decorateTransparentEventRow(row, { type: 'tool', toolCall: row._tcData, status: 'Running', live: true });
|
|
const header = row.querySelector('.tool-card-header');
|
|
const timeBefore = header.querySelector('.transparent-event-time');
|
|
const expected = _activityClockLabel(liveStamp);
|
|
global._activityNowSeconds = () => 1700000999;
|
|
_decorateTransparentEventRow(row, { type: 'tool', toolCall: row._tcData, status: 'Running', live: true });
|
|
const timeAfter = header.querySelector('.transparent-event-time');
|
|
process.stdout.write(JSON.stringify({
|
|
count: header.querySelectorAll('.transparent-event-time').length,
|
|
timeSameNode: timeBefore === timeAfter,
|
|
timeText: timeAfter && timeAfter.textContent,
|
|
expected,
|
|
rowEventAt: row.getAttribute('data-event-at'),
|
|
timeEventAt: timeAfter && timeAfter.getAttribute('data-event-at'),
|
|
}));
|
|
"""
|
|
)
|
|
|
|
assert data["count"] == 1
|
|
assert data["timeSameNode"] is True
|
|
assert data["timeText"] == data["expected"]
|
|
assert data["rowEventAt"] == "1700000123"
|
|
assert data["timeEventAt"] == "1700000123"
|
|
|
|
|
|
def test_live_tool_row_replacement_preserves_original_fallback_timestamp():
|
|
data = _run_node(
|
|
"""
|
|
const firstLiveStamp = 1700000123;
|
|
const replacementLiveStamp = 1700000999;
|
|
global._activityNowSeconds = () => firstLiveStamp;
|
|
global.isTransparentStream = () => true;
|
|
global.S = { session: { session_id: 'session-1' }, activeStreamId: 'stream-1' };
|
|
global.buildToolCard = (tc) => {
|
|
const row = makeToolRow(null, false);
|
|
row._tcData = tc;
|
|
const tid = tc && (tc.tid || tc.id || tc.tool_call_id || tc.tool_use_id || tc.call_id || '');
|
|
if(tid) row.setAttribute('data-live-tid', tid);
|
|
return row;
|
|
};
|
|
const turn = new FakeElement('div');
|
|
turn.id = 'liveAssistantTurn';
|
|
const inner = new FakeElement('div');
|
|
turn.appendChild(inner);
|
|
global.$ = (id) => id === 'liveAssistantTurn' ? turn : null;
|
|
global._assistantTurnBlocks = () => inner;
|
|
global._createAssistantTurn = () => turn;
|
|
global._syncTransparentEventControls = () => {};
|
|
global._toolWorklogListEl = () => null;
|
|
appendLiveToolCard({ name: 'lookup', tid: 'tool-1', done: false });
|
|
const initialRow = inner.querySelector('.transparent-event-row[data-live-tid="tool-1"]');
|
|
const initialTime = initialRow && initialRow.querySelector('.transparent-event-time');
|
|
global._activityNowSeconds = () => replacementLiveStamp;
|
|
appendLiveToolCard({ name: 'lookup', tid: 'tool-1', done: true });
|
|
const replacementRow = inner.querySelector('.transparent-event-row[data-live-tid="tool-1"]');
|
|
const replacementTime = replacementRow && replacementRow.querySelector('.transparent-event-time');
|
|
process.stdout.write(JSON.stringify({
|
|
rowCount: inner.querySelectorAll('.transparent-event-row').length,
|
|
initialEventAt: initialRow && initialRow.getAttribute('data-event-at'),
|
|
replacementEventAt: replacementRow && replacementRow.getAttribute('data-event-at'),
|
|
initialTimeText: initialTime && initialTime.textContent,
|
|
replacementTimeText: replacementTime && replacementTime.textContent,
|
|
sameText: initialTime && replacementTime && initialTime.textContent === replacementTime.textContent,
|
|
}));
|
|
"""
|
|
)
|
|
|
|
expected = _run_node("process.stdout.write(JSON.stringify({_clock:_activityClockLabel(1700000123)}));")["_clock"]
|
|
assert data["rowCount"] == 1
|
|
assert data["initialEventAt"] == "1700000123"
|
|
assert data["replacementEventAt"] == "1700000123"
|
|
assert data["initialTimeText"] == expected
|
|
assert data["replacementTimeText"] == expected
|
|
assert data["sameText"] is True
|
|
|
|
|
|
def test_ordered_tool_use_row_without_s_toolcalls_uses_assistant_timestamp():
|
|
data = _run_node(
|
|
"""
|
|
const messageStamp = 1700000801;
|
|
const partStamp = 1700000802;
|
|
global.buildToolCard = (tc) => {
|
|
const row = makeToolRow(null, false);
|
|
row._tcData = tc;
|
|
return row;
|
|
};
|
|
global._toolArgsSnapshot = (args) => args;
|
|
global._cliPatchSnippetFromArgs = () => '';
|
|
global._cliToolCardSnippet = () => '';
|
|
global._cliToolCardHasDiffSnippet = () => false;
|
|
const ordered = _transparentStreamOrderedParts({
|
|
role: 'assistant',
|
|
content: [
|
|
{ type: 'text', text: 'working' },
|
|
{ type: 'tool_use', id: 'tool-a', name: 'lookup', input: { q: 'x' } },
|
|
{ type: 'tool_use', id: 'tool-b', name: 'lookup', input: { q: 'y' }, ts: partStamp },
|
|
{ type: 'tool_use', id: 'tool-c', name: 'lookup', input: { q: 'z' } },
|
|
],
|
|
});
|
|
const normalizedWithMessageTs = ordered.find(part => part.toolUseId === 'tool-a');
|
|
const normalizedWithPartTs = ordered.find(part => part.toolUseId === 'tool-b');
|
|
const normalizedWithoutTs = ordered.find(part => part.toolUseId === 'tool-c');
|
|
const fromMessage = _transparentOrderedToolCall(normalizedWithMessageTs, 7, new Map(), {}, {}, messageStamp);
|
|
const messageRow = _decorateTransparentEventRow(buildToolCard(fromMessage), {
|
|
type: 'tool',
|
|
toolCall: fromMessage,
|
|
status: 'Completed',
|
|
});
|
|
const messageHeader = messageRow.querySelector('.tool-card-header');
|
|
const messageTime = messageHeader && messageHeader.querySelector('.transparent-event-time');
|
|
const fromPart = _transparentOrderedToolCall(normalizedWithPartTs, 8, new Map(), {}, {}, messageStamp);
|
|
const partRow = _decorateTransparentEventRow(buildToolCard(fromPart), {
|
|
type: 'tool',
|
|
toolCall: fromPart,
|
|
status: 'Completed',
|
|
});
|
|
const partHeader = partRow.querySelector('.tool-card-header');
|
|
const partTime = partHeader && partHeader.querySelector('.transparent-event-time');
|
|
const omitted = _transparentOrderedToolCall(normalizedWithoutTs, 9, new Map(), {}, {}, null);
|
|
const omittedRow = _decorateTransparentEventRow(buildToolCard(omitted), {
|
|
type: 'tool',
|
|
toolCall: omitted,
|
|
status: 'Completed',
|
|
});
|
|
const omittedHeader = omittedRow.querySelector('.tool-card-header');
|
|
process.stdout.write(JSON.stringify({
|
|
messageCount: messageHeader && messageHeader.querySelectorAll('.transparent-event-time').length,
|
|
messageEventAt: messageRow && messageRow.getAttribute('data-event-at'),
|
|
messageText: messageTime && messageTime.textContent,
|
|
partCount: partHeader && partHeader.querySelectorAll('.transparent-event-time').length,
|
|
partEventAt: partRow && partRow.getAttribute('data-event-at'),
|
|
partText: partTime && partTime.textContent,
|
|
omittedCount: omittedHeader && omittedHeader.querySelectorAll('.transparent-event-time').length,
|
|
omittedEventAt: omittedRow && omittedRow.getAttribute('data-event-at'),
|
|
}));
|
|
"""
|
|
)
|
|
|
|
message_expected = _run_node("process.stdout.write(JSON.stringify({_clock:_activityClockLabel(1700000801)}));")["_clock"]
|
|
part_expected = _run_node("process.stdout.write(JSON.stringify({_clock:_activityClockLabel(1700000802)}));")["_clock"]
|
|
assert data["messageCount"] == 1
|
|
assert data["messageEventAt"] == "1700000801"
|
|
assert data["messageText"] == message_expected
|
|
assert data["partCount"] == 1
|
|
assert data["partEventAt"] == "1700000802"
|
|
assert data["partText"] == part_expected
|
|
assert data["omittedCount"] == 0
|
|
assert data["omittedEventAt"] is None
|
|
|
|
|
|
def test_ordered_tool_use_row_prefers_later_valid_timestamp_field():
|
|
data = _run_node(
|
|
"""
|
|
const ordered = _transparentStreamOrderedParts({
|
|
role: 'assistant',
|
|
content: [
|
|
{ type: 'text', text: 'working' },
|
|
{ type: 'tool_use', id: 'tool-a', name: 'lookup', input: { q: 'x' }, ts: 'bad', timestamp: 1700000802 },
|
|
{ type: 'tool_use', id: 'tool-b', name: 'lookup', input: { q: 'y' }, ts: 'bad', timestamp: 'still-bad', created_at: 1700000803 },
|
|
],
|
|
});
|
|
global.buildToolCard = (tc) => {
|
|
const row = makeToolRow(null, false);
|
|
row._tcData = tc;
|
|
return row;
|
|
};
|
|
global._toolArgsSnapshot = (args) => args;
|
|
global._cliPatchSnippetFromArgs = () => '';
|
|
global._cliToolCardSnippet = () => '';
|
|
global._cliToolCardHasDiffSnippet = () => false;
|
|
const partA = ordered.find(part => part.toolUseId === 'tool-a');
|
|
const partB = ordered.find(part => part.toolUseId === 'tool-b');
|
|
const callA = _transparentOrderedToolCall(partA, 7, new Map(), {}, {}, 1700000801);
|
|
const callB = _transparentOrderedToolCall(partB, 8, new Map(), {}, {}, 1700000801);
|
|
const rowA = _decorateTransparentEventRow(buildToolCard(callA), { type: 'tool', toolCall: callA, status: 'Completed' });
|
|
const rowB = _decorateTransparentEventRow(buildToolCard(callB), { type: 'tool', toolCall: callB, status: 'Completed' });
|
|
const headerA = rowA.querySelector('.tool-card-header');
|
|
const headerB = rowB.querySelector('.tool-card-header');
|
|
const timeA = headerA && headerA.querySelector('.transparent-event-time');
|
|
const timeB = headerB && headerB.querySelector('.transparent-event-time');
|
|
process.stdout.write(JSON.stringify({
|
|
eventAtA: rowA.getAttribute('data-event-at'),
|
|
timeTextA: timeA && timeA.textContent,
|
|
eventAtB: rowB.getAttribute('data-event-at'),
|
|
timeTextB: timeB && timeB.textContent,
|
|
countA: headerA && headerA.querySelectorAll('.transparent-event-time').length,
|
|
countB: headerB && headerB.querySelectorAll('.transparent-event-time').length,
|
|
}));
|
|
"""
|
|
)
|
|
|
|
assert data["eventAtA"] == "1700000802"
|
|
assert data["timeTextA"] == _run_node("process.stdout.write(JSON.stringify({_clock:_activityClockLabel(1700000802)}));")["_clock"]
|
|
assert data["eventAtB"] == "1700000803"
|
|
assert data["timeTextB"] == _run_node("process.stdout.write(JSON.stringify({_clock:_activityClockLabel(1700000803)}));")["_clock"]
|
|
assert data["countA"] == 1
|
|
assert data["countB"] == 1
|
|
|
|
|
|
def test_settled_anchor_scene_tool_row_prefers_valid_payload_timestamp_over_invalid_tool_timestamp():
|
|
data = _run_node(
|
|
"""
|
|
global.buildToolCard = (tc) => {
|
|
const row = makeToolRow(null, false);
|
|
row._tcData = tc;
|
|
return row;
|
|
};
|
|
const row = {
|
|
role: 'tool',
|
|
row_id: 'anchor-tool',
|
|
tool: { name: 'lookup', ts: 'bad' },
|
|
payload: { name: 'lookup', timestamp: 1700000904, created_at: 1700000904 },
|
|
};
|
|
const toolCall = _anchorSceneToolCallFromRow(row, { settled: true });
|
|
const node = _anchorSceneTransparentNodeForRow(row, { settled: true, finalAnswer: '' });
|
|
const header = node && node.querySelector('.tool-card-header');
|
|
const timeNode = header && header.querySelector('.transparent-event-time');
|
|
process.stdout.write(JSON.stringify({
|
|
toolTs: toolCall.ts,
|
|
toolTimestamp: toolCall.timestamp,
|
|
toolCreatedAt: toolCall.created_at,
|
|
eventAt: node && node.getAttribute('data-event-at'),
|
|
timeText: timeNode && timeNode.textContent,
|
|
count: header && header.querySelectorAll('.transparent-event-time').length,
|
|
}));
|
|
"""
|
|
)
|
|
|
|
expected = _run_node("process.stdout.write(JSON.stringify({_clock:_activityClockLabel(1700000904)}));")["_clock"]
|
|
assert data["toolTs"] == 1700000904
|
|
assert data["toolTimestamp"] == 1700000904
|
|
assert data["toolCreatedAt"] == 1700000904
|
|
assert data["eventAt"] == "1700000904"
|
|
assert data["timeText"] == expected
|
|
assert data["count"] == 1
|
|
|
|
|
|
def test_live_anchor_scene_refresh_preserves_existing_fallback_timestamp():
|
|
data = _run_node(
|
|
"""
|
|
const firstLiveStamp = 1700000100;
|
|
const refreshLiveStamp = 1700000999;
|
|
global.buildToolCard = (tc) => {
|
|
const row = makeToolRow(null, false);
|
|
row._tcData = tc;
|
|
return row;
|
|
};
|
|
global._activityNowSeconds = () => firstLiveStamp;
|
|
const existing = _anchorSceneTransparentNodeForRow({
|
|
role: 'tool',
|
|
row_id: 'scene-live-tool',
|
|
tool: { name: 'lookup' },
|
|
}, { live: true, settled: false, streamId: 'stream-1', sessionId: 'session-1' });
|
|
const existingHeader = existing.querySelector('.tool-card-header');
|
|
const before = existingHeader && existingHeader.querySelector('.transparent-event-time');
|
|
global._activityNowSeconds = () => refreshLiveStamp;
|
|
const candidate = _anchorSceneTransparentNodeForRow({
|
|
role: 'tool',
|
|
row_id: 'scene-live-tool',
|
|
tool: { name: 'lookup' },
|
|
}, { live: true, settled: false, streamId: 'stream-1', sessionId: 'session-1' });
|
|
const refreshed = _refreshTransparentLiveRow(existing, candidate, {
|
|
preserveEventAt: existing.getAttribute('data-event-at'),
|
|
});
|
|
const refreshedHeader = refreshed.querySelector('.tool-card-header');
|
|
const after = refreshedHeader && refreshedHeader.querySelector('.transparent-event-time');
|
|
process.stdout.write(JSON.stringify({
|
|
beforeText: before && before.textContent,
|
|
afterText: after && after.textContent,
|
|
beforeEventAt: existing.getAttribute('data-event-at'),
|
|
afterEventAt: refreshed.getAttribute('data-event-at'),
|
|
count: refreshedHeader && refreshedHeader.querySelectorAll('.transparent-event-time').length,
|
|
}));
|
|
"""
|
|
)
|
|
|
|
expected = _run_node("process.stdout.write(JSON.stringify({_clock:_activityClockLabel(1700000100)}));")["_clock"]
|
|
assert data["beforeText"] == expected
|
|
assert data["afterText"] == expected
|
|
assert data["beforeEventAt"] == "1700000100"
|
|
assert data["afterEventAt"] == "1700000100"
|
|
assert data["count"] == 1
|
|
|
|
|
|
def test_live_anchor_scene_thinking_refresh_preserves_existing_fallback_timestamp():
|
|
data = _run_node(
|
|
"""
|
|
const firstLiveStamp = 1700000111;
|
|
const refreshLiveStamp = 1700000998;
|
|
global._activityNowSeconds = () => firstLiveStamp;
|
|
global._thinkingActivityNode = (text) => makeThinkingRow(true);
|
|
const existing = _anchorSceneTransparentNodeForRow({
|
|
role: 'thinking',
|
|
row_id: 'scene-live-thinking',
|
|
text: 'quiet trace',
|
|
}, { live: true, settled: false, streamId: 'stream-1', sessionId: 'session-1' });
|
|
const existingHeader = existing.querySelector('.thinking-card-header');
|
|
const before = existingHeader && existingHeader.querySelector('.transparent-event-time');
|
|
global._activityNowSeconds = () => refreshLiveStamp;
|
|
const candidate = _anchorSceneTransparentNodeForRow({
|
|
role: 'thinking',
|
|
row_id: 'scene-live-thinking',
|
|
text: 'quiet trace',
|
|
}, { live: true, settled: false, streamId: 'stream-1', sessionId: 'session-1' });
|
|
const refreshed = _refreshTransparentLiveRow(existing, candidate, {
|
|
preserveEventAt: existing.getAttribute('data-event-at'),
|
|
});
|
|
const refreshedHeader = refreshed.querySelector('.thinking-card-header');
|
|
const after = refreshedHeader && refreshedHeader.querySelector('.transparent-event-time');
|
|
process.stdout.write(JSON.stringify({
|
|
beforeText: before && before.textContent,
|
|
afterText: after && after.textContent,
|
|
beforeEventAt: existing.getAttribute('data-event-at'),
|
|
afterEventAt: refreshed.getAttribute('data-event-at'),
|
|
count: refreshedHeader && refreshedHeader.querySelectorAll('.transparent-event-time').length,
|
|
}));
|
|
"""
|
|
)
|
|
|
|
expected = _run_node("process.stdout.write(JSON.stringify({_clock:_activityClockLabel(1700000111)}));")["_clock"]
|
|
assert data["beforeText"] == expected
|
|
assert data["afterText"] == expected
|
|
assert data["beforeEventAt"] == "1700000111"
|
|
assert data["afterEventAt"] == "1700000111"
|
|
assert data["count"] == 1
|
|
|
|
|
|
def test_live_tool_row_prefers_later_valid_timestamp_field():
|
|
data = _run_node(
|
|
"""
|
|
const liveStamp = 1700000999;
|
|
const liveTool = { name: 'lookup', tid: 'tool-live', ts: 'bad', timestamp: 1700000124, created_at: 'also-bad' };
|
|
global.buildToolCard = (tc) => {
|
|
const row = makeToolRow(null, false);
|
|
row._tcData = tc;
|
|
return row;
|
|
};
|
|
global._toolArgsSnapshot = (args) => args;
|
|
global._cliPatchSnippetFromArgs = () => '';
|
|
global._cliToolCardSnippet = () => '';
|
|
global._cliToolCardHasDiffSnippet = () => false;
|
|
const call = _transparentOrderedToolCall(
|
|
{ kind: 'tool', toolUseId: 'tool-live', name: 'lookup', input: { q: 'x' } },
|
|
11,
|
|
new Map([['tool-live', liveTool]]),
|
|
{},
|
|
{},
|
|
1700000100,
|
|
);
|
|
const row = _decorateTransparentEventRow(buildToolCard(call), {
|
|
type: 'tool',
|
|
toolCall: call,
|
|
status: 'Running',
|
|
});
|
|
const header = row.querySelector('.tool-card-header');
|
|
const timeNode = header && header.querySelector('.transparent-event-time');
|
|
process.stdout.write(JSON.stringify({
|
|
eventAt: row.getAttribute('data-event-at'),
|
|
timeText: timeNode && timeNode.textContent,
|
|
count: header && header.querySelectorAll('.transparent-event-time').length,
|
|
liveStamp,
|
|
}));
|
|
"""
|
|
)
|
|
|
|
assert data["eventAt"] == "1700000124"
|
|
assert data["timeText"] == _run_node("process.stdout.write(JSON.stringify({_clock:_activityClockLabel(1700000124)}));")["_clock"]
|
|
assert data["count"] == 1
|
|
|
|
|
|
def test_live_thinking_row_without_timestamp_uses_observed_live_time():
|
|
data = _run_node(
|
|
"""
|
|
const liveStamp = 1700000456;
|
|
global._activityNowSeconds = () => liveStamp;
|
|
const row = makeThinkingRow(true);
|
|
_decorateTransparentEventRow(row, { type: 'thinking', text: 'quiet trace', preview: 'quiet trace', live: true });
|
|
const header = row.querySelector('.thinking-card-header');
|
|
const timeBefore = header.querySelector('.transparent-event-time');
|
|
const expected = _activityClockLabel(liveStamp);
|
|
global._activityNowSeconds = () => 1700000999;
|
|
_decorateTransparentEventRow(row, { type: 'thinking', text: 'quiet trace', preview: 'quiet trace', live: true });
|
|
const timeAfter = header.querySelector('.transparent-event-time');
|
|
process.stdout.write(JSON.stringify({
|
|
count: header.querySelectorAll('.transparent-event-time').length,
|
|
timeSameNode: timeBefore === timeAfter,
|
|
timeText: timeAfter && timeAfter.textContent,
|
|
expected,
|
|
rowEventAt: row.getAttribute('data-event-at'),
|
|
timeEventAt: timeAfter && timeAfter.getAttribute('data-event-at'),
|
|
}));
|
|
"""
|
|
)
|
|
|
|
assert data["count"] == 1
|
|
assert data["timeSameNode"] is True
|
|
assert data["timeText"] == data["expected"]
|
|
assert data["rowEventAt"] == "1700000456"
|
|
assert data["timeEventAt"] == "1700000456"
|
|
|
|
|
|
def test_live_thinking_row_existing_update_promotes_late_timestamp():
|
|
data = _run_node(
|
|
"""
|
|
eval(extractFunc('appendThinking'));
|
|
global.isSimplifiedToolCalling = () => true;
|
|
global.isTransparentStream = () => true;
|
|
global.S = { session: { session_id: 'session-1' }, activeStreamId: 'stream-1' };
|
|
global._showThinking = true;
|
|
global._sanitizeThinkingDisplayText = (text) => String(text || '');
|
|
global._thinkingActivityNode = () => makeThinkingRow(true);
|
|
global._renderThinkingInto = () => {};
|
|
global._syncTransparentEventControls = () => {};
|
|
global._moveLiveRunStatusToTurnEnd = () => {};
|
|
global._removeEmptyLiveWorklogShells = () => {};
|
|
global._toolWorklogListEl = () => null;
|
|
global.ensureLiveWorklogContainer = () => null;
|
|
global._syncToolCallGroupSummary = () => {};
|
|
global._findLiveAssistantAnchorForSegment = () => null;
|
|
global._findLatestVisibleLiveAssistantByBurst = () => null;
|
|
global._findLatestVisibleLiveAssistant = () => null;
|
|
const turn = new FakeElement('div');
|
|
turn.id = 'liveAssistantTurn';
|
|
const blocks = new FakeElement('div');
|
|
turn.appendChild(blocks);
|
|
global.$ = (id) => id === 'liveAssistantTurn' ? turn : null;
|
|
global._assistantTurnBlocks = () => blocks;
|
|
global._activityNowSeconds = () => 1700000111;
|
|
appendThinking('quiet trace', { thinkingKey: 'thinking-key', live: true });
|
|
const row = blocks.querySelector('.agent-activity-thinking[data-live-thinking="1"][data-live-thinking-key="thinking-key"]');
|
|
const before = row && row.querySelector('.transparent-event-time');
|
|
const beforeEventAt = row && row.getAttribute('data-event-at');
|
|
const beforeText = before && before.textContent;
|
|
global._activityNowSeconds = () => 1700000998;
|
|
appendThinking('quiet trace', { thinkingKey: 'thinking-key', live: true, ts: 1700000998 });
|
|
const afterRow = blocks.querySelector('.agent-activity-thinking[data-live-thinking="1"][data-live-thinking-key="thinking-key"]');
|
|
const after = afterRow && afterRow.querySelector('.transparent-event-time');
|
|
process.stdout.write(JSON.stringify({
|
|
beforeEventAt,
|
|
afterEventAt: afterRow && afterRow.getAttribute('data-event-at'),
|
|
beforeText,
|
|
afterText: after && after.textContent,
|
|
count: afterRow && afterRow.querySelectorAll('.transparent-event-time').length,
|
|
}));
|
|
"""
|
|
)
|
|
|
|
assert data["beforeEventAt"] == "1700000111"
|
|
assert data["afterEventAt"] == "1700000998"
|
|
assert data["beforeText"] == _run_node("process.stdout.write(JSON.stringify({clock:_activityClockLabel(1700000111)}));")["clock"]
|
|
assert data["afterText"] == _run_node("process.stdout.write(JSON.stringify({clock:_activityClockLabel(1700000998)}));")["clock"]
|
|
assert data["count"] == 1
|
|
|
|
|
|
def test_compact_worklog_path_stays_off_transparent_decorator():
|
|
data = _run_node(
|
|
"""
|
|
let decorated = 0;
|
|
let built = 0;
|
|
const turn = new FakeElement('div');
|
|
turn.id = 'liveAssistantTurn';
|
|
const inner = new FakeElement('div');
|
|
const group = new FakeElement('div');
|
|
const list = new FakeElement('div');
|
|
global.S = { session: { session_id: 'session-1' }, activeStreamId: 'stream-1' };
|
|
global.$ = (id) => id === 'liveAssistantTurn' ? turn : null;
|
|
global._assistantTurnBlocks = () => inner;
|
|
global.isTransparentStream = () => false;
|
|
global._decorateTransparentEventRow = () => { decorated += 1; };
|
|
global.ensureLiveWorklogContainer = () => group;
|
|
global._liveToolStepEl = () => list;
|
|
global._toolWorklogListEl = () => list;
|
|
global.buildToolCard = (tc) => {
|
|
built += 1;
|
|
const row = new FakeElement('div');
|
|
row.className = 'tool-card-row';
|
|
row._tcData = tc;
|
|
return row;
|
|
};
|
|
appendLiveToolCard({ name: 'lookup', done: false });
|
|
process.stdout.write(JSON.stringify({
|
|
decorated,
|
|
built,
|
|
rows: list.children.length,
|
|
}));
|
|
"""
|
|
)
|
|
|
|
assert data["decorated"] == 0
|
|
assert data["built"] == 1
|
|
assert data["rows"] == 1
|