sigit-code 1.3.1__tar.gz → 1.3.2__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 (56) hide show
  1. {sigit_code-1.3.1 → sigit_code-1.3.2}/CHANGELOG.md +17 -0
  2. {sigit_code-1.3.1 → sigit_code-1.3.2}/CLAUDE.md +36 -12
  3. {sigit_code-1.3.1 → sigit_code-1.3.2}/Cargo.lock +1 -1
  4. {sigit_code-1.3.1 → sigit_code-1.3.2}/Cargo.toml +1 -1
  5. {sigit_code-1.3.1 → sigit_code-1.3.2}/PKG-INFO +1 -1
  6. {sigit_code-1.3.1 → sigit_code-1.3.2}/src/backend.rs +394 -6
  7. {sigit_code-1.3.1 → sigit_code-1.3.2}/src/chat.rs +300 -6
  8. {sigit_code-1.3.1 → sigit_code-1.3.2}/src/main.rs +391 -26
  9. sigit_code-1.3.2/src/permissions.rs +337 -0
  10. {sigit_code-1.3.1 → sigit_code-1.3.2}/src/provider.rs +5 -5
  11. sigit_code-1.3.2/src/session_store.rs +167 -0
  12. {sigit_code-1.3.1 → sigit_code-1.3.2}/src/settings.rs +134 -5
  13. {sigit_code-1.3.1 → sigit_code-1.3.2}/src/tools.rs +1224 -25
  14. sigit_code-1.3.2/tests/acp_permissions.rs +353 -0
  15. {sigit_code-1.3.1 → sigit_code-1.3.2}/.agents/AGENTS.md +0 -0
  16. {sigit_code-1.3.1 → sigit_code-1.3.2}/.agents/skills/agent-client-protocol/SKILL.md +0 -0
  17. {sigit_code-1.3.1 → sigit_code-1.3.2}/.agents/skills/ai-assisted-coding/SKILL.md +0 -0
  18. {sigit_code-1.3.1 → sigit_code-1.3.2}/.agents/skills/branding/SKILL.md +0 -0
  19. {sigit_code-1.3.1 → sigit_code-1.3.2}/.agents/skills/sigit-code-release/SKILL.md +0 -0
  20. {sigit_code-1.3.1 → sigit_code-1.3.2}/.agents/skills/tool-calling/SKILL.md +0 -0
  21. {sigit_code-1.3.1 → sigit_code-1.3.2}/.claude/skills/run-sigit/SKILL.md +0 -0
  22. {sigit_code-1.3.1 → sigit_code-1.3.2}/.claude/skills/run-sigit/driver.mjs +0 -0
  23. {sigit_code-1.3.1 → sigit_code-1.3.2}/.claude/skills/run-sigit/tui-smoke.sh +0 -0
  24. {sigit_code-1.3.1 → sigit_code-1.3.2}/.github/workflows/ci.yml +0 -0
  25. {sigit_code-1.3.1 → sigit_code-1.3.2}/.github/workflows/release-crates.yml +0 -0
  26. {sigit_code-1.3.1 → sigit_code-1.3.2}/.github/workflows/release-github.yml +0 -0
  27. {sigit_code-1.3.1 → sigit_code-1.3.2}/.github/workflows/release-homebrew.yml +0 -0
  28. {sigit_code-1.3.1 → sigit_code-1.3.2}/.github/workflows/release-npm.yml +0 -0
  29. {sigit_code-1.3.1 → sigit_code-1.3.2}/.github/workflows/release-pypi.yml +0 -0
  30. {sigit_code-1.3.1 → sigit_code-1.3.2}/.gitignore +0 -0
  31. {sigit_code-1.3.1 → sigit_code-1.3.2}/.nvmrc +0 -0
  32. {sigit_code-1.3.1 → sigit_code-1.3.2}/LICENSE +0 -0
  33. {sigit_code-1.3.1 → sigit_code-1.3.2}/README.md +0 -0
  34. {sigit_code-1.3.1 → sigit_code-1.3.2}/examples/skills/README.md +0 -0
  35. {sigit_code-1.3.1 → sigit_code-1.3.2}/examples/skills/commit-message/SKILL.md +0 -0
  36. {sigit_code-1.3.1 → sigit_code-1.3.2}/npm/README.md.tmpl +0 -0
  37. {sigit_code-1.3.1 → sigit_code-1.3.2}/npm/package-main.json.tmpl +0 -0
  38. {sigit_code-1.3.1 → sigit_code-1.3.2}/npm/package.json.tmpl +0 -0
  39. {sigit_code-1.3.1 → sigit_code-1.3.2}/npm/scripts/render-main-package.cjs +0 -0
  40. {sigit_code-1.3.1 → sigit_code-1.3.2}/npm/scripts/render-platform-package.cjs +0 -0
  41. {sigit_code-1.3.1 → sigit_code-1.3.2}/npm/sigit/.gitignore +0 -0
  42. {sigit_code-1.3.1 → sigit_code-1.3.2}/npm/sigit/README.md +0 -0
  43. {sigit_code-1.3.1 → sigit_code-1.3.2}/npm/sigit/package.json +0 -0
  44. {sigit_code-1.3.1 → sigit_code-1.3.2}/npm/sigit/src/index.ts +0 -0
  45. {sigit_code-1.3.1 → sigit_code-1.3.2}/npm/sigit/tsconfig.json +0 -0
  46. {sigit_code-1.3.1 → sigit_code-1.3.2}/pypi/README.md +0 -0
  47. {sigit_code-1.3.1 → sigit_code-1.3.2}/pypi/pyproject.toml +0 -0
  48. {sigit_code-1.3.1 → sigit_code-1.3.2}/pyproject.toml +0 -0
  49. {sigit_code-1.3.1 → sigit_code-1.3.2}/rust-toolchain.toml +0 -0
  50. {sigit_code-1.3.1 → sigit_code-1.3.2}/src/account.rs +0 -0
  51. {sigit_code-1.3.1 → sigit_code-1.3.2}/src/credentials.rs +0 -0
  52. {sigit_code-1.3.1 → sigit_code-1.3.2}/src/instructions.rs +0 -0
  53. {sigit_code-1.3.1 → sigit_code-1.3.2}/src/mcp.rs +0 -0
  54. {sigit_code-1.3.1 → sigit_code-1.3.2}/src/models.rs +0 -0
  55. {sigit_code-1.3.1 → sigit_code-1.3.2}/src/setup.rs +0 -0
  56. {sigit_code-1.3.1 → sigit_code-1.3.2}/src/skills.rs +0 -0
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.2
4
+
5
+ Adds a tool permission system with plan mode, durable sessions with context
6
+ compaction, background command execution, a subagent research tool, and commit
7
+ co-author attribution.
8
+
9
+ ### What changed
10
+
11
+ - Every tool call now passes a permission policy before executing. Read-only tools always run; mutating tools (and all MCP or unknown tools) are governed by, in order: plan mode, session grants, per-tool overrides, and a default mode from `[permissions]` in `settings.toml` (`allow`/`ask`/`deny`, default `ask`). On `ask`, editors get a native ACP permission dialog (allow once / allow for this session / deny) and the TUI pauses on a y/a/n prompt showing the tool and its arguments. `SIGIT_PERMISSIONS` overrides the default mode for headless runs and clients without permission support
12
+ - New `/plan [on|off]` command: plan mode blocks mutating tools and asks the model to present a plan while research tools keep working. `/permissions` prints the effective policy
13
+ - Sessions are durable: conversation history is saved per session under `~/.config/sigit/sessions/` after every turn. ACP `session/load` actually restores it, the TUI gets `/resume`, and `/clear` deletes the saved file
14
+ - Context compaction: `/compact` compresses the conversation on demand, and the agent compacts automatically once the history approaches a 24k-token budget, summarizing older turns and keeping the recent ones. The tool-round cap rises from 10 to 24 now that long sessions have a defense other than the cap
15
+ - `run_command` can run work in the background: pass `run_in_background` and the tool returns a task id immediately, so builds, test suites, and dev servers are no longer killed by the 120 second foreground timeout. Poll with the new `command_output` tool (read-only, never prompts) and stop with `kill_command`
16
+ - New `task` tool: delegate research to a fresh subagent conversation that only gets the read-only tools and returns its final answer, keeping the main context small. Available on OpenAI-compatible backends; on-device returns a clear fallback until onde supports a second context
17
+ - Commits created by the agent are co-authored: commit messages end with `Co-Authored-By: siGit Code <sigit@sigit.si>` (the [sigitc](https://github.com/sigitc) account), which GitHub renders next to the human author. If the model forgets the trailer, siGit amends it in, never rewriting commits that already exist on a remote
18
+ - ACP mode now honors the `OPENAI_BASE_URL`/`OPENAI_API_KEY` provider override at startup, matching the interactive client
19
+
3
20
  ## 1.3.1
4
21
 
5
22
  Adds [Model Context Protocol](https://modelcontextprotocol.io) (MCP) client
@@ -19,14 +19,22 @@ Before the TTY/ACP split, `main` also dispatches the account subcommands `sigit
19
19
 
20
20
  ## Working in this repo
21
21
 
22
- **IMPORTANT — branch naming:** Name every working branch after the *changes it contains*, not
23
- after a task, ticket, or session id. Use a short, descriptive, kebab-case slug so the branch is
24
- self-explanatory from its name alone (e.g. `claude/agent-tools-multiedit-glob-todos-remember`,
25
- not `claude/task-q003hm`).
22
+ **IMPORTANT — branch naming:** Prefix every working branch with `feature/` (new functionality)
23
+ or `fix/` (bug fixes) never a tool- or agent-name prefix like `claude/`. Name the branch after
24
+ the *changes it contains*, as a short, descriptive, kebab-case slug that is self-explanatory
25
+ from the name alone (e.g. `feature/tool-permission-system`, `fix/glob-mtime-sort`), never after
26
+ a task, ticket, or session id (not `feature/task-q003hm`).
26
27
 
27
28
  **IMPORTANT — pull request target:** Always open pull requests against the `development` branch,
28
29
  never `main`. `main` is release-only; `development` is where day-to-day work integrates.
29
30
 
31
+ **IMPORTANT — branch off `development`:** Start every working branch from the latest
32
+ `origin/development`. Exception: when new work *depends on* a feature branch that has not merged
33
+ yet (e.g. it builds on tools or APIs that branch introduces), it may be stacked on top of that
34
+ branch instead. When stacking: merge the base PR into `development` first, then rebase the
35
+ stacked branch onto `development` before opening its pull request, so each PR shows only its own
36
+ commits.
37
+
30
38
  **IMPORTANT — run CI before pushing:** Run the full CI gate locally and confirm it is green
31
39
  *before* pushing a branch or opening a pull request — never push work that fails these:
32
40
 
@@ -55,10 +63,10 @@ Run a single test: `cargo test <test_name>`.
55
63
 
56
64
  ## Critical platform constraint: `#[cfg(unix)]` dead code
57
65
 
58
- The interactive client, the `InferenceBackend` seam (`backend.rs`), and provider resolution
59
- (`provider.rs`) are wired up **only** through `#[cfg(unix)]` code paths. On Windows the binary
60
- runs ACP-only and drives `onde` directly, so much of `backend.rs` and `provider.rs` is
61
- legitimately unused there and the dead-code lint is suppressed *on non-Unix targets only*.
66
+ The interactive client is `#[cfg(unix)]`-only. The `InferenceBackend` seam (`backend.rs`) and
67
+ provider resolution (`provider.rs`) are consumed by both the interactive client and the ACP
68
+ server, but several of their items are reached only through the Unix-only interactive paths, so
69
+ the dead-code lint is suppressed *on non-Unix targets only*.
62
70
 
63
71
  Consequence: code can pass clippy on macOS/Linux but fail on the Windows target (or vice versa).
64
72
  When touching `backend.rs`, `provider.rs`, or the interactive path, keep the `cfg` gates intact —
@@ -83,7 +91,10 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
83
91
  - **`src/tools.rs`** — agent tool schemas + execution: `read_file`, `create_directory`,
84
92
  `list_directory`, `search_files`, `glob`, `read_website`, `create_file`, `edit_file`,
85
93
  `multi_edit`, `delete_file`, `run_command`, `write_todos`, `remember`. Add a tool in both the
86
- spec list (`all_tools`) and the execute `match` (`execute_tool`).
94
+ spec list (`all_tools`) and the execute `match` (`execute_tool`). `run_command` also enforces
95
+ commit attribution: when a command creates a new commit that lacks the
96
+ `Co-Authored-By: siGit Code` trailer (`COMMIT_CO_AUTHOR_TRAILER`), it amends the trailer in —
97
+ unless the commit already exists on a remote, which is never rewritten.
87
98
  - **`src/skills.rs`** — [Agent Skills](https://agentskills.io) support. Discovers skill
88
99
  folders (each with a `SKILL.md`: YAML frontmatter `name` + `description`, then Markdown
89
100
  instructions) from `.sigit/skills/` and `.claude/skills/` in the cwd, `$SIGIT_CONFIG_DIR/skills/`,
@@ -102,6 +113,17 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
102
113
  official server (`<cloud>/mcp`, default `https://sigit.si/api/v1/mcp`) is baked in and authed with the
103
114
  cloud session token; extra servers live in `mcp.toml` (global `$SIGIT_CONFIG_DIR/mcp.toml` and
104
115
  project-local `.sigit/mcp.toml`). stdio transport is not supported.
116
+ - **`src/permissions.rs`** — tool permission policy. Every tool call passes through
117
+ `decision_for` before executing: read-only tools always run; mutating tools (and all
118
+ `mcp__*`/unknown tools) are governed by, in order: per-session plan mode (`/plan` — deny all
119
+ mutating tools with a present-a-plan message), session "always allow" grants, per-tool
120
+ overrides and the default mode from `[permissions]` in `settings.toml` (`allow`/`ask`/`deny`,
121
+ default `ask`; `SIGIT_PERMISSIONS` env overrides the default). On `ask`, the ACP path sends
122
+ `session/request_permission` (allow once / allow for session / deny) and the TUI pauses the
123
+ inference task on a y/a/n prompt. Note: ACP turn-affecting handlers run in `cx.spawn`ed tasks
124
+ serialized by `SiGitAgent::turn_lock` so the dispatch loop can route the client's permission
125
+ answer mid-turn — don't move them back inline, and don't await client requests from inline
126
+ handlers (deadlock).
105
127
  - **`src/instructions.rs`** — project instruction files, the always-on counterpart to skills.
106
128
  Reads `AGENTS.md` (the cross-tool [agents.md](https://agents.md) standard) and `CLAUDE.md`,
107
129
  walking from the session cwd up to the repo root (nearest ancestor with `.git`, never above it),
@@ -120,8 +142,8 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
120
142
  - **`src/models.rs`** — model-picker types shared across platforms.
121
143
 
122
144
  Slash commands (`/help`, `/models`, `/skills`, `/mcp`, `/login`, `/logout`, `/whoami`, `/reload`,
123
- `/clear`, `/status`) are advertised via `advertise_commands` in `main.rs` and handled in both the
124
- TUI and ACP sessions.
145
+ `/plan`, `/permissions`, `/clear`, `/status`) are advertised via `advertise_commands` in `main.rs`
146
+ and handled in both the TUI and ACP sessions.
125
147
 
126
148
  ## Model cache (macOS)
127
149
 
@@ -142,7 +164,9 @@ verbosity with `RUST_LOG`.
142
164
  `OPENAI_BASE_URL` / `OPENAI_API_KEY` (provider override), `SIGIT_API_URL` (account API base,
143
165
  default `https://sigit.si`), `SIGIT_CLOUD_URL`, `SIGIT_CONFIG_DIR` (default `~/.config/sigit`),
144
166
  `SIGIT_MODEL`, `SIGIT_MCP` (`off` disables MCP), `SIGIT_MCP_OFFICIAL` (`off` drops the baked-in
145
- server), `HF_HOME` / `HF_HUB_CACHE`, `RUST_LOG`.
167
+ server), `SIGIT_PERMISSIONS` (`allow`/`ask`/`deny` — overrides the default permission mode for
168
+ mutating tools; the escape hatch for clients without permission-request support),
169
+ `HF_HOME` / `HF_HUB_CACHE`, `RUST_LOG`.
146
170
 
147
171
  ## Releasing
148
172
 
@@ -5200,7 +5200,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
5200
5200
 
5201
5201
  [[package]]
5202
5202
  name = "sigit"
5203
- version = "1.3.1"
5203
+ version = "1.3.2"
5204
5204
  dependencies = [
5205
5205
  "agent-client-protocol",
5206
5206
  "anyhow",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "sigit"
3
- version = "1.3.1"
3
+ version = "1.3.2"
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.3.1
3
+ Version: 1.3.2
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -11,17 +11,17 @@
11
11
  //! The trait exposes neither `onde` nor OpenAI types, so the loop does not depend
12
12
  //! on a specific backend.
13
13
  //!
14
- //! The whole backend seam is wired up only through the interactive client, which
15
- //! is `#[cfg(unix)]` (see `run_interactive` in `main.rs` and `mod tui` in
16
- //! `chat.rs`). On non-Unix targets the binary runs ACP-only and drives `onde`
17
- //! directly, so every item here is legitimately unused there. Suppress the
18
- //! dead-code lint on those targets only — Unix builds still get full coverage.
14
+ //! The seam is consumed by both surfaces: the interactive client (`#[cfg(unix)]`,
15
+ //! see `run_interactive` in `main.rs` and `mod tui` in `chat.rs`) and the ACP
16
+ //! server's prompt loop. Some items are still reached only through the
17
+ //! Unix-only interactive paths, so the dead-code lint stays suppressed on
18
+ //! non-Unix targets only — Unix builds keep full coverage.
19
19
  #![cfg_attr(not(unix), allow(dead_code))]
20
20
 
21
21
  use std::sync::Arc;
22
22
 
23
23
  use async_trait::async_trait;
24
- use onde::inference::{ChatEngine, ToolDefinition};
24
+ use onde::inference::{ChatEngine, ChatMessage, ChatRole, ToolDefinition};
25
25
  use serde::Deserialize;
26
26
  use tokio::sync::Mutex;
27
27
 
@@ -62,6 +62,30 @@ pub struct TurnResult {
62
62
  /// Backend errors are plain strings. Callers map them to ACP errors.
63
63
  pub type BackendError = String;
64
64
 
65
+ /// Rough context budget for a conversation, in estimated tokens (see
66
+ /// [`estimate_tokens`]). When a snapshot exceeds this, the agent loops compact
67
+ /// history before the next tool round.
68
+ pub const DEFAULT_CONTEXT_TOKEN_BUDGET: usize = 24_000;
69
+
70
+ /// How many trailing messages survive a compaction verbatim (the rest are
71
+ /// folded into the summary).
72
+ pub const COMPACT_KEEP_LAST: usize = 6;
73
+
74
+ /// The summarization request sent to the model when compacting history.
75
+ const SUMMARIZE_PROMPT: &str = "Summarize this coding session so far: decisions made, \
76
+ files touched, current state, open items. Be concise and factual.";
77
+
78
+ /// Crude token estimate for a history snapshot: serialized characters / 4.
79
+ /// Deliberately model-agnostic — it only needs to be in the right ballpark to
80
+ /// decide when compaction is worth an extra inference round.
81
+ pub fn estimate_tokens(history: &[serde_json::Value]) -> usize {
82
+ let chars: usize = history
83
+ .iter()
84
+ .map(|message| message.to_string().chars().count())
85
+ .sum();
86
+ chars / 4
87
+ }
88
+
65
89
  /// A sink for streaming assistant text deltas to the UI as they are produced.
66
90
  ///
67
91
  /// When a caller passes `Some(sink)`, a streaming-capable backend forwards each
@@ -98,10 +122,35 @@ pub trait InferenceBackend: Send + Sync {
98
122
  sink: Option<&TokenSink>,
99
123
  ) -> Result<TurnResult, BackendError>;
100
124
 
125
+ /// Record tool results in the conversation history *without* asking the
126
+ /// model to continue the turn. Used when a turn is abandoned mid-round
127
+ /// (the user cancelled at the permission gate): by then the assistant
128
+ /// message carrying the tool calls is already in history, and leaving them
129
+ /// unanswered makes strict OpenAI-compatible endpoints reject every later
130
+ /// request in the session.
131
+ async fn record_cancelled_tool_results(&self, results: Vec<ToolResult>);
132
+
101
133
  /// Whether inference runs over the network (a configured provider) rather
102
134
  /// than on-device. Drives UI labelling so the displayed model can't claim a
103
135
  /// local model while requests actually go to the cloud.
104
136
  fn is_remote(&self) -> bool;
137
+
138
+ /// A serializable snapshot of the conversation history, one JSON object per
139
+ /// message (`{"role": ..., "content": ...}` at minimum). The snapshot is
140
+ /// what the session store persists; it includes any seeded system message
141
+ /// so [`InferenceBackend::restore_history`] can replace state wholesale.
142
+ async fn history_snapshot(&self) -> Vec<serde_json::Value>;
143
+
144
+ /// Replace the conversation history with a previously saved snapshot.
145
+ /// Backends that cannot represent every entry (e.g. on-device history has
146
+ /// no tool-call structure) flatten what they can and drop the rest.
147
+ async fn restore_history(&self, history: Vec<serde_json::Value>);
148
+
149
+ /// Shrink the conversation history: summarize everything so far with one
150
+ /// extra (non-streaming) inference round, then rebuild history as
151
+ /// `[system message, summary, last keep_last non-system messages]`. On
152
+ /// error the original history is left in place.
153
+ async fn compact_history(&self, keep_last: usize) -> Result<(), BackendError>;
105
154
  }
106
155
 
107
156
  // ── Local backend (onde ChatEngine) ──────────────────────────────────────────────
@@ -195,9 +244,92 @@ impl InferenceBackend for LocalBackend {
195
244
  Ok(onde_result_to_turn(result))
196
245
  }
197
246
 
247
+ async fn record_cancelled_tool_results(&self, _results: Vec<ToolResult>) {
248
+ // onde's public API cannot append tool-result history entries without
249
+ // running another inference round, so the dangling tool call stays in
250
+ // its history. The chat template replays it as-is, which local models
251
+ // tolerate — worst case the model re-issues the call next turn.
252
+ }
253
+
198
254
  fn is_remote(&self) -> bool {
199
255
  false
200
256
  }
257
+
258
+ async fn history_snapshot(&self) -> Vec<serde_json::Value> {
259
+ // onde's `history()` already flattens tool entries: assistant tool
260
+ // calls become plain assistant text and tool results are omitted, so
261
+ // the snapshot is lossy for tool-heavy turns (acceptable in this MVP).
262
+ self.engine
263
+ .history()
264
+ .await
265
+ .iter()
266
+ .map(|message| {
267
+ serde_json::json!({
268
+ "role": message.role.to_string(),
269
+ "content": message.content,
270
+ })
271
+ })
272
+ .collect()
273
+ }
274
+
275
+ async fn restore_history(&self, history: Vec<serde_json::Value>) {
276
+ self.engine.clear_history().await;
277
+ for entry in history {
278
+ let role = entry["role"].as_str().unwrap_or("");
279
+ let content = entry["content"].as_str().unwrap_or("").to_string();
280
+ // Tool-call-only assistant entries and empty tool results carry no
281
+ // text a plain chat history can replay; drop them.
282
+ if content.is_empty() && role != "user" && role != "system" {
283
+ continue;
284
+ }
285
+ let message = match role {
286
+ "system" => ChatMessage::system(content),
287
+ "user" => ChatMessage::user(content),
288
+ "assistant" => ChatMessage::assistant(content),
289
+ // Tool results flatten to plain text (MVP; acceptable loss).
290
+ "tool" => ChatMessage::user(format!("[tool result]\n{content}")),
291
+ _ => continue,
292
+ };
293
+ self.engine.push_history(message).await;
294
+ }
295
+ }
296
+
297
+ async fn compact_history(&self, keep_last: usize) -> Result<(), BackendError> {
298
+ let snapshot = self.engine.history().await;
299
+ // One plain (tool-free) inference round produces the summary. On error
300
+ // history is untouched — send_message only mutates it on success, and
301
+ // whatever it appended is wiped by the clear below anyway.
302
+ let result = self
303
+ .engine
304
+ .send_message(SUMMARIZE_PROMPT)
305
+ .await
306
+ .map_err(|error| error.to_string())?;
307
+ // Local models may reason in <think> blocks; keep only the visible part.
308
+ let (_think, summary) = crate::chat::strip_think_blocks(&result.text);
309
+
310
+ self.engine.clear_history().await;
311
+ // Leading system messages carry the session context; keep them all.
312
+ for message in snapshot
313
+ .iter()
314
+ .take_while(|message| message.role == ChatRole::System)
315
+ {
316
+ self.engine.push_history(message.clone()).await;
317
+ }
318
+ self.engine
319
+ .push_history(ChatMessage::user(format!(
320
+ "[Conversation summary]\n{summary}"
321
+ )))
322
+ .await;
323
+ let non_system: Vec<&ChatMessage> = snapshot
324
+ .iter()
325
+ .filter(|message| message.role != ChatRole::System)
326
+ .collect();
327
+ let tail_start = non_system.len().saturating_sub(keep_last);
328
+ for message in &non_system[tail_start..] {
329
+ self.engine.push_history((*message).clone()).await;
330
+ }
331
+ Ok(())
332
+ }
201
333
  }
202
334
 
203
335
  /// Drain an onde streaming receiver, forwarding each token to `sink` and
@@ -562,9 +694,82 @@ impl InferenceBackend for OpenAiBackend {
562
694
  self.complete(tools, sink).await
563
695
  }
564
696
 
697
+ async fn record_cancelled_tool_results(&self, results: Vec<ToolResult>) {
698
+ let mut history = self.history.lock().await;
699
+ for result in results {
700
+ history.push(serde_json::json!({
701
+ "role": "tool",
702
+ "tool_call_id": result.tool_call_id,
703
+ "content": result.content,
704
+ }));
705
+ }
706
+ }
707
+
565
708
  fn is_remote(&self) -> bool {
566
709
  true
567
710
  }
711
+
712
+ async fn history_snapshot(&self) -> Vec<serde_json::Value> {
713
+ self.history.lock().await.clone()
714
+ }
715
+
716
+ async fn restore_history(&self, history: Vec<serde_json::Value>) {
717
+ // The snapshot includes the seeded system message, so a wholesale
718
+ // replacement restores exactly what was saved.
719
+ *self.history.lock().await = history;
720
+ }
721
+
722
+ async fn compact_history(&self, keep_last: usize) -> Result<(), BackendError> {
723
+ let snapshot: Vec<serde_json::Value> = self.history.lock().await.clone();
724
+
725
+ // Ask the endpoint for a summary of the conversation so far, through
726
+ // the ordinary completion machinery (non-streaming).
727
+ self.history
728
+ .lock()
729
+ .await
730
+ .push(serde_json::json!({ "role": "user", "content": SUMMARIZE_PROMPT }));
731
+ let summary = match self.complete(None, None).await {
732
+ Ok(result) => result.text,
733
+ Err(error) => {
734
+ // Roll back the summarization request; the turn never happened.
735
+ *self.history.lock().await = snapshot;
736
+ return Err(error);
737
+ }
738
+ };
739
+
740
+ let system = snapshot
741
+ .first()
742
+ .filter(|message| message["role"] == "system")
743
+ .cloned();
744
+ let non_system: Vec<serde_json::Value> = snapshot
745
+ .iter()
746
+ .filter(|message| message["role"] != "system")
747
+ .cloned()
748
+ .collect();
749
+ let tail_start = non_system.len().saturating_sub(keep_last);
750
+ let mut tail = non_system[tail_start..].to_vec();
751
+ // Drop leading tool results whose assistant tool-call message was
752
+ // summarized away — strict endpoints reject orphaned `role: "tool"`
753
+ // entries on the very next request.
754
+ while tail
755
+ .first()
756
+ .is_some_and(|message| message["role"] == "tool")
757
+ {
758
+ tail.remove(0);
759
+ }
760
+
761
+ let mut rebuilt = Vec::new();
762
+ if let Some(system) = system {
763
+ rebuilt.push(system);
764
+ }
765
+ rebuilt.push(serde_json::json!({
766
+ "role": "user",
767
+ "content": format!("[Conversation summary]\n{summary}"),
768
+ }));
769
+ rebuilt.extend(tail);
770
+ *self.history.lock().await = rebuilt;
771
+ Ok(())
772
+ }
568
773
  }
569
774
 
570
775
  // ── OpenAI response shapes ────────────────────────────────────────────────────────
@@ -727,6 +932,189 @@ mod tests {
727
932
  );
728
933
  }
729
934
 
935
+ #[tokio::test]
936
+ async fn cancelled_tool_results_close_out_history() {
937
+ let backend = OpenAiBackend::new("http://localhost", "", "test-model", None);
938
+ backend
939
+ .history
940
+ .lock()
941
+ .await
942
+ .push(streamed_assistant_history(
943
+ "",
944
+ &[ToolCall {
945
+ id: "call_9".to_string(),
946
+ name: "run_command".to_string(),
947
+ arguments: r#"{"command":"ls"}"#.to_string(),
948
+ }],
949
+ ));
950
+
951
+ backend
952
+ .record_cancelled_tool_results(vec![ToolResult {
953
+ tool_call_id: "call_9".to_string(),
954
+ content: "cancelled by the user".to_string(),
955
+ }])
956
+ .await;
957
+
958
+ let history = backend.history.lock().await;
959
+ let last = history.last().unwrap();
960
+ assert_eq!(last["role"], "tool");
961
+ assert_eq!(last["tool_call_id"], "call_9");
962
+ assert_eq!(last["content"], "cancelled by the user");
963
+ }
964
+
965
+ #[test]
966
+ fn estimate_tokens_scales_with_serialized_size() {
967
+ assert_eq!(estimate_tokens(&[]), 0);
968
+
969
+ let short = vec![serde_json::json!({ "role": "user", "content": "hi" })];
970
+ let long = vec![serde_json::json!({ "role": "user", "content": "x".repeat(4_000) })];
971
+ let short_estimate = estimate_tokens(&short);
972
+ let long_estimate = estimate_tokens(&long);
973
+
974
+ assert!(short_estimate > 0, "non-empty history estimates > 0 tokens");
975
+ assert!(long_estimate > short_estimate, "longer history costs more");
976
+ // 4,000 content chars / 4 ≈ 1,000 tokens, plus a little JSON framing.
977
+ assert!((1_000..1_100).contains(&long_estimate), "{long_estimate}");
978
+ }
979
+
980
+ #[tokio::test]
981
+ async fn openai_snapshot_restore_round_trips_exactly() {
982
+ let backend = OpenAiBackend::new("http://localhost", "", "m", Some("be helpful".into()));
983
+ {
984
+ let mut history = backend.history.lock().await;
985
+ history.push(serde_json::json!({ "role": "user", "content": "hello" }));
986
+ history.push(streamed_assistant_history(
987
+ "",
988
+ &[ToolCall {
989
+ id: "call_1".to_string(),
990
+ name: "read_file".to_string(),
991
+ arguments: r#"{"path":"a.rs"}"#.to_string(),
992
+ }],
993
+ ));
994
+ history.push(serde_json::json!({
995
+ "role": "tool", "tool_call_id": "call_1", "content": "fn main() {}",
996
+ }));
997
+ history.push(serde_json::json!({ "role": "assistant", "content": "done" }));
998
+ }
999
+ let snapshot = backend.history_snapshot().await;
1000
+ assert_eq!(
1001
+ snapshot[0]["role"], "system",
1002
+ "snapshot keeps the system message"
1003
+ );
1004
+
1005
+ // Restoring into a backend seeded with a *different* system prompt must
1006
+ // replace everything, including that seed.
1007
+ let restored = OpenAiBackend::new("http://localhost", "", "m", Some("other seed".into()));
1008
+ restored.restore_history(snapshot.clone()).await;
1009
+ assert_eq!(restored.history_snapshot().await, snapshot);
1010
+ }
1011
+
1012
+ /// Minimal scripted OpenAI-compatible endpoint: accepts one HTTP request on
1013
+ /// a std listener and answers with a fixed non-streaming completion.
1014
+ fn spawn_completion_stub(summary: &str) -> std::net::SocketAddr {
1015
+ use std::io::{Read, Write};
1016
+
1017
+ let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1018
+ let addr = listener.local_addr().unwrap();
1019
+ let body = serde_json::json!({
1020
+ "choices": [{ "message": { "role": "assistant", "content": summary } }]
1021
+ })
1022
+ .to_string();
1023
+ std::thread::spawn(move || {
1024
+ let (mut stream, _) = listener.accept().unwrap();
1025
+ // Read until the full request (headers + content-length body) is in.
1026
+ let mut request = Vec::new();
1027
+ let mut chunk = [0u8; 4096];
1028
+ loop {
1029
+ let n = stream.read(&mut chunk).unwrap_or(0);
1030
+ if n == 0 {
1031
+ break;
1032
+ }
1033
+ request.extend_from_slice(&chunk[..n]);
1034
+ if let Some(headers_end) =
1035
+ request.windows(4).position(|window| window == b"\r\n\r\n")
1036
+ {
1037
+ let headers = String::from_utf8_lossy(&request[..headers_end]);
1038
+ let content_length = headers
1039
+ .lines()
1040
+ .find_map(|line| {
1041
+ line.to_ascii_lowercase()
1042
+ .strip_prefix("content-length:")
1043
+ .map(|value| value.trim().parse::<usize>().unwrap_or(0))
1044
+ })
1045
+ .unwrap_or(0);
1046
+ if request.len() >= headers_end + 4 + content_length {
1047
+ break;
1048
+ }
1049
+ }
1050
+ }
1051
+ let response = format!(
1052
+ "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
1053
+ content-length: {}\r\nconnection: close\r\n\r\n{}",
1054
+ body.len(),
1055
+ body
1056
+ );
1057
+ let _ = stream.write_all(response.as_bytes());
1058
+ });
1059
+ addr
1060
+ }
1061
+
1062
+ #[tokio::test]
1063
+ async fn compact_history_rebuilds_system_summary_and_tail() {
1064
+ let addr = spawn_completion_stub("We refactored backend.rs; tests pass.");
1065
+ let backend = OpenAiBackend::new(
1066
+ format!("http://{addr}/v1"),
1067
+ "test-key",
1068
+ "test-model",
1069
+ Some("be helpful".into()),
1070
+ );
1071
+ {
1072
+ let mut history = backend.history.lock().await;
1073
+ for i in 0..5 {
1074
+ let role = if i % 2 == 0 { "user" } else { "assistant" };
1075
+ history.push(serde_json::json!({
1076
+ "role": role, "content": format!("message {i}"),
1077
+ }));
1078
+ }
1079
+ }
1080
+
1081
+ backend.compact_history(2).await.unwrap();
1082
+
1083
+ let history = backend.history_snapshot().await;
1084
+ assert_eq!(history.len(), 4, "system + summary + last 2: {history:?}");
1085
+ assert_eq!(history[0]["role"], "system");
1086
+ assert_eq!(history[0]["content"], "be helpful");
1087
+ assert_eq!(history[1]["role"], "user");
1088
+ let summary_text = history[1]["content"].as_str().unwrap();
1089
+ assert!(summary_text.starts_with("[Conversation summary]\n"));
1090
+ assert!(summary_text.contains("We refactored backend.rs; tests pass."));
1091
+ assert_eq!(
1092
+ history[2],
1093
+ serde_json::json!({ "role": "assistant", "content": "message 3" })
1094
+ );
1095
+ assert_eq!(
1096
+ history[3],
1097
+ serde_json::json!({ "role": "user", "content": "message 4" })
1098
+ );
1099
+ }
1100
+
1101
+ #[tokio::test]
1102
+ async fn compact_history_failure_leaves_history_intact() {
1103
+ // No listener at this address: the summarization request fails, and
1104
+ // history must roll back to exactly what it was.
1105
+ let backend =
1106
+ OpenAiBackend::new("http://127.0.0.1:9", "", "test-model", Some("sys".into()));
1107
+ backend
1108
+ .history
1109
+ .lock()
1110
+ .await
1111
+ .push(serde_json::json!({ "role": "user", "content": "hello" }));
1112
+ let before = backend.history_snapshot().await;
1113
+
1114
+ assert!(backend.compact_history(2).await.is_err());
1115
+ assert_eq!(backend.history_snapshot().await, before);
1116
+ }
1117
+
730
1118
  #[test]
731
1119
  fn assistant_message_with_tool_calls_round_trips() {
732
1120
  let message = ResponseMessage {