Skip to main content

teamcraft/services/
assistant.rs

1//! Runs the built-in assistant for a user and records the run in the
2//! audit log (who, which action, question length, tools, tokens – never the
3//! question or the answer). See `crate::assistant` for the tool loop.
4
5use serde::Serialize;
6use serde_json::{json, Value};
7
8use crate::{
9    assistant::{self, Answer, AssistantError, McpTools, Question, ToolUse, Trace},
10    auth::Actor,
11    error::{AppError, AppResult},
12    pagecontext::PageContext,
13    services::{
14        assistant_threads as threads, audit,
15        project_policy::{self, Interface},
16    },
17    AppState,
18};
19
20/// The result of a run as the web UI and the API return it.
21#[derive(Debug, Serialize)]
22pub struct Run {
23    pub action: assistant::Action,
24    pub answer: Answer,
25    pub tools: Vec<ToolUse>,
26    pub steps: u32,
27    pub provider: &'static str,
28    pub model: String,
29    pub allow_drafts: bool,
30    pub input_tokens: u64,
31    pub output_tokens: u64,
32    /// "Open 3D preview" links of `open_3d_preview` results, checked by
33    /// `assistant::open_links` (this server's viewer pages only, relative).
34    /// Shown under the fresh answer; the stored thread keeps text only.
35    #[serde(skip_serializing_if = "Vec::is_empty")]
36    pub links: Vec<assistant::OpenLink>,
37    /// Where the question was asked, as resolved with the user's rights
38    /// (conversation requests only).
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub context: Option<ContextNote>,
41}
42
43/// The resolved page context of a question: what the chip and the thread
44/// show.
45#[derive(Debug, Clone, Serialize)]
46pub struct ContextNote {
47    pub kind: String,
48    pub label: String,
49}
50
51/// The interface switches the assistant runs under: its own, and
52/// `external_llm` unless the provider's base URL is on the administrator's
53/// internal list (`TEAMCRAFT_ASSISTANT_INTERNAL_URLS`).
54pub fn switches(config: Option<&crate::config::AssistantConfig>) -> Vec<Interface> {
55    let mut switches = vec![Interface::Assistant];
56    if !config.is_some_and(|c| c.is_internal()) {
57        switches.push(Interface::ExternalLlm);
58    }
59    switches
60}
61
62/// Whether the assistant is configured on this server.
63pub fn enabled(st: &AppState) -> bool {
64    st.assistant.is_some()
65}
66
67/// The audit details of a run (no question text, no answer).
68fn audit_details(
69    question: &Question,
70    allow_drafts: bool,
71    provider: &str,
72    model: &str,
73    trace: &Trace,
74    outcome: &str,
75) -> Value {
76    json!({
77        "provider": provider,
78        "usage_reported": trace.unreported_steps == 0,
79        "action": question.action.as_str(),
80        "question_chars": question.text.chars().count(),
81        "context": {
82            "item": question.item,
83            "revision": question.revision,
84            "change": question.change,
85        },
86        // the page the question was asked on, as resolved with the user's
87        // rights: identifiers and counts, never the typed query text
88        "page": {
89            "kind": question.page.kind,
90            "request": question.page.request,
91            "project": question.page.project,
92            "view": question.page.view,
93            "rule": question.page.rule,
94            "has_query": question.page.query.is_some(),
95            "filters": question.page.filters.len(),
96            "rows_shown": question.page.shown.len(),
97        },
98        "thread": question.thread,
99        "history_messages": question.history.len(),
100        "allow_drafts": allow_drafts,
101        "model": model,
102        "tools": trace.tools.iter().map(|t| t.name.as_str()).collect::<Vec<_>>(),
103        "steps": trace.steps,
104        "input_tokens": trace.input_tokens,
105        "output_tokens": trace.output_tokens,
106        "outcome": outcome,
107    })
108}
109
110async fn record_run(
111    st: &AppState,
112    me: &Actor,
113    action: assistant::Action,
114    details: Value,
115) -> AppResult<()> {
116    let mut conn = st.db.acquire().await?;
117    audit::record(
118        &mut conn,
119        Some(me),
120        "assistant.run",
121        "assistant",
122        None,
123        format!("Assistant: {}", action.label()),
124        details,
125    )
126    .await
127}
128
129/// Records a run that was abandoned before it finished (the client closed
130/// the connection, so the request future was dropped): the model calls and
131/// tool calls made until then – possibly a draft change order – still get
132/// their audit record.
133struct CancelGuard {
134    st: AppState,
135    me: Actor,
136    question: Question,
137    allow_drafts: bool,
138    provider: &'static str,
139    model: String,
140    trace: Trace,
141    finished: bool,
142}
143
144impl Drop for CancelGuard {
145    fn drop(&mut self) {
146        // nothing came back from the API yet: nothing to record
147        if self.finished || self.trace.steps == 0 {
148            return;
149        }
150        let Ok(runtime) = tokio::runtime::Handle::try_current() else {
151            return;
152        };
153        let details = audit_details(
154            &self.question,
155            self.allow_drafts,
156            self.provider,
157            &self.model,
158            &self.trace,
159            "cancelled",
160        );
161        let (st, me, action) = (self.st.clone(), self.me.clone(), self.question.action);
162        runtime.spawn(async move {
163            if let Err(e) = record_run(&st, &me, action, details).await {
164                tracing::warn!(error = %e, "recording a cancelled assistant run failed");
165            }
166        });
167    }
168}
169
170/// Asks the assistant. The tools run with the rights of `me`; the draft
171/// tools are only offered when `question.allow_drafts` is set and `me` may
172/// edit.
173pub async fn ask(st: &AppState, me: &Actor, question: Question) -> AppResult<Run> {
174    Ok(run_once(st, me, question, false).await?.0)
175}
176
177/// One run; also says whether it ended tainted (see `assistant::McpTools`).
178/// `prior_taint`: an earlier answer of the conversation that the question
179/// quotes came from a tainted run, so this one starts tainted too.
180async fn run_once(
181    st: &AppState,
182    me: &Actor,
183    question: Question,
184    prior_taint: bool,
185) -> AppResult<(Run, bool)> {
186    let Some(client) = st.assistant.as_deref() else {
187        return Err(AssistantError::Disabled.into_app_error());
188    };
189    let Ok(_permit) = client.permits.try_acquire() else {
190        return Err(AssistantError::Busy.into_app_error());
191    };
192    // groups that switched the assistant off are hidden from the page context too
193    let narrowed =
194        project_policy::narrow(&st.db, me.clone(), &switches(st.config.assistant.as_ref())).await?;
195    let me = &narrowed;
196    let allow_drafts = question.allow_drafts && me.can_edit();
197    let system = assistant::system_prompt(me, allow_drafts);
198    let instruction = question.instruction();
199    let tools = McpTools::new(st, me).with_origin(question.origin.clone());
200    if prior_taint {
201        tools
202            .tainted
203            .store(true, std::sync::atomic::Ordering::SeqCst);
204    }
205    tools.note_context(&question).await;
206    tools.note_page(&question.page).await;
207    let mut guard = CancelGuard {
208        st: st.clone(),
209        me: me.clone(),
210        question,
211        allow_drafts,
212        provider: client.provider_name,
213        model: client.model.clone(),
214        trace: Trace::default(),
215        finished: false,
216    };
217    let result = tokio::time::timeout(
218        client.run_timeout,
219        assistant::run_loop(
220            client.provider.as_ref(),
221            &client.transport,
222            &tools,
223            &client.limits,
224            &client.model,
225            &system,
226            &instruction,
227            allow_drafts,
228            &mut guard.trace,
229        ),
230    )
231    .await
232    .unwrap_or(Err(AssistantError::Timeout));
233
234    let outcome = match &result {
235        Ok(a) if a.truncated => "truncated",
236        Ok(_) => "ok",
237        Err(e) => e.code(),
238    };
239    if let Err(e) = &result {
240        tracing::warn!(user = %me.username, action = guard.question.action.as_str(), error = %e, "assistant run failed");
241    }
242    // From here on the normal record below is written; the guard must not
243    // write a second one (the request may still be dropped while it runs).
244    guard.finished = true;
245    let details = audit_details(
246        &guard.question,
247        allow_drafts,
248        client.provider_name,
249        &client.model,
250        &guard.trace,
251        outcome,
252    );
253    record_run(st, me, guard.question.action, details).await?;
254
255    let answer = result.map_err(AssistantError::into_app_error)?;
256    let tainted = tools.tainted.load(std::sync::atomic::Ordering::SeqCst);
257    let trace = std::mem::take(&mut guard.trace);
258    let links = assistant::open_links(&trace.open_hints, &st.config.public_url);
259    Ok((
260        Run {
261            action: guard.question.action,
262            answer,
263            tools: trace.tools,
264            steps: trace.steps,
265            provider: client.provider_name,
266            model: client.model.clone(),
267            allow_drafts,
268            input_tokens: trace.input_tokens,
269            output_tokens: trace.output_tokens,
270            links,
271            context: None,
272        },
273        tainted,
274    ))
275}
276
277/// Longest "use as limits" note of the *Fill with AI* box, in characters.
278pub const MAX_LIMITS_CHARS: usize = 500;
279
280/// Whether a file of a simulation can be a report the assistant reads
281/// (PDF, HTML or text; the extraction is `textindex` / `htmltext` / `pdftext`).
282pub fn is_report_file(filename: &str) -> bool {
283    let ext = filename
284        .rsplit_once('.')
285        .map(|(_, e)| e.to_ascii_lowercase())
286        .unwrap_or_default();
287    matches!(
288        ext.as_str(),
289        "pdf" | "html" | "htm" | "xhtml" | "txt" | "md" | "rst" | "log" | "out" | "csv" | "tsv"
290    )
291}
292
293/// *Fill with AI…* on the Results card: asks the assistant to propose result
294/// values of a simulation revision from its reports, as drafts (never as
295/// results). `wanted` names the values to extract (empty = every key number),
296/// `limits` is the user's own note on limits, `files` the report files to read
297/// (empty = all). Returns the run and the number of proposals it created.
298///
299/// # Invariants
300///
301/// * **Permission:** the assistant must be configured (`Disabled`/404); the
302///   revision must be readable (hidden = missing, 404) and its group must not
303///   have switched the `assistant` interface off (404, like a hidden group);
304///   the actor must be an editor of the group (403). Pressing the button is
305///   the request for drafts, so they are allowed for this run; accepting
306///   stays a separate step that needs a check-out.
307/// * **Validation:** the revision is a simulation; texts are capped
308///   (`MAX_QUESTION_CHARS`, `MAX_LIMITS_CHARS`); every named file is a file of
309///   this revision (400 otherwise).
310/// * **Steps:** the run is not one transaction; the proposals are written by
311///   the `propose_result_values` tool in its own transaction.
312/// * **Audit:** `assistant.run` as for every run (action, text length, tools,
313///   tokens – never the text or the answer).
314/// * **Notifications / webhooks:** none.
315pub async fn fill_result_values(
316    st: &AppState,
317    me: &Actor,
318    revision_id: uuid::Uuid,
319    wanted: &str,
320    limits: &str,
321    files: &[String],
322) -> AppResult<(Run, usize)> {
323    if !enabled(st) {
324        return Err(AssistantError::Disabled.into_app_error());
325    }
326    let rev = crate::services::revisions::get(&st.db, me, revision_id).await?;
327    let denied = project_policy::denied_groups(&st.db, &[Interface::Assistant]).await?;
328    if rev.project_id.is_some_and(|p| denied.contains(&p)) {
329        return Err(crate::services::access::not_found_revision());
330    }
331    if rev.item_type != crate::domain::ItemType::Simulation.as_str() {
332        return Err(AppError::BadRequest(
333            "Only simulation revisions have result values.".into(),
334        ));
335    }
336    if !me.can_edit() || !me.can_edit_project(rev.project_id) {
337        return Err(AppError::Forbidden(
338            "You may not edit this simulation.".into(),
339        ));
340    }
341    if limits.chars().count() > MAX_LIMITS_CHARS {
342        return Err(AppError::BadRequest(format!(
343            "The limits note is too long (at most {MAX_LIMITS_CHARS} characters)."
344        )));
345    }
346    let known = crate::services::files::list(&st.db, me, revision_id).await?;
347    for name in files {
348        if !known
349            .iter()
350            .any(|f| &f.filename == name && is_report_file(&f.filename))
351        {
352            return Err(AppError::BadRequest(format!(
353                "`{name}` is not a report file of this revision."
354            )));
355        }
356    }
357    let mut question = Question::new(
358        Some("result_values"),
359        Some(wanted),
360        Some(&rev.item_number),
361        Some(&rev.code),
362        None,
363        true,
364    )
365    .map_err(AppError::BadRequest)?;
366    question.limits = limits.trim().to_string();
367    question.files = files.to_vec();
368    let before = crate::services::result_proposals::list(&st.db, me, revision_id, Some("pending"))
369        .await?
370        .len();
371    let run = ask(st, me, question).await?;
372    let after = crate::services::result_proposals::list(&st.db, me, revision_id, Some("pending"))
373        .await?
374        .len();
375    Ok((run, after.saturating_sub(before)))
376}
377
378// ---------------------------------------------------------------------------
379// The page the user is on
380// ---------------------------------------------------------------------------
381
382/// Resolves a page context that a client sent. **The client's identifiers
383/// are hints**: every one of them goes through the read guards of
384/// `services::access` with the rights of `me`, and only what `me` may read
385/// stays. An identifier of a restricted group that `me` cannot see is
386/// dropped exactly like one that does not exist: the same queries run, the
387/// result looks the same and carries no trace of it. Texts typed on the page
388/// (query, filter values) are the user's own and pass through.
389pub async fn resolve_context(
390    db: &sqlx::PgPool,
391    me: &Actor,
392    page: &PageContext,
393) -> AppResult<PageContext> {
394    use crate::services::access;
395    let mut out = PageContext {
396        kind: page.kind.clone(),
397        view: page.view.clone(),
398        rule: page.rule.clone(),
399        query: page.query.clone(),
400        filters: page.filters.clone(),
401        ..PageContext::default()
402    };
403    // items: the item itself, the selected row and the rows shown, one query
404    let mut numbers: Vec<String> = Vec::new();
405    for n in page
406        .item
407        .iter()
408        .chain(page.selected.iter())
409        .chain(page.shown.iter())
410    {
411        if !numbers.contains(n) {
412            numbers.push(n.clone());
413        }
414    }
415    if !numbers.is_empty() {
416        let rows: Vec<(String, bool)> = sqlx::query_as(&format!(
417            "SELECT i.number,
418                    EXISTS (SELECT 1 FROM revisions r WHERE r.item_id = i.id AND r.code = $2)
419             FROM items i
420             WHERE i.number = ANY($1) AND {}",
421            access::item_visible("i", 3)
422        ))
423        .bind(&numbers)
424        .bind(page.revision.clone().unwrap_or_default())
425        .bind(&me.hidden_projects)
426        .fetch_all(db)
427        .await?;
428        let visible = |n: &String| rows.iter().any(|(number, _)| number == n);
429        if let Some(item) = page.item.as_ref().filter(|n| visible(n)) {
430            out.item = Some(item.clone());
431            let has_revision = rows.iter().any(|(number, ok)| number == item && *ok);
432            out.revision = page.revision.clone().filter(|_| has_revision);
433        }
434        out.selected = page.selected.clone().filter(visible);
435        out.shown = page.shown.iter().filter(|n| visible(n)).cloned().collect();
436    }
437    if let Some(number) = &page.change {
438        let found: Option<String> = sqlx::query_scalar(&format!(
439            "SELECT c.number FROM changes c WHERE c.number = $1 AND {}",
440            access::change_visible("c", 2, 3)
441        ))
442        .bind(number)
443        .bind(me.id)
444        .bind(&me.hidden_projects)
445        .fetch_optional(db)
446        .await?;
447        out.change = found;
448    }
449    if let Some(number) = &page.request {
450        let found: Option<String> = sqlx::query_scalar(&format!(
451            "SELECT r.number FROM change_requests r WHERE r.number = $1 AND {}",
452            access::request_visible("r", 2, 3)
453        ))
454        .bind(number)
455        .bind(me.id)
456        .bind(&me.hidden_projects)
457        .fetch_optional(db)
458        .await?;
459        out.request = found;
460    }
461    if let Some(code) = &page.project {
462        let found: Option<String> = sqlx::query_scalar(&format!(
463            "SELECT p.code FROM projects p WHERE p.code = $1 AND {}",
464            access::project_visible("p.id", 2)
465        ))
466        .bind(code)
467        .bind(&me.hidden_projects)
468        .fetch_optional(db)
469        .await?;
470        out.project = found;
471    }
472    Ok(out)
473}
474
475/// A request of the web sidebar or the REST API: validates the raw fields
476/// and the page context the client sent (`context`, see
477/// [`crate::pagecontext`]), then asks.
478#[allow(clippy::too_many_arguments)]
479pub async fn chat(
480    st: &AppState,
481    me: &Actor,
482    action: Option<&str>,
483    text: Option<&str>,
484    context: &Value,
485    allow_drafts: bool,
486    thread: bool,
487    origin: crate::auth::Origin,
488) -> AppResult<Run> {
489    if !enabled(st) {
490        return Err(AssistantError::Disabled.into_app_error());
491    }
492    // the three identifiers older clients send are validated strictly
493    for field in ["item", "revision", "change"] {
494        if let Some(v) = context.get(field).and_then(Value::as_str) {
495            if !v.trim().is_empty() && !crate::pagecontext::is_reference(v.trim()) {
496                return Err(AppError::BadRequest(format!(
497                    "{field}: not a valid reference."
498                )));
499            }
500        }
501    }
502    let page = PageContext::parse(context);
503    let question = Question::new(
504        action,
505        text,
506        page.item.as_deref(),
507        page.revision.as_deref(),
508        page.change.as_deref(),
509        allow_drafts,
510    )
511    .map_err(AppError::BadRequest)?;
512    let question = Question { origin, ..question };
513    ask_with_page(st, me, question, &page, thread).await
514}
515
516/// A question in the user's conversation (or, with `thread` false, a single
517/// question that sees the page but is not stored): resolves the page with
518/// the user's rights, quotes the earlier messages of the conversation, runs
519/// the assistant with the user's *current* rights and stores the new
520/// question and answer. `page` is what the client sent, unresolved.
521///
522/// # Invariants
523///
524/// * **Permission:** any signed-in user; the assistant must be configured
525///   (otherwise the `Disabled` error) and a free run slot must exist (`Busy`).
526///   The page identifiers are hints and are resolved with the actor's rights,
527///   hidden ones are dropped like missing ones. The tools run with the actor's
528///   current rights; draft tools only when the question allows them and the
529///   actor may edit.
530/// * **Steps:** the run itself is not one transaction. The audit record is
531///   written after the run, in its own connection, for successful, failed and
532///   (by a drop guard) cancelled runs. The question and answer are stored
533///   afterwards in a second transaction (`threads::append`, only with
534///   `thread`); when that fails, it is logged and the answer is still
535///   returned.
536/// * **Audit:** `assistant.run` with provider, action, question length, the
537///   resolved ids and counts of the page, tools and token usage – never the
538///   question, the answer or the typed query text.
539/// * **Notifications / webhooks:** none from this function (draft tools have
540///   those of their own).
541pub async fn ask_with_page(
542    st: &AppState,
543    me: &Actor,
544    mut question: Question,
545    page: &PageContext,
546    thread: bool,
547) -> AppResult<Run> {
548    let Some(client) = st.assistant.as_deref() else {
549        return Err(AssistantError::Disabled.into_app_error());
550    };
551    let resolved = resolve_context(&st.db, me, page).await?;
552    // quick actions and the first message use what the user may read, so a
553    // hidden item behaves exactly like one that does not exist
554    question.item = resolved.item.clone();
555    question.revision = resolved.revision.clone();
556    question.change = resolved.change.clone();
557    question.page = resolved.clone();
558    question.thread = thread;
559    match question.action.needs() {
560        "revision" if question.item.is_none() => {
561            return Err(AppError::BadRequest(format!(
562                "“{}” needs an item.",
563                question.action.label()
564            )))
565        }
566        "change" if question.change.is_none() => {
567            return Err(AppError::BadRequest(format!(
568                "“{}” needs a change order.",
569                question.action.label()
570            )))
571        }
572        _ => {}
573    }
574    let mut prior_taint = false;
575    if thread {
576        let stored = threads::load(&st.db, me, client.retention_days).await?;
577        question.history = threads::history_of(&stored);
578        prior_taint = threads::was_tainted(&stored);
579    }
580    let action = question.action;
581    let text = question.text.clone();
582    let (mut run, tainted) = run_once(st, me, question, prior_taint).await?;
583    let note = ContextNote {
584        kind: resolved.kind.clone(),
585        label: resolved.label(),
586    };
587    if thread {
588        let shown = match (action, text.is_empty()) {
589            (assistant::Action::Ask, _) => text,
590            (_, true) => action.label().to_string(),
591            (_, false) => format!("{} – {text}", action.label()),
592        };
593        let stored = async {
594            let mut tx = st.db.begin().await?;
595            threads::append(
596                &mut tx,
597                me,
598                &threads::Exchange {
599                    action: action.as_str(),
600                    question: &shown,
601                    context_kind: &note.kind,
602                    context_label: &note.label,
603                    answer: &run.answer.text,
604                    tainted,
605                },
606            )
607            .await?;
608            tx.commit().await?;
609            AppResult::Ok(())
610        }
611        .await;
612        if let Err(e) = stored {
613            // the answer is not lost; the thread misses this exchange
614            tracing::warn!(error = %e, "storing an assistant exchange failed");
615        }
616    }
617    run.context = Some(note);
618    Ok(run)
619}
620
621/// What *Admin → AI* shows: the local-agent switch (`TEAMCRAFT_LOCAL_AGENTS`)
622/// and the server-side assistant (provider, model, host, limits). Never the
623/// API key or the URL path – only whether a key is set. Pure, for tests.
624pub fn admin_overview(
625    local_agents: crate::config::LocalAgents,
626    assistant: Option<&crate::config::AssistantConfig>,
627) -> Value {
628    // the mode in one word, for the summary line of the page
629    let mode = match (local_agents.allowed(), assistant.is_some()) {
630        (false, true) => "company-ai",
631        (false, false) => "no-ai",
632        (true, true) => "both",
633        (true, false) => "local-only",
634    };
635    json!({
636        "mode": mode,
637        "local_agents": local_agents.as_str(),
638        "local_agents_allowed": local_agents.allowed(),
639        "assistant": assistant.map(|a| json!({
640            "provider": a.provider.as_str(),
641            "model": a.model,
642            "host": a.host(),
643            "key_set": a.api_key.is_some(),
644            "max_steps": a.max_steps,
645            "max_tokens": a.max_tokens,
646            "max_concurrent": a.max_concurrent,
647            "run_timeout_secs": a.run_timeout.as_secs(),
648            "retention_days": a.retention_days,
649        })),
650    })
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656    use crate::config::{AssistantConfig, AssistantProvider, LocalAgents};
657    use std::time::Duration;
658
659    fn sample(key: Option<&str>) -> AssistantConfig {
660        AssistantConfig {
661            provider: AssistantProvider::OpenAiCompatible,
662            base_url: "https://ai.example.internal/v1/secret-path".into(),
663            api_key: key.map(str::to_string),
664            model: "llama-4-70b".into(),
665            max_steps: 8,
666            max_tokens: 2048,
667            request_timeout: Duration::from_secs(60),
668            run_timeout: Duration::from_secs(180),
669            max_concurrent: 4,
670            retention_days: 30,
671            internal_urls: Vec::new(),
672        }
673    }
674
675    #[test]
676    fn report_files_are_pdf_html_and_text() {
677        for ok in [
678            "report.pdf",
679            "REPORT.PDF",
680            "r.html",
681            "r.htm",
682            "notes.txt",
683            "solver.out",
684        ] {
685            assert!(is_report_file(ok), "{ok}");
686        }
687        for no in [
688            "model.step",
689            "result.vtk",
690            "noextension",
691            "archive.zip",
692            "pdf",
693        ] {
694            assert!(!is_report_file(no), "{no}");
695        }
696    }
697
698    #[test]
699    fn admin_overview_names_provider_and_model_but_never_the_key() {
700        let cfg = sample(Some("sk-very-secret-key"));
701        let v = admin_overview(LocalAgents::Off, Some(&cfg));
702        assert_eq!(v["mode"], "company-ai");
703        assert_eq!(v["local_agents"], "off");
704        assert_eq!(v["assistant"]["provider"], "openai-compatible");
705        assert_eq!(v["assistant"]["model"], "llama-4-70b");
706        assert_eq!(v["assistant"]["host"], "ai.example.internal");
707        assert_eq!(v["assistant"]["key_set"], true);
708        let text = v.to_string();
709        assert!(!text.contains("sk-very-secret-key"), "{text}");
710        assert!(!text.contains("secret-path"), "host only: {text}");
711
712        let v = admin_overview(LocalAgents::Off, None);
713        assert_eq!(v["mode"], "no-ai");
714        assert!(v["assistant"].is_null());
715        assert_eq!(
716            admin_overview(LocalAgents::Allowed, Some(&cfg))["mode"],
717            "both"
718        );
719        assert_eq!(
720            admin_overview(LocalAgents::Allowed, None)["mode"],
721            "local-only"
722        );
723        // a local server without a key
724        let v = admin_overview(LocalAgents::Allowed, Some(&sample(None)));
725        assert_eq!(v["assistant"]["key_set"], false);
726    }
727}