Skip to main content

flux_sdk/
lib.rs

1//! `flux-sdk` — the high-level library API.
2//!
3//! Wraps the flux-flow engine, built-in tools, the safety envelope, and a session into a small
4//! [`Client`]. You supply a [`Provider`] (from `flux-providers`) and a workspace
5//! root; the SDK wires the rest.
6//!
7//! There are three front doors: [`Client`] (an adaptive agent turn driven by an authored Flux-Lang
8//! outer loop, returning a [`TurnOutput`]), [`FlowClient`] (the authored Flux-Lang
9//! `parse → analyze → execute` lifecycle), and the [`dsl`] (author the AST in Rust).
10//! `Client` assembles [`flux_flow::engine::FlowEngine`]; `FlowClient` delegates directly to the same
11//! `flux-flow` runtime adapter, store, and safety envelope for one-flow execution. Each
12//! door has a runnable, no-API-key example: `examples/client_basic.rs`,
13//! `examples/parameterized_flow.rs`, and `examples/dsl_loops.rs` respectively. On top of the DSL,
14//! [`recipes`] is a cookbook of reusable, parameterized flow builders (routing, lookup, the loop
15//! family, resilience).
16//!
17//! ```ignore
18//! // Runnable hermetic version: `cargo run -p codewandler-flux-sdk --example client_basic`.
19//! # async fn ex() -> flux_core::Result<()> {
20//! use flux_sdk::Client;
21//! let provider = Box::new(flux_providers::anthropic::anthropic_from_env()?);
22//! let client = Client::builder().auto_approve(true).build(provider, ".")?;
23//! let out = client.run("Summarize the README").await?;
24//! println!("{}", out.text);
25//! # Ok(()) }
26//! ```
27#![warn(missing_docs)]
28
29mod envelope;
30pub mod events;
31pub mod flow;
32pub mod session;
33pub mod storage;
34
35pub use events::{AgentEvent, FlowStream, TurnStream};
36pub use flow::{assemble_registry, ExecutionResult, FlowClient, FlowClientBuilder};
37pub use session::{Fork, Session};
38pub use storage::Storage;
39
40/// The engine's streaming contract — implement it and pass it to [`Session::send_with`] to
41/// receive a turn's deltas, tool calls, tool results, and observations as they happen (or use
42/// [`Session::stream`] for the owned-event shape). Re-exported from `flux-flow`.
43pub use flux_flow::AgentSink;
44
45/// Cancels a running turn ([`Session::send_with`]) or voice session — re-exported from
46/// `tokio-util` so consumers don't need the direct dependency.
47pub use tokio_util::sync::CancellationToken;
48
49/// The provider trait — the one construction argument every client's `build` takes. The concrete
50/// backends live in `flux-providers` (Anthropic/OpenAI/OpenRouter/Ollama/Bedrock + the
51/// subscription providers); a consumer implements this for a mock, or adds `flux-providers` for a
52/// real one.
53pub use flux_provider::Provider;
54
55#[allow(deprecated)]
56pub use flux_agent::AgentExecutorConfig;
57/// The agent definition ([`ClientBuilder::from_spec`]) plus its permission rules. Re-exported so
58/// the full-control door needs no direct `flux-agent` dependency.
59pub use flux_agent::{
60    AdaptiveLoopPolicy, AgentLoopSpec, AgentSpec, AgentStagePolicy, BuiltinAgentLoop, Permissions,
61};
62
63/// The per-turn token accounting carried on [`TurnOutput`]. Re-exported from `flux-core`.
64pub use flux_core::Usage;
65
66/// The model rate table [`Session::cost`] prices a session against.
67/// [`PricingTable::builtin`](flux_core::PricingTable::builtin) is the curated default; the optional
68/// `pricing` feature adds [`pricing::load_pricing_table`] to overlay a user's `~/.flux/pricing.toml`.
69pub use flux_core::PricingTable;
70
71/// The report [`Session::replay`] returns — the replayed plans, a divergence diagnostic (`None` on a
72/// faithful replay), and cassette-cell accounting. Re-exported from `flux-flow`.
73pub use flux_flow::replay::ReplayReport;
74
75/// **Custom tools.** Implement [`Tool`](tools::Tool) — or build one from a closure with
76/// [`tool_fn`](tools::tool_fn)/[`FnTool`](tools::FnTool) — and register it with
77/// [`ClientBuilder::register_op`]/[`FlowClient::register_op`]. A registered tool dispatches through
78/// the same authorization → approval → guarded-IO envelope as every built-in. [`ToolSpec`](tools::ToolSpec)
79/// (with [`Risk`](tools::Risk)) describes the tool to the model and the envelope;
80/// [`ToolContext`](tools::ToolContext)/[`ToolResult`](tools::ToolResult) are the dispatch types;
81/// [`ToolRegistry`](tools::ToolRegistry) is what a `register_pack` closure receives.
82pub mod tools {
83    pub use flux_runtime::{tool_fn, FnTool, Tool, ToolContext, ToolRegistry, ToolResult};
84    pub use flux_spec::{Risk, ToolSpec};
85}
86
87/// Build a typed, closure-backed stage operation. `I` and `O` are independent contracts: both JSON
88/// Schemas are derived and registered, the Flux analyzer infers `O` at call sites, and execution
89/// still traverses the ordinary authorization/approval dispatcher. The safe default is a low-risk,
90/// idempotent read stage; use a bespoke [`tools::Tool`] when the stage has effects or permission
91/// subjects that need a stronger contract.
92pub fn stage_fn<I, O, F, Fut, E>(
93    name: impl Into<String>,
94    description: impl Into<String>,
95    handler: F,
96) -> Arc<dyn tools::Tool>
97where
98    I: serde::de::DeserializeOwned + schemars::JsonSchema + Send + 'static,
99    O: serde::Serialize + schemars::JsonSchema + Send + 'static,
100    F: Fn(I) -> Fut + Send + Sync + 'static,
101    Fut: Future<Output = std::result::Result<O, E>> + Send + 'static,
102    E: std::fmt::Display + Send + 'static,
103{
104    let spec = flux_spec::ToolSpec::read_only_typed::<I>(name, description)
105        .with_output_schema(flux_spec::tool_output_schema::<O>());
106    let handler = Arc::new(handler);
107    Arc::new(
108        flux_runtime::FnTool::new(spec, move |value| {
109            let handler = handler.clone();
110            async move {
111                let input = serde_json::from_value::<I>(value)
112                    .map_err(|error| format!("invalid stage input: {error}"))?;
113                let output = handler(input).await.map_err(|error| error.to_string())?;
114                serde_json::to_value(output)
115                    .map_err(|error| format!("stage output did not serialize: {error}"))
116            }
117        })
118        .with_staging_disposition(flux_spec::StagingDisposition::Gather),
119    )
120}
121
122/// **Approval policy.** Implement [`Approver`](approval::Approver) and pass it to a builder's
123/// `approver(...)` to gate ops with your own logic; [`ApprovalChoice`](approval::ApprovalChoice) is
124/// your verdict, [`IntentSet`](approval::IntentSet) is the per-call intent your `request` receives,
125/// and [`RiskApprover`](approval::RiskApprover) is a ready-made risk-tiered policy.
126pub mod approval {
127    pub use flux_runtime::{ApprovalChoice, Approver, RiskApprover};
128    pub use flux_spec::IntentSet;
129}
130
131/// **Authorization floor.** Every SDK executor carries an [`ExecutionAuthorization`] profile.
132/// Builders use [`ExecutionAuthorization::local`] by default; embedding services should install a
133/// resolved policy and identity with `with_authorization(...)`.
134pub mod authorization {
135    pub use flux_policy::{
136        default_local_grants, local_identity, AuthorizationPolicy, Caller, Trust,
137    };
138    pub use flux_runtime::{
139        ExecutionAuthorization, ExecutionEnvironment, IdentityCell, TurnIdentity,
140    };
141}
142
143/// **Session observability.** The projection types [`Session`] readers return —
144/// [`Message`](flux_core::Message) ([`history`](Session::history)),
145/// [`TurnSummary`](flux_events::TurnSummary) ([`turns`](Session::turns)),
146/// [`RunEvent`](flux_lang::ast::RunEvent) ([`run_trace`](Session::run_trace)),
147/// [`ModelCost`](flux_events::ModelCost) ([`cost`](Session::cost)), and
148/// [`EfficiencySummary`](flux_events::EfficiencySummary) ([`efficiency`](Session::efficiency)) —
149/// plus the stores [`Storage::custom`] accepts and the evidence-gated surfacing types
150/// [`ClientBuilder::groups`] takes.
151///
152/// A gating [`ToolGroup`](flux_evidence::ToolGroup) hides its `tools` until a
153/// [`SignalMatch`](flux_evidence::SignalMatch) fires — build one with
154/// `SignalMatch { kind: KIND_SIGNAL.to_string(), signal: Some("my_signal".into()) }` and surface it
155/// via [`ClientBuilder::ambient_signals`] or a workspace signal.
156pub mod observe {
157    pub use flux_core::Message;
158    pub use flux_events::EventStore;
159    pub use flux_events::{DiffRow, EfficiencySummary, ModelCost, RunDiff, TurnSummary};
160    pub use flux_evidence::{Observation, SignalMatch, ToolGroup, KIND_SIGNAL};
161    pub use flux_flow::state::FlowStore;
162    pub use flux_lang::ast::RunEvent;
163}
164
165/// **Voice.** The types the two voice front doors take —
166/// [`Session::run_voice_flow`](Session::run_voice_flow) (flow-driven: the flow leads, the model is
167/// speech I/O) and [`FlowClient::run_voice_session`](flow::FlowClient::run_voice_session)
168/// (model-driven: the model leads and calls tools). Implement [`VoiceSink`](voice::VoiceSink) to
169/// receive audio / transcripts / lifecycle; [`VoiceReply`](voice::VoiceReply) is a flow turn's
170/// spoken reply (`Continue`/`Complete`); [`RealtimeProvider`](voice::RealtimeProvider) +
171/// [`RealtimeConfig`](voice::RealtimeConfig) are the full-duplex provider seam (a concrete one lives
172/// behind `flux-providers`' `realtime` feature).
173pub mod voice {
174    pub use flux_flow::voice::{VoiceReply, VoiceSink};
175    pub use flux_provider::{RealtimeConfig, RealtimeProvider};
176}
177
178/// **Pricing ergonomics** (feature `pricing`). [`load_pricing_table`](pricing::load_pricing_table)
179/// builds the effective [`PricingTable`] — the curated built-in rates overlaid by the user's
180/// `~/.flux/pricing.toml` — the same table the CLI's cost display uses. Pass it to
181/// [`Session::cost`]. Without the feature, use
182/// [`PricingTable::builtin`](flux_core::PricingTable::builtin) (no file IO, no `flux-credentials`
183/// dependency).
184#[cfg(feature = "pricing")]
185pub mod pricing {
186    pub use flux_credentials::load_pricing_table;
187}
188
189/// **Provider construction** (feature `providers`). Re-exports the concrete LLM backends from
190/// `flux-providers` (`anthropic`/`openai`/`openrouter`/`ollama`/`bedrock`/`codex` + the `spec`
191/// resolver) and adds [`from_spec`](providers::from_spec) — the one-stop shop that turns a model
192/// spec into a ready-to-`build` provider, using the CLI's exact resolution (including the
193/// `claude`/`codex` subscription token sources and the AWS Bedrock chain).
194///
195/// The default build stays provider-agnostic: without this feature `flux-providers` (and its
196/// transitive `flux-credentials`) are not dependencies at all.
197///
198/// ```ignore
199/// # #[cfg(feature = "providers")]
200/// # fn ex() -> flux_core::Result<()> {
201/// let (provider, model) = flux_sdk::providers::from_spec("ollama/qwen3")?;
202/// let client = flux_sdk::Client::builder().model(model).build(provider, ".")?;
203/// # let _ = client; Ok(()) }
204/// ```
205#[cfg(feature = "providers")]
206pub mod providers {
207    pub use flux_providers::{anthropic, bedrock, codex, ollama, openai, openrouter, spec};
208
209    use flux_core::Result;
210    use flux_provider::Provider;
211
212    /// Build a provider from a model spec (`"claude/sonnet"`, `"ollama/qwen3"`, `"openai/gpt-5.5"`,
213    /// or a bare alias like `sonnet`), returning the boxed provider and its **resolved** model id —
214    /// pass both straight to [`ClientBuilder::model`](crate::ClientBuilder::model) +
215    /// [`ClientBuilder::build`](crate::ClientBuilder::build). Wraps
216    /// [`flux_providers::spec::build`], so credentials resolve from the environment exactly as the
217    /// `flux` CLI does.
218    pub fn from_spec(spec: &str) -> Result<(Box<dyn Provider>, String)> {
219        let (native, _provider, model) = spec::build(spec)?;
220        Ok((Box::new(native), model))
221    }
222}
223
224/// **Subprocess plugin tools** (feature `plugins`). Load an installed plugin's operations as
225/// policy-gated tools inside an embedded agent — the same authorization → approval → guarded-IO
226/// envelope, the same approval path. Attach them with
227/// [`ClientBuilder::with_plugin_tools`](ClientBuilder::with_plugin_tools) (conversational door) or
228/// [`FlowClient::register_plugin`](flow::FlowClient::register_plugin) (flow door), or load them
229/// yourself with [`load_tools`](plugins::load_tools).
230///
231/// **Trust boundary:** plugin binaries are *trusted dependencies* — native code the host spawns as
232/// a subprocess, not OS-sandboxed. Their host capabilities are still manifest-scoped
233/// ([`SystemHostCaps`](plugins::SystemHostCaps) defaults — a plugin may only run programs / read
234/// secrets / reach hosts its manifest declares), but the process itself runs your machine's code.
235/// Install only plugins you trust.
236#[cfg(feature = "plugins")]
237pub mod plugins {
238    pub use flux_plugin::{HostCapabilities, PluginDescriptor, PluginManifest, SystemHostCaps};
239
240    use std::sync::Arc;
241
242    use flux_core::Result;
243    use flux_runtime::Tool;
244    use flux_system::System;
245
246    /// Spawn the plugin described by `descriptor` (verified against its pin), fetch its manifest, and
247    /// project every operation as a policy-gated [`Tool`]. Host capabilities are **manifest-scoped**
248    /// ([`SystemHostCaps`] defaults over `system` — nothing widened). Register the returned tools
249    /// into a client's catalog (they hold the subprocess connection alive); each dispatches through
250    /// the one safety envelope, just like a built-in.
251    pub async fn load_tools(
252        system: &Arc<System>,
253        name: &str,
254        descriptor: &PluginDescriptor,
255    ) -> Result<Vec<Arc<dyn Tool>>> {
256        let caps_system = system.clone();
257        let loaded = flux_plugin::load_plugin_tools(system, name, descriptor, move |m| {
258            Arc::new(SystemHostCaps::new(caps_system).with_manifest(m)) as Arc<dyn HostCapabilities>
259        })
260        .await?;
261        Ok(loaded.tools)
262    }
263}
264
265/// **Sub-agents.** Attach named roles to a conversational client with
266/// [`ClientBuilder::with_sub_agents`] (or to a flow client with
267/// [`FlowClient::with_sub_agents`](flow::FlowClient::with_sub_agents)); a turn whose native stage calls
268/// `task(role, …)` then delegates to a role's child agent through the same
269/// authorization → approval → guarded-IO envelope. [`SubAgents`](subagents::SubAgents) is the bundle
270/// (roles + the child tool surface + a [`ProviderFactory`](subagents::ProviderFactory) + limits),
271/// [`SpawnLimits`](subagents::SpawnLimits) bounds each child (tokens, wall-clock, …), and
272/// [`Role`](subagents::Role)/[`RoleRegistry`](subagents::RoleRegistry) name the roles a `task` may
273/// target ([`try_parse_role`](subagents::try_parse_role) builds a `Role` from a markdown
274/// definition while rejecting malformed capability metadata).
275pub mod subagents {
276    #[allow(deprecated)]
277    pub use flux_orchestrate::{
278        parse_role, try_parse_role, ProviderFactory, Role, RoleRegistry, SpawnLimits, SubAgents,
279    };
280}
281
282/// The OS-sandbox posture types, re-exported so a consumer can inject an explicit sandbox into a
283/// builder via [`ClientBuilder::with_sandbox`]/[`flow::FlowClientBuilder::with_sandbox`] without
284/// taking a direct `flux-system` dependency.
285pub use flux_system::sandbox::{Sandbox, SandboxSettings};
286
287/// The Rust **embedded DSL** for authoring flows — builder primitives that construct the Flux-Lang
288/// AST. Build a [`flux_lang::ast::DraftAst`] with `dsl::Flow`/`dsl::Block` (loops and control-flow are
289/// first-class), then drive it through [`FlowClient::analyze`] + [`FlowClient::execute`]. Re-exported
290/// from `flux-lang` so consumers can stay inside `flux_sdk`. See `examples/dsl_loops.rs`.
291pub use flux_lang::dsl;
292
293pub mod recipes;
294
295use std::future::Future;
296use std::path::PathBuf;
297use std::sync::Arc;
298
299// `AgentSpec`, `Usage`, and `Provider` are in scope via the public re-exports above.
300use flux_cognition::CognitionPack;
301use flux_core::ContextBlock;
302use flux_core::Result;
303use flux_events::EventStore;
304use flux_flow::engine::FlowEngine;
305use flux_orchestrate::{SubAgents, TaskTool};
306#[cfg(test)]
307use flux_runtime::ToolContext;
308use flux_runtime::{Approver, ExecutionEnvironment, PermissionManager, Tool, ToolRegistry};
309use flux_secret::Redactor;
310use flux_system::{System, Workspace};
311
312/// The result of one turn — a [`Client::run`], a [`Session::send`], or a
313/// [`Session::start_flow`](Session::start_flow).
314///
315/// `#[non_exhaustive]`: fields are added as the SDK grows (wave 2 added `suspended`), so construct
316/// it only via the SDK and match with a `..` rest pattern.
317#[derive(Debug, Default, Clone)]
318#[non_exhaustive]
319pub struct TurnOutput {
320    /// The assistant's final text for the turn.
321    pub text: String,
322    /// The names of the tools invoked during the turn, in call order.
323    pub tool_calls: Vec<String>,
324    /// Token usage for the turn, if the provider reported it.
325    pub usage: Option<Usage>,
326    /// Whether the session is parked on a top-level `await` after this turn — a flow-driven session
327    /// (see [`Session::start_flow`](Session::start_flow)) that suspended, or re-suspended on a later
328    /// `await`. Resume by sending the awaited input with [`Session::send`]. Always `false` for an
329    /// ordinary conversational turn and for a flow that ran to completion.
330    pub suspended: bool,
331}
332
333/// A deferred registry installer (the `register_*` pack convention), applied at `build`.
334type RegistryPack = Box<dyn FnOnce(&mut ToolRegistry) -> Result<()>>;
335
336/// Builder for a [`Client`]. Internally an [`AgentSpec`] plus the shared envelope knobs, so every
337/// agent-definition field has exactly one home and [`from_spec`](Self::from_spec) is the
338/// full-control escape hatch rather than a parallel path.
339pub struct ClientBuilder {
340    spec: AgentSpec,
341    envelope: envelope::Envelope,
342    storage: Option<Storage>,
343    cognition: bool,
344    ops: Vec<(String, Arc<dyn Tool>)>,
345    packs: Vec<RegistryPack>,
346    sub_agents: Option<SubAgents>,
347    sub_agent_adaptive_policy: Option<AdaptiveLoopPolicy>,
348}
349
350impl Default for ClientBuilder {
351    fn default() -> Self {
352        Self {
353            spec: AgentSpec::new("unknown"),
354            // Reads pre-allowed; everything else denied unless `auto_approve` (no UI in a library).
355            envelope: envelope::Envelope::with_default_allow(&["read"]),
356            // Unset ⇒ in-memory (ephemeral) stores, the pre-0.16 behavior.
357            storage: None,
358            cognition: false,
359            ops: Vec::new(),
360            packs: Vec::new(),
361            // Unset ⇒ no `task` tool, no spawner (children off by default).
362            sub_agents: None,
363            sub_agent_adaptive_policy: None,
364        }
365    }
366}
367
368impl ClientBuilder {
369    /// Start from a hand-built [`AgentSpec`] — the full-control escape hatch. The spec's own
370    /// permissions are taken as-is: unlike [`builder`](Self::builder), there is **no implicit
371    /// `read` pre-allow**, so a spec with empty `permissions` and no [`auto_approve`](Self::auto_approve)
372    /// denies *every* op (including reads) — grant what the agent needs via the spec's
373    /// `permissions`, [`allow`](Self::allow), or `auto_approve`. Builder methods overlay on top. A
374    /// `skills` is explicit: an empty list means no skills. Use
375    /// [`AgentSpec::try_with_default_skills`] deliberately if enabling every discovered skill is desired.
376    pub fn from_spec(spec: AgentSpec) -> Self {
377        Self {
378            spec,
379            envelope: envelope::Envelope::bare(),
380            storage: None,
381            cognition: false,
382            ops: Vec::new(),
383            packs: Vec::new(),
384            sub_agents: None,
385            sub_agent_adaptive_policy: None,
386        }
387    }
388    /// Set the model id every turn uses.
389    pub fn model(mut self, m: impl Into<String>) -> Self {
390        self.spec.model = m.into();
391        self
392    }
393    /// Override the system prompt (defaults to the agent's built-in prompt).
394    pub fn system_prompt(mut self, s: impl Into<String>) -> Self {
395        self.spec.system_prompt = s.into();
396        self
397    }
398    /// Cap the max output tokens per model call.
399    pub fn max_tokens(mut self, n: u32) -> Self {
400        self.spec.max_tokens = n;
401        self
402    }
403    /// Cap the authored outer loop's decision/batch iterations per turn (default: 50, maximum:
404    /// 1,000). This is separate from the adaptive policy's provider-call budget. Out-of-range
405    /// values fail when the agent is assembled.
406    pub fn max_iterations(mut self, n: usize) -> Self {
407        self.spec.max_iterations = n;
408        self
409    }
410    /// Configure intent/exploration model policy and the shared logical-run provider-call ceiling
411    /// (default: 50).
412    pub fn adaptive_policy(mut self, policy: AdaptiveLoopPolicy) -> Self {
413        self.spec.adaptive_policy = policy;
414        self
415    }
416    /// Select the Flux-Lang outer control program for conversational turns.
417    pub fn agent_loop(mut self, agent_loop: AgentLoopSpec) -> Self {
418        self.spec.agent_loop = agent_loop;
419        self
420    }
421    /// Add a permission allow rule (e.g. `"write"`, `"Bash(git:*)"`).
422    pub fn allow(mut self, rule: impl Into<String>) -> Self {
423        self.envelope.allow.push(rule.into());
424        self
425    }
426    /// Add a permission deny rule (takes precedence over allow rules).
427    pub fn deny(mut self, rule: impl Into<String>) -> Self {
428        self.envelope.deny.push(rule.into());
429        self
430    }
431    /// Approve every tool call automatically (no human in the loop). Use with care.
432    pub fn auto_approve(mut self, yes: bool) -> Self {
433        self.envelope.auto_approve = yes;
434        self
435    }
436    /// Inject a custom [`Approver`] the executor consults per op — a policy between the blanket
437    /// allow of [`auto_approve`](Self::auto_approve) and the headless default deny (e.g. a
438    /// risk-aware confirm gate). Overrides `auto_approve`. The same seam
439    /// [`FlowClientBuilder::approver`](crate::FlowClientBuilder::approver) and the sub-agent
440    /// spawner already have, now on the conversational door.
441    pub fn approver(mut self, approver: Arc<dyn Approver>) -> Self {
442        self.envelope.approver = Some(approver);
443        self
444    }
445    /// Install the mandatory authorization policy and resolved caller identity. This floor is
446    /// evaluated before permission rules or approval, so `auto_approve(true)` cannot widen it.
447    pub fn with_authorization(
448        mut self,
449        policy: flux_policy::AuthorizationPolicy,
450        caller: flux_policy::Caller,
451        trust: flux_policy::Trust,
452    ) -> Self {
453        self.envelope.authorization =
454            flux_runtime::ExecutionAuthorization::new(policy, caller, trust);
455        self
456    }
457    /// Install the shared secret redactor on the assembled agent executor.
458    pub fn with_redactor(mut self, redactor: Redactor) -> Self {
459        self.envelope.redactor = redactor;
460        self
461    }
462    /// Register a custom op (any [`Tool`], e.g. one built with `flux_runtime::tool_fn`) alongside
463    /// the built-ins. Registered ops dispatch through the same authorization → approval → guarded
464    /// IO envelope as every other op — registration grants existence, not permission.
465    pub fn register_op(mut self, tool: Arc<dyn Tool>) -> Self {
466        self.ops
467            .push(("sdk ClientBuilder::register_op".into(), tool));
468        self
469    }
470
471    /// Register a custom operation with an auditable owner label. Prefer this when composing
472    /// independently developed packs so duplicate-name errors identify both contributors.
473    pub fn register_op_from(mut self, source: impl Into<String>, tool: Arc<dyn Tool>) -> Self {
474        self.ops.push((source.into(), tool));
475        self
476    }
477    /// Register a whole pack of ops via a closure over the registry (the `register_*` convention
478    /// used across flux). Same envelope rules as [`register_op`](Self::register_op).
479    pub fn register_pack<F: FnOnce(&mut ToolRegistry) + 'static>(mut self, pack: F) -> Self {
480        self.packs.push(Box::new(move |registry| {
481            pack(registry);
482            Ok(())
483        }));
484        self
485    }
486    /// Register a fallible operation pack. This is the preferred composition seam: installers use
487    /// source-labelled registry methods and `build` returns any duplicate instead of panicking.
488    pub fn try_register_pack<F>(mut self, pack: F) -> Self
489    where
490        F: FnOnce(&mut ToolRegistry) -> Result<()> + 'static,
491    {
492        self.packs.push(Box::new(pack));
493        self
494    }
495    /// Attach a subprocess plugin's operations (feature `plugins`) as policy-gated tools. Load them
496    /// first with [`plugins::load_tools`] over a guarded [`System`] — plugin ops carry their own host
497    /// connection, so they dispatch through the safety envelope like any [`register_op`](Self::register_op)
498    /// tool (a subset via [`tools`](Self::tools) re-admits them). A `tools`-subset build keeps them.
499    ///
500    /// ```ignore
501    /// let tools = flux_sdk::plugins::load_tools(&system, "gitlab", &descriptor).await?;
502    /// let client = flux_sdk::Client::builder().with_plugin_tools(tools).build(provider, ".")?;
503    /// ```
504    #[cfg(feature = "plugins")]
505    pub fn with_plugin_tools(mut self, tools: Vec<Arc<dyn Tool>>) -> Self {
506        self.ops.extend(tools.into_iter().map(|tool| {
507            (
508                "sdk ClientBuilder::with_plugin_tools (plugin source unspecified)".into(),
509                tool,
510            )
511        }));
512        self
513    }
514
515    /// Attach one named plugin's tools with source-aware collision diagnostics.
516    #[cfg(feature = "plugins")]
517    pub fn with_plugin_tools_from(
518        mut self,
519        plugin: impl Into<String>,
520        tools: Vec<Arc<dyn Tool>>,
521    ) -> Self {
522        let source = format!("plugin:{}", plugin.into());
523        self.ops
524            .extend(tools.into_iter().map(|tool| (source.clone(), tool)));
525        self
526    }
527    /// Attach named sub-agents to the conversational client: at [`build`](Self::build) the `task`
528    /// tool joins this client's catalog and the spawner is built over the client's guarded `System`,
529    /// so a turn whose plan calls `task(role, …)` delegates to a role's child agent through the same
530    /// authorization → approval → guarded-IO envelope. The single seam
531    /// [`FlowClient::with_sub_agents`](crate::FlowClient::with_sub_agents) already offered, now on
532    /// the conversational door — a consumer (e.g. a multi-tenant service) drives sub-agents without
533    /// re-assembling the spawner, executor, and context by hand.
534    ///
535    /// A generous default `wall_clock` (10 min) is applied when the bundle sets none, so a hung
536    /// child can't run forever; override it (or any limit) via
537    /// [`SubAgents::with_limits`](subagents::SubAgents::with_limits). A streamed conversational
538    /// turn's cancel token ([`Session::stream`]`().cancel()`) reaches a running child. A top-level
539    /// one-shot [`FlowClient`] has no cancel or parent lineage; when opened inside a guarded adapter,
540    /// however, it lexically inherits both and pins them before streamed execution crosses
541    /// `tokio::spawn`.
542    pub fn with_sub_agents(mut self, mut sub_agents: SubAgents) -> Self {
543        if sub_agents.limits.wall_clock.is_none() {
544            sub_agents.limits.wall_clock = Some(std::time::Duration::from_secs(600));
545        }
546        self.sub_agents = Some(sub_agents);
547        self.sub_agent_adaptive_policy = None;
548        self
549    }
550    /// Attach named sub-agents with an explicit native intent/explore policy for every child.
551    /// This is the conversational-client sibling of
552    /// [`FlowClient::with_sub_agents_policy`](crate::FlowClient::with_sub_agents_policy). Existing
553    /// [`with_sub_agents`](Self::with_sub_agents) callers retain
554    /// [`AdaptiveLoopPolicy::default`]; this method lets an embedding host independently select
555    /// child stage models/effort and bound output plus model calls.
556    pub fn with_sub_agents_policy(
557        mut self,
558        mut sub_agents: SubAgents,
559        adaptive_policy: AdaptiveLoopPolicy,
560    ) -> Self {
561        if sub_agents.limits.wall_clock.is_none() {
562            sub_agents.limits.wall_clock = Some(std::time::Duration::from_secs(600));
563        }
564        self.sub_agents = Some(sub_agents);
565        self.sub_agent_adaptive_policy = Some(adaptive_policy);
566        self
567    }
568    /// Restrict the agent to a subset of the registry's ops by name (`AgentSpec::tools`). Ops
569    /// outside the subset are not just hidden — they are absent from this agent's registry.
570    pub fn tools<I, S>(mut self, subset: I) -> Self
571    where
572        I: IntoIterator<Item = S>,
573        S: Into<String>,
574    {
575        self.spec.tools = Some(subset.into_iter().map(Into::into).collect());
576        self
577    }
578    /// Also wire the provider-backed cognition pack (`ai.extract`/`rank`/`judge`/`reason`,
579    /// `synth`, `ai.rewrite`) into the registry — the same pack [`FlowClient`] assembles by
580    /// default. Off by default on the conversational door.
581    pub fn with_cognition(mut self, yes: bool) -> Self {
582        self.cognition = yes;
583        self
584    }
585    /// Inject an explicit OS-sandbox [`Sandbox`] that the built client's guarded `System` enforces on
586    /// every spawn. When left unset (the default), the posture is resolved from the environment at
587    /// [`build`](Self::build) via `Sandbox::resolve(SandboxSettings::from_env())` — so a consumer that
588    /// exports `FLUX_SANDBOX=require` gets confinement without calling this (off ⇒ disabled, safe).
589    /// Pass one only to pin a posture independent of ambient env.
590    pub fn with_sandbox(mut self, sandbox: Sandbox) -> Self {
591        self.envelope.sandbox = Some(sandbox);
592        self
593    }
594    /// Choose where sessions live ([`Storage::in_memory`] by default). [`Storage::dir`] makes the
595    /// client's sessions — turn history, suspended flows, projections — survive the process, and
596    /// is what makes [`Client::open_session`] useful across restarts.
597    pub fn storage(mut self, storage: Storage) -> Self {
598        self.storage = Some(storage);
599        self
600    }
601    /// Inject a knowledge block into the agent's system prompt as a `<knowledge-base>` section (A-19):
602    /// grounds the agent on a small KB inline, with no retrieval round-trip. Chainable.
603    pub fn add_context(
604        mut self,
605        id: impl Into<String>,
606        title: impl Into<String>,
607        body: impl Into<String>,
608    ) -> Self {
609        self.spec.context.push(ContextBlock::new(id, title, body));
610        self
611    }
612    /// Set the evidence-gated tool groups. Each turn the workspace is probed for signals and only
613    /// ops whose group has surfaced are advertised to the model; an op named in a group's `tools`
614    /// stays hidden until that group's `surface_when` signal fires (surfacing is sticky-monotonic
615    /// within a session). Empty (the default) disables gating — every op is advertised. Name a group
616    /// with [`ToolGroup`](observe::ToolGroup) (re-exported via [`observe`]); pair with
617    /// [`ambient_signals`](Self::ambient_signals) for signals the per-turn workspace walk can't see.
618    pub fn groups<I>(mut self, groups: I) -> Self
619    where
620        I: IntoIterator<Item = flux_evidence::ToolGroup>,
621    {
622        self.spec.groups = groups.into_iter().collect();
623        self
624    }
625    /// Add session-ambient group-surfacing signals (D-115): host-known facts the per-turn workspace
626    /// walk can't observe (e.g. "an endpoints store is loaded"). Appended to every turn's probed
627    /// signals, so a startup-static value is enough to surface its [`groups`](Self::groups). Empty by
628    /// default.
629    pub fn ambient_signals<I, S>(mut self, signals: I) -> Self
630    where
631        I: IntoIterator<Item = S>,
632        S: Into<String>,
633    {
634        self.spec.ambient_signals = signals.into_iter().map(Into::into).collect();
635        self
636    }
637    /// Set the compaction threshold in serialized chars: once a persisted session grows past it,
638    /// older turns are summarized into a durable digest before the next request (A-22), instead of
639    /// re-sending an ever-growing transcript. `0` disables compaction. Defaults to
640    /// `flux_agent::DEFAULT_COMPACT_THRESHOLD_CHARS` (matching the CLI).
641    pub fn with_compaction(mut self, threshold_chars: usize) -> Self {
642        self.spec.compact_threshold_chars = threshold_chars;
643        self
644    }
645    /// Set the byte budget for the rendered inline `context` knowledge blocks
646    /// ([`add_context`](Self::add_context)); over-budget blocks truncate with a marker. `0` =
647    /// unbounded. Defaults to `flux_agent::DEFAULT_CONTEXT_BUDGET`.
648    pub fn context_budget(mut self, bytes: usize) -> Self {
649        self.spec.context_budget = bytes;
650        self
651    }
652
653    /// Build the client with `provider` and a workspace rooted at `root`. Sessions live in the
654    /// configured [`Storage`] (in-memory unless set). The turn runs on [`FlowEngine`] (the model
655    /// plans, the runtime runs the flux-lang agent loop).
656    pub fn build(self, provider: Box<dyn Provider>, root: impl Into<PathBuf>) -> Result<Client> {
657        let root = root.into();
658        let provider: Arc<dyn Provider> = Arc::from(provider);
659        // Attach the OS-sandbox posture so a consumer's `FLUX_SANDBOX=require` is honored on this
660        // client's spawns; a bare `System::new` defaults to `Sandbox::disabled()` (no confinement,
661        // no `require` enforcement). Unset ⇒ resolve from env (off ⇒ disabled, safe default).
662        let sandbox = self.envelope.resolve_sandbox();
663        let system = Arc::new(System::new(Workspace::new(root.clone())?).with_sandbox(sandbox));
664        let mut registry = ToolRegistry::new();
665        flux_tools::try_register_builtins(&mut registry)?;
666        if self.cognition {
667            CognitionPack::new(provider.clone(), self.spec.model.clone())
668                .with_reasoning(self.spec.thinking, self.spec.effort)
669                .try_register_from("sdk ClientBuilder cognition pack", &mut registry)?;
670        }
671        // Snapshot the base op names (built-ins + cognition) so consumer-registered ops can be
672        // told apart below: a `tools` subset restricts the *base* catalog, but must not silently
673        // drop a tool the consumer explicitly registered.
674        let base_names: std::collections::HashSet<String> = registry.names().into_iter().collect();
675        // Consumer ops/packs join the same registry the envelope gates — registration grants
676        // existence, not permission (the safety envelope still gates every dispatch).
677        for (source, tool) in self.ops {
678            registry.try_register_from(source, tool)?;
679        }
680        for pack in self.packs {
681            pack(&mut registry)?;
682        }
683        // Sub-agents: register the `task` tool BEFORE the custom-name snapshot so it rides the same
684        // re-admit into a `tools` subset every consumer-registered op does (a consumer that scoped
685        // the catalog AND asked for sub-agents still keeps `task`). The spawner is attached to the
686        // dispatch context below.
687        if self.sub_agents.is_some() {
688            registry.try_register_from(
689                "sdk ClientBuilder sub-agent task operation",
690                Arc::new(TaskTool),
691            )?;
692        }
693        let custom_names: Vec<String> = registry
694            .names()
695            .into_iter()
696            .filter(|n| !base_names.contains(n))
697            .collect();
698        let approver = self.envelope.resolve_approver();
699
700        let (events, flow) = self.storage.unwrap_or_default().resolve()?;
701
702        // The agent's definition; `assemble` selects the tool subset, applies the permissions,
703        // registers the authored-loop stages, and ties the engine⇄loop-host cycle. Builder rules are
704        // additive to the spec's own (`from_spec` starts from a bare envelope). Skills are explicit:
705        // an empty set stays empty and no default directory is scanned or activated.
706        let mut spec = self.spec;
707        spec.permissions
708            .allow
709            .extend(self.envelope.allow.iter().cloned());
710        spec.permissions
711            .deny
712            .extend(self.envelope.deny.iter().cloned());
713        // A `tools` subset restricts the base catalog only — re-admit every consumer-registered op
714        // so `register_op(...).tools([...])` never silently drops the just-added tool.
715        if let Some(tools) = spec.tools.as_mut() {
716            for name in custom_names {
717                if !tools.contains(&name) {
718                    tools.push(name);
719                }
720            }
721        }
722        spec.cwd = root;
723        let authorization = self.envelope.authorization.clone();
724        let mut environment = ExecutionEnvironment::new(
725            system.clone(),
726            registry,
727            PermissionManager::new(),
728            approver,
729            authorization.clone(),
730        )
731        .with_redactor(self.envelope.redactor.clone());
732        // Thread the sub-agent spawner into the shared environment when sub-agents are attached, so
733        // a `task` call delegates through the same guarded `System`; `None` (the common case) leaves
734        // the context exactly as before. Mirrors `FlowClient::build_executor`.
735        if let Some(sub_agents) = self.sub_agents {
736            let sub_agents = sub_agents
737                .with_reasoning(spec.thinking, spec.effort)
738                .with_authorization_cell(authorization.policy().clone(), authorization.identity());
739            let spawner = match self.sub_agent_adaptive_policy {
740                Some(policy) => {
741                    sub_agents.into_spawner_with_adaptive_policy(system.clone(), policy)
742                }
743                None => sub_agents.into_spawner(system.clone()),
744            };
745            environment = environment.with_spawner(spawner);
746        }
747        let model = spec.model.clone();
748        let engine = spec.assemble_in(provider, environment, events, flow)?;
749        Ok(Client {
750            engine: Arc::new(engine),
751            model,
752            // The default session is created lazily (on first use), so building a client — e.g. a
753            // service restarting against a persistent `Storage::dir` — never leaves an empty
754            // session behind, and `latest_session()` still points at the real prior conversation.
755            default_session: std::sync::Mutex::new(None),
756            turn_guard: Arc::new(tokio::sync::Mutex::new(())),
757        })
758    }
759}
760
761/// A configured agent (runs on [`FlowEngine`]): the expensive, long-lived half of the SDK's
762/// conversational door. Conversations are [`Session`] handles — a fresh default one is created at
763/// build (so [`Client::run`] works out of the box), and [`Client::create_session`] /
764/// [`Client::open_session`] / [`Client::latest_session`] manage the rest. With persistent
765/// [`Storage`], sessions — and their suspended flows — survive the process.
766pub struct Client {
767    engine: Arc<FlowEngine>,
768    model: String,
769    // The default session's id, created lazily on first use (see `default_id`). Kept behind a
770    // std mutex so two concurrent first-uses can't each mint (and leak) a session.
771    default_session: std::sync::Mutex<Option<String>>,
772    // FlowEngine owns its single-active-turn invariant. Every Session also shares this broader SDK
773    // operation guard so turns remain ordered with replay/fork/divergence paths that intentionally
774    // execute against the same stores or executor outside FlowEngine's public turn lifecycle.
775    turn_guard: Arc<tokio::sync::Mutex<()>>,
776}
777
778impl Client {
779    /// Start building a [`Client`].
780    pub fn builder() -> ClientBuilder {
781        ClientBuilder::default()
782    }
783
784    /// The default session's id, minting it on first call. Returns owned since the id lives behind
785    /// a mutex (the lazy-creation guard).
786    fn default_id(&self) -> Result<String> {
787        let mut slot = self.default_session.lock().unwrap();
788        if let Some(id) = slot.as_ref() {
789            return Ok(id.clone());
790        }
791        let id = self.engine.events.create_session(&self.model)?;
792        *slot = Some(id.clone());
793        Ok(id)
794    }
795
796    /// The id of the default session this client's [`run`](Self::run) turns are recorded against.
797    /// The default session is created lazily, so this call mints it on first use.
798    pub fn session_id(&self) -> Result<String> {
799        self.default_id()
800    }
801
802    /// Run one turn on the default session, collecting the final text and the tools invoked.
803    /// Equivalent to `client.default_session()?.send(input)`.
804    pub async fn run(&self, input: &str) -> Result<TurnOutput> {
805        let id = self.default_id()?;
806        self.session(id).send(input).await
807    }
808
809    /// The default session as a [`Session`] handle (created lazily on first use).
810    pub fn default_session(&self) -> Result<Session> {
811        Ok(self.session(self.default_id()?))
812    }
813
814    /// Create a fresh session and return its handle.
815    pub fn create_session(&self) -> Result<Session> {
816        let id = self.engine.events.create_session(&self.model)?;
817        Ok(self.session(id))
818    }
819
820    /// Open an existing session by id — the resume seam. Errors if the id is unknown to this
821    /// client's [`Storage`]. A session parked on a top-level `await` resumes on the next
822    /// [`Session::send`].
823    pub fn open_session(&self, id: &str) -> Result<Session> {
824        self.engine.events.info(id)?;
825        Ok(self.session(id.to_string()))
826    }
827
828    /// The most recently updated session in this client's [`Storage`], if any. Because the default
829    /// session is created lazily (not at build), this returns the real prior conversation after a
830    /// restart against a persistent [`Storage::dir`] — as long as no turn has run on this client
831    /// yet to mint a newer default. To target a specific conversation, persist its id and use
832    /// [`open_session`](Self::open_session).
833    pub fn latest_session(&self) -> Result<Option<Session>> {
834        Ok(self
835            .engine
836            .events
837            .latest_session()?
838            .map(|id| self.session(id)))
839    }
840
841    /// The client's event store — the escape hatch for projections and integrations the typed
842    /// surface doesn't cover yet.
843    pub fn event_store(&self) -> Arc<EventStore> {
844        self.engine.events.clone()
845    }
846
847    /// The assembled engine — the documented advanced escape hatch. Everything reachable from
848    /// here still dispatches through the same authorization → approval → guarded-IO envelope, and
849    /// public turn entries retain the engine's mandatory single-active-turn guarantee.
850    pub fn engine(&self) -> &Arc<FlowEngine> {
851        &self.engine
852    }
853
854    fn session(&self, id: String) -> Session {
855        Session {
856            engine: self.engine.clone(),
857            id,
858            turn_guard: self.turn_guard.clone(),
859        }
860    }
861}
862
863#[cfg(test)]
864mod tests {
865    use super::*;
866    use async_trait::async_trait;
867    use flux_core::{Chunk, ContentBlock, StopReason, Usage};
868    use flux_provider::{ChunkStream, Request};
869    use std::sync::Mutex;
870
871    fn parse_role(content: &str, name_fallback: &str) -> crate::subagents::Role {
872        crate::subagents::try_parse_role(content, name_fallback).unwrap()
873    }
874
875    fn request_has_tool(request: &Request, name: &str) -> bool {
876        request.tools.iter().any(|tool| tool.name == name)
877    }
878
879    fn intent_chunks(intent: &str, families: &[&str]) -> Vec<Chunk> {
880        vec![
881            Chunk::Block(ContentBlock::ToolUse {
882                id: "intent".into(),
883                name: "declare_intent".into(),
884                input: serde_json::json!({
885                    "intent": intent,
886                    "capability_families": families,
887                }),
888            }),
889            Chunk::Done {
890                stop_reason: Some(StopReason::ToolUse),
891            },
892        ]
893    }
894
895    fn native_call(id: &str, name: &str, input: serde_json::Value) -> Vec<Chunk> {
896        vec![
897            Chunk::Block(ContentBlock::ToolUse {
898                id: id.into(),
899                name: name.into(),
900                input,
901            }),
902            Chunk::Done {
903                stop_reason: Some(StopReason::ToolUse),
904            },
905        ]
906    }
907
908    struct OneShotMock {
909        chunks: Mutex<Option<Vec<Chunk>>>,
910    }
911    #[async_trait]
912    impl Provider for OneShotMock {
913        fn name(&self) -> &str {
914            "mock"
915        }
916        async fn stream(&self, req: Request) -> Result<ChunkStream> {
917            let chunks = if request_has_tool(&req, "declare_intent") {
918                intent_chunks("answer the user", &[])
919            } else {
920                self.chunks.lock().unwrap().take().unwrap_or_default()
921            };
922            Ok(Box::pin(futures::stream::iter(chunks.into_iter().map(Ok))))
923        }
924    }
925
926    #[tokio::test]
927    async fn client_runs_a_text_turn() {
928        let dir = std::env::temp_dir().join(format!("flux-sdk-test-{}", std::process::id()));
929        std::fs::create_dir_all(&dir).unwrap();
930        // The model first emits the required intent signal, then answers in prose without calling
931        // an operation.
932        let provider = Box::new(OneShotMock {
933            chunks: Mutex::new(Some(vec![
934                Chunk::TextDelta("hello from sdk".into()),
935                Chunk::Block(ContentBlock::Text {
936                    text: "hello from sdk".into(),
937                }),
938                Chunk::Usage(Usage {
939                    input_tokens: 64,
940                    output_tokens: 8,
941                    cache_read_input_tokens: 16,
942                    ..Default::default()
943                }),
944                Chunk::Done {
945                    stop_reason: Some(StopReason::EndTurn),
946                },
947            ])),
948        });
949        let client = Client::builder()
950            .model("mock")
951            .build(provider, &dir)
952            .unwrap();
953        let out = client.run("hi").await.unwrap();
954        assert_eq!(out.text, "hello from sdk");
955        assert!(out.tool_calls.is_empty());
956        // Token usage rides back out through the unified Flux-authored loop: every model stage's
957        // `Usage` is accumulated by the loop host and handed to `turn_end` at turn completion.
958        let usage = out
959            .usage
960            .expect("usage surfaced through the FlowEngine loop");
961        assert_eq!(usage.input_tokens, 64);
962        assert_eq!(usage.output_tokens, 8);
963        assert_eq!(usage.cache_read_input_tokens, 16);
964        std::fs::remove_dir_all(&dir).ok();
965    }
966
967    /// A mock that records every request's system prompt (segments + legacy `system`) so a test can
968    /// assert what the engine actually sent to the model.
969    struct SystemCaptureMock {
970        systems: Arc<Mutex<Vec<String>>>,
971    }
972    #[async_trait]
973    impl Provider for SystemCaptureMock {
974        fn name(&self) -> &str {
975            "mock"
976        }
977        async fn stream(&self, req: Request) -> Result<ChunkStream> {
978            let mut sys = String::new();
979            for seg in &req.system_segments {
980                sys.push_str(&seg.text);
981                sys.push('\n');
982            }
983            if let Some(s) = &req.system {
984                sys.push_str(s);
985            }
986            self.systems.lock().unwrap().push(sys);
987            let chunks = if request_has_tool(&req, "declare_intent") {
988                intent_chunks("answer the user", &[])
989            } else {
990                vec![
991                    Chunk::Block(ContentBlock::Text { text: "ok".into() }),
992                    Chunk::Done {
993                        stop_reason: Some(StopReason::EndTurn),
994                    },
995                ]
996            };
997            Ok(Box::pin(futures::stream::iter(chunks.into_iter().map(Ok))))
998        }
999    }
1000
1001    /// A-69: SDK skills are explicit. Merely placing a matching skill under the workspace must not
1002    /// alter prompts; a caller can still deliberately populate `AgentSpec.skills`.
1003    #[tokio::test]
1004    async fn sdk_skills_require_an_explicit_agent_spec() {
1005        let dir = std::env::temp_dir().join(format!("flux-sdk-skills-{}", std::process::id()));
1006        let skills = dir.join(".flux").join("skills");
1007        std::fs::create_dir_all(&skills).unwrap();
1008        std::fs::write(
1009            skills.join("greeting.md"),
1010            "---\nname: greeting\ndescription: how to greet\ntriggers: [zorblefrazz]\n---\nAlways greet with ahoy.",
1011        )
1012        .unwrap();
1013
1014        let systems = Arc::new(Mutex::new(Vec::new()));
1015        let provider = Box::new(SystemCaptureMock {
1016            systems: systems.clone(),
1017        });
1018        let client = Client::builder()
1019            .model("mock")
1020            .build(provider, &dir)
1021            .unwrap();
1022        client.run("please zorblefrazz me").await.unwrap();
1023
1024        let sys = systems.lock().unwrap().join("\n---\n");
1025        assert!(
1026            !sys.contains("Always greet with ahoy."),
1027            "workspace discovery must not activate a skill implicitly; got:\n{sys}"
1028        );
1029
1030        let systems = Arc::new(Mutex::new(Vec::new()));
1031        let provider = Box::new(SystemCaptureMock {
1032            systems: systems.clone(),
1033        });
1034        let spec = AgentSpec {
1035            cwd: dir.clone(),
1036            ..AgentSpec::new("mock")
1037        }
1038        .try_with_default_skills()
1039        .unwrap();
1040        let client = ClientBuilder::from_spec(spec)
1041            .build(provider, &dir)
1042            .unwrap();
1043        client.run("an unrelated turn").await.unwrap();
1044        let sys = systems.lock().unwrap().join("\n---\n");
1045        assert!(
1046            sys.contains("<skill name=\"greeting\">") && sys.contains("Always greet with ahoy."),
1047            "an explicitly populated AgentSpec injects the skill; got:\n{sys}"
1048        );
1049        std::fs::remove_dir_all(&dir).ok();
1050    }
1051
1052    /// Adaptive mock: declare a write intent, call the provider-native `write` schema, then answer
1053    /// from the execution report. Proves the SDK drives the full authored outer loop and a real op
1054    /// dispatches through the envelope and surfaces to the sink.
1055    struct PlanThenProseMock {
1056        calls: std::sync::atomic::AtomicUsize,
1057    }
1058    #[async_trait]
1059    impl Provider for PlanThenProseMock {
1060        fn name(&self) -> &str {
1061            "mock"
1062        }
1063        async fn stream(&self, req: Request) -> Result<ChunkStream> {
1064            if request_has_tool(&req, "declare_intent") {
1065                return Ok(Box::pin(futures::stream::iter(
1066                    intent_chunks("write a file", &["workspace.write"])
1067                        .into_iter()
1068                        .map(Ok),
1069                )));
1070            }
1071            let n = self
1072                .calls
1073                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1074            let chunks = if n == 0 {
1075                native_call(
1076                    "write-1",
1077                    "write",
1078                    serde_json::json!({
1079                        "path": "sdk-plan.txt",
1080                        "content": "from the sdk action batch\n"
1081                    }),
1082                )
1083            } else if n == 1 {
1084                native_call(
1085                    "finalize-1",
1086                    "finalize_plan",
1087                    serde_json::json!({
1088                        "instructions": "Report whether the file was written."
1089                    }),
1090                )
1091            } else {
1092                vec![
1093                    Chunk::Block(ContentBlock::Text {
1094                        text: "Wrote the file.".into(),
1095                    }),
1096                    Chunk::Done {
1097                        stop_reason: Some(StopReason::EndTurn),
1098                    },
1099                ]
1100            };
1101            Ok(Box::pin(futures::stream::iter(chunks.into_iter().map(Ok))))
1102        }
1103    }
1104
1105    /// A reusable prose mock: every call answers with the same text (no `take()` — it survives
1106    /// multiple turns and multiple client builds).
1107    struct ProseMock {
1108        text: &'static str,
1109    }
1110    #[async_trait]
1111    impl Provider for ProseMock {
1112        fn name(&self) -> &str {
1113            "mock"
1114        }
1115        async fn stream(&self, req: Request) -> Result<ChunkStream> {
1116            let chunks = if request_has_tool(&req, "declare_intent") {
1117                intent_chunks("answer the user", &[])
1118            } else {
1119                vec![
1120                    Chunk::TextDelta(self.text.into()),
1121                    Chunk::Block(ContentBlock::Text {
1122                        text: self.text.into(),
1123                    }),
1124                    Chunk::Done {
1125                        stop_reason: Some(StopReason::EndTurn),
1126                    },
1127                ]
1128            };
1129            Ok(Box::pin(futures::stream::iter(chunks.into_iter().map(Ok))))
1130        }
1131    }
1132
1133    /// D-142: `Storage::dir` makes sessions durable — a second client over the same directory
1134    /// resumes the first client's session by id and reads its history.
1135    #[tokio::test]
1136    async fn storage_dir_persists_and_resumes_a_session() {
1137        let dir = std::env::temp_dir().join(format!("flux-sdk-store-{}", std::process::id()));
1138        std::fs::remove_dir_all(&dir).ok();
1139        std::fs::create_dir_all(&dir).unwrap();
1140        let store_dir = dir.join("state");
1141
1142        let client = Client::builder()
1143            .model("mock")
1144            .storage(Storage::dir(&store_dir))
1145            .build(Box::new(ProseMock { text: "first" }), &dir)
1146            .unwrap();
1147        let out = client.run("hello").await.unwrap();
1148        assert_eq!(out.text, "first");
1149        let id = client.session_id().unwrap();
1150        drop(client);
1151
1152        // A "new process": a fresh client over the same storage dir resumes the session.
1153        let client = Client::builder()
1154            .model("mock")
1155            .storage(Storage::dir(&store_dir))
1156            .build(Box::new(ProseMock { text: "second" }), &dir)
1157            .unwrap();
1158        let session = client.open_session(&id).unwrap();
1159        let history = session.history().unwrap();
1160        assert!(
1161            history.len() >= 2,
1162            "expected the prior turn's user+assistant messages, got {}",
1163            history.len()
1164        );
1165        let out = session.send("again").await.unwrap();
1166        assert_eq!(out.text, "second");
1167        assert!(session.history().unwrap().len() > history.len());
1168        std::fs::remove_dir_all(&dir).ok();
1169    }
1170
1171    /// A mock that routes to one named provider-native op, then answers from its result.
1172    struct PlanOpMock {
1173        op: &'static str,
1174        calls: std::sync::atomic::AtomicUsize,
1175    }
1176    #[async_trait]
1177    impl Provider for PlanOpMock {
1178        fn name(&self) -> &str {
1179            "mock"
1180        }
1181        async fn stream(&self, req: Request) -> Result<ChunkStream> {
1182            if request_has_tool(&req, "declare_intent") {
1183                return Ok(Box::pin(futures::stream::iter(
1184                    intent_chunks("call the requested operation", &["core"])
1185                        .into_iter()
1186                        .map(Ok),
1187                )));
1188            }
1189            let n = self
1190                .calls
1191                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1192            let chunks = if n == 0 {
1193                let input = if self.op == "park" {
1194                    serde_json::json!({})
1195                } else {
1196                    serde_json::json!({"name": "flux"})
1197                };
1198                native_call("op-1", self.op, input)
1199            } else {
1200                vec![
1201                    Chunk::Block(ContentBlock::Text {
1202                        text: "done".into(),
1203                    }),
1204                    Chunk::Done {
1205                        stop_reason: Some(StopReason::EndTurn),
1206                    },
1207                ]
1208            };
1209            Ok(Box::pin(futures::stream::iter(chunks.into_iter().map(Ok))))
1210        }
1211    }
1212
1213    fn greet_tool(hits: Arc<std::sync::atomic::AtomicUsize>) -> Arc<dyn flux_runtime::Tool> {
1214        flux_runtime::tool_fn(
1215            flux_spec::ToolSpec::read_only(
1216                "greet",
1217                "Greets by name",
1218                serde_json::json!({
1219                    "type": "object",
1220                    "properties": { "name": { "type": "string" } },
1221                    "required": ["name"]
1222                }),
1223            ),
1224            move |input| {
1225                let hits = hits.clone();
1226                async move {
1227                    hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1228                    Ok(serde_json::json!(format!(
1229                        "hello {}",
1230                        input["name"].as_str().unwrap_or("?")
1231                    )))
1232                }
1233            },
1234        )
1235    }
1236
1237    fn guarded_read_tool(hits: Arc<std::sync::atomic::AtomicUsize>) -> Arc<dyn flux_runtime::Tool> {
1238        flux_runtime::tool_fn(
1239            flux_spec::ToolSpec::read_only(
1240                "guarded_read",
1241                "A filesystem-scoped read used to exercise authorization",
1242                serde_json::json!({
1243                    "type": "object",
1244                    "properties": { "name": { "type": "string" } }
1245                }),
1246            )
1247            .with_access(vec![flux_spec::AccessKind::Filesystem]),
1248            move |_input| {
1249                let hits = hits.clone();
1250                async move {
1251                    hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1252                    Ok(serde_json::json!("ran"))
1253                }
1254            },
1255        )
1256    }
1257
1258    /// D-143: a `tool_fn` registered on the builder is callable by a planned turn — and it runs
1259    /// through the envelope, not around it (`auto_approve` is what permits it here).
1260    #[tokio::test]
1261    async fn a_registered_custom_tool_dispatches_through_a_planned_turn() {
1262        let dir = std::env::temp_dir().join(format!("flux-sdk-fntool-{}", std::process::id()));
1263        std::fs::create_dir_all(&dir).unwrap();
1264        let hits = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1265        let client = Client::builder()
1266            .model("mock")
1267            .auto_approve(true)
1268            .register_op(greet_tool(hits.clone()))
1269            .build(
1270                Box::new(PlanOpMock {
1271                    op: "greet",
1272                    calls: std::sync::atomic::AtomicUsize::new(0),
1273                }),
1274                &dir,
1275            )
1276            .unwrap();
1277        let out = client.run("greet flux").await.unwrap();
1278        assert_eq!(hits.load(std::sync::atomic::Ordering::Relaxed), 1);
1279        assert_eq!(out.tool_calls, vec!["greet"]);
1280        std::fs::remove_dir_all(&dir).ok();
1281    }
1282
1283    /// C-60: blanket approval is subordinate to the SDK client's mandatory policy floor.
1284    #[tokio::test]
1285    async fn client_auto_approval_cannot_widen_authorization() {
1286        let dir =
1287            std::env::temp_dir().join(format!("flux-sdk-policy-floor-{}", std::process::id()));
1288        std::fs::create_dir_all(&dir).unwrap();
1289        let hits = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1290        let mut policy = flux_policy::default_local_grants();
1291        policy.grants.retain(|grant| {
1292            !grant
1293                .actions
1294                .iter()
1295                .any(|action| action.0 == "workspace.read")
1296        });
1297        let (caller, trust) = flux_policy::local_identity("sdk-test");
1298        let client = Client::builder()
1299            .model("mock")
1300            .auto_approve(true)
1301            .with_authorization(policy, caller, trust)
1302            .register_op(guarded_read_tool(hits.clone()))
1303            .build(
1304                Box::new(PlanOpMock {
1305                    op: "guarded_read",
1306                    calls: std::sync::atomic::AtomicUsize::new(0),
1307                }),
1308                &dir,
1309            )
1310            .unwrap();
1311
1312        let _ = client.run("greet flux").await;
1313        assert_eq!(
1314            hits.load(std::sync::atomic::Ordering::SeqCst),
1315            0,
1316            "auto approval must not execute a policy-denied operation"
1317        );
1318        std::fs::remove_dir_all(&dir).ok();
1319    }
1320
1321    /// D-143: an injected deny-listing `Approver` gates a registered custom tool — registration
1322    /// grants existence, not permission (mirrors the FlowClient-side
1323    /// `an_injected_approver_policy_gates_per_op`).
1324    #[tokio::test]
1325    async fn an_injected_approver_gates_a_registered_custom_tool() {
1326        struct DenyGreet;
1327        #[async_trait]
1328        impl flux_runtime::Approver for DenyGreet {
1329            async fn request(
1330                &self,
1331                tool: &str,
1332                _subjects: &[String],
1333                _intents: &flux_spec::IntentSet,
1334            ) -> flux_runtime::ApprovalChoice {
1335                if tool == "greet" {
1336                    flux_runtime::ApprovalChoice::Deny
1337                } else {
1338                    flux_runtime::ApprovalChoice::Allow
1339                }
1340            }
1341        }
1342
1343        let dir = std::env::temp_dir().join(format!("flux-sdk-fngate-{}", std::process::id()));
1344        std::fs::create_dir_all(&dir).unwrap();
1345        let hits = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1346        let client = Client::builder()
1347            .model("mock")
1348            .approver(Arc::new(DenyGreet))
1349            .register_op(greet_tool(hits.clone()))
1350            .build(
1351                Box::new(PlanOpMock {
1352                    op: "greet",
1353                    calls: std::sync::atomic::AtomicUsize::new(0),
1354                }),
1355                &dir,
1356            )
1357            .unwrap();
1358        let _ = client.run("greet flux").await;
1359        assert_eq!(
1360            hits.load(std::sync::atomic::Ordering::Relaxed),
1361            0,
1362            "the injected approver must gate the registered tool"
1363        );
1364        std::fs::remove_dir_all(&dir).ok();
1365    }
1366
1367    /// D-143: `tools(subset)` removes ops from the agent's registry — a plan calling an
1368    /// out-of-subset op cannot execute it (the loop machinery itself stays registered).
1369    #[tokio::test]
1370    async fn tools_subset_removes_ops_from_the_registry() {
1371        let dir = std::env::temp_dir().join(format!("flux-sdk-subset-{}", std::process::id()));
1372        std::fs::create_dir_all(&dir).unwrap();
1373        let client = Client::builder()
1374            .model("mock")
1375            .auto_approve(true)
1376            .tools(["read"])
1377            .build(
1378                Box::new(PlanThenProseMock {
1379                    calls: std::sync::atomic::AtomicUsize::new(0),
1380                }),
1381                &dir,
1382            )
1383            .unwrap();
1384        // The plan calls `write`, which the subset removed: the turn must complete without the
1385        // write ever happening.
1386        let _ = client.run("write a file").await;
1387        assert!(
1388            !dir.join("sdk-plan.txt").exists(),
1389            "an out-of-subset op must not execute"
1390        );
1391        std::fs::remove_dir_all(&dir).ok();
1392    }
1393
1394    /// D-144: `send_with` streams to a consumer sink — text deltas arrive AND tool results arrive
1395    /// (the old private collector dropped tool results entirely).
1396    #[tokio::test]
1397    async fn send_with_streams_deltas_and_tool_results_to_a_consumer_sink() {
1398        #[derive(Default)]
1399        struct Recording {
1400            deltas: Vec<String>,
1401            tool_results: Vec<String>,
1402        }
1403        impl AgentSink for Recording {
1404            fn text_delta(&mut self, t: &str) {
1405                self.deltas.push(t.to_string());
1406            }
1407            fn tool_result(&mut self, name: &str, _result: &flux_runtime::ToolResult) {
1408                self.tool_results.push(name.to_string());
1409            }
1410        }
1411
1412        let dir = std::env::temp_dir().join(format!("flux-sdk-sendwith-{}", std::process::id()));
1413        std::fs::create_dir_all(&dir).unwrap();
1414        let client = Client::builder()
1415            .model("mock")
1416            .auto_approve(true)
1417            .build(
1418                Box::new(PlanThenProseMock {
1419                    calls: std::sync::atomic::AtomicUsize::new(0),
1420                }),
1421                &dir,
1422            )
1423            .unwrap();
1424        let session = client.default_session().unwrap();
1425        let mut sink = Recording::default();
1426        let out = session
1427            .send_with("write a file", &mut sink, &CancellationToken::new())
1428            .await
1429            .unwrap();
1430        assert_eq!(out.text, "Wrote the file.");
1431        // `deltas` is exercised live by `stream_yields_events_live_and_finish_collects`; the
1432        // load-bearing assertion here is the tool RESULT reaching the consumer (the old private
1433        // collector dropped those entirely).
1434        assert!(
1435            sink.tool_results.contains(&"write".to_string()),
1436            "the consumer sink must receive tool_result events, got {:?}",
1437            sink.tool_results
1438        );
1439        std::fs::remove_dir_all(&dir).ok();
1440    }
1441
1442    /// Streams the answer in two text deltas, then the final block.
1443    struct TwoDeltaMock;
1444    #[async_trait]
1445    impl Provider for TwoDeltaMock {
1446        fn name(&self) -> &str {
1447            "mock"
1448        }
1449        async fn stream(&self, req: Request) -> Result<ChunkStream> {
1450            let chunks = if request_has_tool(&req, "declare_intent") {
1451                intent_chunks("answer the user", &[])
1452            } else {
1453                vec![
1454                    Chunk::TextDelta("first ".into()),
1455                    Chunk::TextDelta("second".into()),
1456                    Chunk::Block(ContentBlock::Text {
1457                        text: "first second".into(),
1458                    }),
1459                    Chunk::Done {
1460                        stop_reason: Some(StopReason::EndTurn),
1461                    },
1462                ]
1463            };
1464            Ok(Box::pin(futures::stream::iter(chunks.into_iter().map(Ok))))
1465        }
1466    }
1467
1468    /// D-145: `Session::stream` yields the turn's events as owned `AgentEvent`s (deltas preserved,
1469    /// `TurnEnd` fires) and `finish()` returns the collected output. (Cross-turn *liveness* — an
1470    /// event observed before the turn ends — is exercised by the cancel test below, where the turn
1471    /// cannot end until the consumer acts on a mid-turn `ToolCall` event.)
1472    #[tokio::test]
1473    async fn stream_yields_events_and_finish_collects() {
1474        let dir = std::env::temp_dir().join(format!("flux-sdk-stream-{}", std::process::id()));
1475        std::fs::create_dir_all(&dir).unwrap();
1476        let client = Client::builder()
1477            .model("mock")
1478            .build(Box::new(TwoDeltaMock), &dir)
1479            .unwrap();
1480        let mut stream = client.default_session().unwrap().stream("hi");
1481
1482        let mut deltas = String::new();
1483        let mut saw_turn_end = false;
1484        while let Some(event) = stream.next().await {
1485            match event {
1486                AgentEvent::TextDelta(t) => deltas.push_str(&t),
1487                AgentEvent::TurnEnd { .. } => saw_turn_end = true,
1488                _ => {}
1489            }
1490        }
1491        assert_eq!(deltas, "first second");
1492        assert!(saw_turn_end, "the stream must emit a TurnEnd event");
1493        let out = stream.finish().await.unwrap();
1494        assert_eq!(out.text, "first second");
1495        std::fs::remove_dir_all(&dir).ok();
1496    }
1497
1498    /// D-145: cancelling a streamed turn mid-tool ends it and leaves a valid `user → assistant`
1499    /// alternation in the persisted log (the AGENTS.md session-shape invariant).
1500    #[tokio::test]
1501    async fn cancelling_a_streamed_turn_keeps_the_session_shape_valid() {
1502        let dir = std::env::temp_dir().join(format!("flux-sdk-cancel-{}", std::process::id()));
1503        std::fs::create_dir_all(&dir).unwrap();
1504        // A tool that parks forever — the turn can only end via cancellation.
1505        let parked = flux_runtime::tool_fn(
1506            flux_spec::ToolSpec::read_only(
1507                "park",
1508                "Blocks until cancelled",
1509                serde_json::json!({ "type": "object", "properties": {} }),
1510            ),
1511            |_input| async {
1512                tokio::time::sleep(std::time::Duration::from_secs(300)).await;
1513                Ok(serde_json::json!("unreachable"))
1514            },
1515        );
1516        let client = Client::builder()
1517            .model("mock")
1518            .auto_approve(true)
1519            .register_op(parked)
1520            .build(
1521                Box::new(PlanOpMock {
1522                    op: "park",
1523                    calls: std::sync::atomic::AtomicUsize::new(0),
1524                }),
1525                &dir,
1526            )
1527            .unwrap();
1528        let session = client.default_session().unwrap();
1529        let mut stream = session.stream("park it");
1530        // Wait until the parked op is actually in flight, then cancel.
1531        loop {
1532            match stream.next().await {
1533                Some(AgentEvent::ToolCall { name, .. }) if name == "park" => break,
1534                Some(_) => continue,
1535                None => panic!("stream ended before the tool call"),
1536            }
1537        }
1538        stream.cancel();
1539        let _ = stream.finish().await;
1540
1541        let history = session.history().unwrap();
1542        assert!(!history.is_empty());
1543        for pair in history.windows(2) {
1544            assert_ne!(
1545                pair[0].role, pair[1].role,
1546                "roles must alternate after a cancelled turn"
1547            );
1548        }
1549        assert!(
1550            matches!(history.last().unwrap().role, flux_core::Role::Assistant),
1551            "a cancelled turn must still persist exactly one closing assistant message"
1552        );
1553        std::fs::remove_dir_all(&dir).ok();
1554    }
1555
1556    /// D-142: opening an unknown session id errors instead of silently minting a new stream.
1557    #[tokio::test]
1558    async fn open_session_unknown_id_errors() {
1559        let dir = std::env::temp_dir().join(format!("flux-sdk-open-{}", std::process::id()));
1560        std::fs::create_dir_all(&dir).unwrap();
1561        let client = Client::builder()
1562            .model("mock")
1563            .build(Box::new(ProseMock { text: "x" }), &dir)
1564            .unwrap();
1565        assert!(client.open_session("no-such-session").is_err());
1566        std::fs::remove_dir_all(&dir).ok();
1567    }
1568
1569    /// A prose mock that records each provider call's (start, end) interval; the body sleeps so
1570    /// overlapping turns would produce overlapping intervals.
1571    struct SlowRecordingMock {
1572        calls: Arc<Mutex<Vec<(std::time::Instant, std::time::Instant)>>>,
1573    }
1574    #[async_trait]
1575    impl Provider for SlowRecordingMock {
1576        fn name(&self) -> &str {
1577            "mock"
1578        }
1579        async fn stream(&self, req: Request) -> Result<ChunkStream> {
1580            let start = std::time::Instant::now();
1581            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1582            self.calls
1583                .lock()
1584                .unwrap()
1585                .push((start, std::time::Instant::now()));
1586            let chunks = if request_has_tool(&req, "declare_intent") {
1587                intent_chunks("answer the user", &[])
1588            } else {
1589                vec![
1590                    Chunk::Block(ContentBlock::Text { text: "ok".into() }),
1591                    Chunk::Done {
1592                        stop_reason: Some(StopReason::EndTurn),
1593                    },
1594                ]
1595            };
1596            Ok(Box::pin(futures::stream::iter(chunks.into_iter().map(Ok))))
1597        }
1598    }
1599
1600    /// D-142: one engine runs one turn at a time — concurrent `send`s on two sessions of the same
1601    /// client serialize on the turn guard instead of interleaving provider calls.
1602    #[tokio::test]
1603    async fn concurrent_sends_serialize_on_the_turn_guard() {
1604        let dir = std::env::temp_dir().join(format!("flux-sdk-guard-{}", std::process::id()));
1605        std::fs::create_dir_all(&dir).unwrap();
1606        let calls = Arc::new(Mutex::new(Vec::new()));
1607        let client = Client::builder()
1608            .model("mock")
1609            .build(
1610                Box::new(SlowRecordingMock {
1611                    calls: calls.clone(),
1612                }),
1613                &dir,
1614            )
1615            .unwrap();
1616        let a = client.create_session().unwrap();
1617        let b = client.create_session().unwrap();
1618        let (ra, rb) = tokio::join!(a.send("one"), b.send("two"));
1619        ra.unwrap();
1620        rb.unwrap();
1621
1622        let mut intervals = calls.lock().unwrap().clone();
1623        intervals.sort_by_key(|(s, _)| *s);
1624        assert_eq!(intervals.len(), 4, "two adaptive stages per chat turn");
1625        assert!(
1626            intervals.windows(2).all(|pair| pair[1].0 >= pair[0].1),
1627            "provider calls overlapped: the turn guard failed to serialize the turns"
1628        );
1629        std::fs::remove_dir_all(&dir).ok();
1630    }
1631
1632    #[tokio::test]
1633    async fn client_runs_an_action_batch_then_answers() {
1634        let dir = std::env::temp_dir().join(format!("flux-sdk-plan-{}", std::process::id()));
1635        std::fs::create_dir_all(&dir).unwrap();
1636        let provider = Box::new(PlanThenProseMock {
1637            calls: std::sync::atomic::AtomicUsize::new(0),
1638        });
1639        let client = Client::builder()
1640            .model("mock")
1641            .auto_approve(true) // no human in the loop: the action batch's `write` is allowed
1642            .build(provider, &dir)
1643            .unwrap();
1644        let out = client.run("write a file").await.unwrap();
1645        assert_eq!(out.text, "Wrote the file.");
1646        // The real op surfaced to the sink; adaptive loop machinery is filtered out.
1647        assert_eq!(out.tool_calls, vec!["write"]);
1648        // The action actually executed through the guarded envelope.
1649        assert!(dir.join("sdk-plan.txt").exists(), "the batch's write ran");
1650        std::fs::remove_dir_all(&dir).ok();
1651    }
1652
1653    /// D-143 fix: a `tools(subset)` restricts the base catalog but must NOT drop a tool the
1654    /// consumer explicitly registered — `register_op(greet).tools(["read"])` keeps `greet`.
1655    #[tokio::test]
1656    async fn tools_subset_preserves_registered_custom_ops() {
1657        let dir = std::env::temp_dir().join(format!("flux-sdk-subkeep-{}", std::process::id()));
1658        std::fs::create_dir_all(&dir).unwrap();
1659        let hits = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1660        let client = Client::builder()
1661            .model("mock")
1662            .auto_approve(true)
1663            .tools(["read"]) // restricts built-ins, but `greet` was registered on purpose
1664            .register_op(greet_tool(hits.clone()))
1665            .build(
1666                Box::new(PlanOpMock {
1667                    op: "greet",
1668                    calls: std::sync::atomic::AtomicUsize::new(0),
1669                }),
1670                &dir,
1671            )
1672            .unwrap();
1673        let out = client.run("greet flux").await.unwrap();
1674        assert_eq!(
1675            hits.load(std::sync::atomic::Ordering::Relaxed),
1676            1,
1677            "the registered custom op must survive the tools() subset"
1678        );
1679        assert_eq!(out.tool_calls, vec!["greet"]);
1680        std::fs::remove_dir_all(&dir).ok();
1681    }
1682
1683    /// D-142 fix: the default session is created lazily — building a client (e.g. a service
1684    /// restart) mints no session, so `latest_session()` still points at the real prior
1685    /// conversation rather than an empty default.
1686    #[tokio::test]
1687    async fn lazy_default_session_does_not_shadow_the_prior_conversation() {
1688        let dir = std::env::temp_dir().join(format!("flux-sdk-lazy-{}", std::process::id()));
1689        std::fs::remove_dir_all(&dir).ok();
1690        std::fs::create_dir_all(&dir).unwrap();
1691        let store = dir.join("state");
1692
1693        // First process: run a real conversation, capture its id.
1694        let real_id = {
1695            let client = Client::builder()
1696                .model("mock")
1697                .storage(Storage::dir(&store))
1698                .build(Box::new(ProseMock { text: "hi" }), &dir)
1699                .unwrap();
1700            client.run("remember this").await.unwrap();
1701            let id = client.session_id().unwrap();
1702            drop(client);
1703            id
1704        };
1705
1706        // Second process: a fresh client that has NOT run yet must see the real conversation as
1707        // latest (no empty default was minted at build).
1708        let client = Client::builder()
1709            .model("mock")
1710            .storage(Storage::dir(&store))
1711            .build(Box::new(ProseMock { text: "hi" }), &dir)
1712            .unwrap();
1713        let latest = client
1714            .latest_session()
1715            .unwrap()
1716            .expect("a prior session exists");
1717        assert_eq!(
1718            latest.id(),
1719            real_id,
1720            "latest_session must return the real prior conversation, not a fresh empty default"
1721        );
1722        std::fs::remove_dir_all(&dir).ok();
1723    }
1724
1725    /// D-145 fix: dropping a `TurnStream` cancels its turn instead of leaving it running detached
1726    /// and holding the client's turn slot. A parked tool (sleeps 300s) can only be escaped by
1727    /// cancellation, so if `run()` after the drop completes, the drop cancelled the turn.
1728    #[tokio::test]
1729    async fn dropping_a_turn_stream_cancels_the_turn() {
1730        let dir = std::env::temp_dir().join(format!("flux-sdk-dropcancel-{}", std::process::id()));
1731        std::fs::create_dir_all(&dir).unwrap();
1732        let parked = flux_runtime::tool_fn(
1733            flux_spec::ToolSpec::read_only(
1734                "park",
1735                "Blocks until cancelled",
1736                serde_json::json!({ "type": "object", "properties": {} }),
1737            ),
1738            |_input| async {
1739                tokio::time::sleep(std::time::Duration::from_secs(300)).await;
1740                Ok(serde_json::json!("unreachable"))
1741            },
1742        );
1743        let client = Client::builder()
1744            .model("mock")
1745            .auto_approve(true)
1746            .register_op(parked)
1747            .build(
1748                Box::new(PlanOpMock {
1749                    op: "park",
1750                    calls: std::sync::atomic::AtomicUsize::new(0),
1751                }),
1752                &dir,
1753            )
1754            .unwrap();
1755        {
1756            let session = client.default_session().unwrap();
1757            let mut stream = session.stream("park it");
1758            // Wait until the parked op is actually in flight, then drop the stream (no cancel()).
1759            loop {
1760                match stream.next().await {
1761                    Some(AgentEvent::ToolCall { name, .. }) if name == "park" => break,
1762                    Some(_) => continue,
1763                    None => panic!("stream ended before the tool call"),
1764                }
1765            }
1766            drop(stream);
1767        }
1768        // If the drop did not cancel, the parked turn would hold the guard for 300s and this
1769        // would hang (test timeout). It completing proves the drop cancelled the turn.
1770        let out = tokio::time::timeout(
1771            std::time::Duration::from_secs(20),
1772            client.default_session().unwrap().send("are you there"),
1773        )
1774        .await
1775        .expect("run after drop must not hang — the dropped stream should have cancelled the turn")
1776        .unwrap();
1777        assert_eq!(out.text, "done");
1778        std::fs::remove_dir_all(&dir).ok();
1779    }
1780
1781    /// A never-planning mock: the deterministic flow skeleton (echo prompts + `await`) invokes no
1782    /// model stage, so a call here is a bug. Panics if the model is ever hit.
1783    struct NeverMock;
1784    #[async_trait]
1785    impl Provider for NeverMock {
1786        fn name(&self) -> &str {
1787            "mock"
1788        }
1789        async fn stream(&self, _req: Request) -> Result<ChunkStream> {
1790            panic!("a flow-driven session must not invoke a model stage");
1791        }
1792    }
1793
1794    /// A minimal custom op that emits its `text` back as the model-facing view — the flow's authored
1795    /// prompt. Mirrors the engine's test `EchoTool`.
1796    struct EchoTool;
1797    #[async_trait]
1798    impl Tool for EchoTool {
1799        fn spec(&self) -> flux_spec::ToolSpec {
1800            flux_spec::ToolSpec::read_only(
1801                "echo",
1802                "echo text",
1803                serde_json::json!({
1804                    "type": "object",
1805                    "properties": { "text": { "type": "string" } },
1806                    "required": ["text"]
1807                }),
1808            )
1809        }
1810        async fn execute(
1811            &self,
1812            _c: &ToolContext,
1813            params: serde_json::Value,
1814        ) -> Result<flux_runtime::ToolResult> {
1815            Ok(flux_runtime::ToolResult::ok(
1816                params
1817                    .get("text")
1818                    .and_then(|v| v.as_str())
1819                    .unwrap_or("")
1820                    .to_string(),
1821            ))
1822        }
1823    }
1824
1825    #[test]
1826    fn client_builder_reports_source_aware_custom_operation_collisions() {
1827        let error = Client::builder()
1828            .register_op_from("custom-pack:alpha", Arc::new(EchoTool))
1829            .register_op_from("custom-pack:beta", Arc::new(EchoTool))
1830            .build(Box::new(NeverMock), ".")
1831            .err()
1832            .expect("duplicate operation must fail client assembly")
1833            .to_string();
1834
1835        assert!(error.contains("duplicate operation `echo`"));
1836        assert!(error.contains("custom-pack:alpha"));
1837        assert!(error.contains("custom-pack:beta"));
1838    }
1839
1840    #[test]
1841    fn client_builder_rejects_custom_operation_shadowing_a_builtin() {
1842        let shadow = flux_runtime::tool_fn(
1843            flux_spec::ToolSpec::read_only(
1844                "read",
1845                "shadow the workspace reader",
1846                serde_json::json!({"type": "object"}),
1847            ),
1848            |_params| async { Ok(serde_json::Value::Null) },
1849        );
1850        let error = Client::builder()
1851            .register_op_from("custom-pack:shadow", shadow)
1852            .build(Box::new(NeverMock), ".")
1853            .err()
1854            .expect("custom operation must not replace a built-in")
1855            .to_string();
1856
1857        assert!(error.contains("duplicate operation `read`"), "{error}");
1858        assert!(error.contains("custom-pack:shadow"), "{error}");
1859        assert!(error.contains("flux-tools core coding pack"), "{error}");
1860    }
1861
1862    #[cfg(feature = "plugins")]
1863    #[test]
1864    fn client_builder_rejects_two_installed_plugins_with_the_same_public_operation() {
1865        let error = Client::builder()
1866            .with_plugin_tools_from(
1867                "alpha (/plugins/alpha.toml)",
1868                vec![Arc::new(EchoTool) as Arc<dyn Tool>],
1869            )
1870            .with_plugin_tools_from(
1871                "beta (/plugins/beta.toml)",
1872                vec![Arc::new(EchoTool) as Arc<dyn Tool>],
1873            )
1874            .build(Box::new(NeverMock), ".")
1875            .err()
1876            .expect("installed plugin public-name collision must fail assembly")
1877            .to_string();
1878
1879        assert!(error.contains("duplicate operation `echo`"), "{error}");
1880        assert!(
1881            error.contains("plugin:alpha (/plugins/alpha.toml)"),
1882            "{error}"
1883        );
1884        assert!(
1885            error.contains("plugin:beta (/plugins/beta.toml)"),
1886            "{error}"
1887        );
1888    }
1889
1890    /// A two-`await` interview flow: prompt, park, prompt, park, done. `echo` emits each authored
1891    /// prompt; `await` parks for the reply.
1892    fn interview_flow() -> flux_lang::ast::DraftAst {
1893        use flux_lang::ast::{Node, SymbolName};
1894        let prompt = |t: &str| Node::Call {
1895            op: "echo".into(),
1896            args: vec![Node::Lit {
1897                value: serde_json::json!(t),
1898            }],
1899        };
1900        let await_reply = |name: &str| Node::Await {
1901            binding: Some(SymbolName(name.into())),
1902            source: "user_input".into(),
1903            as_type: None,
1904            condition: None,
1905        };
1906        flux_lang::ast::DraftAst {
1907            body: vec![
1908                prompt("What is your name?"),
1909                await_reply("name"),
1910                prompt("Nice to meet you. Favorite color?"),
1911                await_reply("color"),
1912                prompt("All done — thanks!"),
1913            ],
1914            ..Default::default()
1915        }
1916    }
1917
1918    /// D-147: `Session::start_flow` runs an authored flow to its first top-level `await`, surfaces the
1919    /// flow's own authored prompt, and reports `suspended: true`. `send` answers the `await` and
1920    /// resumes to the next prompt (still suspended); the final `send` completes the flow and flips
1921    /// `suspended` to `false` — a durable human-in-the-loop driven entirely by the SDK's front door.
1922    #[tokio::test]
1923    async fn start_flow_suspends_surfaces_prompt_and_send_resumes() {
1924        let dir = std::env::temp_dir().join(format!("flux-sdk-startflow-{}", std::process::id()));
1925        std::fs::create_dir_all(&dir).unwrap();
1926        let client = Client::builder()
1927            .model("mock")
1928            .auto_approve(true)
1929            .register_op(Arc::new(EchoTool))
1930            .build(Box::new(NeverMock), &dir)
1931            .unwrap();
1932        let session = client.create_session().unwrap();
1933
1934        // Start the flow: first authored prompt, parked on await #1.
1935        let out = session.start_flow(&interview_flow()).await.unwrap();
1936        assert!(
1937            out.text.contains("What is your name?"),
1938            "start_flow surfaces the first authored prompt: {:?}",
1939            out.text
1940        );
1941        assert!(out.suspended, "the flow parked on its first `await`");
1942        assert!(
1943            session.suspended().unwrap(),
1944            "the session reports suspended"
1945        );
1946
1947        // Answer #1: resume to the second authored prompt, still parked (await #2).
1948        let out = session.send("Timo").await.unwrap();
1949        assert!(
1950            out.text.contains("Favorite color?"),
1951            "send resumes to the second authored prompt: {:?}",
1952            out.text
1953        );
1954        assert!(out.suspended, "still parked on the second `await`");
1955
1956        // Answer #2: the flow completes — no more awaits, so `suspended` flips false.
1957        let out = session.send("blue").await.unwrap();
1958        assert!(
1959            out.text.contains("All done"),
1960            "the final send completes the flow: {:?}",
1961            out.text
1962        );
1963        assert!(!out.suspended, "a completed flow is no longer suspended");
1964        assert!(
1965            !session.suspended().unwrap(),
1966            "the session reports not suspended"
1967        );
1968
1969        std::fs::remove_dir_all(&dir).ok();
1970    }
1971
1972    /// D-147: a flow suspended between two `await`s survives a process restart. Persist with
1973    /// `Storage::dir`, drop the whole client (simulating the process ending), rebuild a fresh client
1974    /// over the same directory, `open_session` by id, and `send` — the parked flow resumes.
1975    #[tokio::test]
1976    async fn suspended_flow_survives_a_process_restart() {
1977        let dir =
1978            std::env::temp_dir().join(format!("flux-sdk-startflow-restart-{}", std::process::id()));
1979        std::fs::create_dir_all(&dir).unwrap();
1980
1981        let session_id = {
1982            let client = Client::builder()
1983                .model("mock")
1984                .auto_approve(true)
1985                .register_op(Arc::new(EchoTool))
1986                .storage(Storage::dir(&dir))
1987                .build(Box::new(NeverMock), &dir)
1988                .unwrap();
1989            let session = client.create_session().unwrap();
1990            let out = session.start_flow(&interview_flow()).await.unwrap();
1991            assert!(out.suspended, "parked on await #1 before the restart");
1992            session.id().to_string()
1993            // client dropped here — the process "restarts".
1994        };
1995
1996        // Fresh client over the same persistent directory — a new process picking up the session.
1997        let client = Client::builder()
1998            .model("mock")
1999            .auto_approve(true)
2000            .register_op(Arc::new(EchoTool))
2001            .storage(Storage::dir(&dir))
2002            .build(Box::new(NeverMock), &dir)
2003            .unwrap();
2004        let session = client.open_session(&session_id).unwrap();
2005        assert!(
2006            session.suspended().unwrap(),
2007            "the persisted suspension is visible after the restart"
2008        );
2009        let out = session.send("Timo").await.unwrap();
2010        assert!(
2011            out.text.contains("Favorite color?"),
2012            "the parked flow resumes across the restart: {:?}",
2013            out.text
2014        );
2015        assert!(out.suspended, "re-parked on await #2");
2016
2017        std::fs::remove_dir_all(&dir).ok();
2018    }
2019
2020    /// A child sub-agent provider that bills real tokens, so a delegated `task` call contributes
2021    /// usage the parent turn folds in.
2022    struct WorkerMock(Usage);
2023    #[async_trait]
2024    impl Provider for WorkerMock {
2025        fn name(&self) -> &str {
2026            "mock"
2027        }
2028        async fn stream(&self, req: Request) -> Result<ChunkStream> {
2029            let chunks = if request_has_tool(&req, "declare_intent") {
2030                intent_chunks("complete the delegated task", &[])
2031            } else {
2032                vec![
2033                    Chunk::Block(ContentBlock::Text {
2034                        text: "did the subtask".into(),
2035                    }),
2036                    Chunk::Usage(self.0.clone()),
2037                    Chunk::Done {
2038                        stop_reason: Some(StopReason::EndTurn),
2039                    },
2040                ]
2041            };
2042            Ok(Box::pin(futures::stream::iter(chunks.into_iter().map(Ok))))
2043        }
2044    }
2045
2046    /// The parent adaptive loop routes to `task(worker, …)`, then answers from the result. Parent
2047    /// calls bill no usage, so the turn's total isolates the sub-agent's contribution.
2048    struct DelegatingMock {
2049        calls: std::sync::atomic::AtomicUsize,
2050    }
2051    #[async_trait]
2052    impl Provider for DelegatingMock {
2053        fn name(&self) -> &str {
2054            "mock"
2055        }
2056        async fn stream(&self, req: Request) -> Result<ChunkStream> {
2057            if request_has_tool(&req, "declare_intent") {
2058                return Ok(Box::pin(futures::stream::iter(
2059                    intent_chunks("delegate the task", &["process"])
2060                        .into_iter()
2061                        .map(Ok),
2062                )));
2063            }
2064            let n = self
2065                .calls
2066                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2067            let chunks = if n == 0 {
2068                native_call(
2069                    "task-1",
2070                    "task",
2071                    serde_json::json!({"role": "worker", "task": "do it"}),
2072                )
2073            } else if n == 1 {
2074                native_call(
2075                    "finalize-1",
2076                    "finalize_plan",
2077                    serde_json::json!({
2078                        "instructions": "Report the delegated task's actual result."
2079                    }),
2080                )
2081            } else {
2082                vec![
2083                    Chunk::Block(ContentBlock::Text {
2084                        text: "delegated to the worker".into(),
2085                    }),
2086                    Chunk::Done {
2087                        stop_reason: Some(StopReason::EndTurn),
2088                    },
2089                ]
2090            };
2091            Ok(Box::pin(futures::stream::iter(chunks.into_iter().map(Ok))))
2092        }
2093    }
2094
2095    /// D-148: `ClientBuilder::with_sub_agents` puts the `task` tool + spawner on the conversational
2096    /// door. A turn whose native action calls `task(role, …)` runs the child through the parent's envelope,
2097    /// and the child's usage observation lands in the session's run trace (folded into the turn's
2098    /// recorded usage). The `subagents` re-export module names every bundle type.
2099    #[tokio::test]
2100    async fn with_sub_agents_runs_a_delegated_task_and_records_child_usage() {
2101        use crate::subagents::{RoleRegistry, SubAgents};
2102
2103        let dir = std::env::temp_dir().join(format!("flux-sdk-subagents-{}", std::process::id()));
2104        std::fs::create_dir_all(&dir).unwrap();
2105
2106        // A mock role registry with one "worker" role.
2107        let mut roles = RoleRegistry::default();
2108        roles.insert(parse_role("---\n---\nworker prompt", "worker"));
2109
2110        // The child bills real tokens; the provider factory hands each spawn a fresh worker provider.
2111        let child_usage = Usage {
2112            input_tokens: 1000,
2113            output_tokens: 200,
2114            ..Default::default()
2115        };
2116        let factory: crate::subagents::ProviderFactory = Arc::new({
2117            let u = child_usage.clone();
2118            move || Ok(Box::new(WorkerMock(u.clone())) as Box<dyn Provider>)
2119        });
2120        let sub_agents = SubAgents::new(roles, ToolRegistry::new(), factory, "mock", 1024);
2121
2122        let client = Client::builder()
2123            .model("mock")
2124            .auto_approve(true)
2125            .with_sub_agents(sub_agents)
2126            .build(
2127                Box::new(DelegatingMock {
2128                    calls: std::sync::atomic::AtomicUsize::new(0),
2129                }),
2130                &dir,
2131            )
2132            .unwrap();
2133
2134        let out = client.run("delegate this").await.unwrap();
2135        assert!(
2136            out.tool_calls.contains(&"task".to_string()),
2137            "the adaptive turn delegated via `task`: {:?}",
2138            out.tool_calls
2139        );
2140
2141        // The child's tokens reached the session's run trace: the parent turn's recorded usage folds
2142        // in the sub-agent's spend (the parent's own model-stage calls billed nothing).
2143        let sid = client.session_id().unwrap();
2144        let events = client.event_store();
2145        let turns = events.turns(&sid).unwrap();
2146        let usage = turns
2147            .last()
2148            .and_then(|t| t.usage.as_ref())
2149            .expect("the parent turn's usage must be Some — the sub-agent billed tokens");
2150        assert_eq!(
2151            usage.input_tokens, 1000,
2152            "the sub-agent's input tokens landed in the session's run trace"
2153        );
2154        assert_eq!(
2155            usage.output_tokens, 200,
2156            "the sub-agent's output tokens landed in the session's run trace"
2157        );
2158
2159        std::fs::remove_dir_all(&dir).ok();
2160    }
2161
2162    struct PolicyCaptureWorker {
2163        requests: Arc<Mutex<Vec<Request>>>,
2164    }
2165
2166    #[async_trait]
2167    impl Provider for PolicyCaptureWorker {
2168        fn name(&self) -> &str {
2169            "mock"
2170        }
2171
2172        async fn stream(&self, req: Request) -> Result<ChunkStream> {
2173            self.requests.lock().unwrap().push(req.clone());
2174            let chunks = if request_has_tool(&req, "declare_intent") {
2175                intent_chunks("complete the delegated task", &[])
2176            } else {
2177                vec![
2178                    Chunk::Block(ContentBlock::Text {
2179                        text: "policy child done".into(),
2180                    }),
2181                    Chunk::Done {
2182                        stop_reason: Some(StopReason::EndTurn),
2183                    },
2184                ]
2185            };
2186            Ok(Box::pin(futures::stream::iter(chunks.into_iter().map(Ok))))
2187        }
2188    }
2189
2190    /// A-82 review regression: the high-level conversational builder is an embedding door too;
2191    /// its explicit sibling must reach both child-native stages instead of silently taking the
2192    /// legacy default `into_spawner` path.
2193    #[tokio::test]
2194    async fn with_sub_agents_policy_reaches_conversational_children() {
2195        use crate::subagents::{RoleRegistry, SubAgents};
2196
2197        let dir =
2198            std::env::temp_dir().join(format!("flux-sdk-subagent-policy-{}", std::process::id()));
2199        std::fs::create_dir_all(&dir).unwrap();
2200        let mut roles = RoleRegistry::default();
2201        roles.insert(parse_role("---\n---\nworker prompt", "worker"));
2202        let requests = Arc::new(Mutex::new(Vec::new()));
2203        let factory: crate::subagents::ProviderFactory = Arc::new({
2204            let requests = requests.clone();
2205            move || {
2206                Ok(Box::new(PolicyCaptureWorker {
2207                    requests: requests.clone(),
2208                }) as Box<dyn Provider>)
2209            }
2210        });
2211        let sub_agents = SubAgents::new(roles, ToolRegistry::new(), factory, "child-default", 1024);
2212        let policy = AdaptiveLoopPolicy {
2213            max_model_calls: 2,
2214            intent: AgentStagePolicy {
2215                model: Some("intent-fast".into()),
2216                effort: Some(flux_provider::Effort::Low),
2217                max_tokens: Some(111),
2218                max_calls: Some(1),
2219            },
2220            explore: AgentStagePolicy {
2221                model: Some("explore-deep".into()),
2222                effort: Some(flux_provider::Effort::High),
2223                max_tokens: Some(222),
2224                max_calls: Some(1),
2225            },
2226        };
2227
2228        let client = Client::builder()
2229            .model("mock")
2230            .auto_approve(true)
2231            .with_sub_agents_policy(sub_agents, policy)
2232            .build(
2233                Box::new(DelegatingMock {
2234                    calls: std::sync::atomic::AtomicUsize::new(0),
2235                }),
2236                &dir,
2237            )
2238            .unwrap();
2239        let out = client.run("delegate this").await.unwrap();
2240        assert!(out.tool_calls.contains(&"task".to_string()));
2241
2242        let requests = requests.lock().unwrap();
2243        assert_eq!(requests.len(), 2);
2244        assert_eq!(requests[0].trace.as_ref().unwrap().stage, "intent");
2245        assert_eq!(requests[0].model, "intent-fast");
2246        assert_eq!(requests[0].effort, Some(flux_provider::Effort::Low));
2247        assert_eq!(requests[0].max_tokens, 111);
2248        assert_eq!(requests[1].trace.as_ref().unwrap().stage, "explore");
2249        assert_eq!(requests[1].model, "explore-deep");
2250        assert_eq!(requests[1].effort, Some(flux_provider::Effort::High));
2251        assert_eq!(requests[1].max_tokens, 222);
2252        drop(requests);
2253
2254        std::fs::remove_dir_all(&dir).ok();
2255    }
2256
2257    /// A-80 regression: a guarded adapter may open a streamed one-shot runtime, which crosses a
2258    /// `tokio::spawn` boundary before its `task` op runs. The nested task must still inherit the
2259    /// served parent turn's cancellation and session lineage.
2260    struct NestedTaskAdapter {
2261        audit: Arc<EventStore>,
2262        child_entered: tokio::sync::mpsc::UnboundedSender<()>,
2263        child_dropped: tokio::sync::mpsc::UnboundedSender<()>,
2264    }
2265
2266    struct DropNotice(tokio::sync::mpsc::UnboundedSender<()>);
2267
2268    impl Drop for DropNotice {
2269        fn drop(&mut self) {
2270            let _ = self.0.send(());
2271        }
2272    }
2273
2274    struct HangingWorker {
2275        entered: tokio::sync::mpsc::UnboundedSender<()>,
2276        dropped: tokio::sync::mpsc::UnboundedSender<()>,
2277    }
2278
2279    #[async_trait]
2280    impl Provider for HangingWorker {
2281        fn name(&self) -> &str {
2282            "mock"
2283        }
2284
2285        async fn stream(&self, request: Request) -> Result<ChunkStream> {
2286            if request_has_tool(&request, "declare_intent") {
2287                return Ok(Box::pin(futures::stream::iter(
2288                    intent_chunks("wait for the parent request", &[])
2289                        .into_iter()
2290                        .map(Ok),
2291                )));
2292            }
2293            let _notice = DropNotice(self.dropped.clone());
2294            let _ = self.entered.send(());
2295            futures::future::pending::<Result<ChunkStream>>().await
2296        }
2297    }
2298
2299    #[async_trait]
2300    impl Tool for NestedTaskAdapter {
2301        fn spec(&self) -> flux_spec::ToolSpec {
2302            flux_spec::ToolSpec::read_only(
2303                "nested_task_adapter",
2304                "delegate through a nested one-shot runtime",
2305                serde_json::json!({ "type": "object", "properties": {} }),
2306            )
2307        }
2308
2309        async fn execute(
2310            &self,
2311            _ctx: &ToolContext,
2312            _params: serde_json::Value,
2313        ) -> Result<flux_runtime::ToolResult> {
2314            use crate::subagents::{RoleRegistry, SpawnLimits, SubAgents};
2315
2316            let mut roles = RoleRegistry::default();
2317            roles.insert(parse_role(
2318                "---\n---\nWait until the parent request is cancelled.",
2319                "worker",
2320            ));
2321            let factory: crate::subagents::ProviderFactory = Arc::new({
2322                let entered = self.child_entered.clone();
2323                let dropped = self.child_dropped.clone();
2324                move || {
2325                    Ok(Box::new(HangingWorker {
2326                        entered: entered.clone(),
2327                        dropped: dropped.clone(),
2328                    }) as Box<dyn Provider>)
2329                }
2330            });
2331            let mut limits = SpawnLimits::new(1024);
2332            // The assertion below must observe parent cancellation, never this safety backstop.
2333            limits.wall_clock = Some(std::time::Duration::from_secs(30));
2334            let sub_agents = SubAgents::new(roles, ToolRegistry::new(), factory, "mock", 1024)
2335                .with_limits(limits)
2336                .with_audit(self.audit.clone());
2337
2338            let root = std::env::temp_dir().join(format!(
2339                "flux-sdk-nested-turn-context-{}",
2340                std::process::id()
2341            ));
2342            std::fs::create_dir_all(&root)?;
2343            let mut nested = FlowClient::builder()
2344                .model("mock")
2345                .auto_approve(true)
2346                .build(Arc::new(ProseMock { text: "unused" }), &root)?;
2347            nested.with_sub_agents(sub_agents);
2348            let flow: flux_flow::ast::DraftAst = serde_json::from_value(serde_json::json!({
2349                "body": [{
2350                    "kind": "call",
2351                    "op": "task",
2352                    "args": [{
2353                        "kind": "lit",
2354                        "value": { "role": "worker", "task": "wait" }
2355                    }]
2356                }]
2357            }))
2358            .map_err(|error| flux_core::Error::Other(error.to_string()))?;
2359            let outcome = nested.execute_streamed(&flow).finish().await?;
2360            Ok(flux_runtime::ToolResult::ok(outcome.result))
2361        }
2362    }
2363
2364    struct NestedAdapterParent {
2365        calls: std::sync::atomic::AtomicUsize,
2366    }
2367
2368    #[async_trait]
2369    impl Provider for NestedAdapterParent {
2370        fn name(&self) -> &str {
2371            "mock"
2372        }
2373
2374        async fn stream(&self, request: Request) -> Result<ChunkStream> {
2375            if request_has_tool(&request, "declare_intent") {
2376                return Ok(Box::pin(futures::stream::iter(
2377                    intent_chunks("delegate through the adapter", &["core"])
2378                        .into_iter()
2379                        .map(Ok),
2380                )));
2381            }
2382            let chunks = match self
2383                .calls
2384                .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
2385            {
2386                0 => native_call("adapter-1", "nested_task_adapter", serde_json::json!({})),
2387                _ => vec![
2388                    Chunk::Block(ContentBlock::Text {
2389                        text: "unexpected completion".into(),
2390                    }),
2391                    Chunk::Done {
2392                        stop_reason: Some(StopReason::EndTurn),
2393                    },
2394                ],
2395            };
2396            Ok(Box::pin(futures::stream::iter(chunks.into_iter().map(Ok))))
2397        }
2398    }
2399
2400    #[tokio::test]
2401    async fn nested_streamed_task_inherits_parent_cancel_and_session_lineage() {
2402        let dir = std::env::temp_dir().join(format!(
2403            "flux-sdk-parent-turn-context-{}",
2404            std::process::id()
2405        ));
2406        std::fs::create_dir_all(&dir).unwrap();
2407        let audit = Arc::new(EventStore::in_memory().unwrap());
2408        let (entered_tx, mut entered_rx) = tokio::sync::mpsc::unbounded_channel();
2409        let (dropped_tx, mut dropped_rx) = tokio::sync::mpsc::unbounded_channel();
2410        let client = Client::builder()
2411            .model("mock")
2412            .auto_approve(true)
2413            .register_op(Arc::new(NestedTaskAdapter {
2414                audit: audit.clone(),
2415                child_entered: entered_tx,
2416                child_dropped: dropped_tx,
2417            }))
2418            .build(
2419                Box::new(NestedAdapterParent {
2420                    calls: std::sync::atomic::AtomicUsize::new(0),
2421                }),
2422                &dir,
2423            )
2424            .unwrap();
2425        let session = client.default_session().unwrap();
2426        let parent_session = session.id().to_string();
2427        let turn = session.stream("delegate through the adapter");
2428
2429        tokio::time::timeout(std::time::Duration::from_secs(5), entered_rx.recv())
2430            .await
2431            .expect("the nested child must enter its parked provider")
2432            .expect("the nested child entry channel closed");
2433        turn.cancel();
2434        tokio::time::timeout(std::time::Duration::from_secs(2), dropped_rx.recv())
2435            .await
2436            .expect("parent cancellation must reach the nested child before its 30s deadline")
2437            .expect("the nested child drop channel closed");
2438        turn.finish().await.unwrap();
2439
2440        let children = audit.children_of(&parent_session).unwrap();
2441        assert_eq!(
2442            children.len(),
2443            1,
2444            "the child audit stream must be parent-linked"
2445        );
2446        let child = audit.info(&children[0]).unwrap();
2447        assert_eq!(
2448            child.context.correlation_id.as_deref(),
2449            Some(parent_session.as_str())
2450        );
2451        std::fs::remove_dir_all(&dir).ok();
2452    }
2453
2454    /// A gated custom op with a distinctive name unlikely to collide with prompt boilerplate, so a
2455    /// catalog-capturing test can assert its presence/absence in the advertised op catalog.
2456    struct WidgetTool;
2457    #[async_trait]
2458    impl Tool for WidgetTool {
2459        fn spec(&self) -> flux_spec::ToolSpec {
2460            flux_spec::ToolSpec::read_only(
2461                "zzquux_probe",
2462                "a gated probe op",
2463                serde_json::json!({ "type": "object", "properties": {} }),
2464            )
2465        }
2466        async fn execute(
2467            &self,
2468            _c: &ToolContext,
2469            _params: serde_json::Value,
2470        ) -> Result<flux_runtime::ToolResult> {
2471            Ok(flux_runtime::ToolResult::ok("ok"))
2472        }
2473    }
2474
2475    /// D-149: `ClientBuilder::groups` + `ambient_signals` gate an op behind an evidence signal — it
2476    /// is absent from the advertised op catalog until its group surfaces. `ToolGroup`/`SignalMatch`/
2477    /// `KIND_SIGNAL` are named via `flux_sdk::observe` (acceptance 3). Mirrors the engine's
2478    /// evidence-gated surfacing, driven entirely through the SDK builder.
2479    #[tokio::test]
2480    async fn groups_gate_an_op_until_its_ambient_signal_surfaces() {
2481        use crate::observe::{SignalMatch, ToolGroup, KIND_SIGNAL};
2482
2483        let dir = std::env::temp_dir().join(format!("flux-sdk-groups-{}", std::process::id()));
2484        std::fs::create_dir_all(&dir).unwrap();
2485        let group = ToolGroup {
2486            name: "widgets".into(),
2487            description: String::new(),
2488            tools: vec!["zzquux_probe".into()],
2489            surface_when: vec![SignalMatch {
2490                kind: KIND_SIGNAL.to_string(),
2491                signal: Some("widgets_on".into()),
2492            }],
2493        };
2494
2495        // Gated: no signal fires → the op stays hidden from the catalog the model sees.
2496        let systems_gated = Arc::new(Mutex::new(Vec::new()));
2497        let client = Client::builder()
2498            .model("mock")
2499            .register_op(Arc::new(WidgetTool))
2500            .groups([group.clone()])
2501            .build(
2502                Box::new(SystemCaptureMock {
2503                    systems: systems_gated.clone(),
2504                }),
2505                &dir,
2506            )
2507            .unwrap();
2508        client.run("hi").await.unwrap();
2509        let gated = systems_gated.lock().unwrap().join("\n");
2510        assert!(
2511            !gated.contains("zzquux_probe"),
2512            "the gated op must be absent from the catalog until its signal fires"
2513        );
2514
2515        // Surfaced: the ambient signal is present → the op joins the advertised catalog.
2516        let systems_on = Arc::new(Mutex::new(Vec::new()));
2517        let client = Client::builder()
2518            .model("mock")
2519            .register_op(Arc::new(WidgetTool))
2520            .groups([group])
2521            .ambient_signals(["widgets_on"])
2522            .build(
2523                Box::new(SystemCaptureMock {
2524                    systems: systems_on.clone(),
2525                }),
2526                &dir,
2527            )
2528            .unwrap();
2529        client.run("hi").await.unwrap();
2530        let surfaced = systems_on.lock().unwrap().join("\n");
2531        assert!(
2532            surfaced.contains("zzquux_probe"),
2533            "the op must be advertised once its group's signal surfaces:\n{surfaced}"
2534        );
2535
2536        std::fs::remove_dir_all(&dir).ok();
2537    }
2538
2539    /// D-149: `ClientBuilder::with_compaction` sets the threshold past which older turns are
2540    /// summarized. With a tiny threshold, a few turns trip compaction and a `context.compacted`
2541    /// observation lands in the session's evidence. `Observation` is named via `flux_sdk::observe`.
2542    #[tokio::test]
2543    async fn with_compaction_trips_and_records_a_context_compacted_observation() {
2544        let dir = std::env::temp_dir().join(format!("flux-sdk-compact-{}", std::process::id()));
2545        std::fs::create_dir_all(&dir).unwrap();
2546        // Tiny threshold: any real conversation exceeds it, so compaction trips as soon as there are
2547        // enough messages (the engine needs ≥ 4 before it will summarize).
2548        let client = Client::builder()
2549            .model("mock")
2550            .with_compaction(10)
2551            .build(Box::new(ProseMock { text: "ok" }), &dir)
2552            .unwrap();
2553
2554        // Three turns: by the third, the persisted conversation has ≥ 4 messages and far exceeds the
2555        // 10-char threshold, so compaction runs before that turn plans.
2556        for _ in 0..3 {
2557            client.run("tell me something").await.unwrap();
2558        }
2559
2560        let sid = client.session_id().unwrap();
2561        let obs = client.event_store().observations(&sid).unwrap();
2562        let compacted: Option<&crate::observe::Observation> =
2563            obs.iter().find(|o| o.kind == "context.compacted");
2564        let compacted = compacted.expect("a context.compacted observation must be recorded");
2565        assert!(
2566            compacted.data["from_messages"].as_u64().unwrap()
2567                > compacted.data["to_messages"].as_u64().unwrap(),
2568            "compaction shrank the message count: {:?}",
2569            compacted.data
2570        );
2571
2572        std::fs::remove_dir_all(&dir).ok();
2573    }
2574
2575    /// A prose mock that bills a fixed [`Usage`] on every call — so a session's turns have real
2576    /// token spend to project into `turns()`/`cost()`.
2577    struct PricedMock(Usage);
2578    #[async_trait]
2579    impl Provider for PricedMock {
2580        fn name(&self) -> &str {
2581            "mock"
2582        }
2583        async fn stream(&self, req: Request) -> Result<ChunkStream> {
2584            let chunks = if request_has_tool(&req, "declare_intent") {
2585                intent_chunks("answer the user", &[])
2586            } else {
2587                vec![
2588                    Chunk::TextDelta("ok".into()),
2589                    Chunk::Block(ContentBlock::Text { text: "ok".into() }),
2590                    Chunk::Usage(self.0.clone()),
2591                    Chunk::Done {
2592                        stop_reason: Some(StopReason::EndTurn),
2593                    },
2594                ]
2595            };
2596            Ok(Box::pin(futures::stream::iter(chunks.into_iter().map(Ok))))
2597        }
2598    }
2599
2600    /// D-151: a `Session` exposes the EventStore projections recorded for every turn. After two
2601    /// turns: `turns()` has two summaries, `history()` is a user/assistant alternation, and
2602    /// `cost(&table)` prices a non-zero USD for the priced model. The projection types are named via
2603    /// `flux_sdk::observe`.
2604    #[tokio::test]
2605    async fn session_projections_report_turns_history_and_cost() {
2606        use crate::observe::{ModelCost, TurnSummary};
2607        use flux_core::Role;
2608
2609        let dir = std::env::temp_dir().join(format!("flux-sdk-proj-{}", std::process::id()));
2610        std::fs::create_dir_all(&dir).unwrap();
2611        let per_call = Usage {
2612            input_tokens: 1000,
2613            output_tokens: 500,
2614            ..Default::default()
2615        };
2616        let client = Client::builder()
2617            .model("priced-mock")
2618            .build(Box::new(PricedMock(per_call)), &dir)
2619            .unwrap();
2620        let session = client.default_session().unwrap();
2621        session.send("first").await.unwrap();
2622        session.send("second").await.unwrap();
2623
2624        // turns(): one summary per turn.
2625        let turns: Vec<TurnSummary> = session.turns().unwrap();
2626        assert_eq!(turns.len(), 2, "one TurnSummary per turn: {turns:?}");
2627
2628        // history(): a user/assistant alternation (two turns → four messages).
2629        let history = session.history().unwrap();
2630        assert_eq!(history.len(), 4, "two turns = four messages");
2631        assert_eq!(history[0].role, Role::User);
2632        assert_eq!(history[1].role, Role::Assistant);
2633        assert_eq!(history[2].role, Role::User);
2634        assert_eq!(history[3].role, Role::Assistant);
2635
2636        // cost(): non-zero USD once the model is priced.
2637        let mut pricing = PricingTable::builtin();
2638        pricing.set(
2639            "priced-mock",
2640            flux_core::Rates {
2641                input: 1000.0,
2642                output: 1000.0,
2643                ..Default::default()
2644            },
2645        );
2646        let cost: Vec<ModelCost> = session.cost(&pricing).unwrap();
2647        let usd: f64 = cost
2648            .iter()
2649            .filter_map(|c| c.cost.as_ref())
2650            .map(|m| m.usd)
2651            .sum();
2652        assert!(usd > 0.0, "the priced model reports non-zero USD: {cost:?}");
2653
2654        // run_trace()/efficiency() are callable projections over the same store.
2655        let _ = session.run_trace().unwrap();
2656        let _ = session.efficiency().unwrap();
2657
2658        std::fs::remove_dir_all(&dir).ok();
2659    }
2660
2661    /// D-151: with the `pricing` feature, `flux_sdk::pricing::load_pricing_table` resolves and yields
2662    /// a usable table (the built-in rates, before any `~/.flux/pricing.toml` overlay). Without the
2663    /// feature the module is absent and `flux-credentials` is not in the dependency tree (asserted
2664    /// out-of-band via `cargo tree`).
2665    #[cfg(feature = "pricing")]
2666    #[test]
2667    fn pricing_feature_exposes_the_loader() {
2668        let table = crate::pricing::load_pricing_table();
2669        // A well-known model is priced by the built-in table the loader starts from.
2670        assert!(
2671            table.rates_for("claude-sonnet-4.6").is_some() || !format!("{table:?}").is_empty(),
2672            "the loaded table carries the built-in rates"
2673        );
2674    }
2675
2676    /// D-153: with the `providers` feature, `flux_sdk::providers::from_spec` builds a working
2677    /// provider from a model spec. `ollama/qwen3` needs no credential (local endpoint), so it
2678    /// resolves offline; the resolved model id rides back for `ClientBuilder::model`.
2679    #[cfg(feature = "providers")]
2680    #[test]
2681    fn providers_from_spec_builds_a_credential_free_provider() {
2682        let (provider, model) =
2683            crate::providers::from_spec("ollama/qwen3").expect("ollama needs no credential");
2684        assert_eq!(model, "qwen3", "the resolved model id rides back");
2685        // A working `Provider` — usable as the `Client::build` argument.
2686        let _name = provider.name();
2687    }
2688
2689    /// D-153 / lean-default enforcement (not aspirational): the default build must pull no provider
2690    /// batteries. Assert structurally from the manifest — `default = []`, and `flux-providers` /
2691    /// `flux-credentials` are `optional` (so `cargo build` with no features links neither, nor their
2692    /// transitive deps). A regression here (a battery made non-optional, or added to `default`) fails
2693    /// this test rather than silently fattening every downstream default build.
2694    #[test]
2695    fn default_build_pulls_no_optional_provider_batteries() {
2696        let manifest = include_str!("../Cargo.toml");
2697        assert!(
2698            manifest.contains("default = []"),
2699            "default features must be empty (provider-agnostic default build)"
2700        );
2701        assert!(
2702            manifest.contains("flux-providers = { workspace = true, optional = true }"),
2703            "flux-providers must be optional (the `providers` feature only)"
2704        );
2705        assert!(
2706            manifest.contains("flux-credentials = { workspace = true, optional = true }"),
2707            "flux-credentials must be optional (the `pricing` feature only)"
2708        );
2709        assert!(
2710            manifest.contains("flux-plugin = { workspace = true, optional = true }"),
2711            "flux-plugin must be optional (the `plugins` feature only)"
2712        );
2713    }
2714
2715    /// D-156: a cassette-recorded session replays hermetically through the SDK. A first client
2716    /// records a plan-running turn (the `write` op lands a cassette cell); a **second** client over
2717    /// the same `Storage::dir` — built with a **never-called** provider (panics if the model is hit)
2718    /// — replays it: the recorded plan comes back, nothing dispatches live.
2719    #[tokio::test]
2720    async fn session_replays_a_recorded_plan_hermetically() {
2721        let dir = std::env::temp_dir().join(format!("flux-sdk-replay-{}", std::process::id()));
2722        std::fs::remove_dir_all(&dir).ok();
2723        std::fs::create_dir_all(&dir).unwrap();
2724        let store = dir.join("state");
2725
2726        // Record: a plan that writes a file → the write dispatches and is captured on the cassette.
2727        let sid = {
2728            let client = Client::builder()
2729                .model("mock")
2730                .auto_approve(true)
2731                .storage(Storage::dir(&store))
2732                .build(
2733                    Box::new(PlanThenProseMock {
2734                        calls: std::sync::atomic::AtomicUsize::new(0),
2735                    }),
2736                    &dir,
2737                )
2738                .unwrap();
2739            client.run("write a file").await.unwrap();
2740            client.session_id().unwrap()
2741        };
2742
2743        // Replay in a fresh client over the same store, with a provider that must never be called —
2744        // replay serves every op from the cassette, so the model is never hit.
2745        let client = Client::builder()
2746            .model("mock")
2747            .storage(Storage::dir(&store))
2748            .build(Box::new(NeverMock), &dir)
2749            .unwrap();
2750        let session = client.open_session(&sid).unwrap();
2751        struct NullSink;
2752        impl AgentSink for NullSink {}
2753        let mut sink = NullSink;
2754        let report = session.replay(None, &mut sink).await.unwrap();
2755        assert!(
2756            !report.plans.is_empty(),
2757            "the recorded plan replayed: {report:?}"
2758        );
2759        assert!(
2760            report.diverged.is_none(),
2761            "a faithful replay does not diverge: {report:?}"
2762        );
2763
2764        std::fs::remove_dir_all(&dir).ok();
2765    }
2766
2767    /// D-156: a chat-only session (no ops → no cassette cells) is not replayable, and says so.
2768    #[tokio::test]
2769    async fn replay_of_a_non_recorded_session_errors_honestly() {
2770        let dir = std::env::temp_dir().join(format!("flux-sdk-replay-none-{}", std::process::id()));
2771        std::fs::create_dir_all(&dir).unwrap();
2772        let client = Client::builder()
2773            .model("mock")
2774            .storage(Storage::dir(dir.join("state")))
2775            .build(Box::new(ProseMock { text: "hello" }), &dir)
2776            .unwrap();
2777        let session = client.default_session().unwrap();
2778        session.send("hi").await.unwrap(); // a prose turn — no plan, no ops, no cassette cells
2779
2780        struct NullSink;
2781        impl AgentSink for NullSink {}
2782        let mut sink = NullSink;
2783        let err = session
2784            .replay(None, &mut sink)
2785            .await
2786            .expect_err("a chat-only session is not replayable");
2787        assert!(
2788            err.to_string().contains("not replayable"),
2789            "the error is honest about why: {err}"
2790        );
2791
2792        std::fs::remove_dir_all(&dir).ok();
2793    }
2794
2795    /// An adaptive provider that gathers `read(note.txt)` once, then answers in prose — a recorded
2796    /// session with a cassette-captured `read` at the fork point.
2797    struct BindPlanMock {
2798        calls: std::sync::atomic::AtomicUsize,
2799    }
2800    #[async_trait]
2801    impl Provider for BindPlanMock {
2802        fn name(&self) -> &str {
2803            "mock"
2804        }
2805        async fn stream(&self, req: Request) -> Result<ChunkStream> {
2806            if request_has_tool(&req, "declare_intent") {
2807                return Ok(Box::pin(futures::stream::iter(
2808                    intent_chunks("read the note", &["workspace.read"])
2809                        .into_iter()
2810                        .map(Ok),
2811                )));
2812            }
2813            let n = self
2814                .calls
2815                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2816            let chunks = if n == 0 {
2817                native_call("read-1", "read", serde_json::json!({"path": "note.txt"}))
2818            } else {
2819                vec![
2820                    Chunk::Block(ContentBlock::Text {
2821                        text: "done".into(),
2822                    }),
2823                    Chunk::Done {
2824                        stop_reason: Some(StopReason::EndTurn),
2825                    },
2826                ]
2827            };
2828            Ok(Box::pin(futures::stream::iter(chunks.into_iter().map(Ok))))
2829        }
2830    }
2831
2832    /// Record a `bind x = read(note.txt); return x` session on a `Storage::dir`; returns the store
2833    /// dir and the session id.
2834    async fn record_bind_session(tag: &str) -> (std::path::PathBuf, std::path::PathBuf, String) {
2835        let dir = std::env::temp_dir().join(format!("flux-sdk-fork-{tag}-{}", std::process::id()));
2836        std::fs::remove_dir_all(&dir).ok();
2837        std::fs::create_dir_all(&dir).unwrap();
2838        std::fs::write(dir.join("note.txt"), "original").unwrap();
2839        let store = dir.join("state");
2840        let client = Client::builder()
2841            .model("mock")
2842            .auto_approve(true)
2843            .storage(Storage::dir(store.clone()))
2844            .build(
2845                Box::new(BindPlanMock {
2846                    calls: std::sync::atomic::AtomicUsize::new(0),
2847                }),
2848                &dir,
2849            )
2850            .unwrap();
2851        client.run("read the note").await.unwrap();
2852        let sid = client.session_id().unwrap();
2853        (dir, store, sid)
2854    }
2855
2856    struct NullSink;
2857    impl AgentSink for NullSink {}
2858
2859    /// D-157: `Session::fork` + `Fork::inject` — inject a different value at the fork's bound
2860    /// statement; `diff` reports the divergence and the original session's log is untouched.
2861    #[tokio::test]
2862    async fn fork_inject_diverges_and_leaves_the_original_untouched() {
2863        let (dir, store, sid) = record_bind_session("inject").await;
2864
2865        let client = Client::builder()
2866            .model("mock")
2867            .auto_approve(true)
2868            .storage(Storage::dir(store))
2869            .build(Box::new(NeverMock), &dir)
2870            .unwrap();
2871        let events = client.event_store();
2872        let head_before = events.head_seq(&sid).unwrap();
2873
2874        let session = client.open_session(&sid).unwrap();
2875        let fork = session.fork(0).await.unwrap();
2876        let mut sink = NullSink;
2877        fork.inject(&serde_json::json!("injected"), &mut sink)
2878            .await
2879            .unwrap();
2880
2881        // The original session's log is untouched by the fork.
2882        assert_eq!(
2883            events.head_seq(&sid).unwrap(),
2884            head_before,
2885            "forking must not touch the original session's log"
2886        );
2887
2888        // The diff reports the divergence between original and fork.
2889        let diff = fork.diff(&session).unwrap();
2890        assert!(
2891            !diff.identical && !diff.rows.is_empty(),
2892            "the injected fork diverges from the original: {diff:?}"
2893        );
2894
2895        std::fs::remove_dir_all(&dir).ok();
2896    }
2897
2898    /// D-157: `Fork::edit` — diverge by supplying an alternate plan (a different bound value), which
2899    /// runs through the envelope and diverges from the original.
2900    #[tokio::test]
2901    async fn fork_edit_diverges_on_a_bound_value() {
2902        let (dir, store, sid) = record_bind_session("edit").await;
2903
2904        let client = Client::builder()
2905            .model("mock")
2906            .auto_approve(true)
2907            .storage(Storage::dir(store))
2908            .build(Box::new(NeverMock), &dir)
2909            .unwrap();
2910        let session = client.open_session(&sid).unwrap();
2911        let fork = session.fork(0).await.unwrap();
2912
2913        // An alternate tail: bind x to a literal instead of the recorded read, then return it.
2914        let edited: crate::flow::DraftAst = serde_json::from_value(serde_json::json!({ "body": [
2915            { "kind": "bind", "name": "x", "value": { "kind": "lit", "value": "edited-value" } },
2916            { "kind": "return", "value": { "kind": "var", "name": "x" } }
2917        ]}))
2918        .unwrap();
2919        let mut sink = NullSink;
2920        fork.edit(&edited, &mut sink).await.unwrap();
2921
2922        let diff = fork.diff(&session).unwrap();
2923        assert!(
2924            !diff.identical,
2925            "the edited fork diverges from the original: {diff:?}"
2926        );
2927
2928        std::fs::remove_dir_all(&dir).ok();
2929    }
2930}