@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.
package/hooks/subagent_stop.py
CHANGED
|
@@ -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 (
|
|
575
|
-
|
|
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
|
-
|
|
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
|
@@ -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"
|