Compare commits

...

19 Commits

Author SHA1 Message Date
nesquena-hermes
5838f8c770 Merge pull request #4128 from nesquena/stage-4124
Some checks failed
Release & Docker / release (push) Has been cancelled
Release NC (v0.51.390): don't snap long final answers to bottom on PWA refresh (#4123)
2026-06-13 11:17:32 -07:00
nesquena-hermes
0805f557e9 test(#4124): re-anchor scroll-cue test to readerAwayFromBottom-guarded early-return 2026-06-13 18:07:59 +00:00
nesquena-hermes
8db88c882d fix(#4123): preserve reader position on PWA same-session refresh + re-anchor 2 scroll tests
#4124: treat preserved snapshot bottom>250px as active reader (restore before follow-to-bottom),
re-mark restored mid-answer as unpinned, scope _resetScrollDirectionTracker to real session
switches only. Re-anchored test_preserve_scroll_restores_unpinned_viewport + test_render_messages_
preserve_scroll to the new readerAwayFromBottom-guarded early-return (was pinning the old exact line).
CHANGELOG stamped v0.51.390 (NC).
2026-06-13 17:53:53 +00:00
nesquena-hermes
670691c387 Merge #4124 (fix PWA final answer scroll refresh) onto master
# Conflicts:
#	CHANGELOG.md
2026-06-13 17:51:36 +00:00
nesquena-hermes
bba5631a69 Merge pull request #4125 from nesquena/stage-4103
Some checks failed
Release & Docker / release (push) Has been cancelled
Release NB (v0.51.389): surface dirty-install state in update check (#4085)
2026-06-13 10:48:38 -07:00
Frank Song
0fdcd00858 Fix PWA final answer scroll refresh 2026-06-14 01:46:04 +08:00
nesquena-hermes
465d229800 Merge #4103 (surface dirty install state in update-check, #4085) onto master
# Conflicts:
#	CHANGELOG.md
2026-06-13 17:38:48 +00:00
nesquena-hermes
c76e219076 Merge pull request #4122 from nesquena/stage-4120
Some checks failed
Release & Docker / release (push) Has been cancelled
Release NA (v0.51.388): Stable Assistant Turn Anchors dual-run reconciler (inert) (#4120)
2026-06-13 10:35:22 -07:00
nesquena-hermes
33b7517b81 docs(changelog): stamp #4120 anchor dual-run reconciler (inert, slice 7) as v0.51.388 (Release NA) 2026-06-13 17:24:12 +00:00
nesquena-hermes
3e6309d8a8 Merge #4120 (assistant turn anchor dual-run reconciler, slice 7, inert) onto master 2026-06-13 17:22:56 +00:00
Frank Song
c52c832d05 Add assistant turn anchor dual-run reconciler 2026-06-14 01:04:29 +08:00
nesquena-hermes
e3d8a3dc30 Merge pull request #4115 from nesquena/stage-4108
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MZ (v0.51.387): Stable Assistant Turn Anchors live shadow feed (inert) (#4108)
2026-06-13 09:36:44 -07:00
nesquena-hermes
310edac58e fix(#4108): add creation-time backstop cleanup to bound the registry-leak class
Codex found leak paths across 3 rounds (settled-restore, stream-end-fallback, reconnect-
preflight-inactive, stale-bailout, non-current-pane error, external cancelSessionStream).
Rather than chase every current+future teardown route, schedule an identity-guarded cleanup
at registry CREATION (600s) so window._liveAnchorRegistries self-expires no matter how the
stream ends — including external paths (boot.js cancelSessionStream) that bypass the SSE
handlers. Explicit terminal-path calls just expire it sooner. Shadow-only + identity-guarded,
so a rare >10min active stream only loses reconnect-continuity of an unconsumed registry.
2026-06-13 16:19:18 +00:00
nesquena-hermes
3cf96b1293 fix(#4108): cleanup anchor registry on reconnect-preflight-inactive + stale-stream-bailout paths (Codex round 2)
Two more early-return paths created the registry (early in attachLiveStream) then
returned without scheduling cleanup: (1) reconnect preflight when /api/chat/stream/status
is inactive/no-replay (~3966); (2) _bailOutOfTerminalEventsFromStaleStream (~1490) when a
stale stream's terminal event arrives after a newer stream owns the session. Both now
schedule identity-guarded cleanup (map.get(streamId)===registry, keyed on the stale
closure's own streamId so the newer registry is untouched).
2026-06-13 16:13:55 +00:00
nesquena-hermes
9594b97223 fix(#4108): schedule anchor registry cleanup on settled-restore + stream-end-fallback terminal paths (Codex leak catch)
Codex found window._liveAnchorRegistries retained registries for streams ending via
_restoreSettledSession() and _finalizeStreamEndFallback() (both terminal-close without
cleanup). Added _flushReasoningToAnchor()+_scheduleAnchorRegistryCleanup() to both —
identity-guarded delete already prevents clobbering a newer registry. All anchor helpers
are nested in attachLiveStream's closure (hoisted), ESLint runtime gate clean.
2026-06-13 16:09:20 +00:00
nesquena-hermes
fcad7d6db2 docs(changelog): stamp #4108 anchor live shadow feed (inert) as v0.51.387 (Release MZ) 2026-06-13 16:05:06 +00:00
nesquena-hermes
30dd5647ed Merge #4108 (Stable Assistant Turn Anchors: live shadow feed, slice 6) onto master 2026-06-13 16:03:35 +00:00
Frank Song
aa96bc52bc Add assistant turn anchor live shadow feed 2026-06-13 16:26:52 +08:00
b3nw
9bb1dc8573 fix(#4085): surface dirty install state in update-check payload
The update-check payload now carries a `dirty: bool` reflecting the
working-tree state vs HEAD, computed via the same `git diff-index
--quiet HEAD --` primitive already used by `_dirty_suffix()`. A dirty
install at-or-past the latest release tag used to silently report
"Up to date" with no remediation affordance; the Settings panel
can now read this flag to offer `apply_force_update` (which already
does `git checkout .` + `git reset --hard` to reset to a clean
latest).

The probe is conservatively clean on real errors (timeout, missing
git, fatal) so a transient probe failure never produces a false-
positive "local changes" alert.

Closes #4085
2026-06-13 06:05:06 +00:00
16 changed files with 913 additions and 44 deletions

View File

@@ -3,6 +3,30 @@
## [Unreleased]
## [v0.51.390] — 2026-06-13 — Release NC (don't snap long final answers to bottom on PWA refresh, #4123)
### Fixed
- **Long final answers no longer snap back to the bottom while you are reading them in PWA/mobile mode (#4123).** Same-session background refreshes now treat a preserved scroll snapshot whose viewport is clearly away from the bottom (>250px) as an active reading position and restore it before any follow-to-bottom path runs, re-mark restored mid-answer positions as unpinned so later refreshes keep respecting the reader, and no longer reset the scroll-direction tracker on same-session force refreshes (that reset stays scoped to real session switches). (#4123)
## [v0.51.389] — 2026-06-13 — Release NB (surface dirty-install state in update check, #4085)
### Fixed
- **A dirty install no longer reports "Up to date" with no way to recover (#4085).** The update-check payload now carries a `dirty: bool` reflecting the working-tree state vs HEAD, so the Settings update panel can surface a "Local changes detected" state with an apply-latest action (wired to the existing destructive-reset endpoint) when the install is dirty and at-or-past the latest release tag. (#4085)
## [v0.51.388] — 2026-06-13 — Release NA (Stable Assistant Turn Anchors dual-run reconciler, inert, #3926)
### Added
- **Stable Assistant Turn Anchors dual-run reconciler (#3926).** Adds `reconcileAssistantTurnAnchorActivityScene()` to compare an anchor-owned `activity_scene_v1` projection against current renderer-row snapshots before visible renderer replacement. The helper reports row-count, missing/extra row, order, and field mismatch diagnostics for Compact Worklog / Transparent Stream shadow runs while staying inert: no current `renderMessages()`, live SSE, `S.messages`, `INFLIGHT`, Compact Worklog, Transparent Stream, or DOM path consumes it yet. (Slice 7 of the live-to-final unification.)
## [v0.51.387] — 2026-06-13 — Release MZ (Stable Assistant Turn Anchors live shadow feed, inert, #3926)
### Added
- **Stable Assistant Turn Anchors live shadow feed (#3926).** `attachLiveStream()` now creates a per-stream anchor registry and shadow-feeds non-token live activity boundaries into the existing `HermesAssistantTurnAnchors` owner, including aggregate reasoning, tools, control events, compression lifecycle, app errors, cancel, and a slim `done` payload. The feed is still renderer-neutral: Compact Worklog, Transparent Stream, `renderMessages()`, `S.messages`, `INFLIGHT`, and DOM continuity do not consume the live registry yet; token events and EventSource network `error` remain outside the shadow feed. Settled active assistant messages are stamped with `_anchor_stream_id` for the next reconciliation slice, and that ephemeral stamp is carried forward across session refreshes. Every feed call is wrapped in a guarded helper (`_applyToAnchor`) that no-ops if the anchor API is unavailable and swallows any error (warn-once), so a shadow-feed fault can never break the live stream. (#3926)
## [v0.51.386] — 2026-06-13 — Release MY (voice mode survives a dropped speechSynthesis onend, #3983)
### Fixed

View File

@@ -644,7 +644,14 @@ def _check_repo_branch(path, name, *, fetch=True):
def _check_repo(path, name):
"""Check if a git repo is behind its latest release. Returns dict or None."""
"""Check if a git repo is behind its latest release. Returns dict or None.
The returned dict (when not None) always carries a ``dirty: bool`` reflecting
the working-tree state vs HEAD. A dirty install at-or-past the latest release
tag used to silently report "Up to date" with no remediation affordance, so
the Settings panel reads this flag to offer ``apply_force_update`` (issue
#4085).
"""
if path is None or not (path / '.git').exists():
return None
@@ -667,19 +674,43 @@ def _check_repo(path, name):
release_info = dict(release_info)
release_info['error'] = message
release_info['stale_check'] = True
release_info['dirty'] = _is_dirty(path)
return release_info
return {
'name': name,
'behind': None,
'error': message,
'stale_check': True,
'dirty': _is_dirty(path),
}
release_info = _check_repo_release(path, name)
if release_info is not None:
release_info = dict(release_info)
release_info['dirty'] = _is_dirty(path)
return release_info
return _check_repo_branch(path, name, fetch=False)
branch_info = _check_repo_branch(path, name, fetch=False)
if branch_info is not None:
branch_info = dict(branch_info)
branch_info['dirty'] = _is_dirty(path)
return branch_info
return None
def _is_dirty(path: Path, timeout: int = 1) -> bool:
"""Return True when the working tree has uncommitted changes vs HEAD.
Same primitive as ``_dirty_suffix`` (issue #4085): ``git diff-index
--quiet HEAD --`` exits 0 on a clean tree and 1 on a dirty tree (not an
error). Real errors (timeout, missing git, fatal) are conservatively
reported as clean so a transient probe failure never produces a false-
positive "local changes" alert.
"""
out, ok = _run_git(['diff-index', '--quiet', 'HEAD', '--'], path, timeout=timeout)
if ok:
return False
return not out or out.startswith('git exited with status ')
def _ignored_agent_update_info() -> dict:

View File

@@ -26,10 +26,16 @@ streaming or rendering yet.
- Slice 5 starts RFC Phase 5 by projecting anchor-owned activity events into a
renderer-neutral activity scene that Compact Worklog and Transparent Stream
can later consume from the same ordered rows.
- The next independently reviewable boundary is wiring one current renderer to
the activity scene. `S.messages`, `INFLIGHT`, stream-local state, and DOM nodes
remain projection/cache layers outside the settled final-prose path and the
inert activity-scene projection.
- Slice 6 starts the live shadow-feed boundary: `attachLiveStream()` now creates
a per-stream anchor registry and feeds non-token live activity events into it
without changing either current renderer.
- Slice 7 adds the dual-run reconciler for the renderer handoff: it compares a
current Compact Worklog / Transparent Stream renderer-row snapshot with the
anchor-owned `activity_scene_v1` rows and reports missing rows, extra rows,
order changes, and field mismatches before visible renderer replacement.
`S.messages`, `INFLIGHT`, stream-local state, and DOM nodes remain
projection/cache layers outside the settled final-prose path and the live
shadow registry.
## State Layers
@@ -153,7 +159,69 @@ IDs, and sanitized payloads with a chronological display hint. This pins the
shared input shape before either renderer is rewired.
This slice is still inert. No current UI module consumes the activity scene.
`renderMessages()` and the live streaming hot path are unchanged by this slice.
`renderMessages()` and the live streaming hot path were unchanged by Slice 5.
## Slice 6 Live Shadow Feed
`attachLiveStream()` now creates or reuses a per-stream local registry in
`window._liveAnchorRegistries` and feeds current live activity events through
`HermesAssistantTurnAnchors.applyAssistantTurnAnchorSourceEvent()`. This is a
shadow feed only: Compact Worklog, Transparent Stream, `renderMessages()`,
`S.messages`, `INFLIGHT`, and DOM continuity do not read from the registry yet.
The feed intentionally skips `token` events. Token events can arrive at high
frequency and would turn the anchor into a per-token append log before the
renderer reconciliation slice has proven the row model. Reasoning deltas are
also not fed one-by-one; Slice 6 flushes one aggregate reasoning event before a
terminal or settled-restore path. The feed captures the non-token activity
boundaries that define the future scene: interim assistant segments, tool
start/complete, approval, clarify, goal continuation, pending steer leftovers,
compression lifecycle, app errors, cancel, and done.
The SSE `Last-Event-ID` value is copied into the source event before applying it
to the registry, with current event-id fallbacks preserved. Existing registries
are reused by `stream_id` so a reconnect continues the same dedupe ring instead
of starting a parallel owner. Completed, errored, or cancelled streams schedule
registry cleanup after a retention window. Permanently failed network-error
paths schedule a shorter cleanup window after recovery/restore options are
exhausted.
The `done` feed is deliberately slim: status, usage, and creation timestamp are
copied, but the full settled session payload is not duplicated into the live
registry. When the active settled assistant message is available, it is stamped
with `_anchor_stream_id` so later reconciliation can associate the settled
message with the live shadow registry. That field is treated as client-side
ephemeral turn metadata and is carried forward across session refreshes.
EventSource network `error` remains a transport/recovery signal and is not fed
as an anchor terminal event in this slice. Runtime app errors continue through
the existing `apperror` event path and are fed as terminal activity only when
they match the current session.
## Slice 7 Dual-Run Reconciler
`HermesAssistantTurnAnchors.reconcileAssistantTurnAnchorActivityScene()` compares
the anchor-owned `activity_scene_v1` projection against a renderer-derived row
snapshot. It is a shadow harness, not a renderer. Callers pass the current
renderer's observed rows as plain summaries, and the helper returns
`activity_scene_reconciliation_v1` with:
- expected and actual row summaries,
- the comparison fields used,
- row-count, missing-row, unexpected-row, order, and field mismatch diagnostics,
- identity and terminal-state context from the anchor scene.
This slice keeps the comparison renderer-neutral. Compact Worklog and
Transparent Stream can each provide their own row snapshots, while the expected
side always comes from the same anchor scene. Matching rows prove the current
renderer can be replaced by an anchor-backed renderer for that event shape;
mismatches identify the specific event kind, tool identity, status, or ordering
gap that must be fixed before the visible handoff.
No current hot path consumes the reconciler. `renderMessages()`, live SSE
callbacks, `S.messages`, `INFLIGHT`, Compact Worklog, Transparent Stream, and
DOM continuity continue to render exactly as before until a later replacement
slice deliberately switches a renderer to anchor-owned rows.
## Source Event Classification

View File

@@ -1042,6 +1042,279 @@
});
}
const ACTIVITY_RECONCILIATION_DEFAULT_FIELDS=Object.freeze([
'kind',
'role',
'source_event_type',
'status',
'tool_call_id',
'tool_name',
'tool_done',
'tool_is_error',
]);
function _activityReconciliationInputScene(input, options){
const opts=(options&&typeof options==='object')?options:{};
const item=(input&&typeof input==='object')?input:{};
if(_own(item,'version')==='activity_scene_v1') return item;
const explicitScene=_own(item,'scene')||_own(item,'activity_scene')||_own(opts,'scene')||_own(opts,'activity_scene');
if(explicitScene&&typeof explicitScene==='object'&&_own(explicitScene,'version')==='activity_scene_v1'){
return explicitScene;
}
const projectionInput=_own(item,'registry')||_own(item,'anchor_registry')||_own(item,'anchor')||item;
const requestedMode=_cleanString(_own(item,'mode'))||_cleanString(_own(opts,'mode'));
return projectAssistantTurnAnchorActivityScene(projectionInput,{mode:requestedMode});
}
function _activityReconciliationRendererRows(input, options){
const item=(input&&typeof input==='object')?input:{};
const opts=(options&&typeof options==='object')?options:{};
for(const value of [
_own(item,'renderer_rows'),
_own(item,'actual_rows'),
_own(item,'rows'),
_own(opts,'renderer_rows'),
_own(opts,'actual_rows'),
_own(opts,'rows'),
]){
if(Array.isArray(value)) return value;
}
return [];
}
function _activityReconciliationFields(input, options){
const item=(input&&typeof input==='object')?input:{};
const opts=(options&&typeof options==='object')?options:{};
const fields=_own(item,'compare_fields')||_own(item,'fields')||_own(opts,'compare_fields')||_own(opts,'fields');
const list=Array.isArray(fields)?fields:ACTIVITY_RECONCILIATION_DEFAULT_FIELDS;
const out=[];
list.forEach((field)=>{
const name=_cleanString(field);
if(name&&out.indexOf(name)===-1) out.push(name);
});
return out;
}
function _activityReconciliationDataValue(row, keys){
const item=(row&&typeof row==='object')?row:{};
for(let i=0;i<keys.length;i+=1){
const key=keys[i];
const value=_own(item,key);
if(value!==undefined&&value!==null&&value!=='') return value;
}
const dataset=item.dataset&&typeof item.dataset==='object'?item.dataset:null;
if(dataset){
for(let i=0;i<keys.length;i+=1){
const key=keys[i];
const camel=key.replace(/_([a-z])/g,(_,ch)=>ch.toUpperCase());
const value=_own(dataset,camel)||_own(dataset,key);
if(value!==undefined&&value!==null&&value!=='') return value;
}
}
if(typeof item.getAttribute==='function'){
for(let i=0;i<keys.length;i+=1){
const key=keys[i];
const dataKey=`data-${String(key).replace(/_/g,'-')}`;
const value=item.getAttribute(dataKey)||item.getAttribute(key);
if(value!==undefined&&value!==null&&value!=='') return value;
}
}
return undefined;
}
function _activityReconciliationBool(value){
if(typeof value==='boolean') return value;
if(value===0||value==='0') return false;
if(value===1||value==='1') return true;
const raw=_cleanString(value).toLowerCase();
if(!raw) return null;
if(raw==='true'||raw==='yes'||raw==='done'||raw==='completed') return true;
if(raw==='false'||raw==='no'||raw==='running'||raw==='pending') return false;
return null;
}
function _activityReconciliationStatus(value){
const raw=_cleanString(value).toLowerCase().replace(/[\s-]+/g,'_');
if(!raw) return null;
const terminal=normalizeAssistantTurnAnchorTerminalState(raw);
if(terminal) return terminal;
if(raw==='complete'||raw==='done') return 'completed';
if(raw==='failed'||raw==='failure') return 'error';
return raw;
}
function _activityReconciliationText(row){
const item=(row&&typeof row==='object')?row:{};
const value=_firstTextValue(
_activityReconciliationDataValue(item,['text']),
_activityReconciliationDataValue(item,['preview']),
_activityReconciliationDataValue(item,['summary']),
_activityReconciliationDataValue(item,['content'])
);
if(value) return value;
return typeof item.textContent==='string'?item.textContent:'';
}
function _activityReconciliationRowSummary(row, index){
const item=(row&&typeof row==='object')?row:{};
const tool=item.tool&&typeof item.tool==='object'?item.tool:{};
const rawKind=_cleanString(_activityReconciliationDataValue(item,['kind','event_type','type']));
const rawRole=_cleanString(_activityReconciliationDataValue(item,['role']));
const toolName=_cleanString(
_activityReconciliationDataValue(item,['tool_name','name'])||
_activityReconciliationDataValue(tool,['name','tool_name'])
)||null;
const toolCallId=_cleanString(
_activityReconciliationDataValue(item,['tool_call_id','toolCallId','tid','tool_use_id','call_id'])||
_activityReconciliationDataValue(tool,['id','tool_call_id','toolCallId','tid','tool_use_id','call_id'])
)||null;
const toolDone=_activityReconciliationBool(
_activityReconciliationDataValue(item,['tool_done','toolDone','done']) ??
_activityReconciliationDataValue(tool,['done'])
);
const toolError=_activityReconciliationBool(
_activityReconciliationDataValue(item,['tool_is_error','toolError','is_error','isError']) ??
_activityReconciliationDataValue(tool,['is_error','isError'])
);
const rowId=_cleanString(_activityReconciliationDataValue(item,['row_id','rowId','event_id','eventId','id']));
const status=_activityReconciliationStatus(_activityReconciliationDataValue(item,['status','state']));
return Object.freeze({
row_id:rowId||null,
order_index:index,
declared_order_index:_activityReconciliationDataValue(item,['order_index','orderIndex'])??null,
kind:rawKind||null,
role:rawRole||null,
source_event_type:_cleanString(_activityReconciliationDataValue(item,['source_event_type','sourceEventType','event_type','eventType']))||null,
display_hint:_cleanString(_activityReconciliationDataValue(item,['display_hint','displayHint']))||null,
status,
text:_activityReconciliationText(item),
tool_call_id:toolCallId,
tool_name:toolName,
tool_done:toolDone,
tool_is_error:toolError,
});
}
function _activityReconciliationRowsById(rows){
const map=new Map();
rows.forEach((row)=>{
if(row&&row.row_id&&!map.has(row.row_id)) map.set(row.row_id,row);
});
return map;
}
function _activityReconciliationDuplicateRowIds(rows){
const seen=new Set();
const dupes=[];
rows.forEach((row,index)=>{
const rowId=row&&row.row_id;
if(!rowId) return;
if(seen.has(rowId)) dupes.push(Object.freeze({row_id:rowId,index,row}));
else seen.add(rowId);
});
return dupes;
}
function _activityReconciliationMismatch(kind, detail){
return Object.freeze({
kind,
..._copyObject(detail),
});
}
function reconcileAssistantTurnAnchorActivityScene(input, options){
const item=(input&&typeof input==='object')?input:{};
const opts=(options&&typeof options==='object')?options:{};
const scene=_activityReconciliationInputScene(item,opts);
const expectedRows=(Array.isArray(scene.activity_rows)?scene.activity_rows:[])
.map((row,index)=>_activityReconciliationRowSummary(row,index));
const actualRows=_activityReconciliationRendererRows(item,opts)
.map((row,index)=>_activityReconciliationRowSummary(row,index));
const fields=_activityReconciliationFields(item,opts);
const mismatches=[];
if(expectedRows.length!==actualRows.length){
mismatches.push(_activityReconciliationMismatch('row_count',{
expected_count:expectedRows.length,
actual_count:actualRows.length,
}));
}
const expectedById=_activityReconciliationRowsById(expectedRows);
const actualById=_activityReconciliationRowsById(actualRows);
const useRowIds=expectedById.size===expectedRows.length&&actualById.size>0;
const matchedActualIds=new Set();
_activityReconciliationDuplicateRowIds(actualRows).forEach((duplicate)=>{
mismatches.push(_activityReconciliationMismatch('duplicate_actual_row',duplicate));
});
expectedRows.forEach((expected,index)=>{
const actual=useRowIds&&expected.row_id?actualById.get(expected.row_id):actualRows[index];
if(!actual){
mismatches.push(_activityReconciliationMismatch('missing_actual_row',{
row_id:expected.row_id,
expected_index:index,
expected,
}));
return;
}
if(useRowIds&&actual.row_id) matchedActualIds.add(actual.row_id);
if(actual.order_index!==expected.order_index){
mismatches.push(_activityReconciliationMismatch('order_mismatch',{
row_id:expected.row_id,
expected_index:expected.order_index,
actual_index:actual.order_index,
}));
}
fields.forEach((field)=>{
if(!_hasOwn(expected,field)&&!_hasOwn(actual,field)) return;
const expectedValue=expected[field]===undefined?null:expected[field];
const actualValue=actual[field]===undefined?null:actual[field];
if(JSON.stringify(expectedValue)!==JSON.stringify(actualValue)){
mismatches.push(_activityReconciliationMismatch('field_mismatch',{
row_id:expected.row_id,
field,
expected:expectedValue,
actual:actualValue,
}));
}
});
});
if(useRowIds){
actualRows.forEach((actual,index)=>{
if(actual.row_id&&expectedById.has(actual.row_id)) return;
if(actual.row_id&&matchedActualIds.has(actual.row_id)) return;
mismatches.push(_activityReconciliationMismatch('unexpected_actual_row',{
row_id:actual.row_id,
actual_index:index,
actual,
}));
});
}else if(actualRows.length>expectedRows.length){
actualRows.slice(expectedRows.length).forEach((actual,offset)=>{
mismatches.push(_activityReconciliationMismatch('unexpected_actual_row',{
row_id:actual.row_id,
actual_index:expectedRows.length+offset,
actual,
}));
});
}
return Object.freeze({
version:'activity_scene_reconciliation_v1',
mode:scene.mode||(_cleanString(_own(item,'mode'))||_cleanString(_own(opts,'mode'))||'compact_worklog'),
scene_version:scene.version||null,
matched:mismatches.length===0,
summary:Object.freeze({
expected_count:expectedRows.length,
actual_count:actualRows.length,
mismatch_count:mismatches.length,
}),
identity:scene.identity||Object.freeze({source_message_refs:Object.freeze([])}),
terminal_state:scene.terminal_state||null,
fields:Object.freeze(fields),
expected_rows:Object.freeze(expectedRows),
actual_rows:Object.freeze(actualRows),
mismatches:Object.freeze(mismatches),
});
}
function createAssistantTurnAnchorSeed(input){
const opts=(input&&typeof input==='object')?input:{};
const sessionId=_cleanString(opts.session_id);
@@ -1101,7 +1374,7 @@
}
ROOT.HermesAssistantTurnAnchors=Object.freeze({
version:'slice5-activity-scene',
version:'slice7-dual-run-reconciler',
activityEventKinds:ACTIVITY_EVENT_KINDS,
stateLayers:STATE_LAYERS,
sourceEventClassification:SOURCE_EVENT_CLASSIFICATION,
@@ -1120,6 +1393,7 @@
createAssistantTurnAnchorShadowSnapshot,
projectAssistantTurnAnchorSettledMessageFinalAnswer,
projectAssistantTurnAnchorActivityScene,
reconcileAssistantTurnAnchorActivityScene,
isAssistantTurnAnchorActivityKind,
});
})();

View File

@@ -1488,6 +1488,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
function _bailOutOfTerminalEventsFromStaleStream(source){
if(_ownsActiveStreamOrBackground()) return false;
// This stale stream no longer owns the session — schedule cleanup of ITS own
// anchor registry (identity-guarded, so it can't clobber the newer stream's
// registry for the same session) before closing. (Codex leak catch.)
_scheduleAnchorRegistryCleanup(120000);
_closeSource(source);
return true;
}
@@ -1638,6 +1642,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_smdEndParser();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
_clearOwnerInflightState();
_flushReasoningToAnchor();
_scheduleAnchorRegistryCleanup();
_clearApprovalForOwner();
_clearClarifyForOwner('terminal');
if(_isActiveSession()){
@@ -1844,6 +1850,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
if(await _restoreSettledSession(source)) return;
if(_deferStreamErrorIfOffline()||_pageHiddenForStreamError()) return;
_flushReasoningToAnchor();
_scheduleAnchorRegistryCleanup(120000);
_handleStreamError(source);
})();
}
@@ -1903,6 +1911,69 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const _STREAM_FADE_STAGGER_MS=16;
const _STREAM_FADE_DONE_MAX_MS=320;
const _STREAM_FADE_DONE_DRAIN_MAX_MS=900;
const _anchorApi=(typeof window!=='undefined'&&window.HermesAssistantTurnAnchors)
? window.HermesAssistantTurnAnchors
: null;
const _anchorRegistryMap=(typeof window!=='undefined')
? (window._liveAnchorRegistries=window._liveAnchorRegistries||new Map())
: null;
const _existingAnchorRegistry=_anchorRegistryMap?_anchorRegistryMap.get(streamId):null;
const _anchorRegistry=_existingAnchorRegistry||(_anchorApi&&typeof _anchorApi.createAssistantTurnAnchorRegistry==='function'
? _anchorApi.createAssistantTurnAnchorRegistry({
session_id:activeSid,
stream_id:streamId,
run_id:null,
})
: null);
let _anchorShadowWarned=false;
let _anchorReasoningFlushed=false;
if(_anchorRegistryMap&&_anchorRegistry) _anchorRegistryMap.set(streamId,_anchorRegistry);
function _scheduleAnchorRegistryCleanup(delayMs=600000){
if(!_anchorRegistryMap||!_anchorRegistry) return;
setTimeout(()=>{
if(_anchorRegistryMap.get(streamId)===_anchorRegistry) _anchorRegistryMap.delete(streamId);
},delayMs);
}
// Backstop: schedule an identity-guarded cleanup at creation so this shadow
// registry self-expires no matter which teardown path the stream takes
// (incl. external ones like sidebar cancelSessionStream() that bypass the
// in-closure SSE handlers). Explicit terminal-path calls above just expire it
// sooner; this guarantees window._liveAnchorRegistries can't grow unbounded.
_scheduleAnchorRegistryCleanup(600000);
function _applyToAnchor(sourceEventType, rawEventData, sseEvent){
if(!_anchorRegistry||!_anchorApi||typeof _anchorApi.applyAssistantTurnAnchorSourceEvent!=='function') return null;
const raw=(rawEventData&&typeof rawEventData==='object')?rawEventData:{};
const eventId=(sseEvent&&sseEvent.lastEventId)||raw.event_id||raw.lastEventId||raw.last_event_id||'';
const sourceEvent={
...raw,
source_event_type:sourceEventType,
activitySegmentSeq:_assistantSegmentSeq,
activityBurstId:_currentActivityBurstId,
};
if(eventId) sourceEvent.event_id=eventId;
try{
return _anchorApi.applyAssistantTurnAnchorSourceEvent(
_anchorRegistry,
sourceEvent,
{session_id:activeSid,stream_id:streamId}
);
}catch(err){
if(!_anchorShadowWarned&&typeof console!=='undefined'&&console.warn){
_anchorShadowWarned=true;
console.warn('assistant turn anchor live shadow feed failed',err);
}
return null;
}
}
function _flushReasoningToAnchor(){
if(_anchorReasoningFlushed||!reasoningText) return;
_anchorReasoningFlushed=true;
_applyToAnchor('reasoning',{
text:reasoningText,
local_id:'live-reasoning',
seq:_runJournalReplayAfterSeq(),
},null);
}
function _mergeSettledToolCallsWithLiveMetadata(rawCalls){
const liveCalls=Array.isArray(S.toolCalls)?S.toolCalls:[];
@@ -2802,6 +2873,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(!visible){
return;
}
_applyToAnchor('interim_assistant',d,e);
liveReasoningText='';
if(alreadyStreamed){
if(!S.session||S.session.session_id!==activeSid){
@@ -2890,6 +2962,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_completeAutomaticCompressionOnLiveProgress(activeSid);
const tc=upsertLiveToolCall(d,'start');
if(!tc) return;
_applyToAnchor('tool',{...d,...tc},e);
if(S.session&&S.session.session_id===activeSid&&typeof scheduleRenderSessionArtifacts==='function') scheduleRenderSessionArtifacts();
if(!S.session||S.session.session_id!==activeSid) return;
@@ -2922,6 +2995,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const tc=upsertLiveToolCall(d,'complete');
if(!tc) return;
tc.is_error=!!d.is_error;
_applyToAnchor('tool_complete',{...d,...tc,is_error:!!d.is_error},e);
if(typeof noteWorkspaceMutationsFromToolCall==='function') noteWorkspaceMutationsFromToolCall(tc);
if(S.session&&S.session.session_id===activeSid&&typeof scheduleRenderSessionArtifacts==='function') scheduleRenderSessionArtifacts();
if(!S.session||S.session.session_id!==activeSid) return;
@@ -2989,6 +3063,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
source.addEventListener('approval',e=>{
const d=JSON.parse(e.data);
_applyToAnchor('approval',d,e);
showApprovalForSession(activeSid, d, 1);
playAttentionSound(_attentionSoundKey(activeSid,'approval',1));
sendBrowserNotification('Approval required',d.description||'Tool approval needed',{sid:activeSid});
@@ -2996,6 +3071,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
source.addEventListener('clarify',e=>{
const d=JSON.parse(e.data);
_applyToAnchor('clarify',d,e);
showClarifyForSession(activeSid, d);
playAttentionSound(_attentionSoundKey(activeSid,'clarify',1));
sendBrowserNotification('Clarification needed',d.question||'Tool clarification needed',{sid:activeSid});
@@ -3083,6 +3159,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const sid=d.session_id||activeSid;
const continuation_prompt=String(d.continuation_prompt||d.text||'').trim();
if(!continuation_prompt||sid!==activeSid)return;
_applyToAnchor('goal_continue',d,e);
const _modelState=_chatPayloadModelState();
_pendingGoalContinuation={
sid,
@@ -3132,6 +3209,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_terminalStateReached=true;
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
const _doneData=JSON.parse(e.data);
const _doneEvent=e;
const _finishDone=()=>{
// Bug A fix: cancel any pending rAF and mark stream finalized before
// the DOM is settled by renderMessages, so no trailing token/reasoning rAF
@@ -3154,6 +3232,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_smdEndParser();
}
const d=_doneData;
_flushReasoningToAnchor();
_applyToAnchor('done',{
status:d.status||'completed',
usage:d.usage||null,
created_at:d.created_at||null,
},_doneEvent);
_scheduleAnchorRegistryCleanup();
const isActiveSession=_isSessionCurrentPane(activeSid);
const isSessionViewed=_isSessionActivelyViewed(activeSid);
const completedSession=d.session||{session_id:activeSid};
@@ -3201,6 +3286,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
// Find the last assistant message once for both reasoning persistence and timestamp
const lastAsst=[...S.messages].reverse().find(m=>m.role==='assistant');
if(_anchorRegistry&&lastAsst) lastAsst._anchor_stream_id=streamId;
// Persist reasoning trace for Worklog Thinking Cards; normal transcript
// rendering keeps provider reasoning out of the final answer.
if(reasoningText&&lastAsst&&!lastAsst.reasoning) lastAsst.reasoning=reasoningText;
@@ -3383,6 +3469,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const sid=d.session_id||activeSid;
const txt=String(d.text||'').trim();
if(!txt||sid!==activeSid) return;
_applyToAnchor('pending_steer_leftover',d,e);
if(typeof queueSessionMessage==='function'){
const _modelState=_chatPayloadModelState();
queueSessionMessage(sid,{
@@ -3404,6 +3491,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
let d={};
try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; }
if(d.session_id&&d.session_id!==activeSid) return;
_applyToAnchor('compressing',d,e);
const state={
sessionId:activeSid,
phase:'running',
@@ -3438,6 +3526,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const continuationSid=d.new_session_id||d.continuation_session_id||'';
const eventMatchesCurrent=!!(currentSid&&(eventSid===currentSid||d.new_session_id===currentSid||d.continuation_session_id===currentSid));
if(!eventMatchesCurrent) return;
_applyToAnchor('compressed',d,e);
const displaySid=currentSid;
if(d.usage&&typeof _syncCtxIndicator==='function'){
S.lastUsage=typeof _mergeUsageForCtxIndicator==='function'
@@ -3502,8 +3591,22 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const eventSid=d.old_session_id||d.session_id||'';
const continuationSid=(d.session&&d.session.session_id)||d.new_session_id||d.continuation_session_id||'';
const eventMatchesCurrent=!!(currentSid&&(eventSid===currentSid||continuationSid===currentSid));
if(eventMatchesCurrent){
_flushReasoningToAnchor();
_applyToAnchor('apperror',{
type:d.type||'error',
status:d.status||d.type||'error',
message:d.message||'',
hint:d.hint||'',
details:d.details||'',
session_id:d.session_id||eventSid||activeSid,
old_session_id:d.old_session_id||null,
new_session_id:d.new_session_id||d.continuation_session_id||null,
},e);
}
if(S.session&&eventMatchesCurrent){
S.activeStreamId=null;
_scheduleAnchorRegistryCleanup();
clearLiveToolCards();if(!assistantText)removeThinking();
let isRecoveryControlMessage=false;
try{
@@ -3622,6 +3725,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(await _restoreSettledSession(source)) return;
if(_deferStreamErrorIfOffline()) return;
if(_deferStreamErrorIfPageHidden(source)) return;
_flushReasoningToAnchor();
_scheduleAnchorRegistryCleanup(120000);
_handleStreamError(source);
},1500);
return;
@@ -3629,6 +3734,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(await _restoreSettledSession(source)) return;
if(_deferStreamErrorIfOffline()) return;
if(_deferStreamErrorIfPageHidden(source)) return;
_flushReasoningToAnchor();
_scheduleAnchorRegistryCleanup(120000);
_handleStreamError(source);
});
@@ -3646,6 +3753,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_clearOwnerInflightState();
_clearApprovalForOwner();
_clearClarifyForOwner('cancelled');
let _cancelData={};
try{ _cancelData=JSON.parse(e.data||'{}')||{}; }catch(_){ _cancelData={}; }
_flushReasoningToAnchor();
_applyToAnchor('cancel',{
status:_cancelData.status||_cancelData.type||'cancelled',
message:_cancelData.message||'',
session_id:_cancelData.session_id||activeSid,
},e);
_scheduleAnchorRegistryCleanup();
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;
}
@@ -3701,7 +3817,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
return `${m.role}|${ts}|${body.slice(0,160)}`;
}
const _EPHEMERAL_TURN_FIELDS=['_turnUsage','_turnDuration','_turnTps','_gatewayRouting','_statusCard'];
const _EPHEMERAL_TURN_FIELDS=['_turnUsage','_turnDuration','_turnTps','_gatewayRouting','_statusCard','_anchor_stream_id'];
function _carryForwardEphemeralTurnFields(prevMessages, nextMessages){
if(!Array.isArray(prevMessages)||!Array.isArray(nextMessages)) return nextMessages;
if(!prevMessages.length||!nextMessages.length) return nextMessages;
@@ -3746,6 +3862,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_smdEndParser();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
_clearOwnerInflightState();
_flushReasoningToAnchor();
_scheduleAnchorRegistryCleanup();
_closeSource(source);
_clearApprovalForOwner();
_clearClarifyForOwner('terminal');
@@ -3855,6 +3973,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
renderMessages({preserveScroll:true});
renderSessionList();
}
_scheduleAnchorRegistryCleanup(120000);
return;
}
}catch(_){}

View File

@@ -22,7 +22,7 @@ let _loadingSessionId = null;
// clears them on a force-reload of the active session. Consumed by
// _ensureMessagesLoaded() when calling _carryForwardEphemeralTurnFields so
// ephemeral fields (_turnUsage, _turnDuration, _turnTps, _gatewayRouting,
// _statusCard) survive the wholesale replace. null when there is nothing
// _statusCard, _anchor_stream_id) survive the wholesale replace. null when there is nothing
// to carry forward (initial load, switch-to-different-session, etc.).
let _pendingCarryForwardSnapshot = null;
@@ -900,7 +900,7 @@ async function loadSession(sid){
// poll triggering a refresh), snapshot the existing messages BEFORE we
// clear them. _ensureMessagesLoaded() runs the ephemeral-field
// carry-forward (_turnUsage, _turnDuration, _turnTps, _gatewayRouting,
// _statusCard) against S.messages, but by the time the API fetch returns
// _statusCard, _anchor_stream_id) against S.messages, but by the time the API fetch returns
// S.messages has already been reset to [] here and the carry-forward is a
// no-op. The visible symptom is the token-usage badge vanishing ~10s
// after each assistant turn completes. Stash the snapshot so the
@@ -1038,10 +1038,13 @@ async function loadSession(sid){
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
S.session._modelResolutionDeferred=true;
S.lastUsage={...(data.session.last_usage||{})};
// Reset scroll-direction tracker on session switch so the new chat's
// first scroll doesn't compare against the previous chat's scrollTop
// Reset scroll-direction tracker only on real session switches so the new
// chat's first scroll doesn't compare against the previous chat's scrollTop
// and false-trigger an unpin (#1731 follow-up — Opus stage-302 SHOULD-FIX).
if (typeof window !== 'undefined' && typeof window._resetScrollDirectionTracker === 'function') {
// Same-session force refreshes reuse the current transcript viewport; clearing
// the sticky-unpin state here makes preserveScroll treat a reader mid-answer
// as pinned and snap them back to the bottom on the next render.
if (currentSid !== sid && typeof window !== 'undefined' && typeof window._resetScrollDirectionTracker === 'function') {
try { window._resetScrollDirectionTracker(); } catch (_) {}
}
if(typeof _applyPendingSessionModelForSession==='function') _applyPendingSessionModelForSession(sid);
@@ -1914,7 +1917,7 @@ async function _ensureMessagesLoaded(sid) {
}
clearLiveToolCards();
// #3018: preserve client-side ephemeral turn fields (_turnUsage, _turnDuration,
// _turnTps, _gatewayRouting, _statusCard) across the loadSession replace.
// _turnTps, _gatewayRouting, _statusCard, _anchor_stream_id) across the loadSession replace.
if(typeof window._carryForwardEphemeralTurnFields==='function'){
// #3306: Prefer the pre-clear snapshot stashed by loadSession() on a
// force-reload of the active session; S.messages was reset to [] there
@@ -2391,7 +2394,7 @@ async function _loadOlderMessages() {
const container = $('messages');
const prevScrollH = container ? container.scrollHeight : 0;
// Carry forward ephemeral turn fields (_turnUsage/_turnDuration/_turnTps/
// _gatewayRouting/_statusCard) before the wholesale replace so the badge
// _gatewayRouting/_statusCard/_anchor_stream_id) before the wholesale replace so the badge
// does not briefly appear and disappear during older-message expansion.
if (typeof window._carryForwardEphemeralTurnFields === 'function') {
nextMessages = window._carryForwardEphemeralTurnFields(S.messages || [], nextMessages);
@@ -2482,7 +2485,7 @@ async function _ensureAllMessagesLoaded() {
// #3306: Same ephemeral-field carry-forward as _ensureMessagesLoaded.
// Loading older messages also does a wholesale replace of S.messages
// and would otherwise drop _turnUsage/_turnDuration/_turnTps/
// _gatewayRouting/_statusCard on the existing turns.
// _gatewayRouting/_statusCard/_anchor_stream_id on the existing turns.
let _msgsToAssign = msgs;
if (typeof window._carryForwardEphemeralTurnFields === 'function') {
_msgsToAssign = window._carryForwardEphemeralTurnFields(S.messages || [], msgs);
@@ -3823,7 +3826,8 @@ function startGatewaySSE(){
if (next.length < prev) return;
if (prev > 0 && !_isCliImportRefreshPrefixMatch(S.messages, next)) return;
// Carry forward ephemeral turn fields (_turnUsage/
// _turnDuration/_turnTps/_gatewayRouting/_statusCard) so
// _turnDuration/_turnTps/_gatewayRouting/_statusCard/
// _anchor_stream_id) so
// gateway-driven CLI refreshes do not drop the badge.
let _nextToAssign = next;
if (typeof window._carryForwardEphemeralTurnFields === 'function') {

View File

@@ -8718,6 +8718,16 @@ function _restoreMessageScrollSnapshot(snapshot){
el.scrollTop=Math.max(0,Math.min(Number(snapshot.top)||0,maxTop));
// Sync _lastScrollTop after programmatic restore so sticky-unpin does not false-trigger (#1731).
_lastScrollTop=el.scrollTop;
const bottomDistance=el.scrollHeight-el.scrollTop-el.clientHeight;
if(bottomDistance>250){
_messageUserUnpinned=true;
_scrollPinned=false;
_nearBottomCount=0;
}else if(bottomDistance<=120){
_messageUserUnpinned=false;
_scrollPinned=true;
_nearBottomCount=2;
}
requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });
}
function _restoreMessageScrollSnapshotSameFrame(snapshot){
@@ -8773,6 +8783,11 @@ function _scrollAfterMessageRender(preserveScroll, scrollSnapshot){
// pinned users stay at bottom; users who manually scrolled up get their
// pre-render scrollTop restored after the DOM replacement.
if(preserveScroll){
const readerAwayFromBottom=!!(
scrollSnapshot &&
Number.isFinite(Number(scrollSnapshot.bottom)) &&
Number(scrollSnapshot.bottom)>250
);
// Keep master's follow heuristic for pinned / still-near-bottom users:
// _followMessagesAfterDomReplace() does a FORCED scrollToBottom() (synchronous
// bottom write + forced settle), so the final settled response can't leave a
@@ -8781,7 +8796,7 @@ function _scrollAfterMessageRender(preserveScroll, scrollSnapshot){
// new-message cue. (Using scrollIfPinned() here instead would skip the forced
// write unless distance>500 and let the DOM-rebuild scroll event cancel the
// delayed settles — Codex CORE catch on #3631.)
if(!_messageUserUnpinned && _followMessagesAfterDomReplace()) return;
if(!readerAwayFromBottom && !_messageUserUnpinned && _followMessagesAfterDomReplace()) return;
_restoreMessageScrollSnapshot(scrollSnapshot);
_maybeShowNewMessageScrollCue(scrollSnapshot);
return;

View File

@@ -57,8 +57,13 @@ def test_render_messages_preserve_scroll_option_uses_user_pin_state_not_stream_l
assert "const preserveScroll=!!(options&&options.preserveScroll);" in render_body
assert "_scrollAfterMessageRender(preserveScroll, scrollSnapshot);" in render_body
assert "const scrollSnapshot=(preserveScroll||(!_autoScrollFollow&&_messageUserUnpinned))?_captureMessageScrollSnapshot():null" in render_body
assert "if(preserveScroll){\n // Keep master's follow heuristic" in scroll_helper
assert "if(!_messageUserUnpinned && _followMessagesAfterDomReplace()) return;\n _restoreMessageScrollSnapshot(scrollSnapshot);\n _maybeShowNewMessageScrollCue(scrollSnapshot);\n return;\n }" in scroll_helper
assert "if(preserveScroll){" in scroll_helper
# #4124: a reader clearly away from the bottom (>250px) is treated as an active
# reading position, so the forced follow-to-bottom is gated behind it.
assert "const readerAwayFromBottom=" in scroll_helper
assert "Number(scrollSnapshot.bottom)>250" in scroll_helper
assert "// Keep master's follow heuristic" in scroll_helper
assert "if(!readerAwayFromBottom && !_messageUserUnpinned && _followMessagesAfterDomReplace()) return;\n _restoreMessageScrollSnapshot(scrollSnapshot);\n _maybeShowNewMessageScrollCue(scrollSnapshot);\n return;\n }" in scroll_helper
assert "_shouldFollowMessagesOnDomReplace()" in follow_helper
assert "scrollToBottom();" in follow_helper
assert "if(S.activeStreamId){\n scrollIfPinned();\n return;\n }" in scroll_helper

View File

@@ -10,6 +10,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
@@ -78,6 +79,36 @@ def test_same_frame_snapshot_preserves_bottom_distance_and_unpinned_state():
assert "_restoreMessageScrollSnapshotSameFrame(scrollSnapshot);" in wrapper
def test_preserve_scroll_restores_reader_away_from_bottom_before_following():
body = _function_body(UI_JS, "_scrollAfterMessageRender")
compact = _compact(body)
reader_idx = compact.index("constreaderAwayFromBottom=")
follow_idx = compact.index("if(!readerAwayFromBottom&&!_messageUserUnpinned&&_followMessagesAfterDomReplace())return;")
restore_idx = compact.index("_restoreMessageScrollSnapshot(scrollSnapshot);")
assert "Number(scrollSnapshot.bottom)>250" in compact
assert reader_idx < follow_idx < restore_idx
def test_scroll_snapshot_restore_reinstates_unpinned_state_when_reader_is_mid_answer():
restore = _function_body(UI_JS, "_restoreMessageScrollSnapshot")
compact = _compact(restore)
assert "constbottomDistance=el.scrollHeight-el.scrollTop-el.clientHeight;" in compact
assert "if(bottomDistance>250)" in compact
assert "_messageUserUnpinned=true" in compact
assert "_scrollPinned=false" in compact
def test_same_session_force_refresh_does_not_reset_scroll_direction_tracker():
body = _function_body(SESSIONS_JS, "loadSession")
compact = _compact(body)
assert "constcurrentSid=S.session?S.session.session_id:null;" in compact
assert "if(currentSid!==sid&&typeofwindow!=='undefined'&&typeofwindow._resetScrollDirectionTracker==='function')" in compact
def test_clarify_card_is_height_clamped_and_scrollable_on_mobile_viewports():
compact = _compact(STYLE_CSS)

View File

@@ -34,7 +34,7 @@ def test_preserve_scroll_unpinned_branch_shows_new_message_cue_after_restore():
# only the genuinely-scrolled-up cohort restores their viewport + gets the
# new-message cue. (Codex CORE catch: scrollIfPinned() in the pinned branch
# could leave a pinned reader short of the settled response.)
assert "if(!_messageUserUnpinned && _followMessagesAfterDomReplace()) return;" in helper
assert "if(!readerAwayFromBottom && !_messageUserUnpinned && _followMessagesAfterDomReplace()) return;" in helper
assert "_restoreMessageScrollSnapshot(scrollSnapshot);" in helper
assert helper.index("_restoreMessageScrollSnapshot(scrollSnapshot)") < helper.index(
"_maybeShowNewMessageScrollCue(scrollSnapshot)"

View File

@@ -182,7 +182,7 @@ def test_normalizer_maps_live_and_replay_to_same_anchor_event_identity():
live = data["liveToken"]
replay = data["replayToken"]
assert data["version"] == "slice5-activity-scene"
assert data["version"] == "slice7-dual-run-reconciler"
assert live["classification"] == "activity"
assert live["dedupe_key"] == 'event_id:"run-1:7"'
assert replay["dedupe_key"] == live["dedupe_key"]
@@ -324,15 +324,17 @@ def test_structured_fallback_dedupe_keys_do_not_collide_on_delimiters():
assert data["localNoSeqKey"] == ""
def test_normalizer_and_registry_helpers_are_still_unwired_from_rendering_hot_paths():
def test_normalizer_helpers_remain_unwired_from_rendering_and_live_hot_paths():
helper_names = [
"normalizeAssistantTurnAnchorSourceEvent",
"normalizeAssistantTurnAnchorSourceEvents",
"createAssistantTurnAnchorRegistry",
"applyAssistantTurnAnchorSourceEvent",
"applyAssistantTurnAnchorSourceEvents",
]
for helper in helper_names:
assert helper not in _read(UI_JS)
assert helper not in _read(SESSIONS_JS)
assert helper not in _read(MESSAGES_JS)
messages_src = _read(MESSAGES_JS)
assert "createAssistantTurnAnchorRegistry" in messages_src
assert "applyAssistantTurnAnchorSourceEvent" in messages_src

View File

@@ -1,8 +1,7 @@
"""Phase 0 contract tests for Stable Assistant Turn Anchors (#3926).
The first implementation slice is intentionally non-visual. It adds the
inventory and pure helper surface that later phases can wire into send(),
attachLiveStream(), replay hydration, and renderMessages().
The first implementation slice was intentionally non-visual. Later slices keep
the same inventory contract while adding narrow, tested wiring points.
"""
from __future__ import annotations
@@ -94,7 +93,11 @@ def test_phase0_scaffold_is_loaded_before_current_rendering_modules():
assert "createAssistantTurnAnchorRegistry" not in ui_src
assert "applyAssistantTurnAnchorSourceEvent" not in ui_src
assert "HermesAssistantTurnAnchors" not in _read(SESSIONS_JS)
assert "HermesAssistantTurnAnchors" not in _read(MESSAGES_JS)
messages_src = _read(MESSAGES_JS)
assert "window._liveAnchorRegistries" in messages_src
assert "createAssistantTurnAnchorRegistry" in messages_src
assert "applyAssistantTurnAnchorSourceEvent" in messages_src
assert "projectAssistantTurnAnchorActivityScene" not in messages_src
def test_phase0_inventory_names_current_state_layers_in_authority_order():
@@ -226,6 +229,9 @@ def test_phase0_inventory_doc_matches_scaffold_contract():
"`INFLIGHT`",
"Stream closure state",
"Live DOM",
"Slice 7 Dual-Run Reconciler",
"`HermesAssistantTurnAnchors.reconcileAssistantTurnAnchorActivityScene()`",
"`activity_scene_reconciliation_v1`",
"Dedupe Invariant",
"`event_id`",
"`run_id + seq`",

View File

@@ -18,6 +18,19 @@ def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _event_listener_body(src: str, event_name: str) -> str:
start = src.index(f"source.addEventListener('{event_name}'")
end = src.find("\n source.addEventListener(", start + 1)
if end < 0:
end = src.find("\n source.onerror", start + 1)
if end < 0:
end = src.find("\n }catch", start + 1)
if end < 0:
end = len(src)
assert end > start
return src[start:end]
def _registry_snapshot() -> dict:
assert NODE, "node is required for assistant_turn_anchors.js registry tests"
script = f"""
@@ -203,6 +216,83 @@ console.log(JSON.stringify({{
return json.loads(result.stdout)
def _activity_scene_reconciliation_snapshot() -> dict:
assert NODE, "node is required for assistant_turn_anchors.js registry tests"
script = f"""
const fs = require('fs');
const vm = require('vm');
const src = fs.readFileSync({json.dumps(str(ANCHORS_JS))}, 'utf8');
const sandbox = {{window:{{}}}};
vm.createContext(sandbox);
vm.runInContext(src, sandbox, {{filename:'assistant_turn_anchors.js'}});
const api = sandbox.window.HermesAssistantTurnAnchors;
const registry = api.createAssistantTurnAnchorRegistry({{
session_id:'sid-reconcile',
turn_id:'turn-reconcile',
}});
api.applyAssistantTurnAnchorSourceEvents(registry, [
{{event:'reasoning', payload:{{text:'thinking'}}, event_id:'run-reconcile:1', seq:1}},
{{event:'tool', payload:{{tool_call_id:'tool-1', name:'terminal', args:{{command:'rg anchor'}}}}, event_id:'run-reconcile:2', seq:2}},
{{event:'tool_complete', payload:{{tool_call_id:'tool-1', name:'terminal', result:'ok', is_error:false}}, event_id:'run-reconcile:3', seq:3}},
{{event:'done', payload:{{status:'done'}}, event_id:'run-reconcile:4', seq:4}},
], {{run_id:'run-reconcile', stream_id:'stream-reconcile'}});
const scene = api.projectAssistantTurnAnchorActivityScene(registry, {{mode:'transparent_stream'}});
const rendererRows = scene.activity_rows.map((row) => ({{
row_id:row.row_id,
kind:row.kind,
role:row.role,
source_event_type:row.source_event_type,
status:row.status,
tool_call_id:row.tool_call_id,
tool_name:row.tool && row.tool.name,
tool_done:row.tool && row.tool.done,
tool_is_error:row.tool && row.tool.is_error,
}}));
const matched = api.reconcileAssistantTurnAnchorActivityScene({{
registry,
mode:'transparent_stream',
renderer_rows:rendererRows,
}});
const mutatedRows = rendererRows
.filter((row) => row.row_id !== 'run-reconcile:1')
.map((row) => row.row_id === 'run-reconcile:3'
? {{...row, status:'running', tool_done:false}}
: row);
mutatedRows.push({{
row_id:'renderer-extra',
kind:'tool_started',
role:'tool',
source_event_type:'tool',
status:'running',
tool_call_id:'tool-extra',
tool_name:'terminal',
tool_done:false,
tool_is_error:false,
}});
const mismatched = api.reconcileAssistantTurnAnchorActivityScene({{
scene,
renderer_rows:mutatedRows,
}});
const duplicatedRows = rendererRows.map((row) => row.row_id === 'run-reconcile:3'
? {{...rendererRows[1]}}
: row);
const duplicated = api.reconcileAssistantTurnAnchorActivityScene({{
scene,
renderer_rows:duplicatedRows,
}});
console.log(JSON.stringify({{
version:api.version,
scene,
matched,
mismatched,
duplicated,
}}));
"""
result = subprocess.run([NODE, "-e", script], text=True, capture_output=True, check=False)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def _final_projection_snapshot() -> dict:
assert NODE, "node is required for assistant_turn_anchors.js registry tests"
script = f"""
@@ -433,7 +523,7 @@ def test_registry_owns_one_anchor_and_dedupes_live_plus_replay_events():
registry = data["registry"]
anchor = registry["anchor"]
assert data["version"] == "slice5-activity-scene"
assert data["version"] == "slice7-dual-run-reconciler"
assert [item["reason"] for item in data["results"][:2]] == [None, "duplicate"]
assert registry["event_index"]["dedupe_keys"][:2] == [
'event_id:"run-1:1"',
@@ -533,7 +623,7 @@ def test_registry_does_not_destructively_dedupe_seqless_local_tool_lifecycle():
registry = data["toolRegistry"]
anchor = registry["anchor"]
assert data["version"] == "slice5-activity-scene"
assert data["version"] == "slice7-dual-run-reconciler"
assert data["toolResults"] == [
{"applied": True, "reason": None},
{"applied": True, "reason": None},
@@ -583,7 +673,7 @@ def test_shadow_snapshot_feeds_current_source_families_into_one_registry_owner()
registry = data["registry"]
anchor = registry["anchor"]
assert data["version"] == "slice5-activity-scene"
assert data["version"] == "slice7-dual-run-reconciler"
assert data["results"]["live"] == [{"applied": True, "reason": None}]
assert data["results"]["replay"] == [
{"applied": False, "reason": "duplicate"},
@@ -611,7 +701,7 @@ def test_activity_scene_projects_current_activity_events_for_both_render_modes()
compact = data["compact"]
transparent = data["transparent"]
assert data["version"] == "slice5-activity-scene"
assert data["version"] == "slice7-dual-run-reconciler"
assert compact["version"] == "activity_scene_v1"
assert transparent["version"] == "activity_scene_v1"
assert compact["mode"] == "compact_worklog"
@@ -732,13 +822,112 @@ def test_activity_scene_is_renderer_neutral_and_empty_safe():
}
def test_activity_scene_reconciler_matches_renderer_snapshot_rows():
data = _activity_scene_reconciliation_snapshot()
matched = data["matched"]
assert data["version"] == "slice7-dual-run-reconciler"
assert matched["version"] == "activity_scene_reconciliation_v1"
assert matched["scene_version"] == "activity_scene_v1"
assert matched["mode"] == "transparent_stream"
assert matched["matched"] is True
assert matched["summary"] == {
"expected_count": 4,
"actual_count": 4,
"mismatch_count": 0,
}
assert matched["terminal_state"] == "completed"
assert matched["fields"] == [
"kind",
"role",
"source_event_type",
"status",
"tool_call_id",
"tool_name",
"tool_done",
"tool_is_error",
]
assert [row["row_id"] for row in matched["expected_rows"]] == [
"run-reconcile:1",
"run-reconcile:2",
"run-reconcile:3",
"run-reconcile:4",
]
assert matched["actual_rows"][2]["tool_name"] == "terminal"
assert matched["actual_rows"][2]["tool_done"] is True
def test_activity_scene_reconciler_reports_missing_changed_and_extra_rows():
data = _activity_scene_reconciliation_snapshot()
mismatched = data["mismatched"]
kinds = [item["kind"] for item in mismatched["mismatches"]]
assert mismatched["matched"] is False
assert mismatched["summary"]["expected_count"] == 4
assert mismatched["summary"]["actual_count"] == 4
assert mismatched["summary"]["mismatch_count"] == len(mismatched["mismatches"])
assert "missing_actual_row" in kinds
assert "unexpected_actual_row" in kinds
assert {
"kind": "field_mismatch",
"row_id": "run-reconcile:3",
"field": "status",
"expected": "completed",
"actual": "running",
} in mismatched["mismatches"]
assert {
"kind": "field_mismatch",
"row_id": "run-reconcile:3",
"field": "tool_done",
"expected": True,
"actual": False,
} in mismatched["mismatches"]
missing = [
item for item in mismatched["mismatches"]
if item["kind"] == "missing_actual_row"
]
assert missing[0]["row_id"] == "run-reconcile:1"
extra = [
item for item in mismatched["mismatches"]
if item["kind"] == "unexpected_actual_row"
]
assert extra[0]["row_id"] == "renderer-extra"
def test_activity_scene_reconciler_reports_duplicate_renderer_row_ids():
data = _activity_scene_reconciliation_snapshot()
duplicated = data["duplicated"]
kinds = [item["kind"] for item in duplicated["mismatches"]]
assert duplicated["matched"] is False
assert "duplicate_actual_row" in kinds
assert "missing_actual_row" in kinds
duplicates = [
item for item in duplicated["mismatches"]
if item["kind"] == "duplicate_actual_row"
]
assert duplicates == [
{
"kind": "duplicate_actual_row",
"row_id": "run-reconcile:2",
"index": 2,
"row": duplicated["actual_rows"][2],
}
]
missing = [
item for item in duplicated["mismatches"]
if item["kind"] == "missing_actual_row"
]
assert missing[0]["row_id"] == "run-reconcile:3"
def test_final_projection_routes_settled_assistant_message_through_anchor_owner():
data = _final_projection_snapshot()
projected = data["projected"]
registry = projected["registry"]
anchor = registry["anchor"]
assert data["version"] == "slice5-activity-scene"
assert data["version"] == "slice7-dual-run-reconciler"
assert projected["applied"] is True
assert projected["reason"] is None
assert projected["final_message_ref"] == "message-final"
@@ -806,18 +995,77 @@ def test_registry_instances_do_not_share_owner_state():
assert isolated["anchor"]["activity_events"] == []
def test_slice5_scene_projection_does_not_wire_activity_scene_into_rendering_hot_paths():
helper_names = [
"createAssistantTurnAnchorRegistry",
scene_helper = "projectAssistantTurnAnchorActivityScene"
for helper in [
"applyAssistantTurnAnchorNormalizedEvent",
"applyAssistantTurnAnchorSourceEvent",
"applyAssistantTurnAnchorSourceEvents",
"createAssistantTurnAnchorShadowSnapshot",
]
for helper in helper_names:
scene_helper,
"reconcileAssistantTurnAnchorActivityScene",
]:
assert helper not in _read(UI_JS)
assert helper not in _read(SESSIONS_JS)
assert helper not in _read(MESSAGES_JS)
assert "projectAssistantTurnAnchorSettledMessageFinalAnswer" in _read(UI_JS)
assert "projectAssistantTurnAnchorActivityScene" not in _read(UI_JS)
assert "projectAssistantTurnAnchorActivityScene" not in _read(SESSIONS_JS)
assert "projectAssistantTurnAnchorActivityScene" not in _read(MESSAGES_JS)
assert scene_helper not in _read(UI_JS)
assert scene_helper not in _read(SESSIONS_JS)
assert scene_helper not in _read(MESSAGES_JS)
def test_slice6_live_shadow_feed_wires_non_token_events_without_renderer_scene_consumption():
src = _read(MESSAGES_JS)
helper_body = src.split("function _applyToAnchor", 1)[1].split(
"function _mergeSettledToolCallsWithLiveMetadata", 1
)[0]
assert "window._liveAnchorRegistries=window._liveAnchorRegistries||new Map()" in src
assert "_anchorRegistryMap.get(streamId)" in src
assert "_anchorRegistryMap.set(streamId,_anchorRegistry)" in src
assert "createAssistantTurnAnchorRegistry" in src
assert "applyAssistantTurnAnchorSourceEvent" in src
assert "const eventId=(sseEvent&&sseEvent.lastEventId)||raw.event_id||raw.lastEventId||raw.last_event_id||'';" in helper_body
assert helper_body.index("...raw,") < helper_body.index("source_event_type:sourceEventType")
for event_name in [
"interim_assistant",
"tool",
"tool_complete",
"approval",
"clarify",
"goal_continue",
"pending_steer_leftover",
"compressing",
"compressed",
"apperror",
"cancel",
]:
assert f"_applyToAnchor('{event_name}'" in _event_listener_body(src, event_name)
token_body = _event_listener_body(src, "token")
assert "_applyToAnchor" not in token_body
reasoning_body = _event_listener_body(src, "reasoning")
assert "_applyToAnchor" not in reasoning_body
assert "function _flushReasoningToAnchor()" in src
assert "_applyToAnchor('reasoning',{" in src
assert "local_id:'live-reasoning'" in src
error_body = _event_listener_body(src, "error")
assert "_applyToAnchor('error'" not in error_body
assert "_flushReasoningToAnchor();" in error_body
assert "_scheduleAnchorRegistryCleanup(120000);" in error_body
assert "_handleStreamError(source)" in error_body
assert "projectAssistantTurnAnchorActivityScene" not in src
tool_body = _event_listener_body(src, "tool")
assert tool_body.index("upsertLiveToolCall(d,'start')") < tool_body.index(
"_applyToAnchor('tool'"
)
done_body = _event_listener_body(src, "done")
assert "_applyToAnchor('done',{" in done_body
assert "usage:d.usage||null" in done_body
assert "created_at:d.created_at||null" in done_body
assert "_applyToAnchor('done',{...d" not in done_body
assert "_flushReasoningToAnchor();" in done_body
assert "_scheduleAnchorRegistryCleanup();" in done_body
assert "lastAsst._anchor_stream_id=streamId" in done_body
assert "'_anchor_stream_id'" in src
assert src.index("'_anchor_stream_id'") < src.index("function _carryForwardEphemeralTurnFields")

View File

@@ -116,7 +116,7 @@ def test_preserve_scroll_restores_unpinned_viewport_after_dom_rebuild():
"renderMessages({preserveScroll:true}) must capture #messages.scrollTop before "
"replacing transcript DOM, then pass that snapshot to the post-render scroll helper"
)
assert "if(!_messageUserUnpinned && _followMessagesAfterDomReplace()) return;" in after_render
assert "if(!readerAwayFromBottom && !_messageUserUnpinned && _followMessagesAfterDomReplace()) return;" in after_render
assert "_restoreMessageScrollSnapshot(scrollSnapshot);\n _maybeShowNewMessageScrollCue(scrollSnapshot);" in after_render
assert "_shouldFollowMessagesOnDomReplace()" in follow
assert "scrollToBottom();" in follow

View File

@@ -322,3 +322,34 @@ class TestApplyUpdateDiagnostics:
assert 'could not reach' in result['message'].lower() or \
'internet' in result['message'].lower() or \
'remote' in result['message'].lower()
# Issue #4085 regression: a dirty install at-or-past the latest release tag
# must surface `dirty: True` on the check payload so the Settings panel can
# offer `apply_force_update` instead of the silent "Up to date" state.
class TestCheckRepoDirtyFlag:
def test_dirty_tree_surfaces_dirty_true(self, tmp_path):
"""A dirty tree on a network-failed check must still carry dirty=True.
The Settings panel needs the dirty flag on every payload shape so a
user can always recover from a local-modifications state, even when
the latest remote cannot be reached.
"""
(tmp_path / '.git').mkdir()
def fake_git(args, cwd, timeout=10):
if args == ['diff-index', '--quiet', 'HEAD', '--']:
return '', False # dirty
if args == ['fetch', 'origin', '--tags', '--force']:
return 'network unavailable', False # fail fast
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return '', True
raise AssertionError(f'unexpected git args: {args!r}')
from api import updates
with patch(f'{_MODULE}._run_git', side_effect=fake_git):
info = updates._check_repo(tmp_path, 'webui')
assert info is not None
assert info['dirty'] is True
assert info['stale_check'] is True

View File

@@ -5,6 +5,8 @@ import api.updates as updates
def _fake_git_for_release_fetch_failure(args, cwd, timeout=10):
if args == ['diff-index', '--quiet', 'HEAD', '--']:
return '', True # clean tree
if args == ['fetch', 'origin', '--tags', '--force']:
return 'would clobber existing tag v0.50.294', False
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
@@ -32,6 +34,9 @@ def test_check_repo_reports_release_gap_even_when_tag_fetch_fails(tmp_path):
assert info['latest_version'] == 'v0.51.106'
assert info['stale_check'] is True
assert 'would clobber existing tag' in info['error']
# Issue #4085: the dirty flag must ride along on every payload shape.
# The mock returns ('', True) for the dirty probe, so the tree is clean.
assert info['dirty'] is False
def test_check_repo_redacts_credentialed_fetch_failure(tmp_path):
@@ -45,6 +50,8 @@ def test_check_repo_redacts_credentialed_fetch_failure(tmp_path):
)
def fake_git(args, cwd, timeout=10):
if args == ['diff-index', '--quiet', 'HEAD', '--']:
return '', True
if args == ['fetch', 'origin', '--tags', '--force']:
return raw_error, False
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
@@ -68,6 +75,8 @@ def test_check_repo_fetch_failure_without_tags_is_not_up_to_date(tmp_path):
(tmp_path / '.git').mkdir()
def fake_git(args, cwd, timeout=10):
if args == ['diff-index', '--quiet', 'HEAD', '--']:
return '', True
if args == ['fetch', 'origin', '--tags', '--force']:
return 'network unavailable', False
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
@@ -225,6 +234,8 @@ def test_check_repo_fetches_tags_with_force(tmp_path):
def fake_git(args, cwd, timeout=10):
seen_args.append(args)
if args == ['diff-index', '--quiet', 'HEAD', '--']:
return '', True
if args[:2] == ['fetch', 'origin']:
# Force a fetch failure path so we don't have to mock the rest of
# the release/branch logic; the assertion is about the args shape.