Skip to main content

oxicode_sdk/
lib.rs

1//! oxicode SDK - Programmatic API for building AI agents
2//!
3//! # Example
4//! ```
5//! use oxicode_sdk::{OxicodeBuilder, AgentConfig};
6//!
7//! let oxicode = OxicodeBuilder::new().with_builtins().build();
8//! let agent = oxicode.agent(AgentConfig {
9//!     model_id: "anthropic/claude-sonnet-4-20250514".into(),
10//!     ..Default::default()
11//! }).build().unwrap();
12//! ```
13#![warn(missing_docs)]
14// Shipped (non-test) code denies the panic-family lints; test code keeps
15// idiomatic `unwrap()`/`expect()`/`panic!("Expected X")` match-arm assertions.
16#![cfg_attr(
17    not(test),
18    deny(clippy::expect_used, clippy::panic, clippy::unwrap_used)
19)]
20#![cfg_attr(
21    test,
22    allow(
23        clippy::unwrap_used,
24        clippy::field_reassign_with_default,
25        clippy::expect_used,
26        clippy::panic,
27    )
28)]
29// Stability tier attribute macros. Renamed to avoid shadowing the (nightly-only,
30// but rustc-resolved) builtin `#[stable]/#[unstable]/#[deprecated]`. The proc-
31// macro crate's own rustdoc recommends this rename pattern.
32use oxicode_api_stability::{internal as oxicode_internal, stable as oxicode_stable};
33// `oxicode_unstable` is only referenced on feature-gated re-export blocks; in the
34// default (no-feature) build none survive, so the import is conditionally unused.
35#[allow(unused_imports)]
36use oxicode_api_stability::unstable as oxicode_unstable;
37
38pub mod agent_builder;
39pub mod agent_definition;
40pub mod agent_group;
41pub mod bridge;
42pub mod builder;
43pub mod closure_tool;
44pub mod coordination;
45pub mod delegation;
46pub mod error;
47pub mod event_bus;
48pub mod kernel_bridge;
49pub mod lifecycle;
50pub mod message_bus;
51pub mod metrics;
52pub mod middleware;
53pub mod observability;
54pub mod ports;
55
56// Reference implementations bundled with the SDK.
57#[oxicode_internal]
58pub use ports::{fs, inmem};
59pub mod port_memory_backend;
60/// Convenience re-exports: `oxicode_sdk::fs::*`, `oxicode_sdk::inmem::*`.
61pub mod prelude;
62pub mod routing;
63pub mod security;
64pub mod snapcompact_compactor;
65pub mod tool_factory;
66pub mod url_resolver;
67pub mod workflow_dsl;
68pub mod workflow_engine;
69
70// Re-export core SDK types
71#[oxicode_stable(since = "0.63.0")]
72pub use agent_builder::AgentBuilder;
73#[oxicode_stable(since = "0.63.0")]
74pub use agent_group::{AgentGroup, AgentGroupOutput, GroupResult, GroupStrategy};
75#[oxicode_stable(since = "0.63.0")]
76pub use builder::{Oxicode, OxicodeBuilder};
77#[cfg(feature = "delegation")]
78#[oxicode_unstable(feature = "delegation")]
79pub use delegation::SdkSubagentRunner;
80#[cfg(feature = "url-resolver")]
81#[oxicode_unstable(feature = "url-resolver")]
82pub use url_resolver::SdkUrlResolver;
83#[cfg(feature = "workflow-dsl")]
84#[oxicode_unstable(feature = "workflow-dsl")]
85pub use workflow_engine::{StepOutput, WorkflowEngine, WorkflowResult};
86
87// Re-export port types — products implement these traits.
88// Note: Some names conflict with existing modules (e.g. `EventBus` is also a
89// module, `MemoryEntry` is also in `coordination`). We rename on import to
90// avoid ambiguity; users can still access the trait via the explicit path
91// `oxicode_sdk::ports::EventBus` if they need to disambiguate.
92#[oxicode_stable(since = "0.63.0")]
93pub use closure_tool::ClosureTool;
94#[oxicode_internal]
95pub use kernel_bridge::{KernelToolContext, KernelToolProvider};
96#[oxicode_stable(since = "0.63.0")]
97pub use message_bus::{InterAgentMessage, LagAwareReceiver, MessageBus, PublishResult};
98#[oxicode_stable(since = "0.63.0")]
99pub use metrics::{AgentMetrics, MetricsSnapshot};
100#[oxicode_stable(since = "0.63.0")]
101pub use ports::AccessGate as AccessGatePort;
102#[oxicode_stable(since = "0.63.0")]
103pub use ports::EventBus as EventBusPort;
104#[oxicode_stable(since = "0.63.0")]
105pub use ports::MemoryEntry as MemoryEntryPort;
106#[oxicode_stable(since = "0.63.0")]
107pub use ports::{
108    AccessDecision, AuthMethod, AuthProvider, CapabilityResolver, ConfigStore, CronJob,
109    CronScheduler, EventPayload, EventTopic, InMemoryEventBus, MemoryStore, NoopAuthProvider,
110    NoopConfigStore, NoopCronScheduler, NoopEventBus, NoopMemoryStore, NoopPersonaProvider,
111    NoopResourceMonitor, NoopSkillLoader, NoopStateStore, OAuthToken, Persona, PersonaProvider,
112    PortId, PortRegistry, PortValue, ResourceMonitor, ResourceUsage, Skill, SkillLoader, SkillMeta,
113    StateStore, SubscriptionHandle, ToolCallRequest,
114};
115
116// Port 16 — HookRunner.
117#[oxicode_stable(since = "0.66.0")]
118pub use ports::hooks::{HookContext, HookEvent, HookOutcome, HookRunner, HookSpec, NoopHookRunner};
119
120// Catalog port (Port 12).
121#[oxicode_stable(since = "0.63.0")]
122pub use ports::catalog::{
123    CatalogEvent, CatalogModelEntry, CatalogProtocol, CatalogProviderEntry, CatalogSource,
124    ModelCatalog, NoopModelCatalog, RefreshOutcome,
125};
126// File-backed reference impl for the catalog port.
127#[oxicode_internal]
128pub use ports::fs::catalog::{CatalogConfig, FileModelCatalog};
129
130// Composition Layer — EventBus
131#[oxicode_stable(since = "0.63.0")]
132pub use event_bus::EventBus;
133
134// Foundation Layer
135#[oxicode_stable(since = "0.63.0")]
136pub use error::{SdkError, SdkResult};
137#[oxicode_stable(since = "0.63.0")]
138pub use lifecycle::{
139    AgentHandle, AgentLifecycleEvent, AgentPool, AgentSnapshot, AgentStatus, AgentSupervisor,
140    FileSnapshotStore, HubKind, HubStatus, RestartBackoff, SnapshotStore, SupervisorPolicy,
141    ToolManifest,
142};
143#[oxicode_stable(since = "0.63.0")]
144pub use middleware::Middleware;
145#[oxicode_stable(since = "0.63.0")]
146pub use middleware::{
147    MiddlewareContext, MiddlewareData, MiddlewarePhase, MiddlewarePipeline, MiddlewareResult,
148    build_hooks,
149};
150#[oxicode_stable(since = "0.63.0")]
151pub use observability::{
152    AuditAction, AuditEntry, AuditError, AuditFilter, AuditLog, AuditPersistence, AuditTrail,
153    CostBreakdown, CostSnapshot, CostTracker, CostTrackerConfig, EventQuery, EventStore,
154    EventStoreConfig, GlobalCostSnapshot, HashDigest, Span, SpanContext, SpanGuard, SpanId,
155    SpanKind, SpanStatus, StoredEvent, TokenUsage, TraceId, Tracer, TrailEntry,
156};
157
158#[oxicode_stable(since = "0.63.0")]
159// Composition Layer — Security
160pub use security::{
161    AccessDenied, AccessGate, Action, AgentContext, AgentPermissions, AllowlistMode,
162    ApprovalStatus, AuditEvent, AuditSink, Authorizer, Capability, CapabilitySet,
163    CapabilitySubject, CheckRequest, DefaultPolicy, DenyLayer, ExecPolicy, PathMode,
164    PendingApproval, PermAuditEntry, PermissionUpdate, RbacAuditEntry, RbacManager, RbacPolicy,
165    Role, SecurityMiddleware, StringPattern, Subject, TracingAuditSink, TrailAuditSink,
166};
167
168#[oxicode_stable(since = "0.63.0")]
169// Composition Layer — Coordination
170pub use coordination::{
171    Consensus, CoordinatedGroup, CoordinatedGroupBuilder, MemoryEntry, MemoryEvent, MemoryKey,
172    SharedMemory, VoteResult, WorkEvent, WorkItem, WorkQueue, WorkQueueConfig, WorkQueueStats,
173    WorkResult, WorkStatus,
174};
175
176#[oxicode_stable(since = "0.63.0")]
177// Runtime routing control
178pub use routing::RoutingControl;
179
180#[oxicode_stable(since = "0.63.0")]
181// Re-export from oxicode-ai
182pub use oxicode_ai::{
183    Api, CompactionStrategy, ContentBlock, Context, Cost, InputModality, Message, MessageContent,
184    Model, ModelRegistry, Provider, ProviderError, ProviderEvent, ProviderOptions,
185    ProviderRegistry, StreamOptions, UserMessage,
186};
187// Model roles + role switching (ported from omp)
188#[cfg(feature = "role-routing")]
189#[oxicode_unstable(feature = "role-routing")]
190pub use oxicode_ai::role_routing::RoleRoutingProvider;
191#[cfg(feature = "role-switching")]
192#[oxicode_unstable(feature = "role-switching")]
193pub use oxicode_ai::role_switcher::{
194    RoleSignals, decide_role, resolve_role_to_model, role_for_tool,
195};
196#[cfg(feature = "role-routing")]
197#[oxicode_unstable(feature = "role-routing")]
198pub use oxicode_ai::roles::{ModelRole, RoleRegistry, live_role_registry, set_live_role_registry};
199
200#[oxicode_stable(since = "0.63.0")]
201// Credential management (oauth + env key resolution)
202pub use oxicode_ai::env_api_keys::{find_env_keys, get_all_env_keys, get_env_api_key, has_env_key};
203
204#[oxicode_internal]
205// Model database — provider catalog, model metadata
206pub use oxicode_ai::model_db::{
207    ModelEntry, builtin_model_count_sentinel, get_all_models, get_cheapest_models, get_model_entry,
208    get_provider_models, get_providers, get_reasoning_models, get_vision_models, model_count,
209    search_models,
210};
211
212// Catalog — models.dev-backed dynamic catalog (SNAP/LIVE/override/LOCAL).
213// The catalog module exposes the full surface; `model_db` (above) is the
214// legacy compatibility shim that integrates all layers and converts
215#[oxicode_stable(since = "0.63.0")]
216// BuiltinModelEntry → ModelEntry.
217pub use oxicode_ai::catalog::{
218    BuiltinModelEntry, BuiltinProviderEntry, OverrideFile, apply_model_overrides,
219    apply_provider_overrides, builtin_model_count, builtin_providers_count, discover_all,
220    discover_all_authenticated, discover_all_local, discover_models, find_override_files,
221    load_builtin_providers, load_overrides,
222};
223#[oxicode_stable(since = "0.63.0")]
224pub use oxicode_ai::oauth::{
225    AuthStore, OAuthError, TokenBundle, default_auth_path, load_auth_store, load_token,
226    remove_token, save_auth_store, save_token,
227};
228
229// Provider registry — built-in providers (Layer 1 of the catalog) and
230#[oxicode_stable(since = "0.63.0")]
231// the runtime functions that surface them to consumers.
232pub use oxicode_ai::register_builtins::{
233    BuiltinProvider, get_all_provider_aliases, get_all_provider_names, get_api_mappings,
234    get_builtin_provider, get_builtin_providers, get_provider_api, get_provider_base_url,
235    get_provider_env_key, get_provider_env_keys, is_builtin_provider, resolve_provider_name,
236};
237
238// Provider instance registry (custom + built-in at runtime)
239#[oxicode_stable(since = "0.63.0")]
240pub use oxicode_ai::{
241    create_builtin_provider, create_builtin_provider_with_options, custom_provider_names,
242    dynamic_models, fetch_models_async, fetch_models_blocking, get_model, get_models, get_provider,
243    get_provider_arc, lookup_model, register_model, register_provider, unregister_provider,
244};
245
246// Complexity-based routing and the router module
247#[cfg(feature = "router")]
248#[oxicode_unstable(feature = "router")]
249pub use oxicode_ai::router;
250
251// Tool-related types (oxicode-cli's main.rs uses ToolCall, ToolResult, ToolCallType)
252#[oxicode_stable(since = "0.63.0")]
253pub use oxicode_ai::{
254    ProgressCallback, Tool, ToolCall, ToolCallType, ToolResult, ToolValidationError, validate_args,
255};
256
257// Thinking level (re-exported from oxicode_ai::types, since oxicode-ai's top-level
258// re-exports it via `pub use types::*` but not as a named item).
259#[oxicode_stable(since = "0.63.0")]
260pub use oxicode_ai::types::ThinkingLevel;
261
262// Re-export from oxicode-agent
263#[oxicode_stable(since = "0.63.0")]
264pub use oxicode_agent::{
265    Agent, AgentConfig, AgentError, AgentEvent, AgentHooks, AgentLoop, AgentLoopConfig, AgentState,
266    AgentTool, AgentToolResult, BrowseProgress, BrowseProgressCallback, CompactedContext,
267    CompactionEvent, CompactionHook, EditTool, FindTool, GetSearchResultsTool, GrepTool, LsTool,
268    Mode, OutputMode, ProviderResolver, ReadTool, SearchCache, SharedState, StreamDelta,
269    StructuredOutput, StructuredOutputError, ToolCallContext, ToolContext, ToolError,
270    ToolExecutionMode, ToolRegistry, VisitReason, WebSearchTool, WriteTool,
271};
272// ── Advisor subsystem (read-only reviewer that shadows the primary agent) ─
273//
274// SDK consumers can construct a full advisor: build a second `Agent` with the
275// advisor model role + read-only tools + an `AdviseTool` (carrying an
276// `EnqueueAdviceFn`), then drive it with `AdvisorRuntime`. The emission guard
277#[cfg(feature = "advisor")]
278#[oxicode_unstable(feature = "advisor")]
279pub use oxicode_agent::advisor::{
280    ADVISOR_GUIDANCE, ADVISOR_READONLY_TOOL_NAMES, ADVISOR_SYSTEM_PROMPT, AdviseTool, AdvisorAgent,
281    AdvisorDeliveryChannel, AdvisorEmissionGuard, AdvisorNote, AdvisorRuntime, AdvisorRuntimeHost,
282    AdvisorSeverity, AgentAdvisor, DeliveryOpts, EnqueueAdviceFn, format_advisory_batch,
283    is_immune_turn_active, is_interrupting_severity, normalize_advisor_note,
284    resolve_delivery_channel,
285};
286// ── Todo tool types (agent-scoped, observable by SDK consumers) ──────
287#[oxicode_stable(since = "0.63.0")]
288pub use oxicode_agent::tools::todo::{TodoItem, TodoOp, TodoPhase, TodoStatus, TodoUpdateResult};
289#[oxicode_stable(since = "0.63.0")]
290pub use oxicode_agent::{TodoStateProvider, TodoTool};
291
292// ── Capability traits + agent tools (single-dependency pattern) ───────
293//
294// A pure-`oxicode-sdk` consumer (no `oxicode-agent` direct dep) can implement these
295// capability traits to wire the agent's tools, and register the tool structs
296// directly. Without these re-exports the single-dependency pattern
297// (`oxios → oxicode-sdk`) is incomplete: implementing a custom memory backend,
298// URL resolver, LSP provider, subagent runner, or agent-pool source would
299// otherwise force a direct `oxicode-agent` dependency.
300
301/// `MemoryBackend` backed by the SDK `MemoryStore` + `EmbeddingProvider` ports.
302#[cfg(feature = "memory")]
303#[oxicode_unstable(feature = "memory")]
304pub use crate::port_memory_backend::PortMemoryBackend;
305/// `BashTool` (read/write/edit/grep/find/ls are already re-exported above and
306/// bundled by `coding_tools()`).
307#[oxicode_stable(since = "0.63.0")]
308pub use oxicode_agent::BashTool;
309/// In-process subagent runner trait (see [`crate::delegation::SdkSubagentRunner`]).
310#[cfg(feature = "subagent")]
311#[oxicode_unstable(feature = "subagent")]
312pub use oxicode_agent::SubagentRunner;
313/// Memory backend trait + item — implement to back the `memory_*` tools, or
314/// use [`PortMemoryBackend`] to bridge the SDK's `MemoryStore` port.
315#[cfg(feature = "memory")]
316#[oxicode_unstable(feature = "memory")]
317pub use oxicode_agent::tools::{MemoryBackend, MemoryItem};
318/// The `memory_*` + `subagent` tool structs (register directly if desired).
319#[cfg(feature = "memory")]
320#[oxicode_unstable(feature = "memory")]
321pub use oxicode_agent::tools::{
322    MemoryEditTool, MemoryRecallTool, MemoryReflectTool, MemoryRetainTool, SubagentTool,
323};
324/// URL resolver trait + resolved content — implement for internal-URL dispatch.
325#[oxicode_stable(since = "0.63.0")]
326pub use oxicode_agent::tools::{ResolvedContent, UrlResolver};
327/// Agent-pool source for Hub display + todo sub-agent matching.
328#[cfg(feature = "agent-hub")]
329#[oxicode_unstable(feature = "agent-hub")]
330pub use oxicode_agent::{AgentHubStatus, AgentInfo, AgentKind, AgentPoolProvider};
331/// LSP capability — implement to back the `lsp` tool.
332#[cfg(feature = "lsp")]
333#[oxicode_unstable(feature = "lsp")]
334pub use oxicode_agent::{LspAction, LspProvider};
335
336// Re-export the hashline crate so consumers can implement `SnapshotStore`
337// (enables line-anchored edit mode) without a direct `oxicode-hashline` dep.
338#[oxicode_internal]
339pub use oxicode_hashline;
340
341// ── Concrete provider re-exports ─────────────────────────────────────────
342//
343// SDK consumers can construct providers directly without depending on
344// `oxicode-ai`. This enables the single-dependency pattern:
345//   oxios → oxicode-sdk  (no oxicode-ai, no oxicode-agent direct dep)
346
347#[oxicode_stable(since = "0.63.0")]
348pub use oxicode_ai::OpenAiProvider;
349#[oxicode_stable(since = "0.63.0")]
350pub use oxicode_ai::OpenAiResponsesProvider;
351
352// ── MCP (Model Context Protocol) re-exports ───────────────────────────
353//
354// SDK consumers can use MCP servers alongside built-in tools.
355// `McpManager::spawn()` creates a manager; `OxicodeBuilder::with_mcp_config()`
356// injects a programmatic config; `mcp_tools()` auto-discovers from
357// standard config files. See `oxicode_sdk::tool_factory::mcp_tools`.
358
359#[oxicode_stable(since = "0.63.0")]
360pub use oxicode_agent::mcp::{
361    ConsentManager, ConsentState, DirectToolDef, DirectToolsConfig, LifecycleMode, McpCallResult,
362    McpConfig, McpConnectionStatus, McpContent, McpDashboardData, McpDirectTool, McpManager,
363    McpSamplingRequest, McpServerInfo, McpSettings, McpSettingsView, McpTool, McpToolDef,
364    McpToolInfo, MetadataCache, ServerEntry, ToolMetadata, ToolPrefix,
365};
366
367// Transport layer — re-exported so consumers can implement custom transports
368// without a direct `oxicode-agent` dependency. See
369// `docs/oxicode-sdk-ownership.md` §2 (MCP transport is SDK-owned behavior).
370#[cfg(feature = "mcp-transport")]
371#[oxicode_unstable(feature = "mcp-transport")]
372pub use oxicode_agent::mcp::transport::{
373    McpTransport, http::StreamableHttpTransport, stdio::StdioTransport,
374};
375
376// Spawn validation policy — composable trait. SDK owns the trait + noop impl;
377// consumers (oxicode-cli, oxios) register their own policy. See
378// `docs/oxicode-sdk-ownership.md` §2.
379#[cfg(feature = "mcp-spawn-validator")]
380#[oxicode_unstable(feature = "mcp-spawn-validator")]
381pub use oxicode_agent::mcp::{NoopSpawnValidator, SpawnValidator};
382
383// Circuit-breaker behavior trait + reference impl. SDK owns the trait;
384// consumers implement for their domain (A2A, HTTP, etc.). See
385// `docs/oxicode-sdk-ownership.md` §3.
386#[cfg(feature = "circuit-breaker")]
387#[oxicode_unstable(feature = "circuit-breaker")]
388pub use oxicode_ai::circuit_breaker::{
389    BreakerError, BreakerState, CircuitBreaker, DefaultCircuitBreaker, SharedBreaker,
390};
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use std::path::Path;
396
397    #[test]
398    fn catalog_reachable_via_sdk() {
399        // Verify the catalog surface is exposed to SDK consumers.
400        // Models now come from the materialized snapshot (models.dev),
401        // not from load_builtin_models() (which is an empty legacy map).
402        let providers = load_builtin_providers();
403        assert!(
404            providers.len() >= 70,
405            "expected >= 70 providers, got {}",
406            providers.len()
407        );
408        // Materialized catalog should have models.
409        let all = get_all_models().collect::<Vec<_>>();
410        assert!(
411            !all.is_empty(),
412            "models should be loaded via get_all_models()"
413        );
414        assert!(
415            all.len() > 5000,
416            "expected >5000 materialized models, got {}",
417            all.len()
418        );
419    }
420
421    #[test]
422    fn sentinel_count_via_sdk() {
423        let n = builtin_model_count_sentinel();
424        // With materialize from models.dev, no sentinel pricing is applied
425        // (models.dev is the verified source of truth).
426        assert_eq!(
427            n, 0,
428            "expected 0 sentinel entries with materialize, got {n}"
429        );
430    }
431
432    /// Helper to build a minimal Model for tests.
433    fn test_model(id: &str, provider: &str) -> Model {
434        Model::new(
435            id,
436            id,
437            Api::AnthropicMessages,
438            provider,
439            "https://api.example.com",
440        )
441    }
442
443    #[test]
444    fn test_oxicode_builder_new() {
445        let oxicode = OxicodeBuilder::new().build();
446        // Empty registry — no models
447        assert!(
448            oxicode
449                .resolve_model("anthropic/claude-sonnet-4-20250514")
450                .is_err()
451        );
452    }
453
454    #[test]
455    fn test_oxicode_builder_with_builtins() {
456        let oxicode = OxicodeBuilder::new().with_builtins().build();
457        // Should have built-in models
458        assert!(
459            oxicode
460                .resolve_model("anthropic/claude-sonnet-4-20250514")
461                .is_ok()
462        );
463        assert!(oxicode.resolve_model("openai/gpt-4o").is_ok());
464    }
465
466    #[test]
467    fn test_oxicode_builder_custom_model() {
468        let oxicode = OxicodeBuilder::new()
469            .model(test_model("test-model", "test-provider"))
470            .build();
471        assert!(oxicode.resolve_model("test-provider/test-model").is_ok());
472    }
473
474    #[test]
475    fn test_oxicode_provider_resolution() {
476        let oxicode = OxicodeBuilder::new().with_builtins().build();
477        // Built-in provider (falls back to built-in registry)
478        assert!(oxicode.create_provider("anthropic").is_ok());
479        // Unknown provider
480        assert!(oxicode.create_provider("nonexistent").is_err());
481    }
482
483    #[test]
484    fn test_agent_builder_workspace() {
485        let oxicode = OxicodeBuilder::new().with_builtins().build();
486        let config = AgentConfig {
487            model_id: "anthropic/claude-sonnet-4-20250514".into(),
488            timeout_seconds: 30,
489            ..Default::default()
490        };
491        // AgentBuilder with workspace — should not panic
492        let result = oxicode
493            .agent(config)
494            .workspace("/tmp/test-workspace")
495            .build();
496        assert!(result.is_ok() || result.is_err());
497    }
498
499    #[test]
500    fn test_agent_builder_coding_tools() {
501        let oxicode = OxicodeBuilder::new().with_builtins().build();
502        let config = AgentConfig {
503            model_id: "anthropic/claude-sonnet-4-20250514".into(),
504            timeout_seconds: 30,
505            ..Default::default()
506        };
507        let result = oxicode
508            .agent(config)
509            .workspace("/tmp")
510            .coding_tools()
511            .build();
512        if let Ok(agent) = result {
513            let tool_names = agent.tools().names();
514            assert!(tool_names.contains(&"read".to_string()));
515            assert!(tool_names.contains(&"write".to_string()));
516            assert!(tool_names.contains(&"edit".to_string()));
517            assert!(tool_names.contains(&"ls".to_string()));
518        }
519    }
520
521    #[test]
522    fn test_agent_builder_readonly_tools() {
523        let oxicode = OxicodeBuilder::new().with_builtins().build();
524        let config = AgentConfig {
525            model_id: "anthropic/claude-sonnet-4-20250514".into(),
526            timeout_seconds: 30,
527            ..Default::default()
528        };
529        let result = oxicode
530            .agent(config)
531            .workspace("/tmp")
532            .readonly_tools()
533            .build();
534        if let Ok(agent) = result {
535            let tool_names = agent.tools().names();
536            assert!(tool_names.contains(&"read".to_string()));
537            assert!(tool_names.contains(&"ls".to_string()));
538            // Should NOT have write/edit
539            assert!(!tool_names.contains(&"write".to_string()));
540        }
541    }
542
543    #[test]
544    fn test_model_registry_isolation() {
545        // Two separate Oxicode instances should not share state
546        let oxicode1 = OxicodeBuilder::new()
547            .model(test_model("unique-1", "test"))
548            .build();
549
550        let oxicode2 = OxicodeBuilder::new().with_builtins().build();
551
552        // oxicode2 should NOT have oxicode1's custom model
553        assert!(oxicode2.resolve_model("test/unique-1").is_err());
554        // oxicode1 should have its custom model
555        assert!(oxicode1.resolve_model("test/unique-1").is_ok());
556    }
557
558    #[test]
559    fn test_tool_factory_coding_tools() {
560        let tools = crate::tool_factory::coding_tools(Path::new("/tmp"));
561        let names = tools.names();
562        assert!(names.contains(&"read".to_string()));
563        assert!(names.contains(&"write".to_string()));
564        assert!(names.contains(&"edit".to_string()));
565        assert!(names.contains(&"ls".to_string()));
566        assert_eq!(names.len(), 4);
567    }
568
569    #[test]
570    fn test_tool_factory_readonly_tools() {
571        let tools = crate::tool_factory::readonly_tools(Path::new("/tmp"));
572        let names = tools.names();
573        assert!(names.contains(&"read".to_string()));
574        assert!(names.contains(&"ls".to_string()));
575        assert_eq!(names.len(), 2);
576    }
577
578    #[test]
579    fn test_tool_registry_extend_from() {
580        let base = crate::tool_factory::coding_tools(Path::new("/tmp"));
581        let extra = crate::tool_factory::readonly_tools(Path::new("/tmp"));
582
583        // extend_from should add tools from extra into base
584        let combined = ToolRegistry::new();
585        combined.extend_from(&base);
586        combined.extend_from(&extra);
587
588        let names = combined.names();
589        assert!(names.contains(&"read".to_string()));
590        assert!(names.contains(&"write".to_string()));
591        assert!(names.contains(&"ls".to_string()));
592        // read and ls are in both — no duplicates expected since same names
593    }
594
595    // ── Phase 2+ Tests: ProviderResolver, ClosureTool, Isolation ──
596
597    #[test]
598    fn test_provider_resolver_trait_on_oxicode() {
599        let oxicode = OxicodeBuilder::new().with_builtins().build();
600        // Oxicode implements ProviderResolver
601        let resolver: &dyn ProviderResolver = &oxicode;
602        assert!(resolver.resolve_provider("anthropic").is_some());
603        assert!(resolver.resolve_provider("nonexistent").is_none());
604        assert!(
605            resolver
606                .resolve_model("anthropic/claude-sonnet-4-20250514")
607                .is_some()
608        );
609        assert!(resolver.resolve_model("nonexistent/model").is_none());
610    }
611
612    #[test]
613    fn test_agent_uses_resolver_for_switch_model() {
614        // Create isolated Oxicode with only a mock model
615        let oxicode = OxicodeBuilder::new()
616            .model(test_model("test-model", "test-provider"))
617            .build();
618
619        // This should fail because 'anthropic' provider isn't registered
620        let config = AgentConfig {
621            model_id: "test-provider/test-model".into(),
622            timeout_seconds: 5,
623            ..Default::default()
624        };
625        let result = oxicode.agent(config).build();
626        // Agent build fails because provider 'test-provider' has no implementation
627        // (no custom provider registered, no builtins enabled)
628        assert!(result.is_err());
629    }
630
631    #[test]
632    fn test_oxicode_builder_without_builtins() {
633        let oxicode = OxicodeBuilder::new().build();
634        // No models, no providers
635        assert!(
636            oxicode
637                .resolve_model("anthropic/claude-sonnet-4-20250514")
638                .is_err()
639        );
640        assert!(oxicode.create_provider("anthropic").is_err());
641        assert!(!oxicode.has_builtins());
642    }
643
644    #[test]
645    fn test_oxicode_builder_with_builtins_creates_providers() {
646        let oxicode = OxicodeBuilder::new().with_builtins().build();
647        assert!(oxicode.has_builtins());
648        // Built-in provider fallback should work
649        assert!(oxicode.create_provider("anthropic").is_ok());
650        assert!(oxicode.create_provider("openai").is_ok());
651        assert!(oxicode.create_provider("deepseek").is_ok());
652        // Unknown still fails
653        assert!(oxicode.create_provider("unknown-provider").is_err());
654    }
655
656    /// Regression (#40): `Oxicode::create_provider` must consult the wired
657    /// `AuthProvider` port via its sync fast-path when the static
658    /// `OxicodeBuilder::api_key()` map has no entry for the provider. This is
659    /// the **primary** credential source for products like the CLI, which
660    /// never call `OxicodeBuilder::api_key()` and instead register
661    /// `FileAuthProvider` via `.with_auth(...)`. Without this wiring, every
662    /// built-in provider would fall through to env vars and the CLI would
663    /// fail with `MissingApiKey` for any provider not in the environment.
664    #[test]
665    fn test_create_provider_consults_auth_port() {
666        use parking_lot::Mutex;
667        use std::pin::Pin;
668
669        /// Recording AuthProvider — captures every `get_api_key_sync` call
670        /// and returns `None`, forcing the wiring to actually consult us
671        /// (rather than short-circuiting on a returned key).
672        struct RecordingAuth {
673            calls: Mutex<Vec<String>>,
674        }
675        impl AuthProvider for RecordingAuth {
676            fn get_api_key(
677                &self,
678                _provider: &str,
679            ) -> Pin<Box<dyn Future<Output = Result<Option<String>, SdkError>> + Send + '_>>
680            {
681                Box::pin(async { Ok(None) })
682            }
683            fn get_api_key_sync(&self, provider: &str) -> Result<Option<String>, SdkError> {
684                self.calls.lock().push(provider.to_string());
685                Ok(None)
686            }
687            fn set_api_key(
688                &self,
689                _: &str,
690                _: &str,
691            ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>> {
692                Box::pin(async { Ok(()) })
693            }
694            fn delete_api_key(
695                &self,
696                _: &str,
697            ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>> {
698                Box::pin(async { Ok(()) })
699            }
700            fn get_oauth(
701                &self,
702                _: &str,
703            ) -> Pin<
704                Box<
705                    dyn Future<Output = Result<Option<crate::ports::OAuthToken>, SdkError>>
706                        + Send
707                        + '_,
708                >,
709            > {
710                Box::pin(async { Ok(None) })
711            }
712            fn set_oauth(
713                &self,
714                _: &str,
715                _: crate::ports::OAuthToken,
716            ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>> {
717                Box::pin(async { Ok(()) })
718            }
719            fn list_providers(
720                &self,
721            ) -> Pin<Box<dyn Future<Output = Result<Vec<String>, SdkError>> + Send + '_>>
722            {
723                Box::pin(async { Ok(Vec::new()) })
724            }
725        }
726
727        let auth = std::sync::Arc::new(RecordingAuth {
728            calls: Mutex::new(Vec::new()),
729        });
730        let oxicode = OxicodeBuilder::new()
731            .with_builtins()
732            .with_auth(auth.clone())
733            .build();
734
735        // create_provider on a built-in must consult the auth port.
736        let _ = oxicode.create_provider("anthropic");
737        let calls = auth.calls.lock();
738        assert!(
739            calls.iter().any(|c| c == "anthropic"),
740            "Oxicode::create_provider must call AuthProvider::get_api_key_sync \
741             when the static api_keys map has no entry. Got calls: {:?}",
742            *calls
743        );
744    }
745
746    #[test]
747    fn test_closure_tool_sync() {
748        let tool = crate::closure_tool::ClosureTool::new_sync(
749            "test_tool",
750            "A test tool",
751            serde_json::json!({
752                "type": "object",
753                "properties": {
754                    "input": { "type": "string" }
755                }
756            }),
757            |params, _ctx| {
758                let input = params["input"].as_str().unwrap_or("default");
759                Ok(AgentToolResult::success(format!("processed: {}", input)))
760            },
761        );
762
763        assert_eq!(tool.name(), "test_tool");
764        assert_eq!(tool.description(), "A test tool");
765
766        let rt = tokio::runtime::Runtime::new().unwrap();
767        let result = rt
768            .block_on(tool.execute(
769                "call_1",
770                serde_json::json!({"input": "hello"}),
771                None,
772                &ToolContext::default(),
773            ))
774            .unwrap();
775        assert!(result.success);
776        assert!(result.output.contains("processed: hello"));
777    }
778
779    #[test]
780    fn test_custom_tool_in_agent_builder() {
781        let oxicode = OxicodeBuilder::new().with_builtins().build();
782        let config = AgentConfig {
783            model_id: "anthropic/claude-sonnet-4-20250514".into(),
784            timeout_seconds: 30,
785            ..Default::default()
786        };
787        let result = oxicode
788            .agent(config)
789            .workspace("/tmp")
790            .custom_tool(
791                "my_tool",
792                "My custom tool",
793                serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}),
794                |params, _ctx| {
795                    Ok(AgentToolResult::success(format!(
796                        "result: {}",
797                        params["query"]
798                    )))
799                },
800            )
801            .build();
802
803        if let Ok(agent) = result {
804            let tool_names = agent.tools().names();
805            assert!(tool_names.contains(&"my_tool".to_string()));
806        }
807    }
808
809    #[test]
810    fn test_full_isolation_between_instances() {
811        // Instance 1: custom model + no builtins
812        let oxicode1 = OxicodeBuilder::new()
813            .model(test_model("unique-alpha", "p1"))
814            .build();
815
816        // Instance 2: builtins only
817        let oxicode2 = OxicodeBuilder::new().with_builtins().build();
818
819        // Cross-contamination check
820        assert!(oxicode2.resolve_model("p1/unique-alpha").is_err());
821        assert!(
822            oxicode1
823                .resolve_model("anthropic/claude-sonnet-4-20250514")
824                .is_err()
825        );
826
827        // Provider isolation: oxicode1 can't create anthropic (no builtins)
828        assert!(oxicode1.create_provider("anthropic").is_err());
829        // oxicode2 can create anthropic (builtins enabled)
830        assert!(oxicode2.create_provider("anthropic").is_ok());
831    }
832
833    #[test]
834    fn test_agent_builder_system_prompt() {
835        let oxicode = OxicodeBuilder::new().with_builtins().build();
836        let config = AgentConfig {
837            model_id: "anthropic/claude-sonnet-4-20250514".into(),
838            timeout_seconds: 5,
839            ..Default::default()
840        };
841        let agent = oxicode
842            .agent(config)
843            .workspace("/tmp")
844            .system_prompt("You are a test agent.")
845            .build()
846            .unwrap();
847        // Agent built successfully with custom system prompt
848        drop(agent);
849    }
850
851    #[test]
852    fn test_oxicode_builder_api_key() {
853        // Builder accepts api_key without panic
854        let oxicode = OxicodeBuilder::new()
855            .with_builtins()
856            .api_key("anthropic", "sk-ant-test-key")
857            .build();
858        // The key is stored internally. create_provider will use it
859        // (but actual API calls will fail since it's a fake key).
860        assert!(oxicode.has_builtins());
861    }
862
863    #[test]
864    fn test_oxicode_builder_base_url() {
865        let oxicode = OxicodeBuilder::new()
866            .with_builtins()
867            .base_url("openai", "https://my-proxy.example.com/v1")
868            .build();
869        assert!(oxicode.has_builtins());
870    }
871
872    #[test]
873    fn test_oxicode_builder_credential() {
874        let oxicode = OxicodeBuilder::new()
875            .with_builtins()
876            .credential(
877                "openai",
878                "sk-test-key",
879                Some("https://proxy.example.com/v1"),
880            )
881            .credential("anthropic", "sk-ant-test", None)
882            .build();
883        assert!(oxicode.has_builtins());
884    }
885}