Skip to main content

kura_cli/commands/
agent.rs

1use chrono::Utc;
2use clap::{Args, Subcommand, ValueEnum};
3use serde_json::json;
4use uuid::Uuid;
5
6use crate::util::{api_request, exit_error, read_json_from_file};
7
8#[derive(Subcommand)]
9pub enum AgentCommands {
10    /// Get negotiated agent capabilities manifest
11    Capabilities,
12    /// Get agent context bundle (system + user profile + key dimensions)
13    Context {
14        /// Max exercise_progression projections to include (default: 5)
15        #[arg(long)]
16        exercise_limit: Option<u32>,
17        /// Max strength_inference projections to include (default: 5)
18        #[arg(long)]
19        strength_limit: Option<u32>,
20        /// Max custom projections to include (default: 10)
21        #[arg(long)]
22        custom_limit: Option<u32>,
23        /// Optional task intent used for context ranking (e.g. "dunk progression")
24        #[arg(long)]
25        task_intent: Option<String>,
26        /// Include deployment-static system config in response payload (default: API default=true)
27        #[arg(long)]
28        include_system: Option<bool>,
29        /// Optional response token budget hint (min 400, max 12000)
30        #[arg(long)]
31        budget_tokens: Option<u32>,
32    },
33    /// Get deterministic section index for startup + targeted follow-up reads
34    SectionIndex {
35        /// Max exercise_progression projections to include (default: 5)
36        #[arg(long)]
37        exercise_limit: Option<u32>,
38        /// Max strength_inference projections to include (default: 5)
39        #[arg(long)]
40        strength_limit: Option<u32>,
41        /// Max custom projections to include (default: 10)
42        #[arg(long)]
43        custom_limit: Option<u32>,
44        /// Optional task intent used for startup section derivation
45        #[arg(long)]
46        task_intent: Option<String>,
47        /// Include deployment-static system config in response payload (default: API default=true)
48        #[arg(long)]
49        include_system: Option<bool>,
50        /// Optional response token budget hint (min 400, max 12000)
51        #[arg(long)]
52        budget_tokens: Option<u32>,
53    },
54    /// Fetch exactly one context section (optionally paged and field-projected)
55    SectionFetch {
56        /// Stable section id from section-index
57        #[arg(long)]
58        section: String,
59        /// Optional page size for paged sections (1..200)
60        #[arg(long)]
61        limit: Option<u32>,
62        /// Optional opaque cursor for paged sections
63        #[arg(long)]
64        cursor: Option<String>,
65        /// Optional comma-separated top-level fields to project
66        #[arg(long)]
67        fields: Option<String>,
68        /// Optional task intent for startup section derivation
69        #[arg(long)]
70        task_intent: Option<String>,
71    },
72    /// Write events with receipts + read-after-write verification
73    WriteWithProof(WriteWithProofArgs),
74    /// Evidence lineage operations
75    Evidence {
76        #[command(subcommand)]
77        command: AgentEvidenceCommands,
78    },
79    /// Set user save-confirmation preference (persist-intent override)
80    SetSaveConfirmationMode {
81        /// auto | always | never
82        #[arg(value_enum)]
83        mode: SaveConfirmationMode,
84    },
85    /// Resolve visualization policy/output for a task intent
86    ResolveVisualization(ResolveVisualizationArgs),
87    /// Direct agent API access under /v1/agent/*
88    Request(AgentRequestArgs),
89}
90
91#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
92pub enum SaveConfirmationMode {
93    Auto,
94    Always,
95    Never,
96}
97
98impl SaveConfirmationMode {
99    fn as_str(self) -> &'static str {
100        match self {
101            SaveConfirmationMode::Auto => "auto",
102            SaveConfirmationMode::Always => "always",
103            SaveConfirmationMode::Never => "never",
104        }
105    }
106}
107
108#[derive(Subcommand)]
109pub enum AgentEvidenceCommands {
110    /// Explain lineage claims for one persisted event
111    Event {
112        /// Target event UUID
113        #[arg(long)]
114        event_id: Uuid,
115    },
116}
117
118#[derive(Args)]
119pub struct AgentRequestArgs {
120    /// HTTP method (GET, POST, PUT, DELETE, PATCH)
121    pub method: String,
122
123    /// Agent path: relative (e.g. context) or absolute (/v1/agent/context)
124    pub path: String,
125
126    /// Request body as JSON string
127    #[arg(long, short = 'd')]
128    pub data: Option<String>,
129
130    /// Read request body from file (use '-' for stdin)
131    #[arg(long, short = 'f', conflicts_with = "data")]
132    pub data_file: Option<String>,
133
134    /// Query parameters (repeatable: key=value)
135    #[arg(long, short = 'q')]
136    pub query: Vec<String>,
137
138    /// Extra headers (repeatable: Key:Value)
139    #[arg(long, short = 'H')]
140    pub header: Vec<String>,
141
142    /// Skip pretty-printing (raw JSON for piping)
143    #[arg(long)]
144    pub raw: bool,
145
146    /// Include HTTP status and headers in response wrapper
147    #[arg(long, short = 'i')]
148    pub include: bool,
149}
150
151#[derive(Args)]
152pub struct WriteWithProofArgs {
153    /// JSON file containing events array or {"events":[...]} (use '-' for stdin)
154    #[arg(
155        long,
156        required_unless_present = "request_file",
157        conflicts_with = "request_file"
158    )]
159    pub events_file: Option<String>,
160
161    /// Read-after-write target in projection_type:key format (repeatable)
162    #[arg(
163        long,
164        required_unless_present = "request_file",
165        conflicts_with = "request_file"
166    )]
167    pub target: Vec<String>,
168
169    /// Max verification wait in milliseconds (100..10000)
170    #[arg(long)]
171    pub verify_timeout_ms: Option<u64>,
172
173    /// Full request payload JSON file for /v1/agent/write-with-proof
174    #[arg(long, conflicts_with_all = ["events_file", "target", "verify_timeout_ms"])]
175    pub request_file: Option<String>,
176}
177
178#[derive(Args)]
179pub struct ResolveVisualizationArgs {
180    /// Full request payload JSON file for /v1/agent/visualization/resolve
181    #[arg(long, conflicts_with = "task_intent")]
182    pub request_file: Option<String>,
183
184    /// Task intent (required unless --request-file is used)
185    #[arg(long, required_unless_present = "request_file")]
186    pub task_intent: Option<String>,
187
188    /// auto | always | never
189    #[arg(long)]
190    pub user_preference_override: Option<String>,
191
192    /// low | medium | high
193    #[arg(long)]
194    pub complexity_hint: Option<String>,
195
196    /// Allow rich rendering formats when true (default: true)
197    #[arg(long, default_value_t = true)]
198    pub allow_rich_rendering: bool,
199
200    /// Optional visualization_spec JSON file
201    #[arg(long)]
202    pub spec_file: Option<String>,
203
204    /// Optional telemetry session id
205    #[arg(long)]
206    pub telemetry_session_id: Option<String>,
207}
208
209pub async fn run(api_url: &str, token: Option<&str>, command: AgentCommands) -> i32 {
210    match command {
211        AgentCommands::Capabilities => capabilities(api_url, token).await,
212        AgentCommands::Context {
213            exercise_limit,
214            strength_limit,
215            custom_limit,
216            task_intent,
217            include_system,
218            budget_tokens,
219        } => {
220            context(
221                api_url,
222                token,
223                exercise_limit,
224                strength_limit,
225                custom_limit,
226                task_intent,
227                include_system,
228                budget_tokens,
229            )
230            .await
231        }
232        AgentCommands::SectionIndex {
233            exercise_limit,
234            strength_limit,
235            custom_limit,
236            task_intent,
237            include_system,
238            budget_tokens,
239        } => {
240            section_index(
241                api_url,
242                token,
243                exercise_limit,
244                strength_limit,
245                custom_limit,
246                task_intent,
247                include_system,
248                budget_tokens,
249            )
250            .await
251        }
252        AgentCommands::SectionFetch {
253            section,
254            limit,
255            cursor,
256            fields,
257            task_intent,
258        } => section_fetch(api_url, token, section, limit, cursor, fields, task_intent).await,
259        AgentCommands::WriteWithProof(args) => write_with_proof(api_url, token, args).await,
260        AgentCommands::Evidence { command } => match command {
261            AgentEvidenceCommands::Event { event_id } => {
262                evidence_event(api_url, token, event_id).await
263            }
264        },
265        AgentCommands::SetSaveConfirmationMode { mode } => {
266            set_save_confirmation_mode(api_url, token, mode).await
267        }
268        AgentCommands::ResolveVisualization(args) => {
269            resolve_visualization(api_url, token, args).await
270        }
271        AgentCommands::Request(args) => request(api_url, token, args).await,
272    }
273}
274
275async fn capabilities(api_url: &str, token: Option<&str>) -> i32 {
276    api_request(
277        api_url,
278        reqwest::Method::GET,
279        "/v1/agent/capabilities",
280        token,
281        None,
282        &[],
283        &[],
284        false,
285        false,
286    )
287    .await
288}
289
290pub async fn context(
291    api_url: &str,
292    token: Option<&str>,
293    exercise_limit: Option<u32>,
294    strength_limit: Option<u32>,
295    custom_limit: Option<u32>,
296    task_intent: Option<String>,
297    include_system: Option<bool>,
298    budget_tokens: Option<u32>,
299) -> i32 {
300    let query = build_context_query(
301        exercise_limit,
302        strength_limit,
303        custom_limit,
304        task_intent,
305        include_system,
306        budget_tokens,
307    );
308
309    api_request(
310        api_url,
311        reqwest::Method::GET,
312        "/v1/agent/context",
313        token,
314        None,
315        &query,
316        &[],
317        false,
318        false,
319    )
320    .await
321}
322
323async fn section_index(
324    api_url: &str,
325    token: Option<&str>,
326    exercise_limit: Option<u32>,
327    strength_limit: Option<u32>,
328    custom_limit: Option<u32>,
329    task_intent: Option<String>,
330    include_system: Option<bool>,
331    budget_tokens: Option<u32>,
332) -> i32 {
333    let query = build_context_query(
334        exercise_limit,
335        strength_limit,
336        custom_limit,
337        task_intent,
338        include_system,
339        budget_tokens,
340    );
341    api_request(
342        api_url,
343        reqwest::Method::GET,
344        "/v1/agent/context/section-index",
345        token,
346        None,
347        &query,
348        &[],
349        false,
350        false,
351    )
352    .await
353}
354
355async fn section_fetch(
356    api_url: &str,
357    token: Option<&str>,
358    section: String,
359    limit: Option<u32>,
360    cursor: Option<String>,
361    fields: Option<String>,
362    task_intent: Option<String>,
363) -> i32 {
364    let query = build_section_fetch_query(section, limit, cursor, fields, task_intent);
365    api_request(
366        api_url,
367        reqwest::Method::GET,
368        "/v1/agent/context/section-fetch",
369        token,
370        None,
371        &query,
372        &[],
373        false,
374        false,
375    )
376    .await
377}
378
379async fn evidence_event(api_url: &str, token: Option<&str>, event_id: Uuid) -> i32 {
380    let path = format!("/v1/agent/evidence/event/{event_id}");
381    api_request(
382        api_url,
383        reqwest::Method::GET,
384        &path,
385        token,
386        None,
387        &[],
388        &[],
389        false,
390        false,
391    )
392    .await
393}
394
395async fn set_save_confirmation_mode(
396    api_url: &str,
397    token: Option<&str>,
398    mode: SaveConfirmationMode,
399) -> i32 {
400    let body = json!({
401        "timestamp": Utc::now().to_rfc3339(),
402        "event_type": "preference.set",
403        "data": {
404            "key": "save_confirmation_mode",
405            "value": mode.as_str(),
406        },
407        "metadata": {
408            "source": "cli",
409            "agent": "kura-cli",
410            "idempotency_key": Uuid::now_v7().to_string(),
411        }
412    });
413    api_request(
414        api_url,
415        reqwest::Method::POST,
416        "/v1/events",
417        token,
418        Some(body),
419        &[],
420        &[],
421        false,
422        false,
423    )
424    .await
425}
426
427async fn request(api_url: &str, token: Option<&str>, args: AgentRequestArgs) -> i32 {
428    let method = parse_method(&args.method);
429    let path = normalize_agent_path(&args.path);
430    let query = parse_query_pairs(&args.query);
431    let headers = parse_headers(&args.header);
432    let body = resolve_body(args.data.as_deref(), args.data_file.as_deref());
433
434    api_request(
435        api_url,
436        method,
437        &path,
438        token,
439        body,
440        &query,
441        &headers,
442        args.raw,
443        args.include,
444    )
445    .await
446}
447
448pub async fn write_with_proof(api_url: &str, token: Option<&str>, args: WriteWithProofArgs) -> i32 {
449    let body = if let Some(file) = args.request_file.as_deref() {
450        load_full_request(file)
451    } else {
452        build_request_from_events_and_targets(
453            args.events_file.as_deref().unwrap_or(""),
454            &args.target,
455            args.verify_timeout_ms,
456        )
457    };
458
459    api_request(
460        api_url,
461        reqwest::Method::POST,
462        "/v1/agent/write-with-proof",
463        token,
464        Some(body),
465        &[],
466        &[],
467        false,
468        false,
469    )
470    .await
471}
472
473async fn resolve_visualization(
474    api_url: &str,
475    token: Option<&str>,
476    args: ResolveVisualizationArgs,
477) -> i32 {
478    let body = if let Some(file) = args.request_file.as_deref() {
479        match read_json_from_file(file) {
480            Ok(v) => v,
481            Err(e) => exit_error(
482                &e,
483                Some("Provide a valid JSON payload for /v1/agent/visualization/resolve."),
484            ),
485        }
486    } else {
487        let task_intent = match args.task_intent {
488            Some(intent) if !intent.trim().is_empty() => intent,
489            _ => exit_error(
490                "task_intent is required unless --request-file is used.",
491                Some("Use --task-intent or provide --request-file."),
492            ),
493        };
494
495        let mut body = json!({
496            "task_intent": task_intent,
497            "allow_rich_rendering": args.allow_rich_rendering
498        });
499        if let Some(mode) = args.user_preference_override {
500            body["user_preference_override"] = json!(mode);
501        }
502        if let Some(complexity) = args.complexity_hint {
503            body["complexity_hint"] = json!(complexity);
504        }
505        if let Some(session_id) = args.telemetry_session_id {
506            body["telemetry_session_id"] = json!(session_id);
507        }
508        if let Some(spec_file) = args.spec_file.as_deref() {
509            let spec = match read_json_from_file(spec_file) {
510                Ok(v) => v,
511                Err(e) => exit_error(&e, Some("Provide a valid JSON visualization_spec payload.")),
512            };
513            body["visualization_spec"] = spec;
514        }
515        body
516    };
517
518    api_request(
519        api_url,
520        reqwest::Method::POST,
521        "/v1/agent/visualization/resolve",
522        token,
523        Some(body),
524        &[],
525        &[],
526        false,
527        false,
528    )
529    .await
530}
531
532fn parse_method(raw: &str) -> reqwest::Method {
533    match raw.to_uppercase().as_str() {
534        "GET" => reqwest::Method::GET,
535        "POST" => reqwest::Method::POST,
536        "PUT" => reqwest::Method::PUT,
537        "DELETE" => reqwest::Method::DELETE,
538        "PATCH" => reqwest::Method::PATCH,
539        "HEAD" => reqwest::Method::HEAD,
540        "OPTIONS" => reqwest::Method::OPTIONS,
541        other => exit_error(
542            &format!("Unknown HTTP method: {other}"),
543            Some("Supported methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"),
544        ),
545    }
546}
547
548fn normalize_agent_path(raw: &str) -> String {
549    let trimmed = raw.trim();
550    if trimmed.is_empty() {
551        exit_error(
552            "Agent path must not be empty.",
553            Some("Use relative path like 'context' or absolute path '/v1/agent/context'."),
554        );
555    }
556
557    if trimmed.starts_with("/v1/agent") {
558        return trimmed.to_string();
559    }
560    if trimmed.starts_with("v1/agent") {
561        return format!("/{trimmed}");
562    }
563    if trimmed.starts_with('/') {
564        exit_error(
565            &format!("Invalid agent path '{trimmed}'."),
566            Some(
567                "`kura agent request` only supports /v1/agent/* paths. Use `kura api` for other endpoints.",
568            ),
569        );
570    }
571
572    format!("/v1/agent/{}", trimmed.trim_start_matches('/'))
573}
574
575fn parse_query_pairs(raw: &[String]) -> Vec<(String, String)> {
576    raw.iter()
577        .map(|entry| {
578            entry.split_once('=').map_or_else(
579                || {
580                    exit_error(
581                        &format!("Invalid query parameter: '{entry}'"),
582                        Some("Format: key=value, e.g. --query event_type=set.logged"),
583                    )
584                },
585                |(k, v)| (k.to_string(), v.to_string()),
586            )
587        })
588        .collect()
589}
590
591fn build_context_query(
592    exercise_limit: Option<u32>,
593    strength_limit: Option<u32>,
594    custom_limit: Option<u32>,
595    task_intent: Option<String>,
596    include_system: Option<bool>,
597    budget_tokens: Option<u32>,
598) -> Vec<(String, String)> {
599    let mut query = Vec::new();
600    if let Some(v) = exercise_limit {
601        query.push(("exercise_limit".to_string(), v.to_string()));
602    }
603    if let Some(v) = strength_limit {
604        query.push(("strength_limit".to_string(), v.to_string()));
605    }
606    if let Some(v) = custom_limit {
607        query.push(("custom_limit".to_string(), v.to_string()));
608    }
609    if let Some(v) = task_intent {
610        query.push(("task_intent".to_string(), v));
611    }
612    if let Some(v) = include_system {
613        query.push(("include_system".to_string(), v.to_string()));
614    }
615    if let Some(v) = budget_tokens {
616        query.push(("budget_tokens".to_string(), v.to_string()));
617    }
618    query
619}
620
621fn build_section_fetch_query(
622    section: String,
623    limit: Option<u32>,
624    cursor: Option<String>,
625    fields: Option<String>,
626    task_intent: Option<String>,
627) -> Vec<(String, String)> {
628    let section = section.trim();
629    if section.is_empty() {
630        exit_error(
631            "section must not be empty",
632            Some("Provide --section using an id from /v1/agent/context/section-index"),
633        );
634    }
635    let mut query = vec![("section".to_string(), section.to_string())];
636    if let Some(v) = limit {
637        query.push(("limit".to_string(), v.to_string()));
638    }
639    if let Some(v) = cursor {
640        query.push(("cursor".to_string(), v));
641    }
642    if let Some(v) = fields {
643        query.push(("fields".to_string(), v));
644    }
645    if let Some(v) = task_intent {
646        query.push(("task_intent".to_string(), v));
647    }
648    query
649}
650
651fn parse_headers(raw: &[String]) -> Vec<(String, String)> {
652    raw.iter()
653        .map(|entry| {
654            entry.split_once(':').map_or_else(
655                || {
656                    exit_error(
657                        &format!("Invalid header: '{entry}'"),
658                        Some("Format: Key:Value, e.g. --header Content-Type:application/json"),
659                    )
660                },
661                |(k, v)| (k.trim().to_string(), v.trim().to_string()),
662            )
663        })
664        .collect()
665}
666
667fn resolve_body(data: Option<&str>, data_file: Option<&str>) -> Option<serde_json::Value> {
668    if let Some(raw) = data {
669        match serde_json::from_str(raw) {
670            Ok(v) => return Some(v),
671            Err(e) => exit_error(
672                &format!("Invalid JSON in --data: {e}"),
673                Some("Provide valid JSON string"),
674            ),
675        }
676    }
677
678    if let Some(file) = data_file {
679        return match read_json_from_file(file) {
680            Ok(v) => Some(v),
681            Err(e) => exit_error(&e, Some("Provide a valid JSON file or use '-' for stdin")),
682        };
683    }
684
685    None
686}
687
688fn load_full_request(path: &str) -> serde_json::Value {
689    let payload = match read_json_from_file(path) {
690        Ok(v) => v,
691        Err(e) => exit_error(
692            &e,
693            Some(
694                "Provide JSON with events, read_after_write_targets, and optional verify_timeout_ms.",
695            ),
696        ),
697    };
698    if payload
699        .get("events")
700        .and_then(|value| value.as_array())
701        .is_none()
702    {
703        exit_error(
704            "request payload must include an events array",
705            Some(
706                "Use --request-file with {\"events\": [...], \"read_after_write_targets\": [...]}",
707            ),
708        );
709    }
710    if payload
711        .get("read_after_write_targets")
712        .and_then(|value| value.as_array())
713        .is_none()
714    {
715        exit_error(
716            "request payload must include read_after_write_targets array",
717            Some("Set read_after_write_targets to [{\"projection_type\":\"...\",\"key\":\"...\"}]"),
718        );
719    }
720    payload
721}
722
723fn build_request_from_events_and_targets(
724    events_file: &str,
725    raw_targets: &[String],
726    verify_timeout_ms: Option<u64>,
727) -> serde_json::Value {
728    if raw_targets.is_empty() {
729        exit_error(
730            "--target is required when --request-file is not used",
731            Some("Repeat --target projection_type:key for read-after-write checks."),
732        );
733    }
734
735    let parsed_targets = parse_targets(raw_targets);
736    let events_payload = match read_json_from_file(events_file) {
737        Ok(v) => v,
738        Err(e) => exit_error(
739            &e,
740            Some("Provide --events-file as JSON array or object with events array."),
741        ),
742    };
743
744    let events = extract_events_array(events_payload);
745    build_write_with_proof_request(events, parsed_targets, verify_timeout_ms)
746}
747
748fn parse_targets(raw_targets: &[String]) -> Vec<serde_json::Value> {
749    raw_targets
750        .iter()
751        .map(|raw| {
752            let (projection_type, key) = raw.split_once(':').unwrap_or_else(|| {
753                exit_error(
754                    &format!("Invalid --target '{raw}'"),
755                    Some("Use format projection_type:key, e.g. user_profile:me"),
756                )
757            });
758            let projection_type = projection_type.trim();
759            let key = key.trim();
760            if projection_type.is_empty() || key.is_empty() {
761                exit_error(
762                    &format!("Invalid --target '{raw}'"),
763                    Some("projection_type and key must both be non-empty."),
764                );
765            }
766            json!({
767                "projection_type": projection_type,
768                "key": key,
769            })
770        })
771        .collect()
772}
773
774fn extract_events_array(events_payload: serde_json::Value) -> Vec<serde_json::Value> {
775    if let Some(events) = events_payload.as_array() {
776        return events.to_vec();
777    }
778    if let Some(events) = events_payload
779        .get("events")
780        .and_then(|value| value.as_array())
781    {
782        return events.to_vec();
783    }
784    exit_error(
785        "events payload must be an array or object with events array",
786        Some("Example: --events-file events.json where file is [{...}] or {\"events\": [{...}]}"),
787    );
788}
789
790fn build_write_with_proof_request(
791    events: Vec<serde_json::Value>,
792    parsed_targets: Vec<serde_json::Value>,
793    verify_timeout_ms: Option<u64>,
794) -> serde_json::Value {
795    let mut request = json!({
796        "events": events,
797        "read_after_write_targets": parsed_targets,
798    });
799    if let Some(timeout) = verify_timeout_ms {
800        request["verify_timeout_ms"] = json!(timeout);
801    }
802    request
803}
804
805#[cfg(test)]
806mod tests {
807    use super::{
808        SaveConfirmationMode, build_context_query, build_section_fetch_query,
809        build_write_with_proof_request, extract_events_array, normalize_agent_path, parse_method,
810        parse_targets,
811    };
812    use serde_json::json;
813
814    #[test]
815    fn normalize_agent_path_accepts_relative_path() {
816        assert_eq!(
817            normalize_agent_path("evidence/event/abc"),
818            "/v1/agent/evidence/event/abc"
819        );
820    }
821
822    #[test]
823    fn normalize_agent_path_accepts_absolute_agent_path() {
824        assert_eq!(
825            normalize_agent_path("/v1/agent/context"),
826            "/v1/agent/context"
827        );
828    }
829
830    #[test]
831    fn parse_method_accepts_standard_http_methods() {
832        for method in &[
833            "get", "GET", "post", "PUT", "delete", "patch", "head", "OPTIONS",
834        ] {
835            let parsed = parse_method(method);
836            assert!(!parsed.as_str().is_empty());
837        }
838    }
839
840    #[test]
841    fn parse_targets_accepts_projection_type_key_format() {
842        let parsed = parse_targets(&[
843            "user_profile:me".to_string(),
844            "training_timeline:overview".to_string(),
845        ]);
846        assert_eq!(parsed[0]["projection_type"], "user_profile");
847        assert_eq!(parsed[0]["key"], "me");
848        assert_eq!(parsed[1]["projection_type"], "training_timeline");
849        assert_eq!(parsed[1]["key"], "overview");
850    }
851
852    #[test]
853    fn extract_events_array_supports_plain_array() {
854        let events = extract_events_array(json!([
855            {"event_type":"set.logged"},
856            {"event_type":"metric.logged"}
857        ]));
858        assert_eq!(events.len(), 2);
859    }
860
861    #[test]
862    fn extract_events_array_supports_object_wrapper() {
863        let events = extract_events_array(json!({
864            "events": [{"event_type":"set.logged"}]
865        }));
866        assert_eq!(events.len(), 1);
867    }
868
869    #[test]
870    fn build_write_with_proof_request_serializes_expected_fields() {
871        let request = build_write_with_proof_request(
872            vec![json!({"event_type":"set.logged"})],
873            vec![json!({"projection_type":"user_profile","key":"me"})],
874            Some(1200),
875        );
876        assert_eq!(request["events"].as_array().unwrap().len(), 1);
877        assert_eq!(
878            request["read_after_write_targets"]
879                .as_array()
880                .unwrap()
881                .len(),
882            1
883        );
884        assert_eq!(request["verify_timeout_ms"], 1200);
885    }
886
887    #[test]
888    fn save_confirmation_mode_serializes_expected_values() {
889        assert_eq!(SaveConfirmationMode::Auto.as_str(), "auto");
890        assert_eq!(SaveConfirmationMode::Always.as_str(), "always");
891        assert_eq!(SaveConfirmationMode::Never.as_str(), "never");
892    }
893
894    #[test]
895    fn build_context_query_includes_budget_tokens_when_present() {
896        let query = build_context_query(
897            Some(3),
898            Some(2),
899            Some(1),
900            Some("readiness check".to_string()),
901            Some(false),
902            Some(900),
903        );
904        assert!(query.contains(&("exercise_limit".to_string(), "3".to_string())));
905        assert!(query.contains(&("strength_limit".to_string(), "2".to_string())));
906        assert!(query.contains(&("custom_limit".to_string(), "1".to_string())));
907        assert!(query.contains(&("task_intent".to_string(), "readiness check".to_string())));
908        assert!(query.contains(&("include_system".to_string(), "false".to_string())));
909        assert!(query.contains(&("budget_tokens".to_string(), "900".to_string())));
910    }
911
912    #[test]
913    fn build_context_query_supports_section_index_parity_params() {
914        let query = build_context_query(
915            Some(5),
916            Some(5),
917            Some(10),
918            Some("startup".to_string()),
919            Some(false),
920            Some(1200),
921        );
922        assert!(query.contains(&("exercise_limit".to_string(), "5".to_string())));
923        assert!(query.contains(&("strength_limit".to_string(), "5".to_string())));
924        assert!(query.contains(&("custom_limit".to_string(), "10".to_string())));
925        assert!(query.contains(&("task_intent".to_string(), "startup".to_string())));
926        assert!(query.contains(&("include_system".to_string(), "false".to_string())));
927        assert!(query.contains(&("budget_tokens".to_string(), "1200".to_string())));
928    }
929
930    #[test]
931    fn build_section_fetch_query_serializes_optional_params() {
932        let query = build_section_fetch_query(
933            "projections.exercise_progression".to_string(),
934            Some(50),
935            Some("abc123".to_string()),
936            Some("data,meta".to_string()),
937            Some("bench plateau".to_string()),
938        );
939        assert_eq!(
940            query,
941            vec![
942                (
943                    "section".to_string(),
944                    "projections.exercise_progression".to_string(),
945                ),
946                ("limit".to_string(), "50".to_string()),
947                ("cursor".to_string(), "abc123".to_string()),
948                ("fields".to_string(), "data,meta".to_string()),
949                ("task_intent".to_string(), "bench plateau".to_string()),
950            ]
951        );
952    }
953}