pytest-skill-engineering 1.0.2__tar.gz → 1.0.3__tar.gz

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.
Files changed (55) hide show
  1. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/PKG-INFO +1 -1
  2. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/pyproject.toml +1 -1
  3. pytest_skill_engineering-1.0.3/src/pytest_skill_engineering/copilot/config.py +133 -0
  4. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/contracts.py +8 -21
  5. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/eval.py +15 -28
  6. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/events.py +72 -3
  7. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/fixtures.py +43 -43
  8. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/personas.py +23 -25
  9. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/result.py +57 -5
  10. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/runner.py +6 -1
  11. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/core/evals.py +34 -5
  12. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/core/plugin.py +2 -85
  13. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/core/result.py +36 -8
  14. pytest_skill_engineering-1.0.3/src/pytest_skill_engineering/core/serialization.py +128 -0
  15. pytest_skill_engineering-1.0.3/src/pytest_skill_engineering/core/validation.py +83 -0
  16. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/execution/cost.py +5 -0
  17. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/execution/servers.py +9 -9
  18. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/plugin.py +57 -29
  19. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/reporting/collector.py +10 -4
  20. pytest_skill_engineering-1.0.2/src/pytest_skill_engineering/copilot/config.py +0 -57
  21. pytest_skill_engineering-1.0.2/src/pytest_skill_engineering/core/serialization.py +0 -309
  22. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/.gitignore +0 -0
  23. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/LICENSE +0 -0
  24. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/README.md +0 -0
  25. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/__init__.py +0 -0
  26. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/__init__.py +0 -0
  27. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/api.py +0 -0
  28. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/client.py +0 -0
  29. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/controls.py +0 -0
  30. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/requests.py +0 -0
  31. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/skills.py +0 -0
  32. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/core/__init__.py +0 -0
  33. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/core/errors.py +0 -0
  34. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/core/prompt.py +0 -0
  35. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/core/skill.py +0 -0
  36. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/core/skill_evals.py +0 -0
  37. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/core/skill_grading.py +0 -0
  38. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/execution/__init__.py +0 -0
  39. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/execution/rate_limiter.py +0 -0
  40. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/execution/skill_tools.py +0 -0
  41. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/fixtures/__init__.py +0 -0
  42. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/fixtures/factories.py +0 -0
  43. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/fixtures/iteration.py +0 -0
  44. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/onboarding.py +0 -0
  45. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/plugin_options.py +0 -0
  46. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/plugin_report.py +0 -0
  47. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/reporting/__init__.py +0 -0
  48. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/reporting/generator.py +0 -0
  49. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/reporting/schema.py +0 -0
  50. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/testing/__init__.py +0 -0
  51. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/testing/banking.py +0 -0
  52. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/testing/banking_mcp.py +0 -0
  53. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/testing/todo.py +0 -0
  54. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/testing/todo_mcp.py +0 -0
  55. {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/testing/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pytest-skill-engineering
3
- Version: 1.0.2
3
+ Version: 1.0.3
4
4
  Summary: Reliable Copilot execution and evidence for coding-agent-led testing of MCP servers, CLI tools, skills, and custom agents.
5
5
  Project-URL: Homepage, https://github.com/sbroenne/pytest-skill-engineering
6
6
  Project-URL: Repository, https://github.com/sbroenne/pytest-skill-engineering
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pytest-skill-engineering"
7
- version = "1.0.2"
7
+ version = "1.0.3"
8
8
  description = "Reliable Copilot execution and evidence for coding-agent-led testing of MCP servers, CLI tools, skills, and custom agents."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -0,0 +1,133 @@
1
+ """Configuration file loaders for Copilot and Claude Code projects.
2
+
3
+ Provides utilities for loading MCP server configurations from standard
4
+ config files (``.mcp.json``, ``.vscode/mcp.json``).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from copy import deepcopy
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from pytest_skill_engineering.core.plugin import _read_json
14
+ from pytest_skill_engineering.core.validation import _validate_mcp_servers
15
+
16
+ if TYPE_CHECKING:
17
+ from pytest_skill_engineering.copilot.contracts import CopilotEvalConfig
18
+
19
+
20
+ def snapshot_session_configuration(
21
+ agent: CopilotEvalConfig, session_config: dict[str, Any]
22
+ ) -> dict[str, Any]:
23
+ """Capture prepared settings without handlers, credentials, or server environments."""
24
+ system = session_config.get("system_message") or {}
25
+ snapshot = {
26
+ "name": agent.name,
27
+ "model": session_config.get("model"),
28
+ "instructions": system.get("content"),
29
+ "system_message_mode": system.get("mode"),
30
+ "reasoning_effort": session_config.get("reasoning_effort"),
31
+ "client_mode": agent.client_mode,
32
+ "image_detail": agent.image_detail,
33
+ "audit_requests": agent.audit_requests,
34
+ "max_tool_calls": agent.max_tool_calls,
35
+ "max_turns": agent.max_turns,
36
+ "timeout_s": agent.timeout_s,
37
+ "auto_confirm": agent.auto_confirm,
38
+ "allowed_tools": session_config.get("available_tools"),
39
+ "excluded_tools": session_config.get("excluded_tools"),
40
+ "skill_directories": session_config.get("skill_directories", []),
41
+ "disabled_skills": session_config.get("disabled_skills", []),
42
+ "active_agent": session_config.get("agent"),
43
+ "working_directory": session_config.get("working_directory"),
44
+ "persona": type(agent.persona).__name__,
45
+ "tools": [
46
+ {
47
+ "name": tool.name,
48
+ "description": tool.description,
49
+ "parameters": tool.parameters,
50
+ "metadata": tool.metadata,
51
+ }
52
+ for tool in session_config.get("tools") or []
53
+ ],
54
+ "mcp_servers": {
55
+ name: {"type": config.get("type"), "tools": config.get("tools")}
56
+ for name, config in (session_config.get("mcp_servers") or {}).items()
57
+ },
58
+ "custom_agents": [
59
+ {
60
+ key: value
61
+ for key, value in custom.items()
62
+ if key
63
+ in {
64
+ "name",
65
+ "prompt",
66
+ "description",
67
+ "display_name",
68
+ "model",
69
+ "reasoning_effort",
70
+ "tools",
71
+ "infer",
72
+ "skills",
73
+ }
74
+ }
75
+ for custom in session_config.get("custom_agents") or []
76
+ ],
77
+ }
78
+ for key in (
79
+ "enable_config_discovery",
80
+ "enable_file_hooks",
81
+ "enable_on_demand_instruction_discovery",
82
+ "skip_custom_instructions",
83
+ "enable_managed_settings",
84
+ "enable_skills",
85
+ ):
86
+ if key in session_config:
87
+ snapshot[key] = session_config[key]
88
+ return deepcopy(snapshot)
89
+
90
+
91
+ def load_mcp_config(path: str | Path) -> dict[str, dict[str, Any]]:
92
+ """Load MCP server configs from a ``.mcp.json`` file.
93
+
94
+ Supports both Claude Code / root-level ``.mcp.json`` and VS Code
95
+ ``.vscode/mcp.json`` formats. Handles two common top-level keys:
96
+
97
+ * ``mcpServers`` — Claude Code / standard MCP convention
98
+ * ``servers`` — VS Code ``mcp.json`` convention
99
+
100
+ Exactly one supported key must be present. An explicit empty mapping is valid;
101
+ missing keys, conflicting keys, and malformed server definitions raise errors.
102
+
103
+ Returns:
104
+ Dict of ``server_name → config`` compatible with
105
+ ``CopilotEval(mcp_servers=...)``.
106
+
107
+ Raises:
108
+ FileNotFoundError: If the file does not exist.
109
+ ValueError: If the file is not valid JSON or has unexpected structure.
110
+
111
+ Example::
112
+
113
+ from pytest_skill_engineering.copilot.config import load_mcp_config
114
+
115
+ servers = load_mcp_config(".mcp.json")
116
+ agent = CopilotEval(mcp_servers=servers)
117
+ """
118
+ path = Path(path)
119
+ if not path.is_file():
120
+ msg = f"MCP config file not found: {path}"
121
+ raise FileNotFoundError(msg)
122
+
123
+ raw = _read_json(path)
124
+
125
+ if not isinstance(raw, dict):
126
+ msg = f"{path}: MCP config must be a JSON object, got {type(raw).__name__}"
127
+ raise ValueError(msg)
128
+
129
+ fields = [key for key in ("mcpServers", "servers") if key in raw]
130
+ if len(fields) != 1:
131
+ raise ValueError(f"{path}: specify exactly one of 'mcpServers' and 'servers'")
132
+
133
+ return _validate_mcp_servers(raw[fields[0]], path, field=fields[0])
@@ -3,24 +3,16 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from collections.abc import Awaitable, Callable, Mapping
6
- from dataclasses import dataclass
7
6
  from typing import Any, Literal, Protocol, TypeAlias
8
7
 
9
8
  from copilot.session import CustomAgentConfig, MCPServerConfig, ReasoningEffort, SessionHooks
10
9
 
10
+ from pytest_skill_engineering.copilot.result import CopilotResult, CopilotResultAgent
11
+
11
12
  CopilotCustomAgentConfig: TypeAlias = CustomAgentConfig
12
13
  CopilotMCPServerConfig: TypeAlias = MCPServerConfig
13
14
  CopilotReasoningEffort: TypeAlias = ReasoningEffort
14
15
  CopilotSessionHooks: TypeAlias = SessionHooks
15
- SubagentStatus: TypeAlias = Literal["selected", "started", "completed", "failed"]
16
-
17
-
18
- class CopilotResultAgent(Protocol):
19
- """Minimal agent surface stored on :class:`CopilotResult`."""
20
-
21
- @property
22
- def working_directory(self) -> str | None:
23
- raise NotImplementedError
24
16
 
25
17
 
26
18
  class CopilotRunResult(Protocol):
@@ -53,6 +45,11 @@ class CopilotEventMapper(Protocol):
53
45
  def record_subagent_failed(self, *, invocation_id: str, name: str) -> None:
54
46
  raise NotImplementedError
55
47
 
48
+ def record_subagent_result(
49
+ self, *, invocation_id: str, name: str, result: CopilotResult
50
+ ) -> None:
51
+ raise NotImplementedError
52
+
56
53
 
57
54
  class CopilotEvalConfig(CopilotResultAgent, Protocol):
58
55
  """Configuration surface consumed by the runner and personas."""
@@ -141,7 +138,7 @@ class CopilotEvalConfig(CopilotResultAgent, Protocol):
141
138
 
142
139
  CopilotNestedRunner: TypeAlias = Callable[
143
140
  [CopilotEvalConfig, str],
144
- Awaitable[CopilotRunResult],
141
+ Awaitable["CopilotResult"],
145
142
  ]
146
143
 
147
144
 
@@ -158,16 +155,6 @@ class CopilotPersona(Protocol):
158
155
  raise NotImplementedError
159
156
 
160
157
 
161
- @dataclass(slots=True)
162
- class SubagentInvocation:
163
- """A single custom-agent dispatch observed during a run."""
164
-
165
- invocation_id: str
166
- name: str
167
- status: SubagentStatus
168
- duration_ms: float | None = None
169
-
170
-
171
158
  def declared_agent_tools(
172
159
  agent_config: CopilotCustomAgentConfig,
173
160
  ) -> list[str] | None:
@@ -18,7 +18,6 @@ from pytest_skill_engineering.copilot.personas import ClaudeCodePersona, VSCodeP
18
18
  from pytest_skill_engineering.core.evals import load_custom_agent, load_instruction_file
19
19
  from pytest_skill_engineering.core.plugin import (
20
20
  _discover_skills,
21
- _validate_mcp_servers,
22
21
  _validate_skill_reference_names,
23
22
  )
24
23
 
@@ -34,30 +33,20 @@ def _parse_agent_file(path: Path) -> CopilotCustomAgentConfig:
34
33
  fields and uses the Markdown body as the custom agent's system prompt.
35
34
  Parsing and metadata errors retain the definition's file path.
36
35
  """
37
- loaded = load_custom_agent(path)
38
- frontmatter = loaded["metadata"]
39
- agent: CopilotCustomAgentConfig = {
40
- "name": frontmatter.get("name", loaded["name"]),
41
- "prompt": loaded["prompt"],
42
- }
43
- for key in (
44
- "description",
45
- "display_name",
46
- "infer",
47
- "skills",
48
- "model",
49
- "reasoning_effort",
50
- "tools",
51
- ):
52
- if key in frontmatter:
53
- agent[key] = frontmatter[key]
54
- if "mcp-servers" in frontmatter:
55
- agent["mcp_servers"] = cast(
56
- dict[str, CopilotMCPServerConfig],
57
- _validate_mcp_servers(frontmatter["mcp-servers"], path, field="mcp-servers"),
58
- )
36
+ return cast(CopilotCustomAgentConfig, load_custom_agent(path))
37
+
59
38
 
60
- return agent
39
+ def _parse_agent_files(paths: list[Path]) -> list[CopilotCustomAgentConfig]:
40
+ agents: list[CopilotCustomAgentConfig] = []
41
+ names: set[str] = set()
42
+ for path in paths:
43
+ agent = _parse_agent_file(path)
44
+ name = require_custom_agent_name(agent)
45
+ if name in names:
46
+ raise ValueError(f"{path}: Duplicate custom agent name '{name}'")
47
+ names.add(name)
48
+ agents.append(agent)
49
+ return agents
61
50
 
62
51
 
63
52
  def _default_persona() -> CopilotPersona:
@@ -361,8 +350,7 @@ class CopilotEval:
361
350
  if agents_dir.exists():
362
351
  if not agents_dir.is_dir():
363
352
  raise ValueError(f"{agents_dir}: custom agents path must be a directory")
364
- for agent_file in sorted(agents_dir.rglob("*.agent.md")):
365
- agents.append(_parse_agent_file(agent_file))
353
+ agents = _parse_agent_files(sorted(agents_dir.rglob("*.agent.md")))
366
354
 
367
355
  config: dict[str, Any] = {
368
356
  "instructions": instructions,
@@ -527,8 +515,7 @@ class CopilotEval:
527
515
  if not agents_dir.is_dir():
528
516
  raise ValueError(f"{agents_dir}: custom agents path must be a directory")
529
517
  # Claude Code uses plain .md files for agents
530
- for agent_file in sorted(agents_dir.glob("*.md")):
531
- agents.append(_parse_agent_file(agent_file))
518
+ agents = _parse_agent_files(sorted(agents_dir.glob("*.md")))
532
519
 
533
520
  # 3. Discover skill directories from .claude/skills/
534
521
  skills = _discover_skills(claude_dir)
@@ -56,20 +56,23 @@ SDK Event Types (38 values) grouped by what they map to:
56
56
 
57
57
  from __future__ import annotations
58
58
 
59
+ import base64
60
+ import binascii
59
61
  import logging
60
62
  import time
61
63
  from collections import deque
62
64
  from datetime import timedelta
63
65
  from typing import TYPE_CHECKING, Any
64
66
 
65
- from pytest_skill_engineering.copilot.contracts import SubagentStatus
66
67
  from pytest_skill_engineering.copilot.result import (
67
68
  CopilotResult,
68
69
  SubagentInvocation,
70
+ SubagentStatus,
69
71
  ToolCall,
70
72
  Turn,
71
73
  UsageInfo,
72
74
  )
75
+ from pytest_skill_engineering.core.result import ImageContent
73
76
 
74
77
  if TYPE_CHECKING:
75
78
  from copilot.generated.session_events import SessionEvent
@@ -162,7 +165,12 @@ class EventMapper:
162
165
  permissions=self._permissions,
163
166
  model_used=self._model_used,
164
167
  raw_events=self._raw_events,
165
- total_premium_requests=self._total_premium_requests,
168
+ total_premium_requests=self._total_premium_requests
169
+ + sum(
170
+ invocation.result.total_premium_requests
171
+ for invocation in resolved_subagents
172
+ if invocation.result is not None
173
+ ),
166
174
  )
167
175
 
168
176
  # ── Assistant events ──
@@ -342,22 +350,73 @@ class EventMapper:
342
350
  self._early_tool_completions.setdefault(call_id, []).append(event)
343
351
  return
344
352
  result_text: str | None = None
353
+ images: list[ImageContent] = []
345
354
  if result_data is not None and hasattr(result_data, "content"):
346
355
  result_text = result_data.content
347
356
  elif isinstance(result_data, str):
348
357
  result_text = result_data
349
358
  elif result_data is not None:
350
359
  result_text = str(result_data)
360
+ text_parts: list[str] = []
361
+ blocks = getattr(result_data, "contents", None) or []
362
+ for block in blocks:
363
+ block_type = getattr(block, "type", None)
364
+ if block_type == "image":
365
+ try:
366
+ data = getattr(block, "data", None)
367
+ media_type = getattr(block, "mime_type", None)
368
+ if (
369
+ not isinstance(data, str)
370
+ or not isinstance(media_type, str)
371
+ or not media_type
372
+ ):
373
+ raise ValueError("image data and media type are required")
374
+ images.append(ImageContent(base64.b64decode(data, validate=True), media_type))
375
+ except (ValueError, binascii.Error) as exc:
376
+ self._record_contract_error(f"Tool call {call_id} has an invalid image: {exc}")
377
+ elif block_type == "text" and result_text is None:
378
+ text = getattr(block, "text", None)
379
+ if text is not None:
380
+ text_parts.append(text)
381
+ if result_text is None and text_parts:
382
+ result_text = "\n".join(text_parts)
383
+ content_images = list(images)
384
+ for block in getattr(result_data, "binary_results_for_llm", None) or []:
385
+ block_type = getattr(block, "type", None)
386
+ if getattr(block_type, "value", block_type) != "image":
387
+ continue
388
+ try:
389
+ data, media_type = block.data, block.mime_type
390
+ if not isinstance(data, str) or not isinstance(media_type, str) or not media_type:
391
+ raise ValueError("image data and media type are required")
392
+ image = ImageContent(base64.b64decode(data, validate=True), media_type)
393
+ if image not in content_images:
394
+ images.append(image)
395
+ except (ValueError, binascii.Error) as exc:
396
+ self._record_contract_error(f"Tool call {call_id} has an invalid image: {exc}")
351
397
  success = _get_data_field(event, "success", None)
352
398
  error = _stringify_tool_error(_get_data_field(event, "error", None))
353
399
  if tc.completion_received:
354
- if (tc.result, tc.success, tc.error) != (result_text, success, error):
400
+ captured_images = (
401
+ [ImageContent(tc.image_content, tc.image_media_type or "image/png")]
402
+ if tc.image_content is not None
403
+ else []
404
+ ) + tc.additional_images
405
+ if (tc.result, tc.success, tc.error, captured_images) != (
406
+ result_text,
407
+ success,
408
+ error,
409
+ images,
410
+ ):
355
411
  self._record_contract_error(f"Conflicting completion for tool call {call_id}")
356
412
  return
357
413
  tc.completion_received = True
358
414
  tc.success = success
359
415
  tc.result = result_text
360
416
  tc.error = error
417
+ if images:
418
+ tc.image_content, tc.image_media_type = images[0].data, images[0].media_type
419
+ tc.additional_images = images[1:]
361
420
  if tc.success is None:
362
421
  self._record_contract_error(f"Tool call {call_id} completion is missing success")
363
422
  start = self._pending_tool_start_times.pop(call_id, None)
@@ -382,6 +441,16 @@ class EventMapper:
382
441
 
383
442
  # ── Subagent recording (used by runSubagent tool handler) ──
384
443
 
444
+ def record_subagent_result(
445
+ self, *, invocation_id: str, name: str, result: CopilotResult
446
+ ) -> None:
447
+ """Link a framework-owned child without mixing its conversation into this one."""
448
+ invocation = self._ensure_subagent_invocation(invocation_id=invocation_id, name=name)
449
+ if invocation.result is not None and invocation.result is not result:
450
+ self._record_contract_error(f"Conflicting child result for invocation {invocation_id}")
451
+ return
452
+ invocation.result = result
453
+
385
454
  def record_subagent_start(self, *, invocation_id: str, name: str) -> None:
386
455
  """Record a subagent invocation dispatched via a tool call."""
387
456
  invocation = self._ensure_subagent_invocation(invocation_id=invocation_id, name=name)
@@ -83,7 +83,7 @@ def _convert_to_aitest(
83
83
  """
84
84
  from dataclasses import dataclass
85
85
 
86
- from pytest_skill_engineering.core.result import EvalResult
86
+ from pytest_skill_engineering.core.result import EvalResult, SubagentInvocation
87
87
  from pytest_skill_engineering.execution.cost import estimate_cost
88
88
 
89
89
  # Estimate USD cost from captured token usage and pricing.toml.
@@ -96,7 +96,7 @@ def _convert_to_aitest(
96
96
  usage.output_tokens,
97
97
  usage.cache_read_tokens or 0,
98
98
  )
99
- for usage in result.usage
99
+ for usage in result.all_usage
100
100
  if usage.input_tokens is not None and usage.output_tokens is not None
101
101
  )
102
102
 
@@ -108,7 +108,9 @@ def _convert_to_aitest(
108
108
  duration_ms=result.duration_ms,
109
109
  token_usage=result.token_usage,
110
110
  cost_usd=cost_usd,
111
- effective_system_prompt=agent.instructions or "",
111
+ effective_system_prompt=(
112
+ result.configuration.get("instructions") if result.configuration is not None else None
113
+ ),
112
114
  premium_requests=result.total_premium_requests,
113
115
  evidence_complete=result.evidence_complete,
114
116
  capture_errors=list(result.capture_errors),
@@ -117,49 +119,39 @@ def _convert_to_aitest(
117
119
  usage=list(result.usage),
118
120
  tool_calls_admitted=result.tool_calls_admitted,
119
121
  skill_discovery=deepcopy(result.skill_discovery),
120
- configuration=deepcopy(
121
- {
122
- "name": agent.name,
123
- "model": agent.model,
124
- "instructions": agent.instructions,
125
- "system_message_mode": agent.system_message_mode,
126
- "reasoning_effort": agent.reasoning_effort,
127
- "client_mode": agent.client_mode,
128
- "image_detail": agent.image_detail,
129
- "audit_requests": agent.audit_requests,
130
- "max_tool_calls": agent.max_tool_calls,
131
- "tools": [
132
- {
133
- "name": tool.name,
134
- "description": tool.description,
135
- "parameters": tool.parameters,
136
- "metadata": tool.metadata,
137
- }
138
- for tool in agent.extra_config.get("tools", [])
139
- ],
140
- "allowed_tools": agent.allowed_tools,
141
- "excluded_tools": agent.excluded_tools,
142
- "max_turns": agent.max_turns,
143
- "timeout_s": agent.timeout_s,
144
- "auto_confirm": agent.auto_confirm,
145
- "mcp_servers": {
146
- name: {"type": config.get("type"), "tools": config.get("tools")}
147
- for name, config in agent.mcp_servers.items()
148
- },
149
- "skill_directories": agent.skill_directories,
150
- "disabled_skills": agent.disabled_skills,
151
- "active_agent": agent.active_agent,
152
- "persona": type(agent.persona).__name__,
153
- }
154
- ),
122
+ configuration=deepcopy(result.configuration),
123
+ model_used=result.model_used,
124
+ reasoning_traces=list(result.reasoning_traces),
125
+ permission_requested=result.permission_requested,
126
+ permissions=deepcopy(result.permissions),
155
127
  )
128
+ for invocation in result.subagent_invocations:
129
+ child_result = None
130
+ if invocation.result is not None:
131
+ from pytest_skill_engineering.copilot.eval import CopilotEval
132
+
133
+ child_agent = invocation.result.agent
134
+ if not isinstance(child_agent, CopilotEval):
135
+ raise ValueError(f"Child invocation {invocation.invocation_id!r} has no eval")
136
+ converted_child = _convert_to_aitest(child_agent, invocation.result)
137
+ assert converted_child is not None
138
+ child_result = converted_child[0]
139
+ aitest_result.subagent_invocations.append(
140
+ SubagentInvocation(
141
+ invocation_id=invocation.invocation_id,
142
+ name=invocation.name,
143
+ status=invocation.status,
144
+ duration_ms=invocation.duration_ms,
145
+ result=child_result,
146
+ )
147
+ )
156
148
 
157
149
  # Create a minimal wrapper with just the fields needed by plugin.py
158
- @dataclass
150
+ @dataclass(slots=True)
159
151
  class Provider:
160
- model: str
152
+ model: str | None
161
153
 
162
- @dataclass
154
+ @dataclass(slots=True)
163
155
  class AgentWrapper:
164
156
  name: str
165
157
  id: str
@@ -176,7 +168,15 @@ def _convert_to_aitest(
176
168
  aitest_agent = AgentWrapper(
177
169
  name=agent.name,
178
170
  id=agent.name,
179
- provider=Provider(model=result.model_used or agent.model or "claude-haiku-4-5"),
171
+ provider=Provider(
172
+ model=result.model_used
173
+ if result.model_used is not None
174
+ else (
175
+ result.configuration.get("model")
176
+ if result.configuration is not None
177
+ else agent.extra_config.get("model", agent.model)
178
+ )
179
+ ),
180
180
  system_prompt_name=None, # CopilotEval doesn't have named prompts
181
181
  mcp_servers=[], # Could convert agent.mcp_servers if needed
182
182
  allowed_tools=agent.allowed_tools,
@@ -211,7 +211,7 @@ def stash_on_item(
211
211
  report_name = f"{agent.name} ({comparison_role})"
212
212
  converted[1].name = report_name
213
213
  converted[1].id = report_name
214
- converted[0].configuration["comparison_role"] = comparison_role
214
+ converted[0].comparison_role = comparison_role
215
215
  item._aitest_result = converted[0] # type: ignore[attr-defined]
216
216
  item._aitest_agent = converted[1] # type: ignore[attr-defined]
217
217
  runs = getattr(item, "_aitest_runs", None)
@@ -437,6 +437,10 @@ def _make_subagent_dispatch_tool(
437
437
  )
438
438
 
439
439
  sub_result = await nested_runner(sub_agent, prompt_text)
440
+ sub_result.agent = sub_agent
441
+ mapper.record_subagent_result(
442
+ invocation_id=invocation.tool_call_id, name=agent_slug, result=sub_result
443
+ )
440
444
 
441
445
  if sub_result.success:
442
446
  mapper.record_subagent_complete(
@@ -501,32 +505,26 @@ def _inject_skill_reference_tools(
501
505
 
502
506
  Only injected when at least one skill directory has reference documents.
503
507
  """
504
- if not agent.skill_directories:
505
- return
508
+ from pytest_skill_engineering.copilot.skills import requested_skill_files
509
+ from pytest_skill_engineering.core.plugin import _validate_skill_reference_names
510
+ from pytest_skill_engineering.core.skill import Skill
506
511
 
507
- # Collect all reference files across all skill directories
508
- reference_files: dict[str, Path] = {} # filename → full path
509
-
510
- for skill_dir_str in agent.skill_directories:
511
- skill_dir = Path(skill_dir_str)
512
-
513
- # Check if this is a skill directory (contains SKILL.md)
514
- _EXTS = (".md", ".txt", ".json", ".yaml", ".yml")
515
- if (skill_dir / "SKILL.md").exists():
516
- refs_dir = skill_dir / "references"
517
- if refs_dir.is_dir():
518
- for ref_file in refs_dir.iterdir():
519
- if ref_file.is_file() and ref_file.suffix in _EXTS:
520
- reference_files[ref_file.name] = ref_file
521
- else:
522
- # Maybe it's a parent directory containing skill subdirectories
523
- for sub in skill_dir.iterdir():
524
- if sub.is_dir() and (sub / "SKILL.md").exists():
525
- refs_dir = sub / "references"
526
- if refs_dir.is_dir():
527
- for ref_file in refs_dir.iterdir():
528
- if ref_file.is_file() and ref_file.suffix in _EXTS:
529
- reference_files[ref_file.name] = ref_file
512
+ configured = (
513
+ session_config if "skill_directories" in session_config else agent.build_session_config()
514
+ )
515
+ directories = configured.get("skill_directories")
516
+ if not directories:
517
+ return
518
+ disabled = configured.get("disabled_skills") or []
519
+ skills = [
520
+ Skill.from_path(path)
521
+ for path, name in requested_skill_files(directories).items()
522
+ if name not in disabled
523
+ ]
524
+ _validate_skill_reference_names(skills)
525
+ reference_files = {
526
+ name: skill.path / "references" / name for skill in skills for name in skill.references
527
+ }
530
528
 
531
529
  if not reference_files:
532
530
  return