@miller-tech/uap 1.129.2 → 1.129.4

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.
@@ -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