From 6ffcfe60faa63b76a886e4364bfb80505883f74c Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 29 May 2026 15:42:41 +1000 Subject: [PATCH] feat(acp): persist client-authored session system prompts across restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new SQLite column on the `sessions` table that stores client-set system-prompt state (the `Set` override and any `Append` extras whose key starts with `client_`). The ACP `_goose/unstable/session/system-prompt/set` handler writes through to the session record after each successful mutation; `spawn_agent_setup` rehydrates the agent's PromptManager from that record before signalling ProviderReady, so loaded sessions resume with the same persona / system prompt the client last sent. LoadSessionResponse._meta gains a `clientSystemPrompt` map of key -> sha256-short fingerprints so clients can skip the redundant `set` RPC on the first prompt of a loaded session when the value hasn't changed. Schema bumped to v14 with an idempotent ALTER TABLE migration; old sessions without the column read as None. Server-managed Append keys (recipe, final_output, additional, hints_*) are explicitly excluded from persistence — they're rebuilt at agent-spawn time from other session state. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Matt Toohey --- crates/goose-sdk/src/custom_requests.rs | 7 + crates/goose-server/src/openapi.rs | 1 + crates/goose/acp-schema.json | 2 +- crates/goose/src/acp/server.rs | 44 +++ crates/goose/src/acp/server/sessions.rs | 82 ++++- crates/goose/src/session/session_manager.rs | 75 +++- .../goose/tests/acp_custom_requests_test.rs | 322 +++++++++++++++++- ui/desktop/openapi.json | 24 ++ ui/desktop/src/api/types.gen.ts | 16 + ui/sdk/src/generated/types.gen.ts | 7 + ui/sdk/src/generated/zod.gen.ts | 7 + 11 files changed, 571 insertions(+), 16 deletions(-) diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index 5b007574bb..e1b2c2a81d 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -124,6 +124,13 @@ pub enum SessionSystemPromptMode { /// `mode: "set"` replaces Goose's base system prompt. `mode: "append"` adds an /// instruction under "Additional Instructions". Reusing a key replaces the /// previous value for that mode/key; sending empty text clears it. +/// +/// Persistence: values set with `mode = Set`, or with `mode = Append` and a +/// `key` starting with `client_`, are persisted to the session record and +/// rehydrated when the session is loaded. Other `Append` keys (e.g. `recipe`, +/// `final_output`, `additional`, `hints_*`) are server-managed and only mutate +/// the in-memory PromptManager for the current process; they're rebuilt from +/// session state on the next agent spawn. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request( method = "_goose/unstable/session/system-prompt/set", diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index afff2d7c93..3515b6c1cf 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -570,6 +570,7 @@ derive_utoipa!(IconTheme as IconThemeSchema); ThinkingEffort, super::routes::config_management::ProviderModelInfoQuery, Session, + goose::session::session_manager::ClientSystemPrompt, goose::config::goose_mode::GooseMode, SessionInsights, SessionType, diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index 9afa77e6c4..57b2cd3ec0 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -191,7 +191,7 @@ "sessionId", "text" ], - "description": "Set, append, or clear system prompt text for a session.\n\n`mode: \"set\"` replaces Goose's base system prompt. `mode: \"append\"` adds an\ninstruction under \"Additional Instructions\". Reusing a key replaces the\nprevious value for that mode/key; sending empty text clears it.", + "description": "Set, append, or clear system prompt text for a session.\n\n`mode: \"set\"` replaces Goose's base system prompt. `mode: \"append\"` adds an\ninstruction under \"Additional Instructions\". Reusing a key replaces the\nprevious value for that mode/key; sending empty text clears it.\n\nPersistence: values set with `mode = Set`, or with `mode = Append` and a\n`key` starting with `client_`, are persisted to the session record and\nrehydrated when the session is loaded. Other `Append` keys (e.g. `recipe`,\n`final_output`, `additional`, `hints_*`) are server-managed and only mutate\nthe in-memory PromptManager for the current process; they're rebuilt from\nsession state on the next agent spawn.", "x-side": "agent", "x-method": "_goose/unstable/session/system-prompt/set" }, diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 6d06a0b5f7..0b14271be7 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -1557,6 +1557,15 @@ impl GooseAcpAgent { .await .map_err(|e| e.to_string())?; + if let Some(persisted) = goose_session.client_system_prompt.as_ref() { + if let Some(text) = persisted.override_text.as_ref() { + agent.override_system_prompt(text.clone()).await; + } + for (key, text) in &persisted.extras { + agent.extend_system_prompt(key.clone(), text.clone()).await; + } + } + Ok(agent) } .await; @@ -2392,6 +2401,35 @@ fn extract_tool_call_update_meta( Some(meta_map) } +fn sha256_short(s: &str) -> String { + let digest = Sha256::digest(s.as_bytes()); + digest.iter().take(8).map(|b| format!("{b:02x}")).collect() +} + +/// Build a `_meta.clientSystemPrompt` map of `key -> sha256_short(value)` +/// fingerprints for the session's persisted client-authored system prompt +/// state. Returns `None` when nothing is persisted, so the meta field is +/// omitted entirely in that case. +fn client_system_prompt_meta(session: &Session) -> Option { + let csp = session.client_system_prompt.as_ref()?; + let mut keys = serde_json::Map::new(); + if let Some(text) = &csp.override_text { + keys.insert("override".to_string(), sha256_short(text).into()); + } + for (key, value) in &csp.extras { + keys.insert(key.clone(), sha256_short(value).into()); + } + if keys.is_empty() { + return None; + } + let mut meta = serde_json::Map::new(); + meta.insert( + "clientSystemPrompt".to_string(), + serde_json::Value::Object(keys), + ); + Some(meta) +} + fn replay_message_meta(message: &Message) -> Meta { let mut meta = serde_json::Map::new(); meta.insert( @@ -2990,6 +3028,8 @@ impl GooseAcpAgent { .prepare_session_init_config(&resolved, &mode_state, &goose_session) .await; + let csp_meta = client_system_prompt_meta(&goose_session); + self.spawn_agent_setup( cx, agent_tx, @@ -3009,6 +3049,9 @@ impl GooseAcpAgent { if let Some(co) = config_options { response = response.config_options(co); } + if let Some(meta) = csp_meta { + response = response.meta(meta); + } if let Some(usage_update) = initial_usage_update { cx.send_notification(SessionNotification::new( args.session_id.clone(), @@ -4473,6 +4516,7 @@ print(\"hello, world\") goose_mode: GooseMode::default(), archived_at: None, project_id: None, + client_system_prompt: None, } } diff --git a/crates/goose/src/acp/server/sessions.rs b/crates/goose/src/acp/server/sessions.rs index e46a5e75b3..ddfb7d033a 100644 --- a/crates/goose/src/acp/server/sessions.rs +++ b/crates/goose/src/acp/server/sessions.rs @@ -1,5 +1,15 @@ use super::*; +/// Returns true if `key` (used with `SessionSystemPromptMode::Append`) is +/// considered client-authored and should be persisted to the session record. +/// +/// Server-managed keys (`recipe`, `final_output`, `additional`, `hints_*`, +/// `chat_mode`, etc.) are rebuilt at agent-spawn time from other session +/// state, so they must not be persisted via the client-driven path. +pub(super) fn is_client_authored_key(key: &str) -> bool { + key.starts_with("client_") +} + impl GooseAcpAgent { pub(super) async fn on_update_working_dir( &self, @@ -38,20 +48,31 @@ impl GooseAcpAgent { &self, req: SetSessionSystemPromptRequest, ) -> Result { - let session_id = req.session_id.trim(); + let session_id = req.session_id.trim().to_string(); if session_id.is_empty() { return Err( agent_client_protocol::Error::invalid_params().data("sessionId cannot be empty") ); } - let agent = self.get_session_agent_provider_ready(session_id).await?; - match req.mode { + let agent = self.get_session_agent_provider_ready(&session_id).await?; + + enum PersistOp { + SetOverride(Option), + UpsertExtra { key: String, text: String }, + RemoveExtra(String), + Skip, + } + + let persist_op = match req.mode { SessionSystemPromptMode::Set => { - if req.text.trim().is_empty() { + let trimmed_empty = req.text.trim().is_empty(); + if trimmed_empty { agent.clear_system_prompt_override().await; + PersistOp::SetOverride(None) } else { - agent.override_system_prompt(req.text).await; + agent.override_system_prompt(req.text.clone()).await; + PersistOp::SetOverride(Some(req.text)) } } SessionSystemPromptMode::Append => { @@ -63,13 +84,56 @@ impl GooseAcpAgent { .ok_or_else(|| { agent_client_protocol::Error::invalid_params() .data("key cannot be empty for append mode") - })?; - if req.text.trim().is_empty() { - agent.remove_system_prompt_extra(key).await; + })? + .to_string(); + let trimmed_empty = req.text.trim().is_empty(); + let op = if !is_client_authored_key(&key) { + warn!( + key = %key, + "system-prompt set: key not in client-authored allowlist; applied to in-memory PromptManager only (not persisted)" + ); + PersistOp::Skip + } else if trimmed_empty { + PersistOp::RemoveExtra(key.clone()) } else { - agent.extend_system_prompt(key.to_string(), req.text).await; + PersistOp::UpsertExtra { + key: key.clone(), + text: req.text.clone(), + } + }; + if trimmed_empty { + agent.remove_system_prompt_extra(&key).await; + } else { + agent.extend_system_prompt(key, req.text).await; } + op } + }; + + if !matches!(persist_op, PersistOp::Skip) { + let session = self + .session_manager + .get_session(&session_id, false) + .await + .internal_err()?; + let mut state = session.client_system_prompt.unwrap_or_default(); + match persist_op { + PersistOp::SetOverride(value) => state.override_text = value, + PersistOp::UpsertExtra { key, text } => { + state.extras.insert(key, text); + } + PersistOp::RemoveExtra(key) => { + state.extras.remove(&key); + } + PersistOp::Skip => unreachable!(), + } + let next = if state.is_empty() { None } else { Some(state) }; + self.session_manager + .update(&session_id) + .client_system_prompt(next) + .apply() + .await + .internal_err()?; } Ok(EmptyResponse {}) diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 20caf461d6..580ec6789a 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -12,14 +12,14 @@ use rmcp::model::Role; use serde::{Deserialize, Serialize}; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use sqlx::{Pool, Sqlite}; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Arc, LazyLock}; use tracing::{info, warn}; use utoipa::ToSchema; -pub const CURRENT_SCHEMA_VERSION: i32 = 13; +pub const CURRENT_SCHEMA_VERSION: i32 = 14; pub const SESSIONS_FOLDER: &str = "sessions"; pub const DB_NAME: &str = "sessions.db"; @@ -86,6 +86,29 @@ pub struct Session { pub archived_at: Option>, #[serde(default)] pub project_id: Option, + #[serde(default)] + pub client_system_prompt: Option, +} + +/// Client-authored system prompt state persisted with a session. +/// +/// Populated via the ACP `_goose/unstable/session/system-prompt/set` method. +/// `override_text` mirrors `PromptManager::system_prompt_override` (Set mode); +/// `extras` mirrors a filtered subset of `PromptManager::system_prompt_extras` +/// (Append mode, only keys whose persistence is allow-listed at the handler). +#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ClientSystemPrompt { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub override_text: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub extras: BTreeMap, +} + +impl ClientSystemPrompt { + pub fn is_empty(&self) -> bool { + self.override_text.is_none() && self.extras.is_empty() + } } pub struct SessionUpdateBuilder<'a> { @@ -112,6 +135,7 @@ pub struct SessionUpdateBuilder<'a> { archived_at: Option>>, project_id: Option>, + client_system_prompt: Option>, } #[derive(Serialize, ToSchema, Debug)] @@ -146,6 +170,7 @@ impl<'a> SessionUpdateBuilder<'a> { goose_mode: None, archived_at: None, project_id: None, + client_system_prompt: None, } } @@ -268,6 +293,11 @@ impl<'a> SessionUpdateBuilder<'a> { self.project_id = Some(project_id); self } + + pub fn client_system_prompt(mut self, value: Option) -> Self { + self.client_system_prompt = Some(value); + self + } } pub struct SessionManager { @@ -563,6 +593,7 @@ impl Default for Session { goose_mode: GooseMode::default(), archived_at: None, project_id: None, + client_system_prompt: None, } } } @@ -588,6 +619,18 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session { let model_config_json: Option = row.try_get("model_config_json").ok().flatten(); let model_config = model_config_json.and_then(|json| serde_json::from_str(&json).ok()); + let client_system_prompt: Option = row + .try_get::, _>("client_system_prompt_json") + .ok() + .flatten() + .and_then(|json| match serde_json::from_str::(&json) { + Ok(value) => Some(value), + Err(err) => { + warn!(error = %err, "failed to parse client_system_prompt_json; treating as None"); + None + } + }); + let name: String = { let name_val: String = row.try_get("name").unwrap_or_default(); if !name_val.is_empty() { @@ -635,6 +678,7 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session { .unwrap_or_default(), archived_at: row.try_get("archived_at").ok(), project_id: row.try_get("project_id").ok().flatten(), + client_system_prompt, }) } } @@ -752,7 +796,8 @@ impl SessionStorage { model_config_json TEXT, goose_mode TEXT NOT NULL DEFAULT 'auto', archived_at TIMESTAMP, - project_id TEXT + project_id TEXT, + client_system_prompt_json TEXT ) "#, ) @@ -1187,6 +1232,19 @@ impl SessionStorage { .await?; } } + 14 => { + let has_col = sqlx::query_scalar::<_, i32>( + "SELECT COUNT(*) FROM pragma_table_info('sessions') WHERE name = 'client_system_prompt_json'", + ) + .fetch_one(&mut **tx) + .await? + > 0; + if !has_col { + sqlx::query("ALTER TABLE sessions ADD COLUMN client_system_prompt_json TEXT") + .execute(&mut **tx) + .await?; + } + } _ => { anyhow::bail!("Unknown migration version: {}", version); } @@ -1250,7 +1308,7 @@ impl SessionStorage { accumulated_cost, schedule_id, recipe_json, user_recipe_values_json, provider_name, model_config_json, goose_mode, - archived_at, project_id + archived_at, project_id, client_system_prompt_json FROM sessions WHERE id = ? "#, @@ -1318,6 +1376,7 @@ impl SessionStorage { add_update!(builder.archived_at, "archived_at"); add_update!(builder.project_id, "project_id"); + add_update!(builder.client_system_prompt, "client_system_prompt_json"); if updates.is_empty() { return Ok(()); @@ -1396,6 +1455,12 @@ impl SessionStorage { if let Some(ref project_id) = builder.project_id { q = q.bind(project_id.as_ref()); } + if let Some(client_system_prompt) = builder.client_system_prompt { + let json = client_system_prompt + .map(|csp| serde_json::to_string(&csp)) + .transpose()?; + q = q.bind(json); + } let pool = self.pool().await?; let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?; @@ -1582,7 +1647,7 @@ impl SessionStorage { s.accumulated_cost, s.schedule_id, s.recipe_json, s.user_recipe_values_json, s.provider_name, s.model_config_json, s.goose_mode, - s.archived_at, s.project_id, + s.archived_at, s.project_id, s.client_system_prompt_json, COUNT(m.id) as message_count FROM sessions s {} diff --git a/crates/goose/tests/acp_custom_requests_test.rs b/crates/goose/tests/acp_custom_requests_test.rs index 24e237e496..d79fe20906 100644 --- a/crates/goose/tests/acp_custom_requests_test.rs +++ b/crates/goose/tests/acp_custom_requests_test.rs @@ -8,8 +8,11 @@ use common_tests::fixtures::{ TestConnectionConfig, }; use goose::acp::server::AcpProviderFactory; +use goose::conversation::message::Message; use goose::model::ModelConfig; -use goose::providers::base::{MessageStream, Provider}; +use goose::providers::base::{ + stream_from_single_message, MessageStream, Provider, ProviderUsage, Usage, +}; use goose::providers::errors::ProviderError; use goose_test_support::{EnforceSessionId, IgnoreSessionId}; use std::path::PathBuf; @@ -730,3 +733,320 @@ fn test_custom_provider_supported_models_lists_raw_provider_models() { ); }); } + +/// Provider that captures every system prompt it sees, and returns "ok" for +/// each `stream` call. Useful for asserting what the rehydrated PromptManager +/// sends after a `goose serve` restart. +struct CapturingProvider { + model_config: ModelConfig, + captured: Arc>>, +} + +#[async_trait::async_trait] +impl Provider for CapturingProvider { + fn get_name(&self) -> &str { + "capturing" + } + + async fn stream( + &self, + _model_config: &ModelConfig, + _session_id: &str, + system: &str, + _messages: &[Message], + _tools: &[rmcp::model::Tool], + ) -> Result { + self.captured.lock().unwrap().push(system.to_string()); + Ok(stream_from_single_message( + Message::assistant().with_text("ok"), + ProviderUsage::new(self.model_config.model_name.clone(), Usage::default()), + )) + } + + fn get_model_config(&self) -> ModelConfig { + self.model_config.clone() + } +} + +fn capturing_provider_factory(captured: Arc>>) -> AcpProviderFactory { + Arc::new( + move |_provider_name, model_config, _extensions, _working_dir| { + let captured = captured.clone(); + Box::pin(async move { + Ok(Arc::new(CapturingProvider { + model_config, + captured, + }) as Arc) + }) + }, + ) +} + +async fn send_set_system_prompt( + cx: &agent_client_protocol::ConnectionTo, + session_id: &str, + mode: &str, + key: Option<&str>, + text: &str, +) { + let mut params = serde_json::json!({ + "sessionId": session_id, + "mode": mode, + "text": text, + }); + if let Some(k) = key { + params["key"] = serde_json::json!(k); + } + send_custom(cx, "_goose/unstable/session/system-prompt/set", params) + .await + .expect("set session system prompt should succeed"); +} + +async fn read_persisted_client_system_prompt( + data_root: &std::path::Path, + session_id: &str, +) -> Option { + let db_path = data_root.join("sessions").join("sessions.db"); + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .connect(&format!("sqlite:{}?mode=ro", db_path.display())) + .await + .unwrap(); + sqlx::query_scalar::<_, Option>( + "SELECT client_system_prompt_json FROM sessions WHERE id = ?", + ) + .bind(session_id) + .fetch_one(&pool) + .await + .unwrap() +} + +#[test] +fn test_client_system_prompt_persists_across_restart() { + run_test(async move { + let data_root = tempfile::tempdir().unwrap(); + let captured = Arc::new(Mutex::new(Vec::::new())); + + // First connection: register the persona, then drop the connection to + // simulate a `goose serve` restart. + let session_id = { + let openai = + common_tests::fixtures::OpenAiFixture::new(vec![], Arc::new(IgnoreSessionId)).await; + let mut conn = AcpServerConnection::new( + TestConnectionConfig { + data_root: data_root.path().to_path_buf(), + provider_factory: Some(capturing_provider_factory(captured.clone())), + ..Default::default() + }, + openai, + ) + .await; + + let SessionData { session, .. } = conn.new_session().await.unwrap(); + let session_id = session.session_id().0.to_string(); + + send_set_system_prompt( + conn.cx(), + &session_id, + "append", + Some("client_system_prompt"), + "You are Persona A", + ) + .await; + + let persisted = + read_persisted_client_system_prompt(data_root.path(), &session_id).await; + assert!( + persisted + .as_deref() + .is_some_and(|p| p.contains("You are Persona A")), + "expected persona to be persisted, got: {persisted:?}", + ); + + session_id + }; + + // Second connection: reopen against the same data dir, load the + // session, send a prompt, and verify the captured system prompt + // contains the persona we set before the "restart". + let openai = + common_tests::fixtures::OpenAiFixture::new(vec![], Arc::new(IgnoreSessionId)).await; + let mut conn = AcpServerConnection::new( + TestConnectionConfig { + data_root: data_root.path().to_path_buf(), + provider_factory: Some(capturing_provider_factory(captured.clone())), + ..Default::default() + }, + openai, + ) + .await; + + let SessionData { + session: mut loaded, + .. + } = conn.load_session(&session_id, vec![]).await.unwrap(); + + captured.lock().unwrap().clear(); + let _ = loaded + .prompt("hello", PermissionDecision::Cancel) + .await + .expect("prompt should succeed after restart"); + + let prompts = captured.lock().unwrap().clone(); + assert!( + prompts.iter().any(|p| p.contains("You are Persona A")), + "expected rehydrated system prompt to contain persona, got: {prompts:?}", + ); + }); +} + +#[test] +fn test_client_system_prompt_allowlist_excludes_recipe() { + run_test(async move { + let data_root = tempfile::tempdir().unwrap(); + let captured = Arc::new(Mutex::new(Vec::::new())); + + let session_id = { + let openai = + common_tests::fixtures::OpenAiFixture::new(vec![], Arc::new(IgnoreSessionId)).await; + let mut conn = AcpServerConnection::new( + TestConnectionConfig { + data_root: data_root.path().to_path_buf(), + provider_factory: Some(capturing_provider_factory(captured.clone())), + ..Default::default() + }, + openai, + ) + .await; + + let SessionData { session, .. } = conn.new_session().await.unwrap(); + let session_id = session.session_id().0.to_string(); + + send_set_system_prompt( + conn.cx(), + &session_id, + "append", + Some("recipe"), + "should not persist", + ) + .await; + + // Server-managed keys are not persisted, even though the + // in-memory PromptManager mutation succeeds. + let persisted = + read_persisted_client_system_prompt(data_root.path(), &session_id).await; + assert!( + persisted.is_none(), + "recipe key must not be persisted; got: {persisted:?}", + ); + + session_id + }; + + let openai = + common_tests::fixtures::OpenAiFixture::new(vec![], Arc::new(IgnoreSessionId)).await; + let mut conn = AcpServerConnection::new( + TestConnectionConfig { + data_root: data_root.path().to_path_buf(), + provider_factory: Some(capturing_provider_factory(captured.clone())), + ..Default::default() + }, + openai, + ) + .await; + + let SessionData { + session: mut loaded, + .. + } = conn.load_session(&session_id, vec![]).await.unwrap(); + + captured.lock().unwrap().clear(); + let _ = loaded + .prompt("hello", PermissionDecision::Cancel) + .await + .expect("prompt should succeed after restart"); + + let prompts = captured.lock().unwrap().clone(); + assert!( + !prompts.iter().any(|p| p.contains("should not persist")), + "recipe key must not be rehydrated; got: {prompts:?}", + ); + }); +} + +#[test] +fn test_client_system_prompt_override_clear() { + run_test(async move { + let data_root = tempfile::tempdir().unwrap(); + let captured = Arc::new(Mutex::new(Vec::::new())); + + let session_id = { + let openai = + common_tests::fixtures::OpenAiFixture::new(vec![], Arc::new(IgnoreSessionId)).await; + let mut conn = AcpServerConnection::new( + TestConnectionConfig { + data_root: data_root.path().to_path_buf(), + provider_factory: Some(capturing_provider_factory(captured.clone())), + ..Default::default() + }, + openai, + ) + .await; + + let SessionData { session, .. } = conn.new_session().await.unwrap(); + let session_id = session.session_id().0.to_string(); + + send_set_system_prompt(conn.cx(), &session_id, "set", None, "X").await; + let persisted_after_set = + read_persisted_client_system_prompt(data_root.path(), &session_id).await; + assert!( + persisted_after_set + .as_deref() + .is_some_and(|p| p.contains("\"X\"")), + "expected override to be persisted, got: {persisted_after_set:?}", + ); + + // Clearing the override should drop the entire row to NULL since + // there are no other client-authored extras. + send_set_system_prompt(conn.cx(), &session_id, "set", None, "").await; + let persisted_after_clear = + read_persisted_client_system_prompt(data_root.path(), &session_id).await; + assert!( + persisted_after_clear.is_none(), + "expected override to be cleared, got: {persisted_after_clear:?}", + ); + + session_id + }; + + let openai = + common_tests::fixtures::OpenAiFixture::new(vec![], Arc::new(IgnoreSessionId)).await; + let mut conn = AcpServerConnection::new( + TestConnectionConfig { + data_root: data_root.path().to_path_buf(), + provider_factory: Some(capturing_provider_factory(captured.clone())), + ..Default::default() + }, + openai, + ) + .await; + + let SessionData { + session: mut loaded, + .. + } = conn.load_session(&session_id, vec![]).await.unwrap(); + + captured.lock().unwrap().clear(); + let _ = loaded + .prompt("hello", PermissionDecision::Cancel) + .await + .expect("prompt should succeed after restart"); + + let prompts = captured.lock().unwrap().clone(); + assert!( + prompts + .iter() + .all(|p| !p.starts_with("X") && !p.trim_start().starts_with("X\n")), + "cleared override must not be rehydrated as system prompt: {prompts:?}", + ); + }); +} diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index d121999256..dbff668e57 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -4117,6 +4117,22 @@ } } }, + "ClientSystemPrompt": { + "type": "object", + "description": "Client-authored system prompt state persisted with a session.\n\nPopulated via the ACP `_goose/unstable/session/system-prompt/set` method.\n`override_text` mirrors `PromptManager::system_prompt_override` (Set mode);\n`extras` mirrors a filtered subset of `PromptManager::system_prompt_extras`\n(Append mode, only keys whose persistence is allow-listed at the handler).", + "properties": { + "extras": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "overrideText": { + "type": "string", + "nullable": true + } + } + }, "CommandType": { "type": "string", "enum": [ @@ -7793,6 +7809,14 @@ "format": "date-time", "nullable": true }, + "client_system_prompt": { + "allOf": [ + { + "$ref": "#/components/schemas/ClientSystemPrompt" + } + ], + "nullable": true + }, "conversation": { "allOf": [ { diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 09e243ee2b..3350c47a17 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -81,6 +81,21 @@ export type ChatTemplate = { type: 'custom_inline'; }; +/** + * Client-authored system prompt state persisted with a session. + * + * Populated via the ACP `_goose/unstable/session/system-prompt/set` method. + * `override_text` mirrors `PromptManager::system_prompt_override` (Set mode); + * `extras` mirrors a filtered subset of `PromptManager::system_prompt_extras` + * (Append mode, only keys whose persistence is allow-listed at the handler). + */ +export type ClientSystemPrompt = { + extras?: { + [key: string]: string; + }; + overrideText?: string | null; +}; + export type CheckProviderRequest = { provider: string; }; @@ -1288,6 +1303,7 @@ export type Session = { accumulated_output_tokens?: number | null; accumulated_total_tokens?: number | null; archived_at?: string | null; + client_system_prompt?: ClientSystemPrompt | null; conversation?: Conversation | null; created_at: string; extension_data: ExtensionData; diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index 410f74ada5..340333394d 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -96,6 +96,13 @@ export type UpdateWorkingDirRequest_unstable = { * `mode: "set"` replaces Goose's base system prompt. `mode: "append"` adds an * instruction under "Additional Instructions". Reusing a key replaces the * previous value for that mode/key; sending empty text clears it. + * + * Persistence: values set with `mode = Set`, or with `mode = Append` and a + * `key` starting with `client_`, are persisted to the session record and + * rehydrated when the session is loaded. Other `Append` keys (e.g. `recipe`, + * `final_output`, `additional`, `hints_*`) are server-managed and only mutate + * the in-memory PromptManager for the current process; they're rebuilt from + * session state on the next agent spawn. */ export type SetSessionSystemPromptRequest_unstable = { sessionId: string; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index efebe29bc0..47f3b776e8 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -94,6 +94,13 @@ export const zSessionSystemPromptMode = z.union([ * `mode: "set"` replaces Goose's base system prompt. `mode: "append"` adds an * instruction under "Additional Instructions". Reusing a key replaces the * previous value for that mode/key; sending empty text clears it. + * + * Persistence: values set with `mode = Set`, or with `mode = Append` and a + * `key` starting with `client_`, are persisted to the session record and + * rehydrated when the session is loaded. Other `Append` keys (e.g. `recipe`, + * `final_output`, `additional`, `hints_*`) are server-managed and only mutate + * the in-memory PromptManager for the current process; they're rebuilt from + * session state on the next agent spawn. */ export const zSetSessionSystemPromptRequest_unstable = z.object({ sessionId: z.string(),