diff --git a/.github/workflows/conversation-lifecycle.yml b/.github/workflows/conversation-lifecycle.yml index fe9fd1c562..ae2260cc85 100644 --- a/.github/workflows/conversation-lifecycle.yml +++ b/.github/workflows/conversation-lifecycle.yml @@ -34,7 +34,7 @@ jobs: strategy: fail-fast: false matrix: - scenario: [normal, terminal-error] + scenario: [normal, terminal-error, session-reattach, detached-terminal, detached-terminal-error] steps: - uses: actions/checkout@v4 @@ -56,7 +56,7 @@ jobs: - name: Run conversation lifecycle gate env: - LIFECYCLE_ARTIFACT_DIR: lifecycle-artifacts + LIFECYCLE_ARTIFACT_DIR: lifecycle-artifacts/${{ matrix.scenario }} LIFECYCLE_SCENARIO: ${{ matrix.scenario }} run: python tests/browser_conversation_lifecycle.py diff --git a/TESTING.md b/TESTING.md index 3748ded786..4317dbe2d2 100644 --- a/TESTING.md +++ b/TESTING.md @@ -95,12 +95,18 @@ that breaks the page for everyone). `tests/browser_conversation_lifecycle.py` adds a public deterministic multi-row lifecycle gate. It drives the real composer and real WebUI server in Chromium, -while a localhost-only fixture supplies reasoning, tool, process, and final/error -events through the existing Hermes Gateway Runs API. The gate now covers both -normal and terminal-error proof-matrix rows, asserting semantic activity during -live streaming, after settlement, and after hard reload, including -transcript-backed `activity_scene_v1` persistence and zero unexpected browser -errors. It uses isolated temporary state and no provider credentials. +while a localhost-only fixture supplies reasoning, tool, process, final, and +terminal-error events through the existing Hermes Gateway Runs API. The gate now +covers normal, terminal-error, active session reattach, and detached terminal +proof rows, asserting semantic activity during live streaming, after settlement, +and after hard reload, including transcript-backed `activity_scene_v1` +persistence and zero unexpected browser errors. The reattach row leaves an active +session through the real sidebar and returns to prove that the same stream ID, +timer origin, and ordered multi-segment Anchor rows reattach without duplication +or loss. The detached-terminal row performs an unblocked normal switch, confirms +the old chat EventSource is detached, releases terminal completion while another +session is active, and verifies settled/hard-reload parity without active-pane +mutation. The scenarios use isolated temporary state and no provider credentials. ```bash pip install -r requirements.txt playwright @@ -109,8 +115,17 @@ python -m playwright install --with-deps chromium # Normal-path deterministic conversation lifecycle gate. python tests/browser_conversation_lifecycle.py -# Terminal-error lifecycle gate (new row in the proof matrix). -LIFECYCLE_SCENARIO=terminal-error python tests/browser_conversation_lifecycle.py +# Terminal-error lifecycle gate. +LIFECYCLE_SCENARIO=terminal-error \ + python tests/browser_conversation_lifecycle.py + +# Active session reattach lifecycle gate. +LIFECYCLE_SCENARIO=session-reattach \ + python tests/browser_conversation_lifecycle.py + +# Detached terminal completion after ordinary switch detachment. +LIFECYCLE_SCENARIO=detached-terminal \ + python tests/browser_conversation_lifecycle.py ``` To certify that the gate catches its target failure, the test owns an opt-in @@ -120,19 +135,31 @@ must fail at the hard-reload boundary: ```bash LIFECYCLE_TEST_BITE=drop-anchor-persistence \ python tests/browser_conversation_lifecycle.py +``` -# Terminal-state-specific mutation bite: remove terminal row from persisted scene -# so hard reload cannot recover terminal status. +The terminal-error scenario has a mutation that removes the terminal row from +the persisted scene. It must fail at the hard-reload boundary: + +```bash LIFECYCLE_SCENARIO=terminal-error \ LIFECYCLE_TEST_BITE=drop-terminal-anchor-row \ python tests/browser_conversation_lifecycle.py ``` -The dedicated `Conversation lifecycle (informational)` workflow runs both current -proof rows (`normal` and `terminal-error`) and stays non-blocking while the public -matrix expands to additional behavior rows. The maintainer's private QA harness -remains broader; later public slices will add session switching, reconnect/replay, -cancellation, compression, and recovery. +The reattach scenario has a separate mutation that changes the first reasoning +identity in the server runtime snapshot. It must fail at the reattach boundary: + +```bash +LIFECYCLE_SCENARIO=session-reattach \ +LIFECYCLE_TEST_BITE=replace-runtime-reasoning-id \ + python tests/browser_conversation_lifecycle.py +``` + +The dedicated `Conversation lifecycle (informational)` workflow runs the current +proof rows (`normal`, `terminal-error`, `session-reattach`, and +`detached-terminal`) and stays non-blocking while the public matrix expands. The +maintainer's private QA harness remains broader; later public slices will add +cancellation, compression, and broader reconnect/replay recovery. `tests/test_static_js_runtime_lint.py` runs this automatically when eslint is present diff --git a/api/gateway_chat.py b/api/gateway_chat.py index 06053fcb54..7b3e053cbe 100644 --- a/api/gateway_chat.py +++ b/api/gateway_chat.py @@ -617,6 +617,143 @@ def _settle_gateway_terminal_error(session_id, stream_id, workspace, model, mode return error_payload +def _settle_gateway_terminal_payload( + session_id, + stream_id, + workspace, + model, + model_provider, + error_payload, +): + with _get_session_agent_lock(session_id): + session = get_session(session_id) + return _settle_gateway_terminal_payload_locked( + session, + session_id, + stream_id, + workspace, + model, + model_provider, + error_payload, + ) + + +def _settle_gateway_terminal_payload_locked( + session, + session_id, + stream_id, + workspace, + model, + model_provider, + error_payload, +): + from api.streaming import ( + _cancelled_turn_content, + _materialize_pending_user_turn_before_error, + _session_payload_with_full_messages, + _snapshot_and_append_partial_on_error, + ) + + payload = dict(error_payload) if isinstance(error_payload, dict) else {} + if not _stream_writeback_is_current(session, stream_id): + return None + turn_duration_seconds = 0.0 + try: + pending_ts = getattr(session, "pending_started_at", None) + if pending_ts: + turn_duration_seconds = max(0.0, time.time() - float(pending_ts)) + except Exception: + pass + _materialize_pending_user_turn_before_error(session) + session.active_stream_id = None + session.pending_user_message = None + session.pending_attachments = [] + session.pending_started_at = None + session.pending_user_source = None + try: + _snapshot_and_append_partial_on_error(session, stream_id) + except Exception: + logger.debug("Failed to snapshot gateway partials on terminal payload", exc_info=True) + error_type = str(payload.get("type") or "").strip().lower() + label = str(payload.get("label") or "").strip() + if not label: + label = "Task cancelled" if error_type in {"cancelled", "canceled"} else "Gateway request failed" + message = str(payload.get("message") or label).strip() + hint = str(payload.get("hint") or "").strip() + if error_type in {"cancelled", "canceled"}: + content = _cancelled_turn_content(message) + else: + content = f"**{label}:** {message}" + (f"\n\n*{hint}*" if hint else "") + error_message = { + "role": "assistant", + "content": content, + "timestamp": int(time.time()), + "_error": True, + "_turnDuration": round(turn_duration_seconds, 3), + } + if payload.get("details"): + error_message["provider_details"] = payload["details"] + if error_type in {"cancelled", "canceled"}: + error_message["provider_details"] = message + error_message["provider_details_label"] = "Cancellation details" + elif error_type == "interrupted": + error_message["provider_details_label"] = "Interruption details" + if not isinstance(session.messages, list): + session.messages = [] + session.messages.append(error_message) + session.workspace = str(workspace) + session.model = model + session.model_provider = model_provider + try: + session.save() + except Exception: + logger.debug("Failed to persist gateway terminal payload settlement", exc_info=True) + payload["session"] = redact_session_data( + _session_payload_with_full_messages(session, tool_calls=[]) + ) + payload["session_id"] = session.session_id + return payload + + +def _settle_gateway_terminal_event_payload( + session_id, + stream_id, + workspace, + model, + model_provider, + event, + data, +): + if event == "apperror" and isinstance(data, dict): + data = data.copy() + data.setdefault("session_id", session_id) + if event not in {"cancel", "error", "apperror"}: + return data + payload = data if isinstance(data, dict) else {} + if isinstance(payload.get("session"), dict): + return payload + if isinstance(payload.get("terminal_disposition"), dict): + return payload + if event == "cancel": + payload = dict(payload) + payload.setdefault("label", "Task cancelled") + payload.setdefault("type", "cancelled") + payload.setdefault("message", "Cancelled by user") + try: + settled_payload = _settle_gateway_terminal_payload( + session_id, + stream_id, + workspace, + model, + model_provider, + payload, + ) + except Exception: + logger.debug("Failed to settle gateway terminal payload", exc_info=True) + return data + return settled_payload if settled_payload is not None else data + + def _stream_writeback_is_current(session: Any, stream_id: str) -> bool: return bool(stream_id and getattr(session, "active_stream_id", None) == stream_id) @@ -699,9 +836,15 @@ def _run_gateway_chat_streaming( def put_gateway_event(event, data): if cancel_event.is_set() and not success_writeback_committed and event not in ("cancel", "error", "apperror"): return - if event == "apperror" and isinstance(data, dict): - data = data.copy() - data.setdefault("session_id", session_id) + data = _settle_gateway_terminal_event_payload( + session_id, + stream_id, + workspace, + model, + model_provider, + event, + data, + ) event_id = None if run_journal is not None: try: @@ -999,6 +1142,7 @@ def put_gateway_event(event, data): "hint": "Check that Hermes Gateway API server is running and reachable.", }) return + deferred_gateway_event = None with _get_session_agent_lock(session_id): s = get_session(session_id) if not _stream_writeback_is_current(s, stream_id): @@ -1007,112 +1151,163 @@ def put_gateway_event(event, data): # before success writeback. Treat it as cancellation so any # credential-exhausted process-wakeup pause stays in place. if cancel_event.is_set(): - put_gateway_event("cancel", {"message": "Cancelled by user"}) - return - now = time.time() - # Preserve subsecond ordering for gateway-backed turns. Using an - # integer seconds timestamp gives the user and assistant rows the - # same sort key; later transcript merges can then fall back to - # role/content ordering instead of turn order. - assistant_ts = now + 0.000001 - user_msg = {"role": "user", "content": str(msg_text or ""), "timestamp": now} - pending_source = getattr(s, "pending_user_source", None) or "webui" - if pending_source != "webui": - user_msg["_source"] = pending_source - if attachments: - user_msg["attachments"] = list(attachments) - assistant_msg = {"role": "assistant", "content": assistant_text, "timestamp": assistant_ts} - saved_reasoning = STREAM_REASONING_TEXT.get(stream_id, "") - if saved_reasoning: - assistant_msg["reasoning"] = saved_reasoning - previous_messages = list(getattr(s, "messages", None) or []) - previous_context = list(getattr(s, "context_messages", None) or getattr(s, "messages", None) or []) - previous_process_wakeup_pause = dict(getattr(s, "process_wakeup_pause", {}) or {}) - # Stamp stable ids on the two new rows (shared with the display merge - # below) so display and model-context copies share an id for the - # fork/truncate aligner (#context-message-stable-id). - try: - from api.streaming import _assign_stable_message_ids - - _assign_stable_message_ids( - [user_msg, assistant_msg], - previous_context, - list(getattr(s, "messages", None) or []), + deferred_gateway_event = ( + "cancel", + _settle_gateway_terminal_payload_locked( + s, + session_id, + stream_id, + workspace, + model, + model_provider, + { + "label": "Task cancelled", + "type": "cancelled", + "message": "Cancelled by user", + }, + ) or {"message": "Cancelled by user"}, ) - except Exception: - logger.debug("Failed to stamp stable ids on gateway turn rows", exc_info=True) - s.context_messages = previous_context + [user_msg, assistant_msg] - try: - from api.streaming import _is_context_compression_marker + else: + now = time.time() + # Preserve subsecond ordering for gateway-backed turns. Using an + # integer seconds timestamp gives the user and assistant rows the + # same sort key; later transcript merges can then fall back to + # role/content ordering instead of turn order. + assistant_ts = now + 0.000001 + user_msg = {"role": "user", "content": str(msg_text or ""), "timestamp": now} + pending_source = getattr(s, "pending_user_source", None) or "webui" + if pending_source != "webui": + user_msg["_source"] = pending_source + if attachments: + user_msg["attachments"] = list(attachments) + assistant_msg = {"role": "assistant", "content": assistant_text, "timestamp": assistant_ts} + saved_reasoning = STREAM_REASONING_TEXT.get(stream_id, "") + if saved_reasoning: + assistant_msg["reasoning"] = saved_reasoning + previous_messages = list(getattr(s, "messages", None) or []) + previous_context = list(getattr(s, "context_messages", None) or getattr(s, "messages", None) or []) + previous_process_wakeup_pause = dict(getattr(s, "process_wakeup_pause", {}) or {}) + # Stamp stable ids on the two new rows (shared with the display merge + # below) so display and model-context copies share an id for the + # fork/truncate aligner (#context-message-stable-id). + try: + from api.streaming import _assign_stable_message_ids - display_context = [ - msg - for msg in previous_context - if not _is_context_compression_marker(msg) - ] - except Exception: - logger.debug("Failed to filter gateway display context markers", exc_info=True) - display_context = previous_context - display = merge_session_messages_append_only( - previous_messages, - display_context, - ) - try: - from api.streaming import _merge_display_messages_after_agent_result - - s.messages = _merge_display_messages_after_agent_result( - display, - previous_context, - s.context_messages, - str(msg_text or ""), - source=pending_source, + _assign_stable_message_ids( + [user_msg, assistant_msg], + previous_context, + list(getattr(s, "messages", None) or []), + ) + except Exception: + logger.debug("Failed to stamp stable ids on gateway turn rows", exc_info=True) + s.context_messages = previous_context + [user_msg, assistant_msg] + try: + from api.streaming import _is_context_compression_marker + + display_context = [ + msg + for msg in previous_context + if not _is_context_compression_marker(msg) + ] + except Exception: + logger.debug("Failed to filter gateway display context markers", exc_info=True) + display_context = previous_context + display = merge_session_messages_append_only( + previous_messages, + display_context, ) - except Exception: - logger.debug("Failed to merge gateway display transcript", exc_info=True) - # Avoid duplicating the eager-save checkpointed user message. - if display: - latest = display[-1] - if isinstance(latest, dict) and latest.get("role") == "user": - latest_text = " ".join(str(latest.get("content") or "").split()) - msg_norm = " ".join(str(msg_text or "").split()) - if latest_text == msg_norm: - display = display[:-1] - s.messages = display + [user_msg, assistant_msg] - s.active_stream_id = None - s.pending_user_message = None - s.pending_attachments = None - s.pending_started_at = None - s.pending_user_source = None - s.workspace = str(workspace) - s.model = model - s.model_provider = model_provider - - def _restore_cancelled_success_writeback(): - if pending_source == "process_wakeup": - s.context_messages = previous_context - s.messages = previous_messages - s.process_wakeup_pause = dict(previous_process_wakeup_pause) - elif previous_process_wakeup_pause: - s.process_wakeup_pause = dict(previous_process_wakeup_pause) + try: + from api.streaming import _merge_display_messages_after_agent_result + + s.messages = _merge_display_messages_after_agent_result( + display, + previous_context, + s.context_messages, + str(msg_text or ""), + source=pending_source, + ) + except Exception: + logger.debug("Failed to merge gateway display transcript", exc_info=True) + # Avoid duplicating the eager-save checkpointed user message. + if display: + latest = display[-1] + if isinstance(latest, dict) and latest.get("role") == "user": + latest_text = " ".join(str(latest.get("content") or "").split()) + msg_norm = " ".join(str(msg_text or "").split()) + if latest_text == msg_norm: + display = display[:-1] + s.messages = display + [user_msg, assistant_msg] + s.active_stream_id = None + s.pending_user_message = None + s.pending_attachments = None + s.pending_started_at = None + s.pending_user_source = None + s.workspace = str(workspace) + s.model = model + s.model_provider = model_provider + + def _restore_cancelled_success_writeback(): + if pending_source == "process_wakeup": + s.context_messages = previous_context + s.messages = previous_messages + s.process_wakeup_pause = dict(previous_process_wakeup_pause) + s.save() + return ( + "cancel", + { + "label": "Task cancelled", + "type": "cancelled", + "message": "Cancelled by user", + "session_id": session_id, + "terminal_disposition": { + "version": "terminal_disposition_v1", + "kind": "consumed_non_materializable", + "reason": "process_wakeup_success_cancel_rollback", + "session_id": session_id, + "run_id": stream_id, + "stream_id": stream_id, + }, + }, + ) + if previous_process_wakeup_pause: + s.process_wakeup_pause = dict(previous_process_wakeup_pause) + else: + clear_process_wakeup_pause(s, reason="run_completed") + s.save() + from api.streaming import _session_payload_with_full_messages + + return ( + "cancel", + { + "label": "Task cancelled", + "type": "cancelled", + "message": "Cancelled by user", + "session_id": session_id, + "session": redact_session_data( + _session_payload_with_full_messages(s, tool_calls=[]) + ), + }, + ) + + # Recheck immediately before clearing the pause; Stop can arrive + # while the success transcript is being assembled. + if cancel_event.is_set(): + deferred_gateway_event = _restore_cancelled_success_writeback() else: clear_process_wakeup_pause(s, reason="run_completed") - s.save() - put_gateway_event("cancel", {"message": "Cancelled by user"}) - - # Recheck immediately before clearing the pause; Stop can arrive - # while the success transcript is being assembled. - if cancel_event.is_set(): - _restore_cancelled_success_writeback() - return - clear_process_wakeup_pause(s, reason="run_completed") - if cancel_event.is_set(): - _restore_cancelled_success_writeback() - return - s.save() - if cancel_event.is_set(): - _restore_cancelled_success_writeback() - return - success_writeback_committed = True + if cancel_event.is_set(): + deferred_gateway_event = _restore_cancelled_success_writeback() + else: + s.save() + if cancel_event.is_set(): + deferred_gateway_event = _restore_cancelled_success_writeback() + else: + success_writeback_committed = True + if deferred_gateway_event is not None: + put_gateway_event(*deferred_gateway_event) + return + if not success_writeback_committed: + return try: from api.goals import evaluate_goal_after_turn, has_active_goal from api.profiles import get_hermes_home_for_profile diff --git a/api/routes.py b/api/routes.py index 97e8044af6..e527339fe5 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3136,19 +3136,23 @@ def _run_journal_snapshot_merge_args(existing, incoming): def _run_journal_envelope_run_id_result(event: dict) -> tuple[str | None, bool]: raw_run_id = event.get("run_id") + raw_event_id = event.get("event_id") + event_id = str(raw_event_id or "").strip() + event_run_id = None + event_seq = None + if event_id: + event_run_id, event_seq = _shared_parse_run_journal_event_id(event_id) + if not event_run_id or event_seq is None: + return None, True if raw_run_id is None: - return None, False + return event_run_id, False if not isinstance(raw_run_id, str): return None, True run_id = raw_run_id.strip() if not run_id: return None, True - raw_event_id = event.get("event_id") - event_id = str(raw_event_id or "").strip() - if event_id: - event_run_id, event_seq = _shared_parse_run_journal_event_id(event_id) - if event_run_id and event_seq is not None and event_run_id != run_id: - return None, True + if event_run_id and event_run_id != run_id: + return None, True return run_id, False @@ -3166,7 +3170,104 @@ def _run_journal_snapshot_event_id_for_run( return f"{run_id}:{event_seq}" if event_seq else None -def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict | None: +def _terminal_anchor_scene_target_from_payload( + session_id: str, + stream_id: str, + run_id: str, + terminal_payload: dict, +) -> dict | None: + if not isinstance(terminal_payload, dict): + return None + raw_target = terminal_payload.get("terminal_message_target") + if "terminal_message_target" in terminal_payload: + if not isinstance(raw_target, dict): + return None + if raw_target.get("version") != "terminal_message_target_v1": + return None + raw_message_index = raw_target.get("message_index") + if not isinstance(raw_message_index, int) or isinstance(raw_message_index, bool) or raw_message_index < 0: + return None + target_session_id = str(raw_target.get("session_id") or "").strip() + target_run_id = str(raw_target.get("run_id") or "").strip() + target_stream_id = str(raw_target.get("stream_id") or "").strip() + target_message_ref = _normalize_anchor_scene_message_ref(raw_target.get("message_ref") or "") + if not target_session_id or not target_run_id or not target_stream_id or not target_message_ref: + return None + return { + "version": "terminal_message_target_v1", + "session_id": target_session_id, + "run_id": target_run_id, + "stream_id": target_stream_id, + "message_index": raw_message_index, + "message_ref": target_message_ref, + } + terminal_session = terminal_payload.get("session") + if not isinstance(terminal_session, dict): + return None + terminal_messages = ( + terminal_session.get("messages") + if isinstance(terminal_session.get("messages"), list) + else [] + ) + terminal_message_count = terminal_session.get("message_count") + try: + terminal_message_count = int(terminal_message_count) + except (TypeError, ValueError): + terminal_message_count = len(terminal_messages) if terminal_messages else None + if terminal_message_count is None or terminal_message_count <= 0: + return None + terminal_message_index = terminal_message_count - 1 + message_ref = "" + if 0 <= terminal_message_index < len(terminal_messages): + terminal_message = terminal_messages[terminal_message_index] + if isinstance(terminal_message, dict) and terminal_message.get("role") == "assistant": + message_ref = _assistant_anchor_scene_message_ref(terminal_message) + return { + "version": "terminal_message_target_v1", + "session_id": str(terminal_session.get("session_id") or session_id), + "run_id": run_id, + "stream_id": stream_id, + "message_index": terminal_message_index, + "message_ref": message_ref, + } + + +def _terminal_anchor_scene_disposition_from_payload( + session_id: str, + stream_id: str, + run_id: str, + terminal_payload: dict, +) -> dict | None: + if not isinstance(terminal_payload, dict): + return None + raw = terminal_payload.get("terminal_disposition") + if not isinstance(raw, dict): + return None + if raw.get("version") != "terminal_disposition_v1": + return None + kind = str(raw.get("kind") or "").strip() + if kind != "consumed_non_materializable": + return None + target_session_id = str(raw.get("session_id") or "").strip() + target_run_id = str(raw.get("run_id") or "").strip() + target_stream_id = str(raw.get("stream_id") or "").strip() + if ( + target_session_id != session_id + or target_run_id != run_id + or target_stream_id != stream_id + ): + return None + return { + "version": "terminal_disposition_v1", + "kind": kind, + "reason": str(raw.get("reason") or "").strip(), + "session_id": target_session_id, + "run_id": target_run_id, + "stream_id": target_stream_id, + } + + +def _run_journal_live_snapshot(stream_id: str | None, *, handler=None, settled: bool = False) -> dict | None: stream_id = str(stream_id or "").strip() if not stream_id: return None @@ -3194,14 +3295,59 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict | event_run_ids.add(event_run_id) if event_run_id_malformed: malformed_envelope_run_id = True - # The event envelope is the durable identity authority. Older summaries - # are keyed by the transport id, so only use that fallback when the journal - # does not provide one unambiguous run id. - run_id = ( - next(iter(event_run_ids)) - if not malformed_envelope_run_id and len(event_run_ids) == 1 - else str(summary.get("run_id") or stream_id).strip() + terminal_event = next( + ( + event + for event in reversed(events) + if event.get("terminal") and str(event.get("event") or event.get("type") or "") != "stream_end" + ), + next((event for event in reversed(events) if event.get("terminal")), None), + ) + if len(event_run_ids) > 1: + return None + terminal_settlement_snapshot = settled or terminal_event is not None + # Malformed envelopes cannot own terminal settlement. For a non-terminal + # live snapshot, the transport cursor remains a recoverable observation path. + if malformed_envelope_run_id: + if terminal_settlement_snapshot: + return None + event_run_ids.clear() + summary_run_id = str(summary.get("run_id") or stream_id).strip() + if len(event_run_ids) == 1: + run_id = next(iter(event_run_ids)) + # Older summaries can still be keyed by the transport stream id while + # the event envelope carries the stable run id. Any third identity is + # ambiguous and must not be trusted. + if summary_run_id and summary_run_id not in {run_id, stream_id}: + return None + else: + if terminal_settlement_snapshot: + return None + run_id = summary_run_id or stream_id + terminal_payload = ( + terminal_event.get("payload") + if isinstance(terminal_event, dict) and isinstance(terminal_event.get("payload"), dict) + else {} ) + terminal_state = str(summary.get("terminal_state") or "").strip() if summary.get("terminal") else "" + terminal_message_target = _terminal_anchor_scene_target_from_payload( + session_id, + stream_id, + run_id, + terminal_payload, + ) + terminal_disposition = _terminal_anchor_scene_disposition_from_payload( + session_id, + stream_id, + run_id, + terminal_payload, + ) + terminal_message_index = ( + terminal_message_target.get("message_index") + if isinstance(terminal_message_target, dict) + else None + ) + snapshot_row_status = "completed" if settled else "running" assistant_text = "" reasoning_text = "" @@ -3211,25 +3357,66 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict | current_activity_burst_id = 0 fresh_segment = True last_ts = None - reasoning_first_tool_count: int | None = None - - def mark_boundary() -> int: + reasoning_segments: list[dict] = [] + active_reasoning_segment: dict | None = None + live_segment_seq_high_water = 0 + active_live_segment_seq: int | None = None + active_prose_started_order: int | None = None + current_event_order = 0 + + def ensure_live_segment() -> int: + nonlocal active_live_segment_seq, live_segment_seq_high_water + if active_live_segment_seq is None: + live_segment_seq_high_water += 1 + active_live_segment_seq = live_segment_seq_high_water + return active_live_segment_seq + + def mark_boundary() -> tuple[int, int]: nonlocal current_activity_burst_id + nonlocal active_live_segment_seq, active_prose_started_order + # A tool arriving immediately after prose was sealed still belongs to + # that browser-visible segment. Reuse the consumed high-water without + # allocating a new segment; the next reasoning/prose event advances it. + segment_seq = int(active_live_segment_seq or live_segment_seq_high_water or 0) text_end = len(assistant_text) - if text_end <= 0: - return current_activity_burst_id last_end = max( [int(anchor.get("textEnd") or 0) for anchor in activity_burst_anchors] or [0] ) if text_end > last_end: + if not segment_seq: + segment_seq = ensure_live_segment() current_activity_burst_id += 1 activity_burst_anchors.append( - {"id": current_activity_burst_id, "textEnd": text_end} + { + "id": current_activity_burst_id, + "textEnd": text_end, + "segment_seq": segment_seq, + "journal_order": active_prose_started_order or current_event_order, + } ) - return current_activity_burst_id + active_live_segment_seq = None + active_prose_started_order = None + return current_activity_burst_id, segment_seq + + def append_reasoning_segment(text: str) -> None: + nonlocal active_reasoning_segment + if active_reasoning_segment is None: + segment_seq = ensure_live_segment() + active_reasoning_segment = { + "segment_seq": segment_seq, + "text": "", + "first_tool_count": len(tool_calls), + "text_offset": len(assistant_text), + "created_at": last_ts, + "journal_order": current_event_order, + } + reasoning_segments.append(active_reasoning_segment) + active_reasoning_segment["text"] += text + if active_reasoning_segment.get("created_at") is None and last_ts is not None: + active_reasoning_segment["created_at"] = last_ts - def update_completed_tool(payload: dict) -> None: + def update_completed_tool(payload: dict) -> bool: tool_id = _run_journal_snapshot_tool_id(payload) name = str(payload.get("name") or "").strip() for call in reversed(tool_calls): @@ -3251,10 +3438,11 @@ def update_completed_tool(payload: dict) -> None: call["duration"] = payload.get("duration") if payload.get("is_error") is not None: call["is_error"] = bool(payload.get("is_error")) - return + return False if not name or name == "clarify": - return + return False + boundary_id, segment_seq = mark_boundary() call = { "name": name, "preview": str(payload.get("preview") or ""), @@ -3264,6 +3452,7 @@ def update_completed_tool(payload: dict) -> None: "_live": True, "_journal_snapshot": True, "_journal_stream_id": stream_id, + "_journal_order": current_event_order, } tool_id = _run_journal_snapshot_tool_id(payload) if tool_id: @@ -3271,10 +3460,12 @@ def update_completed_tool(payload: dict) -> None: for key in _RUN_JOURNAL_TOOL_ID_KEYS: if payload.get(key): call[key] = str(payload.get(key)) - if current_activity_burst_id: - call["activityBurstId"] = current_activity_burst_id - call["activitySegmentSeq"] = current_activity_burst_id + if boundary_id: + call["activityBurstId"] = boundary_id + if segment_seq: + call["activitySegmentSeq"] = segment_seq tool_calls.append(call) + return True def reasoning_echo_tail_matches(text: str) -> bool: candidate = _compact_for_echo_compare(text) @@ -3283,29 +3474,47 @@ def reasoning_echo_tail_matches(text: str) -> bool: return _compact_for_echo_compare(reasoning_text).endswith(candidate) def strip_reasoning_echo_tail(text: str) -> bool: - nonlocal reasoning_text, reasoning_first_tool_count + nonlocal reasoning_text, active_reasoning_segment next_reasoning, did_remove = _strip_compact_echo_suffix(reasoning_text, text) if did_remove: reasoning_text = next_reasoning - if not _compact_for_echo_compare(reasoning_text): - reasoning_first_tool_count = None + scene_reasoning = "".join(str(segment.get("text") or "") for segment in reasoning_segments) + next_scene_reasoning, scene_removed = _strip_compact_echo_suffix(scene_reasoning, text) + if scene_removed: + remaining = len(next_scene_reasoning) + kept_segments = [] + for segment in reasoning_segments: + raw = str(segment.get("text") or "") + if remaining <= 0: + break + keep = raw[:remaining] + remaining -= len(raw) + if not _compact_for_echo_compare(keep): + continue + segment["text"] = keep + kept_segments.append(segment) + reasoning_segments[:] = kept_segments + active_reasoning_segment = kept_segments[-1] if kept_segments else None return did_remove - for event in events: + for current_event_order, event in enumerate(events, start=1): event_name = str(event.get("event") or event.get("type") or "") payload = event.get("payload") if isinstance(event.get("payload"), dict) else {} last_ts = event.get("created_at", last_ts) if event_name == "token": text = str(payload.get("text") or "") if text: + ensure_live_segment() + if active_prose_started_order is None: + active_prose_started_order = current_event_order assistant_text += text fresh_segment = False continue if event_name == "reasoning": text = str(payload.get("text") or "") - if text and reasoning_first_tool_count is None: - reasoning_first_tool_count = len(tool_calls) - reasoning_text += text + if text: + reasoning_text += text + append_reasoning_segment(text) continue if event_name == "interim_assistant": visible = str(payload.get("text") or "").strip() @@ -3314,17 +3523,24 @@ def strip_reasoning_echo_tail(text: str) -> bool: strip_reasoning_echo_tail(visible) if payload.get("already_streamed"): if not assistant_text: + ensure_live_segment() + if active_prose_started_order is None: + active_prose_started_order = current_event_order assistant_text = visible else: + ensure_live_segment() + if active_prose_started_order is None: + active_prose_started_order = current_event_order assistant_text = f"{assistant_text}\n\n{visible}" if assistant_text else visible mark_boundary() fresh_segment = True + active_reasoning_segment = None continue if event_name == "tool": name = str(payload.get("name") or "").strip() if not name or name == "clarify": continue - boundary_id = mark_boundary() + boundary_id, segment_seq = mark_boundary() tool_id = _run_journal_snapshot_tool_id(payload) call = { "name": name, @@ -3334,6 +3550,7 @@ def strip_reasoning_echo_tail(text: str) -> bool: "_live": True, "_journal_snapshot": True, "_journal_stream_id": stream_id, + "_journal_order": current_event_order, } if tool_id: call["tid"] = tool_id @@ -3342,13 +3559,16 @@ def strip_reasoning_echo_tail(text: str) -> bool: call[key] = str(payload.get(key)) if boundary_id: call["activityBurstId"] = boundary_id - call["activitySegmentSeq"] = boundary_id + if segment_seq: + call["activitySegmentSeq"] = segment_seq tool_calls.append(call) fresh_segment = True + active_reasoning_segment = None continue if event_name == "tool_complete": - update_completed_tool(payload) - fresh_segment = True + if update_completed_tool(payload): + fresh_segment = True + active_reasoning_segment = None if assistant_text or reasoning_text: message = { @@ -3378,7 +3598,14 @@ def scene_group(segment_seq: int | None = None, burst_id: int | None = None) -> group["activity_burst_id"] = burst_id return group - def scene_prose_row(text: str, *, burst_id: int | None, segment_seq: int, status: str) -> dict | None: + def scene_prose_row( + text: str, + *, + burst_id: int | None, + segment_seq: int, + journal_order: int | None, + status: str, + ) -> dict | None: clean = str(text or "").strip() if not clean: return None @@ -3418,14 +3645,22 @@ def scene_prose_row(text: str, *, burst_id: int | None, segment_seq: int, status "activitySegmentSeq": segment_seq, "activityBurstId": burst_id or 0, }, + "_journal_order": journal_order, } - def scene_thinking_row(text: str, *, status: str) -> dict | None: + def scene_thinking_row( + text: str, + *, + segment_seq: int, + created_at, + journal_order: int | None, + status: str, + ) -> dict | None: clean = str(text or "").strip() if not clean: return None preview = " ".join(clean.split()) - local_id = f"live-thinking:{stream_id}:1" + local_id = f"live-reasoning:{stream_id}:{segment_seq}" return { "row_id": local_id, "order_index": len(anchor_activity_rows), @@ -3443,7 +3678,7 @@ def scene_thinking_row(text: str, *, status: str) -> dict | None: "stream_id": stream_id, "seq": None, "status": status, - "created_at": last_ts, + "created_at": created_at, "identity": { "event_id": None, "local_id": local_id, @@ -3451,7 +3686,7 @@ def scene_thinking_row(text: str, *, status: str) -> dict | None: "stream_id": stream_id, "seq": None, }, - "group": scene_group(), + "group": scene_group(segment_seq), "text": clean, "thinking": { "text": clean, @@ -3462,7 +3697,9 @@ def scene_thinking_row(text: str, *, status: str) -> dict | None: "tool": None, "payload": { "text": clean, + "activitySegmentSeq": segment_seq, }, + "_journal_order": journal_order, } def scene_tool_row(call: dict, *, fallback_order: int) -> dict | None: @@ -3534,24 +3771,43 @@ def scene_tool_row(call: dict, *, fallback_order: int) -> dict | None: "tool_call_id": tool_id, "tool": tool, "payload": payload, + "_journal_order": int(call.get("_journal_order") or 0) or None, } anchor_activity_rows: list[dict] = [] - thinking_row_inserted = False + thinking_rows_inserted: set[int] = set() tool_rows_rendered = 0 - def append_thinking_row(*, force: bool = False) -> None: - nonlocal thinking_row_inserted - if thinking_row_inserted: - return - if not force and reasoning_first_tool_count and tool_rows_rendered < reasoning_first_tool_count: - return - row = scene_thinking_row(reasoning_text, status="running") - if not row: - return - row["order_index"] = len(anchor_activity_rows) - anchor_activity_rows.append(row) - thinking_row_inserted = True + def append_thinking_rows( + *, + max_segment_seq: int | None = None, + max_text_offset: int | None = None, + force: bool = False, + ) -> None: + for index, segment in enumerate(reasoning_segments): + if index in thinking_rows_inserted: + continue + segment_seq = max(1, int(segment.get("segment_seq") or 1)) + first_tool_count = max(0, int(segment.get("first_tool_count") or 0)) + if not force and tool_rows_rendered < first_tool_count: + continue + if not force and max_segment_seq is not None and segment_seq > max_segment_seq: + continue + text_offset = max(0, int(segment.get("text_offset") or 0)) + if not force and max_text_offset is not None and text_offset > max_text_offset: + continue + row = scene_thinking_row( + str(segment.get("text") or ""), + segment_seq=segment_seq, + created_at=segment.get("created_at"), + journal_order=int(segment.get("journal_order") or 0) or None, + status=snapshot_row_status, + ) + if not row: + continue + row["order_index"] = len(anchor_activity_rows) + anchor_activity_rows.append(row) + thinking_rows_inserted.add(index) tool_rows_by_burst: dict[int, list[tuple[int, dict]]] = {} ungrouped_tool_rows: list[tuple[int, dict]] = [] @@ -3578,38 +3834,57 @@ def append_thinking_row(*, force: bool = False) -> None: for anchor in sorted_anchors: burst_id = int(anchor.get("id") or 0) or None text_end = min(len(assistant_text), int(anchor.get("textEnd") or 0)) - segment_seq = burst_id or (len(anchor_activity_rows) + 1) + segment_seq = max(1, int(anchor.get("segment_seq") or burst_id or 1)) prose = scene_prose_row( assistant_text[text_start:text_end], burst_id=burst_id, segment_seq=segment_seq, + journal_order=int(anchor.get("journal_order") or 0) or None, status="completed", ) if prose: + append_thinking_rows( + max_segment_seq=segment_seq, + max_text_offset=text_start, + ) anchor_activity_rows.append(prose) - append_thinking_row() + append_thinking_rows( + max_segment_seq=segment_seq, + max_text_offset=text_end, + ) for order, row in tool_rows_by_burst.get(burst_id or 0, []): row["order_index"] = len(anchor_activity_rows) anchor_activity_rows.append(row) consumed_tools.add(order) tool_rows_rendered += 1 - append_thinking_row() + append_thinking_rows( + max_segment_seq=segment_seq + 1, + max_text_offset=text_end, + ) text_start = max(text_start, text_end) if text_start < len(assistant_text): - segment_seq = max(len(sorted_anchors) + 1, 1) + segment_seq = max(1, int(active_live_segment_seq or live_segment_seq_high_water or 1)) tail = scene_prose_row( assistant_text[text_start:], burst_id=None, segment_seq=segment_seq, - status="running", + journal_order=active_prose_started_order, + status=snapshot_row_status, ) if tail: + append_thinking_rows( + max_segment_seq=segment_seq, + max_text_offset=text_start, + ) anchor_activity_rows.append(tail) - append_thinking_row() + append_thinking_rows( + max_segment_seq=segment_seq, + max_text_offset=len(assistant_text), + ) if not assistant_text: - append_thinking_row() + append_thinking_rows(max_segment_seq=1, max_text_offset=0) for order, row in sorted(ungrouped_tool_rows, key=lambda item: item[0]): if order in consumed_tools: @@ -3617,9 +3892,22 @@ def append_thinking_row(*, force: bool = False) -> None: row["order_index"] = len(anchor_activity_rows) anchor_activity_rows.append(row) tool_rows_rendered += 1 - append_thinking_row() + append_thinking_rows() - append_thinking_row(force=True) + append_thinking_rows(force=True) + + for projection_order, row in enumerate(anchor_activity_rows): + row["_projection_order"] = projection_order + anchor_activity_rows.sort( + key=lambda row: ( + int(row.get("_journal_order") or 0) or len(events) + 1, + int(row.get("_projection_order") or 0), + ) + ) + for order_index, row in enumerate(anchor_activity_rows): + row["order_index"] = order_index + row.pop("_journal_order", None) + row.pop("_projection_order", None) # Keep a live anchor shell during session-switch replay even before the # journal has projected visible prose or tool rows from the first events. @@ -3641,7 +3929,7 @@ def append_thinking_row(*, force: bool = False) -> None: "run_id": run_id, "stream_id": stream_id, "seq": None, - "status": "running", + "status": snapshot_row_status, "created_at": last_ts, "identity": { "event_id": None, @@ -3665,7 +3953,24 @@ def append_thinking_row(*, force: bool = False) -> None: if int(anchor.get("textEnd") or 0) < len(assistant_text) ] segment_count = len(visible_anchors) + (1 if assistant_text else 0) - current_live_segment_seq = max(segment_count, len(activity_burst_anchors), 0) + reasoning_segment_seq = max( + live_segment_seq_high_water, + max( + [int(segment.get("segment_seq") or 0) for segment in reasoning_segments] + or [0] + ), + ) + tool_segment_seq = max( + [int(call.get("activitySegmentSeq") or 0) for call in tool_calls] + or [0] + ) + current_live_segment_seq = max( + segment_count, + len(activity_burst_anchors), + reasoning_segment_seq, + tool_segment_seq, + 0, + ) try: summary_last_seq = max(0, int(summary.get("last_seq") or 0)) except (TypeError, ValueError): @@ -3688,18 +3993,35 @@ def append_thinking_row(*, force: bool = False) -> None: # Keep returning a live snapshot even when the journal has events but no # projected message/tool rows yet. The frontend treats the empty activity # scene as "nothing renderable yet" while preserving the live cursor. + public_activity_burst_anchors = [ + {"id": anchor.get("id"), "textEnd": anchor.get("textEnd")} + for anchor in activity_burst_anchors + ] + settled_final_answer = "" + if settled and terminal_state == "completed" and assistant_text: + last_activity_end = max( + [int(anchor.get("textEnd") or 0) for anchor in activity_burst_anchors] + or [0] + ) + settled_final_answer = assistant_text[last_activity_end:].strip() or assistant_text.strip() + for call in tool_calls: + call.pop("_journal_order", None) return { "session_id": session_id, "stream_id": stream_id, "last_seq": last_seq, "last_event_id": last_event_id, + "terminal_state": terminal_state or None, + "terminal_message_index": terminal_message_index, + "terminal_message_target": terminal_message_target, + "terminal_disposition": terminal_disposition, "event_count": len(events), "fresh_segment": fresh_segment, "messages": messages, "tool_calls": tool_calls, "last_assistant_text": assistant_text, "last_reasoning_text": reasoning_text, - "activity_burst_anchors": activity_burst_anchors, + "activity_burst_anchors": public_activity_burst_anchors, "current_activity_burst_id": current_activity_burst_id, "current_live_segment_seq": current_live_segment_seq, "anchor_activity_scene": { @@ -3712,12 +4034,12 @@ def append_thinking_row(*, force: bool = False) -> None: "source_message_refs": [], }, "lifecycle": { - "status": "running", - "terminal_state": None, + "status": terminal_state or snapshot_row_status, + "terminal_state": terminal_state or None, }, - "final_answer": "", + "final_answer": settled_final_answer, "final_message_ref": None, - "terminal_state": None, + "terminal_state": terminal_state or None, "activity_rows": anchor_activity_rows, }, } @@ -4107,6 +4429,57 @@ def _anchor_scene_message_turn_duration(message): return None +def _anchor_scene_final_answer_hint_from_scene(scene) -> str: + if not isinstance(scene, dict): + return "" + explicit = scene.get("final_answer") if isinstance(scene.get("final_answer"), str) else "" + if _anchor_scene_clean_text(explicit): + return explicit + return "" + + +def _anchor_scene_final_answer_hint_from_transcript(message_text, scene) -> str: + raw = str(message_text or "").strip() + if not raw or not isinstance(scene, dict): + return "" + rows = scene.get("activity_rows") if isinstance(scene.get("activity_rows"), list) else [] + last_tool_index = -1 + for idx, row in enumerate(rows): + if isinstance(row, dict) and row.get("role") == "tool": + last_tool_index = idx + if last_tool_index < 0: + return "" + prefixes = [] + for idx, row in enumerate(rows): + if idx > last_tool_index or not isinstance(row, dict): + continue + if row.get("role") != "prose" or row.get("kind") != "process_prose": + continue + if str(row.get("source_event_type") or "") != "token": + continue + if not str(row.get("local_id") or "").startswith("live-prose:"): + continue + text = str(row.get("text") or "").strip() + if text: + prefixes.append(text) + if not prefixes: + return "" + tail = raw + consumed = False + for prefix in prefixes: + if tail.startswith(prefix): + tail = tail[len(prefix) :].strip() + consumed = True + if consumed and _anchor_scene_clean_text(tail): + return tail + for prefix in sorted(prefixes, key=len, reverse=True): + if raw.startswith(prefix): + tail = raw[len(prefix) :].strip() + if _anchor_scene_clean_text(tail): + return tail + return "" + + def _anchor_scene_tool_id(tool) -> str: if not isinstance(tool, dict): return "" @@ -4570,18 +4943,19 @@ def _anchor_scene_row_has_live_identity(row) -> bool: return any(str(value or "").startswith("live-") for value in values) -def _anchor_scene_settle_live_running_row(row, *, has_settled_thinking: bool): +def _anchor_scene_settle_live_running_row(row, *, drop_live_thinking: bool = False): if not isinstance(row, dict): return row role = row.get("role") if role not in ("thinking", "prose", "tool"): return row + has_live_identity = _anchor_scene_row_has_live_identity(row) + if role == "thinking" and drop_live_thinking and has_live_identity: + return None if str(row.get("status") or "").lower() != "running": return row - if not _anchor_scene_row_has_live_identity(row): + if not has_live_identity: return row - if role == "thinking" and has_settled_thinking: - return None next_row = copy.deepcopy(row) next_row["status"] = "completed" return next_row @@ -4603,11 +4977,45 @@ def _complete_hydrated_anchor_scene(messages, scene, message_index, *, message_o turn_start = idx break message_final_answer = _anchor_scene_final_answer_text(final_message) - scene_final_answer = scene.get("final_answer") if isinstance(scene.get("final_answer"), str) else "" - final_answer = message_final_answer if _anchor_scene_clean_text(message_final_answer) else scene_final_answer + scene_final_answer = _anchor_scene_final_answer_hint_from_scene(scene) + if not _anchor_scene_clean_text(scene_final_answer): + scene_final_answer = _anchor_scene_final_answer_hint_from_transcript(message_final_answer, scene) + final_answer = scene_final_answer if _anchor_scene_clean_text(scene_final_answer) else message_final_answer final_key = _anchor_scene_text_key(final_answer) rows = [] seen = {} + scene_thinking_rows = [ + row + for row in scene.get("activity_rows") or [] + if isinstance(row, dict) + and row.get("role") == "thinking" + and _anchor_scene_clean_text(row.get("text")) + ] + scene_has_activity_rows = any( + isinstance(row, dict) and row.get("role") != "terminal" + for row in scene.get("activity_rows") or [] + ) + scene_reasoning_key = _anchor_scene_text_key( + "".join(str(row.get("text") or "") for row in scene_thinking_rows) + ) + transcript_reasoning_key = _anchor_scene_text_key( + "".join( + ( + "".join( + _anchor_scene_content_text(part) + for part in (message.get("content") or []) + if isinstance(part, dict) and part.get("type") in ("thinking", "reasoning") + ) + or _anchor_scene_message_reasoning_text(message) + ) + for message in messages[turn_start + 1 : local_final_idx + 1] + if isinstance(message, dict) and message.get("role") == "assistant" + ) + ) + scene_has_authoritative_thinking = bool(scene_thinking_rows) and ( + not transcript_reasoning_key or scene_reasoning_key == transcript_reasoning_key + ) + drop_live_thinking = bool(transcript_reasoning_key) and not scene_has_authoritative_thinking def merge_duplicate_tool_row(existing, incoming, *, prefer_incoming_body=False): if not isinstance(existing, dict) or not isinstance(incoming, dict): @@ -4676,7 +5084,7 @@ def push(row, *, prefer_incoming_tool_body=False): return row = _anchor_scene_settle_live_running_row( row, - has_settled_thinking=any(existing.get("role") == "thinking" for existing in rows), + drop_live_thinking=drop_live_thinking, ) if row is None or not isinstance(row, dict): return @@ -4709,6 +5117,11 @@ def push(row, *, prefer_incoming_tool_body=False): next_row["seq"] = len(rows) rows.append(next_row) + if scene_has_authoritative_thinking: + for row in scene.get("activity_rows") or []: + if isinstance(row, dict) and row.get("role") != "terminal": + push(row) + order = 0 content_tool_indexes_by_idx = {} used_content_tool_indexes_by_idx = {} @@ -4731,6 +5144,15 @@ def push(row, *, prefer_incoming_tool_body=False): id_flexible_content_tool_indexes = set() if content_rows: for row in content_rows: + if scene_has_authoritative_thinking and row.get("role") == "thinking": + continue + if ( + local_idx == local_final_idx + and scene_has_activity_rows + and _anchor_scene_clean_text(scene_final_answer) + and row.get("role") == "prose" + ): + continue previous_len = len(rows) push(row) if row.get("role") == "tool" and len(rows) > previous_len: @@ -4741,10 +5163,20 @@ def push(row, *, prefer_incoming_tool_body=False): used_content_tool_indexes_by_idx[absolute_idx] = used_content_tool_indexes id_flexible_content_tool_indexes_by_idx[absolute_idx] = id_flexible_content_tool_indexes elif _anchor_scene_clean_text(text): - push(_anchor_scene_prose_row(text, order, absolute_idx, stream_id)) - order += 1 + skip_transcript_final_prose = ( + local_idx == local_final_idx + and scene_has_activity_rows + and _anchor_scene_clean_text(scene_final_answer) + ) + if not skip_transcript_final_prose: + push(_anchor_scene_prose_row(text, order, absolute_idx, stream_id)) + order += 1 reasoning = _anchor_scene_message_reasoning_text(message) - if _anchor_scene_clean_text(reasoning) and _anchor_scene_text_key(reasoning) != _anchor_scene_text_key(text): + if ( + not scene_has_authoritative_thinking + and _anchor_scene_clean_text(reasoning) + and _anchor_scene_text_key(reasoning) != _anchor_scene_text_key(text) + ): push(_anchor_scene_thinking_row(reasoning, order, absolute_idx, stream_id)) order += 1 for key in ("tool_calls", "_partial_tool_calls"): @@ -4824,9 +5256,10 @@ def push(row, *, prefer_incoming_tool_body=False): continue push(row, prefer_incoming_tool_body=True) order += 1 - for row in scene.get("activity_rows") or []: - if isinstance(row, dict) and row.get("role") != "terminal": - push(row) + if not scene_has_authoritative_thinking: + for row in scene.get("activity_rows") or []: + if isinstance(row, dict) and row.get("role") != "terminal": + push(row) for row in scene.get("activity_rows") or []: if isinstance(row, dict) and row.get("role") == "terminal": push(row) @@ -4916,6 +5349,181 @@ def _hydrate_anchor_activity_scenes(messages, records, *, message_offset=0, tool return out +def _anchor_scene_record_exists_for_message(records, message, message_index) -> bool: + if not isinstance(records, dict) or not isinstance(message, dict): + return False + ref = _assistant_anchor_scene_message_ref(message) + for key, record in records.items(): + if not isinstance(record, dict): + continue + if ref and (str(key or "") == ref or str(record.get("message_ref") or "") == ref): + return True + try: + record_index = int(record.get("message_index")) + except (TypeError, ValueError): + record_index = None + if record_index == message_index: + return True + return False + + +def _anchor_scene_record_exists_for_stream(records, stream_id: str) -> bool: + stream_id = str(stream_id or "").strip() + if not stream_id or not isinstance(records, dict): + return False + for record in records.values(): + if isinstance(record, dict) and str(record.get("stream_id") or "") == stream_id: + return True + return False + + +def _terminal_anchor_scene_message_index(messages, snapshot) -> int | None: + if not isinstance(messages, list) or not isinstance(snapshot, dict): + return None + target = snapshot.get("terminal_message_target") + if not isinstance(target, dict): + return None + if target.get("version") != "terminal_message_target_v1": + return None + scene = snapshot.get("anchor_activity_scene") if isinstance(snapshot.get("anchor_activity_scene"), dict) else {} + identity = scene.get("identity") if isinstance(scene.get("identity"), dict) else {} + expected_session_id = str(snapshot.get("session_id") or "").strip() + expected_stream_id = str(snapshot.get("stream_id") or "").strip() + expected_run_id = str(identity.get("run_id") or "").strip() + target_session_id = str(target.get("session_id") or "").strip() + target_stream_id = str(target.get("stream_id") or "").strip() + target_run_id = str(target.get("run_id") or "").strip() + if ( + not target_session_id + or not target_stream_id + or not target_run_id + or not expected_run_id + or target_session_id != expected_session_id + or target_stream_id != expected_stream_id + or target_run_id != expected_run_id + ): + return None + terminal_index = target.get("message_index") + if not isinstance(terminal_index, int) or isinstance(terminal_index, bool) or terminal_index < 0: + return None + if terminal_index >= len(messages): + return None + message_ref = _normalize_anchor_scene_message_ref(target.get("message_ref") or "") + if not message_ref: + return None + message = messages[terminal_index] + if ( + not isinstance(message, dict) + or message.get("role") != "assistant" + or _assistant_anchor_scene_message_ref(message) != message_ref + ): + return None + return terminal_index + + +def _terminal_anchor_scene_non_materializable_disposition(snapshot) -> dict | None: + if not isinstance(snapshot, dict): + return None + disposition = snapshot.get("terminal_disposition") + if not isinstance(disposition, dict): + return None + if disposition.get("version") != "terminal_disposition_v1": + return None + if disposition.get("kind") != "consumed_non_materializable": + return None + return disposition + + +def _materialize_terminal_anchor_scene_from_run_journal(session, messages, *, handler=None) -> bool: + """Persist a settled Anchor scene when the browser stream owner detached before terminal.""" + sid = str(getattr(session, "session_id", "") or "").strip() + if not sid or not isinstance(messages, list) or not messages: + return False + active_stream_ids = _active_stream_ids() + with _get_session_agent_lock(sid): + skip_stream_ids = { + str(record.get("stream_id") or "").strip() + for record in dict(_anchor_scene_records(session)).values() + if isinstance(record, dict) and str(record.get("stream_id") or "").strip() + } + skip_stream_ids.update(active_stream_ids) + summaries = terminal_run_summaries_for_session( + sid, + limit=16, + max_candidates=64, + skip_run_ids=skip_stream_ids, + scan_all_candidates=True, + ) + if not summaries: + return False + with _get_session_agent_lock(sid): + records = dict(_anchor_scene_records(session)) + changed = False + for summary in summaries: + stream_id = str(summary.get("run_id") or summary.get("stream_id") or "").strip() + if not stream_id or stream_id in active_stream_ids: + continue + if _anchor_scene_record_exists_for_stream(records, stream_id): + continue + snapshot = _run_journal_live_snapshot(stream_id, handler=handler, settled=True) + disposition = _terminal_anchor_scene_non_materializable_disposition(snapshot) + if disposition is not None: + records[f"stream:{stream_id}:non_materializable"] = { + "version": "anchor_activity_scene_record_v1", + "message_index": None, + "message_ref": "", + "stream_id": stream_id, + "scene": None, + "terminal_disposition": disposition, + "updated_at": time.time(), + } + changed = True + continue + scene = snapshot.get("anchor_activity_scene") if isinstance(snapshot, dict) else None + rows = scene.get("activity_rows") if isinstance(scene, dict) else None + if not isinstance(rows, list) or not rows: + continue + message_index = _terminal_anchor_scene_message_index(messages, snapshot) + if message_index is None or not (0 <= message_index < len(messages)): + continue + message = messages[message_index] + if not isinstance(message, dict) or message.get("role") != "assistant": + continue + if _anchor_scene_record_exists_for_message(records, message, message_index): + continue + completed_scene = _complete_hydrated_anchor_scene( + messages, + scene, + message_index, + message_offset=0, + tool_calls=getattr(session, "tool_calls", None), + stream_id=stream_id, + ) + if not isinstance(completed_scene, dict) or not completed_scene.get("activity_rows"): + continue + ref = _assistant_anchor_scene_message_ref(message) + records[ref or f"index:{message_index}"] = { + "version": "anchor_activity_scene_record_v1", + "message_index": message_index, + "message_ref": ref, + "stream_id": stream_id, + "scene": completed_scene, + "updated_at": time.time(), + } + changed = True + if not changed: + return False + if len(records) > 256: + ordered = sorted( + records.items(), + key=lambda item: float((item[1] or {}).get("updated_at") or 0), + ) + records = dict(ordered[-256:]) + session.anchor_activity_scenes = records + session.save(touch_updated_at=False, skip_index=True) + return True + + def _handle_session_anchor_scene(handler, body): try: require(body, "session_id", "scene") @@ -9653,6 +10261,7 @@ def _resolve_from_rows(rows: list) -> str | None: read_session_run_events, session_journal_fingerprint, stale_interrupted_event, + terminal_run_summaries_for_session, ) from api.todo_state import attach_todo_state from api.providers import ( @@ -12718,6 +13327,7 @@ def handle_get(handler, parsed) -> bool: msg_before=msg_before, expand_renderable=expand_renderable, ) + _materialize_terminal_anchor_scene_from_run_journal(s, _all_msgs, handler=handler) if msg_limit is not None: _truncated_msgs = _messages_for_limited_payload(_truncated_msgs) _truncated_msgs = _hydrate_anchor_activity_scenes( diff --git a/api/run_journal.py b/api/run_journal.py index 81abf2fc46..cf5669f0e0 100644 --- a/api/run_journal.py +++ b/api/run_journal.py @@ -561,6 +561,86 @@ def find_run_summary(run_id: str, *, session_dir: Path | None = None) -> dict | return None +def latest_terminal_run_summary_for_session( + session_id: str, + *, + session_dir: Path | None = None, +) -> dict | None: + """Return the newest terminal run journal summary for one session.""" + summaries = terminal_run_summaries_for_session( + session_id, + session_dir=session_dir, + limit=1, + ) + return summaries[0] if summaries else None + + +def terminal_run_summaries_for_session( + session_id: str, + *, + session_dir: Path | None = None, + limit: int = 16, + max_candidates: int = 64, + skip_run_ids: Iterable[str] | None = None, + scan_all_candidates: bool = False, +) -> list[dict]: + """Return newest terminal run summaries for one session. + + ``skip_run_ids`` lets callers exclude already-resolved or active streams + before the bounded summary parse. With ``scan_all_candidates`` this provides + pagination-like progress for reconciliation without a durable cursor: newest + resolved entries no longer permanently hide older unresolved terminals. + """ + try: + sid = _validate_id(session_id, "session_id") + except ValueError: + return [] + root = Path(session_dir) if session_dir is not None else _default_session_dir() + session_root = root / RUN_JOURNAL_DIR_NAME / sid + if not session_root.exists(): + return [] + try: + limit = max(1, min(int(limit), 64)) + except (TypeError, ValueError): + limit = 16 + try: + max_candidates = max(limit, min(int(max_candidates), 256)) + except (TypeError, ValueError): + max_candidates = 64 + candidates: list[tuple[float, str, Path]] = [] + for path in session_root.glob("*.jsonl"): + try: + run_id = _validate_id(path.stem, "run_id") + except ValueError: + continue + try: + mtime = path.stat().st_mtime + except OSError: + continue + candidates.append((mtime, run_id, path)) + skip_run_ids = {str(run_id or "").strip() for run_id in (skip_run_ids or [])} + ordered_candidates = sorted(candidates, reverse=True) + if not scan_all_candidates: + ordered_candidates = ordered_candidates[:max_candidates] + summaries: list[dict] = [] + inspected = 0 + for _mtime, run_id, path in ordered_candidates: + if run_id in skip_run_ids: + continue + inspected += 1 + if not scan_all_candidates and inspected > max_candidates: + break + summary = latest_run_summary(sid, run_id, session_dir=root) + if not summary.get("terminal"): + continue + summary = dict(summary) + summary["path"] = str(path) + summaries.append(summary) + if len(summaries) >= limit: + break + return summaries + + def read_session_run_events( session_id: str, *, diff --git a/api/streaming.py b/api/streaming.py index 115b6fe57a..f5067b4d2c 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -7459,6 +7459,18 @@ def put(event, data): # If cancelled, drop all further events except the cancel event itself if cancel_event.is_set() and not _success_writeback_committed and event not in ('cancel', 'error'): return + if event in ('cancel', 'error', 'apperror'): + payload = data if isinstance(data, dict) else {} + if not isinstance(payload.get('session'), dict) and s is not None: + session_payload = _redacted_session_payload_with_full_messages( + s, + tool_calls=getattr(s, 'tool_calls', None), + ) + if session_payload: + payload = dict(payload) + payload['session'] = session_payload + payload['session_id'] = session_payload.get('session_id') or session_id + data = payload event_id = None if run_journal is not None: try: diff --git a/static/assistant_turn_anchors.js b/static/assistant_turn_anchors.js index a237b7af1a..1cfeb232e5 100644 --- a/static/assistant_turn_anchors.js +++ b/static/assistant_turn_anchors.js @@ -392,7 +392,11 @@ const sessionId=_cleanString(_own(event,'session_id')||_payloadSessionId||_own(ctx,'session_id')); const turnId=_cleanString(_own(event,'turn_id')||_payloadTurnId||_own(ctx,'turn_id')); const streamId=_cleanString(_own(event,'stream_id')||_payloadStreamId||_own(ctx,'stream_id'))||null; - const localId=_localIdForSourceEvent(sourceType, {...ctx,seq}, payload); + const localId=_localIdForSourceEvent(sourceType, { + ...ctx, + seq, + local_id:_own(event,'local_id'), + }, payload); const anchorEvent={ event_id:eventId||null, local_id:localId, diff --git a/static/messages.js b/static/messages.js index 9f1532f146..a26f862516 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2112,7 +2112,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ : ''; assistantText = _lastLiveAssistant ? _lastLiveAssistant : ''; reasoningText=_lastLiveReasoning ? _lastLiveReasoning : ''; - let liveReasoningText = reasoningText; + // The durable value above is the whole-turn aggregate. After reattachment, + // new reasoning belongs to a fresh Anchor segment; seeding its live buffer + // with the aggregate would repeat every restored Thinking row in that card. + let liveReasoningText = reconnecting ? '' : reasoningText; let visibleInterimSnippets=[]; let _latestGoalStatus=null; let _pendingGoalContinuation=null; @@ -3336,6 +3339,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const text=_anchorSceneMessageText(message); const contentRows=_anchorSceneRowsFromContentParts(message,idx,{isFinalMessage:idx===lastAsstIndex}); const hasOrderedContentRows=Array.isArray(contentRows)&&contentRows.length>0; + const hasOrderedContentThinking=hasOrderedContentRows&&contentRows.some(row=>row&&row.role==='thinking'); const contentToolRows=[]; const usedContentToolRows=new Set(); const idFlexibleContentToolRows=new Set(); @@ -3352,7 +3356,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ pool.push({..._anchorSceneProseRow(text,0,idx),_phase:2,_encounter:encounter++}); } const reasoning=_anchorSceneMessageReasoningText(message); - if(_anchorSceneCleanText(reasoning)&&_anchorSceneTextKey(reasoning)!==_anchorSceneTextKey(text)){ + if(!hasOrderedContentThinking&&_anchorSceneCleanText(reasoning)&&_anchorSceneTextKey(reasoning)!==_anchorSceneTextKey(text)){ pool.push({..._anchorSceneThinkingRow(reasoning,0,idx),_phase:0,_encounter:encounter++}); } const messageTools=[]; @@ -3476,12 +3480,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } return false; } - function _anchorSceneSettleLiveRunningRow(row, hasSettledThinking){ + function _anchorSceneSettleLiveRunningRow(row, dropLiveThinking){ if(!row||typeof row!=='object') return row; if(row.role!=='thinking'&&row.role!=='prose'&&row.role!=='tool') return row; + const hasLiveIdentity=_anchorSceneRowHasLiveIdentity(row); + if(row.role==='thinking'&&dropLiveThinking&&hasLiveIdentity) return null; if(String(row.status||'').toLowerCase()!=='running') return row; - if(!_anchorSceneRowHasLiveIdentity(row)) return row; - if(row.role==='thinking'&&hasSettledThinking) return null; + if(!hasLiveIdentity) return row; return {...row,status:'completed'}; } function _anchorSceneRowLooksLikeFinalAnswer(rowTextKey, finalKey){ @@ -3507,6 +3512,47 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } return false; } + function _anchorSceneFinalAnswerHintFromScene(scene){ + if(!scene||typeof scene!=='object') return ''; + const explicit=typeof scene.final_answer==='string'?scene.final_answer:''; + if(_anchorSceneCleanText(explicit)) return explicit; + return ''; + } + function _anchorSceneFinalAnswerHintFromTranscript(messageText, scene){ + const raw=String(messageText||'').trim(); + if(!raw||!scene||typeof scene!=='object') return ''; + const rows=Array.isArray(scene.activity_rows)?scene.activity_rows:[]; + let lastToolIndex=-1; + rows.forEach((row,idx)=>{ if(row&&row.role==='tool') lastToolIndex=idx; }); + if(lastToolIndex<0) return ''; + const prefixes=[]; + rows.forEach((row,idx)=>{ + if(idx>lastToolIndex||!row||row.role!=='prose'||row.kind!=='process_prose') return; + if(String(row.source_event_type||'')!=='token') return; + if(!String(row.local_id||'').startsWith('live-prose:')) return; + const text=String(row.text||'').trim(); + if(text) prefixes.push(text); + }); + if(!prefixes.length) return ''; + let tail=raw; + let consumed=false; + prefixes.forEach((prefix)=>{ + if(tail.startsWith(prefix)){ + tail=tail.slice(prefix.length).trim(); + consumed=true; + } + }); + if(consumed&&_anchorSceneCleanText(tail)) return tail; + prefixes.slice().sort((a,b)=>b.length-a.length).some((prefix)=>{ + if(!raw.startsWith(prefix)) return false; + const candidate=raw.slice(prefix.length).trim(); + if(!_anchorSceneCleanText(candidate)) return false; + tail=candidate; + consumed=true; + return true; + }); + return consumed?_anchorSceneCleanText(tail)&&tail||'':''; + } function _anchorSceneTurnDurationForSettlement(lastAsst, base){ if(lastAsst&&lastAsst._turnDuration!==undefined&&lastAsst._turnDuration!==null) return lastAsst._turnDuration; if(base&&base.turn_duration!==undefined&&base.turn_duration!==null) return base.turn_duration; @@ -3535,7 +3581,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } return undefined; } - function _completeSettledAnchorSceneForTurn(messages, lastAsstIndex, projectedScene){ + function _completeSettledAnchorSceneForTurn(messages, lastAsstIndex, projectedScene, options={}){ if(!Array.isArray(messages)||lastAsstIndex<0) return projectedScene; const lastAsst=messages[lastAsstIndex]; if(!lastAsst||lastAsst.role!=='assistant') return projectedScene; @@ -3548,10 +3594,20 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } const base=(projectedScene&&typeof projectedScene==='object')?projectedScene:{}; const sceneMode=base.mode==='transparent_stream'||base.mode==='hide_all_activity' ? base.mode : _anchorSceneActiveMode(); + const rawFinalAnswerHint=typeof options.finalAnswer==='string'?options.finalAnswer:''; const messageFinalAnswer=_anchorSceneFinalAnswerText(lastAsst); - const finalAnswer=_anchorSceneCleanText(messageFinalAnswer) - ? messageFinalAnswer - : (typeof base.final_answer==='string'?base.final_answer:''); + const sceneFinalAnswerHint=_anchorSceneFinalAnswerHintFromScene(base); + const finalAnswerHint=sceneFinalAnswerHint; + const optionFinalAnswer=_anchorSceneFinalAnswerHintFromTranscript(rawFinalAnswerHint,base)||rawFinalAnswerHint; + const transcriptFinalAnswer=_anchorSceneFinalAnswerHintFromTranscript(messageFinalAnswer,base)||messageFinalAnswer; + const baseFinalAnswer=typeof base.final_answer==='string'?base.final_answer:''; + const finalAnswer=_anchorSceneCleanText(finalAnswerHint) + ? finalAnswerHint + : (_anchorSceneCleanText(optionFinalAnswer) + ? optionFinalAnswer + : (_anchorSceneCleanText(transcriptFinalAnswer) + ? transcriptFinalAnswer + : baseFinalAnswer)); const finalKey=_anchorSceneTextKey(finalAnswer); const messageRows=_anchorSceneRowsByMessageIndex(messages,turnStart,lastAsstIndex,{includeFinal:true}); const hasSettledThinking=_anchorSceneMessageRowsHaveThinking(messageRows); @@ -3559,6 +3615,21 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const seen=new Set(); const seenTextKeys=[]; const projectedRows=Array.isArray(base.activity_rows)?base.activity_rows:[]; + const sceneHasActivityRows=projectedRows.some(row=>row&&row.role&&row.role!=='terminal'); + const projectedReasoningKey=_anchorSceneTextKey(projectedRows + .filter(row=>row&&row.role==='thinking'&&_anchorSceneCleanText(row.text)) + .map(row=>row.text).join('')); + const settledReasoningParts=[]; + for(const bucket of messageRows.values()){ + for(const row of (Array.isArray(bucket)?bucket:[])){ + if(row&&row.role==='thinking'&&_anchorSceneCleanText(row.text)) settledReasoningParts.push(row.text); + } + } + const settledReasoningKey=_anchorSceneTextKey(settledReasoningParts.join('')); + const preferProjectedThinking=!!projectedReasoningKey&&( + !settledReasoningKey||projectedReasoningKey===settledReasoningKey + ); + const dropProjectedThinking=hasSettledThinking&&!preferProjectedThinking; const orderedRows=[]; for(const row of projectedRows){ if(row&&row.role==='terminal') continue; @@ -3566,7 +3637,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } for(let idx=turnStart+1;idx<=lastAsstIndex;idx+=1){ const bucket=messageRows.get(idx)||[]; - for(const row of bucket) orderedRows.push(row); + for(const row of bucket){ + // The transcript stores one aggregate reasoning string. Once the live + // Anchor already owns ordered reasoning segments, that aggregate is a + // fallback rather than a replacement for those segment identities. + if(preferProjectedThinking&&row&&row.role==='thinking') continue; + if(idx===lastAsstIndex&&sceneHasActivityRows&&_anchorSceneCleanText(finalAnswer)&&row&&row.role==='prose'&&String(row.source_event_type||'')==='settled_message') continue; + orderedRows.push(row); + } } for(const row of projectedRows){ if(row&&row.role==='terminal') orderedRows.push(row); @@ -3579,17 +3657,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // prefix snapshot then survives into the persisted scene and renders as a // duplicate of the answer's beginning. A live-prose row belongs to the // final segment iff no PROJECTED tool row follows it; pre-tool narration - // that happens to prefix the final answer stays protected. + // that happens to overlap the final answer stays protected. const lastProjectedToolIndex=projectedRows.reduce((last,row,idx)=>(row&&row.role==='tool')?idx:last,-1); const finalSegmentLiveProseRows=new WeakSet(); projectedRows.forEach((row,idx)=>{ if(idx>lastProjectedToolIndex&&row&&row.role==='prose'&&row.kind==='process_prose'&&String(row.source_event_type||'')==='token'&&String(row.local_id||'').startsWith('live-prose:')) finalSegmentLiveProseRows.add(row); }); - const rowIsLiveTokenFinalPrefix=(row,textKey,finalSegmentEligible)=>finalSegmentEligible&&row&&row.role==='prose'&&row.kind==='process_prose'&&String(row.source_event_type||'')==='token'&&String(row.local_id||'').startsWith('live-prose:')&&textKey&&finalKey&&textKey.lengthfinalSegmentEligible&&row&&row.role==='prose'&&row.kind==='process_prose'&&String(row.source_event_type||'')==='token'&&String(row.local_id||'').startsWith('live-prose:')&&textKey&&finalKey&&textKey.length{ if(!row||typeof row!=='object') return; const finalSegmentEligible=finalSegmentLiveProseRows.has(row); - row=_anchorSceneSettleLiveRunningRow(row,hasSettledThinking); + row=_anchorSceneSettleLiveRunningRow(row,dropProjectedThinking); if(!row||typeof row!=='object') return; const textKey=_anchorSceneTextKey(row.text); if(rowIsLiveTokenFinalPrefix(row,textKey,finalSegmentEligible)) return; @@ -3628,8 +3706,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ return scene; } let _persistAnchorSceneWarned=false; - function _anchorSceneMessageOffsetForPersist(){ - const raw=(typeof _oldestIdx!=='undefined')?_oldestIdx:0; + function _anchorSceneMessageOffsetForPersist(messageOffsetOverride){ + const raw=(messageOffsetOverride!==undefined&&messageOffsetOverride!==null) + ? messageOffsetOverride + : ((typeof _oldestIdx!=='undefined')?_oldestIdx:0); const offset=Number(raw); return Number.isFinite(offset)&&offset>0?Math.floor(offset):0; } @@ -3639,10 +3719,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(!Number.isFinite(idx)||idx<0) return messageIndex; return idx+(Number.isFinite(off)&&off>0?Math.floor(off):0); } - function _persistSettledAnchorScene(message, scene, messageIndex){ + function _persistSettledAnchorScene(message, scene, messageIndex, options={}){ if(!activeSid||!message||!scene||typeof api!=='function') return; try{ - const messageOffset=_anchorSceneMessageOffsetForPersist(); + const messageOffset=_anchorSceneMessageOffsetForPersist(options.messageOffset); api('/api/session/anchor-scene',{ method:'POST', timeoutMs:8000, @@ -3700,7 +3780,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ || (Array.isArray(scene&&scene.side_effects)&&scene.side_effects.length) ); } - function _attachProjectedAnchorSceneToLastAssistant(messages){ + function _attachProjectedAnchorSceneToLastAssistant(messages, options={}){ + const attachOptions=(typeof options==='object'&&options)?options:{}; if(!_anchorRegistry||!Array.isArray(messages)) return false; let lastAsst=null; let lastAsstIndex=-1; @@ -3714,7 +3795,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } if(!lastAsst) return false; const projectedScene=_projectLiveAnchorActivityScene(); - const scene=_completeSettledAnchorSceneForTurn(messages,lastAsstIndex,projectedScene); + const scene=_completeSettledAnchorSceneForTurn(messages,lastAsstIndex,projectedScene,attachOptions); const hasOwnedOutcomes=_anchorSceneHasOwnedOutcomes(scene); if(scene&&Array.isArray(scene.activity_rows)&&(scene.activity_rows.length||hasOwnedOutcomes)){ const hasWorklogRows=_anchorSceneHasWorklogWorthyRows(scene); @@ -3722,7 +3803,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(!shouldPersistScene) return false; lastAsst._anchor_stream_id=streamId; lastAsst._anchor_activity_scene=scene; - _persistSettledAnchorScene(lastAsst, scene, lastAsstIndex); + _persistSettledAnchorScene(lastAsst, scene, lastAsstIndex, attachOptions); return hasWorklogRows; } return false; @@ -3880,25 +3961,37 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ function _stripAnchorReasoningEcho(visible){ const events=_anchorActivityEvents(); if(!events||!visible) return false; - for(let i=events.length-1;i>=0;i-=1){ + const reasoning=[]; + for(let i=0;iitem.text).join(''); + const stripped=_stripCompactEchoSuffix(combined, visible); + if(!stripped.removed) return false; + let remaining=String(stripped.text||'').length; + const remove=[]; + for(const item of reasoning){ + if(remaining<=0){ + remove.push(item.index); + continue; + } + const nextText=item.text.slice(0,remaining); + remaining-=item.text.length; + if(nextText.trim()){ + _replaceAnchorActivityEventByLocalId(item.event.local_id,'reasoning',{ payload:{text:nextText}, }); }else{ - events.splice(i,1); + remove.push(item.index); } - _renderAnchorLiveScene(); - return true; } - return false; + remove.sort((a,b)=>b-a).forEach(index=>events.splice(index,1)); + _renderAnchorLiveScene(); + return true; } function _removeLiveReasoningEchoRows(visible){ const turn=$('liveAssistantTurn'); @@ -5085,6 +5178,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ }; } + function _sealReasoningOnlySegmentBeforeTool(){ + if(assistantRow||!String(liveReasoningText||'').trim()) return false; + const segmentSeq=_coerceLiveToolCallSeq(_liveThinkingPlacement().segmentSeq); + if(segmentSeq===undefined) return false; + _currentLiveSegmentSeq=Math.max(Number(_currentLiveSegmentSeq)||0,segmentSeq); + _assistantSegmentSeq=_currentLiveSegmentSeq; + const inflight=INFLIGHT[activeSid]; + if(inflight) inflight.currentLiveSegmentSeq=_currentLiveSegmentSeq; + return true; + } + function upsertLiveToolCall(d, phase){ if(!d||d.name==='clarify') return null; const name=String(d&&d.name||'').trim(); @@ -5463,6 +5567,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const d=JSON.parse(e.data); if(d.name==='clarify') return; _completeAutomaticCompressionOnLiveProgress(activeSid); + _sealReasoningOnlySegmentBeforeTool(); const tc=upsertLiveToolCall(d,'start'); if(!tc) return; const pendingDisplayTextBeforeTool=segmentStart===0 @@ -5499,6 +5604,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const d=JSON.parse(e.data); if(d.name==='clarify') return; _completeAutomaticCompressionOnLiveProgress(activeSid); + _sealReasoningOnlySegmentBeforeTool(); const tc=upsertLiveToolCall(d,'complete'); if(!tc) return; tc.is_error=!!d.is_error; @@ -5786,6 +5892,30 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ S.activeStreamId=null; } let lastAsst=null; + // A session switch starts by changing _loadingSessionId while the old + // pane and its chat EventSource can still own the finishing stream. Do + // not mutate that transitioning pane, but do settle and persist THIS + // closure's Anchor scene against the completed payload. Otherwise the + // final transcript survives the switch while its Worklog disappears. + if(!isActiveSession&&Array.isArray(completedSession.messages)){ + const completedMessages=completedSession.messages; + const completedLastAsst=[...completedMessages].reverse().find(m=>m&&m.role==='assistant')||null; + if(reasoningText&&completedLastAsst&&!completedLastAsst.reasoning) completedLastAsst.reasoning=reasoningText; + if(completedLastAsst&&typeof completedLastAsst.content==='string'&&completedLastAsst.content){ + const split=_splitThinkFromContent(completedLastAsst.content,completedLastAsst.reasoning); + if(split.content!==completedLastAsst.content){ + completedLastAsst.content=split.content; + if(split.reasoning) completedLastAsst.reasoning=split.reasoning; + } + } + if(completedLastAsst&&d.usage&&typeof d.usage.duration_seconds==='number'){ + completedLastAsst._turnDuration=d.usage.duration_seconds; + } + _attachProjectedAnchorSceneToLastAssistant(completedMessages,{ + messageOffset:completedSession._messages_offset||0, + finalAnswer:_stripXmlToolCalls(assistantText.slice(segmentStart)), + }); + } if(isActiveSession){ // Capture previous session totals BEFORE overwriting S.session with the new // cumulative values from the done event. prevIn/prevOut are the totals as of diff --git a/tests/browser_conversation_lifecycle.py b/tests/browser_conversation_lifecycle.py index 539d7804f9..fe689359cd 100644 --- a/tests/browser_conversation_lifecycle.py +++ b/tests/browser_conversation_lifecycle.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 -"""Public browser gate for the normal + terminal-error lifecycle. +"""Public browser gate for the conversation lifecycle proof matrix. This test boots the real WebUI server with isolated state, drives the real chat composer in Chromium, and supplies deterministic runtime events through the existing Hermes Gateway Runs API. It proves that one assistant turn keeps the -same semantic activity across live streaming, settlement, and a hard reload. +same semantic activity across live streaming, terminal errors, session +reattachment, settlement, detached completion, and a hard reload. """ from __future__ import annotations @@ -22,24 +23,35 @@ import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -from urllib.parse import urlsplit +from urllib.parse import parse_qs, urlsplit PROMPT = "Exercise the public conversation lifecycle gate." +SEED_PROMPT = "Create an idle session for the lifecycle switch gate." REASONING_TEXT = "Checking the persistent assistant turn." +SECOND_REASONING_TEXT = "Verifying the tool result before continuing." +THIRD_REASONING_TEXT = "Confirming the no-prose tool boundary." +CONTINUATION_REASONING_TEXT = "Continuing after the session reattach boundary." +PROCESS_TEXT = "Inspecting the fixture before the tool call. " FINAL_TEXT = "Lifecycle gate final answer." -FINAL_ACK_TEXT = "Lifecycle" FINAL_PREFIX = "Lifecycle gate " FINAL_SUFFIX = "final answer." +FINAL_ACK_TEXT = "Lifecycle" +SEED_FINAL_TEXT = "Idle session ready." TERMINAL_PROCESS_TEXT = "Lifecycle terminal process check" TERMINAL_ERROR_TEXT = "Lifecycle gate encountered a terminal-side error." -SCENARIO = os.environ.get("LIFECYCLE_SCENARIO", "normal").strip() or "normal" TOOL_NAME = "read_file" TOOL_ID = "lifecycle-tool-1" +SECOND_TOOL_NAME = "terminal" +SECOND_TOOL_ID = "lifecycle-tool-2" +SCENARIO = os.environ.get("LIFECYCLE_SCENARIO", "normal").strip() or "normal" TEST_BITE = os.environ.get("LIFECYCLE_TEST_BITE", "").strip() +ERROR_SCENARIOS = {"terminal-error", "detached-terminal-error"} +DETACHED_SCENARIOS = {"detached-terminal", "detached-terminal-error"} GATEWAY_ACTIVITY_TIMEOUT = 60.0 ANCHOR_SCENE_PERSIST_TIMEOUT = 60.0 ANCHOR_SCENE_PROJECTION_TIMEOUT = 10_000 +BROKEN_REASONING_LOCAL_ID = "broken-runtime-reasoning-id" def _latest_anchor_scene_from_disk(state_root: Path, session_id: str) -> dict | None: @@ -204,6 +216,67 @@ def _anchor_projection_snapshot(page) -> dict: ) +def _lifecycle_client_snapshot(page) -> dict: + return page.evaluate( + """() => { + const registries = window._liveAnchorRegistries; + const anchorApi = window.HermesAssistantTurnAnchors; + const registryScenes = []; + if (registries && typeof registries.entries === 'function') { + for (const [streamId, registry] of registries.entries()) { + let scene = null; + try { + scene = anchorApi && typeof anchorApi.projectAssistantTurnAnchorActivityScene === 'function' + ? anchorApi.projectAssistantTurnAnchorActivityScene(registry, {mode: 'compact_worklog'}) + : null; + } catch (error) { + scene = {error: String(error)}; + } + const rows = Array.isArray(scene && scene.activity_rows) ? scene.activity_rows : []; + registryScenes.push({ + streamId, + rowCount: rows.length, + rows: rows.map(row => ({ + role: row && row.role || null, + source: row && row.source_event_type || null, + localId: row && row.local_id || null, + status: row && row.status || null, + })), + }); + } + } + const sessionId = typeof S !== 'undefined' && S.session && S.session.session_id || null; + const loadingSessionId = typeof _loadingSessionId !== 'undefined' ? _loadingSessionId : null; + const inflight = typeof INFLIGHT === 'object' && INFLIGHT ? INFLIGHT : {}; + const messages = typeof S !== 'undefined' && Array.isArray(S.messages) ? S.messages : []; + return { + sessionId, + loadingSessionId, + currentPane: typeof _isSessionCurrentPane === 'function' && sessionId + ? _isSessionCurrentPane(sessionId) + : null, + busy: Boolean(typeof S !== 'undefined' && S.busy), + activeStreamId: typeof S !== 'undefined' && S.activeStreamId || null, + registryScenes, + inflight: Object.entries(inflight).map(([sid, value]) => ({ + sessionId: sid, + streamId: value && value.streamId || null, + reattach: Boolean(value && value.reattach), + anchorRowCount: Array.isArray( + value && value.anchorActivityScene && value.anchorActivityScene.activity_rows + ) ? value.anchorActivityScene.activity_rows.length : 0, + })), + messages: messages.map(message => ({ + role: message && message.role || null, + live: Boolean(message && message._live), + anchorStreamId: message && message._anchor_stream_id || null, + hasAnchorScene: Boolean(message && message._anchor_activity_scene), + })), + }; + }""" + ) + + def _wait_for_live_anchor_projection(page) -> dict: try: page.wait_for_function( @@ -240,6 +313,36 @@ def _wait_for_live_anchor_projection(page) -> dict: return _anchor_projection_snapshot(page) +def _wait_for_runtime_scene( + base_url: str, + session_id: str, + expected_reasoning_ids: list[str], + timeout: float = 10.0, +) -> dict: + deadline = time.time() + timeout + url = f"{base_url}/api/session?session_id={session_id}&messages=0&resolve_model=0" + last_scene = None + while time.time() < deadline: + try: + payload = _get_json(url) + except (json.JSONDecodeError, TimeoutError, urllib.error.URLError, OSError): + time.sleep(0.2) + continue + session = payload.get("session") if isinstance(payload, dict) else None + snapshot = session.get("runtime_journal_snapshot") if isinstance(session, dict) else None + scene = snapshot.get("anchor_activity_scene") if isinstance(snapshot, dict) else None + last_scene = scene + rows = scene.get("activity_rows", []) if isinstance(scene, dict) else [] + reasoning_ids = [row.get("local_id") for row in rows if row.get("role") == "thinking"] + if reasoning_ids == expected_reasoning_ids: + return scene + time.sleep(0.2) + raise AssertionError( + "runtime journal scene did not expose the expected reasoning identities; " + f"expected={expected_reasoning_ids!r}, scene={last_scene!r}" + ) + + def _terminate_process(proc: subprocess.Popen | None) -> None: if proc is None or proc.poll() is not None: return @@ -287,13 +390,15 @@ def _start_webui_server(repo_root: Path, env: dict, artifact_dir: Path): class DeterministicGateway: """A localhost-only Gateway Runs server with test-controlled phase gates.""" - def __init__(self, scenario: str) -> None: - self.scenario = scenario + def __init__(self) -> None: self.activity_ready = threading.Event() self.release_settle = threading.Event() + self.continuation_ready = threading.Event() + self.release_final_prefix = threading.Event() self.final_prefix_ready = threading.Event() self.release_terminal = threading.Event() self.request_body = None + self.request_bodies = [] self.emitted_events = [] self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._handler()) self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) @@ -339,6 +444,23 @@ def do_GET(self): } }) return + if request_path == "/v1/runs/lifecycle-seed-run/events": + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "close") + self.end_headers() + self._event("message.delta", { + "event": "message.delta", + "delta": SEED_FINAL_TEXT, + }) + self._event("run.completed", { + "event": "run.completed", + "usage": {"input_tokens": 8, "output_tokens": 4}, + }) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + return if request_path != "/v1/runs/lifecycle-run-1/events": self._json({"error": "not found"}, status=404) return @@ -352,11 +474,15 @@ def do_GET(self): "event": "reasoning.available", "text": REASONING_TEXT, }) - if owner.scenario == "terminal-error": + if SCENARIO in ERROR_SCENARIOS: self._event("message.delta", { "event": "message.delta", "delta": TERMINAL_PROCESS_TEXT, }) + self._event("message.delta", { + "event": "message.delta", + "delta": PROCESS_TEXT, + }) self._event("tool.started", { "event": "tool.started", "tool": TOOL_NAME, @@ -371,10 +497,40 @@ def do_GET(self): "status": "completed", "preview": "README fixture read", }) + self._event("reasoning.available", { + "event": "reasoning.available", + "text": SECOND_REASONING_TEXT, + }) + self._event("tool.started", { + "event": "tool.started", + "tool": SECOND_TOOL_NAME, + "tool_call_id": SECOND_TOOL_ID, + "status": "running", + "args": {"command": "git status --short"}, + }) + self._event("tool.completed", { + "event": "tool.completed", + "tool": SECOND_TOOL_NAME, + "tool_call_id": SECOND_TOOL_ID, + "status": "completed", + "preview": "clean fixture", + }) + self._event("reasoning.available", { + "event": "reasoning.available", + "text": THIRD_REASONING_TEXT, + }) owner.activity_ready.set() if not owner.release_settle.wait(timeout=30): return - if owner.scenario == "terminal-error": + if SCENARIO == "session-reattach": + self._event("reasoning.available", { + "event": "reasoning.available", + "text": CONTINUATION_REASONING_TEXT, + }) + owner.continuation_ready.set() + if not owner.release_final_prefix.wait(timeout=30): + return + if SCENARIO in ERROR_SCENARIOS: owner.final_prefix_ready.set() if not owner.release_terminal.wait(timeout=30): return @@ -382,22 +538,24 @@ def do_GET(self): "event": "run.failed", "error": TERMINAL_ERROR_TEXT, }) - else: - self._event("message.delta", { - "event": "message.delta", - "delta": FINAL_PREFIX, - }) - owner.final_prefix_ready.set() - if not owner.release_terminal.wait(timeout=30): - return - self._event("message.delta", { - "event": "message.delta", - "delta": FINAL_SUFFIX, - }) - self._event("run.completed", { - "event": "run.completed", - "usage": {"input_tokens": 12, "output_tokens": 5}, - }) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + return + self._event("message.delta", { + "event": "message.delta", + "delta": FINAL_PREFIX, + }) + owner.final_prefix_ready.set() + if not owner.release_terminal.wait(timeout=30): + return + self._event("message.delta", { + "event": "message.delta", + "delta": FINAL_SUFFIX, + }) + self._event("run.completed", { + "event": "run.completed", + "usage": {"input_tokens": 12, "output_tokens": 5}, + }) self.wfile.write(b"data: [DONE]\n\n") self.wfile.flush() except (BrokenPipeError, ConnectionResetError): @@ -408,8 +566,16 @@ def do_POST(self): self._json({"error": "not found"}, status=404) return length = int(self.headers.get("Content-Length", "0")) - owner.request_body = json.loads(self.rfile.read(length) or b"{}") - self._json({"run_id": "lifecycle-run-1"}) + request_body = json.loads(self.rfile.read(length) or b"{}") + owner.request_body = request_body + owner.request_bodies.append(request_body) + prompt = request_body.get("input") + if prompt == SEED_PROMPT: + self._json({"run_id": "lifecycle-seed-run"}) + elif prompt == PROMPT: + self._json({"run_id": "lifecycle-run-1"}) + else: + self._json({"error": "unexpected deterministic prompt"}, status=400) return Handler @@ -418,6 +584,7 @@ def start(self) -> None: def close(self) -> None: self.release_settle.set() + self.release_final_prefix.set() self.release_terminal.set() self._server.shutdown() self._server.server_close() @@ -446,10 +613,15 @@ def _capture_anchor_scene_requests(page): def on_request(request): if "/api/session/anchor-scene" not in request.url: return + try: + payload = request.post_data_json + except Exception: + payload = request.post_data events.append({ "type": "request", "method": request.method, "url": request.url, + "payload": payload, }) def on_response(response): @@ -500,6 +672,9 @@ def _activity_snapshot(page) -> dict: .map(el => el.innerText.trim()).filter(Boolean) : []; return { live: Boolean(document.querySelector('#liveAssistantTurn')), + turnIdentity: { + streamId: turn ? turn.getAttribute('data-anchor-stream-id') || '' : '', + }, clientState: { busy: Boolean(typeof S !== 'undefined' && S.busy), activeStreamId: (typeof S !== 'undefined' && S.activeStreamId) || null, @@ -513,6 +688,9 @@ def _activity_snapshot(page) -> dict: settled: group.getAttribute('data-anchor-settled-scene-owner'), classes: group.className, deferred: group.getAttribute('data-worklog-rows-deferred'), + activityKey: group.getAttribute('data-tool-worklog-key') || '', + streamId: group.getAttribute('data-anchor-stream-id') || '', + turnStartedAt: group.getAttribute('data-turn-started-at') || '', expanded: (group.querySelector('.tool-worklog-summary,.tool-call-group-summary') || {}) .getAttribute?.('aria-expanded') || '', })), @@ -520,6 +698,9 @@ def _activity_snapshot(page) -> dict: role: row.getAttribute('data-anchor-row-role'), source: row.getAttribute('data-anchor-source-event-type'), status: row.getAttribute('data-anchor-row-status'), + rowId: row.getAttribute('data-anchor-row-id') || '', + localId: row.getAttribute('data-anchor-local-id') || '', + streamId: row.getAttribute('data-anchor-stream-id') || '', tool: row.getAttribute('data-tool-name'), text: row.innerText.trim(), classes: row.className, @@ -584,10 +765,6 @@ def _is_terminal_process_row_text(text: str) -> bool: ) -def _tool_rows(snapshot: dict) -> list[dict]: - return [row for row in snapshot["rows"] if row["role"] == "tool"] - - def _is_terminal_row_error(row: dict) -> bool: status = (row.get("status") or "").strip() if status in {"error", "failed"}: @@ -595,7 +772,7 @@ def _is_terminal_row_error(row: dict) -> bool: if status and status not in {"", "done"}: return False row_text = (row.get("text") or "").lower() - row_classes = (row.get("classes") or "") + row_classes = row.get("classes") or "" return ( "error" in row_text or "warning" in row_classes @@ -603,51 +780,64 @@ def _is_terminal_row_error(row: dict) -> bool: ) -def _assert_no_running_tool_rows(rows: list[dict]) -> None: - running = [ - row - for row in rows - if ( - row.get("status") == "running" - or ("tool-card-running" in row.get("classes", "")) - ) - ] - assert not running, rows +def _assert_process_row_present(snapshot: dict) -> list[dict]: + rows = _process_rows(snapshot) + assert len(rows) == 1, snapshot + assert all(_is_terminal_process_row_text(row["text"]) for row in rows), snapshot + assert all(TERMINAL_PROCESS_TEXT not in text for text in snapshot["visibleFinal"]), snapshot + return rows -def _assert_live_activity(snapshot: dict) -> None: +def _assert_live_activity(snapshot: dict, *, reasoning_count: int = 3) -> None: assert snapshot["live"], snapshot assert snapshot["groupCount"] == 1, snapshot roles = [row["role"] for row in snapshot["rows"]] - assert roles.count("thinking") == 1, snapshot - assert roles.count("tool") == 1, snapshot - tool_rows = _tool_rows(snapshot) - assert len(tool_rows) == 1 and tool_rows[0]["tool"] == TOOL_NAME, snapshot - _assert_no_running_tool_rows(tool_rows) + assert roles.count("thinking") == reasoning_count, snapshot + assert roles.count("tool") == 2, snapshot + tool_rows = [row for row in snapshot["rows"] if row["role"] == "tool"] + assert [row["tool"] for row in tool_rows] == [TOOL_NAME, SECOND_TOOL_NAME], snapshot + assert all("tool-card-running" not in row["classes"] for row in tool_rows), snapshot assert any(REASONING_TEXT in row["text"] for row in snapshot["rows"]), snapshot + assert any(SECOND_REASONING_TEXT in row["text"] for row in snapshot["rows"]), snapshot + assert any(THIRD_REASONING_TEXT in row["text"] for row in snapshot["rows"]), snapshot + expected_order = [ + "thinking", + "tool", + "thinking", + "tool", + "thinking", + ] + if reasoning_count == 4: + assert any(CONTINUATION_REASONING_TEXT in row["text"] for row in snapshot["rows"]), snapshot + expected_order.append("thinking") + assert [row["role"] for row in snapshot["rows"] if row["role"] in {"thinking", "tool"}] == expected_order, snapshot assert all(FINAL_TEXT not in text for text in snapshot["visibleFinal"]), snapshot assert any(character.isdigit() for character in snapshot["summary"][0]["label"]), snapshot -def _assert_settled(snapshot: dict, scenario: str) -> None: +def _assert_settled(snapshot: dict, *, reasoning_count: int, scenario: str) -> None: assert not snapshot["live"], snapshot assert snapshot["groupCount"] == 1, snapshot roles = [row["role"] for row in snapshot["rows"]] - assert "thinking" in roles and "tool" in roles, snapshot - tool_rows = _tool_rows(snapshot) - assert len(tool_rows) == 1 and tool_rows[0]["tool"] == TOOL_NAME, snapshot - _assert_no_running_tool_rows(tool_rows) + assert roles.count("thinking") == reasoning_count and roles.count("tool") == 2, snapshot + tool_rows = [row for row in snapshot["rows"] if row["role"] == "tool"] + assert [row["tool"] for row in tool_rows] == [TOOL_NAME, SECOND_TOOL_NAME], snapshot + assert all("tool-card-running" not in row["classes"] for row in tool_rows), snapshot assert any(REASONING_TEXT in row["text"] for row in snapshot["rows"]), snapshot - if scenario == "terminal-error": - assert "terminal" in roles, snapshot - terminal_rows = _terminal_rows(snapshot) - assert terminal_rows, snapshot - assert all(_is_terminal_row_error(row) for row in terminal_rows), snapshot + assert any(THIRD_REASONING_TEXT in row["text"] for row in snapshot["rows"]), snapshot + if reasoning_count == 4: + assert any(CONTINUATION_REASONING_TEXT in row["text"] for row in snapshot["rows"]), snapshot + if scenario in ERROR_SCENARIOS: + if scenario == "terminal-error": + assert "terminal" in roles, snapshot + terminal_rows = _terminal_rows(snapshot) + assert terminal_rows, snapshot + assert all(_is_terminal_row_error(row) for row in terminal_rows), snapshot assert snapshot["assistantMessage"] is not None, snapshot turn_duration = snapshot["assistantMessage"].get("turnDuration") assert isinstance(turn_duration, (int, float)) and turn_duration > 0, snapshot assert snapshot["assistantMessage"]["hasError"] is True, snapshot - assert snapshot["assistantMessage"]["anchorTerminalState"] in {"error", "failed"}, snapshot + assert snapshot["assistantMessage"]["anchorTerminalState"] in {"error", "failed", "errored"}, snapshot assert all(FINAL_TEXT not in text for text in snapshot["visibleFinal"]), snapshot assert sum(TERMINAL_ERROR_TEXT in text for text in snapshot["visibleFinal"]) == 1, snapshot assert TERMINAL_ERROR_TEXT in snapshot["transcript"], snapshot @@ -656,6 +846,26 @@ def _assert_settled(snapshot: dict, scenario: str) -> None: assert snapshot["transcript"].count(FINAL_TEXT) == 1, snapshot +def _semantic_activity(snapshot: dict, *, include_identity: bool = False) -> list[dict]: + """Canonical user-visible activity in renderer order.""" + semantic = [] + for row in snapshot["rows"]: + if row["role"] == "thinking": + text = " ".join(row["text"].split()) + if text.startswith("Thinking "): + text = text[len("Thinking ") :] + entry = {"role": "thinking", "text": text} + elif row["role"] == "tool": + entry = {"role": "tool", "tool": row["tool"]} + else: + continue + if include_identity: + entry["rowId"] = row["rowId"] + entry["localId"] = row["localId"] + semantic.append(entry) + return semantic + + def _parse_json_payload(raw: str | bytes | None) -> dict: if raw is None: return {} @@ -672,29 +882,91 @@ def _parse_json_payload(raw: str | bytes | None) -> dict: return payload -def _assert_process_row_present(snapshot: dict) -> list[dict]: - rows = _process_rows(snapshot) - assert len(rows) == 1, snapshot - assert all(_is_terminal_process_row_text(row["text"]) for row in rows), snapshot - assert all(TERMINAL_PROCESS_TEXT not in text for text in snapshot["visibleFinal"]), snapshot - return rows - - -def _semantic_activity(snapshot: dict) -> list[dict]: - """Canonical user-visible activity, independent of renderer row ordering.""" - semantic = [] - for row in snapshot["rows"]: - if row["role"] == "thinking": - text = " ".join(row["text"].split()) - if text.startswith("Thinking "): - text = text[len("Thinking ") :] - semantic.append({"role": "thinking", "text": text}) - elif row["role"] == "tool": - semantic.append({"role": "tool", "tool": row["tool"]}) - return sorted(semantic, key=lambda item: json.dumps(item, sort_keys=True)) +def _assert_same_live_turn(before: dict, after: dict) -> None: + _assert_live_activity(after) + assert _semantic_activity(after) == _semantic_activity(before), { + "before": _semantic_activity(before), + "after": _semantic_activity(after), + } + assert after["clientState"]["activeStreamId"] == before["clientState"]["activeStreamId"], { + "before": before["clientState"], + "after": after["clientState"], + } + assert after["turnIdentity"]["streamId"] == before["turnIdentity"]["streamId"], { + "before": before["turnIdentity"], + "after": after["turnIdentity"], + } + before_group = before["summary"][0] + after_group = after["summary"][0] + for key in ("activityKey", "streamId", "turnStartedAt"): + assert after_group[key] == before_group[key], {"key": key, "before": before_group, "after": after_group} + # ``rowId`` includes the row's current projection index. Reattachment may + # legitimately rebuild that projection, while ``localId`` is the durable + # source-event identity that must survive the rebuild. + before_rows = [(row["role"], row["localId"]) for row in before["rows"]] + after_rows = [(row["role"], row["localId"]) for row in after["rows"]] + assert after_rows == before_rows, {"before": before_rows, "after": after_rows} + + +def _assert_live_continuation(before: dict, after: dict) -> None: + _assert_live_activity(after, reasoning_count=4) + before_semantic = _semantic_activity(before) + after_semantic = _semantic_activity(after) + assert after_semantic[:-1] == before_semantic, { + "before": before_semantic, + "after": after_semantic, + } + assert {key: after_semantic[-1][key] for key in ("role", "text")} == { + "role": "thinking", + "text": CONTINUATION_REASONING_TEXT, + }, after_semantic + stream_id = before["clientState"]["activeStreamId"] + assert after["rows"][-1]["localId"] == f"live-reasoning:{stream_id}:4", after + + +def _replace_runtime_reasoning_identity(route, session_id: str, bite_hits: list[str]) -> None: + parsed = urlsplit(route.request.url) + query = parse_qs(parsed.query) + is_target = ( + route.request.method == "GET" + and parsed.path == "/api/session" + and query.get("session_id") == [session_id] + and query.get("messages") == ["0"] + and query.get("resolve_model") == ["0"] + ) + if not is_target: + route.continue_() + return + response = route.fetch() + payload = response.json() + session = payload.get("session") if isinstance(payload, dict) else None + snapshot = session.get("runtime_journal_snapshot") if isinstance(session, dict) else None + scene = snapshot.get("anchor_activity_scene") if isinstance(snapshot, dict) else None + rows = scene.get("activity_rows", []) if isinstance(scene, dict) else [] + reasoning = next((row for row in rows if row.get("role") == "thinking"), None) + if isinstance(reasoning, dict): + reasoning["local_id"] = BROKEN_REASONING_LOCAL_ID + reasoning["row_id"] = BROKEN_REASONING_LOCAL_ID + identity = reasoning.get("identity") + if isinstance(identity, dict): + identity["local_id"] = BROKEN_REASONING_LOCAL_ID + bite_hits.append(route.request.url) + route.fulfill(status=response.status, content_type="application/json", body=json.dumps(payload)) def main() -> int: + if SCENARIO not in {"normal", "terminal-error", "session-reattach", *DETACHED_SCENARIOS}: + print(f"SETUP FAIL: unsupported LIFECYCLE_SCENARIO={SCENARIO!r}", file=sys.stderr) + return 2 + if TEST_BITE not in {"", "drop-anchor-persistence", "drop-terminal-anchor-row", "replace-runtime-reasoning-id"}: + print(f"SETUP FAIL: unsupported LIFECYCLE_TEST_BITE={TEST_BITE!r}", file=sys.stderr) + return 2 + if TEST_BITE == "drop-terminal-anchor-row" and SCENARIO not in ERROR_SCENARIOS: + print("SETUP FAIL: drop-terminal-anchor-row requires terminal-error", file=sys.stderr) + return 2 + if TEST_BITE == "replace-runtime-reasoning-id" and SCENARIO != "session-reattach": + print("SETUP FAIL: replace-runtime-reasoning-id requires session-reattach", file=sys.stderr) + return 2 try: from playwright.sync_api import sync_playwright except ImportError: @@ -710,24 +982,7 @@ def main() -> int: tempfile.mkdtemp(prefix="hermes-lifecycle-artifacts-") ) artifact_dir.mkdir(parents=True, exist_ok=True) - scenario = SCENARIO - if scenario not in {"normal", "terminal-error"}: - raise ValueError( - f"Unsupported LIFECYCLE_SCENARIO {scenario!r}; " - "expected 'normal' or 'terminal-error'" - ) - if TEST_BITE not in {"", "drop-anchor-persistence", "drop-terminal-anchor-row"}: - raise ValueError( - f"Unsupported LIFECYCLE_TEST_BITE {TEST_BITE!r}; " - "expected one of '', 'drop-anchor-persistence', 'drop-terminal-anchor-row'" - ) - if TEST_BITE == "drop-terminal-anchor-row" and scenario != "terminal-error": - raise ValueError( - "drop-terminal-anchor-row is only valid for " - "LIFECYCLE_SCENARIO=terminal-error" - ) - - gateway = DeterministicGateway(scenario) + gateway = DeterministicGateway() gateway.start() agent_dir = state_dir / "no-agent" @@ -773,6 +1028,7 @@ def main() -> int: page = None errors = [] anchor_scene_requests = [] + pre_terminal_client_snapshot = None try: proc, log, log_path, base_url = _start_webui_server(repo_root, env, artifact_dir) playwright = sync_playwright().start() @@ -783,46 +1039,65 @@ def main() -> int: context = browser.new_context(base_url=base_url) page = context.new_page() anchor_scene_requests = _capture_anchor_scene_requests(page) - if TEST_BITE: + bite_hits = [] + if TEST_BITE == "drop-anchor-persistence": + page.route( + "**/api/session/anchor-scene", + lambda route: route.fulfill( + status=200, + content_type="application/json", + body='{"ok":true}', + ), + ) + elif TEST_BITE == "drop-terminal-anchor-row": def _route_anchor_scene(route): - if TEST_BITE == "drop-anchor-persistence": - route.fulfill( - status=200, - content_type="application/json", - body='{"ok":true}', - ) - return - if TEST_BITE == "drop-terminal-anchor-row": - raw_payload = _safe_request_post_data(route.request) - payload = _parse_json_payload(raw_payload) - if isinstance(payload, dict): - scene = payload.get("scene") - if isinstance(scene, dict): - rows = scene.get("activity_rows") - if isinstance(rows, list): - kept = [ - row for row in rows - if not (isinstance(row, dict) and row.get("role") == "terminal") - ] - if len(kept) != len(rows): - mutated = dict(payload) - updated_scene = dict(scene) - updated_scene["activity_rows"] = kept - mutated["scene"] = updated_scene - response = route.fetch(post_data=json.dumps(mutated)) - route.fulfill(response=response) - return - response = route.fetch() - route.fulfill(response=response) - return + raw_payload = _safe_request_post_data(route.request) + payload = _parse_json_payload(raw_payload) + scene = payload.get("scene") if isinstance(payload, dict) else None + rows = scene.get("activity_rows") if isinstance(scene, dict) else None + if isinstance(rows, list): + kept = [ + row for row in rows + if not (isinstance(row, dict) and row.get("role") == "terminal") + ] + if len(kept) != len(rows): + mutated = dict(payload) + updated_scene = dict(scene) + updated_scene["activity_rows"] = kept + mutated["scene"] = updated_scene + response = route.fetch(post_data=json.dumps(mutated)) + route.fulfill(response=response) + return response = route.fetch() route.fulfill(response=response) - return page.route("**/api/session/anchor-scene", _route_anchor_scene) errors = _capture_page_errors(page) page.goto("/", wait_until="domcontentloaded") page.wait_for_selector("#msg", state="visible", timeout=15000) + seed_session_id = None + if SCENARIO in {"session-reattach", *DETACHED_SCENARIOS}: + page.locator("#msg").fill(SEED_PROMPT) + page.locator("#btnSend").click() + page.wait_for_function( + "text => typeof S !== 'undefined' && S.busy === false && !S.activeStreamId && " + "(document.querySelector('#msgInner') || {}).innerText?.includes(text)", + arg=SEED_FINAL_TEXT, + timeout=15000, + ) + seed_session_id = page.evaluate("S.session && S.session.session_id") + assert seed_session_id, "seed session id missing after settlement" + page.wait_for_selector( + f'.session-item[data-sid="{seed_session_id}"]', + state="visible", + timeout=10000, + ) + page.locator("#btnNewChat").click() + page.wait_for_function( + "sid => S.session && S.session.session_id && S.session.session_id !== sid && !S.busy", + arg=seed_session_id, + timeout=15000, + ) page.locator("#msg").fill(PROMPT) page.locator("#btnSend").click() @@ -832,53 +1107,208 @@ def _route_anchor_scene(route): f"request body: {gateway.request_body!r}; events: {gateway.emitted_events!r}" ) page.wait_for_function( - """({reasoning, tool}) => { + """({reasoning, secondReasoning, thirdReasoning, tool, secondTool}) => { const turn = document.querySelector('#liveAssistantTurn'); if (!turn) return false; const text = turn.innerText || ''; - return text.includes(reasoning) && - Boolean(turn.querySelector(`[data-anchor-row-role="tool"][data-tool-name="${tool}"]`)); + const thinkingRows = turn.querySelectorAll('[data-anchor-row-role="thinking"]'); + return text.includes(reasoning) && text.includes(secondReasoning) && + text.includes(thirdReasoning) && thinkingRows.length === 3 && + Boolean(turn.querySelector(`[data-anchor-row-role="tool"][data-tool-name="${tool}"]`)) && + Boolean(turn.querySelector(`[data-anchor-row-role="tool"][data-tool-name="${secondTool}"]`)); }""", - arg={"reasoning": REASONING_TEXT, "tool": TOOL_NAME}, + arg={ + "reasoning": REASONING_TEXT, + "secondReasoning": SECOND_REASONING_TEXT, + "thirdReasoning": THIRD_REASONING_TEXT, + "tool": TOOL_NAME, + "secondTool": SECOND_TOOL_NAME, + }, timeout=10000, ) live_snapshot = _activity_snapshot(page) _assert_live_activity(live_snapshot) - if scenario == "terminal-error": + if SCENARIO in ERROR_SCENARIOS: _assert_process_row_present(live_snapshot) - print("OK live activity: terminal-error run keeps reasoning + completed tool") + print("OK live activity: terminal-error run keeps process + reasoning + completed tool activity") else: - print("OK live activity: one Anchor worklog with reasoning + completed tool") + print("OK live activity: one Anchor worklog with reasoning + completed tool activity") _wait_for_live_anchor_projection(page) + active_session_id = live_snapshot["clientState"]["sessionId"] + active_stream_id = live_snapshot["clientState"]["activeStreamId"] + assert active_session_id and active_stream_id, live_snapshot + + if SCENARIO == "session-reattach": + expected_reasoning_ids = [ + f"live-reasoning:{active_stream_id}:1", + f"live-reasoning:{active_stream_id}:2", + f"live-reasoning:{active_stream_id}:3", + ] + _wait_for_runtime_scene(base_url, active_session_id, expected_reasoning_ids) + page.wait_for_selector( + f'.session-item[data-sid="{active_session_id}"].streaming', + state="visible", + timeout=10000, + ) + page.locator(f'.session-item[data-sid="{seed_session_id}"]').click() + page.wait_for_function( + "({sid, text}) => S.session && S.session.session_id === sid && !S.busy && " + "!S.activeStreamId && !document.querySelector('#liveAssistantTurn') && " + "(document.querySelector('#msgInner') || {}).innerText?.includes(text)", + arg={"sid": seed_session_id, "text": SEED_FINAL_TEXT}, + timeout=15000, + ) + print("OK session switch: idle session replaces the active live turn") + page.evaluate( + """({sid, stream}) => { + if (typeof INFLIGHT === 'object' && INFLIGHT) delete INFLIGHT[sid]; + if (typeof clearInflightState === 'function') clearInflightState(sid); + if (window._liveAnchorRegistries) window._liveAnchorRegistries.delete(stream); + }""", + {"sid": active_session_id, "stream": active_stream_id}, + ) + if TEST_BITE == "replace-runtime-reasoning-id": + page.route( + "**/api/session*", + lambda route: _replace_runtime_reasoning_identity(route, active_session_id, bite_hits), + ) + page.locator(f'.session-item[data-sid="{active_session_id}"]').click() + page.wait_for_function( + "({sid, stream, reasoning, secondReasoning, thirdReasoning, tool, secondTool}) => {" + " const turn = document.querySelector('#liveAssistantTurn');" + " return S.session && S.session.session_id === sid && S.busy === true && " + " S.activeStreamId === stream && turn && " + " turn.getAttribute('data-anchor-stream-id') === stream && " + " (turn.innerText || '').includes(reasoning) && " + " (turn.innerText || '').includes(secondReasoning) && " + " (turn.innerText || '').includes(thirdReasoning) && " + " turn.querySelectorAll('[data-anchor-row-role=\"thinking\"]').length === 3 && " + " Boolean(turn.querySelector(`[data-anchor-row-role=\"tool\"][data-tool-name=\"${tool}\"]`)) && " + " Boolean(turn.querySelector(`[data-anchor-row-role=\"tool\"][data-tool-name=\"${secondTool}\"]`));" + "}", + arg={ + "sid": active_session_id, + "stream": active_stream_id, + "reasoning": REASONING_TEXT, + "secondReasoning": SECOND_REASONING_TEXT, + "thirdReasoning": THIRD_REASONING_TEXT, + "tool": TOOL_NAME, + "secondTool": SECOND_TOOL_NAME, + }, + timeout=15000, + ) + if TEST_BITE == "replace-runtime-reasoning-id": + page.unroute("**/api/session*") + assert len(bite_hits) == 1, bite_hits + reattached_snapshot = _activity_snapshot(page) + _assert_same_live_turn(live_snapshot, reattached_snapshot) + live_snapshot = reattached_snapshot + print("OK session reattach: same stream and Anchor activity resume without duplication") gateway.release_settle.set() + if SCENARIO == "session-reattach": + if not gateway.continuation_ready.wait(timeout=10): + raise AssertionError("mock Gateway did not emit the post-reattach reasoning continuation") + page.wait_for_function( + """text => { + const turn = document.querySelector('#liveAssistantTurn'); + return turn && (turn.innerText || '').includes(text) && + turn.querySelectorAll('[data-anchor-row-role="thinking"]').length === 4; + }""", + arg=CONTINUATION_REASONING_TEXT, + timeout=10000, + ) + continued_snapshot = _activity_snapshot(page) + _assert_live_continuation(live_snapshot, continued_snapshot) + live_snapshot = continued_snapshot + print("OK live continuation: post-reattach reasoning appends with a new Anchor identity") + gateway.release_final_prefix.set() if not gateway.final_prefix_ready.wait(timeout=10): raise AssertionError("mock Gateway did not emit the final-answer prefix") - if scenario == "normal": + terminal_text = TERMINAL_ERROR_TEXT if SCENARIO in ERROR_SCENARIOS else FINAL_TEXT + if SCENARIO in {"normal", "session-reattach"}: page.wait_for_function( """text => { const turn = document.querySelector('#liveAssistantTurn'); - return Boolean(turn) && turn.innerText.includes(text); + return turn && (turn.innerText || '').includes(text); }""", arg=FINAL_ACK_TEXT, timeout=10000, ) + if SCENARIO == "session-reattach": + page.evaluate( + """() => { + const originalSave = _saveComposerDraftNow; + let releaseSave; + const saveGate = new Promise(resolve => { releaseSave = resolve; }); + window.__releaseLifecycleDraftSave = releaseSave; + _saveComposerDraftNow = async (...args) => { + await saveGate; + return originalSave(...args); + }; + }""" + ) + page.locator(f'.session-item[data-sid="{seed_session_id}"]').click() + page.wait_for_function( + "({loadingSid, activeSid}) => _loadingSessionId === loadingSid && " + "S.session && S.session.session_id === activeSid && S.activeStreamId", + arg={"loadingSid": seed_session_id, "activeSid": active_session_id}, + timeout=10000, + ) + pre_terminal_client_snapshot = _lifecycle_client_snapshot(page) gateway.release_terminal.set() page.wait_for_function( - """text => typeof S !== 'undefined' && S.busy === false && !S.activeStreamId && - ((document.querySelector('#msgInner') || {}).innerText || '').includes(text)""", - arg=FINAL_TEXT, + "sid => S.session && S.session.session_id === sid && S.busy === false", + arg=active_session_id, + timeout=10000, + ) + page.evaluate("window.__releaseLifecycleDraftSave()") + page.wait_for_function( + "sid => S.session && S.session.session_id === sid && !S.busy && !S.activeStreamId", + arg=seed_session_id, timeout=15000, ) - else: - gateway.release_terminal.set() + page.locator(f'.session-item[data-sid="{active_session_id}"]').click() + elif SCENARIO in DETACHED_SCENARIOS: + assert seed_session_id, "seed session id missing for detached-terminal scenario" + page.locator(f'.session-item[data-sid="{seed_session_id}"]').click() page.wait_for_function( - """text => typeof S !== 'undefined' && S.busy === false && !S.activeStreamId && - !document.querySelector('#liveAssistantTurn') && - ((document.querySelector('#msgInner') || {}).innerText||'').includes(text)""", - arg=TERMINAL_ERROR_TEXT, + "({seedSid, activeSid}) => {" + " const live = typeof LIVE_STREAMS === 'object' && LIVE_STREAMS ? LIVE_STREAMS : {};" + " return S.session && S.session.session_id === seedSid && !S.busy && !S.activeStreamId && " + " !document.querySelector('#liveAssistantTurn') && !live[activeSid] && " + " typeof INFLIGHT === 'object' && INFLIGHT && INFLIGHT[activeSid] && " + " INFLIGHT[activeSid].reattach === true;" + "}", + arg={"seedSid": seed_session_id, "activeSid": active_session_id}, timeout=15000, ) + print("OK session switch: old chat EventSource detached after ordinary switch") + pre_terminal_client_snapshot = _lifecycle_client_snapshot(page) + gateway.release_terminal.set() + scene = _wait_for_persisted_scene( + base_url, + active_session_id, + anchor_scene_requests=anchor_scene_requests, + ) + assert scene.get("version") == "activity_scene_v1", scene + assert page.evaluate( + "({seedSid, finalText}) => S.session && S.session.session_id === seedSid && " + "!S.busy && !S.activeStreamId && " + "!((document.querySelector('#msgInner') || {}).innerText || '').includes(finalText)", + {"seedSid": seed_session_id, "finalText": terminal_text}, + ) + page.locator(f'.session-item[data-sid="{active_session_id}"]').click() + else: + pre_terminal_client_snapshot = _lifecycle_client_snapshot(page) + gateway.release_terminal.set() + page.wait_for_function( + """text => typeof S !== 'undefined' && S.busy === false && !S.activeStreamId && + !document.querySelector('#liveAssistantTurn') && + (document.querySelector('#msgInner') || {}).innerText?.includes(text)""", + arg=terminal_text, + timeout=15000, + ) session_id = page.evaluate("S.session && S.session.session_id") assert session_id, "active session id missing after settlement" if TEST_BITE == "drop-terminal-anchor-row": @@ -893,23 +1323,11 @@ def _route_anchor_scene(route): row.get("role") for row in scene_rows if isinstance(row, dict) ] - assert any( - isinstance(row, dict) and row.get("role") == "prose" - for row in scene_rows - ), scene - assert any( - isinstance(row, dict) and row.get("role") == "thinking" - for row in scene_rows - ), scene - assert any( - isinstance(row, dict) and row.get("role") == "tool" - for row in scene_rows - ), scene + assert any(isinstance(row, dict) and row.get("role") == "prose" for row in scene_rows), scene + assert any(isinstance(row, dict) and row.get("role") == "thinking" for row in scene_rows), scene + assert any(isinstance(row, dict) and row.get("role") == "tool" for row in scene_rows), scene assert all( - not ( - isinstance(row, dict) - and row.get("role") == "terminal" - ) + not (isinstance(row, dict) and row.get("role") == "terminal") for row in scene_rows ), scene print( @@ -926,29 +1344,17 @@ def _route_anchor_scene(route): row.get("role") for row in persisted_rows if isinstance(row, dict) ] - assert any( - isinstance(row, dict) and row.get("role") == "thinking" - for row in persisted_rows - ), { + assert any(isinstance(row, dict) and row.get("role") == "thinking" for row in persisted_rows), { "persisted_rows": persisted_rows, } - assert any( - isinstance(row, dict) and row.get("role") == "prose" - for row in persisted_rows - ), { + assert any(isinstance(row, dict) and row.get("role") == "prose" for row in persisted_rows), { "persisted_rows": persisted_rows, } - assert any( - isinstance(row, dict) and row.get("role") == "tool" - for row in persisted_rows - ), { + assert any(isinstance(row, dict) and row.get("role") == "tool" for row in persisted_rows), { "persisted_rows": persisted_rows, } assert all( - not ( - isinstance(row, dict) - and row.get("role") == "terminal" - ) + not (isinstance(row, dict) and row.get("role") == "terminal") for row in persisted_rows ), { "persisted_rows": persisted_rows, @@ -964,7 +1370,7 @@ def _route_anchor_scene(route): anchor_scene_requests=anchor_scene_requests, ) assert scene.get("version") == "activity_scene_v1", scene - if scenario == "terminal-error": + if SCENARIO == "terminal-error": scene_rows = scene.get("activity_rows") or [] assert any( isinstance(row, dict) and row.get("role") == "terminal" for row in scene_rows @@ -975,22 +1381,25 @@ def _route_anchor_scene(route): timeout=10000, ) settled_snapshot = _activity_snapshot(page) - _assert_settled(settled_snapshot, scenario) - if scenario == "terminal-error": + settled_reasoning_count = 4 if SCENARIO == "session-reattach" else 3 + _assert_settled(settled_snapshot, reasoning_count=settled_reasoning_count, scenario=SCENARIO) + if SCENARIO in ERROR_SCENARIOS: _assert_process_row_present(settled_snapshot) assert _semantic_activity(settled_snapshot) == _semantic_activity(live_snapshot), { "live": _semantic_activity(live_snapshot), "settled": _semantic_activity(settled_snapshot), } - if scenario == "terminal-error": + if SCENARIO == "terminal-error": print("OK settled: terminal row and same activity survived terminal settlement") + elif SCENARIO in ERROR_SCENARIOS: + print("OK settled: detached terminal error and same activity survived settlement") else: print("OK settled: final prose and the same semantic activity coexist without duplication") page.reload(wait_until="domcontentloaded") page.wait_for_function( "text => (document.querySelector('#msgInner') || {}).innerText?.includes(text)", - arg=TERMINAL_ERROR_TEXT if scenario == "terminal-error" else FINAL_TEXT, + arg=terminal_text, timeout=15000, ) _expand_settled_worklog(page) @@ -999,14 +1408,21 @@ def _route_anchor_scene(route): timeout=2000 if TEST_BITE else 10000, ) reloaded_snapshot = _activity_snapshot(page) - _assert_settled(reloaded_snapshot, scenario) - if scenario == "terminal-error": + _assert_settled(reloaded_snapshot, reasoning_count=settled_reasoning_count, scenario=SCENARIO) + if SCENARIO in ERROR_SCENARIOS: _assert_process_row_present(reloaded_snapshot) assert _semantic_activity(reloaded_snapshot) == _semantic_activity(settled_snapshot), { "settled": _semantic_activity(settled_snapshot), "reloaded": _semantic_activity(reloaded_snapshot), } - if scenario == "terminal-error": + assert _semantic_activity(reloaded_snapshot, include_identity=True) == _semantic_activity( + settled_snapshot, + include_identity=True, + ), { + "settled": _semantic_activity(settled_snapshot, include_identity=True), + "reloaded": _semantic_activity(reloaded_snapshot, include_identity=True), + } + if SCENARIO in ERROR_SCENARIOS: settled_terminal = _terminal_rows(settled_snapshot) reloaded_terminal = _terminal_rows(reloaded_snapshot) settled_process = _process_rows(settled_snapshot) @@ -1019,17 +1435,24 @@ def _route_anchor_scene(route): "settled_process": settled_process, "reloaded_process": reloaded_process, } - assert len(settled_terminal) == len(reloaded_terminal) == 1, { - "settled_terminal": settled_terminal, - "reloaded_terminal": reloaded_terminal, - } - assert settled_terminal[0]["text"] == reloaded_terminal[0]["text"], { - "settled_terminal": settled_terminal[0], - "reloaded_terminal": reloaded_terminal[0], - } + if SCENARIO == "terminal-error": + assert len(settled_terminal) == len(reloaded_terminal) == 1, { + "settled_terminal": settled_terminal, + "reloaded_terminal": reloaded_terminal, + } + assert settled_terminal[0]["text"] == reloaded_terminal[0]["text"], { + "settled_terminal": settled_terminal[0], + "reloaded_terminal": reloaded_terminal[0], + } print("OK hard reload: transcript-backed Anchor scene preserves settled parity") - assert gateway.request_body and gateway.request_body.get("input") == PROMPT, gateway.request_body + expected_inputs = ( + [SEED_PROMPT, PROMPT] + if SCENARIO in {"session-reattach", *DETACHED_SCENARIOS} + else [PROMPT] + ) + actual_inputs = [request.get("input") for request in gateway.request_bodies] + assert actual_inputs == expected_inputs, actual_inputs if errors: raise AssertionError(f"unexpected browser errors: {errors!r}") context.close() @@ -1045,10 +1468,12 @@ def _route_anchor_scene(route): page.screenshot(path=str(artifact_dir / "failure.png"), full_page=True) (artifact_dir / "snapshot.json").write_text( json.dumps({ - "scenario": scenario, + "scenario": SCENARIO, "test_bite": TEST_BITE or None, "browser_errors": errors, "anchor_scene_requests": anchor_scene_requests, + "pre_terminal_client": pre_terminal_client_snapshot, + "post_failure_client": _lifecycle_client_snapshot(page), "anchor_projection": _anchor_projection_snapshot(page), "gateway_events": gateway.emitted_events, "dom": _activity_snapshot(page), diff --git a/tests/test_5749_anchor_scene_client_prefix_dedupe.py b/tests/test_5749_anchor_scene_client_prefix_dedupe.py index ee42e47739..f7335e02fd 100644 --- a/tests/test_5749_anchor_scene_client_prefix_dedupe.py +++ b/tests/test_5749_anchor_scene_client_prefix_dedupe.py @@ -8,7 +8,19 @@ def _function_body(src: str, name: str) -> str: marker = f"function {name}" start = src.index(marker) - brace = src.index("{", start) + params = src.index("(", start) + depth = 0 + close = -1 + for idx in range(params, len(src)): + if src[idx] == "(": + depth += 1 + elif src[idx] == ")": + depth -= 1 + if depth == 0: + close = idx + break + assert close != -1, f"{name} params did not close" + brace = src.index("{", close) depth = 0 for idx in range(brace, len(src)): if src[idx] == "{": @@ -32,6 +44,7 @@ def test_settled_scene_keys_live_token_prefix_dedupe_to_final_answer_identity(): assert "lastProjectedToolIndex=projectedRows.reduce" in settle_body assert "finalSegmentLiveProseRows" in settle_body assert "rowIsLiveTokenFinalPrefix(row,textKey,finalSegmentEligible)" in settle_body + assert "finalKey.startsWith(textKey)||finalKey.endsWith(textKey)" in settle_body assert "rowHasNonLiveDuplicate" not in settle_body assert "_anchorSceneRowLooksLikeFinalAnswer(textKey,finalKey)" in settle_body assert "(shorter/longer)>=0.9" in final_overlap_body diff --git a/tests/test_anchor_scene_persistence.py b/tests/test_anchor_scene_persistence.py index a978709227..8db41608d0 100644 --- a/tests/test_anchor_scene_persistence.py +++ b/tests/test_anchor_scene_persistence.py @@ -2,6 +2,8 @@ from collections import OrderedDict from types import SimpleNamespace +import pytest + def _client_anchor_scene_message_ref(message): content = message.get("content") if isinstance(message, dict) else "" @@ -1983,7 +1985,10 @@ def test_anchor_scene_hydration_dedupes_compression_lifecycle_rows(): assert compression_rows[0]["order_index"] == compression_rows[0]["seq"] -def test_anchor_scene_hydration_drops_stale_live_running_thinking_when_settled_thinking_exists(): +@pytest.mark.parametrize("stale_status", ["running", "completed"]) +def test_anchor_scene_hydration_drops_stale_live_thinking_when_settled_thinking_exists( + stale_status, +): from api import routes messages = [ @@ -2011,7 +2016,7 @@ def test_anchor_scene_hydration_drops_stale_live_running_thinking_when_settled_t "role": "thinking", "kind": "reasoning", "source_event_type": "reasoning", - "status": "running", + "status": stale_status, "text": "stale live reasoning text", }, {"row_id": "done", "role": "terminal", "kind": "terminal_status", "source_event_type": "done"}, @@ -2281,56 +2286,1388 @@ def test_runtime_journal_snapshot_includes_live_anchor_activity_scene(monkeypatc assert snapshot["last_reasoning_text"] == "thinking through plan" assert [row["role"] for row in rows] == ["prose", "thinking", "tool", "prose"] assert rows[0]["local_id"] == f"live-prose:{stream_id}:1" - assert rows[1]["local_id"] == f"live-thinking:{stream_id}:1" + assert rows[1]["local_id"] == f"live-reasoning:{stream_id}:1" assert rows[1]["thinking"]["text"] == "thinking through plan" assert rows[2]["tool_call_id"] == "call-1" assert rows[2]["tool"]["done"] is True assert rows[3]["status"] == "running" -def test_runtime_journal_snapshot_dedupes_reasoning_interim_progress_echo(monkeypatch): +def test_terminal_journal_snapshot_can_settle_anchor_activity_scene(monkeypatch): from api import routes - stream_id = "stream-live-reasoning-interim-echo" - progress = "我先检查当前仓库状态,然后定位重复渲染路径。" + stream_id = "stream-terminal-scene" events = [ { "event": "reasoning", "seq": 1, "event_id": f"{stream_id}:1", "created_at": 1.0, - "payload": {"text": progress}, + "payload": {"text": "plan"}, }, { - "event": "interim_assistant", + "event": "token", "seq": 2, "event_id": f"{stream_id}:2", "created_at": 2.0, - "payload": {"text": progress}, + "payload": {"text": "progress"}, + }, + { + "event": "done", + "seq": 3, + "event_id": f"{stream_id}:3", + "created_at": 3.0, + "terminal": True, + "payload": {"session": {"message_count": 2}}, }, ] monkeypatch.setattr( routes, "find_run_summary", lambda sid: { - "session_id": "session-live-reasoning-interim-echo", - "last_seq": 2, - "last_event_id": f"{stream_id}:2", + "session_id": "session-terminal-scene", + "run_id": stream_id, + "last_seq": 3, + "last_event_id": f"{stream_id}:3", + "terminal": True, + "terminal_state": "completed", }, ) - monkeypatch.setattr( - routes, - "read_run_events", - lambda session_id, run_id: {"events": events}, + monkeypatch.setattr(routes, "read_run_events", lambda session_id, run_id: {"events": events}) + + snapshot = routes._run_journal_live_snapshot(stream_id, settled=True) + scene = snapshot["anchor_activity_scene"] + + assert snapshot["terminal_state"] == "completed" + assert snapshot["terminal_message_index"] == 1 + assert scene["lifecycle"]["status"] == "completed" + assert scene["terminal_state"] == "completed" + assert scene["final_answer"] == "progress" + assert [row["status"] for row in scene["activity_rows"]] == ["completed", "completed"] + + +def test_terminal_journal_materializes_missing_settled_anchor_scene(monkeypatch): + from api import routes + + class SessionStub(SimpleNamespace): + def save(self, **kwargs): + self.save_calls += 1 + self.save_kwargs = kwargs + + stream_id = "stream-detached-terminal" + events = [ + { + "event": "reasoning", + "seq": 1, + "event_id": f"{stream_id}:1", + "created_at": 1.0, + "payload": {"text": "checking identity"}, + }, + { + "event": "token", + "seq": 2, + "event_id": f"{stream_id}:2", + "created_at": 2.0, + "payload": {"text": "working"}, + }, + { + "event": "tool", + "seq": 3, + "event_id": f"{stream_id}:3", + "created_at": 3.0, + "payload": {"name": "terminal", "tid": "call-detached", "args": {"command": "pytest"}}, + }, + { + "event": "tool_complete", + "seq": 4, + "event_id": f"{stream_id}:4", + "created_at": 4.0, + "payload": {"name": "terminal", "tid": "call-detached", "preview": "ok"}, + }, + { + "event": "token", + "seq": 5, + "event_id": f"{stream_id}:5", + "created_at": 5.0, + "payload": {"text": " final answer"}, + }, + { + "event": "done", + "seq": 6, + "event_id": f"{stream_id}:6", + "created_at": 6.0, + "terminal": True, + "payload": { + "session": { + "session_id": "session-detached-terminal", + "message_count": 2, + "messages": [ + {"role": "user", "content": "do work"}, + {"role": "assistant", "content": "working final answer", "_ts": 6.0}, + ], + }, + }, + }, + ] + summary = { + "session_id": "session-detached-terminal", + "run_id": stream_id, + "last_seq": 6, + "last_event_id": f"{stream_id}:6", + "terminal": True, + "terminal_state": "completed", + } + messages = [ + {"role": "user", "content": "do work"}, + {"role": "assistant", "content": "working final answer", "_ts": 6.0}, + {"role": "user", "content": "newer prompt"}, + {"role": "assistant", "content": "newer answer", "_ts": 6.0}, + ] + session = SessionStub( + session_id="session-detached-terminal", + tool_calls=[], + anchor_activity_scenes={}, + save_calls=0, ) + monkeypatch.setattr(routes, "terminal_run_summaries_for_session", lambda sid, **kwargs: [summary]) + monkeypatch.setattr(routes, "find_run_summary", lambda sid: summary) + monkeypatch.setattr(routes, "read_run_events", lambda session_id, run_id: {"events": events}) + monkeypatch.setattr(routes, "_active_stream_ids", lambda: set()) - snapshot = routes._run_journal_live_snapshot(stream_id) - rows = snapshot["anchor_activity_scene"]["activity_rows"] + assert routes._materialize_terminal_anchor_scene_from_run_journal(session, messages) is True - assert snapshot["last_assistant_text"] == progress - assert snapshot["last_reasoning_text"] == "" - assert [row["role"] for row in rows] == ["prose"] - assert rows[0]["text"] == progress + assert session.save_calls == 1 + assert session.save_kwargs == {"touch_updated_at": False, "skip_index": True} + records = session.anchor_activity_scenes + assert len(records) == 1 + record = next(iter(records.values())) + assert record["message_index"] == 1 + assert record["stream_id"] == stream_id + scene = record["scene"] + assert scene["final_answer"] == "final answer" + assert _anchor_scene_visible_semantics(scene) == [ + {"role": "thinking", "kind": "reasoning", "text": "checking identity"}, + {"role": "prose", "kind": "process_prose", "text": "working"}, + { + "role": "tool", + "kind": "tool_completed", + "status": "completed", + "tool_call_id": "call-detached", + "name": "terminal", + "args": {"command": "pytest"}, + "done": True, + }, + ] + + +def test_terminal_journal_materialization_fails_closed_without_terminal_target(monkeypatch): + from api import routes + + class SessionStub(SimpleNamespace): + def save(self, **kwargs): + self.save_calls += 1 + + stream_id = "stream-detached-cancel-no-target" + events = [ + { + "event": "reasoning", + "seq": 1, + "event_id": f"{stream_id}:1", + "created_at": 1.0, + "payload": {"text": "checking"}, + }, + { + "event": "token", + "seq": 2, + "event_id": f"{stream_id}:2", + "created_at": 2.0, + "payload": {"text": "partial work"}, + }, + { + "event": "cancel", + "seq": 3, + "event_id": f"{stream_id}:3", + "created_at": 3.0, + "terminal": True, + "payload": {"message": "Cancelled by user"}, + }, + ] + summary = { + "session_id": "session-detached-cancel-no-target", + "run_id": stream_id, + "last_seq": 3, + "last_event_id": f"{stream_id}:3", + "terminal": True, + "terminal_state": "interrupted-by-user", + } + messages = [ + {"role": "user", "content": "do work"}, + {"role": "assistant", "content": "partial work", "_ts": 3.0}, + {"role": "user", "content": "newer prompt"}, + {"role": "assistant", "content": "newer answer", "_ts": 4.0}, + ] + session = SessionStub( + session_id="session-detached-cancel-no-target", + tool_calls=[], + anchor_activity_scenes={}, + save_calls=0, + ) + monkeypatch.setattr(routes, "terminal_run_summaries_for_session", lambda sid, **kwargs: [summary]) + monkeypatch.setattr(routes, "find_run_summary", lambda sid: summary) + monkeypatch.setattr(routes, "read_run_events", lambda session_id, run_id: {"events": events}) + monkeypatch.setattr(routes, "_active_stream_ids", lambda: set()) + + assert routes._materialize_terminal_anchor_scene_from_run_journal(session, messages) is False + assert session.save_calls == 0 + assert session.anchor_activity_scenes == {} + + +def test_terminal_journal_materialization_fails_closed_without_message_ref(monkeypatch): + from api import routes + + class SessionStub(SimpleNamespace): + def save(self, **kwargs): + self.save_calls += 1 + + stream_id = "stream-detached-index-only" + events = [ + { + "event": "token", + "seq": 1, + "event_id": f"{stream_id}:1", + "created_at": 1.0, + "payload": {"text": "partial work"}, + }, + { + "event": "cancel", + "seq": 2, + "event_id": f"{stream_id}:2", + "created_at": 2.0, + "terminal": True, + "payload": { + "message": "Cancelled by user", + "terminal_message_target": { + "version": "terminal_message_target_v1", + "session_id": "session-detached-index-only", + "run_id": stream_id, + "stream_id": stream_id, + "message_index": 1, + }, + }, + }, + ] + summary = { + "session_id": "session-detached-index-only", + "run_id": stream_id, + "last_seq": 2, + "last_event_id": f"{stream_id}:2", + "terminal": True, + "terminal_state": "interrupted-by-user", + } + messages = [ + {"role": "user", "content": "do work"}, + {"role": "assistant", "content": "partial work", "_ts": 2.0}, + ] + session = SessionStub( + session_id="session-detached-index-only", + tool_calls=[], + anchor_activity_scenes={}, + save_calls=0, + ) + monkeypatch.setattr(routes, "terminal_run_summaries_for_session", lambda sid, **kwargs: [summary]) + monkeypatch.setattr(routes, "find_run_summary", lambda sid: summary) + monkeypatch.setattr(routes, "read_run_events", lambda session_id, run_id: {"events": events}) + monkeypatch.setattr(routes, "_active_stream_ids", lambda: set()) + + assert routes._materialize_terminal_anchor_scene_from_run_journal(session, messages) is False + assert session.save_calls == 0 + + +def test_terminal_journal_materializes_detached_cancel_with_owned_target(monkeypatch): + from api import routes + + class SessionStub(SimpleNamespace): + def save(self, **kwargs): + self.save_calls += 1 + self.save_kwargs = kwargs + + stream_id = "stream-detached-cancel-target" + messages = [ + {"role": "user", "content": "do work"}, + { + "role": "assistant", + "content": "**Task cancelled:** Cancelled by user", + "_ts": 3.0, + }, + {"role": "user", "content": "newer prompt"}, + {"role": "assistant", "content": "newer answer", "_ts": 4.0}, + ] + events = [ + { + "event": "reasoning", + "seq": 1, + "event_id": f"{stream_id}:1", + "created_at": 1.0, + "payload": {"text": "checking"}, + }, + { + "event": "token", + "seq": 2, + "event_id": f"{stream_id}:2", + "created_at": 2.0, + "payload": {"text": "partial work"}, + }, + { + "event": "cancel", + "seq": 3, + "event_id": f"{stream_id}:3", + "created_at": 3.0, + "terminal": True, + "payload": { + "message": "Cancelled by user", + "session": { + "session_id": "session-detached-cancel-target", + "message_count": 2, + "messages": messages[:2], + }, + }, + }, + ] + summary = { + "session_id": "session-detached-cancel-target", + "run_id": stream_id, + "last_seq": 3, + "last_event_id": f"{stream_id}:3", + "terminal": True, + "terminal_state": "interrupted-by-user", + } + session = SessionStub( + session_id="session-detached-cancel-target", + tool_calls=[], + anchor_activity_scenes={}, + save_calls=0, + ) + monkeypatch.setattr(routes, "terminal_run_summaries_for_session", lambda sid, **kwargs: [summary]) + monkeypatch.setattr(routes, "find_run_summary", lambda sid: summary) + monkeypatch.setattr(routes, "read_run_events", lambda session_id, run_id: {"events": events}) + monkeypatch.setattr(routes, "_active_stream_ids", lambda: set()) + + assert routes._materialize_terminal_anchor_scene_from_run_journal(session, messages) is True + assert session.save_calls == 1 + assert session.save_kwargs == {"touch_updated_at": False, "skip_index": True} + record = next(iter(session.anchor_activity_scenes.values())) + assert record["message_index"] == 1 + assert record["stream_id"] == stream_id + assert record["scene"]["terminal_state"] == "interrupted-by-user" + assert _anchor_scene_visible_semantics(record["scene"]) == [ + {"role": "thinking", "kind": "reasoning", "text": "checking"}, + {"role": "prose", "kind": "process_prose", "text": "partial work"}, + ] + + +def test_terminal_journal_rejects_mismatched_terminal_target(monkeypatch): + from api import routes + + class SessionStub(SimpleNamespace): + def save(self, **kwargs): + self.save_calls += 1 + + stream_id = "stream-detached-mismatch" + messages = [ + {"role": "user", "content": "do work"}, + {"role": "assistant", "content": "old assistant", "_ts": 2.0}, + ] + events = [ + { + "event": "token", + "seq": 1, + "event_id": f"{stream_id}:1", + "created_at": 1.0, + "payload": {"text": "old assistant"}, + }, + { + "event": "apperror", + "seq": 2, + "event_id": f"{stream_id}:2", + "created_at": 2.0, + "terminal": True, + "payload": { + "type": "gateway_error", + "message": "Gateway failed", + "terminal_message_target": { + "version": "terminal_message_target_v1", + "session_id": "session-detached-mismatch", + "run_id": stream_id, + "stream_id": stream_id, + "message_index": 1, + "message_ref": _client_anchor_scene_message_ref( + {"role": "assistant", "content": "different", "_ts": 2.0} + ), + }, + }, + }, + ] + summary = { + "session_id": "session-detached-mismatch", + "run_id": stream_id, + "last_seq": 2, + "last_event_id": f"{stream_id}:2", + "terminal": True, + "terminal_state": "errored", + } + session = SessionStub( + session_id="session-detached-mismatch", + tool_calls=[], + anchor_activity_scenes={}, + save_calls=0, + ) + monkeypatch.setattr(routes, "terminal_run_summaries_for_session", lambda sid, **kwargs: [summary]) + monkeypatch.setattr(routes, "find_run_summary", lambda sid: summary) + monkeypatch.setattr(routes, "read_run_events", lambda session_id, run_id: {"events": events}) + monkeypatch.setattr(routes, "_active_stream_ids", lambda: set()) + + assert routes._materialize_terminal_anchor_scene_from_run_journal(session, messages) is False + assert session.save_calls == 0 + + +def test_terminal_target_rejects_missing_version_and_malformed_index(): + from api import routes + + stream_id = "stream-strict-terminal-target" + session_id = "session-strict-terminal-target" + messages = [ + {"role": "user", "content": "do work"}, + {"role": "assistant", "content": "owned assistant", "_ts": 2.0}, + ] + message_ref = _client_anchor_scene_message_ref(messages[1]) + base_target = { + "version": "terminal_message_target_v1", + "session_id": session_id, + "run_id": stream_id, + "stream_id": stream_id, + "message_index": 1, + "message_ref": message_ref, + } + base_snapshot = { + "session_id": session_id, + "stream_id": stream_id, + "anchor_activity_scene": { + "identity": { + "session_id": session_id, + "run_id": stream_id, + "stream_id": stream_id, + } + }, + } + + invalid_targets = [] + missing_version = dict(base_target) + missing_version.pop("version") + invalid_targets.append(missing_version) + invalid_targets.append({**base_target, "message_index": "1"}) + invalid_targets.append({**base_target, "message_index": True}) + invalid_targets.append({**base_target, "message_index": -1}) + invalid_targets.append({**base_target, "message_index": 2}) + + for target in invalid_targets: + snapshot = {**base_snapshot, "terminal_message_target": target} + assert routes._terminal_anchor_scene_message_index(messages, snapshot) is None + if target.get("message_index") != 2: + assert routes._terminal_anchor_scene_target_from_payload( + session_id, + stream_id, + stream_id, + {"terminal_message_target": target}, + ) is None + + +def test_run_journal_live_snapshot_rejects_conflicting_run_envelope(monkeypatch): + from api import routes + + stream_id = "stream-conflicting-envelope" + events = [ + { + "event": "token", + "seq": 1, + "event_id": f"{stream_id}:1", + "run_id": stream_id, + "created_at": 1.0, + "payload": {"text": "partial"}, + }, + { + "event": "done", + "seq": 2, + "event_id": f"{stream_id}:2", + "run_id": "different-run", + "created_at": 2.0, + "terminal": True, + "payload": { + "session": { + "session_id": "session-conflicting-envelope", + "message_count": 2, + "messages": [ + {"role": "user", "content": "do work"}, + {"role": "assistant", "content": "partial", "_ts": 2.0}, + ], + }, + }, + }, + ] + summary = { + "session_id": "session-conflicting-envelope", + "run_id": stream_id, + "last_seq": 2, + "last_event_id": f"{stream_id}:2", + "terminal": True, + "terminal_state": "completed", + } + monkeypatch.setattr(routes, "find_run_summary", lambda _stream_id: summary) + monkeypatch.setattr(routes, "read_run_events", lambda _session_id, _run_id: {"events": events}) + + assert routes._run_journal_live_snapshot(stream_id, settled=True) is None + + +def test_terminal_journal_records_non_materializable_disposition_once(monkeypatch): + from api import routes + + class SessionStub(SimpleNamespace): + def save(self, **kwargs): + self.save_calls += 1 + self.save_kwargs = kwargs + + stream_id = "stream-non-materializable" + events = [ + { + "event": "token", + "seq": 1, + "event_id": f"{stream_id}:1", + "run_id": stream_id, + "created_at": 1.0, + "payload": {"text": "partial"}, + }, + { + "event": "cancel", + "seq": 2, + "event_id": f"{stream_id}:2", + "run_id": stream_id, + "created_at": 2.0, + "terminal": True, + "payload": { + "message": "Cancelled by user", + "terminal_disposition": { + "version": "terminal_disposition_v1", + "kind": "consumed_non_materializable", + "reason": "process_wakeup_success_cancel_rollback", + "session_id": "session-non-materializable", + "run_id": stream_id, + "stream_id": stream_id, + }, + }, + }, + ] + summary = { + "session_id": "session-non-materializable", + "run_id": stream_id, + "last_seq": 2, + "last_event_id": f"{stream_id}:2", + "terminal": True, + "terminal_state": "interrupted-by-user", + } + session = SessionStub( + session_id="session-non-materializable", + tool_calls=[], + anchor_activity_scenes={}, + save_calls=0, + ) + messages = [ + {"role": "user", "content": "do work"}, + {"role": "assistant", "content": "prior answer", "_ts": 1.0}, + ] + monkeypatch.setattr(routes, "terminal_run_summaries_for_session", lambda sid, **kwargs: [summary]) + monkeypatch.setattr(routes, "find_run_summary", lambda _stream_id: summary) + monkeypatch.setattr(routes, "read_run_events", lambda _session_id, _run_id: {"events": events}) + monkeypatch.setattr(routes, "_active_stream_ids", lambda: set()) + + assert routes._materialize_terminal_anchor_scene_from_run_journal(session, messages) is True + assert session.save_calls == 1 + assert session.save_kwargs == {"touch_updated_at": False, "skip_index": True} + record = next(iter(session.anchor_activity_scenes.values())) + assert record["message_index"] is None + assert record["message_ref"] == "" + assert record["scene"] is None + assert record["stream_id"] == stream_id + assert record["terminal_disposition"]["kind"] == "consumed_non_materializable" + + assert routes._materialize_terminal_anchor_scene_from_run_journal(session, messages) is False + assert session.save_calls == 1 + + +def test_terminal_journal_materializes_older_unresolved_terminal_once(monkeypatch): + from api import routes + + class SessionStub(SimpleNamespace): + def save(self, **kwargs): + self.save_calls += 1 + + old_stream = "stream-old-terminal" + new_stream = "stream-new-terminal" + messages = [ + {"role": "user", "content": "old work"}, + {"role": "assistant", "content": "old answer", "_ts": 2.0}, + {"role": "user", "content": "new work"}, + {"role": "assistant", "content": "new answer", "_ts": 4.0}, + ] + summaries = [ + { + "session_id": "session-older-unresolved", + "run_id": new_stream, + "last_seq": 2, + "last_event_id": f"{new_stream}:2", + "terminal": True, + "terminal_state": "completed", + }, + { + "session_id": "session-older-unresolved", + "run_id": old_stream, + "last_seq": 3, + "last_event_id": f"{old_stream}:3", + "terminal": True, + "terminal_state": "completed", + }, + ] + old_events = [ + { + "event": "reasoning", + "seq": 1, + "event_id": f"{old_stream}:1", + "created_at": 1.0, + "payload": {"text": "old thinking"}, + }, + { + "event": "token", + "seq": 2, + "event_id": f"{old_stream}:2", + "created_at": 2.0, + "payload": {"text": "old answer"}, + }, + { + "event": "done", + "seq": 3, + "event_id": f"{old_stream}:3", + "created_at": 3.0, + "terminal": True, + "payload": { + "session": { + "session_id": "session-older-unresolved", + "message_count": 2, + "messages": messages[:2], + }, + }, + }, + ] + new_events = [ + { + "event": "token", + "seq": 1, + "event_id": f"{new_stream}:1", + "created_at": 3.0, + "payload": {"text": "new answer"}, + }, + { + "event": "done", + "seq": 2, + "event_id": f"{new_stream}:2", + "created_at": 4.0, + "terminal": True, + "payload": { + "session": { + "session_id": "session-older-unresolved", + "message_count": 4, + "messages": messages, + }, + }, + }, + ] + new_ref = _client_anchor_scene_message_ref(messages[3]) + session = SessionStub( + session_id="session-older-unresolved", + tool_calls=[], + anchor_activity_scenes={ + new_ref: { + "version": "anchor_activity_scene_record_v1", + "message_index": 3, + "message_ref": new_ref, + "stream_id": new_stream, + "scene": {"version": "activity_scene_v1", "activity_rows": [{"role": "prose"}]}, + "updated_at": 1.0, + } + }, + save_calls=0, + ) + monkeypatch.setattr(routes, "terminal_run_summaries_for_session", lambda sid, **kwargs: summaries) + monkeypatch.setattr(routes, "find_run_summary", lambda rid: summaries[0] if rid == new_stream else summaries[1]) + monkeypatch.setattr( + routes, + "read_run_events", + lambda session_id, run_id: {"events": new_events if run_id == new_stream else old_events}, + ) + monkeypatch.setattr(routes, "_active_stream_ids", lambda: {new_stream}) + + assert routes._materialize_terminal_anchor_scene_from_run_journal(session, messages) is True + assert session.save_calls == 1 + assert {record["stream_id"] for record in session.anchor_activity_scenes.values()} == { + new_stream, + old_stream, + } + old_record = [ + record for record in session.anchor_activity_scenes.values() if record["stream_id"] == old_stream + ][0] + assert old_record["message_index"] == 1 + + assert routes._materialize_terminal_anchor_scene_from_run_journal(session, messages) is False + assert session.save_calls == 1 + + +def test_anchor_scene_hydration_splits_final_suffix_from_owned_process_prefix(): + from api import routes + + stream_id = "stream-prefix-suffix" + messages = [ + {"role": "user", "content": "do work"}, + { + "role": "assistant", + "content": "Inspecting the fixture before the tool call. Lifecycle gate final answer.", + "_ts": 10.0, + }, + ] + scene = { + "version": "activity_scene_v1", + "mode": "compact_worklog", + "final_answer": "", + "activity_rows": [ + { + "role": "thinking", + "kind": "reasoning", + "source_event_type": "reasoning", + "local_id": f"live-reasoning:{stream_id}:1", + "text": "Checking the persistent assistant turn.", + "status": "completed", + }, + { + "role": "prose", + "kind": "process_prose", + "source_event_type": "token", + "local_id": f"live-prose:{stream_id}:1", + "text": "Inspecting the fixture before the tool call.", + "status": "completed", + }, + { + "role": "tool", + "kind": "tool_completed", + "source_event_type": "tool_complete", + "local_id": "lifecycle-tool-1", + "tool_call_id": "lifecycle-tool-1", + "text": "README fixture read", + "status": "completed", + "tool": {"id": "lifecycle-tool-1", "name": "read_file", "args": {}, "done": True}, + "payload": {"tid": "lifecycle-tool-1", "name": "read_file"}, + }, + ], + } + + hydrated = routes._complete_hydrated_anchor_scene(messages, scene, 1, stream_id=stream_id) + + assert hydrated["final_answer"] == "Lifecycle gate final answer." + assert all(row.get("source_event_type") != "settled_message" for row in hydrated["activity_rows"]) + assert _anchor_scene_visible_semantics(hydrated) == [ + { + "role": "thinking", + "kind": "reasoning", + "text": "Checking the persistent assistant turn.", + }, + { + "role": "prose", + "kind": "process_prose", + "text": "Inspecting the fixture before the tool call.", + }, + { + "role": "tool", + "kind": "tool_completed", + "status": "completed", + "tool_call_id": "lifecycle-tool-1", + "name": "read_file", + "args": {}, + "done": True, + }, + ] + + +def test_runtime_journal_snapshot_preserves_reasoning_segment_identities(monkeypatch): + from api import routes + + stream_id = "stream-segmented-reasoning" + events = [ + { + "event": "reasoning", + "seq": 1, + "created_at": 1.0, + "payload": {"text": "plan first"}, + }, + { + "event": "token", + "seq": 2, + "created_at": 2.0, + "payload": {"text": "first progress"}, + }, + { + "event": "tool", + "seq": 3, + "created_at": 3.0, + "payload": {"name": "read_file", "tid": "call-1"}, + }, + { + "event": "tool_complete", + "seq": 4, + "created_at": 4.0, + "payload": {"name": "read_file", "tid": "call-1", "preview": "done"}, + }, + { + "event": "reasoning", + "seq": 5, + "created_at": 5.0, + "payload": {"text": "check result"}, + }, + { + "event": "token", + "seq": 6, + "created_at": 6.0, + "payload": {"text": " second progress"}, + }, + ] + monkeypatch.setattr( + routes, + "find_run_summary", + lambda sid: { + "session_id": "session-segmented-reasoning", + "last_seq": 6, + "last_event_id": f"{stream_id}:6", + }, + ) + monkeypatch.setattr( + routes, + "read_run_events", + lambda session_id, run_id: {"events": events}, + ) + + snapshot = routes._run_journal_live_snapshot(stream_id) + rows = snapshot["anchor_activity_scene"]["activity_rows"] + thinking_rows = [row for row in rows if row["role"] == "thinking"] + + assert [row["role"] for row in rows] == ["thinking", "prose", "tool", "thinking", "prose"] + assert [row["local_id"] for row in thinking_rows] == [ + f"live-reasoning:{stream_id}:1", + f"live-reasoning:{stream_id}:2", + ] + assert [row["text"] for row in thinking_rows] == ["plan first", "check result"] + assert [row["group"]["activity_segment_seq"] for row in thinking_rows] == [1, 2] + + +def test_runtime_journal_completion_only_tool_starts_a_new_reasoning_segment(monkeypatch): + from api import routes + + stream_id = "stream-completion-only-tool" + events = [ + { + "event": "reasoning", + "seq": 1, + "created_at": 1.0, + "payload": {"text": "plan first"}, + }, + { + "event": "tool_complete", + "seq": 2, + "created_at": 2.0, + "payload": {"name": "read_file", "tid": "call-1", "preview": "done"}, + }, + { + "event": "reasoning", + "seq": 3, + "created_at": 3.0, + "payload": {"text": "check result"}, + }, + ] + monkeypatch.setattr( + routes, + "find_run_summary", + lambda sid: { + "session_id": "session-completion-only-tool", + "last_seq": 3, + "last_event_id": f"{stream_id}:3", + }, + ) + monkeypatch.setattr( + routes, + "read_run_events", + lambda session_id, run_id: {"events": events}, + ) + + snapshot = routes._run_journal_live_snapshot(stream_id) + rows = snapshot["anchor_activity_scene"]["activity_rows"] + thinking_rows = [row for row in rows if row["role"] == "thinking"] + + assert [row["role"] for row in rows] == ["thinking", "tool", "thinking"] + assert [row["local_id"] for row in thinking_rows] == [ + f"live-reasoning:{stream_id}:1", + f"live-reasoning:{stream_id}:2", + ] + assert [row["text"] for row in thinking_rows] == ["plan first", "check result"] + assert rows[1]["tool"]["done"] is True + assert snapshot["current_live_segment_seq"] == 2 + + +def test_runtime_journal_no_prose_tool_preserves_global_segment_order(monkeypatch): + from api import routes + + stream_id = "stream-no-prose-tool-global-order" + events = [ + { + "event": "reasoning", + "seq": 1, + "created_at": 1.0, + "payload": {"text": "plan first"}, + }, + { + "event": "tool", + "seq": 2, + "created_at": 2.0, + "payload": {"name": "read_file", "tid": "call-1"}, + }, + { + "event": "tool_complete", + "seq": 3, + "created_at": 3.0, + "payload": {"name": "read_file", "tid": "call-1", "preview": "done"}, + }, + { + "event": "interim_assistant", + "seq": 4, + "created_at": 4.0, + "payload": {"text": "progress after tool"}, + }, + { + "event": "tool", + "seq": 5, + "created_at": 5.0, + "payload": {"name": "search", "tid": "call-2"}, + }, + { + "event": "tool_complete", + "seq": 6, + "created_at": 6.0, + "payload": {"name": "search", "tid": "call-2", "preview": "done"}, + }, + { + "event": "reasoning", + "seq": 7, + "created_at": 7.0, + "payload": {"text": "check result"}, + }, + ] + monkeypatch.setattr( + routes, + "find_run_summary", + lambda sid: { + "session_id": "session-no-prose-tool-global-order", + "last_seq": 7, + "last_event_id": f"{stream_id}:7", + }, + ) + monkeypatch.setattr( + routes, + "read_run_events", + lambda session_id, run_id: {"events": events}, + ) + + snapshot = routes._run_journal_live_snapshot(stream_id) + rows = snapshot["anchor_activity_scene"]["activity_rows"] + + assert [row["role"] for row in rows] == [ + "thinking", + "tool", + "prose", + "tool", + "thinking", + ] + assert [row["local_id"] for row in rows] == [ + f"live-reasoning:{stream_id}:1", + "call-1", + f"live-prose:{stream_id}:2", + "call-2", + f"live-reasoning:{stream_id}:3", + ] + assert [row["group"].get("activity_segment_seq") for row in rows] == [1, 1, 2, 2, 3] + assert snapshot["current_live_segment_seq"] == 3 + + +def test_runtime_journal_reasoning_segment_keeps_its_first_timestamp(monkeypatch): + from api import routes + + stream_id = "stream-reasoning-timestamp" + events = [ + { + "event": "reasoning", + "seq": 1, + "created_at": 10.0, + "payload": {"text": "first "}, + }, + { + "event": "reasoning", + "seq": 2, + "created_at": 20.0, + "payload": {"text": "second"}, + }, + ] + monkeypatch.setattr( + routes, + "find_run_summary", + lambda sid: { + "session_id": "session-reasoning-timestamp", + "last_seq": 2, + "last_event_id": f"{stream_id}:2", + }, + ) + monkeypatch.setattr( + routes, + "read_run_events", + lambda session_id, run_id: {"events": events}, + ) + + snapshot = routes._run_journal_live_snapshot(stream_id) + thinking_rows = [ + row + for row in snapshot["anchor_activity_scene"]["activity_rows"] + if row["role"] == "thinking" + ] + + assert len(thinking_rows) == 1 + assert thinking_rows[0]["text"] == "first second" + assert thinking_rows[0]["created_at"] == 10.0 + + +def test_hydration_prefers_persisted_reasoning_segments_over_transcript_aggregate(): + from api import routes + + stream_id = "stream-settled-segments" + messages = [ + {"role": "user", "content": "prompt"}, + { + "role": "assistant", + "content": "final answer", + "reasoning": "first thoughtsecond thought", + }, + ] + scene = { + "version": "activity_scene_v1", + "mode": "compact_worklog", + "activity_rows": [ + { + "role": "thinking", + "kind": "reasoning", + "source_event_type": "reasoning", + "status": "running", + "row_id": f"live-reasoning:{stream_id}:1", + "local_id": f"live-reasoning:{stream_id}:1", + "text": "first thought", + }, + { + "role": "tool", + "kind": "tool_completed", + "source_event_type": "tool_complete", + "status": "completed", + "row_id": "tool-1", + "local_id": "tool-1", + "tool_call_id": "tool-1", + "tool": {"id": "tool-1", "name": "read_file", "done": True}, + }, + { + "role": "thinking", + "kind": "reasoning", + "source_event_type": "reasoning", + "status": "running", + "row_id": f"live-reasoning:{stream_id}:2", + "local_id": f"live-reasoning:{stream_id}:2", + "text": "second thought", + }, + ], + } + + hydrated = routes._complete_hydrated_anchor_scene( + messages, + scene, + 1, + stream_id=stream_id, + ) + rows = hydrated["activity_rows"] + thinking_rows = [row for row in rows if row["role"] == "thinking"] + + assert [row["role"] for row in rows] == ["thinking", "tool", "thinking"] + assert [row["local_id"] for row in thinking_rows] == [ + f"live-reasoning:{stream_id}:1", + f"live-reasoning:{stream_id}:2", + ] + assert [row["text"] for row in thinking_rows] == ["first thought", "second thought"] + assert all(row["status"] == "completed" for row in thinking_rows) + + +def test_hydration_prefers_persisted_segments_over_structured_content_reasoning(): + from api import routes + + stream_id = "stream-structured-settled-segments" + messages = [ + {"role": "user", "content": "prompt"}, + { + "role": "assistant", + "content": [ + {"type": "thinking", "text": "first thoughtsecond thought"}, + {"type": "tool_use", "id": "tool-1", "name": "read_file"}, + {"type": "text", "text": "final answer"}, + ], + }, + ] + scene = { + "version": "activity_scene_v1", + "mode": "compact_worklog", + "activity_rows": [ + { + "role": "thinking", + "kind": "reasoning", + "source_event_type": "reasoning", + "status": "running", + "row_id": f"live-reasoning:{stream_id}:1", + "local_id": f"live-reasoning:{stream_id}:1", + "text": "first thought", + }, + { + "role": "tool", + "kind": "tool_completed", + "source_event_type": "tool_complete", + "status": "completed", + "row_id": "tool-1", + "local_id": "tool-1", + "tool_call_id": "tool-1", + "tool": {"id": "tool-1", "name": "read_file", "done": True}, + }, + { + "role": "thinking", + "kind": "reasoning", + "source_event_type": "reasoning", + "status": "running", + "row_id": f"live-reasoning:{stream_id}:2", + "local_id": f"live-reasoning:{stream_id}:2", + "text": "second thought", + }, + ], + } + + hydrated = routes._complete_hydrated_anchor_scene( + messages, + scene, + 1, + stream_id=stream_id, + ) + rows = hydrated["activity_rows"] + thinking_rows = [row for row in rows if row["role"] == "thinking"] + + assert [row["role"] for row in rows] == ["thinking", "tool", "thinking"] + assert [row["local_id"] for row in thinking_rows] == [ + f"live-reasoning:{stream_id}:1", + f"live-reasoning:{stream_id}:2", + ] + assert [row["text"] for row in thinking_rows] == ["first thought", "second thought"] + + +def test_runtime_journal_snapshot_dedupes_reasoning_interim_progress_echo(monkeypatch): + from api import routes + + stream_id = "stream-live-reasoning-interim-echo" + progress = "我先检查当前仓库状态,然后定位重复渲染路径。" + events = [ + { + "event": "reasoning", + "seq": 1, + "event_id": f"{stream_id}:1", + "created_at": 1.0, + "payload": {"text": progress}, + }, + { + "event": "interim_assistant", + "seq": 2, + "event_id": f"{stream_id}:2", + "created_at": 2.0, + "payload": {"text": progress}, + }, + ] + monkeypatch.setattr( + routes, + "find_run_summary", + lambda sid: { + "session_id": "session-live-reasoning-interim-echo", + "last_seq": 2, + "last_event_id": f"{stream_id}:2", + }, + ) + monkeypatch.setattr( + routes, + "read_run_events", + lambda session_id, run_id: {"events": events}, + ) + + snapshot = routes._run_journal_live_snapshot(stream_id) + rows = snapshot["anchor_activity_scene"]["activity_rows"] + + assert snapshot["last_assistant_text"] == progress + assert snapshot["last_reasoning_text"] == "" + assert [row["role"] for row in rows] == ["prose"] + assert rows[0]["text"] == progress + + +def test_runtime_journal_snapshot_dedupes_echo_spanning_reasoning_segments(monkeypatch): + from api import routes + + stream_id = "stream-cross-segment-reasoning-echo" + first = "先检查仓库。" + second = "再确认测试。" + events = [ + { + "event": "reasoning", + "seq": 1, + "created_at": 1.0, + "payload": {"text": first}, + }, + { + "event": "token", + "seq": 2, + "created_at": 2.0, + "payload": {"text": "正在处理。"}, + }, + { + "event": "tool", + "seq": 3, + "created_at": 3.0, + "payload": {"name": "read_file", "tid": "call-1"}, + }, + { + "event": "tool_complete", + "seq": 4, + "created_at": 4.0, + "payload": {"name": "read_file", "tid": "call-1", "preview": "done"}, + }, + { + "event": "reasoning", + "seq": 5, + "created_at": 5.0, + "payload": {"text": second}, + }, + { + "event": "interim_assistant", + "seq": 6, + "created_at": 6.0, + "payload": {"text": first + second, "reasoning_echo": True}, + }, + ] + monkeypatch.setattr( + routes, + "find_run_summary", + lambda sid: { + "session_id": "session-cross-segment-reasoning-echo", + "last_seq": 6, + "last_event_id": f"{stream_id}:6", + }, + ) + monkeypatch.setattr( + routes, + "read_run_events", + lambda session_id, run_id: {"events": events}, + ) + + snapshot = routes._run_journal_live_snapshot(stream_id) + rows = snapshot["anchor_activity_scene"]["activity_rows"] + + assert snapshot["last_reasoning_text"] == "" + assert not any(row["role"] == "thinking" for row in rows) + assert [row["role"] for row in rows] == ["prose", "tool", "prose"] + + +def test_runtime_journal_reasoning_echo_preserves_consumed_segment_high_water(monkeypatch): + from api import routes + + stream_id = "stream-reasoning-echo-high-water" + events = [ + { + "event": "reasoning", + "seq": 1, + "created_at": 1.0, + "payload": {"text": "first"}, + }, + { + "event": "tool", + "seq": 2, + "created_at": 2.0, + "payload": {"name": "read_file", "tid": "call-1"}, + }, + { + "event": "tool_complete", + "seq": 3, + "created_at": 3.0, + "payload": {"name": "read_file", "tid": "call-1", "preview": "done"}, + }, + { + "event": "reasoning", + "seq": 4, + "created_at": 4.0, + "payload": {"text": "second"}, + }, + { + "event": "interim_assistant", + "seq": 5, + "created_at": 5.0, + "payload": {"text": "second", "reasoning_echo": True}, + }, + { + "event": "reasoning", + "seq": 6, + "created_at": 6.0, + "payload": {"text": "third"}, + }, + ] + monkeypatch.setattr( + routes, + "find_run_summary", + lambda sid: { + "session_id": "session-reasoning-echo-high-water", + "last_seq": 6, + "last_event_id": f"{stream_id}:6", + }, + ) + monkeypatch.setattr( + routes, + "read_run_events", + lambda session_id, run_id: {"events": events}, + ) + + snapshot = routes._run_journal_live_snapshot(stream_id) + thinking_rows = [ + row + for row in snapshot["anchor_activity_scene"]["activity_rows"] + if row["role"] == "thinking" + ] + + assert [row["local_id"] for row in thinking_rows] == [ + f"live-reasoning:{stream_id}:1", + f"live-reasoning:{stream_id}:3", + ] + assert [row["text"] for row in thinking_rows] == ["first", "third"] + assert snapshot["current_live_segment_seq"] == 3 def test_runtime_journal_snapshot_has_running_anchor_row_before_first_token(monkeypatch): diff --git a/tests/test_issue3820_chat_activity_display_mode.py b/tests/test_issue3820_chat_activity_display_mode.py index 935b544a0c..845d2f8950 100644 --- a/tests/test_issue3820_chat_activity_display_mode.py +++ b/tests/test_issue3820_chat_activity_display_mode.py @@ -315,12 +315,13 @@ def test_chat_activity_display_mode_legacy_thinking_fallback_does_not_render_act def test_chat_activity_display_mode_settled_hide_all_scene_persists_without_worklog(): - start = MESSAGES_JS.index("function _attachProjectedAnchorSceneToLastAssistant(messages){") + start = MESSAGES_JS.index("function _attachProjectedAnchorSceneToLastAssistant") end = MESSAGES_JS.index("function _upsertAnchorProcessProse", start) block = MESSAGES_JS[start:end] - persist_index = block.index("_persistSettledAnchorScene(lastAsst, scene, lastAsstIndex);") + persist_index = block.index("_persistSettledAnchorScene(lastAsst, scene, lastAsstIndex, attachOptions);") return_index = block.index("return hasWorklogRows;") + assert "const attachOptions=(typeof options==='object'&&options)?options:{};" in block assert "const hasWorklogRows=_anchorSceneHasWorklogWorthyRows(scene);" in block assert "const hasOwnedOutcomes=_anchorSceneHasOwnedOutcomes(scene);" in block assert ( diff --git a/tests/test_issue3929_process_wakeup_pause.py b/tests/test_issue3929_process_wakeup_pause.py index 88a9a9af5d..c5e74e64df 100644 --- a/tests/test_issue3929_process_wakeup_pause.py +++ b/tests/test_issue3929_process_wakeup_pause.py @@ -1973,9 +1973,114 @@ def _save_and_cancel_after_success_clear(self, *args, **kwargs): assert saved.process_wakeup_pause == previous_pause assert saved.messages == previous_messages assert saved.context_messages == previous_messages - queued_events = [item[0] for item in list(stream_queue.queue)] + queued_items = list(stream_queue.queue) + queued_events = [item[0] for item in queued_items] assert "cancel" in queued_events assert "done" not in queued_events + cancel_payload = next(payload for event, payload in queued_items if event == "cancel") + assert "session" not in cancel_payload + disposition = cancel_payload["terminal_disposition"] + assert disposition["version"] == "terminal_disposition_v1" + assert disposition["kind"] == "consumed_non_materializable" + assert disposition["session_id"] == session_id + assert disposition["run_id"] == stream_id + assert disposition["stream_id"] == stream_id + + +def test_gateway_prewriteback_cancel_settles_under_existing_lock(tmp_path, monkeypatch): + stream_id = "gateway-prewriteback-cancel-stream" + session_id = "gateway_prewriteback_cancel" + stream_queue = queue.Queue() + config.STREAMS[stream_id] = stream_queue + config.CANCEL_FLAGS[stream_id] = threading.Event() + monkeypatch.setattr(gateway_chat, "RunJournalWriter", lambda *_args, **_kwargs: None) + monkeypatch.setattr(gateway_chat, "gateway_approval_unavailable_reason", lambda *_args, **_kwargs: None) + monkeypatch.setattr(config, "get_config", lambda: {"webui_gateway_base_url": "http://gateway.test"}) + + class _GatewayResponse: + def __enter__(self): + return self + + def __exit__(self, *_exc): + return False + + def __iter__(self): + payload = {"choices": [{"delta": {"content": "Gateway reply"}}]} + return iter([ + ("data: " + json.dumps(payload) + "\n").encode("utf-8"), + b"data: [DONE]\n", + ]) + + monkeypatch.setattr( + gateway_chat.urllib.request, + "urlopen", + lambda *_args, **_kwargs: _GatewayResponse(), + ) + + session = Session( + session_id=session_id, + workspace=str(tmp_path), + model="claude-sonnet-test", + model_provider="test-provider", + messages=[], + context_messages=[], + active_stream_id=stream_id, + pending_user_message="wake up", + pending_user_source="process_wakeup", + ) + session.save() + models.SESSIONS[session_id] = session + + class _NonReentrantSessionLock: + def __init__(self): + self.entered = False + self.entries = 0 + self.reentries = 0 + + def __enter__(self): + if self.entered: + self.reentries += 1 + raise AssertionError("Gateway terminal settlement re-entered the session lock") + self.entered = True + self.entries += 1 + config.CANCEL_FLAGS[stream_id].set() + return self + + def __exit__(self, *_exc): + self.entered = False + return False + + sentinel_lock = _NonReentrantSessionLock() + monkeypatch.setattr(gateway_chat, "_get_session_agent_lock", lambda _session_id: sentinel_lock) + + gateway_chat._run_gateway_chat_streaming( + session_id, + "wake up", + "claude-sonnet-test", + str(tmp_path), + stream_id, + model_provider="test-provider", + ) + + assert sentinel_lock.reentries == 0 + saved = Session.load(session_id) + assert saved is not None + assert saved.active_stream_id is None + assert saved.pending_user_message is None + assert [msg.get("role") for msg in saved.messages][0] == "user" + assert [msg.get("role") for msg in saved.messages][-1] == "assistant" + assert saved.messages[0]["_source"] == "process_wakeup" + assert saved.messages[-1]["_error"] is True + queued_items = list(stream_queue.queue) + queued_events = [event for event, _payload in queued_items] + assert "cancel" in queued_events + assert "done" not in queued_events + cancel_payload = next(payload for event, payload in queued_items if event == "cancel") + session_payload = cancel_payload["session"] + assert session_payload["session_id"] == session_id + assert session_payload["message_count"] == len(saved.messages) + assert [msg.get("role") for msg in session_payload["messages"]][0] == "user" + assert [msg.get("role") for msg in session_payload["messages"]][-1] == "assistant" def test_gateway_late_cancel_preserves_completed_webui_turn(tmp_path, monkeypatch): @@ -2067,9 +2172,20 @@ def _save_and_cancel_after_completed_webui_turn(self, *args, **kwargs): "hello", "Gateway reply", ] - queued_events = [item[0] for item in list(stream_queue.queue)] + queued_items = list(stream_queue.queue) + queued_events = [item[0] for item in queued_items] assert "cancel" in queued_events assert "done" not in queued_events + cancel_payload = next(payload for event, payload in queued_items if event == "cancel") + session_payload = cancel_payload["session"] + assert "terminal_disposition" not in cancel_payload + assert session_payload["session_id"] == session_id + assert session_payload["message_count"] == 3 + assert [msg.get("content") for msg in session_payload["messages"]] == [ + "before", + "hello", + "Gateway reply", + ] def test_gateway_late_cancel_preserves_existing_pause_for_webui_recovery(tmp_path, monkeypatch): @@ -2172,9 +2288,20 @@ def _save_and_cancel_after_completed_recovery_turn(self, *args, **kwargs): "try recovery", "Gateway recovery reply", ] - queued_events = [item[0] for item in list(stream_queue.queue)] + queued_items = list(stream_queue.queue) + queued_events = [item[0] for item in queued_items] assert "cancel" in queued_events assert "done" not in queued_events + cancel_payload = next(payload for event, payload in queued_items if event == "cancel") + session_payload = cancel_payload["session"] + assert "terminal_disposition" not in cancel_payload + assert session_payload["session_id"] == session_id + assert session_payload["message_count"] == 3 + assert [msg.get("content") for msg in session_payload["messages"]] == [ + "before", + "try recovery", + "Gateway recovery reply", + ] def test_gateway_post_save_cancel_after_success_commit_emits_done(tmp_path, monkeypatch): diff --git a/tests/test_issue5749_transparent_stream_prefix_dedupe.py b/tests/test_issue5749_transparent_stream_prefix_dedupe.py index cb37358cc1..fe7ba73f64 100644 --- a/tests/test_issue5749_transparent_stream_prefix_dedupe.py +++ b/tests/test_issue5749_transparent_stream_prefix_dedupe.py @@ -107,6 +107,8 @@ def test_issue5749_settlement_preserves_pre_tool_live_token_prefix_rows(tmp_path eval(extractFunc('_anchorSceneSettleLiveRunningRow')); eval(extractFunc('_anchorSceneRowLooksLikeFinalAnswer')); eval(extractFunc('_anchorSceneRowTextOverlapsExisting')); +eval(extractFunc('_anchorSceneFinalAnswerHintFromScene')); +eval(extractFunc('_anchorSceneFinalAnswerHintFromTranscript')); eval(extractFunc('_anchorSceneMessageRowsHaveThinking')); eval(extractFunc('_completeSettledAnchorSceneForTurn')); function _anchorSceneActiveMode() {{ return 'transparent_stream'; }} @@ -212,6 +214,8 @@ def test_issue5749_distinct_live_token_prefix_rows_survive_settlement(tmp_path): eval(extractFunc('_anchorSceneSettleLiveRunningRow')); eval(extractFunc('_anchorSceneRowLooksLikeFinalAnswer')); eval(extractFunc('_anchorSceneRowTextOverlapsExisting')); +eval(extractFunc('_anchorSceneFinalAnswerHintFromScene')); +eval(extractFunc('_anchorSceneFinalAnswerHintFromTranscript')); eval(extractFunc('_anchorSceneMessageRowsHaveThinking')); eval(extractFunc('_completeSettledAnchorSceneForTurn')); function _anchorSceneActiveMode() {{ return 'transparent_stream'; }} @@ -295,6 +299,8 @@ def test_issue5749_long_live_progress_prefix_is_suppressed_without_settled_dupli eval(extractFunc('_anchorSceneSettleLiveRunningRow')); eval(extractFunc('_anchorSceneRowLooksLikeFinalAnswer')); eval(extractFunc('_anchorSceneRowTextOverlapsExisting')); +eval(extractFunc('_anchorSceneFinalAnswerHintFromScene')); +eval(extractFunc('_anchorSceneFinalAnswerHintFromTranscript')); eval(extractFunc('_anchorSceneMessageRowsHaveThinking')); eval(extractFunc('_completeSettledAnchorSceneForTurn')); function _anchorSceneActiveMode() {{ return 'transparent_stream'; }} @@ -732,6 +738,8 @@ def test_issue5749_multi_segment_settled_tool_rows_do_not_shield_final_accumulat eval(extractFunc('_anchorSceneSettleLiveRunningRow')); eval(extractFunc('_anchorSceneRowLooksLikeFinalAnswer')); eval(extractFunc('_anchorSceneRowTextOverlapsExisting')); +eval(extractFunc('_anchorSceneFinalAnswerHintFromScene')); +eval(extractFunc('_anchorSceneFinalAnswerHintFromTranscript')); eval(extractFunc('_anchorSceneMessageRowsHaveThinking')); eval(extractFunc('_completeSettledAnchorSceneForTurn')); function _anchorSceneActiveMode() {{ return 'transparent_stream'; }} diff --git a/tests/test_live_anchor_progress_echo.py b/tests/test_live_anchor_progress_echo.py index 2b988c6514..81b1026f78 100644 --- a/tests/test_live_anchor_progress_echo.py +++ b/tests/test_live_anchor_progress_echo.py @@ -2,8 +2,11 @@ from __future__ import annotations +import json import pathlib import re +import shutil +import subprocess REPO = pathlib.Path(__file__).resolve().parent.parent @@ -28,13 +31,69 @@ def test_interim_reasoning_echo_cleans_live_and_anchor_thinking(): assert "if(reasoningEcho) _stripLiveReasoningEcho(visible);" in body assert "function _stripAnchorReasoningEcho(visible)" in MESSAGES assert "function _removeLiveReasoningEchoRows(visible)" in MESSAGES - assert "events.splice(i,1);" in MESSAGES + assert "events.splice(index,1)" in MESSAGES assert '.agent-activity-thinking[data-anchor-scene-row="1"]' in MESSAGES assert "_removeLiveReasoningEchoRows(visible)" in MESSAGES assert "reasoningText=durable.text;" in MESSAGES assert "liveReasoningText=live.text;" in MESSAGES +def test_anchor_reasoning_echo_can_remove_a_suffix_spanning_multiple_segments(): + node = shutil.which("node") + assert node, "node is required for the live reasoning echo behavior test" + script = """ +const fs=require('fs'); +const src=fs.readFileSync(0,'utf8'); +function extractFunc(name){ + const start=src.indexOf('function '+name); + if(start<0) throw new Error(name+' not found'); + const params=src.indexOf('(',start); + let depth=0,close=-1; + for(let i=params;iitem.local_id===localId&&item.source_event_type===sourceEventType); + if(!event) return null; + event.payload={...(event.payload||{}),...((patch&&patch.payload)||{})}; + return event; +} +function _renderAnchorLiveScene(){ return true; } +eval(extractFunc('_compactVisibleEchoText')); +eval(extractFunc('_stripCompactEchoSuffix')); +eval(extractFunc('_stripAnchorReasoningEcho')); +const removed=_stripAnchorReasoningEcho('firstsecond'); +process.stdout.write(JSON.stringify({removed,events})); +""" + result = subprocess.run( + [node, "-e", script], + input=MESSAGES, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["removed"] is True + assert [event["source_event_type"] for event in payload["events"]] == ["tool_complete"] + + def test_interim_anchor_render_runs_after_legacy_segment_flush_without_duplicate_process_row(): body = _interim_listener_body() diff --git a/tests/test_live_anchor_stable_run_identity.py b/tests/test_live_anchor_stable_run_identity.py index e9df7b1dfc..f9683426c2 100644 --- a/tests/test_live_anchor_stable_run_identity.py +++ b/tests/test_live_anchor_stable_run_identity.py @@ -302,3 +302,60 @@ def test_browser_scene_hydration_prefers_stable_run_id_with_legacy_stream_fallba assert data["legacy"]["context"]["run_id"] == "stream-transport-1" assert data["fallbackHydrated"] is True assert data["fallback"]["event"]["local_id"] == "snapshot:stream-scene-scoped:0" + + +@pytest.mark.skipif( + NODE is None, reason="node is required for browser hydration regression coverage" +) +def test_anchor_source_event_preserves_explicit_snapshot_local_id_with_stable_run_context(): + anchors_path = ROOT / "static" / "assistant_turn_anchors.js" + script = f""" +const fs=require('fs'); +const vm=require('vm'); +const src=fs.readFileSync({json.dumps(str(anchors_path))},'utf8'); +const sandbox={{window:{{}}}}; +vm.createContext(sandbox); +vm.runInContext(src,sandbox,{{filename:'assistant_turn_anchors.js'}}); +const api=sandbox.window.HermesAssistantTurnAnchors; +const registry=api.createAssistantTurnAnchorRegistry({{ + session_id:'session-stable-run', +}}); +api.applyAssistantTurnAnchorSourceEvents(registry, [ + {{ + source_event_type:'token', + local_id:'live-prose:stream-transport-1:1', + run_id:'run-stable-1', + stream_id:'stream-transport-1', + payload:{{text:'progress', activitySegmentSeq:1}}, + }}, + {{ + source_event_type:'reasoning', + local_id:'live-reasoning:stream-transport-1:1', + run_id:'run-stable-1', + stream_id:'stream-transport-1', + payload:{{text:'thinking one', activitySegmentSeq:1}}, + }}, + {{ + source_event_type:'reasoning', + local_id:'live-reasoning:stream-transport-1:2', + run_id:'run-stable-1', + stream_id:'stream-transport-1', + payload:{{text:'thinking two', activitySegmentSeq:2}}, + }}, +], {{run_id:'run-stable-1', stream_id:'stream-transport-1'}}); +const scene=api.projectAssistantTurnAnchorActivityScene(registry, {{mode:'compact_worklog'}}); +console.log(JSON.stringify(scene.activity_rows.map(row => [row.role, row.local_id]))); +""" + result = subprocess.run( + [NODE, "-e", script], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [ + ["prose", "live-prose:stream-transport-1:1"], + ["thinking", "live-reasoning:stream-transport-1:1"], + ["thinking", "live-reasoning:stream-transport-1:2"], + ] diff --git a/tests/test_live_to_final_anchor_visible_order.py b/tests/test_live_to_final_anchor_visible_order.py index 239bd2535f..d4e8e9eaf7 100644 --- a/tests/test_live_to_final_anchor_visible_order.py +++ b/tests/test_live_to_final_anchor_visible_order.py @@ -118,6 +118,8 @@ def _run_complete_anchor_settlement_case(active_mode): eval(extractFunc('_anchorSceneSettleLiveRunningRow')); eval(extractFunc('_anchorSceneRowLooksLikeFinalAnswer')); eval(extractFunc('_anchorSceneRowTextOverlapsExisting')); +eval(extractFunc('_anchorSceneFinalAnswerHintFromScene')); +eval(extractFunc('_anchorSceneFinalAnswerHintFromTranscript')); eval(extractFunc('_anchorSceneMessageRowsHaveThinking')); if(src.indexOf('function _anchorSceneActiveMode') !== -1){{ eval(extractFunc('_anchorSceneActiveMode')); @@ -891,11 +893,12 @@ def test_settled_anchor_scene_is_persisted_as_ui_metadata(): persist = _function_body(MESSAGES_JS, "_persistSettledAnchorScene") msg_ref = _function_body(MESSAGES_JS, "_anchorSceneMessageRef") - assert "_persistSettledAnchorScene(lastAsst, scene, lastAsstIndex);" in attach + assert "const attachOptions=(typeof options==='object'&&options)?options:{};" in attach + assert "_persistSettledAnchorScene(lastAsst, scene, lastAsstIndex, attachOptions);" in attach assert "api('/api/session/anchor-scene'" in persist assert "session_id:activeSid" in persist assert "stream_id:streamId" in persist - assert "const messageOffset=_anchorSceneMessageOffsetForPersist();" in persist + assert "const messageOffset=_anchorSceneMessageOffsetForPersist(options.messageOffset);" in persist assert "message_index:_anchorSceneAbsoluteMessageIndexForPersist(messageIndex,messageOffset)" in persist assert "message_window_index:messageIndex" in persist assert "message_offset:messageOffset" in persist @@ -905,13 +908,30 @@ def test_settled_anchor_scene_is_persisted_as_ui_metadata(): assert "content:String(content||'').replace(/\\s+/g,' ').trim()" in msg_ref +def test_done_persists_owned_anchor_scene_during_session_switch_window(): + done = _event_listener_body(MESSAGES_JS, "done") + + background_idx = done.index("if(!isActiveSession&&Array.isArray(completedSession.messages)){") + attach_idx = done.index("_attachProjectedAnchorSceneToLastAssistant(completedMessages,{", background_idx) + active_idx = done.index("if(isActiveSession){", background_idx) + assert background_idx < attach_idx < active_idx + assert "messageOffset:completedSession._messages_offset||0" in done[background_idx:active_idx] + assert "finalAnswer:_stripXmlToolCalls(assistantText.slice(segmentStart))" in done[background_idx:active_idx] + assert "S.session=completedSession" not in done[background_idx:active_idx] + + complete = _function_body(MESSAGES_JS, "_completeSettledAnchorSceneForTurn") + assert "const rawFinalAnswerHint=typeof options.finalAnswer==='string'?options.finalAnswer:'';" in complete + assert "_anchorSceneFinalAnswerHintFromTranscript(rawFinalAnswerHint,base)||rawFinalAnswerHint" in complete + assert "const finalAnswer=_anchorSceneCleanText(finalAnswerHint)" in complete + + def test_settled_anchor_scene_persists_the_full_assistant_turn_not_only_tail(): attach = _function_body(MESSAGES_JS, "_attachProjectedAnchorSceneToLastAssistant") complete = _function_body(MESSAGES_JS, "_completeSettledAnchorSceneForTurn") rows_by_message = _function_body(MESSAGES_JS, "_anchorSceneRowsByMessageIndex") reasoning_text = _function_body(MESSAGES_JS, "_anchorSceneMessageReasoningText") - assert "const scene=_completeSettledAnchorSceneForTurn(messages,lastAsstIndex,projectedScene);" in attach + assert "const scene=_completeSettledAnchorSceneForTurn(messages,lastAsstIndex,projectedScene,attachOptions);" in attach assert "for(let idx=lastAsstIndex-1;idx>=0;idx-=1)" in complete assert "messages.slice(turnStart+1,lastAsstIndex+1)" in complete assert "message.reasoning||message._reasoning||message.reasoning_content||message.thinking" in reasoning_text @@ -922,6 +942,52 @@ def test_settled_anchor_scene_persists_the_full_assistant_turn_not_only_tail(): assert "_anchorSceneToolRowFromCall(tool,0,idx)" in rows_by_message +def test_structured_reasoning_is_not_duplicated_by_top_level_mirror(): + script = f""" +const fs=require('fs'); +const src=fs.readFileSync({json.dumps(str(ROOT / "static" / "messages.js"))},'utf8'); +{_EXTRACT_FUNC_JS} +const activeSid='session'; +const streamId='stream'; +global.S={{toolCalls:[]}}; +function _anchorSceneMessageText(){{ return ''; }} +function _anchorSceneMessageReasoningText(message){{ return String(message.reasoning||''); }} +function _anchorSceneThinkingRow(text){{ return {{row_id:'top-level',role:'thinking',text}}; }} +function _anchorSceneProseRow(text){{ return {{row_id:'prose',role:'prose',text}}; }} +function _anchorSceneToolRowFromCall(tool){{ + return {{row_id:'tool',role:'tool',tool_call_id:tool.id,tool:{{id:tool.id}}}}; +}} +function _anchorSceneMatchingContentToolRow(){{ return null; }} +function _anchorSceneToolRowsHaveDifferentExplicitIds(){{ return false; }} +function _enrichSettledToolRowBodyFromLive(){{ return false; }} +eval(extractFunc('_anchorSceneCleanText')); +eval(extractFunc('_anchorSceneTextKey')); +eval(extractFunc('_anchorSceneContentText')); +eval(extractFunc('_anchorSceneContentVisibleText')); +eval(extractFunc('_anchorSceneMessageHasContentToolUse')); +eval(extractFunc('_anchorSceneContentTool')); +eval(extractFunc('_anchorSceneRowsFromContentParts')); +eval(extractFunc('_anchorSceneRowsByMessageIndex')); +const messages=[ + {{role:'user',content:'prompt'}}, + {{ + role:'assistant', + content:[ + {{type:'thinking',text:'same reasoning'}}, + {{type:'tool_use',id:'tool-1',name:'read_file'}}, + {{type:'text',text:'final answer'}}, + ], + reasoning:'same reasoning', + }}, +]; +const rows=_anchorSceneRowsByMessageIndex(messages,0,1,{{includeFinal:true}}).get(1)||[]; +process.stdout.write(JSON.stringify(rows.filter(row=>row.role==='thinking').map(row=>row.text))); +""" + rows = _run_node_script(script) + + assert rows == ["same reasoning"] + + def test_settled_anchor_scene_preserves_live_projected_order_before_backfill(): complete = _function_body(MESSAGES_JS, "_completeSettledAnchorSceneForTurn") overlap = _function_body(MESSAGES_JS, "_anchorSceneRowTextOverlapsExisting") @@ -940,16 +1006,157 @@ def test_settled_anchor_scene_preserves_live_projected_order_before_backfill(): assert "rowTextKey.includes(existing)||existing.includes(rowTextKey)" in overlap +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_settled_anchor_scene_suppresses_final_suffix_live_accumulator(): + final_answer = "Inspecting the fixture before the tool call. Lifecycle gate final answer." + suffix_text = "Lifecycle gate final answer." + script = f""" +const fs=require('fs'); +const src=fs.readFileSync({json.dumps(str(ROOT / "static" / "messages.js"))},'utf8'); +{_EXTRACT_FUNC_JS} +global.window = {{ chatActivityMode(){{ return 'compact_worklog'; }} }}; +global.S = {{ session: {{}} }}; +eval(extractFunc('_anchorSceneCleanText')); +eval(extractFunc('_anchorSceneTextKey')); +eval(extractFunc('_anchorSceneExistingRowKey')); +eval(extractFunc('_anchorSceneRowHasLiveIdentity')); +eval(extractFunc('_anchorSceneSettleLiveRunningRow')); +eval(extractFunc('_anchorSceneRowLooksLikeFinalAnswer')); +eval(extractFunc('_anchorSceneRowTextOverlapsExisting')); +eval(extractFunc('_anchorSceneFinalAnswerHintFromScene')); +eval(extractFunc('_anchorSceneFinalAnswerHintFromTranscript')); +eval(extractFunc('_anchorSceneMessageRowsHaveThinking')); +eval(extractFunc('_completeSettledAnchorSceneForTurn')); +function _anchorSceneActiveMode(){{ return 'compact_worklog'; }} +function _anchorSceneFinalAnswerText(message){{ return message && (message.content || ''); }} +function _anchorSceneRowsByMessageIndex(){{ return new Map(); }} +function _anchorSceneMessageRef(message){{ return String(message && message.id || ''); }} +function _anchorSceneTurnDurationForSettlement(){{ return 0; }} +function _anchorSceneRowDisplayHintForMode(row){{ return row && row.display_hint || 'activity_row'; }} +const scene = _completeSettledAnchorSceneForTurn([ + {{role:'user', content:'Prompt', id:'user-1'}}, + {{role:'assistant', content:{json.dumps(final_answer)}, id:'assistant-1'}}, +], 1, {{ + mode:'compact_worklog', + final_answer:{json.dumps(final_answer)}, + activity_rows:[ + {{role:'thinking', text:'Checking the fixture', status:'running', local_id:'live-thinking:stream:1'}}, + {{role:'tool', text:'tool output', status:'running', local_id:'live-tool:stream:1', tool_call_id:'call-1'}}, + {{ + role:'prose', + kind:'process_prose', + source_event_type:'token', + local_id:'live-prose:stream:2', + text:{json.dumps(suffix_text)}, + status:'running', + }}, + ], +}}); +process.stdout.write(JSON.stringify(scene.activity_rows.map(row => ({{ + role: row.role, + local_id: row.local_id || '', + text: row.text || '', + status: row.status || '', +}})))); +""" + rows = _run_node_script(script) + + assert all(row["text"] != suffix_text for row in rows) + assert [row["role"] for row in rows] == ["thinking", "tool"] + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_settled_anchor_scene_splits_final_suffix_from_owned_process_prefix(): + final_answer = "Inspecting the fixture before the tool call. Lifecycle gate final answer." + prefix_text = "Inspecting the fixture before the tool call." + suffix_text = "Lifecycle gate final answer." + script = f""" +const fs=require('fs'); +const src=fs.readFileSync({json.dumps(str(ROOT / "static" / "messages.js"))},'utf8'); +{_EXTRACT_FUNC_JS} +global.window = {{ chatActivityMode(){{ return 'compact_worklog'; }} }}; +global.S = {{ session: {{}} }}; +eval(extractFunc('_anchorSceneCleanText')); +eval(extractFunc('_anchorSceneTextKey')); +eval(extractFunc('_anchorSceneExistingRowKey')); +eval(extractFunc('_anchorSceneRowHasLiveIdentity')); +eval(extractFunc('_anchorSceneSettleLiveRunningRow')); +eval(extractFunc('_anchorSceneRowLooksLikeFinalAnswer')); +eval(extractFunc('_anchorSceneRowTextOverlapsExisting')); +eval(extractFunc('_anchorSceneFinalAnswerHintFromScene')); +eval(extractFunc('_anchorSceneFinalAnswerHintFromTranscript')); +eval(extractFunc('_anchorSceneMessageRowsHaveThinking')); +eval(extractFunc('_completeSettledAnchorSceneForTurn')); +function _anchorSceneActiveMode(){{ return 'compact_worklog'; }} +function _anchorSceneFinalAnswerText(message){{ return message && (message.content || ''); }} +function _anchorSceneRowsByMessageIndex(){{ + return new Map([[1,[{{ + role:'prose', + kind:'process_prose', + source_event_type:'settled_message', + text:{json.dumps(final_answer)}, + status:'completed', + }}]]]); +}} +function _anchorSceneMessageRef(message){{ return String(message && message.id || ''); }} +function _anchorSceneTurnDurationForSettlement(){{ return 0; }} +function _anchorSceneRowDisplayHintForMode(row){{ return row && row.display_hint || 'activity_row'; }} +const scene = _completeSettledAnchorSceneForTurn([ + {{role:'user', content:'Prompt', id:'user-1'}}, + {{role:'assistant', content:{json.dumps(final_answer)}, id:'assistant-1'}}, +], 1, {{ + mode:'compact_worklog', + final_answer:'', + activity_rows:[ + {{role:'thinking', text:'Checking the fixture', status:'running', local_id:'live-thinking:stream:1'}}, + {{ + role:'prose', + kind:'process_prose', + source_event_type:'token', + local_id:'live-prose:stream:1', + text:{json.dumps(prefix_text)}, + status:'completed', + }}, + {{role:'tool', text:'tool output', status:'running', local_id:'live-tool:stream:1', tool_call_id:'call-1'}}, + ], +}}, {{finalAnswer:{json.dumps(final_answer)}}}); +process.stdout.write(JSON.stringify({{ + final_answer: scene.final_answer, + rows: scene.activity_rows.map(row => ({{ + role: row.role, + source: row.source_event_type || '', + text: row.text || '', + }})), +}})); +""" + result = _run_node_script(script) + + assert result["final_answer"] == suffix_text + assert all(row["source"] != "settled_message" for row in result["rows"]) + assert result["rows"] == [ + {"role": "thinking", "source": "", "text": "Checking the fixture"}, + {"role": "prose", "source": "token", "text": prefix_text}, + {"role": "tool", "source": "", "text": "tool output"}, + ] + + def test_settled_anchor_scene_does_not_persist_running_live_activity_rows(): complete = _function_body(MESSAGES_JS, "_completeSettledAnchorSceneForTurn") live_identity = _function_body(MESSAGES_JS, "_anchorSceneRowHasLiveIdentity") settle_live = _function_body(MESSAGES_JS, "_anchorSceneSettleLiveRunningRow") - assert "const hasSettledThinking=_anchorSceneMessageRowsHaveThinking(messageRows);" in complete - assert "row=_anchorSceneSettleLiveRunningRow(row,hasSettledThinking);" in complete + assert "const projectedReasoningKey=_anchorSceneTextKey(projectedRows" in complete + assert "const settledReasoningKey=_anchorSceneTextKey(settledReasoningParts.join(''));" in complete + assert "projectedReasoningKey===settledReasoningKey" in complete + assert "if(preferProjectedThinking&&row&&row.role==='thinking') continue;" in complete + assert "row=_anchorSceneSettleLiveRunningRow(row,dropProjectedThinking);" in complete assert "String(value||'').startsWith('live-')" in live_identity assert "String(row.status||'').toLowerCase()!=='running'" in settle_live - assert "if(row.role==='thinking'&&hasSettledThinking) return null;" in settle_live + drop_idx = settle_live.index( + "if(row.role==='thinking'&&dropLiveThinking&&hasLiveIdentity) return null;" + ) + status_idx = settle_live.index("if(String(row.status||'').toLowerCase()!=='running') return row;") + assert drop_idx < status_idx assert "return {...row,status:'completed'};" in settle_live @@ -1390,7 +1597,9 @@ def test_settled_anchor_scene_promotes_final_content_array_to_ordered_activity_r visible_text = _function_body(MESSAGES_JS, "_anchorSceneContentVisibleText") assert "const messageFinalAnswer=_anchorSceneFinalAnswerText(lastAsst);" in complete - assert "const finalAnswer=_anchorSceneCleanText(messageFinalAnswer)" in complete + assert "const finalAnswer=_anchorSceneCleanText(finalAnswerHint)" in complete + assert "_anchorSceneFinalAnswerHintFromTranscript(messageFinalAnswer,base)||messageFinalAnswer" in complete + assert "idx===lastAsstIndex&&sceneHasActivityRows" in complete assert "_anchorSceneRowsByMessageIndex(messages,turnStart,lastAsstIndex,{includeFinal:true})" in complete assert "for(let idx=turnStart+1;idx<=lastAsstIndex;idx+=1)" in complete assert "options=(options&&typeof options==='object')?options:{};" in rows_by_message diff --git a/tests/test_pure_prose_turn_not_worklog.py b/tests/test_pure_prose_turn_not_worklog.py index c508a78ba2..dffcf098f7 100644 --- a/tests/test_pure_prose_turn_not_worklog.py +++ b/tests/test_pure_prose_turn_not_worklog.py @@ -35,7 +35,20 @@ def _function_body(src: str, name: str) -> str: marker = f"function {name}" start = src.index(marker) - brace = src.index("{", start) + params = src.index("(", start) + depth = 0 + close = -1 + for idx in range(params, len(src)): + ch = src[idx] + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + close = idx + break + assert close != -1, f"{name} params did not close" + brace = src.index("{", close) depth = 0 for idx in range(brace, len(src)): ch = src[idx] @@ -52,7 +65,20 @@ def _extract(src: str, name: str) -> str: marker = f"function {name}" start = src.index(marker) body = _function_body(src, name) - sig = src[start : src.index("{", start)] + params = src.index("(", start) + depth = 0 + close = -1 + for idx in range(params, len(src)): + ch = src[idx] + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + close = idx + break + assert close != -1, f"{name} params did not close" + sig = src[start : src.index("{", close)] return f"{sig}{{{body}}}" diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 8d38e44995..ca046e9cc0 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -930,6 +930,7 @@ def test_messages_js_supports_live_reasoning_and_tool_completion(cleanup_test_se "messages.js must track streamed reasoning text separately from assistant text" assert ("let liveReasoningText=''" in src or "let liveReasoningText = reasoningText" in src + or "let liveReasoningText = reconnecting ? '' : reasoningText" in src or "let liveReasoningText=_lastLiveReasoning" in src), \ "messages.js must track the currently active reasoning segment separately from cumulative reasoning" assert "source.addEventListener('reasoning'" in src or 'source.addEventListener("reasoning"' in src, \ diff --git a/tests/test_run_journal.py b/tests/test_run_journal.py index cce7186fa5..68eb4a25bf 100644 --- a/tests/test_run_journal.py +++ b/tests/test_run_journal.py @@ -1,13 +1,16 @@ import json +import os from pathlib import Path from api.run_journal import ( RunJournalWriter, append_run_event, find_run_summary, + latest_terminal_run_summary_for_session, latest_run_summary, read_run_events, stale_interrupted_event, + terminal_run_summaries_for_session, ) @@ -105,6 +108,71 @@ def test_latest_summary_and_find_run_summary_classify_terminal_state(tmp_path): assert found["terminal_state"] == "interrupted-by-user" +def test_latest_terminal_run_summary_for_session_skips_running_runs(tmp_path): + append_run_event("session_1", "run_1", "token", {"text": "old"}, session_dir=tmp_path) + append_run_event("session_1", "run_1", "done", {"session": {}}, session_dir=tmp_path) + append_run_event("session_1", "run_2", "token", {"text": "still running"}, session_dir=tmp_path) + append_run_event("session_1", "run_3", "token", {"text": "latest"}, session_dir=tmp_path) + append_run_event("session_1", "run_3", "cancel", {"message": "Cancelled"}, session_dir=tmp_path) + + summary = latest_terminal_run_summary_for_session("session_1", session_dir=tmp_path) + + assert summary["run_id"] == "run_3" + assert summary["terminal"] is True + assert summary["terminal_state"] == "interrupted-by-user" + + +def test_terminal_run_summaries_for_session_returns_bounded_newest_terminals(tmp_path): + append_run_event("session_1", "run_1", "token", {"text": "old"}, session_dir=tmp_path) + append_run_event("session_1", "run_1", "done", {"session": {}}, session_dir=tmp_path) + append_run_event("session_1", "run_2", "token", {"text": "running"}, session_dir=tmp_path) + append_run_event("session_1", "run_3", "token", {"text": "cancel"}, session_dir=tmp_path) + append_run_event("session_1", "run_3", "cancel", {"message": "Cancelled"}, session_dir=tmp_path) + + summaries = terminal_run_summaries_for_session("session_1", session_dir=tmp_path, limit=2) + + assert [summary["run_id"] for summary in summaries] == ["run_3", "run_1"] + assert [summary["terminal_state"] for summary in summaries] == [ + "interrupted-by-user", + "completed", + ] + + +def test_terminal_run_summaries_can_scan_past_skipped_candidate_window(tmp_path): + append_run_event("session_1", "run_old", "token", {"text": "old"}, session_dir=tmp_path) + append_run_event("session_1", "run_old", "done", {"session": {}}, session_dir=tmp_path) + old_path = tmp_path / "_run_journal" / "session_1" / "run_old.jsonl" + os.utime(old_path, (1.0, 1.0)) + + skipped_run_ids = set() + for idx in range(70): + run_id = f"run_{idx:03d}" + skipped_run_ids.add(run_id) + append_run_event("session_1", run_id, "token", {"text": "new"}, session_dir=tmp_path) + append_run_event("session_1", run_id, "done", {"session": {}}, session_dir=tmp_path) + path = tmp_path / "_run_journal" / "session_1" / f"{run_id}.jsonl" + os.utime(path, (10.0 + idx, 10.0 + idx)) + + bounded = terminal_run_summaries_for_session( + "session_1", + session_dir=tmp_path, + limit=1, + max_candidates=64, + skip_run_ids=skipped_run_ids, + ) + scanned = terminal_run_summaries_for_session( + "session_1", + session_dir=tmp_path, + limit=1, + max_candidates=64, + skip_run_ids=skipped_run_ids, + scan_all_candidates=True, + ) + + assert bounded == [] + assert [summary["run_id"] for summary in scanned] == ["run_old"] + + def test_latest_summary_reuses_unchanged_journal_summary_without_reparsing(tmp_path, monkeypatch): append_run_event("session_1", "run_1", "token", {"text": "ok"}, session_dir=tmp_path) append_run_event("session_1", "run_1", "done", {"session": {}}, session_dir=tmp_path) diff --git a/tests/test_run_journal_routes.py b/tests/test_run_journal_routes.py index 5e9beed143..969170321e 100644 --- a/tests/test_run_journal_routes.py +++ b/tests/test_run_journal_routes.py @@ -365,6 +365,10 @@ def test_live_journal_snapshot_reconstructs_visible_progress_and_tool_aliases(mo assert tool["snippet"] == "passed" assert tool["duration"] == 1.25 assert tool["args"]["extra"] == "x" * 200 + rows = snapshot["anchor_activity_scene"]["activity_rows"] + assert [row["role"] for row in rows] == ["prose", "tool", "thinking", "prose"] + assert rows[2]["local_id"] == "live-reasoning:run_1:2" + assert rows[2]["group"]["activity_segment_seq"] == 2 def test_live_journal_snapshot_bounds_pathological_tool_args(monkeypatch): diff --git a/tests/test_stable_assistant_turn_anchor_registry.py b/tests/test_stable_assistant_turn_anchor_registry.py index a1d8fdc0fe..eea925d89f 100644 --- a/tests/test_stable_assistant_turn_anchor_registry.py +++ b/tests/test_stable_assistant_turn_anchor_registry.py @@ -34,7 +34,20 @@ def _event_listener_body(src: str, event_name: str) -> str: def _function_body(src: str, name: str) -> str: start = src.find(f"function {name}") assert start != -1, f"{name} not found" - brace = src.find("{", start) + params = src.find("(", start) + assert params != -1, f"{name} params not found" + depth = 0 + close = -1 + for idx in range(params, len(src)): + if src[idx] == "(": + depth += 1 + elif src[idx] == ")": + depth -= 1 + if depth == 0: + close = idx + break + assert close != -1, f"{name} params did not close" + brace = src.find("{", close) assert brace != -1, f"{name} body not found" depth = 0 for idx in range(brace, len(src)): diff --git a/tests/test_webui_gateway_chat_backend.py b/tests/test_webui_gateway_chat_backend.py index 7140c50153..3d60a899fe 100644 --- a/tests/test_webui_gateway_chat_backend.py +++ b/tests/test_webui_gateway_chat_backend.py @@ -463,6 +463,8 @@ def __iter__(self): assert apperrors assert apperrors[-1]["type"] in {"model_not_found", "auth_mismatch"} assert apperrors[-1]["session_id"] == s.session_id + assert apperrors[-1]["session"]["message_count"] == len(apperrors[-1]["session"]["messages"]) + assert apperrors[-1]["session"]["messages"][-1].get("_error") is True saved = models.get_session(s.session_id) user_messages = [m for m in saved.messages if m.get("role") == "user"] assert len(user_messages) == 2 @@ -496,6 +498,8 @@ def __iter__(self): empty_errors = [item[1] for item in empty_events if item[0] == "apperror"] assert empty_errors[-1]["type"] == "gateway_empty_response" assert empty_errors[-1]["session_id"] == s.session_id + assert empty_errors[-1]["session"]["message_count"] == len(empty_errors[-1]["session"]["messages"]) + assert empty_errors[-1]["session"]["messages"][-1].get("_error") is True response_error[0] = "Gateway provider failed without a known classification" unknown_stream_id = "stream-gateway-unknown-terminal-error-test" @@ -519,6 +523,8 @@ def __iter__(self): unknown_errors = [item[1] for item in unknown_events if item[0] == "apperror"] assert unknown_errors[-1]["type"] == "error" assert "Gateway provider failed" in unknown_errors[-1]["message"] + assert unknown_errors[-1]["session"]["message_count"] == len(unknown_errors[-1]["session"]["messages"]) + assert unknown_errors[-1]["session"]["messages"][-1].get("_error") is True response_error[0] = "partial" partial_stream_id = "stream-gateway-partial-terminal-error-test"