Files
optimclaw/tests/support/mock_openai_server.rs
T
f05896fe6a Migrate GitHub webhook normalization into github tool (#758)
* Add event-triggered routines and workflow skill templates

* Add generic host-verified webhook ingress for tools

* Migrate GitHub webhook normalization into github tool

* Bump github tool registry version

* Stabilize trace E2E test rig and approval behavior

* Add reusable gateway workflow harness with mock LLM server (#762)

* Add reusable gateway workflow test harness with mock LLM server

* Fix clippy issues in workflow harness

* Stabilize trace E2E test rig and approval behavior

* Address PR review feedback on gateway workflow harness

- Extract shared TestChannelHandle into test_channel.rs with name override
  support, eliminating ~55 lines of duplication between test_rig.rs and
  gateway_workflow_harness.rs
- Remove redundant RoutineEngine creation that was immediately overwritten
  by Agent::run()
- Replace flaky sleep(500ms) with polling loop for routine run count check
- Use components.context_manager instead of creating a fresh ContextManager
  for job tools, ensuring agent and tools share the same instance

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Fix import ordering in gateway_workflow_harness

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>

* Address PR #758 review feedback

- Fix header_value to use fully case-insensitive lookup (iterate with
  to_ascii_lowercase) instead of checking only exact/lower/upper variants
- Change comment_id from u32 to u64 to handle GitHub's billion-range IDs
- Remove handle_webhook from LLM-facing JSON schema to prevent direct
  invocation bypassing HMAC verification
- Rename enrichment keys from repository/sender to repository_name/
  sender_login to preserve original JSON objects in webhook payloads
- Remove put_string_normalized helper (no longer needed)
- Replace no-op tests (test_validate_event_in_create_pr_review,
  test_validate_merge_method) with test_header_value_case_insensitive
- Add README docs for 6 undocumented actions (list_issue_comments,
  create_issue_comment, list_pull_request_comments,
  reply_pull_request_comment, get_pull_request_reviews,
  get_combined_status)
- Add comment explaining max_tool_calls <= 8 bound in e2e test
- Fix gateway workflow harness: add webhook_capability with secret auth
  to MockGithubWebhookTool, matching staging's hardened webhook security
- Fix merge artifacts: remove duplicate test function, orphaned code
  fragment in e2e_routine_heartbeat

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Fix formatting in gateway workflow harness

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Address Copilot review: filter keys, pr_number fallback, feature gate, version alignment

- Update SKILL.md and workflow-routines.md templates to use `repository_name`
  and `sender_login` (matching enriched payload field names)
- Mark webhook HMAC secret as required in SKILL.md prerequisites
- Fall back to `/issue/number` for `pr_number` on issue_comment PR webhooks
- Gate `gateway_workflow_harness` module behind `#[cfg(feature = "libsql")]`
- Align tool version to 0.2.1 in Cargo.toml and capabilities.json

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-12 01:52:47 +00:00

301 lines
8.4 KiB
Rust

#![allow(dead_code)]
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use axum::extract::State;
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Json, Router};
use serde_json::{Value, json};
use tokio::net::TcpListener;
use tokio::sync::{Mutex, oneshot};
#[derive(Clone)]
pub struct MockOpenAiRule {
contains: String,
response: MockOpenAiResponse,
}
impl MockOpenAiRule {
pub fn on_user_contains(contains: impl Into<String>, response: MockOpenAiResponse) -> Self {
Self {
contains: contains.into(),
response,
}
}
}
#[derive(Clone)]
pub enum MockOpenAiResponse {
Text(String),
ToolCalls(Vec<MockToolCall>),
Raw(Value),
}
#[derive(Clone)]
pub struct MockToolCall {
pub id: String,
pub name: String,
pub arguments: Value,
}
impl MockToolCall {
pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: Value) -> Self {
Self {
id: id.into(),
name: name.into(),
arguments,
}
}
}
#[derive(Default)]
pub struct MockOpenAiServerBuilder {
models: Vec<String>,
rules: Vec<MockOpenAiRule>,
default_response: Option<MockOpenAiResponse>,
}
impl MockOpenAiServerBuilder {
pub fn new() -> Self {
Self {
models: vec!["mock-model".to_string()],
..Self::default()
}
}
pub fn with_models(mut self, models: Vec<String>) -> Self {
self.models = models;
self
}
pub fn with_rule(mut self, rule: MockOpenAiRule) -> Self {
self.rules.push(rule);
self
}
pub fn with_default_response(mut self, response: MockOpenAiResponse) -> Self {
self.default_response = Some(response);
self
}
pub async fn start(self) -> MockOpenAiServer {
let state = Arc::new(MockOpenAiState {
models: self.models,
rules: self.rules,
default_response: self
.default_response
.unwrap_or_else(|| MockOpenAiResponse::Text("OK".to_string())),
requests: Mutex::new(Vec::new()),
response_counter: AtomicU64::new(1),
});
let app = Router::new()
.route("/v1/models", get(models_handler))
.route("/v1/chat/completions", post(chat_completions_handler))
.with_state(Arc::clone(&state));
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind mock openai server");
let addr = listener.local_addr().expect("failed to read bound addr");
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
})
.await;
});
MockOpenAiServer {
addr,
state,
shutdown_tx: Some(shutdown_tx),
server_task: Some(handle),
}
}
}
pub struct MockOpenAiServer {
addr: SocketAddr,
state: Arc<MockOpenAiState>,
shutdown_tx: Option<oneshot::Sender<()>>,
server_task: Option<tokio::task::JoinHandle<()>>,
}
impl MockOpenAiServer {
pub fn base_url(&self) -> String {
format!("http://{}", self.addr)
}
pub fn openai_base_url(&self) -> String {
format!("{}/v1", self.base_url())
}
pub async fn requests(&self) -> Vec<Value> {
self.state.requests.lock().await.clone()
}
pub async fn shutdown(mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
if let Some(handle) = self.server_task.take() {
let _ = handle.await;
}
}
}
impl Drop for MockOpenAiServer {
fn drop(&mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
if let Some(handle) = self.server_task.take() {
handle.abort();
}
}
}
struct MockOpenAiState {
models: Vec<String>,
rules: Vec<MockOpenAiRule>,
default_response: MockOpenAiResponse,
requests: Mutex<Vec<Value>>,
response_counter: AtomicU64,
}
async fn models_handler(State(state): State<Arc<MockOpenAiState>>) -> Json<Value> {
Json(json!({
"object": "list",
"data": state
.models
.iter()
.map(|id| json!({"id": id, "object": "model"}))
.collect::<Vec<_>>()
}))
}
async fn chat_completions_handler(
State(state): State<Arc<MockOpenAiState>>,
Json(body): Json<Value>,
) -> Result<Json<Value>, (StatusCode, String)> {
state.requests.lock().await.push(body.clone());
let model = body
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("mock-model");
let last_role = body
.pointer("/messages")
.and_then(|m| m.as_array())
.and_then(|arr| arr.last())
.and_then(|v| v.get("role"))
.and_then(|r| r.as_str())
.unwrap_or_default();
fn extract_text_content(msg: &Value) -> Option<String> {
let content = msg.get("content")?;
if let Some(s) = content.as_str() {
return Some(s.to_string());
}
if let Some(parts) = content.as_array() {
let mut out = String::new();
for part in parts {
if part.get("type").and_then(|v| v.as_str()) == Some("text")
&& let Some(text) = part.get("text").and_then(|v| v.as_str())
{
if !out.is_empty() {
out.push(' ');
}
out.push_str(text);
}
}
if !out.is_empty() {
return Some(out);
}
}
None
}
let latest_user = body
.pointer("/messages")
.and_then(|m| m.as_array())
.and_then(|arr| {
arr.iter().rev().find_map(|msg| {
if msg.get("role").and_then(|r| r.as_str()) == Some("user") {
extract_text_content(msg)
} else {
None
}
})
})
.unwrap_or_default();
let selected = if last_role == "user" {
let latest_user_lower = latest_user.to_ascii_lowercase();
state
.rules
.iter()
.find(|r| latest_user_lower.contains(&r.contains.to_ascii_lowercase()))
.map(|r| r.response.clone())
.unwrap_or_else(|| state.default_response.clone())
} else {
state.default_response.clone()
};
let n = state.response_counter.fetch_add(1, Ordering::Relaxed);
let response = match selected {
MockOpenAiResponse::Text(content) => json!({
"id": format!("chatcmpl-mock-{n}"),
"object": "chat.completion",
"created": 0,
"model": model,
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": content},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
}),
MockOpenAiResponse::ToolCalls(tool_calls) => {
let calls = tool_calls
.iter()
.map(|tc| {
json!({
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": tc.arguments.to_string()
}
})
})
.collect::<Vec<_>>();
json!({
"id": format!("chatcmpl-mock-{n}"),
"object": "chat.completion",
"created": 0,
"model": model,
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": serde_json::Value::Null,
"tool_calls": calls
},
"finish_reason": "tool_calls"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
})
}
MockOpenAiResponse::Raw(v) => v,
};
Ok(Json(response))
}