Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5838f8c770 | ||
|
|
0805f557e9 | ||
|
|
8db88c882d | ||
|
|
670691c387 | ||
|
|
bba5631a69 | ||
|
|
0fdcd00858 | ||
|
|
465d229800 | ||
|
|
c76e219076 | ||
|
|
33b7517b81 | ||
|
|
3e6309d8a8 | ||
|
|
c52c832d05 | ||
|
|
e3d8a3dc30 | ||
|
|
310edac58e | ||
|
|
3cf96b1293 | ||
|
|
9594b97223 | ||
|
|
fcad7d6db2 | ||
|
|
30dd5647ed | ||
|
|
aa96bc52bc | ||
|
|
9bb1dc8573 |
24
CHANGELOG.md
24
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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(_){}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
17
static/ui.js
17
static/ui.js
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user