@jaguilar87/gaia-ops 3.9.7 → 3.9.9

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.
@@ -27,15 +27,28 @@ import sys
27
27
  import json
28
28
  import logging
29
29
  import re
30
+ import select
30
31
  from datetime import datetime
31
32
  from pathlib import Path
32
33
  from typing import Dict, List, Any, Optional
33
34
  import hashlib
34
35
 
35
- # Configure structured logging
36
+ # Configure structured logging with file handler (matching pre_tool_use.py pattern)
37
+ try:
38
+ from modules.core.paths import get_logs_dir
39
+ _log_dir = get_logs_dir()
40
+ except ImportError:
41
+ _log_dir = Path.cwd() / ".claude" / "logs"
42
+ _log_dir.mkdir(parents=True, exist_ok=True)
43
+
44
+ _log_file = _log_dir / f"subagent_stop-{os.getenv('USER', 'unknown')}.log"
36
45
  logging.basicConfig(
37
46
  level=logging.INFO,
38
- format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
47
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
48
+ handlers=[
49
+ logging.FileHandler(_log_file),
50
+ logging.StreamHandler()
51
+ ]
39
52
  )
40
53
  logger = logging.getLogger(__name__)
41
54
 
@@ -571,9 +584,8 @@ def _process_context_updates(agent_output: str, task_info: Dict[str, Any]) -> Op
571
584
  logger.debug("project-context.json not found at %s, skipping context updates", context_path)
572
585
  return None
573
586
 
574
- # Determine config_dir (sibling to .claude at repo root)
575
- repo_root = claude_dir.parent
576
- config_dir = repo_root / "config"
587
+ # Determine config_dir (inside .claude directory)
588
+ config_dir = claude_dir / "config"
577
589
 
578
590
  # Build task_info dict for context_writer
579
591
  agent_type = task_info.get("agent", "unknown")
@@ -665,6 +677,86 @@ def subagent_stop_hook(task_info: Dict[str, Any], agent_output: str) -> Dict[str
665
677
  }
666
678
 
667
679
 
680
+ def _read_transcript(transcript_path: str) -> str:
681
+ """Read agent transcript from file path provided by Claude Code.
682
+
683
+ Claude Code provides ``agent_transcript_path`` pointing to a JSONL file.
684
+ Each line has the structure:
685
+ {"type": "assistant", "message": {"role": "assistant", "content": [...]}, ...}
686
+ The role/content are nested inside the ``message`` field.
687
+
688
+ Falls back to empty string on any error so the hook never crashes.
689
+ """
690
+ try:
691
+ # Expand ~ to home directory (Claude Code may use ~ in paths)
692
+ path = Path(transcript_path).expanduser()
693
+ logger.debug("Reading transcript from: %s", path)
694
+
695
+ if not path.exists():
696
+ logger.warning("Transcript file not found: %s", path)
697
+ return ""
698
+
699
+ lines = path.read_text().strip().splitlines()
700
+
701
+ text_parts: List[str] = []
702
+ for line in lines:
703
+ if not line.strip():
704
+ continue
705
+ try:
706
+ entry = json.loads(line)
707
+
708
+ # Claude Code transcript format: content is inside entry["message"]
709
+ msg = entry.get("message", entry) # fallback to entry itself for simple format
710
+ role = msg.get("role", "")
711
+ if role != "assistant":
712
+ continue
713
+
714
+ content = msg.get("content", "")
715
+ if isinstance(content, str):
716
+ text_parts.append(content)
717
+ elif isinstance(content, list):
718
+ for block in content:
719
+ if isinstance(block, dict) and block.get("type") == "text":
720
+ text_parts.append(block.get("text", ""))
721
+ elif isinstance(block, str):
722
+ text_parts.append(block)
723
+ except (json.JSONDecodeError, TypeError):
724
+ continue
725
+
726
+ result = "\n".join(text_parts)
727
+ logger.debug("Extracted %d text parts, total length: %d chars", len(text_parts), len(result))
728
+ return result
729
+
730
+ except Exception as e:
731
+ logger.debug("Failed to read transcript from %s: %s", transcript_path, e)
732
+ return ""
733
+
734
+
735
+ def _build_task_info_from_hook_data(hook_data: Dict[str, Any]) -> Dict[str, Any]:
736
+ """Build a task_info dict from the Claude Code SubagentStop stdin payload.
737
+
738
+ Claude Code sends these fields for SubagentStop:
739
+ - hook_event_name: "SubagentStop"
740
+ - session_id: str
741
+ - agent_type: str (e.g. "cloud-troubleshooter")
742
+ - agent_id: str
743
+ - transcript_path: str (session-level JSONL)
744
+ - agent_transcript_path: str (subagent JSONL)
745
+ - cwd: str
746
+ - stop_hook_active: bool
747
+ - permission_mode: str
748
+
749
+ We map these to the task_info format expected by subagent_stop_hook().
750
+ """
751
+ return {
752
+ "task_id": hook_data.get("agent_id", "unknown"),
753
+ "description": f"SubagentStop for {hook_data.get('agent_type', 'unknown')}",
754
+ "agent": hook_data.get("agent_type", "unknown"),
755
+ "tier": "T0", # SubagentStop is always a read/audit operation
756
+ "tags": [],
757
+ }
758
+
759
+
668
760
  def main():
669
761
  """CLI interface for testing metrics capture"""
670
762
 
@@ -706,5 +798,59 @@ def main():
706
798
  print("Unknown command. Use --test to run test.")
707
799
 
708
800
 
801
+ def has_stdin_data() -> bool:
802
+ """Check if there's data available on stdin."""
803
+ if sys.stdin.isatty():
804
+ return False
805
+ try:
806
+ readable, _, _ = select.select([sys.stdin], [], [], 0)
807
+ return bool(readable)
808
+ except Exception:
809
+ return not sys.stdin.isatty()
810
+
811
+
812
+ # ============================================================================
813
+ # STDIN HANDLER (Claude Code integration)
814
+ # ============================================================================
815
+
709
816
  if __name__ == "__main__":
710
- main()
817
+ # Check if running from CLI with arguments
818
+ if len(sys.argv) > 1:
819
+ main()
820
+ elif has_stdin_data():
821
+ try:
822
+ stdin_data = sys.stdin.read()
823
+ if not stdin_data.strip():
824
+ print("Error: Empty stdin data")
825
+ sys.exit(1)
826
+
827
+ hook_data = json.loads(stdin_data)
828
+
829
+ logger.info(f"Hook event: {hook_data.get('hook_event_name')}, agent: {hook_data.get('agent_type', 'unknown')}")
830
+
831
+ # Build task_info from Claude Code SubagentStop payload
832
+ task_info = _build_task_info_from_hook_data(hook_data)
833
+
834
+ # Extract agent output from transcript file
835
+ transcript_path = hook_data.get("agent_transcript_path", "")
836
+ agent_output = _read_transcript(transcript_path) if transcript_path else ""
837
+ logger.info(f"Agent output: {len(agent_output)} chars from transcript")
838
+
839
+ # Run the full processing chain
840
+ result = subagent_stop_hook(task_info, agent_output)
841
+
842
+ # Output result as JSON for Claude Code
843
+ print(json.dumps(result))
844
+ sys.exit(0)
845
+
846
+ except json.JSONDecodeError as e:
847
+ logger.error(f"Invalid JSON from stdin: {e}")
848
+ sys.exit(1)
849
+ except Exception as e:
850
+ logger.error(f"Error processing hook: {e}", exc_info=True)
851
+ sys.exit(1)
852
+ else:
853
+ # No args and no stdin - show usage
854
+ print("Usage: python subagent_stop.py --test")
855
+ print(" echo '{...}' | python subagent_stop.py (stdin mode)")
856
+ sys.exit(1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaguilar87/gaia-ops",
3
- "version": "3.9.7",
3
+ "version": "3.9.9",
4
4
  "description": "Multi-agent orchestration system for Claude Code - DevOps automation toolkit",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,637 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ End-to-end integration tests for subagent_stop hook.
4
+
5
+ Validates the FULL flow:
6
+ 1. Agent output with CONTEXT_UPDATE -> subagent_stop processes it
7
+ -> project-context.json is updated -> audit trail created
8
+ 2. Stdin handler (Claude Code SubagentStop) -> processes correctly -> exit 0
9
+
10
+ Modules under test:
11
+ - hooks/subagent_stop.py (subagent_stop_hook, _process_context_updates, stdin handler)
12
+ - hooks/modules/context/context_writer.py (used internally)
13
+ - tools/context/deep_merge.py (used internally by context_writer)
14
+ """
15
+
16
+ import sys
17
+ import json
18
+ import os
19
+ import subprocess
20
+ import shutil
21
+ import pytest
22
+ from pathlib import Path
23
+ from unittest.mock import patch
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Path setup (follows existing project conventions)
27
+ # ---------------------------------------------------------------------------
28
+ REPO_ROOT = Path(__file__).resolve().parents[2]
29
+ HOOKS_DIR = REPO_ROOT / "hooks"
30
+ TOOLS_DIR = REPO_ROOT / "tools"
31
+ CONFIG_DIR = REPO_ROOT / "config"
32
+
33
+ sys.path.insert(0, str(HOOKS_DIR))
34
+ sys.path.insert(0, str(HOOKS_DIR / "modules" / "context"))
35
+ sys.path.insert(0, str(TOOLS_DIR))
36
+ sys.path.insert(0, str(TOOLS_DIR / "context"))
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Lazy imports
41
+ # ---------------------------------------------------------------------------
42
+
43
+ def _import_subagent_stop():
44
+ """Import subagent_stop module at call time so pytest can collect tests."""
45
+ import subagent_stop
46
+ return subagent_stop
47
+
48
+
49
+ def _import_process_agent_output():
50
+ """Import process_agent_output at call time."""
51
+ from context_writer import process_agent_output
52
+ return process_agent_output
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Test data constants
57
+ # ---------------------------------------------------------------------------
58
+
59
+ AGENT_OUTPUT_WITH_CONTEXT_UPDATE = """\
60
+ ## Namespace Validation Report
61
+
62
+ 20 namespaces found across all categories.
63
+
64
+ CONTEXT_UPDATE:
65
+ {
66
+ "cluster_details": {
67
+ "namespaces": {
68
+ "application": ["adm", "dev", "nova-auth-dev"],
69
+ "infrastructure": ["flux-system", "ingress-nginx", "istio-system", "keycloak", "gitlab-runner"],
70
+ "system": ["default", "kube-system", "kube-public", "kube-node-lease"]
71
+ },
72
+ "total_namespace_count": 20
73
+ }
74
+ }
75
+
76
+ <!-- AGENT_STATUS -->
77
+ PLAN_STATUS: COMPLETE
78
+ CURRENT_PHASE: Investigation
79
+ PENDING_STEPS: []
80
+ NEXT_ACTION: Task complete
81
+ AGENT_ID: cloud-troubleshooter
82
+ <!-- /AGENT_STATUS -->
83
+ """
84
+
85
+ INITIAL_CONTEXT = {
86
+ "metadata": {
87
+ "version": "1.0",
88
+ "cloud_provider": "gcp",
89
+ "project_name": "test-project",
90
+ },
91
+ "sections": {
92
+ "project_details": {"project_id": "test-project-id"},
93
+ "cluster_details": {},
94
+ },
95
+ }
96
+
97
+ TASK_INFO_CLOUD_TROUBLESHOOTER = {
98
+ "task_id": "T-E2E-001",
99
+ "description": "Validate cluster namespaces",
100
+ "agent": "cloud-troubleshooter",
101
+ "tier": "T0",
102
+ "tags": ["#gcp", "#debug"],
103
+ }
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Helpers
108
+ # ---------------------------------------------------------------------------
109
+
110
+ def write_context(context_file: Path, data: dict) -> None:
111
+ """Write a project-context.json file."""
112
+ context_file.parent.mkdir(parents=True, exist_ok=True)
113
+ context_file.write_text(json.dumps(data, indent=2))
114
+
115
+
116
+ def read_context(context_file: Path) -> dict:
117
+ """Read and parse a project-context.json file."""
118
+ return json.loads(context_file.read_text())
119
+
120
+
121
+ def read_audit(context_file: Path) -> list:
122
+ """Read the audit JSONL file next to context_file."""
123
+ audit_path = context_file.parent / "context-audit.jsonl"
124
+ if not audit_path.exists():
125
+ return []
126
+ entries = []
127
+ for line in audit_path.read_text().strip().splitlines():
128
+ if line.strip():
129
+ entries.append(json.loads(line))
130
+ return entries
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Fixtures
135
+ # ---------------------------------------------------------------------------
136
+
137
+ @pytest.fixture
138
+ def project_env(tmp_path, monkeypatch):
139
+ """Creates an isolated project environment mimicking a real project.
140
+
141
+ Structure:
142
+ tmp_path/
143
+ .claude/
144
+ project-context/
145
+ project-context.json (initial data, empty cluster_details)
146
+ config/
147
+ context-contracts.gcp.json (copied from real config dir)
148
+ """
149
+ # Isolate file I/O
150
+ monkeypatch.setenv("WORKFLOW_MEMORY_BASE_PATH", str(tmp_path))
151
+ monkeypatch.chdir(tmp_path)
152
+
153
+ # Create directory structure
154
+ claude_dir = tmp_path / ".claude"
155
+ context_dir = claude_dir / "project-context"
156
+ config_dir = claude_dir / "config"
157
+ context_dir.mkdir(parents=True)
158
+ config_dir.mkdir(parents=True)
159
+
160
+ # Write initial project-context.json
161
+ context_file = context_dir / "project-context.json"
162
+ write_context(context_file, INITIAL_CONTEXT)
163
+
164
+ # Copy real GCP contracts file
165
+ real_contracts = CONFIG_DIR / "context-contracts.gcp.json"
166
+ if real_contracts.exists():
167
+ shutil.copy(real_contracts, config_dir / "context-contracts.gcp.json")
168
+
169
+ # Create pending-updates directory (needed by extract_and_store_discoveries)
170
+ pending_dir = context_dir / "pending-updates"
171
+ pending_dir.mkdir(parents=True)
172
+ (pending_dir / "applied").mkdir()
173
+
174
+ # Copy classification rules
175
+ rules_src = CONFIG_DIR / "classification-rules.json"
176
+ if rules_src.exists():
177
+ project_config_dir = tmp_path / "config"
178
+ project_config_dir.mkdir(exist_ok=True)
179
+ shutil.copy(rules_src, project_config_dir / "classification-rules.json")
180
+
181
+ return {
182
+ "tmp_path": tmp_path,
183
+ "claude_dir": claude_dir,
184
+ "context_dir": context_dir,
185
+ "config_dir": config_dir,
186
+ "context_file": context_file,
187
+ }
188
+
189
+
190
+ # ============================================================================
191
+ # Test Suite 1: _process_context_updates E2E
192
+ # ============================================================================
193
+
194
+ class TestProcessContextUpdatesE2E:
195
+ """Test that _process_context_updates correctly updates project-context.json
196
+ when called with agent output containing a CONTEXT_UPDATE block."""
197
+
198
+ def test_context_update_applied_to_project_context(self, project_env):
199
+ """Full flow: agent output with CONTEXT_UPDATE -> project-context.json updated."""
200
+ mod = _import_subagent_stop()
201
+ context_file = project_env["context_file"]
202
+
203
+ # Call _process_context_updates directly
204
+ result = mod._process_context_updates(
205
+ AGENT_OUTPUT_WITH_CONTEXT_UPDATE,
206
+ TASK_INFO_CLOUD_TROUBLESHOOTER,
207
+ )
208
+
209
+ # Verify result indicates success
210
+ assert result is not None, "Expected non-None result from _process_context_updates"
211
+ assert result["updated"] is True
212
+ assert "cluster_details" in result["sections_updated"]
213
+
214
+ # Verify project-context.json was updated
215
+ updated = read_context(context_file)
216
+ namespaces = updated["sections"]["cluster_details"]["namespaces"]
217
+ assert "adm" in namespaces["application"]
218
+ assert "dev" in namespaces["application"]
219
+ assert "nova-auth-dev" in namespaces["application"]
220
+ assert "flux-system" in namespaces["infrastructure"]
221
+ assert "kube-system" in namespaces["system"]
222
+
223
+ # Verify total_namespace_count
224
+ assert updated["sections"]["cluster_details"]["total_namespace_count"] == 20
225
+
226
+ # Verify audit trail was created
227
+ audit = read_audit(context_file)
228
+ assert len(audit) > 0
229
+ assert audit[0]["agent"] == "cloud-troubleshooter"
230
+ assert audit[0]["success"] is True
231
+
232
+ def test_config_dir_uses_claude_dir(self, project_env):
233
+ """Verify config_dir is resolved to .claude/config/, not repo_root/config/."""
234
+ mod = _import_subagent_stop()
235
+ context_file = project_env["context_file"]
236
+ config_dir = project_env["config_dir"]
237
+
238
+ # Confirm contracts file exists in .claude/config/
239
+ assert (config_dir / "context-contracts.gcp.json").exists(), (
240
+ "Contracts file should be in .claude/config/"
241
+ )
242
+
243
+ # If the bug were still present, config_dir would be tmp_path/config
244
+ # and the contracts file would not be found (fallback to legacy).
245
+ # With the fix, it uses .claude/config/ and finds the real contracts.
246
+ result = mod._process_context_updates(
247
+ AGENT_OUTPUT_WITH_CONTEXT_UPDATE,
248
+ TASK_INFO_CLOUD_TROUBLESHOOTER,
249
+ )
250
+
251
+ assert result is not None
252
+ assert result["updated"] is True
253
+
254
+ def test_no_context_update_in_output(self, project_env):
255
+ """Agent output without CONTEXT_UPDATE should not modify project-context.json."""
256
+ mod = _import_subagent_stop()
257
+ context_file = project_env["context_file"]
258
+
259
+ agent_output_no_update = (
260
+ "## Agent Execution Complete\n\n"
261
+ "Checked all pods. Everything looks healthy.\n"
262
+ )
263
+
264
+ result = mod._process_context_updates(
265
+ agent_output_no_update,
266
+ TASK_INFO_CLOUD_TROUBLESHOOTER,
267
+ )
268
+
269
+ # Context should remain unchanged
270
+ updated = read_context(context_file)
271
+ assert updated["sections"]["cluster_details"] == {}
272
+
273
+ # Result should indicate no update
274
+ if result is not None:
275
+ assert result["updated"] is False
276
+
277
+
278
+ # ============================================================================
279
+ # Test Suite 2: Full subagent_stop_hook E2E
280
+ # ============================================================================
281
+
282
+ class TestSubagentStopHookE2E:
283
+ """Test the full subagent_stop_hook() processing chain with context updates."""
284
+
285
+ @patch("subagent_stop.capture_episodic_memory", return_value=None)
286
+ def test_full_hook_with_context_update(self, mock_episodic, project_env):
287
+ """Full hook flow: metrics + anomalies + context update."""
288
+ mod = _import_subagent_stop()
289
+ context_file = project_env["context_file"]
290
+
291
+ result = mod.subagent_stop_hook(
292
+ TASK_INFO_CLOUD_TROUBLESHOOTER,
293
+ AGENT_OUTPUT_WITH_CONTEXT_UPDATE,
294
+ )
295
+
296
+ # Hook should succeed
297
+ assert result["success"] is True
298
+ assert result["metrics_captured"] is True
299
+ assert result["context_updated"] is True
300
+
301
+ # Verify project-context.json was actually updated
302
+ updated = read_context(context_file)
303
+ namespaces = updated["sections"]["cluster_details"]["namespaces"]
304
+ assert len(namespaces["application"]) == 3
305
+ assert "nova-auth-dev" in namespaces["application"]
306
+
307
+ # Verify audit trail
308
+ audit = read_audit(context_file)
309
+ assert len(audit) > 0
310
+
311
+ @patch("subagent_stop.capture_episodic_memory", return_value=None)
312
+ def test_full_hook_without_context_update(self, mock_episodic, project_env):
313
+ """Hook processes metrics even when no CONTEXT_UPDATE is present."""
314
+ mod = _import_subagent_stop()
315
+
316
+ agent_output_plain = (
317
+ "## Investigation Complete\n\n"
318
+ "All systems nominal. No issues found.\n"
319
+ )
320
+
321
+ result = mod.subagent_stop_hook(
322
+ TASK_INFO_CLOUD_TROUBLESHOOTER,
323
+ agent_output_plain,
324
+ )
325
+
326
+ assert result["success"] is True
327
+ assert result["metrics_captured"] is True
328
+ assert result["context_updated"] is False
329
+
330
+
331
+ # ============================================================================
332
+ # Test Suite 3: Stdin handler (subprocess integration)
333
+ # ============================================================================
334
+
335
+ class TestStdinHandler:
336
+ """Test the stdin handler by invoking subagent_stop.py as a subprocess."""
337
+
338
+ def test_stdin_handler_with_transcript(self, project_env, tmp_path):
339
+ """Simulate Claude Code SubagentStop: pipe JSON via stdin with transcript."""
340
+ context_file = project_env["context_file"]
341
+
342
+ # Create a fake transcript JSONL file (Claude Code format: content inside "message")
343
+ transcript_path = tmp_path / "agent_transcript.jsonl"
344
+ transcript_lines = [
345
+ json.dumps({
346
+ "type": "assistant",
347
+ "message": {
348
+ "role": "assistant",
349
+ "content": [{"type": "text", "text": AGENT_OUTPUT_WITH_CONTEXT_UPDATE}],
350
+ },
351
+ }),
352
+ ]
353
+ transcript_path.write_text("\n".join(transcript_lines))
354
+
355
+ # Build the stdin payload matching Claude Code SubagentStop schema
356
+ stdin_payload = json.dumps({
357
+ "hook_event_name": "SubagentStop",
358
+ "session_id": "test-session-e2e-001",
359
+ "agent_type": "cloud-troubleshooter",
360
+ "agent_id": "agent-e2e-001",
361
+ "transcript_path": str(tmp_path / "session_transcript.jsonl"),
362
+ "agent_transcript_path": str(transcript_path),
363
+ "cwd": str(tmp_path),
364
+ "stop_hook_active": True,
365
+ "permission_mode": "default",
366
+ })
367
+
368
+ # Run subagent_stop.py as subprocess
369
+ result = subprocess.run(
370
+ [sys.executable, str(HOOKS_DIR / "subagent_stop.py")],
371
+ input=stdin_payload,
372
+ capture_output=True,
373
+ text=True,
374
+ cwd=str(tmp_path),
375
+ env={
376
+ **os.environ,
377
+ "WORKFLOW_MEMORY_BASE_PATH": str(tmp_path),
378
+ },
379
+ timeout=30,
380
+ )
381
+
382
+ # Verify it exits 0
383
+ assert result.returncode == 0, (
384
+ f"subagent_stop.py exited with code {result.returncode}.\n"
385
+ f"stdout: {result.stdout}\n"
386
+ f"stderr: {result.stderr}"
387
+ )
388
+
389
+ # Parse stdout as JSON
390
+ stdout_lines = result.stdout.strip().splitlines()
391
+ # The result JSON should be the last line (logging may precede it)
392
+ result_json = None
393
+ for line in reversed(stdout_lines):
394
+ try:
395
+ result_json = json.loads(line)
396
+ break
397
+ except json.JSONDecodeError:
398
+ continue
399
+
400
+ assert result_json is not None, (
401
+ f"Expected JSON output from subagent_stop.py, got:\n{result.stdout}"
402
+ )
403
+ assert result_json["success"] is True
404
+
405
+ # Verify project-context.json was updated by the subprocess
406
+ updated = read_context(context_file)
407
+ namespaces = updated["sections"]["cluster_details"].get("namespaces", {})
408
+ assert "application" in namespaces
409
+ assert "nova-auth-dev" in namespaces["application"]
410
+
411
+ # Verify audit trail
412
+ audit = read_audit(context_file)
413
+ assert len(audit) > 0
414
+
415
+ def test_stdin_handler_empty_transcript(self, project_env, tmp_path):
416
+ """Stdin handler should handle missing transcript gracefully."""
417
+ stdin_payload = json.dumps({
418
+ "hook_event_name": "SubagentStop",
419
+ "session_id": "test-session-e2e-002",
420
+ "agent_type": "cloud-troubleshooter",
421
+ "agent_id": "agent-e2e-002",
422
+ "transcript_path": "",
423
+ "agent_transcript_path": "",
424
+ "cwd": str(tmp_path),
425
+ "stop_hook_active": True,
426
+ "permission_mode": "default",
427
+ })
428
+
429
+ result = subprocess.run(
430
+ [sys.executable, str(HOOKS_DIR / "subagent_stop.py")],
431
+ input=stdin_payload,
432
+ capture_output=True,
433
+ text=True,
434
+ cwd=str(tmp_path),
435
+ env={
436
+ **os.environ,
437
+ "WORKFLOW_MEMORY_BASE_PATH": str(tmp_path),
438
+ },
439
+ timeout=30,
440
+ )
441
+
442
+ # Should still exit 0 (graceful handling)
443
+ assert result.returncode == 0, (
444
+ f"subagent_stop.py exited with code {result.returncode}.\n"
445
+ f"stderr: {result.stderr}"
446
+ )
447
+
448
+ def test_stdin_handler_invalid_json(self, tmp_path):
449
+ """Stdin handler should exit 1 on invalid JSON input."""
450
+ result = subprocess.run(
451
+ [sys.executable, str(HOOKS_DIR / "subagent_stop.py")],
452
+ input="not valid json {{{",
453
+ capture_output=True,
454
+ text=True,
455
+ cwd=str(tmp_path),
456
+ env={
457
+ **os.environ,
458
+ "WORKFLOW_MEMORY_BASE_PATH": str(tmp_path),
459
+ },
460
+ timeout=30,
461
+ )
462
+
463
+ assert result.returncode == 1
464
+
465
+ def test_stdin_handler_content_list_format(self, project_env, tmp_path):
466
+ """Verify handling of transcript with content as list of blocks."""
467
+ context_file = project_env["context_file"]
468
+
469
+ # Create transcript with content as list (Claude Code transcript format)
470
+ transcript_path = tmp_path / "agent_transcript_blocks.jsonl"
471
+ transcript_lines = [
472
+ json.dumps({
473
+ "type": "assistant",
474
+ "message": {
475
+ "role": "assistant",
476
+ "content": [
477
+ {"type": "text", "text": "## Namespace Validation Report\n\n20 namespaces found.\n\n"},
478
+ {"type": "text", "text": "CONTEXT_UPDATE:\n"},
479
+ {"type": "text", "text": json.dumps({
480
+ "cluster_details": {
481
+ "namespaces": {
482
+ "application": ["adm", "dev"],
483
+ "system": ["kube-system"],
484
+ }
485
+ }
486
+ })},
487
+ ],
488
+ },
489
+ }),
490
+ ]
491
+ transcript_path.write_text("\n".join(transcript_lines))
492
+
493
+ stdin_payload = json.dumps({
494
+ "hook_event_name": "SubagentStop",
495
+ "session_id": "test-session-e2e-003",
496
+ "agent_type": "cloud-troubleshooter",
497
+ "agent_id": "agent-e2e-003",
498
+ "transcript_path": "",
499
+ "agent_transcript_path": str(transcript_path),
500
+ "cwd": str(tmp_path),
501
+ "stop_hook_active": True,
502
+ "permission_mode": "default",
503
+ })
504
+
505
+ result = subprocess.run(
506
+ [sys.executable, str(HOOKS_DIR / "subagent_stop.py")],
507
+ input=stdin_payload,
508
+ capture_output=True,
509
+ text=True,
510
+ cwd=str(tmp_path),
511
+ env={
512
+ **os.environ,
513
+ "WORKFLOW_MEMORY_BASE_PATH": str(tmp_path),
514
+ },
515
+ timeout=30,
516
+ )
517
+
518
+ assert result.returncode == 0, (
519
+ f"Exit code: {result.returncode}\nstderr: {result.stderr}"
520
+ )
521
+
522
+ # Verify project-context.json was updated
523
+ updated = read_context(context_file)
524
+ namespaces = updated["sections"]["cluster_details"].get("namespaces", {})
525
+ assert "application" in namespaces
526
+ assert "adm" in namespaces["application"]
527
+
528
+
529
+ # ============================================================================
530
+ # Test Suite 4: _read_transcript unit tests
531
+ # ============================================================================
532
+
533
+ class TestReadTranscript:
534
+ """Unit tests for the _read_transcript helper."""
535
+
536
+ def test_read_string_content(self, tmp_path):
537
+ """Claude Code transcript format with string content inside message."""
538
+ mod = _import_subagent_stop()
539
+ transcript = tmp_path / "transcript.jsonl"
540
+ transcript.write_text(json.dumps({
541
+ "type": "assistant",
542
+ "message": {
543
+ "role": "assistant",
544
+ "content": "Hello world",
545
+ },
546
+ }))
547
+
548
+ result = mod._read_transcript(str(transcript))
549
+ assert "Hello world" in result
550
+
551
+ def test_read_list_content(self, tmp_path):
552
+ """Claude Code transcript format with list content blocks."""
553
+ mod = _import_subagent_stop()
554
+ transcript = tmp_path / "transcript.jsonl"
555
+ transcript.write_text(json.dumps({
556
+ "type": "assistant",
557
+ "message": {
558
+ "role": "assistant",
559
+ "content": [
560
+ {"type": "text", "text": "Part 1"},
561
+ {"type": "text", "text": "Part 2"},
562
+ ],
563
+ },
564
+ }))
565
+
566
+ result = mod._read_transcript(str(transcript))
567
+ assert "Part 1" in result
568
+ assert "Part 2" in result
569
+
570
+ def test_skips_user_messages(self, tmp_path):
571
+ """Only assistant messages are extracted, user/progress entries are skipped."""
572
+ mod = _import_subagent_stop()
573
+ transcript = tmp_path / "transcript.jsonl"
574
+ lines = [
575
+ json.dumps({"type": "user", "message": {"role": "user", "content": "user message"}}),
576
+ json.dumps({"type": "assistant", "message": {"role": "assistant", "content": "assistant message"}}),
577
+ json.dumps({"type": "progress", "message": {}}),
578
+ ]
579
+ transcript.write_text("\n".join(lines))
580
+
581
+ result = mod._read_transcript(str(transcript))
582
+ assert "user message" not in result
583
+ assert "assistant message" in result
584
+
585
+ def test_fallback_simple_format(self, tmp_path):
586
+ """Fallback: if no 'message' key, treat entry itself as the message."""
587
+ mod = _import_subagent_stop()
588
+ transcript = tmp_path / "transcript.jsonl"
589
+ transcript.write_text(json.dumps({
590
+ "role": "assistant",
591
+ "content": "simple format",
592
+ }))
593
+
594
+ result = mod._read_transcript(str(transcript))
595
+ assert "simple format" in result
596
+
597
+ def test_missing_file_returns_empty(self, tmp_path):
598
+ mod = _import_subagent_stop()
599
+ result = mod._read_transcript(str(tmp_path / "nonexistent.jsonl"))
600
+ assert result == ""
601
+
602
+ def test_empty_path_returns_empty(self):
603
+ mod = _import_subagent_stop()
604
+ result = mod._read_transcript("")
605
+ assert result == ""
606
+
607
+
608
+ # ============================================================================
609
+ # Test Suite 5: _build_task_info_from_hook_data
610
+ # ============================================================================
611
+
612
+ class TestBuildTaskInfoFromHookData:
613
+ """Unit tests for the _build_task_info_from_hook_data helper."""
614
+
615
+ def test_maps_fields_correctly(self):
616
+ mod = _import_subagent_stop()
617
+ hook_data = {
618
+ "hook_event_name": "SubagentStop",
619
+ "session_id": "sess-123",
620
+ "agent_type": "cloud-troubleshooter",
621
+ "agent_id": "agent-456",
622
+ "cwd": "/tmp/test",
623
+ }
624
+
625
+ task_info = mod._build_task_info_from_hook_data(hook_data)
626
+
627
+ assert task_info["task_id"] == "agent-456"
628
+ assert task_info["agent"] == "cloud-troubleshooter"
629
+ assert task_info["tier"] == "T0"
630
+ assert "SubagentStop" in task_info["description"]
631
+
632
+ def test_handles_missing_fields(self):
633
+ mod = _import_subagent_stop()
634
+ task_info = mod._build_task_info_from_hook_data({})
635
+
636
+ assert task_info["task_id"] == "unknown"
637
+ assert task_info["agent"] == "unknown"