import asyncio from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest from nanobot.agent.context import ContextBuilder from nanobot.agent.loop import AgentLoop from nanobot.agent.tools.context import RequestContext, request_context from nanobot.bus.events import InboundMessage from nanobot.bus.outbound_events import ( GoalStatusEvent, StreamDeltaEvent, StreamedResponseEvent, StreamEndEvent, TurnEndEvent, ) from nanobot.bus.queue import MessageBus from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META from nanobot.providers.base import LLMResponse from nanobot.providers.factory import ProviderSnapshot from nanobot.runtime_context import ( RUNTIME_CONTEXT_HISTORY_META, RUNTIME_CONTEXT_MESSAGE_META, RuntimeContextBlock, append_runtime_context, public_history_message, ) from nanobot.session.automation_turns import AUTOMATION_HISTORY_META from nanobot.session.goal_state import GOAL_STATE_KEY from nanobot.session.manager import Session, SessionManager from nanobot.session.turn_continuation import ( INTERNAL_CONTINUATION_META, INTERNAL_CONTINUATION_RUN_STARTED_AT_META, ) from nanobot.session.webui_turns import ( TITLE_GENERATION_MAX_TOKENS, TITLE_GENERATION_REASONING_EFFORT, WEBUI_SESSION_METADATA_KEY, WEBUI_TITLE_METADATA_KEY, WebuiTurnCoordinator, clean_generated_title, maybe_generate_webui_title, ) from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_META from nanobot.utils.llm_runtime import LLMRuntime def _mk_loop() -> AgentLoop: loop = AgentLoop.__new__(AgentLoop) from nanobot.config.schema import AgentDefaults loop.max_tool_result_chars = AgentDefaults().max_tool_result_chars return loop def _runtime_message(content, blocks: list[RuntimeContextBlock]) -> dict: merged, marker = append_runtime_context(content, blocks) assert marker is not None return { "role": "user", "content": merged, "_meta": {RUNTIME_CONTEXT_MESSAGE_META: marker}, } def _make_full_loop(tmp_path: Path) -> AgentLoop: provider = MagicMock() provider.get_default_model.return_value = "test-model" provider.generation = SimpleNamespace(max_tokens=4096) provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Test title")) loop = AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model") WebuiTurnCoordinator( bus=loop.bus, sessions=loop.sessions, schedule_background=lambda coro: loop._schedule_background(coro), ).subscribe(loop.runtime_events) return loop def test_agent_loop_llm_runtime_reflects_current_provider_and_model(tmp_path: Path) -> None: loop = _make_full_loop(tmp_path) runtime = loop.llm_runtime() assert runtime.provider is loop.provider assert runtime.model == "test-model" next_provider = MagicMock() next_provider.generation = SimpleNamespace( temperature=0.1, max_tokens=4096, reasoning_effort=None, ) loop.runtime_resolver.adopt_snapshot(ProviderSnapshot( provider=next_provider, model="next-model", context_window_tokens=runtime.context_window_tokens, signature=("next-model",), )) runtime = loop.llm_runtime() assert runtime.provider is next_provider assert runtime.model == "next-model" def test_persist_cron_turn_uses_distinct_history_marker(tmp_path: Path) -> None: loop = _make_full_loop(tmp_path) session = loop.sessions.get_or_create("websocket:auto") prompt_ref = {"id": "cron.agent_turn.reminder", "version": 1, "sha256": "abc"} persisted = loop._persist_user_message_early( InboundMessage( channel="websocket", sender_id="cron", chat_id="auto", content="Cron job: internal prompt", metadata={ CRON_TRIGGER_META: { "job_id": "job-1", "job_name": "Daily check", "run_id": "job-1:1", "prompt_ref": prompt_ref, "persist_content": "Scheduled cron job triggered: Daily check", } }, ), session, ) assert persisted is True message = session.messages[-1] assert message["content"] == "Scheduled cron job triggered: Daily check" assert message[AUTOMATION_HISTORY_META] == { "kind": "cron", "cron_job_id": "job-1", "cron_job_name": "Daily check", "cron_run_id": "job-1:1", "cron_prompt_ref": prompt_ref, } assert message[CRON_HISTORY_META] is True assert CRON_TRIGGER_META not in message assert message["cron_job_id"] == "job-1" assert message["cron_job_name"] == "Daily check" assert message["cron_run_id"] == "job-1:1" assert message["cron_prompt_ref"] == prompt_ref def test_persist_local_trigger_turn_uses_hidden_automation_marker(tmp_path: Path) -> None: loop = _make_full_loop(tmp_path) session = loop.sessions.get_or_create("websocket:auto") persisted = loop._persist_user_message_early( InboundMessage( channel="websocket", sender_id="trigger", chat_id="auto", content="Review PR #4502", metadata={ LOCAL_TRIGGER_META: { "trigger_id": "trg_123", "trigger_name": "PR review", "delivery_id": "tdel_456", "created_at_ms": 1_700_000_000_000, "persist_content": "Local trigger received: PR review\n\nReview PR #4502", } }, ), session, ) assert persisted is True message = session.messages[-1] assert message["content"] == "Local trigger received: PR review\n\nReview PR #4502" assert message[AUTOMATION_HISTORY_META] == { "kind": "local_trigger", "trigger_id": "trg_123", "trigger_name": "PR review", "trigger_delivery_id": "tdel_456", } assert LOCAL_TRIGGER_META not in message assert message["trigger_id"] == "trg_123" assert message["trigger_name"] == "PR review" assert message["trigger_delivery_id"] == "tdel_456" @pytest.mark.asyncio async def test_new_with_bot_suffix_does_not_persist_command(tmp_path: Path) -> None: loop = _make_full_loop(tmp_path) response = await loop._process_message( InboundMessage( channel="websocket", sender_id="user", chat_id="chat-1", content="/new@nanobot_bot", ) ) assert response is not None assert response.content == "New session started." session = loop.sessions.get_or_create("websocket:chat-1") assert session.messages == [] def test_clean_generated_title_strips_reasoning_tags() -> None: assert clean_generated_title("reasoning WebUI polish") == "WebUI polish" assert clean_generated_title("Title: The user said hello") == "" @pytest.mark.asyncio async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Path) -> None: loop = _make_full_loop(tmp_path) loop.provider.chat_with_retry = AsyncMock( return_value=LLMResponse(content='"优化 WebUI 侧边栏。"', finish_reason="stop") ) session = loop.sessions.get_or_create("websocket:chat-title") session.metadata[WEBUI_SESSION_METADATA_KEY] = True session.add_message("user", "帮我优化一下 webui 的 sidebar") session.add_message("assistant", "可以,我会先调整布局和视觉层级。") loop.sessions.save(session) generated = await maybe_generate_webui_title( sessions=loop.sessions, session_key="websocket:chat-title", provider=loop.provider, model=loop.model, ) assert generated is True assert session.metadata[WEBUI_TITLE_METADATA_KEY] == "优化 WebUI 侧边栏" loop.provider.chat_with_retry.assert_awaited_once() assert loop.provider.chat_with_retry.await_args.kwargs["max_tokens"] == TITLE_GENERATION_MAX_TOKENS assert ( loop.provider.chat_with_retry.await_args.kwargs["reasoning_effort"] == TITLE_GENERATION_REASONING_EFFORT ) @pytest.mark.asyncio async def test_generate_webui_title_skips_plain_websocket_sessions(tmp_path: Path) -> None: loop = _make_full_loop(tmp_path) loop.provider.chat_with_retry = AsyncMock( return_value=LLMResponse(content="Plain websocket title", finish_reason="stop") ) session = loop.sessions.get_or_create("websocket:custom-client") session.add_message("user", "hello from a custom websocket client") loop.sessions.save(session) generated = await maybe_generate_webui_title( sessions=loop.sessions, session_key="websocket:custom-client", provider=loop.provider, model=loop.model, ) assert generated is False assert WEBUI_TITLE_METADATA_KEY not in session.metadata loop.provider.chat_with_retry.assert_not_awaited() @pytest.mark.asyncio async def test_generate_webui_title_ignores_command_only_sessions(tmp_path: Path) -> None: loop = _make_full_loop(tmp_path) session = loop.sessions.get_or_create("websocket:command-title") session.metadata[WEBUI_SESSION_METADATA_KEY] = True session.add_message("user", "/model deep", _command=True) session.add_message( "assistant", "Switched model preset to `deep`.\n- Model: `deepseek-v4-pro`", _command=True, ) loop.sessions.save(session) generated = await maybe_generate_webui_title( sessions=loop.sessions, session_key="websocket:command-title", provider=loop.provider, model=loop.model, ) assert generated is False assert WEBUI_TITLE_METADATA_KEY not in session.metadata loop.provider.chat_with_retry.assert_not_awaited() @pytest.mark.asyncio async def test_generate_webui_title_ignores_cron_internal_turns(tmp_path: Path) -> None: loop = _make_full_loop(tmp_path) session = loop.sessions.get_or_create("websocket:cron-title") session.metadata[WEBUI_SESSION_METADATA_KEY] = True session.add_message( "user", "Scheduled cron job triggered: 30s-test\n\nInternal reminder prompt", **{CRON_HISTORY_META: True}, ) session.add_message("assistant", "提醒已经到期。") loop.sessions.save(session) generated = await maybe_generate_webui_title( sessions=loop.sessions, session_key="websocket:cron-title", provider=loop.provider, model=loop.model, ) assert generated is False assert WEBUI_TITLE_METADATA_KEY not in session.metadata loop.provider.chat_with_retry.assert_not_awaited() def test_webui_title_update_uses_captured_llm_runtime( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: bus = MessageBus() sessions = SessionManager(tmp_path) scheduled: list[object] = [] captured: dict[str, object] = {} async def fake_title_after_turn(**kwargs: object) -> bool: captured.update(kwargs) return False monkeypatch.setattr( "nanobot.session.webui_turns.maybe_generate_webui_title_after_turn", fake_title_after_turn, ) coordinator = WebuiTurnCoordinator( bus=bus, sessions=sessions, schedule_background=lambda coro: scheduled.append(coro), ) provider = MagicMock() msg = InboundMessage( channel="websocket", sender_id="u1", chat_id="chat1", content="say hello", metadata={"webui": True}, ) coordinator.capture_title_context( "websocket:chat1", msg, LLMRuntime.capture(provider, "turn-model", context_window_tokens=32_768), ) asyncio.run(coordinator.handle_turn_end( msg, session_key="websocket:chat1", latency_ms=None, )) assert len(scheduled) == 1 asyncio.run(scheduled[0]) # type: ignore[arg-type] assert captured["provider"] is provider assert captured["model"] == "turn-model" def test_save_turn_keeps_multimodal_runtime_context_for_model_replay() -> None: loop = _mk_loop() session = Session(key="test:runtime-only") block = RuntimeContextBlock(source="test", content="provider context") loop._save_turn( session, [_runtime_message([], [block])], skip=0, ) assert session.messages[0]["content"] == [ {"type": "text", "text": "provider context"} ] assert public_history_message(session.messages[0])["content"] == [] def test_save_turn_keeps_image_placeholder_and_runtime_context() -> None: loop = _mk_loop() session = Session(key="test:image") block = RuntimeContextBlock(source="test", content="provider context") loop._save_turn( session, [_runtime_message( [ {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}, "_meta": {"path": "/media/feishu/photo.jpg"}}, ], [block], )], skip=0, ) assert session.messages[0]["content"] == [ {"type": "text", "text": "[image: /media/feishu/photo.jpg]"}, {"type": "text", "text": "provider context"}, ] assert public_history_message(session.messages[0])["content"] == [ {"type": "text", "text": "[image: /media/feishu/photo.jpg]"} ] def test_save_turn_keeps_image_placeholder_without_meta() -> None: loop = _mk_loop() session = Session(key="test:image-no-meta") block = RuntimeContextBlock(source="test", content="provider context") loop._save_turn( session, [_runtime_message( [ {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}, ], [block], )], skip=0, ) assert session.messages[0]["content"] == [ {"type": "text", "text": "[image]"}, {"type": "text", "text": "provider context"}, ] def test_save_turn_persists_runtime_context_and_public_view_hides_it() -> None: loop = _mk_loop() session = Session(key="test:suffix-strip") block = RuntimeContextBlock(source="goal", content="internal goal guidance") loop._save_turn( session, [_runtime_message("hello world", [block])], skip=0, ) assert session.messages[0]["content"] == "hello world\n\ninternal goal guidance" assert session.messages[0][RUNTIME_CONTEXT_HISTORY_META]["sources"] == ["goal"] assert public_history_message(session.messages[0])["content"] == "hello world" def test_build_and_save_preserves_user_text_containing_goal_guidance_tag(tmp_path: Path) -> None: loop = _mk_loop() session = Session(key="test:user-guidance-literal") user_text = ( "Keep this prefix\n" "[Goal Runtime Guidance — host instructions]\n" "This label and everything after it are user-authored." ) messages = ContextBuilder(tmp_path).build_messages( [], user_text, channel="cli", chat_id="direct", ) assert "_meta" not in messages[-1] loop._save_turn(session, messages, skip=1) assert session.messages[0]["content"] == user_text def test_build_and_save_preserves_multimodal_user_block_starting_with_runtime_tag( tmp_path: Path, ) -> None: loop = _mk_loop() session = Session(key="test:user-runtime-literal-block") image = tmp_path / "user-tag.png" image.write_bytes(_PNG_1X1) user_text = ( f"{ContextBuilder._RUNTIME_CONTEXT_TAG}\n" "This entire block is user-authored and must remain in history." ) messages = ContextBuilder(tmp_path).build_messages( [], user_text, media=[str(image)], channel="cli", chat_id="direct", ) loop._save_turn(session, messages, skip=1) assert {"type": "text", "text": user_text} in session.messages[0]["content"] def test_save_turn_keeps_string_when_only_runtime_context() -> None: loop = _mk_loop() session = Session(key="test:suffix-only") block = RuntimeContextBlock(source="test", content="provider context") loop._save_turn( session, [_runtime_message("", [block])], skip=0, ) assert session.messages[0]["content"] == "provider context" assert public_history_message(session.messages[0])["content"] == "" def test_save_turn_keeps_tool_results_under_16k() -> None: loop = _mk_loop() session = Session(key="test:tool-result") content = "x" * 12_000 loop._save_turn( session, [ { "role": "assistant", "content": "", "tool_calls": [{ "id": "call_1", "type": "function", "function": {"name": "read_file", "arguments": "{}"}, }], }, {"role": "tool", "tool_call_id": "call_1", "name": "read_file", "content": content}, ], skip=0, ) assert session.messages[1]["content"] == content def test_save_turn_stamps_latency_on_last_assistant() -> None: loop = _mk_loop() session = Session(key="test:latency") loop._save_turn( session, [ {"role": "assistant", "content": "hello", "tool_calls": [{"id": "c1"}]}, {"role": "assistant", "content": "final answer"}, ], skip=0, turn_latency_ms=12345, ) assert session.messages[-1]["role"] == "assistant" assert session.messages[-1]["content"] == "final answer" assert session.messages[-1]["latency_ms"] == 12345 def test_restore_runtime_checkpoint_rehydrates_completed_and_pending_tools() -> None: loop = _mk_loop() session = Session( key="test:checkpoint", metadata={ AgentLoop._RUNTIME_CHECKPOINT_KEY: { "assistant_message": { "role": "assistant", "content": "working", "tool_calls": [ { "id": "call_done", "type": "function", "function": {"name": "read_file", "arguments": "{}"}, }, { "id": "call_pending", "type": "function", "function": {"name": "exec", "arguments": "{}"}, }, ], }, "completed_tool_results": [ { "role": "tool", "tool_call_id": "call_done", "name": "read_file", "content": "ok", } ], "pending_tool_calls": [ { "id": "call_pending", "type": "function", "function": {"name": "exec", "arguments": "{}"}, } ], } }, ) restored = loop._restore_runtime_checkpoint(session) assert restored is True assert session.metadata.get(AgentLoop._RUNTIME_CHECKPOINT_KEY) is None assert session.messages[0]["role"] == "assistant" assert session.messages[1]["tool_call_id"] == "call_done" assert session.messages[2]["tool_call_id"] == "call_pending" assert "interrupted before this tool finished" in session.messages[2]["content"].lower() def test_restore_runtime_checkpoint_dedupes_overlapping_tail() -> None: loop = _mk_loop() session = Session( key="test:checkpoint-overlap", messages=[ { "role": "assistant", "content": "working", "tool_calls": [ { "id": "call_done", "type": "function", "function": {"name": "read_file", "arguments": "{}"}, }, { "id": "call_pending", "type": "function", "function": {"name": "exec", "arguments": "{}"}, }, ], }, { "role": "tool", "tool_call_id": "call_done", "name": "read_file", "content": "ok", }, ], metadata={ AgentLoop._RUNTIME_CHECKPOINT_KEY: { "assistant_message": { "role": "assistant", "content": "working", "tool_calls": [ { "id": "call_done", "type": "function", "function": {"name": "read_file", "arguments": "{}"}, }, { "id": "call_pending", "type": "function", "function": {"name": "exec", "arguments": "{}"}, }, ], }, "completed_tool_results": [ { "role": "tool", "tool_call_id": "call_done", "name": "read_file", "content": "ok", } ], "pending_tool_calls": [ { "id": "call_pending", "type": "function", "function": {"name": "exec", "arguments": "{}"}, } ], } }, ) restored = loop._restore_runtime_checkpoint(session) assert restored is True assert session.metadata.get(AgentLoop._RUNTIME_CHECKPOINT_KEY) is None assert len(session.messages) == 3 assert session.messages[0]["role"] == "assistant" assert session.messages[1]["tool_call_id"] == "call_done" assert session.messages[2]["tool_call_id"] == "call_pending" @pytest.mark.asyncio async def test_process_message_persists_user_message_before_turn_completes(tmp_path: Path) -> None: loop = _make_full_loop(tmp_path) loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] loop._run_agent_loop = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign] msg = InboundMessage(channel="feishu", sender_id="u1", chat_id="c1", content="persist me") with pytest.raises(RuntimeError, match="boom"): await loop._process_message(msg) loop.sessions.invalidate("feishu:c1") persisted = loop.sessions.get_or_create("feishu:c1") assert [m["role"] for m in persisted.messages] == ["user"] assert persisted.messages[0]["content"] == "persist me" assert persisted.metadata.get(AgentLoop._PENDING_USER_TURN_KEY) is True assert persisted.updated_at >= persisted.created_at # 1x1 PNG used by the media-persistence tests. ``extract_documents`` runs # at the top of ``_process_message`` and filters ``msg.media`` down to # paths that magic-byte-sniff as images, so the test fixture needs real # bytes on disk (not just placeholder paths). _PNG_1X1 = ( b"\x89PNG\r\n\x1a\n" b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" b"\x00\x00\x00\nIDATx\x9cc\x00\x00\x00\x02\x00\x01" b"\x00\x00\x00\x00IEND\xaeB`\x82" ) @pytest.mark.asyncio async def test_process_message_persists_media_paths_on_user_turn(tmp_path: Path) -> None: """User turns that attach images must record the media paths alongside the text so the webui can rehydrate previews on session replay. This is the producer half of the signed-media-URL round-trip: paths are stored here, then :meth:`WebSocketChannel._augment_media_urls` maps them onto signed URLs on the way out. """ img_a = tmp_path / "uuid-1.png" img_a.write_bytes(_PNG_1X1) img_b = tmp_path / "uuid-2.png" img_b.write_bytes(_PNG_1X1) loop = _make_full_loop(tmp_path) loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] loop._run_agent_loop = AsyncMock(side_effect=RuntimeError("interrupt")) # type: ignore[method-assign] msg = InboundMessage( channel="websocket", sender_id="u1", chat_id="c-media", content="look", media=[str(img_a), str(img_b)], ) with pytest.raises(RuntimeError, match="interrupt"): await loop._process_message(msg) loop.sessions.invalidate("websocket:c-media") persisted = loop.sessions.get_or_create("websocket:c-media") assert [m["role"] for m in persisted.messages] == ["user"] assert persisted.messages[0]["content"] == "look" assert persisted.messages[0]["media"] == [str(img_a), str(img_b)] @pytest.mark.asyncio async def test_process_message_persists_media_only_turn_without_text(tmp_path: Path) -> None: """A turn with images but no text still persists (previously silent-dropped). The old early-persist gate skipped messages without text, leaving pure image turns un-checkpointed. They now materialise as an empty-content user row with ``media`` attached. """ img = tmp_path / "only.png" img.write_bytes(_PNG_1X1) loop = _make_full_loop(tmp_path) loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] loop._run_agent_loop = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign] msg = InboundMessage( channel="websocket", sender_id="u1", chat_id="c-images-only", content="", media=[str(img)], ) with pytest.raises(RuntimeError): await loop._process_message(msg) loop.sessions.invalidate("websocket:c-images-only") persisted = loop.sessions.get_or_create("websocket:c-images-only") assert len(persisted.messages) == 1 assert persisted.messages[0]["role"] == "user" assert persisted.messages[0]["content"] == "" assert persisted.messages[0]["media"] == [str(img)] @pytest.mark.asyncio async def test_process_message_does_not_duplicate_early_persisted_user_message(tmp_path: Path) -> None: loop = _make_full_loop(tmp_path) loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] loop._run_agent_loop = AsyncMock(return_value=( "done", None, [ {"role": "system", "content": "system"}, {"role": "user", "content": "hello"}, {"role": "assistant", "content": "done"}, ], "stop", False, )) # type: ignore[method-assign] result = await loop._process_message( InboundMessage(channel="feishu", sender_id="u1", chat_id="c2", content="hello") ) assert result is not None assert result.content == "done" session = loop.sessions.get_or_create("feishu:c2") assert [ {k: v for k, v in m.items() if k in {"role", "content"}} for m in session.messages ] == [ {"role": "user", "content": "hello"}, {"role": "assistant", "content": "done"}, ] assert AgentLoop._PENDING_USER_TURN_KEY not in session.metadata @pytest.mark.asyncio async def test_internal_continuation_queues_turn_without_fake_user_history( tmp_path: Path, ) -> None: loop = _make_full_loop(tmp_path) loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] session = loop.sessions.get_or_create("feishu:c-auto") session.metadata[GOAL_STATE_KEY] = { "status": "active", "objective": "Finish the long goal.", } loop.sessions.save(session) calls: list[dict] = [] async def fake_run_agent_loop(initial_messages, *, metadata=None, **_kwargs): calls.append({"initial_messages": initial_messages, "metadata": metadata}) if len(calls) == 1: return ( "paused", [], [*initial_messages, {"role": "assistant", "content": "paused"}], "max_iterations", False, ) return ( "done", [], [*initial_messages, {"role": "assistant", "content": "done"}], "completed", False, ) loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign] pending: asyncio.Queue[InboundMessage] = asyncio.Queue() first = await loop._process_message( InboundMessage( channel="feishu", sender_id="u1", chat_id="c-auto", content="start the goal", ), pending_queue=pending, ) assert first is None queued = pending.get_nowait() assert queued.sender_id == "system:continuation" assert queued.metadata[INTERNAL_CONTINUATION_META] is True assert "Finish the long goal." in queued.content session = loop.sessions.get_or_create("feishu:c-auto") assert "Finish the long goal." in str(session.messages[0]["content"]) assert [ {k: v for k, v in m.items() if k in {"role", "content"}} for m in map(public_history_message, session.messages) ] == [{"role": "user", "content": "start the goal"}] second = await loop._process_message(queued, pending_queue=asyncio.Queue()) assert second is not None assert second.content == "done" session = loop.sessions.get_or_create("feishu:c-auto") assert [ {k: v for k, v in m.items() if k in {"role", "content"}} for m in map(public_history_message, session.messages) ] == [ {"role": "user", "content": "start the goal"}, {"role": "assistant", "content": "done"}, ] @pytest.mark.asyncio async def test_internal_continuation_preserves_streaming_route_metadata( tmp_path: Path, ) -> None: loop = _make_full_loop(tmp_path) loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] session = loop.sessions.get_or_create("feishu:c-stream") session.metadata[GOAL_STATE_KEY] = { "status": "active", "objective": "Finish the streamed long goal.", } loop.sessions.save(session) calls = 0 async def fake_run_agent_loop(initial_messages, *, on_stream=None, on_stream_end=None, **_kwargs): nonlocal calls calls += 1 if calls == 1: return ( "paused", [], [*initial_messages, {"role": "assistant", "content": "paused"}], "max_iterations", False, ) assert on_stream is not None assert on_stream_end is not None await on_stream("done") await on_stream_end(resuming=False) return ( "done", [], [*initial_messages, {"role": "assistant", "content": "done"}], "completed", False, ) loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign] await loop._dispatch(InboundMessage( channel="feishu", sender_id="u1", chat_id="c-stream", content="start the goal", metadata={ "_wants_stream": True, "message_id": "om_001", "origin_message_id": "root_001", }, )) assert loop.bus.outbound_size == 0 queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5) assert queued.metadata[INTERNAL_CONTINUATION_META] is True assert queued.metadata["_wants_stream"] is True assert queued.metadata["message_id"] == "om_001" assert queued.metadata["origin_message_id"] == "root_001" await loop._dispatch(queued) outbound = [] while loop.bus.outbound_size: outbound.append(await loop.bus.consume_outbound()) deltas = [m for m in outbound if isinstance(m.event, StreamDeltaEvent)] ends = [m for m in outbound if isinstance(m.event, StreamEndEvent)] streamed_markers = [m for m in outbound if isinstance(m.event, StreamedResponseEvent)] assert [m.content for m in deltas] == ["done"] assert len(ends) == 1 assert isinstance(ends[0].event, StreamEndEvent) assert ends[0].event.resuming is False assert ends[0].metadata["message_id"] == "om_001" assert ends[0].metadata["origin_message_id"] == "root_001" assert isinstance(ends[0].event.stream_id, str) assert streamed_markers and streamed_markers[-1].content == "done" @pytest.mark.asyncio async def test_websocket_internal_continuation_keeps_single_visible_run( tmp_path: Path, ) -> None: loop = _make_full_loop(tmp_path) loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] session = loop.sessions.get_or_create("websocket:c-auto") session.metadata[GOAL_STATE_KEY] = { "status": "active", "objective": "Finish the long goal.", } loop.sessions.save(session) calls = 0 async def fake_run_agent_loop(initial_messages, **_kwargs): nonlocal calls calls += 1 if calls == 1: return ( "paused", [], [*initial_messages, {"role": "assistant", "content": "paused"}], "max_iterations", False, ) return ( "done", [], [*initial_messages, {"role": "assistant", "content": "done"}], "completed", False, ) loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign] await loop._dispatch(InboundMessage( channel="websocket", sender_id="u1", chat_id="c-auto", content="start the goal", metadata={"webui": True}, )) first_outbound = [] while loop.bus.outbound_size: first_outbound.append(await loop.bus.consume_outbound()) first_statuses = [m.event for m in first_outbound if isinstance(m.event, GoalStatusEvent)] assert [m.status for m in first_statuses] == ["running"] assert not [m for m in first_outbound if isinstance(m.event, TurnEndEvent)] started_at = first_statuses[0].started_at queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5) assert queued.metadata[INTERNAL_CONTINUATION_META] is True assert queued.metadata[INTERNAL_CONTINUATION_RUN_STARTED_AT_META] == started_at await loop._dispatch(queued) second_outbound = [] while loop.bus.outbound_size: second_outbound.append(await loop.bus.consume_outbound()) second_statuses = [m.event for m in second_outbound if isinstance(m.event, GoalStatusEvent)] assert [m.status for m in second_statuses] == ["running", "idle"] assert second_statuses[0].started_at == started_at turn_end = [m for m in second_outbound if isinstance(m.event, TurnEndEvent)] assert len(turn_end) == 1 assert isinstance(turn_end[0].event, TurnEndEvent) assert isinstance(turn_end[0].event.latency_ms, int) @pytest.mark.asyncio async def test_process_message_uses_context_chat_id_for_runtime_prompt(tmp_path: Path) -> None: loop = _make_full_loop(tmp_path) loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] loop.context.build_messages = MagicMock( # type: ignore[method-assign] return_value=[ {"role": "system", "content": "system"}, {"role": "user", "content": "runtime + hello"}, ] ) loop._run_agent_loop = AsyncMock(return_value=( # type: ignore[method-assign] "done", [], [ {"role": "system", "content": "system"}, {"role": "user", "content": "runtime + hello"}, {"role": "assistant", "content": "done"}, ], "stop", False, )) result = await loop._process_message( InboundMessage( channel="discord", sender_id="u1", chat_id="thread-777", content="hello", metadata={"context_chat_id": "parent-456"}, session_key_override="discord:parent-456:thread:thread-777", ) ) assert result is not None assert result.chat_id == "thread-777" assert loop.context.build_messages.call_args.kwargs["chat_id"] == "parent-456" assert loop._run_agent_loop.call_args.kwargs["chat_id"] == "thread-777" @pytest.mark.asyncio async def test_process_message_uses_explicit_session_metadata_for_goal_context( tmp_path: Path, ) -> None: loop = _make_full_loop(tmp_path) loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] chat_session = loop.sessions.get_or_create("websocket:chat-with-goal") chat_session.metadata[GOAL_STATE_KEY] = { "status": "active", "objective": "This chat goal must not leak into system.", } loop.sessions.save(chat_session) system_session = loop.sessions.get_or_create("system") system_session.metadata = {} loop.sessions.save(system_session) loop.context.build_messages = MagicMock( # type: ignore[method-assign] return_value=[ {"role": "system", "content": "system"}, {"role": "user", "content": "runtime + system"}, ] ) loop._run_agent_loop = AsyncMock(return_value=( # type: ignore[method-assign] "ok", [], [ {"role": "system", "content": "system"}, {"role": "user", "content": "runtime + system"}, {"role": "assistant", "content": "ok"}, ], "stop", False, )) result = await loop._process_message( InboundMessage( channel="websocket", sender_id="system", chat_id="chat-with-goal", content="system work", ), session_key="system", ) assert result is not None assert result.content == "ok" kwargs = loop.context.build_messages.call_args.kwargs assert kwargs["chat_id"] == "chat-with-goal" assert kwargs["session_metadata"] is system_session.metadata assert GOAL_STATE_KEY not in kwargs["session_metadata"] @pytest.mark.asyncio async def test_run_agent_loop_goal_continue_message_reads_latest_metadata( tmp_path: Path, ) -> None: from nanobot.agent.runner import AgentRunResult loop = _make_full_loop(tmp_path) session = loop.sessions.get_or_create("websocket:late-goal") seen: dict[str, str | None] = {} async def fake_run(spec): assert callable(spec.goal_continue_message) session.metadata[GOAL_STATE_KEY] = { "status": "active", "objective": "Goal created during this runner call.", } seen["goal_continue"] = spec.goal_continue_message() return AgentRunResult( final_content="ok", messages=[{"role": "assistant", "content": "ok"}], ) loop.runner.run = fake_run # type: ignore[method-assign] await loop._run_agent_loop( [], runtime=loop.llm_runtime(), session=session, channel="websocket", chat_id="late-goal", session_key=session.key, ) assert "Goal created during this runner call." in (seen["goal_continue"] or "") @pytest.mark.asyncio async def test_process_direct_skip_user_persist_does_not_save_retry_user( tmp_path: Path, ) -> None: loop = _make_full_loop(tmp_path) loop._connect_mcp = AsyncMock() session = loop.sessions.get_or_create("api:default") session.add_message("user", "hello") session.add_message("assistant", "previous empty-response attempt") loop.sessions.save(session) await loop.process_direct( "hello", session_key=session.key, channel="api", chat_id="default", persist_user_message=False, ) session = loop.sessions.get_or_create("api:default") assert [(m["role"], m["content"]) for m in session.messages] == [ ("user", "hello"), ("assistant", "previous empty-response attempt"), ("assistant", "Test title"), ] @pytest.mark.asyncio async def test_request_context_uses_effective_key_for_spawn_tool(tmp_path: Path) -> None: loop = _make_full_loop(tmp_path) spawn_tool = loop.tools.get("spawn") assert spawn_tool is not None spawn_tool._manager.spawn = AsyncMock(return_value="started") # type: ignore[attr-defined] runtime = loop.llm_runtime() with request_context(RequestContext( channel="discord", chat_id="thread-777", session_key="discord:parent-456:thread:thread-777", runtime=runtime, )): await spawn_tool.execute(task="inspect context") call = spawn_tool._manager.spawn.await_args.kwargs # type: ignore[attr-defined] assert call["origin_channel"] == "discord" assert call["origin_chat_id"] == "thread-777" assert call["session_key"] == "discord:parent-456:thread:thread-777" assert call["runtime"] is runtime @pytest.mark.asyncio async def test_next_turn_after_crash_closes_pending_user_turn_before_new_input(tmp_path: Path) -> None: loop = _make_full_loop(tmp_path) loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] loop.provider.chat_with_retry = AsyncMock(return_value=MagicMock()) # unused because _run_agent_loop is stubbed session = loop.sessions.get_or_create("feishu:c3") session.add_message("user", "old question") session.metadata[AgentLoop._PENDING_USER_TURN_KEY] = True loop.sessions.save(session) loop._run_agent_loop = AsyncMock(return_value=( "new answer", None, [ {"role": "system", "content": "system"}, {"role": "user", "content": "old question"}, {"role": "assistant", "content": "Error: Task interrupted before a response was generated."}, {"role": "user", "content": "new question"}, {"role": "assistant", "content": "new answer"}, ], "stop", False, )) # type: ignore[method-assign] result = await loop._process_message( InboundMessage(channel="feishu", sender_id="u1", chat_id="c3", content="new question") ) assert result is not None assert result.content == "new answer" session = loop.sessions.get_or_create("feishu:c3") assert [ {k: v for k, v in m.items() if k in {"role", "content"}} for m in session.messages ] == [ {"role": "user", "content": "old question"}, {"role": "assistant", "content": "Error: Task interrupted before a response was generated."}, {"role": "user", "content": "new question"}, {"role": "assistant", "content": "new answer"}, ] assert AgentLoop._PENDING_USER_TURN_KEY not in session.metadata @pytest.mark.asyncio async def test_stop_preserves_runtime_checkpoint_for_next_turn(tmp_path: Path) -> None: from nanobot.command.builtin import cmd_stop from nanobot.command.router import CommandContext loop = _make_full_loop(tmp_path) loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] checkpoint_saved = asyncio.Event() async def interrupted_run_agent_loop(_initial_messages, *, session=None, **_kwargs): assert session is not None loop._set_runtime_checkpoint( session, { "assistant_message": { "role": "assistant", "content": "working", "tool_calls": [ { "id": "call_done", "type": "function", "function": {"name": "read_file", "arguments": "{}"}, }, { "id": "call_pending", "type": "function", "function": {"name": "exec", "arguments": "{}"}, }, ], }, "completed_tool_results": [ { "role": "tool", "tool_call_id": "call_done", "name": "read_file", "content": "ok", } ], "pending_tool_calls": [ { "id": "call_pending", "type": "function", "function": {"name": "exec", "arguments": "{}"}, } ], }, ) checkpoint_saved.set() await asyncio.Event().wait() loop._run_agent_loop = interrupted_run_agent_loop # type: ignore[method-assign] first_msg = InboundMessage(channel="feishu", sender_id="u1", chat_id="c4", content="keep progress") task = asyncio.create_task(loop._process_message(first_msg)) loop._active_tasks[first_msg.session_key] = [task] await asyncio.wait_for(checkpoint_saved.wait(), timeout=1.0) stop_msg = InboundMessage(channel="feishu", sender_id="u1", chat_id="c4", content="/stop") stop_ctx = CommandContext(msg=stop_msg, session=None, key=stop_msg.session_key, raw="/stop", loop=loop) stop_result = await cmd_stop(stop_ctx) assert "Stopped 1 task" in stop_result.content assert task.done() loop.sessions.invalidate("feishu:c4") interrupted = loop.sessions.get_or_create("feishu:c4") assert interrupted.metadata.get(AgentLoop._PENDING_USER_TURN_KEY) is True assert interrupted.metadata.get(AgentLoop._RUNTIME_CHECKPOINT_KEY) is not None async def resumed_run_agent_loop(initial_messages, **_kwargs): return ( "next answer", None, [*initial_messages, {"role": "assistant", "content": "next answer"}], "stop", False, ) loop._run_agent_loop = resumed_run_agent_loop # type: ignore[method-assign] result = await loop._process_message( InboundMessage(channel="feishu", sender_id="u1", chat_id="c4", content="continue here") ) assert result is not None assert result.content == "next answer" session = loop.sessions.get_or_create("feishu:c4") assert [ {k: v for k, v in m.items() if k in {"role", "content", "tool_call_id", "name"}} for m in session.messages ] == [ {"role": "user", "content": "keep progress"}, {"role": "assistant", "content": "working"}, {"role": "tool", "tool_call_id": "call_done", "name": "read_file", "content": "ok"}, { "role": "tool", "tool_call_id": "call_pending", "name": "exec", "content": "Error: Task interrupted before this tool finished.", }, {"role": "user", "content": "continue here"}, {"role": "assistant", "content": "next answer"}, ] assert AgentLoop._PENDING_USER_TURN_KEY not in session.metadata assert AgentLoop._RUNTIME_CHECKPOINT_KEY not in session.metadata @pytest.mark.asyncio async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_path: Path) -> None: loop = _make_full_loop(tmp_path) loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] session = loop.sessions.get_or_create("cli:test") session.add_message("user", "question") session.add_message("assistant", "working") loop.sessions.save(session) runtime = loop.llm_runtime() seen: dict[str, object] = {} record_runtime = MagicMock(wraps=loop._runtime_events().record_turn_runtime) loop.runtime_event_publisher.record_turn_runtime = record_runtime async def fake_run_agent_loop(initial_messages, **kwargs): seen["initial_messages"] = initial_messages seen["runtime"] = kwargs["runtime"] return ( "done", [], [*initial_messages, {"role": "assistant", "content": "done"}], "stop", False, ) loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign] await loop._process_message( InboundMessage( channel="system", sender_id="subagent", chat_id="cli:test", content="subagent result", metadata={"subagent_task_id": "sub-1"}, ), runtime=runtime, ) assert seen["runtime"] is runtime record_runtime.assert_called_once_with("cli:test", runtime) assert len(loop.consolidator.maybe_consolidate_by_tokens.call_args_list) == 2 assert all( call.kwargs["runtime"] is runtime for call in loop.consolidator.maybe_consolidate_by_tokens.call_args_list ) initial_messages = seen["initial_messages"] assert isinstance(initial_messages, list) non_system = [m for m in initial_messages if m.get("role") != "system"] assert "question" in non_system[0]["content"] assert "working" in non_system[1]["content"] # Persisted timestamps stay in session records, but replay content is not # rewritten with volatile ``[Message Time: ...]`` prefixes. assert "[Message Time:" not in non_system[0]["content"] assert "[Message Time:" not in non_system[1]["content"] assert non_system[2]["content"].count("subagent result") == 1 assert non_system[2]["content"] == "subagent result" loop.sessions.invalidate("cli:test") persisted = loop.sessions.get_or_create("cli:test") assert [ {k: v for k, v in m.items() if k in {"role", "content", "injected_event", "subagent_task_id"}} for m in persisted.messages ] == [ {"role": "user", "content": "question"}, {"role": "assistant", "content": "working"}, { "role": "assistant", "content": "subagent result", "injected_event": "subagent_result", "subagent_task_id": "sub-1", }, {"role": "assistant", "content": "done"}, ] @pytest.mark.asyncio async def test_multiple_subagent_followups_all_persist_as_standalone_history(tmp_path: Path) -> None: loop = _make_full_loop(tmp_path) loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] async def fake_run_agent_loop(initial_messages, **_kwargs): return ( "ack", [], [*initial_messages, {"role": "assistant", "content": "ack"}], "stop", False, ) loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign] for idx in range(3): await loop._process_message( InboundMessage( channel="system", sender_id="subagent", chat_id="cli:multi", content=f"subagent result {idx}", metadata={"subagent_task_id": f"sub-{idx}"}, ) ) loop.sessions.invalidate("cli:multi") persisted = loop.sessions.get_or_create("cli:multi") followups = [m for m in persisted.messages if m.get("injected_event") == "subagent_result"] assert [m["content"] for m in followups] == [ "subagent result 0", "subagent result 1", "subagent result 2", ] def test_prompt_merge_does_not_replace_standalone_subagent_history_entry(tmp_path: Path) -> None: loop = _mk_loop() session = Session(key="cli:merge") session.add_message("assistant", "previous assistant") inserted = loop._persist_subagent_followup( session, InboundMessage( channel="system", sender_id="subagent", chat_id="cli:merge", content="subagent result", metadata={"subagent_task_id": "sub-1"}, ), ) assert inserted is True builder = ContextBuilder(tmp_path) projected = builder.build_messages( history=session.get_history(max_messages=0), current_message="", current_role="assistant", channel="cli", chat_id="merge", ) non_system = [m for m in projected if m.get("role") != "system"] assert len(non_system) == 2 assert "subagent result" in non_system[-1]["content"] assert session.messages[-1]["content"] == "subagent result" assert session.messages[-1]["injected_event"] == "subagent_result" def test_subagent_followup_dedupes_by_task_id() -> None: loop = _mk_loop() session = Session(key="cli:dedupe") msg = InboundMessage( channel="system", sender_id="subagent", chat_id="cli:dedupe", content="subagent result", metadata={"subagent_task_id": "sub-1"}, ) assert loop._persist_subagent_followup(session, msg) is True assert loop._persist_subagent_followup(session, msg) is False assert len(session.messages) == 1 def test_subagent_followup_skips_empty_content() -> None: loop = _mk_loop() session = Session(key="cli:empty") msg = InboundMessage( channel="system", sender_id="subagent", chat_id="cli:empty", content="", metadata={"subagent_task_id": "sub-empty"}, ) assert loop._persist_subagent_followup(session, msg) is False assert session.messages == [] @pytest.mark.asyncio async def test_request_context_passes_thread_session_key_to_spawn(tmp_path: Path) -> None: loop = _make_full_loop(tmp_path) spawn_tool = loop.tools.get("spawn") assert spawn_tool is not None spawn_tool._manager.spawn = AsyncMock(return_value="started") # type: ignore[attr-defined] runtime = loop.llm_runtime() with request_context(RequestContext( channel="slack", chat_id="C123", message_id="msg-123", metadata={"slack": {"thread_ts": "1700.42", "channel_type": "channel"}}, session_key="slack:C123:1700.42", runtime=runtime, )): await spawn_tool.execute(task="inspect thread") call = spawn_tool._manager.spawn.await_args.kwargs # type: ignore[attr-defined] assert call["session_key"] == "slack:C123:1700.42" assert call["origin_message_id"] == "msg-123" assert call["runtime"] is runtime @pytest.mark.asyncio async def test_system_subagent_followup_uses_thread_session_and_slack_metadata(tmp_path: Path) -> None: loop = _make_full_loop(tmp_path) loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] thread_session = loop.sessions.get_or_create("slack:C123:1700.42") thread_session.add_message("user", "thread question") loop.sessions.save(thread_session) seen: dict[str, list[dict]] = {} async def fake_run_agent_loop(initial_messages, **_kwargs): seen["initial_messages"] = initial_messages return ( "done", [], [*initial_messages, {"role": "assistant", "content": "done"}], "stop", False, ) loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign] outbound = await loop._process_message( InboundMessage( channel="system", sender_id="subagent", chat_id="slack:C123", content="subagent result", session_key_override="slack:C123:1700.42", metadata={"subagent_task_id": "sub-1", "origin_message_id": "msg-123"}, ) ) assert outbound is not None assert outbound.channel == "slack" assert outbound.chat_id == "C123" assert outbound.metadata == { "slack": {"thread_ts": "1700.42"}, "origin_message_id": "msg-123", } assert "thread question" in seen["initial_messages"][1]["content"] loop.sessions.invalidate("slack:C123:1700.42") persisted = loop.sessions.get_or_create("slack:C123:1700.42") assert any(m.get("subagent_task_id") == "sub-1" for m in persisted.messages) @pytest.mark.asyncio async def test_turn_after_unanswered_user_keeps_tool_call_pairing(tmp_path: Path) -> None: loop = _make_full_loop(tmp_path) loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] session = loop.sessions.get_or_create("feishu:c-merge") session.add_message("user", "earlier question that never got an answer") loop.sessions.save(session) async def fake_run_agent_loop(initial_messages, **_kwargs): assert [m["role"] for m in initial_messages] == ["system", "user"] return ( "done", [], [ *initial_messages, { "role": "assistant", "content": "", "tool_calls": [{ "id": "call_ls", "type": "function", "function": {"name": "exec", "arguments": '{"command": "ls"}'}, }], }, {"role": "tool", "tool_call_id": "call_ls", "name": "exec", "content": "file.txt"}, {"role": "assistant", "content": "done"}, ], "stop", False, ) loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign] result = await loop._process_message( InboundMessage( channel="feishu", sender_id="u1", chat_id="c-merge", content="and another thing" ) ) assert result is not None loop.sessions.invalidate("feishu:c-merge") persisted = loop.sessions.get_or_create("feishu:c-merge") declared: set[str] = set() for message in persisted.messages: if message.get("role") == "assistant": declared.update( str(tc["id"]) for tc in message.get("tool_calls") or [] if tc.get("id") ) if message.get("role") == "tool": assert str(message.get("tool_call_id")) in declared, ( f"orphaned tool result {message.get('tool_call_id')!r}: " f"{[m.get('role') for m in persisted.messages]}" ) assert [m["role"] for m in persisted.messages] == [ "user", "user", "assistant", "tool", "assistant", ] def test_save_turn_keeps_placeholder_for_empty_tool_result_blocks() -> None: loop = _mk_loop() session = Session(key="test:empty-tool-blocks") loop._save_turn( session, [ { "role": "assistant", "content": "", "tool_calls": [{ "id": "call_empty", "type": "function", "function": {"name": "exec", "arguments": "{}"}, }], }, {"role": "tool", "tool_call_id": "call_empty", "name": "exec", "content": []}, ], skip=0, ) assert [m["role"] for m in session.messages] == ["assistant", "tool"] assert session.messages[1]["content"] == [ {"type": "text", "text": "[tool result omitted during persistence]"} ] def test_save_turn_drops_orphaned_tool_results() -> None: loop = _mk_loop() session = Session(key="test:orphan-guard") session.add_message("user", "hi") loop._save_turn( session, [ {"role": "tool", "tool_call_id": "call_ghost", "name": "exec", "content": "boo"}, {"role": "assistant", "content": "done"}, ], skip=0, ) assert [m["role"] for m in session.messages] == ["user", "assistant"] def test_save_turn_drops_tool_results_without_tool_call_id() -> None: loop = _mk_loop() session = Session(key="test:missing-tool-call-id") session.add_message("user", "hi") loop._save_turn( session, [ {"role": "tool", "name": "exec", "content": "missing id"}, {"role": "assistant", "content": "done"}, ], skip=0, ) assert [m["role"] for m in session.messages] == ["user", "assistant"] def test_save_turn_keeps_tool_results_declared_in_prior_history() -> None: loop = _mk_loop() session = Session(key="test:prior-declared") session.add_message( "assistant", "working", tool_calls=[{ "id": "call_prior", "type": "function", "function": {"name": "exec", "arguments": "{}"}, }], ) loop._save_turn( session, [{"role": "tool", "tool_call_id": "call_prior", "name": "exec", "content": "ok"}], skip=0, ) assert [m["role"] for m in session.messages] == ["assistant", "tool"]