Files
hermes-webui/tests/test_anchor_scene_persistence.py
happy5318 d698fb4772 fix(routes): mirror the detail-load endpoint's 409 contract in every profile-mismatch check (#7710)
## Thinking Path

The detail-load endpoint (and the foreign-session synthesizer) already
distinguish "session owned by a KNOWN other profile" (return ``409
session_profile_mismatch`` so the client can offer to switch to it
per #5419) from "session missing/legacy with no profile stamped"
(return ``404 Session not found`` so the frontend's self-heal
clears the stale URL + localStorage).

The generic request-guard
``_session_id_visible_to_request_profile`` flattened both cases to a
``404 Session not found``, so any cross-profile POST/PATCH/DELETE
(archive, rename, pin, delete, session-attach, anchor-scene, …) and
the stream-related status/cancel/replay paths failed with a
misleading "Session not found" instead of the actionable 409 the
detail endpoint emits. The reporter's wire evidence:

```
POST /api/session/archive  {"session_id":"<id>","archived":true}
-> 404 {"error": "Session not found"}

GET /api/session?session_id=<same id>
-> 409 {"error": "Session belongs to a different profile",
        "code": "session_profile_mismatch", "session_id": "<id>", "profile": "def"}
```

Same session id, same moment: the detail endpoint reports a profile
mismatch, archive masks it as 404.

The fix mirrors the detail endpoint's contract in the generic guard
and in every other inline check that previously went to
``bad(handler, "Session not found", 404)`` for a known-other-profile
session.

## What Changed

1. ``api/routes.py:595`` — ``_session_id_visible_to_request_profile``
   now emits ``409 session_profile_mismatch`` for a session owned by
   a known other profile, and keeps ``404 Session not found`` only
   for the None-profile (unknown/legacy) case so the frontend
   self-heal still fires for actually-missing sids.

2. ``api/routes.py:5348`` (anchor-scene persist), ``:23994``
   (compression-recovery start), ``:24385`` (chat/start with
   the inline placeholder-retag rules preserved), ``:28621``
   (session import_cli's already-imported branch) — the same
   409-vs-404 split, with the existing branches kept intact (the
   chat/start retag path, the import_cli ``allow_all_profiles`` short
   circuit).

3. The ``_stream_id_visible_to_request_profile`` wrapper already
   delegates to ``_session_id_visible_to_request_profile`` for the
   owner-session check, so the stream status / cancel / replay
   endpoints get the new contract for free.

## Why It Matters

A user picking the configured default model in the dropdown sees two
visually identical entries for the same model id, picks the wrong
"prefixed" one (the one matching the dropdown's order), the agent
returns 400, and the session state is corrupted with a non-functional
model id until the user manually clears it. The dropdown's own
deduplication already handles `@provider:model` correctly via the
documented prefix form; only the synthesised ``{provider}/{model}``
form was wrong.

The cross-profile write paths (anchor-scene persist,
compression-recovery start, chat/start, session import) all had the
same gap: a successful auth under profile A could persist or recover
state against a session owned by profile B, surfacing a 404 to
profile A's user that reads like "the session is gone" — but the
session is in fact intact, just owned by another profile. The 409
contract is what closes the gap: the client gets a structured signal
it can act on, and the session state stays consistent.

## Verification

- ``tests/test_issue7710_cross_profile_404_to_409.py`` (new, 6 tests):
  - 5 behavioural: profile-mismatch path returns 409 with the
    ``session_profile_mismatch`` payload, the None-profile path
    keeps 404 for the frontend self-heal, visible session passes
    silently, missing session passes silently, ``emit_error=False``
    suppresses the response.
  - 1 source-shape: the helper body MUST contain a 409 branch with
    the ``session_profile_mismatch`` code; a silent reversion to
    the single-line ``bad(..., 404)`` fails the suite.
- ``tests/test_issue4812_session_sse_stream.py`` — 1 assertion
  updated to the new 409 payload (the SSE route walks through the
  generic guard).
- ``tests/test_session_active_profile_authorization.py`` — 7
  assertions updated to the 409 payload (duplicate, file, chat/start
  foreign, chat/stream/status, chat/stream/cancel, chat/stream
  replay). Stream tests use ``foreign_session`` (the owner session
  id), NOT the stream id, because ``_stream_id_visible_to_request_profile``
  delegates to the owner-session check.
- ``tests/test_anchor_scene_persistence.py`` — 1 assertion updated
  to the 409 payload. The 3 ``Assistant message not found`` 404
  cases (lines 454/561/611) are kept as-is — they're not profile
  mismatches, they're missing-message 404s.
- ``tests/test_issue4067_import_cli_cross_profile_guard.py`` —
  1 assertion updated to the 409 payload, plus the ``_capture`` mock
  signature updated to accept the ``status`` keyword that
  ``j(handler, payload, status=200, ...)`` already passes.

Other tests in the existing suite: **161 passed across 8 related
test files** (session active-profile authorization, anchor scene,
issue 4067, issue 4812 SSE, issue 7710 new, chat start claim,
workspace stale recovery, issue 5420 profile switch).

## Contract Change

The following routes now return ``409 session_profile_mismatch``
(``{error, code, session_id, profile}``) for a session owned by a
known other profile, where they previously returned
``404 Session not found``:

- ``POST /api/session/duplicate`` (now also goes through
  ``_guard_request_session_visibility`` for the body session id)
- ``GET  /api/file?session_id=…``
- ``POST /api/chat/start`` (the inline profile check after
  ``_get_or_materialize_session``)
- ``GET  /api/chat/stream/status?stream_id=…``
- ``GET  /api/chat/stream/cancel?stream_id=…``
- ``GET  /api/chat/stream?stream_id=…`` (the dead-stream replay
  branch)
- ``POST /api/session/anchor-scene``
- ``POST /api/session/compression-recovery/start``
- ``POST /api/session/import_cli`` (the already-imported branch
  when ``allow_all_profiles`` is False)

The ``404 Session not found`` self-heal path is preserved for the
None-profile (unknown/legacy) case across all of these routes, so
the frontend's existing stale-URL/localStorage cleanup behaviour is
unchanged for actually-missing sessions.

## Risks

- A consumer that previously relied on the 404 to detect "this
  session belongs to another profile" will now see a 409 with a
  ``code`` field. The detail-load endpoint has emitted this exact
  contract since #5419, and the frontend already handles it
  (offers to switch to the owning profile). The cross-profile write
  paths now match the detail-load contract; the frontend's
  already-shipped switcher UI handles both surfaces consistently.
- The anchor-scene / compression-recovery / chat-start tests that
  were updated are a small set (5 total); the existing
  ``Assistant message not found`` 404 paths in
  ``test_anchor_scene_persistence`` are kept untouched (those are
  not profile mismatches).

## Related

- #7710 — original issue (elkpvl-commits, 2026-09-22) with the
  full repro and the suggested fix shape.
- #7549 — the underlying cross-profile archive bug, already
  tracked by @webtecnica in #7559 (which is the "business-level"
  fix: row-profile carry + frontend sends profile + materialize
  into the row's own profile). This PR is the orthogonal
  error-code-level fix: the request-guard's 404-vs-409 split.
- #5419 — the original auto-switch-profiles-for-session-links
  feature, whose frontend switcher UI consumes the
  ``session_profile_mismatch`` code on the 409.
2026-09-22 10:21:22 +00:00

2413 lines
81 KiB
Python

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"