Merge PR #7749 (contributor head bbca954c5c)
This commit is contained in:
commit
e1c707c6d4
17 files changed
+760
-9
No files matched your search
@@ -12,6 +12,7 @@ on:
|
||||
- 'api/**.py'
|
||||
- 'server.py'
|
||||
- 'tests/browser_conversation_lifecycle.py'
|
||||
- 'tests/sidebar_terminal_barrier_server.py'
|
||||
- 'tests/browser_historical_transcript_hydration.py'
|
||||
- 'tests/browser_reconnect_scene_redraw.py'
|
||||
- 'requirements*.txt'
|
||||
@@ -24,6 +25,7 @@ on:
|
||||
- 'api/**.py'
|
||||
- 'server.py'
|
||||
- 'tests/browser_conversation_lifecycle.py'
|
||||
- 'tests/sidebar_terminal_barrier_server.py'
|
||||
- 'tests/browser_historical_transcript_hydration.py'
|
||||
- 'tests/browser_reconnect_scene_redraw.py'
|
||||
- 'requirements*.txt'
|
||||
@@ -86,6 +88,14 @@ jobs:
|
||||
LIFECYCLE_TEST_BITE: settle-worklog-frame-proof
|
||||
run: python tests/browser_conversation_lifecycle.py
|
||||
|
||||
- name: Recover a missing terminal frame
|
||||
if: matrix.scenario == 'normal'
|
||||
env:
|
||||
LIFECYCLE_ARTIFACT_DIR: lifecycle-artifacts/missing-terminal
|
||||
LIFECYCLE_SCENARIO: normal
|
||||
LIFECYCLE_TEST_BITE: sidebar-idle-missing-terminal
|
||||
run: python tests/browser_conversation_lifecycle.py
|
||||
|
||||
- name: Upload failure artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Sidebar idle / terminal handoff evidence
|
||||
|
||||
## Failure and scope
|
||||
|
||||
The independent `/api/sessions` response can report idle before the chat SSE's
|
||||
terminal frame reaches the page. Three sidebar paths were retiring chat state:
|
||||
idle reconciliation, INFLIGHT purging, and optimistic-row retirement. The idle
|
||||
path clears the Worklog and active stream ID; the subsequent `done` is rejected
|
||||
as stale, so no settled Anchor scene is persisted.
|
||||
|
||||
A real-HTTP barrier reproduces this on both published PR head
|
||||
`75c8ff8d2a991f22389e3e4263ee03fb8d8f67a0` and frozen upstream
|
||||
`b09fded7b8c98b549a49aaaece3721015337d307`. Applying only the `sessions.js`
|
||||
product fix to the frozen upstream also passes the identical oracle: this is a
|
||||
shared lifecycle defect, not evidence that artifact replay introduced it. The
|
||||
historical hosted run lacks this instrumentation, so its exact interleaving
|
||||
cannot be proved retrospectively; its empty-surface/no-persistence signature
|
||||
matches the controlled reproduction.
|
||||
|
||||
## Reproduction
|
||||
|
||||
```sh
|
||||
LIFECYCLE_SCENARIO=normal LIFECYCLE_TEST_BITE=settle-worklog-frame-proof \
|
||||
.venv/bin/python tests/browser_conversation_lifecycle.py
|
||||
./scripts/test.sh tests/test_sidebar_terminal_handoff.py -q
|
||||
```
|
||||
|
||||
The existing frame proof now uses a test-only server wrapper to hold the actual
|
||||
`done` HTTP write. The real worker finishes; the real browser receives an idle
|
||||
sidebar response through `renderSessionList()` while its native EventSource
|
||||
remains open. The test verifies pane, INFLIGHT and registry ownership, then
|
||||
releases `done` and retains all existing painted-frame, final-answer, semantic
|
||||
activity and hard-reload assertions. The fixture always releases/cleans up and
|
||||
has a bounded wait. Normal production server startup is unchanged.
|
||||
|
||||
No renderer, terminal payload or persistence response is fabricated. Runtime
|
||||
events come from the existing deterministic localhost Gateway fixture, not a
|
||||
live model/provider. All state and files are disposable. These are browser
|
||||
viewport checks, not physical-device acceptance.
|
||||
|
||||
## Before / after
|
||||
|
||||
Each screenshot is taken after the idle sidebar response, before the terminal
|
||||
HTTP frame is released. The exact published source fails at both widths. The
|
||||
patched source preserves three live activity rows and then passes completion
|
||||
and hard reload. `results.json` records the predicate results without machine
|
||||
paths or credentials.
|
||||
|
||||
| Viewport | Before | After |
|
||||
| --- | --- | --- |
|
||||
| 1440 x 900 | [Empty surface](before-1440.png) | [Owned handoff retained](after-1440.png) |
|
||||
| 390 x 844 | [Empty surface](before-390.png) | [Owned handoff retained](after-390.png) |
|
||||
|
||||
The fix only defers these sidebar cleanups for the current pane's exact OPEN
|
||||
chat transport. Missing, CONNECTING, CLOSED and mismatched sources still take
|
||||
the existing stale recovery path.
|
||||
|
||||
## Missing terminal frame: bounded recovery
|
||||
|
||||
The OPEN-only guard at `85f1c2d34b62b0ade85f51ed2cf8c1cf56f5ab9e` could wait
|
||||
forever when the terminal frame never arrived. The follow-up keeps the live
|
||||
handoff for 1.5 seconds, then probes the exact stream through
|
||||
`/api/chat/stream/status` with an eight-second timeout and no retries.
|
||||
Repeated sidebar hints cannot restart the deadline or create concurrent
|
||||
requests. `active:true` keeps the OPEN owner intact even when the persisted
|
||||
session fields already look idle; only explicit runtime inactivity permits the
|
||||
canonical session restore. A failed or malformed runtime probe uses the
|
||||
existing interrupted-stream path rather than claiming successful settlement.
|
||||
Normal terminal delivery, transport replacement/close, navigation, and a newer
|
||||
optimistic turn invalidate the old request. Already-running stream-end recovery
|
||||
remains the sole recovery owner.
|
||||
|
||||
```sh
|
||||
LIFECYCLE_SCENARIO=normal LIFECYCLE_TEST_BITE=sidebar-idle-missing-terminal \
|
||||
.venv/bin/python tests/browser_conversation_lifecycle.py
|
||||
./scripts/test.sh tests/test_sidebar_idle_stream_recovery.py -q
|
||||
```
|
||||
|
||||
The new browser row keeps the real terminal HTTP barrier closed until cleanup;
|
||||
the client must recover without receiving `done` or `stream_end`. Published
|
||||
`85f1c2d34b` fails the 11-second completion bound with `S.busy` still true.
|
||||
The patched implementation restores the final answer and passes the unchanged
|
||||
painted-frame, semantic activity, persistence and hard-reload checks before the
|
||||
barrier is released. The cloud workflow runs both delayed- and missing-terminal
|
||||
rows. No live provider or production state is used.
|
||||
|
||||
A production Gateway regression also blocks post-turn goal evaluation after
|
||||
the success transcript has already cleared `active_stream_id` and pending
|
||||
fields. During that interval the worker and exact entry in `STREAMS` remain
|
||||
live and no `done` event has been emitted, proving why persisted session flags
|
||||
cannot authorize sidebar settlement on their own.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,66 @@
|
||||
[
|
||||
{
|
||||
"mode": "before",
|
||||
"viewport": [
|
||||
1440,
|
||||
900
|
||||
],
|
||||
"exit": 1,
|
||||
"observation": {
|
||||
"serverIdle": true,
|
||||
"paneRetained": false,
|
||||
"inflightRetained": false,
|
||||
"sourceRetained": true,
|
||||
"registryRetained": true,
|
||||
"liveRows": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "before",
|
||||
"viewport": [
|
||||
390,
|
||||
844
|
||||
],
|
||||
"exit": 1,
|
||||
"observation": {
|
||||
"serverIdle": true,
|
||||
"paneRetained": false,
|
||||
"inflightRetained": false,
|
||||
"sourceRetained": true,
|
||||
"registryRetained": true,
|
||||
"liveRows": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "after",
|
||||
"viewport": [
|
||||
1440,
|
||||
900
|
||||
],
|
||||
"exit": 0,
|
||||
"observation": {
|
||||
"serverIdle": true,
|
||||
"paneRetained": true,
|
||||
"inflightRetained": true,
|
||||
"sourceRetained": true,
|
||||
"registryRetained": true,
|
||||
"liveRows": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "after",
|
||||
"viewport": [
|
||||
390,
|
||||
844
|
||||
],
|
||||
"exit": 0,
|
||||
"observation": {
|
||||
"serverIdle": true,
|
||||
"paneRetained": true,
|
||||
"inflightRetained": true,
|
||||
"sourceRetained": true,
|
||||
"registryRetained": true,
|
||||
"liveRows": 3
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -470,6 +470,19 @@ Expected behavior:
|
||||
|
||||
- A session row's running indicator should reflect a real active run or a
|
||||
clearly restorable state, not stale persisted metadata alone.
|
||||
- An idle sidebar response does not prove that the browser has consumed the
|
||||
terminal chat frame. While the current pane still owns its exact OPEN chat
|
||||
transport, list reconciliation and cache purging must preserve that handoff.
|
||||
Missing, connecting, closed, or mismatched transports keep the normal stale
|
||||
state recovery path; a cached stream ID or busy flag is insufficient.
|
||||
OPEN is not proof that a terminal frame will arrive: an idle hint must also
|
||||
schedule bounded recovery without erasing the live scene. Before reading a
|
||||
settled session snapshot, that recovery must check the exact stream's runtime
|
||||
status. A runtime-active stream retains ownership even when persisted
|
||||
`active_stream_id` and pending fields are already clear; only explicit
|
||||
runtime inactivity permits canonical snapshot settlement. Probe failure is a
|
||||
degraded/interrupted state, not evidence of successful completion. A newer
|
||||
turn, replacement source, or received terminal event invalidates the request.
|
||||
- Background completion, cancellation, or failure should be represented without
|
||||
stealing the visible pane from the user.
|
||||
- Session switching should not erase pending live context, in-flight snapshots,
|
||||
|
||||
+75
-1
@@ -2089,6 +2089,7 @@ function closeLiveStream(sessionId, streamId, source){
|
||||
if(!live) return;
|
||||
if(streamId&&live.streamId!==streamId) return;
|
||||
if(source&&live.source!==source) return;
|
||||
if(typeof live.cancelIdleRecovery==='function') live.cancelIdleRecovery();
|
||||
// Snapshot the current live-turn DOM BEFORE tearing the stream down. The
|
||||
// per-event snapshot (snapshotLiveTurn) only fires on content/tool_complete
|
||||
// SSE events, so switching away during a quiet window (mid tool-exec, silent
|
||||
@@ -5745,12 +5746,82 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
return true;
|
||||
}
|
||||
|
||||
function _bindSidebarIdleRecovery(live){
|
||||
let pending=null;
|
||||
live.cancelIdleRecovery=()=>{
|
||||
if(pending&&pending.timer) clearTimeout(pending.timer);
|
||||
pending=null;
|
||||
};
|
||||
live.recoverFromSidebarIdle=()=>{
|
||||
if(pending||_streamFinalized||_terminalStateReached||_pendingStreamEndRecovery) return;
|
||||
const request={inflight:INFLIGHT[activeSid],timer:null};
|
||||
const isCurrent=()=>pending===request&&!_streamFinalized&&!_terminalStateReached&&
|
||||
LIVE_STREAMS[activeSid]===live&&live.source.readyState===1&&
|
||||
S.session&&S.session.session_id===activeSid&&S.activeStreamId===streamId&&
|
||||
INFLIGHT[activeSid]===request.inflight&&
|
||||
!(typeof _sendInProgress!=='undefined'&&_sendInProgress&&activeSid===_sendInProgressSid);
|
||||
pending=request;
|
||||
// Give the independently delivered terminal frame a short handoff window,
|
||||
// then use canonical session recovery even if the transport stays OPEN.
|
||||
// One ticket spans both timer and request; list refreshes cannot extend it.
|
||||
request.timer=setTimeout(async()=>{
|
||||
request.timer=null;
|
||||
if(!isCurrent()){
|
||||
if(pending===request) live.cancelIdleRecovery();
|
||||
return;
|
||||
}
|
||||
try{
|
||||
let runtimeStatus=null;
|
||||
try{
|
||||
runtimeStatus=await api(
|
||||
`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`,
|
||||
{timeoutMs:8000,retries:0,timeoutToast:false}
|
||||
);
|
||||
}catch(_){
|
||||
// Runtime ownership is authoritative here. If the probe itself
|
||||
// fails, do not infer completion from already-cleared session
|
||||
// fields; surface the existing interrupted-stream path instead.
|
||||
if(isCurrent()) _handleStreamError(live.source);
|
||||
return;
|
||||
}
|
||||
if(!isCurrent()) return;
|
||||
if(!runtimeStatus||typeof runtimeStatus.active!=='boolean'){
|
||||
_handleStreamError(live.source);
|
||||
return;
|
||||
}
|
||||
// Gateway success writeback clears persisted active/pending fields
|
||||
// before post-turn goal evaluation and terminal event emission.
|
||||
// STREAMS-backed status therefore owns this decision: an exact active
|
||||
// runtime must keep its OPEN browser handoff even if the sidebar row
|
||||
// already looks idle.
|
||||
if(runtimeStatus.active) return;
|
||||
const status=await _restoreSettledSession(live.source,{
|
||||
status:true,
|
||||
isCurrent,
|
||||
requestOptions:{timeoutMs:8000,retries:0,timeoutToast:false},
|
||||
preserveVisibleOnShorterTerminalSnapshot:true,
|
||||
});
|
||||
// A stale sidebar response is not permission to terminate a worker
|
||||
// which the authoritative session snapshot still reports as active.
|
||||
if(isCurrent()&&status!=='restored'&&status!=='active') _handleStreamError(live.source);
|
||||
}finally{
|
||||
if(pending===request) live.cancelIdleRecovery();
|
||||
}
|
||||
},1500);
|
||||
};
|
||||
for(const event of ['done','cancel','apperror','stream_end','error']){
|
||||
live.source.addEventListener(event,live.cancelIdleRecovery);
|
||||
}
|
||||
}
|
||||
|
||||
function _wireSSE(source){
|
||||
const existingLive=LIVE_STREAMS[activeSid];
|
||||
if(existingLive&&existingLive.source&&existingLive.source!==source){
|
||||
if(typeof existingLive.cancelIdleRecovery==='function') existingLive.cancelIdleRecovery();
|
||||
try{if(existingLive.source.readyState!==2)existingLive.source.close();}catch(_){ }
|
||||
}
|
||||
LIVE_STREAMS[activeSid]={streamId,source};
|
||||
_bindSidebarIdleRecovery(LIVE_STREAMS[activeSid]);
|
||||
|
||||
// Note on #631 Bug B: the original PR description stated the server
|
||||
// "replays buffered token events" on reconnect, and proposed resetting
|
||||
@@ -7024,13 +7095,16 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
|
||||
async function _restoreSettledSession(source, options=null){
|
||||
const returnStatus=!!(options&&options.status);
|
||||
const isCurrent=options&&typeof options.isCurrent==='function'?options.isCurrent:null;
|
||||
if(isCurrent&&!isCurrent()) return returnStatus?'stale':false;
|
||||
const preserveVisibleOnShorterTerminalSnapshot=!!(options&&options.preserveVisibleOnShorterTerminalSnapshot);
|
||||
if(_isActiveSession() && S.activeStreamId!==streamId){
|
||||
_closeSource(source);
|
||||
return returnStatus?'stale':false;
|
||||
}
|
||||
try{
|
||||
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`);
|
||||
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`,options&&options.requestOptions||{});
|
||||
if(isCurrent&&!isCurrent()) return returnStatus?'stale':false;
|
||||
// Opus #2852 race-fix: if a late `done` event ran the finalize path while
|
||||
// we were awaiting the network roundtrip, bail out — done already settled.
|
||||
if(_streamFinalized) return returnStatus?'restored':true;
|
||||
|
||||
+20
-1
@@ -852,6 +852,14 @@ function _reconcileActiveSessionIdleStateFromList(serverRows) {
|
||||
const serverRow=serverRows.find(s=>s&&s.session_id===sid);
|
||||
if (!serverRow) return false;
|
||||
if (!_isServerIdleSessionRow(serverRow)) return false;
|
||||
// Sidebar idle metadata can beat the terminal frame on the independent chat
|
||||
// SSE. Let its exact OPEN transport finish the Anchor handoff; orphaned or
|
||||
// disconnected streams still use the existing idle recovery below.
|
||||
if (_hasOwnedOpenLiveStream(sid)) {
|
||||
const live=LIVE_STREAMS[sid];
|
||||
if(typeof live.recoverFromSidebarIdle==='function') live.recoverFromSidebarIdle();
|
||||
return false;
|
||||
}
|
||||
let changed=false;
|
||||
if (S.busy) { S.busy=false; changed=true; }
|
||||
if (S.activeStreamId) { S.activeStreamId=null; changed=true; }
|
||||
@@ -940,6 +948,8 @@ function _purgeStaleInflightEntries() {
|
||||
if (typeof _sendInProgress !== 'undefined' && _sendInProgress && sid === _sendInProgressSid) {
|
||||
continue;
|
||||
}
|
||||
// The sidebar render must not purge what the idle reconciler preserved.
|
||||
if (_hasOwnedOpenLiveStream(sid)) continue;
|
||||
if (!sessionsById.has(sid)) {
|
||||
const knownSource = sourceById ? sourceById.get(sid) : null;
|
||||
if (currentSidebarSource && (!knownSource || knownSource !== currentSidebarSource)) {
|
||||
@@ -961,6 +971,14 @@ function _purgeStaleInflightEntries() {
|
||||
}
|
||||
}
|
||||
|
||||
function _hasOwnedOpenLiveStream(sid) {
|
||||
if (typeof S === 'undefined' || !S || !S.session || S.session.session_id !== sid || !S.activeStreamId) return false;
|
||||
const live = typeof LIVE_STREAMS === 'object' && LIVE_STREAMS ? LIVE_STREAMS[sid] : null;
|
||||
// readyState 1 is EventSource.OPEN. A cached stream ID or busy flag alone
|
||||
// is not ownership and must never disable recovery for a stuck indicator.
|
||||
return Boolean(live && live.streamId === S.activeStreamId && live.source && live.source.readyState === 1);
|
||||
}
|
||||
|
||||
function _rememberSessionListSource(s, sid = null, allowScopeFallback = true) {
|
||||
const resolvedSid = sid || (s && s.session_id);
|
||||
if (!resolvedSid) return;
|
||||
@@ -5278,7 +5296,8 @@ function _shouldKeepLocalOnlyOptimisticSessionRow(local){
|
||||
function _dropStaleOptimisticSessionRow(sid){
|
||||
if(!sid) return;
|
||||
if(typeof _rememberSessionListSource==='function') _rememberSessionListSource(null, sid, false);
|
||||
if(INFLIGHT&&INFLIGHT[sid]){
|
||||
// Retiring sidebar optimism must not retire the independent chat owner.
|
||||
if(INFLIGHT&&INFLIGHT[sid]&&!_hasOwnedOpenLiveStream(sid)){
|
||||
delete INFLIGHT[sid];
|
||||
if(typeof clearInflightState==='function') clearInflightState(sid);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ SCENARIO = os.environ.get("LIFECYCLE_SCENARIO", "normal").strip() or "normal"
|
||||
TOOL_NAME = "read_file"
|
||||
TOOL_ID = "lifecycle-tool-1"
|
||||
TEST_BITE = os.environ.get("LIFECYCLE_TEST_BITE", "").strip()
|
||||
_FRAME_PROOF_BITES = {"settle-worklog-frame-proof", "sidebar-idle-missing-terminal"}
|
||||
GATEWAY_ACTIVITY_TIMEOUT = 60.0
|
||||
ANCHOR_SCENE_PERSIST_TIMEOUT = 60.0
|
||||
ANCHOR_SCENE_PROJECTION_TIMEOUT = 10_000
|
||||
@@ -267,8 +268,17 @@ def _start_webui_server(repo_root: Path, env: dict, artifact_dir: Path):
|
||||
suffix = "" if attempts == 1 else f"-attempt-{attempt + 1}"
|
||||
log_path = artifact_dir / f"server{suffix}.log"
|
||||
log = log_path.open("w", encoding="utf-8")
|
||||
command = [sys.executable, str(repo_root / "server.py")]
|
||||
barrier_dir = env.get("HERMES_TEST_TERMINAL_BARRIER_DIR")
|
||||
if barrier_dir:
|
||||
command = [
|
||||
sys.executable,
|
||||
str(repo_root / "tests" / "sidebar_terminal_barrier_server.py"),
|
||||
str(repo_root),
|
||||
barrier_dir,
|
||||
]
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, str(repo_root / "server.py")],
|
||||
command,
|
||||
cwd=repo_root,
|
||||
env=run_env,
|
||||
stdout=log,
|
||||
@@ -286,6 +296,50 @@ def _start_webui_server(repo_root: Path, env: dict, artifact_dir: Path):
|
||||
raise RuntimeError(f"WebUI server did not become healthy{detail}")
|
||||
|
||||
|
||||
def _exercise_sidebar_terminal_handoff(page, barrier_dir: Path, artifact_dir: Path):
|
||||
"""Order the real idle-list response before the real terminal SSE write."""
|
||||
deadline = time.monotonic() + 10
|
||||
while not (barrier_dir / "ready").exists():
|
||||
if time.monotonic() >= deadline:
|
||||
raise AssertionError("terminal HTTP barrier was not reached")
|
||||
page.wait_for_timeout(10)
|
||||
observation = page.evaluate(
|
||||
"""async () => {
|
||||
const sid = S.session.session_id;
|
||||
const stream = S.activeStreamId;
|
||||
const inflight = INFLIGHT[sid];
|
||||
const source = LIVE_STREAMS[sid] && LIVE_STREAMS[sid].source;
|
||||
const registries = window._liveAnchorRegistries;
|
||||
const registry = registries && registries.get(stream);
|
||||
await renderSessionList();
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const live = document.querySelector('#liveAssistantTurn');
|
||||
return {
|
||||
serverIdle: _allSessions.some(row => row.session_id === sid && _isServerIdleSessionRow(row)),
|
||||
paneRetained: S.busy === true && S.activeStreamId === stream,
|
||||
inflightRetained: !!inflight && INFLIGHT[sid] === inflight,
|
||||
sourceRetained: !!source && source.readyState === EventSource.OPEN &&
|
||||
LIVE_STREAMS[sid] && LIVE_STREAMS[sid].source === source,
|
||||
registryRetained: !!registry && registries.get(stream) === registry,
|
||||
liveRows: live ? live.querySelectorAll('[data-anchor-scene-row="1"]').length : 0,
|
||||
};
|
||||
}"""
|
||||
)
|
||||
(artifact_dir / "sidebar-before-terminal.json").write_text(
|
||||
json.dumps(observation, indent=2), encoding="utf-8"
|
||||
)
|
||||
page.screenshot(path=str(artifact_dir / "sidebar-before-terminal.png"), full_page=True)
|
||||
for invariant in ("serverIdle", "paneRetained", "inflightRetained", "sourceRetained", "registryRetained"):
|
||||
assert observation[invariant] is True, {
|
||||
"message": "idle sidebar response revoked the live terminal handoff",
|
||||
"invariant": invariant,
|
||||
"observation": observation,
|
||||
}
|
||||
assert observation["liveRows"] > 0, observation
|
||||
if TEST_BITE != "sidebar-idle-missing-terminal":
|
||||
(barrier_dir / "release").touch()
|
||||
|
||||
|
||||
class DeterministicGateway:
|
||||
"""A localhost-only Gateway Runs server with test-controlled phase gates."""
|
||||
|
||||
@@ -733,11 +787,12 @@ def main() -> int:
|
||||
"drop-anchor-persistence",
|
||||
"drop-terminal-anchor-row",
|
||||
"settle-worklog-frame-proof",
|
||||
"sidebar-idle-missing-terminal",
|
||||
}:
|
||||
raise ValueError(
|
||||
f"Unsupported LIFECYCLE_TEST_BITE {TEST_BITE!r}; "
|
||||
"expected one of '', 'drop-anchor-persistence', "
|
||||
"'drop-terminal-anchor-row', 'settle-worklog-frame-proof'"
|
||||
"'drop-terminal-anchor-row', 'settle-worklog-frame-proof', 'sidebar-idle-missing-terminal'"
|
||||
)
|
||||
if TEST_BITE == "drop-terminal-anchor-row" and scenario != "terminal-error":
|
||||
raise ValueError(
|
||||
@@ -782,6 +837,12 @@ def main() -> int:
|
||||
"NO_PROXY": "127.0.0.1,localhost",
|
||||
"no_proxy": "127.0.0.1,localhost",
|
||||
})
|
||||
env.pop("HERMES_TEST_TERMINAL_BARRIER_DIR", None)
|
||||
terminal_barrier_dir = None
|
||||
if TEST_BITE in _FRAME_PROOF_BITES and scenario == "normal":
|
||||
terminal_barrier_dir = state_dir / "terminal-barrier"
|
||||
terminal_barrier_dir.mkdir()
|
||||
env["HERMES_TEST_TERMINAL_BARRIER_DIR"] = str(terminal_barrier_dir)
|
||||
proc = None
|
||||
log = None
|
||||
log_path = None
|
||||
@@ -873,7 +934,7 @@ def main() -> int:
|
||||
# must never expose an expanded empty Worklog shell or an activity-less
|
||||
# gap. Sample after rAF callbacks for a frame have drained; DOM mutations
|
||||
# within one task or earlier rAF callbacks are not user-visible frames.
|
||||
if TEST_BITE == "settle-worklog-frame-proof":
|
||||
if TEST_BITE in _FRAME_PROOF_BITES:
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
const samples = [];
|
||||
@@ -941,12 +1002,17 @@ def main() -> int:
|
||||
timeout=10000,
|
||||
)
|
||||
gateway.release_terminal.set()
|
||||
if terminal_barrier_dir is not None:
|
||||
_exercise_sidebar_terminal_handoff(page, terminal_barrier_dir, artifact_dir)
|
||||
page.wait_for_function(
|
||||
"""text => typeof S !== 'undefined' && S.busy === false && !S.activeStreamId &&
|
||||
((document.querySelector('#msgInner') || {}).innerText || '').includes(text)""",
|
||||
arg=FINAL_TEXT,
|
||||
timeout=15000,
|
||||
timeout=11000 if TEST_BITE == "sidebar-idle-missing-terminal" else 15000,
|
||||
)
|
||||
if TEST_BITE == "sidebar-idle-missing-terminal":
|
||||
assert not (terminal_barrier_dir / "release").exists(), "terminal frame was unexpectedly released"
|
||||
print("OK missing terminal: canonical snapshot settled the still-OPEN transport before terminal delivery")
|
||||
else:
|
||||
gateway.release_terminal.set()
|
||||
page.wait_for_function(
|
||||
@@ -958,7 +1024,7 @@ def main() -> int:
|
||||
)
|
||||
session_id = page.evaluate("S.session && S.session.session_id")
|
||||
assert session_id, "active session id missing after settlement"
|
||||
if TEST_BITE == "settle-worklog-frame-proof":
|
||||
if TEST_BITE in _FRAME_PROOF_BITES:
|
||||
page.wait_for_timeout(100)
|
||||
settle_proof = page.evaluate(
|
||||
"window.__lifecycleSettleFrameProof && window.__lifecycleSettleFrameProof.stop()"
|
||||
@@ -1176,6 +1242,8 @@ def main() -> int:
|
||||
exit_code = 1
|
||||
return 1
|
||||
finally:
|
||||
if terminal_barrier_dir is not None:
|
||||
(terminal_barrier_dir / "release").touch()
|
||||
gateway.close()
|
||||
if browser is not None:
|
||||
browser.close()
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Test-only server for the public settle-frame gate's cross-channel race.
|
||||
|
||||
Hold the actual HTTP terminal frame after the worker has finished. The browser
|
||||
keeps its native, open EventSource while /api/sessions independently reports idle.
|
||||
No browser state, renderer, persistence result, or terminal payload is mocked.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
raise SystemExit("expected repository root and isolated barrier directory")
|
||||
repo_root, barrier_dir = (Path(value) for value in sys.argv[1:])
|
||||
sys.path.insert(0, str(repo_root))
|
||||
import server
|
||||
from api import routes
|
||||
|
||||
ready = barrier_dir / "ready"
|
||||
release = barrier_dir / "release"
|
||||
original = routes._sse_with_id
|
||||
|
||||
def terminal_barrier(handler, event, data, event_id=None):
|
||||
if event == "done" and handler.path.startswith("/api/chat/stream?"):
|
||||
ready.touch()
|
||||
deadline = time.monotonic() + 15
|
||||
while not release.exists():
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("test did not release the terminal HTTP frame")
|
||||
time.sleep(0.005)
|
||||
return original(handler, event, data, event_id)
|
||||
|
||||
routes._sse_with_id = terminal_barrier
|
||||
server.main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -183,6 +183,7 @@ def test_optimistic_merge_preserves_server_running_state_when_local_cache_is_sta
|
||||
optimistic_body = _function_body(SESSIONS_SRC, "function _isOptimisticFirstTurnSessionRow(")
|
||||
keep_body = _function_body(SESSIONS_SRC, "function _shouldKeepLocalOnlyOptimisticSessionRow(")
|
||||
drop_body = _function_body(SESSIONS_SRC, "function _dropStaleOptimisticSessionRow(")
|
||||
owner_body = _function_body(SESSIONS_SRC, "function _hasOwnedOpenLiveStream(")
|
||||
merge_body = _function_body(SESSIONS_SRC, "function _mergeOptimisticFirstTurnSessions(")
|
||||
|
||||
script = f"""
|
||||
@@ -206,6 +207,7 @@ function _isServerIdleSessionRow(s) {{{helper_body}}}
|
||||
function _isSessionLocallyStreaming(s) {{{local_body}}}
|
||||
function _isOptimisticFirstTurnSessionRow(s) {{{optimistic_body}}}
|
||||
function _shouldKeepLocalOnlyOptimisticSessionRow(local) {{{keep_body}}}
|
||||
function _hasOwnedOpenLiveStream(sid) {{{owner_body}}}
|
||||
function _dropStaleOptimisticSessionRow(sid) {{{drop_body}}}
|
||||
function _mergeOptimisticFirstTurnSessions(fetchedSessions) {{{merge_body}}}
|
||||
const merged = _mergeOptimisticFirstTurnSessions([{{
|
||||
|
||||
@@ -536,7 +536,7 @@ def test_source_filtered_cache_preserves_hidden_bucket_runtime_state():
|
||||
remember_source_fn = _extract_function(src, "_rememberSessionListSource")
|
||||
remember_streaming_fn = _extract_function(src, "_rememberRenderedStreamingState")
|
||||
remember_snapshot_fn = _extract_function(src, "_rememberRenderedSessionSnapshot")
|
||||
purge_fn = _extract_function(src, "_purgeStaleInflightEntries")
|
||||
purge_fn = _extract_function(src, "_hasOwnedOpenLiveStream") + "\n" + _extract_function(src, "_purgeStaleInflightEntries")
|
||||
mark_fn = _extract_function(src, "_markPollingCompletionUnreadTransitions")
|
||||
script = f"""
|
||||
global._allSessions = [{{
|
||||
@@ -654,7 +654,7 @@ def test_session_list_response_omits_bucket_counts_when_missing(monkeypatch):
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_scope_mismatch_error_path_respects_sidebar_source():
|
||||
src = SESSIONS_JS.read_text(encoding="utf-8")
|
||||
purge_fn = _extract_function(src, "_purgeStaleInflightEntries")
|
||||
purge_fn = _extract_function(src, "_hasOwnedOpenLiveStream") + "\n" + _extract_function(src, "_purgeStaleInflightEntries")
|
||||
clear_fn = _extract_function(src, "_clearSessionSourceTabCounts")
|
||||
requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource")
|
||||
exclude_hidden_fn = _extract_function(src, "_sessionListExcludeHiddenEnabled")
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""A sidebar-idle hint must retain the live handoff without waiting forever.
|
||||
|
||||
Drive the production scheduling helper with a fake clock and a deferred snapshot
|
||||
boundary. The browser lifecycle gate separately covers the real HTTP restore.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.test_issue2454_active_session_spinner import _function_body
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _run(case):
|
||||
sessions = (ROOT / "static/sessions.js").read_text(encoding="utf-8")
|
||||
messages = (ROOT / "static/messages.js").read_text(encoding="utf-8")
|
||||
functions = "\n".join(
|
||||
f"function {name}({params}) {{" + _function_body(sessions, f"function {name}(") + "}"
|
||||
for name, params in [
|
||||
("_isServerIdleSessionRow", "s"),
|
||||
("_hasOwnedOpenLiveStream", "sid"),
|
||||
("_reconcileActiveSessionIdleStateFromList", "serverRows"),
|
||||
]
|
||||
)
|
||||
if "function _bindSidebarIdleRecovery(" in messages:
|
||||
functions += "\nfunction _bindSidebarIdleRecovery(live) {" + _function_body(
|
||||
messages, "function _bindSidebarIdleRecovery("
|
||||
) + "}"
|
||||
script = r"""
|
||||
const assert=require('assert');
|
||||
const activeSid='current',streamId='turn-1';
|
||||
let _terminalStateReached=false,_streamFinalized=false;
|
||||
let _pendingStreamEndRecovery=CASE==='stream-end-pending';
|
||||
const S={session:{session_id:activeSid},busy:true,activeStreamId:streamId};
|
||||
const INFLIGHT={current:{streamId}},original=INFLIGHT.current;
|
||||
const timers=[],listeners={},calls=[],statusCalls=[],failures=[];
|
||||
function setTimeout(fn,delay){const t={fn,delay,cancelled:false};timers.push(t);return t;}
|
||||
function clearTimeout(t){if(t)t.cancelled=true;}
|
||||
const source={readyState:1,addEventListener(name,fn){(listeners[name]??=[]).push(fn);}};
|
||||
const live={streamId,source},LIVE_STREAMS={current:live};
|
||||
let resolveSnapshot;
|
||||
async function api(path,options){
|
||||
statusCalls.push({path,options});
|
||||
if(CASE==='probe-error') throw new Error('status unavailable');
|
||||
return {active:CASE==='runtime-active'};
|
||||
}
|
||||
async function _restoreSettledSession(source,options){
|
||||
calls.push({source,options});
|
||||
return new Promise(resolve=>{resolveSnapshot=resolve;});
|
||||
}
|
||||
function _handleStreamError(s){failures.push(s);S.busy=false;S.activeStreamId=null;}
|
||||
FUNCTIONS
|
||||
if(typeof _bindSidebarIdleRecovery==='function')_bindSidebarIdleRecovery(live);
|
||||
const idle=[{session_id:activeSid,is_streaming:false,active_stream_id:null}];
|
||||
(async()=>{
|
||||
_reconcileActiveSessionIdleStateFromList(idle);
|
||||
_reconcileActiveSessionIdleStateFromList(idle);
|
||||
if(CASE==='stream-end-pending'){assert.strictEqual(timers.length,0,'existing stream-end recovery must remain the sole owner');console.log('ok');return;}
|
||||
assert.strictEqual(timers.length,1,'an OPEN transport must schedule one bounded snapshot recovery');
|
||||
assert.ok(timers[0].delay>0&&timers[0].delay<=2000);
|
||||
assert.strictEqual(S.busy,true,'the first idle hint must not destroy the live handoff');
|
||||
const mode=CASE;
|
||||
if(mode==='cancel-rearm'){
|
||||
live.cancelIdleRecovery();
|
||||
_reconcileActiveSessionIdleStateFromList(idle);
|
||||
assert.strictEqual(timers.length,2);
|
||||
await timers[0].fn();
|
||||
assert.strictEqual(timers[1].cancelled,false,'a queued old callback cannot cancel a replacement ticket');
|
||||
const replacement=timers[1].fn();
|
||||
await Promise.resolve();
|
||||
assert.strictEqual(calls.length,1);
|
||||
resolveSnapshot('active');await replacement;
|
||||
console.log('ok');return;
|
||||
}
|
||||
if(mode==='cancel-before'){
|
||||
live.cancelIdleRecovery();
|
||||
assert.strictEqual(timers[0].cancelled,true);
|
||||
console.log('ok');return;
|
||||
}
|
||||
if(mode==='replace-before')LIVE_STREAMS.current={streamId,source:{readyState:1}};
|
||||
const running=timers[0].fn();
|
||||
if(mode==='replace-before'){
|
||||
await running;assert.strictEqual(calls.length,0);console.log('ok');return;
|
||||
}
|
||||
await Promise.resolve();
|
||||
assert.strictEqual(statusCalls.length,1);
|
||||
assert.ok(statusCalls[0].path.includes('/api/chat/stream/status?stream_id=turn-1'));
|
||||
assert.deepStrictEqual(statusCalls[0].options,{timeoutMs:8000,retries:0,timeoutToast:false});
|
||||
if(mode==='runtime-active'){
|
||||
await running;
|
||||
assert.strictEqual(calls.length,0,'active runtime must not fall through to session restore');
|
||||
assert.strictEqual(failures.length,0);
|
||||
assert.strictEqual(S.busy,true);
|
||||
assert.strictEqual(INFLIGHT.current,original);
|
||||
console.log('ok');return;
|
||||
}
|
||||
if(mode==='probe-error'){
|
||||
await running;
|
||||
assert.strictEqual(calls.length,0,'failed runtime probe must not trust idle session flags');
|
||||
assert.strictEqual(failures.length,1);
|
||||
console.log('ok');return;
|
||||
}
|
||||
assert.strictEqual(calls.length,1);
|
||||
_reconcileActiveSessionIdleStateFromList(idle);
|
||||
assert.strictEqual(timers.length,1,'repeated idle hints must not restart a pending deadline');
|
||||
const owns=calls[0].options.isCurrent;
|
||||
assert.strictEqual(owns(),true);
|
||||
assert.deepStrictEqual(calls[0].options.requestOptions,{timeoutMs:8000,retries:0,timeoutToast:false});
|
||||
if(mode==='replace-during')LIVE_STREAMS.current={streamId,source:{readyState:1}};
|
||||
if(mode==='new-turn')INFLIGHT.current={streamId:null};
|
||||
if(mode==='navigate')S.session={session_id:'other'};
|
||||
if(mode==='done'){
|
||||
for(const fn of listeners.done||[])fn();
|
||||
_streamFinalized=true;S.busy=false;S.activeStreamId=null;
|
||||
}
|
||||
if(['replace-during','new-turn','navigate','done'].includes(mode))assert.strictEqual(owns(),false);
|
||||
resolveSnapshot(mode==='active'?'active':mode==='restored'?'restored':'error');
|
||||
await running;
|
||||
assert.strictEqual(failures.length,mode==='error'?1:0);
|
||||
assert.strictEqual(owns(),false,'settled requests must lose authority');
|
||||
if(mode==='active'){
|
||||
assert.strictEqual(S.busy,true);assert.strictEqual(INFLIGHT.current,original);
|
||||
}
|
||||
console.log('ok');
|
||||
})().catch(e=>{console.error(e.stack||e);process.exit(1);});
|
||||
""".replace("FUNCTIONS", functions).replace("CASE", json.dumps(case))
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node required")
|
||||
result = subprocess.run([node, "-e", script], capture_output=True, text=True, timeout=20)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", [
|
||||
"error", "active", "restored", "runtime-active", "probe-error", "cancel-before", "replace-before",
|
||||
"replace-during", "new-turn", "navigate", "done", "stream-end-pending", "cancel-rearm",
|
||||
])
|
||||
def test_sidebar_idle_open_stream_has_bounded_owner_safe_recovery(case):
|
||||
_run(case)
|
||||
|
||||
|
||||
def test_snapshot_restore_rechecks_request_owner_after_await():
|
||||
source = (ROOT / "static/messages.js").read_text(encoding="utf-8")
|
||||
body = _function_body(source, "async function _restoreSettledSession(")
|
||||
script = r"""
|
||||
const assert=require('assert');
|
||||
const activeSid='current',streamId='turn-1';
|
||||
let current=true,resolveSnapshot;
|
||||
const S={session:{session_id:activeSid},activeStreamId:streamId};
|
||||
let _streamFinalized=false;
|
||||
function _isActiveSession(){return true;}
|
||||
function _closeSource(){throw Error('stale restore must not close a replacement');}
|
||||
function api(){return new Promise(resolve=>{resolveSnapshot=resolve;});}
|
||||
async function _restoreSettledSession(source,options=null){BODY}
|
||||
(async()=>{
|
||||
const pending=_restoreSettledSession({}, {status:true,isCurrent:()=>current});
|
||||
current=false;
|
||||
resolveSnapshot({session:{session_id:activeSid,active_stream_id:'newer-turn',messages:[]}});
|
||||
assert.strictEqual(await pending,'stale','request authority must be rechecked before using the response');
|
||||
assert.strictEqual(S.activeStreamId,streamId);
|
||||
console.log('ok');
|
||||
})().catch(e=>{console.error(e.stack||e);process.exit(1);});
|
||||
""".replace("BODY", body)
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node required")
|
||||
result = subprocess.run([node, "-e", script], capture_output=True, text=True, timeout=20)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Sidebar metadata must not retire a still-open, exact chat transport.
|
||||
|
||||
The live SSE and /api/sessions responses are independently scheduled. An idle
|
||||
sidebar row can arrive before the terminal frame carrying the Anchor scene.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.test_issue2454_active_session_spinner import _function_body
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _run_case(operation, transport, *, absent=False, close_after=False):
|
||||
source = (ROOT / "static/sessions.js").read_text(encoding="utf-8")
|
||||
names = ["_isServerIdleSessionRow", "_reconcileActiveSessionIdleStateFromList", "_purgeStaleInflightEntries", "_dropStaleOptimisticSessionRow"]
|
||||
if "function _hasOwnedOpenLiveStream(" in source:
|
||||
names.append("_hasOwnedOpenLiveStream")
|
||||
parameters = {
|
||||
"_isServerIdleSessionRow": "s",
|
||||
"_reconcileActiveSessionIdleStateFromList": "serverRows",
|
||||
"_hasOwnedOpenLiveStream": "sid",
|
||||
"_dropStaleOptimisticSessionRow": "sid",
|
||||
}
|
||||
functions = "\n".join(
|
||||
f"function {name}({parameters.get(name, '')}) {{"
|
||||
+ _function_body(source, f"function {name}(") + "}"
|
||||
for name in names
|
||||
)
|
||||
script = """
|
||||
const S={session:{session_id:'current',active_stream_id:'turn-1'},busy:true,activeStreamId:'turn-1'};
|
||||
const INFLIGHT={current:{streamId:'turn-1',messages:[{role:'assistant',content:'work so far'}]}};
|
||||
const original=INFLIGHT.current;
|
||||
const LIVE_STREAMS={current:TRANSPORT};
|
||||
const _sessionStreamingById=new Map();
|
||||
const calls=[];
|
||||
const idle={session_id:'current',is_streaming:false,active_stream_id:null,pending_user_message:null};
|
||||
const _allSessions=ABSENT?[]:[idle];
|
||||
function clearInflightState(){calls.push('clear');}
|
||||
function _forgetObservedStreamingSession(){}
|
||||
function hideApprovalCard(){}
|
||||
function hideLiveRunStatus(){}
|
||||
function clearLiveToolCards(){calls.push('clear-dom');}
|
||||
function updateSendBtn(){}
|
||||
function _scheduleActiveSessionIdleReload(){calls.push('reload');}
|
||||
FUNCTIONS
|
||||
function run(){if(OPERATION==='idle')_reconcileActiveSessionIdleStateFromList([idle]);else if(OPERATION==='optimistic')_dropStaleOptimisticSessionRow('current');else _purgeStaleInflightEntries();}
|
||||
run();
|
||||
const beforeClose={busy:S.busy,stream:S.activeStreamId,retained:INFLIGHT.current===original,calls:[...calls]};
|
||||
if(CLOSE_AFTER){LIVE_STREAMS.current.source.readyState=2;run();}
|
||||
console.log(JSON.stringify({beforeClose,afterClose:{retained:!!INFLIGHT.current,busy:S.busy}}));
|
||||
"""
|
||||
script = script.replace("TRANSPORT", json.dumps(transport)).replace("ABSENT", json.dumps(absent))
|
||||
script = script.replace("FUNCTIONS", functions).replace("OPERATION", json.dumps(operation))
|
||||
script = script.replace("CLOSE_AFTER", json.dumps(close_after))
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node required")
|
||||
result = subprocess.run([node, "-e", script], text=True, capture_output=True, timeout=20)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", ["idle", "purge", "optimistic"])
|
||||
def test_current_open_transport_keeps_terminal_handoff_and_recovers_after_close(operation):
|
||||
result = _run_case(operation, {"streamId": "turn-1", "source": {"readyState": 1}}, close_after=True)
|
||||
assert result["beforeClose"] == {"busy": True, "stream": "turn-1", "retained": True, "calls": []}
|
||||
assert result["afterClose"]["retained"] is False
|
||||
if operation == "idle":
|
||||
assert result["afterClose"]["busy"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", ["idle", "purge", "optimistic"])
|
||||
@pytest.mark.parametrize("transport", [
|
||||
None,
|
||||
{"streamId": "turn-1", "source": None},
|
||||
{"streamId": "turn-1", "source": {"readyState": 0}},
|
||||
{"streamId": "turn-1", "source": {"readyState": 2}},
|
||||
{"streamId": "older-turn", "source": {"readyState": 1}},
|
||||
])
|
||||
def test_missing_closed_connecting_or_wrong_transport_does_not_block_idle_recovery(operation, transport):
|
||||
result = _run_case(operation, transport)
|
||||
assert result["beforeClose"]["retained"] is False
|
||||
if operation == "idle":
|
||||
assert result["beforeClose"]["busy"] is False
|
||||
assert "reload" in result["beforeClose"]["calls"]
|
||||
|
||||
|
||||
def test_current_open_transport_survives_a_filtered_sidebar_list():
|
||||
result = _run_case("purge", {"streamId": "turn-1", "source": {"readyState": 1}}, absent=True)
|
||||
assert result["beforeClose"]["retained"] is True
|
||||
@@ -4,6 +4,7 @@ from email.message import Message
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
|
||||
@@ -858,6 +859,104 @@ def test_gateway_chat_worker_emits_goal_continue_for_goal_related_turn(tmp_path,
|
||||
assert saved.messages[-1]["content"] == "goal reply"
|
||||
|
||||
|
||||
def test_gateway_goal_eval_keeps_runtime_active_after_success_writeback_before_done(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""Persisted idle flags can precede terminal emission while STREAMS is still active."""
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
|
||||
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
|
||||
|
||||
class FakeResponse:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def __iter__(self):
|
||||
yield b'data: {"choices":[{"delta":{"content":"goal reply"}}]}\n\n'
|
||||
yield b"data: [DONE]\n\n"
|
||||
|
||||
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
|
||||
monkeypatch.setattr(
|
||||
gateway_chat.urllib.request, "urlopen", lambda req, timeout=0: FakeResponse()
|
||||
)
|
||||
|
||||
from api import goals as webui_goals
|
||||
|
||||
goal_entered = threading.Event()
|
||||
release_goal = threading.Event()
|
||||
observed = {}
|
||||
|
||||
monkeypatch.setattr(webui_goals, "has_active_goal", lambda *args, **kwargs: True)
|
||||
|
||||
def blocked_goal_eval(*args, **kwargs):
|
||||
saved = models.get_session(s.session_id)
|
||||
observed["active_stream_id"] = saved.active_stream_id
|
||||
observed["pending_user_message"] = saved.pending_user_message
|
||||
observed["stream_registered"] = stream_id in STREAMS
|
||||
goal_entered.set()
|
||||
assert release_goal.wait(timeout=10)
|
||||
return {"should_continue": False, "message": "Goal checked"}
|
||||
|
||||
monkeypatch.setattr(webui_goals, "evaluate_goal_after_turn", blocked_goal_eval)
|
||||
|
||||
s = new_session()
|
||||
stream_id = "stream-gateway-goal-runtime-window"
|
||||
s.active_stream_id = stream_id
|
||||
s.pending_user_message = "finish it"
|
||||
s.pending_attachments = []
|
||||
s.pending_started_at = 123
|
||||
s.save()
|
||||
channel = create_stream_channel()
|
||||
subscriber = channel.subscribe()
|
||||
STREAMS[stream_id] = channel
|
||||
|
||||
worker = threading.Thread(
|
||||
target=gateway_chat._run_gateway_chat_streaming,
|
||||
args=(
|
||||
s.session_id,
|
||||
"finish it",
|
||||
"test-model",
|
||||
str(tmp_path),
|
||||
stream_id,
|
||||
[],
|
||||
),
|
||||
kwargs={"goal_related": True},
|
||||
daemon=True,
|
||||
)
|
||||
worker.start()
|
||||
assert goal_entered.wait(timeout=10), "worker did not reach post-writeback goal evaluation"
|
||||
|
||||
# This is the exact authority split the browser recovery must respect:
|
||||
# persisted session metadata already looks idle, while the runtime stream
|
||||
# (and therefore /api/chat/stream/status) is still active and can emit
|
||||
# goal / goal_continue / done / stream_end.
|
||||
assert observed == {
|
||||
"active_stream_id": None,
|
||||
"pending_user_message": None,
|
||||
"stream_registered": True,
|
||||
}
|
||||
assert stream_id in STREAMS
|
||||
assert worker.is_alive()
|
||||
assert not any(item[0] == "done" for item in list(subscriber.queue))
|
||||
|
||||
release_goal.set()
|
||||
worker.join(timeout=10)
|
||||
assert not worker.is_alive()
|
||||
events = []
|
||||
while not subscriber.empty():
|
||||
events.append(subscriber.get_nowait())
|
||||
names = [item[0] for item in events]
|
||||
assert "goal" in names
|
||||
assert "done" in names
|
||||
assert "stream_end" in names
|
||||
assert names.index("goal") < names.index("done") < names.index("stream_end")
|
||||
|
||||
|
||||
def test_gateway_chat_worker_skips_goal_judge_for_non_goal_turn(tmp_path, monkeypatch):
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
|
||||
Reference in new issue
Block a user