[HELD — independent review pending] Release v0.51.294 — stage-3401 (live-to-final redesign #3401 + 4 deep-review fixes) (#3741)
* Harden interrupted recovery control filtering * Redesign live-to-final assistant replies * Fix live activity anchor test fixture * Fix CI lint issues for live reply tests * Strengthen live progress prompt contract * Recover PR #3401 refresh on origin/master * Repair live-to-final refresh regressions * Fix live worklog refresh regressions * Show live footer timer on initial stream start * Restore live stream shell after reload * Preserve per-frame live SSE replay cursors * Preserve reasoning as Worklog Thinking cards * Quiet Worklog Thinking card styling * Align Worklog Thinking card styling * Scope live Worklog Thinking cards by segment * Suppress exact duplicate settled Thinking * Close #3401 merge review test gaps * fix(#3401): resolve 4 deep-review regressions (inline-think, reconnect-dup, neon skin, busy-gate worklog) Deep review (Codex diff-vs-master + live-browser drive) of the live-to-final refactor surfaced 4 regressions vs master that the rewritten suite no longer guarded: 1. Inline <think>…</think>answer reasoning vanished — _assistantReasoningPayloadText used $-anchored regexes so a leading think block + visible answer extracted nothing and the Thinking card never rendered. Removed the 3 $ anchors to match the (non-anchored) display stripper. Live: inline-think thinking-only turn now renders. 2. (CORE) reconnect/reload duplicated the live reply — _rememberRunJournalCursor advanced a closure-local seq but never wrote INFLIGHT[activeSid].lastRunJournalSeq, so a reload replayed the journal from after_seq=0 over restored lastAssistantText. Now mirrors the cursor onto INFLIGHT + schedules a throttled persist. 3. Neon skin silently broke — PR deleted the :root[data-skin="neon"] CSS but left Neon in the picker. Restored the neon CSS block from master. 4. Settled tool-worklog rebuild gated purely on !S.busy — dropped every prior settled turn's worklog when renderMessages re-ran during an active stream (switch-back to an in-progress session). Restored master's !S.busy || (S.toolCalls && S.toolCalls.length). Live: busy re-render now preserves tool cards (4→4, was 4→0). Live-verified all 4 + confirmed #3709/#3592 invariants still hold (1 thinking card, none below footer; distinct siblings preserved). + tests/test_issue3401_deep_review_fixes.py (7). * test(#3401): realign 3 stale source-shape assertions to the deep-review fixes Fix commit changed two source literals that existing stage tests scanned for: - test_live_activity_timeline.py (x2): split anchor 'if(!S.busy){' → the restored 'if(!S.busy || (S.toolCalls&&S.toolCalls.length)){' guard (fix 4). - test_run_journal_frontend_static.py: 'after_seq=0' not in source — fix 2's comment contained that literal; rephrased the comment to 'the zero floor (after_seq of 0)'. Intent of all three assertions unchanged; only the matched string updated. No code behavior change. * docs(changelog): v0.51.294 — Release JJ (stage-3401, #3401 live-to-final redesign) --------- Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: Nathan-Hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: nesquena-hermes <[email protected]>
This commit is contained in:
46 files changed
+5961
-1350
No files matched your search
@@ -3,6 +3,17 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.294] — 2026-06-06 — Release JJ (stage-3401 — live-to-final assistant reply redesign)
|
||||
|
||||
### Changed
|
||||
- **Redesigned the live-to-final assistant reply experience for running sessions** (first slice of the #3400 roadmap). During a running turn, visible process text stays the primary timeline; tool activity and reasoning render as quiet sibling items in a folded **Worklog**; and when the turn settles, implementation detail collapses into a compact activity summary above the final answer. Stream ownership, reconnect/replay, auto-compression status, and session-switching now reconstruct the same structure rather than competing for the surface. Strengthens the visible-progress prompt contract so long tool-running turns don't appear silent. (#3401, @franksong2702; refs #3400, supersedes #3015)
|
||||
|
||||
### Fixed
|
||||
- Inline `<think>…</think>` reasoning followed by a visible answer now renders its Thinking card again (the reasoning extractor was over-anchored and dropped it).
|
||||
- Reconnecting or reloading an in-progress turn no longer duplicates already-rendered live reply content (the run-journal replay cursor is now persisted, so replay resumes from the correct point instead of replaying from the start over restored text).
|
||||
- Switching back to an in-progress session no longer drops prior settled turns' tool/worklog cards while the new turn is still streaming.
|
||||
- Restored the Neon skin styling (its CSS had been dropped while it remained selectable).
|
||||
|
||||
## [v0.51.293] — 2026-06-06 — Release JI (stage-s5 — thinking card no longer renders twice)
|
||||
|
||||
### Fixed
|
||||
@@ -26,6 +37,15 @@
|
||||
## [v0.51.289] — 2026-06-06 — Release JE (hotfix — sidebar ReferenceError #3696 + scope-undef prevention gate)
|
||||
|
||||
### Fixed
|
||||
- **Live stream turns show the bottom timer immediately after starting.** The
|
||||
first chat-start path now places the live footer timer as soon as the backend
|
||||
returns `stream_id` and `pending_started_at`, instead of only restoring it
|
||||
after a session switch or reconnect. Reloading a currently running session
|
||||
also recreates the live worklog shell after the transcript DOM rebuild, so the
|
||||
stream no longer stays invisible until you switch away and back. Live replay
|
||||
cursors now stay tied to each queued SSE frame instead of the latest global
|
||||
stream event, so reconnects cannot skip queued live output. (#3401,
|
||||
@franksong2702)
|
||||
- **Sidebar no longer crashes with `ReferenceError: _sessionAttentionState is not defined`.** The session-attention helper was declared *inside* `renderSessionListFromCache()` and relied on function hoisting, but the top-level `_sidebarRowHasVisibleMessages` (reached via `renderSessionListFromCache` → `_partitionSidebarSessionRows`) called it bare — and hoisting is scoped to the enclosing function, so every sidebar cache-render threw and the session list went blank. `_sessionAttentionState` is now a top-level function reachable by both call sites. Regressed in #3672 (v0.51.269). (#3696)
|
||||
- **Stale-stream terminal events no longer risk a `ReferenceError: source is not defined`.** `_bailOutOfTerminalEventsFromStaleStream` (declared inside `attachLiveStream`) called `_closeSource(source)` against a `source` that was not in its lexical scope — it would have thrown on the late-finalizing-stream path when the user is back in an active session. `source` is now threaded as an explicit parameter. Found by the new scope gate below during review. (#3696)
|
||||
|
||||
|
||||
@@ -150,6 +150,14 @@ A tool card is a debug event row, not a chat message. Show icon, name, short tar
|
||||
|
||||
Same visual family as tool-call metadata. They should be quieter than assistant prose and should not use bright tinted full cards unless the user expands them.
|
||||
|
||||
Automatic compression follows a quiet live-only divider treatment rather than a
|
||||
tool-card row. Use `Compressing context` for the active barrier and
|
||||
`Context auto-compressed` after continuation/completion; render both as centered
|
||||
non-interactive text with horizontal rules. Do not give it a caret, click
|
||||
target, distinct accent color, special leading dot, or separate card identity.
|
||||
Once the final answer is settled, omit the live-only compression row unless it is
|
||||
needed to explain a visible recovery or error state.
|
||||
|
||||
### Composer
|
||||
|
||||
The composer is the command surface. Keep it legible and focused: modest radius, subtle border, transparent inactive chips, no theatrical hover scaling.
|
||||
|
||||
+22
-3
@@ -4871,8 +4871,13 @@ class StreamChannel:
|
||||
self._lock = threading.Lock()
|
||||
self._subscribers: list[queue.Queue] = []
|
||||
self._offline_buffer: list[tuple[str, object]] = []
|
||||
self._last_event_id: str | None = None
|
||||
|
||||
def subscribe(self) -> queue.Queue:
|
||||
q, _snapshot = self.subscribe_with_snapshot()
|
||||
return q
|
||||
|
||||
def subscribe_with_snapshot(self) -> tuple[queue.Queue, dict[str, object]]:
|
||||
q: queue.Queue = queue.Queue()
|
||||
with self._lock:
|
||||
# Replay buffered events to the new subscriber INSIDE the lock so a
|
||||
@@ -4882,8 +4887,12 @@ class StreamChannel:
|
||||
# is safe. Per Opus advisor on stage-292.
|
||||
for item in self._offline_buffer:
|
||||
q.put_nowait(item)
|
||||
snapshot = {
|
||||
"offline_buffered_events": len(self._offline_buffer),
|
||||
"last_event_id": self._last_event_id,
|
||||
}
|
||||
self._subscribers.append(q)
|
||||
return q
|
||||
return q, snapshot
|
||||
|
||||
def unsubscribe(self, q: queue.Queue) -> None:
|
||||
with self._lock:
|
||||
@@ -4892,8 +4901,18 @@ class StreamChannel:
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def put_nowait(self, item: tuple[str, object]) -> None:
|
||||
def note_last_event_id(self, event_id: str | None) -> None:
|
||||
"""Record the latest journal event id without changing the queue shape."""
|
||||
if not event_id:
|
||||
return
|
||||
with self._lock:
|
||||
self._last_event_id = event_id
|
||||
|
||||
def put_nowait(self, item: tuple[str, object] | tuple[str, object, str | None]) -> None:
|
||||
event_id = item[2] if len(item) >= 3 else None
|
||||
with self._lock:
|
||||
if event_id:
|
||||
self._last_event_id = event_id
|
||||
subscribers = list(self._subscribers)
|
||||
if not subscribers:
|
||||
self._offline_buffer.append(item)
|
||||
@@ -4902,7 +4921,7 @@ class StreamChannel:
|
||||
for q in subscribers:
|
||||
q.put_nowait(item)
|
||||
|
||||
def diagnostic_snapshot(self) -> dict[str, int]:
|
||||
def diagnostic_snapshot(self) -> dict[str, object]:
|
||||
"""Return non-sensitive stream observation counters for health checks."""
|
||||
with self._lock:
|
||||
return {
|
||||
|
||||
+8
-1
@@ -224,6 +224,7 @@ def _run_gateway_chat_streaming(
|
||||
def put_gateway_event(event, data):
|
||||
if cancel_event.is_set() and event not in ("cancel", "error", "apperror"):
|
||||
return
|
||||
event_id = None
|
||||
if run_journal is not None:
|
||||
try:
|
||||
journaled = run_journal.append_sse_event(event, data)
|
||||
@@ -232,8 +233,14 @@ def _run_gateway_chat_streaming(
|
||||
STREAM_LAST_EVENT_ID[stream_id] = event_id
|
||||
except Exception:
|
||||
logger.debug("Failed to append gateway event %s for stream %s", event, stream_id, exc_info=True)
|
||||
if event_id and hasattr(q, "note_last_event_id"):
|
||||
try:
|
||||
q.put_nowait((event, data))
|
||||
q.note_last_event_id(event_id)
|
||||
except Exception:
|
||||
logger.debug("Failed to note gateway event_id %s for stream %s", event_id, stream_id, exc_info=True)
|
||||
try:
|
||||
queue_item = (event, data, event_id) if event_id and hasattr(q, "subscribe_with_snapshot") else (event, data)
|
||||
q.put_nowait(queue_item)
|
||||
except Exception:
|
||||
logger.debug("Failed to put gateway event to queue")
|
||||
|
||||
|
||||
+113
-10
@@ -2987,8 +2987,8 @@ from api.models import (
|
||||
get_state_db_session_messages,
|
||||
get_state_db_session_summary,
|
||||
merge_session_messages_append_only,
|
||||
_session_message_merge_key,
|
||||
_active_stream_ids,
|
||||
_session_message_merge_key,
|
||||
_is_empty_partial_activity_message,
|
||||
_hide_from_default_sidebar,
|
||||
prune_session_from_index,
|
||||
@@ -8516,7 +8516,14 @@ def _parse_run_journal_after_seq(qs: dict, stream_id: str | None = None) -> int
|
||||
return 0
|
||||
|
||||
|
||||
def _replay_run_journal(handler, stream_id: str, after_seq: int | None) -> bool:
|
||||
def _replay_run_journal(
|
||||
handler,
|
||||
stream_id: str,
|
||||
after_seq: int | None,
|
||||
*,
|
||||
max_seq: int | None = None,
|
||||
include_stale: bool = True,
|
||||
) -> bool:
|
||||
summary = find_run_summary(stream_id)
|
||||
if not summary:
|
||||
return False
|
||||
@@ -8524,6 +8531,7 @@ def _replay_run_journal(handler, stream_id: str, after_seq: int | None) -> bool:
|
||||
str(summary.get("session_id") or ""),
|
||||
stream_id,
|
||||
after_seq=after_seq,
|
||||
max_seq=max_seq,
|
||||
)
|
||||
for entry in journal.get("events") or []:
|
||||
_sse_with_id(
|
||||
@@ -8532,7 +8540,7 @@ def _replay_run_journal(handler, stream_id: str, after_seq: int | None) -> bool:
|
||||
entry.get("payload"),
|
||||
entry.get("event_id"),
|
||||
)
|
||||
if not summary.get("terminal"):
|
||||
if include_stale and not summary.get("terminal"):
|
||||
stale = stale_interrupted_event(
|
||||
str(summary.get("session_id") or ""),
|
||||
stream_id,
|
||||
@@ -8543,6 +8551,13 @@ def _replay_run_journal(handler, stream_id: str, after_seq: int | None) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _run_journal_same_run_seq(event_id: str | None, stream_id: str) -> int | None:
|
||||
event_run_id, event_seq = _parse_run_journal_event_id(event_id)
|
||||
if event_run_id != stream_id:
|
||||
return None
|
||||
return event_seq
|
||||
|
||||
|
||||
def _runner_stream_cursor_from_query(qs: dict) -> str | None:
|
||||
cursor = str(qs.get("cursor", [""])[0] or "").strip()
|
||||
if cursor:
|
||||
@@ -8660,26 +8675,58 @@ def _handle_sse_stream(handler, parsed):
|
||||
except _CLIENT_DISCONNECT_ERRORS:
|
||||
pass
|
||||
return True
|
||||
if hasattr(stream, "subscribe_with_snapshot"):
|
||||
subscriber, stream_snapshot = stream.subscribe_with_snapshot()
|
||||
else:
|
||||
subscriber = stream.subscribe() if hasattr(stream, "subscribe") else stream
|
||||
stream_snapshot = {}
|
||||
handler.send_response(200)
|
||||
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
||||
handler.send_header("Cache-Control", "no-cache")
|
||||
handler.send_header("X-Accel-Buffering", "no")
|
||||
handler.send_header("Connection", "close")
|
||||
handler.end_headers()
|
||||
replay_cutoff_seq = None
|
||||
if qs.get("replay", [""])[0] or qs.get("after_seq", [None])[0] not in (None, "") or qs.get("after_event_id", [None])[0]:
|
||||
snapshot_cutoff_seq = _run_journal_same_run_seq(
|
||||
str(stream_snapshot.get("last_event_id") or ""),
|
||||
stream_id,
|
||||
)
|
||||
try:
|
||||
replayed = _replay_run_journal(
|
||||
handler,
|
||||
stream_id,
|
||||
_parse_run_journal_after_seq(qs, stream_id),
|
||||
max_seq=snapshot_cutoff_seq,
|
||||
include_stale=False,
|
||||
)
|
||||
if replayed:
|
||||
replay_cutoff_seq = snapshot_cutoff_seq
|
||||
except _CLIENT_DISCONNECT_ERRORS:
|
||||
raise
|
||||
except Exception:
|
||||
logger.debug("Failed to replay active run journal for stream %s", stream_id, exc_info=True)
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
event, data = subscriber.get(timeout=_SSE_HEARTBEAT_INTERVAL_SECONDS)
|
||||
item = subscriber.get(timeout=_SSE_HEARTBEAT_INTERVAL_SECONDS)
|
||||
except queue.Empty:
|
||||
handler.wfile.write(b": heartbeat\n\n")
|
||||
handler.wfile.flush()
|
||||
continue
|
||||
if len(item) >= 3:
|
||||
event, data, queued_event_id = item[0], item[1], item[2]
|
||||
else:
|
||||
event, data = item
|
||||
queued_event_id = STREAM_LAST_EVENT_ID.get(stream_id)
|
||||
# Stage-364: emit `id:` from STREAM_LAST_EVENT_ID side-channel so
|
||||
# the frontend's `_lastRunJournalSeq` cursor advances during live
|
||||
# streaming. Without this, mid-stream error→replay would arrive
|
||||
# with after_seq=0 and double-render every journaled event.
|
||||
event_id = STREAM_LAST_EVENT_ID.get(stream_id)
|
||||
event_id = queued_event_id or STREAM_LAST_EVENT_ID.get(stream_id)
|
||||
event_seq = _run_journal_same_run_seq(event_id, stream_id)
|
||||
if replay_cutoff_seq is not None and event_seq is not None and event_seq <= replay_cutoff_seq:
|
||||
continue
|
||||
if event_id:
|
||||
_sse_with_id(handler, event, data, event_id)
|
||||
else:
|
||||
@@ -10943,6 +10990,41 @@ def _is_hidden_empty_session(s) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _active_stream_blocks_chat_start(session, stream_id: str | None) -> bool:
|
||||
"""Return whether an active_stream_id still owns this session's next turn.
|
||||
|
||||
``active_stream_id`` is written before the SSE channel is registered, so a
|
||||
very fresh pending turn must also block duplicate chat_start requests. If we
|
||||
only check STREAMS here, a second request can race through the registration
|
||||
gap and overwrite the sidecar owner.
|
||||
"""
|
||||
if not stream_id:
|
||||
return False
|
||||
with STREAMS_LOCK:
|
||||
if stream_id in STREAMS:
|
||||
return True
|
||||
try:
|
||||
from api import config as _live_config
|
||||
with _live_config.ACTIVE_RUNS_LOCK:
|
||||
if stream_id in (_live_config.ACTIVE_RUNS or {}):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
if getattr(session, "pending_user_message", None):
|
||||
try:
|
||||
from api.models import _REPAIR_STALE_PENDING_GRACE_SECONDS
|
||||
grace_seconds = float(_REPAIR_STALE_PENDING_GRACE_SECONDS)
|
||||
except Exception:
|
||||
grace_seconds = 30.0
|
||||
try:
|
||||
pending_started_at = float(getattr(session, "pending_started_at", None) or 0)
|
||||
except Exception:
|
||||
pending_started_at = 0.0
|
||||
if pending_started_at and time.time() - pending_started_at < grace_seconds:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _start_chat_stream_for_session(
|
||||
s,
|
||||
*,
|
||||
@@ -10963,10 +11045,7 @@ def _start_chat_stream_for_session(
|
||||
diag.stage("active_stream_check") if diag else None
|
||||
current_stream_id = getattr(s, "active_stream_id", None)
|
||||
if current_stream_id:
|
||||
diag.stage("active_stream_lock_wait") if diag else None
|
||||
with STREAMS_LOCK:
|
||||
current_active = current_stream_id in STREAMS
|
||||
if current_active:
|
||||
if _active_stream_blocks_chat_start(s, current_stream_id):
|
||||
diag.stage("response_write") if diag else None
|
||||
return {
|
||||
"error": "session already has an active stream",
|
||||
@@ -10984,10 +11063,23 @@ def _start_chat_stream_for_session(
|
||||
goal_related = True
|
||||
PENDING_GOAL_CONTINUATION.discard(s.session_id)
|
||||
|
||||
stream_id = uuid.uuid4().hex
|
||||
session_lock = _get_session_agent_lock(s.session_id)
|
||||
diag.stage("session_lock_wait") if diag else None
|
||||
while True:
|
||||
with session_lock:
|
||||
locked_stream_id = getattr(s, "active_stream_id", None)
|
||||
if locked_stream_id:
|
||||
if _active_stream_blocks_chat_start(s, locked_stream_id):
|
||||
diag.stage("response_write") if diag else None
|
||||
return {
|
||||
"error": "session already has an active stream",
|
||||
"active_stream_id": locked_stream_id,
|
||||
"_status": 409,
|
||||
}
|
||||
needs_stale_cleanup = True
|
||||
else:
|
||||
needs_stale_cleanup = False
|
||||
stream_id = uuid.uuid4().hex
|
||||
diag.stage("save_pending_state") if diag else None
|
||||
was_hidden_empty_session = _is_hidden_empty_session(s)
|
||||
_prepare_chat_start_session_for_stream(
|
||||
@@ -10999,6 +11091,17 @@ def _start_chat_stream_for_session(
|
||||
model_provider=model_provider,
|
||||
stream_id=stream_id,
|
||||
)
|
||||
break
|
||||
if needs_stale_cleanup:
|
||||
diag.stage("stale_stream_cleanup") if diag else None
|
||||
cleared = _clear_stale_stream_state(s)
|
||||
if not cleared and getattr(s, "active_stream_id", None):
|
||||
diag.stage("response_write") if diag else None
|
||||
return {
|
||||
"error": "session already has an active stream",
|
||||
"active_stream_id": getattr(s, "active_stream_id", None),
|
||||
"_status": 409,
|
||||
}
|
||||
if was_hidden_empty_session:
|
||||
publish_session_list_changed("session_new", profile=getattr(s, "profile", None))
|
||||
diag.stage("turn_journal_submitted") if diag else None
|
||||
|
||||
@@ -199,12 +199,15 @@ def read_run_events(
|
||||
run_id: str,
|
||||
*,
|
||||
after_seq: int | None = None,
|
||||
max_seq: int | None = None,
|
||||
session_dir: Path | None = None,
|
||||
) -> dict:
|
||||
path = _run_path(session_id, run_id, session_dir=session_dir)
|
||||
events, malformed = _read_jsonl(path)
|
||||
if after_seq is not None:
|
||||
events = [event for event in events if int(event.get("seq") or 0) > int(after_seq)]
|
||||
if max_seq is not None:
|
||||
events = [event for event in events if int(event.get("seq") or 0) <= int(max_seq)]
|
||||
return {
|
||||
"session_id": str(session_id),
|
||||
"run_id": str(run_id),
|
||||
|
||||
+62
-13
@@ -341,8 +341,12 @@ _CANCEL_MARKER_PATTERNS = ('task cancelled', 'task canceled', 'response interrup
|
||||
|
||||
_WEBUI_PROGRESS_PROMPT = """
|
||||
WebUI progress guidance:
|
||||
- Match the normal Hermes messaging style; do not add extra status updates solely because this is a browser session.
|
||||
- For long multi-step work that uses tools, you may provide brief user-visible progress updates before continuing with tool calls.
|
||||
- Match the normal Hermes messaging style, but do not let long tool-running WebUI turns appear silent.
|
||||
- For long multi-step work that uses tools, emit brief user-visible progress updates as normal assistant content, not only as hidden reasoning.
|
||||
- Before the first tool batch in a long task, say what you are about to inspect.
|
||||
- After each meaningful batch of tool calls, say what you just confirmed and what you will check next before continuing with more tools.
|
||||
- Do not run many independent tool batches back-to-back without visible assistant text between them when the task is still ongoing.
|
||||
- Do not keep progress only in reasoning, thinking, or tool-result channels; those are not a substitute for visible interim updates.
|
||||
- Each update should say what you are about to check, what you just confirmed, or why the next tool call is needed.
|
||||
- Keep updates concise, factual, and in the user's language. One or two short sentences are enough.
|
||||
- Do not reveal hidden reasoning, chain-of-thought, private scratchpads, secrets, raw logs, or long tool output.
|
||||
@@ -2983,6 +2987,37 @@ def _deduplicate_context_messages(messages):
|
||||
return deduped
|
||||
|
||||
|
||||
def _prune_context_tool_results_after_compression(agent, context_messages):
|
||||
"""Run the active compressor's cheap tool-result pruning on model context.
|
||||
|
||||
Auto-compression can happen mid-turn and then the agent may run more tools
|
||||
before producing the final answer. Those completed tail tool results are
|
||||
model-facing context, but they were produced after the compression pass and
|
||||
therefore did not go through the compressor's tool-output pruning. Apply the
|
||||
same cheap pruning once more after a confirmed compression event. This keeps
|
||||
the visible transcript untouched while preventing the next turn from seeing
|
||||
raw post-compression tool dumps.
|
||||
"""
|
||||
if not context_messages:
|
||||
return context_messages
|
||||
compressor = getattr(agent, 'context_compressor', None)
|
||||
prune = getattr(compressor, '_prune_old_tool_results', None)
|
||||
if not callable(prune):
|
||||
return context_messages
|
||||
try:
|
||||
pruned_messages, pruned_count = prune(
|
||||
copy.deepcopy(context_messages),
|
||||
protect_tail_count=getattr(compressor, 'protect_last_n', 20),
|
||||
protect_tail_tokens=getattr(compressor, 'tail_token_budget', None),
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("post-compression context tool-result pruning failed", exc_info=True)
|
||||
return context_messages
|
||||
if not pruned_count:
|
||||
return context_messages
|
||||
return _deduplicate_context_messages(pruned_messages)
|
||||
|
||||
|
||||
def _restore_reasoning_metadata(previous_messages, updated_messages):
|
||||
"""Carry forward display-only metadata lost during API-safe history sanitization.
|
||||
|
||||
@@ -4666,24 +4701,28 @@ def _run_agent_streaming(
|
||||
# If cancelled, drop all further events except the cancel event itself
|
||||
if cancel_event.is_set() and event not in ('cancel', 'error'):
|
||||
return
|
||||
event_id = None
|
||||
if run_journal is not None:
|
||||
try:
|
||||
journaled = run_journal.append_sse_event(event, data)
|
||||
# Stage-364: propagate journal event_id via a side-channel dict
|
||||
# (STREAM_LAST_EVENT_ID) instead of changing the queue tuple
|
||||
# shape — keeping the 2-tuple shape preserves backward
|
||||
# compatibility for tests and any non-SSE queue consumer. The
|
||||
# SSE handler reads this dict at emit time to populate `id:`
|
||||
# on every live frame, which lets the frontend's cursor
|
||||
# advance during live streaming and prevents replay from
|
||||
# double-rendering tokens after a mid-stream error→reconnect.
|
||||
# Carry the exact journal id for this queued frame. A global
|
||||
# "latest event" side channel is still kept for legacy queues,
|
||||
# but StreamChannel subscribers need the per-item id so a
|
||||
# queued backlog cannot advance the browser cursor past an
|
||||
# undelivered event.
|
||||
event_id = (journaled or {}).get('event_id') if isinstance(journaled, dict) else None
|
||||
if event_id:
|
||||
STREAM_LAST_EVENT_ID[stream_id] = event_id
|
||||
except Exception:
|
||||
logger.debug("Failed to append run journal event %s for stream %s", event, stream_id, exc_info=True)
|
||||
if event_id and hasattr(q, "note_last_event_id"):
|
||||
try:
|
||||
q.put_nowait((event, data))
|
||||
q.note_last_event_id(event_id)
|
||||
except Exception:
|
||||
logger.debug("Failed to note event_id %s for stream %s", event_id, stream_id, exc_info=True)
|
||||
try:
|
||||
queue_item = (event, data, event_id) if event_id and hasattr(q, "subscribe_with_snapshot") else (event, data)
|
||||
q.put_nowait(queue_item)
|
||||
except Exception:
|
||||
logger.debug("Failed to put event to queue")
|
||||
|
||||
@@ -4711,7 +4750,7 @@ def _run_agent_streaming(
|
||||
if _is_compression_start:
|
||||
put('compressing', {
|
||||
'session_id': session_id,
|
||||
'message': 'Auto-compressing context to continue...',
|
||||
'message': 'Compressing context',
|
||||
})
|
||||
return
|
||||
# Pass through rate-limit and fallback messages so the frontend can
|
||||
@@ -6384,6 +6423,10 @@ def _run_agent_streaming(
|
||||
_compressed = True
|
||||
# Notify the frontend that compression happened
|
||||
if _compressed:
|
||||
s.context_messages = _prune_context_tool_results_after_compression(
|
||||
agent,
|
||||
s.context_messages,
|
||||
)
|
||||
visible_after = visible_messages_for_anchor(s.messages, auto_compression=True)
|
||||
# Find the LAST [CONTEXT COMPACTION] marker in s.messages
|
||||
# and count visible messages before it. This is the correct
|
||||
@@ -6447,7 +6490,7 @@ def _run_agent_streaming(
|
||||
'old_session_id': _compression_origin_session_id,
|
||||
'new_session_id': _compression_continuation_session_id,
|
||||
'continuation_session_id': _compression_continuation_session_id,
|
||||
'message': 'Context auto-compressed to continue the conversation',
|
||||
'message': 'Compression finished',
|
||||
'usage': _live_usage_snapshot(),
|
||||
})
|
||||
|
||||
@@ -7869,6 +7912,12 @@ def cancel_stream(stream_id: str) -> bool:
|
||||
logger.debug("Failed to clear session state on cancel for %s", _cancel_session_id)
|
||||
|
||||
if _emit_cancel_event and q:
|
||||
_cancel_event_id = STREAM_LAST_EVENT_ID.get(stream_id)
|
||||
if _cancel_event_id and hasattr(q, "note_last_event_id"):
|
||||
try:
|
||||
q.note_last_event_id(_cancel_event_id)
|
||||
except Exception:
|
||||
logger.debug("Failed to note cancel event_id %s for stream %s", _cancel_event_id, stream_id, exc_info=True)
|
||||
try:
|
||||
q.put_nowait(('cancel', {'message': 'Cancelled by user'}))
|
||||
except Exception:
|
||||
|
||||
@@ -79,6 +79,15 @@ not raw debug detail. Compact Activity may collapse tool arguments, long tool
|
||||
results, and low-level reasoning detail, but it must not make concise
|
||||
user-visible progress text available only inside a collapsed disclosure.
|
||||
|
||||
Automatic compression is a live-only context barrier, not a special branded
|
||||
tool card. Render it as a centered, non-interactive divider with quiet horizontal
|
||||
rules: `Compressing context` while the compression barrier is active and
|
||||
`Context auto-compressed` when the agent has continued or the compression
|
||||
completion event arrives. Do not give it a caret, click target, leading status
|
||||
dot, or standalone running badge. In settled final history, remove live-only
|
||||
automatic compression rows unless they explain a visible recovery or error
|
||||
state.
|
||||
|
||||
The existing two-stage proposal in `docs/ui-ux/two-stage-proposal.html` records a
|
||||
compatible direction for long turns: live work can be grouped as a worklog, then
|
||||
settled history can collapse while the final answer reads as the calm
|
||||
|
||||
@@ -89,6 +89,16 @@ while WebUI still has multiple overlapping state stores.
|
||||
reference cards are recovery/handoff material. They must not be treated as a
|
||||
new user request, active-turn content, or the default visible explanation for
|
||||
the current answer.
|
||||
Automatic compression may appear during a live turn only as a quiet,
|
||||
non-interactive context divider in the Worklog timeline, not as a clickable
|
||||
tool row. It should use action wording: `Compressing context` while active
|
||||
and `Context auto-compressed` when the agent has continued past the
|
||||
compression barrier or when a completion event arrives. The timer is
|
||||
diagnostic detail, not the source of truth for the divider's running state.
|
||||
Later tool, reasoning, or interim assistant events prove the compression
|
||||
barrier has passed even if no explicit completion event was delivered.
|
||||
Settled final history should omit live-only automatic-compression rows unless
|
||||
there is a user-visible recovery or error state to explain.
|
||||
7. **Observation has a degraded path.** Long-running or many-session observation
|
||||
should expose enough heartbeat/degraded status that the UI does not appear
|
||||
silent and ordinary APIs do not stall behind active streams.
|
||||
|
||||
@@ -419,6 +419,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages-inner" id="msgInner"></div>
|
||||
<div id="liveRunStatus" hidden></div>
|
||||
<div id="liveCompressionCards" class="live-compression-cards"></div>
|
||||
<div id="liveToolCards" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;"></div>
|
||||
</div>
|
||||
|
||||
+566
-128
@@ -693,6 +693,12 @@ async function send(){
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
S.session.active_stream_id = streamId;
|
||||
}
|
||||
if(S.session&&S.session.session_id===activeSid&&typeof showLiveRunStatus==='function'){
|
||||
const _startedAt=typeof startData.pending_started_at==='number'
|
||||
? startData.pending_started_at
|
||||
: (S.session.pending_started_at||Date.now()/1000);
|
||||
showLiveRunStatus(activeSid,{startedAt:_startedAt});
|
||||
}
|
||||
if(typeof upsertActiveSessionForLocalTurn==='function'){
|
||||
// Third optimistic pass: stream_id is now known, so the row can reconcile
|
||||
// against real active-stream metadata before the background refresh lands.
|
||||
@@ -796,6 +802,10 @@ function closeLiveStream(sessionId, streamId, source){
|
||||
// thinking/tool content (only the elapsed clock survives). Capturing here
|
||||
// guarantees switch-back restores the exact state shown at switch-away. (#3668)
|
||||
if(typeof snapshotLiveTurnHtmlForSession==='function') snapshotLiveTurnHtmlForSession(sessionId);
|
||||
// Stop the live footer timer/status for the pane that is being detached; the
|
||||
// reattach path will rebuild it from INFLIGHT/server state if the user returns.
|
||||
if(typeof _clearLiveRunStatusTimer==='function') _clearLiveRunStatusTimer(sessionId);
|
||||
if(typeof hideLiveRunStatus==='function') hideLiveRunStatus(sessionId);
|
||||
try{live.source.close();}catch(_){ }
|
||||
delete LIVE_STREAMS[sessionId];
|
||||
// closeLiveStream() is called during session-switch teardown for any session
|
||||
@@ -807,7 +817,28 @@ function closeLiveStream(sessionId, streamId, source){
|
||||
// finishes and a metadata refresh swaps in the final reply.
|
||||
// If the stream is terminating cleanly, _clearOwnerInflightState() has
|
||||
// already deleted INFLIGHT[sessionId], so this is a safe no-op.
|
||||
if(INFLIGHT[sessionId]) INFLIGHT[sessionId].reattach=true;
|
||||
if(INFLIGHT[sessionId]){
|
||||
INFLIGHT[sessionId].reattach=true;
|
||||
// The browser-side INFLIGHT snapshot is only a compact tail cache. After a
|
||||
// session switch it cannot be treated as the full live turn; rebuild from
|
||||
// the durable run journal instead so earlier prose/tool rows are not lost.
|
||||
INFLIGHT[sessionId].journalReplayFromStart=true;
|
||||
if(typeof saveInflightState==='function'){
|
||||
saveInflightState(sessionId,{
|
||||
streamId:live.streamId||streamId||null,
|
||||
messages:INFLIGHT[sessionId].messages||[],
|
||||
uploaded:INFLIGHT[sessionId].uploaded||[],
|
||||
toolCalls:INFLIGHT[sessionId].toolCalls||[],
|
||||
lastAssistantText:INFLIGHT[sessionId].lastAssistantText||'',
|
||||
lastReasoningText:INFLIGHT[sessionId].lastReasoningText||'',
|
||||
lastRunJournalSeq:INFLIGHT[sessionId].lastRunJournalSeq||0,
|
||||
journalReplayFromStart:true,
|
||||
currentActivityBurstId:INFLIGHT[sessionId].currentActivityBurstId||0,
|
||||
currentLiveSegmentSeq:INFLIGHT[sessionId].currentLiveSegmentSeq||0,
|
||||
activityBurstAnchors:Array.isArray(INFLIGHT[sessionId].activityBurstAnchors)?INFLIGHT[sessionId].activityBurstAnchors:[],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeOtherLiveStreams(activeSid){
|
||||
@@ -828,37 +859,98 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(uploaded.length) INFLIGHT[activeSid].uploaded=[...uploaded];
|
||||
if(!Array.isArray(INFLIGHT[activeSid].toolCalls)) INFLIGHT[activeSid].toolCalls=[];
|
||||
}
|
||||
if(!Array.isArray(INFLIGHT[activeSid].activityBurstAnchors)) INFLIGHT[activeSid].activityBurstAnchors=[];
|
||||
if(INFLIGHT[activeSid].currentActivityBurstId===undefined) INFLIGHT[activeSid].currentActivityBurstId=0;
|
||||
if(INFLIGHT[activeSid].currentLiveSegmentSeq===undefined) INFLIGHT[activeSid].currentLiveSegmentSeq=0;
|
||||
let assistantText='';
|
||||
let reasoningText='';
|
||||
if(S.session&&S.session.session_id===activeSid&&S.activeStreamId===streamId&&typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell();
|
||||
const existingLive=LIVE_STREAMS[activeSid];
|
||||
if(
|
||||
existingLive&&existingLive.streamId===streamId&&existingLive.source&&
|
||||
// A same-stream transport can be reused unless the browser has already
|
||||
// marked it closed; closed streams must still fall through to reopen.
|
||||
(typeof EventSource==='undefined'||existingLive.source.readyState!==EventSource.CLOSED)
|
||||
// During explicit reconnects, only reuse a proven-open transport. A stale
|
||||
// CONNECTING EventSource can survive in page state while the server has no
|
||||
// subscriber, which leaves the live pane blank forever.
|
||||
(typeof EventSource==='undefined'||
|
||||
existingLive.source.readyState===EventSource.OPEN||
|
||||
(!reconnecting&&existingLive.source.readyState===EventSource.CONNECTING))
|
||||
){
|
||||
// Phase D: restore bottom run status on reattach after the Worklog shell
|
||||
// exists. There is no stale transport teardown in this branch.
|
||||
if(reconnecting && S.activeStreamId && typeof showLiveRunStatus==='function'){
|
||||
const _startedAt=(S.session&&S.session.pending_started_at)||Date.now()/1000;
|
||||
showLiveRunStatus(activeSid,{startedAt:_startedAt});
|
||||
}
|
||||
return;
|
||||
}
|
||||
closeOtherLiveStreams(activeSid);
|
||||
closeLiveStream(activeSid);
|
||||
if(!reconnecting&&typeof resetTurnWorkspaceMutations==='function') resetTurnWorkspaceMutations();
|
||||
if(!reconnecting&&typeof _resetStreamScrollFollow==='function') _resetStreamScrollFollow();
|
||||
// Phase D: restore bottom run status after closeLiveStream(); that helper
|
||||
// hides the status while tearing down stale EventSource ownership.
|
||||
if(reconnecting && S.activeStreamId && typeof showLiveRunStatus==='function'){
|
||||
const _startedAt=(S.session&&S.session.pending_started_at)||Date.now()/1000;
|
||||
showLiveRunStatus(activeSid,{startedAt:_startedAt});
|
||||
}
|
||||
|
||||
// On reconnect, restore accumulated text from INFLIGHT so we don't lose
|
||||
// progress made before the session switch. Without this the closure starts
|
||||
// empty and tokens arriving on the new SSE connection append to nothing —
|
||||
// the already-rendered content vanishes.
|
||||
const _lastLiveAssistant = reconnecting
|
||||
? INFLIGHT[activeSid]?.messages?.findLast?.(m => m.role === 'assistant' && m._live)
|
||||
const _liveInflightAssistantMessages = reconnecting
|
||||
? ((INFLIGHT[activeSid]&&Array.isArray(INFLIGHT[activeSid].messages))
|
||||
? INFLIGHT[activeSid].messages.filter(m=>m&&m.role==='assistant'&&m._live)
|
||||
: [])
|
||||
: [];
|
||||
const _liveInflightAssistant = _liveInflightAssistantMessages.length===1
|
||||
? _liveInflightAssistantMessages[0]
|
||||
: null;
|
||||
let assistantText = _lastLiveAssistant ? (_lastLiveAssistant.content || '') : '';
|
||||
let reasoningText = _lastLiveAssistant ? (_lastLiveAssistant.reasoning || '') : '';
|
||||
const _fullInflightAssistant = (INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastAssistantText) || '';
|
||||
const _joinedInflightSegments = _liveInflightAssistantMessages.length>1
|
||||
? _liveInflightAssistantMessages.map(m=>m&&m.content?String(m.content).trim():'').filter(Boolean).join('\n\n')
|
||||
: '';
|
||||
const _lastLiveAssistant = reconnecting
|
||||
? (_liveInflightAssistantMessages.length>1
|
||||
? (_fullInflightAssistant || _joinedInflightSegments)
|
||||
: (_liveInflightAssistant
|
||||
? (_liveInflightAssistant.content || '')
|
||||
: _fullInflightAssistant))
|
||||
: '';
|
||||
const _lastLiveReasoning = reconnecting
|
||||
? (_liveInflightAssistant&&_liveInflightAssistant.reasoning)
|
||||
|| (INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastReasoningText)
|
||||
|| ''
|
||||
: '';
|
||||
assistantText = _lastLiveAssistant ? _lastLiveAssistant : '';
|
||||
reasoningText=_lastLiveReasoning ? _lastLiveReasoning : '';
|
||||
let liveReasoningText = reasoningText;
|
||||
let visibleInterimSnippets=[];
|
||||
let _latestGoalStatus=null;
|
||||
let _pendingGoalContinuation=null;
|
||||
let assistantRow=null;
|
||||
let assistantBody=null;
|
||||
let segmentStart=0; // char offset in assistantText where current segment begins
|
||||
let _freshSegment=false; // true after a tool call — forces a new DOM segment
|
||||
// On reconnect with recorded burst anchors, the rendered DOM has multiple
|
||||
// live assistant segments — one per anchor plus a tail. New tokens belong to
|
||||
// the TAIL segment only.
|
||||
let segmentStart=(()=>{
|
||||
if(!reconnecting) return 0;
|
||||
const inflight=INFLIGHT[activeSid];
|
||||
if(!inflight) return 0;
|
||||
const anchors=Array.isArray(inflight.activityBurstAnchors)?inflight.activityBurstAnchors:[];
|
||||
const textLen=String(assistantText||'').length;
|
||||
let lastEnd=0;
|
||||
for(const a of anchors){
|
||||
const end=Number(a&&a.textEnd);
|
||||
if(Number.isFinite(end)&&end>lastEnd&&end<=textLen) lastEnd=end;
|
||||
}
|
||||
return lastEnd;
|
||||
})();
|
||||
// If reconnect resumes exactly at the last recorded boundary, there is no
|
||||
// projected tail segment yet. The next token must create a fresh segment
|
||||
// after the last Activity group instead of rewriting the previous burst's
|
||||
// text segment.
|
||||
let _freshSegment=reconnecting&&segmentStart>0&&segmentStart>=String(assistantText||'').length;
|
||||
// streaming-markdown state: incremental DOM-building parser per segment
|
||||
let _smdParser=null; // current smd parser instance (null until first content)
|
||||
let _smdWrittenLen=0; // how many chars of displayText have been fed to smd parser
|
||||
@@ -966,6 +1058,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
messages:inflight.messages||[],
|
||||
uploaded:inflight.uploaded||[...uploaded],
|
||||
toolCalls:inflight.toolCalls||[],
|
||||
lastAssistantText:inflight.lastAssistantText||'',
|
||||
lastReasoningText:inflight.lastReasoningText||'',
|
||||
lastRunJournalSeq:inflight.lastRunJournalSeq||0,
|
||||
journalReplayFromStart:!!inflight.journalReplayFromStart,
|
||||
currentActivityBurstId:inflight.currentActivityBurstId||0,
|
||||
currentLiveSegmentSeq:inflight.currentLiveSegmentSeq||0,
|
||||
activityBurstAnchors:Array.isArray(inflight.activityBurstAnchors)?inflight.activityBurstAnchors:[],
|
||||
todos:Array.isArray(inflight.todos)?inflight.todos:S.todos,
|
||||
todoStateMeta:inflight.todoStateMeta||S.todoStateMeta||null,
|
||||
});
|
||||
@@ -998,8 +1097,22 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
return out.trim();
|
||||
}
|
||||
function _liveThinkingText(){
|
||||
const clean=_stripLiveVisibleAssistantEchoFromThinking(liveReasoningText, visibleInterimSnippets);
|
||||
return clean || 'Thinking…';
|
||||
return String(liveReasoningText||'').trim() || 'Thinking…';
|
||||
}
|
||||
function _liveThinkingPlacement(){
|
||||
const activeSeq=Number(_assistantSegmentSeq||0);
|
||||
const nextSeq=Number(_currentLiveSegmentSeq||0)+1;
|
||||
const segmentSeq=(!assistantRow||_freshSegment||!activeSeq)?nextSeq:activeSeq;
|
||||
return {
|
||||
activityKey:S.activeStreamId?'live:'+S.activeStreamId:null,
|
||||
segmentSeq,
|
||||
burstId:_currentActivityBurstId,
|
||||
};
|
||||
}
|
||||
function _updateLiveThinkingCard(text){
|
||||
const opts=_liveThinkingPlacement();
|
||||
if(typeof updateThinking==='function') updateThinking(text, opts);
|
||||
else appendThinking(text, opts);
|
||||
}
|
||||
// Split a content string into {reasoning, content} by extracting any <think>...
|
||||
// blocks (or other known reasoning-tag pairs). If reasoning is already
|
||||
@@ -1038,6 +1151,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
function syncInflightAssistantMessage(){
|
||||
const inflight=INFLIGHT[activeSid];
|
||||
if(!inflight) return;
|
||||
inflight.lastAssistantText=assistantText;
|
||||
inflight.lastReasoningText=reasoningText;
|
||||
if(!Array.isArray(inflight.messages)) inflight.messages=[];
|
||||
let assistantIdx=-1;
|
||||
for(let i=inflight.messages.length-1;i>=0;i--){
|
||||
@@ -1058,6 +1173,33 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
inflight.messages.push({role:'assistant',content:split.content,reasoning:split.reasoning||undefined,_live:true,_ts:ts});
|
||||
_throttledPersist();
|
||||
}
|
||||
function recordActivityBoundary(){
|
||||
const inflight=INFLIGHT[activeSid];
|
||||
if(!inflight) return;
|
||||
if(!Array.isArray(inflight.activityBurstAnchors)) inflight.activityBurstAnchors=[];
|
||||
if(!assistantRow||!assistantRow.isConnected){
|
||||
assistantRow=null;
|
||||
assistantBody=null;
|
||||
}
|
||||
const textEnd=String(assistantText||'').length;
|
||||
const lastTextEnd=inflight.activityBurstAnchors.reduce((max,a)=>{
|
||||
const n=Number(a&&a.textEnd);
|
||||
return Number.isFinite(n)?Math.max(max,n):max;
|
||||
},0);
|
||||
if(textEnd<=lastTextEnd){
|
||||
inflight.currentActivityBurstId=_currentActivityBurstId;
|
||||
if(assistantRow) assistantRow.setAttribute('data-activity-burst-id',String(_currentActivityBurstId));
|
||||
persistInflightState();
|
||||
return;
|
||||
}
|
||||
_currentActivityBurstId+=1;
|
||||
inflight.currentActivityBurstId=_currentActivityBurstId;
|
||||
const existing=inflight.activityBurstAnchors.find(a=>Number(a&&a.id)===_currentActivityBurstId);
|
||||
if(existing) existing.textEnd=textEnd;
|
||||
else inflight.activityBurstAnchors.push({id:_currentActivityBurstId,textEnd});
|
||||
if(assistantRow) assistantRow.setAttribute('data-activity-burst-id',String(_currentActivityBurstId));
|
||||
persistInflightState();
|
||||
}
|
||||
function ensureAssistantRow(force=false){
|
||||
if(!_isActiveSession()) return;
|
||||
if(assistantRow&&!assistantRow.isConnected){assistantRow=null;assistantBody=null;}
|
||||
@@ -1073,19 +1215,25 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const blocks=(typeof _assistantTurnBlocks==='function')?_assistantTurnBlocks(turn):null;
|
||||
if(!blocks) return;
|
||||
if(!assistantRow){
|
||||
// Only reuse an existing segment on the very first creation (e.g. reconnect).
|
||||
// After a tool call _freshSegment=true, so we always create a new segment
|
||||
// below the tool card rather than re-attaching to the old one above it.
|
||||
if(!_freshSegment){
|
||||
const existing=blocks.querySelector('[data-live-assistant="1"]');
|
||||
const liveSegments=blocks.querySelectorAll('[data-live-assistant="1"]');
|
||||
const existing=liveSegments.length?liveSegments[liveSegments.length-1]:null;
|
||||
if(existing){
|
||||
assistantRow=existing;
|
||||
assistantBody=existing.querySelector('.msg-body');
|
||||
const existingSeq=Number(existing.getAttribute('data-live-segment-seq')||'');
|
||||
if(Number.isFinite(existingSeq)&&existingSeq>0){
|
||||
_assistantSegmentSeq=existingSeq;
|
||||
if(_assistantSegmentSeq>_currentLiveSegmentSeq) _currentLiveSegmentSeq=_assistantSegmentSeq;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if(assistantRow){
|
||||
if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost();
|
||||
if(typeof _moveLiveRunStatusToTurnEnd==='function') _moveLiveRunStatusToTurnEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1093,10 +1241,18 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
$('emptyState').style.display='none';
|
||||
assistantRow=document.createElement('div');
|
||||
assistantRow.className='assistant-segment';
|
||||
_currentLiveSegmentSeq+=1;
|
||||
_assistantSegmentSeq=_currentLiveSegmentSeq;
|
||||
assistantRow.setAttribute('data-live-assistant','1');
|
||||
assistantRow.setAttribute('data-activity-burst-id',String(_currentActivityBurstId));
|
||||
assistantRow.setAttribute('data-live-segment-seq',String(_assistantSegmentSeq));
|
||||
assistantBody=document.createElement('div');assistantBody.className='msg-body';
|
||||
assistantRow.appendChild(assistantBody);
|
||||
blocks.appendChild(assistantRow);
|
||||
if(typeof _moveLiveRunStatusToTurnEnd==='function') _moveLiveRunStatusToTurnEnd();
|
||||
if(INFLIGHT[activeSid]){
|
||||
INFLIGHT[activeSid].currentLiveSegmentSeq=_currentLiveSegmentSeq;
|
||||
}
|
||||
_freshSegment=false; // consumed — next reuse check is normal again
|
||||
}
|
||||
|
||||
@@ -1119,7 +1275,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`);
|
||||
if(st.active){
|
||||
setComposerStatus('Reconnected');
|
||||
_wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{withCredentials:true}));
|
||||
_wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}${_runJournalReplayParams()}`,document.baseURI||location.href).href,{withCredentials:true}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1175,7 +1331,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
let _streamFadeReduceMotionMql=null;
|
||||
let _streamFadeReduceMotion=false;
|
||||
let _streamFadeReduceMotionOnChange=null;
|
||||
let _lastRunJournalSeq=0;
|
||||
let _currentActivityBurstId=Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].currentActivityBurstId)||0)||0;
|
||||
let _currentLiveSegmentSeq=Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].currentLiveSegmentSeq)||0)||0;
|
||||
let _assistantSegmentSeq=Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].currentLiveSegmentSeq)||0)||0;
|
||||
let _lastRunJournalSeq=reconnecting
|
||||
? Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastRunJournalSeq)||0)
|
||||
: 0;
|
||||
let _lastRunJournalEventId='';
|
||||
const _STREAM_FADE_MS=200;
|
||||
const _STREAM_FADE_MAX_MS=350;
|
||||
@@ -1184,6 +1345,35 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const _STREAM_FADE_DONE_DRAIN_MAX_MS=900;
|
||||
const _streamFadeEnabledForStream=window._fadeTextEffect===true;
|
||||
|
||||
function _mergeSettledToolCallsWithLiveMetadata(rawCalls){
|
||||
const liveCalls=Array.isArray(S.toolCalls)?S.toolCalls:[];
|
||||
const byTid=new Map();
|
||||
liveCalls.forEach((tc,idx)=>{
|
||||
if(!tc||typeof tc!=='object') return;
|
||||
const tid=tc.tid||tc.id||tc.tool_call_id||tc.call_id||'';
|
||||
if(tid&&!byTid.has(tid)) byTid.set(tid,{tc,idx});
|
||||
});
|
||||
const used=new Set();
|
||||
return (rawCalls||[]).map((raw,idx)=>{
|
||||
const next={...(raw||{}),done:true};
|
||||
const tid=next.tid||next.id||next.tool_call_id||next.call_id||'';
|
||||
let matchEntry=tid?byTid.get(tid):null;
|
||||
if(!matchEntry){
|
||||
const name=next.name||((next.function||{}).name)||'';
|
||||
const matchIdx=liveCalls.findIndex((tc,i)=>tc&&!used.has(i)&&(!name||tc.name===name));
|
||||
if(matchIdx>=0) matchEntry={tc:liveCalls[matchIdx],idx:matchIdx};
|
||||
}
|
||||
if(matchEntry){
|
||||
used.add(matchEntry.idx);
|
||||
const live=matchEntry.tc||{};
|
||||
for(const key of ['activityBurstId','duration','started_at']){
|
||||
if((next[key]===undefined||next[key]===null)&&live[key]!==undefined&&live[key]!==null) next[key]=live[key];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// rAF-throttled rendering: buffer tokens, render at most once per frame
|
||||
let _renderPending=false;
|
||||
// Extract display text from assistantText, stripping completed thinking blocks
|
||||
@@ -1262,8 +1452,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(window._showThinking===false){removeThinking();return;}
|
||||
const text=(parsed&&parsed.thinkingText)||'';
|
||||
if(text||(parsed&&parsed.inThinking)){
|
||||
if(typeof updateThinking==='function') updateThinking(text||'Thinking…');
|
||||
else appendThinking();
|
||||
_updateLiveThinkingCard(text||'Thinking…');
|
||||
return;
|
||||
}
|
||||
// Only remove thinking if we're not in an active reasoning phase.
|
||||
@@ -1693,6 +1882,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
} else {
|
||||
assistantBody.innerHTML=esc(displayText);
|
||||
}
|
||||
if(typeof _syncLiveWorklogReasonsForAnchor==='function') _syncLiveWorklogReasonsForAnchor(assistantRow, displayText);
|
||||
}
|
||||
function _resetAssistantSegment(){
|
||||
assistantRow=null;
|
||||
@@ -1710,6 +1900,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(Number.isFinite(seq)&&seq>_lastRunJournalSeq){
|
||||
_lastRunJournalSeq=seq;
|
||||
_lastRunJournalEventId=raw;
|
||||
// Mirror the advanced cursor onto the persisted INFLIGHT entry. persistInflightState()
|
||||
// saves `inflight.lastRunJournalSeq`, and a hard reload / reattach reads it back as the
|
||||
// `after_seq` replay floor (see attachLiveStream reconnecting init). Without this write
|
||||
// the persisted seq stayed 0, so a reload restored `lastAssistantText` and then replayed
|
||||
// the run journal from the zero floor (after_seq of 0) ON TOP of it — duplicating
|
||||
// already-rendered live reply content. Throttled persist keeps this off the hot token path. (#3401 reconnect dup)
|
||||
const inflight=INFLIGHT[activeSid];
|
||||
if(inflight){
|
||||
inflight.lastRunJournalSeq=seq;
|
||||
if(typeof _throttledPersist==='function') _throttledPersist();
|
||||
}
|
||||
}
|
||||
}
|
||||
function _runJournalReplayAfterSeq(){
|
||||
@@ -1725,6 +1926,246 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
return `&replay=1&after_seq=${encodeURIComponent(String(_runJournalReplayAfterSeq()))}&after_event_id=${encodeURIComponent(_lastRunJournalEventId||'')}`;
|
||||
}
|
||||
|
||||
function _stableStringify(value){
|
||||
const normalize=(v)=>{
|
||||
if(v===null||typeof v!=='object') return v;
|
||||
if(Array.isArray(v)) return v.map(normalize);
|
||||
const obj={};
|
||||
const keys=Object.keys(v).sort();
|
||||
for(const key of keys){
|
||||
obj[key]=normalize(v[key]);
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
try{
|
||||
return JSON.stringify(normalize(value));
|
||||
}catch(_){
|
||||
return String(value||'');
|
||||
}
|
||||
}
|
||||
|
||||
function _hashString(value){
|
||||
let hash=2166136261;
|
||||
for(let i=0;i<String(value||'').length;i++){
|
||||
hash^=String(value||'').charCodeAt(i);
|
||||
hash=Math.imul(hash,16777619);
|
||||
}
|
||||
return (hash>>>0).toString(16);
|
||||
}
|
||||
|
||||
function _toolCallSignature(d, activityBurstId, activitySegmentSeq){
|
||||
const name=String(d&&d.name||'').trim().toLowerCase();
|
||||
const bid=Number(activityBurstId);
|
||||
const seq=Number(activitySegmentSeq);
|
||||
const args=d&&d.args;
|
||||
return `${name}|${Number.isFinite(bid)?bid:0}|${Number.isFinite(seq)?seq:0}|${_stableStringify(args)}`;
|
||||
}
|
||||
|
||||
function _liveToolTid(d, activityBurstId, activitySegmentSeq){
|
||||
const explicit=String(d&&d.tid||'').trim();
|
||||
if(explicit) return explicit;
|
||||
return `live-${activeSid}-${_hashString(_toolCallSignature(d,activityBurstId,activitySegmentSeq))}`;
|
||||
}
|
||||
|
||||
function _coerceLiveToolCallSignature(tc, activityBurstId, activitySegmentSeq){
|
||||
if(tc&&typeof tc==='object' && !tc._liveToolCallSignature){
|
||||
tc._liveToolCallSignature=_toolCallSignature(tc,activityBurstId,activitySegmentSeq);
|
||||
}
|
||||
return tc&&tc._liveToolCallSignature||'';
|
||||
}
|
||||
|
||||
function _findPendingLiveToolCallIndex(toolCalls, opts){
|
||||
if(!Array.isArray(toolCalls)) return -1;
|
||||
const wantedTid=opts&&opts.tid||'';
|
||||
const wantedName=String(opts&&opts.name||'');
|
||||
const wantedSig=opts&&opts.signature||'';
|
||||
const wantedBurst=Number(opts&&opts.activityBurstId);
|
||||
const wantedSeq=Number(opts&&opts.activitySegmentSeq);
|
||||
const allowDone=!!(opts&&opts.allowDone);
|
||||
const matchName=(candidate)=>{
|
||||
return !candidate||!candidate.name||!wantedName ? false : String(candidate.name)===wantedName;
|
||||
};
|
||||
if(wantedTid){
|
||||
for(let i=toolCalls.length-1;i>=0;i--){
|
||||
const candidate=toolCalls[i];
|
||||
if(!candidate||typeof candidate!=='object') continue;
|
||||
if(!allowDone&&candidate.done===true) continue;
|
||||
const candidateTid=String(candidate.tid||candidate.id||candidate.tool_call_id||candidate.call_id||'');
|
||||
if(candidateTid&&candidateTid===wantedTid) return i;
|
||||
}
|
||||
}
|
||||
if(wantedSig){
|
||||
for(let i=toolCalls.length-1;i>=0;i--){
|
||||
const candidate=toolCalls[i];
|
||||
if(!candidate||typeof candidate!=='object') continue;
|
||||
if(!allowDone&&candidate.done===true) continue;
|
||||
const canonicalSig=_coerceLiveToolCallSignature(
|
||||
candidate,
|
||||
Number.isFinite(wantedBurst)?wantedBurst:activityBurstFallbackFromCandidate(candidate),
|
||||
Number.isFinite(wantedSeq)?wantedSeq:activitySegmentSeqFallbackFromCandidate(candidate),
|
||||
);
|
||||
if(canonicalSig&&canonicalSig===wantedSig) return i;
|
||||
}
|
||||
}
|
||||
for(let i=toolCalls.length-1;i>=0;i--){
|
||||
const candidate=toolCalls[i];
|
||||
if(!candidate||typeof candidate!=='object') continue;
|
||||
if(!allowDone&&candidate.done===true) continue;
|
||||
if(!matchName(candidate)) continue;
|
||||
const candidateSeq=Number(candidate.activitySegmentSeq);
|
||||
const candidateBid=Number(candidate.activityBurstId);
|
||||
if(Number.isFinite(wantedSeq)&&Number.isFinite(candidateSeq)&&candidateSeq!==wantedSeq) continue;
|
||||
if(Number.isFinite(wantedBurst)&&Number.isFinite(candidateBid)&&candidateBid!==wantedBurst) continue;
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function activityBurstFallbackFromCandidate(candidate){
|
||||
return Number(candidate && candidate.activityBurstId);
|
||||
}
|
||||
function activitySegmentSeqFallbackFromCandidate(candidate){
|
||||
return Number(candidate && candidate.activitySegmentSeq);
|
||||
}
|
||||
|
||||
function _coerceLiveToolCallSeq(candidate){
|
||||
const raw=Number.isFinite(candidate)?candidate:Number(candidate&&candidate.activitySegmentSeq);
|
||||
return Number.isFinite(raw)&&raw>0?raw:undefined;
|
||||
}
|
||||
|
||||
function _currentLiveToolAnchor(){
|
||||
const segmentSeq=Number(
|
||||
assistantRow&&assistantRow.getAttribute('data-live-segment-seq')||
|
||||
_assistantSegmentSeq||
|
||||
_currentLiveSegmentSeq||
|
||||
0
|
||||
);
|
||||
const burst=Number(_currentActivityBurstId);
|
||||
return {
|
||||
segmentSeq:Number.isFinite(segmentSeq)&&segmentSeq>0?segmentSeq:undefined,
|
||||
burstId:Number.isFinite(burst)?burst:0,
|
||||
};
|
||||
}
|
||||
|
||||
function upsertLiveToolCall(d, phase){
|
||||
if(!d||d.name==='clarify') return null;
|
||||
const name=String(d&&d.name||'').trim();
|
||||
if(!name) return null;
|
||||
const current=_currentLiveToolAnchor();
|
||||
const inflight=INFLIGHT[activeSid] || (INFLIGHT[activeSid]={
|
||||
messages:[...S.messages],
|
||||
uploaded:[...uploaded],
|
||||
toolCalls:[],
|
||||
});
|
||||
if(!Array.isArray(inflight.toolCalls)) inflight.toolCalls=[];
|
||||
if(!Array.isArray(inflight.messages)) inflight.messages=[...(inflight.messages||[])];
|
||||
|
||||
const explicitTid=String(d&&d.tid||'').trim();
|
||||
const isComplete=phase==='complete';
|
||||
let signature=_toolCallSignature(d,current.burstId,current.segmentSeq);
|
||||
let index=-1;
|
||||
|
||||
if(explicitTid){
|
||||
index=_findPendingLiveToolCallIndex(inflight.toolCalls,{
|
||||
tid:explicitTid,
|
||||
allowDone:isComplete,
|
||||
});
|
||||
}
|
||||
if(index<0){
|
||||
index=_findPendingLiveToolCallIndex(inflight.toolCalls,{
|
||||
signature,
|
||||
name,
|
||||
activityBurstId:current.burstId,
|
||||
activitySegmentSeq:current.segmentSeq,
|
||||
allowDone:isComplete,
|
||||
});
|
||||
}
|
||||
if(index<0 && isComplete && !explicitTid){
|
||||
index=_findPendingLiveToolCallIndex(inflight.toolCalls,{
|
||||
name,
|
||||
activityBurstId:current.burstId,
|
||||
allowDone:true,
|
||||
});
|
||||
}
|
||||
|
||||
let tc=null;
|
||||
if(index>=0&&inflight.toolCalls[index]){
|
||||
tc=inflight.toolCalls[index];
|
||||
}
|
||||
|
||||
if(!tc){
|
||||
tc={
|
||||
name,
|
||||
preview:String(d.preview||''),
|
||||
args:d.args||{},
|
||||
snippet:'',
|
||||
done:isComplete,
|
||||
tid:explicitTid||_liveToolTid(d,current.burstId,current.segmentSeq),
|
||||
activityBurstId:current.burstId,
|
||||
activitySegmentSeq:_coerceLiveToolCallSeq(current.segmentSeq),
|
||||
};
|
||||
if(!isComplete){
|
||||
tc.started_at=Date.now()/1000;
|
||||
}
|
||||
if(isComplete) tc._createdByComplete=true;
|
||||
inflight.toolCalls.push(tc);
|
||||
if(!signature){
|
||||
signature=_toolCallSignature(tc,tc.activityBurstId,tc.activitySegmentSeq);
|
||||
}
|
||||
} else {
|
||||
if(!tc.name) tc.name=name;
|
||||
if(!tc._liveToolCallSignature){
|
||||
tc._liveToolCallSignature=_toolCallSignature(tc,tc.activityBurstId,tc.activitySegmentSeq);
|
||||
}
|
||||
}
|
||||
|
||||
if(isComplete){
|
||||
if(d.preview){
|
||||
tc.snippet=tc.snippet||String(d.preview||'');
|
||||
if(!tc.preview) tc.preview=String(d.preview||'');
|
||||
}
|
||||
} else {
|
||||
tc.preview=String(d.preview||tc.preview||'');
|
||||
}
|
||||
if(d.args!==undefined) tc.args=d.args;
|
||||
if(d.snippet!==undefined) tc.snippet=d.snippet;
|
||||
tc._liveToolCallSignature = _toolCallSignature(tc,tc.activityBurstId,tc.activitySegmentSeq);
|
||||
tc.activityBurstId = Number.isFinite(Number(tc.activityBurstId))
|
||||
? Number(tc.activityBurstId)
|
||||
: current.burstId;
|
||||
|
||||
const currentSegmentSeq=_coerceLiveToolCallSeq(current.segmentSeq);
|
||||
const startSeq=_coerceLiveToolCallSeq(tc._toolCallStartSeq);
|
||||
const inferredSeq=_coerceLiveToolCallSeq(tc.activitySegmentSeq);
|
||||
if(!isComplete){
|
||||
if(inferredSeq===undefined && currentSegmentSeq!==undefined){
|
||||
tc.activitySegmentSeq=currentSegmentSeq;
|
||||
} else if(inferredSeq!==undefined){
|
||||
tc.activitySegmentSeq=inferredSeq;
|
||||
}
|
||||
tc._toolCallStartSeq=tc.activitySegmentSeq;
|
||||
} else if(startSeq!==undefined){
|
||||
tc.activitySegmentSeq=startSeq;
|
||||
} else if(inferredSeq!==undefined){
|
||||
tc.activitySegmentSeq=inferredSeq;
|
||||
}
|
||||
|
||||
if(isComplete){
|
||||
tc.done=true;
|
||||
if(typeof d.is_error==='boolean') tc.is_error=d.is_error;
|
||||
if(d.duration!==undefined) tc.duration=d.duration;
|
||||
if(tc.started_at===undefined||tc.started_at===null) tc.started_at=Date.now()/1000;
|
||||
if(!tc.tid) tc.tid=explicitTid||_liveToolTid(d,tc.activityBurstId,tc.activitySegmentSeq);
|
||||
} else {
|
||||
tc.done=false;
|
||||
tc.started_at=tc.started_at||Date.now()/1000;
|
||||
}
|
||||
|
||||
S.toolCalls=inflight.toolCalls;
|
||||
persistInflightState();
|
||||
return tc;
|
||||
}
|
||||
|
||||
let _lastRenderMs=0;
|
||||
function _scheduleRender(){
|
||||
if(_renderPending) return;
|
||||
@@ -1777,6 +2218,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
assistantBody.innerHTML = renderMd ? renderMd(fallbackText) : esc(fallbackText);
|
||||
}
|
||||
}
|
||||
if(typeof _syncLiveWorklogReasonsForAnchor==='function') _syncLiveWorklogReasonsForAnchor(assistantRow, displayText);
|
||||
}
|
||||
scrollIfPinned();
|
||||
snapshotLiveTurn();
|
||||
@@ -1789,6 +2231,22 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
}
|
||||
}
|
||||
|
||||
function _completeAutomaticCompressionOnLiveProgress(sessionId){
|
||||
const sid=String(sessionId||'');
|
||||
const hasRunningLiveCard=!!document.querySelector('[data-live-compression-card="1"][data-compression-started-at]');
|
||||
const hasRunningState=!!(window._compressionUi&&window._compressionUi.automatic&&window._compressionUi.phase==='running'&&(!sid||!window._compressionUi.sessionId||String(window._compressionUi.sessionId)===sid));
|
||||
if(!hasRunningLiveCard&&!hasRunningState) return false;
|
||||
if(typeof appendLiveCompressionCard==='function'){
|
||||
appendLiveCompressionCard({
|
||||
sessionId:sid,
|
||||
phase:'done',
|
||||
automatic:true,
|
||||
message:'Context auto-compressed',
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function _wireSSE(source){
|
||||
const existingLive=LIVE_STREAMS[activeSid];
|
||||
if(existingLive&&existingLive.source&&existingLive.source!==source){
|
||||
@@ -1817,8 +2275,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
assistantText+=d.text;
|
||||
syncInflightAssistantMessage();
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
_completeAutomaticCompressionOnLiveProgress(activeSid);
|
||||
const parsed=_parseStreamState();
|
||||
if(_freshSegment&&window._showThinking!==false) appendThinking(_liveThinkingText());
|
||||
if(_freshSegment) appendThinking('', _liveThinkingPlacement());
|
||||
if(String((parsed&&parsed.displayText)||'').trim()||assistantRow) ensureAssistantRow();
|
||||
_scheduleRender();
|
||||
});
|
||||
@@ -1831,24 +2290,39 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(!visible){
|
||||
return;
|
||||
}
|
||||
reasoningText='';
|
||||
liveReasoningText='';
|
||||
if(alreadyStreamed){
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
if(!S.session||S.session.session_id!==activeSid){
|
||||
recordActivityBoundary();
|
||||
_resetAssistantSegment();
|
||||
return;
|
||||
}
|
||||
_completeAutomaticCompressionOnLiveProgress(activeSid);
|
||||
const parsed=_parseStreamState();
|
||||
if(String((parsed&&parsed.displayText)||'').trim()||assistantRow){
|
||||
ensureAssistantRow(true);
|
||||
_flushPendingSegmentRender({force:true});
|
||||
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
|
||||
if(typeof closeCurrentLiveActivityGroup==='function') closeCurrentLiveActivityGroup();
|
||||
recordActivityBoundary();
|
||||
}
|
||||
_resetAssistantSegment();
|
||||
return;
|
||||
}
|
||||
assistantText += assistantText ? `\n\n${visible}` : visible;
|
||||
visibleInterimSnippets.push(visible);
|
||||
syncInflightAssistantMessage();
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
if(window._showThinking!==false){
|
||||
if(typeof updateThinking==='function') updateThinking(_liveThinkingText());
|
||||
else appendThinking(_liveThinkingText());
|
||||
if(!S.session||S.session.session_id!==activeSid){
|
||||
recordActivityBoundary();
|
||||
_resetAssistantSegment();
|
||||
return;
|
||||
}
|
||||
_completeAutomaticCompressionOnLiveProgress(activeSid);
|
||||
ensureAssistantRow(true);
|
||||
_flushPendingSegmentRender({force:true});
|
||||
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
|
||||
if(typeof closeCurrentLiveActivityGroup==='function') closeCurrentLiveActivityGroup();
|
||||
recordActivityBoundary();
|
||||
_resetAssistantSegment();
|
||||
_scheduleRender();
|
||||
});
|
||||
@@ -1856,51 +2330,41 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
source.addEventListener('reasoning',e=>{
|
||||
if(_terminalStateReached||_streamFinalized) return;
|
||||
const d=JSON.parse(e.data);
|
||||
reasoningText += d.text || '';
|
||||
liveReasoningText += d.text || '';
|
||||
const text=d.text||'';
|
||||
reasoningText += text;
|
||||
liveReasoningText += text;
|
||||
if(d.text&&S.session&&S.session.session_id===activeSid) _completeAutomaticCompressionOnLiveProgress(activeSid);
|
||||
syncInflightAssistantMessage();
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
// Render thinking card synchronously — not via rAF — so the DOM is
|
||||
// up-to-date before a 'tool' event in the same microtask batch calls
|
||||
// finalizeThinkingCard(). The old rAF-only path caused a race where
|
||||
// the thinking row was still a spinner when finalized.
|
||||
if(window._showThinking!==false){
|
||||
if(typeof updateThinking==='function') updateThinking(_liveThinkingText());
|
||||
else appendThinking(_liveThinkingText());
|
||||
if(text&&S.session&&S.session.session_id===activeSid){
|
||||
_updateLiveThinkingCard(_liveThinkingText());
|
||||
}
|
||||
_scheduleRender();
|
||||
});
|
||||
|
||||
source.addEventListener('tool',e=>{
|
||||
if(_terminalStateReached||_streamFinalized) return;
|
||||
if(!S.session||S.session.session_id!==activeSid||S.activeStreamId!==streamId) return;
|
||||
const d=JSON.parse(e.data);
|
||||
if(d.name==='clarify') return;
|
||||
const tc={name:d.name, preview:d.preview||'', args:d.args||{}, snippet:'', done:false, tid:d.tid||`live-${Date.now()}-${Math.random().toString(36).slice(2,8)}`};
|
||||
const inflight = INFLIGHT[activeSid] || (INFLIGHT[activeSid] = {
|
||||
messages:[...S.messages],
|
||||
uploaded:[],
|
||||
toolCalls:[]
|
||||
});
|
||||
if(!Array.isArray(inflight.toolCalls)) inflight.toolCalls=[];
|
||||
INFLIGHT[activeSid].toolCalls.push(tc);
|
||||
S.toolCalls=INFLIGHT[activeSid].toolCalls;
|
||||
persistInflightState();
|
||||
_completeAutomaticCompressionOnLiveProgress(activeSid);
|
||||
const tc=upsertLiveToolCall(d,'start');
|
||||
if(!tc) return;
|
||||
|
||||
if(S.session&&S.session.session_id===activeSid&&typeof scheduleRenderSessionArtifacts==='function') scheduleRenderSessionArtifacts();
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
// NOTE: don't removeThinking() here — keep the thinking card visible
|
||||
// above the tool card so the turn reads top-to-bottom as:
|
||||
// user → thinking → tool cards → response. Removing it caused the card
|
||||
// to be re-created below everything when reasoning resumed post-tool.
|
||||
// Provider reasoning/thinking is a Worklog Thinking Card, separate from
|
||||
// tool cards. Close the current live card before appending a tool row.
|
||||
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
|
||||
liveReasoningText='';
|
||||
reasoningText='';
|
||||
const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove();
|
||||
appendLiveToolCard(tc);
|
||||
snapshotLiveTurn();
|
||||
// Reset the live assistant row reference so that any text tokens arriving
|
||||
// after this tool call create a NEW segment appended below the tool card,
|
||||
// rather than updating the old segment that sits above it in the DOM.
|
||||
const pendingDisplayText=segmentStart===0
|
||||
? (_parseStreamState().displayText||'')
|
||||
: _stripXmlToolCalls(assistantText.slice(segmentStart));
|
||||
if((assistantRow&&assistantBody)||String(pendingDisplayText||'').trim()){
|
||||
ensureAssistantRow(true);
|
||||
}
|
||||
_flushPendingSegmentRender({force:true});
|
||||
appendLiveToolCard(tc,{sessionId:activeSid,streamId});
|
||||
snapshotLiveTurn();
|
||||
_freshSegment=true;
|
||||
_smdEndParser();
|
||||
_resetAssistantSegment();
|
||||
@@ -1908,44 +2372,34 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
});
|
||||
|
||||
source.addEventListener('tool_complete',e=>{
|
||||
if(_terminalStateReached||_streamFinalized) return;
|
||||
if(!S.session||S.session.session_id!==activeSid||S.activeStreamId!==streamId) return;
|
||||
const d=JSON.parse(e.data);
|
||||
if(d.name==='clarify') return;
|
||||
const inflight=INFLIGHT[activeSid];
|
||||
if(!inflight) return;
|
||||
if(!Array.isArray(inflight.toolCalls)) inflight.toolCalls=[];
|
||||
let tc=null;
|
||||
for(let i=inflight.toolCalls.length-1;i>=0;i--){
|
||||
const cur=inflight.toolCalls[i];
|
||||
if(cur&&cur.done===false&&(!d.name||cur.name===d.name)){
|
||||
tc=cur;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(!tc){
|
||||
tc={name:d.name||'tool', preview:d.preview||'', args:d.args||{}, snippet:'', done:true};
|
||||
inflight.toolCalls.push(tc);
|
||||
}
|
||||
// Route result to .snippet (detail) instead of overwriting .preview
|
||||
// (header). During streaming .preview already holds the last progress
|
||||
// text — replacing it with the same content caused header/detail
|
||||
// duplication. Fallback: if no progress events were sent, use the
|
||||
// result as preview so the header is not blank.
|
||||
if(d.preview){
|
||||
tc.snippet=tc.snippet||d.preview;
|
||||
if(!tc.preview) tc.preview=d.preview;
|
||||
}
|
||||
tc.args=d.args||tc.args||{};
|
||||
tc.done=true;
|
||||
_completeAutomaticCompressionOnLiveProgress(activeSid);
|
||||
const tc=upsertLiveToolCall(d,'complete');
|
||||
if(!tc) return;
|
||||
tc.is_error=!!d.is_error;
|
||||
if(d.duration!==undefined) tc.duration=d.duration;
|
||||
S.toolCalls=inflight.toolCalls;
|
||||
persistInflightState();
|
||||
if(typeof noteWorkspaceMutationsFromToolCall==='function') noteWorkspaceMutationsFromToolCall(tc);
|
||||
if(S.session&&S.session.session_id===activeSid&&typeof scheduleRenderSessionArtifacts==='function') scheduleRenderSessionArtifacts();
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
_maybeNotifyPersistentStateSaved(tc);
|
||||
if(typeof refreshOpenPreviewIfMutated==='function') refreshOpenPreviewIfMutated();
|
||||
appendLiveToolCard(tc);
|
||||
if(tc._createdByComplete){
|
||||
const pendingDisplayText=segmentStart===0
|
||||
? (_parseStreamState().displayText||'')
|
||||
: _stripXmlToolCalls(assistantText.slice(segmentStart));
|
||||
if((assistantRow&&assistantBody)||String(pendingDisplayText||'').trim()){
|
||||
ensureAssistantRow(true);
|
||||
_flushPendingSegmentRender({force:true});
|
||||
}
|
||||
appendLiveToolCard(tc,{sessionId:activeSid,streamId});
|
||||
_freshSegment=true;
|
||||
_smdEndParser();
|
||||
_resetAssistantSegment();
|
||||
} else {
|
||||
appendLiveToolCard(tc,{sessionId:activeSid,streamId});
|
||||
}
|
||||
snapshotLiveTurn();
|
||||
scrollIfPinned();
|
||||
});
|
||||
@@ -2181,7 +2635,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
}
|
||||
// Find the last assistant message once for both reasoning persistence and timestamp
|
||||
const lastAsst=[...S.messages].reverse().find(m=>m.role==='assistant');
|
||||
// Persist reasoning trace so thinking card survives page reload
|
||||
// Persist reasoning trace for Worklog Thinking Cards; normal transcript
|
||||
// rendering keeps provider reasoning out of the final answer.
|
||||
if(reasoningText&&lastAsst&&!lastAsst.reasoning) lastAsst.reasoning=reasoningText;
|
||||
// Strip any inline <think> blocks still embedded in the server-side
|
||||
// content (M3 OpenAI-compat doesn't separate reasoning). Move them
|
||||
@@ -2252,15 +2707,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
return hasTc||hasPartialTc||hasTu;
|
||||
});
|
||||
if(!hasMessageToolMetadata&&d.session.tool_calls&&d.session.tool_calls.length){
|
||||
S.toolCalls=d.session.tool_calls.map(tc=>({...tc,done:true}));
|
||||
S.toolCalls=d.session.tool_calls.map(tc=>tc);
|
||||
S.toolCalls=_mergeSettledToolCallsWithLiveMetadata(d.session.tool_calls);
|
||||
} else {
|
||||
if(hasMessageToolMetadata) S._settledLiveToolMetadata=S.toolCalls.map(tc=>({...tc,done:true}));
|
||||
S.toolCalls=hasMessageToolMetadata?[]:S.toolCalls.map(tc=>({...tc,done:true}));
|
||||
}
|
||||
if(typeof renderSessionArtifacts==='function') renderSessionArtifacts();
|
||||
if(typeof _copyActivityDisclosureState==='function'&&lastAsst){
|
||||
const assistantIdx=S.messages.indexOf(lastAsst);
|
||||
if(assistantIdx>=0) _copyActivityDisclosureState('live:'+streamId, 'assistant:'+assistantIdx);
|
||||
}
|
||||
if(uploaded.length){
|
||||
const lastUser=[...S.messages].reverse().find(m=>m.role==='user');
|
||||
if(lastUser)lastUser.attachments=uploaded;
|
||||
@@ -2382,34 +2835,32 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
let d={};
|
||||
try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; }
|
||||
if(d.session_id&&d.session_id!==activeSid) return;
|
||||
if(typeof setCompressionUi==='function'){
|
||||
const state={
|
||||
sessionId:activeSid,
|
||||
phase:'running',
|
||||
automatic:true,
|
||||
message:d.message||'Auto-compressing context...',
|
||||
message:'Compressing context',
|
||||
startedAt:Date.now()/1000,
|
||||
};
|
||||
setCompressionUi(state);
|
||||
const liveAnswerStarted=!!(assistantRow||String(((_parseStreamState&&_parseStreamState())||{}).displayText||'').trim());
|
||||
if(liveAnswerStarted&&typeof appendLiveCompressionCard==='function'&&appendLiveCompressionCard(state)){
|
||||
// The live card is now anchored in the turn. Keeping the same running
|
||||
// state in global transient UI makes later renderMessages() calls insert
|
||||
// a duplicate Automatic Compression card.
|
||||
window._compressionUi=null;
|
||||
if(typeof _restoreCompressionPlaceholder==='function') _restoreCompressionPlaceholder();
|
||||
if(typeof appendLiveCompressionCard==='function'&&appendLiveCompressionCard(state)){
|
||||
// Keep automatic compression inside the active Worklog. Calling
|
||||
// renderMessages() here rebuilds from the still-empty persisted
|
||||
// transcript during active streams and can erase already replayed tools.
|
||||
if(typeof clearCompressionUi==='function') clearCompressionUi();
|
||||
else window._compressionUi=null;
|
||||
snapshotLiveTurn();
|
||||
return;
|
||||
}
|
||||
if(typeof setCompressionUi==='function'){
|
||||
setCompressionUi(state);
|
||||
}
|
||||
if(typeof renderMessages==='function') renderMessages({preserveScroll:true});
|
||||
snapshotLiveTurn();
|
||||
});
|
||||
|
||||
source.addEventListener('compressed',e=>{
|
||||
// Context was auto-compressed during this turn. Render it through the
|
||||
// same transient compression-card path as manual /compress, without
|
||||
// inserting a fake assistant message into history or model context.
|
||||
// Context was auto-compressed during this turn. Keep the live timeline
|
||||
// honest by transitioning the running divider into a completed divider;
|
||||
// final settlement removes live-only compression rows from the Worklog.
|
||||
if(!S.session) return;
|
||||
const currentSid=S.session.session_id;
|
||||
let d={};
|
||||
@@ -2419,39 +2870,25 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const eventMatchesCurrent=!!(currentSid&&(eventSid===currentSid||d.new_session_id===currentSid||d.continuation_session_id===currentSid));
|
||||
if(!eventMatchesCurrent) return;
|
||||
const displaySid=currentSid;
|
||||
const message=String(d.message||'Context auto-compressed to continue the conversation').trim();
|
||||
if(d.usage&&typeof _syncCtxIndicator==='function'){
|
||||
S.lastUsage=typeof _mergeUsageForCtxIndicator==='function'
|
||||
? _mergeUsageForCtxIndicator(d.usage,S.lastUsage||{})
|
||||
: {...(S.lastUsage||{}),...d.usage};
|
||||
_syncCtxIndicator(S.lastUsage);
|
||||
}
|
||||
if(typeof setCompressionUi==='function'){
|
||||
const state={
|
||||
if(typeof appendLiveCompressionCard==='function'){
|
||||
appendLiveCompressionCard({
|
||||
sessionId:displaySid,
|
||||
phase:'done',
|
||||
automatic:true,
|
||||
message,
|
||||
engine:d.engine,
|
||||
mode:d.mode,
|
||||
details:d.details,
|
||||
summary:{headline:message},
|
||||
message:'Context auto-compressed',
|
||||
continuationSessionId:continuationSid,
|
||||
};
|
||||
setCompressionUi(state);
|
||||
const appended=typeof appendLiveCompressionCard==='function'&&appendLiveCompressionCard(state);
|
||||
if(appended){
|
||||
// The live card is now anchored in the turn. Do not keep the automatic
|
||||
// completion state as global transient UI, otherwise every subsequent
|
||||
// render projects the same Auto Compression card again.
|
||||
window._compressionUi=null;
|
||||
if(typeof _restoreCompressionPlaceholder==='function') _restoreCompressionPlaceholder();
|
||||
snapshotLiveTurn();
|
||||
}
|
||||
});
|
||||
}
|
||||
if(typeof clearCompressionUi==='function') clearCompressionUi();
|
||||
else window._compressionUi=null;
|
||||
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
|
||||
if(!S.busy&&typeof renderMessages==='function') renderMessages();
|
||||
showToast(message||'Context compressed', 8000);
|
||||
});
|
||||
|
||||
source.addEventListener('metering',e=>{
|
||||
@@ -2764,8 +3201,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
return hasTc||hasPartialTc||hasTu;
|
||||
});
|
||||
if(!hasMessageToolMetadata&&session.tool_calls&&session.tool_calls.length){
|
||||
S.toolCalls=(session.tool_calls||[]).map(tc=>({...tc,done:true}));
|
||||
S.toolCalls=_mergeSettledToolCallsWithLiveMetadata(session.tool_calls||[]);
|
||||
}else{
|
||||
if(hasMessageToolMetadata) S._settledLiveToolMetadata=S.toolCalls.map(tc=>({...tc,done:true}));
|
||||
S.toolCalls=[];
|
||||
}
|
||||
if(isSessionViewed) _markSessionViewed(completedSid, session.message_count ?? S.messages.length);
|
||||
@@ -2840,7 +3278,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
}
|
||||
}catch(_){}
|
||||
}
|
||||
const replayParams=replayOnly?_runJournalReplayParams():'';
|
||||
const replayParams=(reconnecting||replayOnly)?_runJournalReplayParams():'';
|
||||
_wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}${replayParams}`,document.baseURI||location.href).href,{withCredentials:true}));
|
||||
})();
|
||||
|
||||
|
||||
+395
-23
@@ -413,10 +413,25 @@ function _reconcileActiveSessionIdleStateFromList(serverRows) {
|
||||
}
|
||||
_sessionStreamingById.set(sid, false);
|
||||
_forgetObservedStreamingSession(sid);
|
||||
if (typeof hideApprovalCard==='function') hideApprovalCard(true);
|
||||
if (typeof hideLiveRunStatus==='function') hideLiveRunStatus(sid);
|
||||
if (typeof clearLiveToolCards==='function') clearLiveToolCards();
|
||||
if (changed&&typeof updateSendBtn==='function') updateSendBtn();
|
||||
if (changed&&typeof _scheduleActiveSessionIdleReload==='function') _scheduleActiveSessionIdleReload(sid);
|
||||
return changed;
|
||||
}
|
||||
|
||||
function _scheduleActiveSessionIdleReload(sid) {
|
||||
if(!sid) return;
|
||||
setTimeout(async () => {
|
||||
if(!S||!S.session||S.session.session_id !== sid) return;
|
||||
if(S.busy || S.activeStreamId) return;
|
||||
try{
|
||||
await loadSession(sid, {force:true, externalRefreshReason:'idle-reconcile'});
|
||||
}catch(_){}
|
||||
},0);
|
||||
}
|
||||
|
||||
function _purgeStaleInflightEntries() {
|
||||
// Clean up INFLIGHT entries for sessions the server confirms are NOT
|
||||
// streaming. This prevents the in-memory cache from growing unbounded
|
||||
@@ -458,6 +473,25 @@ function _rememberRenderedStreamingState(s, isStreaming) {
|
||||
_rememberObservedStreamingSession(s);
|
||||
}
|
||||
|
||||
function _inflightHasVisibleLiveState(inflight) {
|
||||
if (!inflight || typeof inflight !== 'object') return false;
|
||||
if (String(inflight.lastAssistantText || '').trim()) return true;
|
||||
if (String(inflight.lastReasoningText || '').trim()) return true;
|
||||
if (String(inflight.liveTurnHtml || '').trim()) return true;
|
||||
if (Array.isArray(inflight.toolCalls) && inflight.toolCalls.length) return true;
|
||||
if (Array.isArray(inflight.activityBurstAnchors) && inflight.activityBurstAnchors.length) return true;
|
||||
if (Array.isArray(inflight.messages)) {
|
||||
return inflight.messages.some((msg) => {
|
||||
if (!msg || msg.role !== 'assistant') return false;
|
||||
const content = msg.content;
|
||||
if (typeof content === 'string') return content.trim();
|
||||
if (Array.isArray(content)) return content.length > 0;
|
||||
return Boolean(content);
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function _rememberRenderedSessionSnapshot(s) {
|
||||
if (!s || !s.session_id) return;
|
||||
const previous = _sessionListSnapshotById.get(s.session_id);
|
||||
@@ -746,8 +780,10 @@ async function loadSession(sid){
|
||||
// tears down active pane state and can reset the long-session scroll window
|
||||
// to the top even though the user did not navigate anywhere. Explicit
|
||||
// refresh paths pass {force:true} when external state.db changes arrive.
|
||||
// Legacy invariant kept for static regression tests: if(currentSid===sid) return
|
||||
if(currentSid===sid && !forceReload) return;
|
||||
// Do not no-op a same-session click while another load is in flight: the
|
||||
// previous transcript may already have been cleared for the pending switch.
|
||||
// Static force-reload invariant: if(currentSid===sid && !forceReload) return;
|
||||
if(currentSid===sid && !forceReload && !_loadingSessionId) return;
|
||||
// Mark this session as the in-flight load. Subsequent loadSession() calls
|
||||
// will overwrite this; stale awaits use the mismatch to bail out (#1060).
|
||||
_loadingSessionId = sid;
|
||||
@@ -914,22 +950,46 @@ async function loadSession(sid){
|
||||
todos:Array.isArray(stored.todos)?stored.todos:null,
|
||||
todoStateMeta:stored.todoStateMeta||null,
|
||||
reattach:true,
|
||||
lastAssistantText:String(stored.lastAssistantText||''),
|
||||
lastReasoningText:String(stored.lastReasoningText||''),
|
||||
lastRunJournalSeq:Number(stored.lastRunJournalSeq||0)||0,
|
||||
journalReplayFromStart:!!stored.journalReplayFromStart,
|
||||
currentActivityBurstId:Number(stored.currentActivityBurstId||0)||0,
|
||||
currentLiveSegmentSeq:Number(stored.currentLiveSegmentSeq||0)||0,
|
||||
activityBurstAnchors:Array.isArray(stored.activityBurstAnchors)?stored.activityBurstAnchors:[],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if(INFLIGHT[sid]&&INFLIGHT[sid].journalReplayFromStart&&activeStreamId){
|
||||
delete INFLIGHT[sid];
|
||||
if(typeof clearInflightState==='function') clearInflightState(sid);
|
||||
}
|
||||
|
||||
if(activeStreamId&&INFLIGHT[sid]&&!_inflightHasVisibleLiveState(INFLIGHT[sid])){
|
||||
// A stale cursor-only INFLIGHT entry is worse than no cache: replay would
|
||||
// resume after lastRunJournalSeq while the pane has no prose/tool DOM to
|
||||
// preserve, making a session switch look like the live turn vanished.
|
||||
delete INFLIGHT[sid];
|
||||
if(typeof clearInflightState==='function') clearInflightState(sid);
|
||||
}
|
||||
|
||||
if(INFLIGHT[sid]){
|
||||
const inflightMessages=INFLIGHT[sid].messages||[];
|
||||
S.messages=[];
|
||||
_ensureInflightLiveAssistantMessage(INFLIGHT[sid]);
|
||||
const inflightMessages=_projectInflightMessagesForActivityBursts(INFLIGHT[sid]);
|
||||
S.toolCalls=[];
|
||||
try {
|
||||
await _ensureMessagesLoaded(sid);
|
||||
} catch(e) {
|
||||
S.messages=inflightMessages;
|
||||
}
|
||||
const liveTailPrepared=_prepareRunningLiveTail(S.messages,inflightMessages);
|
||||
if(liveTailPrepared){
|
||||
S.messages=_dropCurrentTurnAssistantMessages(S.messages);
|
||||
}
|
||||
S.messages=_mergeInflightTailMessages(S.messages,inflightMessages);
|
||||
S.toolCalls=(INFLIGHT[sid].toolCalls||[]);
|
||||
if(_mergePendingSessionMessage(S.session,S.messages)){
|
||||
if(_mergePendingSessionMessage(S.session,S.messages)&&inflightMessages===(INFLIGHT[sid].messages||[])){
|
||||
INFLIGHT[sid].messages=S.messages;
|
||||
}
|
||||
// Refresh todos from cold-load or persisted INFLIGHT before painting.
|
||||
@@ -939,26 +999,55 @@ async function loadSession(sid){
|
||||
// replaying persisted live tools so the compact Activity count survives
|
||||
// switching away from and back to an active chat (#1715).
|
||||
S.activeStreamId=activeStreamId;
|
||||
if(INFLIGHT[sid].reattach&&activeStreamId&&typeof attachLiveStream==='function'){
|
||||
INFLIGHT[sid].reattach=false;
|
||||
if (_loadingSessionId !== sid) return;
|
||||
attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true});
|
||||
}
|
||||
syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined);
|
||||
const restoredLiveTurn=typeof restoreLiveTurnHtmlForSession==='function'&&restoreLiveTurnHtmlForSession(sid);
|
||||
if(typeof ensureRunActivityForCurrentTurn==='function') ensureRunActivityForCurrentTurn();
|
||||
const hasStructuredLiveState=!!(INFLIGHT[sid]&&(
|
||||
String(INFLIGHT[sid].lastAssistantText||'').trim()||
|
||||
String(INFLIGHT[sid].lastReasoningText||'').trim()||
|
||||
(Array.isArray(INFLIGHT[sid].activityBurstAnchors)&&INFLIGHT[sid].activityBurstAnchors.length)||
|
||||
(Array.isArray(INFLIGHT[sid].toolCalls)&&INFLIGHT[sid].toolCalls.length)
|
||||
));
|
||||
let restoredLiveTurn=false;
|
||||
if(typeof restoreLiveTurnHtmlForSession==='function'){
|
||||
if(!hasStructuredLiveState){
|
||||
restoredLiveTurn=restoreLiveTurnHtmlForSession(sid);
|
||||
}else{
|
||||
const liveTurn=document.getElementById('liveAssistantTurn');
|
||||
const hasCurrentWorklogContent=!!(liveTurn&&liveTurn.querySelector(
|
||||
'.live-worklog[data-live-worklog-shell="1"] .tool-card-row,'+
|
||||
'.live-worklog[data-live-worklog-shell="1"] .wl-reason,'+
|
||||
'.tool-call-group[data-live-tool-worklog-group="1"] .tool-card-row,'+
|
||||
'.tool-call-group[data-live-tool-worklog-group="1"] .wl-reason,'+
|
||||
'.tool-call-group[data-live-tool-call-group="1"] .tool-card-row,'+
|
||||
'.tool-call-group[data-live-tool-call-group="1"] .wl-reason'
|
||||
));
|
||||
if(hasCurrentWorklogContent) restoredLiveTurn=true;
|
||||
else restoredLiveTurn=restoreLiveTurnHtmlForSession(sid);
|
||||
}
|
||||
}
|
||||
if(!restoredLiveTurn){
|
||||
appendThinking();
|
||||
clearLiveToolCards();
|
||||
if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost();
|
||||
if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell();
|
||||
else appendThinking();
|
||||
for(const tc of (S.toolCalls||[])){
|
||||
if(tc&&tc.name) appendLiveToolCard(tc);
|
||||
}
|
||||
}
|
||||
if(typeof ensureLiveWorklogShell==='function'){
|
||||
const liveTurn=document.getElementById('liveAssistantTurn');
|
||||
if(!liveTurn||!liveTurn.querySelector('.tool-call-group[data-tool-worklog-group="1"]')) ensureLiveWorklogShell();
|
||||
}
|
||||
loadDir('.');
|
||||
setBusy(true);setComposerStatus('');
|
||||
startApprovalPolling(sid);
|
||||
if(typeof startClarifyPolling==='function') startClarifyPolling(sid);
|
||||
if(typeof _fetchYoloState==='function') _fetchYoloState(sid);
|
||||
if(INFLIGHT[sid].reattach&&activeStreamId&&typeof attachLiveStream==='function'){
|
||||
INFLIGHT[sid].reattach=false;
|
||||
if (_loadingSessionId !== sid) return;
|
||||
attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true});
|
||||
}
|
||||
}else{
|
||||
// Phase 2b: Idle session — load full messages lazily for rendering.
|
||||
// _ensureMessagesLoaded is idempotent; it skips if S.messages already populated.
|
||||
@@ -1020,16 +1109,20 @@ async function loadSession(sid){
|
||||
if(activeStreamId){
|
||||
S.busy=true;
|
||||
S.activeStreamId=activeStreamId;
|
||||
if(typeof attachLiveStream==='function') attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true});
|
||||
else if(typeof watchInflightSession==='function') watchInflightSession(sid, activeStreamId);
|
||||
updateSendBtn();
|
||||
setStatus('');
|
||||
setComposerStatus('');
|
||||
syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined);appendThinking();loadDir('.');
|
||||
// syncTopbar();renderMessages();appendThinking();loadDir('.');
|
||||
syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined);
|
||||
if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell();
|
||||
else appendThinking();
|
||||
loadDir('.');
|
||||
updateQueueBadge(sid);
|
||||
startApprovalPolling(sid);
|
||||
if(typeof startClarifyPolling==='function') startClarifyPolling(sid);
|
||||
if(typeof _fetchYoloState==='function') _fetchYoloState(sid);
|
||||
if(typeof attachLiveStream==='function') attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true});
|
||||
else if(typeof watchInflightSession==='function') watchInflightSession(sid, activeStreamId);
|
||||
}else{
|
||||
S.busy=false;
|
||||
S.activeStreamId=null;
|
||||
@@ -1714,21 +1807,300 @@ function _sameTranscriptMessage(a,b){
|
||||
return false;
|
||||
}
|
||||
|
||||
function _currentTurnAssistantText(messages){
|
||||
const list=Array.isArray(messages)?messages:[];
|
||||
let start=-1;
|
||||
for(let i=list.length-1;i>=0;i--){
|
||||
if(list[i]&&list[i].role==='user'){start=i;break;}
|
||||
}
|
||||
const parts=[];
|
||||
for(let i=start+1;i<list.length;i++){
|
||||
const msg=list[i];
|
||||
if(!msg||msg.role!=='assistant'||msg._live) continue;
|
||||
const text=_messageComparableText(msg);
|
||||
if(text) parts.push(text);
|
||||
}
|
||||
return parts.join('\n\n').trim();
|
||||
}
|
||||
|
||||
function _compactTranscriptText(text){
|
||||
return String(text||'').replace(/\s+/g,' ').trim();
|
||||
}
|
||||
|
||||
function _dropCurrentTurnAssistantMessages(messages){
|
||||
const list=Array.isArray(messages)?messages:[];
|
||||
let start=-1;
|
||||
for(let i=list.length-1;i>=0;i--){
|
||||
if(list[i]&&list[i].role==='user'){start=i;break;}
|
||||
}
|
||||
if(start<0) return list;
|
||||
return list.filter((msg,idx)=>idx<=start||!(msg&&msg.role==='assistant'));
|
||||
}
|
||||
|
||||
function _ensureInflightLiveAssistantMessage(inflight){
|
||||
if(!inflight) return false;
|
||||
const text=String(inflight.lastAssistantText||'').trim();
|
||||
const reasoning=String(inflight.lastReasoningText||'').trim();
|
||||
if(!text&&!reasoning) return false;
|
||||
if(!Array.isArray(inflight.messages)) inflight.messages=[];
|
||||
let live=null;
|
||||
for(let i=inflight.messages.length-1;i>=0;i--){
|
||||
const msg=inflight.messages[i];
|
||||
if(msg&&msg.role==='assistant'&&msg._live){live=msg;break;}
|
||||
}
|
||||
if(live){
|
||||
const liveText=_messageComparableText(live);
|
||||
if(text&&(!liveText||text.startsWith(liveText)||text.length>liveText.length)){
|
||||
live.content=text;
|
||||
}
|
||||
if(reasoning&&!live.reasoning) live.reasoning=reasoning;
|
||||
return true;
|
||||
}
|
||||
inflight.messages.push({
|
||||
role:'assistant',
|
||||
content:text,
|
||||
reasoning:reasoning||undefined,
|
||||
_live:true,
|
||||
_ts:Date.now()/1000,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function _projectInflightMessagesForActivityBursts(inflight){
|
||||
const messages=Array.isArray(inflight&&inflight.messages)?inflight.messages:[];
|
||||
const anchors=Array.isArray(inflight&&inflight.activityBurstAnchors)?inflight.activityBurstAnchors:[];
|
||||
if(!anchors.length) return messages;
|
||||
let liveIdx=-1;
|
||||
for(let i=messages.length-1;i>=0;i--){
|
||||
const msg=messages[i];
|
||||
if(msg&&msg.role==='assistant'&&msg._live){liveIdx=i;break;}
|
||||
}
|
||||
if(liveIdx<0) return messages;
|
||||
let liveTailStartIdx=liveIdx;
|
||||
while(liveTailStartIdx>0){
|
||||
const prev=messages[liveTailStartIdx-1];
|
||||
if(!(prev&&prev.role==='assistant'&&prev._live)) break;
|
||||
liveTailStartIdx-=1;
|
||||
}
|
||||
const live=messages[liveIdx];
|
||||
const text=_messageComparableText(live);
|
||||
if(!text) return messages;
|
||||
const priorLiveTexts=messages.slice(liveTailStartIdx,liveIdx)
|
||||
.filter(m=>m&&m.role==='assistant'&&m._live)
|
||||
.map(m=>_messageComparableText(m))
|
||||
.filter(Boolean);
|
||||
const liveTailIsAccumulator=priorLiveTexts.length>0&&priorLiveTexts.every(part=>
|
||||
_compactTranscriptText(text).includes(_compactTranscriptText(part))
|
||||
);
|
||||
const replaceStartIdx=liveTailIsAccumulator?liveTailStartIdx:liveIdx;
|
||||
if(priorLiveTexts.length&&!liveTailIsAccumulator) return messages;
|
||||
const cleanAnchors=anchors
|
||||
.map(a=>({id:Number(a&&a.id),textEnd:Number(a&&a.textEnd)}))
|
||||
.filter(a=>Number.isFinite(a.id)&&Number.isFinite(a.textEnd)&&a.textEnd>0)
|
||||
.sort((a,b)=>a.textEnd-b.textEnd||a.id-b.id);
|
||||
const aliasBurstIds=new Map();
|
||||
const fallbackBurstId = Number(inflight.currentActivityBurstId||0)||0;
|
||||
aliasBurstIds.set(0,fallbackBurstId);
|
||||
let lastVisibleBurstId=null;
|
||||
let lastVisibleTextEnd=0;
|
||||
const visibleAnchors=[];
|
||||
for(const anchor of cleanAnchors){
|
||||
const end=Math.min(text.length,anchor.textEnd);
|
||||
if(end<=lastVisibleTextEnd){
|
||||
if(lastVisibleBurstId!==null) aliasBurstIds.set(anchor.id,lastVisibleBurstId);
|
||||
continue;
|
||||
}
|
||||
visibleAnchors.push(anchor);
|
||||
lastVisibleBurstId=anchor.id;
|
||||
lastVisibleTextEnd=end;
|
||||
}
|
||||
|
||||
if(visibleAnchors.length&&Number.isFinite(visibleAnchors[0].id)) aliasBurstIds.set(0,visibleAnchors[0].id);
|
||||
if(!visibleAnchors.length){
|
||||
const firstVisibleBurstId=Number(cleanAnchors[0]&&cleanAnchors[0].id);
|
||||
const fallbackAnchorId=Number.isFinite(firstVisibleBurstId)?firstVisibleBurstId:fallbackBurstId;
|
||||
if(fallbackAnchorId!==fallbackBurstId) aliasBurstIds.set(0,fallbackAnchorId);
|
||||
const projected=[{...live,content:text,_activityBurstId:fallbackAnchorId}];
|
||||
|
||||
const baselineSeq=Number(inflight.currentLiveSegmentSeq);
|
||||
const existingSeqs=messages
|
||||
.filter(m=>m&&m._live&&Number.isFinite(Number(m._liveSegmentSeq)))
|
||||
.map(m=>Number(m._liveSegmentSeq));
|
||||
const baseFromMessages=existingSeqs.length
|
||||
? existingSeqs.reduce((acc,n)=>Math.max(acc,n),-Infinity)
|
||||
: 0;
|
||||
const firstSeq=(Number.isFinite(baselineSeq)&&baselineSeq>0)
|
||||
? baselineSeq
|
||||
: (Number.isFinite(baseFromMessages)&&baseFromMessages>0)
|
||||
? baseFromMessages
|
||||
: 1;
|
||||
projected.forEach((seg,i)=>{
|
||||
seg._liveSegmentSeq=i===0?firstSeq:1+i;
|
||||
});
|
||||
if(Array.isArray(inflight.toolCalls)){
|
||||
const segmentSeqByBurstId=new Map();
|
||||
segmentSeqByBurstId.set(String(fallbackAnchorId),firstSeq);
|
||||
projected.forEach(seg=>{
|
||||
const bid=Number(seg&&seg._activityBurstId);
|
||||
const seq=Number(seg&&seg._liveSegmentSeq);
|
||||
if(!Number.isFinite(bid)||!Number.isFinite(seq)) return;
|
||||
const key=String(bid);
|
||||
const current=segmentSeqByBurstId.get(key);
|
||||
if(current===undefined||seq>current) segmentSeqByBurstId.set(key,seq);
|
||||
});
|
||||
|
||||
const validSeqs=new Set(segmentSeqByBurstId.values());
|
||||
const canonicalBurstId=(value)=>{
|
||||
const bid=Number(value);
|
||||
if(!Number.isFinite(bid)) return null;
|
||||
if(aliasBurstIds.has(bid)) return aliasBurstIds.get(bid);
|
||||
return bid;
|
||||
};
|
||||
|
||||
inflight.toolCalls.forEach(tc=>{
|
||||
if(!tc) return;
|
||||
if(tc.activityBurstId!==undefined&&tc.activityBurstId!==null){
|
||||
const current=Number(tc.activityBurstId);
|
||||
if(aliasBurstIds.has(current)) tc.activityBurstId=aliasBurstIds.get(current);
|
||||
}
|
||||
const segSeq=Number(tc.activitySegmentSeq);
|
||||
if(Number.isFinite(segSeq)&&validSeqs.has(segSeq)) return;
|
||||
const canonical=canonicalBurstId(tc.activityBurstId);
|
||||
if(!Number.isFinite(canonical)){
|
||||
if(Number.isFinite(segSeq)) tc.activitySegmentSeq=undefined;
|
||||
return;
|
||||
}
|
||||
const mappedSeq=segmentSeqByBurstId.get(String(canonical));
|
||||
if(Number.isFinite(mappedSeq)) tc.activitySegmentSeq=mappedSeq;
|
||||
else if(Number.isFinite(segSeq)) tc.activitySegmentSeq=undefined;
|
||||
});
|
||||
}
|
||||
return [...messages.slice(0,replaceStartIdx),...projected,...messages.slice(liveIdx+1)];
|
||||
}
|
||||
const projected=[];
|
||||
let prev=0;
|
||||
for(let i=0;i<visibleAnchors.length;i++){
|
||||
const anchor=visibleAnchors[i];
|
||||
const end=Math.max(prev,Math.min(text.length,anchor.textEnd));
|
||||
const part=text.slice(prev,end).trim();
|
||||
if(part) projected.push({...live,content:part,_activityBurstId:anchor.id});
|
||||
else{
|
||||
const fallbackAnchor = visibleAnchors[i+1] || visibleAnchors[i-1];
|
||||
if(fallbackAnchor && Number.isFinite(anchor.id)&&Number.isFinite(fallbackAnchor.id)){
|
||||
aliasBurstIds.set(anchor.id,fallbackAnchor.id);
|
||||
}
|
||||
}
|
||||
prev=end;
|
||||
}
|
||||
const tail=text.slice(prev).trim();
|
||||
if(tail) projected.push({...live,content:tail,_activityBurstId:Number(inflight.currentActivityBurstId||0)||0});
|
||||
if(!projected.length) return messages;
|
||||
|
||||
const baselineSeq=Number(inflight.currentLiveSegmentSeq);
|
||||
const existingSeqs=messages
|
||||
.filter(m=>m&&m._live&&Number.isFinite(Number(m._liveSegmentSeq)))
|
||||
.map(m=>Number(m._liveSegmentSeq));
|
||||
const baseFromMessages=existingSeqs.length
|
||||
? existingSeqs.reduce((acc,n)=>Math.max(acc,n),-Infinity)
|
||||
: 0;
|
||||
const endSeq=(Number.isFinite(baselineSeq)&&baselineSeq>0)
|
||||
? baselineSeq
|
||||
: (Number.isFinite(baseFromMessages)&&baseFromMessages>0)
|
||||
? baseFromMessages
|
||||
: projected.length;
|
||||
let firstSeq=endSeq-projected.length+1;
|
||||
if(!Number.isFinite(firstSeq)||firstSeq<1) firstSeq=1;
|
||||
projected.forEach((seg,i)=>{
|
||||
const seq=firstSeq+i;
|
||||
seg._liveSegmentSeq=seq;
|
||||
});
|
||||
if(Number.isFinite(firstSeq) && projected.length){
|
||||
inflight.currentLiveSegmentSeq=projected[projected.length-1]._liveSegmentSeq;
|
||||
}
|
||||
|
||||
const segmentSeqByBurstId=new Map();
|
||||
projected.forEach(seg=>{
|
||||
const bid=Number(seg&&seg._activityBurstId);
|
||||
const seq=Number(seg&&seg._liveSegmentSeq);
|
||||
if(!Number.isFinite(bid)||!Number.isFinite(seq)) return;
|
||||
const key=String(bid);
|
||||
const current=segmentSeqByBurstId.get(key);
|
||||
if(current===undefined||seq>current) segmentSeqByBurstId.set(key,seq);
|
||||
});
|
||||
|
||||
const canonicalBurstId = (value)=>{
|
||||
const bid=Number(value);
|
||||
if(!Number.isFinite(bid)) return null;
|
||||
if(aliasBurstIds.has(bid)) return aliasBurstIds.get(bid);
|
||||
return bid;
|
||||
};
|
||||
|
||||
const validSeqs=new Set(segmentSeqByBurstId.values());
|
||||
|
||||
if(Array.isArray(inflight.toolCalls)){
|
||||
inflight.toolCalls.forEach(tc=>{
|
||||
if(!tc) return;
|
||||
if(tc.activityBurstId!==undefined&&tc.activityBurstId!==null){
|
||||
const current=Number(tc.activityBurstId);
|
||||
if(aliasBurstIds.has(current)) tc.activityBurstId=aliasBurstIds.get(current);
|
||||
}
|
||||
const segSeq=Number(tc.activitySegmentSeq);
|
||||
if(Number.isFinite(segSeq)&&validSeqs.has(segSeq)) return;
|
||||
const canonical=canonicalBurstId(tc.activityBurstId);
|
||||
if(!Number.isFinite(canonical)){
|
||||
if(Number.isFinite(segSeq)) tc.activitySegmentSeq=undefined;
|
||||
return;
|
||||
}
|
||||
const mappedSeq=segmentSeqByBurstId.get(String(canonical));
|
||||
if(Number.isFinite(mappedSeq)) tc.activitySegmentSeq=mappedSeq;
|
||||
else if(Number.isFinite(segSeq)) tc.activitySegmentSeq=undefined;
|
||||
});
|
||||
}
|
||||
return [...messages.slice(0,replaceStartIdx),...projected,...messages.slice(liveIdx+1)];
|
||||
}
|
||||
|
||||
function _prepareRunningLiveTail(baseMessages,inflightMessages){
|
||||
const inflight=Array.isArray(inflightMessages)?inflightMessages:[];
|
||||
const liveMessages=inflight.filter(m=>m&&m.role==='assistant'&&m._live);
|
||||
if(liveMessages.length>1) return liveMessages.some(m=>!!_messageComparableText(m));
|
||||
const live=liveMessages[0]||null;
|
||||
if(!live) return false;
|
||||
const liveText=_messageComparableText(live);
|
||||
const persistedText=_currentTurnAssistantText(baseMessages);
|
||||
if(persistedText){
|
||||
const compactPersisted=_compactTranscriptText(persistedText);
|
||||
const compactLive=_compactTranscriptText(liveText);
|
||||
if(!liveText || persistedText.startsWith(liveText)){
|
||||
live.content=persistedText;
|
||||
}else if(liveText.startsWith(persistedText)){
|
||||
const extra=liveText.slice(persistedText.length).trim();
|
||||
if(extra&&compactPersisted.includes(_compactTranscriptText(extra))){
|
||||
live.content=persistedText;
|
||||
}
|
||||
}else if(compactPersisted===compactLive){
|
||||
live.content=persistedText;
|
||||
}
|
||||
}
|
||||
return !!_messageComparableText(live);
|
||||
}
|
||||
|
||||
function _mergeInflightTailMessages(baseMessages, inflightMessages){
|
||||
const base=Array.isArray(baseMessages)?baseMessages:[];
|
||||
const inflight=Array.isArray(inflightMessages)?inflightMessages:[];
|
||||
let liveIdx=-1;
|
||||
for(let i=inflight.length-1;i>=0;i--){
|
||||
if(inflight[i]&&inflight[i]._live){liveIdx=i;break;}
|
||||
let firstLiveIdx=-1;
|
||||
for(let i=0;i<inflight.length;i++){
|
||||
if(inflight[i]&&inflight[i]._live){firstLiveIdx=i;break;}
|
||||
}
|
||||
if(liveIdx<0) return base;
|
||||
let start=liveIdx;
|
||||
if(liveIdx>0&&inflight[liveIdx-1]&&inflight[liveIdx-1].role==='user') start=liveIdx-1;
|
||||
if(firstLiveIdx<0) return base;
|
||||
let start=firstLiveIdx;
|
||||
if(firstLiveIdx>0&&inflight[firstLiveIdx-1]&&inflight[firstLiveIdx-1].role==='user') start=firstLiveIdx-1;
|
||||
const tail=inflight.slice(start).filter(m=>m&&m.role);
|
||||
const merged=[...base];
|
||||
for(const msg of tail){
|
||||
const duplicate=merged.slice(-Math.max(5,tail.length+2)).some(existing=>_sameTranscriptMessage(existing,msg));
|
||||
if(!duplicate) merged.push(msg);
|
||||
let candidate=msg;
|
||||
if(!candidate) continue;
|
||||
const duplicate=merged.slice(-Math.max(5,tail.length+2)).some(existing=>_sameTranscriptMessage(existing,candidate));
|
||||
if(!duplicate) merged.push(candidate);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
+485
-259
@@ -12,6 +12,7 @@
|
||||
--radius-sm:4px;--radius-md:8px;--radius-card:8px;--radius-lg:12px;--radius-pill:999px;
|
||||
--space-1:4px;--space-2:8px;--space-3:12px;--space-4:16px;
|
||||
--font-size-xs:11px;--font-size-sm:12px;--font-size-md:14px;
|
||||
--message-body-font-size:14px;--message-body-line-height:1.75;--message-code-font-size:12.5px;--message-pre-code-font-size:13px;--message-table-font-size:12px;
|
||||
--file-tree-toggle-width:10px;
|
||||
--font-ui:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;
|
||||
--surface-subtle:rgba(0,0,0,.025);--surface-subtle-hover:rgba(0,0,0,.045);
|
||||
@@ -24,6 +25,9 @@
|
||||
/* We override the px values directly on key containers since most of the */
|
||||
/* stylesheet uses hardcoded px — changing :root font-size alone only affects */
|
||||
/* the small number of em/rem values. */
|
||||
:root[data-font-size="small"]{--message-body-font-size:12px;--message-code-font-size:10.5px;--message-pre-code-font-size:11px;--message-table-font-size:11px;font-size:12px;}
|
||||
:root[data-font-size="large"]{--message-body-font-size:16px;--message-code-font-size:14.5px;--message-pre-code-font-size:15px;--message-table-font-size:14px;font-size:16px;}
|
||||
:root[data-font-size="xlarge"]{--message-body-font-size:18px;--message-code-font-size:16px;--message-pre-code-font-size:16.5px;--message-table-font-size:16px;font-size:18px;}
|
||||
|
||||
/* Sidebar session list */
|
||||
:root[data-font-size="small"] .session-item { font-size: 11px; }
|
||||
@@ -475,86 +479,6 @@
|
||||
:root.dark[data-skin="nous"] .mcp-http{color:#7EB6E0;}
|
||||
:root.dark[data-skin="nous"] .mcp-status-active{color:#7EB6E0;}
|
||||
|
||||
/* ═══ NEON — cyberpunk theme (dark-first) ═══ */
|
||||
:root[data-skin="neon"]{
|
||||
--bg:#F8F7FC;--sidebar:#F0EEF8;--border:#D8D4EC;--border2:rgba(0,0,0,0.12);
|
||||
--text:#1E1B2E;--muted:#6E6888;--accent:#9333EA;--blue:#7C3AED;--gold:#A855F7;--code-bg:#F0EDF8;
|
||||
--surface:#FFFFFF;--topbar-bg:rgba(248,247,252,.98);--main-bg:rgba(248,247,252,0.5);
|
||||
--focus-ring:rgba(147,51,234,.3);--focus-glow:rgba(147,51,234,.08);
|
||||
--input-bg:rgba(147,51,234,.03);--hover-bg:rgba(147,51,234,.05);
|
||||
--strong:#0D0B14;--em:#7C3AED;--code-text:#6D28D9;--code-inline-bg:rgba(147,51,234,.08);--pre-text:#1E1B2E;
|
||||
--accent-hover:#7E22CE;--accent-bg:rgba(147,51,234,0.06);--accent-bg-strong:rgba(147,51,234,0.12);--accent-text:#6D28D9;
|
||||
--error:#DC2626;--success:#059669;--warning:#D97706;--info:#7C3AED;
|
||||
--radius-sm:2px;--radius-md:4px;--radius-card:4px;--radius-lg:6px;
|
||||
--font-ui:system-ui,-apple-system,sans-serif;
|
||||
--surface-subtle:rgba(147,51,234,.025);--surface-subtle-hover:rgba(147,51,234,.05);
|
||||
--border-subtle:rgba(147,51,234,.08);--border-muted:rgba(147,51,234,.12);
|
||||
--purple:#9333EA;
|
||||
}
|
||||
:root.dark[data-skin="neon"]{
|
||||
--bg:#08060d;--sidebar:#06040a;--border:#1c1633;--border2:rgba(179,71,255,0.15);
|
||||
--text:#d8d6f0;--muted:#6a6588;--accent:#b347ff;--blue:#a78bfa;--gold:#c084fc;--code-bg:#050308;
|
||||
--surface:#0f0d1a;--topbar-bg:rgba(8,6,13,.98);--main-bg:rgba(8,6,13,0.5);
|
||||
--focus-ring:rgba(179,71,255,.35);--focus-glow:rgba(179,71,255,.10);
|
||||
--input-bg:rgba(179,71,255,.04);--hover-bg:rgba(179,71,255,.06);
|
||||
--strong:#f0edff;--em:#00ddff;--code-text:#c8c0f0;--code-inline-bg:rgba(179,71,255,.10);--pre-text:#d8d6f0;
|
||||
--accent-hover:#c76bff;--accent-bg:rgba(179,71,255,0.10);--accent-bg-strong:rgba(179,71,255,0.20);--accent-text:#c8a0ff;
|
||||
--error:#ff4466;--success:#00dd88;--warning:#ffaa33;--info:#00ddff;
|
||||
--surface-subtle:rgba(179,71,255,.03);--surface-subtle-hover:rgba(179,71,255,.06);
|
||||
--border-subtle:rgba(179,71,255,.08);--border-muted:rgba(179,71,255,.12);
|
||||
--purple:#b347ff;
|
||||
}
|
||||
/* ── Neon skin: component-level overrides ── */
|
||||
/* Selection glow */
|
||||
:root.dark[data-skin="neon"] ::selection{background:rgba(179,71,255,0.35);color:#fff;}
|
||||
/* Topbar: purple bottom border with glow */
|
||||
:root.dark[data-skin="neon"] .app-titlebar{border-bottom-color:rgba(179,71,255,0.2);}
|
||||
/* Sidebar: subtle purple inset shadow */
|
||||
:root.dark[data-skin="neon"] .sidebar{box-shadow:inset -1px 0 30px rgba(179,71,255,0.03);}
|
||||
/* Inputs: purple glow on focus */
|
||||
:root.dark[data-skin="neon"] textarea:focus,
|
||||
:root.dark[data-skin="neon"] input[type="text"]:focus,
|
||||
:root.dark[data-skin="neon"] input[type="password"]:focus,
|
||||
:root.dark[data-skin="neon"] select:focus{box-shadow:0 0 16px rgba(179,71,255,0.2);}
|
||||
/* Send button: neon glow */
|
||||
:root.dark[data-skin="neon"] button.send-btn{box-shadow:0 0 14px rgba(179,71,255,0.25);}
|
||||
:root.dark[data-skin="neon"] button.send-btn:hover{box-shadow:0 0 24px rgba(179,71,255,0.45);}
|
||||
/* New chat button: purple gradient */
|
||||
:root.dark[data-skin="neon"] .new-chat-btn{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600;box-shadow:0 0 12px rgba(179,71,255,0.2);}
|
||||
:root.dark[data-skin="neon"] .new-chat-btn:hover{background:var(--accent-hover);border-color:var(--accent-hover);color:#fff;box-shadow:0 0 20px rgba(179,71,255,0.35);}
|
||||
/* Scrollbar: purple */
|
||||
:root.dark[data-skin="neon"]::-webkit-scrollbar-thumb{background:rgba(179,71,255,.18);border-radius:3px;}
|
||||
:root.dark[data-skin="neon"]::-webkit-scrollbar-thumb:hover{background:rgba(179,71,255,.30);}
|
||||
:root.dark[data-skin="neon"]{scrollbar-width:thin;scrollbar-color:rgba(179,71,255,.18) transparent;}
|
||||
/* Tool cards */
|
||||
:root.dark[data-skin="neon"] .tool-card{background:rgba(179,71,255,0.03);border-color:rgba(179,71,255,0.08);}
|
||||
:root.dark[data-skin="neon"] .tool-card:hover{border-color:rgba(179,71,255,0.2);}
|
||||
:root.dark[data-skin="neon"] .tool-card-running{background:var(--accent-bg);border-color:var(--accent-bg-strong);}
|
||||
:root.dark[data-skin="neon"] .tool-card-name{color:var(--muted);font-weight:500;}
|
||||
:root.dark[data-skin="neon"] .tool-arg-key,
|
||||
:root.dark[data-skin="neon"] .tool-card-more{color:var(--accent-text);}
|
||||
:root.dark[data-skin="neon"] .tool-card-running-dot{background:var(--accent);}
|
||||
/* Tree viewer: purple family */
|
||||
:root.dark[data-skin="neon"] .tree-key{color:#b347ff;}
|
||||
:root.dark[data-skin="neon"] .tree-str{color:#c8a0ff;}
|
||||
:root.dark[data-skin="neon"] .tree-num{color:#00ddff;}
|
||||
:root.dark[data-skin="neon"] .tree-bool{color:#b347ff;}
|
||||
/* Session badges/tags */
|
||||
:root.dark[data-skin="neon"] .session-child-count{background:rgba(179,71,255,.16);color:#c8a0ff;}
|
||||
:root.dark[data-skin="neon"] .session-child-count:hover{background:rgba(179,71,255,.26);color:#d8c0ff;}
|
||||
:root.dark[data-skin="neon"] .session-tree-badge{background:rgba(179,71,255,.2);color:#c8a0ff;}
|
||||
:root.dark[data-skin="neon"] .session-tag{background:rgba(179,71,255,.2);color:#c8a0ff;}
|
||||
/* Diff blocks */
|
||||
:root.dark[data-skin="neon"] .diff-block .diff-plus{color:#b347ff;}
|
||||
/* MCP status */
|
||||
:root.dark[data-skin="neon"] .mcp-http{color:#c8a0ff;}
|
||||
:root.dark[data-skin="neon"] .mcp-status-active{color:#00dd88;}
|
||||
/* Image lightbox */
|
||||
:root.dark[data-skin="neon"] .img-lightbox-close:hover{background:rgba(179,71,255,.35);}
|
||||
/* Links: cyan with hover glow */
|
||||
:root.dark[data-skin="neon"] a{color:var(--info);transition:text-shadow 0.2s;}
|
||||
:root.dark[data-skin="neon"] a:hover{text-shadow:0 0 8px rgba(0,221,255,0.4);}
|
||||
|
||||
/* ── Skin: Geist Contrast (Geist-inspired neutral precision) ──
|
||||
Dark mode uses a #FFF175 accent; solid accent controls use black
|
||||
foreground, while selected/navigation states stay neutral and restrained. */
|
||||
@@ -755,6 +679,86 @@
|
||||
:root.dark[data-skin="zeus"] .kanban-modal-overlay{background:rgba(0,0,0,.72);}
|
||||
:root.dark[data-skin="zeus"] .kanban-board-switcher-menu{background:linear-gradient(180deg,rgba(24,24,24,.99),rgba(15,15,15,.99));border-color:rgba(255,215,0,0.15);}
|
||||
|
||||
/* ═══ NEON — cyberpunk theme (dark-first) ═══ */
|
||||
:root[data-skin="neon"]{
|
||||
--bg:#F8F7FC;--sidebar:#F0EEF8;--border:#D8D4EC;--border2:rgba(0,0,0,0.12);
|
||||
--text:#1E1B2E;--muted:#6E6888;--accent:#9333EA;--blue:#7C3AED;--gold:#A855F7;--code-bg:#F0EDF8;
|
||||
--surface:#FFFFFF;--topbar-bg:rgba(248,247,252,.98);--main-bg:rgba(248,247,252,0.5);
|
||||
--focus-ring:rgba(147,51,234,.3);--focus-glow:rgba(147,51,234,.08);
|
||||
--input-bg:rgba(147,51,234,.03);--hover-bg:rgba(147,51,234,.05);
|
||||
--strong:#0D0B14;--em:#7C3AED;--code-text:#6D28D9;--code-inline-bg:rgba(147,51,234,.08);--pre-text:#1E1B2E;
|
||||
--accent-hover:#7E22CE;--accent-bg:rgba(147,51,234,0.06);--accent-bg-strong:rgba(147,51,234,0.12);--accent-text:#6D28D9;
|
||||
--error:#DC2626;--success:#059669;--warning:#D97706;--info:#7C3AED;
|
||||
--radius-sm:2px;--radius-md:4px;--radius-card:4px;--radius-lg:6px;
|
||||
--font-ui:system-ui,-apple-system,sans-serif;
|
||||
--surface-subtle:rgba(147,51,234,.025);--surface-subtle-hover:rgba(147,51,234,.05);
|
||||
--border-subtle:rgba(147,51,234,.08);--border-muted:rgba(147,51,234,.12);
|
||||
--purple:#9333EA;
|
||||
}
|
||||
:root.dark[data-skin="neon"]{
|
||||
--bg:#08060d;--sidebar:#06040a;--border:#1c1633;--border2:rgba(179,71,255,0.15);
|
||||
--text:#d8d6f0;--muted:#6a6588;--accent:#b347ff;--blue:#a78bfa;--gold:#c084fc;--code-bg:#050308;
|
||||
--surface:#0f0d1a;--topbar-bg:rgba(8,6,13,.98);--main-bg:rgba(8,6,13,0.5);
|
||||
--focus-ring:rgba(179,71,255,.35);--focus-glow:rgba(179,71,255,.10);
|
||||
--input-bg:rgba(179,71,255,.04);--hover-bg:rgba(179,71,255,.06);
|
||||
--strong:#f0edff;--em:#00ddff;--code-text:#c8c0f0;--code-inline-bg:rgba(179,71,255,.10);--pre-text:#d8d6f0;
|
||||
--accent-hover:#c76bff;--accent-bg:rgba(179,71,255,0.10);--accent-bg-strong:rgba(179,71,255,0.20);--accent-text:#c8a0ff;
|
||||
--error:#ff4466;--success:#00dd88;--warning:#ffaa33;--info:#00ddff;
|
||||
--surface-subtle:rgba(179,71,255,.03);--surface-subtle-hover:rgba(179,71,255,.06);
|
||||
--border-subtle:rgba(179,71,255,.08);--border-muted:rgba(179,71,255,.12);
|
||||
--purple:#b347ff;
|
||||
}
|
||||
/* ── Neon skin: component-level overrides ── */
|
||||
/* Selection glow */
|
||||
:root.dark[data-skin="neon"] ::selection{background:rgba(179,71,255,0.35);color:#fff;}
|
||||
/* Topbar: purple bottom border with glow */
|
||||
:root.dark[data-skin="neon"] .app-titlebar{border-bottom-color:rgba(179,71,255,0.2);}
|
||||
/* Sidebar: subtle purple inset shadow */
|
||||
:root.dark[data-skin="neon"] .sidebar{box-shadow:inset -1px 0 30px rgba(179,71,255,0.03);}
|
||||
/* Inputs: purple glow on focus */
|
||||
:root.dark[data-skin="neon"] textarea:focus,
|
||||
:root.dark[data-skin="neon"] input[type="text"]:focus,
|
||||
:root.dark[data-skin="neon"] input[type="password"]:focus,
|
||||
:root.dark[data-skin="neon"] select:focus{box-shadow:0 0 16px rgba(179,71,255,0.2);}
|
||||
/* Send button: neon glow */
|
||||
:root.dark[data-skin="neon"] button.send-btn{box-shadow:0 0 14px rgba(179,71,255,0.25);}
|
||||
:root.dark[data-skin="neon"] button.send-btn:hover{box-shadow:0 0 24px rgba(179,71,255,0.45);}
|
||||
/* New chat button: purple gradient */
|
||||
:root.dark[data-skin="neon"] .new-chat-btn{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600;box-shadow:0 0 12px rgba(179,71,255,0.2);}
|
||||
:root.dark[data-skin="neon"] .new-chat-btn:hover{background:var(--accent-hover);border-color:var(--accent-hover);color:#fff;box-shadow:0 0 20px rgba(179,71,255,0.35);}
|
||||
/* Scrollbar: purple */
|
||||
:root.dark[data-skin="neon"]::-webkit-scrollbar-thumb{background:rgba(179,71,255,.18);border-radius:3px;}
|
||||
:root.dark[data-skin="neon"]::-webkit-scrollbar-thumb:hover{background:rgba(179,71,255,.30);}
|
||||
:root.dark[data-skin="neon"]{scrollbar-width:thin;scrollbar-color:rgba(179,71,255,.18) transparent;}
|
||||
/* Tool cards */
|
||||
:root.dark[data-skin="neon"] .tool-card{background:rgba(179,71,255,0.03);border-color:rgba(179,71,255,0.08);}
|
||||
:root.dark[data-skin="neon"] .tool-card:hover{border-color:rgba(179,71,255,0.2);}
|
||||
:root.dark[data-skin="neon"] .tool-card-running{background:var(--accent-bg);border-color:var(--accent-bg-strong);}
|
||||
:root.dark[data-skin="neon"] .tool-card-name{color:var(--muted);font-weight:500;}
|
||||
:root.dark[data-skin="neon"] .tool-arg-key,
|
||||
:root.dark[data-skin="neon"] .tool-card-more{color:var(--accent-text);}
|
||||
:root.dark[data-skin="neon"] .tool-card-running-dot{background:var(--accent);}
|
||||
/* Tree viewer: purple family */
|
||||
:root.dark[data-skin="neon"] .tree-key{color:#b347ff;}
|
||||
:root.dark[data-skin="neon"] .tree-str{color:#c8a0ff;}
|
||||
:root.dark[data-skin="neon"] .tree-num{color:#00ddff;}
|
||||
:root.dark[data-skin="neon"] .tree-bool{color:#b347ff;}
|
||||
/* Session badges/tags */
|
||||
:root.dark[data-skin="neon"] .session-child-count{background:rgba(179,71,255,.16);color:#c8a0ff;}
|
||||
:root.dark[data-skin="neon"] .session-child-count:hover{background:rgba(179,71,255,.26);color:#d8c0ff;}
|
||||
:root.dark[data-skin="neon"] .session-tree-badge{background:rgba(179,71,255,.2);color:#c8a0ff;}
|
||||
:root.dark[data-skin="neon"] .session-tag{background:rgba(179,71,255,.2);color:#c8a0ff;}
|
||||
/* Diff blocks */
|
||||
:root.dark[data-skin="neon"] .diff-block .diff-plus{color:#b347ff;}
|
||||
/* MCP status */
|
||||
:root.dark[data-skin="neon"] .mcp-http{color:#c8a0ff;}
|
||||
:root.dark[data-skin="neon"] .mcp-status-active{color:#00dd88;}
|
||||
/* Image lightbox */
|
||||
:root.dark[data-skin="neon"] .img-lightbox-close:hover{background:rgba(179,71,255,.35);}
|
||||
/* Links: cyan with hover glow */
|
||||
:root.dark[data-skin="neon"] a{color:var(--info);transition:text-shadow 0.2s;}
|
||||
:root.dark[data-skin="neon"] a:hover{text-shadow:0 0 8px rgba(0,221,255,0.4);}
|
||||
|
||||
/* ═══ VERDIGRIS — emerald/bronze dashboard palette (dark-only) ═══ */
|
||||
:root.dark[data-skin="verdigris"]{
|
||||
--bg:#0F1714;--sidebar:#121D18;--border:#22342C;--border2:rgba(200,154,90,0.10);
|
||||
@@ -834,6 +838,7 @@
|
||||
:root:not(.dark) *{scrollbar-color:rgba(0,0,0,.15) transparent;}
|
||||
/* ── Light mode: sidebar, roles, chips, active states ── */
|
||||
:root:not(.dark) .session-item{color:var(--muted);}
|
||||
:root:not(.dark) .session-item:hover{background:var(--hover-bg);color:var(--text);}
|
||||
:root:not(.dark) .session-item.active{background:var(--accent-bg);color:var(--accent-text);}
|
||||
:root:not(.dark) .session-item.active .session-title{color:var(--accent-text);}
|
||||
:root:not(.dark) .session-pin-indicator{color:var(--accent-text);}
|
||||
@@ -1014,106 +1019,25 @@
|
||||
.session-item.attention-approval{box-shadow:inset 3px 0 0 var(--error);background:color-mix(in srgb,var(--error) 9%,transparent);}
|
||||
.session-item.attention-clarify{box-shadow:inset 3px 0 0 var(--warning);background:color-mix(in srgb,var(--warning) 8%,transparent);}
|
||||
@media (hover:hover){.session-item:hover{background:var(--hover-bg);color:var(--text);}}
|
||||
.session-item.loading{background:var(--hover-bg);color:var(--text);}
|
||||
.session-item.long-pressing{
|
||||
background:var(--hover-bg);
|
||||
color:var(--text);
|
||||
box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 38%,transparent);
|
||||
animation:session-long-press .4s cubic-bezier(.2,.8,.2,1) both;
|
||||
}
|
||||
.session-item.long-pressing{background:var(--hover-bg);color:var(--text);box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 38%,transparent);animation:session-long-press .4s cubic-bezier(.2,.8,.2,1) both;}
|
||||
.session-item.swiping-right{background:color-mix(in srgb,var(--warning) 16%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--warning) 48%,transparent);}
|
||||
.session-item.swiping-left{background:color-mix(in srgb,var(--error) 14%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--error) 48%,transparent);}
|
||||
.session-swipe-affordance{
|
||||
position:absolute;
|
||||
top:0;
|
||||
bottom:0;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
gap:0;
|
||||
width:var(--session-swipe-reveal,0px);
|
||||
padding:0;
|
||||
box-sizing:border-box;
|
||||
opacity:0;
|
||||
overflow:hidden;
|
||||
transform:translate3d(calc(-1 * var(--session-swipe-offset,0px)),0,0);
|
||||
pointer-events:none;
|
||||
z-index:0;
|
||||
}
|
||||
.session-swipe-affordance-right{
|
||||
left:0;
|
||||
color:#fff;
|
||||
background:transparent;
|
||||
border-radius:0;
|
||||
align-items:flex-start;
|
||||
transform-origin:left center;
|
||||
--session-swipe-action-color:var(--warning);
|
||||
}
|
||||
.session-swipe-affordance-left{
|
||||
right:0;
|
||||
color:#fff;
|
||||
background:transparent;
|
||||
border-radius:0;
|
||||
align-items:flex-end;
|
||||
transform-origin:right center;
|
||||
--session-swipe-action-color:var(--error);
|
||||
}
|
||||
.session-item.swiping-right .session-swipe-affordance-right,
|
||||
.session-item.swiping-left .session-swipe-affordance-left{
|
||||
opacity:var(--session-swipe-progress,0);
|
||||
}
|
||||
.session-swipe-affordance{position:absolute;top:0;bottom:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0;width:var(--session-swipe-reveal,0px);padding:0;box-sizing:border-box;opacity:0;overflow:hidden;transform:translate3d(calc(-1 * var(--session-swipe-offset,0px)),0,0);pointer-events:none;z-index:0;}
|
||||
.session-swipe-affordance-right{left:0;color:#fff;background:transparent;border-radius:0;align-items:flex-start;transform-origin:left center;--session-swipe-action-color:var(--warning);}
|
||||
.session-swipe-affordance-left{right:0;color:#fff;background:transparent;border-radius:0;align-items:flex-end;transform-origin:right center;--session-swipe-action-color:var(--error);}
|
||||
.session-item.swiping-right .session-swipe-affordance-right,.session-item.swiping-left .session-swipe-affordance-left{opacity:var(--session-swipe-progress,0);}
|
||||
.session-text,.session-state-indicator,.session-actions{z-index:1;}
|
||||
.session-swipe-action-stack{
|
||||
width:calc(var(--session-swipe-badge-size,34px) + var(--session-swipe-badge-stretch,0px));
|
||||
max-width:100%;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
align-items:center;
|
||||
gap:3px;
|
||||
flex:0 0 auto;
|
||||
}
|
||||
.session-swipe-badge{
|
||||
width:calc(var(--session-swipe-badge-size,34px) + var(--session-swipe-badge-stretch,0px));
|
||||
height:var(--session-swipe-badge-size,34px);
|
||||
border-radius:999px;
|
||||
background:var(--session-swipe-action-color);
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
color:#fff;
|
||||
flex:0 0 auto;
|
||||
overflow:hidden;
|
||||
}
|
||||
.session-swipe-badge svg{
|
||||
display:block;
|
||||
width:var(--session-swipe-icon-size,18px);
|
||||
height:var(--session-swipe-icon-size,18px);
|
||||
stroke-width:2.2;
|
||||
}
|
||||
.session-swipe-label{
|
||||
min-width:58px;
|
||||
width:100%;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
text-align:center;
|
||||
color:var(--muted);
|
||||
font-size:10px;
|
||||
font-weight:600;
|
||||
line-height:1;
|
||||
transform:scale(var(--session-swipe-label-scale,1));
|
||||
transform-origin:top center;
|
||||
}
|
||||
.session-swipe-action-stack{width:calc(var(--session-swipe-badge-size,34px) + var(--session-swipe-badge-stretch,0px));max-width:100%;display:flex;flex-direction:column;align-items:center;gap:3px;flex:0 0 auto;}
|
||||
.session-swipe-badge{width:calc(var(--session-swipe-badge-size,34px) + var(--session-swipe-badge-stretch,0px));height:var(--session-swipe-badge-size,34px);border-radius:999px;background:var(--session-swipe-action-color);display:flex;align-items:center;justify-content:center;color:#fff;flex:0 0 auto;overflow:hidden;}
|
||||
.session-swipe-badge svg{display:block;width:var(--session-swipe-icon-size,18px);height:var(--session-swipe-icon-size,18px);stroke-width:2.2;}
|
||||
.session-swipe-label{min-width:58px;width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:center;color:var(--muted);font-size:10px;font-weight:600;line-height:1;transform:scale(var(--session-swipe-label-scale,1));transform-origin:top center;}
|
||||
.session-swipe-affordance-right .session-swipe-action-stack{transform:translateX(6px);}
|
||||
.session-swipe-affordance-left .session-swipe-action-stack{transform:translateX(-6px);}
|
||||
.session-item.dragging{transition:background .15s,color .15s,box-shadow .15s ease;will-change:transform;}
|
||||
.session-item.session-reflowing{transition:background .15s,color .15s,transform .36s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease;will-change:transform;}
|
||||
.session-item.swipe-committed,
|
||||
.session-item.swipe-removing{transition:background .15s,color .15s,transform .5s cubic-bezier(.22,.61,.36,1),box-shadow .15s ease;will-change:transform;}
|
||||
.session-item.swipe-removing{
|
||||
overflow:hidden;
|
||||
}
|
||||
.session-item.swipe-removing{overflow:hidden;}
|
||||
.session-item.session-swipe-returning{animation:sessionSwipeReturn .5s cubic-bezier(.22,.61,.36,1) both;will-change:transform,opacity;}
|
||||
.session-item.swipe-committed .session-swipe-affordance{transition:opacity .18s ease,transform .18s ease;}
|
||||
/* Suppress hover highlight during drag to avoid visual noise mid-scroll */
|
||||
@@ -1129,8 +1053,7 @@
|
||||
.session-item.active.swiping-right{background:color-mix(in srgb,var(--warning) 20%,var(--accent-bg));}
|
||||
.session-item.active.swiping-left{background:color-mix(in srgb,var(--error) 18%,var(--accent-bg));}
|
||||
.session-item.archived .session-swipe-affordance-right{--session-swipe-action-color:var(--success);}
|
||||
.session-item.archived.swiping-right,
|
||||
.session-item.archived.dragging.swiping-right{background:color-mix(in srgb,var(--success) 16%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--success) 48%,transparent);}
|
||||
.session-item.archived.swiping-right,.session-item.archived.dragging.swiping-right{background:color-mix(in srgb,var(--success) 16%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--success) 48%,transparent);}
|
||||
.session-item.active.archived.swiping-right{background:color-mix(in srgb,var(--success) 20%,var(--accent-bg));}
|
||||
.session-item.streaming .session-title{color:var(--accent);}
|
||||
.session-item.streaming .session-title-row{color:var(--text);}
|
||||
@@ -1145,29 +1068,13 @@
|
||||
.session-item.active .session-search-preview{color:var(--accent-text);opacity:.72;}
|
||||
.session-item.active .session-search-hit{background:var(--accent-bg-strong);color:var(--accent-text);}
|
||||
.session-item.active .session-meta{color:var(--accent-text);opacity:.8;}
|
||||
/* Attention dot variants — see the attention-dot block below the base
|
||||
.session-state-indicator rule. */
|
||||
.session-state-indicator{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:10px;height:10px;color:var(--accent);visibility:hidden;}
|
||||
.session-attention-indicator{position:absolute;right:6px;top:50%;transform:translateY(-50%);width:26px;height:26px;z-index:1;pointer-events:none;transition:opacity .15s ease,visibility .15s ease;}
|
||||
.session-item:hover .session-attention-indicator,.session-item:focus-within .session-attention-indicator,.session-item.menu-open .session-attention-indicator{opacity:0;visibility:hidden;}
|
||||
.session-state-indicator.is-streaming,.session-state-indicator.is-unread{visibility:visible;}
|
||||
/* Attention state dot: NO text badge — the right-side status dot itself
|
||||
carries the attention color (red=approval, amber/warning=clarify), and the
|
||||
row keeps its colored left rail + tinted background. Visible even when the
|
||||
session is not streaming/unread. Theme-driven via --error / --warning. */
|
||||
.session-state-indicator.is-attention-approval,
|
||||
.session-state-indicator.is-attention-clarify,
|
||||
.session-state-indicator.is-attention-generic{visibility:visible;}
|
||||
.session-state-indicator.is-streaming,.session-state-indicator.is-unread,.session-state-indicator.is-attention-approval,.session-state-indicator.is-attention-clarify,.session-state-indicator.is-attention-generic{visibility:visible;}
|
||||
.session-state-indicator.is-attention-approval{color:var(--error);}
|
||||
.session-state-indicator.is-attention-clarify,
|
||||
.session-state-indicator.is-attention-clarify{color:var(--warning);}
|
||||
.session-state-indicator.is-attention-generic{color:var(--warning);}
|
||||
.session-state-indicator.is-attention-approval:not(.is-streaming):not(.is-unread)::before,
|
||||
.session-state-indicator.is-attention-clarify:not(.is-streaming):not(.is-unread)::before,
|
||||
.session-state-indicator.is-attention-generic:not(.is-streaming):not(.is-unread)::before{
|
||||
content:"";display:block;width:8px;height:8px;border-radius:50%;background:currentColor;
|
||||
}
|
||||
/* Approval is the more urgent state — gently pulse its dot. */
|
||||
.session-state-indicator.is-attention-approval:not(.is-streaming):not(.is-unread)::before{animation:sessionAttentionPulse 1.6s ease-in-out infinite;}
|
||||
.session-state-indicator::before{content:"";display:block;flex-shrink:0;}
|
||||
.session-state-indicator.is-streaming::before{
|
||||
width:100%;
|
||||
@@ -1184,6 +1091,13 @@
|
||||
border-radius:50%;
|
||||
background:currentColor;
|
||||
}
|
||||
.session-state-indicator.is-attention-approval::before,.session-state-indicator.is-attention-clarify::before,.session-state-indicator.is-attention-generic::before{
|
||||
width:8px;
|
||||
height:8px;
|
||||
border-radius:50%;
|
||||
background:currentColor;
|
||||
box-shadow:0 0 0 3px color-mix(in srgb,currentColor 18%,transparent);
|
||||
}
|
||||
.session-attention-indicator.is-streaming::before{
|
||||
width:10px;
|
||||
height:10px;
|
||||
@@ -1216,6 +1130,7 @@
|
||||
.session-actions-trigger svg{display:block;}
|
||||
.session-action-menu{display:block;position:fixed;left:0;top:0;right:auto;bottom:auto;min-width:220px;max-width:min(280px,calc(100vw - 16px));background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:999;overflow:hidden;max-height:calc(100vh - 16px);overflow-y:auto;transform-origin:top right;will-change:opacity,transform;}
|
||||
.session-action-menu.open-animated{animation:session-menu-in .45s cubic-bezier(.2,.8,.2,1);}
|
||||
.session-action-menu.open{display:block;}
|
||||
.session-action-opt{width:100%;background:none;border:none;text-align:left;font:inherit;color:var(--text);flex-direction:row!important;gap:0!important;padding:0!important;}
|
||||
.session-action-opt .ws-opt-action{display:flex;flex-direction:row;align-items:center;gap:10px;width:100%;padding:8px 14px;}
|
||||
.session-action-opt .ws-opt-icon{color:var(--muted);transition:color .12s,opacity .12s;flex-shrink:0;display:flex;align-items:center;width:16px;}
|
||||
@@ -1679,7 +1594,7 @@
|
||||
.role-icon{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;flex-shrink:0;}
|
||||
.role-icon.user{background:var(--accent-bg);color:var(--accent-text);border:1px solid var(--accent-bg-strong);}
|
||||
.role-icon.assistant{background:var(--accent-bg-strong);color:var(--accent-text);border:1px solid var(--accent-bg-strong);}
|
||||
.msg-body{font-size:14px;line-height:1.75;color:var(--text);padding-left:30px;max-width:680px;overflow-wrap:anywhere;}
|
||||
.msg-body{font-size:var(--message-body-font-size);line-height:var(--message-body-line-height);color:var(--text);padding-left:30px;max-width:680px;overflow-wrap:anywhere;}
|
||||
.msg-body p{margin-bottom:10px;}.msg-body p:last-child{margin-bottom:0;}
|
||||
.msg-body td p,.msg-body th p{margin:0;}
|
||||
.msg-body ul,.msg-body ol{margin:6px 0 10px 20px;}.msg-body li{margin-bottom:3px;}
|
||||
@@ -1695,9 +1610,9 @@
|
||||
.msg-body > h1:first-child,.msg-body > h2:first-child,.msg-body > h3:first-child,
|
||||
.msg-body > h4:first-child,.msg-body > h5:first-child,.msg-body > h6:first-child{margin-top:0;}
|
||||
.msg-body strong{color:var(--strong);font-weight:600;}.msg-body em{color:var(--em);font-style:italic;}
|
||||
.msg-body code{font-family:"SF Mono","Fira Code",ui-monospace,monospace;font-size:12.5px;background:var(--code-inline-bg);padding:1px 5px;border-radius:4px;color:var(--code-text);}
|
||||
.msg-body code{font-family:"SF Mono","Fira Code",ui-monospace,monospace;font-size:var(--message-code-font-size);background:var(--code-inline-bg);padding:1px 5px;border-radius:4px;color:var(--code-text);}
|
||||
.msg-body pre{background:var(--code-bg);border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;margin:10px 0;}
|
||||
.msg-body pre code{background:none;padding:0;border-radius:0;color:var(--pre-text);font-size:13px;line-height:1.6;}
|
||||
.msg-body pre code{background:none;padding:0;border-radius:0;color:var(--pre-text);font-size:var(--message-pre-code-font-size);line-height:1.6;}
|
||||
.provider-error-details{margin:12px 0 0;border:1px solid var(--border);border-radius:10px;background:var(--surface);overflow:hidden;}
|
||||
.provider-error-details>summary{cursor:pointer;color:var(--muted);font-size:12px;font-weight:600;padding:8px 12px;}
|
||||
.provider-error-details>pre{margin:0;border:0;border-top:1px solid var(--border);border-radius:0;max-height:220px;}
|
||||
@@ -2797,22 +2712,24 @@ body.resizing .sidebar{transition:none!important;}
|
||||
|
||||
/* ── Tool call cards ── */
|
||||
/* Running indicator dot (pulsing) */
|
||||
@keyframes wlpulse{0%,100%{box-shadow:0 0 0 0 var(--accent-bg-strong);}50%{box-shadow:0 0 0 5px rgba(255,215,0,0);}}
|
||||
.tool-card-running-dot{
|
||||
display:inline-block;
|
||||
width:7px;
|
||||
height:7px;
|
||||
border-radius:50%;
|
||||
background:var(--blue);
|
||||
opacity:.8;
|
||||
background:var(--accent);
|
||||
opacity:1;
|
||||
flex-shrink:0;
|
||||
vertical-align:middle;
|
||||
animation:pulse 1.2s ease-in-out infinite;
|
||||
animation:wlpulse 1.3s ease-in-out infinite;
|
||||
}
|
||||
.tl-rundot{width:6px;height:6px;border-radius:50%;background:var(--accent);flex-shrink:0;animation:wlpulse 1.3s ease-in-out infinite;}
|
||||
/* Show more button inside tool card result */
|
||||
.tool-card-more{background:none;border:none;color:var(--blue);font-size:10px;cursor:pointer;padding:3px 0 0;opacity:.7;display:block;}
|
||||
.tool-card-more:hover{opacity:1;}
|
||||
/* Subagent cards: indented with accent border */
|
||||
.tool-card.tool-card-subagent{border-left:2px solid var(--accent-bg);margin-left:8px;}
|
||||
.tool-card.tool-card-subagent{border-left:0;margin-left:0;}
|
||||
/* Token usage badge below assistant messages */
|
||||
.msg-usage{font-size:11px;color:var(--muted);opacity:.6;margin-top:2px;padding-left:42px;}
|
||||
.msg-usage:hover{opacity:1;}
|
||||
@@ -2840,13 +2757,283 @@ body.resizing .sidebar{transition:none!important;}
|
||||
.skill-linked-file:hover{background:var(--hover-bg);}
|
||||
.tool-card-row{margin:0;padding:1px 0;}
|
||||
.tool-call-group{margin:4px 0 4px var(--msg-rail);max-width:var(--msg-max);border-left:1px solid var(--border-subtle);}
|
||||
.tool-call-group-summary{width:100%;display:flex;align-items:center;gap:var(--space-2);padding:var(--space-1) var(--space-3);border:0;background:transparent;color:var(--muted);cursor:pointer;text-align:left;font:inherit;font-size:var(--font-size-xs);line-height:1.4;border-radius:var(--radius-card);}
|
||||
.tool-call-group-summary:hover{background:var(--surface-subtle-hover);color:var(--text);}
|
||||
.tool-call-group-summary{width:100%;display:flex;align-items:center;gap:7px;padding:3px 8px;margin-left:-8px;border:0;background:transparent;color:var(--muted);cursor:pointer;text-align:left;font:inherit;font-size:13px;line-height:1.45;border-radius:7px;}
|
||||
.tool-call-group-summary:hover{background:var(--hover-bg);color:var(--text);}
|
||||
.tool-call-group-duration{margin-left:auto;opacity:.62;font-variant-numeric:tabular-nums;white-space:nowrap;}
|
||||
.tool-call-group-chevron{opacity:.45;display:inline-flex;transition:transform .16s ease;}
|
||||
.tool-call-group:not(.tool-call-group-collapsed) .tool-call-group-chevron{transform:rotate(90deg);}
|
||||
.tool-call-group-chevron{margin-left:auto;opacity:.4;display:inline-flex;transition:transform .18s ease;}
|
||||
.tool-call-group:not(.tool-call-group-collapsed) .tool-call-group-chevron,
|
||||
.agent-activity-group:not(.tool-call-group-collapsed) .tool-call-group-chevron{transform:rotate(90deg);}
|
||||
.tool-call-group-body{display:block;padding-left:var(--space-3);}
|
||||
.tool-call-group.tool-call-group-collapsed .tool-call-group-body{display:none;}
|
||||
.tool-call-group.tool-call-group-collapsed .tool-call-group-body,
|
||||
.agent-activity-group.tool-call-group-collapsed .tool-call-group-body{display:none;}
|
||||
.activity{padding-left:var(--msg-rail);margin:4px 0 14px;max-width:var(--msg-max);}
|
||||
.tool-worklog-group{
|
||||
margin:4px 0 14px var(--msg-rail);
|
||||
border-left:0;
|
||||
}
|
||||
.live-worklog{
|
||||
margin:4px 0 14px;
|
||||
max-width:var(--msg-max);
|
||||
}
|
||||
.tool-worklog-group.activity{
|
||||
margin:4px 0 14px;
|
||||
}
|
||||
.tool-worklog-summary{
|
||||
width:auto;
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:9px;
|
||||
padding:5px 8px;
|
||||
border-radius:8px;
|
||||
margin:0 0 0 -8px;
|
||||
border:0;
|
||||
color:var(--muted);
|
||||
font-size:var(--message-body-font-size);
|
||||
font-weight:500;
|
||||
line-height:var(--message-body-line-height);
|
||||
letter-spacing:0;
|
||||
}
|
||||
.as-dot{width:7px;height:7px;border-radius:50%;background:var(--muted);opacity:.45;flex-shrink:0;}
|
||||
.tool-worklog-summary .tool-worklog-label{
|
||||
color:var(--muted);
|
||||
font-family:inherit;
|
||||
font-size:var(--message-body-font-size);
|
||||
font-weight:500;
|
||||
line-height:var(--message-body-line-height);
|
||||
letter-spacing:0;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.tool-worklog-summary .tool-call-group-duration{
|
||||
margin-left:2px;
|
||||
flex-shrink:0;
|
||||
}
|
||||
.tool-worklog-summary .tool-call-group-chevron{
|
||||
margin-left:0;
|
||||
}
|
||||
.tool-worklog-running-label{display:none;color:var(--accent-text);font-family:'SF Mono',ui-monospace,monospace;font-size:12px;font-weight:600;line-height:1.4;}
|
||||
.tool-worklog-group[data-tool-worklog-running="1"] .tool-worklog-running-label{display:inline-flex;}
|
||||
.tool-worklog-body{
|
||||
padding-left:0;
|
||||
}
|
||||
.activity-body{overflow:hidden;max-height:0;opacity:0;transition:max-height .34s ease,opacity .22s ease;}
|
||||
.activity.open .activity-body{max-height:none;overflow:visible;opacity:1;margin-top:10px;}
|
||||
.tool-worklog-list{
|
||||
--worklog-rail:24px;
|
||||
margin:3px 0 0 0;
|
||||
padding-left:0;
|
||||
border-left:0;
|
||||
background:transparent;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:1px;
|
||||
}
|
||||
.live-worklog .tool-worklog-list{
|
||||
--worklog-rail:var(--msg-rail);
|
||||
}
|
||||
.wl-step-tools{
|
||||
padding-left:var(--msg-rail);
|
||||
margin-bottom:12px;
|
||||
}
|
||||
.activity-body .wl-reason,
|
||||
.tool-worklog-list > .wl-reason{
|
||||
padding-left:var(--worklog-rail);
|
||||
}
|
||||
.activity-body .wl-step-tools,
|
||||
.tool-worklog-list > .wl-step-tools{
|
||||
padding-left:var(--worklog-rail);
|
||||
}
|
||||
.tool-worklog-list .tool-card-row{
|
||||
margin:0;
|
||||
}
|
||||
.tool-worklog-list .tool-card,
|
||||
:root[data-skin] .tool-worklog-list .tool-card,
|
||||
:root[data-skin] .tool-worklog-list .tool-card:hover,
|
||||
:root[data-skin] .tool-worklog-list .tool-card-running{
|
||||
margin:0;
|
||||
border:0;
|
||||
border-left:0;
|
||||
border-radius:0;
|
||||
background:transparent;
|
||||
}
|
||||
.tool-worklog-list .tool-card-row + .tool-card-row{
|
||||
border-top:0;
|
||||
}
|
||||
.tool-worklog-list .tool-card-header,
|
||||
.tl-head{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
padding:3px 8px;
|
||||
margin-left:-8px;
|
||||
border-radius:7px;
|
||||
min-width:0;
|
||||
overflow:hidden;
|
||||
white-space:nowrap;
|
||||
gap:7px;
|
||||
}
|
||||
.tool-worklog-list .tool-card-name,
|
||||
.tl-verb{
|
||||
flex-shrink:0;
|
||||
color:var(--muted);
|
||||
font-family:inherit;
|
||||
font-size:var(--message-body-font-size);
|
||||
font-weight:400;
|
||||
line-height:var(--message-body-line-height);
|
||||
letter-spacing:0;
|
||||
position:relative;
|
||||
opacity:.56;
|
||||
}
|
||||
.tool-worklog-list .tool-card-title,
|
||||
.tl-title{
|
||||
min-width:0;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
color:var(--muted);
|
||||
font-family:var(--font-mono);
|
||||
font-size:var(--message-body-font-size);
|
||||
font-weight:400;
|
||||
opacity:.42;
|
||||
}
|
||||
.tool-worklog-list .tool-card-header:hover .tool-card-title,
|
||||
.tl-head:hover .tl-title{
|
||||
opacity:.66;
|
||||
}
|
||||
.tool-worklog-list .tool-card-result pre{
|
||||
margin:0;
|
||||
}
|
||||
.tool-worklog-list > .agent-activity-thinking{
|
||||
margin:0;
|
||||
}
|
||||
.tool-worklog-list > .agent-activity-thinking .thinking-card,
|
||||
:root[data-skin] .tool-worklog-list > .agent-activity-thinking .thinking-card,
|
||||
:root[data-skin] .tool-worklog-list > .agent-activity-thinking .thinking-card:hover{
|
||||
margin:0;
|
||||
max-width:none;
|
||||
border:0;
|
||||
border-radius:0;
|
||||
background:transparent;
|
||||
}
|
||||
.tool-worklog-list > .agent-activity-thinking .thinking-card-header{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:7px;
|
||||
padding:3px 8px;
|
||||
margin-left:-8px;
|
||||
border-radius:7px;
|
||||
color:var(--muted);
|
||||
font-size:var(--message-body-font-size);
|
||||
font-weight:400;
|
||||
line-height:var(--message-body-line-height);
|
||||
opacity:.56;
|
||||
}
|
||||
.tool-worklog-list > .agent-activity-thinking .thinking-card-header:hover{
|
||||
background:var(--hover-bg);
|
||||
color:var(--text);
|
||||
opacity:1;
|
||||
}
|
||||
.tool-worklog-list > .agent-activity-thinking .thinking-card-label{
|
||||
font-weight:400;
|
||||
letter-spacing:0;
|
||||
}
|
||||
.tool-worklog-list > .agent-activity-thinking .thinking-card-icon,
|
||||
.tool-worklog-list > .agent-activity-thinking .thinking-copy-btn,
|
||||
.tool-worklog-list > .agent-activity-thinking .thinking-card-toggle{
|
||||
color:var(--muted);
|
||||
opacity:.4;
|
||||
}
|
||||
.tool-worklog-list > .agent-activity-thinking .thinking-card.open .thinking-card-body{
|
||||
margin-top:4px;
|
||||
padding:6px 8px 7px 8px;
|
||||
border-top-color:transparent;
|
||||
border-radius:8px;
|
||||
background:var(--code-bg);
|
||||
}
|
||||
.tool-worklog-list > .agent-activity-thinking .thinking-card-body pre{
|
||||
font-family:var(--font-mono);
|
||||
font-size:var(--message-body-font-size);
|
||||
line-height:var(--message-body-line-height);
|
||||
color:var(--muted);
|
||||
}
|
||||
.wl-reason{
|
||||
color:var(--text);
|
||||
font-size:var(--message-body-font-size);
|
||||
line-height:var(--message-body-line-height);
|
||||
padding-left:var(--msg-rail);
|
||||
margin-bottom:12px;
|
||||
max-width:var(--msg-max);
|
||||
overflow-wrap:anywhere;
|
||||
}
|
||||
.wl-reason p{margin:0 0 6px;}
|
||||
.wl-reason p:last-child{margin-bottom:0;}
|
||||
.wl-reason ul,
|
||||
.wl-reason ol{
|
||||
margin:6px 0 6px 0;
|
||||
padding-left:1.35em;
|
||||
list-style-position:outside;
|
||||
}
|
||||
.wl-reason li{
|
||||
margin:2px 0;
|
||||
padding-left:.15em;
|
||||
}
|
||||
.assistant-segment-worklog-source{
|
||||
display:none!important;
|
||||
}
|
||||
.tool-worklog-tool-group,
|
||||
.tool-group{
|
||||
max-width:560px;
|
||||
margin:0;
|
||||
padding:0;
|
||||
background:transparent;
|
||||
border:0;
|
||||
}
|
||||
.tool-worklog-tool-group-head,
|
||||
.tool-group-head{
|
||||
width:100%;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:7px;
|
||||
padding:3px 8px;
|
||||
margin-left:-8px;
|
||||
border:0;
|
||||
background:transparent;
|
||||
color:var(--muted);
|
||||
cursor:pointer;
|
||||
text-align:left;
|
||||
font:inherit;
|
||||
font-size:var(--message-body-font-size);
|
||||
line-height:var(--message-body-line-height);
|
||||
border-radius:7px;
|
||||
opacity:.68;
|
||||
}
|
||||
.tool-worklog-tool-group-head:hover,
|
||||
.tool-group-head:hover{background:var(--hover-bg);color:var(--muted);opacity:.86;}
|
||||
.tool-worklog-tool-group-label,
|
||||
.tg-sum{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.tool-worklog-tool-group-running,
|
||||
.tg-run{display:none;color:var(--accent-text);font-family:'SF Mono',ui-monospace,monospace;font-size:12px;font-weight:600;line-height:1.4;white-space:nowrap;}
|
||||
.tool-worklog-tool-group-body,
|
||||
.tool-group-body{overflow:hidden;max-height:0;transition:max-height .3s ease;}
|
||||
.tool-worklog-tool-group.open .tool-worklog-tool-group-body,
|
||||
.tool-group.open .tool-group-body{max-height:1600px;}
|
||||
.tool-worklog-tool-group-collapsed .tool-worklog-tool-group-body,
|
||||
.tool-worklog-tool-group-collapsed .tool-group-body{max-height:0;}
|
||||
.tool-worklog-tool-group-collapsed .tool-call-group-chevron,
|
||||
.tool-worklog-tool-group-collapsed .tg-caret{transform:none!important;}
|
||||
.tool-worklog-tool-group.open .tool-call-group-chevron,
|
||||
.tool-group.open .tg-caret{transform:rotate(90deg);}
|
||||
.tool-worklog-tool-group-rows,
|
||||
.tg-rows{
|
||||
margin:3px 0 0 5px;
|
||||
padding-left:14px;
|
||||
border-left:1px solid var(--border-subtle);
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:1px;
|
||||
}
|
||||
.tool-worklog-tool-group-body .tool-card,
|
||||
.tool-group-body .tl{max-width:none;}
|
||||
.agent-activity-status{display:grid;grid-template-columns:18px minmax(0,1fr) auto;align-items:start;gap:var(--space-2);padding:5px 0;color:var(--muted);font-size:var(--font-size-xs);line-height:1.45;border-bottom:1px solid color-mix(in srgb,var(--border-subtle) 60%,transparent);}
|
||||
.agent-activity-status:last-child{border-bottom:0;}
|
||||
.agent-activity-status-icon{display:inline-flex;align-items:center;justify-content:center;min-height:18px;opacity:.72;color:var(--muted);}
|
||||
@@ -2860,58 +3047,39 @@ body.resizing .sidebar{transition:none!important;}
|
||||
.tool-call-group[data-live-tool-call-group="1"] .tool-call-group-label{
|
||||
color:var(--muted);
|
||||
}
|
||||
@keyframes _tool-shimmer-sweep{
|
||||
0%{-webkit-mask-position:150% 0;mask-position:150% 0;}
|
||||
100%{-webkit-mask-position:-150% 0;mask-position:-150% 0;}
|
||||
}
|
||||
.tool-call-group[data-live-tool-call-group="1"] .tool-call-group-label::after{
|
||||
--activity-sweep-highlight:linear-gradient(90deg,var(--accent) 0%,var(--accent) 40.4%,color-mix(in srgb,var(--accent) 90%,#000) 43%,color-mix(in srgb,var(--accent) 90%,#000) 57%,var(--accent) 60%,var(--accent) 100%);
|
||||
--activity-sweep-mask:linear-gradient(90deg,rgba(0,0,0,0) 0%,rgba(0,0,0,0) 26%,rgba(0,0,0,.18) 31.6%,rgba(0,0,0,.46) 37.2%,rgba(0,0,0,.72) 43%,rgba(0,0,0,.9) 57%,rgba(0,0,0,.52) 61.6%,rgba(0,0,0,.28) 66.4%,rgba(0,0,0,.1) 70.8%,rgba(0,0,0,0) 74%,rgba(0,0,0,0) 100%);
|
||||
content:attr(data-sweep-label);
|
||||
position:absolute;inset:0;
|
||||
color:var(--accent);
|
||||
background-image:var(--activity-sweep-highlight);
|
||||
-webkit-background-clip:text;
|
||||
background-clip:text;
|
||||
-webkit-text-fill-color:transparent;
|
||||
pointer-events:none;
|
||||
-webkit-mask-image:var(--activity-sweep-mask);
|
||||
mask-image:var(--activity-sweep-mask);
|
||||
-webkit-mask-size:250% 100%;
|
||||
mask-size:250% 100%;
|
||||
-webkit-mask-repeat:no-repeat;
|
||||
mask-repeat:no-repeat;
|
||||
animation:_tool-shimmer-sweep 3.0s cubic-bezier(.45,0,.55,1) infinite;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
.tool-call-group[data-live-tool-call-group="1"] .tool-call-group-label::after{
|
||||
.tool-card-running-dot{
|
||||
animation:none;
|
||||
display:none;
|
||||
}
|
||||
}
|
||||
/* Fallback for browsers without CSS mask support */
|
||||
@supports not ((mask-image:linear-gradient(#000,#000)) or (-webkit-mask-image:linear-gradient(#000,#000))){
|
||||
.tool-call-group[data-live-tool-call-group="1"] .tool-call-group-label::after{
|
||||
display:none;
|
||||
}
|
||||
}
|
||||
.tool-card{background:var(--surface-subtle);border:1px solid var(--border-muted);border-left:2px solid var(--border-muted);border-radius:var(--radius-card);margin:2px 0;overflow:hidden;transition:border-color .15s,background-color .15s;}
|
||||
.tool-card:hover{border-color:var(--border2);background:var(--surface-subtle-hover);}
|
||||
.tool-card-running{border-color:var(--accent-bg-strong);background:var(--accent-bg);}
|
||||
.tool-card-header{display:flex;align-items:center;gap:var(--space-2);padding:var(--space-1) var(--space-3);cursor:pointer;user-select:none;}
|
||||
.tool-card-icon{font-size:13px;flex-shrink:0;opacity:.65;}
|
||||
|
||||
.tool-card-name{font-size:var(--font-size-xs);font-weight:600;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;flex-shrink:0;}
|
||||
.tool-card-preview{font-size:var(--font-size-xs);color:var(--muted);opacity:.62;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.tool-card-toggle{font-size:10px;color:var(--muted);opacity:.45;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;transform-origin:center;transition:transform .18s ease;will-change:transform;}
|
||||
.tool-card.open .tool-card-toggle{transform:rotate(90deg);}
|
||||
.tool-card-detail{display:block;max-height:0;opacity:0;overflow:hidden;border-top:1px solid transparent;padding:0 var(--space-3);transition:max-height .22s ease,opacity .18s ease,padding .22s ease,border-top-color .22s ease;}
|
||||
.tool-card.open .tool-card-detail{max-height:600px;opacity:1;padding:var(--space-2) var(--space-3);border-top-color:var(--border-subtle);overflow:auto;}
|
||||
.tool-card{background:var(--surface-subtle);border:1px solid var(--border);border-left:2px solid var(--border-muted);border-radius:8px;margin:0;overflow:hidden;transition:border-color .15s,background .15s;}
|
||||
.tl{background:transparent;border:0;border-left:0;border-radius:0;margin:0;overflow:visible;transition:none;}
|
||||
.tool-card.tool-card-subagent{border-left:0;margin-left:0;}
|
||||
.tool-card:hover,.tl:hover{border-color:transparent;background:transparent;}
|
||||
.tool-card-running,.tl.running{border-color:transparent;background:transparent;}
|
||||
.tool-card-header,.tl-head{display:flex;align-items:center;gap:7px;padding:3px 8px;margin-left:-8px;border-radius:7px;cursor:pointer;user-select:none;color:var(--muted);font-size:var(--message-body-font-size);line-height:var(--message-body-line-height);min-width:0;}
|
||||
.tool-card-header:hover,.tl-head:hover{background:var(--hover-bg);color:var(--text);}
|
||||
.tool-card-running .tool-card-header,.tl.running .tl-head{cursor:default;}
|
||||
.tool-card-icon{display:none;}
|
||||
.tool-card-name,.tl-verb{font-size:var(--message-body-font-size);font-weight:400;color:var(--muted);font-family:inherit;flex-shrink:0;opacity:.56;}
|
||||
.tool-card-running .tl-verb,.tl.running .tl-verb{color:var(--accent-text);font-weight:600;opacity:1;}
|
||||
.tool-card-title,.tl-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted);font-family:var(--font-mono);font-size:var(--message-body-font-size);font-weight:400;opacity:.42;}
|
||||
.tl-diff{display:inline-flex;gap:5px;flex-shrink:0;font-family:var(--font-mono);font-size:12px;}
|
||||
.tl-add{color:var(--success);}
|
||||
.tl-del{color:var(--error);}
|
||||
.tool-card-header:hover .tool-card-title,.tl-head:hover .tl-title{opacity:.66;}
|
||||
.tool-card-preview{display:none;}
|
||||
.tool-card-duration{display:none;font-size:var(--font-size-xs);color:var(--muted);opacity:.72;white-space:nowrap;margin-left:auto;}
|
||||
.tool-card-toggle,.tl-caret{margin-left:auto;font-size:10px;color:var(--muted);opacity:.4;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;transform-origin:center;transition:transform .18s ease;will-change:transform;}
|
||||
.tool-card.open .tool-card-toggle,.tl.open .tl-caret{transform:rotate(90deg);}
|
||||
.tool-card-detail,.tl-detail{display:block;max-height:0;opacity:0;overflow:hidden;border-top:1px solid transparent;padding:0;margin-left:0;transition:max-height .26s ease,opacity .2s ease,padding .22s ease,border-top-color .22s ease;background:var(--code-bg);border-radius:8px;}
|
||||
.tool-card.open .tool-card-detail,.tl.open .tl-detail{max-height:320px;opacity:1;padding:0;border-top-color:transparent;overflow:auto;margin-top:4px;}
|
||||
.tl-detail-inner{padding:9px 12px 11px;}
|
||||
.tool-card-args{margin-bottom:6px;}
|
||||
.tool-card-args div{font-size:var(--font-size-xs);line-height:1.6;}
|
||||
.tool-arg-key{color:var(--blue);font-family:'SF Mono',ui-monospace,monospace;font-size:var(--font-size-xs);display:block;margin-bottom:2px;}
|
||||
.tool-arg-val{color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;font-size:var(--font-size-xs);white-space:pre-wrap;word-break:break-word;display:block;overflow-x:auto;}
|
||||
.tool-card-result pre{font-size:var(--font-size-xs);color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;white-space:pre-wrap;word-break:break-word;max-height:240px;overflow-y:auto;margin:0;line-height:1.55;}
|
||||
.tool-card-args div{font-size:var(--message-code-font-size);line-height:1.6;}
|
||||
.tool-arg-key{color:var(--blue);font-family:'SF Mono',ui-monospace,monospace;font-size:var(--message-code-font-size);display:block;margin-bottom:2px;}
|
||||
.tool-arg-val{color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;font-size:var(--message-code-font-size);white-space:pre-wrap;word-break:break-word;display:block;overflow-x:auto;}
|
||||
.tool-card-result pre{font-size:var(--message-code-font-size);color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;white-space:pre-wrap;word-break:break-word;max-height:240px;overflow-y:auto;margin:0;line-height:1.55;}
|
||||
|
||||
/* ── Manual compression cards (transient transcript-local feedback) ── */
|
||||
.live-compression-cards{
|
||||
@@ -2921,6 +3089,25 @@ body.resizing .sidebar{transition:none!important;}
|
||||
margin:0 auto;
|
||||
padding:0 24px;
|
||||
}
|
||||
/* LiveFooter: inline stream spark · elapsed · tokens · status */
|
||||
.live-run-status{display:flex;align-items:center;gap:8px;padding-left:0;margin:2px 0 6px;margin-left:var(--msg-rail);max-width:var(--msg-max);font-size:13px;color:var(--muted);font-variant-numeric:tabular-nums;line-height:1;}
|
||||
.live-footer{display:flex;align-items:center;gap:8px;padding-left:var(--worklog-rail);margin:2px 0 6px;margin-left:0;max-width:var(--msg-max);font-size:13px;color:var(--muted);}
|
||||
.live-run-status[hidden]{display:none;}
|
||||
.lf-spark{color:var(--accent);font-size:13px;display:inline-block;animation:lfspin 1.7s linear infinite;line-height:1;}
|
||||
@keyframes lfspin{to{transform:rotate(360deg);}}
|
||||
.live-run-status-text{
|
||||
white-space:nowrap;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
}
|
||||
.lf-time{color:var(--text);font-variant-numeric:tabular-nums;}
|
||||
.lf-sep{opacity:.4;}
|
||||
.lf-tokens{font-variant-numeric:tabular-nums;}
|
||||
.lf-status{color:var(--muted);opacity:.85;}
|
||||
@media(max-width:600px){
|
||||
.live-run-status{padding-left:0;font-size:12px;}
|
||||
.live-footer{padding-left:var(--worklog-rail);font-size:12px;}
|
||||
}
|
||||
.compression-block{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
@@ -2970,6 +3157,45 @@ body.resizing .sidebar{transition:none!important;}
|
||||
.tool-card-compress-complete .tool-card-name{
|
||||
color:#4ec984;
|
||||
}
|
||||
.auto-compression-divider-row{
|
||||
padding:7px 0 8px;
|
||||
}
|
||||
.auto-compression-divider{
|
||||
display:grid;
|
||||
grid-template-columns:minmax(32px,1fr) auto minmax(32px,1fr);
|
||||
align-items:center;
|
||||
gap:10px;
|
||||
color:var(--muted);
|
||||
opacity:.72;
|
||||
user-select:none;
|
||||
pointer-events:none;
|
||||
}
|
||||
.auto-compression-divider-line{
|
||||
height:1px;
|
||||
background:color-mix(in srgb,var(--border-subtle) 75%,transparent);
|
||||
}
|
||||
.auto-compression-divider-label{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
gap:6px;
|
||||
color:var(--muted);
|
||||
font-size:calc(var(--message-body-font-size) * .92);
|
||||
font-weight:400!important;
|
||||
line-height:1.2;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.auto-compression-divider-label svg{
|
||||
width:13px;
|
||||
height:13px;
|
||||
opacity:.78;
|
||||
}
|
||||
.auto-compression-divider-done{
|
||||
opacity:.82;
|
||||
}
|
||||
.auto-compression-divider-done .auto-compression-divider-label{
|
||||
color:var(--muted);
|
||||
}
|
||||
.tool-card-compress-reference{
|
||||
background:rgba(124,185,255,.04);
|
||||
border-color:rgba(124,185,255,.18);
|
||||
@@ -3987,7 +4213,7 @@ main.main > #mainPlugin{display:none;}
|
||||
}
|
||||
|
||||
/* Inline code: stop shouting orange; inherit strong text colour instead */
|
||||
.msg-body code { color: var(--strong); background: var(--code-inline-bg); font-size: 12.5px; }
|
||||
.msg-body code { color: var(--strong); background: var(--code-inline-bg); font-size: var(--message-code-font-size); }
|
||||
|
||||
/* ── Unified indent rail — every child of a turn lines up on --msg-rail ── */
|
||||
.msg-row { padding: 12px 0; }
|
||||
@@ -4024,9 +4250,9 @@ main.main > #mainPlugin{display:none;}
|
||||
to it, but visually it adds a ghost "Hermes" header above the tool cards.
|
||||
With the row hidden the transition from live → settled on 'done' is
|
||||
seamless. */
|
||||
.msg-row[data-role="assistant"]:has(.msg-body:empty) { padding: 0; margin: 0; }
|
||||
.msg-row[data-role="assistant"]:has(.msg-body:empty) .msg-role,
|
||||
.msg-row[data-role="assistant"]:has(.msg-body:empty) .msg-files { display: none; }
|
||||
.msg-row[data-role="assistant"]:has(> .msg-body:empty) { padding: 0; margin: 0; }
|
||||
.msg-row[data-role="assistant"]:has(> .msg-body:empty) .msg-role,
|
||||
.msg-row[data-role="assistant"]:has(> .msg-body:empty) .msg-files { display: none; }
|
||||
.msg-files { padding-left: var(--msg-rail); }
|
||||
.msg-usage { padding-left: var(--msg-rail); opacity: 1; margin-top: 6px; font-size: 11px; }
|
||||
.tool-card { margin-left: var(--msg-rail); max-width: var(--msg-max); }
|
||||
@@ -4249,11 +4475,11 @@ main.main > #mainPlugin{display:none;}
|
||||
.thinking-card.open .thinking-card-body { max-height: 260px; overflow-y: auto; opacity: 1; padding: 8px 12px; border-top-color: var(--accent-bg-strong); }
|
||||
.thinking-card-body pre { font-family: 'SF Mono', ui-monospace, monospace; font-size: 11px; line-height: 1.6; color: var(--muted); white-space: pre-wrap; word-break: break-word; margin: 0; }
|
||||
|
||||
/* ── Tool cards: tighter chrome to match quieter thinking card ── */
|
||||
.tool-card { border-radius: 8px; margin-top: 3px; margin-bottom: 3px; }
|
||||
.tool-card-header { padding: 5px 10px; }
|
||||
.tool-card-name { font-size: 11px; }
|
||||
.tool-card-preview { font-size: 11px; }
|
||||
/* ── Tool cards: quiet disclosure rows ── */
|
||||
.tool-card { background:transparent;border:0;border-left:0;border-radius:0;margin-top:0;margin-bottom:0; }
|
||||
.tool-card-header { display:flex;align-items:center;gap:7px;padding:3px 8px;margin-left:-8px;border-radius:7px; }
|
||||
.tool-card-name { font-size: 13px; }
|
||||
.tool-card-preview { display:none; }
|
||||
|
||||
/* ── Streaming cursor at the end of the live assistant body ── */
|
||||
@keyframes hermes-cursor-blink { 0%,49% { opacity: 1; } 50%,100% { opacity: 0; } }
|
||||
|
||||
+1394
-405
File diff suppressed because it is too large.
Load diff
@@ -2,7 +2,10 @@ from pathlib import Path
|
||||
|
||||
from api.compression_anchor import visible_messages_for_anchor
|
||||
from api.models import Session
|
||||
from api.streaming import _is_fallback_lifecycle_message
|
||||
from api.streaming import (
|
||||
_is_fallback_lifecycle_message,
|
||||
_prune_context_tool_results_after_compression,
|
||||
)
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -30,6 +33,46 @@ def _compressing_listener_block() -> str:
|
||||
return src[start:end]
|
||||
|
||||
|
||||
def test_post_compression_context_prunes_tail_tool_results_with_active_compressor():
|
||||
class FakeCompressor:
|
||||
protect_last_n = 20
|
||||
tail_token_budget = 4096
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def _prune_old_tool_results(self, messages, protect_tail_count, protect_tail_tokens=None):
|
||||
self.calls.append(
|
||||
{
|
||||
"protect_tail_count": protect_tail_count,
|
||||
"protect_tail_tokens": protect_tail_tokens,
|
||||
}
|
||||
)
|
||||
out = []
|
||||
pruned = 0
|
||||
for msg in messages:
|
||||
next_msg = dict(msg)
|
||||
if next_msg.get("role") == "tool" and len(str(next_msg.get("content") or "")) > 200:
|
||||
next_msg["content"] = "[browser_navigate] opened page (large snapshot summarized)"
|
||||
pruned += 1
|
||||
out.append(next_msg)
|
||||
return out, pruned
|
||||
|
||||
compressor = FakeCompressor()
|
||||
agent = type("Agent", (), {"context_compressor": compressor})()
|
||||
context_messages = [
|
||||
{"role": "assistant", "content": "", "tool_calls": [{"id": "call_big"}]},
|
||||
{"role": "tool", "tool_call_id": "call_big", "content": "x" * 5000},
|
||||
{"role": "assistant", "content": "Final answer"},
|
||||
]
|
||||
|
||||
pruned = _prune_context_tool_results_after_compression(agent, context_messages)
|
||||
|
||||
assert compressor.calls == [{"protect_tail_count": 20, "protect_tail_tokens": 4096}]
|
||||
assert pruned[1]["content"] == "[browser_navigate] opened page (large snapshot summarized)"
|
||||
assert context_messages[1]["content"] == "x" * 5000
|
||||
|
||||
|
||||
def test_auto_compression_running_sse_uses_active_session_running_card():
|
||||
block = _compressing_listener_block()
|
||||
|
||||
@@ -39,7 +82,8 @@ def test_auto_compression_running_sse_uses_active_session_running_card():
|
||||
assert "setCompressionUi" in block
|
||||
assert "phase:'running'" in block
|
||||
assert "automatic:true" in block
|
||||
assert "message:d.message||'Auto-compressing context...'" in block
|
||||
assert "message:'Compressing context'" in block
|
||||
assert "message:d.message||'Compressing context'" not in block
|
||||
|
||||
|
||||
def test_agent_status_callback_emits_compressing_and_warning_events():
|
||||
@@ -53,7 +97,7 @@ def test_agent_status_callback_emits_compressing_and_warning_events():
|
||||
# compressing events for compression lifecycle notices
|
||||
assert "put('compressing'" in block
|
||||
assert "'session_id': session_id" in block
|
||||
assert "'message': 'Auto-compressing context to continue...'" in block
|
||||
assert "'message': 'Compressing context'" in block
|
||||
assert "'preflight compression'" in block
|
||||
assert "'compressing'" in block
|
||||
assert "'compacting context'" in block
|
||||
@@ -95,7 +139,7 @@ def test_fallback_lifecycle_message_predicate_matches_agent_emitters():
|
||||
)
|
||||
assert not _is_fallback_lifecycle_message(
|
||||
"lifecycle",
|
||||
"Auto-compressing context to continue...",
|
||||
"Compressing context",
|
||||
)
|
||||
|
||||
|
||||
@@ -105,7 +149,18 @@ def test_auto_compression_completion_transition_is_preserved_after_running_liste
|
||||
compressed_idx = src.find("source.addEventListener('compressed'")
|
||||
assert compressing_idx != -1 and compressed_idx != -1
|
||||
assert compressing_idx < compressed_idx
|
||||
assert "appendLiveCompressionCard({" in _compressed_listener_block()
|
||||
assert "phase:'done'" in _compressed_listener_block()
|
||||
assert "message:'Context auto-compressed'" in _compressed_listener_block()
|
||||
assert "clearCompressionUi()" in _compressed_listener_block()
|
||||
|
||||
|
||||
def test_auto_compression_completion_ignores_legacy_payload_message():
|
||||
block = _compressed_listener_block()
|
||||
|
||||
assert "d.message||'Compression finished'" not in block
|
||||
assert "setCompressionUi" not in block
|
||||
assert "message:'Context auto-compressed'" in block
|
||||
|
||||
|
||||
def test_auto_compression_running_sse_stamps_elapsed_timer_start():
|
||||
@@ -115,7 +170,7 @@ def test_auto_compression_running_sse_stamps_elapsed_timer_start():
|
||||
assert block.index("startedAt:Date.now()/1000") < block.index("setCompressionUi(state)")
|
||||
|
||||
|
||||
def test_auto_compression_running_card_renders_elapsed_timer_and_caps_updates():
|
||||
def test_auto_compression_running_card_keeps_elapsed_timer_out_of_visible_copy():
|
||||
src = _read("static/ui.js")
|
||||
start = src.find("function _autoCompressionPreviewText")
|
||||
assert start != -1, "auto compression preview helper not found"
|
||||
@@ -126,8 +181,9 @@ def test_auto_compression_running_card_renders_elapsed_timer_and_caps_updates():
|
||||
assert "const _COMPRESSION_ELAPSED_MAX_SECONDS=5*60;" in src
|
||||
assert "function _compressionElapsedLabel(state)" in src
|
||||
assert "_formatActiveElapsedTimer" in src
|
||||
assert "_compressionElapsedLabel(state)" in helper
|
||||
assert "elapsedLabel" in helper
|
||||
assert "_compressionElapsedLabel(state)" not in helper
|
||||
assert "elapsedLabel" not in helper
|
||||
assert "`Elapsed: ${elapsedLabel}`" not in helper
|
||||
assert "_autoCompressionPreviewText(state)" in helper
|
||||
assert "_autoCompressionDetailText(state)" in helper
|
||||
assert "function _startCompressionElapsedTimer()" in src
|
||||
@@ -137,6 +193,104 @@ def test_auto_compression_running_card_renders_elapsed_timer_and_caps_updates():
|
||||
assert "_clearCompressionElapsedTimer();" in src
|
||||
|
||||
|
||||
def test_auto_compression_uses_command_action_copy():
|
||||
src = _read("static/ui.js")
|
||||
start = src.find("function _autoCompressionPreviewText")
|
||||
assert start != -1, "auto compression preview helper not found"
|
||||
end = src.find("function _autoCompressionDetailText", start)
|
||||
assert end != -1, "auto compression detail helper not found after preview helper"
|
||||
helper = src[start:end]
|
||||
|
||||
assert "Compressing context" in helper
|
||||
assert "Context auto-compressed" in helper
|
||||
assert "Compression finished" not in helper
|
||||
assert "return running?'Running':'Done';" not in helper
|
||||
|
||||
|
||||
def test_auto_compression_running_card_defaults_collapsed():
|
||||
src = _read("static/ui.js")
|
||||
start = src.find("function _autoCompressionCardsHtml")
|
||||
assert start != -1, "auto compression card helper not found"
|
||||
end = src.find("function _compressionCardsNode", start)
|
||||
assert end != -1, "compression cards node helper not found after auto helper"
|
||||
helper = src[start:end]
|
||||
|
||||
assert "auto-compression-divider" in helper
|
||||
assert "open: false" not in helper
|
||||
assert "open: running" not in helper
|
||||
|
||||
|
||||
def test_auto_compression_uses_centered_noninteractive_divider():
|
||||
src = _read("static/style.css")
|
||||
|
||||
assert ".auto-compression-divider" in src
|
||||
assert "grid-template-columns:minmax(32px,1fr) auto minmax(32px,1fr)" in src
|
||||
assert "pointer-events:none" in src
|
||||
override = src.split(".auto-compression-divider{", 1)[1].split("}", 1)[0]
|
||||
assert "color:var(--muted)" in override
|
||||
assert "user-select:none" in override
|
||||
|
||||
|
||||
def test_auto_compression_worklog_row_does_not_use_tool_card_affordances():
|
||||
src = _read("static/ui.js")
|
||||
start = src.find("function _autoCompressionWorklogNode")
|
||||
assert start != -1, "auto compression worklog node helper not found"
|
||||
end = src.find("function _compressionCardsNode", start)
|
||||
assert end != -1, "compression cards node helper not found after worklog helper"
|
||||
helper = src[start:end]
|
||||
|
||||
assert "tool-card-running-dot" not in helper
|
||||
assert "auto_compress_label" not in helper
|
||||
assert "tool-card-header" not in helper
|
||||
assert "onclick" not in helper
|
||||
assert "tabindex" not in helper
|
||||
assert "tl-caret" not in helper
|
||||
assert "auto-compression-divider" in helper
|
||||
assert "auto-compression-divider-line" in helper
|
||||
assert "_autoCompressionPreviewText(state)" in helper
|
||||
|
||||
|
||||
def test_auto_compression_live_card_appends_to_worklog_timeline():
|
||||
src = _read("static/ui.js")
|
||||
start = src.find("function appendLiveCompressionCard")
|
||||
assert start != -1, "live compression card append helper not found"
|
||||
end = src.find("function _isHandoffSummaryToolPayload", start)
|
||||
assert end != -1, "handoff helper not found after live compression helper"
|
||||
helper = src[start:end]
|
||||
|
||||
assert "ensureLiveWorklogContainer" in helper
|
||||
assert "_toolWorklogListEl(group)" in helper
|
||||
assert "_autoCompressionWorklogNode(state)" in helper
|
||||
automatic_branch = helper.split("if(state.automatic){", 1)[1].split("const node=_compressionCardsNode(state);", 1)[0]
|
||||
assert "inner.appendChild(node)" not in automatic_branch
|
||||
assert "list.appendChild(node)" in automatic_branch
|
||||
|
||||
|
||||
def test_final_settle_removes_live_auto_compression_row():
|
||||
src = _read("static/ui.js")
|
||||
start = src.find("function clearLiveToolCards")
|
||||
assert start != -1, "live tool cleanup helper not found"
|
||||
end = src.find("function _removeEmptyLiveWorklogShells", start)
|
||||
assert end != -1, "next live worklog helper not found after cleanup helper"
|
||||
helper = src[start:end]
|
||||
|
||||
assert ".live-worklog[data-live-worklog-shell]" in helper
|
||||
assert "data-live-compression-card" in src
|
||||
|
||||
|
||||
def test_final_settle_drops_transient_automatic_compression_state():
|
||||
src = _read("static/ui.js")
|
||||
start = src.find("function renderMessages")
|
||||
assert start != -1, "renderMessages not found"
|
||||
end = src.find("function _toolDisplayName", start)
|
||||
assert end != -1, "renderMessages end marker not found"
|
||||
helper = src[start:end]
|
||||
|
||||
assert "compressionState && compressionState.automatic" in helper
|
||||
assert "window._compressionUi=null;" in helper
|
||||
assert "compressionState=null;" in helper
|
||||
|
||||
|
||||
def test_auto_compression_elapsed_cap_uses_non_frozen_label():
|
||||
src = _read("static/ui.js")
|
||||
start = src.find("function _compressionElapsedLabel")
|
||||
@@ -158,11 +312,12 @@ def test_auto_compression_running_detail_avoids_duplicate_message_text():
|
||||
assert end != -1, "auto compression card helper not found after detail helper"
|
||||
helper = src[start:end]
|
||||
|
||||
assert "return elapsedLabel?`Elapsed: ${elapsedLabel}`:base;" in helper
|
||||
assert "if(running)return '';" in helper
|
||||
assert "`Elapsed: ${elapsedLabel}`" not in helper
|
||||
assert "${base}\\nElapsed:" not in helper
|
||||
|
||||
|
||||
def test_auto_compression_done_detail_surfaces_continuation_handoff():
|
||||
def test_auto_compression_done_detail_is_not_persisted_in_worklog():
|
||||
src = _read("static/ui.js")
|
||||
start = src.find("function _autoCompressionDetailText")
|
||||
assert start != -1, "auto compression detail helper not found"
|
||||
@@ -170,9 +325,9 @@ def test_auto_compression_done_detail_surfaces_continuation_handoff():
|
||||
assert end != -1, "auto compression card helper not found after detail helper"
|
||||
helper = src[start:end]
|
||||
|
||||
assert "continuationSessionId" in helper
|
||||
assert "Continued in compressed session" in helper
|
||||
assert "return [base,handoff].filter(Boolean).join('\\n');" in helper
|
||||
assert "continuationSessionId" not in helper
|
||||
assert "Continued in compressed session" not in helper
|
||||
assert "return '';" in helper
|
||||
|
||||
|
||||
def test_auto_compression_live_card_keeps_elapsed_state_for_timer_refresh():
|
||||
@@ -188,18 +343,71 @@ def test_auto_compression_live_card_keeps_elapsed_state_for_timer_refresh():
|
||||
assert "_compressionLiveCardState" in src
|
||||
|
||||
|
||||
def test_auto_compression_does_not_rerender_over_live_answer_text():
|
||||
def test_auto_compression_does_not_rerender_over_live_worklog():
|
||||
block = _compressing_listener_block()
|
||||
src = _read("static/ui.js")
|
||||
|
||||
assert "const liveAnswerStarted=" in block
|
||||
assert "const liveAnswerStarted=" not in block
|
||||
assert "appendLiveCompressionCard(state)" in block
|
||||
assert block.index("appendLiveCompressionCard(state)") < block.index("renderMessages({preserveScroll:true})")
|
||||
assert "window._compressionUi=null;" in block
|
||||
assert "renderMessages({preserveScroll:true})" not in block
|
||||
assert "restoreLiveTurnHtmlForSession(activeSid)" not in block
|
||||
assert block.index("appendLiveCompressionCard(state)") < block.index("setCompressionUi(state)")
|
||||
assert "clearCompressionUi()" in block
|
||||
assert "function appendLiveCompressionCard(state)" in src
|
||||
assert 'data-live-compression-card' in src
|
||||
|
||||
|
||||
def test_auto_compression_live_repeated_starts_keep_only_current_running_row():
|
||||
src = _read("static/ui.js")
|
||||
start = src.find("function appendLiveCompressionCard(state)")
|
||||
assert start != -1, "live compression card append helper not found"
|
||||
end = src.find("function _isHandoffSummaryToolPayload", start)
|
||||
assert end != -1, "handoff helper not found after live compression helper"
|
||||
helper = src[start:end]
|
||||
|
||||
assert "node.setAttribute('data-compression-phase',String(state.phase||''));" in helper
|
||||
assert "const existingRunning=group.querySelector('[data-live-compression-card=\"1\"][data-compression-started-at]');" in helper
|
||||
assert 'const existing=state.phase===\'running\'?existingRunning:(existingRunning||existingDone);' in helper
|
||||
assert "if(existing) existing.replaceWith(node);" in helper
|
||||
assert "else list.appendChild(node);" in helper
|
||||
|
||||
|
||||
def test_auto_compression_running_card_completes_on_followup_live_events():
|
||||
src = _read("static/messages.js")
|
||||
|
||||
assert "function _completeAutomaticCompressionOnLiveProgress" in src
|
||||
helper = src.split("function _completeAutomaticCompressionOnLiveProgress", 1)[1].split("source.addEventListener('token'", 1)[0]
|
||||
assert "data-live-compression-card=\"1\"][data-compression-started-at]" in helper
|
||||
assert "window._compressionUi&&window._compressionUi.automatic&&window._compressionUi.phase==='running'" in helper
|
||||
assert "phase:'done'" in helper
|
||||
assert "message:'Context auto-compressed'" in helper
|
||||
assert "appendLiveCompressionCard({" in helper
|
||||
|
||||
for event_name in ("token", "interim_assistant", "reasoning", "tool", "tool_complete"):
|
||||
start = src.find(f"source.addEventListener('{event_name}'")
|
||||
assert start != -1, f"{event_name} listener not found"
|
||||
end = src.find("source.addEventListener(", start + 1)
|
||||
assert end != -1, f"{event_name} listener end not found"
|
||||
block = src[start:end]
|
||||
assert "_completeAutomaticCompressionOnLiveProgress(activeSid)" in block
|
||||
assert "settleLiveCompressionCards" not in block
|
||||
assert "clearCompressionUi()" not in block
|
||||
|
||||
|
||||
def test_auto_compression_elapsed_update_is_not_visible_detail_churn():
|
||||
src = _read("static/ui.js")
|
||||
start = src.find("function _updateCompressionElapsedCards")
|
||||
assert start != -1, "elapsed update helper not found"
|
||||
end = src.find("function _startCompressionElapsedTimer", start)
|
||||
assert end != -1, "timer helper not found after elapsed updater"
|
||||
helper = src[start:end]
|
||||
|
||||
assert "return false;" in helper
|
||||
assert ".tool-card-compress-auto" not in helper
|
||||
assert "tool-card-preview" not in helper
|
||||
assert "tool-card-result" not in helper
|
||||
|
||||
|
||||
def test_auto_compression_sse_uses_transient_card_not_fake_message():
|
||||
"""Auto compression must not inject display-only text into S.messages."""
|
||||
src = _read("static/messages.js")
|
||||
@@ -207,13 +415,12 @@ def test_auto_compression_sse_uses_transient_card_not_fake_message():
|
||||
|
||||
assert "*[Context was auto-compressed to continue the conversation]*" not in src
|
||||
assert "S.messages.push" not in block
|
||||
assert "setCompressionUi" in block
|
||||
assert "setCompressionUi" not in block
|
||||
assert "phase:'done'" in block
|
||||
assert "automatic:true" in block
|
||||
assert "appendLiveCompressionCard" in block
|
||||
assert "_setCompressionSessionLock" in block
|
||||
assert "const appended=typeof appendLiveCompressionCard==='function'&&appendLiveCompressionCard(state);" in block
|
||||
assert "window._compressionUi=null;" in block
|
||||
assert block.index("appendLiveCompressionCard(state)") < block.index("window._compressionUi=null;")
|
||||
assert "clearCompressionUi()" in block
|
||||
|
||||
|
||||
def test_auto_compression_sse_keeps_inactive_and_malformed_paths_safe():
|
||||
@@ -221,7 +428,7 @@ def test_auto_compression_sse_keeps_inactive_and_malformed_paths_safe():
|
||||
|
||||
guard = "if(!S.session) return;"
|
||||
assert guard in block
|
||||
assert block.index(guard) < block.index("setCompressionUi")
|
||||
assert block.index(guard) < block.index("appendLiveCompressionCard")
|
||||
assert "try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; }" in block
|
||||
assert "const eventSid=d.old_session_id||d.session_id||activeSid;" in block
|
||||
assert "const eventMatchesCurrent=" in block
|
||||
@@ -257,9 +464,8 @@ def test_auto_compression_done_accepts_event_after_current_session_rotates():
|
||||
assert "const currentSid=S.session.session_id;" in block
|
||||
assert "const eventMatchesCurrent=" in block
|
||||
assert "const displaySid=currentSid;" in block
|
||||
assert "sessionId:displaySid" in block
|
||||
assert block.index("const eventSid=") < block.index("const eventMatchesCurrent=")
|
||||
assert block.index("const displaySid=") < block.index("setCompressionUi(state)")
|
||||
assert block.index("const displaySid=") < block.index("appendLiveCompressionCard")
|
||||
|
||||
|
||||
def test_auto_compression_done_sse_refreshes_context_indicator_usage():
|
||||
@@ -268,7 +474,7 @@ def test_auto_compression_done_sse_refreshes_context_indicator_usage():
|
||||
assert "if(d.usage&&typeof _syncCtxIndicator==='function')" in block
|
||||
assert "_mergeUsageForCtxIndicator(d.usage,S.lastUsage||{})" in block
|
||||
assert "_syncCtxIndicator(S.lastUsage);" in block
|
||||
assert block.index("_syncCtxIndicator(S.lastUsage);") < block.index("setCompressionUi")
|
||||
assert block.index("_syncCtxIndicator(S.lastUsage);") < block.index("appendLiveCompressionCard")
|
||||
|
||||
|
||||
def test_auto_compression_done_payload_includes_live_usage_snapshot():
|
||||
@@ -283,6 +489,7 @@ def test_auto_compression_done_payload_includes_live_usage_snapshot():
|
||||
assert "'old_session_id': _compression_origin_session_id" in block
|
||||
assert "'new_session_id': _compression_continuation_session_id" in block
|
||||
assert "'continuation_session_id': _compression_continuation_session_id" in block
|
||||
assert "'message': 'Compression finished'" in block
|
||||
assert "'usage': _live_usage_snapshot()" in block
|
||||
|
||||
|
||||
@@ -310,24 +517,17 @@ def test_auto_compression_card_reuses_compression_card_renderer():
|
||||
helper = src[start:end]
|
||||
|
||||
assert "if(state.automatic) return _autoCompressionCardsHtml(state);" in src
|
||||
assert "tool-card-row compression-card-row" in helper
|
||||
assert "tool-card-compress-complete tool-card-compress-auto" in helper
|
||||
assert "auto_compress_label" in helper
|
||||
assert "tool-card-row compression-card-row auto-compression-divider-row" in helper
|
||||
assert "auto-compression-divider-line" in helper
|
||||
assert "variantClass: 'tool-card-compress-auto'" not in helper
|
||||
assert "statusLabel: preview" not in helper
|
||||
|
||||
|
||||
def test_auto_compression_compressed_sse_showtoast_has_explicit_longer_duration():
|
||||
def test_auto_compression_compressed_sse_does_not_show_persistent_completion_toast():
|
||||
block = _compressed_listener_block()
|
||||
|
||||
assert 'showToast' in block
|
||||
# Must call showToast with an explicit duration that is meaningfully longer
|
||||
# than the default (3000 ms) so the compressed event toast is harder to miss.
|
||||
import re
|
||||
m = re.search(r'showToast\(.*?,\s*(\d+)\s*\)', block)
|
||||
assert m is not None, 'showToast call in compressed SSE handler has no explicit duration'
|
||||
duration = int(m.group(1))
|
||||
assert duration >= 8000, (
|
||||
f'compressed SSE showToast duration ({duration} ms) must be >= 8000 ms'
|
||||
)
|
||||
assert 'showToast' not in block
|
||||
assert "Compression finished" not in block
|
||||
|
||||
|
||||
def test_auto_compression_card_survives_compression_session_rotation():
|
||||
|
||||
@@ -122,6 +122,8 @@ class TestCancelledTurnPersistenceGuards:
|
||||
def test_silent_failure_path_checks_cancel_event_before_persisting_provider_error(self):
|
||||
src = _read("api/streaming.py")
|
||||
silent_idx = src.find("# ── Detect silent agent failure")
|
||||
if silent_idx == -1:
|
||||
silent_idx = src.find("# ── Detect missing final assistant reply")
|
||||
assert silent_idx != -1, "silent-failure block not found"
|
||||
apperror_idx = src.find("put('apperror', _error_payload)", silent_idx)
|
||||
assert apperror_idx != -1, "silent-failure apperror emission not found"
|
||||
@@ -135,6 +137,20 @@ class TestCancelledTurnPersistenceGuards:
|
||||
"The cancellation guard should persist/report a cancelled turn, not silently drop state."
|
||||
)
|
||||
|
||||
def test_streamed_progress_without_final_assistant_still_reports_error(self):
|
||||
src = _read("api/streaming.py")
|
||||
failure_idx = src.find("_terminal_failure = (")
|
||||
assert failure_idx != -1, "terminal-failure guard not found"
|
||||
apperror_idx = src.find("put('apperror', _error_payload)", failure_idx)
|
||||
assert apperror_idx != -1, "terminal-failure guard must emit apperror"
|
||||
block = src[failure_idx:apperror_idx]
|
||||
|
||||
assert "_agent_result_terminal_failure(result)" in block
|
||||
assert "if _terminal_failure or (not _assistant_added and not _token_sent):" in block, (
|
||||
"Explicit terminal failures, including compression/tool-tail failures, must report "
|
||||
"an error even when interim progress already streamed."
|
||||
)
|
||||
|
||||
def test_exception_path_classifies_after_cancel_event_before_generic_error(self):
|
||||
src = _read("api/streaming.py")
|
||||
except_idx = src.find("print('[webui] stream error:")
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""Regression tests for preserving live streams across session switches."""
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def _function_body(src: str, name: str) -> str:
|
||||
@@ -26,6 +30,25 @@ def _function_body(src: str, name: str) -> str:
|
||||
return src[brace + 1 : i - 1]
|
||||
|
||||
|
||||
def _function_decl(src: str, name: str) -> str:
|
||||
marker = f"function {name}("
|
||||
start = src.find(marker)
|
||||
assert start != -1, f"{name}() not found"
|
||||
brace = src.find("){", start)
|
||||
assert brace != -1, f"{name}() body not found"
|
||||
brace += 1
|
||||
depth = 1
|
||||
i = brace + 1
|
||||
while i < len(src) and depth:
|
||||
if src[i] == "{":
|
||||
depth += 1
|
||||
elif src[i] == "}":
|
||||
depth -= 1
|
||||
i += 1
|
||||
assert depth == 0, f"{name}() body did not close"
|
||||
return src[start:i]
|
||||
|
||||
|
||||
def test_attach_live_stream_reuses_existing_same_stream_transport():
|
||||
"""Returning to a running session must not tear down its same SSE stream.
|
||||
|
||||
@@ -42,10 +65,31 @@ def test_attach_live_stream_reuses_existing_same_stream_transport():
|
||||
assert close_pos != -1, "attachLiveStream() should still close stale/different streams"
|
||||
assert reuse_pos < close_pos, "same-stream reuse must run before closeLiveStream(activeSid)"
|
||||
assert "existingLive.streamId===streamId" in body
|
||||
assert "existingLive.source.readyState!==EventSource.CLOSED" in body
|
||||
assert "existingLive.source.readyState===EventSource.OPEN" in body
|
||||
assert "(!reconnecting&&existingLive.source.readyState===EventSource.CONNECTING)" in body
|
||||
assert "return" in body[reuse_pos:close_pos]
|
||||
|
||||
|
||||
def test_attach_live_stream_reconnect_does_not_reuse_connecting_transport():
|
||||
"""Explicit reattach must reopen a stale CONNECTING EventSource.
|
||||
|
||||
A page can keep a same-stream EventSource object in CONNECTING while the
|
||||
server has no SSE subscriber. Reconnect paths from loadSession() should not
|
||||
treat that object as healthy, or the live pane remains blank despite the
|
||||
backend stream still emitting events.
|
||||
"""
|
||||
body = _function_body(MESSAGES_JS, "attachLiveStream")
|
||||
reuse_pos = body.find("const existingLive=LIVE_STREAMS[activeSid]")
|
||||
close_pos = body.find("\n closeLiveStream(activeSid);\n")
|
||||
assert reuse_pos != -1
|
||||
assert close_pos != -1
|
||||
reuse_block = body[reuse_pos:close_pos]
|
||||
compact = re.sub(r"\s+", "", reuse_block)
|
||||
assert "existingLive.source.readyState===EventSource.OPEN" in reuse_block
|
||||
assert "(!reconnecting&&existingLive.source.readyState===EventSource.CONNECTING)" in compact
|
||||
assert "existingLive.source.readyState!==EventSource.CLOSED" not in reuse_block
|
||||
|
||||
|
||||
def test_attach_live_stream_closes_other_session_streams_before_opening_new_one():
|
||||
"""Only the selected conversation pane should hold an open chat SSE transport."""
|
||||
body = _function_body(MESSAGES_JS, "attachLiveStream")
|
||||
@@ -102,6 +146,56 @@ def test_load_session_reattach_path_uses_attach_live_stream_for_running_sessions
|
||||
assert "{reconnecting:true}" in body[reattach_pos : reattach_pos + 200]
|
||||
|
||||
|
||||
def test_load_session_same_sid_noop_does_not_mask_pending_switch_back():
|
||||
"""Clicking back to the prior session during a pending switch must reload it.
|
||||
|
||||
loadSession() clears S.messages before the metadata fetch for the target
|
||||
session returns. During that small window S.session still points at the
|
||||
previous session. A fast click back to that previous sid used to hit the
|
||||
same-session no-op guard and leave the pane empty/Loading forever.
|
||||
"""
|
||||
body = _function_body(SESSIONS_JS, "loadSession")
|
||||
compact = re.sub(r"\s+", "", body)
|
||||
guard = "if(currentSid===sid&&!forceReload&&!_loadingSessionId)return;"
|
||||
assert guard in compact, (
|
||||
"same-session no-op must be disabled while another loadSession() call "
|
||||
"is in flight, otherwise switching away and immediately back can keep "
|
||||
"the previous session's cleared transcript"
|
||||
)
|
||||
assert compact.find(guard) < compact.find("_loadingSessionId=sid;")
|
||||
|
||||
|
||||
def test_load_session_preserves_existing_worklog_content_without_destructive_fallback():
|
||||
"""Switching back to an active stream with live Worklog content should be treated as restored.
|
||||
|
||||
If loadSession() sees .wl-reason or .tool-card-row already in #liveAssistantTurn,
|
||||
the destructive fallback must not call clearLiveToolCards() and rebuild a blank
|
||||
Running shell over the preserved timeline.
|
||||
"""
|
||||
body = _function_body(SESSIONS_JS, "loadSession")
|
||||
content_pos = body.find("const hasCurrentWorklogContent=")
|
||||
clear_pos = body.find("clearLiveToolCards();", content_pos)
|
||||
assert content_pos != -1
|
||||
assert clear_pos != -1
|
||||
between = body[content_pos:clear_pos]
|
||||
compact = re.sub(r"\s+", "", between)
|
||||
assert "if(hasCurrentWorklogContent)restoredLiveTurn=true" in compact, (
|
||||
"Existing live Worklog content must mark the turn restored before the "
|
||||
"clearLiveToolCards() fallback runs."
|
||||
)
|
||||
|
||||
|
||||
def test_tool_events_are_guarded_against_stale_session_and_stream():
|
||||
"""Delayed tool events from an old EventSource must not mutate the current session DOM."""
|
||||
tool_handler = MESSAGES_JS.split("source.addEventListener('tool',e=>{", 1)[1].split("source.addEventListener('tool_complete'", 1)[0]
|
||||
complete_handler = MESSAGES_JS.split("source.addEventListener('tool_complete',e=>{", 1)[1].split("source.addEventListener('approval'", 1)[0]
|
||||
for handler in (tool_handler, complete_handler):
|
||||
assert "_terminalStateReached||_streamFinalized" in handler
|
||||
assert "S.session.session_id!==activeSid" in handler
|
||||
assert "S.activeStreamId!==streamId" in handler
|
||||
assert "appendLiveToolCard(tc,{sessionId:activeSid,streamId})" in handler
|
||||
|
||||
|
||||
def test_close_live_stream_marks_inflight_for_reattach_on_return():
|
||||
"""When closeLiveStream() tears down a still-active SSE transport (e.g. the
|
||||
user switched to another session), the corresponding INFLIGHT entry must be
|
||||
@@ -118,8 +212,13 @@ def test_close_live_stream_marks_inflight_for_reattach_on_return():
|
||||
"closeLiveStream() must touch INFLIGHT so loadSession() reattaches the "
|
||||
"SSE when the user switches back to a still-streaming session"
|
||||
)
|
||||
snapshot_pos = body.find("snapshotLiveTurnHtmlForSession(sessionId)")
|
||||
hide_pos = body.find("hideLiveRunStatus")
|
||||
assert snapshot_pos != -1, "closeLiveStream() must snapshot the visible Worklog before tearing down the pane"
|
||||
assert hide_pos != -1 and snapshot_pos < hide_pos
|
||||
assert re.search(r"INFLIGHT\[\w+\]\s*&&\s*\(?INFLIGHT\[\w+\]\.reattach\s*=\s*true", body) \
|
||||
or re.search(r"if\s*\(\s*INFLIGHT\[\w+\]\s*\)\s*INFLIGHT\[\w+\]\.reattach\s*=\s*true", body), (
|
||||
or re.search(r"if\s*\(\s*INFLIGHT\[\w+\]\s*\)\s*INFLIGHT\[\w+\]\.reattach\s*=\s*true", body) \
|
||||
or re.search(r"if\s*\(\s*INFLIGHT\[\w+\]\s*\)\s*\{[^}]*INFLIGHT\[\w+\]\.reattach\s*=\s*true", body, re.DOTALL), (
|
||||
"closeLiveStream() must set INFLIGHT[sessionId].reattach = true "
|
||||
"(guarded by an existence check) so loadSession()'s reattach branch fires"
|
||||
)
|
||||
@@ -157,7 +256,7 @@ def test_load_session_reattaches_when_inflight_is_in_memory_and_marked_for_reatt
|
||||
body = _function_body(SESSIONS_JS, "loadSession")
|
||||
inflight_idx = body.find("if(INFLIGHT[sid]){")
|
||||
assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession"
|
||||
inflight_block = body[inflight_idx : inflight_idx + 2400]
|
||||
inflight_block = body[inflight_idx : inflight_idx + 4200]
|
||||
assert "INFLIGHT[sid].reattach" in inflight_block, (
|
||||
"loadSession()'s INFLIGHT branch must gate the SSE reattach on the "
|
||||
"reattach flag so closeLiveStream()'s marking flows through"
|
||||
@@ -171,3 +270,796 @@ def test_load_session_reattaches_when_inflight_is_in_memory_and_marked_for_reatt
|
||||
"loadSession() must reattach via attachLiveStream() when "
|
||||
"INFLIGHT[sid].reattach && activeStreamId"
|
||||
)
|
||||
|
||||
|
||||
def test_load_session_attaches_sse_before_auxiliary_work():
|
||||
"""Live SSE reattach is the primary recovery path.
|
||||
|
||||
Rendering, workspace refresh, badges, and side-channel pollers must not run
|
||||
before attachLiveStream(), because any synchronous failure in those paths
|
||||
would otherwise leave the backend stream active with no browser subscriber.
|
||||
"""
|
||||
body = _function_body(SESSIONS_JS, "loadSession")
|
||||
active_branch = body[body.find("if(activeStreamId){") : body.find("}else{", body.find("if(activeStreamId){"))]
|
||||
active_attach = active_branch.find("attachLiveStream(sid, activeStreamId")
|
||||
assert active_attach != -1
|
||||
for marker in (
|
||||
"updateSendBtn();",
|
||||
"syncTopbar();renderMessages();appendThinking();loadDir('.');",
|
||||
"updateQueueBadge(sid);",
|
||||
"startApprovalPolling(sid)",
|
||||
):
|
||||
pos = active_branch.find(marker)
|
||||
assert pos != -1, f"{marker} not found in active-stream branch"
|
||||
assert active_attach < pos, f"attachLiveStream() must run before {marker}"
|
||||
|
||||
|
||||
def test_running_reattach_refreshes_single_live_assistant_from_server_progress():
|
||||
"""Switching back to a running session should keep one visible assistant
|
||||
source for the active turn.
|
||||
|
||||
The server transcript can already contain interim assistant progress while
|
||||
INFLIGHT also holds the live assistant tail. Reattach must refresh the live
|
||||
tail from the server copy, drop the server's active-turn assistant rows, and
|
||||
render one `_live` assistant instead of duplicating or deleting progress.
|
||||
"""
|
||||
assert NODE, "node not on PATH"
|
||||
start = SESSIONS_JS.find("function _messageComparableText")
|
||||
end = SESSIONS_JS.find("// Load older messages", start)
|
||||
assert start != -1 and end != -1
|
||||
helper_src = SESSIONS_JS[start:end]
|
||||
script = f"""
|
||||
const assert = require('assert');
|
||||
{helper_src}
|
||||
|
||||
let base = [
|
||||
{{role:'user', content:'go'}},
|
||||
{{role:'assistant', content:'First progress.'}},
|
||||
{{role:'tool', content:'{{}}'}},
|
||||
{{role:'assistant', content:'Second progress.'}},
|
||||
];
|
||||
let inflight = [
|
||||
{{role:'user', content:'go'}},
|
||||
{{role:'assistant', _live:true, content:'First progress.\\n\\nSecond progress.\\n\\nSecond progress.'}},
|
||||
];
|
||||
assert.strictEqual(_prepareRunningLiveTail(base, inflight), true);
|
||||
assert.strictEqual(inflight[1].content, 'First progress.\\n\\nSecond progress.');
|
||||
base = _dropCurrentTurnAssistantMessages(base);
|
||||
let merged = _mergeInflightTailMessages(base, inflight);
|
||||
assert.strictEqual(merged.filter(m => m.role === 'assistant').length, 1);
|
||||
assert.strictEqual(merged[merged.length - 1]._live, true);
|
||||
assert.strictEqual(merged[merged.length - 1].content, 'First progress.\\n\\nSecond progress.');
|
||||
|
||||
base = [
|
||||
{{role:'user', content:'go'}},
|
||||
{{role:'assistant', content:'First progress.'}},
|
||||
{{role:'tool', content:'{{}}'}},
|
||||
{{role:'assistant', content:'Second progress.'}},
|
||||
];
|
||||
inflight = [
|
||||
{{role:'user', content:'go'}},
|
||||
{{role:'assistant', _live:true, content:'First progress.'}},
|
||||
];
|
||||
assert.strictEqual(_prepareRunningLiveTail(base, inflight), true);
|
||||
assert.strictEqual(inflight[1].content, 'First progress.\\n\\nSecond progress.');
|
||||
base = _dropCurrentTurnAssistantMessages(base);
|
||||
merged = _mergeInflightTailMessages(base, inflight);
|
||||
assert.strictEqual(merged.filter(m => m.role === 'assistant').length, 1);
|
||||
assert.strictEqual(merged[merged.length - 1]._live, true);
|
||||
assert.strictEqual(merged[merged.length - 1].content, 'First progress.\\n\\nSecond progress.');
|
||||
"""
|
||||
result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_running_reattach_rebuilds_live_assistant_from_last_text_before_activity():
|
||||
"""A fast session switch can happen after INFLIGHT.lastAssistantText was
|
||||
updated but before the live assistant message/DOM snapshot caught up.
|
||||
|
||||
Reattach must rebuild the structured `_live` assistant before restoring
|
||||
Activity, otherwise the UI can show only the Activity group until another
|
||||
switch or token causes the text segment to reappear.
|
||||
"""
|
||||
assert NODE, "node not on PATH"
|
||||
start = SESSIONS_JS.find("function _messageComparableText")
|
||||
end = SESSIONS_JS.find("// Load older messages", start)
|
||||
assert start != -1 and end != -1
|
||||
helper_src = SESSIONS_JS[start:end]
|
||||
script = f"""
|
||||
const assert = require('assert');
|
||||
{helper_src}
|
||||
|
||||
let base = [{{role:'user', content:'go'}}];
|
||||
let inflightState = {{
|
||||
lastAssistantText:'Recovered progress text.',
|
||||
lastReasoningText:'',
|
||||
messages:[{{role:'user', content:'go'}}],
|
||||
}};
|
||||
assert.strictEqual(_ensureInflightLiveAssistantMessage(inflightState), true);
|
||||
assert.strictEqual(inflightState.messages.length, 2);
|
||||
assert.strictEqual(inflightState.messages[1]._live, true);
|
||||
assert.strictEqual(inflightState.messages[1].content, 'Recovered progress text.');
|
||||
assert.strictEqual(_prepareRunningLiveTail(base, inflightState.messages), true);
|
||||
base = _dropCurrentTurnAssistantMessages(base);
|
||||
const merged = _mergeInflightTailMessages(base, inflightState.messages);
|
||||
assert.strictEqual(merged.filter(m => m.role === 'assistant').length, 1);
|
||||
assert.strictEqual(merged[merged.length - 1]._live, true);
|
||||
assert.strictEqual(merged[merged.length - 1].content, 'Recovered progress text.');
|
||||
"""
|
||||
result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_running_reattach_projects_live_text_into_activity_burst_segments():
|
||||
"""Fallback reattach should rebuild the same process-text/tool-burst
|
||||
timeline even when the DOM snapshot is unavailable.
|
||||
"""
|
||||
assert NODE, "node not on PATH"
|
||||
start = SESSIONS_JS.find("function _messageComparableText")
|
||||
end = SESSIONS_JS.find("// Load older messages", start)
|
||||
assert start != -1 and end != -1
|
||||
helper_src = SESSIONS_JS[start:end]
|
||||
script = f"""
|
||||
const assert = require('assert');
|
||||
{helper_src}
|
||||
|
||||
const inflight = {{
|
||||
currentActivityBurstId: 2,
|
||||
activityBurstAnchors: [
|
||||
{{id: 1, textEnd: 'First progress.'.length}},
|
||||
{{id: 2, textEnd: 'First progress.\\n\\nSecond progress.'.length}},
|
||||
],
|
||||
messages: [
|
||||
{{role:'user', content:'go'}},
|
||||
{{role:'assistant', _live:true, content:'First progress.\\n\\nSecond progress.\\n\\nTail progress.'}},
|
||||
],
|
||||
}};
|
||||
const projected = _projectInflightMessagesForActivityBursts(inflight);
|
||||
assert.strictEqual(projected.length, 4);
|
||||
assert.strictEqual(projected[1].content, 'First progress.');
|
||||
assert.strictEqual(projected[1]._activityBurstId, 1);
|
||||
assert.strictEqual(projected[2].content, 'Second progress.');
|
||||
assert.strictEqual(projected[2]._activityBurstId, 2);
|
||||
assert.strictEqual(projected[3].content, 'Tail progress.');
|
||||
assert.strictEqual(projected[3]._activityBurstId, 2);
|
||||
"""
|
||||
result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_running_reattach_reprojects_segmented_live_tail_without_duplicate_prefix():
|
||||
"""A reconnect write can leave a segmented live tail plus a full accumulator.
|
||||
|
||||
syncInflightAssistantMessage() updates the last `_live` message from the
|
||||
full assistant accumulator. On the next session switch,
|
||||
_projectInflightMessagesForActivityBursts() must replace the whole live
|
||||
tail with one projection from the accumulator; keeping the earlier
|
||||
projected segments and also splitting the full accumulator repeats already
|
||||
visible process text.
|
||||
"""
|
||||
assert NODE, "node not on PATH"
|
||||
start = SESSIONS_JS.find("function _messageComparableText")
|
||||
end = SESSIONS_JS.find("// Load older messages", start)
|
||||
assert start != -1 and end != -1
|
||||
helper_src = SESSIONS_JS[start:end]
|
||||
script = f"""
|
||||
const assert = require('assert');
|
||||
{helper_src}
|
||||
|
||||
const fullText = 'First progress.\\n\\nSecond progress.\\n\\nTail progress.';
|
||||
const inflight = {{
|
||||
currentActivityBurstId: 2,
|
||||
currentLiveSegmentSeq: 2,
|
||||
activityBurstAnchors: [
|
||||
{{id: 1, textEnd: 'First progress.'.length}},
|
||||
{{id: 2, textEnd: 'First progress.\\n\\nSecond progress.'.length}},
|
||||
],
|
||||
messages: [
|
||||
{{role:'user', content:'go'}},
|
||||
{{role:'assistant', _live:true, content:'First progress.', _activityBurstId:1, _liveSegmentSeq:1}},
|
||||
{{role:'assistant', _live:true, content:fullText, _activityBurstId:2, _liveSegmentSeq:2}},
|
||||
],
|
||||
}};
|
||||
const projected = _projectInflightMessagesForActivityBursts(inflight);
|
||||
assert.deepStrictEqual(
|
||||
projected.filter(m => m.role === 'assistant').map(m => m.content),
|
||||
['First progress.', 'Second progress.', 'Tail progress.']
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
projected.filter(m => m.role === 'assistant').map(m => m._liveSegmentSeq),
|
||||
[1, 2, 3]
|
||||
);
|
||||
"""
|
||||
result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_running_reattach_keeps_segmented_tail_when_last_segment_is_not_accumulator():
|
||||
"""Normal segmented live tails must not be collapsed from the last segment.
|
||||
|
||||
The duplicate-prefix repair only applies when the last live message already
|
||||
contains earlier live segment text. If the last segment is only its own tail,
|
||||
the prior live segments are still the source of truth and must be preserved.
|
||||
"""
|
||||
assert NODE, "node not on PATH"
|
||||
start = SESSIONS_JS.find("function _messageComparableText")
|
||||
end = SESSIONS_JS.find("// Load older messages", start)
|
||||
assert start != -1 and end != -1
|
||||
helper_src = SESSIONS_JS[start:end]
|
||||
script = f"""
|
||||
const assert = require('assert');
|
||||
{helper_src}
|
||||
|
||||
const inflight = {{
|
||||
currentActivityBurstId: 2,
|
||||
currentLiveSegmentSeq: 2,
|
||||
activityBurstAnchors: [
|
||||
{{id: 1, textEnd: 'First progress.'.length}},
|
||||
{{id: 2, textEnd: 'First progress.\\n\\nSecond progress.'.length}},
|
||||
],
|
||||
messages: [
|
||||
{{role:'user', content:'go'}},
|
||||
{{role:'assistant', _live:true, content:'First progress.', _activityBurstId:1, _liveSegmentSeq:1}},
|
||||
{{role:'assistant', _live:true, content:'Second progress.', _activityBurstId:2, _liveSegmentSeq:2}},
|
||||
],
|
||||
}};
|
||||
const projected = _projectInflightMessagesForActivityBursts(inflight);
|
||||
assert.deepStrictEqual(
|
||||
projected.filter(m => m.role === 'assistant').map(m => m.content),
|
||||
['First progress.', 'Second progress.']
|
||||
);
|
||||
"""
|
||||
result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_running_reattach_aliases_empty_activity_bursts_to_previous_text_segment():
|
||||
"""Duplicate boundaries with no new text should not leave tool activity
|
||||
attached to a burst id that has no visible assistant segment.
|
||||
"""
|
||||
assert NODE, "node not on PATH"
|
||||
start = SESSIONS_JS.find("function _messageComparableText")
|
||||
end = SESSIONS_JS.find("// Load older messages", start)
|
||||
assert start != -1 and end != -1
|
||||
helper_src = SESSIONS_JS[start:end]
|
||||
script = f"""
|
||||
const assert = require('assert');
|
||||
{helper_src}
|
||||
|
||||
const inflight = {{
|
||||
currentActivityBurstId: 2,
|
||||
activityBurstAnchors: [
|
||||
{{id: 1, textEnd: 'First progress.'.length}},
|
||||
{{id: 2, textEnd: 'First progress.'.length}},
|
||||
],
|
||||
toolCalls: [
|
||||
{{name:'read_file', activityBurstId: 2}},
|
||||
],
|
||||
messages: [
|
||||
{{role:'user', content:'go'}},
|
||||
{{role:'assistant', _live:true, content:'First progress.'}},
|
||||
],
|
||||
}};
|
||||
const projected = _projectInflightMessagesForActivityBursts(inflight);
|
||||
assert.strictEqual(projected.length, 2);
|
||||
assert.strictEqual(projected[1].content, 'First progress.');
|
||||
assert.strictEqual(projected[1]._activityBurstId, 1);
|
||||
assert.strictEqual(inflight.toolCalls[0].activityBurstId, 1);
|
||||
assert.strictEqual(inflight.toolCalls[0].activitySegmentSeq, 1);
|
||||
"""
|
||||
result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_running_reattach_backfills_tool_segment_seq_for_burst_anchors():
|
||||
"""When reattaching a running stream, persisted tool calls without
|
||||
activitySegmentSeq should be rebound to the projected live segment sequence
|
||||
so tool cards land next to their triggering text, not at the tail.
|
||||
"""
|
||||
assert NODE, "node not on PATH"
|
||||
start = SESSIONS_JS.find("function _messageComparableText")
|
||||
end = SESSIONS_JS.find("// Load older messages", start)
|
||||
assert start != -1 and end != -1
|
||||
helper_src = SESSIONS_JS[start:end]
|
||||
script = f"""
|
||||
const assert = require('assert');
|
||||
{helper_src}
|
||||
|
||||
const inflight = {{
|
||||
currentActivityBurstId: 3,
|
||||
activityBurstAnchors: [
|
||||
{{id: 1, textEnd: 'First progress.'.length}},
|
||||
{{id: 2, textEnd: 'First progress.\\n\\nSecond progress.'.length}},
|
||||
],
|
||||
toolCalls: [
|
||||
{{name:'read_file', activityBurstId: 1, activitySegmentSeq: undefined}},
|
||||
{{name:'search', activityBurstId: 2, activitySegmentSeq: undefined}},
|
||||
],
|
||||
messages: [
|
||||
{{role:'user', content:'go'}},
|
||||
{{role:'assistant', _live:true, content:'First progress.\\n\\nSecond progress.\\n\\nTail progress.'}},
|
||||
],
|
||||
}};
|
||||
const projected = _projectInflightMessagesForActivityBursts(inflight);
|
||||
assert.strictEqual(projected.length, 4);
|
||||
assert.strictEqual(projected[1]._liveSegmentSeq, 1);
|
||||
assert.strictEqual(projected[2]._liveSegmentSeq, 2);
|
||||
assert.strictEqual(projected[3]._liveSegmentSeq, 3);
|
||||
assert.strictEqual(inflight.toolCalls[0].activitySegmentSeq, 1);
|
||||
assert.strictEqual(inflight.toolCalls[1].activitySegmentSeq, 2);
|
||||
"""
|
||||
result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_upsert_live_tool_call_preserves_start_seq_for_complete():
|
||||
"""tool_complete should inherit the seq captured by the matching tool_start.
|
||||
|
||||
This guarantees a single in-flight tool row per call and keeps Activity
|
||||
placement stable even when complete arrives on a different segment.
|
||||
"""
|
||||
assert NODE, "node not on PATH"
|
||||
helper_defs = "\n".join([
|
||||
_function_decl(MESSAGES_JS, "_stableStringify"),
|
||||
_function_decl(MESSAGES_JS, "_hashString"),
|
||||
_function_decl(MESSAGES_JS, "_toolCallSignature"),
|
||||
_function_decl(MESSAGES_JS, "_liveToolTid"),
|
||||
_function_decl(MESSAGES_JS, "_coerceLiveToolCallSignature"),
|
||||
_function_decl(MESSAGES_JS, "_coerceLiveToolCallSeq"),
|
||||
_function_decl(MESSAGES_JS, "_currentLiveToolAnchor"),
|
||||
_function_decl(MESSAGES_JS, "_findPendingLiveToolCallIndex"),
|
||||
_function_decl(MESSAGES_JS, "upsertLiveToolCall"),
|
||||
])
|
||||
script = (
|
||||
"const assert = require('assert');\n"
|
||||
f"{helper_defs}\n\n"
|
||||
"const uploaded=[];\n"
|
||||
"let activeSid='sid';\n"
|
||||
"const INFLIGHT={};\n"
|
||||
"const S={\"toolCalls\":[],\"messages\":[]};\n"
|
||||
"let assistantRow={getAttribute:()=>\"7\"};\n"
|
||||
"let _assistantSegmentSeq=7;\n"
|
||||
"let _currentLiveSegmentSeq=7;\n"
|
||||
"let _currentActivityBurstId=1;\n"
|
||||
"const assistantBody=null;\n"
|
||||
"global.persistInflightState=()=>{};\n"
|
||||
"global.S=S;\n"
|
||||
"global.INFLIGHT=INFLIGHT;\n"
|
||||
"global.activeSid=activeSid;\n"
|
||||
"global.uploaded=uploaded;\n"
|
||||
"global.assistantRow=assistantRow;\n"
|
||||
"global.assistantBody=assistantBody;\n"
|
||||
"global._assistantSegmentSeq=_assistantSegmentSeq;\n"
|
||||
"global._currentLiveSegmentSeq=_currentLiveSegmentSeq;\n"
|
||||
"global._currentActivityBurstId=_currentActivityBurstId;\n\n"
|
||||
"const start=upsertLiveToolCall({\"name\":\"read_file\",\"args\":{\"path\":\"/tmp/a\"},\"preview\":\"start\"}, 'start');\n"
|
||||
"assert(start);\n"
|
||||
"start.started_at=111;\n"
|
||||
"assert.strictEqual(start.activitySegmentSeq, 7);\n"
|
||||
"assert.strictEqual(start._toolCallStartSeq, 7);\n"
|
||||
"_currentLiveSegmentSeq=11;\n"
|
||||
"_assistantSegmentSeq=11;\n"
|
||||
"const complete=upsertLiveToolCall({\"name\":\"read_file\",\"args\":{\"path\":\"/tmp/a\"},\"duration\":2}, 'complete');\n"
|
||||
"assert(complete);\n"
|
||||
"assert.strictEqual(complete.activitySegmentSeq, 7);\n"
|
||||
"assert.strictEqual(complete._toolCallStartSeq, 7);\n"
|
||||
"assert.strictEqual(complete===start, true);\n"
|
||||
)
|
||||
result = subprocess.run([NODE, '-e', script], capture_output=True, text=True, check=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_upsert_live_tool_call_complete_matches_by_name_burst_without_tid():
|
||||
"""A complete event without tid must still match the in-flight tool by name+burst.
|
||||
|
||||
This is needed when the provider's complete stream payload does not carry a
|
||||
stable tool call id.
|
||||
"""
|
||||
assert NODE, "node not on PATH"
|
||||
helper_defs = "\n".join([
|
||||
_function_decl(MESSAGES_JS, "_stableStringify"),
|
||||
_function_decl(MESSAGES_JS, "_hashString"),
|
||||
_function_decl(MESSAGES_JS, "_toolCallSignature"),
|
||||
_function_decl(MESSAGES_JS, "_liveToolTid"),
|
||||
_function_decl(MESSAGES_JS, "_coerceLiveToolCallSignature"),
|
||||
_function_decl(MESSAGES_JS, "_coerceLiveToolCallSeq"),
|
||||
_function_decl(MESSAGES_JS, "_currentLiveToolAnchor"),
|
||||
_function_decl(MESSAGES_JS, "_findPendingLiveToolCallIndex"),
|
||||
_function_decl(MESSAGES_JS, "upsertLiveToolCall"),
|
||||
])
|
||||
script = (
|
||||
"const assert = require('assert');\n"
|
||||
f"{helper_defs}\n\n"
|
||||
"const uploaded=[];\n"
|
||||
"let activeSid='sid';\n"
|
||||
"const INFLIGHT={\"sid\":{\"toolCalls\":[{\"name\":\"search\",\"activityBurstId\":3,\"activitySegmentSeq\":4,\"_toolCallStartSeq\":4,\"_liveToolCallSignature\":\"search|3|4|{\\\"query\\\":\\\"x\\\"}\",\"done\":false}],\"messages\":[],\"uploaded\":[]}};\n"
|
||||
"const S={\"toolCalls\":[],\"messages\":[]};\n"
|
||||
"let _assistantSegmentSeq=9;\n"
|
||||
"let _currentLiveSegmentSeq=9;\n"
|
||||
"let _currentActivityBurstId=3;\n"
|
||||
"let assistantRow={getAttribute:()=>\"7\"};\n"
|
||||
"let assistantBody=null;\n"
|
||||
"global.persistInflightState=()=>{};\n"
|
||||
"global.S=S;\n"
|
||||
"global.INFLIGHT=INFLIGHT;\n"
|
||||
"global.activeSid=activeSid;\n"
|
||||
"global.uploaded=uploaded;\n"
|
||||
"global.assistantRow=assistantRow;\n"
|
||||
"global.assistantBody=assistantBody;\n"
|
||||
"global._assistantSegmentSeq=_assistantSegmentSeq;\n"
|
||||
"global._currentLiveSegmentSeq=_currentLiveSegmentSeq;\n"
|
||||
"global._currentActivityBurstId=_currentActivityBurstId;\n\n"
|
||||
"const complete=upsertLiveToolCall({\"name\":\"search\",\"args\":{\"query\":\"x\"}}, 'complete');\n"
|
||||
"assert(complete);\n"
|
||||
"assert.strictEqual(complete.activitySegmentSeq, 4);\n"
|
||||
"assert.strictEqual(complete._toolCallStartSeq, 4);\n"
|
||||
"assert.strictEqual(INFLIGHT[activeSid].toolCalls.length, 1);\n"
|
||||
)
|
||||
result = subprocess.run([NODE, '-e', script], capture_output=True, text=True, check=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_upsert_flags_orphan_complete_but_not_normal_start_complete():
|
||||
"""`_createdByComplete` must be set ONLY when a tool_complete creates a fresh
|
||||
record with no matching tool_start (orphan completion). The SSE handler uses
|
||||
this flag to decide whether to force a fresh segment: an orphan completion is
|
||||
a real tail boundary, but a normal in-place start->complete update must leave
|
||||
the active segment untouched (otherwise interleaved completions fragment the
|
||||
streaming text into spurious empty segments)."""
|
||||
assert NODE, "node not on PATH"
|
||||
helper_defs = "\n".join([
|
||||
_function_decl(MESSAGES_JS, "_stableStringify"),
|
||||
_function_decl(MESSAGES_JS, "_hashString"),
|
||||
_function_decl(MESSAGES_JS, "_toolCallSignature"),
|
||||
_function_decl(MESSAGES_JS, "_liveToolTid"),
|
||||
_function_decl(MESSAGES_JS, "_coerceLiveToolCallSignature"),
|
||||
_function_decl(MESSAGES_JS, "_coerceLiveToolCallSeq"),
|
||||
_function_decl(MESSAGES_JS, "_currentLiveToolAnchor"),
|
||||
_function_decl(MESSAGES_JS, "_findPendingLiveToolCallIndex"),
|
||||
_function_decl(MESSAGES_JS, "upsertLiveToolCall"),
|
||||
])
|
||||
script = (
|
||||
"const assert = require('assert');\n"
|
||||
f"{helper_defs}\n\n"
|
||||
"const uploaded=[];\n"
|
||||
"let activeSid='sid';\n"
|
||||
"const INFLIGHT={};\n"
|
||||
"const S={\"toolCalls\":[],\"messages\":[]};\n"
|
||||
"let assistantRow={getAttribute:()=>\"7\"};\n"
|
||||
"let assistantBody=null;\n"
|
||||
"let _assistantSegmentSeq=7;\n"
|
||||
"let _currentLiveSegmentSeq=7;\n"
|
||||
"let _currentActivityBurstId=1;\n"
|
||||
"global.persistInflightState=()=>{};\n"
|
||||
"global.S=S;\n"
|
||||
"global.INFLIGHT=INFLIGHT;\n"
|
||||
"global.activeSid=activeSid;\n"
|
||||
"global.uploaded=uploaded;\n"
|
||||
"global.assistantRow=assistantRow;\n"
|
||||
"global.assistantBody=assistantBody;\n"
|
||||
"global._assistantSegmentSeq=_assistantSegmentSeq;\n"
|
||||
"global._currentLiveSegmentSeq=_currentLiveSegmentSeq;\n"
|
||||
"global._currentActivityBurstId=_currentActivityBurstId;\n\n"
|
||||
# Case A: normal start -> complete. The start record must NOT be flagged,
|
||||
# and the matching complete must reuse it without setting the flag.
|
||||
"const start=upsertLiveToolCall({\"name\":\"read_file\",\"args\":{\"path\":\"/tmp/a\"},\"tid\":\"T1\"}, 'start');\n"
|
||||
"assert(start);\n"
|
||||
"assert.strictEqual(!!start._createdByComplete, false, 'tool_start must not be flagged');\n"
|
||||
"const completeMatched=upsertLiveToolCall({\"name\":\"read_file\",\"args\":{\"path\":\"/tmp/a\"},\"tid\":\"T1\"}, 'complete');\n"
|
||||
"assert.strictEqual(completeMatched===start, true, 'complete must reuse the start record');\n"
|
||||
"assert.strictEqual(!!completeMatched._createdByComplete, false, 'in-place complete must not be flagged');\n"
|
||||
"assert.strictEqual(INFLIGHT[activeSid].toolCalls.length, 1, 'no duplicate record');\n\n"
|
||||
# Case B: orphan complete (no prior start). The freshly created record
|
||||
# MUST be flagged so the handler forces a fresh segment.
|
||||
"const orphan=upsertLiveToolCall({\"name\":\"write_file\",\"args\":{\"path\":\"/tmp/b\"},\"tid\":\"T2\"}, 'complete');\n"
|
||||
"assert(orphan);\n"
|
||||
"assert.strictEqual(orphan===start, false);\n"
|
||||
"assert.strictEqual(orphan._createdByComplete, true, 'orphan complete must be flagged');\n"
|
||||
"assert.strictEqual(orphan.done, true);\n"
|
||||
"assert.strictEqual(INFLIGHT[activeSid].toolCalls.length, 2);\n"
|
||||
)
|
||||
result = subprocess.run([NODE, '-e', script], capture_output=True, text=True, check=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_tool_complete_handler_gates_segment_reset_on_orphan_flag():
|
||||
"""The tool_complete SSE handler must only force a fresh segment for orphan
|
||||
completions (`_createdByComplete`), updating the card in place otherwise."""
|
||||
handler_start = MESSAGES_JS.find("source.addEventListener('tool_complete'")
|
||||
assert handler_start != -1
|
||||
handler_end = MESSAGES_JS.find("source.addEventListener('approval'", handler_start)
|
||||
assert handler_end != -1
|
||||
handler = MESSAGES_JS[handler_start:handler_end]
|
||||
# The reset trio must live behind the orphan-flag branch.
|
||||
guard_pos = handler.find("if(tc._createdByComplete)")
|
||||
reset_pos = handler.find("_resetAssistantSegment()")
|
||||
assert guard_pos != -1, "tool_complete must branch on tc._createdByComplete"
|
||||
assert reset_pos != -1 and guard_pos < reset_pos, (
|
||||
"segment reset must be gated behind the orphan-completion branch"
|
||||
)
|
||||
# The non-orphan branch must still place the card (in place).
|
||||
assert handler.count("appendLiveToolCard(tc,{sessionId:activeSid,streamId})") >= 2, (
|
||||
"both orphan and in-place branches must append/update the tool card"
|
||||
)
|
||||
|
||||
|
||||
def test_project_inflight_with_no_visible_anchor_maps_tools_to_run_anchor_segment():
|
||||
"""Without a visible burst anchor, in-flight tools should still map to the first
|
||||
segment instead of falling back to the last segment in render order."""
|
||||
assert NODE, "node not on PATH"
|
||||
start = SESSIONS_JS.find("function _messageComparableText")
|
||||
end = SESSIONS_JS.find("// Load older messages", start)
|
||||
assert start != -1 and end != -1
|
||||
helper_src = SESSIONS_JS[start:end]
|
||||
script = f"""
|
||||
const assert = require('assert');
|
||||
{helper_src}
|
||||
|
||||
const inflight = {{
|
||||
currentActivityBurstId: 2,
|
||||
activityBurstAnchors: [
|
||||
{{ id: 1, textEnd: 0 }},
|
||||
],
|
||||
toolCalls: [
|
||||
{{name:'read_file', activityBurstId:0}},
|
||||
],
|
||||
messages: [
|
||||
{{role:'user', content:'go'}},
|
||||
{{role:'assistant', _live:true, _activityBurstId: 2, content:'First progress line'}},
|
||||
],
|
||||
}};
|
||||
const projected = _projectInflightMessagesForActivityBursts(inflight);
|
||||
assert.strictEqual(projected.length, 2);
|
||||
assert.strictEqual(projected[1].content, 'First progress line');
|
||||
assert.strictEqual(projected[1]._liveSegmentSeq, 1);
|
||||
assert.strictEqual(inflight.toolCalls[0].activitySegmentSeq, 1);
|
||||
"""
|
||||
result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_load_session_rebuilds_live_tail_before_snapshot_fallback():
|
||||
body = _function_body(SESSIONS_JS, "loadSession")
|
||||
ensure_pos = body.find("_ensureInflightLiveAssistantMessage(INFLIGHT[sid]);")
|
||||
inflight_pos = body.find("const inflightMessages=_projectInflightMessagesForActivityBursts(INFLIGHT[sid]);")
|
||||
prepare_pos = body.find("const liveTailPrepared=_prepareRunningLiveTail(S.messages,inflightMessages);")
|
||||
drop_assistant_pos = body.find("S.messages=_dropCurrentTurnAssistantMessages(S.messages);")
|
||||
merge_pos = body.find("S.messages=_mergeInflightTailMessages(S.messages,inflightMessages);")
|
||||
restore_pos = body.find("restoreLiveTurnHtmlForSession(sid)")
|
||||
assert ensure_pos != -1 and inflight_pos != -1
|
||||
assert prepare_pos != -1
|
||||
assert drop_assistant_pos != -1 and merge_pos != -1 and restore_pos != -1
|
||||
assert "delete INFLIGHT[sid].liveTurnHtml" not in body
|
||||
assert ensure_pos < inflight_pos < prepare_pos < drop_assistant_pos < merge_pos < restore_pos
|
||||
|
||||
|
||||
def test_load_session_prefers_structured_inflight_state_over_live_turn_snapshot():
|
||||
"""Structured INFLIGHT state is authoritative during reattach.
|
||||
|
||||
The memory-only liveTurnHtml snapshot can be stale across session switches.
|
||||
If loadSession restores that DOM after renderMessages() rebuilt the
|
||||
per-burst live tail, old snapshots can alternately erase progress text and
|
||||
leave Activity groups piled at the bottom of the turn.
|
||||
"""
|
||||
body = _function_body(SESSIONS_JS, "loadSession")
|
||||
structured_pos = body.find("const hasStructuredLiveState=!!(INFLIGHT[sid]&&(")
|
||||
restore_pos = body.find("restoreLiveTurnHtmlForSession(sid)")
|
||||
fallback_pos = body.find("if(!restoredLiveTurn){", restore_pos)
|
||||
assert structured_pos != -1, "loadSession must compute structured live-state presence"
|
||||
assert restore_pos != -1, "loadSession must still retain DOM snapshot fallback"
|
||||
assert fallback_pos != -1
|
||||
assert structured_pos < restore_pos < fallback_pos
|
||||
guard_block = body[structured_pos:fallback_pos]
|
||||
assert "lastAssistantText" in guard_block
|
||||
assert "lastReasoningText" in guard_block
|
||||
assert "activityBurstAnchors" in guard_block
|
||||
assert "toolCalls" in guard_block
|
||||
assert "if(!hasStructuredLiveState)" in guard_block
|
||||
assert "hasCurrentWorklogContent" in guard_block
|
||||
assert "if(hasCurrentWorklogContent) restoredLiveTurn=true;" in guard_block
|
||||
assert "else restoredLiveTurn=restoreLiveTurnHtmlForSession(sid);" in guard_block
|
||||
|
||||
|
||||
def test_load_session_restores_worklog_shell_before_reattach_replay():
|
||||
"""Reattaching before replay/new SSE should not leave the active stream blank."""
|
||||
body = _function_body(SESSIONS_JS, "loadSession")
|
||||
fallback_pos = body.find("if(!restoredLiveTurn){")
|
||||
assert fallback_pos != -1, "loadSession must have a live-turn fallback branch"
|
||||
fallback_block = body[fallback_pos:body.find("loadDir('.')", fallback_pos)]
|
||||
clear_pos = fallback_block.find("clearLiveToolCards();")
|
||||
shell_pos = fallback_block.find("ensureLiveWorklogShell()")
|
||||
legacy_pos = fallback_block.find("else appendThinking();")
|
||||
replay_pos = fallback_block.find("appendLiveToolCard(tc);")
|
||||
invariant_pos = fallback_block.find("!liveTurn||!liveTurn.querySelector")
|
||||
assert clear_pos != -1, "fallback must clear stale live tool DOM first"
|
||||
assert shell_pos != -1, "fallback must restore a quiet live Worklog shell"
|
||||
assert legacy_pos != -1, "fallback should retain legacy thinking-card behavior"
|
||||
assert replay_pos != -1, "fallback must still replay persisted live tools"
|
||||
assert invariant_pos != -1, "reattach must enforce a Worklog shell even after an empty restored snapshot"
|
||||
assert clear_pos < shell_pos < replay_pos
|
||||
assert replay_pos < invariant_pos
|
||||
|
||||
|
||||
def test_merge_inflight_tail_preserves_all_segmented_live_progress():
|
||||
"""The reattach merge must keep every projected live progress segment.
|
||||
|
||||
_projectInflightMessagesForActivityBursts() can split one live assistant
|
||||
accumulator into multiple _live messages. If the merge starts at the last
|
||||
_live segment, the earlier process-text anchors disappear and Activity
|
||||
groups whose burst ids point to those anchors pile up at the bottom.
|
||||
"""
|
||||
assert NODE, "node not on PATH"
|
||||
fn_start = SESSIONS_JS.index("function _mergeInflightTailMessages")
|
||||
fn_end = SESSIONS_JS.index("// Load older messages", fn_start)
|
||||
merge_fn = SESSIONS_JS[fn_start:fn_end]
|
||||
script = f"""
|
||||
const assert = require('assert');
|
||||
function _messageComparableText(m) {{ return String((m&&m.content)||'').trim(); }}
|
||||
function _sameTranscriptMessage(a,b) {{
|
||||
return !!(a&&b&&a.role===b.role&&_messageComparableText(a)===_messageComparableText(b));
|
||||
}}
|
||||
{merge_fn}
|
||||
const base = [{{role:'user', content:'go'}}];
|
||||
const inflight = [
|
||||
{{role:'user', content:'go'}},
|
||||
{{role:'assistant', _live:true, content:'first progress', _activityBurstId:1}},
|
||||
{{role:'assistant', _live:true, content:'second progress', _activityBurstId:2}},
|
||||
{{role:'assistant', _live:true, content:'third progress', _activityBurstId:3}},
|
||||
];
|
||||
const merged = _mergeInflightTailMessages(base, inflight);
|
||||
assert.deepStrictEqual(
|
||||
merged.filter(m => m.role === 'assistant').map(m => m.content),
|
||||
['first progress', 'second progress', 'third progress']
|
||||
);
|
||||
"""
|
||||
result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_load_session_does_not_advance_replay_cursor_from_session_journal_summary():
|
||||
body = _function_body(SESSIONS_JS, "loadSession")
|
||||
assert "INFLIGHT[sid].lastRunJournalSeq=journalSeq;" not in body
|
||||
assert "const journalSeq=_runJournalSeqFromSession(S.session);" not in body
|
||||
assert "function _runJournalSeqFromSession" not in SESSIONS_JS
|
||||
|
||||
|
||||
def test_session_switch_reattach_discards_tail_cache_for_full_journal_replay():
|
||||
close_body = _function_body(MESSAGES_JS, "closeLiveStream")
|
||||
load_body = _function_body(SESSIONS_JS, "loadSession")
|
||||
compact_body = _function_body(UI_JS, "_compactInflightState")
|
||||
|
||||
assert "INFLIGHT[sessionId].journalReplayFromStart=true" in close_body
|
||||
assert "journalReplayFromStart:true" in close_body
|
||||
assert "journalReplayFromStart:!!state.journalReplayFromStart" in compact_body
|
||||
assert "journalReplayFromStart:!!stored.journalReplayFromStart" in load_body
|
||||
assert "delete INFLIGHT[sid]" in load_body
|
||||
assert "clearInflightState(sid)" in load_body
|
||||
|
||||
|
||||
def test_load_session_discards_cursor_only_inflight_before_reattach():
|
||||
"""A cursor-only INFLIGHT cache must not skip historical journal replay.
|
||||
|
||||
Real active sessions can have an empty sidecar transcript while the durable
|
||||
run journal has the full prose/tool timeline. If the browser kept only a
|
||||
lastRunJournalSeq cursor but lost visible INFLIGHT content, reattaching from
|
||||
that cursor makes the session look blank after switching away and back.
|
||||
"""
|
||||
load_body = _function_body(SESSIONS_JS, "loadSession")
|
||||
helper_start = SESSIONS_JS.index("function _inflightHasVisibleLiveState")
|
||||
helper_body = SESSIONS_JS[
|
||||
helper_start : SESSIONS_JS.index("function _rememberRenderedSessionSnapshot", helper_start)
|
||||
]
|
||||
|
||||
assert "function _inflightHasVisibleLiveState" in SESSIONS_JS
|
||||
assert "lastAssistantText" in helper_body
|
||||
assert "lastReasoningText" in helper_body
|
||||
assert "liveTurnHtml" in helper_body
|
||||
assert "toolCalls" in helper_body
|
||||
assert "activityBurstAnchors" in helper_body
|
||||
assert "msg.role !== 'assistant'" in helper_body
|
||||
|
||||
compact_load = re.sub(r"\s+", "", load_body)
|
||||
guard = "if(activeStreamId&&INFLIGHT[sid]&&!_inflightHasVisibleLiveState(INFLIGHT[sid]))"
|
||||
assert guard in compact_load
|
||||
guard_pos = compact_load.find(guard)
|
||||
inflight_branch_pos = compact_load.find("if(INFLIGHT[sid]){")
|
||||
assert 0 <= guard_pos < inflight_branch_pos
|
||||
|
||||
|
||||
def test_reconnect_prefers_trimmed_live_message_over_stale_full_assistant_cache():
|
||||
body = _function_body(MESSAGES_JS, "attachLiveStream")
|
||||
live_msg_pos = body.find("const _liveInflightAssistant")
|
||||
last_text_pos = body.find("const _lastLiveAssistant")
|
||||
assert live_msg_pos != -1 and last_text_pos != -1
|
||||
assert live_msg_pos < last_text_pos
|
||||
assistant_block = body[last_text_pos:body.find("const _lastLiveReasoning", last_text_pos)]
|
||||
assert "_liveInflightAssistant.content" in assistant_block
|
||||
assert "_fullInflightAssistant" in assistant_block
|
||||
assert "lastAssistantText" in body[live_msg_pos:last_text_pos]
|
||||
|
||||
|
||||
def test_reconnect_uses_full_accumulator_when_live_tail_is_segmented():
|
||||
"""When reattach projection splits the live assistant into multiple
|
||||
visible process-text segments, reconnect must resume from the full
|
||||
accumulator instead of the last segment.
|
||||
|
||||
Otherwise the next syncInflightAssistantMessage() write truncates
|
||||
lastAssistantText to only the latest visible segment, so earlier process
|
||||
text anchors disappear on the next session switch and Activity groups fall
|
||||
back to the end of the turn.
|
||||
"""
|
||||
body = _function_body(MESSAGES_JS, "attachLiveStream")
|
||||
helper_pos = body.find("const _liveInflightAssistantMessages")
|
||||
last_text_pos = body.find("const _lastLiveAssistant")
|
||||
assert helper_pos != -1, (
|
||||
"attachLiveStream() should collect all live assistant segments before "
|
||||
"choosing reconnect text"
|
||||
)
|
||||
assert helper_pos < last_text_pos
|
||||
assistant_block = body[last_text_pos:body.find("const _lastLiveReasoning", last_text_pos)]
|
||||
assert "_liveInflightAssistantMessages.length>1" in assistant_block.replace(" ", "")
|
||||
assert "_fullInflightAssistant" in assistant_block
|
||||
assert "lastAssistantText" in body[helper_pos:last_text_pos]
|
||||
|
||||
|
||||
def test_reconnect_seeds_segment_start_from_last_burst_anchor():
|
||||
"""On reattach, segmentStart must align with the last burst anchor's textEnd.
|
||||
|
||||
Without this, _doRender at segmentStart===0 uses the full visible text as
|
||||
displayText, so the smd parser (after _smdReconnect clears assistantBody)
|
||||
rewrites the entire accumulated text into the first live assistant segment.
|
||||
The per-burst segments rendered by _projectInflightMessagesForActivityBursts
|
||||
are left stale, Activity groups end up visually marooned among duplicate
|
||||
text, and the user sees Activity cards pile up at the tail of the turn.
|
||||
"""
|
||||
body = _function_body(MESSAGES_JS, "attachLiveStream")
|
||||
seg_start_pos = body.find("let segmentStart=(()=>{")
|
||||
assert seg_start_pos != -1, (
|
||||
"segmentStart must be initialized via a reconnect-aware IIFE that reads "
|
||||
"INFLIGHT.activityBurstAnchors so the smd parser rewrites only the "
|
||||
"tail-burst segment, not the full text."
|
||||
)
|
||||
seg_end_pos = body.find("})();", seg_start_pos)
|
||||
assert seg_end_pos != -1, "segmentStart IIFE must close with })();"
|
||||
seg_block = body[seg_start_pos:seg_end_pos]
|
||||
assert "activityBurstAnchors" in seg_block
|
||||
assert "reconnecting" in seg_block, "segmentStart should only shift when reconnecting"
|
||||
assert "textEnd" in seg_block
|
||||
|
||||
|
||||
def test_ensure_assistant_row_reattaches_to_last_live_segment():
|
||||
"""ensureAssistantRow must pick the LAST live segment, not the first.
|
||||
|
||||
After session-switch reattach, the projected DOM holds one
|
||||
[data-live-assistant="1"] per recorded burst anchor plus a tail. New
|
||||
tokens belong to the tail segment. querySelector returns the first
|
||||
match, which would funnel all post-reattach tokens into segment 1,
|
||||
leaving the per-burst segments stale and Activity anchors visually
|
||||
detached.
|
||||
"""
|
||||
body = _function_body(MESSAGES_JS, "ensureAssistantRow")
|
||||
assert "querySelectorAll('[data-live-assistant=\"1\"]')" in body, (
|
||||
"must enumerate every live segment so the tail can be selected"
|
||||
)
|
||||
# Sanity: still has the fresh-segment guard so post-tool turns don't
|
||||
# reuse the previous text segment that sits above the new tool card.
|
||||
assert "if(!_freshSegment)" in body
|
||||
# The selected segment must be the last entry, not the first.
|
||||
assert "liveSegments[liveSegments.length-1]" in body
|
||||
|
||||
|
||||
def test_reconnect_without_tail_forces_fresh_segment_after_activity():
|
||||
"""If reconnect resumes at the last recorded boundary, no tail segment exists.
|
||||
|
||||
The next token should create a new segment after the previous Activity group
|
||||
instead of reusing the last burst's text segment above that Activity.
|
||||
"""
|
||||
body = _function_body(MESSAGES_JS, "attachLiveStream")
|
||||
fresh_pos = body.find("let _freshSegment=")
|
||||
seg_pos = body.find("let segmentStart=(()=>{")
|
||||
assert seg_pos != -1 and fresh_pos != -1
|
||||
assert seg_pos < fresh_pos
|
||||
fresh_line = body[fresh_pos:body.find(";", fresh_pos)]
|
||||
assert "reconnecting" in fresh_line
|
||||
assert "segmentStart>0" in fresh_line
|
||||
assert "segmentStart>=String(assistantText||'').length" in fresh_line
|
||||
@@ -11,6 +11,7 @@ import sys
|
||||
import types
|
||||
|
||||
import api.config as config
|
||||
import api.profiles as profiles
|
||||
|
||||
|
||||
_PROVIDER_ENV_VARS = (
|
||||
@@ -80,6 +81,9 @@ def _install_fake_hermes_cli(monkeypatch, *, provider_id: str, live_ids, raise_o
|
||||
|
||||
|
||||
def _configure(monkeypatch, tmp_path, *, provider: str, default: str = ""):
|
||||
hermes_home = tmp_path / "hermes-home"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: hermes_home)
|
||||
monkeypatch.setattr(config, "_get_config_path", lambda: tmp_path / "missing-config.yaml")
|
||||
monkeypatch.setattr(config, "_models_cache_path", tmp_path / "models_cache.json")
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -317,8 +317,7 @@ class TestIssue1298ActivityGroupExpandPersistence:
|
||||
)
|
||||
|
||||
def test_finalize_thinking_card_respects_user_expand(self):
|
||||
"""finalizeThinkingCard() must NOT force-collapse the live activity
|
||||
group when the user has explicitly expanded it (#1298)."""
|
||||
"""finalizeThinkingCard() must not force-collapse the live Worklog."""
|
||||
src = (REPO_ROOT / "static" / "ui.js").read_text()
|
||||
m = re.search(
|
||||
r"function finalizeThinkingCard\(\)\{(.*?)\n\}",
|
||||
@@ -326,16 +325,11 @@ class TestIssue1298ActivityGroupExpandPersistence:
|
||||
)
|
||||
assert m, "finalizeThinkingCard() must exist in ui.js"
|
||||
body = m.group(1)
|
||||
assert "_liveActivityUserExpanded" in body, (
|
||||
"finalizeThinkingCard() must respect the user's expand intent — "
|
||||
"without this guard, the panel snaps shut on every tool boundary"
|
||||
)
|
||||
# Hard fail if force-collapse is unconditional
|
||||
assert "_liveActivityUserExpanded !== true" in body or \
|
||||
"_liveActivityUserExpanded!==true" in body.replace(" ", ""), (
|
||||
"finalizeThinkingCard() must skip the force-collapse path when "
|
||||
"_liveActivityUserExpanded === true"
|
||||
assert "tool-call-group-collapsed" not in body, (
|
||||
"Live Worklog must remain expanded until the settled render replaces "
|
||||
"it with the final collapsed L1 Activity summary."
|
||||
)
|
||||
assert "aria-expanded','false'" not in body
|
||||
|
||||
def test_inline_onclick_records_user_intent(self):
|
||||
"""The summary button's click path must call _onLiveActivityToggle
|
||||
|
||||
@@ -137,7 +137,8 @@ providers:
|
||||
assert "custom" not in groups
|
||||
assert "lmstudio" in groups
|
||||
model_ids = {model["id"] for model in groups["lmstudio"]["models"]}
|
||||
assert {"qwen3.6-35b-a3b@q6_k", "second-lmstudio-model"} <= model_ids
|
||||
bare_model_ids = {mid.removeprefix("@lmstudio:") for mid in model_ids}
|
||||
assert {"qwen3.6-35b-a3b@q6_k", "second-lmstudio-model"} <= bare_model_ids
|
||||
|
||||
|
||||
def test_custom_configured_base_url_is_not_reclassified_as_ollama(tmp_path, monkeypatch):
|
||||
|
||||
@@ -51,12 +51,16 @@ def test_terminal_done_render_preserves_manual_scroll_after_active_stream_is_cle
|
||||
def test_render_messages_preserve_scroll_option_uses_user_pin_state_not_stream_liveness():
|
||||
render_body = _function_body(UI_JS, "renderMessages")
|
||||
scroll_helper = _function_body(UI_JS, "_scrollAfterMessageRender")
|
||||
follow_helper = _function_body(UI_JS, "_followMessagesAfterDomReplace")
|
||||
|
||||
assert "function renderMessages(options)" in render_body
|
||||
assert "const preserveScroll=!!(options&&options.preserveScroll);" in render_body
|
||||
assert "_scrollAfterMessageRender(preserveScroll, scrollSnapshot);" in render_body
|
||||
assert "const scrollSnapshot=preserveScroll?_captureMessageScrollSnapshot():null" in render_body
|
||||
assert "if(preserveScroll){\n if(_scrollPinned) scrollIfPinned();\n else _restoreMessageScrollSnapshot(scrollSnapshot);\n return;\n }" in scroll_helper
|
||||
assert "if(_followMessagesAfterDomReplace()) return;" in scroll_helper
|
||||
assert "_restoreMessageScrollSnapshot(scrollSnapshot);" in scroll_helper
|
||||
assert "_shouldFollowMessagesOnDomReplace()" in follow_helper
|
||||
assert "scrollToBottom();" in follow_helper
|
||||
assert "if(S.activeStreamId){\n scrollIfPinned();\n return;\n }" in scroll_helper
|
||||
|
||||
|
||||
|
||||
@@ -50,6 +50,22 @@ def test_active_session_idle_reconcile_clears_stale_busy_and_inflight_state():
|
||||
assert "_sessionStreamingById.set(sid, false)" in body, "observed active streaming state must be reset"
|
||||
assert "_forgetObservedStreamingSession(sid)" in body, "persisted observed streaming marker must be cleared"
|
||||
assert "updateSendBtn()" in body, "composer controls must reflect the idle state after cleanup"
|
||||
assert "hideApprovalCard(true)" in body, "stale approval UI must be cleared when server says the run is idle"
|
||||
assert "hideLiveRunStatus(sid)" in body, "stale live footer must be cleared when server says the run is idle"
|
||||
assert "clearLiveToolCards()" in body, "stale live-only tool cards must not survive idle reconciliation"
|
||||
assert "_scheduleActiveSessionIdleReload(sid)" in body, (
|
||||
"idle reconciliation must reload the current transcript from server truth "
|
||||
"so missed stream_end events do not leave the active pane stale"
|
||||
)
|
||||
|
||||
|
||||
def test_active_session_idle_reconcile_schedules_forced_transcript_reload():
|
||||
helper_body = _function_body(SESSIONS_SRC, "function _scheduleActiveSessionIdleReload(")
|
||||
|
||||
assert "setTimeout(async () =>" in helper_body
|
||||
assert "S.session.session_id !== sid" in helper_body
|
||||
assert "S.busy || S.activeStreamId" in helper_body
|
||||
assert "loadSession(sid, {force:true, externalRefreshReason:'idle-reconcile'})" in helper_body
|
||||
|
||||
|
||||
def test_session_list_payload_reconciles_active_idle_state_before_optimistic_merge_and_render():
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"""Regression tests for issue #2565: reasoning display bugs.
|
||||
|
||||
Issue 1: reasoningText accumulates across turns within a single SSE stream.
|
||||
- reasoningText must be reset at each turn boundary (tool and interim_assistant
|
||||
events) so the done event only persists the current turn's reasoning.
|
||||
Issue 1: liveReasoningText is segment-local, while reasoningText is durable for
|
||||
the whole assistant turn.
|
||||
- liveReasoningText must reset at tool and interim_assistant boundaries so
|
||||
later reasoning renders in a fresh Thinking Card.
|
||||
- reasoningText must not be reset at those boundaries; it is the fallback
|
||||
durable payload for providers that stream reasoning without final metadata.
|
||||
|
||||
Issue 2: ui.js display prefers m.reasoning over m.reasoning_content.
|
||||
- The rendering path must prefer m.reasoning_content (the clean per-turn value
|
||||
from the backend) over m.reasoning (which can be corrupted by Issue 1).
|
||||
Issue 2: provider reasoning metadata should become a Worklog Thinking Card, not
|
||||
visible Worklog process prose or final-answer text.
|
||||
|
||||
Both fixes are needed: Issue 2 alone cannot cover providers that stream reasoning
|
||||
events without populating reasoning_content on the final API message.
|
||||
Both fixes are needed: Issue 1 keeps live cards scoped to a segment without data
|
||||
loss, while Issue 2 preserves reasoning as low-priority Worklog detail.
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
@@ -22,13 +24,12 @@ def read(rel):
|
||||
return (REPO / rel).read_text(encoding='utf-8')
|
||||
|
||||
|
||||
# ── Issue 1: reasoningText reset at turn boundaries ──────────────────────────
|
||||
# ── Issue 1: live reasoning segment reset at turn boundaries ─────────────────
|
||||
|
||||
|
||||
class TestReasoningTextResetOnTool:
|
||||
"""reasoningText must be reset alongside liveReasoningText in the tool
|
||||
listener so multi-tool-turn sessions don't accumulate reasoning across
|
||||
turns."""
|
||||
class TestLiveReasoningTextResetOnTool:
|
||||
"""liveReasoningText must reset in the tool listener so later provider
|
||||
reasoning renders in a fresh Worklog Thinking Card."""
|
||||
|
||||
def _tool_listener_body(self):
|
||||
"""Extract the full tool listener body between the tool and
|
||||
@@ -42,12 +43,11 @@ class TestReasoningTextResetOnTool:
|
||||
assert tool_complete_start >= 0, "tool_complete listener not found"
|
||||
return src[tool_start:tool_complete_start]
|
||||
|
||||
def test_reasoning_text_reset_in_tool_listener(self):
|
||||
def test_durable_reasoning_text_not_reset_in_tool_listener(self):
|
||||
body = self._tool_listener_body()
|
||||
assert "reasoningText=''" in body, (
|
||||
"reasoningText must be reset to '' inside the tool listener "
|
||||
"(Issue 1: accumulated reasoning from prior turns was assigned "
|
||||
"to the last assistant message on the done event)"
|
||||
assert "reasoningText=''" not in body and 'reasoningText = ""' not in body, (
|
||||
"reasoningText must stay durable across tool boundaries so streamed "
|
||||
"provider reasoning is not silently dropped"
|
||||
)
|
||||
|
||||
def test_live_reasoning_text_also_reset_in_tool_listener(self):
|
||||
@@ -57,13 +57,11 @@ class TestReasoningTextResetOnTool:
|
||||
)
|
||||
|
||||
|
||||
class TestReasoningTextResetOnInterimAssistant:
|
||||
"""reasoningText must be reset at the interim_assistant boundary — the
|
||||
other turn boundary where the previous turn's reasoning closes out.
|
||||
Without this, providers that emit reasoning before an interim_assistant
|
||||
event will still co-mingle reasoning across turns."""
|
||||
class TestLiveReasoningTextResetOnInterimAssistant:
|
||||
"""liveReasoningText must reset at the interim_assistant boundary — the
|
||||
other segment boundary where the previous Thinking Card closes out."""
|
||||
|
||||
def test_reasoning_text_reset_in_interim_assistant_listener(self):
|
||||
def test_durable_reasoning_text_not_reset_in_interim_assistant_listener(self):
|
||||
src = read('static/messages.js')
|
||||
m = re.search(
|
||||
r"source\.addEventListener\('interim_assistant'\s*,\s*(?:e|ev)\s*=>\s*\{(.*?)\n\s*\}\);",
|
||||
@@ -71,9 +69,9 @@ class TestReasoningTextResetOnInterimAssistant:
|
||||
)
|
||||
assert m, "interim_assistant listener not found in messages.js"
|
||||
body = m.group(1)
|
||||
assert "reasoningText=''" in body, (
|
||||
"reasoningText must be reset to '' inside the interim_assistant "
|
||||
"listener (Issue 1: turn boundary where prior reasoning closes)"
|
||||
assert "reasoningText=''" not in body and 'reasoningText = ""' not in body, (
|
||||
"reasoningText must stay durable across interim assistant boundaries "
|
||||
"so streamed provider reasoning is not silently dropped"
|
||||
)
|
||||
|
||||
def test_live_reasoning_text_reset_in_interim_assistant_listener(self):
|
||||
@@ -89,44 +87,43 @@ class TestReasoningTextResetOnInterimAssistant:
|
||||
)
|
||||
|
||||
|
||||
# ── Issue 2: reasoning_content preference on read ────────────────────────────
|
||||
# ── Issue 2: reasoning metadata renders as Worklog Thinking Card ─────────────
|
||||
|
||||
|
||||
class TestReasoningContentPreference:
|
||||
"""The rendering path in ui.js must prefer m.reasoning_content (the clean
|
||||
per-turn value from the backend) over m.reasoning (which can be corrupted
|
||||
by Issue 1's accumulation bug)."""
|
||||
"""Provider reasoning metadata is retained and rendered as Thinking Card
|
||||
detail, but must not become process prose or final-answer text."""
|
||||
|
||||
def test_reasoning_content_checked_before_reasoning(self):
|
||||
def test_reasoning_payload_still_in_message_signature(self):
|
||||
src = read('static/ui.js')
|
||||
assert 'm.reasoning_content' in src, (
|
||||
"ui.js must reference m.reasoning_content so the clean per-turn "
|
||||
"value from the backend is used for thinking card display"
|
||||
sig_fn = src.split("function _messageHasReasoningPayload(m)", 1)[1].split("function", 1)[0]
|
||||
assert 'm.reasoning' in sig_fn, (
|
||||
"ui.js should still treat persisted reasoning as message metadata "
|
||||
"for cache/signature invalidation"
|
||||
)
|
||||
|
||||
def test_reasoning_content_preferred_in_thinking_text_fallback(self):
|
||||
def test_reasoning_metadata_not_used_as_inline_content_extraction(self):
|
||||
src = read('static/ui.js')
|
||||
lines = src.splitlines()
|
||||
for line in lines:
|
||||
if 'thinkingText' in line and 'm.reasoning' in line:
|
||||
if 'm.reasoning_content' not in line and 'reasoning_content' not in line:
|
||||
if 'Array.isArray' not in line:
|
||||
raise AssertionError(
|
||||
f"Line references m.reasoning without checking "
|
||||
f"m.reasoning_content first: {line.strip()}"
|
||||
)
|
||||
extraction = src.split("let thinkingText='';", 1)[1].split("const isUser=m.role==='user';", 1)[0]
|
||||
assert 'm.reasoning_content' not in extraction
|
||||
assert 'm.reasoning' not in extraction
|
||||
|
||||
def test_reasoning_content_has_priority_over_reasoning(self):
|
||||
"""The fallback expression must evaluate reasoning_content first."""
|
||||
def test_reasoning_payload_feeds_worklog_thinking_card_helper(self):
|
||||
src = read('static/ui.js')
|
||||
helper = src.split("function _worklogReasoningTextFromMessage", 1)[1].split("function _thinkingCardHtml", 1)[0]
|
||||
assert "_assistantReasoningPayloadText(m)" in helper
|
||||
assert "_stripVisibleAssistantEchoFromThinking" in helper
|
||||
|
||||
def test_no_direct_reasoning_content_to_inline_thinking_assignment(self):
|
||||
"""Provider reasoning should not be promoted into inline assistant prose."""
|
||||
src = read('static/ui.js')
|
||||
m = re.search(
|
||||
r"thinkingText\s*=\s*(m\.reasoning_content\s*\|\|\s*m\.reasoning)",
|
||||
src,
|
||||
)
|
||||
assert m, (
|
||||
"thinkingText assignment must use m.reasoning_content || m.reasoning "
|
||||
"so the clean backend value takes priority over the potentially "
|
||||
"corrupted frontend-accumulated value"
|
||||
assert not m, (
|
||||
"thinkingText must not be assigned from reasoning_content/reasoning; "
|
||||
"those fields are Worklog Thinking Card detail, not final-answer text"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -177,6 +177,37 @@ class TestInterimAssistantHandlerFlush:
|
||||
"_resetAssistantSegment in the interim_assistant handler"
|
||||
)
|
||||
|
||||
def test_already_streamed_interim_handler_flushes_before_reset(self):
|
||||
"""already_streamed interim events are still visible-progress boundaries.
|
||||
|
||||
The visible text already arrived through token events, so the client
|
||||
must not append it again. It must still flush any pending token render
|
||||
before resetting the segment; otherwise a fast tool boundary can orphan
|
||||
the text until a later render or session switch.
|
||||
"""
|
||||
src = read("static/messages.js")
|
||||
fn = _extract_handler(src, "interim_assistant")
|
||||
branch_start = fn.index("if(alreadyStreamed)")
|
||||
branch = fn[branch_start : fn.index("assistantText +=", branch_start)]
|
||||
assert "ensureAssistantRow(true)" in branch, (
|
||||
"already_streamed interim boundaries must materialize the current "
|
||||
"token segment before reset"
|
||||
)
|
||||
assert "_flushPendingSegmentRender({force:true})" in branch, (
|
||||
"already_streamed interim boundaries must flush pending token DOM "
|
||||
"before reset"
|
||||
)
|
||||
inactive_guard = "if(!S.session||S.session.session_id!==activeSid){"
|
||||
if inactive_guard in branch:
|
||||
active_branch_start = branch.index(inactive_guard) + branch[branch.index(inactive_guard):].index("}") + 1
|
||||
else:
|
||||
active_branch_start = 0
|
||||
flush_pos = branch.index("_flushPendingSegmentRender({force:true})", active_branch_start)
|
||||
reset_pos = branch.index("_resetAssistantSegment()", active_branch_start)
|
||||
assert flush_pos < reset_pos, (
|
||||
"already_streamed interim flush must happen before segment reset"
|
||||
)
|
||||
|
||||
def test_interim_handler_creates_visible_segment_before_forced_flush(self):
|
||||
src = read("static/messages.js")
|
||||
fn = _extract_handler(src, "interim_assistant")
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Regression coverage for the three #3401 deep-review fixes (release stage-3401).
|
||||
|
||||
#3401 (live-to-final assistant reply redesign) is a large refactor of the highest-traffic
|
||||
render/stream surface. Its full test suite was green and Opus passed it, but a Codex
|
||||
diff-vs-master regression read + a live-browser drive surfaced three regressions the
|
||||
rewritten tests no longer guarded:
|
||||
|
||||
FIX 1 — inline ``<think>…</think>answer`` reasoning vanished. ``_assistantReasoningPayloadText``
|
||||
used ``$``-anchored regexes, so a leading think block followed by a visible answer
|
||||
extracted NO reasoning and the Thinking card never rendered (master used the
|
||||
non-anchored form). The matching display-stripper is non-anchored, so the extractor
|
||||
must be too.
|
||||
|
||||
FIX 2 — reconnect/reload duplicated the live reply. ``_rememberRunJournalCursor`` advanced a
|
||||
closure-local seq but never wrote ``INFLIGHT[activeSid].lastRunJournalSeq`` — the value
|
||||
``persistInflightState`` saves and a reload reads back as the ``after_seq`` replay
|
||||
floor. So a hard reload restored ``lastAssistantText`` then replayed the journal from
|
||||
``after_seq=0`` on top of it.
|
||||
|
||||
FIX 3 — the shipped Neon skin silently stopped working. The PR deleted the
|
||||
``:root[data-skin="neon"]`` CSS while leaving Neon registered in the picker, so users
|
||||
could select it and get default styling.
|
||||
|
||||
These are static source-structure / registration assertions plus a general
|
||||
"every registered skin has CSS" guard so a dropped-skin regression fails fast.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _function_body(src: str, name: str) -> str:
|
||||
match = re.search(rf"function\s+{re.escape(name)}\s*\(", src)
|
||||
assert match, f"{name}() not found"
|
||||
brace = src.find("{", match.end())
|
||||
depth = 1
|
||||
i = brace + 1
|
||||
while i < len(src) and depth:
|
||||
if src[i] == "{":
|
||||
depth += 1
|
||||
elif src[i] == "}":
|
||||
depth -= 1
|
||||
i += 1
|
||||
return src[brace + 1:i - 1]
|
||||
|
||||
|
||||
# ── FIX 1: inline <think> reasoning extraction must be non-anchored ──
|
||||
|
||||
def test_reasoning_payload_extractor_is_not_dollar_anchored():
|
||||
"""A leading <think> block followed by visible answer text must still yield the
|
||||
reasoning. A trailing `$` anchor dropped it whenever the turn also had an answer."""
|
||||
body = _function_body(UI_JS, "_assistantReasoningPayloadText")
|
||||
# The think/thought/turn extraction regexes must NOT end with `$` after the close tag.
|
||||
assert "<\\/think>\\s*$/" not in body and "</think>\\s*$/" not in body, (
|
||||
"the <think> extraction regex must not be $-anchored (drops reasoning when a "
|
||||
"visible answer follows the think block) — #3401 inline-think regression"
|
||||
)
|
||||
assert "<channel\\|>\\s*$/" not in body, "channel-thought extraction must not be $-anchored"
|
||||
assert "<turn\\|>\\s*$/" not in body, "turn-thinking extraction must not be $-anchored"
|
||||
# And the non-anchored leading-block form must be present.
|
||||
assert "<think>([\\s\\S]*?)<\\/think>\\s*/" in body, (
|
||||
"extractor must match a LEADING <think> block (non-anchored), mirroring "
|
||||
"_stripLeadingAssistantThinkingMarkup"
|
||||
)
|
||||
|
||||
|
||||
def test_extractor_and_stripper_anchoring_agree():
|
||||
"""The reasoning extractor and the display-content stripper must use the same
|
||||
(non-anchored, leading-block) matching, or one shows a card the other can't strip."""
|
||||
strip = _function_body(UI_JS, "_stripLeadingAssistantThinkingMarkup")
|
||||
extract = _function_body(UI_JS, "_assistantReasoningPayloadText")
|
||||
# Neither should be $-anchored on the think close tag.
|
||||
assert "</think>\\s*$/" not in strip and "<\\/think>\\s*$/" not in strip
|
||||
assert "</think>\\s*$/" not in extract and "<\\/think>\\s*$/" not in extract
|
||||
|
||||
|
||||
# ── FIX 2: reconnect cursor must persist into INFLIGHT ──
|
||||
|
||||
def test_run_journal_cursor_persisted_into_inflight():
|
||||
"""_rememberRunJournalCursor must write the advanced seq onto INFLIGHT so a reload
|
||||
replays from the correct after_seq floor (not 0 over restored live text)."""
|
||||
body = _function_body(MESSAGES_JS, "_rememberRunJournalCursor")
|
||||
assert "INFLIGHT[activeSid]" in body, (
|
||||
"the cursor must be mirrored onto the persisted INFLIGHT entry (#3401 reconnect dup)"
|
||||
)
|
||||
assert "lastRunJournalSeq=seq" in body.replace(" ", ""), (
|
||||
"INFLIGHT[activeSid].lastRunJournalSeq must be set to the advanced seq"
|
||||
)
|
||||
# And a persist must be scheduled so the value survives a reload.
|
||||
assert "_throttledPersist" in body or "persistInflightState" in body, (
|
||||
"advancing the cursor must schedule an INFLIGHT persist"
|
||||
)
|
||||
|
||||
|
||||
def test_persist_inflight_saves_run_journal_seq():
|
||||
"""persistInflightState must still save lastRunJournalSeq (the value reload reads back)."""
|
||||
body = _function_body(MESSAGES_JS, "persistInflightState")
|
||||
assert "lastRunJournalSeq" in body
|
||||
|
||||
|
||||
# ── FIX 3: every registered skin must have CSS (general guard) ──
|
||||
|
||||
def test_neon_skin_css_restored():
|
||||
assert ':root[data-skin="neon"]' in CSS, "Neon skin CSS block must be present"
|
||||
assert ':root.dark[data-skin="neon"]' in CSS, "Neon dark variant CSS must be present"
|
||||
|
||||
|
||||
def test_every_registered_skin_has_css():
|
||||
"""Any skin offered in the picker (_SKINS in boot.js) must have a CSS block, so a
|
||||
refactor cannot silently drop a shipped skin's styling while leaving it selectable."""
|
||||
# Extract skin values registered in boot.js _SKINS = [ {name:'X', value:'y'}, ... ]
|
||||
skins_block = re.search(r"_SKINS\s*=\s*\[(.*?)\]", BOOT_JS, re.DOTALL)
|
||||
assert skins_block, "_SKINS registration not found in boot.js"
|
||||
values = set(re.findall(r"value\s*:\s*'([a-z0-9-]+)'", skins_block.group(1)))
|
||||
# 'default'/'system' style entries have no data-skin CSS; only check non-default skins.
|
||||
css_skins = set(re.findall(r'data-skin="([a-z0-9-]+)"', CSS))
|
||||
missing = sorted(s for s in values if s and s not in css_skins and s not in {"default", "system", ""})
|
||||
assert not missing, f"registered skins with no CSS block (silent breakage): {missing}"
|
||||
|
||||
|
||||
# ── FIX 4: settled tool-worklog rebuild must run while busy too (switch-back) ──
|
||||
|
||||
def test_settled_worklog_rebuild_not_gated_on_idle_only():
|
||||
"""The settled tool/worklog/thinking rebuild must also run when busy if there are
|
||||
tool calls. Gating purely on `!S.busy` dropped every prior settled turn's worklog
|
||||
when renderMessages re-ran during an active stream (switch-back to in-progress
|
||||
session) — the same content-loss-on-switch class as #3668. (#3401 regression)"""
|
||||
body = UI_JS
|
||||
# The rebuild guard must include the `|| (S.toolCalls && S.toolCalls.length)` arm.
|
||||
assert re.search(
|
||||
r"if\(!S\.busy\s*\|\|\s*\(S\.toolCalls\s*&&\s*S\.toolCalls\.length\)\)\{",
|
||||
body,
|
||||
), (
|
||||
"the settled worklog rebuild must run while busy when tool calls exist "
|
||||
"(if(!S.busy || (S.toolCalls && S.toolCalls.length))), not gate purely on !S.busy"
|
||||
)
|
||||
# And the bare `if(!S.busy){` immediately before that rebuild's worklog-wipe must be gone.
|
||||
assert "if(!S.busy){\n inner.querySelectorAll('.tool-worklog-group" not in body, (
|
||||
"the worklog-rebuild block must not be gated on the bare !S.busy form"
|
||||
)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""#3592 -- Thinking-only messages must render inline, not hidden in a collapsed activity group.
|
||||
"""#3592 / #3401 -- Thinking-only messages settle into folded Worklog detail.
|
||||
|
||||
Under Simplified Tool Calling mode, the settlement loop wraps ALL post-settlement
|
||||
assistant content via ensureActivityGroup({collapsed:true}). When an assistant
|
||||
message has thinking but no tool calls, the thinking trace vanished behind a
|
||||
collapsed dropdown. Fix: early-continue guard so thinking-only messages render
|
||||
inline via _thinkingCardHtml instead of being wrapped.
|
||||
Under the #3401 Worklog model, provider reasoning is not inline assistant prose
|
||||
and not a Tool Card. It is preserved as an independent Thinking Card inside the
|
||||
folded Worklog above the final answer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -15,86 +13,60 @@ UI_JS = (Path(__file__).resolve().parent.parent / "static" / "ui.js").read_text(
|
||||
|
||||
|
||||
def test_thinking_card_html_function_exists():
|
||||
"""_thinkingCardHtml must be defined so the inline path can call it."""
|
||||
"""_thinkingCardHtml must be defined so Worklog Thinking Cards can render."""
|
||||
assert "function _thinkingCardHtml(" in UI_JS, (
|
||||
"_thinkingCardHtml function must exist in ui.js"
|
||||
)
|
||||
|
||||
|
||||
def test_settlement_loop_has_empty_cards_guard():
|
||||
"""The simplified-tool-calling settlement loop must check cards.length before
|
||||
calling ensureActivityGroup, so thinking-only messages skip the collapsed group."""
|
||||
assert "!cards.length&&assistantThinking.has(aIdx)" in UI_JS, (
|
||||
"Settlement loop must guard on empty cards + thinking presence before "
|
||||
"wrapping in a collapsed activity group"
|
||||
def test_settlement_loop_does_not_inline_thinking_only_messages():
|
||||
"""Thinking-only messages should flow through the Worklog group path."""
|
||||
assert "!cards.length&&assistantThinking.has(aIdx)" not in UI_JS, (
|
||||
"Thinking-only messages must not use the old inline early-continue path"
|
||||
)
|
||||
assert "_thinkingActivityNode(thinkingText, false)" in UI_JS, (
|
||||
"settled reasoning should render as a collapsed Worklog Thinking Card"
|
||||
)
|
||||
|
||||
|
||||
def test_early_continue_present_in_settlement_loop():
|
||||
"""The guard path must contain a continue statement so the activity group
|
||||
path is skipped for thinking-only messages."""
|
||||
guard_pattern = re.compile(
|
||||
r"!cards\.length&&assistantThinking\.has\(aIdx\).*?continue",
|
||||
re.DOTALL,
|
||||
)
|
||||
assert guard_pattern.search(UI_JS), (
|
||||
"The early-continue guard for thinking-only messages must be present "
|
||||
"in the settlement loop"
|
||||
)
|
||||
def test_worklog_thinking_card_is_not_a_tool_card():
|
||||
"""Thinking Cards should be sibling Worklog items, not Tool Card rows."""
|
||||
thinking_fn = UI_JS.split("function _thinkingActivityNode", 1)[1].split("function", 1)[0]
|
||||
assert "data-worklog-thinking-card" in thinking_fn
|
||||
assert "tool-card-row" not in thinking_fn
|
||||
assert "buildToolCard" not in thinking_fn
|
||||
|
||||
|
||||
def test_alternative_path_calls_thinking_card_html_inline():
|
||||
"""The guard branch must call _thinkingCardHtml directly so thinking renders
|
||||
inline rather than inside a collapsed activity group."""
|
||||
guard_block = re.search(
|
||||
r"!cards\.length&&assistantThinking\.has\(aIdx\)(.*?)continue",
|
||||
UI_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert guard_block, "Guard block not found"
|
||||
block_text = guard_block.group(1)
|
||||
assert "_thinkingCardHtml(" in block_text, (
|
||||
"The early-continue branch must call _thinkingCardHtml to render "
|
||||
"thinking inline"
|
||||
)
|
||||
def test_final_answer_reasoning_worklog_is_placed_before_anchor():
|
||||
"""If the visible final answer carries reasoning metadata, its folded
|
||||
Worklog must be inserted before that final-answer segment.
|
||||
"""
|
||||
assert "beforeAnchor:!!thinkingText&&!anchorIsWorklogSource" in UI_JS
|
||||
ensure_fn = UI_JS.split("function ensureActivityGroup", 1)[1].split("function normalizeLiveActivityGroupPlacement", 1)[0]
|
||||
assert "if(opts.beforeAnchor) inner.insertBefore(group, anchor);" in ensure_fn
|
||||
assert "opts.syncAnchorReason!==false" in ensure_fn
|
||||
|
||||
|
||||
def test_show_thinking_preference_respected():
|
||||
"""The inline thinking path must check _showThinking so the preference is
|
||||
honoured the same way as the non-simplified path."""
|
||||
guard_block = re.search(
|
||||
r"!cards\.length&&assistantThinking\.has\(aIdx\)(.*?)continue",
|
||||
UI_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert guard_block, "Guard block not found"
|
||||
block_text = guard_block.group(1)
|
||||
assert "_showThinking" in block_text, (
|
||||
"The early-continue branch must respect window._showThinking"
|
||||
)
|
||||
"""The simplified render path must respect _showThinking for visible cards."""
|
||||
render_match = re.search(r"if\(thinkingText&&window\._showThinking!==false\)\{(.*?)\n\s*\}", UI_JS, re.DOTALL)
|
||||
assert render_match, "thinking render branch not found"
|
||||
assert "assistantThinking.set(rawIdx, thinkingText)" in render_match.group(1)
|
||||
|
||||
|
||||
def test_messages_with_tool_calls_still_use_activity_group():
|
||||
"""Messages that have tool calls must still flow through ensureActivityGroup
|
||||
so the existing collapsed-group behaviour is preserved."""
|
||||
def test_messages_with_tool_calls_still_use_worklog_group():
|
||||
"""Messages that have tool calls must still flow through the Worklog group."""
|
||||
assert "ensureActivityGroup(" in UI_JS, (
|
||||
"ensureActivityGroup must still be called for messages with tool calls"
|
||||
)
|
||||
|
||||
|
||||
def test_thinking_only_turns_keep_footer_duration():
|
||||
"""#3592 review regression: a thinking-only turn now renders inline with NO
|
||||
activity group, so the footer "Done in …" duration must NOT be suppressed for
|
||||
it — suppression belongs only to turns that actually build an activity group
|
||||
(tool-call turns). The old condition suppressed on assistantThinking.has(mi)
|
||||
too, which silently dropped the duration for thinking-only turns once the
|
||||
inline-render `continue` skipped group creation."""
|
||||
m = re.search(r"const compactActivityForMessage=isSimplifiedToolCalling\(\)&&([^;]+);", UI_JS)
|
||||
assert m, "compactActivityForMessage suppression condition not found"
|
||||
def test_thinking_only_turns_use_worklog_duration():
|
||||
"""Thinking-only turns now create a folded Worklog group, so that group owns
|
||||
the "Done in ..." duration instead of the final answer footer.
|
||||
"""
|
||||
m = re.search(r"const compactWorklogForMessage=isSimplifiedToolCalling\(\)&&([^;]+);", UI_JS)
|
||||
assert m, "compactWorklogForMessage suppression condition not found"
|
||||
cond = m.group(1)
|
||||
assert "toolCallAssistantIdxs.has(mi)" in cond, (
|
||||
"duration suppression must key on toolCallAssistantIdxs (group actually created)"
|
||||
)
|
||||
assert "assistantThinking.has(mi)" not in cond, (
|
||||
"thinking-only turns must NOT suppress the footer duration (no group carries it)"
|
||||
)
|
||||
assert "toolCallAssistantIdxs.has(mi)" in cond
|
||||
assert "assistantThinking.has(mi)" in cond
|
||||
@@ -1,34 +1,16 @@
|
||||
"""#3709 -- Thinking card must not render twice (inside Activity AND below the answer).
|
||||
"""#3709 -- #3401 Worklog Thinking must not render exact duplicates.
|
||||
|
||||
Regression coverage for the double-render introduced by #3592's inline branch
|
||||
(v0.51.258). In a turn that has BOTH a tool-bearing message and a trailing
|
||||
thinking-only message, two code paths emitted a thinking card from the same
|
||||
``assistantThinking`` map:
|
||||
Master fixed a double-render bug in the older Activity rendering path: a turn
|
||||
with tools plus a sibling thinking-only message could show the same Thinking
|
||||
card twice. #3401 replaces that old structure with a folded Worklog made of
|
||||
sibling items: process prose, Thinking Card, and Tool Card/Group.
|
||||
|
||||
1. the Activity-group path (tool-bearing message) put the thinking at the top
|
||||
of the collapsed Activity group, and
|
||||
2. the inline path (thinking-only message) appended a SECOND card via
|
||||
``insertAdjacentHTML('beforeend')`` -- which, because the segment already
|
||||
carried the answer body + ``msg-foot`` footer, stranded the card *below*
|
||||
the "Done in ..." line.
|
||||
These static assertions keep the #3709 invariant in the #3401 model:
|
||||
|
||||
The fix keeps the #3592 inline behaviour for genuinely thinking-only turns (so
|
||||
their thinking is not buried in a collapsed group) but:
|
||||
|
||||
A1. only renders inline when the turn has NO Activity group at all
|
||||
(``turnsWithActivityGroup`` gate), so a tool-bearing turn's thinking-only
|
||||
sibling does not emit a duplicate card;
|
||||
A2. inserts the inline card BEFORE the answer body / footer
|
||||
(``insertAdjacentHTML('beforebegin')`` on ``.msg-body,.msg-foot``) so it
|
||||
reads above the answer instead of orphaned below "Done in ...";
|
||||
B. strips the thinking against the TURN's combined visible answer
|
||||
(``_turnVisibleTextByRawIdx``) so a trailing thinking-only message whose
|
||||
answer prose lives on a sibling message still gets its answer-echo removed.
|
||||
|
||||
These are static source-structure assertions (the render path is DOM-driven and
|
||||
exercised live); they lock the invariants so the double-render cannot silently
|
||||
return, and so a future blunt "just delete the inline branch" change (which would
|
||||
re-break #3592) fails fast here instead.
|
||||
* settled Thinking is rendered through the Worklog item path, not the old inline
|
||||
sibling path below the answer;
|
||||
* exact duplicate Thinking cards are keyed by normalized content and suppressed;
|
||||
* different sibling reasoning can still become distinct Worklog items.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -39,127 +21,123 @@ UI_JS = (Path(__file__).resolve().parent.parent / "static" / "ui.js").read_text(
|
||||
|
||||
|
||||
def _render_messages_body() -> str:
|
||||
"""Return the body of renderMessages() (best-effort slice) for scoped asserts."""
|
||||
start = UI_JS.find("function renderMessages(")
|
||||
assert start != -1, "renderMessages() not found"
|
||||
# Slice a generous window; the activityIdxs loop + footer logic live within.
|
||||
return UI_JS[start:start + 60000]
|
||||
return UI_JS[start:start + 80000]
|
||||
|
||||
|
||||
def test_inline_thinking_branch_still_exists_for_thinking_only_turns():
|
||||
"""#3592 must NOT be reverted: a thinking-only turn still renders its thinking
|
||||
inline (not buried in a collapsed Activity group)."""
|
||||
assert "!cards.length&&assistantThinking.has(aIdx)" in UI_JS, (
|
||||
"the thinking-only inline branch (#3592) must remain — deleting it "
|
||||
"re-buries thinking-only turns in a collapsed Activity group"
|
||||
)
|
||||
assert "_thinkingCardHtml(" in UI_JS
|
||||
def _function_body(name: str) -> str:
|
||||
match = re.search(rf"function\s+{re.escape(name)}\s*\(", UI_JS)
|
||||
assert match, f"{name}() not found"
|
||||
brace = UI_JS.find("{", match.end())
|
||||
assert brace != -1, f"{name}() has no body"
|
||||
depth = 1
|
||||
i = brace + 1
|
||||
in_string = None
|
||||
escaped = False
|
||||
in_line_comment = False
|
||||
in_block_comment = False
|
||||
while i < len(UI_JS) and depth:
|
||||
ch = UI_JS[i]
|
||||
nxt = UI_JS[i + 1] if i + 1 < len(UI_JS) else ""
|
||||
if in_line_comment:
|
||||
if ch == "\n":
|
||||
in_line_comment = False
|
||||
i += 1
|
||||
continue
|
||||
if in_block_comment:
|
||||
if ch == "*" and nxt == "/":
|
||||
in_block_comment = False
|
||||
i += 2
|
||||
continue
|
||||
i += 1
|
||||
continue
|
||||
if in_string:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif ch == "\\":
|
||||
escaped = True
|
||||
elif ch == in_string:
|
||||
in_string = None
|
||||
i += 1
|
||||
continue
|
||||
if ch == "/" and nxt == "/":
|
||||
in_line_comment = True
|
||||
i += 2
|
||||
continue
|
||||
if ch == "/" and nxt == "*":
|
||||
in_block_comment = True
|
||||
i += 2
|
||||
continue
|
||||
if ch in ("'", '"', "`"):
|
||||
in_string = ch
|
||||
i += 1
|
||||
continue
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
i += 1
|
||||
return UI_JS[brace + 1:i - 1]
|
||||
|
||||
|
||||
def test_inline_branch_gated_on_turn_having_no_activity_group():
|
||||
"""A1: the inline card must only render when the turn has no Activity group,
|
||||
so a tool-bearing turn's thinking-only sibling does not duplicate the card."""
|
||||
def test_settled_thinking_renders_through_worklog_item_path():
|
||||
body = _render_messages_body()
|
||||
assert "turnsWithActivityGroup" in body, (
|
||||
"must precompute the set of turns that already own an Activity group (#3709 A1)"
|
||||
assert "_appendWorklogStep(state.group, anchorRow, cards, thinkingText" in body, (
|
||||
"Settled Thinking should render through the #3401 Worklog item path."
|
||||
)
|
||||
assert "_thinkingActivityNode(thinkingText, false)" in UI_JS, (
|
||||
"Thinking should remain a dedicated Worklog Thinking Card node."
|
||||
)
|
||||
assert "data-worklog-thinking-card" in UI_JS, (
|
||||
"Thinking Cards need a stable Worklog-specific hook."
|
||||
)
|
||||
# The inline render must be guarded by a membership check on that set.
|
||||
assert re.search(
|
||||
r"turnsWithActivityGroup\.has\(\s*anchorTurn\s*\)",
|
||||
body,
|
||||
), "the inline thinking render must be gated on turnsWithActivityGroup.has(anchorTurn)"
|
||||
|
||||
|
||||
def test_turns_with_activity_group_built_from_tool_bearing_segments():
|
||||
"""The turnsWithActivityGroup set must be populated from tool-bearing message
|
||||
segments' enclosing .assistant-turn nodes."""
|
||||
def test_settled_worklog_thinking_uses_content_key_for_exact_duplicate_suppression():
|
||||
body_min = re.sub(r"\s+", "", _render_messages_body())
|
||||
assert "thinkingKey:thinkingText?`thinking:${_normalizeThinkingEchoCompare(thinkingText)}`:''" in body_min, (
|
||||
"Settled Worklog should suppress duplicate Thinking by normalized content, "
|
||||
"not by assistant message index."
|
||||
)
|
||||
append_body = _function_body("_appendWorklogStep")
|
||||
assert "seenReasons.has(thinkingKey)" in append_body
|
||||
assert "seenReasons.add(thinkingKey)" in append_body
|
||||
|
||||
|
||||
def test_exact_echo_suppression_compares_turn_visible_texts():
|
||||
body = _render_messages_body()
|
||||
block = re.search(
|
||||
r"const turnsWithActivityGroup=new Set\(\);(.*?)const activityIdxs=",
|
||||
body,
|
||||
re.DOTALL,
|
||||
helper = _function_body("_worklogReasoningTextFromMessage")
|
||||
assert "assistantTurnVisibleContentByRawIdx" in body
|
||||
assert "_worklogReasoningTextFromMessage(m, rawIdx, toolCallAssistantIdxs, displayContent, turnFinalVisibleContent, turnVisibleContents)" in body
|
||||
assert "_stripVisibleAssistantEchoFromThinking(thinkingText, visibleContent, turnFinalVisibleContent, ...visibleTexts)" in helper, (
|
||||
"A thinking-only sibling that exactly echoes the visible process/final text "
|
||||
"should be suppressed after settlement."
|
||||
)
|
||||
assert block, "turnsWithActivityGroup population block not found"
|
||||
text = block.group(1)
|
||||
assert "closest('.assistant-turn')" in text, (
|
||||
"must map tool-bearing segments to their enclosing .assistant-turn"
|
||||
)
|
||||
assert "turnsWithActivityGroup.add(" in text
|
||||
|
||||
|
||||
def test_inline_card_inserted_before_body_and_footer():
|
||||
"""A2: when the inline render is correct, the card must land BEFORE the answer
|
||||
body / msg-foot (beforebegin), not appended after the 'Done in ...' footer."""
|
||||
def test_distinct_sibling_reasoning_is_still_available_to_worklog():
|
||||
body = _render_messages_body()
|
||||
# The inline branch selects the body/foot element and inserts before it.
|
||||
assert re.search(r"querySelector\(\s*'\.msg-body,\.msg-foot'\s*\)", body), (
|
||||
"inline branch must locate the .msg-body/.msg-foot element to anchor before it"
|
||||
assert "for(const aIdx of assistantThinking.keys())" in body, (
|
||||
"Each assistant reasoning entry should still be eligible for a Worklog item."
|
||||
)
|
||||
assert "insertAdjacentHTML('beforebegin'" in body, (
|
||||
"the inline thinking card must be inserted 'beforebegin' the answer body/footer "
|
||||
"(not 'beforeend', which strands it below 'Done in ...') (#3709 A2)"
|
||||
assert "const thinkingText=thinkingIdx!==null?assistantThinking.get(thinkingIdx):''" in body
|
||||
assert "seenReasons:state.seenReasons" in body, (
|
||||
"Duplicate suppression should be scoped to rendered Worklog keys, not by "
|
||||
"dropping reasoning metadata up front."
|
||||
)
|
||||
|
||||
|
||||
def test_no_unconditional_beforeend_thinking_in_inline_branch():
|
||||
"""The old orphaning insert ('beforeend' of the raw thinking card on the anchor
|
||||
row) must be gone from the inline branch."""
|
||||
def test_old_inline_activity_double_render_path_is_not_restored():
|
||||
body = _render_messages_body()
|
||||
# The specific regression pattern: appending the thinking card to the end of
|
||||
# the anchor row unconditionally. It must no longer be the inline path.
|
||||
assert "!cards.length&&assistantThinking.has(aIdx)" not in body, (
|
||||
"The old thinking-only inline Activity branch should not return in the "
|
||||
"#3401 Worklog model."
|
||||
)
|
||||
assert "anchorRow.insertAdjacentHTML('beforeend',_thinkingCardHtml(assistantThinking.get(aIdx)))" not in body, (
|
||||
"the inline branch must not append the thinking card to the end of the "
|
||||
"anchor row (that stranded it below the footer — the #3709 bug)"
|
||||
"Thinking must not be appended below the final answer/footer."
|
||||
)
|
||||
|
||||
|
||||
def test_turn_level_echo_strip_exists():
|
||||
"""B: thinking is stripped against the TURN's combined visible answer, not only
|
||||
the same message's body — so a trailing thinking-only message that echoes the
|
||||
answer gets de-duped too."""
|
||||
body = _render_messages_body()
|
||||
assert "_turnVisibleTextByRawIdx" in body, (
|
||||
"must build a per-turn combined visible-answer map (#3709 defect B)"
|
||||
assert "mergedThinking" not in body, (
|
||||
"The old Activity mergedThinking implementation should not be required "
|
||||
"after #3401 moves Thinking into Worklog sibling items."
|
||||
)
|
||||
# The strip site must consult the turn-level text in addition to displayContent.
|
||||
assert re.search(
|
||||
r"_turnVisibleTextByRawIdx\.get\(\s*rawIdx\s*\)",
|
||||
body,
|
||||
), "the echo-strip must look up the turn's combined visible text"
|
||||
# And it must feed that into the echo-strip helper.
|
||||
strip_block = re.search(
|
||||
r"_turnVisibleTextByRawIdx\.get\(\s*rawIdx\s*\)(.*?)_stripVisibleAssistantEchoFromThinking\(\s*thinkingText\s*,\s*turnVisible\s*\)",
|
||||
body,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert strip_block, (
|
||||
"the turn-level visible text must be passed to "
|
||||
"_stripVisibleAssistantEchoFromThinking"
|
||||
)
|
||||
|
||||
|
||||
def test_suppressed_sibling_thinking_merged_into_group_not_dropped():
|
||||
"""When the A1 gate suppresses a thinking-only sibling's inline card (because
|
||||
its turn has an Activity group), that sibling's thinking must NOT be lost — the
|
||||
group must render the TURN's merged thinking, not only the tool message's own
|
||||
entry. (Codex re-gate finding: rendering only assistantThinking.get(aIdx) for
|
||||
the tool index dropped a distinct sibling's reasoning.)"""
|
||||
body = _render_messages_body()
|
||||
# A per-turn thinking aggregation must exist...
|
||||
assert "turnThinkingParts" in body, (
|
||||
"must aggregate thinking per turn so a suppressed sibling's reasoning is "
|
||||
"carried into the Activity group, not dropped (#3709 / Codex re-gate)"
|
||||
)
|
||||
# ...and the Activity group must render the MERGED text, de-duped, once per turn.
|
||||
assert "mergedThinking" in body, (
|
||||
"the Activity group must render the turn's merged thinking"
|
||||
)
|
||||
assert "_renderedTurnThinking" in body, (
|
||||
"merged thinking must render once per turn (guard against double-emit when "
|
||||
"a turn has multiple tool messages)"
|
||||
)
|
||||
# The group node must be built from the merged text, not the single-index entry.
|
||||
assert re.search(
|
||||
r"_thinkingActivityNode\(\s*mergedThinking\s*,",
|
||||
body,
|
||||
), "the Activity group thinking node must be built from mergedThinking"
|
||||
@@ -6,69 +6,386 @@ family.
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def test_live_activity_group_has_observable_baseline_events():
|
||||
def _function_source(src, name):
|
||||
marker = f"function {name}("
|
||||
start = src.find(marker)
|
||||
assert start != -1
|
||||
brace = src.find("{", start)
|
||||
depth = 0
|
||||
for idx in range(brace, len(src)):
|
||||
ch = src[idx]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[start:idx + 1]
|
||||
raise AssertionError(f"function {name} did not close")
|
||||
|
||||
|
||||
def test_run_activity_group_has_observable_baseline_events():
|
||||
assert "function _ensureLiveActivityBaseline(group)" in UI_JS
|
||||
assert "function ensureRunActivityGroup(inner, opts)" in UI_JS
|
||||
assert "data-run-activity-group" in UI_JS
|
||||
assert "Run started" in UI_JS
|
||||
assert "Observable activity will appear here as the agent works." in UI_JS
|
||||
assert "Model: ${modelLabel}" in UI_JS
|
||||
assert "_ensureLiveActivityBaseline(group);" in UI_JS
|
||||
assert "ensureActivityGroup(inner, opts)" in UI_JS
|
||||
|
||||
|
||||
def test_empty_thinking_placeholder_becomes_status_row_not_raw_thinking_card():
|
||||
assert "data-activity-event-id=\"thinking-placeholder\"" in UI_JS
|
||||
assert "Starting agent" in UI_JS
|
||||
assert "Creating the stream and sending your message…" in UI_JS
|
||||
assert "Waiting for first model token" in UI_JS
|
||||
assert "Stream connected; no model output has arrived yet." in UI_JS
|
||||
assert "Waiting on model" in UI_JS
|
||||
assert "Reviewing the prompt and context, then choosing the next action or composing the response." in UI_JS
|
||||
assert "Reviewing prompt and context" in UI_JS
|
||||
assert "Waiting on tool result" in UI_JS
|
||||
assert "Last step: ${action} (${toolName}); now choosing the next action or composing a response." in UI_JS
|
||||
assert "_thinkingActivityNode(thinkingText, false)" in UI_JS
|
||||
def test_per_segment_tool_activity_does_not_include_run_metadata_rows():
|
||||
activity_fn = UI_JS.split("function ensureActivityGroup(inner, opts)", 1)[1].split("function ensureRunActivityGroup", 1)[0]
|
||||
tool_fn = UI_JS.split("function appendLiveToolCard(tc)", 1)[1].split("function clearLiveToolCards", 1)[0]
|
||||
assert "_ensureLiveActivityBaseline" not in activity_fn
|
||||
assert "_appendActivityEvent(group" not in tool_fn
|
||||
assert "Tool finished: ${toolName}" not in UI_JS
|
||||
assert "Running tool: ${toolName}" not in UI_JS
|
||||
assert "_worklogReasonNodeFromText(thinkingText" not in UI_JS
|
||||
assert "_thinkingActivityNode(clean, false)" in UI_JS
|
||||
assert "data-live-thinking-key" in UI_JS
|
||||
|
||||
|
||||
def test_stream_start_refreshes_waiting_status_after_stream_id_arrives():
|
||||
active_idx = MESSAGES_JS.find("S.activeStreamId = streamId;")
|
||||
assert active_idx != -1
|
||||
refresh_idx = MESSAGES_JS.find("appendThinking('',{pending:true})", active_idx)
|
||||
attach_idx = MESSAGES_JS.find("attachLiveStream(activeSid, streamId, uploadedNames);", active_idx)
|
||||
assert refresh_idx != -1
|
||||
assert attach_idx != -1
|
||||
assert refresh_idx < attach_idx
|
||||
|
||||
|
||||
def test_activity_feed_default_expand_setting_is_wired():
|
||||
index_html = (REPO / "static" / "index.html").read_text(encoding="utf-8")
|
||||
panels_js = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
config_py = (REPO / "api" / "config.py").read_text(encoding="utf-8")
|
||||
|
||||
assert 'id="settingsActivityFeedExpandedDefault"' in index_html
|
||||
assert "settings_label_activity_feed_expanded_default" in index_html
|
||||
assert '"activity_feed_expanded_default": False' in config_py
|
||||
assert "activity_feed_expanded_default" in panels_js
|
||||
assert "window._activityFeedExpandedDefault=!!s.activity_feed_expanded_default;" in boot_js
|
||||
assert "if(window._activityFeedExpandedDefault===true) collapsed=false;" in UI_JS
|
||||
finalize_fn = UI_JS.split("function finalizeThinkingCard")[1].split("\nfunction ")[0]
|
||||
assert "_activityFeedExpandedDefault" in finalize_fn
|
||||
assert "_liveActivityUserExpanded !== false" in finalize_fn
|
||||
|
||||
|
||||
def test_tool_events_update_activity_timeline_and_summary():
|
||||
assert "Tool finished: ${toolName}" in UI_JS
|
||||
assert "Running tool: ${toolName}" in UI_JS
|
||||
assert "No recent activity for ${_formatActiveElapsedTimer(idleAge)}" in UI_JS
|
||||
assert "Activity · Running" in UI_JS
|
||||
def test_tool_activity_uses_tool_cards_and_run_activity_owns_timer():
|
||||
assert "buildToolCard(tc)" in UI_JS
|
||||
build_fn = UI_JS.split("function buildToolCard(tc)", 1)[1].split("function _syncToolCallGroupSummary", 1)[0]
|
||||
assert "tool-card-duration" not in build_fn
|
||||
assert "tool-worklog-list" in UI_JS
|
||||
assert "tool-worklog-summary" in UI_JS
|
||||
assert "tool-call-group-duration" in UI_JS
|
||||
assert "Activity · Running" not in UI_JS
|
||||
assert "Working for ${label}" in UI_JS
|
||||
assert "_isActivityTimerGroup(group)" in UI_JS
|
||||
assert "opts.turnDuration" in UI_JS
|
||||
assert "data-turn-duration" in UI_JS
|
||||
assert "durationText?` Done in ${durationText}`" in UI_JS
|
||||
assert "return !!(group&&group.getAttribute('data-run-activity-group')==='1');" in UI_JS
|
||||
live_summary_fn = UI_JS.split("function _syncToolCallGroupSummary(group)", 1)[1].split("function _activityProgressLabelForToolName", 1)[0]
|
||||
assert "_activityLiveProgressLabel(group)" in live_summary_fn
|
||||
assert "[progressText, activeText].filter(Boolean).join(' · ')" in live_summary_fn
|
||||
|
||||
|
||||
def test_settled_activity_render_keeps_tools_bound_to_progress_bursts():
|
||||
render_fn = UI_JS.split("if(!S.busy || (S.toolCalls&&S.toolCalls.length)){", 1)[1].split("// Render per-turn duration", 1)[0]
|
||||
assert "_assistantAnchorForActivity" in render_fn
|
||||
assert "const byActivity = new Map()" in render_fn
|
||||
assert "tc.activityBurstId" in render_fn
|
||||
assert "activityByTurn" in render_fn
|
||||
assert "_appendWorklogStep(state.group" in render_fn
|
||||
assert "ensureActivityGroup(anchorParent,{" in render_fn
|
||||
assert "_toolWorklogListEl(group)" in render_fn
|
||||
|
||||
|
||||
def test_settled_final_answer_segment_is_not_folded_into_worklog():
|
||||
helper = _function_source(UI_JS, "_assistantMessageBelongsInWorklog")
|
||||
render_fn = UI_JS.split("const messageBelongsInWorklog=", 1)[1].split("if(_ERR_MSG_RE", 1)[0]
|
||||
|
||||
assert "hasVisibleText&&isTurnFinalAssistant" in helper
|
||||
assert "return false;" in helper
|
||||
assert "{isTurnFinalAssistant}" in render_fn
|
||||
assert "assistant-segment-worklog-source" in render_fn
|
||||
|
||||
|
||||
def test_settled_worklog_only_reads_anchor_reasons_from_hidden_progress_segments():
|
||||
render_fn = UI_JS.split("for(const [aIdx,seg] of assistantSegments)", 1)[1].split("activityOrder.sort", 1)[0]
|
||||
|
||||
assert "contains('assistant-segment-worklog-source')" in render_fn
|
||||
assert "_assistantMessageBelongsInWorklog(msg,aIdx,toolCallAssistantIdxs)" not in render_fn
|
||||
|
||||
|
||||
def test_settled_worklog_does_not_reuse_run_activity_group():
|
||||
activity_fn = UI_JS.split("function ensureActivityGroup(inner, opts)", 1)[1].split("function normalizeLiveActivityGroupPlacement", 1)[0]
|
||||
assert ':not([data-run-activity-group="1"])' in activity_fn
|
||||
assert "if(!group&&!activityKey)" in activity_fn
|
||||
assert "data-tool-worklog-group" in activity_fn
|
||||
|
||||
|
||||
def test_settled_worklog_can_move_anchor_text_into_reason():
|
||||
reason_fn = UI_JS.split("function _worklogReasonHtmlFromAnchor(anchor, textOverride)", 1)[1].split("function _syncWorklogReasonFromAnchor", 1)[0]
|
||||
assert "matches('.assistant-segment')" in reason_fn
|
||||
assert "matches('[data-live-assistant=\"1\"]')" not in reason_fn
|
||||
|
||||
|
||||
def test_settled_render_skips_empty_activity_buckets():
|
||||
render_fn = UI_JS.split("for(const entry of activityOrder){", 1)[1].split("// Render per-turn duration", 1)[0]
|
||||
assert "const anchorReasonHtml=_worklogReasonHtmlFromAnchor(anchorRow);" in render_fn
|
||||
assert "if(!cards.length&&!anchorReasonHtml&&!thinkingText) continue;" in render_fn
|
||||
|
||||
|
||||
def test_reattach_normalizes_live_activity_group_placement_by_burst_anchor():
|
||||
assert "function normalizeLiveActivityGroupPlacement(turn)" in UI_JS
|
||||
assert "normalizeLiveActivityGroupPlacement(restored)" in UI_JS
|
||||
activity_fn = UI_JS.split("function ensureActivityGroup(inner, opts)", 1)[1].split("function normalizeLiveActivityGroupPlacement", 1)[0]
|
||||
assert "anchor.insertAdjacentElement('afterend',group);" in activity_fn
|
||||
normalize_fn = UI_JS.split("function normalizeLiveActivityGroupPlacement(turn)", 1)[1].split("function ensureRunActivityGroup", 1)[0]
|
||||
assert '.tool-call-group[data-live-tool-worklog-group="1"],.tool-call-group[data-live-tool-call-group="1"]' in normalize_fn
|
||||
assert "_findLiveAssistantAnchorForSegment(blocks, segmentSeq)" in normalize_fn
|
||||
assert "_findLatestVisibleLiveAssistantByBurst(blocks, burstId)" in normalize_fn
|
||||
assert "_findLatestVisibleLiveAssistant(blocks)" in normalize_fn
|
||||
|
||||
|
||||
def test_done_handler_preserves_live_tool_burst_metadata_for_settled_render():
|
||||
assert "function _mergeSettledToolCallsWithLiveMetadata(rawCalls)" in MESSAGES_JS
|
||||
assert "activityBurstId" in MESSAGES_JS
|
||||
assert "S.toolCalls=_mergeSettledToolCallsWithLiveMetadata(d.session.tool_calls);" in MESSAGES_JS
|
||||
assert "S.toolCalls=_mergeSettledToolCallsWithLiveMetadata(session.tool_calls||[]);" in MESSAGES_JS
|
||||
|
||||
|
||||
def test_message_tool_metadata_path_keeps_live_burst_metadata_available():
|
||||
assert "S._settledLiveToolMetadata=S.toolCalls.map" in MESSAGES_JS
|
||||
assert "S.toolCalls=hasMessageToolMetadata?[]:S.toolCalls.map" in MESSAGES_JS
|
||||
render_fn = UI_JS.split("const derived=[];", 1)[1].split("if(derived.length) S.toolCalls=derived;", 1)[0]
|
||||
assert "S._settledLiveToolMetadata" in render_fn
|
||||
assert "liveToolMetadata" in render_fn
|
||||
assert "copyLiveToolMetadata" in render_fn
|
||||
assert "activityBurstId" in render_fn
|
||||
|
||||
|
||||
def test_message_tool_metadata_empty_assistant_tools_reuse_previous_visible_anchor():
|
||||
assert "function _assistantToolAnchorIdxForMessage(messages, rawIdx)" in UI_JS
|
||||
render_fn = UI_JS.split("const derived=[];", 1)[1].split("if(derived.length) S.toolCalls=derived;", 1)[0]
|
||||
assert "const assistantToolAnchorIdx=_assistantToolAnchorIdxForMessage(S.messages,rawIdx);" in render_fn
|
||||
assert "assistant_msg_idx:assistantToolAnchorIdx" in render_fn
|
||||
|
||||
assert NODE, "node not on PATH"
|
||||
has_visible_fn = _function_source(UI_JS, "_assistantMessageHasVisibleContent")
|
||||
empty_placeholder_fn = _function_source(UI_JS, "_isAssistantEmptyPlaceholderContent")
|
||||
has_reasoning_fn = _function_source(UI_JS, "_messageHasReasoningPayload")
|
||||
reasoning_fn = _function_source(UI_JS, "_assistantReasoningPayloadText")
|
||||
anchor_fn = _function_source(UI_JS, "_assistantToolAnchorIdxForMessage")
|
||||
script = f"""
|
||||
const assert = require('assert');
|
||||
function _isRecoveryControlMessage(){{ return false; }}
|
||||
function msgContent(m){{
|
||||
if(!m) return '';
|
||||
if(typeof m.content === 'string') return m.content;
|
||||
if(Array.isArray(m.content)) return m.content.map(part => part && typeof part.text === 'string' ? part.text : '').join('');
|
||||
return '';
|
||||
}}
|
||||
{has_reasoning_fn}
|
||||
{empty_placeholder_fn}
|
||||
{has_visible_fn}
|
||||
{reasoning_fn}
|
||||
{anchor_fn}
|
||||
const messages = [
|
||||
{{role:'assistant', content:'visible progress'}},
|
||||
{{role:'assistant', content:'', tool_calls:[{{id:'call-1'}}]}},
|
||||
{{role:'assistant', content:'', tool_calls:[{{id:'call-2'}}]}},
|
||||
{{role:'assistant', content:'next progress', tool_calls:[{{id:'call-3'}}]}},
|
||||
{{role:'assistant', content:[{{type:'tool_use', id:'call-4', name:'read_file'}}]}},
|
||||
{{role:'assistant', content:'', reasoning_content:'process text', tool_calls:[{{id:'call-5'}}]}},
|
||||
];
|
||||
assert.strictEqual(_assistantToolAnchorIdxForMessage(messages, 1), 0);
|
||||
assert.strictEqual(_assistantToolAnchorIdxForMessage(messages, 2), 0);
|
||||
assert.strictEqual(_assistantToolAnchorIdxForMessage(messages, 3), 3);
|
||||
assert.strictEqual(_assistantToolAnchorIdxForMessage(messages, 4), 3);
|
||||
assert.strictEqual(_assistantToolAnchorIdxForMessage(messages, 5), 5);
|
||||
"""
|
||||
result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_settled_tool_metadata_merge_replaces_null_activity_metadata():
|
||||
assert NODE, "node not on PATH"
|
||||
fn = _function_source(MESSAGES_JS, "_mergeSettledToolCallsWithLiveMetadata")
|
||||
script = f"""
|
||||
const assert = require('assert');
|
||||
const S = {{
|
||||
toolCalls: [{{tid:'tool-1', name:'read_file', activityBurstId:2, duration:1.25, started_at:123}}]
|
||||
}};
|
||||
{fn}
|
||||
const merged = _mergeSettledToolCallsWithLiveMetadata([
|
||||
{{tid:'tool-1', name:'read_file', activityBurstId:null, duration:null, started_at:null}}
|
||||
]);
|
||||
assert.strictEqual(merged[0].activityBurstId, 2);
|
||||
assert.strictEqual(merged[0].duration, 1.25);
|
||||
assert.strictEqual(merged[0].started_at, 123);
|
||||
"""
|
||||
result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_settled_activity_render_treats_burst_zero_as_unanchored_activity():
|
||||
render_fn = UI_JS.split("if(!S.busy || (S.toolCalls&&S.toolCalls.length)){", 1)[1].split("// Render per-turn duration", 1)[0]
|
||||
assert "String(burstId)!=='0'" in render_fn
|
||||
assert "if(aIdx<assistantIdxs[0]) return null;" in render_fn
|
||||
assert "normalizeToken(tc.activityBurstId)" in render_fn
|
||||
|
||||
|
||||
def test_record_activity_boundary_updates_segment_burst_id_to_post_increment():
|
||||
"""recordActivityBoundary must re-stamp the current assistantRow DOM element with
|
||||
the post-increment burst id so that subsequent tool events (which read the same
|
||||
_currentActivityBurstId) find the matching [data-activity-burst-id] anchor.
|
||||
|
||||
Without this update the segment keeps id=N while tools get id=N+1, causing
|
||||
appendLiveToolCard to miss the anchor and Activity groups to pile up after all
|
||||
text instead of interleaving with their source segments.
|
||||
"""
|
||||
boundary_fn = MESSAGES_JS.split("function recordActivityBoundary()", 1)[1].split("function ensureAssistantRow", 1)[0]
|
||||
# Must update the DOM attribute after incrementing the counter
|
||||
assert "assistantRow.setAttribute('data-activity-burst-id',String(_currentActivityBurstId))" in boundary_fn
|
||||
# The update must be guarded so it only fires when assistantRow exists
|
||||
assert "if(assistantRow) assistantRow.setAttribute" in boundary_fn
|
||||
|
||||
|
||||
def test_record_activity_boundary_does_not_create_empty_duplicate_burst():
|
||||
boundary_fn = MESSAGES_JS.split("function recordActivityBoundary()", 1)[1].split("function ensureAssistantRow", 1)[0]
|
||||
assert "const lastTextEnd=inflight.activityBurstAnchors.reduce" in boundary_fn
|
||||
assert "if(textEnd<=lastTextEnd)" in boundary_fn
|
||||
assert "_currentActivityBurstId+=1;" in boundary_fn
|
||||
assert boundary_fn.find("if(textEnd<=lastTextEnd)") < boundary_fn.find("_currentActivityBurstId+=1;")
|
||||
|
||||
|
||||
def test_inactive_interim_assistant_still_records_activity_boundary():
|
||||
"""A session can receive SSE events while the pane is being switched away.
|
||||
|
||||
Token/tool state is still persisted for reattach in that window, so interim
|
||||
progress boundaries must also be persisted before the inactive-pane return.
|
||||
Otherwise later tool calls keep an activityBurstId with no text anchor and
|
||||
Activity groups pile up at the tail after switching back.
|
||||
"""
|
||||
wire_fn = MESSAGES_JS.split("function _wireSSE(source)", 1)[1].split("source.addEventListener('reasoning'", 1)[0]
|
||||
inactive_returns = [
|
||||
idx for idx in range(len(wire_fn))
|
||||
if wire_fn.startswith("if(!S.session||S.session.session_id!==activeSid){", idx)
|
||||
]
|
||||
assert len(inactive_returns) >= 2
|
||||
for idx in inactive_returns[:2]:
|
||||
branch = wire_fn[idx:wire_fn.find("}", idx) + 1]
|
||||
assert "recordActivityBoundary();" in branch
|
||||
assert "_resetAssistantSegment();" in branch
|
||||
|
||||
|
||||
def test_tool_event_flushes_pending_text_before_inserting_activity():
|
||||
"""A tool card must not appear before the text segment it is anchored to.
|
||||
|
||||
Token rendering is throttled through rAF. On mobile/slow clients a `tool`
|
||||
event can arrive while the current assistantRow exists but its pending text
|
||||
has not been written into `.msg-body` yet. If appendLiveToolCard() runs
|
||||
first, the Activity group appears, then the delayed flush fills the empty
|
||||
segment above it a frame later, which looks like process text was inserted
|
||||
before an already-visible Activity row.
|
||||
"""
|
||||
tool_handler = MESSAGES_JS.split("source.addEventListener('tool',e=>{", 1)[1].split("source.addEventListener('tool_complete'", 1)[0]
|
||||
flush_pos = tool_handler.find("_flushPendingSegmentRender({force:true});")
|
||||
append_pos = tool_handler.find("appendLiveToolCard(tc")
|
||||
assert flush_pos != -1 and append_pos != -1
|
||||
assert flush_pos < append_pos
|
||||
|
||||
|
||||
def test_pending_text_flush_syncs_existing_worklog_reason():
|
||||
"""If Activity was created before text was flushed, the next text render must
|
||||
move that text into the existing Worklog instead of leaving it as a separate
|
||||
assistant segment above the tool rows.
|
||||
"""
|
||||
assert "function _syncLiveWorklogReasonsForAnchor(anchor, displayTextOverride)" in UI_JS
|
||||
flush_fn = MESSAGES_JS.split("function _flushPendingSegmentRender(options={})", 1)[1].split("function _resetAssistantSegment", 1)[0]
|
||||
assert "_syncLiveWorklogReasonsForAnchor(assistantRow, displayText)" in flush_fn
|
||||
render_fn = MESSAGES_JS.split("const _doRender=()=>{", 1)[1].split("scrollIfPinned();", 1)[0]
|
||||
assert "_syncLiveWorklogReasonsForAnchor(assistantRow, displayText)" in render_fn
|
||||
|
||||
|
||||
def test_pending_text_flush_passes_display_text_to_worklog_reason_sync():
|
||||
"""Forced flush owns the authoritative displayText for this frame.
|
||||
|
||||
Do not make Worklog reason synchronization depend only on reading the
|
||||
already-rendered DOM: streaming-markdown can lag a frame behind the known
|
||||
displayText during bursty token/tool boundaries.
|
||||
"""
|
||||
sync_fn = _function_source(UI_JS, "_syncLiveWorklogReasonsForAnchor")
|
||||
assert "displayTextOverride" in sync_fn
|
||||
assert "_syncWorklogReasonFromAnchor(group, anchor, displayTextOverride)" in sync_fn
|
||||
flush_fn = MESSAGES_JS.split("function _flushPendingSegmentRender(options={})", 1)[1].split("function _resetAssistantSegment", 1)[0]
|
||||
assert "_syncLiveWorklogReasonsForAnchor(assistantRow, displayText)" in flush_fn
|
||||
render_fn = MESSAGES_JS.split("const _doRender=()=>{", 1)[1].split("scrollIfPinned();", 1)[0]
|
||||
assert "_syncLiveWorklogReasonsForAnchor(assistantRow, displayText)" in render_fn
|
||||
|
||||
|
||||
def test_tool_event_does_not_create_blank_text_segment_without_pending_text():
|
||||
"""Tool-only bursts should not create empty assistant text segments.
|
||||
|
||||
A tool event can arrive before any visible answer text exists. Forcing
|
||||
ensureAssistantRow(true) in that path creates a blank `.assistant-segment`
|
||||
above every Activity group, making Live Stream look unstable during long
|
||||
polling turns.
|
||||
"""
|
||||
tool_handler = MESSAGES_JS.split("source.addEventListener('tool',e=>{", 1)[1].split("source.addEventListener('tool_complete'", 1)[0]
|
||||
upsert_pos = tool_handler.find("const tc=upsertLiveToolCall(d,'start');")
|
||||
guard_pos = tool_handler.find("String(pendingDisplayText||'').trim()")
|
||||
force_pos = tool_handler.find("ensureAssistantRow(true);")
|
||||
append_pos = tool_handler.find("appendLiveToolCard(tc")
|
||||
assert upsert_pos != -1 and guard_pos != -1 and force_pos != -1 and append_pos != -1
|
||||
assert upsert_pos < guard_pos < force_pos < append_pos
|
||||
assert "if(!assistantRow||!assistantBody) ensureAssistantRow(true);" not in tool_handler
|
||||
|
||||
|
||||
def test_orphan_tool_complete_does_not_create_blank_text_segment_without_pending_text():
|
||||
"""An orphan tool_complete should not manufacture an empty assistant segment."""
|
||||
complete_handler = MESSAGES_JS.split("source.addEventListener('tool_complete',e=>{", 1)[1].split("source.addEventListener('approval'", 1)[0]
|
||||
orphan_branch = complete_handler.split("if(tc._createdByComplete){", 1)[1].split("} else {", 1)[0]
|
||||
guard_pos = orphan_branch.find("String(pendingDisplayText||'').trim()")
|
||||
force_pos = orphan_branch.find("ensureAssistantRow(true);")
|
||||
flush_pos = orphan_branch.find("_flushPendingSegmentRender({force:true});")
|
||||
append_pos = orphan_branch.find("appendLiveToolCard(tc")
|
||||
assert guard_pos != -1 and force_pos != -1 and flush_pos != -1 and append_pos != -1
|
||||
assert guard_pos < force_pos < flush_pos < append_pos
|
||||
assert "if(!assistantRow||!assistantBody) ensureAssistantRow(true);" not in orphan_branch
|
||||
|
||||
|
||||
def test_reattach_segment_start_aligns_with_last_burst_anchor():
|
||||
"""Simulate the reattach segmentStart initializer with multiple anchors.
|
||||
|
||||
The initializer must clamp to the actual assistantText length and ignore
|
||||
stale anchors past the end of the text, otherwise displayText slicing in
|
||||
_doRender would produce empty output for the tail segment.
|
||||
"""
|
||||
assert NODE, "node not on PATH"
|
||||
body = MESSAGES_JS.split("function attachLiveStream(", 1)[1]
|
||||
seg_block_start = body.find("let segmentStart=(()=>{")
|
||||
assert seg_block_start != -1, "expected reconnect-aware segmentStart IIFE"
|
||||
seg_block_end = body.find("})();", seg_block_start) + len("})();")
|
||||
initializer = body[seg_block_start:seg_block_end] + ";"
|
||||
# Wrap as a callable with explicit reconnecting + INFLIGHT/activeSid stand-ins.
|
||||
script = f"""
|
||||
const assert = require('assert');
|
||||
function computeStart(reconnecting, inflight, assistantText) {{
|
||||
const INFLIGHT = {{ 'sid': inflight }};
|
||||
const activeSid = 'sid';
|
||||
{initializer}
|
||||
return segmentStart;
|
||||
}}
|
||||
// No anchors -> 0
|
||||
assert.strictEqual(computeStart(true, {{activityBurstAnchors:[]}}, 'hello world'), 0);
|
||||
// Single anchor inside text length -> anchor textEnd
|
||||
assert.strictEqual(computeStart(true, {{activityBurstAnchors:[{{id:1,textEnd:5}}]}}, 'hello world'), 5);
|
||||
// Multiple anchors -> picks max textEnd within text length
|
||||
assert.strictEqual(computeStart(true, {{activityBurstAnchors:[
|
||||
{{id:1,textEnd:5}}, {{id:2,textEnd:11}}, {{id:3,textEnd:7}}
|
||||
]}}, 'hello world'), 11);
|
||||
// Anchor textEnd past assistantText length -> ignored
|
||||
assert.strictEqual(computeStart(true, {{activityBurstAnchors:[
|
||||
{{id:1,textEnd:5}}, {{id:2,textEnd:99}}
|
||||
]}}, 'hello world'), 5);
|
||||
// Not reconnecting -> always 0
|
||||
assert.strictEqual(computeStart(false, {{activityBurstAnchors:[
|
||||
{{id:1,textEnd:5}}, {{id:2,textEnd:11}}
|
||||
]}}, 'hello world'), 0);
|
||||
// Missing inflight entry -> 0
|
||||
assert.strictEqual(computeStart(true, undefined, 'hello'), 0);
|
||||
"""
|
||||
result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_activity_status_rows_have_quiet_metadata_styling():
|
||||
|
||||
@@ -36,8 +36,6 @@ def test_recovery_control_detection_is_not_broad_phrase_matching():
|
||||
assert "|| /continue exactly where you left off/i.test(normalized)" not in MESSAGES_JS
|
||||
assert "const systemRecovery=/^\\[System:/i.test(normalized)" in UI_JS
|
||||
assert "const backendRecovery=/^the live worker stopped before this run finished\\.?$/i.test(normalized)" in UI_JS
|
||||
|
||||
|
||||
def test_recovery_control_does_not_filter_genuine_interruption_card():
|
||||
"""A real 'Response interrupted' card carries provider_details_label
|
||||
'Interruption details' but is NOT a recovery-control row — it must stay
|
||||
|
||||
@@ -68,6 +68,10 @@ def test_tool_callback_events_keep_existing_frontend_event_contract():
|
||||
|
||||
assert "source.addEventListener('tool',e=>{" in messages
|
||||
assert "source.addEventListener('tool_complete',e=>{" in messages
|
||||
assert "tid:d.tid" in messages
|
||||
assert "String(d&&d.tid" in messages or "explicitTid=String(d&&d.tid" in messages, (
|
||||
"frontend tool handlers must still consume explicit server tid when present"
|
||||
)
|
||||
assert "upsertLiveToolCall(d,'start')" in messages
|
||||
assert "upsertLiveToolCall(d,'complete')" in messages
|
||||
assert "data-live-tid" in ui
|
||||
assert "existing.replaceWith(replacement)" in ui
|
||||
+93
-16
@@ -428,7 +428,7 @@ def test_loadSession_inflight_restores_live_tool_cards(cleanup_test_sessions):
|
||||
# INFLIGHT branch must call appendLiveToolCard
|
||||
inflight_idx = src.find("if(INFLIGHT[sid]){")
|
||||
assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession"
|
||||
inflight_block = src[inflight_idx:inflight_idx+1600]
|
||||
inflight_block = src[inflight_idx:inflight_idx+4200]
|
||||
assert "appendLiveToolCard" in inflight_block, "loadSession INFLIGHT branch must restore live tool cards via appendLiveToolCard"
|
||||
assert "clearLiveToolCards" in inflight_block, "loadSession INFLIGHT branch must clear old live cards before restoring"
|
||||
|
||||
@@ -543,6 +543,18 @@ def test_chat_start_persists_pending_turn_metadata_for_reload_recovery(cleanup_t
|
||||
assert '"pending_user_message": getattr(s, "pending_user_message", None)' in routes_src
|
||||
|
||||
|
||||
def test_session_detail_uses_runtime_streaming_state(cleanup_test_sessions):
|
||||
"""GET /api/session must agree with /api/sessions on live stream ownership."""
|
||||
routes_src = (REPO_ROOT / "api/routes.py").read_text()
|
||||
session_route = routes_src.split('if parsed.path == "/api/session":', 1)[1].split(
|
||||
'if parsed.path == "/api/session/lineage/report":', 1
|
||||
)[0]
|
||||
assert "active_stream_ids = _active_stream_ids()" in session_route
|
||||
assert "s.compact(" in session_route
|
||||
assert "include_runtime=True" in session_route
|
||||
assert "active_stream_ids=active_stream_ids" in session_route
|
||||
|
||||
|
||||
def test_reload_path_restores_pending_message_and_reattaches_live_stream(cleanup_test_sessions):
|
||||
"""R15d: the frontend reload path must show the pending user turn and
|
||||
reattach to the live SSE stream after loadSession().
|
||||
@@ -558,6 +570,19 @@ def test_reload_path_restores_pending_message_and_reattaches_live_stream(cleanup
|
||||
'const activeStreamId=S.session.active_stream_id||null;' in sessions_src)
|
||||
assert 'attachLiveStream(sid, activeStreamId' in sessions_src
|
||||
assert 'if (S.activeStreamId && S.activeStreamId === streamId) return;' in ui_src
|
||||
active_branch_start = sessions_src.index("if(activeStreamId){\n S.busy=true;")
|
||||
active_branch_end = sessions_src.index("}else{\n S.busy=false;", active_branch_start)
|
||||
active_branch = sessions_src[active_branch_start:active_branch_end]
|
||||
render_pos = active_branch.index("renderMessages(")
|
||||
shell_pos = active_branch.index("ensureLiveWorklogShell")
|
||||
assert render_pos < shell_pos, (
|
||||
"Reloading an active stream must recreate the live worklog shell after "
|
||||
"renderMessages() rebuilds msgInner; otherwise the stream stays invisible "
|
||||
"until a session switch triggers another restore path."
|
||||
)
|
||||
assert "else appendThinking();" in active_branch, (
|
||||
"Non-simplified tool-calling must keep the legacy fallback."
|
||||
)
|
||||
|
||||
|
||||
# ── R16: Switching away/back must preserve live partial assistant output ─────
|
||||
@@ -609,7 +634,7 @@ def test_inflight_session_state_tracks_live_tool_cards_per_session(cleanup_test_
|
||||
messages_src = (REPO_ROOT / "static/messages.js").read_text()
|
||||
sessions_src = (REPO_ROOT / "static/sessions.js").read_text()
|
||||
|
||||
assert "INFLIGHT[activeSid].toolCalls.push(tc);" in messages_src, \
|
||||
assert "inflight.toolCalls.push(tc)" in messages_src, \
|
||||
"tool SSE handler must persist live tool calls onto the in-flight session"
|
||||
assert "S.toolCalls=(INFLIGHT[sid].toolCalls||[]);" in sessions_src, \
|
||||
"loadSession() must restore live tool calls from the in-flight session state"
|
||||
@@ -624,7 +649,7 @@ def test_loadSession_inflight_sets_busy_before_renderMessages(cleanup_test_sessi
|
||||
src = (REPO_ROOT / "static/sessions.js").read_text()
|
||||
inflight_idx = src.find("if(INFLIGHT[sid]){")
|
||||
assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession"
|
||||
inflight_block = src[inflight_idx:inflight_idx+1600]
|
||||
inflight_block = src[inflight_idx:inflight_idx+4200]
|
||||
busy_pos = inflight_block.find("S.busy=true;")
|
||||
# #3326 added an optional {preserveScroll} arg to the INFLIGHT-branch render
|
||||
# call, so match the call form rather than the bare `renderMessages();`.
|
||||
@@ -652,6 +677,34 @@ def test_loadSession_inflight_merges_tail_with_persisted_transcript(cleanup_test
|
||||
)
|
||||
|
||||
|
||||
def test_renderMessages_preserves_loading_placeholder_for_session_switch(cleanup_test_sessions):
|
||||
"""R16d: renderMessages should not repaint transcript during session-load window.
|
||||
|
||||
During loadSession(sid) after the loading metadata call, S.messages is
|
||||
intentionally empty until _ensureMessagesLoaded(sid) settles. A concurrent
|
||||
renderMessages() must keep the existing 'Loading conversation...' placeholder
|
||||
instead of clearing #msgInner to an empty transcript.
|
||||
"""
|
||||
ui_src = (REPO_ROOT / "static/ui.js").read_text()
|
||||
fn_start = ui_src.find("function renderMessages")
|
||||
assert fn_start >= 0, "renderMessages() not found in ui.js"
|
||||
fn_body = ui_src[fn_start:fn_start + 1400]
|
||||
|
||||
compact = re.sub(r"\s+", "", fn_body)
|
||||
assert (
|
||||
"if(_loadingSessionId===sid&&msgCount===0&&inner)return;" in compact
|
||||
), (
|
||||
"renderMessages() must return early when loadSession is active for"
|
||||
" the current sid and S.messages is still empty."
|
||||
)
|
||||
|
||||
# Guard must live before render-window reset and message-filter pass.
|
||||
reset_pos = compact.find("if(sid!==_messageRenderWindowSid)_resetMessageRenderWindow(sid);")
|
||||
guard_pos = compact.find("if(_loadingSessionId===sid&&msgCount===0&&inner)return;")
|
||||
assert (
|
||||
0 <= guard_pos < reset_pos
|
||||
), "Session-load empty-state guard must run before render-window/state resets."
|
||||
|
||||
|
||||
def test_browser_session_url_accepts_api_session_id_param(cleanup_test_sessions):
|
||||
"""External links using ?session_id=... should open that session in the browser.
|
||||
@@ -705,7 +758,7 @@ def test_loadSession_inflight_sets_active_stream_before_replaying_live_tool_card
|
||||
src = (REPO_ROOT / "static/sessions.js").read_text()
|
||||
inflight_idx = src.find("if(INFLIGHT[sid]){")
|
||||
assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession"
|
||||
inflight_block = src[inflight_idx:inflight_idx+1600]
|
||||
inflight_block = src[inflight_idx:inflight_idx+4200]
|
||||
active_pos = inflight_block.find("S.activeStreamId=activeStreamId;")
|
||||
replay_pos = inflight_block.find("appendLiveToolCard(tc);")
|
||||
attach_pos = inflight_block.find("attachLiveStream(sid, activeStreamId")
|
||||
@@ -773,12 +826,19 @@ def test_messages_js_supports_live_reasoning_and_tool_completion(cleanup_test_se
|
||||
# On initial connect it defaults to ''; on reconnect it restores from
|
||||
# INFLIGHT so the already-rendered content survives the session switch.
|
||||
assert ("let reasoningText=''" in src
|
||||
or "let reasoningText = _lastLiveAssistant" in src), \
|
||||
or "let reasoningText = _lastLiveAssistant" in src
|
||||
or "let reasoningText=_lastLiveReasoning" in src), \
|
||||
"messages.js must track streamed reasoning text separately from assistant text"
|
||||
assert ("let liveReasoningText=''" in src or "let liveReasoningText = reasoningText" in src), \
|
||||
assert ("let liveReasoningText=''" in src
|
||||
or "let liveReasoningText = 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, \
|
||||
"messages.js must listen for live reasoning SSE events"
|
||||
assert "liveReasoningText += text" in src, \
|
||||
"live reasoning SSE events must update the active Worklog Thinking Card text"
|
||||
assert "_updateLiveThinkingCard(_liveThinkingText())" in src, \
|
||||
"live reasoning SSE events must refresh the current segment's Worklog Thinking Card"
|
||||
assert "source.addEventListener('tool_complete'" in src or 'source.addEventListener("tool_complete"' in src, \
|
||||
"messages.js must listen for live tool completion SSE events"
|
||||
assert "function _parseStreamState()" in src, \
|
||||
@@ -803,14 +863,15 @@ def test_messages_js_supports_interim_assistant_events(cleanup_test_sessions):
|
||||
|
||||
|
||||
def test_ui_js_can_upgrade_thinking_spinner_into_live_reasoning_card(cleanup_test_sessions):
|
||||
"""R19: ui.js must be able to replace the placeholder thinking spinner with
|
||||
streamed reasoning text while a turn is in progress.
|
||||
"""R19: ui.js keeps the thinking helpers available while simplified mode
|
||||
renders provider reasoning as a Worklog Thinking Card.
|
||||
"""
|
||||
src = (REPO_ROOT / "static/ui.js").read_text()
|
||||
assert "function _thinkingMarkup(text='')" in src or 'function _thinkingMarkup(text="")' in src, \
|
||||
"ui.js must centralize thinking row markup so it can switch between spinner and live text"
|
||||
assert "function updateThinking(text=''){appendThinking(text);}" in src or 'function updateThinking(text=""){appendThinking(text);}' in src, \
|
||||
"ui.js must expose an updateThinking helper for live reasoning rendering"
|
||||
assert ("function updateThinking(text='', options){appendThinking(text, options);}" in src
|
||||
or 'function updateThinking(text="", options){appendThinking(text, options);}' in src), \
|
||||
"ui.js must expose an updateThinking helper that preserves live Thinking placement metadata"
|
||||
assert "function finalizeThinkingCard()" in src, \
|
||||
"ui.js must expose a helper to finalize one live thinking card before starting another"
|
||||
|
||||
@@ -841,15 +902,23 @@ def test_ui_js_keeps_reasoning_only_assistant_messages_visible(cleanup_test_sess
|
||||
|
||||
|
||||
def test_ui_js_does_not_hide_anchor_segments_that_contain_thinking(cleanup_test_sessions):
|
||||
"""R19c2/R19c3: reasoning-only messages must remain visible through the
|
||||
shared compact timeline activity UI, even when the anchor segment has no prose.
|
||||
"""R19c2/R19c3: reasoning-only metadata must remain preserved as a
|
||||
collapsed Worklog Thinking Card.
|
||||
"""
|
||||
src = (REPO_ROOT / "static" / "ui.js").read_text()
|
||||
compact = src.replace(' ', '').replace('\n', '')
|
||||
assert "assistantThinking.set(rawIdx,thinkingText)" in compact, \
|
||||
"renderMessages must preserve reasoning text before hiding empty anchor segments"
|
||||
helper_start = src.find("function _worklogReasoningTextFromMessage")
|
||||
helper_end = src.find("function _thinkingCardHtml", helper_start)
|
||||
assert helper_start != -1 and helper_end != -1
|
||||
helper = src[helper_start:helper_end]
|
||||
assert "_assistantReasoningPayloadText(m)" in helper and "_stripVisibleAssistantEchoFromThinking" in helper, \
|
||||
"provider reasoning metadata should feed the Worklog Thinking Card after exact duplicate suppression"
|
||||
assert "data-worklog-thinking-card" in src, \
|
||||
"Thinking Cards should be explicit Worklog items, not tool cards"
|
||||
assert "_thinkingActivityNode(thinkingText, false)" in src, \
|
||||
"thinking-only assistant content should render as a collapsed timeline Thinking card"
|
||||
"settled reasoning should render as a collapsed Worklog Thinking Card"
|
||||
|
||||
|
||||
def test_messages_js_live_assistant_segment_reuses_live_turn_wrapper(cleanup_test_sessions):
|
||||
@@ -872,12 +941,20 @@ def test_messages_js_live_assistant_segment_reuses_live_turn_wrapper(cleanup_tes
|
||||
|
||||
|
||||
def test_messages_js_finalizes_thinking_card_before_tool_card(cleanup_test_sessions):
|
||||
"""R19e: later reasoning after a tool call must render in a fresh card."""
|
||||
"""R19e: later reasoning after a tool call must render in a fresh Worklog
|
||||
Thinking Card without discarding durable reasoning.
|
||||
"""
|
||||
src = (REPO_ROOT / "static/messages.js").read_text()
|
||||
assert "finalizeThinkingCard" in src, \
|
||||
tool_start = src.find("source.addEventListener('tool'")
|
||||
tool_complete_start = src.find("source.addEventListener('tool_complete'", tool_start + 1)
|
||||
assert tool_start >= 0 and tool_complete_start > tool_start
|
||||
body = src[tool_start:tool_complete_start]
|
||||
assert "finalizeThinkingCard()" in body, \
|
||||
"tool handler must finalize the current live thinking card before appending a tool card"
|
||||
assert "liveReasoningText='';" in src or 'liveReasoningText = "";' in src, \
|
||||
assert "liveReasoningText='';" in body or 'liveReasoningText = "";' in body, \
|
||||
"tool handler must reset the active reasoning segment before post-tool reasoning arrives"
|
||||
assert "reasoningText=''" not in body and 'reasoningText = ""' not in body, \
|
||||
"tool handler must not discard durable reasoning already assigned to the Worklog"
|
||||
|
||||
|
||||
# ── R17: Stack traces must not leak to clients in 500 responses ────────────
|
||||
|
||||
@@ -27,6 +27,26 @@ def test_run_journal_appends_monotonic_seq_and_reads_after_cursor(tmp_path):
|
||||
assert [event["event"] for event in journal["events"]] == ["done"]
|
||||
|
||||
|
||||
def test_run_journal_reads_bounded_replay_window(tmp_path):
|
||||
writer = RunJournalWriter("session_1", "run_1", session_dir=tmp_path)
|
||||
|
||||
writer.append_sse_event("token", {"text": "one"})
|
||||
writer.append_sse_event("token", {"text": "two"})
|
||||
writer.append_sse_event("token", {"text": "three"})
|
||||
writer.append_sse_event("token", {"text": "four"})
|
||||
|
||||
journal = read_run_events(
|
||||
"session_1",
|
||||
"run_1",
|
||||
after_seq=1,
|
||||
max_seq=3,
|
||||
session_dir=tmp_path,
|
||||
)
|
||||
|
||||
assert [event["seq"] for event in journal["events"]] == [2, 3]
|
||||
assert [event["payload"]["text"] for event in journal["events"]] == ["two", "three"]
|
||||
|
||||
|
||||
def test_run_journal_default_fsyncs_terminal_events_only(tmp_path, monkeypatch):
|
||||
path = tmp_path / "_run_journal" / "session_1" / "run_1.jsonl"
|
||||
path.parent.mkdir(parents=True)
|
||||
|
||||
@@ -11,7 +11,7 @@ def test_reattach_path_uses_replay_when_status_reports_journal():
|
||||
|
||||
assert "st.replay_available" in block
|
||||
assert "replayOnly=true" in block
|
||||
assert "replayOnly?_runJournalReplayParams():''" in block
|
||||
assert "(reconnecting||replayOnly)?_runJournalReplayParams():''" in block
|
||||
assert "_clearOwnerInflightState()" in block
|
||||
|
||||
|
||||
@@ -64,8 +64,15 @@ def test_replayed_long_task_events_enter_the_same_live_timeline_handlers():
|
||||
f"{event_name} must be handled by the shared live/replay SSE pipeline"
|
||||
)
|
||||
|
||||
assert "updateThinking(" in wire_block, "reasoning replay should use the live Thinking card path"
|
||||
assert "appendLiveToolCard(tc)" in wire_block, "tool replay should use live tool-card rendering"
|
||||
thinking_helper = MESSAGES_SRC[
|
||||
MESSAGES_SRC.index("function _updateLiveThinkingCard") :
|
||||
MESSAGES_SRC.index("// Split a content string", MESSAGES_SRC.index("function _updateLiveThinkingCard"))
|
||||
]
|
||||
assert "_updateLiveThinkingCard(" in wire_block, "reasoning replay should use the live Thinking card path"
|
||||
assert "updateThinking(text, opts)" in thinking_helper and "appendThinking(text, opts)" in thinking_helper, (
|
||||
"the shared Thinking helper should still route replay/live reasoning into the Worklog Thinking card path"
|
||||
)
|
||||
assert "appendLiveToolCard(tc" in wire_block, "tool replay should use live tool-card rendering"
|
||||
# Compression replay must dispatch through setCompressionUi(...). The handler
|
||||
# body may build the state object inline (`setCompressionUi({...})`) or hoist
|
||||
# it into a `state` variable first (`setCompressionUi(state)`) — both forms
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import urlparse
|
||||
import io
|
||||
import queue
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -24,10 +26,232 @@ def test_dead_stream_sse_replays_journal_before_404_fallback():
|
||||
assert "find_run_summary(stream_id)" in block
|
||||
assert "stream not found" in block
|
||||
assert "_replay_run_journal" in block
|
||||
assert "_parse_run_journal_after_seq(qs, stream_id)" in block
|
||||
assert "_parse_run_journal_after_seq" in block
|
||||
assert 'Content-Type", "text/event-stream; charset=utf-8"' in block
|
||||
|
||||
|
||||
def test_active_stream_replay_uses_snapshot_cutoff_and_skips_duplicate_queue_items(monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
class FakeStream:
|
||||
def __init__(self):
|
||||
self.q = queue.Queue()
|
||||
self.q.put_nowait(("token", {"text": "replayed"}, "run_1:1"))
|
||||
self.q.put_nowait(("stream_end", {}, "run_1:2"))
|
||||
self.unsubscribed = False
|
||||
|
||||
def subscribe_with_snapshot(self):
|
||||
return self.q, {"last_event_id": "run_1:1", "offline_buffered_events": 1}
|
||||
|
||||
def unsubscribe(self, q):
|
||||
self.unsubscribed = q is self.q
|
||||
|
||||
class Handler:
|
||||
def __init__(self):
|
||||
self.wfile = io.BytesIO()
|
||||
|
||||
def send_response(self, _code):
|
||||
pass
|
||||
|
||||
def send_header(self, _name, _value):
|
||||
pass
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
handler = Handler()
|
||||
stream = FakeStream()
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"find_run_summary",
|
||||
lambda stream_id: {
|
||||
"session_id": "session_1",
|
||||
"run_id": stream_id,
|
||||
"terminal": False,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"read_run_events",
|
||||
lambda session_id, run_id, after_seq=None, max_seq=None: {
|
||||
"events": [
|
||||
{
|
||||
"event": "token",
|
||||
"payload": {"text": "replayed"},
|
||||
"event_id": f"{run_id}:1",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(routes, "stale_interrupted_event", lambda *_args, **_kwargs: None)
|
||||
previous_streams = dict(routes.STREAMS)
|
||||
routes.STREAMS.clear()
|
||||
routes.STREAMS["run_1"] = stream
|
||||
try:
|
||||
routes._handle_sse_stream(handler, urlparse("/api/chat/stream?stream_id=run_1&replay=1&after_seq=0"))
|
||||
finally:
|
||||
routes.STREAMS.clear()
|
||||
routes.STREAMS.update(previous_streams)
|
||||
|
||||
body = handler.wfile.getvalue().decode("utf-8")
|
||||
assert body.count("event: token\n") == 1
|
||||
assert "id: run_1:1\n" in body
|
||||
assert "id: run_1:2\n" in body
|
||||
assert stream.unsubscribed is True
|
||||
|
||||
|
||||
def test_active_stream_snapshot_keeps_items_for_new_run_with_same_seq_range(monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
class FakeStream:
|
||||
def __init__(self):
|
||||
self.q = queue.Queue()
|
||||
self.q.put_nowait(("token", {"text": "fresh"}, "run_new:1"))
|
||||
self.q.put_nowait(("stream_end", {}, "run_new:2"))
|
||||
self.unsubscribed = False
|
||||
|
||||
def subscribe_with_snapshot(self):
|
||||
return self.q, {
|
||||
"last_event_id": "run_old:3",
|
||||
"offline_buffered_events": 2,
|
||||
}
|
||||
|
||||
def unsubscribe(self, q):
|
||||
self.unsubscribed = q is self.q
|
||||
|
||||
class Handler:
|
||||
def __init__(self):
|
||||
self.wfile = io.BytesIO()
|
||||
|
||||
def send_response(self, _code):
|
||||
pass
|
||||
|
||||
def send_header(self, _name, _value):
|
||||
pass
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
handler = Handler()
|
||||
stream = FakeStream()
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"find_run_summary",
|
||||
lambda stream_id: {
|
||||
"session_id": "session_2",
|
||||
"run_id": stream_id,
|
||||
"terminal": False,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"read_run_events",
|
||||
lambda session_id, run_id, after_seq=None, max_seq=None: {"events": []},
|
||||
)
|
||||
monkeypatch.setattr(routes, "stale_interrupted_event", lambda *_args, **_kwargs: None)
|
||||
previous_streams = dict(routes.STREAMS)
|
||||
routes.STREAMS.clear()
|
||||
routes.STREAMS["run_new"] = stream
|
||||
try:
|
||||
routes._handle_sse_stream(
|
||||
handler,
|
||||
urlparse("/api/chat/stream?stream_id=run_new&replay=1&after_seq=0"),
|
||||
)
|
||||
finally:
|
||||
routes.STREAMS.clear()
|
||||
routes.STREAMS.update(previous_streams)
|
||||
|
||||
body = handler.wfile.getvalue().decode("utf-8")
|
||||
assert "id: run_new:1\n" in body
|
||||
assert "id: run_new:2\n" in body
|
||||
assert body.count("id: run_new:1\n") == 1
|
||||
assert stream.unsubscribed is True
|
||||
|
||||
|
||||
def test_active_stream_replay_without_journal_keeps_buffered_queue_items(monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
class FakeStream:
|
||||
def __init__(self):
|
||||
self.q = queue.Queue()
|
||||
self.q.put_nowait(("token", {"text": "buffered"}, "missing_journal_run:1"))
|
||||
self.q.put_nowait(("stream_end", {}, "missing_journal_run:2"))
|
||||
|
||||
def subscribe_with_snapshot(self):
|
||||
return self.q, {"last_event_id": "missing_journal_run:1", "offline_buffered_events": 1}
|
||||
|
||||
def unsubscribe(self, _q):
|
||||
pass
|
||||
|
||||
class Handler:
|
||||
def __init__(self):
|
||||
self.wfile = io.BytesIO()
|
||||
|
||||
def send_response(self, _code):
|
||||
pass
|
||||
|
||||
def send_header(self, _name, _value):
|
||||
pass
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(routes, "find_run_summary", lambda _stream_id: None)
|
||||
handler = Handler()
|
||||
previous_streams = dict(routes.STREAMS)
|
||||
routes.STREAMS.clear()
|
||||
routes.STREAMS["missing_journal_run"] = FakeStream()
|
||||
try:
|
||||
routes._handle_sse_stream(
|
||||
handler,
|
||||
urlparse("/api/chat/stream?stream_id=missing_journal_run&replay=1&after_seq=0"),
|
||||
)
|
||||
finally:
|
||||
routes.STREAMS.clear()
|
||||
routes.STREAMS.update(previous_streams)
|
||||
|
||||
body = handler.wfile.getvalue().decode("utf-8")
|
||||
assert "id: missing_journal_run:1\n" in body
|
||||
assert "event: token\n" in body
|
||||
assert "buffered" in body
|
||||
|
||||
|
||||
def test_live_sse_uses_each_queue_items_own_event_id():
|
||||
import api.routes as routes
|
||||
from api.config import create_stream_channel
|
||||
|
||||
class Handler:
|
||||
def __init__(self):
|
||||
self.wfile = io.BytesIO()
|
||||
|
||||
def send_response(self, _code):
|
||||
pass
|
||||
|
||||
def send_header(self, _name, _value):
|
||||
pass
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
stream = create_stream_channel()
|
||||
stream.put_nowait(("token", {"text": "A"}, "run_own_id:1"))
|
||||
stream.put_nowait(("stream_end", {"ok": True}, "run_own_id:2"))
|
||||
handler = Handler()
|
||||
previous_streams = dict(routes.STREAMS)
|
||||
routes.STREAMS.clear()
|
||||
routes.STREAMS["run_own_id"] = stream
|
||||
try:
|
||||
routes._handle_sse_stream(handler, urlparse("/api/chat/stream?stream_id=run_own_id"))
|
||||
finally:
|
||||
routes.STREAMS.clear()
|
||||
routes.STREAMS.update(previous_streams)
|
||||
|
||||
body = handler.wfile.getvalue().decode("utf-8")
|
||||
assert "id: run_own_id:1\nevent: token\n" in body
|
||||
assert "id: run_own_id:2\nevent: stream_end\n" in body
|
||||
assert body.count("id: run_own_id:2\n") == 1
|
||||
|
||||
|
||||
def test_replay_emits_event_ids_and_stale_restart_diagnostic():
|
||||
replay_pos = ROUTES_SRC.index("def _replay_run_journal")
|
||||
block = ROUTES_SRC[replay_pos : replay_pos + 1200]
|
||||
@@ -100,7 +324,7 @@ def test_replay_run_journal_writes_replayed_events_and_synthetic_terminal(monkey
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"read_run_events",
|
||||
lambda session_id, run_id, after_seq=None: {
|
||||
lambda session_id, run_id, after_seq=None, max_seq=None: {
|
||||
"events": [
|
||||
{
|
||||
"event": "token",
|
||||
@@ -113,7 +337,7 @@ def test_replay_run_journal_writes_replayed_events_and_synthetic_terminal(monkey
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"stale_interrupted_event",
|
||||
lambda session_id, run_id, after_seq=None: {
|
||||
lambda session_id, run_id, after_seq=None, max_seq=None: {
|
||||
"event": "apperror",
|
||||
"payload": {"type": "interrupted"},
|
||||
"event_id": f"{run_id}:2",
|
||||
@@ -143,8 +367,9 @@ def test_replay_run_journal_honors_after_seq_cursor(monkeypatch):
|
||||
},
|
||||
)
|
||||
|
||||
def fake_read_run_events(session_id, run_id, after_seq=None):
|
||||
def fake_read_run_events(session_id, run_id, after_seq=None, max_seq=None):
|
||||
captured["after_seq"] = after_seq
|
||||
captured["max_seq"] = max_seq
|
||||
return {
|
||||
"events": [
|
||||
{
|
||||
@@ -159,27 +384,75 @@ def test_replay_run_journal_honors_after_seq_cursor(monkeypatch):
|
||||
|
||||
assert routes._replay_run_journal(handler, "run_1", 3) is True
|
||||
assert captured["after_seq"] == 3
|
||||
assert captured["max_seq"] is None
|
||||
body = handler.wfile.getvalue().decode("utf-8")
|
||||
assert "id: run_1:4\n" in body
|
||||
assert "event: done\n" in body
|
||||
|
||||
|
||||
def test_parse_run_journal_after_seq_is_run_aware():
|
||||
def test_active_stream_replay_keeps_items_for_new_run_with_same_seq_range(monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
assert routes._parse_run_journal_after_seq(
|
||||
{"after_event_id": ["new-run:7"], "after_seq": ["7"]},
|
||||
"new-run",
|
||||
) == 7
|
||||
assert routes._parse_run_journal_after_seq(
|
||||
{"after_event_id": ["old-run:7"], "after_seq": ["7"]},
|
||||
"new-run",
|
||||
) is None
|
||||
assert routes._parse_run_journal_after_seq({"after_seq": ["3"]}, "new-run") == 3
|
||||
class FakeStream:
|
||||
def __init__(self):
|
||||
self.q = queue.Queue()
|
||||
self.q.put_nowait(("token", {"text": "fresh"}, "run_new:1"))
|
||||
self.q.put_nowait(("stream_end", {}, "run_new:2"))
|
||||
self.unsubscribed = False
|
||||
|
||||
def subscribe_with_snapshot(self):
|
||||
return self.q, {
|
||||
"last_event_id": "run_old:3",
|
||||
"offline_buffered_events": 2,
|
||||
}
|
||||
|
||||
def test_frontend_sends_run_aware_replay_cursor():
|
||||
messages_src = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
assert "let _lastRunJournalEventId=''" in messages_src
|
||||
assert "_lastRunJournalEventId=raw" in messages_src
|
||||
assert "after_event_id=${encodeURIComponent(_lastRunJournalEventId||'')}" in messages_src
|
||||
def unsubscribe(self, q):
|
||||
self.unsubscribed = q is self.q
|
||||
|
||||
class Handler:
|
||||
def __init__(self):
|
||||
self.wfile = io.BytesIO()
|
||||
|
||||
def send_response(self, _code):
|
||||
pass
|
||||
|
||||
def send_header(self, _name, _value):
|
||||
pass
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
handler = Handler()
|
||||
stream = FakeStream()
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"find_run_summary",
|
||||
lambda stream_id: {
|
||||
"session_id": "session_2",
|
||||
"run_id": stream_id,
|
||||
"terminal": False,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"read_run_events",
|
||||
lambda session_id, run_id, after_seq=None, max_seq=None: {"events": []},
|
||||
)
|
||||
monkeypatch.setattr(routes, "stale_interrupted_event", lambda *_args, **_kwargs: None)
|
||||
previous_streams = dict(routes.STREAMS)
|
||||
routes.STREAMS.clear()
|
||||
routes.STREAMS["run_new"] = stream
|
||||
try:
|
||||
routes._handle_sse_stream(
|
||||
handler,
|
||||
urlparse("/api/chat/stream?stream_id=run_new&replay=1&after_seq=0"),
|
||||
)
|
||||
finally:
|
||||
routes.STREAMS.clear()
|
||||
routes.STREAMS.update(previous_streams)
|
||||
|
||||
body = handler.wfile.getvalue().decode("utf-8")
|
||||
assert "id: run_new:1\n" in body
|
||||
assert "id: run_new:2\n" in body
|
||||
assert body.count("id: run_new:1\n") == 1
|
||||
assert stream.unsubscribed is True
|
||||
@@ -15,14 +15,9 @@ def test_streaming_journals_sse_events_before_queue_delivery():
|
||||
src = Path("api/streaming.py").read_text(encoding="utf-8")
|
||||
put_idx = src.index("def put(event, data):")
|
||||
journal_idx = src.index("run_journal.append_sse_event(event, data)", put_idx)
|
||||
# Stage-364 maintainer fix: put() now pushes 3-tuples (event, data, event_id)
|
||||
# so the SSE consumer can emit `id:` on live frames. Accept either shape
|
||||
# so this test survives both the v0.51.71 in-flight fix and a future revert.
|
||||
try:
|
||||
queue_idx = src.index("q.put_nowait((event, data, event_id))", put_idx)
|
||||
except ValueError:
|
||||
queue_idx = src.index("q.put_nowait((event, data))", put_idx)
|
||||
queue_idx = src.index("q.put_nowait(queue_item)", put_idx)
|
||||
block = src[put_idx:queue_idx]
|
||||
|
||||
assert put_idx < journal_idx < queue_idx
|
||||
assert "Failed to append run journal event" in block
|
||||
assert "queue_item = (event, data, event_id) if event_id and hasattr(q, \"subscribe_with_snapshot\") else (event, data)" in block
|
||||
@@ -148,6 +148,13 @@ def test_session_sidebar_renders_attention_badge_and_semantic_classes():
|
||||
assert ".session-item.attention-clarify" in style_css
|
||||
# The text-badge styles were removed; the dot now carries the color.
|
||||
assert ".session-attention-badge" not in style_css
|
||||
assert "is-attention-clarify" in sessions_js, (
|
||||
"renderSessionList must tag the state indicator with is-attention-clarify."
|
||||
)
|
||||
assert ".session-state-indicator.is-attention-approval" in style_css
|
||||
assert ".session-state-indicator.is-attention-clarify" in style_css
|
||||
assert ".session-state-indicator.is-attention-generic{visibility:visible;}" in style_css
|
||||
assert ".session-state-indicator.is-attention-approval{color:var(--error);}" in style_css
|
||||
assert ".session-state-indicator.is-attention-clarify{color:var(--warning);}" in style_css
|
||||
assert ".session-state-indicator.is-attention-generic{color:var(--warning);}" in style_css
|
||||
assert "prefers-reduced-motion" in style_css
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Regression tests for session id rotation URL sync."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent.resolve()
|
||||
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
@@ -9,16 +10,20 @@ def test_stream_completion_syncs_rotated_session_id_to_tab_state():
|
||||
"""When compact/restore returns a new session id, the tab anchor follows it."""
|
||||
# #3018 inserted a carry-forward of ephemeral per-turn fields into both the
|
||||
# completion (_finishDone) and settled-restore assignments; match the new shapes.
|
||||
completion_marker = "S.session=d.session;S.messages=_carryForwardEphemeralTurnFields(S.messages||[], d.session.messages||[]);"
|
||||
completion_marker = re.compile(
|
||||
r"S\.session=d\.session;\s*"
|
||||
r"S\.messages=_carryForwardEphemeralTurnFields\(S\.messages\|\|\[\], d\.session\.messages\|\|\[\]\);"
|
||||
)
|
||||
settled_marker = "S.session=session;\n const _nextMsgs3018=(session.messages||[]).filter(m=>m&&m.role);"
|
||||
|
||||
completion_pos = MESSAGES_JS.find(completion_marker)
|
||||
completion_match = completion_marker.search(MESSAGES_JS)
|
||||
completion_pos = completion_match.start() if completion_match else -1
|
||||
settled_pos = MESSAGES_JS.find(settled_marker)
|
||||
assert completion_pos != -1
|
||||
assert settled_pos != -1
|
||||
|
||||
completion_block = MESSAGES_JS[completion_pos : completion_pos + 900]
|
||||
settled_block = MESSAGES_JS[settled_pos : settled_pos + 900]
|
||||
completion_block = MESSAGES_JS[completion_pos : completion_pos + 800]
|
||||
settled_block = MESSAGES_JS[settled_pos : settled_pos + 800]
|
||||
|
||||
for block in (completion_block, settled_block):
|
||||
assert "localStorage.setItem('hermes-webui-session',S.session.session_id);" in block
|
||||
|
||||
+41
-14
@@ -403,12 +403,39 @@ class TestRuntimeRouteInjection(unittest.TestCase):
|
||||
self.assertTrue(callable(init_kwargs["interim_assistant_callback"]))
|
||||
self.assertIn("WebUI progress guidance", captured["agent"].ephemeral_system_prompt)
|
||||
self.assertIn("Match the normal Hermes messaging style", captured["agent"].ephemeral_system_prompt)
|
||||
self.assertIn("user-visible progress updates", captured["agent"].ephemeral_system_prompt)
|
||||
self.assertIn(
|
||||
"do not let long tool-running WebUI turns appear silent",
|
||||
captured["agent"].ephemeral_system_prompt,
|
||||
)
|
||||
self.assertIn(
|
||||
"emit brief user-visible progress updates as normal assistant content",
|
||||
captured["agent"].ephemeral_system_prompt,
|
||||
)
|
||||
self.assertIn(
|
||||
"Before the first tool batch in a long task",
|
||||
captured["agent"].ephemeral_system_prompt,
|
||||
)
|
||||
self.assertIn(
|
||||
"Do not run many independent tool batches back-to-back without visible assistant text between them",
|
||||
captured["agent"].ephemeral_system_prompt,
|
||||
)
|
||||
self.assertIn(
|
||||
"Do not keep progress only in reasoning, thinking, or tool-result channels",
|
||||
captured["agent"].ephemeral_system_prompt,
|
||||
)
|
||||
self.assertNotIn(
|
||||
"you may provide brief user-visible progress updates",
|
||||
captured["agent"].ephemeral_system_prompt,
|
||||
)
|
||||
|
||||
interim_events = []
|
||||
while not fake_queue.empty():
|
||||
try:
|
||||
interim_events.append(fake_queue.get_nowait())
|
||||
item = fake_queue.get_nowait()
|
||||
if isinstance(item, tuple) and len(item) >= 2:
|
||||
interim_events.append((item[0], item[1]))
|
||||
else:
|
||||
interim_events.append(item)
|
||||
except queue.Empty:
|
||||
break
|
||||
self.assertTrue(
|
||||
@@ -724,11 +751,11 @@ def test_done_handler_patches_reasoning_field():
|
||||
src = (REPO / 'static' / 'messages.js').read_text()
|
||||
|
||||
# The persistence comment must be present inside the done handler
|
||||
assert "Persist reasoning trace so thinking card survives page reload" in src, \
|
||||
assert "Persist reasoning trace for Worklog Thinking Cards" in src, \
|
||||
"Reasoning persistence comment not found in messages.js done handler"
|
||||
|
||||
# The guard and assignment must be present
|
||||
assert "if(reasoningText){" in src, \
|
||||
assert "if(reasoningText&&lastAsst&&!lastAsst.reasoning)" in src, \
|
||||
"reasoningText guard not found in messages.js"
|
||||
|
||||
assert "lastAsst.reasoning=reasoningText" in src, \
|
||||
@@ -736,7 +763,7 @@ def test_done_handler_patches_reasoning_field():
|
||||
|
||||
# Verify the patch is inside the done handler (after 'source.addEventListener' for done)
|
||||
done_handler_idx = src.index("source.addEventListener('done'")
|
||||
persist_idx = src.index("Persist reasoning trace so thinking card survives page reload")
|
||||
persist_idx = src.index("Persist reasoning trace for Worklog Thinking Cards")
|
||||
assert done_handler_idx < persist_idx, \
|
||||
"Reasoning persistence patch must be inside the done SSE handler"
|
||||
|
||||
@@ -745,21 +772,21 @@ def test_done_handler_patches_reasoning_field():
|
||||
"Guard '!lastAsst.reasoning' missing — would overwrite server-persisted reasoning"
|
||||
|
||||
|
||||
def test_rendermessages_reads_reasoning_from_messages():
|
||||
"""ui.js renderMessages must read m.reasoning to display the thinking card."""
|
||||
def test_rendermessages_keeps_reasoning_metadata_out_of_worklog_display():
|
||||
"""ui.js renderMessages must not promote provider reasoning metadata into Worklog prose."""
|
||||
src = (REPO / 'static' / 'ui.js').read_text()
|
||||
|
||||
# m.reasoning must be read in the render path
|
||||
assert 'm.reasoning' in src, \
|
||||
"m.reasoning not referenced in ui.js — thinking card won't render on reload"
|
||||
sig_fn = src.split("function _messageHasReasoningPayload(m)", 1)[1].split("function", 1)[0]
|
||||
assert 'm.reasoning' in sig_fn, \
|
||||
"m.reasoning should remain part of metadata/cache signature handling"
|
||||
|
||||
# The thinking card rendering block must also be present
|
||||
# Legacy thinking-card helpers may still exist for explicit debug surfaces.
|
||||
assert 'thinking-card' in src, \
|
||||
"thinking-card CSS class not found in ui.js"
|
||||
|
||||
# Specifically, the fallback that reads from top-level m.reasoning field
|
||||
assert 'thinkingText=m.reasoning' in src.replace(' ', ''), \
|
||||
"thinkingText=m.reasoning assignment not found in ui.js renderMessages"
|
||||
extraction = src.split("let thinkingText='';", 1)[1].split("const isUser=m.role==='user';", 1)[0]
|
||||
assert 'm.reasoning' not in extraction
|
||||
assert 'm.reasoning_content' not in extraction
|
||||
|
||||
|
||||
def test_streaming_restores_prior_reasoning_metadata_after_followup():
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Regression test for stage-364 Opus-caught SHOULD-FIX (side-channel approach):
|
||||
"""Regression test for stage-364 Opus-caught SHOULD-FIX (per-frame cursor):
|
||||
|
||||
When the live SSE stream errors mid-stream and the frontend falls back to
|
||||
journal replay, live frames must carry an `id:` field so the frontend's
|
||||
@@ -7,19 +7,17 @@ arrives with `after_seq=0` and the server replays every journaled event from
|
||||
seq 1, double-rendering tokens against the live-phase `assistantText`
|
||||
accumulator.
|
||||
|
||||
Implementation (stage-364 — side-channel approach to avoid breaking the
|
||||
queue tuple contract used by 4 existing tests):
|
||||
Implementation:
|
||||
|
||||
- api/config.py adds `STREAM_LAST_EVENT_ID: dict = {}` module-level dict.
|
||||
- api/streaming.py `put()` captures `journaled["event_id"]` from
|
||||
`RunJournalWriter.append_sse_event()` return and writes it to
|
||||
`STREAM_LAST_EVENT_ID[stream_id]`.
|
||||
- api/routes.py `_handle_sse_stream` reads `STREAM_LAST_EVENT_ID[stream_id]`
|
||||
at SSE emit time and uses `_sse_with_id` when set.
|
||||
- StreamChannel queue items carry `(event, data, event_id)` so active
|
||||
subscribers emit each frame with its own id instead of the latest global id.
|
||||
- Legacy plain queues keep `(event, data)` and use `STREAM_LAST_EVENT_ID` as a
|
||||
compatibility fallback.
|
||||
- api/streaming.py finally-block cleanup pops STREAM_LAST_EVENT_ID.
|
||||
|
||||
The queue tuple shape is preserved as (event, data), so existing tests like
|
||||
test_cancel_puts_sentinel_in_queue still work.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
@@ -28,6 +26,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
STREAMING_PY = (REPO_ROOT / "api" / "streaming.py").read_text(encoding="utf-8")
|
||||
ROUTES_PY = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
|
||||
CONFIG_PY = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8")
|
||||
GATEWAY_CHAT_PY = (REPO_ROOT / "api" / "gateway_chat.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_stream_last_event_id_dict_exists_in_config():
|
||||
@@ -54,16 +53,27 @@ def test_put_writes_event_id_to_side_channel_dict():
|
||||
)
|
||||
|
||||
|
||||
def test_queue_tuple_shape_preserved_as_two_tuple():
|
||||
"""The queue still uses 2-tuples (event, data) so existing consumers
|
||||
that unpack `event, data = q.get()` are not broken."""
|
||||
def test_stream_channel_queue_item_carries_per_event_id_with_legacy_fallback():
|
||||
"""StreamChannel queue items need per-frame ids; legacy queues stay 2-tuples."""
|
||||
put_def_idx = STREAMING_PY.find("def put(event, data):")
|
||||
put_body = STREAMING_PY[put_def_idx:put_def_idx + 2500]
|
||||
assert "q.put_nowait((event, data))" in put_body, (
|
||||
"Queue tuple shape must remain (event, data) — changing to 3-tuple "
|
||||
"breaks 4 existing tests in test_cancel_interrupt, test_sprint42, "
|
||||
"test_sprint51, test_issue1857_usage_overwrite"
|
||||
assert 'queue_item = (event, data, event_id) if event_id and hasattr(q, "subscribe_with_snapshot") else (event, data)' in put_body, (
|
||||
"StreamChannel events must carry their own event_id while legacy queue "
|
||||
"consumers retain the 2-tuple shape"
|
||||
)
|
||||
assert "q.put_nowait(queue_item)" in put_body
|
||||
|
||||
|
||||
def test_gateway_queue_item_carries_per_event_id_with_legacy_fallback():
|
||||
"""Gateway-backed WebUI chat must preserve the same live cursor invariant."""
|
||||
put_def_idx = GATEWAY_CHAT_PY.find("def put_gateway_event(event, data):")
|
||||
assert put_def_idx != -1, "put_gateway_event(event, data) not found"
|
||||
put_body = GATEWAY_CHAT_PY[put_def_idx:put_def_idx + 1800]
|
||||
assert 'queue_item = (event, data, event_id) if event_id and hasattr(q, "subscribe_with_snapshot") else (event, data)' in put_body, (
|
||||
"Gateway live events must carry their own event_id for StreamChannel "
|
||||
"subscribers while preserving legacy queue compatibility"
|
||||
)
|
||||
assert "q.put_nowait(queue_item)" in put_body
|
||||
|
||||
|
||||
def test_sse_handler_reads_event_id_from_side_channel():
|
||||
|
||||
@@ -81,6 +81,80 @@ def test_chat_start_clears_stale_pending_state_not_only_active_id():
|
||||
assert stale_comment_pos < cleanup_pos < stream_id_pos
|
||||
|
||||
|
||||
def test_chat_start_rechecks_active_stream_under_session_lock(monkeypatch, tmp_path):
|
||||
"""A concurrent chat_start must not overwrite stream ownership.
|
||||
|
||||
The first request can pass the pre-lock active_stream_id check while another
|
||||
request is waiting/running. Once this request enters the session lock, it
|
||||
must re-read active_stream_id and reject instead of creating a ghost stream.
|
||||
"""
|
||||
config.STREAMS.clear()
|
||||
config.SESSION_AGENT_LOCKS.clear()
|
||||
existing_stream_id = "already-running-stream"
|
||||
|
||||
class ChatStartSession:
|
||||
session_id = "duplicate-start-session"
|
||||
|
||||
def __init__(self):
|
||||
self.active_stream_id = None
|
||||
self.pending_user_message = None
|
||||
self.pending_attachments = []
|
||||
self.pending_started_at = None
|
||||
self.messages = []
|
||||
self.title = "Untitled"
|
||||
self.worktree_path = None
|
||||
self.workspace = None
|
||||
self.model = None
|
||||
self.model_provider = None
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
session = ChatStartSession()
|
||||
|
||||
class MutatingSessionLock:
|
||||
def __enter__(self):
|
||||
session.active_stream_id = existing_stream_id
|
||||
session.pending_user_message = "prompt already claimed by another start"
|
||||
session.pending_started_at = 123.0
|
||||
routes.STREAMS[existing_stream_id] = queue.Queue()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
class NoopThread:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
def start(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(routes, "_get_session_agent_lock", lambda sid: MutatingSessionLock())
|
||||
monkeypatch.setattr(routes.uuid, "uuid4", lambda: type("FakeUuid", (), {"hex": "new-stream"})())
|
||||
monkeypatch.setattr(routes, "set_last_workspace", lambda workspace: None)
|
||||
monkeypatch.setattr(routes, "create_stream_channel", lambda: queue.Queue())
|
||||
monkeypatch.setattr(routes.threading, "Thread", NoopThread)
|
||||
|
||||
try:
|
||||
response = routes._start_chat_stream_for_session(
|
||||
session,
|
||||
msg="please start once",
|
||||
attachments=[],
|
||||
workspace=str(tmp_path),
|
||||
model="test-model",
|
||||
model_provider=None,
|
||||
)
|
||||
|
||||
assert response["_status"] == 409
|
||||
assert response["active_stream_id"] == existing_stream_id
|
||||
assert session.active_stream_id == existing_stream_id
|
||||
assert "new-stream" not in routes.STREAMS
|
||||
finally:
|
||||
routes.STREAMS.pop(existing_stream_id, None)
|
||||
|
||||
|
||||
def test_stale_stream_cleanup_does_not_clobber_concurrent_chat_start(monkeypatch):
|
||||
"""Regression for #1533: stale cleanup must not erase a new stream id.
|
||||
|
||||
|
||||
@@ -51,7 +51,9 @@ class TestStreamFinalized:
|
||||
|
||||
def test_raf_handle_stored_in_schedule_render(self):
|
||||
src = read('static/messages.js')
|
||||
assert '_pendingRafHandle=requestAnimationFrame' in src or \
|
||||
assert '_pendingRafHandle=_pendingRafFrameHandle' in src or \
|
||||
'_pendingRafHandle = _pendingRafFrameHandle' in src or \
|
||||
'_pendingRafHandle=requestAnimationFrame' in src or \
|
||||
'_pendingRafHandle = requestAnimationFrame' in src, (
|
||||
"rAF handle must be stored in _pendingRafHandle for cancellation"
|
||||
)
|
||||
|
||||
@@ -78,17 +78,21 @@ def test_message_scroll_listener_does_not_downgrade_explicit_bottom_pin_on_first
|
||||
def test_user_scroll_cancels_delayed_bottom_settling():
|
||||
listener_block = _scroll_listener_block()
|
||||
record = _function_body(UI_JS, "function _recordNonMessageScrollIntent")
|
||||
pinned = _function_body(UI_JS, "function scrollIfPinned")
|
||||
|
||||
assert "function _cancelBottomSettle" in UI_JS
|
||||
assert "_cancelBottomSettle();" in listener_block
|
||||
assert "e.deltaY<0" in record
|
||||
assert "_cancelBottomSettle();" in record
|
||||
assert "_scrollPinned=false" in record
|
||||
assert "if(_messageUserUnpinned) return;" in pinned
|
||||
assert "_recentMessageUpwardIntent()" not in pinned
|
||||
|
||||
|
||||
def test_preserve_scroll_restores_unpinned_viewport_after_dom_rebuild():
|
||||
render = _function_body(UI_JS, "function renderMessages")
|
||||
after_render = _function_body(UI_JS, "function _scrollAfterMessageRender")
|
||||
follow = _function_body(UI_JS, "function _followMessagesAfterDomReplace")
|
||||
restore = _function_body(UI_JS, "function _restoreMessageScrollSnapshot")
|
||||
|
||||
snapshot_idx = render.index("const scrollSnapshot=preserveScroll?_captureMessageScrollSnapshot():null")
|
||||
@@ -99,7 +103,9 @@ def test_preserve_scroll_restores_unpinned_viewport_after_dom_rebuild():
|
||||
"renderMessages({preserveScroll:true}) must capture #messages.scrollTop before "
|
||||
"replacing transcript DOM, then pass that snapshot to the post-render scroll helper"
|
||||
)
|
||||
assert "if(_scrollPinned) scrollIfPinned()" in after_render
|
||||
assert "else _restoreMessageScrollSnapshot(scrollSnapshot)" in after_render
|
||||
assert "if(_followMessagesAfterDomReplace()) return;" in after_render
|
||||
assert "_restoreMessageScrollSnapshot(scrollSnapshot)" in after_render
|
||||
assert "_shouldFollowMessagesOnDomReplace()" in follow
|
||||
assert "scrollToBottom();" in follow
|
||||
assert "el.scrollTop=Math.max(0,Math.min(Number(snapshot.top)||0,maxTop))" in restore
|
||||
assert "_programmaticScroll=true" in restore
|
||||
@@ -53,10 +53,14 @@ def test_ui_formats_and_renders_turn_duration_in_footer_and_activity_summary():
|
||||
"Compact tool activity summary should have a dedicated duration span at the end of the line."
|
||||
)
|
||||
assert "data-turn-duration" in UI_JS, (
|
||||
"Activity groups need a stable data-turn-duration hook so settled duration can update the summary."
|
||||
"The spec Activity summary needs a stable data-turn-duration hook so settled duration can update its summary."
|
||||
)
|
||||
assert "compactActivityForMessage" in UI_JS, (
|
||||
"When compact activity is present, duration should live on the Activity row "
|
||||
assert "turnDuration:includeTurnDuration?_turnDurationForAnchor(anchorRow):undefined" in UI_JS, (
|
||||
"Settled compact activity should put turn duration on the first spec Activity row, "
|
||||
"not resurrect the legacy top Run Activity."
|
||||
)
|
||||
assert "compactWorklogForMessage" in UI_JS, (
|
||||
"When folded Worklog detail is present, duration should live on the Worklog row "
|
||||
"instead of being duplicated in the assistant footer."
|
||||
)
|
||||
assert ".msg-duration-inline" in CSS and ".tool-call-group-duration" in CSS, (
|
||||
@@ -73,6 +77,11 @@ def test_active_compact_activity_elapsed_timer_uses_persisted_start_time():
|
||||
"send() should copy chat-start pending_started_at into S.session before "
|
||||
"attaching the live stream."
|
||||
)
|
||||
assert "showLiveRunStatus(activeSid,{startedAt:_startedAt});" in MESSAGES_JS, (
|
||||
"The first chat-start path should show the bottom live footer timer as soon "
|
||||
"as stream_id and pending_started_at are known; reconnect should not be the "
|
||||
"only path that restores it."
|
||||
)
|
||||
assert "function _formatActiveElapsedTimer" in UI_JS and "padStart(2,'0')" in UI_JS, (
|
||||
"ui.js should format the running timer in MM:SS form."
|
||||
)
|
||||
@@ -88,3 +97,18 @@ def test_active_compact_activity_elapsed_timer_uses_persisted_start_time():
|
||||
"The active elapsed label should tick while running and clear its interval "
|
||||
"on terminal/error/session-switch cleanup paths."
|
||||
)
|
||||
|
||||
|
||||
def test_live_footer_timer_is_re_synced_after_message_rerender():
|
||||
assert "function _syncLiveRunStatusAfterRender()" in UI_JS, (
|
||||
"renderMessages() needs a dedicated helper so the live footer timer "
|
||||
"can be restored after DOM rebuilds."
|
||||
)
|
||||
assert "_syncLiveRunStatusAfterRender();" in UI_JS, (
|
||||
"renderMessages() should call the live-status sync helper after it "
|
||||
"rebuilds msgInner."
|
||||
)
|
||||
assert "showLiveRunStatus(sid,{startedAt,tokens:_liveRunStatusTokens});" in UI_JS, (
|
||||
"If the timer node was torn down during a rerender, the helper should "
|
||||
"recreate it for the active session."
|
||||
)
|
||||
@@ -8,20 +8,20 @@ COMPACT_CSS = re.sub(r"\s+", "", STYLE_CSS)
|
||||
|
||||
|
||||
def test_tool_card_toggle_uses_transformable_layout_and_transition():
|
||||
assert ".tool-card-toggle{" in COMPACT_CSS
|
||||
assert ".tool-card-toggle,.tl-caret{" in COMPACT_CSS
|
||||
assert "display:inline-flex" in COMPACT_CSS
|
||||
assert "transition:transform.18sease" in COMPACT_CSS
|
||||
|
||||
|
||||
def test_tool_card_detail_uses_transitionable_collapsed_state():
|
||||
assert ".tool-card-detail{display:block;max-height:0;opacity:0;overflow:hidden;" in COMPACT_CSS
|
||||
assert ".tool-card-detail,.tl-detail{display:block;max-height:0;opacity:0;overflow:hidden;" in COMPACT_CSS
|
||||
assert re.search(
|
||||
r"\.tool-card\.open\s+\.tool-card-detail\s*\{[^}]*max-height:\s*600px;[^}]*opacity:\s*1;",
|
||||
r"\.tool-card\.open\s+\.tool-card-detail,\s*\.tl\.open\s+\.tl-detail\s*\{[^}]*max-height:\s*320px;[^}]*opacity:\s*1;",
|
||||
STYLE_CSS,
|
||||
)
|
||||
# Open state must set overflow to auto so the inner <pre> scroll is not clipped (#1170).
|
||||
assert re.search(
|
||||
r"\.tool-card\.open\s+\.tool-card-detail\s*\{[^}]*overflow:\s*auto;",
|
||||
r"\.tool-card\.open\s+\.tool-card-detail,\s*\.tl\.open\s+\.tl-detail\s*\{[^}]*overflow:\s*auto;",
|
||||
STYLE_CSS,
|
||||
)
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ These tests intentionally follow the repo's existing pytest style: read static
|
||||
source files, isolate the relevant function/rule, and assert implementation
|
||||
invariants before changing the UI.
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
@@ -70,6 +72,86 @@ def _function_body(src: str, name: str) -> str:
|
||||
return src[brace + 1:i - 1]
|
||||
|
||||
|
||||
def _function_src(src: str, name: str) -> str:
|
||||
match = re.search(rf"function\s+{re.escape(name)}\s*\(", src)
|
||||
assert match, f"{name}() not found"
|
||||
brace = src.find("{", match.end())
|
||||
assert brace != -1, f"{name}() has no body"
|
||||
depth = 1
|
||||
i = brace + 1
|
||||
in_string = None
|
||||
escaped = False
|
||||
in_line_comment = False
|
||||
in_block_comment = False
|
||||
while i < len(src) and depth:
|
||||
ch = src[i]
|
||||
nxt = src[i + 1] if i + 1 < len(src) else ""
|
||||
if in_line_comment:
|
||||
if ch == "\n":
|
||||
in_line_comment = False
|
||||
i += 1
|
||||
continue
|
||||
if in_block_comment:
|
||||
if ch == "*" and nxt == "/":
|
||||
in_block_comment = False
|
||||
i += 2
|
||||
continue
|
||||
i += 1
|
||||
continue
|
||||
if in_string:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif ch == "\\":
|
||||
escaped = True
|
||||
elif ch == in_string:
|
||||
in_string = None
|
||||
i += 1
|
||||
continue
|
||||
if ch == "/" and nxt == "/":
|
||||
in_line_comment = True
|
||||
i += 2
|
||||
continue
|
||||
if ch == "/" and nxt == "*":
|
||||
in_block_comment = True
|
||||
i += 2
|
||||
continue
|
||||
if ch in "'\"`":
|
||||
in_string = ch
|
||||
i += 1
|
||||
continue
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
i += 1
|
||||
assert depth == 0, f"{name}() body did not close"
|
||||
return src[match.start():i]
|
||||
|
||||
|
||||
def _run_thinking_echo_helper(*args: str) -> str:
|
||||
helpers = "\n".join(
|
||||
_function_src(UI_JS, name)
|
||||
for name in (
|
||||
"_stripXmlToolCallsDisplay",
|
||||
"_sanitizeThinkingDisplayText",
|
||||
"_normalizeThinkingEchoCompare",
|
||||
"_stripVisibleAssistantEchoFromThinking",
|
||||
)
|
||||
)
|
||||
script = (
|
||||
helpers
|
||||
+ "\nconst args=JSON.parse(process.argv[1]);"
|
||||
+ "\nprocess.stdout.write(JSON.stringify(_stripVisibleAssistantEchoFromThinking(...args)));"
|
||||
)
|
||||
out = subprocess.run(
|
||||
["node", "-e", script, json.dumps(list(args))],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
return json.loads(out)
|
||||
|
||||
|
||||
class TestToolCallGroupingStatic:
|
||||
def test_simplified_tool_calling_setting_is_wired_through_frontend(self):
|
||||
assert "settingsSimplifiedToolCalling" in (REPO / "static" / "index.html").read_text(encoding="utf-8"), (
|
||||
@@ -99,11 +181,14 @@ class TestToolCallGroupingStatic:
|
||||
def test_render_messages_gates_settled_activity_grouping(self):
|
||||
fn = _function_body(UI_JS, "renderMessages")
|
||||
helper = _function_body(UI_JS, "ensureActivityGroup")
|
||||
assert "isSimplifiedToolCalling()" in fn, (
|
||||
"Settled compact inline activity rendering should be gated by the Compact tool activity toggle."
|
||||
assert "byActivity = new Map()" in fn, (
|
||||
"Settled tool rendering should bucket by worklog segments/bursts."
|
||||
)
|
||||
assert "tool-cards-toggle" in fn, (
|
||||
"The non-simplified path should preserve the upstream loose tool-card controls."
|
||||
assert "_toolWorklogListEl(group)" in fn, (
|
||||
"Settled tools should render through the worklog list container."
|
||||
)
|
||||
assert "_syncToolCallGroupSummary(state.group)" in fn, (
|
||||
"Settled worklog groups should refresh summary state."
|
||||
)
|
||||
assert "data-tool-call-group" in helper, (
|
||||
"Tool-call groups need a stable data-tool-call-group attribute for CSS and tests."
|
||||
@@ -156,16 +241,20 @@ class TestToolCallGroupingStatic:
|
||||
def test_live_tool_cards_use_grouping_only_when_simplified(self):
|
||||
live_fn = _function_body(UI_JS, "appendLiveToolCard")
|
||||
settled_fn = _function_body(UI_JS, "renderMessages")
|
||||
assert "isSimplifiedToolCalling()" in live_fn, (
|
||||
"Live streaming tool cards should branch on the Compact tool activity timeline mode."
|
||||
assert "isSimplifiedToolCalling()" not in live_fn, (
|
||||
"Live streaming tool cards should no longer branch on compact/timeline mode."
|
||||
)
|
||||
assert "ensureActivityGroup" in live_fn, (
|
||||
"Compact live tool rendering should use the grouped activity container."
|
||||
assert "ensureLiveWorklogContainer" in live_fn, (
|
||||
"Live tool rendering should use the direct Worklog container."
|
||||
)
|
||||
assert "toolRunningRow" in live_fn, (
|
||||
"The non-simplified live tool path should preserve the upstream running-dots row."
|
||||
assert "ensureActivityGroup" not in live_fn, (
|
||||
"Live tool rendering must not show the settled L1 Activity summary while streaming."
|
||||
)
|
||||
assert "buildToolCard" in live_fn and "buildToolCard" in settled_fn, (
|
||||
assert "_toolWorklogListEl(group)" in live_fn, (
|
||||
"Live tool cards should insert into the worklog list container."
|
||||
)
|
||||
step_fn = _function_body(UI_JS, "_appendWorklogStep")
|
||||
assert "buildToolCard" in live_fn and "buildToolCard" in step_fn and "_appendWorklogStep" in settled_fn, (
|
||||
"Live and settled tool rendering should share buildToolCard() for consistent markup."
|
||||
)
|
||||
assert "data-live-tid" in live_fn, (
|
||||
@@ -201,18 +290,31 @@ class TestToolCallGroupingStatic:
|
||||
assert "live:" in live_fn + thinking_fn, (
|
||||
"Live Activity groups should be keyed by active stream id."
|
||||
)
|
||||
assert "_copyActivityDisclosureState('live:'+streamId, 'assistant:'" in done_fn, (
|
||||
"When a live turn settles, its saved disclosure state should transfer to the persisted assistant turn."
|
||||
assert "_copyActivityDisclosureState('live:'+streamId, 'assistant:'" not in done_fn, (
|
||||
"Live disclosure state must not transfer to the final assistant turn; final L1 starts collapsed."
|
||||
)
|
||||
|
||||
def test_live_tool_activity_defaults_collapsed_unless_saved_open(self):
|
||||
def test_live_tool_worklog_is_direct_until_settled(self):
|
||||
live_fn = _function_body(UI_JS, "appendLiveToolCard")
|
||||
live_container = _function_body(UI_JS, "ensureLiveWorklogContainer")
|
||||
helper = _function_body(UI_JS, "ensureActivityGroup")
|
||||
assert "collapsed:false" not in re.sub(r"\s+", "", live_fn), (
|
||||
"Compact live tool activity should not force-open every time a chat is revisited."
|
||||
assert "ensureLiveWorklogContainer" in live_fn, (
|
||||
"Live tool events should append into the direct Worklog timeline."
|
||||
)
|
||||
assert "tool-worklog-list" in live_container and "data-live-worklog-shell" in live_container, (
|
||||
"The direct live Worklog shell should own the L2 list without an L1 summary row."
|
||||
)
|
||||
assert "activity-summary" not in live_container and "tool-call-group-summary" not in live_container, (
|
||||
"The settled Activity summary should not be present while the stream is running."
|
||||
)
|
||||
assert "savedState==='open'" in helper or 'savedState==="open"' in helper, (
|
||||
"A previously-open Activity group should still restore open from persisted state."
|
||||
"Live Activity groups can still restore explicit live open state."
|
||||
)
|
||||
assert "if(live && savedState==='open')" in helper or 'if(live && savedState==="open")' in helper, (
|
||||
"Saved open state must be scoped to live groups so final L1 defaults collapsed."
|
||||
)
|
||||
assert "savedState==='closed'" in helper or 'savedState==="closed"' in helper, (
|
||||
"A saved closed Activity group should still override the live expanded default."
|
||||
)
|
||||
|
||||
def test_live_activity_summary_shows_readable_progress_without_persisted_content(self):
|
||||
@@ -234,30 +336,103 @@ class TestToolCallGroupingStatic:
|
||||
"Readable progress must not reintroduce the noisy secondary tool-name list."
|
||||
)
|
||||
|
||||
def test_live_thinking_suppresses_visible_interim_echoes(self):
|
||||
def test_terminal_worklog_titles_summarize_common_diagnostic_commands(self):
|
||||
start = UI_JS.find("function _toolCommandTitle")
|
||||
end = UI_JS.find("function _toolQueryTitle", start)
|
||||
assert start != -1 and end != -1, "_toolCommandTitle() source window not found"
|
||||
command_fn = UI_JS[start:end]
|
||||
assert "git fetch" in command_fn and "git ahead/behind" in command_fn, (
|
||||
"Terminal Worklog rows should distinguish common git audit commands "
|
||||
"instead of falling back to the generic 'command' title."
|
||||
)
|
||||
assert "git log" in command_fn, (
|
||||
"Commit/PR audit commands should show a git log title instead of "
|
||||
"the generic command fallback."
|
||||
)
|
||||
assert "health check" in command_fn, (
|
||||
"curl localhost /health checks should get a readable L2 title."
|
||||
)
|
||||
assert "process check" in command_fn and "port ${m[1]} check" in command_fn, (
|
||||
"ps/grep and lsof diagnostics should be scannable in L2 while full "
|
||||
"commands remain in L3 detail."
|
||||
)
|
||||
assert "launchctl" in command_fn, (
|
||||
"launchd service checks should keep their service intent visible in "
|
||||
"the Worklog row title."
|
||||
)
|
||||
assert "return _shortToolLabel(normalized,72);" in command_fn, (
|
||||
"Long shell diagnostics should still expose a short L2 command "
|
||||
"summary instead of falling back to the bare 'command' title."
|
||||
)
|
||||
|
||||
def test_live_thinking_does_not_rewrite_visible_interim_echoes(self):
|
||||
interim_match = re.search(r"source\.addEventListener\('interim_assistant',e=>\{(.*?)\n\s*\}\);", MESSAGES_JS, re.S)
|
||||
assert interim_match, "interim_assistant listener not found"
|
||||
interim_fn = interim_match.group(1)
|
||||
live_thinking_fn = _function_body(MESSAGES_JS, "_liveThinkingText")
|
||||
|
||||
assert "visibleInterimSnippets.push(visible)" in interim_fn, (
|
||||
"Visible interim commentary should be remembered so the live Thinking card does not echo it."
|
||||
"Visible interim commentary should remain available for process-prose boundaries."
|
||||
)
|
||||
assert "_stripLiveVisibleAssistantEchoFromThinking" in live_thinking_fn, (
|
||||
"Live Thinking text should suppress exact visible interim commentary echoes."
|
||||
assert "_stripLiveVisibleAssistantEchoFromThinking" not in live_thinking_fn, (
|
||||
"Live Thinking should not run content-level echo suppression; the card is already low-priority Worklog detail."
|
||||
)
|
||||
assert "String(liveReasoningText||'').trim()" in live_thinking_fn, (
|
||||
"Live Thinking should render the provider reasoning text as-is after normal trimming."
|
||||
)
|
||||
|
||||
def test_settled_thinking_suppresses_visible_assistant_echoes(self):
|
||||
def test_settled_exact_duplicate_thinking_suppressed(self):
|
||||
assert _run_thinking_echo_helper(
|
||||
" I will check the PR status.\nThen inspect the diff. ",
|
||||
"I will check the PR status. Then inspect the diff.",
|
||||
"The final answer is different.",
|
||||
) == "", (
|
||||
"Settled Thinking should be suppressed when normalized text exactly "
|
||||
"matches visible process prose."
|
||||
)
|
||||
|
||||
def test_genuine_reasoning_preserved_when_not_exact(self):
|
||||
reasoning = "I need to inspect the stream state before deciding."
|
||||
assert _run_thinking_echo_helper(
|
||||
reasoning,
|
||||
"I need to inspect the stream state.",
|
||||
"The stream was running.",
|
||||
) == reasoning, (
|
||||
"Non-exact reasoning should stay available as a Worklog Thinking Card."
|
||||
)
|
||||
helper = _function_body(UI_JS, "_stripVisibleAssistantEchoFromThinking")
|
||||
assert ".split(snippet).join('')" not in helper
|
||||
assert ".includes(" not in helper
|
||||
|
||||
def test_reasoning_first_interim_later_does_not_duplicate_settled_worklog(self):
|
||||
render_fn = _function_body(UI_JS, "renderMessages")
|
||||
helper = _function_body(UI_JS, "_worklogReasoningTextFromMessage")
|
||||
assert "assistantTurnFinalVisibleContentByRawIdx" in render_fn, (
|
||||
"renderMessages must compute current assistant-turn final text so "
|
||||
"reasoning-first/interim-later turns can be compared at settlement."
|
||||
)
|
||||
assert "assistantTurnVisibleContentByRawIdx" in render_fn, (
|
||||
"If done-time reasoning is attached to the final assistant message, "
|
||||
"settlement must still compare against earlier visible process prose "
|
||||
"from the same assistant turn."
|
||||
)
|
||||
assert "_worklogReasoningTextFromMessage(m, rawIdx, toolCallAssistantIdxs, displayContent, turnFinalVisibleContent, turnVisibleContents)" in render_fn
|
||||
assert "_stripVisibleAssistantEchoFromThinking(thinkingText, visibleContent, turnFinalVisibleContent, ...visibleTexts)" in helper
|
||||
assert _run_thinking_echo_helper(
|
||||
"I am checking the 3401 review blocker.",
|
||||
"I am checking the 3401 review blocker.",
|
||||
"Conclusion: Thinking dedupe needs a small fix.",
|
||||
) == ""
|
||||
|
||||
def test_settled_thinking_uses_exact_dedupe_not_live_rewrite(self):
|
||||
render_fn = _function_body(UI_JS, "renderMessages")
|
||||
helper = _function_body(UI_JS, "_stripVisibleAssistantEchoFromThinking")
|
||||
assert "_stripVisibleAssistantEchoFromThinking(thinkingText, displayContent)" in render_fn, (
|
||||
"Settled Thinking cards should not repeat text already rendered as visible assistant content."
|
||||
assert "_stripVisibleAssistantEchoFromThinking(thinkingText, displayContent)" not in render_fn, (
|
||||
"Settled Thinking dedupe needs process prose plus turn-final answer, "
|
||||
"not the old single visible-text input."
|
||||
)
|
||||
assert "s.length>=20" in helper, (
|
||||
"Thinking echo suppression should ignore tiny snippets to avoid over-stripping reasoning."
|
||||
)
|
||||
assert "out.split(snippet).join('')" in helper, (
|
||||
"Thinking echo suppression should remove exact visible assistant snippets from reasoning display."
|
||||
assert "_normalizeThinkingEchoCompare" in helper and "visibleNorm===thinkingNorm" in helper, (
|
||||
"Settled Thinking dedupe must be exact / normalized-exact only."
|
||||
)
|
||||
|
||||
def test_compact_activity_keeps_thinking_cards_after_session_switch(self):
|
||||
@@ -270,20 +445,26 @@ class TestToolCallGroupingStatic:
|
||||
)
|
||||
render_fn = _function_body(UI_JS, "renderMessages")
|
||||
assert "isSimplifiedToolCalling()" in render_fn and "assistantThinking.set(rawIdx, thinkingText)" in render_fn, (
|
||||
"Compact settled transcript rendering should preserve Thinking cards after switching sessions."
|
||||
"Compact settled transcript rendering should keep reasoning metadata available without promoting it to visible prose."
|
||||
)
|
||||
# #3709: the Activity disclosure now renders the TURN's merged thinking
|
||||
# (mergedThinking — all of a turn's thinking de-duped, incl. a suppressed
|
||||
# thinking-only sibling) rather than a single message's entry. Same node,
|
||||
# same Activity body — only the source variable changed.
|
||||
assert "_thinkingActivityNode(mergedThinking, false)" in render_fn, (
|
||||
"Settled Thinking cards should render inside the compact Activity disclosure."
|
||||
helper = _function_body(UI_JS, "_worklogReasoningTextFromMessage")
|
||||
assert "_assistantReasoningPayloadText(m)" in helper and "_stripVisibleAssistantEchoFromThinking" in helper, (
|
||||
"Provider reasoning metadata should feed a sanitized Worklog Thinking Card "
|
||||
"after settled exact-duplicate suppression."
|
||||
)
|
||||
assert "body.appendChild(_thinkingActivityNode(mergedThinking, false))" in render_fn, (
|
||||
"Settled Thinking cards should stay inside the same Activity body as the related tools."
|
||||
assert "data-worklog-thinking-card" in UI_JS, (
|
||||
"Thinking should be an explicit Worklog item, independent from Tool Cards."
|
||||
)
|
||||
assert ".agent-activity-thinking:not([data-live-thinking=\"1\"])" in render_fn, (
|
||||
"Settled rerenders must remove previously inserted Thinking activity rows before rebuilding."
|
||||
render_min = re.sub(r"\s+", "", render_fn)
|
||||
assert "thinkingKey:thinkingText?`thinking:${_normalizeThinkingEchoCompare(thinkingText)}`:''" in render_min, (
|
||||
"Settled Worklog should key Thinking Cards by normalized content so exact duplicate "
|
||||
"Thinking from sibling messages does not render twice."
|
||||
)
|
||||
assert "_appendWorklogStep" in render_fn, (
|
||||
"Visible assistant anchors, Thinking Cards, and tools should still build the compact Worklog disclosure."
|
||||
)
|
||||
assert ".wl-reason[data-worklog-reason-source=\"reasoning\"]" in render_fn, (
|
||||
"Settled rerenders must remove previously inserted reasoning Worklog rows before rebuilding."
|
||||
)
|
||||
assert "seg.insertAdjacentHTML('beforeend', _thinkingCardHtml(thinkingText))" in render_fn, (
|
||||
"The non-simplified path should preserve standalone settled thinking cards."
|
||||
@@ -293,17 +474,17 @@ class TestToolCallGroupingStatic:
|
||||
live_thinking_fn = _function_body(UI_JS, "appendThinking")
|
||||
live_tool_fn = _function_body(UI_JS, "appendLiveToolCard")
|
||||
helper = _function_body(UI_JS, "ensureActivityGroup")
|
||||
assert "isSimplifiedToolCalling()" in live_thinking_fn, (
|
||||
"Live thinking should branch on the Compact tool activity toggle."
|
||||
assert "_worklogReasonNodeFromText(thinkingText" not in live_thinking_fn, (
|
||||
"Provider reasoning should not render as live Worklog process prose."
|
||||
)
|
||||
assert "_thinkingActivityNode(thinkingText, false)" in live_thinking_fn, (
|
||||
"Compact live thinking should render inside the Activity disclosure."
|
||||
assert "_thinkingActivityNode(clean, false)" in live_thinking_fn and "data-live-thinking" in live_thinking_fn, (
|
||||
"Live provider thinking should render as a collapsed Worklog Thinking Card."
|
||||
)
|
||||
assert "ensureActivityGroup(blocks,{live:true" in live_thinking_fn and "body.appendChild(row)" in live_thinking_fn, (
|
||||
"Compact live thinking should share the same Activity body as live tool cards."
|
||||
assert "ensureLiveWorklogContainer" in live_thinking_fn, (
|
||||
"Live Thinking Cards should use the shared Worklog container, not a Tool Card group."
|
||||
)
|
||||
assert "removeAttribute('data-live-activity-current')" not in live_thinking_fn, (
|
||||
"Reasoning/Thinking updates alone should not split consecutive tools into one-tool Activity rows."
|
||||
"Reasoning/Thinking updates alone should not split consecutive tools into one-tool Worklog rows."
|
||||
)
|
||||
assert '.tool-call-group[data-live-tool-call-group="1"][data-live-activity-current="1"]' in helper, (
|
||||
"Live tool cards should only reuse the current Activity burst, not the first group in the turn."
|
||||
@@ -311,15 +492,15 @@ class TestToolCallGroupingStatic:
|
||||
assert "group.setAttribute('data-live-activity-current','1')" in helper, (
|
||||
"New live Activity bursts must be marked current so later tools append to the right group."
|
||||
)
|
||||
assert "body.querySelector" in live_tool_fn and "data-live-tid" in live_tool_fn, (
|
||||
assert "querySelector" in live_tool_fn and "data-live-tid" in live_tool_fn, (
|
||||
"tool_complete must still update its current live Activity burst by tool id."
|
||||
)
|
||||
finalize_fn = _function_body(UI_JS, "finalizeThinkingCard")
|
||||
assert "turn.querySelector('.agent-activity-thinking[data-thinking-active=\"1\"]')" in finalize_fn, (
|
||||
"Compact Thinking cards live inside the assistant turn, so finalization must clear the active marker from the whole turn."
|
||||
assert "turn.querySelector('.wl-reason[data-worklog-reason-active=\"1\"]')" in finalize_fn, (
|
||||
"Finalization should still clean up any legacy active reasoning marker."
|
||||
)
|
||||
assert "body.querySelector('.agent-activity-thinking[data-thinking-active=\"1\"]')" in live_thinking_fn and "setAttribute('data-thinking-active','1')" in live_thinking_fn, (
|
||||
"Compact live thinking should reactivate the latest existing Thinking card instead of stacking a new card after every tool boundary."
|
||||
assert "data-worklog-reason-active" not in live_thinking_fn, (
|
||||
"New live reasoning text should not create active Worklog prose rows."
|
||||
)
|
||||
reset_fn = _function_body(MESSAGES_JS, "_resetAssistantSegment")
|
||||
assert "function closeCurrentLiveActivityGroup()" in UI_JS, (
|
||||
@@ -347,6 +528,41 @@ class TestToolCallGroupingStatic:
|
||||
"Tool starts must not split consecutive tools into one-tool Activity rows."
|
||||
)
|
||||
|
||||
def test_live_thinking_card_is_segment_scoped_not_global_singleton(self):
|
||||
live_thinking_fn = _function_body(UI_JS, "appendThinking")
|
||||
placement_fn = _function_body(MESSAGES_JS, "_liveThinkingPlacement")
|
||||
update_fn = _function_body(MESSAGES_JS, "_updateLiveThinkingCard")
|
||||
interim_match = re.search(r"source\.addEventListener\('interim_assistant',e=>\{(.*?)\n\s*\}\);", MESSAGES_JS, re.S)
|
||||
assert interim_match, "interim_assistant listener not found"
|
||||
interim_fn = interim_match.group(1)
|
||||
|
||||
assert "data-live-thinking-key" in live_thinking_fn, (
|
||||
"Live Thinking rows need a segment/burst key so later reasoning does not update "
|
||||
"the first Thinking Card in the turn."
|
||||
)
|
||||
assert 'data-live-thinking="1"][data-live-thinking-key="' in live_thinking_fn, (
|
||||
"appendThinking() must query the current segment's live Thinking Card, not a "
|
||||
"turn-global singleton."
|
||||
)
|
||||
assert "segmentSeq" in placement_fn and "_currentLiveSegmentSeq" in placement_fn, (
|
||||
"Thinking placement should reuse the live segment sequence instead of inventing "
|
||||
"a second placement model."
|
||||
)
|
||||
assert "burstId:_currentActivityBurstId" in placement_fn, (
|
||||
"Thinking placement should carry the current activity burst for Worklog ordering."
|
||||
)
|
||||
assert "updateThinking(text, opts)" in update_fn, (
|
||||
"messages.js should pass segment placement into the UI Thinking helper."
|
||||
)
|
||||
assert "updateThinking('')" not in interim_fn, (
|
||||
"Live interim boundaries should finalize the current Thinking Card instead of "
|
||||
"clearing it mid-stream."
|
||||
)
|
||||
assert "finalizeThinkingCard()" in interim_fn, (
|
||||
"Visible interim assistant progress must close the current Thinking segment "
|
||||
"before the next segment starts."
|
||||
)
|
||||
|
||||
def test_live_compression_card_splits_current_tool_activity_burst(self):
|
||||
compression_fn = _function_body(UI_JS, "appendLiveCompressionCard")
|
||||
close_fn = _function_body(UI_JS, "closeCurrentLiveActivityGroup")
|
||||
@@ -415,28 +631,67 @@ class TestToolCardDesignTokens:
|
||||
def test_tool_card_css_uses_design_tokens_for_chrome(self):
|
||||
css_min = re.sub(r"\s+", "", CSS)
|
||||
assert ".tool-card{" in css_min, ".tool-card rule missing"
|
||||
assert "border-radius:var(--radius-card)" in css_min, (
|
||||
".tool-card border radius should use --radius-card, not hardcoded px."
|
||||
)
|
||||
assert "background:var(--surface-subtle)" in css_min, (
|
||||
".tool-card background should use --surface-subtle."
|
||||
)
|
||||
assert "border:1pxsolidvar(--border-subtle)" in css_min, (
|
||||
".tool-card border should use --border-subtle."
|
||||
tool_card_rule = css_min.rsplit(".tool-card{", 1)[1].split("}", 1)[0]
|
||||
rows_rule = css_min.split(".tg-rows{", 1)[1].split("}", 1)[0]
|
||||
assert "background:transparent" in tool_card_rule
|
||||
assert "border:0" in tool_card_rule
|
||||
assert "border-left:0" in tool_card_rule
|
||||
assert "border-left:1pxsolidvar(--border-subtle)" in rows_rule, (
|
||||
"Nested tool groups should be expressed with only a subtle left guide line."
|
||||
)
|
||||
|
||||
def test_tool_card_header_and_text_use_spacing_and_font_tokens(self):
|
||||
css_min = re.sub(r"\s+", "", CSS)
|
||||
assert ".tool-card-header{" in css_min, ".tool-card-header rule missing"
|
||||
assert "gap:var(--space-2)" in css_min, (
|
||||
".tool-card-header gap should use --space-2."
|
||||
header_rule = css_min.rsplit(".tool-card-header{", 1)[1].split("}", 1)[0]
|
||||
title_rule = css_min.split(".tl-title{", 1)[1].split("}", 1)[0]
|
||||
assert "gap:7px" in header_rule
|
||||
assert "padding:3px8px" in header_rule
|
||||
assert "border-radius:7px" in header_rule
|
||||
assert ".tool-card-name{" in css_min and "font-size:var(--message-body-font-size)" in css_min
|
||||
assert "font-size:var(--message-body-font-size)" in title_rule
|
||||
assert "font-family:var(--font-mono)" in title_rule
|
||||
|
||||
def test_worklog_thinking_card_uses_quiet_tool_row_hierarchy(self):
|
||||
selector = ".tool-worklog-list > .agent-activity-thinking .thinking-card,"
|
||||
assert selector in CSS, "Worklog Thinking Card quiet override missing"
|
||||
card_rule = re.sub(r"\s+", "", CSS.split(selector, 1)[1].split("}", 1)[0])
|
||||
header_rule = re.sub(
|
||||
r"\s+",
|
||||
"",
|
||||
CSS.split(".tool-worklog-list > .agent-activity-thinking .thinking-card-header{", 1)[1].split("}", 1)[0],
|
||||
)
|
||||
assert "padding:var(--space-1)var(--space-3)" in css_min, (
|
||||
".tool-card-header padding should use spacing tokens."
|
||||
label_rule = re.sub(
|
||||
r"\s+",
|
||||
"",
|
||||
CSS.split(".tool-worklog-list > .agent-activity-thinking .thinking-card-label{", 1)[1].split("}", 1)[0],
|
||||
)
|
||||
assert ".tool-card-name{" in css_min and "font-size:var(--font-size-xs)" in css_min, (
|
||||
".tool-card-name should use --font-size-xs."
|
||||
icon_rule = re.sub(
|
||||
r"\s+",
|
||||
"",
|
||||
CSS.split(".tool-worklog-list > .agent-activity-thinking .thinking-card-icon,", 1)[1].split("}", 1)[0],
|
||||
)
|
||||
assert ".tool-card-preview{" in css_min and "font-size:var(--font-size-xs)" in css_min, (
|
||||
".tool-card-preview should use --font-size-xs."
|
||||
body_rule = re.sub(
|
||||
r"\s+",
|
||||
"",
|
||||
CSS.split(".tool-worklog-list > .agent-activity-thinking .thinking-card.open .thinking-card-body{", 1)[1].split("}", 1)[0],
|
||||
)
|
||||
pre_rule = re.sub(
|
||||
r"\s+",
|
||||
"",
|
||||
CSS.split(".tool-worklog-list > .agent-activity-thinking .thinking-card-body pre{", 1)[1].split("}", 1)[0],
|
||||
)
|
||||
|
||||
assert "background:transparent" in card_rule
|
||||
assert "border:0" in card_rule
|
||||
assert "border-radius:0" in card_rule
|
||||
assert "display:flex" in header_rule and "align-items:center" in header_rule
|
||||
assert "color:var(--muted)" in header_rule
|
||||
assert "font-size:var(--message-body-font-size)" in header_rule
|
||||
assert "font-weight:400" in header_rule
|
||||
assert "font-weight:400" in label_rule
|
||||
assert "letter-spacing:0" in label_rule
|
||||
assert "color:var(--muted)" in icon_rule
|
||||
assert "padding:6px8px7px8px" in body_rule
|
||||
assert "font-size:var(--message-body-font-size)" in pre_rule
|
||||
assert "line-height:var(--message-body-line-height)" in pre_rule
|
||||
@@ -315,6 +315,7 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke
|
||||
events = []
|
||||
while not subscriber.empty():
|
||||
events.append(subscriber.get_nowait())
|
||||
event_pairs = [(item[0], item[1]) for item in events]
|
||||
assert ("tool", {
|
||||
"event_type": "tool.started",
|
||||
"name": "terminal",
|
||||
@@ -322,7 +323,7 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke
|
||||
"args": {},
|
||||
"is_error": False,
|
||||
"tid": "call-1",
|
||||
}) in events
|
||||
}) in event_pairs
|
||||
assert ("tool_complete", {
|
||||
"event_type": "tool.completed",
|
||||
"name": "terminal",
|
||||
@@ -330,7 +331,8 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke
|
||||
"args": {},
|
||||
"is_error": False,
|
||||
"tid": "call-1",
|
||||
}) in events
|
||||
}) in event_pairs
|
||||
assert all(len(item) == 3 and item[2] for item in events)
|
||||
|
||||
|
||||
def test_gateway_chat_worker_normalizes_prefill_slice_before_system_prefix(tmp_path, monkeypatch):
|
||||
|
||||
Reference in new issue
Block a user