"""Tests for RunJournal callback handler.
Uses MemoryRunEventStore as the backend for direct event inspection.
"""
import asyncio
from unittest.mock import MagicMock
from uuid import uuid4
import pytest
from langchain_core.messages import HumanMessage
from deerflow.runtime.events.store.memory import MemoryRunEventStore
from deerflow.runtime.journal import RunJournal
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
@pytest.fixture
def journal_setup():
store = MemoryRunEventStore()
j = RunJournal("r1", "t1", store, flush_threshold=100)
return j, store
def _make_llm_response(content="Hello", usage=None, tool_calls=None, additional_kwargs=None):
"""Create a mock LLM response with a message.
model_dump() returns checkpoint-aligned format matching real AIMessage.
"""
msg = MagicMock()
msg.type = "ai"
msg.content = content
msg.id = f"msg-{id(msg)}"
msg.tool_calls = tool_calls or []
msg.invalid_tool_calls = []
msg.response_metadata = {"model_name": "test-model"}
msg.usage_metadata = usage
msg.additional_kwargs = additional_kwargs or {}
msg.name = None
# model_dump returns checkpoint-aligned format
msg.model_dump.return_value = {
"content": content,
"additional_kwargs": additional_kwargs or {},
"response_metadata": {"model_name": "test-model"},
"type": "ai",
"name": None,
"id": msg.id,
"tool_calls": tool_calls or [],
"invalid_tool_calls": [],
"usage_metadata": usage,
}
gen = MagicMock()
gen.message = msg
response = MagicMock()
response.generations = [[gen]]
return response
class TestLlmCallbacks:
@pytest.mark.anyio
async def test_on_chat_model_start_persists_original_user_input_without_mutating_model_message(self, journal_setup):
j, store = journal_setup
wrapped_content = "--- BEGIN USER INPUT ---\nShow revenue\n--- END USER INPUT ---"
model_message = HumanMessage(
content=wrapped_content,
id="human-1",
additional_kwargs={ORIGINAL_USER_CONTENT_KEY: "Show revenue", "channel": "web"},
)
j.on_chat_model_start({}, [[model_message]], run_id=uuid4(), tags=["lead_agent"])
await j.flush()
assert j._first_human_msg == "Show revenue"
events = await store.list_events("t1", "r1")
human_event = next(event for event in events if event["event_type"] == "llm.human.input")
assert human_event["content"]["content"] == "Show revenue"
assert human_event["content"]["id"] == "human-1"
assert human_event["content"]["additional_kwargs"] == {"channel": "web"}
assert model_message.content == wrapped_content
assert model_message.additional_kwargs[ORIGINAL_USER_CONTENT_KEY] == "Show revenue"
@pytest.mark.anyio
async def test_on_llm_end_produces_trace_event(self, journal_setup):
j, store = journal_setup
run_id = uuid4()
j.on_llm_start({}, [], run_id=run_id, tags=["lead_agent"])
j.on_llm_end(_make_llm_response("Hi"), run_id=run_id, parent_run_id=None, tags=["lead_agent"])
await j.flush()
events = await store.list_events("t1", "r1")
trace_events = [e for e in events if e["event_type"] == "llm.ai.response"]
assert len(trace_events) == 1
assert trace_events[0]["category"] == "message"
@pytest.mark.anyio
async def test_on_llm_end_lead_agent_produces_ai_message(self, journal_setup):
j, store = journal_setup
run_id = uuid4()
j.on_llm_start({}, [], run_id=run_id, tags=["lead_agent"])
j.on_llm_end(_make_llm_response("Answer"), run_id=run_id, parent_run_id=None, tags=["lead_agent"])
await j.flush()
messages = await store.list_messages("t1")
assert len(messages) == 1
assert messages[0]["event_type"] == "llm.ai.response"
# Content is checkpoint-aligned model_dump format
assert messages[0]["content"]["type"] == "ai"
assert messages[0]["content"]["content"] == "Answer"
@pytest.mark.anyio
async def test_on_llm_end_with_tool_calls_produces_ai_tool_call(self, journal_setup):
"""LLM response with pending tool_calls emits llm.ai.response with tool_calls in content."""
j, store = journal_setup
run_id = uuid4()
j.on_llm_end(
_make_llm_response("Let me search", tool_calls=[{"id": "call_1", "name": "search", "args": {}}]),
run_id=run_id,
parent_run_id=None,
tags=["lead_agent"],
)
await j.flush()
messages = await store.list_messages("t1")
assert len(messages) == 1
assert messages[0]["event_type"] == "llm.ai.response"
assert len(messages[0]["content"]["tool_calls"]) == 1
@pytest.mark.anyio
async def test_on_llm_end_subagent_no_ai_message(self, journal_setup):
j, store = journal_setup
run_id = uuid4()
j.on_llm_start({}, [], run_id=run_id, tags=["subagent:research"])
j.on_llm_end(_make_llm_response("Sub answer"), run_id=run_id, parent_run_id=None, tags=["subagent:research"])
await j.flush()
messages = await store.list_messages("t1")
# subagent responses still emit llm.ai.response with category="message"
assert len(messages) == 1
@pytest.mark.anyio
async def test_token_accumulation(self, journal_setup):
j, store = journal_setup
usage1 = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}
usage2 = {"input_tokens": 20, "output_tokens": 10, "total_tokens": 30}
j.on_llm_end(_make_llm_response("A", usage=usage1), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"])
j.on_llm_end(_make_llm_response("B", usage=usage2), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"])
assert j._total_input_tokens == 30
assert j._total_output_tokens == 15
assert j._total_tokens == 45
assert j._llm_call_count == 2
@pytest.mark.anyio
async def test_total_tokens_computed_from_input_output(self, journal_setup):
"""If total_tokens is 0, it should be computed from input + output."""
j, store = journal_setup
j.on_llm_end(
_make_llm_response("Hi", usage={"input_tokens": 100, "output_tokens": 50, "total_tokens": 0}),
run_id=uuid4(),
parent_run_id=None,
tags=["lead_agent"],
)
assert j._total_tokens == 150
@pytest.mark.anyio
async def test_caller_token_classification(self, journal_setup):
j, store = journal_setup
usage = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}
j.on_llm_end(_make_llm_response("A", usage=usage), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"])
j.on_llm_end(_make_llm_response("B", usage=usage), run_id=uuid4(), parent_run_id=None, tags=["subagent:research"])
j.on_llm_end(_make_llm_response("C", usage=usage), run_id=uuid4(), parent_run_id=None, tags=["middleware:summarization"])
# token tracking not broken by caller type
assert j._total_tokens == 45
assert j._llm_call_count == 3
@pytest.mark.anyio
async def test_usage_metadata_none_no_crash(self, journal_setup):
j, store = journal_setup
j.on_llm_end(_make_llm_response("No usage", usage=None), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"])
await j.flush()
@pytest.mark.anyio
async def test_latency_tracking(self, journal_setup):
j, store = journal_setup
run_id = uuid4()
j.on_llm_start({}, [], run_id=run_id, tags=["lead_agent"])
j.on_llm_end(_make_llm_response("Fast"), run_id=run_id, parent_run_id=None, tags=["lead_agent"])
await j.flush()
events = await store.list_events("t1", "r1")
llm_resp = [e for e in events if e["event_type"] == "llm.ai.response"][0]
assert "latency_ms" in llm_resp["metadata"]
assert llm_resp["metadata"]["latency_ms"] is not None
class TestLifecycleCallbacks:
@pytest.mark.anyio
async def test_chain_start_end_produce_trace_events(self, journal_setup):
j, store = journal_setup
j.on_chain_start({}, {}, run_id=uuid4(), parent_run_id=None)
j.on_chain_end({}, run_id=uuid4())
await asyncio.sleep(0.05)
await j.flush()
events = await store.list_events("t1", "r1")
types = {e["event_type"] for e in events}
assert "run.start" in types
assert "run.end" in types
@pytest.mark.anyio
async def test_nested_chain_no_run_lifecycle_events(self, journal_setup):
"""Nested chains (parent_run_id set) should NOT produce root run lifecycle events."""
j, store = journal_setup
parent_id = uuid4()
j.on_chain_start({}, {}, run_id=uuid4(), parent_run_id=parent_id)
j.on_chain_end({}, run_id=uuid4(), parent_run_id=parent_id)
await j.flush()
events = await store.list_events("t1", "r1")
assert not any(e["event_type"] == "run.start" for e in events)
assert not any(e["event_type"] == "run.end" for e in events)
class TestToolCallbacks:
@pytest.mark.anyio
async def test_tool_end_with_tool_message(self, journal_setup):
"""on_tool_end with a ToolMessage stores it as llm.tool.result."""
from langchain_core.messages import ToolMessage
j, store = journal_setup
tool_msg = ToolMessage(content="results", tool_call_id="call_1", name="web_search")
j.on_tool_end(tool_msg, run_id=uuid4())
await j.flush()
messages = await store.list_messages("t1")
assert len(messages) == 1
assert messages[0]["event_type"] == "llm.tool.result"
assert messages[0]["content"]["type"] == "tool"
@pytest.mark.anyio
async def test_tool_end_with_command_unwraps_tool_message(self, journal_setup):
"""on_tool_end with Command(update={'messages':[ToolMessage]}) unwraps inner message."""
from langchain_core.messages import ToolMessage
from langgraph.types import Command
j, store = journal_setup
inner = ToolMessage(content="file list", tool_call_id="call_2", name="present_files")
cmd = Command(update={"messages": [inner]})
j.on_tool_end(cmd, run_id=uuid4())
await j.flush()
messages = await store.list_messages("t1")
assert len(messages) == 1
assert messages[0]["event_type"] == "llm.tool.result"
assert messages[0]["content"]["content"] == "file list"
@pytest.mark.anyio
async def test_on_tool_error_no_crash(self, journal_setup):
"""on_tool_error should not crash (no event emitted by default)."""
j, store = journal_setup
j.on_tool_error(TimeoutError("timeout"), run_id=uuid4(), name="web_fetch")
await j.flush()
# Base implementation does not emit tool_error — just verify no crash
events = await store.list_events("t1", "r1")
assert isinstance(events, list)
class TestFinalToolMessageReconciliation:
@pytest.mark.anyio
async def test_root_chain_end_reconciles_missing_ask_clarification_tool_message(self, journal_setup):
from langchain_core.messages import ToolMessage
j, store = journal_setup
j.on_llm_end(
_make_llm_response("", tool_calls=[{"id": "call_clarify", "name": "ask_clarification", "args": {"question": "Which format?"}}]),
run_id=uuid4(),
parent_run_id=None,
tags=["lead_agent"],
)
tool_msg = ToolMessage(
content="Which format?",
tool_call_id="call_clarify",
name="ask_clarification",
artifact={"human_input": {"kind": "human_input_request", "request_id": "clarification:call_clarify"}},
)
j.on_chain_end({"messages": [tool_msg]}, run_id=uuid4())
await j.flush()
messages = await store.list_messages("t1")
tool_results = [m for m in messages if m["event_type"] == "llm.tool.result"]
assert len(tool_results) == 1
assert tool_results[0]["content"]["name"] == "ask_clarification"
assert tool_results[0]["content"]["artifact"]["human_input"]["request_id"] == "clarification:call_clarify"
@pytest.mark.anyio
async def test_root_chain_end_does_not_duplicate_tool_message_captured_by_on_tool_end(self, journal_setup):
from langchain_core.messages import ToolMessage
j, store = journal_setup
j.on_llm_end(
_make_llm_response("", tool_calls=[{"id": "call_clarify", "name": "ask_clarification", "args": {"question": "Which format?"}}]),
run_id=uuid4(),
parent_run_id=None,
tags=["lead_agent"],
)
tool_msg = ToolMessage(content="Which format?", tool_call_id="call_clarify", name="ask_clarification")
j.on_tool_end(tool_msg, run_id=uuid4())
j.on_chain_end({"messages": [tool_msg]}, run_id=uuid4())
await j.flush()
messages = await store.list_messages("t1")
tool_results = [m for m in messages if m["event_type"] == "llm.tool.result"]
assert len(tool_results) == 1
@pytest.mark.anyio
async def test_root_chain_end_ignores_retained_old_tool_message_from_previous_run(self, journal_setup):
from langchain_core.messages import ToolMessage
j, store = journal_setup
j.on_llm_end(
_make_llm_response("", tool_calls=[{"id": "call_current", "name": "ask_clarification", "args": {"question": "Current?"}}]),
run_id=uuid4(),
parent_run_id=None,
tags=["lead_agent"],
)
retained_old_tool_msg = ToolMessage(content="Old question", tool_call_id="call_old", name="ask_clarification")
j.on_chain_end({"messages": [retained_old_tool_msg]}, run_id=uuid4())
await j.flush()
messages = await store.list_messages("t1")
assert not any(m["event_type"] == "llm.tool.result" for m in messages)
@pytest.mark.anyio
async def test_root_chain_end_ignores_non_allowlisted_tool_message(self, journal_setup):
from langchain_core.messages import ToolMessage
j, store = journal_setup
j.on_llm_end(
_make_llm_response("", tool_calls=[{"id": "call_search", "name": "web_search", "args": {"query": "deerflow"}}]),
run_id=uuid4(),
parent_run_id=None,
tags=["lead_agent"],
)
tool_msg = ToolMessage(content="Search result", tool_call_id="call_search", name="web_search")
j.on_chain_end({"messages": [tool_msg]}, run_id=uuid4())
await j.flush()
messages = await store.list_messages("t1")
assert not any(m["event_type"] == "llm.tool.result" for m in messages)
@pytest.mark.anyio
async def test_root_chain_end_ignores_hidden_ask_clarification_tool_message(self, journal_setup):
from langchain_core.messages import ToolMessage
j, store = journal_setup
j.on_llm_end(
_make_llm_response("", tool_calls=[{"id": "call_clarify", "name": "ask_clarification", "args": {"question": "Hidden?"}}]),
run_id=uuid4(),
parent_run_id=None,
tags=["lead_agent"],
)
tool_msg = ToolMessage(
content="Hidden?",
tool_call_id="call_clarify",
name="ask_clarification",
additional_kwargs={"hide_from_ui": True},
)
j.on_chain_end({"messages": [tool_msg]}, run_id=uuid4())
await j.flush()
messages = await store.list_messages("t1")
assert not any(m["event_type"] == "llm.tool.result" for m in messages)
class TestCustomEvents:
@pytest.mark.anyio
async def test_on_custom_event_not_implemented(self, journal_setup):
"""RunJournal does not implement on_custom_event — no crash expected."""
j, store = journal_setup
# BaseCallbackHandler.on_custom_event is a no-op by default
j.on_custom_event("task_running", {"task_id": "t1"}, run_id=uuid4())
await j.flush()
events = await store.list_events("t1", "r1")
assert isinstance(events, list)
class TestBufferFlush:
@pytest.mark.anyio
async def test_flush_threshold(self, journal_setup):
j, store = journal_setup
j._flush_threshold = 2
# Each on_llm_end emits 1 event
j.on_llm_end(_make_llm_response("A"), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"])
assert len(j._buffer) == 1
j.on_llm_end(_make_llm_response("B"), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"])
# At threshold the buffer should have been flushed asynchronously
await asyncio.sleep(0.1)
events = await store.list_events("t1", "r1")
assert len(events) >= 2
@pytest.mark.anyio
async def test_events_retained_when_no_loop(self, journal_setup):
"""Events buffered in a sync (no-loop) context should survive
until the async flush() in the finally block."""
j, store = journal_setup
j._flush_threshold = 1
original = asyncio.get_running_loop
def no_loop():
raise RuntimeError("no running event loop")
asyncio.get_running_loop = no_loop
try:
j._put(event_type="llm.ai.response", category="message", content="test")
finally:
asyncio.get_running_loop = original
assert len(j._buffer) == 1
await j.flush()
events = await store.list_events("t1", "r1")
assert any(e["event_type"] == "llm.ai.response" for e in events)
class TestIdentifyCaller:
def test_lead_agent_tag(self, journal_setup):
j, _ = journal_setup
assert j._identify_caller(["lead_agent"]) == "lead_agent"
def test_subagent_tag(self, journal_setup):
j, _ = journal_setup
assert j._identify_caller(["subagent:research"]) == "subagent:research"
def test_middleware_tag(self, journal_setup):
j, _ = journal_setup
assert j._identify_caller(["middleware:summarization"]) == "middleware:summarization"
def test_no_tags_returns_lead_agent(self, journal_setup):
j, _ = journal_setup
assert j._identify_caller([]) == "lead_agent"
assert j._identify_caller(None) == "lead_agent"
class TestChainErrorCallback:
@pytest.mark.anyio
async def test_on_chain_error_writes_run_error(self, journal_setup):
j, store = journal_setup
j.on_chain_error(ValueError("boom"), run_id=uuid4())
await asyncio.sleep(0.05)
await j.flush()
events = await store.list_events("t1", "r1")
error_events = [e for e in events if e["event_type"] == "run.error"]
assert len(error_events) == 1
assert "boom" in error_events[0]["content"]
assert error_events[0]["metadata"]["error_type"] == "ValueError"
class TestTokenTrackingDisabled:
@pytest.mark.anyio
async def test_track_token_usage_false(self):
store = MemoryRunEventStore()
j = RunJournal("r1", "t1", store, track_token_usage=False, flush_threshold=100)
j.on_llm_end(
_make_llm_response("X", usage={"input_tokens": 50, "output_tokens": 50, "total_tokens": 100}),
run_id=uuid4(),
parent_run_id=None,
tags=["lead_agent"],
)
data = j.get_completion_data()
assert data["total_tokens"] == 0
assert data["llm_call_count"] == 0
class TestConvenienceFields:
@pytest.mark.anyio
async def test_first_human_message_via_set(self, journal_setup):
j, _ = journal_setup
j.set_first_human_message("What is AI?")
data = j.get_completion_data()
assert data["first_human_message"] == "What is AI?"
@pytest.mark.anyio
async def test_completion_data_counts_human_ai_and_tool_messages(self, journal_setup):
from langchain_core.messages import HumanMessage, ToolMessage
j, _ = journal_setup
j.on_chat_model_start({}, [[HumanMessage(content="Question")]], run_id=uuid4(), tags=["lead_agent"])
j.on_llm_end(_make_llm_response("Answer"), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"])
j.on_tool_end(ToolMessage(content="Tool result", tool_call_id="call_1", name="search"), run_id=uuid4())
data = j.get_completion_data()
assert data["message_count"] == 3
assert data["first_human_message"] == "Question"
assert data["last_ai_message"] == "Answer"
@pytest.mark.anyio
async def test_tool_call_only_ai_does_not_clear_last_ai_message(self, journal_setup):
j, _ = journal_setup
j.on_llm_end(_make_llm_response("Useful answer"), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"])
j.on_llm_end(
_make_llm_response("", tool_calls=[{"id": "call_1", "name": "search", "args": {}}]),
run_id=uuid4(),
parent_run_id=None,
tags=["lead_agent"],
)
data = j.get_completion_data()
assert data["message_count"] == 2
assert data["last_ai_message"] == "Useful answer"
@pytest.mark.anyio
async def test_last_ai_message_extracts_mixed_content_without_extra_newlines(self, journal_setup):
j, _ = journal_setup
j.on_llm_end(
_make_llm_response(
[
{"type": "text", "text": "First "},
{"type": "text", "content": "second"},
" third",
{"type": "image", "url": "ignored"},
]
),
run_id=uuid4(),
parent_run_id=None,
tags=["lead_agent"],
)
data = j.get_completion_data()
assert data["message_count"] == 1
assert data["last_ai_message"] == "First second third"
@pytest.mark.anyio
async def test_last_ai_message_extracts_mapping_content(self, journal_setup):
j, _ = journal_setup
j.on_llm_end(_make_llm_response({"content": "Nested answer"}), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"])
data = j.get_completion_data()
assert data["message_count"] == 1
assert data["last_ai_message"] == "Nested answer"
@pytest.mark.anyio
async def test_duplicate_llm_run_id_does_not_double_count_message_summary(self, journal_setup):
j, _ = journal_setup
run_id = uuid4()
j.on_llm_end(_make_llm_response("Answer", usage=None), run_id=run_id, parent_run_id=None, tags=["lead_agent"])
j.on_llm_end(
_make_llm_response("Answer", usage={"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}),
run_id=run_id,
parent_run_id=None,
tags=["lead_agent"],
)
data = j.get_completion_data()
assert data["message_count"] == 1
assert data["last_ai_message"] == "Answer"
assert data["total_tokens"] == 15
@pytest.mark.anyio
async def test_subagent_ai_does_not_overwrite_lead_last_ai_message(self, journal_setup):
j, _ = journal_setup
j.on_llm_end(_make_llm_response("Lead answer"), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"])
j.on_llm_end(_make_llm_response("Subagent detail"), run_id=uuid4(), parent_run_id=None, tags=["subagent:research"])
data = j.get_completion_data()
assert data["message_count"] == 2
assert data["last_ai_message"] == "Lead answer"
@pytest.mark.anyio
async def test_get_completion_data(self, journal_setup):
j, _ = journal_setup
j._total_tokens = 100
j._msg_count = 5
data = j.get_completion_data()
assert data["total_tokens"] == 100
assert data["message_count"] == 5
class TestMiddlewareEvents:
@pytest.mark.anyio
async def test_record_middleware_uses_middleware_category(self, journal_setup):
j, store = journal_setup
j.record_middleware(
"title",
name="TitleMiddleware",
hook="after_model",
action="generate_title",
changes={"title": "Test Title", "thread_id": "t1"},
)
await j.flush()
events = await store.list_events("t1", "r1")
mw_events = [e for e in events if e["event_type"] == "middleware:title"]
assert len(mw_events) == 1
assert mw_events[0]["category"] == "middleware"
assert mw_events[0]["content"]["name"] == "TitleMiddleware"
assert mw_events[0]["content"]["hook"] == "after_model"
assert mw_events[0]["content"]["action"] == "generate_title"
assert mw_events[0]["content"]["changes"]["title"] == "Test Title"
@pytest.mark.anyio
async def test_middleware_tag_variants(self, journal_setup):
"""Different middleware tags produce distinct event_types."""
j, store = journal_setup
j.record_middleware("title", name="TitleMiddleware", hook="after_model", action="generate_title", changes={})
j.record_middleware("guardrail", name="GuardrailMiddleware", hook="before_tool", action="deny", changes={})
await j.flush()
events = await store.list_events("t1", "r1")
event_types = {e["event_type"] for e in events}
assert "middleware:title" in event_types
assert "middleware:guardrail" in event_types
class TestContextEvents:
@pytest.mark.anyio
async def test_record_memory_context_is_readable_from_public_store_contract(self, journal_setup):
j, store = journal_setup
j.record_memory_context(
content_sha256="a" * 64,
)
# Goal continuations may enter the graph more than once under the same
# run-scoped journal; the effective frozen memory event stays singular.
j.record_memory_context(
content_sha256="a" * 64,
)
await j.flush()
events = await store.list_events("t1", "r1", event_types=["context:memory"])
assert len(events) == 1
assert events[0]["category"] == "context"
assert events[0]["content"] == {"content_sha256": "a" * 64}
@pytest.mark.anyio
async def test_record_memory_context_can_retry_after_buffer_failure(self, journal_setup, monkeypatch):
j, store = journal_setup
original_put = j._put
attempts = 0
def fail_once(**kwargs):
nonlocal attempts
attempts += 1
if attempts == 1:
raise RuntimeError("buffer unavailable")
return original_put(**kwargs)
monkeypatch.setattr(j, "_put", fail_once)
with pytest.raises(RuntimeError, match="buffer unavailable"):
j.record_memory_context(content_sha256="a" * 64)
j.record_memory_context(content_sha256="a" * 64)
await j.flush()
events = await store.list_events("t1", "r1", event_types=["context:memory"])
assert len(events) == 1
assert events[0]["content"] == {"content_sha256": "a" * 64}
class TestCallerBucketing:
"""Tests for caller-bucketed token accumulation (lead_agent / subagent / middleware)."""
def test_lead_agent_bucketing(self, journal_setup):
j, _ = journal_setup
usage = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}
j.on_llm_end(_make_llm_response("A", usage=usage), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"])
assert j._lead_agent_tokens == 15
assert j._subagent_tokens == 0
assert j._middleware_tokens == 0
def test_subagent_bucketing(self, journal_setup):
j, _ = journal_setup
usage = {"input_tokens": 20, "output_tokens": 10, "total_tokens": 30}
j.on_llm_end(_make_llm_response("B", usage=usage), run_id=uuid4(), parent_run_id=None, tags=["subagent:research"])
assert j._subagent_tokens == 30
assert j._lead_agent_tokens == 0
assert j._middleware_tokens == 0
def test_middleware_bucketing(self, journal_setup):
j, _ = journal_setup
usage = {"input_tokens": 5, "output_tokens": 2, "total_tokens": 7}
j.on_llm_end(_make_llm_response("C", usage=usage), run_id=uuid4(), parent_run_id=None, tags=["middleware:summarize"])
assert j._middleware_tokens == 7
assert j._lead_agent_tokens == 0
assert j._subagent_tokens == 0
def test_mixed_callers_sum_independently(self, journal_setup):
j, _ = journal_setup
usage = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}
j.on_llm_end(_make_llm_response("A", usage=usage), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"])
j.on_llm_end(_make_llm_response("B", usage=usage), run_id=uuid4(), parent_run_id=None, tags=["subagent:bash"])
j.on_llm_end(_make_llm_response("C", usage=usage), run_id=uuid4(), parent_run_id=None, tags=["middleware:title"])
assert j._lead_agent_tokens == 15
assert j._subagent_tokens == 15
assert j._middleware_tokens == 15
assert j._total_tokens == 45
def test_get_completion_data_includes_buckets(self, journal_setup):
j, _ = journal_setup
j._lead_agent_tokens = 100
j._subagent_tokens = 200
j._middleware_tokens = 50
data = j.get_completion_data()
assert data["lead_agent_tokens"] == 100
assert data["subagent_tokens"] == 200
assert data["middleware_tokens"] == 50
def test_dedup_same_run_id(self, journal_setup):
"""Same langchain run_id in on_llm_end must not double-count."""
j, _ = journal_setup
run_id = uuid4()
usage = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}
j.on_llm_end(_make_llm_response("A", usage=usage), run_id=run_id, parent_run_id=None, tags=["lead_agent"])
j.on_llm_end(_make_llm_response("A", usage=usage), run_id=run_id, parent_run_id=None, tags=["lead_agent"])
assert j._total_tokens == 15
assert j._lead_agent_tokens == 15
assert j._llm_call_count == 1
def test_first_no_usage_second_with_usage(self, journal_setup):
"""First callback with no usage must not block second callback with usage for same run_id."""
j, _ = journal_setup
run_id = uuid4()
j.on_llm_end(_make_llm_response("A", usage=None), run_id=run_id, parent_run_id=None, tags=["lead_agent"])
assert str(run_id) not in j._counted_llm_run_ids
# Second callback for the same run_id with actual usage must still count
usage = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}
j.on_llm_end(_make_llm_response("A", usage=usage), run_id=run_id, parent_run_id=None, tags=["lead_agent"])
assert j._total_tokens == 15
assert j._lead_agent_tokens == 15
def test_track_token_usage_false_skips_buckets(self):
"""When token tracking is disabled, caller buckets stay at 0."""
store = MemoryRunEventStore()
j = RunJournal("r1", "t1", store, track_token_usage=False, flush_threshold=100)
usage = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}
j.on_llm_end(_make_llm_response("X", usage=usage), run_id=uuid4(), parent_run_id=None, tags=["subagent:research"])
assert j._subagent_tokens == 0
assert j._lead_agent_tokens == 0
def test_default_no_tags_buckets_as_lead_agent(self, journal_setup):
"""LLM calls without explicit tags default to lead_agent bucket."""
j, _ = journal_setup
usage = {"input_tokens": 5, "output_tokens": 5, "total_tokens": 10}
j.on_llm_end(_make_llm_response("Hi", usage=usage), run_id=uuid4(), parent_run_id=None)
assert j._lead_agent_tokens == 10
assert j._subagent_tokens == 0
assert j._middleware_tokens == 0
def test_unknown_tag_buckets_as_lead_agent(self, journal_setup):
"""Calls with unrecognized tags (not lead_agent/subagent:/middleware:) go to lead_agent."""
j, _ = journal_setup
usage = {"input_tokens": 5, "output_tokens": 5, "total_tokens": 10}
j.on_llm_end(_make_llm_response("Hi", usage=usage), run_id=uuid4(), parent_run_id=None, tags=["some_random_tag"])
assert j._lead_agent_tokens == 10
class TestExternalUsageRecords:
"""Tests for record_external_llm_usage_records."""
def test_records_added_to_subagent_bucket(self, journal_setup):
j, _ = journal_setup
records = [
{
"source_run_id": "ext-1",
"caller": "subagent:general-purpose",
"input_tokens": 100,
"output_tokens": 50,
"total_tokens": 150,
}
]
j.record_external_llm_usage_records(records)
assert j._subagent_tokens == 150
assert j._total_tokens == 150
assert j._total_input_tokens == 100
assert j._total_output_tokens == 50
def test_records_added_to_middleware_bucket(self, journal_setup):
j, _ = journal_setup
records = [
{
"source_run_id": "ext-2",
"caller": "middleware:summarize",
"input_tokens": 30,
"output_tokens": 10,
"total_tokens": 40,
}
]
j.record_external_llm_usage_records(records)
assert j._middleware_tokens == 40
assert j._lead_agent_tokens == 0
assert j._subagent_tokens == 0
def test_records_added_to_lead_agent_bucket(self, journal_setup):
j, _ = journal_setup
records = [
{
"source_run_id": "ext-3",
"caller": "lead_agent",
"input_tokens": 10,
"output_tokens": 5,
"total_tokens": 15,
}
]
j.record_external_llm_usage_records(records)
assert j._lead_agent_tokens == 15
def test_dedup_same_source_run_id(self, journal_setup):
"""Same source_run_id must not be double-counted."""
j, _ = journal_setup
records = [
{
"source_run_id": "dup-1",
"caller": "subagent:research",
"input_tokens": 50,
"output_tokens": 25,
"total_tokens": 75,
}
]
j.record_external_llm_usage_records(records)
j.record_external_llm_usage_records(records)
assert j._subagent_tokens == 75
assert j._total_tokens == 75
def test_total_tokens_missing_computed_from_input_output(self, journal_setup):
j, _ = journal_setup
records = [
{
"source_run_id": "ext-4",
"caller": "subagent:bash",
"input_tokens": 200,
"output_tokens": 100,
"total_tokens": 0,
}
]
j.record_external_llm_usage_records(records)
assert j._subagent_tokens == 300
assert j._total_tokens == 300
def test_total_tokens_zero_no_count(self, journal_setup):
"""Records with zero total and zero input+output must not be counted."""
j, _ = journal_setup
records = [
{
"source_run_id": "ext-5",
"caller": "subagent:research",
"input_tokens": 0,
"output_tokens": 0,
"total_tokens": 0,
}
]
j.record_external_llm_usage_records(records)
assert j._total_tokens == 0
assert j._subagent_tokens == 0
def test_empty_source_run_id_skipped(self, journal_setup):
j, _ = journal_setup
records = [
{
"source_run_id": "",
"caller": "subagent:research",
"input_tokens": 50,
"output_tokens": 25,
"total_tokens": 75,
}
]
j.record_external_llm_usage_records(records)
assert j._total_tokens == 0
def test_multiple_records_in_single_call(self, journal_setup):
j, _ = journal_setup
records = [
{"source_run_id": "r1", "caller": "subagent:gp", "input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
{"source_run_id": "r2", "caller": "subagent:bash", "input_tokens": 20, "output_tokens": 10, "total_tokens": 30},
]
j.record_external_llm_usage_records(records)
assert j._subagent_tokens == 45
assert j._total_tokens == 45
def test_external_records_coexist_with_inline_callbacks(self, journal_setup):
"""External records and inline on_llm_end must not interfere."""
j, _ = journal_setup
usage = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}
j.on_llm_end(_make_llm_response("A", usage=usage), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"])
j.record_external_llm_usage_records([{"source_run_id": "ext-6", "caller": "subagent:gp", "input_tokens": 100, "output_tokens": 50, "total_tokens": 150}])
assert j._lead_agent_tokens == 15
assert j._subagent_tokens == 150
assert j._total_tokens == 165
def test_track_token_usage_false_skips_external_records(self):
"""When token tracking is disabled, external records must not accumulate."""
store = MemoryRunEventStore()
j = RunJournal("r1", "t1", store, track_token_usage=False, flush_threshold=100)
j.record_external_llm_usage_records([{"source_run_id": "ext-7", "caller": "subagent:gp", "input_tokens": 100, "output_tokens": 50, "total_tokens": 150}])
assert j._total_tokens == 0
assert j._subagent_tokens == 0
class TestProgressSnapshots:
@pytest.mark.anyio
async def test_on_llm_end_reports_progress_snapshot(self):
snapshots: list[dict] = []
async def reporter(snapshot: dict) -> None:
snapshots.append(snapshot)
store = MemoryRunEventStore()
j = RunJournal(
"r1",
"t1",
store,
flush_threshold=100,
progress_reporter=reporter,
progress_flush_interval=0,
)
usage = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}
j.on_llm_end(_make_llm_response("Answer", usage=usage), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"])
await j.flush()
assert snapshots
assert snapshots[-1]["total_tokens"] == 15
assert snapshots[-1]["llm_call_count"] == 1
assert snapshots[-1]["message_count"] == 1
assert snapshots[-1]["last_ai_message"] == "Answer"
@pytest.mark.anyio
async def test_throttled_progress_flush_emits_trailing_snapshot(self):
snapshots: list[dict] = []
trailing_seen = asyncio.Event()
async def reporter(snapshot: dict) -> None:
snapshots.append(snapshot)
if snapshot["total_tokens"] == 45:
trailing_seen.set()
store = MemoryRunEventStore()
j = RunJournal(
"r1",
"t1",
store,
flush_threshold=100,
progress_reporter=reporter,
progress_flush_interval=0.01,
)
j.on_llm_end(
_make_llm_response("First", usage={"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}),
run_id=uuid4(),
parent_run_id=None,
tags=["lead_agent"],
)
j.on_llm_end(
_make_llm_response("Second", usage={"input_tokens": 20, "output_tokens": 10, "total_tokens": 30}),
run_id=uuid4(),
parent_run_id=None,
tags=["lead_agent"],
)
await asyncio.wait_for(trailing_seen.wait(), timeout=1.0)
await j.flush()
assert len(snapshots) >= 2
assert snapshots[-1]["total_tokens"] == 45
assert snapshots[-1]["llm_call_count"] == 2
assert snapshots[-1]["last_ai_message"] == "Second"
@pytest.mark.anyio
async def test_flush_cancels_delayed_progress_without_final_progress_write(self):
snapshots: list[dict] = []
async def reporter(snapshot: dict) -> None:
snapshots.append(snapshot)
store = MemoryRunEventStore()
j = RunJournal(
"r1",
"t1",
store,
flush_threshold=100,
progress_reporter=reporter,
progress_flush_interval=10.0,
)
j.on_llm_end(
_make_llm_response("First", usage={"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}),
run_id=uuid4(),
parent_run_id=None,
tags=["lead_agent"],
)
await asyncio.sleep(0)
assert snapshots[-1]["total_tokens"] == 15
j.on_llm_end(
_make_llm_response("Second", usage={"input_tokens": 20, "output_tokens": 10, "total_tokens": 30}),
run_id=uuid4(),
parent_run_id=None,
tags=["lead_agent"],
)
await asyncio.wait_for(j.flush(), timeout=0.2)
assert snapshots[-1]["total_tokens"] == 15
assert snapshots[-1]["llm_call_count"] == 1
assert snapshots[-1]["last_ai_message"] == "First"
class TestChatModelStartHumanMessage:
"""Tests for on_chat_model_start extracting the first human message."""
@staticmethod
def _human_input_response(source: str = "ask_clarification") -> dict:
return {
"version": 1,
"kind": "human_input_response",
"source": source,
"request_id": "clarification:call-abc",
"response_kind": "option",
"option_id": "option-2",
"value": "staging",
}
@pytest.mark.anyio
async def test_extracts_first_human_message(self, journal_setup):
"""on_chat_model_start captures the first HumanMessage from prompts."""
from langchain_core.messages import AIMessage, HumanMessage
j, store = journal_setup
messages_batch = [
[HumanMessage(content="What is AI?"), AIMessage(content="Hi there")],
]
j.on_chat_model_start({}, messages_batch, run_id=uuid4(), tags=["lead_agent"])
await j.flush()
assert j._first_human_msg == "What is AI?"
events = await store.list_events("t1", "r1")
human_events = [e for e in events if e["event_type"] == "llm.human.input"]
assert len(human_events) == 1
assert human_events[0]["content"]["content"] == "What is AI?"
@pytest.mark.anyio
async def test_skips_hidden_human_messages(self, journal_setup):
"""HumanMessages hidden from the UI are internal context, not user input."""
from langchain_core.messages import HumanMessage
j, store = journal_setup
messages_batch = [
[
HumanMessage(content="What is the weather today?"),
HumanMessage(
content="Your todo list from earlier...",
name="todo_reminder",
additional_kwargs={"hide_from_ui": True},
),
],
]
j.on_chat_model_start({}, messages_batch, run_id=uuid4(), tags=["lead_agent"])
await j.flush()
assert j._first_human_msg == "What is the weather today?"
assert j.get_completion_data()["message_count"] == 1
events = await store.list_events("t1", "r1")
human_events = [e for e in events if e["event_type"] == "llm.human.input"]
assert len(human_events) == 1
assert human_events[0]["content"]["content"] == "What is the weather today?"
@pytest.mark.anyio
async def test_only_hidden_human_messages_are_not_captured(self, journal_setup):
"""A prompt containing only internal HumanMessages has no user input."""
from langchain_core.messages import HumanMessage
j, store = journal_setup
hidden_message = HumanMessage(
content="Internal context",
additional_kwargs={"hide_from_ui": True},
)
j.on_chat_model_start({}, [[hidden_message]], run_id=uuid4(), tags=["lead_agent"])
await j.flush()
assert j._first_human_msg is None
assert j.get_completion_data()["message_count"] == 0
events = await store.list_events("t1", "r1")
assert not any(e["event_type"] == "llm.human.input" for e in events)
@pytest.mark.anyio
async def test_hidden_human_input_response_is_captured(self, journal_setup):
"""Hidden HumanInputCard replies are user-authored and must survive compaction."""
from langchain_core.messages import HumanMessage
j, store = journal_setup
hidden_response = HumanMessage(
content='For your clarification "Which environment?", my answer is: staging',
additional_kwargs={
"hide_from_ui": True,
"human_input_response": self._human_input_response(),
},
)
j.on_chat_model_start({}, [[hidden_response]], run_id=uuid4(), tags=["lead_agent"])
await j.flush()
assert j._first_human_msg == 'For your clarification "Which environment?", my answer is: staging'
assert j.get_completion_data()["message_count"] == 1
events = await store.list_events("t1", "r1")
human_events = [e for e in events if e["event_type"] == "llm.human.input"]
assert len(human_events) == 1
assert human_events[0]["content"]["additional_kwargs"]["hide_from_ui"] is True
assert human_events[0]["content"]["additional_kwargs"]["human_input_response"]["request_id"] == "clarification:call-abc"
@pytest.mark.anyio
async def test_hidden_human_input_response_wins_over_older_visible_prompt(self, journal_setup):
"""The latest hidden card reply is the run input, not an older visible prompt."""
from langchain_core.messages import HumanMessage
j, store = journal_setup
older_prompt = HumanMessage(content="Write a quicksort PDF")
hidden_response = HumanMessage(
content='For your clarification "Which format?", my answer is: tutorial',
additional_kwargs={
"hide_from_ui": True,
"human_input_response": self._human_input_response(),
},
)
j.on_chat_model_start({}, [[older_prompt, hidden_response]], run_id=uuid4(), tags=["lead_agent"])
await j.flush()
assert j._first_human_msg == 'For your clarification "Which format?", my answer is: tutorial'
events = await store.list_events("t1", "r1")
human_events = [e for e in events if e["event_type"] == "llm.human.input"]
assert len(human_events) == 1
assert human_events[0]["content"]["content"] == 'For your clarification "Which format?", my answer is: tutorial'
@pytest.mark.anyio
async def test_hidden_human_input_response_ignores_non_allowlisted_source(self, journal_setup):
"""Only explicit HumanInputCard sources are persisted while hidden."""
from langchain_core.messages import HumanMessage
j, store = journal_setup
hidden_response = HumanMessage(
content="Internal approval response",
additional_kwargs={
"hide_from_ui": True,
"human_input_response": self._human_input_response(source="future_approval"),
},
)
j.on_chat_model_start({}, [[hidden_response]], run_id=uuid4(), tags=["lead_agent"])
await j.flush()
assert j._first_human_msg is None
assert j.get_completion_data()["message_count"] == 0
events = await store.list_events("t1", "r1")
assert not any(e["event_type"] == "llm.human.input" for e in events)
@pytest.mark.anyio
async def test_legacy_summary_message_is_not_captured_as_user_input(self, journal_setup):
"""Legacy synthetic summaries are internal context even if hide_from_ui is absent."""
from langchain_core.messages import HumanMessage
j, store = journal_setup
legacy_summary = HumanMessage(content="Older compressed conversation state", name="summary")
j.on_chat_model_start({}, [[legacy_summary]], run_id=uuid4(), tags=["lead_agent"])
await j.flush()
assert j._first_human_msg is None
assert j.get_completion_data()["message_count"] == 0
events = await store.list_events("t1", "r1")
assert not any(e["event_type"] == "llm.human.input" for e in events)
@pytest.mark.anyio
async def test_visible_human_message_after_hidden_only_prompt_is_captured(self, journal_setup):
"""Skipping an internal-only prompt does not block later user input."""
from langchain_core.messages import HumanMessage
j, store = journal_setup
hidden_message = HumanMessage(
content="Internal context",
additional_kwargs={"hide_from_ui": True},
)
j.on_chat_model_start({}, [[hidden_message]], run_id=uuid4(), tags=["lead_agent"])
j.on_chat_model_start(
{},
[[HumanMessage(content="Real question")]],
run_id=uuid4(),
tags=["lead_agent"],
)
await j.flush()
assert j._first_human_msg == "Real question"
assert j.get_completion_data()["message_count"] == 1
events = await store.list_events("t1", "r1")
human_events = [e for e in events if e["event_type"] == "llm.human.input"]
assert len(human_events) == 1
assert human_events[0]["content"]["content"] == "Real question"
@pytest.mark.anyio
async def test_summarization_prompt_does_not_capture_first_human_message(self, journal_setup):
"""Internal summarization prompts must not replace the run's real user input."""
from langchain_core.messages import HumanMessage
j, store = journal_setup
summarization_prompt = HumanMessage(
content="\nContext Extraction Assistant\n\n\n\nExtract context...",
)
j.on_chat_model_start(
{},
[[summarization_prompt]],
run_id=uuid4(),
tags=["middleware:summarize"],
)
j.on_chat_model_start(
{},
[[HumanMessage(content="Real user follow-up")]],
run_id=uuid4(),
tags=["lead_agent"],
)
await j.flush()
assert j._first_human_msg == "Real user follow-up"
assert j.get_completion_data()["message_count"] == 1
events = await store.list_events("t1", "r1")
human_events = [e for e in events if e["event_type"] == "llm.human.input"]
assert len(human_events) == 1
assert human_events[0]["content"]["content"] == "Real user follow-up"
assert human_events[0]["metadata"]["caller"] == "lead_agent"
@pytest.mark.anyio
@pytest.mark.parametrize("tags", [["middleware:summarize"], ["subagent:research"]])
async def test_non_lead_human_prompts_are_not_captured_as_user_input(self, journal_setup, tags):
"""Only lead-agent LLM starts create UI-facing human input events."""
from langchain_core.messages import HumanMessage
j, store = journal_setup
j.on_chat_model_start(
{},
[[HumanMessage(content="Internal prompt")]],
run_id=uuid4(),
tags=tags,
)
await j.flush()
assert j._first_human_msg is None
assert j.get_completion_data()["message_count"] == 0
events = await store.list_events("t1", "r1")
assert not any(e["event_type"] == "llm.human.input" for e in events)
@pytest.mark.anyio
async def test_only_first_human_message_captured(self, journal_setup):
"""Subsequent on_chat_model_start calls do not overwrite the first message."""
from langchain_core.messages import HumanMessage
j, store = journal_setup
j.on_chat_model_start({}, [[HumanMessage(content="First question")]], run_id=uuid4(), tags=["lead_agent"])
j.on_chat_model_start({}, [[HumanMessage(content="Second question")]], run_id=uuid4(), tags=["lead_agent"])
await j.flush()
assert j._first_human_msg == "First question"
events = await store.list_events("t1", "r1")
human_events = [e for e in events if e["event_type"] == "llm.human.input"]
assert len(human_events) == 1
@pytest.mark.anyio
async def test_empty_messages_no_crash(self, journal_setup):
"""on_chat_model_start with empty messages does not crash."""
j, store = journal_setup
j.on_chat_model_start({}, [], run_id=uuid4(), tags=["lead_agent"])
await j.flush()
assert j._first_human_msg is None
class TestDeliveryTracking:
"""Slice 1 (#4272): journal records artifact production for run.delivery."""
@staticmethod
def _register_tool_call(j: RunJournal, tool_call_id: str, name: str) -> None:
from langchain_core.messages import AIMessage
ai = AIMessage(content="", tool_calls=[{"id": tool_call_id, "name": name, "args": {}}])
j._remember_current_run_tool_calls(ai, caller="lead_agent")
def test_callbacks_run_inline_to_serialize_parallel_mutations(self, journal_setup):
j, _ = journal_setup
# LangChain dispatches synchronous handlers with run_inline=False via
# run_in_executor, allowing parallel tool callbacks to mutate one
# journal from different threads.
assert j.run_inline is True
@pytest.mark.anyio
async def test_concurrent_callbacks_on_one_journal_are_serialized(self, journal_setup):
from langchain_core.callbacks.manager import ahandle_event
from langchain_core.messages import ToolMessage
from langgraph.types import Command
j, _ = journal_setup
commands = []
for index, path in enumerate(("report.md", "report.md", "appendix.md"), start=1):
tool_call_id = f"call_{index}"
self._register_tool_call(j, tool_call_id, "present_files")
commands.append(
Command(
update={
"artifacts": [f"/mnt/user-data/outputs/{path}"],
"messages": [ToolMessage("Successfully presented files", tool_call_id=tool_call_id)],
}
)
)
# This is the real LangChain async callback dispatcher. Because the
# journal is run_inline, each synchronous mutation completes on the
# event-loop thread instead of racing in executor threads.
await asyncio.gather(
*(
ahandle_event(
[j],
"on_tool_end",
"ignore_agent",
command,
run_id=uuid4(),
)
for command in commands
)
)
content = j.get_delivery_content()
assert content["presented"] == 2
assert set(content["paths"]) == {
"/mnt/user-data/outputs/report.md",
"/mnt/user-data/outputs/appendix.md",
}
assert set(content["by_tool"]["present_files"]) == set(content["paths"])
@pytest.mark.anyio
async def test_concurrent_runs_keep_delivery_accumulators_isolated(self):
from langchain_core.messages import ToolMessage
from langgraph.types import Command
store = MemoryRunEventStore()
journals = [RunJournal(run_id, "t1", store, flush_threshold=100) for run_id in ("r1", "r2")]
async def finish_run(journal: RunJournal, index: int) -> None:
tool_call_id = f"call_run_{index}"
self._register_tool_call(journal, tool_call_id, "present_files")
journal.on_tool_end(
Command(
update={
"artifacts": [f"/mnt/user-data/outputs/report-{index}.md"],
"messages": [ToolMessage("Successfully presented files", tool_call_id=tool_call_id)],
}
),
run_id=uuid4(),
)
await asyncio.sleep(0)
journal.record_delivery()
await journal.flush()
await asyncio.gather(*(finish_run(journal, index) for index, journal in enumerate(journals, start=1)))
for index in (1, 2):
events = await store.list_events("t1", f"r{index}")
content = next(e for e in events if e["event_type"] == "run.delivery")["content"]
assert content == {
"presented": 1,
"paths": [f"/mnt/user-data/outputs/report-{index}.md"],
"by_tool": {"present_files": [f"/mnt/user-data/outputs/report-{index}.md"]},
}
@pytest.mark.anyio
async def test_present_files_success_command_recorded_with_attribution(self, journal_setup):
from langchain_core.messages import ToolMessage
from langgraph.types import Command
j, store = journal_setup
self._register_tool_call(j, "call_1", "present_files")
cmd = Command(
update={
"artifacts": ["/mnt/user-data/outputs/report.md"],
"messages": [ToolMessage("Successfully presented files", tool_call_id="call_1")],
}
)
j.on_tool_end(cmd, run_id=uuid4())
j.record_delivery()
await j.flush()
events = await store.list_events("t1", "r1")
delivery = [e for e in events if e["event_type"] == "run.delivery"]
assert len(delivery) == 1
content = delivery[0]["content"]
assert content["presented"] == 1
assert content["paths"] == ["/mnt/user-data/outputs/report.md"]
assert content["by_tool"] == {"present_files": ["/mnt/user-data/outputs/report.md"]}
assert delivery[0]["category"] == "outputs"
@pytest.mark.anyio
async def test_command_with_multiple_messages_records_artifacts_once(self, journal_setup):
from langchain_core.messages import ToolMessage
from langgraph.types import Command
j, store = journal_setup
self._register_tool_call(j, "call_multi", "present_files")
cmd = Command(
update={
"artifacts": ["/mnt/user-data/outputs/report.md"],
"messages": [
ToolMessage("Successfully presented files", tool_call_id="call_multi"),
HumanMessage("Additional command message"),
],
}
)
j.on_tool_end(cmd, run_id=uuid4())
j.record_delivery()
await j.flush()
events = await store.list_events("t1", "r1")
content = next(e for e in events if e["event_type"] == "run.delivery")["content"]
assert content == {
"presented": 1,
"paths": ["/mnt/user-data/outputs/report.md"],
"by_tool": {"present_files": ["/mnt/user-data/outputs/report.md"]},
}
@pytest.mark.anyio
async def test_command_with_multiple_tool_names_leaves_artifacts_unattributed(self, journal_setup):
from langchain_core.messages import ToolMessage
from langgraph.types import Command
j, store = journal_setup
self._register_tool_call(j, "call_present", "present_files")
self._register_tool_call(j, "call_browser", "browser_screenshot")
cmd = Command(
update={
"artifacts": [
"/mnt/user-data/outputs/report.md",
"/mnt/user-data/outputs/shot.png",
],
"messages": [
ToolMessage("Successfully presented files", tool_call_id="call_present"),
ToolMessage("Saved browser screenshot", tool_call_id="call_browser"),
],
}
)
j.on_tool_end(cmd, run_id=uuid4())
j.record_delivery()
await j.flush()
events = await store.list_events("t1", "r1")
content = next(e for e in events if e["event_type"] == "run.delivery")["content"]
assert content == {
"presented": 2,
"paths": [
"/mnt/user-data/outputs/report.md",
"/mnt/user-data/outputs/shot.png",
],
"by_tool": {},
}
@pytest.mark.anyio
async def test_error_command_without_artifacts_not_recorded(self, journal_setup):
from langchain_core.messages import ToolMessage
from langgraph.types import Command
j, store = journal_setup
self._register_tool_call(j, "call_2", "present_files")
cmd = Command(update={"messages": [ToolMessage("Error: Only files in /mnt/user-data/outputs can be presented", tool_call_id="call_2")]})
j.on_tool_end(cmd, run_id=uuid4())
j.record_delivery()
await j.flush()
events = await store.list_events("t1", "r1")
delivery = [e for e in events if e["event_type"] == "run.delivery"]
assert len(delivery) == 1
assert delivery[0]["content"] == {"presented": 0, "paths": [], "by_tool": {}}
@pytest.mark.anyio
async def test_browser_tool_artifacts_recorded_under_producing_tool(self, journal_setup):
from langchain_core.messages import ToolMessage
from langgraph.types import Command
j, store = journal_setup
self._register_tool_call(j, "call_3", "browser_screenshot")
cmd = Command(
update={
"artifacts": ["/mnt/user-data/outputs/shot.png"],
"messages": [ToolMessage("Saved browser screenshot", tool_call_id="call_3")],
}
)
j.on_tool_end(cmd, run_id=uuid4())
j.record_delivery()
await j.flush()
events = await store.list_events("t1", "r1")
content = next(e for e in events if e["event_type"] == "run.delivery")["content"]
assert content["presented"] == 1
assert content["by_tool"] == {"browser_screenshot": ["/mnt/user-data/outputs/shot.png"]}
@pytest.mark.anyio
async def test_duplicate_path_tool_pair_recorded_once(self, journal_setup):
from langchain_core.messages import ToolMessage
from langgraph.types import Command
j, store = journal_setup
self._register_tool_call(j, "call_4", "present_files")
for _ in range(2):
j.on_tool_end(
Command(
update={
"artifacts": ["/mnt/user-data/outputs/report.md"],
"messages": [ToolMessage("Successfully presented files", tool_call_id="call_4")],
}
),
run_id=uuid4(),
)
j.record_delivery()
await j.flush()
events = await store.list_events("t1", "r1")
content = next(e for e in events if e["event_type"] == "run.delivery")["content"]
assert content["presented"] == 1
assert content["paths"] == ["/mnt/user-data/outputs/report.md"]
@pytest.mark.anyio
async def test_unattributed_artifacts_counted_without_by_tool_entry(self, journal_setup):
from langchain_core.messages import ToolMessage
from langgraph.types import Command
j, store = journal_setup
# No _register_tool_call: attribution missing (e.g. tool_call names map miss).
cmd = Command(
update={
"artifacts": ["/mnt/user-data/outputs/anon.txt"],
"messages": [ToolMessage("ok", tool_call_id="call_unknown")],
}
)
j.on_tool_end(cmd, run_id=uuid4())
j.record_delivery()
await j.flush()
events = await store.list_events("t1", "r1")
content = next(e for e in events if e["event_type"] == "run.delivery")["content"]
assert content["presented"] == 1
assert content["paths"] == ["/mnt/user-data/outputs/anon.txt"]
assert content["by_tool"] == {}