@mmmbuto/masix 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +15 -13
  2. package/install.js +89 -26
  3. package/package.json +4 -3
  4. package/packages/plugin-base/codex-backend/0.1.4/SHA256SUMS +3 -0
  5. package/packages/plugin-base/codex-backend/0.1.4/codex-backend-android-aarch64-termux.pkg +0 -0
  6. package/packages/plugin-base/codex-backend/0.1.4/codex-backend-linux-x86_64.pkg +0 -0
  7. package/packages/plugin-base/codex-backend/0.1.4/codex-backend-macos-aarch64.pkg +0 -0
  8. package/packages/plugin-base/codex-backend/0.1.4/manifest.json +33 -0
  9. package/packages/plugin-base/codex-backend/CHANGELOG.md +17 -0
  10. package/packages/plugin-base/codex-backend/README.md +33 -0
  11. package/packages/plugin-base/codex-backend/source/Cargo.toml +25 -0
  12. package/packages/plugin-base/codex-backend/source/README-PACKAGE.txt +54 -0
  13. package/packages/plugin-base/codex-backend/source/plugin.manifest.json +103 -0
  14. package/packages/plugin-base/codex-backend/source/src/error.rs +60 -0
  15. package/packages/plugin-base/codex-backend/source/src/exec.rs +436 -0
  16. package/packages/plugin-base/codex-backend/source/src/http_backend.rs +1198 -0
  17. package/packages/plugin-base/codex-backend/source/src/lib.rs +328 -0
  18. package/packages/plugin-base/codex-backend/source/src/patch.rs +767 -0
  19. package/packages/plugin-base/codex-backend/source/src/policy.rs +297 -0
  20. package/packages/plugin-base/codex-backend/source/src/tools.rs +72 -0
  21. package/packages/plugin-base/codex-backend/source/src/workspace.rs +433 -0
  22. package/packages/plugin-base/codex-tools/0.1.3/SHA256SUMS +3 -0
  23. package/packages/plugin-base/codex-tools/0.1.3/codex-tools-android-aarch64-termux.pkg +0 -0
  24. package/packages/plugin-base/codex-tools/0.1.3/codex-tools-linux-x86_64.pkg +0 -0
  25. package/packages/plugin-base/codex-tools/0.1.3/codex-tools-macos-aarch64.pkg +0 -0
  26. package/packages/plugin-base/codex-tools/0.1.3/manifest.json +33 -0
  27. package/packages/plugin-base/codex-tools/CHANGELOG.md +17 -0
  28. package/packages/plugin-base/codex-tools/README.md +33 -0
  29. package/packages/plugin-base/codex-tools/source/Cargo.toml +23 -0
  30. package/packages/plugin-base/codex-tools/source/plugin.manifest.json +124 -0
  31. package/packages/plugin-base/codex-tools/source/src/main.rs +995 -0
  32. package/packages/plugin-base/discovery/0.2.4/SHA256SUMS +3 -0
  33. package/packages/plugin-base/discovery/0.2.4/discovery-android-aarch64-termux.pkg +0 -0
  34. package/packages/plugin-base/discovery/0.2.4/discovery-linux-x86_64.pkg +0 -0
  35. package/packages/plugin-base/discovery/0.2.4/discovery-macos-aarch64.pkg +0 -0
  36. package/packages/plugin-base/discovery/0.2.4/manifest.json +31 -0
  37. package/packages/plugin-base/discovery/CHANGELOG.md +17 -0
  38. package/packages/plugin-base/discovery/README.md +48 -0
  39. package/packages/plugin-base/discovery/source/Cargo.toml +14 -0
  40. package/packages/plugin-base/discovery/source/plugin.manifest.json +30 -0
  41. package/packages/plugin-base/discovery/source/src/main.rs +2570 -0
  42. package/prebuilt/masix +0 -0
@@ -0,0 +1,1198 @@
1
+ use crate::policy::AdminPolicy;
2
+ use crate::{
3
+ CodingBackend, CodingError, CodingEvent, CodingMode, CodingResult, CodingTask, DryRunInfo,
4
+ ExitStatus, ProviderType, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_MAX_TOKENS, DEFAULT_TIMEOUT_SECS,
5
+ MAX_LOG_ENTRIES,
6
+ };
7
+ use async_trait::async_trait;
8
+ use serde::{Deserialize, Serialize};
9
+ use std::path::{Path, PathBuf};
10
+ use std::time::{Duration, Instant};
11
+ use tracing::debug;
12
+
13
+ const OPENAI_DEFAULT_URL: &str = "https://api.openai.com";
14
+ const ANTHROPIC_DEFAULT_URL: &str = "https://api.anthropic.com";
15
+ const TRUNCATION_MARKER: &str = "\n...[TRUNCATED]";
16
+
17
+ #[derive(Debug, Clone)]
18
+ pub struct HttpBackendConfig {
19
+ pub provider_type: ProviderType,
20
+ pub api_key: String,
21
+ pub base_url: Option<String>,
22
+ pub model: Option<String>,
23
+ pub max_tokens: u32,
24
+ pub timeout: Duration,
25
+ pub max_output_bytes: usize,
26
+ pub max_log_bytes: usize,
27
+ pub legacy_fallback: bool,
28
+ }
29
+
30
+ impl Default for HttpBackendConfig {
31
+ fn default() -> Self {
32
+ Self {
33
+ provider_type: ProviderType::OpenAI,
34
+ api_key: String::new(),
35
+ base_url: None,
36
+ model: None,
37
+ max_tokens: DEFAULT_MAX_TOKENS,
38
+ timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
39
+ max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
40
+ max_log_bytes: 64 * 1024,
41
+ legacy_fallback: false,
42
+ }
43
+ }
44
+ }
45
+
46
+ pub struct HttpBackend {
47
+ client: reqwest::Client,
48
+ config: HttpBackendConfig,
49
+ }
50
+
51
+ impl HttpBackend {
52
+ pub fn new(config: HttpBackendConfig) -> Result<Self, CodingError> {
53
+ if config.api_key.is_empty() {
54
+ return Err(CodingError::MissingApiKey);
55
+ }
56
+ let client = reqwest::Client::builder()
57
+ .timeout(config.timeout)
58
+ .build()
59
+ .map_err(|e| CodingError::IoError(e.to_string()))?;
60
+ Ok(Self { client, config })
61
+ }
62
+
63
+ pub fn new_openai(
64
+ api_key: String,
65
+ base_url: Option<String>,
66
+ model: Option<String>,
67
+ ) -> Result<Self, CodingError> {
68
+ Self::new(HttpBackendConfig {
69
+ provider_type: ProviderType::OpenAI,
70
+ api_key,
71
+ base_url,
72
+ model,
73
+ ..Default::default()
74
+ })
75
+ }
76
+
77
+ pub fn new_anthropic(
78
+ api_key: String,
79
+ base_url: Option<String>,
80
+ model: Option<String>,
81
+ ) -> Result<Self, CodingError> {
82
+ Self::new(HttpBackendConfig {
83
+ provider_type: ProviderType::Anthropic,
84
+ api_key,
85
+ base_url,
86
+ model: model.or_else(|| Some("claude-3-5-sonnet-latest".to_string())),
87
+ ..Default::default()
88
+ })
89
+ }
90
+
91
+ fn normalize_base_url(&self) -> String {
92
+ let base = match (&self.config.base_url, &self.config.provider_type) {
93
+ (Some(url), _) => url.as_str(),
94
+ (None, ProviderType::OpenAI) | (None, ProviderType::OpenAICompatible) => {
95
+ OPENAI_DEFAULT_URL
96
+ }
97
+ (None, ProviderType::Anthropic) => ANTHROPIC_DEFAULT_URL,
98
+ };
99
+ base.trim_end_matches('/')
100
+ .trim_end_matches("/v1")
101
+ .to_string()
102
+ }
103
+
104
+ fn get_model(&self) -> &str {
105
+ match (&self.config.model, &self.config.provider_type) {
106
+ (Some(m), _) => m,
107
+ (None, ProviderType::OpenAI) => "gpt-4.1",
108
+ (None, ProviderType::Anthropic) => "claude-3-5-sonnet-latest",
109
+ (None, ProviderType::OpenAICompatible) => "default",
110
+ }
111
+ }
112
+
113
+ fn build_system_prompt(&self, task: &CodingTask) -> String {
114
+ match task.mode {
115
+ CodingMode::ReadOnly => format!(
116
+ "You are a coding assistant analyzing the repository at {}.\nAvailable tools: read_file, list_dir.\n\nRequest: {}",
117
+ task.repo_path.display(), task.instructions
118
+ ),
119
+ CodingMode::WorkspaceWrite => format!(
120
+ "You are a coding assistant with write access to the repository at {}.\nAvailable tools: read_file, write_file, list_dir.\n\nRequest: {}",
121
+ task.repo_path.display(), task.instructions
122
+ ),
123
+ }
124
+ }
125
+
126
+ fn safe_path(&self, relative: &str, repo_root: &Path) -> Result<PathBuf, CodingError> {
127
+ let normalized = relative.replace('\\', "/");
128
+ if normalized.starts_with('/') || normalized.starts_with('~') {
129
+ return Err(CodingError::PathSecurityViolation(
130
+ "Absolute/home paths not allowed".into(),
131
+ ));
132
+ }
133
+ for component in normalized.split('/') {
134
+ if component == ".." {
135
+ return Err(CodingError::PathSecurityViolation(
136
+ "Path traversal not allowed".into(),
137
+ ));
138
+ }
139
+ }
140
+ let resolved = repo_root.join(&normalized);
141
+ let canonical_root = repo_root
142
+ .canonicalize()
143
+ .map_err(|e| CodingError::IoError(format!("Cannot canonicalize repo root: {}", e)))?;
144
+ let mut check_path = resolved.clone();
145
+ while !check_path.exists() {
146
+ if !check_path.pop() {
147
+ break;
148
+ }
149
+ }
150
+ let canonical_check = check_path
151
+ .canonicalize()
152
+ .unwrap_or_else(|_| check_path.clone());
153
+ if !canonical_check.starts_with(&canonical_root) {
154
+ return Err(CodingError::PathSecurityViolation(
155
+ "Path escapes repository root".into(),
156
+ ));
157
+ }
158
+ if resolved.exists() {
159
+ if let Ok(metadata) = std::fs::symlink_metadata(&resolved) {
160
+ if metadata.file_type().is_symlink() {
161
+ let canonical_resolved = resolved.canonicalize().map_err(|_| {
162
+ CodingError::PathSecurityViolation("Cannot resolve symlink".into())
163
+ })?;
164
+ if !canonical_resolved.starts_with(&canonical_root) {
165
+ return Err(CodingError::PathSecurityViolation(
166
+ "Symlink escapes repository root".into(),
167
+ ));
168
+ }
169
+ }
170
+ }
171
+ }
172
+ Ok(resolved)
173
+ }
174
+
175
+ fn truncate_output(&self, output: &str) -> String {
176
+ let bytes = output.as_bytes();
177
+ if bytes.len() <= self.config.max_output_bytes {
178
+ output.to_string()
179
+ } else {
180
+ let truncated = String::from_utf8_lossy(&bytes[..self.config.max_output_bytes]);
181
+ format!("{}{}", truncated, TRUNCATION_MARKER)
182
+ }
183
+ }
184
+
185
+ fn truncate_log_entry(&self, entry: &str) -> String {
186
+ if entry.len() <= self.config.max_log_bytes {
187
+ entry.to_string()
188
+ } else {
189
+ format!(
190
+ "{}{}",
191
+ &entry[..self.config.max_log_bytes],
192
+ TRUNCATION_MARKER
193
+ )
194
+ }
195
+ }
196
+
197
+ fn get_openai_endpoints(&self) -> Vec<(&'static str, String)> {
198
+ let base = self.normalize_base_url();
199
+ if self.config.legacy_fallback {
200
+ vec![
201
+ ("responses", format!("{}/v1/responses", base)),
202
+ ("chat", format!("{}/v1/chat/completions", base)),
203
+ ("chat_legacy", format!("{}/chat/completions", base)),
204
+ ("chat_minimal", format!("{}/chat", base)),
205
+ ]
206
+ } else {
207
+ vec![("chat", format!("{}/v1/chat/completions", base))]
208
+ }
209
+ }
210
+
211
+ fn get_anthropic_endpoints(&self) -> Vec<(&'static str, String)> {
212
+ let base = self.normalize_base_url();
213
+ if self.config.legacy_fallback {
214
+ vec![
215
+ ("messages", format!("{}/v1/messages", base)),
216
+ ("messages_legacy", format!("{}/messages", base)),
217
+ ]
218
+ } else {
219
+ vec![("messages", format!("{}/v1/messages", base))]
220
+ }
221
+ }
222
+
223
+ fn is_terminal_error(status: u16, body: &str) -> bool {
224
+ matches!(status, 401 | 403) || (status == 400 && body.contains("invalid_api_key"))
225
+ }
226
+
227
+ fn is_retriable_status(status: u16) -> bool {
228
+ matches!(status, 408 | 429 | 500 | 502 | 503 | 504)
229
+ }
230
+
231
+ async fn call_openai_chat(
232
+ &self,
233
+ messages: Vec<serde_json::Value>,
234
+ tools: Vec<serde_json::Value>,
235
+ remaining_timeout: Duration,
236
+ ) -> Result<ChatResponse, CodingError> {
237
+ let endpoints = self.get_openai_endpoints();
238
+ let mut last_error = None;
239
+ for (endpoint_type, url) in endpoints {
240
+ debug!("Trying OpenAI endpoint: {} ({})", url, endpoint_type);
241
+ let body = match endpoint_type {
242
+ "responses" => self.build_responses_body(&messages, &tools),
243
+ _ => self.build_chat_body(&messages, &tools),
244
+ };
245
+ let result = tokio::time::timeout(
246
+ remaining_timeout,
247
+ self.client
248
+ .post(&url)
249
+ .header("Authorization", format!("Bearer {}", self.config.api_key))
250
+ .header("Content-Type", "application/json")
251
+ .json(&body)
252
+ .send(),
253
+ )
254
+ .await;
255
+ match result {
256
+ Ok(Ok(response)) => {
257
+ let status = response.status();
258
+ let body_text = response
259
+ .text()
260
+ .await
261
+ .map_err(|e| CodingError::HttpError(e.to_string()))?;
262
+ if status.is_success() {
263
+ return self.parse_endpoint_response(endpoint_type, &body_text);
264
+ }
265
+ if Self::is_terminal_error(status.as_u16(), &body_text) {
266
+ return Err(CodingError::ApiError(format!(
267
+ "Auth error HTTP {}: {}",
268
+ status,
269
+ &body_text.chars().take(200).collect::<String>()
270
+ )));
271
+ }
272
+ let is_404_or_schema = status == 404
273
+ || body_text.contains("not_found")
274
+ || body_text.contains("invalid_request");
275
+ if !Self::is_retriable_status(status.as_u16()) && !is_404_or_schema {
276
+ return Err(CodingError::ApiError(format!(
277
+ "HTTP {}: {}",
278
+ status,
279
+ &body_text.chars().take(200).collect::<String>()
280
+ )));
281
+ }
282
+ last_error = Some(format!(
283
+ "HTTP {}: {}",
284
+ status,
285
+ &body_text.chars().take(100).collect::<String>()
286
+ ));
287
+ }
288
+ Ok(Err(e)) => {
289
+ last_error = Some(format!("Network error: {}", e));
290
+ }
291
+ Err(_) => {
292
+ last_error = Some("Timeout".to_string());
293
+ }
294
+ }
295
+ }
296
+ Err(CodingError::ApiError(
297
+ last_error.unwrap_or_else(|| "All endpoints failed".to_string()),
298
+ ))
299
+ }
300
+
301
+ fn build_chat_body(
302
+ &self,
303
+ messages: &[serde_json::Value],
304
+ tools: &[serde_json::Value],
305
+ ) -> serde_json::Value {
306
+ let mut body = serde_json::json!({ "model": self.get_model(), "messages": messages, "max_tokens": self.config.max_tokens });
307
+ if !tools.is_empty() {
308
+ body["tools"] = serde_json::json!(tools);
309
+ body["tool_choice"] = serde_json::json!("auto");
310
+ }
311
+ body
312
+ }
313
+
314
+ fn build_responses_body(
315
+ &self,
316
+ messages: &[serde_json::Value],
317
+ tools: &[serde_json::Value],
318
+ ) -> serde_json::Value {
319
+ let user_content = messages
320
+ .iter()
321
+ .filter_map(|m| m.get("content").and_then(|c| c.as_str()))
322
+ .last()
323
+ .unwrap_or("");
324
+ let mut body = serde_json::json!({ "model": self.get_model(), "input": user_content });
325
+ if !tools.is_empty() {
326
+ body["tools"] = serde_json::json!(tools);
327
+ }
328
+ body
329
+ }
330
+
331
+ fn parse_endpoint_response(
332
+ &self,
333
+ endpoint_type: &str,
334
+ body: &str,
335
+ ) -> Result<ChatResponse, CodingError> {
336
+ let json: serde_json::Value =
337
+ serde_json::from_str(body).map_err(|e| CodingError::InvalidResponse(e.to_string()))?;
338
+ match endpoint_type {
339
+ "responses" => parse_responses_response(&json),
340
+ _ => parse_openai_response(&json),
341
+ }
342
+ }
343
+
344
+ async fn call_anthropic_chat(
345
+ &self,
346
+ messages: Vec<serde_json::Value>,
347
+ tools: Vec<serde_json::Value>,
348
+ remaining_timeout: Duration,
349
+ ) -> Result<ChatResponse, CodingError> {
350
+ let endpoints = self.get_anthropic_endpoints();
351
+ let mut last_error = None;
352
+ let system = messages
353
+ .iter()
354
+ .find(|m| m["role"] == "system")
355
+ .and_then(|m| m["content"].as_str())
356
+ .unwrap_or("");
357
+ let other_messages: Vec<_> = messages
358
+ .iter()
359
+ .filter(|m| m["role"] != "system")
360
+ .cloned()
361
+ .collect();
362
+ for (_, url) in endpoints {
363
+ debug!("Trying Anthropic endpoint: {}", url);
364
+ let mut body = serde_json::json!({ "model": self.get_model(), "max_tokens": self.config.max_tokens, "messages": other_messages.clone() });
365
+ if !system.is_empty() {
366
+ body["system"] = serde_json::json!(system);
367
+ }
368
+ if !tools.is_empty() {
369
+ body["tools"] = serde_json::json!(crate::tools::tools_to_anthropic_format(&tools));
370
+ }
371
+ let result = tokio::time::timeout(
372
+ remaining_timeout,
373
+ self.client
374
+ .post(&url)
375
+ .header("x-api-key", &self.config.api_key)
376
+ .header("anthropic-version", "2023-06-01")
377
+ .header("Content-Type", "application/json")
378
+ .json(&body)
379
+ .send(),
380
+ )
381
+ .await;
382
+ match result {
383
+ Ok(Ok(response)) => {
384
+ let status = response.status();
385
+ let body_text = response
386
+ .text()
387
+ .await
388
+ .map_err(|e| CodingError::HttpError(e.to_string()))?;
389
+ if status.is_success() {
390
+ return parse_anthropic_response(
391
+ &serde_json::from_str(&body_text)
392
+ .map_err(|e| CodingError::InvalidResponse(e.to_string()))?,
393
+ );
394
+ }
395
+ if Self::is_terminal_error(status.as_u16(), &body_text) {
396
+ return Err(CodingError::ApiError(format!(
397
+ "Auth error HTTP {}: {}",
398
+ status,
399
+ &body_text.chars().take(200).collect::<String>()
400
+ )));
401
+ }
402
+ last_error = Some(format!(
403
+ "HTTP {}: {}",
404
+ status,
405
+ &body_text.chars().take(100).collect::<String>()
406
+ ));
407
+ }
408
+ Ok(Err(e)) => {
409
+ last_error = Some(format!("Network error: {}", e));
410
+ }
411
+ Err(_) => {
412
+ last_error = Some("Timeout".to_string());
413
+ }
414
+ }
415
+ }
416
+ Err(CodingError::ApiError(
417
+ last_error.unwrap_or_else(|| "All endpoints failed".to_string()),
418
+ ))
419
+ }
420
+
421
+ async fn execute_tool(
422
+ &self,
423
+ name: &str,
424
+ args: &str,
425
+ task: &CodingTask,
426
+ ) -> Result<String, CodingError> {
427
+ match name {
428
+ "read_file" => {
429
+ let args: ReadFileArgs = serde_json::from_str(args)
430
+ .map_err(|e| CodingError::ToolError(format!("Invalid args: {}", e)))?;
431
+ let path = self.safe_path(&args.path, &task.repo_path)?;
432
+ let content = std::fs::read_to_string(&path).map_err(|e| {
433
+ CodingError::IoError(format!("Failed to read {}: {}", path.display(), e))
434
+ })?;
435
+ Ok(self.truncate_output(&content))
436
+ }
437
+ "list_dir" => {
438
+ let args: ListDirArgs = serde_json::from_str(args)
439
+ .map_err(|e| CodingError::ToolError(format!("Invalid args: {}", e)))?;
440
+ let path = self.safe_path(&args.path, &task.repo_path)?;
441
+ let entries = std::fs::read_dir(&path).map_err(|e| {
442
+ CodingError::IoError(format!("Failed to list {}: {}", path.display(), e))
443
+ })?;
444
+ Ok(entries
445
+ .filter_map(|e| e.ok())
446
+ .map(|e| e.file_name().to_string_lossy().to_string())
447
+ .collect::<Vec<_>>()
448
+ .join("\n"))
449
+ }
450
+ "write_file" if matches!(task.mode, CodingMode::WorkspaceWrite) => {
451
+ let args: WriteFileArgs = serde_json::from_str(args)
452
+ .map_err(|e| CodingError::ToolError(format!("Invalid args: {}", e)))?;
453
+ let path = self.safe_path(&args.path, &task.repo_path)?;
454
+ if let Some(parent) = path.parent() {
455
+ std::fs::create_dir_all(parent).map_err(|e| {
456
+ CodingError::IoError(format!("Failed to create dir: {}", e))
457
+ })?;
458
+ }
459
+ std::fs::write(&path, &args.content).map_err(|e| {
460
+ CodingError::IoError(format!("Failed to write {}: {}", path.display(), e))
461
+ })?;
462
+ Ok(format!("Written to {}", path.display()))
463
+ }
464
+ _ => Err(CodingError::ToolError(format!("Unknown tool: {}", name))),
465
+ }
466
+ }
467
+ }
468
+
469
+ #[derive(Debug, Clone, Serialize, Deserialize)]
470
+ struct ReadFileArgs {
471
+ path: String,
472
+ }
473
+ #[derive(Debug, Clone, Serialize, Deserialize)]
474
+ struct ListDirArgs {
475
+ path: String,
476
+ }
477
+ #[derive(Debug, Clone, Serialize, Deserialize)]
478
+ struct WriteFileArgs {
479
+ path: String,
480
+ content: String,
481
+ }
482
+
483
+ #[derive(Debug, Clone)]
484
+ struct ChatResponse {
485
+ content: Option<String>,
486
+ tool_calls: Vec<ToolCall>,
487
+ }
488
+ #[derive(Debug, Clone)]
489
+ struct ToolCall {
490
+ id: String,
491
+ name: String,
492
+ arguments: String,
493
+ }
494
+
495
+ fn parse_openai_response(json: &serde_json::Value) -> Result<ChatResponse, CodingError> {
496
+ let choice = json
497
+ .get("choices")
498
+ .and_then(|c| c.as_array())
499
+ .and_then(|c| c.first())
500
+ .ok_or_else(|| CodingError::InvalidResponse("No choices".into()))?;
501
+ let message = choice
502
+ .get("message")
503
+ .ok_or_else(|| CodingError::InvalidResponse("No message".into()))?;
504
+ let content = message
505
+ .get("content")
506
+ .and_then(|c| c.as_str())
507
+ .map(|s| s.to_string());
508
+ let tool_calls = message
509
+ .get("tool_calls")
510
+ .and_then(|tc| tc.as_array())
511
+ .map(|arr| {
512
+ arr.iter()
513
+ .filter_map(|tc| {
514
+ let id = tc.get("id")?.as_str()?.to_string();
515
+ let func = tc.get("function")?;
516
+ Some(ToolCall {
517
+ id,
518
+ name: func.get("name")?.as_str()?.to_string(),
519
+ arguments: func.get("arguments")?.as_str()?.to_string(),
520
+ })
521
+ })
522
+ .collect()
523
+ })
524
+ .unwrap_or_default();
525
+ Ok(ChatResponse {
526
+ content,
527
+ tool_calls,
528
+ })
529
+ }
530
+
531
+ fn parse_responses_response(json: &serde_json::Value) -> Result<ChatResponse, CodingError> {
532
+ let content = json
533
+ .get("output")
534
+ .and_then(|o| o.as_str())
535
+ .map(|s| s.to_string())
536
+ .or_else(|| {
537
+ json.get("content")
538
+ .and_then(|c| c.as_array())
539
+ .and_then(|arr| arr.first())
540
+ .and_then(|b| b.get("text"))
541
+ .and_then(|t| t.as_str())
542
+ .map(|s| s.to_string())
543
+ });
544
+ let tool_calls = json
545
+ .get("tool_calls")
546
+ .and_then(|tc| tc.as_array())
547
+ .map(|arr| {
548
+ arr.iter()
549
+ .filter_map(|tc| {
550
+ Some(ToolCall {
551
+ id: tc.get("id")?.as_str()?.to_string(),
552
+ name: tc.get("name")?.as_str()?.to_string(),
553
+ arguments: tc
554
+ .get("arguments")
555
+ .and_then(|a| a.as_str())
556
+ .unwrap_or("{}")
557
+ .to_string(),
558
+ })
559
+ })
560
+ .collect()
561
+ })
562
+ .unwrap_or_default();
563
+ Ok(ChatResponse {
564
+ content,
565
+ tool_calls,
566
+ })
567
+ }
568
+
569
+ fn parse_anthropic_response(json: &serde_json::Value) -> Result<ChatResponse, CodingError> {
570
+ let content_blocks = json
571
+ .get("content")
572
+ .and_then(|c| c.as_array())
573
+ .ok_or_else(|| CodingError::InvalidResponse("No content".into()))?;
574
+ let mut content = None;
575
+ let mut tool_calls = Vec::new();
576
+ for block in content_blocks {
577
+ match block.get("type").and_then(|t| t.as_str()).unwrap_or("") {
578
+ "text" => {
579
+ content = block
580
+ .get("text")
581
+ .and_then(|t| t.as_str())
582
+ .map(|s| s.to_string());
583
+ }
584
+ "tool_use" => {
585
+ tool_calls.push(ToolCall {
586
+ id: block
587
+ .get("id")
588
+ .and_then(|i| i.as_str())
589
+ .unwrap_or("")
590
+ .to_string(),
591
+ name: block
592
+ .get("name")
593
+ .and_then(|n| n.as_str())
594
+ .unwrap_or("")
595
+ .to_string(),
596
+ arguments: serde_json::to_string(
597
+ &block.get("input").cloned().unwrap_or(serde_json::json!({})),
598
+ )
599
+ .unwrap_or_default(),
600
+ });
601
+ }
602
+ _ => {}
603
+ }
604
+ }
605
+ Ok(ChatResponse {
606
+ content,
607
+ tool_calls,
608
+ })
609
+ }
610
+
611
+ fn build_anthropic_assistant_message(tool_calls: &[ToolCall]) -> serde_json::Value {
612
+ let content: Vec<_> = tool_calls.iter().map(|tc| {
613
+ serde_json::json!({ "type": "tool_use", "id": tc.id, "name": tc.name, "input": serde_json::from_str::<serde_json::Value>(&tc.arguments).unwrap_or(serde_json::json!({})) })
614
+ }).collect();
615
+ serde_json::json!({ "role": "assistant", "content": content })
616
+ }
617
+
618
+ fn build_openai_assistant_message(tool_calls: &[ToolCall]) -> serde_json::Value {
619
+ serde_json::json!({
620
+ "role": "assistant",
621
+ "tool_calls": tool_calls.iter().map(|tc| {
622
+ serde_json::json!({ "id": tc.id, "type": "function", "function": { "name": tc.name, "arguments": tc.arguments } })
623
+ }).collect::<Vec<_>>()
624
+ })
625
+ }
626
+
627
+ fn build_openai_tool_result(tc: &ToolCall, result: &str) -> serde_json::Value {
628
+ serde_json::json!({ "role": "tool", "tool_call_id": tc.id, "content": result })
629
+ }
630
+
631
+ #[async_trait]
632
+ impl CodingBackend for HttpBackend {
633
+ async fn run(&self, task: CodingTask) -> Result<CodingResult, CodingError> {
634
+ // Check admin policy before execution
635
+ self.check_policy(&task)?;
636
+
637
+ let start_time = Instant::now();
638
+ let task_timeout = task.timeout.unwrap_or(self.config.timeout);
639
+ let tools = match task.mode {
640
+ CodingMode::ReadOnly => crate::tools::get_read_tools(),
641
+ CodingMode::WorkspaceWrite => crate::tools::get_write_tools(),
642
+ };
643
+ let mut messages: Vec<serde_json::Value> = vec![
644
+ serde_json::json!({ "role": "system", "content": self.build_system_prompt(&task) }),
645
+ serde_json::json!({ "role": "user", "content": task.instructions }),
646
+ ];
647
+ let mut logs = Vec::new();
648
+ let mut events = Vec::new();
649
+ let max_iterations = task.max_steps.unwrap_or(50);
650
+ let mut iterations = 0u32;
651
+
652
+ // Record start event
653
+ events.push(CodingEvent::start(&task.repo_path));
654
+
655
+ loop {
656
+ let elapsed = start_time.elapsed();
657
+ if elapsed >= task_timeout {
658
+ let summary = format!("Timeout after {:?}", task_timeout);
659
+ events.push(CodingEvent::final_event("timeout", &summary));
660
+ return Ok(CodingResult {
661
+ patch: None,
662
+ summary,
663
+ logs,
664
+ exit_status: ExitStatus::Timeout,
665
+ events,
666
+ });
667
+ }
668
+ if iterations >= max_iterations {
669
+ let summary = "Max iterations exceeded".to_string();
670
+ events.push(CodingEvent::final_event("max_iterations", &summary));
671
+ return Ok(CodingResult {
672
+ patch: None,
673
+ summary,
674
+ logs,
675
+ exit_status: ExitStatus::MaxIterations,
676
+ events,
677
+ });
678
+ }
679
+ if logs.len() >= MAX_LOG_ENTRIES {
680
+ logs.push(format!("{}[MAX_LOG_ENTRIES]", TRUNCATION_MARKER));
681
+ let summary = "Log limit exceeded".to_string();
682
+ events.push(CodingEvent::final_event("log_limit", &summary));
683
+ return Ok(CodingResult {
684
+ patch: None,
685
+ summary,
686
+ logs,
687
+ exit_status: ExitStatus::Error("Log limit".into()),
688
+ events,
689
+ });
690
+ }
691
+
692
+ // Record iteration event
693
+ iterations += 1;
694
+ events.push(CodingEvent::iteration(iterations));
695
+
696
+ let remaining = task_timeout - elapsed;
697
+ let response = match self.config.provider_type {
698
+ ProviderType::OpenAI | ProviderType::OpenAICompatible => {
699
+ self.call_openai_chat(messages.clone(), tools.clone(), remaining)
700
+ .await?
701
+ }
702
+ ProviderType::Anthropic => {
703
+ self.call_anthropic_chat(messages.clone(), tools.clone(), remaining)
704
+ .await?
705
+ }
706
+ };
707
+
708
+ if response.tool_calls.is_empty() {
709
+ let summary = response
710
+ .content
711
+ .clone()
712
+ .unwrap_or_else(|| "Task completed".into());
713
+ events.push(CodingEvent::final_event("success", &summary));
714
+ return Ok(CodingResult {
715
+ patch: None,
716
+ summary,
717
+ logs,
718
+ exit_status: ExitStatus::Success,
719
+ events,
720
+ });
721
+ }
722
+
723
+ // Process ALL tool calls in this batch (collect results first)
724
+ let mut tool_results: Vec<(&ToolCall, String, bool)> = Vec::new();
725
+ for tc in &response.tool_calls {
726
+ events.push(CodingEvent::tool_call(&tc.id, &tc.name, &tc.arguments));
727
+
728
+ let result = self.execute_tool(&tc.name, &tc.arguments, &task).await;
729
+ let (result_text, success) = match result {
730
+ Ok(r) => (r, true),
731
+ Err(e) => (format!("Error: {}", e), false),
732
+ };
733
+ let truncated = self.truncate_log_entry(&result_text);
734
+ logs.push(format!("[{}] {}", tc.name, truncated));
735
+
736
+ events.push(CodingEvent::tool_result(
737
+ &tc.id, &tc.name, success, &truncated,
738
+ ));
739
+ tool_results.push((tc, truncated, success));
740
+ }
741
+
742
+ // Push messages per provider protocol
743
+ match self.config.provider_type {
744
+ ProviderType::Anthropic => {
745
+ messages.push(build_anthropic_assistant_message(&response.tool_calls));
746
+ // Anthropic protocol: ALL tool_results in SINGLE user message
747
+ let blocks: Vec<_> = tool_results
748
+ .iter()
749
+ .map(|(tc, result, _)| {
750
+ serde_json::json!({
751
+ "type": "tool_result",
752
+ "tool_use_id": tc.id,
753
+ "content": result
754
+ })
755
+ })
756
+ .collect();
757
+ messages.push(serde_json::json!({"role": "user", "content": blocks}));
758
+ }
759
+ ProviderType::OpenAI | ProviderType::OpenAICompatible => {
760
+ messages.push(build_openai_assistant_message(&response.tool_calls));
761
+ for (tc, result, _) in &tool_results {
762
+ messages.push(build_openai_tool_result(tc, result));
763
+ }
764
+ }
765
+ }
766
+ }
767
+ }
768
+
769
+ fn dry_run(&self, task: &CodingTask) -> DryRunInfo {
770
+ let policy_allowed = task
771
+ .policy
772
+ .as_ref()
773
+ .map(|p| p.permission_level.is_admin())
774
+ .unwrap_or(false);
775
+ DryRunInfo {
776
+ backend: "http",
777
+ provider_type: self.config.provider_type.as_str(),
778
+ base_url: Some(self.normalize_base_url()),
779
+ model: Some(self.get_model().to_string()),
780
+ timeout_secs: self.config.timeout.as_secs(),
781
+ mode: match task.mode {
782
+ CodingMode::ReadOnly => "read-only",
783
+ CodingMode::WorkspaceWrite => "workspace-write",
784
+ },
785
+ repo_path: task.repo_path.clone(),
786
+ policy_allowed,
787
+ requires_admin: true,
788
+ }
789
+ }
790
+
791
+ fn check_policy(&self, task: &CodingTask) -> Result<(), CodingError> {
792
+ let policy = AdminPolicy::new();
793
+ policy.check(task.policy.as_ref())
794
+ }
795
+ }
796
+
797
+ #[cfg(test)]
798
+ mod tests {
799
+ use super::*;
800
+ use crate::policy::PolicyContext;
801
+ use mockito::Server;
802
+ use serde_json::json;
803
+
804
+ fn test_backend() -> HttpBackend {
805
+ HttpBackend::new(HttpBackendConfig {
806
+ api_key: "test".into(),
807
+ ..Default::default()
808
+ })
809
+ .unwrap()
810
+ }
811
+
812
+ #[test]
813
+ fn test_safe_path_rejects_traversal() {
814
+ let b = test_backend();
815
+ let root = std::env::temp_dir();
816
+ assert!(b.safe_path("../etc/passwd", &root).is_err());
817
+ assert!(b.safe_path("foo/../../etc", &root).is_err());
818
+ assert!(b.safe_path("~/secret", &root).is_err());
819
+ assert!(b.safe_path("/etc/passwd", &root).is_err());
820
+ }
821
+
822
+ #[test]
823
+ fn test_safe_path_accepts_valid() {
824
+ let b = test_backend();
825
+ let root = std::env::temp_dir();
826
+ assert!(b.safe_path("src/lib.rs", &root).is_ok());
827
+ assert!(b.safe_path("foo/bar/baz.txt", &root).is_ok());
828
+ }
829
+
830
+ #[test]
831
+ fn test_truncate_output() {
832
+ let b = HttpBackend::new(HttpBackendConfig {
833
+ api_key: "test".into(),
834
+ max_output_bytes: 10,
835
+ ..Default::default()
836
+ })
837
+ .unwrap();
838
+ let t = b.truncate_output("This is a very long string");
839
+ assert!(t.ends_with(TRUNCATION_MARKER));
840
+ }
841
+
842
+ #[test]
843
+ fn test_normalize_base_url() {
844
+ let b = HttpBackend::new(HttpBackendConfig {
845
+ api_key: "test".into(),
846
+ base_url: Some("https://api.example.com/v1".into()),
847
+ ..Default::default()
848
+ })
849
+ .unwrap();
850
+ assert_eq!(b.normalize_base_url(), "https://api.example.com");
851
+ }
852
+
853
+ #[test]
854
+ fn test_truncate_log_entry() {
855
+ let b = HttpBackend::new(HttpBackendConfig {
856
+ api_key: "test".into(),
857
+ max_log_bytes: 5,
858
+ ..Default::default()
859
+ })
860
+ .unwrap();
861
+ let t = b.truncate_log_entry("hello world");
862
+ assert!(t.ends_with(TRUNCATION_MARKER));
863
+ }
864
+
865
+ #[tokio::test]
866
+ async fn test_run_denies_without_admin_policy_context() {
867
+ let backend = test_backend();
868
+ let task = CodingTask {
869
+ repo_path: std::env::temp_dir(),
870
+ instructions: "noop".to_string(),
871
+ mode: CodingMode::ReadOnly,
872
+ timeout: Some(Duration::from_secs(2)),
873
+ max_steps: Some(1),
874
+ policy: None,
875
+ };
876
+
877
+ let result = backend.run(task).await;
878
+ match result {
879
+ Err(CodingError::PolicyError(msg)) => {
880
+ assert!(msg.contains("Policy context required"));
881
+ }
882
+ other => panic!("Expected PolicyError, got {:?}", other),
883
+ }
884
+ }
885
+
886
+ #[tokio::test]
887
+ async fn test_openai_fallback_from_responses_to_chat() {
888
+ let mut server = Server::new_async().await;
889
+
890
+ let m1 = server
891
+ .mock("POST", "/v1/responses")
892
+ .with_status(404)
893
+ .with_body(r#"{"error":"not_found"}"#)
894
+ .create_async()
895
+ .await;
896
+
897
+ let m2 = server
898
+ .mock("POST", "/v1/chat/completions")
899
+ .with_status(200)
900
+ .with_body(r#"{"choices":[{"message":{"content":"ok-from-chat"}}]}"#)
901
+ .create_async()
902
+ .await;
903
+
904
+ let backend = HttpBackend::new(HttpBackendConfig {
905
+ provider_type: ProviderType::OpenAI,
906
+ api_key: "test-key".into(),
907
+ base_url: Some(server.url()),
908
+ legacy_fallback: true,
909
+ timeout: Duration::from_secs(2),
910
+ ..Default::default()
911
+ })
912
+ .unwrap();
913
+
914
+ let response = backend
915
+ .call_openai_chat(
916
+ vec![json!({"role":"user","content":"hello"})],
917
+ vec![],
918
+ Duration::from_secs(2),
919
+ )
920
+ .await
921
+ .expect("openai fallback should succeed");
922
+
923
+ assert_eq!(response.content.as_deref(), Some("ok-from-chat"));
924
+ m1.assert_async().await;
925
+ m2.assert_async().await;
926
+ }
927
+
928
+ #[tokio::test]
929
+ async fn test_anthropic_fallback_from_v1_messages_to_messages() {
930
+ let mut server = Server::new_async().await;
931
+
932
+ let m1 = server
933
+ .mock("POST", "/v1/messages")
934
+ .with_status(404)
935
+ .with_body(r#"{"error":"not_found"}"#)
936
+ .create_async()
937
+ .await;
938
+
939
+ let m2 = server
940
+ .mock("POST", "/messages")
941
+ .with_status(200)
942
+ .with_body(r#"{"content":[{"type":"text","text":"ok-from-messages"}]}"#)
943
+ .create_async()
944
+ .await;
945
+
946
+ let backend = HttpBackend::new(HttpBackendConfig {
947
+ provider_type: ProviderType::Anthropic,
948
+ api_key: "test-key".into(),
949
+ base_url: Some(server.url()),
950
+ legacy_fallback: true,
951
+ timeout: Duration::from_secs(2),
952
+ ..Default::default()
953
+ })
954
+ .unwrap();
955
+
956
+ let response = backend
957
+ .call_anthropic_chat(
958
+ vec![
959
+ json!({"role":"system","content":"sys"}),
960
+ json!({"role":"user","content":"hello"}),
961
+ ],
962
+ vec![],
963
+ Duration::from_secs(2),
964
+ )
965
+ .await
966
+ .expect("anthropic fallback should succeed");
967
+
968
+ assert_eq!(response.content.as_deref(), Some("ok-from-messages"));
969
+ m1.assert_async().await;
970
+ m2.assert_async().await;
971
+ }
972
+
973
+ #[test]
974
+ fn test_dry_run_policy_allowed_for_admin() {
975
+ let backend = test_backend();
976
+ let task = CodingTask {
977
+ repo_path: std::env::temp_dir(),
978
+ instructions: "noop".to_string(),
979
+ mode: CodingMode::ReadOnly,
980
+ timeout: None,
981
+ max_steps: None,
982
+ policy: Some(PolicyContext::admin()),
983
+ };
984
+
985
+ let info = backend.dry_run(&task);
986
+ assert!(info.policy_allowed);
987
+ assert!(info.requires_admin);
988
+ }
989
+
990
+ #[tokio::test]
991
+ async fn test_openai_multi_tool_batch() {
992
+ let mut server = Server::new_async().await;
993
+
994
+ // First call: returns 2 tool_calls
995
+ let m1 = server
996
+ .mock("POST", "/v1/chat/completions")
997
+ .with_status(200)
998
+ .with_body(r#"{"choices":[{"message":{"content":null,"tool_calls":[
999
+ {"id":"tc1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"a.txt\"}"}},
1000
+ {"id":"tc2","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"b.txt\"}"}}
1001
+ ]}}]}"#)
1002
+ .expect(1)
1003
+ .create_async()
1004
+ .await;
1005
+
1006
+ // Second call: final text response
1007
+ let m2 = server
1008
+ .mock("POST", "/v1/chat/completions")
1009
+ .with_status(200)
1010
+ .with_body(r#"{"choices":[{"message":{"content":"done"}}]}"#)
1011
+ .expect(1)
1012
+ .create_async()
1013
+ .await;
1014
+
1015
+ // Create temp workspace with test files
1016
+ let tmp = std::env::temp_dir().join("masix_batch_test_oai");
1017
+ let _ = std::fs::create_dir_all(&tmp);
1018
+ std::fs::write(tmp.join("a.txt"), "file-a-content").unwrap();
1019
+ std::fs::write(tmp.join("b.txt"), "file-b-content").unwrap();
1020
+
1021
+ let backend = HttpBackend::new(HttpBackendConfig {
1022
+ provider_type: ProviderType::OpenAI,
1023
+ api_key: "test-key".into(),
1024
+ base_url: Some(server.url()),
1025
+ legacy_fallback: false,
1026
+ timeout: Duration::from_secs(5),
1027
+ ..Default::default()
1028
+ })
1029
+ .unwrap();
1030
+
1031
+ let task = CodingTask {
1032
+ repo_path: tmp.clone(),
1033
+ instructions: "read both files".into(),
1034
+ mode: CodingMode::ReadOnly,
1035
+ timeout: Some(Duration::from_secs(5)),
1036
+ max_steps: Some(5),
1037
+ policy: Some(PolicyContext::admin()),
1038
+ };
1039
+
1040
+ let result = backend.run(task).await.expect("should succeed");
1041
+
1042
+ // Verify both tool calls were executed (2 tool_call + 2 tool_result events)
1043
+ let tool_call_events: Vec<_> = result
1044
+ .events
1045
+ .iter()
1046
+ .filter(|e| e.event == "tool_call")
1047
+ .collect();
1048
+ let tool_result_events: Vec<_> = result
1049
+ .events
1050
+ .iter()
1051
+ .filter(|e| e.event == "tool_result")
1052
+ .collect();
1053
+ assert_eq!(tool_call_events.len(), 2, "Expected 2 tool_call events");
1054
+ assert_eq!(tool_result_events.len(), 2, "Expected 2 tool_result events");
1055
+
1056
+ // Verify logs contain both tool executions
1057
+ assert_eq!(
1058
+ result.logs.len(),
1059
+ 2,
1060
+ "Expected 2 log entries, got {:?}",
1061
+ result.logs
1062
+ );
1063
+
1064
+ m1.assert_async().await;
1065
+ m2.assert_async().await;
1066
+
1067
+ let _ = std::fs::remove_dir_all(&tmp);
1068
+ }
1069
+
1070
+ #[tokio::test]
1071
+ async fn test_anthropic_multi_tool_batch() {
1072
+ let mut server = Server::new_async().await;
1073
+
1074
+ // First call: returns 2 tool_use blocks
1075
+ let m1 = server
1076
+ .mock("POST", "/v1/messages")
1077
+ .with_status(200)
1078
+ .with_body(
1079
+ r#"{"content":[
1080
+ {"type":"tool_use","id":"tu1","name":"read_file","input":{"path":"a.txt"}},
1081
+ {"type":"tool_use","id":"tu2","name":"read_file","input":{"path":"b.txt"}}
1082
+ ]}"#,
1083
+ )
1084
+ .expect(1)
1085
+ .create_async()
1086
+ .await;
1087
+
1088
+ // Second call: final text response
1089
+ let m2 = server
1090
+ .mock("POST", "/v1/messages")
1091
+ .with_status(200)
1092
+ .with_body(r#"{"content":[{"type":"text","text":"done"}]}"#)
1093
+ .expect(1)
1094
+ .create_async()
1095
+ .await;
1096
+
1097
+ let tmp = std::env::temp_dir().join("masix_batch_test_ant");
1098
+ let _ = std::fs::create_dir_all(&tmp);
1099
+ std::fs::write(tmp.join("a.txt"), "file-a-content").unwrap();
1100
+ std::fs::write(tmp.join("b.txt"), "file-b-content").unwrap();
1101
+
1102
+ let backend = HttpBackend::new(HttpBackendConfig {
1103
+ provider_type: ProviderType::Anthropic,
1104
+ api_key: "test-key".into(),
1105
+ base_url: Some(server.url()),
1106
+ legacy_fallback: false,
1107
+ timeout: Duration::from_secs(5),
1108
+ ..Default::default()
1109
+ })
1110
+ .unwrap();
1111
+
1112
+ let task = CodingTask {
1113
+ repo_path: tmp.clone(),
1114
+ instructions: "read both files".into(),
1115
+ mode: CodingMode::ReadOnly,
1116
+ timeout: Some(Duration::from_secs(5)),
1117
+ max_steps: Some(5),
1118
+ policy: Some(PolicyContext::admin()),
1119
+ };
1120
+
1121
+ let result = backend.run(task).await.expect("should succeed");
1122
+
1123
+ // Verify both tool calls were executed
1124
+ let tool_call_events: Vec<_> = result
1125
+ .events
1126
+ .iter()
1127
+ .filter(|e| e.event == "tool_call")
1128
+ .collect();
1129
+ let tool_result_events: Vec<_> = result
1130
+ .events
1131
+ .iter()
1132
+ .filter(|e| e.event == "tool_result")
1133
+ .collect();
1134
+ assert_eq!(tool_call_events.len(), 2, "Expected 2 tool_call events");
1135
+ assert_eq!(tool_result_events.len(), 2, "Expected 2 tool_result events");
1136
+
1137
+ m1.assert_async().await;
1138
+ m2.assert_async().await;
1139
+
1140
+ let _ = std::fs::remove_dir_all(&tmp);
1141
+ }
1142
+
1143
+ #[tokio::test]
1144
+ async fn test_single_tool_regression() {
1145
+ let mut server = Server::new_async().await;
1146
+
1147
+ // Single tool call - regression test after batch fix
1148
+ let m1 = server
1149
+ .mock("POST", "/v1/chat/completions")
1150
+ .with_status(200)
1151
+ .with_body(r#"{"choices":[{"message":{"content":null,"tool_calls":[
1152
+ {"id":"tc1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"test.txt\"}"}}
1153
+ ]}}]}"#)
1154
+ .expect(1)
1155
+ .create_async()
1156
+ .await;
1157
+
1158
+ let m2 = server
1159
+ .mock("POST", "/v1/chat/completions")
1160
+ .with_status(200)
1161
+ .with_body(r#"{"choices":[{"message":{"content":"single tool ok"}}]}"#)
1162
+ .expect(1)
1163
+ .create_async()
1164
+ .await;
1165
+
1166
+ let tmp = std::env::temp_dir().join("masix_single_tool_test");
1167
+ let _ = std::fs::create_dir_all(&tmp);
1168
+ std::fs::write(tmp.join("test.txt"), "hello").unwrap();
1169
+
1170
+ let backend = HttpBackend::new(HttpBackendConfig {
1171
+ provider_type: ProviderType::OpenAI,
1172
+ api_key: "test-key".into(),
1173
+ base_url: Some(server.url()),
1174
+ legacy_fallback: false,
1175
+ timeout: Duration::from_secs(5),
1176
+ ..Default::default()
1177
+ })
1178
+ .unwrap();
1179
+
1180
+ let task = CodingTask {
1181
+ repo_path: tmp.clone(),
1182
+ instructions: "read file".into(),
1183
+ mode: CodingMode::ReadOnly,
1184
+ timeout: Some(Duration::from_secs(5)),
1185
+ max_steps: Some(5),
1186
+ policy: Some(PolicyContext::admin()),
1187
+ };
1188
+
1189
+ let result = backend.run(task).await.expect("should succeed");
1190
+ assert_eq!(result.summary, "single tool ok");
1191
+ assert_eq!(result.logs.len(), 1);
1192
+
1193
+ m1.assert_async().await;
1194
+ m2.assert_async().await;
1195
+
1196
+ let _ = std::fs::remove_dir_all(&tmp);
1197
+ }
1198
+ }