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:
1 parent
5cd070ae76
commit
e2ade27bfe
10 files changed
+3151
-72
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user