memoryd: recall's Honcho part, every source at once with its own limit
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
//! `recall`'s Honcho part (M5c spec, section 8), against a fake Honcho that answers from the
|
||||
//! responses recorded from rift: every read workspace and the fleet's searched at once, never the
|
||||
//! agent's own; a source that hangs or fails is one `unavailable` line after its limit while the
|
||||
//! others answer; a missing workspace is no hits; a query holding a secret asks nothing; and the
|
||||
//! answer made from the local items and Honcho's, with the stricter label. Do not edit.
|
||||
|
||||
#[path = "support/honcho.rs"]
|
||||
mod honcho;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::ffi::OsString;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bxcore::{DataClass, RecallArgs};
|
||||
use honcho::{FakeHoncho, Mode};
|
||||
use memoryd::honcho::config::{HonchoConfig, ReadWorkspace, Trust};
|
||||
use memoryd::honcho::read::{ReadTimeouts, Readers};
|
||||
use memoryd::honcho::search::{SECRET, SOURCE_WAIT, Searched, answer, search};
|
||||
use memoryd::recall::Local;
|
||||
use memoryd::serve::Log;
|
||||
|
||||
const FAST: ReadTimeouts = ReadTimeouts {
|
||||
connect: Duration::from_secs(5),
|
||||
search: Duration::from_secs(5),
|
||||
chat: Duration::from_secs(5),
|
||||
};
|
||||
/// The limit the tests give each source: long, so that a loaded machine never makes a source
|
||||
/// that answers late; a search where every source answers returns at once.
|
||||
const WAIT: Duration = Duration::from_secs(10);
|
||||
/// The limit when a source hangs: the others still answer well inside it.
|
||||
const HANG: Duration = Duration::from_millis(1_500);
|
||||
|
||||
fn read(agent: &str, trust: Trust) -> ReadWorkspace {
|
||||
ReadWorkspace {
|
||||
agent: agent.to_string(),
|
||||
workspace: format!("{agent}-trusted"),
|
||||
trust,
|
||||
class: DataClass::Private,
|
||||
}
|
||||
}
|
||||
|
||||
/// `boxmaker0`, owned by `kyle`, reading `boxmaker1` (labelled trusted) and `boxmaker2`.
|
||||
fn readers(fake: &FakeHoncho, read: Vec<ReadWorkspace>) -> Arc<Readers> {
|
||||
let config = HonchoConfig {
|
||||
url: fake.url.clone(),
|
||||
agent: "boxmaker0".to_string(),
|
||||
owner: "kyle".to_string(),
|
||||
trusted: "boxmaker0-trusted".to_string(),
|
||||
untrusted: "boxmaker0-untrusted".to_string(),
|
||||
fleet: "fleet".to_string(),
|
||||
read,
|
||||
keys: BTreeMap::new(),
|
||||
};
|
||||
let env = |_: &str| -> Option<OsString> { None };
|
||||
Arc::new(Readers::new(&config, &env, FAST).unwrap().0)
|
||||
}
|
||||
|
||||
fn two(fake: &FakeHoncho) -> Arc<Readers> {
|
||||
readers(
|
||||
fake,
|
||||
vec![
|
||||
read("boxmaker1", Trust::Trusted),
|
||||
read("boxmaker2", Trust::Untrusted),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// A log that keeps its lines.
|
||||
fn log() -> (Log, Arc<Mutex<Vec<String>>>) {
|
||||
let lines = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = Arc::clone(&lines);
|
||||
let log: Log = Arc::new(move |line: &str| sink.lock().unwrap().push(line.to_string()));
|
||||
(log, lines)
|
||||
}
|
||||
|
||||
fn args(query: &str, k: u64) -> RecallArgs {
|
||||
RecallArgs {
|
||||
query: query.to_string(),
|
||||
k,
|
||||
}
|
||||
}
|
||||
|
||||
fn nothing_local() -> Local {
|
||||
Local {
|
||||
items: Vec::new(),
|
||||
class: DataClass::Private,
|
||||
untrusted: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn asked(searched: &Searched) -> (Vec<Vec<String>>, Vec<String>) {
|
||||
match searched {
|
||||
Searched::Asked { found, unavailable } => (
|
||||
found
|
||||
.iter()
|
||||
.map(|items| items.iter().map(|i| i.text.clone()).collect())
|
||||
.collect(),
|
||||
unavailable.clone(),
|
||||
),
|
||||
Searched::Secret => panic!("not asked"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_limit() {
|
||||
assert_eq!(SOURCE_WAIT, Duration::from_secs(5));
|
||||
assert_eq!(
|
||||
SECRET,
|
||||
"honcho: not asked; the query looks like it holds a secret"
|
||||
);
|
||||
}
|
||||
|
||||
/// A read agent's workspace: its messages, the conclusions about its peer and about the owner's;
|
||||
/// the fleet's: its messages. Each kept only with a word of the query, once.
|
||||
#[test]
|
||||
fn every_source_is_searched_with_its_calls() {
|
||||
let fake = FakeHoncho::start();
|
||||
let (log, lines) = log();
|
||||
let searched = search(&two(&fake), "kyle", &args("tea", 7), WAIT, &log);
|
||||
let (found, unavailable) = asked(&searched);
|
||||
let tea = [
|
||||
"I prefer tea to coffee in the afternoon.",
|
||||
"Noted: tea in the afternoon.",
|
||||
];
|
||||
let with_owner = [tea[0], tea[1], "Kyle prefers tea in the afternoon."];
|
||||
assert_eq!(found, [&with_owner[..], &with_owner[..], &tea[..]]);
|
||||
assert_eq!(unavailable, Vec::<String>::new());
|
||||
assert!(lines.lock().unwrap().is_empty());
|
||||
let mut seen: Vec<(String, serde_json::Value)> = fake
|
||||
.requests()
|
||||
.into_iter()
|
||||
.map(|r| (r.path, r.body.unwrap_or_default()))
|
||||
.collect();
|
||||
seen.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.to_string().cmp(&b.1.to_string())));
|
||||
let query = |peer: &str| {
|
||||
serde_json::json!({ "query": "tea", "top_k": 7,
|
||||
"filters": { "observer_id": peer, "observed_id": peer } })
|
||||
};
|
||||
let search = serde_json::json!({ "query": "tea", "limit": 7 });
|
||||
let w = "/v3/workspaces";
|
||||
assert_eq!(
|
||||
seen,
|
||||
[
|
||||
(
|
||||
format!("{w}/boxmaker1-trusted/conclusions/query"),
|
||||
query("boxmaker1")
|
||||
),
|
||||
(
|
||||
format!("{w}/boxmaker1-trusted/conclusions/query"),
|
||||
query("kyle")
|
||||
),
|
||||
(format!("{w}/boxmaker1-trusted/search"), search.clone()),
|
||||
(
|
||||
format!("{w}/boxmaker2-trusted/conclusions/query"),
|
||||
query("boxmaker2")
|
||||
),
|
||||
(
|
||||
format!("{w}/boxmaker2-trusted/conclusions/query"),
|
||||
query("kyle")
|
||||
),
|
||||
(format!("{w}/boxmaker2-trusted/search"), search.clone()),
|
||||
(format!("{w}/fleet/search"), search),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// The agent's own workspaces hold what the local index holds: `recall` never searches them.
|
||||
#[test]
|
||||
fn the_agents_own_workspaces_are_never_searched() {
|
||||
let fake = FakeHoncho::start();
|
||||
let (log, _) = log();
|
||||
search(&two(&fake), "kyle", &args("tea", 5), WAIT, &log);
|
||||
let requests = fake.requests();
|
||||
assert_eq!(requests.len(), 7);
|
||||
for request in requests {
|
||||
assert!(!request.path.contains("boxmaker0"), "{}", request.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// The answer shows each source's hits with its tag, numbered after the local items, one from
|
||||
/// each source in turn.
|
||||
#[test]
|
||||
fn the_answer_shows_each_sources_hits_with_its_tag() {
|
||||
let fake = FakeHoncho::start();
|
||||
let (log, _) = log();
|
||||
let searched = search(&two(&fake), "kyle", &args("tea", 4), WAIT, &log);
|
||||
let local = Local {
|
||||
items: vec![
|
||||
"[1] memory · session mm-a, 2026-09-20 14:02 UTC, the owner said · private\n tea time"
|
||||
.to_string(),
|
||||
],
|
||||
class: DataClass::Private,
|
||||
untrusted: false,
|
||||
};
|
||||
let got = answer(local, "tea", 4, searched);
|
||||
let headers: Vec<&str> = got
|
||||
.text
|
||||
.lines()
|
||||
.filter(|l| l.starts_with('['))
|
||||
.map(|l| l.split(" · ").next().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
headers,
|
||||
[
|
||||
"[1] memory",
|
||||
"[2] memory",
|
||||
"[3] memory-tainted",
|
||||
"[4] memory-tainted"
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
got.text.contains("\n[2] memory · boxmaker1: kyle said, "),
|
||||
"{}",
|
||||
got.text
|
||||
);
|
||||
assert!(
|
||||
got.text
|
||||
.contains("\n[4] memory-tainted · fleet: kyle said, "),
|
||||
"{}",
|
||||
got.text
|
||||
);
|
||||
assert_eq!((got.class, got.untrusted), (DataClass::Private, true));
|
||||
}
|
||||
|
||||
/// T58: a fleet hit makes the answer untrusted; a hit from a workspace the owner labelled trusted
|
||||
/// does not.
|
||||
#[test]
|
||||
fn a_fleet_hit_taints_the_answer_and_a_trusted_workspaces_does_not() {
|
||||
let fake = FakeHoncho::start();
|
||||
let (log, _) = log();
|
||||
let one = readers(&fake, vec![read("boxmaker1", Trust::Trusted)]);
|
||||
fake.set_workspace("fleet", Mode::Missing);
|
||||
let got = answer(
|
||||
nothing_local(),
|
||||
"tea",
|
||||
5,
|
||||
search(&one, "kyle", &args("tea", 5), WAIT, &log),
|
||||
);
|
||||
assert!(
|
||||
got.text.contains("[1] memory · boxmaker1: "),
|
||||
"{}",
|
||||
got.text
|
||||
);
|
||||
assert_eq!((got.class, got.untrusted), (DataClass::Private, false));
|
||||
fake.set_workspace("fleet", Mode::Fixtures);
|
||||
fake.set_workspace("boxmaker1-trusted", Mode::Missing);
|
||||
let got = answer(
|
||||
nothing_local(),
|
||||
"tea",
|
||||
5,
|
||||
search(&one, "kyle", &args("tea", 5), WAIT, &log),
|
||||
);
|
||||
assert!(
|
||||
got.text.starts_with("[1] memory-tainted · fleet: "),
|
||||
"{}",
|
||||
got.text
|
||||
);
|
||||
assert_eq!((got.class, got.untrusted), (DataClass::Private, true));
|
||||
}
|
||||
|
||||
/// A source that does not answer is one `unavailable` line after its limit; the others answer.
|
||||
#[test]
|
||||
fn a_hanging_source_is_unavailable_and_the_others_still_answer() {
|
||||
let fake = FakeHoncho::start();
|
||||
fake.set_workspace("boxmaker2-trusted", Mode::Hang);
|
||||
let (log, lines) = log();
|
||||
let started = Instant::now();
|
||||
let searched = search(&two(&fake), "kyle", &args("tea", 10), HANG, &log);
|
||||
let took = started.elapsed();
|
||||
assert!(took >= HANG, "{took:?}");
|
||||
assert!(took < Duration::from_secs(6), "{took:?}");
|
||||
let (found, unavailable) = asked(&searched);
|
||||
assert_eq!(unavailable, ["boxmaker2"]);
|
||||
assert_eq!(found.iter().map(Vec::len).collect::<Vec<_>>(), [3, 0, 2]);
|
||||
assert_eq!(
|
||||
*lines.lock().unwrap(),
|
||||
["memoryd: recall: boxmaker2 unavailable: no answer in 1500 ms"]
|
||||
);
|
||||
let got = answer(nothing_local(), "tea", 10, searched);
|
||||
assert!(
|
||||
got.text.ends_with("\nboxmaker2: unavailable"),
|
||||
"{}",
|
||||
got.text
|
||||
);
|
||||
}
|
||||
|
||||
/// The sources are asked at once: when every one hangs, the answer comes after one limit, not
|
||||
/// one per source.
|
||||
#[test]
|
||||
fn every_source_is_asked_at_once() {
|
||||
let fake = FakeHoncho::start();
|
||||
fake.set(Mode::Hang);
|
||||
let (log, _) = log();
|
||||
let started = Instant::now();
|
||||
let (found, unavailable) = asked(&search(&two(&fake), "kyle", &args("tea", 5), HANG, &log));
|
||||
let took = started.elapsed();
|
||||
assert!(took < Duration::from_millis(4_500), "{took:?}");
|
||||
assert_eq!(unavailable, ["boxmaker1", "boxmaker2", "fleet"]);
|
||||
assert!(found.iter().all(Vec::is_empty));
|
||||
}
|
||||
|
||||
/// A source whose call fails is unavailable, logged by the error's kind and status only.
|
||||
#[test]
|
||||
fn a_failing_source_is_unavailable_and_logged_without_the_query() {
|
||||
let fake = FakeHoncho::start();
|
||||
fake.set_workspace("fleet", Mode::Status(503));
|
||||
fake.set_workspace("boxmaker1-trusted", Mode::Status(422));
|
||||
let (log, lines) = log();
|
||||
let searched = search(&two(&fake), "kyle", &args("tea", 5), WAIT, &log);
|
||||
let (found, unavailable) = asked(&searched);
|
||||
assert_eq!(unavailable, ["boxmaker1", "fleet"]);
|
||||
assert_eq!(found[1].len(), 3);
|
||||
assert_eq!(
|
||||
*lines.lock().unwrap(),
|
||||
[
|
||||
"memoryd: recall: boxmaker1 unavailable: Honcho refused the request (status, HTTP 422)",
|
||||
"memoryd: recall: fleet unavailable: Honcho is unreachable (server, HTTP 503)",
|
||||
]
|
||||
);
|
||||
let got = answer(nothing_local(), "tea", 5, searched);
|
||||
assert!(
|
||||
got.text
|
||||
.ends_with("\nboxmaker1: unavailable\nfleet: unavailable"),
|
||||
"{}",
|
||||
got.text
|
||||
);
|
||||
}
|
||||
|
||||
/// Honcho answers a search or a conclusion query in a workspace that does not exist with 200 and
|
||||
/// no hits: the source answered, with nothing.
|
||||
#[test]
|
||||
fn a_missing_workspace_is_no_hits() {
|
||||
let fake = FakeHoncho::start();
|
||||
fake.set_workspace("boxmaker1-trusted", Mode::Missing);
|
||||
let (log, lines) = log();
|
||||
let (found, unavailable) = asked(&search(&two(&fake), "kyle", &args("tea", 5), WAIT, &log));
|
||||
assert_eq!(found[0], Vec::<String>::new());
|
||||
assert_eq!(unavailable, Vec::<String>::new());
|
||||
assert!(lines.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// A query that holds a known-prefix secret is sent to no source (M5c spec, section 8).
|
||||
#[test]
|
||||
fn a_query_holding_a_secret_asks_nothing() {
|
||||
let fake = FakeHoncho::start();
|
||||
let (log, _) = log();
|
||||
let token = format!("{}{}", ["gh", "p_"].concat(), "A1b2".repeat(9));
|
||||
let searched = search(
|
||||
&two(&fake),
|
||||
"kyle",
|
||||
&args(&format!("tea {token}"), 5),
|
||||
WAIT,
|
||||
&log,
|
||||
);
|
||||
assert_eq!(searched, Searched::Secret);
|
||||
assert_eq!(fake.requests().len(), 0);
|
||||
let local = Local {
|
||||
items: vec!["[1] memory · x · private\n tea".to_string()],
|
||||
class: DataClass::Private,
|
||||
untrusted: false,
|
||||
};
|
||||
let got = answer(local, "tea", 5, Searched::Secret);
|
||||
assert_eq!(
|
||||
got.text,
|
||||
format!("[1] memory · x · private\n tea\n{SECRET}")
|
||||
);
|
||||
assert_eq!((got.class, got.untrusted), (DataClass::Private, false));
|
||||
}
|
||||
|
||||
/// Neither the local index nor Honcho has anything: the sentence local `recall` gives, then any
|
||||
/// `unavailable` lines.
|
||||
#[test]
|
||||
fn when_nothing_is_found_the_answer_says_so() {
|
||||
let fake = FakeHoncho::start();
|
||||
let (log, _) = log();
|
||||
let searched = search(&two(&fake), "kyle", &args("zebra", 5), WAIT, &log);
|
||||
let got = answer(nothing_local(), "zebra", 5, searched);
|
||||
assert_eq!(got.text, "(no matches for \"zebra\")");
|
||||
assert_eq!((got.class, got.untrusted), (DataClass::Private, false));
|
||||
let got = answer(
|
||||
nothing_local(),
|
||||
"zebra",
|
||||
5,
|
||||
Searched::Asked {
|
||||
found: vec![Vec::new()],
|
||||
unavailable: vec!["fleet".to_string()],
|
||||
},
|
||||
);
|
||||
assert_eq!(got.text, "(no matches for \"zebra\")\nfleet: unavailable");
|
||||
}
|
||||
|
||||
/// The local index first: when its hits fill `k`, no Honcho hit is shown and none labels the
|
||||
/// answer. The label is the stricter of the two parts'.
|
||||
#[test]
|
||||
fn local_hits_come_first_and_the_labels_join() {
|
||||
let fake = FakeHoncho::start();
|
||||
let (log, _) = log();
|
||||
let local = Local {
|
||||
items: vec![
|
||||
"[1] memory · a · secret\n tea".to_string(),
|
||||
"[2] memory · b · private\n tea".to_string(),
|
||||
],
|
||||
class: DataClass::Secret,
|
||||
untrusted: false,
|
||||
};
|
||||
let searched = search(&two(&fake), "kyle", &args("tea", 2), WAIT, &log);
|
||||
let got = answer(local.clone(), "tea", 2, searched.clone());
|
||||
assert_eq!(got.text, local.items.join("\n"));
|
||||
assert_eq!((got.class, got.untrusted), (DataClass::Secret, false));
|
||||
let got = answer(local, "tea", 3, searched);
|
||||
assert_eq!(got.text.lines().filter(|l| l.starts_with('[')).count(), 3);
|
||||
assert!(
|
||||
got.text.contains("\n[3] memory · boxmaker1: "),
|
||||
"{}",
|
||||
got.text
|
||||
);
|
||||
assert_eq!((got.class, got.untrusted), (DataClass::Secret, false));
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
//! A fake Honcho for tests, on 127.0.0.1 and a port of its own: it answers each request from the
|
||||
//! responses recorded from rift (`tests/fixtures/honcho/`), by method and path (a batch of
|
||||
//! messages or conclusions answered with as many as it asked for, up to the two recorded), and
|
||||
//! records every request, body included. It can be set to answer every request with a status instead, or to
|
||||
//! answer nothing. Stopped, and its held connections released, when dropped; every wait has a
|
||||
//! deadline. Do not edit.
|
||||
//! records every request, body included. It can be set to answer every request with a status
|
||||
//! instead, to answer nothing, or to answer as a workspace that does not exist, for every
|
||||
//! workspace or for one. Stopped, and its held connections released, when dropped; every wait has
|
||||
//! a deadline. Do not edit.
|
||||
|
||||
#![allow(dead_code)] // each test file uses a different part of this module
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::PathBuf;
|
||||
@@ -46,10 +47,15 @@ pub enum Mode {
|
||||
Status(u16),
|
||||
/// Read the request, then answer nothing until the fake is dropped (at most 30 s).
|
||||
Hang,
|
||||
/// As a workspace that does not exist: a search or a conclusion query answers 200 with no
|
||||
/// hits (fixtures 34, 35), chat 404 (36).
|
||||
Missing,
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
mode: Mutex<Mode>,
|
||||
/// A mode for one workspace's requests, by its name, instead of `mode`.
|
||||
workspaces: Mutex<HashMap<String, Mode>>,
|
||||
requests: Mutex<Vec<Request>>,
|
||||
/// What was made, by method and path and id: a second create answers 200, a second delete
|
||||
/// 404.
|
||||
@@ -85,6 +91,7 @@ impl FakeHoncho {
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let shared = Arc::new(Shared {
|
||||
mode: Mutex::new(Mode::Fixtures),
|
||||
workspaces: Mutex::new(HashMap::new()),
|
||||
requests: Mutex::new(Vec::new()),
|
||||
made: Mutex::new(HashSet::new()),
|
||||
stop: AtomicBool::new(false),
|
||||
@@ -112,6 +119,15 @@ impl FakeHoncho {
|
||||
*self.shared.mode.lock().unwrap() = mode;
|
||||
}
|
||||
|
||||
/// Answer `workspace`'s requests in `mode`, whatever `set` says for the others.
|
||||
pub fn set_workspace(&self, workspace: &str, mode: Mode) {
|
||||
self.shared
|
||||
.workspaces
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(workspace.to_string(), mode);
|
||||
}
|
||||
|
||||
/// Every request read so far, in order.
|
||||
pub fn requests(&self) -> Vec<Request> {
|
||||
self.shared.requests.lock().unwrap().clone()
|
||||
@@ -222,6 +238,21 @@ fn answer(request: &Request, made: &Mutex<HashSet<String>>) -> (u16, String) {
|
||||
request,
|
||||
"conclusions",
|
||||
),
|
||||
// A search answers the three recorded messages about tea (fixture 30), whatever it asked.
|
||||
("POST", ["v3", "workspaces", _, "search"]) => fixture("30-workspace-search-limit.json"),
|
||||
// A conclusion query answers the recorded one about the owner (32) when it asks about
|
||||
// `kyle`, else the one about an agent (33).
|
||||
("POST", ["v3", "workspaces", _, "conclusions", "query"]) => {
|
||||
let about = request
|
||||
.body
|
||||
.as_ref()
|
||||
.and_then(|b| b["filters"]["observed_id"].as_str().map(str::to_string));
|
||||
if about.as_deref() == Some("kyle") {
|
||||
fixture("32-conclusions-query-owner.json")
|
||||
} else {
|
||||
fixture("33-conclusions-query-agent.json")
|
||||
}
|
||||
}
|
||||
("DELETE", ["v3", "workspaces", _, "conclusions", _]) if again => {
|
||||
fixture("16-conclusion-delete-again.json")
|
||||
}
|
||||
@@ -251,9 +282,20 @@ fn serve(mut stream: TcpStream, shared: &Shared) {
|
||||
return;
|
||||
};
|
||||
shared.requests.lock().unwrap().push(request.clone());
|
||||
let mode = *shared.mode.lock().unwrap();
|
||||
let workspace = request.path.split('/').nth(3).unwrap_or("").to_string();
|
||||
let mode = match shared.workspaces.lock().unwrap().get(&workspace) {
|
||||
Some(mode) => *mode,
|
||||
None => *shared.mode.lock().unwrap(),
|
||||
};
|
||||
let (status, body) = match mode {
|
||||
Mode::Fixtures => answer(&request, &shared.made),
|
||||
Mode::Missing if request.path.ends_with("/search") => {
|
||||
fixture("34-workspace-search-missing-workspace.json")
|
||||
}
|
||||
Mode::Missing if request.path.ends_with("/conclusions/query") => {
|
||||
fixture("35-conclusions-query-missing-workspace.json")
|
||||
}
|
||||
Mode::Missing => fixture("36-workspace-chat-missing-workspace.json"),
|
||||
Mode::Status(422) => {
|
||||
let input = request.body.clone().unwrap_or(Value::Null);
|
||||
(
|
||||
|
||||
Reference in New Issue
Block a user