Files
wehub-resource-sync 25576b0be6
Checks (magui2.0) / python-lint (push) Failing after 1s
Checks (magui2.0) / python-test (push) Failing after 0s
Checks (magui2.0) / frontend-lint (push) Failing after 0s
CodeQL Advanced / Analyze (actions) (push) Failing after 1s
Checks (magui2.0) / python-format (push) Failing after 1s
CodeQL Advanced / Analyze (python) (push) Failing after 0s
Checks (magui2.0) / python-pyright (push) Failing after 1s
Checks (magui2.0) / frontend-format (push) Failing after 1s
Checks (magui2.0) / frontend-typecheck (push) Failing after 0s
Checks (magui2.0) / frontend-test (push) Failing after 2s
CodeQL Advanced / Analyze (javascript-typescript) (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:24:32 +08:00

1142 lines
46 KiB
Python

"""Unit tests for OmniResponses context compaction.
Tests exercise OmniResponses in isolation with a mocked AsyncOpenAI
client — no sandbox, no tools, no WebSocket. The goal is to verify the
compaction algorithm and emergency handler behave correctly across
threshold edge cases, hoisting, history reconstruction, prev-handoff
chaining, and error propagation.
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
import openai
import pytest
from magentic_ui.agents.message_schemas import MessageType
from magentic_ui.teams.omniagent._compaction import (
HANDOFF_PREFIX,
build_handoff_message,
extract_opened_files,
extract_summary,
)
from magentic_ui.teams.omniagent._responses import OmniResponses
# ---------------------------------------------------------------------------
# Helpers — plain module-level functions, no pytest fixtures
# ---------------------------------------------------------------------------
def _make_completion(text: str, total_tokens: int = 10) -> MagicMock:
"""Build a minimal MagicMock that quacks like a ChatCompletion."""
response = MagicMock()
response.choices = [MagicMock()]
response.choices[0].message.content = text
response.usage = MagicMock(
prompt_tokens=total_tokens - 2,
completion_tokens=2,
total_tokens=total_tokens,
)
return response
def _bad_request(code: str | int, message: str = "") -> openai.BadRequestError:
"""Build a BadRequestError with a specific code."""
response = MagicMock()
response.status_code = 400
response.request = MagicMock()
err = openai.BadRequestError(
message=message or code,
response=response,
body={"code": code, "message": message or code},
)
err.code = code # ensure the attribute is set (openai SDK exposes this)
return err
def _mock_llm_client(responses: list[Any]) -> MagicMock:
"""Build a MagicMock AsyncOpenAI client with canned responses.
Successive calls to ``client.chat.completions.stream()`` consume
``responses`` in order. Each element is either a string (returned
as a completion with ``total_tokens=10``), a ``(text, total_tokens)``
tuple, or an exception instance (raised from the stream context
manager when that call happens).
"""
from ._stream_mock import install_stream_mock
client = MagicMock()
items: list[Any] = []
for item in responses:
if isinstance(item, Exception):
items.append(item)
elif isinstance(item, tuple):
text, tokens = item
items.append(_make_completion(text, tokens))
else:
items.append(_make_completion(item))
install_stream_mock(client, items)
return client
def _build_chat(
client: MagicMock,
*,
threshold: int | None = 100_000,
system: str = "You are a helpful agent.",
transcripts_dir: Path | None = None,
observability_dir: Path | None = None,
guest_transcripts_dir: Path | None = None,
) -> OmniResponses:
"""Wrap a mock client in an OmniResponses with sensible test defaults."""
if transcripts_dir is not None and observability_dir is None:
observability_dir = transcripts_dir
return OmniResponses(
client=client,
model="gpt-test",
system_prompt=system,
compaction_threshold=threshold,
source_name="OmniAgent",
transcripts_dir=transcripts_dir,
observability_dir=observability_dir,
guest_transcripts_dir=guest_transcripts_dir,
)
def _read_trace(path: Path) -> list[dict[str, Any]]:
"""Parse a trace.jsonl file into a list of event dicts."""
return [
json.loads(line)
for line in path.read_text(encoding="utf-8").splitlines()
if line
]
# ---------------------------------------------------------------------------
# build_handoff_message — helper in _compaction.py
# ---------------------------------------------------------------------------
class TestBuildHandoffMessage:
def test_first_compaction_has_no_prev(self):
out = build_handoff_message(prev_handoff=None, summary="task progress so far")
assert out.startswith(HANDOFF_PREFIX)
assert "task progress so far" in out
assert "Previous handoff" not in out
def test_chains_previous_handoff(self):
out = build_handoff_message(
prev_handoff="earlier summary",
summary="latest summary",
)
assert HANDOFF_PREFIX in out
assert "Previous handoff" in out
assert "earlier summary" in out
assert "latest summary" in out
# Order matters: prev comes before fresh summary
assert out.index("earlier summary") < out.index("latest summary")
def test_includes_transcript_path_when_provided(self):
out = build_handoff_message(
prev_handoff=None,
summary="latest summary",
transcript_path=Path("/workspace/.agent/transcripts/transcript.md"),
)
assert "/workspace/.agent/transcripts/transcript.md" in out
assert "grep" in out # pointer mentions grep as a lookup option
# Pointer prose should describe the file as the agent's own log,
# not as a generic "transcript" — that overload caused models to
# confuse it with task-domain transcripts in real evals.
assert "agent's own log" in out
assert "action log" in out
def test_omits_transcript_pointer_when_path_is_none(self):
out = build_handoff_message(
prev_handoff=None,
summary="latest summary",
transcript_path=None,
)
assert "action log" not in out.lower()
assert "grep" not in out.lower()
def test_includes_files_reviewed_block_when_provided(self):
out = build_handoff_message(
prev_handoff=None,
summary="latest summary",
files_reviewed=["foo.docx", "bar.txt"],
)
assert "Files already reviewed:" in out
assert "- foo.docx" in out
assert "- bar.txt" in out
def test_omits_files_reviewed_block_when_empty(self):
out = build_handoff_message(
prev_handoff=None,
summary="latest summary",
files_reviewed=[],
)
assert "Files already reviewed:" not in out
def test_omits_files_reviewed_block_when_none(self):
out = build_handoff_message(
prev_handoff=None,
summary="latest summary",
)
assert "Files already reviewed:" not in out
# ---------------------------------------------------------------------------
# extract_opened_files — file-path extraction from `open` tool calls
# ---------------------------------------------------------------------------
class TestExtractOpenedFiles:
def test_extracts_paths_from_open_calls(self):
messages = [
{"role": "system", "content": "..."},
{"role": "user", "content": "do work"},
{
"role": "assistant",
"content": (
'thinking...\n<tool_call>{"name":"open",'
'"arguments":{"path":"foo.docx"}}</tool_call>'
),
},
{"role": "user", "content": "<tool_response>...</tool_response>"},
{
"role": "assistant",
"content": (
'<tool_call>{"name":"open",'
'"arguments":{"path":"bar.txt"}}</tool_call>'
),
},
]
assert extract_opened_files(messages) == ["bar.txt", "foo.docx"]
def test_dedupes_repeated_opens(self):
messages = [
{
"role": "assistant",
"content": (
'<tool_call>{"name":"open",'
'"arguments":{"path":"foo.docx"}}</tool_call>'
),
},
{
"role": "assistant",
"content": (
'<tool_call>{"name":"open",'
'"arguments":{"path":"foo.docx"}}</tool_call>'
),
},
]
assert extract_opened_files(messages) == ["foo.docx"]
def test_skips_non_open_tool_calls(self):
messages = [
{
"role": "assistant",
"content": (
'<tool_call>{"name":"bash","arguments":{"command":"ls"}}</tool_call>'
),
},
]
assert extract_opened_files(messages) == []
def test_skips_user_and_system_messages(self):
messages = [
{
"role": "user",
"content": (
'<tool_call>{"name":"open",'
'"arguments":{"path":"foo.docx"}}</tool_call>'
),
},
{"role": "system", "content": "system text"},
]
assert extract_opened_files(messages) == []
def test_handles_assistant_without_tool_calls(self):
messages = [{"role": "assistant", "content": "just thinking, no tools"}]
assert extract_opened_files(messages) == []
def test_skips_internal_agent_paths(self):
messages = [
{
"role": "assistant",
"content": (
'<tool_call>{"name":"open","arguments":'
'{"path":"foo.docx"}}</tool_call>\n'
'<tool_call>{"name":"open","arguments":'
'{"path":"/sessions/X/workspace/.agent/tool_outputs/output_abc"}}'
"</tool_call>\n"
'<tool_call>{"name":"open","arguments":'
'{"path":".agent/transcripts/transcript.md"}}</tool_call>\n'
'<tool_call>{"name":"open","arguments":'
'{"path":"bar.txt"}}</tool_call>'
),
},
]
# Spill files and transcript log filtered out; task data files kept.
assert extract_opened_files(messages) == ["bar.txt", "foo.docx"]
# ---------------------------------------------------------------------------
# extract_summary — tolerant tag extraction for compaction summaries
# ---------------------------------------------------------------------------
class TestExtractSummary:
def test_both_tags(self):
assert extract_summary("<summary>foo</summary>") == "foo"
def test_open_tag_only(self):
assert extract_summary("<summary>foo bar") == "foo bar"
def test_close_tag_only(self):
assert extract_summary("foo bar</summary>") == "foo bar"
def test_no_tags_falls_back_to_raw(self):
assert extract_summary("just prose") == "just prose"
def test_whitespace_stripped(self):
assert extract_summary("<summary>\n foo \n</summary>") == "foo"
def test_extra_content_after_close_dropped(self):
out = extract_summary("<summary>real content</summary>trailing junk")
assert out == "real content"
def test_close_before_open_tag_does_not_misfire(self):
# If a stray ``</summary>`` appears before the real wrapper open,
# we should still anchor on the open tag and find the close after it.
out = extract_summary("noise</summary><summary>real</summary>")
assert out == "real"
def test_stray_close_before_open_with_no_close_after(self):
# ``str.index`` would raise ``ValueError`` here because the only
# ``</summary>`` is before the ``<summary>``. ``str.find``-based
# implementation should fall through to the open-only branch
# and return everything after ``<summary>``.
out = extract_summary("noise</summary><summary>real")
assert out == "real"
# ---------------------------------------------------------------------------
# maybe_compact — threshold edge cases
# ---------------------------------------------------------------------------
class TestMaybeCompact:
@pytest.mark.asyncio
async def test_skips_when_threshold_none(self):
client = _mock_llm_client(["resp"])
chat = _build_chat(client, threshold=None)
await chat.generate("hi")
events = [evt async for evt in chat.maybe_compact()]
assert events == []
@pytest.mark.asyncio
async def test_skips_below_threshold(self):
client = _mock_llm_client([("resp", 50)])
chat = _build_chat(client, threshold=100)
await chat.generate("hi")
events = [evt async for evt in chat.maybe_compact()]
assert events == []
# History unchanged: system + user + assistant
assert len(chat.messages) == 3
@pytest.mark.asyncio
async def test_triggers_at_threshold(self):
# First call: exceeds threshold. Second call: the compaction summary.
client = _mock_llm_client([("long response", 150), ("SUMMARY TEXT", 20)])
chat = _build_chat(client, threshold=100)
await chat.generate("task")
events = [evt async for evt in chat.maybe_compact()]
assert len(events) == 2
start_props = events[0].additional_properties
end_props = events[1].additional_properties
assert start_props is not None and start_props["type"] == "compaction_start"
assert start_props["tokens_before"] == 150
assert end_props is not None and end_props["type"] == "compaction_end"
@pytest.mark.asyncio
async def test_event_source_name(self):
client = _mock_llm_client([("resp", 200), ("summary", 20)])
chat = _build_chat(client, threshold=100)
await chat.generate("task")
events = [evt async for evt in chat.maybe_compact()]
assert events[0].additional_properties["source"] == "OmniAgent"
assert events[1].additional_properties["source"] == "OmniAgent"
# ---------------------------------------------------------------------------
# _compact — history reconstruction
# ---------------------------------------------------------------------------
class TestCompactHistoryShape:
@pytest.mark.asyncio
async def test_preserves_system_message(self):
client = _mock_llm_client([("resp", 200), ("SUMMARY", 20)])
chat = _build_chat(client, threshold=100, system="You are ROLE X.")
await chat.generate("task")
_ = [evt async for evt in chat.maybe_compact()]
assert chat.messages[0]["role"] == "system"
assert chat.messages[0]["content"] == "You are ROLE X."
@pytest.mark.asyncio
async def test_preserves_real_user_messages(self):
client = _mock_llm_client([("resp", 200), ("SUMMARY", 20)])
chat = _build_chat(client, threshold=100)
await chat.generate("original task")
_ = [evt async for evt in chat.maybe_compact()]
msgs = chat.messages
user_contents = [m["content"] for m in msgs if m["role"] == "user"]
assert "original task" in user_contents
@pytest.mark.asyncio
async def test_drops_tool_response_messages(self):
client = _mock_llm_client(
[("resp1", 20), ("resp2", 40), ("resp3", 200), ("SUMMARY", 20)]
)
chat = _build_chat(client, threshold=100)
await chat.generate("task")
# Simulate a tool response being queued + sent
await chat.generate("<tool_response>tool result 1</tool_response>")
await chat.generate("<tool_response>tool result 2</tool_response>")
# Now we're over threshold, trigger compaction
_ = [evt async for evt in chat.maybe_compact()]
# Tool responses should NOT appear verbatim — only summarized
for m in chat.messages:
content = m.get("content", "")
if isinstance(content, str):
assert not content.startswith(
"<tool_response>"
), f"tool_response leaked verbatim into compacted history: {content}"
@pytest.mark.asyncio
async def test_drops_tool_response_with_leading_whitespace(self):
"""Regression: leading whitespace must not bypass the filter.
Earlier versions used plain ``startswith`` which allowed any
leading newline or space to smuggle a ``<tool_response>``
payload into compacted history. The filter now normalizes with
``lstrip()`` before matching.
"""
client = _mock_llm_client(
[("resp1", 20), ("resp2", 40), ("resp3", 200), ("SUMMARY", 20)]
)
chat = _build_chat(client, threshold=100)
await chat.generate("task")
await chat.generate("\n<tool_response>tool result with newline</tool_response>")
await chat.generate(" <tool_response>tool result with spaces</tool_response>")
_ = [evt async for evt in chat.maybe_compact()]
for m in chat.messages:
content = m.get("content", "")
if isinstance(content, str):
assert "<tool_response>" not in content, (
f"whitespace-prefixed tool_response leaked into "
f"compacted history: {content!r}"
)
@pytest.mark.asyncio
async def test_appends_handoff_as_user_message(self):
client = _mock_llm_client([("resp", 200), ("FRESH SUMMARY", 20)])
chat = _build_chat(client, threshold=100)
await chat.generate("task")
_ = [evt async for evt in chat.maybe_compact()]
msgs = chat.messages
# The last non-hoisted user message should contain the handoff
handoff_msgs = [
m
for m in msgs
if m["role"] == "user"
and isinstance(m["content"], str)
and HANDOFF_PREFIX in m["content"]
]
assert len(handoff_msgs) == 1
assert "FRESH SUMMARY" in handoff_msgs[0]["content"]
@pytest.mark.asyncio
async def test_resets_total_tokens_to_zero(self):
client = _mock_llm_client([("resp", 200), ("SUMMARY", 20)])
chat = _build_chat(client, threshold=100)
await chat.generate("task")
assert chat.total_tokens == 200
_ = [evt async for evt in chat.maybe_compact()]
assert chat.total_tokens == 0
# ---------------------------------------------------------------------------
# _compact — prev_handoff chaining across multiple compactions
# ---------------------------------------------------------------------------
class TestPrevHandoffChaining:
@pytest.mark.asyncio
async def test_second_compaction_includes_first_summary(self):
client = _mock_llm_client(
[
("resp1", 200), # triggers first compaction
("FIRST SUMMARY", 20), # first compaction summary
("resp2", 300), # triggers second compaction
("SECOND SUMMARY", 20), # second compaction summary
]
)
chat = _build_chat(client, threshold=100)
await chat.generate("task")
_ = [evt async for evt in chat.maybe_compact()]
# At this point, prev_handoff == "FIRST SUMMARY"
assert chat._prev_handoff == "FIRST SUMMARY"
await chat.generate("follow-up")
_ = [evt async for evt in chat.maybe_compact()]
# Second compaction's handoff should chain the first
handoff_msgs = [
m
for m in chat.messages
if m["role"] == "user"
and isinstance(m["content"], str)
and HANDOFF_PREFIX in m["content"]
]
assert len(handoff_msgs) == 1 # only the latest handoff remains
latest = handoff_msgs[0]["content"]
assert "FIRST SUMMARY" in latest
assert "SECOND SUMMARY" in latest
assert "Previous handoff" in latest
# ---------------------------------------------------------------------------
# _compact — hoisting pending tool calls
# ---------------------------------------------------------------------------
class TestHoisting:
@pytest.mark.asyncio
async def test_hoists_pending_tool_call(self):
# First assistant message contains a tool call. Second call is the
# compaction summary. We push enough tokens to trigger compaction
# right after the first generate.
client = _mock_llm_client(
[
(
"planning\n"
'<tool_call>{"name": "bash", "arguments": {"cmd": "ls"}}</tool_call>',
200,
),
("SUMMARY", 20),
]
)
chat = _build_chat(client, threshold=100)
await chat.generate("task")
_ = [evt async for evt in chat.maybe_compact()]
# The trailing assistant-with-tool-call should still be present
last = chat.messages[-1]
assert last["role"] == "assistant"
assert "<tool_call>" in last["content"]
@pytest.mark.asyncio
async def test_no_hoist_on_plain_assistant_text(self):
client = _mock_llm_client(
[
("just some text", 200),
("SUMMARY", 20),
]
)
chat = _build_chat(client, threshold=100)
await chat.generate("task")
_ = [evt async for evt in chat.maybe_compact()]
# Assistant had no <tool_call>; should NOT be hoisted
# Trailing message should be the handoff
assert chat.messages[-1]["role"] == "user"
assert HANDOFF_PREFIX in chat.messages[-1]["content"]
@pytest.mark.asyncio
async def test_restores_hoisted_on_summary_failure(self):
"""Regression: if the summarize call raises, the popped tool_call
assistant message must be restored to history.
Without the try/except in ``_compact``, a transient failure
during summarization would silently drop the agent's pending
tool action, leaving history inconsistent for the next step.
"""
tool_call_msg = (
"planning\n"
'<tool_call>{"name": "bash", "arguments": {"cmd": "ls"}}</tool_call>'
)
client = _mock_llm_client(
[
(tool_call_msg, 200),
# Compaction summary call (persist=False) fails with a
# non-context-length BadRequestError so it propagates
# straight out of generate() into _compact()'s handler.
_bad_request("invalid_request", "simulated summary failure"),
]
)
chat = _build_chat(client, threshold=100)
await chat.generate("task")
history_before = list(chat.messages)
assert "<tool_call>" in history_before[-1]["content"]
with pytest.raises(openai.BadRequestError):
async for _ in chat.maybe_compact():
pass
# Hoisted message restored verbatim at the tail.
assert chat.messages[-1]["role"] == "assistant"
assert "<tool_call>" in chat.messages[-1]["content"]
assert chat.messages == history_before
# Commit path did not run: prev_handoff unset, tokens unchanged.
assert chat._prev_handoff is None
assert chat.total_tokens == 200
# ---------------------------------------------------------------------------
# Emergency handler — context_length_exceeded
# ---------------------------------------------------------------------------
class TestEmergencyHandler:
@pytest.mark.asyncio
async def test_lowers_threshold_and_retries(self):
# Build history via an initial successful call, then the second
# call raises context_length_exceeded and triggers the emergency
# handler: compaction (persist=False summary) followed by a
# retry of the original call.
client = _mock_llm_client(
[
"priming response", # initial generate succeeds
_bad_request(
"context_length_exceeded",
"This model's maximum context length is 128000 tokens.",
),
"EMERGENCY SUMMARY",
"recovery response",
]
)
chat = _build_chat(client, threshold=10_000)
await chat.generate("task")
# Now call again — the second call raises, triggering emergency
result = await chat.generate("another task")
assert result == "recovery response"
# Threshold should have been lowered to 75% of 128000
assert chat._compaction_threshold == int(128_000 * 0.75)
@pytest.mark.asyncio
async def test_falls_back_when_regex_no_match(self):
client = _mock_llm_client(
[
"priming",
_bad_request(
"context_length_exceeded",
"Some error without a parseable max",
),
"SUMMARY",
"recovery",
]
)
chat = _build_chat(client, threshold=10_000)
await chat.generate("task")
await chat.generate("trigger")
# 10_000 * 0.75 = 7_500 (unified fraction — same as the parsed-max path)
assert chat._compaction_threshold == 7_500
@pytest.mark.asyncio
async def test_retry_preserves_new_input(self):
"""The retried generate call must include the triggering input.
Regression test for a bug where the emergency handler retried
with ``self._messages`` (history only), dropping the ``extra``
from the original call. That made the model respond to the
wrong prompt.
"""
client = _mock_llm_client(
[
"priming",
_bad_request(
"context_length_exceeded",
"This model's maximum context length is 32768 tokens.",
),
"EMERGENCY SUMMARY",
"recovery response",
]
)
chat = _build_chat(client, threshold=10_000)
await chat.generate("initial task")
# Resolve the underlying mock so we can inspect its call history.
mock_stream = chat._client.chat.completions.stream
result = await chat.generate("follow-up with important detail")
assert result == "recovery response"
# Four stream() calls should have been made:
# 1. priming (succeeded)
# 2. follow-up with important detail (raised 400)
# 3. compaction summary (persist=False)
# 4. retry of follow-up with important detail (after compact)
assert mock_stream.call_count == 4
retry_call = mock_stream.call_args_list[3]
retry_messages = retry_call.kwargs["messages"]
# The retry must contain the triggering user input verbatim.
assert any(
m.get("role") == "user"
and m.get("content") == "follow-up with important detail"
for m in retry_messages
), "retry dropped the triggering user input; got messages: " f"{retry_messages}"
@pytest.mark.asyncio
async def test_detects_vllm_integer_code_via_message(self):
"""vLLM sets error.code = 400 (int); detection must match via message.
vLLM's OpenAI-compatible shim doesn't populate error.code as a
string — it uses the integer HTTP status. The emergency
handler must still fire by recognizing the "maximum context
length" phrase in the error message.
"""
vllm_error = _bad_request(
code=400, # integer, not string — mimics vLLM format
message=(
"This model's maximum context length is 32768 tokens. "
"However, your request has 50000 input tokens."
),
)
client = _mock_llm_client(
["priming", vllm_error, "EMERGENCY SUMMARY", "recovery"]
)
chat = _build_chat(client, threshold=10_000)
await chat.generate("task")
result = await chat.generate("trigger")
assert result == "recovery"
# Threshold recomputed from parsed max: int(32768 * 0.75)
assert chat._compaction_threshold == int(32_768 * 0.75)
@pytest.mark.asyncio
async def test_does_not_fire_on_persist_false(self):
# When the compaction summary call itself (which is persist=False)
# hits a BadRequestError, it must propagate instead of recursing.
client = _mock_llm_client(
[
("response", 200), # initial call, triggers threshold
_bad_request(
"context_length_exceeded",
"maximum context length is 128000 tokens",
), # persist=False summary call — this one should propagate
]
)
chat = _build_chat(client, threshold=100)
await chat.generate("task")
with pytest.raises(openai.BadRequestError):
# maybe_compact → _compact → generate(persist=False) → BadRequestError
async for _ in chat.maybe_compact():
pass
@pytest.mark.asyncio
async def test_propagates_other_bad_request_errors(self):
# A non-context-length BadRequestError should still propagate
# even when persist=True.
client = _mock_llm_client([_bad_request("invalid_request", "Bad input")])
chat = _build_chat(client, threshold=100)
with pytest.raises(openai.BadRequestError):
await chat.generate("task")
@pytest.mark.asyncio
async def test_caps_emergency_retries(self):
"""After _MAX_EMERGENCY_COMPACTIONS attempts with persistent
context_length_exceeded, generate re-raises instead of looping.
Regression: without the cap, a request whose ``new_input``
alone exceeds the model's context would trigger compaction
forever.
The mock provides extra unused slots so a broken cap would
visibly overrun the expected call count rather than hitting
StopAsyncIteration and confusing the failure mode.
"""
def ctx_err() -> openai.BadRequestError:
return _bad_request(
"context_length_exceeded",
"This model's maximum context length is 32768 tokens.",
)
# Expected sequence:
# 1. priming — generate("task") succeeds
# 2. ctx_err — follow-up attempt 1, raises
# 3. SUMMARY 1 — 1st emergency compaction (persist=False)
# 4. ctx_err — follow-up attempt 2, raises
# 5. SUMMARY 2 — 2nd emergency compaction
# 6. ctx_err — follow-up attempt 3, cap hits, re-raise
# Slots 7-10 are extras: if the cap were broken, the loop
# would consume them and call_count would exceed 6.
client = _mock_llm_client(
[
"priming",
ctx_err(),
"SUMMARY 1",
ctx_err(),
"SUMMARY 2",
ctx_err(),
# unused slots — cap must prevent these from firing
"SUMMARY 3",
ctx_err(),
"SUMMARY 4",
ctx_err(),
]
)
chat = _build_chat(client, threshold=10_000)
await chat.generate("task")
with pytest.raises(openai.BadRequestError):
await chat.generate("trigger overflow")
# Exactly 6 API calls: 1 priming + 3 failing attempts + 2
# successful compaction summaries. Anything > 6 would mean the
# cap did not engage and we attempted a 3rd compaction.
assert chat._client.chat.completions.stream.call_count == 6
# ---------------------------------------------------------------------------
# Streaming I/O: agent_state transition + usage accounting
# ---------------------------------------------------------------------------
class TestStreaming:
@pytest.mark.asyncio
async def test_stream_requests_usage(self):
"""``include_usage`` must be set or streamed completions carry no
usage block, which would silently zero out token accounting and
keep ``maybe_compact`` from ever firing."""
client = _mock_llm_client(["response"])
chat = _build_chat(client, threshold=None)
await chat.generate("task")
_, kwargs = client.chat.completions.stream.call_args
assert kwargs["stream_options"] == {"include_usage": True}
@pytest.mark.asyncio
async def test_usage_flows_into_total_tokens(self):
"""The streamed completion's usage drives ``total_tokens`` so the
compaction threshold can be evaluated against real token counts."""
client = _mock_llm_client([("response", 4242)])
chat = _build_chat(client, threshold=None)
await chat.generate("task")
assert chat.total_tokens == 4242
@pytest.mark.asyncio
async def test_generating_emitted_once_first_token_arrives(self):
"""A delta event triggers exactly one ``("state", "generating")``
before the final ``("result", text)``."""
from ._stream_mock import content_delta, StreamScript, install_stream_mock
client = MagicMock()
script = StreamScript(
events=[content_delta(), content_delta()], # two token deltas
completion=_make_completion("hello"),
)
install_stream_mock(client, [script])
chat = _build_chat(client, threshold=None)
events = [
(kind, payload) async for kind, payload in chat.generate_streaming("task")
]
# generating emitted once (not per delta), then a single result.
assert events == [("state", "generating"), ("result", "hello")]
@pytest.mark.asyncio
async def test_no_generating_state_when_stream_has_no_tokens(self):
"""With no token events the call still produces a result and never
claims the model started replying."""
client = _mock_llm_client(["response"])
chat = _build_chat(client, threshold=None)
events = [
(kind, payload) async for kind, payload in chat.generate_streaming("task")
]
assert [k for k, _ in events] == ["result"]
assert events[0][1] == "response"
@pytest.mark.asyncio
async def test_model_slow_emitted_when_first_token_is_slow(self, monkeypatch):
"""A slow first token yields ``model_slow`` before ``generating``,
so the UI can warn the user instead of waiting silently."""
from ._stream_mock import content_delta
from magentic_ui.teams.omniagent import _responses
monkeypatch.setattr(_responses, "_SLOW_MODEL_SECONDS", 0.01)
class _SlowStream:
def __init__(self) -> None:
self._events = [content_delta()]
self._first = True
def __aiter__(self) -> "_SlowStream":
return self
async def __anext__(self):
if self._first:
self._first = False
await asyncio.sleep(0.1) # outlasts the patched threshold
if not self._events:
raise StopAsyncIteration
return self._events.pop(0)
async def get_final_completion(self):
return _make_completion("hi")
chat = _build_chat(MagicMock(), threshold=None)
events = [item async for item in chat._consume_stream(_SlowStream())]
states = [payload for kind, payload in events if kind == "state"]
assert states == ["model_slow", "generating"]
assert events[-1][0] == "result"
@pytest.mark.asyncio
async def test_fatal_model_error_fails_fast_without_retry(self):
"""A connection error is fatal — the SDK already retried, so the
agent must not loop ``_MAX_RETRY_ATTEMPTS`` more times."""
from unittest.mock import MagicMock as _MM
err = openai.APIConnectionError(message="unreachable", request=_MM())
client = _mock_llm_client([err])
chat = _build_chat(client, threshold=None)
with pytest.raises(openai.APIConnectionError):
await chat.generate("task")
assert client.chat.completions.stream.call_count == 1
@pytest.mark.asyncio
async def test_transient_error_is_retried(self, monkeypatch):
"""A 5xx is transient, so the call retries and then succeeds."""
from magentic_ui.teams.omniagent import _responses
monkeypatch.setattr(_responses, "_RETRY_DELAY_SECONDS", 0.0)
response = MagicMock()
response.status_code = 503
response.request = MagicMock()
err = openai.InternalServerError(message="busy", response=response, body=None)
client = _mock_llm_client([err, "recovered"])
chat = _build_chat(client, threshold=None)
text = await chat.generate("task")
assert text == "recovered"
assert client.chat.completions.stream.call_count == 2
# ---------------------------------------------------------------------------
# Protocol: MessageType literal includes compaction events
# ---------------------------------------------------------------------------
class TestProtocol:
def test_message_type_includes_compaction_events(self):
# MessageType is a Literal — check that our new entries are present
# by inspecting its __args__.
args = set(MessageType.__args__) # type: ignore[attr-defined]
assert "compaction_start" in args
assert "compaction_end" in args
# ---------------------------------------------------------------------------
# Transcript + trace + snapshot on-disk logging
# ---------------------------------------------------------------------------
class TestTranscriptAndTrace:
@pytest.mark.asyncio
async def test_no_writes_when_transcripts_dir_none(self, tmp_path):
"""Default case: no on-disk artifacts when the dir is not set."""
client = _mock_llm_client(["resp"])
chat = _build_chat(client, threshold=None)
await chat.generate("hi")
# tmp_path is untouched because we didn't pass it
assert list(tmp_path.iterdir()) == []
assert chat._transcript_md_path is None # type: ignore[attr-defined]
assert chat._trace_jsonl_path is None # type: ignore[attr-defined]
@pytest.mark.asyncio
async def test_transcript_starts_with_user_turn_not_system(self, tmp_path):
"""transcript.md omits the system message and starts with the first user turn."""
client = _mock_llm_client(["reply"])
chat = _build_chat(
client,
threshold=None,
system="You are AGENT X.",
transcripts_dir=tmp_path,
)
transcript = tmp_path / "transcript.md"
assert not transcript.exists()
assert not (tmp_path / "trace.jsonl").exists()
await chat.generate("hi")
assert transcript.exists()
content = transcript.read_text(encoding="utf-8")
assert "### System" not in content
assert "You are AGENT X." not in content
assert content.startswith("### User")
assert "hi" in content
assert "### Assistant" in content
assert "reply" in content
@pytest.mark.asyncio
async def test_init_clobbers_stale_files(self, tmp_path):
"""Stale transcript.md and trace.jsonl from a prior run are wiped."""
(tmp_path / "transcript.md").write_text("STALE CONTENT FROM PRIOR RUN")
(tmp_path / "trace.jsonl").write_text('{"event":"stale"}\n')
client = _mock_llm_client(["reply"])
chat = _build_chat(client, threshold=None, transcripts_dir=tmp_path)
assert not (tmp_path / "transcript.md").exists()
assert not (tmp_path / "trace.jsonl").exists()
await chat.generate("hi")
transcript_text = (tmp_path / "transcript.md").read_text(encoding="utf-8")
assert "STALE CONTENT" not in transcript_text
assert "### User" in transcript_text
@pytest.mark.asyncio
async def test_generate_appends_delta_and_trace(self, tmp_path):
"""Each persistent generate() appends new items to transcript and a trace event."""
client = _mock_llm_client(["hello back"])
chat = _build_chat(client, threshold=None, transcripts_dir=tmp_path)
await chat.generate("hello")
content = (tmp_path / "transcript.md").read_text(encoding="utf-8")
assert "### System" not in content
assert "### User" in content
assert "hello" in content
assert "### Assistant" in content
assert "hello back" in content
events = _read_trace(tmp_path / "trace.jsonl")
assert len(events) == 1
assert events[0]["event"] == "llm_call"
assert events[0]["round"] == 1
assert events[0]["response"] == "hello back"
assert "prompt_messages" in events[0]
assert "tokens" in events[0]
assert "ts" in events[0]
@pytest.mark.asyncio
async def test_persist_false_skips_transcript_but_logs_trace(self, tmp_path):
"""persist=False calls leave history/transcript untouched but DO write to trace.
Trace logging is observability and intentionally decoupled from
``persist`` so auxiliary calls (compaction, final-answer) are
still visible in trace.jsonl for post-mortem inspection.
"""
client = _mock_llm_client(["side-effect-free"])
chat = _build_chat(client, threshold=None, transcripts_dir=tmp_path)
await chat.generate("aux prompt", persist=False, call_type="compaction")
# transcript.md is lazily written on first persisted append; persist=False
# leaves it absent.
assert not (tmp_path / "transcript.md").exists()
# trace.jsonl, in contrast, captures every API call regardless of persist.
assert (tmp_path / "trace.jsonl").exists()
events = _read_trace(tmp_path / "trace.jsonl")
assert len(events) == 1
assert events[0]["event"] == "llm_call"
assert events[0]["type"] == "compaction"
assert events[0]["response"] == "side-effect-free"
@pytest.mark.asyncio
async def test_compaction_snapshots_and_records_event(self, tmp_path):
"""Compaction takes a snapshot, appends handoff, emits compaction trace."""
client = _mock_llm_client(
[
("original response", 200), # crosses threshold
("SUMMARY TEXT", 20), # compaction summary (persist=False)
]
)
chat = _build_chat(
client,
threshold=100,
transcripts_dir=tmp_path,
guest_transcripts_dir=Path("/workspace/.agent/transcripts"),
)
await chat.generate("do the task")
_ = [evt async for evt in chat.maybe_compact()]
# Snapshot created with pre-compaction content
snapshot = tmp_path / "transcript.compaction_1.md"
assert snapshot.exists()
snapshot_text = snapshot.read_text(encoding="utf-8")
assert "do the task" in snapshot_text
assert "original response" in snapshot_text
# Live transcript has the handoff appended after the snapshot
live = (tmp_path / "transcript.md").read_text(encoding="utf-8")
assert HANDOFF_PREFIX in live
assert "SUMMARY TEXT" in live
# Handoff pointer uses the guest path, not the host path
assert "/workspace/.agent/transcripts/transcript.md" in live
assert str(tmp_path) not in live.split(HANDOFF_PREFIX, 1)[1]
# Trace has: round llm_call, compaction llm_call (the meta summary
# call), then the compaction marker event. Trace logging is
# decoupled from persist, so the meta call is visible too.
events = _read_trace(tmp_path / "trace.jsonl")
assert [e["event"] for e in events] == [
"llm_call",
"llm_call",
"compaction",
]
assert events[0]["type"] == "round"
assert events[1]["type"] == "compaction"
assert events[1]["response"] == "SUMMARY TEXT" # raw, pre-extract
compaction_evt = events[2]
assert compaction_evt["compaction_n"] == 1
assert compaction_evt["tokens_before"] == 200
assert compaction_evt["summary"] == "SUMMARY TEXT"
assert compaction_evt["snapshot"] == str(snapshot)
# New: handoff field captures the full injected user message so
# consumers don't have to reconstruct it from prompt_messages.
assert HANDOFF_PREFIX in compaction_evt["handoff"]
assert "SUMMARY TEXT" in compaction_evt["handoff"]
@pytest.mark.asyncio
async def test_multiple_compactions_produce_numbered_snapshots(self, tmp_path):
"""Each compaction gets its own transcript.compaction_N.md file."""
client = _mock_llm_client(
[
("resp1", 200), # triggers #1
("SUMMARY 1", 20),
("resp2", 300), # triggers #2
("SUMMARY 2", 20),
]
)
chat = _build_chat(
client,
threshold=100,
transcripts_dir=tmp_path,
guest_transcripts_dir=tmp_path,
)
await chat.generate("task one")
_ = [evt async for evt in chat.maybe_compact()]
await chat.generate("task two")
_ = [evt async for evt in chat.maybe_compact()]
assert (tmp_path / "transcript.compaction_1.md").exists()
assert (tmp_path / "transcript.compaction_2.md").exists()
events = _read_trace(tmp_path / "trace.jsonl")
compaction_events = [e for e in events if e["event"] == "compaction"]
assert [e["compaction_n"] for e in compaction_events] == [1, 2]
@pytest.mark.asyncio
async def test_non_string_content_serialized_as_json(self, tmp_path):
"""Content that isn't a string (e.g. multimodal parts) is JSON-dumped."""
client = _mock_llm_client(["response"])
chat = _build_chat(client, threshold=None, transcripts_dir=tmp_path)
# Feed a message with list content
multimodal: list[dict[str, Any]] = [
{"type": "text", "text": "caption"},
{"type": "image_url", "image_url": {"url": "http://x"}},
]
await chat.generate([{"role": "user", "content": multimodal}]) # type: ignore[arg-type]
content = (tmp_path / "transcript.md").read_text(encoding="utf-8")
# The list-content should be serialized as JSON on disk
assert '"type": "text"' in content
assert '"caption"' in content