Merge remote-tracking branch 'origin/master' into HEAD
This commit is contained in:
commit
e510219e16
21 files changed
+1460
-14
No files matched your search
@@ -5,10 +5,20 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Two WebUI sessions with the same auto-generated title no longer leave the second one blank.** Follow-up to the `state.db` title sync: hermes-agent enforces a uniqueness rule on session titles, so when two sessions independently generated a byte-identical auto-title, the second sync raised (and silently swallowed) a collision error and left that session untitled in `hermes sessions list`. The sync now catches the collision, derives a de-duplicated variant via the existing lineage helper (`"My Session"` → `"My Session #2"`), and retries — and because the retry goes through the same provenance guard, a title you set manually is still never overwritten. Thanks @webtecnica. (#6964)
|
||||
- **Custom-provider models keep their exact identity across catalog refreshes.** When a session used a model served through a custom provider whose upstream id contains a namespace (e.g. `z-ai/glm-5.2` under a `tokenrouter` custom endpoint), a catalog refresh could mis-resolve the picker selection — matching a *different* model whose bare suffix collided, or (in an interim fix) fabricating a new bare-id option instead of re-selecting the real one. Model resolution now matches the exact routed id first, and only falls back to a provider-hinted bare-id repair (preserving the older #6195 behavior for legacy sessions that stored just the suffix) when there is exactly one unambiguous candidate — otherwise it declines to guess. The resolved value is always an existing dropdown option, so re-renders no longer inject duplicates or lose the selection. Thanks @josephcy95. (#6944)
|
||||
- **WebUI no longer crashes a send when running against an older hermes-agent.** The streaming path passed `persist_user_timestamp=` to `agent.run_conversation()` unconditionally, so a WebUI paired with an older agent whose `run_conversation()` predates that keyword raised `TypeError: unexpected keyword argument` and the message failed. The three call sites now check the callable's signature first (via a small `_supports_kwarg` introspection helper) and only pass the timestamp when the agent accepts it — the same defensive pattern already used for `moa_config`. Modern agents are unaffected (the timestamp is still passed with the identical value); older agents degrade gracefully instead of erroring. Thanks @harsh2hell. (#6935)
|
||||
- **`hermes sessions list` no longer shows blank titles for WebUI sessions.** Background title generation wrote the auto-title to the WebUI session sidecar but never to hermes-agent's `state.db`, so the CLI/TUI session list showed those WebUI sessions untitled. The title is now bridged into `state.db` after it's generated (and on adaptive refresh) via `set_auto_title_if_empty`, which respects title provenance — a name you set manually via CLI/Gateway/TUI (`user` source) is never overwritten by the auto-title, an already-auto-titled row stays put, and an existing session's `source` is preserved (the sync creates the row with INSERT-OR-IGNORE semantics). Thanks @liuguangyong93. (#6892)
|
||||
- **No more phantom "Compressing context" barrier after switching sessions.** The compression UI state is per-session, but `loadSession()` did not clear it on a switch, so a compression card from a prior session could leak across the load and appear as a phantom "Compressing context" barrier on a fresh session that never triggered compression. Switching sessions now clears the compression UI (state, elapsed timer, composer session-lock, and barrier element) alongside the other per-session resets, before the transcript loads. A compression genuinely running on the session you switch *to* is unaffected — it re-surfaces from that session's own live event stream (and manual compression from its server job status). Thanks @happy5318. (#6938)
|
||||
|
||||
- **Layout test helper no longer flags scroll-reachable fields as off-viewport.** The shared `assert_layout_sane` degenerate check reported a false "interactive element is off-viewport" for an input that sits below the fold at rest but is reachable by scrolling an `overflow-y:auto` ancestor (e.g. a tall dialog on a short landscape viewport). It now exempts elements whose off-viewport edge is absorbed by a scrollable ancestor, while still flagging genuinely-unreachable elements (no scroll escape). The Kanban board settings modal layout test also drops its synthetic 480×320 short-landscape case (a viewport no real device uses, whose sub-fold geometry was environment-marginal and CI-flaky); real desktop/tablet/narrow viewports still assert the layout. (internal test infra)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Slash-command autocomplete now searches skills by keyword.** Typing `/` and a term now matches plain skills on a case-insensitive keyword in their **name or description** (previously only a name prefix), so a skill is discoverable by what it does, not just its exact slug. Built-in, agent/plugin, and bundle commands keep prefix matching and always take precedence over a same-slug skill, and skill suggestions are held until the bundle-metadata request settles so a slow response can't briefly surface a shadowed skill. Thanks @MinhoJJang. (#6932)
|
||||
|
||||
- **Extensions can observe turn lifecycle events via `handle.events.on(...)`.** A registered extension (see `window.hermesExt.register`) can now subscribe to session turn-lifecycle events — `turn:start`, `turn:complete`, `turn:error`, and `turn:cancel` — through a frozen `events` accessor on its handle, each delivering a frozen scalar event (`sessionId`, `streamId`, `status`, and start/end timestamps; no message content). Core owns the authoritative live-session stream and emits each event exactly once per real transition (a per-session/stream state machine suppresses duplicate starts and post-terminal events, including on reconnect/replay), with per-listener error isolation so a misbehaving extension cannot break streaming. Subscriptions return an idempotent unsubscribe. This is the B1 observer surface on top of the E0 identity handle — a stable page-local facade instead of extensions polling private DOM state. Thanks @franksong2702. (#6924)
|
||||
|
||||
- **Extensions can claim a cooperative page identity via `window.hermesExt.register(id)`.** An injected extension can now claim a stable browser-page identity together with its existing settings and storage accessors: `register(id)` returns a frozen `{id, settings, storage}` handle for an ID present in the effective-enabled, Core-sanitized manifest inventory captured at page boot, and returns `null` (fail-closed, no namespace side effects) for empty, malformed, unknown, or untrusted IDs. Re-registering the same ID in one page returns the same handle; boot-time trust is immutable across status refreshes (a refresh cannot elevate an ID absent at boot). This is cooperative attribution for extensions sharing the page — explicitly not a sandbox, capability, permission, isolation mechanism, or security boundary. Thanks @franksong2702. (#6795)
|
||||
|
||||
- **Refine selected transcript text directly in the composer.** Selecting text inside a chat message now shows a "Refine" action alongside "Reply with selection" in the floating selection toolbar. Refine seeds the selected text into the composer as an editable draft (a quoted block plus a localized "Refine instruction:" line, caret ready) so you can shape a follow-up before sending — the ChatGPT/Notion-style quote-to-edit flow. The transfer is one-way and marker-free (no internal sentinel reaches the composer or the sent message), preserves any existing draft and attachments, collapses to 44×44 icon-only buttons on phones, and covers all 15 locales. Thanks @rodboev. (#6410)
|
||||
|
||||
@@ -294,6 +294,7 @@ If an AI assistant is helping with install, reinstall, bootstrap, provider setup
|
||||
|
||||
### Slash commands
|
||||
- Type `/` in the composer for autocomplete dropdown
|
||||
- Plain skills match case-insensitive keywords in their name or description; built-in, agent/plugin, and bundle commands keep prefix matching and take precedence over a same-slug skill
|
||||
- Built-in: `/help`, `/clear`, `/compress [focus topic]`, `/compact` (alias), `/model <name>`, `/workspace <name>`, `/new`, `/usage`, `/theme`
|
||||
- Arrow keys navigate, Tab/Enter select, Escape closes
|
||||
- Unrecognized commands pass through to the agent
|
||||
|
||||
@@ -195,3 +195,48 @@ def sync_session_usage(session_id: str, input_tokens: int=0, output_tokens: int=
|
||||
db.close()
|
||||
except Exception:
|
||||
logger.debug("Failed to close state.db")
|
||||
|
||||
|
||||
def sync_session_title(session_id: str, title: str, profile: Optional[str] = None) -> None:
|
||||
"""Sync an auto-generated title to state.db (not gated by sync_to_insights).
|
||||
|
||||
Background title generation writes the title to the WebUI sidecar JSON but
|
||||
not to hermes-agent's state.db, so ``hermes sessions list`` shows blank
|
||||
titles for WebUI sessions. This function bridges that gap and is called
|
||||
from the background title update/refresh paths after a title is persisted.
|
||||
|
||||
Uses ``set_auto_title_if_empty`` so it will only populate a NULL title and
|
||||
never overwrite a manual rename made via CLI/Gateway/TUI. This means
|
||||
title refreshes (where state.db already holds the initial auto-title) are
|
||||
effectively no-ops at the state.db layer -- acceptable because the primary
|
||||
goal is ensuring ``hermes sessions list`` is not blank.
|
||||
|
||||
On a title collision (two sessions with the same auto-title), the title is
|
||||
de-duplicated via ``get_next_title_in_lineage`` (e.g. "My Session" ->
|
||||
"My Session #2") and retried, so the second session is never left blank.
|
||||
"""
|
||||
if not title:
|
||||
return
|
||||
db = _get_state_db(profile=profile)
|
||||
if not db:
|
||||
return
|
||||
try:
|
||||
# Ensure the session row exists (idempotent) so the UPDATE has a target.
|
||||
db.ensure_session(session_id=session_id, source='webui')
|
||||
try:
|
||||
db.set_auto_title_if_empty(session_id, title)
|
||||
except ValueError:
|
||||
# state.db enforces uniqueness on sessions.title, so a byte-identical
|
||||
# auto-title generated for two sessions raises ValueError here. Derive
|
||||
# a de-duplicated variant (e.g. "My Session" -> "My Session #2") and
|
||||
# retry instead of leaving the second row blank (#6964).
|
||||
alt = db.get_next_title_in_lineage(title)
|
||||
if alt and alt != title:
|
||||
db.set_auto_title_if_empty(session_id, alt)
|
||||
except Exception:
|
||||
logger.debug("Failed to sync session title to state.db for %s", session_id)
|
||||
finally:
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
logger.debug("Failed to close state.db")
|
||||
+32
-3
@@ -21,6 +21,7 @@ import threading
|
||||
import time
|
||||
import traceback
|
||||
import copy
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -307,6 +308,19 @@ def _install_streaming_cronjob_profile_wrapper() -> None:
|
||||
_STREAMING_CRONJOB_WRAPPER_INSTALLED = True
|
||||
|
||||
|
||||
def _supports_kwarg(func, kwarg_name: str) -> bool:
|
||||
"""Return True if callable `func` accepts `kwarg_name` (explicitly or via **kwargs)."""
|
||||
try:
|
||||
sig = inspect.signature(func)
|
||||
for param in sig.parameters.values():
|
||||
if param.kind == param.VAR_KEYWORD or param.name == kwarg_name:
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
|
||||
_PERSISTENT_MEMORY_FILES = (
|
||||
("memory", ("memories", "MEMORY.md")),
|
||||
("user", ("memories", "USER.md")),
|
||||
@@ -4466,6 +4480,12 @@ def _run_background_title_update(session_id: str, user_text: str, assistant_text
|
||||
else:
|
||||
_put_title_status(put_event, session_id, source, llm_status, effective_title, raw_preview)
|
||||
put_event('title', {'session_id': session_id, 'title': effective_title})
|
||||
# Sync the generated title to state.db so `hermes sessions list` shows it.
|
||||
try:
|
||||
from api.state_sync import sync_session_title
|
||||
sync_session_title(session_id, effective_title, profile=getattr(s, 'profile', None) or 'default')
|
||||
except Exception:
|
||||
logger.debug("Failed to sync title to state.db after generation for %s", session_id)
|
||||
else:
|
||||
_put_title_status(put_event, session_id, 'skipped', source or 'unchanged', effective_title, raw_preview)
|
||||
finally:
|
||||
@@ -4538,6 +4558,12 @@ def _run_background_title_refresh(session_id: str, user_text: str, assistant_tex
|
||||
s.save(touch_updated_at=False)
|
||||
_put_title_status(put_event, session_id, 'refreshed', llm_status, effective_title, raw_preview)
|
||||
put_event('title', {'session_id': session_id, 'title': effective_title})
|
||||
# Sync the refreshed title to state.db so `hermes sessions list` stays current.
|
||||
try:
|
||||
from api.state_sync import sync_session_title
|
||||
sync_session_title(session_id, effective_title, profile=getattr(s, 'profile', None) or 'default')
|
||||
except Exception:
|
||||
logger.debug("Failed to sync refreshed title to state.db for %s", session_id)
|
||||
logger.info("Adaptive title refresh: session=%s new_title=%r", session_id, effective_title)
|
||||
except Exception:
|
||||
logger.debug("Background title refresh failed for session %s", session_id, exc_info=True)
|
||||
@@ -9884,8 +9910,9 @@ def _run_agent_streaming(
|
||||
),
|
||||
task_id=session_id,
|
||||
persist_user_message=msg_text,
|
||||
persist_user_timestamp=getattr(s, 'pending_started_at', None),
|
||||
)
|
||||
if _supports_kwarg(agent.run_conversation, 'persist_user_timestamp'):
|
||||
_run_conversation_kwargs['persist_user_timestamp'] = getattr(s, 'pending_started_at', None)
|
||||
# Only pass moa_config when a /moa override is actually active, so a
|
||||
# normal send never trips a TypeError on an older hermes-agent whose
|
||||
# run_conversation() predates the moa_config kwarg.
|
||||
@@ -10387,8 +10414,9 @@ def _run_agent_streaming(
|
||||
),
|
||||
task_id=session_id,
|
||||
persist_user_message=msg_text,
|
||||
persist_user_timestamp=getattr(s, 'pending_started_at', None),
|
||||
)
|
||||
if _supports_kwarg(agent.run_conversation, 'persist_user_timestamp'):
|
||||
_heal_kwargs['persist_user_timestamp'] = getattr(s, 'pending_started_at', None)
|
||||
if moa_config is not None:
|
||||
_heal_kwargs["moa_config"] = moa_config
|
||||
_heal_result = agent.run_conversation(**_heal_kwargs)
|
||||
@@ -11627,8 +11655,9 @@ def _run_agent_streaming(
|
||||
),
|
||||
task_id=session_id,
|
||||
persist_user_message=msg_text,
|
||||
persist_user_timestamp=getattr(s, 'pending_started_at', None),
|
||||
)
|
||||
if _supports_kwarg(_heal_agent.run_conversation, 'persist_user_timestamp'):
|
||||
_heal_kwargs2['persist_user_timestamp'] = getattr(s, 'pending_started_at', None)
|
||||
if moa_config is not None:
|
||||
_heal_kwargs2["moa_config"] = moa_config
|
||||
_heal_result = _heal_agent.run_conversation(**_heal_kwargs2)
|
||||
|
||||
+50
-2
@@ -289,8 +289,8 @@ Core-sanitized manifest inventory that was present at the initial page boot. It
|
||||
returns `null` for empty, malformed, unknown, or untrusted IDs without creating
|
||||
settings or storage state. Re-registering the same ID in one page returns the
|
||||
same handle object; different IDs receive different handles. The handle exposes
|
||||
only the canonical `id`, `settings`, and `storage` fields, backed by the same
|
||||
factories used by the legacy accessors.
|
||||
only the canonical `id`, `settings`, `storage`, and `events` fields. Settings and
|
||||
storage are backed by the same factories used by the legacy accessors.
|
||||
|
||||
Manifest enable/disable changes still take effect after a WebUI reload. A handle
|
||||
already created in the current page may remain cached across a status refresh;
|
||||
@@ -300,6 +300,54 @@ sandbox, isolation mechanism, capability or permission system, or security
|
||||
boundary. Extensions still execute with the full WebUI session authority
|
||||
described above.
|
||||
|
||||
### Turn lifecycle events
|
||||
|
||||
A registered extension can react when a session turn observed by the current page
|
||||
starts or reaches a terminal state:
|
||||
|
||||
```js
|
||||
const ext = window.hermesExt.register("desktop-companion");
|
||||
const stop = ext && ext.events.on("turn:complete", event => {
|
||||
console.log(event.sessionId, event.streamId, event.status);
|
||||
});
|
||||
|
||||
// Remove the listener when the extension no longer needs it.
|
||||
if (stop) stop();
|
||||
```
|
||||
|
||||
The supported event types are `turn:start`, `turn:complete`, `turn:error`, and
|
||||
`turn:cancel`. The frozen event object always contains `type`, `sessionId`,
|
||||
`streamId`, and `timestamp` (Unix seconds). Start events also contain
|
||||
`startedAt`; terminal events contain `endedAt` and may contain `status`.
|
||||
`events.on(type, handler)` returns an idempotent unsubscribe function, or `null`
|
||||
for an unsupported type or non-function handler. An exception in one extension
|
||||
listener is logged and does not prevent other listeners or the chat UI from
|
||||
continuing.
|
||||
|
||||
The version-1 mapping is deliberately small: attaching a confirmed live session
|
||||
stream emits `turn:start`; SSE `done` emits `turn:complete`; SSE `cancel` and
|
||||
application errors classified as `cancelled` or `interrupted` emit
|
||||
`turn:cancel`; other application errors and an unrecoverable live-stream
|
||||
connection failure emit `turn:error`. Transport-only `stream_end` does not emit
|
||||
a second terminal event.
|
||||
|
||||
Core suppresses reconnect duplicates so a recently observed
|
||||
`sessionId`/`streamId` pair produces at most one start event and one terminal
|
||||
event. `sessionId` is the owner of the original stream; server-side compression
|
||||
or session rotation at completion does not change that lifecycle identity.
|
||||
Terminal callbacks run after Core has cleared the original stream ownership,
|
||||
applied the current session's immediate terminal transcript projection, and
|
||||
returned the owned pane to its idle state. They do not wait for unrelated
|
||||
follow-up work such as notifications, queued turns, or optional recovery
|
||||
refreshes.
|
||||
|
||||
This is a page-local, live-stream notification surface. It does not replay
|
||||
history, report turns that finish without this page observing their stream, or
|
||||
provide token, tool, approval, or metrics events. It follows the same cooperative
|
||||
trust model as `register(id)` and adds no sandbox or permission boundary. A
|
||||
durable or global agent event stream is a separate Core contract. Ephemeral
|
||||
`/btw` answer streams are not included in this version.
|
||||
|
||||
## URL rules
|
||||
|
||||
Injected asset URLs are deliberately restricted:
|
||||
|
||||
+12
-4
@@ -187,6 +187,7 @@ function getMatchingCommands(prefix){
|
||||
const matches=COMMANDS.filter(c=>c.name.startsWith(q)).map(c=>({...c,source:'builtin'}));
|
||||
const seen=new Set(matches.map(c=>c.name));
|
||||
const reserved=_getReservedSlashCommandSlugs();
|
||||
const bundleSlugs=new Set(_bundleCommandCache.map(bundle=>bundle.name));
|
||||
for(const [name, spec] of Object.entries(SLASH_SUBARG_SOURCES)){
|
||||
if(!name.startsWith(q)||seen.has(name))continue;
|
||||
matches.push({
|
||||
@@ -227,10 +228,16 @@ function getMatchingCommands(prefix){
|
||||
seen.add(bundle.name);
|
||||
}
|
||||
}
|
||||
// A same-slug bundle owns dispatch. Hold plain skills until the independent
|
||||
// bundle metadata request settles so a slow bundle response cannot briefly
|
||||
// expose a selectable, shadowed skill.
|
||||
if(!_bundleCommandCacheReady)return matches;
|
||||
for(const skill of _skillCommandCache){
|
||||
if(!skill.name.startsWith(q)||seen.has(skill.name)||reserved.has(skill.name))continue;
|
||||
const name=String(skill&&skill.name||'').toLowerCase();
|
||||
const description=String(skill&&skill.desc||'').toLowerCase();
|
||||
if((!name.includes(q)&&!description.includes(q))||seen.has(name)||reserved.has(name)||bundleSlugs.has(name))continue;
|
||||
matches.push(skill);
|
||||
seen.add(skill.name);
|
||||
seen.add(name);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
@@ -2086,8 +2093,9 @@ function refreshSlashCommandDropdown(){
|
||||
});
|
||||
}
|
||||
function ensureSkillCommandsLoadedForAutocomplete(){
|
||||
if(_skillCommandCacheReady||_skillCommandLoadPromise)return;
|
||||
loadSkillCommands().then(()=>{refreshSlashCommandDropdown();});
|
||||
if(!_skillCommandCacheReady&&!_skillCommandLoadPromise){
|
||||
loadSkillCommands().then(()=>{refreshSlashCommandDropdown();});
|
||||
}
|
||||
if(!_bundleCommandCacheReady&&!_bundleCommandLoadPromise){
|
||||
loadBundleCommands().then(()=>{refreshSlashCommandDropdown();});
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
const SETTINGS_PREFIX='hermes.ext.settings.';
|
||||
const STORAGE_PREFIX='hermes.ext.storage.';
|
||||
const FIELD_TYPES=new Set(['boolean','string','number','integer','enum']);
|
||||
const TURN_LIFECYCLE_TYPES=new Set(['turn:start','turn:complete','turn:error','turn:cancel']);
|
||||
const TURN_LIFECYCLE_STATE_LIMIT=512;
|
||||
const schemas=new Map();
|
||||
const trustedExtensions=new Map();
|
||||
const registrations=new Map();
|
||||
const turnLifecycleListeners=new Map();
|
||||
const turnLifecycleStates=new Map();
|
||||
let trustedSeeded=false;
|
||||
|
||||
function extensionId(value){
|
||||
@@ -318,6 +322,90 @@
|
||||
return storageAccessor(clean,meta);
|
||||
}
|
||||
|
||||
function eventAccessor(clean){
|
||||
return Object.freeze({
|
||||
on(type,handler){
|
||||
if(!TURN_LIFECYCLE_TYPES.has(type)||typeof handler!=='function') return null;
|
||||
let listenersByExtension=turnLifecycleListeners.get(type);
|
||||
if(!listenersByExtension){
|
||||
listenersByExtension=new Map();
|
||||
turnLifecycleListeners.set(type,listenersByExtension);
|
||||
}
|
||||
let listeners=listenersByExtension.get(clean);
|
||||
if(!listeners){
|
||||
listeners=new Set();
|
||||
listenersByExtension.set(clean,listeners);
|
||||
}
|
||||
listeners.add(handler);
|
||||
let active=true;
|
||||
return function unsubscribe(){
|
||||
if(!active) return false;
|
||||
active=false;
|
||||
listeners.delete(handler);
|
||||
if(!listeners.size) listenersByExtension.delete(clean);
|
||||
if(!listenersByExtension.size) turnLifecycleListeners.delete(type);
|
||||
return true;
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function turnLifecycleKey(sessionId,streamId){
|
||||
return `${sessionId}\u0000${streamId}`;
|
||||
}
|
||||
|
||||
function rememberTurnLifecycleState(key,state){
|
||||
if(turnLifecycleStates.has(key)) turnLifecycleStates.delete(key);
|
||||
turnLifecycleStates.set(key,state);
|
||||
while(turnLifecycleStates.size>TURN_LIFECYCLE_STATE_LIMIT){
|
||||
turnLifecycleStates.delete(turnLifecycleStates.keys().next().value);
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchTurnLifecycle(type,raw){
|
||||
if(!TURN_LIFECYCLE_TYPES.has(type)||!raw||typeof raw!=='object') return false;
|
||||
const sessionId=typeof raw.sessionId==='string'?raw.sessionId.trim():'';
|
||||
const streamId=typeof raw.streamId==='string'?raw.streamId.trim():'';
|
||||
if(!sessionId||!streamId) return false;
|
||||
|
||||
const key=turnLifecycleKey(sessionId,streamId);
|
||||
const previous=turnLifecycleStates.get(key)||{started:false,terminal:false};
|
||||
const terminal=type!=='turn:start';
|
||||
if((type==='turn:start'&&(previous.started||previous.terminal))||(terminal&&previous.terminal)) return false;
|
||||
rememberTurnLifecycleState(key,{
|
||||
started:previous.started||type==='turn:start',
|
||||
terminal:previous.terminal||terminal,
|
||||
});
|
||||
|
||||
const now=Date.now()/1000;
|
||||
const event={
|
||||
type,
|
||||
sessionId,
|
||||
streamId,
|
||||
timestamp:Number.isFinite(raw.timestamp)?raw.timestamp:now,
|
||||
};
|
||||
if(type==='turn:start') event.startedAt=Number.isFinite(raw.startedAt)?raw.startedAt:event.timestamp;
|
||||
else event.endedAt=Number.isFinite(raw.endedAt)?raw.endedAt:event.timestamp;
|
||||
if(typeof raw.status==='string'&&raw.status.trim()) event.status=raw.status.trim();
|
||||
const frozenEvent=Object.freeze(event);
|
||||
const listenersByExtension=turnLifecycleListeners.get(type);
|
||||
if(!listenersByExtension) return true;
|
||||
for(const [extensionId,listeners] of listenersByExtension){
|
||||
for(const listener of [...listeners]){
|
||||
try{
|
||||
listener(frozenEvent);
|
||||
}catch(error){
|
||||
if(typeof console!=='undefined'&&typeof console.error==='function'){
|
||||
try{
|
||||
console.error(`[Hermes extensions] ${extensionId} ${type} listener failed:`,error);
|
||||
}catch(_loggingError){ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function registerExtension(id){
|
||||
if(typeof id!=='string') return null;
|
||||
const clean=extensionId(id);
|
||||
@@ -330,6 +418,7 @@
|
||||
id:clean,
|
||||
settings:settingsAccessor(clean,trusted,true),
|
||||
storage:storageAccessor(clean,trusted),
|
||||
events:eventAccessor(clean),
|
||||
});
|
||||
registrations.set(clean,handle);
|
||||
return handle;
|
||||
@@ -341,6 +430,7 @@
|
||||
namespaceForExtension,
|
||||
settingsForExtension,
|
||||
storageForExtension,
|
||||
_dispatchTurnLifecycle:dispatchTurnLifecycle,
|
||||
resetSettingsForExtension(id){return settingsForExtension(id).reset();},
|
||||
clearStorageForExtension(id){return storageForExtension(id).clear();},
|
||||
};
|
||||
|
||||
+40
-2
@@ -2066,9 +2066,26 @@ function closeOtherLiveStreams(activeSid){
|
||||
}
|
||||
}
|
||||
|
||||
function _dispatchExtensionTurnLifecycle(type,sessionId,streamId,details={}){
|
||||
const runtime=typeof window!=='undefined'&&window.HermesExtensionSettings;
|
||||
const dispatch=runtime&&runtime._dispatchTurnLifecycle;
|
||||
if(typeof dispatch!=='function') return false;
|
||||
try{
|
||||
return dispatch(type,{sessionId,streamId,...details});
|
||||
}catch(error){
|
||||
if(typeof console!=='undefined'&&typeof console.error==='function'){
|
||||
try{console.error('[Hermes extensions] lifecycle dispatch failed:',error);}catch(_loggingError){ }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(!activeSid||!streamId) return;
|
||||
const reconnecting=!!options.reconnecting;
|
||||
const _extensionTurnStartedAt=(S.session&&S.session.session_id===activeSid&&Number.isFinite(S.session.pending_started_at))
|
||||
?S.session.pending_started_at
|
||||
:Date.now()/1000;
|
||||
// #4416: start (or, on reconnect for the SAME stream, keep) tracking whether
|
||||
// the tab was hidden during this stream so the done-notification fires for a
|
||||
// backgrounded tab. A reconnect with a different streamId re-seeds (the old
|
||||
@@ -6308,6 +6325,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(isActiveSession) _queueDrainSid=activeSid;
|
||||
renderSessionList();
|
||||
_setActivePaneIdleIfOwner();
|
||||
_dispatchExtensionTurnLifecycle('turn:complete',activeSid,streamId,{
|
||||
status:d.status||'completed',
|
||||
endedAt:Date.now()/1000,
|
||||
});
|
||||
playNotificationSound();
|
||||
// #4416: notify if the tab was hidden at ANY point during this stream
|
||||
// (not just at done-receive time, which a throttled background-tab SSE
|
||||
@@ -6492,6 +6513,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
_clearClarifyForOwner('terminal');
|
||||
let d={};
|
||||
try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; }
|
||||
const _extensionErrorType=(d.type==='cancelled'||d.type==='interrupted')?'turn:cancel':'turn:error';
|
||||
const currentSid=S.session&&S.session.session_id;
|
||||
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||'';
|
||||
@@ -6589,6 +6611,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
}
|
||||
_setActivePaneIdleIfOwner();
|
||||
renderSessionList(); // clear streaming indicator immediately on apperror
|
||||
_dispatchExtensionTurnLifecycle(_extensionErrorType,activeSid,streamId,{
|
||||
status:d.status||d.type||(_extensionErrorType==='turn:cancel'?'cancelled':'error'),
|
||||
endedAt:Date.now()/1000,
|
||||
});
|
||||
});
|
||||
|
||||
source.addEventListener('warning',e=>{
|
||||
@@ -6791,6 +6817,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
// a second GET race where the visible cancelled work briefly collapses to only
|
||||
// the fallback "Task cancelled" marker (#4076).
|
||||
const _cancelSessionPayload=_cancelData&&typeof _cancelData.session==='object'?_cancelData.session:null;
|
||||
renderSessionList();
|
||||
_setActivePaneIdleIfOwner();
|
||||
(async()=>{
|
||||
try{
|
||||
if(_applyCancelSessionPayload(_cancelSessionPayload)) return;
|
||||
@@ -6816,10 +6844,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(_wasFollowingAtCancelFb && typeof scrollToBottom==='function') scrollToBottom();
|
||||
_markSessionViewed(activeSid, S.messages.length);
|
||||
}
|
||||
}finally{
|
||||
_dispatchExtensionTurnLifecycle('turn:cancel',activeSid,streamId,{
|
||||
status:_cancelData.status||_cancelData.type||'cancelled',
|
||||
endedAt:Date.now()/1000,
|
||||
});
|
||||
}
|
||||
})();
|
||||
renderSessionList();
|
||||
_setActivePaneIdleIfOwner();
|
||||
});
|
||||
|
||||
for(const _runJournalEventName of ['token','interim_assistant','reasoning','tool','tool_complete','todo_state','approval','clarify','state_saved','title','title_status','context_status','goal','goal_continue','done','stream_end','pending_steer_leftover','compressing','compressed','metering','apperror','warning','error','cancel']){
|
||||
@@ -7047,6 +7078,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
}
|
||||
}
|
||||
_setActivePaneIdleIfOwner();
|
||||
_dispatchExtensionTurnLifecycle('turn:error',activeSid,streamId,{
|
||||
status:'connection_lost',
|
||||
endedAt:Date.now()/1000,
|
||||
});
|
||||
}
|
||||
|
||||
(async()=>{
|
||||
@@ -7088,6 +7123,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
}catch(_){}
|
||||
}
|
||||
const replayParams=(reconnecting||replayOnly)?_runJournalReplayParams():'';
|
||||
_dispatchExtensionTurnLifecycle('turn:start',activeSid,streamId,{
|
||||
startedAt:_extensionTurnStartedAt,
|
||||
});
|
||||
_wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}${replayParams}`,document.baseURI||location.href).href,{withCredentials:true}));
|
||||
})();
|
||||
|
||||
|
||||
@@ -1734,6 +1734,13 @@ async function loadSession(sid){
|
||||
_yoloEnabled=false;_updateYoloPill();
|
||||
if(typeof stopClarifyPolling==='function') stopClarifyPolling();
|
||||
if(typeof hideClarifyCard==='function') hideClarifyCard(forceReload, forceReload?'external-refresh':'dismissed');
|
||||
// #6572: clear stale compression state when switching sessions.
|
||||
// The compression UI state is per-session and must not leak across loads.
|
||||
// Without this, a compression card from a prior session can appear as a
|
||||
// phantom "Compressing context" barrier on a fresh session that never
|
||||
// triggered compression.
|
||||
if(typeof clearCompressionUi==='function') clearCompressionUi();
|
||||
else window._compressionUi=null;
|
||||
// Show loading indicator immediately for responsiveness.
|
||||
// Cleared by renderMessages() once full session data arrives.
|
||||
// Persist the current composer draft before switching away so it can be
|
||||
|
||||
+28
-1
@@ -3195,7 +3195,34 @@ function _findModelInDropdown(modelId, sel, preferredProviderId){
|
||||
}
|
||||
const preferred=String(preferredProviderId||explicitProvider||'').toLowerCase();
|
||||
if(preferred){
|
||||
const providerMatch=options.find(o=>norm(o.value)===target && _getOptionProviderId(o).toLowerCase()===preferred);
|
||||
if(preferred==='custom'||preferred.startsWith('custom:')){
|
||||
// A slash is part of a custom endpoint's upstream model ID, not a
|
||||
// provider namespace. Match the exact routed ID (allowing only the
|
||||
// WebUI's @provider: wrapper and dash/dot spelling compatibility).
|
||||
const routeNorm=value=>{
|
||||
let routed=String(value||'');
|
||||
const prefix=`@${preferred}:`;
|
||||
if(routed.toLowerCase().startsWith(prefix)) routed=routed.slice(prefix.length);
|
||||
return routed.toLowerCase().replace(/-/g,'.');
|
||||
};
|
||||
const providerOptions=options.filter(o=>_getOptionProviderId(o).toLowerCase()===preferred);
|
||||
const providerMatch=providerOptions.find(o=>routeNorm(o.value)===routeNorm(rawModel));
|
||||
if(providerMatch) return providerMatch.value;
|
||||
// Legacy sessions may store only the bare suffix of a routed custom
|
||||
// option. Preserve #6195's provider-hinted repair, but only for an
|
||||
// explicit @provider: row; an unwrapped slash ID belongs to the active
|
||||
// endpoint and must not substitute for a distinct bare model.
|
||||
if(!rawModel.includes('/')&&!rawModel.startsWith('@')){
|
||||
const prefix=`@${preferred}:`;
|
||||
const suffixMatches=providerOptions.filter(o=>
|
||||
String(o.value||'').toLowerCase().startsWith(prefix)
|
||||
&&norm(o.value)===target
|
||||
);
|
||||
if(suffixMatches.length===1) return suffixMatches[0].value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const providerMatch=options.find(o=>norm(o.value)===target&&_getOptionProviderId(o).toLowerCase()===preferred);
|
||||
if(providerMatch) return providerMatch.value;
|
||||
}
|
||||
// 2. Normalized match — but ONLY when unambiguous. If the bare id
|
||||
|
||||
@@ -173,6 +173,10 @@ def test_cleared_title_allows_initial_auto_generation(monkeypatch):
|
||||
"profile_env_for_background_worker",
|
||||
lambda *_args, **_kwargs: contextlib.nullcontext(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"api.state_sync.sync_session_title",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
|
||||
streaming._run_background_title_update(
|
||||
session.session_id,
|
||||
|
||||
@@ -215,6 +215,18 @@ def _run_commands_js(script_body: str) -> dict:
|
||||
{{
|
||||
name: 'plugin review',
|
||||
description: 'Plugin collisions should stay hidden from slash autocomplete'
|
||||
}},
|
||||
{{
|
||||
name: 'hermes-upgrade',
|
||||
description: 'Safely upgrade and verify a Hermes installation'
|
||||
}},
|
||||
{{
|
||||
name: 'maintenance-guide',
|
||||
description: 'Plan a safe upgrade and rollback path'
|
||||
}},
|
||||
{{
|
||||
name: 'reinstall-helper',
|
||||
description: 'Recover from a failed reinstall'
|
||||
}}
|
||||
]
|
||||
}};
|
||||
@@ -352,6 +364,102 @@ def test_cli_only_slugs_reserve_skill_autocomplete_namespace():
|
||||
assert "use" in result["use_names"]
|
||||
|
||||
|
||||
def test_skill_autocomplete_matches_keyword_in_name_or_description():
|
||||
result = _run_commands_js(
|
||||
"""
|
||||
await loadBundleCommands(true);
|
||||
await loadSkillCommands(true);
|
||||
const upgrade = await getSlashAutocompleteMatches('/upgrade');
|
||||
const reinstall = await getSlashAutocompleteMatches('/reinstall');
|
||||
return {
|
||||
upgrade_names: upgrade.map(item => item.name),
|
||||
upgrade_sources: upgrade.map(item => item.source),
|
||||
reinstall_names: reinstall.map(item => item.name),
|
||||
reinstall_sources: reinstall.map(item => item.source)
|
||||
};
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["upgrade_names"] == ["hermes-upgrade", "maintenance-guide"]
|
||||
assert result["upgrade_sources"] == ["skill", "skill"]
|
||||
assert result["reinstall_names"] == ["reinstall-helper"]
|
||||
assert result["reinstall_sources"] == ["skill"]
|
||||
|
||||
|
||||
def test_skill_autocomplete_hides_keyword_match_shadowed_by_bundle():
|
||||
result = _run_commands_js(
|
||||
"""
|
||||
await loadAgentCommandMetadata(true);
|
||||
await loadBundleCommands(true);
|
||||
await loadSkillCommands(true);
|
||||
const matches = await getSlashAutocompleteMatches('/non-colliding');
|
||||
return matches.map(item => ({ name: item.name, source: item.source }));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_skill_autocomplete_waits_for_bundle_metadata_before_showing_colliding_keyword_match():
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
const vm = require('vm');
|
||||
let releaseBundles;
|
||||
const bundlesReady = new Promise(resolve => {{ releaseBundles = resolve; }});
|
||||
const ctx = {{
|
||||
console,
|
||||
localStorage: {{ getItem(){{return null;}}, setItem(){{}}, removeItem(){{}} }},
|
||||
t: (key) => key,
|
||||
api: async (path) => {{
|
||||
if (path === '/api/commands') return {{ commands: [] }};
|
||||
if (path === '/api/commands/bundles') return bundlesReady;
|
||||
if (path === '/api/skills') return {{ skills: [
|
||||
{{
|
||||
name: 'incident-review',
|
||||
description: 'Handle a non-colliding keyword',
|
||||
category: 'ops'
|
||||
}}
|
||||
] }};
|
||||
throw new Error('unexpected api path: ' + path);
|
||||
}}
|
||||
}};
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext({json.dumps(COMMANDS_JS)}, ctx);
|
||||
(async () => {{
|
||||
await vm.runInContext('loadSkillCommands(true)', ctx);
|
||||
const bundleLoad = vm.runInContext('loadBundleCommands(true)', ctx);
|
||||
const before = await vm.runInContext("getSlashAutocompleteMatches('/non-colliding')", ctx);
|
||||
releaseBundles({{ bundles: [
|
||||
{{
|
||||
name: 'incident-review',
|
||||
description: 'Incident review bundle',
|
||||
skill_count: 3,
|
||||
source: 'bundle'
|
||||
}}
|
||||
] }});
|
||||
await bundleLoad;
|
||||
const after = await vm.runInContext("getSlashAutocompleteMatches('/non-colliding')", ctx);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
before: before.map(item => ({{ name: item.name, source: item.source }})),
|
||||
after: after.map(item => ({{ name: item.name, source: item.source }}))
|
||||
}}));
|
||||
}})().catch(err => {{
|
||||
console.error(err && err.stack || err);
|
||||
process.exit(1);
|
||||
}});
|
||||
"""
|
||||
)
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".js", encoding="utf-8", delete=False) as handle:
|
||||
handle.write(script)
|
||||
script_path = Path(handle.name)
|
||||
try:
|
||||
proc = subprocess.run(["node", str(script_path)], check=True, capture_output=True, text=True)
|
||||
finally:
|
||||
script_path.unlink(missing_ok=True)
|
||||
|
||||
assert json.loads(proc.stdout) == {"before": [], "after": []}
|
||||
|
||||
|
||||
def test_bundle_collisions_stay_hidden_until_agent_metadata_is_ready():
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Regression coverage for exact custom-provider model identity during catalog refresh."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
UI_JS = REPO_ROOT / "static" / "ui.js"
|
||||
NODE = shutil.which("node")
|
||||
|
||||
_DRIVER = r"""
|
||||
const fs=require('fs');
|
||||
const src=fs.readFileSync(process.argv[2],'utf8');
|
||||
function extract(name){
|
||||
const start=src.indexOf('function '+name+'(');
|
||||
if(start<0) throw new Error('missing '+name);
|
||||
let i=src.indexOf('{',start), depth=0;
|
||||
for(;i<src.length;i++){
|
||||
if(src[i]==='{') depth++;
|
||||
else if(src[i]==='}'&&--depth===0) return src.slice(start,i+1);
|
||||
}
|
||||
throw new Error('unterminated '+name);
|
||||
}
|
||||
class Node {
|
||||
constructor(tag){this.tagName=tag.toUpperCase();this.children=[];this.dataset={};this.parentElement=null;this._value='';this.textContent='';this.id='';}
|
||||
appendChild(child){child.parentElement=this;this.children.push(child);return child;}
|
||||
removeChild(child){this.children=this.children.filter(x=>x!==child);child.parentElement=null;}
|
||||
querySelectorAll(selector){
|
||||
if(selector==='optgroup') return this.children.filter(x=>x.tagName==='OPTGROUP');
|
||||
return [];
|
||||
}
|
||||
get options(){
|
||||
if(this.tagName!=='SELECT') return [];
|
||||
return this.children.flatMap(x=>x.tagName==='OPTGROUP'?x.children:[x]).filter(x=>x.tagName==='OPTION');
|
||||
}
|
||||
get value(){return this._value;}
|
||||
set value(value){this._value=String(value||'');}
|
||||
get selectedOptions(){const hit=this.options.find(x=>x.value===this._value);return hit?[hit]:[];}
|
||||
}
|
||||
const document={createElement:tag=>new Node(tag)};
|
||||
const window={_configuredModelBadges:{},_activeProvider:'custom:cpa'};
|
||||
const S={session:null};
|
||||
const _dynamicModelLabels={};
|
||||
const _liveModelFetchPending=new Set();
|
||||
const $=()=>null;
|
||||
const getModelLabel=value=>value;
|
||||
const syncModelChip=()=>{};
|
||||
for(const name of ['_getOptionProviderId','_providerFromModelValue','_modelPickerOptionIdentity','_deduplicateModelPickerOptions','_modelStateForSelect','_findModelInDropdown','_refreshOpenModelDropdown','_applyModelToDropdown','_ensureModelOptionInDropdown','_addLiveModelsToSelect']) eval(extract(name));
|
||||
function makeSelect(selected){
|
||||
const sel=new Node('select');sel.id='modelSelect';
|
||||
const group=new Node('optgroup');group.dataset.provider='custom:cpa';sel.appendChild(group);
|
||||
for(const id of ['jb/gpt-5.6-sol']){const opt=new Node('option');opt.value=id;opt.dataset.provider='custom:cpa';group.appendChild(opt);}
|
||||
sel.value=selected||'jb/gpt-5.6-sol';return sel;
|
||||
}
|
||||
const bare=makeSelect();
|
||||
const appliedBare=_ensureModelOptionInDropdown('gpt-5.6-sol',bare,'custom:cpa');
|
||||
const beforeLive=_modelStateForSelect(bare,bare.value);
|
||||
_addLiveModelsToSelect('custom:cpa',[{id:'gpt-5.6-sol'},{id:'jb/gpt-5.6-sol'}],bare);
|
||||
const afterLive=_modelStateForSelect(bare,bare.value);
|
||||
const qualified=makeSelect('jb/gpt-5.6-sol');
|
||||
_addLiveModelsToSelect('custom:cpa',[{id:'gpt-5.6-sol'}],qualified);
|
||||
console.log(JSON.stringify({
|
||||
appliedBare,beforeLive,afterLive,
|
||||
bareOptions:bare.options.map(x=>x.value),
|
||||
qualifiedAfter:_modelStateForSelect(qualified,qualified.value),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not in PATH")
|
||||
def test_partial_then_live_catalog_preserves_exact_custom_provider_model(tmp_path):
|
||||
driver = tmp_path / "driver.js"
|
||||
driver.write_text(_DRIVER, encoding="utf-8")
|
||||
result = subprocess.run(
|
||||
[NODE, str(driver), str(UI_JS)], capture_output=True, text=True, timeout=30
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
expected = {"model": "gpt-5.6-sol", "model_provider": "custom:cpa"}
|
||||
assert payload["beforeLive"] == expected
|
||||
assert payload["afterLive"] == expected
|
||||
assert payload["appliedBare"] == "@custom:cpa:gpt-5.6-sol"
|
||||
assert "@custom:cpa:gpt-5.6-sol" in payload["bareOptions"]
|
||||
assert "@custom:cpa:jb/gpt-5.6-sol" in payload["bareOptions"]
|
||||
assert payload["qualifiedAfter"] == {
|
||||
"model": "jb/gpt-5.6-sol",
|
||||
"model_provider": "custom:cpa",
|
||||
}
|
||||
|
||||
|
||||
# Ambiguity guard for the #6195 bare-id fallback (Codex round-2 SHOULD-FIX):
|
||||
# when two options under the same custom provider normalize to the same target,
|
||||
# the hinted-fallback must refuse to guess and return null rather than pick
|
||||
# an arbitrary one.
|
||||
_AMBIGUITY_DRIVER = r"""
|
||||
const fs=require('fs');
|
||||
const src=fs.readFileSync(process.argv[2],'utf8');
|
||||
function extract(name){
|
||||
const start=src.indexOf('function '+name+'(');
|
||||
if(start<0) throw new Error('missing '+name);
|
||||
let i=src.indexOf('{',start), depth=0;
|
||||
for(;i<src.length;i++){
|
||||
if(src[i]==='{') depth++;
|
||||
else if(src[i]==='}'&&--depth===0) return src.slice(start,i+1);
|
||||
}
|
||||
throw new Error('unterminated '+name);
|
||||
}
|
||||
function _getOptionProviderId(o){return o.providerId||'';}
|
||||
for(const name of ['_findModelInDropdown']) eval(extract(name));
|
||||
// Single unambiguous suffix match -> resolves to the existing namespaced option.
|
||||
const single={options:[
|
||||
{value:'@custom:tok:z-ai/glm-5.2', providerId:'custom:tok'},
|
||||
{value:'openai/gpt-4', providerId:'openai'},
|
||||
]};
|
||||
// Two same-provider options normalizing to the same target -> ambiguous.
|
||||
const ambiguous={options:[
|
||||
{value:'@custom:tok:z-ai/glm-5.2', providerId:'custom:tok'},
|
||||
{value:'@custom:tok:other/glm-5.2', providerId:'custom:tok'},
|
||||
]};
|
||||
console.log(JSON.stringify({
|
||||
single:_findModelInDropdown('glm-5.2', single, 'custom:tok'),
|
||||
ambiguous:_findModelInDropdown('glm-5.2', ambiguous, 'custom:tok'),
|
||||
exact:_findModelInDropdown('z-ai/glm-5.2', single, 'custom:tok'),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not in PATH")
|
||||
def test_hinted_bare_id_fallback_refuses_ambiguous_match(tmp_path):
|
||||
driver = tmp_path / "amb_driver.js"
|
||||
driver.write_text(_AMBIGUITY_DRIVER, encoding="utf-8")
|
||||
result = subprocess.run(
|
||||
[NODE, str(driver), str(UI_JS)], capture_output=True, text=True, timeout=30
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
# One unambiguous suffix match: the bare id resolves to the existing
|
||||
# namespaced catalog option (#6195 repair preserved).
|
||||
assert payload["single"] == "@custom:tok:z-ai/glm-5.2"
|
||||
# Two same-normalized options under the same provider: refuse to guess.
|
||||
assert payload["ambiguous"] is None
|
||||
# Exact routed match is unaffected by the fallback path (#6944 fix intact).
|
||||
assert payload["exact"] == "@custom:tok:z-ai/glm-5.2"
|
||||
@@ -214,7 +214,7 @@ def test_hermes_ext_register_runtime_identity_and_reload_lifecycle():
|
||||
assert.ok(alpha);
|
||||
assert.strictEqual(alpha.id, 'alpha.ext');
|
||||
assert.strictEqual(Object.isFrozen(alpha), true);
|
||||
assert.deepStrictEqual(Object.keys(alpha).sort(), ['id', 'settings', 'storage']);
|
||||
assert.deepStrictEqual(Object.keys(alpha).sort(), ['events', 'id', 'settings', 'storage']);
|
||||
assert.deepStrictEqual(alpha.settings.values, {{enabled: false, mode: 'alpha'}});
|
||||
assert.strictEqual(alpha.settings.set('enabled', true).ok, true);
|
||||
assert.strictEqual(alpha.storage.set('note', 'alpha-note'), true);
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
"""Behavioral coverage for extension turn-lifecycle subscriptions."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
STATIC = ROOT / "static"
|
||||
EXTENSION_SETTINGS_JS = ROOT / "static" / "extension_settings.js"
|
||||
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
INDEX_HTML = (STATIC / "index.html").read_text(encoding="utf-8")
|
||||
UI_JS = (STATIC / "ui.js").read_text(encoding="utf-8")
|
||||
SUPPORT_SCRIPTS = [
|
||||
(STATIC / name).read_text(encoding="utf-8")
|
||||
for name in ("i18n.js", "icons.js", "assistant_turn_anchors.js")
|
||||
]
|
||||
HARNESS_HTML = re.sub(r"<script\b[^>]*>.*?</script>", "", INDEX_HTML, flags=re.I | re.S)
|
||||
HARNESS_HTML = re.sub(r"<link\b[^>]*>", "", HARNESS_HTML, flags=re.I)
|
||||
|
||||
|
||||
_MOCK_EVENT_SOURCE = r"""
|
||||
class MockEventSource {
|
||||
static CONNECTING = 0;
|
||||
static OPEN = 1;
|
||||
static CLOSED = 2;
|
||||
static instances = [];
|
||||
constructor(url) {
|
||||
this.url = String(url);
|
||||
this.readyState = MockEventSource.OPEN;
|
||||
this.listeners = new Map();
|
||||
MockEventSource.instances.push(this);
|
||||
}
|
||||
addEventListener(name, listener) {
|
||||
const listeners = this.listeners.get(name) || [];
|
||||
listeners.push(listener);
|
||||
this.listeners.set(name, listeners);
|
||||
}
|
||||
removeEventListener(name, listener) {
|
||||
this.listeners.set(name, (this.listeners.get(name) || []).filter(fn => fn !== listener));
|
||||
}
|
||||
async emit(name, payload) {
|
||||
const event = {type: name, data: JSON.stringify(payload), lastEventId: ''};
|
||||
await Promise.all((this.listeners.get(name) || []).map(listener => listener(event)));
|
||||
}
|
||||
close() { this.readyState = MockEventSource.CLOSED; }
|
||||
}
|
||||
window.EventSource = MockEventSource;
|
||||
window.MockEventSource = MockEventSource;
|
||||
"""
|
||||
|
||||
|
||||
_STABILIZE_UNRELATED_UI = r"""
|
||||
appendLiveCompressionCard = () => false;
|
||||
resetTurnWorkspaceMutations = () => {};
|
||||
_resetStreamScrollFollow = () => {};
|
||||
ensureLiveWorklogShell = () => {};
|
||||
_shouldUseLiveProseFade = () => false;
|
||||
_shouldFollowMessagesOnDomReplace = () => false;
|
||||
_isMessagePaneNearBottom = () => false;
|
||||
_markSessionViewed = () => {};
|
||||
setBusy = value => { S.busy = Boolean(value); };
|
||||
setComposerStatus = () => {};
|
||||
setStatus = () => {};
|
||||
syncTopbar = () => {};
|
||||
renderMessages = () => {};
|
||||
renderSessionList = () => {};
|
||||
renderSessionArtifacts = () => {};
|
||||
loadDir = () => {};
|
||||
playNotificationSound = () => {};
|
||||
sendBrowserNotification = () => {};
|
||||
api = async () => ({});
|
||||
"""
|
||||
|
||||
|
||||
_LIFECYCLE_SCENARIO = r"""
|
||||
async ({kind}) => {
|
||||
const activeSid = 'session-a';
|
||||
const streamId = `stream-${kind}`;
|
||||
const lifecycle = [];
|
||||
const extension = window.hermesExt.register('lifecycle-probe');
|
||||
if (!extension) throw new Error('lifecycle probe did not register');
|
||||
for (const type of ['turn:start', 'turn:complete', 'turn:error', 'turn:cancel']) {
|
||||
extension.events.on(type, event => {
|
||||
const lastMessage = Array.isArray(S.messages) ? S.messages.at(-1) : null;
|
||||
lifecycle.push({
|
||||
event: {...event},
|
||||
activeStreamId: S.activeStreamId || null,
|
||||
busy: Boolean(S.busy),
|
||||
currentSid: S.session && S.session.session_id || null,
|
||||
lastContent: lastMessage && lastMessage.content || null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
S.session = {session_id: activeSid, messages: [{role: 'user', content: 'question'}]};
|
||||
S.messages = [{role: 'user', content: 'question'}];
|
||||
S.toolCalls = [];
|
||||
S.activeStreamId = streamId;
|
||||
S.busy = true;
|
||||
attachLiveStream(activeSid, streamId, []);
|
||||
const source = MockEventSource.instances.at(-1);
|
||||
if (!source) throw new Error('attachLiveStream did not construct EventSource');
|
||||
|
||||
if (kind === 'done') {
|
||||
await source.emit('done', {
|
||||
status: 'completed',
|
||||
stream_id: streamId,
|
||||
session: {
|
||||
session_id: activeSid,
|
||||
messages: [{role: 'assistant', content: 'settled-done'}],
|
||||
tool_calls: [],
|
||||
},
|
||||
});
|
||||
} else if (kind === 'apperror') {
|
||||
await source.emit('apperror', {
|
||||
type: 'provider_error',
|
||||
status: 'provider_error',
|
||||
message: 'provider failed',
|
||||
session_id: activeSid,
|
||||
session: {
|
||||
session_id: activeSid,
|
||||
messages: [{role: 'assistant', content: 'settled-error'}],
|
||||
},
|
||||
});
|
||||
} else if (kind === 'apperror-cancel') {
|
||||
await source.emit('apperror', {
|
||||
type: 'interrupted',
|
||||
status: 'interrupted',
|
||||
message: 'interrupted',
|
||||
session_id: activeSid,
|
||||
session: {
|
||||
session_id: activeSid,
|
||||
messages: [{role: 'assistant', content: 'settled-interrupted'}],
|
||||
},
|
||||
});
|
||||
} else if (kind === 'cancel') {
|
||||
await source.emit('cancel', {
|
||||
type: 'cancelled',
|
||||
status: 'cancelled',
|
||||
session_id: activeSid,
|
||||
session: {
|
||||
session_id: activeSid,
|
||||
messages: [{role: 'assistant', content: 'settled-cancel'}],
|
||||
},
|
||||
});
|
||||
} else if (kind === 'connection-error') {
|
||||
const nativeSetTimeout = window.setTimeout;
|
||||
api = async url => String(url).includes('/api/chat/stream/status')
|
||||
? {active: false, replay_available: false}
|
||||
: {};
|
||||
window.setTimeout = callback => {
|
||||
queueMicrotask(callback);
|
||||
return 1;
|
||||
};
|
||||
try {
|
||||
await source.emit('error', {});
|
||||
await new Promise((resolve, reject) => {
|
||||
const deadline = Date.now() + 2000;
|
||||
const poll = () => {
|
||||
if (lifecycle.some(entry => entry.event.type === 'turn:error')) return resolve();
|
||||
if (Date.now() >= deadline) return reject(new Error('connection terminal event not observed'));
|
||||
nativeSetTimeout(poll, 0);
|
||||
};
|
||||
poll();
|
||||
});
|
||||
} finally {
|
||||
window.setTimeout = nativeSetTimeout;
|
||||
}
|
||||
} else {
|
||||
throw new Error(`unknown lifecycle scenario: ${kind}`);
|
||||
}
|
||||
return lifecycle;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _function_body(source: str, name: str) -> str:
|
||||
start = source.index(f"function {name}")
|
||||
brace = source.index("){", start) + 1
|
||||
depth = 0
|
||||
for index in range(brace, len(source)):
|
||||
if source[index] == "{":
|
||||
depth += 1
|
||||
elif source[index] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[start : index + 1]
|
||||
raise AssertionError(f"function {name} body not found")
|
||||
|
||||
|
||||
def _event_listener_body(source: str, event_name: str) -> str:
|
||||
start = source.index(f"source.addEventListener('{event_name}'")
|
||||
brace = source.index("{", start)
|
||||
depth = 0
|
||||
for index in range(brace, len(source)):
|
||||
if source[index] == "{":
|
||||
depth += 1
|
||||
elif source[index] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[start : index + 1]
|
||||
raise AssertionError(f"event listener {event_name!r} body not found")
|
||||
|
||||
|
||||
def _run_node(script: str):
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node is required for extension lifecycle runtime tests")
|
||||
result = subprocess.run(
|
||||
[node, "-e", script],
|
||||
cwd=ROOT,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=20,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
|
||||
def test_registered_extension_receives_bounded_turn_lifecycle_events():
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
const store = new Map();
|
||||
const loggedErrors = [];
|
||||
global.window = {{
|
||||
__HERMES_EXTENSION_CONFIG__: {{
|
||||
extensions: [
|
||||
{{id: 'alpha.ext', storage_owned: false}},
|
||||
{{id: 'beta.ext', storage_owned: false}},
|
||||
],
|
||||
}},
|
||||
localStorage: {{
|
||||
getItem(key) {{ return store.has(key) ? store.get(key) : null; }},
|
||||
setItem(key, value) {{ store.set(key, String(value)); }},
|
||||
removeItem(key) {{ store.delete(key); }},
|
||||
}},
|
||||
}};
|
||||
global.console = {{
|
||||
error(...args) {{ loggedErrors.push(args.map(String).join(' ')); }},
|
||||
}};
|
||||
eval(fs.readFileSync({str(EXTENSION_SETTINGS_JS)!r}, 'utf8'));
|
||||
|
||||
const alpha = window.hermesExt.register('alpha.ext');
|
||||
const beta = window.hermesExt.register('beta.ext');
|
||||
assert.deepStrictEqual(Object.keys(alpha).sort(), ['events', 'id', 'settings', 'storage']);
|
||||
assert.strictEqual(Object.isFrozen(alpha.events), true);
|
||||
|
||||
const alphaEvents = [];
|
||||
const betaEvents = [];
|
||||
const unsubscribeStart = alpha.events.on('turn:start', event => {{
|
||||
assert.strictEqual(Object.isFrozen(event), true);
|
||||
alphaEvents.push(event);
|
||||
}});
|
||||
alpha.events.on('turn:complete', () => {{ throw new Error('extension failure'); }});
|
||||
alpha.events.on('turn:complete', event => alphaEvents.push(event));
|
||||
alpha.events.on('turn:error', event => alphaEvents.push(event));
|
||||
alpha.events.on('turn:cancel', event => alphaEvents.push(event));
|
||||
beta.events.on('turn:complete', event => betaEvents.push(event));
|
||||
|
||||
assert.strictEqual(typeof unsubscribeStart, 'function');
|
||||
assert.strictEqual(alpha.events.on('token', () => {{}}), null);
|
||||
assert.strictEqual(alpha.events.on('turn:start', null), null);
|
||||
|
||||
const emit = window.HermesExtensionSettings._dispatchTurnLifecycle;
|
||||
assert.strictEqual(typeof emit, 'function');
|
||||
assert.strictEqual(emit('turn:start', {{sessionId: '', streamId: 'stream-a'}}), false);
|
||||
assert.strictEqual(emit('turn:start', {{sessionId: 'session-a', streamId: ''}}), false);
|
||||
assert.strictEqual(emit('token', {{sessionId: 'session-a', streamId: 'stream-a'}}), false);
|
||||
|
||||
assert.strictEqual(emit('turn:start', {{
|
||||
sessionId: 'session-a', streamId: 'stream-a', startedAt: 10,
|
||||
}}), true);
|
||||
assert.strictEqual(emit('turn:start', {{
|
||||
sessionId: 'session-a', streamId: 'stream-a', startedAt: 11,
|
||||
}}), false);
|
||||
assert.strictEqual(emit('turn:complete', {{
|
||||
sessionId: 'session-a', streamId: 'stream-a', status: 'completed', endedAt: 20,
|
||||
}}), true);
|
||||
assert.strictEqual(emit('turn:error', {{
|
||||
sessionId: 'session-a', streamId: 'stream-a', status: 'late-error', endedAt: 21,
|
||||
}}), false);
|
||||
assert.strictEqual(emit('turn:cancel', {{
|
||||
sessionId: 'session-a', streamId: 'stream-a', status: 'late-cancel', endedAt: 22,
|
||||
}}), false);
|
||||
|
||||
assert.strictEqual(emit('turn:start', {{sessionId: 'session-b', streamId: 'stream-b'}}), true);
|
||||
assert.strictEqual(emit('turn:error', {{
|
||||
sessionId: 'session-b', streamId: 'stream-b', status: 'provider_error',
|
||||
}}), true);
|
||||
assert.strictEqual(emit('turn:start', {{sessionId: 'session-c', streamId: 'stream-c'}}), true);
|
||||
assert.strictEqual(emit('turn:cancel', {{
|
||||
sessionId: 'session-c', streamId: 'stream-c', status: 'cancelled',
|
||||
}}), true);
|
||||
|
||||
unsubscribeStart();
|
||||
unsubscribeStart();
|
||||
assert.strictEqual(emit('turn:start', {{sessionId: 'session-d', streamId: 'stream-d'}}), true);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
alphaEvents.map(event => [event.type, event.sessionId, event.streamId, event.status || null]),
|
||||
[
|
||||
['turn:start', 'session-a', 'stream-a', null],
|
||||
['turn:complete', 'session-a', 'stream-a', 'completed'],
|
||||
['turn:start', 'session-b', 'stream-b', null],
|
||||
['turn:error', 'session-b', 'stream-b', 'provider_error'],
|
||||
['turn:start', 'session-c', 'stream-c', null],
|
||||
['turn:cancel', 'session-c', 'stream-c', 'cancelled'],
|
||||
],
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
betaEvents.map(event => [event.type, event.sessionId, event.streamId]),
|
||||
[['turn:complete', 'session-a', 'stream-a']],
|
||||
);
|
||||
assert.strictEqual(loggedErrors.length, 1);
|
||||
assert.match(loggedErrors[0], /alpha[.]ext.*turn:complete.*extension failure/);
|
||||
"""
|
||||
)
|
||||
_run_node(script)
|
||||
|
||||
|
||||
def test_live_stream_bridge_forwards_normalized_lifecycle_details():
|
||||
bridge = _function_body(MESSAGES_JS, "_dispatchExtensionTurnLifecycle")
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
const assert = require('assert');
|
||||
const calls = [];
|
||||
global.window = {{
|
||||
HermesExtensionSettings: {{
|
||||
_dispatchTurnLifecycle(type, details) {{
|
||||
calls.push([type, details]);
|
||||
return 'delivered';
|
||||
}},
|
||||
}},
|
||||
}};
|
||||
eval({json.dumps(bridge)});
|
||||
|
||||
assert.strictEqual(
|
||||
_dispatchExtensionTurnLifecycle(
|
||||
'turn:error', 'session-a', 'stream-a', {{status: 'connection_lost'}},
|
||||
),
|
||||
'delivered',
|
||||
);
|
||||
assert.deepStrictEqual(calls, [[
|
||||
'turn:error',
|
||||
{{sessionId: 'session-a', streamId: 'stream-a', status: 'connection_lost'}},
|
||||
]]);
|
||||
|
||||
delete window.HermesExtensionSettings;
|
||||
assert.strictEqual(
|
||||
_dispatchExtensionTurnLifecycle('turn:start', 'session-b', 'stream-b'),
|
||||
false,
|
||||
);
|
||||
|
||||
window.HermesExtensionSettings = {{
|
||||
_dispatchTurnLifecycle() {{ throw new Error('broken extension runtime'); }},
|
||||
}};
|
||||
global.console = {{error() {{}}}};
|
||||
assert.strictEqual(
|
||||
_dispatchExtensionTurnLifecycle('turn:start', 'session-c', 'stream-c'),
|
||||
false,
|
||||
);
|
||||
"""
|
||||
)
|
||||
_run_node(script)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def lifecycle_browser():
|
||||
playwright_api = pytest.importorskip("playwright.sync_api")
|
||||
with playwright_api.sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(
|
||||
headless=True,
|
||||
args=["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
)
|
||||
yield browser
|
||||
browser.close()
|
||||
|
||||
|
||||
def _run_lifecycle_scenario(browser, kind: str) -> list[dict]:
|
||||
page = browser.new_page()
|
||||
page.route(
|
||||
"**/*",
|
||||
lambda route: route.fulfill(
|
||||
status=200,
|
||||
content_type="text/html"
|
||||
if route.request.url == "http://harness.test/"
|
||||
else "text/plain",
|
||||
body=HARNESS_HTML if route.request.url == "http://harness.test/" else "",
|
||||
),
|
||||
)
|
||||
page.add_init_script(_MOCK_EVENT_SOURCE)
|
||||
try:
|
||||
page.goto("http://harness.test/", wait_until="domcontentloaded")
|
||||
page.evaluate(
|
||||
"""
|
||||
window.__HERMES_EXTENSION_CONFIG__ = {
|
||||
extensions: [{id: 'lifecycle-probe', storage_owned: false}],
|
||||
};
|
||||
"""
|
||||
)
|
||||
for script in SUPPORT_SCRIPTS:
|
||||
page.add_script_tag(content=script)
|
||||
page.add_script_tag(content=EXTENSION_SETTINGS_JS.read_text(encoding="utf-8"))
|
||||
page.add_script_tag(content=UI_JS)
|
||||
page.add_script_tag(content=MESSAGES_JS)
|
||||
page.evaluate(_STABILIZE_UNRELATED_UI)
|
||||
return page.evaluate(_LIFECYCLE_SCENARIO, {"kind": kind})
|
||||
finally:
|
||||
page.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kind", "terminal_type", "terminal_status", "last_content"),
|
||||
[
|
||||
("done", "turn:complete", "completed", "settled-done"),
|
||||
("apperror", "turn:error", "provider_error", "settled-error"),
|
||||
("apperror-cancel", "turn:cancel", "interrupted", "settled-interrupted"),
|
||||
("cancel", "turn:cancel", "cancelled", "settled-cancel"),
|
||||
(
|
||||
"connection-error",
|
||||
"turn:error",
|
||||
"connection_lost",
|
||||
"**Connection interrupted:** The browser lost the live SSE connection before the response finished. If the worker completed, reopening this session should restore the settled transcript.",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_real_sse_terminal_callback_observes_settled_core_state(
|
||||
lifecycle_browser,
|
||||
kind,
|
||||
terminal_type,
|
||||
terminal_status,
|
||||
last_content,
|
||||
):
|
||||
lifecycle = _run_lifecycle_scenario(lifecycle_browser, kind)
|
||||
|
||||
assert [entry["event"]["type"] for entry in lifecycle] == [
|
||||
"turn:start",
|
||||
terminal_type,
|
||||
]
|
||||
start, terminal = lifecycle
|
||||
assert start["activeStreamId"] == f"stream-{kind}"
|
||||
assert start["busy"] is True
|
||||
assert terminal["event"]["sessionId"] == "session-a"
|
||||
assert terminal["event"]["streamId"] == f"stream-{kind}"
|
||||
assert terminal["event"]["status"] == terminal_status
|
||||
assert terminal["activeStreamId"] is None
|
||||
assert terminal["busy"] is False
|
||||
assert terminal["currentSid"] == "session-a"
|
||||
assert terminal["lastContent"] == last_content
|
||||
|
||||
|
||||
def test_live_stream_terminal_paths_use_original_stream_owner_identity():
|
||||
attach_start = MESSAGES_JS.index("function attachLiveStream(")
|
||||
attach_body = MESSAGES_JS[attach_start : MESSAGES_JS.index("\nfunction transcript(", attach_start)]
|
||||
done = _event_listener_body(MESSAGES_JS, "done")
|
||||
application_error = _event_listener_body(MESSAGES_JS, "apperror")
|
||||
cancel = _event_listener_body(MESSAGES_JS, "cancel")
|
||||
connection_error = _function_body(MESSAGES_JS, "_handleStreamError")
|
||||
|
||||
start_dispatch = attach_body.index("_dispatchExtensionTurnLifecycle('turn:start',activeSid,streamId")
|
||||
dead_reconnect_return = attach_body.index("_scheduleAnchorRegistryCleanup(120000);")
|
||||
event_source_attach = attach_body.index("_wireSSE(new EventSource", start_dispatch)
|
||||
assert dead_reconnect_return < start_dispatch < event_source_attach
|
||||
assert "_dispatchExtensionTurnLifecycle('turn:complete',activeSid,streamId" in done
|
||||
assert "_dispatchExtensionTurnLifecycle(_extensionErrorType,activeSid,streamId" in application_error
|
||||
assert "_dispatchExtensionTurnLifecycle('turn:cancel',activeSid,streamId" in cancel
|
||||
assert "_dispatchExtensionTurnLifecycle('turn:error',activeSid,streamId" in connection_error
|
||||
|
||||
assert done.index("_setActivePaneIdleIfOwner()") < done.index(
|
||||
"_dispatchExtensionTurnLifecycle('turn:complete'"
|
||||
)
|
||||
assert application_error.index("renderSessionList()") < application_error.index(
|
||||
"_dispatchExtensionTurnLifecycle(_extensionErrorType"
|
||||
)
|
||||
assert cancel.index("await api(") < cancel.index(
|
||||
"_dispatchExtensionTurnLifecycle('turn:cancel'"
|
||||
)
|
||||
assert cancel.index("_setActivePaneIdleIfOwner()") < cancel.index(
|
||||
"_dispatchExtensionTurnLifecycle('turn:cancel'"
|
||||
)
|
||||
assert connection_error.index("_setActivePaneIdleIfOwner()") < connection_error.index(
|
||||
"_dispatchExtensionTurnLifecycle('turn:error'"
|
||||
)
|
||||
|
||||
assert "_dispatchExtensionTurnLifecycle('turn:complete',completedSid" not in done
|
||||
assert "_dispatchExtensionTurnLifecycle(_extensionErrorType,d.session_id" not in application_error
|
||||
assert "_dispatchExtensionTurnLifecycle('turn:cancel',_cancelData.session_id" not in cancel
|
||||
@@ -531,6 +531,7 @@ global.EventSource=FakeEventSource;
|
||||
const attachStart=messagesSrc.indexOf('function attachLiveStream(');
|
||||
const attachEnd=messagesSrc.indexOf('\nfunction transcript(){',attachStart);
|
||||
if(attachStart<0||attachEnd<0) throw new Error('attachLiveStream source boundary not found');
|
||||
eval(extractFunc(messagesSrc,'_dispatchExtensionTurnLifecycle'));
|
||||
eval(messagesSrc.slice(attachStart,attachEnd));
|
||||
attachLiveStream('sid-1','stream-1');
|
||||
const source=FakeEventSource.instances[0];
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Regression: ``loadSession`` must clear stale compression UI state on switch (#6572).
|
||||
|
||||
Context
|
||||
-------
|
||||
The compression UI state (``window._compressionUi`` plus its elapsed timer,
|
||||
composer session-lock, and the "Compressing context" barrier element) is
|
||||
per-session. Before this fix, ``loadSession`` in ``static/sessions.js`` did not
|
||||
clear it when switching sessions, so a compression card belonging to a prior
|
||||
session leaked across the load and surfaced as a phantom "Compressing context"
|
||||
barrier on a fresh session that never triggered compression.
|
||||
|
||||
The fix clears the state near the top of ``loadSession`` — alongside the other
|
||||
per-session resets (``_yoloEnabled=false``, ``stopClarifyPolling``,
|
||||
``hideClarifyCard``) and before transcript loading — by calling the canonical
|
||||
``clearCompressionUi()`` teardown (defined in ``static/ui.js``), with a
|
||||
fail-soft ``window._compressionUi=null`` fallback if the function is not yet
|
||||
defined:
|
||||
|
||||
if(typeof clearCompressionUi==='function') clearCompressionUi();
|
||||
else window._compressionUi=null;
|
||||
|
||||
This is safe for a compression genuinely running on the *target* session:
|
||||
clearing only tears down browser-local UI state; an active automatic
|
||||
compression re-surfaces from the target session's own SSE ``compressing``
|
||||
stream, and a manual compression is re-discovered from server job status when
|
||||
its session loads. Releasing the composer session-lock does not signal or
|
||||
cancel the backend compression.
|
||||
|
||||
Per AGENTS.md the WEBUI suite intentionally avoids a node/jsdom dependency, so
|
||||
this is a source-level grep + brace-balance assertion — the same convention the
|
||||
rest of the JS regression suite uses (e.g.
|
||||
``test_bg_task_complete_loadsession_stream_restart.py``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _read_sessions_js() -> str:
|
||||
return (REPO_ROOT / "static" / "sessions.js").read_text()
|
||||
|
||||
|
||||
def _load_session_body() -> str:
|
||||
"""Return the ``async function loadSession(`` source slice → next top-level
|
||||
``function`` / ``async function`` declaration."""
|
||||
js = _read_sessions_js()
|
||||
start = js.index("async function loadSession(")
|
||||
rest = js[start + 1 :]
|
||||
m = re.search(r"\n(async function |function )", rest)
|
||||
end = start + 1 + (m.start() if m else len(rest))
|
||||
return js[start:end]
|
||||
|
||||
|
||||
def test_loadsession_clears_compression_ui_state():
|
||||
"""loadSession must clear per-session compression UI state on switch."""
|
||||
body = _load_session_body()
|
||||
# The canonical teardown must be invoked, with a fail-soft fallback so a
|
||||
# partial script load cannot leave a dangling barrier/timer/lock.
|
||||
assert "clearCompressionUi()" in body, (
|
||||
"loadSession must call clearCompressionUi() to clear stale per-session "
|
||||
"compression state (#6572 phantom barrier)"
|
||||
)
|
||||
assert re.search(
|
||||
r"typeof\s+clearCompressionUi\s*===\s*['\"]function['\"]", body
|
||||
), "the clearCompressionUi call must be typeof-guarded"
|
||||
assert re.search(
|
||||
r"else\s+window\._compressionUi\s*=\s*null", body
|
||||
), "a fail-soft window._compressionUi=null fallback must guard the guarded call"
|
||||
|
||||
|
||||
def test_loadsession_clears_compression_before_transcript_load():
|
||||
"""The clear must precede transcript rendering so no phantom barrier shows.
|
||||
|
||||
It must sit with the other early per-session resets (near hideClarifyCard),
|
||||
not after renderMessages(), otherwise a stale barrier could paint for a
|
||||
frame on the freshly loaded session.
|
||||
"""
|
||||
body = _load_session_body()
|
||||
clear_idx = body.index("clearCompressionUi")
|
||||
# Anchored to a sibling early reset that is known to run near the top.
|
||||
assert "hideClarifyCard" in body, "expected hideClarifyCard reset in loadSession"
|
||||
clarify_idx = body.index("hideClarifyCard")
|
||||
# The compression clear should be co-located with the other early resets.
|
||||
assert abs(clear_idx - clarify_idx) < 600, (
|
||||
"clearCompressionUi() should sit with the other early per-session resets "
|
||||
"(near hideClarifyCard), before transcript loading"
|
||||
)
|
||||
# And it must come before the success-tail render.
|
||||
render_match = re.search(r"renderMessages\s*\(", body)
|
||||
if render_match:
|
||||
assert clear_idx < render_match.start(), (
|
||||
"clearCompressionUi() must run before renderMessages() so a stale "
|
||||
"compression barrier never paints on the loaded session"
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Direct coverage for api.state_sync.sync_session_title (#6892).
|
||||
|
||||
Codex round-1 SHOULD-FIX: the PR's only test change mocks sync_session_title to a
|
||||
no-op, so the new state.db-writing path had no direct test proving it actually
|
||||
persists a generated title AND preserves a manual rename. These tests exercise
|
||||
the real function against a temporary hermes-agent SessionDB.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_db(tmp_path):
|
||||
hermes_state = pytest.importorskip("hermes_state")
|
||||
SessionDB = hermes_state.SessionDB
|
||||
return SessionDB(db_path=tmp_path / "state.db")
|
||||
|
||||
|
||||
@pytest.mark.requires_agent_modules
|
||||
def test_sync_session_title_persists_generated_title(tmp_path, monkeypatch):
|
||||
"""A generated title is written to state.db so `hermes sessions list` isn't blank."""
|
||||
from api import state_sync
|
||||
|
||||
db = _make_db(tmp_path)
|
||||
try:
|
||||
# Route sync_session_title at the real temp DB (a fresh handle each call,
|
||||
# mirroring production, but pointed at our tmp state.db).
|
||||
monkeypatch.setattr(
|
||||
state_sync,
|
||||
"_get_state_db",
|
||||
lambda profile=None: _make_db(tmp_path),
|
||||
)
|
||||
|
||||
state_sync.sync_session_title("sess-gen", "Generated Title", profile="default")
|
||||
|
||||
assert db.get_session_title("sess-gen") == "Generated Title"
|
||||
# Provenance is the automatic LLM source, not a user rename.
|
||||
assert db.get_session_title_source("sess-gen") == db.TITLE_SOURCE_LLM
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.requires_agent_modules
|
||||
def test_sync_session_title_does_not_overwrite_manual_rename(tmp_path, monkeypatch):
|
||||
"""A user rename (source=user) is never clobbered by a later title sync."""
|
||||
from api import state_sync
|
||||
|
||||
db = _make_db(tmp_path)
|
||||
try:
|
||||
# Seed a manual rename first — this records `user` provenance.
|
||||
db.ensure_session(session_id="sess-manual", source="cli")
|
||||
db.set_session_title("sess-manual", "My Manual Name")
|
||||
assert db.get_session_title_source("sess-manual") == db.TITLE_SOURCE_USER
|
||||
|
||||
monkeypatch.setattr(
|
||||
state_sync,
|
||||
"_get_state_db",
|
||||
lambda profile=None: _make_db(tmp_path),
|
||||
)
|
||||
|
||||
# A background generation now tries to sync an auto title — must be a no-op.
|
||||
state_sync.sync_session_title("sess-manual", "Auto Generated", profile="default")
|
||||
|
||||
assert db.get_session_title("sess-manual") == "My Manual Name"
|
||||
assert db.get_session_title_source("sess-manual") == db.TITLE_SOURCE_USER
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.requires_agent_modules
|
||||
def test_sync_session_title_preserves_existing_source_on_ensure(tmp_path, monkeypatch):
|
||||
"""ensure_session(source='webui') must not clobber an existing cli/gateway row's source."""
|
||||
from api import state_sync
|
||||
|
||||
db = _make_db(tmp_path)
|
||||
try:
|
||||
db.ensure_session(session_id="sess-cli", source="cli")
|
||||
|
||||
monkeypatch.setattr(
|
||||
state_sync,
|
||||
"_get_state_db",
|
||||
lambda profile=None: _make_db(tmp_path),
|
||||
)
|
||||
|
||||
# Sync (which calls ensure_session(source='webui')) on a pre-existing cli row.
|
||||
state_sync.sync_session_title("sess-cli", "Some Title", profile="default")
|
||||
|
||||
# INSERT OR IGNORE semantics: the original source is preserved, the title lands.
|
||||
assert db.get_session_title("sess-cli") == "Some Title"
|
||||
with db._lock:
|
||||
row = db._conn.execute(
|
||||
"SELECT source FROM sessions WHERE id = ?", ("sess-cli",)
|
||||
).fetchone()
|
||||
assert row["source"] == "cli"
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Test for Issue #6935: Ensure agent.run_conversation() does not trip a TypeError
|
||||
when persist_user_timestamp is passed to agent implementations that do not accept it.
|
||||
"""
|
||||
from api.streaming import _supports_kwarg
|
||||
|
||||
|
||||
class LegacyAgentStub:
|
||||
"""Agent stub mimicking older hermes-agent whose run_conversation does NOT accept **kwargs or persist_user_timestamp."""
|
||||
def run_conversation(self, user_message, system_message=None, conversation_history=None, task_id=None, persist_user_message=None):
|
||||
return {"final_response": "ok", "messages": []}
|
||||
|
||||
|
||||
class ModernAgentWithKwargsStub:
|
||||
"""Agent stub whose run_conversation accepts **kwargs."""
|
||||
def run_conversation(self, **kwargs):
|
||||
return {"final_response": "ok", "messages": []}
|
||||
|
||||
|
||||
class ModernAgentWithExplicitParamStub:
|
||||
"""Agent stub whose run_conversation explicitly accepts persist_user_timestamp."""
|
||||
def run_conversation(self, user_message, persist_user_timestamp=None):
|
||||
return {"final_response": "ok", "messages": []}
|
||||
|
||||
|
||||
def test_supports_kwarg_detection():
|
||||
legacy_agent = LegacyAgentStub()
|
||||
modern_kwargs_agent = ModernAgentWithKwargsStub()
|
||||
modern_explicit_agent = ModernAgentWithExplicitParamStub()
|
||||
|
||||
assert _supports_kwarg(legacy_agent.run_conversation, "persist_user_timestamp") is False
|
||||
assert _supports_kwarg(modern_kwargs_agent.run_conversation, "persist_user_timestamp") is True
|
||||
assert _supports_kwarg(modern_explicit_agent.run_conversation, "persist_user_timestamp") is True
|
||||
|
||||
|
||||
def test_legacy_agent_run_conversation_kwargs_filtering():
|
||||
legacy_agent = LegacyAgentStub()
|
||||
kwargs = {
|
||||
"user_message": "Hello",
|
||||
"system_message": "System prompt",
|
||||
"conversation_history": [],
|
||||
"task_id": "test-session-123",
|
||||
"persist_user_message": "Hello",
|
||||
"persist_user_timestamp": 1700000000.0,
|
||||
}
|
||||
|
||||
if not _supports_kwarg(legacy_agent.run_conversation, "persist_user_timestamp"):
|
||||
kwargs.pop("persist_user_timestamp", None)
|
||||
|
||||
# Calling run_conversation with filtered kwargs must not raise TypeError
|
||||
result = legacy_agent.run_conversation(**kwargs)
|
||||
assert result["final_response"] == "ok"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_supports_kwarg_detection()
|
||||
test_legacy_agent_run_conversation_kwargs_filtering()
|
||||
print("All tests passed successfully!")
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Regression tests for state.db title de-dup on collision (#6964).
|
||||
|
||||
Follow-up to #6892 (sync auto-generated session titles to state.db).
|
||||
When two WebUI sessions generate the SAME auto-title, hermes-agent's
|
||||
state.db uniqueness rule makes the second ``set_auto_title_if_empty``
|
||||
raise ValueError, which ``sync_session_title`` used to swallow at debug
|
||||
level -- leaving the second row blank in ``hermes sessions list``.
|
||||
These tests prove the collision path now de-duplicates (suffix variant)
|
||||
instead of leaving the row untitled, and that unique titles are unchanged.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_db(tmp_path):
|
||||
hermes_state = pytest.importorskip("hermes_state")
|
||||
SessionDB = hermes_state.SessionDB
|
||||
return SessionDB(db_path=tmp_path / "state.db")
|
||||
|
||||
|
||||
@pytest.mark.requires_agent_modules
|
||||
def test_sync_session_title_dedups_second_session_on_collision(tmp_path, monkeypatch):
|
||||
"""Two sessions with the same auto-title: the 2nd gets a de-duplicated
|
||||
variant instead of being left blank in state.db."""
|
||||
from api import state_sync
|
||||
|
||||
db = _make_db(tmp_path)
|
||||
try:
|
||||
# Route sync_session_title at the real temp DB (a fresh handle each call,
|
||||
# mirroring production, but pointed at our tmp state.db).
|
||||
monkeypatch.setattr(
|
||||
state_sync,
|
||||
"_get_state_db",
|
||||
lambda profile=None: _make_db(tmp_path),
|
||||
)
|
||||
|
||||
state_sync.sync_session_title("sess-1", "Same Title", profile="default")
|
||||
state_sync.sync_session_title("sess-2", "Same Title", profile="default")
|
||||
|
||||
t1 = db.get_session_title("sess-1")
|
||||
t2 = db.get_session_title("sess-2")
|
||||
assert t1 == "Same Title"
|
||||
# De-duplicated with a lineage suffix -- never left blank.
|
||||
assert t2 == "Same Title #2"
|
||||
assert t1 != t2
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.requires_agent_modules
|
||||
def test_sync_session_title_dedup_handles_numbered_base(tmp_path, monkeypatch):
|
||||
"""A colliding title that already carries a '#N' suffix keeps incrementing
|
||||
deterministically (e.g. 'Same Title #2' -> 'Same Title #3')."""
|
||||
from api import state_sync
|
||||
|
||||
db = _make_db(tmp_path)
|
||||
try:
|
||||
monkeypatch.setattr(
|
||||
state_sync,
|
||||
"_get_state_db",
|
||||
lambda profile=None: _make_db(tmp_path),
|
||||
)
|
||||
|
||||
state_sync.sync_session_title("sess-1", "Same Title #2", profile="default")
|
||||
state_sync.sync_session_title("sess-2", "Same Title #2", profile="default")
|
||||
|
||||
t1 = db.get_session_title("sess-1")
|
||||
t2 = db.get_session_title("sess-2")
|
||||
assert t1 == "Same Title #2"
|
||||
assert t2 == "Same Title #3"
|
||||
assert t1 != t2
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.requires_agent_modules
|
||||
def test_sync_session_title_unique_titles_unchanged(tmp_path, monkeypatch):
|
||||
"""Non-colliding titles keep their exact value (no suffix added)."""
|
||||
from api import state_sync
|
||||
|
||||
db = _make_db(tmp_path)
|
||||
try:
|
||||
monkeypatch.setattr(
|
||||
state_sync,
|
||||
"_get_state_db",
|
||||
lambda profile=None: _make_db(tmp_path),
|
||||
)
|
||||
|
||||
state_sync.sync_session_title("sess-a", "Alpha Title", profile="default")
|
||||
state_sync.sync_session_title("sess-b", "Beta Title", profile="default")
|
||||
|
||||
assert db.get_session_title("sess-a") == "Alpha Title"
|
||||
assert db.get_session_title("sess-b") == "Beta Title"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.requires_agent_modules
|
||||
def test_sync_session_title_dedup_retry_never_clobbers_manual_rename(tmp_path, monkeypatch):
|
||||
"""The de-dup retry still respects title provenance: if the second session
|
||||
was manually renamed (``user`` source) before the colliding auto-title sync
|
||||
runs, the retry with the ``#2`` variant must NOT overwrite the user's name.
|
||||
|
||||
Codex #6966 SHOULD-FIX: covers the manual-rename-during-retry interleaving
|
||||
(the existing tests cover manual-rename-before-sync only indirectly)."""
|
||||
from api import state_sync
|
||||
|
||||
db = _make_db(tmp_path)
|
||||
try:
|
||||
monkeypatch.setattr(
|
||||
state_sync,
|
||||
"_get_state_db",
|
||||
lambda profile=None: _make_db(tmp_path),
|
||||
)
|
||||
|
||||
# sess-1 takes "Same Title" via the normal auto-title sync.
|
||||
state_sync.sync_session_title("sess-1", "Same Title", profile="default")
|
||||
|
||||
# sess-2 is manually renamed by the user (higher-ranked `user` source)
|
||||
# BEFORE the colliding auto-title sync fires for it.
|
||||
db.ensure_session(session_id="sess-2", source="webui")
|
||||
db.set_session_title("sess-2", "My Own Name")
|
||||
|
||||
# Now the colliding auto-title sync runs for sess-2. It collides on
|
||||
# "Same Title", derives "Same Title #2", and retries -- but the retry
|
||||
# goes through the same provenance rank guard, so the user's manual
|
||||
# name must survive.
|
||||
state_sync.sync_session_title("sess-2", "Same Title", profile="default")
|
||||
|
||||
assert db.get_session_title("sess-1") == "Same Title"
|
||||
# The manual (user-source) rename is preserved -- never clobbered by
|
||||
# either the initial auto-title or the de-dup retry variant.
|
||||
assert db.get_session_title("sess-2") == "My Own Name"
|
||||
finally:
|
||||
db.close()
|
||||
@@ -63,7 +63,9 @@ def test_skill_mutations_invalidate_slash_skill_caches():
|
||||
def test_builtin_commands_take_precedence_over_skill_slug_collisions():
|
||||
assert "_getReservedSlashCommandSlugs" in COMMANDS_JS
|
||||
assert "if(_getReservedSlashCommandSlugs().has(slug)) return null;" in COMMANDS_JS
|
||||
assert "if(!skill.name.startsWith(q)||seen.has(skill.name)||reserved.has(skill.name))continue;" in COMMANDS_JS
|
||||
command_matching = _function_body(COMMANDS_JS, "getMatchingCommands")
|
||||
assert "reserved.has(name)" in command_matching
|
||||
assert "!name.includes(q)&&!description.includes(q)" in command_matching
|
||||
|
||||
|
||||
def test_bundle_entries_merge_before_plain_skill_entries():
|
||||
|
||||
Reference in new issue
Block a user