2371 lines
80 KiB
Python
2371 lines
80 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
|
|
assert captured.get("status") == 404
|
|
assert "payload" not in captured # success j() never called
|
|
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": "live-tool:stream-1:call-1",
|
|
"local_id": "live-tool:stream-1:call-1",
|
|
"role": "tool",
|
|
"kind": "tool_started",
|
|
"source_event_type": "tool",
|
|
"status": "running",
|
|
"tool_call_id": "call-1",
|
|
"tool": {"id": "call-1", "name": "terminal", "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"]
|
|
|
|
|
|
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"
|