Compare commits
6 Commits
cc0e258e8c
...
5e42b2abb1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e42b2abb1 | ||
|
|
2b71469e86 | ||
|
|
6188ae15b3 | ||
|
|
e1db7cdf06 | ||
|
|
c53f08229c | ||
|
|
3e2d80d5bb |
@@ -42,8 +42,9 @@ from sqlalchemy import update
|
|||||||
from app.api.routes.agent_setup import handle_journey_message, handle_journey_start
|
from app.api.routes.agent_setup import handle_journey_message, handle_journey_start
|
||||||
from app.config.settings import settings
|
from app.config.settings import settings
|
||||||
from app.core.agent_runner import trigger_pending_runs
|
from app.core.agent_runner import trigger_pending_runs
|
||||||
|
from app.core.agent_session_buffer import session_buffer
|
||||||
from app.core.brief_agent import run_home_brief, run_project_brief
|
from app.core.brief_agent import run_home_brief, run_project_brief
|
||||||
from app.core.deep_agent import run_floating_stream, run_home_stream, run_task_brief_research_stream
|
from app.core.deep_agent import run_contextual_stream, run_floating_stream, run_home_stream, run_task_brief_research_stream
|
||||||
from app.core.output_formatter import extract_canvas_block
|
from app.core.output_formatter import extract_canvas_block
|
||||||
from app.core.device_manager import device_manager
|
from app.core.device_manager import device_manager
|
||||||
from app.core.memory_middleware import MemoryMiddleware
|
from app.core.memory_middleware import MemoryMiddleware
|
||||||
@@ -52,6 +53,7 @@ from app.core.ws_context import clear_client_executor, set_client_executor
|
|||||||
from app.db import async_session
|
from app.db import async_session
|
||||||
from app.models import AgentRunLog
|
from app.models import AgentRunLog
|
||||||
from app.schemas import WsFrameType, WsStreamEnd
|
from app.schemas import WsFrameType, WsStreamEnd
|
||||||
|
from app.schemas.contextual import ContextualScope, render_scope_block
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -197,6 +199,16 @@ async def _message_loop(websocket: WebSocket, user_id: str) -> None:
|
|||||||
elif frame_type == WsFrameType.index_session_cancel:
|
elif frame_type == WsFrameType.index_session_cancel:
|
||||||
await _handle_index_session_cancel(websocket, frame)
|
await _handle_index_session_cancel(websocket, frame)
|
||||||
|
|
||||||
|
elif frame_type == WsFrameType.contextual_request:
|
||||||
|
asyncio.create_task(
|
||||||
|
_handle_contextual_request(websocket, user_id, frame)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif frame_type == WsFrameType.contextual_scope_update:
|
||||||
|
asyncio.create_task(
|
||||||
|
_handle_contextual_scope_update(websocket, user_id, frame)
|
||||||
|
)
|
||||||
|
|
||||||
elif frame_type == "pong":
|
elif frame_type == "pong":
|
||||||
# Heartbeat ack — nothing to do, connection is alive.
|
# Heartbeat ack — nothing to do, connection is alive.
|
||||||
pass
|
pass
|
||||||
@@ -359,6 +371,122 @@ async def _handle_floating_request(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── v8 Contextual Sidebar Handlers ───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_buffer(user_id: str, session_id: str, channel: str = "contextual"):
|
||||||
|
"""Return a session-scoped buffer proxy for the given user+session.
|
||||||
|
|
||||||
|
Returns a _ContextualBufferProxy that exposes append_system_message().
|
||||||
|
Defined at module level so tests can monkeypatch it.
|
||||||
|
The channel kwarg is accepted for forward-compatibility.
|
||||||
|
"""
|
||||||
|
from app.core.agent_session_buffer import ContextualBufferProxy # noqa: PLC0415
|
||||||
|
return ContextualBufferProxy(session_buffer, user_id, session_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_contextual_request(
|
||||||
|
websocket: WebSocket,
|
||||||
|
user_id: str,
|
||||||
|
frame: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Handle a contextual_request frame — runs the contextual agent and streams frames."""
|
||||||
|
request_id = frame.get("request_id") or str(uuid4())
|
||||||
|
message: str = frame.get("message", "")
|
||||||
|
session_id: str = frame.get("session_id") or str(uuid4())
|
||||||
|
scope_payload: dict = frame.get("scope", {})
|
||||||
|
logger.info(
|
||||||
|
"device_ws: contextual_request_start user=%s req=%s session=%s msg=%s",
|
||||||
|
user_id,
|
||||||
|
request_id,
|
||||||
|
session_id,
|
||||||
|
message[:200],
|
||||||
|
)
|
||||||
|
|
||||||
|
scope = ContextualScope.model_validate(scope_payload)
|
||||||
|
|
||||||
|
# Enrich context with memory before the LLM call.
|
||||||
|
async with async_session() as db:
|
||||||
|
memory = MemoryMiddleware(db)
|
||||||
|
memory_context = await memory.enrich_context(
|
||||||
|
user_id,
|
||||||
|
message,
|
||||||
|
trace_id=request_id,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
context: dict = {
|
||||||
|
"conversation_history": frame.get("conversation_history", []),
|
||||||
|
"format_prefs": frame.get("format_prefs"),
|
||||||
|
"_debug": {"request_id": request_id, "session_id": session_id, "user_id": user_id},
|
||||||
|
**memory_context,
|
||||||
|
}
|
||||||
|
|
||||||
|
executor = await _make_ws_executor(websocket, user_id)
|
||||||
|
set_client_executor(executor)
|
||||||
|
response_chunks: list[str] = []
|
||||||
|
try:
|
||||||
|
event_stream = run_contextual_stream(
|
||||||
|
user_id=user_id,
|
||||||
|
message=message,
|
||||||
|
context=context,
|
||||||
|
scope=scope,
|
||||||
|
)
|
||||||
|
formatter = StreamFormatter(request_id=request_id)
|
||||||
|
async for ws_frame in formatter.format(event_stream):
|
||||||
|
await websocket.send_text(ws_frame.model_dump_json())
|
||||||
|
if ws_frame.type == "stream_text": # type: ignore[union-attr]
|
||||||
|
response_chunks.append(ws_frame.chunk) # type: ignore[union-attr]
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"device_ws: contextual_request failed user=%s req=%s: %s",
|
||||||
|
user_id, request_id, exc,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
clear_client_executor()
|
||||||
|
|
||||||
|
# Store episode so the contextual agent can recall prior turns.
|
||||||
|
async with async_session() as db:
|
||||||
|
memory = MemoryMiddleware(db)
|
||||||
|
await memory.store_episode(
|
||||||
|
user_id, session_id, message, "".join(response_chunks), trace_id=request_id
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"device_ws: contextual_request_end user=%s req=%s session=%s response_chars=%d",
|
||||||
|
user_id,
|
||||||
|
request_id,
|
||||||
|
session_id,
|
||||||
|
len("".join(response_chunks)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_contextual_scope_update(
|
||||||
|
websocket: WebSocket,
|
||||||
|
user_id: str,
|
||||||
|
frame: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Handle a contextual_scope_update frame.
|
||||||
|
|
||||||
|
Injects a synthetic system message into the session buffer so the next
|
||||||
|
agent turn knows the user navigated. No LLM call is made.
|
||||||
|
"""
|
||||||
|
session_id: str = frame.get("session_id") or str(uuid4())
|
||||||
|
scope = ContextualScope.model_validate(frame.get("scope", {}))
|
||||||
|
block = render_scope_block(scope)
|
||||||
|
buf = get_session_buffer(user_id, session_id, channel="contextual")
|
||||||
|
buf.append_system_message(
|
||||||
|
f"User navigated to a new view. {block} Treat this as the new active context."
|
||||||
|
)
|
||||||
|
await websocket.send_text(json.dumps({
|
||||||
|
"type": WsFrameType.contextual_scope_ack,
|
||||||
|
"session_id": session_id,
|
||||||
|
}))
|
||||||
|
logger.info(
|
||||||
|
"device_ws: contextual_scope_update user=%s session=%s page=%s",
|
||||||
|
user_id, session_id, scope.page,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_brief_request(
|
async def _handle_brief_request(
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
|
|||||||
@@ -54,6 +54,43 @@ class _SessionBuffer:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
self._store.pop((user_id, session_id), None)
|
self._store.pop((user_id, session_id), None)
|
||||||
|
|
||||||
|
def append_system_message(self, user_id: str, session_id: str, text: str) -> None:
|
||||||
|
"""Append a synthetic system message to the buffer for the given session.
|
||||||
|
|
||||||
|
Creates the session slot if it does not yet exist. Used by the
|
||||||
|
contextual_scope_update handler to inject navigation events without
|
||||||
|
making an LLM call.
|
||||||
|
"""
|
||||||
|
from langchain_core.messages import SystemMessage # noqa: PLC0415
|
||||||
|
|
||||||
|
key = (user_id, session_id)
|
||||||
|
with self._lock:
|
||||||
|
entry = self._store.get(key)
|
||||||
|
if entry is None:
|
||||||
|
msgs: list[BaseMessage] = [SystemMessage(content=text)]
|
||||||
|
else:
|
||||||
|
_, existing = entry
|
||||||
|
msgs = list(existing) + [SystemMessage(content=text)]
|
||||||
|
capped = msgs[-MAX_MESSAGES_PER_SESSION:]
|
||||||
|
self._store[key] = (time.monotonic(), capped)
|
||||||
|
|
||||||
|
|
||||||
|
class ContextualBufferProxy:
|
||||||
|
"""Thin wrapper around _SessionBuffer that closes over user_id + session_id.
|
||||||
|
|
||||||
|
Returned by get_session_buffer() so callers can call
|
||||||
|
``proxy.append_system_message(text)`` without threading user_id/session_id
|
||||||
|
through every call site.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, buf: "_SessionBuffer", user_id: str, session_id: str) -> None:
|
||||||
|
self._buf = buf
|
||||||
|
self._user_id = user_id
|
||||||
|
self._session_id = session_id
|
||||||
|
|
||||||
|
def append_system_message(self, text: str) -> None:
|
||||||
|
self._buf.append_system_message(self._user_id, self._session_id, text)
|
||||||
|
|
||||||
|
|
||||||
# Module-level singleton — same pattern as _pending_states in api/app/api/routes/auth.py
|
# Module-level singleton — same pattern as _pending_states in api/app/api/routes/auth.py
|
||||||
session_buffer = _SessionBuffer()
|
session_buffer = _SessionBuffer()
|
||||||
|
|||||||
@@ -392,6 +392,26 @@ For specific dates not listed, compute local-midnight in the user timezone and c
|
|||||||
{request_context}\
|
{request_context}\
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_CONTEXTUAL_SYSTEM_PROMPT = """You are adiuvAI's contextual assistant. The user is working inside the app and has opened a side chat anchored to a specific view ("current view"). Help them act on that view: recap, plan, create entities, answer questions.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
1. Base context (current view summary) is provided every turn. Treat it as ground truth for ids and names; never invent them.
|
||||||
|
2. When the user asks about details not in the base context (e.g. "what tasks are blocking the launch milestone"), call `get_page_details` for the relevant entity before answering. Don't guess.
|
||||||
|
3. When the user requests an action that creates or updates an entity:
|
||||||
|
- If the current view is a project and no project is specified, use the current project automatically.
|
||||||
|
- If the current view is the global Tasks / Projects / Timeline list and no project is specified, ASK before attaching to any project. Don't silently create orphan entities.
|
||||||
|
4. The current view can change mid-conversation (user navigates). When you see a system message "User navigated to ...", treat the new view as the active context. Prior turns remain visible but the active scope shifts.
|
||||||
|
5. Notes: you can read note bodies via `get_page_details({entityType:'note'})`. You CANNOT edit, summarize-to-replace, or append. Tell the user "note editing is coming in a later release" if asked.
|
||||||
|
6. Be concise. Default to 1-3 short paragraphs. Bullet lists fine. Don't restate the user's request.
|
||||||
|
7. Never expose ids in prose. Use names. Ids only travel through tool calls.
|
||||||
|
|
||||||
|
# Date context
|
||||||
|
{date_context}
|
||||||
|
|
||||||
|
# Language
|
||||||
|
{language_instruction}
|
||||||
|
"""
|
||||||
|
|
||||||
_TASK_BRIEF_RESEARCH_SYSTEM_PROMPT = """\
|
_TASK_BRIEF_RESEARCH_SYSTEM_PROMPT = """\
|
||||||
You are an executive assistant preparing a briefing dossier for your principal before they act on a specific task.
|
You are an executive assistant preparing a briefing dossier for your principal before they act on a specific task.
|
||||||
Your job: gather all relevant context, synthesize it into a tight actionable dossier, and — if the task requires writing (email, message, document) — produce a ready-to-use draft.{user_identity}
|
Your job: gather all relevant context, synthesize it into a tight actionable dossier, and — if the task requires writing (email, message, document) — produce a ready-to-use draft.{user_identity}
|
||||||
@@ -556,6 +576,59 @@ def _all_tools() -> list[Any]:
|
|||||||
return [*TASK_TOOLS, *PROJECT_TOOLS, *NOTE_TOOLS, *TIMELINE_TOOLS]
|
return [*TASK_TOOLS, *PROJECT_TOOLS, *NOTE_TOOLS, *TIMELINE_TOOLS]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Contextual sidebar tools ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def get_page_details(
|
||||||
|
entity_type: str = "",
|
||||||
|
entity_id: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Fetch full details for the entity currently in view.
|
||||||
|
|
||||||
|
entity_type: one of 'project' | 'task' | 'note' | 'timeline_event' |
|
||||||
|
'tasks_all' | 'projects_all' | 'timeline_all'.
|
||||||
|
entity_id: UUID of the entity for singular entity views. Omit for list views.
|
||||||
|
|
||||||
|
The Electron drizzle-executor fulfils this op against local SQLite and
|
||||||
|
returns the row(s) as a JSON tool result.
|
||||||
|
"""
|
||||||
|
result = await execute_on_client(
|
||||||
|
action="get_page_details",
|
||||||
|
table=entity_type or "unknown",
|
||||||
|
data={"entityId": entity_id or None},
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
return "No details found."
|
||||||
|
return str(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _contextual_tools(user_id: str, trace_id: str | None) -> list[Any]:
|
||||||
|
"""Return the tool palette for the contextual sidebar agent.
|
||||||
|
|
||||||
|
Includes get_page_details, entity-create/update tools, and memory tools.
|
||||||
|
Note-edit tools (propose_note_edit) are intentionally excluded — next sprint.
|
||||||
|
"""
|
||||||
|
from app.agents.note_agent import create_note, list_notes, get_note # noqa: PLC0415
|
||||||
|
from app.agents.task_agent import create_task, update_task, list_tasks # noqa: PLC0415
|
||||||
|
from app.agents.timeline_agent import create_timeline, list_timelines # noqa: PLC0415
|
||||||
|
from app.agents.project_agent import PROJECT_TOOLS # noqa: PLC0415
|
||||||
|
|
||||||
|
return [
|
||||||
|
get_page_details,
|
||||||
|
create_task,
|
||||||
|
update_task,
|
||||||
|
list_tasks,
|
||||||
|
create_note,
|
||||||
|
list_notes,
|
||||||
|
get_note,
|
||||||
|
create_timeline,
|
||||||
|
list_timelines,
|
||||||
|
*PROJECT_TOOLS,
|
||||||
|
*_memory_tools(user_id, trace_id),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _trace_id_from_context(context: dict[str, Any]) -> str | None:
|
def _trace_id_from_context(context: dict[str, Any]) -> str | None:
|
||||||
debug = context.get("_debug")
|
debug = context.get("_debug")
|
||||||
if isinstance(debug, dict):
|
if isinstance(debug, dict):
|
||||||
@@ -1522,6 +1595,49 @@ async def run_floating_stream(
|
|||||||
yield "token", _fallback_from_raw_floating_text("".join(raw_chunks))
|
yield "token", _fallback_from_raw_floating_text("".join(raw_chunks))
|
||||||
|
|
||||||
|
|
||||||
|
async def run_contextual_stream(
|
||||||
|
user_id: str,
|
||||||
|
message: str,
|
||||||
|
context: dict[str, Any],
|
||||||
|
scope: "ContextualScope", # type: ignore[name-defined]
|
||||||
|
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||||
|
"""Run the contextual agent for a single user turn.
|
||||||
|
|
||||||
|
Mirrors run_floating_stream's plumbing but injects the rendered scope
|
||||||
|
block into the system prompt and exposes the contextual tool set.
|
||||||
|
Note-edit tools (propose_note_edit) are intentionally excluded.
|
||||||
|
|
||||||
|
*context contract*: callers MUST include ``context["_debug"]["session_id"]``
|
||||||
|
(a non-empty str) so that ``_session_id_from_context`` can extract it for
|
||||||
|
tracing and episode storage downstream. The WS handler in device_ws.py
|
||||||
|
satisfies this by always populating ``_debug`` before calling this function.
|
||||||
|
"""
|
||||||
|
from app.schemas.contextual import ContextualScope, render_scope_block # noqa: PLC0415
|
||||||
|
|
||||||
|
prepared_context = await _prepare_context(message, context)
|
||||||
|
trace_id = _trace_id_from_context(prepared_context)
|
||||||
|
|
||||||
|
system_prompt, langfuse_prompt = _build_system_prompt(
|
||||||
|
"contextual_system", _CONTEXTUAL_SYSTEM_PROMPT, prepared_context,
|
||||||
|
)
|
||||||
|
scope_block = render_scope_block(scope)
|
||||||
|
system_prompt = system_prompt + f"\n\n## Current view\n{scope_block}"
|
||||||
|
|
||||||
|
tools = _contextual_tools(user_id, trace_id)
|
||||||
|
|
||||||
|
async for event in _run_single_agent_stream(
|
||||||
|
user_id=user_id,
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
message=message,
|
||||||
|
context=prepared_context,
|
||||||
|
langfuse_prompt=langfuse_prompt,
|
||||||
|
agent_name="contextual-agent",
|
||||||
|
tools=tools,
|
||||||
|
conversation_history=context.get("conversation_history"),
|
||||||
|
):
|
||||||
|
yield event
|
||||||
|
|
||||||
|
|
||||||
async def run_task_brief_research_stream(
|
async def run_task_brief_research_stream(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
task_id: str,
|
task_id: str,
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ class WsFrameType(str, Enum):
|
|||||||
index_file_result = "index_file_result"
|
index_file_result = "index_file_result"
|
||||||
index_session_progress = "index_session_progress"
|
index_session_progress = "index_session_progress"
|
||||||
index_session_done = "index_session_done"
|
index_session_done = "index_session_done"
|
||||||
|
# ── v8 contextual sidebar frame types ────────────────────────────
|
||||||
|
contextual_request = "contextual_request"
|
||||||
|
contextual_scope_update = "contextual_scope_update"
|
||||||
|
contextual_scope_ack = "contextual_scope_ack"
|
||||||
|
|
||||||
|
|
||||||
class WsToolCall(BaseModel):
|
class WsToolCall(BaseModel):
|
||||||
73
app/schemas/contextual.py
Normal file
73
app/schemas/contextual.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""Contextual sidebar scope schema and prompt block renderer.
|
||||||
|
|
||||||
|
ContextualScope mirrors the TypeScript ContextualScope type sent by the
|
||||||
|
Electron renderer when the user opens the side chat anchored to a specific
|
||||||
|
view. The renderer ships camelCase keys; Pydantic's alias_generator maps
|
||||||
|
them to snake_case Python attributes automatically.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from pydantic.alias_generators import to_camel
|
||||||
|
|
||||||
|
|
||||||
|
PageType = Literal[
|
||||||
|
"timeline",
|
||||||
|
"tasks",
|
||||||
|
"projects-list",
|
||||||
|
"project",
|
||||||
|
"note",
|
||||||
|
]
|
||||||
|
|
||||||
|
EntityType = Literal["project", "note", "task", "timeline_event"]
|
||||||
|
|
||||||
|
|
||||||
|
class ContextualScope(BaseModel):
|
||||||
|
"""Scope payload sent by the Electron renderer for contextual chat.
|
||||||
|
|
||||||
|
The renderer ships camelCase keys (entityType, entityId, ...). Pydantic's
|
||||||
|
alias generator maps them to snake_case Python attrs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
|
||||||
|
|
||||||
|
page: PageType
|
||||||
|
entity_type: Optional[EntityType] = None
|
||||||
|
entity_id: Optional[str] = None
|
||||||
|
entity_name: Optional[str] = None
|
||||||
|
project_id: Optional[str] = None
|
||||||
|
char_count: Optional[int] = None
|
||||||
|
counts: Optional[dict[str, int]] = None
|
||||||
|
filters: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
def render_scope_block(scope: ContextualScope) -> str:
|
||||||
|
"""Produce a single-paragraph human-readable summary of the current view
|
||||||
|
for injection into the contextual agent system prompt.
|
||||||
|
|
||||||
|
Never emits internal ids — only names. The LLM is told to use names in
|
||||||
|
prose; ids travel through tool calls.
|
||||||
|
"""
|
||||||
|
if scope.entity_type == "project":
|
||||||
|
c = scope.counts or {}
|
||||||
|
return (
|
||||||
|
f"User is viewing the project {scope.entity_name!r}. "
|
||||||
|
f"{c.get('tasks', 0)} tasks, "
|
||||||
|
f"{c.get('notes', 0)} notes, "
|
||||||
|
f"{c.get('milestones', 0)} milestones."
|
||||||
|
)
|
||||||
|
if scope.entity_type == "note":
|
||||||
|
return (
|
||||||
|
f"User is viewing the note {scope.entity_name!r} "
|
||||||
|
f"({scope.char_count or 0} characters)."
|
||||||
|
)
|
||||||
|
if scope.page == "tasks":
|
||||||
|
return "User is viewing the global Tasks list (all projects)."
|
||||||
|
if scope.page == "timeline":
|
||||||
|
return "User is viewing the global Timeline view."
|
||||||
|
if scope.page == "projects-list":
|
||||||
|
return "User is viewing the Projects list."
|
||||||
|
return f"User is on page {scope.page}."
|
||||||
52
tests/test_contextual_scope.py
Normal file
52
tests/test_contextual_scope.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import pytest
|
||||||
|
from app.schemas.contextual import ContextualScope, render_scope_block
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_project_scope():
|
||||||
|
scope = ContextualScope(
|
||||||
|
page="project",
|
||||||
|
entity_type="project",
|
||||||
|
entity_id="p1",
|
||||||
|
entity_name="Acme Q3 launch",
|
||||||
|
counts={"tasks": 12, "notes": 4, "milestones": 3},
|
||||||
|
)
|
||||||
|
block = render_scope_block(scope)
|
||||||
|
assert "Acme Q3 launch" in block
|
||||||
|
assert "12 tasks" in block
|
||||||
|
assert "4 notes" in block
|
||||||
|
assert "3 milestones" in block
|
||||||
|
assert "p1" not in block
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_list_scope_no_entity():
|
||||||
|
scope = ContextualScope(page="tasks", entity_type=None)
|
||||||
|
block = render_scope_block(scope)
|
||||||
|
assert "tasks" in block.lower()
|
||||||
|
assert "None" not in block
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_note_scope_includes_char_count():
|
||||||
|
scope = ContextualScope(
|
||||||
|
page="note",
|
||||||
|
entity_type="note",
|
||||||
|
entity_id="n1",
|
||||||
|
entity_name="Meeting 14 May",
|
||||||
|
project_id="p1",
|
||||||
|
char_count=4280,
|
||||||
|
)
|
||||||
|
block = render_scope_block(scope)
|
||||||
|
assert "Meeting 14 May" in block
|
||||||
|
assert "4280" in block or "4,280" in block
|
||||||
|
|
||||||
|
|
||||||
|
def test_parses_camelcase_payload_from_renderer():
|
||||||
|
payload = {
|
||||||
|
"page": "project",
|
||||||
|
"entityType": "project",
|
||||||
|
"entityId": "p1",
|
||||||
|
"entityName": "Acme",
|
||||||
|
"counts": {"tasks": 5, "notes": 1, "milestones": 2},
|
||||||
|
}
|
||||||
|
scope = ContextualScope.model_validate(payload)
|
||||||
|
assert scope.entity_id == "p1"
|
||||||
|
assert scope.entity_name == "Acme"
|
||||||
44
tests/test_contextual_ws.py
Normal file
44
tests/test_contextual_ws.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Tests for contextual WS frame handlers.
|
||||||
|
|
||||||
|
These tests only exercise the new handler functions in device_ws.py and do
|
||||||
|
not depend on litellm or the full deep_agent import chain. They monkeypatch
|
||||||
|
run_contextual_stream so no LLM call is made.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_contextual_scope_update_appends_system_message_no_llm(monkeypatch):
|
||||||
|
"""_handle_contextual_scope_update must:
|
||||||
|
- call append_system_message on the session buffer
|
||||||
|
- send a contextual_scope_ack back on the socket
|
||||||
|
- make no LLM call
|
||||||
|
"""
|
||||||
|
from app.api.routes import device_ws
|
||||||
|
|
||||||
|
ws = AsyncMock()
|
||||||
|
buffer = MagicMock()
|
||||||
|
buffer.append_system_message = MagicMock()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"type": "contextual_scope_update",
|
||||||
|
"session_id": "s1",
|
||||||
|
"scope": {
|
||||||
|
"page": "project",
|
||||||
|
"entityType": "project",
|
||||||
|
"entityId": "p1",
|
||||||
|
"entityName": "Acme",
|
||||||
|
"counts": {"tasks": 1, "notes": 0, "milestones": 0},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(device_ws, "get_session_buffer", lambda *a, **kw: buffer)
|
||||||
|
await device_ws._handle_contextual_scope_update(ws, "user1", payload)
|
||||||
|
|
||||||
|
ws.send_text.assert_awaited_once()
|
||||||
|
import json
|
||||||
|
sent = json.loads(ws.send_text.await_args.args[0])
|
||||||
|
assert sent["type"] == "contextual_scope_ack"
|
||||||
|
assert sent["session_id"] == "s1"
|
||||||
|
buffer.append_system_message.assert_called_once()
|
||||||
76
tests/test_run_contextual.py
Normal file
76
tests/test_run_contextual.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""Tests for run_contextual_stream.
|
||||||
|
|
||||||
|
These tests monkeypatch _run_single_agent_stream (the actual internal runner)
|
||||||
|
rather than the plan's fictional _run_agent_loop, matching the real
|
||||||
|
deep_agent.py architecture.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
from app.schemas.contextual import ContextualScope
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_contextual_stream_includes_scope_block(monkeypatch):
|
||||||
|
"""run_contextual_stream must inject the scope block into the system prompt
|
||||||
|
and include get_page_details in the tool list while excluding note-edit tools."""
|
||||||
|
import app.core.deep_agent as deep_agent
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_stream(
|
||||||
|
*,
|
||||||
|
user_id,
|
||||||
|
system_prompt,
|
||||||
|
message,
|
||||||
|
context,
|
||||||
|
agent_name="agent",
|
||||||
|
tools=None,
|
||||||
|
conversation_history=None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
captured["sys"] = system_prompt
|
||||||
|
captured["tool_names"] = [getattr(t, "name", str(t)) for t in (tools or [])]
|
||||||
|
captured["agent_name"] = agent_name
|
||||||
|
# Async generator that yields nothing — still satisfies the protocol.
|
||||||
|
if False:
|
||||||
|
yield # pragma: no cover
|
||||||
|
|
||||||
|
monkeypatch.setattr(deep_agent, "_run_single_agent_stream", fake_stream)
|
||||||
|
|
||||||
|
scope = ContextualScope(
|
||||||
|
page="project",
|
||||||
|
entity_type="project",
|
||||||
|
entity_id="p1",
|
||||||
|
entity_name="Acme",
|
||||||
|
counts={"tasks": 1, "notes": 0, "milestones": 0},
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"conversation_history": [],
|
||||||
|
"_debug": {"session_id": "s1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
results = []
|
||||||
|
async for item in deep_agent.run_contextual_stream(
|
||||||
|
user_id="user1",
|
||||||
|
message="hi",
|
||||||
|
context=context,
|
||||||
|
scope=scope,
|
||||||
|
):
|
||||||
|
results.append(item)
|
||||||
|
|
||||||
|
assert "Acme" in captured["sys"], "scope block must appear in system prompt"
|
||||||
|
assert "Current view" in captured["sys"], "section header must be present"
|
||||||
|
|
||||||
|
names = captured["tool_names"]
|
||||||
|
assert "get_page_details" in names, "get_page_details tool must be included"
|
||||||
|
|
||||||
|
# Entity-create tools: at least one of these must be present.
|
||||||
|
assert any(n in names for n in ("create_task", "create_note", "update_task")), (
|
||||||
|
"at least one entity-create tool must be present"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "create_timeline" in names, "create_timeline tool must be included"
|
||||||
|
|
||||||
|
# Note edit tools must NOT be exposed.
|
||||||
|
assert "propose_note_edit" not in names, "propose_note_edit must be excluded"
|
||||||
Reference in New Issue
Block a user