1- """Tests for scripts/introspection_extract.py — deterministic, anonymized digest (#89)."""
1+ """Tests for scripts/introspection_extract.py — deterministic, anonymized digest (#89).
2+
3+ Tool-failure detection is STRUCTURAL (#347): every Hermes tool serialises its
4+ result as a JSON envelope carrying the authoritative status (``exit_code`` for
5+ terminal/code-exec, ``error``/``success``/``status`` for the rest). The digest
6+ reads that status instead of substring-scanning the body, so marker words
7+ ("404", "error:", "failed") inside a SUCCESSFUL result's output no longer count
8+ as failures. Fixtures therefore use realistic envelopes, not bare strings.
9+ """
210
311import json
412import sys
@@ -28,21 +36,61 @@ def _tool(cid, content):
2836 return {"role" : "tool" , "tool_call_id" : cid , "content" : content }
2937
3038
39+ # --- realistic tool-result envelopes (#347) ----------------------------------
40+ def _term (output = "" , * , exit_code = 0 , error = None ):
41+ """Terminal / code-exec envelope: failure is signalled by exit_code != 0."""
42+ return json .dumps ({"output" : output , "exit_code" : exit_code , "error" : error }, ensure_ascii = False )
43+
44+
45+ def _ok (** fields ):
46+ """A successful non-terminal envelope (e.g. read_file → {"content": ...}).
47+
48+ No ``error``, no nonzero ``exit_code`` → never counted as a failure, even
49+ when ``fields`` carry marker words in their values."""
50+ return json .dumps (fields or {"success" : True }, ensure_ascii = False )
51+
52+
53+ def _fail (error = "error" ):
54+ """A failed non-terminal envelope (read_file/skill/etc. → {"error": ...})."""
55+ return json .dumps ({"error" : error }, ensure_ascii = False )
56+
57+
3158class TestScanSession :
3259 def test_attributes_failures_to_tool (self , tmp_path ):
3360 p = _session (tmp_path , "s1" , [
3461 {"role" : "session_meta" },
35- _asst ("terminal" , "c1" ), _tool ("c1" , "bash: foo: command not found" ),
36- _asst ("terminal" , "c2" ), _tool ("c2" , " permission denied" ),
37- _asst ("read_file" , "c3" ), _tool ("c3" , "ok, file contents here" ),
62+ _asst ("terminal" , "c1" ), _tool ("c1" , _term ( "bash: foo: command not found" , exit_code = 127 ) ),
63+ _asst ("terminal" , "c2" ), _tool ("c2" , _term ( "" , exit_code = 1 , error = " permission denied") ),
64+ _asst ("read_file" , "c3" ), _tool ("c3" , _ok ( content = "ok, file contents here" ) ),
3865 ])
3966 s = scan_session (p )
4067 assert s ["tool_failures" ] == {"terminal" : 2 }
4168 assert "read_file" not in s ["tool_failures" ]
4269
70+ def test_structural_ignores_marker_words_in_successful_output (self , tmp_path ):
71+ """#347 regression: marker words in the BODY of a SUCCESSFUL result
72+ must NOT be counted. The old substring matcher fired on file content
73+ ("HTTP 404"), grep stdout ("error:"), and skill docs ("timeout") even
74+ though every call succeeded; the structural classifier counts none."""
75+ p = _session (tmp_path , "fp" , [
76+ _asst ("read_file" , "c1" ), _tool ("c1" , _ok (content = "page says HTTP 404 Not Found; error: none" )),
77+ _asst ("terminal" , "c2" ), _tool ("c2" , _term ("grep hit: error: deprecated\n build failed? no" , exit_code = 0 )),
78+ _asst ("skill_view" , "c3" ), _tool ("c3" , _ok (content = "docs cover 404 and timeout handling" )),
79+ ])
80+ s = scan_session (p )
81+ assert s ["tool_failures" ] == {}
82+
83+ def test_error_field_counts_for_non_terminal_tools (self , tmp_path ):
84+ p = _session (tmp_path , "ef" , [
85+ _asst ("read_file" , "c1" ), _tool ("c1" , _fail ("no such file or directory" )),
86+ _asst ("patch" , "c2" ), _tool ("c2" , _ok (success = False )),
87+ ])
88+ s = scan_session (p )
89+ assert s ["tool_failures" ] == {"read_file" : 1 , "patch" : 1 }
90+
4391 def test_counts_timeouts_and_refusals (self , tmp_path ):
4492 p = _session (tmp_path , "s2" , [
45- _asst ("mcp_health" , "c1" ), _tool ("c1" , " request timed out after 120s" ),
93+ _asst ("mcp_health" , "c1" ), _tool ("c1" , _term ( "" , exit_code = - 1 , error = " request timed out after 120s") ),
4694 {"role" : "assistant" , "content" : "I can't access that path." },
4795 ])
4896 s = scan_session (p )
@@ -52,25 +100,27 @@ def test_counts_timeouts_and_refusals(self, tmp_path):
52100 def test_repeated_run_detected (self , tmp_path ):
53101 lines = [{"role" : "session_meta" }]
54102 for i in range (6 ):
55- lines += [_asst ("terminal" , f"c{ i } " ), _tool (f"c{ i } " , "ok" )]
103+ lines += [_asst ("terminal" , f"c{ i } " ), _tool (f"c{ i } " , _term ( "ok" ) )]
56104 p = _session (tmp_path , "s3" , lines )
57105 s = scan_session (p )
58106 assert s ["repeated_tool_runs" ].get ("terminal" ) == 6
59107
60108 def test_no_raw_text_in_output (self , tmp_path ):
61- secret = "USER SECRET email bob@example.com lives at 5 Main St"
109+ secret = "USER SECRET email <REDACTED:email:db677acc382bd26bb3a00162f3e668d3> lives at 5 Main St"
62110 p = _session (tmp_path , "s4" , [
63- _asst ("terminal" , "c1" ), _tool ("c1" , f"error: { secret } " ),
111+ _asst ("terminal" , "c1" ), _tool ("c1" , _term ( "" , exit_code = 1 , error = secret ) ),
64112 ])
65113 s = scan_session (p )
66- # Digest carries only counts/tool names — never the raw content.
114+ # A genuine failure is counted, but the digest carries only counts/tool
115+ # names — never the raw content/error text.
116+ assert s ["tool_failures" ] == {"terminal" : 1 }
67117 assert secret not in json .dumps (s )
68118
69119
70120class TestBuildDigest :
71121 def test_window_excludes_old_sessions (self , tmp_path ):
72- _session (tmp_path , "recent" , [_asst ("terminal" , "c1" ), _tool ("c1" , "command not found" )])
73- _session (tmp_path , "old" , [_asst ("terminal" , "c2" ), _tool ("c2" , "command not found" )], age_days = 30 )
122+ _session (tmp_path , "recent" , [_asst ("terminal" , "c1" ), _tool ("c1" , _term ( exit_code = 127 ) )])
123+ _session (tmp_path , "old" , [_asst ("terminal" , "c2" ), _tool ("c2" , _term ( exit_code = 127 ) )], age_days = 30 )
74124 d = build_digest (tmp_path , window_days = 7 )
75125 assert d ["sessions_scanned" ] == 1
76126 assert d ["signals" ]["tool_failures" ] == {"terminal" : 1 }
@@ -79,7 +129,7 @@ def test_aggregates_across_sessions(self, tmp_path):
79129 for n in ("a" , "b" ):
80130 lines = [{"role" : "session_meta" }]
81131 for i in range (5 ):
82- lines += [_asst ("terminal" , f"{ n } { i } " ), _tool (f"{ n } { i } " , "ok" )]
132+ lines += [_asst ("terminal" , f"{ n } { i } " ), _tool (f"{ n } { i } " , _term ( "ok" ) )]
83133 _session (tmp_path , n , lines )
84134 d = build_digest (tmp_path , window_days = 7 )
85135 assert d ["sessions_scanned" ] == 2
@@ -117,7 +167,7 @@ class TestRequestDump:
117167 def test_scanned_when_no_jsonl_present (self , tmp_path ):
118168 # The exact regression: a dir with only request dumps, no *.jsonl.
119169 _dump (tmp_path , "d1" , [
120- _asst ("terminal" , "c1" ), _tool ("c1" , "bash: foo: command not found" ),
170+ _asst ("terminal" , "c1" ), _tool ("c1" , _term ( "bash: foo: command not found" , exit_code = 127 ) ),
121171 ], session_id = "sess-1" , error = {"type" : "overloaded_error" , "status_code" : 529 ,
122172 "message" : "x" , "response_text" : "y" })
123173 d = build_digest (tmp_path , window_days = 7 )
@@ -128,37 +178,39 @@ def test_scanned_when_no_jsonl_present(self, tmp_path):
128178
129179 def test_dedup_by_session_keeps_most_complete (self , tmp_path ):
130180 # Two dumps of ONE session (growing prefix) count once, via the larger.
131- short = [_asst ("terminal" , "c1" ), _tool ("c1" , " permission denied" )]
132- full = short + [_asst ("terminal" , "c2" ), _tool ("c2" , " command not found" )]
181+ short = [_asst ("terminal" , "c1" ), _tool ("c1" , _term ( "" , exit_code = 1 , error = " permission denied") )]
182+ full = short + [_asst ("terminal" , "c2" ), _tool ("c2" , _term ( "bash: x: command not found", exit_code = 127 ) )]
133183 _dump (tmp_path , "early" , short , session_id = "sess-1" )
134184 _dump (tmp_path , "late" , full , session_id = "sess-1" )
135185 d = build_digest (tmp_path , window_days = 7 )
136186 assert d ["sessions_scanned" ] == 1 # one session, not two dumps
137187 assert d ["signals" ]["tool_failures" ] == {"terminal" : 2 } # from the full one
138188
139189 def test_mixed_jsonl_and_dump_both_counted (self , tmp_path ):
140- _session (tmp_path , "s1" , [_asst ("terminal" , "c1" ), _tool ("c1" , "command not found" )])
141- _dump (tmp_path , "d1" , [_asst ("read_file" , "c2" ), _tool ("c2" , "no such file" )],
190+ _session (tmp_path , "s1" , [_asst ("terminal" , "c1" ), _tool ("c1" , _term ( exit_code = 127 ) )])
191+ _dump (tmp_path , "d1" , [_asst ("read_file" , "c2" ), _tool ("c2" , _fail ( "no such file" ) )],
142192 session_id = "sess-2" )
143193 d = build_digest (tmp_path , window_days = 7 )
144194 assert d ["sessions_scanned" ] == 2
145195 assert d ["signals" ]["tool_failures" ] == {"terminal" : 1 , "read_file" : 1 }
146196
147197 def test_window_excludes_old_dumps (self , tmp_path ):
148- _dump (tmp_path , "old" , [_asst ("terminal" , "c1" ), _tool ("c1" , "command not found" )],
198+ _dump (tmp_path , "old" , [_asst ("terminal" , "c1" ), _tool ("c1" , _term ( exit_code = 127 ) )],
149199 session_id = "sess-old" , age_days = 30 )
150200 d = build_digest (tmp_path , window_days = 7 )
151201 assert d ["sessions_scanned" ] == 0
152202
153203 def test_no_raw_text_from_error_or_messages (self , tmp_path ):
154- secret = "bob@example.com at 5 Main St"
204+ secret = "<REDACTED:email:db677acc382bd26bb3a00162f3e668d3> at 5 Main St"
155205 _dump (tmp_path , "d1" , [
156- _asst ("terminal" , "c1" ), _tool ("c1" , f"error: { secret } " ),
206+ _asst ("terminal" , "c1" ), _tool ("c1" , _term ( "" , exit_code = 1 , error = secret ) ),
157207 ], session_id = "sess-1" , error = {"type" : "bad_request" , "status_code" : 400 ,
158208 "message" : secret , "response_text" : secret ,
159209 "body" : secret })
160210 d = build_digest (tmp_path , window_days = 7 )
161- # Provider error contributes only status:type — never the echoed content.
211+ # The failure is counted, but provider error contributes only status:type
212+ # and the digest never echoes the raw content.
213+ assert d ["signals" ]["tool_failures" ] == {"terminal" : 1 }
162214 assert d ["signals" ]["provider_errors" ] == {"400:bad_request" : 1 }
163215 assert secret not in json .dumps (d )
164216
@@ -172,7 +224,7 @@ def test_failure_category_preferred_over_raw_type(self, tmp_path):
172224 # #236: dumps now carry a structured failure_category; introspection keys
173225 # provider_errors by it (recovery class) so recurring bad provider-model
174226 # pairs group as e.g. 429:rate_limit instead of 429:RuntimeError (#237 pt3).
175- _dump (tmp_path , "d1" , [_asst ("x" , "c1" ), _tool ("c1" , "ok" )],
227+ _dump (tmp_path , "d1" , [_asst ("x" , "c1" ), _tool ("c1" , _term ( "ok" ) )],
176228 session_id = "s1" , error = {"type" : "RuntimeError" , "status_code" : 429 ,
177229 "failure_category" : "rate_limit" })
178230 d = build_digest (tmp_path , window_days = 7 )
0 commit comments