sigit-code 1.5.9__tar.gz → 1.5.10__tar.gz

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.
Files changed (95) hide show
  1. {sigit_code-1.5.9 → sigit_code-1.5.10}/CHANGELOG.md +36 -0
  2. {sigit_code-1.5.9 → sigit_code-1.5.10}/Cargo.lock +1 -1
  3. {sigit_code-1.5.9 → sigit_code-1.5.10}/Cargo.toml +1 -1
  4. {sigit_code-1.5.9 → sigit_code-1.5.10}/PKG-INFO +1 -1
  5. {sigit_code-1.5.9 → sigit_code-1.5.10}/src/backend.rs +74 -29
  6. {sigit_code-1.5.9 → sigit_code-1.5.10}/src/chat.rs +3 -7
  7. {sigit_code-1.5.9 → sigit_code-1.5.10}/src/headless.rs +3 -7
  8. {sigit_code-1.5.9 → sigit_code-1.5.10}/src/inline_tool_calls.rs +104 -10
  9. {sigit_code-1.5.9 → sigit_code-1.5.10}/src/main.rs +53 -10
  10. {sigit_code-1.5.9 → sigit_code-1.5.10}/src/provider.rs +8 -0
  11. {sigit_code-1.5.9 → sigit_code-1.5.10}/src/tools.rs +2 -6
  12. {sigit_code-1.5.9 → sigit_code-1.5.10}/tests/acp_permissions.rs +114 -0
  13. {sigit_code-1.5.9 → sigit_code-1.5.10}/.agents/AGENTS.md +0 -0
  14. {sigit_code-1.5.9 → sigit_code-1.5.10}/.agents/skills/agent-client-protocol/SKILL.md +0 -0
  15. {sigit_code-1.5.9 → sigit_code-1.5.10}/.agents/skills/ai-assisted-coding/SKILL.md +0 -0
  16. {sigit_code-1.5.9 → sigit_code-1.5.10}/.agents/skills/branding/SKILL.md +0 -0
  17. {sigit_code-1.5.9 → sigit_code-1.5.10}/.agents/skills/run-sigit/SKILL.md +0 -0
  18. {sigit_code-1.5.9 → sigit_code-1.5.10}/.agents/skills/run-sigit/driver.mjs +0 -0
  19. {sigit_code-1.5.9 → sigit_code-1.5.10}/.agents/skills/run-sigit/tui-smoke.sh +0 -0
  20. {sigit_code-1.5.9 → sigit_code-1.5.10}/.agents/skills/sigit-code-release/SKILL.md +0 -0
  21. {sigit_code-1.5.9 → sigit_code-1.5.10}/.agents/skills/tool-calling/SKILL.md +0 -0
  22. {sigit_code-1.5.9 → sigit_code-1.5.10}/.claude/skills/agent-client-protocol/SKILL.md +0 -0
  23. {sigit_code-1.5.9 → sigit_code-1.5.10}/.claude/skills/ai-assisted-coding/SKILL.md +0 -0
  24. {sigit_code-1.5.9 → sigit_code-1.5.10}/.claude/skills/branding/SKILL.md +0 -0
  25. {sigit_code-1.5.9 → sigit_code-1.5.10}/.claude/skills/run-sigit/SKILL.md +0 -0
  26. {sigit_code-1.5.9 → sigit_code-1.5.10}/.claude/skills/run-sigit/driver.mjs +0 -0
  27. {sigit_code-1.5.9 → sigit_code-1.5.10}/.claude/skills/run-sigit/tui-smoke.sh +0 -0
  28. {sigit_code-1.5.9 → sigit_code-1.5.10}/.claude/skills/sigit-code-release/SKILL.md +0 -0
  29. {sigit_code-1.5.9 → sigit_code-1.5.10}/.claude/skills/tool-calling/SKILL.md +0 -0
  30. {sigit_code-1.5.9 → sigit_code-1.5.10}/.github/workflows/ci.yml +0 -0
  31. {sigit_code-1.5.9 → sigit_code-1.5.10}/.github/workflows/release-aur.yml +0 -0
  32. {sigit_code-1.5.9 → sigit_code-1.5.10}/.github/workflows/release-crates.yml +0 -0
  33. {sigit_code-1.5.9 → sigit_code-1.5.10}/.github/workflows/release-github.yml +0 -0
  34. {sigit_code-1.5.9 → sigit_code-1.5.10}/.github/workflows/release-homebrew.yml +0 -0
  35. {sigit_code-1.5.9 → sigit_code-1.5.10}/.github/workflows/release-npm.yml +0 -0
  36. {sigit_code-1.5.9 → sigit_code-1.5.10}/.github/workflows/release-nuget.yml +0 -0
  37. {sigit_code-1.5.9 → sigit_code-1.5.10}/.github/workflows/release-pypi.yml +0 -0
  38. {sigit_code-1.5.9 → sigit_code-1.5.10}/.github/workflows/release-scoop.yml +0 -0
  39. {sigit_code-1.5.9 → sigit_code-1.5.10}/.github/workflows/release-winget.yml +0 -0
  40. {sigit_code-1.5.9 → sigit_code-1.5.10}/.gitignore +0 -0
  41. {sigit_code-1.5.9 → sigit_code-1.5.10}/.nvmrc +0 -0
  42. {sigit_code-1.5.9 → sigit_code-1.5.10}/AGENTS.md +0 -0
  43. {sigit_code-1.5.9 → sigit_code-1.5.10}/CLAUDE.md +0 -0
  44. {sigit_code-1.5.9 → sigit_code-1.5.10}/LICENSE +0 -0
  45. {sigit_code-1.5.9 → sigit_code-1.5.10}/README.md +0 -0
  46. {sigit_code-1.5.9 → sigit_code-1.5.10}/docs/hooks.md +0 -0
  47. {sigit_code-1.5.9 → sigit_code-1.5.10}/docs/mcp.md +0 -0
  48. {sigit_code-1.5.9 → sigit_code-1.5.10}/examples/settings-with-hooks.toml +0 -0
  49. {sigit_code-1.5.9 → sigit_code-1.5.10}/examples/skills/README.md +0 -0
  50. {sigit_code-1.5.9 → sigit_code-1.5.10}/examples/skills/commit-message/SKILL.md +0 -0
  51. {sigit_code-1.5.9 → sigit_code-1.5.10}/npm/README.md.tmpl +0 -0
  52. {sigit_code-1.5.9 → sigit_code-1.5.10}/npm/package-compat.json.tmpl +0 -0
  53. {sigit_code-1.5.9 → sigit_code-1.5.10}/npm/package-main.json.tmpl +0 -0
  54. {sigit_code-1.5.9 → sigit_code-1.5.10}/npm/package.json.tmpl +0 -0
  55. {sigit_code-1.5.9 → sigit_code-1.5.10}/npm/scripts/render-main-package.cjs +0 -0
  56. {sigit_code-1.5.9 → sigit_code-1.5.10}/npm/scripts/render-platform-package.cjs +0 -0
  57. {sigit_code-1.5.9 → sigit_code-1.5.10}/npm/sigit/.gitignore +0 -0
  58. {sigit_code-1.5.9 → sigit_code-1.5.10}/npm/sigit/README.md +0 -0
  59. {sigit_code-1.5.9 → sigit_code-1.5.10}/npm/sigit/package.json +0 -0
  60. {sigit_code-1.5.9 → sigit_code-1.5.10}/npm/sigit/src/index.ts +0 -0
  61. {sigit_code-1.5.9 → sigit_code-1.5.10}/npm/sigit/tsconfig.json +0 -0
  62. {sigit_code-1.5.9 → sigit_code-1.5.10}/nuget/.gitignore +0 -0
  63. {sigit_code-1.5.9 → sigit_code-1.5.10}/nuget/sigit/Program.cs +0 -0
  64. {sigit_code-1.5.9 → sigit_code-1.5.10}/nuget/sigit/README.md +0 -0
  65. {sigit_code-1.5.9 → sigit_code-1.5.10}/nuget/sigit/SiGit.Code.csproj +0 -0
  66. {sigit_code-1.5.9 → sigit_code-1.5.10}/packaging/aur/PKGBUILD.in +0 -0
  67. {sigit_code-1.5.9 → sigit_code-1.5.10}/packaging/nfpm.yaml +0 -0
  68. {sigit_code-1.5.9 → sigit_code-1.5.10}/packaging/winget/getSigit.siGitCode.installer.yaml.in +0 -0
  69. {sigit_code-1.5.9 → sigit_code-1.5.10}/packaging/winget/getSigit.siGitCode.locale.en-US.yaml.in +0 -0
  70. {sigit_code-1.5.9 → sigit_code-1.5.10}/packaging/winget/getSigit.siGitCode.yaml.in +0 -0
  71. {sigit_code-1.5.9 → sigit_code-1.5.10}/pypi/README.md +0 -0
  72. {sigit_code-1.5.9 → sigit_code-1.5.10}/pypi/pyproject.toml +0 -0
  73. {sigit_code-1.5.9 → sigit_code-1.5.10}/pyproject.toml +0 -0
  74. {sigit_code-1.5.9 → sigit_code-1.5.10}/rust-toolchain.toml +0 -0
  75. {sigit_code-1.5.9 → sigit_code-1.5.10}/src/account.rs +0 -0
  76. {sigit_code-1.5.9 → sigit_code-1.5.10}/src/browser_auth.rs +0 -0
  77. {sigit_code-1.5.9 → sigit_code-1.5.10}/src/commands.rs +0 -0
  78. {sigit_code-1.5.9 → sigit_code-1.5.10}/src/credentials.rs +0 -0
  79. {sigit_code-1.5.9 → sigit_code-1.5.10}/src/frontmatter.rs +0 -0
  80. {sigit_code-1.5.9 → sigit_code-1.5.10}/src/hooks.rs +0 -0
  81. {sigit_code-1.5.9 → sigit_code-1.5.10}/src/instructions.rs +0 -0
  82. {sigit_code-1.5.9 → sigit_code-1.5.10}/src/mcp.rs +0 -0
  83. {sigit_code-1.5.9 → sigit_code-1.5.10}/src/models.rs +0 -0
  84. {sigit_code-1.5.9 → sigit_code-1.5.10}/src/permissions.rs +0 -0
  85. {sigit_code-1.5.9 → sigit_code-1.5.10}/src/session_store.rs +0 -0
  86. {sigit_code-1.5.9 → sigit_code-1.5.10}/src/settings.rs +0 -0
  87. {sigit_code-1.5.9 → sigit_code-1.5.10}/src/setup.rs +0 -0
  88. {sigit_code-1.5.9 → sigit_code-1.5.10}/src/skills.rs +0 -0
  89. {sigit_code-1.5.9 → sigit_code-1.5.10}/src/subagents.rs +0 -0
  90. {sigit_code-1.5.9 → sigit_code-1.5.10}/src/workspace.rs +0 -0
  91. {sigit_code-1.5.9 → sigit_code-1.5.10}/tests/acp_endpoint_errors.rs +0 -0
  92. {sigit_code-1.5.9 → sigit_code-1.5.10}/tests/acp_multi_root.rs +0 -0
  93. {sigit_code-1.5.9 → sigit_code-1.5.10}/tests/acp_session_load.rs +0 -0
  94. {sigit_code-1.5.9 → sigit_code-1.5.10}/tests/acp_tool_stdin.rs +0 -0
  95. {sigit_code-1.5.9 → sigit_code-1.5.10}/tests/headless_mode.rs +0 -0
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.10
4
+
5
+ ### What changed
6
+
7
+ - **Xcode's context no longer buries slash commands.** Xcode sends the user's
8
+ prompt as the last of several text blocks, with project context in the
9
+ blocks before it, so `/models` and friends sat in the middle of joined text
10
+ and were handed to the model instead of dispatching locally. Slash-command
11
+ parsing now searches the prompt's text blocks from the end, so a standalone
12
+ command in the final user block dispatches no matter what context the client
13
+ prepends
14
+ - **The nova tier is the default cloud engine.** When local inference is off
15
+ and no explicit provider override is set, the cloud tier now defaults to
16
+ `nova` (the `onde-nova` model) instead of the balanced tier, in both the
17
+ interactive and headless paths
18
+
19
+ ### Fixed
20
+
21
+ - **Malformed Chinese-model tool calls are handled end-to-end in ACP.** The
22
+ inline-call recovery that 1.5.9 added for Kimi K3's XTML protocol and GLM's
23
+ mis-tagged blocks only applied to the interactive and headless surfaces;
24
+ the ACP path never ran it, so an editor session still surfaced raw protocol
25
+ text and dropped the calls. Recovery now runs in the ACP prompt loop too,
26
+ covered by integration tests, and the tool names are checked against the
27
+ turn's offered tools before anything executes
28
+ - **Suppressed tool-call log spam is gone.** The guard that logs when a
29
+ structured call is dropped for arriving as forced text fired once per
30
+ streamed argument fragment — one malformed call could log a warning per
31
+ chunk. The check now fires once per turn with the count and names of what
32
+ was dropped, and the non-streaming path reports the same
33
+ - **A history-replay test no longer races on the process cwd.** One ACP test
34
+ built its expected path from `std::env::current_dir()` while other tests
35
+ briefly swap that process-global cwd, so on CI it could read a temp
36
+ directory mid-swap and compare a truncated title against an untruncated
37
+ expectation. It now uses a fixed path it never needed to derive
38
+
3
39
  ## 1.5.9
4
40
 
5
41
  ### Fixed
@@ -5778,7 +5778,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
5778
5778
 
5779
5779
  [[package]]
5780
5780
  name = "sigit"
5781
- version = "1.5.9"
5781
+ version = "1.5.10"
5782
5782
  dependencies = [
5783
5783
  "agent-client-protocol",
5784
5784
  "anyhow",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "sigit"
3
- version = "1.5.9"
3
+ version = "1.5.10"
4
4
  edition = "2024"
5
5
  description = "siGit Code — ACP-compatible AI coding agent. Sí, git."
6
6
  documentation = "https://github.com/getsigit/sigit"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sigit-code
3
- Version: 1.5.9
3
+ Version: 1.5.10
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -159,12 +159,15 @@ pub trait InferenceBackend: Send + Sync {
159
159
  sink: Option<&TokenSink>,
160
160
  ) -> Result<TurnResult, BackendError>;
161
161
 
162
- /// Continue the turn by returning tool results. `tools` may be `None` on the
163
- /// final round to force a text answer. `sink` streams that text when set.
162
+ /// Continue the turn by returning tool results. `allow_tool_calls` controls
163
+ /// whether `tools` are offered again; the complete catalog remains available
164
+ /// so a disabled round can recognize and suppress tool-shaped model output.
165
+ /// `sink` streams assistant text when set.
164
166
  async fn send_tool_results(
165
167
  &self,
166
168
  results: Vec<ToolResult>,
167
- tools: Option<&[ToolSpec]>,
169
+ tools: &[ToolSpec],
170
+ allow_tool_calls: bool,
168
171
  sink: Option<&TokenSink>,
169
172
  ) -> Result<TurnResult, BackendError>;
170
173
 
@@ -257,7 +260,8 @@ impl InferenceBackend for LocalBackend {
257
260
  async fn send_tool_results(
258
261
  &self,
259
262
  results: Vec<ToolResult>,
260
- tools: Option<&[ToolSpec]>,
263
+ tools: &[ToolSpec],
264
+ allow_tool_calls: bool,
261
265
  sink: Option<&TokenSink>,
262
266
  ) -> Result<TurnResult, BackendError> {
263
267
  let onde_results: Vec<onde::inference::ToolResult> = results
@@ -268,10 +272,10 @@ impl InferenceBackend for LocalBackend {
268
272
  })
269
273
  .collect();
270
274
 
271
- // The final round passes `tools = None` to force a text answer; that's
272
- // the only round onde can stream, since no further tool calls are parsed.
275
+ // A forced-text round is the only round onde can stream, since no
276
+ // further tool calls are parsed.
273
277
  if let Some(sink) = sink
274
- && tools.is_none()
278
+ && !allow_tool_calls
275
279
  {
276
280
  let rx = self
277
281
  .engine
@@ -281,7 +285,7 @@ impl InferenceBackend for LocalBackend {
281
285
  return drain_onde_stream(rx, sink).await;
282
286
  }
283
287
 
284
- let onde_tools = tools.map(to_onde_tools);
288
+ let onde_tools = allow_tool_calls.then(|| to_onde_tools(tools));
285
289
  let result = self
286
290
  .engine
287
291
  .send_tool_results(onde_results, onde_tools.as_deref())
@@ -485,12 +489,14 @@ impl OpenAiBackend {
485
489
  .collect()
486
490
  }
487
491
 
488
- /// POST the current history (plus `tools`) and apply the assistant reply to
489
- /// history, returning the neutral turn result. Streams via SSE when `sink`
490
- /// is set; otherwise reads a single JSON response.
492
+ /// POST the current history and apply the assistant reply to history.
493
+ /// `tools` is always the known catalog, while `allow_tool_calls` determines
494
+ /// whether it is advertised to the model and whether returned calls may run.
495
+ /// Streams via SSE when `sink` is set; otherwise reads a single JSON response.
491
496
  async fn complete(
492
497
  &self,
493
- tools: Option<&[ToolSpec]>,
498
+ tools: &[ToolSpec],
499
+ allow_tool_calls: bool,
494
500
  sink: Option<&TokenSink>,
495
501
  ) -> Result<TurnResult, BackendError> {
496
502
  let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
@@ -501,9 +507,7 @@ impl OpenAiBackend {
501
507
  "messages": *self.history.lock().await,
502
508
  "stream": streaming,
503
509
  });
504
- if let Some(tools) = tools
505
- && !tools.is_empty()
506
- {
510
+ if allow_tool_calls && !tools.is_empty() {
507
511
  body["tools"] = serde_json::Value::Array(Self::tools_json(tools));
508
512
  // OpenAI specifies `auto` as the default when tools are present,
509
513
  // but not every OpenAI-compatible gateway implements that default.
@@ -528,13 +532,11 @@ impl OpenAiBackend {
528
532
  return Err(describe_api_error(status, &body));
529
533
  }
530
534
 
531
- // The specs are needed downstream to type the arguments of any tool
532
- // call the model emitted as text rather than as a structured call.
533
- let tools = tools.unwrap_or(&[]);
534
535
  if let Some(sink) = sink {
535
- self.consume_stream(response, sink, tools).await
536
+ self.consume_stream(response, sink, tools, allow_tool_calls)
537
+ .await
536
538
  } else {
537
- self.consume_json(response, tools).await
539
+ self.consume_json(response, tools, allow_tool_calls).await
538
540
  }
539
541
  }
540
542
 
@@ -543,6 +545,7 @@ impl OpenAiBackend {
543
545
  &self,
544
546
  response: reqwest::Response,
545
547
  tools: &[ToolSpec],
548
+ allow_tool_calls: bool,
546
549
  ) -> Result<TurnResult, BackendError> {
547
550
  let parsed: ChatCompletion = response
548
551
  .json()
@@ -556,7 +559,7 @@ impl OpenAiBackend {
556
559
  .map(|choice| choice.message)
557
560
  .ok_or_else(|| "endpoint returned no choices".to_string())?;
558
561
 
559
- let text = message.content.clone().unwrap_or_default();
562
+ let mut text = message.content.clone().unwrap_or_default();
560
563
  let tool_calls: Vec<ToolCall> = message
561
564
  .tool_calls
562
565
  .iter()
@@ -568,6 +571,31 @@ impl OpenAiBackend {
568
571
  })
569
572
  .collect();
570
573
 
574
+ if !allow_tool_calls {
575
+ let (cleaned, recovered) = crate::inline_tool_calls::extract(&text, tools);
576
+ text = cleaned;
577
+ let suppressed = tool_calls.len() + recovered.len();
578
+ if suppressed > 0 {
579
+ let names = tool_calls
580
+ .iter()
581
+ .map(|call| call.name.as_str())
582
+ .chain(recovered.iter().map(|call| call.name.as_str()))
583
+ .collect::<Vec<_>>()
584
+ .join(", ");
585
+ log::warn!(
586
+ "suppressed {suppressed} tool call(s) from a forced-text response: {names}"
587
+ );
588
+ }
589
+ self.history
590
+ .lock()
591
+ .await
592
+ .push(streamed_assistant_history(&text, &[]));
593
+ return Ok(TurnResult {
594
+ text,
595
+ tool_calls: Vec::new(),
596
+ });
597
+ }
598
+
571
599
  // Some models write a tool call out as literal `<tool_call>` text
572
600
  // instead of using the structured field (see `inline_tool_calls`).
573
601
  // Recover it, or the turn ends with the tag rendered as prose and
@@ -617,6 +645,7 @@ impl OpenAiBackend {
617
645
  response: reqwest::Response,
618
646
  sink: &TokenSink,
619
647
  tools: &[ToolSpec],
648
+ allow_tool_calls: bool,
620
649
  ) -> Result<TurnResult, BackendError> {
621
650
  use futures::StreamExt;
622
651
 
@@ -691,10 +720,12 @@ impl OpenAiBackend {
691
720
  }
692
721
  }
693
722
  crate::inline_tool_calls::ScanEvent::ToolCall(call) => {
694
- log::warn!(
695
- "recovered tool call '{}' the model emitted as text instead of a structured call",
696
- call.name
697
- );
723
+ if allow_tool_calls {
724
+ log::warn!(
725
+ "recovered tool call '{}' the model emitted as text instead of a structured call",
726
+ call.name
727
+ );
728
+ }
698
729
  recovered.push(ToolCall {
699
730
  id: format!("call_recovered_{}", recovered.len()),
700
731
  name: call.name,
@@ -755,6 +786,19 @@ impl OpenAiBackend {
755
786
  .collect();
756
787
  tool_calls.extend(recovered);
757
788
 
789
+ if !allow_tool_calls && !tool_calls.is_empty() {
790
+ let names = tool_calls
791
+ .iter()
792
+ .map(|call| call.name.as_str())
793
+ .collect::<Vec<_>>()
794
+ .join(", ");
795
+ log::warn!(
796
+ "suppressed {} tool call(s) from a forced-text response: {names}",
797
+ tool_calls.len()
798
+ );
799
+ tool_calls.clear();
800
+ }
801
+
758
802
  // Record the assistant turn so later tool results have context.
759
803
  self.history
760
804
  .lock()
@@ -813,13 +857,14 @@ impl InferenceBackend for OpenAiBackend {
813
857
  .lock()
814
858
  .await
815
859
  .push(serde_json::json!({ "role": "user", "content": text }));
816
- self.complete(Some(tools), sink).await
860
+ self.complete(tools, true, sink).await
817
861
  }
818
862
 
819
863
  async fn send_tool_results(
820
864
  &self,
821
865
  results: Vec<ToolResult>,
822
- tools: Option<&[ToolSpec]>,
866
+ tools: &[ToolSpec],
867
+ allow_tool_calls: bool,
823
868
  sink: Option<&TokenSink>,
824
869
  ) -> Result<TurnResult, BackendError> {
825
870
  {
@@ -832,7 +877,7 @@ impl InferenceBackend for OpenAiBackend {
832
877
  }));
833
878
  }
834
879
  }
835
- self.complete(tools, sink).await
880
+ self.complete(tools, allow_tool_calls, sink).await
836
881
  }
837
882
 
838
883
  async fn record_cancelled_tool_results(&self, results: Vec<ToolResult>) {
@@ -887,7 +932,7 @@ impl InferenceBackend for OpenAiBackend {
887
932
  }));
888
933
  *self.history.lock().await = request;
889
934
 
890
- let summary = match self.complete(None, None).await {
935
+ let summary = match self.complete(&[], false, None).await {
891
936
  Ok(result) => result.text,
892
937
  Err(error) => {
893
938
  // Roll back the summarization request; the turn never happened.
@@ -3346,12 +3346,8 @@ mod tui {
3346
3346
 
3347
3347
  // on the last round, pass no tools so the model must produce text —
3348
3348
  // that's also the round we can stream on-device.
3349
- let next_tools = if round < MAX_TOOL_ROUNDS {
3350
- Some(tools.as_slice())
3351
- } else {
3352
- None
3353
- };
3354
- let sink = if next_tools.is_none() {
3349
+ let allow_tool_calls = round < MAX_TOOL_ROUNDS;
3350
+ let sink = if !allow_tool_calls {
3355
3351
  streamed = true;
3356
3352
  Some(&delta_tx)
3357
3353
  } else {
@@ -3359,7 +3355,7 @@ mod tui {
3359
3355
  };
3360
3356
 
3361
3357
  match backend
3362
- .send_tool_results(tool_results, next_tools, sink)
3358
+ .send_tool_results(tool_results, &tools, allow_tool_calls, sink)
3363
3359
  .await
3364
3360
  {
3365
3361
  Ok(r) => result = r,
@@ -170,7 +170,7 @@ pub async fn run(config: HeadlessConfig) -> i32 {
170
170
  if settings::local_inference_enabled() {
171
171
  None
172
172
  } else {
173
- provider::cloud_tier_provider("balanced")
173
+ provider::cloud_tier_provider(provider::DEFAULT_CLOUD_TIER)
174
174
  }
175
175
  });
176
176
  let Some(cfg) = provider_cfg else {
@@ -304,18 +304,14 @@ async fn run_prompt(
304
304
  });
305
305
  }
306
306
 
307
- let next_tools = if round < crate::MAX_TOOL_ROUNDS {
308
- Some(tools.as_slice())
309
- } else {
310
- None // last round: force text
311
- };
307
+ let allow_tool_calls = round < crate::MAX_TOOL_ROUNDS;
312
308
 
313
309
  // Whatever this round says starts a new paragraph rather than
314
310
  // continuing the sentence the tool calls interrupted.
315
311
  reply.interrupt();
316
312
 
317
313
  result = drain_to_stdout(
318
- backend.send_tool_results(tool_results, next_tools, sink_opt),
314
+ backend.send_tool_results(tool_results, &tools, allow_tool_calls, sink_opt),
319
315
  &mut sink_rx,
320
316
  &mut reply,
321
317
  )
@@ -83,16 +83,22 @@ pub fn extract(text: &str, tools: &[ToolSpec]) -> (String, Vec<Recovered>) {
83
83
  fn parse_xml_block(inner: &str, tools: &[ToolSpec]) -> Option<Recovered> {
84
84
  let key_idx = inner.find("<arg_key>");
85
85
  let malformed_key_idx = inner.find(XML_OPEN_TAG);
86
- let (name, mut rest, malformed_first_key) = match (key_idx, malformed_key_idx) {
87
- (Some(key_idx), Some(malformed_key_idx)) if malformed_key_idx < key_idx => (
88
- inner[..malformed_key_idx].trim(),
89
- &inner[malformed_key_idx..],
90
- true,
91
- ),
92
- (Some(idx), _) => (inner[..idx].trim(), &inner[idx..], false),
93
- (None, Some(idx)) => (inner[..idx].trim(), &inner[idx..], true),
94
- (None, None) => (inner.trim(), "", false),
95
- };
86
+ let check_status_name = key_idx.and_then(|idx| parse_check_status_preamble(&inner[..idx]));
87
+ let (name, mut rest, malformed_first_key) =
88
+ if let (Some(idx), Some(name)) = (key_idx, check_status_name) {
89
+ (name, &inner[idx..], false)
90
+ } else {
91
+ match (key_idx, malformed_key_idx) {
92
+ (Some(key_idx), Some(malformed_key_idx)) if malformed_key_idx < key_idx => (
93
+ inner[..malformed_key_idx].trim(),
94
+ &inner[malformed_key_idx..],
95
+ true,
96
+ ),
97
+ (Some(idx), _) => (inner[..idx].trim(), &inner[idx..], false),
98
+ (None, Some(idx)) => (inner[..idx].trim(), &inner[idx..], true),
99
+ (None, None) => (inner.trim(), "", false),
100
+ }
101
+ };
96
102
  if name.is_empty() || name.contains(['<', '>']) || !offered_tool(tools, name) {
97
103
  return None;
98
104
  }
@@ -132,6 +138,24 @@ fn parse_xml_block(inner: &str, tools: &[ToolSpec]) -> Option<Recovered> {
132
138
  })
133
139
  }
134
140
 
141
+ /// Some GLM-family responses prefix the real arguments with generation-control
142
+ /// metadata shaped like `tool_name CheckStatus=value</arg_value>`. It is not a
143
+ /// tool argument: accept that exact bounded preamble and discard it, while
144
+ /// leaving every other malformed prefix untouched.
145
+ fn parse_check_status_preamble(prefix: &str) -> Option<&str> {
146
+ let (name, status) = prefix.trim().split_once(" CheckStatus=")?;
147
+ let status = status.strip_suffix("</arg_value>")?;
148
+ if name.is_empty()
149
+ || status.is_empty()
150
+ || !status
151
+ .chars()
152
+ .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
153
+ {
154
+ return None;
155
+ }
156
+ Some(name)
157
+ }
158
+
135
159
  fn parse_k3_tools_block(inner: &str, tools: &[ToolSpec]) -> Option<Vec<Recovered>> {
136
160
  let mut calls = Vec::new();
137
161
  let mut rest = inner;
@@ -565,6 +589,47 @@ mod tests {
565
589
  assert_eq!(args["cwd"], "/tmp");
566
590
  }
567
591
 
592
+ #[test]
593
+ fn recovers_glm_call_with_check_status_before_real_arguments() {
594
+ let tools = vec![command_output_spec()];
595
+ let (text, calls) = extract(
596
+ "polling <tool_call>command_output CheckStatus=true_or_poll_again_with_different_params</arg_value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_call>",
597
+ &tools,
598
+ );
599
+
600
+ assert_eq!(text, "polling ");
601
+ assert_eq!(calls.len(), 1);
602
+ assert_eq!(calls[0].name, "command_output");
603
+ let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
604
+ assert_eq!(args["task_id"], 1);
605
+ assert!(args["task_id"].is_number());
606
+ assert!(args.get("CheckStatus").is_none());
607
+ }
608
+
609
+ #[test]
610
+ fn malformed_check_status_preamble_is_left_as_text() {
611
+ let text = "<tool_call>command_output CheckStatus=keep going</arg_value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_call>";
612
+ let (out, calls) = extract(text, &[command_output_spec()]);
613
+ assert_eq!(out, text);
614
+ assert!(calls.is_empty());
615
+ }
616
+
617
+ #[test]
618
+ fn check_status_after_a_real_argument_is_left_as_text() {
619
+ let text = "<tool_call>command_output<arg_key>task_id</arg_key><arg_value>1</arg_value> CheckStatus=true_or_poll_again_with_different_params</arg_value></tool_call>";
620
+ let (out, calls) = extract(text, &[command_output_spec()]);
621
+ assert_eq!(out, text);
622
+ assert!(calls.is_empty());
623
+ }
624
+
625
+ #[test]
626
+ fn check_status_for_an_unknown_tool_is_left_as_text() {
627
+ let text = "<tool_call>not_offered CheckStatus=true_or_poll_again_with_different_params</arg_value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_call>";
628
+ let (out, calls) = extract(text, &[command_output_spec()]);
629
+ assert_eq!(out, text);
630
+ assert!(calls.is_empty());
631
+ }
632
+
568
633
  #[test]
569
634
  fn unknown_glm_tool_is_left_as_text() {
570
635
  let text = "<tool_call>not_offered<tool_call>command</arg_key><arg_value>pwd</arg_value></tool_call>";
@@ -773,6 +838,35 @@ mod tests {
773
838
  assert_eq!(args["command"], "pwd");
774
839
  }
775
840
 
841
+ #[test]
842
+ fn scanner_recovers_check_status_call_split_across_chunks() {
843
+ let tools = vec![command_output_spec()];
844
+ let mut scanner = StreamScanner::new(&tools);
845
+ let mut text = String::new();
846
+ let mut calls = Vec::new();
847
+
848
+ for chunk in [
849
+ "before <tool_call>command_output CheckStatus=true_or_poll_",
850
+ "again_with_different_params</arg_",
851
+ "value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_",
852
+ "call> after",
853
+ ] {
854
+ for event in scanner.push(chunk) {
855
+ match event {
856
+ ScanEvent::Text(value) => text.push_str(&value),
857
+ ScanEvent::ToolCall(call) => calls.push(call),
858
+ }
859
+ }
860
+ }
861
+ if let Some(rest) = scanner.take_pending() {
862
+ text.push_str(&rest);
863
+ }
864
+
865
+ assert_eq!(text, "before after");
866
+ assert_eq!(calls.len(), 1);
867
+ assert_eq!(calls[0].name, "command_output");
868
+ }
869
+
776
870
  #[test]
777
871
  fn scanner_recovers_kimi_k3_tools_split_across_chunk_boundaries() {
778
872
  let tools = vec![run_command_spec()];
@@ -1733,7 +1733,15 @@ impl SiGitAgent {
1733
1733
  return Ok(PromptResponse::new(StopReason::EndTurn));
1734
1734
  }
1735
1735
 
1736
- let user_text = match parse_slash(&user_text) {
1736
+ let slash_command = parse_slash_from_prompt_texts(args.prompt.iter().filter_map(|block| {
1737
+ if let ContentBlock::Text(text) = block {
1738
+ Some(text.text.as_str())
1739
+ } else {
1740
+ None
1741
+ }
1742
+ }));
1743
+
1744
+ let user_text = match slash_command {
1737
1745
  // `/init` is not a status command: it substitutes the canned
1738
1746
  // AGENTS.md-generation prompt and runs a normal agent turn, so the
1739
1747
  // exploration and file write go through the ordinary tools and
@@ -2036,11 +2044,7 @@ impl SiGitAgent {
2036
2044
  });
2037
2045
  }
2038
2046
 
2039
- let next_tools = if round < MAX_TOOL_ROUNDS && !force_text {
2040
- Some(tools.as_slice())
2041
- } else {
2042
- None // last round: force text
2043
- };
2047
+ let allow_tool_calls = round < MAX_TOOL_ROUNDS && !force_text;
2044
2048
 
2045
2049
  // Whatever this round says starts a new paragraph rather than
2046
2050
  // continuing the sentence the tool calls interrupted.
@@ -2050,7 +2054,7 @@ impl SiGitAgent {
2050
2054
  .drain_turn(
2051
2055
  cx,
2052
2056
  &session_id,
2053
- backend.send_tool_results(tool_results, next_tools, Some(&sink)),
2057
+ backend.send_tool_results(tool_results, &tools, allow_tool_calls, Some(&sink)),
2054
2058
  &mut sink_rx,
2055
2059
  &mut reply,
2056
2060
  )
@@ -2248,7 +2252,11 @@ impl SiGitAgent {
2248
2252
  if settings::local_inference_enabled() {
2249
2253
  return;
2250
2254
  }
2251
- if self.switch_to_cloud_tier("balanced").await.is_some() {
2255
+ if self
2256
+ .switch_to_cloud_tier(provider::DEFAULT_CLOUD_TIER)
2257
+ .await
2258
+ .is_some()
2259
+ {
2252
2260
  log::info!("startup: local inference off; routing inference to siGit Code Cloud");
2253
2261
  } else {
2254
2262
  log::warn!(
@@ -3032,6 +3040,15 @@ fn parse_slash(input: &str) -> Option<SlashCommand> {
3032
3040
  })
3033
3041
  }
3034
3042
 
3043
+ /// ACP clients may prepend context as separate text blocks before the user's
3044
+ /// input. Search from the end so a standalone slash command in the final user
3045
+ /// block is still dispatched locally instead of being buried in joined context.
3046
+ fn parse_slash_from_prompt_texts<'a>(
3047
+ texts: impl DoubleEndedIterator<Item = &'a str>,
3048
+ ) -> Option<SlashCommand> {
3049
+ texts.rev().find_map(parse_slash)
3050
+ }
3051
+
3035
3052
  /// `on`/`off` (and synonyms) → `Some(bool)`; missing or unrecognized → `None`
3036
3053
  /// (meaning "toggle the current value").
3037
3054
  fn parse_on_off(arg: Option<&str>) -> Option<bool> {
@@ -3645,7 +3662,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
3645
3662
  let cloud_when_off = if settings::local_inference_enabled() {
3646
3663
  None
3647
3664
  } else {
3648
- provider::cloud_tier_provider("balanced")
3665
+ provider::cloud_tier_provider(provider::DEFAULT_CLOUD_TIER)
3649
3666
  };
3650
3667
 
3651
3668
  match cloud_when_off {
@@ -4176,7 +4193,19 @@ mod tests {
4176
4193
 
4177
4194
  #[test]
4178
4195
  fn history_replay_redraws_the_conversation_for_the_client() {
4179
- let changelog_path = std::env::current_dir().unwrap().join("CHANGELOG.md");
4196
+ // A fixed path, not `std::env::current_dir()`: the cwd is process-global
4197
+ // and a handful of other tests (skills/commands/subagents discovery,
4198
+ // the subagent end-to-end test) briefly `set_current_dir` to a temp
4199
+ // directory behind `ENV_TEST_LOCK`. This test asserts on the exact
4200
+ // rendered title, so racing one of them mid-run doesn't just read a
4201
+ // stale value — it can hand back a temp path long enough to trip the
4202
+ // title's truncation, breaking an assertion that has nothing to do
4203
+ // with the cwd. A literal path sidesteps the shared state entirely.
4204
+ let changelog_path = if cfg!(windows) {
4205
+ PathBuf::from(r"C:\repo\CHANGELOG.md")
4206
+ } else {
4207
+ PathBuf::from("/repo/CHANGELOG.md")
4208
+ };
4180
4209
  let changelog_path_text = changelog_path.to_string_lossy().into_owned();
4181
4210
  let arguments = serde_json::json!({ "path": changelog_path_text }).to_string();
4182
4211
  let history = vec![
@@ -4411,6 +4440,20 @@ mod tests {
4411
4440
  assert!(matches!(parse_slash(" /init "), Some(SlashCommand::Init)));
4412
4441
  }
4413
4442
 
4443
+ #[test]
4444
+ fn parse_slash_finds_command_after_client_context_blocks() {
4445
+ let texts = [
4446
+ "<system-reminder>Xcode context</system-reminder>",
4447
+ "Project structure: App/App.swift",
4448
+ "/models",
4449
+ ];
4450
+
4451
+ assert!(matches!(
4452
+ parse_slash_from_prompt_texts(texts.into_iter()),
4453
+ Some(SlashCommand::Models(None))
4454
+ ));
4455
+ }
4456
+
4414
4457
  #[test]
4415
4458
  fn system_prompt_points_issue_and_pr_work_at_the_official_mcp_tools() {
4416
4459
  // The guidance must reference the exact namespaced names the official
@@ -43,6 +43,8 @@ pub const CLOUD_TIERS: &[&str] = &[
43
43
  "nova",
44
44
  ];
45
45
 
46
+ pub const DEFAULT_CLOUD_TIER: &str = "nova";
47
+
46
48
  /// Base URL of the siGit Code Cloud inference endpoint. Override with
47
49
  /// `SIGIT_CLOUD_URL` (dev: `http://localhost:8090/v1`).
48
50
  pub fn cloud_base_url() -> String {
@@ -259,4 +261,10 @@ mod tests {
259
261
  assert_eq!(non_empty(Some(" x ".to_string())), Some("x".to_string()));
260
262
  assert_eq!(non_empty(None), None);
261
263
  }
264
+
265
+ #[test]
266
+ fn maps_default_cloud_tier_to_wire_model_id() {
267
+ assert_eq!(DEFAULT_CLOUD_TIER, "nova");
268
+ assert_eq!(tier_to_model(DEFAULT_CLOUD_TIER), "onde-nova");
269
+ }
262
270
  }
@@ -939,13 +939,9 @@ async fn run_subagent(backend: &dyn InferenceBackend, prompt: &str, allowed: &[S
939
939
  }
940
940
 
941
941
  // On the last round, offer no tools so the model must produce text.
942
- let next_tools = if round < SUBAGENT_MAX_TOOL_ROUNDS {
943
- Some(specs.as_slice())
944
- } else {
945
- None
946
- };
942
+ let allow_tool_calls = round < SUBAGENT_MAX_TOOL_ROUNDS;
947
943
  result = match backend
948
- .send_tool_results(tool_results, next_tools, None)
944
+ .send_tool_results(tool_results, &specs, allow_tool_calls, None)
949
945
  .await
950
946
  {
951
947
  Ok(r) => r,
@@ -943,6 +943,120 @@ fn a_tool_call_emitted_as_text_is_executed_rather_than_rendered() {
943
943
  let _ = std::fs::remove_dir_all(&scratch);
944
944
  }
945
945
 
946
+ /// Once the repetition guard removes tools from a request, a model may still
947
+ /// emit its learned tool syntax. Known calls must be removed from the visible
948
+ /// response without being executed or recorded as orphaned history entries.
949
+ #[test]
950
+ fn forced_text_suppresses_glm_check_status_tool_markup() {
951
+ let repeated_arguments = json!({"task_id": 1}).to_string();
952
+ let endpoint = start_fake_endpoint(vec![
953
+ sse_tool_call("call_1", "command_output", &repeated_arguments),
954
+ sse_tool_call("call_2", "command_output", &repeated_arguments),
955
+ sse_tool_call("call_3", "command_output", &repeated_arguments),
956
+ sse_body(&[
957
+ json!({"choices": [{"delta": {"content": "Build is still running. <tool_call>command_output CheckStatus=true_or_poll_"}}]}),
958
+ json!({"choices": [{"delta": {"content": "again_with_different_params</arg_value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_call>"}}]}),
959
+ // Also enforce the forced-text boundary for a structured call.
960
+ json!({"choices": [{"delta": {"tool_calls": [{
961
+ "index": 0,
962
+ "id": "call_4",
963
+ "function": {"name": "command_output", "arguments": "{\"task_id\":999}"},
964
+ }]}}]}),
965
+ ]),
966
+ sse_text("Next turn."),
967
+ ]);
968
+
969
+ let scratch =
970
+ std::env::temp_dir().join(format!("sigit_acp_forced_text_{}", std::process::id()));
971
+ let config_dir = scratch.join("config");
972
+ let cwd = scratch.join("cwd");
973
+ std::fs::create_dir_all(&config_dir).unwrap();
974
+ std::fs::create_dir_all(&cwd).unwrap();
975
+
976
+ let mut agent = spawn_agent(endpoint.port, &config_dir);
977
+
978
+ let id = agent.request(
979
+ "initialize",
980
+ json!({"protocolVersion": 1, "clientCapabilities": {}}),
981
+ );
982
+ agent.wait_for_response(id);
983
+
984
+ let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
985
+ let session_id = agent.wait_for_response(id)["result"]["sessionId"]
986
+ .as_str()
987
+ .expect("session id")
988
+ .to_string();
989
+
990
+ let prompt_id = agent.request(
991
+ "session/prompt",
992
+ json!({
993
+ "sessionId": session_id,
994
+ "prompt": [{"type": "text", "text": "keep polling"}],
995
+ }),
996
+ );
997
+ let (response, rendered) = agent.wait_for_prompt(prompt_id);
998
+ assert_eq!(response["result"]["stopReason"], "end_turn");
999
+ assert!(
1000
+ rendered.contains("Build is still running."),
1001
+ "surrounding prose should remain visible: {rendered:?}"
1002
+ );
1003
+ assert!(
1004
+ !rendered.contains("<tool_call>")
1005
+ && !rendered.contains("<arg_key>")
1006
+ && !rendered.contains("CheckStatus"),
1007
+ "forced tool markup reached the client: {rendered:?}"
1008
+ );
1009
+
1010
+ {
1011
+ let requests = endpoint.requests.lock().unwrap();
1012
+ assert_eq!(
1013
+ requests.len(),
1014
+ 4,
1015
+ "a forced-text tool call must not start another inference round"
1016
+ );
1017
+ assert!(
1018
+ requests[3].get("tools").is_none(),
1019
+ "the repetition guard must not advertise tools: {:?}",
1020
+ requests[3]
1021
+ );
1022
+ }
1023
+
1024
+ // Start another turn so the prior assistant message is replayed and its
1025
+ // sanitized history shape can be inspected in the recorded request.
1026
+ let next_prompt_id = agent.request(
1027
+ "session/prompt",
1028
+ json!({
1029
+ "sessionId": session_id,
1030
+ "prompt": [{"type": "text", "text": "what happened?"}],
1031
+ }),
1032
+ );
1033
+ agent.wait_for_prompt(next_prompt_id);
1034
+
1035
+ let requests = endpoint.requests.lock().unwrap();
1036
+ let replayed_messages = requests[4]["messages"].as_array().expect("messages");
1037
+ let prior_assistant = replayed_messages
1038
+ .iter()
1039
+ .rev()
1040
+ .find(|message| {
1041
+ message["role"] == "assistant"
1042
+ && message["content"]
1043
+ .as_str()
1044
+ .is_some_and(|content| content.contains("Build is still running."))
1045
+ })
1046
+ .expect("sanitized forced-text assistant response");
1047
+ assert!(prior_assistant.get("tool_calls").is_none());
1048
+ assert!(
1049
+ !prior_assistant["content"]
1050
+ .as_str()
1051
+ .expect("assistant content")
1052
+ .contains("<tool_call>")
1053
+ );
1054
+ drop(requests);
1055
+
1056
+ drop(agent);
1057
+ let _ = std::fs::remove_dir_all(&scratch);
1058
+ }
1059
+
946
1060
  /// Kimi K3 may emit tool calls in XTML content blocks even when the endpoint is
947
1061
  /// OpenAI-compatible. The backend should recover those before ACP sees them,
948
1062
  /// unwrap visible response text, and hide private thinking text.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes