sigit-code 1.6.1__tar.gz → 1.6.3__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 (107) hide show
  1. {sigit_code-1.6.1 → sigit_code-1.6.3}/.agents/AGENTS.md +52 -8
  2. {sigit_code-1.6.1/.claude → sigit_code-1.6.3/.agents}/skills/agent-client-protocol/SKILL.md +1 -0
  3. {sigit_code-1.6.1/.agents → sigit_code-1.6.3/.claude}/skills/agent-client-protocol/SKILL.md +1 -0
  4. {sigit_code-1.6.1 → sigit_code-1.6.3}/AGENTS.md +52 -8
  5. {sigit_code-1.6.1 → sigit_code-1.6.3}/CHANGELOG.md +88 -0
  6. {sigit_code-1.6.1 → sigit_code-1.6.3}/CLAUDE.md +52 -8
  7. {sigit_code-1.6.1 → sigit_code-1.6.3}/Cargo.lock +2 -1
  8. {sigit_code-1.6.1 → sigit_code-1.6.3}/Cargo.toml +6 -1
  9. {sigit_code-1.6.1 → sigit_code-1.6.3}/PKG-INFO +1 -1
  10. {sigit_code-1.6.1 → sigit_code-1.6.3}/src/backend.rs +64 -12
  11. sigit_code-1.6.3/src/client_fs.rs +350 -0
  12. {sigit_code-1.6.1 → sigit_code-1.6.3}/src/main.rs +1197 -211
  13. {sigit_code-1.6.1 → sigit_code-1.6.3}/src/tools.rs +379 -180
  14. {sigit_code-1.6.1 → sigit_code-1.6.3}/src/workspace.rs +1 -1
  15. sigit_code-1.6.3/tests/acp_client_fs.rs +549 -0
  16. sigit_code-1.6.3/tests/acp_concurrent_sessions.rs +343 -0
  17. sigit_code-1.6.3/tests/acp_logout.rs +179 -0
  18. {sigit_code-1.6.1 → sigit_code-1.6.3}/tests/acp_permissions.rs +138 -0
  19. {sigit_code-1.6.1 → sigit_code-1.6.3}/tests/acp_session_load.rs +170 -0
  20. sigit_code-1.6.3/tests/version_flag.rs +32 -0
  21. {sigit_code-1.6.1 → sigit_code-1.6.3}/.agents/skills/ai-assisted-coding/SKILL.md +0 -0
  22. {sigit_code-1.6.1 → sigit_code-1.6.3}/.agents/skills/branding/SKILL.md +0 -0
  23. {sigit_code-1.6.1 → sigit_code-1.6.3}/.agents/skills/run-sigit/SKILL.md +0 -0
  24. {sigit_code-1.6.1 → sigit_code-1.6.3}/.agents/skills/run-sigit/driver.mjs +0 -0
  25. {sigit_code-1.6.1 → sigit_code-1.6.3}/.agents/skills/run-sigit/tui-smoke.sh +0 -0
  26. {sigit_code-1.6.1 → sigit_code-1.6.3}/.agents/skills/sigit-code-release/SKILL.md +0 -0
  27. {sigit_code-1.6.1 → sigit_code-1.6.3}/.agents/skills/tool-calling/SKILL.md +0 -0
  28. {sigit_code-1.6.1 → sigit_code-1.6.3}/.cargo/config.toml +0 -0
  29. {sigit_code-1.6.1 → sigit_code-1.6.3}/.claude/skills/ai-assisted-coding/SKILL.md +0 -0
  30. {sigit_code-1.6.1 → sigit_code-1.6.3}/.claude/skills/branding/SKILL.md +0 -0
  31. {sigit_code-1.6.1 → sigit_code-1.6.3}/.claude/skills/run-sigit/SKILL.md +0 -0
  32. {sigit_code-1.6.1 → sigit_code-1.6.3}/.claude/skills/run-sigit/driver.mjs +0 -0
  33. {sigit_code-1.6.1 → sigit_code-1.6.3}/.claude/skills/run-sigit/tui-smoke.sh +0 -0
  34. {sigit_code-1.6.1 → sigit_code-1.6.3}/.claude/skills/sigit-code-release/SKILL.md +0 -0
  35. {sigit_code-1.6.1 → sigit_code-1.6.3}/.claude/skills/tool-calling/SKILL.md +0 -0
  36. {sigit_code-1.6.1 → sigit_code-1.6.3}/.github/workflows/ci.yml +0 -0
  37. {sigit_code-1.6.1 → sigit_code-1.6.3}/.github/workflows/release-aur.yml +0 -0
  38. {sigit_code-1.6.1 → sigit_code-1.6.3}/.github/workflows/release-crates.yml +0 -0
  39. {sigit_code-1.6.1 → sigit_code-1.6.3}/.github/workflows/release-github.yml +0 -0
  40. {sigit_code-1.6.1 → sigit_code-1.6.3}/.github/workflows/release-homebrew.yml +0 -0
  41. {sigit_code-1.6.1 → sigit_code-1.6.3}/.github/workflows/release-npm.yml +0 -0
  42. {sigit_code-1.6.1 → sigit_code-1.6.3}/.github/workflows/release-nuget.yml +0 -0
  43. {sigit_code-1.6.1 → sigit_code-1.6.3}/.github/workflows/release-pypi.yml +0 -0
  44. {sigit_code-1.6.1 → sigit_code-1.6.3}/.github/workflows/release-scoop.yml +0 -0
  45. {sigit_code-1.6.1 → sigit_code-1.6.3}/.github/workflows/release-winget.yml +0 -0
  46. {sigit_code-1.6.1 → sigit_code-1.6.3}/.gitignore +0 -0
  47. {sigit_code-1.6.1 → sigit_code-1.6.3}/.nvmrc +0 -0
  48. {sigit_code-1.6.1 → sigit_code-1.6.3}/.sigit/factory/config.toml +0 -0
  49. {sigit_code-1.6.1 → sigit_code-1.6.3}/LICENSE +0 -0
  50. {sigit_code-1.6.1 → sigit_code-1.6.3}/README.md +0 -0
  51. {sigit_code-1.6.1 → sigit_code-1.6.3}/docs/headless.md +0 -0
  52. {sigit_code-1.6.1 → sigit_code-1.6.3}/docs/hooks.md +0 -0
  53. {sigit_code-1.6.1 → sigit_code-1.6.3}/docs/mcp.md +0 -0
  54. {sigit_code-1.6.1 → sigit_code-1.6.3}/examples/settings-with-hooks.toml +0 -0
  55. {sigit_code-1.6.1 → sigit_code-1.6.3}/examples/skills/README.md +0 -0
  56. {sigit_code-1.6.1 → sigit_code-1.6.3}/examples/skills/commit-message/SKILL.md +0 -0
  57. {sigit_code-1.6.1 → sigit_code-1.6.3}/npm/README.md.tmpl +0 -0
  58. {sigit_code-1.6.1 → sigit_code-1.6.3}/npm/package-compat.json.tmpl +0 -0
  59. {sigit_code-1.6.1 → sigit_code-1.6.3}/npm/package-main.json.tmpl +0 -0
  60. {sigit_code-1.6.1 → sigit_code-1.6.3}/npm/package.json.tmpl +0 -0
  61. {sigit_code-1.6.1 → sigit_code-1.6.3}/npm/scripts/render-main-package.cjs +0 -0
  62. {sigit_code-1.6.1 → sigit_code-1.6.3}/npm/scripts/render-platform-package.cjs +0 -0
  63. {sigit_code-1.6.1 → sigit_code-1.6.3}/npm/sigit/.gitignore +0 -0
  64. {sigit_code-1.6.1 → sigit_code-1.6.3}/npm/sigit/README.md +0 -0
  65. {sigit_code-1.6.1 → sigit_code-1.6.3}/npm/sigit/package.json +0 -0
  66. {sigit_code-1.6.1 → sigit_code-1.6.3}/npm/sigit/src/index.ts +0 -0
  67. {sigit_code-1.6.1 → sigit_code-1.6.3}/npm/sigit/tsconfig.json +0 -0
  68. {sigit_code-1.6.1 → sigit_code-1.6.3}/nuget/.gitignore +0 -0
  69. {sigit_code-1.6.1 → sigit_code-1.6.3}/nuget/sigit/Program.cs +0 -0
  70. {sigit_code-1.6.1 → sigit_code-1.6.3}/nuget/sigit/README.md +0 -0
  71. {sigit_code-1.6.1 → sigit_code-1.6.3}/nuget/sigit/SiGit.Code.csproj +0 -0
  72. {sigit_code-1.6.1 → sigit_code-1.6.3}/packaging/aur/PKGBUILD.in +0 -0
  73. {sigit_code-1.6.1 → sigit_code-1.6.3}/packaging/nfpm.yaml +0 -0
  74. {sigit_code-1.6.1 → sigit_code-1.6.3}/packaging/windows/check-static-crt.ps1 +0 -0
  75. {sigit_code-1.6.1 → sigit_code-1.6.3}/packaging/winget/getSigit.siGitCode.installer.yaml.in +0 -0
  76. {sigit_code-1.6.1 → sigit_code-1.6.3}/packaging/winget/getSigit.siGitCode.locale.en-US.yaml.in +0 -0
  77. {sigit_code-1.6.1 → sigit_code-1.6.3}/packaging/winget/getSigit.siGitCode.yaml.in +0 -0
  78. {sigit_code-1.6.1 → sigit_code-1.6.3}/pypi/README.md +0 -0
  79. {sigit_code-1.6.1 → sigit_code-1.6.3}/pypi/pyproject.toml +0 -0
  80. {sigit_code-1.6.1 → sigit_code-1.6.3}/pyproject.toml +0 -0
  81. {sigit_code-1.6.1 → sigit_code-1.6.3}/rust-toolchain.toml +0 -0
  82. {sigit_code-1.6.1 → sigit_code-1.6.3}/src/account.rs +0 -0
  83. {sigit_code-1.6.1 → sigit_code-1.6.3}/src/browser_auth.rs +0 -0
  84. {sigit_code-1.6.1 → sigit_code-1.6.3}/src/chat.rs +0 -0
  85. {sigit_code-1.6.1 → sigit_code-1.6.3}/src/commands.rs +0 -0
  86. {sigit_code-1.6.1 → sigit_code-1.6.3}/src/credentials.rs +0 -0
  87. {sigit_code-1.6.1 → sigit_code-1.6.3}/src/frontmatter.rs +0 -0
  88. {sigit_code-1.6.1 → sigit_code-1.6.3}/src/headless.rs +0 -0
  89. {sigit_code-1.6.1 → sigit_code-1.6.3}/src/hooks.rs +0 -0
  90. {sigit_code-1.6.1 → sigit_code-1.6.3}/src/inline_tool_calls.rs +0 -0
  91. {sigit_code-1.6.1 → sigit_code-1.6.3}/src/instructions.rs +0 -0
  92. {sigit_code-1.6.1 → sigit_code-1.6.3}/src/mcp.rs +0 -0
  93. {sigit_code-1.6.1 → sigit_code-1.6.3}/src/models.rs +0 -0
  94. {sigit_code-1.6.1 → sigit_code-1.6.3}/src/permissions.rs +0 -0
  95. {sigit_code-1.6.1 → sigit_code-1.6.3}/src/provider.rs +0 -0
  96. {sigit_code-1.6.1 → sigit_code-1.6.3}/src/session_store.rs +0 -0
  97. {sigit_code-1.6.1 → sigit_code-1.6.3}/src/settings.rs +0 -0
  98. {sigit_code-1.6.1 → sigit_code-1.6.3}/src/setup.rs +0 -0
  99. {sigit_code-1.6.1 → sigit_code-1.6.3}/src/skills.rs +0 -0
  100. {sigit_code-1.6.1 → sigit_code-1.6.3}/src/subagents.rs +0 -0
  101. {sigit_code-1.6.1 → sigit_code-1.6.3}/tests/acp_endpoint_errors.rs +0 -0
  102. {sigit_code-1.6.1 → sigit_code-1.6.3}/tests/acp_images.rs +0 -0
  103. {sigit_code-1.6.1 → sigit_code-1.6.3}/tests/acp_multi_root.rs +0 -0
  104. {sigit_code-1.6.1 → sigit_code-1.6.3}/tests/acp_session_list.rs +0 -0
  105. {sigit_code-1.6.1 → sigit_code-1.6.3}/tests/acp_stop_reasons.rs +0 -0
  106. {sigit_code-1.6.1 → sigit_code-1.6.3}/tests/acp_tool_stdin.rs +0 -0
  107. {sigit_code-1.6.1 → sigit_code-1.6.3}/tests/headless_mode.rs +0 -0
@@ -113,9 +113,20 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
113
113
  - **`src/main.rs`** — entry point, mode dispatch, the full ACP `Agent` impl (session lifecycle:
114
114
  new/load/fork/prompt/cancel, config options, slash-command advertisement), and the `SYSTEM_PROMPT`.
115
115
  ACP session state owns its roots, conversation, and selected model even though the process has
116
- one live backend; activating a thread parks and restores all three. Unknown session ids are
117
- rejected instead of silently borrowing the active thread's cwd. Prompt cancellation is routed
118
- outside `turn_lock`, which lets a client cancel the turn currently holding that lock. The
116
+ one working directory and one live backend slot; activating a thread parks and restores all
117
+ three. A thread on an HTTP backend keeps a backend of its own (`InferenceBackend::fresh`,
118
+ `SessionState::remote`), so its turn can wait on the endpoint while another thread is
119
+ installed. Unknown session ids are rejected instead of silently borrowing the active thread's
120
+ cwd. Prompt cancellation takes no lock, which lets a client cancel a turn whatever it holds.
121
+ `session/load` and `session/resume` share `restore_session`; the only difference is that
122
+ resume must not replay the history as `session/update`. `session/close` is the one place a
123
+ `SessionState` is dropped. It runs in two halves: `begin_close` signals the session's turn
124
+ from the dispatch loop (the turn holds the session's lock, so the signal cannot wait for it) and
125
+ marks the id in `closing_sessions`, which makes `handle_prompt` cancel a prompt that was
126
+ still queued; `handle_close_session` then runs under the session and workspace locks and
127
+ removes the state, the permission grants and the background commands
128
+ (`tools::kill_session_tasks`). It leaves `session_store` alone, so a closed thread still lists
129
+ and reopens. The
119
130
  `SYSTEM_PROMPT` bakes in smbCloud-specific context the agent should use when the repo is clearly
120
131
  smbCloud, and stay general otherwise.
121
132
  - **`src/backend.rs`** — the `InferenceBackend` trait and neutral types (`ToolSpec`, `ToolCall`,
@@ -268,10 +279,21 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
268
279
  announced, because it sets the announced status: a call that will ask starts `pending`, the
269
280
  permission request carries that call's own id, and an `in_progress` update follows approval.
270
281
  That update also puts the card's title back, since the permission request overwrites it
271
- with the full arguments. Note: ACP turn-affecting handlers run in `cx.spawn`ed tasks
272
- serialized by `SiGitAgent::turn_lock` so the dispatch loop can route the client's permission
273
- answer mid-turn — don't move them back inline, and don't await client requests from inline
274
- handlers (deadlock).
282
+ with the full arguments. The Manual/Auto/Plan choice reaches an ACP client twice: as the
283
+ `sigit-permission-mode` config option, and as session `modes` with `session/set_mode`, which
284
+ the spec is retiring but some clients still render instead. Both are built from
285
+ `PERMISSION_MODES` in `main.rs` and read the same state, so whatever changes the mode has to
286
+ refresh both (`ConfigOptionUpdate` and `send_current_mode`). Note: ACP turn-affecting handlers run in `cx.spawn`ed tasks
287
+ so the dispatch loop can route the client's permission answer mid-turn — don't move them back
288
+ inline, and don't await client requests from inline handlers (deadlock). Two locks order them.
289
+ A per-session lock (`SiGitAgent::session_lock`) is held for a whole request, so requests on one
290
+ thread keep their order. `SiGitAgent::workspace_lock` guards what a session installs
291
+ process-wide (cwd, workspace roots, session MCP servers, the live backend slot, the engine's
292
+ conversation); lifecycle and config handlers hold it throughout, and `handle_prompt` holds it
293
+ through a `WorkspaceHold` that it releases while waiting on an HTTP endpoint or a permission
294
+ answer and retakes (reinstalling its session via `resume_workspace`) before any tool runs. An
295
+ on-device turn never releases it. Take the session lock first, never the other way round. Tool
296
+ execution, subagents included, still runs one session at a time.
275
297
  - **`src/instructions.rs`** — project instruction files, the always-on counterpart to skills.
276
298
  Reads `AGENTS.md` (the cross-tool [agents.md](https://agents.md) standard) and `CLAUDE.md`,
277
299
  walking from the session cwd up to the repo root (nearest ancestor with `.git`, never above it),
@@ -299,6 +321,23 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
299
321
  every root. MCP is deliberately not on that list — `mcp::init` runs once at startup, before
300
322
  any session exists, so a second root's `.sigit/mcp.toml` has nobody to tell. (The servers a
301
323
  client names in `mcpServers` are a different thing and are per session; see `src/mcp.rs`.)
324
+ - **`src/client_fs.rs`** — file reads and writes through the ACP client. A client that
325
+ advertises `fs.readTextFile` / `fs.writeTextFile` in `initialize` serves `fs/read_text_file`
326
+ and `fs/write_text_file` from its buffers, so `read_file` sees unsaved changes and
327
+ `edit_file` lands in the open buffer instead of underneath it. `tools.rs` has no access to the
328
+ connection, so this is a seam like the one `mcp::call_tool` gives MCP: `handle_initialize`
329
+ registers a `ClientFileSystem` (`AcpClientFs` in `main.rs`, holding the connection), and
330
+ `execute_tool_impl` asks `route_for` before it dispatches `read_file`, `create_file`,
331
+ `edit_file` or `multi_edit`. Each of those tools is split into a parse step and a pure
332
+ render/apply step (`ReadFileCall`, `CreateFileCall`, `EditCall`) that the disk path and the
333
+ client path share, so the model gets the same result text either way. The two capabilities are
334
+ independent: a client that only reads still gets its edits written to disk. The disk is the
335
+ fallback throughout: nothing is registered in the TUI or headless modes, a path outside the
336
+ session's roots is not routed, and a request the client fails or leaves unanswered for 30
337
+ seconds is retried on disk with a warning in the log. The client is only ever asked from
338
+ inside a spawned prompt turn, never from `handle_initialize` itself (deadlock, same as
339
+ permission requests). Existence checks and `create_file`'s parent directories still use the
340
+ disk. `SIGIT_CLIENT_FS=off` turns the routing off. Covered by `tests/acp_client_fs.rs`.
302
341
  - **`src/chat.rs`** — the Unix-only ratatui TUI. Loading-spinner phase then chat; uses
303
342
  `tokio::select!` to multiplex terminal events with streaming tokens.
304
343
  - **`src/headless.rs`** — non-interactive `sigit run` execution for scripts, CI, and Factory
@@ -320,6 +359,10 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
320
359
  those read env vars once at init.
321
360
  - **`src/account.rs`** — siGit Code Cloud auth (`/login`, `/logout`, `/whoami`); authenticates
322
361
  against the account API and stores a session token. Performs no console I/O.
362
+ ACP `logout` (the editor's sign out button, shown only because `handle_initialize` advertises
363
+ `agentCapabilities.auth.logout`) and `/logout` both go through `SiGitAgent::sign_out` in
364
+ `main.rs`. The request names no session, so `leave_cloud` moves the parked threads off their
365
+ cloud tier along with the live one and each of them gets a fresh model picker.
323
366
  - **`src/browser_auth.rs`** — browser sign-in, the path the editor's "Sign in to siGit Code"
324
367
  button takes. sigit is a public OAuth client (client id `sigit-code-cli`, no secret, PKCE
325
368
  S256) against the authorization server at `$SIGIT_API_URL/oauth`. Two ways the code gets
@@ -363,7 +406,8 @@ verbosity with `RUST_LOG`.
363
406
  `OPENAI_BASE_URL` / `OPENAI_API_KEY` (provider override), `SIGIT_API_URL` (account API base,
364
407
  default `https://sigit.si`), `SIGIT_CLOUD_URL`, `SIGIT_CONFIG_DIR` (default `~/.config/sigit`),
365
408
  `SIGIT_MODEL`, `SIGIT_MAX_TOOL_ROUNDS` (1 to 500, default 24; the tool-round cap of a
366
- headless run or an ACP prompt turn), `SIGIT_MCP` (`off` disables MCP), `SIGIT_MCP_SMBCLOUD` (`off` drops the baked-in
409
+ headless run or an ACP prompt turn), `SIGIT_MCP` (`off` disables MCP), `SIGIT_CLIENT_FS` (`off` keeps the file tools on disk even when the
410
+ ACP client offers `fs/read_text_file` / `fs/write_text_file`), `SIGIT_MCP_SMBCLOUD` (`off` drops the baked-in
367
411
  smbCloud CLI server), `SIGIT_MCP_OFFICIAL` (`off` drops the baked-in
368
412
  server), `SIGIT_PERMISSIONS` (`allow`/`ask`/`deny` — overrides the default permission mode for
369
413
  mutating tools; the escape hatch for clients without permission-request support),
@@ -205,6 +205,7 @@ the connection and the agent anymore. Don't reintroduce the mpsc forwarder patte
205
205
  | `ListSessionsRequest` | `handle_list_sessions` | the editor's "Import Threads" picker; gated by `SessionListCapabilities` |
206
206
  | `PromptRequest` | `handle_prompt` | the turn: parse blocks → slash commands or tool-calling loop |
207
207
  | `SetSessionConfigOptionRequest` | `handle_set_session_config_option` | the Zed model picker — switches/downloads models |
208
+ | `SetSessionModeRequest` | `handle_set_session_mode` | legacy mode selector; same Manual/Auto/Plan state as the Permissions config option |
208
209
  | `CancelNotification` | `handle_cancel` | notification, no response |
209
210
 
210
211
  Everything else is left to the SDK default (method not found).
@@ -205,6 +205,7 @@ the connection and the agent anymore. Don't reintroduce the mpsc forwarder patte
205
205
  | `ListSessionsRequest` | `handle_list_sessions` | the editor's "Import Threads" picker; gated by `SessionListCapabilities` |
206
206
  | `PromptRequest` | `handle_prompt` | the turn: parse blocks → slash commands or tool-calling loop |
207
207
  | `SetSessionConfigOptionRequest` | `handle_set_session_config_option` | the Zed model picker — switches/downloads models |
208
+ | `SetSessionModeRequest` | `handle_set_session_mode` | legacy mode selector; same Manual/Auto/Plan state as the Permissions config option |
208
209
  | `CancelNotification` | `handle_cancel` | notification, no response |
209
210
 
210
211
  Everything else is left to the SDK default (method not found).
@@ -113,9 +113,20 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
113
113
  - **`src/main.rs`** — entry point, mode dispatch, the full ACP `Agent` impl (session lifecycle:
114
114
  new/load/fork/prompt/cancel, config options, slash-command advertisement), and the `SYSTEM_PROMPT`.
115
115
  ACP session state owns its roots, conversation, and selected model even though the process has
116
- one live backend; activating a thread parks and restores all three. Unknown session ids are
117
- rejected instead of silently borrowing the active thread's cwd. Prompt cancellation is routed
118
- outside `turn_lock`, which lets a client cancel the turn currently holding that lock. The
116
+ one working directory and one live backend slot; activating a thread parks and restores all
117
+ three. A thread on an HTTP backend keeps a backend of its own (`InferenceBackend::fresh`,
118
+ `SessionState::remote`), so its turn can wait on the endpoint while another thread is
119
+ installed. Unknown session ids are rejected instead of silently borrowing the active thread's
120
+ cwd. Prompt cancellation takes no lock, which lets a client cancel a turn whatever it holds.
121
+ `session/load` and `session/resume` share `restore_session`; the only difference is that
122
+ resume must not replay the history as `session/update`. `session/close` is the one place a
123
+ `SessionState` is dropped. It runs in two halves: `begin_close` signals the session's turn
124
+ from the dispatch loop (the turn holds the session's lock, so the signal cannot wait for it) and
125
+ marks the id in `closing_sessions`, which makes `handle_prompt` cancel a prompt that was
126
+ still queued; `handle_close_session` then runs under the session and workspace locks and
127
+ removes the state, the permission grants and the background commands
128
+ (`tools::kill_session_tasks`). It leaves `session_store` alone, so a closed thread still lists
129
+ and reopens. The
119
130
  `SYSTEM_PROMPT` bakes in smbCloud-specific context the agent should use when the repo is clearly
120
131
  smbCloud, and stay general otherwise.
121
132
  - **`src/backend.rs`** — the `InferenceBackend` trait and neutral types (`ToolSpec`, `ToolCall`,
@@ -268,10 +279,21 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
268
279
  announced, because it sets the announced status: a call that will ask starts `pending`, the
269
280
  permission request carries that call's own id, and an `in_progress` update follows approval.
270
281
  That update also puts the card's title back, since the permission request overwrites it
271
- with the full arguments. Note: ACP turn-affecting handlers run in `cx.spawn`ed tasks
272
- serialized by `SiGitAgent::turn_lock` so the dispatch loop can route the client's permission
273
- answer mid-turn — don't move them back inline, and don't await client requests from inline
274
- handlers (deadlock).
282
+ with the full arguments. The Manual/Auto/Plan choice reaches an ACP client twice: as the
283
+ `sigit-permission-mode` config option, and as session `modes` with `session/set_mode`, which
284
+ the spec is retiring but some clients still render instead. Both are built from
285
+ `PERMISSION_MODES` in `main.rs` and read the same state, so whatever changes the mode has to
286
+ refresh both (`ConfigOptionUpdate` and `send_current_mode`). Note: ACP turn-affecting handlers run in `cx.spawn`ed tasks
287
+ so the dispatch loop can route the client's permission answer mid-turn — don't move them back
288
+ inline, and don't await client requests from inline handlers (deadlock). Two locks order them.
289
+ A per-session lock (`SiGitAgent::session_lock`) is held for a whole request, so requests on one
290
+ thread keep their order. `SiGitAgent::workspace_lock` guards what a session installs
291
+ process-wide (cwd, workspace roots, session MCP servers, the live backend slot, the engine's
292
+ conversation); lifecycle and config handlers hold it throughout, and `handle_prompt` holds it
293
+ through a `WorkspaceHold` that it releases while waiting on an HTTP endpoint or a permission
294
+ answer and retakes (reinstalling its session via `resume_workspace`) before any tool runs. An
295
+ on-device turn never releases it. Take the session lock first, never the other way round. Tool
296
+ execution, subagents included, still runs one session at a time.
275
297
  - **`src/instructions.rs`** — project instruction files, the always-on counterpart to skills.
276
298
  Reads `AGENTS.md` (the cross-tool [agents.md](https://agents.md) standard) and `CLAUDE.md`,
277
299
  walking from the session cwd up to the repo root (nearest ancestor with `.git`, never above it),
@@ -299,6 +321,23 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
299
321
  every root. MCP is deliberately not on that list — `mcp::init` runs once at startup, before
300
322
  any session exists, so a second root's `.sigit/mcp.toml` has nobody to tell. (The servers a
301
323
  client names in `mcpServers` are a different thing and are per session; see `src/mcp.rs`.)
324
+ - **`src/client_fs.rs`** — file reads and writes through the ACP client. A client that
325
+ advertises `fs.readTextFile` / `fs.writeTextFile` in `initialize` serves `fs/read_text_file`
326
+ and `fs/write_text_file` from its buffers, so `read_file` sees unsaved changes and
327
+ `edit_file` lands in the open buffer instead of underneath it. `tools.rs` has no access to the
328
+ connection, so this is a seam like the one `mcp::call_tool` gives MCP: `handle_initialize`
329
+ registers a `ClientFileSystem` (`AcpClientFs` in `main.rs`, holding the connection), and
330
+ `execute_tool_impl` asks `route_for` before it dispatches `read_file`, `create_file`,
331
+ `edit_file` or `multi_edit`. Each of those tools is split into a parse step and a pure
332
+ render/apply step (`ReadFileCall`, `CreateFileCall`, `EditCall`) that the disk path and the
333
+ client path share, so the model gets the same result text either way. The two capabilities are
334
+ independent: a client that only reads still gets its edits written to disk. The disk is the
335
+ fallback throughout: nothing is registered in the TUI or headless modes, a path outside the
336
+ session's roots is not routed, and a request the client fails or leaves unanswered for 30
337
+ seconds is retried on disk with a warning in the log. The client is only ever asked from
338
+ inside a spawned prompt turn, never from `handle_initialize` itself (deadlock, same as
339
+ permission requests). Existence checks and `create_file`'s parent directories still use the
340
+ disk. `SIGIT_CLIENT_FS=off` turns the routing off. Covered by `tests/acp_client_fs.rs`.
302
341
  - **`src/chat.rs`** — the Unix-only ratatui TUI. Loading-spinner phase then chat; uses
303
342
  `tokio::select!` to multiplex terminal events with streaming tokens.
304
343
  - **`src/headless.rs`** — non-interactive `sigit run` execution for scripts, CI, and Factory
@@ -320,6 +359,10 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
320
359
  those read env vars once at init.
321
360
  - **`src/account.rs`** — siGit Code Cloud auth (`/login`, `/logout`, `/whoami`); authenticates
322
361
  against the account API and stores a session token. Performs no console I/O.
362
+ ACP `logout` (the editor's sign out button, shown only because `handle_initialize` advertises
363
+ `agentCapabilities.auth.logout`) and `/logout` both go through `SiGitAgent::sign_out` in
364
+ `main.rs`. The request names no session, so `leave_cloud` moves the parked threads off their
365
+ cloud tier along with the live one and each of them gets a fresh model picker.
323
366
  - **`src/browser_auth.rs`** — browser sign-in, the path the editor's "Sign in to siGit Code"
324
367
  button takes. sigit is a public OAuth client (client id `sigit-code-cli`, no secret, PKCE
325
368
  S256) against the authorization server at `$SIGIT_API_URL/oauth`. Two ways the code gets
@@ -363,7 +406,8 @@ verbosity with `RUST_LOG`.
363
406
  `OPENAI_BASE_URL` / `OPENAI_API_KEY` (provider override), `SIGIT_API_URL` (account API base,
364
407
  default `https://sigit.si`), `SIGIT_CLOUD_URL`, `SIGIT_CONFIG_DIR` (default `~/.config/sigit`),
365
408
  `SIGIT_MODEL`, `SIGIT_MAX_TOOL_ROUNDS` (1 to 500, default 24; the tool-round cap of a
366
- headless run or an ACP prompt turn), `SIGIT_MCP` (`off` disables MCP), `SIGIT_MCP_SMBCLOUD` (`off` drops the baked-in
409
+ headless run or an ACP prompt turn), `SIGIT_MCP` (`off` disables MCP), `SIGIT_CLIENT_FS` (`off` keeps the file tools on disk even when the
410
+ ACP client offers `fs/read_text_file` / `fs/write_text_file`), `SIGIT_MCP_SMBCLOUD` (`off` drops the baked-in
367
411
  smbCloud CLI server), `SIGIT_MCP_OFFICIAL` (`off` drops the baked-in
368
412
  server), `SIGIT_PERMISSIONS` (`allow`/`ask`/`deny` — overrides the default permission mode for
369
413
  mutating tools; the escape hatch for clients without permission-request support),
@@ -1,5 +1,93 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.3
4
+
5
+ ### Added
6
+
7
+ - **A client can resume a session and close one.** siGit Code now advertises
8
+ `sessionCapabilities.resume` and `sessionCapabilities.close`.
9
+ `session/resume` restores a saved thread the way `session/load` does, but
10
+ sends none of it back: it is for a client that still has the thread on
11
+ screen, after an agent restart for example. `session/close` cancels the
12
+ session's running turn and drops what the process held for it, which is its
13
+ state, its permission grants and plan mode, the MCP servers the client named
14
+ for it, and the background commands it started. The saved history is left
15
+ alone, so a closed thread still lists and reopens (#149).
16
+
17
+ - **The permission modes are also offered as ACP session modes.** Manual, Auto
18
+ and Plan were only reachable as a config option, which is what the protocol
19
+ now recommends and what Zed reads. A client that draws the older mode
20
+ selector saw nothing. `session/new`, `session/load` and `session/fork` now
21
+ return the same three in `modes`, `session/set_mode` switches between them,
22
+ and a change made any other way (the config option, `/plan`, `/clear`,
23
+ `/reload`) is announced with `current_mode_update`. A client that reads
24
+ config options ignores `modes`, so nothing changes there (#148).
25
+
26
+ - **An editor can sign out of siGit Code.** The `initialize` reply carried an
27
+ empty `auth` object, so a client drew no sign out button, and typing
28
+ `/logout` was the only way out of an account. The reply now advertises
29
+ `agentCapabilities.auth.logout`, and a `logout` request ends the account
30
+ session through the method `/logout` calls. The request names no session,
31
+ so it moves every open thread that used the account, not only the live one:
32
+ a thread parked on a cloud tier goes on-device with the live thread instead
33
+ of failing to restore its tier on its next prompt, and every thread that
34
+ moved gets a fresh model picker. Local inference is switched back on if it
35
+ was off, so a thread opened after signing out asks for no cloud tier
36
+ (#150).
37
+
38
+ ### Changed
39
+
40
+ - **Two threads can run a turn at the same time.** An editor runs one siGit
41
+ Code process for every thread it has open, and a single lock ordered every
42
+ turn-affecting request in it, so a second prompt waited for the first
43
+ thread's whole turn and two prompts never overlapped. The lock held what a
44
+ session installs process-wide — the working directory, the workspace roots,
45
+ the MCP servers the client named for it, the live backend — none of which a
46
+ turn touches while it waits on an HTTP endpoint, which is most of its time.
47
+ A session on an HTTP backend now keeps a backend of its own, so its
48
+ conversation is no longer swapped in and out of a shared one, and a turn
49
+ releases the process-wide lock while it waits on the endpoint or on a
50
+ permission answer and takes it back, reinstalling its session, before any
51
+ tool runs. On-device turns are unchanged, since the engine keeps one
52
+ conversation, and tool execution still runs one session at a time (#176).
53
+
54
+ ### Fixed
55
+
56
+ - **A prompt whose inference failed is kept through a model switch.** A turn
57
+ that ended in an endpoint error stayed in the client's thread, but the
58
+ carryover into a new model dropped its trailing user message, and the error
59
+ path never saved the session. Only a cancelled prompt should be taken back
60
+ out, so cancellation now removes it from the live history, and the error
61
+ path persists the session (#124).
62
+
63
+ ## 1.6.2
64
+
65
+ ### Added
66
+
67
+ - **`sigit --version` prints the release.** `sigit --version` and `sigit -V`
68
+ print `sigit <version>` on stdout and exit 0. Before, the flag was not
69
+ recognized and siGit Code started a session, which left a script that asked
70
+ for the version waiting.
71
+
72
+ - **File tools read and write through the editor when it offers to.** An ACP
73
+ client that advertises `fs.readTextFile` or `fs.writeTextFile` serves those
74
+ requests from its open buffers. siGit Code always used the disk, so
75
+ `read_file` missed unsaved changes and `edit_file` wrote underneath a buffer
76
+ the user might have modified. `read_file`, `create_file`, `edit_file` and
77
+ `multi_edit` now go through the client for paths inside the session's roots.
78
+ The two capabilities are independent, and the disk is still the fallback:
79
+ for a path outside the roots, and for a request the client fails or leaves
80
+ unanswered for 30 seconds. `SIGIT_CLIENT_FS=off` keeps the file tools on
81
+ disk (#147).
82
+
83
+ ### Fixed
84
+
85
+ - **A file attached from the editor is found when its path needs escaping.**
86
+ A `resource_link` was read by stripping `file://` and using the rest as the
87
+ path, so a space arrived as `%20`, a non-ASCII name failed, and a Windows
88
+ URI kept its leading slash. A `#` in a file name was also taken for the
89
+ line-range fragment. The URI is now parsed properly (#170).
90
+
3
91
  ## 1.6.1
4
92
 
5
93
  ### Added
@@ -113,9 +113,20 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
113
113
  - **`src/main.rs`** — entry point, mode dispatch, the full ACP `Agent` impl (session lifecycle:
114
114
  new/load/fork/prompt/cancel, config options, slash-command advertisement), and the `SYSTEM_PROMPT`.
115
115
  ACP session state owns its roots, conversation, and selected model even though the process has
116
- one live backend; activating a thread parks and restores all three. Unknown session ids are
117
- rejected instead of silently borrowing the active thread's cwd. Prompt cancellation is routed
118
- outside `turn_lock`, which lets a client cancel the turn currently holding that lock. The
116
+ one working directory and one live backend slot; activating a thread parks and restores all
117
+ three. A thread on an HTTP backend keeps a backend of its own (`InferenceBackend::fresh`,
118
+ `SessionState::remote`), so its turn can wait on the endpoint while another thread is
119
+ installed. Unknown session ids are rejected instead of silently borrowing the active thread's
120
+ cwd. Prompt cancellation takes no lock, which lets a client cancel a turn whatever it holds.
121
+ `session/load` and `session/resume` share `restore_session`; the only difference is that
122
+ resume must not replay the history as `session/update`. `session/close` is the one place a
123
+ `SessionState` is dropped. It runs in two halves: `begin_close` signals the session's turn
124
+ from the dispatch loop (the turn holds the session's lock, so the signal cannot wait for it) and
125
+ marks the id in `closing_sessions`, which makes `handle_prompt` cancel a prompt that was
126
+ still queued; `handle_close_session` then runs under the session and workspace locks and
127
+ removes the state, the permission grants and the background commands
128
+ (`tools::kill_session_tasks`). It leaves `session_store` alone, so a closed thread still lists
129
+ and reopens. The
119
130
  `SYSTEM_PROMPT` bakes in smbCloud-specific context the agent should use when the repo is clearly
120
131
  smbCloud, and stay general otherwise.
121
132
  - **`src/backend.rs`** — the `InferenceBackend` trait and neutral types (`ToolSpec`, `ToolCall`,
@@ -268,10 +279,21 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
268
279
  announced, because it sets the announced status: a call that will ask starts `pending`, the
269
280
  permission request carries that call's own id, and an `in_progress` update follows approval.
270
281
  That update also puts the card's title back, since the permission request overwrites it
271
- with the full arguments. Note: ACP turn-affecting handlers run in `cx.spawn`ed tasks
272
- serialized by `SiGitAgent::turn_lock` so the dispatch loop can route the client's permission
273
- answer mid-turn — don't move them back inline, and don't await client requests from inline
274
- handlers (deadlock).
282
+ with the full arguments. The Manual/Auto/Plan choice reaches an ACP client twice: as the
283
+ `sigit-permission-mode` config option, and as session `modes` with `session/set_mode`, which
284
+ the spec is retiring but some clients still render instead. Both are built from
285
+ `PERMISSION_MODES` in `main.rs` and read the same state, so whatever changes the mode has to
286
+ refresh both (`ConfigOptionUpdate` and `send_current_mode`). Note: ACP turn-affecting handlers run in `cx.spawn`ed tasks
287
+ so the dispatch loop can route the client's permission answer mid-turn — don't move them back
288
+ inline, and don't await client requests from inline handlers (deadlock). Two locks order them.
289
+ A per-session lock (`SiGitAgent::session_lock`) is held for a whole request, so requests on one
290
+ thread keep their order. `SiGitAgent::workspace_lock` guards what a session installs
291
+ process-wide (cwd, workspace roots, session MCP servers, the live backend slot, the engine's
292
+ conversation); lifecycle and config handlers hold it throughout, and `handle_prompt` holds it
293
+ through a `WorkspaceHold` that it releases while waiting on an HTTP endpoint or a permission
294
+ answer and retakes (reinstalling its session via `resume_workspace`) before any tool runs. An
295
+ on-device turn never releases it. Take the session lock first, never the other way round. Tool
296
+ execution, subagents included, still runs one session at a time.
275
297
  - **`src/instructions.rs`** — project instruction files, the always-on counterpart to skills.
276
298
  Reads `AGENTS.md` (the cross-tool [agents.md](https://agents.md) standard) and `CLAUDE.md`,
277
299
  walking from the session cwd up to the repo root (nearest ancestor with `.git`, never above it),
@@ -299,6 +321,23 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
299
321
  every root. MCP is deliberately not on that list — `mcp::init` runs once at startup, before
300
322
  any session exists, so a second root's `.sigit/mcp.toml` has nobody to tell. (The servers a
301
323
  client names in `mcpServers` are a different thing and are per session; see `src/mcp.rs`.)
324
+ - **`src/client_fs.rs`** — file reads and writes through the ACP client. A client that
325
+ advertises `fs.readTextFile` / `fs.writeTextFile` in `initialize` serves `fs/read_text_file`
326
+ and `fs/write_text_file` from its buffers, so `read_file` sees unsaved changes and
327
+ `edit_file` lands in the open buffer instead of underneath it. `tools.rs` has no access to the
328
+ connection, so this is a seam like the one `mcp::call_tool` gives MCP: `handle_initialize`
329
+ registers a `ClientFileSystem` (`AcpClientFs` in `main.rs`, holding the connection), and
330
+ `execute_tool_impl` asks `route_for` before it dispatches `read_file`, `create_file`,
331
+ `edit_file` or `multi_edit`. Each of those tools is split into a parse step and a pure
332
+ render/apply step (`ReadFileCall`, `CreateFileCall`, `EditCall`) that the disk path and the
333
+ client path share, so the model gets the same result text either way. The two capabilities are
334
+ independent: a client that only reads still gets its edits written to disk. The disk is the
335
+ fallback throughout: nothing is registered in the TUI or headless modes, a path outside the
336
+ session's roots is not routed, and a request the client fails or leaves unanswered for 30
337
+ seconds is retried on disk with a warning in the log. The client is only ever asked from
338
+ inside a spawned prompt turn, never from `handle_initialize` itself (deadlock, same as
339
+ permission requests). Existence checks and `create_file`'s parent directories still use the
340
+ disk. `SIGIT_CLIENT_FS=off` turns the routing off. Covered by `tests/acp_client_fs.rs`.
302
341
  - **`src/chat.rs`** — the Unix-only ratatui TUI. Loading-spinner phase then chat; uses
303
342
  `tokio::select!` to multiplex terminal events with streaming tokens.
304
343
  - **`src/headless.rs`** — non-interactive `sigit run` execution for scripts, CI, and Factory
@@ -320,6 +359,10 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
320
359
  those read env vars once at init.
321
360
  - **`src/account.rs`** — siGit Code Cloud auth (`/login`, `/logout`, `/whoami`); authenticates
322
361
  against the account API and stores a session token. Performs no console I/O.
362
+ ACP `logout` (the editor's sign out button, shown only because `handle_initialize` advertises
363
+ `agentCapabilities.auth.logout`) and `/logout` both go through `SiGitAgent::sign_out` in
364
+ `main.rs`. The request names no session, so `leave_cloud` moves the parked threads off their
365
+ cloud tier along with the live one and each of them gets a fresh model picker.
323
366
  - **`src/browser_auth.rs`** — browser sign-in, the path the editor's "Sign in to siGit Code"
324
367
  button takes. sigit is a public OAuth client (client id `sigit-code-cli`, no secret, PKCE
325
368
  S256) against the authorization server at `$SIGIT_API_URL/oauth`. Two ways the code gets
@@ -363,7 +406,8 @@ verbosity with `RUST_LOG`.
363
406
  `OPENAI_BASE_URL` / `OPENAI_API_KEY` (provider override), `SIGIT_API_URL` (account API base,
364
407
  default `https://sigit.si`), `SIGIT_CLOUD_URL`, `SIGIT_CONFIG_DIR` (default `~/.config/sigit`),
365
408
  `SIGIT_MODEL`, `SIGIT_MAX_TOOL_ROUNDS` (1 to 500, default 24; the tool-round cap of a
366
- headless run or an ACP prompt turn), `SIGIT_MCP` (`off` disables MCP), `SIGIT_MCP_SMBCLOUD` (`off` drops the baked-in
409
+ headless run or an ACP prompt turn), `SIGIT_MCP` (`off` disables MCP), `SIGIT_CLIENT_FS` (`off` keeps the file tools on disk even when the
410
+ ACP client offers `fs/read_text_file` / `fs/write_text_file`), `SIGIT_MCP_SMBCLOUD` (`off` drops the baked-in
367
411
  smbCloud CLI server), `SIGIT_MCP_OFFICIAL` (`off` drops the baked-in
368
412
  server), `SIGIT_PERMISSIONS` (`allow`/`ask`/`deny` — overrides the default permission mode for
369
413
  mutating tools; the escape hatch for clients without permission-request support),
@@ -5778,7 +5778,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
5778
5778
 
5779
5779
  [[package]]
5780
5780
  name = "sigit"
5781
- version = "1.6.1"
5781
+ version = "1.6.3"
5782
5782
  dependencies = [
5783
5783
  "agent-client-protocol",
5784
5784
  "anyhow",
@@ -5801,6 +5801,7 @@ dependencies = [
5801
5801
  "tokio-util",
5802
5802
  "toml 0.8.23",
5803
5803
  "tracing-subscriber",
5804
+ "url",
5804
5805
  "uuid 1.26.0",
5805
5806
  ]
5806
5807
 
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "sigit"
3
- version = "1.6.1"
3
+ version = "1.6.3"
4
4
  edition = "2024"
5
5
  description = "siGit Code — ACP-compatible AI coding agent. Sí, git."
6
6
  documentation = "https://github.com/getsigit/sigit"
@@ -60,6 +60,11 @@ base64 = "0.22"
60
60
  rand = "0.9"
61
61
  sha2 = "0.10"
62
62
 
63
+ # ACP `resource_link` blocks carry file URIs. Parsing them with `url` decodes
64
+ # percent escapes and maps Windows drive paths, which hand-stripping `file://`
65
+ # did not. Already in the graph through reqwest.
66
+ url = "2"
67
+
63
68
  # Release binaries ship to five registries, and the NuGet package bundles all
64
69
  # six targets in a single archive, so binary size is a distribution constraint
65
70
  # rather than a nicety. Stripping symbols takes the macOS arm64 binary from
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sigit-code
3
- Version: 1.6.1
3
+ Version: 1.6.3
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -445,6 +445,15 @@ pub trait InferenceBackend: Send + Sync {
445
445
  /// local model while requests actually go to the cloud.
446
446
  fn is_remote(&self) -> bool;
447
447
 
448
+ /// A backend for the same endpoint and model with an empty conversation,
449
+ /// or `None` when the conversation cannot be separated from the backend
450
+ /// (on-device, the engine holds it). An editor keeps several threads open
451
+ /// in one process; this is what lets each of them own its history instead
452
+ /// of taking turns on a shared one.
453
+ fn fresh(&self) -> Option<Arc<dyn InferenceBackend>> {
454
+ None
455
+ }
456
+
448
457
  /// A serializable snapshot of the conversation history, one JSON object per
449
458
  /// message (`{"role": ..., "content": ...}` at minimum). The snapshot is
450
459
  /// what the session store persists; it includes any seeded system message
@@ -1554,6 +1563,19 @@ impl InferenceBackend for OpenAiBackend {
1554
1563
  true
1555
1564
  }
1556
1565
 
1566
+ fn fresh(&self) -> Option<Arc<dyn InferenceBackend>> {
1567
+ Some(Arc::new(Self {
1568
+ base_url: self.base_url.clone(),
1569
+ api_key: self.api_key.clone(),
1570
+ model: self.model.clone(),
1571
+ // The client is a handle on one connection pool; sharing it keeps
1572
+ // connections to the endpoint warm across sessions.
1573
+ http: self.http.clone(),
1574
+ history: Mutex::new(Vec::new()),
1575
+ accepts_images: self.accepts_images,
1576
+ }))
1577
+ }
1578
+
1557
1579
  async fn history_snapshot(&self) -> Vec<serde_json::Value> {
1558
1580
  self.history.lock().await.clone()
1559
1581
  }
@@ -1944,18 +1966,25 @@ pub fn carryover_history(snapshot: Vec<serde_json::Value>) -> Vec<serde_json::Va
1944
1966
  }
1945
1967
  }
1946
1968
 
1947
- // Session switches happen only after the serialized turn has ended. A
1948
- // trailing user message therefore belongs to a cancelled/failed inference
1949
- // request with no answer; carrying it would make the next activation feed
1950
- // the model an orphaned prompt that the client considers cancelled.
1951
- while carried
1969
+ carried
1970
+ }
1971
+
1972
+ /// Take a cancelled prompt's user message back out of the live history.
1973
+ ///
1974
+ /// The client drops a cancelled prompt from its thread, so the model must not
1975
+ /// keep answering it. Only the first inference round can cancel with the user
1976
+ /// message as the last entry; later rounds end on tool or assistant output.
1977
+ /// A prompt that fails with an error is not taken back: the client keeps it,
1978
+ /// so the history keeps it too and a model switch carries it over.
1979
+ pub async fn forget_trailing_user_message(backend: &dyn InferenceBackend) {
1980
+ let mut history = backend.history_snapshot().await;
1981
+ if history
1952
1982
  .last()
1953
1983
  .is_some_and(|message| message["role"] == "user")
1954
1984
  {
1955
- carried.pop();
1985
+ history.pop();
1986
+ backend.restore_history(history).await;
1956
1987
  }
1957
-
1958
- carried
1959
1988
  }
1960
1989
 
1961
1990
  /// Replay `carried` (from [`carryover_history`]) into `backend`, on top of the
@@ -2161,16 +2190,39 @@ mod tests {
2161
2190
  }
2162
2191
 
2163
2192
  #[test]
2164
- fn carryover_drops_a_cancelled_trailing_user_message() {
2193
+ fn carryover_keeps_a_trailing_user_message_from_a_failed_turn() {
2194
+ // The client keeps a prompt whose inference errored, so a switch must
2195
+ // not drop it (issue #124).
2165
2196
  let carried = carryover_history(vec![
2166
2197
  serde_json::json!({ "role": "system", "content": "prompt" }),
2167
2198
  serde_json::json!({ "role": "user", "content": "completed question" }),
2168
2199
  serde_json::json!({ "role": "assistant", "content": "completed answer" }),
2169
- serde_json::json!({ "role": "user", "content": "cancelled question" }),
2200
+ serde_json::json!({ "role": "user", "content": "failed question" }),
2170
2201
  ]);
2171
2202
 
2172
- assert_eq!(carried.len(), 2, "{carried:#?}");
2173
- assert_eq!(carried.last().unwrap()["content"], "completed answer");
2203
+ assert_eq!(carried.len(), 3, "{carried:#?}");
2204
+ assert_eq!(carried.last().unwrap()["content"], "failed question");
2205
+ }
2206
+
2207
+ #[tokio::test]
2208
+ async fn forget_trailing_user_message_drops_only_a_cancelled_prompt() {
2209
+ let backend = OpenAiBackend::new("http://localhost", "", "m", None);
2210
+ backend
2211
+ .restore_history(vec![
2212
+ serde_json::json!({ "role": "user", "content": "completed question" }),
2213
+ serde_json::json!({ "role": "assistant", "content": "completed answer" }),
2214
+ serde_json::json!({ "role": "user", "content": "cancelled question" }),
2215
+ ])
2216
+ .await;
2217
+
2218
+ forget_trailing_user_message(&backend).await;
2219
+
2220
+ let history = backend.history_snapshot().await;
2221
+ assert_eq!(history.len(), 2, "{history:#?}");
2222
+ assert_eq!(history[1]["content"], "completed answer");
2223
+
2224
+ forget_trailing_user_message(&backend).await;
2225
+ assert_eq!(backend.history_snapshot().await.len(), 2);
2174
2226
  }
2175
2227
 
2176
2228
  #[test]