Skip to content

Commit 5a0e101

Browse files
authored
test(messages): add live Claude Code CLI smoke test against /v1/messages (#5986)
## What Adds a smoke test that drives the **real** `@anthropic-ai/claude-code` CLI against a local OGX server, verifying the Messages API works end-to-end with the actual client. The CLI emits its full system prompt, tool definitions, and an inline `system`-role message — all of which OGX must accept and dispatch to the backing provider. This is the upstream automated coverage for the Claude Code ↔ `/v1/messages` integration (RHAIENG-5376). ## Why live, not replay The recording/replay system keys recordings on a SHA256 of the request body. The Claude Code CLI bakes the **working directory, the date, and the platform** into every request (its system prompt includes all three), so those hashes are **not reproducible** across runs or machines: - random `tmp_path` cwd → different hash every run - date changes daily → recordings rot - recorded on macOS, CI runs Linux → platform string differs So hash-based replay is fundamentally unworkable for the real CLI. The test runs **live against Ollama** (native passthrough) instead, in a dedicated workflow that provisions Ollama. OpenAI/translation is intentionally excluded to avoid live OpenAI calls on every PR. ## What it asserts Integration health, not answer quality. A small local model driving the Claude Code harness can't be relied on to produce a specific reply, but the test confirms via `--output-format json` that: - the CLI exits 0, - the session completed with no API error (`is_error: false`, `subtype: success`), - the request actually reached the backing model (`modelUsage` contains the model id). A regression such as a rejected `system`-role message (cf. #5982) surfaces as a CLI API error and is caught here. The test self-skips unless the `claude` binary is on PATH. > Depends on #5982's fix (merged), which taught `/v1/messages` to accept inline `system`-role messages. ## Test plan - Ran live locally against Ollama (`ollama/llama3.2:3b-instruct-fp16`, native passthrough): **passed**. ``` ./scripts/integration-tests.sh --stack-config server:ci-tests \ --setup ollama --suite messages --inference-mode live \ --pattern test_claude_code_cli_smoke ``` - `pre-commit run` — ruff, mypy, generate-ci-docs, SHA-pinned-actions checks pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Charlie Doern <cdoern@redhat.com>
1 parent 4af4d0a commit 5a0e101

3 files changed

Lines changed: 171 additions & 0 deletions

File tree

.github/workflows/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ OGX uses GitHub Actions for Continuous Integration (CI). Below is a table detail
1717
| Integration Auth Tests | [integration-auth-tests.yml](integration-auth-tests.yml) | Run the integration test suite with Kubernetes authentication |
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 |
20+
| 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) |
2021
| Integration Tests (Replay) | [integration-tests.yml](integration-tests.yml) | Run the integration test suites from tests/integration in replay mode |
2122
| Vector IO Integration Tests | [integration-vector-io-tests.yml](integration-vector-io-tests.yml) | Run the integration test suite with various VectorIO providers |
2223
| OpenAPI Generator SDK Validation | [openapi-generator-validation.yml](openapi-generator-validation.yml) | Validate OpenAPI Generator SDK generation |
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
name: Messages API - Claude Code CLI Smoke Test
2+
3+
run-name: Drive the real Claude Code CLI 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-cli.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 CLI bakes the cwd, date, and platform into
40+
# every request body, so its traffic cannot be recorded/replayed; this test
41+
# runs live against Ollama instead. Bumping only changes the client behavior
42+
# under test, not any committed recordings.
43+
CLAUDE_CODE_CLI_VERSION: '2.1.159'
44+
45+
jobs:
46+
claude-code-cli-smoke:
47+
name: Claude Code CLI smoke (ollama, live)
48+
runs-on: ubuntu-latest
49+
# CPU-only runners generate slowly; the live CLI session can take several
50+
# minutes against the Ollama model. Keep ample headroom over the test's own
51+
# 600s subprocess 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
59+
# real CLI traffic reaches a real model.
60+
- name: Setup test environment
61+
uses: ./.github/actions/setup-test-environment
62+
with:
63+
python-version: '3.12'
64+
client-version: 'latest'
65+
setup: 'ollama'
66+
suite: 'messages'
67+
inference-mode: 'live'
68+
69+
- name: Install Claude Code CLI
70+
run: |
71+
curl -fsSL https://claude.ai/install.sh | bash -s -- "${CLAUDE_CODE_CLI_VERSION}"
72+
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
73+
"$HOME/.local/bin/claude" --version
74+
75+
- name: Run Claude Code CLI smoke test
76+
uses: ./.github/actions/run-and-record-tests
77+
with:
78+
stack-config: 'server:ci-tests'
79+
setup: 'ollama'
80+
suite: 'messages'
81+
inference-mode: 'live'
82+
pattern: 'test_claude_code_cli_smoke'
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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 Code CLI against the OGX Messages API.
8+
9+
Points ANTHROPIC_BASE_URL at a local OGX server and runs a single prompt
10+
through the upstream @anthropic-ai/claude-code CLI. This exercises the real
11+
client end-to-end against /v1/messages: the CLI emits its full system prompt,
12+
tool definitions, and an inline system-role message, all of which the server
13+
must accept and dispatch to the backing provider before the model's reply
14+
comes back.
15+
16+
This runs LIVE against a real backend, not in replay mode. The CLI bakes the
17+
working directory, date, and platform into every request body, so the
18+
request-body hashes the recording system keys on are not reproducible across
19+
runs or machines; recording/replay is therefore not viable for the real CLI.
20+
21+
The test self-skips unless the `claude` binary is on PATH.
22+
"""
23+
24+
import json
25+
import os
26+
import shutil
27+
import subprocess
28+
29+
import pytest
30+
31+
CLAUDE_CLI = shutil.which("claude")
32+
33+
pytestmark = pytest.mark.skipif(
34+
CLAUDE_CLI is None,
35+
reason="claude-code CLI not installed; install @anthropic-ai/claude-code to run",
36+
)
37+
38+
39+
def test_claude_code_cli_smoke(messages_base_url, text_model_id, tmp_path):
40+
"""Claude Code CLI completes a request against /v1/messages without error.
41+
42+
The smoke signal is integration health, not answer quality: the CLI sends
43+
its full agentic payload (system prompt, tools, inline system message) to
44+
OGX, OGX routes it to the backing model, and the session completes with no
45+
API error. We deliberately do not assert on the model's text output -- a
46+
small local model driving the Claude Code harness cannot be relied on to
47+
produce a specific answer, but a regression like a rejected system-role
48+
message (which would surface as an API error) is caught here.
49+
"""
50+
prompt = "What is the capital of France? Reply with only the city name and nothing else."
51+
52+
env = {
53+
**os.environ,
54+
"ANTHROPIC_BASE_URL": str(messages_base_url).rstrip("/"),
55+
"ANTHROPIC_API_KEY": "dummy",
56+
"ANTHROPIC_MODEL": text_model_id,
57+
}
58+
59+
result = subprocess.run(
60+
[
61+
CLAUDE_CLI,
62+
"--print",
63+
"--output-format",
64+
"json",
65+
"--dangerously-skip-permissions",
66+
prompt,
67+
],
68+
cwd=tmp_path,
69+
env=env,
70+
capture_output=True,
71+
text=True,
72+
# Generous: the real CLI makes several large-context calls and a small
73+
# model on a CPU-only CI runner generates slowly (tens of seconds each).
74+
timeout=600,
75+
)
76+
77+
assert result.returncode == 0, (
78+
f"Failed to run Claude Code CLI: exit {result.returncode}\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}"
79+
)
80+
81+
data = json.loads(result.stdout)
82+
assert data.get("is_error") is False and data.get("subtype") == "success", (
83+
f"Claude Code CLI reported an error talking to /v1/messages: {json.dumps(data, indent=2)}"
84+
)
85+
# Confirm the request actually reached the backing model through /v1/messages.
86+
assert text_model_id in data.get("modelUsage", {}), (
87+
f"Expected model {text_model_id} in CLI modelUsage; got: {json.dumps(data.get('modelUsage', {}), indent=2)}"
88+
)

0 commit comments

Comments
 (0)