Skip to content

Commit ca5c4d0

Browse files
committed
test(messages): add live Claude Agent SDK smoke test against /v1/messages
Drive the upstream claude-agent-sdk Python package against a local OGX server so the SDK's session machinery exercises the Anthropic Messages path end to end. The SDK does not speak HTTP itself; it spawns the Claude Code CLI as a subprocess, so the test asserts on the parsed ResultMessage (successful subtype, no error, and the backing model present in model_usage) rather than on model text, which a small local model driving the Claude Code harness cannot be relied on to produce. The SDK drives the real CLI, which bakes the working directory, date, and platform into every request body, so request-body hashes are not reproducible and record/replay is not viable. The test therefore runs live against Ollama and self-skips unless both the claude-agent-sdk package and the claude binary are available. Add claude-agent-sdk to the test dependency group and a sibling CI workflow that provisions Ollama, installs the CLI, and runs the smoke test live. Signed-off-by: Charlie Doern <cdoern@redhat.com>
1 parent 5a0e101 commit ca5c4d0

5 files changed

Lines changed: 254 additions & 42 deletions

File tree

.github/workflows/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ OGX uses GitHub Actions for Continuous Integration (CI). Below is a table detail
1818
| Integration Responses, Conversations & Prompts Auth Tests | [integration-responses-conversations-auth-tests.yml](integration-responses-conversations-auth-tests.yml) | Run responses, conversations, and prompts auth tests with Kubernetes authentication |
1919
| SqlStore Integration Tests | [integration-sql-store-tests.yml](integration-sql-store-tests.yml) | Run the integration test suite with SqlStore |
2020
| Messages API - Claude Code CLI Smoke Test | [integration-tests-messages-cli.yml](integration-tests-messages-cli.yml) | Drive the real Claude Code CLI against /v1/messages (live, Ollama) |
21+
| Messages API - Claude Agent SDK Smoke Test | [integration-tests-messages-sdk.yml](integration-tests-messages-sdk.yml) | Drive the Claude Agent SDK against /v1/messages (live, Ollama) |
2122
| Integration Tests (Replay) | [integration-tests.yml](integration-tests.yml) | Run the integration test suites from tests/integration in replay mode |
2223
| Vector IO Integration Tests | [integration-vector-io-tests.yml](integration-vector-io-tests.yml) | Run the integration test suite with various VectorIO providers |
2324
| OpenAPI Generator SDK Validation | [openapi-generator-validation.yml](openapi-generator-validation.yml) | Validate OpenAPI Generator SDK generation |
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
name: Messages API - Claude Agent SDK Smoke Test
2+
3+
run-name: Drive the Claude Agent SDK against /v1/messages (live, Ollama)
4+
5+
on:
6+
push:
7+
branches:
8+
- main
9+
- 'release-[0-9]+.[0-9]+.x'
10+
pull_request:
11+
branches:
12+
- main
13+
- 'release-[0-9]+.[0-9]+.x'
14+
types: [opened, synchronize, reopened]
15+
paths:
16+
- 'src/ogx/providers/inline/messages/**'
17+
- 'src/ogx_api/**'
18+
- 'tests/integration/messages/**'
19+
- 'uv.lock'
20+
- 'pyproject.toml'
21+
- '.github/workflows/integration-tests-messages-sdk.yml'
22+
- '.github/actions/setup-test-environment/action.yml'
23+
- '.github/actions/run-and-record-tests/action.yml'
24+
- 'scripts/integration-tests.sh'
25+
merge_group:
26+
branches:
27+
- main
28+
- 'release-[0-9]+.[0-9]+.x'
29+
workflow_dispatch:
30+
31+
concurrency:
32+
group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.run_id || github.ref }}
33+
cancel-in-progress: true
34+
35+
permissions:
36+
contents: read
37+
38+
env:
39+
# Pinned for reproducibility. The SDK spawns this CLI, which bakes the cwd,
40+
# date, and platform into every request body, so its traffic cannot be
41+
# recorded/replayed; this test runs live against Ollama instead. The
42+
# claude-agent-sdk Python package itself is pinned via uv.lock.
43+
CLAUDE_CODE_CLI_VERSION: '2.1.159'
44+
45+
jobs:
46+
claude-agent-sdk-smoke:
47+
name: Claude Agent SDK smoke (ollama, live)
48+
runs-on: ubuntu-latest
49+
# CPU-only runners generate slowly; the live SDK session can take several
50+
# minutes against the Ollama model. Keep ample headroom over the test's own
51+
# 600s session timeout plus environment setup.
52+
timeout-minutes: 30
53+
54+
steps:
55+
- name: Checkout repository
56+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
57+
58+
# Live mode (not replay) so the Ollama backend is provisioned and the SDK
59+
# session reaches a real model. setup-runner's `uv sync --all-groups`
60+
# installs the claude-agent-sdk Python package from the test group.
61+
- name: Setup test environment
62+
uses: ./.github/actions/setup-test-environment
63+
with:
64+
python-version: '3.12'
65+
client-version: 'latest'
66+
setup: 'ollama'
67+
suite: 'messages'
68+
inference-mode: 'live'
69+
70+
# The SDK requires the Claude Code CLI at runtime: it spawns the binary as
71+
# a subprocess and parses its streamed output.
72+
- name: Install Claude Code CLI
73+
run: |
74+
curl -fsSL https://claude.ai/install.sh | bash -s -- "${CLAUDE_CODE_CLI_VERSION}"
75+
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
76+
"$HOME/.local/bin/claude" --version
77+
78+
- name: Run Claude Agent SDK smoke test
79+
uses: ./.github/actions/run-and-record-tests
80+
with:
81+
stack-config: 'server:ci-tests'
82+
setup: 'ollama'
83+
suite: 'messages'
84+
inference-mode: 'live'
85+
pattern: 'test_claude_agent_sdk_smoke'

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ test = [
216216
{include-group = "test-common"},
217217
"autoevals",
218218
"chromadb>=1.0.15",
219+
"claude-agent-sdk>=0.2.0", # Drives the real Claude Code CLI in the Messages API SDK smoke test
219220
"datasets>=4.0.0",
220221
"elasticsearch>=8.16.0, <9.0.0",
221222
"google-genai>=2.3.0",
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Copyright (c) The OGX Contributors.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the terms described in the LICENSE file in
5+
# the root directory of this source tree.
6+
7+
"""Smoke test: drive the Claude Agent SDK against the OGX Messages API.
8+
9+
Points the SDK at a local OGX server and runs a single prompt through the
10+
upstream `claude-agent-sdk` Python package (github.com/anthropics/
11+
claude-agent-sdk-python). The SDK does not speak HTTP itself: it spawns the
12+
Claude Code CLI as a subprocess and parses its streamed session output. This
13+
exercises a different client surface than the CLI smoke test -- the SDK's
14+
session machinery (message streaming, ResultMessage parsing) on top of the same
15+
end-to-end path through /v1/messages: full system prompt, tool definitions, and
16+
an inline system-role message that the server must accept and dispatch to the
17+
backing provider.
18+
19+
This runs LIVE against a real backend, not in replay mode. The SDK drives the
20+
real CLI, which bakes the working directory, date, and platform into every
21+
request body, so the request-body hashes the recording system keys on are not
22+
reproducible across runs or machines; recording/replay is therefore not viable.
23+
24+
The test self-skips unless both the `claude-agent-sdk` package and the `claude`
25+
binary are available, since the SDK requires the CLI at runtime.
26+
"""
27+
28+
import asyncio
29+
import importlib.util
30+
import shutil
31+
32+
import pytest
33+
34+
CLAUDE_CLI = shutil.which("claude")
35+
HAS_SDK = importlib.util.find_spec("claude_agent_sdk") is not None
36+
37+
pytestmark = pytest.mark.skipif(
38+
CLAUDE_CLI is None or not HAS_SDK,
39+
reason="claude-agent-sdk and the claude CLI must both be installed to run",
40+
)
41+
42+
43+
def _run_query(prompt: str, base_url: str, model: str) -> list:
44+
"""Run a single Agent SDK query() to completion and return all messages."""
45+
from claude_agent_sdk import ClaudeAgentOptions, query
46+
47+
options = ClaudeAgentOptions(
48+
model=model,
49+
# Passed to the spawned CLI subprocess so it reaches OGX instead of
50+
# api.anthropic.com.
51+
env={
52+
"ANTHROPIC_BASE_URL": base_url,
53+
"ANTHROPIC_API_KEY": "dummy",
54+
"ANTHROPIC_MODEL": model,
55+
},
56+
# The prompt is pure Q&A and triggers no tools, but bypass permissions
57+
# so the non-interactive session can never stall on a permission prompt.
58+
permission_mode="bypassPermissions",
59+
)
60+
61+
messages: list = []
62+
63+
async def _collect() -> None:
64+
async for message in query(prompt=prompt, options=options):
65+
messages.append(message)
66+
67+
# Generous: the SDK drives the real CLI, which makes several large-context
68+
# calls, and a small model on a CPU-only CI runner generates slowly (tens of
69+
# seconds each). Bound it so a hung session fails loudly instead of riding
70+
# the job timeout.
71+
asyncio.run(asyncio.wait_for(_collect(), timeout=600))
72+
return messages
73+
74+
75+
def test_claude_agent_sdk_smoke(messages_base_url, text_model_id):
76+
"""Claude Agent SDK completes a session against /v1/messages without error.
77+
78+
The smoke signal is integration health, not answer quality: the SDK drives a
79+
full agentic session (system prompt, tools, inline system message) against
80+
OGX, OGX routes it to the backing model, and the session terminates with a
81+
successful ResultMessage. We deliberately do not assert on the model's text
82+
output -- a small local model driving the Claude Code harness cannot be
83+
relied on to produce a specific answer, but a regression like a rejected
84+
system-role message (which would surface as a session error) is caught here.
85+
"""
86+
from claude_agent_sdk import ResultMessage
87+
88+
prompt = "What is the capital of France? Reply with only the city name and nothing else."
89+
base_url = str(messages_base_url).rstrip("/")
90+
91+
messages = _run_query(prompt, base_url, text_model_id)
92+
93+
results = [m for m in messages if isinstance(m, ResultMessage)]
94+
assert results, f"Agent SDK session produced no ResultMessage; got: {[type(m).__name__ for m in messages]}"
95+
96+
result = results[-1]
97+
assert result.subtype == "success" and not result.is_error, (
98+
f"Agent SDK session reported an error talking to /v1/messages: "
99+
f"subtype={result.subtype} is_error={result.is_error} errors={result.errors}"
100+
)
101+
# Confirm the request actually reached the backing model through /v1/messages.
102+
model_usage = result.model_usage or {}
103+
assert text_model_id in model_usage, (
104+
f"Expected model {text_model_id} in ResultMessage.model_usage; got: {list(model_usage)}"
105+
)

0 commit comments

Comments
 (0)