@kontourai/flow-agents 0.1.2 → 0.2.0

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.
Files changed (85) hide show
  1. package/.github/dependabot.yml +23 -0
  2. package/.github/workflows/release-please.yml +31 -0
  3. package/.github/workflows/runtime-compat.yml +118 -0
  4. package/CHANGELOG.md +23 -0
  5. package/CONTRIBUTING.md +4 -0
  6. package/README.md +53 -10
  7. package/build/src/cli/init.js +215 -5
  8. package/build/src/cli/utterance-check.js +65 -1
  9. package/build/src/tools/build-universal-bundles.js +268 -0
  10. package/build/src/tools/filter-installed-packs.js +3 -0
  11. package/build/src/tools/validate-source-tree.js +5 -1
  12. package/context/scripts/telemetry/lib/config.sh +5 -1
  13. package/context/settings/flow-agents-settings.json +7 -0
  14. package/docs/context-map.md +1 -0
  15. package/docs/index.md +45 -4
  16. package/docs/integrations/conformance.md +246 -0
  17. package/docs/integrations/framework-adapter.md +275 -0
  18. package/docs/integrations/harness-install.md +213 -0
  19. package/docs/integrations/index.md +54 -0
  20. package/docs/north-star.md +2 -2
  21. package/docs/spec/runtime-hook-surface.md +472 -0
  22. package/docs/survey-utterance-check.md +211 -94
  23. package/docs/vision.md +45 -0
  24. package/evals/acceptance/run.sh +4 -2
  25. package/evals/acceptance/test_opencode_harness.sh +121 -0
  26. package/evals/acceptance/test_pi_harness.sh +98 -0
  27. package/evals/integration/test_bundle_install.sh +226 -1
  28. package/evals/integration/test_bundle_lifecycle.sh +641 -0
  29. package/evals/integration/test_utterance_check.sh +291 -44
  30. package/evals/run.sh +2 -0
  31. package/evals/static/test_universal_bundles.sh +137 -2
  32. package/integrations/strands/README.md +256 -0
  33. package/integrations/strands/example.py +74 -0
  34. package/integrations/strands/flow_agents_strands/__init__.py +27 -0
  35. package/integrations/strands/flow_agents_strands/hooks.py +194 -0
  36. package/integrations/strands/flow_agents_strands/policy.py +348 -0
  37. package/integrations/strands/flow_agents_strands/steering.py +172 -0
  38. package/integrations/strands/flow_agents_strands/telemetry.py +238 -0
  39. package/integrations/strands/pyproject.toml +38 -0
  40. package/integrations/strands/tests/__init__.py +0 -0
  41. package/integrations/strands/tests/test_hooks.py +304 -0
  42. package/integrations/strands/tests/test_policy.py +315 -0
  43. package/integrations/strands/tests/test_telemetry.py +184 -0
  44. package/integrations/strands-ts/README.md +224 -0
  45. package/integrations/strands-ts/bin/conformance-shim.mjs +257 -0
  46. package/integrations/strands-ts/package.json +53 -0
  47. package/integrations/strands-ts/src/hooks.ts +208 -0
  48. package/integrations/strands-ts/src/index.ts +22 -0
  49. package/integrations/strands-ts/src/policy.ts +345 -0
  50. package/integrations/strands-ts/src/telemetry.ts +251 -0
  51. package/integrations/strands-ts/test/test-policy.ts +322 -0
  52. package/integrations/strands-ts/test/test-telemetry.ts +226 -0
  53. package/integrations/strands-ts/tsconfig.json +20 -0
  54. package/package.json +7 -2
  55. package/packaging/conformance/README.md +142 -0
  56. package/packaging/conformance/fixtures/config-protection--allow-no-path.json +18 -0
  57. package/packaging/conformance/fixtures/config-protection--allow-safe-file.json +20 -0
  58. package/packaging/conformance/fixtures/config-protection--block-biome.json +20 -0
  59. package/packaging/conformance/fixtures/config-protection--block-eslintrc.json +20 -0
  60. package/packaging/conformance/fixtures/quality-gate--allow-no-path.json +17 -0
  61. package/packaging/conformance/fixtures/quality-gate--allow-nonexistent-file.json +19 -0
  62. package/packaging/conformance/fixtures/stop-goal-fit--allow-clean-cwd.json +17 -0
  63. package/packaging/conformance/fixtures/stop-goal-fit--block-strict-mode.json +23 -0
  64. package/packaging/conformance/fixtures/stop-goal-fit--warn-active-delivery.json +21 -0
  65. package/packaging/conformance/fixtures/workflow-steering--allow-no-state.json +16 -0
  66. package/packaging/conformance/fixtures/workflow-steering--inject-active-state.json +29 -0
  67. package/packaging/conformance/fixtures/workflow-steering--inject-subagent-steering.json +25 -0
  68. package/packaging/conformance/package.json +4 -0
  69. package/packaging/conformance/run-conformance.js +322 -0
  70. package/packaging/manifest.json +59 -0
  71. package/schemas/flow-agents-settings.schema.json +48 -0
  72. package/scripts/README.md +4 -0
  73. package/scripts/dogfood.js +16 -0
  74. package/scripts/hooks/opencode-hook-adapter.js +123 -0
  75. package/scripts/hooks/opencode-telemetry-hook.js +101 -0
  76. package/scripts/hooks/pi-hook-adapter.js +123 -0
  77. package/scripts/hooks/pi-telemetry-hook.js +105 -0
  78. package/scripts/hooks/run-hook.js +8 -0
  79. package/scripts/hooks/utterance-check.js +124 -22
  80. package/scripts/telemetry/lib/config.sh +5 -1
  81. package/src/cli/init.ts +219 -6
  82. package/src/cli/utterance-check.ts +71 -1
  83. package/src/tools/build-universal-bundles.ts +266 -0
  84. package/src/tools/filter-installed-packs.ts +3 -0
  85. package/src/tools/validate-source-tree.ts +5 -1
@@ -0,0 +1,315 @@
1
+ """
2
+ Tests for policy module — config-protection gate.
3
+
4
+ Uses stdlib unittest only; no strands-agents required.
5
+ """
6
+
7
+ import unittest
8
+
9
+
10
+ class TestPolicyGateConfigProtection(unittest.TestCase):
11
+
12
+ def setUp(self):
13
+ from flow_agents_strands.policy import PolicyGate
14
+ self._gate = PolicyGate()
15
+
16
+ # --- Blocked write tools ---
17
+
18
+ def test_blocks_write_to_eslintrc(self):
19
+ reason = self._gate.check_tool_call("write", {"path": "/repo/.eslintrc.json"})
20
+ self.assertIsNotNone(reason)
21
+ self.assertIn("BLOCKED", reason)
22
+ self.assertIn(".eslintrc.json", reason)
23
+
24
+ def test_blocks_edit_to_prettier_config(self):
25
+ reason = self._gate.check_tool_call("edit", {"path": "prettier.config.js"})
26
+ self.assertIsNotNone(reason)
27
+ self.assertIn("BLOCKED", reason)
28
+
29
+ def test_blocks_fs_write_to_biome_json(self):
30
+ reason = self._gate.check_tool_call("fs_write", {"file_path": "biome.json"})
31
+ self.assertIsNotNone(reason)
32
+
33
+ def test_blocks_edit_to_ruff_toml(self):
34
+ reason = self._gate.check_tool_call("edit", {"path": "ruff.toml"})
35
+ self.assertIsNotNone(reason)
36
+
37
+ def test_blocks_apply_patch_to_markdownlint(self):
38
+ reason = self._gate.check_tool_call(
39
+ "apply_patch", {"path": ".markdownlint.json"}
40
+ )
41
+ self.assertIsNotNone(reason)
42
+
43
+ def test_block_message_includes_guidance(self):
44
+ reason = self._gate.check_tool_call("write", {"path": ".eslintrc"})
45
+ self.assertIn("linter/formatter rules", reason)
46
+
47
+ # --- Allowed cases ---
48
+
49
+ def test_allows_write_to_regular_python_file(self):
50
+ reason = self._gate.check_tool_call("write", {"path": "src/main.py"})
51
+ self.assertIsNone(reason)
52
+
53
+ def test_allows_read_on_protected_file(self):
54
+ """Read tools must never be blocked."""
55
+ reason = self._gate.check_tool_call("read", {"path": ".eslintrc.json"})
56
+ self.assertIsNone(reason)
57
+
58
+ def test_allows_bash(self):
59
+ reason = self._gate.check_tool_call("bash", {"command": "ls"})
60
+ self.assertIsNone(reason)
61
+
62
+ def test_allows_write_without_path(self):
63
+ """No path → no block."""
64
+ reason = self._gate.check_tool_call("write", {})
65
+ self.assertIsNone(reason)
66
+
67
+ def test_allows_write_to_package_json(self):
68
+ reason = self._gate.check_tool_call("write", {"path": "package.json"})
69
+ self.assertIsNone(reason)
70
+
71
+ # --- Full protected-files coverage ---
72
+
73
+ def test_all_canonical_protected_files_are_blocked(self):
74
+ from flow_agents_strands.policy import PROTECTED_FILES
75
+ for fname in PROTECTED_FILES:
76
+ with self.subTest(file=fname):
77
+ reason = self._gate.check_tool_call("write", {"path": f"/repo/{fname}"})
78
+ self.assertIsNotNone(
79
+ reason,
80
+ f"Expected {fname} to be blocked but got None",
81
+ )
82
+
83
+
84
+ class TestPolicyGateCustomProtectedFiles(unittest.TestCase):
85
+ """Verify callers can override the protected-files set."""
86
+
87
+ def test_custom_protected_set(self):
88
+ from flow_agents_strands.policy import PolicyGate
89
+ gate = PolicyGate(protected_files=frozenset(["pyproject.toml"]))
90
+ self.assertIsNotNone(gate.check_tool_call("write", {"path": "pyproject.toml"}))
91
+ # Default protected files should NOT be blocked with the custom set
92
+ self.assertIsNone(gate.check_tool_call("write", {"path": ".eslintrc.json"}))
93
+
94
+
95
+ if __name__ == "__main__":
96
+ unittest.main()
97
+
98
+
99
+ # ============================================================================
100
+ # Contract-binding tests — verify subprocess delegation to the Node.js engine
101
+ # ============================================================================
102
+
103
+
104
+ class _FakeNodeProcess:
105
+ """
106
+ Fake subprocess.run result for testing the engine binding path.
107
+ """
108
+
109
+ def __init__(self, returncode: int, stdout: str = "", stderr: str = ""):
110
+ self.returncode = returncode
111
+ self.stdout = stdout
112
+ self.stderr = stderr
113
+
114
+
115
+ class TestPolicyGateEngineBinding(unittest.TestCase):
116
+ """
117
+ Verify that PolicyGate delegates to the engine subprocess contract.
118
+
119
+ These tests inject a fake node path and engine path to exercise the
120
+ subprocess-binding code path without requiring a live Node.js process.
121
+ """
122
+
123
+ def _make_gate_with_fake_engine(self, fake_returncode, fake_stderr="", fake_stdout=""):
124
+ """
125
+ Return a PolicyGate wired to a fake engine via monkeypatching.
126
+
127
+ We pass _node_bin='node' and _run_hook_path='/fake/run-hook.js' so
128
+ _engine_available is True, then patch _invoke_engine at the module level.
129
+ """
130
+ import unittest.mock as mock
131
+ from flow_agents_strands import policy as policy_module
132
+
133
+ gate = policy_module.PolicyGate(
134
+ _node_bin="node",
135
+ _run_hook_path="/fake/run-hook.js",
136
+ )
137
+
138
+ fake_result = (fake_returncode, fake_stdout, fake_stderr)
139
+ self._patcher = mock.patch.object(
140
+ policy_module, "_invoke_engine", return_value=fake_result
141
+ )
142
+ self._mock_invoke = self._patcher.start()
143
+ return gate
144
+
145
+ def tearDown(self):
146
+ if hasattr(self, "_patcher"):
147
+ self._patcher.stop()
148
+
149
+ def test_engine_block_returns_stderr_reason(self):
150
+ """When engine exits 2, the block reason is taken from stderr."""
151
+ gate = self._make_gate_with_fake_engine(
152
+ fake_returncode=2,
153
+ fake_stderr="BLOCKED: Modifying .eslintrc.json is not allowed. Fix the source code."
154
+ )
155
+ reason = gate.check_tool_call("write", {"path": ".eslintrc.json"})
156
+ self.assertIsNotNone(reason)
157
+ self.assertIn("BLOCKED", reason)
158
+ self.assertIn(".eslintrc.json", reason)
159
+
160
+ def test_engine_allow_returns_none(self):
161
+ """When engine exits 0, check_tool_call returns None (allowed)."""
162
+ gate = self._make_gate_with_fake_engine(fake_returncode=0)
163
+ result = gate.check_tool_call("write", {"path": "src/main.ts"})
164
+ self.assertIsNone(result)
165
+
166
+ def test_engine_error_fails_open(self):
167
+ """When engine exits non-0 non-2, check_tool_call fails open (returns None)."""
168
+ gate = self._make_gate_with_fake_engine(fake_returncode=1, fake_stderr="some error")
169
+ result = gate.check_tool_call("write", {"path": ".eslintrc.json"})
170
+ self.assertIsNone(result)
171
+
172
+ def test_engine_invoked_with_correct_payload_shape(self):
173
+ """Verify the payload sent to the engine has the expected structure."""
174
+ import unittest.mock as mock
175
+ from flow_agents_strands import policy as policy_module
176
+
177
+ gate = policy_module.PolicyGate(
178
+ _node_bin="node",
179
+ _run_hook_path="/fake/run-hook.js",
180
+ )
181
+
182
+ with mock.patch.object(policy_module, "_invoke_engine", return_value=(0, "", "")) as m:
183
+ gate.check_tool_call("write", {"path": "src/main.ts"})
184
+ m.assert_called_once()
185
+ call_kwargs = m.call_args
186
+ # payload is passed as positional; check via args
187
+ payload = call_kwargs[1]["payload"] if "payload" in call_kwargs[1] else call_kwargs[0][2]
188
+ self.assertEqual("PreToolUse", payload.get("hook_event_name"))
189
+ self.assertEqual("write", payload.get("tool_name"))
190
+ self.assertEqual({"path": "src/main.ts"}, payload.get("tool_input"))
191
+
192
+ def test_read_tool_skips_engine(self):
193
+ """Read tools must bypass the engine entirely (tool-name pre-filter)."""
194
+ import unittest.mock as mock
195
+ from flow_agents_strands import policy as policy_module
196
+
197
+ gate = policy_module.PolicyGate(
198
+ _node_bin="node",
199
+ _run_hook_path="/fake/run-hook.js",
200
+ )
201
+
202
+ with mock.patch.object(policy_module, "_invoke_engine", return_value=(2, "", "BLOCKED")) as m:
203
+ result = gate.check_tool_call("read", {"path": ".eslintrc.json"})
204
+ self.assertIsNone(result)
205
+ m.assert_not_called()
206
+
207
+ def test_custom_protected_set_bypasses_engine(self):
208
+ """Custom protected_files use Python evaluation, not the engine subprocess."""
209
+ import unittest.mock as mock
210
+ from flow_agents_strands import policy as policy_module
211
+
212
+ gate = policy_module.PolicyGate(
213
+ protected_files=frozenset(["pyproject.toml"]),
214
+ _node_bin="node",
215
+ _run_hook_path="/fake/run-hook.js",
216
+ )
217
+
218
+ with mock.patch.object(policy_module, "_invoke_engine") as m:
219
+ result = gate.check_tool_call("write", {"path": "pyproject.toml"})
220
+ self.assertIsNotNone(result) # blocked by custom set
221
+ m.assert_not_called() # engine not called
222
+
223
+ def test_no_engine_path_falls_back_to_python(self):
224
+ """When run-hook.js is not found, PolicyGate falls back to Python evaluation."""
225
+ import warnings
226
+ from flow_agents_strands import policy as policy_module
227
+
228
+ # Passing None explicitly overrides module-level resolution, forcing fallback
229
+ gate = policy_module.PolicyGate(_node_bin="node", _run_hook_path=None)
230
+ with warnings.catch_warnings(record=True) as caught:
231
+ warnings.simplefilter("always")
232
+ result = gate.check_tool_call("write", {"path": ".eslintrc.json"})
233
+ self.assertIsNotNone(result)
234
+ self.assertIn("BLOCKED", result)
235
+ # Should have emitted the fallback warning
236
+ runtime_warnings = [w for w in caught if issubclass(w.category, RuntimeWarning)]
237
+ self.assertEqual(1, len(runtime_warnings))
238
+ self.assertIn("Node.js", str(runtime_warnings[0].message))
239
+
240
+ def test_no_node_falls_back_to_python(self):
241
+ """When node binary is not found, PolicyGate falls back to Python evaluation."""
242
+ import warnings
243
+ from flow_agents_strands import policy as policy_module
244
+
245
+ # Passing None explicitly overrides module-level resolution, forcing fallback
246
+ gate = policy_module.PolicyGate(_node_bin=None, _run_hook_path="/fake/run-hook.js")
247
+ with warnings.catch_warnings(record=True):
248
+ warnings.simplefilter("always")
249
+ result = gate.check_tool_call("write", {"path": ".eslintrc.json"})
250
+ self.assertIsNotNone(result)
251
+ self.assertIn("BLOCKED", result)
252
+
253
+
254
+ # ============================================================================
255
+ # End-to-end test — invokes the actual Node.js engine
256
+ # ============================================================================
257
+
258
+
259
+ class TestPolicyGateEndToEnd(unittest.TestCase):
260
+ """
261
+ Real end-to-end test: invokes the actual node engine via subprocess.
262
+
263
+ Skipped gracefully if node is not available or the engine script cannot
264
+ be located.
265
+ """
266
+
267
+ @classmethod
268
+ def setUpClass(cls):
269
+ """Resolve engine paths once; skip the whole class if unavailable."""
270
+ import shutil
271
+ from flow_agents_strands.policy import _find_engine_paths
272
+
273
+ node, run_hook = _find_engine_paths()
274
+ if not node or not run_hook:
275
+ raise unittest.SkipTest(
276
+ "Node.js or the Flow Agents engine script (run-hook.js) is not available. "
277
+ "Skipping end-to-end policy tests."
278
+ )
279
+ cls._node_bin = node
280
+ cls._run_hook_path = run_hook
281
+
282
+ def _make_gate(self):
283
+ from flow_agents_strands.policy import PolicyGate
284
+ return PolicyGate(_node_bin=self._node_bin, _run_hook_path=self._run_hook_path)
285
+
286
+ def test_e2e_blocks_eslintrc_write(self):
287
+ """Real engine call: blocks write to .eslintrc.json."""
288
+ gate = self._make_gate()
289
+ reason = gate.check_tool_call("write", {"path": "/repo/.eslintrc.json"})
290
+ self.assertIsNotNone(reason, "Expected engine to block .eslintrc.json write")
291
+ self.assertIn("BLOCKED", reason)
292
+ self.assertIn(".eslintrc.json", reason)
293
+
294
+ def test_e2e_allows_safe_file_write(self):
295
+ """Real engine call: allows write to src/main.ts."""
296
+ gate = self._make_gate()
297
+ result = gate.check_tool_call("write", {"path": "src/main.ts"})
298
+ self.assertIsNone(result, "Expected engine to allow src/main.ts write")
299
+
300
+ def test_e2e_allows_read_on_protected_file(self):
301
+ """Real engine call: read tools bypass the engine (tool-name pre-filter)."""
302
+ gate = self._make_gate()
303
+ result = gate.check_tool_call("read", {"path": ".eslintrc.json"})
304
+ self.assertIsNone(result, "Read on protected file must never be blocked")
305
+
306
+ def test_e2e_blocks_biome_json_via_file_path_key(self):
307
+ """Real engine call: blocks edit to biome.json using file_path key."""
308
+ gate = self._make_gate()
309
+ reason = gate.check_tool_call("edit", {"file_path": "biome.json"})
310
+ self.assertIsNotNone(reason)
311
+ self.assertIn("biome.json", reason)
312
+
313
+
314
+ if __name__ == "__main__":
315
+ unittest.main()
@@ -0,0 +1,184 @@
1
+ """
2
+ Tests for telemetry module — event mapping and JSONL emission shape.
3
+
4
+ Uses stdlib unittest only; no strands-agents required.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import tempfile
10
+ import unittest
11
+ from pathlib import Path
12
+
13
+
14
+ class TestStrandsToCanonicalMapping(unittest.TestCase):
15
+ """Verify the Strands → canonical event-name mapping table."""
16
+
17
+ def setUp(self):
18
+ from flow_agents_strands.telemetry import STRANDS_TO_CANONICAL
19
+ self.mapping = STRANDS_TO_CANONICAL
20
+
21
+ def test_all_expected_keys_present(self):
22
+ expected = {
23
+ "AgentInitializedEvent",
24
+ "BeforeInvocationEvent",
25
+ "AfterInvocationEvent",
26
+ "BeforeToolCallEvent",
27
+ "AfterToolCallEvent",
28
+ "AfterModelCallEvent",
29
+ "MessageAddedEvent",
30
+ }
31
+ self.assertEqual(expected, set(self.mapping.keys()))
32
+
33
+ def test_before_invocation_maps_to_user_prompt_submit(self):
34
+ self.assertEqual("userPromptSubmit", self.mapping["BeforeInvocationEvent"])
35
+
36
+ def test_after_invocation_maps_to_stop(self):
37
+ self.assertEqual("stop", self.mapping["AfterInvocationEvent"])
38
+
39
+ def test_before_tool_call_maps_to_pre_tool_use(self):
40
+ self.assertEqual("preToolUse", self.mapping["BeforeToolCallEvent"])
41
+
42
+ def test_after_tool_call_maps_to_post_tool_use(self):
43
+ self.assertEqual("postToolUse", self.mapping["AfterToolCallEvent"])
44
+
45
+ def test_agent_initialized_maps_to_agent_spawn(self):
46
+ self.assertEqual("agentSpawn", self.mapping["AgentInitializedEvent"])
47
+
48
+ def test_all_values_are_strings(self):
49
+ for k, v in self.mapping.items():
50
+ with self.subTest(key=k):
51
+ self.assertIsInstance(v, str)
52
+
53
+
54
+ class TestTelemetrySinkEmission(unittest.TestCase):
55
+ """Verify JSONL emission shape matches the canonical Flow Agents schema."""
56
+
57
+ def setUp(self):
58
+ from flow_agents_strands.telemetry import TelemetrySink
59
+ self._tmp = tempfile.TemporaryDirectory()
60
+ self._sink_dir = Path(self._tmp.name)
61
+ self._sink = TelemetrySink(
62
+ sink_path=str(self._sink_dir),
63
+ agent_name="test-agent",
64
+ runtime="strands-test",
65
+ )
66
+
67
+ def tearDown(self):
68
+ self._tmp.cleanup()
69
+
70
+ def _read_events(self):
71
+ log_file = self._sink_dir / "full.jsonl"
72
+ if not log_file.exists():
73
+ return []
74
+ lines = log_file.read_text(encoding="utf-8").strip().splitlines()
75
+ return [json.loads(line) for line in lines if line.strip()]
76
+
77
+ def test_session_start_event_shape(self):
78
+ evt = self._sink.emit_session_start()
79
+
80
+ # Top-level required fields (mirrors build_base_event in telemetry.sh)
81
+ self.assertEqual("0.3.0", evt["schema_version"])
82
+ self.assertIn("timestamp", evt)
83
+ self.assertIn("session_id", evt)
84
+ self.assertIn("event_id", evt)
85
+ self.assertEqual("session.start", evt["event_type"])
86
+
87
+ # Agent sub-object
88
+ agent = evt["agent"]
89
+ self.assertEqual("test-agent", agent["name"])
90
+ self.assertEqual("strands-test", agent["runtime"])
91
+
92
+ def test_session_start_written_to_jsonl(self):
93
+ self._sink.emit_session_start()
94
+ events = self._read_events()
95
+ self.assertEqual(1, len(events))
96
+ self.assertEqual("session.start", events[0]["event_type"])
97
+
98
+ def test_tool_invoke_event_shape(self):
99
+ evt = self._sink.emit_tool_invoke("edit", {"path": "foo.py"})
100
+ self.assertEqual("tool.invoke", evt["event_type"])
101
+ self.assertEqual("edit", evt["tool"]["name"])
102
+ self.assertEqual("fs_write", evt["tool"]["normalized_name"])
103
+ self.assertEqual({"path": "foo.py"}, evt["tool"]["input"])
104
+
105
+ def test_tool_result_event_shape(self):
106
+ evt = self._sink.emit_tool_result("read", "file contents")
107
+ self.assertEqual("tool.result", evt["event_type"])
108
+ self.assertEqual("read", evt["tool"]["name"])
109
+ self.assertEqual("fs_read", evt["tool"]["normalized_name"])
110
+ self.assertEqual("file contents", evt["tool"]["output"])
111
+
112
+ def test_session_end_event_shape(self):
113
+ evt = self._sink.emit_session_end(duration_s=42.5)
114
+ self.assertEqual("session.end", evt["event_type"])
115
+ self.assertAlmostEqual(42.5, evt["session"]["duration_s"])
116
+
117
+ def test_user_prompt_submit_event_shape(self):
118
+ evt = self._sink.emit("userPromptSubmit")
119
+ self.assertEqual("turn.user", evt["event_type"])
120
+
121
+ def test_hook_context_present(self):
122
+ """Every event must include a hook sub-object (mirrors add_hook_context)."""
123
+ evt = self._sink.emit_session_start()
124
+ self.assertIn("hook", evt)
125
+ hook = evt["hook"]
126
+ self.assertIn("event_name", hook)
127
+ self.assertIn("source", hook)
128
+ self.assertEqual("strands", hook["source"])
129
+
130
+ def test_multiple_events_same_session_id(self):
131
+ self._sink.emit_session_start()
132
+ self._sink.emit_tool_invoke("read", {})
133
+ self._sink.emit_session_end()
134
+ events = self._read_events()
135
+ session_ids = {e["session_id"] for e in events}
136
+ self.assertEqual(1, len(session_ids), "All events must share one session_id")
137
+
138
+ def test_jsonl_each_line_valid_json(self):
139
+ self._sink.emit_session_start()
140
+ self._sink.emit_tool_invoke("bash", {"command": "ls"})
141
+ self._sink.emit_session_end(duration_s=1.0)
142
+ log_file = self._sink_dir / "full.jsonl"
143
+ for line in log_file.read_text(encoding="utf-8").splitlines():
144
+ if line.strip():
145
+ parsed = json.loads(line) # will raise on invalid JSON
146
+ self.assertIsInstance(parsed, dict)
147
+
148
+ def test_sink_path_directory_creates_full_jsonl(self):
149
+ """When sink_path is a directory, file is named full.jsonl."""
150
+ from flow_agents_strands.telemetry import TelemetrySink
151
+ with tempfile.TemporaryDirectory() as d:
152
+ sink = TelemetrySink(sink_path=d)
153
+ sink.emit_session_start()
154
+ log_file = Path(d) / "full.jsonl"
155
+ self.assertTrue(log_file.exists())
156
+
157
+ def test_emit_steering_event_type(self):
158
+ evt = self._sink.emit_steering("STATE: task is status:in_progress")
159
+ self.assertEqual("turn.user", evt["event_type"])
160
+ self.assertIn("steering_context", evt["turn"])
161
+
162
+
163
+ class TestNormalizeToolName(unittest.TestCase):
164
+ """Spot-check normalize_tool_name mirrors telemetry.sh."""
165
+
166
+ def setUp(self):
167
+ from flow_agents_strands.telemetry import _normalize_tool_name
168
+ self._fn = _normalize_tool_name
169
+
170
+ def test_bash(self):
171
+ self.assertEqual("execute_bash", self._fn("bash"))
172
+
173
+ def test_edit_is_fs_write(self):
174
+ self.assertEqual("fs_write", self._fn("edit"))
175
+
176
+ def test_read_is_fs_read(self):
177
+ self.assertEqual("fs_read", self._fn("read"))
178
+
179
+ def test_unknown_passthrough(self):
180
+ self.assertEqual("my_custom_tool", self._fn("my_custom_tool"))
181
+
182
+
183
+ if __name__ == "__main__":
184
+ unittest.main()