From 4e2595eb66258d2df12cec7a8602898c6c07e3c3 Mon Sep 17 00:00:00 2001 From: earayu Date: Thu, 25 Jun 2026 12:15:18 +0800 Subject: [PATCH] chore: sync upstream changes through 2026-06-25 --- .github/workflows/pr-website-preview.yml | 3 +- Cargo.lock | 1 - crates/goose-cli/src/commands/session.rs | 13 +- crates/goose-providers/src/formats/openai.rs | 281 +++++++- crates/goose-providers/src/json.rs | 207 ++++++ crates/goose-sdk-types/src/custom_requests.rs | 56 +- .../src/custom_requests/schedule.rs | 169 +++++ crates/goose-server/Cargo.toml | 1 - crates/goose-server/src/commands/agent.rs | 21 +- crates/goose-server/src/openapi.rs | 2 - crates/goose-server/src/routes/gateway.rs | 225 ------ crates/goose-server/src/routes/mod.rs | 2 - crates/goose-server/src/routes/tunnel.rs | 52 +- crates/goose-server/src/state.rs | 4 - crates/goose-server/src/tunnel/mod.rs | 336 +-------- crates/goose/acp-meta.json | 60 ++ crates/goose/acp-schema.json | 681 +++++++++++++++++- crates/goose/src/acp/response_builder.rs | 200 +++-- crates/goose/src/acp/server.rs | 3 + crates/goose/src/acp/server/agent_mentions.rs | 119 +++ .../goose/src/acp/server/custom_dispatch.rs | 96 +++ crates/goose/src/acp/server/list_sessions.rs | 11 +- crates/goose/src/acp/server/schedule.rs | 343 +++++++++ crates/goose/src/acp/server/slash_commands.rs | 45 ++ crates/goose/src/agents/agent.rs | 212 ++++-- crates/goose/src/hooks/mod.rs | 11 + crates/goose/src/model_config.rs | 9 +- crates/goose/src/providers/anthropic.rs | 1 + .../goose/src/providers/formats/anthropic.rs | 203 +++++- .../goose/src/providers/formats/databricks.rs | 21 +- crates/goose/src/providers/formats/google.rs | 31 + .../goose/src/providers/formats/openrouter.rs | 19 + crates/goose/src/scheduler.rs | 163 ++++- crates/goose/src/session/session_manager.rs | 212 +++++- .../slash_commands/recipe_slash_command.rs | 1 + .../src/slash_commands/skill_slash_command.rs | 1 + .../goose/src/slash_commands/slash_command.rs | 2 + crates/goose/src/slash_commands/types.rs | 1 + crates/goose/tests/acp_common_tests/mod.rs | 2 + crates/goose/tests/agent.rs | 560 ++++++++++++++ .../docs/experimental/goose-mobile.md | 4 +- .../docs/experimental/mobile-access.md | 2 +- .../docs/experimental/remote-access/index.md | 9 +- .../remote-access/mobile-access.md | 77 +- .../docs/guides/context-engineering/hooks.md | 14 +- documentation/package-lock.json | 6 +- ui/desktop/openapi.json | 70 +- ui/desktop/package.json | 1 - ui/desktop/src/App.tsx | 129 +--- .../src/acp/__tests__/autocomplete.test.ts | 113 +++ .../acp/__tests__/chatNotifications.test.ts | 4 + .../__tests__/chatSessionController.test.ts | 184 ++++- .../acp/__tests__/chatSessionStore.test.ts | 181 +++++ .../sessionNotificationAdapter.test.ts | 147 +++- ui/desktop/src/acp/adapter/messages.ts | 54 +- ui/desktop/src/acp/adapter/shared.ts | 25 +- ui/desktop/src/acp/autocomplete.ts | 76 ++ ui/desktop/src/acp/chatSessionController.ts | 31 +- ui/desktop/src/acp/chatSessionStore.ts | 140 +++- ui/desktop/src/acp/prompt.ts | 14 + ui/desktop/src/acp/schedules.ts | 193 +++++ .../src/acp/sessionNotificationAdapter.ts | 20 +- ui/desktop/src/acp/sessions.ts | 4 + ui/desktop/src/acpChatFeatureFlag.ts | 2 +- ui/desktop/src/api/index.ts | 4 +- ui/desktop/src/api/sdk.gen.ts | 12 +- ui/desktop/src/api/types.gen.ts | 53 +- ui/desktop/src/components/BaseChat.tsx | 8 +- ui/desktop/src/components/ChatInput.tsx | 144 +++- ui/desktop/src/components/Hub.tsx | 1 - ui/desktop/src/components/MentionPopover.tsx | 43 +- ui/desktop/src/components/MessageQueue.tsx | 344 +++++---- .../src/components/alerts/useToolCount.ts | 23 - .../src/components/recipes/RecipesView.tsx | 4 +- .../src/components/schedule/CronPicker.tsx | 4 +- .../schedule/ScheduleDetailView.tsx | 218 +++--- .../src/components/schedule/ScheduleModal.tsx | 4 +- .../schedule/SchedulesView.test.tsx | 18 +- .../src/components/schedule/SchedulesView.tsx | 52 +- .../schedule/__tests__/CronPicker.test.tsx | 6 +- .../sessions/SessionHistoryView.tsx | 419 ----------- .../components/sessions/SessionListView.tsx | 32 +- .../src/components/sessions/SessionsView.tsx | 100 +-- .../components/sessions/SharedSessionView.tsx | 104 --- .../components/settings/SettingsView.test.tsx | 12 - .../src/components/settings/SettingsView.tsx | 63 +- .../settings/chat/ChatSettingsSection.tsx | 9 +- .../subcomponents/ExtensionItem.test.tsx | 47 ++ .../subcomponents/ExtensionItem.tsx | 2 +- .../gateways/GatewaySettingsSection.tsx | 502 ------------- .../components/settings/mesh/MeshSection.tsx | 9 - .../components/settings/mesh/MeshSettings.tsx | 660 ----------------- .../components/settings/mode/ModeSection.tsx | 6 +- .../sessions/SessionSharingSection.tsx | 321 --------- .../settings/tunnel/TunnelSection.tsx | 457 ------------ ui/desktop/src/hooks/useAcpChatSession.ts | 66 +- ui/desktop/src/hooks/useChatSessionTypes.ts | 2 + ui/desktop/src/hooks/useChatStream.ts | 1 + ui/desktop/src/hooks/useNavigationSessions.ts | 1 + ui/desktop/src/i18n/messages/en.json | 293 +------- ui/desktop/src/i18n/messages/es.json | 289 +------- ui/desktop/src/i18n/messages/hi.json | 289 +------- ui/desktop/src/i18n/messages/ja.json | 289 +------- ui/desktop/src/i18n/messages/ko.json | 289 +------- ui/desktop/src/i18n/messages/ru.json | 289 +------- ui/desktop/src/i18n/messages/tr.json | 289 +------- ui/desktop/src/i18n/messages/zh-CN.json | 293 +------- ui/desktop/src/main.ts | 35 +- ui/desktop/src/mesh.ts | 319 -------- ui/desktop/src/preload.ts | 17 - ui/desktop/src/schedule.ts | 191 ----- ui/desktop/src/sessionLinks.ts | 68 -- ui/desktop/src/sharedSessions.ts | 114 --- ui/desktop/src/test/setup.ts | 4 - ui/desktop/src/utils/analytics.ts | 52 +- ui/desktop/src/utils/dateUtils.ts | 6 +- ui/desktop/src/utils/navigationUtils.ts | 8 - ui/desktop/src/utils/settings.ts | 10 - ui/pnpm-lock.yaml | 12 - ui/sdk/src/generated/client.gen.ts | 156 ++++ ui/sdk/src/generated/index.ts | 62 +- ui/sdk/src/generated/types.gen.ts | 195 ++++- ui/sdk/src/generated/zod.gen.ts | 221 +++++- 123 files changed, 6022 insertions(+), 7399 deletions(-) create mode 100644 crates/goose-sdk-types/src/custom_requests/schedule.rs delete mode 100644 crates/goose-server/src/routes/gateway.rs create mode 100644 crates/goose/src/acp/server/agent_mentions.rs create mode 100644 crates/goose/src/acp/server/schedule.rs create mode 100644 crates/goose/src/acp/server/slash_commands.rs create mode 100644 ui/desktop/src/acp/__tests__/autocomplete.test.ts create mode 100644 ui/desktop/src/acp/autocomplete.ts create mode 100644 ui/desktop/src/acp/schedules.ts delete mode 100644 ui/desktop/src/components/alerts/useToolCount.ts delete mode 100644 ui/desktop/src/components/sessions/SessionHistoryView.tsx delete mode 100644 ui/desktop/src/components/sessions/SharedSessionView.tsx create mode 100644 ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.test.tsx delete mode 100644 ui/desktop/src/components/settings/gateways/GatewaySettingsSection.tsx delete mode 100644 ui/desktop/src/components/settings/mesh/MeshSection.tsx delete mode 100644 ui/desktop/src/components/settings/mesh/MeshSettings.tsx delete mode 100644 ui/desktop/src/components/settings/sessions/SessionSharingSection.tsx delete mode 100644 ui/desktop/src/components/settings/tunnel/TunnelSection.tsx delete mode 100644 ui/desktop/src/mesh.ts delete mode 100644 ui/desktop/src/schedule.ts delete mode 100644 ui/desktop/src/sharedSessions.ts diff --git a/.github/workflows/pr-website-preview.yml b/.github/workflows/pr-website-preview.yml index 46efc457d00c..ba30aa12e085 100644 --- a/.github/workflows/pr-website-preview.yml +++ b/.github/workflows/pr-website-preview.yml @@ -51,6 +51,7 @@ jobs: run: ./scripts/verify-build.sh - name: Setup Node.js for Wrangler + if: env.CLOUDFLARE_API_TOKEN != '' uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 @@ -78,7 +79,7 @@ jobs: echo "preview-url=$preview_url" >> "$GITHUB_OUTPUT" - name: Comment preview URL - if: steps.cloudflare-pages.outputs.preview-url != '' + if: env.CLOUDFLARE_API_TOKEN != '' && steps.cloudflare-pages.outputs.preview-url != '' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: PREVIEW_URL: ${{ steps.cloudflare-pages.outputs.preview-url }} diff --git a/Cargo.lock b/Cargo.lock index 899297ffaa9c..d4a76d6a8ffc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5053,7 +5053,6 @@ dependencies = [ "chrono", "clap", "config", - "fs2", "futures", "goose", "goose-mcp", diff --git a/crates/goose-cli/src/commands/session.rs b/crates/goose-cli/src/commands/session.rs index 15d5908888d8..72c3610fe320 100644 --- a/crates/goose-cli/src/commands/session.rs +++ b/crates/goose-cli/src/commands/session.rs @@ -70,7 +70,8 @@ fn prompt_interactive_session_removal(sessions: &[Session]) -> Result(out: &mut W, line: &str) -> Result chrono::DateTime { + session.last_message_at.unwrap_or(session.updated_at) +} + pub async fn handle_session_list( format: String, ascending: bool, @@ -170,9 +175,9 @@ pub async fn handle_session_list( } if ascending { - sessions.sort_by(|a, b| a.updated_at.cmp(&b.updated_at)); + sessions.sort_by_key(session_activity_at); } else { - sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + sessions.sort_by_key(|b| std::cmp::Reverse(session_activity_at(b))); } if let Some(n) = limit { @@ -206,7 +211,7 @@ pub async fn handle_session_list( "{} - {} - {} - {}", session.id, session.name, - session.updated_at, + session_activity_at(&session), display_path_with_tilde(&session.working_dir) ); if !write_line_or_broken_pipe_ok(&mut out, &output)? { diff --git a/crates/goose-providers/src/formats/openai.rs b/crates/goose-providers/src/formats/openai.rs index 43d96992f45a..912622bf0bf0 100644 --- a/crates/goose-providers/src/formats/openai.rs +++ b/crates/goose-providers/src/formats/openai.rs @@ -2,7 +2,7 @@ use crate::conversation::message::{Message, MessageContent, ProviderMetadata}; use crate::conversation::token_usage::{ProviderUsage, Usage}; use crate::errors::ProviderError; use crate::images::{convert_image, detect_image_path, load_image_file, ImageFormat}; -use crate::json::safely_parse_json; +use crate::json::{parse_tool_arguments, truncation_error_message}; use crate::mcp_utils::extract_text_from_resource; use crate::model::ModelConfig; use crate::thinking::{ @@ -182,12 +182,37 @@ pub fn format_messages_with_options( ) -> Vec { let mut messages_spec = Vec::new(); let mut pending_assistant_reasoning = String::new(); + // Reasoning to propagate across consecutive tool-call messages in the same turn. + // DeepSeek/Kimi require reasoning_content on every assistant tool-call message. + let mut tool_call_turn_reasoning = String::new(); + let mut saw_tool_response = false; for message in messages { if options.preserve_thinking_context && message.role != Role::Assistant { pending_assistant_reasoning.clear(); } + if options.preserve_thinking_context && message.role == Role::User { + if message + .content + .iter() + .any(|c| matches!(c, MessageContent::ToolResponse(_))) + { + saw_tool_response = true; + } else { + tool_call_turn_reasoning.clear(); + saw_tool_response = false; + } + } + + // A new assistant message after tool results creates a new turn. + // Prevents reasoning from the previous turn leaking into the new one. + if options.preserve_thinking_context && message.role == Role::Assistant && saw_tool_response + { + tool_call_turn_reasoning.clear(); + saw_tool_response = false; + } + let mut converted = json!({ "role": message.role }); @@ -408,6 +433,25 @@ pub fn format_messages_with_options( merge_reasoning_text(&pending_assistant_reasoning, &reasoning_text); pending_assistant_reasoning.clear(); } + + let has_tool_calls = converted + .get("tool_calls") + .and_then(|tc| tc.as_array()) + .is_some_and(|a| !a.is_empty()); + + if has_tool_calls { + if reasoning_text.is_empty() { + reasoning_text = tool_call_turn_reasoning.clone(); + } else { + tool_call_turn_reasoning = reasoning_text.clone(); + } + } else { + // Carry reasoning forward even through non-tool assistant messages + // (e.g., a visible text chunk that's is sent before a tool-call chunk + // in the same streaming turn). An empty reasoning_text is equivalent + // to clear. + tool_call_turn_reasoning = reasoning_text.clone(); + } } // Include reasoning_content only when non-empty. Kimi rejects empty @@ -653,8 +697,8 @@ pub fn response_to_message(response: &Value) -> anyhow::Result { metadata.as_ref(), )); } else { - match safely_parse_json(&arguments_str) { - Ok(params) => { + match parse_tool_arguments(&arguments_str) { + Some(params) => { content.push(MessageContent::tool_request_with_metadata( id, Ok(CallToolRequestParams::new(function_name) @@ -662,13 +706,14 @@ pub fn response_to_message(response: &Value) -> anyhow::Result { metadata.as_ref(), )); } - Err(e) => { + None => { + let message_text = truncation_error_message(&arguments_str) + .unwrap_or_else(|| { + format!("Could not interpret tool use parameters for id {id}") + }); let error = ErrorData { code: ErrorCode::INVALID_PARAMS, - message: Cow::from(format!( - "Could not interpret tool use parameters for id {}: {}. Raw arguments: '{}'", - id, e, arguments_str - )), + message: Cow::from(message_text), data: None, }; content.push(MessageContent::tool_request_with_metadata( @@ -1085,12 +1130,6 @@ where for index in sorted_indices { if let Some((id, function_name, arguments, extra_fields)) = tool_call_data.get(&index) { - let parsed = if arguments.is_empty() { - Ok(json!({})) - } else { - safely_parse_json(arguments) - }; - let metadata = if let Some(sig) = &last_signature { let mut combined = extra_fields.clone().unwrap_or_default(); combined.insert( @@ -1102,26 +1141,34 @@ where extra_fields.as_ref().filter(|m| !m.is_empty()).cloned() }; - let content = match parsed { - Ok(params) => { - MessageContent::tool_request_with_metadata( + let content = if arguments.is_empty() { + MessageContent::tool_request_with_metadata( + id.clone(), + Ok(CallToolRequestParams::new(function_name.clone()).with_arguments(object(json!({})))), + metadata.as_ref(), + ) + } else { + match parse_tool_arguments(arguments) { + Some(params) => MessageContent::tool_request_with_metadata( id.clone(), Ok(CallToolRequestParams::new(function_name.clone()).with_arguments(object(params))), metadata.as_ref(), - ) - }, - Err(e) => { - let error = ErrorData { - code: ErrorCode::INVALID_PARAMS, - message: Cow::from(format!( - "Could not interpret tool use parameters for id {}: {}", - id, e - )), - data: None, - }; - MessageContent::tool_request_with_metadata(id.clone(), Err(error), metadata.as_ref()) + ), + None => { + let message_text = truncation_error_message(arguments) + .unwrap_or_else(|| { + format!("Could not interpret tool use parameters for id {id}") + }); + let error = ErrorData { + code: ErrorCode::INVALID_PARAMS, + message: Cow::from(message_text), + data: None, + }; + MessageContent::tool_request_with_metadata(id.clone(), Err(error), metadata.as_ref()) + } } }; + contents.push(content); } } @@ -1931,7 +1978,7 @@ mod tests { message: msg, data: None, }) => { - assert!(msg.starts_with("Could not interpret tool use parameters")); + assert!(msg.contains("tool arguments") || msg.contains("truncated")); } _ => panic!("Expected InvalidParameters error"), } @@ -3132,6 +3179,180 @@ data: [DONE]"#; Ok(()) } + #[test] + fn test_format_messages_carries_reasoning_through_text_only_chunks() -> anyhow::Result<()> { + // Scenario B from the streaming bug: thinking arrives first, then multiple + // text-only assistant messages, then a tool call with thinking re-attached + // by agent.rs (via the earlier-chunk lookback). + // Text-only messages set tool_call_turn_reasoning="" (line 453 else-branch), + // but the TC's own Thinking content must repopulate it. + let messages = vec![ + Message::assistant().with_content(MessageContent::thinking("reason", "")), + Message::assistant().with_text("partial answer"), + Message::assistant().with_text("more text"), + // agent.rs attaches the earlier thinking to the TC message + Message::assistant() + .with_content(MessageContent::thinking("reason", "")) + .with_tool_request( + "tool1", + Ok(CallToolRequestParams::new("test_tool").with_arguments(object!({}))), + ), + ]; + + let spec = format_messages_with_options( + &messages, + &ImageFormat::OpenAi, + OpenAiFormatOptions { + preserve_thinking_context: true, + }, + ); + + let tool_call_msgs: Vec<_> = spec + .iter() + .filter(|m| { + m.get("tool_calls") + .and_then(|tc| tc.as_array()) + .is_some_and(|a| !a.is_empty()) + }) + .collect(); + + assert_eq!(tool_call_msgs.len(), 1); + assert_eq!( + tool_call_msgs[0]["reasoning_content"], "reason", + "reasoning_content must survive text-only chunks between thinking and tool call" + ); + + Ok(()) + } + + #[test] + fn test_format_messages_carries_reasoning_to_all_split_tool_calls() -> anyhow::Result<()> { + // Simulates DeepSeek/Kimi streaming: a thinking-only chunk arrives first, + // then the agent splits two tool calls into separate messages, each with + // the same reasoning attached (as agent.rs does via response_thinking). + // The formatter must keep reasoning_content on both so that + // merge_split_tool_call_messages can reunite them into one assistant message. + let tool_result1 = Message::user().with_tool_response( + "tool1", + Ok(rmcp::model::CallToolResult::success(vec![ + rmcp::model::Content::text("result1"), + ])), + ); + let messages = vec![ + // Standalone thinking message (created by agent.rs alongside request_msgs) + Message::assistant().with_content(MessageContent::thinking("reasoning", "")), + // Each request_msg has thinking explicitly attached (agent.rs behaviour) + Message::assistant() + .with_content(MessageContent::thinking("reasoning", "")) + .with_tool_request( + "tool1", + Ok(CallToolRequestParams::new("tool_a").with_arguments(object!({}))), + ), + tool_result1, + Message::assistant() + .with_content(MessageContent::thinking("reasoning", "")) + .with_tool_request( + "tool2", + Ok(CallToolRequestParams::new("tool_b").with_arguments(object!({}))), + ), + ]; + + let spec = format_messages_with_options( + &messages, + &ImageFormat::OpenAi, + OpenAiFormatOptions { + preserve_thinking_context: true, + }, + ); + + // After merge: one assistant message with both tool calls + let assistant_msgs: Vec<_> = spec + .iter() + .filter(|m| m.get("role") == Some(&json!("assistant"))) + .collect(); + assert_eq!(assistant_msgs.len(), 1); + assert_eq!(assistant_msgs[0]["reasoning_content"], "reasoning"); + let tool_calls = assistant_msgs[0]["tool_calls"].as_array().unwrap(); + assert_eq!(tool_calls.len(), 2); + + Ok(()) + } + + #[test] + fn test_sequential_tool_calls_not_merged() -> anyhow::Result<()> { + // Verifies that two tool calls from *different* turns are never merged, + // even when the second call carries no fresh reasoning (the previous + // turn's reasoning must not leak into it). + let tool_result1 = Message::user().with_tool_response( + "tool1", + Ok(rmcp::model::CallToolResult::success(vec![ + rmcp::model::Content::text("result1"), + ])), + ); + let messages = vec![ + // Turn 1: thinking then tool call + Message::assistant().with_content(MessageContent::thinking("turn1_reasoning", "")), + Message::assistant().with_tool_request( + "tool1", + Ok(CallToolRequestParams::new("tool_a").with_arguments(object!({}))), + ), + tool_result1, + // Turn 2: new tool call, no fresh thinking + Message::assistant().with_tool_request( + "tool2", + Ok(CallToolRequestParams::new("tool_b").with_arguments(object!({}))), + ), + ]; + + let spec = format_messages_with_options( + &messages, + &ImageFormat::OpenAi, + OpenAiFormatOptions { + preserve_thinking_context: true, + }, + ); + + let assistant_msgs: Vec<_> = spec + .iter() + .filter(|m| m.get("role") == Some(&json!("assistant"))) + .collect(); + + // Must remain two separate assistant messages — not merged across turns. + assert_eq!( + assistant_msgs.len(), + 2, + "sequential tool calls must not be merged" + ); + + // Turn 1 carries reasoning; turn 2 must not inherit it. + assert_eq!(assistant_msgs[0]["reasoning_content"], "turn1_reasoning"); + assert!( + assistant_msgs[1].get("reasoning_content").is_none() + || assistant_msgs[1]["reasoning_content"].is_null(), + "turn 2 must not inherit stale reasoning from turn 1" + ); + + // The tool result must appear between the two assistant messages. + let tool_idx = spec + .iter() + .position(|m| m.get("role") == Some(&json!("tool"))) + .expect("tool result must be present"); + let asst1_idx = spec + .iter() + .position(|m| m.get("role") == Some(&json!("assistant"))) + .unwrap(); + let asst2_idx = spec + .iter() + .rposition(|m| m.get("role") == Some(&json!("assistant"))) + .unwrap(); + assert!( + asst1_idx < tool_idx && tool_idx < asst2_idx, + "tool result must sit between the two assistant messages" + ); + + Ok(()) + } + #[test_case( "data: {\"error\":{\"message\":\"Internal server error\",\"type\":\"server_error\",\"code\":500}}\ndata: [DONE]", "Internal server error"; diff --git a/crates/goose-providers/src/json.rs b/crates/goose-providers/src/json.rs index c8a23418b242..c1a4baabd966 100644 --- a/crates/goose-providers/src/json.rs +++ b/crates/goose-providers/src/json.rs @@ -122,6 +122,121 @@ pub fn json_escape_control_chars_in_string(s: &str) -> String { r } +/// Detect whether a raw tool-arguments string looks truncated (the model hit +/// its output-token limit mid-JSON). Returns true when the string has +/// unbalanced or unclosed structural delimiters — whether the cut-off happened +/// mid-value (e.g. `{"path":"/a` with no closing quote) or after a nested +/// closer but before the outer object closed (e.g. `{"items":[1,2]` where the +/// outer `{` is still open). +pub fn looks_truncated(args: &str) -> bool { + let trimmed = args.trim_end(); + if trimmed.is_empty() { + return false; + } + + let mut in_string = false; + let mut escape_next = false; + let mut depth = Vec::new(); + + for c in trimmed.chars() { + if in_string { + if escape_next { + escape_next = false; + } else if c == '\\' { + escape_next = true; + } else if c == '"' { + in_string = false; + } + continue; + } + + match c { + '"' => in_string = true, + '{' => depth.push('}'), + '[' => depth.push(']'), + '}' | ']' => { + if depth.last() == Some(&c) { + depth.pop(); + } else { + return true; + } + } + _ => {} + } + } + + in_string || escape_next || !depth.is_empty() +} + +/// Build an actionable error message for tool arguments that could not be +/// parsed. `args` is the raw, accumulated arguments string from the provider. +/// +/// The message distinguishes truncation (likely from the output token limit) +/// from other malformation, and includes a snippet of where parsing broke. +pub fn truncation_error_message(args: &str) -> Option { + if args.is_empty() { + return None; + } + + if serde_json::from_str::(args).is_ok() { + return None; + } + + let trimmed = args.trim_end(); + let is_truncated = looks_truncated(trimmed); + + let snippet = { + let len = trimmed.chars().count(); + if len > 80 { + let s: String = trimmed + .chars() + .rev() + .take(80) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("…{s}") + } else { + trimmed.to_string() + } + }; + + let guidance = if is_truncated { + "The model's response was truncated — it hit the output token limit while generating this tool call. \ + Try increasing max_tokens for this provider or breaking the task into smaller steps." + } else { + "The model produced malformed tool arguments. Try resending your message or breaking the task into smaller steps." + }; + + Some(format!( + "{guidance}\nReceived {} characters; cut off at: {snippet}", + trimmed.chars().count() + )) +} + +/// Parse tool-call arguments, returning `None` when the input looks truncated +/// so callers can surface an actionable error rather than invoking a tool with +/// incomplete arguments. Non-truncated malformation (e.g. unescaped control +/// characters some models emit) is still repaired via [`safely_parse_json`]. +pub fn parse_tool_arguments(args: &str) -> Option { + if args.is_empty() { + return Some(serde_json::Value::Object(serde_json::Map::new())); + } + + if let Ok(value) = serde_json::from_str::(args) { + return Some(value); + } + + if !looks_truncated(args) { + if let Ok(value) = safely_parse_json(args) { + return Some(value); + } + } + + None +} + #[cfg(test)] mod tests { use super::*; @@ -218,4 +333,96 @@ mod tests { "Hello\\u0001World" ); } + + #[test] + fn test_truncation_error_message_valid_json() { + assert!(truncation_error_message(r#"{"key":"value"}"#).is_none()); + assert!(truncation_error_message(r#"{}"#).is_none()); + assert!(truncation_error_message(r#"{"a":[1,2],"b":{"c":3}}"#).is_none()); + assert!(truncation_error_message(r#"[1,2,3]"#).is_none()); + assert!(truncation_error_message(r#"{"a":{"b":"c"}}"#).is_none()); + assert!(truncation_error_message("").is_none()); + } + + #[test] + fn test_looks_truncated_nested_closers() { + // Truncated after inner array closes, but outer object still open. + assert!(looks_truncated(r#"{"items":[1,2]"#)); + // Truncated after inner object closes, but outer object still open. + assert!(looks_truncated(r#"{"patch":{"path":"x"}"#)); + // Truncated mid-string. + assert!(looks_truncated( + r##"{"path":"/report.md","content":"# cut"## + )); + // Truncated mid-key. + assert!(looks_truncated(r#"{"key":"val"#)); + + // Well-formed JSON is NOT truncated. + assert!(!looks_truncated(r#"{"key":"value"}"#)); + assert!(!looks_truncated(r#"{"a":[1,2],"b":{"c":3}}"#)); + assert!(!looks_truncated(r#"[1,2,3]"#)); + assert!(!looks_truncated(r#"{"a":{"b":"c"}}"#)); + assert!(!looks_truncated(r#"{}"#)); + assert!(!looks_truncated("")); + } + + #[test] + fn test_parse_tool_arguments_nested_closers_truncated() { + // These end with ] or } so the old check passed, but the outer object + // is still open — silently repairing these would invoke tools with + // incomplete arguments. + let case1 = r#"{"items":[1,2]"#; + assert!(parse_tool_arguments(case1).is_none()); + + let case2 = r#"{"patch":{"path":"x"}"#; + assert!(parse_tool_arguments(case2).is_none()); + } + + #[test] + fn test_parse_tool_arguments_control_char_recovery() { + // Unescaped control chars (raw newline) inside a string value should + // still parse successfully via safely_parse_json fallback. + let args = "{\"key\": \"value\nwith newline\"}"; + let parsed = parse_tool_arguments(args).expect("control-char JSON should parse"); + assert_eq!(parsed["key"], "value\nwith newline"); + } + + #[test] + fn test_parse_tool_arguments_truncated_fails() { + let truncated = r##"{"path":"/report.md","content":"# Big report that got cut"##; + assert!( + parse_tool_arguments(truncated).is_none(), + "truncated JSON should NOT parse (would silently invoke tool with truncated content)" + ); + } + + #[test] + fn test_parse_tool_arguments_strict_json() { + let valid = r#"{"key":"value"}"#; + assert!(parse_tool_arguments(valid).is_some()); + assert!(parse_tool_arguments("").is_some()); + } + + #[test] + fn test_truncation_error_message_truncated() { + let truncated = r##"{"path":"/report.md","content":"# Big report that got cut"##; + let msg = + truncation_error_message(truncated).expect("truncated args should produce an error"); + assert!(msg.contains("truncated"), "msg: {msg}"); + assert!( + msg.contains("max_tokens") || msg.contains("smaller steps"), + "msg: {msg}" + ); + assert!(msg.contains("cut off at:"), "msg: {msg}"); + } + + #[test] + fn test_truncation_error_message_malformed() { + // Malformed JSON that ends with } (not truncated, just broken). + // safely_parse_json should fail too, so truncation_error_message fires. + let malformed = r##"{"key": }"##; + let msg = + truncation_error_message(malformed).expect("malformed args should produce an error"); + assert!(msg.contains("malformed"), "msg: {msg}"); + } } diff --git a/crates/goose-sdk-types/src/custom_requests.rs b/crates/goose-sdk-types/src/custom_requests.rs index 89061ba33e1f..1031eccedd4a 100644 --- a/crates/goose-sdk-types/src/custom_requests.rs +++ b/crates/goose-sdk-types/src/custom_requests.rs @@ -1,4 +1,4 @@ -use agent_client_protocol::schema::{ContentBlock, McpServer, SessionInfo}; +use agent_client_protocol::schema::{AvailableCommand, ContentBlock, McpServer, SessionInfo}; use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -6,6 +6,8 @@ use std::collections::HashMap; mod recipe; pub use recipe::*; +mod schedule; +pub use schedule::*; /// Schema descriptor for a single custom method, produced by the /// `#[custom_methods]` macro's generated `custom_method_schemas()` function. @@ -1134,6 +1136,58 @@ pub struct ListSourcesResponse { pub sources: Vec, } +/// A user-facing `@` mention target backed by an agent, recipe, or subrecipe source. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AgentMention { + pub name: String, + pub description: String, + pub source_type: SourceType, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_path: Option, + pub mention: String, +} + +/// List user-facing agent mention targets for `@` autocomplete. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/agent-mentions/list", + response = ListAgentMentionsResponse +)] +#[serde(rename_all = "camelCase")] +pub struct ListAgentMentionsRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct ListAgentMentionsResponse { + pub agents: Vec, +} + +/// List slash commands available for `/` autocomplete. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/slash-commands/list", + response = ListSlashCommandsResponse +)] +#[serde(rename_all = "camelCase")] +pub struct ListSlashCommandsRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct ListSlashCommandsResponse { + pub available_commands: Vec, +} + /// Update an existing source's name, description, and content by absolute path. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/unstable/sources/update", response = UpdateSourceResponse)] diff --git a/crates/goose-sdk-types/src/custom_requests/schedule.rs b/crates/goose-sdk-types/src/custom_requests/schedule.rs new file mode 100644 index 000000000000..1ec1d9192636 --- /dev/null +++ b/crates/goose-sdk-types/src/custom_requests/schedule.rs @@ -0,0 +1,169 @@ +use agent_client_protocol::schema::SessionInfo; +use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::{EmptyResponse, RecipeDto}; + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ScheduledJobDto { + pub id: String, + pub source: String, + pub cron: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_run: Option, + pub currently_running: bool, + pub paused: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub job_start_time: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/schedules/list", + response = ListSchedulesResponse +)] +pub struct ListSchedulesRequest {} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct ListSchedulesResponse { + pub jobs: Vec, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/schedules/create", + response = CreateScheduleResponse +)] +pub struct CreateScheduleRequest { + pub id: String, + pub recipe: RecipeDto, + pub cron: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct CreateScheduleResponse { + pub job: ScheduledJobDto, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/schedules/delete", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct DeleteScheduleRequest { + pub schedule_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/schedules/update", + response = UpdateScheduleResponse +)] +#[serde(rename_all = "camelCase")] +pub struct UpdateScheduleRequest { + pub schedule_id: String, + pub cron: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct UpdateScheduleResponse { + pub job: ScheduledJobDto, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/schedules/run-now", + response = RunScheduleNowResponse +)] +#[serde(rename_all = "camelCase")] +pub struct RunScheduleNowRequest { + pub schedule_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct RunScheduleNowResponse { + pub status: RunScheduleNowStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum RunScheduleNowStatus { + #[default] + Completed, + Cancelled, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/schedules/sessions/list", + response = ListScheduleSessionsResponse +)] +#[serde(rename_all = "camelCase")] +pub struct ListScheduleSessionsRequest { + pub schedule_id: String, + pub limit: usize, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct ListScheduleSessionsResponse { + pub sessions: Vec, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/schedules/pause", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct PauseScheduleRequest { + pub schedule_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/schedules/unpause", + response = EmptyResponse +)] +#[serde(rename_all = "camelCase")] +pub struct UnpauseScheduleRequest { + pub schedule_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/schedules/running-job/kill", + response = KillRunningJobResponse +)] +#[serde(rename_all = "camelCase")] +pub struct KillRunningJobRequest { + pub job_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct KillRunningJobResponse { + pub message: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/schedules/running-job/inspect", + response = InspectRunningJobResponse +)] +#[serde(rename_all = "camelCase")] +pub struct InspectRunningJobRequest { + pub job_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct InspectRunningJobResponse { + pub running: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub job_start_time: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub running_duration_seconds: Option, +} diff --git a/crates/goose-server/Cargo.toml b/crates/goose-server/Cargo.toml index dd4304798a63..ab25ab25777a 100644 --- a/crates/goose-server/Cargo.toml +++ b/crates/goose-server/Cargo.toml @@ -85,7 +85,6 @@ url = { workspace = true } rand = { workspace = true } hex = { version = "0.4.3", default-features = false, features = ["std"] } socket2 = { version = "0.6", default-features = false } -fs2 = { workspace = true } rustls = { workspace = true, optional = true } uuid = { workspace = true } rcgen = { version = "0.14", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] } diff --git a/crates/goose-server/src/commands/agent.rs b/crates/goose-server/src/commands/agent.rs index 0a255daced2b..1a4d6f0fa4c6 100644 --- a/crates/goose-server/src/commands/agent.rs +++ b/crates/goose-server/src/commands/agent.rs @@ -37,8 +37,8 @@ async fn shutdown_signal() { } pub async fn run() -> Result<()> { - // Install the rustls crypto provider early, before any spawned tasks (tunnel, - // gateways, etc.) try to open TLS connections. Both `ring` and `aws-lc-rs` + // Install the rustls crypto provider early, before any spawned tasks (tunnel, etc.) + // try to open TLS connections. Both `ring` and `aws-lc-rs` // features are enabled on rustls (via different transitive deps), so rustls // cannot auto-detect a provider — we must pick one explicitly. #[cfg(feature = "rustls-tls")] @@ -55,13 +55,6 @@ pub async fn run() -> Result<()> { boot_marker("appstate init start"); let app_state = state::AppState::new(settings.tls).await?; - // Share the server secret with the tunnel manager so it uses the same - // key for forwarded requests, without mutating the process environment. - app_state - .tunnel_manager - .set_server_secret(secret_key.clone()) - .await; - let cors = CorsLayer::new() .allow_origin(Any) .allow_methods(Any) @@ -93,16 +86,6 @@ pub async fn run() -> Result<()> { let addr = settings.socket_addr(); - let tunnel_manager = app_state.tunnel_manager.clone(); - tokio::spawn(async move { - tunnel_manager.check_auto_start().await; - }); - - let gateway_manager = app_state.gateway_manager.clone(); - tokio::spawn(async move { - gateway_manager.check_auto_start().await; - }); - if settings.tls { #[cfg(any(feature = "rustls-tls", feature = "native-tls"))] { diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 9e2ef4d864dd..360f8652fcce 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -476,8 +476,6 @@ derive_utoipa!(IconTheme as IconThemeSchema); super::routes::setup::start_openrouter_setup, super::routes::setup::start_tetrate_setup, super::routes::setup::start_nanogpt_setup, - super::routes::tunnel::start_tunnel, - super::routes::tunnel::stop_tunnel, super::routes::tunnel::get_tunnel_status, super::routes::telemetry::send_telemetry_event, super::routes::dictation::transcribe_dictation, diff --git a/crates/goose-server/src/routes/gateway.rs b/crates/goose-server/src/routes/gateway.rs deleted file mode 100644 index e49299ae4b83..000000000000 --- a/crates/goose-server/src/routes/gateway.rs +++ /dev/null @@ -1,225 +0,0 @@ -use crate::routes::errors::ErrorResponse; -use crate::state::AppState; -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::{IntoResponse, Response}, - routing::{delete, get, post}, - Json, Router, -}; -use goose::gateway::manager::GatewayStatus; -use goose::gateway::GatewayConfig; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use utoipa::ToSchema; - -#[derive(Deserialize, ToSchema)] -pub struct StartGatewayRequest { - pub gateway_type: String, - pub platform_config: serde_json::Value, - #[serde(default)] - pub max_sessions: usize, -} - -#[derive(Deserialize, ToSchema)] -pub struct StopGatewayRequest { - pub gateway_type: String, -} - -#[derive(Deserialize, ToSchema)] -pub struct RestartGatewayRequest { - pub gateway_type: String, -} - -#[derive(Deserialize, ToSchema)] -pub struct RemoveGatewayRequest { - pub gateway_type: String, -} - -#[derive(Deserialize, ToSchema)] -pub struct CreatePairingRequest { - pub gateway_type: String, -} - -#[derive(Serialize, ToSchema)] -pub struct PairingCodeResponse { - pub code: String, - pub expires_at: i64, -} - -#[utoipa::path( - post, - path = "/gateway/start", - request_body = StartGatewayRequest, - responses( - (status = 200, description = "Gateway started"), - (status = 400, description = "Bad request", body = ErrorResponse), - (status = 500, description = "Internal server error", body = ErrorResponse) - ) -)] -pub async fn start_gateway( - State(state): State>, - Json(request): Json, -) -> Response { - let mut config = GatewayConfig { - gateway_type: request.gateway_type, - platform_config: request.platform_config, - max_sessions: request.max_sessions, - }; - - let gw = match goose::gateway::create_gateway(&mut config) { - Ok(gw) => gw, - Err(e) => return ErrorResponse::bad_request(e.to_string()).into_response(), - }; - - match state.gateway_manager.start_gateway(config, gw).await { - Ok(()) => StatusCode::OK.into_response(), - Err(e) => ErrorResponse::bad_request(e.to_string()).into_response(), - } -} - -#[utoipa::path( - post, - path = "/gateway/stop", - request_body = StopGatewayRequest, - responses( - (status = 200, description = "Gateway stopped"), - (status = 404, description = "Gateway not found", body = ErrorResponse) - ) -)] -pub async fn stop_gateway( - State(state): State>, - Json(request): Json, -) -> Response { - match state - .gateway_manager - .stop_gateway(&request.gateway_type) - .await - { - Ok(()) => StatusCode::OK.into_response(), - Err(e) => ErrorResponse::not_found(e.to_string()).into_response(), - } -} - -#[utoipa::path( - post, - path = "/gateway/restart", - request_body = RestartGatewayRequest, - responses( - (status = 200, description = "Gateway restarted"), - (status = 400, description = "Bad request", body = ErrorResponse), - (status = 404, description = "No saved config", body = ErrorResponse) - ) -)] -pub async fn restart_gateway( - State(state): State>, - Json(request): Json, -) -> Response { - match state - .gateway_manager - .restart_gateway(&request.gateway_type) - .await - { - Ok(()) => StatusCode::OK.into_response(), - Err(e) => ErrorResponse::bad_request(e.to_string()).into_response(), - } -} - -#[utoipa::path( - post, - path = "/gateway/remove", - request_body = RemoveGatewayRequest, - responses( - (status = 200, description = "Gateway removed"), - (status = 500, description = "Internal server error", body = ErrorResponse) - ) -)] -pub async fn remove_gateway( - State(state): State>, - Json(request): Json, -) -> Response { - match state - .gateway_manager - .remove_gateway(&request.gateway_type) - .await - { - Ok(()) => StatusCode::OK.into_response(), - Err(e) => ErrorResponse::internal(e.to_string()).into_response(), - } -} - -#[utoipa::path( - get, - path = "/gateway/status", - responses( - (status = 200, description = "Gateway statuses", body = Vec) - ) -)] -pub async fn gateway_status(State(state): State>) -> Json> { - Json(state.gateway_manager.status().await) -} - -#[utoipa::path( - post, - path = "/gateway/pair", - request_body = CreatePairingRequest, - responses( - (status = 200, description = "Pairing code generated", body = PairingCodeResponse), - (status = 500, description = "Internal server error", body = ErrorResponse) - ) -)] -pub async fn create_pairing_code( - State(state): State>, - Json(request): Json, -) -> Response { - match state - .gateway_manager - .generate_pairing_code(&request.gateway_type) - .await - { - Ok((code, expires_at)) => ( - StatusCode::OK, - Json(PairingCodeResponse { code, expires_at }), - ) - .into_response(), - Err(e) => ErrorResponse::internal(e.to_string()).into_response(), - } -} - -#[utoipa::path( - delete, - path = "/gateway/pair/{platform}/{user_id}", - params( - ("platform" = String, Path, description = "Platform name"), - ("user_id" = String, Path, description = "Platform user ID") - ), - responses( - (status = 200, description = "User unpaired"), - (status = 404, description = "Pairing not found", body = ErrorResponse) - ) -)] -pub async fn unpair_user( - State(state): State>, - Path((platform, user_id)): Path<(String, String)>, -) -> Response { - match state.gateway_manager.unpair_user(&platform, &user_id).await { - Ok(true) => StatusCode::OK.into_response(), - Ok(false) => { - ErrorResponse::not_found(format!("No pairing found for {}/{}", platform, user_id)) - .into_response() - } - Err(e) => ErrorResponse::internal(e.to_string()).into_response(), - } -} - -pub fn routes(state: Arc) -> Router { - Router::new() - .route("/gateway/start", post(start_gateway)) - .route("/gateway/stop", post(stop_gateway)) - .route("/gateway/restart", post(restart_gateway)) - .route("/gateway/remove", post(remove_gateway)) - .route("/gateway/status", get(gateway_status)) - .route("/gateway/pair", post(create_pairing_code)) - .route("/gateway/pair/{platform}/{user_id}", delete(unpair_user)) - .with_state(state) -} diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs index 00777d80acc4..13b39236537c 100644 --- a/crates/goose-server/src/routes/mod.rs +++ b/crates/goose-server/src/routes/mod.rs @@ -4,7 +4,6 @@ pub mod config_management; pub mod dictation; pub mod errors; pub mod features; -pub mod gateway; #[cfg(feature = "local-inference")] pub mod local_inference; pub mod mcp_app_proxy; @@ -42,7 +41,6 @@ pub fn configure(state: Arc, secret_key: String) -> Rout .merge(setup::routes(state.clone())) .merge(telemetry::routes(state.clone())) .merge(tunnel::routes(state.clone())) - .merge(gateway::routes(state.clone())) .merge(mcp_ui_proxy::routes(secret_key.clone())) .merge(mcp_app_proxy::routes(secret_key)) .merge(session_events::routes(state.clone())) diff --git a/crates/goose-server/src/routes/tunnel.rs b/crates/goose-server/src/routes/tunnel.rs index d84aff4afe53..023c3aba46e0 100644 --- a/crates/goose-server/src/routes/tunnel.rs +++ b/crates/goose-server/src/routes/tunnel.rs @@ -3,58 +3,10 @@ use axum::{ extract::State, http::StatusCode, response::{IntoResponse, Response}, - routing::{get, post}, + routing::get, Json, Router, }; -use serde::Serialize; use std::sync::Arc; -use utoipa::ToSchema; - -#[derive(Debug, Serialize, ToSchema)] -pub struct ErrorResponse { - pub error: String, -} - -/// Start the tunnel -#[utoipa::path( - post, - path = "/tunnel/start", - responses( - (status = 200, description = "Tunnel started successfully", body = TunnelInfo), - (status = 400, description = "Bad request", body = ErrorResponse), - (status = 500, description = "Internal server error", body = ErrorResponse) - ) -)] -#[axum::debug_handler] -pub async fn start_tunnel(State(state): State>) -> Response { - match state.tunnel_manager.start().await { - Ok(info) => (StatusCode::OK, Json(info)).into_response(), - Err(e) => { - tracing::error!("Failed to start tunnel: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: e.to_string(), - }), - ) - .into_response() - } - } -} - -/// Stop the tunnel -#[utoipa::path( - post, - path = "/tunnel/stop", - responses( - (status = 200, description = "Tunnel stopped successfully"), - (status = 500, description = "Internal server error", body = ErrorResponse) - ) -)] -pub async fn stop_tunnel(State(state): State>) -> Response { - state.tunnel_manager.stop(true).await; - StatusCode::OK.into_response() -} /// Get tunnel info #[utoipa::path( @@ -71,8 +23,6 @@ pub async fn get_tunnel_status(State(state): State>) -> Response { pub fn routes(state: Arc) -> Router { Router::new() - .route("/tunnel/start", post(start_tunnel)) - .route("/tunnel/stop", post(stop_tunnel)) .route("/tunnel/status", get(get_tunnel_status)) .with_state(state) } diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index 3543d051ee6c..8fd5934037cb 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -14,7 +14,6 @@ use tokio::task::JoinHandle; use crate::session_event_bus::SessionEventBus; use crate::tunnel::TunnelManager; use goose::agents::ExtensionLoadResult; -use goose::gateway::manager::GatewayManager; #[cfg(feature = "local-inference")] use goose::providers::local_inference::InferenceRuntime; @@ -27,7 +26,6 @@ pub struct AppState { pub recipe_file_hash_map: Arc>>, recipe_session_tracker: Arc>>, pub tunnel_manager: Arc, - pub gateway_manager: Arc, pub extension_loading_tasks: ExtensionLoadingTasks, #[cfg(feature = "local-inference")] inference_runtime: Arc>>, @@ -40,14 +38,12 @@ impl AppState { let agent_manager = AgentManager::instance().await?; let tunnel_manager = Arc::new(TunnelManager::new(tls)); - let gateway_manager = Arc::new(GatewayManager::new(agent_manager.clone())?); Ok(Arc::new(Self { agent_manager, recipe_file_hash_map: Arc::new(Mutex::new(HashMap::new())), recipe_session_tracker: Arc::new(Mutex::new(HashSet::new())), tunnel_manager, - gateway_manager, extension_loading_tasks: Arc::new(Mutex::new(HashMap::new())), #[cfg(feature = "local-inference")] inference_runtime: Arc::new(OnceLock::new()), diff --git a/crates/goose-server/src/tunnel/mod.rs b/crates/goose-server/src/tunnel/mod.rs index 4e75f0304538..2b28d362abff 100644 --- a/crates/goose-server/src/tunnel/mod.rs +++ b/crates/goose-server/src/tunnel/mod.rs @@ -1,67 +1,6 @@ -pub mod lapstone; - -use crate::configuration::Settings; -use fs2::FileExt as _; -use goose::config::{paths::Paths, Config}; use serde::{Deserialize, Serialize}; -use std::fs::{File, OpenOptions}; -use std::io::Write; -use std::sync::Arc; -use tokio::sync::{mpsc, RwLock}; use utoipa::ToSchema; -fn get_server_port() -> anyhow::Result { - let settings = Settings::new()?; - Ok(settings.port) -} - -fn get_lock_path() -> std::path::PathBuf { - Paths::config_dir().join("tunnel.lock") -} - -fn try_acquire_tunnel_lock() -> anyhow::Result { - let lock_path = get_lock_path(); - - if let Some(parent) = lock_path.parent() { - std::fs::create_dir_all(parent)?; - } - - let mut file = OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(&lock_path)?; - - file.try_lock_exclusive() - .map_err(|_| anyhow::anyhow!("Another goose instance is already running the tunnel"))?; - - writeln!(file, "{}", std::process::id())?; - file.sync_all()?; - - Ok(file) -} - -fn is_tunnel_locked_by_another() -> bool { - let lock_path = get_lock_path(); - - let file = match OpenOptions::new() - .write(true) - .create(true) - .truncate(false) - .open(&lock_path) - { - Ok(f) => f, - Err(_) => return false, - }; - - if file.try_lock_exclusive().is_err() { - return true; - } - - // Lock released when file is dropped - false -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, ToSchema)] #[serde(rename_all = "lowercase")] pub enum TunnelState { @@ -81,16 +20,7 @@ pub struct TunnelInfo { pub secret: String, } -pub struct TunnelManager { - state: Arc>, - info: Arc>>, - lapstone_handle: Arc>>>, - restart_tx: Arc>>>, - watchdog_handle: Arc>>>, - lock_file: Arc>>, - scheme: String, - server_secret: Arc>>, -} +pub struct TunnelManager; impl Default for TunnelManager { fn default() -> Self { @@ -99,55 +29,8 @@ impl Default for TunnelManager { } impl TunnelManager { - pub fn new(tls: bool) -> Self { - TunnelManager { - state: Arc::new(RwLock::new(TunnelState::Idle)), - info: Arc::new(RwLock::new(None)), - lapstone_handle: Arc::new(RwLock::new(None)), - restart_tx: Arc::new(RwLock::new(None)), - watchdog_handle: Arc::new(RwLock::new(None)), - lock_file: Arc::new(std::sync::Mutex::new(None)), - scheme: if tls { "https" } else { "http" }.to_string(), - server_secret: Arc::new(RwLock::new(None)), - } - } - - fn get_auto_start() -> bool { - Config::global() - .get_param("tunnel_auto_start") - .unwrap_or(false) - } - - fn get_secret() -> Option { - Config::global().get_secret("tunnel_secret").ok() - } - - fn get_agent_id() -> Option { - Config::global().get_secret("tunnel_agent_id").ok() - } - - pub async fn check_auto_start(&self) { - let auto_start = Self::get_auto_start(); - let state = self.state.read().await.clone(); - - if auto_start && state == TunnelState::Idle { - if is_tunnel_locked_by_another() { - tracing::info!( - "Tunnel already running on another goose instance, skipping auto-start" - ); - return; - } - - tracing::info!("Auto-starting tunnel"); - match self.start().await { - Ok(info) => { - tracing::info!("Tunnel auto-started successfully: {:?}", info.url); - } - Err(e) => { - tracing::info!("Tunnel auto-start skipped: {}", e); - } - } - } + pub fn new(_tls: bool) -> Self { + TunnelManager } fn is_tunnel_disabled() -> bool { @@ -160,210 +43,15 @@ impl TunnelManager { } pub async fn get_info(&self) -> TunnelInfo { - if Self::is_tunnel_disabled() { - return TunnelInfo { - state: TunnelState::Disabled, - url: String::new(), - hostname: String::new(), - secret: String::new(), - }; - } - - let state = self.state.read().await.clone(); - let info = self.info.read().await.clone(); - - match info { - Some(mut tunnel_info) => { - tunnel_info.state = state; - tunnel_info - } - None => { - let effective_state = if state == TunnelState::Idle && is_tunnel_locked_by_another() - { - TunnelState::Running - } else { - state - }; - TunnelInfo { - state: effective_state, - url: String::new(), - hostname: String::new(), - secret: String::new(), - } - } - } - } - - pub async fn set_server_secret(&self, secret: String) { - *self.server_secret.write().await = Some(secret); - } - - pub fn set_auto_start(auto_start: bool) -> anyhow::Result<()> { - Config::global() - .set_param("tunnel_auto_start", auto_start) - .map_err(|e| anyhow::anyhow!("Failed to save tunnel config: {}", e)) - } - - pub fn set_secret(secret: &str) -> anyhow::Result<()> { - Config::global() - .set_secret("tunnel_secret", &secret.to_string()) - .map_err(|e| anyhow::anyhow!("Failed to save tunnel secret: {}", e)) - } - - pub fn set_agent_id(agent_id: &str) -> anyhow::Result<()> { - Config::global() - .set_secret("tunnel_agent_id", &agent_id.to_string()) - .map_err(|e| anyhow::anyhow!("Failed to save tunnel agent_id: {}", e)) - } - - async fn start_tunnel_internal(&self) -> anyhow::Result<(TunnelInfo, mpsc::Receiver<()>)> { - let server_port = get_server_port()?; - let tunnel_secret = Self::get_secret().unwrap_or_else(generate_secret); - let server_secret = self - .server_secret - .read() - .await - .clone() - .expect("server_secret must be set before starting tunnel"); - let agent_id = Self::get_agent_id().unwrap_or_else(generate_agent_id); - - Self::set_secret(&tunnel_secret)?; - Self::set_agent_id(&agent_id)?; - - let (restart_tx, restart_rx) = mpsc::channel::<()>(1); - *self.restart_tx.write().await = Some(restart_tx.clone()); - - let result = lapstone::start( - server_port, - tunnel_secret, - server_secret, - agent_id, - &self.scheme, - self.lapstone_handle.clone(), - restart_tx, - ) - .await; - - match result { - Ok(info) => Ok((info, restart_rx)), - Err(e) => Err(e), - } - } - - pub async fn start(&self) -> anyhow::Result { - if Self::is_tunnel_disabled() { - anyhow::bail!("Tunnel is disabled via GOOSE_TUNNEL environment variable"); - } - - let mut state = self.state.write().await; - if *state != TunnelState::Idle { - anyhow::bail!("Tunnel is already running or starting"); - } - - let lock = try_acquire_tunnel_lock()?; - *self.lock_file.lock().unwrap() = Some(lock); - - *state = TunnelState::Starting; - drop(state); - - match self.start_tunnel_internal().await { - Ok((info, mut restart_rx)) => { - *self.state.write().await = TunnelState::Running; - *self.info.write().await = Some(info.clone()); - let _ = Self::set_auto_start(true); - - let state = self.state.clone(); - let lapstone_handle = self.lapstone_handle.clone(); - let watchdog_handle_arc = self.watchdog_handle.clone(); - let manager = Arc::new(self.clone_for_watchdog()); - - let watchdog = tokio::spawn(async move { - while restart_rx.recv().await.is_some() { - let auto_start = Self::get_auto_start(); - if !auto_start { - tracing::info!("Tunnel connection lost but auto_start is disabled"); - break; - } - - tracing::warn!("Tunnel connection lost, initiating restart..."); - lapstone::stop(lapstone_handle.clone()).await; - *state.write().await = TunnelState::Idle; - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - *state.write().await = TunnelState::Starting; - - match manager.start_tunnel_internal().await { - Ok((_, new_restart_rx)) => { - *state.write().await = TunnelState::Running; - tracing::info!("Tunnel restarted successfully"); - restart_rx = new_restart_rx; - } - Err(e) => { - tracing::error!("Failed to restart tunnel: {}", e); - *state.write().await = TunnelState::Error; - break; - } - } - } - }); - - *watchdog_handle_arc.write().await = Some(watchdog); - - Ok(info) - } - Err(e) => { - self.release_lock(); - *self.state.write().await = TunnelState::Error; - Err(e) - } - } - } - - fn clone_for_watchdog(&self) -> Self { - TunnelManager { - state: self.state.clone(), - info: self.info.clone(), - lapstone_handle: self.lapstone_handle.clone(), - restart_tx: self.restart_tx.clone(), - watchdog_handle: self.watchdog_handle.clone(), - lock_file: self.lock_file.clone(), - scheme: self.scheme.clone(), - server_secret: self.server_secret.clone(), + TunnelInfo { + state: if Self::is_tunnel_disabled() { + TunnelState::Disabled + } else { + TunnelState::Idle + }, + url: String::new(), + hostname: String::new(), + secret: String::new(), } } - - fn release_lock(&self) { - if let Ok(mut guard) = self.lock_file.lock() { - // Dropping the file releases the lock - guard.take(); - } - } - - pub async fn stop(&self, clear_auto_start: bool) { - if let Some(handle) = self.watchdog_handle.write().await.take() { - handle.abort(); - } - - *self.restart_tx.write().await = None; - - lapstone::stop(self.lapstone_handle.clone()).await; - - self.release_lock(); - - *self.state.write().await = TunnelState::Idle; - *self.info.write().await = None; - - if clear_auto_start { - let _ = Self::set_auto_start(false); - } - } -} - -fn generate_secret() -> String { - let bytes: [u8; 32] = rand::random(); - hex::encode(bytes) -} - -pub(super) fn generate_agent_id() -> String { - let bytes: [u8; 32] = rand::random(); - hex::encode(bytes) } diff --git a/crates/goose/acp-meta.json b/crates/goose/acp-meta.json index f0efcda0fb45..05425f540a52 100644 --- a/crates/goose/acp-meta.json +++ b/crates/goose/acp-meta.json @@ -250,6 +250,56 @@ "requestType": "RecipeToYamlRequest_unstable", "responseType": "RecipeToYamlResponse_unstable" }, + { + "method": "_goose/unstable/schedules/list", + "requestType": "ListSchedulesRequest_unstable", + "responseType": "ListSchedulesResponse_unstable" + }, + { + "method": "_goose/unstable/schedules/sessions/list", + "requestType": "ListScheduleSessionsRequest_unstable", + "responseType": "ListScheduleSessionsResponse_unstable" + }, + { + "method": "_goose/unstable/schedules/create", + "requestType": "CreateScheduleRequest_unstable", + "responseType": "CreateScheduleResponse_unstable" + }, + { + "method": "_goose/unstable/schedules/delete", + "requestType": "DeleteScheduleRequest_unstable", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/unstable/schedules/pause", + "requestType": "PauseScheduleRequest_unstable", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/unstable/schedules/unpause", + "requestType": "UnpauseScheduleRequest_unstable", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/unstable/schedules/update", + "requestType": "UpdateScheduleRequest_unstable", + "responseType": "UpdateScheduleResponse_unstable" + }, + { + "method": "_goose/unstable/schedules/run-now", + "requestType": "RunScheduleNowRequest_unstable", + "responseType": "RunScheduleNowResponse_unstable" + }, + { + "method": "_goose/unstable/schedules/running-job/kill", + "requestType": "KillRunningJobRequest_unstable", + "responseType": "KillRunningJobResponse_unstable" + }, + { + "method": "_goose/unstable/schedules/running-job/inspect", + "requestType": "InspectRunningJobRequest_unstable", + "responseType": "InspectRunningJobResponse_unstable" + }, { "method": "_goose/unstable/session/info", "requestType": "GetSessionInfoRequest_unstable", @@ -290,6 +340,16 @@ "requestType": "ListSourcesRequest_unstable", "responseType": "ListSourcesResponse_unstable" }, + { + "method": "_goose/unstable/agent-mentions/list", + "requestType": "ListAgentMentionsRequest_unstable", + "responseType": "ListAgentMentionsResponse_unstable" + }, + { + "method": "_goose/unstable/slash-commands/list", + "requestType": "ListSlashCommandsRequest_unstable", + "responseType": "ListSlashCommandsResponse_unstable" + }, { "method": "_goose/unstable/sources/update", "requestType": "UpdateSourceRequest_unstable", diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index 9124a9a363ef..35b6951a1b1f 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -3520,32 +3520,105 @@ "x-side": "agent", "x-method": "_goose/unstable/recipes/to-yaml" }, - "GetSessionInfoRequest_unstable": { + "ListSchedulesRequest_unstable": { + "type": "object", + "x-side": "agent", + "x-method": "_goose/unstable/schedules/list" + }, + "ListSchedulesResponse_unstable": { "type": "object", "properties": { - "sessionId": { + "jobs": { + "type": "array", + "items": { + "$ref": "#/$defs/ScheduledJobDto" + } + } + }, + "required": [ + "jobs" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/list" + }, + "ScheduledJobDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "source": { "type": "string" + }, + "cron": { + "type": "string" + }, + "lastRun": { + "type": [ + "string", + "null" + ] + }, + "currentlyRunning": { + "type": "boolean" + }, + "paused": { + "type": "boolean" + }, + "currentSessionId": { + "type": [ + "string", + "null" + ] + }, + "jobStartTime": { + "type": [ + "string", + "null" + ] } }, "required": [ - "sessionId" + "id", + "source", + "cron", + "currentlyRunning", + "paused" + ] + }, + "ListScheduleSessionsRequest_unstable": { + "type": "object", + "properties": { + "scheduleId": { + "type": "string" + }, + "limit": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "scheduleId", + "limit" ], - "description": "Return list-style metadata for a single session without loading the conversation.", "x-side": "agent", - "x-method": "_goose/unstable/session/info" + "x-method": "_goose/unstable/schedules/sessions/list" }, - "GetSessionInfoResponse_unstable": { + "ListScheduleSessionsResponse_unstable": { "type": "object", "properties": { - "session": { - "$ref": "#/$defs/SessionInfo" + "sessions": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionInfo" + } } }, "required": [ - "session" + "sessions" ], "x-side": "agent", - "x-method": "_goose/unstable/session/info" + "x-method": "_goose/unstable/schedules/sessions/list" }, "SessionInfo": { "type": "object", @@ -3598,6 +3671,245 @@ "type": "string", "description": "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)" }, + "CreateScheduleRequest_unstable": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "recipe": { + "$ref": "#/$defs/RecipeDto" + }, + "cron": { + "type": "string" + } + }, + "required": [ + "id", + "recipe", + "cron" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/create" + }, + "CreateScheduleResponse_unstable": { + "type": "object", + "properties": { + "job": { + "$ref": "#/$defs/ScheduledJobDto" + } + }, + "required": [ + "job" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/create" + }, + "DeleteScheduleRequest_unstable": { + "type": "object", + "properties": { + "scheduleId": { + "type": "string" + } + }, + "required": [ + "scheduleId" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/delete" + }, + "PauseScheduleRequest_unstable": { + "type": "object", + "properties": { + "scheduleId": { + "type": "string" + } + }, + "required": [ + "scheduleId" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/pause" + }, + "UnpauseScheduleRequest_unstable": { + "type": "object", + "properties": { + "scheduleId": { + "type": "string" + } + }, + "required": [ + "scheduleId" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/unpause" + }, + "UpdateScheduleRequest_unstable": { + "type": "object", + "properties": { + "scheduleId": { + "type": "string" + }, + "cron": { + "type": "string" + } + }, + "required": [ + "scheduleId", + "cron" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/update" + }, + "UpdateScheduleResponse_unstable": { + "type": "object", + "properties": { + "job": { + "$ref": "#/$defs/ScheduledJobDto" + } + }, + "required": [ + "job" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/update" + }, + "RunScheduleNowRequest_unstable": { + "type": "object", + "properties": { + "scheduleId": { + "type": "string" + } + }, + "required": [ + "scheduleId" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/run-now" + }, + "RunScheduleNowResponse_unstable": { + "type": "object", + "properties": { + "status": { + "$ref": "#/$defs/RunScheduleNowStatus" + }, + "sessionId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "status" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/run-now" + }, + "RunScheduleNowStatus": { + "type": "string", + "enum": [ + "completed", + "cancelled" + ] + }, + "KillRunningJobRequest_unstable": { + "type": "object", + "properties": { + "jobId": { + "type": "string" + } + }, + "required": [ + "jobId" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/running-job/kill" + }, + "KillRunningJobResponse_unstable": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/running-job/kill" + }, + "InspectRunningJobRequest_unstable": { + "type": "object", + "properties": { + "jobId": { + "type": "string" + } + }, + "required": [ + "jobId" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/running-job/inspect" + }, + "InspectRunningJobResponse_unstable": { + "type": "object", + "properties": { + "running": { + "type": "boolean" + }, + "sessionId": { + "type": [ + "string", + "null" + ] + }, + "jobStartTime": { + "type": [ + "string", + "null" + ] + }, + "runningDurationSeconds": { + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "running" + ], + "x-side": "agent", + "x-method": "_goose/unstable/schedules/running-job/inspect" + }, + "GetSessionInfoRequest_unstable": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "description": "Return list-style metadata for a single session without loading the conversation.", + "x-side": "agent", + "x-method": "_goose/unstable/session/info" + }, + "GetSessionInfoResponse_unstable": { + "type": "object", + "properties": { + "session": { + "$ref": "#/$defs/SessionInfo" + } + }, + "required": [ + "session" + ], + "x-side": "agent", + "x-method": "_goose/unstable/session/info" + }, "TruncateSessionConversationRequest_unstable": { "type": "object", "properties": { @@ -3887,6 +4199,175 @@ "x-side": "agent", "x-method": "_goose/unstable/sources/list" }, + "ListAgentMentionsRequest_unstable": { + "type": "object", + "properties": { + "cwd": { + "type": [ + "string", + "null" + ] + }, + "sessionId": { + "type": [ + "string", + "null" + ] + } + }, + "description": "List user-facing agent mention targets for `@` autocomplete.", + "x-side": "agent", + "x-method": "_goose/unstable/agent-mentions/list" + }, + "ListAgentMentionsResponse_unstable": { + "type": "object", + "properties": { + "agents": { + "type": "array", + "items": { + "$ref": "#/$defs/AgentMention" + } + } + }, + "required": [ + "agents" + ], + "x-side": "agent", + "x-method": "_goose/unstable/agent-mentions/list" + }, + "AgentMention": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "sourceType": { + "$ref": "#/$defs/SourceType" + }, + "sourcePath": { + "type": [ + "string", + "null" + ] + }, + "mention": { + "type": "string" + } + }, + "required": [ + "name", + "description", + "sourceType", + "mention" + ], + "description": "A user-facing `@` mention target backed by an agent, recipe, or subrecipe source." + }, + "ListSlashCommandsRequest_unstable": { + "type": "object", + "properties": { + "cwd": { + "type": [ + "string", + "null" + ] + }, + "sessionId": { + "type": [ + "string", + "null" + ] + } + }, + "description": "List slash commands available for `/` autocomplete.", + "x-side": "agent", + "x-method": "_goose/unstable/slash-commands/list" + }, + "ListSlashCommandsResponse_unstable": { + "type": "object", + "properties": { + "availableCommands": { + "type": "array", + "items": { + "$ref": "#/$defs/AvailableCommand" + } + } + }, + "required": [ + "availableCommands" + ], + "x-side": "agent", + "x-method": "_goose/unstable/slash-commands/list" + }, + "AvailableCommand": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Command name (e.g., `create_plan`, `research_codebase`)." + }, + "description": { + "type": "string", + "description": "Human-readable description of what the command does." + }, + "input": { + "anyOf": [ + { + "$ref": "#/$defs/AvailableCommandInput" + }, + { + "type": "null" + } + ], + "description": "Input for the command if required" + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + } + }, + "required": [ + "name", + "description" + ], + "description": "Information about a command." + }, + "AvailableCommandInput": { + "anyOf": [ + { + "$ref": "#/$defs/UnstructuredCommandInput", + "description": "All text that was typed after the command name is provided as input." + } + ], + "description": "The input specification for a command." + }, + "UnstructuredCommandInput": { + "type": "object", + "properties": { + "hint": { + "type": "string", + "description": "A hint to display when the input hasn't been provided yet" + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + } + }, + "required": [ + "hint" + ], + "description": "All text that was typed after the command name is provided as input." + }, "UpdateSourceRequest_unstable": { "type": "object", "properties": { @@ -5026,6 +5507,96 @@ "description": "Params for _goose/unstable/recipes/to-yaml", "title": "RecipeToYamlRequest_unstable" }, + { + "allOf": [ + { + "$ref": "#/$defs/ListSchedulesRequest_unstable" + } + ], + "description": "Params for _goose/unstable/schedules/list", + "title": "ListSchedulesRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ListScheduleSessionsRequest_unstable" + } + ], + "description": "Params for _goose/unstable/schedules/sessions/list", + "title": "ListScheduleSessionsRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/CreateScheduleRequest_unstable" + } + ], + "description": "Params for _goose/unstable/schedules/create", + "title": "CreateScheduleRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DeleteScheduleRequest_unstable" + } + ], + "description": "Params for _goose/unstable/schedules/delete", + "title": "DeleteScheduleRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/PauseScheduleRequest_unstable" + } + ], + "description": "Params for _goose/unstable/schedules/pause", + "title": "PauseScheduleRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/UnpauseScheduleRequest_unstable" + } + ], + "description": "Params for _goose/unstable/schedules/unpause", + "title": "UnpauseScheduleRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/UpdateScheduleRequest_unstable" + } + ], + "description": "Params for _goose/unstable/schedules/update", + "title": "UpdateScheduleRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/RunScheduleNowRequest_unstable" + } + ], + "description": "Params for _goose/unstable/schedules/run-now", + "title": "RunScheduleNowRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/KillRunningJobRequest_unstable" + } + ], + "description": "Params for _goose/unstable/schedules/running-job/kill", + "title": "KillRunningJobRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/InspectRunningJobRequest_unstable" + } + ], + "description": "Params for _goose/unstable/schedules/running-job/inspect", + "title": "InspectRunningJobRequest_unstable" + }, { "allOf": [ { @@ -5098,6 +5669,24 @@ "description": "Params for _goose/unstable/sources/list", "title": "ListSourcesRequest_unstable" }, + { + "allOf": [ + { + "$ref": "#/$defs/ListAgentMentionsRequest_unstable" + } + ], + "description": "Params for _goose/unstable/agent-mentions/list", + "title": "ListAgentMentionsRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ListSlashCommandsRequest_unstable" + } + ], + "description": "Params for _goose/unstable/slash-commands/list", + "title": "ListSlashCommandsRequest_unstable" + }, { "allOf": [ { @@ -5534,6 +6123,62 @@ ], "title": "RecipeToYamlResponse_unstable" }, + { + "allOf": [ + { + "$ref": "#/$defs/ListSchedulesResponse_unstable" + } + ], + "title": "ListSchedulesResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ListScheduleSessionsResponse_unstable" + } + ], + "title": "ListScheduleSessionsResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/CreateScheduleResponse_unstable" + } + ], + "title": "CreateScheduleResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/UpdateScheduleResponse_unstable" + } + ], + "title": "UpdateScheduleResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/RunScheduleNowResponse_unstable" + } + ], + "title": "RunScheduleNowResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/KillRunningJobResponse_unstable" + } + ], + "title": "KillRunningJobResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/InspectRunningJobResponse_unstable" + } + ], + "title": "InspectRunningJobResponse_unstable" + }, { "allOf": [ { @@ -5558,6 +6203,22 @@ ], "title": "ListSourcesResponse_unstable" }, + { + "allOf": [ + { + "$ref": "#/$defs/ListAgentMentionsResponse_unstable" + } + ], + "title": "ListAgentMentionsResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ListSlashCommandsResponse_unstable" + } + ], + "title": "ListSlashCommandsResponse_unstable" + }, { "allOf": [ { diff --git a/crates/goose/src/acp/response_builder.rs b/crates/goose/src/acp/response_builder.rs index d307d911529c..725eeabc3aa0 100644 --- a/crates/goose/src/acp/response_builder.rs +++ b/crates/goose/src/acp/response_builder.rs @@ -2,6 +2,7 @@ use crate::agents::ExtensionLoadResult; use crate::config::{Config, GooseMode}; use crate::providers::inventory::{ProviderInventoryEntry, ProviderInventoryService}; use crate::session::Session; +use crate::slash_commands::types::{SlashCommandEntry, SlashCommandSource}; use agent_client_protocol::schema::{ AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, ModelId, ModelInfo, SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption, SessionId, @@ -11,6 +12,7 @@ use agent_client_protocol::schema::{ use agent_client_protocol::{Client, ConnectionTo}; use goose_providers::model::ModelConfig; use goose_providers::thinking::ThinkingEffort; +use serde::Serialize; use strum::{EnumMessage, VariantNames}; use super::server::{build_usage_updates, DEFAULT_PROVIDER_ID, DEFAULT_PROVIDER_LABEL}; @@ -22,60 +24,54 @@ pub(super) fn session_provider_selection(session: &Session) -> &str { .unwrap_or(DEFAULT_PROVIDER_ID) } -pub(super) fn session_meta(session: &Session) -> serde_json::Map { - let mut meta = serde_json::Map::new(); - meta.insert( - "messageCount".to_string(), - serde_json::Value::Number(session.message_count.into()), - ); - meta.insert( - "createdAt".to_string(), - serde_json::Value::String(session.created_at.to_rfc3339()), - ); - if let Some(ref archived_at) = session.archived_at { - meta.insert( - "archivedAt".to_string(), - serde_json::Value::String(archived_at.to_rfc3339()), - ); - } - meta.insert( - "userSetName".to_string(), - serde_json::Value::Bool(session.user_set_name), - ); - meta.insert( - "sessionType".to_string(), - serde_json::Value::String(session.session_type.to_string()), - ); - meta.insert( - "hasRecipe".to_string(), - serde_json::Value::Bool(session.recipe.is_some()), - ); +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SessionMeta<'a> { + message_count: usize, + created_at: chrono::DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + last_message_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + archived_at: Option>, + user_set_name: bool, + session_type: String, + has_recipe: bool, + #[serde(skip_serializing_if = "Option::is_none")] + project_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + provider_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + model_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + last_message_snippet: Option<&'a str>, +} - if let Some(ref pid) = session.project_id { - meta.insert( - "projectId".to_string(), - serde_json::Value::String(pid.clone()), - ); - } - if let Some(ref provider) = session.provider_name { - meta.insert( - "providerId".to_string(), - serde_json::Value::String(provider.clone()), - ); - } - if let Some(ref mc) = session.model_config { - meta.insert( - "modelId".to_string(), - serde_json::Value::String(mc.model_name.clone()), - ); +impl<'a> From<&'a Session> for SessionMeta<'a> { + fn from(session: &'a Session) -> Self { + Self { + message_count: session.message_count, + created_at: session.created_at, + last_message_at: session.last_message_at, + archived_at: session.archived_at, + user_set_name: session.user_set_name, + session_type: session.session_type.to_string(), + has_recipe: session.recipe.is_some(), + project_id: session.project_id.as_deref(), + provider_id: session.provider_name.as_deref(), + model_id: session + .model_config + .as_ref() + .map(|mc| mc.model_name.as_str()), + last_message_snippet: session.last_message_snippet.as_deref(), + } } - if let Some(ref snippet) = session.last_message_snippet { - meta.insert( - "lastMessageSnippet".to_string(), - serde_json::Value::String(snippet.clone()), - ); +} + +pub(super) fn session_meta(session: &Session) -> serde_json::Map { + match serde_json::to_value(SessionMeta::from(session)) { + Ok(serde_json::Value::Object(meta)) => meta, + _ => serde_json::Map::new(), } - meta } pub(super) fn session_response_meta( @@ -334,21 +330,54 @@ fn current_thinking_effort_value(model_config: &ModelConfig) -> String { } } -fn available_commands_update(working_dir: &std::path::Path) -> AvailableCommandsUpdate { - let commands = crate::slash_commands::slash_command::list_acp_commands(Some(working_dir)) +fn slash_command_meta(entry: &SlashCommandEntry) -> serde_json::Map { + let mut meta = serde_json::Map::new(); + let command_type = match entry.source { + SlashCommandSource::Builtin => "Builtin", + SlashCommandSource::Recipe => "Recipe", + SlashCommandSource::Skill => "Skill", + }; + meta.insert( + "commandType".to_string(), + serde_json::Value::String(command_type.to_string()), + ); + if let Some(source_path) = &entry.source_path { + meta.insert( + "sourcePath".to_string(), + serde_json::Value::String(source_path.clone()), + ); + } + meta +} + +fn slash_command_to_available_command(entry: SlashCommandEntry) -> AvailableCommand { + let meta = slash_command_meta(&entry); + let mut command = AvailableCommand::new(entry.name, entry.description); + if let Some(input_hint) = entry.input_hint { + command = command.input(AvailableCommandInput::Unstructured( + UnstructuredCommandInput::new(input_hint), + )); + } + command.meta(meta) +} + +pub(super) fn available_commands_for_working_dir( + working_dir: &std::path::Path, +) -> Vec { + available_commands_for_optional_working_dir(Some(working_dir)) +} + +pub(super) fn available_commands_for_optional_working_dir( + working_dir: Option<&std::path::Path>, +) -> Vec { + crate::slash_commands::slash_command::list_acp_commands(working_dir) .into_iter() - .map(|entry| { - let mut command = AvailableCommand::new(entry.name, entry.description); - if let Some(input_hint) = entry.input_hint { - command = command.input(AvailableCommandInput::Unstructured( - UnstructuredCommandInput::new(input_hint), - )); - } - command - }) - .collect(); + .map(slash_command_to_available_command) + .collect() +} - AvailableCommandsUpdate::new(commands) +fn available_commands_update(working_dir: &std::path::Path) -> AvailableCommandsUpdate { + AvailableCommandsUpdate::new(available_commands_for_working_dir(working_dir)) } pub(super) fn send_session_setup_notifications( @@ -468,6 +497,49 @@ mod tests { build_mode_state(current_mode) } + #[test] + fn test_slash_command_to_available_command_maps_core_fields_to_acp() { + let cases = [ + (SlashCommandSource::Builtin, "Builtin", None), + ( + SlashCommandSource::Recipe, + "Recipe", + Some("/tmp/release.yaml".to_string()), + ), + (SlashCommandSource::Skill, "Skill", None), + ]; + + for (source, expected_command_type, expected_source_path) in cases { + let command = slash_command_to_available_command(SlashCommandEntry { + name: "release".to_string(), + description: "Run release workflow".to_string(), + source, + source_path: expected_source_path.clone(), + input_hint: Some("[task]".to_string()), + }); + + assert_eq!(command.name, "release"); + assert_eq!(command.description, "Run release workflow"); + + match command.input.as_ref() { + Some(AvailableCommandInput::Unstructured(input)) => { + assert_eq!(input.hint, "[task]"); + } + other => panic!("unexpected command input: {other:?}"), + } + + let meta = command.meta.as_ref().expect("command _meta"); + let expected_command_type = serde_json::json!(expected_command_type); + assert_eq!(meta.get("commandType"), Some(&expected_command_type)); + if let Some(source_path) = expected_source_path { + let expected_source_path = serde_json::json!(source_path); + assert_eq!(meta.get("sourcePath"), Some(&expected_source_path)); + } else { + assert!(meta.get("sourcePath").is_none()); + } + } + } + #[test_case( build_mode_state(GooseMode::Auto).unwrap(), "openai", diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index c8e9f0fb5161..cd31af431650 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -82,6 +82,7 @@ use uuid::Uuid; mod agent_requests; pub use agent_requests::agent_request_schemas; +mod agent_mentions; mod config; mod custom_dispatch; mod diagnostics; @@ -98,6 +99,8 @@ mod onboarding; mod providers; mod recipe; mod resources; +mod schedule; +mod slash_commands; mod sources; mod tool_notifications; mod tools; diff --git a/crates/goose/src/acp/server/agent_mentions.rs b/crates/goose/src/acp/server/agent_mentions.rs new file mode 100644 index 000000000000..7f2f53b69ffc --- /dev/null +++ b/crates/goose/src/acp/server/agent_mentions.rs @@ -0,0 +1,119 @@ +use super::*; +use crate::session::Session; +use goose_sdk_types::custom_requests::{AgentMention, SourceEntry, SourceType}; +use std::collections::HashSet; +use std::path::PathBuf; + +fn add_session_subrecipes( + session: &Session, + sources: &mut Vec, + seen: &mut HashSet, +) { + let Some(sub_recipes) = session + .recipe + .as_ref() + .and_then(|recipe| recipe.sub_recipes.as_ref()) + else { + return; + }; + + for sub_recipe in sub_recipes { + if !seen.insert(sub_recipe.name.clone()) { + continue; + } + + sources.push(SourceEntry { + source_type: SourceType::Subrecipe, + name: sub_recipe.name.clone(), + description: sub_recipe.description.clone().unwrap_or_default(), + content: String::new(), + path: sub_recipe.path.clone(), + global: false, + writable: true, + supporting_files: Vec::new(), + properties: std::collections::HashMap::new(), + }); + } +} + +impl GooseAcpAgent { + pub(super) async fn on_list_agent_mentions( + &self, + req: ListAgentMentionsRequest, + ) -> Result { + let session = if let Some(session_id) = req + .session_id + .as_deref() + .map(str::trim) + .filter(|session_id| !session_id.is_empty()) + { + Some( + self.session_manager + .get_session(session_id, false) + .await + .map_err(|_| { + agent_client_protocol::Error::resource_not_found(Some( + session_id.to_string(), + )) + .data(format!("Session not found: {}", session_id)) + })?, + ) + } else { + None + }; + + let cwd = if let Some(cwd) = req + .cwd + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + { + PathBuf::from(cwd) + } else if let Some(session) = &session { + session.working_dir.clone() + } else { + return Err(agent_client_protocol::Error::invalid_params() + .data("Either cwd or sessionId is required")); + }; + + let filesystem_sources = + crate::agents::platform_extensions::summon::discover_filesystem_sources(&cwd); + let mut sources = Vec::new(); + let mut seen = HashSet::new(); + + if let Some(session) = &session { + add_session_subrecipes(session, &mut sources, &mut seen); + } + + for source in filesystem_sources { + if seen.insert(source.name.clone()) { + sources.push(source); + } + } + + let agents = sources + .into_iter() + .filter(|source| { + matches!( + source.source_type, + SourceType::Agent | SourceType::Recipe | SourceType::Subrecipe + ) && (matches!( + source.source_type, + SourceType::Recipe | SourceType::Subrecipe + ) || !source.content.is_empty()) + }) + .map(|source| { + let mention = format!("@{}", source.name); + AgentMention { + name: source.name, + description: source.description, + source_type: source.source_type, + source_path: (!source.path.is_empty()).then_some(source.path), + mention, + } + }) + .collect(); + + Ok(ListAgentMentionsResponse { agents }) + } +} diff --git a/crates/goose/src/acp/server/custom_dispatch.rs b/crates/goose/src/acp/server/custom_dispatch.rs index 7b10cc3178ca..8f5ff7bbd9bb 100644 --- a/crates/goose/src/acp/server/custom_dispatch.rs +++ b/crates/goose/src/acp/server/custom_dispatch.rs @@ -416,6 +416,86 @@ impl GooseAcpAgent { self.on_recipe_to_yaml(req).await } + #[custom_method(ListSchedulesRequest)] + async fn dispatch_list_schedules( + &self, + req: ListSchedulesRequest, + ) -> Result { + self.on_list_schedules(req).await + } + + #[custom_method(ListScheduleSessionsRequest)] + async fn dispatch_list_schedule_sessions( + &self, + req: ListScheduleSessionsRequest, + ) -> Result { + self.on_list_schedule_sessions(req).await + } + + #[custom_method(CreateScheduleRequest)] + async fn dispatch_create_schedule( + &self, + req: CreateScheduleRequest, + ) -> Result { + self.on_create_schedule(req).await + } + + #[custom_method(DeleteScheduleRequest)] + async fn dispatch_delete_schedule( + &self, + req: DeleteScheduleRequest, + ) -> Result { + self.on_delete_schedule(req).await + } + + #[custom_method(PauseScheduleRequest)] + async fn dispatch_pause_schedule( + &self, + req: PauseScheduleRequest, + ) -> Result { + self.on_pause_schedule(req).await + } + + #[custom_method(UnpauseScheduleRequest)] + async fn dispatch_unpause_schedule( + &self, + req: UnpauseScheduleRequest, + ) -> Result { + self.on_unpause_schedule(req).await + } + + #[custom_method(UpdateScheduleRequest)] + async fn dispatch_update_schedule( + &self, + req: UpdateScheduleRequest, + ) -> Result { + self.on_update_schedule(req).await + } + + #[custom_method(RunScheduleNowRequest)] + async fn dispatch_run_schedule_now( + &self, + req: RunScheduleNowRequest, + ) -> Result { + self.on_run_schedule_now(req).await + } + + #[custom_method(KillRunningJobRequest)] + async fn dispatch_kill_running_job( + &self, + req: KillRunningJobRequest, + ) -> Result { + self.on_kill_running_job(req).await + } + + #[custom_method(InspectRunningJobRequest)] + async fn dispatch_inspect_running_job( + &self, + req: InspectRunningJobRequest, + ) -> Result { + self.on_inspect_running_job(req).await + } + #[custom_method(GetSessionInfoRequest)] async fn dispatch_get_session_info( &self, @@ -480,6 +560,22 @@ impl GooseAcpAgent { self.on_list_sources(req).await } + #[custom_method(ListAgentMentionsRequest)] + async fn dispatch_list_agent_mentions( + &self, + req: ListAgentMentionsRequest, + ) -> Result { + self.on_list_agent_mentions(req).await + } + + #[custom_method(ListSlashCommandsRequest)] + async fn dispatch_list_slash_commands( + &self, + req: ListSlashCommandsRequest, + ) -> Result { + self.on_list_slash_commands(req).await + } + #[custom_method(UpdateSourceRequest)] async fn dispatch_update_source( &self, diff --git a/crates/goose/src/acp/server/list_sessions.rs b/crates/goose/src/acp/server/list_sessions.rs index 48e51f10e82c..b56e73b8da35 100644 --- a/crates/goose/src/acp/server/list_sessions.rs +++ b/crates/goose/src/acp/server/list_sessions.rs @@ -13,9 +13,10 @@ const ACP_SESSION_LIST_TYPES: [SessionType; 3] = #[derive(Debug, Serialize, Deserialize)] struct SessionListCursorToken { - updated_at: chrono::DateTime, - // Goose stores updated_at with second precision in common write paths, so the - // cursor needs the full (updated_at, id) sort key to avoid skipping tied rows. + #[serde(alias = "updated_at")] + sort_at: chrono::DateTime, + // Goose stores timestamps with second precision in common write paths, so the + // cursor needs the full (sort_at, id) sort key to avoid skipping tied rows. session_id: String, filter_hash: String, } @@ -146,7 +147,7 @@ fn decode_session_list_cursor( } Ok(Some(SessionListCursor { - updated_at: token.updated_at, + sort_at: token.sort_at, session_id: token.session_id, })) } @@ -158,7 +159,7 @@ fn encode_session_list_cursor( keyword: Option<&str>, ) -> Result { let token = SessionListCursorToken { - updated_at: cursor.updated_at, + sort_at: cursor.sort_at, session_id: cursor.session_id.clone(), filter_hash: session_list_filter_hash(cwd, session_types, keyword)?, }; diff --git a/crates/goose/src/acp/server/schedule.rs b/crates/goose/src/acp/server/schedule.rs new file mode 100644 index 000000000000..62b43c8ca70c --- /dev/null +++ b/crates/goose/src/acp/server/schedule.rs @@ -0,0 +1,343 @@ +use goose_sdk_types::custom_requests::{ + CreateScheduleRequest, CreateScheduleResponse, DeleteScheduleRequest, EmptyResponse, + InspectRunningJobRequest, InspectRunningJobResponse, KillRunningJobRequest, + KillRunningJobResponse, ListScheduleSessionsRequest, ListScheduleSessionsResponse, + ListSchedulesRequest, ListSchedulesResponse, PauseScheduleRequest, RunScheduleNowRequest, + RunScheduleNowResponse, RunScheduleNowStatus, ScheduledJobDto, UnpauseScheduleRequest, + UpdateScheduleRequest, UpdateScheduleResponse, +}; +use tokio::fs; + +use super::{build_session_info, GooseAcpAgent, ResultExt}; +use crate::recipe::validate_recipe::validate_recipe_template_from_content; +use crate::recipe::Recipe; +use crate::scheduler::{get_default_scheduled_recipes_dir, ScheduledJob, SchedulerError}; + +fn validate_schedule_id(id: &str) -> Result<(), agent_client_protocol::Error> { + let is_valid = !id.is_empty() + && id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ' '); + + if !is_valid { + return Err(agent_client_protocol::Error::invalid_params().data( + "Schedule name must use only alphanumeric characters, hyphens, underscores, or spaces", + )); + } + + Ok(()) +} + +fn validate_schedule_recipe(recipe: &Recipe) -> Result<(), agent_client_protocol::Error> { + let recipe_yaml = recipe + .to_yaml() + .map_err(|e| agent_client_protocol::Error::invalid_params().data(e.to_string()))?; + + validate_recipe_template_from_content(&recipe_yaml, None) + .map_err(|e| agent_client_protocol::Error::invalid_params().data(e.to_string()))?; + + Ok(()) +} + +fn schedule_not_found_or_internal(error: SchedulerError) -> agent_client_protocol::Error { + match error { + SchedulerError::JobNotFound(id) => { + agent_client_protocol::Error::resource_not_found(Some(id)) + } + error => agent_client_protocol::Error::internal_error().data(error.to_string()), + } +} + +fn create_schedule_error(error: SchedulerError) -> agent_client_protocol::Error { + match error { + SchedulerError::CronParseError(message) => agent_client_protocol::Error::invalid_params() + .data(format!("Invalid cron expression: {message}")), + SchedulerError::RecipeLoadError(message) => agent_client_protocol::Error::invalid_params() + .data(format!("Recipe load error: {message}")), + SchedulerError::JobIdExists(id) => agent_client_protocol::Error::invalid_params() + .data(format!("Job ID already exists: {id}")), + error => agent_client_protocol::Error::internal_error() + .data(format!("Error creating schedule: {error}")), + } +} + +fn schedule_state_error(error: SchedulerError) -> agent_client_protocol::Error { + match error { + SchedulerError::JobNotFound(id) => { + agent_client_protocol::Error::resource_not_found(Some(id)) + } + SchedulerError::AnyhowError(error) => { + agent_client_protocol::Error::invalid_params().data(error.to_string()) + } + error => agent_client_protocol::Error::internal_error().data(error.to_string()), + } +} + +fn update_schedule_error(error: SchedulerError) -> agent_client_protocol::Error { + match error { + SchedulerError::JobNotFound(id) => { + agent_client_protocol::Error::resource_not_found(Some(id)) + } + SchedulerError::AnyhowError(error) => { + agent_client_protocol::Error::invalid_params().data(error.to_string()) + } + SchedulerError::CronParseError(message) => agent_client_protocol::Error::invalid_params() + .data(format!("Invalid cron expression: {message}")), + error => agent_client_protocol::Error::internal_error().data(error.to_string()), + } +} + +fn run_schedule_now_error( + error: SchedulerError, +) -> Result { + match error { + SchedulerError::JobNotFound(id) => { + Err(agent_client_protocol::Error::resource_not_found(Some(id))) + } + SchedulerError::AnyhowError(error) + if error.to_string().contains("was successfully cancelled") => + { + Ok(RunScheduleNowResponse { + status: RunScheduleNowStatus::Cancelled, + session_id: None, + }) + } + error => Err(agent_client_protocol::Error::internal_error() + .data(format!("Error running schedule: {error}"))), + } +} + +fn scheduled_job_to_dto(job: ScheduledJob) -> ScheduledJobDto { + ScheduledJobDto { + id: job.id, + source: job.source, + cron: job.cron, + last_run: job.last_run.map(|value| value.to_rfc3339()), + currently_running: job.currently_running, + paused: job.paused, + current_session_id: job.current_session_id, + job_start_time: job.process_start_time.map(|value| value.to_rfc3339()), + } +} + +impl GooseAcpAgent { + pub(super) async fn on_list_schedules( + &self, + _req: ListSchedulesRequest, + ) -> Result { + let jobs = self + .agent_manager + .scheduler() + .list_scheduled_jobs() + .await + .into_iter() + .map(scheduled_job_to_dto) + .collect(); + + Ok(ListSchedulesResponse { jobs }) + } + + pub(super) async fn on_list_schedule_sessions( + &self, + req: ListScheduleSessionsRequest, + ) -> Result { + let sessions = self + .agent_manager + .scheduler() + .sessions(&req.schedule_id, req.limit) + .await + .internal_err_ctx("Failed to fetch schedule sessions")? + .into_iter() + .map(|(_, session)| build_session_info(session)) + .collect(); + + Ok(ListScheduleSessionsResponse { sessions }) + } + + pub(super) async fn on_create_schedule( + &self, + req: CreateScheduleRequest, + ) -> Result { + let id = req.id.trim().to_string(); + validate_schedule_id(&id)?; + + let recipe = Recipe::try_from(req.recipe).map_err(|e| { + agent_client_protocol::Error::invalid_params().data(format!("recipe: {e}")) + })?; + + if recipe.check_for_security_warnings() { + return Err(agent_client_protocol::Error::invalid_params().data( + "This recipe contains hidden characters that could be malicious. Please remove them before trying to save.", + )); + } + validate_schedule_recipe(&recipe)?; + + let scheduled_recipes_dir = get_default_scheduled_recipes_dir().map_err(|e| { + agent_client_protocol::Error::internal_error() + .data(format!("Failed to get scheduled recipes directory: {e}")) + })?; + + let recipe_path = scheduled_recipes_dir.join(format!("{id}.yaml")); + let yaml_content = recipe.to_yaml().map_err(|e| { + agent_client_protocol::Error::internal_error() + .data(format!("Failed to convert recipe to YAML: {e}")) + })?; + fs::write(&recipe_path, yaml_content).await.map_err(|e| { + agent_client_protocol::Error::internal_error() + .data(format!("Failed to save recipe file: {e}")) + })?; + + let job = ScheduledJob { + id, + source: recipe_path.to_string_lossy().into_owned(), + cron: req.cron, + last_run: None, + currently_running: false, + paused: false, + current_session_id: None, + process_start_time: None, + parameters: vec![], + recipe_base_dir: None, + }; + + self.agent_manager + .scheduler() + .add_scheduled_job(job.clone(), false) + .await + .map_err(create_schedule_error)?; + + Ok(CreateScheduleResponse { + job: scheduled_job_to_dto(job), + }) + } + + pub(super) async fn on_delete_schedule( + &self, + req: DeleteScheduleRequest, + ) -> Result { + self.agent_manager + .scheduler() + .remove_scheduled_job(&req.schedule_id, false) + .await + .map_err(schedule_not_found_or_internal)?; + + Ok(EmptyResponse {}) + } + + pub(super) async fn on_pause_schedule( + &self, + req: PauseScheduleRequest, + ) -> Result { + self.agent_manager + .scheduler() + .pause_schedule(&req.schedule_id) + .await + .map_err(schedule_state_error)?; + + Ok(EmptyResponse {}) + } + + pub(super) async fn on_unpause_schedule( + &self, + req: UnpauseScheduleRequest, + ) -> Result { + self.agent_manager + .scheduler() + .unpause_schedule(&req.schedule_id) + .await + .map_err(schedule_not_found_or_internal)?; + + Ok(EmptyResponse {}) + } + + pub(super) async fn on_update_schedule( + &self, + req: UpdateScheduleRequest, + ) -> Result { + let schedule_id = req.schedule_id; + let cron = req.cron; + let scheduler = self.agent_manager.scheduler(); + scheduler + .update_schedule(&schedule_id, cron) + .await + .map_err(update_schedule_error)?; + + let job = scheduler + .list_scheduled_jobs() + .await + .into_iter() + .find(|job| job.id == schedule_id) + .ok_or_else(|| { + agent_client_protocol::Error::internal_error() + .data("Schedule not found after update") + })?; + + Ok(UpdateScheduleResponse { + job: scheduled_job_to_dto(job), + }) + } + + pub(super) async fn on_run_schedule_now( + &self, + req: RunScheduleNowRequest, + ) -> Result { + match self + .agent_manager + .scheduler() + .run_now(&req.schedule_id) + .await + { + Ok(session_id) => Ok(RunScheduleNowResponse { + status: RunScheduleNowStatus::Completed, + session_id: Some(session_id), + }), + Err(error) => run_schedule_now_error(error), + } + } + + pub(super) async fn on_kill_running_job( + &self, + req: KillRunningJobRequest, + ) -> Result { + self.agent_manager + .scheduler() + .kill_running_job(&req.job_id) + .await + .map_err(schedule_state_error)?; + + Ok(KillRunningJobResponse { + message: format!("Successfully killed running job '{}'", req.job_id), + }) + } + + pub(super) async fn on_inspect_running_job( + &self, + req: InspectRunningJobRequest, + ) -> Result { + let job = self + .agent_manager + .scheduler() + .list_scheduled_jobs() + .await + .into_iter() + .find(|job| job.id == req.job_id) + .ok_or_else(|| agent_client_protocol::Error::resource_not_found(Some(req.job_id)))?; + + if !job.currently_running { + return Ok(InspectRunningJobResponse::default()); + } + + let running_duration_seconds = job.process_start_time.map(|start_time| { + chrono::Utc::now() + .signed_duration_since(start_time) + .num_seconds() + }); + + Ok(InspectRunningJobResponse { + running: true, + session_id: job.current_session_id, + job_start_time: job.process_start_time.map(|value| value.to_rfc3339()), + running_duration_seconds, + }) + } +} diff --git a/crates/goose/src/acp/server/slash_commands.rs b/crates/goose/src/acp/server/slash_commands.rs new file mode 100644 index 000000000000..3d31a7614a37 --- /dev/null +++ b/crates/goose/src/acp/server/slash_commands.rs @@ -0,0 +1,45 @@ +use super::*; +use std::path::PathBuf; + +impl GooseAcpAgent { + pub(super) async fn on_list_slash_commands( + &self, + req: ListSlashCommandsRequest, + ) -> Result { + let cwd = if let Some(cwd) = req + .cwd + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + { + Some(PathBuf::from(cwd)) + } else if let Some(session_id) = req + .session_id + .as_deref() + .map(str::trim) + .filter(|session_id| !session_id.is_empty()) + { + Some( + self.session_manager + .get_session(session_id, false) + .await + .map_err(|_| { + agent_client_protocol::Error::resource_not_found(Some( + session_id.to_string(), + )) + .data(format!("Session not found: {}", session_id)) + })? + .working_dir, + ) + } else { + None + }; + + Ok(ListSlashCommandsResponse { + available_commands: + crate::acp::response_builder::available_commands_for_optional_working_dir( + cwd.as_deref(), + ), + }) + } +} diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 3e62fd6ae62e..0cdbe9f489b9 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -68,6 +68,7 @@ use tracing::{debug, error, info, instrument, warn}; const DEFAULT_MAX_TURNS: u32 = 1000; const DEFAULT_STOP_HOOK_BLOCK_CAP: u32 = 8; const COMPACTION_THINKING_TEXT: &str = "goose is compacting the conversation..."; +const MAX_TURNS_MESSAGE: &str = "I've reached the maximum number of actions I can do without user input. Would you like me to continue?"; const DEFAULT_FRONTEND_INSTRUCTIONS: &str = "The following tools are provided directly by the frontend and will be executed by the frontend when called."; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -424,6 +425,39 @@ impl Agent { .await; } + fn stop_hook_context( + session_id: &str, + last_assistant_message: &str, + ) -> crate::hooks::HookContext { + crate::hooks::HookContext::new(crate::hooks::HookEvent::Stop, session_id) + .with_last_assistant_message(last_assistant_message.to_string()) + } + + async fn emit_stop_hook(&self, session_id: &str, last_assistant_message: &str) { + if !self.hook_manager.has_hooks(crate::hooks::HookEvent::Stop) { + return; + } + self.hook_manager + .emit( + crate::hooks::HookEvent::Stop, + Self::stop_hook_context(session_id, last_assistant_message), + ) + .await; + } + + async fn emit_stop_hook_blocking( + &self, + session_id: &str, + last_assistant_message: &str, + ) -> crate::hooks::HookDecision { + self.hook_manager + .emit_blocking( + crate::hooks::HookEvent::Stop, + Self::stop_hook_context(session_id, last_assistant_message), + ) + .await + } + pub async fn steer(&self, session_id: &str, message: Message) { self.pending_steers .lock() @@ -1883,18 +1917,14 @@ impl Agent { guard.as_mut().and_then(|fot| fot.final_output.take()) }; if let Some(output) = final_output { + last_assistant_text = output.clone(); let message = Message::assistant().with_text(output); yield AgentEvent::Message(message.clone()); session_manager.add_message(&session_config.id, &message).await?; conversation.push(message); - let ctx = crate::hooks::HookContext::new( - crate::hooks::HookEvent::Stop, - &session_config.id, - ); match self - .hook_manager - .emit_blocking(crate::hooks::HookEvent::Stop, ctx) + .emit_stop_hook_blocking(&session_config.id, &last_assistant_text) .await { crate::hooks::HookDecision::Allow => { @@ -1926,11 +1956,8 @@ impl Agent { turns_taken += 1; } if turns_taken > max_turns { - yield AgentEvent::Message( - Message::assistant().with_text( - "I've reached the maximum number of actions I can do without user input. Would you like me to continue?" - ) - ); + last_assistant_text = MAX_TURNS_MESSAGE.to_string(); + yield AgentEvent::Message(Message::assistant().with_text(last_assistant_text.clone())); break; } @@ -1951,6 +1978,7 @@ impl Agent { &tools, &toolshim_tools, ).await?; + last_assistant_text.clear(); let current_turn_tool_count = conversation.messages().iter() .flat_map(|m| m.content.iter()) @@ -2038,7 +2066,7 @@ impl Agent { if num_tool_requests == 0 { let text = filtered_response.as_concat_text(); if !text.is_empty() { - last_assistant_text = text; + last_assistant_text.push_str(&text); } messages_to_add.push(response); continue; @@ -2211,37 +2239,70 @@ impl Agent { } } - // Preserve thinking/reasoning content from the original response - // Gemini (and other thinking models) require thinking to be echoed back - // Kimi/DeepSeek require reasoning_content on assistant tool call messages - let thinking_content: Vec = response.content.iter() - .filter(|c| matches!(c, MessageContent::Thinking(_))) + // Preserve thinking/reasoning content from the original response. + // Gemini (and other thinking models) require thinking to be echoed back. + // Kimi/DeepSeek require reasoning_content on assistant tool call messages. + let direct_thinking: Vec = response + .content + .iter() + .filter(|c| { + matches!( + c, + MessageContent::Thinking(_) + | MessageContent::RedactedThinking(_) + ) + }) .cloned() .collect(); - if !thinking_content.is_empty() { + if !direct_thinking.is_empty() { let thinking_msg = Message::new( response.role.clone(), response.created, - thinking_content, - ).with_id(format!("msg_{}", Uuid::new_v4())); + direct_thinking.clone(), + ) + .with_id(format!("msg_{}", Uuid::new_v4())); messages_to_add.push(thinking_msg); } - - // Collect reasoning content to attach to tool request messages - let reasoning_content: Vec = response.content.iter() - .filter(|c| matches!(c, MessageContent::Thinking(_))) - .cloned() - .collect(); + // When thinking arrived in an earlier stream chunk (stored as a + // thinking-only message) and this chunk has only tool calls, + // reuse that thinking so each split request_msg carries it. + let response_thinking = if direct_thinking.is_empty() { + messages_to_add + .messages() + .iter() + .rev() + .find(|m| { + m.role == response.role + && !m.content.is_empty() + && m.content.iter().all(|c| { + matches!( + c, + MessageContent::Thinking(_) + | MessageContent::RedactedThinking(_) + ) + }) + }) + .map(|m| m.content.clone()) + .unwrap_or_default() + } else { + direct_thinking + }; for request in frontend_requests.iter().chain(remaining_requests.iter()) { - if request.tool_call.is_ok() { + if let Err(err) = &request.tool_call { + let err_msg = err.message.to_string(); + error!("Tool call could not be parsed: {}", err_msg); + yield AgentEvent::Message( + Message::assistant().with_text(err_msg) + ); + exit_chat = true; + break; + } else { let mut request_msg = Message::assistant() .with_id(format!("msg_{}", Uuid::new_v4())); - // Providers like Kimi require reasoning_content on all assistant - // messages with tool_calls when thinking mode is enabled. - for rc in &reasoning_content { - request_msg = request_msg.with_content(rc.clone()); + for thinking in &response_thinking { + request_msg = request_msg.with_content(thinking.clone()); } request_msg = request_msg @@ -2261,18 +2322,6 @@ impl Agent { messages_to_add.push(request_msg); yield AgentEvent::Message(final_response.clone()); messages_to_add.push(final_response); - } else { - error!( - "Tool call could not be parsed: {}", - request.tool_call.as_ref().unwrap_err(), - ); - yield AgentEvent::Message( - Message::assistant().with_text( - "A tool call could not be parsed — the response may have been truncated. Try breaking the task into smaller steps or resending your message." - ) - ); - exit_chat = true; - break; } } @@ -2549,6 +2598,7 @@ impl Agent { } if let Some(output) = pending_final_output.take() { + last_assistant_text = output.clone(); let message = Message::assistant().with_text(output); messages_to_add.push(message.clone()); yield AgentEvent::Message(message); @@ -2574,13 +2624,8 @@ impl Agent { } if exit_chat { - let ctx = crate::hooks::HookContext::new( - crate::hooks::HookEvent::Stop, - &session_config.id, - ); match self - .hook_manager - .emit_blocking(crate::hooks::HookEvent::Stop, ctx) + .emit_stop_hook_blocking(&session_config.id, &last_assistant_text) .await { crate::hooks::HookDecision::Allow => { @@ -2613,7 +2658,7 @@ impl Agent { } if !stop_hook_handled_for_exit { - self.emit_hook(crate::hooks::HookEvent::Stop, &session_config.id).await; + self.emit_stop_hook(&session_config.id, &last_assistant_text).await; } }.instrument(reply_stream_span)); Ok(inner) @@ -3340,11 +3385,17 @@ if [ $((count % 2)) -eq 1 ]; then exit 2 fi exit 0 +"#; + + const RECORD_PAYLOAD_SCRIPT: &str = r#"#!/bin/sh +cat > "$PLUGIN_ROOT/payload.json" +exit 0 "#; struct StopHookTestEnv { temp_dir: TempDir, hook_log: PathBuf, + payload_path: PathBuf, } impl StopHookTestEnv { @@ -3372,6 +3423,7 @@ exit 0 Ok(Self { temp_dir, hook_log: plugin_dir.join("hook.log"), + payload_path: plugin_dir.join("payload.json"), }) } @@ -3393,6 +3445,11 @@ exit 0 .lines() .count() } + + fn stop_payload(&self) -> Result { + let payload = std::fs::read_to_string(&self.payload_path)?; + Ok(serde_json::from_str(&payload)?) + } } struct CountingTextProvider { @@ -3432,6 +3489,33 @@ exit 0 } } + struct ChunkedTextProvider; + + #[async_trait::async_trait] + impl crate::providers::base::Provider for ChunkedTextProvider { + async fn stream( + &self, + _model_config: &goose_providers::model::ModelConfig, + _session_id: &str, + _system_prompt: &str, + _messages: &[Message], + _tools: &[Tool], + ) -> Result { + let usage = ProviderUsage::new("mock-model".to_string(), Usage::default()); + Ok(Box::pin(futures::stream::iter(vec![ + Ok((Some(Message::assistant().with_text("streamed ")), None)), + Ok(( + Some(Message::assistant().with_text("assistant reply")), + Some(usage), + )), + ]))) + } + + fn get_name(&self) -> &str { + "chunked-text" + } + } + struct RefusingProvider { call_count: AtomicUsize, } @@ -3649,6 +3733,34 @@ exit 0 Ok(()) } + #[tokio::test] + async fn stop_hook_payload_includes_streamed_assistant_reply_text() -> Result<()> { + let env = StopHookTestEnv::new(RECORD_PAYLOAD_SCRIPT)?; + let provider = Arc::new(ChunkedTextProvider); + let (agent, session_id) = + create_test_agent(env.data_dir(), env.hook_manager(), provider).await?; + + let messages = run_stop_hook_test_turn(&agent, &session_id, "hello").await?; + let texts = visible_texts(&messages); + assert_eq!(texts.join(""), "streamed assistant reply"); + + let payload = env.stop_payload()?; + assert_eq!(payload.get("event").and_then(Value::as_str), Some("Stop")); + assert_eq!( + payload.get("session_id").and_then(Value::as_str), + Some(session_id.as_str()) + ); + assert_eq!( + payload + .get("last_assistant_message") + .and_then(Value::as_str), + Some("streamed assistant reply") + ); + assert!(payload.get("message").is_none()); + + Ok(()) + } + #[tokio::test] async fn test_add_final_output_tool() -> Result<()> { let agent = Agent::new(); diff --git a/crates/goose/src/hooks/mod.rs b/crates/goose/src/hooks/mod.rs index d8566803834c..89bc39295e95 100644 --- a/crates/goose/src/hooks/mod.rs +++ b/crates/goose/src/hooks/mod.rs @@ -166,6 +166,8 @@ pub struct HookContext { #[serde(skip_serializing_if = "Option::is_none")] pub message: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub last_assistant_message: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub working_dir: Option, } @@ -179,6 +181,7 @@ impl HookContext { tool_input: None, tool_output: None, message: None, + last_assistant_message: None, working_dir: None, } } @@ -203,6 +206,14 @@ impl HookContext { self } + pub fn with_last_assistant_message(mut self, message: impl Into) -> Self { + let message = message.into(); + if !message.is_empty() { + self.last_assistant_message = Some(message); + } + self + } + pub fn with_working_dir(mut self, dir: impl Into) -> Self { self.working_dir = Some(dir.into()); self diff --git a/crates/goose/src/model_config.rs b/crates/goose/src/model_config.rs index 2e616de76dfc..a66df05ef688 100644 --- a/crates/goose/src/model_config.rs +++ b/crates/goose/src/model_config.rs @@ -5,6 +5,7 @@ use anyhow::{anyhow, Result}; use goose_providers::conversation::token_usage::ProviderUsage; use goose_providers::errors::ProviderError; use goose_providers::model::ModelConfig; +use goose_providers::thinking::ThinkingEffort; use rmcp::model::Tool; use serde_json::Value; use std::collections::HashMap; @@ -110,7 +111,8 @@ pub async fn complete_fast( ) -> Result<(Message, ProviderUsage), ProviderError> { let fast_model_config = get_fast_model(provider.get_name(), model_config) .await - .map_err(|e| ProviderError::ExecutionError(e.to_string()))?; + .map_err(|e| ProviderError::ExecutionError(e.to_string()))? + .with_thinking_effort(ThinkingEffort::Off); match provider .complete(&fast_model_config, session_id, system, messages, tools) @@ -124,8 +126,11 @@ pub async fn complete_fast( e, model_config.model_name ); + let fallback_config = model_config + .clone() + .with_thinking_effort(ThinkingEffort::Off); provider - .complete(model_config, session_id, system, messages, tools) + .complete(&fallback_config, session_id, system, messages, tools) .await } Err(e) => Err(e), diff --git a/crates/goose/src/providers/anthropic.rs b/crates/goose/src/providers/anthropic.rs index 80379a444ceb..e913fbaeb2c5 100644 --- a/crates/goose/src/providers/anthropic.rs +++ b/crates/goose/src/providers/anthropic.rs @@ -164,6 +164,7 @@ impl AnthropicProvider { AnthropicFormatOptions { preserve_unsigned_thinking: preserves_thinking, preserve_thinking_context: preserves_thinking, + thinking_disabled: false, } } diff --git a/crates/goose/src/providers/formats/anthropic.rs b/crates/goose/src/providers/formats/anthropic.rs index c9018f8247ea..750588825803 100644 --- a/crates/goose/src/providers/formats/anthropic.rs +++ b/crates/goose/src/providers/formats/anthropic.rs @@ -47,6 +47,7 @@ string_enum!(ThinkingType { Adaptive => "adaptive", Enabled => "enabled", Disabl pub struct AnthropicFormatOptions { pub preserve_unsigned_thinking: bool, pub preserve_thinking_context: bool, + pub thinking_disabled: bool, } impl AnthropicFormatOptions { @@ -68,10 +69,13 @@ impl AnthropicFormatOptions { }) .unwrap_or(self.preserve_unsigned_thinking) || preserve_thinking_context; + let thinking_disabled = model_config.reasoning == Some(false) + || model_config.thinking_effort() == Some(ThinkingEffort::Off); Self { preserve_unsigned_thinking, preserve_thinking_context, + thinking_disabled, } } } @@ -257,24 +261,31 @@ fn format_messages_with_options( // Skip } MessageContent::Thinking(thinking) => { - if !thinking.signature.is_empty() { - content.push(json!({ - TYPE_FIELD: THINKING_TYPE, - THINKING_TYPE: thinking.thinking, - SIGNATURE_FIELD: thinking.signature - })); - } else if options.preserve_unsigned_thinking && !thinking.thinking.is_empty() { - content.push(json!({ - TYPE_FIELD: THINKING_TYPE, - THINKING_TYPE: thinking.thinking - })); + // Anthropic rejects thinking blocks sent without a matching thinking config. + if !options.thinking_disabled { + if !thinking.signature.is_empty() { + content.push(json!({ + TYPE_FIELD: THINKING_TYPE, + THINKING_TYPE: thinking.thinking, + SIGNATURE_FIELD: thinking.signature + })); + } else if options.preserve_unsigned_thinking + && !thinking.thinking.is_empty() + { + content.push(json!({ + TYPE_FIELD: THINKING_TYPE, + THINKING_TYPE: thinking.thinking + })); + } } } MessageContent::RedactedThinking(redacted) => { - content.push(json!({ - TYPE_FIELD: REDACTED_THINKING_TYPE, - DATA_FIELD: redacted.data - })); + if !options.thinking_disabled { + content.push(json!({ + TYPE_FIELD: REDACTED_THINKING_TYPE, + DATA_FIELD: redacted.data + })); + } } MessageContent::Image(image) => { content.push(convert_image(image, &ImageFormat::Anthropic)); @@ -600,7 +611,7 @@ fn apply_thinking_config( ThinkingType::Disabled => {} } - if options.preserve_thinking_context { + if options.preserve_thinking_context && !options.thinking_disabled { if !obj.contains_key("thinking") { let budget_tokens = thinking_budget_tokens(model_config) .min(max_tokens.saturating_sub(MIN_ANSWER_TOKENS)); @@ -752,6 +763,7 @@ where let mut final_usage: Option = None; let mut message_id: Option = None; let mut thinking: Option = None; + let mut stop_reason: Option = None; while let Some(line_result) = stream.next().await { let line = line_result?; @@ -878,18 +890,20 @@ where } } if let Some(tool_id) = current_tool_id.take() { - // Tool call finished, yield complete tool call if let Some((name, args)) = accumulated_tool_calls.remove(&tool_id) { let parsed_args = if args.is_empty() { json!({}) } else { - match serde_json::from_str::(&args) { - Ok(parsed) => parsed, - Err(_) => { - // If parsing fails, create an error tool request + match goose_providers::json::parse_tool_arguments(&args) { + Some(parsed) => parsed, + None => { + let message_text = goose_providers::json::truncation_error_message(&args) + .unwrap_or_else(|| { + format!("Could not parse tool arguments: {args}") + }); let error = ErrorData::new( ErrorCode::INVALID_PARAMS, - format!("Could not parse tool arguments: {}", args), + message_text, None, ); let mut message = Message::new( @@ -934,6 +948,11 @@ where } if let Some(delta) = event.data.get("delta") { let stop_details = delta.get("stop_details").filter(|d| !d.is_null()); + if stop_reason.is_none() { + if let Some(sr) = delta.get("stop_reason").and_then(|v| v.as_str()) { + stop_reason = Some(sr.to_string()); + } + } if delta.get("stop_reason").and_then(|v| v.as_str()) == Some(STOP_REASON_REFUSAL) { let str_field = |key: &str| stop_details .and_then(|d| d.get(key)) @@ -980,6 +999,38 @@ where } } + // A tool_use block left open at stream end never received its + // content_block_stop, so its args are truncated rather than complete. + if !accumulated_tool_calls.is_empty() { + let truncated_by_limit = stop_reason.as_deref() == Some("max_tokens"); + let mut ids: Vec = accumulated_tool_calls.keys().cloned().collect(); + ids.sort(); + for id in ids { + if let Some((_name, args)) = accumulated_tool_calls.remove(&id) { + let guidance = if truncated_by_limit { + "The model's response was truncated — it hit the output token limit while generating this tool call. \ + Try increasing max_tokens for this provider or breaking the task into smaller steps." + } else { + "A tool call was not completed before the stream ended. \ + Try resending your message or breaking the task into smaller steps." + }; + let snippet_len = args.chars().count(); + let tail: String = args.chars().rev().take(80).collect::>().into_iter().rev().collect(); + let message_text = format!( + "{guidance}\nReceived {snippet_len} characters of arguments; cut off at: …{tail}" + ); + let error = ErrorData::new(ErrorCode::INVALID_PARAMS, message_text, None); + let mut message = Message::new( + Role::Assistant, + chrono::Utc::now().timestamp(), + vec![MessageContent::tool_request(id, Err(error))], + ); + message.id = message_id.clone(); + yield (Some(message), None); + } + } + } + if let Some(usage) = final_usage { yield (None, Some(usage)); } @@ -1155,6 +1206,7 @@ mod tests { AnthropicFormatOptions { preserve_unsigned_thinking: true, preserve_thinking_context: false, + thinking_disabled: false, }, ); @@ -1369,6 +1421,7 @@ mod tests { AnthropicFormatOptions { preserve_unsigned_thinking: true, preserve_thinking_context: true, + thinking_disabled: false, }, )?; @@ -1713,6 +1766,7 @@ mod tests { redacted_thinking: Vec, text: Vec, tool_calls: Vec, + tool_errors: Vec, } async fn collect_stream(events: &str) -> StreamedParts { @@ -1733,11 +1787,10 @@ mod tests { MessageContent::Text(t) => { parts.text.push(t.text.clone()); } - MessageContent::ToolRequest(req) => { - if let Ok(call) = &req.tool_call { - parts.tool_calls.push(call.name.to_string()); - } - } + MessageContent::ToolRequest(req) => match &req.tool_call { + Ok(call) => parts.tool_calls.push(call.name.to_string()), + Err(e) => parts.tool_errors.push(e.message.to_string()), + }, _ => {} } } @@ -2027,4 +2080,100 @@ mod tests { ); assert!(parts.text[0].contains("context_window")); } + + #[tokio::test] + async fn test_streaming_truncated_tool_args_in_content_block_stop() { + // Block is closed by content_block_stop, but the concatenated deltas form + // truncated JSON (each fragment is valid; together they're unterminated). + let events = concat!( + r##"data: {"type":"message_start","message":{"id":"msg_t","role":"assistant","content":[],"model":"glm-4.7","usage":{"input_tokens":10,"output_tokens":0}}}"##, + "\n", + r##"data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"tool_t","name":"write","input":{}}}"##, + "\n", + r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"path\":\"/some/path.md\","}}"#, + "\n", + r##"data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"content\":\"# Very long markdown"}}"##, + "\n", + r#"data: {"type":"content_block_stop","index":0}"#, + "\n", + r#"data: {"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":4096}}"#, + "\n", + r#"data: {"type":"message_stop"}"#, + ); + + let parts = collect_stream(events).await; + assert_eq!( + parts.tool_errors.len(), + 1, + "expected one tool error, got: {:?}", + parts.tool_errors + ); + let msg = &parts.tool_errors[0]; + assert!( + msg.contains("truncated") || msg.contains("output token limit"), + "expected actionable truncation message, got: {}", + msg + ); + assert!( + msg.contains("max_tokens") || msg.contains("smaller steps"), + "expected guidance to increase max_tokens or break up the task, got: {}", + msg + ); + } + + #[tokio::test] + async fn test_streaming_truncated_tool_args_no_content_block_stop() { + // The stream ends with the tool_use block still open (no content_block_stop), + // which is what happens when the model is cut off mid-tool-call. + let events = concat!( + r##"data: {"type":"message_start","message":{"id":"msg_t2","role":"assistant","content":[],"model":"glm-4.7","usage":{"input_tokens":10,"output_tokens":0}}}"##, + "\n", + r##"data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"tool_t2","name":"write","input":{}}}"##, + "\n", + r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"path\":\"/report.md\","}}"#, + "\n", + r##"data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"content\":\"# Big report that got cut off mid"}}"##, + "\n", + r#"data: {"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":8192}}"#, + "\n", + r#"data: {"type":"message_stop"}"#, + ); + + let parts = collect_stream(events).await; + assert_eq!( + parts.tool_errors.len(), + 1, + "expected one tool error for the dropped/truncated tool call, got: {:?}", + parts.tool_errors + ); + let msg = &parts.tool_errors[0]; + assert!( + msg.contains("truncated") || msg.contains("output token limit"), + "expected actionable truncation message, got: {}", + msg + ); + } + + #[tokio::test] + async fn test_streaming_complete_tool_call_unaffected() { + // Regression guard: a normal, complete tool call must still parse and + // produce no error even though stop_reason handling is added. + let events = concat!( + r#"data: {"type":"message_start","message":{"id":"msg_ok","role":"assistant","content":[],"model":"glm-4.7","usage":{"input_tokens":10,"output_tokens":0}}}"#, + "\n", + r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"tool_ok","name":"write","input":{}}}"#, + "\n", + r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"path\":\"/ok.md\",\"content\":\"hello\"}"}}"#, + "\n", + r#"data: {"type":"content_block_stop","index":0}"#, + "\n", + r#"data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":15}}"#, + "\n", + r#"data: {"type":"message_stop"}"#, + ); + + let parts = collect_stream(events).await; + assert_eq!(parts.tool_calls, vec!["write"]); + assert!(parts.tool_errors.is_empty()); + } } diff --git a/crates/goose/src/providers/formats/databricks.rs b/crates/goose/src/providers/formats/databricks.rs index e4b344a81dcb..ce63261dbda6 100644 --- a/crates/goose/src/providers/formats/databricks.rs +++ b/crates/goose/src/providers/formats/databricks.rs @@ -11,7 +11,6 @@ use goose_providers::formats::openai::{ openai_reasoning_effort_for_thinking, sanitize_function_name, }; use goose_providers::images::{convert_image, detect_image_path, load_image_file, ImageFormat}; -use goose_providers::json::safely_parse_json; use rmcp::model::{ object, AnnotateAble, CallToolRequestParams, Content, ErrorCode, ErrorData, RawContent, ResourceContents, Role, Tool, @@ -423,21 +422,25 @@ pub fn response_to_message(response: &Value) -> anyhow::Result { }; content.push(MessageContent::tool_request(id, Err(error))); } else { - match safely_parse_json(&arguments_str) { - Ok(params) => { + match goose_providers::json::parse_tool_arguments(&arguments_str) { + Some(params) => { content.push(MessageContent::tool_request( id, Ok(CallToolRequestParams::new(function_name) .with_arguments(object(params))), )); } - Err(e) => { + None => { + let message_text = + goose_providers::json::truncation_error_message(&arguments_str) + .unwrap_or_else(|| { + format!( + "Could not interpret tool use parameters for id {id}" + ) + }); let error = ErrorData { code: ErrorCode::INVALID_PARAMS, - message: Cow::from(format!( - "Could not interpret tool use parameters for id {}: {}. Raw arguments: '{}'", - id, e, arguments_str - )), + message: Cow::from(message_text), data: None, }; content.push(MessageContent::tool_request(id, Err(error))); @@ -1025,7 +1028,7 @@ mod tests { message: msg, data: None, }) => { - assert!(msg.starts_with("Could not interpret tool use parameters")); + assert!(msg.contains("tool arguments") || msg.contains("truncated")); } _ => panic!("Expected InvalidParameters error"), } diff --git a/crates/goose/src/providers/formats/google.rs b/crates/goose/src/providers/formats/google.rs index 3c3387b970fa..b8a5e27707eb 100644 --- a/crates/goose/src/providers/formats/google.rs +++ b/crates/goose/src/providers/formats/google.rs @@ -538,6 +538,24 @@ struct GoogleRequest<'a> { } fn get_thinking_config(model_config: &ModelConfig) -> Option { + if model_config.reasoning == Some(false) + || model_config.thinking_effort() == Some(ThinkingEffort::Off) + { + // Gemini 2.5 Flash defaults to dynamic thinking; only an explicit budget + // of 0 turns it off. Other families can't be disabled, so leave them unset. + if model_config + .model_name + .to_lowercase() + .starts_with("gemini-2.5-flash") + { + return Some(ThinkingConfig { + thinking_level: None, + thinking_budget: Some(0), + include_thoughts: false, + }); + } + return None; + } let model_name = model_config.model_name.to_lowercase(); let is_gemini_3 = model_name.starts_with("gemini-3"); let is_gemini_25 = model_name.starts_with("gemini-2.5"); @@ -1397,6 +1415,19 @@ data: [DONE]"#; assert!(schema.get("$defs").is_some()); } + #[test] + fn test_get_thinking_config_disabled_reasoning() { + use goose_providers::model::ModelConfig; + + let config = ModelConfig::new("gemini-2.5-flash").with_thinking_effort(ThinkingEffort::Off); + let thinking_config = get_thinking_config(&config).unwrap(); + assert_eq!(thinking_config.thinking_budget, Some(0)); + assert!(!thinking_config.include_thoughts); + + let config = ModelConfig::new("gemini-2.5-pro").with_thinking_effort(ThinkingEffort::Off); + assert!(get_thinking_config(&config).is_none()); + } + #[test] fn test_get_thinking_config() { use goose_providers::model::ModelConfig; diff --git a/crates/goose/src/providers/formats/openrouter.rs b/crates/goose/src/providers/formats/openrouter.rs index 20cbda455751..ff7ac35b3a1e 100644 --- a/crates/goose/src/providers/formats/openrouter.rs +++ b/crates/goose/src/providers/formats/openrouter.rs @@ -201,6 +201,25 @@ mod tests { assert!(payload.get("reasoning_effort").is_none()); } + #[test] + fn test_apply_reasoning_config_disables_reasoning_capable_model() { + let mut payload = json!({ + "model": "google/gemini-2.5-flash", + "messages": [] + }); + // Reasoning-capable model (per canonical) with thinking explicitly off, as a + // fast-model config is built: OpenRouter must still emit the disable object. + let mut model_config = ModelConfig::new("google/gemini-2.5-flash"); + model_config.reasoning = Some(true); + let mut params = HashMap::new(); + params.insert("thinking_effort".to_string(), json!("off")); + model_config.request_params = Some(params); + + apply_reasoning_config(&mut payload, &model_config); + + assert_eq!(payload["reasoning"], json!({ "effort": "none" })); + } + #[test] fn test_apply_reasoning_config_uses_reasoning_metadata() { let mut payload = json!({ diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index 8466893ca021..0d328dc184e0 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -139,6 +139,16 @@ async fn persist_jobs( Ok(()) } +fn clear_running_state(job: &mut ScheduledJob) -> bool { + let changed = job.currently_running + || job.current_session_id.is_some() + || job.process_start_time.is_some(); + job.currently_running = false; + job.current_session_id = None; + job.process_start_time = None; + changed +} + pub struct Scheduler { tokio_scheduler: TokioJobScheduler, jobs: Arc>, @@ -432,7 +442,7 @@ impl Scheduler { return; } - let list: Vec = match serde_json::from_str(&data) { + let mut list: Vec = match serde_json::from_str(&data) { Ok(jobs) => jobs, Err(e) => { tracing::error!( @@ -444,6 +454,20 @@ impl Scheduler { } }; + let reset_stale_running_state = list + .iter_mut() + .fold(false, |changed, job| clear_running_state(job) || changed); + if reset_stale_running_state { + match serde_json::to_string_pretty(&list) { + Ok(data) => { + if let Err(e) = fs::write(&self.storage_path, data) { + tracing::error!("Failed to persist scheduler startup state: {}", e); + } + } + Err(e) => tracing::error!("Failed to serialize scheduler startup state: {}", e), + } + } + for job_to_load in list { if !Path::new(&job_to_load.source).exists() { tracing::warn!( @@ -554,12 +578,15 @@ impl Scheduler { pub async fn list_scheduled_jobs(&self) -> Vec { self.sync_from_storage().await; - self.jobs + let mut jobs: Vec = self + .jobs .lock() .await .values() .map(|(_, j)| j.clone()) - .collect() + .collect(); + jobs.sort_by(|a, b| a.id.cmp(&b.id)); + jobs } pub async fn remove_scheduled_job( @@ -648,6 +675,7 @@ impl Scheduler { cancel_token.clone(), ) .await; + let was_cancelled = cancel_token.is_cancelled(); { let mut tasks = self.running_tasks.lock().await; @@ -667,6 +695,10 @@ impl Scheduler { persist_jobs(&self.storage_path, &self.jobs).await?; match result { + _ if was_cancelled => Err(SchedulerError::AnyhowError(anyhow!( + "Job '{}' was successfully cancelled", + sched_id + ))), Ok(session_id) => Ok(session_id), Err(e) => Err(SchedulerError::AnyhowError(anyhow!( "Job '{}' failed: {}", @@ -770,14 +802,25 @@ impl Scheduler { } } + let token = { + let mut tasks = self.running_tasks.lock().await; + tasks.remove(sched_id) + }; + if let Some(token) = token { + token.cancel(); + } + { - let tasks = self.running_tasks.lock().await; - if let Some(token) = tasks.get(sched_id) { - token.cancel(); + let mut jobs_guard = self.jobs.lock().await; + match jobs_guard.get_mut(sched_id) { + Some((_, job)) => { + clear_running_state(job); + } + None => return Err(SchedulerError::JobNotFound(sched_id.to_string())), } } - Ok(()) + persist_jobs(&self.storage_path, &self.jobs).await } pub async fn get_running_job_info( @@ -1227,6 +1270,112 @@ mod tests { ); } + #[tokio::test] + async fn test_kill_running_job_clears_state_and_persists() { + let temp_dir = tempdir().unwrap(); + let storage_path = temp_dir.path().join("schedule.json"); + let recipe_path = create_test_recipe(temp_dir.path(), "running_job"); + let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf())); + let scheduler = Scheduler::new(storage_path.clone(), session_manager) + .await + .unwrap(); + + let job = ScheduledJob { + id: "running_job".to_string(), + source: recipe_path.to_string_lossy().to_string(), + cron: "0 0 0 1 1 *".to_string(), + last_run: None, + currently_running: false, + paused: false, + current_session_id: None, + process_start_time: None, + parameters: vec![], + recipe_base_dir: None, + }; + + scheduler.add_scheduled_job(job, false).await.unwrap(); + { + let mut jobs_guard = scheduler.jobs.lock().await; + let (_, job) = jobs_guard.get_mut("running_job").unwrap(); + job.currently_running = true; + job.current_session_id = Some("session-id".to_string()); + job.process_start_time = Some(Utc::now()); + } + { + let mut tasks = scheduler.running_tasks.lock().await; + tasks.insert("running_job".to_string(), CancellationToken::new()); + } + persist_jobs(&storage_path, &scheduler.jobs).await.unwrap(); + + scheduler.kill_running_job("running_job").await.unwrap(); + + let jobs = scheduler.list_scheduled_jobs().await; + let killed_job = jobs.iter().find(|job| job.id == "running_job").unwrap(); + assert!(!killed_job.currently_running); + assert!(killed_job.current_session_id.is_none()); + assert!(killed_job.process_start_time.is_none()); + assert!(scheduler.running_tasks.lock().await.is_empty()); + + let persisted_jobs: Vec = + serde_json::from_str(&fs::read_to_string(storage_path).unwrap()).unwrap(); + let persisted_job = persisted_jobs + .iter() + .find(|job| job.id == "running_job") + .unwrap(); + assert!(!persisted_job.currently_running); + assert!(persisted_job.current_session_id.is_none()); + assert!(persisted_job.process_start_time.is_none()); + } + + #[tokio::test] + async fn test_load_jobs_from_storage_clears_stale_running_state() { + let temp_dir = tempdir().unwrap(); + let storage_path = temp_dir.path().join("schedule.json"); + let recipe_path = create_test_recipe(temp_dir.path(), "stale_running_job"); + let started_at = Utc::now(); + let stale_job = ScheduledJob { + id: "stale_running_job".to_string(), + source: recipe_path.to_string_lossy().to_string(), + cron: "0 0 0 1 1 *".to_string(), + last_run: None, + currently_running: true, + paused: false, + current_session_id: Some("stale-session-id".to_string()), + process_start_time: Some(started_at), + parameters: vec![], + recipe_base_dir: None, + }; + fs::write( + &storage_path, + serde_json::to_string_pretty(&vec![stale_job]).unwrap(), + ) + .unwrap(); + + let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf())); + let scheduler = Scheduler::new(storage_path.clone(), session_manager) + .await + .unwrap(); + + let jobs = scheduler.list_scheduled_jobs().await; + let loaded_job = jobs + .iter() + .find(|job| job.id == "stale_running_job") + .unwrap(); + assert!(!loaded_job.currently_running); + assert!(loaded_job.current_session_id.is_none()); + assert!(loaded_job.process_start_time.is_none()); + + let persisted_jobs: Vec = + serde_json::from_str(&fs::read_to_string(storage_path).unwrap()).unwrap(); + let persisted_job = persisted_jobs + .iter() + .find(|job| job.id == "stale_running_job") + .unwrap(); + assert!(!persisted_job.currently_running); + assert!(persisted_job.current_session_id.is_none()); + assert!(persisted_job.process_start_time.is_none()); + } + #[tokio::test] async fn test_job_with_no_prompt_does_not_panic() { let _guard = env_lock::lock_env([ diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 7b9e3f43de55..ed64b8d1eb53 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -9,7 +9,7 @@ use crate::session::session_naming::{ generate_session_name, MSG_COUNT_FOR_SESSION_NAME_GENERATION, }; use anyhow::Result; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, TimeZone, Utc}; use goose_providers::conversation::token_usage::Usage; use goose_providers::model::ModelConfig; use rmcp::model::Role; @@ -26,6 +26,7 @@ use utoipa::ToSchema; pub const CURRENT_SCHEMA_VERSION: i32 = 14; pub const SESSIONS_FOLDER: &str = "sessions"; pub const DB_NAME: &str = "sessions.db"; +const MILLISECOND_TIMESTAMP_THRESHOLD: i64 = 10_000_000_000; #[derive( Debug, @@ -80,6 +81,8 @@ pub struct Session { pub user_recipe_values: Option>, pub conversation: Option, pub message_count: usize, + #[serde(default)] + pub last_message_at: Option>, pub provider_name: Option, pub model_config: Option, #[serde(default)] @@ -276,7 +279,7 @@ pub struct SessionManager { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct SessionListCursor { - pub(crate) updated_at: DateTime, + pub(crate) sort_at: DateTime, pub(crate) session_id: String, } @@ -601,6 +604,25 @@ pub(crate) fn role_to_string(role: &Role) -> &'static str { } } +fn message_timestamp_to_datetime(timestamp: i64) -> Option> { + let timestamp = if timestamp > MILLISECOND_TIMESTAMP_THRESHOLD { + timestamp / 1000 + } else { + timestamp + }; + Utc.timestamp_opt(timestamp, 0).single() +} + +fn normalized_message_timestamp_sql(column: &str) -> String { + format!( + "CASE WHEN {column} > {MILLISECOND_TIMESTAMP_THRESHOLD} THEN {column} / 1000 ELSE {column} END" + ) +} + +fn session_sort_at(session: &Session) -> DateTime { + session.last_message_at.unwrap_or(session.updated_at) +} + impl Default for Session { fn default() -> Self { Self { @@ -620,6 +642,7 @@ impl Default for Session { user_recipe_values: None, conversation: None, message_count: 0, + last_message_at: None, provider_name: None, model_config: None, goose_mode: GooseMode::default(), @@ -667,6 +690,12 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session { .unwrap_or_else(|_| "user".to_string()); let session_type = session_type_str.parse().unwrap_or_default(); + let last_message_at = row + .try_get::, _>("last_message_timestamp") + .ok() + .flatten() + .and_then(message_timestamp_to_datetime); + Ok(Session { id: row.try_get("id")?, working_dir: PathBuf::from(row.try_get::("working_dir")?), @@ -703,6 +732,7 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session { user_recipe_values, conversation: None, message_count: row.try_get("message_count").unwrap_or(0) as usize, + last_message_at, provider_name: row.try_get("provider_name").ok().flatten(), model_config, goose_mode: row @@ -1374,14 +1404,24 @@ impl SessionStorage { if include_messages { let conv = self.get_conversation(&session.id).await?; session.message_count = conv.messages().len(); + session.last_message_at = conv + .messages() + .iter() + .filter_map(|message| message_timestamp_to_datetime(message.created)) + .max(); session.conversation = Some(conv); } else { - let count = - sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM messages WHERE session_id = ?") - .bind(&session.id) - .fetch_one(pool) - .await? as usize; - session.message_count = count; + let sql = format!( + "SELECT COUNT(*), MAX({}) FROM messages WHERE session_id = ?", + normalized_message_timestamp_sql("created_timestamp") + ); + let (count, last_message_timestamp): (i64, Option) = sqlx::query_as(&sql) + .bind(&session.id) + .fetch_one(pool) + .await?; + session.message_count = count as usize; + session.last_message_at = + last_message_timestamp.and_then(message_timestamp_to_datetime); } Ok(session) @@ -1650,6 +1690,10 @@ impl SessionStorage { let keywords = keyword_terms(filters.keyword); let mut where_clauses = Vec::new(); + let mut having_clauses = Vec::new(); + let normalized_message_timestamp = normalized_message_timestamp_sql("m.created_timestamp"); + let sort_timestamp_sql = + format!("COALESCE(MAX({normalized_message_timestamp}), unixepoch(s.updated_at))"); if let Some(types) = filters.types { let placeholders = types.iter().map(|_| "?").collect::>().join(", "); where_clauses.push(format!("s.session_type IN ({})", placeholders)); @@ -1661,11 +1705,9 @@ impl SessionStorage { where_clauses.push(message_keyword_clause(keywords.len())); } if query.cursor.is_some() { - where_clauses.push( - "(datetime(s.updated_at) < datetime(?) \ - OR (datetime(s.updated_at) = datetime(?) AND s.id < ?))" - .to_string(), - ); + having_clauses.push(format!( + "({sort_timestamp_sql} < ? OR ({sort_timestamp_sql} = ? AND s.id < ?))" + )); } let where_clause = if where_clauses.is_empty() { @@ -1673,16 +1715,17 @@ impl SessionStorage { } else { format!("WHERE {}", where_clauses.join(" AND ")) }; + let having_clause = if having_clauses.is_empty() { + String::new() + } else { + format!("HAVING {}", having_clauses.join(" AND ")) + }; let message_join = if filters.only_sessions_with_messages { "JOIN messages m ON s.id = m.session_id" } else { "LEFT JOIN messages m ON s.id = m.session_id" }; - let order_by = if query.cursor.is_some() || query.limit.is_some() { - "ORDER BY datetime(s.updated_at) DESC, s.id DESC" - } else { - "ORDER BY s.updated_at DESC" - }; + let order_by = "ORDER BY sort_timestamp DESC, s.id DESC"; let limit_clause = if query.limit.is_some() { "LIMIT ?" } else { "" }; let sql = format!( @@ -1696,15 +1739,24 @@ impl SessionStorage { s.schedule_id, s.recipe_json, s.user_recipe_values_json, s.provider_name, s.model_config_json, s.goose_mode, s.archived_at, s.project_id, - COUNT(m.id) as message_count + COUNT(m.id) as message_count, + MAX({}) as last_message_timestamp, + {} as sort_timestamp FROM sessions s {} {} GROUP BY s.id {} {} + {} "#, - message_join, where_clause, order_by, limit_clause + normalized_message_timestamp, + sort_timestamp_sql, + message_join, + where_clause, + having_clause, + order_by, + limit_clause ); let mut q = sqlx::query_as::<_, Session>(&sql); @@ -1720,10 +1772,9 @@ impl SessionStorage { q = q.bind(term); } if let Some(cursor) = query.cursor { - let updated_at = cursor.updated_at.to_rfc3339(); - // Normalize mixed SQLite CURRENT_TIMESTAMP and RFC3339 stored values. - q = q.bind(updated_at.clone()); - q = q.bind(updated_at); + let sort_at = cursor.sort_at.timestamp(); + q = q.bind(sort_at); + q = q.bind(sort_at); q = q.bind(&cursor.session_id); } if let Some(limit) = query.limit { @@ -1769,7 +1820,7 @@ impl SessionStorage { let next_cursor = if has_next_page { let anchor = &sessions[page_size - 1]; Some(SessionListCursor { - updated_at: anchor.updated_at, + sort_at: session_sort_at(anchor), session_id: anchor.id.clone(), }) } else { @@ -2269,6 +2320,31 @@ mod tests { .unwrap(); } + async fn add_message_at_millis( + sm: &SessionManager, + session_id: &str, + text: &str, + timestamp: &str, + ) { + sm.add_message(session_id, &Message::user().with_text(text)) + .await + .unwrap(); + + let pool = sm.storage().pool().await.unwrap(); + let timestamp = chrono::DateTime::parse_from_rfc3339(timestamp).unwrap(); + let timestamp_string = timestamp.format("%Y-%m-%d %H:%M:%S").to_string(); + + sqlx::query( + "UPDATE messages SET timestamp = ?, created_timestamp = ? WHERE id = (SELECT MAX(id) FROM messages WHERE session_id = ?)", + ) + .bind(×tamp_string) + .bind(timestamp.timestamp_millis()) + .bind(session_id) + .execute(pool) + .await + .unwrap(); + } + async fn set_message_timestamp( sm: &SessionManager, session_id: &str, @@ -2297,6 +2373,40 @@ mod tests { .unwrap(); } + #[tokio::test] + async fn test_last_message_at_is_derived_from_messages() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let session = sm + .create_session( + PathBuf::from("/tmp/test"), + "Session recency".to_string(), + SessionType::User, + GooseMode::default(), + ) + .await + .unwrap(); + + let empty = sm.get_session(&session.id, false).await.unwrap(); + assert_eq!(empty.message_count, 0); + assert_eq!(empty.last_message_at, None); + + add_message_at_millis(&sm, &session.id, "older", "2026-01-01T00:00:00Z").await; + add_message_at(&sm, &session.id, "newer", "2026-01-02T03:04:05Z").await; + + let expected = chrono::DateTime::parse_from_rfc3339("2026-01-02T03:04:05Z") + .unwrap() + .with_timezone(&Utc); + + let without_messages = sm.get_session(&session.id, false).await.unwrap(); + assert_eq!(without_messages.message_count, 2); + assert_eq!(without_messages.last_message_at, Some(expected)); + + let with_messages = sm.get_session(&session.id, true).await.unwrap(); + assert_eq!(with_messages.message_count, 2); + assert_eq!(with_messages.last_message_at, Some(expected)); + } + #[tokio::test] async fn test_truncate_conversation_from_message_keeps_same_second_previous_rows() { let temp_dir = TempDir::new().unwrap(); @@ -2561,8 +2671,8 @@ mod tests { sessions.push(sm.get_session(session_id, false).await.unwrap()); } sessions.sort_by(|a, b| { - b.updated_at - .cmp(&a.updated_at) + session_sort_at(b) + .cmp(&session_sort_at(a)) .then_with(|| b.id.cmp(&a.id)) }); sessions.into_iter().map(|session| session.id).collect() @@ -2711,7 +2821,53 @@ mod tests { } #[tokio::test] - async fn test_session_list_paged_uses_id_tiebreaker_for_duplicate_updated_at() { + async fn test_session_list_paged_sorts_by_last_message_at() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let stale_but_modified = create_session_for_list(&sm, "/tmp/session-list", false).await; + add_message_at( + &sm, + &stale_but_modified, + "older message", + "2026-01-01T00:00:00Z", + ) + .await; + set_sessions_updated_at( + &sm, + std::slice::from_ref(&stale_but_modified), + "2026-02-01T00:00:00Z", + ) + .await; + + let active_but_not_modified = + create_session_for_list(&sm, "/tmp/session-list", false).await; + add_message_at( + &sm, + &active_but_not_modified, + "newer message", + "2026-01-02T00:00:00Z", + ) + .await; + set_sessions_updated_at( + &sm, + std::slice::from_ref(&active_but_not_modified), + "2026-01-15T00:00:00Z", + ) + .await; + + assert_session_list_page( + &sm, + None, + None, + 2, + &[active_but_not_modified, stale_but_modified], + false, + ) + .await; + } + + #[tokio::test] + async fn test_session_list_paged_uses_id_tiebreaker_for_duplicate_activity_time() { let temp_dir = TempDir::new().unwrap(); let sm = SessionManager::new(temp_dir.path().to_path_buf()); let mut expected_ids = Vec::new(); diff --git a/crates/goose/src/slash_commands/recipe_slash_command.rs b/crates/goose/src/slash_commands/recipe_slash_command.rs index 78e80e272111..18e4c82b3d7e 100644 --- a/crates/goose/src/slash_commands/recipe_slash_command.rs +++ b/crates/goose/src/slash_commands/recipe_slash_command.rs @@ -80,6 +80,7 @@ pub(super) fn commands_from_mappings(mappings: Vec) -> Vec< name, description: metadata.description, source: SlashCommandSource::Recipe, + source_path: Some(mapping.recipe_path), input_hint: metadata.input_hint, }) }) diff --git a/crates/goose/src/slash_commands/skill_slash_command.rs b/crates/goose/src/slash_commands/skill_slash_command.rs index f85554dcb239..62f941e5fa61 100644 --- a/crates/goose/src/slash_commands/skill_slash_command.rs +++ b/crates/goose/src/slash_commands/skill_slash_command.rs @@ -73,6 +73,7 @@ pub(super) fn commands_from_sources(sources: Vec) -> Vec Vec { name: command.name.to_string(), description: command.description.to_string(), source: SlashCommandSource::Builtin, + source_path: None, input_hint: None, }) .collect() @@ -80,6 +81,7 @@ mod tests { name: name.to_string(), description: format!("{name} description"), source, + source_path: None, input_hint: None, } } diff --git a/crates/goose/src/slash_commands/types.rs b/crates/goose/src/slash_commands/types.rs index 53783d9b5785..89edfc8f98ed 100644 --- a/crates/goose/src/slash_commands/types.rs +++ b/crates/goose/src/slash_commands/types.rs @@ -10,5 +10,6 @@ pub struct SlashCommandEntry { pub name: String, pub description: String, pub source: SlashCommandSource, + pub source_path: Option, pub input_hint: Option, } diff --git a/crates/goose/tests/acp_common_tests/mod.rs b/crates/goose/tests/acp_common_tests/mod.rs index 0065a7bd10d9..5d1e8592c5d8 100644 --- a/crates/goose/tests/acp_common_tests/mod.rs +++ b/crates/goose/tests/acp_common_tests/mod.rs @@ -71,7 +71,9 @@ pub async fn run_list_sessions() { // createdAt is a dynamic timestamp — verify it exists then remove for comparison. if let Some(ref mut meta) = s.meta { assert!(meta.get("createdAt").and_then(|v| v.as_str()).is_some()); + assert!(meta.get("lastMessageAt").and_then(|v| v.as_str()).is_some()); meta.remove("createdAt"); + meta.remove("lastMessageAt"); // Provider/model metadata varies by test fixture; not relevant here. meta.remove("providerId"); meta.remove("modelId"); diff --git a/crates/goose/tests/agent.rs b/crates/goose/tests/agent.rs index 3a932e073218..9a7ee1fa870a 100644 --- a/crates/goose/tests/agent.rs +++ b/crates/goose/tests/agent.rs @@ -1122,6 +1122,566 @@ mod tests { } } + #[cfg(test)] + mod thinking_preservation_tests { + use super::*; + use async_trait::async_trait; + use goose::agents::{AgentConfig, SessionConfig}; + use goose::config::permission::PermissionManager; + use goose::config::GooseMode; + use goose::conversation::message::{Message, MessageContent}; + use goose::providers::base::{MessageStream, Provider, ProviderDef, ProviderMetadata}; + use goose::session::session_manager::SessionType; + use goose::session::SessionManager; + use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; + use goose_providers::errors::ProviderError; + use goose_providers::model::ModelConfig; + use rmcp::model::{CallToolRequestParams, Tool}; + use rmcp::object; + use std::path::PathBuf; + use std::sync::atomic::{AtomicUsize, Ordering}; + + /// Simulates DeepSeek/Kimi streaming: reasoning_content arrives in an early + /// chunk, the tool call arrives in a later chunk with no reasoning_content. + struct ThinkingStreamProvider { + call_count: AtomicUsize, + name: &'static str, + } + + impl ThinkingStreamProvider { + fn new(name: &'static str) -> Self { + Self { + call_count: AtomicUsize::new(0), + name, + } + } + } + + impl goose::providers::base::ProviderDescriptor for ThinkingStreamProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata { + name: "thinking-stream-mock".to_string(), + display_name: "Thinking Stream Mock".to_string(), + description: "Mock for thinking preservation tests".to_string(), + default_model: "mock-model".to_string(), + known_models: vec![], + model_doc_link: "".to_string(), + config_keys: vec![], + setup_steps: vec![], + model_selection_hint: None, + fast_model: None, + } + } + } + + impl ProviderDef for ThinkingStreamProvider { + type Provider = Self; + + fn from_env( + _extensions: Vec, + _tls_config: Option, + ) -> futures::future::BoxFuture<'static, anyhow::Result> { + unimplemented!() + } + } + + #[async_trait] + impl Provider for ThinkingStreamProvider { + async fn stream( + &self, + _model_config: &ModelConfig, + _session_id: &str, + _system_prompt: &str, + _messages: &[Message], + _tools: &[Tool], + ) -> Result { + let call = self.call_count.fetch_add(1, Ordering::SeqCst); + let usage = ProviderUsage::new( + "mock-model".to_string(), + Usage::new(Some(10), Some(20), Some(30)), + ); + match call { + 0 => { + // Chunk 1: reasoning_content only (no tool call) + let thinking = + Message::assistant().with_thinking("I should call test_tool", "sig_0"); + // Chunk 2: tool call only (no reasoning_content) — the bug scenario + let tool_call = CallToolRequestParams::new("test_tool") + .with_arguments(object!({"param": "value"})); + let tool_msg = + Message::assistant().with_tool_request("call_1", Ok(tool_call)); + let stream = futures::stream::iter(vec![ + Ok((Some(thinking), None)), + Ok((Some(tool_msg), Some(usage))), + ]); + Ok(Box::pin(stream)) + } + _ => { + let msg = Message::assistant().with_text("Done."); + Ok(Box::pin(futures::stream::once(async move { + Ok((Some(msg), Some(usage))) + }))) + } + } + } + + fn get_name(&self) -> &str { + self.name + } + } + + async fn run_and_collect(provider_name: &'static str) -> Result> { + let temp_dir = tempfile::tempdir()?; + let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf())); + let config = AgentConfig::new( + session_manager.clone(), + PermissionManager::instance(), + None, + GooseMode::Auto, + true, + GoosePlatform::GooseCli, + ); + let agent = Agent::with_config(config); + let provider = Arc::new(ThinkingStreamProvider::new(provider_name)); + + let session = session_manager + .create_session( + PathBuf::default(), + format!("{provider_name}-thinking-test"), + SessionType::Hidden, + GooseMode::default(), + ) + .await?; + + let session_id = session.id.clone(); + agent + .update_provider(provider, ModelConfig::new("mock-model"), &session_id) + .await?; + + let session_config = SessionConfig { + id: session_id.clone(), + schedule_id: None, + max_turns: Some(2), + retry_config: None, + }; + + let reply_stream = agent + .reply( + Message::user().with_text("Use the test tool"), + session_config, + None, + ) + .await?; + tokio::pin!(reply_stream); + + while let Some(event) = reply_stream.next().await { + event?; + } + + let reloaded = session_manager.get_session(&session_id, true).await?; + Ok(reloaded + .conversation + .expect("should have conversation") + .messages() + .to_vec()) + } + + fn assert_formatter_adds_reasoning_to_tool_calls(messages: &[Message], provider: &str) { + use goose_providers::formats::openai::{ + format_messages_with_options, OpenAiFormatOptions, + }; + use goose_providers::images::ImageFormat; + + assert!( + messages.iter().any(|m| m + .content + .iter() + .any(|c| matches!(c, MessageContent::Thinking(_)))), + "{provider}: conversation must contain at least one Thinking message" + ); + assert!( + messages.iter().any(|m| m + .content + .iter() + .any(|c| matches!(c, MessageContent::ToolRequest(_)))), + "{provider}: conversation must contain at least one tool-call message" + ); + + let spec = format_messages_with_options( + messages, + &ImageFormat::OpenAi, + OpenAiFormatOptions { + preserve_thinking_context: true, + }, + ); + let has_reasoning_on_tool_call = spec.iter().any(|m| { + m.get("tool_calls") + .and_then(|tc| tc.as_array()) + .is_some_and(|a| !a.is_empty()) + && m.get("reasoning_content").is_some() + }); + assert!( + has_reasoning_on_tool_call, + "{provider}: formatter must produce reasoning_content on assistant tool-call \ + messages — {provider} returns HTTP 400 when it is absent on the next turn" + ); + } + + /// DeepSeek streams reasoning_content before the tool-call chunk. The formatter + /// must attach it to the tool-call message so the next turn is accepted. + #[tokio::test] + async fn test_deepseek_thinking_preserved_in_tool_call_message() -> Result<()> { + let messages = run_and_collect("deepseek-mock").await?; + assert_formatter_adds_reasoning_to_tool_calls(&messages, "DeepSeek"); + Ok(()) + } + + /// Kimi has the same streaming behaviour as DeepSeek. + #[tokio::test] + async fn test_kimi_thinking_preserved_in_tool_call_message() -> Result<()> { + let messages = run_and_collect("kimi-mock").await?; + assert_formatter_adds_reasoning_to_tool_calls(&messages, "Kimi"); + Ok(()) + } + + /// Simulates a provider that emits reasoning and a tool call in the same + /// streamed message (no prior thinking-only chunk). + struct CombinedThinkingToolProvider { + call_count: AtomicUsize, + } + + impl CombinedThinkingToolProvider { + fn new() -> Self { + Self { + call_count: AtomicUsize::new(0), + } + } + } + + impl goose::providers::base::ProviderDescriptor for CombinedThinkingToolProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata { + name: "combined-thinking-tool-mock".to_string(), + display_name: "Combined Thinking+Tool Mock".to_string(), + description: "Mock for combined thinking+tool call in one chunk".to_string(), + default_model: "mock-model".to_string(), + known_models: vec![], + model_doc_link: "".to_string(), + config_keys: vec![], + setup_steps: vec![], + model_selection_hint: None, + fast_model: None, + } + } + } + + impl ProviderDef for CombinedThinkingToolProvider { + type Provider = Self; + + fn from_env( + _extensions: Vec, + _tls_config: Option, + ) -> futures::future::BoxFuture<'static, anyhow::Result> { + unimplemented!() + } + } + + #[async_trait] + impl Provider for CombinedThinkingToolProvider { + async fn stream( + &self, + _model_config: &ModelConfig, + _session_id: &str, + _system_prompt: &str, + _messages: &[Message], + _tools: &[Tool], + ) -> Result { + let call = self.call_count.fetch_add(1, Ordering::SeqCst); + let usage = ProviderUsage::new( + "mock-model".to_string(), + Usage::new(Some(10), Some(20), Some(30)), + ); + match call { + 0 => { + // Single chunk: reasoning_content AND tool call together + let tool_call = CallToolRequestParams::new("test_tool") + .with_arguments(object!({"param": "value"})); + let combined = Message::assistant() + .with_thinking("I should call test_tool", "sig_0") + .with_tool_request("call_1", Ok(tool_call)); + Ok(Box::pin(futures::stream::once(async move { + Ok((Some(combined), Some(usage))) + }))) + } + _ => { + let msg = Message::assistant().with_text("Done."); + Ok(Box::pin(futures::stream::once(async move { + Ok((Some(msg), Some(usage))) + }))) + } + } + } + + fn get_name(&self) -> &str { + "combined-thinking-tool-mock" + } + } + + /// When reasoning arrives in the same chunk as the tool call (no prior + /// thinking-only message), the agent must attach it to the persisted + /// request_msg so the formatter can emit reasoning_content on the next turn. + #[tokio::test] + async fn test_reasoning_preserved_when_combined_with_tool_call() -> Result<()> { + let temp_dir = tempfile::tempdir()?; + let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf())); + let config = AgentConfig::new( + session_manager.clone(), + PermissionManager::instance(), + None, + GooseMode::Auto, + true, + GoosePlatform::GooseCli, + ); + let agent = Agent::with_config(config); + let provider = Arc::new(CombinedThinkingToolProvider::new()); + + let session = session_manager + .create_session( + PathBuf::default(), + "combined-thinking-tool-test".to_string(), + SessionType::Hidden, + GooseMode::default(), + ) + .await?; + + let session_id = session.id.clone(); + agent + .update_provider(provider, ModelConfig::new("mock-model"), &session_id) + .await?; + + let session_config = SessionConfig { + id: session_id.clone(), + schedule_id: None, + max_turns: Some(2), + retry_config: None, + }; + + let reply_stream = agent + .reply( + Message::user().with_text("Use the test tool"), + session_config, + None, + ) + .await?; + tokio::pin!(reply_stream); + while let Some(event) = reply_stream.next().await { + match event { + Ok(_) => {} + Err(e) => return Err(e), + } + } + + let reloaded = session_manager.get_session(&session_id, true).await?; + let messages = reloaded + .conversation + .expect("should have conversation") + .messages() + .to_vec(); + + assert_formatter_adds_reasoning_to_tool_calls(&messages, "combined-thinking-tool"); + Ok(()) + } + + /// Simulates the DeepSeek/Kimi multi-tool-call case: thinking arrives as a + /// separate stream chunk, then both tool calls arrive together in a second + /// chunk with no thinking. Before the fix, the second tool-call message + /// (asst(TC2)) received no reasoning_content because lines 210-213 in + /// format_messages_with_options cleared tool_call_turn_reasoning after the + /// first tool result. + struct MultiToolThinkingProvider { + call_count: AtomicUsize, + } + + impl MultiToolThinkingProvider { + fn new() -> Self { + Self { + call_count: AtomicUsize::new(0), + } + } + } + + impl goose::providers::base::ProviderDescriptor for MultiToolThinkingProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata { + name: "multi-tool-thinking-mock".to_string(), + display_name: "Multi Tool Thinking Mock".to_string(), + description: "Mock for multi-tool thinking preservation".to_string(), + default_model: "mock-model".to_string(), + known_models: vec![], + model_doc_link: "".to_string(), + config_keys: vec![], + setup_steps: vec![], + model_selection_hint: None, + fast_model: None, + } + } + } + + impl ProviderDef for MultiToolThinkingProvider { + type Provider = Self; + + fn from_env( + _extensions: Vec, + _tls_config: Option, + ) -> futures::future::BoxFuture<'static, anyhow::Result> { + unimplemented!() + } + } + + #[async_trait] + impl Provider for MultiToolThinkingProvider { + async fn stream( + &self, + _model_config: &ModelConfig, + _session_id: &str, + _system_prompt: &str, + _messages: &[Message], + _tools: &[Tool], + ) -> Result { + let call = self.call_count.fetch_add(1, Ordering::SeqCst); + let usage = ProviderUsage::new( + "mock-model".to_string(), + Usage::new(Some(10), Some(20), Some(30)), + ); + match call { + 0 => { + // Chunk 1: reasoning only (no tool calls) + let thinking = + Message::assistant().with_thinking("multi-tool reasoning", "sig_0"); + // Chunk 2: two tool calls, no reasoning — the multi-tool bug scenario + let tc1 = CallToolRequestParams::new("tool_a") + .with_arguments(object!({"p": "1"})); + let tc2 = CallToolRequestParams::new("tool_b") + .with_arguments(object!({"p": "2"})); + let tool_msg = Message::assistant() + .with_tool_request("call_1", Ok(tc1)) + .with_tool_request("call_2", Ok(tc2)); + let stream = futures::stream::iter(vec![ + Ok((Some(thinking), None)), + Ok((Some(tool_msg), Some(usage))), + ]); + Ok(Box::pin(stream)) + } + _ => { + let msg = Message::assistant().with_text("Done."); + Ok(Box::pin(futures::stream::once(async move { + Ok((Some(msg), Some(usage))) + }))) + } + } + } + + fn get_name(&self) -> &str { + "multi-tool-thinking-mock" + } + } + + #[tokio::test] + async fn test_reasoning_preserved_on_all_tool_calls_when_thinking_in_separate_chunk( + ) -> Result<()> { + use goose_providers::formats::openai::{ + format_messages_with_options, OpenAiFormatOptions, + }; + use goose_providers::images::ImageFormat; + + let temp_dir = tempfile::tempdir()?; + let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf())); + let config = AgentConfig::new( + session_manager.clone(), + PermissionManager::instance(), + None, + GooseMode::Auto, + true, + GoosePlatform::GooseCli, + ); + let agent = Agent::with_config(config); + let provider = Arc::new(MultiToolThinkingProvider::new()); + + let session = session_manager + .create_session( + PathBuf::default(), + "multi-tool-thinking-test".to_string(), + SessionType::Hidden, + GooseMode::default(), + ) + .await?; + + let session_id = session.id.clone(); + agent + .update_provider(provider, ModelConfig::new("mock-model"), &session_id) + .await?; + + let session_config = SessionConfig { + id: session_id.clone(), + schedule_id: None, + max_turns: Some(2), + retry_config: None, + }; + + let reply_stream = agent + .reply( + Message::user().with_text("Use both tools"), + session_config, + None, + ) + .await?; + tokio::pin!(reply_stream); + while let Some(event) = reply_stream.next().await { + event?; + } + + let reloaded = session_manager.get_session(&session_id, true).await?; + let messages = reloaded + .conversation + .expect("should have conversation") + .messages() + .to_vec(); + + let spec = format_messages_with_options( + &messages, + &ImageFormat::OpenAi, + OpenAiFormatOptions { + preserve_thinking_context: true, + }, + ); + + // Both tool calls must end up in one merged assistant message with reasoning_content. + let assistant_msgs: Vec<_> = spec + .iter() + .filter(|m| m.get("role") == Some(&serde_json::json!("assistant"))) + .filter(|m| { + m.get("tool_calls") + .and_then(|tc| tc.as_array()) + .is_some_and(|a| !a.is_empty()) + }) + .collect(); + + assert_eq!( + assistant_msgs.len(), + 1, + "both tool calls must be merged into one assistant message" + ); + assert_eq!( + assistant_msgs[0]["reasoning_content"], "multi-tool reasoning", + "merged message must carry reasoning_content" + ); + let tool_calls = assistant_msgs[0]["tool_calls"].as_array().unwrap(); + assert_eq!(tool_calls.len(), 2, "both tool calls must be present"); + + Ok(()) + } + } + #[cfg(test)] mod goal_checking_tests { use super::*; diff --git a/documentation/docs/experimental/goose-mobile.md b/documentation/docs/experimental/goose-mobile.md index b3ab69e9fc1f..bb2918bdc6de 100644 --- a/documentation/docs/experimental/goose-mobile.md +++ b/documentation/docs/experimental/goose-mobile.md @@ -6,7 +6,7 @@ unlisted: true --- :::info Archived -goose Mobile has been archived. Mobile access to goose is now supported for [iOS devices via tunneling](/docs/experimental/remote-access/mobile-access). +goose Mobile has been archived. The previous iOS mobile tunnel setup is also retired in current goose Desktop builds. ::: goose Mobile is an experimental Android project inspired by the goose application. It acts as an open agent on your phone, automating multistep tasks, responding to notifications, and even replacing your home screen for maximum efficiency. @@ -40,4 +40,4 @@ We welcome contributions! See the [Contributing Guide](https://github.com/aaif-g --- -For more scenarios, instructions, and development setup, visit the [goose Mobile repository](https://github.com/aaif-goose/goose-mobile). \ No newline at end of file +For more scenarios, instructions, and development setup, visit the [goose Mobile repository](https://github.com/aaif-goose/goose-mobile). diff --git a/documentation/docs/experimental/mobile-access.md b/documentation/docs/experimental/mobile-access.md index a205dabb89d6..0c1dea38d72a 100644 --- a/documentation/docs/experimental/mobile-access.md +++ b/documentation/docs/experimental/mobile-access.md @@ -2,7 +2,7 @@ title: Mobile Access via Secure Tunneling sidebar_position: 3 sidebar_label: Mobile Access -description: Enable remote access to goose from mobile devices using secure tunneling. +description: Mobile access via secure tunneling is no longer available in goose Desktop. unlisted: true --- diff --git a/documentation/docs/experimental/remote-access/index.md b/documentation/docs/experimental/remote-access/index.md index 34cdb7a97722..fd22695f40aa 100644 --- a/documentation/docs/experimental/remote-access/index.md +++ b/documentation/docs/experimental/remote-access/index.md @@ -1,7 +1,7 @@ --- title: Remote Access sidebar_position: 2 -description: Access goose remotely from mobile devices and messaging platforms. +description: Access goose remotely from messaging platforms. --- import Card from '@site/src/components/Card'; @@ -9,14 +9,9 @@ import styles from '@site/src/components/Card/styles.module.css'; # Remote Access -Access goose from anywhere using mobile apps or messaging platforms. These features let you interact with goose when you're away from your computer. +Access goose from anywhere using supported messaging platforms. These features let you interact with goose when you're away from your computer.
- button in the top-left to open the sidebar -3. Click `Settings` in the sidebar -4. Click `Session` -5. Scroll down to the `Mobile App` section and click `Start Tunnel` - -Once the tunnel starts, you'll see a `Mobile App Connection` QR code for configuring the app. - -:::info -Click `Stop Tunnel` at any time to close the connection. -::: - -### Connect the App -1. Open the **goose AI** app on your iOS mobile device -2. Scan the `Mobile App Connection` QR code displayed in goose Desktop -3. The app will automatically configure the connection - -You can now access goose Desktop from your mobile device. - -## What You Can Do - -The mobile app gives you full access to goose: -- Start new conversations or continue existing sessions -- Use all your goose extensions and configurations -- Work from anywhere while your computer handles the processing - -## Additional Resources - -import ContentCardCarousel from '@site/src/components/ContentCardCarousel'; -import mobileShots from '@site/blog/2025-12-19-goose-mobile-terminal/mobile_shots.png'; - - +The previous setup flow depended on a Desktop settings panel that started a tunnel and displayed a mobile connection QR code. That panel and the `/tunnel/start` and `/tunnel/stop` APIs have been removed, so new mobile app connections cannot be configured from Desktop. diff --git a/documentation/docs/guides/context-engineering/hooks.md b/documentation/docs/guides/context-engineering/hooks.md index cb17b946647e..fb45b73f48ae 100644 --- a/documentation/docs/guides/context-engineering/hooks.md +++ b/documentation/docs/guides/context-engineering/hooks.md @@ -133,7 +133,7 @@ Use `${PLUGIN_ROOT}` in a command to reference the plugin directory. goose also |---|---|---| | `SessionStart` | A session starts | None | | `SessionEnd` | A session ends | None | -| `Stop` | goose receives a stop event | None | +| `Stop` | goose finishes a turn or receives a stop event | None | | `UserPromptSubmit` | The user submits a prompt | Prompt text | | `PreToolUse` | Before goose runs a tool | Tool name | | `PostToolUse` | After a tool succeeds | Tool name | @@ -151,7 +151,7 @@ The matcher is a regular expression matched against the most relevant string for ## Hook Payload -When a hook runs, goose writes a JSON payload to the command's stdin. The payload always includes the event name and session ID, and may include fields such as the tool name, tool input, user message, or working directory. +When a hook runs, goose writes a JSON payload to the command's stdin. The payload always includes the event name and session ID, and may include fields such as the tool name, tool input, user message, last assistant message, or working directory. Example payload for a tool event: @@ -166,6 +166,16 @@ Example payload for a tool event: } ``` +Example payload for a `Stop` event after an assistant reply: + +```json +{ + "event": "Stop", + "session_id": "abc-123", + "last_assistant_message": "Done. I updated the file and ran the tests." +} +``` + Example script that reads the payload: ```bash diff --git a/documentation/package-lock.json b/documentation/package-lock.json index 5461875e1420..ffec5c1dc579 100644 --- a/documentation/package-lock.json +++ b/documentation/package-lock.json @@ -19181,9 +19181,9 @@ } }, "node_modules/webpack-dev-server": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.4.tgz", - "integrity": "sha512-GqDPGZN9bRqKBTkp4aWkobDDHMsrXKoGSdOH56smIri8qR0JG8gfL8/v/f/OZR3/OKXjG8uwJbFVhKm/FNU/UA==", + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.5.tgz", + "integrity": "sha512-4wZtCquSuv9CKX8oybo+mqxtxZqWz47uM1Ch94lxowBztOhWCbhqvRbfC/mODOwxgV2brY+JGZpHq58/SuVFYg==", "license": "MIT", "dependencies": { "@types/bonjour": "^3.5.13", diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index e299f4b33943..84c028bf0691 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -3727,47 +3727,6 @@ } } }, - "/tunnel/start": { - "post": { - "tags": [ - "super::routes::tunnel" - ], - "summary": "Start the tunnel", - "operationId": "start_tunnel", - "responses": { - "200": { - "description": "Tunnel started successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TunnelInfo" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, "/tunnel/status": { "get": { "tags": [ @@ -3788,30 +3747,6 @@ } } } - }, - "/tunnel/stop": { - "post": { - "tags": [ - "super::routes::tunnel" - ], - "summary": "Stop the tunnel", - "operationId": "stop_tunnel", - "responses": { - "200": { - "description": "Tunnel stopped successfully" - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } } }, "components": { @@ -8189,6 +8124,11 @@ "id": { "type": "string" }, + "last_message_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, "last_message_snippet": { "type": "string", "nullable": true diff --git a/ui/desktop/package.json b/ui/desktop/package.json index af3d17ca1a63..3b3ae0cc9a5f 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -82,7 +82,6 @@ "katex": "^0.16.33", "lodash": "^4.17.23", "lucide-react": "^0.575.0", - "qrcode.react": "^4.2.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-icons": "^5.5.0", diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 072346ffb573..19a9fa303265 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -8,8 +8,7 @@ import { useLocation, useSearchParams, } from 'react-router-dom'; -import { openSharedSessionFromDeepLink, importNostrSessionFromDeepLink } from './sessionLinks'; -import { type SharedSessionDetails } from './sharedSessions'; +import { importNostrSessionFromDeepLink } from './sessionLinks'; import { ErrorUI } from './components/ErrorBoundary'; import { ExtensionInstallModal } from './components/ExtensionInstallModal'; import RecipeParamsModalContainer from './components/RecipeParamsModalContainer'; @@ -31,7 +30,6 @@ interface PairRouteState { } import SettingsView, { SettingsViewOptions } from './components/settings/SettingsView'; import SessionsView from './components/sessions/SessionsView'; -import SharedSessionView from './components/sessions/SharedSessionView'; import SchedulesView from './components/schedule/SchedulesView'; import ProviderSettings from './components/settings/providers/ProviderSettingsPage'; import { AppLayout } from './components/Layout/AppLayout'; @@ -183,7 +181,7 @@ const PairRouteWrapper = ({ return null; }; -const SettingsRoute = ({ activeSessionId }: { activeSessionId?: string }) => { +const SettingsRoute = () => { const location = useLocation(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); @@ -199,13 +197,7 @@ const SettingsRoute = ({ activeSessionId }: { activeSessionId?: string }) => { viewOptions.section = sectionFromUrl; } - return ( - navigate('/')} - setView={setView} - viewOptions={{ ...viewOptions, sessionId: activeSessionId }} - /> - ); + return navigate('/')} setView={setView} viewOptions={viewOptions} />; }; const SessionsRoute = () => { @@ -278,47 +270,6 @@ const ConfigureProvidersRoute = () => { ); }; -// Wrapper component for SharedSessionRoute to access parent state -const SharedSessionRouteWrapper = ({ - isLoadingSharedSession, - setIsLoadingSharedSession, - sharedSessionError, -}: { - isLoadingSharedSession: boolean; - setIsLoadingSharedSession: (loading: boolean) => void; - sharedSessionError: string | null; -}) => { - const location = useLocation(); - const setView = useNavigation(); - - const historyState = window.history.state; - const sessionDetails = (location.state?.sessionDetails || - historyState?.sessionDetails) as SharedSessionDetails | null; - const error = location.state?.error || historyState?.error || sharedSessionError; - const shareToken = location.state?.shareToken || historyState?.shareToken; - const baseUrl = location.state?.baseUrl || historyState?.baseUrl; - - return ( - { - if (shareToken && baseUrl) { - setIsLoadingSharedSession(true); - try { - await openSharedSessionFromDeepLink(`goose://sessions/${shareToken}`, setView, baseUrl); - } catch (error) { - console.error('Failed to retry loading shared session:', error); - } finally { - setIsLoadingSharedSession(false); - } - } - }} - /> - ); -}; - const ExtensionsRoute = () => { const navigate = useNavigate(); const location = useLocation(); @@ -354,8 +305,6 @@ const ExtensionsRoute = () => { export function AppInner() { const [fatalError, setFatalError] = useState(null); - const [isLoadingSharedSession, setIsLoadingSharedSession] = useState(false); - const [sharedSessionError, setSharedSessionError] = useState(null); const navigate = useNavigate(); const setView = useNavigation(); @@ -445,46 +394,32 @@ export function AppInner() { }, []); useEffect(() => { - const handleOpenSharedSession = async (_event: IpcRendererEvent, ...args: unknown[]) => { + const handleOpenSessionShare = async (_event: IpcRendererEvent, ...args: unknown[]) => { const link = args[0] as string; - window.electron.logInfo(`Opening shared session from deep link ${link}`); - setIsLoadingSharedSession(true); - setSharedSessionError(null); + window.electron.logInfo('Opening session share link'); try { if (link.startsWith('goose://sessions/nostr')) { await importNostrSessionFromDeepLink(link); navigate('/sessions'); return; } - await openSharedSessionFromDeepLink(link, (_view: View, options?: ViewOptions) => { - navigate('/shared-session', { state: options }); - }); + + toast.error('Unsupported session share link'); + navigate('/sessions'); } catch (error) { - console.error('Unexpected error opening shared session:', error); + console.error('Unexpected error opening Nostr session share:', error); trackErrorWithContext(error, { component: 'AppInner', - action: 'open_shared_session', + action: 'open_nostr_session_share', recoverable: true, }); - if (link.startsWith('goose://sessions/nostr')) { - toast.error(`Failed to import Nostr session: ${errorMessage(error, 'Unknown error')}`); - navigate('/sessions'); - } else { - const shareToken = link.replace('goose://sessions/', ''); - const options = { - sessionDetails: null, - error: errorMessage(error, 'Unknown error'), - shareToken, - }; - navigate('/shared-session', { state: options }); - } - } finally { - setIsLoadingSharedSession(false); + toast.error(`Failed to import Nostr session: ${errorMessage(error, 'Unknown error')}`); + navigate('/sessions'); } }; - window.electron.on('open-shared-session', handleOpenSharedSession); + window.electron.on('open-shared-session', handleOpenSessionShare); return () => { - window.electron.off('open-shared-session', handleOpenSharedSession); + window.electron.off('open-shared-session', handleOpenSessionShare); }; }, [navigate]); @@ -506,23 +441,6 @@ export function AppInner() { }; }, []); - // Show a toast if mesh is the configured provider but isn't running. - useEffect(() => { - const handler = () => { - toast.warn( - "Inference Mesh is set as your provider but isn't running. Open Settings -> Mesh to start it. Keep ApeMind Agent running to stay connected.", - { - autoClose: false, - toastId: 'mesh-not-running', - } - ); - }; - window.electron.on('mesh-not-running', handler); - return () => { - window.electron.off('mesh-not-running', handler); - }; - }, []); - // Prevent default drag and drop behavior globally to avoid opening files in new windows // but allow our React components to handle drops in designated areas useEffect(() => { @@ -701,14 +619,7 @@ export function AppInner() { /> } /> - - } - /> + } /> } /> } /> } /> - - } - /> } /> diff --git a/ui/desktop/src/acp/__tests__/autocomplete.test.ts b/ui/desktop/src/acp/__tests__/autocomplete.test.ts new file mode 100644 index 000000000000..9ce48bc847b1 --- /dev/null +++ b/ui/desktop/src/acp/__tests__/autocomplete.test.ts @@ -0,0 +1,113 @@ +import type { AgentMention, AvailableCommand } from '@aaif/goose-sdk'; +import { describe, expect, it } from 'vitest'; +import { agentMentionToDisplayItem, availableCommandToDisplayItem } from '../autocomplete'; + +function command(overrides: Partial): AvailableCommand { + return { + name: 'release', + description: 'Run release workflow', + ...overrides, + }; +} + +function agent(overrides: Partial = {}): AgentMention { + return { + name: 'reviewer', + description: 'Review code changes', + sourceType: 'agent', + mention: '@reviewer', + ...overrides, + }; +} + +describe('ACP autocomplete mapping', () => { + it('maps builtin commands to display items with descriptions', () => { + expect( + availableCommandToDisplayItem( + command({ + _meta: { commandType: 'Builtin' }, + }) + ) + ).toEqual({ + name: 'release', + extra: 'Run release workflow', + itemType: 'Builtin', + relativePath: 'release', + }); + }); + + it('maps skill commands to display items with descriptions', () => { + expect( + availableCommandToDisplayItem( + command({ + _meta: { commandType: 'Skill' }, + }) + ) + ).toEqual({ + name: 'release', + extra: 'Run release workflow', + itemType: 'Skill', + relativePath: 'release', + }); + }); + + it('maps recipe commands and prefers sourcePath for display text', () => { + expect( + availableCommandToDisplayItem( + command({ + _meta: { + commandType: 'Recipe', + sourcePath: '/tmp/release.yaml', + }, + }) + ) + ).toEqual({ + name: 'release', + extra: '/tmp/release.yaml', + itemType: 'Recipe', + relativePath: 'release', + }); + }); + + it('falls back to recipe descriptions when sourcePath is missing', () => { + expect( + availableCommandToDisplayItem( + command({ + _meta: { commandType: 'Recipe' }, + }) + ) + ).toEqual({ + name: 'release', + extra: 'Run release workflow', + itemType: 'Recipe', + relativePath: 'release', + }); + }); + + it('skips commands without a valid commandType', () => { + expect(availableCommandToDisplayItem(command({}))).toBeNull(); + expect( + availableCommandToDisplayItem( + command({ + _meta: { commandType: 'Agent' }, + }) + ) + ).toBeNull(); + }); + + it('maps agent mentions and uses server-provided mention text', () => { + expect(agentMentionToDisplayItem(agent())).toEqual({ + name: 'reviewer', + extra: 'Review code changes', + itemType: 'Agent', + relativePath: 'reviewer', + insertText: '@reviewer ', + }); + }); + + it('does not add a second trailing space to agent mention text', () => { + expect(agentMentionToDisplayItem(agent({ mention: '@reviewer ' }))).toMatchObject({ + insertText: '@reviewer ', + }); + }); +}); diff --git a/ui/desktop/src/acp/__tests__/chatNotifications.test.ts b/ui/desktop/src/acp/__tests__/chatNotifications.test.ts index 440209c93457..98393a60d9a8 100644 --- a/ui/desktop/src/acp/__tests__/chatNotifications.test.ts +++ b/ui/desktop/src/acp/__tests__/chatNotifications.test.ts @@ -62,6 +62,8 @@ function snapshotWithName(name: string): AcpChatSessionSnapshot { chatState: ChatState.Idle, sessionLoadError: undefined, activePromptAttemptId: null, + activeRunId: null, + pendingCancelPromptAttemptId: null, }; } @@ -81,6 +83,8 @@ function snapshotWithoutSession(): AcpChatSessionSnapshot { chatState: ChatState.Idle, sessionLoadError: undefined, activePromptAttemptId: null, + activeRunId: null, + pendingCancelPromptAttemptId: null, }; } diff --git a/ui/desktop/src/acp/__tests__/chatSessionController.test.ts b/ui/desktop/src/acp/__tests__/chatSessionController.test.ts index cb598fa2e1fb..be0f0b9a720d 100644 --- a/ui/desktop/src/acp/__tests__/chatSessionController.test.ts +++ b/ui/desktop/src/acp/__tests__/chatSessionController.test.ts @@ -1,8 +1,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { Session } from '../../api'; +import type { Message, Session } from '../../api'; +import { ChatState } from '../../types/chatState'; import { acpChatSessionController } from '../chatSessionController'; -import { acpChatSessionActions, acpChatSessionStore } from '../chatSessionStore'; -import { acpLoadSession, isAcpSessionLoadInFlight, sessionInfoToSession } from '../sessions'; +import { + acpChatSessionActions, + acpChatSessionStore, + type AcpChatSessionSnapshot, +} from '../chatSessionStore'; +import { acpCancelPrompt, acpPromptSession } from '../prompt'; +import { + acpLoadSession, + acpTruncateSessionConversation, + isAcpSessionLoadInFlight, + sessionInfoToSession, +} from '../sessions'; vi.mock('../../utils/extensionErrorUtils', () => ({ showExtensionLoadResults: vi.fn(), @@ -20,7 +31,10 @@ vi.mock('../chatSessionStore', () => ({ finishPromptAttemptIfCurrent: vi.fn(), isCurrentPromptAttempt: vi.fn(), setMessages: vi.fn(), + addPendingLocalSteerMessage: vi.fn(), clearActivePromptAttempt: vi.fn(), + startPromptCancellation: vi.fn(), + clearPromptCancellation: vi.fn(), setChatState: vi.fn(), setSessionMetadata: vi.fn(), setSessionLoadError: vi.fn(), @@ -35,8 +49,23 @@ vi.mock('../sessions', () => ({ acpTruncateSessionConversation: vi.fn(), })); +vi.mock('../prompt', () => ({ + acpCancelPrompt: vi.fn(), + acpPromptSession: vi.fn(), +})); + const SESSION_ID = 'session-1'; +function userMessage(): Message & { id: string } { + return { + id: 'message-1', + role: 'user', + created: 123, + content: [{ type: 'text', text: 'Hello' }], + metadata: { userVisible: true, agentVisible: true }, + }; +} + function loadedSession(): Session { return { id: SESSION_ID, @@ -63,6 +92,27 @@ function mockLoadResult() { } as Awaited>; } +function snapshotWithActivePrompt(activePromptAttemptId: string | null): AcpChatSessionSnapshot { + return { + session: undefined, + messages: [], + tokenState: { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + accumulatedInputTokens: 0, + accumulatedOutputTokens: 0, + accumulatedTotalTokens: 0, + }, + notifications: [], + chatState: activePromptAttemptId ? ChatState.Streaming : ChatState.Idle, + sessionLoadError: undefined, + activePromptAttemptId, + activeRunId: activePromptAttemptId ? 'run-1' : null, + pendingCancelPromptAttemptId: null, + }; +} + describe('acpChatSessionController.loadSession', () => { beforeEach(() => { vi.clearAllMocks(); @@ -97,3 +147,131 @@ describe('acpChatSessionController.loadSession', () => { ); }); }); + +describe('acpChatSessionController.stop', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(acpCancelPrompt).mockResolvedValue(undefined); + }); + + it('marks cancellation pending while clearing visible prompt activity', () => { + vi.mocked(acpChatSessionStore.getSnapshot).mockReturnValue( + snapshotWithActivePrompt('attempt-1') + ); + + acpChatSessionController.stop(SESSION_ID); + + expect(acpChatSessionActions.startPromptCancellation).toHaveBeenCalledWith( + SESSION_ID, + 'attempt-1' + ); + expect(acpCancelPrompt).toHaveBeenCalledWith(SESSION_ID); + }); +}); + +describe('acpChatSessionController.submitMessage', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(acpChatSessionStore.getSnapshot).mockReturnValue(snapshotWithActivePrompt(null)); + vi.mocked(acpPromptSession).mockResolvedValue({ stopReason: 'cancelled' } as never); + vi.mocked(acpChatSessionActions.clearPromptCancellation).mockReturnValue(undefined); + vi.mocked(acpChatSessionActions.finishPromptAttemptIfCurrent).mockReturnValue(true); + }); + + it('clears a pending cancellation barrier when the original prompt settles', async () => { + vi.mocked(acpChatSessionActions.clearPromptCancellation).mockReturnValueOnce( + snapshotWithActivePrompt(null) + ); + const onFinish = vi.fn(); + + await acpChatSessionController.submitMessage(SESSION_ID, userMessage(), { + getCurrentSnapshot: () => snapshotWithActivePrompt(null), + onFinish, + }); + + expect(acpChatSessionActions.clearPromptCancellation).toHaveBeenCalledWith( + SESSION_ID, + expect.any(String) + ); + expect(acpChatSessionActions.finishPromptAttemptIfCurrent).not.toHaveBeenCalled(); + expect(onFinish).not.toHaveBeenCalled(); + }); + + it('rejects while a cancellation barrier is pending', async () => { + vi.mocked(acpChatSessionStore.getSnapshot).mockReturnValue({ + ...snapshotWithActivePrompt(null), + pendingCancelPromptAttemptId: 'attempt-1', + }); + + await expect( + acpChatSessionController.submitMessage(SESSION_ID, userMessage(), { + getCurrentSnapshot: () => snapshotWithActivePrompt(null), + onFinish: vi.fn(), + }) + ).rejects.toThrow('Cannot submit while prompt cancellation is pending'); + + expect(acpChatSessionActions.startPromptAttempt).not.toHaveBeenCalled(); + expect(acpPromptSession).not.toHaveBeenCalled(); + }); +}); + +describe('acpChatSessionController.updateMessage', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(acpTruncateSessionConversation).mockResolvedValue(undefined as never); + vi.mocked(acpChatSessionStore.getSnapshot).mockReturnValue(snapshotWithActivePrompt(null)); + }); + + it('rejects edits before truncating while cancellation is pending', async () => { + vi.mocked(acpChatSessionStore.getSnapshot).mockReturnValue({ + ...snapshotWithActivePrompt(null), + pendingCancelPromptAttemptId: 'attempt-1', + }); + const existingMessage = userMessage(); + const currentSnapshot: AcpChatSessionSnapshot = { + ...snapshotWithActivePrompt(null), + messages: [existingMessage], + }; + + await expect( + acpChatSessionController.updateMessage(SESSION_ID, existingMessage.id, 'Updated', 'edit', { + getCurrentSnapshot: () => currentSnapshot, + onFinish: vi.fn(), + }) + ).rejects.toThrow('Cannot submit while prompt cancellation is pending'); + + expect(acpChatSessionActions.setChatState).not.toHaveBeenCalledWith( + SESSION_ID, + ChatState.Thinking + ); + expect(acpTruncateSessionConversation).not.toHaveBeenCalled(); + expect(acpChatSessionActions.setMessages).not.toHaveBeenCalled(); + expect(acpPromptSession).not.toHaveBeenCalled(); + }); + + it('rejects edits before truncating while a prompt is active', async () => { + vi.mocked(acpChatSessionStore.getSnapshot).mockReturnValue( + snapshotWithActivePrompt('attempt-1') + ); + const existingMessage = userMessage(); + const currentSnapshot: AcpChatSessionSnapshot = { + ...snapshotWithActivePrompt('attempt-1'), + messages: [existingMessage], + }; + + await expect( + acpChatSessionController.updateMessage(SESSION_ID, existingMessage.id, 'Updated', 'edit', { + getCurrentSnapshot: () => currentSnapshot, + onFinish: vi.fn(), + }) + ).rejects.toThrow('Cannot update message while prompt is active'); + + expect(acpChatSessionActions.setChatState).not.toHaveBeenCalledWith( + SESSION_ID, + ChatState.Thinking + ); + expect(acpTruncateSessionConversation).not.toHaveBeenCalled(); + expect(acpChatSessionActions.setMessages).not.toHaveBeenCalled(); + expect(acpPromptSession).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts b/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts index feb750fdc983..23834d0b9189 100644 --- a/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts +++ b/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts @@ -129,6 +129,44 @@ function agentMessageChunkNotification( }; } +function userSteerChunkNotification( + sessionId: string, + messageId: string, + text: string +): SessionNotification { + return { + sessionId, + update: { + sessionUpdate: 'user_message_chunk', + messageId, + content: { + type: 'text', + text, + }, + _meta: { + goose: { + messageId, + steer: true, + }, + }, + } as SessionNotification['update'], + }; +} + +function activeRunNotification(sessionId: string, activeRunId: string | null): SessionNotification { + return { + sessionId, + update: { + sessionUpdate: 'session_info_update', + _meta: { + goose: { + activeRunId, + }, + }, + } as SessionNotification['update'], + }; +} + describe('acpChatSessionStore', () => { const sessionIds = new Set(); const sessionId = (id: string): string => { @@ -224,6 +262,149 @@ describe('acpChatSessionStore', () => { expect(snapshot.chatState).toBe(ChatState.Streaming); }); + it('tracks prompt cancellation separately from visible prompt activity', () => { + const currentSessionId = sessionId('session-1'); + + acpChatSessionActions.startPromptAttempt(currentSessionId, 'attempt-1'); + const cancellationSnapshot = acpChatSessionActions.startPromptCancellation( + currentSessionId, + 'attempt-1' + ); + + expect(cancellationSnapshot).toMatchObject({ + activePromptAttemptId: null, + pendingCancelPromptAttemptId: 'attempt-1', + chatState: ChatState.Idle, + }); + + const staleClearSnapshot = acpChatSessionActions.clearPromptCancellation( + currentSessionId, + 'attempt-2' + ); + expect(staleClearSnapshot).toBeUndefined(); + expect(acpChatSessionStore.getSnapshot(currentSessionId)?.pendingCancelPromptAttemptId).toBe( + 'attempt-1' + ); + + const clearedSnapshot = acpChatSessionActions.clearPromptCancellation( + currentSessionId, + 'attempt-1' + ); + + expect(clearedSnapshot?.pendingCancelPromptAttemptId).toBeNull(); + }); + + it('removes pending local steer messages when cancellation starts', () => { + const currentSessionId = sessionId('session-1'); + const localSteerMessage = { + ...message('steer-1', 'hello'), + metadata: { userVisible: true, agentVisible: true, steer: true }, + }; + + acpChatSessionActions.startPromptAttempt(currentSessionId, 'attempt-1'); + acpChatSessionActions.addPendingLocalSteerMessage(currentSessionId, localSteerMessage); + + expect(acpChatSessionStore.getSnapshot(currentSessionId)?.messages).toHaveLength(1); + + const cancellationSnapshot = acpChatSessionActions.startPromptCancellation( + currentSessionId, + 'attempt-1' + ); + + expect(cancellationSnapshot?.messages).toEqual([]); + }); + + it('keeps confirmed local steer messages when cancellation starts', () => { + const currentSessionId = sessionId('session-1'); + const localSteerMessage = { + ...message('steer-1', 'hello'), + metadata: { userVisible: true, agentVisible: true, steer: true }, + }; + + acpChatSessionActions.startPromptAttempt(currentSessionId, 'attempt-1'); + acpChatSessionActions.addPendingLocalSteerMessage(currentSessionId, localSteerMessage); + acpChatSessionActions.applyAcpSessionNotification( + userSteerChunkNotification(currentSessionId, 'steer-1', 'hello') + ); + + const cancellationSnapshot = acpChatSessionActions.startPromptCancellation( + currentSessionId, + 'attempt-1' + ); + + expect(cancellationSnapshot?.messages).toHaveLength(1); + expect(cancellationSnapshot?.messages[0].id).toBe('steer-1'); + }); + + it('preserves steer text accumulation when another local steer is added', () => { + const currentSessionId = sessionId('session-1'); + const firstSteerMessage = { + ...message('steer-1', 'hello'), + metadata: { userVisible: true, agentVisible: true, steer: true }, + }; + const secondSteerMessage = { + ...message('steer-2', 'second'), + metadata: { userVisible: true, agentVisible: true, steer: true }, + }; + + acpChatSessionActions.startPromptAttempt(currentSessionId, 'attempt-1'); + acpChatSessionActions.addPendingLocalSteerMessage(currentSessionId, firstSteerMessage); + acpChatSessionActions.applyAcpSessionNotification( + userSteerChunkNotification(currentSessionId, 'steer-1', 'hel') + ); + + acpChatSessionActions.addPendingLocalSteerMessage(currentSessionId, secondSteerMessage); + const snapshot = acpChatSessionActions.applyAcpSessionNotification( + userSteerChunkNotification(currentSessionId, 'steer-1', 'lo') + ); + + const firstMessage = snapshot.messages.find((item) => item.id === 'steer-1'); + expect(firstMessage?.content[0]).toMatchObject({ type: 'text', text: 'hello' }); + }); + + it('stores active run ids from session info notifications', () => { + const currentSessionId = sessionId('session-1'); + + const snapshot = acpChatSessionActions.applyAcpSessionNotification( + activeRunNotification(currentSessionId, 'run-1') + ); + + expect(snapshot.activeRunId).toBe('run-1'); + expect(acpChatSessionStore.getSnapshot(currentSessionId)?.activeRunId).toBe('run-1'); + + const clearedSnapshot = acpChatSessionActions.applyAcpSessionNotification( + activeRunNotification(currentSessionId, null) + ); + + expect(clearedSnapshot.activeRunId).toBeNull(); + }); + + it('clears active run ids when the prompt attempt finishes', () => { + const currentSessionId = sessionId('session-1'); + + acpChatSessionActions.startPromptAttempt(currentSessionId, 'attempt-1'); + acpChatSessionActions.applyAcpSessionNotification( + activeRunNotification(currentSessionId, 'run-1') + ); + + expect(acpChatSessionActions.finishPromptAttemptIfCurrent(currentSessionId, 'attempt-1')).toBe( + true + ); + expect(acpChatSessionStore.getSnapshot(currentSessionId)?.activeRunId).toBeNull(); + }); + + it('clears active run ids before replaying a session load', () => { + const currentSessionId = sessionId('session-1'); + + acpChatSessionActions.applyAcpSessionNotification( + activeRunNotification(currentSessionId, 'run-1') + ); + + const snapshot = acpChatSessionActions.startSessionLoad(currentSessionId); + + expect(snapshot.activeRunId).toBeNull(); + }); + it('stores ACP tool notifications and clears them for a new prompt attempt', () => { const currentSessionId = sessionId('session-1'); diff --git a/ui/desktop/src/acp/__tests__/sessionNotificationAdapter.test.ts b/ui/desktop/src/acp/__tests__/sessionNotificationAdapter.test.ts index 9a26e45b88ed..098839155c1e 100644 --- a/ui/desktop/src/acp/__tests__/sessionNotificationAdapter.test.ts +++ b/ui/desktop/src/acp/__tests__/sessionNotificationAdapter.test.ts @@ -67,9 +67,24 @@ function expectOnlyMessagesChange(chatStateChanges: AcpChatStateChange[]): Messa return chatStateChange.messages; } -function expectOnlyNotificationChange( - chatStateChanges: AcpChatStateChange[] -): NotificationEvent { +function expectMessagesAndLocalSteerConfirmation( + chatStateChanges: AcpChatStateChange[], + messageId: string +): Message[] { + expect(chatStateChanges).toHaveLength(2); + + const [messagesChange, confirmationChange] = chatStateChanges; + expect(messagesChange.type).toBe('messages'); + expect(confirmationChange).toEqual({ type: 'localSteerConfirmed', messageId }); + + if (messagesChange.type !== 'messages') { + throw new Error('expected messages state change'); + } + + return messagesChange.messages; +} + +function expectOnlyNotificationChange(chatStateChanges: AcpChatStateChange[]): NotificationEvent { expect(chatStateChanges).toHaveLength(1); const [chatStateChange] = chatStateChanges; @@ -121,6 +136,132 @@ describe('createAcpSessionNotificationAdapter', () => { expect(firstContent(messages[0])).toMatchObject({ type: 'text', text: 'Hell' }); }); + it('reconciles locally rendered steer text with server chunks', () => { + const adapter = createAcpSessionNotificationAdapter([ + { + id: 'steer-1', + role: 'user', + created: 123, + content: [ + { type: 'text', text: 'hello' }, + { type: 'image', data: 'base64-image', mimeType: 'image/png' }, + ], + metadata: { userVisible: true, agentVisible: true, steer: true }, + }, + ]); + + let messages = expectMessagesAndLocalSteerConfirmation( + adapter.apply( + acpUpdate({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'hel' }, + _meta: { + goose: { + messageId: 'steer-1', + steer: true, + }, + }, + } as SessionNotification['update']) + ), + 'steer-1' + ); + + expect(firstContent(messages[0])).toMatchObject({ type: 'text', text: 'hel' }); + expect(messages[0].content[1]).toMatchObject({ + type: 'image', + data: 'base64-image', + mimeType: 'image/png', + }); + expect(messages[0].metadata.steer).toBe(true); + + messages = expectMessagesAndLocalSteerConfirmation( + adapter.apply( + acpUpdate({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'lo' }, + _meta: { + goose: { + messageId: 'steer-1', + steer: true, + }, + }, + } as SessionNotification['update']) + ), + 'steer-1' + ); + + expect(firstContent(messages[0])).toMatchObject({ type: 'text', text: 'hello' }); + + messages = expectMessagesAndLocalSteerConfirmation( + adapter.apply( + acpUpdate({ + sessionUpdate: 'user_message_chunk', + content: { type: 'image', data: 'base64-image', mimeType: 'image/png' }, + _meta: { + goose: { + messageId: 'steer-1', + steer: true, + }, + }, + } as SessionNotification['update']) + ), + 'steer-1' + ); + + expect(messages[0].content).toEqual([ + { type: 'text', text: 'hello' }, + { type: 'image', data: 'base64-image', mimeType: 'image/png' }, + ]); + }); + + it('appends repeated local steer text deltas without collapsing them', () => { + const adapter = createAcpSessionNotificationAdapter([ + { + id: 'steer-1', + role: 'user', + created: 123, + content: [{ type: 'text', text: 'haha' }], + metadata: { userVisible: true, agentVisible: true, steer: true }, + }, + ]); + + let messages = expectMessagesAndLocalSteerConfirmation( + adapter.apply( + acpUpdate({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'ha' }, + _meta: { + goose: { + messageId: 'steer-1', + steer: true, + }, + }, + } as SessionNotification['update']) + ), + 'steer-1' + ); + + expect(firstContent(messages[0])).toMatchObject({ type: 'text', text: 'ha' }); + + messages = expectMessagesAndLocalSteerConfirmation( + adapter.apply( + acpUpdate({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'ha' }, + _meta: { + goose: { + messageId: 'steer-1', + steer: true, + }, + }, + } as SessionNotification['update']) + ), + 'steer-1' + ); + + expect(firstContent(messages[0])).toMatchObject({ type: 'text', text: 'haha' }); + }); + it('maps image and thinking chunks to existing message content shapes', () => { const imageAdapter = createAcpSessionNotificationAdapter(); diff --git a/ui/desktop/src/acp/adapter/messages.ts b/ui/desktop/src/acp/adapter/messages.ts index 1b3b3caf0ce0..9a287e83be00 100644 --- a/ui/desktop/src/acp/adapter/messages.ts +++ b/ui/desktop/src/acp/adapter/messages.ts @@ -30,20 +30,29 @@ export function applyContentChunk( if (existing) { const lastContent = existing.content[existing.content.length - 1]; + if (reconcileLocalSteerTextChunk(state, existing, content, gooseMeta.steer)) { + return messagesChangeWithLocalSteerConfirmation(state, existing, gooseMeta.steer); + } + if (lastContent?.type === 'text' && content.type === 'text') { lastContent.text += content.text; } else if (content.type === 'image' && hasImageContent(existing, content)) { - return messagesChange(state); + return messagesChangeWithLocalSteerConfirmation(state, existing, gooseMeta.steer); } else { existing.content.push(content); } + + return messagesChangeWithLocalSteerConfirmation(state, existing, gooseMeta.steer); } else { state.messages.push({ ...(messageId ? { id: messageId } : {}), role, created: gooseMeta.created ?? Math.floor(Date.now() / 1000), content: [content], - metadata: { ...DEFAULT_VISIBLE_MESSAGE_METADATA }, + metadata: { + ...DEFAULT_VISIBLE_MESSAGE_METADATA, + ...(gooseMeta.steer ? { steer: true } : {}), + }, }); } @@ -148,3 +157,44 @@ function hasImageContent(message: Message, image: Extract } - | { type: 'sessionInfo'; name?: string } + | { + type: 'sessionInfo'; + name?: string; + activeRunId?: string | null; + } + | { type: 'localSteerConfirmed'; messageId: string } | { type: 'notification'; notification: NotificationEvent }; export interface AdapterState { messages: Message[]; + localSteerTextByMessageId: Map; } export interface GooseMessageMeta { messageId?: string; created?: number; + steer?: boolean; } export interface ToolIdentity { @@ -52,9 +59,25 @@ export function getGooseMessageMeta(update: { _meta?: unknown }): GooseMessageMe return { created: typeof goose.created === 'number' ? goose.created : undefined, messageId: typeof goose.messageId === 'string' ? goose.messageId : undefined, + steer: goose.steer === true ? true : undefined, }; } +export function getGooseActiveRunId(update: { _meta?: unknown }): string | null | undefined { + if (!isRecord(update._meta)) { + return undefined; + } + + const goose = update._meta.goose; + if (!isRecord(goose) || !('activeRunId' in goose)) { + return undefined; + } + + return typeof goose.activeRunId === 'string' || goose.activeRunId === null + ? goose.activeRunId + : undefined; +} + export function rawInputToArguments(rawInput: unknown): Record { return isRecord(rawInput) ? rawInput : {}; } diff --git a/ui/desktop/src/acp/autocomplete.ts b/ui/desktop/src/acp/autocomplete.ts new file mode 100644 index 000000000000..d60817fcdea8 --- /dev/null +++ b/ui/desktop/src/acp/autocomplete.ts @@ -0,0 +1,76 @@ +import type { AgentMention, AvailableCommand } from '@aaif/goose-sdk'; +import type { DisplayItem } from '../components/MentionPopover'; +import { getAcpClient } from './acpConnection'; + +type SlashCommandItemType = Extract; +type AutocompleteDisplayItem = DisplayItem; + +const SLASH_COMMAND_ITEM_TYPES = new Set(['Builtin', 'Recipe', 'Skill']); + +function isSlashCommandItemType(value: unknown): value is SlashCommandItemType { + return typeof value === 'string' && SLASH_COMMAND_ITEM_TYPES.has(value); +} + +function stringMetaValue( + meta: AvailableCommand['_meta'], + key: string +): string | undefined { + const value = meta?.[key]; + return typeof value === 'string' && value.trim() ? value : undefined; +} + +function cwdParam(cwd: string): { cwd?: string } { + const trimmed = cwd.trim(); + return trimmed ? { cwd: trimmed } : {}; +} + +export function availableCommandToDisplayItem( + command: AvailableCommand +): AutocompleteDisplayItem | null { + const commandType = stringMetaValue(command._meta, 'commandType'); + if (!isSlashCommandItemType(commandType)) { + return null; + } + + const sourcePath = stringMetaValue(command._meta, 'sourcePath'); + const extra = commandType === 'Recipe' ? sourcePath ?? command.description : command.description; + + return { + name: command.name, + extra, + itemType: commandType, + relativePath: command.name, + }; +} + +export function agentMentionToDisplayItem(agent: AgentMention): AutocompleteDisplayItem { + const mention = agent.mention.trim() || `@${agent.name}`; + + return { + name: agent.name, + extra: agent.description, + itemType: 'Agent', + relativePath: agent.name, + insertText: mention.endsWith(' ') ? mention : `${mention} `, + }; +} + +export async function listSlashCommandItems(cwd: string): Promise { + const client = await getAcpClient(); + const response = await client.goose.slashCommandsList_unstable(cwdParam(cwd)); + return response.availableCommands + .map(availableCommandToDisplayItem) + .filter((item): item is AutocompleteDisplayItem => item !== null); +} + +export async function listAgentMentionItems( + cwd: string, + sessionId?: string +): Promise { + const client = await getAcpClient(); + const response = await client.goose.agentMentionsList_unstable({ + ...cwdParam(cwd), + ...(sessionId ? { sessionId } : {}), + }); + return response.agents.map(agentMentionToDisplayItem); +} diff --git a/ui/desktop/src/acp/chatSessionController.ts b/ui/desktop/src/acp/chatSessionController.ts index ecd4c8c8890d..af8a88bc9d56 100644 --- a/ui/desktop/src/acp/chatSessionController.ts +++ b/ui/desktop/src/acp/chatSessionController.ts @@ -81,6 +81,20 @@ function createAcpCreditsExhaustedMessage(error: AcpCreditsExhaustedError): Mess }; } +function assertNoPendingPromptCancellation(sessionId: string): void { + const snapshot = acpChatSessionStore.getSnapshot(sessionId); + if (snapshot?.pendingCancelPromptAttemptId) { + throw new Error('Cannot submit while prompt cancellation is pending'); + } +} + +function assertNoActivePromptAttempt(sessionId: string): void { + const snapshot = acpChatSessionStore.getSnapshot(sessionId); + if (snapshot?.activePromptAttemptId) { + throw new Error('Cannot update message while prompt is active'); + } +} + async function createSession( cwd: string, gooseExtensions: GooseExtension[], @@ -131,7 +145,10 @@ async function submitMessage( userMessage: Message, options: AcpSubmitMessageOptions ): Promise { - if (acpChatSessionStore.getSnapshot(sessionId)?.activePromptAttemptId) { + assertNoPendingPromptCancellation(sessionId); + + const snapshot = acpChatSessionStore.getSnapshot(sessionId); + if (snapshot?.activePromptAttemptId) { return; } @@ -140,10 +157,17 @@ async function submitMessage( try { await acpPromptSession(sessionId, userMessage); + if (acpChatSessionActions.clearPromptCancellation(sessionId, promptAttemptId)) { + return; + } if (acpChatSessionActions.finishPromptAttemptIfCurrent(sessionId, promptAttemptId)) { void options.onFinish(); } } catch (error) { + if (acpChatSessionActions.clearPromptCancellation(sessionId, promptAttemptId)) { + return; + } + const creditsExhaustedError = parseAcpCreditsExhaustedError(error); if (creditsExhaustedError) { if (!acpChatSessionActions.isCurrentPromptAttempt(sessionId, promptAttemptId)) { @@ -175,7 +199,7 @@ function stop(sessionId: string): void { const hasStoredAcpPrompt = storedPromptAttemptId !== null && storedPromptAttemptId !== undefined; if (hasStoredAcpPrompt) { - acpChatSessionActions.clearActivePromptAttempt(sessionId); + acpChatSessionActions.startPromptCancellation(sessionId, storedPromptAttemptId); cancelAcpPermissionRequestsForSession(sessionId); cancelAcpElicitationRequestsForSession(sessionId); acpCancelPrompt(sessionId).catch((error) => { @@ -194,6 +218,9 @@ async function updateMessage( editType: 'fork' | 'edit' | undefined, options: AcpSubmitMessageOptions ): Promise { + assertNoPendingPromptCancellation(sessionId); + assertNoActivePromptAttempt(sessionId); + const resolvedEditType = editType ?? 'fork'; const currentSnapshot = options.getCurrentSnapshot(); diff --git a/ui/desktop/src/acp/chatSessionStore.ts b/ui/desktop/src/acp/chatSessionStore.ts index 7dfc62608b88..08047dc45bf7 100644 --- a/ui/desktop/src/acp/chatSessionStore.ts +++ b/ui/desktop/src/acp/chatSessionStore.ts @@ -21,12 +21,15 @@ export interface AcpChatSessionSnapshot { chatState: ChatState; sessionLoadError: string | undefined; activePromptAttemptId: string | null; + activeRunId: string | null; + pendingCancelPromptAttemptId: string | null; } type SnapshotListener = (snapshot: AcpChatSessionSnapshot) => void; interface StoreEntry extends AcpChatSessionSnapshot { adapter: AcpSessionNotificationAdapter; + pendingLocalSteerMessageIds: Set; } const initialTokenState: TokenState = { @@ -67,9 +70,18 @@ export interface AcpChatSessionActions { ): AcpChatSessionSnapshot; setMessages(sessionId: string, messages: Message[]): AcpChatSessionSnapshot; + addPendingLocalSteerMessage(sessionId: string, message: Message): AcpChatSessionSnapshot; setChatState(sessionId: string, chatState: ChatState): AcpChatSessionSnapshot; startPromptAttempt(sessionId: string, promptAttemptId: string): AcpChatSessionSnapshot; + startPromptCancellation( + sessionId: string, + promptAttemptId: string + ): AcpChatSessionSnapshot | undefined; + clearPromptCancellation( + sessionId: string, + promptAttemptId: string + ): AcpChatSessionSnapshot | undefined; finishPromptAttemptIfCurrent(sessionId: string, promptAttemptId: string, error?: string): boolean; clearActivePromptAttempt(sessionId: string): AcpChatSessionSnapshot | undefined; isCurrentPromptAttempt(sessionId: string, promptAttemptId: string): boolean; @@ -130,6 +142,9 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal { chatState: ChatState.Idle, sessionLoadError: undefined, activePromptAttemptId: null, + activeRunId: null, + pendingCancelPromptAttemptId: null, + pendingLocalSteerMessageIds: new Set(), adapter: createAcpSessionNotificationAdapter(), }; sessionsById.set(sessionId, entry); @@ -182,7 +197,23 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal { const setMessages: AcpChatSessionActions['setMessages'] = (sessionId, messages) => { const entry = getOrCreateEntry(sessionId); entry.messages = cloneMessages(messages); - entry.adapter = createAcpSessionNotificationAdapter(entry.messages); + retainPendingLocalSteerMessageIds(entry); + entry.adapter = createAdapterForEntry(entry); + return notify(sessionId, entry); + }; + + const addPendingLocalSteerMessage: AcpChatSessionActions['addPendingLocalSteerMessage'] = ( + sessionId, + message + ) => { + const entry = getOrCreateEntry(sessionId); + if (!message.id || entry.messages.some((existing) => existing.id === message.id)) { + return notify(sessionId, entry); + } + + entry.messages = [...entry.messages, cloneMessage(message)]; + entry.pendingLocalSteerMessageIds.add(message.id); + entry.adapter = createAdapterForEntry(entry); return notify(sessionId, entry); }; @@ -206,13 +237,46 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal { promptAttemptId ) => { const entry = getOrCreateEntry(sessionId); + discardPendingLocalSteerMessages(entry); entry.activePromptAttemptId = promptAttemptId; + entry.activeRunId = null; + entry.pendingCancelPromptAttemptId = null; entry.chatState = ChatState.Streaming; entry.sessionLoadError = undefined; entry.notifications = []; return notify(sessionId, entry); }; + const startPromptCancellation: AcpChatSessionActions['startPromptCancellation'] = ( + sessionId, + promptAttemptId + ) => { + const entry = sessionsById.get(sessionId); + if (!entry || entry.activePromptAttemptId !== promptAttemptId) { + return undefined; + } + + entry.activePromptAttemptId = null; + entry.activeRunId = null; + entry.pendingCancelPromptAttemptId = promptAttemptId; + discardPendingLocalSteerMessages(entry); + entry.chatState = ChatState.Idle; + return notify(sessionId, entry); + }; + + const clearPromptCancellation: AcpChatSessionActions['clearPromptCancellation'] = ( + sessionId, + promptAttemptId + ) => { + const entry = sessionsById.get(sessionId); + if (!entry || entry.pendingCancelPromptAttemptId !== promptAttemptId) { + return undefined; + } + + entry.pendingCancelPromptAttemptId = null; + return notify(sessionId, entry); + }; + const finishPromptAttemptIfCurrent: AcpChatSessionActions['finishPromptAttemptIfCurrent'] = ( sessionId, promptAttemptId, @@ -224,6 +288,9 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal { } entry.activePromptAttemptId = null; + entry.activeRunId = null; + entry.pendingCancelPromptAttemptId = null; + discardPendingLocalSteerMessages(entry); entry.chatState = ChatState.Idle; entry.sessionLoadError = error; notify(sessionId, entry); @@ -239,6 +306,8 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal { } entry.activePromptAttemptId = null; + entry.activeRunId = null; + discardPendingLocalSteerMessages(entry); entry.chatState = ChatState.Idle; return notify(sessionId, entry); }; @@ -310,8 +379,11 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal { failSessionLoad, setSessionLoadError, setMessages, + addPendingLocalSteerMessage, setChatState, startPromptAttempt, + startPromptCancellation, + clearPromptCancellation, finishPromptAttemptIfCurrent, clearActivePromptAttempt, isCurrentPromptAttempt, @@ -382,8 +454,11 @@ function actionsFromStore(store: AcpChatSessionStoreInternal): AcpChatSessionAct failSessionLoad: store.failSessionLoad, setSessionLoadError: store.setSessionLoadError, setMessages: store.setMessages, + addPendingLocalSteerMessage: store.addPendingLocalSteerMessage, setChatState: store.setChatState, startPromptAttempt: store.startPromptAttempt, + startPromptCancellation: store.startPromptCancellation, + clearPromptCancellation: store.clearPromptCancellation, finishPromptAttemptIfCurrent: store.finishPromptAttemptIfCurrent, clearActivePromptAttempt: store.clearActivePromptAttempt, isCurrentPromptAttempt: store.isCurrentPromptAttempt, @@ -395,6 +470,7 @@ function applyChatStateChanges(entry: StoreEntry, changes: AcpChatStateChange[]) switch (change.type) { case 'messages': entry.messages = cloneMessages(change.messages); + retainPendingLocalSteerMessageIds(entry); break; case 'tokenState': entry.tokenState = { ...entry.tokenState, ...change.tokenState }; @@ -403,6 +479,12 @@ function applyChatStateChanges(entry: StoreEntry, changes: AcpChatStateChange[]) if (change.name && entry.session) { entry.session = { ...entry.session, name: change.name }; } + if (change.activeRunId !== undefined) { + entry.activeRunId = change.activeRunId; + } + break; + case 'localSteerConfirmed': + entry.pendingLocalSteerMessageIds.delete(change.messageId); break; case 'notification': entry.notifications = [...entry.notifications, change.notification]; @@ -415,9 +497,63 @@ function resetReplayState(entry: StoreEntry): void { entry.messages = []; entry.tokenState = { ...initialTokenState }; entry.notifications = []; + entry.activeRunId = null; + entry.pendingCancelPromptAttemptId = null; + entry.pendingLocalSteerMessageIds.clear(); entry.adapter = createAcpSessionNotificationAdapter(); } +function retainPendingLocalSteerMessageIds(entry: StoreEntry): void { + if (entry.pendingLocalSteerMessageIds.size === 0) { + return; + } + + const messageIds = new Set(entry.messages.map((message) => message.id).filter(Boolean)); + entry.pendingLocalSteerMessageIds = new Set( + [...entry.pendingLocalSteerMessageIds].filter((messageId) => messageIds.has(messageId)) + ); +} + +function discardPendingLocalSteerMessages(entry: StoreEntry): void { + if (entry.pendingLocalSteerMessageIds.size === 0) { + return; + } + + entry.messages = entry.messages.filter( + (message) => !message.id || !entry.pendingLocalSteerMessageIds.has(message.id) + ); + entry.pendingLocalSteerMessageIds.clear(); + entry.adapter = createAdapterForEntry(entry); +} + +function createAdapterForEntry(entry: StoreEntry): AcpSessionNotificationAdapter { + return createAcpSessionNotificationAdapter( + entry.messages, + confirmedLocalSteerTextByMessageId(entry) + ); +} + +function confirmedLocalSteerTextByMessageId(entry: StoreEntry): Map { + const textByMessageId = new Map(); + + for (const message of entry.messages) { + if ( + !message.id || + !message.metadata.steer || + entry.pendingLocalSteerMessageIds.has(message.id) + ) { + continue; + } + + const firstContent = message.content[0]; + if (firstContent?.type === 'text') { + textByMessageId.set(message.id, firstContent.text); + } + } + + return textByMessageId; +} + function snapshotFromEntry(entry: StoreEntry): AcpChatSessionSnapshot { return { session: entry.session, @@ -427,6 +563,8 @@ function snapshotFromEntry(entry: StoreEntry): AcpChatSessionSnapshot { chatState: entry.chatState, sessionLoadError: entry.sessionLoadError, activePromptAttemptId: entry.activePromptAttemptId, + activeRunId: entry.activeRunId, + pendingCancelPromptAttemptId: entry.pendingCancelPromptAttemptId, }; } diff --git a/ui/desktop/src/acp/prompt.ts b/ui/desktop/src/acp/prompt.ts index 780fba242267..5c84af2f8803 100644 --- a/ui/desktop/src/acp/prompt.ts +++ b/ui/desktop/src/acp/prompt.ts @@ -1,4 +1,5 @@ import type { ContentBlock, PromptResponse } from '@agentclientprotocol/sdk'; +import type { SteerSessionRequest_unstable, SteerSessionResponse_unstable } from '@aaif/goose-sdk'; import type { Message } from '../api'; import { getAcpClient } from './acpConnection'; @@ -18,6 +19,19 @@ export async function acpCancelPrompt(sessionId: string): Promise { await client.cancel({ sessionId }); } +export async function acpSteerSession( + sessionId: string, + message: Message, + expectedRunId: string +): Promise { + const client = await getAcpClient(); + return client.goose.sessionSteer_unstable({ + sessionId, + expectedRunId, + prompt: messageToAcpPromptContent(message) as unknown as SteerSessionRequest_unstable['prompt'], + }); +} + export function messageToAcpPromptContent(message: Message): ContentBlock[] { const prompt: ContentBlock[] = []; diff --git a/ui/desktop/src/acp/schedules.ts b/ui/desktop/src/acp/schedules.ts new file mode 100644 index 000000000000..8248af1c70a5 --- /dev/null +++ b/ui/desktop/src/acp/schedules.ts @@ -0,0 +1,193 @@ +import type { + CreateScheduleRequest_unstable, + InspectRunningJobResponse_unstable, + KillRunningJobResponse_unstable, + RunScheduleNowResponse_unstable, + ScheduledJobDto, + SessionInfo, +} from '@aaif/goose-sdk'; +import { getAcpClient } from './acpConnection'; + +let inFlightListSchedules: Promise | null = null; +const inFlightListScheduleSessions = new Map>(); + +function acpErrorMessage(error: unknown): string | null { + if (typeof error !== 'object' || error === null) { + return null; + } + + const candidate = 'error' in error && isRecord(error.error) ? error.error : error; + if (!isRecord(candidate)) { + return null; + } + if (typeof candidate.data === 'string') { + return candidate.data; + } + return typeof candidate.message === 'string' ? candidate.message : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function normalizeAcpError(error: unknown, fallback: string): Error { + const message = acpErrorMessage(error); + if (message) { + return new Error(message); + } + if (error instanceof Error) { + return error; + } + return new Error(fallback); +} + +function clearInFlightScheduleReads(): void { + inFlightListSchedules = null; + inFlightListScheduleSessions.clear(); +} + +export async function acpListSchedules(): Promise { + const pending = inFlightListSchedules; + if (pending) { + return pending; + } + + const listPromise = (async () => { + const client = await getAcpClient(); + const response = await client.goose.schedulesList_unstable({}); + return response.jobs; + })().catch((error) => { + throw normalizeAcpError(error, 'Failed to list schedules'); + }); + + inFlightListSchedules = listPromise; + + try { + return await listPromise; + } finally { + if (inFlightListSchedules === listPromise) { + inFlightListSchedules = null; + } + } +} + +export async function acpCreateSchedule( + request: CreateScheduleRequest_unstable +): Promise { + try { + const client = await getAcpClient(); + const response = await client.goose.schedulesCreate_unstable(request); + clearInFlightScheduleReads(); + return response.job; + } catch (error) { + throw normalizeAcpError(error, 'Failed to create schedule'); + } +} + +export async function acpDeleteSchedule(scheduleId: string): Promise { + try { + const client = await getAcpClient(); + await client.goose.schedulesDelete_unstable({ scheduleId }); + clearInFlightScheduleReads(); + } catch (error) { + throw normalizeAcpError(error, 'Failed to delete schedule'); + } +} + +export async function acpListScheduleSessions( + scheduleId: string, + limit: number +): Promise { + const key = `${scheduleId}:${limit}`; + const pending = inFlightListScheduleSessions.get(key); + if (pending) { + return pending; + } + + const listPromise = (async () => { + const client = await getAcpClient(); + const response = await client.goose.schedulesSessionsList_unstable({ scheduleId, limit }); + return response.sessions; + })().catch((error) => { + throw normalizeAcpError(error, 'Failed to list schedule sessions'); + }); + + inFlightListScheduleSessions.set(key, listPromise); + + try { + return await listPromise; + } finally { + if (inFlightListScheduleSessions.get(key) === listPromise) { + inFlightListScheduleSessions.delete(key); + } + } +} + +export async function acpRunScheduleNow( + scheduleId: string +): Promise { + try { + const client = await getAcpClient(); + const response = await client.goose.schedulesRunNow_unstable({ scheduleId }); + clearInFlightScheduleReads(); + return response; + } catch (error) { + throw normalizeAcpError(error, 'Failed to run schedule now'); + } +} + +export async function acpPauseSchedule(scheduleId: string): Promise { + try { + const client = await getAcpClient(); + await client.goose.schedulesPause_unstable({ scheduleId }); + clearInFlightScheduleReads(); + } catch (error) { + throw normalizeAcpError(error, 'Failed to pause schedule'); + } +} + +export async function acpUnpauseSchedule(scheduleId: string): Promise { + try { + const client = await getAcpClient(); + await client.goose.schedulesUnpause_unstable({ scheduleId }); + clearInFlightScheduleReads(); + } catch (error) { + throw normalizeAcpError(error, 'Failed to unpause schedule'); + } +} + +export async function acpUpdateSchedule( + scheduleId: string, + cron: string +): Promise { + try { + const client = await getAcpClient(); + const response = await client.goose.schedulesUpdate_unstable({ scheduleId, cron }); + clearInFlightScheduleReads(); + return response.job; + } catch (error) { + throw normalizeAcpError(error, 'Failed to update schedule'); + } +} + +export async function acpKillRunningJob(jobId: string): Promise { + try { + const client = await getAcpClient(); + const response = await client.goose.schedulesRunningJobKill_unstable({ jobId }); + clearInFlightScheduleReads(); + return response; + } catch (error) { + throw normalizeAcpError(error, 'Failed to kill running job'); + } +} + +export async function acpInspectRunningJob( + jobId: string +): Promise { + try { + const client = await getAcpClient(); + return await client.goose.schedulesRunningJobInspect_unstable({ jobId }); + } catch (error) { + throw normalizeAcpError(error, 'Failed to inspect running job'); + } +} diff --git a/ui/desktop/src/acp/sessionNotificationAdapter.ts b/ui/desktop/src/acp/sessionNotificationAdapter.ts index 3548a5084d4e..27d7d566ac38 100644 --- a/ui/desktop/src/acp/sessionNotificationAdapter.ts +++ b/ui/desktop/src/acp/sessionNotificationAdapter.ts @@ -9,7 +9,12 @@ import { import { applyGooseSessionNotification } from './adapter/gooseSessionNotifications'; import { applyContentChunk, applyThoughtChunk } from './adapter/messages'; import { applyPermissionRequest as applyPermissionRequestToState } from './adapter/permissions'; -import { type AcpChatStateChange, type AdapterState, cloneMessage } from './adapter/shared'; +import { + type AcpChatStateChange, + type AdapterState, + cloneMessage, + getGooseActiveRunId, +} from './adapter/shared'; import { applyToolCall, applyToolCallUpdate } from './adapter/tools'; import type { AcpElicitationRequest } from './elicitationRequests'; @@ -25,10 +30,12 @@ export interface AcpSessionNotificationAdapter { } export function createAcpSessionNotificationAdapter( - initialMessages: Message[] = [] + initialMessages: Message[] = [], + localSteerTextByMessageId: Map = new Map() ): AcpSessionNotificationAdapter { const state: AdapterState = { messages: initialMessages.map(cloneMessage), + localSteerTextByMessageId: new Map(localSteerTextByMessageId), }; return { @@ -70,13 +77,20 @@ function applyAcpSessionNotification( return applyToolCall(state, update); case 'tool_call_update': return applyToolCallUpdate(state, update); - case 'session_info_update': + case 'session_info_update': { + const activeRunId = getGooseActiveRunId(update); + if (!update.title && activeRunId === undefined) { + return []; + } + return [ { type: 'sessionInfo', ...(update.title ? { name: update.title } : {}), + ...(activeRunId !== undefined ? { activeRunId } : {}), }, ]; + } case 'usage_update': return []; default: diff --git a/ui/desktop/src/acp/sessions.ts b/ui/desktop/src/acp/sessions.ts index 001c99a9fccb..549cea15ef15 100644 --- a/ui/desktop/src/acp/sessions.ts +++ b/ui/desktop/src/acp/sessions.ts @@ -14,6 +14,7 @@ import type { Recipe } from '../recipe'; interface GooseSessionInfoMeta { messageCount?: number; createdAt?: string; + lastMessageAt?: string; archivedAt?: string; projectId?: string; providerId?: string; @@ -30,6 +31,7 @@ export interface SessionListItem { workingDir: string; updatedAt: string; messageCount: number; + lastMessageAt?: string; createdAt: string; archivedAt?: string; projectId?: string; @@ -94,6 +96,7 @@ export function sessionInfoToSession(s: SessionInfo, loadMeta: LoadSessionMeta = working_dir: loadMeta.workingDir ?? s.cwd, created_at: createdAt, updated_at: updatedAt, + last_message_at: meta.lastMessageAt, message_count: meta.messageCount ?? 0, extension_data: {}, archived_at: meta.archivedAt, @@ -116,6 +119,7 @@ function sessionInfoToListItem(s: SessionInfo): SessionListItem { workingDir: s.cwd, updatedAt: s.updatedAt ?? '', messageCount: meta.messageCount ?? 0, + lastMessageAt: meta.lastMessageAt, createdAt: meta.createdAt ?? s.updatedAt ?? '', archivedAt: meta.archivedAt, projectId: meta.projectId, diff --git a/ui/desktop/src/acpChatFeatureFlag.ts b/ui/desktop/src/acpChatFeatureFlag.ts index edea9d78a04d..34ebb17537a1 100644 --- a/ui/desktop/src/acpChatFeatureFlag.ts +++ b/ui/desktop/src/acpChatFeatureFlag.ts @@ -1 +1 @@ -export const USE_ACP_CHAT = false; \ No newline at end of file +export const USE_ACP_CHAT = false; diff --git a/ui/desktop/src/api/index.ts b/ui/desktop/src/api/index.ts index 33ddd72656c4..61b2ad8fc590 100644 --- a/ui/desktop/src/api/index.ts +++ b/ui/desktop/src/api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { addExtension, agentAddExtension, agentRemoveExtension, callTool, cancelDownload, cancelLocalModelDownload, checkProvider, cleanupProviderCache, configureProviderOauth, confirmToolAction, createCustomProvider, createSchedule, decodeRecipe, deleteLocalModel, deleteModel, deleteProviderSecret, deleteRecipe, deleteSchedule, diagnostics, downloadHfModel, downloadModel, encodeRecipe, exportApp, forkSession, getCanonicalModelInfo, getCustomProvider, getDictationConfig, getDownloadProgress, getExtensions, getFeatures, getLocalModelDownloadProgress, getModelSettings, getPrompt, getPrompts, getProviderCatalog, getProviderCatalogTemplate, getProviderModelInfo, getProviderModels, getRepoFiles, getSession, getSessionExtensions, getSlashCommands, getTools, getTunnelStatus, importApp, importSessionNostr, inspectRunningJob, killRunningJob, listApps, listBuiltinChatTemplates, listLocalModels, listModels, listProviderSecrets, listRecipes, listSchedules, mcpUiProxy, type Options, parseRecipe, pauseSchedule, providers, readAllConfig, readConfig, readResource, recipeToYaml, removeConfig, removeCustomProvider, removeExtension, reply, resetPrompt, restartAgent, resumeAgent, runNowHandler, savePrompt, saveRecipe, scanRecipe, scheduleRecipe, searchHfModels, sendTelemetryEvent, sessionCancel, sessionEvents, sessionReply, sessionsHandler, setConfigProvider, setRecipeSlashCommand, shareSessionNostr, startAgent, startNanogptSetup, startOpenrouterSetup, startTetrateSetup, startTunnel, status, stopAgent, stopTunnel, syncFeaturedModels, systemInfo, transcribeDictation, unpauseSchedule, updateAgentProvider, updateCustomProvider, updateFromSession, updateModelSettings, updateSchedule, updateSession, updateSessionName, updateSessionUserRecipeValues, updateWorkingDir, upsertConfig, upsertPermissions, validateConfig } from './sdk.gen'; -export type { ActionRequired, ActionRequiredData, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Annotations, Author, CallToolData, CallToolError, CallToolErrors, CallToolRequest, CallToolResponse, CallToolResponse2, CallToolResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CancelLocalModelDownloadData, CancelLocalModelDownloadErrors, CancelLocalModelDownloadResponses, CancelRequest, ChatRequest, ChatTemplate, CheckProviderData, CheckProviderRequest, CleanupProviderCacheData, CleanupProviderCacheErrors, CleanupProviderCacheResponse, CleanupProviderCacheResponses, ClientOptions, CommandType, ConfigKey, ConfigKeyQuery, ConfigResponse, ConfigureProviderOauthData, ConfigureProviderOauthErrors, ConfigureProviderOauthResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, ContentBlock, Conversation, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponse2, CreateCustomProviderResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CspMetadata, DeclarativeProviderConfig, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeRequest, DecodeRecipeResponse, DecodeRecipeResponse2, DecodeRecipeResponses, DeleteLocalModelData, DeleteLocalModelErrors, DeleteLocalModelResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteProviderSecretData, DeleteProviderSecretErrors, DeleteProviderSecretResponse, DeleteProviderSecretResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeRequest, DeleteRecipeResponse, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DiagnosticsConfig, DiagnosticsData, DiagnosticsError, DiagnosticsErrors, DiagnosticsExtensions, DiagnosticsLevel, DiagnosticsLogs, DiagnosticsPrompt, DiagnosticsReport, DiagnosticsResponse, DiagnosticsResponses, DiagnosticsScheduledRecipe, DiagnosticsTextFile, DictationProvider, DictationProviderStatus, DownloadHfModelData, DownloadHfModelErrors, DownloadHfModelResponse, DownloadHfModelResponses, DownloadModelData, DownloadModelErrors, DownloadModelRequest, DownloadModelResponses, DownloadProgress, DownloadStatus, EmbeddedResource, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeRequest, EncodeRecipeResponse, EncodeRecipeResponse2, EncodeRecipeResponses, Envs, EnvVarConfig, ErrorResponse, ExportAppData, ExportAppError, ExportAppErrors, ExportAppResponse, ExportAppResponses, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, FeaturesResponse, ForkRequest, ForkResponse, ForkSessionData, ForkSessionErrors, ForkSessionResponse, ForkSessionResponses, FrontendToolRequest, GetCanonicalModelInfoData, GetCanonicalModelInfoResponse, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponse, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponse, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetFeaturesData, GetFeaturesResponse, GetFeaturesResponses, GetLocalModelDownloadProgressData, GetLocalModelDownloadProgressErrors, GetLocalModelDownloadProgressResponse, GetLocalModelDownloadProgressResponses, GetModelSettingsData, GetModelSettingsErrors, GetModelSettingsResponse, GetModelSettingsResponses, GetPromptData, GetPromptErrors, GetPromptResponse, GetPromptResponses, GetPromptsData, GetPromptsResponse, GetPromptsResponses, GetProviderCatalogData, GetProviderCatalogErrors, GetProviderCatalogResponse, GetProviderCatalogResponses, GetProviderCatalogTemplateData, GetProviderCatalogTemplateErrors, GetProviderCatalogTemplateResponse, GetProviderCatalogTemplateResponses, GetProviderModelInfoData, GetProviderModelInfoErrors, GetProviderModelInfoResponse, GetProviderModelInfoResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetRepoFilesData, GetRepoFilesResponse, GetRepoFilesResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionResponse, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponse, GetTunnelStatusResponses, GooseApp, GooseMode, HfGgufFile, HfModelInfo, HfModelVariant, HfQuantVariant, Icon, IconTheme, ImageContent, ImportAppData, ImportAppError, ImportAppErrors, ImportAppRequest, ImportAppResponse, ImportAppResponse2, ImportAppResponses, ImportSessionNostrData, ImportSessionNostrErrors, ImportSessionNostrRequest, ImportSessionNostrResponse, ImportSessionNostrResponses, InferenceMetadata, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, JsonObject, KillJobResponse, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsError, ListAppsErrors, ListAppsRequest, ListAppsResponse, ListAppsResponse2, ListAppsResponses, ListBuiltinChatTemplatesData, ListBuiltinChatTemplatesResponse, ListBuiltinChatTemplatesResponses, ListLocalModelsData, ListLocalModelsResponse, ListLocalModelsResponses, ListModelsData, ListModelsResponse, ListModelsResponses, ListProviderSecretsData, ListProviderSecretsErrors, ListProviderSecretsResponse, ListProviderSecretsResponses, ListRecipeResponse, ListRecipesData, ListRecipesErrors, ListRecipesResponse, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, LoadedProvider, LocalModelResponse, McpAppResource, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, Message, MessageContent, MessageEvent, MessageMetadata, ModelCapabilities, ModelConfig, ModelDownloadStatus, ModelInfo, ModelInfoData, ModelInfoQuery, ModelInfoResponse, ModelSettings, ModelTemplate, ParseRecipeData, ParseRecipeError, ParseRecipeErrors, ParseRecipeRequest, ParseRecipeResponse, ParseRecipeResponse2, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, Permission, PermissionLevel, PermissionsMetadata, PrincipalType, PromptContentResponse, PromptsListResponse, ProviderCatalogEntry, ProviderDetails, ProviderEngine, ProviderMetadata, ProviderModelInfoQuery, ProvidersData, ProviderSecret, ProviderSecretsResponse, ProviderSecretStatus, ProviderSecretStorage, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderTemplate, ProviderType, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceRequest, ReadResourceResponse, ReadResourceResponse2, ReadResourceResponses, Recipe, RecipeManifest, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeToYamlData, RecipeToYamlError, RecipeToYamlErrors, RecipeToYamlRequest, RecipeToYamlResponse, RecipeToYamlResponse2, RecipeToYamlResponses, RedactedThinkingContent, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, RepoVariantsResponse, ResetPromptData, ResetPromptErrors, ResetPromptResponse, ResetPromptResponses, ResourceContents, ResourceMetadata, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SamplingConfig, SavePromptData, SavePromptErrors, SavePromptRequest, SavePromptResponse, SavePromptResponses, SaveRecipeData, SaveRecipeError, SaveRecipeErrors, SaveRecipeRequest, SaveRecipeResponse, SaveRecipeResponse2, SaveRecipeResponses, ScanRecipeData, ScanRecipeRequest, ScanRecipeResponse, ScanRecipeResponse2, ScanRecipeResponses, ScheduledJob, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeRequest, ScheduleRecipeResponses, SearchHfModelsData, SearchHfModelsErrors, SearchHfModelsResponse, SearchHfModelsResponses, SendTelemetryEventData, SendTelemetryEventResponses, Session, SessionCancelData, SessionCancelResponses, SessionDisplayInfo, SessionEventsData, SessionEventsErrors, SessionEventsResponse, SessionEventsResponses, SessionExtensionsResponse, SessionReplyData, SessionReplyErrors, SessionReplyRequest, SessionReplyResponse, SessionReplyResponse2, SessionReplyResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionsQuery, SessionType, SetConfigProviderData, SetProviderRequest, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, SetSlashCommandRequest, Settings, SetupResponse, ShareSessionNostrData, ShareSessionNostrErrors, ShareSessionNostrRequest, ShareSessionNostrResponse, ShareSessionNostrResponse2, ShareSessionNostrResponses, SlashCommand, SlashCommandsResponse, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StartNanogptSetupData, StartNanogptSetupResponse, StartNanogptSetupResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponse, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponse, StartTetrateSetupResponses, StartTunnelData, StartTunnelError, StartTunnelErrors, StartTunnelResponse, StartTunnelResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, StopTunnelData, StopTunnelError, StopTunnelErrors, StopTunnelResponses, SubRecipe, SuccessCheck, SyncFeaturedModelsData, SyncFeaturedModelsResponses, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TaskSupport, TelemetryEventRequest, Template, TextContent, ThinkingContent, ThinkingEffort, TokenState, Tool, ToolAnnotations, ToolCallingMode, ToolConfirmationRequest, ToolExecution, ToolInfo, ToolPermission, ToolRequest, ToolResponse, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponse, TranscribeDictationResponses, TranscribeRequest, TranscribeResponse, TunnelInfo, TunnelState, UiMetadata, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateModelSettingsData, UpdateModelSettingsErrors, UpdateModelSettingsResponse, UpdateModelSettingsResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionData, UpdateSessionErrors, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionRequest, UpdateSessionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesError, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesRequest, UpdateSessionUserRecipeValuesResponse, UpdateSessionUserRecipeValuesResponse2, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsQuery, UpsertPermissionsResponse, UpsertPermissionsResponses, Usage, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, WhisperModelResponse, WindowProps } from './types.gen'; +export { addExtension, agentAddExtension, agentRemoveExtension, callTool, cancelDownload, cancelLocalModelDownload, checkProvider, cleanupProviderCache, configureProviderOauth, confirmToolAction, createCustomProvider, createSchedule, decodeRecipe, deleteLocalModel, deleteModel, deleteProviderSecret, deleteRecipe, deleteSchedule, diagnostics, downloadHfModel, downloadModel, encodeRecipe, exportApp, forkSession, getCanonicalModelInfo, getCustomProvider, getDictationConfig, getDownloadProgress, getExtensions, getFeatures, getLocalModelDownloadProgress, getModelSettings, getPrompt, getPrompts, getProviderCatalog, getProviderCatalogTemplate, getProviderModelInfo, getProviderModels, getRepoFiles, getSession, getSessionExtensions, getSlashCommands, getTools, getTunnelStatus, importApp, importSessionNostr, inspectRunningJob, killRunningJob, listApps, listBuiltinChatTemplates, listLocalModels, listModels, listProviderSecrets, listRecipes, listSchedules, mcpUiProxy, type Options, parseRecipe, pauseSchedule, providers, readAllConfig, readConfig, readResource, recipeToYaml, removeConfig, removeCustomProvider, removeExtension, reply, resetPrompt, restartAgent, resumeAgent, runNowHandler, savePrompt, saveRecipe, scanRecipe, scheduleRecipe, searchHfModels, sendTelemetryEvent, sessionCancel, sessionEvents, sessionReply, sessionsHandler, setConfigProvider, setRecipeSlashCommand, shareSessionNostr, startAgent, startNanogptSetup, startOpenrouterSetup, startTetrateSetup, status, stopAgent, syncFeaturedModels, systemInfo, transcribeDictation, unpauseSchedule, updateAgentProvider, updateCustomProvider, updateFromSession, updateModelSettings, updateSchedule, updateSession, updateSessionName, updateSessionUserRecipeValues, updateWorkingDir, upsertConfig, upsertPermissions, validateConfig } from './sdk.gen'; +export type { ActionRequired, ActionRequiredData, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Annotations, Author, CallToolData, CallToolError, CallToolErrors, CallToolRequest, CallToolResponse, CallToolResponse2, CallToolResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CancelLocalModelDownloadData, CancelLocalModelDownloadErrors, CancelLocalModelDownloadResponses, CancelRequest, ChatRequest, ChatTemplate, CheckProviderData, CheckProviderRequest, CleanupProviderCacheData, CleanupProviderCacheErrors, CleanupProviderCacheResponse, CleanupProviderCacheResponses, ClientOptions, CommandType, ConfigKey, ConfigKeyQuery, ConfigResponse, ConfigureProviderOauthData, ConfigureProviderOauthErrors, ConfigureProviderOauthResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, ContentBlock, Conversation, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponse2, CreateCustomProviderResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CspMetadata, DeclarativeProviderConfig, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeRequest, DecodeRecipeResponse, DecodeRecipeResponse2, DecodeRecipeResponses, DeleteLocalModelData, DeleteLocalModelErrors, DeleteLocalModelResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteProviderSecretData, DeleteProviderSecretErrors, DeleteProviderSecretResponse, DeleteProviderSecretResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeRequest, DeleteRecipeResponse, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DiagnosticsConfig, DiagnosticsData, DiagnosticsError, DiagnosticsErrors, DiagnosticsExtensions, DiagnosticsLevel, DiagnosticsLogs, DiagnosticsPrompt, DiagnosticsReport, DiagnosticsResponse, DiagnosticsResponses, DiagnosticsScheduledRecipe, DiagnosticsTextFile, DictationProvider, DictationProviderStatus, DownloadHfModelData, DownloadHfModelErrors, DownloadHfModelResponse, DownloadHfModelResponses, DownloadModelData, DownloadModelErrors, DownloadModelRequest, DownloadModelResponses, DownloadProgress, DownloadStatus, EmbeddedResource, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeRequest, EncodeRecipeResponse, EncodeRecipeResponse2, EncodeRecipeResponses, Envs, EnvVarConfig, ErrorResponse, ExportAppData, ExportAppError, ExportAppErrors, ExportAppResponse, ExportAppResponses, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, FeaturesResponse, ForkRequest, ForkResponse, ForkSessionData, ForkSessionErrors, ForkSessionResponse, ForkSessionResponses, FrontendToolRequest, GetCanonicalModelInfoData, GetCanonicalModelInfoResponse, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponse, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponse, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetFeaturesData, GetFeaturesResponse, GetFeaturesResponses, GetLocalModelDownloadProgressData, GetLocalModelDownloadProgressErrors, GetLocalModelDownloadProgressResponse, GetLocalModelDownloadProgressResponses, GetModelSettingsData, GetModelSettingsErrors, GetModelSettingsResponse, GetModelSettingsResponses, GetPromptData, GetPromptErrors, GetPromptResponse, GetPromptResponses, GetPromptsData, GetPromptsResponse, GetPromptsResponses, GetProviderCatalogData, GetProviderCatalogErrors, GetProviderCatalogResponse, GetProviderCatalogResponses, GetProviderCatalogTemplateData, GetProviderCatalogTemplateErrors, GetProviderCatalogTemplateResponse, GetProviderCatalogTemplateResponses, GetProviderModelInfoData, GetProviderModelInfoErrors, GetProviderModelInfoResponse, GetProviderModelInfoResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetRepoFilesData, GetRepoFilesResponse, GetRepoFilesResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionResponse, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponse, GetTunnelStatusResponses, GooseApp, GooseMode, HfGgufFile, HfModelInfo, HfModelVariant, HfQuantVariant, Icon, IconTheme, ImageContent, ImportAppData, ImportAppError, ImportAppErrors, ImportAppRequest, ImportAppResponse, ImportAppResponse2, ImportAppResponses, ImportSessionNostrData, ImportSessionNostrErrors, ImportSessionNostrRequest, ImportSessionNostrResponse, ImportSessionNostrResponses, InferenceMetadata, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, JsonObject, KillJobResponse, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsError, ListAppsErrors, ListAppsRequest, ListAppsResponse, ListAppsResponse2, ListAppsResponses, ListBuiltinChatTemplatesData, ListBuiltinChatTemplatesResponse, ListBuiltinChatTemplatesResponses, ListLocalModelsData, ListLocalModelsResponse, ListLocalModelsResponses, ListModelsData, ListModelsResponse, ListModelsResponses, ListProviderSecretsData, ListProviderSecretsErrors, ListProviderSecretsResponse, ListProviderSecretsResponses, ListRecipeResponse, ListRecipesData, ListRecipesErrors, ListRecipesResponse, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, LoadedProvider, LocalModelResponse, McpAppResource, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, Message, MessageContent, MessageEvent, MessageMetadata, ModelCapabilities, ModelConfig, ModelDownloadStatus, ModelInfo, ModelInfoData, ModelInfoQuery, ModelInfoResponse, ModelSettings, ModelTemplate, ParseRecipeData, ParseRecipeError, ParseRecipeErrors, ParseRecipeRequest, ParseRecipeResponse, ParseRecipeResponse2, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, Permission, PermissionLevel, PermissionsMetadata, PrincipalType, PromptContentResponse, PromptsListResponse, ProviderCatalogEntry, ProviderDetails, ProviderEngine, ProviderMetadata, ProviderModelInfoQuery, ProvidersData, ProviderSecret, ProviderSecretsResponse, ProviderSecretStatus, ProviderSecretStorage, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderTemplate, ProviderType, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceRequest, ReadResourceResponse, ReadResourceResponse2, ReadResourceResponses, Recipe, RecipeManifest, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeToYamlData, RecipeToYamlError, RecipeToYamlErrors, RecipeToYamlRequest, RecipeToYamlResponse, RecipeToYamlResponse2, RecipeToYamlResponses, RedactedThinkingContent, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, RepoVariantsResponse, ResetPromptData, ResetPromptErrors, ResetPromptResponse, ResetPromptResponses, ResourceContents, ResourceMetadata, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SamplingConfig, SavePromptData, SavePromptErrors, SavePromptRequest, SavePromptResponse, SavePromptResponses, SaveRecipeData, SaveRecipeError, SaveRecipeErrors, SaveRecipeRequest, SaveRecipeResponse, SaveRecipeResponse2, SaveRecipeResponses, ScanRecipeData, ScanRecipeRequest, ScanRecipeResponse, ScanRecipeResponse2, ScanRecipeResponses, ScheduledJob, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeRequest, ScheduleRecipeResponses, SearchHfModelsData, SearchHfModelsErrors, SearchHfModelsResponse, SearchHfModelsResponses, SendTelemetryEventData, SendTelemetryEventResponses, Session, SessionCancelData, SessionCancelResponses, SessionDisplayInfo, SessionEventsData, SessionEventsErrors, SessionEventsResponse, SessionEventsResponses, SessionExtensionsResponse, SessionReplyData, SessionReplyErrors, SessionReplyRequest, SessionReplyResponse, SessionReplyResponse2, SessionReplyResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionsQuery, SessionType, SetConfigProviderData, SetProviderRequest, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, SetSlashCommandRequest, Settings, SetupResponse, ShareSessionNostrData, ShareSessionNostrErrors, ShareSessionNostrRequest, ShareSessionNostrResponse, ShareSessionNostrResponse2, ShareSessionNostrResponses, SlashCommand, SlashCommandsResponse, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StartNanogptSetupData, StartNanogptSetupResponse, StartNanogptSetupResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponse, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponse, StartTetrateSetupResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, SubRecipe, SuccessCheck, SyncFeaturedModelsData, SyncFeaturedModelsResponses, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TaskSupport, TelemetryEventRequest, Template, TextContent, ThinkingContent, ThinkingEffort, TokenState, Tool, ToolAnnotations, ToolCallingMode, ToolConfirmationRequest, ToolExecution, ToolInfo, ToolPermission, ToolRequest, ToolResponse, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponse, TranscribeDictationResponses, TranscribeRequest, TranscribeResponse, TunnelInfo, TunnelState, UiMetadata, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateModelSettingsData, UpdateModelSettingsErrors, UpdateModelSettingsResponse, UpdateModelSettingsResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionData, UpdateSessionErrors, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionRequest, UpdateSessionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesError, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesRequest, UpdateSessionUserRecipeValuesResponse, UpdateSessionUserRecipeValuesResponse2, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsQuery, UpsertPermissionsResponse, UpsertPermissionsResponses, Usage, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, WhisperModelResponse, WindowProps } from './types.gen'; diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 989646f10eef..1553d5f94ee2 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, CallToolData, CallToolErrors, CallToolResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CancelLocalModelDownloadData, CancelLocalModelDownloadErrors, CancelLocalModelDownloadResponses, CheckProviderData, CleanupProviderCacheData, CleanupProviderCacheErrors, CleanupProviderCacheResponses, ConfigureProviderOauthData, ConfigureProviderOauthErrors, ConfigureProviderOauthResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteLocalModelData, DeleteLocalModelErrors, DeleteLocalModelResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteProviderSecretData, DeleteProviderSecretErrors, DeleteProviderSecretResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, DownloadHfModelData, DownloadHfModelErrors, DownloadHfModelResponses, DownloadModelData, DownloadModelErrors, DownloadModelResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportAppData, ExportAppErrors, ExportAppResponses, ForkSessionData, ForkSessionErrors, ForkSessionResponses, GetCanonicalModelInfoData, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetFeaturesData, GetFeaturesResponses, GetLocalModelDownloadProgressData, GetLocalModelDownloadProgressErrors, GetLocalModelDownloadProgressResponses, GetModelSettingsData, GetModelSettingsErrors, GetModelSettingsResponses, GetPromptData, GetPromptErrors, GetPromptResponses, GetPromptsData, GetPromptsResponses, GetProviderCatalogData, GetProviderCatalogErrors, GetProviderCatalogResponses, GetProviderCatalogTemplateData, GetProviderCatalogTemplateErrors, GetProviderCatalogTemplateResponses, GetProviderModelInfoData, GetProviderModelInfoErrors, GetProviderModelInfoResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetRepoFilesData, GetRepoFilesResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportAppData, ImportAppErrors, ImportAppResponses, ImportSessionNostrData, ImportSessionNostrErrors, ImportSessionNostrResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsErrors, ListAppsResponses, ListBuiltinChatTemplatesData, ListBuiltinChatTemplatesResponses, ListLocalModelsData, ListLocalModelsResponses, ListModelsData, ListModelsResponses, ListProviderSecretsData, ListProviderSecretsErrors, ListProviderSecretsResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SavePromptData, SavePromptErrors, SavePromptResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SearchHfModelsData, SearchHfModelsErrors, SearchHfModelsResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionCancelData, SessionCancelResponses, SessionEventsData, SessionEventsErrors, SessionEventsResponses, SessionReplyData, SessionReplyErrors, SessionReplyResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, ShareSessionNostrData, ShareSessionNostrErrors, ShareSessionNostrResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartNanogptSetupData, StartNanogptSetupResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopAgentData, StopAgentErrors, StopAgentResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, SyncFeaturedModelsData, SyncFeaturedModelsResponses, SystemInfoData, SystemInfoResponses, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateModelSettingsData, UpdateModelSettingsErrors, UpdateModelSettingsResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionData, UpdateSessionErrors, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; +import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, CallToolData, CallToolErrors, CallToolResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CancelLocalModelDownloadData, CancelLocalModelDownloadErrors, CancelLocalModelDownloadResponses, CheckProviderData, CleanupProviderCacheData, CleanupProviderCacheErrors, CleanupProviderCacheResponses, ConfigureProviderOauthData, ConfigureProviderOauthErrors, ConfigureProviderOauthResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteLocalModelData, DeleteLocalModelErrors, DeleteLocalModelResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteProviderSecretData, DeleteProviderSecretErrors, DeleteProviderSecretResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, DownloadHfModelData, DownloadHfModelErrors, DownloadHfModelResponses, DownloadModelData, DownloadModelErrors, DownloadModelResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportAppData, ExportAppErrors, ExportAppResponses, ForkSessionData, ForkSessionErrors, ForkSessionResponses, GetCanonicalModelInfoData, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetFeaturesData, GetFeaturesResponses, GetLocalModelDownloadProgressData, GetLocalModelDownloadProgressErrors, GetLocalModelDownloadProgressResponses, GetModelSettingsData, GetModelSettingsErrors, GetModelSettingsResponses, GetPromptData, GetPromptErrors, GetPromptResponses, GetPromptsData, GetPromptsResponses, GetProviderCatalogData, GetProviderCatalogErrors, GetProviderCatalogResponses, GetProviderCatalogTemplateData, GetProviderCatalogTemplateErrors, GetProviderCatalogTemplateResponses, GetProviderModelInfoData, GetProviderModelInfoErrors, GetProviderModelInfoResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetRepoFilesData, GetRepoFilesResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportAppData, ImportAppErrors, ImportAppResponses, ImportSessionNostrData, ImportSessionNostrErrors, ImportSessionNostrResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsErrors, ListAppsResponses, ListBuiltinChatTemplatesData, ListBuiltinChatTemplatesResponses, ListLocalModelsData, ListLocalModelsResponses, ListModelsData, ListModelsResponses, ListProviderSecretsData, ListProviderSecretsErrors, ListProviderSecretsResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SavePromptData, SavePromptErrors, SavePromptResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SearchHfModelsData, SearchHfModelsErrors, SearchHfModelsResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionCancelData, SessionCancelResponses, SessionEventsData, SessionEventsErrors, SessionEventsResponses, SessionReplyData, SessionReplyErrors, SessionReplyResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, ShareSessionNostrData, ShareSessionNostrErrors, ShareSessionNostrResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartNanogptSetupData, StartNanogptSetupResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, StopAgentData, StopAgentErrors, StopAgentResponses, SyncFeaturedModelsData, SyncFeaturedModelsResponses, SystemInfoData, SystemInfoResponses, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateModelSettingsData, UpdateModelSettingsErrors, UpdateModelSettingsResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionData, UpdateSessionErrors, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -571,17 +571,7 @@ export const sendTelemetryEvent = (options } }); -/** - * Start the tunnel - */ -export const startTunnel = (options?: Options) => (options?.client ?? client).post({ url: '/tunnel/start', ...options }); - /** * Get tunnel info */ export const getTunnelStatus = (options?: Options) => (options?.client ?? client).get({ url: '/tunnel/status', ...options }); - -/** - * Stop the tunnel - */ -export const stopTunnel = (options?: Options) => (options?.client ?? client).post({ url: '/tunnel/stop', ...options }); diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 7f8eb3e44fd8..31b39b455819 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -1414,6 +1414,7 @@ export type Session = { extension_data: ExtensionData; goose_mode?: GooseMode; id: string; + last_message_at?: string | null; last_message_snippet?: string | null; message_count: number; model_config?: ModelConfig | null; @@ -4662,35 +4663,6 @@ export type SendTelemetryEventResponses = { 202: unknown; }; -export type StartTunnelData = { - body?: never; - path?: never; - query?: never; - url: '/tunnel/start'; -}; - -export type StartTunnelErrors = { - /** - * Bad request - */ - 400: ErrorResponse; - /** - * Internal server error - */ - 500: ErrorResponse; -}; - -export type StartTunnelError = StartTunnelErrors[keyof StartTunnelErrors]; - -export type StartTunnelResponses = { - /** - * Tunnel started successfully - */ - 200: TunnelInfo; -}; - -export type StartTunnelResponse = StartTunnelResponses[keyof StartTunnelResponses]; - export type GetTunnelStatusData = { body?: never; path?: never; @@ -4706,26 +4678,3 @@ export type GetTunnelStatusResponses = { }; export type GetTunnelStatusResponse = GetTunnelStatusResponses[keyof GetTunnelStatusResponses]; - -export type StopTunnelData = { - body?: never; - path?: never; - query?: never; - url: '/tunnel/stop'; -}; - -export type StopTunnelErrors = { - /** - * Internal server error - */ - 500: ErrorResponse; -}; - -export type StopTunnelError = StopTunnelErrors[keyof StopTunnelErrors]; - -export type StopTunnelResponses = { - /** - * Tunnel stopped successfully - */ - 200: unknown; -}; diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index c9a10d4320ef..f1fa9ea054b8 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -26,7 +26,6 @@ import { scanRecipe } from '../recipe'; import type { Recipe } from '../recipe'; import { UserInput } from '../types/message'; import RecipeActivities from './recipes/RecipeActivities'; -import { useToolCount } from './alerts/useToolCount'; import { getThinkingMessage, getTextAndImageContent } from '../types/message'; import ParameterInputModal from './ParameterInputModal'; import { substituteParameters } from '../utils/parameterSubstitution'; @@ -97,6 +96,7 @@ export default function BaseChat({ setChatState, updateSession, handleSubmit, + onSteerQueuedMessage, submitElicitationResponse, stopStreaming, sessionLoadError, @@ -104,6 +104,7 @@ export default function BaseChat({ tokenState, notifications: toolCallNotifications, pauseQueueOnStop, + queueProcessingBlocked, onMessageUpdate, } = useChatSession({ sessionId, @@ -284,8 +285,6 @@ export default function BaseChat({ } }, [messages.length]); - const toolCount = useToolCount(sessionId); - // Listen for global scroll-to-bottom requests (e.g., from MCP UI prompt actions) useEffect(() => { const handleGlobalScrollRequest = () => { @@ -511,7 +510,9 @@ export default function BaseChat({ chatState={chatState} setChatState={setChatState} onStop={stopStreaming} + onSteerQueuedMessage={onSteerQueuedMessage} pauseQueueOnStop={pauseQueueOnStop} + queueProcessingBlocked={queueProcessingBlocked} commandHistory={commandHistory} initialValue={initialPrompt} setView={setView} @@ -534,7 +535,6 @@ export default function BaseChat({ recipe={recipe} recipeAccepted={!RECIPE_TRUST_WARNINGS_ENABLED || !hasNotAcceptedRecipe} initialPrompt={initialPrompt} - toolCount={toolCount || 0} sessionModel={sessionModel} sessionProvider={sessionProvider} sessionLoaded={sessionLoaded} diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 8acdad2de540..09efa330fb70 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -81,9 +81,7 @@ const removeQueuedMessage = (messages: QueuedMessage[], messageId: string): Queu const MAX_IMAGES_PER_MESSAGE = 10; -// Constants for token and tool alerts const TOKEN_LIMIT_DEFAULT = 128000; // fallback for custom models that the backend doesn't know about -const TOOLS_MAX_SUGGESTED = 60; // max number of tools before we show a warning const getContextAlertType = (totalTokens: number, tokenLimit: number): AlertType => { const percentage = tokenLimit ? (totalTokens / tokenLimit) * 100 : 0; @@ -117,15 +115,6 @@ const i18n = defineMessages({ id: 'chatInput.contextWindow', defaultMessage: 'Context window', }, - tooManyTools: { - id: 'chatInput.tooManyTools', - defaultMessage: - 'Too many tools can degrade performance.\nTool count: {toolCount} (recommend: {recommended})', - }, - viewExtensions: { - id: 'chatInput.viewExtensions', - defaultMessage: 'View extensions', - }, waitingForImages: { id: 'chatInput.waitingForImages', defaultMessage: 'Waiting for images to save...', @@ -154,6 +143,10 @@ const i18n = defineMessages({ id: 'chatInput.send', defaultMessage: 'Send', }, + waitingForCancellation: { + id: 'chatInput.waitingForCancellation', + defaultMessage: 'Waiting for cancellation to finish', + }, failedToReadImage: { id: 'chatInput.failedToReadImage', defaultMessage: 'Failed to read image file', @@ -170,7 +163,9 @@ interface ChatInputProps { chatState: ChatState; setChatState?: (state: ChatState) => void; onStop?: () => void; + onSteerQueuedMessage?: (input: UserInput) => Promise; pauseQueueOnStop?: boolean; + queueProcessingBlocked?: boolean; commandHistory?: string[]; initialValue?: string; droppedFiles?: DroppedFile[]; @@ -186,7 +181,6 @@ interface ChatInputProps { recipeId?: string | null; recipeAccepted?: boolean; initialPrompt?: string; - toolCount: number; append?: (message: Message) => void; onWorkingDirChange?: (newDir: string) => Promise | void; inputRef?: React.RefObject; @@ -205,7 +199,9 @@ export default function ChatInput({ chatState = ChatState.Idle, setChatState, onStop, + onSteerQueuedMessage, pauseQueueOnStop = false, + queueProcessingBlocked = false, commandHistory = [], initialValue = '', droppedFiles = [], @@ -221,7 +217,6 @@ export default function ChatInput({ recipeId: _recipeId, recipeAccepted, initialPrompt, - toolCount, append: _append, onWorkingDirChange, inputRef, @@ -241,15 +236,35 @@ export default function ChatInput({ // Derived state - chatState != Idle means we're in some form of loading state const isLoading = chatState !== ChatState.Idle; + const isLoadingRef = useRef(isLoading); + const queueProcessingBlockedRef = useRef(queueProcessingBlocked); const wasLoadingRef = useRef(isLoading); + const wasQueueProcessingBlockedRef = useRef(queueProcessingBlocked); + isLoadingRef.current = isLoading; + queueProcessingBlockedRef.current = queueProcessingBlocked; // Queue functionality - ephemeral, only exists in memory for this chat instance const [queuedMessages, setQueuedMessages] = useState([]); const queuePausedRef = useRef(false); const editingMessageIdRef = useRef(null); const sendAfterStopMessageIdRef = useRef(null); + const sendNowInFlightMessageIdsRef = useRef>(new Set()); + const [sendNowInFlightMessageIds, setSendNowInFlightMessageIds] = useState>( + new Set() + ); const [lastInterruption, setLastInterruption] = useState(null); + const setSendNowInFlightMessage = useCallback((messageId: string, isInFlight: boolean) => { + const nextMessageIds = new Set(sendNowInFlightMessageIdsRef.current); + if (isInFlight) { + nextMessageIds.add(messageId); + } else { + nextMessageIds.delete(messageId); + } + sendNowInFlightMessageIdsRef.current = nextMessageIds; + setSendNowInFlightMessageIds(nextMessageIds); + }, []); + const pauseRemainingQueue = useCallback(() => { queuePausedRef.current = true; }, []); @@ -357,7 +372,16 @@ export default function ChatInput({ // Queue processing useEffect(() => { - if (wasLoadingRef.current && !isLoading && queuedMessages.length > 0) { + const becameIdle = wasLoadingRef.current && !isLoading; + const becameUnblocked = wasQueueProcessingBlockedRef.current && !queueProcessingBlocked; + const hasSendNowInFlight = sendNowInFlightMessageIdsRef.current.size > 0; + + if ( + (becameIdle || (becameUnblocked && !isLoading)) && + !queueProcessingBlocked && + !hasSendNowInFlight && + queuedMessages.length > 0 + ) { const pendingSendAfterStopId = sendAfterStopMessageIdRef.current; const messageToSend = pendingSendAfterStopId ? queuedMessages.find((message) => message.id === pendingSendAfterStopId) @@ -366,11 +390,13 @@ export default function ChatInput({ if (pendingSendAfterStopId && !messageToSend) { clearPendingSendAfterStop(pendingSendAfterStopId); wasLoadingRef.current = isLoading; + wasQueueProcessingBlockedRef.current = queueProcessingBlocked; return; } if (!messageToSend) { wasLoadingRef.current = isLoading; + wasQueueProcessingBlockedRef.current = queueProcessingBlocked; return; } @@ -406,8 +432,10 @@ export default function ChatInput({ } } wasLoadingRef.current = isLoading; + wasQueueProcessingBlockedRef.current = queueProcessingBlocked; }, [ isLoading, + queueProcessingBlocked, queuedMessages, handleSubmit, lastInterruption, @@ -617,7 +645,7 @@ export default function ChatInput({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [effectiveModel, effectiveProvider, configModel, configProvider]); - // Handle tool count alerts and token usage + // Handle token usage alerts useEffect(() => { clearAlerts(); @@ -640,24 +668,9 @@ export default function ChatInput({ }); } - // Add tool count alert if we have the data - if (toolCount !== null && toolCount > TOOLS_MAX_SUGGESTED) { - addAlert({ - type: AlertType.Warning, - message: intl.formatMessage(i18n.tooManyTools, { - toolCount, - recommended: TOOLS_MAX_SUGGESTED, - }), - action: { - text: intl.formatMessage(i18n.viewExtensions), - onClick: () => setView('extensions'), - }, - autoShow: false, // Don't auto-show tool count warnings - }); - } - // We intentionally omit setView as it shouldn't trigger a re-render of alerts + // Keep alert recalculation scoped to token state changes. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [totalTokens, toolCount, tokenLimit, isTokenLimitLoaded, addAlert, clearAlerts]); + }, [totalTokens, tokenLimit, isTokenLimitLoaded, addAlert, clearAlerts]); // Cleanup effect for component unmount - prevent memory leaks useEffect(() => { @@ -1074,6 +1087,7 @@ export default function ChatInput({ const canSubmit = !isLoading && + !queueProcessingBlocked && (displayValue.trim() || pastedImages.some((img) => img.dataUrl && !img.error && !img.isLoading) || allDroppedFiles.some((file) => !file.error && !file.isLoading)); @@ -1190,12 +1204,16 @@ export default function ChatInput({ const onFormSubmit = (e: React.FormEvent | React.MouseEvent) => { e.preventDefault(); + if (queueProcessingBlocked) { + return; + } if (isLoading && hasSubmittableContent) { handleInterruptionAndQueue(); return; } const canSubmit = !isLoading && + !queueProcessingBlocked && (displayValue.trim() || pastedImages.some((img) => img.dataUrl && !img.error && !img.isLoading) || allDroppedFiles.some((file) => !file.error && !file.isLoading)); @@ -1314,9 +1332,11 @@ export default function ChatInput({ isAnyDroppedFileLoading || isRecording || isTranscribing || + queueProcessingBlocked || chatState === ChatState.RestartingAgent; const getSubmitButtonTooltip = (): string => { + if (queueProcessingBlocked) return intl.formatMessage(i18n.waitingForCancellation); if (isAnyImageLoading) return intl.formatMessage(i18n.waitingForImages); if (isAnyDroppedFileLoading) return intl.formatMessage(i18n.processingDroppedFiles); if (isRecording) return intl.formatMessage(i18n.recording); @@ -1328,28 +1348,35 @@ export default function ChatInput({ // Queue management functions - no storage persistence, only in-memory const handleRemoveQueuedMessage = (messageId: string) => { + if (sendNowInFlightMessageIdsRef.current.has(messageId)) return; clearPendingSendAfterStop(messageId); setQueuedMessages((prev) => prev.filter((msg) => msg.id !== messageId)); }; const handleClearQueue = () => { + if (sendNowInFlightMessageIdsRef.current.size > 0) return; setQueuedMessages([]); clearQueueState(); }; const handleReorderMessages = (reorderedMessages: QueuedMessage[]) => { + if (reorderedMessages.some((message) => sendNowInFlightMessageIdsRef.current.has(message.id))) { + return; + } setQueuedMessages(reorderedMessages); }; const handleEditMessage = (messageId: string, newContent: string) => { + if (sendNowInFlightMessageIdsRef.current.has(messageId)) return; setQueuedMessages((prev) => prev.map((msg) => (msg.id === messageId ? { ...msg, content: newContent } : msg)) ); }; - const handleStopAndSend = (messageId: string) => { + const handleStopAndSend = async (messageId: string) => { const messageToSend = queuedMessages.find((msg) => msg.id === messageId); if (!messageToSend) return; + if (queueProcessingBlocked) return; if (!isLoading) { setQueuedMessages((prev) => removeQueuedMessage(prev, messageId)); @@ -1358,6 +1385,53 @@ export default function ChatInput({ return; } + if (onSteerQueuedMessage) { + if (sendNowInFlightMessageIdsRef.current.has(messageId)) { + return; + } + + const wasQueuePausedBeforeSteer = queuePausedRef.current; + pauseRemainingQueue(); + setSendNowInFlightMessage(messageId, true); + try { + const steerAccepted = await onSteerQueuedMessage({ + msg: messageToSend.content, + images: messageToSend.images, + }); + + if (steerAccepted) { + LocalMessageStorage.addMessage(messageToSend.content); + clearPendingSendAfterStop(messageId); + setQueuedMessages((prev) => { + const newQueue = removeQueuedMessage(prev, messageId); + if (newQueue.length === 0) { + clearQueueState(); + } else { + pauseRemainingQueue(); + } + return newQueue; + }); + return; + } + } finally { + setSendNowInFlightMessage(messageId, false); + } + + if (!isLoadingRef.current && !queueProcessingBlockedRef.current) { + queuePausedRef.current = wasQueuePausedBeforeSteer; + setQueuedMessages((prev) => { + const newQueue = removeQueuedMessage(prev, messageId); + if (newQueue.length === 0) { + clearQueueState(); + } + return newQueue; + }); + LocalMessageStorage.addMessage(messageToSend.content); + handleSubmit({ msg: messageToSend.content, images: messageToSend.images }); + return; + } + } + sendAfterStopMessageIdRef.current = messageId; pauseRemainingQueue(); setQueuedMessages((prev) => moveQueuedMessageToFront(prev, messageId)); @@ -1374,7 +1448,7 @@ export default function ChatInput({ const handleResumeQueue = () => { queuePausedRef.current = false; setLastInterruption(null); - if (!isLoading && queuedMessages.length > 0) { + if (!isLoading && !queueProcessingBlocked && queuedMessages.length > 0) { const nextMessage = queuedMessages[0]; LocalMessageStorage.addMessage(nextMessage.content); handleSubmit({ msg: nextMessage.content, images: nextMessage.images }); @@ -1421,6 +1495,7 @@ export default function ChatInput({ onEditMessage={handleEditMessage} onTriggerQueueProcessing={handleResumeQueue} editingMessageIdRef={editingMessageIdRef} + sendingMessageIds={sendNowInFlightMessageIds} isPaused={queuePausedRef.current} className="border-b border-border-primary" /> @@ -1797,6 +1872,7 @@ export default function ChatInput({ setMentionPopover((prev) => ({ ...prev, selectedIndex: index })) } workingDir={currentWorkingDir} + sessionId={sessionId} />
diff --git a/ui/desktop/src/components/Hub.tsx b/ui/desktop/src/components/Hub.tsx index d00bfabf98c7..7deca289fc82 100644 --- a/ui/desktop/src/components/Hub.tsx +++ b/ui/desktop/src/components/Hub.tsx @@ -146,7 +146,6 @@ export default function Hub({ onFilesProcessed={() => {}} messages={[]} disableAnimation={false} - toolCount={0} onWorkingDirChange={setWorkingDir} inputRef={inputRef} nextChatExtensionDraft={draftForMenu} diff --git a/ui/desktop/src/components/MentionPopover.tsx b/ui/desktop/src/components/MentionPopover.tsx index 80d0933abd20..ccdcadb5e8ae 100644 --- a/ui/desktop/src/components/MentionPopover.tsx +++ b/ui/desktop/src/components/MentionPopover.tsx @@ -8,9 +8,9 @@ import { useState, } from 'react'; import { ItemIcon } from './ItemIcon'; -import { CommandType, getSlashCommands } from '../api'; import { getInitialWorkingDir } from '../utils/workingDir'; import { defineMessages, useIntl } from '../i18n'; +import { listAgentMentionItems, listSlashCommandItems } from '../acp/autocomplete'; const i18n = defineMessages({ scanningFiles: { @@ -35,7 +35,8 @@ const i18n = defineMessages({ }, }); -type DisplayItemType = CommandType | 'Directory' | 'File'; +type CommandItemType = 'Builtin' | 'Recipe' | 'Skill' | 'Agent'; +type DisplayItemType = CommandItemType | 'Directory' | 'File'; const typeOrder: Record = { Agent: 0, @@ -51,6 +52,7 @@ export interface DisplayItem { extra: string; itemType: DisplayItemType; relativePath: string; + insertText?: string; } export interface DisplayItemWithMatch extends DisplayItem { @@ -69,6 +71,7 @@ interface MentionPopoverProps { selectedIndex: number; onSelectedIndexChange: (index: number) => void; workingDir?: string; + sessionId?: string | null; } // Enhanced fuzzy matching algorithm @@ -150,6 +153,7 @@ const MentionPopover = forwardRef< selectedIndex, onSelectedIndexChange, workingDir, + sessionId, }, ref ) => { @@ -452,6 +456,9 @@ const MentionPopover = forwardRef< }, [items, query, currentWorkingDir]); const getSelectionText = (item: DisplayItem): string => { + if (item.insertText) { + return item.insertText; + } if (item.itemType === 'Agent') { return '@' + item.name + ' '; } @@ -484,38 +491,16 @@ const MentionPopover = forwardRef< setIsLoading(true); try { if (isSlashCommand) { - const response = await getSlashCommands({ - query: { working_dir: currentWorkingDir }, - throwOnError: true, - }); + const commandItems = await listSlashCommandItems(currentWorkingDir); if (cancelled) return; - const commandItems: DisplayItem[] = (response.data?.commands || []) - .filter((cmd) => cmd.command_type !== 'Agent') - .map((cmd) => ({ - name: cmd.command, - extra: cmd.help, - itemType: cmd.command_type, - relativePath: cmd.command, - })); setItems(commandItems); } else { // Fetch agents from server and scan files in parallel - const [agentResponse, scannedFiles] = await Promise.all([ - getSlashCommands({ - query: { working_dir: currentWorkingDir }, - throwOnError: true, - }).catch(() => null), + const [agentItems, scannedFiles] = await Promise.all([ + listAgentMentionItems(currentWorkingDir, sessionId ?? undefined).catch(() => []), scanDirectoryFromRoot(currentWorkingDir || getDefaultStartPath()), ]); if (cancelled) return; - const agentItems: DisplayItem[] = (agentResponse?.data?.commands || []) - .filter((cmd) => cmd.command_type === 'Agent') - .map((cmd) => ({ - name: cmd.command, - extra: cmd.help, - itemType: cmd.command_type, - relativePath: cmd.command, - })); setItems([...agentItems, ...scannedFiles]); } } catch (error) { @@ -537,7 +522,7 @@ const MentionPopover = forwardRef< return () => { cancelled = true; }; - }, [isOpen, isSlashCommand, scanDirectoryFromRoot, currentWorkingDir]); + }, [isOpen, isSlashCommand, scanDirectoryFromRoot, currentWorkingDir, sessionId]); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -610,7 +595,7 @@ const MentionPopover = forwardRef< > {displayItems.map((item, index) => (
handleItemClick(index)} data-selected={index === selectedIndex} className={`flex items-center gap-3 p-2 rounded-md cursor-pointer transition-colors ${ diff --git a/ui/desktop/src/components/MessageQueue.tsx b/ui/desktop/src/components/MessageQueue.tsx index ce856ae7467c..242e038e8ee6 100644 --- a/ui/desktop/src/components/MessageQueue.tsx +++ b/ui/desktop/src/components/MessageQueue.tsx @@ -103,6 +103,7 @@ interface MessageQueueProps { onTriggerQueueProcessing?: () => void; editingMessageIdRef?: React.MutableRefObject; onReorderMessages?: (reorderedMessages: QueuedMessage[]) => void; + sendingMessageIds?: ReadonlySet; className?: string; isPaused?: boolean; } @@ -116,6 +117,7 @@ export const MessageQueue: React.FC = ({ onTriggerQueueProcessing, editingMessageIdRef, onReorderMessages, + sendingMessageIds, className = '', isPaused = false, }) => { @@ -126,18 +128,28 @@ export const MessageQueue: React.FC = ({ const [hoveredMessage, setHoveredMessage] = useState(null); const [editingMessage, setEditingMessage] = useState(null); const [editContent, setEditContent] = useState(''); + const isSendingMessage = (messageId: string) => sendingMessageIds?.has(messageId) ?? false; if (queuedMessages.length === 0) { return null; } const handleDragStart = (e: React.DragEvent, messageId: string) => { + if (isSendingMessage(messageId)) { + e.preventDefault(); + return; + } + setDraggedItem(messageId); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/html', messageId); }; const handleDragOver = (e: React.DragEvent, messageId: string) => { + if (isSendingMessage(messageId)) { + return; + } + e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverItem(messageId); @@ -150,7 +162,7 @@ export const MessageQueue: React.FC = ({ const handleDrop = (e: React.DragEvent, targetMessageId: string) => { e.preventDefault(); - if (!draggedItem || !onReorderMessages) return; + if (!draggedItem || !onReorderMessages || isSendingMessage(targetMessageId)) return; const draggedIndex = queuedMessages.findIndex((msg) => msg.id === draggedItem); const targetIndex = queuedMessages.findIndex((msg) => msg.id === targetMessageId); @@ -185,6 +197,8 @@ export const MessageQueue: React.FC = ({ const nextMessage = queuedMessages[0]; const remainingCount = queuedMessages.length - 1; + const nextMessageIsSending = isSendingMessage(nextMessage.id); + const hasSendingMessages = queuedMessages.some((message) => isSendingMessage(message.id)); // Compact View if (!isExpanded) { @@ -232,8 +246,10 @@ export const MessageQueue: React.FC = ({ size="sm" onClick={(e) => { e.stopPropagation(); + if (nextMessageIsSending) return; onStopAndSend(nextMessage.id); }} + disabled={nextMessageIsSending} className="h-7 px-2 text-xs text-info hover:text-info/80 hover:bg-info/10" title={intl.formatMessage(i18n.sendNow)} > @@ -287,12 +303,16 @@ export const MessageQueue: React.FC = ({
- {isPaused ? intl.formatMessage(i18n.queuePaused) : intl.formatMessage(i18n.messageQueue)} + {isPaused + ? intl.formatMessage(i18n.queuePaused) + : intl.formatMessage(i18n.messageQueue)} {intl.formatMessage(i18n.messageCount, { count: queuedMessages.length, - status: isPaused ? intl.formatMessage(i18n.waiting) : intl.formatMessage(i18n.queued), + status: isPaused + ? intl.formatMessage(i18n.waiting) + : intl.formatMessage(i18n.queued), })}
@@ -304,6 +324,7 @@ export const MessageQueue: React.FC = ({ variant="ghost" size="sm" onClick={onClearQueue} + disabled={hasSendingMessages} className="text-xs h-7 px-3 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors" > {intl.formatMessage(i18n.clearAll)} @@ -328,184 +349,193 @@ export const MessageQueue: React.FC = ({
- - {intl.formatMessage(i18n.queuePausedExpanded)} - + {intl.formatMessage(i18n.queuePausedExpanded)}
)} {/* Message Bubbles */}
- {queuedMessages.map((message, index) => ( -
handleDragStart(e, message.id)} - onDragOver={(e) => handleDragOver(e, message.id)} - onDragLeave={handleDragLeave} - onDrop={(e) => handleDrop(e, message.id)} - onDragEnd={handleDragEnd} - onMouseEnter={() => setHoveredMessage(message.id)} - onMouseLeave={() => setHoveredMessage(null)} - > - {/* Main message bubble */} + {queuedMessages.map((message, index) => { + const isSending = isSendingMessage(message.id); + const isEditing = editingMessage === message.id; + return (
handleDragStart(e, message.id)} + onDragOver={(e) => handleDragOver(e, message.id)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, message.id)} + onDragEnd={handleDragEnd} + onMouseEnter={() => setHoveredMessage(message.id)} + onMouseLeave={() => setHoveredMessage(null)} > - {/* Priority indicator */} -
-
- {index + 1} -
- - {/* Drag handle */} - {onReorderMessages && ( + {/* Main message bubble */} +
+ {/* Priority indicator */} +
- + {index + 1}
- )} -
- {/* Message content */} -
- {editingMessage === message.id ? ( -
-