Compare commits

...

31 Commits

Author SHA1 Message Date
Botomir
8d8a88680e fix: record fallback routing metadata
Some checks failed
Browser smoke / browser-smoke (push) Has been cancelled
Tests / lint (push) Has been cancelled
Tests / test (3.11, 0) (push) Has been cancelled
Tests / test (3.11, 1) (push) Has been cancelled
Tests / test (3.11, 2) (push) Has been cancelled
Tests / test (3.12, 0) (push) Has been cancelled
Tests / test (3.12, 1) (push) Has been cancelled
Tests / test (3.12, 2) (push) Has been cancelled
Tests / test (3.13, 0) (push) Has been cancelled
Tests / test (3.13, 1) (push) Has been cancelled
Tests / test (3.13, 2) (push) Has been cancelled
2026-07-10 08:07:32 +02:00
nesquena-hermes
ae90cf620b Merge pull request #4106 from nesquena/stage-4016b
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MY (v0.51.386): voice mode survives dropped speechSynthesis onend (#3983)
2026-06-13 00:47:20 -07:00
nesquena-hermes
8b5c8e32fd docs(changelog): stamp #3983 voice-mode watchdog as v0.51.386 (Release MY) 2026-06-13 07:38:19 +00:00
nesquena-hermes
f61f88f16b Merge #4016 (re-arm browser voice mode when speechSynthesis drops, #3983) onto master 2026-06-13 07:37:31 +00:00
nesquena-hermes
1ae56799cb Merge pull request #4105 from nesquena/stage-4028
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MX (v0.51.385): profile-cookie env var aligned to HERMES_WEBUI_ prefix (#803)
2026-06-13 00:36:33 -07:00
nesquena-hermes
a31466b1a3 docs(changelog): stamp #803 profile-cookie env var rename as v0.51.385 (Release MX) 2026-06-13 07:21:34 +00:00
nesquena-hermes
6e6931a8c3 Merge #4028 (align profile cookie env var with HERMES_WEBUI_ prefix) onto master 2026-06-13 07:20:58 +00:00
nesquena-hermes
abe89f3afa Merge pull request #4104 from nesquena/stage-3899
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MW (v0.51.384): no false streaming / activity-timer reset on session switch (#3900)
2026-06-13 00:19:07 -07:00
nesquena-hermes
e129203854 test(#3899): re-anchor 4 test_inflight_stream_reuse tests to post-#3899 loadSession shape
rfind for the Phase-2 INFLIGHT branch (idle-reset added an earlier if(INFLIGHT[sid]){);
and test_load_session_attaches_sse_before_auxiliary_work now asserts each auxiliary call
individually (the old contiguous syncTopbar();renderMessages();appendThinking();loadDir
literal was legitimately broken up by the new restoreLiveTurnHtmlForSession insertion +
renderMessages(preserveScroll) arg — all calls still run after attachLiveStream). Behavior unchanged.
2026-06-13 07:10:15 +00:00
nesquena-hermes
377a1889aa test(#3899): widen 2 more fixed-window loadSession slices displaced by idle-reset block
test_issue3306 _load_session_clear_block (4000->6500) + test_issue_new_chat_draft_restore
body window (4000->6500): #3899's idle-reset + live-turn-snapshot blocks pushed the
carry-forward snapshot + destructive S.messages clear past the old fixed windows. Behavior
+ ordering unchanged; windows widened to contain them.
2026-06-13 06:55:36 +00:00
nesquena-hermes
5f3e0ab8d9 test(#3899): re-anchor 5 brittle source-window tests displaced by the idle-reset block
#3899's new idle-reset added an EARLIER if(INFLIGHT[sid]){ block in loadSession, so
4 test_regressions tests' src.find('if(INFLIGHT[sid]){') grabbed the wrong (idle-cleanup)
occurrence instead of the Phase-2 restore branch → switched to rfind (the substantive
branch). And the idle-cleanup comment changed from 'discard any browser-side inflight'
to 'reset browser-side streaming flags' → re-anchored test_frontend_drops_inflight_cache
on the new comment + nested if(!activeStreamId){...if(INFLIGHT[sid]){ form (behavior
preserved + enhanced). No production code changed.
2026-06-13 06:48:21 +00:00
nesquena-hermes
5bbddbad1e fix(#3900): false streaming + activity-timer reset on session switch (absorb #3899) + re-anchor regression tests
Production fixes (Tamaz-sujashvili, reviewed sound by maintainer):
- loadSession clears S.busy/S.activeStreamId as soon as metadata confirms no active_stream_id, before the async message-load gap (idle session no longer shows streaming chrome).
- Snapshots the live turn before wiping msgInner + seeds INFLIGHT, restores on the active-stream return path (timer/trace survive switch-back).

Re-anchored the 2 brittle regression tests per maintainer review: test_..snapshots.. now anchors on the unique 'Loading conversation...' marker (was matching the no-space 'Session not available' error path); test_..restores.. now asserts the LIVE Phase 2a restore (after loadInflightState) instead of the unreachable Phase-2b/1184 branch. CHANGELOG stamped v0.51.384 (MW).
2026-06-13 06:39:22 +00:00
nesquena-hermes
0f9b62370b Merge pull request #4102 from nesquena/stage-4094
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MV (v0.51.383): desktop tab title keeps active session name (#4086)
2026-06-12 23:21:13 -07:00
nesquena-hermes
8c54973ed4 docs(changelog): stamp #4086 document-title-owner fix as v0.51.383 (Release MV) 2026-06-13 06:12:28 +00:00
nesquena-hermes
d85fd2c967 Merge #4094 (preserve session document title during bot-name refresh) onto master 2026-06-13 06:11:24 +00:00
Lynn-Lee
46a5ce1b1a fix tab title clobber during bot name refresh 2026-06-13 14:03:02 +08:00
nesquena-hermes
3f2eb8d362 Merge pull request #4101 from nesquena/stage-4093
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MU (v0.51.382): Stable Assistant Turn Anchors activity-scene projection (inert) (#4093)
2026-06-12 23:02:04 -07:00
nesquena-hermes
4eec21c434 docs(changelog): stamp #4093 activity-scene projection (inert) as v0.51.382 (Release MU) 2026-06-13 05:56:53 +00:00
nesquena-hermes
9cf67cbeb2 Merge #4093 (anchor activity scene projection + tool_limit_reached stream label) onto master 2026-06-13 05:56:09 +00:00
nesquena-hermes
2847bd98a0 Merge pull request #4100 from nesquena/stage-4092
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MT (v0.51.381): Stable Assistant Turn Anchors settled final-answer projection (#4092)
2026-06-12 22:40:31 -07:00
nesquena-hermes
f230a16f4d docs(changelog): stamp #4092 settled final-answer projection as v0.51.381 (Release MT) 2026-06-13 05:36:35 +00:00
nesquena-hermes
841d78d4e5 Merge #4092 (assistant turn anchor settled final projection) onto master 2026-06-13 05:36:00 +00:00
Frank Song
59c2ccd9cf feat(anchor): project activity scene rows 2026-06-13 13:23:27 +08:00
Frank Song
b990bd09dd feat(anchor): project settled final answer through anchor 2026-06-13 13:23:15 +08:00
gaku
6a13feaf8b refactor(profiles): align profile cookie env var with HERMES_WEBUI_* naming
The profile cookie has been configurable since #1756 via
WEBUI_PROFILE_COOKIE_NAME, the lone WebUI env var missing the HERMES_WEBUI_
prefix shared by every other setting (e.g. HERMES_WEBUI_COOKIE_NAME from #3981).

- Read HERMES_WEBUI_PROFILE_COOKIE_NAME first (canonical name)
- Keep WEBUI_PROFILE_COOKIE_NAME as a deprecated alias so existing deployments
  are unaffected; behavior is unchanged, only the name is aligned
- Warn once per process for the legacy name (this resolver runs on every
  request, so the deprecation log must not fire per-request)
- Add resolution tests covering canonical, legacy, precedence, blank, and
  warn-once paths
2026-06-12 09:55:49 +00:00
Rod Boev
f6ed8f7302 Make browser TTS watchdog checks brace-aware 2026-06-11 20:42:51 -04:00
Rod Boev
a8137ff21c Guard watchdog recovery from duplicate voice-mode resume 2026-06-11 19:24:57 -04:00
Rod Boev
4ba4f77343 Keep the Edge-branch guard test aligned with branch scope 2026-06-11 19:04:26 -04:00
Rod Boev
09f19d1233 Prevent duplicate voice-mode rearm after watchdog recovery 2026-06-11 18:58:12 -04:00
Rod Boev
f4a6544121 fix(#3983): re-arm browser voice mode when speechSynthesis drops onend 2026-06-11 18:48:58 -04:00
Tamaz_Sujashvili
3807c247e9 Fix false streaming and live UI reset when switching sessions.
Clear stale busy/stream state before async message loads and restore
snapshotted turn HTML when returning to an active stream.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 03:09:44 +04:00
20 changed files with 1264 additions and 42 deletions

View File

@@ -3,6 +3,42 @@
## [Unreleased]
## [v0.51.386] — 2026-06-13 — Release MY (voice mode survives a dropped speechSynthesis onend, #3983)
### Fixed
- **Hands-free voice mode no longer dead-ends after the first browser-TTS reply (#3983).** Chromium intermittently drops the `speechSynthesis` utterance's `onend` event, which left voice mode stuck "speaking" and never re-armed listening. A watchdog now forces a return to listening if `onend` never fires, with the recovery handles cleared on normal completion and on deactivation. The fix is scoped to the browser `speechSynthesis` path — the Edge `Audio` branch (which has a reliable `onended`) is untouched. (#3983)
## [v0.51.385] — 2026-06-13 — Release MX (profile-cookie env var aligned to HERMES_WEBUI_ prefix, #803)
### Changed
- **The profile-cookie name env var now uses the standard `HERMES_WEBUI_` prefix (#803).** Set the per-instance session-profile cookie name via `HERMES_WEBUI_PROFILE_COOKIE_NAME`, matching every other WebUI setting's prefix; the original `WEBUI_PROFILE_COOKIE_NAME` keeps working as a deprecated fallback (warned once per process). Lets multiple WebUI instances on the same host+port disambiguate their profile cookies without env-var-naming surprises. (#803)
## [v0.51.384] — 2026-06-13 — Release MW (no false streaming / activity-timer reset on session switch, #3900)
### Fixed
- **An idle session no longer shows streaming chrome (Stop/spinner/thinking) right after a sidebar switch, and switching back to a live stream no longer resets its activity timer (#3900).** `loadSession` now clears `S.busy` / `S.activeStreamId` as soon as the session metadata confirms there is no `active_stream_id` — before the async message-load gap — so the previous session's busy flag can't bleed onto an idle chat. On switch-away it snapshots the live-turn DOM before replacing the message pane (seeding an `INFLIGHT` bucket if needed) and restores that HTML on the active-stream return path instead of rebuilding the worklog shell from scratch, so the elapsed timer and live trace survive the round-trip. (#3900)
## [v0.51.383] — 2026-06-13 — Release MV (desktop tab title keeps active session name on bot-name refresh, #4086)
### Fixed
- **Desktop tab titles keep the active session name when profile/settings boot paths refresh the assistant name (#4086).** `applyBotName()` now leaves `document.title` alone while a chat session is active, so `syncTopbar()` remains the owner of the per-session `"<session> — <assistant>"` title and desktop tabs no longer collapse to the same fallback name. (#4086)
## [v0.51.382] — 2026-06-13 — Release MU (Stable Assistant Turn Anchors: activity-scene projection, inert, #4093)
### Added
- **Stable Assistant Turn Anchors activity-scene projection (#4093, #3926).** Adds `projectAssistantTurnAnchorActivityScene` to the frozen `HermesAssistantTurnAnchors` surface — a renderer-neutral projection that turns the anchor owner's classified events into ordered activity-scene rows for the future Transparent Stream / Compact Worklog convergence. This slice is **inert**: no render path consumes the scene projection yet (the only live anchor consumer remains the #4092 settled final-answer projection, which still receives a pre-flattened string), so there is zero rendering change in this release. (#4093)
## [v0.51.381] — 2026-06-13 — Release MT (Stable Assistant Turn Anchors: settled final-answer projection, #4092)
### Changed
- **Settled assistant final answers now route through the Stable Assistant Turn Anchor owner (#4092, #3926).** As the first RFC Phase 3 wiring slice, `renderMessages()` derives each *settled* assistant message's final-answer text via `projectAssistantTurnAnchorSettledMessageFinalAnswer` (the anchor registry's settled-message projection) instead of reading the raw content directly. The projection is a faithful identity transform for normal string content (verified by unit echo across plain/markdown/inline-think/unicode/whitespace/code, a real-transcript A/B render that was byte-identical to master, and a live-build `applied:true` echo check), is wrapped in a try/catch that falls back to the original content on any error, and only applies when it produces a usable string — so there is no visible rendering change today; it moves the settled-final derivation onto the single anchor owner that live/replay/settled sources will converge on. Live (`_live`) turns and non-string content are untouched. (#4092)
## [v0.51.380] — 2026-06-13 — Release MS (tool-iteration-limit stops surfaced explicitly, #3821)
### Fixed
@@ -247,6 +283,7 @@
- **Worklog details settings now align with the live-to-final model.** The old "Activity expanded by default" setting is renamed to **Worklog details** (default folded), the legacy "Compact tool activity" preference is deprecated, and the Worklog renderer stays enabled for older installs that had saved `simplified_tool_calling=false`. (#3400, #3820)
## [v0.51.346] — 2026-06-09 — Release LJ (PWA notification controls)
### Added

View File

@@ -495,11 +495,36 @@ def read_body(handler) -> dict:
# ── Profile cookie helpers (issue #798) ─────────────────────────────────────
PROFILE_COOKIE_NAME = 'hermes_profile'
_PROFILE_COOKIE_ENV = 'HERMES_WEBUI_PROFILE_COOKIE_NAME'
_LEGACY_PROFILE_COOKIE_ENV = 'WEBUI_PROFILE_COOKIE_NAME'
_legacy_profile_cookie_warned = False
def get_profile_cookie_name() -> str:
"""Return the cookie name used to persist the active WebUI profile."""
return os.getenv('WEBUI_PROFILE_COOKIE_NAME', PROFILE_COOKIE_NAME)
"""Return the cookie name used to persist the active WebUI profile.
Honours ``HERMES_WEBUI_PROFILE_COOKIE_NAME`` so multiple WebUI instances
sharing a hostname (different ports) can use distinct profile-cookie names
instead of trampling each other; browsers scope cookies by host, not
host+port (RFC 6265). The original ``WEBUI_PROFILE_COOKIE_NAME`` is still
honoured as a deprecated fallback (warned once per process, since this is
called on every request).
"""
name = os.getenv(_PROFILE_COOKIE_ENV, '').strip()
if name:
return name
legacy = os.getenv(_LEGACY_PROFILE_COOKIE_ENV, '').strip()
if legacy:
global _legacy_profile_cookie_warned
if not _legacy_profile_cookie_warned:
logger.warning(
'%s is deprecated; use %s instead.',
_LEGACY_PROFILE_COOKIE_ENV,
_PROFILE_COOKIE_ENV,
)
_legacy_profile_cookie_warned = True
return legacy
return PROFILE_COOKIE_NAME
def get_profile_cookie(handler) -> str | None:

View File

@@ -7307,6 +7307,27 @@ def _run_agent_streaming(
requested_model=resolved_model or model,
requested_provider=resolved_provider,
)
# Synthesize routing metadata from the agent's result when the
# hermes fallback chain switched provider/model but didn't
# populate llm_gateway_metadata (result carries 'model'/'provider',
# not 'used_model'/'used_provider' that _extract_... expects).
if not _gateway_routing and isinstance(result, dict):
_result_model = result.get('model') or ''
_result_provider = result.get('provider') or ''
_req_model = resolved_model or model or ''
_req_provider = resolved_provider or ''
if (_result_model and _result_model != _req_model) or \
(_result_provider and _req_provider and _result_provider != _req_provider):
_gateway_routing = _normalize_gateway_routing_metadata(
{
'used_model': _result_model,
'used_provider': _result_provider,
'requested_model': _req_model,
'requested_provider': _req_provider,
},
requested_model=_req_model,
requested_provider=_req_provider,
)
if _gateway_routing:
s.gateway_routing = _gateway_routing
_history = list(getattr(s, 'gateway_routing_history', None) or [])

View File

@@ -21,9 +21,15 @@ streaming or rendering yet.
anchor seed excludes renderer presentation state, terminal states are exposed
as constants with alias normalization, and replay + settlement ordering is
pinned by tests before visible wiring begins.
- The next independently reviewable boundary is Phase 3 settlement through the
anchor owner. `S.messages`, `INFLIGHT`, stream-local state, and DOM nodes remain
projection/cache layers until that wiring lands.
- Slice 4 starts RFC Phase 3 by routing settled assistant final prose through the
anchor owner before `renderMessages()` renders the final assistant body.
- 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.
## State Layers
@@ -114,6 +120,41 @@ event identity, lifecycle, final answer reference, and activity events.
until the matching field is explicitly moved. The fallback order is journal
replay first, settled transcript second, `INFLIGHT` only for gaps.
## Slice 4 Settled Final Projection
`HermesAssistantTurnAnchors.projectAssistantTurnAnchorSettledMessageFinalAnswer()`
projects one settled assistant transcript message through a local anchor
registry. The settled transcript message reference remains the semantic
authority (`content.final_message_ref`); `content.final_answer` is a derived
render snapshot for the existing markdown pipeline.
`renderMessages()` uses that projection only for settled assistant messages
(`!isUser && !m._live`) and only after preserving the current content-array
flattening behavior. It then continues through the existing inline-thinking and
markdown rendering pipeline. If the anchor helper is unavailable or cannot
produce a final answer, `renderMessages()` falls back to the existing message
content path.
This is intentionally narrower than render-scene ownership: live stream tokens,
replay hydration, worklog rows, transparent-stream rows, tool cards, `INFLIGHT`,
and DOM continuity are still not consumed by the anchor registry in this slice.
## Slice 5 Activity Scene Projection
`HermesAssistantTurnAnchors.projectAssistantTurnAnchorActivityScene()` projects
an anchor or registry into `activity_scene_v1`: identity, lifecycle,
`final_answer`, `final_message_ref`, terminal state, and an ordered
`activity_rows` list.
The rows are renderer-neutral. Compact Worklog receives display hints such as
`main_prose`, `collapsed_thinking`, `tool_row`, and `terminal_status_row`.
Transparent Stream receives the same row IDs, order, kinds, roles, text, tool
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.
## Source Event Classification
Phase 0 classifies current sources before changing render behavior:

View File

@@ -1,9 +1,9 @@
// Stable Assistant Turn Anchors scaffold (#3926).
//
// This file is intentionally inert: it defines the current ownership inventory,
// event classifications, and small owner helpers, but it does not register
// anchors globally or change any renderer. Later phases can wire these helpers into
// send(), attachLiveStream(), replay hydration, and renderMessages().
// This file defines the current ownership inventory, event classifications, and
// small owner helpers. It does not register anchors globally. The only renderer
// wiring in this slice is settled assistant final-answer projection; live
// streaming, replay hydration, tools, and DOM ownership remain unwired.
(function(){
const ROOT=(typeof window!=='undefined')?window:globalThis;
@@ -696,6 +696,352 @@
});
}
function _contextValue(context, keys){
return _firstOwn(context&&typeof context==='object'?context:{},keys);
}
function _messageValue(message, keys){
return _firstOwn(message&&typeof message==='object'?message:{},keys);
}
function _rawIndexMessageRef(context){
const rawIdx=_contextValue(context,['raw_idx','rawIdx']);
if(rawIdx===undefined||rawIdx===null||rawIdx==='') return '';
return 'raw_idx:'+String(rawIdx);
}
function projectAssistantTurnAnchorSettledMessageFinalAnswer(input, context){
const message=(input&&typeof input==='object')?input:{};
const ctx=(context&&typeof context==='object')?context:{};
const sessionId=_cleanString(_contextValue(ctx,['session_id','sessionId']))
||_cleanString(_messageValue(message,['session_id','sessionId']));
if(!sessionId){
return Object.freeze({applied:false,reason:'missing_session',final_answer:'',final_message_ref:null,registry:null});
}
const role=_cleanString(_messageValue(message,['role']))||'assistant';
if(role&&role!=='assistant'){
return Object.freeze({applied:false,reason:'non_assistant',final_answer:'',final_message_ref:null,registry:null});
}
const runId=_cleanString(_contextValue(ctx,['run_id','runId']))
||_cleanString(_messageValue(message,['run_id','runId','_run_id','runtime_run_id']));
const streamId=_cleanString(_contextValue(ctx,['stream_id','streamId']))
||_cleanString(_messageValue(message,['stream_id','streamId','_stream_id']));
const messageRef=_firstTextValue(
_messageValue(message,['message_id','id','local_id']),
_contextValue(ctx,['message_id','messageId','local_id','localId'])
)||_rawIndexMessageRef(ctx);
const turnId=_cleanString(_contextValue(ctx,['turn_id','turnId']))
||_cleanString(_messageValue(message,['turn_id','turnId']))
||[
'settled',
sessionId,
runId||streamId||messageRef||'assistant',
].join(':');
const registry=createAssistantTurnAnchorRegistry({
session_id:sessionId,
turn_id:turnId,
run_id:runId||null,
stream_id:streamId||null,
local_id:messageRef||null,
source_message_refs:messageRef?[messageRef]:[],
});
const payload={
role:'assistant',
id:messageRef||null,
content:_hasOwn(ctx,'content')?_own(ctx,'content'):_own(message,'content'),
};
const usage=_own(message,'usage');
const turnUsage=_own(message,'_turnUsage');
if(usage&&typeof usage==='object') payload.usage=usage;
if(turnUsage&&typeof turnUsage==='object') payload._turnUsage=turnUsage;
const result=applyAssistantTurnAnchorSourceEvent(registry,{
source_type:'settled_message',
payload,
local_id:messageRef||null,
},{
session_id:sessionId,
turn_id:turnId,
run_id:runId||null,
stream_id:streamId||null,
});
if(!result.applied){
return Object.freeze({
applied:false,
reason:result.reason||null,
final_answer:'',
final_message_ref:null,
registry,
});
}
const rawFinalAnswer=registry.anchor&&registry.anchor.content&&registry.anchor.content.final_answer;
const rawFinalMessageRef=registry.anchor&&registry.anchor.content&&registry.anchor.content.final_message_ref;
const finalAnswer=typeof rawFinalAnswer==='string'?rawFinalAnswer:'';
const finalMessageRef=typeof rawFinalMessageRef==='string'?rawFinalMessageRef:null;
return Object.freeze({
applied:!!result.applied,
reason:result.reason||null,
final_answer:finalAnswer,
final_message_ref:finalMessageRef,
registry,
});
}
function _anchorFromProjectionInput(input){
if(!input||typeof input!=='object') return null;
if(input.anchor&&typeof input.anchor==='object') return input.anchor;
if(input.identity&&typeof input.identity==='object') return input;
return null;
}
function _activityRowId(event, index){
const eventId=_cleanString(_own(event,'event_id'));
if(eventId) return eventId;
const runId=_cleanString(_own(event,'run_id'));
const seq=_own(event,'seq');
if(runId&&seq!==undefined&&seq!==null&&seq!=='') return [runId,String(seq)].join(':');
const localId=_cleanString(_own(event,'local_id'));
if(localId){
const sourceType=_cleanString(_own(event,'source_event_type'))||_cleanString(_own(event,'kind'))||'event';
return [localId,sourceType,String(index)].join(':');
}
return 'activity:'+String(index);
}
function _activityRowText(event){
const payload=_own(event,'payload')||{};
return _firstTextValue(
_own(payload,'text'),
_own(payload,'content'),
_own(payload,'message'),
_own(payload,'summary'),
_own(payload,'result'),
_own(payload,'output')
);
}
function _isToolActivityKind(kind){
return kind==='tool_started'||kind==='tool_updated'||kind==='tool_completed';
}
function _activityRowToolId(event, kind){
if(!_isToolActivityKind(kind)) return null;
const payload=_own(event,'payload')||{};
return _firstTextValue(
_own(payload,'tool_call_id'),
_own(payload,'tool_use_id'),
_own(payload,'call_id'),
_own(payload,'tid'),
_own(payload,'id')
)||null;
}
function _activityPayloadFirst(payload, keys){
return _firstOwn(payload||{},keys);
}
function _activityRowToolDone(kind, status, payload){
if(payload&&typeof _own(payload,'done')==='boolean') return _own(payload,'done');
if(kind==='tool_completed') return true;
if(kind==='tool_started'||kind==='tool_updated') return false;
if(status==='completed'||status==='error'||status==='failed') return true;
if(status==='running'||status==='pending') return false;
return null;
}
function _activityRowToolIsError(status, payload){
if(payload&&typeof _own(payload,'is_error')==='boolean') return _own(payload,'is_error');
const raw=_cleanString(status).toLowerCase();
if(raw==='error'||raw==='failed'||raw==='failure') return true;
return false;
}
function _activityRowGroup(event, payload, index){
const activitySegmentSeq=_activityPayloadFirst(payload,['activitySegmentSeq','activity_segment_seq','segmentSeq','segment_seq']);
const activityBurstId=_activityPayloadFirst(payload,['activityBurstId','activity_burst_id','burstId','burst_id']);
const assistantMsgIdx=_activityPayloadFirst(payload,['assistant_msg_idx','assistantMessageIndex','assistant_msg_index']);
const cleanSegment=activitySegmentSeq!==undefined&&activitySegmentSeq!==null&&String(activitySegmentSeq)!==''
? activitySegmentSeq
: null;
const cleanBurst=activityBurstId!==undefined&&activityBurstId!==null&&String(activityBurstId)!==''
? activityBurstId
: null;
const cleanAssistant=assistantMsgIdx!==undefined&&assistantMsgIdx!==null&&String(assistantMsgIdx)!==''
? assistantMsgIdx
: null;
const fallbackSeq=_own(event,'seq');
const fallbackKey=fallbackSeq!==undefined&&fallbackSeq!==null&&fallbackSeq!==''?`event:${String(fallbackSeq)}`:`activity:${String(index)}`;
const groupKey=cleanSegment!==null
? `segment:${String(cleanSegment)}`
: cleanBurst!==null
? `burst:${String(cleanBurst)}`
: cleanAssistant!==null
? `assistant:${String(cleanAssistant)}`
: fallbackKey;
return Object.freeze({
group_key:groupKey,
activity_burst_id:cleanBurst,
activity_segment_seq:cleanSegment,
assistant_msg_idx:cleanAssistant,
});
}
function _activityRowThinking(event, kind, text){
if(kind!=='reasoning') return null;
const payload=_own(event,'payload')||{};
const thinkingText=_firstTextValue(
_own(payload,'thinking'),
_own(payload,'reasoning'),
_own(payload,'text'),
text
);
const preview=thinkingText?String(thinkingText).replace(/\s+/g,' ').trim():'';
return Object.freeze({
text:thinkingText||'',
preview:preview.length>180?`${preview.slice(0,177)}...`:preview,
dedupe_key:preview?`thinking:${preview.toLowerCase()}`:'',
});
}
function _activityRowTool(event, kind, status, text, toolCallId){
if(!_isToolActivityKind(kind)) return null;
const payload=_own(event,'payload')||{};
const toolName=_cleanString(
_activityPayloadFirst(payload,['name','tool_name','function_name'])||
(_own(payload,'function')&&_own(_own(payload,'function'),'name'))
)||'tool';
const args=_activityPayloadFirst(payload,['args','arguments','input','params']);
const preview=_firstTextValue(
_own(payload,'preview'),
_own(payload,'summary'),
text
);
const snippet=_firstTextValue(
_own(payload,'snippet'),
_own(payload,'result'),
_own(payload,'output')
);
const done=_activityRowToolDone(kind,status,payload);
const isError=_activityRowToolIsError(status,payload);
const signatureParts=[
toolName,
toolCallId||'',
JSON.stringify(_sanitizePayload(args||{})),
];
return Object.freeze({
id:toolCallId,
name:toolName,
args:_sanitizePayload(args||{}),
preview:preview||'',
snippet:snippet||'',
result:_sanitizePayload(_own(payload,'result'))??null,
output:_sanitizePayload(_own(payload,'output'))??null,
done,
is_error:isError,
duration:_activityPayloadFirst(payload,['duration','duration_seconds','elapsed'])??null,
started_at:_activityPayloadFirst(payload,['started_at','startedAt'])??null,
signature:signatureParts.join('|'),
});
}
function _activityRowRole(kind){
if(kind==='process_prose') return 'prose';
if(kind==='reasoning') return 'thinking';
if(_isToolActivityKind(kind)) return 'tool';
if(kind==='lifecycle_status') return 'lifecycle';
if(kind==='control_boundary') return 'control';
if(kind==='terminal_status') return 'terminal';
return 'activity';
}
function _activityRowDisplayHint(kind, mode){
if(mode==='transparent_stream') return 'chronological_activity';
if(kind==='process_prose') return 'main_prose';
if(kind==='reasoning') return 'collapsed_thinking';
if(_isToolActivityKind(kind)) return 'tool_row';
if(kind==='lifecycle_status') return 'quiet_lifecycle_row';
if(kind==='control_boundary') return 'control_boundary_row';
if(kind==='terminal_status') return 'terminal_status_row';
return 'activity_row';
}
function _activityRowDisplayHints(kind){
return Object.freeze({
compact_worklog:_activityRowDisplayHint(kind,'compact_worklog'),
transparent_stream:_activityRowDisplayHint(kind,'transparent_stream'),
});
}
function _activitySceneRow(event, index, mode){
const payload=_own(event,'payload');
const kind=_cleanString(_own(event,'kind'))||'activity';
const status=_cleanString(_own(event,'status'))||null;
const text=_activityRowText(event);
const toolCallId=_activityRowToolId(event,kind);
const sanitizedPayload=_sanitizePayload(payload);
return Object.freeze({
row_id:_activityRowId(event,index),
order_index:index,
kind,
role:_activityRowRole(kind),
display_hint:_activityRowDisplayHint(kind,mode),
display_hints:_activityRowDisplayHints(kind),
source_event_type:_cleanString(_own(event,'source_event_type'))||null,
event_id:_cleanString(_own(event,'event_id'))||null,
local_id:_cleanString(_own(event,'local_id'))||null,
run_id:_cleanString(_own(event,'run_id'))||null,
stream_id:_cleanString(_own(event,'stream_id'))||null,
seq:_own(event,'seq')??null,
status,
created_at:_own(event,'created_at')??null,
identity:Object.freeze({
event_id:_cleanString(_own(event,'event_id'))||null,
local_id:_cleanString(_own(event,'local_id'))||null,
run_id:_cleanString(_own(event,'run_id'))||null,
stream_id:_cleanString(_own(event,'stream_id'))||null,
seq:_own(event,'seq')??null,
}),
group:_activityRowGroup(event,payload||{},index),
text,
thinking:_activityRowThinking(event,kind,text),
tool_call_id:toolCallId,
tool:_activityRowTool(event,kind,status,text,toolCallId),
payload:sanitizedPayload,
});
}
function projectAssistantTurnAnchorActivityScene(input, options){
const anchor=_anchorFromProjectionInput(input);
const opts=(options&&typeof options==='object')?options:{};
const requestedMode=_cleanString(_own(opts,'mode'));
const mode=requestedMode==='transparent_stream'?'transparent_stream':'compact_worklog';
if(!anchor){
return Object.freeze({
version:'activity_scene_v1',
mode,
identity:Object.freeze({source_message_refs:Object.freeze([])}),
lifecycle:Object.freeze({}),
final_answer:'',
final_message_ref:null,
terminal_state:null,
activity_rows:Object.freeze([]),
});
}
const rows=(Array.isArray(anchor.activity_events)?anchor.activity_events:[])
.map((event,index)=>_activitySceneRow(event,index,mode));
const lifecycle=_copyObject(anchor.lifecycle);
const content=anchor.content&&typeof anchor.content==='object'?anchor.content:{};
return Object.freeze({
version:'activity_scene_v1',
mode,
identity:_frozenIdentityCopy(anchor.identity||{}),
lifecycle:Object.freeze(lifecycle),
final_answer:typeof content.final_answer==='string'?content.final_answer:'',
final_message_ref:typeof content.final_message_ref==='string'?content.final_message_ref:null,
terminal_state:_cleanString(_own(lifecycle,'terminal_state'))||null,
activity_rows:Object.freeze(rows),
});
}
function createAssistantTurnAnchorSeed(input){
const opts=(input&&typeof input==='object')?input:{};
const sessionId=_cleanString(opts.session_id);
@@ -755,7 +1101,7 @@
}
ROOT.HermesAssistantTurnAnchors=Object.freeze({
version:'slice3-registry-shadow',
version:'slice5-activity-scene',
activityEventKinds:ACTIVITY_EVENT_KINDS,
stateLayers:STATE_LAYERS,
sourceEventClassification:SOURCE_EVENT_CLASSIFICATION,
@@ -772,6 +1118,8 @@
applyAssistantTurnAnchorSourceEvent,
applyAssistantTurnAnchorSourceEvents,
createAssistantTurnAnchorShadowSnapshot,
projectAssistantTurnAnchorSettledMessageFinalAnswer,
projectAssistantTurnAnchorActivityScene,
isAssistantTurnAnchorActivityKind,
});
})();

View File

@@ -853,8 +853,48 @@ window._micPendingSend=window._micPendingSend||false;
// a different session's last assistant reply if the user navigated away
// between send and stream completion. (Opus pre-release advisor.)
let _voiceModeThinkingSid=null;
let _browserTtsKeepAlive=null;
let _browserTtsWatchdog=null;
let _browserTtsSuppressNextErrorRearm=false;
const SILENCE_MS=1800; // auto-send after 1.8s silence
function _clearBrowserTtsRecovery(){
if(_browserTtsKeepAlive){
clearInterval(_browserTtsKeepAlive);
_browserTtsKeepAlive=null;
}
if(_browserTtsWatchdog){
clearTimeout(_browserTtsWatchdog);
_browserTtsWatchdog=null;
}
}
function _armBrowserTtsRecovery(clean, rate){
_clearBrowserTtsRecovery();
_browserTtsSuppressNextErrorRearm=false;
const safeRate=(Number.isFinite(rate)&&rate>0)?rate:1;
// Chromium can drop utter.onend on later turns, so force a recovery path.
const watchdogMs=Math.max(4000,Math.round((String(clean||'').length/(12*safeRate))*1000)+10000);
_browserTtsWatchdog=setTimeout(()=>{
if(!_voiceModeActive||_voiceModeState!=='speaking') return;
_browserTtsSuppressNextErrorRearm=true;
try{ speechSynthesis.cancel(); }catch(_){}
_clearBrowserTtsRecovery();
_startListening();
},watchdogMs);
_browserTtsKeepAlive=setInterval(()=>{
if(!_voiceModeActive||_voiceModeState!=='speaking'){
_clearBrowserTtsRecovery();
return;
}
if(!speechSynthesis.speaking) return;
try{
speechSynthesis.pause();
speechSynthesis.resume();
}catch(_){}
},10000);
}
function _setState(state){
_voiceModeState=state;
indicator.className='voice-mode-indicator '+state;
@@ -867,6 +907,7 @@ window._micPendingSend=window._micPendingSend||false;
function _startListening(){
if(!_voiceModeActive) return;
_clearBrowserTtsRecovery();
_setState('listening');
_recognition=new SpeechRecognition();
@@ -1057,14 +1098,27 @@ window._micPendingSend=window._micPendingSend||false;
if(!isNaN(savedPitch)) utter.pitch=Math.min(2,Math.max(0,savedPitch));
utter.onend=()=>{
_browserTtsSuppressNextErrorRearm=false;
_clearBrowserTtsRecovery();
// After speaking, go back to listening
if(_voiceModeActive) setTimeout(()=>_startListening(),500);
if(_voiceModeActive&&_voiceModeState==='speaking') setTimeout(()=>_startListening(),500);
};
utter.onerror=()=>{
_clearBrowserTtsRecovery();
if(_browserTtsSuppressNextErrorRearm){
_browserTtsSuppressNextErrorRearm=false;
return;
}
if(_voiceModeActive) setTimeout(()=>_startListening(),1000);
};
speechSynthesis.speak(utter);
_armBrowserTtsRecovery(clean, utter.rate);
try{
speechSynthesis.speak(utter);
}catch(_){
_clearBrowserTtsRecovery();
if(_voiceModeActive) setTimeout(()=>_startListening(),1000);
}
}
// Hook into response completion — observe when the agent finishes
@@ -1121,10 +1175,12 @@ window._micPendingSend=window._micPendingSend||false;
_voiceModeActive=false;
_voiceModeState='idle';
_voiceModeThinkingSid=null;
_browserTtsSuppressNextErrorRearm=false;
modeBtn.classList.remove('active');
_setButtonTooltip(modeBtn, t('voice_mode_toggle'));
bar.style.display='none';
clearTimeout(_silenceTimer);
_clearBrowserTtsRecovery();
try{ if(_recognition) _recognition.abort(); }catch(_){}
_recognition=null;
if(typeof stopTTS==='function') stopTTS();
@@ -1761,7 +1817,7 @@ function applyBotName(){
// The saved assistant name applies to the default profile only.
// Non-default profiles use their own profile names.
const name=assistantDisplayName();
document.title=name;
if(!S.session) document.title=name;
const sidebarH1=document.querySelector('.sidebar-header h1');
if(sidebarH1) sidebarH1.textContent=name;
const logo=document.querySelector('.sidebar-header .logo');

View File

@@ -878,6 +878,22 @@ async function loadSession(sid){
// close streams for the session the user actually landed on (#1060 guard,
// extended to cover the new pre-switch await).
if (_loadingSessionId !== sid) return;
// Snapshot the live turn before msgInner is replaced. Preserves the activity
// timer, partial response, and tool cards so switching back does not rebuild
// the stream UI from scratch.
if(
(S.busy||S.activeStreamId||(INFLIGHT&&INFLIGHT[currentSid]))&&
typeof snapshotLiveTurnHtmlForSession==='function'
){
if(!INFLIGHT[currentSid]){
INFLIGHT[currentSid]={
messages:Array.isArray(S.messages)?[...S.messages]:[],
uploaded:[],
toolCalls:Array.isArray(S.toolCalls)?[...S.toolCalls]:[],
};
}
snapshotLiveTurnHtmlForSession(currentSid);
}
}
if (currentSid !== sid || forceReload) {
// #3306: When force-reloading the currently-active session (e.g. external
@@ -1040,14 +1056,21 @@ async function loadSession(sid){
if(typeof startSessionStream==='function') startSessionStream(S.session.session_id);
const activeStreamId=S.session.active_stream_id||null;
// If the server says the session is idle, discard any browser-side inflight
// cache left behind by a crashed/restarted stream. Otherwise the UI can keep
// showing a permanent thinking/running state even though active_streams=0.
if(!activeStreamId&&INFLIGHT[sid]){
delete INFLIGHT[sid];
if(typeof clearInflightState==='function') clearInflightState(sid);
// If the server says the session is idle, reset browser-side streaming flags
// NOW — before the async _ensureMessagesLoaded gap below. Without this,
// S.busy can remain true from a still-running stream in the PREVIOUS session
// while S.session.session_id has already advanced to the new one.
// _isSessionLocallyStreaming() checks (isActive && S.busy), so during the
// async window the new session would appear locally-streaming (sidebar spinner,
// Stop button, thinking state on an idle chat). Also clears stale INFLIGHT
// entries left behind by a crashed/restarted stream.
if(!activeStreamId){
S.activeStreamId=null;
S.busy=false;
if(INFLIGHT[sid]){
delete INFLIGHT[sid];
if(typeof clearInflightState==='function') clearInflightState(sid);
}
}
function _mergePendingSessionMessage(session,messages){
@@ -1262,10 +1285,15 @@ async function loadSession(sid){
updateSendBtn();
setStatus('');
setComposerStatus('');
// syncTopbar();renderMessages();appendThinking();loadDir('.');
syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined);
if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell();
else appendThinking();
let restoredLiveTurn=false;
if(typeof restoreLiveTurnHtmlForSession==='function'){
restoredLiveTurn=restoreLiveTurnHtmlForSession(sid);
}
if(!restoredLiveTurn){
if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell();
else appendThinking();
}
loadDir('.');
updateQueueBadge(sid);
startApprovalPolling(sid);

View File

@@ -8747,6 +8747,26 @@ function _renderMessagesWithScrollSnapshot(options){
renderMessages({...(options||{}),preserveScroll:true});
_restoreMessageScrollSnapshotSameFrame(scrollSnapshot);
}
let _assistantTurnAnchorSettledFinalAnswerWarned=false;
function _assistantTurnAnchorSettledFinalAnswer(message, content, context){
try{
const api=(typeof window!=='undefined')?window.HermesAssistantTurnAnchors:null;
if(!api||typeof api.projectAssistantTurnAnchorSettledMessageFinalAnswer!=='function') return null;
const result=api.projectAssistantTurnAnchorSettledMessageFinalAnswer(message,{
session_id:context&&context.session_id,
raw_idx:context&&context.raw_idx,
content,
});
const finalAnswer=result&&typeof result.final_answer==='string'?result.final_answer:'';
return finalAnswer?finalAnswer:null;
}catch(err){
if(!_assistantTurnAnchorSettledFinalAnswerWarned&&typeof console!=='undefined'&&console.warn){
_assistantTurnAnchorSettledFinalAnswerWarned=true;
console.warn('assistant turn anchor settled-final projection failed',err);
}
return null;
}
}
function _scrollAfterMessageRender(preserveScroll, scrollSnapshot){
// Terminal stream renders can happen after S.activeStreamId is cleared.
// In that case, preserveScroll asks the normal pin-state helper to decide:
@@ -9077,6 +9097,13 @@ function renderMessages(options){
if(Array.isArray(content)){
content=content.filter(p=>p&&p.type==='text').map(p=>p.text||p.content||'').join('\n');
}
if(m.role==='assistant'&&!m._live&&typeof content==='string'){
const anchorFinal=_assistantTurnAnchorSettledFinalAnswer(m, content, {
session_id:sid,
raw_idx:rawIdx,
});
if(anchorFinal!==null) content=anchorFinal;
}
if(typeof content==='string'){
if(typeof window!=='undefined'&&typeof window._extractInlineThinkingFromContentForRender==='function'){
const split=window._extractInlineThinkingFromContentForRender(content, thinkingText);

View File

@@ -254,7 +254,10 @@ def test_load_session_reattaches_when_inflight_is_in_memory_and_marked_for_reatt
pins the gate's shape so future refactors don't drop the flag check.
"""
body = _function_body(SESSIONS_JS, "loadSession")
inflight_idx = body.find("if(INFLIGHT[sid]){")
# Anchor on the Phase-2 INFLIGHT restore branch (the later occurrence): #3899
# added an earlier if(INFLIGHT[sid]){ idle-reset block, so .find() would grab
# the wrong one. rfind = the substantive restore branch.
inflight_idx = body.rfind("if(INFLIGHT[sid]){")
assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession"
inflight_block = body[inflight_idx : inflight_idx + 4200]
assert "INFLIGHT[sid].reattach" in inflight_block, (
@@ -283,9 +286,17 @@ def test_load_session_attaches_sse_before_auxiliary_work():
active_branch = body[body.find("if(activeStreamId){") : body.find("}else{", body.find("if(activeStreamId){"))]
active_attach = active_branch.find("attachLiveStream(sid, activeStreamId")
assert active_attach != -1
# #3899 inserted restoreLiveTurnHtmlForSession between renderMessages and
# appendThinking, and renderMessages now takes a preserveScroll arg — so the
# old contiguous "syncTopbar();renderMessages();appendThinking();loadDir('.');"
# literal no longer exists. Assert each auxiliary call individually; all must
# still run AFTER attachLiveStream (the invariant this test protects).
for marker in (
"updateSendBtn();",
"syncTopbar();renderMessages();appendThinking();loadDir('.');",
"syncTopbar();",
"renderMessages(",
"appendThinking();",
"loadDir('.');",
"updateQueueBadge(sid);",
"startApprovalPolling(sid)",
):
@@ -1013,7 +1024,9 @@ def test_load_session_discards_cursor_only_inflight_before_reattach():
guard = "if(activeStreamId&&INFLIGHT[sid]&&!_inflightHasVisibleLiveState(INFLIGHT[sid]))"
assert guard in compact_load
guard_pos = compact_load.find(guard)
inflight_branch_pos = compact_load.find("if(INFLIGHT[sid]){")
# rfind: anchor on the Phase-2 restore branch, not #3899's earlier idle-reset
# if(INFLIGHT[sid]){ block (which now precedes the guard).
inflight_branch_pos = compact_load.rfind("if(INFLIGHT[sid]){")
assert 0 <= guard_pos < inflight_branch_pos

View File

@@ -26,7 +26,10 @@ SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
def _load_session_clear_block() -> str:
"""The if(currentSid!==sid||forceReload){...} block in loadSession()."""
start = SESSIONS_JS.index("async function loadSession(sid)")
return SESSIONS_JS[start: start + 4000]
# Window widened to 6500: #3899's idle-reset + live-turn-snapshot blocks added
# code earlier in loadSession, pushing the carry-forward snapshot past the old
# 4000-char window.
return SESSIONS_JS[start: start + 6500]
def _ensure_messages_loaded_body() -> str:

View File

@@ -0,0 +1,82 @@
from pathlib import Path
import re
REPO = Path(__file__).resolve().parents[1]
def _extract_function(src: str, name: str) -> str:
anchor = f"function {name}("
start = src.find(anchor)
assert start != -1, f"{name}() must exist"
body_start = src.find("{", start)
assert body_start != -1, f"{name}() must have a body"
depth = 1
idx = body_start + 1
while depth and idx < len(src):
if src[idx] == "{":
depth += 1
elif src[idx] == "}":
depth -= 1
idx += 1
assert depth == 0, f"{name}() body must balance braces"
return src[start:idx]
def test_boot_js_declares_browser_tts_recovery_helpers():
src = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "let _browserTtsKeepAlive=null;" in src
assert "let _browserTtsWatchdog=null;" in src
assert "let _browserTtsSuppressNextErrorRearm=false;" in src
assert "function _clearBrowserTtsRecovery()" in src
assert "function _armBrowserTtsRecovery(clean, rate)" in src
def test_browser_tts_watchdog_rearms_listening_if_onend_drops():
src = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
arm_body = _extract_function(src, "_armBrowserTtsRecovery")
assert "_browserTtsWatchdog=setTimeout" in arm_body
assert "_voiceModeState!=='speaking'" in arm_body
assert "_browserTtsSuppressNextErrorRearm=true;" in arm_body
assert "speechSynthesis.cancel()" in arm_body
assert "_startListening();" in arm_body
assert "_browserTtsKeepAlive=setInterval" in arm_body
assert "speechSynthesis.pause();" in arm_body
assert "speechSynthesis.resume();" in arm_body
def test_browser_tts_callbacks_and_deactivate_clear_recovery_handles():
src = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
speak_body = _extract_function(src, "_speakResponse")
assert "const utter=new SpeechSynthesisUtterance(clean);" in speak_body
assert "utter.onend=()=>{" in speak_body
assert "utter.onerror=()=>{" in speak_body
assert speak_body.count("_clearBrowserTtsRecovery();") >= 2, (
"Both browser TTS completion callbacks must clear watchdog/keep-alive handles."
)
assert "_browserTtsSuppressNextErrorRearm=false;" in speak_body
assert "_voiceModeActive&&_voiceModeState==='speaking'" in speak_body
assert "if(_browserTtsSuppressNextErrorRearm){" in speak_body
assert "_armBrowserTtsRecovery(clean, utter.rate);" in speak_body
deactivate_body = _extract_function(src, "_deactivate")
assert "_clearBrowserTtsRecovery();" in deactivate_body, (
"_deactivate() must clear browser TTS watchdog/keep-alive handles."
)
assert "_browserTtsSuppressNextErrorRearm=false;" in deactivate_body
def test_edge_audio_branch_stays_separate():
src = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
edge_match = re.search(
r'if\(engine==="edge"\)\{(.*?)\n\s+return;\n\s+\}',
src,
re.DOTALL,
)
assert edge_match, "Edge audio branch must exist"
edge_body = edge_match.group(1)
assert "const audio = new Audio(url);" in edge_body
assert "audio.onended = () => {" in edge_body
assert "_armBrowserTtsRecovery" not in edge_body, (
"The browser speechSynthesis workaround must not be injected into the Edge audio branch."
)

View File

@@ -0,0 +1,40 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BOOT_JS = ROOT / "static" / "boot.js"
UI_JS = ROOT / "static" / "ui.js"
def _extract_function(src: str, signature: str) -> str:
start = src.find(signature)
assert start != -1, f"{signature} not found"
depth = 0
for idx in range(start, len(src)):
ch = src[idx]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return src[start : idx + 1]
raise AssertionError(f"{signature} body did not terminate")
def test_apply_bot_name_does_not_overwrite_active_session_document_title():
"""Session titles belong to syncTopbar() while a chat session is active."""
src = BOOT_JS.read_text()
body = _extract_function(src, "function applyBotName(){")
assert "if(!S.session) document.title=name;" in body
assert "document.title=name;" not in body.replace(
"if(!S.session) document.title=name;",
"",
)
def test_sync_topbar_remains_session_document_title_owner():
src = UI_JS.read_text()
body = _extract_function(src, "function syncTopbar(){")
assert "document.title=sessionTitle+' \\u2014 '+assistantDisplayName();" in body

View File

@@ -13,6 +13,7 @@ Covers:
4. switch_profile(process_wide=False) does NOT mutate process globals
5. Concurrent requests on different threads see independent profiles
"""
import logging
import os
import threading
from pathlib import Path
@@ -230,6 +231,60 @@ class TestProfileCookieHelpers:
assert get_profile_cookie(handler) is None
# ── 1b. Profile cookie name resolution (env > legacy env > default) ───────────
class TestProfileCookieNameResolution:
def test_default_when_unset(self, monkeypatch):
from api.helpers import PROFILE_COOKIE_NAME, get_profile_cookie_name
monkeypatch.delenv('HERMES_WEBUI_PROFILE_COOKIE_NAME', raising=False)
monkeypatch.delenv('WEBUI_PROFILE_COOKIE_NAME', raising=False)
assert get_profile_cookie_name() == PROFILE_COOKIE_NAME
def test_canonical_env_overrides_default(self, monkeypatch):
from api.helpers import get_profile_cookie_name
monkeypatch.delenv('WEBUI_PROFILE_COOKIE_NAME', raising=False)
monkeypatch.setenv('HERMES_WEBUI_PROFILE_COOKIE_NAME', 'hermes_profile_alt')
assert get_profile_cookie_name() == 'hermes_profile_alt'
def test_legacy_env_still_honoured(self, monkeypatch):
from api.helpers import get_profile_cookie_name
monkeypatch.delenv('HERMES_WEBUI_PROFILE_COOKIE_NAME', raising=False)
monkeypatch.setenv('WEBUI_PROFILE_COOKIE_NAME', 'hermes_profile_legacy')
assert get_profile_cookie_name() == 'hermes_profile_legacy'
def test_canonical_takes_precedence_over_legacy(self, monkeypatch):
from api.helpers import get_profile_cookie_name
monkeypatch.setenv('HERMES_WEBUI_PROFILE_COOKIE_NAME', 'canonical')
monkeypatch.setenv('WEBUI_PROFILE_COOKIE_NAME', 'legacy')
assert get_profile_cookie_name() == 'canonical'
def test_blank_canonical_falls_back_to_legacy(self, monkeypatch):
from api.helpers import get_profile_cookie_name
monkeypatch.setenv('HERMES_WEBUI_PROFILE_COOKIE_NAME', ' ')
monkeypatch.setenv('WEBUI_PROFILE_COOKIE_NAME', 'hermes_profile_legacy')
assert get_profile_cookie_name() == 'hermes_profile_legacy'
def test_blank_envs_fall_back_to_default(self, monkeypatch):
from api.helpers import PROFILE_COOKIE_NAME, get_profile_cookie_name
monkeypatch.setenv('HERMES_WEBUI_PROFILE_COOKIE_NAME', ' ')
monkeypatch.setenv('WEBUI_PROFILE_COOKIE_NAME', '')
assert get_profile_cookie_name() == PROFILE_COOKIE_NAME
def test_legacy_deprecation_warns_only_once(self, monkeypatch, caplog):
# get_profile_cookie_name() runs on every request, so the deprecation
# warning for the legacy env var must be emitted once per process.
import api.helpers as helpers
monkeypatch.delenv('HERMES_WEBUI_PROFILE_COOKIE_NAME', raising=False)
monkeypatch.setenv('WEBUI_PROFILE_COOKIE_NAME', 'hermes_profile_legacy')
monkeypatch.setattr(helpers, '_legacy_profile_cookie_warned', False)
with caplog.at_level(logging.WARNING, logger='api.helpers'):
for _ in range(3):
assert helpers.get_profile_cookie_name() == 'hermes_profile_legacy'
warned = [r for r in caplog.records if 'deprecated' in r.getMessage()]
assert len(warned) == 1
# ── 2. Thread-local request context ──────────────────────────────────────────
class TestThreadLocalProfileContext:

View File

@@ -76,7 +76,9 @@ def test_pre_switch_draft_flush_rechecks_stale_loading_guard():
(Codex pre-release CORE catch, #3471)."""
start = SESSIONS_JS.find("async function loadSession(")
assert start != -1, "loadSession not found"
body = SESSIONS_JS[start:start + 4000]
# Window widened to 6500: #3899's idle-reset + live-turn-snapshot blocks pushed
# the destructive S.messages clear past the old 4000-char window.
body = SESSIONS_JS[start:start + 6500]
await_idx = body.find("await _saveComposerDraftNow(currentSid")
guard_idx = body.find("if (_loadingSessionId !== sid) return;", await_idx)
clear_idx = body.find("S.messages = [];", await_idx)

View File

@@ -426,7 +426,10 @@ def test_loadSession_inflight_restores_live_tool_cards(cleanup_test_sessions):
"""
src = (REPO_ROOT / "static/sessions.js").read_text()
# INFLIGHT branch must call appendLiveToolCard
inflight_idx = src.find("if(INFLIGHT[sid]){")
# Anchor on the Phase-2 INFLIGHT restore branch (the later occurrence); #3899
# added an earlier if(INFLIGHT[sid]){ idle-reset block, so .find() would
# grab the wrong one. (rfind = the substantive restore branch.)
inflight_idx = src.rfind("if(INFLIGHT[sid]){")
assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession"
inflight_block = src[inflight_idx:inflight_idx+4200]
assert "appendLiveToolCard" in inflight_block, "loadSession INFLIGHT branch must restore live tool cards via appendLiveToolCard"
@@ -647,7 +650,10 @@ def test_loadSession_inflight_sets_busy_before_renderMessages(cleanup_test_sessi
session switch.
"""
src = (REPO_ROOT / "static/sessions.js").read_text()
inflight_idx = src.find("if(INFLIGHT[sid]){")
# Anchor on the Phase-2 INFLIGHT restore branch (the later occurrence); #3899
# added an earlier if(INFLIGHT[sid]){ idle-reset block, so .find() would
# grab the wrong one. (rfind = the substantive restore branch.)
inflight_idx = src.rfind("if(INFLIGHT[sid]){")
assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession"
inflight_block = src[inflight_idx:inflight_idx+4200]
busy_pos = inflight_block.find("S.busy=true;")
@@ -662,7 +668,10 @@ def test_loadSession_inflight_sets_busy_before_renderMessages(cleanup_test_sessi
def test_loadSession_inflight_merges_tail_with_persisted_transcript(cleanup_test_sessions):
src = (REPO_ROOT / "static/sessions.js").read_text()
inflight_idx = src.find("if(INFLIGHT[sid]){")
# Anchor on the Phase-2 INFLIGHT restore branch (the later occurrence); #3899
# added an earlier if(INFLIGHT[sid]){ idle-reset block, so .find() would
# grab the wrong one. (rfind = the substantive restore branch.)
inflight_idx = src.rfind("if(INFLIGHT[sid]){")
assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession"
inflight_block = src[inflight_idx:inflight_idx+1200]
@@ -756,7 +765,10 @@ def test_loadSession_inflight_sets_active_stream_before_replaying_live_tool_card
counter drops the previously-seen tools after a focus change.
"""
src = (REPO_ROOT / "static/sessions.js").read_text()
inflight_idx = src.find("if(INFLIGHT[sid]){")
# Anchor on the Phase-2 INFLIGHT restore branch (the later occurrence); #3899
# added an earlier if(INFLIGHT[sid]){ idle-reset block, so .find() would
# grab the wrong one. (rfind = the substantive restore branch.)
inflight_idx = src.rfind("if(INFLIGHT[sid]){")
assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession"
inflight_block = src[inflight_idx:inflight_idx+4200]
active_pos = inflight_block.find("S.activeStreamId=activeStreamId;")

View File

@@ -0,0 +1,87 @@
"""Regression coverage for session-switch busy-state race and live-turn restore.
Switching from a streaming session to an idle one must clear S.busy before the
async _ensureMessagesLoaded gap. Otherwise _isSessionLocallyStreaming() treats
the newly opened session as locally streaming while messages are still loading.
Switching back to a streaming session must restore the snapshotted live turn
instead of rebuilding thinking/worklog chrome from scratch.
"""
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
SESSIONS_SRC = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
UI_SRC = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
def _function_body(src: str, signature: str) -> str:
start = src.find(signature)
assert start != -1, f"missing {signature}"
brace = src.find("{", start)
assert brace != -1, f"missing opening brace for {signature}"
depth = 0
for i in range(brace, len(src)):
ch = src[i]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return src[brace + 1 : i]
raise AssertionError(f"could not extract function body for {signature}")
def test_loadSession_clears_busy_before_async_message_load_when_server_idle():
body = _function_body(SESSIONS_SRC, "async function loadSession(")
idle_reset = body.find("if(!activeStreamId){")
assert idle_reset != -1, "loadSession must gate idle cleanup on missing active_stream_id"
idle_block = body[idle_reset : idle_reset + 500]
assert "S.busy=false" in idle_block, "idle switch must clear S.busy immediately"
assert "S.activeStreamId=null" in idle_block, "idle switch must clear S.activeStreamId immediately"
ensure_load = body.find("await _ensureMessagesLoaded(sid)")
assert ensure_load != -1, "loadSession must still lazy-load messages for idle sessions"
assert idle_reset < ensure_load, (
"S.busy must be cleared before _ensureMessagesLoaded so session-list polling "
"during the async gap does not mark the new session as locally streaming"
)
def test_loadSession_snapshots_live_turn_before_wiping_message_pane():
body = _function_body(SESSIONS_SRC, "async function loadSession(")
snap_pos = body.find("snapshotLiveTurnHtmlForSession(currentSid)")
# Anchor on the actual loading-placeholder marker (unique), not the
# whitespace-sensitive innerHTML literal which also matches the
# "Session not available" error handler. (Maintainer review.)
wipe_pos = body.find("Loading conversation...")
assert snap_pos != -1, "loadSession must snapshot the outgoing live turn before switching"
assert wipe_pos != -1, "loadSession must still show the loading placeholder on switch"
assert snap_pos < wipe_pos, "snapshot must run before msgInner is replaced with the loading placeholder"
def test_loadSession_restores_live_turn_on_active_stream_return_path():
body = _function_body(SESSIONS_SRC, "async function loadSession(")
# The restore that actually fires on switch-back is the Phase 2a path: after
# loadInflightState() rehydrates INFLIGHT for an active stream, the streaming
# branch calls restoreLiveTurnHtmlForSession(sid). (The old Phase-2b idle-branch
# call was unreachable — INFLIGHT is always seeded by then — so assert the live
# Phase 2a path. Maintainer review.)
phase2a = body.find("Phase 2a")
assert phase2a != -1, "loadSession must keep the Phase 2a streaming-restore branch"
inflight_load = body.find("loadInflightState(sid", phase2a)
assert inflight_load != -1, "Phase 2a must rehydrate INFLIGHT from persisted state for an active stream"
restore = body.find("restoreLiveTurnHtmlForSession(sid)", inflight_load)
assert restore != -1, (
"the active-stream return path must restore the snapshotted live-turn HTML "
"after rehydrating INFLIGHT (Phase 2a), instead of rebuilding the worklog shell"
)
def test_activity_timer_reads_pending_started_at():
body = _function_body(UI_SRC, "function _activityElapsedStartedAt(")
assert "pending_started_at" in body
assert "data-turn-started-at" in body or "turnStartedAt" in body

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"] == "slice3-registry-shadow"
assert data["version"] == "slice5-activity-scene"
assert live["classification"] == "activity"
assert live["dedupe_key"] == 'event_id:"run-1:7"'
assert replay["dedupe_key"] == live["dedupe_key"]

View File

@@ -88,8 +88,11 @@ def test_phase0_scaffold_is_loaded_before_current_rendering_modules():
messages_pos = html.index('static/messages.js?v=__WEBUI_VERSION__')
assert anchor_pos < ui_pos < sessions_pos < messages_pos
ui_src = _read(UI_JS)
assert "'./static/assistant_turn_anchors.js' + VQ" in _read(SW_JS)
assert "HermesAssistantTurnAnchors" not in _read(UI_JS)
assert "projectAssistantTurnAnchorSettledMessageFinalAnswer" in ui_src
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)

View File

@@ -111,6 +111,150 @@ console.log(JSON.stringify({{
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def _activity_scene_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-scene',
turn_id:'turn-scene',
}});
api.applyAssistantTurnAnchorSourceEvents(registry, [
{{event:'token', payload:{{text:'progress'}}, event_id:'run-scene:1', seq:1}},
{{event:'reasoning', payload:{{text:'private thinking'}}, event_id:'run-scene:2', seq:2}},
{{event:'tool', payload:{{
tool_call_id:'tool-1',
name:'terminal',
args:{{command:'rg anchor static'}},
preview:'rg anchor static',
activityBurstId:7,
activitySegmentSeq:3,
assistant_msg_idx:12,
started_at:1781200000
}}, event_id:'run-scene:3', seq:3}},
{{event:'tool_update', payload:{{
tool_call_id:'tool-1',
name:'terminal',
text:'running',
preview:'searching workspace',
activityBurstId:7,
activitySegmentSeq:3,
assistant_msg_idx:12
}}, event_id:'run-scene:4', seq:4}},
{{event:'tool_complete', payload:{{
tool_call_id:'tool-1',
name:'terminal',
result:'done',
output:'done',
snippet:'done',
is_error:false,
duration:1.25,
activityBurstId:7,
activitySegmentSeq:3,
assistant_msg_idx:12
}}, event_id:'run-scene:5', seq:5}},
{{event:'done', payload:{{}}, event_id:'run-scene:6', seq:6}},
{{source_type:'settled_message', payload:{{role:'assistant', id:'message-scene', content:'final answer'}}}},
], {{run_id:'run-scene', stream_id:'stream-scene'}});
const compact = api.projectAssistantTurnAnchorActivityScene(registry, {{mode:'compact_worklog'}});
const transparent = api.projectAssistantTurnAnchorActivityScene(registry.anchor, {{mode:'transparent_stream'}});
const empty = api.projectAssistantTurnAnchorActivityScene(null, {{mode:'transparent_stream'}});
const seqlessRegistry = api.createAssistantTurnAnchorRegistry({{
session_id:'sid-seqless',
turn_id:'turn-seqless',
}});
api.applyAssistantTurnAnchorSourceEvents(seqlessRegistry, [
{{event:'tool', payload:{{tool_call_id:'tool-same', name:'terminal'}}}},
{{event:'tool_update', payload:{{tool_call_id:'tool-same', text:'running'}}}},
{{event:'tool_complete', payload:{{tool_call_id:'tool-same', result:'done'}}}},
]);
const seqless = api.projectAssistantTurnAnchorActivityScene(seqlessRegistry, {{mode:'compact_worklog'}});
const zeroRegistry = api.createAssistantTurnAnchorRegistry({{
session_id:'sid-zero',
turn_id:'turn-zero',
}});
api.applyAssistantTurnAnchorSourceEvents(zeroRegistry, [
{{event:'tool', payload:{{
tool_call_id:'tool-zero',
name:'terminal',
activityBurstId:0,
activitySegmentSeq:0,
assistant_msg_idx:0
}}, event_id:'run-zero:0', seq:0}},
]);
const zero = api.projectAssistantTurnAnchorActivityScene(zeroRegistry, {{mode:'compact_worklog'}});
console.log(JSON.stringify({{
version:api.version,
compact,
transparent,
empty,
seqless,
zero,
}}));
"""
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"""
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 projected = api.projectAssistantTurnAnchorSettledMessageFinalAnswer({{
role:'assistant',
id:'message-final',
content:'raw content should be replaced by render-preserved content',
_turnUsage:{{input_tokens:8, output_tokens:13}},
}}, {{
session_id:'sid-project',
raw_idx:7,
content:'line one\\nline two',
}});
const projectedByRawIdx = api.projectAssistantTurnAnchorSettledMessageFinalAnswer({{
role:'assistant',
content:'message without id',
}}, {{
session_id:'sid-project',
raw_idx:11,
content:'raw index final',
}});
const missingSession = api.projectAssistantTurnAnchorSettledMessageFinalAnswer({{
role:'assistant',
id:'message-missing-session',
content:'final',
}}, {{}});
const nonAssistant = api.projectAssistantTurnAnchorSettledMessageFinalAnswer({{
role:'user',
id:'message-user',
content:'user text',
}}, {{
session_id:'sid-project',
}});
console.log(JSON.stringify({{
version:api.version,
projected,
projectedByRawIdx,
missingSession,
nonAssistant,
}}));
"""
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 _hardening_snapshot() -> dict:
assert NODE, "node is required for assistant_turn_anchors.js registry tests"
@@ -289,7 +433,7 @@ def test_registry_owns_one_anchor_and_dedupes_live_plus_replay_events():
registry = data["registry"]
anchor = registry["anchor"]
assert data["version"] == "slice3-registry-shadow"
assert data["version"] == "slice5-activity-scene"
assert [item["reason"] for item in data["results"][:2]] == [None, "duplicate"]
assert registry["event_index"]["dedupe_keys"][:2] == [
'event_id:"run-1:1"',
@@ -389,7 +533,7 @@ def test_registry_does_not_destructively_dedupe_seqless_local_tool_lifecycle():
registry = data["toolRegistry"]
anchor = registry["anchor"]
assert data["version"] == "slice3-registry-shadow"
assert data["version"] == "slice5-activity-scene"
assert data["toolResults"] == [
{"applied": True, "reason": None},
{"applied": True, "reason": None},
@@ -439,7 +583,7 @@ def test_shadow_snapshot_feeds_current_source_families_into_one_registry_owner()
registry = data["registry"]
anchor = registry["anchor"]
assert data["version"] == "slice3-registry-shadow"
assert data["version"] == "slice5-activity-scene"
assert data["results"]["live"] == [{"applied": True, "reason": None}]
assert data["results"]["replay"] == [
{"applied": False, "reason": "duplicate"},
@@ -462,6 +606,195 @@ def test_shadow_snapshot_feeds_current_source_families_into_one_registry_owner()
]
assert anchor["content"]["final_answer"] == "shadow final"
def test_activity_scene_projects_current_activity_events_for_both_render_modes():
data = _activity_scene_snapshot()
compact = data["compact"]
transparent = data["transparent"]
assert data["version"] == "slice5-activity-scene"
assert compact["version"] == "activity_scene_v1"
assert transparent["version"] == "activity_scene_v1"
assert compact["mode"] == "compact_worklog"
assert transparent["mode"] == "transparent_stream"
assert compact["final_answer"] == "final answer"
assert transparent["final_answer"] == "final answer"
assert compact["terminal_state"] == "completed"
assert transparent["terminal_state"] == "completed"
compact_rows = compact["activity_rows"]
transparent_rows = transparent["activity_rows"]
assert [row["row_id"] for row in compact_rows] == [
"run-scene:1",
"run-scene:2",
"run-scene:3",
"run-scene:4",
"run-scene:5",
"run-scene:6",
]
assert [row["row_id"] for row in transparent_rows] == [
row["row_id"] for row in compact_rows
]
assert [row["kind"] for row in compact_rows] == [
"process_prose",
"reasoning",
"tool_started",
"tool_updated",
"tool_completed",
"terminal_status",
]
assert [row["role"] for row in compact_rows] == [
"prose",
"thinking",
"tool",
"tool",
"tool",
"terminal",
]
assert [row["display_hint"] for row in compact_rows] == [
"main_prose",
"collapsed_thinking",
"tool_row",
"tool_row",
"tool_row",
"terminal_status_row",
]
assert all(row["display_hint"] == "chronological_activity" for row in transparent_rows)
assert compact_rows[0]["text"] == "progress"
assert compact_rows[0]["tool_call_id"] is None
assert compact_rows[2]["tool_call_id"] == "tool-1"
assert compact_rows[4]["text"] == "done"
assert compact_rows[1]["thinking"] == {
"text": "private thinking",
"preview": "private thinking",
"dedupe_key": "thinking:private thinking",
}
assert compact_rows[2]["group"] == {
"group_key": "segment:3",
"activity_burst_id": 7,
"activity_segment_seq": 3,
"assistant_msg_idx": 12,
}
assert compact_rows[2]["tool"] == {
"id": "tool-1",
"name": "terminal",
"args": {"command": "rg anchor static"},
"preview": "rg anchor static",
"snippet": "",
"result": None,
"output": None,
"done": False,
"is_error": False,
"duration": None,
"started_at": 1781200000,
"signature": 'terminal|tool-1|{"command":"rg anchor static"}',
}
assert compact_rows[4]["tool"]["done"] is True
assert compact_rows[4]["tool"]["is_error"] is False
assert compact_rows[4]["tool"]["duration"] == 1.25
assert compact_rows[4]["tool"]["snippet"] == "done"
assert compact_rows[4]["display_hints"] == {
"compact_worklog": "tool_row",
"transparent_stream": "chronological_activity",
}
seqless_ids = [row["row_id"] for row in data["seqless"]["activity_rows"]]
assert len(seqless_ids) == len(set(seqless_ids))
assert seqless_ids == [
"tool-same:tool:0",
"tool-same:tool_update:1",
"tool-same:tool_complete:2",
]
assert data["zero"]["activity_rows"][0]["group"] == {
"group_key": "segment:0",
"activity_burst_id": 0,
"activity_segment_seq": 0,
"assistant_msg_idx": 0,
}
def test_activity_scene_is_renderer_neutral_and_empty_safe():
data = _activity_scene_snapshot()
compact = data["compact"]
empty = data["empty"]
assert "final answer" not in [row["text"] for row in compact["activity_rows"]]
assert compact["identity"]["session_id"] == "sid-scene"
assert compact["identity"]["run_id"] == "run-scene"
assert compact["identity"]["stream_id"] == "stream-scene"
assert empty == {
"version": "activity_scene_v1",
"mode": "transparent_stream",
"identity": {"source_message_refs": []},
"lifecycle": {},
"final_answer": "",
"final_message_ref": None,
"terminal_state": None,
"activity_rows": [],
}
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 projected["applied"] is True
assert projected["reason"] is None
assert projected["final_message_ref"] == "message-final"
assert projected["final_answer"] == "line one\nline two"
assert registry["stats"]["applied"] == 1
assert anchor["identity"]["session_id"] == "sid-project"
assert anchor["content"]["final_answer"] == "line one\nline two"
assert anchor["content"]["final_message_ref"] == "message-final"
assert anchor["usage"] == {"input_tokens": 8, "output_tokens": 13}
assert [event["source_event_type"] for event in anchor["metadata_events"]] == [
"settled_message",
]
assert data["projectedByRawIdx"]["final_message_ref"] == "raw_idx:11"
assert data["projectedByRawIdx"]["registry"]["anchor"]["identity"][
"source_message_refs"
] == ["raw_idx:11"]
anchor_src = _read(ANCHORS_JS)
assert "if(!result.applied)" in anchor_src
def test_final_projection_is_scoped_to_settled_assistant_messages():
data = _final_projection_snapshot()
assert data["missingSession"] == {
"applied": False,
"reason": "missing_session",
"final_answer": "",
"final_message_ref": None,
"registry": None,
}
assert data["nonAssistant"] == {
"applied": False,
"reason": "non_assistant",
"final_answer": "",
"final_message_ref": None,
"registry": None,
}
def test_render_messages_uses_anchor_projection_only_for_settled_final_prose():
src = _read(UI_JS)
start = src.index("function renderMessages")
end = src.index("function _toolDisplayName", start)
render_body = src[start:end]
flatten_idx = render_body.index("content=content.filter(p=>p&&p.type==='text')")
projection_idx = render_body.index("_assistantTurnAnchorSettledFinalAnswer(m, content")
thinking_idx = render_body.index("_extractInlineThinkingFromContentForRender(content")
assert flatten_idx < projection_idx < thinking_idx
assert "if(m.role==='assistant'&&!m._live&&typeof content==='string'){" in render_body
assert "createAssistantTurnAnchorRegistry" not in render_body
assert "applyAssistantTurnAnchorSourceEvent" not in render_body
assert "_assistantTurnAnchorSettledFinalAnswerWarned" in src
assert "console.warn('assistant turn anchor settled-final projection failed',err)" in src
def test_registry_instances_do_not_share_owner_state():
data = _registry_snapshot()
@@ -472,8 +805,7 @@ def test_registry_instances_do_not_share_owner_state():
assert isolated["stats"]["applied"] == 0
assert isolated["anchor"]["activity_events"] == []
def test_slice3_registry_is_still_unwired_from_rendering_hot_paths():
def test_slice5_scene_projection_does_not_wire_activity_scene_into_rendering_hot_paths():
helper_names = [
"createAssistantTurnAnchorRegistry",
"applyAssistantTurnAnchorNormalizedEvent",
@@ -485,3 +817,7 @@ def test_slice3_registry_is_still_unwired_from_rendering_hot_paths():
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)

View File

@@ -398,13 +398,19 @@ def test_stale_stream_cleanup_does_not_clobber_concurrent_chat_start(monkeypatch
def test_frontend_drops_inflight_cache_when_server_session_is_idle():
marker = "If the server says the session is idle, discard any browser-side inflight"
# #3900/#3899 generalized this block: on an idle server session it now resets
# the streaming flags (S.busy/S.activeStreamId) AND drops the inflight cache,
# before the async message-load gap. Anchor on the current comment + assert the
# (preserved) cache-drop behavior in the now-nested form.
marker = "If the server says the session is idle, reset browser-side streaming flags"
marker_pos = SESSIONS_SRC.index(marker)
window = SESSIONS_SRC[marker_pos:marker_pos + 500]
assert "if(!activeStreamId&&INFLIGHT[sid])" in window
window = SESSIONS_SRC[marker_pos:marker_pos + 900]
assert "if(!activeStreamId){" in window
assert "S.busy=false" in window
assert "S.activeStreamId=null" in window
assert "if(INFLIGHT[sid]){" in window
assert "delete INFLIGHT[sid]" in window
assert "clearInflightState" in window
assert "S.busy=false" in window
def test_service_worker_cache_bumped_for_frontend_fix_delivery():