From 333460ca11bf835ab5c0d853c8f8d092edb00284 Mon Sep 17 00:00:00 2001 From: Leo Date: Mon, 13 Jul 2026 11:48:16 -0400 Subject: [PATCH 1/5] fix(request): accept literal newlines in DSL string values --- .../khive-request/src/parser/parser_impl.rs | 24 +++++++--- crates/khive-request/src/parser/scan.rs | 32 +++++++++++++ crates/khive-request/tests/parser.rs | 47 +++++++++++++++++++ docs/adr/ADR-016-request-dsl.md | 8 +++- 4 files changed, 104 insertions(+), 7 deletions(-) diff --git a/crates/khive-request/src/parser/parser_impl.rs b/crates/khive-request/src/parser/parser_impl.rs index 8db4621fb..7ec167e21 100644 --- a/crates/khive-request/src/parser/parser_impl.rs +++ b/crates/khive-request/src/parser/parser_impl.rs @@ -4,7 +4,7 @@ use serde_json::Value; use crate::types::{ArgValue, DslError, ParsedOp, NESTING_DEPTH_LIMIT}; -use super::scan::{char_label, scan_string_end}; +use super::scan::{char_label, escape_literal_control_chars, scan_string_end}; /// Byte-slice cursor for the DSL input. pub(crate) struct Parser<'a> { @@ -391,11 +391,23 @@ impl<'a> Parser<'a> { let end = self.scan_value_end()?; let slice = std::str::from_utf8(&self.src[start..end]) .expect("ascii-or-utf8 maintained by scanner"); - let value: Value = - serde_json::from_str(slice.trim()).map_err(|e| DslError::InvalidValue { - pos: start, - error: e.to_string(), - })?; + let trimmed = slice.trim(); + // A quoted string literal may contain raw control bytes (newline, CR, + // tab) verbatim in the DSL source; JSON proper forbids that, so + // rewrite them to JSON escapes before handing the slice to + // `serde_json`. Non-string values (numbers, bool, null) never + // legitimately contain such bytes, so this only touches strings. + let normalized; + let json_src: &str = if trimmed.starts_with('"') { + normalized = escape_literal_control_chars(trimmed); + &normalized + } else { + trimmed + }; + let value: Value = serde_json::from_str(json_src).map_err(|e| DslError::InvalidValue { + pos: start, + error: e.to_string(), + })?; self.pos = end; Ok(value) } diff --git a/crates/khive-request/src/parser/scan.rs b/crates/khive-request/src/parser/scan.rs index 6d8eaa3ed..3fb4ea482 100644 --- a/crates/khive-request/src/parser/scan.rs +++ b/crates/khive-request/src/parser/scan.rs @@ -20,6 +20,38 @@ pub(crate) fn scan_string_end(src: &[u8], start: usize) -> Result String { + if !s.contains(|c: char| (c as u32) < 0x20) { + return s.to_owned(); + } + let mut out = String::with_capacity(s.len() + 8); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + out.push(c); + if let Some(next) = chars.next() { + out.push(next); + } + continue; + } + match c { + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out +} + /// Human-readable label for a delimiter character in error messages. pub(crate) fn char_label(c: char) -> &'static str { match c { diff --git a/crates/khive-request/tests/parser.rs b/crates/khive-request/tests/parser.rs index 7c26a43a0..2133cd161 100644 --- a/crates/khive-request/tests/parser.rs +++ b/crates/khive-request/tests/parser.rs @@ -109,6 +109,53 @@ fn string_with_escaped_quote() { assert_eq!(val(&v[0].args["title"]), &json!("he said \"hi\"")); } +#[test] +fn multiline_string_value_round_trips() { + // A literal raw newline (0x0A) inside a double-quoted value, as an agent + // client would send it directly rather than escaping it to `\n` text. + let src = "gtd.assign(title=\"line one\nline two\")"; + let v = ops(src); + assert_eq!(val(&v[0].args["title"]), &json!("line one\nline two")); +} + +#[test] +fn tab_and_carriage_return_literal_in_string() { + let src = "gtd.assign(title=\"a\tb\rc\")"; + let v = ops(src); + assert_eq!(val(&v[0].args["title"]), &json!("a\tb\rc")); +} + +#[test] +fn mixed_literal_newline_and_escaped_sequences() { + // A literal raw newline sits next to escaped-quote and escaped-backslash + // pairs in the same value, proving the control-char rewrite does not + // disturb adjacent backslash-escape pairs. + let src = "comm.reply(content=\"line one \\\"quoted\\\"\nline two a\\\\b\")"; + let v = ops(src); + assert_eq!( + val(&v[0].args["content"]), + &json!("line one \"quoted\"\nline two a\\b") + ); +} + +#[test] +fn unterminated_multiline_string_rejected() { + // A raw newline mid-string does not make an unclosed string legal; the + // closing quote is still required somewhere in the input. + let src = "gtd.assign(title=\"line one\nline two)"; + let err = parse_request(src).unwrap_err(); + assert!(matches!(err, DslError::UnclosedString)); +} + +#[test] +fn json_form_rejects_literal_newline_in_string() { + // JSON form stays strict JSON: a raw control character inside a quoted + // string is a parse error there, unlike the function-call form above. + let src = "[{\"tool\":\"gtd.next\",\"args\":{\"note\":\"line one\nline two\"}}]"; + let err = parse_request(src).unwrap_err(); + assert!(matches!(err, DslError::InvalidJson { .. })); +} + #[test] fn null_and_negative_number() { let v = ops(r#"update(id="x", description=null, weight=-0.5)"#); diff --git a/docs/adr/ADR-016-request-dsl.md b/docs/adr/ADR-016-request-dsl.md index 4811c583e..4018254ca 100644 --- a/docs/adr/ADR-016-request-dsl.md +++ b/docs/adr/ADR-016-request-dsl.md @@ -105,7 +105,13 @@ Argument values use JSON literal syntax inside the function-call form: | Reference (chain only) | `$prev`, `$prev.field.path`, `$prev[N].field` | String escapes follow JSON: `\\`, `\"`, `\n`, `\t`, `\r`. Other backslash -sequences are literal. +sequences are literal. Unlike strict JSON, a double-quoted value in the +function-call form may also contain a literal newline, carriage return, or +tab byte verbatim (not just the backslash-escaped form); the parser rewrites +those bytes to their JSON escape before decoding, so multi-paragraph content +round-trips without requiring callers to pre-escape line breaks. The JSON +form (`ops="[{\"tool\":...}]"`) keeps strict JSON string rules: raw control +characters there remain a parse error, per the JSON spec. #### `$prev` reference grammar From bea37770c9903e2c8c3840d7c5da4e5576cb0c2c Mon Sep 17 00:00:00 2001 From: Leo Date: Mon, 13 Jul 2026 12:56:10 -0400 Subject: [PATCH 2/5] fix(request): restrict literal control-char exception to \n/\r/\t Round-1 review (PR #957) flagged that escape_literal_control_chars rewrote every raw U+0000-U+001F byte in a quoted DSL string, but ADR-016 only documents newline/CR/tab as the literal exception. Narrow the rewrite to exactly those three bytes so every other raw control character falls through to serde_json and is rejected, matching pre-PR behavior. Clarify ADR-016 with one sentence stating the same. Add the grammar-boundary test matrix requested in review: CRLF preservation, a literal newline directly before the closing quote, a raw newline immediately after an escaped-quote boundary, rejection of raw controls in bareword position and for every other control byte, an invalid-escape negative case, newline-split batch/chain separators, and an mcp request-boundary test proving a wire-decoded raw newline reaches the pack handler's result. Co-Authored-By: Claude Sonnet 5 --- crates/khive-mcp/src/server.rs | 54 ++++++++++++++ crates/khive-request/src/parser/scan.rs | 22 +++--- crates/khive-request/tests/parser.rs | 98 +++++++++++++++++++++++++ docs/adr/ADR-016-request-dsl.md | 9 ++- 4 files changed, 171 insertions(+), 12 deletions(-) diff --git a/crates/khive-mcp/src/server.rs b/crates/khive-mcp/src/server.rs index f863e9752..ff444cfda 100644 --- a/crates/khive-mcp/src/server.rs +++ b/crates/khive-mcp/src/server.rs @@ -2297,6 +2297,60 @@ mod tests { } } + // ── request-boundary regression: raw controls survive wire decoding ───── + + #[tokio::test] + async fn request_boundary_raw_control_bytes_reach_handler() { + // Simulates the actual MCP wire: a JSON-RPC client sends the tool's + // `ops` argument as a JSON string using the standard JSON `\n` + // escape. Deserializing `RequestParams` decodes that escape into an + // actual raw LF byte inside the DSL source — the exact shape + // `escape_literal_control_chars` (crates/khive-request/src/parser/scan.rs) + // exists to accept. This confirms the decoded raw newline survives + // parsing and dispatch all the way to the pack handler's result. + let wire = "{\"ops\":\"create(kind=\\\"entity\\\", entity_kind=\\\"concept\\\", name=\\\"line1\\nline2\\\")\"}"; + let params: RequestParams = serde_json::from_str(wire).expect("wire JSON deserializes"); + assert!( + params.ops.contains('\n'), + "deserialized ops must carry a raw LF, not the two-char escape: {:?}", + params.ops + ); + + let config = RuntimeConfig { + db_path: None, + default_namespace: Namespace::local(), + embedding_model: None, + additional_embedding_models: vec![], + packs: vec!["kg".to_string()], + ..RuntimeConfig::default() + }; + let runtime = KhiveRuntime::new(config).expect("in-memory runtime"); + let server = KhiveMcpServer::new(runtime).expect("server builds with kg"); + + let parsed = parse_request(¶ms.ops).expect("literal newline inside quotes must parse"); + let response = server + .run_parsed( + parsed.ops, + parsed.mode, + PresentationMode::Verbose, + None, + false, + None, + ) + .await; + + let results = response["results"] + .as_array() + .expect("results must be an array"); + assert_eq!(results.len(), 1); + assert_eq!( + results[0]["ok"], + json!(true), + "unexpected result: {response:?}" + ); + assert_eq!(results[0]["result"]["name"], json!("line1\nline2")); + } + // ── MCP-AUD-002 regression: save_to must bypass daemon forwarding ──────── fn make_daemon_save_to_test_server() -> KhiveMcpServer { diff --git a/crates/khive-request/src/parser/scan.rs b/crates/khive-request/src/parser/scan.rs index 3fb4ea482..115b708a1 100644 --- a/crates/khive-request/src/parser/scan.rs +++ b/crates/khive-request/src/parser/scan.rs @@ -20,15 +20,20 @@ pub(crate) fn scan_string_end(src: &[u8], start: usize) -> Result String { - if !s.contains(|c: char| (c as u32) < 0x20) { + if !s.contains(['\n', '\r', '\t']) { return s.to_owned(); } let mut out = String::with_capacity(s.len() + 8); @@ -45,7 +50,6 @@ pub(crate) fn escape_literal_control_chars(s: &str) -> String { '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), - c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), c => out.push(c), } } diff --git a/crates/khive-request/tests/parser.rs b/crates/khive-request/tests/parser.rs index 2133cd161..0c3971af5 100644 --- a/crates/khive-request/tests/parser.rs +++ b/crates/khive-request/tests/parser.rs @@ -156,6 +156,104 @@ fn json_form_rejects_literal_newline_in_string() { assert!(matches!(err, DslError::InvalidJson { .. })); } +// ── ADR-016 grammar-boundary matrix (round-1 review, PR #957) ──────────────── + +#[test] +fn crlf_preserved_exact_in_quoted_value() { + // A literal CRLF pair (not just LF or CR alone) inside a quoted value + // round-trips byte-for-byte, proving both bytes are rewritten + // independently rather than the pair being collapsed or reordered. + let src = "gtd.assign(title=\"line one\r\nline two\")"; + let v = ops(src); + assert_eq!(val(&v[0].args["title"]), &json!("line one\r\nline two")); +} + +#[test] +fn literal_newline_immediately_before_closing_quote() { + // The raw newline is the very last byte before the closing `"`, with no + // trailing content — the scanner must not mistake it for trailing + // whitespace to trim, nor the rewrite skip the final byte. + let src = "gtd.assign(title=\"abc\n\")"; + let v = ops(src); + assert_eq!(val(&v[0].args["title"]), &json!("abc\n")); +} + +#[test] +fn raw_newline_immediately_after_escaped_quote_boundary() { + // A `\"` escape pair is immediately followed by a raw literal newline. + // `scan_string_end` must consume the escape pair as a unit (not treat + // its second byte as the closing quote), and the rewrite must copy the + // escape pair through untouched before rewriting the adjacent raw LF. + let src = "gtd.assign(title=\"a\\\"\nb\")"; + let v = ops(src); + assert_eq!(val(&v[0].args["title"]), &json!("a\"\nb")); +} + +#[test] +fn raw_control_char_in_bareword_position_rejected() { + // The literal-control-char exception only applies inside a + // double-quoted string value (`parse_value` only calls + // `escape_literal_control_chars` when the trimmed slice starts with + // `"`). A raw NUL embedded in an unquoted (bareword) numeric value must + // still fail to parse as JSON, exactly as before this PR. + let src = format!("gtd.assign(weight=1{}0)", '\u{0}'); + let err = parse_request(&src).unwrap_err(); + assert!( + matches!(err, DslError::InvalidValue { .. }), + "expected InvalidValue for a raw control byte in bareword position, got {err:?}" + ); +} + +#[test] +fn other_raw_control_chars_in_quoted_string_still_rejected() { + // Only `\n`, `\r`, and `\t` are exempted (ADR-016). Every other raw + // U+0000-U+001F byte inside a quoted value must remain a parse error, + // matching pre-PR behavior — NUL, form feed, and ESC here. + for raw in ['\u{0}', '\u{c}', '\u{1b}'] { + let src = format!("gtd.assign(title=\"a{raw}b\")"); + let err = parse_request(&src).unwrap_err(); + assert!( + matches!(err, DslError::InvalidValue { .. }), + "expected InvalidValue for raw control byte {:#04x}, got {err:?}", + raw as u32 + ); + } +} + +#[test] +fn invalid_backslash_escape_in_quoted_string_rejected() { + // A negative invalid-escape case: `\q` is not a JSON escape sequence. + // This must fail regardless of the literal-newline carve-out. + let src = r#"gtd.assign(title="bad \q escape")"#; + let err = parse_request(src).unwrap_err(); + assert!(matches!(err, DslError::InvalidValue { .. })); +} + +#[test] +fn batch_ops_separated_by_raw_newlines() { + // The top-level batch `,` separator may be split across literal + // newlines between ops, since `skip_ws` treats `\n` as ordinary + // whitespace around the separator. + let src = "[create(kind=\"entity\", name=\"A\"),\ncreate(kind=\"entity\", name=\"B\")]"; + let r = req(src); + assert_eq!(r.mode, ExecutionMode::Parallel); + assert_eq!(r.ops.len(), 2); + assert_eq!(val(&r.ops[0].args["name"]), &json!("A")); + assert_eq!(val(&r.ops[1].args["name"]), &json!("B")); +} + +#[test] +fn chain_ops_separated_by_raw_newlines() { + // The chain `|` separator may likewise be split across literal + // newlines between ops. + let src = "create(kind=\"entity\", name=\"A\")\n|\ncreate(kind=\"entity\", name=\"B\")"; + let r = req(src); + assert_eq!(r.mode, ExecutionMode::Chain); + assert_eq!(r.ops.len(), 2); + assert_eq!(val(&r.ops[0].args["name"]), &json!("A")); + assert_eq!(val(&r.ops[1].args["name"]), &json!("B")); +} + #[test] fn null_and_negative_number() { let v = ops(r#"update(id="x", description=null, weight=-0.5)"#); diff --git a/docs/adr/ADR-016-request-dsl.md b/docs/adr/ADR-016-request-dsl.md index 4018254ca..4be32cc06 100644 --- a/docs/adr/ADR-016-request-dsl.md +++ b/docs/adr/ADR-016-request-dsl.md @@ -109,9 +109,12 @@ sequences are literal. Unlike strict JSON, a double-quoted value in the function-call form may also contain a literal newline, carriage return, or tab byte verbatim (not just the backslash-escaped form); the parser rewrites those bytes to their JSON escape before decoding, so multi-paragraph content -round-trips without requiring callers to pre-escape line breaks. The JSON -form (`ops="[{\"tool\":...}]"`) keeps strict JSON string rules: raw control -characters there remain a parse error, per the JSON spec. +round-trips without requiring callers to pre-escape line breaks. This +exception is limited to exactly those three bytes: every other raw +U+0000-U+001F control character inside a function-call quoted value remains +a parse error, same as strict JSON. The JSON form (`ops="[{\"tool\":...}]"`) +keeps strict JSON string rules: raw control characters there remain a parse +error, per the JSON spec. #### `$prev` reference grammar From ddfdbb1f3a48c2aae4305b82e67f8411c46191ab Mon Sep 17 00:00:00 2001 From: Leo Date: Mon, 13 Jul 2026 17:49:45 -0400 Subject: [PATCH 3/5] test(mcp): keep schedule routing regression hermetic --- crates/khive-mcp/src/serve.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/khive-mcp/src/serve.rs b/crates/khive-mcp/src/serve.rs index 6116fac2f..a9fd7a163 100644 --- a/crates/khive-mcp/src/serve.rs +++ b/crates/khive-mcp/src/serve.rs @@ -5048,11 +5048,21 @@ backend = "kg-backend" ); use clap::Parser; - let args = Args::parse_from(["mcp", "--config", config_path.to_str().expect("utf8 path")]); + let args = Args::parse_from([ + "mcp", + "--config", + config_path.to_str().expect("utf8 path"), + "--no-embed", + ]); let (server, schedule_rt) = build_server(&args).expect("build_server must succeed"); // No `[packs.schedule]` entry above, so it defaults to "main". let rt = schedule_rt.expect("schedule pack is loaded by default"); + assert!( + rt.config().embedding_model.is_none() + && rt.config().additional_embedding_models.is_empty(), + "the backend-routing fixture must remain independent of external embedding models" + ); let marker = "adr106-multi-backend-dispatch-marker"; let action_dsl = format!("create(kind=\"observation\", content=\"{marker}\")"); From 7871897f1dde987658144b9a30e6ed37b94dfe46 Mon Sep 17 00:00:00 2001 From: Leo Date: Mon, 13 Jul 2026 17:02:23 -0400 Subject: [PATCH 4/5] test(khive-db): serialize writer-task tests on tx_registry to fix flaky tx-age sweep run_writer_task registers a writer_task_tx handle in the process-wide tx_registry singleton; the checkpoint tx_age_sweep_* tests read that singleton under #[serial(tx_registry)]. The writer_task.rs tests that spawn a writer task were missing from the group, leaking a longer-lived writer_task_tx into the sweep's oldest() read and intermittently failing the assertion on main CI. Join them to the serial group. No prod change. --- crates/khive-db/src/writer_task.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/khive-db/src/writer_task.rs b/crates/khive-db/src/writer_task.rs index c13590065..17738a9a5 100644 --- a/crates/khive-db/src/writer_task.rs +++ b/crates/khive-db/src/writer_task.rs @@ -420,6 +420,7 @@ async fn run_writer_task( mod tests { use super::*; use crate::pool::PoolConfig; + use serial_test::serial; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -432,7 +433,14 @@ mod tests { ConnectionPool::new(cfg).expect("pool open") } + // `#[serial(tx_registry)]`: `run_writer_task` registers a `writer_task_tx` + // handle in the process-wide `tx_registry` singleton for the life of each + // `BEGIN IMMEDIATE`. Tests that observe the registry (the checkpoint + // `tx_age_sweep_*` group) read `tx_registry::oldest()`; an un-serialized + // spawning test here would leak a longer-lived `writer_task_tx` into that + // read and make the sweep name the wrong transaction. Share the key. #[tokio::test] + #[serial(tx_registry)] async fn begin_immediate_failure_replies_error_without_running_op() { // Real lock contention, not a simulation: hold the database-level // write lock from the pool's own writer connection (the unmigrated @@ -504,7 +512,11 @@ mod tests { ); } + // `#[serial(tx_registry)]`: shares the key with the checkpoint + // `tx_age_sweep_*` tests — see the note on + // `begin_immediate_failure_replies_error_without_running_op`. #[tokio::test] + #[serial(tx_registry)] async fn writer_task_executes_op_and_commits() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("writer_task_commit.db"); @@ -627,7 +639,13 @@ mod tests { first.abort(); } + // `#[serial(tx_registry)]`: this test deliberately keeps a request (and + // thus its `writer_task_tx` registry handle) alive past a timeout, so it is + // the worst polluter of the checkpoint `tx_age_sweep_*` reads if left + // un-serialized. Shares the key — see the note on + // `begin_immediate_failure_replies_error_without_running_op`. #[tokio::test] + #[serial(tx_registry)] async fn send_with_timeout_returns_op_result_when_op_outlives_the_timeout() { // `send_with_timeout`'s timeout must bound ONLY the enqueue step — // never the reply-wait. An accepted request (channel not full) must From 1e789bb75d19bdef651fe5f9b8ecd2b7cc8ee5d3 Mon Sep 17 00:00:00 2001 From: Leo Date: Mon, 13 Jul 2026 17:56:25 -0400 Subject: [PATCH 5/5] test(kkernel): make code-ingest vector test hermetic --- crates/kkernel/src/code_ingest.rs | 81 +++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 3 deletions(-) diff --git a/crates/kkernel/src/code_ingest.rs b/crates/kkernel/src/code_ingest.rs index 4d0bf5d5e..2d49613eb 100644 --- a/crates/kkernel/src/code_ingest.rs +++ b/crates/kkernel/src/code_ingest.rs @@ -104,6 +104,16 @@ pub async fn run_code_ingest(args: CodeIngestArgs) -> Result<()> { /// after validation has already succeeded and a real (non-dry-run) write is /// about to occur. async fn code_ingest_batch(args: CodeIngestArgs) -> Result { + code_ingest_batch_with_runtime_setup(args, |_| Ok(())).await +} + +async fn code_ingest_batch_with_runtime_setup( + args: CodeIngestArgs, + runtime_setup: F, +) -> Result +where + F: FnOnce(&KhiveRuntime) -> Result<()>, +{ let bytes = std::fs::read(&args.findings) .with_context(|| format!("failed to read {}", args.findings.display()))?; @@ -161,6 +171,7 @@ async fn code_ingest_batch(args: CodeIngestArgs) -> Result { } let runtime = KhiveRuntime::new(cfg).map_err(|e| anyhow::anyhow!("{e}"))?; + runtime_setup(&runtime)?; let resolved_ns = runtime.config().default_namespace.clone(); let token = runtime .authorize(resolved_ns) @@ -479,10 +490,63 @@ fn wal_sidecar_path(db_path: &Path) -> PathBuf { #[cfg(test)] mod tests { + use std::sync::Arc; + + use async_trait::async_trait; + use khive_runtime::{EmbedderProvider, RuntimeError}; + use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService}; use serial_test::serial; use super::*; + struct FixedEmbeddingService { + dimensions: usize, + } + + #[async_trait] + impl EmbeddingService for FixedEmbeddingService { + async fn embed( + &self, + texts: &[String], + _model: EmbeddingModel, + ) -> std::result::Result>, EmbedError> { + Ok(texts + .iter() + .map(|_| vec![1.0_f32; self.dimensions]) + .collect()) + } + + fn supports_model(&self, _model: EmbeddingModel) -> bool { + true + } + + fn name(&self) -> &'static str { + "code-ingest-test" + } + } + + struct FixedEmbeddingProvider { + name: String, + dimensions: usize, + } + + #[async_trait] + impl EmbedderProvider for FixedEmbeddingProvider { + fn name(&self) -> &str { + &self.name + } + + fn dimensions(&self) -> usize { + self.dimensions + } + + async fn build(&self) -> std::result::Result, RuntimeError> { + Ok(Arc::new(FixedEmbeddingService { + dimensions: self.dimensions, + })) + } + } + fn base_args(findings: PathBuf, db: PathBuf) -> CodeIngestArgs { CodeIngestArgs { findings, @@ -847,9 +911,20 @@ mod tests { let findings = write_valid_findings(tmp.path()); let db = tmp.path().join("scratch.db"); - code_ingest_batch(base_args(findings, db.clone())) - .await - .expect("ingest must succeed"); + code_ingest_batch_with_runtime_setup(base_args(findings, db.clone()), |runtime| { + let model_names = runtime.registered_embedding_model_names(); + assert!( + !model_names.is_empty(), + "test requires at least one configured embedding model" + ); + for name in model_names { + let dimensions = runtime.resolve_embedding_model(Some(&name))?.dimensions(); + runtime.register_embedder(FixedEmbeddingProvider { name, dimensions }); + } + Ok(()) + }) + .await + .expect("ingest must succeed"); let cfg = resolve_runtime_config(RuntimeConfigInputs { db: Some(db.to_str().expect("utf8 path")),