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