From 82d1b60891f5c6a969c366d79fa115418ae56743 Mon Sep 17 00:00:00 2001
From: archipelago
Date: Thu, 6 Aug 2026 13:24:41 -0400
Subject: [PATCH] fix(13-10/13-11): replay chat history to the model; unbreak
general answers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Four defects found by on-device UAT, 2026-08-06.
1. D-08 persistence was WRITE-ONLY. chat() loaded the transcript only
AFTER the loop, to append — the model was never shown any of it. The
assistant answered "I don't have access to any previous conversation
history" with its own transcript on disk, and "and is it healthy?"
resolved to the node instead of the app just discussed. History now
replays into every turn (text only: a stale tool result must not be
re-presented as this turn's evidence), scoped by HistoryKey. The
replayed prefix is excluded from the append, or each turn would
re-persist the conversation and grow it geometrically.
2. The operator persona forbade the very answers the content surfaces
render. 13-01's prompt refuses anything without a matching tool, so
"recommend me 10 sci-fi films" was declined and the film/song/podcast
grids from 13-11 could never populate — two plans in contradiction.
The refusal rule now governs ACTIONS ON THE NODE; general questions
and recommendations are answered from the model's own knowledge.
(Whether the node should also SEARCH THE WEB depends on AIUI's
web-search setting, which embedded mode never forwards — captured as
a separate todo because it opens a new egress path.)
3. The content-surface loader labelled unrelated queries "Podcast
recommendations": the classifier matched a bare "show", which is how
operators phrase almost everything ("show me my files").
4. "Surfacing…" tracks at 0.2em and its final glyph collided with the
close button; the header now spaces them properly.
assistant::history 9/9 green incl. replay_feeds_prior_turns_back_to_the_model.
Co-Authored-By: Claude Fable 5
---
.../src/components/content/ContextLoader.vue | 7 +-
aiui/packages/app/src/pages/ChatPage.vue | 5 +-
core/archipelago/src/assistant/history.rs | 96 +++++++++++++++++++
core/archipelago/src/assistant/mod.rs | 31 +++++-
4 files changed, 132 insertions(+), 7 deletions(-)
diff --git a/aiui/packages/app/src/components/content/ContextLoader.vue b/aiui/packages/app/src/components/content/ContextLoader.vue
index f4cdf5f8..6f90738a 100644
--- a/aiui/packages/app/src/components/content/ContextLoader.vue
+++ b/aiui/packages/app/src/components/content/ContextLoader.vue
@@ -14,8 +14,11 @@
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ contextLabel }}
-
-
+
+
Surfacing…
diff --git a/aiui/packages/app/src/pages/ChatPage.vue b/aiui/packages/app/src/pages/ChatPage.vue
index ece3d8eb..026c7d39 100644
--- a/aiui/packages/app/src/pages/ChatPage.vue
+++ b/aiui/packages/app/src/pages/ChatPage.vue
@@ -646,7 +646,10 @@ const loaderContextType = computed(() => {
if (/\b(tv show|tv series|series|television|binge|season)\b/.test(q)) return 'tvshow'
if (/\b(image|images|photo|photos|picture|pictures|screenshot|gallery|artwork|illustration)\b/.test(q)) return 'image'
if (/\b(restaurant|restaurants|place|places|food|eat|dining|cafe|bar|pub|brunch|lunch|dinner)\b/.test(q)) return 'film'
- if (/\b(podcast|episode|show|listen to)\b/.test(q)) return 'podcast'
+ // NOT a bare `show`: "show me my files" / "show the logs" is the most
+ // common phrasing in an operator chat, and it was labelling every such
+ // query "Podcast recommendations" (reported on-device 2026-08-06).
+ if (/\b(podcast|episode|listen to)\b/.test(q)) return 'podcast'
if (/\b(news|latest|recent|current|what'?s happening|what are people saying)\b/.test(q)) return 'news'
if (/\b(bip|protocol|debate|sentiment|bearish|bull case|macro)\b/.test(q)) return 'magazine'
if (/\b(website|websites|where to check|best places|check online|resources?|sources?)\b/.test(q)) return 'websites'
diff --git a/core/archipelago/src/assistant/history.rs b/core/archipelago/src/assistant/history.rs
index 12e6c5d5..349a0d52 100644
--- a/core/archipelago/src/assistant/history.rs
+++ b/core/archipelago/src/assistant/history.rs
@@ -352,6 +352,51 @@ fn project(
/// (preferring the already-selected local backend) as the eventual
/// implementation; this plan's scope is the compaction MECHANISM
/// (fold-not-truncate, extend-incrementally-not-regenerate-from-scratch),
+/// Replay a persisted transcript as the model-facing prefix for a new
+/// turn: the running summary (if any) as a System note, then the verbatim
+/// recent turns. Text-only — tool calls and their results are deliberately
+/// NOT replayed: a stale tool result is a claim about the node's state at
+/// some earlier moment, and re-presenting it as if it were this turn's
+/// evidence is how an assistant ends up asserting that a container is
+/// running because it was running ten minutes ago.
+///
+/// Without this, D-08's persistence is write-only: 13-10 stored every turn
+/// and never showed the model any of it, so the assistant answered "I
+/// don't have access to any previous conversation history" while its own
+/// transcript sat on disk (found on-device 2026-08-06).
+pub fn replay(hist: &History) -> Vec
{
+ let mut out = Vec::with_capacity(hist.turns.len() + 1);
+ if !hist.summary.trim().is_empty() {
+ out.push(ChatMessage {
+ role: Role::System,
+ text: Some(format!(
+ "Earlier in this conversation: {}",
+ hist.summary.trim()
+ )),
+ tool_calls: vec![],
+ tool_results: vec![],
+ });
+ }
+ for turn in &hist.turns {
+ let role = match turn.role {
+ PersistedRole::User => Role::User,
+ PersistedRole::Assistant => Role::Assistant,
+ // Tool traffic and prior system notes are not replayed.
+ PersistedRole::Tool | PersistedRole::System => continue,
+ };
+ let Some(text) = turn.text.as_ref().filter(|t| !t.trim().is_empty()) else {
+ continue;
+ };
+ out.push(ChatMessage {
+ role,
+ text: Some(text.clone()),
+ tool_calls: vec![],
+ tool_results: vec![],
+ });
+ }
+ out
+}
+
/// proven here with a plain digest rather than a model call — see the
/// plan's SUMMARY for why a model-backed summarizer is left as a named
/// follow-up rather than implemented in this pass.
@@ -394,6 +439,57 @@ mod tests {
}
}
+ /// D-08 regression (on-device, 2026-08-06): the transcript must be
+ /// REPLAYED to the model, not merely stored. Persistence was
+ /// write-only — every turn was saved and none was ever shown, so the
+ /// assistant answered "I don't have access to any previous
+ /// conversation history" with its own transcript sitting on disk.
+ #[tokio::test]
+ async fn replay_feeds_prior_turns_back_to_the_model() {
+ let tmp = tempfile::tempdir().expect("tempdir");
+ let key = HistoryKey::from_caller(&CallerScope::LocalOperator {
+ session_id: "op-1".to_string(),
+ });
+ let categories = HashMap::new();
+ let mut hist = History::load(tmp.path(), &key).await;
+
+ hist.append(
+ tmp.path(),
+ &key,
+ &[
+ user_msg("is filebrowser running?"),
+ ChatMessage {
+ role: Role::Assistant,
+ text: Some("Yes, filebrowser is running.".to_string()),
+ tool_calls: vec![],
+ tool_results: vec![],
+ },
+ ],
+ &categories,
+ )
+ .await
+ .expect("append");
+
+ let replayed = replay(&History::load(tmp.path(), &key).await);
+ let texts: Vec<&str> = replayed.iter().filter_map(|m| m.text.as_deref()).collect();
+ assert!(
+ texts.iter().any(|t| t.contains("is filebrowser running?")),
+ "the user's own prior turn must be replayed: {texts:?}"
+ );
+ assert!(
+ texts.iter().any(|t| t.contains("Yes, filebrowser is running.")),
+ "the assistant's prior answer must be replayed: {texts:?}"
+ );
+ // Tool traffic is never replayed: a stale tool result is a claim
+ // about the node's state at an earlier moment.
+ assert!(
+ replayed
+ .iter()
+ .all(|m| m.tool_calls.is_empty() && m.tool_results.is_empty()),
+ "replay must be text-only"
+ );
+ }
+
/// Behavior: a completed turn is appended to a transcript stored under
/// `data_dir` and survives a daemon restart (simulated by dropping the
/// in-memory `History` and reloading from disk).
diff --git a/core/archipelago/src/assistant/mod.rs b/core/archipelago/src/assistant/mod.rs
index f1d722c2..96d3146c 100644
--- a/core/archipelago/src/assistant/mod.rs
+++ b/core/archipelago/src/assistant/mod.rs
@@ -689,7 +689,14 @@ than redundant: it stalls the action behind a reply you cannot act on, and it tr
operator to rubber-stamp. If the user asks for something outside the tools listed below \
(including anything touching keys, seeds, wallet spends, federation trust, or a factory reset), \
refuse plainly and, if there is a real path in neode-ui's Settings screen for it, name that path \
-instead of fabricating a tool call.";
+instead of fabricating a tool call.\n\n\
+That refusal rule is about ACTIONS ON THIS NODE, not about conversation. You are also the \
+owner's general assistant: answer ordinary questions, explain things, and give recommendations \
+(films, music, podcasts, books, reading) from your own knowledge, exactly as a capable \
+assistant would. Those answers are what the content surfaces in this app render as cards, so \
+declining to answer them leaves the owner staring at an empty panel. Only a request to CHANGE \
+or READ something on the node needs a tool — and if no tool covers it, say so plainly rather \
+than pretending. Never let a general question be refused merely because no tool matches it.";
pub fn build_system_prompt(visible_tools: &[tools::ToolDef]) -> String {
let mut prompt = String::from(SYSTEM_PROMPT_PREAMBLE);
@@ -741,12 +748,25 @@ pub async fn chat(
let key = history::HistoryKey::from_caller(&caller);
- let turn_history = vec![tools::ChatMessage {
+ // D-08: replay this caller's own transcript so the model can resolve
+ // "it"/"that one" and answer "what were we just talking about?".
+ // Loading only AFTER the loop (to append) made persistence write-only:
+ // every turn was stored and none was ever shown, so the assistant
+ // denied having any history while its transcript sat on disk. Scoped
+ // by HistoryKey, so one caller never sees another's turns.
+ let prior = history::History::load(handler.data_dir(), &key).await;
+ let mut turn_history = history::replay(&prior);
+ // Everything before this index is ALREADY persisted — `run_loop`
+ // returns the whole conversation it was given plus this turn's new
+ // messages, so persisting it wholesale would re-append the replayed
+ // prefix and grow the transcript geometrically.
+ let already_persisted = turn_history.len();
+ turn_history.push(tools::ChatMessage {
role: tools::Role::User,
text: Some(user_text),
tool_calls: vec![],
tool_results: vec![],
- }];
+ });
let ctx = ToolExecCtx::new(registry, caller, handler.clone());
@@ -770,8 +790,11 @@ pub async fn chat(
.map(|t| (t.name.to_string(), t.category))
.collect();
let mut hist = history::History::load(handler.data_dir(), &key).await;
+ let new_messages = turn_messages
+ .get(already_persisted..)
+ .unwrap_or(&turn_messages);
if let Err(e) = hist
- .append(handler.data_dir(), &key, &turn_messages, &tool_categories)
+ .append(handler.data_dir(), &key, new_messages, &tool_categories)
.await
{
tracing::warn!(