Files
simbarag/tests/unit/test_context_trim.py
T
ryan 4ce254193e Treat thinking as progress in stream timeout; trim replayed history
The 30s time-to-first-token guard measured from the turn's first model call
to the first answer token, so thinking, tool execution and the second call's
prefill all counted against it. Long reasoning on a long conversation was
cancelled mid-generation (llama-server: stop: cancel task at n_gen=1287).

- stream_query: the timeout now fires only when an in-flight model call goes
  silent for TTFT_TIMEOUT_SECONDS. Any chunk (thinking or empty) resets the
  clock, each model call starts a fresh one, and time spent in tools between
  calls doesn't count.
- Trim replayed history (blueprints/conversation/context.py): tool results
  and user messages from earlier turns are capped at 4000 chars (attachments
  included) and their images replaced by a placeholder. The current turn is
  untouched. The window no longer starts with orphaned ToolMessages.
- Log per-call prefill time, duration and prompt/output tokens
  (stream_usage=True) plus max_prompt_tokens in the stream_request line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:14:02 -04:00

79 lines
2.5 KiB
Python

"""Tests for trimming the replayed history (blueprints/conversation/context.py)."""
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from blueprints.conversation.context import (
EARLIER_MESSAGE_MAX_CHARS,
HISTORY_WINDOW,
IMAGE_PLACEHOLDER,
TRUNCATION_NOTE,
trim_history,
)
BIG = "x" * (EARLIER_MESSAGE_MAX_CHARS * 3)
def _call(call_id: str, name: str = "simba_search") -> AIMessage:
return AIMessage(content="", tool_calls=[{"id": call_id, "name": name, "args": {}}])
def _result(call_id: str, text: str = BIG) -> ToolMessage:
return ToolMessage(content=text, tool_call_id=call_id, name="simba_search")
def test_earlier_tool_results_are_capped_current_turn_untouched():
messages = [
HumanMessage(content="first question"),
_call("c1"),
_result("c1"),
AIMessage(content="first answer"),
HumanMessage(content="second question"),
_call("c2"),
_result("c2"),
]
out = trim_history(messages)
earlier, current = out[2], out[6]
assert earlier.content == BIG[:EARLIER_MESSAGE_MAX_CHARS] + TRUNCATION_NOTE
assert current.content == BIG
# The checkpointed originals are never mutated.
assert messages[2].content == BIG
def test_earlier_attachments_and_images_are_shrunk():
image = {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
messages = [
HumanMessage(content=[{"type": "text", "text": BIG}, image]),
AIMessage(content="nice photo"),
HumanMessage(content=[{"type": "text", "text": "now this one"}, image]),
]
out = trim_history(messages)
earlier = out[0].content
assert earlier[0]["text"].endswith(TRUNCATION_NOTE)
assert earlier[1] == {"type": "text", "text": IMAGE_PLACEHOLDER}
# The current turn keeps its image.
assert out[2].content[1] == image
def test_window_never_starts_with_orphaned_tool_results():
# Many parallel tool results so the window cut lands between an AIMessage's
# tool_calls and their ToolMessages.
messages = [HumanMessage(content="q"), _call("c0")]
messages += [_result("c0", "r") for _ in range(HISTORY_WINDOW)]
messages += [AIMessage(content="answer"), HumanMessage(content="next")]
out = trim_history(messages)
assert len(out) <= HISTORY_WINDOW
assert not isinstance(out[0], ToolMessage)
def test_short_history_is_returned_as_is():
messages = [HumanMessage(content="hi"), AIMessage(content="hello")]
out = trim_history(messages)
assert out == messages
assert out[0] is messages[0]