mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
merge: resolve conflicts (keep both COPILOT_MODE and COPILOT_COMPLETED_SESSIONS)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,6 +17,7 @@ log-ingestion.txt
|
||||
/logs
|
||||
*.log
|
||||
*.mp3
|
||||
!autogpt_platform/frontend/public/notification.mp3
|
||||
mem.sqlite3
|
||||
venvAutoGPT
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Unit tests for baseline service pure-logic helpers.
|
||||
|
||||
These tests cover ``_baseline_conversation_updater`` and ``_BaselineStreamState``
|
||||
without requiring API keys, database connections, or network access.
|
||||
"""
|
||||
|
||||
from backend.copilot.baseline.service import (
|
||||
_BaselineStreamState,
|
||||
_baseline_conversation_updater,
|
||||
)
|
||||
from backend.copilot.transcript_builder import TranscriptBuilder
|
||||
from backend.util.tool_call_loop import LLMLoopResponse, LLMToolCall, ToolCallResult
|
||||
|
||||
|
||||
class TestBaselineStreamState:
|
||||
def test_defaults(self):
|
||||
state = _BaselineStreamState()
|
||||
assert state.pending_events == []
|
||||
assert state.assistant_text == ""
|
||||
assert state.text_started is False
|
||||
assert state.turn_prompt_tokens == 0
|
||||
assert state.turn_completion_tokens == 0
|
||||
assert state.text_block_id # Should be a UUID string
|
||||
|
||||
def test_mutable_fields(self):
|
||||
state = _BaselineStreamState()
|
||||
state.assistant_text = "hello"
|
||||
state.turn_prompt_tokens = 100
|
||||
state.turn_completion_tokens = 50
|
||||
assert state.assistant_text == "hello"
|
||||
assert state.turn_prompt_tokens == 100
|
||||
assert state.turn_completion_tokens == 50
|
||||
|
||||
|
||||
class TestBaselineConversationUpdater:
|
||||
"""Tests for _baseline_conversation_updater which updates the OpenAI
|
||||
message list and transcript builder after each LLM call."""
|
||||
|
||||
def _make_transcript_builder(self) -> TranscriptBuilder:
|
||||
builder = TranscriptBuilder()
|
||||
builder.append_user("test question")
|
||||
return builder
|
||||
|
||||
def test_text_only_response(self):
|
||||
"""When the LLM returns text without tool calls, the updater appends
|
||||
a single assistant message and records it in the transcript."""
|
||||
messages: list = []
|
||||
builder = self._make_transcript_builder()
|
||||
response = LLMLoopResponse(
|
||||
response_text="Hello, world!",
|
||||
tool_calls=[],
|
||||
raw_response=None,
|
||||
prompt_tokens=0,
|
||||
completion_tokens=0,
|
||||
)
|
||||
|
||||
_baseline_conversation_updater(
|
||||
messages,
|
||||
response,
|
||||
tool_results=None,
|
||||
transcript_builder=builder,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["role"] == "assistant"
|
||||
assert messages[0]["content"] == "Hello, world!"
|
||||
# Transcript should have user + assistant
|
||||
assert builder.entry_count == 2
|
||||
assert builder.last_entry_type == "assistant"
|
||||
|
||||
def test_tool_calls_response(self):
|
||||
"""When the LLM returns tool calls, the updater appends the assistant
|
||||
message with tool_calls and tool result messages."""
|
||||
messages: list = []
|
||||
builder = self._make_transcript_builder()
|
||||
response = LLMLoopResponse(
|
||||
response_text="Let me search...",
|
||||
tool_calls=[
|
||||
LLMToolCall(
|
||||
id="tc_1",
|
||||
name="search",
|
||||
arguments='{"query": "test"}',
|
||||
),
|
||||
],
|
||||
raw_response=None,
|
||||
prompt_tokens=0,
|
||||
completion_tokens=0,
|
||||
)
|
||||
tool_results = [
|
||||
ToolCallResult(
|
||||
tool_call_id="tc_1",
|
||||
tool_name="search",
|
||||
content="Found result",
|
||||
),
|
||||
]
|
||||
|
||||
_baseline_conversation_updater(
|
||||
messages,
|
||||
response,
|
||||
tool_results=tool_results,
|
||||
transcript_builder=builder,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
# Messages: assistant (with tool_calls) + tool result
|
||||
assert len(messages) == 2
|
||||
assert messages[0]["role"] == "assistant"
|
||||
assert messages[0]["content"] == "Let me search..."
|
||||
assert len(messages[0]["tool_calls"]) == 1
|
||||
assert messages[0]["tool_calls"][0]["id"] == "tc_1"
|
||||
assert messages[1]["role"] == "tool"
|
||||
assert messages[1]["tool_call_id"] == "tc_1"
|
||||
assert messages[1]["content"] == "Found result"
|
||||
|
||||
# Transcript: user + assistant(tool_use) + user(tool_result)
|
||||
assert builder.entry_count == 3
|
||||
|
||||
def test_tool_calls_without_text(self):
|
||||
"""Tool calls without accompanying text should still work."""
|
||||
messages: list = []
|
||||
builder = self._make_transcript_builder()
|
||||
response = LLMLoopResponse(
|
||||
response_text=None,
|
||||
tool_calls=[
|
||||
LLMToolCall(id="tc_1", name="run", arguments="{}"),
|
||||
],
|
||||
raw_response=None,
|
||||
prompt_tokens=0,
|
||||
completion_tokens=0,
|
||||
)
|
||||
tool_results = [
|
||||
ToolCallResult(
|
||||
tool_call_id="tc_1", tool_name="run", content="done"
|
||||
),
|
||||
]
|
||||
|
||||
_baseline_conversation_updater(
|
||||
messages,
|
||||
response,
|
||||
tool_results=tool_results,
|
||||
transcript_builder=builder,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
assert len(messages) == 2
|
||||
assert "content" not in messages[0] # No text content
|
||||
assert messages[0]["tool_calls"][0]["function"]["name"] == "run"
|
||||
|
||||
def test_no_text_no_tools(self):
|
||||
"""When the response has no text and no tool calls, nothing is appended."""
|
||||
messages: list = []
|
||||
builder = self._make_transcript_builder()
|
||||
response = LLMLoopResponse(
|
||||
response_text=None,
|
||||
tool_calls=[],
|
||||
raw_response=None,
|
||||
prompt_tokens=0,
|
||||
completion_tokens=0,
|
||||
)
|
||||
|
||||
_baseline_conversation_updater(
|
||||
messages,
|
||||
response,
|
||||
tool_results=None,
|
||||
transcript_builder=builder,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
assert len(messages) == 0
|
||||
# Only the user entry from setup
|
||||
assert builder.entry_count == 1
|
||||
|
||||
def test_multiple_tool_calls(self):
|
||||
"""Multiple tool calls in a single response are all recorded."""
|
||||
messages: list = []
|
||||
builder = self._make_transcript_builder()
|
||||
response = LLMLoopResponse(
|
||||
response_text=None,
|
||||
tool_calls=[
|
||||
LLMToolCall(id="tc_1", name="tool_a", arguments="{}"),
|
||||
LLMToolCall(id="tc_2", name="tool_b", arguments='{"x": 1}'),
|
||||
],
|
||||
raw_response=None,
|
||||
prompt_tokens=0,
|
||||
completion_tokens=0,
|
||||
)
|
||||
tool_results = [
|
||||
ToolCallResult(
|
||||
tool_call_id="tc_1", tool_name="tool_a", content="result_a"
|
||||
),
|
||||
ToolCallResult(
|
||||
tool_call_id="tc_2", tool_name="tool_b", content="result_b"
|
||||
),
|
||||
]
|
||||
|
||||
_baseline_conversation_updater(
|
||||
messages,
|
||||
response,
|
||||
tool_results=tool_results,
|
||||
transcript_builder=builder,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
# 1 assistant + 2 tool results
|
||||
assert len(messages) == 3
|
||||
assert len(messages[0]["tool_calls"]) == 2
|
||||
assert messages[1]["tool_call_id"] == "tc_1"
|
||||
assert messages[2]["tool_call_id"] == "tc_2"
|
||||
|
||||
def test_invalid_tool_arguments_handled(self):
|
||||
"""Tool call with invalid JSON arguments: the arguments field is
|
||||
stored as-is in the message, and orjson failure falls back to {}
|
||||
in the transcript content_blocks."""
|
||||
messages: list = []
|
||||
builder = self._make_transcript_builder()
|
||||
response = LLMLoopResponse(
|
||||
response_text=None,
|
||||
tool_calls=[
|
||||
LLMToolCall(id="tc_1", name="tool_x", arguments="not-json"),
|
||||
],
|
||||
raw_response=None,
|
||||
prompt_tokens=0,
|
||||
completion_tokens=0,
|
||||
)
|
||||
tool_results = [
|
||||
ToolCallResult(
|
||||
tool_call_id="tc_1", tool_name="tool_x", content="ok"
|
||||
),
|
||||
]
|
||||
|
||||
_baseline_conversation_updater(
|
||||
messages,
|
||||
response,
|
||||
tool_results=tool_results,
|
||||
transcript_builder=builder,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
# Should not raise — invalid JSON falls back to {} in transcript
|
||||
assert len(messages) == 2
|
||||
assert messages[0]["tool_calls"][0]["function"]["arguments"] == "not-json"
|
||||
@@ -28,23 +28,30 @@ Steps:
|
||||
|
||||
### Workflow for Creating/Editing Agents
|
||||
|
||||
1. **Discover blocks**: Call `find_block(query, include_schemas=true)` to
|
||||
1. **If editing**: First narrow to the specific agent by UUID, then fetch its
|
||||
graph: `find_library_agent(query="<agent_id>", include_graph=true)`. This
|
||||
returns the full graph structure (nodes + links). **Never edit blindly** —
|
||||
always inspect the current graph first so you know exactly what to change.
|
||||
Avoid using `include_graph=true` with broad keyword searches, as fetching
|
||||
multiple graphs at once is expensive and consumes LLM context budget.
|
||||
2. **Discover blocks**: Call `find_block(query, include_schemas=true)` to
|
||||
search for relevant blocks. This returns block IDs, names, descriptions,
|
||||
and full input/output schemas.
|
||||
2. **Find library agents**: Call `find_library_agent` to discover reusable
|
||||
3. **Find library agents**: Call `find_library_agent` to discover reusable
|
||||
agents that can be composed as sub-agents via `AgentExecutorBlock`.
|
||||
3. **Generate JSON**: Build the agent JSON using block schemas:
|
||||
- Use block IDs from step 1 as `block_id` in nodes
|
||||
4. **Generate/modify JSON**: Build or modify the agent JSON using block schemas:
|
||||
- Use block IDs from step 2 as `block_id` in nodes
|
||||
- Wire outputs to inputs using links
|
||||
- Set design-time config in `input_default`
|
||||
- Use `AgentInputBlock` for values the user provides at runtime
|
||||
4. **Write to workspace**: Save the JSON to a workspace file so the user
|
||||
- When editing, apply targeted changes and preserve unchanged parts
|
||||
5. **Write to workspace**: Save the JSON to a workspace file so the user
|
||||
can review it: `write_workspace_file(filename="agent.json", content=...)`
|
||||
5. **Validate**: Call `validate_agent_graph` with the agent JSON to check
|
||||
6. **Validate**: Call `validate_agent_graph` with the agent JSON to check
|
||||
for errors
|
||||
6. **Fix if needed**: Call `fix_agent_graph` to auto-fix common issues,
|
||||
7. **Fix if needed**: Call `fix_agent_graph` to auto-fix common issues,
|
||||
or fix manually based on the error descriptions. Iterate until valid.
|
||||
7. **Save**: Call `create_agent` (new) or `edit_agent` (existing) with
|
||||
8. **Save**: Call `create_agent` (new) or `edit_agent` (existing) with
|
||||
the final `agent_json`
|
||||
|
||||
### Agent JSON Structure
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
@@ -9,7 +10,7 @@ if TYPE_CHECKING:
|
||||
from backend.api.features.library.model import LibraryAgent
|
||||
from backend.api.features.store.model import StoreAgent, StoreAgentDetails
|
||||
|
||||
from backend.data.db_accessors import library_db, store_db
|
||||
from backend.data.db_accessors import graph_db, library_db, store_db
|
||||
from backend.util.exceptions import DatabaseError, NotFoundError
|
||||
|
||||
from .models import (
|
||||
@@ -34,12 +35,13 @@ async def search_agents(
|
||||
source: SearchSource,
|
||||
session_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
include_graph: bool = False,
|
||||
) -> ToolResponseBase:
|
||||
"""Search for agents in marketplace or user library."""
|
||||
if source == "marketplace":
|
||||
return await _search_marketplace(query, session_id)
|
||||
else:
|
||||
return await _search_library(query, session_id, user_id)
|
||||
return await _search_library(query, session_id, user_id, include_graph)
|
||||
|
||||
|
||||
async def _search_marketplace(query: str, session_id: str | None) -> ToolResponseBase:
|
||||
@@ -105,7 +107,10 @@ async def _search_marketplace(query: str, session_id: str | None) -> ToolRespons
|
||||
|
||||
|
||||
async def _search_library(
|
||||
query: str, session_id: str | None, user_id: str | None
|
||||
query: str,
|
||||
session_id: str | None,
|
||||
user_id: str | None,
|
||||
include_graph: bool = False,
|
||||
) -> ToolResponseBase:
|
||||
"""Search user's library agents, with direct UUID lookup fallback."""
|
||||
if not user_id:
|
||||
@@ -149,6 +154,10 @@ async def _search_library(
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
truncation_notice: str | None = None
|
||||
if include_graph and agents:
|
||||
truncation_notice = await _enrich_agents_with_graph(agents, user_id)
|
||||
|
||||
if not agents:
|
||||
if not query:
|
||||
return NoResultsResponse(
|
||||
@@ -182,13 +191,17 @@ async def _search_library(
|
||||
else:
|
||||
title = f"Found {len(agents)} agent{'s' if len(agents) != 1 else ''} in your library for '{query}'"
|
||||
|
||||
message = (
|
||||
"Found agents in the user's library. You can provide a link to view "
|
||||
"an agent at: /library/agents/{agent_id}. Use agent_output to get "
|
||||
"execution results, or run_agent to execute. Let the user know we can "
|
||||
"create a custom agent for them based on their needs."
|
||||
)
|
||||
if truncation_notice:
|
||||
message = f"{message}\n\nNote: {truncation_notice}"
|
||||
|
||||
return AgentsFoundResponse(
|
||||
message=(
|
||||
"Found agents in the user's library. You can provide a link to view "
|
||||
"an agent at: /library/agents/{agent_id}. Use agent_output to get "
|
||||
"execution results, or run_agent to execute. Let the user know we can "
|
||||
"create a custom agent for them based on their needs."
|
||||
),
|
||||
message=message,
|
||||
title=title,
|
||||
agents=agents,
|
||||
count=len(agents),
|
||||
@@ -196,6 +209,81 @@ async def _search_library(
|
||||
)
|
||||
|
||||
|
||||
_MAX_GRAPH_FETCHES = 10
|
||||
|
||||
|
||||
_GRAPH_FETCH_TIMEOUT = 15 # seconds
|
||||
|
||||
|
||||
async def _enrich_agents_with_graph(
|
||||
agents: list[AgentInfo], user_id: str
|
||||
) -> str | None:
|
||||
"""Fetch and attach full Graph (nodes + links) to each agent in-place.
|
||||
|
||||
Only the first ``_MAX_GRAPH_FETCHES`` agents with a ``graph_id`` are
|
||||
enriched. If some agents are skipped, a truncation notice is returned
|
||||
so the caller can surface it to the copilot.
|
||||
|
||||
Graphs are fetched with ``for_export=True`` so that credentials, API keys,
|
||||
and other secrets in ``input_default`` are stripped before the data reaches
|
||||
the LLM context.
|
||||
|
||||
Returns a truncation notice string when some agents were skipped, or
|
||||
``None`` when all eligible agents were enriched.
|
||||
"""
|
||||
with_graph_id = [a for a in agents if a.graph_id]
|
||||
fetchable = with_graph_id[:_MAX_GRAPH_FETCHES]
|
||||
if not fetchable:
|
||||
return None
|
||||
|
||||
gdb = graph_db()
|
||||
|
||||
async def _fetch(agent: AgentInfo) -> None:
|
||||
graph_id = agent.graph_id
|
||||
if not graph_id:
|
||||
return
|
||||
try:
|
||||
graph = await gdb.get_graph(
|
||||
graph_id,
|
||||
version=agent.graph_version,
|
||||
user_id=user_id,
|
||||
for_export=True,
|
||||
)
|
||||
if graph is None:
|
||||
logger.warning("Graph not found for agent %s", graph_id)
|
||||
agent.graph = graph
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch graph for agent %s: %s", graph_id, e)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*[_fetch(a) for a in fetchable]),
|
||||
timeout=_GRAPH_FETCH_TIMEOUT,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"include_graph: timed out after %ds fetching graphs", _GRAPH_FETCH_TIMEOUT
|
||||
)
|
||||
|
||||
skipped = len(with_graph_id) - len(fetchable)
|
||||
if skipped > 0:
|
||||
logger.warning(
|
||||
"include_graph: fetched graphs for %d/%d agents "
|
||||
"(_MAX_GRAPH_FETCHES=%d, %d skipped)",
|
||||
len(fetchable),
|
||||
len(with_graph_id),
|
||||
_MAX_GRAPH_FETCHES,
|
||||
skipped,
|
||||
)
|
||||
return (
|
||||
f"Graph data included for {len(fetchable)} of "
|
||||
f"{len(with_graph_id)} eligible agents (limit: {_MAX_GRAPH_FETCHES}). "
|
||||
f"To fetch graphs for remaining agents, narrow your search to a "
|
||||
f"specific agent by UUID."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _marketplace_agent_to_info(agent: StoreAgent | StoreAgentDetails) -> AgentInfo:
|
||||
"""Convert a marketplace agent (StoreAgent or StoreAgentDetails) to an AgentInfo."""
|
||||
return AgentInfo(
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""Tests for agent search direct lookup functionality."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from .agent_search import search_agents
|
||||
from .models import AgentsFoundResponse, NoResultsResponse
|
||||
from .agent_search import _enrich_agents_with_graph, search_agents
|
||||
from .models import AgentInfo, AgentsFoundResponse, NoResultsResponse
|
||||
|
||||
_TEST_USER_ID = "test-user-agent-search"
|
||||
|
||||
@@ -133,10 +134,10 @@ class TestMarketplaceSlugLookup:
|
||||
class TestLibraryUUIDLookup:
|
||||
"""Tests for UUID direct lookup in library search."""
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_uuid_lookup_found_by_graph_id(self):
|
||||
"""UUID query matching a graph_id returns the agent directly."""
|
||||
agent_id = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
|
||||
@staticmethod
|
||||
def _make_mock_library_agent(
|
||||
agent_id: str = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
|
||||
) -> MagicMock:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.id = "lib-agent-id"
|
||||
mock_agent.name = "My Library Agent"
|
||||
@@ -150,6 +151,13 @@ class TestLibraryUUIDLookup:
|
||||
mock_agent.graph_version = 1
|
||||
mock_agent.input_schema = {}
|
||||
mock_agent.output_schema = {}
|
||||
return mock_agent
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_uuid_lookup_found_by_graph_id(self):
|
||||
"""UUID query matching a graph_id returns the agent directly."""
|
||||
agent_id = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
|
||||
mock_agent = self._make_mock_library_agent(agent_id)
|
||||
|
||||
mock_lib_db = MagicMock()
|
||||
mock_lib_db.get_library_agent_by_graph_id = AsyncMock(return_value=mock_agent)
|
||||
@@ -168,3 +176,427 @@ class TestLibraryUUIDLookup:
|
||||
assert isinstance(response, AgentsFoundResponse)
|
||||
assert response.count == 1
|
||||
assert response.agents[0].name == "My Library Agent"
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_include_graph_fetches_graph(self):
|
||||
"""include_graph=True attaches BaseGraph to agent results."""
|
||||
from backend.data.graph import BaseGraph
|
||||
|
||||
agent_id = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
|
||||
mock_agent = self._make_mock_library_agent(agent_id)
|
||||
mock_lib_db = MagicMock()
|
||||
mock_lib_db.get_library_agent_by_graph_id = AsyncMock(return_value=mock_agent)
|
||||
|
||||
fake_graph = BaseGraph(id=agent_id, name="My Library Agent", description="test")
|
||||
mock_graph_db = MagicMock()
|
||||
mock_graph_db.get_graph = AsyncMock(return_value=fake_graph)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.library_db",
|
||||
return_value=mock_lib_db,
|
||||
),
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.graph_db",
|
||||
return_value=mock_graph_db,
|
||||
),
|
||||
):
|
||||
response = await search_agents(
|
||||
query=agent_id,
|
||||
source="library",
|
||||
session_id="s",
|
||||
user_id=_TEST_USER_ID,
|
||||
include_graph=True,
|
||||
)
|
||||
|
||||
assert isinstance(response, AgentsFoundResponse)
|
||||
assert response.agents[0].graph is not None
|
||||
assert response.agents[0].graph.id == agent_id
|
||||
mock_graph_db.get_graph.assert_awaited_once_with(
|
||||
agent_id,
|
||||
version=1,
|
||||
user_id=_TEST_USER_ID,
|
||||
for_export=True,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_include_graph_false_skips_fetch(self):
|
||||
"""include_graph=False (default) does not fetch graph data."""
|
||||
agent_id = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
|
||||
mock_agent = self._make_mock_library_agent(agent_id)
|
||||
mock_lib_db = MagicMock()
|
||||
mock_lib_db.get_library_agent_by_graph_id = AsyncMock(return_value=mock_agent)
|
||||
|
||||
mock_graph_db = MagicMock()
|
||||
mock_graph_db.get_graph = AsyncMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.library_db",
|
||||
return_value=mock_lib_db,
|
||||
),
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.graph_db",
|
||||
return_value=mock_graph_db,
|
||||
),
|
||||
):
|
||||
response = await search_agents(
|
||||
query=agent_id,
|
||||
source="library",
|
||||
session_id="s",
|
||||
user_id=_TEST_USER_ID,
|
||||
include_graph=False,
|
||||
)
|
||||
|
||||
assert isinstance(response, AgentsFoundResponse)
|
||||
assert response.agents[0].graph is None
|
||||
mock_graph_db.get_graph.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_include_graph_handles_fetch_failure(self):
|
||||
"""include_graph=True still returns agents when graph fetch fails."""
|
||||
agent_id = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
|
||||
mock_agent = self._make_mock_library_agent(agent_id)
|
||||
mock_lib_db = MagicMock()
|
||||
mock_lib_db.get_library_agent_by_graph_id = AsyncMock(return_value=mock_agent)
|
||||
|
||||
mock_graph_db = MagicMock()
|
||||
mock_graph_db.get_graph = AsyncMock(side_effect=Exception("DB down"))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.library_db",
|
||||
return_value=mock_lib_db,
|
||||
),
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.graph_db",
|
||||
return_value=mock_graph_db,
|
||||
),
|
||||
):
|
||||
response = await search_agents(
|
||||
query=agent_id,
|
||||
source="library",
|
||||
session_id="s",
|
||||
user_id=_TEST_USER_ID,
|
||||
include_graph=True,
|
||||
)
|
||||
|
||||
assert isinstance(response, AgentsFoundResponse)
|
||||
assert response.agents[0].graph is None
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_include_graph_handles_none_return(self):
|
||||
"""include_graph=True handles get_graph returning None."""
|
||||
agent_id = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
|
||||
mock_agent = self._make_mock_library_agent(agent_id)
|
||||
mock_lib_db = MagicMock()
|
||||
mock_lib_db.get_library_agent_by_graph_id = AsyncMock(return_value=mock_agent)
|
||||
|
||||
mock_graph_db = MagicMock()
|
||||
mock_graph_db.get_graph = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.library_db",
|
||||
return_value=mock_lib_db,
|
||||
),
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.graph_db",
|
||||
return_value=mock_graph_db,
|
||||
),
|
||||
):
|
||||
response = await search_agents(
|
||||
query=agent_id,
|
||||
source="library",
|
||||
session_id="s",
|
||||
user_id=_TEST_USER_ID,
|
||||
include_graph=True,
|
||||
)
|
||||
|
||||
assert isinstance(response, AgentsFoundResponse)
|
||||
assert response.agents[0].graph is None
|
||||
|
||||
|
||||
class TestEnrichAgentsWithGraph:
|
||||
"""Tests for _enrich_agents_with_graph edge cases."""
|
||||
|
||||
@staticmethod
|
||||
def _make_mock_library_agent(
|
||||
agent_id: str = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
|
||||
graph_id: str | None = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
|
||||
) -> MagicMock:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.id = f"lib-{agent_id[:8]}"
|
||||
mock_agent.name = f"Agent {agent_id[:8]}"
|
||||
mock_agent.description = "A library agent"
|
||||
mock_agent.creator_name = "testuser"
|
||||
mock_agent.status.value = "HEALTHY"
|
||||
mock_agent.can_access_graph = True
|
||||
mock_agent.has_external_trigger = False
|
||||
mock_agent.new_output = False
|
||||
mock_agent.graph_id = graph_id
|
||||
mock_agent.graph_version = 1
|
||||
mock_agent.input_schema = {}
|
||||
mock_agent.output_schema = {}
|
||||
return mock_agent
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_truncation_surfaces_in_response(self):
|
||||
"""When >_MAX_GRAPH_FETCHES agents have graphs, the response contains a truncation notice."""
|
||||
from backend.copilot.tools.agent_search import _MAX_GRAPH_FETCHES
|
||||
from backend.data.graph import BaseGraph
|
||||
|
||||
agent_count = _MAX_GRAPH_FETCHES + 5
|
||||
mock_agents = []
|
||||
for i in range(agent_count):
|
||||
uid = f"a1b2c3d4-e5f6-4a7b-8c9d-{i:012d}"
|
||||
mock_agents.append(self._make_mock_library_agent(uid, uid))
|
||||
|
||||
mock_lib_db = MagicMock()
|
||||
mock_search_results = MagicMock()
|
||||
mock_search_results.agents = mock_agents
|
||||
mock_lib_db.list_library_agents = AsyncMock(return_value=mock_search_results)
|
||||
|
||||
fake_graph = BaseGraph(id="x", name="g", description="d")
|
||||
mock_gdb = MagicMock()
|
||||
mock_gdb.get_graph = AsyncMock(return_value=fake_graph)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.library_db",
|
||||
return_value=mock_lib_db,
|
||||
),
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.graph_db",
|
||||
return_value=mock_gdb,
|
||||
),
|
||||
):
|
||||
response = await search_agents(
|
||||
query="",
|
||||
source="library",
|
||||
session_id="s",
|
||||
user_id=_TEST_USER_ID,
|
||||
include_graph=True,
|
||||
)
|
||||
|
||||
assert isinstance(response, AgentsFoundResponse)
|
||||
assert mock_gdb.get_graph.await_count == _MAX_GRAPH_FETCHES
|
||||
enriched = [a for a in response.agents if a.graph is not None]
|
||||
assert len(enriched) == _MAX_GRAPH_FETCHES
|
||||
assert "Graph data included for" in response.message
|
||||
assert str(_MAX_GRAPH_FETCHES) in response.message
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_mixed_graph_id_presence(self):
|
||||
"""Agents without graph_id are skipped during enrichment."""
|
||||
from backend.data.graph import BaseGraph
|
||||
|
||||
agent_with = self._make_mock_library_agent(
|
||||
"aaaa0000-0000-0000-0000-000000000001",
|
||||
"aaaa0000-0000-0000-0000-000000000001",
|
||||
)
|
||||
agent_without = self._make_mock_library_agent(
|
||||
"bbbb0000-0000-0000-0000-000000000002",
|
||||
graph_id=None,
|
||||
)
|
||||
|
||||
mock_lib_db = MagicMock()
|
||||
mock_search_results = MagicMock()
|
||||
mock_search_results.agents = [agent_with, agent_without]
|
||||
mock_lib_db.list_library_agents = AsyncMock(return_value=mock_search_results)
|
||||
|
||||
fake_graph = BaseGraph(
|
||||
id="aaaa0000-0000-0000-0000-000000000001", name="g", description="d"
|
||||
)
|
||||
mock_gdb = MagicMock()
|
||||
mock_gdb.get_graph = AsyncMock(return_value=fake_graph)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.library_db",
|
||||
return_value=mock_lib_db,
|
||||
),
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.graph_db",
|
||||
return_value=mock_gdb,
|
||||
),
|
||||
):
|
||||
response = await search_agents(
|
||||
query="",
|
||||
source="library",
|
||||
session_id="s",
|
||||
user_id=_TEST_USER_ID,
|
||||
include_graph=True,
|
||||
)
|
||||
|
||||
assert isinstance(response, AgentsFoundResponse)
|
||||
assert len(response.agents) == 2
|
||||
assert response.agents[0].graph is not None
|
||||
assert response.agents[1].graph is None
|
||||
mock_gdb.get_graph.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_partial_failure_across_multiple_agents(self):
|
||||
"""When some graph fetches fail, successful ones still have graphs attached."""
|
||||
from backend.data.graph import BaseGraph
|
||||
|
||||
id_ok = "aaaa0000-0000-0000-0000-000000000001"
|
||||
id_fail = "bbbb0000-0000-0000-0000-000000000002"
|
||||
agent_ok = self._make_mock_library_agent(id_ok, id_ok)
|
||||
agent_fail = self._make_mock_library_agent(id_fail, id_fail)
|
||||
|
||||
mock_lib_db = MagicMock()
|
||||
mock_search_results = MagicMock()
|
||||
mock_search_results.agents = [agent_ok, agent_fail]
|
||||
mock_lib_db.list_library_agents = AsyncMock(return_value=mock_search_results)
|
||||
|
||||
fake_graph = BaseGraph(id=id_ok, name="g", description="d")
|
||||
|
||||
async def _side_effect(graph_id, **kwargs):
|
||||
if graph_id == id_fail:
|
||||
raise Exception("DB error")
|
||||
return fake_graph
|
||||
|
||||
mock_gdb = MagicMock()
|
||||
mock_gdb.get_graph = AsyncMock(side_effect=_side_effect)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.library_db",
|
||||
return_value=mock_lib_db,
|
||||
),
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.graph_db",
|
||||
return_value=mock_gdb,
|
||||
),
|
||||
):
|
||||
response = await search_agents(
|
||||
query="",
|
||||
source="library",
|
||||
session_id="s",
|
||||
user_id=_TEST_USER_ID,
|
||||
include_graph=True,
|
||||
)
|
||||
|
||||
assert isinstance(response, AgentsFoundResponse)
|
||||
assert response.agents[0].graph is not None
|
||||
assert response.agents[0].graph.id == id_ok
|
||||
assert response.agents[1].graph is None
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_keyword_search_with_include_graph(self):
|
||||
"""include_graph works via keyword search (non-UUID path)."""
|
||||
from backend.data.graph import BaseGraph
|
||||
|
||||
agent_id = "cccc0000-0000-0000-0000-000000000003"
|
||||
mock_agent = self._make_mock_library_agent(agent_id, agent_id)
|
||||
|
||||
mock_lib_db = MagicMock()
|
||||
mock_search_results = MagicMock()
|
||||
mock_search_results.agents = [mock_agent]
|
||||
mock_lib_db.list_library_agents = AsyncMock(return_value=mock_search_results)
|
||||
|
||||
fake_graph = BaseGraph(id=agent_id, name="g", description="d")
|
||||
mock_gdb = MagicMock()
|
||||
mock_gdb.get_graph = AsyncMock(return_value=fake_graph)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.library_db",
|
||||
return_value=mock_lib_db,
|
||||
),
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.graph_db",
|
||||
return_value=mock_gdb,
|
||||
),
|
||||
):
|
||||
response = await search_agents(
|
||||
query="email",
|
||||
source="library",
|
||||
session_id="s",
|
||||
user_id=_TEST_USER_ID,
|
||||
include_graph=True,
|
||||
)
|
||||
|
||||
assert isinstance(response, AgentsFoundResponse)
|
||||
assert response.agents[0].graph is not None
|
||||
assert response.agents[0].graph.id == agent_id
|
||||
mock_gdb.get_graph.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_timeout_preserves_successful_fetches(self):
|
||||
"""On timeout, agents that already fetched their graph keep the result."""
|
||||
fast_agent = AgentInfo(
|
||||
id="a1",
|
||||
name="Fast",
|
||||
description="d",
|
||||
source="library",
|
||||
graph_id="fast-graph",
|
||||
)
|
||||
slow_agent = AgentInfo(
|
||||
id="a2",
|
||||
name="Slow",
|
||||
description="d",
|
||||
source="library",
|
||||
graph_id="slow-graph",
|
||||
)
|
||||
fake_graph = MagicMock()
|
||||
fake_graph.id = "graph-1"
|
||||
|
||||
async def mock_get_graph(
|
||||
graph_id, *, version=None, user_id=None, for_export=False
|
||||
):
|
||||
if graph_id == "fast-graph":
|
||||
return fake_graph
|
||||
await asyncio.sleep(999)
|
||||
return MagicMock()
|
||||
|
||||
mock_gdb = MagicMock()
|
||||
mock_gdb.get_graph = AsyncMock(side_effect=mock_get_graph)
|
||||
|
||||
with (
|
||||
patch("backend.copilot.tools.agent_search.graph_db", return_value=mock_gdb),
|
||||
patch("backend.copilot.tools.agent_search._GRAPH_FETCH_TIMEOUT", 0.1),
|
||||
):
|
||||
await _enrich_agents_with_graph([fast_agent, slow_agent], _TEST_USER_ID)
|
||||
|
||||
assert fast_agent.graph is fake_graph
|
||||
assert slow_agent.graph is None
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_enrich_success(self):
|
||||
"""All agents get their graphs when no timeout occurs."""
|
||||
agent = AgentInfo(
|
||||
id="a1", name="Test", description="d", source="library", graph_id="g1"
|
||||
)
|
||||
fake_graph = MagicMock()
|
||||
fake_graph.id = "graph-1"
|
||||
|
||||
mock_gdb = MagicMock()
|
||||
mock_gdb.get_graph = AsyncMock(return_value=fake_graph)
|
||||
|
||||
with patch(
|
||||
"backend.copilot.tools.agent_search.graph_db", return_value=mock_gdb
|
||||
):
|
||||
result = await _enrich_agents_with_graph([agent], _TEST_USER_ID)
|
||||
|
||||
assert agent.graph is fake_graph
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_enrich_skips_agents_without_graph_id(self):
|
||||
"""Agents without graph_id are not fetched."""
|
||||
agent_no_id = AgentInfo(
|
||||
id="a1", name="Test", description="d", source="library", graph_id=None
|
||||
)
|
||||
|
||||
mock_gdb = MagicMock()
|
||||
mock_gdb.get_graph = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"backend.copilot.tools.agent_search.graph_db", return_value=mock_gdb
|
||||
):
|
||||
result = await _enrich_agents_with_graph([agent_no_id], _TEST_USER_ID)
|
||||
|
||||
mock_gdb.get_graph.assert_not_called()
|
||||
assert result is None
|
||||
|
||||
@@ -20,7 +20,8 @@ class FindLibraryAgentTool(BaseTool):
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Search user's library agents. Returns graph_id, schemas for sub-agent composition. "
|
||||
"Omit query to list all."
|
||||
"Omit query to list all. Set include_graph=true to also fetch the full "
|
||||
"graph structure (nodes + links) for debugging or editing."
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -32,6 +33,15 @@ class FindLibraryAgentTool(BaseTool):
|
||||
"type": "string",
|
||||
"description": "Search by name/description. Omit to list all.",
|
||||
},
|
||||
"include_graph": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"When true, includes the full graph structure "
|
||||
"(nodes + links) for each found agent. "
|
||||
"Use when you need to inspect, debug, or edit an agent."
|
||||
),
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
@@ -45,6 +55,7 @@ class FindLibraryAgentTool(BaseTool):
|
||||
user_id: str | None,
|
||||
session: ChatSession,
|
||||
query: str = "",
|
||||
include_graph: bool = False,
|
||||
**kwargs,
|
||||
) -> ToolResponseBase:
|
||||
return await search_agents(
|
||||
@@ -52,4 +63,5 @@ class FindLibraryAgentTool(BaseTool):
|
||||
source="library",
|
||||
session_id=session.session_id,
|
||||
user_id=user_id,
|
||||
include_graph=include_graph,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.data.graph import BaseGraph
|
||||
from backend.data.model import CredentialsMetaInput
|
||||
|
||||
|
||||
@@ -122,6 +123,10 @@ class AgentInfo(BaseModel):
|
||||
default=None,
|
||||
description="Input schema for the agent, including field names, types, and defaults",
|
||||
)
|
||||
graph: BaseGraph | None = Field(
|
||||
default=None,
|
||||
description="Full graph structure (nodes + links) when include_graph is requested",
|
||||
)
|
||||
|
||||
|
||||
class AgentsFoundResponse(ToolResponseBase):
|
||||
|
||||
@@ -90,11 +90,12 @@ async def test_simulate_block_basic():
|
||||
|
||||
with patch(
|
||||
"backend.executor.simulator.get_openai_client", return_value=mock_client
|
||||
):
|
||||
) as mock_get_client:
|
||||
outputs = []
|
||||
async for name, data in simulate_block(mock_block, {"query": "test"}):
|
||||
outputs.append((name, data))
|
||||
|
||||
mock_get_client.assert_called_once_with(prefer_openrouter=True)
|
||||
assert ("result", "simulated output") in outputs
|
||||
# Empty error pin should NOT be yielded — the simulator omits empty values
|
||||
assert ("error", "") not in outputs
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Tests for canonical TranscriptBuilder (backend.copilot.transcript_builder).
|
||||
|
||||
These tests directly import from the canonical module to ensure codecov
|
||||
patch coverage for the new file.
|
||||
"""
|
||||
|
||||
from backend.copilot.transcript_builder import TranscriptBuilder, TranscriptEntry
|
||||
from backend.util import json
|
||||
|
||||
|
||||
def _make_jsonl(*entries: dict) -> str:
|
||||
return "\n".join(json.dumps(e) for e in entries) + "\n"
|
||||
|
||||
|
||||
USER_MSG = {
|
||||
"type": "user",
|
||||
"uuid": "u1",
|
||||
"message": {"role": "user", "content": "hello"},
|
||||
}
|
||||
ASST_MSG = {
|
||||
"type": "assistant",
|
||||
"uuid": "a1",
|
||||
"parentUuid": "u1",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"content": [{"type": "text", "text": "hi"}],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestTranscriptEntry:
|
||||
def test_basic_construction(self):
|
||||
entry = TranscriptEntry(
|
||||
type="user", uuid="u1", message={"role": "user", "content": "hi"}
|
||||
)
|
||||
assert entry.type == "user"
|
||||
assert entry.uuid == "u1"
|
||||
assert entry.parentUuid == ""
|
||||
assert entry.isCompactSummary is None
|
||||
|
||||
def test_optional_fields(self):
|
||||
entry = TranscriptEntry(
|
||||
type="summary",
|
||||
uuid="s1",
|
||||
parentUuid="p1",
|
||||
isCompactSummary=True,
|
||||
message={"role": "user", "content": "summary"},
|
||||
)
|
||||
assert entry.isCompactSummary is True
|
||||
assert entry.parentUuid == "p1"
|
||||
|
||||
|
||||
class TestTranscriptBuilderInit:
|
||||
def test_starts_empty(self):
|
||||
builder = TranscriptBuilder()
|
||||
assert builder.is_empty
|
||||
assert builder.entry_count == 0
|
||||
assert builder.last_entry_type is None
|
||||
assert builder.to_jsonl() == ""
|
||||
|
||||
|
||||
class TestAppendUser:
|
||||
def test_appends_user_entry(self):
|
||||
builder = TranscriptBuilder()
|
||||
builder.append_user("hello")
|
||||
assert builder.entry_count == 1
|
||||
assert builder.last_entry_type == "user"
|
||||
|
||||
def test_chains_parent_uuid(self):
|
||||
builder = TranscriptBuilder()
|
||||
builder.append_user("first", uuid="u1")
|
||||
builder.append_user("second", uuid="u2")
|
||||
output = builder.to_jsonl()
|
||||
entries = [json.loads(line) for line in output.strip().split("\n")]
|
||||
assert entries[0]["parentUuid"] == ""
|
||||
assert entries[1]["parentUuid"] == "u1"
|
||||
|
||||
def test_custom_uuid(self):
|
||||
builder = TranscriptBuilder()
|
||||
builder.append_user("hello", uuid="custom-id")
|
||||
output = builder.to_jsonl()
|
||||
entry = json.loads(output.strip())
|
||||
assert entry["uuid"] == "custom-id"
|
||||
|
||||
|
||||
class TestAppendToolResult:
|
||||
def test_appends_as_user_entry(self):
|
||||
builder = TranscriptBuilder()
|
||||
builder.append_tool_result(tool_use_id="tc_1", content="result text")
|
||||
assert builder.entry_count == 1
|
||||
assert builder.last_entry_type == "user"
|
||||
output = builder.to_jsonl()
|
||||
entry = json.loads(output.strip())
|
||||
content = entry["message"]["content"]
|
||||
assert len(content) == 1
|
||||
assert content[0]["type"] == "tool_result"
|
||||
assert content[0]["tool_use_id"] == "tc_1"
|
||||
assert content[0]["content"] == "result text"
|
||||
|
||||
|
||||
class TestAppendAssistant:
|
||||
def test_appends_assistant_entry(self):
|
||||
builder = TranscriptBuilder()
|
||||
builder.append_user("hi")
|
||||
builder.append_assistant(
|
||||
content_blocks=[{"type": "text", "text": "hello"}],
|
||||
model="test-model",
|
||||
stop_reason="end_turn",
|
||||
)
|
||||
assert builder.entry_count == 2
|
||||
assert builder.last_entry_type == "assistant"
|
||||
|
||||
def test_consecutive_assistants_share_message_id(self):
|
||||
builder = TranscriptBuilder()
|
||||
builder.append_user("hi")
|
||||
builder.append_assistant(
|
||||
content_blocks=[{"type": "text", "text": "part 1"}],
|
||||
model="m",
|
||||
)
|
||||
builder.append_assistant(
|
||||
content_blocks=[{"type": "text", "text": "part 2"}],
|
||||
model="m",
|
||||
)
|
||||
output = builder.to_jsonl()
|
||||
entries = [json.loads(line) for line in output.strip().split("\n")]
|
||||
# The two assistant entries share the same message ID
|
||||
assert entries[1]["message"]["id"] == entries[2]["message"]["id"]
|
||||
|
||||
def test_non_consecutive_assistants_get_different_ids(self):
|
||||
builder = TranscriptBuilder()
|
||||
builder.append_user("q1")
|
||||
builder.append_assistant(
|
||||
content_blocks=[{"type": "text", "text": "a1"}],
|
||||
model="m",
|
||||
)
|
||||
builder.append_user("q2")
|
||||
builder.append_assistant(
|
||||
content_blocks=[{"type": "text", "text": "a2"}],
|
||||
model="m",
|
||||
)
|
||||
output = builder.to_jsonl()
|
||||
entries = [json.loads(line) for line in output.strip().split("\n")]
|
||||
assert entries[1]["message"]["id"] != entries[3]["message"]["id"]
|
||||
|
||||
|
||||
class TestLoadPrevious:
|
||||
def test_loads_valid_entries(self):
|
||||
content = _make_jsonl(USER_MSG, ASST_MSG)
|
||||
builder = TranscriptBuilder()
|
||||
builder.load_previous(content)
|
||||
assert builder.entry_count == 2
|
||||
|
||||
def test_skips_empty_content(self):
|
||||
builder = TranscriptBuilder()
|
||||
builder.load_previous("")
|
||||
assert builder.is_empty
|
||||
builder.load_previous(" ")
|
||||
assert builder.is_empty
|
||||
|
||||
def test_skips_strippable_types(self):
|
||||
progress = {"type": "progress", "uuid": "p1", "message": {}}
|
||||
content = _make_jsonl(USER_MSG, progress, ASST_MSG)
|
||||
builder = TranscriptBuilder()
|
||||
builder.load_previous(content)
|
||||
assert builder.entry_count == 2 # progress was skipped
|
||||
|
||||
def test_preserves_compact_summary(self):
|
||||
compact = {
|
||||
"type": "summary",
|
||||
"uuid": "cs1",
|
||||
"isCompactSummary": True,
|
||||
"message": {"role": "user", "content": "summary"},
|
||||
}
|
||||
content = _make_jsonl(compact, ASST_MSG)
|
||||
builder = TranscriptBuilder()
|
||||
builder.load_previous(content)
|
||||
assert builder.entry_count == 2
|
||||
|
||||
def test_skips_invalid_json_lines(self):
|
||||
content = '{"type":"user","uuid":"u1","message":{}}\nnot-valid-json\n'
|
||||
builder = TranscriptBuilder()
|
||||
builder.load_previous(content)
|
||||
assert builder.entry_count == 1
|
||||
|
||||
|
||||
class TestToJsonl:
|
||||
def test_roundtrip(self):
|
||||
builder = TranscriptBuilder()
|
||||
builder.append_user("hello", uuid="u1")
|
||||
builder.append_assistant(
|
||||
content_blocks=[{"type": "text", "text": "world"}],
|
||||
model="m",
|
||||
)
|
||||
output = builder.to_jsonl()
|
||||
assert output.endswith("\n")
|
||||
lines = output.strip().split("\n")
|
||||
assert len(lines) == 2
|
||||
for line in lines:
|
||||
parsed = json.loads(line)
|
||||
assert "type" in parsed
|
||||
assert "uuid" in parsed
|
||||
assert "message" in parsed
|
||||
|
||||
|
||||
class TestReplaceEntries:
|
||||
def test_replaces_all_entries(self):
|
||||
builder = TranscriptBuilder()
|
||||
builder.append_user("old")
|
||||
builder.append_assistant(
|
||||
content_blocks=[{"type": "text", "text": "old answer"}], model="m"
|
||||
)
|
||||
assert builder.entry_count == 2
|
||||
|
||||
compacted = [
|
||||
{
|
||||
"type": "summary",
|
||||
"uuid": "cs1",
|
||||
"isCompactSummary": True,
|
||||
"message": {"role": "user", "content": "compacted"},
|
||||
}
|
||||
]
|
||||
builder.replace_entries(compacted)
|
||||
assert builder.entry_count == 1
|
||||
|
||||
def test_empty_replacement_keeps_existing(self):
|
||||
builder = TranscriptBuilder()
|
||||
builder.append_user("keep me")
|
||||
builder.replace_entries([])
|
||||
assert builder.entry_count == 1
|
||||
|
||||
|
||||
class TestParseEntry:
|
||||
def test_filters_strippable_non_compact(self):
|
||||
result = TranscriptBuilder._parse_entry(
|
||||
{"type": "progress", "uuid": "p1", "message": {}}
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_keeps_compact_summary(self):
|
||||
result = TranscriptBuilder._parse_entry(
|
||||
{
|
||||
"type": "summary",
|
||||
"uuid": "cs1",
|
||||
"isCompactSummary": True,
|
||||
"message": {},
|
||||
}
|
||||
)
|
||||
assert result is not None
|
||||
assert result.isCompactSummary is True
|
||||
|
||||
def test_generates_uuid_if_missing(self):
|
||||
result = TranscriptBuilder._parse_entry(
|
||||
{"type": "user", "message": {"role": "user", "content": "hi"}}
|
||||
)
|
||||
assert result is not None
|
||||
assert result.uuid # Should be a generated UUID
|
||||
@@ -31,7 +31,6 @@ Inspired by https://github.com/Significant-Gravitas/agent-simulator
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
@@ -44,39 +43,18 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Default simulator model — Gemini 2.5 Flash via OpenRouter (fast, cheap, good at
|
||||
# JSON generation). Configurable via SIMULATION_MODEL env var or
|
||||
# ChatConfig.simulation_model.
|
||||
# JSON generation). Configurable via ChatConfig.simulation_model
|
||||
# (CHAT_SIMULATION_MODEL env var).
|
||||
_DEFAULT_SIMULATOR_MODEL = "google/gemini-2.5-flash"
|
||||
|
||||
|
||||
def _simulator_model() -> str:
|
||||
# 1. Environment variable override (highest priority).
|
||||
env_model = os.environ.get("SIMULATION_MODEL")
|
||||
if env_model:
|
||||
model = env_model
|
||||
else:
|
||||
# 2. ChatConfig.simulation_model (falls back to default).
|
||||
try:
|
||||
from backend.copilot.config import ChatConfig # noqa: PLC0415
|
||||
|
||||
model = ChatConfig().simulation_model or _DEFAULT_SIMULATOR_MODEL
|
||||
except Exception:
|
||||
model = _DEFAULT_SIMULATOR_MODEL
|
||||
|
||||
# get_openai_client() may return a direct OpenAI client (not OpenRouter).
|
||||
# Direct OpenAI expects bare model names ("gpt-4o-mini"), not the
|
||||
# OpenRouter-prefixed form ("openai/gpt-4o-mini"). Strip the prefix when
|
||||
# the internal OpenAI key is configured (i.e. not going through OpenRouter).
|
||||
try:
|
||||
from backend.util.settings import Settings # noqa: PLC0415
|
||||
from backend.copilot.config import ChatConfig # noqa: PLC0415
|
||||
|
||||
secrets = Settings().secrets
|
||||
if secrets.openai_internal_api_key and "/" in model:
|
||||
model = model.split("/", 1)[1]
|
||||
return ChatConfig().simulation_model or _DEFAULT_SIMULATOR_MODEL
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return model
|
||||
return _DEFAULT_SIMULATOR_MODEL
|
||||
|
||||
|
||||
_TEMPERATURE = 0.2
|
||||
@@ -136,7 +114,7 @@ async def _call_llm_for_simulation(
|
||||
RuntimeError: If no LLM client is available.
|
||||
ValueError: If all retry attempts are exhausted.
|
||||
"""
|
||||
client = get_openai_client()
|
||||
client = get_openai_client(prefer_openrouter=True)
|
||||
if client is None:
|
||||
raise RuntimeError(
|
||||
"[SIMULATOR ERROR — NOT A BLOCK FAILURE] No LLM client available "
|
||||
|
||||
@@ -163,23 +163,31 @@ async def get_async_supabase() -> "AClient":
|
||||
|
||||
|
||||
@cached(ttl_seconds=3600)
|
||||
def get_openai_client() -> "AsyncOpenAI | None":
|
||||
def get_openai_client(*, prefer_openrouter: bool = False) -> "AsyncOpenAI | None":
|
||||
"""
|
||||
Get a process-cached async OpenAI client for embeddings.
|
||||
Get a process-cached async OpenAI client.
|
||||
|
||||
Prefers openai_internal_api_key (direct OpenAI). Falls back to
|
||||
open_router_api_key via OpenRouter's OpenAI-compatible endpoint.
|
||||
Returns None if neither key is configured.
|
||||
By default prefers openai_internal_api_key (direct OpenAI) and falls back
|
||||
to open_router_api_key via OpenRouter.
|
||||
|
||||
When ``prefer_openrouter=True``, returns an OpenRouter client or None —
|
||||
does **not** fall back to direct OpenAI (which can't route non-OpenAI
|
||||
models like ``google/gemini-2.5-flash``).
|
||||
"""
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
if settings.secrets.openai_internal_api_key:
|
||||
return AsyncOpenAI(api_key=settings.secrets.openai_internal_api_key)
|
||||
if settings.secrets.open_router_api_key:
|
||||
return AsyncOpenAI(
|
||||
api_key=settings.secrets.open_router_api_key,
|
||||
base_url=OPENROUTER_BASE_URL,
|
||||
)
|
||||
openai_key = settings.secrets.openai_internal_api_key
|
||||
openrouter_key = settings.secrets.open_router_api_key
|
||||
|
||||
if prefer_openrouter:
|
||||
if openrouter_key:
|
||||
return AsyncOpenAI(api_key=openrouter_key, base_url=OPENROUTER_BASE_URL)
|
||||
return None
|
||||
else:
|
||||
if openai_key:
|
||||
return AsyncOpenAI(api_key=openai_key)
|
||||
if openrouter_key:
|
||||
return AsyncOpenAI(api_key=openrouter_key, base_url=OPENROUTER_BASE_URL)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
69
autogpt_platform/backend/backend/util/clients_test.py
Normal file
69
autogpt_platform/backend/backend/util/clients_test.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Tests for get_openai_client prefer_openrouter parameter."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.util.clients import get_openai_client
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_client_cache():
|
||||
"""Clear the @cached singleton between tests."""
|
||||
get_openai_client.cache_clear()
|
||||
yield
|
||||
get_openai_client.cache_clear()
|
||||
|
||||
|
||||
def _mock_secrets(*, openai_key: str = "", openrouter_key: str = "") -> MagicMock:
|
||||
secrets = MagicMock()
|
||||
secrets.openai_internal_api_key = openai_key
|
||||
secrets.open_router_api_key = openrouter_key
|
||||
return secrets
|
||||
|
||||
|
||||
class TestGetOpenaiClientDefault:
|
||||
def test_prefers_openai_key(self):
|
||||
secrets = _mock_secrets(openai_key="sk-openai", openrouter_key="sk-or")
|
||||
with patch("backend.util.clients.settings") as mock_settings:
|
||||
mock_settings.secrets = secrets
|
||||
client = get_openai_client()
|
||||
assert client is not None
|
||||
assert client.api_key == "sk-openai"
|
||||
assert "openrouter" not in str(client.base_url or "")
|
||||
|
||||
def test_falls_back_to_openrouter(self):
|
||||
secrets = _mock_secrets(openrouter_key="sk-or")
|
||||
with patch("backend.util.clients.settings") as mock_settings:
|
||||
mock_settings.secrets = secrets
|
||||
client = get_openai_client()
|
||||
assert client is not None
|
||||
assert client.api_key == "sk-or"
|
||||
|
||||
def test_returns_none_when_no_keys(self):
|
||||
secrets = _mock_secrets()
|
||||
with patch("backend.util.clients.settings") as mock_settings:
|
||||
mock_settings.secrets = secrets
|
||||
assert get_openai_client() is None
|
||||
|
||||
|
||||
class TestGetOpenaiClientPreferOpenrouter:
|
||||
def test_returns_openrouter_client(self):
|
||||
secrets = _mock_secrets(openai_key="sk-openai", openrouter_key="sk-or")
|
||||
with patch("backend.util.clients.settings") as mock_settings:
|
||||
mock_settings.secrets = secrets
|
||||
client = get_openai_client(prefer_openrouter=True)
|
||||
assert client is not None
|
||||
assert client.api_key == "sk-or"
|
||||
|
||||
def test_returns_none_without_openrouter_key(self):
|
||||
secrets = _mock_secrets(openai_key="sk-openai")
|
||||
with patch("backend.util.clients.settings") as mock_settings:
|
||||
mock_settings.secrets = secrets
|
||||
assert get_openai_client(prefer_openrouter=True) is None
|
||||
|
||||
def test_returns_none_when_no_keys(self):
|
||||
secrets = _mock_secrets()
|
||||
with patch("backend.util.clients.settings") as mock_settings:
|
||||
mock_settings.secrets = secrets
|
||||
assert get_openai_client(prefer_openrouter=True) is None
|
||||
BIN
autogpt_platform/frontend/public/notification.mp3
Normal file
BIN
autogpt_platform/frontend/public/notification.mp3
Normal file
Binary file not shown.
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { useOnboardingWizardStore } from "../store";
|
||||
|
||||
beforeEach(() => {
|
||||
useOnboardingWizardStore.getState().reset();
|
||||
});
|
||||
|
||||
describe("useOnboardingWizardStore", () => {
|
||||
describe("initial state", () => {
|
||||
it("starts at step 1 with empty fields", () => {
|
||||
const state = useOnboardingWizardStore.getState();
|
||||
expect(state.currentStep).toBe(1);
|
||||
expect(state.name).toBe("");
|
||||
expect(state.role).toBe("");
|
||||
expect(state.otherRole).toBe("");
|
||||
expect(state.painPoints).toEqual([]);
|
||||
expect(state.otherPainPoint).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setName", () => {
|
||||
it("updates the name", () => {
|
||||
useOnboardingWizardStore.getState().setName("Alice");
|
||||
expect(useOnboardingWizardStore.getState().name).toBe("Alice");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setRole", () => {
|
||||
it("updates the role", () => {
|
||||
useOnboardingWizardStore.getState().setRole("Engineer");
|
||||
expect(useOnboardingWizardStore.getState().role).toBe("Engineer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setOtherRole", () => {
|
||||
it("updates the other role text", () => {
|
||||
useOnboardingWizardStore.getState().setOtherRole("Designer");
|
||||
expect(useOnboardingWizardStore.getState().otherRole).toBe("Designer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("togglePainPoint", () => {
|
||||
it("adds a pain point", () => {
|
||||
useOnboardingWizardStore.getState().togglePainPoint("slow builds");
|
||||
expect(useOnboardingWizardStore.getState().painPoints).toEqual([
|
||||
"slow builds",
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes a pain point when toggled again", () => {
|
||||
useOnboardingWizardStore.getState().togglePainPoint("slow builds");
|
||||
useOnboardingWizardStore.getState().togglePainPoint("slow builds");
|
||||
expect(useOnboardingWizardStore.getState().painPoints).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles multiple pain points", () => {
|
||||
useOnboardingWizardStore.getState().togglePainPoint("slow builds");
|
||||
useOnboardingWizardStore.getState().togglePainPoint("no tests");
|
||||
expect(useOnboardingWizardStore.getState().painPoints).toEqual([
|
||||
"slow builds",
|
||||
"no tests",
|
||||
]);
|
||||
|
||||
useOnboardingWizardStore.getState().togglePainPoint("slow builds");
|
||||
expect(useOnboardingWizardStore.getState().painPoints).toEqual([
|
||||
"no tests",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setOtherPainPoint", () => {
|
||||
it("updates the other pain point text", () => {
|
||||
useOnboardingWizardStore.getState().setOtherPainPoint("flaky CI");
|
||||
expect(useOnboardingWizardStore.getState().otherPainPoint).toBe(
|
||||
"flaky CI",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextStep", () => {
|
||||
it("increments the step", () => {
|
||||
useOnboardingWizardStore.getState().nextStep();
|
||||
expect(useOnboardingWizardStore.getState().currentStep).toBe(2);
|
||||
});
|
||||
|
||||
it("clamps at step 4", () => {
|
||||
useOnboardingWizardStore.getState().goToStep(4);
|
||||
useOnboardingWizardStore.getState().nextStep();
|
||||
expect(useOnboardingWizardStore.getState().currentStep).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prevStep", () => {
|
||||
it("decrements the step", () => {
|
||||
useOnboardingWizardStore.getState().goToStep(3);
|
||||
useOnboardingWizardStore.getState().prevStep();
|
||||
expect(useOnboardingWizardStore.getState().currentStep).toBe(2);
|
||||
});
|
||||
|
||||
it("clamps at step 1", () => {
|
||||
useOnboardingWizardStore.getState().prevStep();
|
||||
expect(useOnboardingWizardStore.getState().currentStep).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("goToStep", () => {
|
||||
it("jumps to an arbitrary step", () => {
|
||||
useOnboardingWizardStore.getState().goToStep(3);
|
||||
expect(useOnboardingWizardStore.getState().currentStep).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset", () => {
|
||||
it("resets all fields to defaults", () => {
|
||||
useOnboardingWizardStore.getState().setName("Alice");
|
||||
useOnboardingWizardStore.getState().setRole("Engineer");
|
||||
useOnboardingWizardStore.getState().setOtherRole("Other");
|
||||
useOnboardingWizardStore.getState().togglePainPoint("slow builds");
|
||||
useOnboardingWizardStore.getState().setOtherPainPoint("flaky CI");
|
||||
useOnboardingWizardStore.getState().goToStep(3);
|
||||
|
||||
useOnboardingWizardStore.getState().reset();
|
||||
|
||||
const state = useOnboardingWizardStore.getState();
|
||||
expect(state.currentStep).toBe(1);
|
||||
expect(state.name).toBe("");
|
||||
expect(state.role).toBe("");
|
||||
expect(state.otherRole).toBe("");
|
||||
expect(state.painPoints).toEqual([]);
|
||||
expect(state.otherPainPoint).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, expect, it, beforeEach, vi } from "vitest";
|
||||
import { useCopilotUIStore } from "../store";
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/services/environment", () => ({
|
||||
environment: {
|
||||
isServerSide: vi.fn(() => false),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("useCopilotUIStore", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
useCopilotUIStore.setState({
|
||||
initialPrompt: null,
|
||||
sessionToDelete: null,
|
||||
isDrawerOpen: false,
|
||||
completedSessionIDs: new Set<string>(),
|
||||
isNotificationsEnabled: false,
|
||||
isSoundEnabled: true,
|
||||
showNotificationDialog: false,
|
||||
copilotMode: "extended_thinking",
|
||||
});
|
||||
});
|
||||
|
||||
describe("initialPrompt", () => {
|
||||
it("starts as null", () => {
|
||||
expect(useCopilotUIStore.getState().initialPrompt).toBeNull();
|
||||
});
|
||||
|
||||
it("sets and clears prompt", () => {
|
||||
useCopilotUIStore.getState().setInitialPrompt("Hello");
|
||||
expect(useCopilotUIStore.getState().initialPrompt).toBe("Hello");
|
||||
|
||||
useCopilotUIStore.getState().setInitialPrompt(null);
|
||||
expect(useCopilotUIStore.getState().initialPrompt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionToDelete", () => {
|
||||
it("starts as null", () => {
|
||||
expect(useCopilotUIStore.getState().sessionToDelete).toBeNull();
|
||||
});
|
||||
|
||||
it("sets and clears a delete target", () => {
|
||||
useCopilotUIStore
|
||||
.getState()
|
||||
.setSessionToDelete({ id: "abc", title: "Test" });
|
||||
expect(useCopilotUIStore.getState().sessionToDelete).toEqual({
|
||||
id: "abc",
|
||||
title: "Test",
|
||||
});
|
||||
|
||||
useCopilotUIStore.getState().setSessionToDelete(null);
|
||||
expect(useCopilotUIStore.getState().sessionToDelete).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("drawer", () => {
|
||||
it("starts closed", () => {
|
||||
expect(useCopilotUIStore.getState().isDrawerOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("opens and closes", () => {
|
||||
useCopilotUIStore.getState().setDrawerOpen(true);
|
||||
expect(useCopilotUIStore.getState().isDrawerOpen).toBe(true);
|
||||
|
||||
useCopilotUIStore.getState().setDrawerOpen(false);
|
||||
expect(useCopilotUIStore.getState().isDrawerOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("completedSessionIDs", () => {
|
||||
it("starts empty", () => {
|
||||
expect(useCopilotUIStore.getState().completedSessionIDs.size).toBe(0);
|
||||
});
|
||||
|
||||
it("adds a completed session", () => {
|
||||
useCopilotUIStore.getState().addCompletedSession("s1");
|
||||
expect(useCopilotUIStore.getState().completedSessionIDs.has("s1")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("clears a single completed session", () => {
|
||||
useCopilotUIStore.getState().addCompletedSession("s1");
|
||||
useCopilotUIStore.getState().addCompletedSession("s2");
|
||||
useCopilotUIStore.getState().clearCompletedSession("s1");
|
||||
expect(useCopilotUIStore.getState().completedSessionIDs.has("s1")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(useCopilotUIStore.getState().completedSessionIDs.has("s2")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("clears all completed sessions", () => {
|
||||
useCopilotUIStore.getState().addCompletedSession("s1");
|
||||
useCopilotUIStore.getState().addCompletedSession("s2");
|
||||
useCopilotUIStore.getState().clearAllCompletedSessions();
|
||||
expect(useCopilotUIStore.getState().completedSessionIDs.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sound toggle", () => {
|
||||
it("starts enabled", () => {
|
||||
expect(useCopilotUIStore.getState().isSoundEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("toggles sound off and on", () => {
|
||||
useCopilotUIStore.getState().toggleSound();
|
||||
expect(useCopilotUIStore.getState().isSoundEnabled).toBe(false);
|
||||
|
||||
useCopilotUIStore.getState().toggleSound();
|
||||
expect(useCopilotUIStore.getState().isSoundEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("persists to localStorage", () => {
|
||||
useCopilotUIStore.getState().toggleSound();
|
||||
expect(window.localStorage.getItem("copilot-sound-enabled")).toBe(
|
||||
"false",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("copilotMode", () => {
|
||||
it("defaults to extended_thinking", () => {
|
||||
expect(useCopilotUIStore.getState().copilotMode).toBe(
|
||||
"extended_thinking",
|
||||
);
|
||||
});
|
||||
|
||||
it("sets mode to fast", () => {
|
||||
useCopilotUIStore.getState().setCopilotMode("fast");
|
||||
expect(useCopilotUIStore.getState().copilotMode).toBe("fast");
|
||||
expect(window.localStorage.getItem("copilot-mode")).toBe("fast");
|
||||
});
|
||||
|
||||
it("sets mode back to extended_thinking", () => {
|
||||
useCopilotUIStore.getState().setCopilotMode("fast");
|
||||
useCopilotUIStore.getState().setCopilotMode("extended_thinking");
|
||||
expect(useCopilotUIStore.getState().copilotMode).toBe(
|
||||
"extended_thinking",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearCopilotLocalData", () => {
|
||||
it("resets state and clears localStorage keys", () => {
|
||||
useCopilotUIStore.getState().setCopilotMode("fast");
|
||||
useCopilotUIStore.getState().setNotificationsEnabled(true);
|
||||
useCopilotUIStore.getState().toggleSound();
|
||||
useCopilotUIStore.getState().addCompletedSession("s1");
|
||||
|
||||
useCopilotUIStore.getState().clearCopilotLocalData();
|
||||
|
||||
const state = useCopilotUIStore.getState();
|
||||
expect(state.copilotMode).toBe("extended_thinking");
|
||||
expect(state.isNotificationsEnabled).toBe(false);
|
||||
expect(state.isSoundEnabled).toBe(true);
|
||||
expect(state.completedSessionIDs.size).toBe(0);
|
||||
expect(window.localStorage.getItem("copilot-mode")).toBeNull();
|
||||
expect(
|
||||
window.localStorage.getItem("copilot-notifications-enabled"),
|
||||
).toBeNull();
|
||||
expect(window.localStorage.getItem("copilot-sound-enabled")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("notifications", () => {
|
||||
it("sets notification preference", () => {
|
||||
useCopilotUIStore.getState().setNotificationsEnabled(true);
|
||||
expect(useCopilotUIStore.getState().isNotificationsEnabled).toBe(true);
|
||||
expect(
|
||||
window.localStorage.getItem("copilot-notifications-enabled"),
|
||||
).toBe("true");
|
||||
});
|
||||
|
||||
it("shows and hides notification dialog", () => {
|
||||
useCopilotUIStore.getState().setShowNotificationDialog(true);
|
||||
expect(useCopilotUIStore.getState().showNotificationDialog).toBe(true);
|
||||
|
||||
useCopilotUIStore.getState().setShowNotificationDialog(false);
|
||||
expect(useCopilotUIStore.getState().showNotificationDialog).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { parseAsString, useQueryState } from "nuqs";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { formatNotificationTitle } from "../../helpers";
|
||||
import { useCopilotUIStore } from "../../store";
|
||||
import { NotificationToggle } from "./components/NotificationToggle/NotificationToggle";
|
||||
import { DeleteChatDialog } from "../DeleteChatDialog/DeleteChatDialog";
|
||||
@@ -123,9 +124,8 @@ export function ChatSidebar() {
|
||||
useEffect(() => {
|
||||
if (!sessionId || !completedSessionIDs.has(sessionId)) return;
|
||||
clearCompletedSession(sessionId);
|
||||
const remaining = completedSessionIDs.size - 1;
|
||||
document.title =
|
||||
remaining > 0 ? `(${remaining}) Otto is ready - AutoGPT` : "AutoGPT";
|
||||
const remaining = Math.max(0, completedSessionIDs.size - 1);
|
||||
document.title = formatNotificationTitle(remaining);
|
||||
}, [sessionId, completedSessionIDs, clearCompletedSession]);
|
||||
|
||||
const sessions =
|
||||
|
||||
@@ -56,8 +56,8 @@ export function NotificationBanner() {
|
||||
<div className="flex items-center gap-3 border-b border-amber-200 bg-amber-50 px-4 py-2.5">
|
||||
<BellRinging className="h-5 w-5 shrink-0 text-amber-600" weight="fill" />
|
||||
<Text variant="body" className="flex-1 text-sm text-amber-800">
|
||||
Enable browser notifications to know when Otto finishes working, even
|
||||
when you switch tabs.
|
||||
Enable browser notifications to know when AutoPilot finishes working,
|
||||
even when you switch tabs.
|
||||
</Text>
|
||||
<Button variant="primary" size="small" onClick={handleEnable}>
|
||||
Enable
|
||||
|
||||
@@ -77,11 +77,12 @@ export function NotificationDialog() {
|
||||
<BellRinging className="h-6 w-6 text-violet-600" weight="fill" />
|
||||
</div>
|
||||
<Text variant="body" className="text-center text-neutral-600">
|
||||
Otto can notify you when a response is ready, even if you switch
|
||||
tabs or close this page. Enable notifications so you never miss one.
|
||||
AutoPilot can notify you when a response is ready, even if you
|
||||
switch tabs or close this page. Enable notifications so you never
|
||||
miss one.
|
||||
</Text>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Dialog.Footer className="justify-center">
|
||||
<Button variant="secondary" onClick={handleDismiss}>
|
||||
Not now
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ORIGINAL_TITLE,
|
||||
formatNotificationTitle,
|
||||
parseSessionIDs,
|
||||
} from "./helpers";
|
||||
|
||||
describe("formatNotificationTitle", () => {
|
||||
it("returns base title when count is 0", () => {
|
||||
expect(formatNotificationTitle(0)).toBe(ORIGINAL_TITLE);
|
||||
});
|
||||
|
||||
it("returns formatted title with count", () => {
|
||||
expect(formatNotificationTitle(3)).toBe(
|
||||
`(3) AutoPilot is ready - ${ORIGINAL_TITLE}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns base title for negative count", () => {
|
||||
expect(formatNotificationTitle(-1)).toBe(ORIGINAL_TITLE);
|
||||
});
|
||||
|
||||
it("returns base title for NaN", () => {
|
||||
expect(formatNotificationTitle(NaN)).toBe(ORIGINAL_TITLE);
|
||||
});
|
||||
|
||||
it("returns formatted title for count of 1", () => {
|
||||
expect(formatNotificationTitle(1)).toBe(
|
||||
`(1) AutoPilot is ready - ${ORIGINAL_TITLE}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSessionIDs", () => {
|
||||
it("returns empty set for null", () => {
|
||||
expect(parseSessionIDs(null)).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("returns empty set for undefined", () => {
|
||||
expect(parseSessionIDs(undefined)).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("returns empty set for empty string", () => {
|
||||
expect(parseSessionIDs("")).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("parses valid JSON array of strings", () => {
|
||||
expect(parseSessionIDs('["a","b","c"]')).toEqual(new Set(["a", "b", "c"]));
|
||||
});
|
||||
|
||||
it("filters out non-string elements", () => {
|
||||
expect(parseSessionIDs('[1,"valid",null,true,"also-valid"]')).toEqual(
|
||||
new Set(["valid", "also-valid"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty set for non-array JSON", () => {
|
||||
expect(parseSessionIDs('{"key":"value"}')).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("returns empty set for JSON string value", () => {
|
||||
expect(parseSessionIDs('"oops"')).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("returns empty set for JSON number value", () => {
|
||||
expect(parseSessionIDs("42")).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("returns empty set for malformed JSON", () => {
|
||||
expect(parseSessionIDs("{broken")).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("deduplicates entries", () => {
|
||||
expect(parseSessionIDs('["a","a","b"]')).toEqual(new Set(["a", "b"]));
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,33 @@
|
||||
import type { UIMessage } from "ai";
|
||||
|
||||
export const ORIGINAL_TITLE = "AutoGPT";
|
||||
|
||||
/**
|
||||
* Build the document title showing how many sessions are ready.
|
||||
* Returns the base title when count is 0.
|
||||
*/
|
||||
export function formatNotificationTitle(count: number): string {
|
||||
return count > 0
|
||||
? `(${count}) AutoPilot is ready - ${ORIGINAL_TITLE}`
|
||||
: ORIGINAL_TITLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse a JSON string (from localStorage) into a `Set<string>` of
|
||||
* session IDs. Returns an empty set for `null`, malformed, or non-array values.
|
||||
*/
|
||||
export function parseSessionIDs(raw: string | null | undefined): Set<string> {
|
||||
if (!raw) return new Set();
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return Array.isArray(parsed)
|
||||
? new Set<string>(parsed.filter((v) => typeof v === "string"))
|
||||
: new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a refetchSession result indicates the backend still has an
|
||||
* active SSE stream for this session.
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import { Key, storage } from "@/services/storage/local-storage";
|
||||
import { create } from "zustand";
|
||||
import { ORIGINAL_TITLE, parseSessionIDs } from "./helpers";
|
||||
|
||||
export interface DeleteTarget {
|
||||
id: string;
|
||||
title: string | null | undefined;
|
||||
}
|
||||
|
||||
const isClient = typeof window !== "undefined";
|
||||
|
||||
function persistCompletedSessions(ids: Set<string>) {
|
||||
if (!isClient) return;
|
||||
try {
|
||||
if (ids.size === 0) {
|
||||
storage.clean(Key.COPILOT_COMPLETED_SESSIONS);
|
||||
} else {
|
||||
storage.set(Key.COPILOT_COMPLETED_SESSIONS, JSON.stringify([...ids]));
|
||||
}
|
||||
} catch {
|
||||
// Keep in-memory state authoritative if persistence is unavailable
|
||||
}
|
||||
}
|
||||
|
||||
interface CopilotUIState {
|
||||
/** Prompt extracted from URL hash (e.g. /copilot#prompt=...) for input prefill. */
|
||||
initialPrompt: string | null;
|
||||
@@ -48,23 +64,30 @@ export const useCopilotUIStore = create<CopilotUIState>((set) => ({
|
||||
isDrawerOpen: false,
|
||||
setDrawerOpen: (open) => set({ isDrawerOpen: open }),
|
||||
|
||||
completedSessionIDs: new Set<string>(),
|
||||
completedSessionIDs: isClient
|
||||
? parseSessionIDs(storage.get(Key.COPILOT_COMPLETED_SESSIONS))
|
||||
: new Set(),
|
||||
addCompletedSession: (id) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.completedSessionIDs);
|
||||
next.add(id);
|
||||
persistCompletedSessions(next);
|
||||
return { completedSessionIDs: next };
|
||||
}),
|
||||
clearCompletedSession: (id) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.completedSessionIDs);
|
||||
next.delete(id);
|
||||
persistCompletedSessions(next);
|
||||
return { completedSessionIDs: next };
|
||||
}),
|
||||
clearAllCompletedSessions: () =>
|
||||
set({ completedSessionIDs: new Set<string>() }),
|
||||
clearAllCompletedSessions: () => {
|
||||
persistCompletedSessions(new Set());
|
||||
set({ completedSessionIDs: new Set<string>() });
|
||||
},
|
||||
|
||||
isNotificationsEnabled:
|
||||
isClient &&
|
||||
storage.get(Key.COPILOT_NOTIFICATIONS_ENABLED) === "true" &&
|
||||
typeof Notification !== "undefined" &&
|
||||
Notification.permission === "granted",
|
||||
@@ -73,7 +96,8 @@ export const useCopilotUIStore = create<CopilotUIState>((set) => ({
|
||||
set({ isNotificationsEnabled: enabled });
|
||||
},
|
||||
|
||||
isSoundEnabled: storage.get(Key.COPILOT_SOUND_ENABLED) !== "false",
|
||||
isSoundEnabled:
|
||||
!isClient || storage.get(Key.COPILOT_SOUND_ENABLED) !== "false",
|
||||
toggleSound: () =>
|
||||
set((state) => {
|
||||
const next = !state.isSoundEnabled;
|
||||
@@ -96,13 +120,18 @@ export const useCopilotUIStore = create<CopilotUIState>((set) => ({
|
||||
storage.clean(Key.COPILOT_SOUND_ENABLED);
|
||||
storage.clean(Key.COPILOT_NOTIFICATION_BANNER_DISMISSED);
|
||||
storage.clean(Key.COPILOT_NOTIFICATION_DIALOG_DISMISSED);
|
||||
|
||||
storage.clean(Key.COPILOT_MODE);
|
||||
|
||||
storage.clean(Key.COPILOT_COMPLETED_SESSIONS);
|
||||
set({
|
||||
completedSessionIDs: new Set<string>(),
|
||||
isNotificationsEnabled: false,
|
||||
isSoundEnabled: true,
|
||||
copilotMode: "extended_thinking",
|
||||
});
|
||||
document.title = "AutoGPT";
|
||||
if (isClient) {
|
||||
document.title = ORIGINAL_TITLE;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,10 +1,42 @@
|
||||
import { getGetV2ListSessionsQueryKey } from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import type { WebSocketNotification } from "@/lib/autogpt-server-api/types";
|
||||
import { Key } from "@/services/storage/local-storage";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
ORIGINAL_TITLE,
|
||||
formatNotificationTitle,
|
||||
parseSessionIDs,
|
||||
} from "./helpers";
|
||||
import { useCopilotUIStore } from "./store";
|
||||
|
||||
const ORIGINAL_TITLE = "AutoGPT";
|
||||
const NOTIFICATION_SOUND_PATH = "/sounds/notification.mp3";
|
||||
const NOTIFICATION_SOUND_PATH = "/notification.mp3";
|
||||
|
||||
/**
|
||||
* Show a browser notification with click-to-navigate behaviour.
|
||||
* Wrapped in try-catch so it degrades gracefully in service-worker or
|
||||
* other restricted contexts where the Notification constructor throws.
|
||||
*/
|
||||
function showBrowserNotification(
|
||||
title: string,
|
||||
opts: { body: string; icon: string; sessionID: string },
|
||||
) {
|
||||
try {
|
||||
const n = new Notification(title, { body: opts.body, icon: opts.icon });
|
||||
n.onclick = () => {
|
||||
window.focus();
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("sessionId", opts.sessionID);
|
||||
window.history.pushState({}, "", url.toString());
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
n.close();
|
||||
};
|
||||
} catch {
|
||||
// Notification constructor is unavailable (e.g. service-worker context).
|
||||
// The user will still see the in-app badge and title update.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens for copilot completion notifications via WebSocket.
|
||||
@@ -12,17 +44,23 @@ const NOTIFICATION_SOUND_PATH = "/sounds/notification.mp3";
|
||||
*/
|
||||
export function useCopilotNotifications(activeSessionID: string | null) {
|
||||
const api = useBackendAPI();
|
||||
const queryClient = useQueryClient();
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const activeSessionRef = useRef(activeSessionID);
|
||||
activeSessionRef.current = activeSessionID;
|
||||
const windowFocusedRef = useRef(true);
|
||||
|
||||
// Pre-load audio element
|
||||
// Pre-load audio element and sync document title with persisted state
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const audio = new Audio(NOTIFICATION_SOUND_PATH);
|
||||
audio.volume = 0.5;
|
||||
audioRef.current = audio;
|
||||
|
||||
const count = useCopilotUIStore.getState().completedSessionIDs.size;
|
||||
if (count > 0) {
|
||||
document.title = formatNotificationTitle(count);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Listen for WebSocket notifications
|
||||
@@ -49,7 +87,7 @@ export function useCopilotNotifications(activeSessionID: string | null) {
|
||||
// Always update UI state (checkmark + title) regardless of notification setting
|
||||
state.addCompletedSession(sessionID);
|
||||
const count = useCopilotUIStore.getState().completedSessionIDs.size;
|
||||
document.title = `(${count}) Otto is ready - ${ORIGINAL_TITLE}`;
|
||||
document.title = formatNotificationTitle(count);
|
||||
|
||||
// Sound and browser notifications are gated by the user setting
|
||||
if (!state.isNotificationsEnabled) return;
|
||||
@@ -65,18 +103,11 @@ export function useCopilotNotifications(activeSessionID: string | null) {
|
||||
Notification.permission === "granted" &&
|
||||
isUserAway
|
||||
) {
|
||||
const n = new Notification("Otto is ready", {
|
||||
showBrowserNotification("AutoPilot is ready", {
|
||||
body: "A response is waiting for you.",
|
||||
icon: "/favicon.ico",
|
||||
sessionID,
|
||||
});
|
||||
n.onclick = () => {
|
||||
window.focus();
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("sessionId", sessionID);
|
||||
window.history.pushState({}, "", url.toString());
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
n.close();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,4 +146,24 @@ export function useCopilotNotifications(activeSessionID: string | null) {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Sync completedSessionIDs across tabs via localStorage storage events
|
||||
useEffect(() => {
|
||||
function handleStorage(e: StorageEvent) {
|
||||
if (e.key !== Key.COPILOT_COMPLETED_SESSIONS) return;
|
||||
// localStorage is the shared source of truth — adopt it directly so both
|
||||
// additions (new completions) and removals (cleared sessions) propagate.
|
||||
const next = parseSessionIDs(e.newValue);
|
||||
useCopilotUIStore.setState({ completedSessionIDs: next });
|
||||
document.title = formatNotificationTitle(next.size);
|
||||
|
||||
// Refetch the session list so the sidebar reflects the latest
|
||||
// is_processing state (avoids stale spinner after cross-tab clear).
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListSessionsQueryKey(),
|
||||
});
|
||||
}
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, [queryClient]);
|
||||
}
|
||||
|
||||
@@ -7495,6 +7495,13 @@
|
||||
],
|
||||
"title": "Inputs",
|
||||
"description": "Input schema for the agent, including field names, types, and defaults"
|
||||
},
|
||||
"graph": {
|
||||
"anyOf": [
|
||||
{ "$ref": "#/components/schemas/BaseGraph-Output" },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"description": "Full graph structure (nodes + links) when include_graph is requested"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/__legacy__/ui/popover";
|
||||
import { Bell } from "@phosphor-icons/react";
|
||||
import { Pulse } from "@phosphor-icons/react";
|
||||
import { ActivityDropdown } from "./components/ActivityDropdown/ActivityDropdown";
|
||||
import { formatNotificationCount } from "./helpers";
|
||||
import { useAgentActivityDropdown } from "./useAgentActivityDropdown";
|
||||
@@ -30,7 +30,7 @@ export function AgentActivityDropdown() {
|
||||
data-testid="agent-activity-button"
|
||||
aria-label="View Agent Activity"
|
||||
>
|
||||
<Bell size={22} className="text-black" />
|
||||
<Pulse size={22} className="text-black" />
|
||||
|
||||
{activeCount > 0 && (
|
||||
<>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDialogCtx } from "../useDialogCtx";
|
||||
|
||||
interface Props {
|
||||
@@ -10,14 +11,14 @@ interface Props {
|
||||
export function BaseFooter({
|
||||
children,
|
||||
testId = "modal-footer",
|
||||
className = "",
|
||||
className,
|
||||
style,
|
||||
}: Props) {
|
||||
const ctx = useDialogCtx();
|
||||
|
||||
return ctx.isLargeScreen ? (
|
||||
<div
|
||||
className={`flex justify-end gap-4 pt-6 ${className}`}
|
||||
className={cn("flex justify-end gap-4 pt-6", className)}
|
||||
data-testid={testId}
|
||||
style={style}
|
||||
>
|
||||
@@ -25,7 +26,7 @@ export function BaseFooter({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`flex w-full items-end justify-end gap-4 pt-6 ${className}`}
|
||||
className={cn("flex w-full items-end justify-end gap-4 pt-6", className)}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, beforeEach, vi } from "vitest";
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/services/environment", () => ({
|
||||
environment: {
|
||||
isServerSide: vi.fn(() => false),
|
||||
},
|
||||
}));
|
||||
|
||||
import { Key, storage } from "../local-storage";
|
||||
import { environment } from "@/services/environment";
|
||||
|
||||
describe("storage", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.mocked(environment.isServerSide).mockReturnValue(false);
|
||||
});
|
||||
|
||||
describe("set and get", () => {
|
||||
it("stores and retrieves a value", () => {
|
||||
storage.set(Key.COPILOT_MODE, "fast");
|
||||
expect(storage.get(Key.COPILOT_MODE)).toBe("fast");
|
||||
});
|
||||
|
||||
it("returns null for unset keys", () => {
|
||||
expect(storage.get(Key.COPILOT_MODE)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clean", () => {
|
||||
it("removes a stored value", () => {
|
||||
storage.set(Key.COPILOT_SOUND_ENABLED, "true");
|
||||
storage.clean(Key.COPILOT_SOUND_ENABLED);
|
||||
expect(storage.get(Key.COPILOT_SOUND_ENABLED)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("server-side guard", () => {
|
||||
it("returns undefined for get when on server side", () => {
|
||||
vi.mocked(environment.isServerSide).mockReturnValue(true);
|
||||
expect(storage.get(Key.COPILOT_MODE)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for set when on server side", () => {
|
||||
vi.mocked(environment.isServerSide).mockReturnValue(true);
|
||||
expect(storage.set(Key.COPILOT_MODE, "fast")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for clean when on server side", () => {
|
||||
vi.mocked(environment.isServerSide).mockReturnValue(true);
|
||||
expect(storage.clean(Key.COPILOT_MODE)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Key enum", () => {
|
||||
it("has expected keys", () => {
|
||||
expect(Key.COPILOT_MODE).toBe("copilot-mode");
|
||||
expect(Key.COPILOT_SOUND_ENABLED).toBe("copilot-sound-enabled");
|
||||
expect(Key.COPILOT_NOTIFICATIONS_ENABLED).toBe(
|
||||
"copilot-notifications-enabled",
|
||||
);
|
||||
expect(Key.CHAT_SESSION_ID).toBe("chat_session_id");
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,10 @@ export enum Key {
|
||||
COPILOT_NOTIFICATIONS_ENABLED = "copilot-notifications-enabled",
|
||||
COPILOT_NOTIFICATION_BANNER_DISMISSED = "copilot-notification-banner-dismissed",
|
||||
COPILOT_NOTIFICATION_DIALOG_DISMISSED = "copilot-notification-dialog-dismissed",
|
||||
|
||||
COPILOT_MODE = "copilot-mode",
|
||||
|
||||
COPILOT_COMPLETED_SESSIONS = "copilot-completed-sessions",
|
||||
}
|
||||
|
||||
function get(key: Key) {
|
||||
|
||||
Reference in New Issue
Block a user