diff --git a/.env.example b/.env.example index 02996b2..a5901ae 100644 --- a/.env.example +++ b/.env.example @@ -1,121 +1,24 @@ -# Set SLACK_*, DISCORD_*, TELEGRAM_BOT_TOKEN, and/or WHATSAPP_* — the bot starts -# an adapter for each platform whose secrets are present (any one, or several at once). +# -- Intelligence Variables -- +# Get an API key at https://intelligence.copilotkit.ai. +# Both variables are required. The included agent listens on port 8123. +AGENT_URL=http://localhost:8123/ +INTELLIGENCE_API_KEY=cpk-... -# ── Slack credentials (from api.slack.com/apps → your app) ────────────── -# Set both to run on Slack; leave blank to skip Slack. -# Bot Token: OAuth & Permissions page, "Bot User OAuth Token" (xoxb-...) -SLACK_BOT_TOKEN=xoxb-... -# App-Level Token: Basic Information → App-Level Tokens → generate with the -# "connections:write" scope (xapp-...) -SLACK_APP_TOKEN=xapp-... +# Optional. Defaults to "open-tag"; set this to the Channel name in Intelligence. +# INTELLIGENCE_CHANNEL_NAME=open-tag -# ── Discord credentials (from discord.com/developers/applications → your app) ── -# Set both to run on Discord; leave blank to skip Discord. Bot page → "Reset -# Token"; under "Privileged Gateway Intents" enable BOTH Message Content and -# Server Members (both required). Application ID is on General Information. -# DISCORD_BOT_TOKEN= -# DISCORD_APP_ID= -# Optional: a guild (server) id registers slash commands instantly during dev -# (global commands can take up to ~1h). Omit in production. -# DISCORD_GUILD_ID= - -# ── Telegram credentials (from @BotFather on Telegram) ────────────────── -# Set to run on Telegram; leave blank to skip Telegram. Message @BotFather → -# /newbot → copy the token it gives you. Long-polling is the default ingress -# (no public URL or webhook setup needed). -# TELEGRAM_BOT_TOKEN= - -# ── Agent backend (runtime.ts) ────────────────────────────────────────── -# The AG-UI endpoint the bridge POSTs to. REQUIRED — the bridge exits at -# startup if this is unset; there is no code-level fallback. The value below -# is a template pointing at the local CopilotKit runtime started by -# `pnpm runtime`; change it if you deploy the runtime elsewhere. -AGENT_URL=http://localhost:8200/api/copilotkit/agent/triage/run -# Optional auth header forwarded to the agent (for a deployed runtime). -# AGENT_AUTH_HEADER=Bearer ... - -# ── Intelligence channel mode — app/managed.ts (`pnpm channel`) ────────── -# Runs the SAME bot over the CopilotKit Intelligence Realtime Gateway instead -# of a native adapter — this process holds NO Slack tokens (Intelligence owns -# the Slack edge). The agent brain reuses AGENT_URL / AGENT_AUTH_HEADER above. -# All values come from your CopilotKit Intelligence project + channel setup. -# Gateway runner WebSocket URL (the /runner endpoint). -# INTELLIGENCE_GATEWAY_WS_URL=wss://dev.intelligence.copilotkit.ai/runner -# Project runtime API key (cpk-{projectId}_...), presented as the socket auth token. -# INTELLIGENCE_API_KEY=cpk-... -# Authoritative org/project/channel scope (from the Intelligence dashboard): -# INTELLIGENCE_ORG_ID=org_... -# INTELLIGENCE_PROJECT_ID=123 -# INTELLIGENCE_CHANNEL_ID=channel_... -# The registered channel name (lowercase kebab). Defaults to "kitebot". -# INTELLIGENCE_CHANNEL_NAME=kitebot -# Optional stable runtime instance id; auto-generated (rti_...) if unset. -# INTELLIGENCE_RUNTIME_INSTANCE_ID=rti_... - -# Model for the BuiltInAgent. The runtime is OpenAI-only (it uses OpenAI's -# Responses API via TanStack AI's `openaiText` adapter, needed for the -# `web_search` provider tool) — there is no multi-provider resolution. -# AGENT_MODEL only accepts an OpenAI model id, optionally prefixed with -# "openai/" (the prefix is stripped); defaults to openai/gpt-5.5. -# AGENT_MODEL=openai/gpt-5.5 +# -- Agent Variables -- +# Get an API key at https://platform.openai.com/api-keys. OPENAI_API_KEY=sk-... -# ANTHROPIC_API_KEY / GOOGLE_API_KEY are NOT supported by this runtime today -# (runtime.ts never reads them) — do not set AGENT_MODEL to an anthropic/... or -# google/... value. -# ── Deep-research agent (agent/) — alternative brain to runtime.ts ────── -# A standalone Python (deepagents/LangGraph) service served over AG-UI on -# :8123. To use it instead of the TS runtime above, point the bridge at it: -# AGENT_URL=http://localhost:8123/ -# Only OPENAI_API_KEY (above) is required to run it — it still chats and -# generates UI components without Tavily. -# OPTIONAL — web research for the agent/ service. Without it the `research` -# web tool is simply not loaded (chat + UI-component generation still work, -# answering from the model's own knowledge); with it, live web research -# turns on. Get a key at https://tavily.com. This is scoped to agent/, not -# to runtime.ts, which has no research tool. +# Optional web research and model behavior overrides. # TAVILY_API_KEY=tvly-... -# Model used by the agent/ service (its own env, separate from AGENT_MODEL -# above which is read by runtime.ts). Defaults to gpt-5.5. # OPENAI_MODEL=gpt-5.5 - -# ── Linear MCP ────────────────────────────────────────────────────────── -# The hosted Linear MCP accepts a raw API key as a bearer token (no OAuth -# dance). Create one at linear.app → Settings → API → Personal API keys. -# Leave LINEAR_API_KEY blank to run without Linear. -LINEAR_API_KEY=lin_api_... -# Override only if you front the MCP yourself; default is the hosted server. -# LINEAR_MCP_URL=https://mcp.linear.app/mcp -# Default Linear team the bot files/queries against. -LINEAR_TEAM_KEY=CPK - -# ── Notion MCP ────────────────────────────────────────────────────────── -# Run the official Notion MCP as a Streamable-HTTP sidecar: `pnpm notion-mcp`. -# NOTION_TOKEN is the Notion integration secret the sidecar uses to call the -# Notion API (notion.so → Settings → Connections → develop integrations). -# NOTION_MCP_AUTH_TOKEN is the bearer the sidecar requires on its HTTP -# transport; the agent sends it. Pick any strong string and use it in both -# `pnpm notion-mcp` and here. Leave NOTION_MCP_AUTH_TOKEN blank to run -# without Notion. -NOTION_TOKEN=ntn_... -NOTION_MCP_AUTH_TOKEN=choose-a-strong-shared-secret -# Override only if the sidecar runs elsewhere; default is the local sidecar. -# NOTION_MCP_URL=http://127.0.0.1:3001/mcp -# Port the `pnpm notion-mcp` sidecar listens on (scripts/start-notion-mcp.ts). -# Must agree with the port in NOTION_MCP_URL above. Defaults to 3001. -# NOTION_MCP_PORT=3001 -# Notion-Version header the sidecar sends to the Notion API. Defaults to -# 2022-06-28; override only if you need a newer Notion API version. -# NOTION_VERSION=2022-06-28 - -# ── WhatsApp Cloud API (optional — omit to run Slack-only) ────────────── -# From your Meta App → WhatsApp → API Setup. Leave WHATSAPP_ACCESS_TOKEN blank -# to disable the WhatsApp adapter entirely. Use a System User token in prod -# (the temporary token expires in 24h). -WHATSAPP_ACCESS_TOKEN= -WHATSAPP_PHONE_NUMBER_ID= -WHATSAPP_APP_SECRET= -WHATSAPP_VERIFY_TOKEN=choose-any-string-and-paste-it-in-the-webhook-config -# Webhook path (default /webhook). The server listens on $PORT — Railway injects -# it and routes the service's public domain there; locally it defaults to 3000. -# WHATSAPP_PATH=/webhook +# OPENAI_REASONING_EFFORT=low +# OPENAI_VERBOSITY=low + +# -- Internal Sources (Optional) -- +# LINEAR_API_KEY=lin_api_... +# 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/.npmrc b/.npmrc deleted file mode 100644 index ab3e21a..0000000 --- a/.npmrc +++ /dev/null @@ -1,7 +0,0 @@ -# @tanstack/ai-openai declares a peer dependency on zod@^4, but the CopilotKit -# bot stack (@copilotkit/bot-slack, @copilotkit/bot-ui, …) is built on zod@^3 — -# and that is the version this app uses. tanstack never receives our zod objects -# directly (tool schemas are converted to JSON before the LLM call), so zod 3 is -# correct at runtime. `legacy-peer-deps` lets `npm install` proceed past that -# advisory peer mismatch — the same thing pnpm does by default in the monorepo. -legacy-peer-deps=true diff --git a/.railway/railway.ts b/.railway/railway.ts index de623ed..5ad431d 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -1,121 +1,67 @@ +/** + * Reconciles OpenTag's production Railway topology: a Python agent and the + * CopilotRuntime process that embeds Channels. Platform credentials and + * attachments stay in Intelligence. + */ import { defineRailway, github, preserve, project, service } from "railway/iac"; -// KiteBot on CopilotKit Intelligence — one-click Railway topology. -// Three services build from this repo; the Python agent uses rootDirectory "agent". -// Inter-service URLs use Railway reference variables (${{svc.VAR}}), resolved at -// deploy over private networking. SECRETS are declared with preserve() so applying -// never clobbers deployer-set values — set their actual values in the Railway UI -// (see README "Deploy to Railway"). -// -// Two topology invariants this file encodes: -// 1. Ports are pinned as explicit service variables (NOT left to Railway's -// auto-injected $PORT) so each service's listen port and the ${{svc.PORT}} -// its peers dial always agree. -// 2. Services reached over Railway private networking must bind :: (all -// interfaces) — private DNS (RAILWAY_PRIVATE_DOMAIN) resolves to IPv6, and -// legacy environments are IPv6-only. A service bound to 127.0.0.1/0.0.0.0 -// is unreachable by its peers. const REPO = "CopilotKit/OpenTag"; +const BRANCH = "main"; export default defineRailway(() => { - // Notion MCP sidecar — streamable-HTTP MCP server. Its launcher - // (scripts/start-notion-mcp.ts) binds NOTION_MCP_PORT (default 3001) and does - // NOT read Railway's injected $PORT, so we pin NOTION_MCP_PORT here and have - // the agent dial that same variable. We also set NOTION_MCP_HOST=:: so the - // sidecar binds all interfaces (its upstream default is 127.0.0.1, which is - // unreachable across containers on the private network). - // - // Notion is an OPTIONAL research source: the agent runs fine (chat + UI + web - // research) without it and drops the Notion tools if this sidecar is - // unreachable. But if you deploy this service it REQUIRES NOTION_TOKEN and - // NOTION_MCP_AUTH_TOKEN — its launcher exits non-zero without them. To skip - // Notion entirely, remove this service from `resources` below and delete the - // agent's NOTION_MCP_URL / NOTION_MCP_AUTH_TOKEN wiring. - const notionMcp = service("notion-mcp", { - source: github(REPO), - start: "pnpm notion-mcp", - deploy: { - restartPolicyType: "ON_FAILURE", - restartPolicyMaxRetries: 5, - }, - env: { - NOTION_MCP_PORT: "3001", - NOTION_MCP_HOST: "::", - // secrets (set in Railway UI): - NOTION_TOKEN: preserve(), - NOTION_MCP_AUTH_TOKEN: preserve(), - }, - }); - - // Python deep-research agent — deepagents over AG-UI (uvicorn, /health). - // This service owns its build + deploy config here (single source of truth): - // there is deliberately NO agent/railway.toml, because Railway forbids a - // service being managed by both IaC and config-as-code at once. const agent = service("agent", { - source: github(REPO, { rootDirectory: "agent" }), - build: { builder: "NIXPACKS" }, + source: github(REPO, { + branch: BRANCH, + rootDirectory: "agent", + }), + build: { builder: "RAILPACK" }, deploy: { - // Bind :: so the agent is reachable over Railway private networking (see - // invariant 2 above). The Railway startCommand runs `uvicorn main:app` - // directly, so agent/main.py's own __main__ port logic does NOT run here — - // the port comes solely from this --port arg. The ${PORT:-8123} fallback - // keeps the bind valid even if PORT were unset; PORT is pinned in env below - // so this and ${{agent.PORT}} (dialed by channel) always agree. - startCommand: "uvicorn main:app --host :: --port ${PORT:-8123}", + startCommand: 'uvicorn main:app --host "" --port ${PORT:-8123}', healthcheckPath: "/health", healthcheckTimeout: 300, restartPolicyType: "ON_FAILURE", restartPolicyMaxRetries: 5, }, env: { - // Pin PORT so uvicorn's bind and ${{agent.PORT}} (dialed by channel) agree. PORT: "8123", - // internal research source (optional Notion), wired to the sidecar on its - // pinned NOTION_MCP_PORT over private networking. The shared bearer is - // referenced from notion-mcp so it has a single source of truth — set - // NOTION_MCP_AUTH_TOKEN once, on the notion-mcp service. - NOTION_MCP_URL: - "http://${{notion-mcp.RAILWAY_PRIVATE_DOMAIN}}:${{notion-mcp.NOTION_MCP_PORT}}/mcp", - NOTION_MCP_AUTH_TOKEN: "${{notion-mcp.NOTION_MCP_AUTH_TOKEN}}", - // OPENAI_MODEL is intentionally NOT set here: the agent defaults to - // gpt-5.5 (agent/agent.py), and leaving it unmanaged means a deployer can - // override it in the Railway UI without a later `config apply` clobbering - // the change. Set OPENAI_MODEL in the agent service's Variables to change it. - // - // secrets (set in Railway UI): OPENAI_API_KEY required; others optional: OPENAI_API_KEY: preserve(), TAVILY_API_KEY: preserve(), LINEAR_API_KEY: preserve(), + NOTION_MCP_URL: preserve(), + NOTION_MCP_AUTH_TOKEN: preserve(), }, }); - // KiteBot channel host — runs the bot over the Intelligence Realtime Gateway. - const channel = service("channel", { - source: github(REPO), - start: "pnpm channel", + const runtime = service("runtime", { + source: github(REPO, { branch: BRANCH }), + start: "pnpm runtime", + // Rich rendering needs Chromium and its runtime libraries. + build: { + builder: "RAILPACK", + buildCommand: "pnpm exec playwright install chromium", + // No watch-path filter: deploy every push to the connected branch. + watchPatterns: [], + }, deploy: { - // Parity with agent/notion-mcp: restart the long-running channel host on - // crash instead of leaving KiteBot silently offline. + healthcheckPath: "/api/copilotkit/info", + healthcheckTimeout: 300, restartPolicyType: "ON_FAILURE", restartPolicyMaxRetries: 5, }, env: { - // brain: points at the agent service over private networking. The agent - // pins PORT=8123, so ${{agent.PORT}} resolves to the port uvicorn binds. - AGENT_URL: "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/", - // INTELLIGENCE_CHANNEL_NAME is intentionally NOT set here: app/managed.ts - // already defaults it to "kitebot", and leaving it unmanaged means a - // deployer can set their own channel name in the Railway UI without a - // later `config apply` clobbering it. Set it in the channel service's - // Variables if your Intelligence channel is named something else. - // secrets (set in Railway UI): - INTELLIGENCE_GATEWAY_WS_URL: preserve(), + PORT: "3000", + AGENT_URL: + "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/", INTELLIGENCE_API_KEY: preserve(), - INTELLIGENCE_ORG_ID: preserve(), - INTELLIGENCE_PROJECT_ID: preserve(), - INTELLIGENCE_CHANNEL_ID: preserve(), + INTELLIGENCE_API_URL: "https://api.intelligence.copilotkit.ai", + INTELLIGENCE_GATEWAY_WS_URL: + "wss://realtime.intelligence.copilotkit.ai", + INTELLIGENCE_CHANNEL_NAME: "open-tag", + PLAYWRIGHT_BROWSERS_PATH: "0", + RAILPACK_DEPLOY_APT_PACKAGES: + "fonts-liberation fonts-noto-color-emoji fonts-unifont libasound2 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 libcairo2 libcups2 libdbus-1-3 libdrm2 libexpat1 libfontconfig1 libfreetype6 libgbm1 libglib2.0-0 libnspr4 libnss3 libpango-1.0-0 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 libxrender1 libxshmfence1", }, }); - return project("kitebot", { resources: [notionMcp, agent, channel] }); + return project("opentag", { resources: [agent, runtime] }); }); diff --git a/README.md b/README.md index a817c8d..02613c1 100644 --- a/README.md +++ b/README.md @@ -1,242 +1,178 @@ -# OpenTag: an open-source alternative to Claude in Slack - -Run your own AI agent inside Slack: it reads a thread, answers, calls your tools, and -renders rich results right in the conversation. Think of it as having Claude in your -workspace, except **open-source and self-hosted**: you own the runtime, bring your own -model, and wire it to your own tools. No per-seat pricing, no lock-in. - -It's built on **[`@copilotkit/channels`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels)** — -CopilotKit's open SDK for chat-platform agents (Slack first; the same code also runs on -Discord, Telegram, and WhatsApp). Clone it, point it at your model and tools, and you own -the whole stack. - -## See it in action +# OpenTag + +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: + +- Slack through Intelligence, including the existing production `@kite` app. +- Microsoft Teams through Intelligence. + +Discord, Telegram, and WhatsApp are coming soon. + +## Architecture + +```text +Slack / Microsoft Teams + │ + ▼ +CopilotKit Intelligence + │ realtime + ▼ +runtime (Node + CopilotRuntime with embedded Channels) + │ AG-UI + ▼ +agent (Python + LangGraph deepagents) + ├── OpenAI + ├── Tavily (optional) + ├── Linear MCP (optional) + └── Notion MCP (optional remote server) +``` -https://github.com/user-attachments/assets/a74fa1cb-add0-463e-a23c-aa09b95d5135 +There is one canonical runtime host: [`server.ts`](./server.ts). +[`app/index.ts`](./app/index.ts) composes one `CopilotKitIntelligence`, one +`CopilotRuntime`, and one adapter-free managed Channel. Intelligence owns its +Slack and Microsoft Teams adapters, credentials, and attachments. -▶️ **[Watch the demo](https://github.com/user-attachments/assets/a74fa1cb-add0-463e-a23c-aa09b95d5135)** (~50s) — a KiteBot agent working a Slack thread: it renders a breakdown, a table, and a bar chart inline (**generative UI**) and files a ticket only after an **Approve** gate (**human-in-the-loop**). +The launch pins the requested canaries: -> **Two ways to run it:** **host it on your own** with the open-source SDK below — or skip the ops and **[sign up for the managed service →](https://go.copilotkit.ai/opentag-managed-gh)** coming soon from CopilotKit. The managed service will be part of our Enterprise Intelligence platform. You'll be able to use our cloud-hosting or enterprises can host it on their own infra. -> -> Note: the **Intelligence Gateway** mode below is part of "host it on your own" — you run that -> process yourself and bring your own CopilotKit Intelligence project. It's distinct from the -> fully-hosted **managed service** above, which is still on the waitlist. +- `@copilotkit/channels@0.2.2-canary.rc-1` +- `@copilotkit/runtime@1.63.3-canary.rc-1` ## Quick start -OpenTag's packages are published on npm — a standalone `pnpm install` in this repo pulls in -everything you need, no monorepo required. - -You'll run two processes: the **agent backend** (`pnpm runtime`) and **the bot**. For the bot, -pick one of two modes: - -- **Intelligence Gateway — recommended.** `pnpm channel` runs the bot over the CopilotKit - Intelligence Realtime Gateway. This process never holds a Slack token — Intelligence owns - the Slack edge — so there's less for you to run and secure. You still run this process - yourself and bring your own CopilotKit Intelligence project — it's not the fully-hosted - managed service described below. -- **Self-hosted.** `pnpm dev` (or `pnpm start`) runs the bot locally and talks to Slack (and - Discord/Telegram/WhatsApp) directly with your own platform tokens. +Prerequisites: Node.js 22+, pnpm, Python 3.12, and +[`uv`](https://docs.astral.sh/uv/). -Both modes talk to the same agent backend over AG-UI. +1. Install dependencies. -### The packages + ```bash + pnpm install --frozen-lockfile + cd agent && uv sync && cd .. + ``` -OpenTag is a thin layer on top of a handful of CopilotKit packages. The `pnpm install` in step 3 installs all of them for you — this is what each one does, so you know what you're running and which ones are optional. +2. Configure both services from the shared root environment. -**Required** — every OpenTag install needs these four: + ```bash + cp .env.example .env + ``` -| Package | Role | -| --- | --- | -| [`@copilotkit/channels`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels) | The platform-agnostic bot engine — threading, tool calls, the human-in-the-loop gate. | -| [`@copilotkit/runtime`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/runtime) | The AG-UI agent backend that runs your LLM and tools. | -| [`@copilotkit/channels-ui`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-ui) | Cross-platform JSX for rich messages (Block Kit on Slack, Components V2 on Discord, HTML on Telegram). | -| [`@copilotkit/channels-slack`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-slack) | The Slack adapter — or swap it for the platform you're targeting (below). | + Set: -**Optional** — add only what you use: + ```dotenv + OPENAI_API_KEY=sk-... + AGENT_URL=http://localhost:8123/ + INTELLIGENCE_API_KEY=cpk-... + ``` -| Package | When you need it | -| --- | --- | -| [`@copilotkit/channels-discord`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-discord) · [`-telegram`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-telegram) · [`-whatsapp`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-whatsapp) | Running on a platform other than Slack — one adapter per platform. | -| [`@copilotkit/channels-intelligence`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-intelligence) | Runs the bot over the CopilotKit Intelligence Realtime Gateway instead of holding platform tokens — see `app/managed.ts`. **Required for the recommended `pnpm channel` (Intelligence Gateway) mode**; omit it only if you run self-hosted mode exclusively. | + Tavily, Linear, and Notion are optional. Both Node and Python load this root + `.env`; Railway supplies the same values as service variables. -**1. Create a Slack app.** At [api.slack.com/apps](https://api.slack.com/apps?new_app=1) → -*From a manifest* → paste [`slack-app-manifest.yaml`](./slack-app-manifest.yaml). Install it, -then grab the **Bot User OAuth Token** (`xoxb-…`) and an **App-Level Token** (`xapp-…`, with the -`connections:write` scope) — needed for self-hosted mode, or to register the app with your -CopilotKit Intelligence project for Intelligence mode. Step-by-step in -[setup.md](./setup.md#1-create-a-slack-app). + `INTELLIGENCE_API_URL` and `INTELLIGENCE_GATEWAY_WS_URL` default to the + production Intelligence endpoints. `INTELLIGENCE_CHANNEL_NAME` defaults to + `open-tag`. -**2. Set your secrets** in `.env` (`cp .env.example .env`): +3. In Intelligence, create one managed Channel named `open-tag` and configure + its Slack and Microsoft Teams adapters. -```bash -OPENAI_API_KEY=sk-... # the TS runtime (runtime.ts) uses OpenAI's Responses API for web search; - # the Python agent/ uses Tavily instead (see "Deep research" below) - -# Optional integrations (see setup.md): -LINEAR_API_KEY=lin_api_... # optional — lets the agent file Linear tickets - -# Self-hosted mode: -SLACK_BOT_TOKEN=xoxb-... -SLACK_APP_TOKEN=xapp-... - -# Intelligence Gateway mode — full list in .env.example: -INTELLIGENCE_GATEWAY_WS_URL=wss://... -INTELLIGENCE_API_KEY=cpk-... -INTELLIGENCE_ORG_ID=org_... -INTELLIGENCE_PROJECT_ID=... -INTELLIGENCE_CHANNEL_ID=channel_... -``` - -**3. Run it:** - -```bash -pnpm install -pnpm runtime # the agent backend, on :8200 +4. Run both services. -pnpm channel # recommended — the bot over the Intelligence Gateway -# or -pnpm dev # alternative — the bot, self-hosted -``` + ```bash + pnpm agent + pnpm runtime + ``` -**4. Talk to it.** @mention the bot in any channel thread: +The runtime waits for its Intelligence connection to become ready before its +HTTP listener accepts traffic. -> @KiteBot summarize this thread and file it as a bug +## What OpenTag includes -That's the whole loop. To wire up Linear, Notion, inline charts, or to run -on Discord / Telegram / WhatsApp, see **[setup.md](./setup.md)**. +- Mentions and app-owned commands. +- Sender-aware context and Slack tools scoped to Slack turns. +- Rich issue, page, status, incident, link, table, chart, and diagram output. +- File-aware prompts. +- A LangGraph interrupt and resumable confirmation card before Linear or Notion + writes. +- Graceful, idempotent shutdown for Channels, HTTP, and the rendering browser. +- Nullable parent-message ID normalization through `SanitizingHttpAgent`. -We won't lie to you, though. Setting up hosting for chat agents is not easy. To skip all of that heartache, go [join the waitlist](https://go.copilotkit.ai/opentag-managed-gh) for the CopilotKit managed service as part of our Intelligence platform, both cloud-hosted or self-hosted. +The Python agent is the only supported backend. Its identity and behavior live +in [`agent/agent.py`](./agent/agent.py); the Channel UI and behavior live under +[`app/`](./app/). -## Make it your own +## Platform setup -OpenTag is deliberately small and hackable: +### Intelligence -- **Change what it does.** The agent's behavior is steered by a single system prompt in - [`runtime.ts`](./runtime.ts) — rewrite it and you have a different agent. -- **Copy `app/` to start your own bot.** It's the platform-agnostic bot (tools, components, the - human-in-the-loop gate). `runtime.ts` is the agent backend: one CopilotKit `BuiltInAgent` (an - LLM + optional MCP tools — no Python, no LangGraph), served over AG-UI. -- **One platform, or all of them.** `createBot` takes an array of adapters; set the secrets for - whichever platform(s) you want and the bot starts an adapter for each. +Use the normal Intelligence flow for both launch platforms: -The full architecture, the file-by-file map, and every integration live in -**[setup.md](./setup.md)**. +1. Create one OpenTag project. +2. Create one Channel named `open-tag`. +3. Issue a runtime API key. +4. Configure the Slack and Microsoft Teams adapters on that Channel. +5. Run one `pnpm runtime` process with that Channel name and key. -## Deep research (LangGraph deep agent) +No organization, project, Channel ID, or runtime-instance ID environment +variables—or Slack/Teams credentials—are required by the runtime. -`agent/` is an alternative agent backend to `runtime.ts` — a Python -[`deepagents`](https://github.com/langchain-ai/deepagents) (LangGraph) planner with a virtual -filesystem and OPTIONAL Tavily web research, served over AG-UI on `:8123`. Instead of a single -system-prompted LLM call, it plans with `write_todos`, reads/writes its own virtual files, and -(when configured) researches the web before synthesizing an answer — while still calling -KiteBot's forwarded generative-UI tools like the TS runtime does. +### Slack manifests -Only `OPENAI_API_KEY` is required. `TAVILY_API_KEY` is **optional** — without it, chat and UI -generation still work (the agent answers from its own knowledge); with it, live web research -turns on. +[`slack-app-manifest.yaml`](./slack-app-manifest.yaml) and +[`slack-app-manifest.json`](./slack-app-manifest.json) describe OpenTag for new +installations. -To run it: +For the production migration, **do not recreate or reinstall the existing +Slack app**. Reusing it preserves the bot user, workspace installation, and +`@kite` handle. Stop the old Socket Mode consumer before attaching its existing +`xapp` and `xoxb` tokens in Intelligence so only one consumer is active. -```bash -cd agent && uv sync # requires uv: https://docs.astral.sh/uv/ -pnpm agent # cd agent && uv run python main.py — serves over AG-UI on :8123 - # (port from PORT/SERVER_PORT env, default 8123) -``` +## Optional research sources -Then point the bot at it instead of `runtime.ts` by setting in `.env`: +- `TAVILY_API_KEY` enables live web research. Without it, OpenTag still chats, + triages requests and renders UI from model knowledge. +- `LINEAR_API_KEY` enables the hosted Linear MCP. +- Notion is optional and remote-only. Set both `NOTION_MCP_URL` and + `NOTION_MCP_AUTH_TOKEN`; setting only one disables the integration. -```bash -AGENT_URL=http://localhost:8123/ -``` +Every Linear and Notion mutation is intercepted in code before the MCP request +runs. The interceptor emits `confirm_write` and proceeds only after approval; +reads and rendering do not pause. -With `agent/` in the mix, the local setup is now three pieces: the deep-research agent -(`pnpm agent`, `:8123`), the bot (`pnpm channel` or `pnpm dev`), and whichever brain -`AGENT_URL` points at — the Python deep agent above, or the TS `runtime.ts` (`pnpm runtime`, -`:8200`) as before. `agent` and `runtime` are alternative brains for the same bot; run one or -the other depending on what `AGENT_URL` targets. +## Railway -## Deploy to Railway (one-click) +[`.railway/railway.ts`](./.railway/railway.ts) defines exactly two services, +all sourced from `CopilotKit/OpenTag` on `main`: -The whole KiteBot stack deploys to [Railway](https://railway.com) as **three services**, all -built from this one repo and wired together automatically over Railway's private networking: +| Service | Root | Start | Health | +| --- | --- | --- | --- | +| `agent` | `agent` | `uvicorn main:app --host "" --port ${PORT:-8123}` | `/health` | +| `runtime` | repository root | `pnpm runtime` | `/api/copilotkit/info` | -| Service | What it is | Build | -| --- | --- | --- | -| `agent` | the Python deep-research backend (`agent/`) | nixpacks, `uvicorn`, `/health` (root dir `agent/`) | -| `notion-mcp` | the Notion MCP sidecar (`pnpm notion-mcp`) | Node | -| `channel` | the KiteBot channel host (`pnpm channel`) | Node | +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, Linear, and remote Notion settings are optional. Connecting both +services to `main` enables GitHub-triggered deployments after merges. -`channel` reaches `agent` via `AGENT_URL`, and `agent` reaches `notion-mcp` via `NOTION_MCP_URL` -— both wired with Railway reference variables in [`.railway/railway.ts`](./.railway/railway.ts), -so you don't set them by hand. +The repository configuration does not mutate the existing production Railway +project. Inventory and cutover should happen after Railway authentication. -**Deploy via Infrastructure-as-Code (recommended):** +## Verification ```bash -pnpm install # installs the `railway` SDK the CLI uses to evaluate .railway/railway.ts -npm i -g @railway/cli # or: brew install railway -railway login -railway init # create a new Railway project (or `railway link` to select an existing one) -railway config apply # from the repo root: provisions agent + notion-mcp + channel from .railway/railway.ts +pnpm install --frozen-lockfile +pnpm check-types +pnpm test +(cd agent && uv run pytest) +node node_modules/railway/dist/iac/bin.js ``` -**Set the secrets** (Railway → each service → *Variables*). The IaC declares them with -`preserve()` and never stores values — you fill them in: - -- **`agent`** — `OPENAI_API_KEY` (required); `TAVILY_API_KEY` (optional — enables web research); - `LINEAR_API_KEY` (optional). (`NOTION_MCP_AUTH_TOKEN` is **not** set here — the agent references - it from `notion-mcp`, so you set it once, below.) -- **`notion-mcp`** — `NOTION_TOKEN` and `NOTION_MCP_AUTH_TOKEN` (any strong string; the agent - reads the same value via a reference variable). **Both are required** for this service to - start — its launcher exits if either is missing. Don't want Notion? Remove the `notion-mcp` - service from `resources` in [`.railway/railway.ts`](./.railway/railway.ts) and delete the - agent's `NOTION_MCP_URL` / `NOTION_MCP_AUTH_TOKEN` lines; the agent still runs (chat, UI, and — - with `TAVILY_API_KEY` — web research). -- **`channel`** — `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY`, `INTELLIGENCE_ORG_ID`, - `INTELLIGENCE_PROJECT_ID`, `INTELLIGENCE_CHANNEL_ID` (from your CopilotKit Intelligence - project + channel). - -The inter-service URLs/ports are wired for you in [`.railway/railway.ts`](./.railway/railway.ts). -Two values are left unmanaged by the IaC so a UI override survives `railway config apply`: the -agent's `OPENAI_MODEL` (defaults to `gpt-5.5`) and the channel's `INTELLIGENCE_CHANNEL_NAME` -(defaults to `kitebot`). Set either in that service's *Variables* to change it — e.g. set -`INTELLIGENCE_CHANNEL_NAME` if your Intelligence channel isn't named `kitebot`. - -Applying the config creates the services and their wiring; **KiteBot goes live only once the -secrets are set and the `channel` service connects** — that's when your Intelligence dashboard -flips *Waiting for runtime → live*. - -> **Cold-start note (Notion):** the `agent` loads its Notion tools once, at startup. On a first -> cold deploy the `agent` can finish booting before `notion-mcp` is accepting connections, in -> which case Notion research is unavailable for that deployment — the agent logs a `WARNING` on -> its stderr (chat, UI, and Tavily web research still work). If KiteBot can't reach Notion after -> the first deploy, **redeploy the `agent` service** once `notion-mcp` is up. (A lazy/retrying -> MCP load would remove this race — tracked as a follow-up.) - -**"Deploy on Railway" button (optional):** to get a literal one-click button, publish this repo -as a [Railway template](https://docs.railway.com/templates/create) from your Railway account, -then drop the generated button in: - -```md -[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/new/template/) -``` - -## Don't want to host it yourself? - -Self-hosting means you run and scale the runtime, persistence, and inspection tooling yourself. -A **managed CopilotKit service** is on its way. It's the same agent, without the ops: durable -threads, persistence, hosted inspection, and agents that improve from feedback (**Continuous -Learning from Human Feedback**). - -- **[Join the waitlist →](https://go.copilotkit.ai/opentag-managed-gh)** — be first in when the managed service opens. -- **[Talk to an engineer →](https://copilotkit.ai/talk-to-an-engineer)** — building something real on this? We'd love to help you ship it. - -## Learn more - -The **[CopilotKit Slack quickstart](https://docs.copilotkit.ai/slack)** is the canonical guide -to building a Slack agent — read it alongside this starter. Detailed setup and configuration -lives in **[setup.md](./setup.md)**. +The Slack live harness is documented in [`e2e/README.md`](./e2e/README.md). +Production acceptance is one end-to-end `@kite` mention that returns the +OpenTag persona through the Python agent. ## License diff --git a/agent/.env.example b/agent/.env.example deleted file mode 100644 index e835ee8..0000000 --- a/agent/.env.example +++ /dev/null @@ -1,26 +0,0 @@ -# KiteBot deep-research agent (agent/) — served over AG-UI on SERVER_PORT. -# The channel host / self-hosted bot points AGENT_URL at http://localhost:8123/ -# Only OPENAI_API_KEY is required; the agent chats + generates UI components without Tavily. -OPENAI_API_KEY=sk-... -# OPTIONAL — web research. Without it the agent still works (chat + UI components, -# answers from its own knowledge); with it, live web research turns on. https://tavily.com -# TAVILY_API_KEY=tvly-... -# OpenAI model (defaults to gpt-5.5, matching the rest of OpenTag). -# OPENAI_MODEL=gpt-5.5 -# Server bind (defaults 0.0.0.0:8123). -# SERVER_HOST=0.0.0.0 -# SERVER_PORT=8123 - -# ── Internal research sources (optional) ──────────────────────────────── -# Setting LINEAR_API_KEY and/or NOTION_MCP_AUTH_TOKEN lets the agent research -# the team's Linear issues / Notion docs alongside (or instead of) the web — -# see internal_source_tools() in tools.py. Leave both blank to research the -# web only. -# Hosted Linear MCP bearer token (linear.app → Settings → API → Personal API -# keys). LINEAR_MCP_URL defaults to the hosted Linear MCP if unset. -# LINEAR_API_KEY=lin_api_... -# LINEAR_MCP_URL=https://mcp.linear.app/mcp -# Bearer token for the local Notion MCP sidecar (`pnpm notion-mcp`). -# NOTION_MCP_URL defaults to that local sidecar if unset. -# NOTION_MCP_AUTH_TOKEN=choose-a-strong-shared-secret -# NOTION_MCP_URL=http://127.0.0.1:3001/mcp diff --git a/agent/agent.py b/agent/agent.py index 2656a7d..26abca1 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -1,119 +1,99 @@ -""" -KiteBot Deep Research Agent - -A Deep Agents-powered research assistant that demonstrates CopilotKit's -planning, filesystem, and subagent capabilities, with optional Tavily-backed -web research. -""" +"""OpenTag's triage-first Deep Agent.""" import os +from pathlib import Path + +from copilotkit import CopilotKitMiddleware +from deepagents import ( + GeneralPurposeSubagentProfile, + HarnessProfile, + create_deep_agent, + register_harness_profile, +) from dotenv import load_dotenv from langchain_openai import ChatOpenAI -from deepagents import create_deep_agent from langgraph.checkpoint.memory import MemorySaver -from copilotkit import CopilotKitMiddleware - -from tools import research, internal_source_tools - -load_dotenv() - - -# Base system prompt - always applies, regardless of whether web research is -# available. The agent chats, plans with write_todos, uses its virtual -# filesystem, and renders results as UI components (via KiteBot's forwarded -# generative-UI tools) even with no research tool loaded. -BASE_SYSTEM_PROMPT = """You are KiteBot's Deep Research Assistant, an expert at planning and -executing comprehensive research on any topic. - -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 -- Prefer rendering results as UI components (tables, cards, links, etc.) when the - frontend offers them, rather than large blocks of raw markdown -- For internal or company-specific questions, prefer the team's Notion/Linear - (internal sources) first; use the web for external questions - -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""" - -# Appended only when TAVILY_API_KEY is configured and the research() tool is loaded. -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...")""" - - -# Appended only when TAVILY_API_KEY is NOT configured, so the agent doesn't -# imply it can browse the live web. -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.""" +from internal_sources import internal_source_tools +from prompts import ( + BASE_SYSTEM_PROMPT, + NO_WEB_SEARCH_TOOL_ADDENDUM, + WEB_SEARCH_TOOL_ADDENDUM, +) +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 Deep Research Agent with CopilotKit integration. - - Creates a main research coordinator agent. Web research via the - research() tool is included only when TAVILY_API_KEY is configured; - otherwise the agent still chats, plans, uses its filesystem, and - generates UI components from its own knowledge. - - Returns: - The compiled research graph (from `create_deep_agent`), bound with - a `recursion_limit=100` run config via `.with_config(...)` - a - RunnableBinding wrapping the compiled StateGraph, not the bare - graph itself. - """ + """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") - # TAVILY_API_KEY is optional. Without it, the research() tool is simply - # not loaded - the agent still works (chat + UI-component generation), - # it just can't do live web lookups. - has_research = bool(os.environ.get("TAVILY_API_KEY")) - - # Initialize LLM - use model from env or default to gpt-5.5 + 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, + use_responses_api=True, ) - # Main agent gets the research tool plus built-in Deep Agents tools - # (write_todos, read_file, write_file) when Tavily is configured; the - # research tool wraps an internal Deep Agent that runs via .invoke() so - # its text doesn't stream to the frontend. internal_tools = internal_source_tools() - main_tools = [research, *internal_tools] if has_research else [*internal_tools] + main_tools = ( + [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 ) - # Create the Deep Agent with CopilotKit middleware. - # No subagents - research() tool (when present) handles web search internally. agent_graph = create_deep_agent( model=llm, system_prompt=system_prompt, @@ -122,10 +102,12 @@ def build_agent(): checkpointer=MemorySaver(), ) - print(f"[AGENT] KiteBot 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]}") - # Configure recursion limit for complex research tasks - 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 new file mode 100644 index 0000000..eac2f86 --- /dev/null +++ b/agent/internal_sources.py @@ -0,0 +1,137 @@ +"""Optional Linear and Notion MCP integrations.""" + +import asyncio +import logging +import os +from collections.abc import Mapping +from datetime import datetime, timezone +from typing import Any + +from langchain_mcp_adapters.client import MultiServerMCPClient + +from write_confirmation import WriteConfirmationInterceptor + + +MCP_SERVERS = { + "linear": { + "token_env": "LINEAR_API_KEY", + "url_env": "LINEAR_MCP_URL", + "default_url": "https://mcp.linear.app/mcp", + }, + "notion": { + "token_env": "NOTION_MCP_AUTH_TOKEN", + "url_env": "NOTION_MCP_URL", + "default_url": None, + }, +} +MCP_LOAD_TIMEOUT_SECONDS = 8.0 + +logger = logging.getLogger(__name__) + + +def _emit_internal_source_error( + error: BaseException, + *, + message: str, + source: str, + recovery: str, +) -> None: + """Emit an observable error without logging credentials or server details.""" + logger.error( + { + "error": message, + "context": { + "component": "internal_source_tools", + "exception_type": type(error).__name__, + "recovery": recovery, + "source": source, + }, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + ) + + +def _configured_connections( + env: Mapping[str, str], +) -> dict[str, dict[str, Any]]: + 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": url, + "headers": {"Authorization": f"Bearer {token}"}, + } + return connections + + +async def _load_tools(connections: dict[str, dict[str, Any]]) -> list: + loaded = [] + for name, connection in connections.items(): + try: + confirmation = WriteConfirmationInterceptor() + client = MultiServerMCPClient( + {name: connection}, + tool_interceptors=[confirmation], + ) + tools = await asyncio.wait_for( + client.get_tools(), + timeout=MCP_LOAD_TIMEOUT_SECONDS, + ) + confirmation.register_tools(tools) + loaded.extend(tools) + print(f"[TOOLS] loaded {len(tools)} tool(s) from {name}") + except asyncio.TimeoutError as error: + _emit_internal_source_error( + error, + message="Configured MCP server timed out while loading tools", + source=name, + recovery="skip_optional_integration", + ) + except Exception as error: + _emit_internal_source_error( + error, + message="Configured MCP server was unavailable while loading tools", + source=name, + recovery="skip_optional_integration", + ) + return loaded + + +def internal_source_tools() -> list: + """Load optional MCP tools for the configured internal sources.""" + connections = _configured_connections(os.environ) + if not connections: + return [] + + # MCP discovery is async; agent construction is synchronous. + try: + return asyncio.run(_load_tools(connections)) + except Exception as error: + _emit_internal_source_error( + error, + message="Failed to initialize MCP tool loading", + source="all", + recovery="skip_optional_integrations", + ) + return [] diff --git a/agent/main.py b/agent/main.py index 8b57c5a..fcbba0f 100644 --- a/agent/main.py +++ b/agent/main.py @@ -1,37 +1,29 @@ -""" -Deep Research Assistant - FastAPI Server - -Serves the Deep Research Agent via AG-UI protocol for CopilotKit integration. -The agent uses Deep Agents for planning and filesystem operations, with optional Tavily web research. -""" +"""FastAPI server for the OpenTag triage agent.""" +from collections.abc import Mapping import os import sys -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from dotenv import load_dotenv + from ag_ui_langgraph import add_langgraph_fastapi_endpoint from copilotkit import LangGraphAGUIAgent +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware from agent import build_agent -load_dotenv() - app = FastAPI( - title="KiteBot 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", ) -# Enable CORS for frontend communication. Defaults to "*" (any origin) for -# local/demo use; on Railway the agent is reached only over private networking -# by the channel service, so this is not a credential vector (allow_credentials -# is False). Set CORS_ALLOW_ORIGINS to a comma-separated allowlist to lock it -# down if the service is ever exposed publicly. -# Trailing `or ["*"]` so ANY blank CORS_ALLOW_ORIGINS — unset, empty, or a -# whitespace/comma-only value a deployer left while "resetting" — falls back to -# the permissive default rather than an empty allowlist that would block every -# origin. +AGENT_NAME = "opentag_research" +AGENT_DESCRIPTION = ( + "OpenTag on-call triage assistant for incidents, research, and " + "Linear/Notion workflows" +) + +# Allow all origins locally, or set CORS_ALLOW_ORIGINS to restrict access. _cors_origins = [ o.strip() for o in (os.getenv("CORS_ALLOW_ORIGINS") or "*").split(",") @@ -48,65 +40,53 @@ @app.get("/health") def health(): - """Health check endpoint for monitoring and Railway deployments""" - return {"status": "ok", "service": "kitebot-research-agent", "version": "0.1.0"} + """Return service health.""" + return {"status": "ok", "service": "opentag-agent", "version": "0.1.0"} -# Build and register the Deep Research Agent -try: - agent_graph = build_agent() +def local_server_port(env: Mapping[str, str] = os.environ) -> int: + """Resolve the local agent port without consuming the Channel's `PORT`.""" + raw_port = env.get("SERVER_PORT", "8123") + try: + port = int(raw_port) + if not (1 <= port <= 65535): + raise ValueError("out of range") + except ValueError as error: + raise ValueError( + f'Invalid SERVER_PORT: "{raw_port}" — ' + "must be an integer between 1 and 65535" + ) from error + return port - # Note: we intentionally do NOT pass an emit_tool_calls allowlist here. - # KiteBot's channel bridge forwards generative-UI + HITL tools (issue_card, - # render_chart, render_table, show_links, confirm_write, ...) that must be - # emitted to the frontend for cards to render and the write-gate to surface. - # An allowlist would filter those out. The recursion limit for complex - # research tasks (6+ research calls + file operations) is already applied - # to the graph itself via `.with_config({"recursion_limit": 100})` in - # agent.py, so no additional AG-UI config is needed here. - # Add AG-UI endpoint at root path for CopilotKit frontend +try: + agent_graph = build_agent() add_langgraph_fastapi_endpoint( app=app, agent=LangGraphAGUIAgent( - name="kitebot_research", - description="KiteBot deep research assistant — plans, searches, and synthesizes cited briefs", + name=AGENT_NAME, + description=AGENT_DESCRIPTION, graph=agent_graph, ), path="/", ) - print("[SERVER] Deep Research Agent registered at /") -except Exception as e: - print(f"[ERROR] Failed to build agent: {e}", file=sys.stderr) + print("[SERVER] OpenTag Agent registered at /") +except Exception as error: + print(f"[ERROR] Failed to build agent: {error}", file=sys.stderr) raise def main(): - """Run the server with uvicorn""" + """Run the local development server.""" import uvicorn - # Local-dev default 0.0.0.0 (IPv4 all-interfaces) — accepts 127.0.0.1/ - # localhost clients on every platform. This __main__ path runs only for - # local `pnpm agent`; on Railway the startCommand binds `--host ::` for the - # IPv6 private network, so this default does not affect the deploy. (Avoid - # defaulting to :: here: on macOS/BSD a `::` socket may not accept IPv4, so - # a local client dialing 127.0.0.1 could be refused.) Override with SERVER_HOST. + # Railway uses its own uvicorn command; these are local defaults. host = os.getenv("SERVER_HOST") or "0.0.0.0" - # Local-dev entrypoint only: on Railway the startCommand runs `uvicorn - # main:app` directly, so this block is bypassed and the port comes from the - # startCommand's `--port ${PORT:-8123}`. Prefer PORT then SERVER_PORT here so - # `pnpm agent` matches that Railway behavior. - raw_port = os.getenv("PORT") or os.getenv("SERVER_PORT") or "8123" try: - port = int(raw_port) - if not (1 <= port <= 65535): - raise ValueError("out of range") - except ValueError: - print( - f'[ERROR] Invalid PORT/SERVER_PORT: "{raw_port}" — must be an integer between 1 and 65535', - file=sys.stderr, - ) + port = local_server_port() + except ValueError as error: + print(f"[ERROR] {error}", file=sys.stderr) sys.exit(1) reload = os.getenv("AGENT_RELOAD", "").lower() in ("1", "true", "yes") diff --git a/agent/prompts/__init__.py b/agent/prompts/__init__.py new file mode 100644 index 0000000..ed93195 --- /dev/null +++ b/agent/prompts/__init__.py @@ -0,0 +1,16 @@ +"""OpenTag agent prompts.""" + +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_WEB_SEARCH_TOOL_ADDENDUM", + "WEB_SEARCH_TOOL_ADDENDUM", +] diff --git a/agent/prompts/system.py b/agent/prompts/system.py new file mode 100644 index 0000000..1616c24 --- /dev/null +++ b/agent/prompts/system.py @@ -0,0 +1,25 @@ +"""Core OpenTag persona and workflow.""" + +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 +- 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 = """ +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/tools.py b/agent/prompts/tools.py new file mode 100644 index 0000000..f348fca --- /dev/null +++ b/agent/prompts/tools.py @@ -0,0 +1,9 @@ +"""Guidance for internal sources and write tools.""" + +TOOLS_PROMPT = """- For internal or company-specific questions, prefer the team's Notion/Linear + (internal sources) first; use the web for external questions +- CRITICAL: Every Linear or Notion mutation tool automatically pauses with its + exact action and draft details. Call the mutation once; it runs only after + the user grants approval, and otherwise no write occurs +- Reads and rendering never require confirmation +""" 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 1830e95..4cf27e4 100644 --- a/agent/pyproject.toml +++ b/agent/pyproject.toml @@ -1,12 +1,12 @@ [project] -name = "kitebot-research-agent" +name = "opentag-agent" version = "0.1.0" -description = "KiteBot 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", @@ -20,7 +20,14 @@ dependencies = [ dev = ["pytest>=8.0.0", "httpx>=0.27.0"] [tool.setuptools] -py-modules = ["agent", "main", "tools"] +packages = ["prompts"] +py-modules = [ + "agent", + "internal_sources", + "main", + "tools", + "write_confirmation", +] [tool.pytest.ini_options] pythonpath = ["."] diff --git a/agent/tests/test_agent_configuration.py b/agent/tests/test_agent_configuration.py new file mode 100644 index 0000000..330b4ae --- /dev/null +++ b/agent/tests/test_agent_configuration.py @@ -0,0 +1,495 @@ +import asyncio +import json + +import agent as agent_mod +import httpx +import pytest +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage +from langchain_core.outputs import ChatGeneration, ChatResult +from langchain_openai import ChatOpenAI as RealChatOpenAI +from pydantic import Field + + +RENDER_TABLE_ARGUMENTS = { + "title": "Open issues", + "columns": [ + {"header": "Issue"}, + {"header": "Priority", "align": "center"}, + {"header": "Age (days)", "align": "right"}, + ], + "rows": [ + ["OSS-631", "High", "2"], + ["OSS-615", "Medium", "11"], + ], +} + + +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" + assert captured["model"]["use_responses_api"] is True + + +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) + + +def _configure_minimal_environment(monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.delenv("OPENAI_MODEL", raising=False) + monkeypatch.delenv("OPENAI_REASONING_EFFORT", raising=False) + monkeypatch.delenv("OPENAI_VERBOSITY", raising=False) + monkeypatch.delenv("TAVILY_API_KEY", raising=False) + monkeypatch.delenv("LINEAR_API_KEY", raising=False) + monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) + + +def _response_payload(output, response_id): + return { + "id": response_id, + "object": "response", + "created_at": 0, + "status": "completed", + "error": None, + "incomplete_details": None, + "instructions": None, + "max_output_tokens": None, + "model": "gpt-5.5", + "output": output, + "parallel_tool_calls": True, + "previous_response_id": None, + "reasoning": {"effort": "low", "summary": None}, + "store": True, + "temperature": None, + "text": {"format": {"type": "text"}, "verbosity": "low"}, + "tool_choice": "auto", + "tools": [], + "top_p": None, + "truncation": "disabled", + "usage": { + "input_tokens": 10, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens": 5, + "output_tokens_details": {"reasoning_tokens": 0}, + "total_tokens": 15, + }, + "metadata": {}, + } + + +def _render_table_action(): + return { + "type": "function", + "function": { + "name": "render_table", + "description": ( + "Render tabular data as a table posted to the conversation " + "thread." + ), + "parameters": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "columns": { + "type": "array", + "items": { + "type": "object", + "properties": { + "header": {"type": "string"}, + "align": { + "type": "string", + "enum": ["left", "center", "right"], + }, + }, + "required": ["header"], + }, + }, + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {"type": "string"}, + }, + }, + }, + "required": ["columns", "rows"], + }, + }, + } + + +def _sse_response(events): + content = "".join( + f"event: {event['type']}\ndata: {json.dumps(event)}\n\n" + for event in events + ) + content += "data: [DONE]\n\n" + return httpx.Response( + 200, + content=content, + headers={"content-type": "text/event-stream"}, + ) + + +def test_async_streaming_responses_round_trip_for_render_table(monkeypatch): + _configure_minimal_environment(monkeypatch) + requests = [] + + async def handle_openai_request(request): + assert request.url.path == "/v1/responses" + body = json.loads(request.content) + requests.append(body) + + if len(requests) == 1: + arguments = json.dumps( + RENDER_TABLE_ARGUMENTS, + separators=(",", ":"), + ) + completed_item = { + "id": "fc_render_table", + "type": "function_call", + "status": "completed", + "arguments": arguments, + "call_id": "call_render_table", + "name": "render_table", + } + created_response = _response_payload([], "resp_tool_call") + created_response["status"] = "in_progress" + created_response["usage"] = None + return _sse_response( + [ + { + "type": "response.created", + "sequence_number": 0, + "response": created_response, + }, + { + "type": "response.output_item.added", + "sequence_number": 1, + "output_index": 0, + "item": { + **completed_item, + "status": "in_progress", + "arguments": "", + }, + }, + { + "type": "response.function_call_arguments.delta", + "sequence_number": 2, + "output_index": 0, + "item_id": "fc_render_table", + "delta": arguments, + }, + { + "type": "response.completed", + "sequence_number": 3, + "response": _response_payload( + [completed_item], + "resp_tool_call", + ), + }, + ] + ) + + completed_item = { + "id": "msg_final", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Rendered the table.", + "annotations": [], + "logprobs": [], + } + ], + } + created_response = _response_payload([], "resp_final") + created_response["status"] = "in_progress" + created_response["usage"] = None + return _sse_response( + [ + { + "type": "response.created", + "sequence_number": 0, + "response": created_response, + }, + { + "type": "response.output_item.added", + "sequence_number": 1, + "output_index": 0, + "item": { + **completed_item, + "status": "in_progress", + "content": [], + }, + }, + { + "type": "response.output_text.delta", + "sequence_number": 2, + "output_index": 0, + "content_index": 0, + "item_id": "msg_final", + "delta": "Rendered the table.", + "logprobs": [], + }, + { + "type": "response.output_text.done", + "sequence_number": 3, + "output_index": 0, + "content_index": 0, + "item_id": "msg_final", + "text": "Rendered the table.", + "logprobs": [], + }, + { + "type": "response.completed", + "sequence_number": 4, + "response": _response_payload( + [completed_item], + "resp_final", + ), + }, + ] + ) + + async def run_round_trip(): + transport = httpx.MockTransport(handle_openai_request) + async with httpx.AsyncClient(transport=transport) as http_client: + + def chat_openai_with_mock_transport(**options): + return RealChatOpenAI( + **options, + http_async_client=http_client, + ) + + monkeypatch.setattr( + agent_mod, + "ChatOpenAI", + chat_openai_with_mock_transport, + ) + graph = agent_mod.build_agent() + action = _render_table_action() + config = { + "configurable": {"thread_id": "streaming-responses-render-table"} + } + + first_events = [ + event + async for event in graph.astream_events( + { + "messages": [ + HumanMessage(content="Show open issues as a table.") + ], + "copilotkit": { + "actions": [action], + "context": [], + }, + }, + config=config, + version="v2", + ) + ] + first_snapshot = await graph.aget_state(config) + + second_events = [ + event + async for event in graph.astream_events( + { + "messages": [ + ToolMessage( + content="Rendered the table for the user.", + tool_call_id="call_render_table", + name="render_table", + ) + ], + "copilotkit": { + "actions": [action], + "context": [], + }, + }, + config=config, + version="v2", + ) + ] + final_snapshot = await graph.aget_state(config) + + return ( + first_events, + first_snapshot.values, + second_events, + final_snapshot.values, + ) + + first_events, first_state, second_events, final_state = asyncio.run( + run_round_trip() + ) + + assert len(requests) == 2 + assert all(request["stream"] is True for request in requests) + assert requests[0]["reasoning"] == {"effort": "low"} + assert requests[0]["text"] == {"verbosity": "low"} + render_table_schema = next( + tool + for tool in requests[0]["tools"] + if tool["name"] == "render_table" + ) + column_schema = render_table_schema["parameters"]["properties"]["columns"][ + "items" + ] + assert column_schema["properties"]["header"] == {"type": "string"} + assert ( + column_schema["properties"]["align"]["enum"] + == ["left", "center", "right"] + ) + tool_call_message = first_state["messages"][-1] + assert isinstance(tool_call_message, AIMessage) + assert tool_call_message.tool_calls == [ + { + "name": "render_table", + "args": RENDER_TABLE_ARGUMENTS, + "id": "call_render_table", + "type": "tool_call", + } + ] + assert "on_chat_model_stream" in { + event["event"] for event in first_events + } + assert any( + item["type"] == "function_call" + and item["call_id"] == "call_render_table" + for item in requests[1]["input"] + ) + assert any( + item == { + "type": "function_call_output", + "call_id": "call_render_table", + "output": "Rendered the table for the user.", + } + for item in requests[1]["input"] + ) + assert "on_chat_model_stream" in { + event["event"] for event in second_events + } + final_message = final_state["messages"][-1] + assert isinstance(final_message, AIMessage) + assert final_message.content[0]["text"] == "Rendered the table." diff --git a/agent/tests/test_health.py b/agent/tests/test_health.py index a451da9..7a882dd 100644 --- a/agent/tests/test_health.py +++ b/agent/tests/test_health.py @@ -1,26 +1,10 @@ from fastapi.testclient import TestClient -# Imported at module top-level (not inside test functions) so that agent.py's -# module-level load_dotenv() runs exactly once, at collection time, before any -# test mutates os.environ. build_agent() reads os.environ at call time, so no -# reload/re-import is ever needed - re-importing later would just be a no-op -# (Python caches modules in sys.modules), but doing it here makes that -# explicit and guarantees these tests are order- and .env-independent: a -# developer's local agent/.env cannot repopulate a key a test just deleted. -# -# NOTE: `main` is intentionally NOT imported here. Unlike agent.py (whose -# module-level code only calls load_dotenv()), main.py's module-level code -# eagerly calls build_agent() and re-raises on failure. Importing it at -# collection time would require a real OPENAI_API_KEY to already be present -# in the environment (before any test's monkeypatch.setenv runs), which -# breaks collection on a clean checkout with no .env and no key exported. -# So `import main` stays function-scoped in test_health_ok, after the -# OPENAI_API_KEY monkeypatch.setenv below - same as before this fix. +# Import before tests mutate environment variables. import agent as agent_mod # noqa: E402 def test_health_ok(monkeypatch): - # Only OPENAI_API_KEY set — TAVILY intentionally absent (it's optional). monkeypatch.setenv("OPENAI_API_KEY", "sk-test") monkeypatch.delenv("TAVILY_API_KEY", raising=False) monkeypatch.delenv("LINEAR_API_KEY", raising=False) @@ -29,38 +13,60 @@ def test_health_ok(monkeypatch): client = TestClient(main.app) r = client.get("/health") assert r.status_code == 200 - assert r.json()["status"] == "ok" + assert r.json() == { + "status": "ok", + "service": "opentag-agent", + "version": "0.1.0", + } + + +def test_server_exposes_opentag_metadata(monkeypatch): + 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) + import main + + assert main.app.title == "OpenTag Agent" + assert main.AGENT_NAME == "opentag_research" + assert main.AGENT_DESCRIPTION.startswith("OpenTag on-call triage assistant") + + +def test_local_agent_port_ignores_the_shared_channel_port(): + import main + + assert main.local_server_port({"PORT": "3000", "SERVER_PORT": "8124"}) == 8124 + assert main.local_server_port({"PORT": "3000"}) == 8123 + + +def test_local_agent_port_rejects_invalid_server_port(): + import main + import pytest + + with pytest.raises(ValueError, match="SERVER_PORT"): + main.local_server_port({"SERVER_PORT": "70000"}) def test_build_agent_without_tavily(monkeypatch, capsys): - # The core requirement: the agent builds with OPENAI_API_KEY alone (no Tavily), - # and the research web tool is NOT loaded in that case. build_agent() reads - # os.environ at call time, so no reload of agent_mod is needed here (see the - # module-level import comment above for why). 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) - graph = agent_mod.build_agent() # must NOT raise + graph = agent_mod.build_agent() assert graph is not None - # Verify the research tool is actually gated off, not just that build - # succeeded - build_agent() logs its gating decision via print(). 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): - # Mirror of the above: when TAVILY_API_KEY IS configured, the research - # tool must be enabled. Together these two tests pin down both branches - # of the has_research gate rather than just asserting the graph exists. monkeypatch.setenv("OPENAI_API_KEY", "sk-test") monkeypatch.setenv("TAVILY_API_KEY", "tvly-test") monkeypatch.delenv("LINEAR_API_KEY", raising=False) monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) - graph = agent_mod.build_agent() # must NOT raise + 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): @@ -68,3 +74,43 @@ def test_build_agent_requires_openai(monkeypatch): import pytest with pytest.raises(RuntimeError, match="OPENAI_API_KEY"): agent_mod.build_agent() + + +def test_build_agent_does_not_expose_a_bypassable_manual_confirmation_tool( + monkeypatch, +): + captured = {} + + class FakeGraph: + def with_config(self, config): + captured["config"] = config + return self + + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.delenv("TAVILY_API_KEY", raising=False) + monkeypatch.setattr(agent_mod, "ChatOpenAI", lambda **_kwargs: object()) + monkeypatch.setattr(agent_mod, "internal_source_tools", lambda: []) + + def fake_create_deep_agent(**kwargs): + captured["tools"] = kwargs["tools"] + return FakeGraph() + + monkeypatch.setattr(agent_mod, "create_deep_agent", fake_create_deep_agent) + + agent_mod.build_agent() + + assert captured["tools"] == [] + + +def test_system_prompt_requires_confirmation_only_for_writes(): + prompt = agent_mod.BASE_SYSTEM_PROMPT + + assert "CRITICAL:" in prompt + assert "automatically pauses" in prompt + assert "Linear or Notion mutation" in prompt + assert "only after" in prompt and "approval" in prompt + assert "Reads and rendering never require confirmation" in prompt + + +def test_system_prompt_uses_opentag_persona(): + 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 new file mode 100644 index 0000000..e9a7094 --- /dev/null +++ b/agent/tests/test_internal_sources.py @@ -0,0 +1,217 @@ +import asyncio +import logging +from datetime import datetime + +import internal_sources +import pytest +from langchain_core.tools import StructuredTool +from write_confirmation import WriteConfirmationInterceptor + + +def test_mcp_servers_are_configured_in_one_place(): + assert internal_sources.MCP_SERVERS == { + "linear": { + "token_env": "LINEAR_API_KEY", + "url_env": "LINEAR_MCP_URL", + "default_url": "https://mcp.linear.app/mcp", + }, + "notion": { + "token_env": "NOTION_MCP_AUTH_TOKEN", + "url_env": "NOTION_MCP_URL", + "default_url": None, + }, + } + + +def test_internal_source_tools_empty_without_env(monkeypatch): + monkeypatch.delenv("LINEAR_API_KEY", raising=False) + monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) + 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 = [] + + class FakeMCPClient: + """Stub replacing MultiServerMCPClient - no real network calls.""" + + def __init__(self, connections, *, tool_interceptors): + self.connections = connections + self.tool_interceptors = tool_interceptors + clients.append(self) + + async def get_tools(self): + return [ + StructuredTool.from_function( + func=lambda: name, + name=f"tool-for-{name}", + description="test tool", + metadata={"readOnlyHint": True}, + ) + for name in self.connections + ] + + monkeypatch.setenv("LINEAR_API_KEY", "lin_api_test") + monkeypatch.setenv("LINEAR_MCP_URL", "https://linear.example.test/mcp") + monkeypatch.setenv("NOTION_MCP_AUTH_TOKEN", "notion-test-token") + monkeypatch.setenv("NOTION_MCP_URL", "https://notion.example.test/mcp") + monkeypatch.setattr( + internal_sources, + "MultiServerMCPClient", + FakeMCPClient, + ) + + result = internal_sources.internal_source_tools() + + assert len(result) > 0 + assert {tool.name for tool in result} == { + "tool-for-linear", + "tool-for-notion", + } + assert all( + isinstance(client.tool_interceptors[0], WriteConfirmationInterceptor) + for client in clients + ) + assert [client.connections for client in clients] == [ + { + "linear": { + "transport": "streamable_http", + "url": "https://linear.example.test/mcp", + "headers": {"Authorization": "Bearer lin_api_test"}, + } + }, + { + "notion": { + "transport": "streamable_http", + "url": "https://notion.example.test/mcp", + "headers": {"Authorization": "Bearer notion-test-token"}, + } + }, + ] + + +def test_one_unavailable_source_does_not_remove_the_other( + monkeypatch, + caplog, +): + class PartialMCPClient: + def __init__(self, connections, *, tool_interceptors): + self.name = next(iter(connections)) + + async def get_tools(self): + if self.name == "linear": + raise RuntimeError("unavailable") + return [ + StructuredTool.from_function( + func=lambda: "notion", + name="notion-search", + description="test tool", + metadata={"readOnlyHint": True}, + ) + ] + + 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", + PartialMCPClient, + ) + + with caplog.at_level(logging.ERROR, logger=internal_sources.__name__): + result = internal_sources.internal_source_tools() + + assert [tool.name for tool in result] == ["notion-search"] + assert caplog.records[0].msg["context"]["source"] == "linear" + + +@pytest.mark.parametrize( + ("failure", "expected_error"), + [ + ( + asyncio.TimeoutError(), + "Configured MCP server timed out while loading tools", + ), + ( + RuntimeError("connection details must not reach logs"), + "Configured MCP server was unavailable while loading tools", + ), + ], +) +def test_internal_source_tools_emits_structured_errors_for_configured_sources( + monkeypatch, + caplog, + failure, + expected_error, +): + class FailingMCPClient: + def __init__(self, _connections, *, tool_interceptors): pass + + async def get_tools(self): + raise failure + + monkeypatch.setenv("LINEAR_API_KEY", "lin_api_test") + monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) + monkeypatch.setattr( + internal_sources, + "MultiServerMCPClient", + FailingMCPClient, + ) + + with caplog.at_level(logging.ERROR, logger=internal_sources.__name__): + result = internal_sources.internal_source_tools() + + assert result == [] + assert len(caplog.records) == 1 + event = caplog.records[0].msg + assert event["error"] == expected_error + assert event["context"] == { + "component": "internal_source_tools", + "exception_type": type(failure).__name__, + "recovery": "skip_optional_integration", + "source": "linear", + } + assert datetime.fromisoformat(event["timestamp"]).tzinfo is not None + + +def test_internal_source_tools_emits_structured_event_loop_errors( + monkeypatch, + caplog, +): + def fail_run(coroutine): + coroutine.close() + raise RuntimeError("loop details must not reach logs") + + monkeypatch.setenv("LINEAR_API_KEY", "lin_api_test") + monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) + monkeypatch.setattr(internal_sources.asyncio, "run", fail_run) + + with caplog.at_level(logging.ERROR, logger=internal_sources.__name__): + result = internal_sources.internal_source_tools() + + assert result == [] + assert len(caplog.records) == 1 + event = caplog.records[0].msg + assert event["error"] == "Failed to initialize MCP tool loading" + assert event["context"] == { + "component": "internal_source_tools", + "exception_type": "RuntimeError", + "recovery": "skip_optional_integrations", + "source": "all", + } + assert datetime.fromisoformat(event["timestamp"]).tzinfo is not None diff --git a/agent/tests/test_metadata.py b/agent/tests/test_metadata.py new file mode 100644 index 0000000..88f5f8a --- /dev/null +++ b/agent/tests/test_metadata.py @@ -0,0 +1,17 @@ +import tomllib +from pathlib import Path + + +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-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_packaging.py b/agent/tests/test_packaging.py new file mode 100644 index 0000000..b2921ab --- /dev/null +++ b/agent/tests/test_packaging.py @@ -0,0 +1,16 @@ +import tomllib +from pathlib import Path + + +def test_wheel_includes_every_runtime_module(): + agent_root = Path(__file__).resolve().parent.parent + project = tomllib.loads((agent_root / "pyproject.toml").read_text()) + packaged_modules = set(project["tool"]["setuptools"]["py-modules"]) + runtime_modules = { + path.stem + for path in agent_root.glob("*.py") + if path.name != "__init__.py" + } + + assert packaged_modules == runtime_modules + assert project["tool"]["setuptools"]["packages"] == ["prompts"] diff --git a/agent/tests/test_prompts.py b/agent/tests/test_prompts.py new file mode 100644 index 0000000..935bd10 --- /dev/null +++ b/agent/tests/test_prompts.py @@ -0,0 +1,22 @@ +from prompts import ( + BASE_SYSTEM_PROMPT, + NO_WEB_SEARCH_TOOL_ADDENDUM, + WEB_SEARCH_TOOL_ADDENDUM, +) + + +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 "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 68021b1..13be38c 100644 --- a/agent/tests/test_tools.py +++ b/agent/tests/test_tools.py @@ -1,7 +1,8 @@ import tools +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): @@ -10,48 +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) - import pytest with pytest.raises(RuntimeError, match="TAVILY_API_KEY"): - tools._do_internet_search("q") + tools._search_tavily("q") -def test_do_internet_search_swallows_errors(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) - out = tools._do_internet_search("q") - assert out == [{"error": "boom"}] + with pytest.raises(RuntimeError, match="Tavily search failed") as error: + tools._search_tavily("q") + assert isinstance(error.value.__cause__, ValueError) -def test_internal_source_tools_empty_without_env(monkeypatch): - monkeypatch.delenv("LINEAR_API_KEY", raising=False) - monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) - assert tools.internal_source_tools() == [] +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 -def test_internal_source_tools_loads_when_env_set(monkeypatch): - class FakeMCPClient: - """Stub replacing MultiServerMCPClient - no real network calls.""" + monkeypatch.setattr(tools, "_search_tavily", fake_search) - def __init__(self, connections): - self.connections = connections + result = tools.web_search.invoke( + {"query": "current incident guidance", "max_results": 3} + ) - async def get_tools(self): - return [f"tool-for-{name}" for name in self.connections] - - monkeypatch.setenv("LINEAR_API_KEY", "lin_api_test") - monkeypatch.setenv("NOTION_MCP_AUTH_TOKEN", "notion-test-token") - monkeypatch.setattr(tools, "MultiServerMCPClient", FakeMCPClient) - - result = tools.internal_source_tools() - - assert len(result) > 0 - assert set(result) == {"tool-for-linear", "tool-for-notion"} + assert result == expected + assert calls == [("current incident guidance", 3)] diff --git a/agent/tests/test_write_confirmation.py b/agent/tests/test_write_confirmation.py new file mode 100644 index 0000000..0f84d70 --- /dev/null +++ b/agent/tests/test_write_confirmation.py @@ -0,0 +1,184 @@ +import asyncio + +import copilotkit.langgraph +import pytest +import write_confirmation +from langchain_core.tools import StructuredTool +from langchain_mcp_adapters.interceptors import MCPToolCallRequest + + +def test_write_confirmation_emits_the_copilotkit_interrupt_envelope(monkeypatch): + calls = [] + handler_calls = [] + + def fake_interrupt(value): + calls.append(value) + return {"confirmed": True} + + monkeypatch.setattr(copilotkit.langgraph, "interrupt", fake_interrupt) + + async def handler(request): + handler_calls.append(request) + return "write-result" + + request = MCPToolCallRequest( + name="create_issue", + args={"title": "Checkout 500s"}, + server_name="linear", + ) + result = asyncio.run( + write_confirmation.WriteConfirmationInterceptor()(request, handler) + ) + + assert result == "write-result" + assert handler_calls == [request] + assert len(calls) == 1 + assert set(calls[0]) == { + "__copilotkit_interrupt_value__", + "__copilotkit_messages__", + } + assert calls[0]["__copilotkit_interrupt_value__"] == { + "action": "confirm_write", + "args": { + "action": "Create issue", + "detail": '{"title": "Checkout 500s"}', + }, + } + + +def test_write_confirmation_interceptor_leaves_annotated_reads_unguarded( + monkeypatch, +): + interrupt_calls = [] + handler_calls = [] + + async def read_issue(issue_id: str): + return issue_id + + read_tool = StructuredTool.from_function( + coroutine=read_issue, + name="get_issue", + description="Read an issue", + metadata={"readOnlyHint": True}, + ) + interceptor = write_confirmation.WriteConfirmationInterceptor() + interceptor.register_tools([read_tool]) + monkeypatch.setattr( + write_confirmation, + "copilotkit_interrupt", + lambda **kwargs: interrupt_calls.append(kwargs), + ) + + async def handler(request): + handler_calls.append(request) + return "read-result" + + result = asyncio.run( + interceptor( + MCPToolCallRequest( + name="get_issue", + args={"issue_id": "CPK-9"}, + server_name="linear", + ), + handler, + ) + ) + + assert result == "read-result" + assert len(handler_calls) == 1 + assert interrupt_calls == [] + + +def test_write_confirmation_interceptor_blocks_a_declined_mutation(monkeypatch): + interrupt_calls = [] + handler_calls = [] + interceptor = write_confirmation.WriteConfirmationInterceptor() + + def decline(**kwargs): + interrupt_calls.append(kwargs) + return '{"confirmed": false}', {"confirmed": False} + + monkeypatch.setattr(write_confirmation, "copilotkit_interrupt", decline) + + async def handler(request): + handler_calls.append(request) + return "write-result" + + result = asyncio.run( + interceptor( + MCPToolCallRequest( + name="create_issue", + args={"title": "Checkout 500s"}, + server_name="linear", + ), + handler, + ) + ) + + assert handler_calls == [] + assert interrupt_calls == [ + { + "action": "confirm_write", + "args": { + "action": "Create issue", + "detail": '{"title": "Checkout 500s"}', + }, + } + ] + assert result.content[0].text == ( + "Write cancelled by the user; no changes were made." + ) + + +def test_write_confirmation_interceptor_runs_an_approved_mutation(monkeypatch): + handler_calls = [] + interceptor = write_confirmation.WriteConfirmationInterceptor() + monkeypatch.setattr( + write_confirmation, + "copilotkit_interrupt", + lambda **_kwargs: ('{"confirmed": true}', {"confirmed": True}), + ) + + async def handler(request): + handler_calls.append(request) + return "write-result" + + request = MCPToolCallRequest( + name="update_issue", + args={"id": "CPK-9", "title": "Checkout 500s"}, + server_name="linear", + ) + result = asyncio.run(interceptor(request, handler)) + + assert result == "write-result" + assert handler_calls == [request] + + +def test_write_confirmation_interceptor_rejects_a_malformed_resume( + monkeypatch, +): + handler_calls = [] + interceptor = write_confirmation.WriteConfirmationInterceptor() + monkeypatch.setattr( + write_confirmation, + "copilotkit_interrupt", + lambda **_kwargs: ("unexpected", {"unexpected": True}), + ) + + async def handler(request): + handler_calls.append(request) + return "write-result" + + with pytest.raises(RuntimeError, match="confirmed"): + asyncio.run( + interceptor( + MCPToolCallRequest( + name="create_issue", + args={"title": "Checkout 500s"}, + server_name="linear", + ), + handler, + ) + ) + + assert handler_calls == [] diff --git a/agent/tools.py b/agent/tools.py index a1fdbd7..1d8c70e 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -1,35 +1,15 @@ -""" -Tavily-based Tools for Deep Research Agent - -Provides web search with content using the Tavily API. -The search returns full page content, eliminating the need for separate scraping. - -The research() tool wraps an internal Deep Agent that runs in a separate thread -to prevent subagent text from leaking to the frontend via LangChain callback propagation. -""" +"""Tavily-backed web search tools.""" import os -import sys -import asyncio from typing import Any -from concurrent.futures import ThreadPoolExecutor + from langchain_core.tools import tool -from langchain_core.messages import HumanMessage from tavily import TavilyClient -from langchain_mcp_adapters.client import MultiServerMCPClient - - -def _do_internet_search(query: str, max_results: int = 5) -> list[dict[str, Any]]: - """Core search logic - callable as regular function. - Args: - query: The search query string - max_results: Maximum number of results to return (default: 5) - Returns: - List of dicts with url, title, and content for each result - """ - print(f"[TOOL] internet_search: query='{query}', max_results={max_results}") +def _search_tavily(query: str, max_results: int = 5) -> list[dict[str, Any]]: + """Search Tavily and normalize its results.""" + print(f"[TOOL] web_search: query='{query}', max_results={max_results}") tavily_key = os.environ.get("TAVILY_API_KEY") if not tavily_key: @@ -40,222 +20,28 @@ def _do_internet_search(query: str, max_results: int = 5) -> list[dict[str, Any] results = client.search( query=query, max_results=max_results, - include_raw_content=False, # Disable raw content for performance + include_raw_content=False, topic="general", ) - # Format results for agent consumption formatted_results = [] for r in results.get("results", []): formatted_results.append( { "url": r.get("url", ""), "title": r.get("title", ""), - "content": (r.get("content") or "")[ - :3000 - ], # Truncate to 3000 chars + "content": (r.get("content") or "")[:3000], } ) - 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 e: - print(f"[TOOL] internet_search error: {e}") - return [{"error": str(e)}] + except Exception as error: + raise RuntimeError("Tavily search failed") from error @tool -def research(query: str) -> dict: - """ - Research a topic using web search. Returns structured data with sources. - - This tool creates an internal Deep Agent that runs in a SEPARATE THREAD to prevent - LangChain callback propagation. The thread has isolated execution context, so the - internal agent's events don't leak to the parent's astream_events() stream. - - Args: - query: The research query/topic to investigate - - Returns: - dict: { - "summary": str - Prose summary of findings, - "sources": list[dict] - [{url, title, content, status}, ...] - } - """ - print(f"[TOOL] research: query='{query}' (using thread isolation)") - - from deepagents import create_deep_agent - from langchain_openai import ChatOpenAI - - def _run_research_isolated(): - """ - Runs in separate thread with no inherited LangChain context. - This breaks callback propagation at the OS level. - """ - # Capture internet_search results - search_results = [] - - # Wrapper to capture results while passing through to agent. - # Named `internet_search` (not `internet_search_tracked`) because - # LangChain derives the tool name the model sees from `__name__`, - # and `researcher_prompt` below refers to the tool as `internet_search`. - def internet_search(query: str, max_results: int = 5): - """Search the web and return results with content. - - Args: - query: The search query string - max_results: Maximum number of results to return (default: 5) - - Returns: - List of dicts with url, title, and content for each result - """ - 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"), - ) - - # System prompt for the internal researcher - researcher_prompt = """You are a Research Specialist. - -Use internet_search to find information. Return a prose summary of findings. - -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""" - - research_agent = create_deep_agent( - model=llm, - system_prompt=researcher_prompt, - tools=[internet_search], # Use tracked version - # No middleware - this runs in isolated thread - ) - - # Run in isolated thread context - no callback inheritance possible - result = research_agent.invoke({"messages": [HumanMessage(content=query)]}) - - summary = result["messages"][-1].content - - # Format sources for frontend - sources = [ - { - "url": r["url"], - "title": r.get("title", ""), - "content": r.get("content", "")[:3000], # Include content preview - "status": "found", - } - for r in search_results - if "url" in r and not r.get("error") - ] - - return {"summary": summary, "sources": sources} - - # Run in thread pool to isolate from parent async context - # This blocks the tool execution until research completes, which is acceptable - with ThreadPoolExecutor(max_workers=1) as executor: - future = executor.submit(_run_research_isolated) - result = future.result() # Blocks until complete - - print(f"[TOOL] research: completed with {len(result['sources'])} sources") - return result - - -def internal_source_tools() -> list: - """Notion + Linear MCP tools, included only when their env is present. - - Reuses OpenTag's Phase-1 MCP servers so the agent can research the team's - own docs/issues alongside the web. Mirrors the transports `runtime.ts`'s - `mcpTransports()` describes: Linear's hosted MCP server (bearer - `LINEAR_API_KEY`, URL from `LINEAR_MCP_URL` or the `mcp.linear.app` - default) and the Notion MCP sidecar (bearer `NOTION_MCP_AUTH_TOKEN`, URL - from `NOTION_MCP_URL` or the localhost default). Each server is entirely - optional and gated independently - set neither, one, or both. - - `langchain_mcp_adapters`'s `MultiServerMCPClient.get_tools()` is - async-only; this function runs it to completion on a short-lived event - loop (`asyncio.run`) so it can be called synchronously from - `build_agent()`. Each server is connected individually so one server - failing to load never drops the other's tools, and a down or - misconfigured MCP server is logged and skipped rather than raised - - this must never break agent startup. - - Returns: - list: LangChain tools from the configured MCP server(s), or an - empty list when neither `LINEAR_API_KEY` nor - `NOTION_MCP_AUTH_TOKEN` is set (or all configured servers fail). - """ - connections: dict[str, dict[str, Any]] = {} - - linear_api_key = os.environ.get("LINEAR_API_KEY") - if linear_api_key: - connections["linear"] = { - "transport": "streamable_http", - "url": os.environ.get("LINEAR_MCP_URL", "https://mcp.linear.app/mcp"), - "headers": {"Authorization": f"Bearer {linear_api_key}"}, - } - - notion_auth_token = os.environ.get("NOTION_MCP_AUTH_TOKEN") - if notion_auth_token: - connections["notion"] = { - "transport": "streamable_http", - "url": os.environ.get("NOTION_MCP_URL", "http://127.0.0.1:3001/mcp"), - "headers": {"Authorization": f"Bearer {notion_auth_token}"}, - } - - if not connections: - return [] - - async def _load_all() -> list: - loaded: list = [] - for name, connection in connections.items(): - try: - server_client = MultiServerMCPClient({name: connection}) - # Bound connect time so a hung MCP server (accepts the - # socket but never responds) can't stall build_agent() - - # and thus the whole app, including /health - at import - # time. Mirrors runtime.ts's MCP_CONNECT_TIMEOUT_MS=8000. - server_tools = await asyncio.wait_for( - server_client.get_tools(), timeout=8.0 - ) - loaded.extend(server_tools) - print( - f"[TOOLS] internal_source_tools: loaded {len(server_tools)} " - f"tool(s) from {name}" - ) - except asyncio.TimeoutError: - # This source WAS configured (its token/URL is set) but is - # unreachable — a real, silent degradation (the agent runs - # without its tools). Warn loudly on stderr so it's not confused - # with a source the operator intentionally left disabled. On - # Railway a cold-start race can cause this; redeploy once the - # sidecar is up (see README "Cold-start note"). - print( - f"[TOOLS] WARNING: {name} is configured but its MCP server " - f"timed out after 8s — running WITHOUT {name} tools", - file=sys.stderr, - ) - except Exception as e: - print( - f"[TOOLS] WARNING: {name} is configured but its MCP server " - f"is unavailable — running WITHOUT {name} tools ({e})", - file=sys.stderr, - ) - return loaded - - try: - return asyncio.run(_load_all()) - except Exception as e: - # Belt-and-suspenders: even a failure in the event-loop plumbing - # itself (not just an individual server) must not break startup. - print( - f"[TOOLS] WARNING: failed to load internal-source MCP tools ({e})", - file=sys.stderr, - ) - return [] +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 6310844..0302438 100644 --- a/agent/uv.lock +++ b/agent/uv.lock @@ -677,49 +677,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] -[[package]] -name = "kitebot-research-agent" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "ag-ui-langgraph" }, - { name = "copilotkit" }, - { name = "deepagents" }, - { name = "fastapi" }, - { name = "langchain" }, - { name = "langchain-mcp-adapters" }, - { name = "langchain-openai" }, - { name = "python-dotenv" }, - { name = "tavily-python" }, - { name = "uvicorn", extra = ["standard"] }, -] - -[package.dev-dependencies] -dev = [ - { name = "httpx" }, - { name = "pytest" }, -] - -[package.metadata] -requires-dist = [ - { name = "ag-ui-langgraph", specifier = ">=0.0.23" }, - { name = "copilotkit", specifier = ">=0.1.76" }, - { name = "deepagents", specifier = ">=0.3.5" }, - { name = "fastapi", specifier = ">=0.115.14" }, - { name = "langchain", specifier = ">=1.2.4" }, - { name = "langchain-mcp-adapters", specifier = ">=0.3.0" }, - { name = "langchain-openai", specifier = ">=1.1.7" }, - { name = "python-dotenv", specifier = ">=1.2.1" }, - { name = "tavily-python", specifier = ">=0.3.0" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.40.0" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "httpx", specifier = ">=0.27.0" }, - { name = "pytest", specifier = ">=8.0.0" }, -] - [[package]] name = "langchain" version = "1.3.13" @@ -951,6 +908,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/b0/2291689e3ec4723fbf5bbf3b54afcd7b160f9ddc98ca7aedfd0132af5677/openai-2.45.0-py3-none-any.whl", hash = "sha256:5df105f5f8c9b711fcb9d06d2d3888cebc82506db216484c14a4e53cdf651777", size = 1629470, upload-time = "2026-07-09T18:02:42.21Z" }, ] +[[package]] +name = "opentag-agent" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "ag-ui-langgraph" }, + { name = "copilotkit" }, + { name = "deepagents" }, + { name = "fastapi" }, + { name = "langchain" }, + { name = "langchain-mcp-adapters" }, + { name = "langchain-openai" }, + { name = "python-dotenv" }, + { name = "tavily-python" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "ag-ui-langgraph", specifier = ">=0.0.23" }, + { name = "copilotkit", specifier = ">=0.1.76" }, + { 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" }, + { name = "langchain-openai", specifier = ">=1.1.7" }, + { name = "python-dotenv", specifier = ">=1.2.1" }, + { name = "tavily-python", specifier = ">=0.3.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.40.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pytest", specifier = ">=8.0.0" }, +] + [[package]] name = "orjson" version = "3.11.9" diff --git a/agent/write_confirmation.py b/agent/write_confirmation.py new file mode 100644 index 0000000..0dbba5e --- /dev/null +++ b/agent/write_confirmation.py @@ -0,0 +1,77 @@ +"""Approval enforcement for mutating MCP tools.""" + +import json + +from copilotkit.langgraph import copilotkit_interrupt +from langchain_core.tools import BaseTool +from langchain_mcp_adapters.interceptors import ( + MCPToolCallRequest, + MCPToolCallResult, +) +from mcp.types import CallToolResult, TextContent + + +class WriteConfirmationInterceptor: + """Require approval for every MCP tool not marked read-only.""" + + # These Notion search endpoints use POST but do not mutate data. + _KNOWN_READ_ONLY_TOOLS = { + "API-post-search", + "API-query-data-source", + } + + def __init__(self): + self._read_only_tools = set(self._KNOWN_READ_ONLY_TOOLS) + + def register_tools(self, tools: list[BaseTool]) -> None: + for source_tool in tools: + metadata = source_tool.metadata or {} + if metadata.get("readOnlyHint") is True: + self._read_only_tools.add(source_tool.name) + + async def __call__( + self, + request: MCPToolCallRequest, + handler, + ) -> MCPToolCallResult: + if request.name in self._read_only_tools: + return await handler(request) + + action = request.name.replace("_", " ").replace("-", " ").strip() + action = action[:1].upper() + action[1:] + detail = json.dumps( + request.args, + ensure_ascii=False, + sort_keys=True, + default=str, + ) + _answer, response = copilotkit_interrupt( + action="confirm_write", + args={"action": action, "detail": detail}, + ) + + if isinstance(response, str): + try: + response = json.loads(response) + except json.JSONDecodeError: + response = None + + if ( + not isinstance(response, dict) + or not isinstance(response.get("confirmed"), bool) + ): + raise RuntimeError( + "confirm_write resume must contain a boolean `confirmed` value" + ) + + if response["confirmed"] is False: + return CallToolResult( + content=[ + TextContent( + type="text", + text="Write cancelled by the user; no changes were made.", + ) + ] + ) + + return await handler(request) diff --git a/app/channel-helpers.test.ts b/app/channel-helpers.test.ts new file mode 100644 index 0000000..8474ab2 --- /dev/null +++ b/app/channel-helpers.test.ts @@ -0,0 +1,69 @@ +import type { AgentContentPart, IncomingMessage } from "@copilotkit/channels"; +import { describe, expect, it } from "vitest"; +import { + defaultSlackContext, + defaultSlackTools, +} from "@copilotkit/channels/slack"; +import { + managedRunInput, + promptFromMessage, +} from "./channel-helpers.js"; + +const message = ( + overrides: Partial = {}, +): IncomingMessage => ({ + text: "hello", + user: { id: "U1", name: "Ada" }, + ref: { id: "m1" }, + platform: "slack", + ...overrides, +}); + +describe("promptFromMessage", () => { + it("prefers non-empty content parts", () => { + const parts: AgentContentPart[] = [{ type: "text", text: "hello" }]; + expect(promptFromMessage(message({ contentParts: parts }))).toBe(parts); + }); + + it("falls back to text when content parts are absent or empty", () => { + expect(promptFromMessage(message())).toBe("hello"); + expect(promptFromMessage(message({ contentParts: [] }))).toBe("hello"); + }); +}); + +describe("managedRunInput", () => { + it("adds the in-flight prompt and Slack-specific defaults", () => { + const parts: AgentContentPart[] = [{ type: "text", text: "from parts" }]; + + expect(managedRunInput(message({ contentParts: parts }))).toEqual({ + prompt: parts, + tools: defaultSlackTools, + context: [ + ...defaultSlackContext, + { + description: "Requesting slack user", + value: "Ada (slack id U1)", + }, + ], + }); + }); + + it("keeps managed Teams free of Slack-specific defaults", () => { + expect( + managedRunInput( + message({ + platform: "teams", + user: { id: "T1", name: "Ada" }, + }), + ), + ).toEqual({ + prompt: "hello", + context: [ + { + description: "Requesting teams user", + value: "Ada (teams id T1)", + }, + ], + }); + }); +}); diff --git a/app/channel-helpers.ts b/app/channel-helpers.ts new file mode 100644 index 0000000..d946859 --- /dev/null +++ b/app/channel-helpers.ts @@ -0,0 +1,62 @@ +import type { + AgentContentPart, + ChannelTool, + ContextEntry, + IncomingMessage, + PlatformUser, +} from "@copilotkit/channels"; +import { + defaultSlackContext, + defaultSlackTools, +} from "@copilotkit/channels/slack"; +import { senderContext } from "./sender-context.js"; + +/** + * Managed history does not include the in-flight turn, so pass it explicitly. + * Multimodal content parts take precedence over the text fallback. + */ +export function promptFromMessage( + message: Pick, +): string | AgentContentPart[] { + return message.contentParts?.length ? message.contentParts : message.text; +} + +/** + * Add only the source platform's defaults. Intelligence is the transport; + * `message.platform` is the originating provider (`slack` or `teams`). + */ +export function managedRunInput(message: IncomingMessage) { + return { + prompt: promptFromMessage(message), + ...platformRunInput(message.platform, message.user), + }; +} + +export function platformRunInput( + platform: string, + user: PlatformUser | undefined, +): { + tools?: ChannelTool[]; + context: ContextEntry[]; +} { + const slack = platform === "slack"; + + return { + ...(slack ? { tools: [...defaultSlackTools] } : {}), + context: [ + ...(slack ? defaultSlackContext : []), + ...senderContext(user, platform), + ], + }; +} + +export function reportRecoverableError( + error: unknown, + context: { operation: string; recovery: string }, +): void { + console.error("[channel] recoverable error", { + error: error instanceof Error ? error : new Error(String(error)), + context, + timestamp: new Date().toISOString(), + }); +} diff --git a/app/channel.test.ts b/app/channel.test.ts new file mode 100644 index 0000000..fc2d492 --- /dev/null +++ b/app/channel.test.ts @@ -0,0 +1,492 @@ +import type { + AgentSubscriber, + RunAgentParameters, + RunAgentResult, +} from "@ag-ui/client"; +import { EventType } from "@ag-ui/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + FakeAdapter, + FakeAgent, + MemoryStore, + type Channel, + type ChannelNode, +} from "@copilotkit/channels"; +import { + defaultSlackContext, + defaultSlackTools, + renderSlackMessage, +} from "@copilotkit/channels/slack"; +import { appContext } from "./context/app-context.js"; +import { appTools } from "./tools/index.js"; +import { createOpenTagChannel } from "./channel.js"; + +class CapturingAgent extends FakeAgent { + readonly calls: Array = []; + + override async runAgent( + parameters?: RunAgentParameters, + subscriber?: AgentSubscriber, + ): Promise { + this.calls.push(parameters); + return super.runAgent(parameters, subscriber); + } +} + +const channels: Channel[] = []; + +function confirmWriteEnvelope( + action = "Create Linear issue", + detail: string | null = "CPK-9: Checkout 500s", +) { + return { + __copilotkit_interrupt_value__: { + action: "confirm_write", + args: { action, detail }, + }, + __copilotkit_messages__: [ + { + content: "", + type: "ai", + tool_calls: [ + { + id: "tool-confirm-write", + name: "confirm_write", + args: { action, detail }, + type: "tool_call", + }, + ], + }, + ], + }; +} + +function findButton( + nodes: ChannelNode[], + confirmed: boolean, +): ChannelNode | undefined { + for (const node of nodes) { + if ( + node.type === "button" && + (node.props.value as { confirmed?: boolean } | undefined)?.confirmed === + confirmed + ) { + return node; + } + const children = node.props.children; + if (Array.isArray(children)) { + const found = findButton(children as ChannelNode[], confirmed); + if (found) return found; + } + } + 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(); +}); + +function makeChannel(options: { agent?: FakeAgent } = {}) { + const adapter = new FakeAdapter({ platform: "intelligence" }); + const agent = options.agent ?? new CapturingAgent(); + const channel = createOpenTagChannel("opentag", agent); + channel.ɵruntime.addAdapter(adapter); + channels.push(channel); + return { adapter, agent, channel }; +} + +describe("createOpenTagChannel", () => { + it("declares one managed Channel and retains app commands", () => { + const channel = createOpenTagChannel("custom-channel", new FakeAgent()); + channels.push(channel); + + expect(channel.name).toBe("custom-channel"); + expect(channel.provider).toBeUndefined(); + expect(channel.adapters).toEqual([]); + expect(channel.commandNames.sort()).toEqual([ + "agent", + "file_issue", + "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"); + }); + + it("injects Slack defaults per managed Slack run", async () => { + const { adapter, agent, channel } = makeChannel(); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "hello", + platform: "slack", + user: { id: "U1", name: "Ada" }, + }); + + const call = (agent as CapturingAgent).calls[0]; + expect(call?.tools?.map(({ name }) => name).sort()).toEqual( + [...appTools, ...defaultSlackTools].map(({ name }) => name).sort(), + ); + expect(call?.context).toEqual([ + ...appContext, + ...defaultSlackContext, + { + description: "Requesting slack user", + value: "Ada (slack id U1)", + }, + ]); + }); + + it("does not inject Slack defaults into managed Teams", async () => { + const { adapter, agent, channel } = makeChannel(); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "hello", + platform: "teams", + user: { id: "T1", name: "Ada" }, + }); + + const call = (agent as CapturingAgent).calls[0]; + expect(call?.tools?.map(({ name }) => name).sort()).toEqual( + appTools.map(({ name }) => name).sort(), + ); + expect(call?.context).toEqual([ + ...appContext, + { + description: "Requesting teams user", + value: "Ada (teams id T1)", + }, + ]); + }); + + it("injects managed content parts as the current agent prompt", async () => { + const { adapter, agent, channel } = makeChannel(); + const parts = [{ type: "text" as const, text: "from content parts" }]; + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "fallback text", + contentParts: parts, + platform: "slack", + user: { id: "U1" }, + }); + + expect(agent.messages).toContainEqual( + expect.objectContaining({ role: "user", content: parts }), + ); + }); + + it("personalizes suggested prompts when a thread starts", async () => { + const { adapter, channel } = makeChannel(); + + await channel.ɵruntime.start(); + await adapter.emitThreadStarted({ + conversationKey: "c1", + replyTarget: {}, + user: { id: "U1", name: "Ada" }, + }); + + expect(adapter.suggestedPromptsCalls[0]?.prompts[0]?.title).toBe( + "Triage Ada's issues", + ); + }); + + it("surfaces a structured recoverable error when suggested prompts fail", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const { adapter, channel } = makeChannel(); + adapter.setSuggestedPrompts = vi.fn(async () => { + throw new Error("suggested prompts unavailable"); + }); + + await channel.ɵruntime.start(); + await adapter.emitThreadStarted({ + conversationKey: "c1", + replyTarget: {}, + user: { id: "U1", name: "Ada" }, + }); + + expect(consoleError).toHaveBeenCalledWith( + "[channel] recoverable error", + expect.objectContaining({ + error: expect.any(Error), + context: { + operation: "set_suggested_prompts", + recovery: "continue_without_suggested_prompts", + }, + timestamp: expect.any(String), + }), + ); + }); + + it("posts a user-facing error when the agent run fails", async () => { + const error = new Error("agent unavailable"); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const { adapter, channel } = makeChannel({ + agent: new FakeAgent([ + () => { + throw error; + }, + ]), + }); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "hello", + platform: "slack", + user: { id: "U1" }, + }); + + expect(JSON.stringify(adapter.posted)).toMatch(/sorry.*error/i); + expect(consoleError).toHaveBeenCalledWith( + "[channel] recoverable error", + expect.objectContaining({ + error, + context: { + operation: "run_agent", + recovery: "posted_user_facing_error", + }, + timestamp: expect.any(String), + }), + ); + }); + + it("posts a real JSON-stringified confirm_write interrupt card and returns immediately", async () => { + const agent = new FakeAgent([ + (subscriber) => { + subscriber.onCustomEvent?.({ + event: { + type: EventType.CUSTOM, + name: "on_interrupt", + value: JSON.stringify(confirmWriteEnvelope()), + }, + } as never); + }, + ]); + const { adapter, channel } = makeChannel({ + agent, + }); + + await channel.ɵruntime.start(); + const turn = adapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "file this", + platform: "slack", + user: { id: "U1" }, + }); + const result = await Promise.race([ + Promise.resolve(turn).then(() => "returned"), + new Promise((resolve) => + setTimeout(() => resolve("blocked"), 100), + ), + ]); + + expect(result).toBe("returned"); + expect(adapter.posted).toHaveLength(1); + const { blocks, accent } = renderSlackMessage(adapter.posted[0]!); + expect(accent).toBe("#E2B340"); + expect(JSON.stringify(blocks)).toContain("Create Linear issue"); + expect(JSON.stringify(blocks)).toContain("CPK-9: Checkout 500s"); + }); + + it("rejects malformed confirm_write interrupt payloads", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const agent = new FakeAgent([ + (subscriber) => { + subscriber.onCustomEvent?.({ + event: { + type: EventType.CUSTOM, + name: "on_interrupt", + value: JSON.stringify({ + ...confirmWriteEnvelope("Injected write"), + __copilotkit_interrupt_value__: { + action: "unexpected_action", + args: { action: "Injected write" }, + }, + }), + }, + } as never); + }, + ]); + const { adapter, channel } = makeChannel({ + agent, + }); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "file this", + platform: "slack", + user: { id: "U1" }, + }); + + expect(JSON.stringify(adapter.posted)).toMatch(/sorry.*error/i); + expect(JSON.stringify(adapter.posted)).not.toContain("Injected write"); + consoleError.mockRestore(); + }); + + it("re-registers confirm_write 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.onCustomEvent?.({ + event: { + type: EventType.CUSTOM, + name: "on_interrupt", + value: confirmWriteEnvelope("Create Linear issue", "CPK-9"), + }, + } as never); + }, + ]); + const firstChannel = createOpenTagChannel("opentag", firstAgent); + firstChannel.ɵruntime.addAdapter(firstAdapter); + channels.push(firstChannel); + await firstChannel.ɵruntime.start(); + await firstAdapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "file this", + platform: "slack", + user: { id: "U1" }, + }); + + const createButton = findButton(firstAdapter.posted[0]!, true); + expect(createButton).toBeDefined(); + const actionId = ( + createButton?.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 secondAgent = new FakeAgent(); + const secondChannel = createOpenTagChannel("opentag", secondAgent); + secondChannel.ɵruntime.addAdapter(secondAdapter); + channels.push(secondChannel); + await secondChannel.ɵruntime.start(); + await secondAdapter.getSink().onInteraction({ + id: actionId!, + conversationKey: "c1", + replyTarget: {}, + messageRef: { id: "msg-1" }, + value: { confirmed: true }, + }); + + expect(secondAdapter.updated).toHaveLength(1); + 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 new file mode 100644 index 0000000..4bb7be0 --- /dev/null +++ b/app/channel.tsx @@ -0,0 +1,96 @@ +import { + createChannel, + type Channel, + type CreateChannelOptions, +} from "@copilotkit/channels"; +import { + managedRunInput, + reportRecoverableError, +} from "./channel-helpers.js"; +import { appCommands } from "./commands/index.js"; +import { IssueCard, IssueList, PageList } from "./components/index.js"; +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; + +/** Build the managed OpenTag Channel; Intelligence owns its platform adapters. */ +export function createOpenTagChannel( + name: string, + agent: ChannelAgent, +): Channel { + const channel = createChannel({ + name, + agent, + tools: appTools, + context: [...appContext], + commands: appCommands, + components: [IssueCard, IssueList, PageList, IncidentCard, ConfirmWrite], + }); + + channel.onMention(async ({ thread, message }) => { + try { + await thread.runAgent(managedRunInput(message)); + } catch (error) { + try { + await thread.post( + "Sorry — I hit an error handling that. Please try again.", + ); + } catch (postError) { + throw new AggregateError( + [error, postError], + "The agent run and its user-facing error reply both failed", + ); + } + + // A failed turn is isolated from future turns. Once the user receives an + // explicit failure response, the Channel can safely remain available. + reportRecoverableError(error, { + operation: "run_agent", + recovery: "posted_user_facing_error", + }); + } + }); + + channel.onModalSubmit(FILE_ISSUE_CALLBACK, fileIssueSubmit); + + channel.onInterrupt("on_interrupt", async ({ payload, thread }) => { + const { args } = parseConfirmWriteInterrupt(payload); + await thread.post( + , + ); + }); + + channel.onThreadStarted(async ({ thread, user }) => { + if (!user?.name) return; + + try { + await thread.setSuggestedPrompts([ + { + title: `Triage ${user.name}'s issues`, + message: "Triage my open issues", + }, + { + title: "What shipped this week?", + message: "Summarize what shipped this week", + }, + ]); + } catch (error) { + // Suggested prompts are an optional affordance; their absence does not + // affect message delivery, agent execution, or later thread turns. + reportRecoverableError(error, { + operation: "set_suggested_prompts", + recovery: "continue_without_suggested_prompts", + }); + } + }); + + return channel; +} 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/commands/__tests__/commands.test.ts b/app/commands/__tests__/commands.test.ts index 7a4a446..356afc9 100644 --- a/app/commands/__tests__/commands.test.ts +++ b/app/commands/__tests__/commands.test.ts @@ -1,14 +1,17 @@ import { describe, it, expect, vi } from "vitest"; -import { renderToIR } from "@copilotkit/channels-ui"; -import type { BotNode } from "@copilotkit/channels-ui"; +import { renderToIR, type ChannelNode } from "@copilotkit/channels"; +import { + defaultSlackContext, + defaultSlackTools, +} from "@copilotkit/channels/slack"; import { appCommands } from "../index.js"; import type { CommandContext } from "@copilotkit/channels"; -function tags(node: BotNode | unknown, acc: string[] = []): string[] { +function tags(node: ChannelNode | unknown, acc: string[] = []): string[] { if (!node || typeof node !== "object") return acc; - const n = node as BotNode; + const n = node as ChannelNode; if (typeof n.type === "string") acc.push(n.type); - for (const c of (n.props?.children as BotNode[] | undefined) ?? []) { + for (const c of (n.props?.children as ChannelNode[] | undefined) ?? []) { tags(c, acc); } return acc; @@ -24,7 +27,11 @@ const byName = (name: string) => { function fakeThread() { return { runAgent: vi.fn( - async (_input?: { prompt?: string; context?: unknown }) => undefined, + async (_input?: { + prompt?: string; + tools?: unknown; + context?: unknown; + }) => undefined, ), post: vi.fn(async (_ui?: unknown) => ({ id: "m1" })), }; @@ -74,7 +81,7 @@ describe("example slash commands", () => { expect(thread.post).toHaveBeenCalledTimes(1); }); - it("/agent logs and posts an apology when runAgent rejects", async () => { + it("/agent reports a recoverable error after posting an apology", async () => { const thread = fakeThread(); thread.runAgent.mockRejectedValueOnce(new Error("backend unavailable")); const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -87,7 +94,17 @@ describe("example slash commands", () => { }), ); - expect(consoleError).toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledWith( + "[channel] recoverable error", + expect.objectContaining({ + error: expect.any(Error), + context: { + operation: "command_agent_run_agent", + recovery: "posted_user_facing_error", + }, + timestamp: expect.any(String), + }), + ); expect(thread.post).toHaveBeenCalledWith( expect.stringMatching(/sorry.*error/i), ); @@ -95,6 +112,26 @@ describe("example slash commands", () => { consoleError.mockRestore(); }); + it("/agent rejects loudly when its run and apology both fail", async () => { + const thread = fakeThread(); + const runError = new Error("backend unavailable"); + const postError = new Error("Slack unavailable"); + thread.runAgent.mockRejectedValueOnce(runError); + thread.post.mockRejectedValueOnce(postError); + + await expect( + byName("agent").handler( + ctx({ + command: "agent", + text: "why is prod down", + thread: thread as never, + }), + ), + ).rejects.toMatchObject({ + errors: [runError, postError], + }); + }); + it("/triage runs the agent with a triage prompt", async () => { const thread = fakeThread(); await byName("triage").handler( @@ -122,6 +159,40 @@ describe("example slash commands", () => { }); }); + it.each([ + ["agent", "why is prod down"], + ["triage", "checkout is failing"], + ])( + "managed /%s uses its Slack command origin for defaults and sender context", + async (command, text) => { + const thread = { + ...fakeThread(), + platform: "intelligence", + }; + + await byName(command).handler( + ctx({ + command, + text, + thread: thread as never, + platform: "slack", + user: { id: "U1", name: "Ada" }, + }), + ); + + const input = thread.runAgent.mock.calls[0]![0]; + expect(input?.tools).toEqual(defaultSlackTools); + expect(input?.context).toEqual([ + ...defaultSlackContext, + { + description: "Requesting slack user", + value: "Ada (slack id U1)", + }, + ]); + expect(JSON.stringify(input)).not.toContain("intelligence user"); + }, + ); + it("/preview posts an ephemeral draft on the native (non-fallback) path", async () => { const preview = appCommands.find((c) => c.name === "preview")!; expect(preview).toBeDefined(); @@ -197,6 +268,7 @@ describe("example slash commands", () => { const capturedView = openModal.mock.calls[0]![0]; const ir = renderToIR(capturedView); const t = tags(ir[0]); + expect(ir[0]?.props.privateMetadata).toBe("slack"); expect(t).toContain("modal_select"); expect(t).toContain("modal_radio"); }); @@ -211,13 +283,20 @@ describe("example slash commands", () => { text: "", options: {}, user: { id: "U1" }, - platform: "telegram", - openModal: undefined, // Telegram: no modal trigger + platform: "teams", + openModal: undefined, } as never); expect(post).toHaveBeenCalledWith( expect.stringMatching(/aren.t supported|chat/i), ); expect(runAgent).toHaveBeenCalledTimes(1); + expect(runAgent.mock.calls[0]![0]?.tools).toBeUndefined(); + expect(runAgent.mock.calls[0]![0]?.context).toEqual([ + { + description: "Requesting teams user", + value: "U1 (teams id U1)", + }, + ]); }); it("/file-issue posts an error message when openModal resolves { ok: false }", async () => { @@ -254,7 +333,7 @@ describe("example slash commands", () => { text: "Login broken", options: {}, user: { id: "U1", name: "Ada" }, - platform: "discord", + platform: "teams", } as never); expect(postEphemeral).toHaveBeenCalledTimes(1); expect(post).toHaveBeenCalledTimes(1); @@ -273,7 +352,7 @@ describe("example slash commands", () => { text: "Login broken", options: {}, user: { id: "U1", name: "Ada" }, - platform: "discord", + platform: "teams", } as never); expect(postEphemeral).toHaveBeenCalledTimes(1); expect(post).toHaveBeenCalledTimes(1); diff --git a/app/commands/index.ts b/app/commands/index.ts index aa0c216..f0da70f 100644 --- a/app/commands/index.ts +++ b/app/commands/index.ts @@ -1,20 +1,24 @@ /** * Slash commands for this bot. Each is registered with the engine via - * `createBot({ commands })`; the Slack adapter forwards every `/command` it + * `createChannel({ commands })`; the Slack adapter forwards every `/command` it * receives and the engine routes by name (ignoring unregistered ones). * * NOTE: a slash command only fires if it's also declared in the Slack app * config ("Slash Commands" / manifest) with the same name — Slack won't * deliver an unregistered command, even over Socket Mode. * - * Args arrive as free text (`ctx.text`) on Slack; `ctx.options` is for - * surfaces with native structured args (e.g. Discord). The `options` schema - * is optional and used there for registration/typing. + * Args arrive as free text (`ctx.text`) on Slack; `ctx.options` is available + * to surfaces that deliver native structured arguments. */ -import { defineBotCommand } from "@copilotkit/channels"; -import type { BotCommand } from "@copilotkit/channels"; -import type { Thread as BotThread } from "@copilotkit/channels-ui"; -import { senderContext } from "../sender-context.js"; +import { + defineChannelCommand, + type ChannelCommand, + type Thread as ChannelThread, +} from "@copilotkit/channels"; +import { + platformRunInput, + reportRecoverableError, +} from "../channel-helpers.js"; import { IssueCard } from "../components/index.js"; import { FileIssueModal } from "../modals/file-issue.js"; @@ -27,65 +31,72 @@ import { FileIssueModal } from "../modals/file-issue.js"; */ async function runAgentSafely( commandName: string, - thread: Pick, - input: Parameters[0], + thread: Pick, + input: Parameters[0], ): Promise { try { await thread.runAgent(input); - } catch (err) { - console.error(`[command] ${commandName} run failed`, err); - await thread - .post("Sorry — I hit an error handling that. Please try again.") - .catch((postErr: unknown) => - console.error(`[command] ${commandName} failed to post error`, postErr), + } catch (error) { + try { + await thread.post( + "Sorry — I hit an error handling that. Please try again.", ); + } catch (postError) { + throw new AggregateError( + [error, postError], + `The /${commandName} agent run and its error reply both failed`, + ); + } + + reportRecoverableError(error, { + operation: `command_${commandName}_run_agent`, + recovery: "posted_user_facing_error", + }); } } -export const appCommands: BotCommand[] = [ +export const appCommands: ChannelCommand[] = [ // `/agent ` — a mention-free entry point. (Previously hardcoded in the // adapter; now an ordinary, app-owned command.) Runs the agent with the // command text as the user prompt, since slash-command args are never // posted to the channel for the agent to read from history. - defineBotCommand({ + defineChannelCommand({ name: "agent", description: "Ask the triage agent anything (no @mention needed).", - async handler({ thread, text, user }) { + async handler({ thread, text, user, platform }) { if (!text) { await thread.post("Usage: `/agent `"); return; } await runAgentSafely("agent", thread, { prompt: text, - context: senderContext(user, thread.platform), + ...platformRunInput(platform, user), }); }, }), // `/triage [note]` — summarize the current channel/thread and propose Linear // issues to file. Demonstrates a command with its own intent. - defineBotCommand({ + defineChannelCommand({ name: "triage", description: "Summarize the conversation and propose Linear issues to file.", - async handler({ thread, text, user }) { + async handler({ thread, text, user, platform }) { const prompt = text ? `Triage this and propose Linear issues to file: ${text}` : "Triage the current conversation: summarize it and propose Linear issues to file."; await runAgentSafely("triage", thread, { prompt, - context: senderContext(user, thread.platform), + ...platformRunInput(platform, user), }); }, }), // `/preview ` — ephemeral demo. Show the invoker a private draft of the // issue we'd file BEFORE anything is posted publicly or written to Linear. - // `postEphemeral` is capability-gated with an explicit DM fallback: Slack shows - // a native only-you message; Discord and Telegram have no ephemeral surface, so - // `fallbackToDM: true` sends it as a direct message instead. We narrate which - // path was taken so the degradation is visible, never silent. - defineBotCommand({ + // `postEphemeral` is capability-gated with an explicit DM fallback. Slack + // shows a native only-you message; other surfaces report their actual support. + defineChannelCommand({ name: "preview", description: "Privately preview the issue I'd file (only you see it).", async handler({ thread, text, user, platform }) { @@ -125,12 +136,9 @@ export const appCommands: BotCommand[] = [ // `/file-issue` — modal demo. Open a structured issue form, or degrade // honestly where modals aren't available. - // - Slack → rich modal (dropdowns + radio). - // - Discord → text-only modal (discord.js modals take only text inputs); the - // dropdowns/radio drop and defaults apply (see FileIssueModal). - // - Telegram→ no modal trigger at all (`ctx.openModal` is undefined), so we - // say so and continue the same job conversationally via the agent. - defineBotCommand({ + // Slack opens the rich modal. Teams currently has no modal trigger, so we say + // so and continue the same job conversationally via the agent. + defineChannelCommand({ name: "file-issue", description: "Open a form to file a Linear issue.", async handler({ thread, openModal, platform, user }) { @@ -143,12 +151,15 @@ export const appCommands: BotCommand[] = [ prompt: "The user wants to file a Linear issue but this platform has no modal form. " + "Ask them for a title and description, then (after the usual confirm) file it.", - context: senderContext(user, platform), + ...platformRunInput(platform, user), }); return; } const res = await openModal( - FileIssueModal({ rich: platform === "slack" }), + FileIssueModal({ + rich: platform === "slack", + sourcePlatform: platform, + }), ); if (!res.ok) { await thread.post( diff --git a/app/components/__tests__/components.test.tsx b/app/components/__tests__/components.test.tsx index b3a9681..405d38a 100644 --- a/app/components/__tests__/components.test.tsx +++ b/app/components/__tests__/components.test.tsx @@ -1,6 +1,6 @@ /** * Block Kit parity tests for the JSX render components. Each component is a - * `@copilotkit/channels-ui` `ComponentFn`; we assert the full + * `@copilotkit/channels` `ComponentFn`; we assert the full * `renderSlackMessage(renderToIR(<… />))` output — both the `blocks` and the * attachment `accent` — against the legacy `defineSlackComponent` shapes. * @@ -12,12 +12,12 @@ * the old `defineSlackComponent` code produced. * * Status/priority glyphs are now platform-neutral unicode (✅ 🔵 🚨 🔴 etc.) - * so they render identically on both Slack and Telegram. + * so they render identically on both Slack and Teams. */ import { describe, it, expect } from "vitest"; -import { renderToIR } from "@copilotkit/channels-ui"; -import { renderSlackMessage } from "@copilotkit/channels-slack"; -import { renderTelegram } from "@copilotkit/channels-telegram"; +import { renderToIR } from "@copilotkit/channels"; +import { renderSlackMessage } from "@copilotkit/channels/slack"; +import { renderAdaptiveCard } from "@copilotkit/channels/teams"; import { IssueList } from "../issue-list.js"; import { IssueCard } from "../issue-card.js"; import { PageList } from "../page-list.js"; @@ -335,119 +335,55 @@ describe("PageList component", () => { }); }); -// ── Telegram parity tests ──────────────────────────────────────────────────── -// These tests render the same IR through renderTelegram and assert that the -// unicode status/priority glyphs appear correctly (no Slack `:shortcode:` -// strings that Telegram would not expand). - -describe("IssueCard Telegram parity", () => { - it("renders unicode status and priority glyphs in Telegram output", () => { - const payload = renderTelegram( +describe("Microsoft Teams rendering parity", () => { + it("renders rich issue and page content as Adaptive Cards", () => { + const issue = renderAdaptiveCard( renderToIR( <IssueCard identifier="CPK-101" title="Checkout 500s under load" - url="https://linear.app/copilotkit/issue/CPK-101" state="In Progress" - assignee="Alem" priority="Urgent" - team="CPK" />, ), ); - // renderTelegram returns a TelegramPayload with a `text` field (HTML string) - // and `parseMode: "HTML"` — confirmed from telegram.test.ts line: - // expect(out.parseMode).toBe("HTML"); - // expect(out.text).toContain("<b>Status</b>"); - expect(typeof payload.text).toBe("string"); - // In-progress maps to the blue dot unicode glyph. - expect(payload.text).toContain("🔵"); - // Urgent priority maps to the siren glyph. - expect(payload.text).toContain("🚨"); - // Identifier and title text must appear in the output. - expect(payload.text).toContain("CPK-101"); - expect(payload.text).toContain("Checkout 500s under load"); - // No Slack mrkdwn shortcodes must appear. - expect(payload.text).not.toContain(":large_blue_circle:"); - expect(payload.text).not.toContain(":rotating_light:"); - }); - - it("renders 'done' unicode glyph for justCreated issue in Telegram output", () => { - const payload = renderTelegram( - renderToIR( - <IssueCard identifier="CPK-200" title="New bug" justCreated />, - ), - ); - expect(typeof payload.text).toBe("string"); - // justCreated uses the check-mark glyph. - expect(payload.text).toContain("✅"); - expect(payload.text).toContain("CPK-200"); - expect(payload.text).toContain("New bug"); - }); -}); - -describe("IssueList Telegram parity", () => { - it("renders unicode status glyphs for each issue in Telegram output", () => { - const payload = renderTelegram( + const issues = renderAdaptiveCard( renderToIR( <IssueList - heading="Open" + heading="Open incidents" issues={[ - { - identifier: "CPK-101", - title: "Checkout 500s under load", - url: "https://linear.app/copilotkit/issue/CPK-101", - state: "In Progress", - assignee: "Alem", - priority: "Urgent", - updated: "2d ago", - }, { identifier: "CPK-102", title: "Login redirect loop", - url: "https://linear.app/copilotkit/issue/CPK-102", state: "Todo", - assignee: "Sam", priority: "High", - updated: "5h ago", }, ]} />, ), ); - expect(typeof payload.text).toBe("string"); - // In-progress maps to the blue dot. - expect(payload.text).toContain("🔵"); - // Todo/unknown maps to the orange dot. - expect(payload.text).toContain("🟠"); - // Identifiers must be present. - expect(payload.text).toContain("CPK-101"); - expect(payload.text).toContain("CPK-102"); - // No Slack mrkdwn shortcodes. - expect(payload.text).not.toContain(":large_blue_circle:"); - expect(payload.text).not.toContain(":large_orange_circle:"); - }); -}); - -describe("PageList Telegram parity", () => { - it("renders page titles and snippets in Telegram output", () => { - const payload = renderTelegram( + const pages = renderAdaptiveCard( renderToIR( <PageList heading="Runbooks" pages={[ { title: "Auth outage runbook", - url: "https://www.notion.so/abc", snippet: "Steps to mitigate auth provider downtime.", - edited: "3d ago", }, ]} />, ), ); - expect(typeof payload.text).toBe("string"); - expect(payload.text).toContain("Auth outage runbook"); - expect(payload.text).toContain("Steps to mitigate auth provider downtime."); + + 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/_status.ts b/app/components/_status.ts index ccd9238..1dd410b 100644 --- a/app/components/_status.ts +++ b/app/components/_status.ts @@ -1,7 +1,7 @@ /** * Shared status/priority → glyph mapping for the Linear components. The * functions return unicode glyphs (not Slack `:shortcode:` strings), so the - * components render identically on Slack and Telegram. + * components render consistently on Slack and Teams. */ /** Unicode glyph for a Linear workflow-state name. */ diff --git a/app/components/index.ts b/app/components/index.ts index 823bf9d..2662747 100644 --- a/app/components/index.ts +++ b/app/components/index.ts @@ -1,16 +1,12 @@ /** * App-specific render components — agent-renderable Block Kit cards authored - * with the `@copilotkit/channels-ui` JSX vocabulary. + * with the umbrella `@copilotkit/channels` package's JSX vocabulary. * * Each component is a plain `ComponentFn` returning a `<Message>` tree; its - * exported zod prop schema doubles as the render-tool input schema. Render a - * component with `renderSlackMessage(renderToIR(<IssueCard {...props} />))`. + * 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 fbe9f2f..8853c79 100644 --- a/app/components/issue-card.tsx +++ b/app/components/issue-card.tsx @@ -7,7 +7,7 @@ * Use it for one issue — when the user asks about a specific issue, or * right after creating one (it doubles as the "filed!" confirmation). * - * Authored with the `@copilotkit/channels-ui` JSX vocabulary; the Block Kit + * Authored with the `@copilotkit/channels` JSX vocabulary; the Block Kit * shapes are produced by `renderSlackMessage(renderToIR(<IssueCard .../>))`. */ import { z } from "zod"; @@ -19,8 +19,8 @@ import { Header, Message, Section, -} from "@copilotkit/channels-ui"; -import type { BotNode } from "@copilotkit/channels-ui"; + type ChannelNode, +} from "@copilotkit/channels"; import { accentForIssue, priorityGlyph, stateGlyph } from "./_status.js"; export const issueCardSchema = z.object({ @@ -48,10 +48,10 @@ export const issueCardSchema = z.object({ ), }); -export type IssueCardProps = z.infer<typeof issueCardSchema>; +type IssueCardProps = z.infer<typeof issueCardSchema>; /** Render ONE Linear issue as a rich Block Kit card. */ -export function IssueCard(issue: IssueCardProps): BotNode { +export function IssueCard(issue: IssueCardProps): ChannelNode { const titleText = issue.url ? `[**${issue.title}**](${issue.url})` : `**${issue.title}**`; diff --git a/app/components/issue-list.tsx b/app/components/issue-list.tsx index 9a3c71f..39efd2e 100644 --- a/app/components/issue-list.tsx +++ b/app/components/issue-list.tsx @@ -13,11 +13,16 @@ * it wants shown; the Slack formatting lives here. For a single issue (or * right after creating one) prefer `issue_card`, which shows a full grid. * - * Authored with the `@copilotkit/channels-ui` JSX vocabulary. + * Authored with the `@copilotkit/channels` JSX vocabulary. */ import { z } from "zod"; -import { Context, Header, Message, Section } from "@copilotkit/channels-ui"; -import type { BotNode } from "@copilotkit/channels-ui"; +import { + Context, + Header, + Message, + Section, + type ChannelNode, +} from "@copilotkit/channels"; import { accentForIssues, stateGlyph } from "./_status.js"; const issueSchema = z.object({ @@ -50,7 +55,7 @@ export const issueListSchema = z.object({ issues: z.array(issueSchema).min(1).describe("The issues to render."), }); -export type IssueListProps = z.infer<typeof issueListSchema>; +type IssueListProps = z.infer<typeof issueListSchema>; type Issue = z.infer<typeof issueSchema>; /** Max issues rendered inline; the rest are summarized in the footer. */ @@ -59,7 +64,7 @@ const MAX = 15; const TITLE_MAX = 70; /** Render a list of Linear issues as a compact, fixed-size Block Kit card. */ -export function IssueList({ heading, issues }: IssueListProps): BotNode { +export function IssueList({ heading, issues }: IssueListProps): ChannelNode { const lines = issues.slice(0, MAX).map((issue: Issue) => { const idLink = issue.url ? `[**${issue.identifier}**](${issue.url})` diff --git a/app/components/page-list.tsx b/app/components/page-list.tsx index f7dca33..ff4d73b 100644 --- a/app/components/page-list.tsx +++ b/app/components/page-list.tsx @@ -13,11 +13,17 @@ * The agent searches Notion via MCP and passes the pages it wants to * surface; the Slack formatting lives here. * - * Authored with the `@copilotkit/channels-ui` JSX vocabulary. + * Authored with the `@copilotkit/channels` JSX vocabulary. */ import { z } from "zod"; -import { Context, Divider, Header, Message, Section } from "@copilotkit/channels-ui"; -import type { BotNode } from "@copilotkit/channels-ui"; +import { + Context, + Divider, + Header, + Message, + Section, + type ChannelNode, +} from "@copilotkit/channels"; import { ACCENT } from "./_status.js"; const pageSchema = z.object({ @@ -42,16 +48,16 @@ export const pageListSchema = z.object({ pages: z.array(pageSchema).min(1).describe("The pages to render."), }); -export type PageListProps = z.infer<typeof pageListSchema>; +type PageListProps = z.infer<typeof pageListSchema>; type Page = z.infer<typeof pageSchema>; /** Max pages rendered inline; the rest are summarized in the footer. */ const MAX = 15; /** Render a list of Notion pages as a Block Kit card. */ -export function PageList({ heading, pages }: PageListProps): BotNode { +export function PageList({ heading, pages }: PageListProps): ChannelNode { const shown = pages.slice(0, MAX); - const rows: BotNode[] = []; + const rows: ChannelNode[] = []; shown.forEach((page: Page, i: number) => { const titleLink = page.url ? `[**${page.title}**](${page.url})` 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 ebeea11..e88e74e 100644 --- a/app/context/app-context.ts +++ b/app/context/app-context.ts @@ -1,9 +1,7 @@ /** * App-specific context entries — bot identity, tone, policy. - * Platform tagging/formatting/thread-model guidance ships in each adapter's - * default context (`defaultSlackContext` / `defaultTelegramContext`) and is - * spread per-bot in `app/index.ts`; this file holds platform-neutral identity - * and triage policy only. + * Platform tagging/formatting/thread-model guidance comes from the selected + * platform setup; this file holds platform-neutral identity and triage policy. * * Each entry is `{description, value}`. The SDK forwards them as AG-UI * `context` on every turn; the agent backend surfaces them as a @@ -15,7 +13,7 @@ export const appContext: ReadonlyArray<ContextEntry> = [ { description: "Bot identity & tone", value: [ - "You are KiteBot, the team's on-call triage assistant. Be concise and action-", + "You are OpenTag, the team's on-call triage assistant. Be concise and action-", "oriented — responders are mid-incident. Lead with the answer, then any", "links. Prefer rendering issues/pages as cards over long prose.", ].join("\n"), @@ -29,4 +27,26 @@ export const appContext: ReadonlyArray<ContextEntry> = [ "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/e2e-cases.test.ts b/app/e2e-cases.test.ts new file mode 100644 index 0000000..154c3dc --- /dev/null +++ b/app/e2e-cases.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { CASES } from "../e2e/cases.js"; + +const pendingConfirmWriteMessage = { + attachments: [ + { + color: "#E2B340", + blocks: [ + { + type: "header", + text: { type: "plain_text", text: "📝 Create Linear issue?" }, + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: "🔒 Nothing is written until you click *Create*.", + }, + ], + }, + { + type: "actions", + elements: [ + { + type: "button", + action_id: "ck:create", + text: { type: "plain_text", text: "Create" }, + value: JSON.stringify({ confirmed: true }), + }, + { + type: "button", + action_id: "ck:cancel", + text: { type: "plain_text", text: "Cancel" }, + value: JSON.stringify({ confirmed: false }), + }, + ], + }, + ], + }, + ], +}; + +function caseWithPrefix(prefix: string) { + const found = CASES.find(({ name }) => name.startsWith(prefix)); + if (!found) throw new Error(`missing E2E case ${prefix}`); + return found; +} + +describe("confirm_write E2E cases", () => { + it("recognizes the actual initial Create/Cancel Block Kit card", () => { + const hitl = caseWithPrefix("E-hitl-1"); + expect( + hitl.expectations?.perReplyChecks?.( + ["📝 Create Linear issue?"], + [pendingConfirmWriteMessage], + ), + ).toEqual([]); + }); + + it("describes and validates serialized action IDs and resume values without claiming a restart", () => { + const durable = caseWithPrefix("E-durable-1"); + expect(durable.name).not.toMatch(/restart|surviv/i); + expect( + durable.expectations?.perReplyChecks?.( + ["📝 Create Linear issue?"], + [pendingConfirmWriteMessage], + ), + ).toEqual([]); + }); +}); diff --git a/app/env.test.ts b/app/env.test.ts new file mode 100644 index 0000000..6ea12e4 --- /dev/null +++ b/app/env.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_INTELLIGENCE_API_URL, + DEFAULT_INTELLIGENCE_CHANNEL_NAME, + DEFAULT_INTELLIGENCE_GATEWAY_WS_URL, + parsePort, + readEnvironment, +} from "./env.js"; + +const requiredEnvironment = { + AGENT_URL: "http://localhost:8123/", + INTELLIGENCE_API_KEY: "cpk_test", +}; + +describe("readEnvironment", () => { + it("requires AGENT_URL", () => { + expect(() => + readEnvironment({ INTELLIGENCE_API_KEY: "cpk_test" }), + ).toThrow("Missing required env var: AGENT_URL"); + }); + + it("requires INTELLIGENCE_API_KEY", () => { + expect(() => + readEnvironment({ AGENT_URL: "http://localhost:8123/" }), + ).toThrow("Missing required env var: INTELLIGENCE_API_KEY"); + }); + + it("uses the Intelligence, channel-name, and port defaults", () => { + expect(readEnvironment(requiredEnvironment)).toMatchObject({ + agentUrl: "http://localhost:8123/", + intelligenceApiKey: "cpk_test", + intelligenceApiUrl: DEFAULT_INTELLIGENCE_API_URL, + intelligenceGatewayWsUrl: DEFAULT_INTELLIGENCE_GATEWAY_WS_URL, + channelName: DEFAULT_INTELLIGENCE_CHANNEL_NAME, + port: 3000, + }); + }); + + it("honors Intelligence URL and channel-name overrides", () => { + expect( + readEnvironment({ + ...requiredEnvironment, + INTELLIGENCE_API_URL: "https://intelligence.example.test", + INTELLIGENCE_GATEWAY_WS_URL: "wss://realtime.example.test", + INTELLIGENCE_CHANNEL_NAME: "custom-channel", + }), + ).toMatchObject({ + intelligenceApiUrl: "https://intelligence.example.test", + intelligenceGatewayWsUrl: "wss://realtime.example.test", + channelName: "custom-channel", + }); + }); + + it("does not expose platform credentials owned by Intelligence", () => { + const environment = readEnvironment({ + ...requiredEnvironment, + SLACK_BOT_TOKEN: "xoxb-unused", + TEAMS_CLIENT_ID: "teams-unused", + }); + + expect(environment).not.toHaveProperty("slackBotToken"); + expect(environment).not.toHaveProperty("teamsClientId"); + expect(environment).not.toHaveProperty("teamsPort"); + }); +}); + +describe("parsePort", () => { + it("defaults to 3000", () => { + expect(parsePort(undefined)).toBe(3000); + }); + + it("accepts a valid integer port", () => { + expect(parsePort("4242")).toBe(4242); + }); + + it.each(["", "0", "65536", "12.5", "abc"])( + "rejects invalid PORT %j", + (raw) => { + expect(() => parsePort(raw)).toThrow(`Invalid PORT: "${raw}"`); + }, + ); +}); diff --git a/app/env.ts b/app/env.ts new file mode 100644 index 0000000..30942d3 --- /dev/null +++ b/app/env.ts @@ -0,0 +1,55 @@ +export const DEFAULT_INTELLIGENCE_API_URL = + "https://api.intelligence.copilotkit.ai"; +export const DEFAULT_INTELLIGENCE_GATEWAY_WS_URL = + "wss://realtime.intelligence.copilotkit.ai"; +export const DEFAULT_INTELLIGENCE_CHANNEL_NAME = "open-tag"; + +export interface AppEnvironment { + agentUrl: string; + agentAuthHeader?: string; + intelligenceApiKey: string; + intelligenceApiUrl: string; + intelligenceGatewayWsUrl: string; + channelName: string; + port: number; +} + +function required(env: NodeJS.ProcessEnv, name: string): string { + const value = env[name]; + if (!value) { + throw new Error(`Missing required env var: ${name}`); + } + return value; +} + +export function parsePort( + raw: string | undefined, + defaultPort = 3000, + name = "PORT", +): number { + if (raw === undefined) return defaultPort; + + const port = Number(raw); + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error(`Invalid ${name}: "${raw}"`); + } + return port; +} + +export function readEnvironment( + env: NodeJS.ProcessEnv = process.env, +): AppEnvironment { + return { + agentUrl: required(env, "AGENT_URL"), + agentAuthHeader: env.AGENT_AUTH_HEADER, + intelligenceApiKey: required(env, "INTELLIGENCE_API_KEY"), + intelligenceApiUrl: + env.INTELLIGENCE_API_URL ?? DEFAULT_INTELLIGENCE_API_URL, + intelligenceGatewayWsUrl: + env.INTELLIGENCE_GATEWAY_WS_URL ?? + DEFAULT_INTELLIGENCE_GATEWAY_WS_URL, + channelName: + env.INTELLIGENCE_CHANNEL_NAME ?? DEFAULT_INTELLIGENCE_CHANNEL_NAME, + port: parsePort(env.PORT), + }; +} diff --git a/app/human-in-the-loop/__tests__/confirm-write-tool.test.tsx b/app/human-in-the-loop/__tests__/confirm-write-tool.test.tsx deleted file mode 100644 index d223b44..0000000 --- a/app/human-in-the-loop/__tests__/confirm-write-tool.test.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { renderToIR, type BotNode } from "@copilotkit/channels-ui"; -import { renderSlackMessage } from "@copilotkit/channels-slack"; -import { confirmWriteTool } from "../confirm-write-tool.js"; - -/** A fake thread whose `awaitChoice` records the posted UI and returns a fixed choice. */ -function fakeThread(choice: unknown) { - const awaited: unknown[] = []; - const thread = { - async awaitChoice(ui: unknown) { - awaited.push(ui); - return choice; - }, - }; - return { thread, awaited }; -} - -describe("confirm_write tool", () => { - it("posts a ConfirmWrite picker and returns the resolved {confirmed:true}", async () => { - const { thread, awaited } = fakeThread({ confirmed: true }); - - const result = await confirmWriteTool.handler( - { - action: "Create Linear issue", - detail: "CPK-9: Checkout 500s under load", - }, - { thread, platform: "slack" } as never, - ); - - expect(result).toBe("The user APPROVED the write — proceed."); - - // The posted UI is a ConfirmWrite picker: amber accent + header carrying the action. - expect(awaited).toHaveLength(1); - const { blocks, accent } = renderSlackMessage( - renderToIR(awaited[0] as BotNode), - ); - expect(accent).toBe("#E2B340"); - const header = blocks.find((b) => b.type === "header") as - | { text: { text: string } } - | undefined; - expect(header?.text.text).toContain("Create Linear issue"); - }); - - it("returns {confirmed:false} when the user declines", async () => { - const { thread } = fakeThread({ confirmed: false }); - - const result = await confirmWriteTool.handler( - { action: "Create Linear issue" }, - { thread, platform: "slack" } as never, - ); - - expect(result).toBe( - "The user DECLINED — do not write; acknowledge and stop.", - ); - }); - - it("returns a NO-RESPONSE message and logs when awaitChoice resolves null (timeout/dismissed)", async () => { - const { thread } = fakeThread(null); - const consoleError = vi - .spyOn(console, "error") - .mockImplementation(() => undefined); - - const result = await confirmWriteTool.handler( - { action: "Create Linear issue" }, - { thread, platform: "slack" } as never, - ); - - expect(result).toMatch(/no response/i); - expect(result).not.toMatch(/APPROVED/); - expect(consoleError).toHaveBeenCalled(); - - consoleError.mockRestore(); - }); -}); 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 fbe98bd..8be2f8f 100644 --- a/app/human-in-the-loop/__tests__/confirm-write.test.tsx +++ b/app/human-in-the-loop/__tests__/confirm-write.test.tsx @@ -1,35 +1,39 @@ import { describe, it, expect, vi } from "vitest"; import { renderToIR, - type BotNode, + type ChannelNode, type InteractionContext, type ClickHandler, -} from "@copilotkit/channels-ui"; -import { renderSlackMessage } from "@copilotkit/channels-slack"; +} 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). */ -function childNodes(node: BotNode): BotNode[] { +function childNodes(node: ChannelNode): ChannelNode[] { const children = node.props?.children; - if (Array.isArray(children)) return children as BotNode[]; + if (Array.isArray(children)) return children as ChannelNode[]; if ( children && typeof children === "object" && "type" in (children as object) ) { - return [children as BotNode]; + return [children as ChannelNode]; } return []; } /** Concatenate the text of all descendant `text` nodes (depth-first). */ -function collectText(node: BotNode): string { +function collectText(node: ChannelNode): string { if (node.type === "text") return String(node.props?.value ?? ""); return childNodes(node).map(collectText).join(""); } /** Walk the whole tree to find the first node of a given intrinsic type. */ -function findByType(nodes: BotNode[], type: string): BotNode | undefined { +function findByType( + nodes: ChannelNode[], + type: string, +): ChannelNode | undefined { for (const n of nodes) { if (n.type === type) return n; const hit = findByType(childNodes(n), type); @@ -39,8 +43,8 @@ function findByType(nodes: BotNode[], type: string): BotNode | undefined { } /** All button nodes in the tree. */ -function findButtons(nodes: BotNode[]): BotNode[] { - const out: BotNode[] = []; +function findButtons(nodes: ChannelNode[]): ChannelNode[] { + const out: ChannelNode[] = []; for (const n of nodes) { if (n.type === "button") out.push(n); out.push(...findButtons(childNodes(n))); @@ -48,7 +52,7 @@ function findButtons(nodes: BotNode[]): BotNode[] { return out; } -function buttonByText(ir: BotNode[], text: string): BotNode { +function buttonByText(ir: ChannelNode[], text: string): ChannelNode { const btn = findButtons(ir).find((b) => collectText(b) === text); if (!btn) throw new Error(`button "${text}" not found`); return btn; @@ -102,24 +106,47 @@ describe("ConfirmWrite", () => { expect(blocks.some((b) => b.type === "section")).toBe(false); }); - it("approve onClick updates the picker in place to the resolved (green) state", async () => { + it("renders Create and Cancel actions as a Teams Adaptive Card", () => { + const card = renderAdaptiveCard( + renderToIR( + <ConfirmWrite + action="Create Linear issue" + detail="CPK-9: Checkout 500s" + />, + ), + ); + 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( <ConfirmWrite action="Create Linear issue" detail="CPK-9: ..." />, ); const create = buttonByText(ir, "Create"); - // `value` survives on the button props — that's what awaitChoice resolves to. + // `value` survives on the button props and native interaction payload. expect(create.props.value).toEqual({ confirmed: true }); const update = vi.fn(async () => ({ id: "m1" })); + const resume = vi.fn(async () => ({ id: "m2" })); const ctx = { - thread: { update }, + thread: { update, resume }, message: { ref: { id: "m1" } }, } as unknown as InteractionContext; await (create.props.onClick as ClickHandler)(ctx); expect(update).toHaveBeenCalledTimes(1); + expect(resume).toHaveBeenCalledWith({ confirmed: true }); + expect(update.mock.invocationCallOrder[0]).toBeLessThan( + resume.mock.invocationCallOrder[0]!, + ); const [ref, renderable] = update.mock.calls[0] as unknown as [ { id: string }, Parameters<typeof renderToIR>[0], @@ -138,7 +165,26 @@ describe("ConfirmWrite", () => { expect(context?.elements[0]?.text).toContain("Approved"); }); - it("cancel onClick updates the picker in place to the declined (red) state", async () => { + it("does not resume approval when the status update fails", async () => { + const ir = renderToIR(<ConfirmWrite action="Create Linear issue" />); + const create = buttonByText(ir, "Create"); + const failure = new Error("status update unavailable"); + const update = vi.fn(async () => { + throw failure; + }); + const resume = vi.fn(async () => ({ id: "m2" })); + const ctx = { + thread: { update, resume }, + message: { ref: { id: "m1" } }, + } as unknown as InteractionContext; + + await expect( + (create.props.onClick as ClickHandler)(ctx), + ).rejects.toBe(failure); + expect(resume).not.toHaveBeenCalled(); + }); + + it("cancel onClick updates the picker and resumes the interrupted agent", async () => { const ir = renderToIR( <ConfirmWrite action="Create Linear issue" detail="CPK-9: ..." />, ); @@ -147,14 +193,19 @@ describe("ConfirmWrite", () => { expect(cancel.props.value).toEqual({ confirmed: false }); const update = vi.fn(async () => ({ id: "m1" })); + const resume = vi.fn(async () => ({ id: "m2" })); const ctx = { - thread: { update }, + thread: { update, resume }, message: { ref: { id: "m1" } }, } as unknown as InteractionContext; await (cancel.props.onClick as ClickHandler)(ctx); expect(update).toHaveBeenCalledTimes(1); + expect(resume).toHaveBeenCalledWith({ confirmed: false }); + expect(update.mock.invocationCallOrder[0]).toBeLessThan( + resume.mock.invocationCallOrder[0]!, + ); const [ref, renderable] = update.mock.calls[0] as unknown as [ { id: string }, Parameters<typeof renderToIR>[0], @@ -172,4 +223,85 @@ describe("ConfirmWrite", () => { | undefined; expect(context?.elements[0]?.text).toContain("Declined"); }); + + it("does not resume a decline when the status update fails", async () => { + const ir = renderToIR(<ConfirmWrite action="Create Linear issue" />); + const cancel = buttonByText(ir, "Cancel"); + const failure = new Error("status update unavailable"); + const update = vi.fn(async () => { + throw failure; + }); + const resume = vi.fn(async () => ({ id: "m2" })); + const ctx = { + thread: { update, resume }, + message: { ref: { id: "m1" } }, + } as unknown as InteractionContext; + + await expect( + (cancel.props.onClick as ClickHandler)(ctx), + ).rejects.toBe(failure); + expect(resume).not.toHaveBeenCalled(); + }); + + it("replaces the optimistic card with a retry state when resume fails", async () => { + const ir = renderToIR( + <ConfirmWrite action="Create Linear issue" detail="CPK-9: ..." />, + ); + const create = buttonByText(ir, "Create"); + const failure = new Error("resume unavailable"); + const update = vi.fn(async () => ({ id: "m1" })); + const resume = vi.fn(async () => { + throw failure; + }); + const ctx = { + thread: { update, resume }, + message: { ref: { id: "m1" } }, + } as unknown as InteractionContext; + + await expect((create.props.onClick as ClickHandler)(ctx)).rejects.toBe( + failure, + ); + + expect(update).toHaveBeenCalledTimes(2); + const [, failedRenderable] = update.mock.calls[1] as unknown as [ + { id: string }, + Parameters<typeof renderToIR>[0], + ]; + const { blocks, accent } = renderSlackMessage( + renderToIR(failedRenderable), + ); + expect(accent).toBe("#EB5757"); + expect(JSON.stringify(blocks)).toMatch(/couldn.t resume|retry/i); + }); + + it("surfaces both resume and retry-card failures", async () => { + const ir = renderToIR(<ConfirmWrite action="Create Linear issue" />); + const create = buttonByText(ir, "Create"); + const resumeFailure = new Error("resume unavailable"); + const updateFailure = new Error("retry card unavailable"); + const update = vi + .fn() + .mockResolvedValueOnce({ id: "m1" }) + .mockRejectedValueOnce(updateFailure); + const resume = vi.fn(async () => { + throw resumeFailure; + }); + const ctx = { + thread: { update, resume }, + message: { ref: { id: "m1" } }, + } as unknown as InteractionContext; + + let thrown: unknown; + try { + await (create.props.onClick as ClickHandler)(ctx); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(AggregateError); + expect((thrown as AggregateError).errors).toEqual([ + resumeFailure, + updateFailure, + ]); + }); }); diff --git a/app/human-in-the-loop/confirm-write-tool.tsx b/app/human-in-the-loop/confirm-write-tool.tsx deleted file mode 100644 index 71fa7b1..0000000 --- a/app/human-in-the-loop/confirm-write-tool.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/** - * `confirm_write` — the agent-facing write-gate TOOL. - * - * The migration kept the {@link ConfirmWrite} JSX card but this tool is what - * makes the system prompt's contract real: "call the confirm_write tool before - * any Linear/Notion write". In the new model HITL is a BLOCKING FRONTEND TOOL — - * the handler calls `await thread.awaitChoice(<ConfirmWrite .../>)`, which posts - * the picker and BLOCKS until the user clicks Create/Cancel, then resolves to - * the clicked button's `value` (`{ confirmed: boolean }`). The agent only - * performs the write once this returns `{ confirmed: true }`. - */ -import { z } from "zod"; -import { defineBotTool } from "@copilotkit/channels"; -import { ConfirmWrite } from "./confirm-write.js"; - -export const confirmWriteSchema = z.object({ - action: z - .string() - .describe( - "One-line summary of exactly what you are about to write, e.g. 'Create Linear issue: CPK-123 — Checkout 500s'", - ), - detail: z - .string() - .optional() - .describe( - "Optional detail block shown under the prompt, e.g. the drafted title + description/outline", - ), -}); - -export const confirmWriteTool = defineBotTool({ - name: "confirm_write", - description: - "Ask the user to approve a write before you perform it. Posts a " + - "confirm/cancel card and BLOCKS until the user clicks; returns " + - "{confirmed: boolean}. You MUST call this before creating or modifying " + - "anything in Linear or Notion. Reads never need confirmation.", - parameters: confirmWriteSchema, - async handler({ action, detail }, { thread }) { - const choice = await thread.awaitChoice<{ confirmed?: boolean }>( - <ConfirmWrite action={action} detail={detail} />, - ); - if (choice == null) { - console.error( - "[confirm-write] no choice resolved (timeout/dismissed)", - ); - return "NO RESPONSE — the confirmation card was dismissed or timed out; do not write."; - } - return choice.confirmed - ? "The user APPROVED the write — proceed." - : "The user DECLINED — do not write; acknowledge and stop."; - }, -}); diff --git a/app/human-in-the-loop/confirm-write.tsx b/app/human-in-the-loop/confirm-write.tsx index e180aed..065b249 100644 --- a/app/human-in-the-loop/confirm-write.tsx +++ b/app/human-in-the-loop/confirm-write.tsx @@ -1,11 +1,9 @@ /** * `confirm_write` — the human-in-the-loop gate in front of every Linear / - * Notion write. The agent is instructed (see the system prompt in - * `runtime.ts`) to confirm BEFORE creating an issue or a page: a tool handler - * calls `await thread.awaitChoice(<ConfirmWrite .../>)`, which posts this - * interactive card and **blocks until the user clicks Create or Cancel**, - * resolving to the clicked button's `value` (`{ confirmed: true | false }`). - * The agent only performs the write once it resolves with `{ confirmed: true }`. + * Notion write. The Python MCP interceptor pauses before invoking a mutating + * tool. The Channel interrupt handler posts this card and returns immediately. + * A click updates the card, then resumes the paused graph with + * `{ confirmed: true | false }`. * * Each button also carries an `onClick` that updates the picker in place to a * resolved / declined state — so the card reflects the decision the moment it's @@ -22,16 +20,45 @@ import { Context, Actions, Button, -} from "@copilotkit/channels-ui"; -import type { InteractionContext } from "@copilotkit/channels-ui"; +} 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. */ detail?: string; } +async function resumeOrShowFailure( + thread: InteractionContext["thread"], + messageRef: InteractionContext["message"]["ref"], + action: string, + confirmed: boolean, +): Promise<void> { + try { + await thread.resume({ confirmed }); + } catch (error) { + try { + await thread.update( + messageRef, + <Message accent="#EB5757"> + <Header>{`⚠️ ${action} paused`}</Header> + <Context> + {"I couldn't resume the agent. Please retry the action."} + </Context> + </Message>, + ); + } catch (updateError) { + throw new AggregateError( + [error, updateError], + `Failed to resume "${action}" and show its retry state`, + ); + } + throw error; + } +} + export function ConfirmWrite({ action, detail }: ConfirmWriteProps) { return ( <Message accent="#E2B340"> @@ -43,17 +70,19 @@ export function ConfirmWrite({ action, detail }: ConfirmWriteProps) { value={{ confirmed: true }} style="primary" onClick={async ({ thread, message }: InteractionContext) => { - try { - await thread.update( - message.ref, - <Message accent="#27AE60"> - <Header>{`✅ ${action}`}</Header> - <Context>{"✅ Approved — writing now."}</Context> - </Message>, - ); - } catch (err) { - console.error("[confirm-write] onClick failed", err); - } + await thread.update( + message.ref, + <Message accent="#27AE60"> + <Header>{`✅ ${action}`}</Header> + <Context>{"✅ Approved — writing now."}</Context> + </Message>, + ); + await resumeOrShowFailure( + thread, + message.ref, + action, + true, + ); }} > Create @@ -62,17 +91,19 @@ export function ConfirmWrite({ action, detail }: ConfirmWriteProps) { value={{ confirmed: false }} style="danger" onClick={async ({ thread, message }: InteractionContext) => { - try { - await thread.update( - message.ref, - <Message accent="#EB5757"> - <Header>{`🚫 ${action}`}</Header> - <Context>{"🚫 Declined — nothing was written."}</Context> - </Message>, - ); - } catch (err) { - console.error("[confirm-write] onClick failed", err); - } + await thread.update( + message.ref, + <Message accent="#EB5757"> + <Header>{`🚫 ${action}`}</Header> + <Context>{"🚫 Declined — nothing was written."}</Context> + </Message>, + ); + await resumeOrShowFailure( + thread, + message.ref, + action, + false, + ); }} > Cancel diff --git a/app/human-in-the-loop/index.ts b/app/human-in-the-loop/index.ts index 31c01e0..d20645f 100644 --- a/app/human-in-the-loop/index.ts +++ b/app/human-in-the-loop/index.ts @@ -1,13 +1,9 @@ /** * App-specific human-in-the-loop components — interactive Block Kit cards the - * agent can render to ask the user a structured question and wait for the - * answer. + * Channel interrupt handlers can render to ask the user a structured question + * before resuming the paused agent. * - * `confirm_write` is now a JSX component (`ConfirmWrite`) used as a BLOCKING - * FRONTEND TOOL: a tool handler calls `await thread.awaitChoice(<ConfirmWrite - * .../>)` (wired in a later wave), which posts the picker and resolves to the - * clicked button's `value`. Add new HITL components here and re-export them. + * The backend MCP write interceptor emits `confirm_write`. Its `on_interrupt` + * event posts `ConfirmWrite`; the card's buttons call `thread.resume(...)`. */ export { ConfirmWrite } from "./confirm-write.js"; -export type { ConfirmWriteProps } from "./confirm-write.js"; -export { confirmWriteTool, confirmWriteSchema } from "./confirm-write-tool.js"; diff --git a/app/index.ts b/app/index.ts index c2758a0..0cf4e44 100644 --- a/app/index.ts +++ b/app/index.ts @@ -1,308 +1,26 @@ -/** - * The bot _application_ — user-land code, not SDK code. The companion - * `runtime.ts` holds the AG-UI agent backend (a CopilotKit `BuiltInAgent` - * wired to the Linear + Notion MCP servers); this directory holds everything - * that runs on the chat-platform side of the bot for this deployment. - * - * MULTI-PLATFORM: this single app drives Slack, Discord, Telegram, and/or - * WhatsApp from one process. `@copilotkit/channels`'s `createBot` accepts an array - * of adapters and starts them all, so we include each platform's adapter only - * when its secrets are present. Drop in `SLACK_*` to run Slack, `DISCORD_*` for - * Discord, `TELEGRAM_BOT_TOKEN` for Telegram, `WHATSAPP_*` for WhatsApp — or any - * combination to run them at once. The rest of `app/` (tools, components, HITL, - * rendering) is platform-agnostic and shared verbatim. - * - * Defaults are not auto-applied — you spread them explicitly. That's - * deliberate: there's no hidden behavior, and the canonical pattern is right - * here in the file you copy from to start a new bot. - */ -import "dotenv/config"; -import { createBot } from "@copilotkit/channels"; -import type { PlatformAdapter, BotTool, ContextEntry } from "@copilotkit/channels"; -import { - slack, - defaultSlackTools, - defaultSlackContext, - SanitizingHttpAgent, -} from "@copilotkit/channels-slack"; -import { - discord, - defaultDiscordTools, - defaultDiscordContext, -} from "@copilotkit/channels-discord"; -import { - telegram, - defaultTelegramTools, - defaultTelegramContext, -} from "@copilotkit/channels-telegram"; -import { - whatsapp, - defaultWhatsAppTools, - defaultWhatsAppContext, -} from "@copilotkit/channels-whatsapp"; -import { appTools } from "./tools/index.js"; -import { appContext } from "./context/app-context.js"; -import { appCommands } from "./commands/index.js"; -import { senderContext } from "./sender-context.js"; -import { fileIssueSubmit, FILE_ISSUE_CALLBACK } from "./modals/file-issue.js"; -import { closeBrowser } from "./render/browser.js"; - -const required = (name: string): string => { - const v = process.env[name]; - if (!v) { - console.error(`Missing required env var: ${name}`); - process.exit(1); - } - return v; -}; - -/** True only when every named env var is set and non-empty. */ -const have = (...names: string[]): boolean => - names.every((n) => Boolean(process.env[n])); - -async function main() { - const agentUrl = required("AGENT_URL"); - const agentHeaders = process.env.AGENT_AUTH_HEADER - ? { Authorization: process.env.AGENT_AUTH_HEADER } - : undefined; - - // Build the platform list from whichever secrets are present. Each adapter - // contributes its own built-in tools (e.g. `lookup_slack_user` / - // `lookup_discord_user` / `lookup_telegram_user`) and context (tagging + - // formatting guidance), added only when that platform is active so the model - // isn't handed a different platform's conventions. - const adapters: PlatformAdapter[] = []; - const tools: BotTool[] = [...appTools]; - const context: ContextEntry[] = [...appContext]; - - if (have("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN")) { - adapters.push( - slack({ - botToken: required("SLACK_BOT_TOKEN"), - appToken: required("SLACK_APP_TOKEN"), - // Don't surface tool-call progress in the UI (no task_update timeline, - // `:wrench:` rows, or pane "is using `tool`…" status). Tools still run; - // only the display is hidden. - showToolStatus: false, - // KiteBot keeps DMs conversational and responds to explicit app mentions - // in channels/threads. Plain channel thread replies stay quiet unless - // they mention KiteBot again. - respondTo: { - directMessages: true, - appMentions: { reply: "thread" }, - threadReplies: "mentionsOnly", - }, - // Assistant-pane behavior is ON by default; this just customizes it. - // The greeting + chips show when a user opens the pane (matching the - // app manifest's `assistant_view`); native streaming + status need no - // config. Pass `assistant: false` / `streaming: "legacy"` to opt out. - assistant: { - greeting: "Hi! I can triage issues, search docs, and more.", - suggestedPrompts: [ - { - title: "Triage my open issues", - message: "Triage my open issues", - }, - { - title: "What shipped this week?", - message: "Summarize what shipped this week", - }, - ], - }, - }), - ); - tools.push(...defaultSlackTools); - context.push(...defaultSlackContext); - } - - if (have("DISCORD_BOT_TOKEN", "DISCORD_APP_ID")) { - adapters.push( - discord({ - botToken: required("DISCORD_BOT_TOKEN"), - appId: required("DISCORD_APP_ID"), - // Optional: register slash commands to one guild instantly during dev - // (global commands can take up to ~1h to propagate). Omit in prod. - guildId: process.env.DISCORD_GUILD_ID, - }), - ); - tools.push(...defaultDiscordTools); - context.push(...defaultDiscordContext); - } - - if (have("TELEGRAM_BOT_TOKEN")) { - // Telegram long-polls by default (no public URL / webhook setup needed). - // No greeting/suggestedPrompts: Telegram has no assistant-pane surface. - adapters.push(telegram({ token: required("TELEGRAM_BOT_TOKEN") })); - tools.push(...defaultTelegramTools); - context.push(...defaultTelegramContext); - } - - if ( - have( - "WHATSAPP_ACCESS_TOKEN", - "WHATSAPP_PHONE_NUMBER_ID", - "WHATSAPP_APP_SECRET", - "WHATSAPP_VERIFY_TOKEN", - ) - ) { - // Unlike Slack/Discord (outbound), WhatsApp adds an INBOUND webhook HTTP - // server. It listens on Railway's injected `$PORT` (the public domain - // routes there); locally it defaults to 3000. Fail loud on a malformed - // PORT rather than letting `Number("abc")` → NaN reach `server.listen()`. - const port = process.env.PORT ? Number(process.env.PORT) : 3000; - if (!Number.isInteger(port) || port < 1 || port > 65535) { - console.error( - `Invalid PORT: "${process.env.PORT}" is not a valid port number (must be an integer between 1 and 65535)`, - ); - process.exit(1); - } - adapters.push( - whatsapp({ - accessToken: required("WHATSAPP_ACCESS_TOKEN"), - phoneNumberId: required("WHATSAPP_PHONE_NUMBER_ID"), - appSecret: required("WHATSAPP_APP_SECRET"), - verifyToken: required("WHATSAPP_VERIFY_TOKEN"), - port, - path: process.env.WHATSAPP_PATH ?? "/webhook", - }), - ); - tools.push(...defaultWhatsAppTools); - context.push(...defaultWhatsAppContext); - } - - if (adapters.length === 0) { - console.error( - "No platform secrets found. Set SLACK_BOT_TOKEN + SLACK_APP_TOKEN, " + - "DISCORD_BOT_TOKEN + DISCORD_APP_ID, TELEGRAM_BOT_TOKEN, " + - "and/or the WHATSAPP_* vars (see README).", - ); - process.exit(1); - } - - const bot = createBot({ - adapters, - // One AG-UI agent per conversation. The backend is a CopilotKit - // `BuiltInAgent` (CopilotSseRuntime), which does NOT require a UUID-format - // threadId, so the raw conversation thread id is fine. - // `SanitizingHttpAgent` is a lenient superset of `HttpAgent` (tolerates a - // null `parentMessageId` from `@ag-ui/langgraph`); it's safe for every - // platform, so one factory covers Slack, Discord, Telegram, and WhatsApp alike. - agent: (threadId) => { - const a = new SanitizingHttpAgent({ - url: agentUrl, - headers: agentHeaders, - }); - a.threadId = threadId; - return a; - }, - // `appTools` adds this bot's tools (read_thread, render_*, issue/page - // cards); the per-platform `default*Tools` add `lookup_*_user`. All are - // plain `BotTool`s — the active adapter supplies `thread`/`message`/`user` - // per call. `default*Context` ships tagging/formatting/thread-model - // guidance; `appContext` adds identity + triage policy. - tools, - context, - // Slash commands (`/agent`, `/triage`, `/preview`, `/file-issue`). For Slack - // each must ALSO be declared in the app config (or paste the manifest); Discord - // and Telegram register them up front. The engine routes by name; adapters that - // can't take commands ignore them. - commands: appCommands, - }); - - // The turn handler. Each adapter pre-filters ingress to the turns this bot - // should answer — DMs, explicit mentions, and every WhatsApp message. - // createBot is mention-preferred: a single handler covers them across every - // active platform. `senderContext` names the - // requesting user per `thread.platform`, so the label is correct on whichever - // surface the turn arrived from. Additional feature demos below add their own - // handlers for modal submissions and assistant-pane thread starts. Wrap the - // turn so a failed run (agent backend down, network/auth error) is logged - // and surfaced to the user instead of crashing the process or vanishing - // silently. - bot.onMention(async ({ thread, message }) => { - try { - await thread.runAgent({ - context: senderContext(message.user, thread.platform), - }); - } catch (err) { - console.error("[bot] agent run failed", err); - await thread - .post("Sorry — I hit an error handling that. Please try again.") - .catch((postErr: unknown) => - console.error("[bot] failed to post agent error", postErr), - ); - } - }); - - // Modal demo (cont.) — handle the /file-issue submission. The handler lives in - // `modals/file-issue.tsx` (extracted + unit-tested): it validates, then - // fire-and-forgets the agent run so the submission can be ack'd within Slack's - // ~3s view_submission deadline (awaiting the run blows it → Slack double-files). - bot.onModalSubmit(FILE_ISSUE_CALLBACK, fileIssueSubmit); - - // Slack-only nicety: personalize the assistant-pane prompt chips for the - // opener. Harmless elsewhere — `onThreadStarted` only fires from adapters - // that emit it (Discord/Telegram/WhatsApp have no assistant pane), and - // platforms without suggested-prompt support no-op. - bot.onThreadStarted(async ({ thread, user }) => { - if (!user?.name) return; - try { - await thread.setSuggestedPrompts([ - { - title: `Triage ${user.name}'s issues`, - message: "Triage my open issues", - }, - { - title: "What shipped this week?", - message: "Summarize what shipped this week", - }, - ]); - } catch (err) { - console.error("[bot] onThreadStarted failed", err); - } - }); - - await bot.start(); - console.log( - `[bot] started on: ${adapters.map((a) => a.platform).join(", ")}`, - ); - - const shutdown = async (signal: string) => { - console.log(`\n[bot] received ${signal}, stopping…`); - let exitCode = 0; - try { - await bot.stop(); - } catch (err) { - console.error("[bot] error stopping bot", err); - exitCode = 1; - } - // Tear down the shared headless browser used for chart/diagram rendering. - await closeBrowser().catch((err: unknown) => - console.error("[bot] browser cleanup failed (continuing shutdown)", err), - ); - process.exit(exitCode); - }; - const runShutdown = (signal: string): void => { - shutdown(signal).catch((err: unknown) => { - console.error(`[bot] fatal during ${signal} shutdown`, err); - process.exit(1); +import { SanitizingHttpAgent } from "@copilotkit/channels/slack"; +import { createOpenTagChannel } from "./channel.js"; +import { readEnvironment, type AppEnvironment } from "./env.js"; +import { createOpenTagRuntime } from "./runtime-host.js"; + +export function createOpenTagApplication( + environment: AppEnvironment = readEnvironment(), +) { + // Channels agents are stateful, so each conversation gets its own SDK agent. + const agent = (threadId: string) => { + const instance = new SanitizingHttpAgent({ + url: environment.agentUrl, + headers: environment.agentAuthHeader + ? { Authorization: environment.agentAuthHeader } + : undefined, }); + instance.threadId = threadId; + return instance; }; - process.on("SIGINT", () => runShutdown("SIGINT")); - process.on("SIGTERM", () => runShutdown("SIGTERM")); -} -// Fail loud, not silent: surface any stray async error (e.g. a throw deep in an -// interaction/callback path) instead of letting it kill the process with no -// log. Log and keep running — one bad turn shouldn't take the bot down. -process.on("unhandledRejection", (reason) => { - console.error("[bot] unhandledRejection:", reason); -}); -process.on("uncaughtException", (err) => { - console.error("[bot] uncaughtException:", err); -}); + // Intelligence owns the Slack and Teams adapters for this logical Channel. + const channels = [createOpenTagChannel(environment.channelName, agent)]; + const runtimeHost = createOpenTagRuntime({ environment, channels }); -main().catch((err) => { - console.error("[bot] fatal", err); - process.exit(1); -}); + return { channels, environment, ...runtimeHost }; +} diff --git a/app/interrupt.test.ts b/app/interrupt.test.ts new file mode 100644 index 0000000..2b7ade9 --- /dev/null +++ b/app/interrupt.test.ts @@ -0,0 +1,78 @@ +import { EventType } from "@ag-ui/client"; +import { createRunRenderer } from "@copilotkit/channels/slack/render"; +import { describe, expect, it, vi } from "vitest"; +import { parseConfirmWriteInterrupt } from "./interrupt.js"; + +const realEnvelope = { + __copilotkit_interrupt_value__: { + action: "confirm_write", + args: { + action: "Create Linear issue", + detail: "CPK-9: Checkout 500s", + }, + }, + __copilotkit_messages__: [ + { + content: "", + type: "ai", + tool_calls: [ + { + id: "tool-confirm-write", + name: "confirm_write", + args: { + action: "Create Linear issue", + detail: "CPK-9: Checkout 500s", + }, + type: "tool_call", + }, + ], + }, + ], +}; + +describe("parseConfirmWriteInterrupt", () => { + it("parses ag_ui_langgraph's JSON-stringified interrupt envelope", () => { + expect(parseConfirmWriteInterrupt(JSON.stringify(realEnvelope))).toEqual( + realEnvelope.__copilotkit_interrupt_value__, + ); + }); + + it("parses the object produced by the canary Slack renderer", () => { + const renderer = createRunRenderer({ + transport: { + setStatus: vi.fn(async () => undefined), + postMessage: vi.fn(async () => ({ ts: "1.0" })), + updateMessage: vi.fn(async () => undefined), + }, + target: { channel: "C1", threadTs: "1.0" }, + }); + renderer.subscriber.onCustomEvent?.({ + event: { + type: EventType.CUSTOM, + name: "on_interrupt", + value: JSON.stringify(realEnvelope), + }, + } as never); + + const renderedPayload = renderer.getPendingInterrupt()?.value; + expect(renderedPayload).toEqual(realEnvelope); + expect(parseConfirmWriteInterrupt(renderedPayload)).toEqual( + realEnvelope.__copilotkit_interrupt_value__, + ); + }); + + it("rejects malformed envelopes and other actions", () => { + expect(() => + parseConfirmWriteInterrupt( + JSON.stringify({ + ...realEnvelope, + __copilotkit_interrupt_value__: { + action: "delete_without_confirmation", + args: { action: "Delete everything" }, + }, + }), + ), + ).toThrow(/confirm_write/); + expect(() => parseConfirmWriteInterrupt("{broken")).toThrow(); + }); +}); diff --git a/app/interrupt.ts b/app/interrupt.ts new file mode 100644 index 0000000..25f18f0 --- /dev/null +++ b/app/interrupt.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +const confirmWriteInterruptSchema = z.object({ + __copilotkit_interrupt_value__: z.object({ + action: z.literal("confirm_write"), + args: z.object({ + action: z.string().min(1), + detail: z.string().nullish(), + }), + }), + __copilotkit_messages__: z.array(z.unknown()), +}); + +export function parseConfirmWriteInterrupt(payload: unknown) { + const normalized = + typeof payload === "string" ? JSON.parse(payload) : payload; + return confirmWriteInterruptSchema.parse(normalized) + .__copilotkit_interrupt_value__; +} diff --git a/app/managed.test.ts b/app/managed.test.ts deleted file mode 100644 index 6d92d2e..0000000 --- a/app/managed.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, it, expect } from "vitest"; -import type { AgentContentPart } from "@copilotkit/channels-ui"; -import { - createKiteBot, - promptFromMessage, - buildAgentHeaders, - parseProjectId, -} from "./managed.js"; - -describe("createKiteBot", () => { - it("declares the kitebot channel name", () => { - const bot = createKiteBot({ agentUrl: "http://localhost:8200" }); - expect(bot.name).toBe("kitebot"); - }); - - it("registers the app's slash commands on the bot", () => { - const bot = createKiteBot({ agentUrl: "http://localhost:8200" }); - // createBot normalizes slash-command names (hyphens -> underscores): - // app/commands declares "file-issue"; commandNames reports "file_issue". - expect(bot.commandNames).toContain("file_issue"); - }); - - it("honors a custom channel name", () => { - const bot = createKiteBot({ - agentUrl: "http://localhost:8200", - channelName: "kite-bot", - }); - expect(bot.name).toBe("kite-bot"); - }); -}); - -describe("promptFromMessage", () => { - it("returns contentParts when present", () => { - const parts: AgentContentPart[] = [{ type: "text", text: "hi" }]; - expect( - promptFromMessage({ contentParts: parts, text: "hi" }), - ).toBe(parts); - }); - - it("falls back to text when contentParts is empty", () => { - expect(promptFromMessage({ contentParts: [], text: "hello" })).toBe( - "hello", - ); - }); - - it("falls back to text when contentParts is absent", () => { - expect(promptFromMessage({ text: "hello" })).toBe("hello"); - }); -}); - -describe("buildAgentHeaders", () => { - it("returns undefined when no auth header is given", () => { - expect(buildAgentHeaders(undefined)).toBeUndefined(); - }); - - it("wraps the auth header value in an Authorization object", () => { - expect(buildAgentHeaders("Bearer abc123")).toEqual({ - Authorization: "Bearer abc123", - }); - }); -}); - -describe("parseProjectId", () => { - it("parses a valid positive integer string", () => { - expect(parseProjectId("42")).toBe(42); - }); - - it("throws on \"0\"", () => { - expect(() => parseProjectId("0")).toThrow( - /Invalid INTELLIGENCE_PROJECT_ID/, - ); - }); - - it("throws on a non-numeric string", () => { - expect(() => parseProjectId("abc")).toThrow( - /Invalid INTELLIGENCE_PROJECT_ID/, - ); - }); - - it("throws when undefined", () => { - expect(() => parseProjectId(undefined)).toThrow( - /Invalid INTELLIGENCE_PROJECT_ID/, - ); - }); - - it("throws on a negative integer string", () => { - expect(() => parseProjectId("-5")).toThrow( - /Invalid INTELLIGENCE_PROJECT_ID/, - ); - }); - - it("throws on a non-integer float string", () => { - expect(() => parseProjectId("1.5")).toThrow( - /Invalid INTELLIGENCE_PROJECT_ID/, - ); - }); - - it("throws on an empty string", () => { - expect(() => parseProjectId("")).toThrow( - /Invalid INTELLIGENCE_PROJECT_ID/, - ); - }); - - it("throws on a whitespace-only string", () => { - expect(() => parseProjectId(" ")).toThrow( - /Invalid INTELLIGENCE_PROJECT_ID/, - ); - }); - - // Number("1e3") === 1000, Number("0x10") === 16, and Number("0b11") === 3 - // all pass Number.isInteger(n) && n > 0 — so a naive Number(raw) parse - // would silently accept a typo'd env var as the wrong project id instead - // of throwing. Only plain decimal-digit strings are valid. - it("throws on exponential notation", () => { - expect(() => parseProjectId("1e3")).toThrow( - /Invalid INTELLIGENCE_PROJECT_ID/, - ); - }); - - it("throws on hexadecimal notation", () => { - expect(() => parseProjectId("0x10")).toThrow( - /Invalid INTELLIGENCE_PROJECT_ID/, - ); - }); - - it("throws on binary notation", () => { - expect(() => parseProjectId("0b11")).toThrow( - /Invalid INTELLIGENCE_PROJECT_ID/, - ); - }); - - it("throws on a decimal float string", () => { - expect(() => parseProjectId("12.5")).toThrow( - /Invalid INTELLIGENCE_PROJECT_ID/, - ); - }); -}); diff --git a/app/managed.ts b/app/managed.ts deleted file mode 100644 index e0af66f..0000000 --- a/app/managed.ts +++ /dev/null @@ -1,244 +0,0 @@ -/** - * Intelligence channel host for KiteBot — the "managed" run mode. - * - * Unlike app/index.ts (self-hosted: holds Slack tokens, talks to Slack - * directly), this process holds NO platform credentials. It runs the SAME bot - * over the CopilotKit Intelligence Realtime Gateway: it declares one channel - * ("kitebot") to the gateway and streams render frames back. Intelligence owns - * the Slack edge (signed ingress + Connector Outbox egress). - * - * The bot's brain is an external AG-UI agent reached over HTTP at AGENT_URL — - * for now the runtime.ts triage backend; in Phase 2 a LangGraph deep agent. - * intelligenceAdapter is exclusive, so the bot is created WITHOUT a native - * adapter and startChannelsOverRealtimeGateway attaches the channel transport. - * - * Run: `pnpm channel` with INTELLIGENCE_* + AGENT_URL set (see .env.example). - */ -import "dotenv/config"; -import { randomUUID } from "node:crypto"; -import { createBot } from "@copilotkit/channels"; -import type { Bot } from "@copilotkit/channels"; -import type { AgentContentPart } from "@copilotkit/channels-ui"; -import { - SanitizingHttpAgent, - defaultSlackTools, - defaultSlackContext, -} from "@copilotkit/channels-slack"; -import { startChannelsOverRealtimeGateway } from "@copilotkit/channels-intelligence"; -import { appTools } from "./tools/index.js"; -import { appContext } from "./context/app-context.js"; -import { appCommands } from "./commands/index.js"; -import { senderContext } from "./sender-context.js"; -import { fileIssueSubmit, FILE_ISSUE_CALLBACK } from "./modals/file-issue.js"; -import { closeBrowser } from "./render/browser.js"; - -const required = (name: string): string => { - const v = process.env[name]; - if (!v) { - console.error(`Missing required env var: ${name}`); - process.exit(1); - } - return v; -}; - -export interface CreateKiteBotOptions { - /** AG-UI agent endpoint the bot's HttpAgent posts to. */ - agentUrl: string; - /** Optional Authorization header value forwarded to the agent. */ - agentAuthHeader?: string; - /** Intelligence channel name (lowercase kebab). Defaults to "kitebot". */ - channelName?: string; -} - -/** - * Pick the prompt to send to the agent for the current turn. Managed history - * does NOT include the in-flight turn, so the current message is always - * passed explicitly — preferring multimodal parts when present. - */ -export function promptFromMessage(message: { - contentParts?: AgentContentPart[]; - text: string; -}): string | AgentContentPart[] { - return message.contentParts?.length ? message.contentParts : message.text; -} - -/** Build the Authorization header object forwarded to the agent, if any. */ -export function buildAgentHeaders( - authHeader?: string, -): { Authorization: string } | undefined { - return authHeader ? { Authorization: authHeader } : undefined; -} - -/** - * Parse and validate INTELLIGENCE_PROJECT_ID, throwing on any invalid value. - * - * Only plain decimal-digit strings (e.g. "42") are accepted. `Number(raw)` - * alone would also accept hex ("0x10"), exponential ("1e3"), and binary - * ("0b11") notation — all of which pass `Number.isInteger(n) && n > 0` — - * so a typo'd env var could silently resolve to the wrong project. The - * `/^\d+$/` guard rejects anything that isn't a bare non-negative integer - * before handing it to `Number(...)`. - */ -export function parseProjectId(raw: string | undefined): number { - if (raw === undefined || !/^\d+$/.test(raw.trim())) { - throw new Error(`Invalid INTELLIGENCE_PROJECT_ID: "${raw}"`); - } - const n = Number(raw.trim()); - if (!Number.isInteger(n) || n <= 0) { - throw new Error(`Invalid INTELLIGENCE_PROJECT_ID: "${raw}"`); - } - return n; -} - -/** - * Build the KiteBot bot: same tools/context/commands/handlers as the native - * bot, minus any platform adapter (the intelligenceAdapter is attached at - * activation by startChannelsOverRealtimeGateway). Pure — no network, no env - * reads — so it is unit-testable. - */ -export function createKiteBot(opts: CreateKiteBotOptions): Bot { - const channelName = opts.channelName ?? "kitebot"; - const agentHeaders = buildAgentHeaders(opts.agentAuthHeader); - - const bot = createBot({ - name: channelName, - agent: (threadId: string) => { - const a = new SanitizingHttpAgent({ - url: opts.agentUrl, - headers: agentHeaders, - }); - a.threadId = threadId; - return a; - }, - tools: [...appTools, ...defaultSlackTools], - context: [...appContext, ...defaultSlackContext], - commands: appCommands, - }); - - // Managed history does NOT include the in-flight turn, so pass the current - // message explicitly as `prompt` (prefer multimodal parts). Mirrors the - // native bot's onMention otherwise. - bot.onMention(async ({ thread, message }) => { - try { - await thread.runAgent({ - prompt: promptFromMessage(message), - context: senderContext(message.user, thread.platform), - }); - } catch (err) { - console.error("[channel] agent run failed", err); - await thread - .post("Sorry — I hit an error handling that. Please try again.") - .catch((postErr: unknown) => - console.error("[channel] failed to post agent error", postErr), - ); - } - }); - - bot.onModalSubmit(FILE_ISSUE_CALLBACK, fileIssueSubmit); - - bot.onThreadStarted(async ({ thread, user }) => { - if (!user?.name) return; - try { - await thread.setSuggestedPrompts([ - { - title: `Triage ${user.name}'s issues`, - message: "Triage my open issues", - }, - { - title: "What shipped this week?", - message: "Summarize what shipped this week", - }, - ]); - } catch (err) { - console.error("[channel] onThreadStarted failed", err); - } - }); - - return bot; -} - -async function main() { - const channelName = process.env.INTELLIGENCE_CHANNEL_NAME ?? "kitebot"; - - const bot = createKiteBot({ - agentUrl: required("AGENT_URL"), - agentAuthHeader: process.env.AGENT_AUTH_HEADER, - channelName, - }); - - let projectId: number; - try { - projectId = parseProjectId(process.env.INTELLIGENCE_PROJECT_ID); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - console.error(msg); - process.exit(1); - } - - const handle = await startChannelsOverRealtimeGateway([bot], { - wsUrl: required("INTELLIGENCE_GATEWAY_WS_URL"), - apiKey: required("INTELLIGENCE_API_KEY"), - scope: { - organizationId: required("INTELLIGENCE_ORG_ID"), - projectId, - channelId: required("INTELLIGENCE_CHANNEL_ID"), - channelName, - }, - runtimeInstanceId: - process.env.INTELLIGENCE_RUNTIME_INSTANCE_ID ?? - `rti_${randomUUID().replace(/-/g, "")}`, - adapter: "slack", - // meta can contain message content, so it is gated behind CHANNEL_DEBUG - // and omitted from stdout/logs by default. - log: (msg, meta) => - console.log( - `[channel] ${msg}`, - process.env.CHANNEL_DEBUG ? (meta ?? "") : "", - ), - }); - console.log( - `[channel] KiteBot channel "${channelName}" started over Realtime Gateway on project ${projectId}`, - ); - - const shutdown = async (signal: string) => { - console.log(`\n[channel] received ${signal}, stopping…`); - let exitCode = 0; - try { - await handle.stop(); - } catch (err) { - console.error("[channel] error stopping channel runtime", err); - exitCode = 1; - } - await closeBrowser().catch((err: unknown) => - console.error( - "[channel] browser cleanup failed (continuing shutdown)", - err, - ), - ); - process.exit(exitCode); - }; - const runShutdown = (signal: string): void => { - shutdown(signal).catch((err: unknown) => { - console.error(`[channel] fatal during ${signal} shutdown`, err); - process.exit(1); - }); - }; - process.on("SIGINT", () => runShutdown("SIGINT")); - process.on("SIGTERM", () => runShutdown("SIGTERM")); -} - -process.on("unhandledRejection", (reason) => { - console.error("[channel] unhandledRejection:", reason); -}); -process.on("uncaughtException", (err) => { - console.error("[channel] uncaughtException:", err); -}); - -// Only start the gateway connection when executed directly (`pnpm channel`), -// not when the test imports `createKiteBot`. -if (process.argv[1] && process.argv[1].endsWith("managed.ts")) { - main().catch((err: unknown) => { - console.error("[channel] fatal: failed to start channel runtime", err); - process.exit(1); - }); -} diff --git a/app/modals/__tests__/file-issue.test.tsx b/app/modals/__tests__/file-issue.test.tsx index 066073a..f06b20c 100644 --- a/app/modals/__tests__/file-issue.test.tsx +++ b/app/modals/__tests__/file-issue.test.tsx @@ -1,19 +1,21 @@ import { describe, it, expect, vi } from "vitest"; -import { renderToIR } from "@copilotkit/channels-ui"; +import { renderToIR, type ChannelNode } from "@copilotkit/channels"; +import { + defaultSlackContext, + defaultSlackTools, +} from "@copilotkit/channels/slack"; import { FileIssueModal, fileIssueSubmit, issueFromValues, FILE_ISSUE_CALLBACK, } from "../file-issue.js"; -import type { BotNode } from "@copilotkit/channels-ui"; -import { senderContext } from "../../sender-context.js"; -function tags(node: BotNode | unknown, acc: string[] = []): string[] { +function tags(node: ChannelNode | unknown, acc: string[] = []): string[] { if (!node || typeof node !== "object") return acc; - const n = node as BotNode; + const n = node as ChannelNode; if (typeof n.type === "string") acc.push(n.type); - for (const c of (n.props?.children as BotNode[] | undefined) ?? []) { + for (const c of (n.props?.children as ChannelNode[] | undefined) ?? []) { tags(c, acc); } return acc; @@ -21,18 +23,23 @@ function tags(node: BotNode | unknown, acc: string[] = []): string[] { describe("FileIssueModal", () => { it("rich variant (Slack) includes selects and radios", () => { - const ir = renderToIR(FileIssueModal({ rich: true })); + const ir = renderToIR( + FileIssueModal({ rich: true, sourcePlatform: "slack" }), + ); const root = ir[0]!; const t = tags(root); expect(root.type).toBe("modal"); expect(root.props.callbackId).toBe(FILE_ISSUE_CALLBACK); + expect(root.props.privateMetadata).toBe("slack"); expect(t).toContain("modal_text_input"); expect(t).toContain("modal_select"); expect(t).toContain("modal_radio"); }); - it("text-only variant (Discord) drops selects/radios, ≤5 inputs", () => { - const ir = renderToIR(FileIssueModal({ rich: false })); + it("text-only variant drops selects and radios", () => { + const ir = renderToIR( + FileIssueModal({ rich: false, sourcePlatform: "teams" }), + ); const root = ir[0]!; const t = tags(root); expect(t).not.toContain("modal_select"); @@ -58,7 +65,7 @@ describe("issueFromValues", () => { }); }); - it("applies defaults when controls were absent (Discord text-only)", () => { + it("applies defaults when optional controls were absent", () => { expect(issueFromValues({ title: "X", description: "Y" })).toEqual({ title: "X", description: "Y", @@ -115,12 +122,12 @@ describe("fileIssueSubmit", () => { ).resolves.toBeUndefined(); }); - it("runs the agent with the interpolated prompt and sender context on a valid submit", async () => { + it("uses the command's managed Slack origin for defaults and sender context on submit", async () => { const runAgent = vi.fn( - (_input?: { prompt: string; context: unknown }) => + (_input?: { prompt: string; tools?: unknown; context: unknown }) => new Promise<void>(() => {}), ); - const thread = { runAgent, platform: "slack" }; + const thread = { runAgent, platform: "intelligence" }; const user = { id: "U1", name: "Ada Lovelace", email: "ada@example.com" }; await fileIssueSubmit({ values: { @@ -131,6 +138,7 @@ describe("fileIssueSubmit", () => { }, thread, user, + privateMetadata: "slack", } as never); expect(runAgent).toHaveBeenCalledTimes(1); @@ -139,10 +147,22 @@ describe("fileIssueSubmit", () => { expect(call.prompt).toContain("- Type: bug"); expect(call.prompt).toContain("- Priority: High"); expect(call.prompt).toContain("- Description: 500 on submit"); - expect(call.context).toEqual(senderContext(user, thread.platform)); + expect(call.prompt).toContain("confirm_write"); + expect(call.prompt).not.toContain("already confirmed"); + expect(call.tools).toEqual(defaultSlackTools); + expect(call.context).toEqual([ + ...defaultSlackContext, + { + description: "Requesting slack user", + value: "Ada Lovelace <ada@example.com> (slack id U1)", + }, + ]); }); - it("posts a failure message to the thread when runAgent rejects", async () => { + it("posts and reports a recoverable failure when runAgent rejects", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); const post = vi.fn().mockResolvedValue({ id: "m1" }); const thread = { runAgent: vi.fn(() => Promise.reject(new Error("LLM timeout"))), @@ -158,5 +178,51 @@ describe("fileIssueSubmit", () => { expect(post).toHaveBeenCalledWith( expect.stringMatching(/couldn.t file|try again/i), ); + expect(consoleError).toHaveBeenCalledWith( + "[channel] recoverable error", + expect.objectContaining({ + error: expect.any(Error), + context: { + operation: "file_issue_modal_run_agent", + recovery: "posted_user_facing_error", + }, + timestamp: expect.any(String), + }), + ); + consoleError.mockRestore(); + }); + + it("reports a structured terminal error when the apology also fails", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const runError = new Error("LLM timeout"); + const postError = new Error("Slack unavailable"); + const thread = { + runAgent: vi.fn(() => Promise.reject(runError)), + post: vi.fn(() => Promise.reject(postError)), + }; + + await fileIssueSubmit({ + values: { title: "T", description: "D", type: "bug", priority: "High" }, + thread, + user: { id: "U1" }, + } as never); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(consoleError).toHaveBeenCalledWith( + "[channel] recoverable error", + expect.objectContaining({ + error: expect.objectContaining({ + errors: [runError, postError], + }), + context: { + operation: "file_issue_modal_run_agent", + recovery: "terminal_failure", + }, + timestamp: expect.any(String), + }), + ); + consoleError.mockRestore(); }); }); diff --git a/app/modals/file-issue.tsx b/app/modals/file-issue.tsx index cebf48a..8764008 100644 --- a/app/modals/file-issue.tsx +++ b/app/modals/file-issue.tsx @@ -2,12 +2,8 @@ * Modal demo — a structured "file a Linear issue" form. Beats parsing free text: * the fields come back typed and validated by the platform. * - * Per-platform honesty (the modal vocabulary degrades, it never lies): - * - Slack → the rich form: text inputs + team/priority dropdowns + a type radio. - * - Discord→ text-only, ≤5 inputs (discord.js modals take only text inputs), so - * the dropdowns/radio drop out and `issueFromValues` applies defaults. - * - Telegram→ no modals at all; the `/file-issue` command (Task 5) detects the - * missing trigger and falls back to a conversational flow. + * Slack receives the rich form. Platforms without modal support are detected + * by the `/file-issue` command and fall back to the same conversational flow. */ import { Modal, @@ -15,10 +11,12 @@ import { ModalSelect, ModalSelectOption, RadioButtons, -} from "@copilotkit/channels-ui"; -import type { ModalView } from "@copilotkit/channels-ui"; -import type { ModalSubmitHandler } from "@copilotkit/channels"; -import { senderContext } from "../sender-context.js"; +} from "@copilotkit/channels"; +import type { ModalSubmitHandler, ModalView } from "@copilotkit/channels"; +import { + platformRunInput, + reportRecoverableError, +} from "../channel-helpers.js"; export const FILE_ISSUE_CALLBACK = "file_issue"; @@ -30,7 +28,7 @@ export function issueFromValues(values: Record<string, unknown>) { return { title: str(values.title), description: str(values.description), - type: str(values.type, "bug"), // absent on Discord text-only → default + type: str(values.type, "bug"), priority: str(values.priority, "Medium"), }; } @@ -39,7 +37,7 @@ export function issueFromValues(values: Record<string, unknown>) { * Handle a `/file-issue` modal submission: validate, then file via the agent * (Linear MCP) so the existing confirm-before-write + filed-card flow is reused. * Returning `{ errors }` keeps the modal open with a field error (Slack); on - * text-only Discord, `type`/`priority` default in. + * submissions without `type`/`priority` use safe defaults. * * CRITICAL — Slack's view_submission ack deadline (~3s): the adapter awaits this * handler before it can `ack()` the submission, and Slack expects that ack within @@ -54,6 +52,7 @@ export const fileIssueSubmit: ModalSubmitHandler = async ({ values, thread, user, + privateMetadata, }) => { const issue = issueFromValues(values); if (!issue.title.trim()) { @@ -65,32 +64,57 @@ export const fileIssueSubmit: ModalSubmitHandler = async ({ void thread .runAgent({ prompt: - `File a Linear issue now (this was already confirmed via the form):\n` + + `Draft this Linear issue from the submitted form:\n` + `- Title: ${issue.title}\n- Type: ${issue.type}\n- Priority: ${issue.priority}\n` + `- Description: ${issue.description || "(none)"}\n` + - `After filing, show the issue card.`, - context: senderContext(user, thread.platform), + `Call the Linear create tool with these exact details. Its protected ` + + `MCP handler emits confirm_write and files only after approval. Then ` + + `show the issue card.`, + ...platformRunInput(privateMetadata ?? thread.platform, user), }) - .catch((err) => { - console.error("[bot] file-issue modal run failed", err); - void thread - .post("Sorry — I couldn't file that issue. Please try again.") - .catch((postErr: unknown) => - console.error("[file-issue] failed to post error", postErr), + .catch(async (error) => { + try { + await thread.post( + "Sorry — I couldn't file that issue. Please try again.", + ); + } catch (postError) { + throw new AggregateError( + [error, postError], + "The file-issue agent run and its error reply both failed", ); + } + + reportRecoverableError(error, { + operation: "file_issue_modal_run_agent", + recovery: "posted_user_facing_error", + }); + }) + .catch((error) => { + // This is the terminal observer for the intentionally detached promise. + reportRecoverableError(error, { + operation: "file_issue_modal_run_agent", + recovery: "terminal_failure", + }); }); }; /** * The form. `rich` controls whether the structured controls (selects/radio) are - * present; pass `false` on text-only surfaces (Discord). + * present; pass `false` when a future text-only modal surface needs it. */ -export function FileIssueModal({ rich }: { rich: boolean }): ModalView { +export function FileIssueModal({ + rich, + sourcePlatform, +}: { + rich: boolean; + sourcePlatform: string; +}): ModalView { return ( <Modal callbackId={FILE_ISSUE_CALLBACK} title="File an issue" submitLabel="File" + privateMetadata={sourcePlatform} > <TextInput id="title" @@ -120,8 +144,8 @@ export function FileIssueModal({ rich }: { rich: boolean }): ModalView { </RadioButtons> ) : null} </Modal> - // JSX expressions type as `JSX.Element` (= `BotNode`, not `ModalView`) per - // the channels-ui jsx-runtime, so the narrower literal `type: "modal"` needs + // JSX expressions type as `JSX.Element` (= `ChannelNode`, not `ModalView`) per + // the Channels JSX runtime, so the narrower literal `type: "modal"` needs // an explicit assertion even though `Modal` itself returns `ModalView`. ) as ModalView; } diff --git a/app/railway.test.ts b/app/railway.test.ts new file mode 100644 index 0000000..6a5c5e9 --- /dev/null +++ b/app/railway.test.ts @@ -0,0 +1,121 @@ +import { execFileSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +interface RailwayVariable { + type: "literal" | "preserve"; + value?: string; +} + +interface RailwayResource { + name: string; + source?: { + repo?: string; + branch?: string; + rootDirectory?: string; + }; + build?: { + builder?: string; + buildCommand?: string; + watchPatterns?: string[] | null; + }; + deploy?: { + startCommand?: string; + healthcheckPath?: string; + }; + variables?: Record<string, RailwayVariable>; +} + +function evaluateRailwayGraph(): RailwayResource[] { + const stdout = execFileSync( + process.execPath, + ["node_modules/railway/dist/iac/bin.js"], + { + cwd: process.cwd(), + encoding: "utf8", + }, + ); + const result = JSON.parse(stdout) as { + ok: boolean; + diagnostics: unknown[]; + graph: { resources: RailwayResource[] }; + }; + expect(result.ok).toBe(true); + expect(result.diagnostics).toEqual([]); + return result.graph.resources; +} + +describe("Railway deployment graph", () => { + it("ships the Python agent and Chromium-capable runtime services", () => { + const resources = evaluateRailwayGraph(); + expect(resources.map(({ name }) => name).sort()).toEqual(["agent", "runtime"]); + + const agent = resources.find(({ name }) => name === "agent"); + expect(agent).toMatchObject({ + source: { + repo: "CopilotKit/OpenTag", + branch: "main", + rootDirectory: "agent", + }, + build: { + builder: "RAILPACK", + }, + deploy: { + startCommand: + 'uvicorn main:app --host "" --port ${PORT:-8123}', + healthcheckPath: "/health", + }, + }); + 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({ + source: { + repo: "CopilotKit/OpenTag", + branch: "main", + }, + build: { + builder: "RAILPACK", + buildCommand: "pnpm exec playwright install chromium", + watchPatterns: [], + }, + deploy: { + startCommand: "pnpm runtime", + healthcheckPath: "/api/copilotkit/info", + }, + variables: { + AGENT_URL: { + type: "literal", + value: + "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/", + }, + INTELLIGENCE_API_KEY: { type: "preserve" }, + INTELLIGENCE_API_URL: { + type: "literal", + value: "https://api.intelligence.copilotkit.ai", + }, + INTELLIGENCE_GATEWAY_WS_URL: { + type: "literal", + value: "wss://realtime.intelligence.copilotkit.ai", + }, + INTELLIGENCE_CHANNEL_NAME: { + type: "literal", + value: "open-tag", + }, + PLAYWRIGHT_BROWSERS_PATH: { + type: "literal", + value: "0", + }, + RAILPACK_DEPLOY_APT_PACKAGES: { + type: "literal", + value: expect.stringContaining("libnss3"), + }, + }, + }); + }); +}); diff --git a/app/runtime-host.test.ts b/app/runtime-host.test.ts new file mode 100644 index 0000000..36a1cbf --- /dev/null +++ b/app/runtime-host.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; +import { FakeAdapter, FakeAgent } from "@copilotkit/channels"; +import { createOpenTagChannel } from "./channel.js"; +import type { AppEnvironment } from "./env.js"; +import { + OPENTAG_SERVICE_USER, + createOpenTagRuntime, +} from "./runtime-host.js"; + +const environment: AppEnvironment = { + agentUrl: "http://agent.internal", + agentAuthHeader: "Bearer agent-secret", + intelligenceApiKey: "intelligence-secret", + intelligenceApiUrl: "https://intelligence.example.test", + intelligenceGatewayWsUrl: "wss://gateway.example.test", + channelName: "open-tag", + port: 3000, +}; + +describe("createOpenTagRuntime", () => { + it("registers the Channel in an Intelligence runtime and Node listener", async () => { + const slackAdapter = new FakeAdapter({ platform: "slack" }); + const teamsAdapter = new FakeAdapter({ platform: "teams" }); + const stopSlack = vi.spyOn(slackAdapter, "stop"); + const stopTeams = vi.spyOn(teamsAdapter, "stop"); + const channel = createOpenTagChannel("open-tag", new FakeAgent()); + channel.ɵruntime.addAdapter(slackAdapter); + channel.ɵruntime.addAdapter(teamsAdapter); + const channels = [channel]; + + const { intelligence, listener, runtime } = createOpenTagRuntime({ + environment, + channels, + }); + + expect(runtime.mode).toBe("intelligence"); + expect(runtime.channels).toEqual(channels); + expect(intelligence.ɵgetApiUrl()).toBe(environment.intelligenceApiUrl); + expect(intelligence.ɵgetClientWsUrl()).toContain( + "gateway.example.test", + ); + expect( + await runtime.identifyUser?.(new Request("http://localhost")), + ).toEqual(OPENTAG_SERVICE_USER); + + expect(listener.channels).toBeDefined(); + await listener.channels?.ready({ timeoutMs: 500 }); + expect(listener.channels?.status()).toEqual({ + overall: "online", + channels: { + "open-tag": "online", + }, + }); + expect(slackAdapter.started).toBe(true); + expect(teamsAdapter.started).toBe(true); + + await listener.channels?.stop(); + expect(stopSlack).toHaveBeenCalledOnce(); + expect(stopTeams).toHaveBeenCalledOnce(); + }); +}); diff --git a/app/runtime-host.ts b/app/runtime-host.ts new file mode 100644 index 0000000..d3b9ac7 --- /dev/null +++ b/app/runtime-host.ts @@ -0,0 +1,37 @@ +import type { Channel } from "@copilotkit/channels"; +import { + CopilotKitIntelligence, + CopilotRuntime, +} from "@copilotkit/runtime/v2"; +import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node"; +import type { AppEnvironment } from "./env.js"; + +export const OPENTAG_SERVICE_USER = { + id: "opentag-service", + name: "OpenTag Channel Service", +} as const; + +export function createOpenTagRuntime(options: { + environment: AppEnvironment; + channels: Channel[]; +}) { + const intelligence = new CopilotKitIntelligence({ + apiUrl: options.environment.intelligenceApiUrl, + wsUrl: options.environment.intelligenceGatewayWsUrl, + apiKey: options.environment.intelligenceApiKey, + }); + + const runtime = new CopilotRuntime({ + agents: {}, + intelligence, + identifyUser: () => OPENTAG_SERVICE_USER, + channels: options.channels, + }); + + const listener = createCopilotNodeListener({ + runtime, + basePath: "/api/copilotkit", + }); + + return { intelligence, listener, runtime }; +} diff --git a/app/sender-context.test.ts b/app/sender-context.test.ts index c1b9319..287d1b0 100644 --- a/app/sender-context.test.ts +++ b/app/sender-context.test.ts @@ -19,22 +19,22 @@ describe("senderContext", () => { ]); }); - it("labels a whatsapp user (no email) with the platform", () => { - const out = senderContext({ id: "15551230000", name: "Bob" }, "whatsapp"); + it("labels a Teams user without an email with the platform", () => { + const out = senderContext({ id: "teams-user", name: "Bob" }, "teams"); expect(out).toEqual([ { - description: "Requesting whatsapp user", - value: "Bob (whatsapp id 15551230000)", + description: "Requesting teams user", + value: "Bob (teams id teams-user)", }, ]); }); - it("falls back to the id when the user has no name (e.g. a WhatsApp sender)", () => { - const out = senderContext({ id: "15551230000" }, "whatsapp"); + it("falls back to the id when a Teams user has no name", () => { + const out = senderContext({ id: "teams-user" }, "teams"); expect(out).toEqual([ { - description: "Requesting whatsapp user", - value: "15551230000 (whatsapp id 15551230000)", + description: "Requesting teams user", + value: "teams-user (teams id teams-user)", }, ]); }); diff --git a/app/sender-context.ts b/app/sender-context.ts index 87e3d88..abdcd1d 100644 --- a/app/sender-context.ts +++ b/app/sender-context.ts @@ -1,19 +1,17 @@ -import type { ContextEntry } from "@copilotkit/channels"; -import type { PlatformUser } from "@copilotkit/channels-ui"; +import type { ContextEntry, PlatformUser } from "@copilotkit/channels"; /** * Build the per-turn context naming the requesting user, so the agent can act * "as" them (filter Linear by their email, @-mention/tag them). The platform * adapter resolves `{ id, name?, email? }` per turn; if it's absent there's - * nothing to attribute, so we add no entry. `platform` is the surface the turn - * came from (`thread.platform`), so the label is correct across Slack, Discord, - * Telegram, and WhatsApp alike. + * nothing to attribute, so we add no entry. `platform` is the source surface, + * so the label is correct for managed Slack and Teams turns alike. */ export function senderContext( user: PlatformUser | undefined, platform: string, ): ContextEntry[] { - // `createBot` substitutes `{ id: "" }` for an unresolved sender (a truthy + // `createChannel` substitutes `{ id: "" }` for an unresolved sender (a truthy // object), so guard on a usable id — not mere object presence — otherwise we // emit a "Requesting <platform> user (... id )" entry with nothing to attribute. if (!user?.id) return []; diff --git a/app/server.test.ts b/app/server.test.ts new file mode 100644 index 0000000..45ad551 --- /dev/null +++ b/app/server.test.ts @@ -0,0 +1,160 @@ +import { EventEmitter } from "node:events"; +import type { RequestListener } from "node:http"; +import { describe, expect, it, vi } from "vitest"; +import type { ChannelsControl } from "@copilotkit/runtime/v2"; +import { + startOpenTagServer, + type HttpServerLike, + type RuntimeListener, +} from "../server.js"; +import type { AppEnvironment } from "./env.js"; +import { createOpenTagApplication } from "./index.js"; + +class FakeServer extends EventEmitter implements HttpServerLike { + listening = false; + listenCalls: Array<{ port: number; host: string }> = []; + closeCalls = 0; + + listen(port: number, host: string, callback: () => void): this { + this.listenCalls.push({ port, host }); + this.listening = true; + callback(); + return this; + } + + close(callback: (error?: Error) => void): this { + this.closeCalls += 1; + this.listening = false; + callback(); + return this; + } +} + +function makeControls(overrides: Partial<ChannelsControl> = {}) { + return { + ready: vi.fn(async () => undefined), + status: vi.fn(() => ({ + overall: "online" as const, + channels: { opentag: "online" as const }, + })), + stop: vi.fn(async () => undefined), + ...overrides, + } satisfies ChannelsControl; +} + +function makeListener(controls: ChannelsControl): RuntimeListener { + const requestListener: RequestListener = (_request, response) => { + response.end(); + }; + return Object.assign(requestListener, { channels: controls }); +} + +describe("startOpenTagServer", () => { + it("awaits runtime-owned Channel readiness before listening", async () => { + const calls: string[] = []; + const controls = makeControls({ + ready: vi.fn(async () => { + calls.push("ready"); + }), + }); + const server = new FakeServer(); + server.listen = vi.fn( + (port: number, host: string, callback: () => void) => { + calls.push("listen"); + server.listenCalls.push({ port, host }); + server.listening = true; + callback(); + return server; + }, + ); + + const running = await startOpenTagServer({ + listener: makeListener(controls), + port: 4321, + closeBrowser: vi.fn(async () => undefined), + createHttpServer: () => server, + signalTarget: new EventEmitter(), + }); + + expect(calls).toEqual(["ready", "listen"]); + expect(controls.ready).toHaveBeenCalledWith({ timeoutMs: 15_000 }); + expect(server.listenCalls).toEqual([{ port: 4321, host: "::" }]); + + await running.shutdown(); + }); + + it("rejects startup, cleans up, and never listens when readiness fails", async () => { + const failure = new Error("gateway unavailable"); + const controls = makeControls({ + ready: vi.fn(async () => { + throw failure; + }), + }); + const server = new FakeServer(); + const closeBrowser = vi.fn(async () => undefined); + const createHttpServer = vi.fn(() => server); + + await expect( + startOpenTagServer({ + listener: makeListener(controls), + port: 3000, + closeBrowser, + createHttpServer, + signalTarget: new EventEmitter(), + }), + ).rejects.toBe(failure); + + expect(createHttpServer).not.toHaveBeenCalled(); + expect(controls.stop).toHaveBeenCalledOnce(); + expect(closeBrowser).toHaveBeenCalledOnce(); + }); + + it("stops every owned resource exactly once across repeated shutdowns", async () => { + const controls = makeControls(); + const server = new FakeServer(); + const closeBrowser = vi.fn(async () => undefined); + const signalTarget = new EventEmitter(); + + const running = await startOpenTagServer({ + listener: makeListener(controls), + port: 3000, + closeBrowser, + createHttpServer: () => server, + signalTarget, + }); + + signalTarget.emit("SIGINT"); + signalTarget.emit("SIGTERM"); + await Promise.all([running.shutdown(), running.shutdown()]); + + expect(controls.stop).toHaveBeenCalledOnce(); + expect(server.closeCalls).toBe(1); + expect(closeBrowser).toHaveBeenCalledOnce(); + }); +}); + +describe("createOpenTagApplication", () => { + it("declares one adapter-free managed Channel", () => { + const environment: AppEnvironment = { + agentUrl: "http://agent.internal/", + intelligenceApiKey: "cpk-1_test", + intelligenceApiUrl: "https://api.intelligence.test", + intelligenceGatewayWsUrl: "wss://realtime.intelligence.test", + channelName: "open-tag", + port: 3000, + }; + + const application = createOpenTagApplication(environment); + + expect( + application.channels.map((channel) => ({ + name: channel.name, + provider: channel.provider, + adapters: channel.adapters, + })), + ).toEqual([ + { name: "open-tag", provider: undefined, adapters: [] }, + ]); + expect(application.runtime.channels).toEqual(application.channels); + }); +}); diff --git a/app/tools/__tests__/read-thread.test.ts b/app/tools/__tests__/read-thread.test.ts index 60198d2..9e3b727 100644 --- a/app/tools/__tests__/read-thread.test.ts +++ b/app/tools/__tests__/read-thread.test.ts @@ -1,13 +1,13 @@ import { describe, it, expect, vi } from "vitest"; import { readThreadTool } from "../read-thread.js"; -import type { ThreadMessage } from "@copilotkit/channels-ui"; +import type { ThreadMessage } from "@copilotkit/channels"; -/** The ctx a BotTool handler receives. */ +/** The ctx a ChannelTool handler receives. */ type HandlerCtx = Parameters<typeof readThreadTool.handler>[1]; /** * Build a fake handler ctx. The handler only touches - * `thread.getMessages()`; the other `BotToolContext` fields are unused by + * `thread.getMessages()`; the other `ChannelToolContext` fields are unused by * this tool, so we cast the minimal literal to the handler ctx type. */ function makeCtx(messages: ThreadMessage[]): HandlerCtx { diff --git a/app/tools/__tests__/render-table.test.tsx b/app/tools/__tests__/render-table.test.tsx index afef3f9..89cfbd6 100644 --- a/app/tools/__tests__/render-table.test.tsx +++ b/app/tools/__tests__/render-table.test.tsx @@ -5,8 +5,9 @@ * `renderToIR` → `renderSlackMessage` yields the expected Block Kit shape. */ import { describe, it, expect } from "vitest"; -import { renderToIR } from "@copilotkit/channels-ui"; -import { renderSlackMessage } from "@copilotkit/channels-slack"; +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<typeof renderTableTool.handler>[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__/render-tools.test.ts b/app/tools/__tests__/render-tools.test.ts index 76383fb..40b8715 100644 --- a/app/tools/__tests__/render-tools.test.ts +++ b/app/tools/__tests__/render-tools.test.ts @@ -8,8 +8,8 @@ * separately in render-tools.test.tsx. */ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderToIR } from "@copilotkit/channels-ui"; -import { renderSlackMessage } from "@copilotkit/channels-slack"; +import { renderToIR } from "@copilotkit/channels"; +import { renderSlackMessage } from "@copilotkit/channels/slack"; // The exact PNG buffer instance `renderChart` resolves with, so tests can // assert `postFile` was handed the real render output — not just a filename. @@ -25,7 +25,7 @@ vi.mock("../../render/diagram.js", () => ({ renderDiagram })); const { renderChartTool } = await import("../render-chart.js"); const { renderDiagramTool } = await import("../render-diagram.js"); -/** The ctx a BotTool handler receives. */ +/** The ctx a ChannelTool handler receives. */ type HandlerCtx = Parameters<typeof renderChartTool.handler>[1]; function makeCtx(opts?: { diff --git a/app/tools/__tests__/render-tools.test.tsx b/app/tools/__tests__/render-tools.test.tsx index aff26ee..48832f3 100644 --- a/app/tools/__tests__/render-tools.test.tsx +++ b/app/tools/__tests__/render-tools.test.tsx @@ -6,8 +6,8 @@ * shape — i.e. the tool posted the right component. */ import { describe, it, expect } from "vitest"; -import { renderToIR } from "@copilotkit/channels-ui"; -import { renderSlackMessage } from "@copilotkit/channels-slack"; +import { renderToIR } from "@copilotkit/channels"; +import { renderSlackMessage } from "@copilotkit/channels/slack"; import { issueCardTool, issueListTool, pageListTool } from "../render-tools.js"; /** A fake `thread` that records each posted Renderable. */ diff --git a/app/tools/__tests__/showcase-tools.test.tsx b/app/tools/__tests__/showcase-tools.test.tsx index 9a70b23..954647d 100644 --- a/app/tools/__tests__/showcase-tools.test.tsx +++ b/app/tools/__tests__/showcase-tools.test.tsx @@ -7,13 +7,14 @@ * updates the message in place with a green "Acknowledged" card. */ import { describe, it, expect, vi } from "vitest"; -import { renderToIR } from "@copilotkit/channels-ui"; +import { renderToIR } from "@copilotkit/channels"; import type { - BotNode, + ChannelNode, InteractionContext, ClickHandler, -} from "@copilotkit/channels-ui"; -import { renderSlackMessage } from "@copilotkit/channels-slack"; +} from "@copilotkit/channels"; +import { renderSlackMessage } from "@copilotkit/channels/slack"; +import { renderAdaptiveCard } from "@copilotkit/channels/teams"; import { showIncidentTool, showStatusTool, @@ -43,29 +44,29 @@ function fakeThread() { /** Depth-first: find the first IR node whose `type` matches and that has the named prop. */ function findWithProp( - nodes: BotNode[], + nodes: ChannelNode[], type: string, prop: string, -): BotNode | undefined { +): ChannelNode | undefined { return findAllWithProp(nodes, type, prop)[0]; } /** Depth-first: find every IR node whose `type` matches and that has the named prop. */ function findAllWithProp( - nodes: BotNode[], + nodes: ChannelNode[], type: string, prop: string, -): BotNode[] { - const matches: BotNode[] = []; +): ChannelNode[] { + const matches: ChannelNode[] = []; for (const node of nodes) { if (node.type === type && node.props && prop in node.props) { matches.push(node); } const children = node.props?.children; const childArr = Array.isArray(children) - ? (children as BotNode[]) + ? (children as ChannelNode[]) : children && typeof children === "object" - ? [children as BotNode] + ? [children as ChannelNode] : []; matches.push(...findAllWithProp(childArr, type, prop)); } @@ -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( @@ -142,6 +164,41 @@ describe("show_incident render-tool", () => { expect(JSON.stringify(blocks)).toContain("Ack'd by Alem"); }); + it("propagates Acknowledge update failures", async () => { + const { posts, thread } = fakeThread(); + await showIncidentTool.handler( + { + id: "INC-1", + title: "Checkout 500s", + severity: "SEV2", + summary: "Latency creeping up.", + }, + { thread } as unknown as IncidentCtx, + ); + const failure = new Error("message update unavailable"); + thread.update.mockRejectedValueOnce(failure); + + const ir = renderToIR(posts[0] as never); + const button = findWithProp(ir, "button", "onClick"); + const onClick = button?.props.onClick as ClickHandler; + + await expect( + onClick({ + thread, + message: { + ref: { id: "m1" }, + text: "", + user: { id: "U1" }, + platform: "slack", + }, + user: { id: "U1", name: "Alem" }, + action: { id: "a1" }, + values: {}, + platform: "slack", + } as unknown as InteractionContext), + ).rejects.toBe(failure); + }); + it("the Escalate button's onClick posts a paging notice", async () => { const { posts, thread } = fakeThread(); await showIncidentTool.handler( @@ -184,6 +241,44 @@ describe("show_incident render-tool", () => { ); }); + it("propagates Escalate post failures", 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 failure = new Error("message post unavailable"); + thread.post.mockRejectedValueOnce(failure); + + const ir = renderToIR(posts[0] as never); + const escalateButton = findAllWithProp(ir, "button", "onClick").find( + (button) => + (button.props?.value as { action?: string })?.action === "escalate", + ); + const onClick = escalateButton?.props.onClick as ClickHandler; + + await expect( + onClick({ + thread, + message: { + ref: { id: "m1" }, + text: "", + user: { id: "U1" }, + platform: "slack", + }, + user: { id: "U1", name: "Alem" }, + action: { id: "a1" }, + values: {}, + platform: "slack", + } as unknown as InteractionContext), + ).rejects.toBe(failure); + }); + it("uses the SEV2 accent (#F2994A)", async () => { const { posts, thread } = fakeThread(); await showIncidentTool.handler( @@ -242,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", () => { @@ -266,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 4e4371f..c6b91d4 100644 --- a/app/tools/index.ts +++ b/app/tools/index.ts @@ -1,12 +1,9 @@ /** - * App-specific frontend tools — anything that's bot-specific, not - * universal-Slack. Universal-Slack stuff (tagging, formatting, - * conversation model) lives in the SDK and is auto-included by - * `defaultSlackTools` (spread in `app/index.ts`). + * 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 `createBot({tools: [...defaultSlackTools, ...appTools]})` - * in `app/index.ts`. + * 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"; @@ -18,18 +15,17 @@ import { showStatusTool, showLinksTool, } from "./showcase-tools.js"; -import { confirmWriteTool } from "../human-in-the-loop/index.js"; -import type { BotTool } from "@copilotkit/channels"; +import type { ChannelTool } from "@copilotkit/channels"; /** - * Every tool is a plain `BotTool`: its handler receives the generic - * `BotToolContext` (`{ thread, message?, user?, signal?, platform }`) the + * Every tool is a plain `ChannelTool`: its handler receives the generic + * `ChannelToolContext` (`{ thread, message?, user?, signal?, platform }`) the * adapter supplies at call time. Platform power (post/stream/postFile, * `thread.getMessages()`, `thread.lookupUser()`, …) is reached via the * `thread` methods, so there's no per-adapter context and no cast needed — - * the array assigns straight into `createBot({ tools })`. + * the array assigns straight into `createChannel({ tools })`. */ -export const appTools: BotTool[] = [ +export const appTools: ChannelTool[] = [ readThreadTool, renderChartTool, renderDiagramTool, @@ -40,19 +36,4 @@ export const appTools: BotTool[] = [ showIncidentTool, showStatusTool, showLinksTool, - confirmWriteTool, ]; - -export { - readThreadTool, - renderChartTool, - renderDiagramTool, - renderTableTool, - issueCardTool, - issueListTool, - pageListTool, - showIncidentTool, - showStatusTool, - showLinksTool, - confirmWriteTool, -}; diff --git a/app/tools/read-thread.ts b/app/tools/read-thread.ts index 7249acf..c397053 100644 --- a/app/tools/read-thread.ts +++ b/app/tools/read-thread.ts @@ -1,19 +1,19 @@ /** - * `read_thread` — an app-side `BotTool` that hands the agent the full + * `read_thread` — an app-side `ChannelTool` that hands the agent the full * conversation thread it's replying in. This is what makes "write this * incident thread up as a postmortem" possible: the agent calls * `read_thread`, gets the actual messages, and summarizes those instead * of inventing content. * - * It's a worked example of a `BotTool` that reads conversation history + * It's a worked example of a `ChannelTool` that reads conversation history * via the platform-agnostic `ctx.thread.getMessages()` capability — * the adapter targets the current thread, so no channel/ts plumbing is * needed. */ import { z } from "zod"; -import { defineBotTool } from "@copilotkit/channels"; +import { defineChannelTool } from "@copilotkit/channels"; -export const readThreadTool = defineBotTool({ +export const readThreadTool = defineChannelTool({ name: "read_thread", description: "Fetch the messages in the current conversation thread so you can " + diff --git a/app/tools/render-chart.tsx b/app/tools/render-chart.tsx index 61d3255..20f6145 100644 --- a/app/tools/render-chart.tsx +++ b/app/tools/render-chart.tsx @@ -7,8 +7,7 @@ * (`<Context>`) so the tool doubles as a render-tool demo. */ import { z } from "zod"; -import { Context, Message } from "@copilotkit/channels-ui"; -import { defineBotTool } from "@copilotkit/channels"; +import { Context, Message, defineChannelTool } from "@copilotkit/channels"; import { renderChart } from "../render/chart.js"; const schema = z.object({ @@ -63,7 +62,7 @@ function slug(s: string): string { ); } -export const renderChartTool = defineBotTool({ +export const renderChartTool = defineChannelTool({ name: "render_chart", description: "Render a chart as an image and post it to the conversation thread. Pass " + diff --git a/app/tools/render-diagram.tsx b/app/tools/render-diagram.tsx index f302c87..9dff65e 100644 --- a/app/tools/render-diagram.tsx +++ b/app/tools/render-diagram.tsx @@ -7,8 +7,7 @@ * render-tool demo. */ import { z } from "zod"; -import { Context, Message } from "@copilotkit/channels-ui"; -import { defineBotTool } from "@copilotkit/channels"; +import { Context, Message, defineChannelTool } from "@copilotkit/channels"; import { renderDiagram } from "../render/diagram.js"; const schema = z.object({ @@ -33,7 +32,7 @@ function slug(s: string): string { ); } -export const renderDiagramTool = defineBotTool({ +export const renderDiagramTool = defineChannelTool({ name: "render_diagram", description: "Render a Mermaid diagram as an image and post it to the conversation " + diff --git a/app/tools/render-table.tsx b/app/tools/render-table.tsx index ecba6c4..fbde82e 100644 --- a/app/tools/render-table.tsx +++ b/app/tools/render-table.tsx @@ -4,7 +4,7 @@ * issues with several fields, metrics parsed from an uploaded CSV, side-by-side * comparisons — anything where a chart isn't the right shape. * - * Authored as JSX over `@copilotkit/channels-ui`'s `<Table>/<Row>/<Cell>` vocabulary + * Authored as JSX over `@copilotkit/channels`'s `<Table>/<Row>/<Cell>` vocabulary * and posted via `thread.post`. If the platform rejects the native Table block, * we fall back to a column-aligned monospace (code-fenced) table posted as a * platform-neutral `<Message>` so the data always lands — the same look the @@ -19,8 +19,8 @@ import { Row, Cell, Context, -} from "@copilotkit/channels-ui"; -import { defineBotTool } from "@copilotkit/channels"; +} from "@copilotkit/channels"; +import { defineChannelTool } from "@copilotkit/channels"; const schema = z.object({ title: z @@ -120,7 +120,7 @@ export function toMonospaceTable(cols: Column[], dataRows: string[][]): string { return "```\n" + [fmt(header), ...body.map(fmt)].join("\n") + "\n```"; } -export const renderTableTool = defineBotTool({ +export const renderTableTool = defineChannelTool({ name: "render_table", description: "Render tabular data as a table posted to the conversation thread. Pass " + diff --git a/app/tools/render-tools.tsx b/app/tools/render-tools.tsx index c821a35..5726c42 100644 --- a/app/tools/render-tools.tsx +++ b/app/tools/render-tools.tsx @@ -1,11 +1,11 @@ /** * Render-tools — the agent-facing wrappers that turn the JSX render - * components into `BotTool`s. The agent calls `issue_card` / `issue_list` / - * `page_list`; each handler renders the finished `@copilotkit/channels-ui` + * components into `ChannelTool`s. The agent calls `issue_card` / `issue_list` / + * `page_list`; each handler renders the finished `@copilotkit/channels` * component (`<IssueCard … />` etc.) and posts it to the thread via * `thread.post`. */ -import { defineBotTool } from "@copilotkit/channels"; +import { defineChannelTool } from "@copilotkit/channels"; import { IssueCard, IssueList, @@ -15,7 +15,7 @@ import { pageListSchema, } from "../components/index.js"; -export const issueCardTool = defineBotTool({ +export const issueCardTool = defineChannelTool({ name: "issue_card", description: "Render ONE Linear issue as a rich card with a status header, " + @@ -29,7 +29,7 @@ export const issueCardTool = defineBotTool({ }, }); -export const issueListTool = defineBotTool({ +export const issueListTool = defineChannelTool({ name: "issue_list", description: "Render a list of Linear issues as a card — a header plus one row per " + @@ -44,7 +44,7 @@ export const issueListTool = defineBotTool({ }, }); -export const pageListTool = defineBotTool({ +export const pageListTool = defineChannelTool({ name: "page_list", description: "Render a list of Notion pages as a card — a header plus one row per " + diff --git a/app/tools/showcase-tools.tsx b/app/tools/showcase-tools.tsx index 4e0b537..d95e989 100644 --- a/app/tools/showcase-tools.tsx +++ b/app/tools/showcase-tools.tsx @@ -1,6 +1,6 @@ /** - * Showcase render-tools — three small JSX `BotTool`s that demonstrate the - * `@copilotkit/channels-ui` vocabulary end-to-end: + * Showcase render-tools — three small JSX `ChannelTool`s that demonstrate the + * `@copilotkit/channels` vocabulary end-to-end: * * - `show_incident` — an interactive card whose `Acknowledge`/`Escalate` * buttons carry inline `onClick` handlers. These are FIRE-AND-FORGET @@ -20,9 +20,9 @@ import { Field, Actions, Button, -} from "@copilotkit/channels-ui"; -import type { InteractionContext } from "@copilotkit/channels-ui"; -import { defineBotTool } from "@copilotkit/channels"; +} from "@copilotkit/channels"; +import type { InteractionContext } from "@copilotkit/channels"; +import { defineChannelTool } from "@copilotkit/channels"; // ── show_incident ────────────────────────────────────────────────────────── @@ -54,17 +54,13 @@ export function IncidentCard({ id, title, severity, summary }: IncidentProps) { value={{ action: "ack", id }} style="primary" onClick={async ({ thread, user, message }: InteractionContext) => { - try { - await thread.update( - message.ref, - <Message accent="#27AE60"> - <Header>{`✅ Acknowledged · ${title}`}</Header> - <Context>{`Ack'd by ${user?.name ?? user?.id ?? "someone"}`}</Context> - </Message>, - ); - } catch (err) { - console.error("[showcase] onClick failed", err); - } + await thread.update( + message.ref, + <Message accent="#27AE60"> + <Header>{`✅ Acknowledged · ${title}`}</Header> + <Context>{`Ack'd by ${user?.name ?? user?.id ?? "someone"}`}</Context> + </Message>, + ); }} > Acknowledge @@ -73,13 +69,9 @@ export function IncidentCard({ id, title, severity, summary }: IncidentProps) { value={{ action: "escalate", id }} style="danger" onClick={async ({ thread }: InteractionContext) => { - try { - await thread.post( - `🚨 Escalating *${title}* — paging the next on-call.`, - ); - } catch (err) { - console.error("[showcase] onClick failed", err); - } + await thread.post( + `🚨 Escalating *${title}* — paging the next on-call.`, + ); }} > Escalate @@ -89,7 +81,7 @@ export function IncidentCard({ id, title, severity, summary }: IncidentProps) { ); } -export const showIncidentTool = defineBotTool({ +export const showIncidentTool = defineChannelTool({ name: "show_incident", description: "Render an interactive incident card with Acknowledge/Escalate buttons. " + @@ -120,7 +112,7 @@ const statusSchema = z.object({ type StatusProps = z.infer<typeof statusSchema>; -export function StatusCard({ heading, fields }: StatusProps) { +function StatusCard({ heading, fields }: StatusProps) { return ( <Message accent="#5E6AD2"> <Header>{`📊 ${heading}`}</Header> @@ -133,7 +125,7 @@ export function StatusCard({ heading, fields }: StatusProps) { ); } -export const showStatusTool = defineBotTool({ +export const showStatusTool = defineChannelTool({ name: "show_status", description: "Render a status card: a heading plus a grid of label/value fields " + @@ -163,7 +155,7 @@ const linksSchema = z.object({ type LinksProps = z.infer<typeof linksSchema>; -export function LinksCard({ heading, links }: LinksProps) { +function LinksCard({ heading, links }: LinksProps) { // `[label](url)` is rewritten to Slack's `<url|label>` link form by // `markdownToMrkdwn`; authoring the raw `<url|label>` here would have its // inner text mangled, so we author markdown links instead. @@ -177,7 +169,7 @@ export function LinksCard({ heading, links }: LinksProps) { ); } -export const showLinksTool = defineBotTool({ +export const showLinksTool = defineChannelTool({ name: "show_links", description: "Render a card of links: a heading plus a dot-separated row of clickable " + diff --git a/e2e/README.md b/e2e/README.md index 2fca77f..3e66ae0 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -1,85 +1,54 @@ -# `e2e/` — live end-to-end test harness +# Slack live end-to-end harness -True end-to-end coverage for the Slack bridge: send real user messages -in a real Slack workspace, sample the bot's reply _while it's streaming_, -take screenshots in the middle of long streams, and verify what landed. +This API-first harness sends real user messages to a Slack test channel, polls +OpenTag replies while they stream, and writes a JSON report under +`e2e/results/`. -> **Why this exists.** Unit tests (under `src/__tests__/`) lock in the -> internal contracts of each module — they don't catch issues that only -> surface end-to-end: an open code fence leaking through the rest of the -> Slack message during streaming, a mrkdwn translation that _looks_ right -> in tests but renders weird in Slack's actual client, a Block Kit limit -> we forgot about, a Bolt event that doesn't fire under some setting. -> -> The catalog at `e2e/cases.ts` is the source of truth for what -> "feature-complete" means. +It complements unit tests by exercising the deployed Slack path, including +mentions, threads, streaming text, rich blocks, follow-up turns, and +interruptions. -## What's in here +## Configure -``` -e2e/ -├── README.md this -├── cases.ts catalog of test cases (technical axes; expand liberally) -├── slack-api.ts Slack Web API helpers (history, thread replies, sampling) -├── run.ts harness entrypoint — sends prompts, samples, screenshots -└── results/ per-run output: screenshots + JSON report -``` - -## Running - -```bash -# from packages/slack/ - -# one-time: log into Slack once in the playwright browser profile. -# Subsequent runs reuse that profile. -pnpm exec playwright open --browser=chromium --user-data-dir=./e2e/.chrome-profile \ - https://app.slack.com/client/T05QFA4BW9X/C0B49MEJ1HQ +Set these only in your local root `.env`: -# then: -pnpm e2e +```dotenv +SLACK_BOT_TOKEN=xoxb-... +SLACK_USER_TOKEN=xoxp-... +BOT_USER_ID=U... +E2E_CHANNEL=C... ``` -The runner expects `.env` to already contain `SLACK_BOT_TOKEN` (used for -polling the channel history while the bot streams). Sending the user -message happens through the playwright-driven Slack UI using Atai's -session cookies from the persistent profile. +- `SLACK_BOT_TOKEN` reads channel history and thread replies. +- `SLACK_USER_TOKEN` posts as a real user so Slack emits the user event. +- `BOT_USER_ID` identifies the OpenTag bot in replies. +- `E2E_CHANNEL` is the test channel ID. -## How sampling works +These are test-harness credentials, not Channel-runtime configuration; +Intelligence still owns the deployed Slack attachment. The harness never +provisions or reinstalls an app. -For each case the harness: +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. -1. Sends the prompt via the Slack UI (or `/agent` slash command). -2. Polls `conversations.replies` (or `.history` for DMs / flat replies) - every `sampleIntervalMs` until `maxWaitMs` elapses. -3. At each sample, records: - - elapsed time - - bot's reply text snapshot - - bracket-balance check (`isBalanced(text)`) -4. At each `screenshots[i]` offset (ms after send), takes a screenshot of - the Slack thread pane via playwright. -5. After the run, writes `results/<timestamp>/report.json` and the screenshots. +## Run -## What this catches that unit tests don't +Start or deploy the OpenTag agent and Channel, then: -- Open code fences leaking through the rest of the Slack message -- Slack's `chat.update` rate limits creating visible "jumps" -- mrkdwn rendering differences vs. our translator's expectations -- The bot's actual streaming cadence with the model -- Thread vs DM rendering differences -- Real concurrency from multiple users in the channel -- Mid-stream cancellation / kill behaviour +```bash +pnpm e2e +``` -## Adding cases +Run a subset by case-name substring: -Edit `cases.ts`. The bar is low — anything you'd want to _see_ working in -Slack belongs in the catalog. Don't be afraid of duplication with -unit tests; the unit test proves the code is internally correct, the E2E -proves Slack actually renders it that way. +```bash +CASE_FILTER='A1' pnpm e2e +``` -## Limitations (current) +Cases live in [`cases.ts`](./cases.ts). The harness entrypoint is +[`run.ts`](./run.ts), and Slack API helpers are in +[`slack-api.ts`](./slack-api.ts). -- Sending the user message still relies on UI automation (no user - token), so a one-time signin in the persistent profile is required. -- A future enhancement: a long-lived user OAuth token would let us skip - the browser entirely for the _send_ step (screenshots still need the - browser, but sampling already uses pure API). +Microsoft Teams is supported for launch through Intelligence, but this folder +does not yet contain a Teams live harness. diff --git a/e2e/TELEGRAM-README.md b/e2e/TELEGRAM-README.md deleted file mode 100644 index f5dfd47..0000000 --- a/e2e/TELEGRAM-README.md +++ /dev/null @@ -1,119 +0,0 @@ -# `e2e/telegram-*` — live end-to-end test harness for the Telegram bot - -True end-to-end coverage: send real messages to a real Telegram chat, poll -the bot's reply via the Bot API, and verify what landed. - -> **Why this exists.** Unit tests (under `app/**/__tests__/`) lock in internal -> module contracts. They don't catch issues that only surface end-to-end: an -> unbalanced code fence leaking through, a Markdown→HTML translation that -> looks correct in tests but renders wrong in Telegram, or an agentic reply -> that truncates when the LLM hits a tool call boundary. - -## What's in here - -``` -e2e/ -├── TELEGRAM-README.md this -├── telegram-cases.ts catalog of test cases (expand liberally) -├── telegram-api.ts Telegram Bot API helpers (send, poll, balance check) -└── telegram-run.ts harness entrypoint — sends prompts, polls replies -``` - -Results land under `e2e/results/<timestamp>/report.json` (shared with the -Slack harness). - -## Approach chosen: (b) MANUAL-TRIGGER smoke with automated upgrade path - -The Telegram Bot API does **not** allow impersonating a human user to send -messages. This creates a bootstrapping problem that Slack avoids via its -user-token (`xoxp-`) mechanism: - -- A bot can call `sendMessage` as itself, but the CopilotKit bot's loop guard - ignores messages from other bots to prevent infinite loops. -- MTProto-based user automation (TDLib, Telethon) requires a verified - Telegram account, a registered API app (`api_id` + `api_hash`), a session - file, and significant additional infrastructure. - -Therefore the default flow is **manual-trigger**: - -1. The harness prints the test prompt. -2. You open the Telegram chat with the bot and send that text. -3. The harness polls `getUpdates` on the bot token and validates the reply. - -### Automated upgrade (approach a) - -Set `TELEGRAM_SENDER_BOT_TOKEN` in `.env` to a second ("sender") bot token. -The test chat must be a **group or supergroup** with both the sender bot and -the main bot as members. In this mode the harness posts prompts -programmatically via the sender bot and the main bot replies to the group. - -> Note on coverage: the manual-trigger flow does NOT reduce assertion -> coverage. All expectations (`finalContains`, `finalNotContains`, -> `balancedBrackets`, `minLength`, `perReplyChecks`) — plus the optional -> `followUp` second turn — are evaluated against the real bot reply. What it -> reduces is _automation_: you need to type (or paste) each prompt once. - -## Prerequisites - -| Variable | Required | Description | -| --------------------------- | -------- | ------------------------------------------------- | -| `TELEGRAM_BOT_TOKEN` | Yes | The main bot's token from BotFather | -| `TELEGRAM_TEST_CHAT_ID` | Yes | Numeric chat ID of the test chat (DM or group) | -| `TELEGRAM_SENDER_BOT_TOKEN` | No | Second bot token for full automation (group mode) | - -### Finding your `TELEGRAM_TEST_CHAT_ID` - -- **DM with the bot:** Start a chat with the bot, then call - `https://api.telegram.org/bot<TOKEN>/getUpdates` — the `chat.id` in your - message is your user ID (a positive integer). -- **Group:** Add the bot to a group, send a message, call `getUpdates` — the - `chat.id` is a negative integer. - -## Running - -```bash -# from examples/slack/ - -# Copy the example env and fill in the required vars: -cp .env.example .env # edit TELEGRAM_BOT_TOKEN + TELEGRAM_TEST_CHAT_ID - -# Run all cases (manual-trigger mode by default): -pnpm e2e:telegram - -# Run a single case by name filter: -CASE_FILTER='C1' pnpm e2e:telegram -``` - -In manual-trigger mode the harness will pause before each case and print the -prompt to send. You have ~15 seconds to paste it into the Telegram chat before -the harness starts polling. - -## How polling works - -For each case the harness: - -1. Calls `getUpdates` to drain any stale messages from the bot's queue. -2. (Automated) Sends the prompt via the sender bot, OR (manual) waits for the - operator to send it. -3. Polls `getUpdates` on the main bot token every `sampleIntervalMs` until - `maxWaitMs` elapses or the reply stabilises. -4. Runs expectations on the final reply text. -5. Writes `results/<timestamp>/report.json`. - -### Streaming via message edits - -The example bot uses chunked-edit mode (`editMessageText`) to stream replies: -it posts a `_thinking…_` placeholder and then edits it repeatedly as chunks -arrive from the LLM. To observe this, the harness subscribes to both -`message` and `edited_message` update types in `getUpdates` and tracks the -**latest text for each bot `message_id`**. This means `finalText` in -expectations reflects the last edit (the completed reply), not the initial -placeholder. - -Mid-stream samples may still show intermediate edited texts between polls, -but the `balancedBrackets` check is applied only to the final stable text. - -## Adding cases - -Edit `telegram-cases.ts`. The bar is low — anything you'd want to _see_ working in -Telegram belongs in the catalog. diff --git a/e2e/cases.ts b/e2e/cases.ts index 95b3a51..65199b7 100644 --- a/e2e/cases.ts +++ b/e2e/cases.ts @@ -86,6 +86,50 @@ export interface E2ECase { }; } +function confirmWriteCard( + raw: Array<Record<string, any>>, +): { + buttons: Array<Record<string, any>>; + errors: string[]; +} { + const blocks = raw.flatMap((message) => [ + ...((message["blocks"] as Array<Record<string, any>> | undefined) ?? []), + ...( + (message["attachments"] as + | Array<{ blocks?: Array<Record<string, any>> }> + | undefined) ?? [] + ).flatMap((attachment) => attachment.blocks ?? []), + ]); + const header = blocks.find((block) => block["type"] === "header"); + const headerText = String( + (header?.["text"] as { text?: string } | undefined)?.text ?? "", + ); + const buttons = blocks + .filter((block) => block["type"] === "actions") + .flatMap( + (block) => + (block["elements"] as Array<Record<string, any>> | undefined) ?? [], + ) + .filter((element) => element["type"] === "button"); + const buttonLabels = buttons.map((button) => + String( + (button["text"] as { text?: string } | undefined)?.text ?? "", + ), + ); + const errors: string[] = []; + + if (!headerText.trim()) { + errors.push("confirm_write card has no Block Kit header"); + } + for (const expected of ["Create", "Cancel"]) { + if (!buttonLabels.includes(expected)) { + errors.push(`confirm_write card has no ${expected} button`); + } + } + + return { buttons, errors }; +} + export const CASES: E2ECase[] = [ // ── A. Trigger surface ────────────────────────────────────────────── { @@ -355,76 +399,54 @@ export const CASES: E2ECase[] = [ }, }, { - name: "E-restart-1 — confirm_write picker has resume values encoded in button.value (survives bridge restart)", - // The agent must call confirm_write before any write. Once the picker - // lands, we read it back via conversations.replies and verify each - // button carries a JSON-encoded resume payload in its `value` field. - // That's what Slack stores and what the bridge decodes on a "stale - // click" after a restart — the durable-action story. (The full - // kill→restart→click cycle is covered by e2e/restart-recovery.ts.) + name: "E-durable-1 — confirm_write buttons serialize action IDs and resume values", + // This live case does not restart the process. It reads the initial picker + // back through conversations.replies and verifies Slack stored durable + // action IDs plus JSON resume values. Handler re-registration with a shared + // store is covered in app/channel.test.ts; this does not claim persistence + // across an operating-system process restart. prompt: '<@U0B45V75NNR> file a Linear issue titled "Checkout 500s under load". ' + - "Use the confirm_write tool to ask me to approve it first.", + "The protected write must ask me to approve it before MCP runs.", sampleIntervalMs: 700, maxWaitMs: 20_000, expectations: { perReplyChecks: (_replies, raw) => { - const errs: string[] = []; - const buttons: Array<{ action_id?: string; value?: string }> = []; - for (const m of raw) { - // confirm_write wraps its blocks in a colored attachment, so the - // buttons live under attachments[].blocks; scan both. - const blocks = [ - ...(m.blocks ?? []), - ...( - (m.attachments as Array<{ blocks?: any[] }> | undefined) ?? [] - ).flatMap((a) => a.blocks ?? []), - ]; - for (const b of blocks) { - if (b.type === "actions" && Array.isArray(b.elements)) { - for (const el of b.elements) { - if (el?.type === "button") buttons.push(el); - } - } - } - } - if (buttons.length < 2) { - errs.push( - `expected ≥2 confirm_write buttons (Create/Cancel); got ${buttons.length}`, + const { buttons, errors } = confirmWriteCard(raw); + for (const [label, confirmed] of [ + ["Create", true], + ["Cancel", false], + ] as const) { + const btn = buttons.find( + (button) => + (button["text"] as { text?: string } | undefined)?.text === + label, ); - return errs; - } - let sawConfirmTrue = false; - for (const btn of buttons) { + if (!btn) continue; + if ( + typeof btn["action_id"] !== "string" || + !btn["action_id"].startsWith("ck:") + ) { + errors.push(`${label} button has no durable ck: action_id`); + } if (!btn.value) { - errs.push(`button action_id=${btn.action_id} has no value field`); + errors.push(`${label} button has no value field`); continue; } try { const decoded = JSON.parse(btn.value); - if ( - decoded && - typeof decoded === "object" && - "confirmed" in decoded - ) { - if (decoded.confirmed === true) sawConfirmTrue = true; - } else { - errs.push( - `button action_id=${btn.action_id} decoded to unexpected shape: ${btn.value}`, + if (decoded?.confirmed !== confirmed) { + errors.push( + `${label} button decoded to unexpected resume value: ${btn.value}`, ); } } catch (e) { - errs.push( - `button action_id=${btn.action_id} value isn't valid JSON: ${(e as Error).message}`, + errors.push( + `${label} button value isn't valid JSON: ${(e as Error).message}`, ); } } - if (!sawConfirmTrue) { - errs.push( - "no button encoded { confirmed: true } (the Create button)", - ); - } - return errs; + return errors; }, }, }, @@ -432,25 +454,16 @@ export const CASES: E2ECase[] = [ name: "E-hitl-1 — agent renders the confirm_write HITL Block Kit message", // Verifies the human-in-the-loop gate renders into the thread. We // can't simulate the button click via Slack's API, so this case only - // asserts that the Block Kit message lands; the click→resolve flow - // is covered by unit tests in the slack package, and the full - // restart cycle by e2e/restart-recovery.ts. The agent's run will be - // left dangling on the HITL wait for up to the component's timeoutMs. + // asserts that the initial Block Kit card lands with Create/Cancel. + // Click→resume handling and shared-store action re-registration are covered + // by the Channel unit tests. prompt: - '<@U0B45V75NNR> file a Linear issue titled "Test from e2e". Call the ' + - "confirm_write tool to ask me to approve it before creating anything.", + '<@U0B45V75NNR> file a Linear issue titled "Test from e2e". The ' + + "protected write must ask me to approve it before creating anything.", sampleIntervalMs: 700, maxWaitMs: 20_000, expectations: { - perReplyChecks: (replies) => { - const errs: string[] = []; - const joined = replies.join("\n").toLowerCase(); - // The HITL fallback for confirm_write is "Approve: <action>". - if (!joined.includes("approve")) { - errs.push("no bot reply contained the confirm_write 'Approve' text"); - } - return errs; - }, + perReplyChecks: (_replies, raw) => confirmWriteCard(raw).errors, }, }, { 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<void> { - // 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<void> { - 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<void> { - 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 <button type=submit>). - await page.evaluate(() => { - const btn = Array.from(document.querySelectorAll("button")).find( - (b) => b.textContent?.trim() === "Allow" && !b.disabled, - ) as HTMLButtonElement | undefined; - if (!btn) throw new Error("Allow button not found / disabled"); - // Submit the form (Slack's Allow is type=submit inside a form). - if (btn.form) btn.form.submit(); - else btn.click(); - }); - console.log("[grab-token] Allow submitted; waiting for redirect"); - await page.waitForLoadState("networkidle", { timeout: 15000 }); - await page.waitForTimeout(2000); - } catch (err) { - console.log( - "[grab-token] no Allow button (maybe already approved?):", - (err as Error).message, - ); - } - - // ── 5. Read both tokens from OAuth & Permissions ────────────────── - console.log("[grab-token] → OAuth & Permissions"); - await page.goto(`https://api.slack.com/apps/${APP_ID}/oauth`, { - waitUntil: "networkidle", - }); - await page.waitForTimeout(2500); - - const tokens = await page.evaluate(() => { - const fields = Array.from( - document.querySelectorAll("input[readonly]"), - ) as HTMLInputElement[]; - const found: { kind: string; value: string }[] = []; - for (const f of fields) { - const v = f.value ?? ""; - if (v.startsWith("xoxb-")) found.push({ kind: "bot", value: v }); - else if (v.startsWith("xoxp-")) found.push({ kind: "user", value: v }); - } - return found; - }); - console.log( - "[grab-token] found tokens:", - tokens.map((t) => t.kind), - ); - - const bot = tokens.find((t) => t.kind === "bot")?.value; - const user = tokens.find((t) => t.kind === "user")?.value; - if (!user) { - // Dump some page state to help debug. - const debugInfo = await page.evaluate(() => ({ - title: document.title, - url: location.href, - readonlyValues: Array.from(document.querySelectorAll("input[readonly]")) - .map((i) => (i as HTMLInputElement).value) - .slice(0, 8), - buttons: Array.from(document.querySelectorAll("button")) - .map((b) => b.textContent?.trim()) - .filter((t) => t && t.length < 40), - })); - console.log("[grab-token] debug:", JSON.stringify(debugInfo, null, 2)); - throw new Error("Did not find xoxp- user token"); - } - - // ── 6. Write to .env (preserve existing keys) ───────────────────── - const envText = readFileSync(ENV_PATH, "utf8"); - let updated = upsertEnv(envText, "SLACK_USER_TOKEN", user); - if (bot) updated = upsertEnv(updated, "SLACK_BOT_TOKEN", bot); - writeFileSync(ENV_PATH, updated); - console.log("[grab-token] wrote tokens to .env"); - - await context.close(); -} - -function upsertEnv(text: string, key: string, value: string): string { - if (!value) return text; - const re = new RegExp(`^${key}=.*$`, "m"); - if (re.test(text)) return text.replace(re, `${key}=${value}`); - return text + (text.endsWith("\n") ? "" : "\n") + `${key}=${value}\n`; -} - -main().catch((err) => { - console.error("[grab-token] failed:", err); - process.exit(1); -}); diff --git a/e2e/restart-recovery.ts b/e2e/restart-recovery.ts deleted file mode 100644 index 4f35024..0000000 --- a/e2e/restart-recovery.ts +++ /dev/null @@ -1,440 +0,0 @@ -/** - * End-to-end test: bridge-restart-recovery for HITL components. - * - * Verifies that after a bridge restart between picker-post and click, - * the picker is still actionable. Slack is the source of truth for - * both the bound resume value (`button.value`) AND the dispatch - * context (`message.metadata.event_payload.{handler, ...}`). - * - * Scenario: - * - * • **HITL** — `defineHumanInTheLoop` frontend tool (`confirm_write`). - * Resume mechanism: `runAgent({forwardedProps:{command:{resume}}})`, - * which the CopilotKit middleware turns into a tool-result message - * for the intercepted frontend-tool call. This is the "approve the - * write 20 minutes later, after a deploy restarted the bot" story. - * - * Flow: - * - * 1. Spawn bridge instance #1 (in-process). Start it. - * 2. Post a user prompt that triggers the picker. - * 3. Poll Slack until the picker lands; assert metadata + per-button - * encoded values are present. - * 4. **Stop** instance #1 — its in-memory `HumanInTheLoopRegistry` - * is discarded. - * 5. Spawn bridge instance #2. - * 6. Inject a synthetic Slack `block_actions` event into instance - * #2's Bolt app via `app.processEvent`. - * 7. Poll Slack: assert the picker has been replaced in-place by - * the resolved-state render AND the agent's natural-language - * reply lands in the same thread. - * 8. Tear down instance #2. - * - * Run: `pnpm e2e:restart` - */ -import "dotenv/config"; -import type { ReceiverEvent } from "@slack/bolt"; -import { - createSlackBridge, - defaultSlackContext, - defaultSlackTools, -} from "@copilotkit/slack"; -import type { SlackBridge } from "@copilotkit/slack"; -import { appComponents } from "../app/components/index.js"; -import { appContext } from "../app/context/app-context.js"; -import { appHitl } from "../app/human-in-the-loop/index.js"; -import { appTools } from "../app/tools/index.js"; -import { postAsUser, threadReplies, BOT_USER_ID } from "./slack-api.js"; - -const TEST_CHANNEL = process.env.E2E_CHANNEL ?? "C0B49MEJ1HQ"; // #ag-ui-bot-test - -function required(name: string): string { - const v = process.env[name]; - if (!v) { - console.error(`missing required env var ${name}`); - process.exit(1); - } - return v; -} - -function makeBridge() { - return createSlackBridge({ - agentUrl: required("AGENT_URL"), - slackBotToken: required("SLACK_BOT_TOKEN"), - slackAppToken: required("SLACK_APP_TOKEN"), - tools: [...defaultSlackTools, ...appTools], - context: [...defaultSlackContext, ...appContext], - components: appComponents, - humanInTheLoopComponents: appHitl, - }); -} - -const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -async function waitForPicker( - parentTs: string, - expectedEventType: string, -): Promise<{ - ts: string; - buttons: Array<{ action_id: string; value: string; text: string }>; - metadata: { event_type?: string; event_payload?: Record<string, unknown> }; -}> { - const t0 = Date.now(); - while (Date.now() - t0 < 30_000) { - await wait(1500); - const r = await threadReplies(TEST_CHANNEL, parentTs, true); - const picker = r.find((m) => { - const md = (m as { metadata?: { event_type?: string } }).metadata; - return m.user === BOT_USER_ID && md?.event_type === expectedEventType; - }) as - | { - ts?: string; - blocks?: Array<Record<string, unknown>>; - metadata?: { - event_type?: string; - event_payload?: Record<string, unknown>; - }; - } - | undefined; - if (!picker) continue; - const buttons: Array<{ action_id: string; value: string; text: string }> = - []; - // confirm_write wraps its blocks in a colored attachment, so the action - // buttons live under attachments[].blocks; older pickers use top-level - // blocks. Scan both. - const pickerAtt = ( - picker as { - attachments?: Array<{ blocks?: Array<Record<string, unknown>> }>; - } - ).attachments; - const allBlocks = [ - ...(picker.blocks ?? []), - ...(pickerAtt ?? []).flatMap((a) => a.blocks ?? []), - ]; - for (const b of allBlocks) { - if ( - b.type === "actions" && - Array.isArray((b as { elements?: unknown[] }).elements) - ) { - for (const el of ( - b as { - elements: Array<{ - type?: string; - action_id?: string; - value?: string; - text?: { text?: string }; - }>; - } - ).elements) { - if (el.type === "button" && el.action_id && el.value) { - buttons.push({ - action_id: el.action_id, - value: el.value, - text: el.text?.text ?? "", - }); - } - } - } - } - return { - ts: picker.ts!, - buttons, - metadata: picker.metadata ?? {}, - }; - } - throw new Error(`never saw picker with event_type=${expectedEventType}`); -} - -async function waitForAgentReply( - parentTs: string, - sinceCount: number, - regex: RegExp, - timeoutMs = 30_000, -): Promise<string> { - const t0 = Date.now(); - while (Date.now() - t0 < timeoutMs) { - await wait(1500); - const r = await threadReplies(TEST_CHANNEL, parentTs); - const replies = r.filter((m) => m.user === BOT_USER_ID); - for (let i = sinceCount; i < replies.length; i++) { - const txt = replies[i]?.text ?? ""; - if (regex.test(txt)) return txt; - } - } - throw new Error(`never saw agent reply matching ${regex}`); -} - -interface BlockActionsBody { - type: "block_actions"; - user: { id: string; username: string; name: string; team_id: string }; - api_app_id: string; - token: string; - container: { - type: "message"; - message_ts: string; - channel_id: string; - thread_ts?: string; - }; - trigger_id: string; - team: { id: string; domain: string }; - channel: { id: string; name: string }; - message: { - ts: string; - type: "message"; - user: string; - text: string; - blocks: unknown[]; - thread_ts?: string; - }; - state: { values: Record<string, unknown> }; - response_url: string; - actions: Array<{ - action_id: string; - block_id: string; - text: { type: "plain_text"; text: string; emoji: boolean }; - type: "button"; - value: string; - action_ts: string; - }>; -} - -function synthesiseBlockActions(args: { - pickerTs: string; - parentTs: string; - actionId: string; - value: string; - buttonText: string; -}): BlockActionsBody { - return { - type: "block_actions", - user: { - id: "U05PN5700P9", - username: "atai", - name: "atai", - team_id: "T05QFA4BW9X", - }, - api_app_id: "A0B49763Y66", - token: "synthetic-token", - container: { - type: "message", - message_ts: args.pickerTs, - channel_id: TEST_CHANNEL, - thread_ts: args.parentTs, - }, - trigger_id: "synthetic-trigger", - team: { id: "T05QFA4BW9X", domain: "copilotkit" }, - channel: { id: TEST_CHANNEL, name: "ag-ui-bot-test" }, - message: { - ts: args.pickerTs, - type: "message", - user: BOT_USER_ID, - text: "", - blocks: [], - thread_ts: args.parentTs, - }, - state: { values: {} }, - response_url: "", - actions: [ - { - action_id: args.actionId, - block_id: "synthetic-block", - text: { type: "plain_text", text: args.buttonText, emoji: true }, - type: "button", - value: args.value, - action_ts: `${Date.now() / 1000}`, - }, - ], - }; -} - -async function injectClick( - bridge: SlackBridge, - ev: BlockActionsBody, -): Promise<void> { - let acked = false; - const fakeEvent: ReceiverEvent = { - body: ev, - ack: async () => { - acked = true; - }, - }; - await bridge.app.processEvent(fakeEvent); - if (!acked) throw new Error("Bolt didn't ack the synthetic event"); -} - -/** - * One full restart-recovery cycle. Returns nothing; throws on any - * assertion failure (the caller decides whether to continue running - * the remaining scenarios). - */ -async function runScenario(args: { - label: string; - prompt: string; - pickerEventType: string; - pickButton: ( - buttons: Array<{ action_id: string; value: string; text: string }>, - ) => { action_id: string; value: string; text: string }; - /** - * Pattern the agent's natural-language reply must match. For - * interrupts the graph is paused and resume produces a fresh - * reply — set this to the expected text. For HITL the graph is - * already finished; pass `undefined` to skip the reply check. - */ - replyRegex?: RegExp; - resolvedTextRegex: RegExp; -}): Promise<void> { - console.log(`\n══════ ${args.label} ══════`); - console.log(`[${args.label}] starting bridge instance #1…`); - const b1 = makeBridge(); - await b1.start(); - console.log(`[${args.label}] instance #1 up`); - - const sent = await postAsUser(TEST_CHANNEL, args.prompt); - const parentTs = (sent as { ts?: string }).ts!; - console.log(`[${args.label}] posted prompt, parent ts:`, parentTs); - - const picker = await waitForPicker(parentTs, args.pickerEventType); - console.log( - `[${args.label}] picker landed at ts=%s with %d buttons`, - picker.ts, - picker.buttons.length, - ); - - // Verify metadata. - const evType = picker.metadata.event_type; - if (evType !== args.pickerEventType) { - throw new Error( - `picker has event_type=${evType}, expected ${args.pickerEventType}`, - ); - } - const ep = picker.metadata.event_payload as { handler?: string } | undefined; - if (!ep?.handler) throw new Error("picker metadata missing handler"); - console.log(`[${args.label}] ✓ picker metadata: handler=%s`, ep.handler); - - for (const btn of picker.buttons) JSON.parse(btn.value); // throws if malformed - console.log( - `[${args.label}] ✓ all %d buttons carry JSON-encoded values`, - picker.buttons.length, - ); - - const existing = await threadReplies(TEST_CHANNEL, parentTs); - const seenCount = existing.filter((m) => m.user === BOT_USER_ID).length; - - console.log(`[${args.label}] stopping bridge instance #1…`); - await b1.stop(); - - console.log( - `[${args.label}] starting bridge instance #2 (fresh in-memory registry)…`, - ); - const b2 = makeBridge(); - await b2.start(); - - const chosen = args.pickButton(picker.buttons); - console.log( - `[${args.label}] simulating click on action_id=%s text="%s" value=%s`, - chosen.action_id, - chosen.text, - chosen.value.slice(0, 80), - ); - try { - await injectClick( - b2, - synthesiseBlockActions({ - pickerTs: picker.ts, - parentTs, - actionId: chosen.action_id, - value: chosen.value, - buttonText: chosen.text, - }), - ); - console.log(`[${args.label}] ✓ synthetic block_actions processed`); - - if (args.replyRegex) { - const reply = await waitForAgentReply( - parentTs, - seenCount, - args.replyRegex, - 30_000, - ); - console.log(`[${args.label}] ✓ agent reply landed: %s`, reply); - } else { - // HITL: no agent reply on this turn — the LangGraph thread is - // already finished; the resolved-render replacement IS the - // visible outcome. Wait briefly to give chat.update time to land. - await wait(2000); - console.log( - `[${args.label}] ✓ (no agent reply expected — HITL graph is already RUN_FINISHED)`, - ); - } - - // Verify picker replaced in-place. - const after = await threadReplies(TEST_CHANNEL, parentTs); - const replacedPicker = after.find((m) => m.ts === picker.ts) as - | { blocks?: Array<Record<string, unknown>> } - | undefined; - if (!replacedPicker) - throw new Error("original picker message vanished entirely"); - const stillHasButtons = (replacedPicker.blocks ?? []).some( - (b) => - b.type === "actions" && - Array.isArray((b as { elements?: unknown[] }).elements) && - ( - (b as { elements: unknown[] }).elements as Array<{ type?: string }> - ).some((e) => e.type === "button"), - ); - if (stillHasButtons) { - throw new Error( - "picker still has buttons — resolved render didn't replace", - ); - } - const sectionText = ( - (replacedPicker.blocks ?? []).find((b) => b.type === "section") as - | { text?: { text?: string } } - | undefined - )?.text?.text; - if (!sectionText || !args.resolvedTextRegex.test(sectionText)) { - throw new Error( - `resolved render didn't match ${args.resolvedTextRegex}; got: ${sectionText}`, - ); - } - console.log( - `[${args.label}] ✓ picker replaced in-place by resolved render: %s`, - sectionText.slice(0, 100), - ); - } finally { - await b2.stop(); - } -} - -async function main() { - await runScenario({ - label: "hitl-restart", - prompt: `<@${BOT_USER_ID}> file a Linear issue titled "Checkout 500s under load" with a one-line description. Use the confirm_write tool to ask me to approve it first.`, - pickerEventType: "copilotkit_slack_hitl", - pickButton: (buttons) => { - const b = buttons.find((btn) => { - try { - const v = JSON.parse(btn.value); - return v && typeof v === "object" && v.confirmed === true; - } catch { - return false; - } - }); - if (!b) throw new Error("no Create (confirmed:true) button found"); - return b; - }, - // After the user approves, the agent goes on to perform the write and - // reply — but the resolved-render replacement of the picker is the - // deterministic signal this test asserts on. - replyRegex: undefined, - resolvedTextRegex: /approved|declined/i, - }); - - console.log("\n══════ ALL GREEN ══════"); - console.log("HITL restart-recovery scenario passed."); -} - -main().catch((err) => { - console.error("[restart-e2e] fatal:", err); - process.exit(1); -}); diff --git a/e2e/run.ts b/e2e/run.ts index 96c5b70..70c2128 100644 --- a/e2e/run.ts +++ b/e2e/run.ts @@ -326,7 +326,7 @@ async function runCase(spec: E2ECase): Promise<CaseResult> { async function main() { if (!USER_TOKEN) { console.error( - "SLACK_USER_TOKEN missing in .env — run `pnpm exec tsx e2e/grab-user-token.ts` first.", + "SLACK_USER_TOKEN missing in .env — add an xoxp token with chat:write; see e2e/README.md.", ); process.exit(1); } diff --git a/e2e/slack-api.ts b/e2e/slack-api.ts index a5bdda8..5234703 100644 --- a/e2e/slack-api.ts +++ b/e2e/slack-api.ts @@ -45,7 +45,7 @@ export async function postAsUser( ) { if (!USER_TOKEN) { throw new Error( - "SLACK_USER_TOKEN missing — run `pnpm exec tsx e2e/grab-user-token.ts` first", + "SLACK_USER_TOKEN missing — add an xoxp token with chat:write to .env; see e2e/README.md", ); } // `link_names: 1` makes Slack resolve `@username` (and `@here`/`@channel`) diff --git a/e2e/telegram-api.ts b/e2e/telegram-api.ts deleted file mode 100644 index 58abdbf..0000000 --- a/e2e/telegram-api.ts +++ /dev/null @@ -1,413 +0,0 @@ -/** - * Telegram Bot API helpers used by the E2E harness. - * - * ## Chosen approach: (b) MANUAL-TRIGGER smoke - * - * Unlike Slack, the Telegram Bot API does NOT allow impersonating a human - * user to send messages programmatically. The Bot API only lets a bot send - * messages AS ITSELF. This creates a bootstrapping problem: - * - * - We cannot "send a message as a test user" purely via the Bot API. - * - A bot can call `sendMessage` into a chat, but the CopilotKit bot's - * loop guard intentionally ignores messages originating from bots - * (including itself) to prevent infinite loops. - * - The MTProto (TDLib / Telegram Desktop) approach — driving a REAL user - * account programmatically — requires a separate phone-number-verified - * account, a registered Telegram API App (api_id + api_hash), a session - * file, and far more infra than is practical here. - * - * Therefore this harness uses a DOCUMENTED MANUAL-TRIGGER flow: - * - * 1. The operator opens the Telegram chat with the bot and sends the test - * prompt manually (the exact text logged by the harness before each case). - * 2. The harness polls `getUpdates` (or `getMessages` via a stored - * `offset`) until it sees the bot's reply in that chat, then runs the - * expectations against the reply text. - * - * ### Path to full automation (approach a) - * - * Full automation IS achievable by adding a second lightweight Telegram bot - * ("sender bot") and a test supergroup: - * - Add both the main bot AND the sender bot to a supergroup. - * - The sender bot calls `sendMessage` into the group; the main bot's - * listener fires on group messages (not from itself), processes them, - * and replies back into the group. - * - The harness drives the sender bot, polls `getUpdates` on the main - * bot token for the group replies, and validates them. - * - * Set TELEGRAM_SENDER_BOT_TOKEN in .env to enable automatic sending when a - * sender bot is available. When it's missing, the harness falls back to the - * manual-trigger flow and logs a clear prompt for the operator. - * - * ### NOTE on coverage - * - * The manual-trigger flow DOES NOT reduce assertion coverage — all - * expectations (finalContains, balancedBrackets, minLength, followUp) are - * evaluated on the real bot reply. What it reduces is automation: the - * operator must type (or paste) each prompt. The harness logs the exact text - * to send and waits up to `maxWaitMs` for a reply before timing out. - */ -import "dotenv/config"; - -// ── Env ────────────────────────────────────────────────────────────────────── - -const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN; -if (!BOT_TOKEN) throw new Error("TELEGRAM_BOT_TOKEN missing in .env"); - -/** - * The numeric chat ID of the test chat where the bot is a member. - * For DMs this is the user's numeric Telegram ID (positive integer). - * For groups/supergroups it is the negative chat ID. - */ -export const TEST_CHAT_ID: string = process.env.TELEGRAM_TEST_CHAT_ID ?? ""; - -/** - * Optional second bot token. When set, the harness sends prompts - * programmatically via this "sender bot" (approach a). When absent, - * the harness falls back to the manual-trigger flow (approach b). - */ -export const SENDER_BOT_TOKEN: string | undefined = - process.env.TELEGRAM_SENDER_BOT_TOKEN; - -// ── Raw Bot API helper ──────────────────────────────────────────────────────── - -const TELEGRAM_API = "https://api.telegram.org/bot"; - -async function tgApi<T = Record<string, unknown>>( - token: string, - method: string, - params: Record<string, unknown> = {}, -): Promise<T> { - const url = `${TELEGRAM_API}${token}/${method}`; - const res = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(params), - }); - const json = (await res.json()) as { - ok: boolean; - result?: T; - description?: string; - }; - if (!json.ok) { - throw new Error( - `Telegram ${method} failed: ${json.description ?? JSON.stringify(json)}`, - ); - } - return json.result as T; -} - -// ── Types ───────────────────────────────────────────────────────────────────── - -export interface TelegramMessage { - message_id: number; - from?: { - id: number; - is_bot: boolean; - username?: string; - first_name?: string; - }; - chat: { id: number; type: string }; - date: number; - text?: string; - reply_to_message?: TelegramMessage; -} - -export interface TelegramUpdate { - update_id: number; - message?: TelegramMessage; - edited_message?: TelegramMessage; -} - -// ── Sending ─────────────────────────────────────────────────────────────────── - -/** - * Send a message into `chatId` using the sender bot token (approach a). - * Returns the sent message (includes its `message_id`). - * - * IMPORTANT: this triggers the main CopilotKit bot only when: - * (a) the chat is a group/supergroup with BOTH the sender bot and the main - * bot as members, OR - * (b) the main bot's listener is configured to also handle messages from - * other bots (non-default — requires explicit allow-bot config). - * - * In a DM context (TELEGRAM_TEST_CHAT_ID is the operator's personal ID) this - * call would fail unless the operator's chat id is also the sender bot's - * user id, which doesn't make sense. Use group chats for automated mode. - */ -export async function sendMessageAsSenderBot( - chatId: string | number, - text: string, - opts: { replyToMessageId?: number } = {}, -): Promise<TelegramMessage> { - if (!SENDER_BOT_TOKEN) { - throw new Error( - "TELEGRAM_SENDER_BOT_TOKEN not set — automated send unavailable", - ); - } - const params: Record<string, unknown> = { chat_id: chatId, text }; - if (opts.replyToMessageId) params.reply_to_message_id = opts.replyToMessageId; - return tgApi<TelegramMessage>(SENDER_BOT_TOKEN, "sendMessage", params); -} - -// ── Polling helpers ─────────────────────────────────────────────────────────── - -/** - * Fetch a page of updates from the main bot since `offset`. - * Uses long-poll with a short timeout so we don't block indefinitely. - */ -export async function getUpdates( - offset: number, - limit = 20, -): Promise<TelegramUpdate[]> { - return tgApi<TelegramUpdate[]>(BOT_TOKEN!, "getUpdates", { - offset, - limit, - timeout: 5, - // Include both new messages and edits so we can observe streamed replies. - // The example bot streams by posting a placeholder and then editing it - // (chunked-edit mode), so we must subscribe to edited_message to see the - // final text. - allowed_updates: ["message", "edited_message"], - }); -} - -/** - * Drain any pending updates from the bot's queue (advances the offset without - * acting on them). Call this BEFORE sending a test prompt so we know the next - * update we see is the bot's reply to our case — not a stale message from a - * previous run. - * - * Returns the update_id to use as the "drain fence": poll for updates with - * `offset > drainFence` after this call. - */ -export async function drainUpdates(): Promise<number> { - let highestUpdateId = -1; - // Keep fetching until we get an empty page (queue exhausted). - for (;;) { - const updates = await getUpdates(highestUpdateId + 1, 100); - if (updates.length === 0) break; - for (const u of updates) { - if (u.update_id > highestUpdateId) highestUpdateId = u.update_id; - } - } - return highestUpdateId; -} - -/** - * Poll the bot's updates for a message FROM THE BOT in `chatId` after - * `sinceUpdateId`. Calls `onSample` after each poll so the caller can record - * mid-stream snapshots. - * - * NOTE: The example bot uses chunked-edit streaming — it posts a placeholder - * message (`_thinking…_`) and then edits it repeatedly as chunks arrive. This - * function subscribes to both `message` and `edited_message` updates (see - * `getUpdates`) and tracks the LATEST text for each bot `message_id`, so - * `finalText` reflects the last edit rather than the initial placeholder. - * - * Returns the highest `update_id` consumed (`reachedUpdateId`) so callers can - * pass it as the baseline for a follow-up `watchForNextReply` call. - */ -export async function watchForReply(args: { - chatId: string | number; - sinceUpdateId: number; - intervalMs: number; - timeoutMs: number; - onSample: (sample: { - elapsedMs: number; - text: string | undefined; - message: TelegramMessage | undefined; - }) => Promise<void> | void; -}): Promise<{ - finalText: string | undefined; - finalMessage: TelegramMessage | undefined; - reachedUpdateId: number; -}> { - const start = Date.now(); - let offset = args.sinceUpdateId + 1; - // Map from message_id → latest known TelegramMessage (tracks edits). - const botMessageMap = new Map<number, TelegramMessage>(); - let stable = 0; - let lastLen = -1; - // Track the highest update_id we have consumed so callers can use it as the - // next baseline without re-delivering already-confirmed updates. - let reachedUpdateId = args.sinceUpdateId; - - while (Date.now() - start < args.timeoutMs) { - const updates = await getUpdates(offset); - for (const u of updates) { - if (u.update_id >= offset) offset = u.update_id + 1; - if (u.update_id > reachedUpdateId) reachedUpdateId = u.update_id; - // Accept both new messages and edits. - const msg = u.message ?? u.edited_message; - if (!msg) continue; - if (String(msg.chat.id) !== String(args.chatId)) continue; - // Track the latest text for each bot message_id. - if (msg.from?.is_bot) { - botMessageMap.set(msg.message_id, msg); - } - } - // The "last" bot message is the one with the highest message_id. - let lastMessage: TelegramMessage | undefined; - for (const msg of botMessageMap.values()) { - if (!lastMessage || msg.message_id > lastMessage.message_id) { - lastMessage = msg; - } - } - const text = lastMessage?.text; - await args.onSample({ - elapsedMs: Date.now() - start, - text, - message: lastMessage, - }); - const len = text?.length ?? 0; - if (len === lastLen && len > 0) { - stable++; - if (stable >= 3) break; - } else { - stable = 0; - lastLen = len; - } - await new Promise((r) => setTimeout(r, args.intervalMs)); - } - - let lastMessage: TelegramMessage | undefined; - for (const msg of botMessageMap.values()) { - if (!lastMessage || msg.message_id > lastMessage.message_id) { - lastMessage = msg; - } - } - return { - finalText: lastMessage?.text, - finalMessage: lastMessage, - reachedUpdateId, - }; -} - -/** - * Watch for a SUBSEQUENT bot reply in the same chat after `seenCount` distinct - * bot message_ids have already been observed. Used by the follow-up step. - * - * Like `watchForReply`, this function tracks both `message` and - * `edited_message` updates and keeps the latest text per `message_id` so edits - * (chunked-edit streaming) are reflected in `finalText`. - * - * `sinceUpdateId` should be the `reachedUpdateId` returned by the preceding - * `watchForReply` call — NOT the original drain fence — because `getUpdates` - * destructively advances the server-side offset and prior updates will not - * reappear. - * - * Returns the highest `update_id` consumed (`reachedUpdateId`). - */ -export async function watchForNextReply(args: { - chatId: string | number; - sinceUpdateId: number; - seenCount: number; - intervalMs: number; - timeoutMs: number; - onSample: (sample: { - elapsedMs: number; - text: string | undefined; - message: TelegramMessage | undefined; - }) => Promise<void> | void; -}): Promise<{ - finalText: string | undefined; - finalMessage: TelegramMessage | undefined; - reachedUpdateId: number; -}> { - const start = Date.now(); - let offset = args.sinceUpdateId + 1; - // Map from message_id → latest known TelegramMessage (tracks edits). - const botMessageMap = new Map<number, TelegramMessage>(); - let stable = 0; - let lastLen = -1; - let reachedUpdateId = args.sinceUpdateId; - - while (Date.now() - start < args.timeoutMs) { - const updates = await getUpdates(offset); - for (const u of updates) { - if (u.update_id >= offset) offset = u.update_id + 1; - if (u.update_id > reachedUpdateId) reachedUpdateId = u.update_id; - // Accept both new messages and edits. - const msg = u.message ?? u.edited_message; - if (!msg) continue; - if (String(msg.chat.id) !== String(args.chatId)) continue; - if (msg.from?.is_bot) { - botMessageMap.set(msg.message_id, msg); - } - } - // Collect distinct bot message_ids in insertion order (Map preserves it). - const distinctMessages = Array.from(botMessageMap.values()).sort( - (a, b) => a.message_id - b.message_id, - ); - // Target is the (seenCount+1)-th distinct message, i.e. the first NEW one. - const target = - distinctMessages.length > args.seenCount - ? distinctMessages[args.seenCount] - : undefined; - const text = target?.text; - await args.onSample({ - elapsedMs: Date.now() - start, - text, - message: target, - }); - const len = text?.length ?? 0; - if (target && len === lastLen && len > 0) { - stable++; - if (stable >= 3) break; - } else { - stable = 0; - lastLen = len; - } - await new Promise((r) => setTimeout(r, args.intervalMs)); - } - - const distinctMessages = Array.from(botMessageMap.values()).sort( - (a, b) => a.message_id - b.message_id, - ); - const target = - distinctMessages.length > args.seenCount - ? distinctMessages[args.seenCount] - : undefined; - return { finalText: target?.text, finalMessage: target, reachedUpdateId }; -} - -// ── Bracket balance ──────────────────────────────────────────────────────────── - -/** - * Check that the text has balanced Markdown code fences and inline backticks. - * - * Telegram uses MarkdownV2 / HTML formatting — but the bot's text field in - * `getUpdates` is the raw text the bot sent, which uses Markdown-style fences - * (the telegram-html module converts them before sending to Telegram). We - * assert on the raw text from the bot's perspective (what the LLM produced) - * before the HTML renderer processes it. - * - * Note: The Telegram harness observes edits via `edited_message` updates, so - * it tracks the latest text of each bot message. The `balancedBrackets` check - * in `telegram-run.ts` is applied to the final (most recently edited) text. - */ -export function isBalanced(text: string): boolean { - if (!text) return true; - - // ── Fences ───────────────────────────────────────────────── - const fences = (text.match(/```/g) || []).length; - if (fences % 2 !== 0) { - const lastFenceIdx = text.lastIndexOf("```"); - const tail = text.slice(lastFenceIdx + 3); - const nl = tail.indexOf("\n"); - const codeBody = nl >= 0 ? tail.slice(nl + 1) : ""; - if (/\S/.test(codeBody)) return false; - // just-opened fence; treat as balanced - } - - // ── Inline backticks (outside fences) ────────────────────── - const noFence = text.replace(/```[\s\S]*?```/g, ""); - const inline = (noFence.match(/`/g) || []).length; - if (inline % 2 !== 0) { - const lastBt = noFence.lastIndexOf("`"); - const after = noFence.slice(lastBt + 1); - if (/\S/.test(after)) return false; - } - return true; -} diff --git a/e2e/telegram-cases.ts b/e2e/telegram-cases.ts deleted file mode 100644 index 9bd228c..0000000 --- a/e2e/telegram-cases.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * Catalog of end-to-end test cases for the Telegram bot harness. - * - * Each case describes a prompt to send and expectations to assert on the - * bot's reply. The shape mirrors `examples/slack/e2e/cases.ts` with - * Telegram-specific adaptations: - * - * - No Block Kit assertions (Telegram uses HTML/MarkdownV2 rendering). - * - No Slack mrkdwn format (`*bold*` → Telegram uses `**bold**` before - * the HTML converter, or `<b>` after). - * - No @mention syntax in prompts (Telegram uses @username or /commands). - * - Bullet list assertion checks for `•` or `-` markers in plain text - * (not Slack's translated `•`). - * - * Fields: - * name human-readable label - * prompt text to send (operator pastes this or sender-bot posts it) - * sampleIntervalMs how often to poll for the bot's reply - * maxWaitMs give up after this long (default 30 s) - * expectations checks on the final reply text - * followUp optional second turn in the same thread - */ - -export interface E2ECase { - name: string; - prompt: string; - sampleIntervalMs?: number; - maxWaitMs?: number; - /** - * Optional follow-up turn: after the first reply lands, this prompt is - * sent into the same reply chain. Used to test conversation continuity. - * In the manual-trigger flow the operator sends this second prompt too; - * in automated mode the sender bot posts it as a reply to the bot's - * previous message. - */ - followUp?: { - prompt: string; - expectations?: E2ECase["expectations"]; - }; - expectations?: { - /** Bot's final reply must contain all of these substrings (case-insensitive). */ - finalContains?: string[]; - /** Bot's final reply must NOT contain any of these. */ - finalNotContains?: string[]; - /** Final text must have balanced code fences and backticks. */ - balancedBrackets?: boolean; - /** Minimum reply length in characters (catches truncation regressions). */ - minLength?: number; - /** - * Custom predicate run against all bot messages collected for this case. - * `replies` is the array of text strings; return an array of error - * strings (empty = pass). - */ - perReplyChecks?: (replies: string[]) => string[]; - }; -} - -export const CASES: E2ECase[] = [ - // ── A. Basic response ────────────────────────────────────────────────────── - { - name: "A1 — single-word echo", - // Confirms the bot responds and loop-guard doesn't swallow the reply. - prompt: "Reply with exactly the word HOTEL and nothing else", - expectations: { - finalContains: ["HOTEL"], - minLength: 5, - }, - }, - { - name: "A2 — single-token response (regression: was ECHO/AL truncation bug)", - prompt: "Reply with exactly the word ECHO and nothing else", - expectations: { - finalContains: ["ECHO"], - finalNotContains: ["…"], - minLength: 4, - }, - }, - - // ── B. Response length / shape ───────────────────────────────────────────── - { - name: "B1 — multi-paragraph prose", - prompt: - "Write 4 paragraphs about the history of the printing press. " + - "Take your time. Be detailed.", - sampleIntervalMs: 1000, - maxWaitMs: 60_000, - expectations: { - minLength: 600, - balancedBrackets: true, - }, - }, - { - name: "B2 — long response balanced fences", - prompt: - "Write a thorough 6-paragraph essay about agent protocols. " + - "Each paragraph 4-6 sentences. Be detailed, no apologies.", - sampleIntervalMs: 1000, - maxWaitMs: 90_000, - expectations: { - minLength: 1000, - balancedBrackets: true, - }, - }, - - // ── B/markdown — Telegram formatting ────────────────────────────────────── - { - name: "B11 — fenced code block (Python snippet)", - // Confirms the LLM emits a fenced block and the bot doesn't corrupt it. - prompt: - "Show me a short Python snippet for a Fibonacci function in a fenced code block.", - sampleIntervalMs: 700, - maxWaitMs: 45_000, - expectations: { - finalContains: ["```"], - balancedBrackets: true, - }, - }, - { - name: "B12 — bullet list", - prompt: - "List three programming languages as bullet points using - markers.", - expectations: { - perReplyChecks: (replies) => { - const joined = replies.join("\n"); - // Expect at least one line starting with "-" or "•" - const hasBullets = /^[-•]/m.test(joined); - if (!hasBullets) { - return ["no bullet-point line found in bot reply"]; - } - return []; - }, - balancedBrackets: true, - }, - }, - - // ── C. Triage / agentic prompts ──────────────────────────────────────────── - { - name: "C1 — triage prompt (structured summary expected)", - // Core use-case for the on-call triage bot. - prompt: "Triage my open issues and give me a structured summary.", - sampleIntervalMs: 1000, - maxWaitMs: 60_000, - expectations: { - // The bot should produce a non-trivial response. - minLength: 100, - balancedBrackets: true, - }, - }, - { - name: "C2 — render table prompt (text/markdown table expected)", - // Unlike Slack (which renders a monospace-aligned table in a code fence), - // Telegram may emit a plain markdown table or a pre-formatted block. - // We assert the key names appear in the output and fences are balanced. - prompt: - "Give me a 3-row table comparing LangGraph, AG-UI, and CopilotKit " + - "(columns: name, role). Use plain text or a code block.", - expectations: { - finalContains: ["langgraph", "ag-ui", "copilotkit"], - balancedBrackets: true, - }, - }, - - // ── D. Conversation continuity ───────────────────────────────────────────── - { - name: "D1 — thread continuation (two-turn conversation)", - // Sends a first prompt, then a follow-up in the same reply chain. - prompt: "Say the single word ALPHA", - expectations: { finalContains: ["ALPHA"] }, - followUp: { - prompt: "Now say the single word BRAVO.", - expectations: { finalContains: ["BRAVO"] }, - }, - }, -]; diff --git a/e2e/telegram-run.ts b/e2e/telegram-run.ts deleted file mode 100644 index 5f24f86..0000000 --- a/e2e/telegram-run.ts +++ /dev/null @@ -1,346 +0,0 @@ -/** - * E2E harness entrypoint for the Telegram bot. - * - * Control flow mirrors `examples/slack/e2e/run.ts`, adapted for the - * Telegram Bot API polling model. - * - * ## Send mode - * - * The harness detects which send mode is available at startup: - * - * AUTOMATED (approach a) - * Requires: TELEGRAM_SENDER_BOT_TOKEN set in .env. - * The sender bot posts each prompt into TELEGRAM_TEST_CHAT_ID; the - * main bot (TELEGRAM_BOT_TOKEN) sees it, processes it, and replies. - * The harness polls getUpdates on the MAIN bot token for the reply. - * - * MANUAL-TRIGGER (approach b — fallback) - * No TELEGRAM_SENDER_BOT_TOKEN needed. - * The harness prints each prompt and waits for the operator to send it - * in the test chat. It then polls getUpdates on the main bot token for - * the bot's reply. Coverage is identical; only the trigger step is manual. - * - * Run with: pnpm e2e:telegram - * - * Optional env: - * CASE_FILTER substring filter on case name (e.g. CASE_FILTER='C1' pnpm e2e:telegram) - */ -import "dotenv/config"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { CASES } from "./telegram-cases.js"; -import type { E2ECase } from "./telegram-cases.js"; -import { - drainUpdates, - sendMessageAsSenderBot, - watchForReply, - watchForNextReply, - isBalanced, - SENDER_BOT_TOKEN, - TEST_CHAT_ID, -} from "./telegram-api.js"; - -const RESULTS_DIR = "./e2e/results"; - -// ── Startup checks ──────────────────────────────────────────────────────────── - -if (!TEST_CHAT_ID) { - console.error( - "TELEGRAM_TEST_CHAT_ID missing in .env — set it to the numeric chat ID " + - "of the chat where the bot is a member.", - ); - process.exit(1); -} - -const AUTOMATED = !!SENDER_BOT_TOKEN; -if (AUTOMATED) { - console.log( - "[e2e] Mode: AUTOMATED — sender bot will post prompts automatically.", - ); -} else { - console.log( - "[e2e] Mode: MANUAL-TRIGGER — you will need to send each prompt manually.\n" + - " (Set TELEGRAM_SENDER_BOT_TOKEN in .env for full automation.)", - ); -} - -// ── Result types ────────────────────────────────────────────────────────────── - -interface CaseResult { - name: string; - prompt: string; - status: "pass" | "fail"; - errors: string[]; - durationMs: number; - finalText: string | undefined; - samples: { - elapsedMs: number; - balanced: boolean; - len: number; - preview: string; - full?: string; - }[]; - followUp?: CaseResult; -} - -// ── Expectations runner ─────────────────────────────────────────────────────── - -function runExpectations( - exp: NonNullable<E2ECase["expectations"]>, - finalText: string | undefined, - errors: string[], - prefix = "", -): void { - const tag = prefix ? `${prefix}: ` : ""; - if (exp.finalContains) { - for (const needle of exp.finalContains) { - if (!(finalText ?? "").toLowerCase().includes(needle.toLowerCase())) { - errors.push(`${tag}missing: ${JSON.stringify(needle)}`); - } - } - } - if (exp.finalNotContains) { - for (const needle of exp.finalNotContains) { - if ((finalText ?? "").toLowerCase().includes(needle.toLowerCase())) { - errors.push(`${tag}contained forbidden: ${JSON.stringify(needle)}`); - } - } - } - if (exp.balancedBrackets && finalText && !isBalanced(finalText)) { - errors.push(`${tag}text has unbalanced brackets`); - } - if (exp.minLength && (finalText?.length ?? 0) < exp.minLength) { - errors.push( - `${tag}too short (${finalText?.length ?? 0} < ${exp.minLength})`, - ); - } -} - -// ── Case runner ─────────────────────────────────────────────────────────────── - -/** - * Wait for the operator to send a prompt (manual-trigger mode). - * Prints the prompt text and waits `promptWaitMs` for the user to act. - */ -async function waitForOperator( - prompt: string, - promptWaitMs: number, -): Promise<void> { - console.log( - `\n [MANUAL] Please send the following message in the test chat:\n` + - ` ┌──────────────────────────────────────────────────────────┐\n` + - ` │ ${prompt.slice(0, 56).padEnd(56)} │\n` + - ` └──────────────────────────────────────────────────────────┘\n` + - ` Waiting up to ${Math.round(promptWaitMs / 1000)}s for your send…`, - ); - await new Promise((r) => setTimeout(r, promptWaitMs)); -} - -async function runCase(spec: E2ECase): Promise<CaseResult> { - const errors: string[] = []; - const samples: CaseResult["samples"] = []; - const t0 = Date.now(); - - const sampleIntervalMs = spec.sampleIntervalMs ?? 1000; - const maxWaitMs = spec.maxWaitMs ?? 30_000; - - // Drain stale updates so we don't accidentally match a previous run's reply. - const drainFence = await drainUpdates(); - - if (AUTOMATED) { - // Automated mode: sender bot sends the prompt. - await sendMessageAsSenderBot(TEST_CHAT_ID, spec.prompt).catch((e: Error) => - errors.push(`send failed: ${e.message}`), - ); - } else { - // Manual-trigger mode: give the operator 15 s to send the prompt manually. - // This wait is BEFORE we start polling — the bot won't have replied yet. - await waitForOperator(spec.prompt, 15_000); - } - - const onSample = (s: { elapsedMs: number; text: string | undefined }) => { - const text = s.text ?? ""; - const balanced = isBalanced(text); - samples.push({ - elapsedMs: s.elapsedMs, - balanced, - len: text.length, - preview: text.slice(0, 100), - ...(text.length > 0 && !balanced ? { full: text } : {}), - }); - }; - - const result = await watchForReply({ - chatId: TEST_CHAT_ID, - sinceUpdateId: drainFence, - intervalMs: sampleIntervalMs, - timeoutMs: maxWaitMs, - onSample, - }); - - // Capture the highest update_id consumed so the follow-up baseline is - // correct. getUpdates is destructive (advancing the offset confirms/deletes - // prior updates server-side), so we must NOT reuse drainFence here. - const firstReplyFence = result.reachedUpdateId; - - const finalText = result.finalText; - const exp = spec.expectations ?? {}; - runExpectations(exp, finalText, errors); - - const unbalancedSamples = samples.filter( - (s) => s.len > 0 && !s.balanced, - ).length; - if (exp.balancedBrackets && unbalancedSamples > 0) { - errors.push(`${unbalancedSamples} mid-stream samples were not balanced`); - } - - if (exp.perReplyChecks && finalText !== undefined) { - for (const e of exp.perReplyChecks([finalText])) { - errors.push(e); - } - } - - // ── Follow-up turn ────────────────────────────────────────────────────────── - let followUpResult: CaseResult | undefined; - if (spec.followUp && finalText) { - const followErrors: string[] = []; - const followSamples: CaseResult["samples"] = []; - const f0 = Date.now(); - - // Since getUpdates is destructive, the first reply's updates are already - // confirmed (gone from the server queue). The follow-up watcher starts from - // firstReplyFence and will see only NEW updates, so seenCount = 0. - const seenCount = 0; - - if (AUTOMATED && result.finalMessage) { - await sendMessageAsSenderBot(TEST_CHAT_ID, spec.followUp.prompt, { - replyToMessageId: result.finalMessage.message_id, - }).catch((e: Error) => - followErrors.push(`followUp send failed: ${e.message}`), - ); - } else { - await waitForOperator(spec.followUp.prompt, 15_000); - } - - const fResult = await watchForNextReply({ - chatId: TEST_CHAT_ID, - sinceUpdateId: firstReplyFence, - seenCount, - intervalMs: sampleIntervalMs, - timeoutMs: maxWaitMs, - onSample: (s) => { - const text = s.text ?? ""; - followSamples.push({ - elapsedMs: s.elapsedMs, - balanced: isBalanced(text), - len: text.length, - preview: text.slice(0, 100), - }); - }, - }); - - const followText = fResult.finalText; - const fexp = spec.followUp.expectations ?? {}; - if (fexp.finalContains) { - for (const needle of fexp.finalContains) { - if (!(followText ?? "").toLowerCase().includes(needle.toLowerCase())) { - followErrors.push(`followUp missing: ${JSON.stringify(needle)}`); - } - } - } - if (fexp.minLength && (followText?.length ?? 0) < fexp.minLength) { - followErrors.push("followUp too short"); - } - - followUpResult = { - name: `${spec.name} → followUp`, - prompt: spec.followUp.prompt, - status: followErrors.length === 0 ? "pass" : "fail", - errors: followErrors, - durationMs: Date.now() - f0, - finalText: followText, - samples: followSamples, - }; - } - - return { - name: spec.name, - prompt: spec.prompt, - status: - errors.length === 0 && (followUpResult?.status ?? "pass") === "pass" - ? "pass" - : "fail", - errors, - durationMs: Date.now() - t0, - finalText, - samples, - followUp: followUpResult, - }; -} - -// ── Main ─────────────────────────────────────────────────────────────────────── - -async function main() { - mkdirSync(RESULTS_DIR, { recursive: true }); - const stamp = new Date().toISOString().replace(/[:.]/g, "-"); - const runDir = join(RESULTS_DIR, stamp); - mkdirSync(runDir, { recursive: true }); - - const results: CaseResult[] = []; - const filter = process.env["CASE_FILTER"]; - const selected = filter - ? CASES.filter((c) => c.name.includes(filter)) - : CASES; - - for (const spec of selected) { - process.stdout.write(`\n──── ${spec.name} ────\n`); - try { - const r = await runCase(spec); - const flag = r.status === "pass" ? "✓" : "✗"; - console.log( - ` ${flag} ${r.durationMs}ms len=${r.finalText?.length ?? 0} samples=${r.samples.length}`, - ); - if (r.errors.length) console.log(" " + r.errors.join("\n ")); - if (r.followUp) { - const fflag = r.followUp.status === "pass" ? "✓" : "✗"; - console.log( - ` ↳ followUp ${fflag} ${r.followUp.durationMs}ms len=${r.followUp.finalText?.length ?? 0} samples=${r.followUp.samples.length}`, - ); - if (r.followUp.errors.length) { - console.log(" " + r.followUp.errors.join("\n ")); - } - } - results.push(r); - } catch (err) { - console.log(` ✗ exception: ${(err as Error).message}`); - results.push({ - name: spec.name, - prompt: spec.prompt, - status: "fail", - errors: [(err as Error).message], - durationMs: 0, - finalText: undefined, - samples: [], - }); - } - } - - writeFileSync( - join(runDir, "report.json"), - JSON.stringify( - { ranAt: stamp, mode: AUTOMATED ? "automated" : "manual", results }, - null, - 2, - ), - ); - const pass = results.filter((r) => r.status === "pass").length; - console.log( - `\n${pass}/${results.length} cases passed. Report: ${runDir}/report.json`, - ); - process.exit(pass === results.length ? 0 : 1); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/package.json b/package.json index de0de42..8fe787c 100644 --- a/package.json +++ b/package.json @@ -2,37 +2,22 @@ "name": "opentag", "version": "0.2.0", "private": true, - "description": "OpenTag — an open-source, self-hosted alternative to Claude in Slack: run your own AI agent on your infrastructure, with your model and your tools. Built on @copilotkit/channels; also runs on Discord, Telegram & WhatsApp.", + "description": "OpenTag — an open-source, self-hosted AI agent for Slack and Microsoft Teams, powered by CopilotKit Channels.", "license": "MIT", "type": "module", "scripts": { - "dev": "tsx watch app/index.ts", - "start": "tsx app/index.ts", - "channel": "tsx app/managed.ts", + "dev": "tsx watch server.ts", + "start": "tsx server.ts", + "runtime": "tsx server.ts", "agent": "cd agent && uv run python main.py", - "runtime": "tsx runtime.ts", - "notion-mcp": "tsx scripts/start-notion-mcp.ts", "check-types": "tsc --noEmit -p tsconfig.json", "test": "vitest run", - "e2e": "tsx e2e/run.ts", - "e2e:restart": "tsx e2e/restart-recovery.ts", - "e2e:telegram": "tsx e2e/telegram-run.ts" + "e2e": "tsx e2e/run.ts" }, "dependencies": { - "@copilotkit/channels": "^0.1.1", - "@copilotkit/channels-discord": "^0.0.3", - "@copilotkit/channels-intelligence": "^0.1.1", - "@copilotkit/channels-slack": "^0.1.2", - "@copilotkit/channels-telegram": "^0.0.4", - "@copilotkit/channels-ui": "^0.1.1", - "@copilotkit/channels-whatsapp": "^0.0.2", - "@copilotkit/runtime": "^1.62.3", - "@notionhq/notion-mcp-server": "^2.4.0", - "@slack/bolt": "^4.2.0", - "@slack/types": "^2.21.1", - "@tanstack/ai": "^0.32.0", - "@tanstack/ai-mcp": "^0.1.3", - "@tanstack/ai-openai": "^0.15.2", + "@ag-ui/client": "^0.0.57", + "@copilotkit/channels": "0.2.2-canary.rc-1", + "@copilotkit/runtime": "1.63.3-canary.rc-1", "dotenv": "^16.4.5", "playwright": "^1.49.0", "tsx": "^4.19.2", @@ -48,5 +33,6 @@ "overrides": { "rxjs": "^7.8.2" } - } + }, + "packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 383baff..4e33de4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,48 +11,15 @@ importers: .: dependencies: + '@ag-ui/client': + specifier: ^0.0.57 + version: 0.0.57 '@copilotkit/channels': - specifier: ^0.1.1 - version: 0.1.1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) - '@copilotkit/channels-discord': - specifier: ^0.0.3 - version: 0.0.3(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) - '@copilotkit/channels-intelligence': - specifier: ^0.1.1 - version: 0.1.1(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) - '@copilotkit/channels-slack': - specifier: ^0.1.2 - version: 0.1.2(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) - '@copilotkit/channels-telegram': - specifier: ^0.0.4 - version: 0.0.4(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) - '@copilotkit/channels-ui': - specifier: ^0.1.1 - version: 0.1.1(@ag-ui/core@0.0.57) - '@copilotkit/channels-whatsapp': - specifier: ^0.0.2 - version: 0.0.2(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + specifier: 0.2.2-canary.rc-1 + version: 0.2.2-canary.rc-1(@ag-ui/core@0.0.57)(@opentelemetry/api@1.9.1)(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) '@copilotkit/runtime': - specifier: ^1.62.3 - version: 1.62.3(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@6.47.0(ws@8.21.1)(zod@3.25.76)) - '@notionhq/notion-mcp-server': - specifier: ^2.4.0 - version: 2.4.1(@cfworker/json-schema@4.1.1)(js-yaml@4.3.0) - '@slack/bolt': - specifier: ^4.2.0 - version: 4.7.3(@types/express@5.0.6) - '@slack/types': - specifier: ^2.21.1 - version: 2.22.0 - '@tanstack/ai': - specifier: ^0.32.0 - version: 0.32.0(@opentelemetry/api@1.9.1) - '@tanstack/ai-mcp': - specifier: ^0.1.3 - version: 0.1.10(@cfworker/json-schema@4.1.1)(@opentelemetry/api@1.9.1)(zod@3.25.76) - '@tanstack/ai-openai': - specifier: ^0.15.2 - version: 0.15.10(@tanstack/ai@0.32.0(@opentelemetry/api@1.9.1))(ws@8.21.1)(zod@3.25.76) + specifier: 1.63.3-canary.rc-1 + version: 1.63.3-canary.rc-1(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) dotenv: specifier: ^16.4.5 version: 16.6.1 @@ -96,9 +63,6 @@ packages: '@ag-ui/client@0.0.57': resolution: {integrity: sha512-Xap2alG9Z0/j5kb3x4D7oTpe2sw1dfrC9rgJJr2NZu5vKcm8dzIPNd31mF2B4zS3BKqYIu245yxKPhEtT30MHw==} - '@ag-ui/core@0.0.52': - resolution: {integrity: sha512-Xo0bUaNV56EqylzcrAuhUkQX7et7+SZIrqZZtEByGwEq/I1EHny6ZMkWHLkKR7UNi0FJZwJyhKYmKJS3B2SEgA==} - '@ag-ui/core@0.0.54': resolution: {integrity: sha512-Ilx31OvRQaZfU7jSArGqz06JZKOsAt8zWiCPJljyp9zR6Tzl18oyfx8o6FsuGfAktGRe50GI9SCCxNXXysZwtA==} @@ -207,49 +171,80 @@ packages: resolution: {integrity: sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA==} engines: {node: '>=18'} + '@azure/abort-controller@2.2.0': + resolution: {integrity: sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==} + engines: {node: '>=22.0.0'} + + '@azure/core-auth@1.10.1': + resolution: {integrity: sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==} + engines: {node: '>=20.0.0'} + + '@azure/core-util@1.14.0': + resolution: {integrity: sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==} + engines: {node: '>=22.0.0'} + + '@azure/msal-common@16.5.2': + resolution: {integrity: sha512-GkDEL6TYo3HgT3UuqakdgE9PZfc1hMki6+Hwgy1uddb/EauvAKfu85vVhuofRSo22D1xTnWt8Ucwfg4vSCVwvA==} + engines: {node: '>=0.8.0'} + + '@azure/msal-node@5.1.5': + resolution: {integrity: sha512-ObTeMoNPmq19X3z40et9Xvs4ZoWVeJg43PZMRLG5iwVL+2nCtAerG3YTDItqPp1CfXNwmCXBbg8jn1DOx65c3g==} + engines: {node: '>=20'} + '@bufbuild/protobuf@2.12.1': resolution: {integrity: sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==} '@cfworker/json-schema@4.1.1': resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} - '@copilotkit/channels-discord@0.0.3': - resolution: {integrity: sha512-UXkWHcQJ9kLDkkGMXC3NyHuYwdVxWW09uyn1wm9er5tcL5/ZK12gvqgnj7G5j5pF7VI0qWXQ+ZvO3uZENo7o0A==} + '@copilotkit/channels-core@0.2.2-canary.rc-1': + resolution: {integrity: sha512-e1o6AGi67ylOHIFS/GgKWwv879sD4fe4Fuy42PhEDjj83/wQ8Ya5Tdv/X3A3g7elnJDa9nX1UVJvJMbztN3O5Q==} + peerDependencies: + vitest: ^4.0.0 + peerDependenciesMeta: + vitest: + optional: true + + '@copilotkit/channels-discord@0.2.2-canary.rc-1': + resolution: {integrity: sha512-fqjWRhSjRMdh0Ghlisk0gnT8tTyrPR47RpywDUoMxKQfkvObNx+GmgEqc/0c3LmT7yz3+W1z7Lab4AahrAFm8w==} - '@copilotkit/channels-intelligence@0.1.1': - resolution: {integrity: sha512-lS3vzbuRaeLTE+ucdsinBd9YHdm4lqUjvA57wowGMs+/WUZKzKCLGiE9BYOErQzo0PaM8MTx8TomTzNSny287w==} + '@copilotkit/channels-intelligence@0.2.2-canary.rc-1': + resolution: {integrity: sha512-LmjgGB/yuwqvDIiEXK3ZJ4xbaafxT0xuRPMyA7ChrR53y1R1Jta134n+oD4DauI7iHKI5mJ9Bbc0sPPQ9gp+yQ==} - '@copilotkit/channels-slack@0.1.2': - resolution: {integrity: sha512-h8VR+raH1GvdNxEuNcpGMe7TayZxzj33xobq6RCCU4hLu15jadCyYj9CXArYIcwyManlbWE5jqi6n8cla23E0g==} + '@copilotkit/channels-slack@0.2.2-canary.rc-1': + resolution: {integrity: sha512-9Du0gAtoMBEpg0EgCVsM+RZrF+LzIrc7R0m430f5njZ4bIncdFzUNcQ9SPfn64OuIQUl1+WfeTHT4qpZpN4m0g==} - '@copilotkit/channels-telegram@0.0.4': - resolution: {integrity: sha512-LyzGU9wHVPPc7MsR9+w1kBXcEqotI3k8XRJLola99ktzEpAtXTwCmBlnyh+j7UTCMXsRTjKz9eC6ULxDy82GbQ==} + '@copilotkit/channels-teams@0.2.2-canary.rc-1': + resolution: {integrity: sha512-bsswD71VQHh48x03hNEYEq1CFjTb9BSDA7F1bRBkF8LLCEcL4BslYwOL23H+Nn6vvDhz8OSSa+7Tih/jYO7svQ==} - '@copilotkit/channels-ui@0.1.1': - resolution: {integrity: sha512-wQ099E62DEhIuKz8O9Iqy1VP4qCyLtS8d5EwfF2MK8m8amWtuyyTUxV8vC1q9ecJMgAwCtKmeW/fYQR8NsLiXQ==} + '@copilotkit/channels-telegram@0.2.2-canary.rc-1': + resolution: {integrity: sha512-w98CTf53pIAEZ5z+lEZSLWceO3gjVjNCbTaghFuXf45WSx4FAdZeY4eINbOm/OHbWhrXMhWhExYDfQD3vNaawg==} - '@copilotkit/channels-whatsapp@0.0.2': - resolution: {integrity: sha512-T2eCeY6NXSAZbdQsARhhpLGsXP667wWqgsWpMPXy+dULvGhemw/ueyijV5/kEzMnxlaBoZ4TuGClXweMtDxZfw==} + '@copilotkit/channels-ui@0.2.2-canary.rc-1': + resolution: {integrity: sha512-S6z5OzXfrQs5XsQOUF5cGF3qMjzHdcb+rcqvOnruDLqLWdr3PQjHuPpqgjMxELxN1ru+bZsraFBTVROm0zcY9Q==} - '@copilotkit/channels@0.1.1': - resolution: {integrity: sha512-v/cOFfRZWKXstrj06YZmR5kikE9XhXluuJa81hZEhZ4I9C3U+Lnao1K5pL5DtmhUbowynMvBedVUNqKwGFLCvQ==} + '@copilotkit/channels-whatsapp@0.2.2-canary.rc-1': + resolution: {integrity: sha512-aKPxgR3EJ7RI7Mr4Pey+LZp0s7S8If9n1S8/Y1RRqJCqhRFkLcvXAeDCVp79k4p/HzFxzKzLpHhaLSGkOOOwAQ==} + + '@copilotkit/channels@0.2.2-canary.rc-1': + resolution: {integrity: sha512-hSc9Cvpak9M4UYLRcDTe2IkypKa87XytBhoMTBc/YLSXWudTtZMuLqlNnw2Ig+v4KYL7zo1h06pAlhGM4HLqXQ==} peerDependencies: vitest: ^4.0.0 peerDependenciesMeta: vitest: optional: true - '@copilotkit/core@1.62.3': - resolution: {integrity: sha512-Iw0XTJDylh4DTL1qqqoDSvMeL4lXGWfn8UAunnvYtVZ2BF3gs6Lw6K/yOQ0gGQKMqIxeQmgLVclErxwBcaJ8KQ==} + '@copilotkit/core@1.63.3-canary.rc-1': + resolution: {integrity: sha512-0jjmGj0WqkAui6l9SjjXeV9Kx8womeLtdGeMsjSfQKiuEPi+JE2fG4I08pd7D/H7ijEXlL10u5DSbLO+hYeuaQ==} engines: {node: '>=18'} '@copilotkit/license-verifier@0.5.0': resolution: {integrity: sha512-vrwKtIpYwF0FT9ZoYASH8owa2cGV0dhDvJGaCRaRMStwDxpc6DRdydKkhx8cWZXyBRxEYcq/Vygv4JvevhQQdQ==} - '@copilotkit/runtime@1.62.3': - resolution: {integrity: sha512-pOyQbd0IGH/rmyZ7yoGodc2FRicO3AXBI6y7lQDGjbat2lI9SLhsGQve7O3QfeYH0UZ4T9019EgnnKtSN7ImUQ==} + '@copilotkit/runtime@1.63.3-canary.rc-1': + resolution: {integrity: sha512-j8jATXoqM8P3EmhMyWm4D6l3DUQSDWZwTeR0c3SgnljMgc3TAHD40m4c7pQ7xJ0XMXSvZqHDQYyAJjmltzYbXA==} peerDependencies: - '@anthropic-ai/sdk': ^0.57.0 + '@anthropic-ai/sdk': '>=0.57.0' '@langchain/aws': '>=0.1.9' '@langchain/community': '>=0.3.58' '@langchain/core': '>=0.3.66' @@ -279,8 +274,8 @@ packages: openai: optional: true - '@copilotkit/shared@1.62.3': - resolution: {integrity: sha512-dfqaYjfJzTIjMBUMOHGZGoZ+v9pumsBuM1FMi5Sb66WkzxbtIKN0CCbYXXIHd0xsEo1pa8u1vZSN30sBtJuQ7g==} + '@copilotkit/shared@1.63.3-canary.rc-1': + resolution: {integrity: sha512-IGriuU7Z7jKQLLfqezo2L1DrJXWSUw33H65PLf4M0AfESaUhlsfcHom8YmA+E1v+PuXCr3MiWKH66fI1em42Dw==} peerDependencies: '@ag-ui/core': '>=0.0.48' @@ -604,6 +599,26 @@ packages: resolution: {integrity: sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==} engines: {node: '>=8'} + '@microsoft/agents-activity@1.7.1': + resolution: {integrity: sha512-jbomRRTOWh1fxaEt4zb5ZTCVSBMMlH+/A7ws2RBEiCF3yGFioWbuLPHmwW+KAU+VkE8979IAbtagXXjd5x+XkQ==} + engines: {node: '>=20.0.0'} + + '@microsoft/agents-hosting@1.7.1': + resolution: {integrity: sha512-sWxAlNMpRHtuLfs5Z454HGpl/caFXksyo3MdJm5QBnkgM2cCETUAD5TaHzxYCnYM9DDW45wHJbzDOAX5mZnSoQ==} + engines: {node: '>=20.0.0'} + + '@microsoft/agents-telemetry@1.7.1': + resolution: {integrity: sha512-zCtoJaqefNSUzHrv7gBxy9AHdoMsXT5fIS+/43UEN3e1CPZjaYIdGU6eMkX/LGWUaVJaPpIlMoV79sKvecn2Ow==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@opentelemetry/api': '1' + '@opentelemetry/api-logs': '>=0.214.0 <1' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/api-logs': + optional: true + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -620,10 +635,6 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@notionhq/notion-mcp-server@2.4.1': - resolution: {integrity: sha512-D/LixSjTgWi3LrE386yFp+/JHQg768stP5AnWR7SY7p9anDi1NR8Wl6RA4bIKVfLf1kReBVKMbPaDKpM4rm89A==} - hasBin: true - '@opentelemetry/api@1.9.1': resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} @@ -796,55 +807,11 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@tanstack/ai-event-client@0.6.3': - resolution: {integrity: sha512-aQb+v9a4T+uDS3VuDGKOdhwPvr2iwjHVweroXHwbasmvClFqV6WJhoekv74VJrhJrlwz8wVMEPcwlKHxnSAzLQ==} - peerDependencies: - '@tanstack/ai': 0.32.0 - - '@tanstack/ai-event-client@0.6.8': - resolution: {integrity: sha512-h7/HLz9u2LF9ba6uKFMnSZkFlkzQONJ5u3Hi+4qGxpsHyvcGRtW6v186jaFLoklndhEwyC49ytkC4eafi7Qq8w==} - - '@tanstack/ai-mcp@0.1.10': - resolution: {integrity: sha512-J6PDw9KOpc5C6/L5IYq4SBnzhTUqjfSHcyOuE+ESVbhTeEctnKTH2IT50JZ46sVinmGUSgQ67j2YuItLZoJRWw==} - hasBin: true - - '@tanstack/ai-openai@0.15.10': - resolution: {integrity: sha512-S/mbplx7sf3lhJrMZBTIBteaaWID6LuNBW8MkOk1I12x0fCmHxZaisf4fKILekcrZ6WniqVNyOLKrJnDD+T2PQ==} - peerDependencies: - '@tanstack/ai': ^0.39.0 - zod: ^4.0.0 - - '@tanstack/ai-utils@0.3.1': - resolution: {integrity: sha512-fgbjd5DohL3k5rTWr/KInauLVYMiHVm0cnmTsNrzL3cr4wWhEld5vmFSrjiwUWACAuONjZa+uBXuS+ZSO8fwDQ==} - - '@tanstack/ai@0.32.0': - resolution: {integrity: sha512-8Saiyws4irNkxPcyLOucquYaEFEGUVBCrBJmfr1W6z1oDVDYF+pwb31rOLjN0/xqbGCHzjOaXc4TLAZS3kPS4g==} - engines: {node: '>=18'} - peerDependencies: - '@opentelemetry/api': '>=1.9.0' - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - - '@tanstack/ai@0.37.0': - resolution: {integrity: sha512-5vM8XCBUyk6whQZ7gAiM1nX+NqvOAOZqAJrFxlo6g+vIvIfqfNqcD378E7kmZIIt5idFkzA8/IH/00oQDsZRyg==} - engines: {node: '>=18'} - peerDependencies: - '@opentelemetry/api': '>=1.9.0' - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@tanstack/devtools-event-client@0.4.4': resolution: {integrity: sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==} engines: {node: '>=18'} hasBin: true - '@tanstack/openai-base@0.9.6': - resolution: {integrity: sha512-vHF5VTKrLrb0c+fLm4hdd5xOKsYUI1/Qt3bLaFYMeUogfYq1LIZlEGUb4EJaFaMYLrjIuep+exjpaHx0TY9kMQ==} - peerDependencies: - '@tanstack/ai': ^0.39.0 - '@tanstack/pacer@0.20.1': resolution: {integrity: sha512-ZNQ1bIL6eUXVKdic0tiImvBVkWrg/IoSK6VIacTrO3d3HAGnd70qFJNJagR/YOJIOw4EKGWnodwpYZkN1pWuVQ==} engines: {node: '>=18'} @@ -918,6 +885,10 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typespec/ts-http-runtime@0.3.7': + resolution: {integrity: sha512-JVUD8X2tfDMWjcjLs4yVxxVrS8yR5vnh386GAXT9Qj79nBxxXSaHFQZg5FweLmT8HlPQ3kii6noUB+Z9RN7DvQ==} + engines: {node: '>=22.0.0'} + '@vercel/oidc@3.2.0': resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} @@ -1005,14 +976,6 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - ajv-formats@2.1.1: - resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -1024,17 +987,10 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} @@ -1055,9 +1011,6 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - bath-es5@3.0.3: - resolution: {integrity: sha512-PdCioDToH3t84lP40kUFCKWCOCH389Dl1kbC8FGoqOwamxsmqxxnJSXdkTOsPoNHXjem4+sJ+bbNoQm5zeCqxg==} - bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} @@ -1105,10 +1058,6 @@ packages: class-validator@0.14.4: resolution: {integrity: sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==} - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1200,9 +1149,6 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - dereference-json-schema@0.2.2: - resolution: {integrity: sha512-w8dUsJyrzH4Zsj8W/tKcjLsmcTKXfdNf+n3BBm1SAfnqpaCodgEUWqQGJ+pNb9NOqPwYMGvUnZZ8nQfeFjJlbQ==} - destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -1236,9 +1182,6 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -1270,10 +1213,6 @@ packages: engines: {node: '>=18'} hasBin: true - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} @@ -1413,10 +1352,6 @@ packages: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -1489,6 +1424,10 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -1522,10 +1461,6 @@ packages: is-electron@2.2.2: resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==} - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - is-network-error@1.3.2: resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==} engines: {node: '>=16'} @@ -1540,10 +1475,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isexe@3.1.5: - resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} - engines: {node: '>=18'} - jose@5.10.0: resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} @@ -1557,10 +1488,6 @@ packages: js-tiktoken@1.0.21: resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} - hasBin: true - json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} @@ -1580,6 +1507,10 @@ packages: jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + jwks-rsa@4.0.1: + resolution: {integrity: sha512-poXwUA8S4cP9P5N8tZS3xnUDJH8WmwSGfKK9gIaRPdjLHyJtd9iX/cngX9CUIe0Caof5JhK2EbN7N5lnnaf9NA==} + engines: {node: ^20.19.0 || ^22.12.0 || >= 23.0.0} + jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} @@ -1686,6 +1617,12 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + limiter@1.1.5: + resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} + + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash.get@4.4.2: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. @@ -1708,9 +1645,6 @@ packages: lodash.isstring@4.0.1: resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} @@ -1723,6 +1657,13 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lru-memoizer@3.0.0: + resolution: {integrity: sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==} + magic-bytes.js@1.13.0: resolution: {integrity: sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==} @@ -1860,18 +1801,6 @@ packages: zod: optional: true - openapi-client-axios@7.9.0: - resolution: {integrity: sha512-1VRBbbNQTz6pAWFALXrqr88GclEb+LirqbzmLnFzqOlnCFC2Ao5Gv4JYf783+A8PQbEAu5Or4Rg32RaetllnwA==} - peerDependencies: - axios: '>=0.25.0' - js-yaml: ^4.1.0 - - openapi-schema-validator@12.1.3: - resolution: {integrity: sha512-xTHOmxU/VQGUgo7Cm0jhwbklOKobXby+/237EG967+3TQEYJztMgX9Q5UE2taZKwyKPUq0j11dngpGjUuxz1hQ==} - - openapi-types@12.1.3: - resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} - p-finally@1.0.0: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} engines: {node: '>=4'} @@ -2019,10 +1948,6 @@ packages: reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -2128,17 +2053,9 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -2348,20 +2265,11 @@ packages: engines: {node: '>= 8'} hasBin: true - which@5.0.0: - resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} - engines: {node: ^18.17.0 || >=20.5.0} - hasBin: true - why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -2377,25 +2285,13 @@ packages: utf-8-validate: optional: true - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@17.7.3: - resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} - engines: {node: '>=12'} - zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: zod: ^3.25.28 || ^4 - zod@3.24.1: - resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} + zod@3.25.75: + resolution: {integrity: sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg==} zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -2437,10 +2333,6 @@ snapshots: uuid: 11.1.1 zod: 3.25.76 - '@ag-ui/core@0.0.52': - dependencies: - zod: 3.25.76 - '@ag-ui/core@0.0.54': dependencies: zod: 3.25.76 @@ -2596,15 +2488,56 @@ snapshots: dependencies: json-schema: 0.4.0 + '@azure/abort-controller@2.2.0': + dependencies: + tslib: 2.8.1 + + '@azure/core-auth@1.10.1': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-util@1.14.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@typespec/ts-http-runtime': 0.3.7 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/msal-common@16.5.2': {} + + '@azure/msal-node@5.1.5': + dependencies: + '@azure/msal-common': 16.5.2 + jsonwebtoken: 9.0.3 + '@bufbuild/protobuf@2.12.1': {} '@cfworker/json-schema@4.1.1': {} - '@copilotkit/channels-discord@0.0.3(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': + '@copilotkit/channels-core@0.2.2-canary.rc-1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': + dependencies: + '@ag-ui/client': 0.0.57 + '@ag-ui/core': 0.0.57 + '@copilotkit/channels-ui': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57) + '@copilotkit/core': 1.63.3-canary.rc-1(@ag-ui/core@0.0.57)(zod@3.25.76) + '@copilotkit/shared': 1.63.3-canary.rc-1(@ag-ui/core@0.0.57) + zod-to-json-schema: 3.25.2(zod@3.25.76) + optionalDependencies: + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)) + transitivePeerDependencies: + - encoding + - zod + + '@copilotkit/channels-discord@0.2.2-canary.rc-1(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': dependencies: '@ag-ui/client': 0.0.57 - '@copilotkit/channels': 0.1.1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) - '@copilotkit/channels-ui': 0.1.1(@ag-ui/core@0.0.57) + '@copilotkit/channels-core': 0.2.2-canary.rc-1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-ui': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57) discord.js: 14.27.0 zod: 3.25.76 transitivePeerDependencies: @@ -2614,11 +2547,11 @@ snapshots: - utf-8-validate - vitest - '@copilotkit/channels-intelligence@0.1.1(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': + '@copilotkit/channels-intelligence@0.2.2-canary.rc-1(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': dependencies: '@ag-ui/client': 0.0.57 - '@copilotkit/channels': 0.1.1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) - '@copilotkit/channels-ui': 0.1.1(@ag-ui/core@0.0.57) + '@copilotkit/channels-core': 0.2.2-canary.rc-1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-ui': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57) phoenix: 1.8.9 transitivePeerDependencies: - '@ag-ui/core' @@ -2626,14 +2559,14 @@ snapshots: - vitest - zod - '@copilotkit/channels-slack@0.1.2(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': + '@copilotkit/channels-slack@0.2.2-canary.rc-1(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': dependencies: '@ag-ui/client': 0.0.57 '@ag-ui/core': 0.0.57 - '@copilotkit/channels': 0.1.1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) - '@copilotkit/channels-ui': 0.1.1(@ag-ui/core@0.0.57) - '@copilotkit/core': 1.62.3(@ag-ui/core@0.0.57)(zod@3.25.76) - '@copilotkit/shared': 1.62.3(@ag-ui/core@0.0.57) + '@copilotkit/channels-core': 0.2.2-canary.rc-1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-ui': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57) + '@copilotkit/core': 1.63.3-canary.rc-1(@ag-ui/core@0.0.57)(zod@3.25.76) + '@copilotkit/shared': 1.63.3-canary.rc-1(@ag-ui/core@0.0.57) '@slack/bolt': 4.7.3(@types/express@5.0.6) '@slack/types': 2.22.0 '@slack/web-api': 7.19.0 @@ -2649,11 +2582,32 @@ snapshots: - utf-8-validate - vitest - '@copilotkit/channels-telegram@0.0.4(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': + '@copilotkit/channels-teams@0.2.2-canary.rc-1(@opentelemetry/api@1.9.1)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': + dependencies: + '@ag-ui/client': 0.0.57 + '@ag-ui/core': 0.0.57 + '@copilotkit/channels-core': 0.2.2-canary.rc-1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-ui': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57) + '@copilotkit/core': 1.63.3-canary.rc-1(@ag-ui/core@0.0.57)(zod@3.25.76) + '@copilotkit/shared': 1.63.3-canary.rc-1(@ag-ui/core@0.0.57) + '@microsoft/agents-activity': 1.7.1 + '@microsoft/agents-hosting': 1.7.1(@opentelemetry/api@1.9.1) + express: 4.22.2 + rxjs: 7.8.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/api-logs' + - encoding + - supports-color + - vitest + + '@copilotkit/channels-telegram@0.2.2-canary.rc-1(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': dependencies: '@ag-ui/client': 0.0.57 - '@copilotkit/channels': 0.1.1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) - '@copilotkit/channels-ui': 0.1.1(@ag-ui/core@0.0.57) + '@copilotkit/channels-core': 0.2.2-canary.rc-1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-ui': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57) grammy: 1.44.0 zod: 3.25.76 transitivePeerDependencies: @@ -2662,42 +2616,52 @@ snapshots: - supports-color - vitest - '@copilotkit/channels-ui@0.1.1(@ag-ui/core@0.0.57)': + '@copilotkit/channels-ui@0.2.2-canary.rc-1(@ag-ui/core@0.0.57)': dependencies: - '@copilotkit/shared': 1.62.3(@ag-ui/core@0.0.57) + '@copilotkit/shared': 1.63.3-canary.rc-1(@ag-ui/core@0.0.57) transitivePeerDependencies: - '@ag-ui/core' - encoding - '@copilotkit/channels-whatsapp@0.0.2(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': + '@copilotkit/channels-whatsapp@0.2.2-canary.rc-1(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': dependencies: '@ag-ui/client': 0.0.57 - '@copilotkit/channels': 0.1.1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) - '@copilotkit/channels-ui': 0.1.1(@ag-ui/core@0.0.57) + '@copilotkit/channels-core': 0.2.2-canary.rc-1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-ui': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57) transitivePeerDependencies: - '@ag-ui/core' - encoding - vitest - zod - '@copilotkit/channels@0.1.1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': + '@copilotkit/channels@0.2.2-canary.rc-1(@ag-ui/core@0.0.57)(@opentelemetry/api@1.9.1)(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': dependencies: - '@ag-ui/client': 0.0.57 - '@ag-ui/core': 0.0.57 - '@copilotkit/channels-ui': 0.1.1(@ag-ui/core@0.0.57) - '@copilotkit/core': 1.62.3(@ag-ui/core@0.0.57)(zod@3.25.76) - '@copilotkit/shared': 1.62.3(@ag-ui/core@0.0.57) - zod-to-json-schema: 3.25.2(zod@3.25.76) + '@copilotkit/channels-core': 0.2.2-canary.rc-1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-discord': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) + '@copilotkit/channels-intelligence': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-slack': 0.2.2-canary.rc-1(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) + '@copilotkit/channels-teams': 0.2.2-canary.rc-1(@opentelemetry/api@1.9.1)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) + '@copilotkit/channels-telegram': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) + '@copilotkit/channels-ui': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57) + '@copilotkit/channels-whatsapp': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) optionalDependencies: vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)) transitivePeerDependencies: + - '@ag-ui/core' + - '@opentelemetry/api' + - '@opentelemetry/api-logs' + - '@types/express' + - bufferutil + - debug - encoding + - supports-color + - utf-8-validate - zod - '@copilotkit/core@1.62.3(@ag-ui/core@0.0.57)(zod@3.25.76)': + '@copilotkit/core@1.63.3-canary.rc-1(@ag-ui/core@0.0.57)(zod@3.25.76)': dependencies: '@ag-ui/client': 0.0.57 - '@copilotkit/shared': 1.62.3(@ag-ui/core@0.0.57) + '@copilotkit/shared': 1.63.3-canary.rc-1(@ag-ui/core@0.0.57) '@tanstack/pacer': 0.20.1 phoenix: 1.8.9 rxjs: 7.8.2 @@ -2709,7 +2673,7 @@ snapshots: '@copilotkit/license-verifier@0.5.0': {} - '@copilotkit/runtime@1.62.3(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@6.47.0(ws@8.21.1)(zod@3.25.76))': + '@copilotkit/runtime@1.63.3-canary.rc-1(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': dependencies: '@ag-ui/a2ui-middleware': 0.0.10(@ag-ui/client@0.0.57)(rxjs@7.8.2) '@ag-ui/client': 0.0.57 @@ -2723,8 +2687,10 @@ snapshots: '@ai-sdk/google-vertex': 3.0.150(zod@3.25.76) '@ai-sdk/mcp': 1.0.62(zod@3.25.76) '@ai-sdk/openai': 3.0.85(zod@3.25.76) + '@copilotkit/channels-core': 0.2.2-canary.rc-1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-intelligence': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) '@copilotkit/license-verifier': 0.5.0 - '@copilotkit/shared': 1.62.3(@ag-ui/core@0.0.57) + '@copilotkit/shared': 1.63.3-canary.rc-1(@ag-ui/core@0.0.57) '@graphql-yoga/plugin-defer-stream': 3.21.2(graphql-yoga@5.21.2(graphql@16.14.2))(graphql@16.14.2) '@hono/node-server': 1.19.14(hono@4.12.30) '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) @@ -2768,9 +2734,10 @@ snapshots: - supports-color - svelte - utf-8-validate + - vitest - vue - '@copilotkit/shared@1.62.3(@ag-ui/core@0.0.57)': + '@copilotkit/shared@1.63.3-canary.rc-1(@ag-ui/core@0.0.57)': dependencies: '@ag-ui/client': 0.0.57 '@ag-ui/core': 0.0.57 @@ -3071,27 +3038,30 @@ snapshots: dependencies: '@lukeed/csprng': 1.1.0 - '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@3.24.1)': + '@microsoft/agents-activity@1.7.1': dependencies: - '@hono/node-server': 1.19.14(hono@4.12.30) - ajv: 8.20.0 - ajv-formats: 3.0.1(ajv@8.20.0) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.1.0 - express: 5.2.1 - express-rate-limit: 8.5.2(express@5.2.1) - hono: 4.12.30 - jose: 6.2.3 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 3.24.1 - zod-to-json-schema: 3.25.2(zod@3.24.1) + zod: 3.25.75 + + '@microsoft/agents-hosting@1.7.1(@opentelemetry/api@1.9.1)': + dependencies: + '@azure/core-auth': 1.10.1 + '@azure/msal-node': 5.1.5 + '@microsoft/agents-activity': 1.7.1 + '@microsoft/agents-telemetry': 1.7.1(@opentelemetry/api@1.9.1) + jsonwebtoken: 9.0.3 + jwks-rsa: 4.0.1 + zod: 3.25.75 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/api-logs' + - supports-color + + '@microsoft/agents-telemetry@1.7.1(@opentelemetry/api@1.9.1)': + dependencies: + '@microsoft/agents-activity': 1.7.1 + debug: 4.4.3 optionalDependencies: - '@cfworker/json-schema': 4.1.1 + '@opentelemetry/api': 1.9.1 transitivePeerDependencies: - supports-color @@ -3126,26 +3096,6 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@notionhq/notion-mcp-server@2.4.1(@cfworker/json-schema@4.1.1)(js-yaml@4.3.0)': - dependencies: - '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@3.24.1) - axios: 1.18.1 - express: 4.22.2 - form-data: 4.0.6 - mustache: 4.2.0 - node-fetch: 3.3.2 - openapi-client-axios: 7.9.0(axios@1.18.1)(js-yaml@4.3.0) - openapi-schema-validator: 12.1.3 - openapi-types: 12.1.3 - which: 5.0.0 - yargs: 17.7.3 - zod: 3.24.1 - transitivePeerDependencies: - - '@cfworker/json-schema' - - debug - - js-yaml - - supports-color - '@opentelemetry/api@1.9.1': {} '@oxc-project/types@0.139.0': {} @@ -3313,73 +3263,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@tanstack/ai-event-client@0.6.3(@tanstack/ai@0.32.0(@opentelemetry/api@1.9.1))': - dependencies: - '@tanstack/ai': 0.32.0(@opentelemetry/api@1.9.1) - '@tanstack/devtools-event-client': 0.4.4 - - '@tanstack/ai-event-client@0.6.8': - dependencies: - '@tanstack/devtools-event-client': 0.4.4 - - '@tanstack/ai-mcp@0.1.10(@cfworker/json-schema@4.1.1)(@opentelemetry/api@1.9.1)(zod@3.25.76)': - dependencies: - '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76) - '@tanstack/ai': 0.37.0(@opentelemetry/api@1.9.1) - transitivePeerDependencies: - - '@cfworker/json-schema' - - '@opentelemetry/api' - - supports-color - - zod - - '@tanstack/ai-openai@0.15.10(@tanstack/ai@0.32.0(@opentelemetry/api@1.9.1))(ws@8.21.1)(zod@3.25.76)': - dependencies: - '@tanstack/ai': 0.32.0(@opentelemetry/api@1.9.1) - '@tanstack/ai-utils': 0.3.1 - '@tanstack/openai-base': 0.9.6(@tanstack/ai@0.32.0(@opentelemetry/api@1.9.1))(ws@8.21.1)(zod@3.25.76) - openai: 6.47.0(ws@8.21.1)(zod@3.25.76) - zod: 3.25.76 - transitivePeerDependencies: - - '@aws-sdk/credential-provider-node' - - '@smithy/hash-node' - - '@smithy/signature-v4' - - ws - - '@tanstack/ai-utils@0.3.1': {} - - '@tanstack/ai@0.32.0(@opentelemetry/api@1.9.1)': - dependencies: - '@ag-ui/core': 0.0.52 - '@standard-schema/spec': 1.1.0 - '@tanstack/ai-event-client': 0.6.3(@tanstack/ai@0.32.0(@opentelemetry/api@1.9.1)) - partial-json: 0.1.7 - optionalDependencies: - '@opentelemetry/api': 1.9.1 - - '@tanstack/ai@0.37.0(@opentelemetry/api@1.9.1)': - dependencies: - '@ag-ui/core': 0.0.52 - '@standard-schema/spec': 1.1.0 - '@tanstack/ai-event-client': 0.6.8 - '@tanstack/ai-utils': 0.3.1 - partial-json: 0.1.7 - optionalDependencies: - '@opentelemetry/api': 1.9.1 - '@tanstack/devtools-event-client@0.4.4': {} - '@tanstack/openai-base@0.9.6(@tanstack/ai@0.32.0(@opentelemetry/api@1.9.1))(ws@8.21.1)(zod@3.25.76)': - dependencies: - '@tanstack/ai': 0.32.0(@opentelemetry/api@1.9.1) - '@tanstack/ai-utils': 0.3.1 - openai: 6.47.0(ws@8.21.1)(zod@3.25.76) - transitivePeerDependencies: - - '@aws-sdk/credential-provider-node' - - '@smithy/hash-node' - - '@smithy/signature-v4' - - ws - - zod - '@tanstack/pacer@0.20.1': dependencies: '@tanstack/devtools-event-client': 0.4.4 @@ -3463,6 +3348,14 @@ snapshots: dependencies: '@types/node': 22.20.1 + '@typespec/ts-http-runtime@0.3.7': + dependencies: + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@vercel/oidc@3.2.0': {} '@vitest/expect@4.1.10': @@ -3571,10 +3464,6 @@ snapshots: '@opentelemetry/api': 1.9.1 zod: 3.25.76 - ajv-formats@2.1.1(ajv@8.20.0): - optionalDependencies: - ajv: 8.20.0 - ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -3586,14 +3475,10 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - ansi-regex@5.0.1: {} - ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 - argparse@2.0.1: {} - array-flatten@1.1.1: {} assertion-error@2.0.1: {} @@ -3614,8 +3499,6 @@ snapshots: base64-js@1.5.1: {} - bath-es5@3.0.3: {} - bignumber.js@9.3.1: {} body-parser@1.20.6: @@ -3685,12 +3568,6 @@ snapshots: libphonenumber-js: 1.13.8 validator: 13.15.35 - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3754,8 +3631,6 @@ snapshots: depd@2.0.0: {} - dereference-json-schema@0.2.2: {} - destroy@1.2.0: {} detect-libc@2.1.2: {} @@ -3797,8 +3672,6 @@ snapshots: ee-first@1.1.1: {} - emoji-regex@8.0.0: {} - encodeurl@2.0.0: {} end-of-stream@1.4.5: @@ -3851,8 +3724,6 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 - escalade@3.2.0: {} - escape-html@1.0.3: {} estree-walker@3.0.3: @@ -4039,8 +3910,6 @@ snapshots: transitivePeerDependencies: - supports-color - get-caller-file@2.0.5: {} - get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4136,6 +4005,13 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -4168,8 +4044,6 @@ snapshots: is-electron@2.2.2: {} - is-fullwidth-code-point@3.0.0: {} - is-network-error@1.3.2: {} is-promise@4.0.0: {} @@ -4178,8 +4052,6 @@ snapshots: isexe@2.0.0: {} - isexe@3.1.5: {} - jose@5.10.0: {} jose@6.2.3: {} @@ -4190,10 +4062,6 @@ snapshots: dependencies: base64-js: 1.5.1 - js-yaml@4.3.0: - dependencies: - argparse: 2.0.1 - json-bigint@1.0.0: dependencies: bignumber.js: 9.3.1 @@ -4223,6 +4091,16 @@ snapshots: ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 + jwks-rsa@4.0.1: + dependencies: + '@types/jsonwebtoken': 9.0.10 + debug: 4.4.3 + jose: 6.2.3 + limiter: 1.1.5 + lru-memoizer: 3.0.0 + transitivePeerDependencies: + - supports-color + jws@4.0.1: dependencies: jwa: 2.0.1 @@ -4305,6 +4183,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + limiter@1.1.5: {} + + lodash.clonedeep@4.5.0: {} + lodash.get@4.4.2: {} lodash.includes@4.3.0: {} @@ -4319,8 +4201,6 @@ snapshots: lodash.isstring@4.0.1: {} - lodash.merge@4.6.2: {} - lodash.once@4.1.1: {} lodash.snakecase@4.1.1: {} @@ -4329,6 +4209,13 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.5.2: {} + + lru-memoizer@3.0.0: + dependencies: + lodash.clonedeep: 4.5.0 + lru-cache: 11.5.2 + magic-bytes.js@1.13.0: {} magic-string@0.30.21: @@ -4407,23 +4294,7 @@ snapshots: optionalDependencies: ws: 8.21.1 zod: 3.25.76 - - openapi-client-axios@7.9.0(axios@1.18.1)(js-yaml@4.3.0): - dependencies: - axios: 1.18.1 - bath-es5: 3.0.3 - dereference-json-schema: 0.2.2 - js-yaml: 4.3.0 - openapi-types: 12.1.3 - - openapi-schema-validator@12.1.3: - dependencies: - ajv: 8.20.0 - ajv-formats: 2.1.1(ajv@8.20.0) - lodash.merge: 4.6.2 - openapi-types: 12.1.3 - - openapi-types@12.1.3: {} + optional: true p-finally@1.0.0: {} @@ -4582,8 +4453,6 @@ snapshots: reflect-metadata@0.2.2: {} - require-directory@2.1.1: {} - require-from-string@2.0.2: {} retry@0.13.1: {} @@ -4737,20 +4606,10 @@ snapshots: std-env@4.2.0: {} - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - strip-json-comments@3.1.1: {} supports-color@7.2.0: @@ -4888,47 +4747,19 @@ snapshots: dependencies: isexe: 2.0.0 - which@5.0.0: - dependencies: - isexe: 3.1.5 - why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrappy@1.0.2: {} ws@8.21.1: {} - y18n@5.0.8: {} - - yargs-parser@21.1.1: {} - - yargs@17.7.3: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - - zod-to-json-schema@3.25.2(zod@3.24.1): - dependencies: - zod: 3.24.1 - zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76 - zod@3.24.1: {} + zod@3.25.75: {} zod@3.25.76: {} diff --git a/runtime.ts b/runtime.ts deleted file mode 100644 index 32a673e..0000000 --- a/runtime.ts +++ /dev/null @@ -1,376 +0,0 @@ -/** - * Agent backend for the KiteBot triage assistant. - * - * This is the brain behind the chat bridge: a single CopilotKit - * `BuiltInAgent` (LLM + MCP) served over AG-UI by a `CopilotSseRuntime`. - * It replaces the old vendored Python/LangGraph showcase backend — there - * is no Python, no `langgraph dev`, no A2UI middleware. Everything is a - * few dozen lines of TypeScript. - * - * The bridge (`app/index.ts`) fronts this same backend with the - * platform-specific `@copilotkit/channels-*` packages (Slack, Discord, - * Telegram, WhatsApp) and cross-platform `@copilotkit/channels-ui` - * components, so this runtime itself has no platform-specific code. - * - * What it does - * ------------ - * The agent connects to **Linear** and **Notion** via their MCP servers - * and acts as an on-call / triage assistant inside chat: it pulls and - * files Linear issues, finds Notion runbooks, and writes incident - * threads up as Notion postmortems. The data access is entirely MCP — - * the agent discovers the available tools (list/search/create issues, - * search/create pages) from each server at runtime. - * - * The chat-side primitives (read_thread, the confirm_write HITL picker, - * the issue/page @copilotkit/channels-ui components) are forwarded to the - * agent as client-provided tools by the bridge on every run — see - * `app/index.ts`. - * - * Auth & deployment - * ----------------- - * Every connection is env-driven, so the same process runs locally and - * deployed — only the env differs (see `.env.example`): - * - * - Linear: the hosted MCP accepts a raw API key as a bearer token, so - * we connect straight to `LINEAR_MCP_URL` with `LINEAR_API_KEY`. - * - Notion: run the official `@notionhq/notion-mcp-server` as a - * Streamable-HTTP sidecar (`pnpm notion-mcp` locally, a second - * process/container in prod) and point `NOTION_MCP_URL` / - * `NOTION_MCP_AUTH_TOKEN` at it. - * - * A server is only wired up when its credentials are present, so the bot - * runs Linear-only, Notion-only, or both. - * - * Exposed route (the bridge's `AGENT_URL`, shared by every platform): - * POST http://localhost:8200/api/copilotkit/agent/triage/run - */ -import "dotenv/config"; -import { createServer } from "node:http"; -import { - BuiltInAgent, - CopilotSseRuntime, - convertInputToTanStackAI, -} from "@copilotkit/runtime/v2"; -import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node"; -import { chat } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; -import { webSearchTool } from "@tanstack/ai-openai/tools"; -import { createMCPClient } from "@tanstack/ai-mcp"; - -const LINEAR_TEAM_KEY = process.env["LINEAR_TEAM_KEY"] ?? "CPK"; - -/** - * HTTP MCP transports (Linear hosted + Notion sidecar), each carrying a static - * `Authorization: Bearer`. TanStack AI's `chat()` connects these per run and - * closes them when the run ends (its `mcp.connection: "close"` default), so we - * just describe the transports here and create fresh clients inside the agent - * factory on each turn. - */ -interface McpHttpTransport { - type: "http"; - url: string; - headers: Record<string, string>; -} - -/** A transport plus the human label we surface when it's up or down. */ -interface LabeledTransport { - name: string; - transport: McpHttpTransport; -} - -function mcpTransports(): LabeledTransport[] { - const transports: LabeledTransport[] = []; - if (process.env["LINEAR_API_KEY"]) { - transports.push({ - name: "Linear", - transport: { - type: "http", - url: process.env["LINEAR_MCP_URL"] ?? "https://mcp.linear.app/mcp", - headers: { Authorization: `Bearer ${process.env["LINEAR_API_KEY"]}` }, - }, - }); - } - if (process.env["NOTION_MCP_AUTH_TOKEN"]) { - transports.push({ - name: "Notion", - transport: { - type: "http", - url: process.env["NOTION_MCP_URL"] ?? "http://127.0.0.1:3001/mcp", - headers: { - Authorization: `Bearer ${process.env["NOTION_MCP_AUTH_TOKEN"]}`, - }, - }, - }); - } - return transports; -} - -/** Max time to wait for an MCP server to connect before giving up on it. */ -const MCP_CONNECT_TIMEOUT_MS = 8000; - -/** - * Connect one MCP client without ever taking the run down with it. A server - * that's misconfigured (bad key), down (sidecar not running), or hanging must - * NOT abort the turn — the agent should keep working with whatever else is - * available. We race the connect against a timeout. - * - * `connecting` can settle after we've already given up on it: a late - * rejection must not surface as an unhandled rejection, and a late - * *success* hands back a live `MCPClient` that nothing else will ever - * close — left alone that's a leaked MCP connection every time a server is - * merely slow instead of down. So once the timeout wins the race, we attach - * a follow-up that closes whatever client `connecting` eventually produces; - * closing is itself best-effort (swallow errors from `close()`) since there's - * no one left to hand a close failure to. - */ -async function connectMcp(transport: McpHttpTransport) { - const connecting = createMCPClient({ transport }); - let timedOut = false; - connecting.then( - (client) => { - if (timedOut) void client.close().catch(() => {}); // leaked otherwise: nothing else closes it - }, - () => {}, // late reject (post-timeout) must not crash the process - ); - let timer: ReturnType<typeof setTimeout> | undefined; - const timeout = new Promise<never>((_, reject) => { - timer = setTimeout(() => { - timedOut = true; - reject(new Error(`timed out after ${MCP_CONNECT_TIMEOUT_MS}ms`)); - }, MCP_CONNECT_TIMEOUT_MS); - timer.unref?.(); // don't keep the process alive on the timer alone - }); - try { - return await Promise.race([connecting, timeout]); - } finally { - if (timer) clearTimeout(timer); - } -} - -if (mcpTransports().length === 0) { - console.warn( - "[runtime] No MCP servers configured. Set LINEAR_API_KEY and/or " + - "NOTION_MCP_AUTH_TOKEN in .env — without them the bot can chat and " + - "search the web but can't read or write Linear/Notion.", - ); -} - -const SYSTEM_PROMPT = [ - "You are KiteBot, an on-call triage assistant living in your team's chat workspace. You help", - "an engineering team turn incident chatter into tracked work: you pull and", - "file Linear issues, find Notion runbooks, and write incident threads up as", - "Notion postmortems.", - "", - "Data access:", - "- Linear and Notion are connected via MCP. Use those tools to search, read,", - ` and create issues and pages. The default Linear team is "${LINEAR_TEAM_KEY}"`, - " unless the user names another team.", - "", - "Linear tool tips (the filters are picky — follow these to avoid empty results):", - `- Pass the team KEY directly to list_issues, e.g. {team: "${LINEAR_TEAM_KEY}"}. Do`, - " NOT call list_teams to look a team up by its key — list_teams matches the", - ' team\'s full NAME, not its key, so a key like "CPK" returns nothing. If you', - " must resolve a team, use get_team with the key.", - '- For "my issues" / "assigned to me": set assignee to the requesting user\'s', - ' email (it\'s in your context) or the literal "me" — both work.', - "- The state filter takes a Linear state TYPE (backlog, unstarted, started,", - ' completed, canceled) or a specific state name — NOT "open" or "closed". For', - ' "open" issues, OMIT the state filter entirely (state:"open" returns nothing).', - '- There is no cycle:"current"/"active" value. For "this cycle", just list the', - " team's issues (omit the cycle filter) unless the user names a cycle number.", - "- QUERY ONCE. Call list_issues a SINGLE time with the team key + any needed", - " filter. Do NOT paginate or re-run it with different filter combinations to", - " gather every issue — one query is enough. If the result set is large, render", - " the ~15 most recent and note the rest (e.g. 'showing 15 of 39') instead of", - " dumping the whole backlog; a 39-row card is noise, not an answer.", - "- Use get_issue for one issue; render it with issue_card.", - "- To act on a chat conversation (e.g. 'write this thread up'), call the", - " read_thread tool to fetch the messages first — never invent thread content.", - "", - "Files & visuals: uploaded files arrive in the message as content you can", - "read — images and PDFs directly, and CSV/JSON/text as decoded text. When a", - "user uploads data and wants a chart, parse it and call render_chart with a", - "Chart.js config OBJECT — pick a sensible type (bar/line/pie) and inline the", - "data. When the user wants the data itself shown as a table (not a chart),", - "call render_table with columns + rows (each row an array of cell values in", - "column order; set a column's align to 'right' for numeric columns). When", - "asked to diagram a flow/architecture/timeline, call render_diagram with", - "Mermaid source. render_chart and render_diagram post an image; render_table", - "posts a table. If render_diagram returns an error, fix the Mermaid and", - "retry. These are read/reply actions — no confirm_write needed.", - "- render_chart / render_diagram post a TITLED image themselves (a caption", - " header followed by the image). Do NOT narrate the act with a separate", - ' "Charting `file.csv`…" line or a "rendered above/below" sentence — that', - " text lands AFTER the image and reads out of order. Let the titled image be", - " the answer; if you must reply, ONE short past-tense clause naming the file", - ' is enough (e.g. "Charted `incidents-2026.csv`.").', - "- If more than one file is in the thread and the request doesn't make clear", - " which one to use, ASK which file (list them by name) instead of guessing.", - "", - "Acting per-user: each turn's context names the requesting user, with", - 'their name and email. When someone says "my issues", "assigned to me", or', - '"file this for me", use that email/name to find their Linear user, then:', - "- Querying: filter Linear by that person (assignee), so each user gets THEIR", - " issues — not everyone's.", - "- Creating: set the new issue's assignee to that person and @mention them.", - " (Heads up: issues are still authored by the bot's API key, so the Linear", - " 'creator' is the bot — assignee is how you attribute work to the requester.)", - "Never assume every request is from the same person; always use the requester", - "named in context. If their email isn't in context, say so rather than guessing.", - "", - "RENDERING — THIS IS A HARD RULE. Whenever your answer contains structured", - "output, you MUST call the matching render tool and let IT draw the card. Do", - "NOT reproduce that content as Markdown bullets, a table, or prose — a hand-", - "written list/table/card is a BUG, not an answer. Map the request to a tool and", - "call it FIRST, then add at most one short sentence around it:", - "- Several Linear issues -> issue_list", - "- A single Linear issue -> issue_card (and right after you create one, justCreated: true)", - "- Notion pages -> page_list", - "- Tabular data / 'as a table' -> render_table (columns + rows)", - "- A status / metrics / health summary (counts, KPIs, label/value pairs)", - " -> show_status (heading + fields:[{label,value}])", - "- An incident / outage -> show_incident (id, title, severity SEV1|SEV2|SEV3,", - " summary) — an interactive card with Acknowledge/Escalate", - "- A set of links / runbooks -> show_links (heading + links:[{label,url}])", - "- A chart from data -> render_chart; a flow/architecture/timeline -> render_diagram", - "If the user explicitly asks for a card/table/incident/status/links, calling the", - "tool IS the whole answer — never describe what the card 'would' contain in prose.", - "Your text message alongside a rendered card MUST be empty or ONE short line (e.g.", - '"Open CPK issues:"). NEVER restate the issues/rows/fields as text after rendering', - "— the card already shows them, and a duplicate text wall is the single most", - "annoying thing you can do. Render, then stop.", - "- ALWAYS populate each issue's state and priority as plain strings (e.g.", - ' state:"In Progress", priority:"High") on the component props — the cards', - " use them for the status dot and the colored border. The Linear MCP returns", - ' priority as an object {value, name}; pass its NAME string (e.g. "High"),', - " not the object. Map the issue's workflow status into state. Include", - " assignee, url, and updated too when you have them.", - "", - "WRITE GATING: a 'write' is CREATING or MODIFYING something in Linear or Notion", - "(create_issue, update_issue, create_page, …). ONLY before such a write, call the", - "confirm_write tool with a one-line summary and wait for approval; perform the", - "write only if confirmed. Rendering a card/table (issue_list, issue_card,", - "show_incident, show_status, show_links, render_table, render_chart/diagram) and", - "any read (search/list/get) are NOT writes — never gate them, and never add an", - "'I'll need approval' disclaimer to a pure render or read.", -].join("\n"); - -// OpenAI-only here: web search is an OpenAI hosted (provider) tool, so this -// agent runs on the OpenAI Responses API via TanStack AI's `openaiText` -// adapter. Override the model with AGENT_MODEL (a bare OpenAI id, or -// "openai/<id>" — the prefix is stripped); defaults to gpt-5.5. The cast is -// needed because AGENT_MODEL is a dynamic (env-sourced) string while -// `openaiText` types its argument to the known OpenAI model literal union — -// the cast is a trapdoor around that check, so the env value is NOT validated -// against the union; an unsupported/misspelled model id will only surface as -// an OpenAI API error at request time, not a compile-time or startup check. -const model = (process.env["AGENT_MODEL"] ?? "openai/gpt-5.5").replace( - /^openai\//, - "", -) as Parameters<typeof openaiText>[0]; - -// Factory mode: we own the LLM call (TanStack AI `chat()`); BuiltInAgent owns -// the AG-UI run lifecycle and converts TanStack's stream into AG-UI events. -// `chat()` runs the multi-turn tool loop, the OpenAI `web_search` provider -// tool, and the MCP tools — discovering MCP tools and closing the connections -// when the run ends. The big triage prompt is prepended as a system prompt, -// ahead of any system/context/state prompts derived from the run input. -const agent = new BuiltInAgent({ - type: "tanstack", - factory: async (ctx) => { - const { - messages, - systemPrompts, - tools: clientTools, - } = convertInputToTanStackAI(ctx.input); - - // Connect each MCP server independently so one bad/unreachable server can't - // kill the turn. Failures are dropped (the agent runs with whatever else is - // up) and noted so the model only tells the user a source is down if they - // actually ask for it — see `availabilityNote` below. - const transports = mcpTransports(); - const settled = await Promise.allSettled( - transports.map((t) => connectMcp(t.transport)), - ); - const clients: Array<Awaited<ReturnType<typeof connectMcp>>> = []; - const unavailable: string[] = []; - settled.forEach((result, i) => { - if (result.status === "fulfilled") { - clients.push(result.value); - } else { - unavailable.push(transports[i]!.name); - const msg = - result.reason instanceof Error - ? result.reason.message - : String(result.reason); - console.error( - `[runtime] MCP "${transports[i]!.name}" unavailable this turn:`, - msg, - ); - } - }); - - // Tell the model which sources are down THIS turn so it degrades gracefully: - // keep answering with everything that works, and only surface the outage if - // the user's request needs the missing source (never invent data). - const isAre = unavailable.length > 1 ? "are" : "is"; - const itsTheir = unavailable.length > 1 ? "their" : "its"; - const availabilityNote = - unavailable.length > 0 - ? `\n\nDATA SOURCE STATUS: ${unavailable.join(" and ")} ${isAre} ` + - `temporarily UNAVAILABLE this turn (connection failed), so ${itsTheir} ` + - `tools are not loaded. Everything else — web search, rendering cards/` + - `charts, reading the thread — still works normally. ONLY if the ` + - `user asks for something that needs ${unavailable.join(" or ")}, tell ` + - `them that source is temporarily unreachable and to try again shortly; ` + - `never invent data or claim a write/read succeeded.` - : ""; - - return chat({ - adapter: openaiText(model), - messages, - systemPrompts: [SYSTEM_PROMPT + availabilityNote, ...systemPrompts], - // `web_search` is an OpenAI provider tool (run server-side by OpenAI); - // `clientTools` are the bot's frontend tools (issue/page cards, charts, - // confirm_write HITL) forwarded on every run — passed as client-side - // tools so the model can call them and the bot renders/gates them via - // the AG-UI client-tool round-trip. MCP tools come in via `mcp` below. - tools: [webSearchTool({ type: "web_search" }), ...clientTools], - ...(clients.length > 0 ? { mcp: { clients } } : {}), - // TanStack AI needs the full AbortController (not just the signal). - abortController: ctx.abortController, - }); - }, -}); - -const runtime = new CopilotSseRuntime({ - agents: { triage: agent }, -}); - -const listener = createCopilotNodeListener({ - runtime, - basePath: "/api/copilotkit", - cors: true, -}); - -const rawPort = process.env["PORT"]; -const port = - rawPort && rawPort.trim() !== "" ? Number(rawPort) : 8200; -if (!Number.isInteger(port) || port < 1 || port > 65535) { - console.error(`Invalid PORT: "${rawPort}" is not a valid port number`); - process.exit(1); -} -createServer(listener).listen(port, () => { - console.log( - `[runtime] listening on http://localhost:${port}/api/copilotkit/agent/triage/run`, - ); - const connected = [ - process.env["LINEAR_API_KEY"] ? "Linear" : null, - process.env["NOTION_MCP_AUTH_TOKEN"] ? "Notion" : null, - ].filter(Boolean); - console.log( - `[runtime] agent "triage" ready · MCP: ${ - connected.length ? connected.join(", ") : "none" - }`, - ); -}); diff --git a/scripts/start-notion-mcp.ts b/scripts/start-notion-mcp.ts deleted file mode 100644 index eced358..0000000 --- a/scripts/start-notion-mcp.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Starts the official Notion MCP server as a Streamable-HTTP sidecar for the - * agent backends — the TS runtime (`runtime.ts`) and the Python deep-research - * agent (`agent/`); on Railway the Python agent is the sole consumer (see - * `.railway/railway.ts`). Run with `pnpm notion-mcp`. - * - * Why a launcher instead of a raw npm script: the two `.env` values need to be - * mapped to the two DIFFERENT things the Notion server expects — the Notion - * integration secret (`NOTION_TOKEN`) becomes the OUTBOUND `Authorization: - * Bearer` the server sends to Notion's API (passed via `OPENAPI_MCP_HEADERS`, - * see below), and `NOTION_MCP_AUTH_TOKEN` becomes the server's INBOUND HTTP - * transport bearer (`AUTH_TOKEN` / `--auth-token`) that the agent presents to - * reach this sidecar. Doing the mapping here works identically on - * Windows/macOS/Linux without shell-specific env interpolation. - */ -import "dotenv/config"; -import { spawn } from "node:child_process"; - -const authToken = process.env["NOTION_MCP_AUTH_TOKEN"]; -const notionToken = process.env["NOTION_TOKEN"]; - -if (!authToken) { - console.error( - "[notion-mcp] NOTION_MCP_AUTH_TOKEN is required — it's the bearer the " + - "agent uses to reach this sidecar. Set it in .env (any strong string).", - ); - process.exit(1); -} -if (!notionToken) { - console.error( - "[notion-mcp] NOTION_TOKEN is required — the Notion integration secret " + - "(ntn_...). Create one at notion.so → Settings → Connections.", - ); - process.exit(1); -} - -// Port the sidecar listens on. Must agree with NOTION_MCP_URL as dialed by -// runtime.ts (TS backend) AND by the Python agent/ backend on Railway — both -// default to http://127.0.0.1:3001/mcp. Validated up front because it's passed -// as a `--port` arg to the spawn below, which uses a shell on Windows -// (`shell: isWindows`) — an unvalidated value with spaces/shell metacharacters -// could mangle or inject the command there. An empty string (a bare -// `NOTION_MCP_PORT=` in .env) is treated as unset → default. -const rawPort = process.env["NOTION_MCP_PORT"] || undefined; -if (rawPort !== undefined && !/^\d+$/.test(rawPort)) { - console.error( - `[notion-mcp] NOTION_MCP_PORT must be a positive integer (1–65535), got: ${JSON.stringify(rawPort)}`, - ); - process.exit(1); -} -const portNumber = rawPort === undefined ? 3001 : Number(rawPort); -if (!Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535) { - console.error( - `[notion-mcp] NOTION_MCP_PORT must be an integer between 1 and 65535, got: ${JSON.stringify(rawPort)}`, - ); - process.exit(1); -} -const port = String(portNumber); - -// Host the sidecar binds to. Defaults to loopback for local dev (matches the -// upstream server's own default). On Railway the `notion-mcp` service sets -// NOTION_MCP_HOST=:: so the sidecar listens on all interfaces and is reachable -// over Railway's (IPv6) private network — without it the server binds 127.0.0.1 -// and the agent's cross-container connection is refused. Validated because it's -// passed as a `--host` arg to the spawn below (which uses a shell on Windows, -// `shell: isWindows`), and to prevent a leading `-` being read as a flag. -const rawHost = process.env["NOTION_MCP_HOST"] || undefined; -// Must start with an alphanumeric or ":" (for IPv6 like "::") — this both keeps -// it shell-inert and prevents a leading-"-" value being consumed as a flag by -// the child CLI rather than as the host. -if (rawHost !== undefined && !/^[A-Za-z0-9:][A-Za-z0-9.:_-]*$/.test(rawHost)) { - console.error( - `[notion-mcp] NOTION_MCP_HOST must be a hostname or IP address starting ` + - `with a letter, digit, or ":" (e.g. "::", "0.0.0.0", "localhost"), got: ` + - `${JSON.stringify(rawHost)}`, - ); - process.exit(1); -} -const host = rawHost ?? "127.0.0.1"; - -// Carry the Notion secret as the outbound `Authorization: Bearer` via -// OPENAPI_MCP_HEADERS. We deliberately do NOT set `Notion-Version` here: the -// server (@notionhq/notion-mcp-server ≥ 2.4) sources the API version -// per-operation from its bundled OpenAPI spec, and a globally-configured -// `Notion-Version` header would OVERRIDE those per-operation defaults, pinning -// every call to one (soon-stale) version and breaking newer endpoints. Leaving -// it out lets each operation use the version its schema was written for. (Set -// NOTION_VERSION only if you must force a specific version for an older server.) -const forcedVersion = process.env["NOTION_VERSION"]; -const openApiHeaders = JSON.stringify({ - Authorization: `Bearer ${notionToken}`, - ...(forcedVersion ? { "Notion-Version": forcedVersion } : {}), -}); - -// OPENAPI_MCP_HEADERS is our Notion auth source. It takes precedence anyway -// (the server's parseHeadersFromEnv checks OPENAPI_MCP_HEADERS before falling -// back to NOTION_TOKEN), so deleting NOTION_TOKEN from the child env is -// defensive cleanup: it removes the unused fallback and suppresses the server's -// NOTION_TOKEN startup diagnostic (a /v1/users/me probe). dotenv loaded it into -// process.env, so delete it after the spread. -const childEnv: NodeJS.ProcessEnv = { - ...process.env, - OPENAPI_MCP_HEADERS: openApiHeaders, - AUTH_TOKEN: authToken, -}; -delete childEnv["NOTION_TOKEN"]; - -const isWindows = process.platform === "win32"; -const child = spawn( - "npx", - [ - "-y", - "@notionhq/notion-mcp-server", - "--transport", - "http", - "--host", - host, - "--port", - port, - ], - { - stdio: "inherit", - // Windows only: shell:true so `npx` resolves to `npx.cmd`. Node refuses to - // spawn `.cmd`/`.bat` without a shell (CVE-2024-27980) → `spawn EINVAL`. On - // POSIX we spawn `npx` directly (no shell), which both removes the shell - // wrapper entirely and lets `detached` put the server in its own process - // group so we can signal the WHOLE tree on shutdown (see killTree below). - shell: isWindows, - detached: !isWindows, - env: childEnv, - }, -); - -// Set when WE forward a shutdown signal (below), so the exit handler can tell a -// clean, operator/platform-initiated stop from an abnormal termination. -let shuttingDown = false; -child.on("exit", (code, signal) => { - // A shutdown WE initiated (SIGINT/SIGTERM forwarded below) is clean. - if (shuttingDown) process.exit(0); - // Anything else is an unexpected termination of a long-running server — a - // FAILURE even on code 0 (e.g. the server exits after an unknown flag). Exit - // non-zero and log so Railway's ON_FAILURE restart fires and it's visible; - // preserve the child's own non-zero code when it has one. - console.error( - `[notion-mcp] sidecar exited unexpectedly (code=${code}, signal=${signal ?? "none"}) — treating as failure`, - ); - process.exit(code || 1); -}); -// Without this, a failed spawn (e.g. `npx` not on PATH -> ENOENT, or EACCES) -// surfaces as an uncaught 'error' event with a raw Node stack trace instead -// of the clean, actionable messages this script prints for every other -// misconfiguration. -child.on("error", (err) => { - console.error("[notion-mcp] failed to start the sidecar:", err.message); - process.exit(1); -}); -// Signal the whole child tree. On POSIX the child is a detached group leader, so -// process.kill(-pid) reaches npx AND the underlying node server; on Windows we -// fall back to child.kill (no POSIX process groups). Wrapped because the group -// may already be gone (ESRCH) by the time the escalation fires. -const killTree = (sig: NodeJS.Signals) => { - try { - if (!isWindows && child.pid) process.kill(-child.pid, sig); - else child.kill(sig); - } catch { - // already exited — nothing to signal - } -}; -// Forward shutdown signals, then escalate to SIGKILL if the tree hasn't exited -// within a grace window. `.unref()` so this timer never keeps us alive. -const shutdown = (sig: NodeJS.Signals) => { - shuttingDown = true; - killTree(sig); - setTimeout(() => killTree("SIGKILL"), 5000).unref(); -}; -process.on("SIGINT", () => shutdown("SIGINT")); -process.on("SIGTERM", () => shutdown("SIGTERM")); diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..1511b3b --- /dev/null +++ b/server.ts @@ -0,0 +1,186 @@ +import "dotenv/config"; +import { + createServer, + type RequestListener, + type Server, +} from "node:http"; +import { pathToFileURL } from "node:url"; +import type { ChannelsControl } from "@copilotkit/runtime/v2"; +import { createOpenTagApplication } from "./app/index.js"; +import { closeBrowser } from "./app/render/browser.js"; + +export type RuntimeListener = RequestListener & { + channels?: ChannelsControl; +}; + +export interface HttpServerLike { + readonly listening: boolean; + listen(port: number, host: string, callback: () => void): unknown; + close(callback: (error?: Error) => void): unknown; + once(event: "error", listener: (error: Error) => void): unknown; + off(event: "error", listener: (error: Error) => void): unknown; +} + +export interface SignalTarget { + once(signal: NodeJS.Signals, listener: () => void): unknown; + off(signal: NodeJS.Signals, listener: () => void): unknown; +} + +export interface RunningOpenTagServer { + server: HttpServerLike; + shutdown(): Promise<void>; +} + +export interface StartOpenTagServerOptions { + listener: RuntimeListener; + port: number; + closeBrowser: () => Promise<void>; + createHttpServer?: (listener: RequestListener) => HttpServerLike; + signalTarget?: SignalTarget; + readinessTimeoutMs?: number; + onShutdownError?: (error: unknown) => void; +} + +function listen(server: HttpServerLike, port: number): Promise<void> { + return new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off("error", onError); + reject(error); + }; + + server.once("error", onError); + server.listen(port, "::", () => { + server.off("error", onError); + resolve(); + }); + }); +} + +function close(server: HttpServerLike): Promise<void> { + if (!server.listening) return Promise.resolve(); + + return new Promise((resolve, reject) => { + server.close((error?: Error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +async function settleCleanup( + operations: ReadonlyArray<() => Promise<void>>, +): Promise<void> { + const results = await Promise.allSettled( + operations.map((operation) => operation()), + ); + for (const result of results) { + if (result.status === "rejected") { + console.error("[opentag] startup cleanup failed", result.reason); + } + } +} + +export async function startOpenTagServer( + options: StartOpenTagServerOptions, +): Promise<RunningOpenTagServer> { + const controls = options.listener.channels; + if (!controls) { + throw new Error( + "The CopilotRuntime listener has no Channels lifecycle control.", + ); + } + + let server: HttpServerLike | undefined; + + try { + await controls.ready({ + timeoutMs: options.readinessTimeoutMs ?? 15_000, + }); + + const createHttpServer = + options.createHttpServer ?? + ((listener: RequestListener): Server => createServer(listener)); + server = createHttpServer(options.listener); + await listen(server, options.port); + } catch (error) { + const failedServer = server; + await settleCleanup([ + () => controls.stop(), + ...(failedServer ? [() => close(failedServer)] : []), + options.closeBrowser, + ]); + throw error; + } + + const startedServer = server; + const signalTarget = options.signalTarget ?? process; + const onShutdownError = + options.onShutdownError ?? + ((error: unknown) => { + console.error("[opentag] shutdown failed", error); + process.exitCode = 1; + }); + let shutdownPromise: Promise<void> | undefined; + + const shutdown = (): Promise<void> => { + if (shutdownPromise) return shutdownPromise; + + shutdownPromise = (async () => { + signalTarget.off("SIGINT", onSignal); + signalTarget.off("SIGTERM", onSignal); + + const results = await Promise.allSettled([ + controls.stop(), + close(startedServer), + options.closeBrowser(), + ]); + const errors = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (errors.length > 0) { + throw new AggregateError(errors, "OpenTag shutdown failed"); + } + })(); + + return shutdownPromise; + }; + + const onSignal = (): void => { + // Signal callbacks cannot be awaited by EventEmitter. The terminal catch + // reports cleanup failure and sets a non-zero process exit code. + void shutdown().catch(onShutdownError); + }; + signalTarget.once("SIGINT", onSignal); + signalTarget.once("SIGTERM", onSignal); + + return { server: startedServer, shutdown }; +} + +export async function main(): Promise<RunningOpenTagServer> { + const application = createOpenTagApplication(); + const running = await startOpenTagServer({ + listener: application.listener, + port: application.environment.port, + closeBrowser, + }); + + console.log( + `[opentag] channels ${application.channels + .map(({ name }) => `"${name}"`) + .join(", ")} listening on [::]:${application.environment.port}`, + ); + return running; +} + +const isMain = + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isMain) { + try { + await main(); + } catch (error) { + console.error("[opentag] startup failed", error); + process.exitCode = 1; + } +} diff --git a/setup.md b/setup.md index 7ec428a..3bc711a 100644 --- a/setup.md +++ b/setup.md @@ -1,301 +1,239 @@ -# OpenTag — setup & configuration - -Everything beyond the [quick start](./README.md#quick-start): the full Slack app walkthrough, -the complete environment reference, running standalone vs. from the monorepo, the Intelligence -Gateway channel mode, wiring up Linear / Notion / inline charts, the other chat platforms, -slash commands, tests, and how the pieces fit together. - -- [How it fits together](#how-it-fits-together) -- [Running it](#running-it) — monorepo or standalone, self-hosted or Intelligence Gateway -- [Deep research (LangGraph deep agent)](#deep-research-langgraph-deep-agent) -- [Intelligence channel mode](#intelligence-channel-mode) -- [1. Create a Slack app](#1-create-a-slack-app) -- [2. Environment variables](#2-environment-variables) -- [3. Integrations](#3-integrations) — Linear, Notion, charts -- [Other platforms](#other-platforms) — Discord, Telegram, WhatsApp -- [Slash commands](#slash-commands) -- [Files → charts, diagrams & tables](#files--charts-diagrams--tables) -- [Tests](#tests) - -## How it fits together +# OpenTag setup -``` -Slack / Discord / Telegram / WhatsApp ──@mention──▶ KiteBot (app/) ──AG-UI──▶ runtime (runtime.ts) - │ BuiltInAgent (LLM) - ├── Linear MCP (hosted) - └── Notion MCP (sidecar) -``` +This guide covers the canonical OpenTag deployment: one Python agent service +and one Node CopilotRuntime service with Channels embedded. -Three moving parts: the **chat-platform app(s)** in `app/`, the **agent** (`runtime.ts`), and — -if you use Notion — a small **Notion MCP sidecar**. KiteBot speaks to the agent over -[AG-UI](https://docs.ag-ui.com); the agent is one CopilotKit `BuiltInAgent` (an LLM plus -optional MCP tools — no Python, no LangGraph). - -KiteBot runs in one of two modes: **self-hosted** (`pnpm dev` → `app/index.ts`, holds the Slack -tokens directly) or **Intelligence Gateway** (`pnpm channel` → `app/managed.ts`, over the -CopilotKit Intelligence Realtime Gateway — see [Intelligence channel -mode](#intelligence-channel-mode)). Both modes talk to the same agent backend -(`pnpm runtime` → `runtime.ts`) via `AGENT_URL`. - -| Concept | Where | -| -------------------------------------------------------------------- | ------------------------------------------------------------------ | -| `createBot({ adapters, agent, tools, context, commands })` | [`app/index.ts`](./app/index.ts) | -| Multi-adapter wiring (Slack/Discord/Telegram/WhatsApp, secret-gated) | [`app/index.ts`](./app/index.ts) | -| `read_thread` — grounds the agent in the real conversation | [`app/tools/read-thread.ts`](./app/tools/read-thread.ts) | -| Render-tools + JSX components (issue card/list, Notion pages) | [`app/tools/render-tools.tsx`](./app/tools/render-tools.tsx), [`app/components/`](./app/components/) | -| Chart / diagram rendering (Playwright → PNG) | [`app/tools/render-chart.tsx`](./app/tools/render-chart.tsx), `render-diagram.tsx`, [`app/render/`](./app/render/) | -| Table rendering (native `<Table>` block, monospace fallback) | [`app/tools/render-table.tsx`](./app/tools/render-table.tsx) | -| Status / incident / links showcase cards | [`app/tools/showcase-tools.tsx`](./app/tools/showcase-tools.tsx), [`app/components/_status.ts`](./app/components/_status.ts) | -| Blocking **human-in-the-loop** gate (`confirm_write`) | [`app/human-in-the-loop/confirm-write.tsx`](./app/human-in-the-loop/confirm-write.tsx) | -| Slash commands (`/agent`, `/triage`, `/preview`, `/file-issue`) | [`app/commands/index.ts`](./app/commands/index.ts) | -| A Block Kit **modal** (`/file-issue`) | [`app/modals/file-issue.tsx`](./app/modals/file-issue.tsx) | -| The agent backend — one `BuiltInAgent` (LLM + Linear/Notion MCP) | [`runtime.ts`](./runtime.ts) | - -- **`app/`** is the platform-agnostic KiteBot code. **This is the directory you copy to start your own bot.** -- **`runtime.ts`** is the agent backend, served over AG-UI. -- **`e2e/`** holds live test harnesses (the Slack harness is being migrated to the new - `createBot` API; the Telegram harness is a working manual-trigger smoke test — see - [`e2e/TELEGRAM-README.md`](./e2e/TELEGRAM-README.md)). - -It's built on: - -- **[`@copilotkit/channels`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels)** — the platform-agnostic bot engine. -- **[`@copilotkit/channels-slack`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-slack)** / **[`-discord`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-discord)** / **[`-telegram`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-telegram)** / **[`-whatsapp`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-whatsapp)** — the platform adapters. -- **[`@copilotkit/channels-ui`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-ui)** — a cross-platform JSX vocabulary for rich messages (Block Kit on Slack, Components V2 on Discord, HTML on Telegram). -- **[`@copilotkit/channels-intelligence`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-intelligence)** — runs the same KiteBot over the CopilotKit Intelligence Realtime Gateway (Intelligence Gateway mode, no platform tokens in this process). -- **[`@copilotkit/runtime`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/runtime)** — the AG-UI agent backend. - -## Running it - -### From the monorepo - -If you're working inside the [CopilotKit monorepo](https://github.com/CopilotKit/CopilotKit), -this code runs there as `examples/slack`, whose `package.json` name is `slack-example` (this -standalone repo's `package.json` name is `opentag` — the `--filter` below only resolves inside -the monorepo), building the `@copilotkit/channels*` adapters from source: +Slack and Microsoft Teams are supported for this launch. Discord, Telegram, +and WhatsApp are coming soon. -```bash -pnpm install # repo root -pnpm --filter slack-example notion-mcp # only if using Notion → http://127.0.0.1:3001/mcp -pnpm --filter slack-example runtime # CopilotKit runtime on :8200, agent "triage" -pnpm --filter slack-example dev # KiteBot (tsx watch app/index.ts) -``` +## Components -### Standalone (npm) +| Component | Location | Responsibility | +| --- | --- | --- | +| Runtime entrypoint | [`server.ts`](./server.ts) | Environment, Channels readiness, HTTP lifecycle, and shutdown | +| Application composition | [`app/index.ts`](./app/index.ts) | SDK agent factory, managed Channel, and runtime | +| Channel definition | [`app/channel.tsx`](./app/channel.tsx) | Mentions, commands, components, modals, and interrupts | +| Intelligence runtime | [`app/runtime-host.ts`](./app/runtime-host.ts) | One `CopilotKitIntelligence` and one `CopilotRuntime` | +| Python agent | [`agent/`](./agent) | LangGraph deep agent served over AG-UI | +| Railway topology | [`.railway/railway.ts`](./.railway/railway.ts) | Two services sourced from OpenTag `main` | -The `@copilotkit/channels*` packages are published on npm, so a plain `npm install` here works -as-is — no monorepo required: - -```bash -npm install -npm run notion-mcp # terminal 1 — only if using Notion -npm run runtime # terminal 2 — the agent backend on :8200 -npm run dev # terminal 3 — KiteBot, self-hosted (holds the Slack tokens) -``` +The host always uses the Intelligence-owned runtime. It declares one +adapter-free Channel using the configured name. Its Slack and Microsoft Teams +adapters, credentials, and attachments are configured only in Intelligence. -The chart/diagram renderers need a Chromium binary: `npx playwright install chromium`. +## Install -## Deep research (LangGraph deep agent) +Prerequisites: -[`agent/`](./agent) is an alternative agent backend to `runtime.ts`: a Python -[`deepagents`](https://github.com/langchain-ai/deepagents) (LangGraph) planner with a virtual -filesystem and OPTIONAL Tavily web research, served over AG-UI on `:8123`. Instead of -`runtime.ts`'s single system-prompted `BuiltInAgent` call, it plans with `write_todos`, -reads/writes its own virtual files, and — when configured — researches the web before -synthesizing an answer, all while still calling KiteBot's forwarded generative-UI tools the same -way the TS runtime does. +- Node.js 22+ +- pnpm +- Python 3.12 +- [`uv`](https://docs.astral.sh/uv/) +- A CopilotKit Intelligence project, Channel, and runtime API key +- An OpenAI API key for the Python agent -**Setup** — requires [`uv`](https://docs.astral.sh/uv/) and Python 3.12: +Install both dependency sets: ```bash -cd agent && uv sync +pnpm install --frozen-lockfile +cd agent +uv sync +cd .. ``` -Copy `agent/.env.example` to `agent/.env` and fill it in: +The Channels and Runtime packages are intentionally pinned to canaries: -| Variable | What it's for | -| --- | --- | -| `OPENAI_API_KEY` | **Required** — the model. | -| `TAVILY_API_KEY` | **Optional** — turns on live web research. Without it the agent still chats and generates UI components, answering from its own knowledge. | -| `OPENAI_MODEL` | Defaults to `gpt-5.5`, matching the rest of OpenTag. | -| `SERVER_HOST` / `SERVER_PORT` | Defaults to `0.0.0.0:8123`. | +```text +@copilotkit/channels 0.2.2-canary.rc-1 +@copilotkit/runtime 1.63.3-canary.rc-1 +``` -**Run it:** +## Configure the environment ```bash -pnpm agent # cd agent && uv run python main.py (port from SERVER_PORT/PORT env, default 8123) +cp .env.example .env ``` -Then point the bot at it instead of `runtime.ts` by setting in the root `.env`: +| Variable | Required | Purpose | +| --- | --- | --- | +| `OPENAI_API_KEY` | Yes | Model access | +| `OPENAI_MODEL` | No | Defaults to `gpt-5.5` | +| `OPENAI_REASONING_EFFORT` | No | Defaults to `low` | +| `OPENAI_VERBOSITY` | No | Defaults to `low` | +| `TAVILY_API_KEY` | No | Enables live web research | +| `LINEAR_API_KEY` | No | Enables the hosted Linear MCP | +| `LINEAR_MCP_URL` | No | Overrides the hosted Linear MCP URL | +| `NOTION_MCP_AUTH_TOKEN` | No | Bearer token for a remote Notion MCP; requires `NOTION_MCP_URL` | +| `NOTION_MCP_URL` | No | Remote Notion MCP endpoint; requires `NOTION_MCP_AUTH_TOKEN` | +| `SERVER_HOST` | No | Local bind host; defaults to `0.0.0.0` | +| `SERVER_PORT` / `PORT` | No | Local port; defaults to `8123` | + +Only `OPENAI_API_KEY` is required. Without Tavily or internal-source +credentials, the agent still chats, triages, and renders supported UI +components. Planning and virtual files remain available for explicitly +substantial work. The Python agent explicitly loads this root `.env` for local +development; Railway service variables work normally without a checked-in +environment file. + +Run it: ```bash -AGENT_URL=http://localhost:8123/ +pnpm agent ``` -With the deep agent in the mix, a local setup is three processes: `pnpm agent` (the Python -deep-research brain, `:8123`), the bot (`pnpm channel` or `pnpm dev`), and — if you're using -`runtime.ts` instead — `pnpm runtime` (`:8200`). `agent` and `runtime` are two alternative brains -for the same bot; run whichever one `AGENT_URL` points at. +The AG-UI endpoint is `http://localhost:8123/`; `/health` reports the +`opentag-agent` service. + +## Configure Intelligence + +In [CopilotKit Intelligence](https://intelligence.copilotkit.ai): + +1. Create or select the OpenTag project. +2. Create one Channel named `open-tag`. +3. Issue a runtime API key. +4. Configure the Slack and Microsoft Teams adapters on that Channel. + +| Variable | Required | Purpose | +| --- | --- | --- | +| `AGENT_URL` | Yes | Python AG-UI endpoint, locally `http://localhost:8123/` | +| `INTELLIGENCE_API_KEY` | Yes | Runtime authentication | +| `INTELLIGENCE_API_URL` | No | Defaults to `https://api.intelligence.copilotkit.ai` | +| `INTELLIGENCE_GATEWAY_WS_URL` | No | Defaults to `wss://realtime.intelligence.copilotkit.ai` | +| `INTELLIGENCE_CHANNEL_NAME` | No | Defaults to `open-tag`; Railway uses `open-tag` | +| `AGENT_AUTH_HEADER` | No | Authorization header forwarded to the agent | +| `PORT` | No | Channel HTTP port; defaults to `3000` | -## Intelligence channel mode +Legacy organization, project, Channel ID, and runtime-instance ID variables are +not used. Slack and Teams credentials also do not belong in this environment; +Intelligence owns them. -`pnpm channel` (`app/managed.ts`) runs the same KiteBot over the **CopilotKit Intelligence -Realtime Gateway** instead of a native platform adapter — this process holds **no Slack tokens**; -Intelligence owns the Slack edge (signed ingress + Connector Outbox egress) and streams render -frames back over `@copilotkit/channels-intelligence`. It's the Intelligence Gateway counterpart to -the self-hosted `pnpm dev` mode described above — you still run this process yourself and bring -your own CopilotKit Intelligence project. +Start the runtime: ```bash -npm run runtime # terminal 1 — the agent backend on :8200 (same as self-hosted) -npm run channel # terminal 2 — the Intelligence Gateway KiteBot (tsx app/managed.ts) +pnpm runtime ``` -Configure it with: +Use `pnpm dev` for watch mode. `pnpm start` and `pnpm runtime` both run the +same canonical entrypoint. Startup waits for `listener.channels.ready()` before +opening HTTP. SIGINT and SIGTERM stop Channels, HTTP, and the rendering browser +once, even if shutdown is requested more than once. -| Variable | What it's for | -| --- | --- | -| `INTELLIGENCE_GATEWAY_WS_URL` | The Intelligence Realtime Gateway websocket endpoint. | -| `INTELLIGENCE_API_KEY` | Auth for the gateway connection. | -| `INTELLIGENCE_ORG_ID` / `INTELLIGENCE_PROJECT_ID` / `INTELLIGENCE_CHANNEL_ID` | Scopes the connection to your Intelligence org/project/channel. | -| `INTELLIGENCE_CHANNEL_NAME` | The registered channel name (lowercase kebab). Defaults to `kitebot`. | +## Slack -The agent backend is still required in this mode — `pnpm runtime` (`runtime.ts`) — the Intelligence -channel host points its `AGENT_URL` at it exactly like the self-hosted KiteBot does. `AGENT_URL` -itself is required in every mode (the process exits at startup if it's unset); `.env.example` ships -it pre-filled with the local runtime URL as a template, not as a code-level default. See -[`.env.example`](./.env.example) for the full annotated list. +### Existing production `@kite` -## 1. Create a Slack app +Do not create, reinstall, or replace the current production Slack app. The +existing app owns the bot user, workspace installation, and `@kite` handle. -1. Go to <https://api.slack.com/apps?new_app=1> → **From a manifest** → paste - [`slack-app-manifest.yaml`](./slack-app-manifest.yaml). The manifest declares all four slash - commands, the assistant pane, the `users:read.email` scope, and **Socket Mode** (so KiteBot - connects outbound — no public URL needed). -2. **OAuth & Permissions** → **Install to Workspace** → copy the `xoxb-` **Bot User OAuth - Token** → this is your `SLACK_BOT_TOKEN`. -3. **Basic Information → App-Level Tokens** → generate one with the `connections:write` scope → - copy the `xapp-` token → this is your `SLACK_APP_TOKEN`. +For cutover: -(Discord, Telegram, and WhatsApp setup is documented inline in [`.env.example`](./.env.example) -and summarized under [Other platforms](#other-platforms).) +1. Stop the old Kite Socket Mode runtime so there is only one consumer. +2. Enter the existing `xapp` and `xoxb` tokens directly into the Slack + attachment in Intelligence. Do not put them in source control or chat. +3. Start the OpenTag `runtime` service with + `INTELLIGENCE_CHANNEL_NAME=open-tag`. +4. Send one `@kite` mention and verify the reply uses the OpenTag persona and + Python deep agent. -## 2. Environment variables +If the cutover fails, stop the new Channel before restoring the prior Railway +deployment. -Copy the template and fill in the platform(s) and integrations you want — KiteBot starts an -adapter for each platform whose secrets are present, and the agent wires up whichever data -sources have credentials. (Running in [Intelligence channel mode](#intelligence-channel-mode) -instead uses the `INTELLIGENCE_*` variables in place of the platform tokens below.) +### New Slack installations -```bash -cp .env.example .env -``` +The JSON and YAML Slack manifests in this repository describe OpenTag for +future installations. Create a new Slack app from either manifest, install it +to the workspace, and attach its app-level and bot tokens in Intelligence. -| Variable | What it's for | -| --- | --- | -| `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` | Run on Slack (see [step 1](#1-create-a-slack-app)). | -| `OPENAI_API_KEY` | The model. Required — the runtime is OpenAI-only (it runs on the OpenAI Responses API, needed for `web_search`); `ANTHROPIC_API_KEY` / `GOOGLE_API_KEY` are not read by this runtime. | -| `AGENT_MODEL` | OpenAI model id override, optionally prefixed `openai/` (stripped). Defaults to `openai/gpt-5.5`. | -| `LINEAR_API_KEY` / `LINEAR_TEAM_KEY` | Wire up Linear (linear.app → Settings → API → Personal API keys). | -| `NOTION_TOKEN` / `NOTION_MCP_AUTH_TOKEN` | Wire up Notion (see [Notion](#notion)). | -| `DISCORD_BOT_TOKEN` / `DISCORD_APP_ID` | Run on Discord. | -| `TELEGRAM_BOT_TOKEN` | Run on Telegram. | -| `WHATSAPP_ACCESS_TOKEN` (+ siblings) | Run on WhatsApp Cloud API. | -| `INTELLIGENCE_GATEWAY_WS_URL` / `INTELLIGENCE_API_KEY` / `INTELLIGENCE_ORG_ID` / `INTELLIGENCE_PROJECT_ID` / `INTELLIGENCE_CHANNEL_ID` / `INTELLIGENCE_CHANNEL_NAME` | Run in [Intelligence channel mode](#intelligence-channel-mode) instead of holding platform tokens directly. | -| `AGENT_URL` | Where KiteBot POSTs. **Required** — the process exits at startup if unset; the template value points at the local runtime (`…/agent/triage/run`). | +Those manifests are not a production migration step for `@kite`. -Every integration is independent — set only what you need. The full annotated list, including the -WhatsApp webhook details, is in [`.env.example`](./.env.example). +## Microsoft Teams -## 3. Integrations +Managed Microsoft Teams is supported. Configure the Teams adapter on the same +`open-tag` Channel in Intelligence. The same Node process and runtime host both +platforms; there is no direct adapter or Railway platform credential. -### Linear +## Tools, commands, and UI -The hosted Linear MCP accepts a raw API key as a bearer token (no OAuth dance). Create one at -**linear.app → Settings → API → Personal API keys**, set `LINEAR_API_KEY`, and optionally -`LINEAR_TEAM_KEY` (the default team to file/query against). Leave `LINEAR_API_KEY` blank to run -without Linear. With it set, the agent can: +OpenTag registers: -- **Query Linear** — _"what's open in CPK this cycle?"_ → renders the issues as a rich card. -- **File a Linear issue** — _"file this thread as a bug"_ → drafts it, asks you to **confirm**, then creates it. +- `/agent <text>` to run a mention-free prompt. +- `/triage [note]` to summarize and propose Linear issues. +- `/preview <title>` to preview an issue privately where supported. +- `/file-issue` to open a form where supported, with a conversational fallback. -### Notion +The Channel also forwards sender context, Slack-specific tools on Slack turns, +file content, and rich issue/page/table/chart/diagram/status/incident/link +components. -Notion runs as a small **Streamable-HTTP sidecar** wrapping the official -[`@notionhq/notion-mcp-server`](https://www.npmjs.com/package/@notionhq/notion-mcp-server). Start -it with `pnpm notion-mcp` (or `npm run notion-mcp`). +Before a Linear or Notion mutation reaches MCP, a Python interceptor emits +`confirm_write`. The Channel posts an approval card, and the button resumes the +graph with the user's decision. The MCP handler runs only after approval. +Reads and UI rendering are never gated. -- `NOTION_TOKEN` — the Notion integration secret the sidecar uses to call the Notion API - (notion.so → Settings → Connections → develop integrations). -- `NOTION_MCP_AUTH_TOKEN` — a bearer the sidecar requires on its HTTP transport; pick any strong - string and set the same value here and when starting the sidecar. Leave it blank to run without - Notion. +## Optional sources -With it set, the agent can **find pages** (_"find the runbook for the auth outage"_) and -**write a postmortem** (_"write this thread up as a Notion doc"_ → reads, summarizes, -**confirms**, then creates the page). +### Tavily -### The human-in-the-loop write gate +Set `TAVILY_API_KEY` in the root `.env` to enable live web research. The +`web_search` tool is not registered when the key is absent. -Every write — Linear or Notion — goes through a blocking **`confirm_write`** gate: the agent must -call that tool and wait for a **Create / Cancel** click before it performs the write. See -[`app/human-in-the-loop/confirm-write.tsx`](./app/human-in-the-loop/confirm-write.tsx). +### Linear -### Charts, diagrams & tables +Set `LINEAR_API_KEY` in the root `.env`. OpenTag connects to the hosted Linear +MCP by default. Railway preserves this optional secret on the `agent` service. -The chart/diagram libraries load from a CDN into a **local** headless browser (override -`CHART_JS_URL` / `MERMAID_URL`) — your data is rendered locally and never sent to a rendering -service. Requires a Chromium binary: `npx playwright install chromium`. +### Notion -## Other platforms +Notion is optional and is not a separate Railway service. Configure an existing +remote MCP endpoint by setting both `NOTION_MCP_URL` and +`NOTION_MCP_AUTH_TOKEN`, then restart `pnpm agent` so it discovers the tools. +If either value is absent, OpenTag skips Notion without blocking startup. -The same `app/` code runs on every platform — `createBot` takes an array of adapters, and -`app/index.ts` starts one for each platform whose secrets are present. Everything else (tools, -components, the HITL gate, rendering) is shared verbatim. +## Railway -- **Discord** — set `DISCORD_BOT_TOKEN` + `DISCORD_APP_ID` (and optionally `DISCORD_GUILD_ID` for - instant slash-command registration in dev). Enable the **Message Content** and **Server - Members** privileged intents. -- **Telegram** — message [@BotFather](https://t.me/BotFather) → `/newbot` → set `TELEGRAM_BOT_TOKEN`. - Long-polling is the default ingress (no public URL needed). -- **WhatsApp** — set `WHATSAPP_ACCESS_TOKEN` + siblings from your Meta App → WhatsApp → API Setup. - The server listens on `$PORT` for the webhook. +The IaC file declares exactly: -Per-platform details are documented inline in [`.env.example`](./.env.example). +- `agent`: `CopilotKit/OpenTag`, branch `main`, root `agent`, Railpack, + `/health`, port `8123`. +- `runtime`: `CopilotKit/OpenTag`, branch `main`, repository root, + `pnpm runtime`, `/api/copilotkit/info`, port `3000`. -## Slash commands +`runtime.AGENT_URL` references the agent's Railway private domain and port. +Production Intelligence URLs are literal configuration, the API key is +preserved, and the Channel name is `open-tag`. `OPENAI_API_KEY` is required on +`agent`; Tavily, Linear, and the paired remote Notion variables are optional +preserved settings. -Four app-owned commands, registered via `createBot({ commands })` -([`app/commands/index.ts`](./app/commands/index.ts)): +Evaluate the configuration locally without applying it: -- **`/agent <text>`** — a mention-free entry point; runs the agent with the command text. -- **`/triage [note]`** — summarizes the conversation and proposes issues to file. -- **`/preview <title>`** — privately previews the issue KiteBot would file (only you see it); - degrades to a DM where ephemerals aren't supported. -- **`/file-issue`** — opens a structured issue **modal**; degrades to a conversational flow on - platforms without modals (e.g. Telegram). +```bash +node node_modules/railway/dist/iac/bin.js +``` -On Slack, all four must be declared under **Slash Commands** — the manifest already does this. +The live Railway migration reuses the existing Kite `runtime` service, adds the +`agent`, connects both to OpenTag, and enables GitHub autodeploys. -## Files → charts, diagrams & tables +## Coming soon -Upload a file and KiteBot analyzes it: images and **PDFs** go straight to the model; CSV/JSON/text -are decoded and handed over as text. Then ask it to visualize: +Discord, Telegram, and WhatsApp are intentionally not configured for this +launch. Their adapters and setup instructions will be added after launch +support is ready. -> chart revenue by month · diagram this incident flow · show it as a table +## Tests -> **PDFs and images need a vision/document-capable model.** The runtime is OpenAI-only, and the -> default `openai/gpt-5.5` reads both natively. If you override `AGENT_MODEL`, pick another -> vision/document-capable OpenAI model — non-OpenAI model ids (Claude, Gemini, etc.) are not -> supported by this runtime. +```bash +pnpm install --frozen-lockfile +pnpm check-types +pnpm test +cd agent && uv run pytest +``` -## Tests +The Slack API live harness is separate from unit tests: ```bash -npm test # unit: read_thread, render tools, components, confirm_write, modals, commands -npm run check-types # tsc --noEmit +pnpm e2e ``` -The live-Slack e2e harness (`npm run e2e`) is being migrated to the new `createBot` API and -doesn't run against this code as-is. The Telegram harness (`npm run e2e:telegram`) is a working -manual-trigger smoke test — see [`e2e/TELEGRAM-README.md`](./e2e/TELEGRAM-README.md). +See [`e2e/README.md`](./e2e/README.md) for its required workspace credentials. +There is no launch-blocking Teams E2E harness; production acceptance is the +single `@kite` round trip described above. diff --git a/slack-app-manifest.json b/slack-app-manifest.json index eadac88..bebd608 100644 --- a/slack-app-manifest.json +++ b/slack-app-manifest.json @@ -1,7 +1,7 @@ { "display_information": { - "name": "KiteBot", - "description": "On-call triage assistant \u2014 query/file Linear issues and find/write Notion pages from Slack.", + "name": "OpenTag", + "description": "OpenTag research and triage assistant for Slack.", "background_color": "#1a1a1a" }, "features": { @@ -11,11 +11,11 @@ "messages_tab_read_only_enabled": false }, "bot_user": { - "display_name": "KiteBot", + "display_name": "OpenTag", "always_online": true }, "assistant_view": { - "assistant_description": "On-call triage assistant — query/file Linear issues and find/write Notion pages.", + "assistant_description": "OpenTag research and triage assistant.", "suggested_prompts": [ { "title": "Triage my open issues", diff --git a/slack-app-manifest.yaml b/slack-app-manifest.yaml index 97e216a..94b38c0 100644 --- a/slack-app-manifest.yaml +++ b/slack-app-manifest.yaml @@ -1,10 +1,10 @@ display_information: - name: KiteBot - description: On-call triage assistant — query/file Linear issues and find/write Notion pages from Slack. + name: OpenTag + description: OpenTag research and triage assistant for Slack. background_color: "#1a1a1a" features: bot_user: - display_name: KiteBot + display_name: OpenTag always_online: true # App Home: enable the Messages tab so users can DM the bot (and so the tab # isn't read-only — otherwise Slack shows "Sending messages to this app has @@ -14,9 +14,9 @@ features: messages_tab_enabled: true messages_tab_read_only_enabled: false # Enables the assistant pane ("Agents & AI Apps"). The greeting + chips here - # mirror the `assistant` option in app/index.ts. + # mirror the suggestions registered when an OpenTag thread starts. assistant_view: - assistant_description: On-call triage assistant — query/file Linear issues and find/write Notion pages. + assistant_description: OpenTag research and triage assistant. suggested_prompts: - title: Triage my open issues message: Triage my open issues @@ -42,7 +42,7 @@ features: oauth_config: scopes: user: - - chat:write # user-token write access (e.g. grab-user-token.ts for e2e) + - chat:write # user-token write access for the optional live E2E harness bot: # --- Read what the user said --- - app_mentions:read # see @mentions of the bot diff --git a/tsconfig.json b/tsconfig.json index e736d85..90cd3aa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,8 +14,13 @@ "lib": ["es2022", "dom"], "types": ["node"], "jsx": "react-jsx", - "jsxImportSource": "@copilotkit/channels-ui" + "jsxImportSource": "@copilotkit/channels" }, - "include": ["app/**/*.ts", "app/**/*.tsx", "runtime.ts", "scripts/**/*.ts"], + "include": [ + "app/**/*.ts", + "app/**/*.tsx", + "server.ts", + "scripts/**/*.ts" + ], "exclude": ["node_modules", "e2e"] } diff --git a/vitest.config.ts b/vitest.config.ts index b03a7fd..c7f2558 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,11 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ - esbuild: { - jsx: "automatic", - jsxImportSource: "@copilotkit/channels-ui", + oxc: { + jsx: { + runtime: "automatic", + importSource: "@copilotkit/channels", + }, }, test: { include: ["app/**/*.test.ts", "app/**/*.test.tsx"],