From e2ade27bfe2236cf49524c0efbf9d727804aa8aa Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 27 Jun 2026 22:54:29 +0000 Subject: [PATCH] fix(anchor): preserve settled mixed content order (#4958) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a settled assistant turn carries _anchor_activity_scene and has mixed content[] (text interleaved with tool_use), promote that ordering into anchor scene rows (prose / tool / thinking rows in sequence) instead of only the raw content[] fallback, and mirror the same scene model in the backend hydration path so a cold reload reconstructs an identical transcript. Also fixes: post-tool_use text/thinking in a NON-FINAL assistant message was silently dropped — only the turn-final assistant's post-last-tool text is the "final answer" tail; earlier assistant messages keep post-tool content as activity rows. Tail thinking is excluded from the final-answer text but still emitted as a thinking row (verified on both JS and Python paths). Tool-row de-duplication is conservative — ID-equal always merges; name/ invocation matches are cardinality-gated and same-message; body-prefix is only a secondary confirmation inside the id-flexible branch (also requires matching started_at + compatible name + invocation). It biases toward an extra visible tool card over ever silently merging two distinct calls. Gate: Codex SAFE (tail-thinking edge verified already-handled both sides), Opus SHIP (dedup fails toward duplicate-not-loss; non-final preservation, XSS, non-mixed regression all clean), full suite 10778 passed (only the 4 known pre-existing flakes). One non-blocking follow-up noted: bring the prose/thinking peer-dedup into JS↔Python parity (cosmetic cold-reload extra-narration row). Co-authored-by: franksong2702 --- CHANGELOG.md | 2 + api/routes.py | 554 +++++++- api/session_recovery.py | 54 +- .../stable-assistant-turn-anchor-phase0.md | 16 +- static/messages.js | 370 +++++- tests/test_anchor_scene_persistence.py | 1163 +++++++++++++++++ tests/test_issue4622_settled_toolcard_body.py | 972 +++++++++++++- tests/test_issue4811_reconnect_chronology.py | 40 +- ...test_live_to_final_anchor_visible_order.py | 41 +- tests/test_session_recovery_api.py | 11 +- 10 files changed, 3151 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5df5a536..1cb7ea87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed +- **Settled assistant turns with interleaved text and tool calls now keep their original order, and no post-tool text is dropped.** When an assistant turn mixed prose with tool calls (text → tool → more text), the settled/reloaded transcript could lose the chronological ordering or silently drop post-tool text/thinking from a non-final assistant message. The Stable Assistant Turn Anchor now promotes a settled turn's mixed `content[]` into ordered scene rows (prose, tool, and thinking rows in sequence) instead of only recovering the raw fallback, and the backend hydration path mirrors the same scene model so a cold reload reconstructs an identical transcript. Only the turn-final assistant message's post-last-tool text is treated as the final answer; earlier assistant messages keep their post-tool content as activity rows. Tool-row de-duplication is conservative (it only merges rows it can positively confirm are the same call), biasing toward an extra visible card over ever silently losing one. Thanks @franksong2702. (#4958) + - **Manual `/compress` now sticks across reloads and restarts instead of springing back to the old context size.** A manual compression shrinks the model-facing context but the old transcript could return through two paths: (1) append-only `state.db` reconciliation re-appending pre-compression rows, and (2) startup `.bak` recovery treating the intentional shrink as data loss. `/compress` now persists a truncation watermark/boundary (so reconciliation stops replaying pre-compression rows), refreshes the post-compression token metric, and drops the now-stale backup. Crucially, the `.bak` safeguard is only suppressed for the *pre-compression* backup whose restore would undo the shrink (detected by content — its model-facing context is still the larger uncompressed one) — a backup written *after* the compression that captures genuine post-compression data loss is **still recovered**, so manual compression can never permanently disable crash-recovery for that session. Thanks @hyl-ailab. (#4986, fixes #4836) - **Typed composer input is no longer wiped when a saved session auto-opens on boot.** On a hard refresh, boot-restore auto-opens the last session from localStorage — but that load can finish *after* you've already started typing into the fresh composer, and the saved-session draft restore would clobber what you typed. The boot-restore path now passes `preserveActiveInput` through to the existing draft-restore guard, so your in-progress text wins over the saved draft; a blank composer still restores the saved draft as before. Thanks @rodboev. (#5069, fixes #5060) diff --git a/api/routes.py b/api/routes.py index f5db7d1c..00b9f6e2 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3094,6 +3094,62 @@ def _anchor_scene_message_text(message) -> str: return str(content or "") +def _anchor_scene_content_text(part) -> str: + if part is None: + return "" + if isinstance(part, str): + return part + if not isinstance(part, dict): + return str(part or "") + return str( + part.get("text") + or part.get("content") + or part.get("input_text") + or part.get("output_text") + or part.get("thinking") + or part.get("reasoning") + or part.get("summary") + or "" + ) + + +def _anchor_scene_content_visible_text(part) -> str: + if part is None: + return "" + if isinstance(part, str): + return part + if not isinstance(part, dict): + return str(part or "") + part_type = str(part.get("type") or "") + if part_type in ("thinking", "reasoning"): + return "" + content_text = part.get("content") if part_type in ("text", "input_text", "output_text") else "" + return str(part.get("text") or part.get("input_text") or part.get("output_text") or content_text or "") + + +def _anchor_scene_message_has_content_tool_use(message) -> bool: + content = message.get("content") if isinstance(message, dict) else None + return isinstance(content, list) and any( + isinstance(part, dict) and part.get("type") == "tool_use" for part in content + ) + + +def _anchor_scene_final_answer_text(message) -> str: + if not _anchor_scene_message_has_content_tool_use(message): + return _anchor_scene_message_text(message) + content = message.get("content") if isinstance(message, dict) else [] + last_tool_index = -1 + for idx, part in enumerate(content): + if isinstance(part, dict) and part.get("type") == "tool_use": + last_tool_index = idx + tail_text = "\n".join( + text + for text in (_anchor_scene_content_visible_text(part) for part in content[last_tool_index + 1 :]) + if _anchor_scene_clean_text(text) + ) + return tail_text if _anchor_scene_clean_text(tail_text) else "" + + def _anchor_scene_message_reasoning_text(message) -> str: if not isinstance(message, dict): return "" @@ -3137,6 +3193,32 @@ def _anchor_scene_text_key(value) -> str: return _anchor_scene_clean_text(value).lower() +_ANCHOR_SCENE_SETTLED_SNIPPET_CAP = 4000 + + +def _anchor_scene_string_payload(value) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + try: + return json.dumps(value) + except Exception: + return str(value) + + +def _anchor_scene_is_bounded_tool_body_preview(settled, full) -> bool: + settled_text = _anchor_scene_string_payload(settled) + full_text = _anchor_scene_string_payload(full) + return bool( + settled_text + and full_text + and len(full_text) > len(settled_text) + and len(settled_text) >= _ANCHOR_SCENE_SETTLED_SNIPPET_CAP + and full_text.startswith(settled_text) + ) + + def _anchor_scene_row_looks_like_final_answer(row_text_key: str, final_key: str) -> bool: if not row_text_key or not final_key: return False @@ -3242,6 +3324,40 @@ def _anchor_scene_tool_args(tool): return {} +def _anchor_scene_content_tool(part): + if not isinstance(part, dict): + return {} + fn = part.get("function") if isinstance(part.get("function"), dict) else {} + tool_id = ( + part.get("id") + or part.get("tid") + or part.get("tool_call_id") + or part.get("tool_use_id") + or part.get("call_id") + ) + return { + "id": tool_id, + "tid": part.get("tid") or tool_id, + "tool_call_id": part.get("tool_call_id"), + "tool_use_id": part.get("tool_use_id"), + "call_id": part.get("call_id"), + "name": part.get("name") or part.get("tool_name") or fn.get("name") or "tool", + "tool_name": part.get("tool_name"), + "args": part.get("args"), + "input": part.get("input"), + "function": copy.deepcopy(part.get("function")) if isinstance(part.get("function"), dict) else None, + "command": part.get("command") or part.get("raw_command") or part.get("original_command") or part.get("display_command"), + "preview": part.get("preview") or part.get("summary"), + "snippet": part.get("snippet") or part.get("result") or part.get("output"), + "result": copy.deepcopy(part.get("result")), + "output": copy.deepcopy(part.get("output")), + "is_error": part.get("is_error"), + "error": part.get("error"), + "duration": part.get("duration"), + "started_at": part.get("started_at"), + } + + def _anchor_scene_row_base(role, kind, source_event_type, order_index, message_index, stream_id=""): return { "row_id": f"hydrated:{stream_id or 'stream'}:{role}:{message_index}:{order_index}", @@ -3333,6 +3449,273 @@ def _anchor_scene_tool_row(tool, order_index, message_index, stream_id=""): return row +def _anchor_scene_tool_row_id(row) -> str: + if not isinstance(row, dict): + return "" + tool = row.get("tool") if isinstance(row.get("tool"), dict) else {} + payload = row.get("payload") if isinstance(row.get("payload"), dict) else {} + return str( + row.get("tool_call_id") + or tool.get("id") + or tool.get("tid") + or tool.get("tool_call_id") + or tool.get("tool_use_id") + or tool.get("call_id") + or payload.get("tid") + or payload.get("id") + or "" + ).strip() + + +def _anchor_scene_tool_row_name(row) -> str: + if not isinstance(row, dict): + return "" + tool = row.get("tool") if isinstance(row.get("tool"), dict) else {} + payload = row.get("payload") if isinstance(row.get("payload"), dict) else {} + return str(tool.get("name") or payload.get("name") or "tool").strip().lower() + + +def _anchor_scene_tool_rows_have_compatible_names(existing, incoming) -> bool: + existing_name = _anchor_scene_tool_row_name(existing) + incoming_name = _anchor_scene_tool_row_name(incoming) + return ( + not existing_name + or not incoming_name + or existing_name == "tool" + or incoming_name == "tool" + or existing_name == incoming_name + ) + + +def _anchor_scene_tool_row_args(row): + if not isinstance(row, dict): + return None + tool = row.get("tool") if isinstance(row.get("tool"), dict) else {} + payload = row.get("payload") if isinstance(row.get("payload"), dict) else {} + args = tool.get("args") if isinstance(tool.get("args"), dict) else payload.get("args") + return args if isinstance(args, dict) and args else None + + +def _anchor_scene_object_contains_subset(base, subset) -> bool: + if not isinstance(base, dict) or not isinstance(subset, dict): + return False + for key, value in subset.items(): + if key not in base: + return False + if json.dumps(base[key], sort_keys=True, separators=(",", ":")) != json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ): + return False + return True + + +def _anchor_scene_tool_rows_have_compatible_invocation(existing, incoming) -> bool: + if not isinstance(existing, dict) or not isinstance(incoming, dict): + return False + existing_tool = existing.get("tool") if isinstance(existing.get("tool"), dict) else {} + incoming_tool = incoming.get("tool") if isinstance(incoming.get("tool"), dict) else {} + existing_payload = existing.get("payload") if isinstance(existing.get("payload"), dict) else {} + incoming_payload = incoming.get("payload") if isinstance(incoming.get("payload"), dict) else {} + existing_command = str(existing_tool.get("command") or existing_payload.get("command") or "").strip() + incoming_command = str(incoming_tool.get("command") or incoming_payload.get("command") or "").strip() + if existing_command and incoming_command: + return existing_command == incoming_command + existing_args = _anchor_scene_tool_row_args(existing) + incoming_args = _anchor_scene_tool_row_args(incoming) + if not existing_args or not incoming_args: + return False + return _anchor_scene_object_contains_subset( + existing_args, + incoming_args, + ) or _anchor_scene_object_contains_subset(incoming_args, existing_args) + + +def _anchor_scene_tool_row_has_invocation_evidence(row) -> bool: + if not isinstance(row, dict): + return False + tool = row.get("tool") if isinstance(row.get("tool"), dict) else {} + payload = row.get("payload") if isinstance(row.get("payload"), dict) else {} + command = str(tool.get("command") or payload.get("command") or "").strip() + args = _anchor_scene_tool_row_args(row) + return bool(command or args) + + +def _anchor_scene_tool_rows_can_name_match(existing, incoming) -> bool: + if not _anchor_scene_tool_rows_have_compatible_names(existing, incoming): + return False + if _anchor_scene_tool_row_has_invocation_evidence(existing) and _anchor_scene_tool_row_has_invocation_evidence(incoming): + return _anchor_scene_tool_rows_have_compatible_invocation(existing, incoming) + return True + + +def _anchor_scene_tool_rows_have_different_explicit_ids(existing, incoming) -> bool: + existing_id = _anchor_scene_tool_row_id(existing) + incoming_id = _anchor_scene_tool_row_id(incoming) + return bool(existing_id and incoming_id and existing_id != incoming_id) + + +def _anchor_scene_tool_row_started_at(row) -> str: + if not isinstance(row, dict): + return "" + tool = row.get("tool") if isinstance(row.get("tool"), dict) else {} + payload = row.get("payload") if isinstance(row.get("payload"), dict) else {} + value = tool.get("started_at") + if value is None or value == "": + value = payload.get("started_at") + return str(value) if value is not None and value != "" else "" + + +def _anchor_scene_tool_rows_have_same_started_at(existing, incoming) -> bool: + existing_started_at = _anchor_scene_tool_row_started_at(existing) + incoming_started_at = _anchor_scene_tool_row_started_at(incoming) + return bool(existing_started_at and incoming_started_at and existing_started_at == incoming_started_at) + + +def _anchor_scene_tool_row_body_text(row) -> str: + if not isinstance(row, dict): + return "" + tool = row.get("tool") if isinstance(row.get("tool"), dict) else {} + payload = row.get("payload") if isinstance(row.get("payload"), dict) else {} + for value in ( + tool.get("snippet"), + payload.get("snippet"), + tool.get("output"), + payload.get("output"), + tool.get("result"), + payload.get("result"), + tool.get("preview"), + payload.get("preview"), + ): + text = str(value or "").strip() + if text: + return text + return "" + + +def _anchor_scene_tool_rows_have_compatible_body(existing, incoming) -> bool: + existing_body = _anchor_scene_tool_row_body_text(existing) + incoming_body = _anchor_scene_tool_row_body_text(incoming) + return bool( + existing_body + and incoming_body + and ( + existing_body == incoming_body + or existing_body.startswith(incoming_body) + or incoming_body.startswith(existing_body) + ) + ) + + +def _anchor_scene_matching_content_tool_row_index( + rows, + content_tool_indexes, + incoming_row, + ordinal, + used_indexes, + incoming_total=0, + id_flexible_indexes=None, +): + if not isinstance(rows, list) or not isinstance(content_tool_indexes, list) or not isinstance(incoming_row, dict): + return None + incoming_id = _anchor_scene_tool_row_id(incoming_row) + for index in content_tool_indexes: + if index in used_indexes or index < 0 or index >= len(rows): + continue + existing_id = _anchor_scene_tool_row_id(rows[index]) + if existing_id and incoming_id and existing_id == incoming_id: + return index + if len(content_tool_indexes) == 1 and incoming_total == 1: + index = content_tool_indexes[0] + if ( + index not in used_indexes + and 0 <= index < len(rows) + and _anchor_scene_tool_rows_can_name_match(rows[index], incoming_row) + ): + return index + available_indexes = [ + index for index in content_tool_indexes if index not in used_indexes and 0 <= index < len(rows) + ] + if len(available_indexes) == 1: + index = available_indexes[0] + if incoming_total == 1 and _anchor_scene_tool_rows_can_name_match(rows[index], incoming_row): + return index + if _anchor_scene_tool_rows_have_compatible_names( + rows[index], + incoming_row, + ) and _anchor_scene_tool_rows_have_compatible_invocation(rows[index], incoming_row): + return index + reusable_indexes = [ + index for index in content_tool_indexes if index in used_indexes and 0 <= index < len(rows) + ] + if len(reusable_indexes) == 1 and incoming_total == 1: + index = reusable_indexes[0] + existing_id = _anchor_scene_tool_row_id(rows[index]) + id_flexible = isinstance(id_flexible_indexes, set) and index in id_flexible_indexes + if _anchor_scene_tool_rows_have_compatible_names( + rows[index], + incoming_row, + ) and ( + (existing_id and incoming_id and existing_id == incoming_id) + or ( + id_flexible + and _anchor_scene_tool_rows_have_same_started_at(rows[index], incoming_row) + and _anchor_scene_tool_rows_have_compatible_body(rows[index], incoming_row) + ) + ) and _anchor_scene_tool_rows_have_compatible_invocation(rows[index], incoming_row): + return index + for index in content_tool_indexes: + if index in used_indexes or index < 0 or index >= len(rows): + continue + existing_id = _anchor_scene_tool_row_id(rows[index]) + if not existing_id and not incoming_id and _anchor_scene_tool_rows_can_name_match(rows[index], incoming_row): + return index + return None + + +def _anchor_scene_content_rows(message, order_index, message_index, stream_id="", *, is_final_message=False): + if not _anchor_scene_message_has_content_tool_use(message): + return None + rows = [] + content = message.get("content") if isinstance(message, dict) else [] + last_tool_index = -1 + for idx, part in enumerate(content): + if isinstance(part, dict) and part.get("type") == "tool_use": + last_tool_index = idx + for idx, part in enumerate(content): + if not isinstance(part, dict): + if is_final_message and idx > last_tool_index: + continue + text = _anchor_scene_content_text(part) + if _anchor_scene_clean_text(text): + rows.append(_anchor_scene_prose_row(text, order_index + len(rows), message_index, stream_id)) + continue + part_type = part.get("type") + if part_type in ("text", "input_text", "output_text"): + if is_final_message and idx > last_tool_index and _anchor_scene_content_visible_text(part): + continue + text = _anchor_scene_content_text(part) + if _anchor_scene_clean_text(text): + rows.append(_anchor_scene_prose_row(text, order_index + len(rows), message_index, stream_id)) + continue + if part_type in ("thinking", "reasoning"): + text = _anchor_scene_content_text(part) + if _anchor_scene_clean_text(text): + rows.append(_anchor_scene_thinking_row(text, order_index + len(rows), message_index, stream_id)) + continue + if part_type == "tool_use": + rows.append( + _anchor_scene_tool_row( + _anchor_scene_content_tool(part), + order_index + len(rows), + message_index, + stream_id, + ) + ) + return rows + + def _anchor_scene_row_key(row) -> str: if not isinstance(row, dict): return "" @@ -3398,12 +3781,76 @@ def _complete_hydrated_anchor_scene(messages, scene, message_index, *, message_o if isinstance(message, dict) and message.get("role") == "user": turn_start = idx break - final_answer = _anchor_scene_message_text(final_message) + message_final_answer = _anchor_scene_final_answer_text(final_message) + scene_final_answer = scene.get("final_answer") if isinstance(scene.get("final_answer"), str) else "" + final_answer = message_final_answer if _anchor_scene_clean_text(message_final_answer) else scene_final_answer final_key = _anchor_scene_text_key(final_answer) rows = [] seen = {} - def push(row): + def merge_duplicate_tool_row(existing, incoming, *, prefer_incoming_body=False): + if not isinstance(existing, dict) or not isinstance(incoming, dict): + return existing + merged = copy.deepcopy(existing) + merged_tool = merged.get("tool") if isinstance(merged.get("tool"), dict) else {} + incoming_tool = incoming.get("tool") if isinstance(incoming.get("tool"), dict) else {} + merged_payload = merged.get("payload") if isinstance(merged.get("payload"), dict) else {} + incoming_payload = incoming.get("payload") if isinstance(incoming.get("payload"), dict) else {} + + def empty(value): + return value is None or value == "" or value == {} + + def merge_missing_args(existing_args, incoming_args): + if not isinstance(incoming_args, dict) or not incoming_args: + return existing_args, False + base = copy.deepcopy(existing_args) if isinstance(existing_args, dict) else {} + changed = not isinstance(existing_args, dict) + for key, value in incoming_args.items(): + if key not in base: + base[key] = copy.deepcopy(value) + changed = True + return base, changed + + for key in ("snippet", "result", "output"): + incoming_value = incoming_tool.get(key) + if not empty(incoming_value) and ( + empty(merged_tool.get(key)) + or ( + prefer_incoming_body + and _anchor_scene_is_bounded_tool_body_preview(merged_tool.get(key), incoming_value) + ) + ): + merged_tool[key] = copy.deepcopy(incoming_value) + incoming_value = incoming_payload.get(key) + if not empty(incoming_value) and ( + empty(merged_payload.get(key)) + or ( + prefer_incoming_body + and _anchor_scene_is_bounded_tool_body_preview(merged_payload.get(key), incoming_value) + ) + ): + merged_payload[key] = copy.deepcopy(incoming_value) + for key in ("preview", "command", "duration", "started_at"): + incoming_value = incoming_tool.get(key) + if not empty(incoming_value) and empty(merged_tool.get(key)): + merged_tool[key] = copy.deepcopy(incoming_value) + incoming_value = incoming_payload.get(key) + if not empty(incoming_value) and empty(merged_payload.get(key)): + merged_payload[key] = copy.deepcopy(incoming_value) + merged_args, args_changed = merge_missing_args(merged_tool.get("args"), incoming_tool.get("args")) + if args_changed: + merged_tool["args"] = merged_args + merged_payload_args, payload_args_changed = merge_missing_args( + merged_payload.get("args"), + incoming_payload.get("args"), + ) + if payload_args_changed: + merged_payload["args"] = merged_payload_args + merged["tool"] = merged_tool + merged["payload"] = merged_payload + return merged + + def push(row, *, prefer_incoming_tool_body=False): if not isinstance(row, dict): return row = _anchor_scene_settle_live_running_row( @@ -3419,6 +3866,14 @@ def _complete_hydrated_anchor_scene(messages, scene, message_index, *, message_o return key = _anchor_scene_row_key(row) if key and key in seen: + if key.startswith("tool:"): + index = seen[key] + rows[index] = merge_duplicate_tool_row( + rows[index], + row, + prefer_incoming_body=prefer_incoming_tool_body, + ) + return if key == "lifecycle:compression": index = seen[key] next_row = copy.deepcopy(row) @@ -3434,13 +3889,37 @@ def _complete_hydrated_anchor_scene(messages, scene, message_index, *, message_o rows.append(next_row) order = 0 - for local_idx in range(turn_start + 1, local_final_idx): + content_tool_indexes_by_idx = {} + used_content_tool_indexes_by_idx = {} + id_flexible_content_tool_indexes_by_idx = {} + for local_idx in range(turn_start + 1, local_final_idx + 1): message = messages[local_idx] if not isinstance(message, dict) or message.get("role") != "assistant": continue absolute_idx = int(message_offset or 0) + local_idx text = _anchor_scene_message_text(message) - if _anchor_scene_clean_text(text): + content_rows = _anchor_scene_content_rows( + message, + order, + absolute_idx, + stream_id, + is_final_message=local_idx == local_final_idx, + ) + content_tool_indexes = [] + used_content_tool_indexes = set() + id_flexible_content_tool_indexes = set() + if content_rows: + for row in content_rows: + previous_len = len(rows) + push(row) + if row.get("role") == "tool" and len(rows) > previous_len: + content_tool_indexes.append(len(rows) - 1) + order += 1 + if content_tool_indexes: + content_tool_indexes_by_idx[absolute_idx] = content_tool_indexes + used_content_tool_indexes_by_idx[absolute_idx] = used_content_tool_indexes + id_flexible_content_tool_indexes_by_idx[absolute_idx] = id_flexible_content_tool_indexes + elif _anchor_scene_clean_text(text): push(_anchor_scene_prose_row(text, order, absolute_idx, stream_id)) order += 1 reasoning = _anchor_scene_message_reasoning_text(message) @@ -3450,9 +3929,42 @@ def _complete_hydrated_anchor_scene(messages, scene, message_index, *, message_o for key in ("tool_calls", "_partial_tool_calls"): calls = message.get(key) if isinstance(calls, list): - for call in calls: - push(_anchor_scene_tool_row(call, order, absolute_idx, stream_id)) + for tool_ordinal, call in enumerate(calls): + row = _anchor_scene_tool_row(call, order, absolute_idx, stream_id) + content_match_index = _anchor_scene_matching_content_tool_row_index( + rows, + content_tool_indexes, + row, + tool_ordinal, + used_content_tool_indexes, + len(calls), + id_flexible_content_tool_indexes, + ) + if content_match_index is not None: + if _anchor_scene_tool_rows_have_different_explicit_ids( + rows[content_match_index], + row, + ): + id_flexible_content_tool_indexes.add(content_match_index) + rows[content_match_index] = merge_duplicate_tool_row(rows[content_match_index], row) + incoming_key = _anchor_scene_row_key(row) + if incoming_key: + seen[incoming_key] = content_match_index + used_content_tool_indexes.add(content_match_index) + order += 1 + continue + push(row) order += 1 + external_tool_counts = {} + for call in tool_calls or []: + if not isinstance(call, dict): + continue + try: + absolute_idx = int(call.get("assistant_msg_idx")) + except (TypeError, ValueError): + continue + external_tool_counts[absolute_idx] = external_tool_counts.get(absolute_idx, 0) + 1 + external_tool_ordinals = {} for call in tool_calls or []: if not isinstance(call, dict): continue @@ -3461,9 +3973,35 @@ def _complete_hydrated_anchor_scene(messages, scene, message_index, *, message_o except (TypeError, ValueError): continue local_idx = absolute_idx - int(message_offset or 0) - if not (turn_start < local_idx < local_final_idx): + if not (turn_start < local_idx <= local_final_idx): continue - push(_anchor_scene_tool_row(call, order, absolute_idx, stream_id)) + row = _anchor_scene_tool_row(call, order, absolute_idx, stream_id) + tool_ordinal = external_tool_ordinals.get(absolute_idx, 0) + external_tool_ordinals[absolute_idx] = tool_ordinal + 1 + content_match_index = _anchor_scene_matching_content_tool_row_index( + rows, + content_tool_indexes_by_idx.get(absolute_idx, []), + row, + tool_ordinal, + used_content_tool_indexes_by_idx.setdefault(absolute_idx, set()), + external_tool_counts.get(absolute_idx, 0), + id_flexible_content_tool_indexes_by_idx.setdefault(absolute_idx, set()), + ) + if content_match_index is not None: + if _anchor_scene_tool_rows_have_different_explicit_ids(rows[content_match_index], row): + id_flexible_content_tool_indexes_by_idx.setdefault(absolute_idx, set()).add(content_match_index) + rows[content_match_index] = merge_duplicate_tool_row( + rows[content_match_index], + row, + prefer_incoming_body=True, + ) + incoming_key = _anchor_scene_row_key(row) + if incoming_key: + seen[incoming_key] = content_match_index + used_content_tool_indexes_by_idx[absolute_idx].add(content_match_index) + order += 1 + continue + push(row, prefer_incoming_tool_body=True) order += 1 for row in scene.get("activity_rows") or []: if isinstance(row, dict) and row.get("role") != "terminal": diff --git a/api/session_recovery.py b/api/session_recovery.py index 6dce748a..e806ecf0 100644 --- a/api/session_recovery.py +++ b/api/session_recovery.py @@ -65,6 +65,52 @@ def _msg_count(p: Path) -> int: return len(msgs) if isinstance(msgs, list) else -1 +def _rebuild_recovery_session_index(session_dir: Path) -> None: + """Rebuild ``session_dir/_index.json`` from persisted sidecars only. + + Recovery repair/audit operates on a concrete sidecar directory. Unlike the + live sidebar path, its rebuilt index must not include unrelated in-memory + ``Session`` cache entries whose backing JSON files are absent from this + directory; those would immediately audit as ``index_missing_file`` rows. + """ + from api.models import _load_session_from_path + + entry_map: dict[str, dict] = {} + for path in sorted(session_dir.glob('*.json')): + if path.name.startswith('_'): + continue + session = _load_session_from_path(path) + if not session: + continue + entry = session.compact() + session_id = entry.get('session_id') + if not session_id: + continue + existing = entry_map.get(session_id) + if existing is None or entry.get('message_count', 0) > existing.get('message_count', 0): + entry_map[session_id] = entry + + entries = sorted( + entry_map.values(), + key=lambda entry: entry.get('updated_at', 0), + reverse=True, + ) + index_path = session_dir / '_index.json' + tmp = index_path.with_suffix(f'.tmp.recovery.{os.getpid()}.{threading.current_thread().ident}') + try: + with open(tmp, 'w', encoding='utf-8') as fh: + fh.write(json.dumps(entries, ensure_ascii=False, indent=2)) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, index_path) + except Exception: + try: + tmp.unlink(missing_ok=True) + except Exception: + pass + raise + + def _session_records_intentional_compress_shrink(session_path: Path) -> bool: """Return True when the live sidecar records an intentional context shrink. @@ -669,8 +715,7 @@ def repair_safe_session_recovery(session_dir: Path, state_db_path: Path | None = sidecar_repair = recover_missing_sidecars_from_state_db(session_dir, state_db_path) if sidecar_repair.get('materialized'): try: - from api.models import _write_session_index - _write_session_index(updates=None) + _rebuild_recovery_session_index(session_dir) except Exception as exc: logger.warning("repair_safe_session_recovery: index rebuild after state.db reconciliation failed: %s", exc) after = audit_session_recovery(session_dir, state_db_path=state_db_path) @@ -734,9 +779,8 @@ def recover_all_sessions_on_startup( ) if rebuild_index: try: - from api.models import SESSION_INDEX_FILE, _write_session_index - if restored or not SESSION_INDEX_FILE.exists(): - _write_session_index(updates=None) + if restored or not (session_dir / '_index.json').exists(): + _rebuild_recovery_session_index(session_dir) except Exception as exc: logger.warning("recover_all_sessions_on_startup: index rebuild failed: %s", exc) return { diff --git a/docs/architecture/stable-assistant-turn-anchor-phase0.md b/docs/architecture/stable-assistant-turn-anchor-phase0.md index c5c15f0c..53016d22 100644 --- a/docs/architecture/stable-assistant-turn-anchor-phase0.md +++ b/docs/architecture/stable-assistant-turn-anchor-phase0.md @@ -281,9 +281,19 @@ 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. +state. The original live Compact Worklog handoff did not by itself cover +Transparent Stream or durable reload; those require explicit settled-scene +persistence or read-side hydration slices. + +Settled mixed `content[]` assistant messages now bridge into the same +`activity_scene_v1` ownership. When the final assistant message interleaves text +parts with `tool_use` parts, settlement and read-side hydration promote those +ordered parts into anchor activity rows. The final answer remains the text after +the last tool use on the final assistant message, while earlier text, non-final +post-tool process text, thinking rows, and tool rows stay in chronological +activity order for Compact Worklog and Transparent Stream renderers. The +Transparent Stream raw `content[]` helper remains a fallback for settled +messages that do not yet carry `_anchor_activity_scene`. ## Source Event Classification diff --git a/static/messages.js b/static/messages.js index 0d4a41ec..ce1c0382 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2584,6 +2584,38 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } return typeof content==='string'?content:String(content||''); } + function _anchorSceneContentText(part){ + if(part===undefined||part===null) return ''; + if(typeof part==='string') return part; + if(typeof part!=='object') return String(part||''); + return String(part.text||part.content||part.input_text||part.output_text||part.thinking||part.reasoning||part.summary||''); + } + function _anchorSceneContentVisibleText(part){ + if(part===undefined||part===null) return ''; + if(typeof part==='string') return part; + if(typeof part!=='object') return String(part||''); + const partType=String(part.type||''); + if(partType==='thinking'||partType==='reasoning') return ''; + const contentText=(partType==='text'||partType==='input_text'||partType==='output_text')?part.content:''; + return String(part.text||part.input_text||part.output_text||contentText||''); + } + function _anchorSceneMessageHasContentToolUse(message){ + return !!(message&&Array.isArray(message.content)&&message.content.some(part=>part&&typeof part==='object'&&part.type==='tool_use')); + } + function _anchorSceneFinalAnswerText(message){ + if(!_anchorSceneMessageHasContentToolUse(message)) return _anchorSceneMessageText(message); + const content=Array.isArray(message.content)?message.content:[]; + let lastToolIndex=-1; + for(let i=0;i_anchorSceneContentVisibleText(part)) + .filter(text=>_anchorSceneCleanText(text)) + .join('\n'); + return _anchorSceneCleanText(tailText)?tailText:''; + } function _anchorSceneCleanText(value){ return String(value||'').replace(/\s+/g,' ').trim(); } @@ -2619,6 +2651,31 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } return {}; } + function _anchorSceneContentTool(part){ + if(!part||typeof part!=='object') return {}; + const fn=part.function&&typeof part.function==='object'?part.function:{}; + return { + id:part.id||part.tid||part.tool_call_id||part.tool_use_id||part.call_id, + tid:part.tid||part.id||part.tool_call_id||part.tool_use_id||part.call_id, + tool_call_id:part.tool_call_id, + tool_use_id:part.tool_use_id, + call_id:part.call_id, + name:part.name||part.tool_name||fn.name||'tool', + tool_name:part.tool_name, + args:part.args, + input:part.input, + function:part.function, + command:part.command||part.raw_command||part.original_command||part.display_command, + preview:part.preview||part.summary, + snippet:part.snippet||part.result||part.output, + result:part.result, + output:part.output, + is_error:part.is_error, + error:part.error, + duration:part.duration, + started_at:part.started_at, + }; + } function _anchorSceneStringPayload(value){ if(value===undefined||value===null) return ''; if(typeof value==='string') return value; @@ -2721,10 +2778,208 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ }; return row; } + function _anchorSceneToolRowName(row){ + const tool=row&&row.tool&&typeof row.tool==='object'?row.tool:{}; + const payload=row&&row.payload&&typeof row.payload==='object'?row.payload:{}; + return String(tool.name||payload.name||'tool').trim().toLowerCase(); + } + function _anchorSceneToolRowId(row){ + const tool=row&&row.tool&&typeof row.tool==='object'?row.tool:{}; + const payload=row&&row.payload&&typeof row.payload==='object'?row.payload:{}; + return String( + (row&&row.tool_call_id)|| + tool.id|| + tool.tid|| + tool.tool_call_id|| + tool.tool_use_id|| + tool.call_id|| + payload.tid|| + payload.id|| + '' + ).trim(); + } + function _anchorSceneToolRowsHaveNonConflictingIds(existing, incoming){ + const existingId=_anchorSceneToolRowId(existing); + const incomingId=_anchorSceneToolRowId(incoming); + return !existingId||!incomingId||existingId===incomingId; + } + function _anchorSceneToolRowsHaveDifferentExplicitIds(existing, incoming){ + const existingId=_anchorSceneToolRowId(existing); + const incomingId=_anchorSceneToolRowId(incoming); + return !!existingId&&!!incomingId&&existingId!==incomingId; + } + function _anchorSceneToolRowStartedAt(row){ + const tool=row&&row.tool&&typeof row.tool==='object'?row.tool:{}; + const payload=row&&row.payload&&typeof row.payload==='object'?row.payload:{}; + const value=tool.started_at!==undefined&&tool.started_at!==null&&tool.started_at!==''?tool.started_at:payload.started_at; + return value!==undefined&&value!==null&&value!==''?String(value):''; + } + function _anchorSceneToolRowsHaveSameStartedAt(existing, incoming){ + const existingStartedAt=_anchorSceneToolRowStartedAt(existing); + const incomingStartedAt=_anchorSceneToolRowStartedAt(incoming); + return !!existingStartedAt&&!!incomingStartedAt&&existingStartedAt===incomingStartedAt; + } + function _anchorSceneToolRowBodyText(row){ + const tool=row&&row.tool&&typeof row.tool==='object'?row.tool:{}; + const payload=row&&row.payload&&typeof row.payload==='object'?row.payload:{}; + for(const value of [tool.snippet,payload.snippet,tool.output,payload.output,tool.result,payload.result,tool.preview,payload.preview]){ + const text=_anchorSceneStringPayload(value).trim(); + if(text) return text; + } + return ''; + } + function _anchorSceneToolRowsHaveCompatibleBody(existing, incoming){ + const existingBody=_anchorSceneToolRowBodyText(existing); + const incomingBody=_anchorSceneToolRowBodyText(incoming); + return !!existingBody&&!!incomingBody&&( + existingBody===incomingBody|| + existingBody.startsWith(incomingBody)|| + incomingBody.startsWith(existingBody) + ); + } + function _anchorSceneToolRowsHaveCompatibleNames(existing, incoming){ + const existingName=_anchorSceneToolRowName(existing); + const incomingName=_anchorSceneToolRowName(incoming); + return !existingName||!incomingName||existingName==='tool'||incomingName==='tool'||existingName===incomingName; + } + function _anchorSceneToolRowArgs(row){ + const tool=row&&row.tool&&typeof row.tool==='object'?row.tool:{}; + const payload=row&&row.payload&&typeof row.payload==='object'?row.payload:{}; + const args=(tool.args&&typeof tool.args==='object'&&!Array.isArray(tool.args))?tool.args:payload.args; + return args&&typeof args==='object'&&!Array.isArray(args)?args:null; + } + function _anchorSceneObjectContainsSubset(base, subset){ + if(!base||!subset||typeof base!=='object'||typeof subset!=='object') return false; + const stableStringify=(candidate)=>{ + const normalize=(value)=>{ + if(!value||typeof value!=='object') return value; + if(Array.isArray(value)) return value.map(normalize); + const normalized={}; + Object.keys(value).sort().forEach((key)=>{normalized[key]=normalize(value[key]);}); + return normalized; + }; + try{return JSON.stringify(normalize(candidate));}catch(_){return JSON.stringify(candidate);} + }; + for(const [key,value] of Object.entries(subset)){ + if(!Object.prototype.hasOwnProperty.call(base,key)) return false; + if(stableStringify(base[key])!==stableStringify(value)) return false; + } + return true; + } + function _anchorSceneToolRowsHaveCompatibleInvocation(existing, incoming){ + const existingTool=existing&&existing.tool&&typeof existing.tool==='object'?existing.tool:{}; + const incomingTool=incoming&&incoming.tool&&typeof incoming.tool==='object'?incoming.tool:{}; + const existingPayload=existing&&existing.payload&&typeof existing.payload==='object'?existing.payload:{}; + const incomingPayload=incoming&&incoming.payload&&typeof incoming.payload==='object'?incoming.payload:{}; + const existingCommand=_anchorSceneStringPayload(existingTool.command||existingPayload.command).trim(); + const incomingCommand=_anchorSceneStringPayload(incomingTool.command||incomingPayload.command).trim(); + if(existingCommand&&incomingCommand) return existingCommand===incomingCommand; + const existingArgs=_anchorSceneToolRowArgs(existing); + const incomingArgs=_anchorSceneToolRowArgs(incoming); + if(!existingArgs||!incomingArgs||!Object.keys(existingArgs).length||!Object.keys(incomingArgs).length) return false; + return _anchorSceneObjectContainsSubset(existingArgs,incomingArgs)||_anchorSceneObjectContainsSubset(incomingArgs,existingArgs); + } + function _anchorSceneToolRowHasInvocationEvidence(row){ + const tool=row&&row.tool&&typeof row.tool==='object'?row.tool:{}; + const payload=row&&row.payload&&typeof row.payload==='object'?row.payload:{}; + const command=_anchorSceneStringPayload(tool.command||payload.command).trim(); + const args=_anchorSceneToolRowArgs(row); + return !!command||!!(args&&Object.keys(args).length); + } + function _anchorSceneToolRowsCanNameMatch(existing, incoming){ + if(!_anchorSceneToolRowsHaveCompatibleNames(existing,incoming)) return false; + if(_anchorSceneToolRowHasInvocationEvidence(existing)&&_anchorSceneToolRowHasInvocationEvidence(incoming)){ + return _anchorSceneToolRowsHaveCompatibleInvocation(existing,incoming); + } + return true; + } + function _anchorSceneMatchingContentToolRow(contentToolRows, incomingRow, ordinal, usedRows, incomingTotal, idFlexibleRows){ + if(!Array.isArray(contentToolRows)||!incomingRow) return null; + const incomingTid=incomingRow.tool_call_id||(incomingRow.tool&&incomingRow.tool.id); + for(const row of contentToolRows){ + if(!row||usedRows.has(row)) continue; + const tid=row.tool_call_id||(row.tool&&row.tool.id); + if(tid&&incomingTid&&tid===incomingTid) return row; + } + if(contentToolRows.length===1&&Number(incomingTotal)===1){ + const onlyRow=contentToolRows[0]; + if(onlyRow&&!usedRows.has(onlyRow)&&_anchorSceneToolRowsCanNameMatch(onlyRow,incomingRow)) return onlyRow; + } + const availableRows=contentToolRows.filter(row=>row&&!usedRows.has(row)); + if(availableRows.length===1){ + if(Number(incomingTotal)===1&&_anchorSceneToolRowsCanNameMatch(availableRows[0],incomingRow)) return availableRows[0]; + if( + _anchorSceneToolRowsHaveCompatibleNames(availableRows[0],incomingRow)&& + _anchorSceneToolRowsHaveCompatibleInvocation(availableRows[0],incomingRow) + ) return availableRows[0]; + } + const reusableRows=contentToolRows.filter(row=>row&&usedRows.has(row)); + if( + reusableRows.length===1&& + Number(incomingTotal)===1&& + ( + ( + _anchorSceneToolRowId(reusableRows[0])&& + _anchorSceneToolRowId(incomingRow)&& + _anchorSceneToolRowId(reusableRows[0])===_anchorSceneToolRowId(incomingRow) + )|| + ( + idFlexibleRows&& + idFlexibleRows.has(reusableRows[0])&& + _anchorSceneToolRowsHaveSameStartedAt(reusableRows[0],incomingRow)&& + _anchorSceneToolRowsHaveCompatibleBody(reusableRows[0],incomingRow) + ) + )&& + _anchorSceneToolRowsHaveCompatibleNames(reusableRows[0],incomingRow)&& + _anchorSceneToolRowsHaveCompatibleInvocation(reusableRows[0],incomingRow) + ) return reusableRows[0]; + for(const row of contentToolRows){ + if(!row||usedRows.has(row)) continue; + const tid=row.tool_call_id||(row.tool&&row.tool.id); + if(!tid&&!incomingTid&&_anchorSceneToolRowsCanNameMatch(row,incomingRow)) return row; + } + return null; + } function _anchorSceneMessageReasoningText(message){ if(!message||typeof message!=='object') return ''; return String(message.reasoning||message._reasoning||message.reasoning_content||message.thinking||''); } + function _anchorSceneRowsFromContentParts(message, messageIndex, options){ + if(!_anchorSceneMessageHasContentToolUse(message)) return null; + options=(options&&typeof options==='object')?options:{}; + const isFinalMessage=!!options.isFinalMessage; + const rows=[]; + const content=Array.isArray(message.content)?message.content:[]; + let lastToolIndex=-1; + for(let i=0;ilastToolIndex) continue; + const text=_anchorSceneContentText(part); + if(_anchorSceneCleanText(text)) rows.push(_anchorSceneProseRow(text,rows.length,messageIndex)); + continue; + } + if(part.type==='text'||part.type==='input_text'||part.type==='output_text'){ + if(isFinalMessage&&i>lastToolIndex&&_anchorSceneContentVisibleText(part)) continue; + const text=_anchorSceneContentText(part); + if(_anchorSceneCleanText(text)) rows.push(_anchorSceneProseRow(text,rows.length,messageIndex)); + continue; + } + if(part.type==='thinking'||part.type==='reasoning'){ + const text=_anchorSceneContentText(part); + if(_anchorSceneCleanText(text)) rows.push(_anchorSceneThinkingRow(text,rows.length,messageIndex)); + continue; + } + if(part.type==='tool_use'){ + rows.push(_anchorSceneToolRowFromCall(_anchorSceneContentTool(part),rows.length,messageIndex)); + } + } + return rows; + } // #4622: a settled tool row built from messages[].tool_calls (state.db/sidecar) // can lack the result body — terminal stdout, or the diff/output that a // patch/edit card renders — because the persisted row carries only a short @@ -2768,14 +3023,31 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(liveCommand&&_empty(tool.command)&&_empty(payload.command)){ tool.command=liveCommand; payload.command=liveCommand; enriched=true; } - const liveArgs=(live.args&&typeof live.args==='object')?live.args:null; - const argsEmpty=o=>!o||typeof o!=='object'||Object.keys(o).length===0; - if(liveArgs&&argsEmpty(tool.args)&&argsEmpty(payload.args)){ - tool.args={...liveArgs}; payload.args={...liveArgs}; enriched=true; + if(!_empty(live.started_at)&&_empty(tool.started_at)&&_empty(payload.started_at)){ + tool.started_at=live.started_at; payload.started_at=live.started_at; enriched=true; + } + const liveArgs=_anchorSceneToolArgs(live); + if(liveArgs&&typeof liveArgs==='object'&&Object.keys(liveArgs).length){ + const mergeMissingArgs=(existing)=>{ + const base=(existing&&typeof existing==='object'&&!Array.isArray(existing))?{...existing}:{}; + let changed=!(existing&&typeof existing==='object'&&!Array.isArray(existing)); + for(const [key,value] of Object.entries(liveArgs)){ + if(!Object.prototype.hasOwnProperty.call(base,key)){ + base[key]=value; + changed=true; + } + } + return changed?base:existing; + }; + const nextToolArgs=mergeMissingArgs(tool.args); + const nextPayloadArgs=mergeMissingArgs(payload.args); + if(nextToolArgs!==tool.args){ tool.args=nextToolArgs; enriched=true; } + if(nextPayloadArgs!==payload.args){ payload.args=nextPayloadArgs; enriched=true; } } return enriched; } - function _anchorSceneRowsByMessageIndex(messages, turnStart, lastAsstIndex){ + function _anchorSceneRowsByMessageIndex(messages, turnStart, lastAsstIndex, options){ + options=(options&&typeof options==='object')?options:{}; const byIdx=new Map(); const add=(idx,row)=>{ if(!byIdx.has(idx)) byIdx.set(idx,[]); @@ -2791,12 +3063,27 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } } let encounter=0; - for(let idx=turnStart+1;idx0; + const contentToolRows=[]; + const usedContentToolRows=new Set(); + const idFlexibleContentToolRows=new Set(); + const seenToolIds=new Set(); + const rowByToolId=new Map(); + if(hasOrderedContentRows){ + for(const row of contentRows){ + pool.push({...row,_phase:1,_encounter:encounter++,_fromContent:true}); + const tid=row.tool_call_id||(row.tool&&row.tool.id); + if(tid){ seenToolIds.add(tid); rowByToolId.set(tid,row); } + if(row.role==='tool') contentToolRows.push(row); + } + }else if(_anchorSceneCleanText(text)){ pool.push({..._anchorSceneProseRow(text,0,idx),_phase:2,_encounter:encounter++}); } const reasoning=_anchorSceneMessageReasoningText(message); @@ -2806,19 +3093,38 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ 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); - const seenToolIds=new Set(); - const rowByToolId=new Map(); + let messageToolOrdinal=0; for(const tool of messageTools){ const row=_anchorSceneToolRowFromCall(tool,0,idx); - pool.push({...row,_phase:1,_encounter:encounter++}); const tid=row.tool_call_id||(row.tool&&row.tool.id); + if(tid&&seenToolIds.has(tid)){ + const existing=rowByToolId.get(tid); + if(existing){ + _enrichSettledToolRowBodyFromLive(existing, tool); + if(contentToolRows.includes(existing)) usedContentToolRows.add(existing); + } + messageToolOrdinal+=1; + continue; + } + const contentMatch=_anchorSceneMatchingContentToolRow(contentToolRows,row,messageToolOrdinal,usedContentToolRows,messageTools.length,idFlexibleContentToolRows); + if(contentMatch){ + if(_anchorSceneToolRowsHaveDifferentExplicitIds(contentMatch,row)) idFlexibleContentToolRows.add(contentMatch); + _enrichSettledToolRowBodyFromLive(contentMatch, tool); + if(tid){ seenToolIds.add(tid); rowByToolId.set(tid,contentMatch); } + usedContentToolRows.add(contentMatch); + messageToolOrdinal+=1; + continue; + } + pool.push({...row,_phase:1,_encounter:encounter++}); if(tid){ seenToolIds.add(tid); rowByToolId.set(tid,row); } + messageToolOrdinal+=1; } // Merge S.toolCalls for this index, dedup by tool id. When a live call // matches a settled row already in the pool, don't just skip it — // restore any result body the settled row is missing (#4622): the live // S.toolCalls entry carries the full terminal output / patch diff that the // persisted state.db row may have dropped to a short preview or nothing. + let liveToolOrdinal=0; for(const tool of (toolsByIdx.get(idx)||[])){ if(!tool||typeof tool!=='object') continue; const toolIdx=Number(tool.assistant_msg_idx); @@ -2827,18 +3133,37 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const tid=row.tool_call_id||(row.tool&&row.tool.id); if(tid&&seenToolIds.has(tid)){ const existing=rowByToolId.get(tid); - if(existing) _enrichSettledToolRowBodyFromLive(existing, tool); + if(existing){ + _enrichSettledToolRowBodyFromLive(existing, tool); + if(contentToolRows.includes(existing)) usedContentToolRows.add(existing); + } + liveToolOrdinal+=1; + continue; + } + const liveTools=toolsByIdx.get(idx)||[]; + const contentMatch=_anchorSceneMatchingContentToolRow(contentToolRows,row,liveToolOrdinal,usedContentToolRows,liveTools.length,idFlexibleContentToolRows); + if(contentMatch){ + if(_anchorSceneToolRowsHaveDifferentExplicitIds(contentMatch,row)) idFlexibleContentToolRows.add(contentMatch); + _enrichSettledToolRowBodyFromLive(contentMatch, tool); + if(tid){ seenToolIds.add(tid); rowByToolId.set(tid,contentMatch); } + usedContentToolRows.add(contentMatch); + liveToolOrdinal+=1; continue; } if(tid){ seenToolIds.add(tid); rowByToolId.set(tid,row); } pool.push({...row,_phase:1,_encounter:encounter++}); + liveToolOrdinal+=1; } - // Stable sort by (phase, started_at, encounter) + // Stable sort by (phase, started_at, encounter). Once a message has an + // ordered content[] scene, preserve that content bucket order exactly. + const useStartedAt=!hasOrderedContentRows; pool.sort((a,b)=>{ if(a._phase!==b._phase) return a._phase-b._phase; - const aTime=(a.tool&&a.tool.started_at!=null)?a.tool.started_at:Infinity; - const bTime=(b.tool&&b.tool.started_at!=null)?b.tool.started_at:Infinity; - if(aTime!==bTime) return aTime-bTime; + if(useStartedAt){ + const aTime=(a.tool&&a.tool.started_at!=null)?a.tool.started_at:Infinity; + const bTime=(b.tool&&b.tool.started_at!=null)?b.tool.started_at:Infinity; + if(aTime!==bTime) return aTime-bTime; + } return a._encounter-b._encounter; }); // Emit with sequential order_index values, strip temp props. @@ -2848,7 +3173,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // (no tool id) at the same message index don't collide on the same row_id // and get silently deduped by _completeSettledAnchorSceneForTurn(). for(const row of pool){ - const {_phase,_encounter,...clean}=row; + const {_phase,_encounter,_fromContent,...clean}=row; const oi=byIdx.has(idx)?byIdx.get(idx).length:0; clean.order_index=oi; clean.seq=oi; @@ -2956,10 +3281,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ break; } } - const finalAnswer=_anchorSceneMessageText(lastAsst); - const finalKey=_anchorSceneTextKey(finalAnswer); const base=(projectedScene&&typeof projectedScene==='object')?projectedScene:{}; - const messageRows=_anchorSceneRowsByMessageIndex(messages,turnStart,lastAsstIndex); + const messageFinalAnswer=_anchorSceneFinalAnswerText(lastAsst); + const finalAnswer=_anchorSceneCleanText(messageFinalAnswer) + ? messageFinalAnswer + : (typeof base.final_answer==='string'?base.final_answer:''); + const finalKey=_anchorSceneTextKey(finalAnswer); + const messageRows=_anchorSceneRowsByMessageIndex(messages,turnStart,lastAsstIndex,{includeFinal:true}); const hasSettledThinking=_anchorSceneMessageRowsHaveThinking(messageRows); const rows=[]; const seen=new Set(); @@ -2983,7 +3311,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(row&&row.role==='terminal') continue; pushRow(row); } - for(let idx=turnStart+1;idx_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_answer:_anchorSceneCleanText(finalAnswer)?finalAnswer:'', final_message_ref:_anchorSceneMessageRef(lastAsst), turn_duration:_anchorSceneTurnDurationForSettlement(lastAsst,base), terminal_state:base.terminal_state||((base.lifecycle&&base.lifecycle.terminal_state)||null), diff --git a/tests/test_anchor_scene_persistence.py b/tests/test_anchor_scene_persistence.py index 5b1a9fbf..486b839b 100644 --- a/tests/test_anchor_scene_persistence.py +++ b/tests/test_anchor_scene_persistence.py @@ -696,6 +696,1169 @@ def test_anchor_scene_hydration_backfills_turn_duration_from_final_message(): assert hydrated[1]["_anchor_activity_scene"]["turn_duration"] == 731.2 +def test_anchor_scene_hydration_promotes_final_content_array_tool_use_to_ordered_rows(): + from api import routes + + messages = [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "content": [ + "Let me inspect the files first.", + {"type": "tool_use", "tool_use_id": "toolu_content", "tool_name": "grep", "args": {"pattern": "TODO"}}, + "Found it,", + {"type": "text", "text": "here's the fix."}, + ], + "tool_calls": [ + { + "id": "toolu_message", + "name": "grep", + "input": {"pattern": "TODO"}, + "snippet": "TODO in static/messages.js", + "started_at": 100, + } + ], + }, + ] + 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": "", + "activity_rows": [ + {"row_id": "done", "role": "terminal", "kind": "terminal_status", "source_event_type": "done"} + ], + }, + } + } + + hydrated = routes._hydrate_anchor_activity_scenes( + messages, + records, + tool_calls=[ + { + "assistant_msg_idx": 1, + "tid": "toolu_durable", + "name": "grep", + "input": {"pattern": "TODO"}, + "snippet": "TODO in static/messages.js", + "started_at": 100, + } + ], + ) + + scene = hydrated[1]["_anchor_activity_scene"] + rows = scene["activity_rows"] + activity = [ + (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"] == "Found it,\nhere's the fix." + assert activity == [ + ("prose", "Let me inspect the files first."), + ("tool", "toolu_content"), + ] + assert rows[1]["tool"]["name"] == "grep" + assert rows[1]["tool"]["args"] == {"pattern": "TODO"} + assert rows[1]["tool"]["snippet"] == "TODO in static/messages.js" + assert len([row for row in rows if row.get("role") == "tool"]) == 1 + assert rows[-1]["role"] == "terminal" + + +def test_anchor_scene_hydration_preserves_non_final_post_tool_text(): + from api import routes + + messages = [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "content": [ + "I will inspect first.", + {"type": "tool_use", "tool_use_id": "toolu_content", "tool_name": "grep"}, + {"type": "text", "text": "I found the relevant file."}, + {"type": "thinking", "text": "Need one more check."}, + ], + }, + {"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": [], + }, + } + } + + hydrated = routes._hydrate_anchor_activity_scenes(messages, records, tool_calls=[]) + + scene = hydrated[2]["_anchor_activity_scene"] + rows = scene["activity_rows"] + activity = [ + (row.get("role"), row.get("text") or row.get("tool_call_id")) + for row in rows + ] + + assert scene["final_answer"] == "final answer" + assert activity == [ + ("prose", "I will inspect first."), + ("tool", "toolu_content"), + ("prose", "I found the relevant file."), + ("thinking", "Need one more check."), + ] + + +def test_anchor_scene_hydration_keeps_final_tail_thinking_as_activity_only(): + from api import routes + + messages = [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "content": [ + {"type": "text", "text": "Let me check first."}, + { + "type": "tool_use", + "tool_use_id": "toolu_content", + "tool_name": "terminal", + "args": {"cmd": "ls"}, + }, + {"type": "thinking", "text": "Tail thinking must stay activity."}, + {"type": "reasoning", "reasoning": "Tail reasoning key must stay activity."}, + {"type": "text", "text": "Final visible answer."}, + ], + "tool_calls": [ + { + "id": "toolu_message", + "name": "terminal", + "args": {"cmd": "ls"}, + "snippet": "OUTPUT", + "started_at": 100, + } + ], + }, + ] + 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": "", + "activity_rows": [], + }, + } + } + + hydrated = routes._hydrate_anchor_activity_scenes(messages, records, tool_calls=[]) + + scene = hydrated[1]["_anchor_activity_scene"] + rows = scene["activity_rows"] + activity = [ + (row.get("role"), row.get("text") or row.get("tool_call_id")) + for row in rows + ] + + assert scene["final_answer"] == "Final visible answer." + assert activity == [ + ("prose", "Let me check first."), + ("tool", "toolu_content"), + ("thinking", "Tail thinking must stay activity."), + ("thinking", "Tail reasoning key must stay activity."), + ] + assert rows[1]["tool"]["snippet"] == "OUTPUT" + assert len([row for row in rows if row.get("role") == "tool"]) == 1 + + +def test_anchor_scene_hydration_promotes_output_text_content_tail_to_final_answer(): + from api import routes + + messages = [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "content": [ + {"type": "text", "text": "Let me check first."}, + { + "type": "tool_use", + "tool_use_id": "toolu_content", + "tool_name": "terminal", + "args": {"cmd": "ls"}, + }, + {"type": "output_text", "content": "Final answer from content field."}, + ], + "tool_calls": [ + { + "id": "toolu_message", + "name": "terminal", + "args": {"cmd": "ls"}, + "snippet": "OUTPUT", + "started_at": 100, + } + ], + }, + ] + 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": "", + "activity_rows": [], + }, + } + } + + hydrated = routes._hydrate_anchor_activity_scenes(messages, records, tool_calls=[]) + + scene = hydrated[1]["_anchor_activity_scene"] + rows = scene["activity_rows"] + activity = [ + (row.get("role"), row.get("text") or row.get("tool_call_id")) + for row in rows + ] + + assert scene["final_answer"] == "Final answer from content field." + assert activity == [ + ("prose", "Let me check first."), + ("tool", "toolu_content"), + ] + assert rows[1]["tool"]["snippet"] == "OUTPUT" + assert len([row for row in rows if row.get("role") == "tool"]) == 1 + + +def test_anchor_scene_hydration_restores_durable_body_after_message_tool_merge(): + from api import routes + + full_output = "X" * 9000 + capped_preview = full_output[:4000] + messages = [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "content": [ + "I will inspect first.", + { + "type": "tool_use", + "tool_use_id": "toolu_content", + "tool_name": "terminal", + "args": {"cmd": "long-output"}, + }, + "Done.", + ], + "tool_calls": [ + { + "id": "toolu_message", + "name": "terminal", + "input": {"cmd": "long-output"}, + "snippet": capped_preview, + "started_at": 100, + } + ], + }, + ] + 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": "", + "activity_rows": [], + }, + } + } + + hydrated = routes._hydrate_anchor_activity_scenes( + messages, + records, + tool_calls=[ + { + "assistant_msg_idx": 1, + "tid": "toolu_durable", + "name": "terminal", + "input": {"cmd": "long-output"}, + "snippet": full_output, + "started_at": 100, + } + ], + ) + + tools = [row for row in hydrated[1]["_anchor_activity_scene"]["activity_rows"] if row.get("role") == "tool"] + assert len(tools) == 1 + assert tools[0]["tool_call_id"] == "toolu_content" + assert tools[0]["tool"]["snippet"] == full_output + assert tools[0]["payload"]["snippet"] == full_output + + +def test_anchor_scene_hydration_keeps_third_same_command_id_distinct_after_alt_id_match(): + from api import routes + + messages = [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "content": [ + "First check.", + { + "type": "tool_use", + "tool_use_id": "content-a", + "tool_name": "terminal", + "args": {"cmd": "ls"}, + }, + "Second check.", + ], + "tool_calls": [ + { + "id": "message-a", + "name": "terminal", + "input": {"cmd": "ls"}, + "snippet": "OUTPUT A", + "started_at": 100, + } + ], + }, + ] + 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": "", + "activity_rows": [], + }, + } + } + + hydrated = routes._hydrate_anchor_activity_scenes( + messages, + records, + tool_calls=[ + { + "assistant_msg_idx": 1, + "tid": "durable-b", + "name": "terminal", + "input": {"cmd": "ls"}, + "snippet": "OUTPUT B", + "started_at": 200, + } + ], + ) + + tools = [row for row in hydrated[1]["_anchor_activity_scene"]["activity_rows"] if row.get("role") == "tool"] + by_id = {row.get("tool_call_id"): row for row in tools} + + assert by_id["content-a"]["tool"]["snippet"] == "OUTPUT A" + assert "message-a" not in by_id + assert by_id["durable-b"]["tool"]["snippet"] == "OUTPUT B" + assert len(tools) == 2 + + +def test_anchor_scene_hydration_keeps_identical_output_repeat_distinct_after_alt_id_match(): + from api import routes + + messages = [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "content": [ + "First check.", + { + "type": "tool_use", + "tool_use_id": "content-a", + "tool_name": "terminal", + "args": {"cmd": "ls"}, + }, + "Second check.", + ], + "tool_calls": [ + { + "id": "message-a", + "name": "terminal", + "input": {"cmd": "ls"}, + "snippet": "SAME OUTPUT", + } + ], + }, + ] + 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": "", + "activity_rows": [], + }, + } + } + + hydrated = routes._hydrate_anchor_activity_scenes( + messages, + records, + tool_calls=[ + { + "assistant_msg_idx": 1, + "tid": "durable-b", + "name": "terminal", + "input": {"cmd": "ls"}, + "snippet": "SAME OUTPUT", + } + ], + ) + + tools = [row for row in hydrated[1]["_anchor_activity_scene"]["activity_rows"] if row.get("role") == "tool"] + by_id = {row.get("tool_call_id"): row for row in tools} + + assert by_id["content-a"]["tool"]["snippet"] == "SAME OUTPUT" + assert "message-a" not in by_id + assert by_id["durable-b"]["tool"]["snippet"] == "SAME OUTPUT" + assert len(tools) == 2 + + +def test_anchor_scene_hydration_keeps_same_started_at_repeat_distinct_after_alt_id_match(): + from api import routes + + messages = [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "content": [ + "First check.", + { + "type": "tool_use", + "tool_use_id": "content-a", + "tool_name": "terminal", + "args": {"cmd": "ls"}, + }, + "Second check.", + ], + "tool_calls": [ + { + "id": "message-a", + "name": "terminal", + "input": {"cmd": "ls"}, + "snippet": "OUTPUT A", + "started_at": 100, + } + ], + }, + ] + 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": "", + "activity_rows": [], + }, + } + } + + hydrated = routes._hydrate_anchor_activity_scenes( + messages, + records, + tool_calls=[ + { + "assistant_msg_idx": 1, + "tid": "durable-b", + "name": "terminal", + "input": {"cmd": "ls"}, + "snippet": "OUTPUT B", + "started_at": 100, + } + ], + ) + + tools = [row for row in hydrated[1]["_anchor_activity_scene"]["activity_rows"] if row.get("role") == "tool"] + by_id = {row.get("tool_call_id"): row for row in tools} + + assert by_id["content-a"]["tool"]["snippet"] == "OUTPUT A" + assert "message-a" not in by_id + assert by_id["durable-b"]["tool"]["snippet"] == "OUTPUT B" + assert len(tools) == 2 + + +def test_anchor_scene_hydration_keeps_short_persisted_body_after_durable_merge(): + from api import routes + + full_output = "short output line\nwith more detail that came later" + short_body = "short output line" + messages = [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "content": [ + "I will inspect first.", + { + "type": "tool_use", + "tool_use_id": "toolu_content", + "tool_name": "terminal", + "args": {"cmd": "short-output"}, + }, + "Done.", + ], + "tool_calls": [ + { + "id": "toolu_message", + "name": "terminal", + "input": {"cmd": "short-output"}, + "snippet": short_body, + "started_at": 100, + } + ], + }, + ] + 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": "", + "activity_rows": [], + }, + } + } + + hydrated = routes._hydrate_anchor_activity_scenes( + messages, + records, + tool_calls=[ + { + "assistant_msg_idx": 1, + "tid": "toolu_durable", + "name": "terminal", + "input": {"cmd": "short-output"}, + "snippet": full_output, + "started_at": 100, + } + ], + ) + + tools = [row for row in hydrated[1]["_anchor_activity_scene"]["activity_rows"] if row.get("role") == "tool"] + assert len(tools) == 1 + assert tools[0]["tool"]["snippet"] == short_body + assert tools[0]["payload"]["snippet"] == short_body + + +def test_anchor_scene_hydration_merges_missing_args_after_content_tool_match(): + from api import routes + + messages = [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "content": [ + "I will patch the file.", + { + "type": "tool_use", + "tool_use_id": "patch-content", + "tool_name": "edit_file", + "args": {"path": "x.py"}, + }, + "Done.", + ], + "tool_calls": [ + { + "id": "patch-message", + "name": "edit_file", + "input": { + "path": "x.py", + "old_string": "old", + "new_string": "new", + }, + "snippet": "@@ -1 +1 @@\n-old\n+new", + } + ], + }, + ] + 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": "", + "activity_rows": [], + }, + } + } + + hydrated = routes._hydrate_anchor_activity_scenes(messages, records, tool_calls=[]) + + tools = [ + row + for row in hydrated[1]["_anchor_activity_scene"]["activity_rows"] + if row.get("role") == "tool" + ] + assert len(tools) == 1 + assert tools[0]["tool"]["args"] == { + "path": "x.py", + "old_string": "old", + "new_string": "new", + } + assert tools[0]["payload"]["args"] == { + "path": "x.py", + "old_string": "old", + "new_string": "new", + } + + +def test_anchor_scene_hydration_keeps_consumed_different_name_tool_distinct(): + from api import routes + + messages = [ + {"role": "user", "content": "edit then inspect"}, + { + "role": "assistant", + "content": [ + "Edit the file.", + { + "type": "tool_use", + "tool_use_id": "content-a", + "tool_name": "edit_file", + "args": {"path": "x.py"}, + }, + "Inspect it.", + ], + "tool_calls": [ + { + "id": "content-a", + "name": "edit_file", + "input": {"path": "x.py"}, + "snippet": "EDITED", + } + ], + }, + ] + 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": "", + "activity_rows": [], + }, + } + } + + hydrated = routes._hydrate_anchor_activity_scenes( + messages, + records, + tool_calls=[ + { + "assistant_msg_idx": 1, + "tid": "live-b", + "name": "terminal", + "input": {"path": "x.py"}, + "snippet": "TERMINAL OUTPUT", + } + ], + ) + + tools = [ + row + for row in hydrated[1]["_anchor_activity_scene"]["activity_rows"] + if row.get("role") == "tool" + ] + by_id = {row["tool"]["id"]: row for row in tools} + + assert by_id["content-a"]["tool"]["name"] == "edit_file" + assert by_id["content-a"]["tool"]["snippet"] == "EDITED" + assert by_id["live-b"]["tool"]["name"] == "terminal" + assert by_id["live-b"]["tool"]["snippet"] == "TERMINAL OUTPUT" + + +def test_anchor_scene_hydration_does_not_position_merge_ambiguous_different_id_tools(): + from api import routes + + messages = [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "content": [ + "First check.", + {"type": "tool_use", "tool_use_id": "content-a", "tool_name": "terminal"}, + "Second check.", + {"type": "tool_use", "tool_use_id": "content-b", "tool_name": "terminal"}, + "Done.", + ], + }, + ] + 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": "", + "activity_rows": [], + }, + } + } + + hydrated = routes._hydrate_anchor_activity_scenes( + messages, + records, + tool_calls=[ + {"assistant_msg_idx": 1, "tid": "message-b", "name": "terminal", "snippet": "OUTPUT B"}, + {"assistant_msg_idx": 1, "tid": "message-a", "name": "terminal", "snippet": "OUTPUT A"}, + ], + ) + + rows = hydrated[1]["_anchor_activity_scene"]["activity_rows"] + tools = [row for row in rows if row.get("role") == "tool"] + by_id = {row.get("tool_call_id"): row for row in tools} + + assert by_id["content-a"]["tool"]["snippet"] == "" + assert by_id["content-b"]["tool"]["snippet"] == "" + assert by_id["message-a"]["tool"]["snippet"] == "OUTPUT A" + assert by_id["message-b"]["tool"]["snippet"] == "OUTPUT B" + + +def test_anchor_scene_hydration_does_not_name_merge_remaining_same_name_tool_after_exact_match(): + from api import routes + + messages = [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "content": [ + "First check.", + {"type": "tool_use", "tool_use_id": "content-a", "tool_name": "terminal"}, + "Second check.", + {"type": "tool_use", "tool_use_id": "content-b", "tool_name": "terminal"}, + "Done.", + ], + "tool_calls": [ + {"id": "content-a", "name": "terminal", "snippet": "OUTPUT A"}, + {"id": "message-b", "name": "terminal", "snippet": "OUTPUT B"}, + ], + }, + ] + 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": "", + "activity_rows": [], + }, + } + } + + hydrated = routes._hydrate_anchor_activity_scenes(messages, records, tool_calls=[]) + + rows = hydrated[1]["_anchor_activity_scene"]["activity_rows"] + tools = [row for row in rows if row.get("role") == "tool"] + by_id = {row.get("tool_call_id"): row for row in tools} + + assert by_id["content-a"]["tool"]["snippet"] == "OUTPUT A" + assert by_id["content-b"]["tool"]["snippet"] == "" + assert by_id["message-b"]["tool"]["snippet"] == "OUTPUT B" + + +def test_anchor_scene_hydration_merges_remaining_matching_tool_after_exact_match(): + from api import routes + + messages = [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "content": [ + "First check.", + { + "type": "tool_use", + "tool_use_id": "content-a", + "tool_name": "terminal", + "args": {"cmd": "ls"}, + }, + "Second check.", + { + "type": "tool_use", + "tool_use_id": "content-b", + "tool_name": "terminal", + "args": {"cmd": "pwd"}, + }, + "Done.", + ], + "tool_calls": [ + {"id": "content-a", "name": "terminal", "input": {"cmd": "ls"}, "snippet": "OUTPUT A"}, + {"id": "message-b", "name": "terminal", "input": {"cmd": "pwd"}, "snippet": "OUTPUT B"}, + ], + }, + ] + 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": "", + "activity_rows": [], + }, + } + } + + hydrated = routes._hydrate_anchor_activity_scenes(messages, records, tool_calls=[]) + + rows = hydrated[1]["_anchor_activity_scene"]["activity_rows"] + tools = [row for row in rows if row.get("role") == "tool"] + by_id = {row.get("tool_call_id"): row for row in tools} + + assert by_id["content-a"]["tool"]["snippet"] == "OUTPUT A" + assert by_id["content-b"]["tool"]["snippet"] == "OUTPUT B" + assert "message-b" not in by_id + assert len(tools) == 2 + + +def test_anchor_scene_hydration_keeps_distinct_used_singleton_tool_call(): + from api import routes + + messages = [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "content": [ + "First check.", + { + "type": "tool_use", + "tool_use_id": "content-a", + "tool_name": "terminal", + "args": {"cmd": "ls"}, + }, + "Second check.", + ], + "tool_calls": [ + { + "id": "content-a", + "name": "terminal", + "input": {"cmd": "ls"}, + "snippet": "OUTPUT A", + } + ], + }, + ] + 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": "", + "activity_rows": [], + }, + } + } + + hydrated = routes._hydrate_anchor_activity_scenes( + messages, + records, + tool_calls=[ + { + "assistant_msg_idx": 1, + "tid": "durable-b", + "name": "terminal", + "input": {"cmd": "pwd"}, + "snippet": "OUTPUT B", + } + ], + ) + + rows = hydrated[1]["_anchor_activity_scene"]["activity_rows"] + tools = [row for row in rows if row.get("role") == "tool"] + by_id = {row.get("tool_call_id"): row for row in tools} + + assert by_id["content-a"]["tool"]["snippet"] == "OUTPUT A" + assert by_id["durable-b"]["tool"]["snippet"] == "OUTPUT B" + assert len(tools) == 2 + + +def test_anchor_scene_hydration_keeps_same_command_used_singleton_tool_distinct(): + from api import routes + + messages = [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "content": [ + "First check.", + { + "type": "tool_use", + "tool_use_id": "content-a", + "tool_name": "terminal", + "args": {"cmd": "ls"}, + }, + "Second check.", + ], + "tool_calls": [ + { + "id": "content-a", + "name": "terminal", + "input": {"cmd": "ls"}, + "snippet": "OUTPUT A", + } + ], + }, + ] + 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": "", + "activity_rows": [], + }, + } + } + + hydrated = routes._hydrate_anchor_activity_scenes( + messages, + records, + tool_calls=[ + { + "assistant_msg_idx": 1, + "tid": "durable-b", + "name": "terminal", + "input": {"cmd": "ls"}, + "snippet": "OUTPUT B", + } + ], + ) + + rows = hydrated[1]["_anchor_activity_scene"]["activity_rows"] + tools = [row for row in rows if row.get("role") == "tool"] + by_id = {row.get("tool_call_id"): row for row in tools} + + assert by_id["content-a"]["tool"]["snippet"] == "OUTPUT A" + assert by_id["durable-b"]["tool"]["snippet"] == "OUTPUT B" + assert len(tools) == 2 + + +def test_anchor_scene_hydration_keeps_anonymous_used_singleton_tool_distinct(): + from api import routes + + messages = [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "content": [ + "First check.", + { + "type": "tool_use", + "tool_use_id": "content-a", + "tool_name": "terminal", + "args": {"cmd": "ls"}, + }, + "Second check.", + ], + "tool_calls": [ + { + "id": "content-a", + "name": "terminal", + "input": {"cmd": "ls"}, + "snippet": "OUTPUT A", + } + ], + }, + ] + 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": "", + "activity_rows": [], + }, + } + } + + hydrated = routes._hydrate_anchor_activity_scenes( + messages, + records, + tool_calls=[ + { + "assistant_msg_idx": 1, + "name": "terminal", + "input": {"cmd": "ls"}, + "snippet": "OUTPUT B", + } + ], + ) + + rows = hydrated[1]["_anchor_activity_scene"]["activity_rows"] + tools = [row for row in rows if row.get("role") == "tool"] + snippets = sorted(row["tool"]["snippet"] for row in tools) + + assert len(tools) == 2 + assert snippets == ["OUTPUT A", "OUTPUT B"] + + +def test_anchor_scene_hydration_keeps_body_only_distinct_used_singleton_tool_call(): + from api import routes + + messages = [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "content": [ + "First check.", + { + "type": "tool_use", + "tool_use_id": "content-a", + "tool_name": "terminal", + "args": {"cmd": "ls"}, + }, + "Second check.", + ], + "tool_calls": [ + { + "id": "content-a", + "name": "terminal", + "input": {"cmd": "ls"}, + "snippet": "OUTPUT A", + } + ], + }, + ] + 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": "", + "activity_rows": [], + }, + } + } + + hydrated = routes._hydrate_anchor_activity_scenes( + messages, + records, + tool_calls=[ + { + "assistant_msg_idx": 1, + "tid": "durable-b", + "name": "terminal", + "snippet": "OUTPUT B", + } + ], + ) + + rows = hydrated[1]["_anchor_activity_scene"]["activity_rows"] + tools = [row for row in rows if row.get("role") == "tool"] + by_id = {row.get("tool_call_id"): row for row in tools} + + assert by_id["content-a"]["tool"]["snippet"] == "OUTPUT A" + assert by_id["durable-b"]["tool"]["snippet"] == "OUTPUT B" + assert len(tools) == 2 + + +def test_anchor_scene_hydration_does_not_name_merge_singleton_with_conflicting_args(): + from api import routes + + messages = [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "content": [ + "Patch a.py.", + { + "type": "tool_use", + "tool_use_id": "content-a", + "tool_name": "edit_file", + "args": {"path": "a.py"}, + }, + ], + "tool_calls": [ + { + "id": "message-b", + "name": "edit_file", + "input": {"path": "b.py"}, + "snippet": "PATCH B", + } + ], + }, + ] + 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": "", + "activity_rows": [], + }, + } + } + + hydrated = routes._hydrate_anchor_activity_scenes(messages, records, tool_calls=[]) + + rows = hydrated[1]["_anchor_activity_scene"]["activity_rows"] + tools = [row for row in rows if row.get("role") == "tool"] + by_id = {row.get("tool_call_id"): row for row in tools} + + assert by_id["content-a"]["tool"]["args"] == {"path": "a.py"} + assert by_id["content-a"]["tool"]["snippet"] == "" + assert by_id["message-b"]["tool"]["args"] == {"path": "b.py"} + assert by_id["message-b"]["tool"]["snippet"] == "PATCH B" + assert len(tools) == 2 + + def test_anchor_scene_hydration_dedupes_compression_lifecycle_rows(): from api import routes diff --git a/tests/test_issue4622_settled_toolcard_body.py b/tests/test_issue4622_settled_toolcard_body.py index f2ceea8c..29686c51 100644 --- a/tests/test_issue4622_settled_toolcard_body.py +++ b/tests/test_issue4622_settled_toolcard_body.py @@ -61,13 +61,25 @@ function extractFunc(src, name) { return src.slice(start, i); } const messagesFns = [ - '_anchorSceneMessageText','_anchorSceneCleanText','_anchorSceneTextKey', - '_anchorSceneSafePayload','_anchorSceneToolId','_anchorSceneToolName', - '_anchorSceneToolArgs','_anchorSceneStringPayload','_anchorSceneRowBase', - '_anchorSceneProseRow','_anchorSceneThinkingRow','_anchorSceneToolRowFromCall', - '_anchorSceneMessageReasoningText','_enrichSettledToolRowBodyFromLive', - '_anchorSceneRowsByMessageIndex', -]; + '_anchorSceneMessageText','_anchorSceneCleanText','_anchorSceneTextKey', + '_anchorSceneContentText','_anchorSceneContentVisibleText','_anchorSceneMessageHasContentToolUse', + '_anchorSceneFinalAnswerText', + '_anchorSceneSafePayload','_anchorSceneToolId','_anchorSceneToolName', + '_anchorSceneToolArgs','_anchorSceneContentTool','_anchorSceneStringPayload','_anchorSceneRowBase', + '_anchorSceneProseRow','_anchorSceneThinkingRow','_anchorSceneToolRowFromCall', + '_anchorSceneToolRowName','_anchorSceneToolRowId', + '_anchorSceneToolRowsHaveNonConflictingIds','_anchorSceneToolRowsHaveDifferentExplicitIds', + '_anchorSceneToolRowStartedAt','_anchorSceneToolRowsHaveSameStartedAt', + '_anchorSceneToolRowBodyText','_anchorSceneToolRowsHaveCompatibleBody', + '_anchorSceneToolRowsHaveCompatibleNames', + '_anchorSceneToolRowArgs','_anchorSceneObjectContainsSubset', + '_anchorSceneToolRowsHaveCompatibleInvocation', + '_anchorSceneToolRowHasInvocationEvidence','_anchorSceneToolRowsCanNameMatch', + '_anchorSceneMatchingContentToolRow', + '_anchorSceneMessageReasoningText','_anchorSceneRowsFromContentParts', + '_enrichSettledToolRowBodyFromLive', + '_anchorSceneRowsByMessageIndex', + ]; const uiFns = ['_anchorSceneToolCallFromRow']; let code = '(function(){\n'; code += 'var activeSid="test-session"; var streamId="test-stream"; var S;\n'; @@ -79,13 +91,19 @@ process.stdin.on('data',c=>buf+=c); process.stdin.on('end',()=>{ var p=JSON.parse(buf||'{}'); S=p.S||{toolCalls:[]}; - var messages=p.messages||[]; - var turnStart=p.turnStart!==undefined?p.turnStart:0; - var lastAsstIndex=p.lastAsstIndex!==undefined?p.lastAsstIndex:messages.length-1; - var byIdx=_anchorSceneRowsByMessageIndex(messages,turnStart,lastAsstIndex); - var cards=[]; - byIdx.forEach(function(rows,idx){ - rows.forEach(function(row){ + var messages=p.messages||[]; + var turnStart=p.turnStart!==undefined?p.turnStart:0; + var lastAsstIndex=p.lastAsstIndex!==undefined?p.lastAsstIndex:messages.length-1; + var byIdx=_anchorSceneRowsByMessageIndex(messages,turnStart,lastAsstIndex,p.includeFinal?{includeFinal:true}:undefined); + var finalAnswer=_anchorSceneFinalAnswerText(messages[lastAsstIndex]||{}); + var cards=[]; + var rows=[]; + byIdx.forEach(function(bucket,idx){ + bucket.forEach(function(row){ + rows.push({ + idx:idx, role:row.role||'', text:row.text||'', + tool_call_id:row.tool_call_id||'', kind:row.kind||'' + }); if(row.role!=='tool') return; var tc=_anchorSceneToolCallFromRow(row,{settled:true}); var outputTab=String((tc.snippet||tc.preview||tc.result||tc.output)||'').trim(); @@ -100,8 +118,9 @@ process.stdin.on('end',()=>{ }); }); }); - process.stdout.write(JSON.stringify(cards)); -}); + if(p.returnRows) process.stdout.write(JSON.stringify({cards:cards,rows:rows,finalAnswer:finalAnswer})); + else process.stdout.write(JSON.stringify(cards)); + }); })(); `; process.stdout.write(code); @@ -182,6 +201,44 @@ def _card(cards, name): return matches[0] +def test_non_final_post_tool_text_survives_fresh_settlement(driver_path): + """A non-final assistant message may contain process text after a tool_use. + That text is activity, not the final answer tail, and must survive fresh + settlement in the anchor rows.""" + messages = [ + {"role": "user", "content": "inspect"}, + { + "role": "assistant", + "content": [ + "I will inspect first.", + {"type": "tool_use", "tool_use_id": "term-content", "tool_name": "terminal"}, + {"type": "text", "text": "I found the relevant file."}, + {"type": "thinking", "text": "Need one more check."}, + ], + }, + {"role": "assistant", "content": "final answer"}, + ] + + result = _run( + driver_path, + { + "messages": messages, + "turnStart": 0, + "lastAsstIndex": 2, + "S": {"toolCalls": []}, + "returnRows": True, + }, + ) + + rows = [row for row in result["rows"] if row["idx"] == 1] + assert [(row["role"], row["text"] or row["tool_call_id"]) for row in rows] == [ + ("prose", "I will inspect first."), + ("tool", "term-content"), + ("prose", "I found the relevant file."), + ("thinking", "Need one more check."), + ] + + # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- @@ -241,6 +298,889 @@ def test_single_row_no_duplicate_after_enrich(driver_path): assert len([c for c in cards if c["tid"] == "term-1"]) == 1 +def test_content_tool_use_enriched_from_matching_message_tool_call(driver_path): + """Fresh settlement keeps content[] ordering while preserving richer tool body.""" + messages = [ + {"role": "user", "content": "inspect"}, + { + "role": "assistant", + "content": [ + "I will inspect first.", + {"type": "tool_use", "tool_use_id": "term-content", "tool_name": "terminal"}, + "Done.", + ], + "tool_calls": [ + { + "id": "term-message", + "function": {"name": "terminal", "arguments": '{"cmd":"ls -la"}'}, + "snippet": _TERM_OUTPUT, + "started_at": 100, + } + ], + }, + {"role": "assistant", "content": "final answer"}, + ] + + cards = _run( + driver_path, + { + "messages": messages, + "turnStart": 0, + "lastAsstIndex": 2, + "S": { + "toolCalls": [ + { + "id": "term-live", + "name": "terminal", + "assistant_msg_idx": 1, + "args": {"cmd": "ls -la"}, + "snippet": _TERM_OUTPUT, + "started_at": 100, + } + ] + }, + }, + ) + + matching = [card for card in cards if card["tid"] == "term-content"] + assert len(matching) == 1 + assert not [card for card in cards if card["tid"] in ("term-message", "term-live")] + assert matching[0]["snippet"] == _TERM_OUTPUT + assert matching[0]["args"] == {"cmd": "ls -la"} + assert matching[0]["rendersOutputBody"] is True + + +def test_final_content_tail_thinking_stays_activity_and_content_order_wins_started_at(driver_path): + """Fresh settlement keeps final content order and does not fold thinking into the answer.""" + messages = [ + {"role": "user", "content": "inspect"}, + { + "role": "assistant", + "content": [ + {"type": "text", "text": "Let me check first."}, + { + "type": "tool_use", + "tool_use_id": "term-content", + "tool_name": "terminal", + "args": {"cmd": "ls"}, + }, + {"type": "thinking", "text": "Tail thinking must stay activity."}, + {"type": "reasoning", "reasoning": "Tail reasoning key must stay activity."}, + {"type": "text", "text": "Final visible answer."}, + ], + "tool_calls": [ + { + "id": "term-message", + "name": "terminal", + "args": {"cmd": "ls"}, + "snippet": "OUTPUT", + "started_at": 100, + } + ], + }, + ] + + result = _run( + driver_path, + { + "messages": messages, + "turnStart": 0, + "lastAsstIndex": 1, + "includeFinal": True, + "returnRows": True, + "S": {"toolCalls": []}, + }, + ) + + rows = [row for row in result["rows"] if row["idx"] == 1] + assert result["finalAnswer"] == "Final visible answer." + assert [(row["role"], row["text"] or row["tool_call_id"]) for row in rows] == [ + ("prose", "Let me check first."), + ("tool", "term-content"), + ("thinking", "Tail thinking must stay activity."), + ("thinking", "Tail reasoning key must stay activity."), + ] + + +def test_final_output_text_tail_content_becomes_answer_not_activity(driver_path): + """Fresh settlement treats output_text.content after the last tool as final answer text.""" + messages = [ + {"role": "user", "content": "inspect"}, + { + "role": "assistant", + "content": [ + {"type": "text", "text": "Let me check first."}, + { + "type": "tool_use", + "tool_use_id": "term-content", + "tool_name": "terminal", + "args": {"cmd": "ls"}, + }, + {"type": "output_text", "content": "Final answer from content field."}, + ], + "tool_calls": [ + { + "id": "term-message", + "name": "terminal", + "args": {"cmd": "ls"}, + "snippet": "OUTPUT", + "started_at": 100, + } + ], + }, + ] + + result = _run( + driver_path, + { + "messages": messages, + "turnStart": 0, + "lastAsstIndex": 1, + "includeFinal": True, + "returnRows": True, + "S": {"toolCalls": []}, + }, + ) + + rows = [row for row in result["rows"] if row["idx"] == 1] + assert result["finalAnswer"] == "Final answer from content field." + assert [(row["role"], row["text"] or row["tool_call_id"]) for row in rows] == [ + ("prose", "Let me check first."), + ("tool", "term-content"), + ] + + +def test_ordered_content_bucket_ignores_unmatched_live_started_at(driver_path): + """When content[] owns row order, unmatched live rows keep encounter order.""" + messages = [ + {"role": "user", "content": "inspect"}, + { + "role": "assistant", + "content": [ + {"type": "text", "text": "First content row."}, + { + "type": "tool_use", + "tool_use_id": "content-tool", + "tool_name": "terminal", + "args": {"cmd": "first"}, + }, + ], + }, + {"role": "assistant", "content": "final answer"}, + ] + + result = _run( + driver_path, + { + "messages": messages, + "turnStart": 0, + "lastAsstIndex": 2, + "returnRows": True, + "S": { + "toolCalls": [ + { + "id": "live-late", + "name": "terminal", + "assistant_msg_idx": 1, + "args": {"cmd": "late"}, + "snippet": "late", + "started_at": 300, + }, + { + "id": "live-early", + "name": "terminal", + "assistant_msg_idx": 1, + "args": {"cmd": "early"}, + "snippet": "early", + "started_at": 100, + }, + ] + }, + }, + ) + + rows = [row for row in result["rows"] if row["idx"] == 1] + assert [(row["role"], row["text"] or row["tool_call_id"]) for row in rows] == [ + ("prose", "First content row."), + ("tool", "content-tool"), + ("tool", "live-late"), + ("tool", "live-early"), + ] + + +def test_content_tool_use_partial_args_enriched_from_matching_live_tool_call(driver_path): + """Matched live tool args fill missing invocation details without clobbering content args.""" + messages = [ + {"role": "user", "content": "patch"}, + { + "role": "assistant", + "content": [ + "I will patch the file.", + { + "type": "tool_use", + "tool_use_id": "patch-content", + "tool_name": "edit_file", + "args": {"path": "x.py"}, + }, + "Done.", + ], + "tool_calls": [ + {"id": "patch-message", "name": "edit_file", "snippet": _DIFF, "started_at": 100} + ], + }, + {"role": "assistant", "content": "final answer"}, + ] + + cards = _run( + driver_path, + { + "messages": messages, + "turnStart": 0, + "lastAsstIndex": 2, + "S": { + "toolCalls": [ + { + "id": "patch-live", + "name": "edit_file", + "assistant_msg_idx": 1, + "args": { + "path": "x.py", + "old_string": "old", + "new_string": "new", + }, + "snippet": _DIFF, + "started_at": 100, + } + ] + }, + }, + ) + + matching = [card for card in cards if card["tid"] == "patch-content"] + assert len(matching) == 1 + assert matching[0]["args"] == { + "path": "x.py", + "old_string": "old", + "new_string": "new", + } + assert matching[0]["rendersDiff"] is True + + +def test_id_flexible_content_tool_does_not_absorb_third_same_command_id(driver_path): + """A content row matched to one alternate id must not hide a later repeated call.""" + messages = [ + {"role": "user", "content": "inspect twice"}, + { + "role": "assistant", + "content": [ + "First check.", + { + "type": "tool_use", + "tool_use_id": "content-a", + "tool_name": "terminal", + "args": {"cmd": "ls"}, + }, + "Second check.", + ], + "tool_calls": [ + { + "id": "message-a", + "name": "terminal", + "args": {"cmd": "ls"}, + "snippet": "OUTPUT A", + "started_at": 100, + } + ], + }, + {"role": "assistant", "content": "final answer"}, + ] + + cards = _run( + driver_path, + { + "messages": messages, + "turnStart": 0, + "lastAsstIndex": 2, + "S": { + "toolCalls": [ + { + "id": "live-b", + "name": "terminal", + "assistant_msg_idx": 1, + "args": {"cmd": "ls"}, + "snippet": "OUTPUT B", + "started_at": 200, + } + ] + }, + }, + ) + by_tid = {card["tid"]: card for card in cards} + + assert by_tid["content-a"]["snippet"] == "OUTPUT A" + assert "message-a" not in by_tid + assert by_tid["live-b"]["snippet"] == "OUTPUT B" + assert len([card for card in cards if card["name"] == "terminal"]) == 2 + + +def test_id_flexible_content_tool_keeps_same_started_at_repeat_distinct(driver_path): + """Same started_at is not enough to reuse a consumed different-id content row.""" + messages = [ + {"role": "user", "content": "inspect twice"}, + { + "role": "assistant", + "content": [ + "First check.", + { + "type": "tool_use", + "tool_use_id": "content-a", + "tool_name": "terminal", + "args": {"cmd": "ls"}, + }, + "Second check.", + ], + "tool_calls": [ + { + "id": "message-a", + "name": "terminal", + "args": {"cmd": "ls"}, + "snippet": "OUTPUT A", + "started_at": 100, + } + ], + }, + {"role": "assistant", "content": "final answer"}, + ] + + cards = _run( + driver_path, + { + "messages": messages, + "turnStart": 0, + "lastAsstIndex": 2, + "S": { + "toolCalls": [ + { + "id": "live-b", + "name": "terminal", + "assistant_msg_idx": 1, + "args": {"cmd": "ls"}, + "snippet": "OUTPUT B", + "started_at": 100, + } + ] + }, + }, + ) + by_tid = {card["tid"]: card for card in cards} + + assert by_tid["content-a"]["snippet"] == "OUTPUT A" + assert "message-a" not in by_tid + assert by_tid["live-b"]["snippet"] == "OUTPUT B" + assert len([card for card in cards if card["name"] == "terminal"]) == 2 + + +def test_id_flexible_content_tool_keeps_identical_output_repeat_distinct(driver_path): + """Identical output is not identity proof for repeated same-command calls.""" + messages = [ + {"role": "user", "content": "inspect twice"}, + { + "role": "assistant", + "content": [ + "First check.", + { + "type": "tool_use", + "tool_use_id": "content-a", + "tool_name": "terminal", + "args": {"cmd": "ls"}, + }, + "Second check.", + ], + "tool_calls": [ + { + "id": "message-a", + "name": "terminal", + "args": {"cmd": "ls"}, + "snippet": "SAME OUTPUT", + } + ], + }, + {"role": "assistant", "content": "final answer"}, + ] + + cards = _run( + driver_path, + { + "messages": messages, + "turnStart": 0, + "lastAsstIndex": 2, + "S": { + "toolCalls": [ + { + "id": "live-b", + "name": "terminal", + "assistant_msg_idx": 1, + "args": {"cmd": "ls"}, + "snippet": "SAME OUTPUT", + } + ] + }, + }, + ) + by_tid = {card["tid"]: card for card in cards} + + assert by_tid["content-a"]["snippet"] == "SAME OUTPUT" + assert "message-a" not in by_tid + assert by_tid["live-b"]["snippet"] == "SAME OUTPUT" + assert len([card for card in cards if card["name"] == "terminal"]) == 2 + + +def test_ambiguous_different_id_content_tools_do_not_merge_by_position(driver_path): + """Multiple same-name tools with different ids must not be paired by position.""" + messages = [ + {"role": "user", "content": "inspect twice"}, + { + "role": "assistant", + "content": [ + "First check.", + {"type": "tool_use", "tool_use_id": "content-a", "tool_name": "terminal"}, + "Second check.", + {"type": "tool_use", "tool_use_id": "content-b", "tool_name": "terminal"}, + "Done.", + ], + "tool_calls": [ + {"id": "message-b", "name": "terminal", "snippet": "OUTPUT B"}, + {"id": "message-a", "name": "terminal", "snippet": "OUTPUT A"}, + ], + }, + {"role": "assistant", "content": "final answer"}, + ] + + cards = _run( + driver_path, + {"messages": messages, "turnStart": 0, "lastAsstIndex": 2, "S": {"toolCalls": []}}, + ) + by_tid = {card["tid"]: card for card in cards} + + assert by_tid["content-a"]["snippet"] == "" + assert by_tid["content-b"]["snippet"] == "" + assert by_tid["message-a"]["snippet"] == "OUTPUT A" + assert by_tid["message-b"]["snippet"] == "OUTPUT B" + + +def test_remaining_same_name_content_tool_does_not_name_merge_after_exact_match(driver_path): + """A prior exact-id match must not make the remaining same-name row look safe.""" + messages = [ + {"role": "user", "content": "inspect twice"}, + { + "role": "assistant", + "content": [ + "First check.", + {"type": "tool_use", "tool_use_id": "content-a", "tool_name": "terminal"}, + "Second check.", + {"type": "tool_use", "tool_use_id": "content-b", "tool_name": "terminal"}, + "Done.", + ], + "tool_calls": [ + {"id": "content-a", "name": "terminal", "snippet": "OUTPUT A"}, + {"id": "message-b", "name": "terminal", "snippet": "OUTPUT B"}, + ], + }, + {"role": "assistant", "content": "final answer"}, + ] + + cards = _run( + driver_path, + {"messages": messages, "turnStart": 0, "lastAsstIndex": 2, "S": {"toolCalls": []}}, + ) + by_tid = {card["tid"]: card for card in cards} + + assert by_tid["content-a"]["snippet"] == "OUTPUT A" + assert by_tid["content-b"]["snippet"] == "" + assert by_tid["message-b"]["snippet"] == "OUTPUT B" + + +def test_remaining_matching_content_tool_merges_after_exact_match(driver_path): + """After an exact match, one remaining row may merge with matching invocation evidence.""" + messages = [ + {"role": "user", "content": "inspect twice"}, + { + "role": "assistant", + "content": [ + "First check.", + { + "type": "tool_use", + "tool_use_id": "content-a", + "tool_name": "terminal", + "args": {"cmd": "ls"}, + }, + "Second check.", + { + "type": "tool_use", + "tool_use_id": "content-b", + "tool_name": "terminal", + "args": {"cmd": "pwd"}, + }, + "Done.", + ], + "tool_calls": [ + {"id": "content-a", "name": "terminal", "args": {"cmd": "ls"}, "snippet": "OUTPUT A"}, + {"id": "message-b", "name": "terminal", "args": {"cmd": "pwd"}, "snippet": "OUTPUT B"}, + ], + }, + {"role": "assistant", "content": "final answer"}, + ] + + cards = _run( + driver_path, + {"messages": messages, "turnStart": 0, "lastAsstIndex": 2, "S": {"toolCalls": []}}, + ) + by_tid = {card["tid"]: card for card in cards} + + assert by_tid["content-a"]["snippet"] == "OUTPUT A" + assert by_tid["content-b"]["snippet"] == "OUTPUT B" + assert "message-b" not in by_tid + assert len([card for card in cards if card["name"] == "terminal"]) == 2 + + +def test_nested_args_with_different_key_order_merge_without_duplicate(driver_path): + """Nested args compare by value, not object insertion order.""" + messages = [ + {"role": "user", "content": "patch twice"}, + { + "role": "assistant", + "content": [ + "First patch.", + { + "type": "tool_use", + "tool_use_id": "content-a", + "tool_name": "edit_file", + "args": {"patch": {"path": "a.py", "body": "x"}}, + }, + "Second patch.", + { + "type": "tool_use", + "tool_use_id": "content-b", + "tool_name": "edit_file", + "args": {"patch": {"path": "b.py", "body": "y"}}, + }, + "Done.", + ], + "tool_calls": [ + { + "id": "content-a", + "name": "edit_file", + "args": {"patch": {"path": "a.py", "body": "x"}}, + "snippet": "PATCH A", + }, + { + "id": "message-b", + "name": "edit_file", + "args": {"patch": {"body": "y", "path": "b.py"}}, + "snippet": "PATCH B", + }, + ], + }, + {"role": "assistant", "content": "final answer"}, + ] + + cards = _run( + driver_path, + {"messages": messages, "turnStart": 0, "lastAsstIndex": 2, "S": {"toolCalls": []}}, + ) + by_tid = {card["tid"]: card for card in cards} + + assert by_tid["content-a"]["snippet"] == "PATCH A" + assert by_tid["content-b"]["snippet"] == "PATCH B" + assert "message-b" not in by_tid + assert len([card for card in cards if card["name"] == "edit_file"]) == 2 + + +def test_consumed_singleton_content_tool_keeps_distinct_live_invocation(driver_path): + """A row consumed by message.tool_calls must not hide a later distinct live call.""" + messages = [ + {"role": "user", "content": "inspect twice"}, + { + "role": "assistant", + "content": [ + "First check.", + { + "type": "tool_use", + "tool_use_id": "content-a", + "tool_name": "terminal", + "args": {"cmd": "ls"}, + }, + "Second check.", + ], + "tool_calls": [ + {"id": "content-a", "name": "terminal", "args": {"cmd": "ls"}, "snippet": "OUTPUT A"} + ], + }, + {"role": "assistant", "content": "final answer"}, + ] + + cards = _run( + driver_path, + { + "messages": messages, + "turnStart": 0, + "lastAsstIndex": 2, + "S": { + "toolCalls": [ + { + "id": "live-b", + "name": "terminal", + "assistant_msg_idx": 1, + "args": {"cmd": "pwd"}, + "snippet": "OUTPUT B", + } + ] + }, + }, + ) + by_tid = {card["tid"]: card for card in cards} + + assert by_tid["content-a"]["snippet"] == "OUTPUT A" + assert by_tid["live-b"]["snippet"] == "OUTPUT B" + assert len([card for card in cards if card["name"] == "terminal"]) == 2 + + +def test_consumed_singleton_same_command_live_tool_stays_distinct(driver_path): + """A repeated same-command invocation with a new id must keep its own card.""" + messages = [ + {"role": "user", "content": "inspect twice"}, + { + "role": "assistant", + "content": [ + "First check.", + { + "type": "tool_use", + "tool_use_id": "content-a", + "tool_name": "terminal", + "args": {"cmd": "ls"}, + }, + "Second check.", + ], + "tool_calls": [ + {"id": "content-a", "name": "terminal", "args": {"cmd": "ls"}, "snippet": "OUTPUT A"} + ], + }, + {"role": "assistant", "content": "final answer"}, + ] + + cards = _run( + driver_path, + { + "messages": messages, + "turnStart": 0, + "lastAsstIndex": 2, + "S": { + "toolCalls": [ + { + "id": "live-b", + "name": "terminal", + "assistant_msg_idx": 1, + "args": {"cmd": "ls"}, + "snippet": "OUTPUT B", + } + ] + }, + }, + ) + by_tid = {card["tid"]: card for card in cards} + + assert by_tid["content-a"]["snippet"] == "OUTPUT A" + assert by_tid["live-b"]["snippet"] == "OUTPUT B" + assert len([card for card in cards if card["name"] == "terminal"]) == 2 + + +def test_consumed_singleton_anonymous_live_tool_stays_distinct(driver_path): + """A consumed content row must not absorb a later anonymous live call.""" + messages = [ + {"role": "user", "content": "inspect twice"}, + { + "role": "assistant", + "content": [ + "First check.", + { + "type": "tool_use", + "tool_use_id": "content-a", + "tool_name": "terminal", + "args": {"cmd": "ls"}, + }, + "Second check.", + ], + "tool_calls": [ + {"id": "content-a", "name": "terminal", "args": {"cmd": "ls"}, "snippet": "OUTPUT A"} + ], + }, + {"role": "assistant", "content": "final answer"}, + ] + + cards = _run( + driver_path, + { + "messages": messages, + "turnStart": 0, + "lastAsstIndex": 2, + "S": { + "toolCalls": [ + { + "name": "terminal", + "assistant_msg_idx": 1, + "args": {"cmd": "ls"}, + "snippet": "OUTPUT B", + } + ] + }, + }, + ) + + terminal_cards = [card for card in cards if card["name"] == "terminal"] + snippets = sorted(card["snippet"] for card in terminal_cards) + + assert len(terminal_cards) == 2 + assert snippets == ["OUTPUT A", "OUTPUT B"] + + +def test_consumed_singleton_body_only_live_tool_stays_distinct(driver_path): + """A body-only live row has no proof that it is the consumed content tool.""" + messages = [ + {"role": "user", "content": "inspect twice"}, + { + "role": "assistant", + "content": [ + "First check.", + { + "type": "tool_use", + "tool_use_id": "content-a", + "tool_name": "terminal", + "args": {"cmd": "ls"}, + }, + "Second check.", + ], + "tool_calls": [ + {"id": "content-a", "name": "terminal", "args": {"cmd": "ls"}, "snippet": "OUTPUT A"} + ], + }, + {"role": "assistant", "content": "final answer"}, + ] + + cards = _run( + driver_path, + { + "messages": messages, + "turnStart": 0, + "lastAsstIndex": 2, + "S": { + "toolCalls": [ + { + "id": "live-b", + "name": "terminal", + "assistant_msg_idx": 1, + "snippet": "OUTPUT B", + } + ] + }, + }, + ) + by_tid = {card["tid"]: card for card in cards} + + assert by_tid["content-a"]["snippet"] == "OUTPUT A" + assert by_tid["live-b"]["snippet"] == "OUTPUT B" + assert len([card for card in cards if card["name"] == "terminal"]) == 2 + + +def test_consumed_singleton_different_name_live_tool_stays_distinct(driver_path): + """A consumed content row must not be reused for a different tool name.""" + messages = [ + {"role": "user", "content": "edit then inspect"}, + { + "role": "assistant", + "content": [ + "Edit the file.", + { + "type": "tool_use", + "tool_use_id": "content-a", + "tool_name": "edit_file", + "args": {"path": "x.py"}, + }, + "Inspect it.", + ], + "tool_calls": [ + { + "id": "content-a", + "name": "edit_file", + "args": {"path": "x.py"}, + "snippet": "EDITED", + } + ], + }, + {"role": "assistant", "content": "final answer"}, + ] + + cards = _run( + driver_path, + { + "messages": messages, + "turnStart": 0, + "lastAsstIndex": 2, + "S": { + "toolCalls": [ + { + "id": "live-b", + "name": "terminal", + "assistant_msg_idx": 1, + "args": {"path": "x.py"}, + "snippet": "TERMINAL OUTPUT", + } + ] + }, + }, + ) + by_tid = {card["tid"]: card for card in cards} + + assert by_tid["content-a"]["name"] == "edit_file" + assert by_tid["content-a"]["snippet"] == "EDITED" + assert by_tid["live-b"]["name"] == "terminal" + assert by_tid["live-b"]["snippet"] == "TERMINAL OUTPUT" + + +def test_singleton_content_tool_does_not_name_merge_conflicting_args(driver_path): + """A one-row content pool still must not merge explicit different invocations.""" + messages = [ + {"role": "user", "content": "patch"}, + { + "role": "assistant", + "content": [ + "Patch a.py.", + { + "type": "tool_use", + "tool_use_id": "content-a", + "tool_name": "edit_file", + "args": {"path": "a.py"}, + }, + ], + "tool_calls": [ + {"id": "message-b", "name": "edit_file", "args": {"path": "b.py"}, "snippet": "PATCH B"} + ], + }, + {"role": "assistant", "content": "final answer"}, + ] + + cards = _run( + driver_path, + {"messages": messages, "turnStart": 0, "lastAsstIndex": 2, "S": {"toolCalls": []}}, + ) + by_tid = {card["tid"]: card for card in cards} + + assert by_tid["content-a"]["args"] == {"path": "a.py"} + assert by_tid["content-a"]["snippet"] == "" + assert by_tid["message-b"]["args"] == {"path": "b.py"} + assert by_tid["message-b"]["snippet"] == "PATCH B" + assert len([card for card in cards if card["name"] == "edit_file"]) == 2 + + def test_settled_capped_preview_restored_to_full_live_body(driver_path): """#4622's real symptom: the backend persists a 4000-char CAPPED PREVIEW (_TOOL_RESULT_SNIPPET_MAX), so a long output settles to a truncated prefix, diff --git a/tests/test_issue4811_reconnect_chronology.py b/tests/test_issue4811_reconnect_chronology.py index 98312006..145cd572 100644 --- a/tests/test_issue4811_reconnect_chronology.py +++ b/tests/test_issue4811_reconnect_chronology.py @@ -46,22 +46,29 @@ function extractFunc(src, name) { } const funcNames = [ - '_anchorSceneMessageText', - '_anchorSceneCleanText', - '_anchorSceneTextKey', - '_anchorSceneSafePayload', - '_anchorSceneToolId', - '_anchorSceneToolName', - '_anchorSceneToolArgs', - '_anchorSceneStringPayload', - '_anchorSceneRowBase', - '_anchorSceneProseRow', - '_anchorSceneThinkingRow', - '_anchorSceneToolRowFromCall', - '_anchorSceneMessageReasoningText', - '_enrichSettledToolRowBodyFromLive', - '_anchorSceneRowsByMessageIndex', -]; + '_anchorSceneMessageText', + '_anchorSceneCleanText', + '_anchorSceneTextKey', + '_anchorSceneContentText', + '_anchorSceneMessageHasContentToolUse', + '_anchorSceneSafePayload', + '_anchorSceneToolId', + '_anchorSceneToolName', + '_anchorSceneToolArgs', + '_anchorSceneContentTool', + '_anchorSceneStringPayload', + '_anchorSceneRowBase', + '_anchorSceneProseRow', + '_anchorSceneThinkingRow', + '_anchorSceneToolRowFromCall', + '_anchorSceneToolRowName', + '_anchorSceneToolRowsHaveCompatibleNames', + '_anchorSceneMatchingContentToolRow', + '_anchorSceneMessageReasoningText', + '_anchorSceneRowsFromContentParts', + '_enrichSettledToolRowBodyFromLive', + '_anchorSceneRowsByMessageIndex', + ]; // Build a self-contained module: closure variables + all functions + runner let code = '(function() {\n'; @@ -331,4 +338,3 @@ def test_anonymous_tool_rows_get_distinct_row_ids(driver_path): assert len(set(row_ids)) == 2, f"anonymous tool rows collided on row_id: {row_ids}" seqs = [r.get("seq") for r in tool_rows] assert len(set(seqs)) == 2, f"anonymous tool rows collided on seq: {seqs}" - diff --git a/tests/test_live_to_final_anchor_visible_order.py b/tests/test_live_to_final_anchor_visible_order.py index 3fa78e3b..6f399ad0 100644 --- a/tests/test_live_to_final_anchor_visible_order.py +++ b/tests/test_live_to_final_anchor_visible_order.py @@ -726,7 +726,7 @@ def test_settled_anchor_scene_preserves_live_projected_order_before_backfill(): 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;idxlastToolIndex) continue;" in content_rows + assert "if(isFinalMessage&&i>lastToolIndex&&_anchorSceneContentVisibleText(part)) continue;" in content_rows + assert "_anchorSceneProseRow(text,rows.length,messageIndex)" in content_rows + assert "_anchorSceneToolRowFromCall(_anchorSceneContentTool(part),rows.length,messageIndex)" in content_rows + assert "lastToolIndex+1" in final_answer + assert "_anchorSceneContentVisibleText(part)" in final_answer + assert "if(typeof part==='string') return part;" in content_text + assert "part.thinking||part.reasoning||part.summary" in content_text + assert "partType==='thinking'||partType==='reasoning'" in visible_text + assert "part.text||part.input_text||part.output_text" in visible_text + assert "_fromContent:true" in rows_by_message + assert "const useStartedAt=!hasOrderedContentRows;" in rows_by_message + assert "if(useStartedAt){" in rows_by_message + assert "const {_phase,_encounter,_fromContent,...clean}=row;" in rows_by_message + content_tool = _function_body(MESSAGES_JS, "_anchorSceneContentTool") + assert "part.id||part.tid||part.tool_call_id||part.tool_use_id||part.call_id" in content_tool + assert "part.name||part.tool_name||fn.name||'tool'" in content_tool + assert "args:part.args" in content_tool + assert "input:part.input" in content_tool + + def test_settled_anchor_scene_hides_prior_process_segments_not_final_answer(): settled = _function_body(UI_JS, "_renderSettledAnchorSceneForMessage") group = _function_body(UI_JS, "_anchorSceneWorklogGroup") diff --git a/tests/test_session_recovery_api.py b/tests/test_session_recovery_api.py index a62cafdd..9aadfc6a 100644 --- a/tests/test_session_recovery_api.py +++ b/tests/test_session_recovery_api.py @@ -24,8 +24,17 @@ def test_repair_safe_session_recovery_restores_backup_and_rebuilds_index(tmp_pat index.write_text(json.dumps([]), encoding="utf-8") monkeypatch.setattr(_m, "SESSION_DIR", tmp_path) monkeypatch.setattr(_m, "SESSION_INDEX_FILE", index) + stale = _m.Session( + session_id="stale_cached", + title="stale", + messages=[{"role": "user", "content": "from another test"}], + ) + _m.SESSIONS[stale.session_id] = stale - result = repair_safe_session_recovery(tmp_path) + try: + result = repair_safe_session_recovery(tmp_path) + finally: + _m.SESSIONS.pop(stale.session_id, None) assert result["clean"] is True assert result["ok"] is True