diff --git a/openhands-sdk/openhands/sdk/context/condenser/llm_summarizing_condenser.py b/openhands-sdk/openhands/sdk/context/condenser/llm_summarizing_condenser.py
index a1d2105db3..bfd527f701 100644
--- a/openhands-sdk/openhands/sdk/context/condenser/llm_summarizing_condenser.py
+++ b/openhands-sdk/openhands/sdk/context/condenser/llm_summarizing_condenser.py
@@ -17,7 +17,6 @@
from openhands.sdk.context.view import View
from openhands.sdk.event.base import LLMConvertibleEvent
from openhands.sdk.event.condenser import Condensation
-from openhands.sdk.event.llm_convertible import MessageEvent
from openhands.sdk.llm import LLM, Message, TextContent
from openhands.sdk.observability.laminar import observe
@@ -117,25 +116,8 @@ def condensation_requirement(
if Reason.REQUEST in reasons:
return CondensationRequirement.HARD
- def _get_summary_event_content(self, view: View) -> str:
- """Extract the text content from the summary event in the view, if any.
-
- If there is no summary event or it does not contain text content, returns an
- empty string.
- """
- summary_event_content: str = ""
-
- summary_event = view.summary_event
- if isinstance(summary_event, MessageEvent):
- message_content = summary_event.llm_message.content[0]
- if isinstance(message_content, TextContent):
- summary_event_content = message_content.text
-
- return summary_event_content
-
def _generate_condensation(
self,
- summary_event_content: str,
forgotten_events: Sequence[LLMConvertibleEvent],
summary_offset: int,
) -> Condensation:
@@ -143,7 +125,6 @@ def _generate_condensation(
events.
Args:
- summary_event_content: The content of the previous summary event.
forgotten_events: The list of events to be summarized.
summary_offset: The index where the summary event should be inserted.
@@ -161,7 +142,6 @@ def _generate_condensation(
prompt = render_template(
os.path.join(os.path.dirname(__file__), "prompts"),
"summarizing_prompt.j2",
- previous_summary=summary_event_content,
events=event_strings,
)
@@ -269,10 +249,7 @@ def get_condensation(
"events. Consider adjusting keep_first or max_size parameters."
)
- summary_event_content = self._get_summary_event_content(view)
-
return self._generate_condensation(
- summary_event_content=summary_event_content,
forgotten_events=forgotten_events,
summary_offset=summary_offset,
)
diff --git a/openhands-sdk/openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 b/openhands-sdk/openhands/sdk/context/condenser/prompts/summarizing_prompt.j2
index e252beb2c2..189d769905 100644
--- a/openhands-sdk/openhands/sdk/context/condenser/prompts/summarizing_prompt.j2
+++ b/openhands-sdk/openhands/sdk/context/condenser/prompts/summarizing_prompt.j2
@@ -1,5 +1,5 @@
You are maintaining a context-aware state summary for an interactive agent.
-You will be given a list of events corresponding to actions taken by the agent, and the most recent previous summary if one exists.
+You will be given a list of events corresponding to actions taken by the agent, which will include previous summaries.
If the events being summarized contain ANY task-tracking, you MUST include a TASK_TRACKING section to maintain continuity.
When referencing tasks make sure to preserve exact task IDs and statuses.
@@ -46,10 +46,6 @@ COMPLETED: 15 haikus written for results [T,H,T,H,T,H,T,T,H,T,H,T,H,T,H]
PENDING: 5 more haikus needed
CURRENT_STATE: Last flip: Heads, Haiku count: 15/20
-
-{{ previous_summary }}
-
-
{% for event in events %}
{{ event }}
diff --git a/openhands-sdk/openhands/sdk/context/view.py b/openhands-sdk/openhands/sdk/context/view.py
index e79c95b1f5..006e0d09e3 100644
--- a/openhands-sdk/openhands/sdk/context/view.py
+++ b/openhands-sdk/openhands/sdk/context/view.py
@@ -11,7 +11,6 @@
from openhands.sdk.event import (
Condensation,
CondensationRequest,
- CondensationSummaryEvent,
LLMConvertibleEvent,
)
from openhands.sdk.event.base import Event, EventID
@@ -86,32 +85,6 @@ class View(BaseModel):
def __len__(self) -> int:
return len(self.events)
- @property
- def most_recent_condensation(self) -> Condensation | None:
- """Return the most recent condensation, or None if no condensations exist."""
- return self.condensations[-1] if self.condensations else None
-
- @property
- def summary_event_index(self) -> int | None:
- """Return the index of the summary event, or None if no summary exists."""
- recent_condensation = self.most_recent_condensation
- if (
- recent_condensation is not None
- and recent_condensation.summary is not None
- and recent_condensation.summary_offset is not None
- ):
- return recent_condensation.summary_offset
- return None
-
- @property
- def summary_event(self) -> CondensationSummaryEvent | None:
- """Return the summary event, or None if no summary exists."""
- if self.summary_event_index is not None:
- event = self.events[self.summary_event_index]
- if isinstance(event, CondensationSummaryEvent):
- return event
- return None
-
@computed_field # type: ignore[prop-decorator]
@cached_property
def manipulation_indices(self) -> list[int]:
@@ -291,46 +264,53 @@ def __getitem__(
@staticmethod
def _enforce_batch_atomicity(
- events: Sequence[Event],
- removed_event_ids: set[EventID],
- ) -> set[EventID]:
- """Ensure that if any ActionEvent in a batch is removed, all ActionEvents
- in that batch are removed.
+ view_events: Sequence[LLMConvertibleEvent],
+ all_events: Sequence[Event],
+ ) -> list[LLMConvertibleEvent]:
+ """Ensure that if any ActionEvent in a batch is removed from the view,
+ all ActionEvents in that batch are removed.
This prevents partial batches from being sent to the LLM, which can cause
API errors when thinking blocks are separated from their tool calls.
Args:
- events: The original list of events
- removed_event_ids: Set of event IDs that are being removed
+ view_events: The list of events that are being kept in the view
+ all_events: The complete original list of all events
Returns:
- Updated set of event IDs that should be removed (including all
- ActionEvents in batches where any ActionEvent was removed)
+ Filtered list of view events with batch atomicity enforced
+ (removing all ActionEvents from batches where any ActionEvent
+ was already removed)
"""
- action_batch = ActionBatch.from_events(events)
+ action_batch = ActionBatch.from_events(all_events)
if not action_batch.batches:
- return removed_event_ids
+ return list(view_events)
+
+ # Get set of event IDs currently in the view
+ view_event_ids = {event.id for event in view_events}
- updated_removed_ids = set(removed_event_ids)
+ # Track which event IDs should be removed due to batch atomicity
+ ids_to_remove: set[EventID] = set()
for llm_response_id, batch_event_ids in action_batch.batches.items():
- # Check if any ActionEvent in this batch is being removed
- if any(event_id in removed_event_ids for event_id in batch_event_ids):
+ # Check if any ActionEvent in this batch is missing from view
+ if any(event_id not in view_event_ids for event_id in batch_event_ids):
# If so, remove all ActionEvents in this batch
- updated_removed_ids.update(batch_event_ids)
+ ids_to_remove.update(batch_event_ids)
logger.debug(
f"Enforcing batch atomicity: removing entire batch "
f"with llm_response_id={llm_response_id} "
f"({len(batch_event_ids)} events)"
)
- return updated_removed_ids
+ # Filter out events that need to be removed
+ return [event for event in view_events if event.id not in ids_to_remove]
@staticmethod
- def filter_unmatched_tool_calls(
- events: list[LLMConvertibleEvent],
+ def _filter_unmatched_tool_calls(
+ view_events: Sequence[LLMConvertibleEvent],
+ all_events: Sequence[Event],
) -> list[LLMConvertibleEvent]:
"""Filter out unmatched tool call events.
@@ -338,49 +318,58 @@ def filter_unmatched_tool_calls(
but don't have matching pairs. Also enforces batch atomicity - if any
ActionEvent in a batch is filtered out, all ActionEvents in that batch
are also filtered out.
+
+ Args:
+ view_events: The list of events to filter
+ all_events: The complete original list of all events
+
+ Returns:
+ Filtered list of events with unmatched tool calls removed
"""
- action_tool_call_ids = View._get_action_tool_call_ids(events)
- observation_tool_call_ids = View._get_observation_tool_call_ids(events)
+ action_tool_call_ids = View._get_action_tool_call_ids(view_events)
+ observation_tool_call_ids = View._get_observation_tool_call_ids(view_events)
# Build batch info for batch atomicity enforcement
- action_batch = ActionBatch.from_events(events)
+ batch = ActionBatch.from_events(all_events)
- # First pass: identify which events would NOT be kept based on matching
- removed_event_ids: set[EventID] = set()
- for event in events:
- if not View._should_keep_event(
+ # First pass: filter out events that don't match based on tool call pairing
+ kept_events = [
+ event
+ for event in view_events
+ if View._should_keep_event(
event, action_tool_call_ids, observation_tool_call_ids
- ):
- removed_event_ids.add(event.id)
+ )
+ ]
# Second pass: enforce batch atomicity for ActionEvents
# If any ActionEvent in a batch is removed, all ActionEvents in that
# batch should also be removed
- removed_event_ids = View._enforce_batch_atomicity(events, removed_event_ids)
+ kept_events = View._enforce_batch_atomicity(kept_events, all_events)
# Third pass: also remove ObservationEvents whose ActionEvents were removed
# due to batch atomicity
- tool_call_ids_to_remove: set[ToolCallID] = set()
- for action_id in removed_event_ids:
- if action_id in action_batch.action_id_to_tool_call_id:
- tool_call_ids_to_remove.add(
- action_batch.action_id_to_tool_call_id[action_id]
- )
-
- # Filter out removed events
- result = []
- for event in events:
- if event.id in removed_event_ids:
- continue
- if isinstance(event, ObservationBaseEvent):
- if event.tool_call_id in tool_call_ids_to_remove:
- continue
- result.append(event)
+ # Find which action IDs are now missing after batch atomicity enforcement
+ kept_event_ids = {event.id for event in kept_events}
+ tool_call_ids_to_remove: set[ToolCallID] = {
+ tool_call_id
+ for action_id, tool_call_id in batch.action_id_to_tool_call_id.items()
+ if action_id not in kept_event_ids
+ }
+
+ # Filter out ObservationEvents whose ActionEvents were removed
+ result = [
+ event
+ for event in kept_events
+ if not (
+ isinstance(event, ObservationBaseEvent)
+ and event.tool_call_id in tool_call_ids_to_remove
+ )
+ ]
return result
@staticmethod
- def _get_action_tool_call_ids(events: list[LLMConvertibleEvent]) -> set[ToolCallID]:
+ def _get_action_tool_call_ids(events: Sequence[Event]) -> set[ToolCallID]:
"""Extract tool_call_ids from ActionEvents."""
tool_call_ids = set()
for event in events:
@@ -390,7 +379,7 @@ def _get_action_tool_call_ids(events: list[LLMConvertibleEvent]) -> set[ToolCall
@staticmethod
def _get_observation_tool_call_ids(
- events: list[LLMConvertibleEvent],
+ events: Sequence[Event],
) -> set[ToolCallID]:
"""Extract tool_call_ids from ObservationEvents."""
tool_call_ids = set()
@@ -404,7 +393,7 @@ def _get_observation_tool_call_ids(
@staticmethod
def _should_keep_event(
- event: LLMConvertibleEvent,
+ event: Event,
action_tool_call_ids: set[ToolCallID],
observation_tool_call_ids: set[ToolCallID],
) -> bool:
@@ -434,67 +423,64 @@ def find_next_manipulation_index(self, threshold: int) -> int:
return idx
return threshold
+ @staticmethod
+ def unhandled_condensation_request_exists(
+ events: Sequence[Event],
+ ) -> bool:
+ """Check if there is an unhandled condensation request in the list of events.
+
+ An unhandled condensation request is defined as a CondensationRequest event
+ that appears after the most recent Condensation event in the list.
+ """
+ for event in reversed(events):
+ if isinstance(event, Condensation):
+ return False
+ if isinstance(event, CondensationRequest):
+ return True
+ return False
+
@staticmethod
def from_events(events: Sequence[Event]) -> View:
"""Create a view from a list of events, respecting the semantics of any
condensation events.
"""
- forgotten_event_ids: set[EventID] = set()
+ output: list[LLMConvertibleEvent] = []
condensations: list[Condensation] = []
+
+ # Generate the LLMConvertibleEvent objects the agent can send to the LLM by
+ # removing un-sendable events and applying condensations in order.
for event in events:
+ # By the time we come across a Condensation event, the output list should
+ # already reflect the events seen by the agent up to that point. We can
+ # therefore apply the condensation semantics directly to the output list.
if isinstance(event, Condensation):
condensations.append(event)
- forgotten_event_ids.update(event.forgotten_event_ids)
- # Make sure we also forget the condensation action itself
- forgotten_event_ids.add(event.id)
- if isinstance(event, CondensationRequest):
- forgotten_event_ids.add(event.id)
-
- # Enforce batch atomicity: if any event in a multi-action batch is forgotten,
- # forget all events in that batch to prevent partial batches with thinking
- # blocks separated from their tool calls
- forgotten_event_ids = View._enforce_batch_atomicity(events, forgotten_event_ids)
-
- kept_events = [
- event
- for event in events
- if event.id not in forgotten_event_ids
- and isinstance(event, LLMConvertibleEvent)
- ]
+ output = event.apply(output)
- # If we have a summary, insert it at the specified offset.
- summary: str | None = None
- summary_offset: int | None = None
+ elif isinstance(event, LLMConvertibleEvent):
+ output.append(event)
- # The relevant summary is always in the last condensation event (i.e., the most
- # recent one).
- for event in reversed(events):
- if isinstance(event, Condensation):
- if event.summary is not None and event.summary_offset is not None:
- summary = event.summary
- summary_offset = event.summary_offset
- break
-
- if summary is not None and summary_offset is not None:
- logger.debug(f"Inserting summary at offset {summary_offset}")
-
- _new_summary_event = CondensationSummaryEvent(summary=summary)
- kept_events.insert(summary_offset, _new_summary_event)
-
- # Check for an unhandled condensation request -- these are events closer to the
- # end of the list than any condensation action.
- unhandled_condensation_request = False
-
- for event in reversed(events):
- if isinstance(event, Condensation):
- break
+ # If the event isn't related to condensation and isn't LLMConvertible, it
+ # should not be in the resulting view. Examples include certain internal
+ # events used for state tracking that the LLM does not need to see -- see,
+ # for example, ConversationStateUpdateEvent, PauseEvent, and (relevant here)
+ # CondensationRequest.
+ else:
+ logger.debug(
+ f"Skipping non-LLMConvertibleEvent of type {type(event)} "
+ f"in View.from_events"
+ )
- if isinstance(event, CondensationRequest):
- unhandled_condensation_request = True
- break
+ # Enforce batch atomicity: if any event in a multi-action batch is removed,
+ # remove all events in that batch to prevent partial batches with thinking
+ # blocks separated from their tool calls
+ output = View._enforce_batch_atomicity(output, events)
+ output = View._filter_unmatched_tool_calls(output, events)
return View(
- events=View.filter_unmatched_tool_calls(kept_events),
- unhandled_condensation_request=unhandled_condensation_request,
+ events=output,
+ unhandled_condensation_request=View.unhandled_condensation_request_exists(
+ events
+ ),
condensations=condensations,
)
diff --git a/openhands-sdk/openhands/sdk/event/condenser.py b/openhands-sdk/openhands/sdk/event/condenser.py
index 6f58a45d17..b609245275 100644
--- a/openhands-sdk/openhands/sdk/event/condenser.py
+++ b/openhands-sdk/openhands/sdk/event/condenser.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
from pydantic import Field
from rich.text import Text
@@ -22,8 +24,9 @@ class Condensation(Event):
summary_offset: int | None = Field(
default=None,
ge=0,
- description="An optional offset to the start of the resulting view"
- " indicating where the summary should be inserted.",
+ description="An optional offset to the start of the resulting view (after"
+ " forgotten events have been removed) indicating where the summary should be"
+ " inserted. If not provided, the summary will not be inserted into the view.",
)
llm_response_id: EventID = Field(
description=(
@@ -45,6 +48,53 @@ def visualize(self) -> Text:
text.append(f"{self.summary}\n")
return text
+ @property
+ def summary_event(self) -> CondensationSummaryEvent:
+ """Generates a CondensationSummaryEvent.
+
+ Since summary events are not part of the main event store and are generated
+ dynamically, this property ensures the created event has a unique and consistent
+ ID based on the condensation event's ID.
+
+ Raises:
+ ValueError: If no summary is present.
+ """
+ if self.summary is None:
+ raise ValueError("No summary present to generate CondensationSummaryEvent.")
+
+ # Create a deterministic ID for the summary event.
+ # This ID will be unique amongst all auto-generated IDs (by virtue of the
+ # "-summary" suffix).
+ # These events are not intended to be stored alongside regular events, but the
+ # ID is still compatible with the file-based event store.
+ summary_id = f"{self.id}-summary"
+
+ return CondensationSummaryEvent(
+ id=summary_id,
+ summary=self.summary,
+ source=self.source,
+ )
+
+ @property
+ def has_summary_metadata(self) -> bool:
+ """Checks if both summary and summary_offset are present."""
+ return self.summary is not None and self.summary_offset is not None
+
+ def apply(self, events: list[LLMConvertibleEvent]) -> list[LLMConvertibleEvent]:
+ """Applies the condensation to a list of events.
+
+ This method removes events that are marked to be forgotten and returns a new
+ list of events. If the summary metadata is present (both summary and offset),
+ the corresponding CondensationSummaryEvent will be inserted at the specified
+ offset _after_ the forgotten events have been removed.
+ """
+ output = [event for event in events if event.id not in self.forgotten_event_ids]
+ if self.has_summary_metadata:
+ assert self.summary_offset is not None
+ summary_event = self.summary_event
+ output.insert(self.summary_offset, summary_event)
+ return output
+
class CondensationRequest(Event):
"""This action is used to request a condensation of the conversation history.
diff --git a/tests/sdk/context/condenser/test_llm_summarizing_condenser.py b/tests/sdk/context/condenser/test_llm_summarizing_condenser.py
index 4f7707ddd5..1a8901438a 100644
--- a/tests/sdk/context/condenser/test_llm_summarizing_condenser.py
+++ b/tests/sdk/context/condenser/test_llm_summarizing_condenser.py
@@ -568,7 +568,6 @@ def test_generate_condensation_raises_on_zero_events(mock_llm: LLM) -> None:
with pytest.raises(AssertionError, match="No events to condense"):
condenser._generate_condensation(
- summary_event_content="",
forgotten_events=[],
summary_offset=0,
)
diff --git a/tests/sdk/context/test_view.py b/tests/sdk/context/test_view.py
index 6ab90ca97a..7d3455604a 100644
--- a/tests/sdk/context/test_view.py
+++ b/tests/sdk/context/test_view.py
@@ -1,4 +1,3 @@
-from typing import cast
from unittest.mock import create_autospec
from openhands.sdk.context.view import View
@@ -311,7 +310,6 @@ def test_condensations_field_empty_when_no_condensations() -> None:
view = View.from_events(events)
assert view.condensations == []
- assert view.most_recent_condensation is None
def test_condensations_field_stores_all_condensations_in_order() -> None:
@@ -357,48 +355,6 @@ def test_condensations_field_stores_all_condensations_in_order() -> None:
assert view.condensations[2] == condensation3
-def test_most_recent_condensation_property() -> None:
- """Test that most_recent_condensation property returns the last condensation."""
- message_events = [message_event(f"Event {i}") for i in range(3)]
-
- # Test with no condensations
- events_no_condensation: list[Event] = cast(list[Event], message_events.copy())
- view_no_condensation = View.from_events(events_no_condensation)
- assert view_no_condensation.most_recent_condensation is None
-
- # Test with single condensation
- condensation1 = Condensation(
- forgotten_event_ids=[],
- summary="First summary",
- llm_response_id="condensation_response_1",
- )
- events_single: list[Event] = [*message_events, condensation1]
- view_single = View.from_events(events_single)
- assert view_single.most_recent_condensation == condensation1
-
- # Test with multiple condensations
- condensation2 = Condensation(
- forgotten_event_ids=[],
- summary="Second summary",
- llm_response_id="condensation_response_2",
- )
- condensation3 = Condensation(
- forgotten_event_ids=[],
- summary="Third summary",
- llm_response_id="condensation_response_3",
- )
- events_multiple: list[Event] = [
- message_events[0],
- condensation1,
- message_events[1],
- condensation2,
- message_events[2],
- condensation3,
- ]
- view_multiple = View.from_events(events_multiple)
- assert view_multiple.most_recent_condensation == condensation3
-
-
def test_condensations_field_with_mixed_events() -> None:
"""Test condensations field behavior with mixed event types including requests."""
message_events = [message_event(f"Event {i}") for i in range(4)]
@@ -428,171 +384,6 @@ def test_condensations_field_with_mixed_events() -> None:
assert len(view.condensations) == 2
assert view.condensations[0] == condensation1
assert view.condensations[1] == condensation2
- assert view.most_recent_condensation == condensation2
-
-
-def test_summary_event_index_none_when_no_summary() -> None:
- """Test that summary_event_index is None when there's no summary."""
- events: list[Event] = [message_event(f"Event {i}") for i in range(3)]
- view = View.from_events(events)
-
- assert view.summary_event_index is None
- assert view.summary_event is None
-
-
-def test_summary_event_index_none_when_condensation_has_no_summary() -> None:
- """Test that summary_event_index is None when condensation exists but has no
- summary.
- """
- message_events = [message_event(f"Event {i}") for i in range(3)]
-
- # Condensation without summary
- condensation = Condensation(
- forgotten_event_ids=[message_events[0].id],
- llm_response_id="condensation_response_1",
- )
-
- events: list[Event] = [
- message_events[0],
- message_events[1],
- condensation,
- message_events[2],
- ]
-
- view = View.from_events(events)
-
- assert view.summary_event_index is None
- assert view.summary_event is None
- assert len(view.condensations) == 1
-
-
-def test_summary_event_index_and_event_with_summary() -> None:
- """Test that summary_event_index and summary_event work correctly when summary
- exists.
- """
- message_events = [message_event(f"Event {i}") for i in range(4)]
-
- # Condensation with summary at offset 1
- condensation = Condensation(
- forgotten_event_ids=[message_events[0].id],
- summary="This is a test summary",
- summary_offset=1,
- llm_response_id="condensation_response_1",
- )
-
- events: list[Event] = [
- message_events[0], # Will be forgotten
- message_events[1],
- condensation,
- message_events[2],
- message_events[3],
- ]
-
- view = View.from_events(events)
-
- # Should have summary at index 1
- assert view.summary_event_index == 1
- assert view.summary_event is not None
-
- # Check the summary event properties
- summary_event = view.summary_event
- assert summary_event.summary == "This is a test summary"
-
- # Verify the view structure
- assert len(view) == 4 # 3 kept events + 1 summary
- assert view[1] == summary_event # Summary at index 1
-
-
-def test_summary_event_with_multiple_condensations() -> None:
- """Test that summary_event uses the most recent condensation's summary."""
- message_events = [message_event(f"Event {i}") for i in range(5)]
-
- # First condensation with summary
- condensation1 = Condensation(
- forgotten_event_ids=[message_events[0].id],
- summary="First summary",
- summary_offset=0,
- llm_response_id="condensation_response_1",
- )
-
- # Second condensation with different summary (should override)
- condensation2 = Condensation(
- forgotten_event_ids=[message_events[1].id],
- summary="Second summary",
- summary_offset=1,
- llm_response_id="condensation_response_2",
- )
-
- events: list[Event] = [
- message_events[0], # Will be forgotten by condensation1
- message_events[1], # Will be forgotten by condensation2
- condensation1,
- message_events[2],
- condensation2,
- message_events[3],
- message_events[4],
- ]
-
- view = View.from_events(events)
-
- # Should use the most recent condensation's summary
- assert view.summary_event_index == 1
- assert view.summary_event is not None
- assert view.summary_event.summary == "Second summary"
-
- # Should have both condensations
- assert len(view.condensations) == 2
-
-
-def test_summary_event_with_condensation_without_offset() -> None:
- """Test that summary is ignored if condensation has summary but no offset."""
- message_events = [message_event(f"Event {i}") for i in range(3)]
-
- # Condensation with summary but no offset
- condensation = Condensation(
- forgotten_event_ids=[message_events[0].id],
- summary="This summary should be ignored",
- llm_response_id="condensation_response_1",
- # No summary_offset
- )
-
- events: list[Event] = [
- message_events[0],
- message_events[1],
- condensation,
- message_events[2],
- ]
-
- view = View.from_events(events)
-
- assert view.summary_event_index is None
- assert view.summary_event is None
-
-
-def test_summary_event_with_zero_offset() -> None:
- """Test that summary_event works correctly with offset 0."""
- message_events = [message_event(f"Event {i}") for i in range(3)]
-
- condensation = Condensation(
- forgotten_event_ids=[message_events[0].id],
- summary="Summary at beginning",
- summary_offset=0,
- llm_response_id="condensation_response_1",
- )
-
- events: list[Event] = [
- message_events[0], # Will be forgotten
- message_events[1],
- condensation,
- message_events[2],
- ]
-
- view = View.from_events(events)
-
- assert view.summary_event_index == 0
- assert view.summary_event is not None
- assert view.summary_event.summary == "Summary at beginning"
- assert view[0] == view.summary_event # Summary is first event
# Tests for unmatched tool call filtering functionality moved from CondenserBase
@@ -600,7 +391,7 @@ def test_summary_event_with_zero_offset() -> None:
def test_filter_unmatched_tool_calls_empty_list() -> None:
"""Test filter_unmatched_tool_calls with empty event list."""
- result = View.filter_unmatched_tool_calls([])
+ result = View._filter_unmatched_tool_calls([], [])
assert result == []
@@ -613,7 +404,7 @@ def test_filter_unmatched_tool_calls_no_tool_events() -> None:
message_event_2.id = "msg_2"
events = [message_event_1, message_event_2]
- result = View.filter_unmatched_tool_calls(events) # type: ignore
+ result = View._filter_unmatched_tool_calls(events, events) # type: ignore
# All non-tool events should be kept
assert len(result) == 2
@@ -655,7 +446,7 @@ def test_filter_unmatched_tool_calls_matched_pairs() -> None:
observation_event_2,
]
- result = View.filter_unmatched_tool_calls(events) # type: ignore
+ result = View._filter_unmatched_tool_calls(events, events) # type: ignore
# All events should be kept (all tool calls are matched)
assert len(result) == 5
@@ -695,7 +486,7 @@ def test_filter_unmatched_tool_calls_unmatched_action() -> None:
action_event_unmatched,
]
- result = View.filter_unmatched_tool_calls(events) # type: ignore
+ result = View._filter_unmatched_tool_calls(events, events) # type: ignore
# Should keep: message_event, matched pair
# Should filter out: unmatched ActionEvent
@@ -734,7 +525,7 @@ def test_filter_unmatched_tool_calls_unmatched_observation() -> None:
observation_event_unmatched,
]
- result = View.filter_unmatched_tool_calls(events) # type: ignore
+ result = View._filter_unmatched_tool_calls(events, events) # type: ignore
# Should keep: message_event, matched pair
# Should filter out: unmatched ObservationEvent
@@ -795,7 +586,7 @@ def test_filter_unmatched_tool_calls_mixed_scenario() -> None:
observation_event_2,
]
- result = View.filter_unmatched_tool_calls(events) # type: ignore
+ result = View._filter_unmatched_tool_calls(events, events) # type: ignore
# Should keep: message events and matched pairs
# Should filter out: unmatched action and observation events
@@ -839,7 +630,7 @@ def test_filter_unmatched_tool_calls_none_tool_call_id() -> None:
observation_event_valid,
]
- result = View.filter_unmatched_tool_calls(events) # type: ignore
+ result = View._filter_unmatched_tool_calls(events, events) # type: ignore
# Should keep only the valid matched pair
# Events with None tool_call_id should be filtered out
diff --git a/tests/sdk/context/test_view_action_filtering.py b/tests/sdk/context/test_view_action_filtering.py
index d7da64c934..3a021a2dd0 100644
--- a/tests/sdk/context/test_view_action_filtering.py
+++ b/tests/sdk/context/test_view_action_filtering.py
@@ -57,7 +57,7 @@ def test_filter_unmatched_tool_calls_with_user_reject_observation() -> None:
]
# Filter the events
- result = View.filter_unmatched_tool_calls(events) # type: ignore
+ result = View._filter_unmatched_tool_calls(events, events) # type: ignore
# Both the ActionEvent and UserRejectObservation should be kept
# because they form a matched pair (after the fix)
@@ -99,7 +99,7 @@ def test_filter_unmatched_tool_calls_with_agent_error_event() -> None:
]
# Filter the events
- result = View.filter_unmatched_tool_calls(events) # type: ignore
+ result = View._filter_unmatched_tool_calls(events, events) # type: ignore
# Both the ActionEvent and AgentErrorEvent should be kept
# because they form a matched pair (after the fix)
@@ -164,7 +164,7 @@ def test_filter_unmatched_tool_calls_mixed_observation_types() -> None:
message_event("End"),
]
- result = View.filter_unmatched_tool_calls(events) # type: ignore
+ result = View._filter_unmatched_tool_calls(events, events) # type: ignore
# After fix: all matched pairs should be kept
# action_event_1 paired with observation_event
diff --git a/tests/sdk/context/test_view_multi_summary.py b/tests/sdk/context/test_view_multi_summary.py
new file mode 100644
index 0000000000..6c002a9fe4
--- /dev/null
+++ b/tests/sdk/context/test_view_multi_summary.py
@@ -0,0 +1,1162 @@
+"""Tests for multi-summary support in View.
+
+This module tests the View system's ability to handle multiple CondensationSummaryEvents
+simultaneously, including the ability to forget previous summaries in subsequent
+condensations.
+
+Key behaviors tested:
+- Multiple summaries can coexist in the same view
+- Summaries can be forgotten individually or in groups
+- Summary offsets work correctly with multiple summaries
+- Summaries have stable identifiers across view reconstructions
+- Integration with event forgetting
+- Backward compatibility with existing summary properties
+"""
+
+from openhands.sdk.context.view import View
+from openhands.sdk.event import Condensation, CondensationSummaryEvent
+from openhands.sdk.event.llm_convertible import MessageEvent
+from openhands.sdk.llm import Message, TextContent
+
+
+def message_event(content: str) -> MessageEvent:
+ """Helper to create a MessageEvent."""
+ return MessageEvent(
+ llm_message=Message(role="user", content=[TextContent(text=content)]),
+ source="user",
+ )
+
+
+# ==============================================================================
+# Category 1: Multiple Summaries Coexistence
+# ==============================================================================
+
+
+def test_multiple_summaries_at_different_offsets() -> None:
+ """Test that two summaries from different condensations can coexist in a view.
+
+ Scenario:
+ - First condensation: forgets event 0, adds summary at offset 0
+ - Second condensation: forgets event 2, adds summary at offset 2
+ - Both summaries should appear in the final view at their specified offsets
+ """
+ message_events = [message_event(f"Event {i}") for i in range(5)]
+
+ condensation1 = Condensation(
+ id="condensation-1",
+ forgotten_event_ids=[message_events[0].id],
+ summary="Summary of event 0",
+ summary_offset=0,
+ llm_response_id="condensation_1",
+ )
+
+ condensation2 = Condensation(
+ id="condensation-2",
+ forgotten_event_ids=[message_events[2].id],
+ summary="Summary of event 2",
+ summary_offset=2,
+ llm_response_id="condensation_2",
+ )
+
+ events = [
+ message_events[0],
+ message_events[1],
+ condensation1,
+ message_events[2],
+ message_events[3],
+ condensation2,
+ message_events[4],
+ ]
+
+ view = View.from_events(events)
+
+ # Find all CondensationSummaryEvents in the view
+ summary_events = [e for e in view.events if isinstance(e, CondensationSummaryEvent)]
+
+ assert len(summary_events) == 2, "Both summaries should be present in view"
+
+ # Verify first summary is at offset 0
+ assert isinstance(view.events[0], CondensationSummaryEvent)
+ assert view.events[0].summary == "Summary of event 0"
+
+ # Verify second summary is at offset 2
+ assert isinstance(view.events[2], CondensationSummaryEvent)
+ assert view.events[2].summary == "Summary of event 2"
+
+
+def test_multiple_summaries_from_sequential_condensations() -> None:
+ """Test three condensations each adding a summary at different positions.
+
+ This tests that summaries accumulate as condensations are processed sequentially.
+ """
+ message_events = [message_event(f"Event {i}") for i in range(6)]
+
+ condensation1 = Condensation(
+ id="condensation-1",
+ forgotten_event_ids=[],
+ summary="First summary",
+ summary_offset=0,
+ llm_response_id="condensation_1",
+ )
+
+ condensation2 = Condensation(
+ id="condensation-2",
+ forgotten_event_ids=[],
+ summary="Second summary",
+ summary_offset=3,
+ llm_response_id="condensation_2",
+ )
+
+ condensation3 = Condensation(
+ id="condensation-3",
+ forgotten_event_ids=[],
+ summary="Third summary",
+ summary_offset=5,
+ llm_response_id="condensation_3",
+ )
+
+ events = [
+ message_events[0],
+ condensation1,
+ message_events[1],
+ message_events[2],
+ condensation2,
+ message_events[3],
+ condensation3,
+ message_events[4],
+ message_events[5],
+ ]
+
+ view = View.from_events(events)
+
+ summary_events = [e for e in view.events if isinstance(e, CondensationSummaryEvent)]
+
+ assert len(summary_events) == 3, "All three summaries should be present"
+
+ # Verify each summary is at its specified offset
+ assert isinstance(view.events[0], CondensationSummaryEvent)
+ assert view.events[0].summary == "First summary"
+
+ assert isinstance(view.events[3], CondensationSummaryEvent)
+ assert view.events[3].summary == "Second summary"
+
+ assert isinstance(view.events[5], CondensationSummaryEvent)
+ assert view.events[5].summary == "Third summary"
+
+
+def test_summaries_preserve_order_and_content() -> None:
+ """Test that multiple summaries maintain their order and content correctly.
+
+ Verifies that summaries don't interfere with each other and each maintains
+ its own content and position.
+ """
+ messages = [message_event(f"Msg {i}") for i in range(4)]
+
+ condensation1 = Condensation(
+ id="cond-1",
+ forgotten_event_ids=[messages[0].id],
+ summary="Summary A",
+ summary_offset=0,
+ llm_response_id="cond_1",
+ )
+
+ condensation2 = Condensation(
+ id="cond-2",
+ forgotten_event_ids=[messages[2].id],
+ summary="Summary B",
+ summary_offset=2,
+ llm_response_id="cond_2",
+ )
+
+ events = [
+ messages[0],
+ condensation1,
+ messages[1],
+ messages[2],
+ condensation2,
+ messages[3],
+ ]
+
+ view = View.from_events(events)
+
+ # Event 0 forgotten, Event 2 forgotten
+ # Expected: [Summary A, Msg 1, Summary B, Msg 3]
+ assert len(view.events) == 4
+
+ assert isinstance(view.events[0], CondensationSummaryEvent)
+ assert view.events[0].summary == "Summary A"
+
+ assert isinstance(view.events[1], MessageEvent)
+ assert isinstance(view.events[1].llm_message.content[0], TextContent)
+ assert view.events[1].llm_message.content[0].text == "Msg 1"
+
+ assert isinstance(view.events[2], CondensationSummaryEvent)
+ assert view.events[2].summary == "Summary B"
+
+ assert isinstance(view.events[3], MessageEvent)
+ assert isinstance(view.events[3].llm_message.content[0], TextContent)
+ assert view.events[3].llm_message.content[0].text == "Msg 3"
+
+
+# ==============================================================================
+# Category 2: Forgetting Individual Summaries
+# ==============================================================================
+
+
+def test_forget_first_summary_keeps_second() -> None:
+ """Test that forgetting the first summary preserves the second summary.
+
+ Scenario:
+ - Condensation 1: adds summary A
+ - Condensation 2: adds summary B
+ - Condensation 3: forgets summary A
+ - Result: only summary B remains
+ """
+ messages = [message_event(f"Msg {i}") for i in range(3)]
+
+ condensation1 = Condensation(
+ id="cond-1",
+ forgotten_event_ids=[],
+ summary="Summary A",
+ summary_offset=0,
+ llm_response_id="cond_1",
+ )
+
+ condensation2 = Condensation(
+ id="cond-2",
+ forgotten_event_ids=[],
+ summary="Summary B",
+ summary_offset=2,
+ llm_response_id="cond_2",
+ )
+
+ # To forget summary A, we need its event ID. Using deterministic ID approach:
+ # summary_id = f"{condensation_id}_summary"
+ summary_a_id = "cond-1-summary"
+
+ condensation3 = Condensation(
+ id="cond-3",
+ forgotten_event_ids=[summary_a_id],
+ summary=None,
+ summary_offset=None,
+ llm_response_id="cond_3",
+ )
+
+ events = [
+ messages[0],
+ condensation1,
+ messages[1],
+ condensation2,
+ messages[2],
+ condensation3,
+ ]
+
+ view = View.from_events(events)
+
+ summary_events = [e for e in view.events if isinstance(e, CondensationSummaryEvent)]
+
+ assert len(summary_events) == 1, "Only summary B should remain"
+ assert summary_events[0].summary == "Summary B"
+
+
+def test_forget_middle_summary_keeps_others() -> None:
+ """Test forgetting a middle summary while keeping first and last summaries.
+
+ Scenario:
+ - Three summaries A, B, C
+ - Forget B
+ - A and C remain
+ """
+ messages = [message_event(f"Msg {i}") for i in range(4)]
+
+ condensation1 = Condensation(
+ id="cond-1",
+ forgotten_event_ids=[],
+ summary="Summary A",
+ summary_offset=0,
+ llm_response_id="cond_1",
+ )
+
+ condensation2 = Condensation(
+ id="cond-2",
+ forgotten_event_ids=[],
+ summary="Summary B",
+ summary_offset=2,
+ llm_response_id="cond_2",
+ )
+
+ condensation3 = Condensation(
+ id="cond-3",
+ forgotten_event_ids=[],
+ summary="Summary C",
+ summary_offset=4,
+ llm_response_id="cond_3",
+ )
+
+ summary_b_id = "cond-2-summary"
+
+ condensation4 = Condensation(
+ id="cond-4",
+ forgotten_event_ids=[summary_b_id],
+ summary=None,
+ llm_response_id="cond_4",
+ )
+
+ events = [
+ messages[0],
+ condensation1,
+ messages[1],
+ condensation2,
+ messages[2],
+ condensation3,
+ messages[3],
+ condensation4,
+ ]
+
+ view = View.from_events(events)
+
+ summary_events = [e for e in view.events if isinstance(e, CondensationSummaryEvent)]
+
+ assert len(summary_events) == 2, "Summaries A and C should remain"
+
+ summaries_text = [s.summary for s in summary_events]
+ assert "Summary A" in summaries_text
+ assert "Summary C" in summaries_text
+ assert "Summary B" not in summaries_text
+
+
+def test_forget_most_recent_summary() -> None:
+ """Test forgetting the most recently added summary.
+
+ Verifies that newer summaries can be forgotten, not just older ones.
+ """
+ messages = [message_event(f"Msg {i}") for i in range(2)]
+
+ condensation1 = Condensation(
+ id="cond-1",
+ forgotten_event_ids=[],
+ summary="Summary A",
+ summary_offset=0,
+ llm_response_id="cond_1",
+ )
+
+ condensation2 = Condensation(
+ id="cond-2",
+ forgotten_event_ids=[],
+ summary="Summary B",
+ summary_offset=1,
+ llm_response_id="cond_2",
+ )
+
+ summary_b_id = "cond-2-summary"
+
+ condensation3 = Condensation(
+ id="cond-3",
+ forgotten_event_ids=[summary_b_id],
+ summary=None,
+ llm_response_id="cond_3",
+ )
+
+ events = [
+ messages[0],
+ condensation1,
+ messages[1],
+ condensation2,
+ condensation3,
+ ]
+
+ view = View.from_events(events)
+
+ summary_events = [e for e in view.events if isinstance(e, CondensationSummaryEvent)]
+
+ assert len(summary_events) == 1, "Only summary A should remain"
+ assert summary_events[0].summary == "Summary A"
+
+
+def test_forget_summary_adjusts_later_summary_positions() -> None:
+ """Test that forgetting a summary correctly adjusts positions of later summaries.
+
+ When a summary is forgotten, the indices of events after it shift down by 1.
+ """
+ messages = [message_event(f"Msg {i}") for i in range(3)]
+
+ condensation1 = Condensation(
+ id="cond-1",
+ forgotten_event_ids=[],
+ summary="Summary at position 0",
+ summary_offset=0,
+ llm_response_id="cond_1",
+ )
+
+ condensation2 = Condensation(
+ id="cond-2",
+ forgotten_event_ids=[],
+ summary="Summary at position 2",
+ summary_offset=2,
+ llm_response_id="cond_2",
+ )
+
+ summary_1_id = "cond-1-summary"
+
+ condensation3 = Condensation(
+ id="cond-3",
+ forgotten_event_ids=[summary_1_id],
+ summary=None,
+ llm_response_id="cond_3",
+ )
+
+ events = [
+ messages[0],
+ condensation1,
+ messages[1],
+ condensation2,
+ messages[2],
+ condensation3,
+ ]
+
+ view = View.from_events(events)
+
+ # After forgetting first summary: [Msg 0, Summary at position 2, Msg 1, Msg 2]
+ # The second summary should now be at index 1
+ assert isinstance(view.events[1], CondensationSummaryEvent)
+ assert view.events[1].summary == "Summary at position 2"
+
+
+# ==============================================================================
+# Category 3: Forgetting Multiple Summaries
+# ==============================================================================
+
+
+def test_forget_multiple_summaries_simultaneously() -> None:
+ """Test a single condensation forgetting multiple summaries at once.
+
+ Scenario:
+ - Three summaries exist
+ - One condensation forgets two of them
+ - Only one summary remains
+ """
+ messages = [message_event(f"Msg {i}") for i in range(4)]
+
+ condensation1 = Condensation(
+ id="cond-1",
+ forgotten_event_ids=[],
+ summary="Summary A",
+ summary_offset=0,
+ llm_response_id="cond_1",
+ )
+
+ condensation2 = Condensation(
+ id="cond-2",
+ forgotten_event_ids=[],
+ summary="Summary B",
+ summary_offset=2,
+ llm_response_id="cond_2",
+ )
+
+ condensation3 = Condensation(
+ id="cond-3",
+ forgotten_event_ids=[],
+ summary="Summary C",
+ summary_offset=4,
+ llm_response_id="cond_3",
+ )
+
+ summary_a_id = "cond-1-summary"
+ summary_c_id = "cond-3-summary"
+
+ condensation4 = Condensation(
+ id="cond-4",
+ forgotten_event_ids=[summary_a_id, summary_c_id],
+ summary=None,
+ llm_response_id="cond_4",
+ )
+
+ events = [
+ messages[0],
+ condensation1,
+ messages[1],
+ condensation2,
+ messages[2],
+ condensation3,
+ messages[3],
+ condensation4,
+ ]
+
+ view = View.from_events(events)
+
+ summary_events = [e for e in view.events if isinstance(e, CondensationSummaryEvent)]
+
+ assert len(summary_events) == 1, "Only summary B should remain"
+ assert summary_events[0].summary == "Summary B"
+
+
+def test_forget_all_summaries() -> None:
+ """Test forgetting all summaries from a view.
+
+ After forgetting all summaries, view should contain only message events.
+ """
+ messages = [message_event(f"Msg {i}") for i in range(3)]
+
+ condensation1 = Condensation(
+ id="cond-1",
+ forgotten_event_ids=[],
+ summary="Summary A",
+ summary_offset=0,
+ llm_response_id="cond_1",
+ )
+
+ condensation2 = Condensation(
+ id="cond-2",
+ forgotten_event_ids=[],
+ summary="Summary B",
+ summary_offset=2,
+ llm_response_id="cond_2",
+ )
+
+ summary_a_id = "cond-1-summary"
+ summary_b_id = "cond-2-summary"
+
+ condensation3 = Condensation(
+ id="cond-3",
+ forgotten_event_ids=[summary_a_id, summary_b_id],
+ summary=None,
+ llm_response_id="cond_3",
+ )
+
+ events = [
+ messages[0],
+ condensation1,
+ messages[1],
+ condensation2,
+ messages[2],
+ condensation3,
+ ]
+
+ view = View.from_events(events)
+
+ summary_events = [e for e in view.events if isinstance(e, CondensationSummaryEvent)]
+
+ assert len(summary_events) == 0, "No summaries should remain"
+ assert len(view.events) == 3, "Only message events should remain"
+
+
+def test_sequential_condensations_each_forget_summary() -> None:
+ """Test multiple condensations each forgetting one summary.
+
+ Scenario:
+ - Create 3 summaries
+ - Condensation 4 forgets summary 1
+ - Condensation 5 forgets summary 2
+ - Only summary 3 remains
+ """
+ messages = [message_event(f"Msg {i}") for i in range(4)]
+
+ condensation1 = Condensation(
+ id="cond-1",
+ forgotten_event_ids=[],
+ summary="Summary 1",
+ summary_offset=0,
+ llm_response_id="cond_1",
+ )
+
+ condensation2 = Condensation(
+ id="cond-2",
+ forgotten_event_ids=[],
+ summary="Summary 2",
+ summary_offset=2,
+ llm_response_id="cond_2",
+ )
+
+ condensation3 = Condensation(
+ id="cond-3",
+ forgotten_event_ids=[],
+ summary="Summary 3",
+ summary_offset=4,
+ llm_response_id="cond_3",
+ )
+
+ summary_1_id = "cond-1-summary"
+ summary_2_id = "cond-2-summary"
+
+ condensation4 = Condensation(
+ id="cond-4",
+ forgotten_event_ids=[summary_1_id],
+ summary=None,
+ llm_response_id="cond_4",
+ )
+
+ condensation5 = Condensation(
+ id="cond-5",
+ forgotten_event_ids=[summary_2_id],
+ summary=None,
+ llm_response_id="cond_5",
+ )
+
+ events = [
+ messages[0],
+ condensation1,
+ messages[1],
+ condensation2,
+ messages[2],
+ condensation3,
+ messages[3],
+ condensation4,
+ condensation5,
+ ]
+
+ view = View.from_events(events)
+
+ summary_events = [e for e in view.events if isinstance(e, CondensationSummaryEvent)]
+
+ assert len(summary_events) == 1, "Only summary 3 should remain"
+ assert summary_events[0].summary == "Summary 3"
+
+
+# ==============================================================================
+# Category 4: Summary Identification Mechanism
+# ==============================================================================
+
+
+def test_summary_events_have_stable_identifiers() -> None:
+ """Test that summary event IDs are stable across view reconstructions.
+
+ This is the core requirement: if we construct the same view twice with the
+ same input events, summary events should have the same IDs both times.
+ """
+ messages = [message_event(f"Msg {i}") for i in range(2)]
+
+ condensation1 = Condensation(
+ id="stable-condensation",
+ forgotten_event_ids=[],
+ summary="Stable summary",
+ summary_offset=0,
+ llm_response_id="stable_condensation",
+ )
+
+ events = [messages[0], condensation1, messages[1]]
+
+ # Construct view first time
+ view1 = View.from_events(events)
+ summary1 = [e for e in view1.events if isinstance(e, CondensationSummaryEvent)][0]
+
+ # Construct view second time with same events
+ view2 = View.from_events(events)
+ summary2 = [e for e in view2.events if isinstance(e, CondensationSummaryEvent)][0]
+
+ assert summary1.id == summary2.id, (
+ "Summary event ID should be stable across reconstructions"
+ )
+
+ # Verify the ID follows the expected pattern
+ expected_id = "stable-condensation-summary"
+ assert summary1.id == expected_id, f"Summary ID should be {expected_id}"
+
+
+def test_condensation_tracks_its_summary_event() -> None:
+ """Test that we can determine which condensation created which summary.
+
+ This might be through ID conventions or explicit tracking.
+ """
+ messages = [message_event(f"Msg {i}") for i in range(3)]
+
+ condensation1 = Condensation(
+ id="cond-A",
+ forgotten_event_ids=[],
+ summary="First",
+ summary_offset=0,
+ llm_response_id="cond_A",
+ )
+
+ condensation2 = Condensation(
+ id="cond-B",
+ forgotten_event_ids=[],
+ summary="Second",
+ summary_offset=2,
+ llm_response_id="cond_B",
+ )
+
+ events = [
+ messages[0],
+ condensation1,
+ messages[1],
+ condensation2,
+ messages[2],
+ ]
+
+ view = View.from_events(events)
+
+ summary_events = [e for e in view.events if isinstance(e, CondensationSummaryEvent)]
+
+ # Verify we can identify which summary came from which condensation
+ summary_1 = [s for s in summary_events if s.summary == "First"][0]
+ summary_2 = [s for s in summary_events if s.summary == "Second"][0]
+
+ assert summary_1.id == "cond-A-summary"
+ assert summary_2.id == "cond-B-summary"
+
+
+def test_can_reference_summary_from_previous_condensation() -> None:
+ """Test the core use case: referencing a summary created by an earlier condensation.
+
+ This verifies that the identification mechanism enables forgetting summaries.
+ """
+ messages = [message_event(f"Msg {i}") for i in range(2)]
+
+ # First condensation creates a summary
+ condensation1 = Condensation(
+ id="cond-1",
+ forgotten_event_ids=[],
+ summary="To be forgotten",
+ summary_offset=0,
+ llm_response_id="cond_original",
+ )
+
+ events_before_forgetting = [messages[0], condensation1, messages[1]]
+ view_before = View.from_events(events_before_forgetting)
+
+ # Find the summary's ID
+ summary_event = [
+ e for e in view_before.events if isinstance(e, CondensationSummaryEvent)
+ ][0]
+ summary_id = summary_event.id
+
+ # Second condensation references and forgets that summary
+ condensation2 = Condensation(
+ id="cond-2",
+ forgotten_event_ids=[summary_id],
+ summary="New summary",
+ summary_offset=0,
+ llm_response_id="cond_new",
+ )
+
+ events_after_forgetting = [messages[0], condensation1, messages[1], condensation2]
+ view_after = View.from_events(events_after_forgetting)
+
+ summary_events = [
+ e for e in view_after.events if isinstance(e, CondensationSummaryEvent)
+ ]
+
+ # Old summary should be gone, new summary should be present
+ assert len(summary_events) == 1
+ assert summary_events[0].summary == "New summary"
+
+
+# ==============================================================================
+# Category 5: Offset Behavior
+# ==============================================================================
+
+
+def test_summary_offset_is_absolute_in_final_view() -> None:
+ """Test that summary_offset refers to the absolute position in the final view.
+
+ After events are forgotten, the offset should place the summary at that exact
+ index in the resulting event list.
+ """
+ messages = [message_event(f"Msg {i}") for i in range(5)]
+
+ condensation1 = Condensation(
+ id="cond-1",
+ forgotten_event_ids=[messages[0].id, messages[1].id],
+ summary="Summary at offset 1",
+ summary_offset=1,
+ llm_response_id="cond_1",
+ )
+
+ events = [
+ messages[0],
+ messages[1],
+ messages[2],
+ condensation1,
+ messages[3],
+ messages[4],
+ ]
+
+ view = View.from_events(events)
+
+ # After forgetting events 0 and 1: [Msg 2, Msg 3, Msg 4]
+ # Summary at offset 1 should be between Msg 2 and Msg 3
+ # Expected: [Msg 2, Summary, Msg 3, Msg 4]
+
+ assert len(view.events) == 4
+ assert isinstance(view.events[0], MessageEvent)
+ assert isinstance(view.events[0].llm_message.content[0], TextContent)
+ assert view.events[0].llm_message.content[0].text == "Msg 2"
+
+ assert isinstance(view.events[1], CondensationSummaryEvent)
+ assert view.events[1].summary == "Summary at offset 1"
+
+ assert isinstance(view.events[2], MessageEvent)
+ assert isinstance(view.events[2].llm_message.content[0], TextContent)
+ assert view.events[2].llm_message.content[0].text == "Msg 3"
+
+
+def test_summary_offset_zero_inserts_at_beginning() -> None:
+ """Test that offset=0 inserts summary at the very beginning of the view."""
+ messages = [message_event(f"Msg {i}") for i in range(3)]
+
+ condensation1 = Condensation(
+ id="cond-1",
+ forgotten_event_ids=[],
+ summary="At the start",
+ summary_offset=0,
+ llm_response_id="cond_1",
+ )
+
+ events = [messages[0], condensation1, messages[1], messages[2]]
+
+ view = View.from_events(events)
+
+ assert isinstance(view.events[0], CondensationSummaryEvent)
+ assert view.events[0].summary == "At the start"
+
+
+def test_summary_offset_at_end_of_events() -> None:
+ """Test that summary can be inserted at the end of the event list."""
+ messages = [message_event(f"Msg {i}") for i in range(3)]
+
+ condensation1 = Condensation(
+ id="cond-1",
+ forgotten_event_ids=[],
+ summary="At the end",
+ summary_offset=3, # After all 3 messages
+ llm_response_id="cond_1",
+ )
+
+ events = [messages[0], messages[1], messages[2], condensation1]
+
+ view = View.from_events(events)
+
+ assert len(view.events) == 4
+ assert isinstance(view.events[3], CondensationSummaryEvent)
+ assert view.events[3].summary == "At the end"
+
+
+def test_multiple_summaries_with_same_offset() -> None:
+ """Test behavior when multiple summaries have the same offset.
+
+ This is an edge case that tests how the system handles offset collisions.
+ Expected: summaries are inserted in the order they were created.
+ """
+ messages = [message_event(f"Msg {i}") for i in range(2)]
+
+ condensation1 = Condensation(
+ id="cond-1",
+ forgotten_event_ids=[],
+ summary="First at offset 1",
+ summary_offset=1,
+ llm_response_id="cond_1",
+ )
+
+ condensation2 = Condensation(
+ id="cond-2",
+ forgotten_event_ids=[],
+ summary="Second at offset 1",
+ summary_offset=1,
+ llm_response_id="cond_2",
+ )
+
+ events = [messages[0], condensation1, condensation2, messages[1]]
+
+ view = View.from_events(events)
+
+ # Both summaries should be in the view
+ summary_events = [e for e in view.events if isinstance(e, CondensationSummaryEvent)]
+ assert len(summary_events) == 2
+
+ # When inserting at the same offset, later insertions appear before earlier ones
+ # (standard list.insert() behavior)
+ summaries_in_order = [s.summary for s in summary_events]
+ assert summaries_in_order[0] == "Second at offset 1"
+ assert summaries_in_order[1] == "First at offset 1"
+
+
+# ==============================================================================
+# Category 6: Integration with Event Forgetting
+# ==============================================================================
+
+
+def test_forget_events_and_summary_together() -> None:
+ """Test a condensation that forgets both regular events and a summary.
+
+ Verifies that summaries can be forgotten alongside regular events in the
+ same condensation.
+ """
+ messages = [message_event(f"Msg {i}") for i in range(4)]
+
+ condensation1 = Condensation(
+ id="cond-1",
+ forgotten_event_ids=[],
+ summary="Old summary",
+ summary_offset=1,
+ llm_response_id="cond_1",
+ )
+
+ old_summary_id = "cond-1-summary"
+
+ condensation2 = Condensation(
+ id="cond-2",
+ forgotten_event_ids=[messages[0].id, messages[2].id, old_summary_id],
+ summary="New summary",
+ summary_offset=0,
+ llm_response_id="cond_2",
+ )
+
+ events = [
+ messages[0],
+ messages[1],
+ condensation1,
+ messages[2],
+ messages[3],
+ condensation2,
+ ]
+
+ view = View.from_events(events)
+
+ # Should have forgotten: Msg 0, Msg 2, old summary
+ # Should remain: Msg 1, Msg 3, new summary
+ summary_events = [e for e in view.events if isinstance(e, CondensationSummaryEvent)]
+
+ assert len(summary_events) == 1
+ assert summary_events[0].summary == "New summary"
+
+ message_events_in_view = [e for e in view.events if isinstance(e, MessageEvent)]
+ assert len(message_events_in_view) == 2
+
+
+def test_summary_offset_remains_valid_after_forgetting_events() -> None:
+ """Test that summary offsets work correctly when events before them are forgotten.
+
+ When earlier events are removed, the summary offset should still place the
+ summary at the correct position in the resulting view.
+ """
+ messages = [message_event(f"Msg {i}") for i in range(5)]
+
+ # Forget first two messages, add summary at offset 2
+ condensation1 = Condensation(
+ id="cond-1",
+ forgotten_event_ids=[messages[0].id, messages[1].id],
+ summary="Summary after forgetting",
+ summary_offset=2,
+ llm_response_id="cond_1",
+ )
+
+ events = [
+ messages[0],
+ messages[1],
+ messages[2],
+ messages[3],
+ condensation1,
+ messages[4],
+ ]
+
+ view = View.from_events(events)
+
+ # After forgetting: [Msg 2, Msg 3, Msg 4]
+ # Summary at offset 2 should be after Msg 3
+ # Expected: [Msg 2, Msg 3, Summary, Msg 4]
+
+ assert len(view.events) == 4
+ assert isinstance(view.events[2], CondensationSummaryEvent)
+ assert view.events[2].summary == "Summary after forgetting"
+
+
+def test_interleaved_events_and_summaries() -> None:
+ """Test complex scenario with events and summaries interleaved.
+
+ Scenario:
+ - Messages and summaries interleaved
+ - Some messages forgotten
+ - Verify final view has correct structure
+ """
+ messages = [message_event(f"Msg {i}") for i in range(6)]
+
+ condensation1 = Condensation(
+ id="cond-1",
+ forgotten_event_ids=[messages[1].id],
+ summary="Summary A",
+ summary_offset=1,
+ llm_response_id="cond_1",
+ )
+
+ condensation2 = Condensation(
+ id="cond-2",
+ forgotten_event_ids=[messages[3].id],
+ summary="Summary B",
+ summary_offset=3,
+ llm_response_id="cond_2",
+ )
+
+ events = [
+ messages[0],
+ messages[1],
+ condensation1,
+ messages[2],
+ messages[3],
+ condensation2,
+ messages[4],
+ messages[5],
+ ]
+
+ view = View.from_events(events)
+
+ # Messages 1 and 3 forgotten
+ # Remaining: Msg 0, Msg 2, Msg 4, Msg 5 + Summary A, Summary B
+ # Expected: [Msg 0, Summary A, Msg 2, Summary B, Msg 4, Msg 5]
+
+ assert len(view.events) == 6
+
+ assert isinstance(view.events[0], MessageEvent)
+ assert isinstance(view.events[0].llm_message.content[0], TextContent)
+ assert view.events[0].llm_message.content[0].text == "Msg 0"
+
+ assert isinstance(view.events[1], CondensationSummaryEvent)
+ assert view.events[1].summary == "Summary A"
+
+ assert isinstance(view.events[2], MessageEvent)
+ assert isinstance(view.events[2].llm_message.content[0], TextContent)
+ assert view.events[2].llm_message.content[0].text == "Msg 2"
+
+ assert isinstance(view.events[3], CondensationSummaryEvent)
+ assert view.events[3].summary == "Summary B"
+
+
+# ==============================================================================
+# Category 7: Edge Cases
+# ==============================================================================
+
+
+def test_condensation_without_summary_no_summary_event_created() -> None:
+ """Test that condensations without summaries don't create summary events.
+
+ Not all condensations have summaries - verify this still works.
+ """
+ messages = [message_event(f"Msg {i}") for i in range(3)]
+
+ condensation1 = Condensation(
+ id="cond-1",
+ forgotten_event_ids=[messages[1].id],
+ summary=None, # No summary
+ summary_offset=None,
+ llm_response_id="cond_1",
+ )
+
+ events = [messages[0], messages[1], condensation1, messages[2]]
+
+ view = View.from_events(events)
+
+ summary_events = [e for e in view.events if isinstance(e, CondensationSummaryEvent)]
+
+ assert len(summary_events) == 0, "No summary should be created"
+ assert len(view.events) == 2, "Only Msg 0 and Msg 2 should remain"
+
+
+def test_empty_view_with_only_summaries() -> None:
+ """Test edge case where all regular events are forgotten, only summaries remain.
+
+ Verifies that a view can consist entirely of summary events.
+ """
+ messages = [message_event(f"Msg {i}") for i in range(3)]
+
+ condensation1 = Condensation(
+ id="cond-1",
+ forgotten_event_ids=[messages[0].id, messages[1].id, messages[2].id],
+ summary="Only summary remains",
+ summary_offset=0,
+ llm_response_id="cond_1",
+ )
+
+ events = [messages[0], messages[1], messages[2], condensation1]
+
+ view = View.from_events(events)
+
+ assert len(view.events) == 1
+ assert isinstance(view.events[0], CondensationSummaryEvent)
+ assert view.events[0].summary == "Only summary remains"
+
+
+def test_forget_nonexistent_summary_is_noop() -> None:
+ """Test that trying to forget a non-existent summary doesn't cause errors.
+
+ Graceful handling of invalid summary references.
+ """
+ messages = [message_event(f"Msg {i}") for i in range(2)]
+
+ condensation1 = Condensation(
+ id="cond-1",
+ forgotten_event_ids=[],
+ summary="Existing summary",
+ summary_offset=0,
+ llm_response_id="cond_1",
+ )
+
+ # Try to forget a summary that doesn't exist
+ condensation2 = Condensation(
+ id="cond-2",
+ forgotten_event_ids=["nonexistent_summary_id"],
+ summary=None,
+ llm_response_id="cond_2",
+ )
+
+ events = [messages[0], condensation1, messages[1], condensation2]
+
+ view = View.from_events(events)
+
+ # Existing summary should still be there
+ summary_events = [e for e in view.events if isinstance(e, CondensationSummaryEvent)]
+
+ assert len(summary_events) == 1
+ assert summary_events[0].summary == "Existing summary"
+
+
+def test_multiple_condensations_same_summary_offset() -> None:
+ """Test multiple condensations each trying to insert at the same offset.
+
+ Verifies that when condensations are processed sequentially, each can
+ specify the same offset and they get inserted in order.
+ """
+ messages = [message_event(f"Msg {i}") for i in range(2)]
+
+ condensation1 = Condensation(
+ id="cond-1",
+ forgotten_event_ids=[],
+ summary="First at 1",
+ summary_offset=1,
+ llm_response_id="cond_1",
+ )
+
+ condensation2 = Condensation(
+ id="cond-2",
+ forgotten_event_ids=[],
+ summary="Second at 1",
+ summary_offset=1,
+ llm_response_id="cond_2",
+ )
+
+ condensation3 = Condensation(
+ id="cond-3",
+ forgotten_event_ids=[],
+ summary="Third at 1",
+ summary_offset=1,
+ llm_response_id="cond_3",
+ )
+
+ events = [
+ messages[0],
+ condensation1,
+ condensation2,
+ condensation3,
+ messages[1],
+ ]
+
+ view = View.from_events(events)
+
+ # All three summaries should be present
+ summary_events = [e for e in view.events if isinstance(e, CondensationSummaryEvent)]
+
+ assert len(summary_events) == 3
+
+ # Verify they maintain insertion order
+ summaries_text = [s.summary for s in summary_events]
+ assert "First at 1" in summaries_text
+ assert "Second at 1" in summaries_text
+ assert "Third at 1" in summaries_text
diff --git a/tests/sdk/context/test_view_non_exec_filtering.py b/tests/sdk/context/test_view_non_exec_filtering.py
index 928f7d4808..f6ef0f1d17 100644
--- a/tests/sdk/context/test_view_non_exec_filtering.py
+++ b/tests/sdk/context/test_view_non_exec_filtering.py
@@ -48,7 +48,7 @@ def test_filter_keeps_action_none_when_matched_by_observation() -> None:
events = [m1, action_event, err, m2]
- filtered = View.filter_unmatched_tool_calls(events) # type: ignore[arg-type]
+ filtered = View._filter_unmatched_tool_calls(events, events) # type: ignore[arg-type]
# Both ActionEvent(action=None) and matching AgentErrorEvent must be kept
assert len(filtered) == 4