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:
nesquena-hermes committed 2026-06-20 21:48:55 +00:00
1 parent 0d6adf9a08
commit 6951d10ae5
25 files changed
+5163 -219

No files matched your search

+111 -9
View File
@@ -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
View File
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+864
View File
@@ -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"
+11 -4
View File
@@ -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
+8
View File
@@ -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):
+5 -2
View File
@@ -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]
+1 -1
View File
@@ -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"
+7 -3
View File
@@ -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 => ({{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}}[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
+1 -1
View File
@@ -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]
+1 -1
View File
@@ -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")
+31 -5
View File
@@ -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
+116 -34
View File
@@ -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
+34
View File
@@ -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",
+243
View File
@@ -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