rebase #4411 onto master (v0.51.542) for deep-review — Stable Assistant Turn Anchors capstone
franksong2702's activity_scene_v1 anchor-scene model: live-stream/settled/refresh/re-entry show the same ordered turn. Rebased clean (no conflicts) onto current master. Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
This commit is contained in:
1 parent
0d6adf9a08
commit
6951d10ae5
25 files changed
+5163
-219
No files matched your search
+111
-9
@@ -644,11 +644,12 @@ class Session:
|
||||
parent_session_id: str=None,
|
||||
worktree_path=None,
|
||||
worktree_branch=None,
|
||||
worktree_repo_root=None,
|
||||
worktree_created_at=None,
|
||||
enabled_toolsets=None,
|
||||
composer_draft=None,
|
||||
**kwargs):
|
||||
worktree_repo_root=None,
|
||||
worktree_created_at=None,
|
||||
enabled_toolsets=None,
|
||||
composer_draft=None,
|
||||
anchor_activity_scenes=None,
|
||||
**kwargs):
|
||||
self.session_id = session_id or uuid.uuid4().hex[:12]
|
||||
self.title = title
|
||||
self.workspace = str(Path(workspace).expanduser().resolve())
|
||||
@@ -704,6 +705,7 @@ class Session:
|
||||
self.read_only = bool(kwargs.get('read_only', False))
|
||||
self.enabled_toolsets = enabled_toolsets # List[str] or None — per-session toolset override
|
||||
self.composer_draft = composer_draft if isinstance(composer_draft, dict) else {}
|
||||
self.anchor_activity_scenes = anchor_activity_scenes if isinstance(anchor_activity_scenes, dict) else {}
|
||||
raw_message_count = kwargs.get('message_count')
|
||||
parsed_message_count = None
|
||||
if raw_message_count is not None:
|
||||
@@ -759,7 +761,7 @@ class Session:
|
||||
'parent_session_id',
|
||||
'worktree_path', 'worktree_branch', 'worktree_repo_root', 'worktree_created_at',
|
||||
'is_cli_session', 'source_tag', 'raw_source', 'session_source', 'source_label', 'read_only',
|
||||
'enabled_toolsets', 'composer_draft',
|
||||
'enabled_toolsets', 'composer_draft', 'anchor_activity_scenes',
|
||||
]
|
||||
meta = {k: getattr(self, k, None) for k in METADATA_FIELDS}
|
||||
meta['message_count'] = len(self.messages or [])
|
||||
@@ -2283,12 +2285,39 @@ def _cache_has_stale_unsaved_user_tail(cached, disk_session) -> bool:
|
||||
return False
|
||||
cached_messages = getattr(cached, 'messages', None) or []
|
||||
disk_messages = getattr(disk_session, 'messages', None) or []
|
||||
if len(cached_messages) <= len(disk_messages):
|
||||
return False
|
||||
if _last_non_tool_role(cached_messages) != 'user':
|
||||
return False
|
||||
if _last_non_tool_role(disk_messages) != 'assistant':
|
||||
return False
|
||||
if len(cached_messages) < len(disk_messages):
|
||||
return True
|
||||
if len(cached_messages) == len(disk_messages):
|
||||
# Same-length divergence is still stale: a completed assistant turn can
|
||||
# be persisted through a sibling Session object while this inactive LRU
|
||||
# entry still ends on the optimistic/recovered user row.
|
||||
#
|
||||
# Keep this narrow: only evict when the shared prefix is the same and
|
||||
# the cached user tail is not newer than the persisted assistant. A
|
||||
# genuine just-submitted user message can exist briefly before the
|
||||
# stream id is attached, and that must not be replaced by older disk
|
||||
# state.
|
||||
cached_tail = _last_non_tool_message(cached_messages)
|
||||
disk_tail = _last_non_tool_message(disk_messages)
|
||||
cached_prefix = [
|
||||
(_message_role(message), _message_content_text(message))
|
||||
for message in cached_messages[:-1]
|
||||
]
|
||||
disk_prefix = [
|
||||
(_message_role(message), _message_content_text(message))
|
||||
for message in disk_messages[:-1]
|
||||
]
|
||||
if cached_prefix != disk_prefix:
|
||||
return False
|
||||
cached_tail_ts = _message_timestamp(cached_tail)
|
||||
disk_tail_ts = _message_timestamp(disk_tail)
|
||||
if cached_tail_ts is not None and disk_tail_ts is not None and cached_tail_ts > disk_tail_ts:
|
||||
return False
|
||||
return True
|
||||
|
||||
cached_tail = _last_non_tool_message(cached_messages)
|
||||
previous_disk_user = None
|
||||
@@ -2305,6 +2334,30 @@ def _cache_has_stale_unsaved_user_tail(cached, disk_session) -> bool:
|
||||
return _message_content_text(cached_tail) == _message_content_text(previous_disk_user)
|
||||
|
||||
|
||||
def _anchor_scene_record_keys(session) -> set[str]:
|
||||
records = getattr(session, 'anchor_activity_scenes', None)
|
||||
if not isinstance(records, dict):
|
||||
return set()
|
||||
return {str(key) for key, value in records.items() if key and isinstance(value, dict)}
|
||||
|
||||
|
||||
def _anchor_scene_records_updated_at(session) -> float:
|
||||
records = getattr(session, 'anchor_activity_scenes', None)
|
||||
if not isinstance(records, dict):
|
||||
return 0.0
|
||||
latest = 0.0
|
||||
for record in records.values():
|
||||
if not isinstance(record, dict):
|
||||
continue
|
||||
try:
|
||||
updated_at = float(record.get('updated_at') or 0)
|
||||
except (TypeError, ValueError):
|
||||
updated_at = 0.0
|
||||
if updated_at > latest:
|
||||
latest = updated_at
|
||||
return latest
|
||||
|
||||
|
||||
def _cached_session_lags_disk(cached) -> bool:
|
||||
"""Return True when a cached full session is older than its sidecar.
|
||||
|
||||
@@ -2329,7 +2382,19 @@ def _cached_session_lags_disk(cached) -> bool:
|
||||
disk_count = _parse_nonnegative_int(getattr(disk_meta, '_metadata_message_count', None))
|
||||
if disk_count is None:
|
||||
disk_count = _lookup_index_message_count(sid)
|
||||
return bool(disk_count is not None and disk_count > cached_count)
|
||||
if disk_count is not None and disk_count > cached_count:
|
||||
return True
|
||||
if not getattr(cached, 'active_stream_id', None) and not getattr(cached, 'pending_user_message', None):
|
||||
cached_scene_keys = _anchor_scene_record_keys(cached)
|
||||
disk_scene_keys = _anchor_scene_record_keys(disk_meta)
|
||||
if disk_scene_keys and not disk_scene_keys.issubset(cached_scene_keys):
|
||||
return True
|
||||
if (
|
||||
disk_scene_keys
|
||||
and _anchor_scene_records_updated_at(disk_meta) > _anchor_scene_records_updated_at(cached)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_session(sid, metadata_only=False):
|
||||
@@ -4610,6 +4675,41 @@ def _has_visible_duplicate(visible_key: tuple, visible_keys: set[tuple]) -> bool
|
||||
return _matching_visible_duplicate(visible_key, visible_keys) is not None
|
||||
|
||||
|
||||
def _sidecar_has_terminal_partial_error(sidecar_messages: list) -> bool:
|
||||
"""Return True when WebUI already owns an interrupted live partial turn.
|
||||
|
||||
After a cancelled/error terminal event, the WebUI sidecar contains the
|
||||
user prompt, the streamed partial assistant prose/tool snapshot, and the
|
||||
explicit terminal carrier. state.db may still contain the same run's raw
|
||||
assistant/tool replay rows; appending those rows makes Compact Worklog show
|
||||
duplicated process prose after cancel. In that shape, the sidecar is the
|
||||
display owner.
|
||||
"""
|
||||
messages = [msg for msg in (sidecar_messages or []) if isinstance(msg, dict)]
|
||||
latest_error_idx = None
|
||||
for idx, msg in enumerate(messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if str(msg.get("role") or "").lower() != "assistant":
|
||||
continue
|
||||
if msg.get("_error"):
|
||||
latest_error_idx = idx
|
||||
if latest_error_idx is None:
|
||||
return False
|
||||
for msg in messages[latest_error_idx + 1 :]:
|
||||
if str(msg.get("role") or "").lower() in ("user", "assistant"):
|
||||
return False
|
||||
segment_start = 0
|
||||
for idx in range(latest_error_idx - 1, -1, -1):
|
||||
if str(messages[idx].get("role") or "").lower() == "user":
|
||||
segment_start = idx + 1
|
||||
break
|
||||
for msg in messages[segment_start:latest_error_idx]:
|
||||
if str(msg.get("role") or "").lower() == "assistant" and msg.get("_partial"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def state_db_delta_after_context(sidecar_context: list, state_messages: list) -> list:
|
||||
"""Return only state.db rows that are newer than model-facing context.
|
||||
|
||||
@@ -4816,6 +4916,8 @@ def merge_session_messages_append_only(
|
||||
sidecar_visible_counts[visible_key] = sidecar_visible_counts.get(visible_key, 0) + 1
|
||||
sidecar_visible_sequence.append(visible_key)
|
||||
merged_messages.append(msg)
|
||||
if _sidecar_has_terminal_partial_error(sidecar_messages):
|
||||
return merged_messages
|
||||
sidecar_visible_lookup = _build_visible_duplicate_lookup(sidecar_visible_keys)
|
||||
state_replay_idx = 0
|
||||
skipped_state_visible_counts = {}
|
||||
|
||||
+1012
-3
File diff suppressed because it is too large.
Load diff
@@ -41,6 +41,15 @@ streaming or rendering yet.
|
||||
Transparent Stream row hooks and feed them through the reconciler to produce a
|
||||
concrete matched / mismatched answer. The adapter remains opt-in and is not
|
||||
invoked by `renderMessages()` or the live SSE hot path.
|
||||
- The first visible-order handoff switches live Compact Worklog rendering from
|
||||
legacy DOM mirroring to the per-stream anchor `activity_scene_v1` projection
|
||||
for same-browser active streams. Visible process prose, reasoning rows, and
|
||||
tool start/complete boundaries are represented as ordered scene rows. The
|
||||
existing Compact Worklog writers remain as fallback paths only when no anchor
|
||||
scene is available, and settled assistant messages may carry an in-memory
|
||||
`_anchor_activity_scene` snapshot so the folded activity summary can appear
|
||||
above the final answer. This handoff does not claim Transparent Stream wiring
|
||||
or durable hard-reload scene persistence.
|
||||
|
||||
## State Layers
|
||||
|
||||
@@ -250,6 +259,32 @@ anchor-owned activity scene. A `matched: false` result is expected while current
|
||||
renderers intentionally collapse or omit events, such as representing a tool
|
||||
start + tool completion as one visible row or omitting terminal status rows.
|
||||
|
||||
## Compact Worklog Visible-Order Handoff
|
||||
|
||||
The first renderer handoff deliberately targets the live Compact Worklog path
|
||||
only. `attachLiveStream()` keeps the existing streaming markdown parser as a hot
|
||||
write buffer, but every visible process-prose segment is upserted into the
|
||||
per-stream anchor registry as a single `process_prose` row. Reasoning and tool
|
||||
events enter the same registry before their legacy renderer callbacks run, so
|
||||
the projected `activity_scene_v1` owns the visible row order.
|
||||
|
||||
`renderLiveAnchorActivityScene()` consumes only that projected scene for the
|
||||
active live turn. Once the live turn is anchor-owned, legacy live
|
||||
`appendLiveToolCard()`, `appendThinking()`, auto-compression, and Worklog
|
||||
reason-mirroring paths re-render or exit instead of creating a second activity
|
||||
rail. On same-browser session switch, `loadSession()` tries the live anchor
|
||||
scene before falling back to saved live DOM snapshots or persisted `INFLIGHT`
|
||||
tool replay.
|
||||
|
||||
At settle time, the active final assistant message may receive the current
|
||||
in-memory `_anchor_activity_scene`. `renderMessages()` uses that snapshot to
|
||||
build a folded activity summary above the final answer and leaves the final
|
||||
answer as ordinary assistant prose. Successful auto-compression rows remain
|
||||
live-only in settled history unless they explain a visible error or recovery
|
||||
state. This is not a Transparent Stream handoff and not a durable reload
|
||||
guarantee: hard reload scene persistence still requires a later persisted scene
|
||||
or journal hydration slice.
|
||||
|
||||
## Source Event Classification
|
||||
|
||||
Phase 0 classifies current sources before changing render behavior:
|
||||
|
||||
+210
@@ -594,6 +594,12 @@ const LOCALES = {
|
||||
settings_label_chat_activity_display_mode: 'Activity display',
|
||||
settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
|
||||
first_token_time: 'Time to first token',
|
||||
processed_elapsed: _i18nProcessedElapsedEn,
|
||||
worklog_thinking: 'Thinking',
|
||||
tool_target_skill_suffix: 'skill',
|
||||
tool_action_label: _i18nToolActionLabelEn,
|
||||
tool_worklog_summary: _i18nToolWorklogSummaryEn,
|
||||
tool_summary_join: _i18nToolSummaryJoinEn,
|
||||
done: 'Done',
|
||||
settings_option_compact_worklog: 'Compact Worklog',
|
||||
settings_option_transparent_stream: 'Transparent Stream',
|
||||
@@ -2118,6 +2124,12 @@ const LOCALES = {
|
||||
settings_label_chat_activity_display_mode: 'Activity display',
|
||||
settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
|
||||
first_token_time: 'Time to first token',
|
||||
processed_elapsed: _i18nProcessedElapsedEn,
|
||||
worklog_thinking: 'Thinking',
|
||||
tool_target_skill_suffix: 'skill',
|
||||
tool_action_label: _i18nToolActionLabelEn,
|
||||
tool_worklog_summary: _i18nToolWorklogSummaryEn,
|
||||
tool_summary_join: _i18nToolSummaryJoinEn,
|
||||
done: 'Done',
|
||||
settings_option_compact_worklog: 'Compact Worklog',
|
||||
settings_option_transparent_stream: 'Transparent Stream',
|
||||
@@ -3633,6 +3645,12 @@ const LOCALES = {
|
||||
settings_label_chat_activity_display_mode: 'アクティビティ表示',
|
||||
settings_desc_chat_activity_display_mode: '「コンパクトワークログ」はバックグラウンドアクティビティを簡潔なサマリーにまとめます。「トランスペアレントストリーム」は思考プロセスやツールの呼び出しイベントを、時系列順に展開可能な個別の行として表示し続けます。',
|
||||
first_token_time: '最初のトークン出力までの時間',
|
||||
processed_elapsed: _i18nProcessedElapsedEn,
|
||||
worklog_thinking: 'Thinking',
|
||||
tool_target_skill_suffix: 'skill',
|
||||
tool_action_label: _i18nToolActionLabelEn,
|
||||
tool_worklog_summary: _i18nToolWorklogSummaryEn,
|
||||
tool_summary_join: _i18nToolSummaryJoinEn,
|
||||
done: '完了',
|
||||
settings_option_compact_worklog: 'コンパクトワークログ',
|
||||
settings_option_transparent_stream: 'トランスペアレントストリーム',
|
||||
@@ -5835,6 +5853,12 @@ const LOCALES = {
|
||||
settings_label_chat_activity_display_mode: 'Activity display',
|
||||
settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
|
||||
first_token_time: 'Time to first token',
|
||||
processed_elapsed: _i18nProcessedElapsedEn,
|
||||
worklog_thinking: 'Thinking',
|
||||
tool_target_skill_suffix: 'skill',
|
||||
tool_action_label: _i18nToolActionLabelEn,
|
||||
tool_worklog_summary: _i18nToolWorklogSummaryEn,
|
||||
tool_summary_join: _i18nToolSummaryJoinEn,
|
||||
done: 'Done',
|
||||
settings_option_compact_worklog: 'Compact Worklog',
|
||||
settings_option_transparent_stream: 'Transparent Stream',
|
||||
@@ -7275,6 +7299,12 @@ const LOCALES = {
|
||||
settings_label_chat_activity_display_mode: 'Activity display',
|
||||
settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
|
||||
first_token_time: 'Time to first token',
|
||||
processed_elapsed: _i18nProcessedElapsedEn,
|
||||
worklog_thinking: 'Thinking',
|
||||
tool_target_skill_suffix: 'skill',
|
||||
tool_action_label: _i18nToolActionLabelEn,
|
||||
tool_worklog_summary: _i18nToolWorklogSummaryEn,
|
||||
tool_summary_join: _i18nToolSummaryJoinEn,
|
||||
done: 'Done',
|
||||
settings_option_compact_worklog: 'Compact Worklog',
|
||||
settings_option_transparent_stream: 'Transparent Stream',
|
||||
@@ -8409,6 +8439,12 @@ const LOCALES = {
|
||||
settings_label_chat_activity_display_mode: 'Activity display',
|
||||
settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
|
||||
first_token_time: 'Time to first token',
|
||||
processed_elapsed: _i18nProcessedElapsedEn,
|
||||
worklog_thinking: 'Thinking',
|
||||
tool_target_skill_suffix: 'skill',
|
||||
tool_action_label: _i18nToolActionLabelEn,
|
||||
tool_worklog_summary: _i18nToolWorklogSummaryEn,
|
||||
tool_summary_join: _i18nToolSummaryJoinEn,
|
||||
done: 'Done',
|
||||
settings_option_compact_worklog: 'Compact Worklog',
|
||||
settings_option_transparent_stream: 'Transparent Stream',
|
||||
@@ -10190,6 +10226,12 @@ const LOCALES = {
|
||||
settings_label_chat_activity_display_mode: 'Activity display',
|
||||
settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
|
||||
first_token_time: 'Time to first token',
|
||||
processed_elapsed: _i18nProcessedElapsedZh,
|
||||
worklog_thinking: '正在思考',
|
||||
tool_target_skill_suffix: '技能',
|
||||
tool_action_label: _i18nToolActionLabelZh,
|
||||
tool_worklog_summary: _i18nToolWorklogSummaryZh,
|
||||
tool_summary_join: _i18nToolSummaryJoinCjk,
|
||||
done: 'Done',
|
||||
settings_option_compact_worklog: 'Compact Worklog',
|
||||
settings_option_transparent_stream: 'Transparent Stream',
|
||||
@@ -10957,6 +10999,12 @@ const LOCALES = {
|
||||
settings_label_chat_activity_display_mode: 'Activity display',
|
||||
settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
|
||||
first_token_time: 'Time to first token',
|
||||
processed_elapsed: _i18nProcessedElapsedZhHant,
|
||||
worklog_thinking: '正在思考',
|
||||
tool_target_skill_suffix: '技能',
|
||||
tool_action_label: _i18nToolActionLabelZhHant,
|
||||
tool_worklog_summary: _i18nToolWorklogSummaryZhHant,
|
||||
tool_summary_join: _i18nToolSummaryJoinCjk,
|
||||
done: 'Done',
|
||||
settings_option_compact_worklog: 'Compact Worklog',
|
||||
settings_option_transparent_stream: 'Transparent Stream',
|
||||
@@ -12368,6 +12416,12 @@ const LOCALES = {
|
||||
settings_label_chat_activity_display_mode: 'Activity display',
|
||||
settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
|
||||
first_token_time: 'Time to first token',
|
||||
processed_elapsed: _i18nProcessedElapsedEn,
|
||||
worklog_thinking: 'Thinking',
|
||||
tool_target_skill_suffix: 'skill',
|
||||
tool_action_label: _i18nToolActionLabelEn,
|
||||
tool_worklog_summary: _i18nToolWorklogSummaryEn,
|
||||
tool_summary_join: _i18nToolSummaryJoinEn,
|
||||
done: 'Done',
|
||||
settings_option_compact_worklog: 'Compact Worklog',
|
||||
settings_option_transparent_stream: 'Transparent Stream',
|
||||
@@ -13783,6 +13837,12 @@ const LOCALES = {
|
||||
settings_label_chat_activity_display_mode: 'Activity display',
|
||||
settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
|
||||
first_token_time: 'Time to first token',
|
||||
processed_elapsed: _i18nProcessedElapsedEn,
|
||||
worklog_thinking: 'Thinking',
|
||||
tool_target_skill_suffix: 'skill',
|
||||
tool_action_label: _i18nToolActionLabelEn,
|
||||
tool_worklog_summary: _i18nToolWorklogSummaryEn,
|
||||
tool_summary_join: _i18nToolSummaryJoinEn,
|
||||
done: 'Done',
|
||||
settings_option_compact_worklog: 'Compact Worklog',
|
||||
settings_option_transparent_stream: 'Transparent Stream',
|
||||
@@ -15222,6 +15282,12 @@ const LOCALES = {
|
||||
settings_label_chat_activity_display_mode: 'Activity display',
|
||||
settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
|
||||
first_token_time: 'Time to first token',
|
||||
processed_elapsed: _i18nProcessedElapsedEn,
|
||||
worklog_thinking: 'Thinking',
|
||||
tool_target_skill_suffix: 'skill',
|
||||
tool_action_label: _i18nToolActionLabelEn,
|
||||
tool_worklog_summary: _i18nToolWorklogSummaryEn,
|
||||
tool_summary_join: _i18nToolSummaryJoinEn,
|
||||
done: 'Done',
|
||||
settings_option_compact_worklog: 'Compact Worklog',
|
||||
settings_option_transparent_stream: 'Transparent Stream',
|
||||
@@ -16755,6 +16821,12 @@ const LOCALES = {
|
||||
settings_label_chat_activity_display_mode: 'Activity display',
|
||||
settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
|
||||
first_token_time: 'Time to first token',
|
||||
processed_elapsed: _i18nProcessedElapsedEn,
|
||||
worklog_thinking: 'Thinking',
|
||||
tool_target_skill_suffix: 'skill',
|
||||
tool_action_label: _i18nToolActionLabelEn,
|
||||
tool_worklog_summary: _i18nToolWorklogSummaryEn,
|
||||
tool_summary_join: _i18nToolSummaryJoinEn,
|
||||
done: 'Done',
|
||||
settings_option_compact_worklog: 'Compact Worklog',
|
||||
settings_option_transparent_stream: 'Transparent Stream',
|
||||
@@ -18284,6 +18356,12 @@ const LOCALES = {
|
||||
settings_label_chat_activity_display_mode: 'Activity display',
|
||||
settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
|
||||
first_token_time: 'Time to first token',
|
||||
processed_elapsed: _i18nProcessedElapsedEn,
|
||||
worklog_thinking: 'Thinking',
|
||||
tool_target_skill_suffix: 'skill',
|
||||
tool_action_label: _i18nToolActionLabelEn,
|
||||
tool_worklog_summary: _i18nToolWorklogSummaryEn,
|
||||
tool_summary_join: _i18nToolSummaryJoinEn,
|
||||
done: 'Done',
|
||||
settings_option_compact_worklog: 'Compact Worklog',
|
||||
settings_option_transparent_stream: 'Transparent Stream',
|
||||
@@ -19205,6 +19283,138 @@ const LOCALES = {
|
||||
},
|
||||
};
|
||||
|
||||
const _I18N_TOOL_ACTION_TEXT_EN = {
|
||||
shell: { running: 'Running', done: 'Ran', fail: 'run', fallback: 'a command' },
|
||||
read: { running: 'Reading', done: 'Read', fail: 'read', fallback: 'a file' },
|
||||
list: { running: 'Listing', done: 'Listed', fail: 'list', fallback: 'files' },
|
||||
search: { running: 'Searching', done: 'Searched', fail: 'search', fallback: 'workspace' },
|
||||
web: { running: 'Checking', done: 'Checked', fail: 'check', fallback: 'web data' },
|
||||
write: { running: 'Updating', done: 'Updated', fail: 'update', fallback: 'a file' },
|
||||
skill: { running: 'Loading', done: 'Loaded', fail: 'load', fallback: 'a skill' },
|
||||
memory: { running: 'Saving', done: 'Saved', fail: 'save', fallback: 'memory' },
|
||||
delegate: { running: 'Delegating', done: 'Delegated', fail: 'delegate', fallback: 'a task' },
|
||||
unknown: { running: 'Running', done: 'Ran', fail: 'run', fallback: 'a tool' },
|
||||
};
|
||||
const _I18N_TOOL_SUMMARY_TEXT_EN = {
|
||||
shell: { running: ['Running a command', 'Running {n} commands'], done: ['Ran a command', 'Ran {n} commands'] },
|
||||
read: { running: ['Reading a file', 'Reading {n} files'], done: ['Read a file', 'Read {n} files'] },
|
||||
list: { running: ['Listing files', 'Listing {n} items'], done: ['Listed files', 'Listed {n} files'] },
|
||||
search: { running: ['Searching workspace', 'Searching workspace {n} times'], done: ['Searched workspace', 'Searched workspace {n} times'] },
|
||||
web: { running: ['Checking web', 'Checking web {n} times'], done: ['Checked the web', 'Checked the web {n} times'] },
|
||||
write: { running: ['Updating a file', 'Updating {n} files'], done: ['Updated a file', 'Updated {n} files'] },
|
||||
skill: { running: ['Loading a skill', 'Loading {n} skills'], done: ['Loaded a skill', 'Loaded {n} skills'] },
|
||||
memory: { running: ['Saving memory', 'Saving {n} memory updates'], done: ['Saved memory', 'Saved {n} memory updates'] },
|
||||
delegate: { running: ['Delegating a task', 'Delegating {n} tasks'], done: ['Delegated a task', 'Delegated {n} tasks'] },
|
||||
unknown: { running: ['Running a tool', 'Running {n} tools'], done: ['Ran a tool', 'Ran {n} tools'] },
|
||||
};
|
||||
const _I18N_TOOL_ACTION_TEXT_ZH = {
|
||||
shell: { running: '正在运行', done: '已运行', fail: '运行', fallback: '命令' },
|
||||
read: { running: '正在读取', done: '已读取', fail: '读取', fallback: '文件' },
|
||||
list: { running: '正在列出', done: '已列出', fail: '列出', fallback: '文件' },
|
||||
search: { running: '正在搜索', done: '已搜索', fail: '搜索', fallback: '代码' },
|
||||
web: { running: '正在检查', done: '已检查', fail: '检查', fallback: '网页' },
|
||||
write: { running: '正在更新', done: '已更新', fail: '更新', fallback: '文件' },
|
||||
skill: { running: '正在读取', done: '已读取', fail: '读取', fallback: '技能' },
|
||||
memory: { running: '正在保存', done: '已保存', fail: '保存', fallback: '记忆' },
|
||||
delegate: { running: '正在委派', done: '已委派', fail: '委派', fallback: '任务' },
|
||||
unknown: { running: '正在运行', done: '已运行', fail: '运行', fallback: '工具' },
|
||||
};
|
||||
const _I18N_TOOL_SUMMARY_TEXT_ZH = {
|
||||
shell: { running: ['正在运行命令', '正在运行 {n} 条命令'], done: ['已运行命令', '已运行 {n} 条命令'] },
|
||||
read: { running: ['正在读取文件', '正在读取 {n} 个文件'], done: ['已读取文件', '已读取 {n} 个文件'] },
|
||||
list: { running: ['正在列出文件', '正在列出 {n} 个项目'], done: ['已列出文件', '已列出 {n} 个项目'] },
|
||||
search: { running: ['正在搜索代码', '正在搜索代码 {n} 次'], done: ['已搜索代码', '已搜索代码 {n} 次'] },
|
||||
web: { running: ['正在检查网页', '正在检查网页 {n} 次'], done: ['已检查网页', '已检查网页 {n} 次'] },
|
||||
write: { running: ['正在更新文件', '正在更新 {n} 个文件'], done: ['已更新文件', '已更新 {n} 个文件'] },
|
||||
skill: { running: ['正在读取技能', '正在读取 {n} 个技能'], done: ['已读取技能', '已读取 {n} 个技能'] },
|
||||
memory: { running: ['正在保存记忆', '正在保存 {n} 条记忆'], done: ['已保存记忆', '已保存 {n} 条记忆'] },
|
||||
delegate: { running: ['正在委派任务', '正在委派 {n} 个任务'], done: ['已委派任务', '已委派 {n} 个任务'] },
|
||||
unknown: { running: ['正在运行工具', '正在运行 {n} 个工具'], done: ['已运行工具', '已运行 {n} 个工具'] },
|
||||
};
|
||||
const _I18N_TOOL_ACTION_TEXT_ZH_HANT = {
|
||||
shell: { running: '正在執行', done: '已執行', fail: '執行', fallback: '命令' },
|
||||
read: { running: '正在讀取', done: '已讀取', fail: '讀取', fallback: '檔案' },
|
||||
list: { running: '正在列出', done: '已列出', fail: '列出', fallback: '檔案' },
|
||||
search: { running: '正在搜尋', done: '已搜尋', fail: '搜尋', fallback: '程式碼' },
|
||||
web: { running: '正在檢查', done: '已檢查', fail: '檢查', fallback: '網頁' },
|
||||
write: { running: '正在更新', done: '已更新', fail: '更新', fallback: '檔案' },
|
||||
skill: { running: '正在讀取', done: '已讀取', fail: '讀取', fallback: '技能' },
|
||||
memory: { running: '正在儲存', done: '已儲存', fail: '儲存', fallback: '記憶' },
|
||||
delegate: { running: '正在委派', done: '已委派', fail: '委派', fallback: '任務' },
|
||||
unknown: { running: '正在執行', done: '已執行', fail: '執行', fallback: '工具' },
|
||||
};
|
||||
const _I18N_TOOL_SUMMARY_TEXT_ZH_HANT = {
|
||||
shell: { running: ['正在執行命令', '正在執行 {n} 條命令'], done: ['已執行命令', '已執行 {n} 條命令'] },
|
||||
read: { running: ['正在讀取檔案', '正在讀取 {n} 個檔案'], done: ['已讀取檔案', '已讀取 {n} 個檔案'] },
|
||||
list: { running: ['正在列出檔案', '正在列出 {n} 個項目'], done: ['已列出檔案', '已列出 {n} 個項目'] },
|
||||
search: { running: ['正在搜尋程式碼', '正在搜尋程式碼 {n} 次'], done: ['已搜尋程式碼', '已搜尋程式碼 {n} 次'] },
|
||||
web: { running: ['正在檢查網頁', '正在檢查網頁 {n} 次'], done: ['已檢查網頁', '已檢查網頁 {n} 次'] },
|
||||
write: { running: ['正在更新檔案', '正在更新 {n} 個檔案'], done: ['已更新檔案', '已更新 {n} 個檔案'] },
|
||||
skill: { running: ['正在讀取技能', '正在讀取 {n} 個技能'], done: ['已讀取技能', '已讀取 {n} 個技能'] },
|
||||
memory: { running: ['正在儲存記憶', '正在儲存 {n} 條記憶'], done: ['已儲存記憶', '已儲存 {n} 條記憶'] },
|
||||
delegate: { running: ['正在委派任務', '正在委派 {n} 個任務'], done: ['已委派任務', '已委派 {n} 個任務'] },
|
||||
unknown: { running: ['正在執行工具', '正在執行 {n} 個工具'], done: ['已執行工具', '已執行 {n} 個工具'] },
|
||||
};
|
||||
function _i18nProcessedElapsed(prefix, duration) {
|
||||
return duration ? `${prefix} ${duration}` : prefix;
|
||||
}
|
||||
function _i18nProcessedElapsedEn(duration) {
|
||||
return _i18nProcessedElapsed('Processed', duration);
|
||||
}
|
||||
function _i18nProcessedElapsedZh(duration) {
|
||||
return _i18nProcessedElapsed('已处理', duration);
|
||||
}
|
||||
function _i18nProcessedElapsedZhHant(duration) {
|
||||
return _i18nProcessedElapsed('已處理', duration);
|
||||
}
|
||||
function _i18nToolActionLabelFromMap(map, kind, state, target, display, failed) {
|
||||
const verbs = map[kind] || map.unknown || _I18N_TOOL_ACTION_TEXT_EN.unknown;
|
||||
const object = target || verbs.fallback || display || 'tool';
|
||||
if (failed) return `Failed to ${verbs.fail || 'run'} ${object}`;
|
||||
return `${verbs[state] || verbs.running} ${object}`;
|
||||
}
|
||||
function _i18nToolActionLabelEn(kind, state, target, display, failed) {
|
||||
return _i18nToolActionLabelFromMap(_I18N_TOOL_ACTION_TEXT_EN, kind, state, target, display, failed);
|
||||
}
|
||||
function _i18nToolActionLabelZh(kind, state, target, display, failed) {
|
||||
if (failed) {
|
||||
const verbs = _I18N_TOOL_ACTION_TEXT_ZH[kind] || _I18N_TOOL_ACTION_TEXT_ZH.unknown;
|
||||
return `未能${verbs.fail || '运行'} ${target || verbs.fallback || display || '工具'}`;
|
||||
}
|
||||
return _i18nToolActionLabelFromMap(_I18N_TOOL_ACTION_TEXT_ZH, kind, state, target, display, failed);
|
||||
}
|
||||
function _i18nToolActionLabelZhHant(kind, state, target, display, failed) {
|
||||
if (failed) {
|
||||
const verbs = _I18N_TOOL_ACTION_TEXT_ZH_HANT[kind] || _I18N_TOOL_ACTION_TEXT_ZH_HANT.unknown;
|
||||
return `未能${verbs.fail || '執行'} ${target || verbs.fallback || display || '工具'}`;
|
||||
}
|
||||
return _i18nToolActionLabelFromMap(_I18N_TOOL_ACTION_TEXT_ZH_HANT, kind, state, target, display, failed);
|
||||
}
|
||||
function _i18nToolWorklogSummaryFromMap(map, kind, state, count) {
|
||||
const n = Math.max(1, Number(count) || 1);
|
||||
const form = (map[kind] || map.unknown || _I18N_TOOL_SUMMARY_TEXT_EN.unknown)[state] || map.unknown.running;
|
||||
return (n === 1 ? form[0] : form[1]).replace('{n}', String(n));
|
||||
}
|
||||
function _i18nToolWorklogSummaryEn(kind, state, count) {
|
||||
return _i18nToolWorklogSummaryFromMap(_I18N_TOOL_SUMMARY_TEXT_EN, kind, state, count);
|
||||
}
|
||||
function _i18nToolWorklogSummaryZh(kind, state, count) {
|
||||
return _i18nToolWorklogSummaryFromMap(_I18N_TOOL_SUMMARY_TEXT_ZH, kind, state, count);
|
||||
}
|
||||
function _i18nToolWorklogSummaryZhHant(kind, state, count) {
|
||||
return _i18nToolWorklogSummaryFromMap(_I18N_TOOL_SUMMARY_TEXT_ZH_HANT, kind, state, count);
|
||||
}
|
||||
function _i18nToolSummaryJoinEn(parts) {
|
||||
return Array.isArray(parts) ? parts.filter(Boolean).join(', ') : '';
|
||||
}
|
||||
function _i18nToolSummaryJoinCjk(parts) {
|
||||
const items = Array.isArray(parts) ? parts.filter(Boolean) : [];
|
||||
if (items.length <= 1) return items[0] || '';
|
||||
if (items.length === 2) return `${items[0]}和${items[1]}`;
|
||||
return `${items.slice(0, -1).join('、')}和${items[items.length - 1]}`;
|
||||
}
|
||||
|
||||
|
||||
// Active locale — defaults to English; overridden by loadLocale() at boot.
|
||||
let _locale = LOCALES.en;
|
||||
|
||||
|
||||
+694
-22
@@ -1194,7 +1194,10 @@ async function send(){
|
||||
clearLiveToolCards(); // clear any leftover live cards from last turn
|
||||
let optimisticMessages;
|
||||
try{
|
||||
S.messages.push(userMsg);renderMessages();appendThinking('',{pending:true});setBusy(true);
|
||||
S.messages.push(userMsg);renderMessages();setBusy(true);
|
||||
if(S.session&&!S.session.pending_started_at) S.session.pending_started_at=Date.now()/1000;
|
||||
if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell();
|
||||
else appendThinking('',{pending:true});
|
||||
// First optimistic pass: make the local user turn visible before /api/chat/start
|
||||
// can save pending state on the server.
|
||||
_runOptionalPreStartUiStep('upsertActiveSessionForLocalTurn.initial', ()=>{
|
||||
@@ -1253,7 +1256,9 @@ async function send(){
|
||||
optimisticMessages=[...S.messages];
|
||||
INFLIGHT[activeSid]={messages:optimisticMessages,uploaded:uploadedNames,toolCalls:[]};
|
||||
try{setBusy(true);}catch(_){S.busy=true;}
|
||||
if(S.session&&!S.session.pending_started_at) S.session.pending_started_at=Date.now()/1000;
|
||||
S.activeStreamId=null;
|
||||
if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell();
|
||||
}
|
||||
|
||||
// Start the agent via POST, get a stream_id back
|
||||
@@ -1397,10 +1402,15 @@ async function send(){
|
||||
if(typeof syncTopbar==='function') syncTopbar();
|
||||
}
|
||||
|
||||
if(typeof appendThinking==='function') appendThinking('',{pending:true});
|
||||
if(S.session&&typeof startData.pending_started_at==='number'){
|
||||
S.session.pending_started_at=startData.pending_started_at;
|
||||
}
|
||||
if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell();
|
||||
else if(typeof appendThinking==='function') appendThinking('',{pending:true});
|
||||
// setBusy(true) already ran with activeStreamId=null; refresh now that we
|
||||
// have a stream id so the primary button can switch to Stop (see
|
||||
// getComposerPrimaryAction).
|
||||
if(typeof updateSendBtn==='function') updateSendBtn();
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
S.session.active_stream_id = streamId;
|
||||
}
|
||||
@@ -1750,6 +1760,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
lastReasoningText:inflight.lastReasoningText||'',
|
||||
lastRunJournalSeq:inflight.lastRunJournalSeq||0,
|
||||
journalReplayFromStart:!!inflight.journalReplayFromStart,
|
||||
anchorActivityScene:inflight.anchorActivityScene||null,
|
||||
currentActivityBurstId:inflight.currentActivityBurstId||0,
|
||||
currentLiveSegmentSeq:inflight.currentLiveSegmentSeq||0,
|
||||
activityBurstAnchors:Array.isArray(inflight.activityBurstAnchors)?inflight.activityBurstAnchors:[],
|
||||
@@ -2098,6 +2109,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
: null);
|
||||
let _anchorShadowWarned=false;
|
||||
let _anchorReasoningFlushed=false;
|
||||
let _anchorLocalSeq=0;
|
||||
if(_anchorRegistryMap&&_anchorRegistry) _anchorRegistryMap.set(streamId,_anchorRegistry);
|
||||
function _scheduleAnchorRegistryCleanup(delayMs=600000){
|
||||
if(!_anchorRegistryMap||!_anchorRegistry) return;
|
||||
@@ -2118,16 +2130,18 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const sourceEvent={
|
||||
...raw,
|
||||
source_event_type:sourceEventType,
|
||||
activitySegmentSeq:_assistantSegmentSeq,
|
||||
activityBurstId:_currentActivityBurstId,
|
||||
activitySegmentSeq:raw.activitySegmentSeq??raw.activity_segment_seq??_assistantSegmentSeq,
|
||||
activityBurstId:raw.activityBurstId??raw.activity_burst_id??_currentActivityBurstId,
|
||||
};
|
||||
if(eventId) sourceEvent.event_id=eventId;
|
||||
try{
|
||||
return _anchorApi.applyAssistantTurnAnchorSourceEvent(
|
||||
const result=_anchorApi.applyAssistantTurnAnchorSourceEvent(
|
||||
_anchorRegistry,
|
||||
sourceEvent,
|
||||
{session_id:activeSid,stream_id:streamId}
|
||||
);
|
||||
_renderAnchorLiveScene();
|
||||
return result;
|
||||
}catch(err){
|
||||
if(!_anchorShadowWarned&&typeof console!=='undefined'&&console.warn){
|
||||
_anchorShadowWarned=true;
|
||||
@@ -2136,15 +2150,640 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function _anchorActivityEvents(){
|
||||
const anchor=_anchorRegistry&&_anchorRegistry.anchor;
|
||||
return anchor&&Array.isArray(anchor.activity_events)?anchor.activity_events:null;
|
||||
}
|
||||
function _findAnchorActivityEventByLocalId(localId, sourceEventType){
|
||||
const events=_anchorActivityEvents();
|
||||
if(!events||!localId) return null;
|
||||
for(let i=events.length-1;i>=0;i--){
|
||||
const event=events[i];
|
||||
if(!event||event.local_id!==localId) continue;
|
||||
if(sourceEventType&&event.source_event_type!==sourceEventType) continue;
|
||||
return event;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function _latestAnchorCompressionEventIndex(sourceEventType){
|
||||
const events=_anchorActivityEvents();
|
||||
if(!events) return -1;
|
||||
for(let i=events.length-1;i>=0;i--){
|
||||
const event=events[i];
|
||||
if(event&&event.source_event_type===sourceEventType) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function _anchorCompressionCompletedAfter(index){
|
||||
const events=_anchorActivityEvents();
|
||||
if(!events) return false;
|
||||
for(let i=events.length-1;i>index;i--){
|
||||
const event=events[i];
|
||||
if(event&&event.source_event_type==='compressed') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function _ensureAnchorCompressionCompletedOnLiveProgress(sessionId){
|
||||
if(!_anchorRegistry||!_anchorApi) return false;
|
||||
const sid=String(sessionId||activeSid||'');
|
||||
const events=_anchorActivityEvents();
|
||||
const runningIndex=_latestAnchorCompressionEventIndex('compressing');
|
||||
if(runningIndex>=0&&_anchorCompressionCompletedAfter(runningIndex)) return true;
|
||||
const runningEvent=(events&&runningIndex>=0)?events[runningIndex]:null;
|
||||
const basis=String((runningEvent&&(runningEvent.local_id||runningEvent.event_id))||streamId||sid||'compression');
|
||||
const localId=`live-compression-complete:${basis}`;
|
||||
if(_findAnchorActivityEventByLocalId(localId,'compressed')) return true;
|
||||
const eventId=`synthetic:${localId}`;
|
||||
const result=_applyToAnchor('compressed',{
|
||||
event_id:eventId,
|
||||
local_id:localId,
|
||||
session_id:sid,
|
||||
old_session_id:sid,
|
||||
automatic:true,
|
||||
synthetic:true,
|
||||
status:'completed',
|
||||
phase:'done',
|
||||
message:'Context auto-compressed',
|
||||
},{lastEventId:eventId});
|
||||
return !!(result&&(result.applied||result.reason==='duplicate'));
|
||||
}
|
||||
function _replaceAnchorActivityEventByLocalId(localId, sourceEventType, patch){
|
||||
const events=_anchorActivityEvents();
|
||||
if(!events||!localId) return null;
|
||||
for(let i=events.length-1;i>=0;i--){
|
||||
const event=events[i];
|
||||
if(!event||event.local_id!==localId) continue;
|
||||
if(sourceEventType&&event.source_event_type!==sourceEventType) continue;
|
||||
const next={
|
||||
...event,
|
||||
...(patch||{}),
|
||||
payload:{
|
||||
...(event.payload&&typeof event.payload==='object'?event.payload:{}),
|
||||
...((patch&&patch.payload&&typeof patch.payload==='object')?patch.payload:{}),
|
||||
},
|
||||
};
|
||||
events[i]=next;
|
||||
return next;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function _nextAnchorLocalSeq(){
|
||||
_anchorLocalSeq+=1;
|
||||
const cursor=Number(_runJournalReplayAfterSeq&&_runJournalReplayAfterSeq());
|
||||
return (Number.isFinite(cursor)?cursor:0)+_anchorLocalSeq;
|
||||
}
|
||||
function _anchorSegmentSeq(){
|
||||
const seq=Number(_assistantSegmentSeq||_currentLiveSegmentSeq||0);
|
||||
return Number.isFinite(seq)&&seq>0?seq:1;
|
||||
}
|
||||
function _renderAnchorLiveScene(){
|
||||
if(!_anchorRegistry||!_isActiveSession()) return false;
|
||||
if(typeof window==='undefined'||typeof window._renderLiveAnchorActivitySceneForStream!=='function') return false;
|
||||
try{
|
||||
return !!window._renderLiveAnchorActivitySceneForStream(streamId, activeSid, {
|
||||
mode:'compact_worklog',
|
||||
});
|
||||
}catch(err){
|
||||
if(!_anchorShadowWarned&&typeof console!=='undefined'&&console.warn){
|
||||
_anchorShadowWarned=true;
|
||||
console.warn('assistant turn anchor live scene render failed',err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function _projectLiveAnchorActivityScene(){
|
||||
if(!_anchorRegistry||!_anchorApi||typeof _anchorApi.projectAssistantTurnAnchorActivityScene!=='function') return null;
|
||||
try{
|
||||
return _anchorApi.projectAssistantTurnAnchorActivityScene(_anchorRegistry,{mode:'compact_worklog'});
|
||||
}catch(_){
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function _anchorSceneMessageRef(message){
|
||||
if(!message||typeof message!=='object') return '';
|
||||
let content=message.content||'';
|
||||
if(Array.isArray(content)){
|
||||
try{
|
||||
content=content.map(part=>{
|
||||
if(part&&typeof part==='object') return part.text||part.content||part.input_text||'';
|
||||
return String(part||'');
|
||||
}).join('\n');
|
||||
}catch(_){ content=''; }
|
||||
}
|
||||
const payload={
|
||||
role:String(message.role||''),
|
||||
content:String(content||'').replace(/\s+/g,' ').trim(),
|
||||
timestamp:message._ts||message.timestamp||'',
|
||||
};
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
function _anchorSceneMessageText(message){
|
||||
if(!message||typeof message!=='object') return '';
|
||||
let content=message.content||'';
|
||||
if(Array.isArray(content)){
|
||||
try{
|
||||
content=content.map(part=>{
|
||||
if(part&&typeof part==='object') return part.text||part.content||part.input_text||'';
|
||||
return String(part||'');
|
||||
}).join('\n');
|
||||
}catch(_){ content=''; }
|
||||
}
|
||||
return typeof content==='string'?content:String(content||'');
|
||||
}
|
||||
function _anchorSceneCleanText(value){
|
||||
return String(value||'').replace(/\s+/g,' ').trim();
|
||||
}
|
||||
function _anchorSceneTextKey(value){
|
||||
return _anchorSceneCleanText(value).toLowerCase();
|
||||
}
|
||||
function _anchorSceneSafePayload(value){
|
||||
if(value===undefined) return undefined;
|
||||
if(value===null||typeof value!=='object') return value;
|
||||
try{
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}catch(_){
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
function _anchorSceneToolId(tool){
|
||||
return String(tool&&(tool.tid||tool.id||tool.tool_call_id||tool.tool_use_id||tool.call_id)||'').trim();
|
||||
}
|
||||
function _anchorSceneToolName(tool){
|
||||
const fn=tool&&tool.function&&typeof tool.function==='object'?tool.function:{};
|
||||
return String(tool&&(tool.name||tool.tool_name)||fn.name||'tool').trim()||'tool';
|
||||
}
|
||||
function _anchorSceneToolArgs(tool){
|
||||
if(!tool||typeof tool!=='object') return {};
|
||||
if(tool.args&&typeof tool.args==='object') return _anchorSceneSafePayload(tool.args)||{};
|
||||
if(tool.input&&typeof tool.input==='object') return _anchorSceneSafePayload(tool.input)||{};
|
||||
const fn=tool.function&&typeof tool.function==='object'?tool.function:{};
|
||||
if(typeof fn.arguments==='string'&&fn.arguments.trim()){
|
||||
try{
|
||||
const parsed=JSON.parse(fn.arguments);
|
||||
return parsed&&typeof parsed==='object'?_anchorSceneSafePayload(parsed):{};
|
||||
}catch(_){}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
function _anchorSceneStringPayload(value){
|
||||
if(value===undefined||value===null) return '';
|
||||
if(typeof value==='string') return value;
|
||||
try{
|
||||
return JSON.stringify(value);
|
||||
}catch(_){
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
function _anchorSceneRowBase(role, kind, sourceEventType, orderIndex, messageIndex){
|
||||
const groupKey=Number.isFinite(Number(messageIndex))?`assistant:${Number(messageIndex)}`:`activity:${orderIndex}`;
|
||||
return {
|
||||
row_id:`settled:${activeSid||'session'}:${streamId||'stream'}:${role}:${messageIndex}:${orderIndex}`,
|
||||
order_index:orderIndex,
|
||||
kind,
|
||||
role,
|
||||
display_hint:role==='prose'?'main_prose':role==='thinking'?'collapsed_thinking':role==='tool'?'tool_row':role==='terminal'?'terminal_status_row':'activity_row',
|
||||
display_hints:{
|
||||
compact_worklog:role==='prose'?'main_prose':role==='thinking'?'collapsed_thinking':role==='tool'?'tool_row':role==='terminal'?'terminal_status_row':'activity_row',
|
||||
transparent_stream:'chronological_activity',
|
||||
},
|
||||
source_event_type:sourceEventType,
|
||||
event_id:null,
|
||||
local_id:null,
|
||||
run_id:null,
|
||||
stream_id:streamId||null,
|
||||
seq:orderIndex,
|
||||
status:role==='terminal'?'completed':'completed',
|
||||
created_at:null,
|
||||
identity:{event_id:null,local_id:null,run_id:null,stream_id:streamId||null,seq:orderIndex},
|
||||
group:{
|
||||
group_key:groupKey,
|
||||
activity_burst_id:null,
|
||||
activity_segment_seq:null,
|
||||
assistant_msg_idx:Number.isFinite(Number(messageIndex))?Number(messageIndex):null,
|
||||
},
|
||||
text:'',
|
||||
thinking:null,
|
||||
tool_call_id:null,
|
||||
tool:null,
|
||||
payload:{assistant_msg_idx:Number.isFinite(Number(messageIndex))?Number(messageIndex):null},
|
||||
};
|
||||
}
|
||||
function _anchorSceneProseRow(text, orderIndex, messageIndex){
|
||||
const row=_anchorSceneRowBase('prose','process_prose','settled_message',orderIndex,messageIndex);
|
||||
row.text=String(text||'');
|
||||
row.payload={...row.payload,text:row.text};
|
||||
return row;
|
||||
}
|
||||
function _anchorSceneThinkingRow(text, orderIndex, messageIndex){
|
||||
const row=_anchorSceneRowBase('thinking','reasoning','reasoning',orderIndex,messageIndex);
|
||||
row.text=String(text||'');
|
||||
const preview=_anchorSceneCleanText(text);
|
||||
row.thinking={
|
||||
text:row.text,
|
||||
preview:preview.length>180?`${preview.slice(0,177)}...`:preview,
|
||||
dedupe_key:preview?`thinking:${preview.toLowerCase()}`:'',
|
||||
};
|
||||
row.payload={...row.payload,text:row.text};
|
||||
return row;
|
||||
}
|
||||
function _anchorSceneToolRowFromCall(tool, orderIndex, messageIndex){
|
||||
const row=_anchorSceneRowBase('tool','tool_completed','tool_complete',orderIndex,messageIndex);
|
||||
const tid=_anchorSceneToolId(tool);
|
||||
const name=_anchorSceneToolName(tool);
|
||||
const args=_anchorSceneToolArgs(tool);
|
||||
const command=_anchorSceneStringPayload(tool&&(tool.command||tool.raw_command||tool.original_command||tool.display_command))||_anchorSceneStringPayload(args&&(args.cmd||args.command));
|
||||
const preview=_anchorSceneStringPayload(tool&&(tool.preview||tool.summary));
|
||||
const snippet=_anchorSceneStringPayload(tool&&(tool.snippet||tool.result||tool.output));
|
||||
const isError=!!(tool&&(tool.is_error||tool.error));
|
||||
row.row_id=tid?`settled:${activeSid||'session'}:${streamId||'stream'}:tool:${tid}`:row.row_id;
|
||||
row.tool_call_id=tid||null;
|
||||
row.tool={
|
||||
id:tid||null,
|
||||
name,
|
||||
args,
|
||||
command,
|
||||
preview,
|
||||
snippet,
|
||||
result:_anchorSceneSafePayload(tool&&tool.result)??null,
|
||||
output:_anchorSceneSafePayload(tool&&tool.output)??null,
|
||||
done:true,
|
||||
is_error:isError,
|
||||
duration:tool&&tool.duration!==undefined?tool.duration:null,
|
||||
started_at:tool&&tool.started_at!==undefined?tool.started_at:null,
|
||||
signature:[name,tid||'',JSON.stringify(args||{})].join('|'),
|
||||
};
|
||||
row.payload={
|
||||
...row.payload,
|
||||
tid:tid||undefined,
|
||||
id:tid||undefined,
|
||||
name,
|
||||
args,
|
||||
command,
|
||||
preview,
|
||||
snippet,
|
||||
is_error:isError,
|
||||
duration:tool&&tool.duration!==undefined?tool.duration:undefined,
|
||||
started_at:tool&&tool.started_at!==undefined?tool.started_at:undefined,
|
||||
};
|
||||
return row;
|
||||
}
|
||||
function _anchorSceneMessageReasoningText(message){
|
||||
if(!message||typeof message!=='object') return '';
|
||||
return String(message.reasoning||message._reasoning||message.reasoning_content||message.thinking||'');
|
||||
}
|
||||
function _anchorSceneRowsByMessageIndex(messages, turnStart, lastAsstIndex){
|
||||
const byIdx=new Map();
|
||||
const add=(idx,row)=>{
|
||||
if(!byIdx.has(idx)) byIdx.set(idx,[]);
|
||||
byIdx.get(idx).push(row);
|
||||
};
|
||||
let order=0;
|
||||
for(let idx=turnStart+1;idx<lastAsstIndex;idx+=1){
|
||||
const message=messages[idx];
|
||||
if(!message||message.role!=='assistant') continue;
|
||||
const text=_anchorSceneMessageText(message);
|
||||
if(_anchorSceneCleanText(text)) add(idx,_anchorSceneProseRow(text,order++,idx));
|
||||
const reasoning=_anchorSceneMessageReasoningText(message);
|
||||
if(_anchorSceneCleanText(reasoning)&&_anchorSceneTextKey(reasoning)!==_anchorSceneTextKey(text)){
|
||||
add(idx,_anchorSceneThinkingRow(reasoning,order++,idx));
|
||||
}
|
||||
const messageTools=[];
|
||||
if(Array.isArray(message.tool_calls)) messageTools.push(...message.tool_calls);
|
||||
if(Array.isArray(message._partial_tool_calls)) messageTools.push(...message._partial_tool_calls);
|
||||
for(const tool of messageTools){
|
||||
add(idx,_anchorSceneToolRowFromCall(tool,order++,idx));
|
||||
}
|
||||
}
|
||||
const toolCalls=Array.isArray(S.toolCalls)?S.toolCalls:[];
|
||||
for(const tool of toolCalls){
|
||||
if(!tool||typeof tool!=='object') continue;
|
||||
const idx=Number(tool.assistant_msg_idx);
|
||||
if(!Number.isFinite(idx)||idx<=turnStart||idx>=lastAsstIndex) continue;
|
||||
add(idx,_anchorSceneToolRowFromCall(tool,order++,idx));
|
||||
}
|
||||
return byIdx;
|
||||
}
|
||||
function _anchorSceneExistingRowKey(row){
|
||||
if(!row||typeof row!=='object') return '';
|
||||
if(row.role==='tool'){
|
||||
const tool=row.tool&&typeof row.tool==='object'?row.tool:{};
|
||||
return `tool:${row.tool_call_id||tool.id||tool.tid||tool.tool_call_id||tool.tool_use_id||tool.call_id||row.row_id||''}`;
|
||||
}
|
||||
if(row.role==='prose'||row.role==='thinking') return `${row.role}:${_anchorSceneTextKey(row.text)}`;
|
||||
return `${row.role||row.kind}:${row.source_event_type||''}:${row.status||''}:${row.row_id||''}`;
|
||||
}
|
||||
function _anchorSceneRowHasLiveIdentity(row){
|
||||
if(!row||typeof row!=='object') return false;
|
||||
const identity=row.identity&&typeof row.identity==='object'?row.identity:{};
|
||||
const values=[row.row_id,row.local_id,row.event_id,identity.local_id,identity.event_id];
|
||||
return values.some(value=>String(value||'').startsWith('live-'));
|
||||
}
|
||||
function _anchorSceneMessageRowsHaveThinking(messageRows){
|
||||
if(!(messageRows instanceof Map)) return false;
|
||||
for(const bucket of messageRows.values()){
|
||||
if(Array.isArray(bucket)&&bucket.some(row=>row&&row.role==='thinking')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function _anchorSceneSettleLiveRunningRow(row, hasSettledThinking){
|
||||
if(!row||typeof row!=='object') return row;
|
||||
if(row.role!=='thinking'&&row.role!=='prose'&&row.role!=='tool') return row;
|
||||
if(String(row.status||'').toLowerCase()!=='running') return row;
|
||||
if(!_anchorSceneRowHasLiveIdentity(row)) return row;
|
||||
if(row.role==='thinking'&&hasSettledThinking) return null;
|
||||
return {...row,status:'completed'};
|
||||
}
|
||||
function _anchorSceneRowLooksLikeFinalAnswer(rowTextKey, finalKey){
|
||||
if(!rowTextKey||!finalKey) return false;
|
||||
if(rowTextKey===finalKey) return true;
|
||||
return rowTextKey.length>=80&&(finalKey.startsWith(rowTextKey)||rowTextKey.startsWith(finalKey));
|
||||
}
|
||||
function _anchorSceneRowTextOverlapsExisting(rowTextKey, seenTextKeys){
|
||||
if(!rowTextKey||!Array.isArray(seenTextKeys)) return false;
|
||||
for(const existing of seenTextKeys){
|
||||
if(!existing) continue;
|
||||
if(rowTextKey===existing) return true;
|
||||
const minLen=Math.min(rowTextKey.length,existing.length);
|
||||
if(minLen>=80&&(rowTextKey.includes(existing)||existing.includes(rowTextKey))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function _anchorSceneTurnDurationForSettlement(lastAsst, base){
|
||||
if(lastAsst&&lastAsst._turnDuration!==undefined&&lastAsst._turnDuration!==null) return lastAsst._turnDuration;
|
||||
if(base&&base.turn_duration!==undefined&&base.turn_duration!==null) return base.turn_duration;
|
||||
const session=(typeof S!=='undefined'&&S&&S.session)?S.session:null;
|
||||
const candidates=[
|
||||
session&&session.pending_started_at,
|
||||
session&&session.active_started_at,
|
||||
session&&session.run_started_at,
|
||||
session&&session.started_at,
|
||||
];
|
||||
for(const raw of candidates){
|
||||
const started=Number(raw);
|
||||
if(Number.isFinite(started)&&started>0){
|
||||
const elapsed=(Date.now()/1000)-started;
|
||||
if(Number.isFinite(elapsed)&&elapsed>=0) return elapsed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
function _completeSettledAnchorSceneForTurn(messages, lastAsstIndex, projectedScene){
|
||||
if(!Array.isArray(messages)||lastAsstIndex<0) return projectedScene;
|
||||
const lastAsst=messages[lastAsstIndex];
|
||||
if(!lastAsst||lastAsst.role!=='assistant') return projectedScene;
|
||||
let turnStart=-1;
|
||||
for(let idx=lastAsstIndex-1;idx>=0;idx-=1){
|
||||
if(messages[idx]&&messages[idx].role==='user'){
|
||||
turnStart=idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const finalAnswer=_anchorSceneMessageText(lastAsst);
|
||||
const finalKey=_anchorSceneTextKey(finalAnswer);
|
||||
const base=(projectedScene&&typeof projectedScene==='object')?projectedScene:{};
|
||||
const messageRows=_anchorSceneRowsByMessageIndex(messages,turnStart,lastAsstIndex);
|
||||
const hasSettledThinking=_anchorSceneMessageRowsHaveThinking(messageRows);
|
||||
const rows=[];
|
||||
const seen=new Set();
|
||||
const seenTextKeys=[];
|
||||
const pushRow=(row)=>{
|
||||
if(!row||typeof row!=='object') return;
|
||||
row=_anchorSceneSettleLiveRunningRow(row,hasSettledThinking);
|
||||
if(!row||typeof row!=='object') return;
|
||||
const textKey=_anchorSceneTextKey(row.text);
|
||||
const isTextual=row.role==='prose'||row.role==='thinking';
|
||||
if(isTextual&&_anchorSceneRowLooksLikeFinalAnswer(textKey,finalKey)) return;
|
||||
if(isTextual&&_anchorSceneRowTextOverlapsExisting(textKey,seenTextKeys)) return;
|
||||
const key=_anchorSceneExistingRowKey(row);
|
||||
if(key&&seen.has(key)) return;
|
||||
if(key) seen.add(key);
|
||||
if(isTextual&&textKey) seenTextKeys.push(textKey);
|
||||
rows.push({...row,order_index:rows.length,seq:rows.length});
|
||||
};
|
||||
const projectedRows=Array.isArray(base.activity_rows)?base.activity_rows:[];
|
||||
for(const row of projectedRows){
|
||||
if(row&&row.role==='terminal') continue;
|
||||
pushRow(row);
|
||||
}
|
||||
for(let idx=turnStart+1;idx<lastAsstIndex;idx+=1){
|
||||
const bucket=messageRows.get(idx)||[];
|
||||
for(const row of bucket) pushRow(row);
|
||||
}
|
||||
for(const row of projectedRows){
|
||||
if(row&&row.role==='terminal') pushRow(row);
|
||||
}
|
||||
const scene={
|
||||
...base,
|
||||
version:'activity_scene_v1',
|
||||
mode:'compact_worklog',
|
||||
identity:{
|
||||
...((base.identity&&typeof base.identity==='object')?base.identity:{}),
|
||||
source_message_refs:messages.slice(turnStart+1,lastAsstIndex+1)
|
||||
.filter(m=>m&&m.role==='assistant')
|
||||
.map(m=>_anchorSceneMessageRef(m)),
|
||||
},
|
||||
lifecycle:(base.lifecycle&&typeof base.lifecycle==='object')?{...base.lifecycle}:{},
|
||||
final_answer:_anchorSceneCleanText(finalAnswer)?finalAnswer:(typeof base.final_answer==='string'?base.final_answer:''),
|
||||
final_message_ref:_anchorSceneMessageRef(lastAsst),
|
||||
turn_duration:_anchorSceneTurnDurationForSettlement(lastAsst,base),
|
||||
terminal_state:base.terminal_state||((base.lifecycle&&base.lifecycle.terminal_state)||null),
|
||||
activity_rows:rows,
|
||||
};
|
||||
return scene;
|
||||
}
|
||||
let _persistAnchorSceneWarned=false;
|
||||
function _anchorSceneMessageOffsetForPersist(){
|
||||
const raw=(typeof _oldestIdx!=='undefined')?_oldestIdx:0;
|
||||
const offset=Number(raw);
|
||||
return Number.isFinite(offset)&&offset>0?Math.floor(offset):0;
|
||||
}
|
||||
function _anchorSceneAbsoluteMessageIndexForPersist(messageIndex, offset){
|
||||
const idx=Number(messageIndex);
|
||||
const off=Number(offset);
|
||||
if(!Number.isFinite(idx)||idx<0) return messageIndex;
|
||||
return idx+(Number.isFinite(off)&&off>0?Math.floor(off):0);
|
||||
}
|
||||
function _persistSettledAnchorScene(message, scene, messageIndex){
|
||||
if(!activeSid||!message||!scene||typeof api!=='function') return;
|
||||
try{
|
||||
const messageOffset=_anchorSceneMessageOffsetForPersist();
|
||||
api('/api/session/anchor-scene',{
|
||||
method:'POST',
|
||||
timeoutMs:8000,
|
||||
timeoutToast:false,
|
||||
body:JSON.stringify({
|
||||
session_id:activeSid,
|
||||
stream_id:streamId,
|
||||
message_index:_anchorSceneAbsoluteMessageIndexForPersist(messageIndex,messageOffset),
|
||||
message_window_index:messageIndex,
|
||||
message_offset:messageOffset,
|
||||
message_ref:_anchorSceneMessageRef(message),
|
||||
scene,
|
||||
}),
|
||||
}).catch(err=>{
|
||||
if(!_persistAnchorSceneWarned&&typeof console!=='undefined'&&console.warn){
|
||||
_persistAnchorSceneWarned=true;
|
||||
console.warn('anchor activity scene persistence failed',err);
|
||||
}
|
||||
});
|
||||
}catch(err){
|
||||
if(!_persistAnchorSceneWarned&&typeof console!=='undefined'&&console.warn){
|
||||
_persistAnchorSceneWarned=true;
|
||||
console.warn('anchor activity scene persistence failed',err);
|
||||
}
|
||||
}
|
||||
}
|
||||
function _attachProjectedAnchorSceneToLastAssistant(messages){
|
||||
if(!_anchorRegistry||!Array.isArray(messages)) return false;
|
||||
let lastAsst=null;
|
||||
let lastAsstIndex=-1;
|
||||
for(let i=messages.length-1;i>=0;i--){
|
||||
const candidate=messages[i];
|
||||
if(candidate&&candidate.role==='assistant'){
|
||||
lastAsst=candidate;
|
||||
lastAsstIndex=i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(!lastAsst) return false;
|
||||
const projectedScene=_projectLiveAnchorActivityScene();
|
||||
const scene=_completeSettledAnchorSceneForTurn(messages,lastAsstIndex,projectedScene);
|
||||
if(scene&&Array.isArray(scene.activity_rows)&&scene.activity_rows.length){
|
||||
lastAsst._anchor_stream_id=streamId;
|
||||
lastAsst._anchor_activity_scene=scene;
|
||||
_persistSettledAnchorScene(lastAsst, scene, lastAsstIndex);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function _upsertAnchorProcessProse(displayText, options={}){
|
||||
const text=String(displayText||'').trim();
|
||||
if(!text||!_anchorRegistry) return null;
|
||||
const segmentSeq=Number(options.segmentSeq||_anchorSegmentSeq());
|
||||
const localId=`live-prose:${streamId}:${segmentSeq}`;
|
||||
const existing=_findAnchorActivityEventByLocalId(localId,'token');
|
||||
if(existing){
|
||||
const replaced=_replaceAnchorActivityEventByLocalId(localId,'token',{
|
||||
status:options.sealed?'completed':'running',
|
||||
payload:{text,activitySegmentSeq:segmentSeq,activityBurstId:_currentActivityBurstId},
|
||||
});
|
||||
_renderAnchorLiveScene();
|
||||
return replaced;
|
||||
}
|
||||
_applyToAnchor('token',{
|
||||
text,
|
||||
local_id:localId,
|
||||
seq:_nextAnchorLocalSeq(),
|
||||
status:options.sealed?'completed':'running',
|
||||
activitySegmentSeq:segmentSeq,
|
||||
activityBurstId:_currentActivityBurstId,
|
||||
},null);
|
||||
return _findAnchorActivityEventByLocalId(localId,'token');
|
||||
}
|
||||
function _anchorHasReasoningEvents(){
|
||||
const events=_anchorActivityEvents();
|
||||
return !!(events&&events.some(event=>event&&event.source_event_type==='reasoning'));
|
||||
}
|
||||
function _upsertAnchorReasoning(text, options={}){
|
||||
const clean=String(text||'').trim();
|
||||
if(!clean||!_anchorRegistry||window._showThinking===false) return null;
|
||||
const placement=_liveThinkingPlacement();
|
||||
const segmentSeq=Number(options.segmentSeq||placement.segmentSeq||_anchorSegmentSeq());
|
||||
const localId=String(options.localId||`live-reasoning:${streamId}:${segmentSeq}`);
|
||||
const existing=_findAnchorActivityEventByLocalId(localId,'reasoning');
|
||||
if(existing){
|
||||
const replaced=_replaceAnchorActivityEventByLocalId(localId,'reasoning',{
|
||||
status:options.sealed?'completed':'running',
|
||||
payload:{text:clean,activitySegmentSeq:segmentSeq,activityBurstId:_currentActivityBurstId},
|
||||
});
|
||||
_renderAnchorLiveScene();
|
||||
return replaced;
|
||||
}
|
||||
_applyToAnchor('reasoning',{
|
||||
text:clean,
|
||||
local_id:localId,
|
||||
seq:_nextAnchorLocalSeq(),
|
||||
status:options.sealed?'completed':'running',
|
||||
activitySegmentSeq:segmentSeq,
|
||||
activityBurstId:_currentActivityBurstId,
|
||||
},null);
|
||||
return _findAnchorActivityEventByLocalId(localId,'reasoning');
|
||||
}
|
||||
function _flushReasoningToAnchor(){
|
||||
if(_anchorReasoningFlushed||!reasoningText) return;
|
||||
_anchorReasoningFlushed=true;
|
||||
_applyToAnchor('reasoning',{
|
||||
text:reasoningText,
|
||||
local_id:'live-reasoning',
|
||||
seq:_runJournalReplayAfterSeq(),
|
||||
},null);
|
||||
if(_anchorHasReasoningEvents()) return;
|
||||
_upsertAnchorReasoning(reasoningText,{sealed:true,localId:`live-reasoning:${streamId}:final`});
|
||||
}
|
||||
function _sourceEventTypeForSnapshotAnchorRow(row){
|
||||
const source=String(row&&row.source_event_type||'').trim();
|
||||
if(source&&source!=='runtime_journal_snapshot') return source;
|
||||
const role=String(row&&row.role||'').trim();
|
||||
const kind=String(row&&row.kind||'').trim();
|
||||
if(role==='prose'||kind==='process_prose') return 'token';
|
||||
if(role==='thinking'||kind==='reasoning') return 'reasoning';
|
||||
if(role==='tool') return row&&row.status==='running'?'tool':'tool_complete';
|
||||
if(role==='terminal'||kind==='terminal_status') return row&&row.status==='running'?'compressing':'done';
|
||||
if(role==='lifecycle'||kind==='lifecycle_status') return 'compressing';
|
||||
return '';
|
||||
}
|
||||
function _hydrateAnchorRegistryFromActivityScene(scene){
|
||||
if(!_anchorRegistry||!_anchorApi||typeof _anchorApi.applyAssistantTurnAnchorSourceEvent!=='function') return false;
|
||||
if(!scene||scene.version!=='activity_scene_v1'||!Array.isArray(scene.activity_rows)||!scene.activity_rows.length) return false;
|
||||
const sceneKey=[
|
||||
scene.identity&&scene.identity.stream_id||streamId||'',
|
||||
scene.activity_rows.length,
|
||||
scene.activity_rows.map(row=>row&&row.row_id||row&&row.local_id||'').join('|'),
|
||||
].join(':');
|
||||
if(_anchorRegistry._hydrated_activity_scene_key===sceneKey) return true;
|
||||
const rows=scene.activity_rows;
|
||||
for(let i=0;i<rows.length;i+=1){
|
||||
const row=rows[i];
|
||||
if(!row||typeof row!=='object') continue;
|
||||
const sourceType=_sourceEventTypeForSnapshotAnchorRow(row);
|
||||
if(!sourceType) continue;
|
||||
const payload={
|
||||
...((row.payload&&typeof row.payload==='object')?row.payload:{}),
|
||||
};
|
||||
if(row.text&&!payload.text) payload.text=row.text;
|
||||
if(row.tool&&typeof row.tool==='object'){
|
||||
payload.name=payload.name||row.tool.name;
|
||||
payload.args=payload.args||row.tool.args;
|
||||
payload.preview=payload.preview||row.tool.preview;
|
||||
payload.snippet=payload.snippet||row.tool.snippet;
|
||||
payload.tid=payload.tid||row.tool.tid||row.tool.id;
|
||||
payload.id=payload.id||row.tool.id||row.tool.tid;
|
||||
payload.is_error=payload.is_error||row.tool.is_error;
|
||||
payload.duration=payload.duration||row.tool.duration;
|
||||
}
|
||||
if(row.group&&typeof row.group==='object'){
|
||||
payload.activitySegmentSeq=payload.activitySegmentSeq||row.group.activity_segment_seq;
|
||||
payload.activityBurstId=payload.activityBurstId||row.group.activity_burst_id;
|
||||
}
|
||||
const sourceEvent={
|
||||
...payload,
|
||||
source_event_type:sourceType,
|
||||
local_id:row.local_id||row.row_id||`snapshot:${streamId}:${i}`,
|
||||
event_id:row.event_id||null,
|
||||
seq:row.seq??undefined,
|
||||
status:row.status||undefined,
|
||||
stream_id:row.stream_id||streamId,
|
||||
run_id:row.run_id||streamId,
|
||||
};
|
||||
try{
|
||||
_anchorApi.applyAssistantTurnAnchorSourceEvent(_anchorRegistry,sourceEvent,{session_id:activeSid,stream_id:streamId,run_id:streamId});
|
||||
}catch(err){
|
||||
if(!_anchorShadowWarned&&typeof console!=='undefined'&&console.warn){
|
||||
_anchorShadowWarned=true;
|
||||
console.warn('assistant turn anchor snapshot hydration failed',err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_anchorRegistry._hydrated_activity_scene_key=sceneKey;
|
||||
return true;
|
||||
}
|
||||
_hydrateAnchorRegistryFromActivityScene(INFLIGHT[activeSid]&&INFLIGHT[activeSid].anchorActivityScene);
|
||||
|
||||
function _mergeSettledToolCallsWithLiveMetadata(rawCalls){
|
||||
const liveCalls=Array.isArray(S.toolCalls)?S.toolCalls:[];
|
||||
@@ -2636,6 +3275,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
} else {
|
||||
assistantBody.innerHTML=esc(displayText);
|
||||
}
|
||||
_upsertAnchorProcessProse(displayText,{sealed:force});
|
||||
if(typeof _syncLiveWorklogReasonsForAnchor==='function') _syncLiveWorklogReasonsForAnchor(assistantRow, displayText);
|
||||
}
|
||||
function _resetAssistantSegment(){
|
||||
@@ -2960,9 +3600,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(_smdReconnect){assistantBody.innerHTML='';_smdReconnect=false;}
|
||||
_smdNewParser(assistantBody);
|
||||
}
|
||||
if(_smdParser){
|
||||
_smdWrite(displayText);
|
||||
} else {
|
||||
if(_smdParser){
|
||||
_smdWrite(displayText);
|
||||
} else {
|
||||
// Fallback: smd not loaded yet, reconnect session, or smd unavailable — use renderMd
|
||||
// for every live segment. Without this, the first segment inserts raw
|
||||
// parsed.displayText and users see unformatted markdown until done.
|
||||
@@ -2972,6 +3612,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
assistantBody.innerHTML = renderMd ? renderMd(fallbackText) : esc(fallbackText);
|
||||
}
|
||||
}
|
||||
_upsertAnchorProcessProse(displayText);
|
||||
if(typeof _syncLiveWorklogReasonsForAnchor==='function') _syncLiveWorklogReasonsForAnchor(assistantRow, displayText);
|
||||
}
|
||||
scrollIfPinned();
|
||||
@@ -2990,6 +3631,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
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;
|
||||
_ensureAnchorCompressionCompletedOnLiveProgress(sid);
|
||||
if(typeof appendLiveCompressionCard==='function'){
|
||||
appendLiveCompressionCard({
|
||||
sessionId:sid,
|
||||
@@ -3044,7 +3686,6 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(!visible){
|
||||
return;
|
||||
}
|
||||
_applyToAnchor('interim_assistant',d,e);
|
||||
liveReasoningText='';
|
||||
if(alreadyStreamed){
|
||||
if(!S.session||S.session.session_id!==activeSid){
|
||||
@@ -3064,6 +3705,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
_resetAssistantSegment();
|
||||
return;
|
||||
}
|
||||
_applyToAnchor('interim_assistant',d,e);
|
||||
assistantText += assistantText ? `\n\n${visible}` : visible;
|
||||
visibleInterimSnippets.push(visible);
|
||||
syncInflightAssistantMessage();
|
||||
@@ -3121,6 +3763,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(d.text&&S.session&&S.session.session_id===activeSid) _completeAutomaticCompressionOnLiveProgress(activeSid);
|
||||
syncInflightAssistantMessage();
|
||||
if(text&&S.session&&S.session.session_id===activeSid){
|
||||
_upsertAnchorReasoning(_liveThinkingText());
|
||||
_updateLiveThinkingCard(_liveThinkingText());
|
||||
}
|
||||
});
|
||||
@@ -3133,6 +3776,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
_completeAutomaticCompressionOnLiveProgress(activeSid);
|
||||
const tc=upsertLiveToolCall(d,'start');
|
||||
if(!tc) return;
|
||||
const pendingDisplayTextBeforeTool=segmentStart===0
|
||||
? (_parseStreamState().displayText||'')
|
||||
: _stripXmlToolCalls(assistantText.slice(segmentStart));
|
||||
if(String(pendingDisplayTextBeforeTool||'').trim()) _upsertAnchorProcessProse(pendingDisplayTextBeforeTool,{sealed:true});
|
||||
_applyToAnchor('tool',{...d,...tc},e);
|
||||
|
||||
if(S.session&&S.session.session_id===activeSid&&typeof scheduleRenderSessionArtifacts==='function') scheduleRenderSessionArtifacts();
|
||||
@@ -3166,6 +3813,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const tc=upsertLiveToolCall(d,'complete');
|
||||
if(!tc) return;
|
||||
tc.is_error=!!d.is_error;
|
||||
const pendingDisplayTextBeforeComplete=segmentStart===0
|
||||
? (_parseStreamState().displayText||'')
|
||||
: _stripXmlToolCalls(assistantText.slice(segmentStart));
|
||||
if(String(pendingDisplayTextBeforeComplete||'').trim()) _upsertAnchorProcessProse(pendingDisplayTextBeforeComplete,{sealed:true});
|
||||
_applyToAnchor('tool_complete',{...d,...tc,is_error:!!d.is_error},e);
|
||||
if(typeof noteWorkspaceMutationsFromToolCall==='function') noteWorkspaceMutationsFromToolCall(tc);
|
||||
if(S.session&&S.session.session_id===activeSid&&typeof scheduleRenderSessionArtifacts==='function') scheduleRenderSessionArtifacts();
|
||||
@@ -3457,7 +4108,6 @@ 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');
|
||||
if(_anchorRegistry&&lastAsst) lastAsst._anchor_stream_id=streamId;
|
||||
// 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;
|
||||
@@ -3522,6 +4172,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
}
|
||||
}
|
||||
}
|
||||
_attachProjectedAnchorSceneToLastAssistant(S.messages);
|
||||
const hasMessageToolMetadata=S.messages.some(m=>{
|
||||
if(!m||m.role!=='assistant') return false;
|
||||
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
|
||||
@@ -3567,7 +4218,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
_messageRenderWindowSize=Math.max(typeof _currentMessageRenderWindowSize==='function'?_currentMessageRenderWindowSize():50, _messageRenderableMessageCount());
|
||||
}
|
||||
syncTopbar();renderMessages({preserveScroll:true});
|
||||
if(shouldFollowOnDone&&typeof scrollToBottom==='function'&&typeof _isMessagePaneNearBottom==='function'&&_isMessagePaneNearBottom(250)) scrollToBottom();
|
||||
if(shouldFollowOnDone&&typeof scrollToBottom==='function') scrollToBottom();
|
||||
if(typeof noteWorkspaceMutationsFromToolCalls==='function') noteWorkspaceMutationsFromToolCalls(S.toolCalls);
|
||||
loadDir('.', { preservePreview: true });
|
||||
// TTS auto-read: speak the last assistant response if enabled (#499)
|
||||
@@ -3811,16 +4462,20 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(typeof showToast==='function') showToast('Stream recovery signal received. Restoring transcript...',3500,'error');
|
||||
} else if(d.session&&typeof d.session==='object'){
|
||||
S.session=d.session;
|
||||
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], d.session.messages||[]);
|
||||
const _nextMsgs3018=(d.session.messages||[]).filter(m=>m&&m.role);
|
||||
_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);
|
||||
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
|
||||
if(S.session&&S.session.session_id){
|
||||
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
|
||||
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
|
||||
}
|
||||
} else {
|
||||
S.messages.push({role:'assistant',content:`**${label}:** ${d.message}${hint}`,provider_details:details,provider_details_label:detailsLabel});
|
||||
_attachProjectedAnchorSceneToLastAssistant(S.messages);
|
||||
}
|
||||
}catch(_){
|
||||
S.messages.push({role:'assistant',content:'**Error:** An error occurred. Check server logs.'});
|
||||
_attachProjectedAnchorSceneToLastAssistant(S.messages);
|
||||
}
|
||||
if(isRecoveryControlMessage){
|
||||
(async()=>{
|
||||
@@ -3959,6 +4614,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(data&&data.session&&S.session&&S.session.session_id===activeSid){
|
||||
S.session=data.session;
|
||||
const _nextMsgs3018=(data.session.messages||[]).filter(m=>m&&m.role);
|
||||
_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);
|
||||
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
|
||||
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
|
||||
clearLiveToolCards();if(!assistantText)removeThinking();
|
||||
@@ -3970,7 +4626,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
clearLiveToolCards();if(!assistantText)removeThinking();
|
||||
const cancelAgentName=(assistantDisplayName()+'').trim()||'Hermes';
|
||||
S.messages.push({role:'assistant',content:`**Task cancelled:** Task cancelled.\n\n*The run was cancelled by the user before ${cancelAgentName} finished. No provider failure occurred.*`,provider_details:'Task cancelled.',provider_details_label:'Cancellation details',_error:true});renderMessages({preserveScroll:true});
|
||||
S.messages.push({role:'assistant',content:`**Task cancelled:** Task cancelled.\n\n*The run was cancelled by the user before ${cancelAgentName} finished. No provider failure occurred.*`,provider_details:'Task cancelled.',provider_details_label:'Cancellation details',_error:true});
|
||||
_attachProjectedAnchorSceneToLastAssistant(S.messages);
|
||||
renderMessages({preserveScroll:true});
|
||||
_markSessionViewed(activeSid, S.messages.length);
|
||||
}
|
||||
}
|
||||
@@ -4002,7 +4660,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
}
|
||||
return `${m.role}|${ts}|${body.slice(0,160)}`;
|
||||
}
|
||||
const _EPHEMERAL_TURN_FIELDS=['_turnUsage','_turnDuration','_turnTps','_gatewayRouting','_statusCard','_anchor_stream_id'];
|
||||
const _EPHEMERAL_TURN_FIELDS=['_turnUsage','_turnDuration','_turnTps','_gatewayRouting','_statusCard','_anchor_stream_id','_anchor_activity_scene'];
|
||||
function _carryForwardEphemeralTurnFields(prevMessages, nextMessages){
|
||||
if(!Array.isArray(prevMessages)||!Array.isArray(nextMessages)) return nextMessages;
|
||||
if(!prevMessages.length||!nextMessages.length) return nextMessages;
|
||||
@@ -4063,6 +4721,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
clearLiveToolCards();if(!assistantText)removeThinking();
|
||||
S.session=session;
|
||||
const _nextMsgs3018=(session.messages||[]).filter(m=>m&&m.role);
|
||||
_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);
|
||||
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
|
||||
S.messages=_filterRecoveryControlMessages(S.messages || []);
|
||||
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
|
||||
@@ -4124,8 +4783,16 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
_clearClarifyForOwner('terminal');
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
S.activeStreamId=null;
|
||||
_flushReasoningToAnchor();
|
||||
_applyToAnchor('error',{
|
||||
status:'connection_lost',
|
||||
message:'The browser lost the live SSE connection before the response finished.',
|
||||
session_id:activeSid,
|
||||
},null);
|
||||
clearLiveToolCards();if(!assistantText)removeThinking();
|
||||
S.messages.push({role:'assistant',content:'**Connection interrupted:** The browser lost the live SSE connection before the response finished. If the worker completed, reopening this session should restore the settled transcript.'});renderMessages({preserveScroll:true});
|
||||
S.messages.push({role:'assistant',content:'**Connection interrupted:** The browser lost the live SSE connection before the response finished. If the worker completed, reopening this session should restore the settled transcript.'});
|
||||
_attachProjectedAnchorSceneToLastAssistant(S.messages);
|
||||
renderMessages({preserveScroll:true});
|
||||
_markSessionViewed(activeSid, S.messages.length);
|
||||
}else{
|
||||
if(typeof trackBackgroundError==='function'){
|
||||
@@ -4641,11 +5308,16 @@ function startSessionStream(sid) {
|
||||
// attachLiveStream reconstructs the in-progress stream.
|
||||
S.busy = true;
|
||||
S.activeStreamId = streamId;
|
||||
if (S.session && S.session.session_id === sid) S.session.active_stream_id = streamId;
|
||||
if (S.session && S.session.session_id === sid) {
|
||||
S.session.active_stream_id = streamId;
|
||||
if (typeof d.pending_started_at === 'number') S.session.pending_started_at = d.pending_started_at;
|
||||
else if (!S.session.pending_started_at) S.session.pending_started_at = Date.now()/1000;
|
||||
}
|
||||
if (typeof ensureLiveWorklogShell === 'function') ensureLiveWorklogShell();
|
||||
else if (typeof appendThinking === 'function') appendThinking();
|
||||
if (typeof updateSendBtn === 'function') updateSendBtn();
|
||||
if (typeof setComposerStatus === 'function') setComposerStatus('');
|
||||
if (typeof syncTopbar === 'function') syncTopbar();
|
||||
if (typeof appendThinking === 'function') appendThinking();
|
||||
if (typeof startApprovalPolling === 'function') startApprovalPolling(sid);
|
||||
if (typeof startClarifyPolling === 'function') startClarifyPolling(sid);
|
||||
if (typeof attachLiveStream === 'function') {
|
||||
|
||||
+41
-6
@@ -526,11 +526,15 @@ function _serverLiveSnapshotInflight(snapshot, uploaded){
|
||||
_journal_snapshot:true,
|
||||
});
|
||||
}
|
||||
if(!messages.length&&!toolCalls.length&&!lastAssistantText&&!lastReasoningText) return null;
|
||||
const replayAfterSeq=Number(snapshot.last_seq||0);
|
||||
const activityBurstAnchors=Array.isArray(snapshot.activity_burst_anchors)
|
||||
? snapshot.activity_burst_anchors
|
||||
: (Array.isArray(snapshot.activityBurstAnchors)?snapshot.activityBurstAnchors:[]);
|
||||
const anchorActivityScene=(snapshot.anchor_activity_scene&&snapshot.anchor_activity_scene.version==='activity_scene_v1')
|
||||
? snapshot.anchor_activity_scene
|
||||
: ((snapshot.anchorActivityScene&&snapshot.anchorActivityScene.version==='activity_scene_v1')?snapshot.anchorActivityScene:null);
|
||||
const hasAnchorActivityScene=!!(anchorActivityScene&&Array.isArray(anchorActivityScene.activity_rows)&&anchorActivityScene.activity_rows.length);
|
||||
if(!messages.length&&!toolCalls.length&&!lastAssistantText&&!lastReasoningText&&!hasAnchorActivityScene) return null;
|
||||
return {
|
||||
messages,
|
||||
uploaded:Array.isArray(uploaded)?[...uploaded]:[],
|
||||
@@ -542,12 +546,30 @@ function _serverLiveSnapshotInflight(snapshot, uploaded){
|
||||
lastAssistantText,
|
||||
lastReasoningText,
|
||||
lastRunJournalSeq:Number.isFinite(replayAfterSeq)?Math.max(0,replayAfterSeq):0,
|
||||
anchorActivityScene,
|
||||
currentActivityBurstId:Number(snapshot.current_activity_burst_id||snapshot.currentActivityBurstId||0)||0,
|
||||
currentLiveSegmentSeq:Number(snapshot.current_live_segment_seq||snapshot.currentLiveSegmentSeq||0)||0,
|
||||
activityBurstAnchors,
|
||||
};
|
||||
}
|
||||
|
||||
function _runtimeJournalAnchorActivitySceneForSession(sid){
|
||||
const inflight=INFLIGHT&&sid?INFLIGHT[sid]:null;
|
||||
if(inflight&&inflight.anchorActivityScene&&inflight.anchorActivityScene.version==='activity_scene_v1'){
|
||||
return inflight.anchorActivityScene;
|
||||
}
|
||||
const snapshot=S.session&&S.session.runtime_journal_snapshot;
|
||||
const scene=snapshot&&(snapshot.anchor_activity_scene||snapshot.anchorActivityScene);
|
||||
return scene&&scene.version==='activity_scene_v1'?scene:null;
|
||||
}
|
||||
|
||||
function _renderRuntimeJournalAnchorActivityScene(activeStreamId, sid){
|
||||
if(!activeStreamId||typeof window==='undefined'||typeof window._renderLiveAnchorActivitySceneSnapshotForStream!=='function') return false;
|
||||
const scene=_runtimeJournalAnchorActivitySceneForSession(sid);
|
||||
if(!scene) return false;
|
||||
return !!window._renderLiveAnchorActivitySceneSnapshotForStream(activeStreamId, scene, sid, {mode:'compact_worklog'});
|
||||
}
|
||||
|
||||
function _rememberRenderedSessionSnapshot(s) {
|
||||
if (!s || !s.session_id) return;
|
||||
const previous = _sessionListSnapshotById.get(s.session_id);
|
||||
@@ -1161,6 +1183,7 @@ async function loadSession(sid){
|
||||
lastReasoningText:String(stored.lastReasoningText||''),
|
||||
lastRunJournalSeq:Number(stored.lastRunJournalSeq||0)||0,
|
||||
journalReplayFromStart:!!stored.journalReplayFromStart,
|
||||
anchorActivityScene:(stored.anchorActivityScene&&stored.anchorActivityScene.version==='activity_scene_v1')?stored.anchorActivityScene:null,
|
||||
currentActivityBurstId:Number(stored.currentActivityBurstId||0)||0,
|
||||
currentLiveSegmentSeq:Number(stored.currentLiveSegmentSeq||0)||0,
|
||||
activityBurstAnchors:Array.isArray(stored.activityBurstAnchors)?stored.activityBurstAnchors:[],
|
||||
@@ -1192,6 +1215,9 @@ async function loadSession(sid){
|
||||
_ensureInflightLiveAssistantMessage(INFLIGHT[sid]);
|
||||
const inflightMessages=_projectInflightMessagesForActivityBursts(INFLIGHT[sid]);
|
||||
S.toolCalls=[];
|
||||
// Switching between active sessions should rebuild the live worklog from
|
||||
// this session's INFLIGHT snapshot, not leave prior-session rows in place.
|
||||
if(typeof clearLiveToolCards==='function') clearLiveToolCards();
|
||||
try {
|
||||
await _ensureMessagesLoaded(sid);
|
||||
} catch(e) {
|
||||
@@ -1234,15 +1260,20 @@ async function loadSession(sid){
|
||||
attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true});
|
||||
}
|
||||
syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined);
|
||||
const restoredAnchorScene=activeStreamId&&typeof window!=='undefined'
|
||||
? ((typeof window._renderLiveAnchorActivitySceneForStream==='function'&&window._renderLiveAnchorActivitySceneForStream(activeStreamId, sid, {mode:'compact_worklog'}))||
|
||||
_renderRuntimeJournalAnchorActivityScene(activeStreamId, sid))
|
||||
: false;
|
||||
if(typeof ensureRunActivityForCurrentTurn==='function') ensureRunActivityForCurrentTurn();
|
||||
const hasStructuredLiveState=!!(INFLIGHT[sid]&&(
|
||||
String(INFLIGHT[sid].lastAssistantText||'').trim()||
|
||||
String(INFLIGHT[sid].lastReasoningText||'').trim()||
|
||||
!!(INFLIGHT[sid].anchorActivityScene&&Array.isArray(INFLIGHT[sid].anchorActivityScene.activity_rows)&&INFLIGHT[sid].anchorActivityScene.activity_rows.length)||
|
||||
(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'){
|
||||
let restoredLiveTurn=!!restoredAnchorScene;
|
||||
if(!restoredLiveTurn&&typeof restoreLiveTurnHtmlForSession==='function'){
|
||||
if(!hasStructuredLiveState){
|
||||
restoredLiveTurn=restoreLiveTurnHtmlForSession(sid);
|
||||
}else{
|
||||
@@ -1269,7 +1300,7 @@ async function loadSession(sid){
|
||||
else appendThinking();
|
||||
replayPersistedLiveToolCards();
|
||||
}
|
||||
if(typeof ensureLiveWorklogShell==='function'){
|
||||
if(!restoredAnchorScene&&typeof ensureLiveWorklogShell==='function'){
|
||||
const liveTurn=document.getElementById('liveAssistantTurn');
|
||||
if(!liveTurn||!liveTurn.querySelector('.tool-call-group[data-tool-worklog-group="1"]')) ensureLiveWorklogShell();
|
||||
}
|
||||
@@ -1340,8 +1371,12 @@ async function loadSession(sid){
|
||||
setStatus('');
|
||||
setComposerStatus('');
|
||||
syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined);
|
||||
let restoredLiveTurn=false;
|
||||
if(typeof restoreLiveTurnHtmlForSession==='function'){
|
||||
const restoredAnchorScene=activeStreamId&&typeof window!=='undefined'
|
||||
? ((typeof window._renderLiveAnchorActivitySceneForStream==='function'&&window._renderLiveAnchorActivitySceneForStream(activeStreamId, sid, {mode:'compact_worklog'}))||
|
||||
_renderRuntimeJournalAnchorActivityScene(activeStreamId, sid))
|
||||
: false;
|
||||
let restoredLiveTurn=!!restoredAnchorScene;
|
||||
if(!restoredLiveTurn&&typeof restoreLiveTurnHtmlForSession==='function'){
|
||||
restoredLiveTurn=restoreLiveTurnHtmlForSession(sid);
|
||||
}
|
||||
if(!restoredLiveTurn){
|
||||
|
||||
+104
-21
@@ -2915,6 +2915,37 @@ body.resizing .sidebar{transition:none!important;}
|
||||
.tool-worklog-summary .tool-call-group-chevron{
|
||||
margin-left:0;
|
||||
}
|
||||
.tool-worklog-group[data-tool-worklog-group="1"]:not([data-run-activity-group="1"]){
|
||||
margin-top:2px;
|
||||
}
|
||||
.tool-worklog-group[data-tool-worklog-group="1"]:not([data-run-activity-group="1"]) .tool-worklog-summary{
|
||||
display:flex;
|
||||
width:100%;
|
||||
padding:0 0 10px;
|
||||
margin:0 0 12px;
|
||||
border-bottom:1px solid color-mix(in srgb,var(--border-subtle) 62%,transparent);
|
||||
border-radius:0;
|
||||
background:transparent;
|
||||
}
|
||||
.tool-worklog-group[data-tool-worklog-group="1"]:not([data-run-activity-group="1"]) .tool-worklog-label{
|
||||
color:color-mix(in srgb,var(--muted) 74%,var(--bg));
|
||||
font-size:calc(var(--message-body-font-size) * .9);
|
||||
font-weight:400;
|
||||
opacity:.82;
|
||||
}
|
||||
.tool-worklog-group[data-tool-worklog-group="1"]:not([data-run-activity-group="1"]) .tool-worklog-summary:hover{
|
||||
background:transparent;
|
||||
}
|
||||
.tool-worklog-group[data-live-tool-call-group="1"] .tool-worklog-summary[data-live-summary-static="1"]{
|
||||
cursor:default;
|
||||
opacity:1;
|
||||
}
|
||||
.tool-worklog-group[data-tool-worklog-group="1"]:not([data-run-activity-group="1"]) .as-dot{
|
||||
display:none;
|
||||
}
|
||||
.tool-worklog-group[data-live-tool-call-group="1"][data-live-activity-current="1"] .tool-call-group-chevron{
|
||||
display:none;
|
||||
}
|
||||
.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{
|
||||
@@ -2941,11 +2972,11 @@ body.resizing .sidebar{transition:none!important;}
|
||||
}
|
||||
.activity-body .wl-reason,
|
||||
.tool-worklog-list > .wl-reason{
|
||||
padding-left:var(--worklog-rail);
|
||||
padding-left:0;
|
||||
}
|
||||
.activity-body .wl-step-tools,
|
||||
.tool-worklog-list > .wl-step-tools{
|
||||
padding-left:var(--worklog-rail);
|
||||
padding-left:0;
|
||||
}
|
||||
.tool-worklog-list .tool-card-row{
|
||||
margin:0;
|
||||
@@ -2975,9 +3006,31 @@ body.resizing .sidebar{transition:none!important;}
|
||||
white-space:nowrap;
|
||||
gap:7px;
|
||||
}
|
||||
.tool-worklog-list .tool-card-icon,
|
||||
.tool-worklog-tool-group-icon,
|
||||
.tg-icon{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
width:18px;
|
||||
height:18px;
|
||||
flex:0 0 18px;
|
||||
color:var(--muted);
|
||||
opacity:.42;
|
||||
}
|
||||
.tool-worklog-list .tool-card-icon svg,
|
||||
.tool-worklog-tool-group-icon svg,
|
||||
.tg-icon svg{
|
||||
width:14px;
|
||||
height:14px;
|
||||
}
|
||||
.tool-worklog-list .tool-card-name,
|
||||
.tl-verb{
|
||||
flex-shrink:0;
|
||||
flex:0 1 auto;
|
||||
min-width:0;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
color:var(--muted);
|
||||
font-family:inherit;
|
||||
font-size:var(--message-body-font-size);
|
||||
@@ -2988,7 +3041,9 @@ body.resizing .sidebar{transition:none!important;}
|
||||
opacity:.56;
|
||||
}
|
||||
.tool-worklog-list .tool-card-title,
|
||||
.tool-worklog-list .tool-card-preview,
|
||||
.tl-title{
|
||||
display:block;
|
||||
min-width:0;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
@@ -3085,7 +3140,9 @@ body.resizing .sidebar{transition:none!important;}
|
||||
}
|
||||
.tool-worklog-tool-group,
|
||||
.tool-group{
|
||||
max-width:560px;
|
||||
width:100%;
|
||||
max-width:100%;
|
||||
box-sizing:border-box;
|
||||
margin:0;
|
||||
padding:0;
|
||||
background:transparent;
|
||||
@@ -3109,11 +3166,14 @@ body.resizing .sidebar{transition:none!important;}
|
||||
line-height:var(--message-body-line-height);
|
||||
border-radius:7px;
|
||||
opacity:.68;
|
||||
min-width:0;
|
||||
overflow:hidden;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.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;}
|
||||
.tg-sum{flex:1 1 auto;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,
|
||||
@@ -3128,15 +3188,23 @@ body.resizing .sidebar{transition:none!important;}
|
||||
.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);
|
||||
margin:3px 0 0 0;
|
||||
padding-left:0;
|
||||
border-left:0;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:1px;
|
||||
}
|
||||
.tool-worklog-tool-group-body .tool-card,
|
||||
.tool-group-body .tl{max-width:none;}
|
||||
.tool-worklog-tool-group-body .tool-card-row,
|
||||
.tool-group-body .tool-card-row{width:100%;max-width:100%;margin:0;box-sizing:border-box;}
|
||||
.tool-worklog-tool-group-body .tool-card-icon,
|
||||
.tool-group-body .tool-card-icon{display:none;}
|
||||
.tool-worklog-tool-group-body .tool-card-header,
|
||||
.tool-group-body .tool-card-header{margin-left:0;padding-left:0;}
|
||||
.tool-worklog-tool-group-body .tool-card-detail,
|
||||
.tool-group-body .tool-card-detail{width:100%;max-width:100%;box-sizing:border-box;}
|
||||
.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);}
|
||||
@@ -3160,11 +3228,18 @@ body.resizing .sidebar{transition:none!important;}
|
||||
.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-row,.tool-card{width:100%;max-width:100%;box-sizing:border-box;min-width:0;}
|
||||
.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-no-detail .tool-card-header{cursor:default;}
|
||||
.tool-card-no-detail .tool-card-header:hover{background:transparent;color:var(--muted);}
|
||||
.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-name-label{display:inline;}
|
||||
.tool-card-name-generic{display:none;}
|
||||
.tool-card.open .tool-card-name-label{display:none;}
|
||||
.tool-card.open .tool-card-name-generic{display:inline;}
|
||||
.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;}
|
||||
@@ -3175,14 +3250,18 @@ body.resizing .sidebar{transition:none!important;}
|
||||
.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-detail,.tl-detail{display:block;max-height:0;opacity:0;overflow:hidden;width:100%;max-width:100%;box-sizing:border-box;min-width:0;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-detail-lead,.tool-card-args,.tool-card-result{width:100%;max-width:100%;box-sizing:border-box;min-width:0;}
|
||||
.tool-card-detail-lead{padding:9px 12px 0;}
|
||||
.tool-card-detail-lead-label{color:var(--muted);font-family:inherit;font-size:var(--message-body-font-size);line-height:1.35;margin-bottom:5px;opacity:.72;}
|
||||
.tool-card-detail-lead pre{margin:0;color:var(--text);font-family:var(--font-mono);font-size:var(--message-code-font-size);line-height:1.55;white-space:pre-wrap;word-break:break-word;}
|
||||
.tool-card-args{margin-bottom:6px;}
|
||||
.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;}
|
||||
.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;width:100%;max-width:100%;box-sizing:border-box;min-width:0;}
|
||||
|
||||
/* ── Transparent Stream event rows ── */
|
||||
/* Inline rhythm: transparent stream events are transcript metadata inside an
|
||||
@@ -3638,29 +3717,33 @@ body.resizing .sidebar{transition:none!important;}
|
||||
color:#4ec984;
|
||||
}
|
||||
.auto-compression-divider-row{
|
||||
padding:7px 0 8px;
|
||||
padding:3px 0 4px;
|
||||
}
|
||||
.auto-compression-divider{
|
||||
display:grid;
|
||||
grid-template-columns:minmax(32px,1fr) auto minmax(32px,1fr);
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:10px;
|
||||
gap:0;
|
||||
color:var(--muted);
|
||||
opacity:.72;
|
||||
opacity:.58;
|
||||
user-select:none;
|
||||
pointer-events:none;
|
||||
}
|
||||
.auto-compression-inline-row{
|
||||
margin:0;
|
||||
}
|
||||
.auto-compression-inline{
|
||||
justify-content:flex-start;
|
||||
}
|
||||
.auto-compression-divider-line{
|
||||
height:1px;
|
||||
background:color-mix(in srgb,var(--border-subtle) 75%,transparent);
|
||||
display:none;
|
||||
}
|
||||
.auto-compression-divider-label{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
justify-content:flex-start;
|
||||
gap:6px;
|
||||
color:var(--muted);
|
||||
font-size:calc(var(--message-body-font-size) * .92);
|
||||
font-size:calc(var(--message-body-font-size) * .86);
|
||||
font-weight:400!important;
|
||||
line-height:1.2;
|
||||
white-space:nowrap;
|
||||
@@ -3668,10 +3751,10 @@ body.resizing .sidebar{transition:none!important;}
|
||||
.auto-compression-divider-label svg{
|
||||
width:13px;
|
||||
height:13px;
|
||||
opacity:.78;
|
||||
opacity:.64;
|
||||
}
|
||||
.auto-compression-divider-done{
|
||||
opacity:.82;
|
||||
opacity:.6;
|
||||
}
|
||||
.auto-compression-divider-done .auto-compression-divider-label{
|
||||
color:var(--muted);
|
||||
|
||||
+745
-87
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,864 @@
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
def _client_anchor_scene_message_ref(message):
|
||||
content = message.get("content") if isinstance(message, dict) else ""
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
parts.append(str(part.get("text") or part.get("content") or part.get("input_text") or ""))
|
||||
else:
|
||||
parts.append(str(part or ""))
|
||||
content = "\n".join(parts)
|
||||
payload = {
|
||||
"role": str(message.get("role") or ""),
|
||||
"content": " ".join(str(content or "").split()),
|
||||
"timestamp": message.get("_ts") or message.get("timestamp") or "",
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def test_anchor_scene_persistence_round_trip_outside_provider_messages(tmp_path, monkeypatch):
|
||||
from api import models, routes
|
||||
from api.models import Session
|
||||
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
|
||||
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
|
||||
monkeypatch.setattr(routes, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(routes, "SESSIONS", models.SESSIONS)
|
||||
|
||||
session = Session(
|
||||
session_id="anchorpersist1",
|
||||
title="Anchor persistence",
|
||||
messages=[
|
||||
{"role": "user", "content": "question"},
|
||||
{"role": "assistant", "content": "final answer", "timestamp": 10.0},
|
||||
],
|
||||
)
|
||||
session.save(skip_index=True)
|
||||
|
||||
scene = {
|
||||
"version": "activity_scene_v1",
|
||||
"mode": "compact_worklog",
|
||||
"activity_rows": [
|
||||
{
|
||||
"row_id": "tool-1",
|
||||
"role": "tool",
|
||||
"tool_call_id": "call-1",
|
||||
"tool": {"id": "call-1", "name": "terminal", "args": {"command": "git status"}},
|
||||
}
|
||||
],
|
||||
"final_answer": "final answer",
|
||||
}
|
||||
request_body = {
|
||||
"session_id": "anchorpersist1",
|
||||
"stream_id": "stream-1",
|
||||
"message_index": 1,
|
||||
"message_ref": "stale-ref-after-content-normalization",
|
||||
"scene": scene,
|
||||
}
|
||||
|
||||
captured = {}
|
||||
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
||||
monkeypatch.setattr(routes, "read_body", lambda handler: request_body)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"j",
|
||||
lambda handler, payload, status=200, extra_headers=None: captured.update(
|
||||
payload=payload, status=status
|
||||
) or True,
|
||||
)
|
||||
|
||||
assert routes.handle_post(SimpleNamespace(command="POST"), SimpleNamespace(path="/api/session/anchor-scene")) is True
|
||||
assert captured["status"] == 200
|
||||
assert captured["payload"]["ok"] is True
|
||||
assert captured["payload"]["message_index"] == 1
|
||||
|
||||
raw = json.loads((session_dir / "anchorpersist1.json").read_text(encoding="utf-8"))
|
||||
assert "_anchor_activity_scene" not in raw["messages"][1]
|
||||
assert raw["messages"][1]["content"] == "final answer"
|
||||
records = raw["anchor_activity_scenes"]
|
||||
assert len(records) == 1
|
||||
record = next(iter(records.values()))
|
||||
assert record["message_index"] == 1
|
||||
assert record["stream_id"] == "stream-1"
|
||||
assert record["scene"]["version"] == "activity_scene_v1"
|
||||
|
||||
loaded = Session.load("anchorpersist1")
|
||||
hydrated = routes._hydrate_anchor_activity_scenes(
|
||||
loaded.messages,
|
||||
loaded.anchor_activity_scenes,
|
||||
message_offset=0,
|
||||
)
|
||||
assert "_anchor_activity_scene" not in loaded.messages[1]
|
||||
assert hydrated[1]["_anchor_stream_id"] == "stream-1"
|
||||
assert hydrated[1]["_anchor_activity_scene"]["final_answer"] == "final answer"
|
||||
assert hydrated[1]["_anchor_activity_scene"]["activity_rows"][0]["tool_call_id"] == "call-1"
|
||||
|
||||
|
||||
def test_anchor_scene_hydration_rejects_stale_index_fallback_when_final_answer_mismatches():
|
||||
from api import routes
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "old question"},
|
||||
{"role": "assistant", "content": "old final"},
|
||||
{"role": "user", "content": "new question"},
|
||||
{"role": "assistant", "content": "new final"},
|
||||
]
|
||||
records = {
|
||||
"stale-ref": {
|
||||
"message_index": 3,
|
||||
"message_ref": "missing-ref-after-window-shift",
|
||||
"stream_id": "stream-1",
|
||||
"scene": {
|
||||
"version": "activity_scene_v1",
|
||||
"mode": "compact_worklog",
|
||||
"final_answer": "old final",
|
||||
"activity_rows": [
|
||||
{"row_id": "old-tool", "role": "tool", "tool_call_id": "old-call"}
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
hydrated = routes._hydrate_anchor_activity_scenes(messages, records)
|
||||
|
||||
assert "_anchor_activity_scene" not in hydrated[3]
|
||||
|
||||
|
||||
def test_anchor_scene_persistence_rejects_invalid_scene(tmp_path, monkeypatch):
|
||||
from api import models, routes
|
||||
from api.models import Session
|
||||
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
|
||||
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
|
||||
monkeypatch.setattr(routes, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(routes, "SESSIONS", models.SESSIONS)
|
||||
|
||||
Session(
|
||||
session_id="anchorpersist2",
|
||||
messages=[{"role": "assistant", "content": "answer"}],
|
||||
).save(skip_index=True)
|
||||
|
||||
captured = {}
|
||||
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"read_body",
|
||||
lambda handler: {
|
||||
"session_id": "anchorpersist2",
|
||||
"message_index": 0,
|
||||
"scene": {"version": "wrong", "activity_rows": []},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"bad",
|
||||
lambda handler, msg, status=400: captured.update(error=msg, status=status) or True,
|
||||
)
|
||||
|
||||
assert routes.handle_post(SimpleNamespace(command="POST"), SimpleNamespace(path="/api/session/anchor-scene")) is True
|
||||
assert captured["status"] == 400
|
||||
assert "activity_scene_v1" in captured["error"]
|
||||
|
||||
|
||||
def test_anchor_scene_persistence_prefers_unique_ref_over_stale_index(tmp_path, monkeypatch):
|
||||
from api import models, routes
|
||||
from api.models import Session
|
||||
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
|
||||
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
|
||||
monkeypatch.setattr(routes, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(routes, "SESSIONS", models.SESSIONS)
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "old question"},
|
||||
{"role": "assistant", "content": "old final"},
|
||||
{"role": "user", "content": "new question"},
|
||||
{"role": "assistant", "content": "new final"},
|
||||
]
|
||||
Session(session_id="anchorpersist_ref", messages=messages).save(skip_index=True)
|
||||
client_ref = _client_anchor_scene_message_ref(messages[3])
|
||||
assert client_ref != routes._assistant_anchor_scene_message_ref(messages[3])
|
||||
|
||||
captured = {}
|
||||
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"read_body",
|
||||
lambda handler: {
|
||||
"session_id": "anchorpersist_ref",
|
||||
"message_index": 1,
|
||||
"message_ref": client_ref,
|
||||
"scene": {
|
||||
"version": "activity_scene_v1",
|
||||
"mode": "compact_worklog",
|
||||
"activity_rows": [{"row_id": "tool-1", "role": "tool"}],
|
||||
"final_answer": "new final",
|
||||
},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"j",
|
||||
lambda handler, payload, status=200, extra_headers=None: captured.update(
|
||||
payload=payload, status=status
|
||||
) or True,
|
||||
)
|
||||
|
||||
assert routes.handle_post(SimpleNamespace(command="POST"), SimpleNamespace(path="/api/session/anchor-scene")) is True
|
||||
assert captured["payload"]["message_index"] == 3
|
||||
|
||||
raw = json.loads((session_dir / "anchorpersist_ref.json").read_text(encoding="utf-8"))
|
||||
record = next(iter(raw["anchor_activity_scenes"].values()))
|
||||
assert record["message_index"] == 3
|
||||
assert record["scene"]["final_answer"] == "new final"
|
||||
|
||||
|
||||
def test_anchor_scene_persistence_rejects_duplicate_client_ref_over_stale_index(tmp_path, monkeypatch):
|
||||
from api import models, routes
|
||||
from api.models import Session
|
||||
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
|
||||
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
|
||||
monkeypatch.setattr(routes, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(routes, "SESSIONS", models.SESSIONS)
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "old question"},
|
||||
{"role": "assistant", "content": "same final"},
|
||||
{"role": "user", "content": "new question"},
|
||||
{"role": "assistant", "content": "same final"},
|
||||
]
|
||||
Session(session_id="anchorpersist_duplicate_ref", messages=messages).save(skip_index=True)
|
||||
|
||||
captured = {}
|
||||
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"read_body",
|
||||
lambda handler: {
|
||||
"session_id": "anchorpersist_duplicate_ref",
|
||||
"message_index": 1,
|
||||
"message_ref": _client_anchor_scene_message_ref(messages[3]),
|
||||
"scene": {
|
||||
"version": "activity_scene_v1",
|
||||
"mode": "compact_worklog",
|
||||
"activity_rows": [{"row_id": "tool-1", "role": "tool"}],
|
||||
"final_answer": "same final",
|
||||
},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"bad",
|
||||
lambda handler, msg, status=400: captured.update(error=msg, status=status) or True,
|
||||
)
|
||||
|
||||
assert routes.handle_post(SimpleNamespace(command="POST"), SimpleNamespace(path="/api/session/anchor-scene")) is True
|
||||
assert captured["status"] == 404
|
||||
assert captured["error"] == "Assistant message not found"
|
||||
|
||||
raw = json.loads((session_dir / "anchorpersist_duplicate_ref.json").read_text(encoding="utf-8"))
|
||||
assert not raw.get("anchor_activity_scenes")
|
||||
|
||||
|
||||
def test_anchor_scene_persistence_converts_window_index_to_full_index(tmp_path, monkeypatch):
|
||||
from api import models, routes
|
||||
from api.models import Session
|
||||
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
|
||||
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
|
||||
monkeypatch.setattr(routes, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(routes, "SESSIONS", models.SESSIONS)
|
||||
|
||||
Session(
|
||||
session_id="anchorpersist_window",
|
||||
messages=[
|
||||
{"role": "user", "content": "old question"},
|
||||
{"role": "assistant", "content": "old final"},
|
||||
{"role": "user", "content": "new question"},
|
||||
{"role": "assistant", "content": "new final"},
|
||||
],
|
||||
).save(skip_index=True)
|
||||
|
||||
captured = {}
|
||||
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"read_body",
|
||||
lambda handler: {
|
||||
"session_id": "anchorpersist_window",
|
||||
"message_index": 1,
|
||||
"message_window_index": 1,
|
||||
"message_offset": 2,
|
||||
"message_ref": "stale-ref-after-content-normalization",
|
||||
"scene": {
|
||||
"version": "activity_scene_v1",
|
||||
"mode": "compact_worklog",
|
||||
"activity_rows": [{"row_id": "tool-1", "role": "tool"}],
|
||||
"final_answer": "new final",
|
||||
},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"j",
|
||||
lambda handler, payload, status=200, extra_headers=None: captured.update(
|
||||
payload=payload, status=status
|
||||
) or True,
|
||||
)
|
||||
|
||||
assert routes.handle_post(SimpleNamespace(command="POST"), SimpleNamespace(path="/api/session/anchor-scene")) is True
|
||||
assert captured["payload"]["message_index"] == 3
|
||||
|
||||
raw = json.loads((session_dir / "anchorpersist_window.json").read_text(encoding="utf-8"))
|
||||
record = next(iter(raw["anchor_activity_scenes"].values()))
|
||||
assert record["message_index"] == 3
|
||||
assert record["message_ref"] == routes._assistant_anchor_scene_message_ref(raw["messages"][3])
|
||||
|
||||
|
||||
def test_anchor_scene_persistence_rejects_unmatched_ref_without_index(tmp_path, monkeypatch):
|
||||
from api import models, routes
|
||||
from api.models import Session
|
||||
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
|
||||
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
|
||||
monkeypatch.setattr(routes, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(routes, "SESSIONS", models.SESSIONS)
|
||||
|
||||
Session(
|
||||
session_id="anchorpersist_no_index",
|
||||
messages=[
|
||||
{"role": "user", "content": "question"},
|
||||
{"role": "assistant", "content": "final"},
|
||||
],
|
||||
).save(skip_index=True)
|
||||
|
||||
captured = {}
|
||||
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"read_body",
|
||||
lambda handler: {
|
||||
"session_id": "anchorpersist_no_index",
|
||||
"message_ref": "missing-ref",
|
||||
"scene": {
|
||||
"version": "activity_scene_v1",
|
||||
"mode": "compact_worklog",
|
||||
"activity_rows": [{"row_id": "tool-1", "role": "tool"}],
|
||||
},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"bad",
|
||||
lambda handler, msg, status=400: captured.update(error=msg, status=status) or True,
|
||||
)
|
||||
|
||||
assert routes.handle_post(SimpleNamespace(command="POST"), SimpleNamespace(path="/api/session/anchor-scene")) is True
|
||||
assert captured["status"] == 404
|
||||
assert captured["error"] == "Assistant message not found"
|
||||
|
||||
|
||||
def test_anchor_scene_persistence_rejects_ref_miss_stale_index_mismatch(tmp_path, monkeypatch):
|
||||
from api import models, routes
|
||||
from api.models import Session
|
||||
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
|
||||
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
|
||||
monkeypatch.setattr(routes, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(routes, "SESSIONS", models.SESSIONS)
|
||||
|
||||
Session(
|
||||
session_id="anchorpersist_stale_index_mismatch",
|
||||
messages=[
|
||||
{"role": "user", "content": "old question"},
|
||||
{"role": "assistant", "content": "old final"},
|
||||
{"role": "user", "content": "new question"},
|
||||
{"role": "assistant", "content": "new final"},
|
||||
],
|
||||
).save(skip_index=True)
|
||||
|
||||
captured = {}
|
||||
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"read_body",
|
||||
lambda handler: {
|
||||
"session_id": "anchorpersist_stale_index_mismatch",
|
||||
"message_index": 1,
|
||||
"message_ref": "stale-ref-after-content-normalization",
|
||||
"scene": {
|
||||
"version": "activity_scene_v1",
|
||||
"mode": "compact_worklog",
|
||||
"activity_rows": [{"row_id": "tool-1", "role": "tool"}],
|
||||
"final_answer": "new final",
|
||||
},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"bad",
|
||||
lambda handler, msg, status=400: captured.update(error=msg, status=status) or True,
|
||||
)
|
||||
|
||||
assert routes.handle_post(SimpleNamespace(command="POST"), SimpleNamespace(path="/api/session/anchor-scene")) is True
|
||||
assert captured["status"] == 404
|
||||
assert captured["error"] == "Assistant message not found"
|
||||
|
||||
raw = json.loads((session_dir / "anchorpersist_stale_index_mismatch.json").read_text(encoding="utf-8"))
|
||||
assert not raw.get("anchor_activity_scenes")
|
||||
|
||||
|
||||
def test_anchor_scene_hydration_repairs_tail_only_scene_from_full_turn():
|
||||
from api import routes
|
||||
|
||||
final_answer = (
|
||||
"final answer with enough detail to be the answer and a shared verification paragraph "
|
||||
"about cron proxy fallback removal, 127.0.0.1:7890, direct git pulls, and durable Worklog ordering"
|
||||
)
|
||||
stale_final_draft = (
|
||||
"draft answer with enough detail to be the answer and a shared verification paragraph "
|
||||
"about cron proxy fallback removal, 127.0.0.1:7890, direct git pulls, and durable Worklog ordering"
|
||||
)
|
||||
short_stale_final_draft = "cron 127.0.0.1:7890 fallback " + ("x " * 45) + "Worklog"
|
||||
messages = [
|
||||
{"role": "user", "content": "question"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "first progress",
|
||||
"reasoning": "thinking near first progress",
|
||||
"tool_calls": [{"id": "call-1", "function": {"name": "terminal"}}],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call-1", "content": "ok"},
|
||||
{"role": "assistant", "content": "second progress"},
|
||||
{"role": "assistant", "content": final_answer},
|
||||
]
|
||||
old_scene = {
|
||||
"version": "activity_scene_v1",
|
||||
"mode": "compact_worklog",
|
||||
"final_answer": "",
|
||||
"activity_rows": [
|
||||
{
|
||||
"row_id": "tail-prose",
|
||||
"role": "prose",
|
||||
"kind": "process_prose",
|
||||
"source_event_type": "token",
|
||||
"text": "second progress",
|
||||
},
|
||||
{
|
||||
"row_id": "bad-final-prefix",
|
||||
"role": "thinking",
|
||||
"kind": "reasoning",
|
||||
"source_event_type": "reasoning",
|
||||
"text": final_answer,
|
||||
},
|
||||
{
|
||||
"row_id": "tail-thinking",
|
||||
"role": "thinking",
|
||||
"kind": "reasoning",
|
||||
"source_event_type": "reasoning",
|
||||
"text": "thinking near first progress",
|
||||
},
|
||||
{
|
||||
"row_id": "tail-final-draft",
|
||||
"role": "prose",
|
||||
"kind": "process_prose",
|
||||
"source_event_type": "token",
|
||||
"text": stale_final_draft,
|
||||
},
|
||||
{
|
||||
"row_id": "tail-short-final-draft",
|
||||
"role": "prose",
|
||||
"kind": "process_prose",
|
||||
"source_event_type": "token",
|
||||
"text": short_stale_final_draft,
|
||||
},
|
||||
{"row_id": "done", "role": "terminal", "kind": "terminal_status", "source_event_type": "done"},
|
||||
],
|
||||
}
|
||||
records = {
|
||||
"record": {
|
||||
"message_index": 4,
|
||||
"message_ref": routes._assistant_anchor_scene_message_ref(messages[4]),
|
||||
"stream_id": "stream-1",
|
||||
"scene": old_scene,
|
||||
}
|
||||
}
|
||||
|
||||
hydrated = routes._hydrate_anchor_activity_scenes(
|
||||
messages,
|
||||
records,
|
||||
message_offset=0,
|
||||
tool_calls=[
|
||||
{
|
||||
"assistant_msg_idx": 1,
|
||||
"tid": "call-1",
|
||||
"name": "terminal",
|
||||
"preview": "running",
|
||||
"snippet": "ok",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
scene = hydrated[4]["_anchor_activity_scene"]
|
||||
rows = scene["activity_rows"]
|
||||
prose_texts = [row.get("text") for row in rows if row.get("role") == "prose"]
|
||||
tool_ids = [row.get("tool_call_id") for row in rows if row.get("role") == "tool"]
|
||||
thinking_texts = [row.get("text") for row in rows if row.get("role") == "thinking"]
|
||||
chronological = [
|
||||
(row.get("role"), row.get("text") or row.get("tool_call_id"))
|
||||
for row in rows
|
||||
if row.get("role") != "terminal"
|
||||
]
|
||||
|
||||
assert scene["final_answer"] == final_answer
|
||||
assert prose_texts == ["first progress", "second progress"]
|
||||
assert tool_ids == ["call-1"]
|
||||
assert thinking_texts == ["thinking near first progress"]
|
||||
assert chronological[:4] == [
|
||||
("prose", "first progress"),
|
||||
("thinking", "thinking near first progress"),
|
||||
("tool", "call-1"),
|
||||
("prose", "second progress"),
|
||||
]
|
||||
assert rows[-1]["role"] == "terminal"
|
||||
|
||||
|
||||
def test_anchor_scene_hydration_backfills_turn_duration_from_final_message():
|
||||
from api import routes
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "question"},
|
||||
{"role": "assistant", "content": "final answer", "_turnDuration": 731.2},
|
||||
]
|
||||
records = {
|
||||
"record": {
|
||||
"message_index": 1,
|
||||
"message_ref": routes._assistant_anchor_scene_message_ref(messages[1]),
|
||||
"stream_id": "stream-1",
|
||||
"scene": {
|
||||
"version": "activity_scene_v1",
|
||||
"mode": "compact_worklog",
|
||||
"final_answer": "final answer",
|
||||
"activity_rows": [
|
||||
{"row_id": "done", "role": "terminal", "kind": "terminal_status"}
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
hydrated = routes._hydrate_anchor_activity_scenes(messages, records)
|
||||
|
||||
assert hydrated[1]["_anchor_activity_scene"]["turn_duration"] == 731.2
|
||||
|
||||
|
||||
def test_anchor_scene_hydration_dedupes_compression_lifecycle_rows():
|
||||
from api import routes
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "question"},
|
||||
{"role": "assistant", "content": "thinking text", "reasoning": "thinking text"},
|
||||
{"role": "assistant", "content": "final answer"},
|
||||
]
|
||||
records = {
|
||||
"record": {
|
||||
"message_index": 2,
|
||||
"message_ref": routes._assistant_anchor_scene_message_ref(messages[2]),
|
||||
"stream_id": "stream-1",
|
||||
"scene": {
|
||||
"version": "activity_scene_v1",
|
||||
"mode": "compact_worklog",
|
||||
"final_answer": "final answer",
|
||||
"activity_rows": [
|
||||
{
|
||||
"row_id": "compressing-1",
|
||||
"role": "lifecycle",
|
||||
"kind": "lifecycle_status",
|
||||
"source_event_type": "compressing",
|
||||
"status": "running",
|
||||
"text": "Compressing context",
|
||||
},
|
||||
{
|
||||
"row_id": "compressing-2",
|
||||
"role": "lifecycle",
|
||||
"kind": "lifecycle_status",
|
||||
"source_event_type": "compressing",
|
||||
"status": "running",
|
||||
"text": "Compressing context",
|
||||
},
|
||||
{
|
||||
"row_id": "compressed",
|
||||
"role": "lifecycle",
|
||||
"kind": "lifecycle_status",
|
||||
"source_event_type": "compressed",
|
||||
"status": "completed",
|
||||
"text": "Context auto-compressed",
|
||||
},
|
||||
{"row_id": "done", "role": "terminal", "kind": "terminal_status", "source_event_type": "done"},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
hydrated = routes._hydrate_anchor_activity_scenes(messages, records)
|
||||
|
||||
rows = hydrated[2]["_anchor_activity_scene"]["activity_rows"]
|
||||
compression_rows = [
|
||||
row
|
||||
for row in rows
|
||||
if row.get("role") == "lifecycle"
|
||||
and row.get("source_event_type") in {"compressing", "compressed"}
|
||||
]
|
||||
assert len(compression_rows) == 1
|
||||
assert compression_rows[0]["source_event_type"] == "compressed"
|
||||
assert compression_rows[0]["order_index"] == compression_rows[0]["seq"]
|
||||
|
||||
|
||||
def test_anchor_scene_hydration_drops_stale_live_running_thinking_when_settled_thinking_exists():
|
||||
from api import routes
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "question"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "process update",
|
||||
"reasoning": "settled thinking from transcript",
|
||||
},
|
||||
{"role": "assistant", "content": "final answer"},
|
||||
]
|
||||
records = {
|
||||
"record": {
|
||||
"message_index": 2,
|
||||
"message_ref": routes._assistant_anchor_scene_message_ref(messages[2]),
|
||||
"stream_id": "stream-1",
|
||||
"scene": {
|
||||
"version": "activity_scene_v1",
|
||||
"mode": "compact_worklog",
|
||||
"final_answer": "final answer",
|
||||
"activity_rows": [
|
||||
{
|
||||
"row_id": "live-reasoning:stream-1:2",
|
||||
"local_id": "live-reasoning:stream-1:2",
|
||||
"role": "thinking",
|
||||
"kind": "reasoning",
|
||||
"source_event_type": "reasoning",
|
||||
"status": "running",
|
||||
"text": "stale live reasoning text",
|
||||
},
|
||||
{"row_id": "done", "role": "terminal", "kind": "terminal_status", "source_event_type": "done"},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
hydrated = routes._hydrate_anchor_activity_scenes(messages, records)
|
||||
|
||||
rows = hydrated[2]["_anchor_activity_scene"]["activity_rows"]
|
||||
thinking_rows = [row for row in rows if row.get("role") == "thinking"]
|
||||
assert [row.get("text") for row in thinking_rows] == ["settled thinking from transcript"]
|
||||
assert not any(str(row.get("row_id") or "").startswith("live-reasoning:") for row in rows)
|
||||
assert not any(
|
||||
row.get("role") in {"thinking", "prose", "tool"} and row.get("status") == "running"
|
||||
for row in rows
|
||||
)
|
||||
|
||||
|
||||
def test_anchor_scene_hydration_seals_unmatched_live_running_activity_rows():
|
||||
from api import routes
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "question"},
|
||||
{"role": "assistant", "content": "final answer"},
|
||||
]
|
||||
records = {
|
||||
"record": {
|
||||
"message_index": 1,
|
||||
"message_ref": routes._assistant_anchor_scene_message_ref(messages[1]),
|
||||
"stream_id": "stream-1",
|
||||
"scene": {
|
||||
"version": "activity_scene_v1",
|
||||
"mode": "compact_worklog",
|
||||
"final_answer": "final answer",
|
||||
"activity_rows": [
|
||||
{
|
||||
"row_id": "live-reasoning:stream-1:1",
|
||||
"local_id": "live-reasoning:stream-1:1",
|
||||
"role": "thinking",
|
||||
"kind": "reasoning",
|
||||
"source_event_type": "reasoning",
|
||||
"status": "running",
|
||||
"text": "only live reasoning",
|
||||
},
|
||||
{
|
||||
"row_id": "live-prose:stream-1:1",
|
||||
"local_id": "live-prose:stream-1:1",
|
||||
"role": "prose",
|
||||
"kind": "process_prose",
|
||||
"source_event_type": "token",
|
||||
"status": "running",
|
||||
"text": "only live prose",
|
||||
},
|
||||
{
|
||||
"row_id": "live-tool:stream-1:call-1",
|
||||
"local_id": "live-tool:stream-1:call-1",
|
||||
"role": "tool",
|
||||
"kind": "tool_started",
|
||||
"source_event_type": "tool",
|
||||
"status": "running",
|
||||
"tool_call_id": "call-1",
|
||||
"tool": {"id": "call-1", "name": "terminal", "done": False},
|
||||
},
|
||||
{"row_id": "done", "role": "terminal", "kind": "terminal_status", "source_event_type": "done"},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
hydrated = routes._hydrate_anchor_activity_scenes(messages, records)
|
||||
|
||||
rows = hydrated[1]["_anchor_activity_scene"]["activity_rows"]
|
||||
activity_rows = [row for row in rows if row.get("role") in {"thinking", "prose", "tool"}]
|
||||
assert [row.get("status") for row in activity_rows] == ["completed", "completed", "completed"]
|
||||
assert [row.get("role") for row in activity_rows] == ["thinking", "prose", "tool"]
|
||||
|
||||
|
||||
def test_runtime_journal_snapshot_includes_live_anchor_activity_scene(monkeypatch):
|
||||
from api import routes
|
||||
|
||||
stream_id = "stream-live-scene"
|
||||
events = [
|
||||
{
|
||||
"event": "token",
|
||||
"seq": 1,
|
||||
"event_id": f"{stream_id}:1",
|
||||
"created_at": 1.0,
|
||||
"payload": {"text": "first progress"},
|
||||
},
|
||||
{
|
||||
"event": "reasoning",
|
||||
"seq": 2,
|
||||
"event_id": f"{stream_id}:2",
|
||||
"created_at": 2.0,
|
||||
"payload": {"text": "thinking through plan"},
|
||||
},
|
||||
{
|
||||
"event": "interim_assistant",
|
||||
"seq": 3,
|
||||
"event_id": f"{stream_id}:3",
|
||||
"created_at": 3.0,
|
||||
"payload": {"text": "checkpoint"},
|
||||
},
|
||||
{
|
||||
"event": "tool",
|
||||
"seq": 4,
|
||||
"event_id": f"{stream_id}:4",
|
||||
"created_at": 4.0,
|
||||
"payload": {"name": "terminal", "tid": "call-1", "args": {"command": "pytest"}},
|
||||
},
|
||||
{
|
||||
"event": "tool_complete",
|
||||
"seq": 5,
|
||||
"event_id": f"{stream_id}:5",
|
||||
"created_at": 5.0,
|
||||
"payload": {"name": "terminal", "tid": "call-1", "preview": "ok"},
|
||||
},
|
||||
{
|
||||
"event": "token",
|
||||
"seq": 6,
|
||||
"event_id": f"{stream_id}:6",
|
||||
"created_at": 6.0,
|
||||
"payload": {"text": " tail"},
|
||||
},
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"find_run_summary",
|
||||
lambda sid: {
|
||||
"session_id": "session-live-scene",
|
||||
"last_seq": 6,
|
||||
"last_event_id": f"{stream_id}:6",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"read_run_events",
|
||||
lambda session_id, run_id: {"events": events},
|
||||
)
|
||||
|
||||
snapshot = routes._run_journal_live_snapshot(stream_id)
|
||||
scene = snapshot["anchor_activity_scene"]
|
||||
rows = scene["activity_rows"]
|
||||
|
||||
assert scene["version"] == "activity_scene_v1"
|
||||
assert snapshot["last_assistant_text"] == "first progress\n\ncheckpoint tail"
|
||||
assert snapshot["last_reasoning_text"] == "thinking through plan"
|
||||
assert [row["role"] for row in rows] == ["prose", "thinking", "tool", "prose"]
|
||||
assert rows[0]["local_id"] == f"live-prose:{stream_id}:1"
|
||||
assert rows[1]["local_id"] == f"live-thinking:{stream_id}:1"
|
||||
assert rows[1]["thinking"]["text"] == "thinking through plan"
|
||||
assert rows[2]["tool_call_id"] == "call-1"
|
||||
assert rows[2]["tool"]["done"] is True
|
||||
assert rows[3]["status"] == "running"
|
||||
|
||||
|
||||
def test_runtime_journal_snapshot_has_running_anchor_row_before_first_token(monkeypatch):
|
||||
from api import routes
|
||||
|
||||
stream_id = "stream-live-empty"
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"find_run_summary",
|
||||
lambda sid: {
|
||||
"session_id": "session-live-empty",
|
||||
"last_seq": 1,
|
||||
"last_event_id": f"{stream_id}:1",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"read_run_events",
|
||||
lambda session_id, run_id: {
|
||||
"events": [
|
||||
{
|
||||
"event": "context_status",
|
||||
"seq": 1,
|
||||
"event_id": f"{stream_id}:1",
|
||||
"created_at": 1.0,
|
||||
"payload": {"session_id": "session-live-empty"},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
snapshot = routes._run_journal_live_snapshot(stream_id)
|
||||
rows = snapshot["anchor_activity_scene"]["activity_rows"]
|
||||
|
||||
assert rows
|
||||
assert rows[0]["role"] == "lifecycle"
|
||||
assert rows[0]["status"] == "running"
|
||||
@@ -220,11 +220,12 @@ def test_auto_compression_running_card_defaults_collapsed():
|
||||
assert "open: running" not in helper
|
||||
|
||||
|
||||
def test_auto_compression_uses_centered_noninteractive_divider():
|
||||
def test_auto_compression_uses_inline_noninteractive_status():
|
||||
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 "grid-template-columns:minmax(32px,1fr) auto minmax(32px,1fr)" not in src
|
||||
assert ".auto-compression-inline" 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
|
||||
@@ -246,7 +247,8 @@ def test_auto_compression_worklog_row_does_not_use_tool_card_affordances():
|
||||
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 "auto-compression-inline-row" in helper
|
||||
assert "auto-compression-divider-line" not in helper
|
||||
assert "_autoCompressionPreviewText(state)" in helper
|
||||
|
||||
|
||||
@@ -379,6 +381,10 @@ def test_auto_compression_running_card_completes_on_followup_live_events():
|
||||
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 "function _ensureAnchorCompressionCompletedOnLiveProgress" in src
|
||||
assert "_ensureAnchorCompressionCompletedOnLiveProgress(sid);" in helper
|
||||
assert "const eventId=`synthetic:${localId}`;" in src
|
||||
assert "_findAnchorActivityEventByLocalId(localId,'compressed')" in src
|
||||
assert "phase:'done'" in helper
|
||||
assert "message:'Context auto-compressed'" in helper
|
||||
assert "appendLiveCompressionCard({" in helper
|
||||
@@ -518,7 +524,8 @@ def test_auto_compression_card_reuses_compression_card_renderer():
|
||||
|
||||
assert "if(state.automatic) return _autoCompressionCardsHtml(state);" in src
|
||||
assert "tool-card-row compression-card-row auto-compression-divider-row" in helper
|
||||
assert "auto-compression-divider-line" in helper
|
||||
assert "auto-compression-inline-row" in helper
|
||||
assert "auto-compression-divider-line" not in helper
|
||||
assert "variantClass: 'tool-card-compress-auto'" not in helper
|
||||
assert "statusLabel: preview" not in helper
|
||||
|
||||
|
||||
@@ -111,6 +111,14 @@ class TestCancelledTurnFinalizer:
|
||||
assert "provider_details_label||'Provider details'" in src
|
||||
assert "provider-error-details" in src
|
||||
|
||||
def test_cancel_error_carrier_is_not_folded_into_worklog(self):
|
||||
src = _read("static/ui.js")
|
||||
start = src.index("function _assistantMessageBelongsInWorklog")
|
||||
end = src.index("function _assistantThinkingBelongsInWorklog", start)
|
||||
block = src[start:end]
|
||||
|
||||
assert "if(m._error) return false;" in block
|
||||
|
||||
|
||||
class TestCancelledTurnPersistenceGuards:
|
||||
def test_cancel_marker_patterns_are_centralized_for_dedupe(self):
|
||||
|
||||
@@ -102,7 +102,7 @@ def test_pre_start_optimistic_ui_helpers_cannot_block_chat_start():
|
||||
body = _function_body(MESSAGES_JS, "send")
|
||||
helper_body = _function_body(MESSAGES_JS, "_runOptionalPreStartUiStep")
|
||||
|
||||
optimistic_idx = body.index("S.messages.push(userMsg);renderMessages();appendThinking('',{pending:true});setBusy(true);")
|
||||
optimistic_idx = body.index("S.messages.push(userMsg);renderMessages();setBusy(true);")
|
||||
chat_start_idx = body.index("api('/api/chat/start'")
|
||||
pre_start = body[optimistic_idx:chat_start_idx]
|
||||
|
||||
@@ -116,13 +116,16 @@ def test_pre_start_optimistic_ui_helpers_cannot_block_chat_start():
|
||||
assert "_runOptionalPreStartUiStep" in pre_start, (
|
||||
"send() should wrap optimistic sidebar/title/polling helpers before /api/chat/start"
|
||||
)
|
||||
assert "ensureLiveWorklogShell" in pre_start or "appendThinking('',{pending:true})" in pre_start, (
|
||||
"send() should render an assistant-side pending shell before /api/chat/start"
|
||||
)
|
||||
assert "upsertActiveSessionForLocalTurn" in pre_start and "applySessionTitleUpdate" in pre_start
|
||||
|
||||
|
||||
def test_pre_start_optimistic_block_cannot_prevent_chat_start():
|
||||
"""Any pre-start UI/storage exception must still fall through to /api/chat/start."""
|
||||
body = _function_body(MESSAGES_JS, "send")
|
||||
optimistic_idx = body.index("S.messages.push(userMsg);renderMessages();appendThinking('',{pending:true});setBusy(true);")
|
||||
optimistic_idx = body.index("S.messages.push(userMsg);renderMessages();setBusy(true);")
|
||||
chat_start_idx = body.index("api('/api/chat/start'")
|
||||
pre_start = body[optimistic_idx:chat_start_idx]
|
||||
|
||||
|
||||
@@ -898,7 +898,7 @@ def test_restore_succeeded_reconnect_replays_tool_cards():
|
||||
body = _function_body(SESSIONS_JS, "loadSession")
|
||||
replay_fn = body.find("const replayPersistedLiveToolCards=(opts)=>{")
|
||||
reattach_pos = body.find("if(INFLIGHT[sid].reattach&&activeStreamId&&typeof attachLiveStream==='function')")
|
||||
restore_pos = body.find("if(typeof restoreLiveTurnHtmlForSession==='function'){", reattach_pos if reattach_pos != -1 else 0)
|
||||
restore_pos = body.find("restoreLiveTurnHtmlForSession", reattach_pos if reattach_pos != -1 else 0)
|
||||
fallback_pos = body.find("if(!restoredLiveTurn){", restore_pos)
|
||||
restore_replay_pos = body.find("if(restoredLiveTurn&&didReconnect){", restore_pos)
|
||||
restore_replay_block = body[restore_replay_pos:fallback_pos]
|
||||
|
||||
@@ -6,14 +6,15 @@ STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_tool_cards_use_legacy_compact_header_without_tool_output_badge():
|
||||
"""Tool cards keep the legacy compact header: icon, tool name, preview."""
|
||||
"""Tool cards keep the compact header without surfacing output as a badge."""
|
||||
build_start = UI_JS.index('function buildToolCard(tc){')
|
||||
build_end = UI_JS.index('function _syncToolCallGroupSummary', build_start)
|
||||
build_tool_card = UI_JS[build_start:build_end]
|
||||
|
||||
assert 'tool-card-badge' not in build_tool_card
|
||||
assert 'Tool output' not in build_tool_card
|
||||
assert '<span class="tool-card-name">${esc(displayName)}</span>' in build_tool_card
|
||||
assert '<span class="tool-card-name-label">${esc(displayName)}</span>' in build_tool_card
|
||||
assert '<span class="tool-card-name-generic">${esc(genericName)}</span>' in build_tool_card
|
||||
|
||||
|
||||
def test_tool_card_badge_style_is_absent():
|
||||
|
||||
@@ -100,12 +100,12 @@ def test_setting_controls_worklog_item_details_not_only_outer_group():
|
||||
"The deprecated compact-tool toggle should not keep dead branches in Thinking markup"
|
||||
|
||||
tool_fn = _function_body(src, "buildToolCard")
|
||||
assert "_worklogDetailsExpandedDefault()" in tool_fn and "openClass" in tool_fn, \
|
||||
"Tool cards should respect the Worklog details default when they have detail content"
|
||||
assert "const openClass='';" in tool_fn and "hasDetail&&_worklogDetailsExpandedDefault()" not in tool_fn, \
|
||||
"Tool cards must stay one-line by default; details should open only after the user expands that card"
|
||||
|
||||
grouped_tools_fn = _function_body(src, "_syncToolRowsContainer")
|
||||
assert "const shouldOpen=_worklogDetailsExpandedDefault()" in grouped_tools_fn, \
|
||||
"Multi-tool Worklog groups should respect the Worklog details default"
|
||||
assert "const shouldOpen=wasOpen||_worklogDetailsExpandedDefault()" in grouped_tools_fn, \
|
||||
"Multi-tool Worklog groups should respect the default without closing a group the user already opened"
|
||||
|
||||
|
||||
def test_setting_toggle_applies_to_existing_worklog_details():
|
||||
@@ -113,8 +113,8 @@ def test_setting_toggle_applies_to_existing_worklog_details():
|
||||
helper = _function_body(ui_src, "_applyWorklogDetailsExpandedDefault")
|
||||
assert "scope.querySelectorAll('.thinking-card')" in helper, \
|
||||
"Toggling the setting should update existing Thinking cards"
|
||||
assert "scope.querySelectorAll('.tool-card')" in helper and ".tool-card-detail" in helper, \
|
||||
"Toggling the setting should update existing Tool cards that have details"
|
||||
assert "scope.querySelectorAll('.tool-card')" not in helper, \
|
||||
"Toggling the setting must not expand every Tool card into multi-line rows"
|
||||
assert "data-tool-worklog-tool-group" in helper and "aria-expanded" in helper, \
|
||||
"Toggling the setting should update existing multi-tool Worklog groups"
|
||||
|
||||
|
||||
@@ -65,15 +65,17 @@ def test_tool_activity_uses_tool_cards_and_run_activity_owns_timer():
|
||||
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 "_processedElapsedLabel" in UI_JS
|
||||
assert "t('processed_elapsed',text)" 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
|
||||
assert "_activityLiveProgressLabel(group)" not in live_summary_fn
|
||||
assert "_activityProcessedElapsedLabel(group)" in live_summary_fn
|
||||
assert "durationEl.textContent='';" in live_summary_fn
|
||||
|
||||
|
||||
def test_settled_activity_render_keeps_tools_bound_to_progress_bursts():
|
||||
@@ -162,6 +164,7 @@ def test_message_tool_metadata_empty_assistant_tools_reuse_previous_visible_anch
|
||||
has_visible_fn = _function_source(UI_JS, "_assistantMessageHasVisibleContent")
|
||||
empty_placeholder_fn = _function_source(UI_JS, "_isAssistantEmptyPlaceholderContent")
|
||||
has_reasoning_fn = _function_source(UI_JS, "_messageHasReasoningPayload")
|
||||
anchor_scene_final_fn = _function_source(UI_JS, "_assistantAnchorSceneFinalAnswerText")
|
||||
reasoning_fn = _function_source(UI_JS, "_assistantReasoningPayloadText")
|
||||
anchor_fn = _function_source(UI_JS, "_assistantToolAnchorIdxForMessage")
|
||||
script = f"""
|
||||
@@ -175,6 +178,7 @@ function msgContent(m){{
|
||||
}}
|
||||
{has_reasoning_fn}
|
||||
{empty_placeholder_fn}
|
||||
{anchor_scene_final_fn}
|
||||
{has_visible_fn}
|
||||
{reasoning_fn}
|
||||
{anchor_fn}
|
||||
|
||||
@@ -0,0 +1,840 @@
|
||||
"""Visible-order contract for the first anchor-backed Compact Worklog handoff."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
ROUTES_PY = (ROOT / "api" / "routes.py").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def _function_body(src, name):
|
||||
start = src.find(f"function {name}")
|
||||
assert start != -1, f"{name} not found"
|
||||
params = src.find("(", start)
|
||||
assert params != -1, f"{name} params not found"
|
||||
depth = 0
|
||||
close = -1
|
||||
for idx in range(params, len(src)):
|
||||
if src[idx] == "(":
|
||||
depth += 1
|
||||
elif src[idx] == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
close = idx
|
||||
break
|
||||
assert close != -1, f"{name} params did not close"
|
||||
brace = src.find("{", close)
|
||||
depth = 0
|
||||
for idx in range(brace, len(src)):
|
||||
if src[idx] == "{":
|
||||
depth += 1
|
||||
elif src[idx] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[brace + 1:idx]
|
||||
raise AssertionError(f"{name} body did not close")
|
||||
|
||||
|
||||
def _event_listener_body(src, event_name):
|
||||
marker = f"source.addEventListener('{event_name}',e=>{{"
|
||||
start = src.find(marker)
|
||||
if start == -1:
|
||||
marker = f"es.addEventListener('{event_name}', e => {{"
|
||||
start = src.find(marker)
|
||||
assert start != -1, f"{event_name} listener not found"
|
||||
brace = src.find("{", start)
|
||||
depth = 0
|
||||
for idx in range(brace, len(src)):
|
||||
if src[idx] == "{":
|
||||
depth += 1
|
||||
elif src[idx] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[brace + 1:idx]
|
||||
raise AssertionError(f"{event_name} listener did not close")
|
||||
|
||||
|
||||
def _run_node_script(script):
|
||||
assert NODE, "node is required for DOM-executed anchor render tests"
|
||||
result = subprocess.run([NODE, "-e", script], text=True, capture_output=True, check=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_dom_render_live_compression_row_transitions_to_settled_scene():
|
||||
script = f"""
|
||||
const fs = require('fs');
|
||||
const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "ui.js"))}, 'utf8');
|
||||
function extractFunc(name){{
|
||||
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
|
||||
const start = src.search(re);
|
||||
if(start < 0) throw new Error(name + ' not found');
|
||||
let i = src.indexOf('{{', start) + 1;
|
||||
let depth = 1;
|
||||
while(depth > 0 && i < src.length){{
|
||||
if(src[i] === '{{') depth += 1;
|
||||
else if(src[i] === '}}') depth -= 1;
|
||||
i += 1;
|
||||
}}
|
||||
return src.slice(start, i);
|
||||
}}
|
||||
class FakeElement {{
|
||||
constructor(tag){{
|
||||
this.tagName = String(tag || 'div').toUpperCase();
|
||||
this.attributes = Object.create(null);
|
||||
this.dataset = {{}};
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.style = {{}};
|
||||
this.className = '';
|
||||
this._innerHTML = '';
|
||||
}}
|
||||
setAttribute(name, value){{
|
||||
this.attributes[name] = String(value);
|
||||
if(name === 'class') this.className = String(value);
|
||||
if(name.startsWith('data-')){{
|
||||
const key = name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
||||
this.dataset[key] = String(value);
|
||||
}}
|
||||
}}
|
||||
getAttribute(name){{ return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null; }}
|
||||
removeAttribute(name){{ delete this.attributes[name]; }}
|
||||
appendChild(child){{ child.parentNode = this; this.children.push(child); return child; }}
|
||||
querySelector(){{ return null; }}
|
||||
querySelectorAll(){{ return []; }}
|
||||
set innerHTML(value){{ this._innerHTML = String(value); }}
|
||||
get innerHTML(){{ return this._innerHTML; }}
|
||||
set textContent(value){{ this._textContent = String(value); }}
|
||||
get textContent(){{ return this._textContent || ''; }}
|
||||
}}
|
||||
global.window = {{}};
|
||||
global.document = {{ createElement(tag){{ return new FakeElement(tag); }} }};
|
||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, ch => ({{'&':'&','<':'<','>':'>','"':'"',"'":'''}}[ch]));
|
||||
const li = name => `<i data-icon="${{name}}"></i>`;
|
||||
const renderMd = text => esc(text);
|
||||
function buildToolCard(){{ throw new Error('tool branch should not execute'); }}
|
||||
function _thinkingActivityNode(){{ throw new Error('thinking branch should not execute'); }}
|
||||
function _activityStatusNode(){{ throw new Error('status branch should not execute'); }}
|
||||
function _anchorSceneToolRowLogicalKey(){{ return ''; }}
|
||||
function _anchorSceneMergeToolRows(_prev, row){{ return row; }}
|
||||
eval(extractFunc('_anchorSceneIsSettledSuccessfulCompression'));
|
||||
eval(extractFunc('_anchorSceneRowsForRendering'));
|
||||
eval(extractFunc('_autoCompressionPreviewText'));
|
||||
eval(extractFunc('_autoCompressionWorklogNode'));
|
||||
eval(extractFunc('_anchorSceneNodeForRow'));
|
||||
const compressing = {{
|
||||
row_id:'compressing-1',
|
||||
role:'lifecycle',
|
||||
kind:'lifecycle_status',
|
||||
source_event_type:'compressing',
|
||||
status:'running',
|
||||
text:'Compressing context',
|
||||
}};
|
||||
const compressed = {{
|
||||
row_id:'compressed-1',
|
||||
role:'lifecycle',
|
||||
kind:'lifecycle_status',
|
||||
source_event_type:'compressed',
|
||||
status:'completed',
|
||||
text:'Context auto-compressed',
|
||||
}};
|
||||
const runningRows = _anchorSceneRowsForRendering({{activity_rows:[compressing]}}, {{settled:false}});
|
||||
const completedRows = _anchorSceneRowsForRendering({{activity_rows:[compressing, compressed]}}, {{settled:false}});
|
||||
const settledRows = _anchorSceneRowsForRendering({{activity_rows:[compressing, compressed]}}, {{settled:true}});
|
||||
const runningNode = _anchorSceneNodeForRow(runningRows[0], {{live:true, settled:false}});
|
||||
const completedNode = _anchorSceneNodeForRow(completedRows[0], {{live:true, settled:false}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
runningCount: runningRows.length,
|
||||
runningSource: runningRows[0] && runningRows[0].source_event_type,
|
||||
runningRole: runningNode && runningNode.getAttribute('data-anchor-row-role'),
|
||||
runningHtml: runningNode && runningNode.innerHTML,
|
||||
completedCount: completedRows.length,
|
||||
completedSource: completedRows[0] && completedRows[0].source_event_type,
|
||||
completedRole: completedNode && completedNode.getAttribute('data-anchor-row-role'),
|
||||
completedHtml: completedNode && completedNode.innerHTML,
|
||||
settledCount: settledRows.length,
|
||||
}}));
|
||||
"""
|
||||
data = _run_node_script(script)
|
||||
|
||||
assert data["runningCount"] == 1
|
||||
assert data["runningSource"] == "compressing"
|
||||
assert data["runningRole"] == "lifecycle"
|
||||
assert "auto-compression-divider-done" not in data["runningHtml"]
|
||||
assert data["completedCount"] == 1
|
||||
assert data["completedSource"] == "compressed"
|
||||
assert data["completedRole"] == "lifecycle"
|
||||
assert "auto-compression-divider-done" in data["completedHtml"]
|
||||
assert data["settledCount"] == 0
|
||||
|
||||
|
||||
def test_process_prose_is_an_anchor_scene_row_not_a_dom_mirror():
|
||||
schedule = _function_body(MESSAGES_JS, "_scheduleRender")
|
||||
flush = _function_body(MESSAGES_JS, "_flushPendingSegmentRender")
|
||||
|
||||
assert "_upsertAnchorProcessProse(displayText,{sealed:force})" in flush
|
||||
assert "function _upsertAnchorProcessProse" in MESSAGES_JS
|
||||
assert "source_event_type:sourceEventType" in _function_body(MESSAGES_JS, "_applyToAnchor")
|
||||
assert "_upsertAnchorProcessProse(displayText)" in schedule
|
||||
assert "function _replaceAnchorActivityEventByLocalId" in MESSAGES_JS
|
||||
assert "events[i]=next" in MESSAGES_JS
|
||||
assert "_renderAnchorLiveScene();" in _function_body(MESSAGES_JS, "_upsertAnchorProcessProse")
|
||||
|
||||
|
||||
def test_already_streamed_interim_does_not_duplicate_token_prose_in_anchor():
|
||||
interim = _event_listener_body(MESSAGES_JS, "interim_assistant")
|
||||
|
||||
already_idx = interim.index("if(alreadyStreamed)")
|
||||
return_idx = interim.index("return;", already_idx)
|
||||
apply_idx = interim.index("_applyToAnchor('interim_assistant'", return_idx)
|
||||
assert already_idx < return_idx < apply_idx
|
||||
|
||||
|
||||
def test_tool_boundaries_seal_prose_before_tool_rows_enter_anchor_scene():
|
||||
tool = _event_listener_body(MESSAGES_JS, "tool")
|
||||
complete = _event_listener_body(MESSAGES_JS, "tool_complete")
|
||||
|
||||
assert tool.index("_upsertAnchorProcessProse(pendingDisplayTextBeforeTool") < tool.index(
|
||||
"_applyToAnchor('tool'"
|
||||
)
|
||||
assert complete.index("_upsertAnchorProcessProse(pendingDisplayTextBeforeComplete") < complete.index(
|
||||
"_applyToAnchor('tool_complete'"
|
||||
)
|
||||
|
||||
|
||||
def test_live_ui_legacy_paths_exit_when_anchor_scene_owns_the_turn():
|
||||
for fn_name in [
|
||||
"appendLiveToolCard",
|
||||
"appendLiveCompressionCard",
|
||||
"appendThinking",
|
||||
"ensureLiveWorklogShell",
|
||||
"_syncLiveWorklogReasonsForAnchor",
|
||||
]:
|
||||
body = _function_body(UI_JS, fn_name)
|
||||
assert "isLiveAnchorActivitySceneOwner" in body
|
||||
assert "_renderLiveAnchorActivitySceneForStream" in body or fn_name == "_syncLiveWorklogReasonsForAnchor"
|
||||
|
||||
remove = _function_body(UI_JS, "removeThinking")
|
||||
assert '.agent-activity-thinking:not([data-anchor-scene-row="1"])' in remove
|
||||
assert ':not([data-anchor-scene-owner="1"])' in remove
|
||||
|
||||
|
||||
def test_anchor_scene_projection_stays_scoped_to_compact_worklog():
|
||||
render_live = _function_body(MESSAGES_JS, "_renderAnchorLiveScene")
|
||||
project_live = _function_body(MESSAGES_JS, "_projectLiveAnchorActivityScene")
|
||||
ui_live = _function_body(UI_JS, "_renderLiveAnchorActivitySceneForStream")
|
||||
|
||||
assert "mode:'compact_worklog'" in render_live
|
||||
assert "mode:'compact_worklog'" in project_live
|
||||
assert "(opts&&opts.mode)||'compact_worklog'" in ui_live
|
||||
assert "if(typeof isCompactWorklogMode==='function'&&!isCompactWorklogMode()) return false;" in _function_body(UI_JS, "renderLiveAnchorActivityScene")
|
||||
|
||||
|
||||
def test_live_processed_anchor_renders_before_first_activity_row():
|
||||
live = _function_body(UI_JS, "renderLiveAnchorActivityScene")
|
||||
shell = _function_body(UI_JS, "ensureLiveWorklogShell")
|
||||
send = _function_body(MESSAGES_JS, "send")
|
||||
|
||||
assert "if(!rows.length) return false;" not in live
|
||||
assert "if(!ok){" in live
|
||||
assert "_syncToolCallGroupSummary(group);" in live
|
||||
assert "_startActivityElapsedTimer(group)" in live
|
||||
assert "turnStartedAt:S.session&&S.session.pending_started_at" in live
|
||||
assert "if(!S.session) return null;" in shell
|
||||
assert "const activeStreamId=S.activeStreamId||'';" in shell
|
||||
assert "_renderLiveAnchorActivitySceneForStream(activeStreamId, S.session.session_id, {mode:'compact_worklog'})" in shell
|
||||
assert "if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell();" in send
|
||||
|
||||
|
||||
def test_live_processed_anchor_starts_before_chat_start_returns_stream_id():
|
||||
send = _function_body(MESSAGES_JS, "send")
|
||||
|
||||
optimistic_idx = send.index("S.messages.push(userMsg);renderMessages();setBusy(true);")
|
||||
started_idx = send.index("if(S.session&&!S.session.pending_started_at) S.session.pending_started_at=Date.now()/1000;", optimistic_idx)
|
||||
ensure_idx = send.index("if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell();", optimistic_idx)
|
||||
fallback_idx = send.index("else appendThinking('',{pending:true});", ensure_idx)
|
||||
chat_start_idx = send.index("const startData=await api('/api/chat/start'")
|
||||
assert optimistic_idx < started_idx < ensure_idx < fallback_idx < chat_start_idx
|
||||
|
||||
|
||||
def test_live_processed_anchor_rekeys_when_stream_id_is_known():
|
||||
send = _function_body(MESSAGES_JS, "send")
|
||||
shell = _function_body(UI_JS, "ensureLiveWorklogShell")
|
||||
|
||||
stream_idx = send.index("S.activeStreamId = streamId;")
|
||||
pending_idx = send.index("S.session.pending_started_at=startData.pending_started_at;")
|
||||
ensure_idx = send.index("if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell();", pending_idx)
|
||||
stop_idx = send.index("if(typeof updateSendBtn==='function') updateSendBtn();", ensure_idx)
|
||||
assert stream_idx < pending_idx < ensure_idx < stop_idx
|
||||
|
||||
compact_idx = shell.index("const compactWorklog=typeof isCompactWorklogMode")
|
||||
legacy_idx = shell.index("if(!compactWorklog&&!isSimplifiedToolCalling())")
|
||||
timer_idx = shell.index("if(typeof _startActivityElapsedTimer==='function') _startActivityElapsedTimer(group);")
|
||||
assert compact_idx < legacy_idx < timer_idx
|
||||
|
||||
|
||||
def test_live_processed_anchor_timer_falls_back_after_real_started_at():
|
||||
timer = _function_body(UI_JS, "_startActivityElapsedTimer")
|
||||
|
||||
set_idx = timer.index("_setActivityElapsedStartedAt(group);")
|
||||
fallback_idx = timer.index("if(!group.getAttribute('data-turn-started-at'))")
|
||||
update_idx = timer.index("_updateActiveActivityElapsedTimer();")
|
||||
assert set_idx < fallback_idx < update_idx
|
||||
assert "String(_activityNowSeconds())" in timer
|
||||
|
||||
|
||||
def test_server_started_turn_also_creates_processed_anchor_before_stop_button_refresh():
|
||||
listener = _event_listener_body(MESSAGES_JS, "server_turn_started")
|
||||
|
||||
pending_idx = listener.index("S.session.pending_started_at = d.pending_started_at")
|
||||
ensure_idx = listener.index("if (typeof ensureLiveWorklogShell === 'function') ensureLiveWorklogShell();")
|
||||
stop_idx = listener.index("if (typeof updateSendBtn === 'function') updateSendBtn();")
|
||||
assert pending_idx < ensure_idx < stop_idx
|
||||
|
||||
assert "else if (!S.session.pending_started_at) S.session.pending_started_at = Date.now()/1000;" in listener
|
||||
assert "if (typeof appendThinking === 'function') appendThinking();" in listener
|
||||
|
||||
|
||||
def test_server_started_turn_payload_carries_pending_started_at():
|
||||
recovery = ROUTES_PY.split("source\": \"subscribe_recovery\"", 1)[0].rsplit("try:", 1)[-1]
|
||||
assert "recover_session = get_session(sid, metadata_only=True)" in recovery
|
||||
assert "pending_started_at = getattr(recover_session, \"pending_started_at\", None)" in recovery
|
||||
assert '"pending_started_at": pending_started_at' in ROUTES_PY
|
||||
assert '"pending_started_at": getattr(session, "pending_started_at", None)' not in ROUTES_PY
|
||||
assert '"pending_started_at": (resp or {}).get("pending_started_at")' in ROUTES_PY
|
||||
|
||||
|
||||
def test_live_processed_anchor_is_deduped_across_restore_paths():
|
||||
dedupe = _function_body(UI_JS, "_dedupeLiveProcessedWorklogAnchors")
|
||||
score = _function_body(UI_JS, "_liveProcessedWorklogAnchorScore")
|
||||
live = _function_body(UI_JS, "renderLiveAnchorActivityScene")
|
||||
restore = _function_body(UI_JS, "restoreLiveTurnHtmlForSession")
|
||||
shell = _function_body(UI_JS, "ensureLiveWorklogShell")
|
||||
|
||||
assert ".tool-worklog-group[data-tool-worklog-group=\"1\"]" in dedupe
|
||||
assert ".live-worklog[data-live-worklog-shell=\"1\"]" in dedupe
|
||||
assert "if(groups.length<=1)" in dedupe
|
||||
assert "group.remove()" in dedupe
|
||||
assert "hasElapsed" in score and "hasRows" in score
|
||||
assert "_dedupeLiveProcessedWorklogAnchors(turn)" in live
|
||||
assert "_dedupeLiveProcessedWorklogAnchors(restored)" in restore
|
||||
assert "_dedupeLiveProcessedWorklogAnchors($('liveAssistantTurn'))" in shell
|
||||
|
||||
|
||||
def test_scene_renderer_coalesces_row_updates_and_renders_in_scene_order():
|
||||
rows = _function_body(UI_JS, "_anchorSceneRowsForRendering")
|
||||
render = _function_body(UI_JS, "_renderAnchorSceneRowsIntoWorklog")
|
||||
live = _function_body(UI_JS, "renderLiveAnchorActivityScene")
|
||||
|
||||
assert "byKey.set(key,out.length)" in rows
|
||||
assert "out[index]=row.role==='tool'?_anchorSceneMergeToolRows(out[index],row):row" in rows
|
||||
assert "for(const row of rows)" in render
|
||||
assert "_anchorSceneNodeForRow(row,opts)" in render
|
||||
assert "blocks.querySelectorAll('[data-live-assistant=\"1\"]')" in live
|
||||
assert "assistant-segment-worklog-source" in live
|
||||
|
||||
|
||||
def test_tool_scene_rows_coalesce_by_logical_tool_call_identity():
|
||||
rows = _function_body(UI_JS, "_anchorSceneRowsForRendering")
|
||||
key = _function_body(UI_JS, "_anchorSceneToolRowLogicalKey")
|
||||
merge = _function_body(UI_JS, "_anchorSceneMergeToolRows")
|
||||
|
||||
assert "function _anchorSceneToolRowLogicalKey" in UI_JS
|
||||
assert "if(row.role==='tool') return `tool:${_anchorSceneToolRowLogicalKey(row)||row.row_id||row.event_id||row.local_id||out.length}`" in rows
|
||||
assert "row.tool_call_id||tool.id||tool.tid||tool.tool_call_id||tool.tool_use_id||tool.call_id" in key
|
||||
assert "payload.tid||payload.id||payload.tool_call_id||payload.tool_use_id||payload.call_id" in key
|
||||
assert "mergedTool.args=prevArgs" in merge
|
||||
assert "mergedTool.preview=prevTool.preview||prevPayload.preview||prevPreview" in merge
|
||||
assert "row_id:prev.row_id||row.row_id" in merge
|
||||
|
||||
|
||||
def test_anchor_tool_result_text_stays_out_of_header_preview():
|
||||
tool = _function_body(UI_JS, "_anchorSceneToolCallFromRow")
|
||||
row_builder = _function_body(MESSAGES_JS, "_anchorSceneToolRowFromCall")
|
||||
|
||||
assert "command:tool.command||payload.command||payload.cmd||''," in tool
|
||||
assert "const command=" in row_builder
|
||||
assert "args&&(args.cmd||args.command)" in row_builder
|
||||
assert "preview:tool.preview||payload.preview||''," in tool
|
||||
assert "preview:tool.preview||payload.preview||row.text||''" not in tool
|
||||
assert "row&&row.status!=='running'&&row.status!=='pending'?row.text:''" in tool
|
||||
|
||||
|
||||
def test_scene_renderer_allows_prose_tool_prose_tool_interleaving():
|
||||
render = _function_body(UI_JS, "_renderAnchorSceneRowsIntoWorklog")
|
||||
|
||||
assert "currentTools=null;" in render
|
||||
assert render.index("if(row.role==='tool')") < render.index("}else{")
|
||||
assert render.index("currentTools=null;") < render.index("list.appendChild(node);")
|
||||
|
||||
|
||||
def test_anchor_tool_preview_slot_stays_empty_for_result_text():
|
||||
assert ".tool-worklog-list .tool-card-preview" in STYLE_CSS
|
||||
preview_rule = STYLE_CSS[
|
||||
STYLE_CSS.index(".tool-worklog-list .tool-card-title"):
|
||||
STYLE_CSS.index(".tool-worklog-list .tool-card-header:hover", STYLE_CSS.index(".tool-worklog-list .tool-card-title"))
|
||||
]
|
||||
assert ".tool-worklog-list .tool-card-preview" in preview_rule
|
||||
assert "display:block" in preview_rule
|
||||
preview = _function_body(UI_JS, "_toolCardPreviewText")
|
||||
assert "const explicit=String(tc&&tc.preview||'').trim();" not in preview
|
||||
assert "if(explicit) return explicit;" not in preview
|
||||
|
||||
|
||||
def test_anchor_tool_rows_are_action_labeled_iconed_and_single_line():
|
||||
build = _function_body(UI_JS, "buildToolCard")
|
||||
sync = _function_body(UI_JS, "_syncToolRowsContainer")
|
||||
icon = _function_body(UI_JS, "toolIcon")
|
||||
summary = _function_body(UI_JS, "_syncToolCallGroupSummary")
|
||||
|
||||
assert "_toolActionLabelText(tc,{limit:112})" in build
|
||||
assert "const hasRawDetail=!!(tc.snippet)" in build
|
||||
assert "_toolCardAllowsDetail(toolKind,tc)" in build
|
||||
assert "previewText===argPreview" in build
|
||||
assert "previewText==='Completed'||previewText==='Running'||previewText==='Failed'" in build
|
||||
assert "skill_view" in icon and "book-open" in icon
|
||||
assert "tool-worklog-tool-group-icon" in sync
|
||||
assert "_toolGroupIcon(rows)" in sync
|
||||
assert "wasOpen||_worklogDetailsExpandedDefault()" in sync
|
||||
assert "data-anchor-scene-owner')!=='1'" not in summary
|
||||
assert "if(group.getAttribute('data-tool-worklog-group')==='1') _syncToolWorklogToolGroup(group);" in summary
|
||||
|
||||
assert ".tool-worklog-list .tool-card-icon" in STYLE_CSS
|
||||
icon_rule = STYLE_CSS[
|
||||
STYLE_CSS.index(".tool-worklog-list .tool-card-icon"):
|
||||
STYLE_CSS.index(".tool-worklog-list .tool-card-name", STYLE_CSS.index(".tool-worklog-list .tool-card-icon"))
|
||||
]
|
||||
assert "display:inline-flex" in icon_rule
|
||||
name_rule = STYLE_CSS[
|
||||
STYLE_CSS.index(".tool-worklog-list .tool-card-name"):
|
||||
STYLE_CSS.index(".tool-worklog-list .tool-card-title", STYLE_CSS.index(".tool-worklog-list .tool-card-name"))
|
||||
]
|
||||
assert "text-overflow:ellipsis" in name_rule
|
||||
assert "white-space:nowrap" in name_rule
|
||||
group_rule = STYLE_CSS[
|
||||
STYLE_CSS.index(".tool-worklog-tool-group-head"):
|
||||
STYLE_CSS.index(".tool-worklog-tool-group-head:hover", STYLE_CSS.index(".tool-worklog-tool-group-head"))
|
||||
]
|
||||
assert "overflow:hidden" in group_rule and "white-space:nowrap" in group_rule
|
||||
|
||||
|
||||
def test_tool_worklog_action_summaries_are_i18n_backed():
|
||||
assert "tool_action_label" in UI_JS
|
||||
assert "tool_worklog_summary" in UI_JS
|
||||
assert "tool_summary_join" in UI_JS
|
||||
assert "tool_target_skill_suffix" in UI_JS
|
||||
|
||||
assert "tool_action_label" in I18N_JS
|
||||
assert "tool_worklog_summary" in I18N_JS
|
||||
assert "tool_summary_join" in I18N_JS
|
||||
assert "worklog_thinking" in I18N_JS
|
||||
assert "已读取" in I18N_JS and "已搜索代码" in I18N_JS
|
||||
assert "已讀取" in I18N_JS and "已搜尋程式碼" in I18N_JS
|
||||
assert "正在思考" in I18N_JS
|
||||
|
||||
|
||||
def test_live_anchor_scene_rerender_preserves_inner_tool_detail_state():
|
||||
live = _function_body(UI_JS, "renderLiveAnchorActivityScene")
|
||||
|
||||
capture_idx = live.index("const liveDisclosureState=")
|
||||
remove_idx = live.index("blocks.querySelectorAll('[data-anchor-scene-owner=\"1\"],[data-anchor-scene-row=\"1\"]')")
|
||||
render_idx = live.index("const ok=_renderAnchorSceneRowsIntoWorklog")
|
||||
restore_idx = live.index("_restoreWorklogDetailDisclosureState(blocks, liveDisclosureState)")
|
||||
assert capture_idx < remove_idx < render_idx < restore_idx
|
||||
|
||||
|
||||
def test_settled_anchor_scene_carries_live_disclosure_state_by_stream():
|
||||
settled = _function_body(UI_JS, "_renderSettledAnchorSceneForMessage")
|
||||
group = _function_body(UI_JS, "_anchorSceneWorklogGroup")
|
||||
key = _function_body(UI_JS, "_worklogDetailBaseKey")
|
||||
|
||||
assert "const streamId=String(message._anchor_stream_id||scene.stream_id||scene.identity&&scene.identity.stream_id||'');" in settled
|
||||
assert "_copyActivityDisclosureState(`live:${streamId}`, activityKey)" in settled
|
||||
assert "streamId," in settled
|
||||
assert "data-anchor-stream-id" in group
|
||||
assert "stream:${activity.getAttribute('data-anchor-stream-id')}" in key
|
||||
|
||||
|
||||
def test_live_footer_owner_guard_blocks_stale_session_updates():
|
||||
update = _function_body(UI_JS, "updateLiveRunStatus")
|
||||
hide = _function_body(UI_JS, "hideLiveRunStatus")
|
||||
|
||||
assert "opts&&opts.sessionId&&_liveRunStatusSessionId&&opts.sessionId!==_liveRunStatusSessionId" in update
|
||||
assert "sid&&_liveRunStatusSessionId&&sid!==_liveRunStatusSessionId" in hide
|
||||
|
||||
|
||||
def test_settled_scene_keeps_user_visible_lifecycle_and_control_rows():
|
||||
rows = _function_body(UI_JS, "_anchorSceneRowsForRendering")
|
||||
node = _function_body(UI_JS, "_anchorSceneNodeForRow")
|
||||
|
||||
assert "return 'lifecycle:compression';" in rows
|
||||
assert 'return "lifecycle:compression"' in ROUTES_PY
|
||||
assert 'if key == "lifecycle:compression":' in ROUTES_PY
|
||||
assert "return out.slice().sort" not in rows
|
||||
assert "source==='compressing'||source==='compressed'" in rows
|
||||
assert "(weight(a)-weight(b))" not in rows
|
||||
assert "_anchorSceneIsSettledSuccessfulCompression(row,settled)" in rows
|
||||
assert "}else if(row.role==='control'){" in node
|
||||
assert "}else if(!settled&&row.role==='control'){" not in node
|
||||
assert "phase:settled||row.source_event_type==='compressed'?'done':'running'" in node
|
||||
assert "kind:settled?'done':'waiting'" in node
|
||||
assert "status:settled?'done':'running'" in node
|
||||
assert "status:!settled&&row.status==='running'?'running':'done'" in node
|
||||
|
||||
|
||||
def test_settled_successful_auto_compression_stays_live_only():
|
||||
helper = _function_body(UI_JS, "_anchorSceneIsSettledSuccessfulCompression")
|
||||
|
||||
assert "source!=='compressing'&&source!=='compressed'" in helper
|
||||
assert "compression_exhausted" in helper
|
||||
assert "degraded" in helper
|
||||
assert "connection_lost" in helper
|
||||
|
||||
|
||||
def test_settled_scene_does_not_render_tool_start_as_still_running():
|
||||
tool = _function_body(UI_JS, "_anchorSceneToolCallFromRow")
|
||||
node = _function_body(UI_JS, "_anchorSceneNodeForRow")
|
||||
|
||||
assert "const settled=!!(opts&&opts.settled);" in tool
|
||||
assert "done:settled?true:" in tool
|
||||
assert "buildToolCard(_anchorSceneToolCallFromRow(row,opts))" in node
|
||||
|
||||
|
||||
def test_stream_end_restore_attaches_projected_anchor_scene_before_render():
|
||||
restore = _function_body(MESSAGES_JS, "_restoreSettledSession")
|
||||
|
||||
assert "function _attachProjectedAnchorSceneToLastAssistant" in MESSAGES_JS
|
||||
attach_idx = restore.index("_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);")
|
||||
carry_idx = restore.index("S.messages=_carryForwardEphemeralTurnFields")
|
||||
render_idx = restore.index("syncTopbar();renderMessages({preserveScroll:true})")
|
||||
assert attach_idx < carry_idx < render_idx
|
||||
|
||||
|
||||
def test_cancel_settlement_attaches_projected_anchor_scene_before_render():
|
||||
cancel = _event_listener_body(MESSAGES_JS, "cancel")
|
||||
|
||||
fetch_idx = cancel.index("const _nextMsgs3018=(data.session.messages||[]).filter(m=>m&&m.role);")
|
||||
attach_idx = cancel.index("_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);")
|
||||
carry_idx = cancel.index("S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);")
|
||||
render_idx = cancel.index("renderMessages({preserveScroll:true});")
|
||||
assert fetch_idx < attach_idx < carry_idx < render_idx
|
||||
|
||||
fallback_push_idx = cancel.index("S.messages.push({role:'assistant',content:`**Task cancelled:**")
|
||||
fallback_attach_idx = cancel.index("_attachProjectedAnchorSceneToLastAssistant(S.messages);", fallback_push_idx)
|
||||
fallback_render_idx = cancel.index("renderMessages({preserveScroll:true});", fallback_attach_idx)
|
||||
assert fallback_push_idx < fallback_attach_idx < fallback_render_idx
|
||||
|
||||
|
||||
def test_application_error_settlement_attaches_projected_anchor_scene_before_render():
|
||||
apperror = _event_listener_body(MESSAGES_JS, "apperror")
|
||||
|
||||
assert "_applyToAnchor('apperror'" in apperror
|
||||
session_idx = apperror.index("const _nextMsgs3018=(d.session.messages||[]).filter(m=>m&&m.role);")
|
||||
attach_idx = apperror.index("_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);")
|
||||
carry_idx = apperror.index("S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);")
|
||||
render_idx = apperror.index("renderMessages({preserveScroll:true});")
|
||||
assert session_idx < attach_idx < carry_idx < render_idx
|
||||
|
||||
synthetic_push_idx = apperror.index("S.messages.push({role:'assistant',content:`**${label}:**")
|
||||
synthetic_attach_idx = apperror.index("_attachProjectedAnchorSceneToLastAssistant(S.messages);", synthetic_push_idx)
|
||||
assert synthetic_push_idx < synthetic_attach_idx < render_idx
|
||||
|
||||
|
||||
def test_connection_error_terminal_message_attaches_projected_anchor_scene_before_render():
|
||||
error = _function_body(MESSAGES_JS, "_handleStreamError")
|
||||
|
||||
assert "_applyToAnchor('error'" in error
|
||||
push_idx = error.index("S.messages.push({role:'assistant',content:'**Connection interrupted:**")
|
||||
attach_idx = error.index("_attachProjectedAnchorSceneToLastAssistant(S.messages);")
|
||||
render_idx = error.index("renderMessages({preserveScroll:true});")
|
||||
assert push_idx < attach_idx < render_idx
|
||||
|
||||
|
||||
def test_settled_final_answer_gets_anchor_activity_above_it():
|
||||
done = _event_listener_body(MESSAGES_JS, "done")
|
||||
settled = _function_body(UI_JS, "_renderSettledAnchorSceneForMessage")
|
||||
group = _function_body(UI_JS, "_anchorSceneWorklogGroup")
|
||||
attach = _function_body(MESSAGES_JS, "_attachProjectedAnchorSceneToLastAssistant")
|
||||
|
||||
assert "_attachProjectedAnchorSceneToLastAssistant(S.messages);" in done
|
||||
assert "lastAsst._anchor_activity_scene=scene" in attach
|
||||
assert "beforeAnchor:true" in settled
|
||||
assert "collapsed:true" in settled or "live:false" in settled
|
||||
assert "syncAnchorReason:false" in group
|
||||
assert "_renderSettledAnchorSceneForMessage(msg, seg, rawIdx)" in UI_JS
|
||||
|
||||
|
||||
def test_done_sets_turn_duration_before_persisting_anchor_scene():
|
||||
done = _event_listener_body(MESSAGES_JS, "done")
|
||||
|
||||
duration_idx = done.index("lastAsst._turnDuration=d.usage.duration_seconds;")
|
||||
attach_idx = done.index("_attachProjectedAnchorSceneToLastAssistant(S.messages);")
|
||||
assert duration_idx < attach_idx
|
||||
|
||||
|
||||
def test_settled_anchor_scene_is_persisted_as_ui_metadata():
|
||||
attach = _function_body(MESSAGES_JS, "_attachProjectedAnchorSceneToLastAssistant")
|
||||
persist = _function_body(MESSAGES_JS, "_persistSettledAnchorScene")
|
||||
msg_ref = _function_body(MESSAGES_JS, "_anchorSceneMessageRef")
|
||||
|
||||
assert "_persistSettledAnchorScene(lastAsst, scene, lastAsstIndex);" in attach
|
||||
assert "api('/api/session/anchor-scene'" in persist
|
||||
assert "session_id:activeSid" in persist
|
||||
assert "stream_id:streamId" in persist
|
||||
assert "const messageOffset=_anchorSceneMessageOffsetForPersist();" in persist
|
||||
assert "message_index:_anchorSceneAbsoluteMessageIndexForPersist(messageIndex,messageOffset)" in persist
|
||||
assert "message_window_index:messageIndex" in persist
|
||||
assert "message_offset:messageOffset" in persist
|
||||
assert "message_ref:_anchorSceneMessageRef(message)" in persist
|
||||
assert "timeoutToast:false" in persist
|
||||
assert "console.warn('anchor activity scene persistence failed',err)" in persist
|
||||
assert "content:String(content||'').replace(/\\s+/g,' ').trim()" in msg_ref
|
||||
|
||||
|
||||
def test_settled_anchor_scene_persists_the_full_assistant_turn_not_only_tail():
|
||||
attach = _function_body(MESSAGES_JS, "_attachProjectedAnchorSceneToLastAssistant")
|
||||
complete = _function_body(MESSAGES_JS, "_completeSettledAnchorSceneForTurn")
|
||||
rows_by_message = _function_body(MESSAGES_JS, "_anchorSceneRowsByMessageIndex")
|
||||
reasoning_text = _function_body(MESSAGES_JS, "_anchorSceneMessageReasoningText")
|
||||
|
||||
assert "const scene=_completeSettledAnchorSceneForTurn(messages,lastAsstIndex,projectedScene);" in attach
|
||||
assert "for(let idx=lastAsstIndex-1;idx>=0;idx-=1)" in complete
|
||||
assert "messages.slice(turnStart+1,lastAsstIndex+1)" in complete
|
||||
assert "message.reasoning||message._reasoning||message.reasoning_content||message.thinking" in reasoning_text
|
||||
assert "const reasoning=_anchorSceneMessageReasoningText(message);" in rows_by_message
|
||||
assert "const toolCalls=Array.isArray(S.toolCalls)?S.toolCalls:[]" in rows_by_message
|
||||
assert "idx<=turnStart||idx>=lastAsstIndex" in rows_by_message
|
||||
assert "add(idx,_anchorSceneToolRowFromCall(tool,order++,idx));" in rows_by_message
|
||||
|
||||
|
||||
def test_settled_anchor_scene_preserves_live_projected_order_before_backfill():
|
||||
complete = _function_body(MESSAGES_JS, "_completeSettledAnchorSceneForTurn")
|
||||
overlap = _function_body(MESSAGES_JS, "_anchorSceneRowTextOverlapsExisting")
|
||||
|
||||
projected_idx = complete.index("const projectedRows=Array.isArray(base.activity_rows)?base.activity_rows:[];")
|
||||
projected_push_idx = complete.index("for(const row of projectedRows){")
|
||||
backfill_idx = complete.index("for(let idx=turnStart+1;idx<lastAsstIndex;idx+=1)")
|
||||
terminal_idx = complete.index("if(row&&row.role==='terminal') pushRow(row);", backfill_idx)
|
||||
assert projected_idx < projected_push_idx < backfill_idx < terminal_idx
|
||||
|
||||
assert "const seenTextKeys=[];" in complete
|
||||
assert "_anchorSceneRowTextOverlapsExisting(textKey,seenTextKeys)" in complete
|
||||
assert "if(isTextual&&textKey) seenTextKeys.push(textKey);" in complete
|
||||
assert "rowTextKey.includes(existing)||existing.includes(rowTextKey)" in overlap
|
||||
|
||||
|
||||
def test_settled_anchor_scene_does_not_persist_running_live_activity_rows():
|
||||
complete = _function_body(MESSAGES_JS, "_completeSettledAnchorSceneForTurn")
|
||||
live_identity = _function_body(MESSAGES_JS, "_anchorSceneRowHasLiveIdentity")
|
||||
settle_live = _function_body(MESSAGES_JS, "_anchorSceneSettleLiveRunningRow")
|
||||
|
||||
assert "const hasSettledThinking=_anchorSceneMessageRowsHaveThinking(messageRows);" in complete
|
||||
assert "row=_anchorSceneSettleLiveRunningRow(row,hasSettledThinking);" in complete
|
||||
assert "String(value||'').startsWith('live-')" in live_identity
|
||||
assert "String(row.status||'').toLowerCase()!=='running'" in settle_live
|
||||
assert "if(row.role==='thinking'&&hasSettledThinking) return null;" in settle_live
|
||||
assert "return {...row,status:'completed'};" in settle_live
|
||||
|
||||
|
||||
def test_settled_anchor_scene_separates_final_answer_from_activity_rows():
|
||||
complete = _function_body(MESSAGES_JS, "_completeSettledAnchorSceneForTurn")
|
||||
final_filter = _function_body(MESSAGES_JS, "_anchorSceneRowLooksLikeFinalAnswer")
|
||||
duration = _function_body(MESSAGES_JS, "_anchorSceneTurnDurationForSettlement")
|
||||
|
||||
assert "final_answer:_anchorSceneCleanText(finalAnswer)?finalAnswer" in complete
|
||||
assert "final_message_ref:_anchorSceneMessageRef(lastAsst)" in complete
|
||||
assert "turn_duration:_anchorSceneTurnDurationForSettlement(lastAsst,base)" in complete
|
||||
assert "_anchorSceneRowLooksLikeFinalAnswer(textKey,finalKey)" in complete
|
||||
assert "finalKey.startsWith(rowTextKey)||rowTextKey.startsWith(finalKey)" in final_filter
|
||||
assert "session&&session.pending_started_at" in duration
|
||||
assert "Date.now()/1000" in duration
|
||||
|
||||
|
||||
def test_anchor_owned_settled_turn_skips_legacy_worklog_rebuild():
|
||||
render = _function_body(UI_JS, "renderMessages")
|
||||
|
||||
assert "const anchorOwnedAssistantRawIdxs=new Set();" in render
|
||||
assert "msg._anchor_activity_scene" in render
|
||||
assert "anchorOwnedAssistantRawIdxs.add(idx)" in render
|
||||
assert "if(anchorOwnedAssistantRawIdxs.has(aIdx)) continue;" in render
|
||||
assert "if(anchorOwnedAssistantRawIdxs.has(rawIdx)) return;" in render
|
||||
assert "!anchorOwnedAssistantRawIdxs.has(S.messages.indexOf(m))" in render
|
||||
|
||||
|
||||
def test_settled_anchor_scene_final_answer_does_not_fold_into_worklog_source():
|
||||
belongs = _function_body(UI_JS, "_assistantMessageBelongsInWorklog")
|
||||
render = _function_body(UI_JS, "renderMessages")
|
||||
|
||||
assert "if(hasVisibleText&&m._anchor_activity_scene) return false;" in belongs
|
||||
assert belongs.index("if(m._live) return true;") < belongs.index(
|
||||
"if(hasVisibleText&&m._anchor_activity_scene) return false;"
|
||||
)
|
||||
assert belongs.index("if(hasVisibleText&&m._anchor_activity_scene) return false;") < belongs.index(
|
||||
"if(m._activityBurstId!==undefined||m._liveSegmentSeq!==undefined) return true;"
|
||||
)
|
||||
assert "seg.classList.add('assistant-segment-worklog-source')" in render
|
||||
assert "_renderSettledAnchorSceneForMessage(msg, seg, rawIdx)" in render
|
||||
|
||||
|
||||
def test_settled_anchor_scene_hides_prior_process_segments_not_final_answer():
|
||||
settled = _function_body(UI_JS, "_renderSettledAnchorSceneForMessage")
|
||||
group = _function_body(UI_JS, "_anchorSceneWorklogGroup")
|
||||
|
||||
assert "blocks.querySelectorAll('.assistant-segment[data-msg-idx]').forEach" in settled
|
||||
assert "idx<rawIdx" in settled
|
||||
assert "node.classList.add('assistant-segment-worklog-source')" in settled
|
||||
assert "node.setAttribute('aria-hidden','true')" in settled
|
||||
assert "turnDuration:message._turnDuration!==undefined&&message._turnDuration!==null?message._turnDuration:scene.turn_duration" in settled
|
||||
assert "turnDuration:opts&&opts.turnDuration" in group
|
||||
assert "data-turn-duration" in group
|
||||
|
||||
|
||||
def test_anchor_scene_worklog_summary_is_processed_time_anchor():
|
||||
summary = _function_body(UI_JS, "_syncToolCallGroupSummary")
|
||||
live = _function_body(UI_JS, "renderLiveAnchorActivityScene")
|
||||
|
||||
assert "_activityProcessedElapsedLabel(group)" in summary
|
||||
assert "_activitySettledProcessedLabel(group)" in summary
|
||||
assert "label.textContent=processedLabel||t('processed_elapsed','')" in summary
|
||||
assert "durationEl.textContent='';" in summary
|
||||
assert "else if(isWorklogGroup)" in summary
|
||||
assert "collapsed:false" in live
|
||||
assert ".tool-worklog-group[data-tool-worklog-group=\"1\"]:not([data-run-activity-group=\"1\"]) .tool-worklog-summary" in STYLE_CSS
|
||||
|
||||
|
||||
def test_processed_time_anchor_uses_lightweight_summary_style():
|
||||
summary_rule = STYLE_CSS[
|
||||
STYLE_CSS.index(".tool-worklog-group[data-tool-worklog-group=\"1\"]:not([data-run-activity-group=\"1\"]) .tool-worklog-summary{"):
|
||||
STYLE_CSS.index(".tool-worklog-group[data-tool-worklog-group=\"1\"]:not([data-run-activity-group=\"1\"]) .tool-worklog-label{")
|
||||
]
|
||||
label_rule = STYLE_CSS[
|
||||
STYLE_CSS.index(".tool-worklog-group[data-tool-worklog-group=\"1\"]:not([data-run-activity-group=\"1\"]) .tool-worklog-label{"):
|
||||
STYLE_CSS.index(".tool-worklog-group[data-tool-worklog-group=\"1\"]:not([data-run-activity-group=\"1\"]) .tool-worklog-summary:hover{")
|
||||
]
|
||||
|
||||
assert "border-bottom:1px solid color-mix(in srgb,var(--border-subtle) 62%,transparent)" in summary_rule
|
||||
assert "font-size:calc(var(--message-body-font-size) * .9)" in label_rule
|
||||
assert "font-weight:400" in label_rule
|
||||
assert "color:color-mix(in srgb,var(--muted) 74%,var(--bg))" in label_rule
|
||||
assert "opacity:.82" in label_rule
|
||||
|
||||
|
||||
def test_legacy_settled_worklog_summary_uses_processed_anchor_too():
|
||||
summary = _function_body(UI_JS, "_syncToolCallGroupSummary")
|
||||
|
||||
assert "const processedLabel=isLiveWorklog" in summary
|
||||
assert ": _activitySettledProcessedLabel(group)" in summary
|
||||
assert "_toolWorklogSummary(cards,{live:isLiveWorklog, toolCount, labelOnly:!toolCount&&isLiveWorklog})" not in summary
|
||||
assert ".tool-worklog-group[data-tool-worklog-group=\"1\"]:not([data-run-activity-group=\"1\"]) .tool-worklog-summary" in STYLE_CSS
|
||||
|
||||
|
||||
def test_live_processed_anchor_is_not_clickable_until_settled():
|
||||
toggle = _function_body(UI_JS, "_toggleActivityGroup")
|
||||
ensure = _function_body(UI_JS, "ensureActivityGroup")
|
||||
finalize = _function_body(UI_JS, "_finalizeLiveActivityDisclosureGroup")
|
||||
close = _function_body(UI_JS, "closeCurrentLiveActivityGroup")
|
||||
|
||||
assert "group.getAttribute('data-live-tool-call-group')==='1'&&group.getAttribute('data-live-activity-current')==='1'" in toggle
|
||||
assert "return;" in toggle
|
||||
assert "summary.setAttribute('data-live-summary-static','1')" in ensure
|
||||
assert "summary.setAttribute('aria-disabled','true')" in ensure
|
||||
assert "summary.disabled=true" in ensure
|
||||
assert "summary.disabled=false" in ensure
|
||||
assert "group.removeAttribute('data-live-tool-call-group')" in finalize
|
||||
assert "group.removeAttribute('data-live-tool-worklog-group')" in finalize
|
||||
assert ".tool-card.open,.thinking-card.open,.tool-group.open,.tool-worklog-tool-group.open" in finalize
|
||||
assert "group.classList.toggle('tool-call-group-collapsed', !keepOpen)" in finalize
|
||||
assert "if(keepOpen&&disclosureKey) _writeActivityDisclosureState(disclosureKey, true);" in finalize
|
||||
assert "summary.removeAttribute('data-live-summary-static')" in finalize
|
||||
assert "summary.disabled=false" in finalize
|
||||
assert "summary.setAttribute('aria-expanded',keepOpen?'true':'false')" in finalize
|
||||
assert "_finalizeLiveActivityDisclosureGroup(group)" in close
|
||||
assert ".tool-worklog-summary[data-live-summary-static=\"1\"]" in STYLE_CSS
|
||||
assert ".tool-worklog-group[data-live-tool-call-group=\"1\"][data-live-activity-current=\"1\"] .tool-call-group-chevron" in STYLE_CSS
|
||||
|
||||
|
||||
def test_pre_start_worklog_shell_shows_thinking_placeholder_immediately():
|
||||
ensure = _function_body(UI_JS, "ensureLiveWorklogShell")
|
||||
placeholder = _function_body(UI_JS, "_setLiveWorklogThinkingPlaceholder")
|
||||
|
||||
assert "ensureActivityGroup(blocks" in ensure
|
||||
assert "ensureLiveWorklogContainer(blocks" not in ensure
|
||||
assert "if(activeStreamId)" in ensure
|
||||
assert "_setLiveWorklogThinkingPlaceholder(group)" in ensure
|
||||
assert "group.removeAttribute('data-prestart-thinking')" in ensure
|
||||
assert "group.setAttribute('data-prestart-thinking','1')" in placeholder
|
||||
assert "t('worklog_thinking')" in placeholder
|
||||
assert "durationEl.textContent='';" in placeholder
|
||||
|
||||
|
||||
def test_done_time_empty_message_uses_anchor_scene_final_answer():
|
||||
helper = _function_body(UI_JS, "_assistantAnchorSceneFinalAnswerText")
|
||||
projection = _function_body(UI_JS, "_assistantTurnAnchorSettledFinalAnswer")
|
||||
visible = _function_body(UI_JS, "_assistantMessageHasVisibleContent")
|
||||
reasoning_visible = _function_body(UI_JS, "_assistantVisibleContentForReasoningCompare")
|
||||
|
||||
assert "m._anchor_activity_scene" in helper
|
||||
assert "scene.final_answer" in helper
|
||||
assert "const effectiveContent=String(content||'').trim()?content:sceneFinal;" in projection
|
||||
assert "content:effectiveContent" in projection
|
||||
assert "return String(sceneFinal||'').trim()?sceneFinal:null;" in projection
|
||||
assert "if(_assistantAnchorSceneFinalAnswerText(m)) return true;" in visible
|
||||
assert "const anchorFinal=_assistantAnchorSceneFinalAnswerText(m);" in reasoning_visible
|
||||
assert "if(anchorFinal) return anchorFinal;" in reasoning_visible
|
||||
|
||||
|
||||
def test_done_follow_scroll_uses_pre_settle_follow_state():
|
||||
done = _event_listener_body(MESSAGES_JS, "done")
|
||||
|
||||
capture_idx = done.index("const shouldFollowOnDone=")
|
||||
render_idx = done.index("syncTopbar();renderMessages({preserveScroll:true});")
|
||||
follow_idx = done.index("if(shouldFollowOnDone&&typeof scrollToBottom==='function') scrollToBottom();")
|
||||
assert capture_idx < render_idx < follow_idx
|
||||
after_render = done[render_idx:follow_idx]
|
||||
assert "_isMessagePaneNearBottom(250)" not in after_render
|
||||
|
||||
|
||||
def test_session_switch_prefers_live_anchor_scene_before_snapshot_fallback():
|
||||
assert "window._renderLiveAnchorActivitySceneForStream(activeStreamId, sid" in SESSIONS_JS
|
||||
first = SESSIONS_JS.index("window._renderLiveAnchorActivitySceneForStream(activeStreamId, sid")
|
||||
assert "_renderRuntimeJournalAnchorActivityScene(activeStreamId, sid)" in SESSIONS_JS[first:first + 500]
|
||||
fallback = SESSIONS_JS.index("restoreLiveTurnHtmlForSession", first)
|
||||
assert first < fallback
|
||||
assert "let restoredLiveTurn=!!restoredAnchorScene;" in SESSIONS_JS
|
||||
assert "{mode:'compact_worklog'}" in SESSIONS_JS
|
||||
|
||||
|
||||
def test_session_reload_can_render_runtime_journal_anchor_scene_snapshot():
|
||||
helper = _function_body(SESSIONS_JS, "_serverLiveSnapshotInflight")
|
||||
renderer = _function_body(SESSIONS_JS, "_renderRuntimeJournalAnchorActivityScene")
|
||||
ui_export = UI_JS[UI_JS.index("function _renderLiveAnchorActivitySceneSnapshotForStream") : UI_JS.index("function _renderSettledAnchorSceneForMessage")]
|
||||
|
||||
assert "snapshot.anchor_activity_scene" in helper
|
||||
assert "anchorActivityScene" in helper
|
||||
assert "hasAnchorActivityScene" in helper
|
||||
assert "window._renderLiveAnchorActivitySceneSnapshotForStream" in renderer
|
||||
assert "scene.version!=='activity_scene_v1'" in ui_export
|
||||
assert "window._renderLiveAnchorActivitySceneSnapshotForStream=function(streamId, scene, sessionId, opts)" in ui_export
|
||||
assert "return _renderLiveAnchorActivitySceneSnapshotForStream(streamId, scene, sessionId, opts);" in ui_export
|
||||
|
||||
|
||||
def test_runtime_journal_anchor_scene_seeds_live_registry_before_new_events():
|
||||
persist = _function_body(MESSAGES_JS, "persistInflightState")
|
||||
hydrate = _function_body(MESSAGES_JS, "_hydrateAnchorRegistryFromActivityScene")
|
||||
|
||||
assert "anchorActivityScene:inflight.anchorActivityScene||null" in persist
|
||||
assert "applyAssistantTurnAnchorSourceEvent" in hydrate
|
||||
assert "_sourceEventTypeForSnapshotAnchorRow" in hydrate
|
||||
assert "_hydrateAnchorRegistryFromActivityScene(INFLIGHT[activeSid]&&INFLIGHT[activeSid].anchorActivityScene);" in MESSAGES_JS
|
||||
@@ -112,7 +112,7 @@ def test_run_journal_cursor_tracks_every_long_task_timeline_event():
|
||||
|
||||
def test_server_runtime_journal_snapshot_restores_structured_inflight_state():
|
||||
helper_pos = SESSIONS_SRC.index("function _serverLiveSnapshotToolId")
|
||||
helper_block = SESSIONS_SRC[helper_pos : helper_pos + 2600]
|
||||
helper_block = SESSIONS_SRC[helper_pos : helper_pos + 3600]
|
||||
load_pos = SESSIONS_SRC.index("async function loadSession")
|
||||
load_block = SESSIONS_SRC[load_pos : load_pos + 18000]
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class TestSidebarFirstTurnVisibility:
|
||||
"send() must optimistically upsert the active session into the sidebar "
|
||||
"as soon as the local user message is pushed."
|
||||
)
|
||||
push_idx = src.index("S.messages.push(userMsg);renderMessages();appendThinking('',{pending:true});setBusy(true);")
|
||||
push_idx = src.index("S.messages.push(userMsg);renderMessages();setBusy(true);")
|
||||
helper_idx = src.index("upsertActiveSessionForLocalTurn", push_idx)
|
||||
start_idx = src.index("api('/api/chat/start'", push_idx)
|
||||
assert helper_idx < start_idx, (
|
||||
|
||||
@@ -92,12 +92,13 @@ def test_phase0_scaffold_is_loaded_before_current_rendering_modules():
|
||||
assert "projectAssistantTurnAnchorSettledMessageFinalAnswer" in ui_src
|
||||
assert "createAssistantTurnAnchorRegistry" not in ui_src
|
||||
assert "applyAssistantTurnAnchorSourceEvent" not in ui_src
|
||||
assert "HermesAssistantTurnAnchors" in ui_src
|
||||
assert "HermesAssistantTurnAnchors" not in _read(SESSIONS_JS)
|
||||
messages_src = _read(MESSAGES_JS)
|
||||
assert "window._liveAnchorRegistries" in messages_src
|
||||
assert "createAssistantTurnAnchorRegistry" in messages_src
|
||||
assert "applyAssistantTurnAnchorSourceEvent" in messages_src
|
||||
assert "projectAssistantTurnAnchorActivityScene" not in messages_src
|
||||
assert "projectAssistantTurnAnchorActivityScene" in messages_src
|
||||
|
||||
|
||||
def test_phase0_inventory_names_current_state_layers_in_authority_order():
|
||||
|
||||
@@ -31,6 +31,22 @@ def _event_listener_body(src: str, event_name: str) -> str:
|
||||
return src[start:end]
|
||||
|
||||
|
||||
def _function_body(src: str, name: str) -> str:
|
||||
start = src.find(f"function {name}")
|
||||
assert start != -1, f"{name} not found"
|
||||
brace = src.find("{", start)
|
||||
assert brace != -1, f"{name} body not found"
|
||||
depth = 0
|
||||
for idx in range(brace, len(src)):
|
||||
if src[idx] == "{":
|
||||
depth += 1
|
||||
elif src[idx] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[brace + 1 : idx]
|
||||
raise AssertionError(f"{name} body did not close")
|
||||
|
||||
|
||||
def _registry_snapshot() -> dict:
|
||||
assert NODE, "node is required for assistant_turn_anchors.js registry tests"
|
||||
script = f"""
|
||||
@@ -1236,25 +1252,26 @@ def test_registry_instances_do_not_share_owner_state():
|
||||
assert isolated["stats"]["applied"] == 0
|
||||
assert isolated["anchor"]["activity_events"] == []
|
||||
|
||||
def test_slice5_scene_projection_does_not_wire_activity_scene_into_rendering_hot_paths():
|
||||
def test_live_visible_order_handoff_wires_scene_projection_without_ui_registry_ownership():
|
||||
scene_helper = "projectAssistantTurnAnchorActivityScene"
|
||||
for helper in [
|
||||
"applyAssistantTurnAnchorNormalizedEvent",
|
||||
"applyAssistantTurnAnchorSourceEvents",
|
||||
"createAssistantTurnAnchorShadowSnapshot",
|
||||
scene_helper,
|
||||
"reconcileAssistantTurnAnchorActivityScene",
|
||||
]:
|
||||
assert helper not in _read(UI_JS)
|
||||
assert helper not in _read(SESSIONS_JS)
|
||||
assert helper not in _read(MESSAGES_JS)
|
||||
assert "projectAssistantTurnAnchorSettledMessageFinalAnswer" in _read(UI_JS)
|
||||
assert scene_helper not in _read(UI_JS)
|
||||
assert scene_helper in _read(UI_JS)
|
||||
assert scene_helper not in _read(SESSIONS_JS)
|
||||
assert scene_helper not in _read(MESSAGES_JS)
|
||||
assert scene_helper in _read(MESSAGES_JS)
|
||||
assert "function _renderLiveAnchorActivitySceneForStream" in _read(UI_JS)
|
||||
assert "window._renderLiveAnchorActivitySceneForStream" in _read(SESSIONS_JS)
|
||||
assert "window._renderLiveAnchorActivitySceneForStream" in _read(MESSAGES_JS)
|
||||
|
||||
|
||||
def test_slice6_live_shadow_feed_wires_non_token_events_without_renderer_scene_consumption():
|
||||
def test_slice6_live_shadow_feed_wires_anchor_scene_for_visible_order_handoff():
|
||||
src = _read(MESSAGES_JS)
|
||||
helper_body = src.split("function _applyToAnchor", 1)[1].split(
|
||||
"function _mergeSettledToolCallsWithLiveMetadata", 1
|
||||
@@ -1284,23 +1301,29 @@ def test_slice6_live_shadow_feed_wires_non_token_events_without_renderer_scene_c
|
||||
assert f"_applyToAnchor('{event_name}'" in _event_listener_body(src, event_name)
|
||||
|
||||
token_body = _event_listener_body(src, "token")
|
||||
assert "_applyToAnchor" not in token_body
|
||||
assert "_scheduleRender();" in token_body
|
||||
assert "function _upsertAnchorProcessProse" in src
|
||||
assert "_upsertAnchorProcessProse(displayText" in src
|
||||
reasoning_body = _event_listener_body(src, "reasoning")
|
||||
assert "_applyToAnchor" not in reasoning_body
|
||||
assert "_upsertAnchorReasoning(_liveThinkingText())" in reasoning_body
|
||||
assert "function _flushReasoningToAnchor()" in src
|
||||
assert "_applyToAnchor('reasoning',{" in src
|
||||
assert "local_id:'live-reasoning'" in src
|
||||
assert "_upsertAnchorReasoning(reasoningText" in src
|
||||
assert "`live-reasoning:${streamId}:final`" in src
|
||||
error_body = _event_listener_body(src, "error")
|
||||
assert "_applyToAnchor('error'" not in error_body
|
||||
assert "_flushReasoningToAnchor();" in error_body
|
||||
assert "_scheduleAnchorRegistryCleanup(120000);" in error_body
|
||||
assert "_handleStreamError(source)" in error_body
|
||||
assert "projectAssistantTurnAnchorActivityScene" not in src
|
||||
assert "projectAssistantTurnAnchorActivityScene" in src
|
||||
|
||||
tool_body = _event_listener_body(src, "tool")
|
||||
assert tool_body.index("upsertLiveToolCall(d,'start')") < tool_body.index(
|
||||
"_applyToAnchor('tool'"
|
||||
)
|
||||
assert tool_body.index("_upsertAnchorProcessProse(pendingDisplayTextBeforeTool") < tool_body.index(
|
||||
"_applyToAnchor('tool'"
|
||||
)
|
||||
done_body = _event_listener_body(src, "done")
|
||||
assert "_applyToAnchor('done',{" in done_body
|
||||
assert "usage:d.usage||null" in done_body
|
||||
@@ -1308,6 +1331,10 @@ def test_slice6_live_shadow_feed_wires_non_token_events_without_renderer_scene_c
|
||||
assert "_applyToAnchor('done',{...d" not in done_body
|
||||
assert "_flushReasoningToAnchor();" in done_body
|
||||
assert "_scheduleAnchorRegistryCleanup();" in done_body
|
||||
assert "lastAsst._anchor_stream_id=streamId" in done_body
|
||||
assert "_attachProjectedAnchorSceneToLastAssistant(S.messages);" in done_body
|
||||
attach_body = _function_body(src, "_attachProjectedAnchorSceneToLastAssistant")
|
||||
assert "lastAsst._anchor_stream_id=streamId" in attach_body
|
||||
assert "lastAsst._anchor_activity_scene=scene" in attach_body
|
||||
assert "'_anchor_stream_id'" in src
|
||||
assert "'_anchor_activity_scene'" in src
|
||||
assert src.index("'_anchor_stream_id'") < src.index("function _carryForwardEphemeralTurnFields")
|
||||
@@ -10,6 +10,7 @@ STREAMING_PY = (REPO / "api" / "streaming.py").read_text(encoding="utf-8")
|
||||
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
ROUTES_PY = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
I18N_JS = (REPO / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@@ -82,16 +83,16 @@ def test_active_compact_activity_elapsed_timer_uses_persisted_start_time():
|
||||
"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."
|
||||
assert "function _processedElapsedLabel" in UI_JS and "t('processed_elapsed',text)" in UI_JS, (
|
||||
"Compact Worklog should present the running timer as the stable processed-time anchor."
|
||||
)
|
||||
assert "data-turn-started-at" in UI_JS and "data-active-turn-elapsed" in UI_JS, (
|
||||
"Live compact Activity groups need stable start-time and active-elapsed "
|
||||
"hooks for browser QA and reconnect/rerender safety."
|
||||
)
|
||||
assert "Working " in UI_JS, (
|
||||
"The in-progress Activity summary should distinguish the live counter "
|
||||
"from the settled 'Done in …' duration."
|
||||
assert "_activityProcessedElapsedLabel(group)" in UI_JS, (
|
||||
"The in-progress Activity summary should own the live elapsed label "
|
||||
"instead of relying on the bottom live footer."
|
||||
)
|
||||
assert "setInterval" in UI_JS and "_clearActivityElapsedTimer" in UI_JS, (
|
||||
"The active elapsed label should tick while running and clear its interval "
|
||||
@@ -112,3 +113,28 @@ def test_live_footer_timer_is_re_synced_after_message_rerender():
|
||||
"If the timer node was torn down during a rerender, the helper should "
|
||||
"recreate it for the active session."
|
||||
)
|
||||
|
||||
|
||||
def test_compact_worklog_hides_bottom_live_footer_timer():
|
||||
show = UI_JS.split("function showLiveRunStatus", 1)[1].split("function _renderLiveRunStatusContent", 1)[0]
|
||||
sync = UI_JS.split("function _syncLiveRunStatusAfterRender", 1)[1].split("function hideLiveRunStatus", 1)[0]
|
||||
|
||||
assert "isCompactWorklogMode" in show
|
||||
assert "if(el){el.hidden=true;el.innerHTML='';}" in show
|
||||
assert "isCompactWorklogMode" in sync
|
||||
assert "if(el){el.hidden=true;el.innerHTML='';}" in sync
|
||||
|
||||
|
||||
def test_processed_elapsed_anchor_is_i18n_driven():
|
||||
assert "function _i18nProcessedElapsed(prefix, duration)" in I18N_JS
|
||||
assert "function _i18nProcessedElapsedEn(duration)" in I18N_JS
|
||||
assert "return _i18nProcessedElapsed('Processed', duration);" in I18N_JS
|
||||
assert "function _i18nProcessedElapsedZh(duration)" in I18N_JS
|
||||
assert "return _i18nProcessedElapsed('已处理', duration);" in I18N_JS
|
||||
assert "function _i18nProcessedElapsedZhHant(duration)" in I18N_JS
|
||||
assert "return _i18nProcessedElapsed('已處理', duration);" in I18N_JS
|
||||
assert "processed_elapsed: _i18nProcessedElapsedEn" in I18N_JS
|
||||
assert "processed_elapsed: _i18nProcessedElapsedZh" in I18N_JS
|
||||
assert "processed_elapsed: _i18nProcessedElapsedZhHant" in I18N_JS
|
||||
assert "t('processed_elapsed','')" in UI_JS
|
||||
assert "`已处理 ${" not in UI_JS
|
||||
@@ -435,11 +435,13 @@ class TestToolCallGroupingStatic:
|
||||
sync_fn = _function_body(UI_JS, "_syncToolCallGroupSummary")
|
||||
progress_fn = _function_body(UI_JS, "_activityProgressLabelForToolName")
|
||||
live_progress_fn = _function_body(UI_JS, "_activityLiveProgressLabel")
|
||||
assert "_activityLiveProgressLabel" in sync_fn, (
|
||||
"Live compact Activity rows should expose a readable transient progress label."
|
||||
assert "_activityLiveProgressLabel" not in sync_fn, (
|
||||
"Live compact Activity rows should no longer mix transient tool-progress text "
|
||||
"into the processed-time anchor."
|
||||
)
|
||||
assert "durationEl.textContent" in sync_fn and "filter(Boolean).join(' · ')" in sync_fn, (
|
||||
"Progress should share the existing non-persistent summary/duration slot, not become transcript text."
|
||||
assert "_activityProcessedElapsedLabel(group)" in sync_fn and "durationEl.textContent='';" in sync_fn, (
|
||||
"The Worklog summary should own the processed-time anchor while the old "
|
||||
"duration slot stays empty."
|
||||
)
|
||||
for label in ("Searching workspace", "Reading files", "Updating files", "Running command"):
|
||||
assert label in progress_fn
|
||||
@@ -450,35 +452,33 @@ class TestToolCallGroupingStatic:
|
||||
"Readable progress must not reintroduce the noisy secondary tool-name list."
|
||||
)
|
||||
|
||||
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_individual_tool_rows_do_not_surface_result_previews(self):
|
||||
action_fn = _function_body(UI_JS, "_toolActionLabelText")
|
||||
target_fn = _function_body(UI_JS, "_toolTargetLabel")
|
||||
preview_fn = _function_body(UI_JS, "_toolCardPreviewText")
|
||||
build_fn = _function_body(UI_JS, "buildToolCard")
|
||||
|
||||
assert "kind==='shell'&&target" not in action_fn
|
||||
assert "_toolCommandTitle(target)" not in action_fn
|
||||
assert "tc.command||tc.raw_command||tc.original_command||tc.display_command" in target_fn
|
||||
assert "tc.preview" not in target_fn
|
||||
assert "const explicit=String(tc&&tc.preview||'').trim();" not in preview_fn
|
||||
assert "if(explicit) return explicit;" not in preview_fn
|
||||
assert "const hasRawDetail=!!(tc.snippet)" in build_fn
|
||||
assert "const allowsDetail=typeof _toolCardAllowsDetail==='function'?_toolCardAllowsDetail(toolKind,tc):true;" in build_fn
|
||||
assert "const hasDetail=hasRawDetail&&allowsDetail" in build_fn
|
||||
assert "if(toolKind==='shell'||previewText===argPreview" in build_fn, (
|
||||
"Individual tool rows should show input targets in the row title and "
|
||||
"keep result previews inside the expanded detail."
|
||||
)
|
||||
|
||||
def test_read_search_list_web_detail_is_error_only(self):
|
||||
helper = _function_body(UI_JS, "_toolCardAllowsDetail")
|
||||
|
||||
assert "read:1,search:1,list:1,web:1" in helper
|
||||
assert "if(infoKinds[kind]&&!(tc&&tc.is_error)) return false;" in helper
|
||||
assert "return true;" in helper
|
||||
|
||||
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"
|
||||
@@ -620,6 +620,7 @@ class TestToolCallGroupingStatic:
|
||||
"New live reasoning text should not create active Worklog prose rows."
|
||||
)
|
||||
reset_fn = _function_body(MESSAGES_JS, "_resetAssistantSegment")
|
||||
assert "assistantRow=null" in reset_fn and "assistantBody=null" in reset_fn
|
||||
assert "function closeCurrentLiveActivityGroup()" in UI_JS, (
|
||||
"Visible interim assistant progress needs a shared helper to close the current Activity burst."
|
||||
)
|
||||
@@ -753,9 +754,21 @@ class TestToolCardDesignTokens:
|
||||
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."
|
||||
)
|
||||
assert "margin:3px000" in rows_rule
|
||||
assert "padding-left:0" in rows_rule
|
||||
assert "border-left:0" in rows_rule
|
||||
|
||||
def test_grouped_tool_rows_hide_child_icons_and_left_align(self):
|
||||
css_min = re.sub(r"\s+", "", CSS)
|
||||
assert ".tool-worklog-tool-group,.tool-group{width:100%;max-width:100%;" in css_min
|
||||
assert ".tool-worklog-tool-group-body .tool-card-row," in CSS
|
||||
assert ".tool-group-body .tool-card-row{width:100%;max-width:100%;margin:0;box-sizing:border-box;}" in CSS
|
||||
assert ".tool-worklog-tool-group-body .tool-card-icon," in CSS
|
||||
assert ".tool-group-body .tool-card-icon{display:none;}" in CSS
|
||||
assert ".tool-worklog-tool-group-body .tool-card-header," in CSS
|
||||
assert ".tool-group-body .tool-card-header{margin-left:0;padding-left:0;}" in CSS
|
||||
assert ".tool-worklog-tool-group-body .tool-card-detail," in CSS
|
||||
assert ".tool-group-body .tool-card-detail{width:100%;max-width:100%;box-sizing:border-box;}" in CSS
|
||||
|
||||
def test_tool_card_header_and_text_use_spacing_and_font_tokens(self):
|
||||
css_min = re.sub(r"\s+", "", CSS)
|
||||
@@ -769,6 +782,28 @@ class TestToolCardDesignTokens:
|
||||
assert "font-size:var(--message-body-font-size)" in title_rule
|
||||
assert "font-family:var(--font-mono)" in title_rule
|
||||
|
||||
def test_tool_card_open_state_switches_from_specific_to_generic_label(self):
|
||||
css_min = re.sub(r"\s+", "", CSS)
|
||||
assert ".tool-card-name-label{display:inline;}" in css_min
|
||||
assert ".tool-card-name-generic{display:none;}" in css_min
|
||||
assert ".tool-card.open .tool-card-name-label{display:none;}" in CSS
|
||||
assert ".tool-card.open .tool-card-name-generic{display:inline;}" in CSS
|
||||
assert ".tool-card-no-detail .tool-card-header{cursor:default;}" in CSS
|
||||
assert ".tool-card-no-detail .tool-card-header:hover{background:transparent;color:var(--muted);}" in CSS
|
||||
|
||||
build_start = UI_JS.index("function buildToolCard(tc){")
|
||||
build_end = UI_JS.index("function _syncToolCallGroupSummary", build_start)
|
||||
build = UI_JS[build_start:build_end]
|
||||
assert "_toolActionLabelText(tc,{limit:112})" in build
|
||||
assert "_toolActionLabelText(tc,{generic:true,limit:112})" in build
|
||||
assert "(hasDetail?'':' tool-card-no-detail')" in build
|
||||
assert "const headerClick=hasDetail?" in build
|
||||
assert '<div class="tool-card-header"${headerClick}>' in build
|
||||
assert "tool-card-name-label" in build and "tool-card-name-generic" in build
|
||||
assert "tool-card-detail-lead" in build
|
||||
assert "_toolDetailLeadText(toolKind,tc)" in build
|
||||
assert "const visibleArgs=(detailLeadText&&toolKind==='shell')?[]:argsEntries;" in build
|
||||
|
||||
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"
|
||||
@@ -812,3 +847,50 @@ class TestToolCardDesignTokens:
|
||||
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
|
||||
|
||||
def test_worklog_tool_steps_align_with_thinking_rows(self):
|
||||
rule = re.sub(
|
||||
r"\s+",
|
||||
"",
|
||||
CSS.split(".activity-body .wl-step-tools,", 1)[1].split("}", 1)[0],
|
||||
)
|
||||
reason_rule = re.sub(
|
||||
r"\s+",
|
||||
"",
|
||||
CSS.split(".activity-body .wl-reason,", 1)[1].split("}", 1)[0],
|
||||
)
|
||||
|
||||
assert ".tool-worklog-list>.wl-step-tools" in rule
|
||||
assert "padding-left:0" in rule
|
||||
assert "padding-left:var(--worklog-rail)" not in rule
|
||||
assert ".tool-worklog-list>.wl-reason" in reason_rule
|
||||
assert "padding-left:0" in reason_rule
|
||||
assert "padding-left:var(--worklog-rail)" not in reason_rule
|
||||
|
||||
def test_tool_detail_layers_span_full_width(self):
|
||||
card_rule = re.sub(
|
||||
r"\s+",
|
||||
"",
|
||||
CSS.split(".tool-card-row,.tool-card{", 1)[1].split("}", 1)[0],
|
||||
)
|
||||
detail_rule = re.sub(
|
||||
r"\s+",
|
||||
"",
|
||||
CSS.split(".tool-card-detail,.tl-detail{", 1)[1].split("}", 1)[0],
|
||||
)
|
||||
result_rule = re.sub(
|
||||
r"\s+",
|
||||
"",
|
||||
CSS.split(".tool-card-args,.tool-card-result{", 1)[1].split("}", 1)[0],
|
||||
)
|
||||
pre_rule = re.sub(
|
||||
r"\s+",
|
||||
"",
|
||||
CSS[CSS.index(".tool-card-result pre{", CSS.index(".tool-card-args,.tool-card-result{")):].split("{", 1)[1].split("}", 1)[0],
|
||||
)
|
||||
|
||||
for rule in (card_rule, detail_rule, result_rule, pre_rule):
|
||||
assert "width:100%" in rule
|
||||
assert "max-width:100%" in rule
|
||||
assert "box-sizing:border-box" in rule
|
||||
assert "min-width:0" in rule
|
||||
@@ -44,6 +44,40 @@ def test_webui_lineage_display_merge_includes_parent_only_rows(monkeypatch):
|
||||
]
|
||||
|
||||
|
||||
def test_webui_lineage_display_keeps_cumulative_child_tail_without_timestamps(monkeypatch):
|
||||
parent = SimpleNamespace(
|
||||
session_id="parent",
|
||||
messages=[
|
||||
_msg("user", "first prompt", 1000.0),
|
||||
_msg("assistant", "first answer", 1001.0),
|
||||
],
|
||||
)
|
||||
tip = SimpleNamespace(
|
||||
session_id="tip",
|
||||
parent_session_id="parent",
|
||||
session_source="webui",
|
||||
messages=[
|
||||
_msg("user", "first prompt", 1000.0),
|
||||
_msg("assistant", "first answer", 1001.0),
|
||||
{"role": "user", "content": "continue after compression"},
|
||||
{"role": "assistant", "content": "final after compression"},
|
||||
],
|
||||
truncation_watermark=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: parent)
|
||||
|
||||
merged = routes._merged_webui_lineage_messages_for_display(tip, tip.messages)
|
||||
|
||||
assert [m["content"] for m in merged] == [
|
||||
"first prompt",
|
||||
"first answer",
|
||||
"continue after compression",
|
||||
"final after compression",
|
||||
]
|
||||
assert merged[-1]["role"] == "assistant"
|
||||
|
||||
|
||||
def test_webui_lineage_display_merge_skips_explicit_forks(monkeypatch):
|
||||
parent = SimpleNamespace(
|
||||
session_id="parent",
|
||||
|
||||
@@ -100,6 +100,58 @@ def _install_test_session(monkeypatch, tmp_path, sid, sidecar_messages):
|
||||
return session
|
||||
|
||||
|
||||
def test_tail_cancelled_partial_blocks_state_db_replay():
|
||||
from api.models import merge_session_messages_append_only
|
||||
|
||||
sidecar = [
|
||||
{"role": "user", "content": "cancelled turn", "timestamp": 1000.0},
|
||||
{"role": "assistant", "content": "partial answer", "_partial": True, "timestamp": 1001.0},
|
||||
{"role": "assistant", "content": "Task cancelled: stopped", "_error": True, "timestamp": 1002.0},
|
||||
]
|
||||
state = [
|
||||
{"role": "user", "content": "cancelled turn", "timestamp": 1000.0},
|
||||
{"role": "assistant", "content": "partial answer", "timestamp": 1001.0},
|
||||
{"role": "assistant", "content": "Task cancelled: stopped", "timestamp": 1002.0},
|
||||
{"role": "assistant", "content": "raw replay after cancel", "timestamp": 1003.0},
|
||||
]
|
||||
|
||||
merged = merge_session_messages_append_only(sidecar, state)
|
||||
|
||||
assert [msg["content"] for msg in merged] == [
|
||||
"cancelled turn",
|
||||
"partial answer",
|
||||
"Task cancelled: stopped",
|
||||
]
|
||||
|
||||
|
||||
def test_historical_cancelled_partial_does_not_disable_later_state_db_merge():
|
||||
from api.models import merge_session_messages_append_only
|
||||
|
||||
sidecar = [
|
||||
{"role": "user", "content": "cancelled turn", "timestamp": 1000.0},
|
||||
{"role": "assistant", "content": "partial answer", "_partial": True, "timestamp": 1001.0},
|
||||
{"role": "assistant", "content": "Task cancelled: stopped", "_error": True, "timestamp": 1002.0},
|
||||
{"role": "user", "content": "later user", "timestamp": 1003.0},
|
||||
{"role": "assistant", "content": "later answer", "timestamp": 1004.0},
|
||||
]
|
||||
state = [
|
||||
{"role": "user", "content": "cancelled turn", "timestamp": 1000.0},
|
||||
{"role": "assistant", "content": "partial answer", "timestamp": 1001.0},
|
||||
{"role": "assistant", "content": "Task cancelled: stopped", "timestamp": 1002.0},
|
||||
{"role": "user", "content": "later user", "timestamp": 1003.0},
|
||||
{"role": "assistant", "content": "later answer", "timestamp": 1004.0},
|
||||
{"role": "user", "content": "state db only user", "timestamp": 1005.0},
|
||||
{"role": "assistant", "content": "state db only answer", "timestamp": 1006.0},
|
||||
]
|
||||
|
||||
merged = merge_session_messages_append_only(sidecar, state)
|
||||
|
||||
assert [msg["content"] for msg in merged][-2:] == [
|
||||
"state db only user",
|
||||
"state db only answer",
|
||||
]
|
||||
|
||||
|
||||
def test_api_session_includes_state_db_messages_newer_than_webui_sidecar(monkeypatch, tmp_path):
|
||||
import api.routes as routes
|
||||
|
||||
@@ -451,6 +503,67 @@ def test_state_db_reconciliation_preserves_repeated_sidecar_rows(monkeypatch, tm
|
||||
assert handler.response_json["session"]["message_count"] == 3
|
||||
|
||||
|
||||
def test_cancelled_partial_sidecar_owns_display_over_state_db_replay(monkeypatch, tmp_path):
|
||||
import api.routes as routes
|
||||
|
||||
sid = "webui_cancel_partial_display_owner"
|
||||
partial_text = (
|
||||
"I am reading the RFC and current implementation.\n\n"
|
||||
"Baseline confirmed: the assistant turn must preserve visible process rows."
|
||||
)
|
||||
replay_fragment = "Baseline confirmed: the assistant turn must preserve visible process rows."
|
||||
sidecar_messages = [
|
||||
{"role": "user", "content": "review the current anchor slice", "timestamp": 1000.0},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": partial_text,
|
||||
"timestamp": 1001.0,
|
||||
"_partial": True,
|
||||
"_partial_tool_calls": [
|
||||
{"tid": "call_1", "name": "terminal", "done": True, "snippet": "pytest output"}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "**Task cancelled:** Task cancelled.",
|
||||
"timestamp": 1002.0,
|
||||
"_error": True,
|
||||
"provider_details_label": "Cancellation details",
|
||||
},
|
||||
]
|
||||
_install_test_session(monkeypatch, tmp_path, sid, sidecar_messages)
|
||||
_make_state_db(
|
||||
tmp_path / "state.db",
|
||||
sid,
|
||||
[
|
||||
{"role": "user", "content": "review the current anchor slice", "timestamp": 1000.0},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": partial_text,
|
||||
"timestamp": 1001.1,
|
||||
"tool_calls": json.dumps([{"id": "call_1", "function": {"name": "terminal"}}]),
|
||||
},
|
||||
{"role": "tool", "content": "pytest output", "timestamp": 1001.2, "tool_call_id": "call_1"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": replay_fragment,
|
||||
"timestamp": 1001.3,
|
||||
"tool_calls": json.dumps([{"id": "call_2", "function": {"name": "terminal"}}]),
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
handler = _GetHandler(f"/api/session?session_id={sid}&messages=1&resolve_model=0")
|
||||
routes.handle_get(handler, urlparse(handler.path))
|
||||
|
||||
assert handler.status == 200
|
||||
session = handler.response_json["session"]
|
||||
messages = session["messages"]
|
||||
assert [m["content"] for m in messages] == [m["content"] for m in sidecar_messages]
|
||||
assert session["message_count"] == 3
|
||||
assert sum(1 for m in messages if replay_fragment in (m.get("content") or "")) == 1
|
||||
|
||||
|
||||
def test_metadata_fast_path_reports_reconciled_state_db_count(monkeypatch, tmp_path):
|
||||
import api.routes as routes
|
||||
|
||||
@@ -567,6 +680,136 @@ def test_api_session_reload_drops_stale_cached_user_tail_after_saved_assistant(m
|
||||
assert handler.response_json["session"]["message_count"] == 2
|
||||
|
||||
|
||||
def test_get_session_reloads_equal_count_cached_user_tail_after_saved_assistant(monkeypatch, tmp_path):
|
||||
import api.models as models
|
||||
|
||||
sid = "webui_reconcile_equal_count_user_tail"
|
||||
disk = _install_test_session(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
sid,
|
||||
[
|
||||
{"role": "user", "content": "review anchor scene", "timestamp": 1000.0},
|
||||
{"role": "assistant", "content": "review complete", "timestamp": 1001.0},
|
||||
],
|
||||
)
|
||||
disk.anchor_activity_scenes = {
|
||||
"assistant-final": {
|
||||
"version": "anchor_activity_scene_record_v1",
|
||||
"message_index": 1,
|
||||
"message_ref": "assistant-final",
|
||||
"stream_id": "stream-equal-count",
|
||||
"scene": {
|
||||
"version": "activity_scene_v1",
|
||||
"mode": "compact_worklog",
|
||||
"activity_rows": [{"row_id": "tool-1", "role": "tool"}],
|
||||
"final_answer": "review complete",
|
||||
},
|
||||
"updated_at": 1002.0,
|
||||
}
|
||||
}
|
||||
disk.save(touch_updated_at=False)
|
||||
|
||||
cached = models.Session(
|
||||
session_id=sid,
|
||||
title="Reconcile",
|
||||
workspace=str(tmp_path),
|
||||
model="test-model",
|
||||
messages=[
|
||||
{"role": "user", "content": "review anchor scene", "timestamp": 1000.0},
|
||||
{"role": "user", "content": "You've reached the maximum number of tool-calling iterations.", "timestamp": 1001.0},
|
||||
],
|
||||
created_at=1000.0,
|
||||
updated_at=1001.0,
|
||||
)
|
||||
models.SESSIONS[sid] = cached
|
||||
|
||||
loaded = models.get_session(sid)
|
||||
|
||||
assert loaded.messages[-1]["role"] == "assistant"
|
||||
assert loaded.messages[-1]["content"] == "review complete"
|
||||
assert "assistant-final" in loaded.anchor_activity_scenes
|
||||
assert models.SESSIONS[sid] is loaded
|
||||
|
||||
|
||||
def test_get_session_keeps_equal_count_newer_cached_user_tail(monkeypatch, tmp_path):
|
||||
import api.models as models
|
||||
|
||||
sid = "webui_reconcile_equal_count_newer_user_tail"
|
||||
_install_test_session(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
sid,
|
||||
[
|
||||
{"role": "user", "content": "old prompt", "timestamp": 1000.0},
|
||||
{"role": "assistant", "content": "old answer", "timestamp": 1001.0},
|
||||
],
|
||||
)
|
||||
|
||||
cached = models.Session(
|
||||
session_id=sid,
|
||||
title="Reconcile",
|
||||
workspace=str(tmp_path),
|
||||
model="test-model",
|
||||
messages=[
|
||||
{"role": "user", "content": "old prompt", "timestamp": 1000.0},
|
||||
{"role": "user", "content": "new prompt before stream id", "timestamp": 1002.0},
|
||||
],
|
||||
created_at=1000.0,
|
||||
updated_at=1002.0,
|
||||
)
|
||||
models.SESSIONS[sid] = cached
|
||||
|
||||
loaded = models.get_session(sid)
|
||||
|
||||
assert loaded is cached
|
||||
assert loaded.messages[-1]["role"] == "user"
|
||||
assert loaded.messages[-1]["content"] == "new prompt before stream id"
|
||||
assert models.SESSIONS[sid] is cached
|
||||
|
||||
|
||||
def test_get_session_reloads_when_disk_adds_anchor_scene_without_new_messages(monkeypatch, tmp_path):
|
||||
import api.models as models
|
||||
|
||||
sid = "webui_reconcile_anchor_scene_delta"
|
||||
_install_test_session(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
sid,
|
||||
[
|
||||
{"role": "user", "content": "question", "timestamp": 1000.0},
|
||||
{"role": "assistant", "content": "final answer", "timestamp": 1001.0},
|
||||
],
|
||||
)
|
||||
cached = models.Session.load(sid)
|
||||
assert cached is not None
|
||||
models.SESSIONS[sid] = cached
|
||||
|
||||
disk = models.Session.load(sid)
|
||||
disk.anchor_activity_scenes = {
|
||||
"assistant-final": {
|
||||
"version": "anchor_activity_scene_record_v1",
|
||||
"message_index": 1,
|
||||
"message_ref": "assistant-final",
|
||||
"stream_id": "stream-scene-delta",
|
||||
"scene": {
|
||||
"version": "activity_scene_v1",
|
||||
"mode": "compact_worklog",
|
||||
"activity_rows": [{"row_id": "tool-1", "role": "tool"}],
|
||||
"final_answer": "final answer",
|
||||
},
|
||||
"updated_at": 1002.0,
|
||||
}
|
||||
}
|
||||
disk.save(touch_updated_at=False)
|
||||
|
||||
loaded = models.get_session(sid)
|
||||
|
||||
assert loaded.messages[-1]["role"] == "assistant"
|
||||
assert "assistant-final" in loaded.anchor_activity_scenes
|
||||
assert models.SESSIONS[sid] is loaded
|
||||
|
||||
|
||||
def test_get_session_reloads_when_cached_session_lags_disk(monkeypatch, tmp_path):
|
||||
import api.models as models
|
||||
|
||||
|
||||
Reference in new issue
Block a user