mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-31 01:48:23 -05:00
feat(chat): add input schema to discovery tools + validate unknown fields
- Add 'inputs' field to AgentInfo model for find_agent/find_library_agent - Fetch input schemas from graphs during agent search - Reject unknown input fields in run_agent with helpful error message Closes OPEN-2980
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
"""Shared agent search functionality for find_agent and find_library_agent tools."""
|
||||
|
||||
import logging
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
from backend.api.features.library import db as library_db
|
||||
from backend.api.features.store import db as store_db
|
||||
from backend.data.graph import get_graph
|
||||
from backend.util.exceptions import DatabaseError, NotFoundError
|
||||
|
||||
from .models import (
|
||||
@@ -14,12 +15,39 @@ from .models import (
|
||||
NoResultsResponse,
|
||||
ToolResponseBase,
|
||||
)
|
||||
from .utils import fetch_graph_from_store_slug
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SearchSource = Literal["marketplace", "library"]
|
||||
|
||||
|
||||
async def _fetch_input_schema_for_store_agent(
|
||||
creator: str, slug: str
|
||||
) -> dict[str, Any] | None:
|
||||
"""Fetch input schema for a marketplace agent. Returns None on error."""
|
||||
try:
|
||||
graph, _ = await fetch_graph_from_store_slug(creator, slug)
|
||||
if graph and graph.input_schema:
|
||||
return graph.input_schema.get("properties", {})
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not fetch input schema for {creator}/{slug}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch_input_schema_for_library_agent(
|
||||
graph_id: str, graph_version: int, user_id: str
|
||||
) -> dict[str, Any] | None:
|
||||
"""Fetch input schema for a library agent. Returns None on error."""
|
||||
try:
|
||||
graph = await get_graph(graph_id, graph_version, user_id=user_id)
|
||||
if graph and graph.input_schema:
|
||||
return graph.input_schema.get("properties", {})
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not fetch input schema for graph {graph_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def search_agents(
|
||||
query: str,
|
||||
source: SearchSource,
|
||||
@@ -55,6 +83,10 @@ async def search_agents(
|
||||
logger.info(f"Searching marketplace for: {query}")
|
||||
results = await store_db.get_store_agents(search_query=query, page_size=5)
|
||||
for agent in results.agents:
|
||||
# Fetch input schema for this agent
|
||||
inputs = await _fetch_input_schema_for_store_agent(
|
||||
agent.creator, agent.slug
|
||||
)
|
||||
agents.append(
|
||||
AgentInfo(
|
||||
id=f"{agent.creator}/{agent.slug}",
|
||||
@@ -67,6 +99,7 @@ async def search_agents(
|
||||
rating=agent.rating,
|
||||
runs=agent.runs,
|
||||
is_featured=False,
|
||||
inputs=inputs,
|
||||
)
|
||||
)
|
||||
else: # library
|
||||
@@ -77,6 +110,10 @@ async def search_agents(
|
||||
page_size=10,
|
||||
)
|
||||
for agent in results.agents:
|
||||
# Fetch input schema for this agent
|
||||
inputs = await _fetch_input_schema_for_library_agent(
|
||||
agent.graph_id, agent.graph_version, user_id # type: ignore[arg-type]
|
||||
)
|
||||
agents.append(
|
||||
AgentInfo(
|
||||
id=agent.id,
|
||||
@@ -90,6 +127,7 @@ async def search_agents(
|
||||
has_external_trigger=agent.has_external_trigger,
|
||||
new_output=agent.new_output,
|
||||
graph_id=agent.graph_id,
|
||||
inputs=inputs,
|
||||
)
|
||||
)
|
||||
logger.info(f"Found {len(agents)} agents in {source}")
|
||||
|
||||
@@ -68,6 +68,10 @@ class AgentInfo(BaseModel):
|
||||
has_external_trigger: bool | None = None
|
||||
new_output: bool | None = None
|
||||
graph_id: str | None = None
|
||||
inputs: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Input schema for the agent (properties from input_schema)",
|
||||
)
|
||||
|
||||
|
||||
class AgentsFoundResponse(ToolResponseBase):
|
||||
|
||||
@@ -273,6 +273,27 @@ class RunAgentTool(BaseTool):
|
||||
input_properties = graph.input_schema.get("properties", {})
|
||||
required_fields = set(graph.input_schema.get("required", []))
|
||||
provided_inputs = set(params.inputs.keys())
|
||||
valid_fields = set(input_properties.keys())
|
||||
|
||||
# Check for unknown fields - reject early with helpful message
|
||||
unknown_fields = provided_inputs - valid_fields
|
||||
if unknown_fields:
|
||||
valid_list = ", ".join(sorted(valid_fields)) if valid_fields else "none"
|
||||
return AgentDetailsResponse(
|
||||
message=(
|
||||
f"Unknown input field(s) provided: {', '.join(sorted(unknown_fields))}. "
|
||||
f"Valid fields for '{graph.name}': {valid_list}. "
|
||||
"Please check the field names and try again."
|
||||
),
|
||||
session_id=session_id,
|
||||
agent=self._build_agent_details(
|
||||
graph,
|
||||
extract_credentials_from_schema(graph.credentials_input_schema),
|
||||
),
|
||||
user_authenticated=True,
|
||||
graph_id=graph.id,
|
||||
graph_version=graph.version,
|
||||
)
|
||||
|
||||
# If agent has inputs but none were provided AND use_defaults is not set,
|
||||
# always show what's available first so user can decide
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, test, afterEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@/tests/integrations/test-utils";
|
||||
import { FavoritesSection } from "../FavoritesSection/FavoritesSection";
|
||||
import { server } from "@/mocks/mock-server";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import {
|
||||
mockAuthenticatedUser,
|
||||
resetAuthState,
|
||||
} from "@/tests/integrations/helpers/mock-supabase-auth";
|
||||
|
||||
const mockFavoriteAgent = {
|
||||
id: "fav-agent-id",
|
||||
graph_id: "fav-graph-id",
|
||||
graph_version: 1,
|
||||
owner_user_id: "test-owner-id",
|
||||
image_url: null,
|
||||
creator_name: "Test Creator",
|
||||
creator_image_url: "https://example.com/avatar.png",
|
||||
status: "READY",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
name: "Favorite Agent Name",
|
||||
description: "Test favorite agent",
|
||||
input_schema: {},
|
||||
output_schema: {},
|
||||
credentials_input_schema: null,
|
||||
has_external_trigger: false,
|
||||
has_human_in_the_loop: false,
|
||||
has_sensitive_action: false,
|
||||
new_output: false,
|
||||
can_access_graph: true,
|
||||
is_latest_version: true,
|
||||
is_favorite: true,
|
||||
};
|
||||
|
||||
describe("FavoritesSection", () => {
|
||||
afterEach(() => {
|
||||
resetAuthState();
|
||||
});
|
||||
|
||||
test("renders favorites section when there are favorites", async () => {
|
||||
mockAuthenticatedUser();
|
||||
|
||||
server.use(
|
||||
http.get("*/api/library/agents/favorites*", () => {
|
||||
return HttpResponse.json({
|
||||
agents: [mockFavoriteAgent],
|
||||
pagination: {
|
||||
total_items: 1,
|
||||
total_pages: 1,
|
||||
current_page: 1,
|
||||
page_size: 20,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
render(<FavoritesSection searchTerm="" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/favorites/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("renders favorite agent cards", async () => {
|
||||
mockAuthenticatedUser();
|
||||
|
||||
server.use(
|
||||
http.get("*/api/library/agents/favorites*", () => {
|
||||
return HttpResponse.json({
|
||||
agents: [mockFavoriteAgent],
|
||||
pagination: {
|
||||
total_items: 1,
|
||||
total_pages: 1,
|
||||
current_page: 1,
|
||||
page_size: 20,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
render(<FavoritesSection searchTerm="" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Favorite Agent Name")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("shows agent count", async () => {
|
||||
mockAuthenticatedUser();
|
||||
|
||||
server.use(
|
||||
http.get("*/api/library/agents/favorites*", () => {
|
||||
return HttpResponse.json({
|
||||
agents: [mockFavoriteAgent],
|
||||
pagination: {
|
||||
total_items: 1,
|
||||
total_pages: 1,
|
||||
current_page: 1,
|
||||
page_size: 20,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
render(<FavoritesSection searchTerm="" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("agents-count")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("does not render when there are no favorites", async () => {
|
||||
mockAuthenticatedUser();
|
||||
|
||||
server.use(
|
||||
http.get("*/api/library/agents/favorites*", () => {
|
||||
return HttpResponse.json({
|
||||
agents: [],
|
||||
pagination: {
|
||||
total_items: 0,
|
||||
total_pages: 0,
|
||||
current_page: 1,
|
||||
page_size: 20,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = render(<FavoritesSection searchTerm="" />);
|
||||
|
||||
// Wait for loading to complete
|
||||
await waitFor(() => {
|
||||
// Component should return null when no favorites
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
test("filters favorites based on search term", async () => {
|
||||
mockAuthenticatedUser();
|
||||
|
||||
// Mock that returns different results based on search term
|
||||
server.use(
|
||||
http.get("*/api/library/agents/favorites*", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const searchTerm = url.searchParams.get("search_term");
|
||||
|
||||
if (searchTerm === "nonexistent") {
|
||||
return HttpResponse.json({
|
||||
agents: [],
|
||||
pagination: {
|
||||
total_items: 0,
|
||||
total_pages: 0,
|
||||
current_page: 1,
|
||||
page_size: 20,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
agents: [mockFavoriteAgent],
|
||||
pagination: {
|
||||
total_items: 1,
|
||||
total_pages: 1,
|
||||
current_page: 1,
|
||||
page_size: 20,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const { rerender } = render(<FavoritesSection searchTerm="" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Favorite Agent Name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Rerender with search term that yields no results
|
||||
rerender(<FavoritesSection searchTerm="nonexistent" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Favorite Agent Name")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, test, afterEach } from "vitest";
|
||||
import { render, screen } from "@/tests/integrations/test-utils";
|
||||
import { LibraryAgentCard } from "../LibraryAgentCard/LibraryAgentCard";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import {
|
||||
mockAuthenticatedUser,
|
||||
resetAuthState,
|
||||
} from "@/tests/integrations/helpers/mock-supabase-auth";
|
||||
|
||||
const mockAgent: LibraryAgent = {
|
||||
id: "test-agent-id",
|
||||
graph_id: "test-graph-id",
|
||||
graph_version: 1,
|
||||
owner_user_id: "test-owner-id",
|
||||
image_url: null,
|
||||
creator_name: "Test Creator",
|
||||
creator_image_url: "https://example.com/avatar.png",
|
||||
status: "READY",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
name: "Test Agent Name",
|
||||
description: "Test agent description",
|
||||
input_schema: {},
|
||||
output_schema: {},
|
||||
credentials_input_schema: null,
|
||||
has_external_trigger: false,
|
||||
has_human_in_the_loop: false,
|
||||
has_sensitive_action: false,
|
||||
new_output: false,
|
||||
can_access_graph: true,
|
||||
is_latest_version: true,
|
||||
is_favorite: false,
|
||||
};
|
||||
|
||||
describe("LibraryAgentCard", () => {
|
||||
afterEach(() => {
|
||||
resetAuthState();
|
||||
});
|
||||
|
||||
test("renders agent name", () => {
|
||||
mockAuthenticatedUser();
|
||||
render(<LibraryAgentCard agent={mockAgent} />);
|
||||
|
||||
expect(screen.getByText("Test Agent Name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders see runs link", () => {
|
||||
mockAuthenticatedUser();
|
||||
render(<LibraryAgentCard agent={mockAgent} />);
|
||||
|
||||
expect(screen.getByText(/see runs/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders open in builder link when can_access_graph is true", () => {
|
||||
mockAuthenticatedUser();
|
||||
render(<LibraryAgentCard agent={mockAgent} />);
|
||||
|
||||
expect(screen.getByText(/open in builder/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not render open in builder link when can_access_graph is false", () => {
|
||||
mockAuthenticatedUser();
|
||||
const agentWithoutAccess = { ...mockAgent, can_access_graph: false };
|
||||
render(<LibraryAgentCard agent={agentWithoutAccess} />);
|
||||
|
||||
expect(screen.queryByText(/open in builder/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows 'FROM MARKETPLACE' label for marketplace agents", () => {
|
||||
mockAuthenticatedUser();
|
||||
const marketplaceAgent = {
|
||||
...mockAgent,
|
||||
marketplace_listing: {
|
||||
id: "listing-id",
|
||||
name: "Marketplace Agent",
|
||||
slug: "marketplace-agent",
|
||||
creator: {
|
||||
id: "creator-id",
|
||||
name: "Creator Name",
|
||||
slug: "creator-slug",
|
||||
},
|
||||
},
|
||||
};
|
||||
render(<LibraryAgentCard agent={marketplaceAgent} />);
|
||||
|
||||
expect(screen.getByText(/from marketplace/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows 'Built by you' label for user's own agents", () => {
|
||||
mockAuthenticatedUser();
|
||||
render(<LibraryAgentCard agent={mockAgent} />);
|
||||
|
||||
expect(screen.getByText(/built by you/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders favorite button", () => {
|
||||
mockAuthenticatedUser();
|
||||
render(<LibraryAgentCard agent={mockAgent} />);
|
||||
|
||||
// The favorite button should be present (as a heart icon button)
|
||||
const card = screen.getByTestId("library-agent-card");
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("links to correct agent detail page", () => {
|
||||
mockAuthenticatedUser();
|
||||
render(<LibraryAgentCard agent={mockAgent} />);
|
||||
|
||||
const link = screen.getByTestId("library-agent-card-see-runs-link");
|
||||
expect(link).toHaveAttribute("href", "/library/agents/test-agent-id");
|
||||
});
|
||||
|
||||
test("links to correct builder page", () => {
|
||||
mockAuthenticatedUser();
|
||||
render(<LibraryAgentCard agent={mockAgent} />);
|
||||
|
||||
const builderLink = screen.getByTestId(
|
||||
"library-agent-card-open-in-builder-link",
|
||||
);
|
||||
expect(builderLink).toHaveAttribute("href", "/build?flowID=test-graph-id");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@/tests/integrations/test-utils";
|
||||
import { LibrarySearchBar } from "../LibrarySearchBar/LibrarySearchBar";
|
||||
|
||||
describe("LibrarySearchBar", () => {
|
||||
test("renders search input", () => {
|
||||
const setSearchTerm = vi.fn();
|
||||
render(<LibrarySearchBar setSearchTerm={setSearchTerm} />);
|
||||
|
||||
expect(screen.getByPlaceholderText(/search agents/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders search icon", () => {
|
||||
const setSearchTerm = vi.fn();
|
||||
const { container } = render(
|
||||
<LibrarySearchBar setSearchTerm={setSearchTerm} />,
|
||||
);
|
||||
|
||||
// Check for the magnifying glass icon (SVG element)
|
||||
const searchIcon = container.querySelector("svg");
|
||||
expect(searchIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls setSearchTerm on input change", async () => {
|
||||
const setSearchTerm = vi.fn();
|
||||
render(<LibrarySearchBar setSearchTerm={setSearchTerm} />);
|
||||
|
||||
const input = screen.getByPlaceholderText(/search agents/i);
|
||||
fireEvent.change(input, { target: { value: "test query" } });
|
||||
|
||||
// The search bar uses debouncing, so we need to wait
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(setSearchTerm).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("has correct test id", () => {
|
||||
const setSearchTerm = vi.fn();
|
||||
render(<LibrarySearchBar setSearchTerm={setSearchTerm} />);
|
||||
|
||||
expect(screen.getByTestId("search-bar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("input has correct test id", () => {
|
||||
const setSearchTerm = vi.fn();
|
||||
render(<LibrarySearchBar setSearchTerm={setSearchTerm} />);
|
||||
|
||||
expect(screen.getByTestId("library-textbox")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@/tests/integrations/test-utils";
|
||||
import { LibrarySortMenu } from "../LibrarySortMenu/LibrarySortMenu";
|
||||
|
||||
describe("LibrarySortMenu", () => {
|
||||
test("renders sort dropdown", () => {
|
||||
const setLibrarySort = vi.fn();
|
||||
render(<LibrarySortMenu setLibrarySort={setLibrarySort} />);
|
||||
|
||||
expect(screen.getByTestId("sort-by-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows 'sort by' label on larger screens", () => {
|
||||
const setLibrarySort = vi.fn();
|
||||
render(<LibrarySortMenu setLibrarySort={setLibrarySort} />);
|
||||
|
||||
expect(screen.getByText(/sort by/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows default placeholder text", () => {
|
||||
const setLibrarySort = vi.fn();
|
||||
render(<LibrarySortMenu setLibrarySort={setLibrarySort} />);
|
||||
|
||||
expect(screen.getByText(/last modified/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens dropdown when clicked", async () => {
|
||||
const setLibrarySort = vi.fn();
|
||||
render(<LibrarySortMenu setLibrarySort={setLibrarySort} />);
|
||||
|
||||
const trigger = screen.getByRole("combobox");
|
||||
fireEvent.click(trigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/creation date/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("shows both sort options in dropdown", async () => {
|
||||
const setLibrarySort = vi.fn();
|
||||
render(<LibrarySortMenu setLibrarySort={setLibrarySort} />);
|
||||
|
||||
const trigger = screen.getByRole("combobox");
|
||||
fireEvent.click(trigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/creation date/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByText(/last modified/i).length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, test, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@/tests/integrations/test-utils";
|
||||
import LibraryUploadAgentDialog from "../LibraryUploadAgentDialog/LibraryUploadAgentDialog";
|
||||
import {
|
||||
mockAuthenticatedUser,
|
||||
resetAuthState,
|
||||
} from "@/tests/integrations/helpers/mock-supabase-auth";
|
||||
|
||||
describe("LibraryUploadAgentDialog", () => {
|
||||
afterEach(() => {
|
||||
resetAuthState();
|
||||
});
|
||||
|
||||
test("renders upload button", () => {
|
||||
mockAuthenticatedUser();
|
||||
render(<LibraryUploadAgentDialog />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /upload agent/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens dialog when upload button is clicked", async () => {
|
||||
mockAuthenticatedUser();
|
||||
render(<LibraryUploadAgentDialog />);
|
||||
|
||||
const uploadButton = screen.getByRole("button", { name: /upload agent/i });
|
||||
fireEvent.click(uploadButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Upload Agent")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("dialog contains agent name input", async () => {
|
||||
mockAuthenticatedUser();
|
||||
render(<LibraryUploadAgentDialog />);
|
||||
|
||||
const uploadButton = screen.getByRole("button", { name: /upload agent/i });
|
||||
fireEvent.click(uploadButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/agent name/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("dialog contains agent description input", async () => {
|
||||
mockAuthenticatedUser();
|
||||
render(<LibraryUploadAgentDialog />);
|
||||
|
||||
const uploadButton = screen.getByRole("button", { name: /upload agent/i });
|
||||
fireEvent.click(uploadButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/agent description/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("upload button is disabled when form is incomplete", async () => {
|
||||
mockAuthenticatedUser();
|
||||
render(<LibraryUploadAgentDialog />);
|
||||
|
||||
const triggerButton = screen.getByRole("button", { name: /upload agent/i });
|
||||
fireEvent.click(triggerButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const submitButton = screen.getByRole("button", { name: /^upload$/i });
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test("has correct test id on trigger button", () => {
|
||||
mockAuthenticatedUser();
|
||||
render(<LibraryUploadAgentDialog />);
|
||||
|
||||
expect(screen.getByTestId("upload-agent-button")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test, afterEach } from "vitest";
|
||||
import { render, screen } from "@/tests/integrations/test-utils";
|
||||
import LibraryPage from "../../page";
|
||||
import {
|
||||
mockAuthenticatedUser,
|
||||
mockUnauthenticatedUser,
|
||||
resetAuthState,
|
||||
} from "@/tests/integrations/helpers/mock-supabase-auth";
|
||||
|
||||
describe("LibraryPage - Auth State", () => {
|
||||
afterEach(() => {
|
||||
resetAuthState();
|
||||
});
|
||||
|
||||
test("renders page correctly when logged in", async () => {
|
||||
mockAuthenticatedUser();
|
||||
render(<LibraryPage />);
|
||||
|
||||
// Wait for upload button text to appear (indicates page is rendered)
|
||||
expect(
|
||||
await screen.findByText("Upload agent", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Search bar should be visible
|
||||
expect(screen.getByTestId("search-bar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders page correctly when logged out", async () => {
|
||||
mockUnauthenticatedUser();
|
||||
render(<LibraryPage />);
|
||||
|
||||
// Wait for upload button text to appear (indicates page is rendered)
|
||||
expect(
|
||||
await screen.findByText("Upload agent", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Search bar should still be visible
|
||||
expect(screen.getByTestId("search-bar")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, test, afterEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@/tests/integrations/test-utils";
|
||||
import LibraryPage from "../../page";
|
||||
import { server } from "@/mocks/mock-server";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import {
|
||||
mockAuthenticatedUser,
|
||||
resetAuthState,
|
||||
} from "@/tests/integrations/helpers/mock-supabase-auth";
|
||||
|
||||
describe("LibraryPage - Empty State", () => {
|
||||
afterEach(() => {
|
||||
resetAuthState();
|
||||
});
|
||||
|
||||
test("handles empty agents list gracefully", async () => {
|
||||
mockAuthenticatedUser();
|
||||
|
||||
server.use(
|
||||
http.get("*/api/library/agents*", () => {
|
||||
return HttpResponse.json({
|
||||
agents: [],
|
||||
pagination: {
|
||||
total_items: 0,
|
||||
total_pages: 0,
|
||||
current_page: 1,
|
||||
page_size: 20,
|
||||
},
|
||||
});
|
||||
}),
|
||||
http.get("*/api/library/agents/favorites*", () => {
|
||||
return HttpResponse.json({
|
||||
agents: [],
|
||||
pagination: {
|
||||
total_items: 0,
|
||||
total_pages: 0,
|
||||
current_page: 1,
|
||||
page_size: 20,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
render(<LibraryPage />);
|
||||
|
||||
// Page should still render without crashing
|
||||
// Search bar should be visible even with no agents
|
||||
expect(
|
||||
await screen.findByPlaceholderText(/search agents/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Upload button should be visible
|
||||
expect(
|
||||
screen.getByRole("button", { name: /upload agent/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles empty favorites gracefully", async () => {
|
||||
mockAuthenticatedUser();
|
||||
|
||||
server.use(
|
||||
http.get("*/api/library/agents/favorites*", () => {
|
||||
return HttpResponse.json({
|
||||
agents: [],
|
||||
pagination: {
|
||||
total_items: 0,
|
||||
total_pages: 0,
|
||||
current_page: 1,
|
||||
page_size: 20,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
render(<LibraryPage />);
|
||||
|
||||
// Page should still render without crashing
|
||||
expect(
|
||||
await screen.findByPlaceholderText(/search agents/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test, afterEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@/tests/integrations/test-utils";
|
||||
import LibraryPage from "../../page";
|
||||
import { server } from "@/mocks/mock-server";
|
||||
import {
|
||||
mockAuthenticatedUser,
|
||||
resetAuthState,
|
||||
} from "@/tests/integrations/helpers/mock-supabase-auth";
|
||||
import { create500Handler } from "@/tests/integrations/helpers/create-500-handler";
|
||||
import {
|
||||
getGetV2ListLibraryAgentsMockHandler422,
|
||||
getGetV2ListFavoriteLibraryAgentsMockHandler422,
|
||||
} from "@/app/api/__generated__/endpoints/library/library.msw";
|
||||
|
||||
describe("LibraryPage - Error Handling", () => {
|
||||
afterEach(() => {
|
||||
resetAuthState();
|
||||
});
|
||||
|
||||
test("handles API 422 error gracefully", async () => {
|
||||
mockAuthenticatedUser();
|
||||
|
||||
server.use(getGetV2ListLibraryAgentsMockHandler422());
|
||||
|
||||
render(<LibraryPage />);
|
||||
|
||||
// Page should still render without crashing
|
||||
// Search bar should be visible even with error
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/search agents/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("handles favorites API 422 error gracefully", async () => {
|
||||
mockAuthenticatedUser();
|
||||
|
||||
server.use(getGetV2ListFavoriteLibraryAgentsMockHandler422());
|
||||
|
||||
render(<LibraryPage />);
|
||||
|
||||
// Page should still render without crashing
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/search agents/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("handles API 500 error gracefully", async () => {
|
||||
mockAuthenticatedUser();
|
||||
|
||||
server.use(create500Handler("get", "*/api/library/agents*"));
|
||||
|
||||
render(<LibraryPage />);
|
||||
|
||||
// Page should still render without crashing
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/search agents/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test, afterEach } from "vitest";
|
||||
import { render, screen } from "@/tests/integrations/test-utils";
|
||||
import LibraryPage from "../../page";
|
||||
import { server } from "@/mocks/mock-server";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import {
|
||||
mockAuthenticatedUser,
|
||||
resetAuthState,
|
||||
} from "@/tests/integrations/helpers/mock-supabase-auth";
|
||||
|
||||
describe("LibraryPage - Loading State", () => {
|
||||
afterEach(() => {
|
||||
resetAuthState();
|
||||
});
|
||||
|
||||
test("shows loading spinner while agents are being fetched", async () => {
|
||||
mockAuthenticatedUser();
|
||||
|
||||
// Override handlers to add delay to simulate loading
|
||||
server.use(
|
||||
http.get("*/api/library/agents*", async () => {
|
||||
await delay(500);
|
||||
return HttpResponse.json({
|
||||
agents: [],
|
||||
pagination: {
|
||||
total_items: 0,
|
||||
total_pages: 0,
|
||||
current_page: 1,
|
||||
page_size: 20,
|
||||
},
|
||||
});
|
||||
}),
|
||||
http.get("*/api/library/agents/favorites*", async () => {
|
||||
await delay(500);
|
||||
return HttpResponse.json({
|
||||
agents: [],
|
||||
pagination: {
|
||||
total_items: 0,
|
||||
total_pages: 0,
|
||||
current_page: 1,
|
||||
page_size: 20,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = render(<LibraryPage />);
|
||||
|
||||
// Check for loading spinner (LoadingSpinner component)
|
||||
const loadingElements = container.querySelectorAll(
|
||||
'[class*="animate-spin"]',
|
||||
);
|
||||
expect(loadingElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test, afterEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@/tests/integrations/test-utils";
|
||||
import LibraryPage from "../../page";
|
||||
import {
|
||||
mockAuthenticatedUser,
|
||||
resetAuthState,
|
||||
} from "@/tests/integrations/helpers/mock-supabase-auth";
|
||||
|
||||
describe("LibraryPage - Rendering", () => {
|
||||
afterEach(() => {
|
||||
resetAuthState();
|
||||
});
|
||||
|
||||
test("renders search bar", async () => {
|
||||
mockAuthenticatedUser();
|
||||
render(<LibraryPage />);
|
||||
|
||||
expect(
|
||||
await screen.findByPlaceholderText(/search agents/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders upload agent button", async () => {
|
||||
mockAuthenticatedUser();
|
||||
render(<LibraryPage />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", { name: /upload agent/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders agent cards when data is loaded", async () => {
|
||||
mockAuthenticatedUser();
|
||||
render(<LibraryPage />);
|
||||
|
||||
// Wait for agent cards to appear (from mock data)
|
||||
await waitFor(() => {
|
||||
const agentCards = screen.getAllByTestId("library-agent-card");
|
||||
expect(agentCards.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("agent cards display agent name", async () => {
|
||||
mockAuthenticatedUser();
|
||||
render(<LibraryPage />);
|
||||
|
||||
// Wait for agent cards and check they have names
|
||||
await waitFor(() => {
|
||||
const agentNames = screen.getAllByTestId("library-agent-card-name");
|
||||
expect(agentNames.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("agent cards have see runs link", async () => {
|
||||
mockAuthenticatedUser();
|
||||
render(<LibraryPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
const seeRunsLinks = screen.getAllByTestId(
|
||||
"library-agent-card-see-runs-link",
|
||||
);
|
||||
expect(seeRunsLinks.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* useChatContainerAiSdk - ChatContainer hook using Vercel AI SDK
|
||||
*
|
||||
* This is a drop-in replacement for useChatContainer that uses @ai-sdk/react
|
||||
* instead of the custom streaming implementation. The API surface is identical
|
||||
* to enable easy A/B testing and gradual migration.
|
||||
*/
|
||||
|
||||
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { UIMessage } from "ai";
|
||||
import { useAiSdkChat } from "../../useAiSdkChat";
|
||||
import { usePageContext } from "../../usePageContext";
|
||||
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
||||
import {
|
||||
filterAuthMessages,
|
||||
hasSentInitialPrompt,
|
||||
markInitialPromptSent,
|
||||
processInitialMessages,
|
||||
} from "./helpers";
|
||||
|
||||
// Helper to convert backend messages to AI SDK UIMessage format
|
||||
function convertToUIMessages(
|
||||
messages: SessionDetailResponse["messages"],
|
||||
): UIMessage[] {
|
||||
const result: UIMessage[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.role || !msg.content) continue;
|
||||
|
||||
// Create parts based on message type
|
||||
const parts: UIMessage["parts"] = [];
|
||||
|
||||
if (msg.role === "user" || msg.role === "assistant") {
|
||||
if (typeof msg.content === "string") {
|
||||
parts.push({ type: "text", text: msg.content });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool calls in assistant messages
|
||||
if (msg.role === "assistant" && msg.tool_calls) {
|
||||
for (const toolCall of msg.tool_calls as Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
function: { name: string; arguments: string };
|
||||
}>) {
|
||||
if (toolCall.type === "function") {
|
||||
let args = {};
|
||||
try {
|
||||
args = JSON.parse(toolCall.function.arguments);
|
||||
} catch {
|
||||
// Keep empty args
|
||||
}
|
||||
parts.push({
|
||||
type: `tool-${toolCall.function.name}` as `tool-${string}`,
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.function.name,
|
||||
state: "input-available",
|
||||
input: args,
|
||||
} as UIMessage["parts"][number]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool responses
|
||||
if (msg.role === "tool" && msg.tool_call_id) {
|
||||
// Find the matching tool call to get the name
|
||||
const toolName = "unknown";
|
||||
let output: unknown = msg.content;
|
||||
try {
|
||||
output =
|
||||
typeof msg.content === "string"
|
||||
? JSON.parse(msg.content)
|
||||
: msg.content;
|
||||
} catch {
|
||||
// Keep as string
|
||||
}
|
||||
|
||||
parts.push({
|
||||
type: `tool-${toolName}` as `tool-${string}`,
|
||||
toolCallId: msg.tool_call_id as string,
|
||||
toolName,
|
||||
state: "output-available",
|
||||
output,
|
||||
} as UIMessage["parts"][number]);
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
result.push({
|
||||
id: msg.id || `msg-${Date.now()}-${Math.random()}`,
|
||||
role: msg.role === "tool" ? "assistant" : (msg.role as "user" | "assistant"),
|
||||
parts,
|
||||
createdAt: msg.created_at ? new Date(msg.created_at as string) : new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
interface Args {
|
||||
sessionId: string | null;
|
||||
initialMessages: SessionDetailResponse["messages"];
|
||||
initialPrompt?: string;
|
||||
onOperationStarted?: () => void;
|
||||
}
|
||||
|
||||
export function useChatContainerAiSdk({
|
||||
sessionId,
|
||||
initialMessages,
|
||||
initialPrompt,
|
||||
onOperationStarted,
|
||||
}: Args) {
|
||||
const { capturePageContext } = usePageContext();
|
||||
const sendMessageRef = useRef<
|
||||
(
|
||||
content: string,
|
||||
isUserMessage?: boolean,
|
||||
context?: { url: string; content: string },
|
||||
) => Promise<void>
|
||||
>();
|
||||
|
||||
// Convert initial messages to AI SDK format
|
||||
const uiMessages = useMemo(
|
||||
() => convertToUIMessages(initialMessages),
|
||||
[initialMessages],
|
||||
);
|
||||
|
||||
const {
|
||||
messages: aiSdkMessages,
|
||||
streamingChunks,
|
||||
isStreaming,
|
||||
error,
|
||||
isRegionBlockedModalOpen,
|
||||
setIsRegionBlockedModalOpen,
|
||||
sendMessage,
|
||||
stopStreaming,
|
||||
} = useAiSdkChat({
|
||||
sessionId,
|
||||
initialMessages: uiMessages,
|
||||
onOperationStarted,
|
||||
});
|
||||
|
||||
// Keep ref updated for initial prompt handling
|
||||
sendMessageRef.current = sendMessage;
|
||||
|
||||
// Merge AI SDK messages with processed initial messages
|
||||
// This ensures we show both historical messages and new streaming messages
|
||||
const allMessages = useMemo(() => {
|
||||
const processedInitial = processInitialMessages(initialMessages);
|
||||
|
||||
// Build a set of message keys for deduplication
|
||||
const seenKeys = new Set<string>();
|
||||
const result: ChatMessageData[] = [];
|
||||
|
||||
// Add processed initial messages first
|
||||
for (const msg of processedInitial) {
|
||||
const key = getMessageKey(msg);
|
||||
if (!seenKeys.has(key)) {
|
||||
seenKeys.add(key);
|
||||
result.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Add AI SDK messages that aren't duplicates
|
||||
for (const msg of aiSdkMessages) {
|
||||
const key = getMessageKey(msg);
|
||||
if (!seenKeys.has(key)) {
|
||||
seenKeys.add(key);
|
||||
result.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [initialMessages, aiSdkMessages]);
|
||||
|
||||
// Handle initial prompt
|
||||
useEffect(
|
||||
function handleInitialPrompt() {
|
||||
if (!initialPrompt || !sessionId) return;
|
||||
if (initialMessages.length > 0) return;
|
||||
if (hasSentInitialPrompt(sessionId)) return;
|
||||
|
||||
markInitialPromptSent(sessionId);
|
||||
const context = capturePageContext();
|
||||
sendMessageRef.current?.(initialPrompt, true, context);
|
||||
},
|
||||
[initialPrompt, sessionId, initialMessages.length, capturePageContext],
|
||||
);
|
||||
|
||||
// Send message with page context
|
||||
async function sendMessageWithContext(
|
||||
content: string,
|
||||
isUserMessage: boolean = true,
|
||||
) {
|
||||
const context = capturePageContext();
|
||||
await sendMessage(content, isUserMessage, context);
|
||||
}
|
||||
|
||||
function handleRegionModalOpenChange(open: boolean) {
|
||||
setIsRegionBlockedModalOpen(open);
|
||||
}
|
||||
|
||||
function handleRegionModalClose() {
|
||||
setIsRegionBlockedModalOpen(false);
|
||||
}
|
||||
|
||||
return {
|
||||
messages: filterAuthMessages(allMessages),
|
||||
streamingChunks,
|
||||
isStreaming,
|
||||
error,
|
||||
isRegionBlockedModalOpen,
|
||||
setIsRegionBlockedModalOpen,
|
||||
sendMessageWithContext,
|
||||
handleRegionModalOpenChange,
|
||||
handleRegionModalClose,
|
||||
sendMessage,
|
||||
stopStreaming,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to generate deduplication key for a message
|
||||
function getMessageKey(msg: ChatMessageData): string {
|
||||
if (msg.type === "message") {
|
||||
return `msg:${msg.role}:${msg.content}`;
|
||||
} else if (msg.type === "tool_call") {
|
||||
return `toolcall:${msg.toolId}`;
|
||||
} else if (msg.type === "tool_response") {
|
||||
return `toolresponse:${(msg as { toolId?: string }).toolId}`;
|
||||
} else if (
|
||||
msg.type === "operation_started" ||
|
||||
msg.type === "operation_pending" ||
|
||||
msg.type === "operation_in_progress"
|
||||
) {
|
||||
const typedMsg = msg as {
|
||||
toolId?: string;
|
||||
operationId?: string;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
};
|
||||
return `op:${typedMsg.toolId || typedMsg.operationId || typedMsg.toolCallId || ""}:${typedMsg.toolName || ""}`;
|
||||
} else {
|
||||
return `${msg.type}:${JSON.stringify(msg).slice(0, 100)}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* useAiSdkChat - Vercel AI SDK integration for CoPilot Chat
|
||||
*
|
||||
* This hook wraps @ai-sdk/react's useChat to provide:
|
||||
* - Streaming chat with the existing Python backend (already AI SDK protocol compatible)
|
||||
* - Integration with existing session management
|
||||
* - Custom tool response parsing for AutoGPT-specific types
|
||||
* - Page context injection
|
||||
*
|
||||
* The Python backend already implements the AI SDK Data Stream Protocol (v1),
|
||||
* so this hook can communicate directly without any backend changes.
|
||||
*/
|
||||
|
||||
import { useChat as useAiSdkChatBase } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport, type UIMessage } from "ai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { ChatMessageData } from "./components/ChatMessage/useChatMessage";
|
||||
|
||||
// Tool response types from the backend
|
||||
type OperationType =
|
||||
| "operation_started"
|
||||
| "operation_pending"
|
||||
| "operation_in_progress";
|
||||
|
||||
interface ToolOutputBase {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UseAiSdkChatOptions {
|
||||
sessionId: string | null;
|
||||
initialMessages?: UIMessage[];
|
||||
onOperationStarted?: () => void;
|
||||
onStreamingChange?: (isStreaming: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tool output from AI SDK message parts into ChatMessageData format
|
||||
*/
|
||||
function parseToolOutput(
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
output: unknown,
|
||||
): ChatMessageData | null {
|
||||
if (!output) return null;
|
||||
|
||||
let parsed: ToolOutputBase;
|
||||
try {
|
||||
parsed =
|
||||
typeof output === "string"
|
||||
? JSON.parse(output)
|
||||
: (output as ToolOutputBase);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = parsed.type;
|
||||
|
||||
// Handle operation status types
|
||||
if (
|
||||
type === "operation_started" ||
|
||||
type === "operation_pending" ||
|
||||
type === "operation_in_progress"
|
||||
) {
|
||||
return {
|
||||
type: type as OperationType,
|
||||
toolId: toolCallId,
|
||||
toolName: toolName,
|
||||
operationId: (parsed.operation_id as string) || undefined,
|
||||
message: (parsed.message as string) || undefined,
|
||||
timestamp: new Date(),
|
||||
} as ChatMessageData;
|
||||
}
|
||||
|
||||
// Handle agent carousel
|
||||
if (type === "agent_carousel" && Array.isArray(parsed.agents)) {
|
||||
return {
|
||||
type: "agent_carousel",
|
||||
toolId: toolCallId,
|
||||
toolName: toolName,
|
||||
agents: parsed.agents,
|
||||
timestamp: new Date(),
|
||||
} as ChatMessageData;
|
||||
}
|
||||
|
||||
// Handle execution started
|
||||
if (type === "execution_started") {
|
||||
return {
|
||||
type: "execution_started",
|
||||
toolId: toolCallId,
|
||||
toolName: toolName,
|
||||
graphId: parsed.graph_id as string,
|
||||
graphVersion: parsed.graph_version as number,
|
||||
graphExecId: parsed.graph_exec_id as string,
|
||||
nodeExecIds: parsed.node_exec_ids as string[],
|
||||
timestamp: new Date(),
|
||||
} as ChatMessageData;
|
||||
}
|
||||
|
||||
// Handle error responses
|
||||
if (type === "error") {
|
||||
return {
|
||||
type: "tool_response",
|
||||
toolId: toolCallId,
|
||||
toolName: toolName,
|
||||
result: parsed,
|
||||
success: false,
|
||||
timestamp: new Date(),
|
||||
} as ChatMessageData;
|
||||
}
|
||||
|
||||
// Handle clarification questions
|
||||
if (type === "clarification_questions" && Array.isArray(parsed.questions)) {
|
||||
return {
|
||||
type: "clarification_questions",
|
||||
toolId: toolCallId,
|
||||
toolName: toolName,
|
||||
questions: parsed.questions,
|
||||
timestamp: new Date(),
|
||||
} as ChatMessageData;
|
||||
}
|
||||
|
||||
// Handle credentials needed
|
||||
if (type === "credentials_needed" || type === "setup_requirements") {
|
||||
const credentials = parsed.credentials as
|
||||
| Array<{
|
||||
provider: string;
|
||||
provider_name: string;
|
||||
credential_type: string;
|
||||
scopes?: string[];
|
||||
}>
|
||||
| undefined;
|
||||
if (credentials && credentials.length > 0) {
|
||||
return {
|
||||
type: "credentials_needed",
|
||||
toolId: toolCallId,
|
||||
toolName: toolName,
|
||||
credentials: credentials,
|
||||
timestamp: new Date(),
|
||||
} as ChatMessageData;
|
||||
}
|
||||
}
|
||||
|
||||
// Default: generic tool response
|
||||
return {
|
||||
type: "tool_response",
|
||||
toolId: toolCallId,
|
||||
toolName: toolName,
|
||||
result: parsed,
|
||||
success: true,
|
||||
timestamp: new Date(),
|
||||
} as ChatMessageData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert AI SDK UIMessage parts to ChatMessageData array
|
||||
*/
|
||||
function convertMessageToChatData(message: UIMessage): ChatMessageData[] {
|
||||
const result: ChatMessageData[] = [];
|
||||
|
||||
for (const part of message.parts) {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
if (part.text.trim()) {
|
||||
result.push({
|
||||
type: "message",
|
||||
role: message.role as "user" | "assistant",
|
||||
content: part.text,
|
||||
timestamp: new Date(message.createdAt || Date.now()),
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Handle tool parts (tool-*)
|
||||
if (part.type.startsWith("tool-")) {
|
||||
const toolPart = part as {
|
||||
type: string;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
state: string;
|
||||
input?: Record<string, unknown>;
|
||||
output?: unknown;
|
||||
};
|
||||
|
||||
// Show tool call in progress
|
||||
if (
|
||||
toolPart.state === "input-streaming" ||
|
||||
toolPart.state === "input-available"
|
||||
) {
|
||||
result.push({
|
||||
type: "tool_call",
|
||||
toolId: toolPart.toolCallId,
|
||||
toolName: toolPart.toolName,
|
||||
arguments: toolPart.input || {},
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
// Parse tool output when available
|
||||
if (
|
||||
toolPart.state === "output-available" &&
|
||||
toolPart.output !== undefined
|
||||
) {
|
||||
const parsed = parseToolOutput(
|
||||
toolPart.toolCallId,
|
||||
toolPart.toolName,
|
||||
toolPart.output,
|
||||
);
|
||||
if (parsed) {
|
||||
result.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool errors
|
||||
if (toolPart.state === "output-error") {
|
||||
result.push({
|
||||
type: "tool_response",
|
||||
toolId: toolPart.toolCallId,
|
||||
toolName: toolPart.toolName,
|
||||
response: {
|
||||
type: "error",
|
||||
message: (toolPart as { errorText?: string }).errorText,
|
||||
},
|
||||
success: false,
|
||||
timestamp: new Date(),
|
||||
} as ChatMessageData);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function useAiSdkChat({
|
||||
sessionId,
|
||||
initialMessages = [],
|
||||
onOperationStarted,
|
||||
onStreamingChange,
|
||||
}: UseAiSdkChatOptions) {
|
||||
const [isRegionBlockedModalOpen, setIsRegionBlockedModalOpen] =
|
||||
useState(false);
|
||||
const previousSessionIdRef = useRef<string | null>(null);
|
||||
const hasNotifiedOperationRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Create transport with session-specific endpoint
|
||||
const transport = useMemo(() => {
|
||||
if (!sessionId) return undefined;
|
||||
return new DefaultChatTransport({
|
||||
api: `/api/chat/sessions/${sessionId}/stream`,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}, [sessionId]);
|
||||
|
||||
const {
|
||||
messages: aiMessages,
|
||||
status,
|
||||
error,
|
||||
stop,
|
||||
setMessages,
|
||||
sendMessage: aiSendMessage,
|
||||
} = useAiSdkChatBase({
|
||||
transport,
|
||||
initialMessages,
|
||||
onError: (err) => {
|
||||
console.error("[useAiSdkChat] Error:", err);
|
||||
|
||||
// Check for region blocking
|
||||
if (
|
||||
err.message?.toLowerCase().includes("not available in your region") ||
|
||||
(err as { code?: string }).code === "MODEL_NOT_AVAILABLE_REGION"
|
||||
) {
|
||||
setIsRegionBlockedModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Chat Error", {
|
||||
description: err.message || "An error occurred",
|
||||
});
|
||||
},
|
||||
onFinish: ({ message }) => {
|
||||
console.info("[useAiSdkChat] Message finished:", {
|
||||
id: message.id,
|
||||
partsCount: message.parts.length,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Track streaming status
|
||||
const isStreaming = status === "streaming" || status === "submitted";
|
||||
|
||||
// Notify parent of streaming changes
|
||||
useEffect(() => {
|
||||
onStreamingChange?.(isStreaming);
|
||||
}, [isStreaming, onStreamingChange]);
|
||||
|
||||
// Handle session changes - reset state
|
||||
useEffect(() => {
|
||||
if (sessionId === previousSessionIdRef.current) return;
|
||||
|
||||
if (previousSessionIdRef.current && status === "streaming") {
|
||||
stop();
|
||||
}
|
||||
|
||||
previousSessionIdRef.current = sessionId;
|
||||
hasNotifiedOperationRef.current = new Set();
|
||||
|
||||
if (sessionId) {
|
||||
setMessages(initialMessages);
|
||||
}
|
||||
}, [sessionId, status, stop, setMessages, initialMessages]);
|
||||
|
||||
// Convert AI SDK messages to ChatMessageData format
|
||||
const messages = useMemo(() => {
|
||||
const result: ChatMessageData[] = [];
|
||||
|
||||
for (const message of aiMessages) {
|
||||
const converted = convertMessageToChatData(message);
|
||||
result.push(...converted);
|
||||
|
||||
// Check for operation_started and notify
|
||||
for (const msg of converted) {
|
||||
if (
|
||||
msg.type === "operation_started" &&
|
||||
!hasNotifiedOperationRef.current.has(
|
||||
(msg as { toolId?: string }).toolId || "",
|
||||
)
|
||||
) {
|
||||
hasNotifiedOperationRef.current.add(
|
||||
(msg as { toolId?: string }).toolId || "",
|
||||
);
|
||||
onOperationStarted?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [aiMessages, onOperationStarted]);
|
||||
|
||||
// Get streaming text chunks from the last assistant message
|
||||
const streamingChunks = useMemo(() => {
|
||||
if (!isStreaming) return [];
|
||||
|
||||
const lastMessage = aiMessages[aiMessages.length - 1];
|
||||
if (!lastMessage || lastMessage.role !== "assistant") return [];
|
||||
|
||||
const chunks: string[] = [];
|
||||
for (const part of lastMessage.parts) {
|
||||
if (part.type === "text" && part.text) {
|
||||
chunks.push(part.text);
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}, [aiMessages, isStreaming]);
|
||||
|
||||
// Send message with optional context
|
||||
const sendMessage = useCallback(
|
||||
async (
|
||||
content: string,
|
||||
isUserMessage: boolean = true,
|
||||
context?: { url: string; content: string },
|
||||
) => {
|
||||
if (!sessionId || !transport) {
|
||||
console.error("[useAiSdkChat] Cannot send message: no session");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRegionBlockedModalOpen(false);
|
||||
|
||||
try {
|
||||
await aiSendMessage(
|
||||
{ text: content },
|
||||
{
|
||||
body: {
|
||||
is_user_message: isUserMessage,
|
||||
context: context || null,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[useAiSdkChat] Failed to send message:", err);
|
||||
|
||||
if (err instanceof Error && err.name === "AbortError") return;
|
||||
|
||||
toast.error("Failed to send message", {
|
||||
description:
|
||||
err instanceof Error ? err.message : "Failed to send message",
|
||||
});
|
||||
}
|
||||
},
|
||||
[sessionId, transport, aiSendMessage],
|
||||
);
|
||||
|
||||
// Stop streaming
|
||||
const stopStreaming = useCallback(() => {
|
||||
stop();
|
||||
}, [stop]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
streamingChunks,
|
||||
isStreaming,
|
||||
error,
|
||||
status,
|
||||
isRegionBlockedModalOpen,
|
||||
setIsRegionBlockedModalOpen,
|
||||
sendMessage,
|
||||
stopStreaming,
|
||||
// Expose raw AI SDK state for advanced use cases
|
||||
aiMessages,
|
||||
setAiMessages: setMessages,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user