Skip to content

Commit 028ce3a

Browse files
committed
feat: mcp activity history for Copilot
1 parent 9889bca commit 028ce3a

3 files changed

Lines changed: 302 additions & 30 deletions

File tree

ggshield/verticals/ai/agents/copilot.py

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import logging
22
import re
3+
from datetime import datetime
34
from pathlib import Path
4-
from typing import Dict, Iterator, Optional, Tuple
5+
from typing import Any, Dict, Iterator, Optional, Tuple
56

67
from ggshield.core.dirs import get_user_home_dir
78
from ggshield.verticals.ai.models import (
@@ -17,6 +18,10 @@
1718

1819
from .vscode import VSCode
1920

21+
22+
logger = logging.getLogger(__name__)
23+
24+
2025
logger = logging.getLogger(__name__)
2126

2227

@@ -127,7 +132,75 @@ def _lookup_server_name(
127132
def iter_history_events(
128133
self, ai_config: Optional[AIDiscovery]
129134
) -> Iterator[MCPActivityRequest]:
130-
return iter(())
135+
"""Walk every Copilot CLI session and yield MCP tool calls.
136+
137+
Sessions live under ``~/.copilot/session-state/<uuid>/events.jsonl``.
138+
"""
139+
history_root = get_user_home_dir() / ".copilot" / "session-state"
140+
for session_dir in sorted(history_root.glob("*")):
141+
events_path = session_dir / "events.jsonl"
142+
if not events_path.is_file():
143+
continue
144+
yield from self._parse_events_file(events_path, ai_config)
145+
146+
def _parse_events_file(
147+
self, path: Path, ai_config: Optional[AIDiscovery]
148+
) -> Iterator[MCPActivityRequest]:
149+
"""Yield MCP events from a single Copilot CLI events file."""
150+
cwd = ""
151+
for line in self._load_jsonl_file(path):
152+
if not isinstance(line, dict):
153+
continue
154+
event_type = line.get("type")
155+
data = line.get("data") or {}
156+
if event_type == "session.start":
157+
folder = (data.get("context") or {}).get("cwd")
158+
if isinstance(folder, str):
159+
cwd = folder
160+
continue
161+
if event_type != "tool.execution_start":
162+
continue
163+
event = self._build_event(line, data, cwd, ai_config)
164+
if event is not None:
165+
yield event
166+
167+
def _build_event(
168+
self,
169+
line: Dict[str, Any],
170+
data: Dict[str, Any],
171+
cwd: str,
172+
ai_config: Optional[AIDiscovery],
173+
) -> Optional[MCPActivityRequest]:
174+
server_cfg_name = data.get("mcpServerName") or ""
175+
tool_name = data.get("mcpToolName") or ""
176+
if not server_cfg_name or not tool_name:
177+
return None
178+
timestamp = _parse_iso_timestamp(line.get("timestamp"))
179+
if timestamp is None:
180+
return None
181+
arguments = data.get("arguments")
182+
if not isinstance(arguments, dict):
183+
arguments = {}
184+
return MCPActivityRequest(
185+
user=self._user_or_default(ai_config),
186+
tool=tool_name,
187+
server=self._resolve_server_name(server_cfg_name, ai_config),
188+
agent=self.name,
189+
model="",
190+
cwd=cwd,
191+
input=arguments,
192+
timestamp=timestamp,
193+
)
194+
195+
196+
def _parse_iso_timestamp(raw: Any) -> Optional[datetime]:
197+
"""Parse an ISO-8601 timestamp string, tolerating a trailing ``Z``."""
198+
if not isinstance(raw, str):
199+
return None
200+
try:
201+
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
202+
except ValueError:
203+
return None
131204

132205

133206
def _mangle_name(name: str) -> str:
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import json
2+
from datetime import datetime, timezone
3+
from pathlib import Path
4+
from unittest.mock import patch
5+
6+
import pytest
7+
from pygitguardian.models import AIDiscovery, MCPConfiguration, MCPServer, UserInfo
8+
9+
from ggshield.verticals.ai.agents.copilot import Copilot
10+
11+
12+
def _session_start(cwd: str = "/repo", session_id: str = "sess-1") -> str:
13+
return json.dumps(
14+
{
15+
"type": "session.start",
16+
"data": {
17+
"sessionId": session_id,
18+
"context": {"cwd": cwd},
19+
},
20+
"id": "evt-start",
21+
"timestamp": "2026-05-18T14:09:37.678Z",
22+
}
23+
)
24+
25+
26+
def _tool_execution_start(
27+
tool_call_id: str = "call-1",
28+
mcp_server_name: str = "github-mcp-server",
29+
mcp_tool_name: str = "get_file_contents",
30+
arguments=None,
31+
timestamp: str = "2026-05-18T14:10:46.052Z",
32+
) -> str:
33+
data: dict = {
34+
"toolCallId": tool_call_id,
35+
"toolName": f"{mcp_server_name}-{mcp_tool_name}",
36+
"arguments": arguments if arguments is not None else {"owner": "GitGuardian"},
37+
"turnId": "0",
38+
}
39+
if mcp_server_name:
40+
data["mcpServerName"] = mcp_server_name
41+
if mcp_tool_name:
42+
data["mcpToolName"] = mcp_tool_name
43+
return json.dumps(
44+
{
45+
"type": "tool.execution_start",
46+
"data": data,
47+
"id": f"evt-{tool_call_id}",
48+
"timestamp": timestamp,
49+
}
50+
)
51+
52+
53+
def _seed_session(tmp_path: Path, lines: list, session_id: str = "sess-1") -> Path:
54+
session_dir = tmp_path / ".copilot" / "session-state" / session_id
55+
session_dir.mkdir(parents=True)
56+
events = session_dir / "events.jsonl"
57+
events.write_text("\n".join(lines) + "\n")
58+
return events
59+
60+
61+
@pytest.fixture
62+
def empty_ai_config() -> AIDiscovery:
63+
return AIDiscovery(
64+
user=UserInfo(hostname="h", username="u", machine_id="m"),
65+
servers=[],
66+
discovery_duration=0.0,
67+
)
68+
69+
70+
class TestCopilotIterHistoryEvents:
71+
def test_extracts_mcp_call(self, tmp_path: Path, empty_ai_config) -> None:
72+
_seed_session(
73+
tmp_path,
74+
[
75+
_session_start(cwd="/repo"),
76+
_tool_execution_start(
77+
tool_call_id="call_abc",
78+
mcp_server_name="github-mcp-server",
79+
mcp_tool_name="get_file_contents",
80+
arguments={"owner": "GitGuardian", "repo": "ggshield"},
81+
timestamp="2026-05-18T14:10:46.052Z",
82+
),
83+
],
84+
)
85+
86+
with patch(
87+
"ggshield.verticals.ai.agents.copilot.get_user_home_dir",
88+
return_value=tmp_path,
89+
):
90+
events = list(Copilot().iter_history_events(empty_ai_config))
91+
92+
assert len(events) == 1
93+
ev = events[0]
94+
assert ev.tool == "get_file_contents"
95+
assert ev.server == "github-mcp-server"
96+
assert ev.agent == "copilot"
97+
assert ev.cwd == "/repo"
98+
assert ev.timestamp == datetime(
99+
2026, 5, 18, 14, 10, 46, 52000, tzinfo=timezone.utc
100+
)
101+
assert ev.input == {"owner": "GitGuardian", "repo": "ggshield"}
102+
103+
def test_skips_non_mcp_tool_calls(self, tmp_path: Path, empty_ai_config) -> None:
104+
"""``tool.execution_start`` events without ``mcpServerName`` are not MCP calls."""
105+
builtin = json.dumps(
106+
{
107+
"type": "tool.execution_start",
108+
"data": {
109+
"toolCallId": "call_builtin",
110+
"toolName": "report_intent",
111+
"arguments": {"intent": "Searching"},
112+
"turnId": "0",
113+
},
114+
"id": "evt-builtin",
115+
"timestamp": "2026-05-18T14:10:46.052Z",
116+
}
117+
)
118+
_seed_session(tmp_path, [_session_start(), builtin])
119+
120+
with patch(
121+
"ggshield.verticals.ai.agents.copilot.get_user_home_dir",
122+
return_value=tmp_path,
123+
):
124+
events = list(Copilot().iter_history_events(empty_ai_config))
125+
126+
assert events == []
127+
128+
def test_drops_event_with_unparseable_timestamp(
129+
self, tmp_path: Path, empty_ai_config
130+
) -> None:
131+
_seed_session(
132+
tmp_path,
133+
[
134+
_session_start(),
135+
_tool_execution_start(timestamp="not-a-date"),
136+
],
137+
)
138+
139+
with patch(
140+
"ggshield.verticals.ai.agents.copilot.get_user_home_dir",
141+
return_value=tmp_path,
142+
):
143+
events = list(Copilot().iter_history_events(empty_ai_config))
144+
145+
assert events == []
146+
147+
def test_resolves_server_name_via_configuration(self, tmp_path: Path) -> None:
148+
"""``mcpServerName`` matches ``MCPConfiguration.name`` byte-for-byte."""
149+
_seed_session(
150+
tmp_path,
151+
[
152+
_session_start(),
153+
_tool_execution_start(mcp_server_name="github-mcp-server"),
154+
],
155+
)
156+
157+
config = AIDiscovery(
158+
user=UserInfo(hostname="h", username="u", machine_id="m"),
159+
servers=[
160+
MCPServer(
161+
name="GitHub",
162+
configurations=[
163+
MCPConfiguration(
164+
name="github-mcp-server",
165+
agent="copilot",
166+
scope=MCPConfiguration.Scope.USER,
167+
transport=MCPConfiguration.Transport.STDIO,
168+
project=None,
169+
)
170+
],
171+
)
172+
],
173+
discovery_duration=0.0,
174+
)
175+
176+
with patch(
177+
"ggshield.verticals.ai.agents.copilot.get_user_home_dir",
178+
return_value=tmp_path,
179+
):
180+
events = list(Copilot().iter_history_events(config))
181+
182+
assert events[0].server == "GitHub"
183+
184+
def test_walks_multiple_sessions(self, tmp_path: Path, empty_ai_config) -> None:
185+
_seed_session(
186+
tmp_path,
187+
[_session_start(cwd="/repo-a"), _tool_execution_start(tool_call_id="a")],
188+
session_id="sess-a",
189+
)
190+
_seed_session(
191+
tmp_path,
192+
[_session_start(cwd="/repo-b"), _tool_execution_start(tool_call_id="b")],
193+
session_id="sess-b",
194+
)
195+
196+
with patch(
197+
"ggshield.verticals.ai.agents.copilot.get_user_home_dir",
198+
return_value=tmp_path,
199+
):
200+
events = list(Copilot().iter_history_events(empty_ai_config))
201+
202+
assert {ev.cwd for ev in events} == {"/repo-a", "/repo-b"}
203+
204+
def test_missing_history_root_yields_nothing(
205+
self, tmp_path: Path, empty_ai_config
206+
) -> None:
207+
"""A user who has never run Copilot CLI has no ``~/.copilot/session-state``."""
208+
with patch(
209+
"ggshield.verticals.ai.agents.copilot.get_user_home_dir",
210+
return_value=tmp_path,
211+
):
212+
events = list(Copilot().iter_history_events(empty_ai_config))
213+
214+
assert events == []
215+
216+
def test_session_without_events_file_is_skipped(
217+
self, tmp_path: Path, empty_ai_config
218+
) -> None:
219+
(tmp_path / ".copilot" / "session-state" / "empty").mkdir(parents=True)
220+
221+
with patch(
222+
"ggshield.verticals.ai.agents.copilot.get_user_home_dir",
223+
return_value=tmp_path,
224+
):
225+
events = list(Copilot().iter_history_events(empty_ai_config))
226+
227+
assert events == []

tests/unit/verticals/ai/test_vscode_history.py

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -78,34 +78,6 @@ def test_skips_internal_tools(self) -> None:
7878
assert list(_find_mcp_invocations(obj)) == []
7979

8080

81-
class TestCopilotIterHistoryEvents:
82-
"""Copilot CLI sessions are not in workspaceStorage — the parser must not pick them up."""
83-
84-
def test_returns_empty(self, tmp_path: Path, empty_ai_config) -> None:
85-
from ggshield.verticals.ai.agents.copilot import Copilot
86-
87-
# Even with VSCode-shaped data on disk, the Copilot CLI agent should yield nothing.
88-
workspace_hash = (
89-
tmp_path / ".config" / "Code" / "User" / "workspaceStorage" / "ws1"
90-
)
91-
sessions = workspace_hash / "chatSessions"
92-
sessions.mkdir(parents=True)
93-
(workspace_hash / "workspace.json").write_text(
94-
json.dumps({"folder": "file:///repo"})
95-
)
96-
(sessions / "session.jsonl").write_text(
97-
_request_line(1_778_855_521_780, [_invocation("call-1")]) + "\n"
98-
)
99-
100-
with patch(
101-
"ggshield.verticals.ai.agents.vscode.get_user_home_dir",
102-
return_value=tmp_path,
103-
):
104-
events = list(Copilot().iter_history_events(empty_ai_config))
105-
106-
assert events == []
107-
108-
10981
class TestVSCodeIterHistoryEvents:
11082
def _seed_session(
11183
self, tmp_path: Path, lines: list[str], workspace_folder: str

0 commit comments

Comments
 (0)