Skip to content

Commit 6f506ee

Browse files
committed
feat: mcp activity history for Copilot
1 parent 1b88980 commit 6f506ee

3 files changed

Lines changed: 305 additions & 30 deletions

File tree

ggshield/verticals/ai/agents/copilot.py

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import logging
12
import re
3+
from datetime import datetime
24
from pathlib import Path
3-
from typing import Dict, Iterator, Optional, Tuple
5+
from typing import Any, Dict, Iterator, Optional, Tuple
46

57
from ggshield.core.dirs import get_user_home_dir
68
from ggshield.verticals.ai.models import (
@@ -17,6 +19,9 @@
1719
from .vscode import VSCode
1820

1921

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

124198

125199
def _mangle_name(name: str) -> str:
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
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.idempotency_key == "call_abc"
99+
assert ev.timestamp == datetime(
100+
2026, 5, 18, 14, 10, 46, 52000, tzinfo=timezone.utc
101+
)
102+
assert ev.input == {"owner": "GitGuardian", "repo": "ggshield"}
103+
104+
def test_skips_non_mcp_tool_calls(self, tmp_path: Path, empty_ai_config) -> None:
105+
"""``tool.execution_start`` events without ``mcpServerName`` are not MCP calls."""
106+
builtin = json.dumps(
107+
{
108+
"type": "tool.execution_start",
109+
"data": {
110+
"toolCallId": "call_builtin",
111+
"toolName": "report_intent",
112+
"arguments": {"intent": "Searching"},
113+
"turnId": "0",
114+
},
115+
"id": "evt-builtin",
116+
"timestamp": "2026-05-18T14:10:46.052Z",
117+
}
118+
)
119+
_seed_session(tmp_path, [_session_start(), builtin])
120+
121+
with patch(
122+
"ggshield.verticals.ai.agents.copilot.get_user_home_dir",
123+
return_value=tmp_path,
124+
):
125+
events = list(Copilot().iter_history_events(empty_ai_config))
126+
127+
assert events == []
128+
129+
def test_drops_event_with_unparseable_timestamp(
130+
self, tmp_path: Path, empty_ai_config
131+
) -> None:
132+
_seed_session(
133+
tmp_path,
134+
[
135+
_session_start(),
136+
_tool_execution_start(timestamp="not-a-date"),
137+
],
138+
)
139+
140+
with patch(
141+
"ggshield.verticals.ai.agents.copilot.get_user_home_dir",
142+
return_value=tmp_path,
143+
):
144+
events = list(Copilot().iter_history_events(empty_ai_config))
145+
146+
assert events == []
147+
148+
def test_resolves_server_name_via_configuration(self, tmp_path: Path) -> None:
149+
"""``mcpServerName`` matches ``MCPConfiguration.name`` byte-for-byte."""
150+
_seed_session(
151+
tmp_path,
152+
[
153+
_session_start(),
154+
_tool_execution_start(mcp_server_name="github-mcp-server"),
155+
],
156+
)
157+
158+
config = AIDiscovery(
159+
user=UserInfo(hostname="h", username="u", machine_id="m"),
160+
servers=[
161+
MCPServer(
162+
name="GitHub",
163+
configurations=[
164+
MCPConfiguration(
165+
name="github-mcp-server",
166+
agent="copilot",
167+
scope=MCPConfiguration.Scope.USER,
168+
transport=MCPConfiguration.Transport.STDIO,
169+
project=None,
170+
)
171+
],
172+
)
173+
],
174+
discovery_duration=0.0,
175+
)
176+
177+
with patch(
178+
"ggshield.verticals.ai.agents.copilot.get_user_home_dir",
179+
return_value=tmp_path,
180+
):
181+
events = list(Copilot().iter_history_events(config))
182+
183+
assert events[0].server == "GitHub"
184+
185+
def test_walks_multiple_sessions(self, tmp_path: Path, empty_ai_config) -> None:
186+
_seed_session(
187+
tmp_path,
188+
[_session_start(cwd="/repo-a"), _tool_execution_start(tool_call_id="a")],
189+
session_id="sess-a",
190+
)
191+
_seed_session(
192+
tmp_path,
193+
[_session_start(cwd="/repo-b"), _tool_execution_start(tool_call_id="b")],
194+
session_id="sess-b",
195+
)
196+
197+
with patch(
198+
"ggshield.verticals.ai.agents.copilot.get_user_home_dir",
199+
return_value=tmp_path,
200+
):
201+
events = list(Copilot().iter_history_events(empty_ai_config))
202+
203+
assert {ev.idempotency_key for ev in events} == {"a", "b"}
204+
assert {ev.cwd for ev in events} == {"/repo-a", "/repo-b"}
205+
206+
def test_missing_history_root_yields_nothing(
207+
self, tmp_path: Path, empty_ai_config
208+
) -> None:
209+
"""A user who has never run Copilot CLI has no ``~/.copilot/session-state``."""
210+
with patch(
211+
"ggshield.verticals.ai.agents.copilot.get_user_home_dir",
212+
return_value=tmp_path,
213+
):
214+
events = list(Copilot().iter_history_events(empty_ai_config))
215+
216+
assert events == []
217+
218+
def test_session_without_events_file_is_skipped(
219+
self, tmp_path: Path, empty_ai_config
220+
) -> None:
221+
(tmp_path / ".copilot" / "session-state" / "empty").mkdir(parents=True)
222+
223+
with patch(
224+
"ggshield.verticals.ai.agents.copilot.get_user_home_dir",
225+
return_value=tmp_path,
226+
):
227+
events = list(Copilot().iter_history_events(empty_ai_config))
228+
229+
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)