fix(honcho): read Honcho 3.0.x response schema — memory reads were silently empty
Nightly Build / build (push) Successful in 3m48s
Nightly Build / build (push) Successful in 3m48s
Against a self-hosted Honcho 3.0.11 every read path came back empty while the server was healthy and full of derived facts: the plugin parsed a `conclusions`/`summary` shape the API no longer emits. - honcho-client: typed models for the real schema — `PeerContext` (`representation` markdown + `peer_card`), `SessionContext` (`summary` as an object, `peer_representation`), wrapped `PeerCard` (a bare array on PUT is a 422, which also broke `honcho_profile` writes). - plugin: the turn-time injection and `honcho_context` read the representation; `honcho_search` and the page's /search now use `conclusions/query` (observer/observed scoping inside `filters`) — a real ranked semantic search with fact ids, which `peer_context?search_query` never provided; /overview returns card + representation + conclusions. - compose: pin the Honcho image by digest (3.0.11) — ghcr publishes no v3 semver tags, and an untracked `:latest` pull is what drifted the schema. - tests: fixture tests from payloads captured on the live server, plus an env-gated live smoke test (HONCHO_E2E_URL/_WS/_PEER, `cargo test -p honcho-client -- --ignored`) — run it before any future Honcho bump.
This commit is contained in:
@@ -13,3 +13,6 @@ tracing = "0.1"
|
||||
# The crate-level doc example (`#[tokio::main]`) compiles under `cargo test`.
|
||||
tokio = { version = "1", features = ["macros", "rt"] }
|
||||
anyhow = "1"
|
||||
# The live smoke test builds a reqwest client without the host app around, so
|
||||
# it must install the rustls crypto provider itself (the app does it in main).
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
|
||||
@@ -171,3 +171,76 @@ fn urlencoding(s: &str) -> String {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Live smoke test against a real Honcho server — the regression net for
|
||||
//! the schema drift that made every read return empty against 3.0.11.
|
||||
//! Gated on env vars, run explicitly:
|
||||
//!
|
||||
//! ```sh
|
||||
//! HONCHO_E2E_URL=http://host:8000 HONCHO_E2E_WS=<workspace> HONCHO_E2E_PEER=<peer> \
|
||||
//! cargo test -p honcho-client -- --ignored --nocapture
|
||||
//! ```
|
||||
use super::HonchoClient;
|
||||
use crate::models::*;
|
||||
|
||||
fn live() -> Option<(HonchoClient, String, String)> {
|
||||
let url = std::env::var("HONCHO_E2E_URL").ok()?;
|
||||
let ws = std::env::var("HONCHO_E2E_WS").ok()?;
|
||||
let peer = std::env::var("HONCHO_E2E_PEER").ok()?;
|
||||
Some((HonchoClient::with_base_url(url, ""), ws, peer))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a live Honcho: set HONCHO_E2E_URL/_WS/_PEER"]
|
||||
async fn live_read_path_smoke() {
|
||||
// Standalone test process: no host app installed a crypto provider.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let Some((client, ws, peer)) = live() else {
|
||||
eprintln!("env vars not set — skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
// peer_context: the representation string is where the facts live.
|
||||
let ctx = client
|
||||
.peer_context(&ws, &peer, &PeerRepresentationGet::default())
|
||||
.await
|
||||
.expect("peer_context");
|
||||
println!("representation: {:?}", ctx.representation.as_deref().map(|r| &r[..r.len().min(120)]));
|
||||
println!("peer_card: {:?}", ctx.peer_card);
|
||||
|
||||
// card endpoint: wrapped, nullable.
|
||||
let card = client.get_peer_card(&ws, &peer, None).await.expect("get_peer_card");
|
||||
println!("card endpoint: {:?}", card.peer_card);
|
||||
|
||||
// semantic search over conclusions, scoped via filters.
|
||||
let hits = client
|
||||
.query_conclusions(&ws, &ConclusionQuery {
|
||||
query: "test".into(),
|
||||
top_k: Some(5),
|
||||
distance: None,
|
||||
filters: Some(serde_json::json!({ "observer_id": peer, "observed_id": peer })),
|
||||
})
|
||||
.await
|
||||
.expect("query_conclusions");
|
||||
println!("conclusions hits: {}", hits.len());
|
||||
for c in &hits {
|
||||
assert!(!c.id.is_empty() && !c.content.is_empty());
|
||||
}
|
||||
|
||||
// conclusions list with the same scoping (what /overview shows).
|
||||
let page = client
|
||||
.list_conclusions(
|
||||
&ws,
|
||||
&PageParams { size: Some(50), ..Default::default() },
|
||||
&ConclusionGet {
|
||||
filters: Some(serde_json::json!({ "observer_id": peer, "observed_id": peer })),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("list_conclusions");
|
||||
println!("conclusions total: {}", page.total);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,13 +185,16 @@ pub struct MessageUpdate {
|
||||
// Conclusion
|
||||
// ──────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Conclusion {
|
||||
pub id: String,
|
||||
pub content: String,
|
||||
pub observer_id: String,
|
||||
pub observed_id: String,
|
||||
pub session_id: Option<String>,
|
||||
/// explicit | deductive | inductive | contradiction
|
||||
#[serde(default)]
|
||||
pub level: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
@@ -270,6 +273,66 @@ pub struct PeerRepresentationGet {
|
||||
pub max_conclusions: Option<u32>,
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// Context / card responses (Honcho v3.0.x — verified against 3.0.11)
|
||||
//
|
||||
// These endpoints do NOT return a `conclusions` array: the derived facts are
|
||||
// delivered as a single pre-rendered markdown `representation` string, and the
|
||||
// curated card is a plain list of fact strings wrapped in an object. Parse
|
||||
// these typed shapes — reading `conclusions`/`summary` off them yields nothing.
|
||||
// ──────────────────────────────────────────
|
||||
|
||||
/// `GET /workspaces/{ws}/peers/{peer}/context`
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct PeerContext {
|
||||
pub peer_id: String,
|
||||
#[serde(default)]
|
||||
pub target_id: Option<String>,
|
||||
/// Curated subset of the target peer's representation, as seen by the
|
||||
/// observer — a pre-rendered markdown document (e.g. an
|
||||
/// `## Explicit Observations` section with one dated line per fact).
|
||||
#[serde(default)]
|
||||
pub representation: Option<String>,
|
||||
#[serde(default)]
|
||||
pub peer_card: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// `GET /workspaces/{ws}/sessions/{session}/context`
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SessionContext {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub messages: Vec<Message>,
|
||||
#[serde(default)]
|
||||
pub summary: Option<Summary>,
|
||||
/// Representation of the session's peer, when a perspective is available.
|
||||
#[serde(default)]
|
||||
pub peer_representation: Option<String>,
|
||||
#[serde(default)]
|
||||
pub peer_card: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Nested in [`SessionContext`]. Only `content` is consumed; the remaining
|
||||
/// fields (`message_id`, `summary_type`, `created_at`) are ignored.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Summary {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// `GET /workspaces/{ws}/peers/{peer}/card` — note the wrapper object: the
|
||||
/// card never travels as a bare array.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PeerCard {
|
||||
pub peer_card: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Body of `PUT /workspaces/{ws}/peers/{peer}/card` — same wrapper: sending a
|
||||
/// bare array is a 422.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PeerCardSet {
|
||||
pub peer_card: Vec<String>,
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// Search
|
||||
// ──────────────────────────────────────────
|
||||
@@ -306,3 +369,82 @@ pub struct PageParams {
|
||||
pub size: Option<u64>,
|
||||
pub reverse: Option<bool>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Real `GET /peers/{id}/context` payload captured from a self-hosted
|
||||
/// Honcho 3.0.11. Guards the schema the plugin parses: facts live in the
|
||||
/// `representation` markdown string, the card is `peer_card`, and there is
|
||||
/// NO `conclusions` array (the bug this test would have caught).
|
||||
const PEER_CONTEXT_3_0_11: &str = r###"{
|
||||
"peer_id": "506cd15e-ae2a-47f1-9553-85ffe33e3e5b",
|
||||
"target_id": "506cd15e-ae2a-47f1-9553-85ffe33e3e5b",
|
||||
"representation": "## Explicit Observations\n\n[2026-09-09 16:55:39] Daniele ha una passione per i vulcani e ha dormito al bordo del cratere del Nyiragongo in RD Congo nel 2015.\n[2026-09-09 16:57:12] 506cd15e-ae2a-47f1-9553-85ffe33e3e5b speaks Italian (greeted with \"Ciao\")\n",
|
||||
"peer_card": null
|
||||
}"###;
|
||||
|
||||
#[test]
|
||||
fn peer_context_parses_representation_and_card() {
|
||||
let ctx: PeerContext = serde_json::from_str(PEER_CONTEXT_3_0_11).unwrap();
|
||||
assert_eq!(ctx.peer_id, "506cd15e-ae2a-47f1-9553-85ffe33e3e5b");
|
||||
let rep = ctx.representation.unwrap();
|
||||
assert!(rep.contains("Nyiragongo"));
|
||||
assert!(ctx.peer_card.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_context_tolerates_empty_peer() {
|
||||
let ctx: PeerContext = serde_json::from_str(
|
||||
r#"{"peer_id":"p","target_id":"p","representation":null,"peer_card":null}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(ctx.representation.is_none());
|
||||
assert!(ctx.peer_card.is_none());
|
||||
}
|
||||
|
||||
/// Real `GET /sessions/{id}/context` payload: `summary` is an OBJECT
|
||||
/// (not a string) and the session-scoped facts are in
|
||||
/// `peer_representation`.
|
||||
#[test]
|
||||
fn session_context_summary_is_an_object() {
|
||||
let ctx: SessionContext = serde_json::from_str(
|
||||
r###"{
|
||||
"id": "skaldcircle-u-1",
|
||||
"messages": [],
|
||||
"summary": {"content": "They talked about commuting.", "message_id": "m1", "summary_type": "short", "created_at": "2026-09-09T16:00:00Z"},
|
||||
"peer_representation": "## Explicit Observations\n\n[2026-09-09] fact",
|
||||
"peer_card": ["likes volcanoes"]
|
||||
}"###,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ctx.summary.unwrap().content, "They talked about commuting.");
|
||||
assert!(ctx.peer_representation.unwrap().contains("fact"));
|
||||
assert_eq!(ctx.peer_card.unwrap(), vec!["likes volcanoes".to_string()]);
|
||||
}
|
||||
|
||||
/// Real `GET /peers/{id}/card` payload: the card is wrapped in an object
|
||||
/// and null until curated.
|
||||
#[test]
|
||||
fn peer_card_is_wrapped_and_nullable() {
|
||||
let card: PeerCard = serde_json::from_str(r#"{"peer_card":null}"#).unwrap();
|
||||
assert!(card.peer_card.is_none());
|
||||
let card: PeerCard =
|
||||
serde_json::from_str(r#"{"peer_card":["a","b"]}"#).unwrap();
|
||||
assert_eq!(card.peer_card.unwrap(), vec!["a".to_string(), "b".to_string()]);
|
||||
}
|
||||
|
||||
/// Real conclusion entries (from `conclusions/query`), including `level`.
|
||||
#[test]
|
||||
fn conclusion_tolerates_level_and_null_session() {
|
||||
let c: Conclusion = serde_json::from_str(
|
||||
r#"{"id":"uM0BSpIuDMNp9wL97c6M-","content":"Daniele ha una passione per i vulcani","observer_id":"p","observed_id":"p","session_id":null,"level":"explicit","created_at":"2026-09-09T16:55:39.863412Z"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(c.level.as_deref(), Some("explicit"));
|
||||
assert!(c.session_id.is_none());
|
||||
// Round-trip: the router serializes conclusions into its JSON responses.
|
||||
assert!(serde_json::to_string(&c).unwrap().contains("uM0BSpIuDMNp9wL97c6M-"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,13 +92,15 @@ impl HonchoClient {
|
||||
|
||||
// ── Context ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Get context for a peer (conclusions + card, ready for injection into prompts).
|
||||
/// Get context for a peer: the pre-rendered markdown `representation` of
|
||||
/// everything derived about them, plus their curated card. (No
|
||||
/// `conclusions` array exists in this response — see [`PeerContext`].)
|
||||
pub async fn peer_context(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
peer_id: &str,
|
||||
opts: &PeerRepresentationGet,
|
||||
) -> Result<serde_json::Value> {
|
||||
) -> Result<PeerContext> {
|
||||
let mut q: Vec<(&str, String)> = vec![];
|
||||
if let Some(ref v) = opts.target {
|
||||
q.push(("target", v.clone()));
|
||||
@@ -132,7 +134,7 @@ impl HonchoClient {
|
||||
workspace_id: &str,
|
||||
peer_id: &str,
|
||||
target: Option<&str>,
|
||||
) -> Result<serde_json::Value> {
|
||||
) -> Result<PeerCard> {
|
||||
let mut q: Vec<(&str, String)> = vec![];
|
||||
if let Some(t) = target {
|
||||
q.push(("target", t.to_owned()));
|
||||
@@ -144,13 +146,15 @@ impl HonchoClient {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Overwrite the peer card. The API wraps the list in an object
|
||||
/// (`{"peer_card": [...]}`) — a bare array is rejected with 422.
|
||||
pub async fn set_peer_card(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
peer_id: &str,
|
||||
target: Option<&str>,
|
||||
card: serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
card: Vec<String>,
|
||||
) -> Result<PeerCard> {
|
||||
let mut q: Vec<(&str, String)> = vec![];
|
||||
if let Some(t) = target {
|
||||
q.push(("target", t.to_owned()));
|
||||
@@ -158,7 +162,7 @@ impl HonchoClient {
|
||||
self.put_with_query(
|
||||
&format!("/v3/workspaces/{workspace_id}/peers/{peer_id}/card"),
|
||||
&q,
|
||||
&card,
|
||||
&PeerCardSet { peer_card: card },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -161,14 +161,17 @@ impl HonchoClient {
|
||||
|
||||
// ── Context / Summaries ───────────────────────────────────────────────
|
||||
|
||||
/// Retrieve context for a session (messages + peer conclusions, token-budgeted).
|
||||
/// Retrieve context for a session (messages + summary + the session peer's
|
||||
/// representation, token-budgeted). The summary is a `Summary` object and
|
||||
/// the facts live in `peer_representation` — there is no `conclusions`
|
||||
/// array (see [`SessionContext`]).
|
||||
pub async fn session_context(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
session_id: &str,
|
||||
tokens: Option<u32>,
|
||||
search_query: Option<&str>,
|
||||
) -> Result<serde_json::Value> {
|
||||
) -> Result<SessionContext> {
|
||||
let mut q: Vec<(&str, String)> = vec![];
|
||||
if let Some(t) = tokens {
|
||||
q.push(("tokens", t.to_string()));
|
||||
|
||||
Reference in New Issue
Block a user