9201ef759e
CI / lint (push) Waiting to run
CI / mypy (push) Waiting to run
CI / docs (push) Waiting to run
CI / test on 3.10 (standard) (push) Waiting to run
CI / test on 3.11 (standard) (push) Waiting to run
CI / test on 3.12 (standard) (push) Waiting to run
CI / test on 3.10 (all-extras) (push) Waiting to run
CI / test on 3.11 (all-extras) (push) Waiting to run
CI / test on 3.12 (all-extras) (push) Waiting to run
CI / test on 3.13 (all-extras) (push) Waiting to run
CI / test on 3.14 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (standard) (push) Waiting to run
CI / test on 3.14 (standard) (push) Waiting to run
CI / test on 3.14 (all-extras) (push) Waiting to run
CI / test on 3.10 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.11 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.12 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.13 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.14 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.10 (pydantic-evals) (push) Waiting to run
CI / test on 3.11 (pydantic-evals) (push) Waiting to run
CI / test on 3.12 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (pydantic-evals) (push) Waiting to run
CI / test on 3.10 (lowest-versions) (push) Waiting to run
CI / test on 3.11 (lowest-versions) (push) Waiting to run
CI / test on 3.12 (lowest-versions) (push) Waiting to run
CI / test on 3.13 (lowest-versions) (push) Waiting to run
CI / test on 3.14 (lowest-versions) (push) Waiting to run
CI / test examples on 3.11 (push) Waiting to run
CI / test examples on 3.12 (push) Waiting to run
CI / test examples on 3.13 (push) Waiting to run
CI / test examples on 3.14 (push) Waiting to run
CI / coverage (push) Blocked by required conditions
CI / check (push) Blocked by required conditions
CI / deploy-docs (push) Blocked by required conditions
CI / deploy-docs-preview (push) Blocked by required conditions
CI / build release artifacts (push) Blocked by required conditions
CI / publish to PyPI (push) Blocked by required conditions
CI / Send tweet (push) Blocked by required conditions
Harness Compat / harness compat (push) Failing after 0s
1604 lines
70 KiB
Python
1604 lines
70 KiB
Python
"""Offline tests for the Pydantic AI gh-aw shim (.github/scripts/pydantic-ai-runner).
|
|
|
|
These cover the gh-aw compatibility surface with no network or credentials:
|
|
argv tolerance, prompt recovery, model resolution, MCP-config translation and
|
|
allow-list filtering, Claude-named tools, `--allowed-tools` /
|
|
`--permission-mode` enforcement, structured-error guarantees, and the
|
|
stream-json schema.
|
|
|
|
The single live test is skipped unless an Anthropic-shape endpoint is given
|
|
via env: GH_AW_SHIM_LIVE_API_KEY / _BASE_URL / _MODEL.
|
|
|
|
Run: uv run --with pytest pytest .github/scripts/test_pydantic_ai_runner.py
|
|
"""
|
|
|
|
import asyncio
|
|
import io
|
|
import json
|
|
import os
|
|
import sys
|
|
from contextlib import redirect_stdout
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
|
|
import pytest
|
|
from pytest import LogCaptureFixture
|
|
|
|
# `.github/scripts/` isn't on sys.path by default — the shim package lives
|
|
# there. The runtime equivalent is the PEP-723 launcher script
|
|
# (`pydantic-ai-runner`) which inserts the same directory before
|
|
# `runpy.run_module`-ing the package.
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
|
|
# Tool callables, shared helpers, and the CLI live in distinct submodules;
|
|
# tests import each from where it actually lives, not from a re-export.
|
|
import pydantic_ai_gh_aw_shim as pkg
|
|
from pydantic_ai_gh_aw_shim import (
|
|
cli as shim,
|
|
shared,
|
|
)
|
|
|
|
from pydantic_ai.messages import ModelMessage
|
|
from pydantic_ai.models import Model as _Model
|
|
from pydantic_ai.tools import RunContext
|
|
from pydantic_ai.toolsets import AbstractToolset
|
|
|
|
# The exact argv shape gh-aw's claude_harness.cjs passes, prompt appended last.
|
|
GHAW_ARGV = [
|
|
'--print',
|
|
'--no-chrome',
|
|
'--allowed-tools',
|
|
'Bash,Read,Edit(/tmp/*),mcp__github__get_me,mcp__safeoutputs',
|
|
'--debug-file',
|
|
'/tmp/gh-aw/agent-stdio.log',
|
|
'--verbose',
|
|
'--permission-mode',
|
|
'bypassPermissions',
|
|
'--output-format',
|
|
'stream-json',
|
|
'--mcp-config',
|
|
'/tmp/mcp-servers.json',
|
|
'--prompt-file',
|
|
'/tmp/gh-aw/aw-prompts/prompt.txt',
|
|
]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# argv / prompt
|
|
# --------------------------------------------------------------------------- #
|
|
def test_parses_full_claude_argv_without_error():
|
|
args = shim.parse_args([*GHAW_ARGV, 'do the thing'])
|
|
assert args.mcp_config == '/tmp/mcp-servers.json'
|
|
assert args.prompt_file == '/tmp/gh-aw/aw-prompts/prompt.txt'
|
|
assert args.prompt_positional == 'do the thing'
|
|
assert args.permission_mode == 'bypassPermissions'
|
|
|
|
|
|
def test_unknown_future_claude_flags_are_tolerated():
|
|
args = shim.parse_args([*GHAW_ARGV, '--some-future-flag', 'x', 'prompt'])
|
|
assert args.prompt_positional == 'prompt'
|
|
|
|
|
|
def test_prompt_recovered_from_trailing_positional():
|
|
args = shim.parse_args([*GHAW_ARGV, 'Investigate the failing CI run.'])
|
|
assert shim.resolve_prompt(args) == 'Investigate the failing CI run.'
|
|
|
|
|
|
def test_prompt_falls_back_to_prompt_file(tmp_path: Path):
|
|
pf = tmp_path / 'prompt.txt'
|
|
pf.write_text('from file', encoding='utf-8')
|
|
args = shim.parse_args(['--prompt-file', str(pf)])
|
|
assert shim.resolve_prompt(args) == 'from file'
|
|
|
|
|
|
def test_prompt_falls_back_to_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
pf = tmp_path / 'p.txt'
|
|
pf.write_text('from env path', encoding='utf-8')
|
|
monkeypatch.setenv('GH_AW_PROMPT', str(pf))
|
|
assert shim.resolve_prompt(shim.parse_args(['--print'])) == 'from env path'
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# --allowed-tools parsing & enforcement
|
|
# --------------------------------------------------------------------------- #
|
|
def test_allowed_tools_absent_is_none():
|
|
assert shim.parse_args(['--print']).allowed_tools is None
|
|
assert shim._split_allowed_tools(None) is None # pyright: ignore[reportPrivateUsage]
|
|
|
|
|
|
def test_allowed_tools_parsed_and_scope_stripped():
|
|
args = shim.parse_args([*GHAW_ARGV, 'p'])
|
|
assert args.allowed_tools == frozenset({'Bash', 'Read', 'Edit', 'mcp__github__get_me', 'mcp__safeoutputs'})
|
|
|
|
|
|
async def _toolset_names(
|
|
allowed: frozenset[str] | None,
|
|
permission_mode: str | None,
|
|
*,
|
|
task: shim.TaskCallable | None = None,
|
|
) -> list[str]:
|
|
"""Resolve a `select_claude_code_toolset(...)` result to its post-filter tool
|
|
name list. The filtered toolset reports its tools through
|
|
`.get_tools(ctx)`, so we drive it with a minimal RunContext.
|
|
"""
|
|
from pydantic_ai.usage import RunUsage
|
|
|
|
toolset = shim.select_claude_code_toolset(allowed, permission_mode, task=task)
|
|
ctx = RunContext(
|
|
deps=None,
|
|
model=cast(_Model[Any], None),
|
|
usage=RunUsage(),
|
|
prompt=None,
|
|
messages=[],
|
|
run_step=0,
|
|
)
|
|
tools = await toolset.get_tools(ctx)
|
|
return list(tools.keys())
|
|
|
|
|
|
def test_select_claude_code_toolset_no_allowlist_keeps_all():
|
|
import asyncio
|
|
|
|
names = asyncio.run(_toolset_names(None, None, task=shim.task))
|
|
# task=shim.task adds "Task" alongside the base callables. Order is
|
|
# insertion order from `_BASE_TOOLS` + the appended Task entry.
|
|
assert names == [*pkg.CLAUDE_CODE_TOOL_NAMES, 'Task']
|
|
|
|
|
|
def test_select_claude_code_toolset_enforces_allowlist():
|
|
import asyncio
|
|
|
|
names = asyncio.run(_toolset_names(frozenset({'Bash', 'Read', 'mcp__safeoutputs'}), None))
|
|
assert names == ['Bash', 'Read']
|
|
|
|
|
|
def test_plan_mode_withholds_mutating_tools():
|
|
import asyncio
|
|
|
|
names = set(asyncio.run(_toolset_names(None, 'plan')))
|
|
assert names.isdisjoint(pkg.MUTATING_TOOLS)
|
|
assert 'Read' in names and 'Grep' in names and 'Glob' in names
|
|
|
|
|
|
def test_plan_mode_and_allowlist_compose():
|
|
import asyncio
|
|
|
|
names = asyncio.run(_toolset_names(frozenset({'Bash', 'Read'}), 'plan'))
|
|
assert names == ['Read'] # Bash dropped by plan mode
|
|
|
|
|
|
def test_claude_code_tool_names():
|
|
# `WebFetch` is wired separately via a `NativeTool(WebFetchTool())`
|
|
# capability — it's not in the callable Claude Code tool list.
|
|
# `Task` is registered through `build_claude_code_toolset(task=...)` and
|
|
# so isn't part of the static `CLAUDE_CODE_TOOL_NAMES` tuple either.
|
|
assert pkg.CLAUDE_CODE_TOOL_NAMES == (
|
|
'Bash',
|
|
'Read',
|
|
'Write',
|
|
'Edit',
|
|
'MultiEdit',
|
|
'Grep',
|
|
'Glob',
|
|
'LS',
|
|
'TodoWrite',
|
|
'ExitPlanMode',
|
|
)
|
|
|
|
|
|
def test_harness_backed_tools_are_async_and_pin_the_remaining_gaps():
|
|
"""Guard the harness-backed vs hand-rolled split — the boundary of the swap.
|
|
|
|
The harness-backed tools delegate to pydantic-ai-harness and are async:
|
|
`Bash`/`Read`/`Write`/`Edit`/`Grep`/`Glob`/`LS` to `FileSystemToolset` /
|
|
`ShellToolset`, and `TodoWrite` to the experimental `planning` capability's
|
|
`write_plan` (experimental is acceptable; the warning is silenced at import).
|
|
|
|
The remaining tools have no harness equivalent and stay sync:
|
|
|
|
- `MultiEdit` — the harness has no atomic multi-replacement primitive
|
|
(`edit_file` is single-unique-occurrence, one call, no batch rollback).
|
|
- `ExitPlanMode` — a plan-mode protocol ack with no capability behind it.
|
|
|
|
(`Task`, in the main shim, stays hand-rolled too: the experimental
|
|
`SubAgentToolset.delegate_task` routes to *pre-named* agents, while Claude's
|
|
`Task` spawns an ad-hoc sub-agent from a free-form prompt — a different
|
|
interface, not just an experimental one.)
|
|
|
|
If a future change backs one of these with the harness (or accidentally
|
|
de-async's a backed tool), this test trips so the gap list stays honest.
|
|
"""
|
|
fn_by_name = {
|
|
'Bash': pkg.bash,
|
|
'Read': pkg.read_file,
|
|
'Write': pkg.write_file,
|
|
'Edit': pkg.edit_file,
|
|
'Grep': pkg.grep,
|
|
'Glob': pkg.glob_search,
|
|
'LS': pkg.list_dir,
|
|
'MultiEdit': pkg.multi_edit,
|
|
'TodoWrite': pkg.todo_write,
|
|
'ExitPlanMode': pkg.exit_plan_mode,
|
|
}
|
|
harness_backed = {'Bash', 'Read', 'Write', 'Edit', 'Grep', 'Glob', 'LS', 'TodoWrite'}
|
|
hand_rolled = {'MultiEdit', 'ExitPlanMode'}
|
|
# Every Claude tool is accounted for in exactly one bucket.
|
|
assert harness_backed.isdisjoint(hand_rolled)
|
|
assert harness_backed | hand_rolled == set(pkg.CLAUDE_CODE_TOOL_NAMES) == set(fn_by_name)
|
|
for name in harness_backed:
|
|
assert asyncio.iscoroutinefunction(fn_by_name[name]), f'{name} should be harness-backed (async)'
|
|
for name in hand_rolled:
|
|
assert not asyncio.iscoroutinefunction(fn_by_name[name]), f'{name} has no stable harness equivalent (sync)'
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Claude Code tool behavior
|
|
# --------------------------------------------------------------------------- #
|
|
def test_file_tools_roundtrip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
# The harness-backed Read/Write/Edit are contained to the workspace root.
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
f = tmp_path / 'sub' / 'note.txt'
|
|
# Write creates the missing parent directory, mirroring Claude's `Write`.
|
|
assert 'Wrote' in asyncio.run(pkg.write_file(str(f), 'hello\nworld\n'))
|
|
body = asyncio.run(pkg.read_file(str(f)))
|
|
assert 'hello' in body and 'world' in body
|
|
assert 'Edited' in asyncio.run(pkg.edit_file(str(f), 'world', 'there'))
|
|
assert 'there' in asyncio.run(pkg.read_file(str(f)))
|
|
assert 'note.txt' in asyncio.run(pkg.list_dir(str(tmp_path / 'sub')))
|
|
# The harness requires a unique match; a missing string comes back as an error.
|
|
miss = asyncio.run(pkg.edit_file(str(f), 'absent', 'x'))
|
|
assert miss.startswith('error:') and 'not found' in miss
|
|
|
|
|
|
def test_read_file_offset_and_limit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
f = tmp_path / 'n.txt'
|
|
f.write_text('l1\nl2\nl3\nl4\n', encoding='utf-8')
|
|
# Claude's 1-based offset=2 maps to the harness 0-based offset; limit=2.
|
|
out = asyncio.run(pkg.read_file(str(f), offset=2, limit=2))
|
|
assert 'l2' in out and 'l3' in out
|
|
assert 'l1' not in out and 'l4' not in out
|
|
|
|
|
|
def test_read_continuation_hint_uses_one_based_offset(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
# The harness's truncation hint carries its own 0-based offset; this tool's
|
|
# `offset` is 1-based, so the hint must be bumped by one. Otherwise a model
|
|
# that follows the hint literally re-reads the last line of the prior chunk.
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
f = tmp_path / 'big.txt'
|
|
f.write_text('\n'.join(f'L{i}' for i in range(1, 4)) + '\n', encoding='utf-8') # L1..L3
|
|
first = asyncio.run(pkg.read_file(str(f), limit=2)) # reads L1,L2 + a continuation hint
|
|
assert 'Use offset=3 to continue reading.' in first # harness emits 2 (0-based); bumped to 3
|
|
assert 'Use offset=2 to continue reading.' not in first
|
|
# Following the (1-based) hint continues at L3 with no duplicated boundary line.
|
|
nxt = asyncio.run(pkg.read_file(str(f), offset=3, limit=2))
|
|
assert 'L3' in nxt and 'L2' not in nxt
|
|
|
|
|
|
def test_read_limit_zero_behaves_like_omitted_limit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
# Passed straight to the harness, `limit=0` reads zero lines and emits a
|
|
# same-offset hint that loops. The adapter normalizes `limit<=0` to an omitted
|
|
# limit, so a small file comes back whole with no continuation hint.
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
f = tmp_path / 'f.txt'
|
|
f.write_text('one\ntwo\nthree\n', encoding='utf-8')
|
|
out = asyncio.run(pkg.read_file(str(f), offset=1, limit=0))
|
|
assert 'one' in out and 'three' in out
|
|
assert 'to continue reading' not in out # whole short file fit; no looping hint
|
|
|
|
|
|
def test_harness_backed_tools_surface_oserror_as_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
# The harness converts only a fixed set of exceptions to `ModelRetry`; a bare
|
|
# `OSError` (here `ENAMETOOLONG` from an over-long path component) is not one
|
|
# of them, so each adapter must catch it and return an `error:` string instead
|
|
# of letting it escape and abort the whole agent run.
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
long_path = 'a' * 10_000
|
|
assert asyncio.run(pkg.read_file(long_path)).startswith('error:')
|
|
assert asyncio.run(pkg.edit_file(long_path, 'x', 'y')).startswith('error:')
|
|
assert asyncio.run(pkg.list_dir(long_path)).startswith('error:')
|
|
assert asyncio.run(pkg.glob_search('*.py', long_path)).startswith('error:')
|
|
assert asyncio.run(pkg.grep('x', long_path)).startswith('error:')
|
|
|
|
|
|
def test_read_large_chunk_keeps_accurate_continuation_offset(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
# The harness puts its continuation hint at the tail, but the shim's output cap
|
|
# keeps the head -- so a chunk over the char cap (common for long-lined files)
|
|
# would lose the hint entirely. The adapter truncates on a whole-line boundary
|
|
# and re-advertises the exact 1-based offset of the first dropped line.
|
|
import re
|
|
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
f = tmp_path / 'big.txt'
|
|
# Zero-padded line ids (distinct from the harness's space-padded line numbers)
|
|
# plus long padding so the chunk blows well past the char cap.
|
|
f.write_text('\n'.join(f'{i:06d}' + 'x' * 200 for i in range(1, 2001)) + '\n', encoding='utf-8')
|
|
out = asyncio.run(pkg.read_file(str(f)))
|
|
assert len(out) <= shared.MAX_TOOL_OUTPUT + 256 # bounded by the output cap
|
|
m = re.search(r'Use offset=(\d+) to continue reading', out)
|
|
assert m, 'continuation hint must survive char-budget truncation'
|
|
nxt = int(m.group(1))
|
|
# The advertised offset is the first line NOT shown: line (nxt-1) is present,
|
|
# line nxt is not (no off-by-one, no gap).
|
|
assert f'{nxt - 1:06d}x' in out and f'{nxt:06d}x' not in out
|
|
# Following the offset continues exactly at line nxt.
|
|
cont = asyncio.run(pkg.read_file(str(f), offset=nxt))
|
|
assert f'{nxt:06d}x' in cont
|
|
|
|
|
|
def test_read_does_not_rewrite_hint_text_in_file_contents(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
# The offset bump must target only the harness's own continuation hint, not file
|
|
# content -- even a line reproducing the *full* hint verbatim. The harness writes
|
|
# the real hint at column 0; content lines are line-number-prefixed, so anchoring
|
|
# to `^` leaves the content untouched.
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
f = tmp_path / 'doc.txt'
|
|
f.write_text('... (4 more lines. Use offset=7 to continue reading.)\n', encoding='utf-8')
|
|
out = asyncio.run(pkg.read_file(str(f))) # short file: not truncated, no real hint added
|
|
assert 'Use offset=7 to continue reading.' in out # content preserved verbatim
|
|
assert 'Use offset=8' not in out
|
|
|
|
|
|
def test_edit_file_replace_all(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
# `replace_all` has no harness equivalent, so it stays an in-place rewrite
|
|
# (still contained to the workspace -- see the companion containment test).
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
f = tmp_path / 'r.txt'
|
|
f.write_text('a a a', encoding='utf-8')
|
|
asyncio.run(pkg.edit_file(str(f), 'a', 'b', replace_all=True))
|
|
assert f.read_text(encoding='utf-8') == 'b b b'
|
|
|
|
|
|
def test_edit_replace_all_is_contained_to_the_workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
# `replace_all` does the rewrite by hand rather than through the harness, but
|
|
# it must still honor the workspace boundary the single-edit path enforces;
|
|
# otherwise an absolute path outside the root would be editable via this flag.
|
|
workspace = tmp_path / 'ws'
|
|
workspace.mkdir()
|
|
outside = tmp_path / 'outside.txt'
|
|
outside.write_text('a a a', encoding='utf-8')
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(workspace))
|
|
out = asyncio.run(pkg.edit_file(str(outside), 'a', 'b', replace_all=True))
|
|
assert out.startswith('error:')
|
|
assert outside.read_text(encoding='utf-8') == 'a a a' # untouched
|
|
|
|
|
|
def test_bash_tool():
|
|
out = asyncio.run(pkg.bash('echo hello-from-bash'))
|
|
assert 'hello-from-bash' in out
|
|
|
|
|
|
def test_bash_timeout_is_surfaced_as_error():
|
|
# The harness *returns* a `[Command timed out ...]` sentinel rather than
|
|
# raising; the adapter must wrap it as an `error:` string (as the old tool
|
|
# did) so the model doesn't read a timeout as a successful, empty result.
|
|
out = asyncio.run(pkg.bash('sleep 5', timeout=1))
|
|
assert out.startswith('error:') and 'timed out' in out
|
|
|
|
|
|
def test_bash_subprocess_startup_failure_is_an_error_not_a_crash(monkeypatch: pytest.MonkeyPatch):
|
|
# `run_command` can raise a raw `OSError` (not converted to `ModelRetry`) when
|
|
# subprocess startup fails -- e.g. the workspace cwd doesn't exist. That must
|
|
# come back as an `error:` string instead of aborting the whole agent run.
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', '/definitely/not/a/workspace/xyz')
|
|
out = asyncio.run(pkg.bash('echo hi', timeout=1))
|
|
assert out.startswith('error:')
|
|
|
|
|
|
def test_grep_tool(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
# grep runs ripgrep through the harness shell capability; the adapter keys off
|
|
# ripgrep's exit code (parsed from `run_command`'s trailing `[exit code: N]`)
|
|
# to unwrap the `[stdout]` framing into `file:line:text` matches, and maps
|
|
# exit-1 ("nothing matched") to the harness's own `No matches found.` sentinel.
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
(tmp_path / 'a.txt').write_text('alpha\nNEEDLE here\n', encoding='utf-8')
|
|
assert 'a.txt:2:NEEDLE here' in asyncio.run(pkg.grep('NEEDLE', '.'))
|
|
assert asyncio.run(pkg.grep('ZZZNOPE', '.')) == 'No matches found.'
|
|
# An empty path normalizes to the workspace root rather than reaching `rg`
|
|
# as an empty argument (which would error).
|
|
assert 'a.txt:2:NEEDLE here' in asyncio.run(pkg.grep('NEEDLE', ''))
|
|
|
|
|
|
def test_grep_bad_pattern_is_an_error_not_a_match(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
# ripgrep exits 2 on a malformed regex. Even though it writes to the framed
|
|
# output, the adapter keys off the exit code, so it surfaces as an error
|
|
# rather than being mistaken for a match (the `[stdout]`-prefix sniff bug).
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
(tmp_path / 'a.txt').write_text('alpha\n', encoding='utf-8')
|
|
out = asyncio.run(pkg.grep('(', '.'))
|
|
assert out.startswith('error:')
|
|
|
|
|
|
def test_grep_path_is_contained_to_the_workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
# `ShellToolset` would happily run `rg -- ../..`; the filesystem preflight
|
|
# rejects a path that escapes the workspace root before ripgrep ever runs.
|
|
workspace = tmp_path / 'ws'
|
|
workspace.mkdir()
|
|
(tmp_path / 'secret.txt').write_text('TOPSECRET\n', encoding='utf-8')
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(workspace))
|
|
out = asyncio.run(pkg.grep('TOPSECRET', '..'))
|
|
assert out.startswith('error:')
|
|
assert 'TOPSECRET' not in out
|
|
|
|
|
|
def test_grep_large_match_set_is_not_misreported_as_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
# A match set larger than the harness output cap is tail-truncated, which
|
|
# elides the `[stdout]` header and prepends a truncation marker. The adapter
|
|
# must still return it as matches, not as an error.
|
|
import importlib
|
|
|
|
# `pkg.grep` is the re-exported callable; reach the module to patch its deps.
|
|
grep_mod = importlib.import_module('pydantic_ai_gh_aw_shim.grep')
|
|
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path)) # empty workspace: no context blocks
|
|
truncated = f'{grep_mod._TRUNCATION_PREFIX}, showing last 50000 chars]\nsrc/a.py:1:hit\nsrc/b.py:2:hit\n'
|
|
|
|
class _FakeShell:
|
|
async def run_command(self, command: str, *, timeout_seconds: float) -> str:
|
|
return truncated
|
|
|
|
class _FakeFs:
|
|
async def file_info(self, path: str) -> str:
|
|
return 'ok'
|
|
|
|
monkeypatch.setattr(grep_mod, 'shell', lambda: _FakeShell())
|
|
monkeypatch.setattr(grep_mod, 'filesystem', lambda: _FakeFs())
|
|
out = asyncio.run(grep_mod.grep('hit', '.'))
|
|
assert not out.startswith('error:')
|
|
assert 'src/a.py:1:hit' in out and 'src/b.py:2:hit' in out
|
|
assert grep_mod._TRUNCATION_PREFIX not in out
|
|
|
|
|
|
def test_glob_tool(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
# `Glob` returns matches relative to the search path (workspace root for '.').
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
(tmp_path / 'x').mkdir()
|
|
(tmp_path / 'x' / 'a.py').write_text('', encoding='utf-8')
|
|
(tmp_path / 'x' / 'b.txt').write_text('', encoding='utf-8')
|
|
res = asyncio.run(pkg.glob_search('**/*.py', '.'))
|
|
assert 'x/a.py' in res and 'b.txt' not in res
|
|
|
|
|
|
def test_glob_outside_base_is_handled(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
# An absolute glob pattern can't resolve under the workspace root; the
|
|
# adapter rejects it up front.
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
out = asyncio.run(pkg.glob_search('/etc/*', '.'))
|
|
assert out.startswith('error:')
|
|
|
|
|
|
def test_ls_and_glob_surface_dotfiles(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
# The harness `list_directory`/`find_files` walkers hide every dot-prefixed
|
|
# path, which would make `.github/` (where gh-aw's workflows live) invisible.
|
|
# The shim hand-rolls the enumeration so dot-paths stay discoverable.
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
(tmp_path / '.github' / 'workflows').mkdir(parents=True)
|
|
(tmp_path / '.github' / 'workflows' / 'ci.yml').write_text('', encoding='utf-8')
|
|
assert '.github/' in asyncio.run(pkg.list_dir('.'))
|
|
assert 'workflows/' in asyncio.run(pkg.list_dir('.github'))
|
|
assert '.github/workflows/ci.yml' in asyncio.run(pkg.glob_search('.github/**/*.yml', '.'))
|
|
|
|
|
|
def test_ls_and_glob_paths_are_contained_to_the_workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
# The hand-rolled enumeration still preflights containment through the
|
|
# filesystem capability, so a path escaping the workspace is rejected.
|
|
workspace = tmp_path / 'ws'
|
|
workspace.mkdir()
|
|
(tmp_path / 'secret.txt').write_text('TOPSECRET\n', encoding='utf-8')
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(workspace))
|
|
ls_out = asyncio.run(pkg.list_dir('..'))
|
|
glob_out = asyncio.run(pkg.glob_search('*.txt', '..'))
|
|
assert ls_out.startswith('error:') and 'secret.txt' not in ls_out
|
|
assert glob_out.startswith('error:') and 'secret.txt' not in glob_out
|
|
|
|
|
|
def test_glob_reports_matched_symlink_not_its_target(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
# Resolving a match is only for the containment decision; the returned path
|
|
# must be the name that matched. An in-workspace symlink (here `CLAUDE.md` ->
|
|
# `AGENTS.md`, as this repo actually has) must be reported as `CLAUDE.md`, and
|
|
# must not be collapsed into its target by dedup.
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
(tmp_path / 'AGENTS.md').write_text('x', encoding='utf-8')
|
|
(tmp_path / 'CLAUDE.md').symlink_to('AGENTS.md')
|
|
assert asyncio.run(pkg.glob_search('CLAUDE.md', '.')).splitlines()[-1] == 'CLAUDE.md'
|
|
both = asyncio.run(pkg.glob_search('*.md', '.'))
|
|
assert 'AGENTS.md' in both and 'CLAUDE.md' in both
|
|
|
|
|
|
def test_glob_pattern_cannot_escape_via_dotdot_or_symlink(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
# Reverting to a stdlib glob must not lose containment: a `..` in the pattern,
|
|
# or an in-workspace symlink pointing out, must NOT surface a file outside the
|
|
# workspace -- a purely lexical `relative_to` check would miss both.
|
|
workspace = tmp_path / 'ws'
|
|
workspace.mkdir()
|
|
(tmp_path / 'secret.txt').write_text('TOPSECRET\n', encoding='utf-8')
|
|
(workspace / 'link').symlink_to(tmp_path) # symlink that climbs out of the workspace
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(workspace))
|
|
via_dotdot = asyncio.run(pkg.glob_search('../secret.txt', '.'))
|
|
via_symlink = asyncio.run(pkg.glob_search('link/*.txt', '.'))
|
|
assert 'secret.txt' not in via_dotdot
|
|
assert 'secret.txt' not in via_symlink
|
|
|
|
|
|
def test_multi_edit_atomic(tmp_path: Path):
|
|
f = tmp_path / 'm.txt'
|
|
f.write_text('one two three', encoding='utf-8')
|
|
ok = pkg.multi_edit(str(f), [{'old_string': 'one', 'new_string': '1'}, {'old_string': 'three', 'new_string': '3'}])
|
|
assert 'applied 2 edit(s)' in ok
|
|
assert f.read_text(encoding='utf-8') == '1 two 3'
|
|
# A failing edit writes nothing (atomic).
|
|
res = pkg.multi_edit(str(f), [{'old_string': '1', 'new_string': 'X'}, {'old_string': 'absent', 'new_string': 'Y'}])
|
|
assert 'edit #2' in res and 'not found' in res
|
|
assert f.read_text(encoding='utf-8') == '1 two 3'
|
|
|
|
|
|
def test_multi_edit_replace_all(tmp_path: Path):
|
|
f = tmp_path / 'r.txt'
|
|
f.write_text('a a a', encoding='utf-8')
|
|
pkg.multi_edit(str(f), [{'old_string': 'a', 'new_string': 'b', 'replace_all': True}])
|
|
assert f.read_text(encoding='utf-8') == 'b b b'
|
|
|
|
|
|
def test_web_fetch_only_enabled_on_real_anthropic(monkeypatch: pytest.MonkeyPatch):
|
|
"""`web_fetch_20250910` is an Anthropic-server-side tool; compat
|
|
endpoints (MiniMax etc.) reject it with HTTP 400. The capability is
|
|
gated by `ANTHROPIC_BASE_URL`."""
|
|
from pydantic_ai.capabilities import NativeTool
|
|
|
|
monkeypatch.delenv('ANTHROPIC_BASE_URL', raising=False)
|
|
caps = shim._anthropic_native_capabilities() # pyright: ignore[reportPrivateUsage]
|
|
assert len(caps) == 1 and isinstance(caps[0], NativeTool)
|
|
|
|
monkeypatch.setenv('ANTHROPIC_BASE_URL', 'https://api.anthropic.com')
|
|
assert len(shim._anthropic_native_capabilities()) == 1 # pyright: ignore[reportPrivateUsage]
|
|
|
|
monkeypatch.setenv('ANTHROPIC_BASE_URL', 'https://api.minimax.io/anthropic')
|
|
assert shim._anthropic_native_capabilities() == [] # pyright: ignore[reportPrivateUsage]
|
|
|
|
|
|
def test_todo_write_renders_plan_via_harness():
|
|
# TodoWrite maps Claude's todo schema onto the harness `planning` capability
|
|
# and returns its `write_plan` rendering (a checklist with a progress line).
|
|
out = asyncio.run(pkg.todo_write([{'content': 'do x', 'status': 'in_progress', 'activeForm': 'doing x'}]))
|
|
assert 'do x' in out and '[~]' in out and '(0/1 completed)' in out
|
|
# A completed step shows as done; an unknown status falls back to pending.
|
|
out2 = asyncio.run(
|
|
pkg.todo_write(
|
|
[
|
|
{'content': 'a', 'status': 'completed', 'activeForm': ''},
|
|
{'content': 'b', 'status': 'bogus', 'activeForm': ''},
|
|
]
|
|
)
|
|
)
|
|
assert '[x] a' in out2 and '[ ] b' in out2 and '(1/2 completed)' in out2
|
|
|
|
|
|
def test_exit_plan_mode_returns_ack():
|
|
assert 'proceeding' in pkg.exit_plan_mode('step 1; step 2').lower()
|
|
|
|
|
|
def test_plan_mode_keeps_new_readonly_tools_drops_multiedit():
|
|
import asyncio
|
|
|
|
# Note: WebFetch is an Anthropic server-side capability (not in the callable list).
|
|
names = set(asyncio.run(_toolset_names(None, 'plan')))
|
|
assert 'MultiEdit' not in names # mutating
|
|
assert {'TodoWrite', 'ExitPlanMode'} <= names # non-mutating callables
|
|
|
|
|
|
def test_request_limit_is_a_constant():
|
|
assert shim.REQUEST_LIMIT == 200
|
|
|
|
|
|
def test_instructions_encourage_parallel_tool_calls():
|
|
assert shim.INSTRUCTIONS.strip()
|
|
assert 'parallel' in shim.INSTRUCTIONS.lower()
|
|
|
|
|
|
def test_run_routes_workflow_prompt_to_system_instructions(monkeypatch: pytest.MonkeyPatch):
|
|
"""Workflow prompt rides in the system instruction; user message is RUN_TRIGGER."""
|
|
import asyncio
|
|
|
|
from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart, UserPromptPart
|
|
from pydantic_ai.models.function import AgentInfo, FunctionModel
|
|
|
|
seen_instructions: list[str] = []
|
|
received: list[ModelMessage] = []
|
|
emitted: list[dict[str, object]] = []
|
|
|
|
def _respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
seen_instructions.append(info.instructions or '')
|
|
received.extend(messages)
|
|
return ModelResponse(parts=[TextPart('done')])
|
|
|
|
async def _stream(messages: list[ModelMessage], info: AgentInfo):
|
|
seen_instructions.append(info.instructions or '')
|
|
received.extend(messages)
|
|
yield 'done'
|
|
|
|
monkeypatch.setattr(shim, 'emit', lambda obj: emitted.append(dict(obj))) # pyright: ignore[reportUnknownArgumentType, reportUnknownLambdaType]
|
|
monkeypatch.setattr(shim, 'log_safe_outputs_state', lambda: None)
|
|
|
|
sentinel = '### WORKFLOW TASK SPEC: review the PR per the rules above ###'
|
|
asyncio.run(
|
|
shim.run(
|
|
prompt=sentinel,
|
|
model=FunctionModel(_respond, stream_function=_stream),
|
|
label='test-model',
|
|
claude_code_toolset=shim.select_claude_code_toolset(None, None, task=None),
|
|
mcp_servers=[],
|
|
session_id='test-session',
|
|
)
|
|
)
|
|
|
|
instructions = seen_instructions[0]
|
|
user_text = '\n'.join(str(p.content) for m in received for p in m.parts if isinstance(p, UserPromptPart))
|
|
|
|
# Order matters for prompt-prefix caching: INSTRUCTIONS must come first.
|
|
assert instructions.startswith(shim.INSTRUCTIONS)
|
|
assert sentinel in instructions
|
|
assert user_text == shim.RUN_TRIGGER
|
|
# `run()` must emit both a `system`/`init` line and a `result` line for gh-aw.
|
|
kinds = [(e.get('type'), e.get('subtype')) for e in emitted]
|
|
assert ('system', 'init') in kinds
|
|
assert any(t == 'result' and s == 'success' for t, s in kinds)
|
|
assert sentinel not in user_text
|
|
|
|
|
|
def test_read_only_subagent_tools_are_non_mutating_and_exclude_task():
|
|
assert pkg.READ_ONLY_SUBAGENT_TOOLS.isdisjoint(pkg.MUTATING_TOOLS)
|
|
assert 'Task' not in pkg.READ_ONLY_SUBAGENT_TOOLS # no recursion
|
|
# All entries are real Claude Code tool names.
|
|
assert pkg.READ_ONLY_SUBAGENT_TOOLS <= set(pkg.CLAUDE_CODE_TOOL_NAMES)
|
|
|
|
|
|
def test_task_registered_via_build_claude_code_toolset():
|
|
"""`Task` isn't part of the static `CLAUDE_CODE_TOOL_NAMES` tuple — it gets
|
|
appended dynamically by `build_claude_code_toolset(task=...)` only for the
|
|
parent (sub-agents pass `task=None` so they can't recurse).
|
|
"""
|
|
import asyncio
|
|
|
|
parent_names = asyncio.run(_toolset_names(None, None, task=shim.task))
|
|
sub_names = asyncio.run(_toolset_names(None, None, task=None))
|
|
assert 'Task' in parent_names
|
|
assert 'Task' not in sub_names
|
|
|
|
|
|
def test_subagent_request_limit_is_a_constant():
|
|
assert shim.SUBAGENT_REQUEST_LIMIT == 75
|
|
|
|
|
|
def test_task_runs_subagent_with_run_model_and_read_only_tools(monkeypatch: pytest.MonkeyPatch):
|
|
# The Task tool spawns a sub-Agent on ctx.model with the read-only tool
|
|
# set, runs the given prompt, and returns the sub-agent's output.
|
|
import asyncio
|
|
|
|
from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart, UserPromptPart
|
|
from pydantic_ai.models.function import AgentInfo, FunctionModel
|
|
from pydantic_ai.usage import RunUsage
|
|
|
|
seen_instructions: list[str] = []
|
|
received_messages: list[ModelMessage] = []
|
|
received_tool_names: set[str] = set()
|
|
|
|
def _capture(messages: list[ModelMessage], info: AgentInfo) -> None:
|
|
seen_instructions.append(info.instructions or '')
|
|
received_messages.extend(messages)
|
|
received_tool_names.update(td.name for td in info.function_tools)
|
|
|
|
def _respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
_capture(messages, info)
|
|
return ModelResponse(parts=[TextPart('SUB: investigated')])
|
|
|
|
async def _stream(messages: list[ModelMessage], info: AgentInfo):
|
|
_capture(messages, info)
|
|
yield 'SUB: investigated'
|
|
|
|
# The original bug: sharing `ctx.usage` made `SUBAGENT_REQUEST_LIMIT` fire
|
|
# immediately past parent's 75th request. Sub-agent should run regardless.
|
|
parent_usage = RunUsage(requests=100, input_tokens=20_000, output_tokens=10_000)
|
|
|
|
class _Ctx:
|
|
model = FunctionModel(_respond, stream_function=_stream)
|
|
usage = parent_usage
|
|
|
|
out = asyncio.run(shim.task(cast(RunContext[None], _Ctx()), 'scan models/openai.py', 'find tool_call_id bugs'))
|
|
assert out == 'SUB: investigated'
|
|
|
|
instructions = seen_instructions[0]
|
|
user_text = '\n'.join(str(p.content) for m in received_messages for p in m.parts if isinstance(p, UserPromptPart))
|
|
assert shim.INSTRUCTIONS in instructions
|
|
assert shim.SUBAGENT_INSTRUCTIONS in instructions
|
|
assert 'find tool_call_id bugs' in instructions
|
|
assert user_text == shim.RUN_TRIGGER
|
|
assert 'find tool_call_id bugs' not in user_text
|
|
|
|
assert received_tool_names == set(pkg.READ_ONLY_SUBAGENT_TOOLS)
|
|
assert 'Task' not in received_tool_names
|
|
assert 'Bash' not in received_tool_names
|
|
|
|
# Sub-agent's cost rolls up to the parent without making the parent's
|
|
# request total trip the sub-agent's request_limit.
|
|
assert parent_usage.requests > 100
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# directory-scoped AGENTS.md / CLAUDE.md auto-loading
|
|
# --------------------------------------------------------------------------- #
|
|
def test_attach_context_surfaces_files_once_per_run(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
|
|
# AGENTS.md at root of the "workspace" + CLAUDE.md in a subdir.
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
(tmp_path / 'AGENTS.md').write_text('# repo conventions', encoding='utf-8')
|
|
sub = tmp_path / 'pkg'
|
|
sub.mkdir()
|
|
(sub / 'CLAUDE.md').write_text('# pkg conventions', encoding='utf-8')
|
|
(sub / 'code.py').write_text('x = 1\n', encoding='utf-8')
|
|
shared.reset_context_state()
|
|
|
|
first = shared.attach_context('pkg/code.py')
|
|
assert 'context: pkg/CLAUDE.md' in first # nearest first when walking up
|
|
assert 'context: AGENTS.md' in first
|
|
assert 'pkg conventions' in first and 'repo conventions' in first
|
|
|
|
# Subsequent calls in same run dedupe.
|
|
again = shared.attach_context('pkg/code.py')
|
|
assert again == ''
|
|
|
|
# A different path under the same dir hits no new context files.
|
|
(sub / 'other.py').write_text('', encoding='utf-8')
|
|
assert shared.attach_context('pkg/other.py') == ''
|
|
|
|
|
|
def test_attach_context_truncates_large_files(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
big = 'X' * (shared.MAX_CONTEXT_FILE_CHARS + 5000)
|
|
(tmp_path / 'AGENTS.md').write_text(big, encoding='utf-8')
|
|
shared.reset_context_state()
|
|
out = shared.attach_context('.')
|
|
# Body of the AGENTS.md block is capped to MAX_CONTEXT_FILE_CHARS.
|
|
body = out.split('---\n', 2)[-1]
|
|
assert len(body) <= shared.MAX_CONTEXT_FILE_CHARS + 50 # +slack for trailing markers
|
|
|
|
|
|
def test_attach_context_empty_for_missing_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
shared.reset_context_state()
|
|
assert shared.attach_context(None) == ''
|
|
assert shared.attach_context('does-not-exist.py') == '' # parent has no AGENTS.md/CLAUDE.md
|
|
|
|
|
|
def test_read_file_prepends_context(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
(tmp_path / 'AGENTS.md').write_text('repo rules', encoding='utf-8')
|
|
(tmp_path / 'f.txt').write_text('file body', encoding='utf-8')
|
|
shared.reset_context_state()
|
|
out = asyncio.run(pkg.read_file('f.txt'))
|
|
assert 'context: AGENTS.md' in out and 'repo rules' in out and 'file body' in out
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# history compaction (ProcessHistory capability)
|
|
# --------------------------------------------------------------------------- #
|
|
def test_compaction_thresholds_are_sane():
|
|
# ~100k tokens at 4 chars/tok = half a 200k-token window. The trigger
|
|
# is hardcoded (no per-knob multiplication) — fewer dials.
|
|
assert shim.COMPACTION_TRIGGER_CHARS == 400_000
|
|
assert shim.COMPACTION_KEEP_RECENT >= 4
|
|
assert shim.TOOL_RESULT_TRIM_THRESHOLD > shim.TOOL_RESULT_HEAD_TAIL_CHARS * 2
|
|
|
|
|
|
def test_history_size_chars_sums_all_part_content():
|
|
from pydantic_ai.messages import ModelRequest, UserPromptPart
|
|
|
|
msgs: list[ModelMessage] = [
|
|
ModelRequest(parts=[UserPromptPart(content='hello')]), # 5
|
|
ModelRequest(parts=[UserPromptPart(content='x' * 20)]), # 20
|
|
]
|
|
assert shim._history_size_chars(msgs) == 25 # pyright: ignore[reportPrivateUsage]
|
|
|
|
|
|
def test_compact_history_no_op_below_char_budget(monkeypatch: pytest.MonkeyPatch):
|
|
import asyncio
|
|
|
|
from pydantic_ai.messages import ModelRequest, UserPromptPart
|
|
|
|
# Many tiny messages — total chars stays well below the default 80k budget.
|
|
msgs: list[ModelMessage] = [ModelRequest(parts=[UserPromptPart(content=f'm{i}')]) for i in range(100)]
|
|
|
|
class _Ctx:
|
|
model = None
|
|
|
|
out = asyncio.run(shim._compact_history(cast(RunContext[None], _Ctx()), msgs)) # pyright: ignore[reportPrivateUsage]
|
|
assert out is msgs # size-based: count alone never triggers
|
|
|
|
|
|
def test_compact_history_summarises_with_fresh_usage_then_merges():
|
|
"""Summariser uses a fresh `RunUsage` (so request_limit doesn't trip on the
|
|
parent's running total) and the parent usage absorbs its cost after."""
|
|
import asyncio
|
|
|
|
from pydantic_ai.messages import ModelMessage, ModelRequest, ModelResponse, TextPart, UserPromptPart
|
|
from pydantic_ai.models.function import FunctionModel
|
|
from pydantic_ai.usage import RunUsage
|
|
|
|
big = 'x' * 50_000
|
|
msgs: list[ModelMessage] = [ModelRequest(parts=[UserPromptPart(content=f'm{i} {big}')]) for i in range(13)]
|
|
|
|
def _respond(_messages: list[ModelMessage], _info: object) -> ModelResponse:
|
|
return ModelResponse(parts=[TextPart('SHORT SUMMARY')])
|
|
|
|
async def _stream(_messages: list[ModelMessage], _info: object):
|
|
yield 'SHORT SUMMARY'
|
|
|
|
# Parent has already done 50 requests; the old shared-usage bug would
|
|
# trip `UsageLimits(request_limit=2)` immediately. With the fix it runs.
|
|
parent_usage = RunUsage(requests=50)
|
|
|
|
class _Ctx:
|
|
model = FunctionModel(_respond, stream_function=_stream)
|
|
usage = parent_usage
|
|
|
|
out = asyncio.run(shim._compact_history(cast(RunContext[None], _Ctx()), msgs)) # pyright: ignore[reportPrivateUsage]
|
|
assert len(out) == 1 + shim.COMPACTION_KEEP_RECENT
|
|
assert parent_usage.requests > 50 # summariser's cost merged into parent
|
|
summary_part = out[0].parts[0]
|
|
assert isinstance(summary_part, UserPromptPart)
|
|
assert 'SHORT SUMMARY' in str(summary_part.content)
|
|
|
|
|
|
def test_trim_dedupes_superseded_reads_and_truncates_large_results():
|
|
"""The cheap pre-pass should rewrite older tool results without invoking
|
|
the LLM: superseded `Read` returns become a one-line marker, oversized
|
|
returns are head/tail-truncated, and the last KEEP_RECENT messages are
|
|
left untouched."""
|
|
from pydantic_ai.messages import (
|
|
ModelMessage,
|
|
ModelRequest,
|
|
ModelResponse,
|
|
ToolCallPart,
|
|
ToolReturnPart,
|
|
UserPromptPart,
|
|
)
|
|
|
|
big = 'X' * 20_000
|
|
# Three Read calls for the same file: only the last is current; the first
|
|
# two should be marked superseded.
|
|
msgs: list[ModelMessage] = [
|
|
ModelRequest(parts=[UserPromptPart(content='start')]),
|
|
ModelResponse(parts=[ToolCallPart(tool_name='Read', args={'file_path': 'a.py'}, tool_call_id='r1')]),
|
|
ModelRequest(parts=[ToolReturnPart(tool_name='Read', content=big, tool_call_id='r1')]),
|
|
ModelResponse(parts=[ToolCallPart(tool_name='Read', args={'file_path': 'a.py'}, tool_call_id='r2')]),
|
|
ModelRequest(parts=[ToolReturnPart(tool_name='Read', content=big, tool_call_id='r2')]),
|
|
# An unrelated big Bash result that should get head/tail trimmed.
|
|
ModelResponse(parts=[ToolCallPart(tool_name='Bash', args={'command': 'ls -la'}, tool_call_id='b1')]),
|
|
ModelRequest(parts=[ToolReturnPart(tool_name='Bash', content=big, tool_call_id='b1')]),
|
|
ModelResponse(parts=[ToolCallPart(tool_name='Read', args={'file_path': 'a.py'}, tool_call_id='r3')]),
|
|
]
|
|
# Pad to exceed KEEP_RECENT so the older entries are eligible for trimming.
|
|
for i in range(shim.COMPACTION_KEEP_RECENT):
|
|
msgs.append(ModelRequest(parts=[UserPromptPart(content=f'tail{i}')]))
|
|
|
|
out = shim._trim_tool_results(msgs) # pyright: ignore[reportPrivateUsage]
|
|
assert len(out) == len(msgs)
|
|
|
|
# r1 (superseded by r2 and r3) → marker.
|
|
r1_return = out[2].parts[0]
|
|
assert isinstance(r1_return, ToolReturnPart)
|
|
assert 'superseded read' in str(r1_return.content) and 'a.py' in str(r1_return.content)
|
|
|
|
# r2 (superseded by r3) → also marker.
|
|
r2_return = out[4].parts[0]
|
|
assert isinstance(r2_return, ToolReturnPart)
|
|
assert 'superseded read' in str(r2_return.content)
|
|
|
|
# Bash result → head/tail truncated, not dedup-marked.
|
|
bash_return = out[6].parts[0]
|
|
assert isinstance(bash_return, ToolReturnPart)
|
|
bash_content = str(bash_return.content)
|
|
assert 'trimmed' in bash_content
|
|
assert len(bash_content) < len(big)
|
|
|
|
# Tail (last KEEP_RECENT) untouched and is the same object identity.
|
|
for i in range(shim.COMPACTION_KEEP_RECENT):
|
|
assert out[-(i + 1)] is msgs[-(i + 1)]
|
|
|
|
|
|
def test_trim_preserves_distinct_read_slices_of_same_file():
|
|
"""A `Read` with `offset=N, limit=M` returns different content than a
|
|
`Read` of the same file with no slice (or a different slice). The
|
|
dedup key is the full `(file_path, offset, limit)` tuple, so distinct
|
|
slices stay distinct — only an exact-args re-read is superseded."""
|
|
from pydantic_ai.messages import (
|
|
ModelMessage,
|
|
ModelRequest,
|
|
ModelResponse,
|
|
ToolCallPart,
|
|
ToolReturnPart,
|
|
UserPromptPart,
|
|
)
|
|
|
|
big = 'Y' * 20_000
|
|
msgs: list[ModelMessage] = [
|
|
# Slice 1 of foo.py — distinct content.
|
|
ModelResponse(
|
|
parts=[
|
|
ToolCallPart(
|
|
tool_name='Read', args={'file_path': 'foo.py', 'offset': 1, 'limit': 100}, tool_call_id='s1'
|
|
)
|
|
]
|
|
),
|
|
ModelRequest(parts=[ToolReturnPart(tool_name='Read', content=big, tool_call_id='s1')]),
|
|
# Different slice — must NOT be deduped against s1.
|
|
ModelResponse(
|
|
parts=[
|
|
ToolCallPart(
|
|
tool_name='Read', args={'file_path': 'foo.py', 'offset': 500, 'limit': 100}, tool_call_id='s2'
|
|
)
|
|
]
|
|
),
|
|
ModelRequest(parts=[ToolReturnPart(tool_name='Read', content=big, tool_call_id='s2')]),
|
|
# Same args as s1 — supersedes it.
|
|
ModelResponse(
|
|
parts=[
|
|
ToolCallPart(
|
|
tool_name='Read', args={'file_path': 'foo.py', 'offset': 1, 'limit': 100}, tool_call_id='s3'
|
|
)
|
|
]
|
|
),
|
|
ModelRequest(parts=[ToolReturnPart(tool_name='Read', content=big, tool_call_id='s3')]),
|
|
]
|
|
for i in range(shim.COMPACTION_KEEP_RECENT):
|
|
msgs.append(ModelRequest(parts=[UserPromptPart(content=f't{i}')]))
|
|
|
|
out = shim._trim_tool_results(msgs) # pyright: ignore[reportPrivateUsage]
|
|
|
|
# s1 (superseded by s3 — same args) → marker that mentions the slice args.
|
|
s1_return = out[1].parts[0]
|
|
assert isinstance(s1_return, ToolReturnPart)
|
|
s1_content = str(s1_return.content)
|
|
assert 'superseded read' in s1_content and 'foo.py' in s1_content and 'offset=1' in s1_content
|
|
|
|
# s2 (different slice) is oversized so it gets head/tail-truncated but
|
|
# NOT marked superseded — its content is genuinely distinct.
|
|
s2_return = out[3].parts[0]
|
|
assert isinstance(s2_return, ToolReturnPart)
|
|
s2_content = str(s2_return.content)
|
|
assert 'superseded' not in s2_content
|
|
assert 'trimmed' in s2_content
|
|
|
|
|
|
def test_trim_logs_substitution_counts_only_when_changes_fired(caplog: LogCaptureFixture):
|
|
"""Trim logs once when it substitutes; silent on a no-op pass."""
|
|
from pydantic_ai.messages import (
|
|
ModelMessage,
|
|
ModelRequest,
|
|
ModelResponse,
|
|
ToolCallPart,
|
|
ToolReturnPart,
|
|
UserPromptPart,
|
|
)
|
|
|
|
tiny_msgs: list[ModelMessage] = [
|
|
ModelRequest(parts=[UserPromptPart(content=f'm{i}')]) for i in range(shim.COMPACTION_KEEP_RECENT + 5)
|
|
]
|
|
with caplog.at_level('INFO', logger='pydantic_ai_gh_aw_shim'):
|
|
shim._trim_tool_results(tiny_msgs) # pyright: ignore[reportPrivateUsage]
|
|
assert not any('trim: deduped' in m for m in caplog.messages)
|
|
caplog.clear()
|
|
|
|
big = 'Z' * 20_000
|
|
msgs: list[ModelMessage] = [
|
|
ModelResponse(parts=[ToolCallPart(tool_name='Read', args={'file_path': 'x.py'}, tool_call_id='r1')]),
|
|
ModelRequest(parts=[ToolReturnPart(tool_name='Read', content=big, tool_call_id='r1')]),
|
|
ModelResponse(parts=[ToolCallPart(tool_name='Read', args={'file_path': 'x.py'}, tool_call_id='r2')]),
|
|
ModelRequest(parts=[ToolReturnPart(tool_name='Read', content=big, tool_call_id='r2')]),
|
|
ModelResponse(parts=[ToolCallPart(tool_name='Bash', args={'command': 'ls'}, tool_call_id='b1')]),
|
|
ModelRequest(parts=[ToolReturnPart(tool_name='Bash', content=big, tool_call_id='b1')]),
|
|
]
|
|
for i in range(shim.COMPACTION_KEEP_RECENT):
|
|
msgs.append(ModelRequest(parts=[UserPromptPart(content=f't{i}')]))
|
|
with caplog.at_level('INFO', logger='pydantic_ai_gh_aw_shim'):
|
|
shim._trim_tool_results(msgs) # pyright: ignore[reportPrivateUsage]
|
|
log_line = next((m for m in caplog.messages if 'trim: deduped' in m), None)
|
|
assert log_line is not None
|
|
assert 'deduped 1' in log_line and 'truncated 2' in log_line and 'saved' in log_line
|
|
|
|
|
|
def test_compact_history_uses_trim_alone_when_sufficient(monkeypatch: pytest.MonkeyPatch):
|
|
"""Trim alone is enough — the LLM summariser must not fire."""
|
|
import asyncio
|
|
|
|
from pydantic_ai.messages import (
|
|
ModelMessage,
|
|
ModelRequest,
|
|
ModelResponse,
|
|
ToolCallPart,
|
|
ToolReturnPart,
|
|
UserPromptPart,
|
|
)
|
|
|
|
# 13 messages: a couple of huge superseded reads, then KEEP_RECENT trivial
|
|
# tail messages. The dedup pass should crush the size.
|
|
big = 'Y' * 60_000
|
|
msgs: list[ModelMessage] = [
|
|
ModelResponse(parts=[ToolCallPart(tool_name='Read', args={'file_path': 'big.py'}, tool_call_id='c1')]),
|
|
ModelRequest(parts=[ToolReturnPart(tool_name='Read', content=big, tool_call_id='c1')]),
|
|
ModelResponse(parts=[ToolCallPart(tool_name='Read', args={'file_path': 'big.py'}, tool_call_id='c2')]),
|
|
]
|
|
for i in range(shim.COMPACTION_KEEP_RECENT):
|
|
msgs.append(ModelRequest(parts=[UserPromptPart(content=f't{i}')]))
|
|
|
|
def _fail_agent_ctor(*_a: object, **_kw: object) -> None:
|
|
raise AssertionError('summariser should not be invoked when trim is enough')
|
|
|
|
monkeypatch.setattr(shim, 'Agent', _fail_agent_ctor)
|
|
|
|
class _Ctx:
|
|
model = None
|
|
|
|
out = asyncio.run(shim._compact_history(cast(RunContext[None], _Ctx()), msgs)) # pyright: ignore[reportPrivateUsage]
|
|
assert len(out) == len(msgs)
|
|
# The first read result is now a marker, not the original payload.
|
|
first_return = out[1].parts[0]
|
|
assert isinstance(first_return, ToolReturnPart)
|
|
assert 'superseded read' in str(first_return.content)
|
|
|
|
|
|
def test_compact_history_falls_back_to_truncation_on_failure(monkeypatch: pytest.MonkeyPatch):
|
|
import asyncio
|
|
|
|
from pydantic_ai.messages import ModelRequest, UserPromptPart
|
|
from pydantic_ai.models.test import TestModel
|
|
|
|
# Same size-driven setup as the previous test — 13 big msgs > trigger.
|
|
big = 'x' * 50_000
|
|
msgs: list[ModelMessage] = [ModelRequest(parts=[UserPromptPart(content=f'm{i} {big}')]) for i in range(13)]
|
|
|
|
class _FailingAgent:
|
|
def __init__(self, *a: object, **k: object) -> None:
|
|
pass
|
|
|
|
async def run(self, *a: object, **k: object) -> None:
|
|
raise RuntimeError('boom')
|
|
|
|
monkeypatch.setattr(shim, 'Agent', _FailingAgent)
|
|
|
|
from pydantic_ai.usage import RunUsage
|
|
|
|
class _Ctx:
|
|
model = TestModel()
|
|
usage = RunUsage()
|
|
|
|
out = asyncio.run(shim._compact_history(cast(RunContext[None], _Ctx()), msgs)) # pyright: ignore[reportPrivateUsage]
|
|
# On failure: keep just the tail (no head, no synthetic summary).
|
|
assert len(out) == shim.COMPACTION_KEEP_RECENT
|
|
|
|
|
|
def test_compact_history_preserves_prior_synthetic_on_fallback(monkeypatch: pytest.MonkeyPatch):
|
|
"""A second compaction round whose summary fails (or doesn't fit) must
|
|
keep the earlier round's `[compacted history]` block. Dropping it would
|
|
silently forget the entire run's prior work."""
|
|
import asyncio
|
|
|
|
from pydantic_ai.messages import ModelRequest, UserPromptPart
|
|
from pydantic_ai.models.test import TestModel
|
|
|
|
big = 'x' * 50_000
|
|
prior_synthetic = ModelRequest(parts=[UserPromptPart(content='[compacted history]\nearlier summary')])
|
|
msgs: list[ModelMessage] = [
|
|
prior_synthetic,
|
|
*(ModelRequest(parts=[UserPromptPart(content=f'm{i} {big}')]) for i in range(12)),
|
|
]
|
|
|
|
class _FailingAgent:
|
|
def __init__(self, *a: object, **k: object) -> None:
|
|
pass
|
|
|
|
async def run(self, *a: object, **k: object) -> None:
|
|
raise RuntimeError('boom')
|
|
|
|
monkeypatch.setattr(shim, 'Agent', _FailingAgent)
|
|
|
|
from pydantic_ai.usage import RunUsage
|
|
|
|
class _Ctx:
|
|
model = TestModel()
|
|
usage = RunUsage()
|
|
|
|
out = asyncio.run(shim._compact_history(cast(RunContext[None], _Ctx()), msgs)) # pyright: ignore[reportPrivateUsage]
|
|
# First element is the preserved prior synthetic; rest is the tail.
|
|
assert len(out) == 1 + shim.COMPACTION_KEEP_RECENT
|
|
assert out[0] is prior_synthetic
|
|
|
|
|
|
def test_task_surfaces_subagent_failure_as_tool_result(monkeypatch: pytest.MonkeyPatch):
|
|
import asyncio
|
|
|
|
from pydantic_ai.models.test import TestModel
|
|
|
|
class _FailingAgent:
|
|
def __init__(self, *a: object, **k: object) -> None:
|
|
pass
|
|
|
|
async def run(self, *a: object, **k: object) -> None:
|
|
raise RuntimeError('downstream model exploded')
|
|
|
|
monkeypatch.setattr(shim, 'Agent', _FailingAgent)
|
|
|
|
from pydantic_ai.usage import RunUsage
|
|
|
|
class _Ctx:
|
|
model = TestModel()
|
|
usage = RunUsage()
|
|
|
|
out = asyncio.run(shim.task(cast(RunContext[None], _Ctx()), 'x', 'y'))
|
|
assert out == 'error: sub-agent failed: downstream model exploded'
|
|
|
|
|
|
def test_task_isolates_attach_context_dedupe_set_from_parent(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
|
|
"""Sub-agents start with a fresh AGENTS.md seen-set, not the parent's."""
|
|
import asyncio
|
|
|
|
from pydantic_ai.messages import ModelResponse, TextPart
|
|
from pydantic_ai.models.function import AgentInfo, FunctionModel
|
|
from pydantic_ai.usage import RunUsage
|
|
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
(tmp_path / 'AGENTS.md').write_text('# parent-touched guidance', encoding='utf-8')
|
|
(tmp_path / 'f.txt').write_text('parent file', encoding='utf-8')
|
|
|
|
# Parent reads f.txt, which marks AGENTS.md as seen in the parent's set.
|
|
shared.reset_context_state()
|
|
parent_first = shared.attach_context('f.txt')
|
|
assert 'AGENTS.md' in parent_first
|
|
|
|
def _respond(_messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
|
|
return ModelResponse(parts=[TextPart('SUB: done')])
|
|
|
|
async def _stream(_messages: list[ModelMessage], _info: AgentInfo):
|
|
yield 'SUB: done'
|
|
|
|
class _Ctx:
|
|
model = FunctionModel(_respond, stream_function=_stream)
|
|
usage = RunUsage()
|
|
|
|
asyncio.run(shim.task(cast(RunContext[None], _Ctx()), 'sub', 'work'))
|
|
|
|
# After the sub-agent ran, the parent's seen set still includes AGENTS.md
|
|
# (sub-agent's reset only affected its own context branch). If we re-call
|
|
# attach_context in the *parent's* context, it should still be deduped.
|
|
assert shared.attach_context('f.txt') == ''
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# live tool-call stream-json emission (`_stream_events`)
|
|
# --------------------------------------------------------------------------- #
|
|
def test_stream_events_emits_tool_use_and_tool_result_lines():
|
|
"""`_stream_events` is the live emitter that turns pydantic-ai events into
|
|
Claude-shape stream-json on stdout — the surface gh-aw's log parser
|
|
reads. Drive it with synthetic events and assert the wire shape."""
|
|
import asyncio
|
|
|
|
from pydantic_ai.messages import (
|
|
FunctionToolCallEvent,
|
|
FunctionToolResultEvent,
|
|
ToolCallPart,
|
|
ToolReturnPart,
|
|
)
|
|
|
|
async def _events():
|
|
yield FunctionToolCallEvent(
|
|
part=ToolCallPart(tool_name='Bash', args={'command': 'ls'}, tool_call_id='c1'),
|
|
)
|
|
yield FunctionToolResultEvent(
|
|
part=ToolReturnPart(tool_name='Bash', content='exit=0\nfile1\nfile2', tool_call_id='c1'),
|
|
)
|
|
|
|
buf = io.StringIO()
|
|
with redirect_stdout(buf):
|
|
# `_stream_events` discards its `ctx` arg; passing None keeps the test
|
|
# free of pydantic-ai RunContext construction noise.
|
|
asyncio.run(shim._stream_events(cast(RunContext[None], None), _events())) # pyright: ignore[reportPrivateUsage]
|
|
|
|
lines = [json.loads(x) for x in buf.getvalue().splitlines() if x.strip()]
|
|
assert len(lines) == 2
|
|
|
|
use_block = lines[0]
|
|
assert use_block['type'] == 'assistant'
|
|
use_content = use_block['message']['content'][0]
|
|
assert use_content == {'type': 'tool_use', 'id': 'c1', 'name': 'Bash', 'input': {'command': 'ls'}}
|
|
|
|
result_block = lines[1]
|
|
assert result_block['type'] == 'user'
|
|
result_content = result_block['message']['content'][0]
|
|
assert result_content['type'] == 'tool_result'
|
|
assert result_content['tool_use_id'] == 'c1'
|
|
assert result_content['content'].startswith('exit=0')
|
|
|
|
|
|
def test_stream_events_truncates_long_tool_results():
|
|
"""Result content over `MAX_LIVE_TOOL_RESULT_CHARS` is truncated for the
|
|
stream-json view (the model's view is unaffected — this handler is
|
|
observation-only)."""
|
|
import asyncio
|
|
|
|
from pydantic_ai.messages import FunctionToolResultEvent, ToolReturnPart
|
|
|
|
huge = 'A' * 5000
|
|
|
|
async def _events():
|
|
yield FunctionToolResultEvent(
|
|
part=ToolReturnPart(tool_name='Bash', content=huge, tool_call_id='c1'),
|
|
)
|
|
|
|
buf = io.StringIO()
|
|
with redirect_stdout(buf):
|
|
asyncio.run(shim._stream_events(cast(RunContext[None], None), _events())) # pyright: ignore[reportPrivateUsage]
|
|
|
|
line = json.loads(buf.getvalue().strip())
|
|
emitted = line['message']['content'][0]['content']
|
|
assert len(emitted) < len(huge)
|
|
assert '…[+' in emitted and 'chars]' in emitted
|
|
|
|
|
|
def test_stream_events_tags_retry_prompt_as_error():
|
|
"""`ToolResultEvent.part` is `ToolReturnPart | RetryPromptPart`. A retry
|
|
means tool-call validation failed — gh-aw must see `is_error=True` so it
|
|
doesn't read it as a successful result."""
|
|
import asyncio
|
|
|
|
from pydantic_ai.messages import FunctionToolResultEvent, RetryPromptPart, ToolReturnPart
|
|
|
|
async def _events():
|
|
yield FunctionToolResultEvent(
|
|
part=ToolReturnPart(tool_name='Bash', content='ok', tool_call_id='c1'),
|
|
)
|
|
yield FunctionToolResultEvent(
|
|
part=RetryPromptPart(content='Validation failed', tool_name='Bash', tool_call_id='c2'),
|
|
)
|
|
|
|
buf = io.StringIO()
|
|
with redirect_stdout(buf):
|
|
asyncio.run(shim._stream_events(cast(RunContext[None], None), _events())) # pyright: ignore[reportPrivateUsage]
|
|
|
|
lines = [json.loads(x) for x in buf.getvalue().splitlines() if x.strip()]
|
|
assert lines[0]['message']['content'][0]['is_error'] is False
|
|
assert lines[1]['message']['content'][0]['is_error'] is True
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# disk / IO failure paths for the file tools
|
|
# --------------------------------------------------------------------------- #
|
|
def test_read_missing_file_returns_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
out = asyncio.run(pkg.read_file(str(tmp_path / 'nope.txt')))
|
|
assert out.startswith('error:')
|
|
|
|
|
|
def test_edit_missing_file_returns_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
out = asyncio.run(pkg.edit_file(str(tmp_path / 'missing.txt'), 'old', 'new'))
|
|
assert out.startswith('error:')
|
|
|
|
|
|
def test_multi_edit_missing_file_returns_error(tmp_path: Path):
|
|
out = pkg.multi_edit(str(tmp_path / 'absent.txt'), [{'old_string': 'a', 'new_string': 'b'}])
|
|
assert out.startswith('error:')
|
|
|
|
|
|
def test_list_dir_missing_path_returns_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
out = asyncio.run(pkg.list_dir(str(tmp_path / 'nope')))
|
|
assert out.startswith('error:')
|
|
|
|
|
|
def test_write_to_existing_parent_succeeds_otherwise_creates(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
# The harness `write_file` requires an existing parent; the Write adapter
|
|
# calls `create_directory` first, so nested writes still succeed.
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
nested = tmp_path / 'a' / 'b' / 'c.txt'
|
|
assert 'Wrote' in asyncio.run(pkg.write_file(str(nested), 'ok'))
|
|
assert nested.read_text(encoding='utf-8') == 'ok'
|
|
|
|
|
|
def test_write_under_a_file_path_returns_error_not_crash(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
# When a parent segment is an existing *file*, the adapter's `create_directory`
|
|
# makes `Path.mkdir(exist_ok=True)` raise a bare `FileExistsError` -- which the
|
|
# harness does NOT convert to `ModelRetry`. It must come back as an `error:`
|
|
# string, not escape and abort the whole agent run.
|
|
monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path))
|
|
(tmp_path / 'afile').write_text('x', encoding='utf-8')
|
|
out = asyncio.run(pkg.write_file(str(tmp_path / 'afile' / 'inner.txt'), 'data'))
|
|
assert out.startswith('error:')
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# safe-outputs log diagnostic
|
|
# --------------------------------------------------------------------------- #
|
|
def test_log_safe_outputs_state_reports_entry_count(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: LogCaptureFixture
|
|
):
|
|
safe_path = tmp_path / 'safe-outputs.jsonl'
|
|
safe_path.write_text('{"a": 1}\n{"b": 2}\n\n', encoding='utf-8')
|
|
monkeypatch.setenv('GH_AW_SAFE_OUTPUTS', str(safe_path))
|
|
with caplog.at_level('INFO', logger='pydantic_ai_gh_aw_shim'):
|
|
shim.log_safe_outputs_state()
|
|
joined = '\n'.join(caplog.messages)
|
|
assert 'entries=2' in joined and 'bytes=' in joined
|
|
|
|
|
|
def test_log_safe_outputs_state_handles_missing_env(monkeypatch: pytest.MonkeyPatch, caplog: LogCaptureFixture):
|
|
monkeypatch.delenv('GH_AW_SAFE_OUTPUTS', raising=False)
|
|
with caplog.at_level('INFO', logger='pydantic_ai_gh_aw_shim'):
|
|
shim.log_safe_outputs_state()
|
|
assert any('GH_AW_SAFE_OUTPUTS not set' in m for m in caplog.messages)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# model resolution (proxy semantics — unchanged)
|
|
# --------------------------------------------------------------------------- #
|
|
def test_model_defaults_to_claude_sonnet_4_6(monkeypatch: pytest.MonkeyPatch):
|
|
for v in ('ANTHROPIC_MODEL', 'ANTHROPIC_BASE_URL'):
|
|
monkeypatch.delenv(v, raising=False)
|
|
model, label = shim.build_model(shim.parse_args(['--print']))
|
|
assert label == 'anthropic:claude-sonnet-4-6'
|
|
assert model.__class__.__name__ == 'AnthropicModel'
|
|
|
|
|
|
def test_model_argv_flag_wins(monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setenv('ANTHROPIC_MODEL', 'from-env')
|
|
model, label = shim.build_model(shim.parse_args(['--model', 'from-argv']))
|
|
assert label == 'anthropic:from-argv'
|
|
assert model.__class__.__name__ == 'AnthropicModel'
|
|
|
|
|
|
def test_model_anthropic_env_falls_back_when_no_argv(monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.delenv('ANTHROPIC_BASE_URL', raising=False)
|
|
# gh-aw v0.74+ sets `ANTHROPIC_MODEL` (the Anthropic SDK standard) from
|
|
# the workflow's `engine.model:` field. The runner picks it up because
|
|
# the Claude-Code CLI does the same.
|
|
monkeypatch.setenv('ANTHROPIC_MODEL', 'MiniMax-M2.7-Highspeed')
|
|
monkeypatch.setenv('ANTHROPIC_AUTH_TOKEN', 'placeholder')
|
|
model, label = shim.build_model(shim.parse_args(['--print']))
|
|
assert label == 'anthropic:MiniMax-M2.7-Highspeed'
|
|
assert model.__class__.__name__ == 'AnthropicModel'
|
|
|
|
|
|
def test_build_model_applies_llm_timeout_and_retries(monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.delenv('ANTHROPIC_BASE_URL', raising=False)
|
|
monkeypatch.delenv('ANTHROPIC_MODEL', raising=False)
|
|
model, _ = shim.build_model(shim.parse_args(['--print']))
|
|
# The underlying AsyncAnthropic client should carry our timeout + retries.
|
|
client = model.provider.client # type: ignore[attr-defined]
|
|
assert client.timeout == shim._LLM_TIMEOUT # pyright: ignore[reportPrivateUsage]
|
|
assert client.max_retries == shim._LLM_MAX_RETRIES # pyright: ignore[reportPrivateUsage]
|
|
|
|
|
|
def test_run_with_timeout_emits_error_on_global_timeout(monkeypatch: pytest.MonkeyPatch):
|
|
async def _hang(*_a: object, **_kw: object) -> int:
|
|
await asyncio.sleep(9999)
|
|
return 0
|
|
|
|
monkeypatch.setattr(shim, 'run', _hang)
|
|
monkeypatch.setattr(shim, 'RUN_TIMEOUT_SECS', 0.01)
|
|
buf = io.StringIO()
|
|
with redirect_stdout(buf):
|
|
rc = asyncio.run(
|
|
shim._run_with_timeout( # pyright: ignore[reportPrivateUsage]
|
|
'p', cast(_Model[Any], object()), 'lbl', cast(AbstractToolset[object], object()), [], 'sess-test'
|
|
)
|
|
)
|
|
assert rc == 1
|
|
obj = json.loads(buf.getvalue().strip())
|
|
assert obj['type'] == 'result' and obj['is_error'] is True
|
|
assert 'timed out' in obj['result']
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# MCP translation & allow-list filtering
|
|
# --------------------------------------------------------------------------- #
|
|
def test_mcp_missing_config_degrades_gracefully():
|
|
assert shim.build_mcp_servers(shim.Args(mcp_config='/no/such/file.json')) == []
|
|
|
|
|
|
def _mcp_cfg(tmp_path: Path) -> Path:
|
|
cfg = tmp_path / 'mcp.json'
|
|
cfg.write_text(
|
|
json.dumps(
|
|
{
|
|
'mcpServers': {
|
|
'github': {'command': 'docker', 'args': ['run'], 'env': {'X': '1'}},
|
|
'safeoutputs': {
|
|
'type': 'http',
|
|
'url': 'http://host.docker.internal:1234',
|
|
'headers': {'Authorization': 'k'},
|
|
},
|
|
}
|
|
}
|
|
),
|
|
encoding='utf-8',
|
|
)
|
|
return cfg
|
|
|
|
|
|
def test_mcp_translates_stdio_and_http_unfiltered(tmp_path: Path):
|
|
# `load_mcp_toolsets` wraps each server in a `PrefixedToolset`; the shim
|
|
# then re-prefixes to Claude Code's wire format.
|
|
servers = shim.build_mcp_servers(shim.Args(mcp_config=str(_mcp_cfg(tmp_path))))
|
|
assert len(servers) == 2
|
|
assert {s.__class__.__name__ for s in servers} == {'PrefixedToolset'}
|
|
|
|
|
|
def test_mcp_tools_use_claude_code_wire_format(tmp_path: Path):
|
|
"""The re-prefix step makes the model-visible tool name exactly equal to
|
|
gh-aw's `mcp__<server>__<tool>` allow-list entry — the same name Claude
|
|
Code uses on the wire and that Claude was trained to call. With matching
|
|
names the allow-list filter becomes a literal containment check."""
|
|
from pydantic_ai.toolsets import PrefixedToolset
|
|
|
|
servers = shim.build_mcp_servers(shim.Args(mcp_config=str(_mcp_cfg(tmp_path))))
|
|
prefixed = [s for s in servers if isinstance(s, PrefixedToolset)]
|
|
assert len(prefixed) == 2
|
|
# `PrefixedToolset` inserts a literal `_` between prefix and tool name;
|
|
# combined with our trailing-underscore prefix this yields the canonical
|
|
# `mcp__<server>__<tool>` double-underscore shape.
|
|
assert {s.prefix for s in prefixed} == {'mcp__github_', 'mcp__safeoutputs_'}
|
|
|
|
|
|
def test_mcp_wrapped_in_filter_when_allowlist_present(tmp_path: Path):
|
|
servers = shim.build_mcp_servers(
|
|
shim.Args(mcp_config=str(_mcp_cfg(tmp_path)), allowed_tools=frozenset({'mcp__safeoutputs'}))
|
|
)
|
|
assert len(servers) == 2
|
|
assert {s.__class__.__name__ for s in servers} == {'FilteredToolset'}
|
|
|
|
|
|
def test_mcp_allow_predicate_server_wildcard_vs_specific():
|
|
from pydantic_ai.tools import ToolDefinition
|
|
|
|
# The model-visible tool name is Claude Code's wire form
|
|
# `mcp__<server>__<tool>` (see `_apply_claude_mcp_prefix`), identical to
|
|
# gh-aw's allow-list entries — so the predicate is a literal containment
|
|
# check, with a wildcard for whole-server allows.
|
|
|
|
# whole-server allow
|
|
pred = shim._mcp_tool_allowed('safeoutputs', frozenset({'mcp__safeoutputs'})) # pyright: ignore[reportPrivateUsage]
|
|
assert pred(cast(RunContext[None], None), ToolDefinition(name='mcp__safeoutputs__create_issue')) is True
|
|
assert (
|
|
pred(cast(RunContext[None], None), ToolDefinition(name='mcp__safeoutputs__create_pull_request_review_comment'))
|
|
is True
|
|
)
|
|
|
|
# specific-tool allow only
|
|
pred = shim._mcp_tool_allowed('github', frozenset({'mcp__github__get_me'})) # pyright: ignore[reportPrivateUsage]
|
|
assert pred(cast(RunContext[None], None), ToolDefinition(name='mcp__github__get_me')) is True
|
|
assert pred(cast(RunContext[None], None), ToolDefinition(name='mcp__github__delete_repo')) is False
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# stream-json schema & structured-error guarantee
|
|
# --------------------------------------------------------------------------- #
|
|
def test_emit_result_matches_claude_stream_json_schema():
|
|
buf = io.StringIO()
|
|
with redirect_stdout(buf):
|
|
shim.emit_result('answer', usage=None, session_id='run-1')
|
|
obj = json.loads(buf.getvalue().strip())
|
|
assert obj['type'] == 'result'
|
|
assert obj['subtype'] == 'success'
|
|
assert obj['is_error'] is False
|
|
assert obj['result'] == 'answer'
|
|
for k in ('input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens'):
|
|
assert k in obj['usage']
|
|
|
|
|
|
def test_emit_result_passes_through_turns_and_duration():
|
|
buf = io.StringIO()
|
|
with redirect_stdout(buf):
|
|
shim.emit_result('x', usage=None, session_id='s', num_turns=3, duration_ms=1234)
|
|
obj = json.loads(buf.getvalue().strip())
|
|
assert obj['num_turns'] == 3 and obj['duration_ms'] == 1234
|
|
|
|
|
|
def test_emit_result_error_subtype():
|
|
buf = io.StringIO()
|
|
with redirect_stdout(buf):
|
|
shim.emit_result('boom', usage=None, session_id='run-1', is_error=True)
|
|
obj = json.loads(buf.getvalue().strip())
|
|
assert obj['subtype'] == 'error' and obj['is_error'] is True
|
|
|
|
|
|
def test_emit_result_reads_usage_attributes():
|
|
class U:
|
|
input_tokens = 22
|
|
output_tokens = 292
|
|
cache_write_tokens = 5
|
|
cache_read_tokens = 7
|
|
|
|
from pydantic_ai.usage import RunUsage as _RunUsage
|
|
|
|
buf = io.StringIO()
|
|
with redirect_stdout(buf):
|
|
shim.emit_result('x', usage=cast(_RunUsage, U()), session_id='s')
|
|
usage = json.loads(buf.getvalue().strip())['usage']
|
|
assert usage['input_tokens'] == 22
|
|
assert usage['output_tokens'] == 292
|
|
assert usage['cache_creation_input_tokens'] == 5 # mapped from cache_write_tokens
|
|
assert usage['cache_read_input_tokens'] == 7
|
|
|
|
|
|
def test_main_emits_structured_error_on_empty_prompt(monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setattr(sys, 'argv', ['pydantic-ai-runner', '--print'])
|
|
monkeypatch.delenv('GH_AW_PROMPT', raising=False)
|
|
buf = io.StringIO()
|
|
with redirect_stdout(buf):
|
|
rc = shim.main()
|
|
assert rc == 1
|
|
obj = json.loads(buf.getvalue().strip())
|
|
assert obj['type'] == 'result' and obj['is_error'] is True
|
|
|
|
|
|
def test_main_emits_structured_error_on_startup_failure(monkeypatch: pytest.MonkeyPatch):
|
|
# A failure *before* the agent loop (e.g. model build) must still produce a
|
|
# parseable stream-json result, never an opaque "no entries" run.
|
|
def boom(_args: object) -> None:
|
|
raise RuntimeError('kaboom')
|
|
|
|
monkeypatch.setattr(shim, 'build_model', boom)
|
|
monkeypatch.setattr(sys, 'argv', ['pydantic-ai-runner', '--print', 'hello'])
|
|
buf = io.StringIO()
|
|
with redirect_stdout(buf):
|
|
rc = shim.main()
|
|
assert rc == 1
|
|
obj = json.loads(buf.getvalue().strip())
|
|
assert obj['is_error'] is True
|
|
assert 'shim startup failed' in obj['result']
|
|
assert 'kaboom' in obj['result']
|
|
|
|
|
|
def test_main_emits_structured_error_on_argparse_rejection(monkeypatch: pytest.MonkeyPatch):
|
|
# argparse `action='store_true'` raises `SystemExit(2)` on
|
|
# `--print=true`; gh-aw still needs a structured `result` line.
|
|
monkeypatch.setattr(sys, 'argv', ['pydantic-ai-runner', '--print=true', 'hi'])
|
|
buf = io.StringIO()
|
|
with redirect_stdout(buf):
|
|
rc = shim.main()
|
|
assert rc == 1
|
|
obj = json.loads(buf.getvalue().strip())
|
|
assert obj['type'] == 'result' and obj['is_error'] is True
|
|
assert 'shim startup failed' in obj['result']
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not os.environ.get('GH_AW_SHIM_LIVE_API_KEY'),
|
|
reason='set GH_AW_SHIM_LIVE_API_KEY/_BASE_URL/_MODEL to run the live test',
|
|
)
|
|
def test_live_anthropic_compatible_endpoint(monkeypatch: pytest.MonkeyPatch):
|
|
"""End-to-end against a real Anthropic-shape endpoint (api.anthropic.com,
|
|
MiniMax's /anthropic, etc.). Verifies the shim+endpoint integration —
|
|
not the model's instruction-following.
|
|
"""
|
|
monkeypatch.setenv('ANTHROPIC_API_KEY', os.environ['GH_AW_SHIM_LIVE_API_KEY'])
|
|
monkeypatch.setenv(
|
|
'ANTHROPIC_BASE_URL',
|
|
os.environ.get('GH_AW_SHIM_LIVE_BASE_URL', 'https://api.anthropic.com'),
|
|
)
|
|
model = os.environ.get('GH_AW_SHIM_LIVE_MODEL', 'claude-sonnet-4-6')
|
|
argv = list(GHAW_ARGV)
|
|
i = argv.index('--mcp-config')
|
|
del argv[i : i + 2] # no MCP gateway outside a gh-aw run
|
|
argv += ['--model', model, 'Say hi.']
|
|
monkeypatch.setattr(sys, 'argv', ['pydantic-ai-runner', *argv])
|
|
buf = io.StringIO()
|
|
with redirect_stdout(buf):
|
|
rc = shim.main()
|
|
assert rc == 0
|
|
lines = [json.loads(x) for x in buf.getvalue().splitlines() if x.strip()]
|
|
result = next(x for x in lines if x['type'] == 'result')
|
|
assert result['is_error'] is False
|
|
assert result['result']
|
|
# `input_tokens > 0` proves the prompt round-tripped; `output_tokens > 0`
|
|
# proves the model actually responded.
|
|
assert result['usage']['input_tokens'] > 0
|
|
assert result['usage']['output_tokens'] > 0
|