Merge branch 'main' into micn/live-testing

* main: (24 commits)
  Lifei/create save recipe to file (#4895)
  feat(nightly): build nightlies from main shas (#4888)
  Add missing library for fedora/rhel/centos docs (#4819)
  feat(process): Add GOVERNANCE and MAINTAINERS documents (#4962)
  Pause test finder, have it run cargo fmt (#4958)
  Disable the issue comment trigger on pr-comment-bundle (#4961)
  fix(providers): update Claude Sonnet 4 model identifier (#4884)
  fix redirect to extensions page after deeplink install and show toast with success message (#4863)
  Remove wait-for-ready log (#4956)
  docs: add a new goose tip (#4940)
  Add PR template (#4934)
  Using --resume with --name should still accept session IDs (#4937)
  Fix auto scroll to bottom during chat (#4923)
  Fix Typo, Add Description to Hacktoberfest Content Issue Template (#4931)
  Don't set agent props twice (#4872)
  fix: conversation fixer merges assistant text blocks and drops empty text messages (#4898)
  Batch fetch remaining issues for documentation updates
  fix: session timestamps (#4913)
  feat: lazy infinite scroller for session history view (#4922)
  chore: properly identify when to try oauth (#4918)
  ...
This commit is contained in:
Michael Neale
2025-10-03 11:25:45 +10:00
57 changed files with 1551 additions and 735 deletions
@@ -0,0 +1,29 @@
name: Hacktobefest Content
title: "Hacktoberfest Blog: <add your title here>"
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.
@@ -0,0 +1,30 @@
name: Hacktoberfest Content
description: Issue template for various blog and video contributions as part of Hacktoberfest 2025
title: "Hacktoberfest Blog: <add your title here>"
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.
+26 -3
View File
@@ -1,8 +1,31 @@
## Pull Request Description
## Summary
<!-- Describe your change -->
<!-- Describe your changes here -->
---
### Type of Change
<!-- Select all that apply -->
- [ ] Feature
- [ ] Bug fix
- [ ] Refactor / Code quality
- [ ] Performance improvement
- [ ] Documentation
- [ ] Tests
- [ ] Security fix
- [ ] Build / Release
- [ ] Other (specify below)
### Testing
<!-- How have this change been tested? Unit/integration tests? Manual testing? -->
### Related Issues
Relates to #ISSUE_ID
Discussion: LINK (if any)
### Screenshots/Demos (for UX changes)
Before:
After:
<!-- For Recipe Cookbook Submissions ONLY: Include your email below to receive $10 OpenRouter credits once approved & merged -->
**Email**:
+24 -29
View File
@@ -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
-2
View File
@@ -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:
+7 -6
View File
@@ -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
+1 -1
View File
@@ -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:**
+166
View File
@@ -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.
+11
View File
@@ -0,0 +1,11 @@
# Core Maintainers
* Bradley Axen
* Douwe Osinga
* David Katz
* Mic Neale
* Alex Hancock
* Zane Staggs
* Jack Amadeo
# Maintainers
+1 -1
View File
@@ -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 🦢
+1 -1
View File
@@ -75,7 +75,7 @@ async fn get_session_id(identifier: Identifier) -> Result<String> {
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 {
+5
View File
@@ -758,6 +758,11 @@ impl CliSession {
}
}
}
if let Some(id) = &self.session_id {
println!("Closing session. Session ID: {}", console::style(id).cyan());
}
Ok(())
}
+8
View File
@@ -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(),
+6
View File
@@ -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,
+24
View File
@@ -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()
}
}
+1
View File
@@ -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;
+71 -3
View File
@@ -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<String>,
is_global: Option<bool>,
}
#[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<Arc<AppState>>,
Json(request): Json<SaveRecipeRequest>,
) -> Result<StatusCode, ErrorResponse> {
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<ParseRecipeRequest>,
) -> Result<Json<ParseRecipeResponse>, 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<AppState>) -> Router {
Router::new()
.route("/recipes/create", post(create_recipe))
@@ -286,6 +352,8 @@ pub fn routes(state: Arc<AppState>) -> 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)
}
+23 -57
View File
@@ -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<Vec<RecipeManifestWithPath>> {
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::<chrono::Utc>::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<Vec<RecipeManifestWithPath>> {
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::<chrono::Utc>::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<Vec<RecipeManifestWithPath>> {
#[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);
}
}
+1 -5
View File
@@ -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);
+6 -15
View File
@@ -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<Arc<goose::agents::Agent>> {
self.agent_manager
.get_or_create_agent(session_id, mode)
.await
pub async fn get_agent(&self, session_id: String) -> anyhow::Result<Arc<goose::agents::Agent>> {
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<Arc<goose::agents::Agent>, 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
})
}
}
+31 -11
View File
@@ -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<McpClient, ClientInitializeError>,
) -> Option<&AuthRequiredError> {
match res {
Ok(_) => None,
Err(err) => match err {
ClientInitializeError::TransportError {
error: DynamicTransportError { error, .. },
..
} => error
.downcast_ref::<StreamableHttpError<reqwest::Error>>()
.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,
@@ -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),
+2 -15
View File
@@ -379,7 +379,7 @@ impl From<PromptMessage> 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::<Vec<_>>()
.join(" ");
write!(f, "{:?}: {}", self.role, joined_content)
}
}
fn default_created() -> i64 {
0 // old messages do not have timestamps.
}
+189 -31
View File
@@ -168,20 +168,61 @@ pub fn fix_conversation(conversation: Conversation) -> (Conversation, Vec<String
}
fn fix_messages(messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
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<Message>) -> (Vec<Message>, Vec<String>) {
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<Message>) -> (Vec<Message>, Vec<String>) {
@@ -189,7 +230,11 @@ fn remove_empty_messages(messages: Vec<Message>) -> (Vec<Message>, Vec<String>)
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<Message>) -> (Vec<Message>, Vec<String>) {
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");
}
}
}
+14 -39
View File
@@ -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<Arc<Agent>> {
let agent = {
pub async fn get_or_create_agent(&self, session_id: String) -> Result<Arc<Agent>> {
{
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<()> {
+2 -2
View File
@@ -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";
+1
View File
@@ -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";
+104
View File
@@ -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<Vec<(PathBuf, Recipe)>> {
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<Vec<(PathBuf, Recipe)>> {
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::<String>()
.split_whitespace()
.collect::<Vec<&str>>()
.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<bool>,
file_path: Option<PathBuf>,
) -> anyhow::Result<PathBuf> {
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)
}
+5 -13
View File
@@ -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<Session>
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::<Utc>::from(created_time)));
obj.entry("updated_at")
.or_insert(serde_json::json!(format_timestamp(modified_time)?));
.or_insert(serde_json::json!(DateTime::<Utc>::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<Session>
Ok(session)
}
fn format_timestamp(time: SystemTime) -> Result<String> {
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<SystemTime> {
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)
}
+7 -6
View File
@@ -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<Utc>,
pub updated_at: DateTime<Utc>,
pub extension_data: ExtensionData,
pub total_tokens: Option<i32>,
pub input_tokens: Option<i32>,
@@ -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)
+7 -14
View File
@@ -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;
}
}
}
+52 -209
View File
@@ -8,25 +8,28 @@ use tokio::sync::OnceCell;
use crate::conversation::message::Message;
// Global tokenizer instance to avoid repeated initialization
static TOKENIZER: OnceCell<Arc<CoreBPE>> = 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<CoreBPE>,
token_cache: Arc<DashMap<u64, usize>>, // content hash -> token count
token_cache: Arc<DashMap<u64, usize>>,
}
/// Legacy synchronous token counter for backward compatibility
pub struct TokenCounter {
tokenizer: Arc<CoreBPE>,
}
impl AsyncTokenCounter {
/// Creates a new async token counter with caching
pub async fn new() -> Result<Self, String> {
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<Arc<CoreBPE>, String> {
let tokenizer = TOKENIZER
.get_or_init(|| async {
@@ -355,18 +325,14 @@ async fn get_tokenizer() -> Result<Arc<CoreBPE>, String> {
Ok(tokenizer.clone())
}
/// Get the global tokenizer instance (blocking version for backward compatibility)
fn get_tokenizer_blocking() -> Result<Arc<CoreBPE>, 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<Arc<CoreBPE>, String> {
}
}
/// Factory function for creating async token counters with proper error handling
pub async fn create_async_token_counter() -> Result<AsyncTokenCounter, String> {
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";
+12 -84
View File
@@ -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();
}
}
+60 -15
View File
@@ -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<String>,
}
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::<CargoBuildMessage>)
.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<PathBuf> = 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::<Vec<String>>();
let cmd = REPLAY_BINARY_PATH.to_string_lossy().to_string();
let mut args = vec!["stdio", mode_arg]
.into_iter()
.map(str::to_string)
.collect::<Vec<String>>();
args.push(replay_file_path.to_string_lossy().to_string());
+2 -2
View File
@@ -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,
}
+3
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
Temporary file for batch operations
+131 -6
View File
@@ -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"
+27 -15
View File
@@ -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 (
<Hub
@@ -86,8 +86,7 @@ const PairRouteWrapper = ({
loadCurrentChat: (context: InitializationContext) => Promise<ChatType>;
}) => {
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 <SessionsView setView={setView} />;
};
@@ -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<boolean>(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
/>
<ExtensionInstallModal addExtension={addExtension} />
<ExtensionInstallModal addExtension={addExtension} setView={setView} />
<div className="relative w-screen h-screen overflow-hidden bg-background-muted flex flex-col">
<div className="titlebar-drag-region" />
<Routes>
@@ -582,7 +582,19 @@ export function AppInner() {
}
/>
<Route path="settings" element={<SettingsRoute />} />
<Route path="extensions" element={<ExtensionsRoute />} />
<Route
path="extensions"
element={
<ChatProvider
chat={chat}
setChat={setChat}
contextKey="extensions"
agentWaitingMessage={agentWaitingMessage}
>
<ExtensionsRoute />
</ChatProvider>
}
/>
<Route path="sessions" element={<SessionsRoute />} />
<Route path="schedules" element={<SchedulesRoute />} />
<Route path="recipes" element={<RecipesRoute />} />
+23 -1
View File
@@ -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<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
@@ -328,6 +328,28 @@ export const listRecipes = <ThrowOnError extends boolean = false>(options?: Opti
});
};
export const parseRecipe = <ThrowOnError extends boolean = false>(options: Options<ParseRecipeData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<ParseRecipeResponses, ParseRecipeErrors, ThrowOnError>({
url: '/recipes/parse',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};
export const saveRecipe = <ThrowOnError extends boolean = false>(options: Options<SaveRecipeData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<SaveRecipeResponses, SaveRecipeErrors, ThrowOnError>({
url: '/recipes/save',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};
export const scanRecipe = <ThrowOnError extends boolean = false>(options: Options<ScanRecipeData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<ScanRecipeResponses, unknown, ThrowOnError>({
url: '/recipes/scan',
+72 -1
View File
@@ -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;
+20 -52
View File
@@ -117,50 +117,6 @@ function BaseChatContent({
const [currentRecipeTitle, setCurrentRecipeTitle] = React.useState<string | null>(null);
const { isCompacting, handleManualCompaction } = useContextManager();
// Timeout ref for debouncing auto-scroll
const autoScrollTimeoutRef = useRef<number | null>(null);
// Track if user was following when agent started responding
const wasFollowingRef = useRef<boolean>(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
+1 -1
View File
@@ -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: <ScrollText size={12} />,
autoCompactThreshold: autoCompactThreshold,
@@ -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(<ExtensionInstallModal addExtension={mockAddExtension} />);
render(<ExtensionInstallModal addExtension={mockAddExtension} setView={mockSetView} />);
const eventHandler = getAddExtensionEventHandler();
@@ -60,7 +61,7 @@ describe('ExtensionInstallModal', () => {
it('should handle trusted extension (from allowlist)', async () => {
mockElectron.getAllowedExtensions.mockResolvedValue(['npx test-extension']);
render(<ExtensionInstallModal addExtension={mockAddExtension} />);
render(<ExtensionInstallModal addExtension={mockAddExtension} setView={mockSetView} />);
const eventHandler = getAddExtensionEventHandler();
@@ -78,7 +79,7 @@ describe('ExtensionInstallModal', () => {
});
mockElectron.getAllowedExtensions.mockResolvedValue(['uvx allowed-package']);
render(<ExtensionInstallModal addExtension={mockAddExtension} />);
render(<ExtensionInstallModal addExtension={mockAddExtension} setView={mockSetView} />);
const eventHandler = getAddExtensionEventHandler();
@@ -97,7 +98,7 @@ describe('ExtensionInstallModal', () => {
it('should handle i-ching-mcp-server as allowed command', async () => {
mockElectron.getAllowedExtensions.mockResolvedValue([]);
render(<ExtensionInstallModal addExtension={mockAddExtension} />);
render(<ExtensionInstallModal addExtension={mockAddExtension} setView={mockSetView} />);
const eventHandler = getAddExtensionEventHandler();
@@ -116,7 +117,7 @@ describe('ExtensionInstallModal', () => {
it('should handle blocked extension', async () => {
mockElectron.getAllowedExtensions.mockResolvedValue(['uvx allowed-package']);
render(<ExtensionInstallModal addExtension={mockAddExtension} />);
render(<ExtensionInstallModal addExtension={mockAddExtension} setView={mockSetView} />);
const eventHandler = getAddExtensionEventHandler();
@@ -135,7 +136,7 @@ describe('ExtensionInstallModal', () => {
it('should dismiss modal correctly', async () => {
mockElectron.getAllowedExtensions.mockResolvedValue([]);
render(<ExtensionInstallModal addExtension={mockAddExtension} />);
render(<ExtensionInstallModal addExtension={mockAddExtension} setView={mockSetView} />);
const eventHandler = getAddExtensionEventHandler();
@@ -156,7 +157,7 @@ describe('ExtensionInstallModal', () => {
vi.mocked(addExtensionFromDeepLink).mockResolvedValue(undefined);
mockElectron.getAllowedExtensions.mockResolvedValue([]);
render(<ExtensionInstallModal addExtension={mockAddExtension} />);
render(<ExtensionInstallModal addExtension={mockAddExtension} setView={mockSetView} />);
const eventHandler = getAddExtensionEventHandler();
@@ -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<void>;
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<ExtensionModalState>({
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');
@@ -12,12 +12,14 @@ interface ContextManagerActions {
handleAutoCompaction: (
messages: Message[],
setMessages: (messages: Message[]) => void,
append: (message: Message) => void
append: (message: Message) => void,
sessionId: string
) => Promise<void>;
handleManualCompaction: (
messages: Message[],
setMessages: (messages: Message[]) => void,
append?: (message: Message) => void
append?: (message: Message) => void,
sessionId?: string
) => Promise<void>;
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]
);
@@ -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
@@ -18,12 +18,14 @@ import { generateId } from 'ai';
export async function manageContextFromBackend({
messages,
manageAction,
sessionId,
}: {
messages: FrontendMessage[];
manageAction: 'truncation' | 'summarize';
sessionId: string;
}): Promise<ContextManageResponse> {
try {
const contextManagementRequest = { manageAction, messages };
const contextManagementRequest = { manageAction, messages, sessionId };
// Cast to the API-expected type
const result = await manageContext({
@@ -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);
}}
/>
</div>
@@ -119,17 +119,13 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
return recipe as Recipe;
};
const validateTitleUniqueness = async (
title: string,
isGlobal: boolean
): Promise<string | undefined> => {
const validateTitleUniqueness = async (title: string): Promise<string | undefined> => {
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);
}
@@ -185,6 +185,8 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
currentIndex: number;
} | null>(null);
const [visibleGroupsCount, setVisibleGroupsCount] = useState(15);
// Edit modal state
const [showEditModal, setShowEditModal] = useState(false);
const [editingSession, setEditingSession] = useState<Session | null>(null);
@@ -210,6 +212,33 @@ const SessionListView: React.FC<SessionListViewProps> = 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<SessionListViewProps> = React.memo(
);
}
// For regular rendering in grid layout
return (
<div className="space-y-8">
{dateGroups.map((group) => (
{visibleDateGroups.map((group) => (
<div key={group.label} className="space-y-4">
<div className="sticky top-0 z-10 bg-background-default/95 backdrop-blur-sm">
<h2 className="text-text-muted">{group.label}</h2>
@@ -577,6 +605,15 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
</div>
</div>
))}
{visibleGroupsCount < dateGroups.length && (
<div className="flex justify-center py-8">
<div className="flex items-center space-x-2 text-text-muted">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-text-muted"></div>
<span>Loading more sessions...</span>
</div>
</div>
)}
</div>
);
};
@@ -597,7 +634,7 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
</div>
<div className="flex-1 min-h-0 relative px-8">
<ScrollArea className="h-full" data-search-scroll-area>
<ScrollArea handleScroll={handleScroll} className="h-full" data-search-scroll-area>
<div ref={containerRef} className="h-full relative">
<SearchView
onSearch={handleSearch}
@@ -24,6 +24,7 @@ interface ExtensionSectionProps {
disableConfiguration?: boolean;
customToggle?: (extension: FixedExtensionEntry) => Promise<boolean | void>;
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<FixedExtensionEntry | null>(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);
}
}
};
@@ -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<void>,
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 });
}
@@ -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 (
<Card
id={`extension-${kebabCase(extension.name)}`}
className="transition-all duration-200 hover:shadow-default hover:cursor-pointer min-h-[120px] overflow-hidden"
onClick={() => handleToggle(extension)}
>
+115 -23
View File
@@ -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<HTMLDivElement | null>;
}
interface ScrollAreaProps extends React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> {
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<ScrollAreaHandle, ScrollAreaProps>(
({ className, children, autoScroll = false, paddingX, paddingY, ...props }, ref) => {
(
{
className,
children,
autoScroll = false,
onScrollChange,
paddingX,
paddingY,
handleScroll: handleScrollProp,
...props
},
ref
) => {
const rootRef = React.useRef<React.ElementRef<typeof ScrollAreaPrimitive.Root>>(null);
const viewportRef = React.useRef<HTMLDivElement>(null);
const viewportEndRef = React.useRef<HTMLDivElement>(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<number | null>(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<ScrollAreaHandle, ScrollAreaProps>(
() => ({
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<number>(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 (
+1 -2
View File
@@ -34,8 +34,7 @@ export const checkServerStatus = async (client: Client): Promise<boolean> => {
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`);
}
+13
View File
@@ -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);
};