feat(acp): persist client-authored session system prompts across restarts

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) <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Matt Toohey
2026-05-29 15:42:41 +10:00
parent 25ff547487
commit 6ffcfe60fa
11 changed files with 571 additions and 16 deletions
+7
View File
@@ -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",
+1
View File
@@ -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,
+1 -1
View File
@@ -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"
},
+44
View File
@@ -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<Meta> {
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,
}
}
+73 -9
View File
@@ -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<EmptyResponse, agent_client_protocol::Error> {
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<String>),
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 {})
+70 -5
View File
@@ -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<DateTime<Utc>>,
#[serde(default)]
pub project_id: Option<String>,
#[serde(default)]
pub client_system_prompt: Option<ClientSystemPrompt>,
}
/// 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<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extras: BTreeMap<String, String>,
}
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<Option<DateTime<Utc>>>,
project_id: Option<Option<String>>,
client_system_prompt: Option<Option<ClientSystemPrompt>>,
}
#[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<ClientSystemPrompt>) -> 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<String> = 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<ClientSystemPrompt> = row
.try_get::<Option<String>, _>("client_system_prompt_json")
.ok()
.flatten()
.and_then(|json| match serde_json::from_str::<ClientSystemPrompt>(&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
{}
+321 -1
View File
@@ -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<Mutex<Vec<String>>>,
}
#[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<MessageStream, ProviderError> {
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<Mutex<Vec<String>>>) -> 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<dyn Provider>)
})
},
)
}
async fn send_set_system_prompt(
cx: &agent_client_protocol::ConnectionTo<agent_client_protocol::Agent>,
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<String> {
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<String>>(
"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::<String>::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::<String>::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::<String>::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:?}",
);
});
}
+24
View File
@@ -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": [
{
+16
View File
@@ -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;
+7
View File
@@ -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;
+7
View File
@@ -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(),