Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,45 @@
consumer halves via `Arc<TtsEngine>`. The first-reply latency banner
(#19) now reports the true LLM-until-first-sentence figure instead
of the full-response figure.
- **Voice-cycle integration test + mockable surface** (#21). Extracts
`voice_loop::process_transcript` from `voice_cycle` so the
post-record orchestration (intent gate, speaker identity, memory
recall, quick-tool fast path, LLM streaming + TTS, tool dispatch,
conversation persistence, latency banner, memory extract) can be
driven end-to-end with mocks. Adds `LlmClient::mock(replies)` (new
`MockLlmBackend` implementation of the existing `LlmBackendClient`
trait, replays scripted replies on both `chat` and `chat_stream`,
optional `with_fallback("…")`), `SttEngine::mock(transcripts)` (new
`SttMode::Mock` variant with a `MockTranscript` queue;
`transcribe_file` pops the next scripted transcript), and
`TtsEngine::silent()` (new `TtsMode::Silent` variant; `speak`,
`synthesize`, `synthesize_to_file`, `start`, and `stop` all no-op).
`TtsEngine::snapshot()` returns a config-only copy so a borrowed
`&TtsEngine` can be wrapped in `Arc<TtsEngine>` for
`streaming::stream_and_speak`. New integration test at
`crates/genie-core/tests/voice_loop_integration.rs` drives
`process_transcript` with these mocks and asserts on transcript
flow, the conversation store, and the dispatcher's tool-audit
JSONL.
- **Parallel-safe SQLite test paths** (#21). The memory test helper
now gives every test a `${tmpdir}/geniepod-mem-${label}-${pid}-${id}-${nanos}/`
parent directory; the DB lives at `<dir>/memory.db` and
`Memory::open` derives `canonical_dir = <dir>/memory`, so the
markdown promotion pipeline (`MEMORY.md`, `namespaces/*/preference.md`,
`events/*.jsonl`) is per-test instead of shared. Fixes the
`promotion_redacts_person_memory_in_namespace_note` flake the
issue calls out. The two bespoke-path memory tests
(`evergreen_memories_dont_decay`,
`open_backfills_policy_columns_for_existing_rows`) flow through
the same `temp_memory_path("label")` helper.
- **`tools::parser` Linux-only test gate** (#21). The
`try_tool_call_executes_single_key_system_info_shape` test asserts
the rendered `system_info` tool output contains `Memory available:`,
which the production tool only emits on Linux (the line comes from
`tegrastats::mem_available_mb()` reading `/proc/meminfo`). Gated
behind `#[cfg(target_os = "linux")]` so macOS CI no longer flags a
false negative.

### Changed

- `deploy/scripts/genie-restart-all.sh` rewritten as a full hard-reset:
Expand Down
153 changes: 153 additions & 0 deletions crates/genie-core/src/llm/mock.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
//! In-memory `LlmBackendClient` for tests. Issue #21, IS-2.
//!
//! Implements the same `LlmBackendClient` trait the real backends do
//! (llama.cpp, genie-ai-runtime) so anywhere `LlmClient` is consumed the
//! mock can be dropped in. Lets `tests/voice_loop_integration.rs` exercise
//! the LLM-driven part of the voice cycle without a live model server.
//!
//! The mock is deliberately tiny — it does NOT try to be a smart fixture:
//! it returns the next scripted reply on each call, streams it token by
//! token if `chat_stream` is invoked, and reports healthy.

use anyhow::{Result, bail};
use async_trait::async_trait;
use std::sync::Mutex;

use super::{LlmBackendClient, Message, ResponseFormat};

/// Scripted-reply LLM backend.
///
/// Construct with a queue of replies; each call to `chat_with_format` or
/// `chat_stream` consumes the next reply in order. When the queue is empty
/// the backend returns the configured fallback (`Result::Err` by default,
/// or a fixed string if set via [`MockLlmBackend::with_fallback`]).
pub struct MockLlmBackend {
replies: Mutex<Vec<String>>,
fallback: Option<String>,
backend_name: String,
}

impl MockLlmBackend {
/// New mock that will replay `replies` in order. After the last reply
/// is consumed, further calls return `Err`.
pub fn new<I, S>(replies: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut q: Vec<String> = replies.into_iter().map(Into::into).collect();
q.reverse(); // pop from the back so we yield in insertion order
Self {
replies: Mutex::new(q),
fallback: None,
backend_name: "mock".into(),
}
}

/// Configure a fallback reply used once the scripted queue is exhausted.
/// Useful when a test wants the mock to "keep talking" rather than fail.
pub fn with_fallback(mut self, fallback: impl Into<String>) -> Self {
self.fallback = Some(fallback.into());
self
}

fn next_reply(&self) -> Result<String> {
let mut q = self.replies.lock().expect("mock LLM reply queue poisoned");
if let Some(reply) = q.pop() {
Ok(reply)
} else if let Some(fallback) = &self.fallback {
Ok(fallback.clone())
} else {
bail!("MockLlmBackend reply queue exhausted");
}
}
}

#[async_trait]
impl LlmBackendClient for MockLlmBackend {
fn backend_name(&self) -> &str {
&self.backend_name
}

async fn health(&self) -> bool {
true
}

async fn chat_with_format(
&self,
_messages: &[Message],
_max_tokens: Option<u32>,
_response_format: Option<ResponseFormat>,
) -> Result<String> {
self.next_reply()
}

async fn chat_stream(
&self,
_messages: &[Message],
_max_tokens: Option<u32>,
on_token: &mut (dyn for<'a> FnMut(&'a str) + Send),
) -> Result<String> {
let reply = self.next_reply()?;
// Stream word-by-word so callers see the streaming code path.
for token in reply.split_inclusive(' ') {
on_token(token);
}
Ok(reply)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[tokio::test]
async fn returns_scripted_replies_in_order() {
let mock = MockLlmBackend::new(["first", "second"]);
assert_eq!(
mock.chat_with_format(&[], None, None).await.unwrap(),
"first"
);
assert_eq!(
mock.chat_with_format(&[], None, None).await.unwrap(),
"second"
);
assert!(mock.chat_with_format(&[], None, None).await.is_err());
}

#[tokio::test]
async fn fallback_kicks_in_after_exhaustion() {
let mock = MockLlmBackend::new(["only"]).with_fallback("filler");
assert_eq!(
mock.chat_with_format(&[], None, None).await.unwrap(),
"only"
);
assert_eq!(
mock.chat_with_format(&[], None, None).await.unwrap(),
"filler"
);
assert_eq!(
mock.chat_with_format(&[], None, None).await.unwrap(),
"filler"
);
}

#[tokio::test]
async fn stream_emits_tokens_and_returns_full_reply() {
let mock = MockLlmBackend::new(["hello there friend"]);
let mut seen = String::new();
let full = mock
.chat_stream(&[], None, &mut |tok| seen.push_str(tok))
.await
.unwrap();
assert_eq!(full, "hello there friend");
assert_eq!(seen, "hello there friend");
}

#[tokio::test]
async fn backend_name_is_mock_and_health_is_true() {
let mock = MockLlmBackend::new(Vec::<String>::new());
assert_eq!(mock.backend_name(), "mock");
assert!(mock.health().await);
}
}
14 changes: 14 additions & 0 deletions crates/genie-core/src/llm/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod genie_ai_runtime;
mod llama_cpp;
mod mock;
mod openai_compat;
mod retry;

Expand All @@ -9,6 +10,7 @@ use genie_common::config::{LlmBackendKind, ServiceEndpoint};

pub use genie_ai_runtime::GenieAiRuntimeBackend;
pub use llama_cpp::LlamaCppBackend;
pub use mock::MockLlmBackend;
pub use openai_compat::{Message, ResponseFormat};
#[allow(unused_imports)]
pub use retry::RetryLlmClient;
Expand Down Expand Up @@ -82,6 +84,18 @@ impl LlmClient {
}
}

/// Construct an in-memory LLM client that replays the given scripted
/// replies in order. Used by `tests/voice_loop_integration.rs` (issue #21).
pub fn mock<I, S>(replies: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
backend: Box::new(MockLlmBackend::new(replies)),
}
}

pub fn backend_name(&self) -> &str {
self.backend.backend_name()
}
Expand Down
35 changes: 22 additions & 13 deletions crates/genie-core/src/memory/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1419,30 +1419,39 @@ mod tests {
use super::*;

use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);

// Each test gets its own subdirectory so the canonical memory layout
// (`memory/`, `namespaces/`, `events/`) doesn't collide across parallel
// tests. `rebuild_root_memory_file` does `remove_dir_all` on `namespaces/`
// before rewriting it; sharing one dir caused tests to race-wipe each
// other's files when run with multiple test threads.
fn temp_memory() -> Memory {
Memory::open(&temp_memory_path("test")).unwrap()
}

/// Return a freshly-created unique parent dir and a `memory.db` path
/// inside it. `Memory::open` derives
/// `canonical_dir = path.parent().join("memory")` and writes promotion
/// and namespace markdown files into it, so sharing a parent dir across
/// tests causes promotion tests to race on shared files like
/// `namespaces/person/preference.md` (issue #21, AC-D2). Every memory
/// test path MUST flow through this helper. The `nanos` suffix on top
/// of `pid + counter` defends against rapid test-binary reruns that
/// could reuse a pid before the previous run's tempdir was cleaned.
fn temp_memory_path(label: &str) -> PathBuf {
let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir = std::env::temp_dir().join(format!(
"geniepod-mem-{}-{}-{}",
"geniepod-mem-{}-{}-{}-{}",
label,
std::process::id(),
id
id,
nanos
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&dir).expect("create temp memory dir");
dir.join("memory.db")
}

fn temp_memory() -> Memory {
Memory::open(&temp_memory_path("test")).unwrap()
}

#[test]
fn store_and_search() {
let mem = temp_memory();
Expand Down
8 changes: 8 additions & 0 deletions crates/genie-core/src/tools/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,14 @@ mod tests {
assert!(normalize_single_key_tool_call(value, &dispatcher).is_none());
}

// The `system_info` tool reads /proc/meminfo (via tegrastats), /proc/uptime,
// and /proc/loadavg. On macOS those files do not exist, so the "Memory
// available:" line is absent from the rendered output and this assertion
// fails. Per issue #21 AC-D1 we gate the test (not the production code) —
// the tool itself is Linux-targeted by design, so its end-to-end shape
// assertion only makes sense on Linux. macOS dev boxes still exercise the
// dispatch / parsing path through the unit tests above.
#[cfg(target_os = "linux")]
#[tokio::test]
async fn try_tool_call_executes_single_key_system_info_shape() {
let dispatcher = ToolDispatcher::new(None);
Expand Down
Loading
Loading