Files
mem0/integrations/agent-plugin-core/tests/test_session_handoff.py
T

393 lines
16 KiB
Python

from __future__ import annotations
import io
import json
import sys
from pathlib import Path
import pytest
CORE = Path(__file__).resolve().parents[1] / "python"
sys.path.insert(0, str(CORE))
import handoff_engine as engine # noqa: E402
from handoff_sources import _messages_items, read_source # noqa: E402
def message(role, text):
return {
"type": "message",
"role": role,
"content": [{"type": "input_text" if role == "user" else "output_text", "text": text}],
}
def envelope(tmp_path, items=None):
return {
"format": "mem0.session-handoff.v1",
"source": {"host": "pi-agent", "session_id": "s1", "cwd": str(tmp_path), "title": "Continue the task"},
"items": items or [message("user", "Continue here"), message("assistant", "Ready")],
}
def transcript(tmp_path, records):
path = tmp_path / "session.jsonl"
path.write_text("".join(json.dumps(record) + "\n" for record in records))
return path
def test_neutral_stdin_save_round_trip_without_models(tmp_path, monkeypatch, capsys):
monkeypatch.setattr(engine, "DEFAULT_BUNDLE_DIR", tmp_path / "handoffs")
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(envelope(tmp_path))))
assert engine.main(["--save", "--bundle", "-"]) == 0
destination = Path(json.loads(capsys.readouterr().out)["resource"])
plan = engine.load_bundle(destination)
assert plan.source.host == "pi-agent"
assert plan.source.title == "Continue the task"
assert plan.items == envelope(tmp_path)["items"]
assert destination.stat().st_mode & 0o777 == 0o600
@pytest.mark.parametrize(
"item",
[
{"type": "message", "role": "system", "content": [{"type": "input_text", "text": "privileged"}]},
{
"type": "message",
"role": "assistant",
"status": "incomplete",
"content": [{"type": "output_text", "text": "partial"}],
},
{
"type": "message",
"role": "user",
"content": [{"type": "input_image", "image_url": "https://example.com/not-local.png"}],
},
{"type": "function_call", "call_id": "pending", "name": "Read", "arguments": "{}"},
{"type": "function_call_output", "call_id": "missing", "output": "result"},
{"type": "unknown"},
],
)
def test_neutral_boundary_rejects_unsupported_or_incomplete_items(tmp_path, item):
with pytest.raises(engine.HandoffError):
engine.plan_from_bundle(envelope(tmp_path, [message("user", "Task"), item]))
def test_neutral_missing_tool_name_is_restored_and_host_path_is_validated(tmp_path):
payload = envelope(
tmp_path,
[
message("user", "Task"),
{"type": "function_call", "call_id": "c1", "name": "Read", "arguments": "{}"},
{"type": "function_call_output", "call_id": "c1", "output": "Complete output"},
],
)
plan = engine.plan_from_bundle(payload)
assert plan.items[-1]["name"] == "Read"
payload["source"]["host"] = "../../escape"
with pytest.raises(engine.HandoffError, match="invalid source"):
engine.plan_from_bundle(payload)
def test_neutral_project_resolves_git_root_for_nested_cwd(tmp_path, monkeypatch):
nested = tmp_path / "nested"
nested.mkdir()
payload = envelope(nested)
plan = engine.plan_from_bundle(payload)
monkeypatch.setattr(engine, "_git_root", lambda cwd: str(tmp_path))
assert engine._with_cwd(plan, None).source.project_cwd == str(tmp_path)
assert engine._with_cwd(plan, nested).source.project_cwd == str(tmp_path)
def test_codex_native_rollout_keeps_response_items_and_excludes_harness(tmp_path):
# openai/codex: codex-rs/protocol/src/protocol.rs and persisted response_item payloads.
records = [
{"type": "session_meta", "payload": {"id": "codex-session", "cwd": str(tmp_path)}},
{"type": "world_state", "payload": {"full": True, "state": {"agents_md": {"text": "harness config"}}}},
{"type": "token_usage_record", "payload": {"input_tokens": 123, "output_tokens": 45}},
{"type": "response_item", "payload": message("developer", "harness config")},
{"type": "response_item", "payload": {"type": "reasoning", "encrypted_content": "opaque"}},
{"type": "response_item", "payload": message("user", "Keep user")},
{"type": "response_item", "payload": message("assistant", "Keep answer")},
{"type": "event_msg", "payload": {"type": "agent_message", "message": "duplicate UI text"}},
]
plan = read_source("codex", transcript(tmp_path, records))
assert plan.source.session_id == "codex-session"
assert len(plan.items) == 2
assert "harness config" not in json.dumps(plan.items)
assert "duplicate UI text" not in json.dumps(plan.items)
@pytest.mark.parametrize(
"record",
[
{"type": "compacted", "payload": {"message": "opaque summary"}},
{"type": "response_item", "payload": {"type": "compaction", "encrypted_content": "opaque"}},
{"type": "event_msg", "payload": {"type": "thread_rolled_back", "num_turns": 1}},
],
)
def test_codex_opaque_compaction_and_rollback_fail(tmp_path, record):
records = [{"type": "response_item", "payload": message("user", "Task")}, record]
with pytest.raises(engine.HandoffError):
read_source("codex", transcript(tmp_path, records), cwd=tmp_path)
def test_cursor_native_text_and_tool_records_keep_all_evidence(tmp_path):
# Cursor native role/message.content JSONL; missing tool outputs are rejected.
records = [
{"role": "user", "message": {"content": [{"type": "text", "text": "Inspect"}]}},
{
"role": "assistant",
"message": {"content": [{"type": "tool_use", "id": "c1", "name": "Read", "input": {"path": "file.py"}}]},
},
{"role": "user", "message": {"content": [{"type": "tool_result", "tool_use_id": "c1", "content": "x" * 6000}]}},
{"role": "assistant", "message": {"content": [{"type": "text", "text": "Finished"}]}},
]
plan = read_source("cursor", transcript(tmp_path, records), cwd=tmp_path, title="Cursor task")
assert plan.source.host == "cursor"
assert plan.source.title == "Cursor task"
assert plan.items[2]["output"] == "x" * 6000
with pytest.raises(engine.HandoffError, match="unfinished"):
read_source("cursor", transcript(tmp_path, records[:2]), cwd=tmp_path)
def test_antigravity_native_completed_text_steps(tmp_path):
# Existing native adapter fixture; public paths: https://www.antigravity.google/docs/hooks
records = [
{
"type": "USER_INPUT",
"source": "USER_EXPLICIT",
"status": "DONE",
"content": "<USER_REQUEST>Do work</USER_REQUEST>",
},
{"type": "PLANNER_RESPONSE", "source": "MODEL", "status": "DONE", "content": "Done"},
]
plan = read_source("antigravity", transcript(tmp_path, records), cwd=tmp_path)
assert "<USER_REQUEST>Do work</USER_REQUEST>" in json.dumps(plan.items)
assert plan.items[1]["content"][0]["text"] == "Done"
records[-1]["status"] = "RUNNING"
with pytest.raises(engine.HandoffError, match="unfinished"):
read_source("antigravity", transcript(tmp_path, records), cwd=tmp_path)
def test_kimi_native_wire_stream_and_compaction(tmp_path):
# MoonshotAI/kimi-code: apps/vis/server/test/fixtures/sessions/sample-main/agents/main/wire.jsonl
# and packages/agent-core-v2/src/agent/contextMemory/{loopEventFold,compactionHandoff}.ts
records = [
{"type": "metadata", "protocol_version": "1.5"},
{"type": "config.update", "cwd": str(tmp_path)},
{
"type": "context.append_message",
"message": {"role": "user", "content": [{"type": "text", "text": "Original user"}]},
},
{"type": "context.append_loop_event", "event": {"type": "step.begin", "uuid": "s1"}},
{
"type": "context.append_loop_event",
"event": {"type": "content.part", "stepUuid": "s1", "part": {"type": "text", "text": "Original answer"}},
},
{"type": "context.append_loop_event", "event": {"type": "step.end", "uuid": "s1", "finishReason": "end_turn"}},
{
"type": "context.apply_compaction",
"summary": "Native summary",
"compactedCount": 2,
"keptUserMessageCount": 1,
},
{
"type": "context.append_message",
"message": {"role": "user", "content": [{"type": "text", "text": "Continue"}]},
},
]
plan = read_source("kimi", transcript(tmp_path, records))
assert [item["content"][0]["text"] for item in plan.items] == [
"Original user",
"Native summary",
"<system-reminder>\nContext compaction is complete — continue the work that was in progress when it began.\n</system-reminder>",
"Continue",
]
assert "Original answer" not in json.dumps(plan.items)
records.append({"type": "context.undo", "count": 1})
with pytest.raises(engine.HandoffError, match="active-context"):
read_source("kimi", transcript(tmp_path, records))
def test_kimi_native_tool_events_and_unfinished_turn(tmp_path):
records = [
{"type": "config.update", "cwd": str(tmp_path)},
{
"type": "context.append_message",
"message": {"role": "user", "content": [{"type": "text", "text": "Inspect"}]},
},
{"type": "context.append_loop_event", "event": {"type": "step.begin", "uuid": "s1"}},
{
"type": "context.append_loop_event",
"event": {
"type": "tool.call",
"stepUuid": "s1",
"toolCallId": "c1",
"name": "Read",
"args": {"path": "a.py"},
},
},
{
"type": "context.append_loop_event",
"event": {"type": "tool.result", "toolCallId": "c1", "result": {"output": "contents", "isError": True}},
},
{"type": "context.append_loop_event", "event": {"type": "step.end", "uuid": "s1", "finishReason": "tool_use"}},
]
plan = read_source("kimi", transcript(tmp_path, records))
assert plan.items[1]["name"] == "Read"
assert plan.items[2]["output"] == [{"type": "text", "text": "Tool failed."}, {"type": "text", "text": "contents"}]
with pytest.raises(engine.HandoffError, match="streaming"):
read_source("kimi", transcript(tmp_path, records[:-1]))
@pytest.mark.parametrize("compactions", [2, 3])
def test_kimi_repeated_compaction_replaces_previous_continuation(tmp_path, compactions):
records = [
{"type": "config.update", "cwd": str(tmp_path)},
{
"type": "context.append_message",
"message": {"role": "user", "content": [{"type": "text", "text": "Original user"}]},
},
]
for index in range(compactions):
records.append({
"type": "context.apply_compaction",
"summary": f"Native summary {index + 1}",
"compactedCount": 1 if index == 0 else 3,
"keptUserMessageCount": 1,
})
plan = read_source("kimi", transcript(tmp_path, records))
# Native Kimi marks the continuation as an injection, excluded by the next compaction.
assert [item["content"][0]["text"] for item in plan.items] == [
"Original user",
f"Native summary {compactions}",
"<system-reminder>\nContext compaction is complete — continue the work that was in progress when it began.\n</system-reminder>",
]
@pytest.mark.parametrize("host", ["pi-agent", "openclaw"])
@pytest.mark.parametrize("latest_title", ["Renamed title", ""])
def test_pi_title_is_session_wide_after_branching(tmp_path, host, latest_title):
records = [
{"type": "session", "id": "native-session", "cwd": str(tmp_path)},
{"id": "old-title", "parentId": None, "type": "session_info", "name": "Original title"},
{"id": "new-title", "parentId": "old-title", "type": "session_info", "name": latest_title},
{
"id": "branch",
"parentId": "old-title",
"type": "message",
"message": {"role": "user", "content": "Continue on another branch"},
},
]
plan = read_source(host, transcript(tmp_path, records))
# SessionManager.getSessionName scans all entries, including title clears on other branches.
assert plan.source.title == (latest_title or f"{host} session session")
assert plan.items == [message("user", "Continue on another branch")]
def test_openclaw_active_branch_compaction_and_original_title(tmp_path):
# Pi native session-manager.buildSessionContext and messages.convertToLlm, used by OpenClaw.
records = [
{"type": "session", "id": "native-session", "cwd": str(tmp_path)},
{"id": "title", "parentId": None, "type": "session_info", "name": "Original title"},
{"id": "u1", "parentId": "title", "type": "message", "message": {"role": "user", "content": "Discarded"}},
{
"id": "abandoned",
"parentId": "u1",
"type": "message",
"message": {"role": "assistant", "content": [{"type": "text", "text": "Wrong branch"}]},
},
{"id": "u2", "parentId": "u1", "type": "message", "message": {"role": "user", "content": "Keep user"}},
{
"id": "compact",
"parentId": "u2",
"type": "compaction",
"summary": "Native summary",
"firstKeptEntryId": "u2",
},
{
"id": "a1",
"parentId": "compact",
"type": "message",
"message": {"role": "assistant", "content": [{"type": "text", "text": "Continue"}]},
},
]
plan = read_source("openclaw", transcript(tmp_path, records))
assert plan.source.title == "Original title"
assert plan.source.session_id == "native-session"
assert "Native summary" in plan.items[0]["content"][0]["text"]
assert "Keep user" in json.dumps(plan.items)
assert "Discarded" not in json.dumps(plan.items)
assert "Wrong branch" not in json.dumps(plan.items)
def test_inline_tool_order_is_preserved():
items = _messages_items(
[
{
"role": "assistant",
"content": [
{"type": "text", "text": "before"},
{"type": "toolCall", "id": "c1", "name": "Read", "arguments": {}},
{"type": "text", "text": "after"},
],
}
],
[],
)
assert [item["type"] for item in items] == ["message", "function_call", "message"]
assert items[0]["content"][0]["text"] == "before"
assert items[2]["content"][0]["text"] == "after"
def test_generic_cli_requires_source_and_accepts_native_openclaw(tmp_path, monkeypatch, capsys):
path = transcript(
tmp_path,
[
{"type": "session", "id": "s1", "cwd": str(tmp_path)},
{"type": "message", "id": "u1", "parentId": None, "message": {"role": "user", "content": "Task"}},
],
)
monkeypatch.setattr(engine, "DEFAULT_BUNDLE_DIR", tmp_path / "handoffs")
assert engine.main(["--save", "--session", str(path)], default_source=None) == 1
assert "--source is required" in capsys.readouterr().err
assert engine.main(["--save", "--source", "openclaw", "--session", str(path)], default_source=None) == 0
assert json.loads(capsys.readouterr().out)["source_host"] == "openclaw"
@pytest.mark.parametrize(
"patch",
[
{"status": []},
{"type": {}},
{"role": []},
{"content": [{"type": []}]},
],
)
def test_malformed_neutral_item_returns_handoff_error(tmp_path, patch):
item = {**message("user", "Task"), **patch}
with pytest.raises(engine.HandoffError):
engine.plan_from_bundle(envelope(tmp_path, [item]))
@pytest.mark.parametrize(
"kind,source",
[
("PLANNER_THOUGHT", "MODEL"),
("HARNESS_CONTEXT", "SYSTEM"),
("UNKNOWN_TOOL", "TOOL"),
],
)
def test_antigravity_unverified_steps_never_become_visible_messages(tmp_path, kind, source):
records = [
{"type": "USER_INPUT", "source": "USER_EXPLICIT", "status": "DONE", "content": "Do work"},
{"type": kind, "source": source, "status": "DONE", "content": "Unverified internal data"},
]
with pytest.raises(engine.HandoffError, match="Unsupported Antigravity step"):
read_source("antigravity", transcript(tmp_path, records), cwd=tmp_path)