mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
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:
@@ -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.
|
||||
@@ -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**:
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Core Maintainers
|
||||
|
||||
* Bradley Axen
|
||||
* Douwe Osinga
|
||||
* David Katz
|
||||
* Mic Neale
|
||||
* Alex Hancock
|
||||
* Zane Staggs
|
||||
* Jack Amadeo
|
||||
|
||||
# Maintainers
|
||||
@@ -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 🦢
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -758,6 +758,11 @@ impl CliSession {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(id) = &self.session_id {
|
||||
println!("Closing session. Session ID: {}", console::style(id).cyan());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Temporary file for batch operations
|
||||
+131
-6
@@ -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
@@ -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 />} />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user