buildwithnexus 0.11.3 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,7 +49,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
49
49
 
50
50
  [[package]]
51
51
  name = "buildwithnexus"
52
- version = "0.11.3"
52
+ version = "0.11.4"
53
53
  dependencies = [
54
54
  "criterion",
55
55
  "crossterm",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "buildwithnexus"
3
- version = "0.11.3"
3
+ version = "0.11.4"
4
4
  edition = "2021"
5
5
  description = "A hilariously fast agentic AI CLI harness — remote or local models, full TUI with modes, memory, skills, hooks, and image input"
6
6
  license = "MIT"
@@ -711,12 +711,288 @@ fn is_casual_turn(text: &str) -> bool {
711
711
  }
712
712
 
713
713
  fn parse_text_tool_calls(text: &str, defs: &[tools::ToolDef]) -> Option<Vec<provider::ToolCall>> {
714
- let candidate = extract_json_tool_candidate(text)?;
715
- let value = serde_json::from_str::<serde_json::Value>(candidate).ok()?;
716
714
  let names = defs.iter().map(|d| d.name).collect::<HashSet<_>>();
717
- let mut calls = Vec::new();
718
- collect_text_tool_calls(&value, &names, &mut calls);
719
- (!calls.is_empty()).then_some(calls)
715
+ if let Some(candidate) = extract_json_tool_candidate(text) {
716
+ if let Ok(value) = serde_json::from_str::<serde_json::Value>(candidate) {
717
+ let mut calls = Vec::new();
718
+ collect_text_tool_calls(&value, &names, &mut calls);
719
+ if !calls.is_empty() {
720
+ return Some(calls);
721
+ }
722
+ }
723
+ }
724
+ // Fall back to Gemma's `tool_code` Python-call format.
725
+ parse_tool_code_call(text, &names)
726
+ }
727
+
728
+ // Positional argument names for the tools small models most often call, so a
729
+ // Gemma call like `write_file("/p", """…""")` maps arg 0→path, arg 1→content.
730
+ fn positional_params(name: &str) -> &'static [&'static str] {
731
+ match name {
732
+ "write_file" | "write" => &["path", "content"],
733
+ "read_file" | "read" => &["path"],
734
+ "edit_file" | "edit" => &["path", "old", "new"],
735
+ "run_command" | "bash" => &["command"],
736
+ "Artifact" | "publish_artifact" => &["contents"],
737
+ "list_dir" | "list" => &["path"],
738
+ "find_files" | "glob" => &["pattern"],
739
+ "grep_files" | "grep" => &["pattern"],
740
+ "finish" => &["summary"],
741
+ "python_tool" => &["code"],
742
+ "create_docx" => &["path", "title", "body"],
743
+ _ => &[],
744
+ }
745
+ }
746
+
747
+ // Gemma emits tool calls as a ```tool_code fenced Python function call, e.g.
748
+ // write_file("/p/index.html", """<!doctype html>…""")
749
+ // Artifact(contents="""…""")
750
+ // sometimes wrapped in `print(...)`. Native tool_calls / JSON never match, so
751
+ // the call is otherwise treated as prose. Parse the first known-tool call.
752
+ fn parse_tool_code_call(
753
+ text: &str,
754
+ allowed: &HashSet<&'static str>,
755
+ ) -> Option<Vec<provider::ToolCall>> {
756
+ let scope = tool_code_scope(text);
757
+ let (name, after) = find_tool_call(scope, allowed)?;
758
+ let inside = balanced_parens(after)?;
759
+ let args = parse_python_args(inside);
760
+ let input = tool_code_args_to_input(name, args);
761
+ Some(vec![text_tool_call(name, input)])
762
+ }
763
+
764
+ // The region to scan: inside a ```tool_code fence when present, else the whole
765
+ // text (Gemma sometimes omits the fence).
766
+ fn tool_code_scope(text: &str) -> &str {
767
+ if let Some(pos) = text.find("```tool_code") {
768
+ let after = &text[pos + "```tool_code".len()..];
769
+ let end = after.find("```").unwrap_or(after.len());
770
+ &after[..end]
771
+ } else {
772
+ text
773
+ }
774
+ }
775
+
776
+ // Find the earliest occurrence of a known tool name immediately followed by
777
+ // `(` (skipping any `print(` wrapper, which isn't a known tool). Returns the
778
+ // name and the slice just after the opening paren.
779
+ fn find_tool_call<'a>(
780
+ scope: &'a str,
781
+ allowed: &HashSet<&'static str>,
782
+ ) -> Option<(&'static str, &'a str)> {
783
+ let bytes = scope.as_bytes();
784
+ let mut best: Option<(usize, &'static str, usize)> = None;
785
+ for &name in allowed.iter() {
786
+ let mut from = 0;
787
+ while let Some(rel) = scope[from..].find(name) {
788
+ let at = from + rel;
789
+ // Must be a whole identifier (not a substring of a longer name).
790
+ let before_ok = at == 0 || !is_ident_byte(bytes[at - 1]);
791
+ let after_idx = at + name.len();
792
+ let mut j = after_idx;
793
+ while j < bytes.len() && bytes[j] == b' ' {
794
+ j += 1;
795
+ }
796
+ if before_ok && j < bytes.len() && bytes[j] == b'(' {
797
+ if best.map(|(p, _, _)| at < p).unwrap_or(true) {
798
+ best = Some((at, name, j + 1));
799
+ }
800
+ break;
801
+ }
802
+ from = at + name.len();
803
+ }
804
+ }
805
+ best.map(|(_, name, paren)| (name, &scope[paren..]))
806
+ }
807
+
808
+ fn is_ident_byte(b: u8) -> bool {
809
+ b.is_ascii_alphanumeric() || b == b'_'
810
+ }
811
+
812
+ // Given the slice just after an opening `(`, return the argument text up to the
813
+ // matching `)`, tracking string literals (triple/single/double) and nesting.
814
+ fn balanced_parens(s: &str) -> Option<&str> {
815
+ let bytes = s.as_bytes();
816
+ let mut depth = 1i32;
817
+ let mut i = 0;
818
+ while i < bytes.len() {
819
+ // Skip string literals whole so commas/parens inside don't count.
820
+ if let Some(consumed) = string_literal_len(&s[i..]) {
821
+ i += consumed;
822
+ continue;
823
+ }
824
+ match bytes[i] {
825
+ b'(' | b'[' | b'{' => depth += 1,
826
+ b')' | b']' | b'}' => {
827
+ depth -= 1;
828
+ if depth == 0 {
829
+ return Some(&s[..i]);
830
+ }
831
+ }
832
+ _ => {}
833
+ }
834
+ i += 1;
835
+ }
836
+ None
837
+ }
838
+
839
+ // If `s` starts with a Python string literal, return its byte length (including
840
+ // delimiters). Handles triple `"""`/`'''` (raw) and single `"`/`'` (with `\`
841
+ // escapes). None if `s` doesn't start with a quote.
842
+ fn string_literal_len(s: &str) -> Option<usize> {
843
+ let b = s.as_bytes();
844
+ for q in ["\"\"\"", "'''"] {
845
+ if let Some(rest) = s.strip_prefix(q) {
846
+ let close = rest.find(q)?;
847
+ return Some(q.len() + close + q.len());
848
+ }
849
+ }
850
+ let quote = *b.first()?;
851
+ if quote == b'"' || quote == b'\'' {
852
+ let mut i = 1;
853
+ while i < b.len() {
854
+ if b[i] == b'\\' {
855
+ i += 2;
856
+ continue;
857
+ }
858
+ if b[i] == quote {
859
+ return Some(i + 1);
860
+ }
861
+ i += 1;
862
+ }
863
+ }
864
+ None
865
+ }
866
+
867
+ // Split a Python argument list at top-level commas, then classify each as
868
+ // keyword (`name=value`) or positional, with the value parsed to a JSON value.
869
+ fn parse_python_args(inside: &str) -> Vec<(Option<String>, serde_json::Value)> {
870
+ let bytes = inside.as_bytes();
871
+ let mut parts: Vec<&str> = Vec::new();
872
+ let (mut start, mut i) = (0usize, 0usize);
873
+ while i < bytes.len() {
874
+ if let Some(consumed) = string_literal_len(&inside[i..]) {
875
+ i += consumed;
876
+ continue;
877
+ }
878
+ match bytes[i] {
879
+ b'(' | b'[' | b'{' => {
880
+ if let Some(inner) = balanced_parens(&inside[i + 1..]) {
881
+ i += 1 + inner.len();
882
+ }
883
+ }
884
+ b',' => {
885
+ parts.push(&inside[start..i]);
886
+ start = i + 1;
887
+ }
888
+ _ => {}
889
+ }
890
+ i += 1;
891
+ }
892
+ if start < inside.len() {
893
+ parts.push(&inside[start..]);
894
+ }
895
+ parts
896
+ .into_iter()
897
+ .filter_map(|p| {
898
+ let p = p.trim();
899
+ if p.is_empty() {
900
+ return None;
901
+ }
902
+ // Keyword only if the `=` precedes any string literal.
903
+ if let Some(eq) = kw_eq_pos(p) {
904
+ let key = p[..eq].trim();
905
+ if !key.is_empty() && key.chars().all(|c| c.is_alphanumeric() || c == '_') {
906
+ return Some((Some(key.to_string()), parse_py_value(p[eq + 1..].trim())));
907
+ }
908
+ }
909
+ Some((None, parse_py_value(p)))
910
+ })
911
+ .collect()
912
+ }
913
+
914
+ // Position of a top-level `=` (keyword separator) before any string literal,
915
+ // or None. `==`/`!=`/`>=`/`<=` are not keyword separators.
916
+ fn kw_eq_pos(p: &str) -> Option<usize> {
917
+ let b = p.as_bytes();
918
+ let mut i = 0;
919
+ while i < b.len() {
920
+ if string_literal_len(&p[i..]).is_some() {
921
+ return None; // hit a string before any '='
922
+ }
923
+ if b[i] == b'=' {
924
+ let prev = if i > 0 { b[i - 1] } else { b' ' };
925
+ let next = b.get(i + 1).copied().unwrap_or(b' ');
926
+ if prev != b'!' && prev != b'<' && prev != b'>' && next != b'=' {
927
+ return Some(i);
928
+ }
929
+ }
930
+ i += 1;
931
+ }
932
+ None
933
+ }
934
+
935
+ // Parse a Python literal value into JSON: triple/single/double-quoted strings,
936
+ // booleans, and numbers; anything else becomes a raw string.
937
+ fn parse_py_value(expr: &str) -> serde_json::Value {
938
+ let e = expr.trim();
939
+ for q in ["\"\"\"", "'''"] {
940
+ if let Some(rest) = e.strip_prefix(q) {
941
+ if let Some(end) = rest.rfind(q) {
942
+ return serde_json::Value::String(rest[..end].to_string());
943
+ }
944
+ }
945
+ }
946
+ if (e.starts_with('"') && e.ends_with('"') && e.len() >= 2)
947
+ || (e.starts_with('\'') && e.ends_with('\'') && e.len() >= 2)
948
+ {
949
+ let inner = &e[1..e.len() - 1];
950
+ // Unescape the common sequences for single-quoted strings.
951
+ let unescaped = inner
952
+ .replace("\\n", "\n")
953
+ .replace("\\t", "\t")
954
+ .replace("\\\"", "\"")
955
+ .replace("\\'", "'")
956
+ .replace("\\\\", "\\");
957
+ return serde_json::Value::String(unescaped);
958
+ }
959
+ match e {
960
+ "True" | "true" => return serde_json::Value::Bool(true),
961
+ "False" | "false" => return serde_json::Value::Bool(false),
962
+ _ => {}
963
+ }
964
+ if let Ok(n) = e.parse::<i64>() {
965
+ return serde_json::Value::from(n);
966
+ }
967
+ if let Ok(f) = e.parse::<f64>() {
968
+ return serde_json::Value::from(f);
969
+ }
970
+ serde_json::Value::String(e.to_string())
971
+ }
972
+
973
+ // Build the tool input object: keyword args by name, positional args mapped
974
+ // through the tool's positional signature.
975
+ fn tool_code_args_to_input(
976
+ name: &str,
977
+ args: Vec<(Option<String>, serde_json::Value)>,
978
+ ) -> serde_json::Value {
979
+ let mut map = serde_json::Map::new();
980
+ let params = positional_params(name);
981
+ let mut pos = 0;
982
+ for (key, val) in args {
983
+ match key {
984
+ Some(k) => {
985
+ map.insert(k, val);
986
+ }
987
+ None => {
988
+ if let Some(pname) = params.get(pos) {
989
+ map.insert((*pname).to_string(), val);
990
+ }
991
+ pos += 1;
992
+ }
993
+ }
994
+ }
995
+ serde_json::Value::Object(map)
720
996
  }
721
997
 
722
998
  fn extract_json_tool_candidate(text: &str) -> Option<&str> {
@@ -3346,6 +3622,65 @@ mod tests {
3346
3622
  assert_eq!(b.calls[0].input["path"], "b.txt");
3347
3623
  }
3348
3624
 
3625
+ #[test]
3626
+ fn gemma_tool_code_write_file_positional_triple_quoted() {
3627
+ // The exact shape captured from gemma-2-2b-it: a ```tool_code fence with
3628
+ // a Python call, positional args, and triple-quoted HTML containing
3629
+ // commas, parens, and braces.
3630
+ let defs = tools::defs_for_context(true, 8192);
3631
+ let text = "```tool_code\nwrite_file(\"/p/index.html\", \"\"\"<!doctype html>\\n<canvas id=\"c\"></canvas>\\n<script>const a={x:1}; f(a,b);</script>\"\"\")\n```";
3632
+ let r = normalize_text_tool_calls(
3633
+ Reply {
3634
+ text: text.to_string(),
3635
+ ..Default::default()
3636
+ },
3637
+ &defs,
3638
+ "build a canvas game",
3639
+ );
3640
+ assert_eq!(r.calls.len(), 1, "text: {}", r.text);
3641
+ assert_eq!(r.calls[0].name, "write_file");
3642
+ assert_eq!(r.calls[0].input["path"], "/p/index.html");
3643
+ let content = r.calls[0].input["content"].as_str().unwrap();
3644
+ assert!(content.contains("<canvas"), "{content}");
3645
+ assert!(content.contains("f(a,b)"), "{content}");
3646
+ }
3647
+
3648
+ #[test]
3649
+ fn gemma_tool_code_keyword_and_print_wrapper() {
3650
+ let defs = tools::defs_for_context(true, 8192);
3651
+ // Keyword arg + a print(...) wrapper Gemma sometimes adds.
3652
+ let text =
3653
+ "```tool_code\nprint(Artifact(title=\"Game\", contents=\"\"\"<html>ok</html>\"\"\"))\n```";
3654
+ let r = normalize_text_tool_calls(
3655
+ Reply {
3656
+ text: text.to_string(),
3657
+ ..Default::default()
3658
+ },
3659
+ &defs,
3660
+ "build it",
3661
+ );
3662
+ assert_eq!(r.calls.len(), 1);
3663
+ assert_eq!(r.calls[0].name, "Artifact");
3664
+ assert_eq!(r.calls[0].input["title"], "Game");
3665
+ assert_eq!(r.calls[0].input["contents"], "<html>ok</html>");
3666
+ }
3667
+
3668
+ #[test]
3669
+ fn python_value_and_arg_parsing_helpers() {
3670
+ assert_eq!(parse_py_value("\"\"\"hi, there\"\"\""), json!("hi, there"));
3671
+ assert_eq!(parse_py_value("'x'"), json!("x"));
3672
+ assert_eq!(parse_py_value("42"), json!(42));
3673
+ assert_eq!(parse_py_value("true"), json!(true));
3674
+ // A single top-level comma inside a triple-quoted string is not a split.
3675
+ let args = parse_python_args("\"a\", \"\"\"b, c\"\"\"");
3676
+ assert_eq!(args.len(), 2);
3677
+ assert_eq!(args[0].1, json!("a"));
3678
+ assert_eq!(args[1].1, json!("b, c"));
3679
+ // kw_eq only fires before a string literal, not on `==` in content.
3680
+ assert!(kw_eq_pos("contents=\"x\"").is_some());
3681
+ assert!(kw_eq_pos("\"a == b\"").is_none());
3682
+ }
3683
+
3349
3684
  #[test]
3350
3685
  fn balanced_json_object_respects_strings_and_truncation() {
3351
3686
  assert_eq!(
@@ -195,6 +195,134 @@ pub fn stream(
195
195
  request(p, msgs, tools, true, on_text, on_thinking)
196
196
  }
197
197
 
198
+ // A restrictive chat template (notably Gemma's on llama.cpp) rejected the
199
+ // standard message roles: it can't render the `system`/`tool` roles or requires
200
+ // strictly alternating user/assistant turns. Detected from the server's 400 so
201
+ // the retry only fires for models that need it (qwen's template accepts them).
202
+ fn is_template_role_error(e: &str) -> bool {
203
+ let l = e.to_lowercase();
204
+ l.contains("roles must alternate")
205
+ || l.contains("conversation roles")
206
+ || l.contains("unable to generate parser")
207
+ || l.contains("does not support")
208
+ || (l.contains("template") && l.contains("role"))
209
+ }
210
+
211
+ // Flatten a message's content (string or multimodal parts) to plain text.
212
+ fn message_text(content: &Value) -> String {
213
+ if let Some(s) = content.as_str() {
214
+ return s.to_string();
215
+ }
216
+ if let Some(arr) = content.as_array() {
217
+ return arr
218
+ .iter()
219
+ .filter_map(|p| p["text"].as_str())
220
+ .collect::<Vec<_>>()
221
+ .join(" ");
222
+ }
223
+ String::new()
224
+ }
225
+
226
+ // Rewrite the request body's messages into the shape restrictive templates
227
+ // accept: strictly alternating user/assistant with no `system`/`tool` roles.
228
+ // System and tool-result content fold into user turns, an assistant turn's
229
+ // tool_calls render as text, consecutive same-role turns merge, and the native
230
+ // `tools` field is dropped — the model then emits tool_code/text calls, which
231
+ // the agent's recovery parser handles.
232
+ fn flatten_openai_messages(body: &mut Value) {
233
+ let Some(msgs) = body["messages"].as_array() else {
234
+ return;
235
+ };
236
+ let mut folded: Vec<(String, String)> = Vec::new();
237
+ for m in msgs {
238
+ let role = m["role"].as_str().unwrap_or("user");
239
+ let text = message_text(&m["content"]);
240
+ match role {
241
+ // Keep only the assistant's own prose. A prior tool_call is NOT
242
+ // rendered as text — a small model will imitate whatever call-shaped
243
+ // marker it sees and stop making real calls. The following "Tool
244
+ // result:" user turn already conveys what happened.
245
+ "assistant" => {
246
+ if !text.is_empty() {
247
+ folded.push(("assistant".into(), text));
248
+ }
249
+ }
250
+ "tool" => {
251
+ if !text.is_empty() {
252
+ folded.push(("user".into(), format!("Tool result:\n{text}")));
253
+ }
254
+ }
255
+ _ => folded.push(("user".into(), text)),
256
+ }
257
+ }
258
+ // Merge consecutive same-role turns so roles strictly alternate.
259
+ let mut merged: Vec<(String, String)> = Vec::new();
260
+ for (role, text) in folded {
261
+ if let Some(last) = merged.last_mut() {
262
+ if last.0 == role {
263
+ if !text.is_empty() {
264
+ if !last.1.is_empty() {
265
+ last.1.push_str("\n\n");
266
+ }
267
+ last.1.push_str(&text);
268
+ }
269
+ continue;
270
+ }
271
+ }
272
+ merged.push((role, text));
273
+ }
274
+ // These templates require the first turn to be `user`.
275
+ if merged.first().is_some_and(|(r, _)| r == "assistant") {
276
+ merged.insert(0, ("user".into(), String::new()));
277
+ }
278
+ body["messages"] = json!(merged
279
+ .iter()
280
+ .map(|(r, t)| json!({"role": r, "content": t}))
281
+ .collect::<Vec<_>>());
282
+ if let Some(obj) = body.as_object_mut() {
283
+ obj.remove("tools");
284
+ }
285
+ }
286
+
287
+ // Send an OpenAI-compatible request; on a template/role rejection, retry once
288
+ // with a flattened, strictly-alternating message body.
289
+ fn openai_exchange(
290
+ req: ureq::Request,
291
+ mut body: Value,
292
+ streaming: bool,
293
+ on_text: &mut dyn FnMut(&str),
294
+ on_thinking: &mut dyn FnMut(&str),
295
+ ) -> Result<Reply, String> {
296
+ if streaming {
297
+ body["stream"] = json!(true);
298
+ }
299
+ match send_raw(req.clone(), body.clone()) {
300
+ Ok(resp) => finish_openai(resp, streaming, on_text, on_thinking),
301
+ Err(e) if is_template_role_error(&e) => {
302
+ flatten_openai_messages(&mut body);
303
+ let resp = send_raw(req, body)?;
304
+ finish_openai(resp, streaming, on_text, on_thinking)
305
+ }
306
+ Err(e) => Err(e),
307
+ }
308
+ }
309
+
310
+ fn finish_openai(
311
+ resp: ureq::Response,
312
+ streaming: bool,
313
+ on_text: &mut dyn FnMut(&str),
314
+ on_thinking: &mut dyn FnMut(&str),
315
+ ) -> Result<Reply, String> {
316
+ if streaming {
317
+ openai_stream(resp.into_reader(), on_text, on_thinking)
318
+ } else {
319
+ let v = resp
320
+ .into_json::<Value>()
321
+ .map_err(|e| format!("bad JSON from server: {e}"))?;
322
+ openai_parse(v)
323
+ }
324
+ }
325
+
198
326
  fn request(
199
327
  p: &Provider,
200
328
  msgs: &[Msg],
@@ -214,13 +342,8 @@ fn request(
214
342
  }
215
343
  }
216
344
  Protocol::OpenAi => {
217
- let (req, mut body) = openai_request(p, msgs, tools);
218
- if streaming {
219
- body["stream"] = json!(true);
220
- openai_stream(send_raw(req, body)?.into_reader(), on_text, on_thinking)
221
- } else {
222
- openai_parse(send(req, body)?)
223
- }
345
+ let (req, body) = openai_request(p, msgs, tools);
346
+ openai_exchange(req, body, streaming, on_text, on_thinking)
224
347
  }
225
348
  Protocol::OllamaNative => {
226
349
  // The native path exists to set options.num_ctx (unavailable on
@@ -242,13 +365,8 @@ fn request(
242
365
  None => {
243
366
  warn_ollama_fallback();
244
367
  let base = format!("{}/v1", ollama_root(&p.base_url));
245
- let (req, mut body) = openai_request_at(p, &base, msgs, tools);
246
- if streaming {
247
- body["stream"] = json!(true);
248
- openai_stream(send_raw(req, body)?.into_reader(), on_text, on_thinking)
249
- } else {
250
- openai_parse(send(req, body)?)
251
- }
368
+ let (req, body) = openai_request_at(p, &base, msgs, tools);
369
+ openai_exchange(req, body, streaming, on_text, on_thinking)
252
370
  }
253
371
  }
254
372
  }
@@ -1520,6 +1638,67 @@ mod tests {
1520
1638
  );
1521
1639
  }
1522
1640
 
1641
+ #[test]
1642
+ fn template_role_error_detected() {
1643
+ assert!(is_template_role_error(
1644
+ "HTTP 400: Unable to generate parser for this template. Conversation roles must alternate user/assistant"
1645
+ ));
1646
+ assert!(!is_template_role_error("HTTP 500: internal error"));
1647
+ assert!(!is_template_role_error("HTTP 429: rate limited"));
1648
+ }
1649
+
1650
+ #[test]
1651
+ fn flatten_messages_produces_alternating_user_assistant() {
1652
+ // system, user, assistant(+tool_call), tool-result → must collapse to
1653
+ // strictly alternating user/assistant with no system/tool roles.
1654
+ let mut body = json!({
1655
+ "model": "gemma",
1656
+ "tools": [{"type": "function"}],
1657
+ "messages": [
1658
+ {"role": "system", "content": "You are helpful."},
1659
+ {"role": "user", "content": "build it"},
1660
+ {"role": "assistant", "content": "", "tool_calls": [
1661
+ {"id": "1", "type": "function", "function": {"name": "write_file", "arguments": "{\"path\":\"a\"}"}}
1662
+ ]},
1663
+ {"role": "tool", "tool_call_id": "1", "content": "wrote a"}
1664
+ ]
1665
+ });
1666
+ flatten_openai_messages(&mut body);
1667
+ let m = body["messages"].as_array().unwrap();
1668
+ // The empty tool-call-only assistant turn is dropped (no call-shaped
1669
+ // text for a small model to imitate), so the remaining turns are all
1670
+ // user/assistant with none of the system/tool roles.
1671
+ let roles: Vec<&str> = m.iter().map(|x| x["role"].as_str().unwrap()).collect();
1672
+ assert!(roles.iter().all(|r| *r == "user" || *r == "assistant"));
1673
+ let joined: String = m
1674
+ .iter()
1675
+ .map(|x| x["content"].as_str().unwrap_or(""))
1676
+ .collect::<Vec<_>>()
1677
+ .join("\n");
1678
+ assert!(joined.contains("You are helpful"));
1679
+ assert!(joined.contains("build it"));
1680
+ assert!(joined.contains("Tool result"));
1681
+ // A synthetic "[called …]" marker must never leak into the prompt.
1682
+ assert!(!joined.contains("[called"));
1683
+ // The native tools field is dropped for the restrictive template.
1684
+ assert!(body.get("tools").is_none());
1685
+ }
1686
+
1687
+ #[test]
1688
+ fn flatten_messages_prepends_user_when_starting_on_assistant() {
1689
+ let mut body = json!({
1690
+ "messages": [{"role": "assistant", "content": "hi"}]
1691
+ });
1692
+ flatten_openai_messages(&mut body);
1693
+ let roles: Vec<&str> = body["messages"]
1694
+ .as_array()
1695
+ .unwrap()
1696
+ .iter()
1697
+ .map(|x| x["role"].as_str().unwrap())
1698
+ .collect();
1699
+ assert_eq!(roles, vec!["user", "assistant"]);
1700
+ }
1701
+
1523
1702
  #[test]
1524
1703
  fn openai_body_tool_results_each_become_a_message() {
1525
1704
  let msgs = vec![Msg::Tool(vec![
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buildwithnexus",
3
- "version": "0.11.3",
3
+ "version": "0.11.4",
4
4
  "description": "A hilariously fast, agentic AI CLI harness in Rust — remote (API key) or local models, full-screen TUI with sessions, plan/build/brainstorm modes, and hooks",
5
5
  "bin": {
6
6
  "buildwithnexus": "bin/buildwithnexus.js",