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, find_entry_by_model_id, get_all_models,
208    get_cheapest_models, get_model_entry, get_provider_models, get_providers, get_reasoning_models,
209    get_vision_models, model_count, 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// ── Issue tool + store (agent-scoped, observable by SDK consumers) ─────
293pub use oxicode_agent::issues::liveness;
294#[oxicode_stable(since = "0.77.0")]
295pub use oxicode_agent::issues::{
296    Assignment, FileIssueStore, GithubRef, Issue, IssueError, IssueFilter, IssueMeta, IssuePatch,
297    IssueSummary, Priority, Status,
298};
299pub use oxicode_agent::issues::{content_hash, issues_dir};
300#[oxicode_stable(since = "0.77.0")]
301pub use oxicode_agent::tools::issue::{IssueTool, cas_retry, format_issue_full, format_issue_line};
302
303// ── Capability traits + agent tools (single-dependency pattern) ───────
304//
305// A pure-`oxicode-sdk` consumer (no `oxicode-agent` direct dep) can implement these
306// capability traits to wire the agent's tools, and register the tool structs
307// directly. Without these re-exports the single-dependency pattern
308// (`oxios → oxicode-sdk`) is incomplete: implementing a custom memory backend,
309// URL resolver, LSP provider, subagent runner, or agent-pool source would
310// otherwise force a direct `oxicode-agent` dependency.
311
312/// `MemoryBackend` backed by the SDK `MemoryStore` + `EmbeddingProvider` ports.
313#[cfg(feature = "memory")]
314#[oxicode_unstable(feature = "memory")]
315pub use crate::port_memory_backend::PortMemoryBackend;
316/// `BashTool` (read/write/edit/grep/find/ls are already re-exported above and
317/// bundled by `coding_tools()`).
318#[oxicode_stable(since = "0.63.0")]
319pub use oxicode_agent::BashTool;
320/// In-process subagent runner trait (see [`crate::delegation::SdkSubagentRunner`]).
321#[cfg(feature = "subagent")]
322#[oxicode_unstable(feature = "subagent")]
323pub use oxicode_agent::SubagentRunner;
324/// Memory backend trait + item — implement to back the `memory_*` tools, or
325/// use [`PortMemoryBackend`] to bridge the SDK's `MemoryStore` port.
326#[cfg(feature = "memory")]
327#[oxicode_unstable(feature = "memory")]
328pub use oxicode_agent::tools::{MemoryBackend, MemoryItem};
329/// The `memory_*` + `subagent` tool structs (register directly if desired).
330#[cfg(feature = "memory")]
331#[oxicode_unstable(feature = "memory")]
332pub use oxicode_agent::tools::{
333    MemoryEditTool, MemoryRecallTool, MemoryReflectTool, MemoryRetainTool, SubagentTool,
334};
335/// URL resolver trait + resolved content — implement for internal-URL dispatch.
336#[oxicode_stable(since = "0.63.0")]
337pub use oxicode_agent::tools::{ResolvedContent, UrlResolver};
338/// Agent-pool source for Hub display + todo sub-agent matching.
339#[cfg(feature = "agent-hub")]
340#[oxicode_unstable(feature = "agent-hub")]
341pub use oxicode_agent::{AgentHubStatus, AgentInfo, AgentKind, AgentPoolProvider};
342/// LSP capability — implement to back the `lsp` tool.
343#[cfg(feature = "lsp")]
344#[oxicode_unstable(feature = "lsp")]
345pub use oxicode_agent::{LspAction, LspProvider};
346
347// Re-export the hashline crate so consumers can implement `SnapshotStore`
348// (enables line-anchored edit mode) without a direct `oxicode-hashline` dep.
349#[oxicode_internal]
350pub use oxicode_hashline;
351
352// ── Concrete provider re-exports ─────────────────────────────────────────
353//
354// SDK consumers can construct providers directly without depending on
355// `oxicode-ai`. This enables the single-dependency pattern:
356//   oxios → oxicode-sdk  (no oxicode-ai, no oxicode-agent direct dep)
357
358#[oxicode_stable(since = "0.63.0")]
359pub use oxicode_ai::OpenAiProvider;
360#[oxicode_stable(since = "0.63.0")]
361pub use oxicode_ai::OpenAiResponsesProvider;
362
363// ── MCP (Model Context Protocol) re-exports ───────────────────────────
364//
365// SDK consumers can use MCP servers alongside built-in tools.
366// `McpManager::spawn()` creates a manager; `OxicodeBuilder::with_mcp_config()`
367// injects a programmatic config; `mcp_tools()` auto-discovers from
368// standard config files. See `oxicode_sdk::tool_factory::mcp_tools`.
369
370#[oxicode_stable(since = "0.63.0")]
371pub use oxicode_agent::mcp::{
372    ConsentManager, ConsentState, DirectToolDef, DirectToolsConfig, LifecycleMode, McpCallResult,
373    McpConfig, McpConnectionStatus, McpContent, McpDashboardData, McpDirectTool, McpManager,
374    McpSamplingRequest, McpServerInfo, McpSettings, McpSettingsView, McpTool, McpToolDef,
375    McpToolInfo, MetadataCache, ServerEntry, ToolMetadata, ToolPrefix,
376};
377
378// Transport layer — re-exported so consumers can implement custom transports
379// without a direct `oxicode-agent` dependency. See
380// `docs/oxicode-sdk-ownership.md` §2 (MCP transport is SDK-owned behavior).
381#[cfg(feature = "mcp-transport")]
382#[oxicode_unstable(feature = "mcp-transport")]
383pub use oxicode_agent::mcp::transport::{
384    McpTransport, http::StreamableHttpTransport, stdio::StdioTransport,
385};
386
387// Spawn validation policy — composable trait. SDK owns the trait + noop impl;
388// consumers (oxicode-cli, oxios) register their own policy. See
389// `docs/oxicode-sdk-ownership.md` §2.
390#[cfg(feature = "mcp-spawn-validator")]
391#[oxicode_unstable(feature = "mcp-spawn-validator")]
392pub use oxicode_agent::mcp::{NoopSpawnValidator, SpawnValidator};
393
394// Circuit-breaker behavior trait + reference impl. SDK owns the trait;
395// consumers implement for their domain (A2A, HTTP, etc.). See
396// `docs/oxicode-sdk-ownership.md` §3.
397#[cfg(feature = "circuit-breaker")]
398#[oxicode_unstable(feature = "circuit-breaker")]
399pub use oxicode_ai::circuit_breaker::{
400    BreakerError, BreakerState, CircuitBreaker, DefaultCircuitBreaker, SharedBreaker,
401};
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use std::path::Path;
407
408    #[test]
409    fn catalog_reachable_via_sdk() {
410        // Verify the catalog surface is exposed to SDK consumers.
411        // Models now come from the materialized snapshot (models.dev),
412        // not from load_builtin_models() (which is an empty legacy map).
413        let providers = load_builtin_providers();
414        assert!(
415            providers.len() >= 70,
416            "expected >= 70 providers, got {}",
417            providers.len()
418        );
419        // Materialized catalog should have models.
420        let all = get_all_models().collect::<Vec<_>>();
421        assert!(
422            !all.is_empty(),
423            "models should be loaded via get_all_models()"
424        );
425        assert!(
426            all.len() > 5000,
427            "expected >5000 materialized models, got {}",
428            all.len()
429        );
430    }
431
432    #[test]
433    fn sentinel_count_via_sdk() {
434        let n = builtin_model_count_sentinel();
435        // With materialize from models.dev, no sentinel pricing is applied
436        // (models.dev is the verified source of truth).
437        assert_eq!(
438            n, 0,
439            "expected 0 sentinel entries with materialize, got {n}"
440        );
441    }
442
443    /// Helper to build a minimal Model for tests.
444    fn test_model(id: &str, provider: &str) -> Model {
445        Model::new(
446            id,
447            id,
448            Api::AnthropicMessages,
449            provider,
450            "https://api.example.com",
451        )
452    }
453
454    #[test]
455    fn test_oxicode_builder_new() {
456        let oxicode = OxicodeBuilder::new().build();
457        // Empty registry — no models
458        assert!(
459            oxicode
460                .resolve_model("anthropic/claude-sonnet-4-20250514")
461                .is_err()
462        );
463    }
464
465    #[test]
466    fn test_oxicode_builder_with_builtins() {
467        let oxicode = OxicodeBuilder::new().with_builtins().build();
468        // Should have built-in models
469        assert!(
470            oxicode
471                .resolve_model("anthropic/claude-sonnet-4-20250514")
472                .is_ok()
473        );
474        assert!(oxicode.resolve_model("openai/gpt-4o").is_ok());
475    }
476
477    #[test]
478    fn test_oxicode_builder_custom_model() {
479        let oxicode = OxicodeBuilder::new()
480            .model(test_model("test-model", "test-provider"))
481            .build();
482        assert!(oxicode.resolve_model("test-provider/test-model").is_ok());
483    }
484
485    #[test]
486    fn test_oxicode_provider_resolution() {
487        let oxicode = OxicodeBuilder::new().with_builtins().build();
488        // Built-in provider (falls back to built-in registry)
489        assert!(oxicode.create_provider("anthropic").is_ok());
490        // Unknown provider
491        assert!(oxicode.create_provider("nonexistent").is_err());
492    }
493
494    #[test]
495    fn test_agent_builder_workspace() {
496        let oxicode = OxicodeBuilder::new().with_builtins().build();
497        let config = AgentConfig {
498            model_id: "anthropic/claude-sonnet-4-20250514".into(),
499            timeout_seconds: 30,
500            ..Default::default()
501        };
502        // AgentBuilder with workspace — should not panic
503        let result = oxicode
504            .agent(config)
505            .workspace("/tmp/test-workspace")
506            .build();
507        assert!(result.is_ok() || result.is_err());
508    }
509
510    #[test]
511    fn test_agent_builder_coding_tools() {
512        let oxicode = OxicodeBuilder::new().with_builtins().build();
513        let config = AgentConfig {
514            model_id: "anthropic/claude-sonnet-4-20250514".into(),
515            timeout_seconds: 30,
516            ..Default::default()
517        };
518        let result = oxicode
519            .agent(config)
520            .workspace("/tmp")
521            .coding_tools()
522            .build();
523        if let Ok(agent) = result {
524            let tool_names = agent.tools().names();
525            assert!(tool_names.contains(&"read".to_string()));
526            assert!(tool_names.contains(&"write".to_string()));
527            assert!(tool_names.contains(&"edit".to_string()));
528            assert!(tool_names.contains(&"ls".to_string()));
529        }
530    }
531
532    #[test]
533    fn test_agent_builder_readonly_tools() {
534        let oxicode = OxicodeBuilder::new().with_builtins().build();
535        let config = AgentConfig {
536            model_id: "anthropic/claude-sonnet-4-20250514".into(),
537            timeout_seconds: 30,
538            ..Default::default()
539        };
540        let result = oxicode
541            .agent(config)
542            .workspace("/tmp")
543            .readonly_tools()
544            .build();
545        if let Ok(agent) = result {
546            let tool_names = agent.tools().names();
547            assert!(tool_names.contains(&"read".to_string()));
548            assert!(tool_names.contains(&"ls".to_string()));
549            // Should NOT have write/edit
550            assert!(!tool_names.contains(&"write".to_string()));
551        }
552    }
553
554    #[test]
555    fn test_model_registry_isolation() {
556        // Two separate Oxicode instances should not share state
557        let oxicode1 = OxicodeBuilder::new()
558            .model(test_model("unique-1", "test"))
559            .build();
560
561        let oxicode2 = OxicodeBuilder::new().with_builtins().build();
562
563        // oxicode2 should NOT have oxicode1's custom model
564        assert!(oxicode2.resolve_model("test/unique-1").is_err());
565        // oxicode1 should have its custom model
566        assert!(oxicode1.resolve_model("test/unique-1").is_ok());
567    }
568
569    #[test]
570    fn test_tool_factory_coding_tools() {
571        let tools = crate::tool_factory::coding_tools(Path::new("/tmp"));
572        let names = tools.names();
573        assert!(names.contains(&"read".to_string()));
574        assert!(names.contains(&"write".to_string()));
575        assert!(names.contains(&"edit".to_string()));
576        assert!(names.contains(&"ls".to_string()));
577        assert_eq!(names.len(), 4);
578    }
579
580    #[test]
581    fn test_tool_factory_readonly_tools() {
582        let tools = crate::tool_factory::readonly_tools(Path::new("/tmp"));
583        let names = tools.names();
584        assert!(names.contains(&"read".to_string()));
585        assert!(names.contains(&"ls".to_string()));
586        assert_eq!(names.len(), 2);
587    }
588
589    #[test]
590    fn test_tool_registry_extend_from() {
591        let base = crate::tool_factory::coding_tools(Path::new("/tmp"));
592        let extra = crate::tool_factory::readonly_tools(Path::new("/tmp"));
593
594        // extend_from should add tools from extra into base
595        let combined = ToolRegistry::new();
596        combined.extend_from(&base);
597        combined.extend_from(&extra);
598
599        let names = combined.names();
600        assert!(names.contains(&"read".to_string()));
601        assert!(names.contains(&"write".to_string()));
602        assert!(names.contains(&"ls".to_string()));
603        // read and ls are in both — no duplicates expected since same names
604    }
605
606    // ── Phase 2+ Tests: ProviderResolver, ClosureTool, Isolation ──
607
608    #[test]
609    fn test_provider_resolver_trait_on_oxicode() {
610        let oxicode = OxicodeBuilder::new().with_builtins().build();
611        // Oxicode implements ProviderResolver
612        let resolver: &dyn ProviderResolver = &oxicode;
613        assert!(resolver.resolve_provider("anthropic").is_some());
614        assert!(resolver.resolve_provider("nonexistent").is_none());
615        assert!(
616            resolver
617                .resolve_model("anthropic/claude-sonnet-4-20250514")
618                .is_some()
619        );
620        assert!(resolver.resolve_model("nonexistent/model").is_none());
621    }
622
623    #[test]
624    fn test_agent_uses_resolver_for_switch_model() {
625        // Create isolated Oxicode with only a mock model
626        let oxicode = OxicodeBuilder::new()
627            .model(test_model("test-model", "test-provider"))
628            .build();
629
630        // This should fail because 'anthropic' provider isn't registered
631        let config = AgentConfig {
632            model_id: "test-provider/test-model".into(),
633            timeout_seconds: 5,
634            ..Default::default()
635        };
636        let result = oxicode.agent(config).build();
637        // Agent build fails because provider 'test-provider' has no implementation
638        // (no custom provider registered, no builtins enabled)
639        assert!(result.is_err());
640    }
641
642    #[test]
643    fn test_oxicode_builder_without_builtins() {
644        let oxicode = OxicodeBuilder::new().build();
645        // No models, no providers
646        assert!(
647            oxicode
648                .resolve_model("anthropic/claude-sonnet-4-20250514")
649                .is_err()
650        );
651        assert!(oxicode.create_provider("anthropic").is_err());
652        assert!(!oxicode.has_builtins());
653    }
654
655    #[test]
656    fn test_oxicode_builder_with_builtins_creates_providers() {
657        let oxicode = OxicodeBuilder::new().with_builtins().build();
658        assert!(oxicode.has_builtins());
659        // Built-in provider fallback should work
660        assert!(oxicode.create_provider("anthropic").is_ok());
661        assert!(oxicode.create_provider("openai").is_ok());
662        assert!(oxicode.create_provider("deepseek").is_ok());
663        // Unknown still fails
664        assert!(oxicode.create_provider("unknown-provider").is_err());
665    }
666
667    /// Regression (#40): `Oxicode::create_provider` must consult the wired
668    /// `AuthProvider` port via its sync fast-path when the static
669    /// `OxicodeBuilder::api_key()` map has no entry for the provider. This is
670    /// the **primary** credential source for products like the CLI, which
671    /// never call `OxicodeBuilder::api_key()` and instead register
672    /// `FileAuthProvider` via `.with_auth(...)`. Without this wiring, every
673    /// built-in provider would fall through to env vars and the CLI would
674    /// fail with `MissingApiKey` for any provider not in the environment.
675    #[test]
676    fn test_create_provider_consults_auth_port() {
677        use parking_lot::Mutex;
678        use std::pin::Pin;
679
680        /// Recording AuthProvider — captures every `get_api_key_sync` call
681        /// and returns `None`, forcing the wiring to actually consult us
682        /// (rather than short-circuiting on a returned key).
683        struct RecordingAuth {
684            calls: Mutex<Vec<String>>,
685        }
686        impl AuthProvider for RecordingAuth {
687            fn get_api_key(
688                &self,
689                _provider: &str,
690            ) -> Pin<Box<dyn Future<Output = Result<Option<String>, SdkError>> + Send + '_>>
691            {
692                Box::pin(async { Ok(None) })
693            }
694            fn get_api_key_sync(&self, provider: &str) -> Result<Option<String>, SdkError> {
695                self.calls.lock().push(provider.to_string());
696                Ok(None)
697            }
698            fn set_api_key(
699                &self,
700                _: &str,
701                _: &str,
702            ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>> {
703                Box::pin(async { Ok(()) })
704            }
705            fn delete_api_key(
706                &self,
707                _: &str,
708            ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>> {
709                Box::pin(async { Ok(()) })
710            }
711            fn get_oauth(
712                &self,
713                _: &str,
714            ) -> Pin<
715                Box<
716                    dyn Future<Output = Result<Option<crate::ports::OAuthToken>, SdkError>>
717                        + Send
718                        + '_,
719                >,
720            > {
721                Box::pin(async { Ok(None) })
722            }
723            fn set_oauth(
724                &self,
725                _: &str,
726                _: crate::ports::OAuthToken,
727            ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>> {
728                Box::pin(async { Ok(()) })
729            }
730            fn list_providers(
731                &self,
732            ) -> Pin<Box<dyn Future<Output = Result<Vec<String>, SdkError>> + Send + '_>>
733            {
734                Box::pin(async { Ok(Vec::new()) })
735            }
736        }
737
738        let auth = std::sync::Arc::new(RecordingAuth {
739            calls: Mutex::new(Vec::new()),
740        });
741        let oxicode = OxicodeBuilder::new()
742            .with_builtins()
743            .with_auth(auth.clone())
744            .build();
745
746        // create_provider on a built-in must consult the auth port.
747        let _ = oxicode.create_provider("anthropic");
748        let calls = auth.calls.lock();
749        assert!(
750            calls.iter().any(|c| c == "anthropic"),
751            "Oxicode::create_provider must call AuthProvider::get_api_key_sync \
752             when the static api_keys map has no entry. Got calls: {:?}",
753            *calls
754        );
755    }
756
757    #[test]
758    fn test_closure_tool_sync() {
759        let tool = crate::closure_tool::ClosureTool::new_sync(
760            "test_tool",
761            "A test tool",
762            serde_json::json!({
763                "type": "object",
764                "properties": {
765                    "input": { "type": "string" }
766                }
767            }),
768            |params, _ctx| {
769                let input = params["input"].as_str().unwrap_or("default");
770                Ok(AgentToolResult::success(format!("processed: {}", input)))
771            },
772        );
773
774        assert_eq!(tool.name(), "test_tool");
775        assert_eq!(tool.description(), "A test tool");
776
777        let rt = tokio::runtime::Runtime::new().unwrap();
778        let result = rt
779            .block_on(tool.execute(
780                "call_1",
781                serde_json::json!({"input": "hello"}),
782                None,
783                &ToolContext::default(),
784            ))
785            .unwrap();
786        assert!(result.success);
787        assert!(result.output.contains("processed: hello"));
788    }
789
790    #[test]
791    fn test_custom_tool_in_agent_builder() {
792        let oxicode = OxicodeBuilder::new().with_builtins().build();
793        let config = AgentConfig {
794            model_id: "anthropic/claude-sonnet-4-20250514".into(),
795            timeout_seconds: 30,
796            ..Default::default()
797        };
798        let result = oxicode
799            .agent(config)
800            .workspace("/tmp")
801            .custom_tool(
802                "my_tool",
803                "My custom tool",
804                serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}),
805                |params, _ctx| {
806                    Ok(AgentToolResult::success(format!(
807                        "result: {}",
808                        params["query"]
809                    )))
810                },
811            )
812            .build();
813
814        if let Ok(agent) = result {
815            let tool_names = agent.tools().names();
816            assert!(tool_names.contains(&"my_tool".to_string()));
817        }
818    }
819
820    #[test]
821    fn test_full_isolation_between_instances() {
822        // Instance 1: custom model + no builtins
823        let oxicode1 = OxicodeBuilder::new()
824            .model(test_model("unique-alpha", "p1"))
825            .build();
826
827        // Instance 2: builtins only
828        let oxicode2 = OxicodeBuilder::new().with_builtins().build();
829
830        // Cross-contamination check
831        assert!(oxicode2.resolve_model("p1/unique-alpha").is_err());
832        assert!(
833            oxicode1
834                .resolve_model("anthropic/claude-sonnet-4-20250514")
835                .is_err()
836        );
837
838        // Provider isolation: oxicode1 can't create anthropic (no builtins)
839        assert!(oxicode1.create_provider("anthropic").is_err());
840        // oxicode2 can create anthropic (builtins enabled)
841        assert!(oxicode2.create_provider("anthropic").is_ok());
842    }
843
844    #[test]
845    fn test_agent_builder_system_prompt() {
846        let oxicode = OxicodeBuilder::new().with_builtins().build();
847        let config = AgentConfig {
848            model_id: "anthropic/claude-sonnet-4-20250514".into(),
849            timeout_seconds: 5,
850            ..Default::default()
851        };
852        let agent = oxicode
853            .agent(config)
854            .workspace("/tmp")
855            .system_prompt("You are a test agent.")
856            .build()
857            .unwrap();
858        // Agent built successfully with custom system prompt
859        drop(agent);
860    }
861
862    #[test]
863    fn test_oxicode_builder_api_key() {
864        // Builder accepts api_key without panic
865        let oxicode = OxicodeBuilder::new()
866            .with_builtins()
867            .api_key("anthropic", "sk-ant-test-key")
868            .build();
869        // The key is stored internally. create_provider will use it
870        // (but actual API calls will fail since it's a fake key).
871        assert!(oxicode.has_builtins());
872    }
873
874    #[test]
875    fn test_oxicode_builder_base_url() {
876        let oxicode = OxicodeBuilder::new()
877            .with_builtins()
878            .base_url("openai", "https://my-proxy.example.com/v1")
879            .build();
880        assert!(oxicode.has_builtins());
881    }
882
883    #[test]
884    fn test_oxicode_builder_credential() {
885        let oxicode = OxicodeBuilder::new()
886            .with_builtins()
887            .credential(
888                "openai",
889                "sk-test-key",
890                Some("https://proxy.example.com/v1"),
891            )
892            .credential("anthropic", "sk-ant-test", None)
893            .build();
894        assert!(oxicode.has_builtins());
895    }
896}