diff --git a/.github/ISSUE_TEMPLATE/hacktobefest-content-submission.yml b/.github/ISSUE_TEMPLATE/hacktobefest-content-submission.yml new file mode 100644 index 0000000000..9b0b957463 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/hacktobefest-content-submission.yml @@ -0,0 +1,29 @@ +name: Hacktobefest Content +title: "Hacktoberfest Blog: " +labels: ["hacktoberfest", "hacktoberfest-accepted"] +assignees: ["ebonylouis", "blackgirlbytes", "taniandjerry"] +body: + - type: textarea + id: description + attributes: + label: Description + + - type: input + id: url + attributes: + label: URL + + - type: dropdown + id: kind + attributes: + label: Type + options: + - Blog post + - YouTube video + + - type: checkboxes + id: public + attributes: + label: Confirmation + options: + - label: My submission is publicly accessible right now. diff --git a/.github/ISSUE_TEMPLATE/hacktoberfest-content-submission.yml b/.github/ISSUE_TEMPLATE/hacktoberfest-content-submission.yml new file mode 100644 index 0000000000..c50851be6c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/hacktoberfest-content-submission.yml @@ -0,0 +1,30 @@ +name: Hacktoberfest Content +description: Issue template for various blog and video contributions as part of Hacktoberfest 2025 +title: "Hacktoberfest Blog: " +labels: ["hacktoberfest", "hacktoberfest-accepted"] +assignees: ["ebonylouis", "blackgirlbytes", "taniandjerry"] +body: + - type: textarea + id: description + attributes: + label: Description + + - type: input + id: url + attributes: + label: URL + + - type: dropdown + id: kind + attributes: + label: Type + options: + - Blog post + - YouTube video + + - type: checkboxes + id: public + attributes: + label: Confirmation + options: + - label: My submission is publicly accessible right now. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 22fdcd1f90..b8f1f7e29f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,8 +1,31 @@ -## Pull Request Description +## Summary + - ---- +### Type of Change + +- [ ] Feature +- [ ] Bug fix +- [ ] Refactor / Code quality +- [ ] Performance improvement +- [ ] Documentation +- [ ] Tests +- [ ] Security fix +- [ ] Build / Release +- [ ] Other (specify below) + +### Testing + + +### Related Issues +Relates to #ISSUE_ID +Discussion: LINK (if any) + + +### Screenshots/Demos (for UX changes) +Before: + +After: **Email**: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 0737cad68f..5e9e6d3298 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -1,4 +1,5 @@ -# This workflow is for nightly releases, automatically triggered at midnight US Eastern +# A release from main. Automatically triggered at midnight US Eastern. + name: Nightly Build on: @@ -12,6 +13,7 @@ on: required: false default: 'main' + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -22,25 +24,30 @@ jobs: runs-on: ubuntu-latest outputs: version: ${{ steps.set-version.outputs.version }} + commit_sha: ${{ steps.get-commit.outputs.commit_sha }} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 with: - ref: ${{ github.event.inputs.branch || github.ref }} + ref: main # Ensure we're building from latest main + fetch-depth: 0 + + - name: Get commit info + id: get-commit + run: | + echo "commit_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + echo "Building from commit: $(git rev-parse HEAD)" + echo "Latest commit: $(git log -1 --oneline)" - name: Generate a nightly version id: set-version run: | + # Extract the version from Cargo.toml and add nightly tag with date and short commit VERSION=$(grep '^version\s*=' Cargo.toml | head -n 1 | cut -d\" -f2) DATE=$(date -u +%Y%m%d) - VERSION="${VERSION}-nightly.${DATE}" - - # validate - if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-nightly\.[0-9]{8}$ ]]; then - echo "Error: Invalid version format: $VERSION" - exit 1 - fi - + SHORT_SHA=$(git rev-parse --short HEAD) + VERSION="${VERSION}-nightly.${DATE}.${SHORT_SHA}" echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Generated version: $VERSION" build-cli: needs: [prepare-version] @@ -67,7 +74,9 @@ jobs: contents: read with: version: ${{ needs.prepare-version.outputs.version }} - signing: false + signing: true + secrets: + OSX_CODESIGN_ROLE: ${{ secrets.OSX_CODESIGN_ROLE }} bundle-desktop-linux: needs: [prepare-version] @@ -80,7 +89,10 @@ jobs: uses: ./.github/workflows/bundle-desktop-windows.yml with: version: ${{ needs.prepare-version.outputs.version }} - signing: false + signing: true + secrets: + WINDOW_SIGNING_ROLE: ${{ secrets.WINDOW_SIGNING_ROLE }} + WINDOW_SIGNING_ROLE_TAG: ${{ secrets.WINDOW_SIGNING_ROLE_TAG }} release: name: Release @@ -88,24 +100,8 @@ jobs: needs: [prepare-version, build-cli, install-script, bundle-desktop, bundle-desktop-linux, bundle-desktop-windows] permissions: contents: write - actions: read steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 - with: - ref: ${{ github.event.inputs.branch || github.ref }} - fetch-depth: 0 # Fetch all history for proper tagging - - - name: Create tag - run: | - TAG="${{ needs.prepare-version.outputs.version }}" - - echo "Creating tag: $TAG" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - git tag -a "$TAG" -m "Nightly release $TAG - SHA: ${{ github.sha }} - Run: ${{ github.run_number }}" - git push origin "$TAG" - echo "Tag $TAG created successfully" - name: Download all artifacts uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # pin@v4 @@ -129,4 +125,3 @@ jobs: omitBody: true prerelease: true makeLatest: false - omitPrereleaseDuringUpdate: true diff --git a/.github/workflows/pr-comment-bundle.yml b/.github/workflows/pr-comment-bundle.yml index e9d52656e8..3325f93cf7 100644 --- a/.github/workflows/pr-comment-bundle.yml +++ b/.github/workflows/pr-comment-bundle.yml @@ -2,8 +2,6 @@ # It bundles the ARM64 Desktop App, then creates a PR comment with a link to download the app. on: - issue_comment: - types: [created] workflow_dispatch: inputs: pr_number: diff --git a/.github/workflows/test-finder.yml b/.github/workflows/test-finder.yml index c4d31e5646..47df9e3195 100644 --- a/.github/workflows/test-finder.yml +++ b/.github/workflows/test-finder.yml @@ -1,9 +1,9 @@ name: Daily Test Coverage Finder on: - schedule: - # Run daily at 2 AM UTC - - cron: '0 2 * * *' + # schedule: + # # Run daily at 2 AM UTC - PAUSED + # - cron: '0 2 * * *' workflow_dispatch: inputs: dry_run: @@ -68,9 +68,10 @@ jobs: - Fix the test code - Apply the fix and run the test again - Repeat up to 3 times until the test passes - 6. Once the test passes (or after 3 attempts), save the final changes as a git diff to /tmp/test_addition.patch - 7. If successful, write the name of the function you tested to /tmp/function_tested.txt (just the function name, e.g., "check_tool_call" or "MyStruct::my_method") - 8. Only create the patch file if the test actually passes + 6. Once the test passes, run `cargo fmt` to format all the code properly + 7. After formatting, save the final changes as a git diff to /tmp/test_addition.patch + 8. If successful, write the name of the function you tested to /tmp/function_tested.txt (just the function name, e.g., "check_tool_call" or "MyStruct::my_method") + 9. Only create the patch file if the test actually passes Important: - Only add ONE test for ONE function diff --git a/BUILDING_LINUX.md b/BUILDING_LINUX.md index 76cb30fa45..33c213c206 100644 --- a/BUILDING_LINUX.md +++ b/BUILDING_LINUX.md @@ -19,7 +19,7 @@ sudo pacman -S --needed dpkg fakeroot base-devel **Fedora/RHEL/CentOS:** ```bash -sudo dnf install dpkg-dev fakeroot gcc gcc-c++ make +sudo dnf install dpkg-dev fakeroot gcc gcc-c++ make libxcb-devel ``` **openSUSE:** diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000000..7eef53672c --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,166 @@ +# goose Technical Governance and Stewardship + +Learn about goose's governance structure and how to participate + +goose follows a lightweight technical governance model designed to support rapid iteration while maintaining community involvement. This document outlines how the project is organized and how decisions are made. + +## Core Values + +goose's governance is guided by three fundamental values: + +* **Open**: goose is open source, but we go beyond code availability. We plan and build in the open. Our roadmap as well as goose recipes, extensions, and prompts are editable and shareable. Our goal is to make goose the most hackable agent available. +* **Flexible**: we prefer open models – but we don’t restrict ourselves. goose equally supports remotely deployed frontier models as well as local private models, whether open or proprietary. +* **Choice**: We're not bound to any one model, protocol, or stack. goose is built for choice and open standards, adapting to your tools, workflow, and identity as a creator. + +## Technical Governance + +goose adopts a streamlined two-tier structure optimized for speed and flexibility: + +* **Core Maintainers** drive overall project direction and make final decisions +* **Maintainers** who have demonstrated extraordinary contributions and help drive specific components + +All Maintainers are expected to embody goose's philosophy of openness and user autonomy. Membership in the technical governance process is for individuals, not companies. + +### Core Maintainers + +Core Maintainers are members of the goose team responsible for: + +* Setting the overall technical direction and vision for goose +* Reviewing and merging pull requests +* Making architectural decisions along with Maintainers +* Maintaining release processes +* Resolving disputes and contentious issues +* Appointing Maintainers +* Ensuring goose remains fast-moving and experimental +* Ensuring the quality and stability of goose + +Core Maintainers have full write access to the goose repositories and infrastructure. + +### Maintainers + +Maintainers are exceptional contributors from the broader community who have: + +* Demonstrated deep understanding of goose through significant contributions +* Shown alignment with goose's values and technical direction +* Consistently provided high-quality code reviews and community support + +Maintainers can: + +* Submit pull requests and push branches directly to the main repository +* Review and provide feedback on pull requests +* Help triage issues and guide contributors +* Participate in technical discussions and planning + +Maintainers have write access for creating pull requests but cannot directly merge to main or modify sensitive repository settings. + +## Decision Making + +### Fast-Track Process + +To maintain goose's experimental and fast-moving nature: + +* Most decisions are made through informal consensus in pull requests, GitHub discussions, and issues +* Core Maintainers can approve and merge changes quickly when there's clear benefit +* Significant architectural changes ([such as adopting ACP](https://github.com/block/goose/discussions/4645)) should have discussion in a GitHub issue or discussion before implementation +* We optimize for shipping and iterating rather than lengthy deliberation + +### Community Input + +While we move fast, we value community input: + +* All changes happen through pull requests, providing visibility +* Significant features should have an associated GitHub issue describing the feature and testing approach +* Community feedback is actively sought on Discord and GitHub discussions +* External contributions are reviewed within two days, with merge/close decisions within two weeks + +## Working Practices + +### Code Review + +Following our way of working: + +* **Review AI-generated work carefully**: Check for redundant comments, bloated tests, outdated patterns, and repeated code +* **Prioritize reviews**: Others are waiting, but take time to understand the changes +* **Avoid review shopping**: Seek review from those familiar with the code being modified +* **Test thoroughly**: Manual and automated E2E testing is essential for larger features; post videos for UI changes + +### Contributing + +* **Discuss first**: For new features or architectural changes, open an issue or discussion +* **Keep PRs focused**: Smaller, focused changes are easier to review and merge +* **Write meaningful tests**: Tests should guard against real bugs, not just increase coverage +* **Engage with the community**: All Maintainers should be active on Discord and on GitHub, and be responsive to other contributors + +### Release Process + +* Regular releases with clear documentation of delivered features +* Quick bug fixes or security resolutions are cherry-picked to patch releases when needed +* All releases are tested by multiple Core Maintainers or Maintainers before publication + +## Communication + +### Channels + +* **GitHub**: Primary platform for PRs, issues, and technical discussions +* **Discord**: Real-time community discussion and support + +### Transparency + +* All technical decisions are made in public through GitHub and Discord +* Meeting notes and significant decisions are shared with the community on GitHub +* Roadmap and priorities are openly discussed and published on GitHub + +## Nominating Maintainers + +### Principles + +* Recognition is based on individual merit and contributions +* No term limits, but inactive Maintainers may be moved to emeritus status +* Membership is for individuals, not their employers + +### Process + +1. Core Maintainers identify exceptional contributors through: + - History of high-quality merged PRs + - Consistent helpful code reviews + - Strong community engagement + - Alignment with goose values +2. Discussion among Core Maintainers about the nomination +3. If approved, the contributor is invited to become a Maintainer +4. Announcement made to the community via Discord and GitHub + +### Removal + +Core Maintainers may remove Maintainer status if: + +* Extended inactivity (3+ months without contribution) +* Actions contrary to goose's values +* By request of the Maintainer +* Appeals can be sent to the Core Maintainers group with rationale on why someone disagrees with the removal decision, with data to back up their case. As long as there is new information and good reasons to reverse the decision the appeal will be considered. + +## Current Membership + +Core Maintainers and Maintainers are listed in the main goose repository's [MAINTAINERS.md](https://github.com/block/goose/blob/main/MAINTAINERS.md) file with their areas of expertise where applicable. + +## Evolution of Governance + +This governance model is designed to be lightweight and may evolve as goose grows. Changes to governance require: + +1. Proposal via GitHub issue with rationale +2. Community discussion period (minimum 1 week) +3. Consensus among Core Maintainers +4. Clear communication of changes to the community +5. A PR to the [GOVERNANCE.md](https://github.com/block/goose/blob/main/GOVERNANCE.md) file in the main goose repository + +The key principle is maintaining goose's ability to move fast and experiment while respecting community contributions and maintaining transparency. + +## Summary + +goose's governance prioritizes: + +* **Speed**: Minimal process to support rapid experimentation +* **Openness**: Transparent decision-making and community involvement +* **Autonomy**: Empowering users and contributors to shape goose +* **Quality**: Thoughtful review while avoiding bureaucracy + +We believe this balance enables goose to remain innovative while building a strong, engaged community around the shared goal of creating the most hackable, user-controlled AI agent available. diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000000..12a42a99dd --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,11 @@ +# Core Maintainers + +* Bradley Axen +* Douwe Osinga +* David Katz +* Mic Neale +* Alex Hancock +* Zane Staggs +* Jack Amadeo + +# Maintainers diff --git a/README.md b/README.md index 635510b33b..40d34e6604 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Designed for maximum flexibility, goose works with any LLM and supports multi-mo - [Installation](https://block.github.io/goose/docs/getting-started/installation) - [Tutorials](https://block.github.io/goose/docs/category/tutorials) - [Documentation](https://block.github.io/goose/docs/category/getting-started) - +- [Governance](https://github.com/block/goose/blob/main/GOVERNANCE.md) # a little goose humor 🦢 diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index ad5cc9835e..5c644bf6eb 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -75,7 +75,7 @@ async fn get_session_id(identifier: Identifier) -> Result { sessions .into_iter() - .find(|s| s.description == name) + .find(|s| s.id == name || s.description.contains(&name)) .map(|s| s.id) .ok_or_else(|| anyhow::anyhow!("No session found with name '{}'", name)) } else if let Some(path) = identifier.path { diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 61bb06d7ac..aadcdd5f0f 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -758,6 +758,11 @@ impl CliSession { } } } + + if let Some(id) = &self.session_id { + println!("Closing session. Session ID: {}", console::style(id).cyan()); + } + Ok(()) } diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 9027426ca1..639d7da961 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -743,6 +743,14 @@ pub fn display_session_info( ); } + if let Some(id) = session_id { + println!( + " {} {}", + style("session id:").dim(), + style(id).cyan().dim() + ); + } + println!( " {} {}", style("working directory:").dim(), diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 582caa2887..497588e5e1 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -368,6 +368,8 @@ derive_utoipa!(Icon as IconSchema); super::routes::recipe::scan_recipe, super::routes::recipe::list_recipes, super::routes::recipe::delete_recipe, + super::routes::recipe::save_recipe, + super::routes::recipe::parse_recipe, super::routes::setup::start_openrouter_setup, super::routes::setup::start_tetrate_setup, ), @@ -448,6 +450,10 @@ derive_utoipa!(Icon as IconSchema); super::routes::recipe::RecipeManifestResponse, super::routes::recipe::ListRecipeResponse, super::routes::recipe::DeleteRecipeRequest, + super::routes::recipe::SaveRecipeRequest, + super::routes::errors::ErrorResponse, + super::routes::recipe::ParseRecipeRequest, + super::routes::recipe::ParseRecipeResponse, goose::recipe::Recipe, goose::recipe::Author, goose::recipe::Settings, diff --git a/crates/goose-server/src/routes/errors.rs b/crates/goose-server/src/routes/errors.rs new file mode 100644 index 0000000000..894f13e426 --- /dev/null +++ b/crates/goose-server/src/routes/errors.rs @@ -0,0 +1,24 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::Serialize; +use utoipa::ToSchema; + +#[derive(Debug, Serialize, ToSchema)] +pub struct ErrorResponse { + pub message: String, + #[serde(skip)] + pub status: StatusCode, +} + +impl IntoResponse for ErrorResponse { + fn into_response(self) -> Response { + let body = Json(serde_json::json!({ + "message": self.message, + })); + + (self.status, body).into_response() + } +} diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs index 65764143d5..1d34dc29b6 100644 --- a/crates/goose-server/src/routes/mod.rs +++ b/crates/goose-server/src/routes/mod.rs @@ -2,6 +2,7 @@ pub mod agent; pub mod audio; pub mod config_management; pub mod context; +pub mod errors; pub mod extension; pub mod health; pub mod recipe; diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index 96268a9bdf..ce829e1ff4 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -5,12 +5,14 @@ use std::sync::Arc; use axum::routing::get; use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; use goose::conversation::{message::Message, Conversation}; +use goose::recipe::recipe_library; use goose::recipe::Recipe; use goose::recipe_deeplink; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +use crate::routes::errors::ErrorResponse; use crate::routes::recipe_utils::get_all_recipes_manifests; use crate::state::AppState; @@ -72,11 +74,25 @@ pub struct ScanRecipeResponse { has_security_warnings: bool, } +#[derive(Debug, Deserialize, ToSchema)] +pub struct SaveRecipeRequest { + recipe: Recipe, + id: Option, + is_global: Option, +} +#[derive(Debug, Deserialize, ToSchema)] +pub struct ParseRecipeRequest { + pub content: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ParseRecipeResponse { + pub recipe: Recipe, +} + #[derive(Debug, Serialize, ToSchema)] pub struct RecipeManifestResponse { name: String, - #[serde(rename = "isGlobal")] - is_global: bool, recipe: Recipe, #[serde(rename = "lastModified")] last_modified: String, @@ -235,7 +251,6 @@ async fn list_recipes( recipe_file_hash_map.insert(id.clone(), file_path); RecipeManifestResponse { name: recipe_manifest_with_path.name.clone(), - is_global: recipe_manifest_with_path.is_global, recipe: recipe_manifest_with_path.recipe.clone(), id: id.clone(), last_modified: recipe_manifest_with_path.last_modified.clone(), @@ -278,6 +293,57 @@ async fn delete_recipe( StatusCode::NO_CONTENT } +#[utoipa::path( + post, + path = "/recipes/save", + request_body = SaveRecipeRequest, + responses( + (status = 204, description = "Recipe saved to file successfully"), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 500, description = "Internal server error", body = ErrorResponse) + ), + tag = "Recipe Management" +)] +async fn save_recipe( + State(state): State>, + Json(request): Json, +) -> Result { + let file_path = match request.id { + Some(id) => state.recipe_file_hash_map.lock().await.get(&id).cloned(), + None => None, + }; + + match recipe_library::save_recipe_to_file(request.recipe, request.is_global, file_path) { + Ok(_) => Ok(StatusCode::NO_CONTENT), + Err(e) => Err(ErrorResponse { + message: e.to_string(), + status: StatusCode::INTERNAL_SERVER_ERROR, + }), + } +} + +#[utoipa::path( + post, + path = "/recipes/parse", + request_body = ParseRecipeRequest, + responses( + (status = 200, description = "Recipe parsed successfully", body = ParseRecipeResponse), + (status = 400, description = "Bad request - Invalid recipe format", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse) + ), + tag = "Recipe Management" +)] +async fn parse_recipe( + Json(request): Json, +) -> Result, ErrorResponse> { + let recipe = Recipe::from_content(&request.content).map_err(|e| ErrorResponse { + message: format!("Invalid recipe format: {}", e), + status: StatusCode::BAD_REQUEST, + })?; + + Ok(Json(ParseRecipeResponse { recipe })) +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/recipes/create", post(create_recipe)) @@ -286,6 +352,8 @@ pub fn routes(state: Arc) -> Router { .route("/recipes/scan", post(scan_recipe)) .route("/recipes/list", get(list_recipes)) .route("/recipes/delete", post(delete_recipe)) + .route("/recipes/save", post(save_recipe)) + .route("/recipes/parse", post(parse_recipe)) .with_state(state) } diff --git a/crates/goose-server/src/routes/recipe_utils.rs b/crates/goose-server/src/routes/recipe_utils.rs index 6caa7fc814..c6b7827c7c 100644 --- a/crates/goose-server/src/routes/recipe_utils.rs +++ b/crates/goose-server/src/routes/recipe_utils.rs @@ -4,10 +4,8 @@ use std::hash::{Hash, Hasher}; use std::path::PathBuf; use anyhow::Result; -use etcetera::{choose_app_strategy, AppStrategy}; -use goose::config::APP_STRATEGY; -use goose::recipe::read_recipe_file_content::read_recipe_file; +use goose::recipe::recipe_library::list_all_recipes_from_library; use goose::recipe::Recipe; use std::path::Path; @@ -18,7 +16,6 @@ use utoipa::ToSchema; pub struct RecipeManifestWithPath { pub id: String, pub name: String, - pub is_global: bool, pub recipe: Recipe, pub file_path: PathBuf, pub last_modified: String, @@ -31,59 +28,31 @@ fn short_id_from_path(path: &str) -> String { format!("{:016x}", h) } -fn load_recipes_from_path(path: &PathBuf, is_global: bool) -> Result> { - let mut recipe_manifests_with_path = Vec::new(); - if path.exists() { - for entry in fs::read_dir(path)? { - let path = entry?.path(); - if path.extension() == Some("yaml".as_ref()) { - let Ok(recipe_file) = read_recipe_file(path.clone()) else { - continue; - }; - let Ok(recipe) = Recipe::from_content(&recipe_file.content) else { - continue; - }; - let Ok(last_modified) = fs::metadata(path.clone()).map(|m| { - chrono::DateTime::::from(m.modified().unwrap()).to_rfc3339() - }) else { - continue; - }; - let recipe_metadata = - RecipeManifestMetadata::from_yaml_file(&path).unwrap_or_else(|_| { - RecipeManifestMetadata { - name: recipe.title.clone(), - is_global, - } - }); - - let manifest_with_path = RecipeManifestWithPath { - id: short_id_from_path(recipe_file.file_path.to_string_lossy().as_ref()), - name: recipe_metadata.name, - is_global: recipe_metadata.is_global, - recipe, - file_path: recipe_file.file_path, - last_modified, - }; - recipe_manifests_with_path.push(manifest_with_path); - } - } - } - Ok(recipe_manifests_with_path) -} - pub fn get_all_recipes_manifests() -> Result> { - let current_dir = std::env::current_dir()?; - let local_recipe_path = current_dir.join(".goose/recipes"); - - let global_recipe_path = choose_app_strategy(APP_STRATEGY.clone()) - .expect("goose requires a home dir") - .config_dir() - .join("recipes"); - + let recipes_with_path = list_all_recipes_from_library()?; let mut recipe_manifests_with_path = Vec::new(); + for (file_path, recipe) in recipes_with_path { + let Ok(last_modified) = fs::metadata(file_path.clone()) + .map(|m| chrono::DateTime::::from(m.modified().unwrap()).to_rfc3339()) + else { + continue; + }; + let recipe_metadata = + RecipeManifestMetadata::from_yaml_file(&file_path).unwrap_or_else(|_| { + RecipeManifestMetadata { + name: recipe.title.clone(), + } + }); - recipe_manifests_with_path.extend(load_recipes_from_path(&local_recipe_path, false)?); - recipe_manifests_with_path.extend(load_recipes_from_path(&global_recipe_path, true)?); + let manifest_with_path = RecipeManifestWithPath { + id: short_id_from_path(file_path.to_string_lossy().as_ref()), + name: recipe_metadata.name, + recipe, + file_path, + last_modified, + }; + recipe_manifests_with_path.push(manifest_with_path); + } recipe_manifests_with_path.sort_by(|a, b| b.last_modified.cmp(&a.last_modified)); Ok(recipe_manifests_with_path) @@ -93,8 +62,6 @@ pub fn get_all_recipes_manifests() -> Result> { #[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] struct RecipeManifestMetadata { pub name: String, - #[serde(rename = "isGlobal")] - pub is_global: bool, } impl RecipeManifestMetadata { @@ -129,6 +96,5 @@ recipe: recipe_content let result = RecipeManifestMetadata::from_yaml_file(&file_path).unwrap(); assert_eq!(result.name, "Test Recipe"); - assert!(result.is_global); } } diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index abef20b87b..3f2b983efb 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -10,7 +10,6 @@ use bytes::Bytes; use futures::{stream::StreamExt, Stream}; use goose::conversation::message::{Message, MessageContent}; use goose::conversation::Conversation; -use goose::execution::SessionExecutionMode; use goose::mcp_utils::ToolResult; use goose::permission::{Permission, PermissionConfirmation}; use goose::session::SessionManager; @@ -207,10 +206,7 @@ async fn reply_handler( let task_tx = tx.clone(); drop(tokio::spawn(async move { - let agent = match state - .get_agent(session_id.clone(), SessionExecutionMode::Interactive) - .await - { + let agent = match state.get_agent(session_id.clone()).await { Ok(agent) => agent, Err(e) => { tracing::error!("Failed to get session agent: {}", e); diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index cf6696a61f..b8b916bf91 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -1,6 +1,5 @@ use axum::http::StatusCode; use goose::execution::manager::AgentManager; -use goose::execution::SessionExecutionMode; use goose::scheduler_trait::SchedulerTrait; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; @@ -46,14 +45,8 @@ impl AppState { } } - pub async fn get_agent( - &self, - session_id: String, - mode: SessionExecutionMode, - ) -> anyhow::Result> { - self.agent_manager - .get_or_create_agent(session_id, mode) - .await + pub async fn get_agent(&self, session_id: String) -> anyhow::Result> { + self.agent_manager.get_or_create_agent(session_id).await } /// Get agent for route handlers - always uses Interactive mode and converts any error to 500 @@ -61,11 +54,9 @@ impl AppState { &self, session_id: String, ) -> Result, StatusCode> { - self.get_agent(session_id, SessionExecutionMode::Interactive) - .await - .map_err(|e| { - tracing::error!("Failed to get agent: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - }) + self.get_agent(session_id).await.map_err(|e| { + tracing::error!("Failed to get agent: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + }) } } diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index c876231e35..26eedad5d8 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -4,9 +4,12 @@ use chrono::{DateTime, Utc}; use futures::stream::{FuturesUnordered, StreamExt}; use futures::{future, FutureExt}; use rmcp::service::ClientInitializeError; -use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; +use rmcp::transport::streamable_http_client::{ + AuthRequiredError, StreamableHttpClientTransportConfig, StreamableHttpError, +}; use rmcp::transport::{ - ConfigureCommandExt, SseClientTransport, StreamableHttpClientTransport, TokioChildProcess, + ConfigureCommandExt, DynamicTransportError, SseClientTransport, StreamableHttpClientTransport, + TokioChildProcess, }; use std::collections::HashMap; use std::process::Stdio; @@ -205,6 +208,28 @@ async fn child_process_client( } } +fn extract_auth_error( + res: &Result, +) -> Option<&AuthRequiredError> { + match res { + Ok(_) => None, + Err(err) => match err { + ClientInitializeError::TransportError { + error: DynamicTransportError { error, .. }, + .. + } => error + .downcast_ref::>() + .and_then(|auth_error| match auth_error { + StreamableHttpError::AuthRequired(auth_required_error) => { + Some(auth_required_error) + } + _ => None, + }), + _ => None, + }, + } +} + impl ExtensionManager { pub fn new() -> Self { Self { @@ -340,15 +365,10 @@ impl ExtensionManager { ), ) .await; - let client = if let Err(e) = client_res { - // make an attempt at oauth, but failing that, return the original error, - // because this might not have been an auth error at all. - // TODO: when rmcp supports it, we should trigger this flow on 401s with - // WWW-Authenticate headers, not just any init error - let am = match oauth_flow(uri, name).await { - Ok(am) => am, - Err(_) => return Err(e.into()), - }; + let client = if let Some(_auth_error) = extract_auth_error(&client_res) { + let am = oauth_flow(uri, name) + .await + .map_err(|_| ExtensionError::SetupError("auth error".to_string()))?; let client = AuthClient::new(reqwest::Client::default(), am); let transport = StreamableHttpClientTransport::with_client( client, diff --git a/crates/goose/src/context_mgmt/auto_compact.rs b/crates/goose/src/context_mgmt/auto_compact.rs index 01941af614..8977a3fd58 100644 --- a/crates/goose/src/context_mgmt/auto_compact.rs +++ b/crates/goose/src/context_mgmt/auto_compact.rs @@ -317,8 +317,8 @@ mod tests { id: "test_session".to_string(), working_dir: PathBuf::from(working_dir), description: "Test session".to_string(), - created_at: "2024-01-01T00:00:00Z".to_string(), - updated_at: "2024-01-01T00:00:00Z".to_string(), + created_at: Default::default(), + updated_at: Default::default(), schedule_id: Some("test_job".to_string()), recipe: None, total_tokens: Some(100), diff --git a/crates/goose/src/conversation/message.rs b/crates/goose/src/conversation/message.rs index ac3e0c9626..b3c6ab6b7b 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose/src/conversation/message.rs @@ -379,7 +379,7 @@ impl From for Message { } } -#[derive(ToSchema, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[derive(ToSchema, Clone, Copy, PartialEq, Serialize, Deserialize, Debug)] /// Metadata for message visibility #[serde(rename_all = "camelCase")] pub struct MessageMetadata { @@ -462,7 +462,7 @@ fn default_true() -> bool { true } -#[derive(ToSchema, Clone, PartialEq, Serialize, Deserialize)] +#[derive(ToSchema, Clone, PartialEq, Serialize, Deserialize, Debug)] /// A message to or from an LLM #[serde(rename_all = "camelCase")] pub struct Message { @@ -476,19 +476,6 @@ pub struct Message { pub metadata: MessageMetadata, } -impl fmt::Debug for Message { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let joined_content: String = self - .content - .iter() - .map(|c| format!("{c}")) - .collect::>() - .join(" "); - - write!(f, "{:?}: {}", self.role, joined_content) - } -} - fn default_created() -> i64 { 0 // old messages do not have timestamps. } diff --git a/crates/goose/src/conversation/mod.rs b/crates/goose/src/conversation/mod.rs index ba956fd7ff..a77442b075 100644 --- a/crates/goose/src/conversation/mod.rs +++ b/crates/goose/src/conversation/mod.rs @@ -168,20 +168,61 @@ pub fn fix_conversation(conversation: Conversation) -> (Conversation, Vec) -> (Vec, Vec) { - let (messages_1, empty_removed) = remove_empty_messages(messages); - let (messages_2, tool_calling_fixed) = fix_tool_calling(messages_1); - let (messages_3, messages_merged) = merge_consecutive_messages(messages_2); - let (messages_4, lead_trail_fixed) = fix_lead_trail(messages_3); - let (messages_5, populated_if_empty) = populate_if_empty(messages_4); + [ + merge_text_content_items, + remove_empty_messages, + fix_tool_calling, + merge_consecutive_messages, + fix_lead_trail, + populate_if_empty, + ] + .into_iter() + .fold( + (messages, Vec::new()), + |(msgs, mut all_issues), processor| { + let (new_msgs, issues) = processor(msgs); + all_issues.extend(issues); + (new_msgs, all_issues) + }, + ) +} - let mut issues = Vec::new(); - issues.extend(empty_removed); - issues.extend(tool_calling_fixed); - issues.extend(messages_merged); - issues.extend(lead_trail_fixed); - issues.extend(populated_if_empty); +fn merge_text_content_in_message(mut msg: Message) -> Message { + if msg.role != Role::Assistant { + return msg; + } + msg.content = msg + .content + .into_iter() + .fold(Vec::new(), |mut content, item| { + match item { + MessageContent::Text(text) => { + if let Some(MessageContent::Text(ref mut last)) = content.last_mut() { + last.text.push_str(&text.text); + } else { + content.push(MessageContent::Text(text)); + } + } + other => content.push(other), + } + content + }); + msg +} - (messages_5, issues) +fn merge_text_content_items(messages: Vec) -> (Vec, Vec) { + messages.into_iter().fold( + (Vec::new(), Vec::new()), + |(mut messages, mut issues), message| { + let content_len = message.content.len(); + let message = merge_text_content_in_message(message); + if content_len != message.content.len() { + issues.push(String::from("Merged text content")) + } + messages.push(message); + (messages, issues) + }, + ) } fn remove_empty_messages(messages: Vec) -> (Vec, Vec) { @@ -189,7 +230,11 @@ fn remove_empty_messages(messages: Vec) -> (Vec, Vec) let filtered_messages = messages .into_iter() .filter(|msg| { - if msg.content.is_empty() { + if msg + .content + .iter() + .all(|c| c.as_text().is_some_and(str::is_empty)) + { issues.push("Removed empty message".to_string()); false } else { @@ -402,6 +447,24 @@ mod tests { use rmcp::model::{CallToolRequestParam, Role}; use rmcp::object; + macro_rules! assert_has_issues_unordered { + ($fixed:expr, $issues:expr, $($expected:expr),+ $(,)?) => { + { + let mut expected: Vec<&str> = vec![$($expected),+]; + let mut actual: Vec<&str> = $issues.iter().map(|s| s.as_str()).collect(); + expected.sort(); + actual.sort(); + + if actual != expected { + panic!( + "assertion failed: issues don't match\nexpected: {:?}\n actual: {:?}. Fixed conversation is:\n{:#?}", + expected, $issues, $fixed, + ); + } + } + }; + } + fn run_verify(messages: Vec) -> (Vec, Vec) { let (fixed, issues) = fix_conversation(Conversation::new_unvalidated(messages.clone())); @@ -486,17 +549,15 @@ mod tests { let (fixed, issues) = run_verify(messages); assert_eq!(fixed.len(), 3); - assert_eq!(issues.len(), 4); - assert!(issues - .iter() - .any(|i| i.contains("Merged consecutive user messages"))); - assert!(issues - .iter() - .any(|i| i.contains("Removed tool response 'orphan_1' from assistant message"))); - assert!(issues - .iter() - .any(|i| i.contains("Removed tool request 'bad_req' from user message"))); + assert_has_issues_unordered!( + fixed, + issues, + "Merged consecutive assistant messages", + "Merged consecutive user messages", + "Removed tool response 'orphan_1' from assistant message", + "Removed tool request 'bad_req' from user message", + ); assert_eq!(fixed[0].role, Role::User); assert_eq!(fixed[1].role, Role::Assistant); @@ -536,10 +597,18 @@ mod tests { assert_eq!(fixed.len(), 1); - assert!(issues.iter().any(|i| i.contains("Removed empty message"))); - assert!(issues - .iter() - .any(|i| i.contains("Removed orphaned tool response 'wrong_id'"))); + assert_has_issues_unordered!( + fixed, + issues, + "Removed empty message", + "Removed orphaned tool response 'wrong_id'", + "Removed orphaned tool request 'search_1'", + "Removed orphaned tool request 'search_2'", + "Removed empty message", + "Removed empty message", + "Removed leading assistant message", + "Added placeholder user message to empty conversation", + ); assert_eq!(fixed[0].role, Role::User); assert_eq!(fixed[0].as_concat_text(), "Hello"); @@ -569,9 +638,12 @@ mod tests { let (fixed, issues) = fix_conversation(conversation); assert_eq!(fixed.len(), 5); - assert_eq!(issues.len(), 2); - assert!(issues[0].contains("Removed orphaned tool request")); - assert!(issues[1].contains("Merged consecutive assistant messages")); + assert_has_issues_unordered!( + fixed, + issues, + "Removed orphaned tool request 'toolu_bdrk_018adWbP4X26CfoJU5hkhu3i'", + "Merged consecutive assistant messages" + ) } #[test] @@ -592,6 +664,92 @@ mod tests { ]; let (_fixed, issues) = run_verify(messages); - assert_eq!(issues.len(), 0); + assert!(issues.is_empty()); + } + + #[test] + fn test_merge_text_content_items() { + use crate::conversation::message::MessageContent; + use rmcp::model::{AnnotateAble, RawTextContent}; + + let mut message = Message::assistant().with_text("Hello"); + + message.content.push(MessageContent::Text( + RawTextContent { + text: " world".to_string(), + meta: None, + } + .no_annotation(), + )); + message.content.push(MessageContent::Text( + RawTextContent { + text: "!".to_string(), + meta: None, + } + .no_annotation(), + )); + + let messages = vec![ + Message::user().with_text("hello"), + message, + Message::user().with_text("thanks"), + ]; + + let (fixed, issues) = run_verify(messages); + + assert_eq!(fixed.len(), 3); + assert_has_issues_unordered!(fixed, issues, "Merged text content"); + + let fixed_msg = &fixed[1]; + assert_eq!(fixed_msg.content.len(), 1); + + if let MessageContent::Text(text_content) = &fixed_msg.content[0] { + assert_eq!(text_content.text, "Hello world!"); + } else { + panic!("Expected text content"); + } + } + + #[test] + fn test_merge_text_content_items_with_mixed_content() { + use crate::conversation::message::MessageContent; + use rmcp::model::{AnnotateAble, RawTextContent}; + + let mut image_message = Message::assistant().with_text("Look at"); + + image_message.content.push(MessageContent::Text( + RawTextContent { + text: " this image:".to_string(), + meta: None, + } + .no_annotation(), + )); + + image_message = image_message.with_image("", ""); + + let messages = vec![ + Message::user().with_text("hello"), + image_message, + Message::user().with_text("thanks"), + ]; + + let (fixed, issues) = run_verify(messages); + + assert_eq!(fixed.len(), 3); + assert_has_issues_unordered!(fixed, issues, "Merged text content"); + let fixed_msg = &fixed[1]; + + assert_eq!(fixed_msg.content.len(), 2); + if let MessageContent::Text(text_content) = &fixed_msg.content[0] { + assert_eq!(text_content.text, "Look at this image:"); + } else { + panic!("Expected first item to be text content"); + } + + if let MessageContent::Image(_) = &fixed_msg.content[1] { + // Good + } else { + panic!("Expected second item to be an image"); + } } } diff --git a/crates/goose/src/execution/manager.rs b/crates/goose/src/execution/manager.rs index fc3257a7bb..25eb8bdc4a 100644 --- a/crates/goose/src/execution/manager.rs +++ b/crates/goose/src/execution/manager.rs @@ -1,6 +1,3 @@ -//! Agent lifecycle management with session isolation - -use super::SessionExecutionMode; use crate::agents::Agent; use crate::config::APP_STRATEGY; use crate::model::ModelConfig; @@ -112,49 +109,27 @@ impl AgentManager { Ok(()) } - pub async fn get_or_create_agent( - &self, - session_id: String, - mode: SessionExecutionMode, - ) -> Result> { - let agent = { + pub async fn get_or_create_agent(&self, session_id: String) -> Result> { + { let mut sessions = self.sessions.write().await; - if let Some(agent) = sessions.get(&session_id) { - debug!("Found existing agent for session {}", session_id); - return Ok(Arc::clone(agent)); - } - - info!( - "Creating new agent for session {} with mode {}", - session_id, mode - ); - let agent = Arc::new(Agent::new()); - sessions.put(session_id.clone(), Arc::clone(&agent)); - agent - }; - - match &mode { - SessionExecutionMode::Interactive | SessionExecutionMode::Background => { - debug!("Setting scheduler on agent for session {}", session_id); - agent.set_scheduler(Arc::clone(&self.scheduler)).await; - } - SessionExecutionMode::SubTask { .. } => { - debug!( - "SubTask mode for session {}, skipping scheduler setup", - session_id - ); + if let Some(existing) = sessions.get(&session_id) { + return Ok(Arc::clone(existing)); } } + let agent = Arc::new(Agent::new()); + agent.set_scheduler(Arc::clone(&self.scheduler)).await; if let Some(provider) = &*self.default_provider.read().await { - debug!( - "Setting default provider on agent for session {}", - session_id - ); - let _ = agent.update_provider(Arc::clone(provider)).await; + agent.update_provider(Arc::clone(provider)).await?; } - Ok(agent) + let mut sessions = self.sessions.write().await; + if let Some(existing) = sessions.get(&session_id) { + Ok(Arc::clone(existing)) + } else { + sessions.put(session_id, agent.clone()); + Ok(agent) + } } pub async fn remove_session(&self, session_id: &str) -> Result<()> { diff --git a/crates/goose/src/providers/githubcopilot.rs b/crates/goose/src/providers/githubcopilot.rs index e57eec08e0..a533a5407a 100644 --- a/crates/goose/src/providers/githubcopilot.rs +++ b/crates/goose/src/providers/githubcopilot.rs @@ -30,11 +30,11 @@ pub const GITHUB_COPILOT_KNOWN_MODELS: &[&str] = &[ "o1", "o3-mini", "claude-3.7-sonnet", - "claude-sonnet-4-20250514", + "claude-sonnet-4", ]; pub const GITHUB_COPILOT_STREAM_MODELS: &[&str] = - &["gpt-4.1", "claude-3.7-sonnet", "claude-sonnet-4-20250514"]; + &["gpt-4.1", "claude-3.7-sonnet", "claude-sonnet-4"]; const GITHUB_COPILOT_DOC_URL: &str = "https://docs.github.com/en/copilot/using-github-copilot/ai-models"; diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index f6367fbd1d..6626e3d06a 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -12,6 +12,7 @@ use utoipa::ToSchema; pub mod build_recipe; pub mod read_recipe_file_content; +pub mod recipe_library; pub mod template_recipe; pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir"; diff --git a/crates/goose/src/recipe/recipe_library.rs b/crates/goose/src/recipe/recipe_library.rs new file mode 100644 index 0000000000..8a615957b0 --- /dev/null +++ b/crates/goose/src/recipe/recipe_library.rs @@ -0,0 +1,104 @@ +use crate::config::APP_STRATEGY; +use crate::recipe::read_recipe_file_content::read_recipe_file; +use crate::recipe::Recipe; +use anyhow::Result; +use etcetera::{choose_app_strategy, AppStrategy}; +use serde_yaml; +use std::fs; +use std::path::PathBuf; + +pub fn get_recipe_library_dir(is_global: bool) -> PathBuf { + if is_global { + choose_app_strategy(APP_STRATEGY.clone()) + .expect("goose requires a home dir") + .config_dir() + .join("recipes") + } else { + std::env::current_dir().unwrap().join(".goose/recipes") + } +} + +pub fn list_recipes_from_library(is_global: bool) -> Result> { + let path = get_recipe_library_dir(is_global); + let mut recipes_with_path = Vec::new(); + if path.exists() { + for entry in fs::read_dir(path)? { + let path = entry?.path(); + let extension = path.extension(); + + if extension == Some("yaml".as_ref()) || extension == Some("json".as_ref()) { + let Ok(recipe_file) = read_recipe_file(path.clone()) else { + continue; + }; + let Ok(recipe) = Recipe::from_content(&recipe_file.content) else { + continue; + }; + recipes_with_path.push((path, recipe)); + } + } + } + Ok(recipes_with_path) +} + +pub fn list_all_recipes_from_library() -> Result> { + let mut recipes_with_path = Vec::new(); + recipes_with_path.extend(list_recipes_from_library(true)?); + recipes_with_path.extend(list_recipes_from_library(false)?); + Ok(recipes_with_path) +} + +fn generate_recipe_filename(title: &str) -> String { + let base_name = title + .to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric() || c.is_whitespace() || *c == '-') + .collect::() + .split_whitespace() + .collect::>() + .join("-"); + + let filename = if base_name.is_empty() { + "untitled-recipe".to_string() + } else { + base_name + }; + format!("{}.yaml", filename) +} + +pub fn save_recipe_to_file( + recipe: Recipe, + is_global: Option, + file_path: Option, +) -> anyhow::Result { + let is_global_value = is_global.unwrap_or(true); + + let default_file_path = + get_recipe_library_dir(is_global_value).join(generate_recipe_filename(&recipe.title)); + + let file_path_value = match file_path { + Some(path) => path, + None => { + if default_file_path.exists() { + return Err(anyhow::anyhow!( + "Recipe file already exists at: {:?}", + default_file_path + )); + } + default_file_path + } + }; + let all_recipes = list_all_recipes_from_library()?; + + for (existing_path, existing_recipe) in &all_recipes { + if existing_recipe.title == recipe.title && existing_path != &file_path_value { + return Err(anyhow::anyhow!( + "Recipe with title '{}' already exists", + recipe.title + )); + } + } + + let yaml_content = serde_yaml::to_string(&recipe)?; + fs::write(&file_path_value, yaml_content)?; + Ok(file_path_value) +} diff --git a/crates/goose/src/session/legacy.rs b/crates/goose/src/session/legacy.rs index 3de432ce82..ad05356fcb 100644 --- a/crates/goose/src/session/legacy.rs +++ b/crates/goose/src/session/legacy.rs @@ -1,7 +1,7 @@ use crate::conversation::Conversation; use crate::session::Session; use anyhow::Result; -use chrono::NaiveDateTime; +use chrono::{DateTime, Local, NaiveDateTime, TimeZone, Utc}; use std::fs; use std::io::{self, BufRead}; use std::path::{Path, PathBuf}; @@ -65,9 +65,9 @@ pub fn load_session(session_name: &str, session_path: &Path) -> Result if let Some(obj) = metadata_json.as_object_mut() { obj.entry("id").or_insert(serde_json::json!(session_name)); obj.entry("created_at") - .or_insert(serde_json::json!(format_timestamp(created_time)?)); + .or_insert(serde_json::json!(DateTime::::from(created_time))); obj.entry("updated_at") - .or_insert(serde_json::json!(format_timestamp(modified_time)?)); + .or_insert(serde_json::json!(DateTime::::from(modified_time))); obj.entry("extension_data").or_insert(serde_json::json!({})); obj.entry("message_count").or_insert(serde_json::json!(0)); @@ -97,17 +97,9 @@ pub fn load_session(session_name: &str, session_path: &Path) -> Result Ok(session) } -fn format_timestamp(time: SystemTime) -> Result { - let duration = time.duration_since(std::time::UNIX_EPOCH)?; - let timestamp = chrono::DateTime::from_timestamp(duration.as_secs() as i64, 0) - .unwrap_or_default() - .format("%Y-%m-%d %H:%M:%S") - .to_string(); - Ok(timestamp) -} - fn parse_session_timestamp(session_name: &str) -> Option { NaiveDateTime::parse_from_str(session_name, "%Y%m%d_%H%M%S") .ok() - .map(|dt| SystemTime::from(dt.and_utc())) + .and_then(|dt| Local.from_local_datetime(&dt).single()) + .map(SystemTime::from) } diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 9a31c4ea43..beee202bb6 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -5,6 +5,7 @@ use crate::providers::base::{Provider, MSG_COUNT_FOR_SESSION_NAME_GENERATION}; use crate::recipe::Recipe; use crate::session::extension_data::ExtensionData; use anyhow::Result; +use chrono::{DateTime, Utc}; use etcetera::{choose_app_strategy, AppStrategy}; use rmcp::model::Role; use serde::{Deserialize, Serialize}; @@ -27,8 +28,8 @@ pub struct Session { #[schema(value_type = String)] pub working_dir: PathBuf, pub description: String, - pub created_at: String, - pub updated_at: String, + pub created_at: DateTime, + pub updated_at: DateTime, pub extension_data: ExtensionData, pub total_tokens: Option, pub input_tokens: Option, @@ -279,8 +280,8 @@ impl Default for Session { id: String::new(), working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), description: String::new(), - created_at: String::new(), - updated_at: String::new(), + created_at: Default::default(), + updated_at: Default::default(), extension_data: ExtensionData::default(), total_tokens: None, input_tokens: None, @@ -510,8 +511,8 @@ impl SessionStorage { .bind(&session.id) .bind(&session.description) .bind(session.working_dir.to_string_lossy().as_ref()) - .bind(&session.created_at) - .bind(&session.updated_at) + .bind(session.created_at) + .bind(session.updated_at) .bind(serde_json::to_string(&session.extension_data)?) .bind(session.total_tokens) .bind(session.input_tokens) diff --git a/crates/goose/src/temporal_scheduler.rs b/crates/goose/src/temporal_scheduler.rs index 9554434532..d10061efc1 100644 --- a/crates/goose/src/temporal_scheduler.rs +++ b/crates/goose/src/temporal_scheduler.rs @@ -851,25 +851,18 @@ impl TemporalScheduler { if let Some(session_info) = all_sessions.iter().find(|s| s.id == session_id) { - // Parse the updated_at timestamp from the database - if let Ok(modified_dt) = DateTime::parse_from_str( - &session_info.updated_at, - "%Y-%m-%d %H:%M:%S UTC", - ) { - let modified_utc = modified_dt.with_timezone(&Utc); - let now = Utc::now(); - let time_diff = now.signed_duration_since(modified_utc); + let now = Utc::now(); + let time_diff = now.signed_duration_since(session_info.updated_at); - // Increased tolerance to 5 minutes to reduce false positives - if time_diff.num_minutes() < 5 { - has_active_session = true; - tracing::debug!( + // Increased tolerance to 5 minutes to reduce false positives + if time_diff.num_minutes() < 5 { + has_active_session = true; + tracing::debug!( "Found active session for job '{}' modified {} minutes ago", job.id, time_diff.num_minutes() ); - break; - } + break; } } } diff --git a/crates/goose/src/token_counter.rs b/crates/goose/src/token_counter.rs index fa1d243e23..f61396a416 100644 --- a/crates/goose/src/token_counter.rs +++ b/crates/goose/src/token_counter.rs @@ -8,25 +8,28 @@ use tokio::sync::OnceCell; use crate::conversation::message::Message; -// Global tokenizer instance to avoid repeated initialization static TOKENIZER: OnceCell> = OnceCell::const_new(); -// Cache size limits to prevent unbounded growth const MAX_TOKEN_CACHE_SIZE: usize = 10_000; -/// Async token counter with caching capabilities +// token use for various bits of a tool calls: +const FUNC_INIT: usize = 7; +const PROP_INIT: usize = 3; +const PROP_KEY: usize = 3; +const ENUM_INIT: isize = -3; +const ENUM_ITEM: usize = 3; +const FUNC_END: usize = 12; + pub struct AsyncTokenCounter { tokenizer: Arc, - token_cache: Arc>, // content hash -> token count + token_cache: Arc>, } -/// Legacy synchronous token counter for backward compatibility pub struct TokenCounter { tokenizer: Arc, } impl AsyncTokenCounter { - /// Creates a new async token counter with caching pub async fn new() -> Result { let tokenizer = get_tokenizer().await?; Ok(Self { @@ -35,25 +38,19 @@ impl AsyncTokenCounter { }) } - /// Count tokens with optimized caching pub fn count_tokens(&self, text: &str) -> usize { - // Use faster AHash for better performance let mut hasher = AHasher::default(); text.hash(&mut hasher); let hash = hasher.finish(); - // Check cache first if let Some(count) = self.token_cache.get(&hash) { return *count; } - // Compute and cache result with size management let tokens = self.tokenizer.encode_with_special_tokens(text); let count = tokens.len(); - // Manage cache size to prevent unbounded growth if self.token_cache.len() >= MAX_TOKEN_CACHE_SIZE { - // Simple eviction: remove a random entry if let Some(entry) = self.token_cache.iter().next() { let old_hash = *entry.key(); self.token_cache.remove(&old_hash); @@ -64,20 +61,11 @@ impl AsyncTokenCounter { count } - /// Count tokens for tools with optimized string handling pub fn count_tokens_for_tools(&self, tools: &[Tool]) -> usize { - // Token counts for different function components - let func_init = 7; // Tokens for function initialization - let prop_init = 3; // Tokens for properties initialization - let prop_key = 3; // Tokens for each property key - let enum_init: isize = -3; // Tokens adjustment for enum list start - let enum_item = 3; // Tokens for each enum item - let func_end = 12; // Tokens for function ending - let mut func_token_count = 0; if !tools.is_empty() { for tool in tools { - func_token_count += func_init; + func_token_count += FUNC_INIT; let name = &tool.name; let description = &tool .description @@ -86,7 +74,6 @@ impl AsyncTokenCounter { .unwrap_or_default() .trim_end_matches('.'); - // Note: the separator (:) is likely tokenized with adjacent tokens, so we use original approach for accuracy let line = format!("{}:{}", name, description); func_token_count += self.count_tokens(&line); @@ -94,26 +81,27 @@ impl AsyncTokenCounter { tool.input_schema.get("properties") { if !properties.is_empty() { - func_token_count += prop_init; + func_token_count += PROP_INIT; for (key, value) in properties { - func_token_count += prop_key; + func_token_count += PROP_KEY; let p_name = key; - let p_type = value["type"].as_str().unwrap_or(""); - let p_desc = value["description"] - .as_str() + let p_type = value.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let p_desc = value + .get("description") + .and_then(|v| v.as_str()) .unwrap_or("") .trim_end_matches('.'); - // Note: separators are tokenized with adjacent tokens, keep original for accuracy let line = format!("{}:{}:{}", p_name, p_type, p_desc); func_token_count += self.count_tokens(&line); - if let Some(enum_values) = value["enum"].as_array() { + if let Some(enum_values) = value.get("enum").and_then(|v| v.as_array()) + { func_token_count = - func_token_count.saturating_add_signed(enum_init); + func_token_count.saturating_add_signed(ENUM_INIT); for item in enum_values { if let Some(item_str) = item.as_str() { - func_token_count += enum_item; + func_token_count += ENUM_ITEM; func_token_count += self.count_tokens(item_str); } } @@ -122,13 +110,12 @@ impl AsyncTokenCounter { } } } - func_token_count += func_end; + func_token_count += FUNC_END; } func_token_count } - /// Count chat tokens (using cached count_tokens) pub fn count_chat_tokens( &self, system_prompt: &str, @@ -148,13 +135,13 @@ impl AsyncTokenCounter { if let Some(content_text) = content.as_text() { num_tokens += self.count_tokens(content_text); } else if let Some(tool_request) = content.as_tool_request() { - let tool_call = tool_request.tool_call.as_ref().unwrap(); - // Note: separators are tokenized with adjacent tokens, keep original for accuracy - let text = format!( - "{}:{}:{:?}", - tool_request.id, tool_call.name, tool_call.arguments - ); - num_tokens += self.count_tokens(&text); + if let Ok(tool_call) = tool_request.tool_call.as_ref() { + let text = format!( + "{}:{}:{:?}", + tool_request.id, tool_call.name, tool_call.arguments + ); + num_tokens += self.count_tokens(&text); + } } else if let Some(tool_response_text) = content.as_tool_response_text() { num_tokens += self.count_tokens(&tool_response_text); } @@ -170,7 +157,6 @@ impl AsyncTokenCounter { num_tokens } - /// Count everything including resources (using cached count_tokens) pub fn count_everything( &self, system_prompt: &str, @@ -188,7 +174,6 @@ impl AsyncTokenCounter { num_tokens } - /// Cache management methods pub fn clear_cache(&self) { self.token_cache.clear(); } @@ -205,32 +190,21 @@ impl Default for TokenCounter { } impl TokenCounter { - /// Creates a new `TokenCounter` using the fixed o200k_base encoding. pub fn new() -> Self { - // Use blocking version of get_tokenizer let tokenizer = get_tokenizer_blocking().expect("Failed to initialize tokenizer"); Self { tokenizer } } - /// Count tokens for a piece of text using our single tokenizer. pub fn count_tokens(&self, text: &str) -> usize { let tokens = self.tokenizer.encode_with_special_tokens(text); tokens.len() } pub fn count_tokens_for_tools(&self, tools: &[Tool]) -> usize { - // Token counts for different function components - let func_init = 7; // Tokens for function initialization - let prop_init = 3; // Tokens for properties initialization - let prop_key = 3; // Tokens for each property key - let enum_init: isize = -3; // Tokens adjustment for enum list start - let enum_item = 3; // Tokens for each enum item - let func_end = 12; // Tokens for function ending - let mut func_token_count = 0; if !tools.is_empty() { for tool in tools { - func_token_count += func_init; // Add tokens for start of each function + func_token_count += FUNC_INIT; let name = &tool.name; let description = &tool .description @@ -239,27 +213,31 @@ impl TokenCounter { .unwrap_or_default() .trim_end_matches('.'); let line = format!("{}:{}", name, description); - func_token_count += self.count_tokens(&line); // Add tokens for name and description + func_token_count += self.count_tokens(&line); - if let serde_json::Value::Object(properties) = &tool.input_schema["properties"] { + if let Some(serde_json::Value::Object(properties)) = + tool.input_schema.get("properties") + { if !properties.is_empty() { - func_token_count += prop_init; // Add tokens for start of properties + func_token_count += PROP_INIT; for (key, value) in properties { - func_token_count += prop_key; // Add tokens for each property + func_token_count += PROP_KEY; let p_name = key; - let p_type = value["type"].as_str().unwrap_or(""); - let p_desc = value["description"] - .as_str() + let p_type = value.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let p_desc = value + .get("description") + .and_then(|v| v.as_str()) .unwrap_or("") .trim_end_matches('.'); let line = format!("{}:{}:{}", p_name, p_type, p_desc); func_token_count += self.count_tokens(&line); - if let Some(enum_values) = value["enum"].as_array() { + if let Some(enum_values) = value.get("enum").and_then(|v| v.as_array()) + { func_token_count = - func_token_count.saturating_add_signed(enum_init); // Add tokens if property has enum list + func_token_count.saturating_add_signed(ENUM_INIT); for item in enum_values { if let Some(item_str) = item.as_str() { - func_token_count += enum_item; + func_token_count += ENUM_ITEM; func_token_count += self.count_tokens(item_str); } } @@ -268,7 +246,7 @@ impl TokenCounter { } } } - func_token_count += func_end; + func_token_count += FUNC_END; } func_token_count @@ -280,10 +258,8 @@ impl TokenCounter { messages: &[Message], tools: &[Tool], ) -> usize { - // <|im_start|>ROLE<|im_sep|>MESSAGE<|im_end|> let tokens_per_message = 4; - // Count tokens in the system prompt let mut num_tokens = 0; if !system_prompt.is_empty() { num_tokens += self.count_tokens(system_prompt) + tokens_per_message; @@ -291,33 +267,29 @@ impl TokenCounter { for message in messages { num_tokens += tokens_per_message; - // Count tokens in the content for content in &message.content { - // content can either be text response or tool request if let Some(content_text) = content.as_text() { num_tokens += self.count_tokens(content_text); } else if let Some(tool_request) = content.as_tool_request() { - let tool_call = tool_request.tool_call.as_ref().unwrap(); - let text = format!( - "{}:{}:{:?}", - tool_request.id, tool_call.name, tool_call.arguments - ); - num_tokens += self.count_tokens(&text); + if let Ok(tool_call) = tool_request.tool_call.as_ref() { + let text = format!( + "{}:{}:{:?}", + tool_request.id, tool_call.name, tool_call.arguments + ); + num_tokens += self.count_tokens(&text); + } } else if let Some(tool_response_text) = content.as_tool_response_text() { num_tokens += self.count_tokens(&tool_response_text); } else { - // unsupported content type such as image - pass continue; } } } - // Count tokens for tools if provided if !tools.is_empty() { num_tokens += self.count_tokens_for_tools(tools); } - // Every reply is primed with <|start|>assistant<|message|> num_tokens += 3; num_tokens @@ -341,8 +313,6 @@ impl TokenCounter { } } -/// Get the global tokenizer instance (async version) -/// Fixed encoding for all tokenization - using o200k_base for GPT-4o and o1 models async fn get_tokenizer() -> Result, String> { let tokenizer = TOKENIZER .get_or_init(|| async { @@ -355,18 +325,14 @@ async fn get_tokenizer() -> Result, String> { Ok(tokenizer.clone()) } -/// Get the global tokenizer instance (blocking version for backward compatibility) fn get_tokenizer_blocking() -> Result, String> { - // For the blocking version, we need to handle the case where the tokenizer hasn't been initialized yet if let Some(tokenizer) = TOKENIZER.get() { return Ok(tokenizer.clone()); } - // Initialize the tokenizer synchronously match tiktoken_rs::o200k_base() { Ok(bpe) => { let tokenizer = Arc::new(bpe); - // Try to set it in the OnceCell, but it's okay if another thread beat us to it let _ = TOKENIZER.set(tokenizer.clone()); Ok(tokenizer) } @@ -374,7 +340,6 @@ fn get_tokenizer_blocking() -> Result, String> { } } -/// Factory function for creating async token counters with proper error handling pub async fn create_async_token_counter() -> Result { AsyncTokenCounter::new().await } @@ -386,30 +351,6 @@ mod tests { use rmcp::model::{Role, Tool}; use rmcp::object; - #[test] - fn test_token_counter_basic() { - let counter = TokenCounter::new(); - - let text = "Hello, how are you?"; - let count = counter.count_tokens(text); - println!("Token count for '{}': {:?}", text, count); - - // With o200k_base encoding, this should give us a reasonable count - assert!(count > 0, "Token count should be greater than 0"); - } - - #[test] - fn test_token_counter_simple_text() { - let counter = TokenCounter::new(); - - let text = "Hey there!"; - let count = counter.count_tokens(text); - println!("Token count for '{}': {:?}", text, count); - - // With o200k_base encoding, this should give us a reasonable count - assert!(count > 0, "Token count should be greater than 0"); - } - #[test] fn test_count_chat_tokens() { let counter = TokenCounter::new(); @@ -464,7 +405,6 @@ mod tests { let token_count_with_tools = counter.count_chat_tokens(system_prompt, &messages, &tools); println!("Total tokens with tools: {}", token_count_with_tools); - // Basic sanity checks - with o200k_base the exact counts may differ from the old tokenizer assert!( token_count_without_tools > 0, "Should have some tokens without tools" @@ -475,122 +415,37 @@ mod tests { ); } - #[tokio::test] - async fn test_async_token_counter() { - let counter = create_async_token_counter().await.unwrap(); - - let text = "Hello, how are you?"; - let count = counter.count_tokens(text); - println!("Async token count for '{}': {:?}", text, count); - - assert!(count > 0, "Async token count should be greater than 0"); - } - #[tokio::test] async fn test_async_token_caching() { let counter = create_async_token_counter().await.unwrap(); let text = "This is a test for caching functionality"; - // First call should compute and cache let count1 = counter.count_tokens(text); assert_eq!(counter.cache_size(), 1); - // Second call should use cache let count2 = counter.count_tokens(text); assert_eq!(count1, count2); assert_eq!(counter.cache_size(), 1); - // Different text should increase cache let count3 = counter.count_tokens("Different text"); assert_eq!(counter.cache_size(), 2); assert_ne!(count1, count3); } - #[tokio::test] - async fn test_async_count_chat_tokens() { - let counter = create_async_token_counter().await.unwrap(); - - let system_prompt = - "You are a helpful assistant that can answer questions about the weather."; - - let messages = vec![ - Message::new( - Role::User, - 0, - vec![MessageContent::text( - "What's the weather like in San Francisco?", - )], - ), - Message::new( - Role::Assistant, - 1, - vec![MessageContent::text( - "Looks like it's 60 degrees Fahrenheit in San Francisco.", - )], - ), - Message::new( - Role::User, - 2, - vec![MessageContent::text("How about New York?")], - ), - ]; - - let tools = vec![Tool::new( - "get_current_weather", - "Get the current weather in a given location", - object!({ - "properties": { - "location": { - "type": "string", - "description": "The city and state, e.g. San Francisco, CA" - }, - "unit": { - "type": "string", - "description": "The unit of temperature to return", - "enum": ["celsius", "fahrenheit"] - } - }, - "required": ["location"] - }), - )]; - - let token_count_without_tools = counter.count_chat_tokens(system_prompt, &messages, &[]); - println!( - "Async total tokens without tools: {}", - token_count_without_tools - ); - - let token_count_with_tools = counter.count_chat_tokens(system_prompt, &messages, &tools); - println!("Async total tokens with tools: {}", token_count_with_tools); - - // Basic sanity checks - assert!( - token_count_without_tools > 0, - "Should have some tokens without tools" - ); - assert!( - token_count_with_tools > token_count_without_tools, - "Should have more tokens with tools" - ); - } - #[tokio::test] async fn test_async_cache_management() { let counter = create_async_token_counter().await.unwrap(); - // Add some items to cache counter.count_tokens("First text"); counter.count_tokens("Second text"); counter.count_tokens("Third text"); assert_eq!(counter.cache_size(), 3); - // Clear cache counter.clear_cache(); assert_eq!(counter.cache_size(), 0); - // Re-count should work fine let count = counter.count_tokens("First text"); assert!(count > 0); assert_eq!(counter.cache_size(), 1); @@ -598,7 +453,6 @@ mod tests { #[tokio::test] async fn test_concurrent_token_counter_creation() { - // Test concurrent creation of token counters to verify no race conditions let handles: Vec<_> = (0..10) .map(|_| tokio::spawn(async { create_async_token_counter().await.unwrap() })) .collect(); @@ -609,7 +463,6 @@ mod tests { .map(|r| r.unwrap()) .collect(); - // All should work and give same results let text = "Test concurrent creation"; let expected_count = counters[0].count_tokens(text); @@ -622,7 +475,6 @@ mod tests { async fn test_cache_eviction_behavior() { let counter = create_async_token_counter().await.unwrap(); - // Fill cache beyond normal size to test eviction let mut cached_texts = Vec::new(); for i in 0..50 { let text = format!("Test string number {}", i); @@ -630,14 +482,11 @@ mod tests { cached_texts.push(text); } - // Cache should be bounded assert!(counter.cache_size() <= MAX_TOKEN_CACHE_SIZE); - // Earlier entries may have been evicted, but recent ones should still be cached let recent_text = &cached_texts[cached_texts.len() - 1]; let start_size = counter.cache_size(); - // This should be a cache hit (no size increase) counter.count_tokens(recent_text); assert_eq!(counter.cache_size(), start_size); } @@ -646,12 +495,11 @@ mod tests { async fn test_concurrent_cache_operations() { let counter = std::sync::Arc::new(create_async_token_counter().await.unwrap()); - // Test concurrent token counting operations let handles: Vec<_> = (0..20) .map(|i| { let counter_clone = counter.clone(); tokio::spawn(async move { - let text = format!("Concurrent test {}", i % 5); // Some repetition for cache hits + let text = format!("Concurrent test {}", i % 5); counter_clone.count_tokens(&text) }) }) @@ -663,27 +511,22 @@ mod tests { .map(|r| r.unwrap()) .collect(); - // All results should be valid (> 0) for result in results { assert!(result > 0); } - // Cache should have some entries but be bounded assert!(counter.cache_size() > 0); assert!(counter.cache_size() <= MAX_TOKEN_CACHE_SIZE); } #[test] fn test_tokenizer_consistency() { - // Test that both sync and async versions give the same results let sync_counter = TokenCounter::new(); let text = "This is a test for tokenizer consistency"; let sync_count = sync_counter.count_tokens(text); - // Test that the tokenizer is working correctly assert!(sync_count > 0, "Sync tokenizer should produce tokens"); - // Test with different text lengths let short_text = "Hi"; let long_text = "This is a much longer text that should produce significantly more tokens than the short text"; diff --git a/crates/goose/tests/execution_tests.rs b/crates/goose/tests/execution_tests.rs index 55ec61134f..07e8932563 100644 --- a/crates/goose/tests/execution_tests.rs +++ b/crates/goose/tests/execution_tests.rs @@ -33,24 +33,15 @@ mod execution_tests { let session1 = uuid::Uuid::new_v4().to_string(); let session2 = uuid::Uuid::new_v4().to_string(); - let agent1 = manager - .get_or_create_agent(session1.clone(), SessionExecutionMode::Interactive) - .await - .unwrap(); + let agent1 = manager.get_or_create_agent(session1.clone()).await.unwrap(); - let agent2 = manager - .get_or_create_agent(session2.clone(), SessionExecutionMode::Interactive) - .await - .unwrap(); + let agent2 = manager.get_or_create_agent(session2.clone()).await.unwrap(); // Different sessions should have different agents assert!(!Arc::ptr_eq(&agent1, &agent2)); // Getting the same session should return the same agent - let agent1_again = manager - .get_or_create_agent(session1, SessionExecutionMode::chat()) - .await - .unwrap(); + let agent1_again = manager.get_or_create_agent(session1).await.unwrap(); assert!(Arc::ptr_eq(&agent1, &agent1_again)); @@ -66,18 +57,12 @@ mod execution_tests { let sessions: Vec<_> = (0..100).map(|i| format!("session-{}", i)).collect(); for session in &sessions { - manager - .get_or_create_agent(session.clone(), SessionExecutionMode::chat()) - .await - .unwrap(); + manager.get_or_create_agent(session.clone()).await.unwrap(); } // Create a new session after cleanup let new_session = "new-session".to_string(); - let _new_agent = manager - .get_or_create_agent(new_session, SessionExecutionMode::chat()) - .await - .unwrap(); + let _new_agent = manager.get_or_create_agent(new_session).await.unwrap(); assert_eq!(manager.session_count().await, 100); } @@ -89,18 +74,13 @@ mod execution_tests { let manager = AgentManager::instance().await.unwrap(); let session = String::from("remove-test"); - manager - .get_or_create_agent(session.clone(), SessionExecutionMode::chat()) - .await - .unwrap(); + manager.get_or_create_agent(session.clone()).await.unwrap(); assert!(manager.has_session(&session).await); manager.remove_session(&session).await.unwrap(); assert!(!manager.has_session(&session).await); assert!(manager.remove_session(&session).await.is_err()); - - AgentManager::reset_for_test(); } #[tokio::test] @@ -115,9 +95,7 @@ mod execution_tests { let mgr = Arc::clone(&manager); let sess = session.clone(); handles.push(tokio::spawn(async move { - mgr.get_or_create_agent(sess, SessionExecutionMode::chat()) - .await - .unwrap() + mgr.get_or_create_agent(sess).await.unwrap() })); } @@ -132,33 +110,6 @@ mod execution_tests { } assert_eq!(manager.session_count().await, 1); - - AgentManager::reset_for_test(); - } - - #[tokio::test] - #[serial] - async fn test_different_modes_same_session() { - AgentManager::reset_for_test(); - let manager = AgentManager::instance().await.unwrap(); - let session_id = String::from("mode-test"); - - // Create initial agent - let agent1 = manager - .get_or_create_agent(session_id.clone(), SessionExecutionMode::chat()) - .await - .unwrap(); - - // Get same session with different mode - should return same agent - // (mode is stored but agent is reused) - let agent2 = manager - .get_or_create_agent(session_id.clone(), SessionExecutionMode::Background) - .await - .unwrap(); - - assert!(Arc::ptr_eq(&agent1, &agent2)); - - AgentManager::reset_for_test(); } #[tokio::test] @@ -176,10 +127,7 @@ mod execution_tests { let sess = session_id.clone(); let mgr_clone = Arc::clone(&manager); handles.push(tokio::spawn(async move { - mgr_clone - .get_or_create_agent(sess, SessionExecutionMode::Interactive) - .await - .unwrap() + mgr_clone.get_or_create_agent(sess).await.unwrap() })); } @@ -190,18 +138,13 @@ mod execution_tests { .map(|r| r.unwrap()) .collect(); - // All should be the same agent (double-check pattern should prevent duplicates) for agent in &agents[1..] { assert!( Arc::ptr_eq(&agents[0], agent), "All concurrent requests should get the same agent" ); } - - // Only one session should exist assert_eq!(manager.session_count().await, 1); - - AgentManager::reset_for_test(); } #[tokio::test] @@ -233,8 +176,6 @@ mod execution_tests { } else { env::remove_var("GOOSE_DEFAULT_MODEL"); } - - AgentManager::reset_for_test(); } #[tokio::test] @@ -260,14 +201,9 @@ mod execution_tests { manager.set_default_provider(Arc::new(test_provider)).await; let session = String::from("provider-test"); - let _agent = manager - .get_or_create_agent(session.clone(), SessionExecutionMode::Interactive) - .await - .unwrap(); + let _agent = manager.get_or_create_agent(session.clone()).await.unwrap(); assert!(manager.has_session(&session).await); - - AgentManager::reset_for_test(); } #[tokio::test] @@ -281,10 +217,7 @@ mod execution_tests { let sessions: Vec<_> = (0..100).map(|i| format!("session-{}", i)).collect(); for session in &sessions { - manager - .get_or_create_agent(session.clone(), SessionExecutionMode::chat()) - .await - .unwrap(); + manager.get_or_create_agent(session.clone()).await.unwrap(); // Small delay to ensure different timestamps tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; } @@ -292,23 +225,20 @@ mod execution_tests { // Access the first session again to update its last_used tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; manager - .get_or_create_agent(sessions[0].clone(), SessionExecutionMode::Interactive) + .get_or_create_agent(sessions[0].clone()) .await .unwrap(); // Now create a 101st session - should evict session2 (least recently used) let session101 = String::from("session-101"); manager - .get_or_create_agent(session101.clone(), SessionExecutionMode::Interactive) + .get_or_create_agent(session101.clone()) .await .unwrap(); - // session1 should still exist (recently accessed) - // session2 should be evicted (least recently used) assert!(manager.has_session(&sessions[0]).await); assert!(!manager.has_session(&sessions[1]).await); assert!(manager.has_session(&session101).await); - AgentManager::reset_for_test(); } #[tokio::test] @@ -322,7 +252,5 @@ mod execution_tests { let result = manager.remove_session(&session).await; assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("not found")); - - AgentManager::reset_for_test(); } } diff --git a/crates/goose/tests/mcp_integration_test.rs b/crates/goose/tests/mcp_integration_test.rs index cfc2a55d03..ad2ca137af 100644 --- a/crates/goose/tests/mcp_integration_test.rs +++ b/crates/goose/tests/mcp_integration_test.rs @@ -1,3 +1,4 @@ +use serde::Deserialize; use std::collections::HashMap; use std::fs::File; use std::path::PathBuf; @@ -12,6 +13,60 @@ use goose::agents::extension_manager::ExtensionManager; use test_case::test_case; +use once_cell::sync::Lazy; +use std::process::Command; + +#[derive(Deserialize)] +struct CargoBuildMessage { + reason: String, + target: Target, + executable: String, +} + +#[derive(Deserialize)] +struct Target { + name: String, + kind: Vec, +} + +fn build_and_get_binary_path() -> PathBuf { + let output = Command::new("cargo") + .args([ + "build", + "--frozen", + "-p", + "goose-test", + "--bin", + "capture", + "--message-format=json", + ]) + .output() + .expect("failed to build binary"); + + if !output.status.success() { + panic!("build failed: {}", String::from_utf8_lossy(&output.stderr)); + } + + String::from_utf8_lossy(&output.stdout) + .lines() + .map(serde_json::from_str::) + .filter_map(Result::ok) + .filter(|message| message.reason == "compiler-artifact") + .filter_map(|message| { + if message.target.name == "capture" + && message.target.kind.contains(&String::from("bin")) + { + Some(PathBuf::from(message.executable)) + } else { + None + } + }) + .next() + .expect("failed to parase binary path") +} + +static REPLAY_BINARY_PATH: Lazy = Lazy::new(build_and_get_binary_path); + enum TestMode { Record, Playback, @@ -104,21 +159,11 @@ async fn test_replayed_session( TestMode::Record => "record", TestMode::Playback => "playback", }; - let cmd = "cargo".to_string(); - let mut args = vec![ - "run", - "--quiet", - "-p", - "goose-test", - "--bin", - "capture", - "--", - "stdio", - mode_arg, - ] - .into_iter() - .map(str::to_string) - .collect::>(); + let cmd = REPLAY_BINARY_PATH.to_string_lossy().to_string(); + let mut args = vec!["stdio", mode_arg] + .into_iter() + .map(str::to_string) + .collect::>(); args.push(replay_file_path.to_string_lossy().to_string()); diff --git a/crates/goose/tests/test_support.rs b/crates/goose/tests/test_support.rs index d53319477d..c84ecd3e8e 100644 --- a/crates/goose/tests/test_support.rs +++ b/crates/goose/tests/test_support.rs @@ -382,7 +382,7 @@ pub fn create_test_session_metadata(message_count: usize, working_dir: &str) -> id: "".to_string(), working_dir: PathBuf::from(working_dir), description: "Test session".to_string(), - created_at: "".to_string(), + created_at: Default::default(), schedule_id: Some("test_job".to_string()), recipe: None, total_tokens: Some(100), @@ -392,7 +392,7 @@ pub fn create_test_session_metadata(message_count: usize, working_dir: &str) -> accumulated_input_tokens: Some(50), accumulated_output_tokens: Some(50), extension_data: Default::default(), - updated_at: "".to_string(), + updated_at: Default::default(), conversation: None, message_count, } diff --git a/documentation/docs/guides/tips.md b/documentation/docs/guides/tips.md index a5e4a1f9c2..363f81b444 100644 --- a/documentation/docs/guides/tips.md +++ b/documentation/docs/guides/tips.md @@ -46,3 +46,6 @@ You don’t need to get it right the first time. Iterating on prompts and tools ### Keep Goose updated Regularly [update](/docs/guides/updating-goose) Goose to benefit from the latest features, bug fixes, and performance improvements. + +### Keep Recipes Idempotent +Write "[recipe](/docs/guides/recipes/session-recipes)" so they can run multiple times without causing unintended side effects. This ensures safer re-runs and easier debugging in automated workflows. diff --git a/temp_batch_fetch.txt b/temp_batch_fetch.txt new file mode 100644 index 0000000000..d3df745170 --- /dev/null +++ b/temp_batch_fetch.txt @@ -0,0 +1 @@ +Temporary file for batch operations \ No newline at end of file diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 19c9091f05..08bd121c49 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -1090,6 +1090,92 @@ } } }, + "/recipes/parse": { + "post": { + "tags": [ + "Recipe Management" + ], + "operationId": "parse_recipe", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ParseRecipeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Recipe parsed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ParseRecipeResponse" + } + } + } + }, + "400": { + "description": "Bad request - Invalid recipe format", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/recipes/save": { + "post": { + "tags": [ + "Recipe Management" + ], + "operationId": "save_recipe", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveRecipeRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Recipe saved to file successfully" + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/recipes/scan": { "post": { "tags": [ @@ -2984,6 +3070,28 @@ } } }, + "ParseRecipeRequest": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "content": { + "type": "string" + } + } + }, + "ParseRecipeResponse": { + "type": "object", + "required": [ + "recipe" + ], + "properties": { + "recipe": { + "$ref": "#/components/schemas/Recipe" + } + } + }, "PermissionConfirmationRequest": { "type": "object", "required": [ @@ -3300,7 +3408,6 @@ "type": "object", "required": [ "name", - "isGlobal", "recipe", "lastModified", "id" @@ -3309,9 +3416,6 @@ "id": { "type": "string" }, - "isGlobal": { - "type": "boolean" - }, "lastModified": { "type": "string" }, @@ -3517,6 +3621,25 @@ } } }, + "SaveRecipeRequest": { + "type": "object", + "required": [ + "recipe" + ], + "properties": { + "id": { + "type": "string", + "nullable": true + }, + "is_global": { + "type": "boolean", + "nullable": true + }, + "recipe": { + "$ref": "#/components/schemas/Recipe" + } + } + }, "ScanRecipeRequest": { "type": "object", "required": [ @@ -3618,7 +3741,8 @@ "nullable": true }, "created_at": { - "type": "string" + "type": "string", + "format": "date-time" }, "description": { "type": "string" @@ -3661,7 +3785,8 @@ "nullable": true }, "updated_at": { - "type": "string" + "type": "string", + "format": "date-time" }, "working_dir": { "type": "string" diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 12aa2fe37e..2a0dbd4a6d 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { IpcRendererEvent } from 'electron'; import { HashRouter, @@ -37,13 +37,14 @@ import PermissionSettingsView from './components/settings/permission/PermissionS import ExtensionsView, { ExtensionsViewOptions } from './components/extensions/ExtensionsView'; import RecipesView from './components/recipes/RecipesView'; import RecipeEditor from './components/recipes/RecipeEditor'; -import { createNavigationHandler, View, ViewOptions } from './utils/navigationUtils'; +import { View, ViewOptions } from './utils/navigationUtils'; import { AgentState, InitializationContext, NoProviderOrModelError, useAgent, } from './hooks/useAgent'; +import { useNavigation } from './hooks/useNavigation'; // Route Components const HubRouteWrapper = ({ @@ -55,8 +56,7 @@ const HubRouteWrapper = ({ isExtensionsLoading: boolean; resetChat: () => void; }) => { - const navigate = useNavigate(); - const setView = useMemo(() => createNavigationHandler(navigate), [navigate]); + const setView = useNavigation(); return ( Promise; }) => { const location = useLocation(); - const navigate = useNavigate(); - const setView = useMemo(() => createNavigationHandler(navigate), [navigate]); + const setView = useNavigation(); const routeState = (location.state as PairRouteState) || (window.history.state as PairRouteState) || {}; const [searchParams] = useSearchParams(); @@ -114,7 +113,7 @@ const PairRouteWrapper = ({ const SettingsRoute = () => { const location = useLocation(); const navigate = useNavigate(); - const setView = useMemo(() => createNavigationHandler(navigate), [navigate]); + const setView = useNavigation(); // Get viewOptions from location.state or history.state const viewOptions = @@ -123,8 +122,7 @@ const SettingsRoute = () => { }; const SessionsRoute = () => { - const navigate = useNavigate(); - const setView = useMemo(() => createNavigationHandler(navigate), [navigate]); + const setView = useNavigation(); return ; }; @@ -241,8 +239,7 @@ const SharedSessionRouteWrapper = ({ sharedSessionError: string | null; }) => { const location = useLocation(); - const navigate = useNavigate(); - const setView = createNavigationHandler(navigate); + const setView = useNavigation(); const historyState = window.history.state; const sessionDetails = (location.state?.sessionDetails || @@ -315,6 +312,7 @@ export function AppInner() { const [didSelectProvider, setDidSelectProvider] = useState(false); const navigate = useNavigate(); + const setView = useNavigation(); const location = useLocation(); const [_searchParams, setSearchParams] = useSearchParams(); @@ -357,10 +355,12 @@ export function AppInner() { if (loadingHub) { (async () => { try { - await loadCurrentChat({ + const loadedChat = await loadCurrentChat({ setAgentWaitingMessage, setIsExtensionsLoading, }); + // Update the chat state with the loaded session to ensure sessionId is available globally + setChat(loadedChat); } catch (e) { if (e instanceof NoProviderOrModelError) { // the onboarding flow will trigger @@ -370,7 +370,7 @@ export function AppInner() { } })(); } - }, [resetChat, loadCurrentChat, setAgentWaitingMessage, navigate, loadingHub]); + }, [resetChat, loadCurrentChat, setAgentWaitingMessage, navigate, loadingHub, setChat]); useEffect(() => { const handleOpenSharedSession = async (_event: IpcRendererEvent, ...args: unknown[]) => { @@ -533,7 +533,7 @@ export function AppInner() { closeOnClick pauseOnHover /> - +
@@ -582,7 +582,19 @@ export function AppInner() { } /> } /> - } /> + + + + } + /> } /> } /> } /> diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 7be7d8b9da..4c265e88ad 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Options as ClientOptions, TDataShape, Client } from './client'; -import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, ResumeAgentData, ResumeAgentResponses, ResumeAgentErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, StartAgentData, StartAgentResponses, StartAgentErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, CreateCustomProviderData, CreateCustomProviderResponses, CreateCustomProviderErrors, RemoveCustomProviderData, RemoveCustomProviderResponses, RemoveCustomProviderErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, GetProviderModelsData, GetProviderModelsResponses, GetProviderModelsErrors, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, DeleteRecipeData, DeleteRecipeResponses, DeleteRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ListRecipesData, ListRecipesResponses, ListRecipesErrors, ScanRecipeData, ScanRecipeResponses, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, GetSessionInsightsData, GetSessionInsightsResponses, GetSessionInsightsErrors, DeleteSessionData, DeleteSessionResponses, DeleteSessionErrors, GetSessionData, GetSessionResponses, GetSessionErrors, UpdateSessionDescriptionData, UpdateSessionDescriptionResponses, UpdateSessionDescriptionErrors, StatusData, StatusResponses } from './types.gen'; +import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, ResumeAgentData, ResumeAgentResponses, ResumeAgentErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, StartAgentData, StartAgentResponses, StartAgentErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, CreateCustomProviderData, CreateCustomProviderResponses, CreateCustomProviderErrors, RemoveCustomProviderData, RemoveCustomProviderResponses, RemoveCustomProviderErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, GetProviderModelsData, GetProviderModelsResponses, GetProviderModelsErrors, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, DeleteRecipeData, DeleteRecipeResponses, DeleteRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ListRecipesData, ListRecipesResponses, ListRecipesErrors, ParseRecipeData, ParseRecipeResponses, ParseRecipeErrors, SaveRecipeData, SaveRecipeResponses, SaveRecipeErrors, ScanRecipeData, ScanRecipeResponses, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, GetSessionInsightsData, GetSessionInsightsResponses, GetSessionInsightsErrors, DeleteSessionData, DeleteSessionResponses, DeleteSessionErrors, GetSessionData, GetSessionResponses, GetSessionErrors, UpdateSessionDescriptionData, UpdateSessionDescriptionResponses, UpdateSessionDescriptionErrors, StatusData, StatusResponses } from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -328,6 +328,28 @@ export const listRecipes = (options?: Opti }); }; +export const parseRecipe = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/recipes/parse', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + +export const saveRecipe = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/recipes/save', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + export const scanRecipe = (options: Options) => { return (options.client ?? _heyApiClient).post({ url: '/recipes/scan', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 962ba6f4a2..1354ea1a53 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -448,6 +448,14 @@ export type ModelInfo = { supports_cache_control?: boolean | null; }; +export type ParseRecipeRequest = { + content: string; +}; + +export type ParseRecipeResponse = { + recipe: Recipe; +}; + export type PermissionConfirmationRequest = { action: string; id: string; @@ -616,7 +624,6 @@ export type Recipe = { export type RecipeManifestResponse = { id: string; - isGlobal: boolean; lastModified: string; name: string; recipe: Recipe; @@ -695,6 +702,12 @@ export type RunNowResponse = { session_id: string; }; +export type SaveRecipeRequest = { + id?: string | null; + is_global?: boolean | null; + recipe: Recipe; +}; + export type ScanRecipeRequest = { recipe: Recipe; }; @@ -1784,6 +1797,64 @@ export type ListRecipesResponses = { export type ListRecipesResponse = ListRecipesResponses[keyof ListRecipesResponses]; +export type ParseRecipeData = { + body: ParseRecipeRequest; + path?: never; + query?: never; + url: '/recipes/parse'; +}; + +export type ParseRecipeErrors = { + /** + * Bad request - Invalid recipe format + */ + 400: ErrorResponse; + /** + * Internal server error + */ + 500: ErrorResponse; +}; + +export type ParseRecipeError = ParseRecipeErrors[keyof ParseRecipeErrors]; + +export type ParseRecipeResponses = { + /** + * Recipe parsed successfully + */ + 200: ParseRecipeResponse; +}; + +export type ParseRecipeResponse2 = ParseRecipeResponses[keyof ParseRecipeResponses]; + +export type SaveRecipeData = { + body: SaveRecipeRequest; + path?: never; + query?: never; + url: '/recipes/save'; +}; + +export type SaveRecipeErrors = { + /** + * Unauthorized - Invalid or missing API key + */ + 401: unknown; + /** + * Internal server error + */ + 500: ErrorResponse; +}; + +export type SaveRecipeError = SaveRecipeErrors[keyof SaveRecipeErrors]; + +export type SaveRecipeResponses = { + /** + * Recipe saved to file successfully + */ + 204: void; +}; + +export type SaveRecipeResponse = SaveRecipeResponses[keyof SaveRecipeResponses]; + export type ScanRecipeData = { body: ScanRecipeRequest; path?: never; diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 66aeb5cfe7..caf710fbd4 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -117,50 +117,6 @@ function BaseChatContent({ const [currentRecipeTitle, setCurrentRecipeTitle] = React.useState(null); const { isCompacting, handleManualCompaction } = useContextManager(); - // Timeout ref for debouncing auto-scroll - const autoScrollTimeoutRef = useRef(null); - // Track if user was following when agent started responding - const wasFollowingRef = useRef(true); - - const isNearBottom = React.useCallback(() => { - if (!scrollRef.current) return false; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const viewport = scrollRef.current as any; - if (!viewport.viewportRef?.current) return false; - - const viewportElement = viewport.viewportRef.current; - const { scrollHeight, scrollTop, clientHeight } = viewportElement; - const scrollBottom = scrollTop + clientHeight; - const distanceFromBottom = scrollHeight - scrollBottom; - - return distanceFromBottom <= 100; - }, []); - - // Function to auto-scroll if user was following when agent started - const conditionalAutoScroll = React.useCallback(() => { - // Clear any existing timeout - if (autoScrollTimeoutRef.current) { - clearTimeout(autoScrollTimeoutRef.current); - } - - // Debounce the auto-scroll to prevent jumpy behavior and prevent multiple rapid scrolls - autoScrollTimeoutRef.current = window.setTimeout(() => { - // Only auto-scroll if user was following when the agent started responding - if (wasFollowingRef.current && scrollRef.current) { - scrollRef.current.scrollToBottom(); - } - }, 150); - }, []); - - useEffect(() => { - return () => { - if (autoScrollTimeoutRef.current) { - clearTimeout(autoScrollTimeoutRef.current); - } - }; - }, []); - // Use shared chat engine const { messages, @@ -187,14 +143,10 @@ function BaseChatContent({ chat, setChat, onMessageStreamFinish: () => { - conditionalAutoScroll(); - // Call the original callback if provided onMessageStreamFinish?.(); }, onMessageSent: () => { - wasFollowingRef.current = isNearBottom(); - // Mark that user has started using the recipe if (recipeConfig) { setHasStartedUsingRecipe(true); @@ -275,12 +227,23 @@ function BaseChatContent({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Track if this is the initial render for session resuming + const initialRenderRef = useRef(true); + // Auto-scroll when messages are loaded (for session resuming) const handleRenderingComplete = React.useCallback(() => { - if (scrollRef.current?.scrollToBottom) { - scrollRef.current.scrollToBottom(); + // Only force scroll on the very first render + if (initialRenderRef.current && messages.length > 0) { + initialRenderRef.current = false; + if (scrollRef.current?.scrollToBottom) { + scrollRef.current.scrollToBottom(); + } + } else if (scrollRef.current?.isFollowing) { + if (scrollRef.current?.scrollToBottom) { + scrollRef.current.scrollToBottom(); + } } - }, []); + }, [messages.length]); // Handle submit const handleSubmit = (e: React.FormEvent) => { @@ -441,7 +404,12 @@ function BaseChatContent({ onClick={async () => { clearError(); - await handleManualCompaction(messages, setMessages, append); + await handleManualCompaction( + messages, + setMessages, + append, + chat.sessionId + ); }} > Summarize Conversation diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index aff49368f1..2ed48b1d09 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -568,7 +568,7 @@ export default function ChatInput({ // Hide the alert popup by dispatching a custom event that the popover can listen to // Importantly, this leaves the alert so the dot still shows up, but hides the popover window.dispatchEvent(new CustomEvent('hide-alert-popover')); - handleManualCompaction(messages, setMessages, append); + handleManualCompaction(messages, setMessages, append, sessionId || ''); }, compactIcon: , autoCompactThreshold: autoCompactThreshold, diff --git a/ui/desktop/src/components/ExtensionInstallModal.test.tsx b/ui/desktop/src/components/ExtensionInstallModal.test.tsx index 3720271cf8..057451f56a 100644 --- a/ui/desktop/src/components/ExtensionInstallModal.test.tsx +++ b/ui/desktop/src/components/ExtensionInstallModal.test.tsx @@ -21,6 +21,7 @@ const mockElectron = { describe('ExtensionInstallModal', () => { const mockAddExtension = vi.fn(); + const mockSetView = vi.fn(); const getAddExtensionEventHandler = () => { const addExtensionCall = mockElectron.on.mock.calls.find((call) => call[0] === 'add-extension'); @@ -43,7 +44,7 @@ describe('ExtensionInstallModal', () => { it('should handle trusted extension (default behaviour, no allowlist)', async () => { mockElectron.getAllowedExtensions.mockResolvedValue([]); - render(); + render(); const eventHandler = getAddExtensionEventHandler(); @@ -60,7 +61,7 @@ describe('ExtensionInstallModal', () => { it('should handle trusted extension (from allowlist)', async () => { mockElectron.getAllowedExtensions.mockResolvedValue(['npx test-extension']); - render(); + render(); const eventHandler = getAddExtensionEventHandler(); @@ -78,7 +79,7 @@ describe('ExtensionInstallModal', () => { }); mockElectron.getAllowedExtensions.mockResolvedValue(['uvx allowed-package']); - render(); + render(); const eventHandler = getAddExtensionEventHandler(); @@ -97,7 +98,7 @@ describe('ExtensionInstallModal', () => { it('should handle i-ching-mcp-server as allowed command', async () => { mockElectron.getAllowedExtensions.mockResolvedValue([]); - render(); + render(); const eventHandler = getAddExtensionEventHandler(); @@ -116,7 +117,7 @@ describe('ExtensionInstallModal', () => { it('should handle blocked extension', async () => { mockElectron.getAllowedExtensions.mockResolvedValue(['uvx allowed-package']); - render(); + render(); const eventHandler = getAddExtensionEventHandler(); @@ -135,7 +136,7 @@ describe('ExtensionInstallModal', () => { it('should dismiss modal correctly', async () => { mockElectron.getAllowedExtensions.mockResolvedValue([]); - render(); + render(); const eventHandler = getAddExtensionEventHandler(); @@ -156,7 +157,7 @@ describe('ExtensionInstallModal', () => { vi.mocked(addExtensionFromDeepLink).mockResolvedValue(undefined); mockElectron.getAllowedExtensions.mockResolvedValue([]); - render(); + render(); const eventHandler = getAddExtensionEventHandler(); diff --git a/ui/desktop/src/components/ExtensionInstallModal.tsx b/ui/desktop/src/components/ExtensionInstallModal.tsx index 2ac3bea07c..0478730c53 100644 --- a/ui/desktop/src/components/ExtensionInstallModal.tsx +++ b/ui/desktop/src/components/ExtensionInstallModal.tsx @@ -12,6 +12,7 @@ import { Button } from './ui/button'; import { extractExtensionName } from './settings/extensions/utils'; import { addExtensionFromDeepLink } from './settings/extensions/deeplink'; import type { ExtensionConfig } from '../api/types.gen'; +import { View, ViewOptions } from '../utils/navigationUtils'; type ModalType = 'blocked' | 'untrusted' | 'trusted'; @@ -41,10 +42,19 @@ interface ExtensionModalConfig { interface ExtensionInstallModalProps { addExtension?: (name: string, config: ExtensionConfig, enabled: boolean) => Promise; + setView: (view: View, options?: ViewOptions) => void; } function extractCommand(link: string): string { const url = new URL(link); + + // For remote extensions (SSE or Streaming HTTP), return the URL + const remoteUrl = url.searchParams.get('url'); + if (remoteUrl) { + return remoteUrl; + } + + // For stdio extensions, return the command const cmd = url.searchParams.get('cmd') || 'Unknown Command'; const args = url.searchParams.getAll('arg').map(decodeURIComponent); return `${cmd} ${args.join(' ')}`.trim(); @@ -55,7 +65,7 @@ function extractRemoteUrl(link: string): string | null { return url.searchParams.get('url'); } -export function ExtensionInstallModal({ addExtension }: ExtensionInstallModalProps) { +export function ExtensionInstallModal({ addExtension, setView }: ExtensionInstallModalProps) { const [modalState, setModalState] = useState({ isOpen: false, modalType: 'trusted', @@ -197,9 +207,14 @@ export function ExtensionInstallModal({ addExtension }: ExtensionInstallModalPro console.log(`Confirming installation of extension from: ${pendingLink}`); if (addExtension) { - await addExtensionFromDeepLink(pendingLink, addExtension, () => { - console.log('Extension installation completed, navigating to extensions'); - }); + await addExtensionFromDeepLink( + pendingLink, + addExtension, + (view: string, options?: ViewOptions) => { + console.log('Extension installation completed, navigating to:', view, options); + setView(view as View, options); + } + ); } else { throw new Error('addExtension function not provided to component'); } @@ -216,7 +231,7 @@ export function ExtensionInstallModal({ addExtension }: ExtensionInstallModalPro isPending: false, })); } - }, [pendingLink, dismissModal, addExtension]); + }, [pendingLink, dismissModal, addExtension, setView]); useEffect(() => { console.log('Setting up extension install modal handler'); diff --git a/ui/desktop/src/components/context_management/ContextManager.tsx b/ui/desktop/src/components/context_management/ContextManager.tsx index f95e396cb3..4d5ad9d9e1 100644 --- a/ui/desktop/src/components/context_management/ContextManager.tsx +++ b/ui/desktop/src/components/context_management/ContextManager.tsx @@ -12,12 +12,14 @@ interface ContextManagerActions { handleAutoCompaction: ( messages: Message[], setMessages: (messages: Message[]) => void, - append: (message: Message) => void + append: (message: Message) => void, + sessionId: string ) => Promise; handleManualCompaction: ( messages: Message[], setMessages: (messages: Message[]) => void, - append?: (message: Message) => void + append?: (message: Message) => void, + sessionId?: string ) => Promise; hasCompactionMarker: (message: Message) => boolean; } @@ -37,6 +39,7 @@ export const ContextManagerProvider: React.FC<{ children: React.ReactNode }> = ( messages: Message[], setMessages: (messages: Message[]) => void, append: (message: Message) => void, + sessionId: string, isManual: boolean = false ) => { setIsCompacting(true); @@ -47,6 +50,7 @@ export const ContextManagerProvider: React.FC<{ children: React.ReactNode }> = ( const summaryResponse = await manageContextFromBackend({ messages: messages, manageAction: 'summarize', + sessionId: sessionId, }); // Convert API messages to frontend messages @@ -100,9 +104,10 @@ export const ContextManagerProvider: React.FC<{ children: React.ReactNode }> = ( async ( messages: Message[], setMessages: (messages: Message[]) => void, - append: (message: Message) => void + append: (message: Message) => void, + sessionId: string ) => { - await performCompaction(messages, setMessages, append, false); + await performCompaction(messages, setMessages, append, sessionId, false); }, [performCompaction] ); @@ -111,9 +116,10 @@ export const ContextManagerProvider: React.FC<{ children: React.ReactNode }> = ( async ( messages: Message[], setMessages: (messages: Message[]) => void, - append?: (message: Message) => void + append?: (message: Message) => void, + sessionId?: string ) => { - await performCompaction(messages, setMessages, append || (() => {}), true); + await performCompaction(messages, setMessages, append || (() => {}), sessionId || '', true); }, [performCompaction] ); diff --git a/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx b/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx index 453d8cfa5a..53ac40b7a0 100644 --- a/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx +++ b/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx @@ -166,12 +166,18 @@ describe('ContextManager', () => { const { result } = renderContextManager(); await act(async () => { - await result.current.handleAutoCompaction(mockMessages, mockSetMessages, mockAppend); + await result.current.handleAutoCompaction( + mockMessages, + mockSetMessages, + mockAppend, + 'test-session-id' + ); }); expect(mockManageContextFromBackend).toHaveBeenCalledWith({ messages: mockMessages, manageAction: 'summarize', + sessionId: 'test-session-id', }); // Verify conversion calls with correct parameters @@ -225,7 +231,12 @@ describe('ContextManager', () => { const { result } = renderContextManager(); await act(async () => { - await result.current.handleAutoCompaction(mockMessages, mockSetMessages, mockAppend); + await result.current.handleAutoCompaction( + mockMessages, + mockSetMessages, + mockAppend, + 'test-session-id' + ); }); expect(result.current.compactionError).toBe('Backend error'); @@ -256,7 +267,12 @@ describe('ContextManager', () => { // Start compaction act(() => { - result.current.handleAutoCompaction(mockMessages, mockSetMessages, mockAppend); + result.current.handleAutoCompaction( + mockMessages, + mockSetMessages, + mockAppend, + 'test-session-id' + ); }); // Should be compacting @@ -306,7 +322,12 @@ describe('ContextManager', () => { const { result } = renderContextManager(); await act(async () => { - await result.current.handleAutoCompaction(messages, mockSetMessages, mockAppend); + await result.current.handleAutoCompaction( + messages, + mockSetMessages, + mockAppend, + 'test-session-id' + ); }); // No server messages -> setMessages called with empty list @@ -369,12 +390,18 @@ describe('ContextManager', () => { const { result } = renderContextManager(); await act(async () => { - await result.current.handleManualCompaction(mockMessages, mockSetMessages, mockAppend); + await result.current.handleManualCompaction( + mockMessages, + mockSetMessages, + mockAppend, + 'test-session-id' + ); }); expect(mockManageContextFromBackend).toHaveBeenCalledWith({ messages: mockMessages, manageAction: 'summarize', + sessionId: 'test-session-id', }); // Verify all three messages are set @@ -481,7 +508,12 @@ describe('ContextManager', () => { const { result } = renderContextManager(); await act(async () => { - await result.current.handleManualCompaction(mockMessages, mockSetMessages, mockAppend); + await result.current.handleManualCompaction( + mockMessages, + mockSetMessages, + mockAppend, + 'test-session-id' + ); }); // Verify all three messages are set @@ -508,7 +540,12 @@ describe('ContextManager', () => { const { result } = renderContextManager(); await act(async () => { - await result.current.handleAutoCompaction(mockMessages, mockSetMessages, mockAppend); + await result.current.handleAutoCompaction( + mockMessages, + mockSetMessages, + mockAppend, + 'test-session-id' + ); }); expect(result.current.compactionError).toBe('Unknown error during compaction'); @@ -539,7 +576,12 @@ describe('ContextManager', () => { const { result } = renderContextManager(); await act(async () => { - await result.current.handleAutoCompaction(mockMessages, mockSetMessages, mockAppend); + await result.current.handleAutoCompaction( + mockMessages, + mockSetMessages, + mockAppend, + 'test-session-id' + ); }); // Should complete without error even if content is not text diff --git a/ui/desktop/src/components/context_management/index.ts b/ui/desktop/src/components/context_management/index.ts index 68ddbb8cf0..c54d95eb0f 100644 --- a/ui/desktop/src/components/context_management/index.ts +++ b/ui/desktop/src/components/context_management/index.ts @@ -18,12 +18,14 @@ import { generateId } from 'ai'; export async function manageContextFromBackend({ messages, manageAction, + sessionId, }: { messages: FrontendMessage[]; manageAction: 'truncation' | 'summarize'; + sessionId: string; }): Promise { try { - const contextManagementRequest = { manageAction, messages }; + const contextManagementRequest = { manageAction, messages, sessionId }; // Cast to the API-expected type const result = await manageContext({ diff --git a/ui/desktop/src/components/extensions/ExtensionsView.tsx b/ui/desktop/src/components/extensions/ExtensionsView.tsx index 8907435dc1..b4af1adc23 100644 --- a/ui/desktop/src/components/extensions/ExtensionsView.tsx +++ b/ui/desktop/src/components/extensions/ExtensionsView.tsx @@ -7,13 +7,14 @@ import { Button } from '../ui/button'; import { Plus } from 'lucide-react'; import { GPSIcon } from '../ui/icons'; import { useState, useEffect } from 'react'; +import kebabCase from 'lodash/kebabCase'; import ExtensionModal from '../settings/extensions/modal/ExtensionModal'; import { getDefaultFormData, ExtensionFormData, createExtensionConfig, } from '../settings/extensions/utils'; -import { activateExtension } from '../settings/extensions/index'; +import { activateExtension } from '../settings/extensions'; import { useConfig } from '../ConfigContext'; export type ExtensionsViewOptions = { @@ -38,13 +39,37 @@ export default function ExtensionsView({ console.error('ExtensionsView: No session ID available'); } - // Trigger refresh when deep link config changes (i.e., when a deep link is processed) + // Only trigger refresh when deep link config changes AND we don't need to show env vars useEffect(() => { - if (viewOptions.deepLinkConfig) { + if (viewOptions.deepLinkConfig && !viewOptions.showEnvVars) { setRefreshKey((prevKey) => prevKey + 1); } }, [viewOptions.deepLinkConfig, viewOptions.showEnvVars]); + const scrollToExtension = (extensionName: string) => { + setTimeout(() => { + const element = document.getElementById(`extension-${kebabCase(extensionName)}`); + if (element) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + // Add a subtle highlight effect + element.style.boxShadow = '0 0 0 2px rgba(59, 130, 246, 0.5)'; + setTimeout(() => { + element.style.boxShadow = ''; + }, 2000); + } + }, 200); + }; + + // Scroll to extension whenever extensionId is provided (after refresh) + useEffect(() => { + if (viewOptions.deepLinkConfig?.name && refreshKey > 0) { + scrollToExtension(viewOptions.deepLinkConfig?.name); + } + }, [viewOptions.deepLinkConfig?.name, refreshKey]); + const handleModalClose = () => { setIsAddModalOpen(false); }; @@ -119,6 +144,9 @@ export default function ExtensionsView({ deepLinkConfig={viewOptions.deepLinkConfig} showEnvVars={viewOptions.showEnvVars} hideButtons={true} + onModalClose={(extensionName: string) => { + scrollToExtension(extensionName); + }} />
diff --git a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx index 6b68a1edc7..cc30abaf94 100644 --- a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx +++ b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx @@ -119,17 +119,13 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR return recipe as Recipe; }; - const validateTitleUniqueness = async ( - title: string, - isGlobal: boolean - ): Promise => { + const validateTitleUniqueness = async (title: string): Promise => { if (!title.trim()) return undefined; try { const existingRecipes = await listSavedRecipes(); const titleExists = existingRecipes.some( - (recipe) => - recipe.recipe.title?.toLowerCase() === title.toLowerCase() && recipe.isGlobal === isGlobal + (recipe) => recipe.recipe.title?.toLowerCase() === title.toLowerCase() ); if (titleExists) { @@ -171,10 +167,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR recipe.title = value.recipeTitle.trim(); - const titleValidationError = await validateTitleUniqueness( - value.recipeTitle.trim(), - value.global - ); + const titleValidationError = await validateTitleUniqueness(value.recipeTitle.trim()); if (titleValidationError) { throw new Error(titleValidationError); } diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index e56842b9c9..e1b5e01cfd 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -185,6 +185,8 @@ const SessionListView: React.FC = React.memo( currentIndex: number; } | null>(null); + const [visibleGroupsCount, setVisibleGroupsCount] = useState(15); + // Edit modal state const [showEditModal, setShowEditModal] = useState(false); const [editingSession, setEditingSession] = useState(null); @@ -210,6 +212,33 @@ const SessionListView: React.FC = React.memo( } }; + const visibleDateGroups = useMemo(() => { + return dateGroups.slice(0, visibleGroupsCount); + }, [dateGroups, visibleGroupsCount]); + + const handleScroll = useCallback( + (target: HTMLDivElement) => { + const { scrollTop, scrollHeight, clientHeight } = target; + const threshold = 200; + + if ( + scrollHeight - scrollTop - clientHeight < threshold && + visibleGroupsCount < dateGroups.length + ) { + setVisibleGroupsCount((prev) => Math.min(prev + 5, dateGroups.length)); + } + }, + [visibleGroupsCount, dateGroups.length] + ); + + useEffect(() => { + if (debouncedSearchTerm) { + setVisibleGroupsCount(dateGroups.length); + } else { + setVisibleGroupsCount(15); + } + }, [debouncedSearchTerm, dateGroups.length]); + const loadSessions = useCallback(async () => { setIsLoading(true); setShowSkeleton(true); @@ -557,10 +586,9 @@ const SessionListView: React.FC = React.memo( ); } - // For regular rendering in grid layout return (
- {dateGroups.map((group) => ( + {visibleDateGroups.map((group) => (

{group.label}

@@ -577,6 +605,15 @@ const SessionListView: React.FC = React.memo(
))} + + {visibleGroupsCount < dateGroups.length && ( +
+
+
+ Loading more sessions... +
+
+ )}
); }; @@ -597,7 +634,7 @@ const SessionListView: React.FC = React.memo(
- +
Promise; selectedExtensions?: string[]; // Add controlled state + onModalClose?: (extensionName: string) => void; } export default function ExtensionsSection({ @@ -34,6 +35,7 @@ export default function ExtensionsSection({ disableConfiguration, customToggle, selectedExtensions = [], + onModalClose, }: ExtensionSectionProps) { const { getExtensions, addExtension, removeExtension, extensionsList } = useConfig(); const [selectedExtension, setSelectedExtension] = useState(null); @@ -127,11 +129,15 @@ export default function ExtensionsSection({ extensionConfig: extensionConfig, sessionId: sessionId, }); - // Immediately refresh the extensions list after successful activation - await fetchExtensions(); } catch (error) { console.error('Failed to activate extension:', error); + } finally { await fetchExtensions(); + if (onModalClose) { + setTimeout(() => { + onModalClose(formData.name); + }, 200); + } } }; diff --git a/ui/desktop/src/components/settings/extensions/deeplink.ts b/ui/desktop/src/components/settings/extensions/deeplink.ts index 308dbf2d34..8620acedbe 100644 --- a/ui/desktop/src/components/settings/extensions/deeplink.ts +++ b/ui/desktop/src/components/settings/extensions/deeplink.ts @@ -88,7 +88,8 @@ function getStreamableHttpConfig( name: string, description: string, timeout: number, - headers?: { [key: string]: string } + headers?: { [key: string]: string }, + envs?: { [key: string]: string } ) { const config: ExtensionConfig = { name, @@ -97,6 +98,7 @@ function getStreamableHttpConfig( description, timeout: timeout, headers: headers, + envs: envs, }; return config; @@ -114,14 +116,9 @@ export async function addExtensionFromDeepLink( ) => Promise, setView: ( view: string, - options: - | { extensionId: string; showEnvVars: boolean } - | { deepLinkConfig: ExtensionConfig; showEnvVars: boolean } + options: { showEnvVars: boolean; deepLinkConfig?: ExtensionConfig } ) => void ) { - console.log('=== addExtensionFromDeepLink Debug ==='); - console.log('URL:', url); - const parsedUrl = new URL(url); if (parsedUrl.protocol !== 'goose:') { @@ -168,9 +165,21 @@ export async function addExtensionFromDeepLink( ) : undefined; + // Parse env vars for remote extensions (same logic as stdio) + const envList = parsedUrl.searchParams.getAll('env'); + const envs = + envList.length > 0 + ? Object.fromEntries( + envList.map((env) => { + const [key] = env.split('='); + return [key, '']; + }) + ) + : undefined; + const config = remoteUrl ? transportType === 'streamable_http' - ? getStreamableHttpConfig(remoteUrl, name, description || '', timeout, headers) + ? getStreamableHttpConfig(remoteUrl, name, description || '', timeout, headers, envs) : getSseConfig(remoteUrl, name, description || '', timeout) : getStdioConfig(cmd!, parsedUrl, name, description || '', timeout); @@ -180,21 +189,25 @@ export async function addExtensionFromDeepLink( config.type === 'streamable_http' && config.headers && Object.keys(config.headers).length > 0; if (hasEnvVars || hasHeaders) { - console.log('Environment variables or headers required, redirecting to settings'); - console.log('Calling setView with:', { deepLinkConfig: config, showEnvVars: true }); - setView('settings', { deepLinkConfig: config, showEnvVars: true }); + console.log( + 'Environment variables or headers required, redirecting to extensions with env variables modal showing' + ); + setView('extensions', { deepLinkConfig: config, showEnvVars: true }); return; } - try { - console.log('No env vars required, activating extension directly'); - // Note: deeplink activation doesn't have access to sessionId - // The extension will be added to config but not activated in the current session - // It will be activated when the next session starts - console.warn('Extension will be added to config but requires a session to activate'); - await addExtensionFn(config.name, config, true); - } catch (error) { - console.error('Failed to activate extension from deeplink:', error); - throw error; - } + console.log('No env vars required, activating extension directly'); + // Note: deeplink activation doesn't have access to sessionId + // The extension will be added to config but not activated in the current session + // It will be activated when the next session starts + await addExtensionFn(config.name, config, true); + + // Show success toast and navigate to extensions page + toastService.success({ + title: 'Extension Installed', + msg: `${config.name} extension has been installed successfully. Start a new chat session to use it.`, + }); + + // Navigate to extensions page to show the newly installed extension + setView('extensions', { deepLinkConfig: config, showEnvVars: false }); } diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx index 578a001b83..872142dfbc 100644 --- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx +++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; +import kebabCase from 'lodash/kebabCase'; import { Switch } from '../../../ui/switch'; -import { Gear } from '../../../icons/Gear'; +import { Gear } from '../../../icons'; import { FixedExtensionEntry } from '../../../ConfigContext'; import { getSubtitle, getFriendlyTitle } from './ExtensionList'; import { Card, CardHeader, CardTitle, CardContent, CardAction } from '../../../ui/card'; @@ -73,6 +74,7 @@ export default function ExtensionItem({ return ( handleToggle(extension)} > diff --git a/ui/desktop/src/components/ui/scroll-area.tsx b/ui/desktop/src/components/ui/scroll-area.tsx index e116e94b5e..43ce277339 100644 --- a/ui/desktop/src/components/ui/scroll-area.tsx +++ b/ui/desktop/src/components/ui/scroll-area.tsx @@ -8,34 +8,68 @@ import { cn } from '../../utils'; export interface ScrollAreaHandle { scrollToBottom: () => void; scrollToPosition: (options: { top: number; behavior?: ScrollBehavior }) => void; + isAtBottom: () => boolean; + isFollowing: boolean; + viewportRef: React.RefObject; } interface ScrollAreaProps extends React.ComponentPropsWithoutRef { autoScroll?: boolean; + onScrollChange?: (isAtBottom: boolean) => void; /* padding needs to be passed into the container inside ScrollArea to avoid pushing the scrollbar out */ paddingX?: number; paddingY?: number; + handleScroll?: (viewport: HTMLDivElement) => void; } const ScrollArea = React.forwardRef( - ({ className, children, autoScroll = false, paddingX, paddingY, ...props }, ref) => { + ( + { + className, + children, + autoScroll = false, + onScrollChange, + paddingX, + paddingY, + handleScroll: handleScrollProp, + ...props + }, + ref + ) => { const rootRef = React.useRef>(null); const viewportRef = React.useRef(null); const viewportEndRef = React.useRef(null); const [isFollowing, setIsFollowing] = React.useState(true); const [isScrolled, setIsScrolled] = React.useState(false); + const userScrolledUpRef = React.useRef(false); + const lastScrollHeightRef = React.useRef(0); + const isActivelyScrollingRef = React.useRef(false); + const scrollTimeoutRef = React.useRef(null); + + const BOTTOM_SCROLL_THRESHOLD = 100; + + const isAtBottom = React.useCallback(() => { + if (!viewportRef.current) return false; + + const viewport = viewportRef.current; + const { scrollHeight, scrollTop, clientHeight } = viewport; + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + + return distanceFromBottom <= BOTTOM_SCROLL_THRESHOLD; + }, []); const scrollToBottom = React.useCallback(() => { - if (viewportEndRef.current) { - viewportEndRef.current.scrollIntoView({ + if (viewportRef.current) { + viewportRef.current.scrollTo({ + top: viewportRef.current.scrollHeight, behavior: 'smooth', - block: 'end', - inline: 'nearest', }); // When explicitly scrolling to bottom, reset the following state setIsFollowing(true); + userScrolledUpRef.current = false; + onScrollChange?.(true); } - }, []); + }, [onScrollChange]); const scrollToPosition = React.useCallback( ({ top, behavior = 'smooth' }: { top: number; behavior?: ScrollBehavior }) => { @@ -55,49 +89,107 @@ const ScrollArea = React.forwardRef( () => ({ scrollToBottom, scrollToPosition, + isAtBottom, + isFollowing, + viewportRef, }), - [scrollToBottom, scrollToPosition] + [scrollToBottom, scrollToPosition, isAtBottom, isFollowing] ); + // track last scroll position to detect user-initiated scrolling + const lastScrollTopRef = React.useRef(0); + // Handle scroll events to update isFollowing state const handleScroll = React.useCallback(() => { if (!viewportRef.current) return; const viewport = viewportRef.current; - const { scrollHeight, scrollTop, clientHeight } = viewport; + const { scrollTop } = viewport; + const currentIsAtBottom = isAtBottom(); - const scrollBottom = scrollTop + clientHeight; - const isAtBottom = scrollHeight - scrollBottom <= 10; + // detect if this is a user-initiated scroll (position changed from last known position) + const scrollDelta = Math.abs(scrollTop - lastScrollTopRef.current); + if (scrollDelta > 0) { + // Mark that user is actively scrolling immediately + isActivelyScrollingRef.current = true; + + // clear any existing timeout and set a new one + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + + // mark as not actively scrolling + scrollTimeoutRef.current = window.setTimeout(() => { + isActivelyScrollingRef.current = false; + }, 100); + } + + lastScrollTopRef.current = scrollTop; + + // Detect if user manually scrolled up from the bottom + if (!currentIsAtBottom && isFollowing) { + // user scrolled up, disabling auto-scroll + userScrolledUpRef.current = true; + setIsFollowing(false); + onScrollChange?.(false); + } else if (currentIsAtBottom && userScrolledUpRef.current) { + // user scrolled back to bottom + userScrolledUpRef.current = false; + setIsFollowing(true); + onScrollChange?.(true); + } - setIsFollowing(isAtBottom); setIsScrolled(scrollTop > 0); - }, []); - // Track previous scroll height to detect content changes - const prevScrollHeightRef = React.useRef(0); + if (handleScrollProp) { + handleScrollProp(viewport); + } + }, [isAtBottom, isFollowing, onScrollChange, handleScrollProp]); + // Auto-scroll when content changes and user is following React.useEffect(() => { - if (!autoScroll || !isFollowing || !viewportRef.current) return; + if (!autoScroll || !viewportRef.current) return; const viewport = viewportRef.current; const currentScrollHeight = viewport.scrollHeight; - // Only auto-scroll if content has actually grown (new content added) - // and we were already following (at the bottom) - if (currentScrollHeight > prevScrollHeightRef.current) { - scrollToBottom(); + // Only auto-scroll if: + // 1. Content has actually grown (new content added) + // 2. User was following (at the bottom) + // 3. User hasn't manually scrolled up + // 4. User is not actively scrolling + if ( + currentScrollHeight > lastScrollHeightRef.current && + isFollowing && + !userScrolledUpRef.current && + !isActivelyScrollingRef.current + ) { + // Use requestAnimationFrame to ensure DOM has updated + requestAnimationFrame(() => { + if (viewportRef.current && !isActivelyScrollingRef.current) { + viewportRef.current.scrollTo({ + top: viewportRef.current.scrollHeight, + behavior: 'smooth', + }); + } + }); } - prevScrollHeightRef.current = currentScrollHeight; - }, [children, autoScroll, isFollowing, scrollToBottom]); + lastScrollHeightRef.current = currentScrollHeight; + }, [children, autoScroll, isFollowing]); // Add scroll event listener React.useEffect(() => { const viewport = viewportRef.current; if (!viewport) return; - viewport.addEventListener('scroll', handleScroll); - return () => viewport.removeEventListener('scroll', handleScroll); + viewport.addEventListener('scroll', handleScroll, { passive: true }); + return () => { + viewport.removeEventListener('scroll', handleScroll); + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + }; }, [handleScroll]); return ( diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index 1f957f8a5c..a74904b9d2 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -34,8 +34,7 @@ export const checkServerStatus = async (client: Client): Promise => { try { await status({ client, throwOnError: true }); return true; - } catch (error) { - log.error('failure to connect, will retry', error); + } catch { if (attempt === maxAttempts) { log.error(`Server failed to respond after ${(interval * maxAttempts) / 1000} seconds`); } diff --git a/ui/desktop/src/hooks/useNavigation.ts b/ui/desktop/src/hooks/useNavigation.ts new file mode 100644 index 0000000000..2dfefa3a41 --- /dev/null +++ b/ui/desktop/src/hooks/useNavigation.ts @@ -0,0 +1,13 @@ +import { useNavigate } from 'react-router-dom'; +import { createNavigationHandler } from '../utils/navigationUtils'; + +/** + * Custom hook that provides a navigation handler function. + * Eliminates the repetitive pattern of creating navigation handlers in components. + * + * @returns A navigation handler function + */ +export const useNavigation = () => { + const navigate = useNavigate(); + return createNavigationHandler(navigate); +};