import json from collections import OrderedDict from types import SimpleNamespace def _client_anchor_scene_message_ref(message): content = message.get("content") if isinstance(message, dict) else "" if isinstance(content, list): parts = [] for part in content: if isinstance(part, dict): parts.append(str(part.get("text") or part.get("content") or part.get("input_text") or "")) else: parts.append(str(part or "")) content = "\n".join(parts) payload = { "role": str(message.get("role") or ""), "content": " ".join(str(content or "").split()), "timestamp": message.get("_ts") or message.get("timestamp") or "", } return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) def _anchor_scene_visible_semantics(scene, *, include_terminal=False): semantics = [] for row in scene.get("activity_rows") or []: role = row.get("role") if role == "terminal" and not include_terminal: continue if role in {"prose", "thinking"}: semantics.append( { "role": role, "kind": row.get("kind"), "text": " ".join(str(row.get("text") or "").split()), } ) continue if role == "tool": 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 "args" in tool else payload.get("args") if args is None: args = {} semantics.append( { "role": role, "kind": row.get("kind"), "status": row.get("status"), "tool_call_id": row.get("tool_call_id") or tool.get("id") or payload.get("tid"), "name": tool.get("name") or payload.get("name"), "args": args, "done": tool.get("done"), } ) continue if role == "terminal": semantics.append( { "role": role, "kind": row.get("kind"), "source_event_type": row.get("source_event_type"), "status": row.get("status"), } ) return semantics def test_anchor_scene_visible_semantics_preserves_empty_tool_args(): scene = { "activity_rows": [ { "role": "tool", "kind": "tool_completed", "status": "completed", "tool_call_id": "call-1", "tool": {"id": "call-1", "name": "terminal", "args": {}}, "payload": {"tid": "call-1", "name": "terminal", "args": {"command": "stale"}}, } ] } assert _anchor_scene_visible_semantics(scene)[0]["args"] == {} def test_anchor_scene_persistence_round_trip_outside_provider_messages(tmp_path, monkeypatch): from api import models, routes from api.models import Session session_dir = tmp_path / "sessions" session_dir.mkdir() monkeypatch.setattr(models, "SESSION_DIR", session_dir) monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json") monkeypatch.setattr(models, "SESSIONS", OrderedDict()) monkeypatch.setattr(routes, "SESSION_DIR", session_dir) monkeypatch.setattr(routes, "SESSIONS", models.SESSIONS) session = Session( session_id="anchorpersist1", title="Anchor persistence", messages=[ {"role": "user", "content": "question"}, {"role": "assistant", "content": "final answer", "timestamp": 10.0}, ], ) session.save(skip_index=True) scene = { "version": "activity_scene_v1", "mode": "compact_worklog", "activity_rows": [ { "row_id": "tool-1", "role": "tool", "tool_call_id": "call-1", "tool": {"id": "call-1", "name": "terminal", "args": {"command": "git status"}}, } ], "final_answer": "final answer", } request_body = { "session_id": "anchorpersist1", "stream_id": "stream-1", "message_index": 1, "message_ref": "stale-ref-after-content-normalization", "scene": scene, } captured = {} monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) monkeypatch.setattr(routes, "read_body", lambda handler: request_body) monkeypatch.setattr( routes, "j", lambda handler, payload, status=200, extra_headers=None: captured.update( payload=payload, status=status ) or True, ) assert routes.handle_post(SimpleNamespace(command="POST"), SimpleNamespace(path="/api/session/anchor-scene")) is True assert captured["status"] == 200 assert captured["payload"]["ok"] is True assert captured["payload"]["message_index"] == 1 raw = json.loads((session_dir / "anchorpersist1.json").read_text(encoding="utf-8")) assert "_anchor_activity_scene" not in raw["messages"][1] assert raw["messages"][1]["content"] == "final answer" records = raw["anchor_activity_scenes"] assert len(records) == 1 record = next(iter(records.values())) assert record["message_index"] == 1 assert record["stream_id"] == "stream-1" assert record["scene"]["version"] == "activity_scene_v1" loaded = Session.load("anchorpersist1") hydrated = routes._hydrate_anchor_activity_scenes( loaded.messages, loaded.anchor_activity_scenes, message_offset=0, ) assert "_anchor_activity_scene" not in loaded.messages[1] assert hydrated[1]["_anchor_stream_id"] == "stream-1" assert hydrated[1]["_anchor_activity_scene"]["final_answer"] == "final answer" assert hydrated[1]["_anchor_activity_scene"]["activity_rows"][0]["tool_call_id"] == "call-1" def test_anchor_scene_persistence_rejects_cross_profile_write(tmp_path, monkeypatch): """#4411 security: /api/session/anchor-scene must not persist a scene onto a session that isn't visible to the active request profile. _get_or_materialize_session loads by id with no profile scoping, so the handler must apply the same _session_visible_to_active_profile guard GET /api/session uses — returning 404 and leaving anchor_activity_scenes untouched (no cross-profile write).""" from api import models, routes from api.models import Session session_dir = tmp_path / "sessions" session_dir.mkdir() monkeypatch.setattr(models, "SESSION_DIR", session_dir) monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json") monkeypatch.setattr(models, "SESSIONS", OrderedDict()) monkeypatch.setattr(routes, "SESSION_DIR", session_dir) monkeypatch.setattr(routes, "SESSIONS", models.SESSIONS) session = Session( session_id="foreignprofile1", title="Owned by profile B", messages=[ {"role": "user", "content": "question"}, {"role": "assistant", "content": "final answer", "timestamp": 10.0}, ], ) session.profile = "profile-b" session.save(skip_index=True) scene = { "version": "activity_scene_v1", "mode": "compact_worklog", "activity_rows": [ { "row_id": "tool-1", "role": "tool", "tool_call_id": "call-1", "tool": {"id": "call-1", "name": "terminal", "args": {"command": "git status"}}, } ], "final_answer": "final answer", } request_body = { "session_id": "foreignprofile1", "stream_id": "stream-1", "message_index": 1, "message_ref": "ref", "scene": scene, } captured = {} monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) monkeypatch.setattr(routes, "read_body", lambda handler: request_body) # Request runs under a profile that CANNOT see profile-b's session. monkeypatch.setattr( routes, "_session_visible_to_active_profile", lambda session_profile, handler=None: session_profile not in ("profile-b",), ) monkeypatch.setattr( routes, "bad", lambda handler, msg, status=400, extra_headers=None: captured.update( error=msg, status=status ) or True, ) monkeypatch.setattr( routes, "j", lambda handler, payload, status=200, extra_headers=None: captured.update( payload=payload, status=status ) or True, ) assert routes.handle_post( SimpleNamespace(command="POST"), SimpleNamespace(path="/api/session/anchor-scene"), ) is True # #7710: the generic request-guard now mirrors the detail-load # endpoint's contract — a session owned by a KNOWN other profile # yields 409 ``session_profile_mismatch`` so the client can offer # to switch to it (#5419). The 404 self-heal path is preserved for # the None-profile (unknown/legacy) case. assert captured.get("status") == 409, captured assert captured.get("payload") == { "error": "Session belongs to a different profile", "code": "session_profile_mismatch", "session_id": "foreignprofile1", "profile": "profile-b", } raw = json.loads((session_dir / "foreignprofile1.json").read_text(encoding="utf-8")) assert not raw.get("anchor_activity_scenes"), ( "cross-profile request must NOT persist anchor_activity_scenes" ) def test_anchor_scene_hydration_skips_ambiguous_ref_match(monkeypatch): """#4411 defense-in-depth: when two assistant messages share a ref (byte-identical content + identical _ts), the read-side hydration must NOT attach the same scene to both (which would render duplicate worklog groups) — mirroring the write-side _find_anchor_scene_message ambiguity guard. The ambiguous ref falls through to the positional index match instead.""" from api import routes # Two assistant messages that normalize to the SAME ref. messages = [ {"role": "assistant", "content": "dup answer", "timestamp": 5.0}, {"role": "assistant", "content": "dup answer", "timestamp": 5.0}, ] ref = routes._assistant_anchor_scene_message_ref(messages[0]) assert ref == routes._assistant_anchor_scene_message_ref(messages[1]), "refs must collide for this test" # A single record keyed by that ambiguous ref, index-targeted at message 0. records = { ref: { "version": "anchor_activity_scene_record_v1", "message_index": 0, "message_ref": ref, "scene": {"version": "activity_scene_v1", "activity_rows": [], "final_answer": "dup answer"}, } } out = routes._hydrate_anchor_activity_scenes(messages, records, message_offset=0) attached = [("_anchor_activity_scene" in m) for m in out] # The ambiguous ref must NOT fan the scene out to BOTH messages. assert attached.count(True) <= 1, ( f"ambiguous ref must not double-attach the scene; got {attached}" ) def test_anchor_scene_hydration_rejects_stale_index_fallback_when_final_answer_mismatches(): from api import routes messages = [ {"role": "user", "content": "old question"}, {"role": "assistant", "content": "old final"}, {"role": "user", "content": "new question"}, {"role": "assistant", "content": "new final"}, ] records = { "stale-ref": { "message_index": 3, "message_ref": "missing-ref-after-window-shift", "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "old final", "activity_rows": [ {"row_id": "old-tool", "role": "tool", "tool_call_id": "old-call"} ], }, } } hydrated = routes._hydrate_anchor_activity_scenes(messages, records) assert "_anchor_activity_scene" not in hydrated[3] def test_anchor_scene_persistence_rejects_invalid_scene(tmp_path, monkeypatch): from api import models, routes from api.models import Session session_dir = tmp_path / "sessions" session_dir.mkdir() monkeypatch.setattr(models, "SESSION_DIR", session_dir) monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json") monkeypatch.setattr(models, "SESSIONS", OrderedDict()) monkeypatch.setattr(routes, "SESSION_DIR", session_dir) monkeypatch.setattr(routes, "SESSIONS", models.SESSIONS) Session( session_id="anchorpersist2", messages=[{"role": "assistant", "content": "answer"}], ).save(skip_index=True) captured = {} monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) monkeypatch.setattr( routes, "read_body", lambda handler: { "session_id": "anchorpersist2", "message_index": 0, "scene": {"version": "wrong", "activity_rows": []}, }, ) monkeypatch.setattr( routes, "bad", lambda handler, msg, status=400: captured.update(error=msg, status=status) or True, ) assert routes.handle_post(SimpleNamespace(command="POST"), SimpleNamespace(path="/api/session/anchor-scene")) is True assert captured["status"] == 400 assert "activity_scene_v1" in captured["error"] def test_anchor_scene_persistence_prefers_unique_ref_over_stale_index(tmp_path, monkeypatch): from api import models, routes from api.models import Session session_dir = tmp_path / "sessions" session_dir.mkdir() monkeypatch.setattr(models, "SESSION_DIR", session_dir) monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json") monkeypatch.setattr(models, "SESSIONS", OrderedDict()) monkeypatch.setattr(routes, "SESSION_DIR", session_dir) monkeypatch.setattr(routes, "SESSIONS", models.SESSIONS) messages = [ {"role": "user", "content": "old question"}, {"role": "assistant", "content": "old final"}, {"role": "user", "content": "new question"}, {"role": "assistant", "content": "new final"}, ] Session(session_id="anchorpersist_ref", messages=messages).save(skip_index=True) client_ref = _client_anchor_scene_message_ref(messages[3]) assert client_ref != routes._assistant_anchor_scene_message_ref(messages[3]) captured = {} monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) monkeypatch.setattr( routes, "read_body", lambda handler: { "session_id": "anchorpersist_ref", "message_index": 1, "message_ref": client_ref, "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "activity_rows": [{"row_id": "tool-1", "role": "tool"}], "final_answer": "new final", }, }, ) monkeypatch.setattr( routes, "j", lambda handler, payload, status=200, extra_headers=None: captured.update( payload=payload, status=status ) or True, ) assert routes.handle_post(SimpleNamespace(command="POST"), SimpleNamespace(path="/api/session/anchor-scene")) is True assert captured["payload"]["message_index"] == 3 raw = json.loads((session_dir / "anchorpersist_ref.json").read_text(encoding="utf-8")) record = next(iter(raw["anchor_activity_scenes"].values())) assert record["message_index"] == 3 assert record["scene"]["final_answer"] == "new final" def test_anchor_scene_persistence_rejects_duplicate_client_ref_over_stale_index(tmp_path, monkeypatch): from api import models, routes from api.models import Session session_dir = tmp_path / "sessions" session_dir.mkdir() monkeypatch.setattr(models, "SESSION_DIR", session_dir) monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json") monkeypatch.setattr(models, "SESSIONS", OrderedDict()) monkeypatch.setattr(routes, "SESSION_DIR", session_dir) monkeypatch.setattr(routes, "SESSIONS", models.SESSIONS) messages = [ {"role": "user", "content": "old question"}, {"role": "assistant", "content": "same final"}, {"role": "user", "content": "new question"}, {"role": "assistant", "content": "same final"}, ] Session(session_id="anchorpersist_duplicate_ref", messages=messages).save(skip_index=True) captured = {} monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) monkeypatch.setattr( routes, "read_body", lambda handler: { "session_id": "anchorpersist_duplicate_ref", "message_index": 1, "message_ref": _client_anchor_scene_message_ref(messages[3]), "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "activity_rows": [{"row_id": "tool-1", "role": "tool"}], "final_answer": "same final", }, }, ) monkeypatch.setattr( routes, "bad", lambda handler, msg, status=400: captured.update(error=msg, status=status) or True, ) assert routes.handle_post(SimpleNamespace(command="POST"), SimpleNamespace(path="/api/session/anchor-scene")) is True assert captured["status"] == 404 assert captured["error"] == "Assistant message not found" raw = json.loads((session_dir / "anchorpersist_duplicate_ref.json").read_text(encoding="utf-8")) assert not raw.get("anchor_activity_scenes") def test_anchor_scene_persistence_converts_window_index_to_full_index(tmp_path, monkeypatch): from api import models, routes from api.models import Session session_dir = tmp_path / "sessions" session_dir.mkdir() monkeypatch.setattr(models, "SESSION_DIR", session_dir) monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json") monkeypatch.setattr(models, "SESSIONS", OrderedDict()) monkeypatch.setattr(routes, "SESSION_DIR", session_dir) monkeypatch.setattr(routes, "SESSIONS", models.SESSIONS) Session( session_id="anchorpersist_window", messages=[ {"role": "user", "content": "old question"}, {"role": "assistant", "content": "old final"}, {"role": "user", "content": "new question"}, {"role": "assistant", "content": "new final"}, ], ).save(skip_index=True) captured = {} monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) monkeypatch.setattr( routes, "read_body", lambda handler: { "session_id": "anchorpersist_window", "message_index": 1, "message_window_index": 1, "message_offset": 2, "message_ref": "stale-ref-after-content-normalization", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "activity_rows": [{"row_id": "tool-1", "role": "tool"}], "final_answer": "new final", }, }, ) monkeypatch.setattr( routes, "j", lambda handler, payload, status=200, extra_headers=None: captured.update( payload=payload, status=status ) or True, ) assert routes.handle_post(SimpleNamespace(command="POST"), SimpleNamespace(path="/api/session/anchor-scene")) is True assert captured["payload"]["message_index"] == 3 raw = json.loads((session_dir / "anchorpersist_window.json").read_text(encoding="utf-8")) record = next(iter(raw["anchor_activity_scenes"].values())) assert record["message_index"] == 3 assert record["message_ref"] == routes._assistant_anchor_scene_message_ref(raw["messages"][3]) def test_anchor_scene_persistence_rejects_unmatched_ref_without_index(tmp_path, monkeypatch): from api import models, routes from api.models import Session session_dir = tmp_path / "sessions" session_dir.mkdir() monkeypatch.setattr(models, "SESSION_DIR", session_dir) monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json") monkeypatch.setattr(models, "SESSIONS", OrderedDict()) monkeypatch.setattr(routes, "SESSION_DIR", session_dir) monkeypatch.setattr(routes, "SESSIONS", models.SESSIONS) Session( session_id="anchorpersist_no_index", messages=[ {"role": "user", "content": "question"}, {"role": "assistant", "content": "final"}, ], ).save(skip_index=True) captured = {} monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) monkeypatch.setattr( routes, "read_body", lambda handler: { "session_id": "anchorpersist_no_index", "message_ref": "missing-ref", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "activity_rows": [{"row_id": "tool-1", "role": "tool"}], }, }, ) monkeypatch.setattr( routes, "bad", lambda handler, msg, status=400: captured.update(error=msg, status=status) or True, ) assert routes.handle_post(SimpleNamespace(command="POST"), SimpleNamespace(path="/api/session/anchor-scene")) is True assert captured["status"] == 404 assert captured["error"] == "Assistant message not found" def test_anchor_scene_persistence_rejects_ref_miss_stale_index_mismatch(tmp_path, monkeypatch): from api import models, routes from api.models import Session session_dir = tmp_path / "sessions" session_dir.mkdir() monkeypatch.setattr(models, "SESSION_DIR", session_dir) monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json") monkeypatch.setattr(models, "SESSIONS", OrderedDict()) monkeypatch.setattr(routes, "SESSION_DIR", session_dir) monkeypatch.setattr(routes, "SESSIONS", models.SESSIONS) Session( session_id="anchorpersist_stale_index_mismatch", messages=[ {"role": "user", "content": "old question"}, {"role": "assistant", "content": "old final"}, {"role": "user", "content": "new question"}, {"role": "assistant", "content": "new final"}, ], ).save(skip_index=True) captured = {} monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) monkeypatch.setattr( routes, "read_body", lambda handler: { "session_id": "anchorpersist_stale_index_mismatch", "message_index": 1, "message_ref": "stale-ref-after-content-normalization", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "activity_rows": [{"row_id": "tool-1", "role": "tool"}], "final_answer": "new final", }, }, ) monkeypatch.setattr( routes, "bad", lambda handler, msg, status=400: captured.update(error=msg, status=status) or True, ) assert routes.handle_post(SimpleNamespace(command="POST"), SimpleNamespace(path="/api/session/anchor-scene")) is True assert captured["status"] == 404 assert captured["error"] == "Assistant message not found" raw = json.loads((session_dir / "anchorpersist_stale_index_mismatch.json").read_text(encoding="utf-8")) assert not raw.get("anchor_activity_scenes") def test_anchor_scene_hydration_repairs_tail_only_scene_from_full_turn(): from api import routes final_answer = ( "final answer with enough detail to be the answer and a shared verification paragraph " "about cron proxy fallback removal, 127.0.0.1:7890, direct git pulls, and durable Worklog ordering" ) stale_final_draft = ( "draft answer with enough detail to be the answer and a shared verification paragraph " "about cron proxy fallback removal, 127.0.0.1:7890, direct git pulls, and durable Worklog ordering" ) short_stale_final_draft = "cron 127.0.0.1:7890 fallback " + ("x " * 45) + "Worklog" messages = [ {"role": "user", "content": "question"}, { "role": "assistant", "content": "first progress", "reasoning": "thinking near first progress", "tool_calls": [{"id": "call-1", "function": {"name": "terminal"}}], }, {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, {"role": "assistant", "content": "second progress"}, {"role": "assistant", "content": final_answer}, ] old_scene = { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "", "activity_rows": [ { "row_id": "tail-prose", "role": "prose", "kind": "process_prose", "source_event_type": "token", "text": "second progress", }, { "row_id": "bad-final-prefix", "role": "thinking", "kind": "reasoning", "source_event_type": "reasoning", "text": final_answer, }, { "row_id": "tail-thinking", "role": "thinking", "kind": "reasoning", "source_event_type": "reasoning", "text": "thinking near first progress", }, { "row_id": "tail-final-draft", "role": "prose", "kind": "process_prose", "source_event_type": "token", "text": stale_final_draft, }, { "row_id": "tail-short-final-draft", "role": "prose", "kind": "process_prose", "source_event_type": "token", "text": short_stale_final_draft, }, {"row_id": "done", "role": "terminal", "kind": "terminal_status", "source_event_type": "done"}, ], } records = { "record": { "message_index": 4, "message_ref": routes._assistant_anchor_scene_message_ref(messages[4]), "stream_id": "stream-1", "scene": old_scene, } } hydrated = routes._hydrate_anchor_activity_scenes( messages, records, message_offset=0, tool_calls=[ { "assistant_msg_idx": 1, "tid": "call-1", "name": "terminal", "preview": "running", "snippet": "ok", } ], ) scene = hydrated[4]["_anchor_activity_scene"] rows = scene["activity_rows"] prose_texts = [row.get("text") for row in rows if row.get("role") == "prose"] tool_ids = [row.get("tool_call_id") for row in rows if row.get("role") == "tool"] thinking_texts = [row.get("text") for row in rows if row.get("role") == "thinking"] chronological = [ (row.get("role"), row.get("text") or row.get("tool_call_id")) for row in rows if row.get("role") != "terminal" ] assert scene["final_answer"] == final_answer assert prose_texts == ["first progress", "second progress"] assert tool_ids == ["call-1"] assert thinking_texts == ["thinking near first progress"] assert chronological[:4] == [ ("prose", "first progress"), ("thinking", "thinking near first progress"), ("tool", "call-1"), ("prose", "second progress"), ] assert rows[-1]["role"] == "terminal" def test_anchor_scene_hydration_backfills_turn_duration_from_final_message(): from api import routes messages = [ {"role": "user", "content": "question"}, {"role": "assistant", "content": "final answer", "_turnDuration": 731.2}, ] records = { "record": { "message_index": 1, "message_ref": routes._assistant_anchor_scene_message_ref(messages[1]), "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "final answer", "activity_rows": [ {"row_id": "done", "role": "terminal", "kind": "terminal_status"} ], }, } } hydrated = routes._hydrate_anchor_activity_scenes(messages, records) assert hydrated[1]["_anchor_activity_scene"]["turn_duration"] == 731.2 def test_anchor_scene_hydration_promotes_final_content_array_tool_use_to_ordered_rows(): from api import routes messages = [ {"role": "user", "content": "question"}, { "role": "assistant", "content": [ "Let me inspect the files first.", {"type": "tool_use", "tool_use_id": "toolu_content", "tool_name": "grep", "args": {"pattern": "TODO"}}, "Found it,", {"type": "text", "text": "here's the fix."}, ], "tool_calls": [ { "id": "toolu_message", "name": "grep", "input": {"pattern": "TODO"}, "snippet": "TODO in static/messages.js", "started_at": 100, } ], }, ] records = { "record": { "message_index": 1, "message_ref": routes._assistant_anchor_scene_message_ref(messages[1]), "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "", "activity_rows": [ {"row_id": "done", "role": "terminal", "kind": "terminal_status", "source_event_type": "done"} ], }, } } hydrated = routes._hydrate_anchor_activity_scenes( messages, records, tool_calls=[ { "assistant_msg_idx": 1, "tid": "toolu_durable", "name": "grep", "input": {"pattern": "TODO"}, "snippet": "TODO in static/messages.js", "started_at": 100, } ], ) scene = hydrated[1]["_anchor_activity_scene"] rows = scene["activity_rows"] activity = [ (row.get("role"), row.get("text") or row.get("tool_call_id")) for row in rows if row.get("role") != "terminal" ] assert scene["final_answer"] == "Found it,\nhere's the fix." assert activity == [ ("prose", "Let me inspect the files first."), ("tool", "toolu_content"), ] assert rows[1]["tool"]["name"] == "grep" assert rows[1]["tool"]["args"] == {"pattern": "TODO"} assert rows[1]["tool"]["snippet"] == "TODO in static/messages.js" assert len([row for row in rows if row.get("role") == "tool"]) == 1 assert rows[-1]["role"] == "terminal" def test_anchor_scene_hydration_preserves_non_final_post_tool_text(): from api import routes messages = [ {"role": "user", "content": "question"}, { "role": "assistant", "content": [ "I will inspect first.", {"type": "tool_use", "tool_use_id": "toolu_content", "tool_name": "grep"}, {"type": "text", "text": "I found the relevant file."}, {"type": "thinking", "text": "Need one more check."}, ], }, {"role": "assistant", "content": "final answer"}, ] records = { "record": { "message_index": 2, "message_ref": routes._assistant_anchor_scene_message_ref(messages[2]), "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "final answer", "activity_rows": [], }, } } hydrated = routes._hydrate_anchor_activity_scenes(messages, records, tool_calls=[]) scene = hydrated[2]["_anchor_activity_scene"] rows = scene["activity_rows"] activity = [ (row.get("role"), row.get("text") or row.get("tool_call_id")) for row in rows ] assert scene["final_answer"] == "final answer" assert activity == [ ("prose", "I will inspect first."), ("tool", "toolu_content"), ("prose", "I found the relevant file."), ("thinking", "Need one more check."), ] def test_anchor_scene_hydration_keeps_final_tail_thinking_as_activity_only(): from api import routes messages = [ {"role": "user", "content": "question"}, { "role": "assistant", "content": [ {"type": "text", "text": "Let me check first."}, { "type": "tool_use", "tool_use_id": "toolu_content", "tool_name": "terminal", "args": {"cmd": "ls"}, }, {"type": "thinking", "text": "Tail thinking must stay activity."}, {"type": "reasoning", "reasoning": "Tail reasoning key must stay activity."}, {"type": "text", "text": "Final visible answer."}, ], "tool_calls": [ { "id": "toolu_message", "name": "terminal", "args": {"cmd": "ls"}, "snippet": "OUTPUT", "started_at": 100, } ], }, ] records = { "record": { "message_index": 1, "message_ref": routes._assistant_anchor_scene_message_ref(messages[1]), "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "", "activity_rows": [], }, } } hydrated = routes._hydrate_anchor_activity_scenes(messages, records, tool_calls=[]) scene = hydrated[1]["_anchor_activity_scene"] rows = scene["activity_rows"] activity = [ (row.get("role"), row.get("text") or row.get("tool_call_id")) for row in rows ] assert scene["final_answer"] == "Final visible answer." assert activity == [ ("prose", "Let me check first."), ("tool", "toolu_content"), ("thinking", "Tail thinking must stay activity."), ("thinking", "Tail reasoning key must stay activity."), ] assert rows[1]["tool"]["snippet"] == "OUTPUT" assert len([row for row in rows if row.get("role") == "tool"]) == 1 def test_anchor_scene_hydration_promotes_output_text_content_tail_to_final_answer(): from api import routes messages = [ {"role": "user", "content": "question"}, { "role": "assistant", "content": [ {"type": "text", "text": "Let me check first."}, { "type": "tool_use", "tool_use_id": "toolu_content", "tool_name": "terminal", "args": {"cmd": "ls"}, }, {"type": "output_text", "content": "Final answer from content field."}, ], "tool_calls": [ { "id": "toolu_message", "name": "terminal", "args": {"cmd": "ls"}, "snippet": "OUTPUT", "started_at": 100, } ], }, ] records = { "record": { "message_index": 1, "message_ref": routes._assistant_anchor_scene_message_ref(messages[1]), "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "", "activity_rows": [], }, } } hydrated = routes._hydrate_anchor_activity_scenes(messages, records, tool_calls=[]) scene = hydrated[1]["_anchor_activity_scene"] rows = scene["activity_rows"] activity = [ (row.get("role"), row.get("text") or row.get("tool_call_id")) for row in rows ] assert scene["final_answer"] == "Final answer from content field." assert activity == [ ("prose", "Let me check first."), ("tool", "toolu_content"), ] assert rows[1]["tool"]["snippet"] == "OUTPUT" assert len([row for row in rows if row.get("role") == "tool"]) == 1 def test_anchor_scene_hydration_restores_durable_body_after_message_tool_merge(): from api import routes full_output = "X" * 9000 capped_preview = full_output[:4000] messages = [ {"role": "user", "content": "question"}, { "role": "assistant", "content": [ "I will inspect first.", { "type": "tool_use", "tool_use_id": "toolu_content", "tool_name": "terminal", "args": {"cmd": "long-output"}, }, "Done.", ], "tool_calls": [ { "id": "toolu_message", "name": "terminal", "input": {"cmd": "long-output"}, "snippet": capped_preview, "started_at": 100, } ], }, ] records = { "record": { "message_index": 1, "message_ref": routes._assistant_anchor_scene_message_ref(messages[1]), "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "", "activity_rows": [], }, } } hydrated = routes._hydrate_anchor_activity_scenes( messages, records, tool_calls=[ { "assistant_msg_idx": 1, "tid": "toolu_durable", "name": "terminal", "input": {"cmd": "long-output"}, "snippet": full_output, "started_at": 100, } ], ) tools = [row for row in hydrated[1]["_anchor_activity_scene"]["activity_rows"] if row.get("role") == "tool"] assert len(tools) == 1 assert tools[0]["tool_call_id"] == "toolu_content" assert tools[0]["tool"]["snippet"] == full_output assert tools[0]["payload"]["snippet"] == full_output def test_anchor_scene_hydration_keeps_third_same_command_id_distinct_after_alt_id_match(): from api import routes messages = [ {"role": "user", "content": "question"}, { "role": "assistant", "content": [ "First check.", { "type": "tool_use", "tool_use_id": "content-a", "tool_name": "terminal", "args": {"cmd": "ls"}, }, "Second check.", ], "tool_calls": [ { "id": "message-a", "name": "terminal", "input": {"cmd": "ls"}, "snippet": "OUTPUT A", "started_at": 100, } ], }, ] records = { "record": { "message_index": 1, "message_ref": routes._assistant_anchor_scene_message_ref(messages[1]), "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "", "activity_rows": [], }, } } hydrated = routes._hydrate_anchor_activity_scenes( messages, records, tool_calls=[ { "assistant_msg_idx": 1, "tid": "durable-b", "name": "terminal", "input": {"cmd": "ls"}, "snippet": "OUTPUT B", "started_at": 200, } ], ) tools = [row for row in hydrated[1]["_anchor_activity_scene"]["activity_rows"] if row.get("role") == "tool"] by_id = {row.get("tool_call_id"): row for row in tools} assert by_id["content-a"]["tool"]["snippet"] == "OUTPUT A" assert "message-a" not in by_id assert by_id["durable-b"]["tool"]["snippet"] == "OUTPUT B" assert len(tools) == 2 def test_anchor_scene_hydration_keeps_identical_output_repeat_distinct_after_alt_id_match(): from api import routes messages = [ {"role": "user", "content": "question"}, { "role": "assistant", "content": [ "First check.", { "type": "tool_use", "tool_use_id": "content-a", "tool_name": "terminal", "args": {"cmd": "ls"}, }, "Second check.", ], "tool_calls": [ { "id": "message-a", "name": "terminal", "input": {"cmd": "ls"}, "snippet": "SAME OUTPUT", } ], }, ] records = { "record": { "message_index": 1, "message_ref": routes._assistant_anchor_scene_message_ref(messages[1]), "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "", "activity_rows": [], }, } } hydrated = routes._hydrate_anchor_activity_scenes( messages, records, tool_calls=[ { "assistant_msg_idx": 1, "tid": "durable-b", "name": "terminal", "input": {"cmd": "ls"}, "snippet": "SAME OUTPUT", } ], ) tools = [row for row in hydrated[1]["_anchor_activity_scene"]["activity_rows"] if row.get("role") == "tool"] by_id = {row.get("tool_call_id"): row for row in tools} assert by_id["content-a"]["tool"]["snippet"] == "SAME OUTPUT" assert "message-a" not in by_id assert by_id["durable-b"]["tool"]["snippet"] == "SAME OUTPUT" assert len(tools) == 2 def test_anchor_scene_hydration_keeps_same_started_at_repeat_distinct_after_alt_id_match(): from api import routes messages = [ {"role": "user", "content": "question"}, { "role": "assistant", "content": [ "First check.", { "type": "tool_use", "tool_use_id": "content-a", "tool_name": "terminal", "args": {"cmd": "ls"}, }, "Second check.", ], "tool_calls": [ { "id": "message-a", "name": "terminal", "input": {"cmd": "ls"}, "snippet": "OUTPUT A", "started_at": 100, } ], }, ] records = { "record": { "message_index": 1, "message_ref": routes._assistant_anchor_scene_message_ref(messages[1]), "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "", "activity_rows": [], }, } } hydrated = routes._hydrate_anchor_activity_scenes( messages, records, tool_calls=[ { "assistant_msg_idx": 1, "tid": "durable-b", "name": "terminal", "input": {"cmd": "ls"}, "snippet": "OUTPUT B", "started_at": 100, } ], ) tools = [row for row in hydrated[1]["_anchor_activity_scene"]["activity_rows"] if row.get("role") == "tool"] by_id = {row.get("tool_call_id"): row for row in tools} assert by_id["content-a"]["tool"]["snippet"] == "OUTPUT A" assert "message-a" not in by_id assert by_id["durable-b"]["tool"]["snippet"] == "OUTPUT B" assert len(tools) == 2 def test_anchor_scene_hydration_keeps_short_persisted_body_after_durable_merge(): from api import routes full_output = "short output line\nwith more detail that came later" short_body = "short output line" messages = [ {"role": "user", "content": "question"}, { "role": "assistant", "content": [ "I will inspect first.", { "type": "tool_use", "tool_use_id": "toolu_content", "tool_name": "terminal", "args": {"cmd": "short-output"}, }, "Done.", ], "tool_calls": [ { "id": "toolu_message", "name": "terminal", "input": {"cmd": "short-output"}, "snippet": short_body, "started_at": 100, } ], }, ] records = { "record": { "message_index": 1, "message_ref": routes._assistant_anchor_scene_message_ref(messages[1]), "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "", "activity_rows": [], }, } } hydrated = routes._hydrate_anchor_activity_scenes( messages, records, tool_calls=[ { "assistant_msg_idx": 1, "tid": "toolu_durable", "name": "terminal", "input": {"cmd": "short-output"}, "snippet": full_output, "started_at": 100, } ], ) tools = [row for row in hydrated[1]["_anchor_activity_scene"]["activity_rows"] if row.get("role") == "tool"] assert len(tools) == 1 assert tools[0]["tool"]["snippet"] == short_body assert tools[0]["payload"]["snippet"] == short_body def test_anchor_scene_hydration_merges_missing_args_after_content_tool_match(): from api import routes messages = [ {"role": "user", "content": "question"}, { "role": "assistant", "content": [ "I will patch the file.", { "type": "tool_use", "tool_use_id": "patch-content", "tool_name": "edit_file", "args": {"path": "x.py"}, }, "Done.", ], "tool_calls": [ { "id": "patch-message", "name": "edit_file", "input": { "path": "x.py", "old_string": "old", "new_string": "new", }, "snippet": "@@ -1 +1 @@\n-old\n+new", } ], }, ] records = { "record": { "message_index": 1, "message_ref": routes._assistant_anchor_scene_message_ref(messages[1]), "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "", "activity_rows": [], }, } } hydrated = routes._hydrate_anchor_activity_scenes(messages, records, tool_calls=[]) tools = [ row for row in hydrated[1]["_anchor_activity_scene"]["activity_rows"] if row.get("role") == "tool" ] assert len(tools) == 1 assert tools[0]["tool"]["args"] == { "path": "x.py", "old_string": "old", "new_string": "new", } assert tools[0]["payload"]["args"] == { "path": "x.py", "old_string": "old", "new_string": "new", } def test_anchor_scene_hydration_keeps_consumed_different_name_tool_distinct(): from api import routes messages = [ {"role": "user", "content": "edit then inspect"}, { "role": "assistant", "content": [ "Edit the file.", { "type": "tool_use", "tool_use_id": "content-a", "tool_name": "edit_file", "args": {"path": "x.py"}, }, "Inspect it.", ], "tool_calls": [ { "id": "content-a", "name": "edit_file", "input": {"path": "x.py"}, "snippet": "EDITED", } ], }, ] records = { "record": { "message_index": 1, "message_ref": routes._assistant_anchor_scene_message_ref(messages[1]), "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "", "activity_rows": [], }, } } hydrated = routes._hydrate_anchor_activity_scenes( messages, records, tool_calls=[ { "assistant_msg_idx": 1, "tid": "live-b", "name": "terminal", "input": {"path": "x.py"}, "snippet": "TERMINAL OUTPUT", } ], ) tools = [ row for row in hydrated[1]["_anchor_activity_scene"]["activity_rows"] if row.get("role") == "tool" ] by_id = {row["tool"]["id"]: row for row in tools} assert by_id["content-a"]["tool"]["name"] == "edit_file" assert by_id["content-a"]["tool"]["snippet"] == "EDITED" assert by_id["live-b"]["tool"]["name"] == "terminal" assert by_id["live-b"]["tool"]["snippet"] == "TERMINAL OUTPUT" def test_anchor_scene_hydration_does_not_position_merge_ambiguous_different_id_tools(): from api import routes messages = [ {"role": "user", "content": "question"}, { "role": "assistant", "content": [ "First check.", {"type": "tool_use", "tool_use_id": "content-a", "tool_name": "terminal"}, "Second check.", {"type": "tool_use", "tool_use_id": "content-b", "tool_name": "terminal"}, "Done.", ], }, ] records = { "record": { "message_index": 1, "message_ref": routes._assistant_anchor_scene_message_ref(messages[1]), "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "", "activity_rows": [], }, } } hydrated = routes._hydrate_anchor_activity_scenes( messages, records, tool_calls=[ {"assistant_msg_idx": 1, "tid": "message-b", "name": "terminal", "snippet": "OUTPUT B"}, {"assistant_msg_idx": 1, "tid": "message-a", "name": "terminal", "snippet": "OUTPUT A"}, ], ) rows = hydrated[1]["_anchor_activity_scene"]["activity_rows"] tools = [row for row in rows if row.get("role") == "tool"] by_id = {row.get("tool_call_id"): row for row in tools} assert by_id["content-a"]["tool"]["snippet"] == "" assert by_id["content-b"]["tool"]["snippet"] == "" assert by_id["message-a"]["tool"]["snippet"] == "OUTPUT A" assert by_id["message-b"]["tool"]["snippet"] == "OUTPUT B" def test_anchor_scene_hydration_does_not_name_merge_remaining_same_name_tool_after_exact_match(): from api import routes messages = [ {"role": "user", "content": "question"}, { "role": "assistant", "content": [ "First check.", {"type": "tool_use", "tool_use_id": "content-a", "tool_name": "terminal"}, "Second check.", {"type": "tool_use", "tool_use_id": "content-b", "tool_name": "terminal"}, "Done.", ], "tool_calls": [ {"id": "content-a", "name": "terminal", "snippet": "OUTPUT A"}, {"id": "message-b", "name": "terminal", "snippet": "OUTPUT B"}, ], }, ] records = { "record": { "message_index": 1, "message_ref": routes._assistant_anchor_scene_message_ref(messages[1]), "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "", "activity_rows": [], }, } } hydrated = routes._hydrate_anchor_activity_scenes(messages, records, tool_calls=[]) rows = hydrated[1]["_anchor_activity_scene"]["activity_rows"] tools = [row for row in rows if row.get("role") == "tool"] by_id = {row.get("tool_call_id"): row for row in tools} assert by_id["content-a"]["tool"]["snippet"] == "OUTPUT A" assert by_id["content-b"]["tool"]["snippet"] == "" assert by_id["message-b"]["tool"]["snippet"] == "OUTPUT B" def test_anchor_scene_hydration_merges_remaining_matching_tool_after_exact_match(): from api import routes messages = [ {"role": "user", "content": "question"}, { "role": "assistant", "content": [ "First check.", { "type": "tool_use", "tool_use_id": "content-a", "tool_name": "terminal", "args": {"cmd": "ls"}, }, "Second check.", { "type": "tool_use", "tool_use_id": "content-b", "tool_name": "terminal", "args": {"cmd": "pwd"}, }, "Done.", ], "tool_calls": [ {"id": "content-a", "name": "terminal", "input": {"cmd": "ls"}, "snippet": "OUTPUT A"}, {"id": "message-b", "name": "terminal", "input": {"cmd": "pwd"}, "snippet": "OUTPUT B"}, ], }, ] records = { "record": { "message_index": 1, "message_ref": routes._assistant_anchor_scene_message_ref(messages[1]), "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "", "activity_rows": [], }, } } hydrated = routes._hydrate_anchor_activity_scenes(messages, records, tool_calls=[]) rows = hydrated[1]["_anchor_activity_scene"]["activity_rows"] tools = [row for row in rows if row.get("role") == "tool"] by_id = {row.get("tool_call_id"): row for row in tools} assert by_id["content-a"]["tool"]["snippet"] == "OUTPUT A" assert by_id["content-b"]["tool"]["snippet"] == "OUTPUT B" assert "message-b" not in by_id assert len(tools) == 2 def test_anchor_scene_hydration_keeps_distinct_used_singleton_tool_call(): from api import routes messages = [ {"role": "user", "content": "question"}, { "role": "assistant", "content": [ "First check.", { "type": "tool_use", "tool_use_id": "content-a", "tool_name": "terminal", "args": {"cmd": "ls"}, }, "Second check.", ], "tool_calls": [ { "id": "content-a", "name": "terminal", "input": {"cmd": "ls"}, "snippet": "OUTPUT A", } ], }, ] records = { "record": { "message_index": 1, "message_ref": routes._assistant_anchor_scene_message_ref(messages[1]), "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "", "activity_rows": [], }, } } hydrated = routes._hydrate_anchor_activity_scenes( messages, records, tool_calls=[ { "assistant_msg_idx": 1, "tid": "durable-b", "name": "terminal", "input": {"cmd": "pwd"}, "snippet": "OUTPUT B", } ], ) rows = hydrated[1]["_anchor_activity_scene"]["activity_rows"] tools = [row for row in rows if row.get("role") == "tool"] by_id = {row.get("tool_call_id"): row for row in tools} assert by_id["content-a"]["tool"]["snippet"] == "OUTPUT A" assert by_id["durable-b"]["tool"]["snippet"] == "OUTPUT B" assert len(tools) == 2 def test_anchor_scene_hydration_keeps_same_command_used_singleton_tool_distinct(): from api import routes messages = [ {"role": "user", "content": "question"}, { "role": "assistant", "content": [ "First check.", { "type": "tool_use", "tool_use_id": "content-a", "tool_name": "terminal", "args": {"cmd": "ls"}, }, "Second check.", ], "tool_calls": [ { "id": "content-a", "name": "terminal", "input": {"cmd": "ls"}, "snippet": "OUTPUT A", } ], }, ] records = { "record": { "message_index": 1, "message_ref": routes._assistant_anchor_scene_message_ref(messages[1]), "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "", "activity_rows": [], }, } } hydrated = routes._hydrate_anchor_activity_scenes( messages, records, tool_calls=[ { "assistant_msg_idx": 1, "tid": "durable-b", "name": "terminal", "input": {"cmd": "ls"}, "snippet": "OUTPUT B", } ], ) rows = hydrated[1]["_anchor_activity_scene"]["activity_rows"] tools = [row for row in rows if row.get("role") == "tool"] by_id = {row.get("tool_call_id"): row for row in tools} assert by_id["content-a"]["tool"]["snippet"] == "OUTPUT A" assert by_id["durable-b"]["tool"]["snippet"] == "OUTPUT B" assert len(tools) == 2 def test_anchor_scene_hydration_keeps_anonymous_used_singleton_tool_distinct(): from api import routes messages = [ {"role": "user", "content": "question"}, { "role": "assistant", "content": [ "First check.", { "type": "tool_use", "tool_use_id": "content-a", "tool_name": "terminal", "args": {"cmd": "ls"}, }, "Second check.", ], "tool_calls": [ { "id": "content-a", "name": "terminal", "input": {"cmd": "ls"}, "snippet": "OUTPUT A", } ], }, ] records = { "record": { "message_index": 1, "message_ref": routes._assistant_anchor_scene_message_ref(messages[1]), "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "", "activity_rows": [], }, } } hydrated = routes._hydrate_anchor_activity_scenes( messages, records, tool_calls=[ { "assistant_msg_idx": 1, "name": "terminal", "input": {"cmd": "ls"}, "snippet": "OUTPUT B", } ], ) rows = hydrated[1]["_anchor_activity_scene"]["activity_rows"] tools = [row for row in rows if row.get("role") == "tool"] snippets = sorted(row["tool"]["snippet"] for row in tools) assert len(tools) == 2 assert snippets == ["OUTPUT A", "OUTPUT B"] def test_anchor_scene_hydration_keeps_body_only_distinct_used_singleton_tool_call(): from api import routes messages = [ {"role": "user", "content": "question"}, { "role": "assistant", "content": [ "First check.", { "type": "tool_use", "tool_use_id": "content-a", "tool_name": "terminal", "args": {"cmd": "ls"}, }, "Second check.", ], "tool_calls": [ { "id": "content-a", "name": "terminal", "input": {"cmd": "ls"}, "snippet": "OUTPUT A", } ], }, ] records = { "record": { "message_index": 1, "message_ref": routes._assistant_anchor_scene_message_ref(messages[1]), "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "", "activity_rows": [], }, } } hydrated = routes._hydrate_anchor_activity_scenes( messages, records, tool_calls=[ { "assistant_msg_idx": 1, "tid": "durable-b", "name": "terminal", "snippet": "OUTPUT B", } ], ) rows = hydrated[1]["_anchor_activity_scene"]["activity_rows"] tools = [row for row in rows if row.get("role") == "tool"] by_id = {row.get("tool_call_id"): row for row in tools} assert by_id["content-a"]["tool"]["snippet"] == "OUTPUT A" assert by_id["durable-b"]["tool"]["snippet"] == "OUTPUT B" assert len(tools) == 2 def test_anchor_scene_hydration_does_not_name_merge_singleton_with_conflicting_args(): from api import routes messages = [ {"role": "user", "content": "question"}, { "role": "assistant", "content": [ "Patch a.py.", { "type": "tool_use", "tool_use_id": "content-a", "tool_name": "edit_file", "args": {"path": "a.py"}, }, ], "tool_calls": [ { "id": "message-b", "name": "edit_file", "input": {"path": "b.py"}, "snippet": "PATCH B", } ], }, ] records = { "record": { "message_index": 1, "message_ref": routes._assistant_anchor_scene_message_ref(messages[1]), "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "", "activity_rows": [], }, } } hydrated = routes._hydrate_anchor_activity_scenes(messages, records, tool_calls=[]) rows = hydrated[1]["_anchor_activity_scene"]["activity_rows"] tools = [row for row in rows if row.get("role") == "tool"] by_id = {row.get("tool_call_id"): row for row in tools} assert by_id["content-a"]["tool"]["args"] == {"path": "a.py"} assert by_id["content-a"]["tool"]["snippet"] == "" assert by_id["message-b"]["tool"]["args"] == {"path": "b.py"} assert by_id["message-b"]["tool"]["snippet"] == "PATCH B" assert len(tools) == 2 def test_anchor_scene_hydration_dedupes_compression_lifecycle_rows(): from api import routes messages = [ {"role": "user", "content": "question"}, {"role": "assistant", "content": "thinking text", "reasoning": "thinking text"}, {"role": "assistant", "content": "final answer"}, ] records = { "record": { "message_index": 2, "message_ref": routes._assistant_anchor_scene_message_ref(messages[2]), "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "final answer", "activity_rows": [ { "row_id": "compressing-1", "role": "lifecycle", "kind": "lifecycle_status", "source_event_type": "compressing", "status": "running", "text": "Compressing context", }, { "row_id": "compressing-2", "role": "lifecycle", "kind": "lifecycle_status", "source_event_type": "compressing", "status": "running", "text": "Compressing context", }, { "row_id": "compressed", "role": "lifecycle", "kind": "lifecycle_status", "source_event_type": "compressed", "status": "completed", "text": "Context auto-compressed", }, {"row_id": "done", "role": "terminal", "kind": "terminal_status", "source_event_type": "done"}, ], }, } } hydrated = routes._hydrate_anchor_activity_scenes(messages, records) rows = hydrated[2]["_anchor_activity_scene"]["activity_rows"] compression_rows = [ row for row in rows if row.get("role") == "lifecycle" and row.get("source_event_type") in {"compressing", "compressed"} ] assert len(compression_rows) == 1 assert compression_rows[0]["source_event_type"] == "compressed" assert compression_rows[0]["order_index"] == compression_rows[0]["seq"] def test_anchor_scene_hydration_drops_stale_live_running_thinking_when_settled_thinking_exists(): from api import routes messages = [ {"role": "user", "content": "question"}, { "role": "assistant", "content": "process update", "reasoning": "settled thinking from transcript", }, {"role": "assistant", "content": "final answer"}, ] records = { "record": { "message_index": 2, "message_ref": routes._assistant_anchor_scene_message_ref(messages[2]), "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "final answer", "activity_rows": [ { "row_id": "live-reasoning:stream-1:2", "local_id": "live-reasoning:stream-1:2", "role": "thinking", "kind": "reasoning", "source_event_type": "reasoning", "status": "running", "text": "stale live reasoning text", }, {"row_id": "done", "role": "terminal", "kind": "terminal_status", "source_event_type": "done"}, ], }, } } hydrated = routes._hydrate_anchor_activity_scenes(messages, records) rows = hydrated[2]["_anchor_activity_scene"]["activity_rows"] thinking_rows = [row for row in rows if row.get("role") == "thinking"] assert [row.get("text") for row in thinking_rows] == ["settled thinking from transcript"] assert not any(str(row.get("row_id") or "").startswith("live-reasoning:") for row in rows) assert not any( row.get("role") in {"thinking", "prose", "tool"} and row.get("status") == "running" for row in rows ) def test_anchor_scene_hydration_seals_unmatched_live_running_activity_rows(): from api import routes messages = [ {"role": "user", "content": "question"}, {"role": "assistant", "content": "final answer"}, ] records = { "record": { "message_index": 1, "message_ref": routes._assistant_anchor_scene_message_ref(messages[1]), "stream_id": "stream-1", "scene": { "version": "activity_scene_v1", "mode": "compact_worklog", "final_answer": "final answer", "activity_rows": [ { "row_id": "live-reasoning:stream-1:1", "local_id": "live-reasoning:stream-1:1", "role": "thinking", "kind": "reasoning", "source_event_type": "reasoning", "status": "running", "text": "only live reasoning", }, { "row_id": "live-prose:stream-1:1", "local_id": "live-prose:stream-1:1", "role": "prose", "kind": "process_prose", "source_event_type": "token", "status": "running", "text": "only live prose", }, { "row_id": "tool:call-1:0", "local_id": "call-1", "role": "tool", "kind": "tool_started", "source_event_type": "tool", "status": "running", "run_id": "run-1", "stream_id": "stream-1", "identity": { "local_id": "call-1", "run_id": "run-1", "stream_id": "stream-1", }, "group": {"group_key": "segment:1", "activity_segment_seq": 1}, "tool_call_id": "call-1", "tool": {"id": "call-1", "name": "terminal", "done": False}, "payload": {"tid": "call-1", "status": "running", "done": False}, }, {"row_id": "done", "role": "terminal", "kind": "terminal_status", "source_event_type": "done"}, ], }, } } hydrated = routes._hydrate_anchor_activity_scenes(messages, records) rows = hydrated[1]["_anchor_activity_scene"]["activity_rows"] activity_rows = [row for row in rows if row.get("role") in {"thinking", "prose", "tool"}] assert [row.get("status") for row in activity_rows] == ["completed", "completed", "completed"] assert [row.get("role") for row in activity_rows] == ["thinking", "prose", "tool"] tool_row = activity_rows[-1] assert tool_row["tool"]["done"] is True assert tool_row["payload"]["status"] == "completed" assert tool_row["payload"]["done"] is True def test_anchor_scene_settlement_does_not_reclassify_transcript_owned_tool_row(): from api import routes row = { "row_id": "hydrated:stream-1:tool:call-1", "local_id": "call-1", "role": "tool", "status": "running", "stream_id": "stream-1", "group": {"assistant_msg_idx": 1}, "tool": {"id": "call-1", "done": False}, "payload": {"tid": "call-1", "status": "running", "done": False}, } assert routes._anchor_scene_settle_live_running_row(row, has_settled_thinking=False) is row assert row["tool"]["done"] is False assert row["payload"]["done"] is False def test_runtime_journal_anchor_scene_matches_settled_hydrated_visible_semantics(tmp_path, monkeypatch): """Runtime journal replay and settled read hydration must preserve the same visible anchor activity semantics for one turn. Compared path: _run_journal_live_snapshot(...).anchor_activity_scene -> persisted anchor_activity_scenes record -> _hydrate_anchor_activity_scenes(...)._anchor_activity_scene. """ from api import models, routes from api.run_journal import RunJournalWriter session_dir = tmp_path / "sessions" session_dir.mkdir() monkeypatch.setattr(models, "SESSION_DIR", session_dir) monkeypatch.setattr(routes, "SESSION_DIR", session_dir) session_id = "anchorparity1" stream_id = "stream-parity-1" process_before_tool = "first progress" thinking = "thinking through plan" process_after_tool = "checkpoint tail" final_answer = "Final answer: keep the activity above this answer." writer = RunJournalWriter(session_id, stream_id, session_dir=session_dir) writer.append_sse_event("token", {"text": process_before_tool}) writer.append_sse_event("reasoning", {"text": thinking}) writer.append_sse_event( "tool", {"name": "terminal", "tid": "call-1", "args": {"command": "pytest"}}, ) writer.append_sse_event( "tool_complete", {"name": "terminal", "tid": "call-1", "preview": "ok"}, ) writer.append_sse_event("token", {"text": f" {process_after_tool}"}) runtime_snapshot = routes._run_journal_live_snapshot(stream_id) assert runtime_snapshot is not None runtime_scene = runtime_snapshot.get("anchor_activity_scene") assert isinstance(runtime_scene, dict) assert _anchor_scene_visible_semantics(runtime_scene) == [ {"role": "prose", "kind": "process_prose", "text": process_before_tool}, {"role": "thinking", "kind": "reasoning", "text": thinking}, { "role": "tool", "kind": "tool_completed", "status": "completed", "tool_call_id": "call-1", "name": "terminal", "args": {"command": "pytest"}, "done": True, }, {"role": "prose", "kind": "process_prose", "text": process_after_tool}, ] persisted_scene = json.loads(json.dumps(runtime_scene)) persisted_scene["activity_rows"].append( { "row_id": "terminal-done", "role": "terminal", "kind": "terminal_status", "source_event_type": "done", "status": "completed", "text": "Response complete", } ) messages = [ {"role": "user", "content": "question"}, { "role": "assistant", "content": process_before_tool, "reasoning": thinking, "tool_calls": [ { "id": "call-1", "name": "terminal", "args": {"command": "pytest"}, "preview": "pytest", "snippet": "ok", } ], }, {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, {"role": "assistant", "content": process_after_tool}, {"role": "assistant", "content": final_answer}, ] message_ref = routes._assistant_anchor_scene_message_ref(messages[4]) records = { message_ref: { "version": "anchor_activity_scene_record_v1", "message_index": 4, "message_ref": message_ref, "stream_id": stream_id, "scene": persisted_scene, } } hydrated = routes._hydrate_anchor_activity_scenes(messages, records) settled_scene = hydrated[4]["_anchor_activity_scene"] assert settled_scene["final_answer"] == final_answer assert final_answer not in [ row.get("text") for row in settled_scene["activity_rows"] if row.get("role") != "terminal" ] assert _anchor_scene_visible_semantics(settled_scene) == _anchor_scene_visible_semantics(runtime_scene) assert _anchor_scene_visible_semantics(settled_scene, include_terminal=True)[-1] == { "role": "terminal", "kind": "terminal_status", "source_event_type": "done", "status": "completed", } assert not any( row.get("role") in {"prose", "thinking", "tool"} and row.get("status") == "running" for row in settled_scene["activity_rows"] ) def test_runtime_journal_snapshot_includes_live_anchor_activity_scene(monkeypatch): from api import routes stream_id = "stream-live-scene" events = [ { "event": "token", "seq": 1, "event_id": f"{stream_id}:1", "created_at": 1.0, "payload": {"text": "first progress"}, }, { "event": "reasoning", "seq": 2, "event_id": f"{stream_id}:2", "created_at": 2.0, "payload": {"text": "thinking through plan"}, }, { "event": "interim_assistant", "seq": 3, "event_id": f"{stream_id}:3", "created_at": 3.0, "payload": {"text": "checkpoint"}, }, { "event": "tool", "seq": 4, "event_id": f"{stream_id}:4", "created_at": 4.0, "payload": {"name": "terminal", "tid": "call-1", "args": {"command": "pytest"}}, }, { "event": "tool_complete", "seq": 5, "event_id": f"{stream_id}:5", "created_at": 5.0, "payload": {"name": "terminal", "tid": "call-1", "preview": "ok"}, }, { "event": "token", "seq": 6, "event_id": f"{stream_id}:6", "created_at": 6.0, "payload": {"text": " tail"}, }, ] monkeypatch.setattr( routes, "find_run_summary", lambda sid: { "session_id": "session-live-scene", "last_seq": 6, "last_event_id": f"{stream_id}:6", }, ) monkeypatch.setattr( routes, "read_run_events", lambda session_id, run_id: {"events": events}, ) snapshot = routes._run_journal_live_snapshot(stream_id) scene = snapshot["anchor_activity_scene"] rows = scene["activity_rows"] assert scene["version"] == "activity_scene_v1" assert snapshot["last_assistant_text"] == "first progress\n\ncheckpoint tail" assert snapshot["last_reasoning_text"] == "thinking through plan" assert [row["role"] for row in rows] == ["prose", "thinking", "tool", "prose"] assert rows[0]["local_id"] == f"live-prose:{stream_id}:1" assert rows[1]["local_id"] == f"live-thinking:{stream_id}:1" assert rows[1]["thinking"]["text"] == "thinking through plan" assert rows[2]["tool_call_id"] == "call-1" assert rows[2]["tool"]["done"] is True assert rows[3]["status"] == "running" def test_runtime_journal_snapshot_dedupes_reasoning_interim_progress_echo(monkeypatch): from api import routes stream_id = "stream-live-reasoning-interim-echo" progress = "我先检查当前仓库状态,然后定位重复渲染路径。" events = [ { "event": "reasoning", "seq": 1, "event_id": f"{stream_id}:1", "created_at": 1.0, "payload": {"text": progress}, }, { "event": "interim_assistant", "seq": 2, "event_id": f"{stream_id}:2", "created_at": 2.0, "payload": {"text": progress}, }, ] monkeypatch.setattr( routes, "find_run_summary", lambda sid: { "session_id": "session-live-reasoning-interim-echo", "last_seq": 2, "last_event_id": f"{stream_id}:2", }, ) monkeypatch.setattr( routes, "read_run_events", lambda session_id, run_id: {"events": events}, ) snapshot = routes._run_journal_live_snapshot(stream_id) rows = snapshot["anchor_activity_scene"]["activity_rows"] assert snapshot["last_assistant_text"] == progress assert snapshot["last_reasoning_text"] == "" assert [row["role"] for row in rows] == ["prose"] assert rows[0]["text"] == progress def test_runtime_journal_snapshot_has_running_anchor_row_before_first_token(monkeypatch): from api import routes stream_id = "stream-live-empty" monkeypatch.setattr( routes, "find_run_summary", lambda sid: { "session_id": "session-live-empty", "last_seq": 1, "last_event_id": f"{stream_id}:1", }, ) monkeypatch.setattr( routes, "read_run_events", lambda session_id, run_id: { "events": [ { "event": "context_status", "seq": 1, "event_id": f"{stream_id}:1", "created_at": 1.0, "payload": {"session_id": "session-live-empty"}, } ] }, ) snapshot = routes._run_journal_live_snapshot(stream_id) rows = snapshot["anchor_activity_scene"]["activity_rows"] assert rows assert rows[0]["role"] == "lifecycle" assert rows[0]["status"] == "running"