fix(anchor): preserve settled mixed content order (#4958)

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 <franksong2702@users.noreply.github.com>
This commit is contained in:
nesquena-hermesandfranksong2702 committed 2026-06-27 22:54:29 +00:00
1 parent 5cd070ae76
commit e2ade27bfe
10 files changed
+3151 -72

No files matched your search

+2
View File
@@ -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)
+546 -8
View File
@@ -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":
+49 -5
View File
@@ -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 {
@@ -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
+349 -21
View File
@@ -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<content.length;i+=1){
const part=content[i];
if(part&&typeof part==='object'&&part.type==='tool_use') lastToolIndex=i;
}
const tailText=content.slice(lastToolIndex+1)
.map(part=>_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;i<content.length;i+=1){
const part=content[i];
if(part&&typeof part==='object'&&part.type==='tool_use') lastToolIndex=i;
}
for(let i=0;i<content.length;i+=1){
const part=content[i];
if(!part||typeof part!=='object'){
if(isFinalMessage&&i>lastToolIndex) 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;idx<lastAsstIndex;idx+=1){
const endIndex=options&&options.includeFinal?lastAsstIndex+1:lastAsstIndex;
for(let idx=turnStart+1;idx<endIndex;idx+=1){
const message=messages[idx];
if(!message||message.role!=='assistant') continue;
const pool=[];
const text=_anchorSceneMessageText(message);
if(_anchorSceneCleanText(text)){
const contentRows=_anchorSceneRowsFromContentParts(message,idx,{isFinalMessage:idx===lastAsstIndex});
const hasOrderedContentRows=Array.isArray(contentRows)&&contentRows.length>0;
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<lastAsstIndex;idx+=1){
for(let idx=turnStart+1;idx<=lastAsstIndex;idx+=1){
const bucket=messageRows.get(idx)||[];
for(const row of bucket) pushRow(row);
}
@@ -3001,7 +3329,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
.map(m=>_anchorSceneMessageRef(m)),
},
lifecycle:(base.lifecycle&&typeof base.lifecycle==='object')?{...base.lifecycle}:{},
final_answer:_anchorSceneCleanText(finalAnswer)?finalAnswer:(typeof base.final_answer==='string'?base.final_answer:''),
final_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),
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+23 -17
View File
@@ -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}"
@@ -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;idx<lastAsstIndex;idx+=1)")
backfill_idx = complete.index("for(let idx=turnStart+1;idx<=lastAsstIndex;idx+=1)")
terminal_idx = complete.index("if(row&&row.role==='terminal') pushRow(row);", backfill_idx)
assert projected_idx < projected_push_idx < backfill_idx < terminal_idx
@@ -842,6 +842,45 @@ def test_settled_anchor_scene_final_answer_does_not_fold_into_worklog_source():
assert "_renderSettledAnchorSceneForMessage(msg, seg, rawIdx)" in render
def test_settled_anchor_scene_promotes_final_content_array_to_ordered_activity_rows():
complete = _function_body(MESSAGES_JS, "_completeSettledAnchorSceneForTurn")
rows_by_message = _function_body(MESSAGES_JS, "_anchorSceneRowsByMessageIndex")
content_rows = _function_body(MESSAGES_JS, "_anchorSceneRowsFromContentParts")
final_answer = _function_body(MESSAGES_JS, "_anchorSceneFinalAnswerText")
content_text = _function_body(MESSAGES_JS, "_anchorSceneContentText")
visible_text = _function_body(MESSAGES_JS, "_anchorSceneContentVisibleText")
assert "const messageFinalAnswer=_anchorSceneFinalAnswerText(lastAsst);" in complete
assert "const finalAnswer=_anchorSceneCleanText(messageFinalAnswer)" in complete
assert "_anchorSceneRowsByMessageIndex(messages,turnStart,lastAsstIndex,{includeFinal:true})" in complete
assert "for(let idx=turnStart+1;idx<=lastAsstIndex;idx+=1)" in complete
assert "options=(options&&typeof options==='object')?options:{};" in rows_by_message
assert "const endIndex=options&&options.includeFinal?lastAsstIndex+1:lastAsstIndex;" in rows_by_message
assert "const contentRows=_anchorSceneRowsFromContentParts(message,idx,{isFinalMessage:idx===lastAsstIndex});" in rows_by_message
assert "part.type==='tool_use'" in content_rows
assert "const isFinalMessage=!!options.isFinalMessage;" in content_rows
assert "if(!part||typeof part!=='object'){" in content_rows
assert "if(isFinalMessage&&i>lastToolIndex) 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")
+10 -1
View File
@@ -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