Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 2 additions & 107 deletions crates/goose-cli/src/commands/session.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
use crate::session::user_projected_message_to_markdown;
use anyhow::{Context, Result};

use cliclack::{confirm, multiselect, select};
Expand All @@ -8,7 +7,8 @@ use goose::config::Config;
#[cfg(feature = "nostr")]
use goose::session::nostr_share;
use goose::session::{
generate_diagnostics, DiagnosticsLevel, Session, SessionManager, SessionType,
export_session_to_markdown, generate_diagnostics, DiagnosticsLevel, Session, SessionManager,
SessionType,
};
use goose::utils::safe_truncate;
use regex::Regex;
Expand Down Expand Up @@ -365,74 +365,6 @@ pub async fn handle_diagnostics(session_id: &str, output_path: Option<PathBuf>)
Ok(())
}

fn export_session_to_markdown(
messages: Vec<goose::conversation::message::Message>,
session_name: &String,
) -> String {
let mut markdown_output = String::new();

markdown_output.push_str(&format!("# Session Export: {}\n\n", session_name));

if messages.is_empty() {
markdown_output.push_str("*(This session has no messages)*\n");
return markdown_output;
}

markdown_output.push_str(&format!("*Total messages: {}*\n\n---\n\n", messages.len()));

// Track if the last message had tool requests to properly handle tool responses
let mut skip_next_if_tool_response = false;

for message in &messages {
// Check if this is a User message containing only ToolResponses
let is_only_tool_response = message.role == rmcp::model::Role::User
&& message.content.iter().all(|content| {
matches!(
content,
goose::conversation::message::MessageContent::ToolResponse(_)
)
});

// If the previous message had tool requests and this one is just tool responses,
// don't create a new User section - we'll attach the responses to the tool calls
if skip_next_if_tool_response && is_only_tool_response {
// Export the tool responses without a User heading
markdown_output.push_str(&user_projected_message_to_markdown(message));
markdown_output.push_str("\n\n---\n\n");
skip_next_if_tool_response = false;
continue;
}

// Reset the skip flag - we'll update it below if needed
skip_next_if_tool_response = false;

// Output the role prefix except for tool response-only messages
if !is_only_tool_response {
let role_prefix = match message.role {
rmcp::model::Role::User => "### User:\n",
rmcp::model::Role::Assistant => "### Assistant:\n",
};
markdown_output.push_str(role_prefix);
}

// Add the message content
markdown_output.push_str(&user_projected_message_to_markdown(message));
markdown_output.push_str("\n\n---\n\n");

// Check if this message has any tool requests, to handle the next message differently
if message.content.iter().any(|content| {
matches!(
content,
goose::conversation::message::MessageContent::ToolRequest(_)
)
}) {
skip_next_if_tool_response = true;
}
}

markdown_output
}

/// Prompt the user to interactively select a session
///
/// Shows a list of available sessions and lets the user select one
Expand Down Expand Up @@ -487,40 +419,3 @@ pub async fn prompt_interactive_session_selection(
Err(anyhow::anyhow!("Invalid selection"))
}
}

#[cfg(test)]
mod tests {
use super::*;
use goose::conversation::message::Message;
use goose::conversation::Conversation;
use rmcp::model::{Annotations, ContentBlock, Role, TextContent};

#[test]
fn markdown_export_preserves_user_audience_tool_output() {
let user_output = ContentBlock::Text(
TextContent::new("user-visible output")
.with_annotations(Annotations::default().with_audience(vec![Role::User])),
);
let assistant_output = ContentBlock::Text(
TextContent::new("assistant-only output")
.with_annotations(Annotations::default().with_audience(vec![Role::Assistant])),
);
let conversation = Conversation::new_unvalidated([Message::user().with_tool_response(
"tool-1",
Ok(rmcp::model::CallToolResult::success(vec![
user_output,
assistant_output,
ContentBlock::text("shared output"),
])),
)]);

let markdown = export_session_to_markdown(
conversation.user_visible_messages(),
&"Audience export".to_string(),
);

assert!(markdown.contains("user-visible output"));
assert!(markdown.contains("shared output"));
assert!(!markdown.contains("assistant-only output"));
}
}
2 changes: 0 additions & 2 deletions crates/goose-cli/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ mod builder;
mod completion;
pub mod editor;
mod elicitation;
mod export;
mod input;
mod output;
mod paste;
Expand All @@ -20,7 +19,6 @@ use std::str::FromStr;
use tokio::signal::ctrl_c;
use tokio_util::task::AbortOnDropHandle;

pub use self::export::{message_to_markdown, user_projected_message_to_markdown};
pub use builder::{build_session, SessionBuilderConfig};
use console::Color;
use goose::agents::platform_extensions::developer::shell::{
Expand Down
35 changes: 33 additions & 2 deletions crates/goose-sdk-types/src/custom_requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -758,15 +758,26 @@ pub struct UnarchiveSessionRequest {
pub session_id: String,
}

/// Export a session as a JSON string.
/// Export a session as a JSON or markdown string.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/unstable/session/export", response = ExportSessionResponse)]
#[serde(rename_all = "camelCase")]
pub struct ExportSessionRequest {
pub session_id: String,
#[serde(default)]
pub format: SessionExportFormat,
}

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SessionExportFormat {
#[default]
Json,
Markdown,
}

/// Export session response — raw JSON of the goose session with `conversation`.
/// Export session response — raw JSON of the goose session with `conversation`,
/// or a markdown transcript when `format` is `markdown`.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
pub struct ExportSessionResponse {
pub data: String,
Expand Down Expand Up @@ -2262,3 +2273,23 @@ pub struct SetToolPermissionsRequest {

#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
pub struct SetToolPermissionsResponse {}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn export_session_request_defaults_to_json_without_format() {
let req: ExportSessionRequest = serde_json::from_str(r#"{"sessionId":"abc"}"#).unwrap();

assert_eq!(req.format, SessionExportFormat::Json);
}

#[test]
fn export_session_request_accepts_markdown_format() {
let req: ExportSessionRequest =
serde_json::from_str(r#"{"sessionId":"abc","format":"markdown"}"#).unwrap();

assert_eq!(req.format, SessionExportFormat::Markdown);
}
}
15 changes: 13 additions & 2 deletions crates/goose/acp-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3409,15 +3409,26 @@
"properties": {
"sessionId": {
"type": "string"
},
"format": {
"$ref": "#/$defs/SessionExportFormat",
"default": "json"
}
},
"required": [
"sessionId"
],
"description": "Export a session as a JSON string.",
"description": "Export a session as a JSON or markdown string.",
"x-side": "agent",
"x-method": "_goose/unstable/session/export"
},
"SessionExportFormat": {
"type": "string",
"enum": [
"json",
"markdown"
]
},
"ExportSessionResponse_unstable": {
"type": "object",
"properties": {
Expand All @@ -3428,7 +3439,7 @@
"required": [
"data"
],
"description": "Export session response — raw JSON of the goose session with `conversation`.",
"description": "Export session response — raw JSON of the goose session with `conversation`,\nor a markdown transcript when `format` is `markdown`.",
"x-side": "agent",
"x-method": "_goose/unstable/session/export"
},
Expand Down
14 changes: 9 additions & 5 deletions crates/goose/src/acp/server/manage_sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,15 @@ impl GooseAcpAgent {
&self,
req: ExportSessionRequest,
) -> Result<ExportSessionResponse, agent_client_protocol::Error> {
let data = self
.session_manager
.export_session(&req.session_id)
.await
.internal_err()?;
let data = match req.format {
SessionExportFormat::Json => self.session_manager.export_session(&req.session_id).await,
SessionExportFormat::Markdown => {
self.session_manager
.export_session_markdown(&req.session_id)
.await
}
}
.internal_err()?;
Ok(ExportSessionResponse { data })
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use goose::conversation::message::{
use crate::conversation::message::{
ActionRequiredData, Message, MessageContent, ToolNameParts, ToolRequest, ToolResponse,
};
use goose::utils::safe_truncate;
use crate::utils::safe_truncate;
use rmcp::model::{ContentBlock, ResourceContents, Role};
use serde_json::Value;

Expand Down Expand Up @@ -446,11 +446,65 @@ fn message_to_markdown_for_audience(
md.trim_end_matches("\n").to_string()
}

pub fn export_session_to_markdown(messages: Vec<Message>, session_name: &str) -> String {
let mut markdown_output = String::new();

markdown_output.push_str(&format!("# Session Export: {}\n\n", session_name));

if messages.is_empty() {
markdown_output.push_str("*(This session has no messages)*\n");
return markdown_output;
}

markdown_output.push_str(&format!("*Total messages: {}*\n\n---\n\n", messages.len()));

let mut skip_next_if_tool_response = false;

for message in &messages {
let is_only_tool_response = message.role == Role::User
&& message
.content
.iter()
.all(|content| matches!(content, MessageContent::ToolResponse(_)));

if skip_next_if_tool_response && is_only_tool_response {
markdown_output.push_str(&user_projected_message_to_markdown(message));
markdown_output.push_str("\n\n---\n\n");
skip_next_if_tool_response = false;
continue;
}

skip_next_if_tool_response = false;

if !is_only_tool_response {
let role_prefix = match message.role {
Role::User => "### User:\n",
Role::Assistant => "### Assistant:\n",
};
markdown_output.push_str(role_prefix);
}

markdown_output.push_str(&user_projected_message_to_markdown(message));
markdown_output.push_str("\n\n---\n\n");

if message
.content
.iter()
.any(|content| matches!(content, MessageContent::ToolRequest(_)))
{
skip_next_if_tool_response = true;
}
}

markdown_output
}

#[cfg(test)]
mod tests {
use super::*;
use goose::conversation::message::{Message, ToolRequest, ToolResponse};
use rmcp::model::{CallToolRequestParams, ContentBlock, TextContent};
use crate::conversation::message::{Message, ToolRequest, ToolResponse};
use crate::conversation::Conversation;
use rmcp::model::{Annotations, CallToolRequestParams, ContentBlock, TextContent};
use rmcp::object;
use serde_json::json;

Expand Down Expand Up @@ -1139,4 +1193,39 @@ found 0 vulnerabilities"#;
assert!(response_result.contains("added 57 packages"));
assert!(response_result.contains("found 0 vulnerabilities"));
}

#[test]
fn markdown_export_preserves_user_audience_tool_output() {
let user_output = ContentBlock::Text(
TextContent::new("user-visible output")
.with_annotations(Annotations::default().with_audience(vec![Role::User])),
);
let assistant_output = ContentBlock::Text(
TextContent::new("assistant-only output")
.with_annotations(Annotations::default().with_audience(vec![Role::Assistant])),
);
let conversation = Conversation::new_unvalidated([Message::user().with_tool_response(
"tool-1",
Ok(rmcp::model::CallToolResult::success(vec![
user_output,
assistant_output,
ContentBlock::text("shared output"),
])),
)]);

let markdown =
export_session_to_markdown(conversation.user_visible_messages(), "Audience export");

assert!(markdown.contains("user-visible output"));
assert!(markdown.contains("shared output"));
assert!(!markdown.contains("assistant-only output"));
}

#[test]
fn markdown_export_handles_empty_conversation() {
let markdown = export_session_to_markdown(Vec::new(), "Empty session");

assert!(markdown.contains("# Session Export: Empty session"));
assert!(markdown.contains("*(This session has no messages)*"));
}
}
4 changes: 4 additions & 0 deletions crates/goose/src/session/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod chat_history_search;
mod diagnostics;
mod export_markdown;
pub mod extension_data;
pub mod import_formats;
mod last_message_snippet;
Expand All @@ -15,6 +16,9 @@ pub use diagnostics::{
DiagnosticsLevel, DiagnosticsLogs, DiagnosticsPrompt, DiagnosticsReport,
DiagnosticsScheduledRecipe, DiagnosticsTextFile, SystemInfo,
};
pub use export_markdown::{
export_session_to_markdown, message_to_markdown, user_projected_message_to_markdown,
};
pub use extension_data::{EnabledExtensionsState, ExtensionData, ExtensionState, TodoState};
pub use session_manager::{
Session, SessionInsights, SessionManager, SessionNameUpdate, SessionType, SessionUpdateBuilder,
Expand Down
Loading