diff --git a/Cargo.lock b/Cargo.lock index a0639b39f4ad..5bdcc32d4f85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2610,7 +2610,7 @@ dependencies = [ [[package]] name = "goose" -version = "1.12.0" +version = "1.12.1" dependencies = [ "ahash", "anyhow", @@ -2687,7 +2687,7 @@ dependencies = [ [[package]] name = "goose-bench" -version = "1.12.0" +version = "1.12.1" dependencies = [ "anyhow", "async-trait", @@ -2710,7 +2710,7 @@ dependencies = [ [[package]] name = "goose-cli" -version = "1.12.0" +version = "1.12.1" dependencies = [ "agent-client-protocol", "anstream", @@ -2762,7 +2762,7 @@ dependencies = [ [[package]] name = "goose-mcp" -version = "1.12.0" +version = "1.12.1" dependencies = [ "anyhow", "async-trait", @@ -2828,7 +2828,7 @@ dependencies = [ [[package]] name = "goose-server" -version = "1.12.0" +version = "1.12.1" dependencies = [ "anyhow", "async-trait", @@ -2865,7 +2865,7 @@ dependencies = [ [[package]] name = "goose-test" -version = "1.12.0" +version = "1.12.1" dependencies = [ "clap", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 88aa934e2c2e..80a01d0c624e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "1.12.0" +version = "1.12.1" authors = ["Block "] license = "Apache-2.0" repository = "https://github.com/block/goose" diff --git a/crates/goose-server/src/routes/schedule.rs b/crates/goose-server/src/routes/schedule.rs index a9ce8f399bec..ad3b0172ecc0 100644 --- a/crates/goose-server/src/routes/schedule.rs +++ b/crates/goose-server/src/routes/schedule.rs @@ -8,8 +8,6 @@ use axum::{ }; use serde::{Deserialize, Serialize}; -use chrono::NaiveDateTime; - use crate::state::AppState; use goose::scheduler::ScheduledJob; @@ -82,12 +80,6 @@ pub struct SessionDisplayInfo { accumulated_output_tokens: Option, } -fn parse_session_name_to_iso(session_name: &str) -> String { - NaiveDateTime::parse_from_str(session_name, "%Y%m%d_%H%M%S") - .map(|dt| dt.and_utc().to_rfc3339()) - .unwrap_or_else(|_| String::new()) // Fallback to empty string if parsing fails -} - #[utoipa::path( post, path = "/schedule/create", @@ -326,7 +318,7 @@ async fn sessions_handler( display_infos.push(SessionDisplayInfo { id: session_name.clone(), name: session.description, - created_at: parse_session_name_to_iso(&session_name), + created_at: session.created_at.to_rfc3339(), working_dir: session.working_dir.to_string_lossy().into_owned(), schedule_id: session.schedule_id, message_count: session.message_count, diff --git a/crates/goose/src/context_mgmt/mod.rs b/crates/goose/src/context_mgmt/mod.rs index a56c87006d1f..b40f2d9ef449 100644 --- a/crates/goose/src/context_mgmt/mod.rs +++ b/crates/goose/src/context_mgmt/mod.rs @@ -42,21 +42,55 @@ pub async fn compact_messages( let messages = conversation.messages(); - // Check if the most recent message is a user message - let (messages_to_compact, preserved_user_message) = if let Some(last_message) = messages.last() - { - if matches!(last_message.role, rmcp::model::Role::User) { - // Remove the last user message before compaction - (&messages[..messages.len() - 1], Some(last_message.clone())) + let has_text_only = |msg: &Message| { + let has_text = msg + .content + .iter() + .any(|c| matches!(c, MessageContent::Text(_))); + let has_tool_content = msg.content.iter().any(|c| { + matches!( + c, + MessageContent::ToolRequest(_) | MessageContent::ToolResponse(_) + ) + }); + has_text && !has_tool_content + }; + + // Helper function to extract text content from a message + let extract_text = |msg: &Message| -> Option { + let text_parts: Vec = msg + .content + .iter() + .filter_map(|c| { + if let MessageContent::Text(text) = c { + Some(text.text.clone()) + } else { + None + } + }) + .collect(); + + if text_parts.is_empty() { + None + } else { + Some(text_parts.join("\n")) + } + }; + + // Check if the most recent message is a user message with text content only + let (messages_to_compact, preserved_user_text) = if let Some(last_message) = messages.last() { + if matches!(last_message.role, rmcp::model::Role::User) && has_text_only(last_message) { + // Remove the last user message before compaction and preserve its text + (&messages[..messages.len() - 1], extract_text(last_message)) } else if preserve_last_user_message { - // Last message is not a user message, but we want to preserve the most recent user message - // Find the most recent user message and copy it (don't remove from history) - let most_recent_user_message = messages + // Last message is not a user message with text only, but we want to preserve the most recent user message with text only + // Find the most recent user message with text content only and extract its text + let preserved_text = messages .iter() .rev() - .find(|msg| matches!(msg.role, rmcp::model::Role::User)) - .cloned(); - (messages.as_slice(), most_recent_user_message) + .find(|msg| matches!(msg.role, rmcp::model::Role::User) && has_text_only(msg)) + .and_then(extract_text); + (messages.as_slice(), preserved_text) } else { (messages.as_slice(), None) } @@ -125,8 +159,8 @@ Just continue the conversation naturally based on the summarized context" final_token_counts.push(assistant_message_tokens); // Add back the preserved user message if it exists - if let Some(user_message) = preserved_user_message { - final_messages.push(user_message); + if let Some(user_text) = preserved_user_text { + final_messages.push(Message::user().with_text(&user_text)); } Ok(( diff --git a/crates/goose/src/providers/formats/google.rs b/crates/goose/src/providers/formats/google.rs index 35dea95a483f..7fd1dd075197 100644 --- a/crates/goose/src/providers/formats/google.rs +++ b/crates/goose/src/providers/formats/google.rs @@ -160,7 +160,6 @@ pub fn get_accepted_keys(parent_key: Option<&str>) -> Vec<&str> { "anyOf", "allOf", "type", - // "format", // Google's APIs don't support this well "description", "nullable", "enum", @@ -169,26 +168,37 @@ pub fn get_accepted_keys(parent_key: Option<&str>) -> Vec<&str> { "items", ], Some("items") => vec!["type", "properties", "items", "required"], - // This is the top-level schema. _ => vec!["type", "properties", "required", "anyOf", "allOf"], } } +pub fn process_value(value: &Value, parent_key: Option<&str>) -> Value { + match value { + Value::Object(map) => process_map(map, parent_key), + Value::Array(arr) if parent_key == Some("type") => arr + .iter() + .find(|v| v.as_str() != Some("null")) + .cloned() + .unwrap_or_else(|| json!("string")), + _ => value.clone(), + } +} + /// Process a JSON map to filter out unsupported attributes, mirroring the logic /// from the official Google Gemini CLI. /// See: https://github.com/google-gemini/gemini-cli/blob/8a6509ffeba271a8e7ccb83066a9a31a5d72a647/packages/core/src/tools/tool-registry.ts#L356 pub fn process_map(map: &Map, parent_key: Option<&str>) -> Value { let accepted_keys = get_accepted_keys(parent_key); + let filtered_map: Map = map .iter() .filter_map(|(key, value)| { if !accepted_keys.contains(&key.as_str()) { - return None; // Skip if key is not accepted + return None; } - match key.as_str() { + let processed_value = match key.as_str() { "properties" => { - // Process each property within the properties object if let Some(nested_map) = value.as_object() { let processed_properties: Map = nested_map .iter() @@ -200,29 +210,44 @@ pub fn process_map(map: &Map, parent_key: Option<&str>) -> Value } }) .collect(); - Some((key.clone(), Value::Object(processed_properties))) + Value::Object(processed_properties) } else { - None + value.clone() } } "items" => { - // If it's a nested structure, recurse if it's an object. - value.as_object().map(|nested_map| { - (key.clone(), process_map(nested_map, Some(key.as_str()))) - }) + if let Some(items_map) = value.as_object() { + process_map(items_map, Some("items")) + } else { + value.clone() + } } - _ => { - // For other accepted keys, just clone the value. - Some((key.clone(), value.clone())) + "anyOf" | "allOf" => { + if let Some(arr) = value.as_array() { + let processed_arr: Vec = arr + .iter() + .map(|item| { + item.as_object().map_or_else( + || item.clone(), + |obj| process_map(obj, parent_key), + ) + }) + .collect(); + Value::Array(processed_arr) + } else { + value.clone() + } } - } + _ => value.clone(), + }; + + Some((key.clone(), processed_value)) }) .collect(); Value::Object(filtered_map) } -/// Convert Google's API response to internal Message format pub fn response_to_message(response: Value) -> Result { let mut content = Vec::new(); let binding = vec![]; diff --git a/crates/goose/src/providers/openrouter.rs b/crates/goose/src/providers/openrouter.rs index f890e87b704e..f9acb72152f6 100644 --- a/crates/goose/src/providers/openrouter.rs +++ b/crates/goose/src/providers/openrouter.rs @@ -28,7 +28,7 @@ pub const OPENROUTER_KNOWN_MODELS: &[&str] = &[ "anthropic/claude-opus-4", "anthropic/claude-3.7-sonnet", "google/gemini-2.5-pro", - "google/gemini-flash-2.5", + "google/gemini-2.5-flash", "deepseek/deepseek-r1-0528", "qwen/qwen3-coder", "moonshotai/kimi-k2", diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index a339731498fe..e62ed05e49f5 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -655,7 +655,9 @@ impl Scheduler { schedule_sessions.push((session.id.clone(), session)); } } - schedule_sessions.sort_by(|a, b| b.0.cmp(&a.0)); + + // Sort by created_at timestamp, newest first + schedule_sessions.sort_by(|a, b| b.1.created_at.cmp(&a.1.created_at)); let result_sessions: Vec<(String, Session)> = schedule_sessions.into_iter().take(limit).collect(); @@ -1171,14 +1173,10 @@ async fn run_scheduled_job_internal( } }; - // Create session upfront for both cases + // Create session upfront let session = match SessionManager::create_session( current_dir.clone(), - if recipe.prompt.is_some() { - format!("Scheduled job: {}", job.id) - } else { - "Empty job - no prompt".to_string() - }, + format!("Scheduled job: {}", job.id), ) .await { @@ -1199,65 +1197,64 @@ async fn run_scheduled_job_internal( } } - if let Some(ref prompt_text) = recipe.prompt { - let mut conversation = - Conversation::new_unvalidated(vec![Message::user().with_text(prompt_text.clone())]); - - let session_config = SessionConfig { - id: session.id.clone(), - working_dir: current_dir.clone(), - schedule_id: Some(job.id.clone()), - execution_mode: job.execution_mode.clone(), - max_turns: None, - retry_config: None, - }; + // Use prompt if available, otherwise fall back to instructions + let prompt_text = recipe + .prompt + .as_ref() + .or(recipe.instructions.as_ref()) + .unwrap(); + + let mut conversation = + Conversation::new_unvalidated(vec![Message::user().with_text(prompt_text.clone())]); + + let session_config = SessionConfig { + id: session.id.clone(), + working_dir: current_dir.clone(), + schedule_id: Some(job.id.clone()), + execution_mode: job.execution_mode.clone(), + max_turns: None, + retry_config: None, + }; - match agent - .reply(conversation.clone(), Some(session_config.clone()), None) - .await - { - Ok(mut stream) => { - use futures::StreamExt; + match agent + .reply(conversation.clone(), Some(session_config.clone()), None) + .await + { + Ok(mut stream) => { + use futures::StreamExt; - while let Some(message_result) = stream.next().await { - tokio::task::yield_now().await; + while let Some(message_result) = stream.next().await { + tokio::task::yield_now().await; - match message_result { - Ok(AgentEvent::Message(msg)) => { - if msg.role == rmcp::model::Role::Assistant { - tracing::info!("[Job {}] Assistant: {:?}", job.id, msg.content); - } - conversation.push(msg); - } - Ok(AgentEvent::McpNotification(_)) => {} - Ok(AgentEvent::ModelChange { .. }) => {} - Ok(AgentEvent::HistoryReplaced(updated_conversation)) => { - conversation = updated_conversation; - } - Err(e) => { - tracing::error!( - "[Job {}] Error receiving message from agent: {}", - job.id, - e - ); - break; + match message_result { + Ok(AgentEvent::Message(msg)) => { + if msg.role == rmcp::model::Role::Assistant { + tracing::info!("[Job {}] Assistant: {:?}", job.id, msg.content); } + conversation.push(msg); + } + Ok(AgentEvent::McpNotification(_)) => {} + Ok(AgentEvent::ModelChange { .. }) => {} + Ok(AgentEvent::HistoryReplaced(updated_conversation)) => { + conversation = updated_conversation; + } + Err(e) => { + tracing::error!( + "[Job {}] Error receiving message from agent: {}", + job.id, + e + ); + break; } } } - Err(e) => { - return Err(JobExecutionError { - job_id: job.id.clone(), - error: format!("Agent failed to reply for recipe '{}': {}", job.source, e), - }); - } } - } else { - tracing::warn!( - "[Job {}] Recipe '{}' has no prompt to execute.", - job.id, - job.source - ); + Err(e) => { + return Err(JobExecutionError { + job_id: job.id.clone(), + error: format!("Agent failed to reply for recipe '{}': {}", job.source, e), + }); + } } if let Err(e) = SessionManager::update_session(&session.id) diff --git a/scripts/test_providers.sh b/scripts/test_providers.sh index e0b5d82a45f9..078b51553bba 100755 --- a/scripts/test_providers.sh +++ b/scripts/test_providers.sh @@ -15,10 +15,10 @@ fi SCRIPT_DIR=$(pwd) PROVIDERS=( - "openrouter:anthropic/claude-sonnet-4.5:qwen/qwen3-coder" - "openai:gpt-4o:gpt-4o-mini:gpt-3.5-turbo" + "openrouter:google/gemini-2.5-pro:google/gemini-2.5-flash:anthropic/claude-sonnet-4.5:qwen/qwen3-coder" + "openai:gpt-4o:gpt-4o-mini:gpt-3.5-turbo:gpt-5" "anthropic:claude-sonnet-4-5-20250929:claude-opus-4-1-20250805" - "google:gemini-2.5-pro:gemini-2.5-pro:gemini-2.5-flash" + "google:gemini-2.5-pro:gemini-2.5-flash" ) # In CI, only run Databricks tests if DATABRICKS_HOST and DATABRICKS_TOKEN are set @@ -50,14 +50,14 @@ for provider_config in "${PROVIDERS[@]}"; do echo "Model: ${MODEL}" echo "" TMPFILE=$(mktemp) - (cd "$TESTDIR" && "$SCRIPT_DIR/target/release/goose" run --text "please list files in the current directory" --with-builtin developer 2>&1) | tee "$TMPFILE" + (cd "$TESTDIR" && "$SCRIPT_DIR/target/release/goose" run --text "please list files in the current directory" --with-builtin developer,autovisualiser,computercontroller,tutorial 2>&1) | tee "$TMPFILE" echo "" if grep -q "shell | developer" "$TMPFILE"; then echo "✓ SUCCESS: Test passed - developer tool called" - RESULTS+=("✓ ${PROVIDER}/${MODEL}") + RESULTS+=("✓ ${PROVIDER}: ${MODEL}") else echo "✗ FAILED: Test failed - no developer tools called" - RESULTS+=("✗ ${PROVIDER}/${MODEL}") + RESULTS+=("✗ ${PROVIDER}: ${MODEL}") fi rm "$TMPFILE" rm -rf "$TESTDIR" diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index f19d5786abfe..038c8b828e06 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "goose-app", - "version": "1.12.0", + "version": "1.12.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "goose-app", - "version": "1.12.0", + "version": "1.12.1", "license": "Apache-2.0", "dependencies": { "@ai-sdk/openai": "^2.0.52", @@ -38,7 +38,6 @@ "electron-updater": "^6.6.2", "electron-window-state": "^5.0.3", "express": "^5.1.0", - "gsap": "^3.13.0", "lodash": "^4.17.21", "lucide-react": "^0.546.0", "react": "^19.2.0", @@ -11550,12 +11549,6 @@ "dev": true, "license": "MIT" }, - "node_modules/gsap": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.13.0.tgz", - "integrity": "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==", - "license": "Standard 'no charge' license: https://gsap.com/standard-license." - }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 8e4b67377c98..43bcb6cda1eb 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -1,7 +1,7 @@ { "name": "goose-app", "productName": "Goose", - "version": "1.12.0", + "version": "1.12.1", "description": "Goose App", "engines": { "node": "^22.17.1" @@ -68,7 +68,6 @@ "electron-updater": "^6.6.2", "electron-window-state": "^5.0.3", "express": "^5.1.0", - "gsap": "^3.13.0", "lodash": "^4.17.21", "lucide-react": "^0.546.0", "react": "^19.2.0", diff --git a/ui/desktop/src/components/schedule/ScheduleDetailView.tsx b/ui/desktop/src/components/schedule/ScheduleDetailView.tsx index 5f4e8cbc4040..7697c033babd 100644 --- a/ui/desktop/src/components/schedule/ScheduleDetailView.tsx +++ b/ui/desktop/src/components/schedule/ScheduleDetailView.tsx @@ -631,7 +631,7 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN

Created:{' '} - {session.createdAt ? new Date(session.createdAt).toLocaleString() : 'N/A'} + {session.createdAt ? formatToLocalDateWithTimezone(session.createdAt) : 'N/A'}

{session.messageCount !== undefined && (

diff --git a/ui/desktop/src/components/settings/permission/PermissionModal.tsx b/ui/desktop/src/components/settings/permission/PermissionModal.tsx index 3359eb18522c..2ac07593a6f2 100644 --- a/ui/desktop/src/components/settings/permission/PermissionModal.tsx +++ b/ui/desktop/src/components/settings/permission/PermissionModal.tsx @@ -9,6 +9,7 @@ import { DropdownMenuContent, DropdownMenuItem, } from '../../ui/dropdown-menu'; +import { useChatContext } from '../../../contexts/ChatContext'; function getFirstSentence(text: string): string { const match = text.match(/^([^.?!]+[.?!])/); @@ -27,6 +28,9 @@ export default function PermissionModal({ extensionName, onClose }: PermissionMo { value: 'never_allow', label: 'Never allow' }, ] as { value: PermissionLevel; label: string }[]; + const chatContext = useChatContext(); + const sessionId = chatContext?.chat.sessionId || ''; + const [tools, setTools] = useState([]); const [updatedPermissions, setUpdatedPermissions] = useState>({}); @@ -41,8 +45,7 @@ export default function PermissionModal({ extensionName, onClose }: PermissionMo const fetchTools = async () => { try { const response = await getTools({ - // TODO(Douwe): pass session ID or maybe? do we configure the tools for the agent or globally? - query: { extension_name: extensionName, session_id: '' }, + query: { extension_name: extensionName, session_id: sessionId }, }); if (response.error) { console.error('Failed to get tools'); @@ -59,7 +62,7 @@ export default function PermissionModal({ extensionName, onClose }: PermissionMo }; fetchTools(); - }, [extensionName]); + }, [extensionName, sessionId]); const handleSettingChange = (toolName: string, newPermission: PermissionLevel) => { setUpdatedPermissions((prev) => ({ diff --git a/ui/desktop/src/hooks/use-text-animator.tsx b/ui/desktop/src/hooks/use-text-animator.tsx index bc8291a4ccaf..6f848b92b2fa 100644 --- a/ui/desktop/src/hooks/use-text-animator.tsx +++ b/ui/desktop/src/hooks/use-text-animator.tsx @@ -1,4 +1,3 @@ -import { gsap } from 'gsap'; import SplitType from 'split-type'; import { useEffect, useRef } from 'react'; @@ -110,6 +109,8 @@ export class TextAnimator { textElement: HTMLElement; splitter!: TextSplitter; originalChars!: string[]; + activeAnimations: globalThis.Animation[] = []; + activeTimeouts: ReturnType[] = []; constructor(textElement: HTMLElement) { if (!textElement || !(textElement instanceof HTMLElement)) { @@ -124,7 +125,7 @@ export class TextAnimator { this.splitter = new TextSplitter(this.textElement, { splitTypeTypes: ['words', 'chars'], }); - this.originalChars = this.splitter.getChars().map((char) => char.innerHTML); + this.originalChars = this.splitter.getChars().map((char) => char.textContent || ''); } animate() { @@ -133,64 +134,83 @@ export class TextAnimator { const chars = this.splitter.getChars(); chars.forEach((char, position) => { - const initialHTML = char.innerHTML; - let repeatCount = 0; - - // Set initial state - gsap.set(char, { - opacity: 1, - display: 'inline-block', - position: 'relative', - }); - - gsap.fromTo( - char, - { - opacity: 1, - }, - { - duration: 0.1, // Increased duration - ease: 'power2.out', - onStart: () => { - gsap.set(char, { - fontFamily: 'Cash Sans Mono', - fontWeight: 300, - color: '#666', // Add color change - }); + const initialText = char.textContent || ''; + + char.style.opacity = '1'; + char.style.display = 'inline-block'; + char.style.position = 'relative'; + + const animation = char.animate( + [ + { + opacity: 1, + color: '#666', + fontFamily: 'Cash Sans Mono', + fontWeight: '300', }, - onComplete: () => { - gsap.set(char, { - innerHTML: initialHTML, - color: '', - fontFamily: '', - opacity: 1, - }); + { + opacity: 0.5, + color: '#999', }, - repeat: 2, // Reduced repeats - onRepeat: () => { - repeatCount++; - if (repeatCount === 1) { - gsap.set(char, { - opacity: 0.5, - color: '#999', - }); - } + { + opacity: 1, + color: 'inherit', + fontFamily: 'inherit', + fontWeight: 'inherit', }, - repeatRefresh: true, - repeatDelay: 0.05, // Increased delay - delay: position * 0.03, // Reduced delay between chars - innerHTML: () => lettersAndSymbols[Math.floor(Math.random() * lettersAndSymbols.length)], - opacity: 1, + ], + { + duration: 300, // Total duration for all iterations + easing: 'ease-in-out', + delay: position * 30, // Stagger the start of each animation + iterations: 1, } ); + + this.activeAnimations.push(animation); + + let iteration = 0; + const maxIterations = 2; + + const animateCharacterChange = () => { + if (iteration < maxIterations) { + char.textContent = + lettersAndSymbols[Math.floor(Math.random() * lettersAndSymbols.length)]; + const timeoutId = setTimeout(animateCharacterChange, 100); + this.activeTimeouts.push(timeoutId); + iteration++; + } else { + char.textContent = initialText; + } + }; + + const timeoutId = setTimeout(animateCharacterChange, position * 30); + this.activeTimeouts.push(timeoutId); + + animation.onfinish = () => { + char.textContent = initialText; + char.style.color = ''; + char.style.fontFamily = ''; + char.style.opacity = '1'; + }; }); } reset() { + // Clear all timeouts + this.activeTimeouts.forEach((timeoutId) => clearTimeout(timeoutId)); + this.activeTimeouts = []; + + // Cancel all animations + this.activeAnimations.forEach((animation) => animation.cancel()); + this.activeAnimations = []; + + // Reset text content const chars = this.splitter.getChars(); chars.forEach((char, index) => { - gsap.killTweensOf(char); - char.innerHTML = this.originalChars[index]; + if (this.originalChars[index]) { + char.textContent = this.originalChars[index]; + } }); } }