@miller-tech/uap 1.129.3 → 1.129.5

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.129.3",
3
+ "version": "1.129.5",
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": "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_workdir_scope_enforcer 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",
24
+ "test:enforcers": "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_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",
25
25
  "test:coverage": "vitest --coverage",
26
26
  "bench": "vitest --config vitest.bench.config.ts",
27
27
  "lint": "eslint src --ext .ts",
@@ -536,18 +536,19 @@ except Exception: # pragma: no cover - optional middleware
536
536
  _TOOLCALL_NORMALIZER_OK = False
537
537
 
538
538
 
539
- def _record_project_telemetry(body, model, usage) -> None:
539
+ def _record_project_telemetry(body, model, usage, duration_ms: float = 0.0) -> None:
540
540
  """Fail-open per-project routing/cost telemetry: derive the request's project
541
- dir and append a task_outcomes row to its analytics DB so per-project
542
- dashboards see the model calls this shared proxy makes. See
543
- tools/agents/scripts/project_telemetry.py. Never raises."""
541
+ dir and append a task_outcomes row (plus a dashboard live-feed event) to its
542
+ analytics/telemetry DBs so per-project dashboards see the model calls this
543
+ shared proxy makes. See tools/agents/scripts/project_telemetry.py. Never
544
+ raises."""
544
545
  try:
545
546
  import sys as _s
546
547
  _d = os.path.dirname(os.path.abspath(__file__))
547
548
  if _d not in _s.path:
548
549
  _s.path.insert(0, _d)
549
550
  import project_telemetry as _pt
550
- _pt.record_from_request(body, model, usage)
551
+ _pt.record_from_request(body, model, usage, duration_ms=duration_ms)
551
552
  except Exception:
552
553
  pass
553
554
 
@@ -8930,6 +8931,7 @@ async def stream_anthropic_response(
8930
8931
  - Proper upstream response closure on client disconnect
8931
8932
  """
8932
8933
  msg_id = f"msg_{uuid.uuid4().hex[:24]}"
8934
+ stream_started = time.monotonic()
8933
8935
 
8934
8936
  # message_start
8935
8937
  yield (
@@ -9257,16 +9259,40 @@ async def stream_anthropic_response(
9257
9259
  upstream_input = int(upstream_usage.get("prompt_tokens") or 0)
9258
9260
  if upstream_input > 0:
9259
9261
  monitor.last_input_tokens = upstream_input
9262
+ final_output = int(upstream_usage.get("completion_tokens") or 0)
9263
+ if final_output <= 0:
9264
+ # Upstream sent no usable usage (older servers ignore stream_options):
9265
+ # estimate from everything actually generated — text, tool-call
9266
+ # arguments, and reasoning — at ~4 chars/token, never below the
9267
+ # per-delta chunk count.
9268
+ generated_chars = (
9269
+ sum(len(c) for c in text_chunks)
9270
+ # isinstance guard: the XML-recovery path stores a bare sentinel
9271
+ # (tool_calls_by_index["_xml_recovered"] = True) in this map.
9272
+ + sum(
9273
+ len(tc.get("arguments", ""))
9274
+ for tc in tool_calls_by_index.values()
9275
+ if isinstance(tc, dict)
9276
+ )
9277
+ + sum(len(c) for c in reasoning_chunks)
9278
+ )
9279
+ final_output = max(output_tokens, (generated_chars + 3) // 4)
9260
9280
  usage_final = {
9261
9281
  "input_tokens": upstream_input or getattr(monitor, "last_input_tokens", 0) or 0,
9262
- "output_tokens": int(upstream_usage.get("completion_tokens") or 0) or output_tokens,
9282
+ "output_tokens": final_output,
9263
9283
  }
9264
9284
  # After message_stop (never delays the client's final frame; a disconnect
9265
9285
  # parked exactly on that yield skips recording, matching the non-stream
9266
9286
  # "completed responses only" semantics) and off the event loop (a locked
9267
9287
  # analytics DB must not stall token delivery for concurrent sessions).
9268
9288
  try:
9269
- await asyncio.to_thread(_record_project_telemetry, anthropic_body, model, usage_final)
9289
+ await asyncio.to_thread(
9290
+ _record_project_telemetry,
9291
+ anthropic_body,
9292
+ model,
9293
+ usage_final,
9294
+ (time.monotonic() - stream_started) * 1000.0,
9295
+ )
9270
9296
  except Exception:
9271
9297
  pass
9272
9298
 
@@ -9836,7 +9862,12 @@ async def messages(request: Request):
9836
9862
  upstream_input = anthropic_resp.get("usage", {}).get("input_tokens", 0)
9837
9863
  if upstream_input > 0:
9838
9864
  monitor.last_input_tokens = upstream_input
9839
- _record_project_telemetry(body, model, anthropic_resp.get("usage", {}))
9865
+ # Off the event loop: the recorder now writes TWO DBs (analytics +
9866
+ # dashboard telemetry) with 2s busy timeouts — a lock stall here
9867
+ # would block every concurrent session's turn.
9868
+ await asyncio.to_thread(
9869
+ _record_project_telemetry, body, model, anthropic_resp.get("usage", {})
9870
+ )
9840
9871
  if PROXY_FORCE_NON_STREAM:
9841
9872
  logger.info(
9842
9873
  "FORCED NON-STREAM: served stream response via guarded non-stream path"
@@ -9877,6 +9908,10 @@ async def messages(request: Request):
9877
9908
 
9878
9909
  if is_stream:
9879
9910
  openai_body["stream"] = True
9911
+ # Without this, OpenAI-compat upstreams (llama-server included) omit
9912
+ # completion_tokens from the final stream chunk — the reason streamed
9913
+ # tool-use turns recorded tokensOut=0 in per-project telemetry.
9914
+ openai_body["stream_options"] = {"include_usage": True}
9880
9915
 
9881
9916
  # Retry upstream connection with backoff to handle
9882
9917
  # llama-server restarts gracefully instead of 500-ing to the client.
@@ -10276,7 +10311,10 @@ async def messages(request: Request):
10276
10311
  if upstream_input > 0:
10277
10312
  monitor.last_input_tokens = upstream_input
10278
10313
 
10279
- _record_project_telemetry(body, model, anthropic_resp.get("usage", {}))
10314
+ # Off the event loop — see the guarded-non-stream call site.
10315
+ await asyncio.to_thread(
10316
+ _record_project_telemetry, body, model, anthropic_resp.get("usage", {})
10317
+ )
10280
10318
  return anthropic_resp
10281
10319
 
10282
10320
 
@@ -18,6 +18,7 @@ Design constraints (it runs in the hot path of a live proxy):
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
+ import json
21
22
  import os
22
23
  import re
23
24
  import sqlite3
@@ -171,19 +172,135 @@ def record_task_outcome(
171
172
  return False
172
173
 
173
174
 
174
- def record_from_request(body: dict, model: str, usage: dict, *, task_id: str = "") -> bool:
175
+ # ── dashboard live-feed (telemetry.db) ─────────────────────────────────────
176
+ # The Live Events / Performance panels read <project>/agents/data/memory/
177
+ # telemetry.db, which is otherwise written only by the TS producers
178
+ # (mcp-router/executor). Plain claude+proxy sessions never run those, so the
179
+ # panels stayed empty forever. The proxy is the one process that sees every
180
+ # turn — mirror the turn into the same store. Compression stats are NOT
181
+ # fabricated here: the proxy does no compression, that panel is honestly
182
+ # empty for plain sessions.
183
+ #
184
+ # DDL matches src/utils/telemetry-store.ts exactly (CREATE IF NOT EXISTS
185
+ # no-ops when the TS side created the tables first; the shape only matters
186
+ # when the proxy creates them for a fresh project).
187
+ _TELEMETRY_CREATE_SQL = """
188
+ CREATE TABLE IF NOT EXISTS perf_samples (
189
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
190
+ metric TEXT NOT NULL,
191
+ duration_ms REAL NOT NULL,
192
+ ts TEXT NOT NULL DEFAULT (datetime('now'))
193
+ );
194
+ CREATE INDEX IF NOT EXISTS idx_perf_samples_metric ON perf_samples(metric);
195
+ CREATE TABLE IF NOT EXISTS dashboard_events (
196
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
197
+ ts TEXT NOT NULL DEFAULT (datetime('now')),
198
+ category TEXT NOT NULL,
199
+ type TEXT NOT NULL,
200
+ severity TEXT NOT NULL DEFAULT 'info',
201
+ title TEXT NOT NULL,
202
+ detail TEXT,
203
+ metadata TEXT
204
+ );
205
+ """
206
+
207
+ # NOTE: the TS side trims events harder (MAX_PERSISTED_EVENTS = 500); this
208
+ # looser cap only matters for proxy-only projects where no TS producer runs.
209
+ _EVENTS_KEEP = 5000
210
+ _PERF_KEEP_PER_METRIC = 1000 # mirrors telemetry-store.ts PERF_SAMPLES_PER_METRIC
211
+ # Best-effort trim trigger: global across projects and not thread-safe, so a
212
+ # given DB trims roughly every 50*N-projects inserts — overshoot is bounded
213
+ # by the DELETEs themselves, precision is not needed here.
214
+ _trim_counter = 0
215
+
216
+
217
+ def record_dashboard_turn(
218
+ project_dir: str,
219
+ model: str,
220
+ tokens_in: int,
221
+ tokens_out: int,
222
+ duration_ms: float = 0.0,
223
+ ) -> bool:
224
+ """Mirror one completed proxy turn into the project's telemetry.db so the
225
+ dashboard's Live Events and Performance panels move for plain
226
+ claude+proxy sessions. Never raises; a dropped event is strictly better
227
+ than a stalled turn."""
228
+ global _trim_counter
229
+ try:
230
+ db_dir = os.path.join(project_dir, "agents", "data", "memory")
231
+ if not os.path.isdir(db_dir):
232
+ return False
233
+ conn = sqlite3.connect(os.path.join(db_dir, "telemetry.db"), timeout=1.0)
234
+ try:
235
+ # Match the TS producers' settings so cross-process writes don't
236
+ # silently drop under lock contention (busy_timeout 2000: telemetry
237
+ # must never stall the hot path).
238
+ conn.execute("PRAGMA journal_mode = WAL")
239
+ conn.execute("PRAGMA synchronous = NORMAL")
240
+ conn.execute("PRAGMA busy_timeout = 2000")
241
+ conn.executescript(_TELEMETRY_CREATE_SQL)
242
+ conn.execute(
243
+ "INSERT INTO dashboard_events (category, type, severity, title, metadata) "
244
+ "VALUES ('model', 'turn', 'info', ?, ?)",
245
+ (
246
+ f"{model}: {int(tokens_out or 0)} tok out / {int(tokens_in or 0)} in",
247
+ json.dumps(
248
+ {
249
+ "model": str(model or "unknown"),
250
+ "tokensIn": int(tokens_in or 0),
251
+ "tokensOut": int(tokens_out or 0),
252
+ "durationMs": round(float(duration_ms or 0), 1),
253
+ }
254
+ ),
255
+ ),
256
+ )
257
+ if duration_ms and duration_ms > 0:
258
+ conn.execute(
259
+ "INSERT INTO perf_samples (metric, duration_ms) VALUES ('proxy_turn_ms', ?)",
260
+ (float(duration_ms),),
261
+ )
262
+ _trim_counter += 1
263
+ if _trim_counter % 50 == 0:
264
+ conn.execute(
265
+ "DELETE FROM dashboard_events WHERE id NOT IN "
266
+ "(SELECT id FROM dashboard_events ORDER BY id DESC LIMIT ?)",
267
+ (_EVENTS_KEEP,),
268
+ )
269
+ conn.execute(
270
+ "DELETE FROM perf_samples WHERE metric = 'proxy_turn_ms' AND id NOT IN "
271
+ "(SELECT id FROM perf_samples WHERE metric = 'proxy_turn_ms' "
272
+ "ORDER BY id DESC LIMIT ?)",
273
+ (_PERF_KEEP_PER_METRIC,),
274
+ )
275
+ conn.commit()
276
+ finally:
277
+ conn.close()
278
+ return True
279
+ except Exception:
280
+ return False
281
+
282
+
283
+ def record_from_request(
284
+ body: dict, model: str, usage: dict, *, task_id: str = "", duration_ms: float = 0.0
285
+ ) -> bool:
175
286
  """Proxy hot-path entry point: derive project + record. Never raises."""
176
287
  try:
177
288
  project_dir = derive_project_dir(body or {})
178
289
  if not project_dir:
179
290
  return False
180
291
  u = usage or {}
181
- return record_task_outcome(
292
+ tokens_in = int(u.get("input_tokens") or 0)
293
+ tokens_out = int(u.get("output_tokens") or 0)
294
+ wrote = record_task_outcome(
182
295
  project_dir,
183
296
  model,
184
- int(u.get("input_tokens") or 0),
185
- int(u.get("output_tokens") or 0),
297
+ tokens_in,
298
+ tokens_out,
299
+ duration_ms=int(duration_ms or 0),
186
300
  task_id=task_id,
187
301
  )
302
+ if wrote:
303
+ record_dashboard_turn(project_dir, model, tokens_in, tokens_out, duration_ms)
304
+ return wrote
188
305
  except Exception:
189
306
  return False
@@ -0,0 +1,95 @@
1
+ """record_from_request must feed the dashboard live panels, not just analytics.
2
+
3
+ Live Events / Performance read <project>/agents/data/memory/telemetry.db,
4
+ which only the TS producers wrote — plain claude+proxy sessions left those
5
+ panels empty forever. The proxy now mirrors each completed turn there.
6
+ """
7
+ import importlib.util
8
+ import json
9
+ import sqlite3
10
+ import tempfile
11
+ import unittest
12
+ from pathlib import Path
13
+
14
+ pt_path = Path(__file__).resolve().parents[1] / "scripts" / "project_telemetry.py"
15
+ spec = importlib.util.spec_from_file_location("project_telemetry", pt_path)
16
+ pt = importlib.util.module_from_spec(spec)
17
+ spec.loader.exec_module(pt)
18
+
19
+
20
+ class ProjectTelemetryEventsTest(unittest.TestCase):
21
+ def setUp(self):
22
+ self._tmp = tempfile.TemporaryDirectory(prefix="pt-events-")
23
+ self.root = Path(self._tmp.name)
24
+ # A recordable UAP project: marker + existing memory dir (the writer
25
+ # must never scaffold into arbitrary derived directories).
26
+ (self.root / ".git").mkdir()
27
+ (self.root / "agents" / "data" / "memory").mkdir(parents=True)
28
+
29
+ def tearDown(self):
30
+ self._tmp.cleanup()
31
+
32
+ def _body(self):
33
+ # derive_project_dir walks absolute paths echoed in the request.
34
+ return {"messages": [{"role": "user", "content": f"edit {self.root}/src/main.rs"}]}
35
+
36
+ def test_turn_writes_analytics_event_and_perf_sample(self):
37
+ ok = pt.record_from_request(
38
+ self._body(),
39
+ "qwen-test",
40
+ {"input_tokens": 1000, "output_tokens": 50},
41
+ duration_ms=1234.5,
42
+ )
43
+ self.assertTrue(ok)
44
+ mem = self.root / "agents" / "data" / "memory"
45
+
46
+ con = sqlite3.connect(mem / "model_analytics.db")
47
+ row = con.execute(
48
+ "SELECT modelId, tokensIn, tokensOut, durationMs FROM task_outcomes"
49
+ ).fetchone()
50
+ con.close()
51
+ self.assertEqual(row, ("qwen-test", 1000, 50, 1234))
52
+
53
+ con = sqlite3.connect(mem / "telemetry.db")
54
+ ev = con.execute(
55
+ "SELECT category, type, severity, title, metadata FROM dashboard_events"
56
+ ).fetchone()
57
+ perf = con.execute("SELECT metric, duration_ms FROM perf_samples").fetchone()
58
+ con.close()
59
+ self.assertEqual(ev[0:3], ("model", "turn", "info"))
60
+ self.assertIn("qwen-test", ev[3])
61
+ meta = json.loads(ev[4])
62
+ self.assertEqual(meta["tokensIn"], 1000)
63
+ self.assertEqual(meta["tokensOut"], 50)
64
+ self.assertEqual(perf, ("proxy_turn_ms", 1234.5))
65
+
66
+ def test_schema_matches_ts_producer_shape(self):
67
+ # The TS side (telemetry-store.ts) CREATE IF NOT EXISTS must no-op on a
68
+ # DB the proxy created first — the column shape has to match exactly.
69
+ pt.record_from_request(self._body(), "m", {"input_tokens": 1, "output_tokens": 1})
70
+ con = sqlite3.connect(self.root / "agents" / "data" / "memory" / "telemetry.db")
71
+ cols = [r[1] for r in con.execute("PRAGMA table_info(dashboard_events)")]
72
+ perf_cols = [r[1] for r in con.execute("PRAGMA table_info(perf_samples)")]
73
+ con.close()
74
+ self.assertEqual(cols, ["id", "ts", "category", "type", "severity", "title", "detail", "metadata"])
75
+ self.assertEqual(perf_cols, ["id", "metric", "duration_ms", "ts"])
76
+
77
+ def test_never_scaffolds_memory_dir(self):
78
+ bare = Path(self._tmp.name) / "bare"
79
+ (bare / ".git").mkdir(parents=True)
80
+ body = {"messages": [{"role": "user", "content": f"see {bare}/x.txt"}]}
81
+ self.assertFalse(pt.record_from_request(body, "m", {"input_tokens": 1, "output_tokens": 1}))
82
+ self.assertFalse((bare / "agents").exists())
83
+
84
+ def test_zero_duration_skips_perf_sample_but_keeps_event(self):
85
+ pt.record_from_request(self._body(), "m", {"input_tokens": 5, "output_tokens": 5})
86
+ con = sqlite3.connect(self.root / "agents" / "data" / "memory" / "telemetry.db")
87
+ events = con.execute("SELECT count(*) FROM dashboard_events").fetchone()[0]
88
+ perf = con.execute("SELECT count(*) FROM perf_samples").fetchone()[0]
89
+ con.close()
90
+ self.assertEqual(events, 1)
91
+ self.assertEqual(perf, 0)
92
+
93
+
94
+ if __name__ == "__main__":
95
+ unittest.main()
@@ -4,8 +4,11 @@ Regression for the frozen-dashboard incident (2026-07-08): the only
4
4
  _record_project_telemetry call sites were on the non-stream / guarded
5
5
  non-stream paths, so streaming sessions (the client default) wrote no
6
6
  task_outcomes rows and per-project dashboards showed static data forever.
7
+ Follow-up coverage: real output tokens on tool-use turns (upstream usage via
8
+ stream_options.include_usage, char-estimate fallback) and turn duration.
7
9
  """
8
10
  import asyncio
11
+ import json
9
12
  import importlib.util
10
13
  import unittest
11
14
  from pathlib import Path
@@ -30,24 +33,32 @@ class FakeOpenAIStream:
30
33
  pass
31
34
 
32
35
 
33
- OPENAI_LINES = [
36
+ TEXT_TURN_LINES = [
34
37
  'data: {"choices": [{"delta": {"content": "hello"}, "finish_reason": null}]}',
35
38
  'data: {"choices": [{"delta": {}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 456, "completion_tokens": 5}}',
36
39
  "data: [DONE]",
37
40
  ]
38
41
 
42
+ # A pure tool-call turn whose upstream sends NO usage (older servers ignore
43
+ # stream_options.include_usage): output must be estimated from the generated
44
+ # tool-call arguments, not recorded as 0.
45
+ TOOL_TURN_NO_USAGE_LINES = [
46
+ 'data: {"choices": [{"delta": {"tool_calls": [{"index": 0, "id": "call_1", "function": {"name": "Bash", "arguments": ""}}]}, "finish_reason": null}]}',
47
+ 'data: {"choices": [{"delta": {"tool_calls": [{"index": 0, "function": {"arguments": "{\\"command\\": \\"echo hello world this is a long command string\\"}"}}]}, "finish_reason": null}]}',
48
+ 'data: {"choices": [{"delta": {}, "finish_reason": "tool_calls"}]}',
49
+ "data: [DONE]",
50
+ ]
51
+
39
52
 
40
53
  class StreamTelemetryTest(unittest.TestCase):
41
- def _drive(self, body):
54
+ def _drive(self, body, lines=TEXT_TURN_LINES):
42
55
  monitor = ap.SessionMonitor()
43
56
  monitor.last_input_tokens = 123
44
57
  recorded = []
45
58
  saved = ap._record_project_telemetry
46
- ap._record_project_telemetry = lambda b, m, u: recorded.append((b, m, u))
59
+ ap._record_project_telemetry = lambda b, m, u, d=0.0: recorded.append((b, m, u, d))
47
60
  try:
48
- gen = ap.stream_anthropic_response(
49
- FakeOpenAIStream(OPENAI_LINES), "test-model", monitor, body
50
- )
61
+ gen = ap.stream_anthropic_response(FakeOpenAIStream(lines), "test-model", monitor, body)
51
62
 
52
63
  async def consume():
53
64
  return [frame async for frame in gen]
@@ -62,13 +73,37 @@ class StreamTelemetryTest(unittest.TestCase):
62
73
  frames, recorded = self._drive(body)
63
74
  self.assertTrue(any("message_stop" in f for f in frames))
64
75
  self.assertEqual(len(recorded), 1)
65
- recorded_body, model, usage = recorded[0]
76
+ recorded_body, model, usage, duration_ms = recorded[0]
66
77
  self.assertIs(recorded_body, body)
67
78
  self.assertEqual(model, "test-model")
68
79
  # Upstream's final usage chunk wins over the request-side estimate and
69
80
  # the per-delta chunk counter (which misses tool-call turns).
70
81
  self.assertEqual(usage.get("input_tokens"), 456)
71
82
  self.assertEqual(usage.get("output_tokens"), 5)
83
+ self.assertGreaterEqual(duration_ms, 0)
84
+
85
+ def test_tool_turn_without_upstream_usage_estimates_output_tokens(self):
86
+ frames, recorded = self._drive({"messages": []}, TOOL_TURN_NO_USAGE_LINES)
87
+ self.assertEqual(len(recorded), 1)
88
+ _, _, usage, _ = recorded[0]
89
+ # ~60 chars of tool arguments -> well above zero at ~4 chars/token.
90
+ self.assertGreater(usage.get("output_tokens"), 5)
91
+
92
+ def test_xml_recovered_tool_turn_without_usage_still_records(self):
93
+ # The XML-recovery path plants a bare sentinel in tool_calls_by_index
94
+ # (_xml_recovered = True); the char-estimate fallback must not choke on
95
+ # it (regression: AttributeError after message_stop, telemetry lost).
96
+ xml = '<tool_call>{"name": "Bash", "arguments": {"command": "ls -la"}}</tool_call>'
97
+ lines = [
98
+ f'data: {json.dumps({"choices": [{"delta": {"content": xml}, "finish_reason": None}]})}',
99
+ 'data: {"choices": [{"delta": {}, "finish_reason": "stop"}]}',
100
+ "data: [DONE]",
101
+ ]
102
+ frames, recorded = self._drive({"messages": []}, lines)
103
+ self.assertTrue(any("tool_use" in f for f in frames)) # recovery actually fired
104
+ self.assertEqual(len(recorded), 1)
105
+ _, _, usage, _ = recorded[0]
106
+ self.assertGreater(usage.get("output_tokens"), 0)
72
107
 
73
108
  def test_telemetry_recorded_after_client_visible_stream_end(self):
74
109
  # The recorder must not delay or reorder client frames: message_stop is