@miller-tech/uap 1.186.2 → 1.186.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.186.2",
3
+ "version": "1.186.3",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -21,7 +21,7 @@
21
21
  "start": "node dist/bin/cli.js",
22
22
  "test": "vitest",
23
23
  "test:ci": "vitest run",
24
- "test:enforcers": "UAP_PROXY_ENV_AUTOLOAD=0 python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_stream_telemetry tools.agents.tests.test_project_telemetry_events tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_gate_evidence tools.agents.tests.test_gate_integrity tools.agents.tests.test_gate_failclosed_indirection tools.agents.tests.test_expert_review_pr_scope tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache tools.agents.tests.test_doubling_break tools.agents.tests.test_error_loop_ignores_correctives tools.agents.tests.test_attractor_detection tools.agents.tests.test_client_disconnect tools.agents.tests.test_confidence_escalation tools.agents.tests.test_coordination_ban tools.agents.tests.test_coordination_early_ban tools.agents.tests.test_cycle_break_exploration tools.agents.tests.test_deferral_break tools.agents.tests.test_deliver_autoroute tools.agents.tests.test_delivery_enforcement_all_langs tools.agents.tests.test_delivery_enforcement_exemptions tools.agents.tests.test_delivery_enforcement_filepath tools.agents.tests.test_delivery_enforcement_web_and_bash tools.agents.tests.test_disconnect_watcher tools.agents.tests.test_empty_maxtokens_recovery tools.agents.tests.test_empty_tool_loop_break tools.agents.tests.test_enforcer_escape_hatches tools.agents.tests.test_error_loop_break tools.agents.tests.test_finalize_suppression tools.agents.tests.test_malformed_unclosed_think tools.agents.tests.test_mandate_beats_recon tools.agents.tests.test_mandate_deliver tools.agents.tests.test_overflow_truncate_count_tokens tools.agents.tests.test_passthrough_oauth tools.agents.tests.test_project_telemetry tools.agents.tests.test_proxy_auth_headers tools.agents.tests.test_prune_preserve_force_write tools.agents.tests.test_recon_deliver_gate tools.agents.tests.test_session_admission tools.agents.tests.test_stream_heartbeat tools.agents.tests.test_stuck_break_reattach tools.agents.tests.test_turn_count_breaker_periodic tools.agents.tests.test_upstream_chokepoint tools.agents.tests.test_vision_passthrough tools.agents.tests.test_worktree_required tools.agents.tests.test_enforcer_suite_coverage tools.agents.tests.test_validate_plan_gate tools.agents.tests.test_validate_plan_inside_project tools.agents.tests.test_anthropic_proxy_streaming tools.agents.tests.test_delivery_enforcement_worktree tools.agents.tests.test_output_token_ceilings tools.agents.tests.test_tool_narrowing_core tools.agents.tests.test_models_context_window",
24
+ "test:enforcers": "UAP_PROXY_ENV_AUTOLOAD=0 python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_stream_telemetry tools.agents.tests.test_project_telemetry_events tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_gate_evidence tools.agents.tests.test_repeat_call_guard tools.agents.tests.test_gate_integrity tools.agents.tests.test_gate_failclosed_indirection tools.agents.tests.test_expert_review_pr_scope tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache tools.agents.tests.test_doubling_break tools.agents.tests.test_error_loop_ignores_correctives tools.agents.tests.test_attractor_detection tools.agents.tests.test_client_disconnect tools.agents.tests.test_confidence_escalation tools.agents.tests.test_coordination_ban tools.agents.tests.test_coordination_early_ban tools.agents.tests.test_cycle_break_exploration tools.agents.tests.test_deferral_break tools.agents.tests.test_deliver_autoroute tools.agents.tests.test_delivery_enforcement_all_langs tools.agents.tests.test_delivery_enforcement_exemptions tools.agents.tests.test_delivery_enforcement_filepath tools.agents.tests.test_delivery_enforcement_web_and_bash tools.agents.tests.test_disconnect_watcher tools.agents.tests.test_empty_maxtokens_recovery tools.agents.tests.test_empty_tool_loop_break tools.agents.tests.test_enforcer_escape_hatches tools.agents.tests.test_error_loop_break tools.agents.tests.test_finalize_suppression tools.agents.tests.test_malformed_unclosed_think tools.agents.tests.test_mandate_beats_recon tools.agents.tests.test_mandate_deliver tools.agents.tests.test_overflow_truncate_count_tokens tools.agents.tests.test_passthrough_oauth tools.agents.tests.test_project_telemetry tools.agents.tests.test_proxy_auth_headers tools.agents.tests.test_prune_preserve_force_write tools.agents.tests.test_recon_deliver_gate tools.agents.tests.test_session_admission tools.agents.tests.test_stream_heartbeat tools.agents.tests.test_stuck_break_reattach tools.agents.tests.test_turn_count_breaker_periodic tools.agents.tests.test_upstream_chokepoint tools.agents.tests.test_vision_passthrough tools.agents.tests.test_worktree_required tools.agents.tests.test_enforcer_suite_coverage tools.agents.tests.test_validate_plan_gate tools.agents.tests.test_validate_plan_inside_project tools.agents.tests.test_anthropic_proxy_streaming tools.agents.tests.test_delivery_enforcement_worktree tools.agents.tests.test_output_token_ceilings tools.agents.tests.test_tool_narrowing_core tools.agents.tests.test_models_context_window",
25
25
  "test:coverage": "vitest --coverage",
26
26
  "bench": "vitest --config vitest.bench.config.ts",
27
27
  "lint": "eslint src --ext .ts",
@@ -31,7 +31,7 @@
31
31
  "install:web": "bash scripts/setup/install-web.sh",
32
32
  "install:desktop": "bash scripts/setup/install-desktop.sh",
33
33
  "install:cloakbrowser": "tsx scripts/setup/install-cloakbrowser.ts",
34
- "postinstall": "echo '\n Run: npx @miller-tech/uap init --interactive'",
34
+ "postinstall": "echo '\n\u2728 Run: npx @miller-tech/uap init --interactive'",
35
35
  "version:patch": "bash scripts/version-bump.sh patch",
36
36
  "version:minor": "bash scripts/version-bump.sh minor",
37
37
  "version:major": "bash scripts/version-bump.sh major",
@@ -374,6 +374,30 @@ _RATE_LIMITED_API_RE = re.compile(r"api\.github\.com", re.IGNORECASE)
374
374
  PROXY_STUCK_TEXT_THRESHOLD = int(os.environ.get("PROXY_STUCK_TEXT_THRESHOLD", "2"))
375
375
  PROXY_STUCK_API_THRESHOLD = int(os.environ.get("PROXY_STUCK_API_THRESHOLD", "3"))
376
376
 
377
+ # REPEAT-CALL guardrail: the same tool call, with the same arguments, over and
378
+ # over -- while SUCCEEDING every time.
379
+ #
380
+ # Observed live (opencode + qwen3.6, 2026-08-07): `git diff --stat` re-issued 44
381
+ # times in one run, ~2.5s apart, until the operator interrupted. On screen it
382
+ # reads as the final message repeating forever.
383
+ #
384
+ # Every existing guard missed it, and each for a defensible reason:
385
+ # STUCK-BREAK needs self-reported "stuck" phrasing or an api.github.com arg.
386
+ # ERROR-LOOP needs a repeated tool-RESULT error signature; this call works.
387
+ # LOOP BREAKER detects the identical fingerprint, but is ANDed with
388
+ # no_progress_streak -- and a command that returns output every
389
+ # time never accumulates one, so the condition never holds.
390
+ #
391
+ # The blind spot is therefore a repeatedly-SUCCESSFUL identical call: the other
392
+ # guards all key off failure or self-awareness, and this loop has neither. A
393
+ # read-only command issued four times with identical arguments is not a
394
+ # strategy, so this fires on the fingerprint alone, independent of outcome.
395
+ # PROXY_REPEAT_CALL_THRESHOLD=0 disables.
396
+ PROXY_REPEAT_CALL_THRESHOLD = int(os.environ.get("PROXY_REPEAT_CALL_THRESHOLD", "4"))
397
+ # Marker so the injected directive can address a SUCCEEDING loop correctly
398
+ # rather than telling the model to stop retrying "a failing action".
399
+ _REPEAT_CALL_REASON = "identical tool call"
400
+
377
401
  # ---------------------------------------------------------------------------
378
402
  # ERROR-LOOP guardrail: the model edits, runs a command, hits the SAME failure,
379
403
  # edits again (a DIFFERENT edit), runs, hits the same failure — for many turns.
@@ -1993,6 +2017,14 @@ class SessionMonitor:
1993
2017
  return True, f"self-reported stuck x{self.self_stuck_streak}"
1994
2018
  if self.rate_limited_api_streak >= PROXY_STUCK_API_THRESHOLD:
1995
2019
  return True, f"rate-limited-API retries x{self.rate_limited_api_streak}"
2020
+ # Repeated identical call, judged on the fingerprint ALONE. Deliberately
2021
+ # not ANDed with no_progress_streak the way the LOOP BREAKER is: a call
2022
+ # that succeeds every time never builds a no-progress streak, which is
2023
+ # exactly how a 44-turn `git diff --stat` loop ran unchallenged.
2024
+ if PROXY_REPEAT_CALL_THRESHOLD > 0:
2025
+ looping, count = self.detect_tool_loop(window=PROXY_REPEAT_CALL_THRESHOLD)
2026
+ if looping and count >= PROXY_REPEAT_CALL_THRESHOLD:
2027
+ return True, f"{_REPEAT_CALL_REASON} x{count}"
1996
2028
  return False, ""
1997
2029
 
1998
2030
  def note_deferral_signal(self, text: str, had_tool_call: bool) -> None:
@@ -5677,6 +5709,51 @@ def _strip_sandbox_unreachable_tools(body: dict) -> int:
5677
5709
  return removed
5678
5710
 
5679
5711
 
5712
+ def _seed_tool_history_from_request(monitor: "SessionMonitor", messages: list) -> None:
5713
+ """Rebuild the tool-call streak from the CONVERSATION, not from server state.
5714
+
5715
+ Every streak guard here counted appends to a per-session SessionMonitor. That
5716
+ monitor is keyed `fp:<hash of the first user message>` whenever the client
5717
+ sends no session header — and opencode sends none. So anything that shifts
5718
+ that text (compaction, a re-summarised opening turn) silently starts a FRESH
5719
+ monitor with empty history, and every streak restarts at zero. A proxy
5720
+ restart mid-session does the same.
5721
+
5722
+ The client re-sends the whole conversation each turn, so the streak is
5723
+ already in the request. Deriving it from there makes the guards independent
5724
+ of monitor identity and of proxy uptime.
5725
+
5726
+ Only ever EXTENDS: if the monitor already knows at least as much as the
5727
+ request implies, it is left alone, so this cannot double-count the normal
5728
+ path that appends one fingerprint per request.
5729
+ """
5730
+ if not isinstance(messages, list):
5731
+ return
5732
+ rebuilt: list[str] = []
5733
+ for msg in messages:
5734
+ if not isinstance(msg, dict) or msg.get("role") != "assistant":
5735
+ continue
5736
+ content = msg.get("content")
5737
+ if not isinstance(content, list):
5738
+ continue
5739
+ fps = [
5740
+ _tool_call_fingerprint(b)
5741
+ for b in content
5742
+ if isinstance(b, dict) and b.get("type") == "tool_use"
5743
+ ]
5744
+ if fps:
5745
+ rebuilt.append("|".join(sorted(fps)))
5746
+ # Drop the LAST turn: the incremental path immediately appends that one, and
5747
+ # seeding it here too would count the current turn twice — inflating every
5748
+ # streak by one and firing the guards a turn early. Caught by two existing
5749
+ # streaming tests, which replay one body three times and reached the
5750
+ # threshold sooner than they should have.
5751
+ rebuilt = rebuilt[:-1]
5752
+ if len(rebuilt) > len(monitor.tool_call_history):
5753
+ # Keep the same bound the incremental path uses.
5754
+ monitor.tool_call_history = rebuilt[-30:]
5755
+
5756
+
5680
5757
  def _maybe_inject_stuck_break(openai_body: dict, monitor: "SessionMonitor") -> None:
5681
5758
  """Force a terminal turn when the model is looping self-awarely or hammering
5682
5759
  a rate-limited API. Unlike the cycle-breaker (which narrows tools), this
@@ -5697,15 +5774,29 @@ def _maybe_inject_stuck_break(openai_body: dict, monitor: "SessionMonitor") -> N
5697
5774
  if monitor.sandboxed
5698
5775
  else "use the browser tool or `git clone` (git protocol)"
5699
5776
  )
5700
- directive = (
5701
- "\n\nSTOP you are repeating a failing action (" + reason + "). Do NOT "
5702
- "retry the same tool or fetch again. If a resource is unreachable (e.g. a "
5703
- "rate-limited GitHub REST API), switch channel: " + channel_hint + ", NOT "
5704
- "api.github.com. If it is still "
5705
- "unavailable, proceed WITHOUT it using what you already have, or ask the "
5706
- "operator the single blocking question in one sentence. Take a DIFFERENT "
5707
- "action now."
5708
- )
5777
+ if reason.startswith(_REPEAT_CALL_REASON):
5778
+ # The call SUCCEEDS every time, so "stop retrying a failing action" and
5779
+ # the switch-channel advice would both be nonsense here. Name the real
5780
+ # problem: the answer is already in hand and re-asking cannot change it.
5781
+ directive = (
5782
+ "\n\nSTOP you have issued the same tool call with the same "
5783
+ "arguments " + reason.rsplit("x", 1)[-1] + " times in a row. It "
5784
+ "SUCCEEDED each time and the result will not change by asking again. "
5785
+ "You already have that output. Do NOT repeat it. Either take the "
5786
+ "NEXT concrete action using what it told you, or — if you genuinely "
5787
+ "cannot proceed — state the single blocking question in one sentence "
5788
+ "and stop. Answer in plain text now if the work is done."
5789
+ )
5790
+ else:
5791
+ directive = (
5792
+ "\n\nSTOP — you are repeating a failing action (" + reason + "). Do NOT "
5793
+ "retry the same tool or fetch again. If a resource is unreachable (e.g. a "
5794
+ "rate-limited GitHub REST API), switch channel: " + channel_hint + ", NOT "
5795
+ "api.github.com. If it is still "
5796
+ "unavailable, proceed WITHOUT it using what you already have, or ask the "
5797
+ "operator the single blocking question in one sentence. Take a DIFFERENT "
5798
+ "action now."
5799
+ )
5709
5800
  msgs = openai_body.get("messages")
5710
5801
  if not isinstance(msgs, list):
5711
5802
  msgs = []
@@ -7095,6 +7186,7 @@ def _record_last_assistant_tool_calls(
7095
7186
  _latest_err = any(_flags)
7096
7187
  break
7097
7188
  monitor.note_tool_result_error(_latest_tr, _latest_err)
7189
+ _seed_tool_history_from_request(monitor, messages)
7098
7190
  tool_fingerprints = []
7099
7191
  tool_targets: dict[str, str] = {}
7100
7192
  assistant_had_text = False # Fix B: did the last assistant turn emit prose?
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env python3
2
+ """A tool call that keeps SUCCEEDING can loop forever, and every guard missed it.
3
+
4
+ Observed live (opencode + qwen3.6, 2026-08-07): `git diff --stat` re-issued 44
5
+ times in one run, ~2.5s apart, until the operator interrupted. On screen it
6
+ reads as the final message repeating without end.
7
+
8
+ Each existing guard declined it for a defensible reason:
9
+
10
+ STUCK-BREAK wants self-reported "stuck" phrasing, or an `api.github.com`
11
+ argument. The model said nothing and this is a git command.
12
+ ERROR-LOOP wants a repeated tool-RESULT error signature. This call works.
13
+ LOOP BREAKER does detect the identical fingerprint, but ANDs it with
14
+ `no_progress_streak` — and a command that returns output every
15
+ time never accumulates one, so the condition never held.
16
+
17
+ The blind spot is a repeatedly-SUCCESSFUL identical call: the other guards all
18
+ key off failure or self-awareness, and this loop has neither. So this guard
19
+ fires on the fingerprint alone, at 4, independent of outcome.
20
+ """
21
+
22
+ import importlib.util
23
+ import os
24
+ import sys
25
+ import unittest
26
+ from pathlib import Path
27
+
28
+ PROXY = Path(__file__).resolve().parents[3] / "tools" / "agents" / "scripts" / "anthropic_proxy.py"
29
+
30
+
31
+ def load_proxy(env: dict | None = None):
32
+ """Import the proxy module with a chosen env (thresholds are read at import)."""
33
+ saved = dict(os.environ)
34
+ os.environ.update(env or {})
35
+ try:
36
+ spec = importlib.util.spec_from_file_location(f"proxy_{len(sys.modules)}", PROXY)
37
+ mod = importlib.util.module_from_spec(spec)
38
+ spec.loader.exec_module(mod)
39
+ return mod
40
+ finally:
41
+ os.environ.clear()
42
+ os.environ.update(saved)
43
+
44
+
45
+ class TestRepeatCallGuard(unittest.TestCase):
46
+ @classmethod
47
+ def setUpClass(cls):
48
+ cls.proxy = load_proxy()
49
+
50
+ def monitor(self):
51
+ return self.proxy.SessionMonitor(context_window=100000)
52
+
53
+ def record(self, mon, fingerprint, times):
54
+ for _ in range(times):
55
+ mon.record_tool_calls(tool_names=["Bash"], fingerprint=fingerprint)
56
+
57
+ def test_the_observed_loop_is_caught(self):
58
+ # The real thing: same tool, same args, succeeding every time.
59
+ mon = self.monitor()
60
+ self.record(mon, "Bash|git diff --stat", 4)
61
+ should, reason = mon.should_force_stuck_break()
62
+ self.assertTrue(should, "4 identical successful calls must break the loop")
63
+ self.assertIn("identical tool call", reason)
64
+
65
+ def test_three_identical_calls_are_left_alone(self):
66
+ # Repetition is not automatically a loop — a couple of retries is normal.
67
+ mon = self.monitor()
68
+ self.record(mon, "Bash|git diff --stat", 3)
69
+ should, _ = mon.should_force_stuck_break()
70
+ self.assertFalse(should)
71
+
72
+ def test_no_progress_streak_is_NOT_required(self):
73
+ # The precise reason the existing LOOP BREAKER never fired: a succeeding
74
+ # command leaves no_progress_streak at 0 forever.
75
+ mon = self.monitor()
76
+ self.record(mon, "Bash|git diff --stat", 6)
77
+ self.assertEqual(mon.no_progress_streak, 0)
78
+ should, _ = mon.should_force_stuck_break()
79
+ self.assertTrue(should)
80
+
81
+ def test_varied_calls_do_not_trip_it(self):
82
+ # Ordinary agentic work: different tools, different arguments.
83
+ mon = self.monitor()
84
+ for fp in ("Bash|ls", "Read|a.ts", "Bash|npm test", "Edit|a.ts", "Bash|git status"):
85
+ mon.record_tool_calls(tool_names=["Bash"], fingerprint=fp)
86
+ should, _ = mon.should_force_stuck_break()
87
+ self.assertFalse(should)
88
+
89
+ def test_a_broken_streak_resets_it(self):
90
+ # Three repeats, something else, three repeats — not a loop.
91
+ mon = self.monitor()
92
+ self.record(mon, "Bash|git diff --stat", 3)
93
+ mon.record_tool_calls(tool_names=["Read"], fingerprint="Read|src/index.ts")
94
+ self.record(mon, "Bash|git diff --stat", 3)
95
+ should, _ = mon.should_force_stuck_break()
96
+ self.assertFalse(should)
97
+
98
+ def test_the_guard_can_be_disabled(self):
99
+ proxy = load_proxy({"PROXY_REPEAT_CALL_THRESHOLD": "0"})
100
+ mon = proxy.SessionMonitor(context_window=100000)
101
+ for _ in range(12):
102
+ mon.record_tool_calls(tool_names=["Bash"], fingerprint="Bash|git diff --stat")
103
+ should, _ = mon.should_force_stuck_break()
104
+ self.assertFalse(should, "PROXY_REPEAT_CALL_THRESHOLD=0 must disable it")
105
+
106
+ def test_existing_stuck_signals_still_work(self):
107
+ # The new branch must not shadow the two guards that were already there.
108
+ mon = self.monitor()
109
+ for _ in range(self.proxy.PROXY_STUCK_TEXT_THRESHOLD):
110
+ mon.note_assistant_text("I've been stuck in a loop, let me break out")
111
+ should, reason = mon.should_force_stuck_break()
112
+ self.assertTrue(should)
113
+ self.assertIn("self-reported", reason)
114
+
115
+
116
+ class TestDirectiveMatchesTheFailure(unittest.TestCase):
117
+ """A succeeding loop must not be told to stop retrying 'a failing action'."""
118
+
119
+ @classmethod
120
+ def setUpClass(cls):
121
+ cls.proxy = load_proxy()
122
+
123
+ def inject(self, mon):
124
+ body = {"messages": [{"role": "system", "content": "base"}], "tool_choice": "required"}
125
+ self.proxy._maybe_inject_stuck_break(body, mon)
126
+ return body
127
+
128
+ def test_repeat_loop_gets_the_right_words(self):
129
+ mon = self.proxy.SessionMonitor(context_window=100000)
130
+ for _ in range(4):
131
+ mon.record_tool_calls(tool_names=["Bash"], fingerprint="Bash|git diff --stat")
132
+ text = self.inject(mon)["messages"][0]["content"]
133
+ self.assertIn("SUCCEEDED each time", text)
134
+ self.assertIn("will not change", text)
135
+ # Wrong-diagnosis wording from the other branch must not appear.
136
+ self.assertNotIn("failing action", text)
137
+ self.assertNotIn("api.github.com", text)
138
+
139
+ def test_tool_choice_is_released_so_a_text_turn_is_possible(self):
140
+ # Without this the model is still coerced into calling a tool, which is
141
+ # the loop it is being asked to leave.
142
+ mon = self.proxy.SessionMonitor(context_window=100000)
143
+ for _ in range(4):
144
+ mon.record_tool_calls(tool_names=["Bash"], fingerprint="Bash|git diff --stat")
145
+ self.assertEqual(self.inject(mon)["tool_choice"], "auto")
146
+
147
+ def test_failing_loop_keeps_its_original_directive(self):
148
+ mon = self.proxy.SessionMonitor(context_window=100000)
149
+ for _ in range(self.proxy.PROXY_STUCK_API_THRESHOLD):
150
+ mon.note_tool_arg_hosts(["https://api.github.com/repos/x/y"])
151
+ text = self.inject(mon)["messages"][0]["content"]
152
+ self.assertIn("failing action", text)
153
+
154
+
155
+ if __name__ == "__main__":
156
+ unittest.main()
157
+
158
+
159
+ class TestStreakSurvivesAFreshMonitor(unittest.TestCase):
160
+ """The guards counted server-side state that silently resets.
161
+
162
+ The monitor is keyed `fp:<hash of the first user message>` when the client
163
+ sends no session header — and opencode sends none. Compaction, a re-summarised
164
+ opening turn, or a proxy restart therefore starts a FRESH monitor with empty
165
+ history, and every streak restarts at zero no matter how long the real loop is.
166
+
167
+ The conversation is re-sent whole each turn, so the streak is derivable from
168
+ the request. These tests pin that.
169
+ """
170
+
171
+ @classmethod
172
+ def setUpClass(cls):
173
+ cls.proxy = load_proxy()
174
+
175
+ @staticmethod
176
+ def convo(n, cmd="git diff --stat"):
177
+ """A conversation containing `n` identical assistant tool calls."""
178
+ msgs = [{"role": "user", "content": "check the diff"}]
179
+ for i in range(n):
180
+ msgs.append({"role": "assistant", "content": [
181
+ {"type": "tool_use", "id": f"t{i}", "name": "Bash", "input": {"command": cmd}}]})
182
+ msgs.append({"role": "user", "content": [
183
+ {"type": "tool_result", "tool_use_id": f"t{i}", "content": "1 file changed"}]})
184
+ return msgs
185
+
186
+ def test_a_fresh_monitor_still_sees_the_loop(self):
187
+ # THE RESIDUAL: brand-new monitor, 44-turn loop already in the transcript.
188
+ mon = self.proxy.SessionMonitor(context_window=100000)
189
+ self.assertEqual(mon.tool_call_history, [])
190
+ self.proxy._seed_tool_history_from_request(mon, self.convo(44))
191
+ should, reason = mon.should_force_stuck_break()
192
+ self.assertTrue(should, "a reset monitor must not erase a live loop")
193
+ self.assertIn("identical tool call", reason)
194
+
195
+ def test_it_only_extends_never_double_counts(self):
196
+ # The incremental path appends one fingerprint per request; seeding must
197
+ # not stack on top of that and inflate the streak.
198
+ mon = self.proxy.SessionMonitor(context_window=100000)
199
+ for _ in range(6):
200
+ mon.record_tool_calls(tool_names=["Bash"], fingerprint="Bash|x")
201
+ before = list(mon.tool_call_history)
202
+ self.proxy._seed_tool_history_from_request(mon, self.convo(2))
203
+ self.assertEqual(mon.tool_call_history, before)
204
+
205
+ def test_varied_history_is_reconstructed_without_tripping(self):
206
+ mon = self.proxy.SessionMonitor(context_window=100000)
207
+ msgs = [{"role": "user", "content": "go"}]
208
+ for i, cmd in enumerate(["ls", "npm test", "git status", "npm run build", "ls -la"]):
209
+ msgs.append({"role": "assistant", "content": [
210
+ {"type": "tool_use", "id": f"t{i}", "name": "Bash", "input": {"command": cmd}}]})
211
+ self.proxy._seed_tool_history_from_request(mon, msgs)
212
+ # 5 assistant turns, 4 seeded: the last is deliberately left for the
213
+ # incremental path to append, so the current turn is not counted twice.
214
+ self.assertEqual(len(mon.tool_call_history), 4)
215
+ should, _ = mon.should_force_stuck_break()
216
+ self.assertFalse(should)
217
+
218
+ def test_it_is_bounded(self):
219
+ mon = self.proxy.SessionMonitor(context_window=100000)
220
+ self.proxy._seed_tool_history_from_request(mon, self.convo(200))
221
+ self.assertLessEqual(len(mon.tool_call_history), 30)
222
+
223
+ def test_malformed_input_is_survivable(self):
224
+ mon = self.proxy.SessionMonitor(context_window=100000)
225
+ for bad in (None, "not-a-list", [], [None, 3, {"role": "assistant"}],
226
+ [{"role": "assistant", "content": "plain text"}]):
227
+ self.proxy._seed_tool_history_from_request(mon, bad)
228
+ self.assertEqual(mon.tool_call_history, [])