Skip to content

Commit 43e0386

Browse files
authored
fix(config): restore default system prompt on upgrade for uncustomized configs (#158)
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
1 parent 579a93b commit 43e0386

8 files changed

Lines changed: 111 additions & 19 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ The loader is forgiving and never crashes the app on user config:
121121

122122
- Missing file → defaults seeded and written. (Only fatal failure path is the seed write itself.)
123123
- Missing fields/sections → `#[serde(default)]` fills from compiled defaults.
124-
- Empty/whitespace strings → replaced with compiled defaults. Exception: `prompt.system` is a deliberate user override; an empty value is preserved and means "send no persona" (only the slash-command appendix is composed into `resolved_system`).
124+
- Empty/whitespace strings → replaced with compiled defaults. Exception: `prompt.system` with `prompt.system_customized = true` is a deliberate user override; an empty value is preserved and means "send no persona" (only the slash-command appendix is composed into `resolved_system`). When `system_customized` is `false` (old configs predating the Settings UI), an empty `system` is treated as a migration artifact and restored to `DEFAULT_SYSTEM_PROMPT_BASE`.
125125
- Out-of-bounds numerics → reset to default with a stderr warning.
126126
- Unparseable TOML → file renamed `config.toml.corrupt-<unix_ts>` and a fresh defaults file written.
127127

docs/configurations.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,8 @@ Controls the personality and instructions Thuki gives to the AI at the start of
132132

133133
| Constant | Default | Tunable? | Why not tunable | Bounds | Description |
134134
| :------------------------------ | :------------------------------------- | :------- | :---------------------------------------------------------------------------------------------------------------------------------------------------- | :--------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
135-
| `system` | full built-in body (~17 KB) | Yes || any string | The full secretary personality prompt. Seeded into your `config.toml` on first run so the file is the single source of truth: edit, tweak, or replace it. Clearing the field sends no persona at all. The slash-command appendix is always added on top, so `/search` etc. work either way. |
135+
| `system` | full built-in body (~17 KB) | Yes || any string | The full secretary personality prompt. Seeded into your `config.toml` on first run so the file is the single source of truth: edit, tweak, or replace it. Clearing the field via Settings sends no persona at all. Clearing it by hand in the TOML (without ever saving via Settings) is treated as an old-config migration artifact and the default is restored on the next boot. The slash-command appendix is always added on top, so `/search` etc. work either way. |
136+
| `DEFAULT_SYSTEM_CUSTOMIZED` | `false` | No | Internal migration flag. Set to `true` the first time the user saves the system prompt via Settings. Guards the upgrade path that distinguishes configs where `system = ""` was the old compiled default from a deliberate clear made in the Settings UI. Not user-tunable because exposing it would let users suppress the safety net that restores the built-in persona on upgrade. || Tracks whether the user has ever explicitly saved a system prompt through the Settings UI. |
136137
| `DEFAULT_SYSTEM_PROMPT_BASE` | `prompts/system_prompt.txt` | No | The shipped seed for `system` on first run. Once your `config.toml` exists, only the file matters; this constant is no longer consulted at runtime. || Source-of-truth file used to seed `system` on first run. |
137138
| `SLASH_COMMAND_PROMPT_APPENDIX` | `prompts/generated/slash_commands.txt` | No | Auto-generated from the slash-command registry at build time. Editing by hand would desync the AI's understanding of the commands from the real ones. || The list of slash commands (`/search`, `/screen`, etc.) Thuki tells the AI about so it knows what each one does. Always added on top of your `system` prompt. |
138139

src-tauri/src/config/defaults.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ pub const DEFAULT_SYSTEM_PROMPT_BASE: &str = include_str!("../../prompts/system_
4444
pub const SLASH_COMMAND_PROMPT_APPENDIX: &str =
4545
include_str!("../../prompts/generated/slash_commands.txt");
4646

47+
/// Whether the user has explicitly saved a system prompt via Settings. Starts
48+
/// `false` so the upgrade-migration path in the loader can distinguish old
49+
/// configs (where `system = ""` was the compiled default) from a deliberate
50+
/// clear made through the Settings UI.
51+
pub const DEFAULT_SYSTEM_CUSTOMIZED: bool = false;
52+
4753
/// Window defaults (logical pixels and counts). Only the user-tunable knobs
4854
/// live here; the collapsed-bar height and the close-animation deadline are
4955
/// baked into `App.tsx` because their effective range is invisible to users

src-tauri/src/config/loader.rs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ use super::defaults::{
3333
DEFAULT_QUOTE_MAX_DISPLAY_CHARS, DEFAULT_QUOTE_MAX_DISPLAY_LINES,
3434
DEFAULT_READER_BATCH_TIMEOUT_S, DEFAULT_READER_PER_URL_TIMEOUT_S, DEFAULT_READER_URL,
3535
DEFAULT_ROUTER_TIMEOUT_S, DEFAULT_SEARCH_TIMEOUT_S, DEFAULT_SEARXNG_MAX_RESULTS,
36-
DEFAULT_SEARXNG_URL, DEFAULT_TOP_K_URLS, DEFAULT_UPDATER_CHECK_INTERVAL_HOURS,
37-
DEFAULT_UPDATER_MANIFEST_URL, SLASH_COMMAND_PROMPT_APPENDIX,
36+
DEFAULT_SEARXNG_URL, DEFAULT_SYSTEM_PROMPT_BASE, DEFAULT_TOP_K_URLS,
37+
DEFAULT_UPDATER_CHECK_INTERVAL_HOURS, DEFAULT_UPDATER_MANIFEST_URL,
38+
SLASH_COMMAND_PROMPT_APPENDIX,
3839
};
3940
use super::error::ConfigError;
4041
use super::schema::AppConfig;
@@ -152,10 +153,15 @@ pub(crate) fn resolve(config: &mut AppConfig) {
152153
"inference.num_ctx",
153154
);
154155

155-
// Prompt section: compose the user's persona with the slash-command
156-
// appendix into the runtime-only `resolved_system`. The on-disk `system`
157-
// string is the single source of truth; if the user has cleared it, no
158-
// persona is sent (only the appendix).
156+
// Prompt section: if the user has never explicitly saved a system prompt
157+
// (system_customized is false) and the on-disk value is empty, restore
158+
// the built-in default. This heals configs from before the Settings UI
159+
// existed, where system="" was the old compiled default rather than an
160+
// intentional clear. Once the user saves via Settings, system_customized
161+
// is set to true and an explicit empty is respected.
162+
if !config.prompt.system_customized && config.prompt.system.trim().is_empty() {
163+
config.prompt.system = DEFAULT_SYSTEM_PROMPT_BASE.to_string();
164+
}
159165
config.prompt.resolved_system =
160166
compose_system_prompt(&config.prompt.system, SLASH_COMMAND_PROMPT_APPENDIX);
161167

src-tauri/src/config/schema.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ use super::defaults::{
2121
DEFAULT_QUOTE_MAX_DISPLAY_LINES, DEFAULT_READER_BATCH_TIMEOUT_S,
2222
DEFAULT_READER_PER_URL_TIMEOUT_S, DEFAULT_READER_URL, DEFAULT_ROUTER_TIMEOUT_S,
2323
DEFAULT_SEARCH_TIMEOUT_S, DEFAULT_SEARXNG_MAX_RESULTS, DEFAULT_SEARXNG_URL,
24-
DEFAULT_SYSTEM_PROMPT_BASE, DEFAULT_TOP_K_URLS, DEFAULT_UPDATER_AUTO_CHECK,
25-
DEFAULT_UPDATER_CHECK_INTERVAL_HOURS, DEFAULT_UPDATER_MANIFEST_URL,
24+
DEFAULT_SYSTEM_CUSTOMIZED, DEFAULT_SYSTEM_PROMPT_BASE, DEFAULT_TOP_K_URLS,
25+
DEFAULT_UPDATER_AUTO_CHECK, DEFAULT_UPDATER_CHECK_INTERVAL_HOURS, DEFAULT_UPDATER_MANIFEST_URL,
2626
};
2727

2828
/// Static, user-tunable inference daemon configuration.
@@ -70,9 +70,18 @@ impl Default for InferenceSection {
7070
#[serde(default)]
7171
pub struct PromptSection {
7272
/// User-editable persona prompt. Seeded with the built-in body and
73-
/// freely editable thereafter. If the user clears it, no persona is
74-
/// sent (only the slash-command appendix).
73+
/// freely editable thereafter. If the user clears it (with
74+
/// `system_customized` set), no persona is sent (only the
75+
/// slash-command appendix).
7576
pub system: String,
77+
/// Set to `true` the first time the user explicitly saves the system
78+
/// prompt via Settings. Guards upgrade migration: configs from before
79+
/// the Settings UI was added have `system = ""` because that was the
80+
/// old compiled default, not an intentional clear. The loader resets
81+
/// an empty `system` to the built-in default when this flag is
82+
/// `false`, preserving the intentional-clear semantic for users who
83+
/// actively cleared the field in the new UI.
84+
pub system_customized: bool,
7685
/// Composed runtime value (base prompt plus slash-command appendix).
7786
/// Not serialized; computed by the loader.
7887
#[serde(skip)]
@@ -83,6 +92,7 @@ impl Default for PromptSection {
8392
fn default() -> Self {
8493
Self {
8594
system: DEFAULT_SYSTEM_PROMPT_BASE.to_string(),
95+
system_customized: DEFAULT_SYSTEM_CUSTOMIZED,
8696
resolved_system: String::new(),
8797
}
8898
}

src-tauri/src/config/tests.rs

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -509,10 +509,11 @@ fn resolve_empty_ollama_url_falls_back() {
509509
}
510510

511511
#[test]
512-
fn resolve_empty_system_prompt_keeps_only_appendix() {
513-
// The user has explicitly cleared their persona; resolved_system contains
514-
// the slash-command appendix only. Built-in persona is no longer auto
515-
// re-injected, so the on-disk file remains the single source of truth.
512+
fn resolve_empty_system_prompt_without_customized_flag_uses_built_in_default() {
513+
// Upgrade migration path: old configs have system="" because that was the
514+
// compiled default before the Settings UI existed. Without system_customized,
515+
// the loader restores the built-in persona so upgraded users are not silently
516+
// left with no system prompt.
516517
let dir = fresh_temp_dir();
517518
let path = config_path_in(&dir);
518519
std::fs::write(
@@ -524,6 +525,35 @@ fn resolve_empty_system_prompt_keeps_only_appendix() {
524525
)
525526
.unwrap();
526527
let config = load_from_path(&path).unwrap();
528+
assert_eq!(config.prompt.system, DEFAULT_SYSTEM_PROMPT_BASE);
529+
assert!(config
530+
.prompt
531+
.resolved_system
532+
.contains(DEFAULT_SYSTEM_PROMPT_BASE.trim()));
533+
assert!(config
534+
.prompt
535+
.resolved_system
536+
.contains(SLASH_COMMAND_PROMPT_APPENDIX.trim()));
537+
}
538+
539+
#[test]
540+
fn resolve_empty_system_prompt_with_customized_flag_keeps_only_appendix() {
541+
// Intentional clear: user opened Settings, cleared the prompt, and saved.
542+
// set_config_field co-writes system_customized=true so the loader respects
543+
// the deliberate empty and does not restore the built-in default.
544+
let dir = fresh_temp_dir();
545+
let path = config_path_in(&dir);
546+
std::fs::write(
547+
&path,
548+
r#"
549+
[prompt]
550+
system = ""
551+
system_customized = true
552+
"#,
553+
)
554+
.unwrap();
555+
let config = load_from_path(&path).unwrap();
556+
assert_eq!(config.prompt.system, "");
527557
assert_eq!(
528558
config.prompt.resolved_system,
529559
SLASH_COMMAND_PROMPT_APPENDIX.trim()

src-tauri/src/settings_commands.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,14 @@ pub(crate) fn write_field_to_disk(
177177

178178
let mut doc = read_document(path)?;
179179
patch_document(&mut doc, section, key, value)?;
180+
// When the user saves the system prompt, mark it as explicitly customized
181+
// so the upgrade-migration path in the loader (empty + !customized →
182+
// restore default) does not overwrite a deliberate clear on next boot.
183+
if section == "prompt" && key == "system" {
184+
if let Some(table) = doc.get_mut("prompt").and_then(Item::as_table_mut) {
185+
table.insert("system_customized", toml_value(true));
186+
}
187+
}
180188

181189
config::atomic_write_bytes(path, doc.to_string().as_bytes()).map_err(|source| {
182190
ConfigError::IoError {

src-tauri/src/settings_commands/tests.rs

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,11 @@ fn allowed_fields_count_matches_schema_field_count() {
6565
// lives in the SQLite app_config table via ActiveModelState, not in TOML. The
6666
// collapsed bar height and hide-commit delay are baked into the frontend (see
6767
// `WindowSection` doc) because they have no perceptible effect across
68-
// their usable range. If this assertion fails, the schema has drifted
69-
// from the allowlist and someone added a field without extending
70-
// ALLOWED_FIELDS.
68+
// their usable range. `prompt.system_customized` is an internal migration flag
69+
// co-written by set_config_field when prompt.system is saved; it is not
70+
// directly user-tunable and is intentionally absent from ALLOWED_FIELDS.
71+
// If this assertion fails, the schema has drifted from the allowlist and
72+
// someone added a field without extending ALLOWED_FIELDS.
7173
assert_eq!(ALLOWED_FIELDS.len(), 25);
7274
}
7375

@@ -541,6 +543,35 @@ fn write_field_to_disk_accepts_search_pipeline_wall_clock_budget() {
541543
assert!(on_disk.contains("pipeline_wall_clock_budget_s = 90"));
542544
}
543545

546+
#[test]
547+
fn write_field_to_disk_writing_prompt_system_co_writes_customized_flag() {
548+
// Saving prompt.system must atomically set system_customized=true so a
549+
// subsequent boot does not mistake an intentional clear for the legacy
550+
// empty-default and restore the built-in persona.
551+
let dir = tempdir();
552+
let path = dir.join("config.toml");
553+
std::fs::write(&path, SAMPLE_CONFIG).unwrap();
554+
555+
let resolved = write_field_to_disk(&path, "prompt", "system", json!("")).unwrap();
556+
assert!(resolved.prompt.system_customized);
557+
558+
let on_disk = std::fs::read_to_string(&path).unwrap();
559+
assert!(on_disk.contains("system_customized = true"));
560+
}
561+
562+
#[test]
563+
fn write_field_to_disk_writing_prompt_system_preserves_customized_flag_for_non_empty() {
564+
// Saving a non-empty system prompt also sets system_customized=true.
565+
let dir = tempdir();
566+
let path = dir.join("config.toml");
567+
std::fs::write(&path, SAMPLE_CONFIG).unwrap();
568+
569+
let resolved =
570+
write_field_to_disk(&path, "prompt", "system", json!("You are a custom AI.")).unwrap();
571+
assert!(resolved.prompt.system_customized);
572+
assert_eq!(resolved.prompt.system, "You are a custom AI.");
573+
}
574+
544575
#[test]
545576
fn write_field_to_disk_rejects_unknown_section() {
546577
let dir = tempdir();

0 commit comments

Comments
 (0)