diff --git a/.env.example b/.env.example index e6259eb..a5901ae 100644 --- a/.env.example +++ b/.env.example @@ -11,11 +11,14 @@ INTELLIGENCE_API_KEY=cpk-... # Get an API key at https://platform.openai.com/api-keys. OPENAI_API_KEY=sk-... -# Optional web research and model override. +# Optional web research and model behavior overrides. # TAVILY_API_KEY=tvly-... # OPENAI_MODEL=gpt-5.5 +# OPENAI_REASONING_EFFORT=low +# OPENAI_VERBOSITY=low # -- Internal Sources (Optional) -- # LINEAR_API_KEY=lin_api_... -# NOTION_TOKEN=ntn_... -# NOTION_MCP_AUTH_TOKEN=choose-a-shared-secret +# Remote Notion requires both values; no local sidecar is included. +# NOTION_MCP_URL=https://your-notion-mcp.example.com/mcp +# NOTION_MCP_AUTH_TOKEN=your-remote-mcp-bearer-token diff --git a/.railway/railway.ts b/.railway/railway.ts index 085a6fa..5ad431d 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -27,6 +27,8 @@ export default defineRailway(() => { OPENAI_API_KEY: preserve(), TAVILY_API_KEY: preserve(), LINEAR_API_KEY: preserve(), + NOTION_MCP_URL: preserve(), + NOTION_MCP_AUTH_TOKEN: preserve(), }, }); diff --git a/README.md b/README.md index 1c35c33..02613c1 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # OpenTag -OpenTag is an open-source research and triage agent for Slack and Microsoft -Teams. It runs on CopilotKit Channels, connects to CopilotKit Intelligence, and -uses a Python LangGraph deep agent over AG-UI. +OpenTag is an open-source on-call triage assistant for Slack and Microsoft +Teams, with research available on demand. It runs on CopilotKit Channels, +connects to CopilotKit Intelligence, and uses a Python LangGraph agent over +AG-UI. The launch supports: @@ -27,7 +28,7 @@ agent (Python + LangGraph deepagents) ├── OpenAI ├── Tavily (optional) ├── Linear MCP (optional) - └── Notion MCP (optional local sidecar) + └── Notion MCP (optional remote server) ``` There is one canonical runtime host: [`server.ts`](./server.ts). @@ -130,10 +131,10 @@ Slack app**. Reusing it preserves the bot user, workspace installation, and ## Optional research sources - `TAVILY_API_KEY` enables live web research. Without it, OpenTag still chats, - plans, uses its virtual filesystem, and renders UI from model knowledge. + triages requests and renders UI from model knowledge. - `LINEAR_API_KEY` enables the hosted Linear MCP. -- Notion is optional for local development: put its token and shared - `NOTION_MCP_AUTH_TOKEN` in the root `.env` and run `pnpm notion-mcp`. +- Notion is optional and remote-only. Set both `NOTION_MCP_URL` and + `NOTION_MCP_AUTH_TOKEN`; setting only one disables the integration. Every Linear and Notion mutation is intercepted in code before the MCP request runs. The interceptor emits `confirm_write` and proceeds only after approval; @@ -153,8 +154,8 @@ The runtime reaches the agent over Railway private networking and embeds the managed `open-tag` Channel. Railway sets `INTELLIGENCE_CHANNEL_NAME=open-tag`; Intelligence owns both platform adapters. The runtime API key is preserved. OpenAI is required on `agent`; -Tavily and Linear secrets are optional. Connecting both services to `main` -enables GitHub-triggered deployments after merges. +Tavily, Linear, and remote Notion settings are optional. Connecting both +services to `main` enables GitHub-triggered deployments after merges. The repository configuration does not mutate the existing production Railway project. Inventory and cutover should happen after Railway authentication. diff --git a/agent/agent.py b/agent/agent.py index b14d7ad..7d242ef 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -1,10 +1,15 @@ -"""OpenTag's Deep Agents research assistant.""" +"""OpenTag's triage-first Deep Agent.""" import os from pathlib import Path from copilotkit import CopilotKitMiddleware -from deepagents import create_deep_agent +from deepagents import ( + GeneralPurposeSubagentProfile, + HarnessProfile, + create_deep_agent, + register_harness_profile, +) from dotenv import load_dotenv from langchain_openai import ChatOpenAI from langgraph.checkpoint.memory import MemorySaver @@ -12,36 +17,80 @@ from internal_sources import internal_source_tools from prompts import ( BASE_SYSTEM_PROMPT, - NO_RESEARCH_TOOL_ADDENDUM, - RESEARCH_TOOL_ADDENDUM, + NO_WEB_SEARCH_TOOL_ADDENDUM, + WEB_SEARCH_TOOL_ADDENDUM, ) -from tools import research +from tools import web_search load_dotenv(Path(__file__).resolve().parent.parent / ".env") +VALID_REASONING_EFFORTS = frozenset( + {"none", "minimal", "low", "medium", "high", "xhigh", "max"} +) +VALID_VERBOSITY_LEVELS = frozenset({"low", "medium", "high"}) + +# Deep Agents adds shell execution and a general-purpose delegation tool by +# default. OpenTag has no sandbox for execute, and delegating routine turns to +# another agent adds latency without improving triage. +register_harness_profile( + "openai", + HarnessProfile( + excluded_tools=frozenset({"execute"}), + general_purpose_subagent=GeneralPurposeSubagentProfile(enabled=False), + ), +) + + +def _validated_openai_setting( + name: str, + *, + default: str, + allowed: frozenset[str], +) -> str: + """Read and validate a non-secret OpenAI tuning setting.""" + value = os.environ.get(name, default).strip().lower() + if value not in allowed: + choices = ", ".join(sorted(allowed)) + raise RuntimeError(f"Invalid {name}: expected one of {choices}") + return value + def build_agent(): - """Build the OpenTag research graph.""" + """Build the OpenTag triage graph.""" api_key = os.environ.get("OPENAI_API_KEY") if not api_key: raise RuntimeError("Missing OPENAI_API_KEY environment variable") - has_research = bool(os.environ.get("TAVILY_API_KEY")) + reasoning_effort = _validated_openai_setting( + "OPENAI_REASONING_EFFORT", + default="low", + allowed=VALID_REASONING_EFFORTS, + ) + verbosity = _validated_openai_setting( + "OPENAI_VERBOSITY", + default="low", + allowed=VALID_VERBOSITY_LEVELS, + ) + has_web_search = bool(os.environ.get("TAVILY_API_KEY")) model_name = os.environ.get("OPENAI_MODEL", "gpt-5.5") llm = ChatOpenAI( model=model_name, api_key=api_key, + reasoning_effort=reasoning_effort, + verbosity=verbosity, ) internal_tools = internal_source_tools() main_tools = ( - [research, *internal_tools] - if has_research + [web_search, *internal_tools] + if has_web_search else [*internal_tools] ) system_prompt = BASE_SYSTEM_PROMPT + ( - RESEARCH_TOOL_ADDENDUM if has_research else NO_RESEARCH_TOOL_ADDENDUM + WEB_SEARCH_TOOL_ADDENDUM + if has_web_search + else NO_WEB_SEARCH_TOOL_ADDENDUM ) agent_graph = create_deep_agent( @@ -52,9 +101,12 @@ def build_agent(): checkpointer=MemorySaver(), ) - print(f"[AGENT] OpenTag Deep Research Agent created with model={model_name}") - print(f"[AGENT] research: {'enabled' if has_research else 'disabled'}") + print( + "[AGENT] OpenTag Agent created " + f"with model={model_name}, reasoning={reasoning_effort}, verbosity={verbosity}" + ) + print(f"[AGENT] web search: {'enabled' if has_web_search else 'disabled'}") print(f"[AGENT] internal-source tools: {len(internal_tools)}") print(f"[AGENT] Main tools: {[t.name for t in main_tools]}") - return agent_graph.with_config({"recursion_limit": 100}) + return agent_graph.with_config({"recursion_limit": 25}) diff --git a/agent/internal_sources.py b/agent/internal_sources.py index c120009..eac2f86 100644 --- a/agent/internal_sources.py +++ b/agent/internal_sources.py @@ -21,7 +21,7 @@ "notion": { "token_env": "NOTION_MCP_AUTH_TOKEN", "url_env": "NOTION_MCP_URL", - "default_url": "http://127.0.0.1:3001/mcp", + "default_url": None, }, } MCP_LOAD_TIMEOUT_SECONDS = 8.0 @@ -57,12 +57,29 @@ def _configured_connections( connections: dict[str, dict[str, Any]] = {} for name, config in MCP_SERVERS.items(): token = env.get(config["token_env"]) + configured_url = env.get(config["url_env"]) + url = configured_url or config["default_url"] if not token: + if configured_url: + logger.warning( + "[TOOLS] skipping %s: %s must be set with %s", + name, + config["token_env"], + config["url_env"], + ) + continue + if not url: + logger.warning( + "[TOOLS] skipping %s: %s must be set with %s", + name, + config["url_env"], + config["token_env"], + ) continue connections[name] = { "transport": "streamable_http", - "url": env.get(config["url_env"], config["default_url"]), + "url": url, "headers": {"Authorization": f"Bearer {token}"}, } return connections diff --git a/agent/main.py b/agent/main.py index 9b99eb8..fcbba0f 100644 --- a/agent/main.py +++ b/agent/main.py @@ -1,4 +1,4 @@ -"""FastAPI server for the OpenTag research agent.""" +"""FastAPI server for the OpenTag triage agent.""" from collections.abc import Mapping import os @@ -12,14 +12,15 @@ from agent import build_agent app = FastAPI( - title="OpenTag Deep Research Agent", - description="A research assistant powered by Deep Agents and CopilotKit", + title="OpenTag Agent", + description="An on-call triage assistant powered by Deep Agents and CopilotKit", version="0.1.0", ) AGENT_NAME = "opentag_research" AGENT_DESCRIPTION = ( - "OpenTag deep research assistant — plans, searches, and synthesizes cited briefs" + "OpenTag on-call triage assistant for incidents, research, and " + "Linear/Notion workflows" ) # Allow all origins locally, or set CORS_ALLOW_ORIGINS to restrict access. @@ -40,7 +41,7 @@ @app.get("/health") def health(): """Return service health.""" - return {"status": "ok", "service": "opentag-research-agent", "version": "0.1.0"} + return {"status": "ok", "service": "opentag-agent", "version": "0.1.0"} def local_server_port(env: Mapping[str, str] = os.environ) -> int: @@ -70,7 +71,7 @@ def local_server_port(env: Mapping[str, str] = os.environ) -> int: path="/", ) - print("[SERVER] Deep Research Agent registered at /") + print("[SERVER] OpenTag Agent registered at /") except Exception as error: print(f"[ERROR] Failed to build agent: {error}", file=sys.stderr) raise diff --git a/agent/prompts/__init__.py b/agent/prompts/__init__.py index fda5e64..ed93195 100644 --- a/agent/prompts/__init__.py +++ b/agent/prompts/__init__.py @@ -1,18 +1,16 @@ """OpenTag agent prompts.""" -from .research import ( - NO_RESEARCH_TOOL_ADDENDUM, - RESEARCH_TOOL_ADDENDUM, - RESEARCHER_SYSTEM_PROMPT, -) from .system import SYSTEM_PROMPT, WORKFLOW_PROMPT from .tools import TOOLS_PROMPT +from .web_search import ( + NO_WEB_SEARCH_TOOL_ADDENDUM, + WEB_SEARCH_TOOL_ADDENDUM, +) BASE_SYSTEM_PROMPT = SYSTEM_PROMPT + TOOLS_PROMPT + WORKFLOW_PROMPT __all__ = [ "BASE_SYSTEM_PROMPT", - "NO_RESEARCH_TOOL_ADDENDUM", - "RESEARCH_TOOL_ADDENDUM", - "RESEARCHER_SYSTEM_PROMPT", + "NO_WEB_SEARCH_TOOL_ADDENDUM", + "WEB_SEARCH_TOOL_ADDENDUM", ] diff --git a/agent/prompts/research.py b/agent/prompts/research.py deleted file mode 100644 index 037a3a6..0000000 --- a/agent/prompts/research.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Prompts for optional web research.""" - -RESEARCH_TOOL_ADDENDUM = """ - -Live web research is available via the research(query) tool: -- Call research() for each distinct research question that needs current or - external information -- The research tool returns prose summaries of findings - synthesize them, - don't just relay them -- Example workflow: - 1. write_todos(["Research topic A", "Research topic B", "Synthesize findings"]) - 2. research("Find information about topic A") -> receives prose summary - 3. research("Find information about topic B") -> receives prose summary - 4. write_file("/reports/final_report.md", "# Research Report\\n\\n...") -""" - -NO_RESEARCH_TOOL_ADDENDUM = """ - -You do NOT have a live web research tool available right now. Answer from your -own knowledge, state plainly when you cannot look up current or external -information, and never claim to have searched the web. You can still plan -with write_todos, read/write files, and render findings as UI components. -""" - -RESEARCHER_SYSTEM_PROMPT = """You are a Research Specialist. - -Use internet_search to find information and return a prose summary. - -Rules: -- Call internet_search ONCE with a focused query -- Analyze the returned content -- Return a brief summary (2-3 sentences) of key findings -- No JSON, no code blocks, just prose""" diff --git a/agent/prompts/system.py b/agent/prompts/system.py index c0e16c6..1616c24 100644 --- a/agent/prompts/system.py +++ b/agent/prompts/system.py @@ -1,24 +1,25 @@ """Core OpenTag persona and workflow.""" -SYSTEM_PROMPT = """You are OpenTag's Deep Research Assistant, an expert at planning and -executing comprehensive research on any topic. +SYSTEM_PROMPT = """You are OpenTag, your team's on-call triage assistant for fast +incident support, research, and Linear/Notion workflows. Hard rules (ALWAYS follow): - NEVER output raw JSON, data structures, or code blocks in your messages - Communicate with the user only in natural, readable prose -- When you receive data from research or from your own knowledge, synthesize it into insights +- Lead with the answer and keep routine replies concise and action-oriented +- When you receive source data or use your own knowledge, synthesize it into insights - Prefer rendering results as UI components (tables, cards, links, etc.) when the frontend offers them, rather than large blocks of raw markdown """ WORKFLOW_PROMPT = """ -Your workflow: -1. PLAN: Create a research plan using write_todos with clear, actionable steps -2. INVESTIGATE: Work through each step, drawing on the tools available to you -3. SYNTHESIZE: Write a final report to /reports/final_report.md using write_file - -Important guidelines: -- Always start by creating a research plan with write_todos -- You write all files - compile findings into a comprehensive report -- Update todos as you complete each step -- Always maintain a professional, comprehensive research style""" +Operating mode: +- Answer ordinary requests directly. Do not turn them into research projects +- Use the smallest number of tool calls needed to answer reliably +- Use write_todos only for explicitly substantial, multi-step work where a + visible plan helps the user +- Use filesystem tools only when the user asks for an artifact or when + substantial, multi-step work needs durable notes +- Do not write a report by default +- When a request is ambiguous in a way that changes the action, ask one focused + question; otherwise make a reasonable, stated assumption and proceed""" diff --git a/agent/prompts/web_search.py b/agent/prompts/web_search.py new file mode 100644 index 0000000..8f9fd7a --- /dev/null +++ b/agent/prompts/web_search.py @@ -0,0 +1,17 @@ +"""Prompt guidance for optional direct web search.""" + +WEB_SEARCH_TOOL_ADDENDUM = """ + +Live web research is available via web_search(query, max_results=5): +- Use it when a request needs current or external information +- Start with one focused query and search again only when a material gap remains +- The tool returns source snippets with URLs; synthesize the evidence and cite + the useful sources rather than dumping raw results +""" + +NO_WEB_SEARCH_TOOL_ADDENDUM = """ + +You do NOT have a live web research tool available right now. Answer from your +own knowledge, state plainly when you cannot look up current or external +information, and never claim to have searched the web. +""" diff --git a/agent/pyproject.toml b/agent/pyproject.toml index 01c7148..4cf27e4 100644 --- a/agent/pyproject.toml +++ b/agent/pyproject.toml @@ -1,12 +1,12 @@ [project] -name = "opentag-research-agent" +name = "opentag-agent" version = "0.1.0" -description = "OpenTag Deep Research Agent — CopilotKit Deep Agents (LangGraph) over AG-UI" +description = "OpenTag on-call triage agent — CopilotKit Deep Agents (LangGraph) over AG-UI" requires-python = ">=3.12" dependencies = [ "ag-ui-langgraph>=0.0.23", "copilotkit>=0.1.76", - "deepagents>=0.3.5", + "deepagents>=0.6.12", "fastapi>=0.115.14", "langchain>=1.2.4", "langchain-mcp-adapters>=0.3.0", diff --git a/agent/tests/test_agent_configuration.py b/agent/tests/test_agent_configuration.py new file mode 100644 index 0000000..4a870a9 --- /dev/null +++ b/agent/tests/test_agent_configuration.py @@ -0,0 +1,132 @@ +import agent as agent_mod +import pytest +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import AIMessage, BaseMessage +from langchain_core.outputs import ChatGeneration, ChatResult +from pydantic import Field + + +class FakeGraph: + def __init__(self, captured): + self.captured = captured + + def with_config(self, config): + self.captured["config"] = config + return self + + +def build_with_captured_configuration(monkeypatch): + captured = {} + + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.delenv("TAVILY_API_KEY", raising=False) + monkeypatch.delenv("LINEAR_API_KEY", raising=False) + monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) + monkeypatch.setattr(agent_mod, "internal_source_tools", lambda: []) + + def fake_chat_openai(**kwargs): + captured["model"] = kwargs + return object() + + def fake_create_deep_agent(**kwargs): + captured["agent"] = kwargs + return FakeGraph(captured) + + monkeypatch.setattr(agent_mod, "ChatOpenAI", fake_chat_openai) + monkeypatch.setattr(agent_mod, "create_deep_agent", fake_create_deep_agent) + + graph = agent_mod.build_agent() + return graph, captured + + +def test_build_agent_defaults_to_low_reasoning_and_verbosity(monkeypatch): + monkeypatch.delenv("OPENAI_REASONING_EFFORT", raising=False) + monkeypatch.delenv("OPENAI_VERBOSITY", raising=False) + + _, captured = build_with_captured_configuration(monkeypatch) + + assert captured["model"]["reasoning_effort"] == "low" + assert captured["model"]["verbosity"] == "low" + + +def test_build_agent_accepts_valid_reasoning_and_verbosity_overrides(monkeypatch): + monkeypatch.setenv("OPENAI_REASONING_EFFORT", "high") + monkeypatch.setenv("OPENAI_VERBOSITY", "medium") + + _, captured = build_with_captured_configuration(monkeypatch) + + assert captured["model"]["reasoning_effort"] == "high" + assert captured["model"]["verbosity"] == "medium" + + +@pytest.mark.parametrize( + ("name", "value"), + [ + ("OPENAI_REASONING_EFFORT", "extreme"), + ("OPENAI_VERBOSITY", "verbose"), + ], +) +def test_build_agent_rejects_invalid_openai_tuning(monkeypatch, name, value): + monkeypatch.setenv(name, value) + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + + with pytest.raises(RuntimeError, match=name): + agent_mod.build_agent() + + +def test_build_agent_bounds_graph_recursion(monkeypatch): + _, captured = build_with_captured_configuration(monkeypatch) + + assert captured["config"] == {"recursion_limit": 25} + + +def test_openai_harness_excludes_unusable_delegation_tools(monkeypatch): + class RecordingOpenAIModel(BaseChatModel): + model_name: str = "gpt-5.5" + seen_tool_names: list[str] = Field(default_factory=list) + + @property + def _llm_type(self): + return "recording-openai" + + def _get_ls_params(self, **_kwargs): + return { + "ls_provider": "openai", + "ls_model_name": self.model_name, + "ls_model_type": "chat", + } + + def bind_tools(self, tools, **_kwargs): + self.seen_tool_names = [tool.name for tool in tools] + return self + + def _generate( + self, + messages: list[BaseMessage], + stop=None, + run_manager=None, + **_kwargs, + ): + del messages, stop, run_manager + return ChatResult( + generations=[ChatGeneration(message=AIMessage(content="done"))] + ) + + model = RecordingOpenAIModel() + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.delenv("TAVILY_API_KEY", raising=False) + monkeypatch.delenv("LINEAR_API_KEY", raising=False) + monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) + monkeypatch.setattr(agent_mod, "ChatOpenAI", lambda **_kwargs: model) + monkeypatch.setattr(agent_mod, "internal_source_tools", lambda: []) + + graph = agent_mod.build_agent() + graph.invoke( + {"messages": [{"role": "user", "content": "hello"}]}, + config={"configurable": {"thread_id": "tool-availability-test"}}, + ) + tool_names = set(model.seen_tool_names) + + assert "execute" not in tool_names + assert "task" not in tool_names + assert {"write_todos", "read_file", "write_file"}.issubset(tool_names) diff --git a/agent/tests/test_health.py b/agent/tests/test_health.py index 8697cbd..7a882dd 100644 --- a/agent/tests/test_health.py +++ b/agent/tests/test_health.py @@ -15,7 +15,7 @@ def test_health_ok(monkeypatch): assert r.status_code == 200 assert r.json() == { "status": "ok", - "service": "opentag-research-agent", + "service": "opentag-agent", "version": "0.1.0", } @@ -27,9 +27,9 @@ def test_server_exposes_opentag_metadata(monkeypatch): monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) import main - assert main.app.title == "OpenTag Deep Research Agent" + assert main.app.title == "OpenTag Agent" assert main.AGENT_NAME == "opentag_research" - assert main.AGENT_DESCRIPTION.startswith("OpenTag deep research assistant") + assert main.AGENT_DESCRIPTION.startswith("OpenTag on-call triage assistant") def test_local_agent_port_ignores_the_shared_channel_port(): @@ -55,7 +55,7 @@ def test_build_agent_without_tavily(monkeypatch, capsys): graph = agent_mod.build_agent() assert graph is not None out = capsys.readouterr().out - assert "[AGENT] research: disabled" in out + assert "[AGENT] web search: disabled" in out def test_build_agent_with_tavily(monkeypatch, capsys): @@ -66,7 +66,7 @@ def test_build_agent_with_tavily(monkeypatch, capsys): graph = agent_mod.build_agent() assert graph is not None out = capsys.readouterr().out - assert "[AGENT] research: enabled" in out + assert "[AGENT] web search: enabled" in out def test_build_agent_requires_openai(monkeypatch): @@ -113,4 +113,4 @@ def test_system_prompt_requires_confirmation_only_for_writes(): def test_system_prompt_uses_opentag_persona(): - assert "OpenTag's Deep Research Assistant" in agent_mod.BASE_SYSTEM_PROMPT + assert "OpenTag, your team's on-call triage assistant" in agent_mod.BASE_SYSTEM_PROMPT diff --git a/agent/tests/test_internal_sources.py b/agent/tests/test_internal_sources.py index 103527a..e9a7094 100644 --- a/agent/tests/test_internal_sources.py +++ b/agent/tests/test_internal_sources.py @@ -18,7 +18,7 @@ def test_mcp_servers_are_configured_in_one_place(): "notion": { "token_env": "NOTION_MCP_AUTH_TOKEN", "url_env": "NOTION_MCP_URL", - "default_url": "http://127.0.0.1:3001/mcp", + "default_url": None, }, } @@ -29,6 +29,17 @@ def test_internal_source_tools_empty_without_env(monkeypatch): assert internal_sources.internal_source_tools() == [] +@pytest.mark.parametrize( + "env", + [ + {"NOTION_MCP_AUTH_TOKEN": "notion-test-token"}, + {"NOTION_MCP_URL": "https://notion.example.test/mcp"}, + ], +) +def test_remote_notion_requires_both_url_and_auth_token(env): + assert internal_sources._configured_connections(env) == {} + + def test_internal_source_tools_loads_when_env_set(monkeypatch): clients = [] @@ -112,6 +123,10 @@ async def get_tools(self): monkeypatch.setenv("LINEAR_API_KEY", "lin_api_test") monkeypatch.setenv("NOTION_MCP_AUTH_TOKEN", "notion-test-token") + monkeypatch.setenv( + "NOTION_MCP_URL", + "https://notion.example.test/mcp", + ) monkeypatch.setattr( internal_sources, "MultiServerMCPClient", diff --git a/agent/tests/test_metadata.py b/agent/tests/test_metadata.py index 8b15f50..88f5f8a 100644 --- a/agent/tests/test_metadata.py +++ b/agent/tests/test_metadata.py @@ -6,5 +6,12 @@ def test_python_package_uses_opentag_identity(): pyproject_path = Path(__file__).parents[1] / "pyproject.toml" project = tomllib.loads(pyproject_path.read_text())["project"] - assert project["name"] == "opentag-research-agent" - assert project["description"].startswith("OpenTag Deep Research Agent") + assert project["name"] == "opentag-agent" + assert project["description"].startswith("OpenTag on-call triage agent") + + +def test_deepagents_minimum_supports_harness_profiles(): + pyproject_path = Path(__file__).parents[1] / "pyproject.toml" + project = tomllib.loads(pyproject_path.read_text())["project"] + + assert "deepagents>=0.6.12" in project["dependencies"] diff --git a/agent/tests/test_prompts.py b/agent/tests/test_prompts.py index 7f204b4..935bd10 100644 --- a/agent/tests/test_prompts.py +++ b/agent/tests/test_prompts.py @@ -1,15 +1,22 @@ from prompts import ( BASE_SYSTEM_PROMPT, - NO_RESEARCH_TOOL_ADDENDUM, - RESEARCH_TOOL_ADDENDUM, - RESEARCHER_SYSTEM_PROMPT, + NO_WEB_SEARCH_TOOL_ADDENDUM, + WEB_SEARCH_TOOL_ADDENDUM, ) -def test_prompt_modules_preserve_agent_behavior(): - assert "OpenTag's Deep Research Assistant" in BASE_SYSTEM_PROMPT +def test_prompt_uses_triage_first_behavior(): + assert "on-call triage assistant" in BASE_SYSTEM_PROMPT assert "CRITICAL:" in BASE_SYSTEM_PROMPT assert "Linear or Notion mutation" in BASE_SYSTEM_PROMPT - assert "research(query)" in RESEARCH_TOOL_ADDENDUM - assert "do NOT have a live web research tool" in NO_RESEARCH_TOOL_ADDENDUM - assert "Call internet_search ONCE" in RESEARCHER_SYSTEM_PROMPT + assert "Answer ordinary requests directly" in BASE_SYSTEM_PROMPT + assert "substantial, multi-step work" in BASE_SYSTEM_PROMPT + assert "Do not write a report by default" in BASE_SYSTEM_PROMPT + assert "Always start by creating a research plan" not in BASE_SYSTEM_PROMPT + assert "/reports/final_report.md" not in BASE_SYSTEM_PROMPT + + +def test_prompt_describes_direct_optional_web_search(): + assert "web_search(query, max_results=5)" in WEB_SEARCH_TOOL_ADDENDUM + assert "source snippets" in WEB_SEARCH_TOOL_ADDENDUM + assert "do NOT have a live web research tool" in NO_WEB_SEARCH_TOOL_ADDENDUM diff --git a/agent/tests/test_tools.py b/agent/tests/test_tools.py index c0ad8b6..13be38c 100644 --- a/agent/tests/test_tools.py +++ b/agent/tests/test_tools.py @@ -2,7 +2,7 @@ import pytest -def test_do_internet_search_formats_results(monkeypatch): +def test_search_tavily_formats_results(monkeypatch): class FakeClient: def __init__(self, api_key): pass def search(self, **kwargs): @@ -11,22 +11,46 @@ def search(self, **kwargs): ]} monkeypatch.setenv("TAVILY_API_KEY", "tvly-test") monkeypatch.setattr(tools, "TavilyClient", FakeClient) - out = tools._do_internet_search("q", max_results=1) + out = tools._search_tavily("q", max_results=1) assert out == [{"url": "https://x.com", "title": "X", "content": "c" * 3000}] -def test_do_internet_search_missing_key(monkeypatch): +def test_search_tavily_missing_key(monkeypatch): monkeypatch.delenv("TAVILY_API_KEY", raising=False) with pytest.raises(RuntimeError, match="TAVILY_API_KEY"): - tools._do_internet_search("q") + tools._search_tavily("q") -def test_do_internet_search_raises_when_tavily_fails(monkeypatch): +def test_search_tavily_raises_when_tavily_fails(monkeypatch): class BoomClient: def __init__(self, api_key): pass def search(self, **kwargs): raise ValueError("boom") monkeypatch.setenv("TAVILY_API_KEY", "tvly-test") monkeypatch.setattr(tools, "TavilyClient", BoomClient) with pytest.raises(RuntimeError, match="Tavily search failed") as error: - tools._do_internet_search("q") + tools._search_tavily("q") assert isinstance(error.value.__cause__, ValueError) + + +def test_web_search_returns_tavily_results_directly(monkeypatch): + calls = [] + expected = [ + { + "url": "https://example.com", + "title": "Example", + "content": "Current source material", + } + ] + + def fake_search(query, max_results): + calls.append((query, max_results)) + return expected + + monkeypatch.setattr(tools, "_search_tavily", fake_search) + + result = tools.web_search.invoke( + {"query": "current incident guidance", "max_results": 3} + ) + + assert result == expected + assert calls == [("current incident guidance", 3)] diff --git a/agent/tools.py b/agent/tools.py index 0f861cc..1d8c70e 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -1,19 +1,15 @@ -"""Tavily-backed web research tools.""" +"""Tavily-backed web search tools.""" -from concurrent.futures import ThreadPoolExecutor import os from typing import Any -from langchain_core.messages import HumanMessage from langchain_core.tools import tool from tavily import TavilyClient -from prompts import RESEARCHER_SYSTEM_PROMPT - -def _do_internet_search(query: str, max_results: int = 5) -> list[dict[str, Any]]: +def _search_tavily(query: str, max_results: int = 5) -> list[dict[str, Any]]: """Search Tavily and normalize its results.""" - print(f"[TOOL] internet_search: query='{query}', max_results={max_results}") + print(f"[TOOL] web_search: query='{query}', max_results={max_results}") tavily_key = os.environ.get("TAVILY_API_KEY") if not tavily_key: @@ -38,7 +34,7 @@ def _do_internet_search(query: str, max_results: int = 5) -> list[dict[str, Any] } ) - print(f"[TOOL] internet_search: found {len(formatted_results)} results") + print(f"[TOOL] web_search: found {len(formatted_results)} results") return formatted_results except Exception as error: @@ -46,54 +42,6 @@ def _do_internet_search(query: str, max_results: int = 5) -> list[dict[str, Any] @tool -def research(query: str) -> dict: - """Research a topic and return a prose summary with sources.""" - print(f"[TOOL] research: query='{query}' (using thread isolation)") - - from deepagents import create_deep_agent - from langchain_openai import ChatOpenAI - - def _run_research_isolated(): - search_results = [] - - def internet_search(query: str, max_results: int = 5): - """Search the web and capture sources for the final response.""" - results = _do_internet_search(query, max_results) - search_results.extend(results) - return results - - model_name = os.environ.get("OPENAI_MODEL", "gpt-5.5") - llm = ChatOpenAI( - model=model_name, - api_key=os.environ.get("OPENAI_API_KEY"), - ) - - research_agent = create_deep_agent( - model=llm, - system_prompt=RESEARCHER_SYSTEM_PROMPT, - tools=[internet_search], - ) - - result = research_agent.invoke({"messages": [HumanMessage(content=query)]}) - summary = result["messages"][-1].content - - sources = [ - { - "url": r["url"], - "title": r.get("title", ""), - "content": r.get("content", "")[:3000], - "status": "found", - } - for r in search_results - if "url" in r - ] - - return {"summary": summary, "sources": sources} - - # The separate thread prevents callbacks from leaking into the parent run. - with ThreadPoolExecutor(max_workers=1) as executor: - future = executor.submit(_run_research_isolated) - result = future.result() - - print(f"[TOOL] research: completed with {len(result['sources'])} sources") - return result +def web_search(query: str, max_results: int = 5) -> list[dict[str, Any]]: + """Search the live web and return source URLs with concise content snippets.""" + return _search_tavily(query, max_results) diff --git a/agent/uv.lock b/agent/uv.lock index e76b41f..0302438 100644 --- a/agent/uv.lock +++ b/agent/uv.lock @@ -909,7 +909,7 @@ wheels = [ ] [[package]] -name = "opentag-research-agent" +name = "opentag-agent" version = "0.1.0" source = { virtual = "." } dependencies = [ @@ -935,7 +935,7 @@ dev = [ requires-dist = [ { name = "ag-ui-langgraph", specifier = ">=0.0.23" }, { name = "copilotkit", specifier = ">=0.1.76" }, - { name = "deepagents", specifier = ">=0.3.5" }, + { name = "deepagents", specifier = ">=0.6.12" }, { name = "fastapi", specifier = ">=0.115.14" }, { name = "langchain", specifier = ">=1.2.4" }, { name = "langchain-mcp-adapters", specifier = ">=0.3.0" }, diff --git a/app/channel.test.ts b/app/channel.test.ts index cd6577c..fc2d492 100644 --- a/app/channel.test.ts +++ b/app/channel.test.ts @@ -82,6 +82,26 @@ function findButton( return undefined; } +function findIncidentButton( + nodes: ChannelNode[], + action: "ack" | "escalate", +): ChannelNode | undefined { + for (const node of nodes) { + if ( + node.type === "button" && + (node.props.value as { action?: string } | undefined)?.action === action + ) { + return node; + } + const children = node.props.children; + if (Array.isArray(children)) { + const found = findIncidentButton(children as ChannelNode[], action); + if (found) return found; + } + } + return undefined; +} + afterEach(async () => { await Promise.all(channels.splice(0).map((channel) => channel.ɵruntime.stop())); vi.restoreAllMocks(); @@ -110,6 +130,18 @@ describe("createOpenTagChannel", () => { "preview", "triage", ]); + expect(appTools.map(({ name }) => name).sort()).toEqual([ + "issue_card", + "issue_list", + "page_list", + "read_thread", + "render_chart", + "render_diagram", + "render_table", + "show_incident", + "show_links", + "show_status", + ]); expect(appTools.map(({ name }) => name)).not.toContain("confirm_write"); }); @@ -395,4 +427,66 @@ describe("createOpenTagChannel", () => { expect(JSON.stringify(secondAdapter.updated)).toContain("Approved"); expect(secondAgent.runAgentCalls).toBe(1); }); + + it("re-registers incident actions when a new Channel uses the same store", async () => { + const sharedState = new MemoryStore(); + const firstAdapter = new FakeAdapter({ platform: "intelligence" }); + firstAdapter.stateStore = sharedState; + const firstAgent = new FakeAgent([ + (subscriber) => { + subscriber.onToolCallEndEvent?.({ + event: { toolCallId: "show-incident-1" }, + toolCallName: "show_incident", + toolCallArgs: { + id: "INC-42", + title: "Checkout unavailable", + severity: "SEV1", + summary: "Requests are returning 500.", + }, + } as never); + subscriber.onRunFinishedEvent?.({ event: {} } as never); + }, + (subscriber) => + subscriber.onRunFinishedEvent?.({ event: {} } as never), + ]); + const firstChannel = createOpenTagChannel("opentag", firstAgent); + firstChannel.ɵruntime.addAdapter(firstAdapter); + channels.push(firstChannel); + await firstChannel.ɵruntime.start(); + await firstAdapter.getSink().onTurn({ + conversationKey: "incident-thread", + replyTarget: {}, + userText: "show the incident", + platform: "slack", + user: { id: "U1" }, + }); + + const acknowledge = findIncidentButton(firstAdapter.posted[0]!, "ack"); + const actionId = ( + acknowledge?.props.onClick as { id?: string } | undefined + )?.id; + expect(actionId).toMatch(/^ck:/); + await firstChannel.ɵruntime.stop(); + + const secondAdapter = new FakeAdapter({ platform: "intelligence" }); + secondAdapter.stateStore = sharedState; + const secondChannel = createOpenTagChannel("opentag", new FakeAgent()); + secondChannel.ɵruntime.addAdapter(secondAdapter); + channels.push(secondChannel); + await secondChannel.ɵruntime.start(); + await secondAdapter.getSink().onInteraction({ + id: actionId!, + conversationKey: "incident-thread", + replyTarget: {}, + messageRef: { id: "incident-message" }, + user: { id: "U2", name: "Ada" }, + value: { action: "ack", id: "INC-42" }, + }); + + expect(secondAdapter.updated).toHaveLength(1); + expect(JSON.stringify(secondAdapter.updated)).toContain( + "Acknowledged · Checkout unavailable", + ); + expect(JSON.stringify(secondAdapter.updated)).toContain("Ack'd by Ada"); + }); }); diff --git a/app/channel.tsx b/app/channel.tsx index 1a3f979..4bb7be0 100644 --- a/app/channel.tsx +++ b/app/channel.tsx @@ -13,6 +13,7 @@ import { appContext } from "./context/app-context.js"; import { ConfirmWrite } from "./human-in-the-loop/index.js"; import { parseConfirmWriteInterrupt } from "./interrupt.js"; import { FILE_ISSUE_CALLBACK, fileIssueSubmit } from "./modals/file-issue.js"; +import { IncidentCard } from "./tools/showcase-tools.js"; import { appTools } from "./tools/index.js"; type ChannelAgent = NonNullable; @@ -28,7 +29,7 @@ export function createOpenTagChannel( tools: appTools, context: [...appContext], commands: appCommands, - components: [IssueCard, IssueList, PageList, ConfirmWrite], + components: [IssueCard, IssueList, PageList, IncidentCard, ConfirmWrite], }); channel.onMention(async ({ thread, message }) => { diff --git a/app/cleanup.test.ts b/app/cleanup.test.ts new file mode 100644 index 0000000..0f55db3 --- /dev/null +++ b/app/cleanup.test.ts @@ -0,0 +1,34 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const root = process.cwd(); +const packageJson = JSON.parse( + readFileSync(resolve(root, "package.json"), "utf8"), +) as { + scripts: Record; + dependencies: Record; +}; + +describe("launch dependency and cleanup contract", () => { + it("keeps the exact Channels and Runtime canaries", () => { + expect(packageJson.dependencies["@copilotkit/channels"]).toBe( + "0.2.2-canary.rc-1", + ); + expect(packageJson.dependencies["@copilotkit/runtime"]).toBe( + "1.63.3-canary.rc-1", + ); + }); + + it("has no local Notion sidecar or token-grabbing browser automation", () => { + expect(packageJson.scripts).not.toHaveProperty("notion-mcp"); + expect(packageJson.dependencies).not.toHaveProperty( + "@notionhq/notion-mcp-server", + ); + expect(existsSync(resolve(root, "scripts/start-notion-mcp.ts"))).toBe( + false, + ); + expect(existsSync(resolve(root, "e2e/grab-user-token.ts"))).toBe(false); + expect(existsSync(resolve(root, "e2e/TELEGRAM-README.md"))).toBe(false); + }); +}); diff --git a/app/components/__tests__/components.test.tsx b/app/components/__tests__/components.test.tsx index 2741d5b..405d38a 100644 --- a/app/components/__tests__/components.test.tsx +++ b/app/components/__tests__/components.test.tsx @@ -336,7 +336,7 @@ describe("PageList component", () => { }); describe("Microsoft Teams rendering parity", () => { - it("renders rich issue and page content as an Adaptive Card", () => { + it("renders rich issue and page content as Adaptive Cards", () => { const issue = renderAdaptiveCard( renderToIR( { />, ), ); + const issues = renderAdaptiveCard( + renderToIR( + , + ), + ); const pages = renderAdaptiveCard( renderToIR( { expect(issue.type).toBe("AdaptiveCard"); expect(JSON.stringify(issue)).toContain("🔵 CPK-101"); expect(JSON.stringify(issue)).toContain("🚨 Urgent"); + expect(JSON.stringify(issues)).toContain("Open incidents"); + expect(JSON.stringify(issues)).toContain("CPK-102"); expect(JSON.stringify(pages)).toContain("Auth outage runbook"); expect(JSON.stringify(pages)).toContain( "Steps to mitigate auth provider downtime.", diff --git a/app/components/index.ts b/app/components/index.ts index 5a324f3..2662747 100644 --- a/app/components/index.ts +++ b/app/components/index.ts @@ -6,10 +6,7 @@ * exported zod prop schema doubles as the render-tool input schema. */ export { IssueCard, issueCardSchema } from "./issue-card.js"; -export type { IssueCardProps } from "./issue-card.js"; export { IssueList, issueListSchema } from "./issue-list.js"; -export type { IssueListProps } from "./issue-list.js"; export { PageList, pageListSchema } from "./page-list.js"; -export type { PageListProps } from "./page-list.js"; diff --git a/app/components/issue-card.tsx b/app/components/issue-card.tsx index bf3ba9c..8853c79 100644 --- a/app/components/issue-card.tsx +++ b/app/components/issue-card.tsx @@ -48,7 +48,7 @@ export const issueCardSchema = z.object({ ), }); -export type IssueCardProps = z.infer; +type IssueCardProps = z.infer; /** Render ONE Linear issue as a rich Block Kit card. */ export function IssueCard(issue: IssueCardProps): ChannelNode { diff --git a/app/components/issue-list.tsx b/app/components/issue-list.tsx index 09785d2..39efd2e 100644 --- a/app/components/issue-list.tsx +++ b/app/components/issue-list.tsx @@ -55,7 +55,7 @@ export const issueListSchema = z.object({ issues: z.array(issueSchema).min(1).describe("The issues to render."), }); -export type IssueListProps = z.infer; +type IssueListProps = z.infer; type Issue = z.infer; /** Max issues rendered inline; the rest are summarized in the footer. */ diff --git a/app/components/page-list.tsx b/app/components/page-list.tsx index 91fc048..ff4d73b 100644 --- a/app/components/page-list.tsx +++ b/app/components/page-list.tsx @@ -48,7 +48,7 @@ export const pageListSchema = z.object({ pages: z.array(pageSchema).min(1).describe("The pages to render."), }); -export type PageListProps = z.infer; +type PageListProps = z.infer; type Page = z.infer; /** Max pages rendered inline; the rest are summarized in the footer. */ diff --git a/app/context/app-context.test.ts b/app/context/app-context.test.ts new file mode 100644 index 0000000..d88255f --- /dev/null +++ b/app/context/app-context.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { appContext } from "./app-context.js"; + +describe("appContext rich-rendering policy", () => { + it("maps every structured response shape to exactly one render tool", () => { + const context = appContext.map(({ value }) => value).join("\n"); + + expect(context).toContain("Several Linear issues -> issue_list"); + expect(context).toContain("A single Linear issue -> issue_card"); + expect(context).toContain("Notion pages -> page_list"); + expect(context).toContain("Tabular data -> render_table"); + expect(context).toContain("Status or metrics -> show_status"); + expect(context).toContain("Incident or outage -> show_incident"); + expect(context).toContain("Links or runbooks -> show_links"); + expect(context).toContain("Chart from data -> render_chart"); + expect(context).toContain( + "Flow, architecture, or timeline -> render_diagram", + ); + }); + + it("makes rendering mandatory and forbids duplicate prose", () => { + const context = appContext.map(({ value }) => value).join("\n"); + + expect(context).toContain("CRITICAL: RENDERING IS A HARD RULE"); + expect(context).toContain("MUST call the matching render tool"); + expect(context).toContain("call it first"); + expect(context).toContain("empty or one short line"); + expect(context).toContain("Never restate"); + expect(context).toContain("Render, then stop."); + }); +}); diff --git a/app/context/app-context.ts b/app/context/app-context.ts index 8ed4c7c..e88e74e 100644 --- a/app/context/app-context.ts +++ b/app/context/app-context.ts @@ -27,4 +27,26 @@ export const appContext: ReadonlyArray = [ "platform's tagging procedure when you know who they are.", ].join("\n"), }, + { + description: "Rich rendering policy", + value: [ + "CRITICAL: RENDERING IS A HARD RULE. Whenever an answer contains structured", + "output, you MUST call the matching render tool and let it draw the", + "result. Do not reproduce the same content as Markdown bullets, a", + "hand-written table, or prose. Choose exactly one matching tool:", + "- Several Linear issues -> issue_list", + "- A single Linear issue -> issue_card (use justCreated after creation)", + "- Notion pages -> page_list", + "- Tabular data -> render_table", + "- Status or metrics -> show_status", + "- Incident or outage -> show_incident", + "- Links or runbooks -> show_links", + "- Chart from data -> render_chart", + "- Flow, architecture, or timeline -> render_diagram", + "When the user explicitly asks for one of these structured outputs, call it first;", + "the tool output is the answer. Text alongside a rendered", + "result must be empty or one short line. Never restate issues, rows,", + "fields, links, or image contents after rendering. Render, then stop.", + ].join("\n"), + }, ]; diff --git a/app/env.test.ts b/app/env.test.ts index b45f9cf..6ea12e4 100644 --- a/app/env.test.ts +++ b/app/env.test.ts @@ -73,23 +73,6 @@ describe("parsePort", () => { expect(parsePort("4242")).toBe(4242); }); - it("can require an unpadded decimal string", () => { - expect(() => - parsePort(" 4242", 3001, "NOTION_MCP_PORT", { - strictDecimal: true, - }), - ).toThrow('Invalid NOTION_MCP_PORT: " 4242"'); - }); - - it("can treat an empty value as the configured default", () => { - expect( - parsePort("", 3001, "NOTION_MCP_PORT", { - emptyIsDefault: true, - strictDecimal: true, - }), - ).toBe(3001); - }); - it.each(["", "0", "65536", "12.5", "abc"])( "rejects invalid PORT %j", (raw) => { diff --git a/app/env.ts b/app/env.ts index e37c11f..30942d3 100644 --- a/app/env.ts +++ b/app/env.ts @@ -26,21 +26,12 @@ export function parsePort( raw: string | undefined, defaultPort = 3000, name = "PORT", - options: { - emptyIsDefault?: boolean; - strictDecimal?: boolean; - } = {}, ): number { - const value = options.emptyIsDefault && raw === "" ? undefined : raw; - if (value === undefined) return defaultPort; + if (raw === undefined) return defaultPort; - if (options.strictDecimal && !/^\d+$/.test(value)) { - throw new Error(`Invalid ${name}: "${value}"`); - } - - const port = Number(value); + const port = Number(raw); if (!Number.isInteger(port) || port < 1 || port > 65_535) { - throw new Error(`Invalid ${name}: "${value}"`); + throw new Error(`Invalid ${name}: "${raw}"`); } return port; } diff --git a/app/human-in-the-loop/__tests__/confirm-write.test.tsx b/app/human-in-the-loop/__tests__/confirm-write.test.tsx index 56dd9a3..8be2f8f 100644 --- a/app/human-in-the-loop/__tests__/confirm-write.test.tsx +++ b/app/human-in-the-loop/__tests__/confirm-write.test.tsx @@ -6,6 +6,7 @@ import { type ClickHandler, } from "@copilotkit/channels"; import { renderSlackMessage } from "@copilotkit/channels/slack"; +import { renderAdaptiveCard } from "@copilotkit/channels/teams"; import { ConfirmWrite } from "../confirm-write.js"; /** Children of an IR node as an array (empty if none). */ @@ -105,6 +106,24 @@ describe("ConfirmWrite", () => { expect(blocks.some((b) => b.type === "section")).toBe(false); }); + it("renders Create and Cancel actions as a Teams Adaptive Card", () => { + const card = renderAdaptiveCard( + renderToIR( + , + ), + ); + const json = JSON.stringify(card); + + expect(card.type).toBe("AdaptiveCard"); + expect(json).toContain("Create Linear issue"); + expect(json).toContain("Create"); + expect(json).toContain("Cancel"); + expect(json).toContain("Action.Submit"); + }); + it("approve onClick updates the picker and resumes the interrupted agent", async () => { const ir = renderToIR( , diff --git a/app/human-in-the-loop/confirm-write.tsx b/app/human-in-the-loop/confirm-write.tsx index c787c38..065b249 100644 --- a/app/human-in-the-loop/confirm-write.tsx +++ b/app/human-in-the-loop/confirm-write.tsx @@ -23,7 +23,7 @@ import { } from "@copilotkit/channels"; import type { InteractionContext } from "@copilotkit/channels"; -export interface ConfirmWriteProps { +interface ConfirmWriteProps { /** Short imperative title of the write, e.g. 'Create Linear issue'. */ action: string; /** The specifics being approved — issue title + one-line description, etc. */ diff --git a/app/human-in-the-loop/index.ts b/app/human-in-the-loop/index.ts index 201b6bb..d20645f 100644 --- a/app/human-in-the-loop/index.ts +++ b/app/human-in-the-loop/index.ts @@ -7,4 +7,3 @@ * event posts `ConfirmWrite`; the card's buttons call `thread.resume(...)`. */ export { ConfirmWrite } from "./confirm-write.js"; -export type { ConfirmWriteProps } from "./confirm-write.js"; diff --git a/app/railway.test.ts b/app/railway.test.ts index dd48088..6a5c5e9 100644 --- a/app/railway.test.ts +++ b/app/railway.test.ts @@ -65,8 +65,13 @@ describe("Railway deployment graph", () => { healthcheckPath: "/health", }, }); - expect(agent?.variables).not.toHaveProperty("NOTION_MCP_URL"); - expect(agent?.variables).not.toHaveProperty("NOTION_MCP_AUTH_TOKEN"); + expect(agent?.variables).toMatchObject({ + OPENAI_API_KEY: { type: "preserve" }, + TAVILY_API_KEY: { type: "preserve" }, + LINEAR_API_KEY: { type: "preserve" }, + NOTION_MCP_URL: { type: "preserve" }, + NOTION_MCP_AUTH_TOKEN: { type: "preserve" }, + }); const runtime = resources.find(({ name }) => name === "runtime"); expect(runtime).toMatchObject({ diff --git a/app/tools/__tests__/render-table.test.tsx b/app/tools/__tests__/render-table.test.tsx index c558504..89cfbd6 100644 --- a/app/tools/__tests__/render-table.test.tsx +++ b/app/tools/__tests__/render-table.test.tsx @@ -7,6 +7,7 @@ import { describe, it, expect } from "vitest"; import { renderToIR } from "@copilotkit/channels"; import { renderSlackMessage } from "@copilotkit/channels/slack"; +import { renderAdaptiveCard } from "@copilotkit/channels/teams"; import { renderTableTool, toMonospaceTable, clamp } from "../render-table.js"; type HandlerCtx = Parameters[1]; @@ -119,6 +120,21 @@ describe("render_table tool", () => { ]); }); + it("renders tabular data as a Teams Adaptive Card", async () => { + const { posts, ctx } = fakeThread(); + await renderTableTool.handler( + { title: "Open issues", columns: COLS, rows: ROWS }, + ctx, + ); + + const card = renderAdaptiveCard(renderToIR(posts[0] as never)); + const json = JSON.stringify(card); + expect(card.type).toBe("AdaptiveCard"); + expect(json).toContain("Open issues"); + expect(json).toContain("CPK-1"); + expect(json).toContain("High"); + }); + it("falls back to a monospace table when the native post is rejected", async () => { const { posts, ctx } = fakeThread([1]); const out = (await renderTableTool.handler( diff --git a/app/tools/__tests__/showcase-tools.test.tsx b/app/tools/__tests__/showcase-tools.test.tsx index b5976cb..954647d 100644 --- a/app/tools/__tests__/showcase-tools.test.tsx +++ b/app/tools/__tests__/showcase-tools.test.tsx @@ -14,6 +14,7 @@ import type { ClickHandler, } from "@copilotkit/channels"; import { renderSlackMessage } from "@copilotkit/channels/slack"; +import { renderAdaptiveCard } from "@copilotkit/channels/teams"; import { showIncidentTool, showStatusTool, @@ -100,6 +101,27 @@ describe("show_incident render-tool", () => { expect(text).toContain("Escalate"); }); + it("renders the incident and its actions as a Teams Adaptive Card", async () => { + const { posts, thread } = fakeThread(); + await showIncidentTool.handler( + { + id: "INC-1", + title: "Checkout 500s", + severity: "SEV1", + summary: "Error rate spiking on /checkout.", + }, + { thread } as unknown as IncidentCtx, + ); + + const card = renderAdaptiveCard(renderToIR(posts[0] as never)); + const json = JSON.stringify(card); + expect(card.type).toBe("AdaptiveCard"); + expect(json).toContain("Checkout 500s"); + expect(json).toContain("Acknowledge"); + expect(json).toContain("Escalate"); + expect(json).toContain("Action.Submit"); + }); + it("the Acknowledge button's onClick updates the message with a green card", async () => { const { posts, updates, thread } = fakeThread(); await showIncidentTool.handler( @@ -315,6 +337,22 @@ describe("show_status render-tool", () => { expect(text).toContain("*Queue depth*"); expect(text).toContain("operational"); }); + + it("renders status fields as a Teams Adaptive Card", async () => { + const { posts, thread } = fakeThread(); + await showStatusTool.handler( + { + heading: "Service health", + fields: [{ label: "API", value: "operational" }], + }, + { thread } as unknown as StatusCtx, + ); + + const card = renderAdaptiveCard(renderToIR(posts[0] as never)); + expect(card.type).toBe("AdaptiveCard"); + expect(JSON.stringify(card)).toContain("Service health"); + expect(JSON.stringify(card)).toContain("operational"); + }); }); describe("show_links render-tool", () => { @@ -339,4 +377,22 @@ describe("show_links render-tool", () => { // No leftover markdown link syntax. expect(text).not.toContain("](http"); }); + + it("renders links as a Teams Adaptive Card", async () => { + const { posts, thread } = fakeThread(); + await showLinksTool.handler( + { + heading: "Runbooks", + links: [ + { label: "Auth outage", url: "https://example.com/auth" }, + ], + }, + { thread } as unknown as LinksCtx, + ); + + const card = renderAdaptiveCard(renderToIR(posts[0] as never)); + expect(card.type).toBe("AdaptiveCard"); + expect(JSON.stringify(card)).toContain("Runbooks"); + expect(JSON.stringify(card)).toContain("https://example.com/auth"); + }); }); diff --git a/app/tools/index.ts b/app/tools/index.ts index 70dec6e..c6b91d4 100644 --- a/app/tools/index.ts +++ b/app/tools/index.ts @@ -2,8 +2,8 @@ * App-specific frontend tools. Provider defaults are added per turn by * `app/channel-helpers.ts`. * - * Add new tools here and re-export them through `appTools`. Wire the - * array into `createChannel({ tools })`. + * Add new tools here and include them in `appTools`. Wire the array into + * `createChannel({ tools })`. */ import { readThreadTool } from "./read-thread.js"; import { renderChartTool } from "./render-chart.js"; @@ -37,16 +37,3 @@ export const appTools: ChannelTool[] = [ showStatusTool, showLinksTool, ]; - -export { - readThreadTool, - renderChartTool, - renderDiagramTool, - renderTableTool, - issueCardTool, - issueListTool, - pageListTool, - showIncidentTool, - showStatusTool, - showLinksTool, -}; diff --git a/app/tools/showcase-tools.tsx b/app/tools/showcase-tools.tsx index 2aa4ce3..d95e989 100644 --- a/app/tools/showcase-tools.tsx +++ b/app/tools/showcase-tools.tsx @@ -112,7 +112,7 @@ const statusSchema = z.object({ type StatusProps = z.infer; -export function StatusCard({ heading, fields }: StatusProps) { +function StatusCard({ heading, fields }: StatusProps) { return (
{`📊 ${heading}`}
@@ -155,7 +155,7 @@ const linksSchema = z.object({ type LinksProps = z.infer; -export function LinksCard({ heading, links }: LinksProps) { +function LinksCard({ heading, links }: LinksProps) { // `[label](url)` is rewritten to Slack's `` link form by // `markdownToMrkdwn`; authoring the raw `` here would have its // inner text mangled, so we author markdown links instead. diff --git a/e2e/README.md b/e2e/README.md index 2399a87..3e66ae0 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -28,6 +28,10 @@ These are test-harness credentials, not Channel-runtime configuration; Intelligence still owns the deployed Slack attachment. The harness never provisions or reinstalls an app. +Obtain the user token through your approved Slack app-management flow and add +it to `.env` yourself. The repository intentionally contains no browser +automation that edits the manifest, reinstalls the app, or extracts tokens. + ## Run Start or deploy the OpenTag agent and Channel, then: diff --git a/e2e/TELEGRAM-README.md b/e2e/TELEGRAM-README.md deleted file mode 100644 index b73f25d..0000000 --- a/e2e/TELEGRAM-README.md +++ /dev/null @@ -1,6 +0,0 @@ -# Telegram end-to-end testing — coming soon - -Telegram is not an active OpenTag launch surface. Its adapter, configuration, -and live end-to-end harness will be documented when support is ready. - -For this launch, use Slack or Microsoft Teams through CopilotKit Intelligence. diff --git a/e2e/grab-user-token.ts b/e2e/grab-user-token.ts deleted file mode 100644 index f94212d..0000000 --- a/e2e/grab-user-token.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * One-off: grab the User OAuth Token (xoxp-) for the bot's Slack app by: - * 1) updating the manifest to declare `chat:write` user scope - * 2) saving the manifest - * 3) reinstalling the app (which approves the new user scope) - * 4) reading the User OAuth Token from the OAuth & Permissions page - * 5) writing SLACK_USER_TOKEN into .env - * - * Uses the persistent playwright profile under ./e2e/.chrome-profile/. - */ -import "dotenv/config"; -import { chromium } from "playwright"; -import type { Page } from "playwright"; -import { readFileSync, writeFileSync } from "node:fs"; - -const PROFILE_DIR = "./e2e/.chrome-profile"; -const APP_ID = "A0B49763Y66"; -const TEAM_ID = "T05QFA4BW9X"; - -const MANIFEST_PATH = "./slack-app-manifest.json"; -const ENV_PATH = "./.env"; - -async function setCodeMirrorValue(page: Page, value: string): Promise { - // CodeMirror v5: set the value via the instance, dispatch a synthetic - // "change" so React (the Slack dashboard) notices the dirty state and - // enables Save Changes. - await page.evaluate((val) => { - const cm = ( - document.querySelector(".CodeMirror") as unknown as { - CodeMirror?: { - setValue(s: string): void; - getValue(): string; - }; - } - )?.CodeMirror; - if (!cm) throw new Error("CodeMirror instance not found"); - cm.setValue(val); - }, value); -} - -async function waitForVisibleEnabledButton( - page: Page, - label: string, - timeoutMs = 15000, -): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const found = await page.evaluate((lab) => { - const btn = Array.from(document.querySelectorAll("button")).find( - (b) => - b.textContent?.trim() === lab && - !b.disabled && - (b as HTMLElement).offsetParent !== null, - ); - return !!btn; - }, label); - if (found) return; - await page.waitForTimeout(300); - } - throw new Error(`Timed out waiting for enabled button: "${label}"`); -} - -async function clickButtonByText(page: Page, label: string): Promise { - await page.evaluate((lab) => { - const btn = Array.from(document.querySelectorAll("button")).find( - (b) => b.textContent?.trim() === lab && !b.disabled, - ) as HTMLButtonElement | undefined; - if (!btn) throw new Error(`No enabled button "${lab}"`); - btn.click(); - }, label); -} - -async function main() { - const manifestJson = readFileSync(MANIFEST_PATH, "utf8"); - const context = await chromium.launchPersistentContext(PROFILE_DIR, { - headless: false, - }); - const page = context.pages()[0] ?? (await context.newPage()); - - // ── 1+2. Save manifest ──────────────────────────────────────────── - console.log("[grab-token] → manifest editor"); - await page.goto( - `https://app.slack.com/app-settings/${TEAM_ID}/${APP_ID}/app-manifest`, - { waitUntil: "networkidle" }, - ); - await page.waitForSelector(".CodeMirror", { timeout: 15000 }); - await page.waitForTimeout(1000); - - await setCodeMirrorValue(page, manifestJson); - await page.waitForTimeout(800); - - console.log("[grab-token] waiting for Save Changes to enable"); - try { - await waitForVisibleEnabledButton(page, "Save Changes", 10_000); - } catch { - // Already saved? Pull the current editor contents and compare. - const currentVal = await page.evaluate(() => { - const cm = ( - document.querySelector(".CodeMirror") as unknown as { - CodeMirror?: { getValue(): string }; - } - )?.CodeMirror; - return cm?.getValue() ?? ""; - }); - if (currentVal.includes('"user"') && currentVal.includes('"chat:write"')) { - console.log( - "[grab-token] manifest already has user scope, no save needed", - ); - } else { - throw new Error( - "Save Changes button did not enable and current manifest doesn't have user scope", - ); - } - } - - // Try save (may already be saved) - const saveEnabled = await page.evaluate(() => { - const btn = Array.from(document.querySelectorAll("button")).find( - (b) => b.textContent?.trim() === "Save Changes", - ) as HTMLButtonElement | undefined; - return !!(btn && !btn.disabled); - }); - if (saveEnabled) { - console.log("[grab-token] clicking Save Changes"); - await clickButtonByText(page, "Save Changes"); - // Some manifest changes trigger a confirmation modal (esp. scope changes). - await page.waitForTimeout(2000); - // Click any confirmation buttons that pop up. - const confirmed = await page.evaluate(() => { - const labels = ["Save", "Yes, save changes", "Continue", "Save Changes"]; - for (const lab of labels) { - const btn = Array.from(document.querySelectorAll("button")).find( - (b) => - b.textContent?.trim() === lab && - !b.disabled && - (b as HTMLElement).offsetParent !== null, - ) as HTMLButtonElement | undefined; - if (btn) { - btn.click(); - return lab; - } - } - return null; - }); - if (confirmed) console.log(`[grab-token] confirmation: "${confirmed}"`); - await page.waitForTimeout(3000); - } - - // ── 3. Reinstall (will OAuth-approve the new user scope) ────────── - console.log("[grab-token] → install page"); - await page.goto(`https://api.slack.com/apps/${APP_ID}/install-on-team`, { - waitUntil: "networkidle", - }); - await page.waitForTimeout(2000); - - const installLink = await page.evaluate(() => { - const a = Array.from(document.querySelectorAll("a")).find( - (el) => - /^(re)?install to/i.test((el.textContent ?? "").trim()) && - el.href.includes("/oauth/v2/authorize"), - ); - return a?.href ?? null; - }); - if (!installLink) throw new Error("Couldn't find Install/Reinstall link"); - console.log("[grab-token] → install link →", installLink.slice(0, 80) + "…"); - await page.goto(installLink, { waitUntil: "networkidle" }); - await page.waitForTimeout(2500); - - // ── 4. Approve OAuth — Allow button on the consent page ─────────── - console.log("[grab-token] looking for Allow button"); - try { - await page.waitForFunction( - () => - !!Array.from(document.querySelectorAll("button")).find( - (b) => b.textContent?.trim() === "Allow", - ), - { timeout: 8000 }, - ); - // Real click via DOM event submission (the Allow button is a real