@kontourai/flow-agents 0.1.2 → 0.3.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.
- package/.github/dependabot.yml +23 -0
- package/.github/workflows/release-please.yml +31 -0
- package/.github/workflows/runtime-compat.yml +118 -0
- package/CHANGELOG.md +46 -0
- package/CONTRIBUTING.md +4 -0
- package/README.md +80 -18
- package/build/src/cli/flow-kit.js +9 -4
- package/build/src/cli/init.js +215 -5
- package/build/src/cli/runtime-adapter.js +9 -5
- package/build/src/cli/telemetry-doctor.js +4 -1
- package/build/src/cli/utterance-check.js +65 -1
- package/build/src/runtime-adapters.js +34 -0
- package/build/src/tools/build-universal-bundles.js +285 -0
- package/build/src/tools/filter-installed-packs.js +3 -0
- package/build/src/tools/validate-source-tree.js +5 -1
- package/console.telemetry.json +115 -20
- package/context/scripts/telemetry/lib/config.sh +5 -1
- package/context/settings/flow-agents-settings.json +7 -0
- package/docs/_layouts/default.html +2 -0
- package/docs/context-map.md +1 -0
- package/docs/index.md +53 -4
- package/docs/integrations/conformance.md +246 -0
- package/docs/integrations/framework-adapter.md +275 -0
- package/docs/integrations/harness-install.md +213 -0
- package/docs/integrations/index.md +58 -0
- package/docs/integrations/knowledge-kit-live.md +211 -0
- package/docs/kit-authoring-guide.md +169 -0
- package/docs/north-star.md +2 -2
- package/docs/spec/runtime-hook-surface.md +525 -0
- package/docs/survey-utterance-check.md +211 -94
- package/docs/vision.md +45 -0
- package/evals/acceptance/run.sh +13 -2
- package/evals/acceptance/test_knowledge_kit_live.sh +221 -0
- package/evals/acceptance/test_opencode_harness.sh +121 -0
- package/evals/acceptance/test_pi_harness.sh +113 -0
- package/evals/integration/test_bundle_install.sh +226 -1
- package/evals/integration/test_bundle_lifecycle.sh +641 -0
- package/evals/integration/test_runtime_adapter_activation.sh +113 -1
- package/evals/integration/test_utterance_check.sh +291 -44
- package/evals/run.sh +2 -0
- package/evals/static/test_universal_bundles.sh +137 -2
- package/integrations/strands/README.md +256 -0
- package/integrations/strands/example.py +74 -0
- package/integrations/strands/examples/knowledge_kit_live.py +461 -0
- package/integrations/strands/flow_agents_strands/__init__.py +27 -0
- package/integrations/strands/flow_agents_strands/hooks.py +194 -0
- package/integrations/strands/flow_agents_strands/policy.py +348 -0
- package/integrations/strands/flow_agents_strands/steering.py +225 -0
- package/integrations/strands/flow_agents_strands/telemetry.py +238 -0
- package/integrations/strands/pyproject.toml +38 -0
- package/integrations/strands/tests/__init__.py +0 -0
- package/integrations/strands/tests/test_hooks.py +392 -0
- package/integrations/strands/tests/test_policy.py +315 -0
- package/integrations/strands/tests/test_telemetry.py +184 -0
- package/integrations/strands-ts/README.md +224 -0
- package/integrations/strands-ts/bin/conformance-shim.mjs +257 -0
- package/integrations/strands-ts/package.json +53 -0
- package/integrations/strands-ts/src/hooks.ts +312 -0
- package/integrations/strands-ts/src/index.ts +22 -0
- package/integrations/strands-ts/src/policy.ts +345 -0
- package/integrations/strands-ts/src/telemetry.ts +251 -0
- package/integrations/strands-ts/test/test-policy.ts +322 -0
- package/integrations/strands-ts/test/test-steering.ts +159 -0
- package/integrations/strands-ts/test/test-telemetry.ts +226 -0
- package/integrations/strands-ts/tsconfig.json +20 -0
- package/kits/catalog.json +6 -0
- package/kits/knowledge/adapters/default-store/index.js +821 -0
- package/kits/knowledge/adapters/flow-runner/index.js +1179 -0
- package/kits/knowledge/adapters/flow-runner/telemetry.js +174 -0
- package/kits/knowledge/docs/README.md +135 -0
- package/kits/knowledge/docs/store-contract.md +526 -0
- package/kits/knowledge/evals/consolidation/suite.test.js +1234 -0
- package/kits/knowledge/evals/contract-suite/suite.test.js +670 -0
- package/kits/knowledge/evals/ingest-compile/suite.test.js +574 -0
- package/kits/knowledge/evals/synthesis/suite.test.js +909 -0
- package/kits/knowledge/flows/compile.flow.json +60 -0
- package/kits/knowledge/flows/consolidate.flow.json +77 -0
- package/kits/knowledge/flows/ingest.flow.json +60 -0
- package/kits/knowledge/flows/store-contract.flow.json +48 -0
- package/kits/knowledge/flows/synthesize.flow.json +77 -0
- package/kits/knowledge/kit.json +78 -0
- package/package.json +7 -2
- package/packaging/conformance/README.md +142 -0
- package/packaging/conformance/fixtures/config-protection--allow-no-path.json +18 -0
- package/packaging/conformance/fixtures/config-protection--allow-safe-file.json +20 -0
- package/packaging/conformance/fixtures/config-protection--block-biome.json +20 -0
- package/packaging/conformance/fixtures/config-protection--block-eslintrc.json +20 -0
- package/packaging/conformance/fixtures/quality-gate--allow-no-path.json +17 -0
- package/packaging/conformance/fixtures/quality-gate--allow-nonexistent-file.json +19 -0
- package/packaging/conformance/fixtures/stop-goal-fit--allow-clean-cwd.json +17 -0
- package/packaging/conformance/fixtures/stop-goal-fit--block-strict-mode.json +23 -0
- package/packaging/conformance/fixtures/stop-goal-fit--warn-active-delivery.json +21 -0
- package/packaging/conformance/fixtures/workflow-steering--allow-no-state.json +16 -0
- package/packaging/conformance/fixtures/workflow-steering--inject-active-state.json +29 -0
- package/packaging/conformance/fixtures/workflow-steering--inject-subagent-steering.json +25 -0
- package/packaging/conformance/package.json +4 -0
- package/packaging/conformance/run-conformance.js +322 -0
- package/packaging/manifest.json +59 -0
- package/schemas/flow-agents-settings.schema.json +48 -0
- package/scripts/README.md +4 -0
- package/scripts/dogfood.js +16 -0
- package/scripts/hooks/opencode-hook-adapter.js +123 -0
- package/scripts/hooks/opencode-telemetry-hook.js +101 -0
- package/scripts/hooks/pi-hook-adapter.js +123 -0
- package/scripts/hooks/pi-telemetry-hook.js +105 -0
- package/scripts/hooks/run-hook.js +8 -0
- package/scripts/hooks/utterance-check.js +124 -22
- package/scripts/telemetry/lib/config.sh +5 -1
- package/src/cli/flow-kit.ts +10 -4
- package/src/cli/init.ts +219 -6
- package/src/cli/runtime-adapter.ts +10 -5
- package/src/cli/telemetry-doctor.ts +4 -1
- package/src/cli/utterance-check.ts +71 -1
- package/src/runtime-adapters.ts +35 -0
- package/src/tools/build-universal-bundles.ts +283 -0
- package/src/tools/filter-installed-packs.ts +3 -0
- 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()
|