# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """L2/L3 history chunk compression (history redesign §3.3, M3). Covers: the mechanical band-③ action ledger (per-step lines, FA recovery sub-lines, verbatim never-evict user injections), the band-② interval coverage machine check, the three-band chunk block structure, all triggers (real milestone switch via stamped-hash change, pseudo-switch rollback protection, segment size, fake-meter soft/hard thresholds), the sliding-window floor, the tool-pair invariant at the F/A boundary, frozen-block byte stability between compression events, checkpoint post-hoc annotation version bumps, era merging, the extreme-layer recall-only period paragraph (step range + start/end offsets + mechanically assembled synopsis, §10 decision 5 final review), and step addressability (step number + ``T+mm:ss``) after every compression level — degrading to period addressability at the extreme layer only. """ import json import re from types import SimpleNamespace import pytest from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from artemis.memory.chunking import ( CHUNK_PENDING_NOTE, ChunkState, EraState, HistoryChunkManager, StepCapsuleLens, build_action_ledger, render_era_block, render_era_period_paragraph, validate_interval_coverage, ) from artemis.memory.step_memory import StepMemoryService from artemis.memory.transcript import TranscriptLedger SESSION_START = 1000.0 # --------------------------------------------------------------------------- # Fixtures / helpers # --------------------------------------------------------------------------- def _step(i: int, subgoal: str = "hash-a", **overrides) -> dict: step = { "step_id": f"s{i}", "step_number": i, "timestamp": SESSION_START + 10.0 * i, "summary": f"Visual transition of step {i}.", "action_taken": {"action": "tap", "target_text": f"btn{i}", "coordinates": [i, i]}, "last_execution_result": {"status": "success"}, "operator_raw_thinking": f"thinking {i}", "interleaved_events": [], "extra_metadata": {"subgoal_hash": subgoal}, } step.update(overrides) return step class FakeEngine: def __init__(self, steps: list[dict], base_dir=None): self.steps = steps self.session_start_time = SESSION_START self.current_session_id = "session-1" self.chunk_writes: list[dict] = [] if base_dir is not None: self.base_dir = base_dir def get_agent_friendly_steps(self): return self.steps def record_history_chunk(self, **kwargs): self.chunk_writes.append(kwargs) return f"chunk-row-{len(self.chunk_writes)}" class StubCapsuleService(StepMemoryService): """Records capsule submissions without spawning asyncio tasks (sync tests). ``auto_resolve=True`` makes every submitted capsule ready immediately, so the ready-gated swap fires at the same render that closed the segment — the shape trigger/era/L3 tests need. The default (manual) form keeps capsules pending until ``resolve`` is called, which is the gating tests' lever. """ def __init__(self, auto_resolve: bool = False): super().__init__(ctx=None) self.submitted: list[str] = [] self.auto_resolve = auto_resolve def submit(self, key, payload, aliases=()): self.submitted.append(str(key)) self._step_inputs[key] = payload self._failed.discard(key) if self.auto_resolve: self.resolve(key, _capsule(payload["start_step"], payload["end_step"])) def resolve(self, key, capsule: dict): self._summaries[key] = json.dumps(capsule, ensure_ascii=False) def _capsule(start: int, end: int) -> dict: return { "doing": "Working toward the login milestone.", "did": "Walked the SMS-code login path", "effect": "Logged-in state; note notes/login_flow.md records the code entry path.", "entry_state": "Home screen, logged out", "exit_state": "Settings page open", "verified_facts": [f"fact-{start}"], "unresolved": ["u1"], "failed_paths": [], "important_entities": ["account A"], "intervals": [ {"start_step": start, "end_step": end, "text": "carried out the segment's taps"} ], } def _observation(i: int) -> HumanMessage: return HumanMessage( content=[ {"type": "text", "text": f"# CURRENT OBSERVATION [T+00:{i % 60:02d}]"}, {"type": "text", "text": "--- Current Screenshot ---"}, {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,IMG_{i}"}}, ] ) def _turn(i: int) -> list: tool_call = {"name": "click", "args": {"target": i}, "id": f"tc{i}", "type": "tool_call"} return [ _observation(i), AIMessage(content=f"thinking {i}", tool_calls=[tool_call]), ToolMessage(tool_call_id=f"tc{i}", content="Action Recorded"), ] def _make( steps: list[dict], *, min_active: int = 2, max_steps: int = 12, target_tokens: int = 10**9, max_chunks: int = 8, max_eras: int | None = None, meter=None, auto_capsule: bool = False, ): ledger = TranscriptLedger(step_memory=StepMemoryService(ctx=None)) engine = FakeEngine(steps) capsule = StubCapsuleService(auto_resolve=auto_capsule) chunker = HistoryChunkManager( engine=engine, chunking_config=SimpleNamespace( max_steps=max_steps, target_source_tokens=target_tokens, model="test-model", max_chunks=max_chunks, max_eras=max_eras, ), transcript_config=SimpleNamespace( context_budget_tokens=100_000, soft_ratio=0.7, hard_ratio=0.9, min_active_steps=min_active, ), capsule_service=capsule, meter_getter=meter or (lambda: None), goal="TEST GOAL", ) ledger.attach_chunker(chunker) return ledger, chunker, engine, capsule def _run_turns(ledger, chunker, start: int, upto: int, hash_for=None): """Mimic the real per-turn ordering: commit previous → render (chunker hook) → stage this turn → stamp this turn's step (execution_check).""" hash_for = hash_for or (lambda i: "hash-a") rendered = None for i in range(start, upto + 1): ledger.commit_staged( step_key=f"s{i - 1}" if i > 1 else None, validator_result={"status": "success"} if i > 1 else None, ) rendered = ledger.render([_observation(i)]) ledger.stage_turn(_turn(i)) chunker.on_step_stamped(f"s{i}", hash_for(i)) return rendered def _rendered_text(messages) -> str: return "\n".join(str(getattr(m, "content", "")) for m in messages) def _frozen_text(ledger) -> str: return "\n".join(str(m.content) for m in ledger.frozen_blocks) # --------------------------------------------------------------------------- # Band ③ — mechanical action ledger # --------------------------------------------------------------------------- def test_action_ledger_lines_carry_step_number_offset_action_and_result(): steps = [_step(1), _step(2, last_execution_result={"status": "failed", "error": "boom"})] ledger_text = build_action_ledger(steps, SESSION_START) lines = ledger_text.splitlines() assert lines[0] == "- Step 1 (T+00:10): Tapped 'btn1' at [1, 1] -> executed" assert lines[1].startswith("- Step 2 (T+00:20): Tapped 'btn2' at [2, 2] -> Error: boom") def test_action_ledger_renders_fa_recovery_sublines_indented(): steps = [ _step( 3, interleaved_events=[ { "type": "tool_call", "name": "_exec_click", "args": {"target": [5, 6], "target_text": "Retry"}, "result": "ok", }, {"type": "tool_call", "name": "report_failure_analysis", "args": {}, "result": ""}, ], ) ] text = build_action_ledger(steps, SESSION_START) lines = text.splitlines() assert lines[0].startswith("- Step 3 ") assert lines[1].startswith(" FA: Tapped 'Retry'") assert "report_failure_analysis" not in text def test_action_ledger_preserves_injected_instruction_verbatim(): steps = [ _step(4, extra_metadata={"subgoal_hash": "h", "injected_instruction": "跳过弹窗,直接登录"}) ] full = build_action_ledger(steps, SESSION_START) minimal = build_action_ledger(steps, SESSION_START, minimal=True) expected = ' User @ Step 4: "跳过弹窗,直接登录"' assert expected in full assert expected in minimal # never-evict at every width def test_action_ledger_minimal_width_keeps_step_and_time_addressability(): steps = [_step(i) for i in range(1, 4)] minimal = build_action_ledger(steps, SESSION_START, minimal=True) for i in range(1, 4): assert f"- Step {i} (T+00:{10 * i:02d}): Tapped 'btn{i}'" in minimal assert "-> executed" not in minimal # --------------------------------------------------------------------------- # Band ② — machine-checked coverage + capsule parsing # --------------------------------------------------------------------------- def test_interval_coverage_requires_seamless_union(): ok = [{"start_step": 3, "end_step": 5, "text": "a"}, {"start_step": 6, "end_step": 6, "text": "b"}] assert validate_interval_coverage(ok, 3, 6) gap = [{"start_step": 3, "end_step": 4, "text": "a"}, {"start_step": 6, "end_step": 6, "text": "b"}] assert not validate_interval_coverage(gap, 3, 6) overlap = [{"start_step": 3, "end_step": 5, "text": "a"}, {"start_step": 5, "end_step": 6, "text": "b"}] assert not validate_interval_coverage(overlap, 3, 6) short = [{"start_step": 3, "end_step": 5, "text": "a"}] assert not validate_interval_coverage(short, 3, 6) assert not validate_interval_coverage([], 3, 6) def test_capsule_parse_rejects_coverage_gap_and_accepts_full_cover(): lens = StepCapsuleLens(model_name="test", llm=object()) payload = {"start_step": 3, "end_step": 6} good = json.dumps(_capsule(3, 6)) parsed = lens.parse_capsule(good, payload) assert parsed is not None assert parsed["verified_facts"] == ["fact-3"] bad = _capsule(3, 6) bad["intervals"] = [{"start_step": 3, "end_step": 4, "text": "partial"}] assert lens.parse_capsule(json.dumps(bad), payload) is None assert lens.parse_capsule("not json", payload) is None @pytest.mark.asyncio async def test_capsule_lens_render_returns_none_on_gap_so_service_retries(): class GapLLM: async def ainvoke(self, messages): capsule = _capsule(1, 2) capsule["intervals"] = [{"start_step": 1, "end_step": 1, "text": "only one"}] return SimpleNamespace(content=json.dumps(capsule)) lens = StepCapsuleLens(model_name="test", llm=GapLLM()) payload = {"start_step": 1, "end_step": 2, "steps": []} assert await lens.render("chunk:1-2", payload) is None class FullLLM: async def ainvoke(self, messages): return SimpleNamespace(content=json.dumps(_capsule(1, 2))) lens_ok = StepCapsuleLens(model_name="test", llm=FullLLM()) result = await lens_ok.render("chunk:1-2", payload) assert result is not None and json.loads(result)["doing"] # --------------------------------------------------------------------------- # Triggers # --------------------------------------------------------------------------- def _hashes(boundary_after: int): return lambda i: "hash-a" if i <= boundary_after else "hash-b" def test_milestone_switch_closes_previous_segment_whole_and_gates_swap(): """Ready-gated swap (user decision 2026-09-01): a milestone switch closes the previous segment whole and dispatches its capsule, but the original turns stay in the transcript until the capsule header is ready; the swap happens at the first render after readiness.""" steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] ledger, chunker, engine, capsule = _make(steps, min_active=2) rendered = _run_turns(ledger, chunker, 1, 8, _hashes(4)) # Closed whole as ONE awaiting chunk; NOT swapped (capsule pending). assert len(chunker.chunks) == 0 assert len(chunker.awaiting_chunks) == 1 chunk = chunker.awaiting_chunks[0] assert (chunk.start_step_number, chunk.end_step_number) == (1, 4) assert chunk.subgoal_hash == "hash-a" assert chunk.status == "pending" # v1 pending row persisted with band ③ (recall/auxiliary views see it). assert engine.chunk_writes[0]["version"] == 1 assert engine.chunk_writes[0]["status"] == "pending" assert "- Step 1 (T+00:10)" in engine.chunk_writes[0]["band3"] # Capsule dispatched in the background. assert capsule.submitted == ["chunk:1-4"] # Original history retained verbatim: no chunk block, no frozen region, # turn 1's observation still live in the rendered transcript. text = _rendered_text(rendered) assert "[Chunk 1" not in text assert ledger.frozen_blocks == () assert "# CURRENT OBSERVATION [T+00:01]" in text # Capsule ready → the next render swaps: frozen ①②③ block, originals out. capsule.resolve("chunk:1-4", _capsule(1, 4)) ledger.commit_staged(step_key="s8", validator_result={"status": "success"}) rendered_after = ledger.render([_observation(9)]) assert len(chunker.chunks) == 1 assert chunker.awaiting_chunks == () frozen = _frozen_text(ledger) assert "[Chunk 1 | Steps 1–4" in frozen assert "① Synopsis & effects" in frozen assert "③ Step action ledger" in frozen assert CHUNK_PENDING_NOTE not in frozen assert "# CURRENT OBSERVATION [T+00:01]" not in _rendered_text(rendered_after) # The ready write bumped the DB version (append-only). ready_rows = [w for w in engine.chunk_writes if w["status"] == "ready"] assert ready_rows and ready_rows[0]["version"] == 2 def test_plan_write_hint_without_stamp_change_is_discarded(): """Pseudo-switch protection: a vetoed+rolled-back plan write queues a hint that the next stamped step (same hash) discards — no chunk is created.""" steps = [_step(i) for i in range(1, 9)] ledger, chunker, engine, capsule = _make(steps, min_active=2) _run_turns(ledger, chunker, 1, 3) chunker.queue_boundary_hint() # plan write claimed a completion... assert chunker.boundary_hint_pending _run_turns(ledger, chunker, 4, 8) # ...but stamps never change assert not chunker.boundary_hint_pending assert len(chunker.chunks) == 0 assert engine.chunk_writes == [] assert ledger.frozen_blocks == () def test_segment_size_threshold_triggers_without_milestone(): steps = [_step(i) for i in range(1, 10)] ledger, chunker, engine, _ = _make(steps, min_active=2, max_steps=4, auto_capsule=True) _run_turns(ledger, chunker, 1, 9) # Single milestone, but the open segment exceeded max_steps. assert len(chunker.chunks) >= 1 assert chunker.chunks[0].start_step_number == 1 total = sum( c.end_step_number - c.start_step_number + 1 for c in chunker.chunks ) assert total >= 4 assert len(ledger.unchunked_turns()) >= 2 # floor respected def test_source_token_threshold_triggers_chunk(): steps = [_step(i) for i in range(1, 8)] ledger, chunker, _, _ = _make( steps, min_active=2, max_steps=100, target_tokens=10, auto_capsule=True ) _run_turns(ledger, chunker, 1, 7) assert len(chunker.chunks) >= 1 def test_soft_threshold_compresses_oldest_open_segment(): steps = [_step(i) for i in range(1, 9)] meter = {"value": None} ledger, chunker, _, _ = _make( steps, min_active=2, max_steps=100, meter=lambda: meter["value"], auto_capsule=True ) _run_turns(ledger, chunker, 1, 6) assert len(chunker.chunks) == 0 # nothing due yet meter["value"] = 75_000 # ≥ 0.7 * 100k, < 0.9 * 100k _run_turns(ledger, chunker, 7, 7) assert len(chunker.chunks) == 1 assert chunker.chunks[0].start_step_number == 1 def test_min_active_floor_blocks_all_triggers(): steps = [_step(i, "hash-a" if i <= 2 else "hash-b") for i in range(1, 6)] ledger, chunker, engine, _ = _make( steps, min_active=5, max_steps=2, meter=lambda: 95_000 ) _run_turns(ledger, chunker, 1, 6, _hashes(2)) # Only 5 committed turns; the floor keeps everything raw. assert len(chunker.chunks) == 0 assert chunker.awaiting_chunks == () assert engine.chunk_writes == [] def test_compression_boundary_never_splits_tool_call_pairs(): steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] ledger, chunker, _, _ = _make(steps, min_active=2, auto_capsule=True) _run_turns(ledger, chunker, 1, 8, _hashes(4)) assert len(chunker.chunks) == 1 rendered = ledger.render([_observation(99)]) # Walk the rendered list: every AIMessage with tool_calls must be # immediately followed by its ToolMessages. for idx, msg in enumerate(rendered): if isinstance(msg, AIMessage) and msg.tool_calls: following = rendered[idx + 1 : idx + 1 + len(msg.tool_calls)] assert [getattr(m, "tool_call_id", None) for m in following] == [ tc["id"] for tc in msg.tool_calls ] # And no orphan ToolMessage right after the frozen blocks. first_active = next( (m for m in rendered if isinstance(m, (AIMessage, ToolMessage, HumanMessage)) and "# CURRENT OBSERVATION" in str(getattr(m, "content", ""))), None, ) assert first_active is not None # --------------------------------------------------------------------------- # Async capsule discipline: ready-gated swap, byte-stable frozen region # --------------------------------------------------------------------------- def test_frozen_region_stays_byte_stable_between_swap_events(): """A pending closed segment never mutates the frozen region (its original turns stay live); the region re-renders only when a swap event fires.""" steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 13)] ledger, chunker, engine, capsule = _make(steps, min_active=2) _run_turns(ledger, chunker, 1, 8, _hashes(4)) # Swap chunk 1 (Steps 1–4) once its capsule is ready. capsule.resolve("chunk:1-4", _capsule(1, 4)) ledger.commit_staged(step_key="s8", validator_result={"status": "success"}) ledger.render([_observation(9)]) assert len(chunker.chunks) == 1 frozen_before = _frozen_text(ledger) assert "① Synopsis & effects" in frozen_before assert "② Compressed step summary" in frozen_before assert "- Steps 1–4: carried out the segment's taps" in frozen_before # A hash-c stretch closes the hash-b segment — capsule pending, so the # frozen text stays byte-identical and the pending chunk does not swap. ledger.stage_turn(_turn(9)) chunker.on_step_stamped("s9", "hash-c") _run_turns(ledger, chunker, 10, 12, lambda i: "hash-c") assert len(chunker.awaiting_chunks) == 1 pending = chunker.awaiting_chunks[0] assert pending.status == "pending" assert _frozen_text(ledger) == frozen_before # Its capsule readies: the next render swaps and re-renders the region. capsule.resolve(pending.capsule_key, _capsule(5, 8)) ledger.commit_staged(step_key="s12", validator_result={"status": "success"}) ledger.render([_observation(13)]) assert len(chunker.chunks) == 2 frozen_after = _frozen_text(ledger) assert frozen_after != frozen_before assert "[Chunk 2" in frozen_after # Ready writes bumped the DB versions (append-only). ready_rows = [w for w in engine.chunk_writes if w["status"] == "ready"] assert ready_rows and all(w["version"] == 2 for w in ready_rows) def test_chunk_block_renders_three_bands_in_order(): steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 13)] ledger, chunker, _, capsule = _make(steps, min_active=2) _run_turns(ledger, chunker, 1, 8, _hashes(4)) capsule.resolve("chunk:1-4", _capsule(1, 4)) _run_turns(ledger, chunker, 9, 12, lambda i: "hash-c") # second event block = next( str(m.content) for m in ledger.frozen_blocks if "[Chunk 1" in str(m.content) ) # §3.3 structure: header, then ① → ② → ③ strictly in order. i1 = block.index("① Synopsis & effects") i2 = block.index("② Compressed step summary") i3 = block.index("③ Step action ledger") assert 0 < i1 < i2 < i3 assert "What this segment was doing:" in block assert "What was actually done:" in block assert "Effects / left behind:" in block # The effect line carries the notes left behind (from the capsule). assert "notes/login_flow.md" in block assert "Entry: Home screen, logged out" in block assert "Exit: Settings page open" in block assert "Verified: fact-1" in block # ② lines carry step references; ③ is the untouched mechanical ledger. assert "- Steps 1–4:" in block assert "- Step 1 (T+00:10): Tapped 'btn1' at [1, 1] -> executed" in block assert " ago" not in block # --------------------------------------------------------------------------- # Checkpoint post-hoc annotation # --------------------------------------------------------------------------- def test_checkpoint_annotation_bumps_version_and_rerenders_at_next_event(): steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 13)] ledger, chunker, engine, capsule = _make(steps, min_active=2) _run_turns(ledger, chunker, 1, 8, _hashes(4)) # Ready gating: the closed segment is still awaiting its capsule — # post-hoc annotations are independent of the header and land anyway. chunk = chunker.awaiting_chunks[0] version_before = chunk.version frozen_before = _frozen_text(ledger) annotated = chunker.annotate_from_checkpoint( "hash-a", [{"kind": "verify", "item_text": "logged in", "status": "failed", "evidence": "no session"}], ) assert annotated assert chunk.version == version_before + 1 # DB write is immediate... assert engine.chunk_writes[-1]["version"] == chunk.version assert engine.chunk_writes[-1]["band1"]["annotations"][0]["item_text"] == "logged in" # ...but the frozen text only re-renders at the next compression event. assert _frozen_text(ledger) == frozen_before capsule.resolve("chunk:1-4", _capsule(1, 4)) _run_turns(ledger, chunker, 9, 12, lambda i: "hash-c") frozen_after = _frozen_text(ledger) assert "Post-hoc check results:" in frozen_after assert "'logged in' → failed (no session)" in frozen_after def test_checkpoint_annotation_survives_milestone_rename_via_alias_chain(): """Chunk identity is the step range; the subgoal hash is a label resolved through the alias chain — a renamed milestone still reaches its chunk.""" import pathlib import tempfile steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] with tempfile.TemporaryDirectory() as tmp: base_dir = pathlib.Path(tmp) (base_dir / "notes").mkdir() (base_dir / "notes" / "subgoal_hash_chain.json").write_text( json.dumps({"hash-a": "hash-a-renamed"}), encoding="utf-8" ) ledger, chunker, engine, _ = _make(steps, min_active=2) engine.base_dir = base_dir _run_turns(ledger, chunker, 1, 8, _hashes(4)) assert chunker.awaiting_chunks[0].subgoal_hash == "hash-a" assert chunker.annotate_from_checkpoint( "hash-a-renamed", [{"kind": "verify", "item_text": "x", "status": "passed", "evidence": "ok"}], ) assert chunker.awaiting_chunks[0].annotations def test_checkpoint_annotation_ignores_unmatched_subgoal(): steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] ledger, chunker, engine, _ = _make(steps, min_active=2) _run_turns(ledger, chunker, 1, 8, _hashes(4)) writes_before = len(engine.chunk_writes) assert not chunker.annotate_from_checkpoint( "hash-unknown", [{"kind": "verify", "item_text": "x", "status": "failed", "evidence": "e"}] ) assert len(engine.chunk_writes) == writes_before # --------------------------------------------------------------------------- # Era merging + recall-only overflow + L3 snapshot # --------------------------------------------------------------------------- def _many_chunks( chunk_count: int, *, max_chunks: int, max_eras: int | None = None, injected_step: int | None = None, ): """Drive enough milestone switches to accumulate ``chunk_count`` chunks of 2 steps each (min_active=2).""" total_steps = chunk_count * 2 + 6 hash_for = lambda i: f"hash-{(i - 1) // 2}" # noqa: E731 — switch every 2 steps steps = [] for i in range(1, total_steps + 1): extra = {"subgoal_hash": hash_for(i)} if injected_step == i: extra["injected_instruction"] = "keep account A logged in" steps.append(_step(i, hash_for(i), extra_metadata=extra)) ledger, chunker, engine, capsule = _make( steps, min_active=2, max_chunks=max_chunks, max_eras=max_eras, auto_capsule=True ) _run_turns(ledger, chunker, 1, total_steps, hash_for) return ledger, chunker, engine, capsule def test_era_merge_keeps_ledgers_and_merges_headers(): ledger, chunker, _, capsule = _many_chunks(6, max_chunks=3) assert len(chunker.chunks) <= 3 assert len(chunker.eras) >= 1 merged_eras = [e for e in chunker.eras if not e.recall_only] assert merged_eras era_text = "\n".join( str(m.content) for m in ledger.frozen_blocks if "① Merged synopsis" in str(m.content) ) assert "① Merged synopsis (structured fields, set-merged)" in era_text assert "② Segment titles" in era_text assert "③ Step action ledger" in era_text # ③ is retained 1:1 for every era-folded chunk (recall-only overflow # aside, covered separately): every step keeps its addressable line # (step number + T+ time). for era in merged_eras: for chunk in era.chunks: for n in range(chunk.start_step_number, chunk.end_step_number + 1): assert f"- Step {n} (T+" in era_text def test_era_overflow_compresses_oldest_era_to_period_paragraph(): """§10 decision 5 (final review 2026-09-01): era overflow renders the oldest era as a period paragraph — step range AND start/end session offsets in the header, a non-empty mechanically assembled synopsis, the recall guidance line, and verbatim user-injection lines.""" ledger, chunker, _, _ = _many_chunks(14, max_chunks=2, injected_step=1) assert len(chunker.eras) > 2 recall_eras = [e for e in chunker.eras if e.recall_only] assert recall_eras, "oldest eras should have overflowed to recall-only" frozen = _frozen_text(ledger) era = recall_eras[0] header = ( f"[Era {era.ordinal} | Steps {era.start_step_number}–{era.end_step_number}" f" | {era.chunks[0].start_offset} → {era.chunks[-1].end_offset}]" ) assert header in frozen # The paragraph follows on the same line and is never empty. match = re.search(re.escape(header) + r" (.+)", frozen) assert match and match.group(1).strip() # Mechanical assembly from band ①: doing survives verbatim, note # references from effect are merged in, verified facts are quoted. assert "Working toward the login milestone." in match.group(1) assert "notes/login_flow.md" in match.group(1) assert "Verified: " in match.group(1) # Recall guidance line is retained. assert ( f" (Step-level ledger via recall_history for steps" f" {era.start_step_number}–{era.end_step_number})" ) in frozen # Never-evict: the user injection survives even the period paragraph. assert 'User @ Step 1: "keep account A logged in"' in frozen def _chunk_state( ordinal: int, start: int, end: int, *, band1: dict | None = None, milestone: str | None = None, user_lines: tuple = (), ) -> ChunkState: return ChunkState( ordinal=ordinal, start_step_number=start, end_step_number=end, start_step_id=f"s{start}", end_step_id=f"s{end}", source_step_ids=[f"s{i}" for i in range(start, end + 1)], subgoal_hash=None, milestone_label=milestone, start_offset=f"T+{start:02d}:00", end_offset=f"T+{end:02d}:59", band3="", minimal_index="", user_lines=list(user_lines), status="ready" if band1 else "pending", band1=band1 or {}, ) def test_period_paragraph_falls_back_per_chunk_without_band1(): """A pending (band-①-less) chunk contributes its milestone label — or its step range when unlabeled — to the 'Did:' chain instead of vanishing.""" era = EraState( ordinal=7, chunks=[ _chunk_state(1, 1, 4, band1=_capsule(1, 4)), _chunk_state(2, 5, 8, milestone="Checkout flow"), # pending, labeled _chunk_state(3, 9, 12), # pending, unlabeled ], recall_only=True, ) paragraph = render_era_period_paragraph(era) assert "Working toward the login milestone." in paragraph assert "Checkout flow" in paragraph assert "steps 9–12" in paragraph assert "notes/login_flow.md" in paragraph assert "fact-1" in paragraph def test_period_paragraph_degrades_to_milestone_list_when_all_band1_missing(): """With no band ① anywhere the paragraph degrades to the milestone-label list + step ranges — never empty — and the block still carries the range/ offset header, the recall guidance line, and verbatim user lines.""" era = EraState( ordinal=3, chunks=[ _chunk_state(1, 1, 4, milestone="Login"), _chunk_state(2, 5, 8, user_lines=[' User @ Step 6: "stay logged in"']), ], recall_only=True, ) paragraph = render_era_period_paragraph(era) assert paragraph.strip() assert paragraph == "Milestones: Login (Steps 1–4); segment (Steps 5–8)." block = render_era_block(era) assert block.startswith(f"[Era 3 | Steps 1–8 | T+01:00 → T+08:59] {paragraph}") assert "(Step-level ledger via recall_history for steps 1–8)" in block assert 'User @ Step 6: "stay logged in"' in block def test_independent_max_eras_cap_decouples_from_max_chunks(): """M5: ``chunking.max_eras`` caps eras independently; None follows ``max_chunks`` (the pre-M5 equal-value behavior, asserted by the overflow test above).""" # A generous era cap keeps every folded era's ledger despite max_chunks=2. _, roomy, _, _ = _many_chunks(14, max_chunks=2, max_eras=50) assert len(roomy.eras) > 2 assert not [e for e in roomy.eras if e.recall_only] # A tight era cap overflows old eras even though max_chunks is large. _, tight, _, _ = _many_chunks(14, max_chunks=2, max_eras=1) assert [e for e in tight.eras if e.recall_only] def test_hard_threshold_renders_l3_snapshot_with_minimal_step_index(): steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 13)] meter = {"value": None} ledger, chunker, _, capsule = _make( steps, min_active=2, meter=lambda: meter["value"] ) _run_turns(ledger, chunker, 1, 8, _hashes(4)) capsule.resolve("chunk:1-4", _capsule(1, 4)) meter["value"] = 95_000 # ≥ 0.9 * 100k → L3 _run_turns(ledger, chunker, 9, 12, lambda i: "hash-c") frozen = _frozen_text(ledger) assert "[Session snapshot" in frozen assert "Overall goal: TEST GOAL" in frozen assert "Verified facts: fact-1" in frozen assert "Device/app state: Settings page open" in frozen # The minimal per-step index survives: step + time + action phrase for # every chunked step (steps 1–4 and the second event's chunk). assert "--- Step index (minimal width, per chunk) ---" in frozen for chunk in chunker.chunks: for n in range(chunk.start_step_number, chunk.end_step_number + 1): assert f"- Step {n} (T+" in frozen assert "-> executed" not in frozen.split("--- Step index")[1] def test_step_addressability_survives_every_compression_level(): """Hard invariant (§8, revised by §10 decision 5 final review): after L2 chunks, era merges, and L3, every compressed step's number and session-offset time are directly readable; the extreme period-paragraph layer alone degrades to *period* addressability — step range and start/end offsets must be present in the header.""" ledger, chunker, _, _ = _many_chunks(6, max_chunks=3) frozen = _frozen_text(ledger) covered_steps = [ n for era in chunker.eras if not era.recall_only for c in era.chunks for n in range(c.start_step_number, c.end_step_number + 1) ] + [ n for c in chunker.chunks for n in range(c.start_step_number, c.end_step_number + 1) ] assert covered_steps for n in covered_steps: assert f"- Step {n} (T+" in frozen, f"step {n} lost its addressable line" # Extreme layer: recall-only eras stay *period*-addressable — the header # regex pins step range and T+..→T+.. start/end offsets in place. overflow_ledger, overflow_chunker, _, _ = _many_chunks(14, max_chunks=2) overflow_frozen = _frozen_text(overflow_ledger) recall_eras = [e for e in overflow_chunker.eras if e.recall_only] assert recall_eras for era in recall_eras: pattern = ( rf"\[Era {era.ordinal} \| Steps {era.start_step_number}" rf"–{era.end_step_number} \| T\+\d{{2,}}:\d{{2}} → T\+\d{{2,}}:\d{{2}}\]" ) assert re.search(pattern, overflow_frozen), ( f"era {era.ordinal} lost its period-addressable header" ) @pytest.mark.asyncio async def test_capsule_retry_exhaustion_degrades_to_pending_chunk(): """Bounded retries: a lens that never covers the range exhausts its retry budget, the job enters the explicit failed state, and the chunk simply stays pending — band ③ remains independently usable.""" from artemis.memory.chunking import ChunkCapsuleService class NeverCoversLens(StepCapsuleLens): def __init__(self): super().__init__(model_name="test", llm=object()) self.attempts = 0 async def render(self, key, payload): self.attempts += 1 return None lens = NeverCoversLens() service = ChunkCapsuleService(ctx=None, lens=lens, retry_limit=1) service.submit("chunk:1-4", {"start_step": 1, "end_step": 4, "steps": []}) await service.flush() assert lens.attempts == 2 # 1 + retry_limit, never unbounded assert service.has_failed("chunk:1-4") assert service.get_summary("chunk:1-4") is None # A chunker harvesting this key leaves the chunk pending — and under # ready gating the segment never swaps: the original turns stay live. steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] ledger, chunker, _, _ = _make(steps, min_active=2) _run_turns(ledger, chunker, 1, 8, _hashes(4)) chunker._capsule_service = service # type: ignore[attr-defined] chunker._harvest_capsules() assert chunker.awaiting_chunks[0].status == "pending" assert chunker.chunks == () assert ledger.frozen_blocks == () # --------------------------------------------------------------------------- # Capsule availability hardening (2026-09-01 A/B root cause: the dedicated # `chunking.model` endpoint was down all day, so every capsule attempt died # and all 21 chunk headers stayed pending at version 1 forever) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_capsule_lens_falls_back_to_secondary_model_on_provider_outage(): """A provider outage on the primary capsule model switches the attempt to the configured fallback model instead of failing the capsule outright.""" class DeadPrimary: def __init__(self): self.calls = 0 async def ainvoke(self, messages): self.calls += 1 raise Exception( "503 Service Unavailable. This model is currently experiencing" " high demand." ) class HealthyFallback: def __init__(self): self.calls = 0 async def ainvoke(self, messages): self.calls += 1 return SimpleNamespace(content=json.dumps(_capsule(1, 2))) primary, fallback = DeadPrimary(), HealthyFallback() lens = StepCapsuleLens( model_name="dead-model", llm=primary, fallback_model_name="healthy-model", fallback_llm=fallback, ) result = await lens.render("chunk:1-2", {"start_step": 1, "end_step": 2, "steps": []}) assert result is not None and json.loads(result)["doing"] assert primary.calls == 1 assert fallback.calls == 1 @pytest.mark.asyncio async def test_capsule_outage_without_fallback_exhausts_to_failed(): """The A/B breakage shape: one dead endpoint and no fallback fails every attempt; the job exhausts to the explicit failed state (and the chunk stays pending, per test_capsule_retry_exhaustion_degrades_to_pending_chunk).""" from artemis.memory.chunking import ChunkCapsuleService class DeadLLM: async def ainvoke(self, messages): raise Exception("503 Service Unavailable") lens = StepCapsuleLens(model_name="dead", llm=DeadLLM()) assert not lens.has_fallback service = ChunkCapsuleService(ctx=None, lens=lens, retry_limit=1) service._retry_delays = (0.0,) service.submit("chunk:1-2", {"start_step": 1, "end_step": 2, "steps": []}) await service.flush() assert service.has_failed("chunk:1-2") assert service.get_summary("chunk:1-2") is None def test_capsule_fallback_model_resolution_google_only_and_not_primary(): mgr = HistoryChunkManager( capsule_service=StubCapsuleService(), chunking_config=SimpleNamespace( max_steps=12, target_source_tokens=2000, model="gemini-3.7-flash", max_chunks=8, ), ) def ctx_with(provider, model): return SimpleNamespace( llm_config=SimpleNamespace( summarizer=SimpleNamespace( fallback=SimpleNamespace(provider=provider, model=model) ) ) ) assert ( mgr._resolve_capsule_fallback_model(ctx_with("google", "gemini-3.6-flash")) == "gemini-3.6-flash" ) # Non-google fallbacks cannot ride the raw google model path. assert mgr._resolve_capsule_fallback_model(ctx_with("openai", "gpt-4o-mini")) is None # A fallback identical to the primary adds nothing. assert ( mgr._resolve_capsule_fallback_model(ctx_with("google", "gemini-3.7-flash")) is None ) @pytest.mark.asyncio async def test_flush_harvests_late_capsule_and_persists_ready_version(): """Short-session backstop: a capsule that becomes ready after the last render is harvested and persisted at flush; flush itself never swaps (no freeze at shutdown), and the next render performs the swap normally.""" steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] ledger, chunker, engine, capsule = _make(steps, min_active=2) _run_turns(ledger, chunker, 1, 8, _hashes(4)) assert chunker.awaiting_chunks[0].status == "pending" v1_writes = len(engine.chunk_writes) capsule.resolve("chunk:1-4", _capsule(1, 4)) await chunker.flush(0.1) chunk = chunker.awaiting_chunks[0] assert chunk.status == "ready" assert chunk.version == 2 ready_rows = [w for w in engine.chunk_writes[v1_writes:] if w["status"] == "ready"] assert ready_rows and ready_rows[-1]["version"] == 2 # Flush drains and persists but never freezes the transcript. assert ledger.frozen_blocks == () # A later render (e.g. after a process resume) swaps normally. ledger.commit_staged(step_key="s8", validator_result={"status": "success"}) ledger.render([_observation(9)]) assert chunker.chunks and chunker.chunks[0].status == "ready" # --------------------------------------------------------------------------- # Ready-gated swap (user decision 2026-09-01): degradation ladder # --------------------------------------------------------------------------- def test_soft_pressure_alone_never_swaps_unready_segment(): """Soft-threshold pressure closes segments but never swaps an unready one: correctness over tokens — the original text stays until the header lands.""" steps = [_step(i) for i in range(1, 9)] meter = {"value": 75_000} # ≥ 0.7 * 100k, < 0.9 * 100k ledger, chunker, _, _ = _make( steps, min_active=2, max_steps=100, meter=lambda: meter["value"] ) _run_turns(ledger, chunker, 1, 8) assert len(chunker.awaiting_chunks) >= 1 # soft pressure closed a portion assert chunker.chunks == () # ...but nothing swapped assert ledger.frozen_blocks == () # original text retained def test_failed_capsule_redispatches_and_retains_original(): """Failure rung of the ladder: an exhausted capsule job is re-dispatched at the next trigger/pressure render while the original turns stay live.""" steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] meter = {"value": None} ledger, chunker, _, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) _run_turns(ledger, chunker, 1, 8, _hashes(4)) key = chunker.awaiting_chunks[0].capsule_key assert capsule.submitted.count(key) == 1 # Bounded retries exhausted → explicit failed state, chunk stays pending. capsule._failed.add(key) meter["value"] = 75_000 # pressure render = a re-dispatch occasion ledger.commit_staged(step_key="s8", validator_result={"status": "success"}) ledger.render([_observation(9)]) assert capsule.submitted.count(key) == 2 # re-dispatched assert chunker.chunks == () # still not swapped assert ledger.frozen_blocks == () # original text retained def test_hard_threshold_force_swaps_pending_chunks_into_l3(): """Emergency path: only the hard threshold may swap pending chunks — the frozen region becomes the L3 snapshot and ③'s minimal index survives.""" steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] meter = {"value": None} ledger, chunker, _, _ = _make(steps, min_active=2, meter=lambda: meter["value"]) _run_turns(ledger, chunker, 1, 8, _hashes(4)) assert chunker.awaiting_chunks[0].status == "pending" assert ledger.frozen_blocks == () meter["value"] = 95_000 # ≥ 0.9 * 100k → hard ledger.commit_staged(step_key="s8", validator_result={"status": "success"}) ledger.render([_observation(9)]) assert chunker.awaiting_chunks == () assert len(chunker.chunks) >= 1 assert all(c.status == "pending" for c in chunker.chunks) frozen = _frozen_text(ledger) assert "[Session snapshot" in frozen for chunk in chunker.chunks: for n in range(chunk.start_step_number, chunk.end_step_number + 1): assert f"- Step {n} (T+" in frozen assert len(ledger.unchunked_turns()) < 8 # turns actually consumed # --------------------------------------------------------------------------- # Band ① note-linkage machine check (user decision 2026-09-01) # --------------------------------------------------------------------------- def test_capsule_note_coverage_is_machine_checked(): """Every note key written during the segment must surface in the band-① text; a missing key fails the attempt (regenerate), same rank as the ② interval-coverage check. Segments without notes are unaffected.""" lens = StepCapsuleLens(model_name="test", llm=object()) payload = { "start_step": 3, "end_step": 6, "steps": [ { "step_number": 3, "note_writes": [ {"tool": "save_note", "key": "login_flow", "gist": "code entry"} ], }, { "step_number": 5, "note_writes": [ {"tool": "append_note", "key": "prices", "gist": "totals"} ], }, ], } capsule = _capsule(3, 6) # effect mentions notes/login_flow.md only # 'prices' is missing from every band-① field → judged failed. assert lens.parse_capsule(json.dumps(capsule), payload) is None covered = dict( capsule, effect=capsule["effect"] + " Also appended notes/prices.md with the totals.", ) parsed = lens.parse_capsule(json.dumps(covered), payload) assert parsed is not None assert "prices" in parsed["effect"] # A no-notes segment keeps the original acceptance behavior. no_notes = {"start_step": 3, "end_step": 6, "steps": []} assert lens.parse_capsule(json.dumps(capsule), no_notes) is not None # --------------------------------------------------------------------------- # Formal lens interface (M1 difference-b closure) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_lens_backed_service_uses_lens_render(): from artemis.memory.step_memory import StepLens class EchoLens(StepLens): name = "echo" async def render(self, key, payload): return f"summary-for-{key}" service = StepMemoryService(ctx=None, lens=EchoLens()) service.submit("k1", {"step_number": 1}) await service.flush() assert service.get_summary("k1") == "summary-for-k1" assert not service.has_failed("k1")