Compare commits

..
Author SHA1 Message Date
Ian e011253ebc test(compaction): drop comment restating the test name 2026-10-06 21:33:30 -04:00
Ian 5227960e99 fix(compaction): fall back to reasoning text when the summary has none
A reasoning model can spend the whole summary output budget thinking and return only a reasoning item. _extract_text read only output messages, so compaction logged 'summary returned no content' and the agent failed. Use the reasoning text, with a warning, when there is no output text.
2026-10-06 21:33:30 -04:00
4 changed files with 45 additions and 37 deletions

No files matched your search

@@ -90,7 +90,6 @@ type Model struct {
client *Client
width, height int
snapshot protocol.Snapshot
windowTitle string
input textarea.Model
viewport viewport.Model
viewportContent string
-27
View File
@@ -2,13 +2,10 @@ package app
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"
"github.com/usestrix/strix/tui/internal/protocol"
"github.com/usestrix/strix/tui/internal/render"
)
@@ -59,10 +56,6 @@ func (m *Model) handleEnvelope(envelope protocol.Envelope) tea.Cmd {
// resize (not just refresh): status-row visibility changes the chat height.
m.resizeViewport()
m.resizeVulnerabilityViewport()
if title := windowTitle(m.snapshot.Targets); title != m.windowTitle {
m.windowTitle = title
return setTerminalTitle(title)
}
case "collection_bootstrap":
return m.handleCollectionBootstrap(envelope.Payload)
case "collection_delta":
@@ -125,26 +118,6 @@ func (m *Model) handleEnvelope(envelope protocol.Envelope) tea.Cmd {
return nil
}
// tea.SetWindowTitle only sends OSC 2 (window title); iTerm tabs show the OSC 1
// icon name, which shells usually set, so send OSC 0 to set both.
func setTerminalTitle(title string) tea.Cmd {
return func() tea.Msg {
_, _ = os.Stdout.WriteString(ansi.SetIconNameWindowTitle(title))
return nil
}
}
func windowTitle(targets []string) string {
switch len(targets) {
case 0:
return "strix"
case 1:
return targets[0]
default:
return fmt.Sprintf("%s +%d", targets[0], len(targets)-1)
}
}
func (m *Model) consumeMessages(messages []protocol.Message, setupMode bool) {
if m.seenMessages == nil {
m.seenMessages = map[string]bool{}
+20 -9
View File
@@ -15,7 +15,11 @@ from typing import TYPE_CHECKING, Any
from agents.model_settings import ModelSettings
from agents.models.interface import ModelTracing
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
from openai.types.responses import (
ResponseOutputMessage,
ResponseOutputText,
ResponseReasoningItem,
)
from strix.config import load_settings
from strix.config.models import StrixProvider
@@ -288,15 +292,22 @@ def _checkpoint_item(summary: str) -> dict[str, Any]:
def _extract_text(response: ModelResponse) -> str:
parts: list[str] = []
reasoning: list[str] = []
for item in response.output:
if not isinstance(item, ResponseOutputMessage):
continue
parts.extend(
chunk.text
for chunk in item.content
if isinstance(chunk, ResponseOutputText) and chunk.text
)
return "".join(parts)
if isinstance(item, ResponseOutputMessage):
parts.extend(
chunk.text
for chunk in item.content
if isinstance(chunk, ResponseOutputText) and chunk.text
)
elif isinstance(item, ResponseReasoningItem):
reasoning.extend(summary.text for summary in item.summary if summary.text)
if parts or not reasoning:
return "".join(parts)
# Reasoning models can spend the whole output budget thinking and return no
# message; their reasoning is a degraded but usable summary.
logger.warning("compaction summary had no output text; using reasoning text")
return "\n".join(reasoning)
async def _summarize(model: str, prompt: str, max_tokens: int) -> str | None:
+25
View File
@@ -6,6 +6,8 @@ from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
import pytest
from agents.extensions.models.litellm_model import InternalChatCompletionMessage
from agents.models.chatcmpl_converter import Converter
from litellm.exceptions import BadRequestError, ContextWindowExceededError, RateLimitError
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
@@ -242,6 +244,29 @@ async def test_summarize_routes_through_provider_with_settings(
assert settings.max_tokens == 64
@pytest.mark.asyncio
async def test_summarize_falls_back_to_reasoning_when_no_output_text(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_budget(monkeypatch, keep_tokens=30, window=4_000)
message = InternalChatCompletionMessage(
role="assistant", content=None, reasoning_content="REASONED SUMMARY"
)
response = SimpleNamespace(output=Converter.message_to_output_items(message))
class FakeModel:
async def get_response(self, **_kwargs: Any) -> Any:
return response
class FakeProvider:
def get_model(self, _model_name: str | None) -> Any:
return FakeModel()
monkeypatch.setattr(compaction, "StrixProvider", FakeProvider)
assert await compaction._summarize("m", "p", 64) == "REASONED SUMMARY"
def test_fit_to_tokens_truncates_oversized_text(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(compaction, "count_tokens", lambda _m, t: len(t))
text = "x" * 10_000