merge: resolve conflicts (keep both COPILOT_MODE and COPILOT_COMPLETED_SESSIONS)

This commit is contained in:
Zamil Majdy
2026-04-03 14:12:26 +02:00
27 changed files with 1786 additions and 96 deletions

1
.gitignore vendored
View File

@@ -17,6 +17,7 @@ log-ingestion.txt
/logs
*.log
*.mp3
!autogpt_platform/frontend/public/notification.mp3
mem.sqlite3
venvAutoGPT

View File

@@ -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"

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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 "

View File

@@ -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

View 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

Binary file not shown.

View File

@@ -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("");
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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 =

View File

@@ -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

View File

@@ -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>

View File

@@ -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"]));
});
});

View File

@@ -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.

View File

@@ -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;
}
},
}));

View File

@@ -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]);
}

View File

@@ -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",

View File

@@ -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 && (
<>

View File

@@ -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}

View File

@@ -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");
});
});

View File

@@ -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) {