sigit-code 1.7.0__tar.gz → 1.7.1__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.
- {sigit_code-1.7.0 → sigit_code-1.7.1}/Cargo.lock +2 -2
- {sigit_code-1.7.0 → sigit_code-1.7.1}/Cargo.toml +2 -2
- {sigit_code-1.7.0 → sigit_code-1.7.1}/PKG-INFO +1 -1
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/Cargo.toml +1 -1
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/chat.rs +6 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/main.rs +52 -21
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/setup.rs +117 -102
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/tools.rs +119 -66
- {sigit_code-1.7.0 → sigit_code-1.7.1}/README.md +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/README.md +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/account.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/backend.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/client_fs.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/client_terminal.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/commands.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/frontmatter.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/harness_markup.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/headless.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/hooks.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/inline_tool_calls.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/instructions.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/mcp.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/models.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/permissions.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/provider.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/repetition.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/session_store.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/settings.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/skills.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/subagents.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/src/workspace.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/tests/acp_audio.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/tests/acp_client_fs.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/tests/acp_client_terminal.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/tests/acp_concurrent_sessions.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/tests/acp_elicitation.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/tests/acp_embedded_context.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/tests/acp_endpoint_errors.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/tests/acp_error_codes.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/tests/acp_harness_markup.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/tests/acp_images.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/tests/acp_logout.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/tests/acp_multi_root.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/tests/acp_permissions.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/tests/acp_session_list.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/tests/acp_session_load.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/tests/acp_stop_reasons.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/tests/acp_tool_call_name.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/tests/acp_tool_stdin.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/tests/headless_mode.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/cli/tests/version_flag.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/sigit-agent/Cargo.toml +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/sigit-agent/src/auth.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/sigit-agent/src/browser_auth.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/sigit-agent/src/credentials.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/sigit-agent/src/lib.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/sigit-agent/src/profile.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/sigit-agent/src/prompt.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/crates/sigit-agent/tests/embedded.rs +0 -0
- {sigit_code-1.7.0 → sigit_code-1.7.1}/pyproject.toml +0 -0
|
@@ -5945,7 +5945,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
|
|
5945
5945
|
|
|
5946
5946
|
[[package]]
|
|
5947
5947
|
name = "sigit"
|
|
5948
|
-
version = "1.7.
|
|
5948
|
+
version = "1.7.1"
|
|
5949
5949
|
dependencies = [
|
|
5950
5950
|
"agent-client-protocol",
|
|
5951
5951
|
"anyhow",
|
|
@@ -5976,7 +5976,7 @@ dependencies = [
|
|
|
5976
5976
|
|
|
5977
5977
|
[[package]]
|
|
5978
5978
|
name = "sigit-agent"
|
|
5979
|
-
version = "1.7.
|
|
5979
|
+
version = "1.7.1"
|
|
5980
5980
|
dependencies = [
|
|
5981
5981
|
"agent-client-protocol",
|
|
5982
5982
|
"anyhow",
|
|
@@ -3,7 +3,7 @@ resolver = "3"
|
|
|
3
3
|
members = ["crates/cli", "crates/sigit-*"]
|
|
4
4
|
|
|
5
5
|
[workspace.package]
|
|
6
|
-
version = "1.7.
|
|
6
|
+
version = "1.7.1"
|
|
7
7
|
edition = "2024"
|
|
8
8
|
license = "Apache-2.0"
|
|
9
9
|
repository = "https://github.com/getsigit/sigit"
|
|
@@ -16,7 +16,7 @@ authors = ["Seto Elkahfi <seto@sigit.si>", "paydii <193906237+paydii@users.norep
|
|
|
16
16
|
# crates.io, so this resolves from the registry rather than the onde-ed checkout next door.
|
|
17
17
|
ed-acp = { version = "1.3.0", default-features = false }
|
|
18
18
|
ed-acp-testkit = "1.3.0"
|
|
19
|
-
sigit-agent = { version = "1.7.
|
|
19
|
+
sigit-agent = { version = "1.7.1", default-features = false, path = "crates/sigit-agent" }
|
|
20
20
|
|
|
21
21
|
# ACP protocol SDK
|
|
22
22
|
agent-client-protocol = { version = "2.2", features = ["unstable_session_fork"] }
|
|
@@ -8,7 +8,7 @@ license.workspace = true
|
|
|
8
8
|
repository.workspace = true
|
|
9
9
|
homepage.workspace = true
|
|
10
10
|
readme = "README.md"
|
|
11
|
-
keywords = ["cli", "ai", "coding-agent", "llm", "acp"
|
|
11
|
+
keywords = ["cli", "ai", "coding-agent", "llm", "acp"]
|
|
12
12
|
categories = ["command-line-utilities"]
|
|
13
13
|
authors.workspace = true
|
|
14
14
|
# `src/bin/` holds test-only helper binaries (the MCP stdio stub used by the
|
|
@@ -1634,6 +1634,10 @@ mod tui {
|
|
|
1634
1634
|
};
|
|
1635
1635
|
if app.history_pending_delete.as_deref() == Some(id.as_str()) {
|
|
1636
1636
|
crate::session_store::delete(&id);
|
|
1637
|
+
// A plan must not outlive its conversation. The id is
|
|
1638
|
+
// the live session's key when the user deletes the
|
|
1639
|
+
// thread the TUI is currently running.
|
|
1640
|
+
crate::tools::clear_todos(&id);
|
|
1637
1641
|
app.refresh_history();
|
|
1638
1642
|
app.history_notice = Some(format!("Deleted session '{id}'."));
|
|
1639
1643
|
} else {
|
|
@@ -2837,6 +2841,8 @@ mod tui {
|
|
|
2837
2841
|
crate::permissions::reset_session(crate::permissions::TUI_SESSION);
|
|
2838
2842
|
// The saved session must not resurrect what the user just wiped.
|
|
2839
2843
|
crate::session_store::delete(TUI_STORE_SESSION);
|
|
2844
|
+
// The plan belongs to the conversation, so the wipe drops it too.
|
|
2845
|
+
crate::tools::clear_todos(crate::permissions::TUI_SESSION);
|
|
2840
2846
|
app.context_tokens_used = None;
|
|
2841
2847
|
app.messages.push(ChatMessage::system(format!(
|
|
2842
2848
|
"Cleared {cleared} turn(s). History is empty.",
|
|
@@ -743,11 +743,12 @@ fn todos_arguments_to_plan(arguments: &str) -> Option<Plan> {
|
|
|
743
743
|
Some(Plan::new(entries))
|
|
744
744
|
}
|
|
745
745
|
|
|
746
|
-
/// Rebuilds an ACP [`Plan`] from the persisted todo list in
|
|
747
|
-
/// Used to re-emit the plan on error turns so the client
|
|
748
|
-
/// when the model never got to (or forgot to) call
|
|
749
|
-
|
|
750
|
-
|
|
746
|
+
/// Rebuilds an ACP [`Plan`] from the session's persisted todo list in
|
|
747
|
+
/// `tools::get_todos()`. Used to re-emit the plan on error turns so the client
|
|
748
|
+
/// panel stays populated when the model never got to (or forgot to) call
|
|
749
|
+
/// `write_todos` this turn.
|
|
750
|
+
fn persisted_todos_to_plan(session: &SessionId) -> Option<Plan> {
|
|
751
|
+
let todos = tools::get_todos(&session.to_string())?;
|
|
751
752
|
let entries = todos
|
|
752
753
|
.into_iter()
|
|
753
754
|
.map(|todo| {
|
|
@@ -3011,10 +3012,11 @@ impl SiGitAgent {
|
|
|
3011
3012
|
///
|
|
3012
3013
|
/// Drops everything this process holds for it: the parked state (and with
|
|
3013
3014
|
/// it the MCP servers the client named), its permission grants and plan
|
|
3014
|
-
/// mode, and the background commands it started. The saved
|
|
3015
|
-
/// so the thread still lists and reopens. The caller has
|
|
3016
|
-
/// the session's running turn (see `begin_close`) and
|
|
3017
|
-
/// lock and `workspace_lock`, so that turn has ended by
|
|
3015
|
+
/// mode, its todo list, and the background commands it started. The saved
|
|
3016
|
+
/// history stays, so the thread still lists and reopens. The caller has
|
|
3017
|
+
/// already signalled the session's running turn (see `begin_close`) and
|
|
3018
|
+
/// holds the session's lock and `workspace_lock`, so that turn has ended by
|
|
3019
|
+
/// the time this runs.
|
|
3018
3020
|
///
|
|
3019
3021
|
/// An id this process does not hold is answered with success. The spec
|
|
3020
3022
|
/// allows an error there, but the client wants the session gone and it is.
|
|
@@ -3050,6 +3052,10 @@ impl SiGitAgent {
|
|
|
3050
3052
|
self.announced_titles.lock().unwrap().remove(&key);
|
|
3051
3053
|
let killed = tools::kill_session_tasks(&key);
|
|
3052
3054
|
tools::forget_abort(&key);
|
|
3055
|
+
// The plan is the conversation's, so it goes with the conversation:
|
|
3056
|
+
// an id that comes back later starts with an empty panel rather than
|
|
3057
|
+
// the previous life's list.
|
|
3058
|
+
tools::clear_todos(&key);
|
|
3053
3059
|
self.closing_sessions.lock().unwrap().remove(&key);
|
|
3054
3060
|
|
|
3055
3061
|
log::info!(
|
|
@@ -3599,7 +3605,7 @@ impl SiGitAgent {
|
|
|
3599
3605
|
// Re-emit the persisted plan so the client panel doesn't go
|
|
3600
3606
|
// empty when the initial inference call fails before the model
|
|
3601
3607
|
// can call `write_todos`.
|
|
3602
|
-
if let Some(plan) = persisted_todos_to_plan() {
|
|
3608
|
+
if let Some(plan) = persisted_todos_to_plan(&session_id) {
|
|
3603
3609
|
let _ = self.send_plan_update(cx, session_id.clone(), plan);
|
|
3604
3610
|
}
|
|
3605
3611
|
// Verbatim, no prefix: the backend has already turned this into
|
|
@@ -3996,7 +4002,7 @@ impl SiGitAgent {
|
|
|
3996
4002
|
log::error!("send_tool_results failed: {error}");
|
|
3997
4003
|
// Re-emit the persisted plan so the client panel doesn't go
|
|
3998
4004
|
// empty when a tool round fails mid-turn.
|
|
3999
|
-
if let Some(plan) = persisted_todos_to_plan() {
|
|
4005
|
+
if let Some(plan) = persisted_todos_to_plan(&session_id) {
|
|
4000
4006
|
let _ = self.send_plan_update(cx, session_id.clone(), plan);
|
|
4001
4007
|
}
|
|
4002
4008
|
self.finish_prompt(&session_id, &cancellation);
|
|
@@ -5492,6 +5498,13 @@ async fn exec_slash_acp(
|
|
|
5492
5498
|
permissions::reset_session(&session_key);
|
|
5493
5499
|
// The saved session must not resurrect what the user just wiped.
|
|
5494
5500
|
session_store::delete(&session_key);
|
|
5501
|
+
// The plan belongs to the conversation, so the wipe drops it too.
|
|
5502
|
+
// The empty plan is what clears the client's panel: without it the
|
|
5503
|
+
// editor keeps showing the list of the thread that no longer exists.
|
|
5504
|
+
tools::clear_todos(&session_key);
|
|
5505
|
+
agent
|
|
5506
|
+
.send_plan_update(cx, session_id.clone(), Plan::new(Vec::new()))
|
|
5507
|
+
.ok();
|
|
5495
5508
|
// The wipe drops the session's Permissions override too; push the
|
|
5496
5509
|
// refreshed chip so it doesn't keep showing a stale pick.
|
|
5497
5510
|
let config_options = {
|
|
@@ -5922,6 +5935,12 @@ fn init_logging(is_tty: bool) {
|
|
|
5922
5935
|
/// LeaveAlternateScreen (ratatui 0.29 hides `writer_mut()`).
|
|
5923
5936
|
#[cfg(unix)]
|
|
5924
5937
|
async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> anyhow::Result<()> {
|
|
5938
|
+
// The TUI is one long-lived conversation and shares the key the permission
|
|
5939
|
+
// and session stores already use, so its tool calls are scoped to it —
|
|
5940
|
+
// background task ownership and the todo list — instead of the session-less
|
|
5941
|
+
// `""` key, which no teardown path can name.
|
|
5942
|
+
tools::set_active_session(Some(permissions::TUI_SESSION));
|
|
5943
|
+
|
|
5925
5944
|
let engine = Arc::new(ChatEngine::new());
|
|
5926
5945
|
|
|
5927
5946
|
let startup_selection = setup::startup_model_selection();
|
|
@@ -6042,6 +6061,18 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
|
|
|
6042
6061
|
}
|
|
6043
6062
|
}
|
|
6044
6063
|
|
|
6064
|
+
// The conversation is over, however it ended (`/exit`, Ctrl+C, a panic in
|
|
6065
|
+
// the event loop). Drop what it owned — background tasks, the plan, the
|
|
6066
|
+
// permission grants and the abort slot — and detach the active session, so
|
|
6067
|
+
// nothing outlives the conversation. The saved history deliberately stays:
|
|
6068
|
+
// it is what `/resume` brings back on the next launch.
|
|
6069
|
+
let killed = tools::kill_session_tasks(permissions::TUI_SESSION);
|
|
6070
|
+
tools::clear_todos(permissions::TUI_SESSION);
|
|
6071
|
+
tools::forget_abort(permissions::TUI_SESSION);
|
|
6072
|
+
permissions::reset_session(permissions::TUI_SESSION);
|
|
6073
|
+
tools::set_active_session(None);
|
|
6074
|
+
log::info!("tui exit: {killed} background task(s) stopped");
|
|
6075
|
+
|
|
6045
6076
|
chat_result
|
|
6046
6077
|
}
|
|
6047
6078
|
|
|
@@ -6896,7 +6927,7 @@ mod tests {
|
|
|
6896
6927
|
async fn unknown_session_is_rejected_instead_of_borrowing_live_cwd() {
|
|
6897
6928
|
let agent = SiGitAgent::new(
|
|
6898
6929
|
Arc::new(ChatEngine::new()),
|
|
6899
|
-
|
|
6930
|
+
GgufModelConfig::qwen25_3b(),
|
|
6900
6931
|
Arc::new(AtomicBool::new(true)),
|
|
6901
6932
|
Arc::new(AtomicBool::new(false)),
|
|
6902
6933
|
Arc::new(std::sync::Mutex::new(None)),
|
|
@@ -6917,7 +6948,7 @@ mod tests {
|
|
|
6917
6948
|
async fn a_turn_leaves_no_cancellation_behind_however_it_returns() {
|
|
6918
6949
|
let agent = SiGitAgent::new(
|
|
6919
6950
|
Arc::new(ChatEngine::new()),
|
|
6920
|
-
|
|
6951
|
+
GgufModelConfig::qwen25_3b(),
|
|
6921
6952
|
Arc::new(AtomicBool::new(true)),
|
|
6922
6953
|
Arc::new(AtomicBool::new(false)),
|
|
6923
6954
|
Arc::new(std::sync::Mutex::new(None)),
|
|
@@ -6956,7 +6987,7 @@ mod tests {
|
|
|
6956
6987
|
async fn a_finished_turn_does_not_remove_its_successors_cancellation() {
|
|
6957
6988
|
let agent = SiGitAgent::new(
|
|
6958
6989
|
Arc::new(ChatEngine::new()),
|
|
6959
|
-
|
|
6990
|
+
GgufModelConfig::qwen25_3b(),
|
|
6960
6991
|
Arc::new(AtomicBool::new(true)),
|
|
6961
6992
|
Arc::new(AtomicBool::new(false)),
|
|
6962
6993
|
Arc::new(std::sync::Mutex::new(None)),
|
|
@@ -6982,7 +7013,7 @@ mod tests {
|
|
|
6982
7013
|
async fn cancel_signals_only_the_matching_active_prompt() {
|
|
6983
7014
|
let agent = SiGitAgent::new(
|
|
6984
7015
|
Arc::new(ChatEngine::new()),
|
|
6985
|
-
|
|
7016
|
+
GgufModelConfig::qwen25_3b(),
|
|
6986
7017
|
Arc::new(AtomicBool::new(true)),
|
|
6987
7018
|
Arc::new(AtomicBool::new(false)),
|
|
6988
7019
|
Arc::new(std::sync::Mutex::new(None)),
|
|
@@ -7034,7 +7065,7 @@ mod tests {
|
|
|
7034
7065
|
std::env::set_var("SIGIT_LOCAL_INFERENCE", "on");
|
|
7035
7066
|
}
|
|
7036
7067
|
|
|
7037
|
-
let local =
|
|
7068
|
+
let local = GgufModelConfig::qwen25_3b();
|
|
7038
7069
|
let agent = SiGitAgent::new(
|
|
7039
7070
|
Arc::new(ChatEngine::new()),
|
|
7040
7071
|
local.clone(),
|
|
@@ -7131,7 +7162,7 @@ mod tests {
|
|
|
7131
7162
|
|
|
7132
7163
|
let agent = SiGitAgent::new(
|
|
7133
7164
|
Arc::new(ChatEngine::new()),
|
|
7134
|
-
|
|
7165
|
+
GgufModelConfig::qwen25_3b(),
|
|
7135
7166
|
Arc::new(AtomicBool::new(true)),
|
|
7136
7167
|
Arc::new(AtomicBool::new(false)),
|
|
7137
7168
|
Arc::new(std::sync::Mutex::new(None)),
|
|
@@ -7287,7 +7318,7 @@ mod tests {
|
|
|
7287
7318
|
|
|
7288
7319
|
let agent = SiGitAgent::new(
|
|
7289
7320
|
Arc::new(ChatEngine::new()),
|
|
7290
|
-
|
|
7321
|
+
GgufModelConfig::qwen25_3b(),
|
|
7291
7322
|
Arc::new(AtomicBool::new(true)),
|
|
7292
7323
|
Arc::new(AtomicBool::new(false)),
|
|
7293
7324
|
Arc::new(std::sync::Mutex::new(None)),
|
|
@@ -7378,7 +7409,7 @@ mod tests {
|
|
|
7378
7409
|
|
|
7379
7410
|
let agent = SiGitAgent::new(
|
|
7380
7411
|
Arc::new(ChatEngine::new()),
|
|
7381
|
-
|
|
7412
|
+
GgufModelConfig::qwen25_3b(),
|
|
7382
7413
|
Arc::new(AtomicBool::new(true)),
|
|
7383
7414
|
Arc::new(AtomicBool::new(false)),
|
|
7384
7415
|
Arc::new(std::sync::Mutex::new(None)),
|
|
@@ -7477,7 +7508,7 @@ mod tests {
|
|
|
7477
7508
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
7478
7509
|
let agent = Arc::new(SiGitAgent::new(
|
|
7479
7510
|
Arc::new(ChatEngine::new()),
|
|
7480
|
-
|
|
7511
|
+
GgufModelConfig::qwen25_3b(),
|
|
7481
7512
|
Arc::new(AtomicBool::new(true)),
|
|
7482
7513
|
Arc::new(AtomicBool::new(false)),
|
|
7483
7514
|
Arc::new(std::sync::Mutex::new(None)),
|
|
@@ -7525,7 +7556,7 @@ mod tests {
|
|
|
7525
7556
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
7526
7557
|
let agent = SiGitAgent::new(
|
|
7527
7558
|
Arc::new(ChatEngine::new()),
|
|
7528
|
-
|
|
7559
|
+
GgufModelConfig::qwen25_3b(),
|
|
7529
7560
|
Arc::new(AtomicBool::new(true)),
|
|
7530
7561
|
Arc::new(AtomicBool::new(false)),
|
|
7531
7562
|
Arc::new(std::sync::Mutex::new(None)),
|
|
@@ -109,27 +109,70 @@ pub enum ModelCacheHealth {
|
|
|
109
109
|
NotDownloaded,
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/// Where the model store lives, resolved once and handed to the readers.
|
|
113
|
+
///
|
|
114
|
+
/// Resolving it from the environment is a global decision, so a caller that
|
|
115
|
+
/// needs a store of its own — a test pointing at a scratch directory — passes
|
|
116
|
+
/// the roots in rather than mutating `HF_HUB_CACHE`, `HF_HOME` and `HOME` out
|
|
117
|
+
/// from under whatever else is running in the same process.
|
|
118
|
+
#[derive(Debug, Clone, Default)]
|
|
119
|
+
struct ModelRoots {
|
|
120
|
+
/// Onde App Group model hub, when the signed app has run at least once.
|
|
121
|
+
app_group: Option<PathBuf>,
|
|
122
|
+
/// `HF_HUB_CACHE`, or `HF_HOME/hub`, when set and present.
|
|
123
|
+
configured_cache: Option<PathBuf>,
|
|
124
|
+
/// `~/.cache/huggingface/hub`, when present.
|
|
125
|
+
default_cache: Option<PathBuf>,
|
|
126
|
+
/// File holding the persisted selection.
|
|
127
|
+
selected_model_file: Option<PathBuf>,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
impl ModelRoots {
|
|
131
|
+
fn from_env() -> Self {
|
|
132
|
+
Self {
|
|
133
|
+
app_group: app_group_models_root(),
|
|
134
|
+
configured_cache: hf_cache_root(),
|
|
135
|
+
default_cache: default_hf_cache_root(),
|
|
136
|
+
selected_model_file: selected_model_file_path(),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/// One cache dir and one selection file, with no environment lookups at all.
|
|
141
|
+
#[cfg(test)]
|
|
142
|
+
fn in_dir(cache_root: &Path, selected_model_file: &Path) -> Self {
|
|
143
|
+
Self {
|
|
144
|
+
configured_cache: Some(cache_root.to_path_buf()),
|
|
145
|
+
selected_model_file: Some(selected_model_file.to_path_buf()),
|
|
146
|
+
..Self::default()
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
112
151
|
/// find all GGUF models on disk. checks Onde app group first, then HF cache.
|
|
113
152
|
pub fn discover_local_models() -> Vec<DiscoveredModel> {
|
|
153
|
+
discover_local_models_in(&ModelRoots::from_env())
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
fn discover_local_models_in(roots: &ModelRoots) -> Vec<DiscoveredModel> {
|
|
114
157
|
let mut models = Vec::new();
|
|
115
158
|
let mut seen_roots = Vec::new();
|
|
116
159
|
|
|
117
|
-
if let Some(app_group_models) =
|
|
160
|
+
if let Some(app_group_models) = roots.app_group.as_ref() {
|
|
118
161
|
seen_roots.push(app_group_models.clone());
|
|
119
|
-
collect_models_from_cache_root(
|
|
162
|
+
collect_models_from_cache_root(app_group_models, true, &mut models);
|
|
120
163
|
}
|
|
121
164
|
|
|
122
|
-
if let Some(hf_cache) =
|
|
123
|
-
&& !seen_roots.iter().any(|root| root ==
|
|
165
|
+
if let Some(hf_cache) = roots.configured_cache.as_ref()
|
|
166
|
+
&& !seen_roots.iter().any(|root| root == hf_cache)
|
|
124
167
|
{
|
|
125
168
|
seen_roots.push(hf_cache.clone());
|
|
126
|
-
collect_models_from_cache_root(
|
|
169
|
+
collect_models_from_cache_root(hf_cache, false, &mut models);
|
|
127
170
|
}
|
|
128
171
|
|
|
129
|
-
if let Some(default_hf_cache) =
|
|
130
|
-
&& !seen_roots.iter().any(|root| root ==
|
|
172
|
+
if let Some(default_hf_cache) = roots.default_cache.as_ref()
|
|
173
|
+
&& !seen_roots.iter().any(|root| root == default_hf_cache)
|
|
131
174
|
{
|
|
132
|
-
collect_models_from_cache_root(
|
|
175
|
+
collect_models_from_cache_root(default_hf_cache, false, &mut models);
|
|
133
176
|
}
|
|
134
177
|
|
|
135
178
|
models.sort_by(|left, right| {
|
|
@@ -300,7 +343,10 @@ fn default_hf_cache_root() -> Option<PathBuf> {
|
|
|
300
343
|
}
|
|
301
344
|
|
|
302
345
|
pub fn load_selected_model() -> Option<SelectedModel> {
|
|
303
|
-
|
|
346
|
+
load_selected_model_from(&selected_model_file_path()?)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
fn load_selected_model_from(path: &Path) -> Option<SelectedModel> {
|
|
304
350
|
let contents = std::fs::read_to_string(path).ok()?;
|
|
305
351
|
let trimmed = contents.trim();
|
|
306
352
|
if trimmed.is_empty() {
|
|
@@ -333,9 +379,16 @@ pub fn load_selected_model_name() -> Option<String> {
|
|
|
333
379
|
/// pick a model for startup: saved selection > first local model > none.
|
|
334
380
|
/// if we fall back to a local model, persist it so ACP and TUI agree next time.
|
|
335
381
|
pub fn startup_model_selection() -> Option<StartupModelSelection> {
|
|
336
|
-
|
|
382
|
+
startup_model_selection_in(&ModelRoots::from_env())
|
|
383
|
+
}
|
|
337
384
|
|
|
338
|
-
|
|
385
|
+
fn startup_model_selection_in(roots: &ModelRoots) -> Option<StartupModelSelection> {
|
|
386
|
+
let discovered = discover_local_models_in(roots);
|
|
387
|
+
|
|
388
|
+
if let Some(saved_model) = roots
|
|
389
|
+
.selected_model_file
|
|
390
|
+
.as_deref()
|
|
391
|
+
.and_then(load_selected_model_from)
|
|
339
392
|
&& let Some(model) = discovered.iter().find(|model| {
|
|
340
393
|
saved_model.matches(model) && model.cache_health == ModelCacheHealth::Complete
|
|
341
394
|
})
|
|
@@ -351,7 +404,9 @@ pub fn startup_model_selection() -> Option<StartupModelSelection> {
|
|
|
351
404
|
.find(|model| model.cache_health == ModelCacheHealth::Complete)
|
|
352
405
|
.map(|model| {
|
|
353
406
|
let selected_model = SelectedModel::from_discovered(&model);
|
|
354
|
-
let
|
|
407
|
+
if let Some(path) = roots.selected_model_file.as_deref() {
|
|
408
|
+
let _ = save_selected_model_to(path, &selected_model);
|
|
409
|
+
}
|
|
355
410
|
StartupModelSelection {
|
|
356
411
|
display_name: model.display_name.clone(),
|
|
357
412
|
selected_model: Some(selected_model),
|
|
@@ -363,6 +418,10 @@ pub fn save_selected_model(selected_model: &SelectedModel) -> Result<(), String>
|
|
|
363
418
|
let path = selected_model_file_path()
|
|
364
419
|
.ok_or_else(|| "Could not determine where to store the selected model.".to_string())?;
|
|
365
420
|
|
|
421
|
+
save_selected_model_to(&path, selected_model)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
fn save_selected_model_to(path: &Path, selected_model: &SelectedModel) -> Result<(), String> {
|
|
366
425
|
if let Some(parent) = path.parent()
|
|
367
426
|
&& !parent.exists()
|
|
368
427
|
{
|
|
@@ -375,7 +434,7 @@ pub fn save_selected_model(selected_model: &SelectedModel) -> Result<(), String>
|
|
|
375
434
|
selected_model.model_id, selected_model.gguf_file
|
|
376
435
|
);
|
|
377
436
|
|
|
378
|
-
std::fs::write(
|
|
437
|
+
std::fs::write(path, contents)
|
|
379
438
|
.map_err(|error| format!("Could not save selected model: {error}"))
|
|
380
439
|
}
|
|
381
440
|
|
|
@@ -431,22 +490,32 @@ fn resolve_shared_container() -> Option<PathBuf> {
|
|
|
431
490
|
#[cfg(test)]
|
|
432
491
|
mod tests {
|
|
433
492
|
use super::*;
|
|
434
|
-
use std::sync::Mutex;
|
|
435
493
|
|
|
436
|
-
///
|
|
437
|
-
///
|
|
438
|
-
///
|
|
439
|
-
|
|
440
|
-
|
|
494
|
+
/// A cache dir and selection file owned by this test. The model store is
|
|
495
|
+
/// resolved from `HF_HUB_CACHE`/`HF_HOME`/`HOME`, which are process-global
|
|
496
|
+
/// and read by the picker on other threads, so tests hand their roots in
|
|
497
|
+
/// instead of aiming those variables at a scratch directory.
|
|
498
|
+
fn test_roots(cache_root: &Path, selection_dir: &Path) -> ModelRoots {
|
|
499
|
+
ModelRoots::in_dir(cache_root, &selection_dir.join(SELECTED_MODEL_FILE_NAME))
|
|
441
500
|
}
|
|
442
501
|
|
|
502
|
+
/// A scratch directory no other test in this run can land on. The clock
|
|
503
|
+
/// alone is not enough: its resolution is coarse on some platforms and
|
|
504
|
+
/// parallel test threads can read the same tick, so the pid and a
|
|
505
|
+
/// process-local counter are mixed in.
|
|
443
506
|
fn unique_temp_dir(name: &str) -> PathBuf {
|
|
507
|
+
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
508
|
+
|
|
444
509
|
let nanos = std::time::SystemTime::now()
|
|
445
510
|
.duration_since(std::time::UNIX_EPOCH)
|
|
446
511
|
.expect("system time before unix epoch")
|
|
447
512
|
.as_nanos();
|
|
513
|
+
let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
448
514
|
|
|
449
|
-
std::env::temp_dir().join(format!(
|
|
515
|
+
std::env::temp_dir().join(format!(
|
|
516
|
+
"sigit-setup-tests-{name}-{}-{seq}-{nanos}",
|
|
517
|
+
std::process::id()
|
|
518
|
+
))
|
|
450
519
|
}
|
|
451
520
|
|
|
452
521
|
fn create_snapshot(
|
|
@@ -474,48 +543,11 @@ mod tests {
|
|
|
474
543
|
snapshot_dir
|
|
475
544
|
}
|
|
476
545
|
|
|
477
|
-
fn with_test_env<T>(hf_hub_cache: &Path, hf_home: &Path, f: impl FnOnce() -> T) -> T {
|
|
478
|
-
let _guard = env_lock().lock().expect("lock env");
|
|
479
|
-
|
|
480
|
-
let old_hf_hub_cache = std::env::var_os("HF_HUB_CACHE");
|
|
481
|
-
let old_hf_home = std::env::var_os("HF_HOME");
|
|
482
|
-
let old_home = std::env::var_os("HOME");
|
|
483
|
-
|
|
484
|
-
// SAFETY: tests serialize environment mutation with a process-wide mutex.
|
|
485
|
-
unsafe {
|
|
486
|
-
std::env::set_var("HF_HUB_CACHE", hf_hub_cache);
|
|
487
|
-
std::env::set_var("HF_HOME", hf_home);
|
|
488
|
-
std::env::set_var("HOME", hf_home);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
let result = f();
|
|
492
|
-
|
|
493
|
-
// SAFETY: tests serialize environment mutation with a process-wide mutex.
|
|
494
|
-
unsafe {
|
|
495
|
-
match old_hf_hub_cache {
|
|
496
|
-
Some(value) => std::env::set_var("HF_HUB_CACHE", value),
|
|
497
|
-
None => std::env::remove_var("HF_HUB_CACHE"),
|
|
498
|
-
}
|
|
499
|
-
match old_hf_home {
|
|
500
|
-
Some(value) => std::env::set_var("HF_HOME", value),
|
|
501
|
-
None => std::env::remove_var("HF_HOME"),
|
|
502
|
-
}
|
|
503
|
-
match old_home {
|
|
504
|
-
Some(value) => std::env::set_var("HOME", value),
|
|
505
|
-
None => std::env::remove_var("HOME"),
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
result
|
|
510
|
-
}
|
|
511
|
-
|
|
512
546
|
#[test]
|
|
513
547
|
fn discover_local_models_marks_complete_and_incomplete_snapshots() {
|
|
514
548
|
let root = unique_temp_dir("discover-health");
|
|
515
549
|
let cache_root = root.join("hf-cache");
|
|
516
|
-
let hf_home = root.join("hf-home");
|
|
517
550
|
std::fs::create_dir_all(&cache_root).expect("create cache root");
|
|
518
|
-
std::fs::create_dir_all(&hf_home).expect("create hf home");
|
|
519
551
|
|
|
520
552
|
create_snapshot(
|
|
521
553
|
&cache_root,
|
|
@@ -532,7 +564,7 @@ mod tests {
|
|
|
532
564
|
false,
|
|
533
565
|
);
|
|
534
566
|
|
|
535
|
-
let models =
|
|
567
|
+
let models = discover_local_models_in(&test_roots(&cache_root, &root));
|
|
536
568
|
|
|
537
569
|
assert_eq!(models.len(), 2);
|
|
538
570
|
|
|
@@ -557,9 +589,7 @@ mod tests {
|
|
|
557
589
|
fn startup_model_selection_skips_saved_incomplete_model_and_picks_complete_one() {
|
|
558
590
|
let root = unique_temp_dir("startup-selection");
|
|
559
591
|
let cache_root = root.join("hf-cache");
|
|
560
|
-
let hf_home = root.join("hf-home");
|
|
561
592
|
std::fs::create_dir_all(&cache_root).expect("create cache root");
|
|
562
|
-
std::fs::create_dir_all(&hf_home).expect("create hf home");
|
|
563
593
|
|
|
564
594
|
create_snapshot(
|
|
565
595
|
&cache_root,
|
|
@@ -576,20 +606,18 @@ mod tests {
|
|
|
576
606
|
true,
|
|
577
607
|
);
|
|
578
608
|
|
|
579
|
-
let
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
609
|
+
let roots = test_roots(&cache_root, &root);
|
|
610
|
+
let selected_path = roots
|
|
611
|
+
.selected_model_file
|
|
612
|
+
.clone()
|
|
613
|
+
.expect("selected model path");
|
|
614
|
+
std::fs::write(
|
|
615
|
+
&selected_path,
|
|
616
|
+
"bartowski/Qwen2.5-Coder-3B-Instruct-GGUF\nQwen2.5-Coder-3B-Instruct-Q4_K_M.gguf\n",
|
|
617
|
+
)
|
|
618
|
+
.expect("write selected model");
|
|
584
619
|
|
|
585
|
-
|
|
586
|
-
&selected_path,
|
|
587
|
-
"bartowski/Qwen2.5-Coder-3B-Instruct-GGUF\nQwen2.5-Coder-3B-Instruct-Q4_K_M.gguf\n",
|
|
588
|
-
)
|
|
589
|
-
.expect("write selected model");
|
|
590
|
-
|
|
591
|
-
startup_model_selection().expect("startup selection")
|
|
592
|
-
});
|
|
620
|
+
let selection = startup_model_selection_in(&roots).expect("startup selection");
|
|
593
621
|
|
|
594
622
|
assert_eq!(
|
|
595
623
|
selection.display_name,
|
|
@@ -606,11 +634,9 @@ mod tests {
|
|
|
606
634
|
fn discover_empty_cache_returns_no_models() {
|
|
607
635
|
let root = unique_temp_dir("discover-empty");
|
|
608
636
|
let cache_root = root.join("hf-cache");
|
|
609
|
-
let hf_home = root.join("hf-home");
|
|
610
637
|
std::fs::create_dir_all(&cache_root).expect("create cache root");
|
|
611
|
-
std::fs::create_dir_all(&hf_home).expect("create hf home");
|
|
612
638
|
|
|
613
|
-
let models =
|
|
639
|
+
let models = discover_local_models_in(&test_roots(&cache_root, &root));
|
|
614
640
|
assert!(models.is_empty());
|
|
615
641
|
|
|
616
642
|
std::fs::remove_dir_all(root).expect("remove temp dir");
|
|
@@ -620,9 +646,7 @@ mod tests {
|
|
|
620
646
|
fn complete_models_sort_before_incomplete() {
|
|
621
647
|
let root = unique_temp_dir("sort-order");
|
|
622
648
|
let cache_root = root.join("hf-cache");
|
|
623
|
-
let hf_home = root.join("hf-home");
|
|
624
649
|
std::fs::create_dir_all(&cache_root).expect("create cache root");
|
|
625
|
-
std::fs::create_dir_all(&hf_home).expect("create hf home");
|
|
626
650
|
|
|
627
651
|
create_snapshot(
|
|
628
652
|
&cache_root,
|
|
@@ -639,7 +663,7 @@ mod tests {
|
|
|
639
663
|
true,
|
|
640
664
|
);
|
|
641
665
|
|
|
642
|
-
let models =
|
|
666
|
+
let models = discover_local_models_in(&test_roots(&cache_root, &root));
|
|
643
667
|
assert_eq!(models.len(), 2);
|
|
644
668
|
assert_eq!(models[0].cache_health, ModelCacheHealth::Complete);
|
|
645
669
|
assert_eq!(models[1].cache_health, ModelCacheHealth::Incomplete);
|
|
@@ -650,21 +674,19 @@ mod tests {
|
|
|
650
674
|
#[test]
|
|
651
675
|
fn load_selected_model_roundtrip() {
|
|
652
676
|
let root = unique_temp_dir("persistence-roundtrip");
|
|
653
|
-
let
|
|
654
|
-
std::fs::create_dir_all(&hf_home).expect("create hf home");
|
|
677
|
+
let path = root.join(SELECTED_MODEL_FILE_NAME);
|
|
655
678
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
};
|
|
679
|
+
let original = SelectedModel {
|
|
680
|
+
model_id: "bartowski/Qwen_Qwen3-4B-GGUF".to_string(),
|
|
681
|
+
gguf_file: "Qwen_Qwen3-4B-Q4_K_M.gguf".to_string(),
|
|
682
|
+
};
|
|
661
683
|
|
|
662
|
-
|
|
663
|
-
|
|
684
|
+
// The parent dir does not exist yet: saving has to create it.
|
|
685
|
+
save_selected_model_to(&path, &original).expect("save");
|
|
686
|
+
let loaded = load_selected_model_from(&path).expect("load");
|
|
664
687
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
});
|
|
688
|
+
assert_eq!(loaded.model_id, original.model_id);
|
|
689
|
+
assert_eq!(loaded.gguf_file, original.gguf_file);
|
|
668
690
|
|
|
669
691
|
std::fs::remove_dir_all(root).expect("remove temp dir");
|
|
670
692
|
}
|
|
@@ -672,18 +694,11 @@ mod tests {
|
|
|
672
694
|
#[test]
|
|
673
695
|
fn load_selected_model_empty_file_returns_none() {
|
|
674
696
|
let root = unique_temp_dir("persistence-empty");
|
|
675
|
-
let
|
|
676
|
-
std::fs::create_dir_all(&
|
|
697
|
+
let path = root.join(SELECTED_MODEL_FILE_NAME);
|
|
698
|
+
std::fs::create_dir_all(&root).expect("create root");
|
|
699
|
+
std::fs::write(&path, "").expect("write empty");
|
|
677
700
|
|
|
678
|
-
|
|
679
|
-
let path = selected_model_file_path().expect("path");
|
|
680
|
-
if let Some(parent) = path.parent() {
|
|
681
|
-
std::fs::create_dir_all(parent).expect("create parent");
|
|
682
|
-
}
|
|
683
|
-
std::fs::write(&path, "").expect("write empty");
|
|
684
|
-
|
|
685
|
-
assert!(load_selected_model().is_none());
|
|
686
|
-
});
|
|
701
|
+
assert!(load_selected_model_from(&path).is_none());
|
|
687
702
|
|
|
688
703
|
std::fs::remove_dir_all(root).expect("remove temp dir");
|
|
689
704
|
}
|
|
@@ -577,7 +577,9 @@ fn execute_sync_tool(name: &str, arguments: &str, owner: Option<&str>) -> ToolOu
|
|
|
577
577
|
"read_website" => exec_read_website(arguments),
|
|
578
578
|
"create_directory" => exec_create_directory(arguments),
|
|
579
579
|
"glob" => exec_glob(arguments),
|
|
580
|
-
|
|
580
|
+
// A missing owner is a session-less call (the headless runner sets
|
|
581
|
+
// one too), and its list is scoped to that same empty key.
|
|
582
|
+
"write_todos" => exec_write_todos(arguments, owner.unwrap_or("")),
|
|
581
583
|
"remember" => exec_remember(arguments),
|
|
582
584
|
"delete_file" => exec_delete_file(arguments),
|
|
583
585
|
"run_command" => exec_run_command(arguments, owner),
|
|
@@ -2081,43 +2083,58 @@ impl TodoStatus {
|
|
|
2081
2083
|
}
|
|
2082
2084
|
}
|
|
2083
2085
|
|
|
2084
|
-
///
|
|
2085
|
-
///
|
|
2086
|
-
///
|
|
2087
|
-
///
|
|
2088
|
-
///
|
|
2089
|
-
///
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
///
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
.read()
|
|
2097
|
-
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
2098
|
-
.clone()
|
|
2086
|
+
/// Per-session todo store. The model owns the list conceptually, but when it
|
|
2087
|
+
/// forgets to re-send the full list — an error turn, a context shift, a simple
|
|
2088
|
+
/// omission — the panel goes empty with nothing to fall back on. This keeps
|
|
2089
|
+
/// the last-known list so `main.rs` can re-emit it (`todos_arguments_to_plan` +
|
|
2090
|
+
/// `SessionUpdate::Plan`) without the model's help.
|
|
2091
|
+
///
|
|
2092
|
+
/// Keyed by session, like `permissions::sessions()`: one process serves every
|
|
2093
|
+
/// thread an editor has open, and a plan belonging to one conversation must
|
|
2094
|
+
/// never surface in another.
|
|
2095
|
+
fn todo_sessions() -> &'static Mutex<HashMap<String, Vec<Todo>>> {
|
|
2096
|
+
static SESSIONS: OnceLock<Mutex<HashMap<String, Vec<Todo>>>> = OnceLock::new();
|
|
2097
|
+
SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
|
|
2099
2098
|
}
|
|
2100
2099
|
|
|
2101
|
-
///
|
|
2102
|
-
///
|
|
2103
|
-
|
|
2104
|
-
|
|
2100
|
+
/// Read the session's persisted todo list, if any. Returns `None` when no
|
|
2101
|
+
/// `write_todos` call has succeeded yet (or the list was cleared).
|
|
2102
|
+
pub fn get_todos(session: &str) -> Option<Vec<Todo>> {
|
|
2103
|
+
todo_sessions()
|
|
2104
|
+
.lock()
|
|
2105
|
+
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
2106
|
+
.get(session)
|
|
2107
|
+
.cloned()
|
|
2108
|
+
}
|
|
2105
2109
|
|
|
2106
|
-
///
|
|
2107
|
-
///
|
|
2108
|
-
|
|
2109
|
-
fn
|
|
2110
|
-
let mut
|
|
2111
|
-
.
|
|
2110
|
+
/// Store the session's list, or drop the key when `None`. Dropping it rather
|
|
2111
|
+
/// than storing an empty list keeps "no plan" a single state the map doesn't
|
|
2112
|
+
/// accumulate entries for.
|
|
2113
|
+
fn set_todos(session: &str, todos: Option<Vec<Todo>>) {
|
|
2114
|
+
let mut map = todo_sessions()
|
|
2115
|
+
.lock()
|
|
2112
2116
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
2113
|
-
|
|
2117
|
+
match todos {
|
|
2118
|
+
Some(todos) => {
|
|
2119
|
+
map.insert(session.to_string(), todos);
|
|
2120
|
+
}
|
|
2121
|
+
None => {
|
|
2122
|
+
map.remove(session);
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
/// Forget a session's list (session teardown, a wiped conversation) so a plan
|
|
2128
|
+
/// never outlives the conversation it belongs to.
|
|
2129
|
+
pub fn clear_todos(session: &str) {
|
|
2130
|
+
set_todos(session, None);
|
|
2114
2131
|
}
|
|
2115
2132
|
|
|
2116
2133
|
/// Renders the model's task checklist back as the tool result so the surface
|
|
2117
2134
|
/// (TUI / ACP client) can show live progress. Also persists the list so the
|
|
2118
2135
|
/// panel survives model omissions and error turns — the list is still owned
|
|
2119
2136
|
/// by the model, but the last-known state is kept as a fallback.
|
|
2120
|
-
fn exec_write_todos(arguments: &str) -> String {
|
|
2137
|
+
fn exec_write_todos(arguments: &str, session: &str) -> String {
|
|
2121
2138
|
let args: Value = match serde_json::from_str(arguments) {
|
|
2122
2139
|
Ok(v) => v,
|
|
2123
2140
|
Err(err) => return format!("Error: failed to parse arguments: {err}"),
|
|
@@ -2132,10 +2149,7 @@ fn exec_write_todos(arguments: &str) -> String {
|
|
|
2132
2149
|
// restate at least one step. Clear the store too so a later omission
|
|
2133
2150
|
// doesn't bring the old list back from the dead.
|
|
2134
2151
|
if todos.is_empty() {
|
|
2135
|
-
|
|
2136
|
-
.write()
|
|
2137
|
-
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
2138
|
-
*guard = None;
|
|
2152
|
+
set_todos(session, None);
|
|
2139
2153
|
return "Task list cleared.".to_string();
|
|
2140
2154
|
}
|
|
2141
2155
|
|
|
@@ -2172,10 +2186,7 @@ fn exec_write_todos(arguments: &str) -> String {
|
|
|
2172
2186
|
// Persist the parsed list so the panel survives a later turn that omits
|
|
2173
2187
|
// `write_todos` or errors out before reaching it. The store is the
|
|
2174
2188
|
// fallback `get_todos()` reads.
|
|
2175
|
-
|
|
2176
|
-
.write()
|
|
2177
|
-
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
2178
|
-
*guard = Some(persisted);
|
|
2189
|
+
set_todos(session, Some(persisted));
|
|
2179
2190
|
|
|
2180
2191
|
format!(
|
|
2181
2192
|
"Task list updated ({completed}/{} done):\n{}",
|
|
@@ -3569,7 +3580,7 @@ mod tests {
|
|
|
3569
3580
|
]
|
|
3570
3581
|
})
|
|
3571
3582
|
.to_string();
|
|
3572
|
-
let result = exec_write_todos(&args);
|
|
3583
|
+
let result = exec_write_todos(&args, "todos-test-render");
|
|
3573
3584
|
assert!(result.contains("1/3 done"), "{result}");
|
|
3574
3585
|
assert!(result.contains("[x] Read code"), "{result}");
|
|
3575
3586
|
assert!(result.contains("[~] Make change"), "{result}");
|
|
@@ -3578,7 +3589,7 @@ mod tests {
|
|
|
3578
3589
|
|
|
3579
3590
|
#[test]
|
|
3580
3591
|
fn test_write_todos_accepts_an_empty_list_to_clear() {
|
|
3581
|
-
let result = exec_write_todos(r#"{"todos":[]}"
|
|
3592
|
+
let result = exec_write_todos(r#"{"todos":[]}"#, "todos-test-empty-clear");
|
|
3582
3593
|
assert_eq!(result, "Task list cleared.");
|
|
3583
3594
|
}
|
|
3584
3595
|
|
|
@@ -5378,15 +5389,14 @@ mod tests {
|
|
|
5378
5389
|
}
|
|
5379
5390
|
|
|
5380
5391
|
// ── write_todos persistence ───────────────────────────────────────────
|
|
5381
|
-
//
|
|
5382
|
-
//
|
|
5383
|
-
//
|
|
5392
|
+
// Each test owns a distinct session key, so the store's per-session
|
|
5393
|
+
// scoping is what keeps parallel `cargo test` runs out of each other's
|
|
5394
|
+
// lists — no shared lock needed.
|
|
5384
5395
|
|
|
5385
5396
|
#[test]
|
|
5386
5397
|
fn write_todos_persists_list() {
|
|
5387
|
-
let
|
|
5388
|
-
|
|
5389
|
-
assert!(get_todos().is_none());
|
|
5398
|
+
let session = "todos-test-persists";
|
|
5399
|
+
assert!(get_todos(session).is_none());
|
|
5390
5400
|
|
|
5391
5401
|
let output = exec_write_todos(
|
|
5392
5402
|
r#"{"todos":[
|
|
@@ -5394,10 +5404,11 @@ mod tests {
|
|
|
5394
5404
|
{"content":"step two","status":"in_progress"},
|
|
5395
5405
|
{"content":"step three","status":"pending"}
|
|
5396
5406
|
]}"#,
|
|
5407
|
+
session,
|
|
5397
5408
|
);
|
|
5398
5409
|
assert!(output.contains("Task list updated (1/3 done)"));
|
|
5399
5410
|
|
|
5400
|
-
let todos = get_todos().expect("list should be persisted");
|
|
5411
|
+
let todos = get_todos(session).expect("list should be persisted");
|
|
5401
5412
|
assert_eq!(todos.len(), 3);
|
|
5402
5413
|
assert_eq!(todos[0].content, "step one");
|
|
5403
5414
|
assert_eq!(todos[0].status, TodoStatus::Completed);
|
|
@@ -5406,63 +5417,105 @@ mod tests {
|
|
|
5406
5417
|
}
|
|
5407
5418
|
|
|
5408
5419
|
#[test]
|
|
5409
|
-
fn
|
|
5410
|
-
|
|
5411
|
-
|
|
5412
|
-
|
|
5420
|
+
fn write_todos_is_scoped_to_one_session() {
|
|
5421
|
+
exec_write_todos(
|
|
5422
|
+
r#"{"todos":[{"content":"mine","status":"pending"}]}"#,
|
|
5423
|
+
"todos-test-scope-a",
|
|
5424
|
+
);
|
|
5425
|
+
|
|
5426
|
+
// A second session starts empty rather than inheriting the first
|
|
5427
|
+
// session's plan, and writing there leaves the first untouched.
|
|
5428
|
+
assert!(get_todos("todos-test-scope-b").is_none());
|
|
5429
|
+
exec_write_todos(
|
|
5430
|
+
r#"{"todos":[{"content":"theirs","status":"completed"}]}"#,
|
|
5431
|
+
"todos-test-scope-b",
|
|
5432
|
+
);
|
|
5433
|
+
|
|
5434
|
+
let mine = get_todos("todos-test-scope-a").expect("first session keeps its list");
|
|
5435
|
+
assert_eq!(mine.len(), 1);
|
|
5436
|
+
assert_eq!(mine[0].content, "mine");
|
|
5437
|
+
let theirs = get_todos("todos-test-scope-b").expect("second session has its own");
|
|
5438
|
+
assert_eq!(theirs[0].content, "theirs");
|
|
5439
|
+
}
|
|
5440
|
+
|
|
5441
|
+
#[test]
|
|
5442
|
+
fn get_todos_returns_none_for_unknown_session() {
|
|
5443
|
+
assert!(get_todos("todos-test-unknown").is_none());
|
|
5444
|
+
}
|
|
5445
|
+
|
|
5446
|
+
#[test]
|
|
5447
|
+
fn clear_todos_forgets_only_that_session() {
|
|
5448
|
+
exec_write_todos(
|
|
5449
|
+
r#"{"todos":[{"content":"gone","status":"pending"}]}"#,
|
|
5450
|
+
"todos-test-clear",
|
|
5451
|
+
);
|
|
5452
|
+
exec_write_todos(
|
|
5453
|
+
r#"{"todos":[{"content":"kept","status":"pending"}]}"#,
|
|
5454
|
+
"todos-test-clear-other",
|
|
5455
|
+
);
|
|
5456
|
+
|
|
5457
|
+
clear_todos("todos-test-clear");
|
|
5458
|
+
|
|
5459
|
+
assert!(get_todos("todos-test-clear").is_none());
|
|
5460
|
+
assert!(get_todos("todos-test-clear-other").is_some());
|
|
5413
5461
|
}
|
|
5414
5462
|
|
|
5415
5463
|
#[test]
|
|
5416
5464
|
fn write_todos_empty_list_clears_store() {
|
|
5417
|
-
let
|
|
5465
|
+
let session = "todos-test-empty";
|
|
5418
5466
|
// Seed the store so we can prove the empty call wipes it.
|
|
5419
|
-
exec_write_todos(
|
|
5420
|
-
|
|
5467
|
+
exec_write_todos(
|
|
5468
|
+
r#"{"todos":[{"content":"seed","status":"pending"}]}"#,
|
|
5469
|
+
session,
|
|
5470
|
+
);
|
|
5471
|
+
assert!(get_todos(session).is_some());
|
|
5421
5472
|
|
|
5422
|
-
let output = exec_write_todos(r#"{"todos":[]}"
|
|
5473
|
+
let output = exec_write_todos(r#"{"todos":[]}"#, session);
|
|
5423
5474
|
assert_eq!(output, "Task list cleared.");
|
|
5424
|
-
assert!(get_todos().is_none());
|
|
5475
|
+
assert!(get_todos(session).is_none());
|
|
5425
5476
|
}
|
|
5426
5477
|
|
|
5427
5478
|
#[test]
|
|
5428
5479
|
fn write_todos_unknown_status_falls_back_to_pending() {
|
|
5429
|
-
let
|
|
5430
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
5480
|
+
let session = "todos-test-unknown-status";
|
|
5481
|
+
exec_write_todos(
|
|
5482
|
+
r#"{"todos":[{"content":"weird","status":"bogus"}]}"#,
|
|
5483
|
+
session,
|
|
5484
|
+
);
|
|
5485
|
+
let todos = get_todos(session).expect("list should be persisted");
|
|
5433
5486
|
assert_eq!(todos.len(), 1);
|
|
5434
5487
|
assert_eq!(todos[0].status, TodoStatus::Pending);
|
|
5435
5488
|
}
|
|
5436
5489
|
|
|
5437
5490
|
#[test]
|
|
5438
5491
|
fn write_todos_missing_status_defaults_to_pending() {
|
|
5439
|
-
let
|
|
5440
|
-
|
|
5441
|
-
|
|
5442
|
-
let todos = get_todos().expect("list should be persisted");
|
|
5492
|
+
let session = "todos-test-missing-status";
|
|
5493
|
+
exec_write_todos(r#"{"todos":[{"content":"no status"}]}"#, session);
|
|
5494
|
+
let todos = get_todos(session).expect("list should be persisted");
|
|
5443
5495
|
assert_eq!(todos.len(), 1);
|
|
5444
5496
|
assert_eq!(todos[0].status, TodoStatus::Pending);
|
|
5445
5497
|
}
|
|
5446
5498
|
|
|
5447
5499
|
#[test]
|
|
5448
5500
|
fn write_todos_overwrites_previous_list() {
|
|
5449
|
-
let
|
|
5450
|
-
reset_todos();
|
|
5501
|
+
let session = "todos-test-overwrite";
|
|
5451
5502
|
exec_write_todos(
|
|
5452
5503
|
r#"{"todos":[
|
|
5453
5504
|
{"content":"a","status":"pending"},
|
|
5454
5505
|
{"content":"b","status":"pending"}
|
|
5455
5506
|
]}"#,
|
|
5507
|
+
session,
|
|
5456
5508
|
);
|
|
5457
|
-
assert_eq!(get_todos().unwrap().len(), 2);
|
|
5509
|
+
assert_eq!(get_todos(session).unwrap().len(), 2);
|
|
5458
5510
|
|
|
5459
5511
|
// A second call with a different list replaces, not appends.
|
|
5460
5512
|
exec_write_todos(
|
|
5461
5513
|
r#"{"todos":[
|
|
5462
5514
|
{"content":"only","status":"completed"}
|
|
5463
5515
|
]}"#,
|
|
5516
|
+
session,
|
|
5464
5517
|
);
|
|
5465
|
-
let todos = get_todos().unwrap();
|
|
5518
|
+
let todos = get_todos(session).unwrap();
|
|
5466
5519
|
assert_eq!(todos.len(), 1);
|
|
5467
5520
|
assert_eq!(todos[0].content, "only");
|
|
5468
5521
|
assert_eq!(todos[0].status, TodoStatus::Completed);
|
|
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
|
|
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
|
|
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
|