@jaguilar87/gaia-ops 3.9.6 → 3.9.8

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.
@@ -561,7 +561,7 @@ def _handle_bash(tool_name: str, parameters: dict) -> str | dict | None:
561
561
  return None
562
562
 
563
563
 
564
- def _handle_task(tool_name: str, parameters: dict) -> str | None:
564
+ def _handle_task(tool_name: str, parameters: dict) -> str | dict | None:
565
565
  """
566
566
  Handle Task tool validation with resume support.
567
567
 
@@ -569,10 +569,12 @@ def _handle_task(tool_name: str, parameters: dict) -> str | None:
569
569
  Resume operations skip heavy validations since agent was already validated.
570
570
 
571
571
  NEW: Automatically injects project context for project agents.
572
+ Returns updatedInput when prompt is modified so Claude Code applies changes.
572
573
  """
573
574
  # ========================================================================
574
575
  # PROJECT CONTEXT INJECTION (for new tasks only, not resume)
575
576
  # ========================================================================
577
+ original_prompt = parameters.get("prompt", "")
576
578
  if not parameters.get("resume"):
577
579
  parameters = _inject_project_context(parameters)
578
580
  parameters = _inject_session_events(parameters)
@@ -651,6 +653,20 @@ def _handle_task(tool_name: str, parameters: dict) -> str | None:
651
653
  save_hook_state(state)
652
654
 
653
655
  logger.info(f"ALLOWED Task: {result.agent_name}")
656
+
657
+ # Return updatedInput if prompt was modified by context/skills injection
658
+ if parameters.get("prompt", "") != original_prompt:
659
+ updated_input = {k: v for k, v in parameters.items() if not k.startswith("_")}
660
+ logger.info(f"Returning updatedInput for {result.agent_name} (prompt enriched)")
661
+ return {
662
+ "hookSpecificOutput": {
663
+ "hookEventName": "PreToolUse",
664
+ "permissionDecision": "allow",
665
+ "permissionDecisionReason": f"Context and skills injected for {result.agent_name}",
666
+ "updatedInput": updated_input
667
+ }
668
+ }
669
+
654
670
  return None
655
671
 
656
672
 
@@ -27,6 +27,7 @@ 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
@@ -571,9 +572,8 @@ def _process_context_updates(agent_output: str, task_info: Dict[str, Any]) -> Op
571
572
  logger.debug("project-context.json not found at %s, skipping context updates", context_path)
572
573
  return None
573
574
 
574
- # Determine config_dir (sibling to .claude at repo root)
575
- repo_root = claude_dir.parent
576
- config_dir = repo_root / "config"
575
+ # Determine config_dir (inside .claude directory)
576
+ config_dir = claude_dir / "config"
577
577
 
578
578
  # Build task_info dict for context_writer
579
579
  agent_type = task_info.get("agent", "unknown")
@@ -665,6 +665,75 @@ def subagent_stop_hook(task_info: Dict[str, Any], agent_output: str) -> Dict[str
665
665
  }
666
666
 
667
667
 
668
+ def _read_transcript(transcript_path: str) -> str:
669
+ """Read agent transcript from file path provided by Claude Code.
670
+
671
+ Claude Code provides ``agent_transcript_path`` pointing to a JSONL file
672
+ with the subagent's conversation messages. We extract text content from
673
+ assistant messages to reconstruct the agent output.
674
+
675
+ Falls back to empty string on any error so the hook never crashes.
676
+ """
677
+ try:
678
+ path = Path(transcript_path)
679
+ if not path.exists():
680
+ logger.debug("Transcript file not found: %s", transcript_path)
681
+ return ""
682
+
683
+ lines = path.read_text().strip().splitlines()
684
+ text_parts: List[str] = []
685
+ for line in lines:
686
+ if not line.strip():
687
+ continue
688
+ try:
689
+ msg = json.loads(line)
690
+ # Extract text from assistant messages
691
+ role = msg.get("role", "")
692
+ if role == "assistant":
693
+ content = msg.get("content", "")
694
+ if isinstance(content, str):
695
+ text_parts.append(content)
696
+ elif isinstance(content, list):
697
+ for block in content:
698
+ if isinstance(block, dict) and block.get("type") == "text":
699
+ text_parts.append(block.get("text", ""))
700
+ elif isinstance(block, str):
701
+ text_parts.append(block)
702
+ except (json.JSONDecodeError, TypeError):
703
+ continue
704
+
705
+ return "\n".join(text_parts)
706
+
707
+ except Exception as e:
708
+ logger.debug("Failed to read transcript from %s: %s", transcript_path, e)
709
+ return ""
710
+
711
+
712
+ def _build_task_info_from_hook_data(hook_data: Dict[str, Any]) -> Dict[str, Any]:
713
+ """Build a task_info dict from the Claude Code SubagentStop stdin payload.
714
+
715
+ Claude Code sends these fields for SubagentStop:
716
+ - hook_event_name: "SubagentStop"
717
+ - session_id: str
718
+ - agent_type: str (e.g. "cloud-troubleshooter")
719
+ - agent_id: str
720
+ - transcript_path: str (session-level JSONL)
721
+ - agent_transcript_path: str (subagent JSONL)
722
+ - cwd: str
723
+ - stop_hook_active: bool
724
+ - permission_mode: str
725
+
726
+ We map these to the task_info format expected by subagent_stop_hook().
727
+ """
728
+ return {
729
+ "task_id": hook_data.get("agent_id", "unknown"),
730
+ "description": f"SubagentStop for {hook_data.get('agent_type', 'unknown')}",
731
+ "agent": hook_data.get("agent_type", "unknown"),
732
+ "tier": "T0", # SubagentStop is always a read/audit operation
733
+ "tags": [],
734
+ }
735
+
736
+
668
737
  def main():
669
738
  """CLI interface for testing metrics capture"""
670
739
 
@@ -706,5 +775,58 @@ def main():
706
775
  print("Unknown command. Use --test to run test.")
707
776
 
708
777
 
778
+ def has_stdin_data() -> bool:
779
+ """Check if there's data available on stdin."""
780
+ if sys.stdin.isatty():
781
+ return False
782
+ try:
783
+ readable, _, _ = select.select([sys.stdin], [], [], 0)
784
+ return bool(readable)
785
+ except Exception:
786
+ return not sys.stdin.isatty()
787
+
788
+
789
+ # ============================================================================
790
+ # STDIN HANDLER (Claude Code integration)
791
+ # ============================================================================
792
+
709
793
  if __name__ == "__main__":
710
- main()
794
+ # Check if running from CLI with arguments
795
+ if len(sys.argv) > 1:
796
+ main()
797
+ elif has_stdin_data():
798
+ try:
799
+ stdin_data = sys.stdin.read()
800
+ if not stdin_data.strip():
801
+ print("Error: Empty stdin data")
802
+ sys.exit(1)
803
+
804
+ hook_data = json.loads(stdin_data)
805
+
806
+ logger.info(f"Hook event: {hook_data.get('hook_event_name')}")
807
+
808
+ # Build task_info from Claude Code SubagentStop payload
809
+ task_info = _build_task_info_from_hook_data(hook_data)
810
+
811
+ # Extract agent output from transcript file
812
+ transcript_path = hook_data.get("agent_transcript_path", "")
813
+ agent_output = _read_transcript(transcript_path) if transcript_path else ""
814
+
815
+ # Run the full processing chain
816
+ result = subagent_stop_hook(task_info, agent_output)
817
+
818
+ # Output result as JSON for Claude Code
819
+ print(json.dumps(result))
820
+ sys.exit(0)
821
+
822
+ except json.JSONDecodeError as e:
823
+ logger.error(f"Invalid JSON from stdin: {e}")
824
+ sys.exit(1)
825
+ except Exception as e:
826
+ logger.error(f"Error processing hook: {e}", exc_info=True)
827
+ sys.exit(1)
828
+ else:
829
+ # No args and no stdin - show usage
830
+ print("Usage: python subagent_stop.py --test")
831
+ print(" echo '{...}' | python subagent_stop.py (stdin mode)")
832
+ sys.exit(1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaguilar87/gaia-ops",
3
- "version": "3.9.6",
3
+ "version": "3.9.8",
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,609 @@
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
343
+ transcript_path = tmp_path / "agent_transcript.jsonl"
344
+ transcript_lines = [
345
+ json.dumps({
346
+ "role": "assistant",
347
+ "content": AGENT_OUTPUT_WITH_CONTEXT_UPDATE,
348
+ }),
349
+ ]
350
+ transcript_path.write_text("\n".join(transcript_lines))
351
+
352
+ # Build the stdin payload matching Claude Code SubagentStop schema
353
+ stdin_payload = json.dumps({
354
+ "hook_event_name": "SubagentStop",
355
+ "session_id": "test-session-e2e-001",
356
+ "agent_type": "cloud-troubleshooter",
357
+ "agent_id": "agent-e2e-001",
358
+ "transcript_path": str(tmp_path / "session_transcript.jsonl"),
359
+ "agent_transcript_path": str(transcript_path),
360
+ "cwd": str(tmp_path),
361
+ "stop_hook_active": True,
362
+ "permission_mode": "default",
363
+ })
364
+
365
+ # Run subagent_stop.py as subprocess
366
+ result = subprocess.run(
367
+ [sys.executable, str(HOOKS_DIR / "subagent_stop.py")],
368
+ input=stdin_payload,
369
+ capture_output=True,
370
+ text=True,
371
+ cwd=str(tmp_path),
372
+ env={
373
+ **os.environ,
374
+ "WORKFLOW_MEMORY_BASE_PATH": str(tmp_path),
375
+ },
376
+ timeout=30,
377
+ )
378
+
379
+ # Verify it exits 0
380
+ assert result.returncode == 0, (
381
+ f"subagent_stop.py exited with code {result.returncode}.\n"
382
+ f"stdout: {result.stdout}\n"
383
+ f"stderr: {result.stderr}"
384
+ )
385
+
386
+ # Parse stdout as JSON
387
+ stdout_lines = result.stdout.strip().splitlines()
388
+ # The result JSON should be the last line (logging may precede it)
389
+ result_json = None
390
+ for line in reversed(stdout_lines):
391
+ try:
392
+ result_json = json.loads(line)
393
+ break
394
+ except json.JSONDecodeError:
395
+ continue
396
+
397
+ assert result_json is not None, (
398
+ f"Expected JSON output from subagent_stop.py, got:\n{result.stdout}"
399
+ )
400
+ assert result_json["success"] is True
401
+
402
+ # Verify project-context.json was updated by the subprocess
403
+ updated = read_context(context_file)
404
+ namespaces = updated["sections"]["cluster_details"].get("namespaces", {})
405
+ assert "application" in namespaces
406
+ assert "nova-auth-dev" in namespaces["application"]
407
+
408
+ # Verify audit trail
409
+ audit = read_audit(context_file)
410
+ assert len(audit) > 0
411
+
412
+ def test_stdin_handler_empty_transcript(self, project_env, tmp_path):
413
+ """Stdin handler should handle missing transcript gracefully."""
414
+ stdin_payload = json.dumps({
415
+ "hook_event_name": "SubagentStop",
416
+ "session_id": "test-session-e2e-002",
417
+ "agent_type": "cloud-troubleshooter",
418
+ "agent_id": "agent-e2e-002",
419
+ "transcript_path": "",
420
+ "agent_transcript_path": "",
421
+ "cwd": str(tmp_path),
422
+ "stop_hook_active": True,
423
+ "permission_mode": "default",
424
+ })
425
+
426
+ result = subprocess.run(
427
+ [sys.executable, str(HOOKS_DIR / "subagent_stop.py")],
428
+ input=stdin_payload,
429
+ capture_output=True,
430
+ text=True,
431
+ cwd=str(tmp_path),
432
+ env={
433
+ **os.environ,
434
+ "WORKFLOW_MEMORY_BASE_PATH": str(tmp_path),
435
+ },
436
+ timeout=30,
437
+ )
438
+
439
+ # Should still exit 0 (graceful handling)
440
+ assert result.returncode == 0, (
441
+ f"subagent_stop.py exited with code {result.returncode}.\n"
442
+ f"stderr: {result.stderr}"
443
+ )
444
+
445
+ def test_stdin_handler_invalid_json(self, tmp_path):
446
+ """Stdin handler should exit 1 on invalid JSON input."""
447
+ result = subprocess.run(
448
+ [sys.executable, str(HOOKS_DIR / "subagent_stop.py")],
449
+ input="not valid json {{{",
450
+ capture_output=True,
451
+ text=True,
452
+ cwd=str(tmp_path),
453
+ env={
454
+ **os.environ,
455
+ "WORKFLOW_MEMORY_BASE_PATH": str(tmp_path),
456
+ },
457
+ timeout=30,
458
+ )
459
+
460
+ assert result.returncode == 1
461
+
462
+ def test_stdin_handler_content_list_format(self, project_env, tmp_path):
463
+ """Verify handling of transcript with content as list of blocks."""
464
+ context_file = project_env["context_file"]
465
+
466
+ # Create transcript with content as list (Claude API format)
467
+ transcript_path = tmp_path / "agent_transcript_blocks.jsonl"
468
+ transcript_lines = [
469
+ json.dumps({
470
+ "role": "assistant",
471
+ "content": [
472
+ {"type": "text", "text": "## Namespace Validation Report\n\n20 namespaces found.\n\n"},
473
+ {"type": "text", "text": "CONTEXT_UPDATE:\n"},
474
+ {"type": "text", "text": json.dumps({
475
+ "cluster_details": {
476
+ "namespaces": {
477
+ "application": ["adm", "dev"],
478
+ "system": ["kube-system"],
479
+ }
480
+ }
481
+ })},
482
+ ],
483
+ }),
484
+ ]
485
+ transcript_path.write_text("\n".join(transcript_lines))
486
+
487
+ stdin_payload = json.dumps({
488
+ "hook_event_name": "SubagentStop",
489
+ "session_id": "test-session-e2e-003",
490
+ "agent_type": "cloud-troubleshooter",
491
+ "agent_id": "agent-e2e-003",
492
+ "transcript_path": "",
493
+ "agent_transcript_path": str(transcript_path),
494
+ "cwd": str(tmp_path),
495
+ "stop_hook_active": True,
496
+ "permission_mode": "default",
497
+ })
498
+
499
+ result = subprocess.run(
500
+ [sys.executable, str(HOOKS_DIR / "subagent_stop.py")],
501
+ input=stdin_payload,
502
+ capture_output=True,
503
+ text=True,
504
+ cwd=str(tmp_path),
505
+ env={
506
+ **os.environ,
507
+ "WORKFLOW_MEMORY_BASE_PATH": str(tmp_path),
508
+ },
509
+ timeout=30,
510
+ )
511
+
512
+ assert result.returncode == 0, (
513
+ f"Exit code: {result.returncode}\nstderr: {result.stderr}"
514
+ )
515
+
516
+ # Verify project-context.json was updated
517
+ updated = read_context(context_file)
518
+ namespaces = updated["sections"]["cluster_details"].get("namespaces", {})
519
+ assert "application" in namespaces
520
+ assert "adm" in namespaces["application"]
521
+
522
+
523
+ # ============================================================================
524
+ # Test Suite 4: _read_transcript unit tests
525
+ # ============================================================================
526
+
527
+ class TestReadTranscript:
528
+ """Unit tests for the _read_transcript helper."""
529
+
530
+ def test_read_string_content(self, tmp_path):
531
+ mod = _import_subagent_stop()
532
+ transcript = tmp_path / "transcript.jsonl"
533
+ transcript.write_text(json.dumps({
534
+ "role": "assistant",
535
+ "content": "Hello world",
536
+ }))
537
+
538
+ result = mod._read_transcript(str(transcript))
539
+ assert "Hello world" in result
540
+
541
+ def test_read_list_content(self, tmp_path):
542
+ mod = _import_subagent_stop()
543
+ transcript = tmp_path / "transcript.jsonl"
544
+ transcript.write_text(json.dumps({
545
+ "role": "assistant",
546
+ "content": [
547
+ {"type": "text", "text": "Part 1"},
548
+ {"type": "text", "text": "Part 2"},
549
+ ],
550
+ }))
551
+
552
+ result = mod._read_transcript(str(transcript))
553
+ assert "Part 1" in result
554
+ assert "Part 2" in result
555
+
556
+ def test_skips_user_messages(self, tmp_path):
557
+ mod = _import_subagent_stop()
558
+ transcript = tmp_path / "transcript.jsonl"
559
+ lines = [
560
+ json.dumps({"role": "user", "content": "user message"}),
561
+ json.dumps({"role": "assistant", "content": "assistant message"}),
562
+ ]
563
+ transcript.write_text("\n".join(lines))
564
+
565
+ result = mod._read_transcript(str(transcript))
566
+ assert "user message" not in result
567
+ assert "assistant message" in result
568
+
569
+ def test_missing_file_returns_empty(self, tmp_path):
570
+ mod = _import_subagent_stop()
571
+ result = mod._read_transcript(str(tmp_path / "nonexistent.jsonl"))
572
+ assert result == ""
573
+
574
+ def test_empty_path_returns_empty(self):
575
+ mod = _import_subagent_stop()
576
+ result = mod._read_transcript("")
577
+ assert result == ""
578
+
579
+
580
+ # ============================================================================
581
+ # Test Suite 5: _build_task_info_from_hook_data
582
+ # ============================================================================
583
+
584
+ class TestBuildTaskInfoFromHookData:
585
+ """Unit tests for the _build_task_info_from_hook_data helper."""
586
+
587
+ def test_maps_fields_correctly(self):
588
+ mod = _import_subagent_stop()
589
+ hook_data = {
590
+ "hook_event_name": "SubagentStop",
591
+ "session_id": "sess-123",
592
+ "agent_type": "cloud-troubleshooter",
593
+ "agent_id": "agent-456",
594
+ "cwd": "/tmp/test",
595
+ }
596
+
597
+ task_info = mod._build_task_info_from_hook_data(hook_data)
598
+
599
+ assert task_info["task_id"] == "agent-456"
600
+ assert task_info["agent"] == "cloud-troubleshooter"
601
+ assert task_info["tier"] == "T0"
602
+ assert "SubagentStop" in task_info["description"]
603
+
604
+ def test_handles_missing_fields(self):
605
+ mod = _import_subagent_stop()
606
+ task_info = mod._build_task_info_from_hook_data({})
607
+
608
+ assert task_info["task_id"] == "unknown"
609
+ assert task_info["agent"] == "unknown"