mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {})
|
||||
|
||||
@@ -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
|
||||
{}
|
||||
|
||||
@@ -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:?}",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user