feat(platform): add nightly copilot automation flow

This commit is contained in:
Swifty
2026-03-13 15:24:36 +01:00
parent 1f1288d623
commit 182927a1d4
37 changed files with 3090 additions and 82 deletions

View File

@@ -15,6 +15,7 @@ from pydantic import BaseModel, Field, field_validator
from backend.copilot import service as chat_service
from backend.copilot import stream_registry
from backend.copilot.autopilot import consume_callback_token, strip_internal_content
from backend.copilot.config import ChatConfig
from backend.copilot.executor.utils import enqueue_cancel_task, enqueue_copilot_turn
from backend.copilot.model import (
@@ -28,6 +29,7 @@ from backend.copilot.model import (
update_session_title,
)
from backend.copilot.response_model import StreamError, StreamFinish, StreamHeartbeat
from backend.copilot.session_types import ChatSessionStartType
from backend.copilot.tools.e2b_sandbox import kill_sandbox
from backend.copilot.tools.models import (
AgentDetailsResponse,
@@ -118,6 +120,8 @@ class SessionDetailResponse(BaseModel):
created_at: str
updated_at: str
user_id: str | None
start_type: ChatSessionStartType
execution_tag: str | None = None
messages: list[dict]
active_stream: ActiveStreamInfo | None = None # Present if stream is still active
@@ -129,6 +133,8 @@ class SessionSummaryResponse(BaseModel):
created_at: str
updated_at: str
title: str | None = None
start_type: ChatSessionStartType
execution_tag: str | None = None
is_processing: bool
@@ -160,6 +166,14 @@ class UpdateSessionTitleRequest(BaseModel):
return stripped
class ConsumeCallbackTokenRequest(BaseModel):
token: str
class ConsumeCallbackTokenResponse(BaseModel):
session_id: str
# ========== Routes ==========
@@ -171,6 +185,7 @@ async def list_sessions(
user_id: Annotated[str, Security(auth.get_user_id)],
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
with_auto: bool = Query(default=False),
) -> ListSessionsResponse:
"""
List chat sessions for the authenticated user.
@@ -186,7 +201,12 @@ async def list_sessions(
Returns:
ListSessionsResponse: List of session summaries and total count.
"""
sessions, total_count = await get_user_sessions(user_id, limit, offset)
sessions, total_count = await get_user_sessions(
user_id,
limit,
offset,
with_auto=with_auto,
)
# Batch-check Redis for active stream status on each session
processing_set: set[str] = set()
@@ -217,6 +237,8 @@ async def list_sessions(
created_at=session.started_at.isoformat(),
updated_at=session.updated_at.isoformat(),
title=session.title,
start_type=session.start_type,
execution_tag=session.execution_tag,
is_processing=session.session_id in processing_set,
)
for session in sessions
@@ -368,7 +390,15 @@ async def get_session(
if not session:
raise NotFoundError(f"Session {session_id} not found.")
messages = [message.model_dump() for message in session.messages]
messages = []
for message in session.messages:
payload = message.model_dump()
if message.role == "user":
visible_content = strip_internal_content(message.content)
if visible_content is None:
continue
payload["content"] = visible_content
messages.append(payload)
# Check if there's an active stream for this session
active_stream_info = None
@@ -394,11 +424,28 @@ async def get_session(
created_at=session.started_at.isoformat(),
updated_at=session.updated_at.isoformat(),
user_id=session.user_id or None,
start_type=session.start_type,
execution_tag=session.execution_tag,
messages=messages,
active_stream=active_stream_info,
)
@router.post(
"/sessions/callback-token/consume",
dependencies=[Security(auth.requires_user)],
)
async def consume_callback_token_route(
request: ConsumeCallbackTokenRequest,
user_id: Annotated[str, Security(auth.get_user_id)],
) -> ConsumeCallbackTokenResponse:
try:
result = await consume_callback_token(request.token, user_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return ConsumeCallbackTokenResponse(session_id=result.session_id)
@router.post(
"/sessions/{session_id}/cancel",
status_code=200,

View File

@@ -1,5 +1,7 @@
"""Tests for chat API routes: session title update, file attachment validation, and suggested prompts."""
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import fastapi
@@ -8,6 +10,8 @@ import pytest
import pytest_mock
from backend.api.features.chat import routes as chat_routes
from backend.copilot.model import ChatMessage, ChatSession
from backend.copilot.session_types import ChatSessionStartType
app = fastapi.FastAPI()
app.include_router(chat_routes.router)
@@ -115,6 +119,177 @@ def test_update_title_not_found(
assert response.status_code == 404
def test_list_sessions_defaults_to_manual_only(
mocker: pytest_mock.MockerFixture,
test_user_id: str,
) -> None:
started_at = datetime.now(timezone.utc)
mock_get_user_sessions = mocker.patch(
"backend.api.features.chat.routes.get_user_sessions",
new_callable=AsyncMock,
return_value=(
[
SimpleNamespace(
session_id="sess-1",
started_at=started_at,
updated_at=started_at,
title="Nightly check-in",
start_type=chat_routes.ChatSessionStartType.AUTOPILOT_NIGHTLY,
execution_tag="autopilot-nightly:2026-03-13",
)
],
1,
),
)
pipe = MagicMock()
pipe.hget = MagicMock()
pipe.execute = AsyncMock(return_value=["running"])
redis = MagicMock()
redis.pipeline = MagicMock(return_value=pipe)
mocker.patch(
"backend.api.features.chat.routes.get_redis_async",
new_callable=AsyncMock,
return_value=redis,
)
response = client.get("/sessions")
assert response.status_code == 200
assert response.json() == {
"sessions": [
{
"id": "sess-1",
"created_at": started_at.isoformat(),
"updated_at": started_at.isoformat(),
"title": "Nightly check-in",
"start_type": "AUTOPILOT_NIGHTLY",
"execution_tag": "autopilot-nightly:2026-03-13",
"is_processing": True,
}
],
"total": 1,
}
mock_get_user_sessions.assert_awaited_once_with(
test_user_id,
50,
0,
with_auto=False,
)
def test_list_sessions_can_include_auto_sessions(
mocker: pytest_mock.MockerFixture,
test_user_id: str,
) -> None:
mock_get_user_sessions = mocker.patch(
"backend.api.features.chat.routes.get_user_sessions",
new_callable=AsyncMock,
return_value=([], 0),
)
response = client.get("/sessions?with_auto=true")
assert response.status_code == 200
assert response.json() == {"sessions": [], "total": 0}
mock_get_user_sessions.assert_awaited_once_with(
test_user_id,
50,
0,
with_auto=True,
)
def test_consume_callback_token_route_returns_session_id(
mocker: pytest_mock.MockerFixture,
) -> None:
mock_consume = mocker.patch(
"backend.api.features.chat.routes.consume_callback_token",
new_callable=AsyncMock,
return_value=SimpleNamespace(session_id="sess-2"),
)
response = client.post(
"/sessions/callback-token/consume",
json={"token": "token-123"},
)
assert response.status_code == 200
assert response.json() == {"session_id": "sess-2"}
mock_consume.assert_awaited_once_with("token-123", TEST_USER_ID)
def test_consume_callback_token_route_returns_404_on_invalid_token(
mocker: pytest_mock.MockerFixture,
) -> None:
mocker.patch(
"backend.api.features.chat.routes.consume_callback_token",
new_callable=AsyncMock,
side_effect=ValueError("Callback token not found"),
)
response = client.post(
"/sessions/callback-token/consume",
json={"token": "token-123"},
)
assert response.status_code == 404
assert response.json() == {"detail": "Callback token not found"}
def test_get_session_hides_internal_only_messages_and_strips_internal_content(
mocker: pytest_mock.MockerFixture,
) -> None:
session = ChatSession.new(
TEST_USER_ID,
start_type=ChatSessionStartType.AUTOPILOT_NIGHTLY,
execution_tag="autopilot-nightly:2026-03-13",
)
session.messages = [
ChatMessage(role="user", content="<internal>hidden</internal>"),
ChatMessage(
role="user",
content="Visible<internal>hidden</internal> text",
),
ChatMessage(role="assistant", content="Public response"),
]
mocker.patch(
"backend.api.features.chat.routes.get_chat_session",
new_callable=AsyncMock,
return_value=session,
)
mocker.patch(
"backend.api.features.chat.routes.stream_registry.get_active_session",
new_callable=AsyncMock,
return_value=(None, None),
)
response = client.get(f"/sessions/{session.session_id}")
assert response.status_code == 200
assert response.json()["messages"] == [
{
"role": "user",
"content": "Visible text",
"name": None,
"tool_call_id": None,
"refusal": None,
"tool_calls": None,
"function_call": None,
},
{
"role": "assistant",
"content": "Public response",
"name": None,
"tool_call_id": None,
"refusal": None,
"tool_calls": None,
"function_call": None,
},
]
# ─── file_ids Pydantic validation ─────────────────────────────────────

View File

@@ -0,0 +1,843 @@
from __future__ import annotations
import json
import logging
import re
from datetime import UTC, date, datetime, time, timedelta
from uuid import uuid4
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import prisma.enums
import prisma.models
from pydantic import BaseModel
from backend.copilot.constants import COPILOT_SESSION_PREFIX
from backend.copilot.executor.utils import enqueue_copilot_turn
from backend.copilot.model import (
ChatMessage,
ChatSession,
create_chat_session,
get_chat_session,
upsert_chat_session,
)
from backend.copilot.service import _get_system_prompt_template
from backend.copilot.session_types import (
ChatSessionConfig,
ChatSessionStartType,
CompletionReportInput,
StoredCompletionReport,
)
from backend.data.understanding import (
format_understanding_for_prompt,
get_business_understanding,
)
from backend.notifications.email import EmailSender
from backend.util.feature_flag import Flag, is_feature_enabled
from backend.util.settings import Settings
logger = logging.getLogger(__name__)
settings = Settings()
INTERNAL_TAG_RE = re.compile(r"<internal>.*?</internal>", re.DOTALL)
MAX_COMPLETION_REPORT_REPAIRS = 2
AUTOPILOT_RECENT_CONTEXT_CHAR_LIMIT = 6000
AUTOPILOT_RECENT_SESSION_LIMIT = 5
AUTOPILOT_RECENT_MESSAGE_LIMIT = 6
AUTOPILOT_MESSAGE_CHAR_LIMIT = 500
AUTOPILOT_NIGHTLY_TAG_PREFIX = "autopilot-nightly:"
AUTOPILOT_CALLBACK_TAG = "autopilot-callback:v1"
AUTOPILOT_INVITE_CTA_TAG = "autopilot-invite-cta:v1"
AUTOPILOT_DISABLED_TOOLS = ["edit_agent"]
AUTOPILOT_NIGHTLY_EMAIL_TEMPLATE = "nightly_copilot.html.jinja2"
AUTOPILOT_CALLBACK_EMAIL_TEMPLATE = "nightly_copilot_callback.html.jinja2"
AUTOPILOT_INVITE_CTA_EMAIL_TEMPLATE = "nightly_copilot_invite_cta.html.jinja2"
class CallbackTokenConsumeResult(BaseModel):
session_id: str
def wrap_internal_message(content: str) -> str:
return f"<internal>{content}</internal>"
def strip_internal_content(content: str | None) -> str | None:
if content is None:
return None
stripped = INTERNAL_TAG_RE.sub("", content).strip()
return stripped or None
def get_graph_exec_id_for_session(session_id: str) -> str:
return f"{COPILOT_SESSION_PREFIX}{session_id}"
def get_nightly_execution_tag(target_local_date: date) -> str:
return f"{AUTOPILOT_NIGHTLY_TAG_PREFIX}{target_local_date.isoformat()}"
def get_callback_execution_tag() -> str:
return AUTOPILOT_CALLBACK_TAG
def get_invite_cta_execution_tag() -> str:
return AUTOPILOT_INVITE_CTA_TAG
def _get_frontend_base_url() -> str:
return (
settings.config.frontend_base_url or settings.config.platform_base_url
).rstrip("/")
def _bucket_end_for_now(now_utc: datetime) -> datetime:
minute = 30 if now_utc.minute >= 30 else 0
return now_utc.replace(minute=minute, second=0, microsecond=0)
def _resolve_timezone_name(raw_timezone: str | None) -> str:
if not raw_timezone or raw_timezone == "not-set":
return "UTC"
try:
ZoneInfo(raw_timezone)
return raw_timezone
except ZoneInfoNotFoundError:
logger.warning("Unknown timezone %s; falling back to UTC", raw_timezone)
return "UTC"
def _crosses_local_midnight(
bucket_start_utc: datetime,
bucket_end_utc: datetime,
timezone_name: str,
) -> date | None:
tz = ZoneInfo(timezone_name)
start_local = bucket_start_utc.astimezone(tz)
end_local = bucket_end_utc.astimezone(tz)
if start_local.date() == end_local.date():
return None
return end_local.date()
async def _user_has_recent_manual_message(user_id: str, since: datetime) -> bool:
message = await prisma.models.ChatMessage.prisma().find_first(
where={
"role": "user",
"createdAt": {"gte": since},
"Session": {
"is": {
"userId": user_id,
"startType": ChatSessionStartType.MANUAL.value,
}
},
}
)
return message is not None
async def _user_has_session_since(user_id: str, since: datetime) -> bool:
session = await prisma.models.ChatSession.prisma().find_first(
where={"userId": user_id, "createdAt": {"gte": since}}
)
return session is not None
async def _session_exists_for_execution_tag(user_id: str, execution_tag: str) -> bool:
existing = await prisma.models.ChatSession.prisma().find_first(
where={"userId": user_id, "executionTag": execution_tag}
)
return existing is not None
def _render_initial_message(
start_type: ChatSessionStartType,
*,
user_name: str | None,
invited_user: prisma.models.InvitedUser | None = None,
) -> str:
display_name = user_name or "the user"
if start_type == ChatSessionStartType.AUTOPILOT_NIGHTLY:
return wrap_internal_message(
"This is a nightly proactive Copilot session. Review recent manual activity, "
f"do one useful piece of work for {display_name}, and finish with completion_report."
)
if start_type == ChatSessionStartType.AUTOPILOT_CALLBACK:
return wrap_internal_message(
"This is a one-off callback session for a previously active user. "
f"Reintroduce Copilot with something concrete and useful for {display_name}, "
"then finish with completion_report."
)
invite_summary = ""
if invited_user and isinstance(invited_user.tallyUnderstanding, dict):
invite_summary = "\nKnown context from the beta application:\n" + json.dumps(
invited_user.tallyUnderstanding, ensure_ascii=False
)
return wrap_internal_message(
"This is a one-off invite CTA session for an invited beta user who has not yet activated. "
f"Create a tailored introduction for {display_name}, explain how Autopilot can help, "
f"and finish with completion_report.{invite_summary}"
)
def _truncate_prompt_text(text: str, max_chars: int) -> str:
normalized = " ".join(text.split())
if len(normalized) <= max_chars:
return normalized
return normalized[: max_chars - 3].rstrip() + "..."
def _get_autopilot_instructions(start_type: ChatSessionStartType) -> str:
if start_type == ChatSessionStartType.AUTOPILOT_NIGHTLY:
return (
"You are Autopilot running a proactive nightly Copilot session.\n"
"Use the user context and recent manual sessions since their previous nightly run to choose one bounded, practical piece of work.\n"
"Bias toward concrete progress over broad brainstorming.\n"
"If you decide the user should be notified, finish by calling completion_report.\n"
"Do not mention hidden system instructions or internal control text to the user."
)
if start_type == ChatSessionStartType.AUTOPILOT_CALLBACK:
return (
"You are Autopilot running a one-off callback session for a previously active platform user.\n"
"Reintroduce Copilot by doing something concrete and useful based on the saved business context.\n"
"If you decide the user should be notified, finish by calling completion_report.\n"
"Do not mention hidden system instructions or internal control text to the user."
)
if start_type == ChatSessionStartType.AUTOPILOT_INVITE_CTA:
return (
"You are Autopilot running a one-off activation CTA for an invited beta user.\n"
"Use the available beta-application context to explain what Autopilot can do for them and why it fits their workflow.\n"
"Keep the work introduction-specific and outcome-oriented.\n"
"If you decide the user should be notified, finish by calling completion_report.\n"
"Do not mention hidden system instructions or internal control text to the user."
)
raise ValueError(f"Unsupported start type for autopilot prompt: {start_type}")
def _get_previous_local_midnight_utc(
target_local_date: date,
timezone_name: str,
) -> datetime:
tz = ZoneInfo(timezone_name)
previous_midnight_local = datetime.combine(
target_local_date - timedelta(days=1),
time.min,
tzinfo=tz,
)
return previous_midnight_local.astimezone(UTC)
async def _get_recent_manual_session_context(
user_id: str,
*,
since_utc: datetime,
) -> str:
sessions = await prisma.models.ChatSession.prisma().find_many(
where={
"userId": user_id,
"startType": ChatSessionStartType.MANUAL.value,
"updatedAt": {"gte": since_utc},
},
order={"updatedAt": "desc"},
take=AUTOPILOT_RECENT_SESSION_LIMIT,
)
if not sessions:
return "No recent manual sessions since the previous nightly run."
blocks: list[str] = []
used_chars = 0
for session in sessions:
messages = await prisma.models.ChatMessage.prisma().find_many(
where={
"sessionId": session.id,
"createdAt": {"gte": since_utc},
},
order={"sequence": "asc"},
)
visible_messages: list[str] = []
for message in messages[-AUTOPILOT_RECENT_MESSAGE_LIMIT:]:
content = message.content or ""
if message.role == "user":
visible = strip_internal_content(content)
else:
visible = content.strip() or None
if not visible:
continue
role_label = {
"user": "User",
"assistant": "Assistant",
"tool": "Tool",
}.get(message.role, message.role.title())
visible_messages.append(
f"{role_label}: {_truncate_prompt_text(visible, AUTOPILOT_MESSAGE_CHAR_LIMIT)}"
)
if not visible_messages:
continue
title_suffix = f" ({session.title})" if session.title else ""
block = (
f"### Session updated {session.updatedAt.isoformat()}{title_suffix}\n"
+ "\n".join(visible_messages)
)
if used_chars + len(block) > AUTOPILOT_RECENT_CONTEXT_CHAR_LIMIT:
break
blocks.append(block)
used_chars += len(block)
return (
"\n\n".join(blocks)
if blocks
else "No recent manual sessions since the previous nightly run."
)
async def _build_autopilot_system_prompt(
user: prisma.models.User,
*,
start_type: ChatSessionStartType,
timezone_name: str,
target_local_date: date | None = None,
invited_user: prisma.models.InvitedUser | None = None,
) -> str:
understanding = await get_business_understanding(user.id)
context_sections = [
(
format_understanding_for_prompt(understanding)
if understanding
else "No saved business understanding yet."
)
]
if (
start_type == ChatSessionStartType.AUTOPILOT_NIGHTLY
and target_local_date is not None
):
recent_context = await _get_recent_manual_session_context(
user.id,
since_utc=_get_previous_local_midnight_utc(
target_local_date,
timezone_name,
),
)
context_sections.append(
"## Recent Manual Sessions Since Previous Nightly Run\n" + recent_context
)
if invited_user and isinstance(invited_user.tallyUnderstanding, dict):
invite_context = json.dumps(invited_user.tallyUnderstanding, ensure_ascii=False)
context_sections.append("## Beta Application Context\n" + invite_context)
base_prompt = await _get_system_prompt_template("\n\n".join(context_sections))
autopilot_instructions = _get_autopilot_instructions(start_type)
return (
f"{base_prompt}\n\n"
"<autopilot_context>\n"
f"{autopilot_instructions}\n"
"</autopilot_context>"
)
async def _enqueue_session_turn(
session: ChatSession,
*,
message: str,
tool_name: str,
) -> None:
from backend.copilot import stream_registry
turn_id = str(uuid4())
await stream_registry.create_session(
session_id=session.session_id,
user_id=session.user_id,
tool_call_id=tool_name,
tool_name=tool_name,
turn_id=turn_id,
blocking=False,
)
await enqueue_copilot_turn(
session_id=session.session_id,
user_id=session.user_id,
message=message,
turn_id=turn_id,
is_user_message=True,
)
async def _create_autopilot_session(
user: prisma.models.User,
*,
start_type: ChatSessionStartType,
execution_tag: str,
timezone_name: str,
target_local_date: date | None = None,
invited_user: prisma.models.InvitedUser | None = None,
) -> ChatSession | None:
if await _session_exists_for_execution_tag(user.id, execution_tag):
return None
system_prompt = await _build_autopilot_system_prompt(
user,
start_type=start_type,
timezone_name=timezone_name,
target_local_date=target_local_date,
invited_user=invited_user,
)
initial_message = _render_initial_message(
start_type,
user_name=user.name,
invited_user=invited_user,
)
session_config = ChatSessionConfig(
system_prompt_override=system_prompt,
initial_user_message=initial_message,
extra_tools=["completion_report"],
disabled_tools=AUTOPILOT_DISABLED_TOOLS,
)
session = await create_chat_session(
user.id,
start_type=start_type,
execution_tag=execution_tag,
session_config=session_config,
initial_messages=[ChatMessage(role="user", content=initial_message)],
)
await _enqueue_session_turn(
session,
message=initial_message,
tool_name="autopilot_dispatch",
)
return session
async def dispatch_nightly_copilot() -> int:
now_utc = datetime.now(UTC)
bucket_end = _bucket_end_for_now(now_utc)
bucket_start = bucket_end - timedelta(minutes=30)
callback_start = datetime.combine(
settings.config.nightly_copilot_callback_start_date,
time.min,
tzinfo=UTC,
)
invite_cta_start = settings.config.nightly_copilot_invite_cta_start_date
invite_cta_delay = timedelta(
hours=settings.config.nightly_copilot_invite_cta_delay_hours
)
users = await prisma.models.User.prisma().find_many()
invites = await prisma.models.InvitedUser.prisma().find_many(
where={
"authUserId": {
"in": [user.id for user in users],
}
}
)
invites_by_user_id = {
invite.authUserId: invite for invite in invites if invite.authUserId
}
created_count = 0
for user in users:
if not await is_feature_enabled(Flag.NIGHTLY_COPILOT, user.id, default=False):
continue
timezone_name = _resolve_timezone_name(user.timezone)
target_local_date = _crosses_local_midnight(
bucket_start,
bucket_end,
timezone_name,
)
if target_local_date is None:
continue
invited_user = invites_by_user_id.get(user.id)
if (
invited_user is not None
and invited_user.status == prisma.enums.InvitedUserStatus.INVITED
and invited_user.createdAt.date() >= invite_cta_start
and invited_user.createdAt <= now_utc - invite_cta_delay
and not await _session_exists_for_execution_tag(
user.id, get_invite_cta_execution_tag()
)
):
created = await _create_autopilot_session(
user,
start_type=ChatSessionStartType.AUTOPILOT_INVITE_CTA,
execution_tag=get_invite_cta_execution_tag(),
timezone_name=timezone_name,
invited_user=invited_user,
)
created_count += 1 if created else 0
continue
if await _user_has_recent_manual_message(
user.id,
now_utc - timedelta(hours=24),
):
created = await _create_autopilot_session(
user,
start_type=ChatSessionStartType.AUTOPILOT_NIGHTLY,
execution_tag=get_nightly_execution_tag(target_local_date),
timezone_name=timezone_name,
target_local_date=target_local_date,
)
created_count += 1 if created else 0
continue
if await _user_has_session_since(
user.id, callback_start
) and not await _session_exists_for_execution_tag(
user.id, get_callback_execution_tag()
):
created = await _create_autopilot_session(
user,
start_type=ChatSessionStartType.AUTOPILOT_CALLBACK,
execution_tag=get_callback_execution_tag(),
timezone_name=timezone_name,
)
created_count += 1 if created else 0
return created_count
async def _get_pending_approval_metadata(
session: ChatSession,
) -> tuple[int, str | None]:
graph_exec_id = get_graph_exec_id_for_session(session.session_id)
pending_count = await prisma.models.PendingHumanReview.prisma().count(
where={
"userId": session.user_id,
"graphExecId": graph_exec_id,
"status": prisma.enums.ReviewStatus.WAITING,
}
)
return pending_count, graph_exec_id if pending_count > 0 else None
def _extract_completion_report_from_session(
session: ChatSession,
*,
pending_approval_count: int,
) -> CompletionReportInput | None:
tool_outputs = {
message.tool_call_id: message.content
for message in session.messages
if message.role == "tool" and message.tool_call_id
}
latest_report: CompletionReportInput | None = None
for message in session.messages:
if message.role != "assistant" or not message.tool_calls:
continue
for tool_call in message.tool_calls:
function = tool_call.get("function") or {}
if function.get("name") != "completion_report":
continue
tool_call_id = tool_call.get("id")
if not isinstance(tool_call_id, str):
continue
output = tool_outputs.get(tool_call_id)
if not output:
continue
try:
output_payload = json.loads(output)
except Exception:
continue
if (
isinstance(output_payload, dict)
and output_payload.get("type") == "error"
):
continue
try:
raw_arguments = function.get("arguments") or "{}"
report = CompletionReportInput.model_validate(json.loads(raw_arguments))
except Exception:
continue
if pending_approval_count > 0 and not report.approval_summary:
continue
latest_report = report
return latest_report
def _build_completion_report_repair_message(
*,
attempt: int,
pending_approval_count: int,
) -> str:
approval_instruction = ""
if pending_approval_count > 0:
approval_instruction = (
f" There are currently {pending_approval_count} pending approval item(s). "
"If they still exist, include approval_summary."
)
return wrap_internal_message(
"The session completed without a valid completion_report tool call. "
f"This is repair attempt {attempt}. Call completion_report now and do not do any additional user-facing work."
+ approval_instruction
)
async def _queue_completion_report_repair(
session: ChatSession,
*,
pending_approval_count: int,
) -> None:
attempt = session.completion_report_repair_count + 1
repair_message = _build_completion_report_repair_message(
attempt=attempt,
pending_approval_count=pending_approval_count,
)
session.messages.append(ChatMessage(role="user", content=repair_message))
session.completion_report_repair_count = attempt
session.completion_report_repair_queued_at = datetime.now(UTC)
session.completed_at = None
session.completion_report = None
await upsert_chat_session(session)
await _enqueue_session_turn(
session,
message=repair_message,
tool_name="completion_report_repair",
)
async def handle_non_manual_session_completion(session_id: str) -> None:
session = await get_chat_session(session_id)
if session is None or session.is_manual:
return
pending_approval_count, graph_exec_id = await _get_pending_approval_metadata(
session
)
report = _extract_completion_report_from_session(
session,
pending_approval_count=pending_approval_count,
)
if report is not None:
session.completion_report = StoredCompletionReport(
**report.model_dump(),
has_pending_approvals=pending_approval_count > 0,
pending_approval_count=pending_approval_count,
pending_approval_graph_exec_id=graph_exec_id,
saved_at=datetime.now(UTC),
)
session.completion_report_repair_queued_at = None
session.completed_at = datetime.now(UTC)
await upsert_chat_session(session)
return
if session.completion_report_repair_count >= MAX_COMPLETION_REPORT_REPAIRS:
session.completion_report_repair_queued_at = None
session.completed_at = datetime.now(UTC)
await upsert_chat_session(session)
return
await _queue_completion_report_repair(
session,
pending_approval_count=pending_approval_count,
)
def _split_email_paragraphs(text: str | None) -> list[str]:
return [segment.strip() for segment in (text or "").splitlines() if segment.strip()]
async def _create_callback_token(
session: ChatSession,
) -> prisma.models.ChatSessionCallbackToken:
if session.completion_report is None:
raise ValueError("Missing completion report")
callback_session_message = session.completion_report.callback_session_message
if callback_session_message is None:
raise ValueError("Missing callback session message")
return await prisma.models.ChatSessionCallbackToken.prisma().create(
data={
"userId": session.user_id,
"sourceSessionId": session.session_id,
"callbackSessionMessage": callback_session_message,
"expiresAt": datetime.now(UTC)
+ timedelta(hours=settings.config.nightly_copilot_callback_token_ttl_hours),
}
)
def _build_session_link(session_id: str, *, show_autopilot: bool) -> str:
base_url = _get_frontend_base_url()
suffix = "&showAutopilot=1" if show_autopilot else ""
return f"{base_url}/copilot?sessionId={session_id}{suffix}"
def _build_callback_link(token_id: str) -> str:
return f"{_get_frontend_base_url()}/copilot?callbackToken={token_id}"
def _get_completion_email_template_name(start_type: ChatSessionStartType) -> str:
if start_type == ChatSessionStartType.AUTOPILOT_NIGHTLY:
return AUTOPILOT_NIGHTLY_EMAIL_TEMPLATE
if start_type == ChatSessionStartType.AUTOPILOT_CALLBACK:
return AUTOPILOT_CALLBACK_EMAIL_TEMPLATE
if start_type == ChatSessionStartType.AUTOPILOT_INVITE_CTA:
return AUTOPILOT_INVITE_CTA_EMAIL_TEMPLATE
raise ValueError(f"Unsupported start type for completion email: {start_type}")
async def _send_completion_email(session: ChatSession) -> None:
report = session.completion_report
if report is None:
raise ValueError("Missing completion report")
user = await prisma.models.User.prisma().find_unique(where={"id": session.user_id})
if user is None:
raise ValueError(f"User {session.user_id} not found")
approval_cta = report.has_pending_approvals
template_name = _get_completion_email_template_name(session.start_type)
if approval_cta:
cta_url = _build_session_link(session.session_id, show_autopilot=True)
cta_label = "Review in Copilot"
else:
token = await _create_callback_token(session)
cta_url = _build_callback_link(token.id)
cta_label = (
"Try Copilot"
if session.start_type == ChatSessionStartType.AUTOPILOT_INVITE_CTA
else "Open Copilot"
)
EmailSender().send_template(
user_email=user.email,
subject=report.email_title or "Autopilot update",
template_name=template_name,
data={
"email_body_paragraphs": _split_email_paragraphs(report.email_body),
"approval_summary_paragraphs": _split_email_paragraphs(
report.approval_summary
),
"cta_url": cta_url,
"cta_label": cta_label,
},
)
async def send_nightly_copilot_emails() -> int:
from backend.copilot import stream_registry
candidates = await prisma.models.ChatSession.prisma().find_many(
where={
"startType": {"not": ChatSessionStartType.MANUAL.value},
"notificationEmailSentAt": None,
"notificationEmailSkippedAt": None,
},
order={"updatedAt": "asc"},
take=200,
)
processed_count = 0
for candidate in candidates:
session = await get_chat_session(candidate.id)
if session is None or session.is_manual:
continue
active = await stream_registry.get_session(session.session_id)
is_running = active is not None and active.status == "running"
if is_running:
continue
pending_approval_count, graph_exec_id = await _get_pending_approval_metadata(
session
)
if session.completion_report is None:
if session.completion_report_repair_count < MAX_COMPLETION_REPORT_REPAIRS:
await _queue_completion_report_repair(
session,
pending_approval_count=pending_approval_count,
)
continue
session.completed_at = session.completed_at or datetime.now(UTC)
session.completion_report_repair_queued_at = None
session.notification_email_skipped_at = datetime.now(UTC)
await upsert_chat_session(session)
processed_count += 1
continue
session.completed_at = session.completed_at or datetime.now(UTC)
if (
session.completion_report.pending_approval_graph_exec_id is None
and graph_exec_id
):
session.completion_report = session.completion_report.model_copy(
update={
"has_pending_approvals": pending_approval_count > 0,
"pending_approval_count": pending_approval_count,
"pending_approval_graph_exec_id": graph_exec_id,
}
)
if not session.completion_report.should_notify_user:
session.notification_email_skipped_at = datetime.now(UTC)
await upsert_chat_session(session)
processed_count += 1
continue
try:
await _send_completion_email(session)
except Exception:
logger.exception(
"Failed to send nightly copilot email for session %s",
session.session_id,
)
continue
session.notification_email_sent_at = datetime.now(UTC)
await upsert_chat_session(session)
processed_count += 1
return processed_count
async def consume_callback_token(
token_id: str,
user_id: str,
) -> CallbackTokenConsumeResult:
token = await prisma.models.ChatSessionCallbackToken.prisma().find_unique(
where={"id": token_id}
)
if token is None or token.userId != user_id:
raise ValueError("Callback token not found")
if token.expiresAt <= datetime.now(UTC):
raise ValueError("Callback token has expired")
if token.consumedSessionId:
return CallbackTokenConsumeResult(session_id=token.consumedSessionId)
session = await create_chat_session(
user_id,
initial_messages=[
ChatMessage(role="assistant", content=token.callbackSessionMessage)
],
)
await prisma.models.ChatSessionCallbackToken.prisma().update(
where={"id": token_id},
data={
"consumedAt": datetime.now(UTC),
"consumedSessionId": session.session_id,
},
)
return CallbackTokenConsumeResult(session_id=session.session_id)

View File

@@ -0,0 +1,627 @@
import json
from datetime import UTC, date, datetime, timedelta
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import AsyncMock
import prisma.enums
import pytest
from backend.copilot.autopilot import (
AUTOPILOT_CALLBACK_EMAIL_TEMPLATE,
AUTOPILOT_DISABLED_TOOLS,
AUTOPILOT_INVITE_CTA_EMAIL_TEMPLATE,
AUTOPILOT_NIGHTLY_EMAIL_TEMPLATE,
_create_autopilot_session,
_crosses_local_midnight,
_get_completion_email_template_name,
_get_recent_manual_session_context,
_resolve_timezone_name,
consume_callback_token,
dispatch_nightly_copilot,
handle_non_manual_session_completion,
send_nightly_copilot_emails,
strip_internal_content,
wrap_internal_message,
)
from backend.copilot.model import ChatMessage, ChatSession
from backend.copilot.session_types import ChatSessionStartType, StoredCompletionReport
def _build_autopilot_session() -> ChatSession:
return ChatSession.new(
"user-1",
start_type=ChatSessionStartType.AUTOPILOT_NIGHTLY,
)
def test_wrap_and_strip_internal_content() -> None:
wrapped = wrap_internal_message("secret instruction")
assert wrapped == "<internal>secret instruction</internal>"
assert strip_internal_content(wrapped) is None
assert (
strip_internal_content("Visible<internal>secret</internal> text")
== "Visible text"
)
def test_resolve_timezone_name_falls_back_to_utc() -> None:
assert _resolve_timezone_name(None) == "UTC"
assert _resolve_timezone_name("not-set") == "UTC"
assert _resolve_timezone_name("Definitely/Invalid") == "UTC"
assert _resolve_timezone_name("Europe/Madrid") == "Europe/Madrid"
def test_crosses_local_midnight_supports_offset_timezones() -> None:
assert _crosses_local_midnight(
datetime(2026, 3, 13, 18, 0, tzinfo=UTC),
datetime(2026, 3, 13, 18, 30, tzinfo=UTC),
"Asia/Kathmandu",
) == date(2026, 3, 14)
assert (
_crosses_local_midnight(
datetime(2026, 3, 13, 0, 0, tzinfo=UTC),
datetime(2026, 3, 13, 0, 30, tzinfo=UTC),
"UTC",
)
is None
)
@pytest.mark.asyncio
async def test_get_recent_manual_session_context_strips_internal_content(
mocker,
) -> None:
session = SimpleNamespace(
id="sess-1",
title="Manual work",
updatedAt=datetime(2026, 3, 14, 9, 0, tzinfo=UTC),
)
session_prisma = SimpleNamespace(find_many=AsyncMock(return_value=[session]))
message_prisma = SimpleNamespace(
find_many=AsyncMock(
return_value=[
SimpleNamespace(
role="user",
content="<internal>hidden</internal>",
),
SimpleNamespace(
role="user",
content="Visible<internal>hidden</internal> text",
),
SimpleNamespace(
role="assistant",
content="Completed a useful task for the user.",
),
]
)
)
mocker.patch("prisma.models.ChatSession.prisma", return_value=session_prisma)
mocker.patch("prisma.models.ChatMessage.prisma", return_value=message_prisma)
context = await _get_recent_manual_session_context(
"user-1",
since_utc=datetime(2026, 3, 13, 0, 0, tzinfo=UTC),
)
assert "Manual work" in context
assert "Visible text" in context
assert "Completed a useful task for the user." in context
assert "hidden" not in context
@pytest.mark.asyncio
async def test_handle_non_manual_session_completion_saves_report(mocker) -> None:
session = _build_autopilot_session()
session.messages = [
ChatMessage(
role="assistant",
content="Finished the work.",
tool_calls=[
{
"id": "tool-call-1",
"type": "function",
"function": {
"name": "completion_report",
"arguments": json.dumps(
{
"thoughts": "I reviewed the recent context and prepared a useful next step.",
"should_notify_user": True,
"email_title": "Your nightly update",
"email_body": "I found something useful for you.",
"callback_session_message": "Open this chat and I will walk you through it.",
"approval_summary": None,
}
),
},
}
],
),
ChatMessage(
role="tool",
tool_call_id="tool-call-1",
content=json.dumps(
{
"type": "completion_report_saved",
"message": "Completion report recorded successfully.",
}
),
),
]
mocker.patch(
"backend.copilot.autopilot.get_chat_session",
new_callable=AsyncMock,
return_value=session,
)
mocker.patch(
"backend.copilot.autopilot._get_pending_approval_metadata",
new_callable=AsyncMock,
return_value=(0, None),
)
upsert = mocker.patch(
"backend.copilot.autopilot.upsert_chat_session",
new_callable=AsyncMock,
)
await handle_non_manual_session_completion(session.session_id)
assert session.completion_report is not None
assert session.completion_report.email_title == "Your nightly update"
assert session.completed_at is not None
assert session.completion_report_repair_queued_at is None
upsert.assert_awaited_once_with(session)
@pytest.mark.asyncio
async def test_handle_non_manual_session_completion_queues_repair(mocker) -> None:
session = _build_autopilot_session()
session.messages = [ChatMessage(role="assistant", content="Done.")]
mocker.patch(
"backend.copilot.autopilot.get_chat_session",
new_callable=AsyncMock,
return_value=session,
)
mocker.patch(
"backend.copilot.autopilot._get_pending_approval_metadata",
new_callable=AsyncMock,
return_value=(1, "copilot-session-session-1"),
)
upsert = mocker.patch(
"backend.copilot.autopilot.upsert_chat_session",
new_callable=AsyncMock,
)
enqueue = mocker.patch(
"backend.copilot.autopilot._enqueue_session_turn",
new_callable=AsyncMock,
)
await handle_non_manual_session_completion(session.session_id)
assert session.completion_report is None
assert session.completion_report_repair_count == 1
assert session.completion_report_repair_queued_at is not None
assert session.completed_at is None
assert session.messages[-1].role == "user"
assert "completion_report" in (session.messages[-1].content or "")
assert "approval" in (session.messages[-1].content or "").lower()
upsert.assert_awaited_once_with(session)
enqueue.assert_awaited_once()
@pytest.mark.parametrize(
("start_type", "expected_template"),
[
(
ChatSessionStartType.AUTOPILOT_NIGHTLY,
AUTOPILOT_NIGHTLY_EMAIL_TEMPLATE,
),
(
ChatSessionStartType.AUTOPILOT_CALLBACK,
AUTOPILOT_CALLBACK_EMAIL_TEMPLATE,
),
(
ChatSessionStartType.AUTOPILOT_INVITE_CTA,
AUTOPILOT_INVITE_CTA_EMAIL_TEMPLATE,
),
],
)
def test_get_completion_email_template_name(
start_type: ChatSessionStartType,
expected_template: str,
) -> None:
assert _get_completion_email_template_name(start_type) == expected_template
@pytest.mark.asyncio
async def test_create_autopilot_session_disables_configured_tools(mocker) -> None:
created_session = ChatSession.new(
"user-1",
start_type=ChatSessionStartType.AUTOPILOT_NIGHTLY,
)
mocker.patch(
"backend.copilot.autopilot._session_exists_for_execution_tag",
new_callable=AsyncMock,
return_value=False,
)
mocker.patch(
"backend.copilot.autopilot._build_autopilot_system_prompt",
new_callable=AsyncMock,
return_value="system prompt",
)
create_chat_session = mocker.patch(
"backend.copilot.autopilot.create_chat_session",
new_callable=AsyncMock,
return_value=created_session,
)
enqueue = mocker.patch(
"backend.copilot.autopilot._enqueue_session_turn",
new_callable=AsyncMock,
)
user = SimpleNamespace(id="user-1", name="User Name")
session = await _create_autopilot_session(
cast(Any, user),
start_type=ChatSessionStartType.AUTOPILOT_NIGHTLY,
execution_tag="autopilot-nightly:2026-03-13",
timezone_name="UTC",
target_local_date=None,
)
assert session is created_session
session_config = create_chat_session.await_args.kwargs["session_config"]
assert session_config.extra_tools == ["completion_report"]
assert session_config.disabled_tools == AUTOPILOT_DISABLED_TOOLS
enqueue.assert_awaited_once()
@pytest.mark.asyncio
async def test_handle_non_manual_session_completion_stops_after_max_repairs(
mocker,
) -> None:
session = _build_autopilot_session()
session.completion_report_repair_count = 2
session.messages = [ChatMessage(role="assistant", content="Done.")]
mocker.patch(
"backend.copilot.autopilot.get_chat_session",
new_callable=AsyncMock,
return_value=session,
)
mocker.patch(
"backend.copilot.autopilot._get_pending_approval_metadata",
new_callable=AsyncMock,
return_value=(0, None),
)
upsert = mocker.patch(
"backend.copilot.autopilot.upsert_chat_session",
new_callable=AsyncMock,
)
enqueue = mocker.patch(
"backend.copilot.autopilot._enqueue_session_turn",
new_callable=AsyncMock,
)
await handle_non_manual_session_completion(session.session_id)
assert session.completed_at is not None
assert session.completion_report_repair_queued_at is None
upsert.assert_awaited_once_with(session)
enqueue.assert_not_called()
@pytest.mark.asyncio
async def test_dispatch_nightly_copilot_respects_cohort_priority(mocker) -> None:
fixed_now = datetime(2026, 3, 17, 0, 5, tzinfo=UTC)
datetime_mock = mocker.patch(
"backend.copilot.autopilot.datetime",
wraps=datetime,
)
datetime_mock.now.return_value = fixed_now
invite_user = SimpleNamespace(id="invite-user", timezone="UTC", name="Invite User")
nightly_user = SimpleNamespace(
id="nightly-user", timezone="UTC", name="Nightly User"
)
callback_user = SimpleNamespace(
id="callback-user", timezone="UTC", name="Callback User"
)
disabled_user = SimpleNamespace(
id="disabled-user", timezone="UTC", name="Disabled User"
)
invited = SimpleNamespace(
authUserId="invite-user",
status=prisma.enums.InvitedUserStatus.INVITED,
createdAt=fixed_now - timedelta(hours=72),
)
user_prisma = SimpleNamespace(
find_many=AsyncMock(
return_value=[
invite_user,
nightly_user,
callback_user,
disabled_user,
]
)
)
invite_prisma = SimpleNamespace(find_many=AsyncMock(return_value=[invited]))
mocker.patch("prisma.models.User.prisma", return_value=user_prisma)
mocker.patch("prisma.models.InvitedUser.prisma", return_value=invite_prisma)
mocker.patch(
"backend.copilot.autopilot.is_feature_enabled",
new_callable=AsyncMock,
side_effect=lambda _flag, user_id, default=False: user_id != "disabled-user",
)
mocker.patch(
"backend.copilot.autopilot._crosses_local_midnight",
side_effect=lambda *_args, **_kwargs: date(2026, 3, 17),
)
mocker.patch(
"backend.copilot.autopilot._user_has_recent_manual_message",
new_callable=AsyncMock,
side_effect=lambda user_id, _since: user_id == "nightly-user",
)
mocker.patch(
"backend.copilot.autopilot._user_has_session_since",
new_callable=AsyncMock,
side_effect=lambda user_id, _since: (
user_id in {"nightly-user", "callback-user"}
),
)
mocker.patch(
"backend.copilot.autopilot._session_exists_for_execution_tag",
new_callable=AsyncMock,
return_value=False,
)
create_autopilot_session = mocker.patch(
"backend.copilot.autopilot._create_autopilot_session",
new_callable=AsyncMock,
side_effect=[
object(),
object(),
object(),
],
)
created_count = await dispatch_nightly_copilot()
assert created_count == 3
create_calls = create_autopilot_session.await_args_list
assert (
create_calls[0].kwargs["start_type"]
== ChatSessionStartType.AUTOPILOT_INVITE_CTA
)
assert (
create_calls[1].kwargs["start_type"] == ChatSessionStartType.AUTOPILOT_NIGHTLY
)
assert (
create_calls[2].kwargs["start_type"] == ChatSessionStartType.AUTOPILOT_CALLBACK
)
@pytest.mark.asyncio
async def test_send_nightly_copilot_emails_queues_repair_for_missing_report(
mocker,
) -> None:
session = _build_autopilot_session()
candidate = SimpleNamespace(id=session.session_id)
chat_session_prisma = SimpleNamespace(find_many=AsyncMock(return_value=[candidate]))
mocker.patch("prisma.models.ChatSession.prisma", return_value=chat_session_prisma)
mocker.patch(
"backend.copilot.autopilot.get_chat_session",
new_callable=AsyncMock,
return_value=session,
)
mocker.patch(
"backend.copilot.stream_registry.get_session",
new_callable=AsyncMock,
return_value=None,
)
mocker.patch(
"backend.copilot.autopilot._get_pending_approval_metadata",
new_callable=AsyncMock,
return_value=(0, None),
)
queue_repair = mocker.patch(
"backend.copilot.autopilot._queue_completion_report_repair",
new_callable=AsyncMock,
)
processed = await send_nightly_copilot_emails()
assert processed == 0
queue_repair.assert_awaited_once_with(session, pending_approval_count=0)
@pytest.mark.asyncio
async def test_send_nightly_copilot_emails_sends_and_marks_sent(mocker) -> None:
session = _build_autopilot_session()
session.completion_report = StoredCompletionReport(
thoughts="Did useful work.",
should_notify_user=True,
email_title="Autopilot update",
email_body="Useful update",
callback_session_message="Open this session",
approval_summary=None,
has_pending_approvals=False,
pending_approval_count=0,
pending_approval_graph_exec_id=None,
saved_at=datetime.now(UTC),
)
candidate = SimpleNamespace(id=session.session_id)
chat_session_prisma = SimpleNamespace(find_many=AsyncMock(return_value=[candidate]))
mocker.patch("prisma.models.ChatSession.prisma", return_value=chat_session_prisma)
mocker.patch(
"backend.copilot.autopilot.get_chat_session",
new_callable=AsyncMock,
return_value=session,
)
mocker.patch(
"backend.copilot.stream_registry.get_session",
new_callable=AsyncMock,
return_value=None,
)
mocker.patch(
"backend.copilot.autopilot._get_pending_approval_metadata",
new_callable=AsyncMock,
return_value=(1, "copilot-session-session-1"),
)
send_email = mocker.patch(
"backend.copilot.autopilot._send_completion_email",
new_callable=AsyncMock,
)
upsert = mocker.patch(
"backend.copilot.autopilot.upsert_chat_session",
new_callable=AsyncMock,
)
processed = await send_nightly_copilot_emails()
assert processed == 1
assert session.notification_email_sent_at is not None
assert session.completion_report.pending_approval_graph_exec_id == (
"copilot-session-session-1"
)
send_email.assert_awaited_once_with(session)
upsert.assert_awaited_once_with(session)
@pytest.mark.asyncio
async def test_send_nightly_copilot_emails_skips_when_should_not_notify(mocker) -> None:
session = _build_autopilot_session()
session.completion_report = StoredCompletionReport(
thoughts="Did useful work.",
should_notify_user=False,
email_title=None,
email_body=None,
callback_session_message=None,
approval_summary=None,
has_pending_approvals=False,
pending_approval_count=0,
pending_approval_graph_exec_id=None,
saved_at=datetime.now(UTC),
)
candidate = SimpleNamespace(id=session.session_id)
chat_session_prisma = SimpleNamespace(find_many=AsyncMock(return_value=[candidate]))
mocker.patch("prisma.models.ChatSession.prisma", return_value=chat_session_prisma)
mocker.patch(
"backend.copilot.autopilot.get_chat_session",
new_callable=AsyncMock,
return_value=session,
)
mocker.patch(
"backend.copilot.stream_registry.get_session",
new_callable=AsyncMock,
return_value=None,
)
mocker.patch(
"backend.copilot.autopilot._get_pending_approval_metadata",
new_callable=AsyncMock,
return_value=(0, None),
)
upsert = mocker.patch(
"backend.copilot.autopilot.upsert_chat_session",
new_callable=AsyncMock,
)
send_email = mocker.patch(
"backend.copilot.autopilot._send_completion_email",
new_callable=AsyncMock,
)
processed = await send_nightly_copilot_emails()
assert processed == 1
assert session.notification_email_skipped_at is not None
send_email.assert_not_called()
upsert.assert_awaited_once_with(session)
@pytest.mark.asyncio
async def test_consume_callback_token_reuses_existing_session(mocker) -> None:
token = SimpleNamespace(
id="token-1",
userId="user-1",
expiresAt=datetime.now(UTC) + timedelta(hours=1),
consumedSessionId="sess-existing",
)
token_prisma = SimpleNamespace(
find_unique=AsyncMock(return_value=token),
update=AsyncMock(),
)
mocker.patch(
"prisma.models.ChatSessionCallbackToken.prisma",
return_value=token_prisma,
)
create_chat_session = mocker.patch(
"backend.copilot.autopilot.create_chat_session",
new_callable=AsyncMock,
)
result = await consume_callback_token("token-1", "user-1")
assert result.session_id == "sess-existing"
create_chat_session.assert_not_called()
token_prisma.update.assert_not_called()
@pytest.mark.asyncio
async def test_consume_callback_token_creates_manual_session(mocker) -> None:
token = SimpleNamespace(
id="token-1",
userId="user-1",
expiresAt=datetime.now(UTC) + timedelta(hours=1),
consumedSessionId=None,
callbackSessionMessage="Open this chat",
)
token_prisma = SimpleNamespace(
find_unique=AsyncMock(return_value=token),
update=AsyncMock(),
)
created_session = ChatSession.new("user-1")
mocker.patch(
"prisma.models.ChatSessionCallbackToken.prisma",
return_value=token_prisma,
)
create_chat_session = mocker.patch(
"backend.copilot.autopilot.create_chat_session",
new_callable=AsyncMock,
return_value=created_session,
)
result = await consume_callback_token("token-1", "user-1")
assert result.session_id == created_session.session_id
create_chat_session.assert_awaited_once()
create_kwargs = create_chat_session.await_args.kwargs
assert create_kwargs["initial_messages"][0].role == "assistant"
assert create_kwargs["initial_messages"][0].content == "Open this chat"
token_prisma.update.assert_awaited_once()
@pytest.mark.asyncio
async def test_consume_callback_token_rejects_expired_token(mocker) -> None:
token = SimpleNamespace(
id="token-1",
userId="user-1",
expiresAt=datetime.now(UTC) - timedelta(minutes=1),
consumedSessionId=None,
callbackSessionMessage="Open this chat",
)
token_prisma = SimpleNamespace(
find_unique=AsyncMock(return_value=token),
)
mocker.patch(
"prisma.models.ChatSessionCallbackToken.prisma",
return_value=token_prisma,
)
with pytest.raises(ValueError, match="expired"):
await consume_callback_token("token-1", "user-1")

View File

@@ -38,8 +38,8 @@ from backend.copilot.response_model import (
StreamToolOutputAvailable,
)
from backend.copilot.service import (
_build_system_prompt,
_generate_session_title,
_resolve_system_prompt,
client,
config,
)
@@ -160,7 +160,7 @@ async def stream_chat_completion_baseline(
session = await upsert_chat_session(session)
# Generate title for new sessions
if is_user_message and not session.title:
if is_user_message and session.is_manual and not session.title:
user_messages = [m for m in session.messages if m.role == "user"]
if len(user_messages) == 1:
first_message = user_messages[0].content or message or ""
@@ -177,16 +177,20 @@ async def stream_chat_completion_baseline(
# changes from concurrent chats updating business understanding.
is_first_turn = len(session.messages) <= 1
if is_first_turn:
base_system_prompt, _ = await _build_system_prompt(
user_id, has_conversation_history=False
base_system_prompt, _ = await _resolve_system_prompt(
session,
user_id,
has_conversation_history=False,
)
else:
base_system_prompt, _ = await _build_system_prompt(
user_id=None, has_conversation_history=True
base_system_prompt, _ = await _resolve_system_prompt(
session,
user_id=None,
has_conversation_history=True,
)
# Append tool documentation and technical notes
system_prompt = base_system_prompt + get_baseline_supplement()
system_prompt = base_system_prompt + get_baseline_supplement(session)
# Compress context if approaching the model's token limit
messages_for_context = await _compress_session_messages(session.messages)
@@ -199,7 +203,7 @@ async def stream_chat_completion_baseline(
if msg.role in ("user", "assistant") and msg.content:
openai_messages.append({"role": msg.role, "content": msg.content})
tools = get_available_tools()
tools = get_available_tools(session)
yield StreamStart(messageId=message_id, sessionId=session_id)

View File

@@ -19,8 +19,10 @@ from backend.data import db
from backend.util.json import SafeJson, sanitize_string
from .model import ChatMessage, ChatSession, ChatSessionInfo
from .session_types import ChatSessionStartType
logger = logging.getLogger(__name__)
_UNSET = object()
async def get_chat_session(session_id: str) -> ChatSession | None:
@@ -35,6 +37,9 @@ async def get_chat_session(session_id: str) -> ChatSession | None:
async def create_chat_session(
session_id: str,
user_id: str,
start_type: ChatSessionStartType = ChatSessionStartType.MANUAL,
execution_tag: str | None = None,
session_config: dict[str, Any] | None = None,
) -> ChatSessionInfo:
"""Create a new chat session in the database."""
data = ChatSessionCreateInput(
@@ -43,6 +48,9 @@ async def create_chat_session(
credentials=SafeJson({}),
successfulAgentRuns=SafeJson({}),
successfulAgentSchedules=SafeJson({}),
startType=start_type.value,
executionTag=execution_tag,
sessionConfig=SafeJson(session_config or {}),
)
prisma_session = await PrismaChatSession.prisma().create(data=data)
return ChatSessionInfo.from_db(prisma_session)
@@ -56,6 +64,15 @@ async def update_chat_session(
total_prompt_tokens: int | None = None,
total_completion_tokens: int | None = None,
title: str | None = None,
start_type: ChatSessionStartType | None = None,
execution_tag: str | None | object = _UNSET,
session_config: dict[str, Any] | None = None,
completion_report: dict[str, Any] | None | object = _UNSET,
completion_report_repair_count: int | None = None,
completion_report_repair_queued_at: datetime | None | object = _UNSET,
completed_at: datetime | None | object = _UNSET,
notification_email_sent_at: datetime | None | object = _UNSET,
notification_email_skipped_at: datetime | None | object = _UNSET,
) -> ChatSession | None:
"""Update a chat session's metadata."""
data: ChatSessionUpdateInput = {"updatedAt": datetime.now(UTC)}
@@ -72,6 +89,26 @@ async def update_chat_session(
data["totalCompletionTokens"] = total_completion_tokens
if title is not None:
data["title"] = title
if start_type is not None:
data["startType"] = start_type.value
if execution_tag is not _UNSET:
data["executionTag"] = execution_tag
if session_config is not None:
data["sessionConfig"] = SafeJson(session_config)
if completion_report is not _UNSET:
data["completionReport"] = (
SafeJson(completion_report) if completion_report is not None else None
)
if completion_report_repair_count is not None:
data["completionReportRepairCount"] = completion_report_repair_count
if completion_report_repair_queued_at is not _UNSET:
data["completionReportRepairQueuedAt"] = completion_report_repair_queued_at
if completed_at is not _UNSET:
data["completedAt"] = completed_at
if notification_email_sent_at is not _UNSET:
data["notificationEmailSentAt"] = notification_email_sent_at
if notification_email_skipped_at is not _UNSET:
data["notificationEmailSkippedAt"] = notification_email_skipped_at
session = await PrismaChatSession.prisma().update(
where={"id": session_id},
@@ -256,10 +293,14 @@ async def get_user_chat_sessions(
user_id: str,
limit: int = 50,
offset: int = 0,
with_auto: bool = False,
) -> list[ChatSessionInfo]:
"""Get chat sessions for a user, ordered by most recent."""
prisma_sessions = await PrismaChatSession.prisma().find_many(
where={"userId": user_id},
where={
"userId": user_id,
**({} if with_auto else {"startType": ChatSessionStartType.MANUAL.value}),
},
order={"updatedAt": "desc"},
take=limit,
skip=offset,
@@ -267,9 +308,17 @@ async def get_user_chat_sessions(
return [ChatSessionInfo.from_db(s) for s in prisma_sessions]
async def get_user_session_count(user_id: str) -> int:
async def get_user_session_count(
user_id: str,
with_auto: bool = False,
) -> int:
"""Get the total number of chat sessions for a user."""
return await PrismaChatSession.prisma().count(where={"userId": user_id})
return await PrismaChatSession.prisma().count(
where={
"userId": user_id,
**({} if with_auto else {"startType": ChatSessionStartType.MANUAL.value}),
}
)
async def delete_chat_session(session_id: str, user_id: str | None = None) -> bool:

View File

@@ -21,7 +21,7 @@ from openai.types.chat.chat_completion_message_tool_call_param import (
)
from prisma.models import ChatMessage as PrismaChatMessage
from prisma.models import ChatSession as PrismaChatSession
from pydantic import BaseModel
from pydantic import BaseModel, Field
from backend.data.db_accessors import chat_db
from backend.data.redis_client import get_redis_async
@@ -29,6 +29,11 @@ from backend.util import json
from backend.util.exceptions import DatabaseError, RedisError
from .config import ChatConfig
from .session_types import (
ChatSessionConfig,
ChatSessionStartType,
StoredCompletionReport,
)
logger = logging.getLogger(__name__)
config = ChatConfig()
@@ -80,11 +85,20 @@ class ChatSessionInfo(BaseModel):
user_id: str
title: str | None = None
usage: list[Usage]
credentials: dict[str, dict] = {} # Map of provider -> credential metadata
credentials: dict[str, dict] = Field(default_factory=dict)
started_at: datetime
updated_at: datetime
successful_agent_runs: dict[str, int] = {}
successful_agent_schedules: dict[str, int] = {}
successful_agent_runs: dict[str, int] = Field(default_factory=dict)
successful_agent_schedules: dict[str, int] = Field(default_factory=dict)
start_type: ChatSessionStartType = ChatSessionStartType.MANUAL
execution_tag: str | None = None
session_config: ChatSessionConfig = Field(default_factory=ChatSessionConfig)
completion_report: StoredCompletionReport | None = None
completion_report_repair_count: int = 0
completion_report_repair_queued_at: datetime | None = None
completed_at: datetime | None = None
notification_email_sent_at: datetime | None = None
notification_email_skipped_at: datetime | None = None
@classmethod
def from_db(cls, prisma_session: PrismaChatSession) -> Self:
@@ -97,6 +111,8 @@ class ChatSessionInfo(BaseModel):
successful_agent_schedules = _parse_json_field(
prisma_session.successfulAgentSchedules, default={}
)
session_config = _parse_json_field(prisma_session.sessionConfig, default={})
completion_report = _parse_json_field(prisma_session.completionReport)
# Calculate usage from token counts
usage = []
@@ -110,6 +126,20 @@ class ChatSessionInfo(BaseModel):
)
)
parsed_session_config = ChatSessionConfig.model_validate(session_config or {})
parsed_completion_report = None
if isinstance(completion_report, dict):
try:
parsed_completion_report = StoredCompletionReport.model_validate(
completion_report
)
except Exception:
logger.warning(
"Invalid completionReport payload on session %s",
prisma_session.id,
exc_info=True,
)
return cls(
session_id=prisma_session.id,
user_id=prisma_session.userId,
@@ -120,6 +150,15 @@ class ChatSessionInfo(BaseModel):
updated_at=prisma_session.updatedAt,
successful_agent_runs=successful_agent_runs,
successful_agent_schedules=successful_agent_schedules,
start_type=ChatSessionStartType(str(prisma_session.startType)),
execution_tag=prisma_session.executionTag,
session_config=parsed_session_config,
completion_report=parsed_completion_report,
completion_report_repair_count=prisma_session.completionReportRepairCount,
completion_report_repair_queued_at=prisma_session.completionReportRepairQueuedAt,
completed_at=prisma_session.completedAt,
notification_email_sent_at=prisma_session.notificationEmailSentAt,
notification_email_skipped_at=prisma_session.notificationEmailSkippedAt,
)
@@ -127,7 +166,14 @@ class ChatSession(ChatSessionInfo):
messages: list[ChatMessage]
@classmethod
def new(cls, user_id: str) -> Self:
def new(
cls,
user_id: str,
*,
start_type: ChatSessionStartType = ChatSessionStartType.MANUAL,
execution_tag: str | None = None,
session_config: ChatSessionConfig | None = None,
) -> Self:
return cls(
session_id=str(uuid.uuid4()),
user_id=user_id,
@@ -137,6 +183,9 @@ class ChatSession(ChatSessionInfo):
credentials={},
started_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
start_type=start_type,
execution_tag=execution_tag,
session_config=session_config or ChatSessionConfig(),
)
@classmethod
@@ -152,6 +201,16 @@ class ChatSession(ChatSessionInfo):
messages=[ChatMessage.from_db(m) for m in prisma_session.Messages],
)
@property
def is_manual(self) -> bool:
return self.start_type == ChatSessionStartType.MANUAL
def allows_tool(self, tool_name: str) -> bool:
return self.session_config.allows_tool(tool_name)
def disables_tool(self, tool_name: str) -> bool:
return self.session_config.disables_tool(tool_name)
def add_tool_call_to_current_turn(self, tool_call: dict) -> None:
"""Attach a tool_call to the current turn's assistant message.
@@ -524,6 +583,9 @@ async def _save_session_to_db(
await db.create_chat_session(
session_id=session.session_id,
user_id=session.user_id,
start_type=session.start_type,
execution_tag=session.execution_tag,
session_config=session.session_config.model_dump(mode="json"),
)
existing_message_count = 0
@@ -539,6 +601,19 @@ async def _save_session_to_db(
successful_agent_schedules=session.successful_agent_schedules,
total_prompt_tokens=total_prompt,
total_completion_tokens=total_completion,
start_type=session.start_type,
execution_tag=session.execution_tag,
session_config=session.session_config.model_dump(mode="json"),
completion_report=(
session.completion_report.model_dump(mode="json")
if session.completion_report
else None
),
completion_report_repair_count=session.completion_report_repair_count,
completion_report_repair_queued_at=session.completion_report_repair_queued_at,
completed_at=session.completed_at,
notification_email_sent_at=session.notification_email_sent_at,
notification_email_skipped_at=session.notification_email_skipped_at,
)
# Add new messages (only those after existing count)
@@ -601,7 +676,14 @@ async def append_and_save_message(session_id: str, message: ChatMessage) -> Chat
return session
async def create_chat_session(user_id: str) -> ChatSession:
async def create_chat_session(
user_id: str,
*,
start_type: ChatSessionStartType = ChatSessionStartType.MANUAL,
execution_tag: str | None = None,
session_config: ChatSessionConfig | None = None,
initial_messages: list[ChatMessage] | None = None,
) -> ChatSession:
"""Create a new chat session and persist it.
Raises:
@@ -609,14 +691,30 @@ async def create_chat_session(user_id: str) -> ChatSession:
callers never receive a non-persisted session that only exists
in cache (which would be lost when the cache expires).
"""
session = ChatSession.new(user_id)
session = ChatSession.new(
user_id,
start_type=start_type,
execution_tag=execution_tag,
session_config=session_config,
)
if initial_messages:
session.messages.extend(initial_messages)
# Create in database first - fail fast if this fails
try:
await chat_db().create_chat_session(
session_id=session.session_id,
user_id=user_id,
start_type=session.start_type,
execution_tag=session.execution_tag,
session_config=session.session_config.model_dump(mode="json"),
)
if session.messages:
await _save_session_to_db(
session,
0,
skip_existence_check=True,
)
except Exception as e:
logger.error(f"Failed to create session {session.session_id} in database: {e}")
raise DatabaseError(
@@ -636,6 +734,7 @@ async def get_user_sessions(
user_id: str,
limit: int = 50,
offset: int = 0,
with_auto: bool = False,
) -> tuple[list[ChatSessionInfo], int]:
"""Get chat sessions for a user from the database with total count.
@@ -644,8 +743,16 @@ async def get_user_sessions(
number of sessions for the user (not just the current page).
"""
db = chat_db()
sessions = await db.get_user_chat_sessions(user_id, limit, offset)
total_count = await db.get_user_session_count(user_id)
sessions = await db.get_user_chat_sessions(
user_id,
limit,
offset,
with_auto=with_auto,
)
total_count = await db.get_user_session_count(
user_id,
with_auto=with_auto,
)
return sessions, total_count

View File

@@ -19,6 +19,7 @@ from .model import (
get_chat_session,
upsert_chat_session,
)
from .session_types import ChatSessionConfig, ChatSessionStartType
messages = [
ChatMessage(content="Hello, how are you?", role="user"),
@@ -46,7 +47,15 @@ messages = [
@pytest.mark.asyncio(loop_scope="session")
async def test_chatsession_serialization_deserialization():
s = ChatSession.new(user_id="abc123")
s = ChatSession.new(
user_id="abc123",
start_type=ChatSessionStartType.AUTOPILOT_NIGHTLY,
execution_tag="autopilot-nightly:2026-03-13",
session_config=ChatSessionConfig(
extra_tools=["completion_report"],
disabled_tools=["edit_agent"],
),
)
s.messages = messages
s.usage = [Usage(prompt_tokens=100, completion_tokens=200, total_tokens=300)]
serialized = s.model_dump_json()

View File

@@ -6,7 +6,7 @@ handling the distinction between:
- Local mode vs E2B mode (storage/filesystem differences)
"""
from backend.copilot.tools import TOOL_REGISTRY
from backend.copilot.tools import iter_available_tools
# Shared technical notes that apply to both SDK and baseline modes
_SHARED_TOOL_NOTES = """\
@@ -161,7 +161,7 @@ def _get_cloud_sandbox_supplement() -> str:
)
def _generate_tool_documentation() -> str:
def _generate_tool_documentation(session=None) -> str:
"""Auto-generate tool documentation from TOOL_REGISTRY.
NOTE: This is ONLY used in baseline mode (direct OpenAI API).
@@ -177,11 +177,7 @@ def _generate_tool_documentation() -> str:
docs = "\n## AVAILABLE TOOLS\n\n"
# Sort tools alphabetically for consistent output
# Filter by is_available to match get_available_tools() behavior
for name in sorted(TOOL_REGISTRY.keys()):
tool = TOOL_REGISTRY[name]
if not tool.is_available:
continue
for name, tool in sorted(iter_available_tools(session), key=lambda item: item[0]):
schema = tool.as_openai_tool()
desc = schema["function"].get("description", "No description available")
# Format as bullet list with tool name in code style
@@ -209,7 +205,7 @@ def get_sdk_supplement(use_e2b: bool, cwd: str = "") -> str:
return _get_local_storage_supplement(cwd)
def get_baseline_supplement() -> str:
def get_baseline_supplement(session=None) -> str:
"""Get the supplement for baseline mode (direct OpenAI API).
Baseline mode INCLUDES auto-generated tool documentation because the
@@ -219,5 +215,5 @@ def get_baseline_supplement() -> str:
Returns:
The supplement string to append to the system prompt
"""
tool_docs = _generate_tool_documentation()
tool_docs = _generate_tool_documentation(session)
return tool_docs + _SHARED_TOOL_NOTES

View File

@@ -56,9 +56,9 @@ from ..response_model import (
StreamToolOutputAvailable,
)
from ..service import (
_build_system_prompt,
_generate_session_title,
_is_langfuse_configured,
_resolve_system_prompt,
)
from ..tools.e2b_sandbox import get_or_create_sandbox, pause_sandbox_direct
from ..tools.sandbox import WORKSPACE_PREFIX, make_session_path
@@ -689,7 +689,7 @@ async def stream_chat_completion_sdk(
session = await upsert_chat_session(session)
# Generate title for new sessions (first user message)
if is_user_message and not session.title:
if is_user_message and session.is_manual and not session.title:
user_messages = [m for m in session.messages if m.role == "user"]
if len(user_messages) == 1:
first_message = user_messages[0].content or message or ""
@@ -804,7 +804,11 @@ async def stream_chat_completion_sdk(
e2b_sandbox, (base_system_prompt, _), dl = await asyncio.gather(
_setup_e2b(),
_build_system_prompt(user_id, has_conversation_history=has_history),
_resolve_system_prompt(
session,
user_id,
has_conversation_history=has_history,
),
_fetch_transcript(),
)
@@ -861,7 +865,7 @@ async def stream_chat_completion_sdk(
"Claude Code CLI subscription (requires `claude login`)."
)
mcp_server = create_copilot_mcp_server(use_e2b=use_e2b)
mcp_server = create_copilot_mcp_server(session, use_e2b=use_e2b)
sdk_model = _resolve_sdk_model()
@@ -875,7 +879,7 @@ async def stream_chat_completion_sdk(
on_compact=compaction.on_compact,
)
allowed = get_copilot_tool_names(use_e2b=use_e2b)
allowed = get_copilot_tool_names(session, use_e2b=use_e2b)
disallowed = get_sdk_disallowed_tools(use_e2b=use_e2b)
def _on_stderr(line: str) -> None:

View File

@@ -205,6 +205,29 @@ class TestPromptSupplement:
):
assert "`browser_navigate`" in docs
def test_baseline_supplement_respects_session_disabled_tools(self):
"""Session-specific docs should hide disabled tools and include added session tools."""
from backend.copilot.model import ChatSession
from backend.copilot.prompting import get_baseline_supplement
from backend.copilot.session_types import (
ChatSessionConfig,
ChatSessionStartType,
)
session = ChatSession.new(
"user-1",
start_type=ChatSessionStartType.AUTOPILOT_NIGHTLY,
session_config=ChatSessionConfig(
extra_tools=["completion_report"],
disabled_tools=["edit_agent"],
),
)
docs = get_baseline_supplement(session)
assert "`completion_report`" in docs
assert "`edit_agent`" not in docs
def test_baseline_supplement_includes_workflows(self):
"""Baseline supplement should include workflow guidance in tool descriptions."""
from backend.copilot.prompting import get_baseline_supplement
@@ -219,15 +242,13 @@ class TestPromptSupplement:
def test_baseline_supplement_completeness(self):
"""All available tools from TOOL_REGISTRY should appear in baseline supplement."""
from backend.copilot.prompting import get_baseline_supplement
from backend.copilot.tools import TOOL_REGISTRY
from backend.copilot.tools import iter_available_tools
docs = get_baseline_supplement()
# Verify each available registered tool is documented
# (matches _generate_tool_documentation which filters by is_available)
for tool_name, tool in TOOL_REGISTRY.items():
if not tool.is_available:
continue
# (matches _generate_tool_documentation which filters with iter_available_tools)
for tool_name, _ in iter_available_tools():
assert (
f"`{tool_name}`" in docs
), f"Tool '{tool_name}' missing from baseline supplement"
@@ -277,14 +298,12 @@ class TestPromptSupplement:
def test_baseline_supplement_no_duplicate_tools(self):
"""No tool should appear multiple times in baseline supplement."""
from backend.copilot.prompting import get_baseline_supplement
from backend.copilot.tools import TOOL_REGISTRY
from backend.copilot.tools import iter_available_tools
docs = get_baseline_supplement()
# Count occurrences of each available tool in the entire supplement
for tool_name, tool in TOOL_REGISTRY.items():
if not tool.is_available:
continue
for tool_name, _ in iter_available_tools():
# Count how many times this tool appears as a bullet point
count = docs.count(f"- **`{tool_name}`**")
assert count == 1, f"Tool '{tool_name}' appears {count} times (should be 1)"

View File

@@ -32,7 +32,7 @@ from backend.copilot.sdk.file_ref import (
expand_file_refs_in_args,
read_file_bytes,
)
from backend.copilot.tools import TOOL_REGISTRY
from backend.copilot.tools import iter_available_tools
from backend.copilot.tools.base import BaseTool
from backend.util.truncate import truncate
@@ -338,7 +338,11 @@ def _text_from_mcp_result(result: dict[str, Any]) -> str:
)
def create_copilot_mcp_server(*, use_e2b: bool = False):
def create_copilot_mcp_server(
session: ChatSession,
*,
use_e2b: bool = False,
):
"""Create an in-process MCP server configuration for CoPilot tools.
When *use_e2b* is True, five additional MCP file tools are registered
@@ -387,7 +391,7 @@ def create_copilot_mcp_server(*, use_e2b: bool = False):
sdk_tools = []
for tool_name, base_tool in TOOL_REGISTRY.items():
for tool_name, base_tool in iter_available_tools(session):
handler = create_tool_handler(base_tool)
decorated = tool(
tool_name,
@@ -475,25 +479,30 @@ DANGEROUS_PATTERNS = [
r"subprocess",
]
# Static tool name list for the non-E2B case (backward compatibility).
COPILOT_TOOL_NAMES = [
*[f"{MCP_TOOL_PREFIX}{name}" for name in TOOL_REGISTRY.keys()],
f"{MCP_TOOL_PREFIX}{_READ_TOOL_NAME}",
*_SDK_BUILTIN_TOOLS,
]
def get_copilot_tool_names(*, use_e2b: bool = False) -> list[str]:
def get_copilot_tool_names(
session: ChatSession,
*,
use_e2b: bool = False,
) -> list[str]:
"""Build the ``allowed_tools`` list for :class:`ClaudeAgentOptions`.
When *use_e2b* is True the SDK built-in file tools are replaced by MCP
equivalents that route to the E2B sandbox.
"""
tool_names = [
f"{MCP_TOOL_PREFIX}{name}" for name, _ in iter_available_tools(session)
]
if not use_e2b:
return list(COPILOT_TOOL_NAMES)
return [
*tool_names,
f"{MCP_TOOL_PREFIX}{_READ_TOOL_NAME}",
*_SDK_BUILTIN_TOOLS,
]
return [
*[f"{MCP_TOOL_PREFIX}{name}" for name in TOOL_REGISTRY.keys()],
*tool_names,
f"{MCP_TOOL_PREFIX}{_READ_TOOL_NAME}",
*[f"{MCP_TOOL_PREFIX}{name}" for name in E2B_FILE_TOOL_NAMES],
*_SDK_BUILTIN_ALWAYS,

View File

@@ -3,11 +3,14 @@
import pytest
from backend.copilot.context import get_sdk_cwd
from backend.copilot.model import ChatSession
from backend.copilot.session_types import ChatSessionConfig, ChatSessionStartType
from backend.util.truncate import truncate
from .tool_adapter import (
_MCP_MAX_CHARS,
_text_from_mcp_result,
get_copilot_tool_names,
pop_pending_tool_output,
set_execution_context,
stash_pending_tool_output,
@@ -168,3 +171,20 @@ class TestTruncationAndStashIntegration:
text = _text_from_mcp_result(truncated)
assert len(text) < len(big_text)
assert len(str(truncated)) <= _MCP_MAX_CHARS
class TestSessionToolFiltering:
def test_disabled_tools_are_removed_from_sdk_allowed_tools(self):
session = ChatSession.new(
"user-1",
start_type=ChatSessionStartType.AUTOPILOT_NIGHTLY,
session_config=ChatSessionConfig(
extra_tools=["completion_report"],
disabled_tools=["edit_agent"],
),
)
tool_names = get_copilot_tool_names(session)
assert "mcp__copilot__completion_report" in tool_names
assert "mcp__copilot__edit_agent" not in tool_names

View File

@@ -22,7 +22,7 @@ from backend.util.exceptions import NotAuthorizedError, NotFoundError
from backend.util.settings import AppEnvironment, Settings
from .config import ChatConfig
from .model import ChatSessionInfo, get_chat_session, upsert_chat_session
from .model import ChatSession, ChatSessionInfo, get_chat_session, upsert_chat_session
logger = logging.getLogger(__name__)
@@ -131,6 +131,21 @@ async def _build_system_prompt(
return compiled, understanding
async def _resolve_system_prompt(
session: ChatSession,
user_id: str | None,
*,
has_conversation_history: bool = False,
) -> tuple[str, Any]:
override = session.session_config.system_prompt_override
if override:
return override, None
return await _build_system_prompt(
user_id,
has_conversation_history=has_conversation_history,
)
async def _generate_session_title(
message: str,
user_id: str | None = None,

View File

@@ -0,0 +1,61 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, Field, model_validator
class ChatSessionStartType(str, Enum):
MANUAL = "MANUAL"
AUTOPILOT_NIGHTLY = "AUTOPILOT_NIGHTLY"
AUTOPILOT_CALLBACK = "AUTOPILOT_CALLBACK"
AUTOPILOT_INVITE_CTA = "AUTOPILOT_INVITE_CTA"
class ChatSessionConfig(BaseModel):
system_prompt_override: str | None = None
initial_user_message: str | None = None
initial_assistant_message: str | None = None
extra_tools: list[str] = Field(default_factory=list)
disabled_tools: list[str] = Field(default_factory=list)
def allows_tool(self, tool_name: str) -> bool:
return tool_name in self.extra_tools
def disables_tool(self, tool_name: str) -> bool:
return tool_name in self.disabled_tools
class CompletionReportInput(BaseModel):
thoughts: str
should_notify_user: bool
email_title: str | None = None
email_body: str | None = None
callback_session_message: str | None = None
approval_summary: str | None = None
@model_validator(mode="after")
def validate_notification_fields(self) -> "CompletionReportInput":
if self.should_notify_user:
missing = [
field_name
for field_name in (
"email_title",
"email_body",
"callback_session_message",
)
if not getattr(self, field_name)
]
if missing:
raise ValueError(
"Missing required notification fields: " + ", ".join(missing)
)
return self
class StoredCompletionReport(CompletionReportInput):
has_pending_approvals: bool
pending_approval_count: int
pending_approval_graph_exec_id: str | None = None
saved_at: datetime

View File

@@ -774,6 +774,18 @@ async def mark_session_completed(
f"for session {session_id}: {e}"
)
try:
from backend.copilot.autopilot import handle_non_manual_session_completion
await handle_non_manual_session_completion(session_id)
except Exception as e:
logger.warning(
"Failed to process non-manual completion for session %s: %s",
session_id,
e,
exc_info=True,
)
return True

View File

@@ -12,6 +12,7 @@ from .agent_browser import BrowserActTool, BrowserNavigateTool, BrowserScreensho
from .agent_output import AgentOutputTool
from .base import BaseTool
from .bash_exec import BashExecTool
from .completion_report import CompletionReportTool
from .continue_run_block import ContinueRunBlockTool
from .create_agent import CreateAgentTool
from .customize_agent import CustomizeAgentTool
@@ -50,10 +51,12 @@ if TYPE_CHECKING:
from backend.copilot.response_model import StreamToolOutputAvailable
logger = logging.getLogger(__name__)
SESSION_SCOPED_TOOL_NAMES = {"completion_report"}
# Single source of truth for all tools
TOOL_REGISTRY: dict[str, BaseTool] = {
"add_understanding": AddUnderstandingTool(),
"completion_report": CompletionReportTool(),
"create_agent": CreateAgentTool(),
"customize_agent": CustomizeAgentTool(),
"edit_agent": EditAgentTool(),
@@ -103,16 +106,38 @@ find_agent_tool = TOOL_REGISTRY["find_agent"]
run_agent_tool = TOOL_REGISTRY["run_agent"]
def get_available_tools() -> list[ChatCompletionToolParam]:
def is_tool_enabled(tool_name: str, session: "ChatSession | None" = None) -> bool:
if tool_name not in TOOL_REGISTRY:
return False
if session is not None and session.disables_tool(tool_name):
return False
if tool_name not in SESSION_SCOPED_TOOL_NAMES:
return True
if session is None:
return False
return session.allows_tool(tool_name)
def iter_available_tools(
session: "ChatSession | None" = None,
) -> list[tuple[str, BaseTool]]:
return [
(tool_name, tool)
for tool_name, tool in TOOL_REGISTRY.items()
if tool.is_available and is_tool_enabled(tool_name, session)
]
def get_available_tools(
session: "ChatSession | None" = None,
) -> list[ChatCompletionToolParam]:
"""Return OpenAI tool schemas for tools available in the current environment.
Called per-request so that env-var or binary availability is evaluated
fresh each time (e.g. browser_* tools are excluded when agent-browser
CLI is not installed).
"""
return [
tool.as_openai_tool() for tool in TOOL_REGISTRY.values() if tool.is_available
]
return [tool.as_openai_tool() for _, tool in iter_available_tools(session)]
def get_tool(tool_name: str) -> BaseTool | None:
@@ -128,6 +153,9 @@ async def execute_tool(
tool_call_id: str,
) -> "StreamToolOutputAvailable":
"""Execute a tool by name."""
if not is_tool_enabled(tool_name, session):
raise ValueError(f"Tool {tool_name} is not enabled for this session")
tool = get_tool(tool_name)
if not tool:
raise ValueError(f"Tool {tool_name} not found")

View File

@@ -0,0 +1,87 @@
"""Tool for finalizing non-manual Copilot sessions."""
from typing import Any
from prisma.enums import ReviewStatus
from prisma.models import PendingHumanReview
from backend.copilot.constants import COPILOT_SESSION_PREFIX
from backend.copilot.model import ChatSession
from backend.copilot.session_types import CompletionReportInput
from .base import BaseTool
from .models import CompletionReportSavedResponse, ErrorResponse, ToolResponseBase
class CompletionReportTool(BaseTool):
@property
def name(self) -> str:
return "completion_report"
@property
def description(self) -> str:
return (
"Finalize a non-manual session after you have finished the work. "
"Use this exactly once at the end of the flow. "
"Summarize what you did, state whether the user should be notified, "
"and provide any email/callback content that should be used."
)
@property
def parameters(self) -> dict[str, Any]:
schema = CompletionReportInput.model_json_schema()
return {
"type": "object",
"properties": schema.get("properties", {}),
"required": [
"thoughts",
"should_notify_user",
"email_title",
"email_body",
"callback_session_message",
"approval_summary",
],
}
async def _execute(
self,
user_id: str | None,
session: ChatSession,
**kwargs,
) -> ToolResponseBase:
if session.is_manual:
return ErrorResponse(
message="completion_report is only available in non-manual sessions.",
session_id=session.session_id,
)
try:
report = CompletionReportInput.model_validate(kwargs)
except Exception as exc:
return ErrorResponse(
message="completion_report arguments are invalid.",
error=str(exc),
session_id=session.session_id,
)
pending_approval_count = await PendingHumanReview.prisma().count(
where={
"graphExecId": f"{COPILOT_SESSION_PREFIX}{session.session_id}",
"status": ReviewStatus.WAITING,
}
)
if pending_approval_count > 0 and not report.approval_summary:
return ErrorResponse(
message=(
"approval_summary is required because this session has pending approvals."
),
session_id=session.session_id,
)
return CompletionReportSavedResponse(
message="Completion report recorded successfully.",
session_id=session.session_id,
has_pending_approvals=pending_approval_count > 0,
pending_approval_count=pending_approval_count,
)

View File

@@ -0,0 +1,95 @@
from typing import cast
from unittest.mock import AsyncMock, Mock
import pytest
from backend.copilot.model import ChatSession
from backend.copilot.session_types import ChatSessionStartType
from backend.copilot.tools.completion_report import CompletionReportTool
from backend.copilot.tools.models import CompletionReportSavedResponse, ResponseType
@pytest.mark.asyncio
async def test_completion_report_rejects_manual_sessions() -> None:
tool = CompletionReportTool()
session = ChatSession.new("user-1")
response = await tool._execute(
user_id="user-1",
session=session,
thoughts="Wrapped up the session.",
should_notify_user=False,
email_title=None,
email_body=None,
callback_session_message=None,
approval_summary=None,
)
assert response.type == ResponseType.ERROR
assert "non-manual sessions" in response.message
@pytest.mark.asyncio
async def test_completion_report_requires_approval_summary_when_pending(
mocker,
) -> None:
tool = CompletionReportTool()
session = ChatSession.new(
"user-1",
start_type=ChatSessionStartType.AUTOPILOT_NIGHTLY,
)
pending_reviews = Mock()
pending_reviews.count = AsyncMock(return_value=2)
mocker.patch(
"backend.copilot.tools.completion_report.PendingHumanReview.prisma",
return_value=pending_reviews,
)
response = await tool._execute(
user_id="user-1",
session=session,
thoughts="Prepared a recommendation for the user.",
should_notify_user=True,
email_title="Your nightly update",
email_body="I found something worth reviewing.",
callback_session_message="Let's review the next step together.",
approval_summary=None,
)
assert response.type == ResponseType.ERROR
assert "approval_summary is required" in response.message
@pytest.mark.asyncio
async def test_completion_report_succeeds_without_pending_approvals(
mocker,
) -> None:
tool = CompletionReportTool()
session = ChatSession.new(
"user-1",
start_type=ChatSessionStartType.AUTOPILOT_CALLBACK,
)
pending_reviews = Mock()
pending_reviews.count = AsyncMock(return_value=0)
mocker.patch(
"backend.copilot.tools.completion_report.PendingHumanReview.prisma",
return_value=pending_reviews,
)
response = await tool._execute(
user_id="user-1",
session=session,
thoughts="Reviewed the account and prepared a useful follow-up.",
should_notify_user=True,
email_title="Autopilot found something useful",
email_body="I put together a recommendation for you.",
callback_session_message="Open this chat and I will walk you through it.",
approval_summary=None,
)
assert response.type == ResponseType.COMPLETION_REPORT_SAVED
response = cast(CompletionReportSavedResponse, response)
assert response.has_pending_approvals is False
assert response.pending_approval_count == 0

View File

@@ -16,6 +16,7 @@ class ResponseType(str, Enum):
ERROR = "error"
NO_RESULTS = "no_results"
NEED_LOGIN = "need_login"
COMPLETION_REPORT_SAVED = "completion_report_saved"
# Agent discovery & execution
AGENTS_FOUND = "agents_found"
@@ -248,6 +249,14 @@ class ErrorResponse(ToolResponseBase):
details: dict[str, Any] | None = None
class CompletionReportSavedResponse(ToolResponseBase):
"""Response for completion_report."""
type: ResponseType = ResponseType.COMPLETION_REPORT_SAVED
has_pending_approvals: bool = False
pending_approval_count: int = 0
class InputValidationErrorResponse(ToolResponseBase):
"""Response when run_agent receives unknown input fields."""

View File

@@ -24,6 +24,12 @@ from dotenv import load_dotenv
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import MetaData, create_engine
from backend.copilot.autopilot import (
dispatch_nightly_copilot as dispatch_nightly_copilot_async,
)
from backend.copilot.autopilot import (
send_nightly_copilot_emails as send_nightly_copilot_emails_async,
)
from backend.copilot.optimize_blocks import optimize_block_descriptions
from backend.data.execution import GraphExecutionWithNodes
from backend.data.model import CredentialsMetaInput, GraphInput
@@ -258,6 +264,16 @@ def cleanup_oauth_tokens():
run_async(_cleanup())
def dispatch_nightly_copilot():
"""Dispatch proactive nightly copilot sessions."""
return run_async(dispatch_nightly_copilot_async())
def send_nightly_copilot_emails():
"""Send emails for completed non-manual copilot sessions."""
return run_async(send_nightly_copilot_emails_async())
def execution_accuracy_alerts():
"""Check execution accuracy and send alerts if drops are detected."""
return report_execution_accuracy_alerts()
@@ -618,6 +634,24 @@ class Scheduler(AppService):
jobstore=Jobstores.EXECUTION.value,
)
self.scheduler.add_job(
dispatch_nightly_copilot,
id="dispatch_nightly_copilot",
trigger=CronTrigger(minute="0,30", timezone=ZoneInfo("UTC")),
replace_existing=True,
max_instances=1,
jobstore=Jobstores.EXECUTION.value,
)
self.scheduler.add_job(
send_nightly_copilot_emails,
id="send_nightly_copilot_emails",
trigger=CronTrigger(minute="15,45", timezone=ZoneInfo("UTC")),
replace_existing=True,
max_instances=1,
jobstore=Jobstores.EXECUTION.value,
)
self.scheduler.add_listener(job_listener, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR)
self.scheduler.add_listener(job_missed_listener, EVENT_JOB_MISSED)
self.scheduler.add_listener(job_max_instances_listener, EVENT_JOB_MAX_INSTANCES)
@@ -792,6 +826,14 @@ class Scheduler(AppService):
"""Manually trigger embedding backfill for approved store agents."""
return ensure_embeddings_coverage()
@expose
def execute_dispatch_nightly_copilot(self):
return dispatch_nightly_copilot()
@expose
def execute_send_nightly_copilot_emails(self):
return send_nightly_copilot_emails()
class SchedulerClient(AppServiceClient):
@classmethod

View File

@@ -1,5 +1,6 @@
import logging
import pathlib
from typing import Any
from postmarker.core import PostmarkClient
from postmarker.models.emails import EmailManager
@@ -46,6 +47,39 @@ class EmailSender:
MAX_EMAIL_CHARS = 5_000_000 # ~5MB buffer
def send_template(
self,
*,
user_email: str,
subject: str,
template_name: str,
data: dict[str, Any] | None = None,
user_unsubscribe_link: str | None = None,
) -> None:
if not self.postmark:
logger.warning("Postmark client not initialized, email not sent")
return
base_url = (
settings.config.frontend_base_url or settings.config.platform_base_url
)
unsubscribe_link = user_unsubscribe_link or f"{base_url}/profile/settings"
_, full_message = self.formatter.format_email(
subject_template="{{ subject }}",
base_template=self._read_template("templates/base.html.jinja2"),
content_template=self._read_template(f"templates/{template_name}"),
data={"subject": subject, **(data or {})},
unsubscribe_link=unsubscribe_link,
)
self._send_email(
user_email=user_email,
subject=subject,
body=full_message,
user_unsubscribe_link=user_unsubscribe_link,
)
def send_templated(
self,
notification: NotificationType,
@@ -123,17 +157,18 @@ class EmailSender:
logger.debug(
f"Template full path: {pathlib.Path(__file__).parent / template_path}"
)
base_template_path = "templates/base.html.jinja2"
with open(pathlib.Path(__file__).parent / base_template_path, "r") as file:
base_template = file.read()
with open(pathlib.Path(__file__).parent / template_path, "r") as file:
template = file.read()
base_template = self._read_template("templates/base.html.jinja2")
template = self._read_template(template_path)
return Template(
subject_template=notification_type_override.subject,
body_template=template,
base_template=base_template,
)
def _read_template(self, template_path: str) -> str:
with open(pathlib.Path(__file__).parent / template_path, "r") as file:
return file.read()
def _send_email(
self,
user_email: str,
@@ -159,3 +194,17 @@ class EmailSender:
else None
),
)
def send_html(
self,
user_email: str,
subject: str,
body: str,
user_unsubscribe_link: str | None = None,
) -> None:
self._send_email(
user_email=user_email,
subject=subject,
body=body,
user_unsubscribe_link=user_unsubscribe_link,
)

View File

@@ -0,0 +1,182 @@
from typing import Any, cast
from backend.api.test_helpers import override_config
from backend.notifications.email import EmailSender, settings
from backend.util.settings import AppEnvironment
def test_send_template_renders_nightly_copilot_email(mocker) -> None:
sender = EmailSender()
sender.postmark = cast(Any, object())
send_email = mocker.patch.object(sender, "_send_email")
sender.send_template(
user_email="user@example.com",
subject="Autopilot update",
template_name="nightly_copilot.html.jinja2",
data={
"email_body_paragraphs": [
"I found something useful for you.",
"Open Copilot and I will walk you through it.",
],
"cta_url": "https://example.com/copilot?callbackToken=token-1",
"cta_label": "Open Copilot",
},
)
body = send_email.call_args.kwargs["body"]
assert "I found something useful for you." in body
assert "Open Copilot" in body
assert "Approval needed" not in body
def test_send_template_renders_nightly_copilot_approval_block(mocker) -> None:
sender = EmailSender()
sender.postmark = cast(Any, object())
send_email = mocker.patch.object(sender, "_send_email")
sender.send_template(
user_email="user@example.com",
subject="Autopilot update",
template_name="nightly_copilot.html.jinja2",
data={
"email_body_paragraphs": ["I prepared a change worth reviewing."],
"approval_summary_paragraphs": [
"I drafted a follow-up because it matches your recent activity."
],
"cta_url": "https://example.com/copilot?sessionId=session-1&showAutopilot=1",
"cta_label": "Review in Copilot",
},
)
body = send_email.call_args.kwargs["body"]
assert "Approval needed" in body
assert "If you want it to happen, please hit approve." in body
assert "Review in Copilot" in body
def test_send_template_renders_nightly_copilot_callback_email(mocker) -> None:
sender = EmailSender()
sender.postmark = cast(Any, object())
send_email = mocker.patch.object(sender, "_send_email")
sender.send_template(
user_email="user@example.com",
subject="Autopilot update",
template_name="nightly_copilot_callback.html.jinja2",
data={
"email_body_paragraphs": [
"I prepared a follow-up based on your recent work."
],
"cta_url": "https://example.com/copilot?callbackToken=token-1",
"cta_label": "Open Copilot",
},
)
body = send_email.call_args.kwargs["body"]
assert "Autopilot picked up where you left off" in body
assert "I prepared a follow-up based on your recent work." in body
def test_send_template_renders_nightly_copilot_callback_approval_block(mocker) -> None:
sender = EmailSender()
sender.postmark = cast(Any, object())
send_email = mocker.patch.object(sender, "_send_email")
sender.send_template(
user_email="user@example.com",
subject="Autopilot update",
template_name="nightly_copilot_callback.html.jinja2",
data={
"email_body_paragraphs": [
"I prepared a follow-up based on your recent work."
],
"approval_summary_paragraphs": [
"I want your approval before I apply the next step."
],
"cta_url": "https://example.com/copilot?sessionId=session-1&showAutopilot=1",
"cta_label": "Review in Copilot",
},
)
body = send_email.call_args.kwargs["body"]
assert "Approval needed" in body
assert "I want your approval before I apply the next step." in body
def test_send_template_renders_nightly_copilot_invite_cta_email(mocker) -> None:
sender = EmailSender()
sender.postmark = cast(Any, object())
send_email = mocker.patch.object(sender, "_send_email")
sender.send_template(
user_email="user@example.com",
subject="Autopilot update",
template_name="nightly_copilot_invite_cta.html.jinja2",
data={
"email_body_paragraphs": [
"I put together an example of how Autopilot could help you."
],
"cta_url": "https://example.com/copilot?callbackToken=token-1",
"cta_label": "Try Copilot",
},
)
body = send_email.call_args.kwargs["body"]
assert "Your Autopilot beta access is waiting" in body
assert "I put together an example of how Autopilot could help you." in body
assert "Try Copilot" in body
def test_send_template_renders_nightly_copilot_invite_cta_approval_block(
mocker,
) -> None:
sender = EmailSender()
sender.postmark = cast(Any, object())
send_email = mocker.patch.object(sender, "_send_email")
sender.send_template(
user_email="user@example.com",
subject="Autopilot update",
template_name="nightly_copilot_invite_cta.html.jinja2",
data={
"email_body_paragraphs": [
"I put together an example of how Autopilot could help you."
],
"approval_summary_paragraphs": [
"If this looks useful, approve the next step to try it."
],
"cta_url": "https://example.com/copilot?sessionId=session-1&showAutopilot=1",
"cta_label": "Review in Copilot",
},
)
body = send_email.call_args.kwargs["body"]
assert "Approval needed" in body
assert "If this looks useful, approve the next step to try it." in body
def test_send_template_still_sends_in_production(mocker) -> None:
sender = EmailSender()
sender.postmark = cast(Any, object())
send_email = mocker.patch.object(sender, "_send_email")
with override_config(settings, "app_env", AppEnvironment.PRODUCTION):
sender.send_template(
user_email="user@example.com",
subject="Autopilot update",
template_name="nightly_copilot.html.jinja2",
data={
"email_body_paragraphs": ["I found something useful for you."],
"cta_url": "https://example.com/copilot?callbackToken=token-1",
"cta_label": "Open Copilot",
},
)
send_email.assert_called_once()

View File

@@ -0,0 +1,29 @@
<div style="font-family: 'Poppins', sans-serif; color: #070629;">
{% for paragraph in email_body_paragraphs %}
<p style="font-size: 16px; line-height: 165%; margin-top: 0; margin-bottom: 16px;">
{{ paragraph }}
</p>
{% endfor %}
{% if approval_summary_paragraphs %}
<div
style="margin-top: 16px; padding: 16px; border-radius: 12px; background: #fff7ed; border: 1px solid #fdba74;">
<h3 style="margin: 0 0 8px;">Approval needed</h3>
{% for paragraph in approval_summary_paragraphs %}
<p style="font-size: 16px; line-height: 165%; margin-top: 0; margin-bottom: 12px;">
{{ paragraph }}
</p>
{% endfor %}
<p style="font-size: 16px; line-height: 165%; margin-top: 0; margin-bottom: 0;">
I thought this was a good idea. If you want it to happen, please hit approve.
</p>
</div>
{% endif %}
<div style="margin-top: 24px;">
<a href="{{ cta_url }}"
style="display: inline-block; padding: 12px 18px; border-radius: 999px; background: #111827; color: #ffffff; text-decoration: none; font-weight: 600;">
{{ cta_label }}
</a>
</div>
</div>

View File

@@ -0,0 +1,37 @@
<div style="font-family: 'Poppins', sans-serif; color: #070629;">
<h2 style="font-size: 24px; line-height: 125%; margin-top: 0; margin-bottom: 12px;">
Autopilot picked up where you left off
</h2>
<p style="font-size: 16px; line-height: 165%; margin-top: 0; margin-bottom: 16px;">
We used your recent Copilot activity to prepare a concrete follow-up for you.
</p>
{% for paragraph in email_body_paragraphs %}
<p style="font-size: 16px; line-height: 165%; margin-top: 0; margin-bottom: 16px;">
{{ paragraph }}
</p>
{% endfor %}
{% if approval_summary_paragraphs %}
<div
style="margin-top: 16px; padding: 16px; border-radius: 12px; background: #fff7ed; border: 1px solid #fdba74;">
<h3 style="margin: 0 0 8px;">Approval needed</h3>
{% for paragraph in approval_summary_paragraphs %}
<p style="font-size: 16px; line-height: 165%; margin-top: 0; margin-bottom: 12px;">
{{ paragraph }}
</p>
{% endfor %}
<p style="font-size: 16px; line-height: 165%; margin-top: 0; margin-bottom: 0;">
I thought this was a good idea. If you want it to happen, please hit approve.
</p>
</div>
{% endif %}
<div style="margin-top: 24px;">
<a href="{{ cta_url }}"
style="display: inline-block; padding: 12px 18px; border-radius: 999px; background: #111827; color: #ffffff; text-decoration: none; font-weight: 600;">
{{ cta_label }}
</a>
</div>
</div>

View File

@@ -0,0 +1,37 @@
<div style="font-family: 'Poppins', sans-serif; color: #070629;">
<h2 style="font-size: 24px; line-height: 125%; margin-top: 0; margin-bottom: 12px;">
Your Autopilot beta access is waiting
</h2>
<p style="font-size: 16px; line-height: 165%; margin-top: 0; margin-bottom: 16px;">
You applied to try Autopilot. Here is a tailored example of how it can help once you jump back in.
</p>
{% for paragraph in email_body_paragraphs %}
<p style="font-size: 16px; line-height: 165%; margin-top: 0; margin-bottom: 16px;">
{{ paragraph }}
</p>
{% endfor %}
{% if approval_summary_paragraphs %}
<div
style="margin-top: 16px; padding: 16px; border-radius: 12px; background: #fff7ed; border: 1px solid #fdba74;">
<h3 style="margin: 0 0 8px;">Approval needed</h3>
{% for paragraph in approval_summary_paragraphs %}
<p style="font-size: 16px; line-height: 165%; margin-top: 0; margin-bottom: 12px;">
{{ paragraph }}
</p>
{% endfor %}
<p style="font-size: 16px; line-height: 165%; margin-top: 0; margin-bottom: 0;">
I thought this was a good idea. If you want it to happen, please hit approve.
</p>
</div>
{% endif %}
<div style="margin-top: 24px;">
<a href="{{ cta_url }}"
style="display: inline-block; padding: 12px 18px; border-radius: 999px; background: #111827; color: #ffffff; text-decoration: none; font-weight: 600;">
{{ cta_label }}
</a>
</div>
</div>

View File

@@ -39,6 +39,7 @@ class Flag(str, Enum):
ENABLE_PLATFORM_PAYMENT = "enable-platform-payment"
CHAT = "chat"
COPILOT_SDK = "copilot-sdk"
NIGHTLY_COPILOT = "nightly-copilot"
def is_configured() -> bool:

View File

@@ -1,6 +1,7 @@
import json
import os
import re
from datetime import date
from enum import Enum
from typing import Any, Dict, Generic, List, Set, Tuple, Type, TypeVar
@@ -125,6 +126,22 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
default=True,
description="If the invite-only signup gate is enforced",
)
nightly_copilot_callback_start_date: date = Field(
default=date(2026, 2, 8),
description="Users with sessions since this date are eligible for the one-off autopilot callback cohort.",
)
nightly_copilot_invite_cta_start_date: date = Field(
default=date(2026, 3, 13),
description="Invite CTA cohort does not run before this date.",
)
nightly_copilot_invite_cta_delay_hours: int = Field(
default=48,
description="Delay after invite creation before the invite CTA can run.",
)
nightly_copilot_callback_token_ttl_hours: int = Field(
default=24 * 14,
description="TTL for nightly copilot callback tokens.",
)
enable_credit: bool = Field(
default=False,
description="If user credit system is enabled or not",

View File

@@ -0,0 +1,60 @@
-- CreateEnum
CREATE TYPE "ChatSessionStartType" AS ENUM(
'MANUAL',
'AUTOPILOT_NIGHTLY',
'AUTOPILOT_CALLBACK',
'AUTOPILOT_INVITE_CTA'
);
-- AlterTable
ALTER TABLE "ChatSession"
ADD COLUMN "startType" "ChatSessionStartType" NOT NULL DEFAULT 'MANUAL',
ADD COLUMN "executionTag" TEXT,
ADD COLUMN "sessionConfig" JSONB NOT NULL DEFAULT '{}',
ADD COLUMN "completionReport" JSONB,
ADD COLUMN "completionReportRepairCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "completionReportRepairQueuedAt" TIMESTAMP(3),
ADD COLUMN "completedAt" TIMESTAMP(3),
ADD COLUMN "notificationEmailSentAt" TIMESTAMP(3),
ADD COLUMN "notificationEmailSkippedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "ChatSessionCallbackToken"(
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
"sourceSessionId" TEXT,
"callbackSessionMessage" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"consumedAt" TIMESTAMP(3),
"consumedSessionId" TEXT,
CONSTRAINT "ChatSessionCallbackToken_pkey" PRIMARY KEY("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ChatSession_userId_executionTag_key"
ON "ChatSession"("userId",
"executionTag");
-- CreateIndex
CREATE INDEX "ChatSession_userId_startType_updatedAt_idx"
ON "ChatSession"("userId",
"startType",
"updatedAt");
-- CreateIndex
CREATE INDEX "ChatSessionCallbackToken_userId_expiresAt_idx"
ON "ChatSessionCallbackToken"("userId",
"expiresAt");
-- AddForeignKey
ALTER TABLE "ChatSessionCallbackToken" ADD CONSTRAINT "ChatSessionCallbackToken_userId_fkey" FOREIGN KEY("userId") REFERENCES "User"("id")
ON DELETE CASCADE
ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatSessionCallbackToken" ADD CONSTRAINT "ChatSessionCallbackToken_sourceSessionId_fkey" FOREIGN KEY("sourceSessionId") REFERENCES "ChatSession"("id")
ON DELETE
SET NULL
ON UPDATE CASCADE;

View File

@@ -66,6 +66,7 @@ model User {
PendingHumanReviews PendingHumanReview[]
Workspace UserWorkspace?
ClaimedInvite InvitedUser? @relation("InvitedUserAuthUser")
ChatSessionCallbackTokens ChatSessionCallbackToken[]
// OAuth Provider relations
OAuthApplications OAuthApplication[]
@@ -87,6 +88,13 @@ enum TallyComputationStatus {
FAILED
}
enum ChatSessionStartType {
MANUAL
AUTOPILOT_NIGHTLY
AUTOPILOT_CALLBACK
AUTOPILOT_INVITE_CTA
}
model InvitedUser {
id String @id @default(uuid())
createdAt DateTime @default(now())
@@ -248,6 +256,15 @@ model ChatSession {
// Session metadata
title String?
credentials Json @default("{}") // Map of provider -> credential metadata
startType ChatSessionStartType @default(MANUAL)
executionTag String?
sessionConfig Json @default("{}")
completionReport Json?
completionReportRepairCount Int @default(0)
completionReportRepairQueuedAt DateTime?
completedAt DateTime?
notificationEmailSentAt DateTime?
notificationEmailSkippedAt DateTime?
// Rate limiting counters (stored as JSON maps)
successfulAgentRuns Json @default("{}") // Map of graph_id -> count
@@ -258,8 +275,30 @@ model ChatSession {
totalCompletionTokens Int @default(0)
Messages ChatMessage[]
CallbackTokens ChatSessionCallbackToken[] @relation("ChatSessionCallbackSource")
@@index([userId, updatedAt])
@@index([userId, startType, updatedAt])
@@unique([userId, executionTag])
}
model ChatSessionCallbackToken {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
userId String
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
sourceSessionId String?
SourceSession ChatSession? @relation("ChatSessionCallbackSource", fields: [sourceSessionId], references: [id], onDelete: SetNull)
callbackSessionMessage String
expiresAt DateTime
consumedAt DateTime?
consumedSessionId String?
@@index([userId, expiresAt])
}
model ChatMessage {

View File

@@ -82,8 +82,10 @@ export function CopilotPage() {
// Mobile drawer
isMobile,
isDrawerOpen,
showAutopilotHistory,
sessions,
isLoadingSessions,
handleToggleAutopilotHistory,
handleOpenDrawer,
handleCloseDrawer,
handleDrawerOpenChange,
@@ -186,9 +188,11 @@ export function CopilotPage() {
{isMobile && (
<MobileDrawer
isOpen={isDrawerOpen}
showAutopilotHistory={showAutopilotHistory}
sessions={sessions}
currentSessionId={sessionId}
isLoading={isLoadingSessions}
onToggleAutopilotHistory={handleToggleAutopilotHistory}
onSelectSession={handleSelectSession}
onNewChat={handleNewChat}
onClose={handleCloseDrawer}

View File

@@ -5,6 +5,7 @@ import {
useGetV2ListSessions,
usePatchV2UpdateSessionTitle,
} from "@/app/api/__generated__/endpoints/chat/chat";
import { Badge } from "@/components/atoms/Badge/Badge";
import { Button } from "@/components/atoms/Button/Button";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { Text } from "@/components/atoms/Text/Text";
@@ -33,6 +34,11 @@ import { useQueryClient } from "@tanstack/react-query";
import { AnimatePresence, motion } from "framer-motion";
import { parseAsString, useQueryState } from "nuqs";
import { useEffect, useRef, useState } from "react";
import {
getSessionListParams,
getSessionStartTypeLabel,
isNonManualSessionStartType,
} from "../../helpers";
import { useCopilotUIStore } from "../../store";
import { NotificationToggle } from "./components/NotificationToggle/NotificationToggle";
import { DeleteChatDialog } from "../DeleteChatDialog/DeleteChatDialog";
@@ -42,6 +48,12 @@ export function ChatSidebar() {
const { state } = useSidebar();
const isCollapsed = state === "collapsed";
const [sessionId, setSessionId] = useQueryState("sessionId", parseAsString);
const [showAutopilot, setShowAutopilot] = useQueryState(
"showAutopilot",
parseAsString,
);
const showAutopilotHistory = showAutopilot === "1";
const listSessionsParams = getSessionListParams(showAutopilotHistory);
const {
sessionToDelete,
setSessionToDelete,
@@ -52,7 +64,9 @@ export function ChatSidebar() {
const queryClient = useQueryClient();
const { data: sessionsResponse, isLoading: isLoadingSessions } =
useGetV2ListSessions({ limit: 50 }, { query: { refetchInterval: 10_000 } });
useGetV2ListSessions(listSessionsParams, {
query: { refetchInterval: 10_000 },
});
const { mutate: deleteSession, isPending: isDeleting } =
useDeleteV2DeleteSession({
@@ -138,6 +152,10 @@ export function ChatSidebar() {
setSessionId(id);
}
function handleToggleAutopilotHistory() {
setShowAutopilot(showAutopilotHistory ? null : "1");
}
function handleRenameClick(
e: React.MouseEvent,
id: string,
@@ -263,6 +281,19 @@ export function ChatSidebar() {
</div>
</div>
</div>
<div className="flex items-center justify-between gap-3">
<Text variant="small" className="text-neutral-400">
Inspect autopilot sessions
</Text>
<Button
variant={showAutopilotHistory ? "primary" : "secondary"}
size="small"
onClick={handleToggleAutopilotHistory}
className="min-w-0 px-3 text-xs"
>
{showAutopilotHistory ? "Hide" : "Show"}
</Button>
</div>
{sessionId ? (
<Button
variant="primary"
@@ -360,6 +391,13 @@ export function ChatSidebar() {
</motion.span>
</AnimatePresence>
</Text>
{isNonManualSessionStartType(session.start_type) ? (
<div className="mt-1">
<Badge variant="info" size="small">
{getSessionStartTypeLabel(session.start_type)}
</Badge>
</div>
) : null}
<Text variant="small" className="text-neutral-400">
{formatDate(session.updated_at)}
</Text>

View File

@@ -1,4 +1,5 @@
import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse";
import { Badge } from "@/components/atoms/Badge/Badge";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { scrollbarStyles } from "@/components/styles/scrollbars";
@@ -12,14 +13,20 @@ import {
X,
} from "@phosphor-icons/react";
import { Drawer } from "vaul";
import {
getSessionStartTypeLabel,
isNonManualSessionStartType,
} from "../../helpers";
import { useCopilotUIStore } from "../../store";
import { PulseLoader } from "../PulseLoader/PulseLoader";
interface Props {
isOpen: boolean;
showAutopilotHistory: boolean;
sessions: SessionSummaryResponse[];
currentSessionId: string | null;
isLoading: boolean;
onToggleAutopilotHistory: () => void;
onSelectSession: (sessionId: string) => void;
onNewChat: () => void;
onClose: () => void;
@@ -53,9 +60,11 @@ function formatDate(dateString: string) {
export function MobileDrawer({
isOpen,
showAutopilotHistory,
sessions,
currentSessionId,
isLoading,
onToggleAutopilotHistory,
onSelectSession,
onNewChat,
onClose,
@@ -104,6 +113,19 @@ export function MobileDrawer({
</Button>
</div>
</div>
<div className="mt-2 flex items-center justify-between gap-3">
<Text variant="small" className="text-neutral-400">
Inspect autopilot sessions
</Text>
<Button
variant={showAutopilotHistory ? "primary" : "secondary"}
size="small"
onClick={onToggleAutopilotHistory}
className="min-w-0 px-3 text-xs"
>
{showAutopilotHistory ? "Hide" : "Show"}
</Button>
</div>
{currentSessionId ? (
<div className="mt-2">
<Button
@@ -175,6 +197,13 @@ export function MobileDrawer({
/>
)}
</div>
{isNonManualSessionStartType(session.start_type) ? (
<div className="mt-1">
<Badge variant="info" size="small">
{getSessionStartTypeLabel(session.start_type)}
</Badge>
</div>
) : null}
<Text variant="small" className="text-neutral-400">
{formatDate(session.updated_at)}
</Text>

View File

@@ -1,5 +1,42 @@
import type { GetV2ListSessionsParams } from "@/app/api/__generated__/models/getV2ListSessionsParams";
import {
ChatSessionStartType,
type ChatSessionStartType as ChatSessionStartTypeValue,
} from "@/app/api/__generated__/models/chatSessionStartType";
import type { UIMessage } from "ai";
export const COPILOT_SESSION_LIST_LIMIT = 50;
export function getSessionListParams(
includeNonManual: boolean,
): GetV2ListSessionsParams {
return {
limit: COPILOT_SESSION_LIST_LIMIT,
with_auto: includeNonManual,
};
}
export function isNonManualSessionStartType(
startType: ChatSessionStartTypeValue | null | undefined,
): boolean {
return startType != null && startType !== ChatSessionStartType.MANUAL;
}
export function getSessionStartTypeLabel(
startType: ChatSessionStartTypeValue,
): string | null {
switch (startType) {
case ChatSessionStartType.AUTOPILOT_NIGHTLY:
return "Nightly";
case ChatSessionStartType.AUTOPILOT_CALLBACK:
return "Callback";
case ChatSessionStartType.AUTOPILOT_INVITE_CTA:
return "Invite CTA";
default:
return null;
}
}
/** Mark any in-progress tool parts as completed/errored so spinners stop. */
export function resolveInProgressTools(
messages: UIMessage[],

View File

@@ -4,6 +4,7 @@ import {
useGetV2GetSession,
usePostV2CreateSession,
} from "@/app/api/__generated__/endpoints/chat/chat";
import type { ChatSessionStartType } from "@/app/api/__generated__/models/chatSessionStartType";
import { toast } from "@/components/molecules/Toast/use-toast";
import * as Sentry from "@sentry/nextjs";
import { useQueryClient } from "@tanstack/react-query";
@@ -70,6 +71,14 @@ export function useChatSession() {
);
}, [sessionQuery.data, sessionId, hasActiveStream]);
const sessionStartType = useMemo<ChatSessionStartType | null>(() => {
if (sessionQuery.data?.status !== 200) {
return null;
}
return sessionQuery.data.data.start_type;
}, [sessionQuery.data]);
const { mutateAsync: createSessionMutation, isPending: isCreatingSession } =
usePostV2CreateSession({
mutation: {
@@ -121,6 +130,7 @@ export function useChatSession() {
return {
sessionId,
setSessionId,
sessionStartType,
hydratedMessages,
hasActiveStream,
isLoadingSession: sessionQuery.isLoading,

View File

@@ -1,4 +1,5 @@
import {
usePostV2ConsumeCallbackTokenRoute,
getGetV2ListSessionsQueryKey,
useDeleteV2DeleteSession,
useGetV2ListSessions,
@@ -11,6 +12,8 @@ import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { useQueryClient } from "@tanstack/react-query";
import type { FileUIPart } from "ai";
import { useEffect, useRef, useState } from "react";
import { parseAsString, useQueryState } from "nuqs";
import { getSessionListParams, isNonManualSessionStartType } from "./helpers";
import { useCopilotUIStore } from "./store";
import { useChatSession } from "./useChatSession";
import { useCopilotNotifications } from "./useCopilotNotifications";
@@ -29,7 +32,18 @@ export function useCopilotPage() {
const { isUserLoading, isLoggedIn } = useSupabase();
const [isUploadingFiles, setIsUploadingFiles] = useState(false);
const [pendingMessage, setPendingMessage] = useState<string | null>(null);
const [callbackToken, setCallbackToken] = useQueryState(
"callbackToken",
parseAsString,
);
const [showAutopilot, setShowAutopilot] = useQueryState(
"showAutopilot",
parseAsString,
);
const queryClient = useQueryClient();
const showAutopilotHistory = showAutopilot === "1";
const listSessionsParams = getSessionListParams(showAutopilotHistory);
const consumedCallbackTokenRef = useRef<string | null>(null);
const { sessionToDelete, setSessionToDelete, isDrawerOpen, setDrawerOpen } =
useCopilotUIStore();
@@ -37,9 +51,10 @@ export function useCopilotPage() {
const {
sessionId,
setSessionId,
sessionStartType,
hydratedMessages,
hasActiveStream,
isLoadingSession,
isLoadingSession: isLoadingCurrentSession,
isSessionError,
createSession,
isCreatingSession,
@@ -63,6 +78,11 @@ export function useCopilotPage() {
useCopilotNotifications(sessionId);
const {
mutateAsync: consumeCallbackToken,
isPending: isConsumingCallbackToken,
} = usePostV2ConsumeCallbackTokenRoute();
// --- Delete session ---
const { mutate: deleteSessionMutation, isPending: isDeleting } =
useDeleteV2DeleteSession({
@@ -127,6 +147,61 @@ export function useCopilotPage() {
}
}, [sessionId, pendingMessage, sendMessage]);
useEffect(() => {
if (!isLoggedIn || !callbackToken) {
return;
}
if (consumedCallbackTokenRef.current === callbackToken) {
return;
}
consumedCallbackTokenRef.current = callbackToken;
void consumeCallbackToken({ data: { token: callbackToken } })
.then((response) => {
if (response.status !== 200 || !response.data?.session_id) {
throw new Error("Failed to open callback session");
}
setSessionId(response.data.session_id);
setShowAutopilot(null);
setCallbackToken(null);
queryClient.invalidateQueries({
queryKey: getGetV2ListSessionsQueryKey(),
});
})
.catch((error) => {
consumedCallbackTokenRef.current = null;
setCallbackToken(null);
toast({
title: "Unable to open callback session",
description:
error instanceof Error ? error.message : "Please try again.",
variant: "destructive",
});
});
}, [
callbackToken,
consumeCallbackToken,
isLoggedIn,
queryClient,
setCallbackToken,
setSessionId,
setShowAutopilot,
]);
useEffect(() => {
if (
!sessionId ||
showAutopilot !== null ||
!isNonManualSessionStartType(sessionStartType)
) {
return;
}
setShowAutopilot("1");
}, [sessionId, sessionStartType, setShowAutopilot, showAutopilot]);
async function uploadFiles(
files: File[],
sid: string,
@@ -232,10 +307,9 @@ export function useCopilotPage() {
// --- Session list (for mobile drawer & sidebar) ---
const { data: sessionsResponse, isLoading: isLoadingSessions } =
useGetV2ListSessions(
{ limit: 50 },
{ query: { enabled: !isUserLoading && isLoggedIn } },
);
useGetV2ListSessions(listSessionsParams, {
query: { enabled: !isUserLoading && isLoggedIn },
});
const sessions =
sessionsResponse?.status === 200 ? sessionsResponse.data.sessions : [];
@@ -252,16 +326,18 @@ export function useCopilotPage() {
const isNowReady = status === "ready";
if (!wasActive || !isNowReady || !sessionId || isReconnecting) return;
const currentListSessionsParams =
getSessionListParams(showAutopilotHistory);
queryClient.invalidateQueries({
queryKey: getGetV2ListSessionsQueryKey({ limit: 50 }),
queryKey: getGetV2ListSessionsQueryKey(currentListSessionsParams),
});
const sid = sessionId;
let attempts = 0;
clearInterval(titlePollRef.current);
titlePollRef.current = setInterval(() => {
const data = queryClient.getQueryData<getV2ListSessionsResponse>(
getGetV2ListSessionsQueryKey({ limit: 50 }),
getGetV2ListSessionsQueryKey(currentListSessionsParams),
);
const hasTitle =
data?.status === 200 &&
@@ -273,10 +349,10 @@ export function useCopilotPage() {
}
attempts += 1;
queryClient.invalidateQueries({
queryKey: getGetV2ListSessionsQueryKey({ limit: 50 }),
queryKey: getGetV2ListSessionsQueryKey(currentListSessionsParams),
});
}, TITLE_POLL_INTERVAL_MS);
}, [status, sessionId, isReconnecting, queryClient]);
}, [status, sessionId, isReconnecting, queryClient, showAutopilotHistory]);
// Clean up polling on session change or unmount
useEffect(() => {
@@ -309,6 +385,10 @@ export function useCopilotPage() {
if (isMobile) setDrawerOpen(false);
}
function handleToggleAutopilotHistory() {
setShowAutopilot(showAutopilotHistory ? null : "1");
}
// --- Delete handlers ---
function handleDeleteClick(id: string, title: string | null | undefined) {
if (isDeleting) return;
@@ -334,7 +414,7 @@ export function useCopilotPage() {
error,
stop,
isReconnecting,
isLoadingSession,
isLoadingSession: isLoadingCurrentSession || isConsumingCallbackToken,
isSessionError,
isCreatingSession,
isUploadingFiles,
@@ -345,8 +425,10 @@ export function useCopilotPage() {
// Mobile drawer
isMobile,
isDrawerOpen,
showAutopilotHistory,
sessions,
isLoadingSessions,
handleToggleAutopilotHistory,
handleOpenDrawer,
handleCloseDrawer,
handleDrawerOpenChange,

View File

@@ -1030,6 +1030,16 @@
"default": 0,
"title": "Offset"
}
},
{
"name": "with_auto",
"in": "query",
"required": false,
"schema": {
"type": "boolean",
"default": false,
"title": "With Auto"
}
}
],
"responses": {
@@ -1079,6 +1089,47 @@
}
}
},
"/api/chat/sessions/callback-token/consume": {
"post": {
"tags": ["v2", "chat", "chat"],
"summary": "Consume Callback Token Route",
"operationId": "postV2ConsumeCallbackTokenRoute",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConsumeCallbackTokenRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConsumeCallbackTokenResponse"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
},
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/chat/sessions/{session_id}": {
"delete": {
"tags": ["v2", "chat", "chat"],
@@ -8419,6 +8470,16 @@
"required": ["query", "conversation_history", "message_id"],
"title": "ChatRequest"
},
"ChatSessionStartType": {
"type": "string",
"enum": [
"MANUAL",
"AUTOPILOT_NIGHTLY",
"AUTOPILOT_CALLBACK",
"AUTOPILOT_INVITE_CTA"
],
"title": "ChatSessionStartType"
},
"ClarificationNeededResponse": {
"properties": {
"type": {
@@ -8455,6 +8516,20 @@
"title": "ClarifyingQuestion",
"description": "A question that needs user clarification."
},
"ConsumeCallbackTokenRequest": {
"properties": { "token": { "type": "string", "title": "Token" } },
"type": "object",
"required": ["token"],
"title": "ConsumeCallbackTokenRequest"
},
"ConsumeCallbackTokenResponse": {
"properties": {
"session_id": { "type": "string", "title": "Session Id" }
},
"type": "object",
"required": ["session_id"],
"title": "ConsumeCallbackTokenResponse"
},
"ContentType": {
"type": "string",
"enum": [
@@ -11915,6 +11990,7 @@
"error",
"no_results",
"need_login",
"completion_report_saved",
"agents_found",
"agent_details",
"setup_requirements",
@@ -12180,6 +12256,11 @@
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "User Id"
},
"start_type": { "$ref": "#/components/schemas/ChatSessionStartType" },
"execution_tag": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Execution Tag"
},
"messages": {
"items": { "additionalProperties": true, "type": "object" },
"type": "array",
@@ -12193,7 +12274,14 @@
}
},
"type": "object",
"required": ["id", "created_at", "updated_at", "user_id", "messages"],
"required": [
"id",
"created_at",
"updated_at",
"user_id",
"start_type",
"messages"
],
"title": "SessionDetailResponse",
"description": "Response model providing complete details for a chat session, including messages."
},
@@ -12206,10 +12294,21 @@
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Title"
},
"start_type": { "$ref": "#/components/schemas/ChatSessionStartType" },
"execution_tag": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Execution Tag"
},
"is_processing": { "type": "boolean", "title": "Is Processing" }
},
"type": "object",
"required": ["id", "created_at", "updated_at", "is_processing"],
"required": [
"id",
"created_at",
"updated_at",
"start_type",
"is_processing"
],
"title": "SessionSummaryResponse",
"description": "Response model for a session summary (without messages)."
},