Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions opencollab/application/compaction_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ async def asummarize(self, segment: list[dict[str, Any]]) -> str:

async def _complete(self, request: list[dict[str, Any]]) -> str:
response = await self._acomplete(request)
if getattr(response, "finish_reason", None) in {"length", "max_tokens", "model_context_window_exceeded"}:
raise ValueError("The model returned a truncated conversation summary")
return format_compact_summary(getattr(response, "content", None) or "")

def _fits(self, request: list[dict[str, Any]], *, allow_budget_stop: bool = False) -> bool:
Expand Down
7 changes: 5 additions & 2 deletions tests/runtime/test_bounded_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,10 @@ async def complete(request):
assert requests == [build_summary_request(segment)]
assert summarizer.last_call_cacheable

@pytest.mark.parametrize("failure", [RuntimeError("provider failed"), "<summary> </summary>"])
@pytest.mark.parametrize("failure", [
RuntimeError("provider failed"), "<summary> </summary>",
SimpleNamespace(content="<summary>Truncated earlier changes.</summary>", finish_reason="max_tokens"),
])
async def test_chunk_failure_stops_before_the_tail_and_never_returns_a_partial_summary(failure):
requests = []

Expand All @@ -123,7 +126,7 @@ async def complete(request):
return SimpleNamespace(content="<summary>earlier user changes</summary>")
if isinstance(failure, Exception):
raise failure
return SimpleNamespace(content=failure)
return SimpleNamespace(content=failure) if isinstance(failure, str) else failure

summarizer = ReadTimeSummarizer(
complete, context_window=WINDOW, max_output_tokens=OUTPUT, transcript_path="session.json",
Expand Down
20 changes: 20 additions & 0 deletions tests/runtime/test_compaction_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import asyncio
from types import SimpleNamespace

import pytest

import opencollab.application.compaction_summary as compaction_summary
from opencollab.application.compaction_summary import ReadTimeSummarizer, run_coro_blocking

Expand Down Expand Up @@ -68,6 +70,24 @@ def test_summarizer_falls_back_on_llm_error():
assert "Summary:" not in out
assert s.last_call_cacheable is False

@pytest.mark.parametrize("finish_reason", ["length", "max_tokens", "model_context_window_exceeded"])
async def test_truncation_rejects_even_a_well_formed_summary(finish_reason):
async def complete(_request):
return SimpleNamespace(content="<summary>Partial context.</summary>", finish_reason=finish_reason)

summarizer = ReadTimeSummarizer(complete)
assert await summarizer.asummarize(SEGMENT) == "[user]: build the thing\n[assistant]: working on it"
assert not summarizer.last_call_cacheable

@pytest.mark.parametrize("finish_reason", [None, "stop", "end_turn", "stop_sequence"])
async def test_complete_summary_accepts_supported_stop_reasons(finish_reason):
async def complete(_request):
return SimpleNamespace(content="<summary>Complete context.</summary>", finish_reason=finish_reason)

summarizer = ReadTimeSummarizer(complete)
assert await summarizer.asummarize(SEGMENT) == "Summary:\nComplete context."
assert summarizer.last_call_cacheable

def test_summarizer_falls_back_when_no_summary_block():
s = _summarizer("<analysis>only thinking, forgot the summary</analysis>")
out = s(SEGMENT)
Expand Down
116 changes: 116 additions & 0 deletions tests/runtime/test_summary_completion_http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Provider truncation retains history until a complete summary can be cached."""

from __future__ import annotations

import copy
import json

import pytest

from opencollab.adapters.env import LocalEnvironment
from opencollab.adapters.llm.client import LLMClient
from opencollab.bootstrap import build_session
from opencollab.domain.agent import Agent
from tests.support.provider_sdk_http import http_response, install_sdk_transport
from tests.support.responses_provider_test_support import message_item

REQUIREMENT = "Preserve the SQLite backup before replacing the database."


@pytest.mark.parametrize("configuration", [
{"provider": "openai", "wire_protocol": "chat_completions", "model": "gpt-4o"},
{"provider": "openai", "wire_protocol": "responses", "model": "gpt-4o"},
{"provider": "anthropic", "wire_protocol": "chat_completions", "model": "claude-sonnet-4-6"},
], ids=["chat", "responses", "anthropic"])
@pytest.mark.parametrize("truncated", [False, True], ids=["complete", "truncated"])
async def test_native_summary_completion_preserves_history_usage_and_cache(
tmp_path, monkeypatch, configuration, truncated,
):
requests = []
monkeypatch.setenv("OPENCOLLAB_API_USAGE_LOG", str(tmp_path / "usage.jsonl"))
monkeypatch.setenv("OPENCOLLAB_BUDGET_NUDGE_MODE", "off")
monkeypatch.setenv("OPENCOLLAB_WRITE_NUDGE_MODE", "off")

async def handler(request):
body = json.loads(request.content)
requests.append(body)
summary = "create a detailed summary of the conversation" in json.dumps(body)
partial = summary and truncated and len(requests) == 1
text = (
"<summary>Earlier setup notes." if partial else f"<summary>{REQUIREMENT}</summary>"
) if summary else ("Backup preserved." if REQUIREMENT in json.dumps(body) else "Backup missing.")
output_tokens = 1_024 if partial else 10
if configuration["provider"] == "anthropic":
return http_response(request, 200, json={
"id": "msg_summary", "type": "message", "role": "assistant", "model": body["model"],
"content": [{"type": "text", "text": text}],
"stop_reason": "max_tokens" if partial else "end_turn", "stop_sequence": None,
"usage": {"input_tokens": 100, "output_tokens": output_tokens},
})
if configuration["wire_protocol"] == "responses":
item = message_item(text)
item["status"] = "incomplete" if partial else "completed"
terminal = {
"id": "resp_summary", "object": "response", "model": body["model"],
"status": "incomplete" if partial else "completed", "error": None,
"incomplete_details": {"reason": "max_output_tokens"} if partial else None,
"output": [item],
"usage": {"input_tokens": 100, "output_tokens": output_tokens, "total_tokens": 100 + output_tokens},
}
events = [
{"type": "response.output_item.done", "output_index": 0, "item": item},
{"type": "response.incomplete" if partial else "response.completed", "response": terminal},
]
wire = "".join(f"data: {json.dumps(event)}\n\n" for event in events)
return http_response(request, 200, headers={"content-type": "text/event-stream"}, text=wire)
return http_response(request, 200, json={
"id": "chat_summary", "object": "chat.completion", "created": 1, "model": body["model"],
"choices": [{"index": 0, "message": {"role": "assistant", "content": text},
"finish_reason": "length" if partial else "stop"}],
"usage": {"prompt_tokens": 100, "completion_tokens": output_tokens, "total_tokens": 100 + output_tokens},
})

install_sdk_transport(monkeypatch, configuration["provider"], handler)
async with LLMClient(
**configuration, api_key="controlled-test", # pragma: allowlist secret
base_url="https://controlled.invalid/v1", max_retries=0, context_window=40_000,
) as client:
session = build_session(
agent=Agent(name="notes", system_prompt="Complete the user task.",
**configuration, tools=[], max_tokens_per_step=1_024),
llm=client, env=LocalEnvironment(str(tmp_path)), max_budget_tokens=None,
)
original = [
{"role": "system", "content": "Complete the user task."},
{"role": "user", "content": "Earlier setup notes. " * 1_800 + REQUIREMENT},
{"role": "assistant", "content": "Earlier result."},
{"role": "user", "content": "The next work item is ready."},
{"role": "assistant", "content": "Another result."},
{"role": "user", "content": "Now finish the task."},
]
session.messages = copy.deepcopy(original)
try:
assert await session.run_loop() == "Backup preserved."
assert len(requests) == 2
assert REQUIREMENT in json.dumps(requests[0])
assert REQUIREMENT in json.dumps(requests[1])
assert session.messages[:len(original)] == original
assert session.used_tokens == (1_234 if truncated else 220)

compact = session.runner.shaper._shapers[-1]
assert compact.summarizer.last_call_cacheable is (not truncated)
assert len(compact._summary_cache) == (0 if truncated else 1)
if truncated:
assert original[1]["content"] in json.dumps(requests[1])

projection = await session.runner.shaper.ashape(original)
assert REQUIREMENT in json.dumps(projection)
assert len(requests) == (3 if truncated else 2)
assert compact.summarizer.last_call_cacheable
assert len(compact._summary_cache) == 1
assert await session.runner.shaper.ashape(original) == projection
assert len(requests) == (3 if truncated else 2)
assert session.used_tokens == (1_344 if truncated else 220)
finally:
await session.aclose()
await session.env.cleanup()
Loading