Wire up ACP session lifecycle: create, load, list, and stream (#115)

Replace the old chat/sessions commands and frontend stores with direct
ACP integration. Sessions are created lazily, loaded via list_sessions
and load_session, and messages stream through the existing ACP event
pipeline.

- Add acp::create_session, list_sessions, load_session Tauri commands
- Simplify chatSessionStore to drive state from ACP responses
- Add ChatLoadingSkeleton for session loading transitions
- Add shared/api/acp.ts frontend bindings
- Remove legacy chat.rs, sessions.rs, catchup.rs, and store.rs
- Support GOOSE_LOCAL_BINARY env var for local dev
- Add tests for session loading and draft persistence

Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
Bradley Axen
2026-04-07 19:06:53 -07:00
committed by GitHub
parent 354ac358cc
commit 2a8fa0d3bc
38 changed files with 1082 additions and 1684 deletions
+28 -20
View File
@@ -79,17 +79,21 @@ dev:
#!/usr/bin/env bash
set -euo pipefail
LOCAL_GOOSE_BIN="$(./scripts/ensure-local-goose.sh --check-bin)" || {
rc=$?
if [[ $rc -eq 2 ]]; then
echo "❌ Local goose binary is not ready. Run 'just setup' first." >&2
exit 1
if [[ -n "${GOOSE_BIN:-}" ]]; then
echo "Using explicitly set GOOSE_BIN: ${GOOSE_BIN}"
else
LOCAL_GOOSE_BIN="$(./scripts/ensure-local-goose.sh --check-bin)" || {
rc=$?
if [[ $rc -eq 2 ]]; then
echo "❌ Local goose binary is not ready. Run 'just setup' first." >&2
exit 1
fi
exit $rc
}
if [[ -n "${LOCAL_GOOSE_BIN}" ]]; then
export GOOSE_BIN="${LOCAL_GOOSE_BIN}"
echo "Using local goose binary: ${GOOSE_BIN}"
fi
exit $rc
}
if [[ -n "${LOCAL_GOOSE_BIN}" ]]; then
export GOOSE_BIN="${LOCAL_GOOSE_BIN}"
echo "Using local goose binary: ${GOOSE_BIN}"
fi
# Derive a stable port from the working directory so the same worktree
@@ -125,17 +129,21 @@ dev-debug:
#!/usr/bin/env bash
set -euo pipefail
LOCAL_GOOSE_BIN="$(./scripts/ensure-local-goose.sh --check-bin)" || {
rc=$?
if [[ $rc -eq 2 ]]; then
echo "❌ Local goose binary is not ready. Run 'just setup' first." >&2
exit 1
if [[ -n "${GOOSE_BIN:-}" ]]; then
echo "Using explicitly set GOOSE_BIN: ${GOOSE_BIN}"
else
LOCAL_GOOSE_BIN="$(./scripts/ensure-local-goose.sh --check-bin)" || {
rc=$?
if [[ $rc -eq 2 ]]; then
echo "❌ Local goose binary is not ready. Run 'just setup' first." >&2
exit 1
fi
exit $rc
}
if [[ -n "${LOCAL_GOOSE_BIN}" ]]; then
export GOOSE_BIN="${LOCAL_GOOSE_BIN}"
echo "Using local goose binary: ${GOOSE_BIN}"
fi
exit $rc
}
if [[ -n "${LOCAL_GOOSE_BIN}" ]]; then
export GOOSE_BIN="${LOCAL_GOOSE_BIN}"
echo "Using local goose binary: ${GOOSE_BIN}"
fi
VITE_PORT=$(python3 -c "import hashlib,os; h=int(hashlib.sha256(os.getcwd().encode()).hexdigest(),16); print(10000 + h % 55000)")
+5
View File
@@ -24,6 +24,11 @@ const EXCEPTIONS = {
limit: 575,
justification: "Added search-as-you-type filtering for sidebar sessions.",
},
"src-tauri/src/services/acp/manager.rs": {
limit: 520,
justification:
"ACP manager command dispatch loop — each new command variant adds ~10 lines of boilerplate.",
},
};
// Directories excluded from size checks (imported library code)
+33 -34
View File
@@ -4,11 +4,8 @@ use std::sync::Arc;
use tauri::{AppHandle, State};
use crate::services::acp::{
make_composite_key, AcpRunningSession, AcpService, AcpSessionRegistry, GooseAcpManager,
};
use crate::services::sessions::SessionStore;
use crate::types::messages::{
MessageCompletionStatus, MessageContent, MessageMetadata, ToolCallStatus,
make_composite_key, AcpRunningSession, AcpService, AcpSessionInfo, AcpSessionRegistry,
GooseAcpManager,
};
const DEPRECATED_PROVIDER_IDS: &[&str] = &["claude-code", "codex", "gemini-cli"];
@@ -109,7 +106,6 @@ pub async fn discover_acp_providers(
pub async fn acp_send_message(
app_handle: AppHandle,
registry: State<'_, Arc<AcpSessionRegistry>>,
session_store: State<'_, Arc<SessionStore>>,
session_id: String,
provider_id: String,
prompt: String,
@@ -126,7 +122,6 @@ pub async fn acp_send_message(
AcpService::send_prompt(
app_handle,
Arc::clone(&registry),
Arc::clone(&session_store),
session_id,
provider_id,
prompt,
@@ -142,7 +137,6 @@ pub async fn acp_send_message(
#[tauri::command]
pub async fn acp_prepare_session(
app_handle: AppHandle,
session_store: State<'_, Arc<SessionStore>>,
session_id: String,
provider_id: String,
working_dir: Option<String>,
@@ -152,15 +146,36 @@ pub async fn acp_prepare_session(
.map_err(|error| format!("Failed to determine current working directory: {error}"))?;
let working_dir = resolve_working_dir(working_dir, &current_dir)?;
AcpService::prepare_session(
app_handle,
session_id,
provider_id,
working_dir,
persona_id,
Arc::clone(&session_store),
)
.await
AcpService::prepare_session(app_handle, session_id, provider_id, working_dir, persona_id).await
}
/// List all sessions known to the goose binary.
#[tauri::command]
pub async fn acp_list_sessions(app_handle: AppHandle) -> Result<Vec<AcpSessionInfo>, String> {
let manager = GooseAcpManager::start(app_handle).await?;
manager.list_sessions().await
}
/// Load an existing session, replaying its messages as Tauri events.
///
/// The goose binary sends `SessionNotification` events for each message in
/// the session history. The frontend's `useAcpStream` hook picks these up
/// the same way it handles live streaming.
#[tauri::command]
pub async fn acp_load_session(
app_handle: AppHandle,
session_id: String,
goose_session_id: String,
working_dir: Option<String>,
) -> Result<(), String> {
let current_dir = std::env::current_dir()
.map_err(|error| format!("Failed to determine current working directory: {error}"))?;
let working_dir = resolve_working_dir(working_dir, &current_dir)?;
let manager = GooseAcpManager::start(app_handle).await?;
manager
.load_session(session_id, goose_session_id, working_dir)
.await
}
#[cfg(test)]
@@ -228,30 +243,14 @@ mod tests {
pub async fn acp_cancel_session(
app_handle: AppHandle,
registry: State<'_, Arc<AcpSessionRegistry>>,
session_store: State<'_, Arc<SessionStore>>,
session_id: String,
persona_id: Option<String>,
) -> Result<bool, String> {
let key = make_composite_key(&session_id, persona_id.as_deref());
let assistant_message_id = registry.cancel(&key);
let _assistant_message_id = registry.cancel(&key);
let manager = GooseAcpManager::start(app_handle).await?;
let was_cancelled = manager.cancel_session(key).await?;
if let Some(message_id) = assistant_message_id.as_deref() {
let _ = session_store.update_message(&session_id, message_id, |message| {
for block in &mut message.content {
if let MessageContent::ToolRequest { status, .. } = block {
*status = ToolCallStatus::Stopped;
}
}
let metadata = message
.metadata
.get_or_insert_with(MessageMetadata::default);
metadata.completion_status = Some(MessageCompletionStatus::Stopped);
});
}
Ok(was_cancelled)
}
-30
View File
@@ -1,30 +0,0 @@
use std::sync::Arc;
use crate::services::sessions::SessionStore;
use crate::types::messages::*;
use tauri::State;
#[tauri::command]
pub async fn chat_send_message(
session_store: State<'_, Arc<SessionStore>>,
session_id: String,
message: Message,
) -> Result<Message, String> {
// 1. Store the user message
session_store.add_message(&session_id, message.clone())?;
// 2. TODO: Connect to goosed via HTTP/SSE for real inference
// For now, return a mock assistant response
let response = Message {
id: uuid::Uuid::new_v4().to_string(),
role: MessageRole::Assistant,
created: chrono::Utc::now().timestamp_millis(),
content: vec![MessageContent::Text {
text: "I'm Goose, your AI coding assistant. The chat backend is being set up — real inference coming soon! For now, I can confirm the message pipeline is working end-to-end.".to_string(),
}],
metadata: None,
};
session_store.add_message(&session_id, response.clone())?;
Ok(response)
}
-2
View File
@@ -1,9 +1,7 @@
pub mod acp;
pub mod agents;
pub mod chat;
pub mod doctor;
pub mod git;
pub mod projects;
pub mod sessions;
pub mod skills;
pub mod system;
@@ -1,66 +0,0 @@
use std::sync::Arc;
use crate::services::sessions::SessionStore;
use crate::types::agents::{Session, SessionUpdate};
use crate::types::messages::Message;
use tauri::State;
#[tauri::command]
pub fn create_session(
store: State<'_, Arc<SessionStore>>,
agent_id: Option<String>,
project_id: Option<String>,
) -> Session {
store.create_session(agent_id, project_id)
}
#[tauri::command]
pub fn list_sessions(store: State<'_, Arc<SessionStore>>) -> Vec<Session> {
store.list_sessions()
}
#[tauri::command]
pub fn get_session_messages(
store: State<'_, Arc<SessionStore>>,
session_id: String,
) -> Vec<Message> {
store.get_messages(&session_id)
}
#[tauri::command]
pub fn update_session(
store: State<'_, Arc<SessionStore>>,
session_id: String,
update: SessionUpdate,
) -> Result<(), String> {
store.update_session(&session_id, update)
}
#[tauri::command]
pub fn delete_session(
store: State<'_, Arc<SessionStore>>,
session_id: String,
) -> Result<(), String> {
store.delete_session(&session_id)
}
#[tauri::command]
pub fn list_archived_sessions(store: State<'_, Arc<SessionStore>>) -> Vec<Session> {
store.list_archived_sessions()
}
#[tauri::command]
pub fn archive_session(
store: State<'_, Arc<SessionStore>>,
session_id: String,
) -> Result<(), String> {
store.archive_session(&session_id)
}
#[tauri::command]
pub fn unarchive_session(
store: State<'_, Arc<SessionStore>>,
session_id: String,
) -> Result<(), String> {
store.unarchive_session(&session_id)
}
+2 -14
View File
@@ -6,14 +6,10 @@ use std::sync::Arc;
use services::acp::AcpSessionRegistry;
use services::personas::PersonaStore;
use services::sessions::SessionStore;
use tauri_plugin_window_state::StateFlags;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Clean up stale ACP session files older than 24 hours.
services::acp::TauriStore::cleanup_stale_sessions(std::time::Duration::from_secs(24 * 60 * 60));
let acp_registry = Arc::new(AcpSessionRegistry::new());
let acp_registry_for_exit = Arc::clone(&acp_registry);
@@ -34,7 +30,6 @@ pub fn run() {
.build(),
)
.manage(PersonaStore::new())
.manage(Arc::new(SessionStore::new()))
.manage(acp_registry)
.invoke_handler(tauri::generate_handler![
commands::agents::list_personas,
@@ -47,19 +42,12 @@ pub fn run() {
commands::agents::save_persona_avatar,
commands::agents::save_persona_avatar_bytes,
commands::agents::get_avatars_dir,
commands::sessions::create_session,
commands::sessions::list_sessions,
commands::sessions::get_session_messages,
commands::sessions::update_session,
commands::sessions::delete_session,
commands::sessions::list_archived_sessions,
commands::sessions::archive_session,
commands::sessions::unarchive_session,
commands::chat::chat_send_message,
commands::acp::discover_acp_providers,
commands::acp::acp_prepare_session,
commands::acp::acp_send_message,
commands::acp::acp_cancel_session,
commands::acp::acp_list_sessions,
commands::acp::acp_load_session,
commands::acp::acp_list_running,
commands::acp::acp_cancel_all,
commands::skills::create_skill,
@@ -1,213 +0,0 @@
use crate::types::messages::{Message, MessageContent, MessageRole};
/// Build catch-up context for a persona from intervening messages.
///
/// When the user sends a message to persona X and there have been
/// intervening messages to/from other personas since persona X last
/// responded, this function produces a context block so persona X
/// stays informed.
///
/// Returns `None` if there are no intervening messages to summarize.
pub fn build_catchup_context(
messages: &[Message],
persona_id: &str,
current_message_id: &str,
) -> Option<String> {
// Algorithm:
// 1. Find the index of the last assistant message FROM this persona
// 2. Collect all messages after that point, excluding:
// - The current user prompt being sent now
// - Messages from this persona itself
// 3. Format as catch-up text
// 4. Return None if no intervening messages
if messages.is_empty() {
return None;
}
// Find last assistant message from this persona
let last_own_idx = messages.iter().rposition(|m| {
matches!(m.role, MessageRole::Assistant)
&& (m
.metadata
.as_ref()
.and_then(|meta| meta.persona_id.as_deref())
== Some(persona_id))
});
// Start collecting from after the last own message (or from beginning)
let start_idx = match last_own_idx {
Some(idx) => idx + 1,
None => 0,
};
let mut lines = Vec::new();
for msg in &messages[start_idx..] {
if msg.id == current_message_id {
continue;
}
if matches!(msg.role, MessageRole::User) {
let target_persona_id = msg
.metadata
.as_ref()
.and_then(|meta| meta.target_persona_id.as_deref());
if target_persona_id == Some(persona_id) {
continue;
}
}
// Skip assistant messages from this persona itself
if matches!(msg.role, MessageRole::Assistant) {
let msg_persona_id = msg
.metadata
.as_ref()
.and_then(|meta| meta.persona_id.as_deref());
if msg_persona_id == Some(persona_id) {
continue;
}
}
let text = extract_text(msg);
if text.is_empty() {
continue;
}
let truncated = truncate_text(&text, 200);
match msg.role {
MessageRole::User => {
let target_name = msg
.metadata
.as_ref()
.and_then(|meta| meta.target_persona_name.as_deref())
.unwrap_or("Unknown");
lines.push(format!("- User \u{2192} {}: {}", target_name, truncated));
}
MessageRole::Assistant => {
let name = msg
.metadata
.as_ref()
.and_then(|meta| meta.persona_name.as_deref().or(meta.persona_id.as_deref()))
.unwrap_or("Unknown");
lines.push(format!("- {}: {}", name, truncated));
}
MessageRole::System => {
lines.push(format!("- System: {}", truncated));
}
}
}
if lines.is_empty() {
return None;
}
Some(format!(
"[Context from other participants since your last message]\n{}",
lines.join("\n")
))
}
/// Extract concatenated text content from a message.
fn extract_text(msg: &Message) -> String {
msg.content
.iter()
.filter_map(|c| match c {
MessageContent::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join(" ")
}
/// Truncate text to `max_chars` characters, appending "..." if truncated.
fn truncate_text(text: &str, max_chars: usize) -> String {
match text.char_indices().nth(max_chars) {
Some((byte_idx, _)) => format!("{}...", &text[..byte_idx]),
None => text.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::build_catchup_context;
use crate::types::messages::{Message, MessageContent, MessageMetadata, MessageRole};
fn text_message(
id: &str,
role: MessageRole,
text: &str,
metadata: Option<MessageMetadata>,
) -> Message {
Message {
id: id.to_string(),
role,
created: 0,
content: vec![MessageContent::Text {
text: text.to_string(),
}],
metadata,
}
}
#[test]
fn skips_user_messages_already_targeted_at_current_persona() {
let messages = vec![
text_message(
"assistant-a",
MessageRole::Assistant,
"Earlier reply",
Some(MessageMetadata {
persona_id: Some("persona-a".to_string()),
persona_name: Some("Persona A".to_string()),
..Default::default()
}),
),
text_message(
"user-to-a",
MessageRole::User,
"Please continue",
Some(MessageMetadata {
target_persona_id: Some("persona-a".to_string()),
target_persona_name: Some("Persona A".to_string()),
..Default::default()
}),
),
text_message(
"assistant-b",
MessageRole::Assistant,
"Something happened elsewhere",
Some(MessageMetadata {
persona_id: Some("persona-b".to_string()),
persona_name: Some("Persona B".to_string()),
..Default::default()
}),
),
];
let context = build_catchup_context(&messages, "persona-a", "current-user-message")
.expect("expected catch-up context");
assert!(!context.contains("Please continue"));
assert!(context.contains("Persona B: Something happened elsewhere"));
}
#[test]
fn keeps_user_messages_for_other_personas_in_catchup() {
let messages = vec![text_message(
"user-to-b",
MessageRole::User,
"Can Persona B take this one?",
Some(MessageMetadata {
target_persona_id: Some("persona-b".to_string()),
target_persona_name: Some("Persona B".to_string()),
..Default::default()
}),
)];
let context = build_catchup_context(&messages, "persona-a", "current-user-message")
.expect("expected catch-up context");
assert!(context.contains("User → Persona B: Can Persona B take this one?"));
}
}
+74 -13
View File
@@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use acp_client::{MessageWriter, Store};
use acp_client::MessageWriter;
use agent_client_protocol::{
Agent, ClientSideConnection, ExtRequest, Implementation, InitializeRequest, ProtocolVersion,
};
@@ -20,22 +20,31 @@ use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
use super::goose_serve::{GooseServeProcess, WS_BRIDGE_BUFFER_BYTES};
use dispatcher::{SessionEventDispatcher, SessionRoute};
pub use session_ops::AcpSessionInfo;
use session_ops::{
cancel_session_inner, prepare_session_inner, send_prompt_inner, ManagerState,
PrepareSessionInput,
cancel_session_inner, list_sessions_inner, load_session_inner, prepare_session_inner,
send_prompt_inner, ManagerState, PrepareSessionInput,
};
enum ManagerCommand {
ListProviders {
response: oneshot::Sender<Result<Vec<GooseAcpProvider>, String>>,
},
ListSessions {
response: oneshot::Sender<Result<Vec<AcpSessionInfo>, String>>,
},
LoadSession {
local_session_id: String,
goose_session_id: String,
working_dir: PathBuf,
response: oneshot::Sender<Result<(), String>>,
},
PrepareSession {
composite_key: String,
local_session_id: String,
provider_id: String,
working_dir: PathBuf,
existing_agent_session_id: Option<String>,
store: Arc<dyn Store>,
response: oneshot::Sender<Result<(), String>>,
},
SendPrompt {
@@ -44,7 +53,6 @@ enum ManagerCommand {
provider_id: String,
working_dir: PathBuf,
existing_agent_session_id: Option<String>,
store: Arc<dyn Store>,
writer: Arc<dyn MessageWriter>,
prompt: String,
images: Vec<(String, String)>,
@@ -98,6 +106,38 @@ impl GooseAcpManager {
.map(Arc::clone)
}
pub async fn list_sessions(&self) -> Result<Vec<AcpSessionInfo>, String> {
let (response_tx, response_rx) = oneshot::channel();
self.command_tx
.send(ManagerCommand::ListSessions {
response: response_tx,
})
.map_err(|_| "Goose ACP manager is unavailable".to_string())?;
response_rx
.await
.map_err(|_| "Goose ACP manager dropped list sessions request".to_string())?
}
pub async fn load_session(
&self,
local_session_id: String,
goose_session_id: String,
working_dir: PathBuf,
) -> Result<(), String> {
let (response_tx, response_rx) = oneshot::channel();
self.command_tx
.send(ManagerCommand::LoadSession {
local_session_id,
goose_session_id,
working_dir,
response: response_tx,
})
.map_err(|_| "Goose ACP manager is unavailable".to_string())?;
response_rx
.await
.map_err(|_| "Goose ACP manager dropped load session request".to_string())?
}
pub async fn prepare_session(
&self,
composite_key: String,
@@ -105,7 +145,6 @@ impl GooseAcpManager {
provider_id: String,
working_dir: PathBuf,
existing_agent_session_id: Option<String>,
store: Arc<dyn Store>,
) -> Result<(), String> {
let (response_tx, response_rx) = oneshot::channel();
self.command_tx
@@ -115,7 +154,6 @@ impl GooseAcpManager {
provider_id,
working_dir,
existing_agent_session_id,
store,
response: response_tx,
})
.map_err(|_| "Goose ACP manager is unavailable".to_string())?;
@@ -144,7 +182,6 @@ impl GooseAcpManager {
provider_id: String,
working_dir: PathBuf,
existing_agent_session_id: Option<String>,
store: Arc<dyn Store>,
writer: Arc<dyn MessageWriter>,
prompt: String,
images: Vec<(String, String)>,
@@ -157,7 +194,6 @@ impl GooseAcpManager {
provider_id,
working_dir,
existing_agent_session_id,
store,
writer,
prompt,
images,
@@ -361,13 +397,41 @@ fn run_manager_thread(
let _ = response.send(result);
});
}
ManagerCommand::ListSessions { response } => {
let connection = Arc::clone(&connection);
tokio::task::spawn_local(async move {
let result = list_sessions_inner(&connection).await;
let _ = response.send(result);
});
}
ManagerCommand::LoadSession {
local_session_id,
goose_session_id,
working_dir,
response,
} => {
let connection = Arc::clone(&connection);
let dispatcher = dispatcher.clone();
let state = Arc::clone(&state);
tokio::task::spawn_local(async move {
let result = load_session_inner(
&connection,
&dispatcher,
&state,
&local_session_id,
&goose_session_id,
working_dir,
)
.await;
let _ = response.send(result);
});
}
ManagerCommand::PrepareSession {
composite_key,
local_session_id,
provider_id,
working_dir,
existing_agent_session_id,
store,
response,
} => {
let connection = Arc::clone(&connection);
@@ -384,7 +448,6 @@ fn run_manager_thread(
provider_id,
working_dir,
existing_agent_session_id,
store,
},
)
.await
@@ -398,7 +461,6 @@ fn run_manager_thread(
provider_id,
working_dir,
existing_agent_session_id,
store,
writer,
prompt,
images,
@@ -417,7 +479,6 @@ fn run_manager_thread(
provider_id,
working_dir,
existing_agent_session_id,
store,
writer,
prompt,
images,
@@ -13,13 +13,18 @@ use async_trait::async_trait;
use tauri::Emitter;
use tokio::sync::Mutex;
use crate::services::acp::payloads::{ModelStatePayload, SessionInfoPayload};
use crate::services::acp::payloads::{
DonePayload, MessageCreatedPayload, ModelStatePayload, SessionInfoPayload, TextPayload,
ToolCallPayload, ToolResultPayload, ToolTitlePayload,
};
#[derive(Clone)]
pub(super) struct SessionRoute {
pub(super) local_session_id: String,
pub(super) writer: Option<Arc<dyn MessageWriter>>,
pub(super) canceled: bool,
/// Tracks the current assistant message ID during replay (no writer).
pub(super) replay_message_id: Option<String>,
}
pub(super) struct SessionEventDispatcher {
@@ -44,6 +49,7 @@ impl SessionEventDispatcher {
local_session_id: local_session_id.to_string(),
writer: None,
canceled: false,
replay_message_id: None,
});
}
@@ -60,6 +66,7 @@ impl SessionEventDispatcher {
local_session_id: local_session_id.to_string(),
writer: Some(writer),
canceled: false,
replay_message_id: None,
},
);
}
@@ -90,6 +97,24 @@ impl SessionEventDispatcher {
.is_some_and(|route| route.canceled)
}
/// Finalize any in-progress replay message and clear the replay state.
/// Called after `load_session` completes to mark the last replayed
/// assistant message as done.
pub(super) async fn finalize_replay(&self, goose_session_id: &str) {
let mut routes = self.routes.lock().await;
if let Some(route) = routes.get_mut(goose_session_id) {
if let Some(message_id) = route.replay_message_id.take() {
let _ = self.app_handle.emit(
"acp:done",
DonePayload {
session_id: route.local_session_id.clone(),
message_id,
},
);
}
}
}
pub(super) fn emit_session_info(&self, local_session_id: &str, info: &SessionInfoUpdate) {
let _ = self.app_handle.emit(
"acp:session_info",
@@ -233,30 +258,165 @@ impl Client for SessionEventDispatcher {
_ => {}
}
let Some(writer) = route.writer else {
// Live streaming path — writer is present
if let Some(writer) = route.writer {
match &notification.update {
SessionUpdate::AgentMessageChunk(chunk) => {
if let AcpContentBlock::Text(text) = &chunk.content {
writer.append_text(&text.text).await;
}
}
SessionUpdate::ToolCall(tool_call) => {
writer
.record_tool_call(&tool_call.tool_call_id.0, &tool_call.title)
.await;
}
SessionUpdate::ToolCallUpdate(update) => {
if let Some(title) = &update.fields.title {
writer
.update_tool_call_title(&update.tool_call_id.0, title)
.await;
}
if let Some(content) = &update.fields.content {
if let Some(result) = extract_content_preview(content) {
writer.record_tool_result(&result).await;
}
}
}
_ => {}
}
return Ok(());
};
}
// Replay path — no writer, emit Tauri events directly.
// This handles messages replayed by load_session.
let local_session_id = route.local_session_id.clone();
match &notification.update {
SessionUpdate::UserMessageChunk(chunk) => {
if let AcpContentBlock::Text(text) = &chunk.content {
// Finalize any in-progress assistant message first
{
let mut routes = self.routes.lock().await;
if let Some(route) = routes.get_mut(&goose_session_id) {
if let Some(prev_msg_id) = route.replay_message_id.take() {
let _ = self.app_handle.emit(
"acp:done",
DonePayload {
session_id: local_session_id.clone(),
message_id: prev_msg_id,
},
);
}
}
}
let message_id = uuid::Uuid::new_v4().to_string();
let _ = self.app_handle.emit(
"acp:replay_user_message",
serde_json::json!({
"sessionId": local_session_id,
"messageId": message_id,
"text": text.text,
}),
);
}
}
SessionUpdate::AgentMessageChunk(chunk) => {
if let AcpContentBlock::Text(text) = &chunk.content {
writer.append_text(&text.text).await;
// Check if we already have a replay message in progress
let replay_msg_id = {
let routes = self.routes.lock().await;
routes
.get(&goose_session_id)
.and_then(|r| r.replay_message_id.clone())
};
let message_id = if let Some(id) = replay_msg_id {
id
} else {
// Start a new assistant message
let new_id = uuid::Uuid::new_v4().to_string();
let _ = self.app_handle.emit(
"acp:message_created",
MessageCreatedPayload {
session_id: local_session_id.clone(),
message_id: new_id.clone(),
persona_id: None,
persona_name: None,
},
);
let mut routes = self.routes.lock().await;
if let Some(route) = routes.get_mut(&goose_session_id) {
route.replay_message_id = Some(new_id.clone());
}
new_id
};
let _ = self.app_handle.emit(
"acp:text",
TextPayload {
session_id: local_session_id,
message_id: message_id.clone(),
text: text.text.clone(),
},
);
}
}
SessionUpdate::ToolCall(tool_call) => {
writer
.record_tool_call(&tool_call.tool_call_id.0, &tool_call.title)
.await;
let replay_msg_id = {
let routes = self.routes.lock().await;
routes
.get(&goose_session_id)
.and_then(|r| r.replay_message_id.clone())
};
if let Some(message_id) = replay_msg_id {
let _ = self.app_handle.emit(
"acp:tool_call",
ToolCallPayload {
session_id: local_session_id,
message_id,
tool_call_id: tool_call.tool_call_id.0.to_string(),
title: tool_call.title.clone(),
},
);
}
}
SessionUpdate::ToolCallUpdate(update) => {
if let Some(title) = &update.fields.title {
writer
.update_tool_call_title(&update.tool_call_id.0, title)
.await;
}
if let Some(content) = &update.fields.content {
if let Some(result) = extract_content_preview(content) {
writer.record_tool_result(&result).await;
let replay_msg_id = {
let routes = self.routes.lock().await;
routes
.get(&goose_session_id)
.and_then(|r| r.replay_message_id.clone())
};
if let Some(message_id) = replay_msg_id {
if let Some(title) = &update.fields.title {
let _ = self.app_handle.emit(
"acp:tool_title",
ToolTitlePayload {
session_id: local_session_id.clone(),
message_id: message_id.clone(),
tool_call_id: update.tool_call_id.0.to_string(),
title: title.clone(),
},
);
}
if let Some(content) = &update.fields.content {
// During replay, always emit a tool_result so the
// frontend can mark the tool request as completed.
// Fall back to a generic summary when the content
// type has no preview extractor.
let result =
extract_content_preview(content).unwrap_or_else(|| "Done".to_string());
let _ = self.app_handle.emit(
"acp:tool_result",
ToolResultPayload {
session_id: local_session_id,
message_id,
content: result,
},
);
}
}
}
@@ -2,16 +2,25 @@ use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use acp_client::{MessageWriter, Store};
use acp_client::MessageWriter;
use agent_client_protocol::{
Agent, CancelNotification, ClientSideConnection, ContentBlock as AcpContentBlock, ImageContent,
LoadSessionRequest, NewSessionRequest, PromptRequest, SessionConfigKind, SessionConfigOption,
SessionConfigSelectOptions, SetSessionConfigOptionRequest, TextContent,
ListSessionsRequest, LoadSessionRequest, NewSessionRequest, PromptRequest, SessionConfigKind,
SessionConfigOption, SessionConfigSelectOptions, SetSessionConfigOptionRequest, TextContent,
};
use tokio::sync::Mutex;
use super::dispatcher::SessionEventDispatcher;
/// Lightweight session metadata returned by `list_sessions`.
#[derive(Clone, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AcpSessionInfo {
pub session_id: String,
pub title: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Clone)]
pub(super) struct PreparedSession {
goose_session_id: String,
@@ -79,7 +88,6 @@ pub(super) struct PrepareSessionInput {
pub(super) provider_id: String,
pub(super) working_dir: PathBuf,
pub(super) existing_agent_session_id: Option<String>,
pub(super) store: Arc<dyn Store>,
}
pub(super) async fn prepare_session_inner(
@@ -94,7 +102,6 @@ pub(super) async fn prepare_session_inner(
provider_id,
working_dir,
existing_agent_session_id,
store,
} = input;
let session_lock = {
@@ -220,9 +227,6 @@ pub(super) async fn prepare_session_inner(
.map_err(|error| format!("Failed to create Goose session: {error:?}"))?;
let new_id = response.session_id.to_string();
store
.set_agent_session_id(&local_session_id, &new_id)
.map_err(|error| format!("Failed to save Goose session ID: {error}"))?;
dispatcher.bind_session(&new_id, &local_session_id).await;
@@ -282,7 +286,6 @@ pub(super) async fn send_prompt_inner(
provider_id: String,
working_dir: PathBuf,
existing_agent_session_id: Option<String>,
store: Arc<dyn Store>,
writer: Arc<dyn MessageWriter>,
prompt: String,
images: Vec<(String, String)>,
@@ -297,7 +300,6 @@ pub(super) async fn send_prompt_inner(
provider_id,
working_dir,
existing_agent_session_id,
store,
},
)
.await
@@ -349,6 +351,76 @@ pub(super) async fn send_prompt_inner(
result
}
/// List all sessions known to the goose binary.
pub(super) async fn list_sessions_inner(
connection: &Arc<ClientSideConnection>,
) -> Result<Vec<AcpSessionInfo>, String> {
let response = connection
.list_sessions(ListSessionsRequest::default())
.await
.map_err(|error| format!("Failed to list sessions via Goose ACP: {error:?}"))?;
Ok(response
.sessions
.into_iter()
.map(|info| AcpSessionInfo {
session_id: info.session_id.to_string(),
title: info.title,
updated_at: info.updated_at,
})
.collect())
}
/// Load an existing session from the goose binary.
///
/// This binds the goose session ID to the local session ID in the dispatcher
/// so that replayed `SessionNotification` events are routed to the correct
/// frontend session. It also registers the session in the manager state so
/// that subsequent `send_prompt` calls can reuse the goose session.
pub(super) async fn load_session_inner(
connection: &Arc<ClientSideConnection>,
dispatcher: &Arc<SessionEventDispatcher>,
state: &Arc<Mutex<ManagerState>>,
local_session_id: &str,
goose_session_id: &str,
working_dir: PathBuf,
) -> Result<(), String> {
dispatcher
.bind_session(goose_session_id, local_session_id)
.await;
let response = connection
.load_session(LoadSessionRequest::new(
goose_session_id.to_string(),
working_dir.clone(),
))
.await
.map_err(|error| format!("Failed to load Goose session: {error:?}"))?;
// Finalize any in-progress replay assistant message
dispatcher.finalize_replay(goose_session_id).await;
if let Some(models) = &response.models {
dispatcher.emit_model_state(local_session_id, models);
}
if let Some(options) = &response.config_options {
dispatcher.emit_model_state_from_options(local_session_id, options);
}
// Register the session so future prompts reuse this goose session
let mut guard = state.lock().await;
guard.sessions.insert(
local_session_id.to_string(),
PreparedSession {
goose_session_id: goose_session_id.to_string(),
provider_id: "goose".to_string(), // will be updated on next prepare
working_dir,
},
);
Ok(())
}
pub(super) async fn cancel_session_inner(
connection: &Arc<ClientSideConnection>,
dispatcher: &Arc<SessionEventDispatcher>,
+17 -110
View File
@@ -1,26 +1,19 @@
mod catchup;
mod goose_serve;
mod manager;
mod payloads;
mod registry;
mod store;
mod writer;
pub use catchup::build_catchup_context;
pub use manager::GooseAcpManager;
pub use manager::{AcpSessionInfo, GooseAcpManager};
pub use registry::{AcpRunningSession, AcpSessionRegistry};
pub use store::TauriStore;
pub use writer::TauriMessageWriter;
use std::path::PathBuf;
use std::sync::Arc;
use acp_client::{MessageWriter, Store};
use acp_client::MessageWriter;
use tauri::Emitter;
use crate::services::sessions::SessionStore;
use crate::types::messages::{
MessageCompletionStatus, MessageContent, MessageMetadata, MessageRole, ToolCallStatus,
};
/// Build a composite registry key: `{session_id}__{persona_id}` when a
/// persona is active, or plain `session_id` for backward compatibility.
///
@@ -53,14 +46,8 @@ impl AcpService {
provider_id: String,
working_dir: PathBuf,
persona_id: Option<String>,
session_store: Arc<SessionStore>,
) -> Result<(), String> {
session_store.ensure_session(&session_id, Some(provider_id.clone()));
let manager = GooseAcpManager::start(app_handle).await?;
let tauri_store = TauriStore::new(session_store, session_id.clone(), persona_id.clone());
let existing_agent_session_id = tauri_store.get_agent_session_id();
let store: Arc<dyn Store> = Arc::new(tauri_store);
manager
.prepare_session(
@@ -68,8 +55,7 @@ impl AcpService {
session_id,
provider_id,
working_dir,
existing_agent_session_id,
store,
None, // no existing agent session ID — goose binary owns sessions
)
.await
}
@@ -80,7 +66,6 @@ impl AcpService {
pub async fn send_prompt(
app_handle: tauri::AppHandle,
registry: Arc<AcpSessionRegistry>,
session_store: Arc<SessionStore>,
session_id: String,
provider_id: String,
prompt: String,
@@ -90,50 +75,12 @@ impl AcpService {
persona_name: Option<String>,
images: Vec<(String, String)>,
) -> Result<(), String> {
// Ensure the session exists in the SessionStore (create if needed)
session_store.ensure_session(&session_id, Some(provider_id.clone()));
// Save the user message to SessionStore, with persona metadata when targeted
let user_message_id = uuid::Uuid::new_v4().to_string();
let user_message = crate::types::messages::Message {
id: user_message_id.clone(),
role: MessageRole::User,
created: chrono::Utc::now().timestamp_millis(),
content: vec![MessageContent::Text {
text: prompt.clone(),
}],
metadata: if persona_id.is_some() {
Some(crate::types::messages::MessageMetadata {
target_persona_id: persona_id.clone(),
target_persona_name: persona_name.clone(),
..Default::default()
})
} else {
None
},
};
if let Err(e) = session_store.add_message(&session_id, user_message) {
eprintln!(
"Failed to save user message for session {}: {}",
session_id, e
);
}
// Build catch-up context from intervening messages for this persona
let catchup_context = if let Some(ref pid) = persona_id {
let all_messages = session_store.get_messages(&session_id);
build_catchup_context(&all_messages, pid, &user_message_id)
} else {
None
};
let registry_key = make_composite_key(&session_id, persona_id.as_deref());
let cancel_token = registry.register(&registry_key, &provider_id);
let writer_impl = Arc::new(TauriMessageWriter::new(
app_handle.clone(),
session_id.clone(),
Arc::clone(&session_store),
persona_id.clone(),
persona_name.clone(),
));
@@ -141,18 +88,13 @@ impl AcpService {
&registry_key,
writer_impl.assistant_message_id().to_string(),
);
let tauri_store =
TauriStore::new(Arc::clone(&session_store), session_id.clone(), persona_id);
let agent_session_id = tauri_store.get_agent_session_id();
let store: Arc<dyn Store> = Arc::new(tauri_store);
let writer: Arc<dyn MessageWriter> = writer_impl.clone();
// Build the effective prompt, including persona instructions and
// catch-up context when available. When there is no extra context we
// pass the raw prompt for backward compatibility.
// Build the effective prompt, including persona instructions when
// available. When there is no extra context we pass the raw prompt
// for backward compatibility.
let has_system = system_prompt.as_ref().is_some_and(|s| !s.is_empty());
let has_catchup = catchup_context.is_some();
let effective_prompt = if has_system || has_catchup {
let effective_prompt = if has_system {
let mut parts = Vec::new();
if let Some(ref sp) = system_prompt {
if !sp.is_empty() {
@@ -161,11 +103,6 @@ impl AcpService {
));
}
}
if let Some(ref ctx) = catchup_context {
parts.push(format!(
"<conversation-context>\n{ctx}\n</conversation-context>"
));
}
parts.push(format!("<user-message>\n{prompt}\n</user-message>"));
parts.join("\n\n")
} else {
@@ -179,8 +116,7 @@ impl AcpService {
session_id.clone(),
provider_id.clone(),
working_dir,
agent_session_id,
store,
None, // no existing agent session ID — goose binary owns sessions
writer,
effective_prompt,
images,
@@ -191,44 +127,15 @@ impl AcpService {
drop(cancel_token);
if let Err(ref error) = result {
let _ = session_store.update_message(
&session_id,
writer_impl.assistant_message_id(),
|message| {
for block in &mut message.content {
if let MessageContent::ToolRequest { status, .. } = block {
*status = ToolCallStatus::Error;
}
}
let metadata = message
.metadata
.get_or_insert_with(MessageMetadata::default);
metadata.completion_status = Some(MessageCompletionStatus::Error);
},
);
let error_message = crate::types::messages::Message {
id: uuid::Uuid::new_v4().to_string(),
role: MessageRole::System,
created: chrono::Utc::now().timestamp_millis(),
content: vec![MessageContent::SystemNotification {
notification_type: "error".to_string(),
text: error.clone(),
}],
metadata: Some(crate::types::messages::MessageMetadata {
user_visible: Some(true),
agent_visible: Some(false),
..Default::default()
// Emit an error event so the frontend can display it
let _ = app_handle.emit(
"acp:error",
serde_json::json!({
"sessionId": session_id,
"messageId": writer_impl.assistant_message_id(),
"error": error,
}),
};
if let Err(save_error) = session_store.add_message(&session_id, error_message) {
eprintln!(
"Failed to save ACP error message for session {}: {}",
session_id, save_error
);
}
);
}
result
@@ -3,7 +3,7 @@ use serde::Serialize;
/// Payload for the `acp:message_created` event.
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct MessageCreatedPayload {
pub(crate) struct MessageCreatedPayload {
pub session_id: String,
pub message_id: String,
pub persona_id: Option<String>,
@@ -13,7 +13,7 @@ pub(super) struct MessageCreatedPayload {
/// Payload for the `acp:text` event.
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct TextPayload {
pub(crate) struct TextPayload {
pub session_id: String,
pub message_id: String,
pub text: String,
@@ -22,7 +22,7 @@ pub(super) struct TextPayload {
/// Payload for the `acp:done` event.
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct DonePayload {
pub(crate) struct DonePayload {
pub session_id: String,
pub message_id: String,
}
@@ -30,7 +30,7 @@ pub(super) struct DonePayload {
/// Payload for the `acp:tool_call` event.
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ToolCallPayload {
pub(crate) struct ToolCallPayload {
pub session_id: String,
pub message_id: String,
pub tool_call_id: String,
@@ -40,7 +40,7 @@ pub(super) struct ToolCallPayload {
/// Payload for the `acp:tool_title` event.
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ToolTitlePayload {
pub(crate) struct ToolTitlePayload {
pub session_id: String,
pub message_id: String,
pub tool_call_id: String,
@@ -50,7 +50,7 @@ pub(super) struct ToolTitlePayload {
/// Payload for the `acp:tool_result` event.
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ToolResultPayload {
pub(crate) struct ToolResultPayload {
pub session_id: String,
pub message_id: String,
pub content: String,
@@ -58,14 +58,14 @@ pub(super) struct ToolResultPayload {
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct SessionInfoPayload {
pub(crate) struct SessionInfoPayload {
pub session_id: String,
pub title: Option<String>,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ModelStatePayload {
pub(crate) struct ModelStatePayload {
pub session_id: String,
pub current_model_id: String,
pub current_model_name: Option<String>,
@@ -1,190 +0,0 @@
use std::path::PathBuf;
use std::sync::Arc;
use acp_client::Store;
use super::make_composite_key;
use crate::services::sessions::SessionStore;
use crate::types::messages::{Message, MessageContent, MessageRole};
/// A [`Store`] implementation that persists ACP session mappings to disk
/// under `~/.goose/acp_sessions/` and reads conversation history from the
/// [`SessionStore`].
///
/// When `persona_id` is set, the store uses a composite key
/// (`{sessionId}__{personaId}`) for both file-based session persistence and
/// message filtering, so each persona maintains an independent ACP agent
/// session and sees only its own conversation slice.
pub struct TauriStore {
sessions_dir: PathBuf,
session_store: Arc<SessionStore>,
/// The raw Goose session ID (without persona suffix).
session_id: String,
/// Optional persona scoping. When `Some`, the effective key used for
/// on-disk persistence becomes `{session_id}__{persona_id}`.
persona_id: Option<String>,
}
impl TauriStore {
/// Create a new store, ensuring the backing directory exists.
///
/// * `session_store` – shared session store for reading messages.
/// * `session_id` – the Goose chat session ID.
/// * `persona_id` – when `Some`, enables per-persona composite keying.
/// Pass `None` to preserve single-persona behaviour.
pub fn new(
session_store: Arc<SessionStore>,
session_id: String,
persona_id: Option<String>,
) -> Self {
let sessions_dir = dirs::home_dir()
.expect("home dir")
.join(".goose")
.join("acp_sessions");
let _ = std::fs::create_dir_all(&sessions_dir);
Self {
sessions_dir,
session_store,
session_id,
persona_id,
}
}
// -- composite-key helpers ------------------------------------------------
/// Build the effective key used for on-disk session files.
/// Returns `"{session_id}__{persona_id}"` when a persona is set,
/// otherwise plain `"{session_id}"`.
fn effective_key(&self) -> String {
make_composite_key(&self.session_id, self.persona_id.as_deref())
}
/// Look up a previously stored agent session ID, or `None` for new sessions.
pub fn get_agent_session_id(&self) -> Option<String> {
let key = self.effective_key();
let path = self.sessions_dir.join(format!("{key}.json"));
let json = std::fs::read_to_string(&path).ok()?;
let mapping: serde_json::Value = serde_json::from_str(&json).ok()?;
mapping["agent_session_id"].as_str().map(String::from)
}
/// Remove session files that are older than the given duration.
pub fn cleanup_stale_sessions(max_age: std::time::Duration) {
let sessions_dir = dirs::home_dir()
.expect("home dir")
.join(".goose")
.join("acp_sessions");
if let Ok(entries) = std::fs::read_dir(&sessions_dir) {
let cutoff = std::time::SystemTime::now() - max_age;
for entry in entries.flatten() {
if let Ok(metadata) = entry.metadata() {
if let Ok(modified) = metadata.modified() {
if modified < cutoff {
let _ = std::fs::remove_file(entry.path());
}
}
}
}
}
}
// -- persona-filtered message helpers -------------------------------------
/// Returns `true` if `msg` should be visible to the persona identified by
/// `persona_id`.
///
/// Rules:
/// * **User messages** are included when the message's
/// `metadata.target_persona_id` equals `persona_id`, **or** when the
/// message's `metadata.persona_id` equals `persona_id`.
/// * **Assistant/System messages** are included when
/// `metadata.persona_id` equals `persona_id`.
/// * Messages with **no metadata** or no matching persona fields are
/// excluded.
fn message_matches_persona(msg: &Message, persona_id: &str) -> bool {
let meta = match &msg.metadata {
Some(m) => m,
None => return false,
};
match msg.role {
MessageRole::User => {
let target_match = meta.target_persona_id.as_deref() == Some(persona_id);
let persona_match = meta.persona_id.as_deref() == Some(persona_id);
target_match || persona_match
}
MessageRole::Assistant | MessageRole::System => {
meta.persona_id.as_deref() == Some(persona_id)
}
}
}
/// Convert a [`Message`] into the `(role, text)` pair expected by the
/// ACP `Store` trait.
fn message_to_pair(msg: &Message) -> Option<(String, String)> {
let role = match msg.role {
MessageRole::User => "user",
MessageRole::Assistant => "assistant",
MessageRole::System => "system",
}
.to_string();
let text_parts: Vec<String> = msg
.content
.iter()
.filter_map(|c| match c {
MessageContent::Text { text } => Some(text.clone()),
_ => None,
})
.collect();
if text_parts.is_empty() {
None
} else {
Some((role, text_parts.join("\n")))
}
}
}
impl Store for TauriStore {
fn set_agent_session_id(
&self,
_session_id: &str,
agent_session_id: &str,
) -> Result<(), String> {
let key = self.effective_key();
let path = self.sessions_dir.join(format!("{key}.json"));
let payload = serde_json::json!({
"session_id": self.session_id,
"persona_id": self.persona_id,
"agent_session_id": agent_session_id,
});
let json = serde_json::to_string_pretty(&payload)
.map_err(|e| format!("Failed to serialize agent session mapping: {e}"))?;
std::fs::write(&path, json)
.map_err(|e| format!("Failed to write agent session file: {e}"))?;
Ok(())
}
fn get_session_messages(&self, _session_id: &str) -> Result<Vec<(String, String)>, String> {
let messages = self.session_store.get_messages(&self.session_id);
let pairs: Vec<(String, String)> = match &self.persona_id {
Some(pid) => {
// Persona mode: only return messages relevant to this persona.
messages
.iter()
.filter(|m| Self::message_matches_persona(m, pid))
.filter_map(Self::message_to_pair)
.collect()
}
None => {
// Single-persona / backward-compat mode: return all messages.
messages.iter().filter_map(Self::message_to_pair).collect()
}
};
Ok(pairs)
}
}
+2 -194
View File
@@ -1,35 +1,20 @@
use std::sync::Arc;
use async_trait::async_trait;
use tauri::Emitter;
use acp_client::{MessageWriter, SessionInfoUpdate, SessionModelState};
use crate::services::sessions::SessionStore;
use crate::types::messages::{
Message, MessageCompletionStatus, MessageContent, MessageMetadata, MessageRole, ToolCallStatus,
};
use super::payloads::{
DonePayload, MessageCreatedPayload, ModelStatePayload, SessionInfoPayload, TextPayload,
ToolCallPayload, ToolResultPayload, ToolTitlePayload,
};
/// A [`MessageWriter`] implementation that streams ACP output to the frontend
/// via Tauri events, and saves the final assistant message to the
/// [`SessionStore`] on finalization.
struct PersistedMessageState {
content: Vec<MessageContent>,
has_unflushed_changes: bool,
}
/// via Tauri events. No local persistence — the goose binary is the sole
/// source of truth for messages.
pub struct TauriMessageWriter {
app_handle: tauri::AppHandle,
session_id: String,
session_store: Arc<SessionStore>,
assistant_message_id: String,
/// Accumulated structured content (text + tool calls/results) for persistence.
state: std::sync::Mutex<PersistedMessageState>,
}
impl TauriMessageWriter {
@@ -37,32 +22,10 @@ impl TauriMessageWriter {
pub fn new(
app_handle: tauri::AppHandle,
session_id: String,
session_store: Arc<SessionStore>,
persona_id: Option<String>,
persona_name: Option<String>,
) -> Self {
let assistant_message_id = uuid::Uuid::new_v4().to_string();
let message = Message {
id: assistant_message_id.clone(),
role: MessageRole::Assistant,
created: chrono::Utc::now().timestamp_millis(),
content: Vec::new(),
metadata: Some(MessageMetadata {
user_visible: Some(true),
agent_visible: Some(true),
persona_id: persona_id.clone(),
persona_name: persona_name.clone(),
completion_status: Some(MessageCompletionStatus::InProgress),
..Default::default()
}),
};
if let Err(error) = session_store.add_message(&session_id, message) {
eprintln!(
"Failed to create streaming assistant message for session {}: {}",
session_id, error
);
}
let _ = app_handle.emit(
"acp:message_created",
@@ -77,95 +40,13 @@ impl TauriMessageWriter {
Self {
app_handle,
session_id,
session_store,
assistant_message_id,
state: std::sync::Mutex::new(PersistedMessageState {
content: Vec::new(),
has_unflushed_changes: false,
}),
}
}
pub fn assistant_message_id(&self) -> &str {
&self.assistant_message_id
}
fn persist_snapshot(&self, completion_status: Option<MessageCompletionStatus>) {
let (should_persist_content, content) = {
let mut state = self.state.lock().expect("state lock");
let should_persist = state.has_unflushed_changes || completion_status.is_some();
if should_persist {
state.has_unflushed_changes = false;
}
(should_persist, state.content.clone())
};
if !should_persist_content {
return;
}
if let Err(error) = self.session_store.update_message(
&self.session_id,
&self.assistant_message_id,
|message| {
message.content = content.clone();
if let Some(status) = completion_status.clone() {
let metadata = message
.metadata
.get_or_insert_with(MessageMetadata::default);
let current = metadata.completion_status.clone();
let should_replace = !matches!(
current,
Some(MessageCompletionStatus::Stopped)
| Some(MessageCompletionStatus::Error)
) || matches!(
status,
MessageCompletionStatus::Stopped | MessageCompletionStatus::Error
);
if should_replace {
metadata.completion_status = Some(status);
}
}
},
) {
eprintln!(
"Failed to update assistant message {} for session {}: {}",
self.assistant_message_id, self.session_id, error
);
}
}
}
fn append_text_block(content: &mut Vec<MessageContent>, text: &str) {
if text.is_empty() {
return;
}
match content.last_mut() {
Some(MessageContent::Text { text: existing }) => existing.push_str(text),
_ => content.push(MessageContent::Text {
text: text.to_string(),
}),
}
}
fn find_latest_unpaired_tool_request(
content: &[MessageContent],
) -> Option<(usize, String, String)> {
for index in (0..content.len()).rev() {
let MessageContent::ToolRequest { id, name, .. } = &content[index] else {
continue;
};
let already_has_response = content.iter().any(
|candidate| matches!(candidate, MessageContent::ToolResponse { id: response_id, .. } if response_id == id),
);
if !already_has_response {
return Some((index, id.clone(), name.clone()));
}
}
None
}
#[async_trait]
@@ -175,19 +56,6 @@ impl MessageWriter for TauriMessageWriter {
return;
}
{
let mut state = self.state.lock().expect("state lock");
let starts_new_text_block =
!matches!(state.content.last(), Some(MessageContent::Text { .. }));
if starts_new_text_block {
drop(state);
self.persist_snapshot(None);
state = self.state.lock().expect("state lock");
}
append_text_block(&mut state.content, text);
state.has_unflushed_changes = true;
}
let _ = self.app_handle.emit(
"acp:text",
TextPayload {
@@ -199,8 +67,6 @@ impl MessageWriter for TauriMessageWriter {
}
async fn finalize(&self) {
self.persist_snapshot(Some(MessageCompletionStatus::Completed));
let _ = self.app_handle.emit(
"acp:done",
DonePayload {
@@ -211,19 +77,6 @@ impl MessageWriter for TauriMessageWriter {
}
async fn record_tool_call(&self, tool_call_id: &str, title: &str) {
self.persist_snapshot(None);
{
let mut state = self.state.lock().expect("state lock");
state.content.push(MessageContent::ToolRequest {
id: tool_call_id.to_string(),
name: title.to_string(),
arguments: serde_json::json!({}),
status: ToolCallStatus::Executing,
});
state.has_unflushed_changes = true;
}
let _ = self.app_handle.emit(
"acp:tool_call",
ToolCallPayload {
@@ -236,20 +89,6 @@ impl MessageWriter for TauriMessageWriter {
}
async fn update_tool_call_title(&self, tool_call_id: &str, title: &str) {
{
let mut state = self.state.lock().expect("state lock");
for block in state.content.iter_mut().rev() {
if let MessageContent::ToolRequest { id, name, .. } = block {
if id == tool_call_id {
*name = title.to_string();
break;
}
}
}
state.has_unflushed_changes = true;
}
self.persist_snapshot(None);
let _ = self.app_handle.emit(
"acp:tool_title",
ToolTitlePayload {
@@ -262,37 +101,6 @@ impl MessageWriter for TauriMessageWriter {
}
async fn record_tool_result(&self, content: &str) {
self.persist_snapshot(None);
{
let mut state = self.state.lock().expect("state lock");
let paired_request = find_latest_unpaired_tool_request(&state.content);
if let Some((request_index, tool_call_id, tool_name)) = paired_request {
if let MessageContent::ToolRequest { status, .. } =
&mut state.content[request_index]
{
*status = ToolCallStatus::Completed;
}
state.content.push(MessageContent::ToolResponse {
id: tool_call_id,
name: tool_name,
result: content.to_string(),
is_error: false,
});
} else {
state.content.push(MessageContent::ToolResponse {
id: uuid::Uuid::new_v4().to_string(),
name: String::new(),
result: content.to_string(),
is_error: false,
});
}
state.has_unflushed_changes = true;
}
let _ = self.app_handle.emit(
"acp:tool_result",
ToolResultPayload {
-1
View File
@@ -1,3 +1,2 @@
pub mod acp;
pub mod personas;
pub mod sessions;
@@ -1,317 +0,0 @@
use crate::types::agents::{Session, SessionUpdate};
use crate::types::messages::Message;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
pub struct SessionStore {
/// Session metadata indexed by session ID. Messages are loaded lazily from disk.
sessions: Mutex<HashMap<String, Session>>,
sessions_dir: PathBuf,
}
impl SessionStore {
fn message_preview(message: &Message) -> Option<String> {
for content in &message.content {
if let crate::types::messages::MessageContent::Text { text } = content {
let cutoff = text.char_indices().nth(100).map(|(index, _)| index);
return Some(match cutoff {
Some(index) => format!("{}...", &text[..index]),
None => text.clone(),
});
}
}
None
}
fn refresh_session_message_metadata(session: &mut Session, messages: &[Message]) {
session.message_count = messages.len() as u32;
session.last_message_preview = messages.iter().rev().find_map(Self::message_preview);
session.updated_at = chrono::Utc::now().to_rfc3339();
}
pub fn new() -> Self {
let sessions_dir = dirs::home_dir()
.expect("home dir")
.join(".goose")
.join("sessions");
let _ = std::fs::create_dir_all(&sessions_dir);
let metadata_file = sessions_dir.join("metadata.json");
let sessions: HashMap<String, Session> = std::fs::read_to_string(&metadata_file)
.ok()
.and_then(|json| serde_json::from_str(&json).ok())
.unwrap_or_default();
Self {
sessions: Mutex::new(sessions),
sessions_dir,
}
}
/// Persist the full metadata map to a single file.
fn save_metadata(&self, sessions: &HashMap<String, Session>) {
let metadata_file = self.sessions_dir.join("metadata.json");
match serde_json::to_string_pretty(sessions) {
Ok(json) => {
if let Err(e) = std::fs::write(&metadata_file, json) {
eprintln!(
"Failed to write session metadata to {}: {}",
metadata_file.display(),
e
);
}
}
Err(e) => {
eprintln!("Failed to serialize session metadata: {}", e);
}
}
}
/// Persist messages for a single session to its directory.
fn save_messages(&self, session_id: &str, messages: &[Message]) {
let dir = self.sessions_dir.join(session_id);
if let Err(e) = std::fs::create_dir_all(&dir) {
eprintln!(
"Failed to create session directory {}: {}",
dir.display(),
e
);
return;
}
match serde_json::to_string_pretty(messages) {
Ok(json) => {
let path = dir.join("messages.json");
if let Err(e) = std::fs::write(&path, &json) {
eprintln!("Failed to write messages for session {}: {}", session_id, e);
}
}
Err(e) => {
eprintln!(
"Failed to serialize messages for session {}: {}",
session_id, e
);
}
}
}
/// Load messages for a session from disk.
fn load_messages(&self, session_id: &str) -> Vec<Message> {
let path = self.sessions_dir.join(session_id).join("messages.json");
std::fs::read_to_string(&path)
.ok()
.and_then(|json| serde_json::from_str(&json).ok())
.unwrap_or_default()
}
pub fn create_session(&self, agent_id: Option<String>, project_id: Option<String>) -> Session {
let now = chrono::Utc::now().to_rfc3339();
let id = uuid::Uuid::new_v4().to_string();
let session = Session {
id: id.clone(),
title: "New Chat".to_string(),
agent_id,
project_id,
provider_id: None,
persona_id: None,
model_name: None,
created_at: now.clone(),
updated_at: now,
message_count: 0,
last_message_preview: None,
archived_at: None,
};
// Write empty messages file
self.save_messages(&id, &[]);
let mut sessions = self.sessions.lock().unwrap();
sessions.insert(id, session.clone());
self.save_metadata(&sessions);
session
}
#[allow(dead_code)]
pub fn get_session(&self, id: &str) -> Option<Session> {
let sessions = self.sessions.lock().unwrap();
sessions.get(id).cloned()
}
/// Ensure a session with the given ID exists, creating it if necessary.
/// Returns the session (existing or newly created).
pub fn ensure_session(&self, id: &str, agent_id: Option<String>) -> Session {
use std::collections::hash_map::Entry;
let mut sessions = self.sessions.lock().unwrap();
match sessions.entry(id.to_string()) {
Entry::Occupied(entry) => entry.get().clone(),
Entry::Vacant(entry) => {
let now = chrono::Utc::now().to_rfc3339();
let session = Session {
id: id.to_string(),
title: "New Chat".to_string(),
agent_id,
project_id: None,
provider_id: None,
persona_id: None,
model_name: None,
created_at: now.clone(),
updated_at: now,
message_count: 0,
last_message_preview: None,
archived_at: None,
};
self.save_messages(id, &[]);
entry.insert(session.clone());
self.save_metadata(&sessions);
session
}
}
}
pub fn list_sessions(&self) -> Vec<Session> {
let sessions = self.sessions.lock().unwrap();
let mut list: Vec<Session> = sessions
.values()
.filter(|s| s.archived_at.is_none())
.cloned()
.collect();
// Sort by updated_at descending (most recent first)
list.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
list
}
pub fn list_archived_sessions(&self) -> Vec<Session> {
let sessions = self.sessions.lock().unwrap();
let mut list: Vec<Session> = sessions
.values()
.filter(|s| s.archived_at.is_some())
.cloned()
.collect();
// Sort by archived_at descending (most recently archived first)
list.sort_by(|a, b| b.archived_at.cmp(&a.archived_at));
list
}
pub fn archive_session(&self, id: &str) -> Result<(), String> {
let mut sessions = self.sessions.lock().unwrap();
let session = sessions
.get_mut(id)
.ok_or_else(|| format!("Session '{}' not found", id))?;
session.archived_at = Some(chrono::Utc::now().to_rfc3339());
self.save_metadata(&sessions);
Ok(())
}
pub fn unarchive_session(&self, id: &str) -> Result<(), String> {
let mut sessions = self.sessions.lock().unwrap();
let session = sessions
.get_mut(id)
.ok_or_else(|| format!("Session '{}' not found", id))?;
session.archived_at = None;
self.save_metadata(&sessions);
Ok(())
}
pub fn add_message(&self, session_id: &str, message: Message) -> Result<(), String> {
let mut sessions = self.sessions.lock().unwrap();
let session = sessions
.get_mut(session_id)
.ok_or_else(|| format!("Session '{}' not found", session_id))?;
let mut messages = self.load_messages(session_id);
messages.push(message);
Self::refresh_session_message_metadata(session, &messages);
self.save_metadata(&sessions);
self.save_messages(session_id, &messages);
Ok(())
}
pub fn update_message<F>(
&self,
session_id: &str,
message_id: &str,
updater: F,
) -> Result<(), String>
where
F: FnOnce(&mut Message),
{
let mut sessions = self.sessions.lock().unwrap();
let session = sessions
.get_mut(session_id)
.ok_or_else(|| format!("Session '{}' not found", session_id))?;
let mut messages = self.load_messages(session_id);
let message = messages
.iter_mut()
.find(|message| message.id == message_id)
.ok_or_else(|| {
format!(
"Message '{}' not found in session '{}'",
message_id, session_id
)
})?;
updater(message);
Self::refresh_session_message_metadata(session, &messages);
self.save_metadata(&sessions);
self.save_messages(session_id, &messages);
Ok(())
}
pub fn get_messages(&self, session_id: &str) -> Vec<Message> {
// Acquire the mutex to avoid reading a partially-written messages file
let _guard = self.sessions.lock().unwrap();
self.load_messages(session_id)
}
pub fn update_session(&self, id: &str, update: SessionUpdate) -> Result<(), String> {
let mut sessions = self.sessions.lock().unwrap();
let session = sessions
.get_mut(id)
.ok_or_else(|| format!("Session '{}' not found", id))?;
let mut should_bump_timestamp = false;
if let Some(title) = update.title {
session.title = title;
should_bump_timestamp = true;
}
if let Some(provider_id) = update.provider_id {
session.provider_id = Some(provider_id);
}
if let Some(persona_id) = update.persona_id {
session.persona_id = Some(persona_id);
}
if let Some(model_name) = update.model_name {
session.model_name = Some(model_name);
}
if let Some(project_id) = update.project_id {
session.project_id = project_id;
}
if should_bump_timestamp {
session.updated_at = chrono::Utc::now().to_rfc3339();
}
self.save_metadata(&sessions);
Ok(())
}
pub fn delete_session(&self, id: &str) -> Result<(), String> {
let mut sessions = self.sessions.lock().unwrap();
sessions
.remove(id)
.ok_or_else(|| format!("Session '{}' not found", id))?;
self.save_metadata(&sessions);
// Remove messages directory from disk
let dir = self.sessions_dir.join(id);
let _ = std::fs::remove_dir_all(dir);
Ok(())
}
}
@@ -121,7 +121,6 @@ pub struct MessageAttachment {
pub url: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenState {
+2
View File
@@ -1,3 +1,5 @@
#[allow(dead_code)]
pub mod agents;
pub mod builtin_personas;
#[allow(dead_code)]
pub mod messages;
+29 -20
View File
@@ -19,7 +19,6 @@ import { useAcpStream } from "@/features/chat/hooks/useAcpStream";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import { useProjectStore } from "@/features/projects/stores/projectStore";
import { findExistingDraft } from "@/features/chat/lib/newChat";
import { getSessionMessages } from "@/shared/api/chat";
import { useAppStartup } from "./hooks/useAppStartup";
export type AppView =
@@ -59,35 +58,45 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
useAcpStream(true);
const loadingSessionsRef = useRef<Set<string>>(new Set());
const pendingProjectCreatedRef = useRef<((projectId: string) => void) | null>(
null,
);
// TODO: wire to ACP load_session replay instead of fetching from SessionStore
const loadSessionMessages = useCallback(async (sessionId: string) => {
const session = useChatSessionStore
.getState()
.sessions.find((s) => s.id === sessionId);
if (session?.draft) return;
const { messagesBySession, setMessages } = useChatStore.getState();
const existing = messagesBySession[sessionId];
if (
(existing && existing.length > 0) ||
loadingSessionsRef.current.has(sessionId)
) {
// Skip if we already have messages for this session (e.g. it was just created)
const existing = useChatStore.getState().messagesBySession[sessionId];
if (existing && existing.length > 0) {
console.log(
`[perf:load] ${sessionId.slice(0, 8)} skip — already has messages`,
);
return;
}
loadingSessionsRef.current.add(sessionId);
const t0 = performance.now();
console.log(`[perf:load] ${sessionId.slice(0, 8)} start`);
const store = useChatStore.getState();
store.setSessionLoading(sessionId, true);
try {
const messages = await getSessionMessages(sessionId);
const current = useChatStore.getState().messagesBySession[sessionId];
if (!current || current.length === 0) {
setMessages(sessionId, messages);
}
const t1 = performance.now();
const { acpLoadSession } = await import("@/shared/api/acp");
const t2 = performance.now();
console.log(
`[perf:load] ${sessionId.slice(0, 8)} import took ${(t2 - t1).toFixed(1)}ms`,
);
// For sessions loaded from ACP, the session ID is the goose session ID
await acpLoadSession(sessionId, sessionId);
const t3 = performance.now();
console.log(
`[perf:load] ${sessionId.slice(0, 8)} acpLoadSession resolved in ${(t3 - t2).toFixed(1)}ms (total ${(t3 - t0).toFixed(1)}ms)`,
);
} catch (err) {
console.error(`Failed to load messages for session ${sessionId}:`, err);
console.error("Failed to load session messages:", err);
} finally {
loadingSessionsRef.current.delete(sessionId);
useChatStore.getState().setSessionLoading(sessionId, false);
console.log(
`[perf:load] ${sessionId.slice(0, 8)} done, loading=false at +${(performance.now() - t0).toFixed(1)}ms`,
);
}
}, []);
+5
View File
@@ -33,9 +33,14 @@ export function useAppStartup() {
};
const loadSessionState = async () => {
const t0 = performance.now();
console.log("[perf:startup] loadSessionState start");
const { loadSessions, setActiveSession } =
useChatSessionStore.getState();
await loadSessions();
console.log(
`[perf:startup] loadSessions done in ${(performance.now() - t0).toFixed(1)}ms`,
);
setActiveSession(null);
};
@@ -155,15 +155,23 @@ export function useAcpStream(enabled: boolean): void {
unlisteners.push(
listen<AcpMessageCreatedPayload>("acp:message_created", (event) => {
if (!active) return;
console.log(
`[perf:stream] ${event.payload.sessionId.slice(0, 8)} message_created mid=${event.payload.messageId.slice(0, 8)} at ${performance.now().toFixed(1)}ms`,
);
const store = useChatStore.getState();
if (
!shouldTrackStreamingEvent(
store,
event.payload.sessionId,
event.payload.messageId,
)
) {
return;
// Accept if: session is loading (replay), or we're actively streaming/thinking
const isLoading = store.loadingSessionIds.has(event.payload.sessionId);
if (!isLoading) {
if (
!shouldTrackStreamingEvent(
store,
event.payload.sessionId,
event.payload.messageId,
)
) {
return;
}
}
const existing = store.messagesBySession[event.payload.sessionId]?.find(
@@ -197,6 +205,9 @@ export function useAcpStream(enabled: boolean): void {
unlisteners.push(
listen<AcpTextPayload>("acp:text", (event) => {
if (!active) return;
console.log(
`[perf:stream] ${event.payload.sessionId.slice(0, 8)} text mid=${event.payload.messageId.slice(0, 8)} len=${event.payload.text.length} at ${performance.now().toFixed(1)}ms`,
);
const store = useChatStore.getState();
if (
!shouldTrackStreamingEvent(
@@ -219,13 +230,33 @@ export function useAcpStream(enabled: boolean): void {
unlisteners.push(
listen<AcpDonePayload>("acp:done", (event) => {
if (!active) return;
console.log(
`[perf:stream] ${event.payload.sessionId.slice(0, 8)} done mid=${event.payload.messageId.slice(0, 8)} at ${performance.now().toFixed(1)}ms`,
);
const store = useChatStore.getState();
const isLoading = store.loadingSessionIds.has(event.payload.sessionId);
store.updateMessage(
event.payload.sessionId,
event.payload.messageId,
(message) => updateCompletionStatus(message, "completed"),
(message) => {
// Mark any tool requests still in "executing" as completed.
// During replay, tool_result events may not fire for every
// tool call (e.g. when the result content type has no preview),
// so we finalize them here to stop running timers.
const content = message.content.map((block) =>
block.type === "toolRequest" && block.status === "executing"
? { ...block, status: "completed" as const }
: block,
);
return updateCompletionStatus({ ...message, content }, "completed");
},
);
store.setStreamingMessageId(event.payload.sessionId, null);
// During replay, don't reset chat state or trigger side effects
if (isLoading) return;
store.setChatState(event.payload.sessionId, "idle");
if (
useChatSessionStore.getState().activeSessionId !==
@@ -357,6 +388,30 @@ export function useAcpStream(enabled: boolean): void {
);
// acp:session_info — update session title from ACP provider
// Replay: user messages from load_session history
unlisteners.push(
listen<{ sessionId: string; messageId: string; text: string }>(
"acp:replay_user_message",
(event) => {
if (!active) return;
console.log(
`[perf:stream] ${event.payload.sessionId.slice(0, 8)} replay_user_message at ${performance.now().toFixed(1)}ms`,
);
const store = useChatStore.getState();
store.addMessage(event.payload.sessionId, {
id: event.payload.messageId,
role: "user",
created: Date.now(),
content: [{ type: "text", text: event.payload.text }],
metadata: {
userVisible: true,
agentVisible: true,
},
});
},
),
);
unlisteners.push(
listen<AcpSessionInfoPayload>("acp:session_info", (event) => {
if (!active) return;
@@ -0,0 +1,266 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AcpSessionInfo } from "@/shared/api/acp";
import { useChatSessionStore } from "../chatSessionStore";
vi.mock("@/shared/api/acp", () => ({
acpListSessions: vi.fn(),
}));
import { acpListSessions } from "@/shared/api/acp";
const mockedAcpListSessions = vi.mocked(acpListSessions);
const SESSION_CACHE_KEY = "goose:chat-sessions";
function resetStore() {
useChatSessionStore.setState({
sessions: [],
activeSessionId: null,
isLoading: false,
contextPanelOpenBySession: {},
});
}
describe("chatSessionStore", () => {
beforeEach(() => {
resetStore();
window.localStorage.removeItem(SESSION_CACHE_KEY);
vi.clearAllMocks();
});
afterEach(() => {
window.localStorage.removeItem(SESSION_CACHE_KEY);
});
describe("createDraftSession", () => {
it("creates a draft session with default title", () => {
const session = useChatSessionStore.getState().createDraftSession();
expect(session.title).toBe("New Chat");
expect(session.draft).toBe(true);
expect(session.messageCount).toBe(0);
expect(useChatSessionStore.getState().sessions).toContainEqual(session);
});
it("creates a draft session with custom options", () => {
const session = useChatSessionStore.getState().createDraftSession({
title: "My Custom Chat",
projectId: "proj-1",
providerId: "openai",
personaId: "persona-1",
});
expect(session.title).toBe("My Custom Chat");
expect(session.projectId).toBe("proj-1");
expect(session.providerId).toBe("openai");
expect(session.personaId).toBe("persona-1");
expect(session.draft).toBe(true);
});
});
describe("promoteDraft", () => {
it("removes draft flag from a draft session", () => {
const session = useChatSessionStore.getState().createDraftSession();
expect(session.draft).toBe(true);
useChatSessionStore.getState().promoteDraft(session.id);
const updated = useChatSessionStore
.getState()
.sessions.find((s) => s.id === session.id);
expect(updated?.draft).toBeUndefined();
});
it("does nothing for non-draft sessions", () => {
useChatSessionStore.setState({
sessions: [
{
id: "non-draft",
title: "Regular Session",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
messageCount: 5,
},
],
});
useChatSessionStore.getState().promoteDraft("non-draft");
const session = useChatSessionStore
.getState()
.sessions.find((s) => s.id === "non-draft");
expect(session?.draft).toBeUndefined();
});
});
describe("removeDraft", () => {
it("removes a draft session", () => {
const session = useChatSessionStore.getState().createDraftSession();
expect(useChatSessionStore.getState().sessions).toHaveLength(1);
useChatSessionStore.getState().removeDraft(session.id);
expect(useChatSessionStore.getState().sessions).toHaveLength(0);
});
it("clears activeSessionId if removing the active draft", () => {
const session = useChatSessionStore.getState().createDraftSession();
useChatSessionStore.getState().setActiveSession(session.id);
useChatSessionStore.getState().removeDraft(session.id);
expect(useChatSessionStore.getState().activeSessionId).toBeNull();
});
it("does not remove non-draft sessions", () => {
useChatSessionStore.setState({
sessions: [
{
id: "non-draft",
title: "Regular Session",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
messageCount: 5,
},
],
});
useChatSessionStore.getState().removeDraft("non-draft");
expect(useChatSessionStore.getState().sessions).toHaveLength(1);
});
});
describe("loadSessions", () => {
it("loads sessions from ACP and maps them correctly", async () => {
mockedAcpListSessions.mockResolvedValue([
{ sessionId: "acp-1", title: "ACP Session 1", updatedAt: "2026-04-01" },
{ sessionId: "acp-2", title: null, updatedAt: "2026-04-02" },
]);
await useChatSessionStore.getState().loadSessions();
const sessions = useChatSessionStore.getState().sessions;
expect(sessions).toHaveLength(2);
expect(sessions[0].id).toBe("acp-2"); // Most recent first
expect(sessions[0].title).toBe("Untitled"); // null title becomes "Untitled"
expect(sessions[1].id).toBe("acp-1");
expect(sessions[1].title).toBe("ACP Session 1");
});
it("preserves local drafts alongside ACP sessions", async () => {
const draft = useChatSessionStore.getState().createDraftSession({
title: "My Draft",
});
mockedAcpListSessions.mockResolvedValue([
{ sessionId: "acp-1", title: "ACP Session", updatedAt: "2026-04-01" },
]);
await useChatSessionStore.getState().loadSessions();
const sessions = useChatSessionStore.getState().sessions;
expect(sessions).toHaveLength(2);
expect(sessions.find((s) => s.id === "acp-1")).toBeDefined();
expect(sessions.find((s) => s.id === draft.id)).toBeDefined();
});
it("sets isLoading during fetch", async () => {
let resolvePromise: (value: AcpSessionInfo[]) => void = () => {};
mockedAcpListSessions.mockReturnValue(
new Promise((resolve) => {
resolvePromise = resolve;
}),
);
const loadPromise = useChatSessionStore.getState().loadSessions();
expect(useChatSessionStore.getState().isLoading).toBe(true);
resolvePromise([]);
await loadPromise;
expect(useChatSessionStore.getState().isLoading).toBe(false);
});
it("falls back to cached drafts on error", async () => {
window.localStorage.setItem(
SESSION_CACHE_KEY,
JSON.stringify([
{
id: "cached-draft",
title: "Cached Draft",
draft: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
messageCount: 0,
},
]),
);
mockedAcpListSessions.mockRejectedValue(new Error("Network error"));
await useChatSessionStore.getState().loadSessions();
const sessions = useChatSessionStore.getState().sessions;
expect(sessions).toHaveLength(1);
expect(sessions[0].id).toBe("cached-draft");
expect(sessions[0].draft).toBe(true);
});
});
describe("updateSession", () => {
it("updates session properties", () => {
const session = useChatSessionStore.getState().createDraftSession();
useChatSessionStore.getState().updateSession(session.id, {
title: "Updated Title",
projectId: "new-project",
});
const updated = useChatSessionStore
.getState()
.sessions.find((s) => s.id === session.id);
expect(updated?.title).toBe("Updated Title");
expect(updated?.projectId).toBe("new-project");
});
it("updates the updatedAt timestamp", () => {
const session = useChatSessionStore.getState().createDraftSession();
const originalUpdatedAt = session.updatedAt;
// Small delay to ensure timestamp changes
vi.useFakeTimers();
vi.advanceTimersByTime(1000);
useChatSessionStore.getState().updateSession(session.id, {
title: "New Title",
});
vi.useRealTimers();
const updated = useChatSessionStore
.getState()
.sessions.find((s) => s.id === session.id);
expect(updated?.updatedAt).not.toBe(originalUpdatedAt);
});
});
describe("archiveSession", () => {
it("removes the session from the list", async () => {
const session = useChatSessionStore.getState().createDraftSession();
await useChatSessionStore.getState().archiveSession(session.id);
expect(useChatSessionStore.getState().sessions).toHaveLength(0);
});
it("clears activeSessionId if archiving the active session", async () => {
const session = useChatSessionStore.getState().createDraftSession();
useChatSessionStore.getState().setActiveSession(session.id);
await useChatSessionStore.getState().archiveSession(session.id);
expect(useChatSessionStore.getState().activeSessionId).toBeNull();
});
});
});
@@ -220,3 +220,60 @@ describe("chatStore draft localStorage persistence", () => {
expect(stored).toEqual({ s2: "world" });
});
});
describe("chatStore session loading state", () => {
beforeEach(() => {
useChatStore.setState({
messagesBySession: {},
sessionStateById: {},
queuedMessageBySession: {},
draftsBySession: {},
activeSessionId: null,
isConnected: false,
loadingSessionIds: new Set<string>(),
});
});
it("starts with empty loadingSessionIds", () => {
expect(useChatStore.getState().loadingSessionIds.size).toBe(0);
});
it("adds session to loadingSessionIds when setSessionLoading(true)", () => {
useChatStore.getState().setSessionLoading("s1", true);
expect(useChatStore.getState().loadingSessionIds.has("s1")).toBe(true);
});
it("removes session from loadingSessionIds when setSessionLoading(false)", () => {
useChatStore.getState().setSessionLoading("s1", true);
useChatStore.getState().setSessionLoading("s1", false);
expect(useChatStore.getState().loadingSessionIds.has("s1")).toBe(false);
});
it("tracks multiple sessions independently", () => {
useChatStore.getState().setSessionLoading("s1", true);
useChatStore.getState().setSessionLoading("s2", true);
expect(useChatStore.getState().loadingSessionIds.has("s1")).toBe(true);
expect(useChatStore.getState().loadingSessionIds.has("s2")).toBe(true);
useChatStore.getState().setSessionLoading("s1", false);
expect(useChatStore.getState().loadingSessionIds.has("s1")).toBe(false);
expect(useChatStore.getState().loadingSessionIds.has("s2")).toBe(true);
});
it("is idempotent for adding the same session", () => {
useChatStore.getState().setSessionLoading("s1", true);
useChatStore.getState().setSessionLoading("s1", true);
expect(useChatStore.getState().loadingSessionIds.size).toBe(1);
});
it("is idempotent for removing a non-existent session", () => {
useChatStore.getState().setSessionLoading("s1", false);
expect(useChatStore.getState().loadingSessionIds.size).toBe(0);
});
});
@@ -1,14 +1,6 @@
import { create } from "zustand";
import {
createSession as apiCreateSession,
listSessions as apiListSessions,
archiveSession as apiArchiveSession,
unarchiveSession as apiUnarchiveSession,
updateSession as apiUpdateSession,
} from "@/shared/api/chat";
import type { Session } from "@/shared/types/chat";
const SESSION_CACHE_STORAGE_KEY = "goose:chat-sessions";
import { acpListSessions } from "@/shared/api/acp";
// Extended session metadata used by the frontend session list
export interface ChatSession {
@@ -68,6 +60,8 @@ interface ChatSessionStoreActions {
export type ChatSessionStore = ChatSessionStoreState & ChatSessionStoreActions;
const SESSION_CACHE_STORAGE_KEY = "goose:chat-sessions";
function loadCachedSessions(): ChatSession[] {
if (typeof window === "undefined") return [];
try {
@@ -110,7 +104,8 @@ function persistSessions(sessions: ChatSession[]): void {
}
}
function sessionToChatSession(session: Session): ChatSession {
/** Map a backend Session to the frontend ChatSession shape. */
export function sessionToChatSession(session: Session): ChatSession {
return {
id: session.id,
title: session.title,
@@ -129,43 +124,19 @@ function sessionToChatSession(session: Session): ChatSession {
export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
// State
sessions: loadCachedSessions(),
sessions: [],
activeSessionId: null,
isLoading: false,
contextPanelOpenBySession: {},
// Session lifecycle
createSession: async (opts) => {
const backendSession = await apiCreateSession(
opts?.agentId,
opts?.projectId,
// Session lifecycle — local-only for now.
// The goose binary creates the real ACP session on first prompt
// (via prepare_session / send_prompt). These just manage the
// frontend's in-memory session list.
createSession: async (_opts) => {
throw new Error(
"createSession not yet wired to ACP — use createDraftSession",
);
const now = new Date().toISOString();
const chatSession: ChatSession = {
id: backendSession.id,
title: opts?.title ?? backendSession.title,
projectId: opts?.projectId,
agentId: opts?.agentId ?? backendSession.agentId,
providerId: opts?.providerId,
personaId: opts?.personaId,
createdAt: backendSession.createdAt ?? now,
updatedAt: backendSession.updatedAt ?? now,
messageCount: backendSession.messageCount ?? 0,
};
const initialUpdate: Record<string, string> = {};
if (opts?.title) initialUpdate.title = opts.title;
if (opts?.providerId) initialUpdate.providerId = opts.providerId;
if (opts?.personaId) initialUpdate.personaId = opts.personaId;
if (Object.keys(initialUpdate).length > 0) {
apiUpdateSession(backendSession.id, initialUpdate).catch((err) => {
console.error("Failed to persist initial session metadata:", err);
});
}
set((state) => ({
sessions: [...state.sessions, chatSession],
}));
persistSessions([...get().sessions]);
return chatSession;
},
createDraftSession: (opts) => {
@@ -214,11 +185,28 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
loadSessions: async () => {
set({ isLoading: true });
try {
const backendSessions = await apiListSessions();
const chatSessions = backendSessions.map(sessionToChatSession);
const acpSessions = await acpListSessions();
const sessions: ChatSession[] = acpSessions.map((s) => ({
id: s.sessionId,
title: s.title ?? "Untitled",
createdAt: s.updatedAt ?? new Date().toISOString(),
updatedAt: s.updatedAt ?? new Date().toISOString(),
messageCount: 0,
}));
// Sort by updatedAt descending (most recent first)
sessions.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
);
// Preserve local drafts
const drafts = get().sessions.filter((s) => s.draft);
set({ sessions: [...chatSessions, ...drafts] });
persistSessions(chatSessions);
set({ sessions: [...sessions, ...drafts] });
} catch (err) {
console.error("Failed to load sessions from ACP:", err);
// On error, at least load cached drafts
const cached = loadCachedSessions();
const drafts = cached.filter((s) => s.draft);
set({ sessions: drafts });
} finally {
set({ isLoading: false });
}
@@ -227,78 +215,28 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
updateSession: (id, patch, opts) => {
set((state) => ({
sessions: state.sessions.map((s) =>
s.id === id ? { ...s, ...patch } : s,
s.id === id
? { ...s, ...patch, updatedAt: new Date().toISOString() }
: s,
),
}));
persistSessions(get().sessions);
if (opts?.localOnly) return;
const session = get().sessions.find((s) => s.id === id);
if (session?.draft) return; // skip backend for drafts
const backendPatch: {
title?: string;
providerId?: string;
personaId?: string;
modelName?: string;
projectId?: string | null;
userSetName?: boolean;
} = {};
if (patch.title) backendPatch.title = patch.title;
if (patch.providerId) backendPatch.providerId = patch.providerId;
if (patch.personaId) backendPatch.personaId = patch.personaId;
if (patch.modelName) backendPatch.modelName = patch.modelName;
if ("projectId" in patch) {
backendPatch.projectId = patch.projectId ?? null;
}
if ("userSetName" in patch) {
backendPatch.userSetName = patch.userSetName;
}
if (Object.keys(backendPatch).length > 0) {
apiUpdateSession(id, backendPatch).catch((err) => {
console.error("Failed to persist session update:", err);
});
}
if (session?.draft) return;
// TODO: wire non-draft updates to ACP when supported
},
archiveSession: async (id) => {
const previousActiveSessionId = get().activeSessionId;
set((state) => {
return {
activeSessionId:
state.activeSessionId === id ? null : state.activeSessionId,
};
});
// Archive on backend — update local state on success
try {
await apiArchiveSession(id);
const archivedAt = new Date().toISOString();
const nextSessions = get()
.sessions.map((s) => (s.id === id ? { ...s, archivedAt } : s))
.filter((s) => !s.archivedAt);
const { [id]: _, ...remainingPanelState } =
get().contextPanelOpenBySession;
set({
sessions: nextSessions,
contextPanelOpenBySession: remainingPanelState,
});
persistSessions(nextSessions);
} catch (err) {
set({
activeSessionId: previousActiveSessionId,
});
console.error("Failed to archive session:", err);
throw err;
}
set((state) => ({
sessions: state.sessions.filter((s) => s.id !== id),
activeSessionId:
state.activeSessionId === id ? null : state.activeSessionId,
}));
},
unarchiveSession: async (id) => {
try {
await apiUnarchiveSession(id);
await get().loadSessions();
} catch (err) {
console.error("Failed to unarchive session:", err);
throw err;
}
unarchiveSession: async (_id) => {
// No-op — no archive persistence yet
},
setActiveSession: (sessionId) => {
@@ -71,6 +71,9 @@ interface ChatStoreState {
// Connection
isConnected: boolean;
// Sessions currently being loaded from history (replay in progress)
loadingSessionIds: Set<string>;
}
interface ChatStoreActions {
@@ -119,6 +122,9 @@ interface ChatStoreActions {
setDraft: (sessionId: string, text: string) => void;
clearDraft: (sessionId: string) => void;
// Session loading (replay)
setSessionLoading: (sessionId: string, loading: boolean) => void;
// Cleanup
cleanupSession: (sessionId: string) => void;
}
@@ -133,6 +139,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
draftsBySession: loadCachedDrafts(),
activeSessionId: null,
isConnected: false,
loadingSessionIds: new Set<string>(),
// Session management
setActiveSession: (sessionId) =>
@@ -421,6 +428,18 @@ export const useChatStore = create<ChatStore>((set, get) => ({
persistDrafts(get().draftsBySession);
},
// Session loading (replay)
setSessionLoading: (sessionId, loading) =>
set((state) => {
const next = new Set(state.loadingSessionIds);
if (loading) {
next.add(sessionId);
} else {
next.delete(sessionId);
}
return { loadingSessionIds: next };
}),
// Cleanup
cleanupSession: (sessionId) => {
set((state) => {
@@ -0,0 +1,75 @@
import { Skeleton } from "@/shared/ui/skeleton";
/**
* Placeholder skeleton shown while session history is being replayed
* from the backend. Mimics the visual rhythm of a real conversation
* with alternating user/assistant message shapes.
*/
export function ChatLoadingSkeleton() {
return (
<div
className="flex flex-1 items-start overflow-hidden"
role="status"
aria-label="Loading conversation"
>
<div className="mx-auto w-full max-w-3xl py-4 space-y-6 px-4">
{/* Date separator skeleton */}
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-border" />
<Skeleton className="h-3 w-16 rounded-full" />
<div className="h-px flex-1 bg-border" />
</div>
{/* User message */}
<div className="flex justify-end">
<div className="space-y-2 max-w-[70%]">
<Skeleton className="h-4 w-64 rounded-lg" />
</div>
</div>
{/* Assistant message — multi-line */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="size-6 rounded-full" />
<Skeleton className="h-3 w-14 rounded-full" />
</div>
<div className="pl-8 space-y-2">
<Skeleton className="h-4 w-full rounded-lg" />
<Skeleton className="h-4 w-[85%] rounded-lg" />
<Skeleton className="h-4 w-[60%] rounded-lg" />
</div>
</div>
{/* User message */}
<div className="flex justify-end">
<div className="space-y-2 max-w-[70%]">
<Skeleton className="h-4 w-48 rounded-lg" />
</div>
</div>
{/* Assistant message — shorter */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="size-6 rounded-full" />
<Skeleton className="h-3 w-14 rounded-full" />
</div>
<div className="pl-8 space-y-2">
<Skeleton className="h-4 w-full rounded-lg" />
<Skeleton className="h-4 w-[70%] rounded-lg" />
</div>
</div>
{/* Tool call block */}
<div className="pl-8">
<Skeleton className="h-10 w-[50%] rounded-lg" />
</div>
{/* Assistant continuation */}
<div className="pl-8 space-y-2">
<Skeleton className="h-4 w-full rounded-lg" />
<Skeleton className="h-4 w-[45%] rounded-lg" />
</div>
</div>
</div>
);
}
+19 -16
View File
@@ -8,10 +8,10 @@ import { MessageTimeline } from "./MessageTimeline";
import { ChatInput } from "./ChatInput";
import type { PastedImage } from "@/shared/types/messages";
import { LoadingGoose } from "./LoadingGoose";
import { ChatLoadingSkeleton } from "./ChatLoadingSkeleton";
import { ContextPanel } from "./ContextPanel";
import { useChat } from "../hooks/useChat";
import { useMessageQueue } from "../hooks/useMessageQueue";
import { useSessionAutoTitle } from "@/features/sessions/hooks/useSessionAutoTitle";
import { useChatStore } from "../stores/chatStore";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import { useProviderSelection } from "@/features/agents/hooks/useProviderSelection";
@@ -30,7 +30,7 @@ import { Button } from "@/shared/ui/button";
import { ArtifactPolicyProvider } from "../hooks/ArtifactPolicyContext";
interface ChatViewProps {
sessionId?: string;
sessionId: string;
agentName?: string;
agentAvatarUrl?: string;
initialProvider?: string;
@@ -61,7 +61,7 @@ export function ChatView({
onInitialMessageConsumed,
onCreateProject,
}: ChatViewProps) {
const [activeSessionId] = useState(() => sessionId ?? crypto.randomUUID());
const activeSessionId = sessionId;
const isContextPanelOpen = useChatSessionStore(
(s) => s.contextPanelOpenBySession[activeSessionId] ?? false,
);
@@ -76,7 +76,6 @@ export function ChatView({
selectedProvider: globalSelectedProvider,
setSelectedProvider: setGlobalSelectedProvider,
} = useProviderSelection();
const personas = useAgentStore((s) => s.personas);
const [selectedPersonaId, setSelectedPersonaId] = useState<string | null>(
initialPersonaId ?? null,
@@ -143,7 +142,6 @@ export function ChatView({
useEffect(() => {
let cancelled = false;
if (!session?.projectId || storedProject) {
setFallbackProject(null);
return;
@@ -187,7 +185,6 @@ export function ChatView({
if (providerId === selectedProvider) {
return;
}
setGlobalSelectedProvider(providerId);
useChatSessionStore
.getState()
@@ -259,7 +256,6 @@ export function ChatView({
useEffect(() => {
let cancelled = false;
acpPrepareSession(activeSessionId, selectedProvider, {
workingDir: effectiveWorkingDir,
personaId: selectedPersonaId ?? undefined,
@@ -289,8 +285,11 @@ export function ChatView({
personaInfo,
effectiveWorkingDir,
);
useSessionAutoTitle(activeSessionId);
const isLoadingHistory = useChatStore(
(s) =>
s.loadingSessionIds.has(activeSessionId) &&
(s.messagesBySession[activeSessionId]?.length ?? 0) === 0,
);
const deferredSend = useRef<{ text: string; images?: PastedImage[] } | null>(
null,
@@ -385,14 +384,18 @@ export function ChatView({
>
<div className="relative flex h-full min-w-0">
<div className="flex min-w-0 flex-1 flex-col pr-1">
<MessageTimeline
messages={messages}
streamingMessageId={streamingMessageId}
agentName={displayAgentName}
agentAvatarUrl={personaAvatarSrc ?? agentAvatarUrl}
/>
{isLoadingHistory ? (
<ChatLoadingSkeleton />
) : (
<MessageTimeline
messages={messages}
streamingMessageId={streamingMessageId}
agentName={displayAgentName}
agentAvatarUrl={personaAvatarSrc ?? agentAvatarUrl}
/>
)}
{showIndicator && (
{showIndicator && !isLoadingHistory && (
<LoadingGoose
agentName={displayAgentName}
chatState={
@@ -0,0 +1,26 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ChatLoadingSkeleton } from "../ChatLoadingSkeleton";
describe("ChatLoadingSkeleton", () => {
it("renders with loading status role", () => {
render(<ChatLoadingSkeleton />);
expect(screen.getByRole("status")).toBeInTheDocument();
});
it("has accessible label for screen readers", () => {
render(<ChatLoadingSkeleton />);
expect(
screen.getByRole("status", { name: /loading conversation/i }),
).toBeInTheDocument();
});
it("renders skeleton elements", () => {
const { container } = render(<ChatLoadingSkeleton />);
const skeletons = container.querySelectorAll('[class*="animate-pulse"]');
expect(skeletons.length).toBeGreaterThan(0);
});
});
@@ -1,136 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook } from "@testing-library/react";
import { useChatStore } from "@/features/chat/stores/chatStore";
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
// Mock the API module — include updateSession since chatSessionStore imports it
vi.mock("@/shared/api/chat", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/shared/api/chat")>();
return {
...actual,
generateSessionTitle: vi.fn(),
updateSession: vi.fn().mockResolvedValue(undefined),
};
});
import { generateSessionTitle } from "@/shared/api/chat";
import { useSessionAutoTitle } from "../useSessionAutoTitle";
const mockGenerateTitle = vi.mocked(generateSessionTitle);
function seedSession(
id: string,
overrides: Partial<{
title: string;
userSetName: boolean;
draft: boolean;
messageCount: number;
}> = {},
) {
const now = new Date().toISOString();
useChatSessionStore.setState({
sessions: [
{
id,
title: overrides.title ?? "New Chat",
createdAt: now,
updatedAt: now,
messageCount: overrides.messageCount ?? 0,
draft: overrides.draft,
userSetName: overrides.userSetName,
},
],
});
}
describe("useSessionAutoTitle", () => {
beforeEach(() => {
vi.clearAllMocks();
useChatStore.setState({
messagesBySession: {},
sessionStateById: {},
draftsBySession: {},
activeSessionId: null,
isConnected: false,
});
useChatSessionStore.setState({
sessions: [],
activeSessionId: null,
isLoading: false,
contextPanelOpenBySession: {},
});
});
it("does not trigger when session has userSetName", () => {
seedSession("s1", { userSetName: true, messageCount: 2 });
useChatStore.getState().setChatState("s1", "idle");
renderHook(() => useSessionAutoTitle("s1"));
expect(mockGenerateTitle).not.toHaveBeenCalled();
});
it("does not trigger when messageCount is not 2", () => {
seedSession("s1", { messageCount: 0 });
useChatStore.getState().setChatState("s1", "idle");
renderHook(() => useSessionAutoTitle("s1"));
expect(mockGenerateTitle).not.toHaveBeenCalled();
});
it("generates title when first exchange completes", async () => {
seedSession("s1", { messageCount: 2 });
mockGenerateTitle.mockResolvedValue("Fix sidebar resize bug");
// Start in streaming state
useChatStore.getState().setChatState("s1", "streaming");
const { rerender } = renderHook(() => useSessionAutoTitle("s1"));
// Transition to idle (stream completed)
useChatStore.getState().setChatState("s1", "idle");
rerender();
// Wait for the async call
await vi.waitFor(() => {
expect(mockGenerateTitle).toHaveBeenCalledWith("s1");
});
});
it("uses fallback title on API failure", async () => {
seedSession("s1", { messageCount: 2 });
mockGenerateTitle.mockRejectedValue(new Error("LLM unavailable"));
// Add a user message so fallback can extract text
useChatStore.getState().addMessage("s1", {
id: "msg-1",
role: "user",
created: Date.now(),
content: [
{
type: "text",
text: "Help me fix the authentication middleware in the login flow",
},
],
metadata: { userVisible: true },
});
useChatStore.getState().setChatState("s1", "streaming");
const { rerender } = renderHook(() => useSessionAutoTitle("s1"));
useChatStore.getState().setChatState("s1", "idle");
rerender();
// Wait for the rejection to be handled and fallback title to be set
await vi.waitFor(() => {
expect(mockGenerateTitle).toHaveBeenCalledWith("s1");
// Should have updated session with fallback (first 50 chars of user message)
const session = useChatSessionStore.getState().getSession("s1");
expect(session?.title).toBe(
"Help me fix the authentication middleware in the l",
);
});
});
});
@@ -1,59 +0,0 @@
import { useEffect, useRef } from "react";
import { useChatStore } from "@/features/chat/stores/chatStore";
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
import { generateSessionTitle } from "@/shared/api/chat";
/**
* Automatically generates a session title after the first exchange completes.
* Watches for the transition from streaming → idle when messageCount === 2.
* Skips sessions where the user has manually set a title.
*/
export function useSessionAutoTitle(sessionId: string) {
const firedRef = useRef(false);
const prevChatStateRef = useRef<string | null>(null);
const chatState = useChatStore(
(s) => s.getSessionRuntime(sessionId).chatState,
);
const session = useChatSessionStore((s) =>
s.sessions.find((candidate) => candidate.id === sessionId),
);
useEffect(() => {
if (firedRef.current) return;
if (!session || session.userSetName) return;
if (session.messageCount !== 2) {
prevChatStateRef.current = chatState;
return;
}
const wasStreaming = prevChatStateRef.current === "streaming";
prevChatStateRef.current = chatState;
if (chatState !== "idle" || !wasStreaming) return;
firedRef.current = true;
generateSessionTitle(sessionId)
.then((title) => {
useChatSessionStore
.getState()
.updateSession(sessionId, { title, userSetName: false });
})
.catch(() => {
// Fallback: use first 50 chars of first user message
const messages =
useChatStore.getState().messagesBySession[sessionId] ?? [];
const firstUserMessage = messages.find((m) => m.role === "user");
const textBlock = firstUserMessage?.content.find(
(c) => c.type === "text",
);
if (textBlock && "text" in textBlock) {
const fallback = textBlock.text.trim().slice(0, 50);
useChatSessionStore
.getState()
.updateSession(sessionId, { title: fallback, userSetName: false });
}
});
}, [sessionId, chatState, session]);
}
@@ -8,7 +8,6 @@ import { filterSessions } from "../lib/filterSessions";
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import { useProjectStore } from "@/features/projects/stores/projectStore";
import { listArchivedSessions } from "@/shared/api/chat";
import type { ChatSession } from "@/features/chat/stores/chatSessionStore";
interface SessionHistoryViewProps {
@@ -32,33 +31,9 @@ export function SessionHistoryView({
const [search, setSearch] = useState("");
const loadArchived = useCallback(() => {
let cancelled = false;
listArchivedSessions()
.then((result) => {
if (cancelled) return;
setArchivedSessions(
result.map((s) => ({
id: s.id,
title: s.title,
projectId: s.projectId,
agentId: s.agentId,
providerId: s.providerId,
personaId: s.personaId,
modelName: s.modelName,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
archivedAt: s.archivedAt,
messageCount: s.messageCount,
userSetName: s.userSetName,
})),
);
})
.catch(() => {
if (!cancelled) setArchivedSessions([]);
});
return () => {
cancelled = true;
};
// TODO: Wire to ACP when archived sessions are supported
// For now, archived sessions aren't persisted to the backend
setArchivedSessions([]);
}, []);
useEffect(loadArchived, [loadArchived]);
@@ -30,7 +30,7 @@ import {
} from "@/features/projects/api/projects";
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
import { useProjectStore } from "@/features/projects/stores/projectStore";
import { listArchivedSessions } from "@/shared/api/chat";
import type { Session } from "@/shared/types/chat";
const NAV_ITEMS = [
@@ -74,12 +74,10 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
.finally(() => setLoadingArchived(false));
}, []);
// Load archived chats on mount
// TODO: wire to ACP for archived session listing
useEffect(() => {
listArchivedSessions()
.then(setArchivedChats)
.catch(() => setArchivedChats([]))
.finally(() => setLoadingArchivedChats(false));
setArchivedChats([]);
setLoadingArchivedChats(false);
}, []);
const handleRestoreProject = async (id: string) => {
+30
View File
@@ -59,6 +59,36 @@ export async function acpPrepareSession(
});
}
/** Session info returned by the goose binary's list_sessions. */
export interface AcpSessionInfo {
sessionId: string;
title: string | null;
updatedAt: string | null;
}
/** List all sessions known to the goose binary. */
export async function acpListSessions(): Promise<AcpSessionInfo[]> {
return invoke("acp_list_sessions");
}
/**
* Load an existing session from the goose binary.
*
* This triggers message replay via SessionNotification events that the
* frontend's useAcpStream hook picks up automatically.
*/
export async function acpLoadSession(
sessionId: string,
gooseSessionId: string,
workingDir?: string,
): Promise<void> {
return invoke("acp_load_session", {
sessionId,
gooseSessionId,
workingDir: workingDir ?? null,
});
}
/** Cancel an in-progress ACP session so the backend stops streaming. */
export async function acpCancelSession(
sessionId: string,
-61
View File
@@ -1,61 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import type { Message } from "@/shared/types/messages";
import type { Session } from "@/shared/types/chat";
export async function createSession(
agentId?: string,
projectId?: string,
): Promise<Session> {
return invoke("create_session", { agentId, projectId });
}
export async function listSessions(): Promise<Session[]> {
return invoke("list_sessions");
}
export async function listArchivedSessions(): Promise<Session[]> {
return invoke("list_archived_sessions");
}
export async function getSessionMessages(
sessionId: string,
): Promise<Message[]> {
return invoke("get_session_messages", { sessionId });
}
export async function sendMessage(
sessionId: string,
message: Message,
): Promise<Message> {
return invoke("chat_send_message", { sessionId, message });
}
export async function updateSession(
sessionId: string,
update: {
title?: string;
providerId?: string;
personaId?: string;
modelName?: string;
projectId?: string | null;
userSetName?: boolean;
},
): Promise<void> {
return invoke("update_session", { sessionId, update });
}
export async function deleteSession(sessionId: string): Promise<void> {
return invoke("delete_session", { sessionId });
}
export async function archiveSession(sessionId: string): Promise<void> {
return invoke("archive_session", { sessionId });
}
export async function unarchiveSession(sessionId: string): Promise<void> {
return invoke("unarchive_session", { sessionId });
}
export async function generateSessionTitle(sessionId: string): Promise<string> {
return invoke("generate_session_title", { sessionId });
}
-1
View File
@@ -1,4 +1,3 @@
export * from "./chat";
export * from "./agents";
export * from "./acp";
export * from "./git";
@@ -153,7 +153,10 @@ describe("createUserMessage", () => {
expect(msg.created).toBeGreaterThan(0);
expect(msg.content).toHaveLength(1);
expect(msg.content[0]).toEqual({ type: "text", text: "hi there" });
expect(msg.metadata).toBeUndefined();
expect(msg.metadata).toEqual({
userVisible: true,
agentVisible: true,
});
});
it("includes attachments when provided", () => {
@@ -162,6 +165,8 @@ describe("createUserMessage", () => {
];
const msg = createUserMessage("check this", attachments);
expect(msg.metadata).toBeDefined();
expect(msg.metadata?.userVisible).toBe(true);
expect(msg.metadata?.agentVisible).toBe(true);
expect(msg.metadata?.attachments).toEqual(attachments);
});
});
+5 -1
View File
@@ -168,7 +168,11 @@ export function createUserMessage(
role: "user",
created: Date.now(),
content: [{ type: "text", text }],
metadata: attachments ? { attachments } : undefined,
metadata: {
userVisible: true,
agentVisible: true,
...(attachments ? { attachments } : {}),
},
};
}