feat: expose bounded memory inspection and lifecycle controls via Runtime API

Implements GET/v1/memory (list with scope/search/limit), GET /v1/memory/{id}
(inspect), POST /v1/memory (create, auth-gated), and DELETE /v1/memory (clear
by scope) backed by the existing NativeMemoryStore.

Key design decisions:
- Raw file-system paths are never exposed; entries carry scope ("global" /
  "workspace") and workspace_id (SHA-256 digest of origin URL, not a path)
- Summaries are bounded to 300 chars to prevent private data exfiltration
- Workspace scope lookups are silently empty when no git origin is configured
  (same behavior as the existing get_for_workspace boundary)
- DELETE /v1/memory requires explicit scope= param, rejecting absent/empty values
- memory: true is advertised in GET /v1/runtime/info capabilities

Also adds NativeMemoryStore::list_all() for ordered listing without FTS, and
updates the RuntimeCapabilities struct + test in the protocol crate.

Closes #5072
This commit is contained in:
copilot-swe-agent[bot]
2026-08-03 01:57:54 +00:00
committed by CodeWhale Bot
parent 8cf9280e25
commit 548b8b52df
4 changed files with 677 additions and 0 deletions
+8
View File
@@ -63,6 +63,12 @@ pub struct RuntimeCapabilities {
/// lifecycle actions are available.
#[serde(default)]
pub thread_goals: bool,
/// `GET /v1/memory` and `GET /v1/memory/{id}` are available for
/// bounded inspection of the native memory store. `POST /v1/memory`
/// and `DELETE /v1/memory` are also available (auth-gated via the
/// standard route layer) for lifecycle controls.
#[serde(default)]
pub memory: bool,
}
/// Experimental opt-in flags advertised by `GET /v1/runtime/info`.
@@ -360,6 +366,7 @@ mod tests {
fleet_event_stream: true,
fleet_local_target: true,
thread_goals: true,
memory: true,
};
let value = serde_json::to_value(&caps).unwrap();
let obj = value.as_object().unwrap();
@@ -370,6 +377,7 @@ mod tests {
assert_eq!(obj.get("fleet_run_create").unwrap(), &json!(true));
assert_eq!(obj.get("fleet_event_stream").unwrap(), &json!(true));
assert_eq!(obj.get("thread_goals").unwrap(), &json!(true));
assert_eq!(obj.get("memory").unwrap(), &json!(true));
}
#[test]
+53
View File
@@ -397,6 +397,59 @@ impl NativeMemoryStore {
})
}
/// List all entries in the selected scope ordered by insertion. When
/// `scope` is `None`, every scope is included. Reindexes before retrieval
/// so direct Markdown edits are always visible.
pub fn list_all(
&self,
scope: Option<MemoryScope>,
workspace_id: Option<&str>,
limit: usize,
) -> Result<Vec<MemoryHit>> {
self.with_write_lock(|| {
self.reindex_unlocked()?;
let conn = self.connection_unlocked()?;
let limit = limit.clamp(1, 500) as i64;
match scope {
None => {
let mut stmt = conn.prepare(
"SELECT e.id,e.text,e.source,e.line_start,e.line_end,
CASE WHEN e.source_mtime != s.mtime THEN 1 ELSE 0 END
FROM memory_entries e
LEFT JOIN memory_sources s ON s.path=e.source
ORDER BY e.id LIMIT ?1",
)?;
let rows = stmt.query_map(params![limit], memory_hit_from_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
Some(scope_val) => {
let source = match scope_val {
MemoryScope::Global => self.global_path(),
MemoryScope::Workspace => {
self.workspace_path(workspace_id.ok_or_else(|| {
anyhow!("workspace scope requires a workspace id")
})?)?
}
};
let mut stmt = conn.prepare(
"SELECT e.id,e.text,e.source,e.line_start,e.line_end,
CASE WHEN e.source_mtime != s.mtime THEN 1 ELSE 0 END
FROM memory_entries e
LEFT JOIN memory_sources s ON s.path=e.source
WHERE e.source=?1 ORDER BY e.id LIMIT ?2",
)?;
let rows = stmt.query_map(
params![source.to_string_lossy(), limit],
memory_hit_from_row,
)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
}
})
}
pub fn get(&self, id: i64) -> Result<Option<MemoryHit>> {
self.with_fresh_index(|conn| {
Ok(conn
+295
View File
@@ -402,6 +402,7 @@ fn default_runtime_capabilities() -> RuntimeCapabilities {
fleet_event_stream: true,
fleet_local_target: true,
thread_goals: true,
memory: true,
}
}
@@ -804,6 +805,13 @@ pub fn build_router(state: RuntimeApiState) -> Router {
.route("/v1/providers/{id}/switch", post(switch_provider))
.route("/v1/config", get(get_config).post(set_config))
.route("/v1/config/reload", post(reload_config))
.route(
"/v1/memory",
get(list_memory)
.post(create_memory_entry)
.delete(clear_memory),
)
.route("/v1/memory/{id}", get(get_memory_entry))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_runtime_token,
@@ -4846,6 +4854,293 @@ async fn reload_config(
}))
}
// ── Memory inspection and lifecycle endpoints ──
/// Maximum summary length returned per entry. Bounds the API surface so raw
/// private text cannot exfiltrate through JSON responses.
const MEMORY_SUMMARY_MAX_CHARS: usize = 300;
/// Default result cap for `GET /v1/memory`.
const MEMORY_LIST_DEFAULT_LIMIT: usize = 50;
/// Hard ceiling — protects against oversized responses.
const MEMORY_LIST_MAX_LIMIT: usize = 200;
/// Typed, redacted projection of a single native memory entry.
///
/// Raw file-system paths are never exposed; `scope` and `workspace_id` (a
/// SHA-256 digest of the repository origin URL, not a local path) give
/// managed clients enough provenance to reason about each entry.
#[derive(Debug, Serialize)]
struct MemoryEntryRecord {
/// SQLite row id. Stable across reindexes unless the source Markdown
/// file is cleared and rewritten.
id: i64,
/// `"global"` or `"workspace"`.
scope: &'static str,
/// SHA-256 digest of the repository origin URL for workspace-scoped
/// entries; `null` for global entries.
workspace_id: Option<String>,
/// Bounded plain-text summary (max `MEMORY_SUMMARY_MAX_CHARS` chars).
/// Truncated with `…` when the source text is longer. Never contains
/// raw prompt or turn content.
summary: String,
/// `true` when the source Markdown file has been modified since the
/// entry was last indexed.
stale: bool,
/// 1-based start line in the source Markdown file.
line_start: usize,
/// 1-based end line in the source Markdown file.
line_end: usize,
/// `"active"` or `"stale"` (human-readable alias for `stale`).
status: &'static str,
}
#[derive(Debug, Deserialize)]
struct ListMemoryQuery {
/// Filter by scope: `"global"`, `"workspace"`, or `"all"` (default).
scope: Option<String>,
/// FTS search query (max 256 chars). When absent all entries for the
/// requested scope are returned in insertion order.
q: Option<String>,
/// Maximum entries to return (default 50, max 200).
limit: Option<usize>,
}
/// Request body for `POST /v1/memory`.
#[derive(Debug, Deserialize)]
struct CreateMemoryRequest {
/// The memory note text (max 64 KiB after normalisation).
text: String,
/// `"global"` (default) or `"workspace"`.
#[serde(default)]
scope: String,
}
/// Query params for `DELETE /v1/memory`.
#[derive(Debug, Deserialize)]
struct ClearMemoryQuery {
/// One of `"global"`, `"workspace"`, or `"all"`. Required.
scope: String,
}
/// Build a `NativeMemoryStore` rooted at the same location the TUI uses.
/// Mirrors `native_store()` in `commands/groups/memory/memory.rs`.
fn native_store_for_state(state: &RuntimeApiState) -> crate::native_memory::NativeMemoryStore {
let memory_path = state.config.read().memory_path();
if let Some(store) = crate::native_memory::NativeMemoryStore::from_global_path(&memory_path) {
return store;
}
let root = memory_path
.parent()
.unwrap_or_else(|| FsPath::new("."))
.join("memory");
crate::native_memory::NativeMemoryStore::new(root)
}
/// Derive a scope label from a source path relative to the store root.
/// Returns `"global"`, `"workspace"`, or `"unknown"`.
fn scope_label_for_source(source: &FsPath, store_root: &FsPath) -> &'static str {
let Ok(rel) = source.strip_prefix(store_root) else {
return "unknown";
};
match rel.components().next().and_then(|c| c.as_os_str().to_str()) {
Some("global") => "global",
Some("workspace") => "workspace",
_ => "unknown",
}
}
/// Extract the workspace_id component from a workspace-scoped source path.
fn workspace_id_for_source(source: &FsPath, store_root: &FsPath) -> Option<String> {
let rel = source.strip_prefix(store_root).ok()?;
let mut comps = rel.components();
if comps.next()?.as_os_str().to_str()? != "workspace" {
return None;
}
Some(comps.next()?.as_os_str().to_str()?.to_string())
}
/// Convert a `MemoryHit` into a redacted, bounded `MemoryEntryRecord`.
fn memory_hit_to_record(
hit: crate::native_memory::MemoryHit,
store_root: &FsPath,
) -> MemoryEntryRecord {
let scope = scope_label_for_source(&hit.source, store_root);
let workspace_id = workspace_id_for_source(&hit.source, store_root);
let summary = truncate_text(&hit.text, MEMORY_SUMMARY_MAX_CHARS);
let status = if hit.stale { "stale" } else { "active" };
MemoryEntryRecord {
id: hit.id,
scope,
workspace_id,
summary,
stale: hit.stale,
line_start: hit.line_start,
line_end: hit.line_end,
status,
}
}
/// Resolve a scope query parameter into a `MemoryScope` filter and an
/// optional workspace_id. `"all"` / absent → `(None, None)`.
fn resolve_memory_scope(
scope_param: &Option<String>,
workspace: &FsPath,
) -> Result<(Option<crate::native_memory::MemoryScope>, Option<String>), ApiError> {
match scope_param.as_deref().unwrap_or("all").trim() {
"all" | "" => Ok((None, None)),
"global" => Ok((Some(crate::native_memory::MemoryScope::Global), None)),
"workspace" => {
let workspace_id = crate::native_memory::NativeMemoryStore::workspace_id(workspace)
.map_err(|e| ApiError::internal(format!("resolve workspace id: {e}")))?;
Ok((
Some(crate::native_memory::MemoryScope::Workspace),
workspace_id,
))
}
other => Err(ApiError::bad_request(format!(
"Invalid scope '{other}': expected one of all, global, workspace"
))),
}
}
/// `GET /v1/memory` — list memory entries with optional scope and FTS
/// filtering.
///
/// Query params:
/// - `scope` — `"global"`, `"workspace"`, or `"all"` (default)
/// - `q` — FTS search query (max 256 chars; omit to list all)
/// - `limit` — max results (default 50, max 200)
async fn list_memory(
State(state): State<RuntimeApiState>,
Query(query): Query<ListMemoryQuery>,
) -> Result<Json<Value>, ApiError> {
let limit = match query.limit.unwrap_or(MEMORY_LIST_DEFAULT_LIMIT) {
0 => {
return Err(ApiError::bad_request("limit must be at least 1"));
}
n if n > MEMORY_LIST_MAX_LIMIT => {
return Err(ApiError::bad_request(format!(
"limit must be at most {MEMORY_LIST_MAX_LIMIT}; got {n}"
)));
}
n => n,
};
let store = native_store_for_state(&state);
let root = store.root().to_path_buf();
let (scope_filter, workspace_id) = resolve_memory_scope(&query.scope, &state.workspace)?;
let hits = if let Some(ref q) = query.q {
let q = q.trim();
if q.is_empty() || q.chars().count() > 256 {
return Err(ApiError::bad_request("q must be 1256 characters"));
}
match scope_filter {
None => store.search(q, limit),
Some(crate::native_memory::MemoryScope::Global) => store.search(q, limit).map(|h| {
h.into_iter()
.filter(|h| scope_label_for_source(&h.source, &root) == "global")
.collect()
}),
Some(crate::native_memory::MemoryScope::Workspace) => store
.search_for_workspace(&state.workspace, q, limit)
.map(|h| {
h.into_iter()
.filter(|h| scope_label_for_source(&h.source, &root) == "workspace")
.collect()
}),
}
} else {
store.list_all(scope_filter, workspace_id.as_deref(), limit)
}
.map_err(|e| ApiError::internal(format!("memory list error: {e}")))?;
let entries: Vec<MemoryEntryRecord> = hits
.into_iter()
.map(|h| memory_hit_to_record(h, &root))
.collect();
let total = entries.len();
Ok(Json(json!({ "entries": entries, "total": total })))
}
/// `GET /v1/memory/{id}` — inspect a single memory entry.
///
/// The lookup is scoped to global memory plus the current repository's
/// workspace memory; numeric IDs from a different machine or repository
/// will not resolve.
async fn get_memory_entry(
State(state): State<RuntimeApiState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, ApiError> {
let store = native_store_for_state(&state);
let root = store.root().to_path_buf();
let hit = store
.get_for_workspace(&state.workspace, id)
.map_err(|e| ApiError::internal(format!("memory lookup error: {e}")))?
.ok_or_else(|| ApiError::not_found(format!("memory entry '{id}' not found")))?;
let entry = memory_hit_to_record(hit, &root);
Ok(Json(json!({ "entry": entry })))
}
/// `POST /v1/memory` — append a new memory entry.
///
/// The note is treated as user data (lower authority than instructions).
/// Requires the standard Runtime auth token when auth is configured.
async fn create_memory_entry(
State(state): State<RuntimeApiState>,
Json(req): Json<CreateMemoryRequest>,
) -> Result<(StatusCode, Json<Value>), ApiError> {
let scope_str = if req.scope.is_empty() {
"global"
} else {
req.scope.as_str()
};
let scope = match scope_str.trim() {
"global" => crate::native_memory::MemoryScope::Global,
"workspace" => crate::native_memory::MemoryScope::Workspace,
other => {
return Err(ApiError::bad_request(format!(
"Invalid scope '{other}': expected 'global' or 'workspace'"
)));
}
};
let workspace_id = if scope == crate::native_memory::MemoryScope::Workspace {
let id = crate::native_memory::NativeMemoryStore::workspace_id(&state.workspace)
.map_err(|e| ApiError::internal(format!("resolve workspace id: {e}")))?
.ok_or_else(|| {
ApiError::bad_request(
"workspace scope requires a git repository with a remote origin",
)
})?;
Some(id)
} else {
None
};
let store = native_store_for_state(&state);
let root = store.root().to_path_buf();
let hit = store
.remember(scope, workspace_id.as_deref(), &req.text)
.map_err(|e| ApiError::bad_request(format!("memory create error: {e}")))?;
let entry = memory_hit_to_record(hit, &root);
Ok((StatusCode::CREATED, Json(json!({ "entry": entry }))))
}
/// `DELETE /v1/memory` — clear all memory entries for the given scope.
///
/// The `scope` query parameter is required: `"global"`, `"workspace"`, or
/// `"all"`. This is a destructive, non-reversible operation.
async fn clear_memory(
State(state): State<RuntimeApiState>,
Query(query): Query<ClearMemoryQuery>,
) -> Result<Json<Value>, ApiError> {
let (scope_filter, workspace_id) = resolve_memory_scope(&Some(query.scope), &state.workspace)?;
let store = native_store_for_state(&state);
store
.delete_all(scope_filter, workspace_id.as_deref())
.map_err(|e| ApiError::internal(format!("memory clear error: {e}")))?;
Ok(Json(json!({ "cleared": true })))
}
const MOBILE_HTML: &str = include_str!("runtime_mobile.html");
/// Built-in dev origins always allowed by the runtime API (whalescale#255).
+321
View File
@@ -7431,6 +7431,10 @@ async fn cors_layer_advertises_exact_supported_headers_and_never_an_extra() -> R
#[tokio::test]
async fn thread_goal_crud_and_invalid_transition() -> Result<()> {
// ── Memory API tests ──
#[tokio::test]
async fn memory_info_capability_is_advertised() -> Result<()> {
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
return Ok(());
};
@@ -7888,6 +7892,7 @@ async fn runtime_info_advertises_thread_goals_capability() -> Result<()> {
};
let client = crate::tls::reqwest_client();
>>>>>>> 3130b49a6 (feat: expose bounded memory inspection and lifecycle controls via Runtime API)
let info: serde_json::Value = client
.get(format!("http://{addr}/v1/runtime/info"))
.send()
@@ -7896,6 +7901,7 @@ async fn runtime_info_advertises_thread_goals_capability() -> Result<()> {
.json()
.await?;
assert_eq!(
<<<<<<< HEAD
info["capabilities"]["thread_goals"].as_bool(),
Some(true),
"runtime info must advertise thread_goals capability"
@@ -7918,6 +7924,321 @@ async fn runtime_info_advertises_thread_goals_capability() -> Result<()> {
.send()
.await?;
assert_eq!(missing.status(), 404);
=======
info["capabilities"]["memory"], true,
"memory capability must be advertised in runtime/info"
);
handle.abort();
Ok(())
}
#[tokio::test]
async fn memory_list_returns_empty_for_fresh_store() -> Result<()> {
let root = std::env::temp_dir().join(format!("cw-memory-list-{}", Uuid::new_v4()));
let sessions_dir = root.join("sessions");
let _lock = lock_test_env();
let home = root.join("home");
fs::create_dir_all(&home)?;
let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &home);
let Some((addr, _rt, handle)) = spawn_test_server_with_root(root, sessions_dir).await? else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let body: serde_json::Value = client
.get(format!("http://{addr}/v1/memory"))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(body["entries"].as_array().map(Vec::len), Some(0));
assert_eq!(body["total"], 0);
// scope=global should also be empty.
let global: serde_json::Value = client
.get(format!("http://{addr}/v1/memory?scope=global"))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(global["total"], 0);
// Invalid scope returns 400.
let bad = client
.get(format!("http://{addr}/v1/memory?scope=invalid"))
.send()
.await?;
assert_eq!(bad.status(), StatusCode::BAD_REQUEST);
// limit=0 returns 400.
let bad_limit = client
.get(format!("http://{addr}/v1/memory?limit=0"))
.send()
.await?;
assert_eq!(bad_limit.status(), StatusCode::BAD_REQUEST);
// limit above max returns 400.
let over_limit = client
.get(format!("http://{addr}/v1/memory?limit=201"))
.send()
.await?;
assert_eq!(over_limit.status(), StatusCode::BAD_REQUEST);
handle.abort();
Ok(())
}
#[tokio::test]
async fn memory_create_list_and_get_entry() -> Result<()> {
let root = std::env::temp_dir().join(format!("cw-memory-create-{}", Uuid::new_v4()));
let sessions_dir = root.join("sessions");
let _lock = lock_test_env();
let home = root.join("home");
fs::create_dir_all(&home)?;
let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &home);
let Some((addr, _rt, handle)) = spawn_test_server_with_root(root.clone(), sessions_dir).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
// Create a global memory entry.
let create_resp = client
.post(format!("http://{addr}/v1/memory"))
.json(&json!({ "text": "prefer snake_case for identifiers", "scope": "global" }))
.send()
.await?;
assert_eq!(create_resp.status(), StatusCode::CREATED);
let created: serde_json::Value = create_resp.json().await?;
assert_eq!(created["entry"]["scope"], "global");
assert_eq!(created["entry"]["status"], "active");
assert!(created["entry"]["id"].is_number());
assert!(
created["entry"]["summary"]
.as_str()
.unwrap_or("")
.contains("snake_case"),
"summary must include the note text"
);
let entry_id = created["entry"]["id"].as_i64().unwrap();
// GET /v1/memory lists the entry.
let list: serde_json::Value = client
.get(format!("http://{addr}/v1/memory?scope=global"))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(list["total"], 1);
assert_eq!(list["entries"][0]["id"], entry_id);
assert_eq!(list["entries"][0]["scope"], "global");
// workspace_id must be absent for global entries.
assert!(list["entries"][0]["workspace_id"].is_null());
// GET /v1/memory/{id} returns the entry.
let single: serde_json::Value = client
.get(format!("http://{addr}/v1/memory/{entry_id}"))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(single["entry"]["id"], entry_id);
assert_eq!(single["entry"]["scope"], "global");
assert!(single["entry"]["stale"].is_boolean());
// GET /v1/memory/{id} for a missing id returns 404.
let missing = client
.get(format!("http://{addr}/v1/memory/999999"))
.send()
.await?;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
handle.abort();
Ok(())
}
#[tokio::test]
async fn memory_summary_is_redacted_to_max_chars() -> Result<()> {
let root = std::env::temp_dir().join(format!("cw-memory-redact-{}", Uuid::new_v4()));
let sessions_dir = root.join("sessions");
let _lock = lock_test_env();
let home = root.join("home");
fs::create_dir_all(&home)?;
let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &home);
let Some((addr, _rt, handle)) = spawn_test_server_with_root(root.clone(), sessions_dir).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let long_note = "x".repeat(350);
let create_resp = client
.post(format!("http://{addr}/v1/memory"))
.json(&json!({ "text": long_note, "scope": "global" }))
.send()
.await?;
assert_eq!(create_resp.status(), StatusCode::CREATED);
let created: serde_json::Value = create_resp.json().await?;
let summary = created["entry"]["summary"].as_str().unwrap_or("");
// Summary must be bounded: at most 300 chars + 3 for the "…" suffix.
assert!(
summary.chars().count() <= 303,
"summary must be bounded; got {} chars",
summary.chars().count()
);
assert!(
summary.ends_with("") || summary.chars().count() <= 300,
"overlong text must be truncated with an ellipsis"
);
handle.abort();
Ok(())
}
#[tokio::test]
async fn memory_clear_removes_global_scope() -> Result<()> {
let root = std::env::temp_dir().join(format!("cw-memory-clear-{}", Uuid::new_v4()));
let sessions_dir = root.join("sessions");
let _lock = lock_test_env();
let home = root.join("home");
fs::create_dir_all(&home)?;
let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &home);
let Some((addr, _rt, handle)) = spawn_test_server_with_root(root.clone(), sessions_dir).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
// Seed two entries.
for note in ["first note", "second note"] {
client
.post(format!("http://{addr}/v1/memory"))
.json(&json!({ "text": note, "scope": "global" }))
.send()
.await?
.error_for_status()?;
}
let before: serde_json::Value = client
.get(format!("http://{addr}/v1/memory?scope=global"))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(
before["total"], 2,
"seed entries must be present before clear"
);
// Clear global scope.
let clear: serde_json::Value = client
.delete(format!("http://{addr}/v1/memory?scope=global"))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(clear["cleared"], true);
let after: serde_json::Value = client
.get(format!("http://{addr}/v1/memory?scope=global"))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(after["total"], 0, "global scope must be empty after clear");
handle.abort();
Ok(())
}
#[tokio::test]
async fn memory_create_rejects_empty_text_and_bad_scope() -> Result<()> {
let root = std::env::temp_dir().join(format!("cw-memory-invalid-{}", Uuid::new_v4()));
let sessions_dir = root.join("sessions");
let _lock = lock_test_env();
let home = root.join("home");
fs::create_dir_all(&home)?;
let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &home);
let Some((addr, _rt, handle)) = spawn_test_server_with_root(root.clone(), sessions_dir).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
// Empty text must be rejected with 400.
let empty = client
.post(format!("http://{addr}/v1/memory"))
.json(&json!({ "text": "", "scope": "global" }))
.send()
.await?;
assert_eq!(empty.status(), StatusCode::BAD_REQUEST);
// An unknown scope must be rejected with 400.
let bad_scope = client
.post(format!("http://{addr}/v1/memory"))
.json(&json!({ "text": "valid note", "scope": "thread" }))
.send()
.await?;
assert_eq!(bad_scope.status(), StatusCode::BAD_REQUEST);
handle.abort();
Ok(())
}
#[tokio::test]
async fn memory_search_query_filters_results() -> Result<()> {
let root = std::env::temp_dir().join(format!("cw-memory-search-{}", Uuid::new_v4()));
let sessions_dir = root.join("sessions");
let _lock = lock_test_env();
let home = root.join("home");
fs::create_dir_all(&home)?;
let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &home);
let Some((addr, _rt, handle)) = spawn_test_server_with_root(root.clone(), sessions_dir).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
// Seed two entries with distinct text.
for note in ["prefer functional style", "always use snake_case"] {
client
.post(format!("http://{addr}/v1/memory"))
.json(&json!({ "text": note, "scope": "global" }))
.send()
.await?
.error_for_status()?;
}
// Searching for "functional" returns only the matching entry.
let resp: serde_json::Value = client
.get(format!("http://{addr}/v1/memory?q=functional&scope=global"))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(resp["total"], 1);
let summary = resp["entries"][0]["summary"].as_str().unwrap_or("");
assert!(
summary.contains("functional"),
"search must return the matching entry"
);
// An empty q must be rejected with 400.
let empty_q = client
.get(format!("http://{addr}/v1/memory?q="))
.send()
.await?;
assert_eq!(empty_q.status(), StatusCode::BAD_REQUEST);
handle.abort();
Ok(())
}