chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
# Set SLACK_*, DISCORD_*, and/or TELEGRAM_BOT_TOKEN — the bot starts an adapter
# for each platform whose secrets are present (any one, or several at once).
# ── 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-...
# ── 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. Default points at the local
# CopilotKit runtime started by `pnpm runtime`.
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 ...
# Model for the BuiltInAgent. provider/model — the runtime resolves the
# provider and reads the matching API key below. Defaults to openai/gpt-5.5.
# AGENT_MODEL=anthropic/claude-sonnet-4.5
OPENAI_API_KEY=sk-...
# ANTHROPIC_API_KEY=sk-ant-...
# GOOGLE_API_KEY=...
# ── Intelligence Channel mode — app/managed.ts (`pnpm channel`) ──────────
# The Channel variant runs the SAME bot over the Intelligence realtime gateway
# instead of a native adapter — it holds NO Slack tokens (Intelligence owns the
# Slack edge). Set these to run `pnpm channel`; the native `pnpm dev` ignores
# them. The agent brain reuses AGENT_URL / AGENT_AUTH_HEADER above.
# Gateway runner WebSocket URL (the /runner socket hosting the bot-IO channel).
# INTELLIGENCE_GATEWAY_WS_URL=wss://gateway.intelligence.example/runner
# Project runtime API key (cpk-…) — presented as the socket authToken.
# INTELLIGENCE_API_KEY=cpk-...
# Product organization id (as issued by Intelligence).
# INTELLIGENCE_ORG_ID=org_...
# Numeric project id (the channel topic is channels:project:<id>).
# INTELLIGENCE_PROJECT_ID=123
# Channel id + immutable project-unique channel name (from Intelligence Channel setup).
# INTELLIGENCE_CHANNEL_ID=channel_...
# INTELLIGENCE_CHANNEL_NAME=triage
# Optional: stable runtime instance id; a random rti_… is generated if unset.
# INTELLIGENCE_RUNTIME_INSTANCE_ID=rti_...
# ── 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
# ── 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
+10
View File
@@ -0,0 +1,10 @@
node_modules
.env
.env.local
# E2E harness artifacts
e2e/.chrome-profile
e2e/results
state.db
state.db-shm
state.db-wal
+495
View File
@@ -0,0 +1,495 @@
# bot-example — on-call triage assistant (Slack, Discord, Telegram &/or WhatsApp)
A runnable demo for [`@copilotkit/channels-slack`](../../packages/channels-slack),
[`@copilotkit/channels-discord`](../../packages/channels-discord),
[`@copilotkit/channels-telegram`](../../packages/channels-telegram), **and**
[`@copilotkit/channels-whatsapp`](../../packages/channels-whatsapp): an on-call triage bot
that turns incident chatter into tracked work. It's built with
[`@copilotkit/channels`](../../packages/channels) (the platform-agnostic bot core), one or
more platform adapters, and [`@copilotkit/channels-ui`](../../packages/channels-ui) (a
cross-platform JSX vocabulary for rich messages).
**One app, any platform — or all at once.** `createBot` takes an array of
adapters; `app/index.ts` includes the Slack adapter when `SLACK_*` secrets are
present, the Discord adapter when `DISCORD_*` are present, the Telegram adapter
when `TELEGRAM_BOT_TOKEN` is present, and the WhatsApp adapter when `WHATSAPP_*`
are present. Everything else in `app/` (tools,
components, the `confirm_write` HITL gate, chart/diagram/table rendering) is
platform-agnostic and shared verbatim — set the secrets for whichever
platform(s) you want and run the same process. It connects to **Linear** and
**Notion** over MCP and can:
- **Query Linear** — _"what's open in CPK this cycle?"_ → renders issues
as a rich card (Block Kit on Slack, Components V2 on Discord, HTML on
Telegram).
- **File a Linear issue** — _"file this thread as a bug"_ → drafts the
issue, asks you to **confirm**, then creates it.
- **Find Notion pages** — _"find the runbook for the auth outage"_
renders matching pages with links.
- **Write a postmortem** — _"write this thread up as a Notion doc"_
reads the thread, summarizes, **confirms**, then creates the page.
Every write goes through a human-in-the-loop **`confirm_write`** gate: the
agent must call that tool and wait for a Create/Cancel click before it
performs any Linear/Notion write.
## How it fits together
```
Slack / Discord / Telegram ──@mention──▶ bot (app/) ──AG-UI──▶ runtime (runtime.ts)
│ BuiltInAgent (LLM)
├── Linear MCP (hosted)
└── Notion MCP (sidecar)
```
- **`app/`** — the platform-agnostic bot: `createBot` + whichever of the
`slack()` / `discord()` / `telegram()` adapters have secrets, the
`read_thread` / `render_chart` / `render_diagram` / `render_table` tools,
the `issue_card` / `issue_list` / `page_list` render-tools, the
`confirm_write` HITL gate, and the bot's context. The components emit a
cross-platform JSX IR that each adapter renders natively. This is the
directory you'd copy to start your own bot.
- **`runtime.ts`** — the agent backend: a single CopilotKit `BuiltInAgent`
(LLM + Linear/Notion MCP), served over AG-UI. No Python, no LangGraph.
- **`e2e/`** — live test harnesses. The Slack harness (`run.ts` /
`restart-recovery.ts`, `pnpm e2e`) is _legacy/WIP — see [Tests](#tests)_;
the Telegram harness (`telegram-run.ts`, `pnpm e2e:telegram`) is a
manual-trigger smoke test — see [`e2e/TELEGRAM-README.md`](e2e/TELEGRAM-README.md).
### The bot (`app/index.ts`)
The core shape is `createBot` + one or more adapters, an `onMention` handler,
and `start()`. The snippet below is an **abridged, single-platform sketch**
the real `app/index.ts` builds the adapter list from whichever secrets are
present (Slack, Discord, and/or Telegram) and adds graceful shutdown; read the
file for the full multi-platform wiring:
```ts
import { createBot } from "@copilotkit/channels";
import {
slack,
defaultSlackTools,
defaultSlackContext,
SanitizingHttpAgent,
} from "@copilotkit/channels-slack";
import { appTools } from "./tools/index.js";
import { appContext } from "./context/app-context.js";
const bot = createBot({
adapters: [
slack({
botToken: process.env.SLACK_BOT_TOKEN!,
appToken: process.env.SLACK_APP_TOKEN!,
respondTo: {
directMessages: true,
appMentions: { reply: "thread" },
threadReplies: "mentionsOnly",
},
}),
],
// One AG-UI agent per conversation, pointed at the runtime.
agent: (threadId) => {
const a = new SanitizingHttpAgent({ url: process.env.AGENT_URL! });
a.threadId = threadId;
return a;
},
// defaultSlackTools ships universal-Slack tools (e.g. lookup_slack_user
// for @-mentions); appTools adds this bot's tools. defaultSlackContext
// ships tagging/mrkdwn/thread-model guidance; appContext adds identity +
// triage policy.
tools: [...defaultSlackTools, ...appTools],
context: [...defaultSlackContext, ...appContext],
});
// One handler covers explicit @-mentions and normal DMs.
// senderContext names the requesting user so the agent acts "as" them.
bot.onMention(async ({ thread, message }) => {
await thread.runAgent({ context: senderContext(message.user) });
});
await bot.start();
```
The runnable Slack example keeps DMs and the assistant pane conversational, but
channel/private-channel threads require `@Kite` on each follow-up by default.
Set `respondTo.threadReplies: "afterBotReply"` to restore legacy behavior where
plain replies in a thread can continue after the bot has posted there.
### Tools (`app/tools/index.ts`)
The bot's tools are plain `BotTool`s, collected into `appTools` and spread
into `createBot({ tools })`. Each handler receives the generic
`BotToolContext` (`{ thread, message?, user?, signal?, platform }`) the
adapter supplies at call time; tools reach platform power (post, postFile,
`thread.getMessages()`, …) via the `thread` methods:
- **`read_thread`** — fetches the messages in the current conversation thread
so the agent can summarize/act on a real conversation (e.g. "write this
thread up as a postmortem") instead of inventing content.
- **`render_chart`** — the agent emits a Chart.js config; rendered to a PNG
**locally** in a headless browser (reusing the Playwright dep) and posted
inline.
- **`render_diagram`** — the agent emits Mermaid; rendered to a PNG the same
way.
- **`render_table`** — the agent emits columns + rows; rendered natively per
platform (a Slack Table block, otherwise a monospace fallback).
### UI as JSX components
Rich messages are authored as JSX components over the `@copilotkit/channels-ui`
vocabulary (`<Message>`, `<Header>`, `<Section>`, `<Context>`, `<Actions>`,
`<Button>`, …). Each component (`IssueCard`, `IssueList`, `PageList`,
`ConfirmWrite`) is a plain function whose zod prop schema doubles as a tool
input schema. Each adapter renders the same IR natively (Block Kit on Slack,
Components V2 on Discord, HTML on Telegram).
The agent renders them through **render-tools**`BotTool`s that wrap a
component and post it. The agent calls the tool; the handler renders the
component and posts it to the thread:
```tsx
export const issueCardTool: BotTool<typeof issueCardSchema> = {
name: "issue_card",
description: "Render ONE Linear issue as a rich card …",
parameters: issueCardSchema,
async handler(props, { thread }) {
await thread.post(<IssueCard {...props} />);
return JSON.stringify({ ok: true, rendered: "issue_card" });
},
};
```
The three render-tools are **`issue_card`** (a single Linear issue, or one
you just created with `justCreated: true`), **`issue_list`** (several Linear
issues), and **`page_list`** (Notion pages). The system prompt steers the
agent to present results with these instead of prose.
### Human-in-the-loop: `confirm_write`
HITL is a **blocking frontend tool**. Before any Linear/Notion write the
agent must call `confirm_write`, whose handler posts a Create/Cancel card
and blocks until the user clicks — then resolves to the clicked button's
`value`, `{ confirmed: boolean }`. The agent only performs the write when it
gets back `{ confirmed: true }`.
```tsx
export const confirmWriteTool: BotTool<typeof confirmWriteSchema> = {
name: "confirm_write",
description:
"Ask the user to approve a write before you perform it … returns {confirmed}.",
parameters: confirmWriteSchema,
async handler({ action, detail }, { thread }) {
const choice = await thread.awaitChoice(
<ConfirmWrite action={action} detail={detail} />,
);
return JSON.stringify(choice ?? { confirmed: false });
},
};
```
`<ConfirmWrite>` is a JSX card whose Create/Cancel `<Button>`s each carry a
`value` (`{ confirmed: true|false }`) and an inline `onClick` that updates
the card in place to an approved/declined state — so the picker reflects the
decision the moment it's clicked. (On Telegram the value can't ride in the
64-byte `callback_data`, so the core recovers it from the rendered button.)
### Slash commands (`app/commands/`)
Four app-owned slash commands, registered via `createBot({ commands })`:
- **`/agent <text>`** — a mention-free entry point; runs the agent with the
command text as the prompt.
- **`/triage [note]`** — summarizes the conversation and proposes Linear
issues to file.
- **`/preview <title>`** — privately previews the issue the bot would file
(only you see it); degrades to a DM on platforms without ephemeral messages.
- **`/file-issue`** — opens a structured Linear issue form; degrades to a
conversational flow on platforms without modal support (e.g. Telegram).
```ts
defineBotCommand({
name: "agent",
description: "Ask the triage agent anything (no @mention needed).",
async handler({ thread, text, user }) {
if (!text) return void thread.post("Usage: `/agent <your question>`");
await thread.runAgent({ prompt: text, context: senderContext(user) });
},
});
```
The args arrive as `ctx.text`; `runAgent({ prompt })` injects them as the
user message (a slash command's text is never posted to the channel, so it
isn't in the history the agent reconstructs).
> **Slack setup:** all four commands (`/agent`, `/triage`, `/preview`,
> `/file-issue`) must be declared in your Slack app under **Slash Commands** —
> Slack won't deliver an unregistered command, even over Socket Mode. The
> easiest path is to paste the full `slack-app-manifest.yaml` when creating
> (or updating) your app, which already declares all four. Discord and Telegram
> register their commands up front via the adapter.
### The agent (`runtime.ts`)
A single CopilotKit `BuiltInAgent` (LLM + MCP) served over AG-UI by a
`CopilotSseRuntime`. It connects to Linear (hosted MCP, raw API key as
bearer token) and Notion (the official MCP server run as a local
Streamable-HTTP sidecar), discovering the available list/search/create tools
from each server at runtime. A server is only wired up when its credentials
are present, so the bot runs Linear-only, Notion-only, or both. The default
model is `openai/gpt-5.5` (override with `AGENT_MODEL`).
## Local run
Pieces: the **chat-platform app(s)** (Slack, Discord, and/or Telegram, created
once), the optional **Notion MCP sidecar**, the **agent** (`runtime.ts`), and
the **bot** (`app/`). Set up whichever platform(s) you want — the bot starts an
adapter for each one whose secrets are present (so you can run any one, or
several from one process).
> **This example runs from the monorepo.** The Telegram work ships an
> unpublished package (`@copilotkit/channels-telegram`) and depends on a fix in the
> core (`@copilotkit/channels`), so all `@copilotkit/*` deps are `workspace:*` and
> the example runs against local source: `pnpm --filter slack-example <script>`.
> Once those versions publish, switch the deps to published ranges for a
> standalone build.
### 1a. Slack app (set `SLACK_*` to enable Slack)
- <https://api.slack.com/apps?new_app=1> → **From a manifest** → paste
`slack-app-manifest.yaml`.
- _OAuth & Permissions_ → **Install to Workspace** → copy the `xoxb-`
bot token (`SLACK_BOT_TOKEN`).
- _Basic Information → App-Level Tokens_ → generate one with
`connections:write` → copy the `xapp-` app token (`SLACK_APP_TOKEN`).
- The manifest is tuned for mention-only channel threads. If you enable
`respondTo.threadReplies: "afterBotReply"`, also subscribe to
`message.channels` and `message.groups` so Slack delivers plain thread
replies.
### 1b. Discord app (set `DISCORD_*` to enable Discord)
- <https://discord.com/developers/applications> → **New Application**.
- **Bot** → copy the token (`DISCORD_BOT_TOKEN`); under **Privileged Gateway
Intents** enable **both** **Message Content** and **Server Members** — both
are required or the Gateway login is rejected.
- **General Information** → copy the **Application ID** (`DISCORD_APP_ID`).
- **OAuth2 → URL Generator** → scopes `bot` + `applications.commands`,
permissions Send Messages / Read Message History / Use Slash Commands /
Embed Links → open the URL to add it to your server. Optionally set
`DISCORD_GUILD_ID` (your server id) so slash commands register instantly
during dev.
### 1c. Telegram bot (set `TELEGRAM_BOT_TOKEN` to enable Telegram)
- In Telegram, message **@BotFather** → `/newbot` → follow the prompts (name +
a username ending in `bot`) → copy the HTTP API token (`TELEGRAM_BOT_TOKEN`).
- Long-polling is the default ingress — no public URL or webhook needed.
- The bot auto-registers its slash commands (`/agent`, `/triage`, `/preview`,
`/file-issue` — all four passed to `createBot`) via `setMyCommands` on start
(no manual BotFather `/setcommands` step). For group use, `/setprivacy`
**Disable** if you want it to see non-mention messages.
### 2. Credentials
```bash
cp .env.example .env
# Fill in (set SLACK_*, DISCORD_*, and/or TELEGRAM_BOT_TOKEN — whichever you want):
# SLACK_BOT_TOKEN / SLACK_APP_TOKEN (to run on Slack)
# DISCORD_BOT_TOKEN / DISCORD_APP_ID (to run on Discord; DISCORD_GUILD_ID optional)
# TELEGRAM_BOT_TOKEN (to run on Telegram)
# OPENAI_API_KEY (or ANTHROPIC_API_KEY / GOOGLE_API_KEY + AGENT_MODEL)
# LINEAR_API_KEY (linear.app → Settings → API → Personal API keys)
# NOTION_TOKEN (notion.so → Settings → Connections → integrations)
# NOTION_MCP_AUTH_TOKEN (any strong string; shared between the sidecar and the agent)
```
Linear and Notion are independent — set only the ones you want; the agent
wires up whichever credentials are present.
### 3. Notion MCP sidecar (only if using Notion)
The agent talks to Notion through the official MCP server, run locally as
a Streamable-HTTP sidecar:
```bash
pnpm install # from the repo root
pnpm --filter slack-example notion-mcp # serves http://127.0.0.1:3001/mcp
```
Linear needs no sidecar — its hosted MCP accepts the API key directly.
### 4. Agent
```bash
pnpm --filter slack-example runtime # CopilotKit runtime on :8200, agent "triage"
```
Exposes `http://localhost:8200/api/copilotkit/agent/triage/run` — the
default `AGENT_URL`.
### 5. Bot
```bash
pnpm --filter slack-example dev # tsx watch app/index.ts
```
### 6. Try it
@mention the bot in a channel (Slack/Discord) or DM it / @mention it in a
group (Telegram). In Slack channel threads, mention Kite again for each
follow-up unless you enabled legacy thread continuation:
> @CopilotKit Triage what are the open CPK issues this cycle?
> @CopilotKit Triage file this thread as a bug in CPK
> @CopilotKit Triage find the runbook for our last auth outage
> @CopilotKit Triage write this thread up as a Notion postmortem
## Per-user identity
The `onMention` handler forwards the **requesting user** (resolved to name +
email where the platform exposes it) to the agent each turn via
`senderContext(message.user)`, so the bot acts on behalf of whoever's asking:
"my issues" is scoped to you, and issues it files are assigned to you. On Slack
this needs the `users:read.email` scope (already in the manifest — reinstall
the app once after adding it).
Caveat: a single API key can't forge Linear's `creator`, so created issues
are _authored_ by the bot and _assigned_ to the requester. True per-user
attribution (and reliable Notion personalization) needs per-user OAuth.
## Files → charts, diagrams & tables
Upload a file and the bot analyzes it: images and **PDFs** go straight to the
model, and CSV/JSON/text are decoded and handed over as text. The adapter is
transport-only — it downloads the upload and delivers it to the agent as
multimodal content; the **app** (the `render_*` tools above) decides what to
do.
> **PDFs and images need a vision/document-capable model.** The default
> `openai/gpt-5.5` reads both natively through this path, as do recent Claude
> (`anthropic/claude-sonnet-4-6`) and Gemini (`google/gemini-2.5-*`) models.
> An older text-only model will ignore the attached document.
Try it: drop a CSV and say _"chart revenue by month"_, _"diagram this incident
flow"_, or _"show the incidents as a table"_. The chart/diagram renderers need
a Chromium binary:
```bash
npx playwright install chromium
```
Notes: the chart/diagram libraries load from a CDN into the local browser
(override `CHART_JS_URL` / `MERMAID_URL`); your data is rendered locally and
never sent to a rendering service.
## Deploying
There's nothing local-only here: the bot and the runtime are plain Node
processes, and every connection is env-driven. Deploy the runtime and bot,
set the same env vars, and (for Notion) run the
`@notionhq/notion-mcp-server` sidecar alongside the runtime with
`NOTION_MCP_URL` pointed at it.
### Deploy as a workspace member (built from source)
This example consumes the `@copilotkit/*` packages via the **`workspace:*`**
protocol, so it always builds from the in-repo source — **not** the npm
registry. That decouples the deploy from publishing: a change to
`packages/**` redeploys with the new code immediately, and `npm publish` is an
independent, manual step (no "release first, then bump the example" dance).
Because it's a workspace member, the deploy must run from the **repo root** so
the workspace and `packages/**` are visible. On Railway (or any host), set:
| Setting | Value |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| **Root Directory** | repo root (`/`) |
| **Build Command** | `pnpm install && pnpm --filter slack-example build` |
| **Start Command** | `pnpm --filter slack-example start` (bot) — a second service runs the runtime: `pnpm --filter slack-example run runtime` |
| **Watch Paths** | `packages/**`, `examples/slack/**`, `pnpm-lock.yaml`, `package.json` |
`pnpm --filter slack-example build` builds the workspace libs the example
imports (`@copilotkit/channels-slack` / `-discord` / `-telegram` / `runtime`) and
everything they depend on, via the Nx project graph — so `tsx` runs against
fresh `dist`. The **Watch Paths** are what makes a `packages/**`-only change
trigger a redeploy (the example's own files no longer need to change to provoke
one).
> **Copying this example out of the monorepo?** Replace the `workspace:*`
> ranges in `package.json` with the published versions (e.g.
> `@copilotkit/channels-slack: ^0.0.3`) — `workspace:*` only resolves inside this
> monorepo.
### WhatsApp (inbound webhook, needs a public domain)
Slack and Discord are outbound (Socket Mode / gateway) and need no public
ingress. WhatsApp is different: it adds an inbound webhook HTTP server on
`$PORT`, so the bot service needs a public URL. To enable it on the deployed
bot service (Railway):
1. Generate a public domain on the **bot** service (Settings → Networking).
Railway routes it to `$PORT`, which the WhatsApp adapter listens on.
2. Set `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_PHONE_NUMBER_ID`, `WHATSAPP_APP_SECRET`,
`WHATSAPP_VERIFY_TOKEN` on the bot service (use a System User token — the
temporary one expires in 24h). The `runtime` service is unchanged.
3. In the Meta app → WhatsApp → Configuration: Callback URL
`https://<bot-domain>/webhook`, Verify Token = `WHATSAPP_VERIFY_TOKEN`,
subscribe to the `messages` field.
Health check: `GET https://<bot-domain>/` returns `ok`. Chart/diagram tools use
the same headless browser the Slack/Discord paths already run; their PNGs go
out as WhatsApp images via the media upload.
## Feature demos
Two runnable demos extend the on-call triage bot to narrate per-platform degradation explicitly.
### 1. Ephemeral — `/preview <title>`
```
/preview Login button throws 500 on submit
```
Posts a private draft issue card visible only to you — a "here's what I'd file, only you see this" preview — before anything is written to Linear or posted publicly. Run `/file-issue` afterwards to actually file it.
**Source:** `app/commands/index.ts` (`preview` command) using `thread.postEphemeral(user, draft, { fallbackToDM: true })`.
> **Slack setup:** `/preview` must be declared under **Slash Commands** in your Slack app manifest (already present in `slack-app-manifest.yaml`). Slack won't deliver an undeclared command even over Socket Mode.
### 2. Modals — `/file-issue`
```
/file-issue
```
Opens a structured Linear issue form. On Slack you get the full form (title, description text inputs, priority dropdown, type radio). On Discord the form is text-only. On Telegram there is no modal surface, so the bot narrates that and continues conversationally.
On submission (`bot.onModalSubmit("file_issue", …)` in `app/index.ts`), the bot validates the inputs and files the issue via the agent (Linear MCP) with the usual `confirm_write` gate, then shows the filed card.
**Source:** `app/modals/file-issue.tsx` (`FileIssueModal`, `issueFromValues`), `app/commands/index.ts` (`file-issue` command).
> **Slack setup:** `/file-issue` must be declared under **Slash Commands** in your Slack app manifest (already present in `slack-app-manifest.yaml`).
### Per-platform behavior
| Demo | Slack | Discord | Telegram |
| ---------------------- | ----------------------------- | ----------------------------------------------- | ------------------------------------- |
| Ephemeral (`/preview`) | native only-you message | DM fallback | DM fallback |
| Modal (`/file-issue`) | rich form (dropdowns + radio) | text-only (≤5 inputs; type/priority default in) | unsupported → conversational fallback |
The degradation is always narrated, never silent: `/preview` reports whether it used the DM path; `/file-issue` says "modals aren't supported here" on Telegram and continues in chat.
## Tests
```bash
pnpm --filter slack-example test # unit tests (read_thread, render tools, components, confirm_write, modals, commands)
```
> **Note:** the live-Slack e2e harness (`pnpm e2e` / `pnpm e2e:restart`) is
> being migrated to the new `createBot` API — it still targets the old bridge
> and the obsolete button-value resume path, so it does not run against this
> example as-is. The Telegram harness (`pnpm e2e:telegram`) is a working
> manual-trigger smoke test — see [`e2e/TELEGRAM-README.md`](e2e/TELEGRAM-README.md).
@@ -0,0 +1,227 @@
import { describe, it, expect, vi } from "vitest";
import { renderToIR } from "@copilotkit/channels-ui";
import type { BotNode } from "@copilotkit/channels-ui";
import { appCommands } from "../index.js";
import type { CommandContext } from "@copilotkit/channels";
function tags(node: BotNode | unknown, acc: string[] = []): string[] {
if (!node || typeof node !== "object") return acc;
const n = node as BotNode;
if (typeof n.type === "string") acc.push(n.type);
for (const c of (n.props?.children as BotNode[] | undefined) ?? []) {
tags(c, acc);
}
return acc;
}
const byName = (name: string) => {
const cmd = appCommands.find((c) => c.name === name);
if (!cmd) throw new Error(`command ${name} not registered`);
return cmd;
};
/** A minimal fake thread capturing runAgent input and posted text. */
function fakeThread() {
return {
runAgent: vi.fn(
async (_input?: { prompt?: string; context?: unknown }) => undefined,
),
post: vi.fn(async (_ui?: unknown) => ({ id: "m1" })),
};
}
const ctx = (over: Partial<CommandContext>): CommandContext =>
({
thread: fakeThread() as never,
command: "x",
text: "",
options: {},
platform: "slack",
...over,
}) as CommandContext;
describe("example slash commands", () => {
it("registers /agent, /file-issue, /preview and /triage", () => {
expect(appCommands.map((c) => c.name).sort()).toEqual([
"agent",
"file-issue",
"preview",
"triage",
]);
});
it("/agent runs the agent with the command text as the prompt", async () => {
const thread = fakeThread();
await byName("agent").handler(
ctx({
command: "agent",
text: "why is prod down",
thread: thread as never,
}),
);
expect(thread.runAgent).toHaveBeenCalledTimes(1);
expect(thread.runAgent.mock.calls[0]![0]).toMatchObject({
prompt: "why is prod down",
});
});
it("/agent with no text posts usage and does not run the agent", async () => {
const thread = fakeThread();
await byName("agent").handler(
ctx({ command: "agent", text: "", thread: thread as never }),
);
expect(thread.runAgent).not.toHaveBeenCalled();
expect(thread.post).toHaveBeenCalledTimes(1);
});
it("/triage runs the agent with a triage prompt", async () => {
const thread = fakeThread();
await byName("triage").handler(
ctx({ command: "triage", text: "", thread: thread as never }),
);
expect(thread.runAgent).toHaveBeenCalledTimes(1);
expect(String(thread.runAgent.mock.calls[0]![0]?.prompt)).toMatch(
/triage/i,
);
});
it("/preview posts an ephemeral draft and reports the native path", async () => {
const preview = appCommands.find((c) => c.name === "preview")!;
expect(preview).toBeDefined();
const postEphemeral = vi
.fn()
.mockResolvedValue({ ok: true, usedFallback: false });
const post = vi.fn().mockResolvedValue({ id: "1" });
await preview.handler({
thread: { postEphemeral, post } as never,
command: "preview",
text: "Login button is broken",
options: {},
user: { id: "U1", name: "Ada" },
platform: "slack",
} as never);
expect(postEphemeral).toHaveBeenCalledTimes(1);
const [user, , opts] = postEphemeral.mock.calls[0]!;
expect(user).toEqual({ id: "U1", name: "Ada" });
expect(opts).toEqual({ fallbackToDM: true });
});
it("/preview asks for a title when none is given", async () => {
const preview = appCommands.find((c) => c.name === "preview")!;
const postEphemeral = vi.fn();
const post = vi.fn().mockResolvedValue({ id: "1" });
await preview.handler({
thread: { postEphemeral, post } as never,
command: "preview",
text: "",
options: {},
user: { id: "U1" },
platform: "slack",
} as never);
expect(post).toHaveBeenCalledWith(expect.stringContaining("Usage"));
expect(postEphemeral).not.toHaveBeenCalled();
});
it("/file-issue opens the rich modal on Slack", async () => {
const cmd = appCommands.find((c) => c.name === "file-issue")!;
expect(cmd).toBeDefined();
const openModal = vi.fn().mockResolvedValue({ ok: true });
await cmd.handler({
thread: { post: vi.fn() } as never,
command: "file-issue",
text: "",
options: {},
user: { id: "U1" },
platform: "slack",
openModal,
} as never);
expect(openModal).toHaveBeenCalledTimes(1);
// Verify the modal passed to openModal is the RICH variant (Slack path).
const capturedView = openModal.mock.calls[0]![0];
const ir = renderToIR(capturedView);
const t = tags(ir[0]);
expect(t).toContain("modal_select");
expect(t).toContain("modal_radio");
});
it("/file-issue falls back to conversation where modals are unsupported", async () => {
const cmd = appCommands.find((c) => c.name === "file-issue")!;
const post = vi.fn().mockResolvedValue({ id: "1" });
const runAgent = vi.fn().mockResolvedValue(undefined);
await cmd.handler({
thread: { post, runAgent } as never,
command: "file-issue",
text: "",
options: {},
user: { id: "U1" },
platform: "telegram",
openModal: undefined, // Telegram: no modal trigger
} as never);
expect(post).toHaveBeenCalledWith(
expect.stringMatching(/aren.t supported|chat/i),
);
expect(runAgent).toHaveBeenCalledTimes(1);
});
it("/file-issue posts an error message when openModal resolves { ok: false }", async () => {
const cmd = byName("file-issue");
const post = vi.fn().mockResolvedValue({ id: "1" });
const openModal = vi
.fn()
.mockResolvedValue({ ok: false, error: "channel_not_found" });
await cmd.handler({
thread: { post } as never,
command: "file-issue",
text: "",
options: {},
user: { id: "U1" },
platform: "slack",
openModal,
} as never);
expect(openModal).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledWith(
expect.stringMatching(/couldn.t open the form|channel_not_found/i),
);
});
it("/preview posts 📬 DM notice when postEphemeral used the fallback path", async () => {
const preview = byName("preview");
const postEphemeral = vi
.fn()
.mockResolvedValue({ ok: true, usedFallback: true });
const post = vi.fn().mockResolvedValue({ id: "1" });
await preview.handler({
thread: { postEphemeral, post } as never,
command: "preview",
text: "Login broken",
options: {},
user: { id: "U1", name: "Ada" },
platform: "discord",
} as never);
expect(postEphemeral).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledWith(
expect.stringMatching(/📬|direct message/i),
);
});
it("/preview posts a failure note when postEphemeral returns null", async () => {
const preview = byName("preview");
const postEphemeral = vi.fn().mockResolvedValue(null);
const post = vi.fn().mockResolvedValue({ id: "1" });
await preview.handler({
thread: { postEphemeral, post } as never,
command: "preview",
text: "Login broken",
options: {},
user: { id: "U1", name: "Ada" },
platform: "discord",
} as never);
expect(postEphemeral).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledWith(
expect.stringMatching(/couldn.t send a private preview|file-issue/i),
);
});
});
+135
View File
@@ -0,0 +1,135 @@
/**
* Slash commands for this bot. Each is registered with the engine via
* `createBot({ 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.
*/
import { defineBotCommand } from "@copilotkit/channels";
import type { BotCommand } from "@copilotkit/channels";
import { senderContext } from "../sender-context.js";
import { IssueCard } from "../components/index.js";
import { FileIssueModal } from "../modals/file-issue.js";
export const appCommands: BotCommand[] = [
// `/agent <text>` — 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({
name: "agent",
description: "Ask the triage agent anything (no @mention needed).",
async handler({ thread, text, user }) {
if (!text) {
await thread.post("Usage: `/agent <your question>`");
return;
}
await thread.runAgent({
prompt: text,
context: senderContext(user, thread.platform),
});
},
}),
// `/triage [note]` — summarize the current channel/thread and propose Linear
// issues to file. Demonstrates a command with its own intent.
defineBotCommand({
name: "triage",
description:
"Summarize the conversation and propose Linear issues to file.",
async handler({ thread, text, user }) {
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 thread.runAgent({
prompt,
context: senderContext(user, thread.platform),
});
},
}),
// `/preview <title>` — 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({
name: "preview",
description: "Privately preview the issue I'd file (only you see it).",
async handler({ thread, text, user, platform }) {
if (!text) {
await thread.post("Usage: `/preview <issue title>`");
return;
}
if (!user) {
await thread.post(
"I couldn't tell who you are, so I can't send a private preview here.",
);
return;
}
const draft = IssueCard({
identifier: "DRAFT",
title: text,
state: "Triage",
description: "_Draft — nothing is filed until you run_ `/file-issue`.",
});
const res = await thread.postEphemeral(user, draft, {
fallbackToDM: true,
});
// Degrade, never throw: report what actually happened.
if (!res || !res.ok) {
await thread.post(
`I couldn't send a private preview on ${platform}. Run \`/file-issue\` to file it.`,
);
return;
}
if (res.usedFallback) {
await thread.post(
"📬 I sent you the draft as a direct message (this surface has no private messages).",
);
}
},
}),
// `/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({
name: "file-issue",
description: "Open a form to file a Linear issue.",
async handler({ thread, openModal, platform, user }) {
if (!openModal) {
await thread.post(
"Modals aren't supported here — let's do it in chat instead. " +
"Tell me the issue title and a short description and I'll file it.",
);
await thread.runAgent({
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),
});
return;
}
const res = await openModal(
FileIssueModal({ rich: platform === "slack" }),
);
if (!res.ok) {
await thread.post(
`I couldn't open the form${res.error ? `: ${res.error}` : ""}.`,
);
}
},
}),
];
@@ -0,0 +1,366 @@
/**
* Block Kit parity tests for the JSX render components. Each component is a
* `@copilotkit/channels-ui` `ComponentFn`; we assert the full
* `renderSlackMessage(renderToIR(<… />))` output — both the `blocks` and the
* attachment `accent` — against the legacy `defineSlackComponent` shapes.
*
* The shared IR→mrkdwn path runs section/field/context text through
* `markdownToMrkdwn`, so the components author Markdown bold (`**x**`) which
* the transform rewrites into Slack bold (`*x*`). The block structure,
* ordering, emoji, dividers, footers and accent colors match the legacy
* `.ts` output, and the link/label forms below assert the Slack-bold `*…*`
* the old `defineSlackComponent` code produced.
*
* Status/priority glyphs are now platform-neutral unicode (✅ 🔵 🚨 🔴 etc.)
* so they render identically on both Slack and Telegram.
*/
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 { IssueList } from "../issue-list.js";
import { IssueCard } from "../issue-card.js";
import { PageList } from "../page-list.js";
describe("IssueList component", () => {
it("renders exactly three blocks: header, a single section with one line per issue, and a count footer", () => {
const { blocks, accent } = renderSlackMessage(
renderToIR(
<IssueList
heading="Open"
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",
},
]}
/>,
),
);
// Fixed three-block layout regardless of issue count.
expect(blocks).toHaveLength(3);
expect(blocks[0]).toMatchObject({
type: "header",
text: { type: "plain_text", text: "📋 Open" },
});
expect(blocks[1]).toMatchObject({ type: "section" });
expect(blocks[2]).toMatchObject({ type: "context" });
const section = blocks[1] as { text: { type: string; text: string } };
expect(section.text.type).toBe("mrkdwn");
const text = section.text.text;
// One line per issue, joined by newlines.
expect(text.split("\n")).toHaveLength(2);
// Each issue is a linked, bold identifier (Markdown bold → Slack bold).
expect(text).toContain(
"<https://linear.app/copilotkit/issue/CPK-101|*CPK-101*>",
);
expect(text).toContain(
"<https://linear.app/copilotkit/issue/CPK-102|*CPK-102*>",
);
// Titles, assignees and updated meta are inline on the line.
expect(text).toContain("Checkout 500s under load");
expect(text).toContain("Login redirect loop");
expect(text).toContain("Alem");
expect(text).toContain("Sam");
expect(text).toContain("2d ago");
// In-progress maps to the blue dot.
expect(text).toContain("🔵");
// Count footer.
expect(JSON.stringify(blocks[2])).toContain("2 issues");
// Hottest priority (Urgent) drives the accent.
expect(accent).toBe("#EB5757");
});
it("caps the section at 15 lines and reports the overflow in the footer", () => {
const issues = Array.from({ length: 20 }, (_, i) => ({
identifier: `CPK-${i + 1}`,
title: `Issue ${i + 1}`,
}));
const { blocks } = renderSlackMessage(
renderToIR(<IssueList heading="Many" issues={issues} />),
);
expect(blocks).toHaveLength(3);
const section = blocks[1] as { text: { text: string } };
// Only the first 15 issues are rendered.
expect(section.text.text.split("\n")).toHaveLength(15);
expect(section.text.text).toContain("*CPK-1*");
expect(section.text.text).toContain("*CPK-15*");
expect(section.text.text).not.toContain("*CPK-16*");
// Footer surfaces the overflow.
expect(JSON.stringify(blocks[2])).toContain("Showing 15 of 20 issues");
});
it("falls back to an emphasized identifier and 'unassigned' when fields are missing", () => {
const { blocks, accent } = renderSlackMessage(
renderToIR(
<IssueList issues={[{ identifier: "CPK-9", title: "No assignee" }]} />,
),
);
const json = JSON.stringify(blocks);
// No url → bold identifier, no link wrapper.
expect(json).toContain("*CPK-9*");
expect(json).not.toContain("|*CPK-9*>");
expect(json).toContain("unassigned");
// No urgent/high priority → Linear purple.
expect(accent).toBe("#5E6AD2");
});
});
describe("IssueCard component", () => {
it("renders a status header, linked title and a fields grid", () => {
const { blocks, accent } = renderSlackMessage(
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"
/>,
),
);
const json = JSON.stringify(blocks);
// Header: in-progress unicode dot + identifier (plain_text, untouched).
expect(blocks[0]).toMatchObject({
type: "header",
text: { type: "plain_text", text: "🔵 CPK-101" },
});
// Title section with the linked, bold title.
expect(json).toContain(
"<https://linear.app/copilotkit/issue/CPK-101|*Checkout 500s under load*>",
);
// A section carries the 2-column metadata grid.
const fieldsSection = blocks.find(
(b) => b.type === "section" && "fields" in b && Array.isArray(b.fields),
) as { fields: { text: string }[] } | undefined;
expect(fieldsSection).toBeDefined();
expect(fieldsSection?.fields).toHaveLength(4);
expect(json).toContain("*Assignee*\\nAlem");
expect(json).toContain("*Priority*\\n🚨 Urgent");
expect(json).toContain("*Status*\\n🔵 In Progress");
expect(json).toContain("*Team*\\nCPK");
// Footer: "Open in Linear" link.
expect(json).toContain(
"<https://linear.app/copilotkit/issue/CPK-101|Open in Linear →>",
);
// Urgent priority drives the accent.
expect(accent).toBe("#EB5757");
});
it("shows a 'Filed' banner and a check header when justCreated", () => {
const { blocks, accent } = renderSlackMessage(
renderToIR(
<IssueCard identifier="CPK-200" title="New bug" justCreated />,
),
);
const json = JSON.stringify(blocks);
expect(blocks[0]).toMatchObject({
type: "header",
text: { type: "plain_text", text: "✅ CPK-200" },
});
expect(json).toContain("✨ Filed in Linear");
// The Filed banner sits before the fields grid.
const bannerIdx = blocks.findIndex(
(b) =>
b.type === "context" && JSON.stringify(b).includes("Filed in Linear"),
);
const fieldsIdx = blocks.findIndex(
(b) => b.type === "section" && "fields" in b,
);
expect(bannerIdx).toBeGreaterThan(-1);
expect(bannerIdx).toBeLessThan(fieldsIdx);
// unassigned fallback + Status placeholder grid still render.
expect(json).toContain("_unassigned_");
// No priority/state → Linear purple.
expect(accent).toBe("#5E6AD2");
});
it("appends a divider + trimmed description when present", () => {
const long = "x".repeat(700);
const { blocks } = renderSlackMessage(
renderToIR(
<IssueCard identifier="CPK-300" title="Big" description={long} />,
),
);
expect(blocks.filter((b) => b.type === "divider")).toHaveLength(1);
const descSection = blocks[blocks.length - 1] as {
text?: { text: string };
};
// Description is trimmed to 600 chars + an ellipsis.
expect(descSection.text?.text).toBe(`${"x".repeat(600)}`);
});
});
describe("PageList component", () => {
it("renders linked titles, snippets and a count footer", () => {
const { blocks, accent } = renderSlackMessage(
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",
},
{ title: "No-link page" },
]}
/>,
),
);
const json = JSON.stringify(blocks);
expect(blocks[0]).toMatchObject({
type: "header",
text: { type: "plain_text", text: "📚 Runbooks" },
});
expect(json).toContain("<https://www.notion.so/abc|*Auth outage runbook*>");
expect(json).toContain("Steps to mitigate auth provider downtime.");
expect(json).toContain("🕒 edited 3d ago");
// A page without a url renders as bold text rather than a link.
expect(json).toContain("*No-link page*");
expect(json).not.toContain("|*No-link page*>");
expect(json).toContain("2 pages");
// Exactly one divider between the two pages.
expect(blocks.filter((b) => b.type === "divider")).toHaveLength(1);
// Notion-dark accent.
expect(accent).toBe("#2F3437");
});
});
// ── 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(
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(
renderToIR(
<IssueList
heading="Open"
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(
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.");
});
});
+65
View File
@@ -0,0 +1,65 @@
/**
* 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.
*/
/** Unicode glyph for a Linear workflow-state name. */
export function stateGlyph(state?: string): string {
const s = (state ?? "").toLowerCase();
if (s.includes("done") || s.includes("complete")) return "✅";
if (s.includes("progress") || s.includes("started")) return "🔵";
if (s.includes("review")) return "🟣";
if (s.includes("cancel")) return "🚫";
if (s.includes("backlog")) return "⚪";
return "🟠";
}
/** Unicode glyph for a Linear priority label, or "" for none/unknown. */
export function priorityGlyph(priority?: string): string {
const p = (priority ?? "").toLowerCase();
if (p.includes("urgent")) return "🚨";
if (p.includes("high")) return "🔴";
if (p.includes("medium")) return "🟠";
if (p.includes("low")) return "⚪";
return "";
}
/** Brand + semantic accent colors for the attachment left-border. */
export const ACCENT = {
linear: "#5E6AD2",
notion: "#2F3437",
urgent: "#EB5757",
high: "#F2994A",
done: "#27AE60",
progress: "#2D9CDB",
canceled: "#9B9B9B",
} as const;
/**
* Accent color for a single issue: priority wins (urgent/high), then state
* (done/in-progress/canceled), falling back to Linear purple.
*/
export function accentForIssue(issue: {
state?: string;
priority?: string;
}): string {
const p = (issue.priority ?? "").toLowerCase();
if (p.includes("urgent")) return ACCENT.urgent;
if (p.includes("high")) return ACCENT.high;
const s = (issue.state ?? "").toLowerCase();
if (s.includes("done") || s.includes("complete")) return ACCENT.done;
if (s.includes("cancel")) return ACCENT.canceled;
if (s.includes("progress") || s.includes("started")) return ACCENT.progress;
return ACCENT.linear;
}
/** Accent for a list: surface the hottest priority present, else Linear purple. */
export function accentForIssues(
issues: ReadonlyArray<{ priority?: string }>,
): string {
const prios = issues.map((i) => (i.priority ?? "").toLowerCase());
if (prios.some((p) => p.includes("urgent"))) return ACCENT.urgent;
if (prios.some((p) => p.includes("high"))) return ACCENT.high;
return ACCENT.linear;
}
+16
View File
@@ -0,0 +1,16 @@
/**
* App-specific render components — agent-renderable Block Kit cards authored
* with the `@copilotkit/channels-ui` 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} />))`.
*/
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";
@@ -0,0 +1,96 @@
/**
* `issue_card` — a rich single-issue card: a header with the status + id,
* the title as a link, a two-column metadata grid (status / assignee /
* priority / team / cycle / updated), an optional description, and an
* optional labels + "Open in Linear" footer.
*
* 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
* shapes are produced by `renderSlackMessage(renderToIR(<IssueCard .../>))`.
*/
import { z } from "zod";
import {
Context,
Divider,
Fields,
Field,
Header,
Message,
Section,
} from "@copilotkit/channels-ui";
import type { BotNode } from "@copilotkit/channels-ui";
import { accentForIssue, priorityGlyph, stateGlyph } from "./_status.js";
export const issueCardSchema = z.object({
identifier: z.string().describe("Issue identifier, e.g. 'CPK-1234'."),
title: z.string().describe("Issue title."),
url: z.string().optional().describe("Link to the issue in Linear."),
state: z.string().optional().describe("Workflow state name."),
assignee: z.string().optional().describe("Assignee display name."),
priority: z.string().optional().describe("Priority label."),
team: z.string().optional().describe("Team key/name, e.g. 'CPK'."),
cycle: z.string().optional().describe("Cycle name/number."),
updated: z.string().optional().describe("Human-readable last-updated."),
description: z
.string()
.optional()
.describe(
"Issue description (markdown). Kept short; long text is trimmed.",
),
labels: z.array(z.string()).optional().describe("Label names."),
justCreated: z
.boolean()
.optional()
.describe(
"Set true right after creating the issue to show a 'Filed' banner.",
),
});
export type IssueCardProps = z.infer<typeof issueCardSchema>;
/** Render ONE Linear issue as a rich Block Kit card. */
export function IssueCard(issue: IssueCardProps): BotNode {
const titleText = issue.url
? `[**${issue.title}**](${issue.url})`
: `**${issue.title}**`;
const prio = priorityGlyph(issue.priority);
const description = issue.description
? issue.description.length > 600
? `${issue.description.slice(0, 600)}`
: issue.description
: undefined;
const footer: string[] = [];
if (issue.labels?.length) footer.push(`🏷️ ${issue.labels.join(" ")}`);
if (issue.url) footer.push(`[Open in Linear →](${issue.url})`);
const footerText = footer.length ? footer.join(" · ") : undefined;
return (
<Message accent={accentForIssue(issue)}>
<Header>
{`${issue.justCreated ? "✅ " : `${stateGlyph(issue.state)} `}${issue.identifier}`}
</Header>
<Section>{titleText}</Section>
{issue.justCreated ? <Context>{"✨ Filed in Linear"}</Context> : null}
<Fields>
<Field>{`**Status**\n${stateGlyph(issue.state)} ${issue.state ?? "—"}`}</Field>
<Field>{`**Assignee**\n${issue.assignee ?? "_unassigned_"}`}</Field>
{issue.priority ? (
<Field>{`**Priority**\n${prio ? `${prio} ` : ""}${issue.priority}`}</Field>
) : null}
{issue.team ? <Field>{`**Team**\n${issue.team}`}</Field> : null}
{issue.cycle ? <Field>{`**Cycle**\n${issue.cycle}`}</Field> : null}
{issue.updated ? (
<Field>{`**Updated**\n${issue.updated}`}</Field>
) : null}
</Fields>
{description ? <Divider /> : null}
{description ? <Section>{description}</Section> : null}
{footerText ? <Context>{footerText}</Context> : null}
</Message>
);
}
@@ -0,0 +1,87 @@
/**
* `issue_list` — renders a set of Linear issues as a compact Block Kit card:
* a header, ONE section with one scannable line per issue (status dot + linked
* identifier + title + assignee · updated), and a count footer.
*
* This is deliberately a fixed THREE-block layout (header + section + context)
* regardless of issue count: a card-per-issue layout (~3 blocks each) blows
* past Slack's per-attachment block limit on long lists and gets rejected with
* `invalid_attachments`. We instead inline up to `MAX` issues into a single
* section and surface the overflow in the footer.
*
* The agent fetches issues from the Linear MCP server and passes the fields
* 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.
*/
import { z } from "zod";
import { Context, Header, Message, Section } from "@copilotkit/channels-ui";
import type { BotNode } from "@copilotkit/channels-ui";
import { accentForIssues, stateGlyph } from "./_status.js";
const issueSchema = z.object({
identifier: z.string().describe("Linear issue identifier, e.g. 'CPK-1234'."),
title: z.string().describe("Issue title."),
url: z.string().optional().describe("Link to the issue in Linear."),
state: z
.string()
.optional()
.describe("Workflow state name, e.g. 'Todo', 'In Progress', 'Done'."),
assignee: z
.string()
.optional()
.describe("Assignee display name, or omit if unassigned."),
priority: z
.string()
.optional()
.describe("Priority label, e.g. 'Urgent', 'High', 'Medium', 'Low'."),
updated: z
.string()
.optional()
.describe("Human-readable last-updated, e.g. '2d ago'."),
});
export const issueListSchema = z.object({
heading: z
.string()
.optional()
.describe("Optional heading, e.g. 'Open CPK issues this cycle'."),
issues: z.array(issueSchema).min(1).describe("The issues to render."),
});
export type IssueListProps = z.infer<typeof issueListSchema>;
type Issue = z.infer<typeof issueSchema>;
/** Max issues rendered inline; the rest are summarized in the footer. */
const MAX = 15;
/** Max title length before trimming (keeps each line scannable). */
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 {
const lines = issues.slice(0, MAX).map((issue: Issue) => {
const idLink = issue.url
? `[**${issue.identifier}**](${issue.url})`
: `**${issue.identifier}**`;
const title =
issue.title.length > TITLE_MAX
? `${issue.title.slice(0, TITLE_MAX)}`
: issue.title;
const meta = `${issue.assignee ?? "unassigned"}${issue.updated ? ` · ${issue.updated}` : ""}`;
return `${stateGlyph(issue.state)} ${idLink} ${title}${meta}`;
});
const footer =
issues.length > MAX
? `Showing ${MAX} of ${issues.length} issues`
: `${issues.length} issue${issues.length === 1 ? "" : "s"}`;
return (
<Message accent={accentForIssues(issues)}>
<Header>{`📋 ${heading ?? "Linear issues"}`}</Header>
<Section>{lines.join("\n")}</Section>
<Context>{footer}</Context>
</Message>
);
}
@@ -0,0 +1,76 @@
/**
* `page_list` — renders Notion page search results as a Block Kit card:
* a header, then one row per page (📄 linked title + a greyed snippet and
* optional last-edited), with dividers and a count footer.
*
* 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.
*/
import { z } from "zod";
import {
Context,
Divider,
Header,
Message,
Section,
} from "@copilotkit/channels-ui";
import type { BotNode } from "@copilotkit/channels-ui";
import { ACCENT } from "./_status.js";
const pageSchema = z.object({
title: z.string().describe("Page title."),
url: z.string().optional().describe("Link to the page in Notion."),
snippet: z
.string()
.optional()
.describe("A short excerpt or summary of the page."),
editedBy: z.string().optional().describe("Who last edited it, if known."),
edited: z
.string()
.optional()
.describe("Human-readable last-edited, e.g. '3d ago'."),
});
export const pageListSchema = z.object({
heading: z
.string()
.optional()
.describe("Optional heading, e.g. 'Runbooks matching “auth outage”'."),
pages: z.array(pageSchema).min(1).describe("The pages to render."),
});
export type PageListProps = z.infer<typeof pageListSchema>;
type Page = z.infer<typeof pageSchema>;
/** Render a list of Notion pages as a Block Kit card. */
export function PageList({ heading, pages }: PageListProps): BotNode {
const rows: BotNode[] = [];
pages.forEach((page: Page, i: number) => {
const titleLink = page.url
? `[**${page.title}**](${page.url})`
: `**${page.title}**`;
const meta = [
page.snippet,
page.edited
? `🕒 edited ${page.edited}${page.editedBy ? ` by ${page.editedBy}` : ""}`
: null,
]
.filter(Boolean)
.join("\n");
rows.push(<Section>{`📄 ${titleLink}`}</Section>);
if (meta) rows.push(<Context>{meta}</Context>);
if (i < pages.length - 1) rows.push(<Divider />);
});
return (
<Message accent={ACCENT.notion}>
<Header>{`📚 ${heading ?? "Notion pages"}`}</Header>
{rows}
<Context>{`${pages.length} page${pages.length === 1 ? "" : "s"}`}</Context>
</Message>
);
}
+32
View File
@@ -0,0 +1,32 @@
/**
* 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.
*
* Each entry is `{description, value}`. The SDK forwards them as AG-UI
* `context` on every turn; the agent backend surfaces them as a
* system-level "App Context:" message.
*/
import type { ContextEntry } from "@copilotkit/channels";
export const appContext: ReadonlyArray<ContextEntry> = [
{
description: "Bot identity & tone",
value: [
"You are 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"),
},
{
description: "Triage policy",
value: [
"When asked to file an issue or write a postmortem from a thread, read the",
"thread first, draft a clear title and a short description, then confirm",
"with the user before writing. Tag the relevant people using the",
"platform's tagging procedure when you know who they are.",
].join("\n"),
},
];
@@ -0,0 +1,56 @@
import { describe, it, expect } 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.",
);
});
});
@@ -0,0 +1,175 @@
import { describe, it, expect, vi } from "vitest";
import {
renderToIR,
type BotNode,
type InteractionContext,
type ClickHandler,
} from "@copilotkit/channels-ui";
import { renderSlackMessage } from "@copilotkit/channels-slack";
import { ConfirmWrite } from "../confirm-write.js";
/** Children of an IR node as an array (empty if none). */
function childNodes(node: BotNode): BotNode[] {
const children = node.props?.children;
if (Array.isArray(children)) return children as BotNode[];
if (
children &&
typeof children === "object" &&
"type" in (children as object)
) {
return [children as BotNode];
}
return [];
}
/** Concatenate the text of all descendant `text` nodes (depth-first). */
function collectText(node: BotNode): 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 {
for (const n of nodes) {
if (n.type === type) return n;
const hit = findByType(childNodes(n), type);
if (hit) return hit;
}
return undefined;
}
/** All button nodes in the tree. */
function findButtons(nodes: BotNode[]): BotNode[] {
const out: BotNode[] = [];
for (const n of nodes) {
if (n.type === "button") out.push(n);
out.push(...findButtons(childNodes(n)));
}
return out;
}
function buttonByText(ir: BotNode[], text: string): BotNode {
const btn = findButtons(ir).find((b) => collectText(b) === text);
if (!btn) throw new Error(`button "${text}" not found`);
return btn;
}
describe("ConfirmWrite", () => {
it("renders the pending picker: amber accent, header, detail, lock context, Create/Cancel", () => {
const ir = renderToIR(
<ConfirmWrite
action="Create Linear issue"
detail="CPK-9: Checkout 500s under load"
/>,
);
const { blocks, accent } = renderSlackMessage(ir);
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");
const section = blocks.find((b) => b.type === "section") as
| { text: { text: string } }
| undefined;
expect(section?.text.text).toContain("CPK-9: Checkout 500s under load");
const context = blocks.find((b) => b.type === "context") as
| { elements: { text: string }[] }
| undefined;
expect(context?.elements[0]?.text).toContain(
"Nothing is written until you click",
);
// "Create" is authored as Markdown bold (`**Create**`) so the IR→mrkdwn
// transform renders it as Slack bold (`*Create*`), matching the old card.
expect(context?.elements[0]?.text).toContain("*Create*");
expect(context?.elements[0]?.text).not.toContain("_Create_");
const actions = blocks.find((b) => b.type === "actions") as
| { elements: { text: { text: string } }[] }
| undefined;
expect(actions?.elements.map((e) => e.text.text)).toEqual([
"Create",
"Cancel",
]);
});
it("omits the detail section when no detail is given", () => {
const ir = renderToIR(<ConfirmWrite action="Create Linear issue" />);
const { blocks } = renderSlackMessage(ir);
expect(blocks.some((b) => b.type === "section")).toBe(false);
});
it("approve onClick updates the picker in place to the resolved (green) state", 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.
expect(create.props.value).toEqual({ confirmed: true });
const update = vi.fn(async () => ({ id: "m1" }));
const ctx = {
thread: { update },
message: { ref: { id: "m1" } },
} as unknown as InteractionContext;
await (create.props.onClick as ClickHandler)(ctx);
expect(update).toHaveBeenCalledTimes(1);
const [ref, renderable] = update.mock.calls[0] as unknown as [
{ id: string },
Parameters<typeof renderToIR>[0],
];
expect(ref).toEqual({ id: "m1" });
const { blocks, accent } = renderSlackMessage(renderToIR(renderable));
expect(accent).toBe("#27AE60");
const header = blocks.find((b) => b.type === "header") as
| { text: { text: string } }
| undefined;
expect(header?.text.text).toContain("Create Linear issue");
const context = blocks.find((b) => b.type === "context") as
| { elements: { text: string }[] }
| undefined;
expect(context?.elements[0]?.text).toContain("Approved");
});
it("cancel onClick updates the picker in place to the declined (red) state", async () => {
const ir = renderToIR(
<ConfirmWrite action="Create Linear issue" detail="CPK-9: ..." />,
);
const cancel = buttonByText(ir, "Cancel");
expect(cancel.props.value).toEqual({ confirmed: false });
const update = vi.fn(async () => ({ id: "m1" }));
const ctx = {
thread: { update },
message: { ref: { id: "m1" } },
} as unknown as InteractionContext;
await (cancel.props.onClick as ClickHandler)(ctx);
expect(update).toHaveBeenCalledTimes(1);
const [ref, renderable] = update.mock.calls[0] as unknown as [
{ id: string },
Parameters<typeof renderToIR>[0],
];
expect(ref).toEqual({ id: "m1" });
const { blocks, accent } = renderSlackMessage(renderToIR(renderable));
expect(accent).toBe("#EB5757");
const header = blocks.find((b) => b.type === "header") as
| { text: { text: string } }
| undefined;
expect(header?.text.text).toContain("Create Linear issue");
const context = blocks.find((b) => b.type === "context") as
| { elements: { text: string }[] }
| undefined;
expect(context?.elements[0]?.text).toContain("Declined");
});
});
@@ -0,0 +1,46 @@
/**
* `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} />,
);
return choice?.confirmed
? "The user APPROVED the write — proceed."
: "The user DECLINED — do not write; acknowledge and stop.";
},
});
@@ -0,0 +1,75 @@
/**
* `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 }`.
*
* 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
* clicked, even minutes later (the "approve the action 20 minutes later"
* durability story).
*
* The Slack-side equivalent of React's `useHumanInTheLoop`, expressed as a
* plain JSX component over the cross-platform bot-ui vocabulary.
*/
import {
Message,
Header,
Section,
Context,
Actions,
Button,
} from "@copilotkit/channels-ui";
import type { InteractionContext } from "@copilotkit/channels-ui";
export 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;
}
export function ConfirmWrite({ action, detail }: ConfirmWriteProps) {
return (
<Message accent="#E2B340">
<Header>{`📝 ${action}?`}</Header>
{detail ? <Section>{detail}</Section> : null}
<Context>{"🔒 Nothing is written until you click **Create**."}</Context>
<Actions>
<Button
value={{ confirmed: true }}
style="primary"
onClick={async ({ thread, message }: InteractionContext) => {
await thread.update(
message.ref,
<Message accent="#27AE60">
<Header>{`${action}`}</Header>
<Context>{"✅ Approved — writing now."}</Context>
</Message>,
);
}}
>
Create
</Button>
<Button
value={{ confirmed: false }}
style="danger"
onClick={async ({ thread, message }: InteractionContext) => {
await thread.update(
message.ref,
<Message accent="#EB5757">
<Header>{`🚫 ${action}`}</Header>
<Context>{"🚫 Declined — nothing was written."}</Context>
</Message>,
);
}}
>
Cancel
</Button>
</Actions>
</Message>
);
}
@@ -0,0 +1,13 @@
/**
* 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.
*
* `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.
*/
export { ConfirmWrite } from "./confirm-write.js";
export type { ConfirmWriteProps } from "./confirm-write.js";
export { confirmWriteTool, confirmWriteSchema } from "./confirm-write-tool.js";
+292
View File
@@ -0,0 +1,292 @@
/**
* 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,
// Kite keeps DMs conversational and responds to explicit app mentions
// in channels/threads. Plain channel thread replies stay quiet unless
// they mention Kite 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 < 0) {
console.error(
`Invalid PORT: "${process.env.PORT}" is not a valid port number`,
);
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(() => {});
}
});
// 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;
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",
},
]);
});
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…`);
await bot.stop();
// Tear down the shared headless browser used for chart/diagram rendering.
await closeBrowser();
process.exit(0);
};
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("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);
});
main().catch((err) => {
console.error("[bot] fatal", err);
process.exit(1);
});
+102
View File
@@ -0,0 +1,102 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const fakes = vi.hoisted(() => {
const stop = vi.fn(async () => {
throw new Error("stop failed");
});
return {
stop,
closeBrowser: vi.fn(async () => {}),
startChannelsOverRealtimeGateway: vi.fn(async () => ({ stop })),
bot: {
onMention: vi.fn(),
onModalSubmit: vi.fn(),
onThreadStarted: vi.fn(),
},
};
});
vi.mock("@copilotkit/channels", () => ({
createBot: vi.fn(() => fakes.bot),
}));
vi.mock("@copilotkit/channels-slack", () => ({
defaultSlackTools: [],
defaultSlackContext: [],
SanitizingHttpAgent: function SanitizingHttpAgent() {},
}));
vi.mock("@copilotkit/channels-intelligence", () => ({
startChannelsOverRealtimeGateway: fakes.startChannelsOverRealtimeGateway,
}));
vi.mock("./tools/index.js", () => ({ appTools: [] }));
vi.mock("./context/app-context.js", () => ({ appContext: [] }));
vi.mock("./commands/index.js", () => ({ appCommands: [] }));
vi.mock("./sender-context.js", () => ({ senderContext: vi.fn() }));
vi.mock("./modals/file-issue.js", () => ({
fileIssueSubmit: vi.fn(),
FILE_ISSUE_CALLBACK: "file-issue",
}));
vi.mock("./render/browser.js", () => ({ closeBrowser: fakes.closeBrowser }));
const envKeys = [
"AGENT_URL",
"INTELLIGENCE_PROJECT_ID",
"INTELLIGENCE_CHANNEL_NAME",
"INTELLIGENCE_GATEWAY_WS_URL",
"INTELLIGENCE_API_KEY",
"INTELLIGENCE_ORG_ID",
"INTELLIGENCE_CHANNEL_ID",
] as const;
describe("channel entrypoint shutdown", () => {
const previousEnv = new Map<string, string | undefined>();
afterEach(() => {
vi.restoreAllMocks();
for (const key of envKeys) {
const value = previousEnv.get(key);
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
previousEnv.clear();
});
it("exits nonzero when stopping the channel runtime fails", async () => {
for (const key of envKeys) previousEnv.set(key, process.env[key]);
process.env.AGENT_URL = "http://agent.test/run";
process.env.INTELLIGENCE_PROJECT_ID = "7";
process.env.INTELLIGENCE_CHANNEL_NAME = "opentag";
process.env.INTELLIGENCE_GATEWAY_WS_URL = "wss://gateway.test/runner";
process.env.INTELLIGENCE_API_KEY = "cpk-test";
process.env.INTELLIGENCE_ORG_ID = "org_1";
process.env.INTELLIGENCE_CHANNEL_ID = "channel_1";
let sigterm: (() => void) | undefined;
vi.spyOn(process, "on").mockImplementation(((event, listener) => {
if (event === "SIGTERM") sigterm = listener as () => void;
return process;
}) as typeof process.on);
const exit = vi
.spyOn(process, "exit")
.mockImplementation((() => undefined as never) as typeof process.exit);
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
await import("./managed.js");
await vi.waitFor(() => expect(sigterm).toBeTypeOf("function"));
expect(fakes.startChannelsOverRealtimeGateway).toHaveBeenCalledWith(
[fakes.bot],
expect.objectContaining({
scope: expect.objectContaining({
channelId: "channel_1",
channelName: "opentag",
}),
}),
);
sigterm!();
await vi.waitFor(() => expect(exit).toHaveBeenCalled());
expect(fakes.stop).toHaveBeenCalledOnce();
expect(fakes.closeBrowser).toHaveBeenCalledOnce();
expect(exit).toHaveBeenCalledWith(1);
});
});
+184
View File
@@ -0,0 +1,184 @@
/**
* Intelligence Channel entrypoint for the same Slack bot as
* `app/index.ts`.
*
* `index.ts` is the SELF-HOSTED variant: it holds the Slack bot/app tokens and
* talks to Slack directly via the native `slack()` adapter. This file is the
* Channel variant: it holds no Slack credentials and no public endpoint — it
* connects to the Intelligence Realtime Gateway, receives leased
* deliveries, and streams render frames back. Intelligence owns the Slack edge
* (signed ingress → app-api, egress via the Connector Outbox).
*
* The bot itself — the agent, tools, context, commands, and turn handlers — is
* IDENTICAL to the native bot; only the transport changes. `intelligenceAdapter`
* is exclusive, so the Channel Bot is created WITHOUT a native adapter and
* {@link startChannelsOverRealtimeGateway} attaches the Channel transport.
*
* native: createBot({ adapters: [slack({ botToken, appToken }) ] }) // index.ts
* channel: startChannelsOverRealtimeGateway([ createBot({ … }) ], { … }) // this file
*
* Run: `pnpm --filter slack-example channel` with the INTELLIGENCE_* env set
* (see `.env.example`).
*/
import "dotenv/config";
import { randomUUID } from "node:crypto";
import { createBot } from "@copilotkit/channels";
import {
defaultSlackTools,
defaultSlackContext,
SanitizingHttpAgent,
} 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;
};
async function main() {
const agentUrl = required("AGENT_URL");
const agentHeaders = process.env.AGENT_AUTH_HEADER
? { Authorization: process.env.AGENT_AUTH_HEADER }
: undefined;
const projectId = Number(required("INTELLIGENCE_PROJECT_ID"));
if (!Number.isInteger(projectId) || projectId <= 0) {
console.error(
`Invalid INTELLIGENCE_PROJECT_ID: "${process.env.INTELLIGENCE_PROJECT_ID}"`,
);
process.exit(1);
}
const channelName = required("INTELLIGENCE_CHANNEL_NAME");
// Same Slack Bot as the native example, minus the adapter: the Channel transport is
// attached by startChannelsOverRealtimeGateway. Slack is the only Channel provider
// here, so it always ships the Slack tools/context (the native example adds
// these conditionally per active adapter).
const bot = createBot({
name: channelName,
agent: (threadId) => {
const a = new SanitizingHttpAgent({
url: agentUrl,
headers: agentHeaders,
});
a.threadId = threadId;
return a;
},
tools: [...appTools, ...defaultSlackTools],
context: [...appContext, ...defaultSlackContext],
commands: appCommands,
});
// Turn + feature handlers — identical to the native example (app/index.ts).
bot.onMention(async ({ thread, message }) => {
try {
// Channel history (app-api /api/channels/history) does NOT include the
// in-flight turn (unlike native adapters whose getHistory rebuilds the
// live thread), so pass the current message explicitly as `prompt` —
// otherwise runAgent runs with zero messages. Prefer multimodal parts.
await thread.runAgent({
prompt: message.contentParts?.length
? message.contentParts
: message.text,
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;
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",
},
]);
});
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",
// DEBUG-ONLY logging. `meta` (and the raw `err` in the onMention catch
// above) can contain message content/payloads — the design says telemetry
// must not include raw message text. In production, drop this `log` or trim
// `meta` to safe fields (ids, counts) before emitting.
log: (msg, meta) => console.log(`[channel] ${msg}`, meta ?? ""),
});
console.log(
`[channel] started over Realtime Gateway as "${channelName}" 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;
}
// Browser teardown is best-effort, but still surface a failure rather than
// swallow it silently.
await closeBrowser().catch((err: unknown) =>
console.error(
"[channel] browser cleanup failed (continuing shutdown)",
err,
),
);
process.exit(exitCode);
};
// A failed shutdown must not vanish — log it and exit nonzero.
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"));
}
// Fail loud, not silent: surface any stray async error instead of letting it
// kill the process with no log (mirrors the native entrypoint).
process.on("unhandledRejection", (reason) => {
console.error("[channel] unhandledRejection:", reason);
});
process.on("uncaughtException", (err) => {
console.error("[channel] uncaughtException:", err);
});
main().catch((err: unknown) => {
console.error("[channel] fatal: failed to start Channel runtime", err);
process.exit(1);
});
@@ -0,0 +1,134 @@
import { describe, it, expect, vi } from "vitest";
import { renderToIR } from "@copilotkit/channels-ui";
import {
FileIssueModal,
fileIssueSubmit,
issueFromValues,
FILE_ISSUE_CALLBACK,
} from "../file-issue.js";
import type { BotNode } from "@copilotkit/channels-ui";
function tags(node: BotNode | unknown, acc: string[] = []): string[] {
if (!node || typeof node !== "object") return acc;
const n = node as BotNode;
if (typeof n.type === "string") acc.push(n.type);
for (const c of (n.props?.children as BotNode[] | undefined) ?? []) {
tags(c, acc);
}
return acc;
}
describe("FileIssueModal", () => {
it("rich variant (Slack) includes selects and radios", () => {
const ir = renderToIR(FileIssueModal({ rich: true }));
const root = ir[0]!;
const t = tags(root);
expect(root.type).toBe("modal");
expect(root.props.callbackId).toBe(FILE_ISSUE_CALLBACK);
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 }));
const root = ir[0]!;
const t = tags(root);
expect(t).not.toContain("modal_select");
expect(t).not.toContain("modal_radio");
expect(t.filter((x) => x === "modal_text_input").length).toBe(2);
});
});
describe("issueFromValues", () => {
it("reads submitted values", () => {
expect(
issueFromValues({
title: "Login broken",
description: "500 on submit",
type: "bug",
priority: "High",
}),
).toEqual({
title: "Login broken",
description: "500 on submit",
type: "bug",
priority: "High",
});
});
it("applies defaults when controls were absent (Discord text-only)", () => {
expect(issueFromValues({ title: "X", description: "Y" })).toEqual({
title: "X",
description: "Y",
type: "bug",
priority: "Medium",
});
});
});
describe("fileIssueSubmit", () => {
it("returns a title error and does not run the agent on a blank title", async () => {
const thread = { runAgent: vi.fn(() => new Promise<void>(() => {})) };
const result = await fileIssueSubmit({
values: { title: "" },
thread,
user: { id: "U1" },
} as never);
expect(result).toEqual({ errors: { title: expect.any(String) } });
expect(thread.runAgent).not.toHaveBeenCalled();
});
it("treats a whitespace-only title as blank", async () => {
const thread = { runAgent: vi.fn(() => new Promise<void>(() => {})) };
const result = await fileIssueSubmit({
values: { title: " ", description: "x" },
thread,
user: { id: "U1" },
} as never);
expect(result).toEqual({ errors: { title: expect.any(String) } });
expect(thread.runAgent).not.toHaveBeenCalled();
});
it("resolves immediately even though runAgent never settles (fire-and-forget)", async () => {
// A never-resolving runAgent: if the handler awaited it, this would hang and
// the test would time out — that is the regression guard for the ~3s ack bug.
const thread = { runAgent: vi.fn(() => new Promise<void>(() => {})) };
await expect(
fileIssueSubmit({
values: { title: "T", description: "D", type: "bug", priority: "High" },
thread,
user: { id: "U1" },
} as never),
).resolves.toBeUndefined();
expect(thread.runAgent).toHaveBeenCalledTimes(1);
});
it("acks without running the agent when there is no thread", async () => {
await expect(
fileIssueSubmit({
values: { title: "T", description: "D" },
thread: undefined,
user: { id: "U1" },
} as never),
).resolves.toBeUndefined();
});
it("posts a failure message to the thread when runAgent rejects", async () => {
const post = vi.fn().mockResolvedValue({ id: "m1" });
const thread = {
runAgent: vi.fn(() => Promise.reject(new Error("LLM timeout"))),
post,
};
await fileIssueSubmit({
values: { title: "T", description: "D", type: "bug", priority: "High" },
thread,
user: { id: "U1" },
} as never);
// Let the fire-and-forget .catch() run before asserting.
await new Promise((r) => setTimeout(r, 0));
expect(post).toHaveBeenCalledWith(
expect.stringMatching(/couldn.t file|try again/i),
);
});
});
+122
View File
@@ -0,0 +1,122 @@
/**
* 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.
*/
import {
Modal,
TextInput,
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";
export const FILE_ISSUE_CALLBACK = "file_issue";
const str = (v: unknown, fallback = ""): string =>
typeof v === "string" && v.length > 0 ? v : fallback;
/** Map a modal submission's `values` (keyed by input id) to a typed issue. */
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
priority: str(values.priority, "Medium"),
};
}
/**
* 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.
*
* 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
* ~3 seconds. A `runAgent` call (an LLM round-trip + Linear MCP write) routinely
* exceeds that, so awaiting it here blows the deadline → Slack shows the user a
* submission error AND retries the submission → the issue gets filed twice.
* Synchronous validation (the `{ errors }` return) legitimately must run before
* ack, but the agent run must NOT be awaited on the ack path — so we fire-and-
* forget it (logging any rejection) and return immediately.
*/
export const fileIssueSubmit: ModalSubmitHandler = async ({
values,
thread,
user,
}) => {
const issue = issueFromValues(values);
if (!issue.title.trim()) {
return { errors: { title: "Give the issue a title." } };
}
// No conversation context on the submission → nothing to post into; ack only.
if (!thread) return;
// Fire-and-forget: see the deadline note above — do NOT await this.
void thread
.runAgent({
prompt:
`File a Linear issue now (this was already confirmed via the 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),
})
.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(() => {});
});
};
/**
* The form. `rich` controls whether the structured controls (selects/radio) are
* present; pass `false` on text-only surfaces (Discord).
*/
export function FileIssueModal({ rich }: { rich: boolean }): ModalView {
return (
<Modal
callbackId={FILE_ISSUE_CALLBACK}
title="File an issue"
submitLabel="File"
>
<TextInput
id="title"
label="Title"
placeholder="Short summary of the issue"
/>
<TextInput
id="description"
label="Description"
multiline
optional
placeholder="What happened? Steps, expected vs actual…"
/>
{rich ? (
<ModalSelect id="priority" label="Priority" initialOption="Medium">
<ModalSelectOption label="Urgent" value="Urgent" />
<ModalSelectOption label="High" value="High" />
<ModalSelectOption label="Medium" value="Medium" />
<ModalSelectOption label="Low" value="Low" />
</ModalSelect>
) : null}
{rich ? (
<RadioButtons id="type" label="Type" initialOption="bug">
<ModalSelectOption label="🐛 Bug" value="bug" />
<ModalSelectOption label="✨ Feature" value="feature" />
<ModalSelectOption label="🧹 Chore" value="chore" />
</RadioButtons>
) : null}
</Modal>
) as ModalView;
}
+36
View File
@@ -0,0 +1,36 @@
/**
* A single shared headless Chromium for local chart/diagram rendering.
*
* The rendering happens entirely in-process (our own browser) — only the
* charting *library* is fetched from a CDN; the user's data never leaves the
* host. Reuse one browser across renders so we don't pay a launch per call;
* `closeBrowser()` is wired into the bridge's shutdown.
*
* Requires a Chromium binary: `npx playwright install chromium`.
*/
import { chromium, type Browser } from "playwright";
let browserPromise: Promise<Browser> | undefined;
let closing = false;
export function getBrowser(): Promise<Browser> {
if (closing) {
return Promise.reject(new Error("renderer is shutting down"));
}
if (!browserPromise) {
browserPromise = chromium.launch({ args: ["--no-sandbox"] });
}
return browserPromise;
}
export async function closeBrowser(): Promise<void> {
if (!browserPromise) return;
// Claim shutdown and detach the promise BEFORE awaiting, so a concurrent
// getBrowser() rejects cleanly instead of handing back a browser we're
// about to close out from under it.
closing = true;
const pending = browserPromise;
browserPromise = undefined;
const b = await pending.catch(() => undefined);
await b?.close().catch(() => {});
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Render a Chart.js config to a PNG, locally, in headless Chromium.
* The agent produces the Chart.js config (type + data + options); we draw it
* to a canvas and screenshot it. Chart.js is loaded from a CDN into our own
* browser — the chart *data* never leaves the host.
*/
import { getBrowser } from "./browser.js";
const CHART_JS_CDN =
process.env["CHART_JS_URL"] ??
"https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.js";
export async function renderChart(
spec: Record<string, unknown>,
opts: { width?: number; height?: number } = {},
): Promise<Buffer> {
const width = opts.width ?? 720;
const height = opts.height ?? 440;
const browser = await getBrowser();
const page = await browser.newPage({
viewport: { width, height },
deviceScaleFactor: 2,
});
try {
await page.setContent(
`<!doctype html><html><body style="margin:0;background:#ffffff">` +
`<canvas id="c" width="${width}" height="${height}"></canvas></body></html>`,
);
await page.addScriptTag({ url: CHART_JS_CDN });
const err = await page.evaluate((spec) => {
const el = document.getElementById("c") as HTMLCanvasElement | null;
if (!el) return "no canvas";
const s = spec as { options?: Record<string, unknown> };
s.options = { ...(s.options ?? {}), animation: false, responsive: false };
try {
// @ts-expect-error Chart is injected by the CDN script
new Chart(el.getContext("2d"), s);
return null;
} catch (e) {
return String((e as Error)?.message ?? e);
}
}, spec);
if (err) throw new Error(`Chart.js render failed: ${err}`);
// Chart.js with animation disabled paints synchronously; a tiny settle
// guards against font/layout reflow.
await page.waitForTimeout(120);
const canvas = await page.$("#c");
if (!canvas) throw new Error("canvas disappeared");
return (await canvas.screenshot({ type: "png" })) as Buffer;
} finally {
await page.close();
}
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Render Mermaid diagram source to a PNG, locally, in headless Chromium.
* Mermaid is loaded from a CDN into our own browser; the diagram source
* never leaves the host. Invalid Mermaid throws with the parser message so
* the tool can hand the agent a clear error to repair.
*
* Two-stage to keep AI-authored content from executing scripts:
* 1. A "render" page loads Mermaid and turns the DSL into a sanitized SVG
* *string* (securityLevel "strict"); Mermaid only parses its own DSL
* here, never arbitrary HTML.
* 2. A "shot" page displays that SVG with a `script-src 'none'` CSP, so
* even a crafted SVG can't run a script, then we screenshot it.
*/
import { getBrowser } from "./browser.js";
const MERMAID_CDN =
process.env["MERMAID_URL"] ??
"https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js";
export async function renderDiagram(code: string): Promise<Buffer> {
const browser = await getBrowser();
// ── Stage 1: DSL → sanitized SVG string ─────────────────────────────
const renderPage = await browser.newPage();
let svg: string;
try {
await renderPage.setContent("<!doctype html><html><body></body></html>");
await renderPage.addScriptTag({ url: MERMAID_CDN });
const result = await renderPage.evaluate(async (code) => {
// @ts-expect-error mermaid is injected by the CDN script
mermaid.initialize({ startOnLoad: false, securityLevel: "strict" });
try {
// @ts-expect-error mermaid global
const out = await mermaid.render("graph", code);
return { svg: out.svg as string };
} catch (e) {
return { error: String((e as Error)?.message ?? e) };
}
}, code);
if ("error" in result) {
throw new Error(`Mermaid render failed: ${result.error}`);
}
svg = result.svg;
} finally {
await renderPage.close();
}
// ── Stage 2: display under a no-script CSP and screenshot ────────────
const shotPage = await browser.newPage({
viewport: { width: 1000, height: 800 },
deviceScaleFactor: 2,
});
try {
await shotPage.setContent(
`<!doctype html><html><head>` +
`<meta http-equiv="Content-Security-Policy" content="script-src 'none'; object-src 'none'">` +
`</head><body style="margin:0;padding:16px;background:#ffffff">` +
`<div id="out">${svg}</div></body></html>`,
{ waitUntil: "load" },
);
const el = await shotPage.$("#out svg");
if (!el) throw new Error("Mermaid produced no SVG");
return (await el.screenshot({ type: "png" })) as Buffer;
} finally {
await shotPage.close();
}
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect } from "vitest";
import { senderContext } from "./sender-context.js";
describe("senderContext", () => {
it("returns [] when there is no user", () => {
expect(senderContext(undefined, "slack")).toEqual([]);
});
it("labels a slack user with email", () => {
const out = senderContext(
{ id: "U1", name: "Ada", email: "ada@x.io" },
"slack",
);
expect(out).toEqual([
{
description: "Requesting slack user",
value: "Ada <ada@x.io> (slack id U1)",
},
]);
});
it("labels a whatsapp user (no email) with the platform", () => {
const out = senderContext({ id: "15551230000", name: "Bob" }, "whatsapp");
expect(out).toEqual([
{
description: "Requesting whatsapp user",
value: "Bob (whatsapp id 15551230000)",
},
]);
});
it("falls back to the id when the user has no name (e.g. a WhatsApp sender)", () => {
const out = senderContext({ id: "15551230000" }, "whatsapp");
expect(out).toEqual([
{
description: "Requesting whatsapp user",
value: "15551230000 (whatsapp id 15551230000)",
},
]);
});
});
+22
View File
@@ -0,0 +1,22 @@
import type { ContextEntry } from "@copilotkit/channels";
import type { PlatformUser } from "@copilotkit/channels-ui";
/**
* 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.
*/
export function senderContext(
user: PlatformUser | undefined,
platform: string,
): ContextEntry[] {
// `createBot` 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 [];
const label = `${user.name ?? user.id}${user.email ? ` <${user.email}>` : ""} (${platform} id ${user.id})`;
return [{ description: `Requesting ${platform} user`, value: label }];
}
@@ -0,0 +1,83 @@
import { describe, it, expect, vi } from "vitest";
import { readThreadTool } from "../read-thread.js";
import type { ThreadMessage } from "@copilotkit/channels-ui";
/** The ctx a BotTool 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
* this tool, so we cast the minimal literal to the handler ctx type.
*/
function makeCtx(messages: ThreadMessage[]): HandlerCtx {
const thread = { getMessages: vi.fn(async () => messages) };
return { thread } as unknown as HandlerCtx;
}
describe("read_thread tool", () => {
it("returns the thread messages in a normalized shape", async () => {
const ctx = makeCtx([
{
user: { id: "UALICE", name: "Alice" },
text: "checkout is 500ing",
ts: "1700000000.000100",
},
{
user: { id: "UBOT", name: "Triage Bot" },
text: "looking into it",
ts: "1700000000.000200",
isBot: true,
},
{
text: "alert: error rate 12%",
ts: "1700000000.000300",
isBot: true,
},
]);
const out = (await readThreadTool.handler({}, ctx)) as {
count: number;
messages: Array<{ user: string; text: string; ts?: string }>;
};
expect(out.count).toBe(3);
expect(out.messages).toHaveLength(3);
expect(out.messages[0]).toMatchObject({
user: "Alice",
text: "checkout is 500ing",
ts: "1700000000.000100",
});
expect(out.messages[1]?.user).toBe("Triage Bot");
// A user-less bot message falls back to the "bot" label.
expect(out.messages[2]).toMatchObject({ user: "bot" });
});
it("returns an empty list when there is no history", async () => {
const ctx = makeCtx([]);
const out = (await readThreadTool.handler({}, ctx)) as {
count: number;
messages: unknown[];
};
expect(out.count).toBe(0);
expect(out.messages).toEqual([]);
});
it("labels a user-less, non-bot message as unknown", async () => {
const ctx = makeCtx([{ text: "system note", ts: "1.0" }]);
const out = (await readThreadTool.handler({}, ctx)) as {
messages: Array<{ user: string }>;
};
expect(out.messages[0]?.user).toBe("unknown");
});
it("calls thread.getMessages once", async () => {
const ctx = makeCtx([]);
await readThreadTool.handler({}, ctx);
expect(ctx.thread.getMessages).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,154 @@
/**
* `render_table` posts a `<Table>` JSX component to the thread. We drive the
* handler with a fake `thread` whose `post` records the posted Renderable (or
* throws to exercise the monospace fallback), then assert the rendering through
* `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 { renderTableTool, toMonospaceTable, clamp } from "../render-table.js";
type HandlerCtx = Parameters<typeof renderTableTool.handler>[1];
const COLS = [
{ header: "Issue" },
{ header: "Priority", align: "right" as const },
];
const ROWS = [
["CPK-1", "High"],
["CPK-2", "Low"],
];
interface TableBlock {
type: string;
rows?: Array<Array<{ type: string; text: string }>>;
column_settings?: Array<{ align?: string }>;
}
/** A fake `thread` recording each posted Renderable; optionally throws on post N. */
function fakeThread(throwOnPost?: number) {
const posts: unknown[] = [];
let n = 0;
const thread = {
post: async (ui: unknown) => {
n += 1;
if (throwOnPost === n) throw new Error("invalid_blocks");
posts.push(ui);
return { id: `m${n}` };
},
};
return { posts, ctx: { thread } as unknown as HandlerCtx };
}
describe("toMonospaceTable", () => {
it("renders a column-aligned, code-fenced table", () => {
const out = toMonospaceTable(COLS, ROWS);
expect(out.startsWith("```\n")).toBe(true);
expect(out.endsWith("\n```")).toBe(true);
// "Priority" (8) is the widest in column 2, so "High" is padded to width 8.
expect(out).toContain("| CPK-1 | High |");
expect(out).toContain("| Issue | Priority |");
});
});
describe("render_table tool", () => {
it("posts a <Table> rendering to a header + native table block", async () => {
const { posts, ctx } = fakeThread();
const out = (await renderTableTool.handler(
{ title: "Open issues", columns: COLS, rows: ROWS },
ctx,
)) as string;
expect(posts).toHaveLength(1);
expect(out).toBe("Rendered the table for the user.");
const { blocks } = renderSlackMessage(renderToIR(posts[0] as never));
expect(blocks[0]).toMatchObject({
type: "header",
text: { type: "plain_text", text: "Open issues" },
});
const table = blocks.find((b) => b.type === "table") as
| TableBlock
| undefined;
expect(table).toBeDefined();
// Header row from columns, then one row per data row.
expect(table?.rows).toHaveLength(3);
expect(table?.rows?.[0]).toEqual([
{ type: "raw_text", text: "Issue" },
{ type: "raw_text", text: "Priority" },
]);
expect(table?.rows?.[1]).toEqual([
{ type: "raw_text", text: "CPK-1" },
{ type: "raw_text", text: "High" },
]);
// Alignment carries through column_settings.
expect(table?.column_settings).toEqual([
{ align: "left" },
{ align: "right" },
]);
});
it("falls back to a monospace table when the native post is rejected", async () => {
const { posts, ctx } = fakeThread(1);
const out = (await renderTableTool.handler(
{ title: "Open issues", columns: COLS, rows: ROWS },
ctx,
)) as string;
// First post threw; second (fallback) recorded.
expect(posts).toHaveLength(1);
expect(out).toBe("Rendered the table (monospace fallback) for the user.");
// Fallback is a platform-neutral <Message><Header>…</Header><Section>…</Section></Message>.
const { blocks } = renderSlackMessage(renderToIR(posts[0] as never));
// Title is in a plain-text header block.
expect(blocks[0]).toMatchObject({
type: "header",
text: { type: "plain_text", text: "Open issues" },
});
// Monospace table is in the section block.
const text = JSON.stringify(blocks);
expect(text).toContain("```");
expect(text).toContain("CPK-1");
});
it("clamps to 99 data rows and reports the drop", async () => {
const { posts, ctx } = fakeThread();
const manyRows = Array.from({ length: 150 }, (_, i) => [`r${i}`, "x"]);
const out = (await renderTableTool.handler(
{ columns: COLS, rows: manyRows },
ctx,
)) as string;
expect(out).toBe("Rendered the table for the user.");
const { blocks } = renderSlackMessage(renderToIR(posts[0] as never));
const table = blocks.find((b) => b.type === "table") as
| TableBlock
| undefined;
// 99 data rows + 1 header row.
expect(table?.rows).toHaveLength(100);
});
it("clamps to 20 columns and reports the drop", async () => {
const { posts, ctx } = fakeThread();
const manyCols = Array.from({ length: 25 }, (_, i) => ({
header: `c${i}`,
}));
const wideRow = manyCols.map((_, i) => `v${i}`);
const out = (await renderTableTool.handler(
{ columns: manyCols, rows: [wideRow] },
ctx,
)) as string;
expect(out).toBe("Rendered the table for the user.");
const { blocks } = renderSlackMessage(renderToIR(posts[0] as never));
const table = blocks.find((b) => b.type === "table") as
| TableBlock
| undefined;
expect(table?.rows?.[0]).toHaveLength(20);
});
});
describe("clamp", () => {
it("returns no notes when within limits", () => {
const { notes } = clamp(COLS, ROWS);
expect(notes).toEqual([]);
});
});
@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToIR } from "@copilotkit/channels-ui";
import { renderSlackMessage } from "@copilotkit/channels-slack";
// Mock the local renderers so no headless browser is launched.
const renderChart = vi.fn(async () => Buffer.from("CHARTPNG"));
const renderDiagram = vi.fn(async () => Buffer.from("DIAGRAMPNG"));
vi.mock("../../render/chart.js", () => ({ renderChart }));
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. */
type HandlerCtx = Parameters<typeof renderChartTool.handler>[1];
function makeCtx() {
const postFile = vi.fn(async () => ({ ok: true, fileId: "F1" }));
const posts: unknown[] = [];
const thread = {
post: vi.fn(async (ui: unknown) => {
posts.push(ui);
return { id: "m1" };
}),
postFile,
};
const ctx = { thread } as unknown as HandlerCtx;
return { ctx, postFile, thread, posts };
}
beforeEach(() => {
renderChart.mockClear();
renderDiagram.mockClear();
});
describe("render_chart tool", () => {
it("renders a config object and posts the PNG", async () => {
const { ctx, postFile, posts } = makeCtx();
const out = (await renderChartTool.handler(
{
title: "Revenue Q2",
chartSpec: {
type: "bar",
data: { labels: ["a"], datasets: [{ data: [1] }] },
},
},
ctx,
)) as string;
expect(renderChart).toHaveBeenCalledWith(
expect.objectContaining({ type: "bar" }),
);
expect(postFile).toHaveBeenCalledWith(
expect.objectContaining({
filename: "revenue-q2.png",
title: "Revenue Q2",
}),
);
expect(out).toBe("Rendered and posted the chart image to the thread.");
// The caption card was posted after the upload.
expect(posts).toHaveLength(1);
const { blocks } = renderSlackMessage(renderToIR(posts[0] as never));
expect(JSON.stringify(blocks)).toContain("📊");
expect(JSON.stringify(blocks)).toContain("Revenue Q2");
});
it("returns ok:false (not a throw) when rendering fails", async () => {
const { ctx, postFile } = makeCtx();
renderChart.mockRejectedValueOnce(
new Error("Chart.js render failed: bad type"),
);
const out = (await renderChartTool.handler(
{
chartSpec: {
type: "nope",
data: { labels: [], datasets: [{ data: [] }] },
},
},
ctx,
)) as string;
expect(out).toContain("Chart render failed");
expect(out).toContain("Chart.js render failed");
expect(postFile).not.toHaveBeenCalled();
});
});
describe("render_diagram tool", () => {
it("renders Mermaid and posts the PNG", async () => {
const { ctx, postFile, posts } = makeCtx();
const out = (await renderDiagramTool.handler(
{ title: "Flow", mermaid: "flowchart TD\n A-->B" },
ctx,
)) as string;
expect(renderDiagram).toHaveBeenCalledWith("flowchart TD\n A-->B");
expect(postFile).toHaveBeenCalledWith(
expect.objectContaining({ filename: "flow.png" }),
);
expect(out).toBe("Rendered and posted the diagram image to the thread.");
expect(posts).toHaveLength(1);
const { blocks } = renderSlackMessage(renderToIR(posts[0] as never));
expect(JSON.stringify(blocks)).toContain("📐");
expect(JSON.stringify(blocks)).toContain("Flow");
});
it("surfaces a render error for the agent to repair", async () => {
const { ctx, postFile } = makeCtx();
renderDiagram.mockRejectedValueOnce(new Error("Parse error on line 2"));
const out = (await renderDiagramTool.handler(
{ mermaid: "bogus" },
ctx,
)) as string;
expect(out).toContain("Diagram render failed");
expect(out).toContain("Parse error");
expect(postFile).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,117 @@
/**
* Render-tool wrappers post the finished JSX component to the thread. We
* drive each tool's handler with a minimal fake `thread` whose `post` records
* the posted `Renderable`, then assert that rendering it through the shared
* `renderToIR` → `renderSlackMessage` path yields the expected Block Kit
* 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 { issueCardTool, issueListTool, pageListTool } from "../render-tools.js";
/** A fake `thread` that records each posted Renderable. */
function fakeThread() {
const posts: unknown[] = [];
const thread = {
post: async (ui: unknown) => {
posts.push(ui);
return { id: "m1" };
},
};
return { posts, ctx: { thread, platform: "slack" } as never };
}
describe("issue_card render-tool", () => {
it("posts an IssueCard rendering to a header + linked title", async () => {
const { posts, ctx } = fakeThread();
const result = await issueCardTool.handler(
{
identifier: "CPK-101",
title: "Checkout 500s under load",
url: "https://linear.app/copilotkit/issue/CPK-101",
state: "In Progress",
assignee: "Alem",
priority: "Urgent",
},
ctx,
);
expect(posts).toHaveLength(1);
expect(result).toBe("Displayed the issue card to the user.");
const { blocks } = renderSlackMessage(renderToIR(posts[0] as never));
expect(blocks[0]).toMatchObject({
type: "header",
text: { type: "plain_text", text: "🔵 CPK-101" },
});
expect(JSON.stringify(blocks)).toContain(
"<https://linear.app/copilotkit/issue/CPK-101|*Checkout 500s under load*>",
);
});
});
describe("issue_list render-tool", () => {
it("posts an IssueList rendering to a header + one row per issue", async () => {
const { posts, ctx } = fakeThread();
const result = await issueListTool.handler(
{
heading: "Open CPK issues",
issues: [
{
identifier: "CPK-101",
title: "Checkout 500s under load",
url: "https://linear.app/copilotkit/issue/CPK-101",
state: "In Progress",
},
],
},
ctx,
);
expect(posts).toHaveLength(1);
expect(result).toBe("Displayed the issue list to the user.");
const { blocks } = renderSlackMessage(renderToIR(posts[0] as never));
expect(blocks[0]).toMatchObject({
type: "header",
text: { type: "plain_text", text: "📋 Open CPK issues" },
});
expect(JSON.stringify(blocks)).toContain(
"<https://linear.app/copilotkit/issue/CPK-101|*CPK-101*>",
);
expect(JSON.stringify(blocks)).toContain("1 issue");
});
});
describe("page_list render-tool", () => {
it("posts a PageList rendering to a header + one row per page", async () => {
const { posts, ctx } = fakeThread();
const result = await pageListTool.handler(
{
heading: "Runbooks",
pages: [
{
title: "Auth outage runbook",
url: "https://www.notion.so/abc",
snippet: "Steps to mitigate auth provider downtime.",
},
],
},
ctx,
);
expect(posts).toHaveLength(1);
expect(result).toBe("Displayed the Notion pages to the user.");
const { blocks } = renderSlackMessage(renderToIR(posts[0] as never));
expect(blocks[0]).toMatchObject({
type: "header",
text: { type: "plain_text", text: "📚 Runbooks" },
});
expect(JSON.stringify(blocks)).toContain(
"<https://www.notion.so/abc|*Auth outage runbook*>",
);
expect(JSON.stringify(blocks)).toContain("1 page");
});
});
@@ -0,0 +1,184 @@
/**
* The three showcase render-tools post a JSX component to the thread. We drive
* each handler with a fake `thread` that records the posted Renderable, then
* assert the rendering through `renderToIR` → `renderSlackMessage`. For
* `show_incident` we also reach into the IR, pull the Acknowledge button's
* inline `onClick`, invoke it with a fake interaction context, and assert it
* updates the message in place with a green "Acknowledged" card.
*/
import { describe, it, expect, vi } from "vitest";
import { renderToIR } from "@copilotkit/channels-ui";
import type {
BotNode,
InteractionContext,
ClickHandler,
} from "@copilotkit/channels-ui";
import { renderSlackMessage } from "@copilotkit/channels-slack";
import {
showIncidentTool,
showStatusTool,
showLinksTool,
} from "../showcase-tools.js";
type IncidentCtx = Parameters<typeof showIncidentTool.handler>[1];
type StatusCtx = Parameters<typeof showStatusTool.handler>[1];
type LinksCtx = Parameters<typeof showLinksTool.handler>[1];
/** A fake `thread` recording posts and updates. */
function fakeThread() {
const posts: unknown[] = [];
const updates: Array<{ ref: unknown; ui: unknown }> = [];
const thread = {
post: vi.fn(async (ui: unknown) => {
posts.push(ui);
return { id: "m1" };
}),
update: vi.fn(async (ref: unknown, ui: unknown) => {
updates.push({ ref, ui });
return { id: (ref as { id: string }).id };
}),
};
return { posts, updates, thread };
}
/** Depth-first: find the first IR node whose `type` matches and that has the named prop. */
function findWithProp(
nodes: BotNode[],
type: string,
prop: string,
): BotNode | undefined {
for (const node of nodes) {
if (node.type === type && node.props && prop in node.props) return node;
const children = node.props?.children;
const childArr = Array.isArray(children)
? (children as BotNode[])
: children && typeof children === "object"
? [children as BotNode]
: [];
const found = findWithProp(childArr, type, prop);
if (found) return found;
}
return undefined;
}
describe("show_incident render-tool", () => {
it("posts an interactive IncidentCard with severity accent", async () => {
const { posts, thread } = fakeThread();
const result = await showIncidentTool.handler(
{
id: "INC-1",
title: "Checkout 500s",
severity: "SEV1",
summary: "Error rate spiking on /checkout.",
},
{ thread } as unknown as IncidentCtx,
);
expect(posts).toHaveLength(1);
expect(result).toBe("Posted the incident card to the user.");
const ir = renderToIR(posts[0] as never);
const { blocks, accent } = renderSlackMessage(ir);
expect(accent).toBe("#EB5757"); // SEV1
expect(blocks[0]).toMatchObject({
type: "header",
text: { type: "plain_text", text: "🚨 SEV1 · Checkout 500s" },
});
const text = JSON.stringify(blocks);
expect(text).toContain("Acknowledge");
expect(text).toContain("Escalate");
});
it("the Acknowledge button's onClick updates the message with a green card", async () => {
const { posts, updates, thread } = fakeThread();
await showIncidentTool.handler(
{
id: "INC-1",
title: "Checkout 500s",
severity: "SEV2",
summary: "Latency creeping up.",
},
{ thread } as unknown as IncidentCtx,
);
const ir = renderToIR(posts[0] as never);
const button = findWithProp(ir, "button", "onClick");
expect(button).toBeDefined();
const onClick = button?.props.onClick as ClickHandler;
// Invoke the inline handler with a fake interaction context.
await 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);
expect(updates).toHaveLength(1);
expect(updates[0]?.ref).toEqual({ id: "m1" });
const { blocks, accent } = renderSlackMessage(
renderToIR(updates[0]?.ui as never),
);
expect(accent).toBe("#27AE60"); // green
expect(JSON.stringify(blocks)).toContain("✅ Acknowledged · Checkout 500s");
expect(JSON.stringify(blocks)).toContain("Ack'd by Alem");
});
});
describe("show_status render-tool", () => {
it("posts a StatusCard with bold field labels and accent", async () => {
const { posts, thread } = fakeThread();
const result = await showStatusTool.handler(
{
heading: "Service health",
fields: [
{ label: "API", value: "operational" },
{ label: "Queue depth", value: "12" },
],
},
{ thread } as unknown as StatusCtx,
);
expect(result).toBe("Posted the status card to the user.");
const { blocks, accent } = renderSlackMessage(
renderToIR(posts[0] as never),
);
expect(accent).toBe("#5E6AD2");
const text = JSON.stringify(blocks);
// `**API**` markdown → `*API*` mrkdwn bold.
expect(text).toContain("*API*");
expect(text).toContain("*Queue depth*");
expect(text).toContain("operational");
});
});
describe("show_links render-tool", () => {
it("posts a LinksCard rendering clean <url|label> links", async () => {
const { posts, thread } = fakeThread();
const result = await showLinksTool.handler(
{
heading: "Runbooks",
links: [
{ label: "Auth outage", url: "https://example.com/auth" },
{ label: "Dashboard", url: "https://example.com/dash" },
],
},
{ thread } as unknown as LinksCtx,
);
expect(result).toBe("Posted the links to the user.");
const { blocks } = renderSlackMessage(renderToIR(posts[0] as never));
const text = JSON.stringify(blocks);
expect(text).toContain("<https://example.com/auth|Auth outage>");
expect(text).toContain("<https://example.com/dash|Dashboard>");
// No leftover markdown link syntax.
expect(text).not.toContain("](http");
});
});
+58
View File
@@ -0,0 +1,58 @@
/**
* 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`).
*
* Add new tools here and re-export them through `appTools`. Wire the
* array into `createBot({tools: [...defaultSlackTools, ...appTools]})`
* in `app/index.ts`.
*/
import { readThreadTool } from "./read-thread.js";
import { renderChartTool } from "./render-chart.js";
import { renderDiagramTool } from "./render-diagram.js";
import { renderTableTool } from "./render-table.js";
import { issueCardTool, issueListTool, pageListTool } from "./render-tools.js";
import {
showIncidentTool,
showStatusTool,
showLinksTool,
} from "./showcase-tools.js";
import { confirmWriteTool } from "../human-in-the-loop/index.js";
import type { BotTool } from "@copilotkit/channels";
/**
* Every tool is a plain `BotTool`: its handler receives the generic
* `BotToolContext` (`{ 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 })`.
*/
export const appTools: BotTool[] = [
readThreadTool,
renderChartTool,
renderDiagramTool,
renderTableTool,
issueCardTool,
issueListTool,
pageListTool,
showIncidentTool,
showStatusTool,
showLinksTool,
confirmWriteTool,
];
export {
readThreadTool,
renderChartTool,
renderDiagramTool,
renderTableTool,
issueCardTool,
issueListTool,
pageListTool,
showIncidentTool,
showStatusTool,
showLinksTool,
confirmWriteTool,
};
+35
View File
@@ -0,0 +1,35 @@
/**
* `read_thread` — an app-side `BotTool` 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
* 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";
export const readThreadTool = defineBotTool({
name: "read_thread",
description:
"Fetch the messages in the current conversation thread so you can " +
"summarize or act on them. Call this before turning a conversation into a " +
"Linear issue or a Notion postmortem — never guess what was said. Returns " +
"the messages in chronological order with author and timestamp.",
parameters: z.object({}),
async handler(_args, { thread }) {
const messages = await thread.getMessages();
return {
count: messages.length,
messages: messages.map((m) => ({
user: m.user?.name ?? m.user?.handle ?? (m.isBot ? "bot" : "unknown"),
text: m.text,
ts: m.ts,
})),
};
},
});
+113
View File
@@ -0,0 +1,113 @@
/**
* `render_chart` — the agent emits a Chart.js config; we render it to a PNG
* locally (headless Chromium) and deliver it to the thread via the SDK's
* `ctx.thread.postFile`. The image renders inline in the conversation. This
* is the "upload a CSV → get a chart" payoff: the agent parses the data, then
* calls this. After the upload we also post a small JSX caption card
* (`<Context>`) so the tool doubles as a render-tool demo.
*/
import { z } from "zod";
import { Context } from "@copilotkit/channels-ui";
import { defineBotTool } from "@copilotkit/channels";
import { renderChart } from "../render/chart.js";
const schema = z.object({
title: z
.string()
.optional()
.describe("Short title shown as the image's filename/caption."),
chartSpec: z
.object({
type: z
.string()
.describe("'bar' | 'line' | 'pie' | 'doughnut' | 'scatter' | 'radar'."),
data: z
.object({
labels: z
.array(z.string())
.describe("X-axis / category labels, e.g. ['2026-01','2026-02']."),
datasets: z
.array(
z
.object({
label: z
.string()
.optional()
.describe("Series name in the legend, e.g. 'Sev1'."),
data: z
.array(z.number())
.describe("One numeric value per label."),
})
// Allow Chart.js dataset extras: stack, backgroundColor, fill…
.passthrough(),
)
.min(1)
.describe("One entry per data series."),
})
.describe("Chart.js data — inline the actual numbers."),
options: z
.record(z.string(), z.any())
.optional()
.describe(
"Optional Chart.js options. Stacked bar: " +
"{ scales: { x: { stacked: true }, y: { stacked: true } } }.",
),
})
.describe("A Chart.js config with all values inlined."),
});
function slug(s: string): string {
return (
(s || "chart")
.replace(/[^a-z0-9]+/gi, "-")
.replace(/^-|-$/g, "")
.toLowerCase() || "chart"
);
}
export const renderChartTool = defineBotTool({
name: "render_chart",
description:
"Render a chart as an image and post it to the conversation thread. Pass " +
"a Chart.js config OBJECT (type + data, optionally options). Use this to " +
"visualize data — e.g. after analyzing an uploaded CSV. The image renders " +
"inline in the conversation.",
parameters: schema,
async handler({ title, chartSpec }, ctx) {
// chartSpec is an object; tolerate a stringified one too (some models
// still hand back a JSON string).
let spec: Record<string, unknown>;
if (typeof chartSpec === "string") {
try {
spec = JSON.parse(chartSpec) as Record<string, unknown>;
} catch (e) {
return `Chart render failed: chartSpec must be a Chart.js config object; got an unparseable string: ${(e as Error).message}`;
}
} else {
spec = chartSpec as Record<string, unknown>;
}
try {
const png = await renderChart(spec);
// Post the caption as a HEADER first, then the image. A file upload's
// channel message lands a beat after `postFile` resolves, so posting the
// caption first keeps a stable caption → image order (posting it after
// would let the image's message overtake it). Also doubles as a
// render-tool demo of a JSX <Context> card.
await ctx.thread.post(
<Context>{`📊 *${title ?? "Chart"}* — chart below.`}</Context>,
);
const res = await ctx.thread.postFile({
bytes: png,
filename: `${slug(title ?? "chart")}.png`,
title: title ?? "Chart",
altText: title ?? "Generated chart",
});
if (!res.ok) {
return `Chart render failed: ${res.error ?? "upload was rejected"}`;
}
return "Rendered and posted the chart image to the thread.";
} catch (e) {
return `Chart render failed: ${(e as Error).message}`;
}
},
});
@@ -0,0 +1,68 @@
/**
* `render_diagram` — the agent emits Mermaid source; we render it to a PNG
* locally (headless Chromium) and deliver it to the thread via `ctx.thread.postFile`.
* On invalid Mermaid the tool returns the parser error so the agent can fix
* and retry rather than posting a broken image. After a successful upload we
* also post a small JSX caption card (`<Context>`) so the tool doubles as a
* render-tool demo.
*/
import { z } from "zod";
import { Context } from "@copilotkit/channels-ui";
import { defineBotTool } from "@copilotkit/channels";
import { renderDiagram } from "../render/diagram.js";
const schema = z.object({
title: z
.string()
.optional()
.describe("Short title shown as the image's filename/caption."),
mermaid: z
.string()
.describe(
"Mermaid diagram source. e.g. 'flowchart TD\\n A[Alert] --> B{Sev?}\\n " +
"B -->|1| C[Page owner]'. Supports flowchart, sequence, state, etc.",
),
});
function slug(s: string): string {
return (
(s || "diagram")
.replace(/[^a-z0-9]+/gi, "-")
.replace(/^-|-$/g, "")
.toLowerCase() || "diagram"
);
}
export const renderDiagramTool = defineBotTool({
name: "render_diagram",
description:
"Render a Mermaid diagram as an image and post it to the conversation " +
"thread. Pass Mermaid source (flowchart/sequence/state/etc). Use this to " +
"diagram a flow, architecture, or incident timeline. The image renders " +
"inline in the conversation.",
parameters: schema,
async handler({ title, mermaid }, ctx) {
try {
const png = await renderDiagram(mermaid);
// Post the caption as a HEADER first, then the image (see render-chart:
// a file upload's message lands a beat after `postFile` resolves, so
// caption-first keeps a stable caption → image order).
await ctx.thread.post(
<Context>{`📐 *${title ?? "Diagram"}* — diagram below.`}</Context>,
);
const res = await ctx.thread.postFile({
bytes: png,
filename: `${slug(title ?? "diagram")}.png`,
title: title ?? "Diagram",
altText: title ?? "Generated diagram",
});
if (!res.ok) {
return `Diagram render failed: ${res.error ?? "upload was rejected"}. Fix the Mermaid syntax and retry.`;
}
return "Rendered and posted the diagram image to the thread.";
} catch (e) {
// Surface the Mermaid parse error so the agent can repair the source.
return `Diagram render failed: ${(e as Error).message}. Fix the Mermaid syntax and retry.`;
}
},
});
+143
View File
@@ -0,0 +1,143 @@
/**
* `render_table` — render tabular data as a native Table block,
* posted into the current thread. Use this for "show X as a table": a list of
* 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
* 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
* bridge gives GFM tables in prose.
*/
import { z } from "zod";
import {
Message,
Header,
Section,
Table,
Row,
Cell,
} from "@copilotkit/channels-ui";
import { defineBotTool } from "@copilotkit/channels";
const schema = z.object({
title: z
.string()
.optional()
.describe("Optional heading shown above the table."),
columns: z
.array(
z.object({
header: z.string().describe("Column header text."),
align: z
.enum(["left", "center", "right"])
.optional()
.describe(
"Alignment for this column's cells. Default left; right for numbers.",
),
}),
)
.min(1)
.describe(
"Columns, left to right. At most 20 are used; extras are dropped.",
),
rows: z
.array(z.array(z.coerce.string()))
.describe(
"Data rows; each row is an array of cell values in column order " +
"(numbers are fine — they're rendered as text). Max 100 rows.",
),
});
type Column = z.infer<typeof schema>["columns"][number];
// Cap the native Table block at 100 rows (header included) and 20 cols.
const MAX_COLUMNS = 20;
const MAX_DATA_ROWS = 99;
/** Clamp to platform limits, recording what was dropped. */
export function clamp(
columns: Column[],
rows: string[][],
): { cols: Column[]; dataRows: string[][]; notes: string[] } {
const cols = columns.slice(0, MAX_COLUMNS);
const dataRows = rows.slice(0, MAX_DATA_ROWS);
const notes: string[] = [];
if (columns.length > MAX_COLUMNS) {
notes.push(
`only the first ${MAX_COLUMNS} of ${columns.length} columns shown`,
);
}
if (rows.length > MAX_DATA_ROWS) {
notes.push(`only the first ${MAX_DATA_ROWS} of ${rows.length} rows shown`);
}
return { cols, dataRows, notes };
}
/**
* Column-aligned monospace fallback, wrapped in a code fence — matches the
* `alignTable` render the bridge applies to GFM tables in streamed prose.
*/
export function toMonospaceTable(cols: Column[], dataRows: string[][]): string {
const header = cols.map((c) => c.header);
const body = dataRows.map((r) => cols.map((_, i) => String(r[i] ?? "")));
const widths = cols.map((_, c) =>
Math.max(
(header[c] ?? "").length,
...body.map((row) => (row[c] ?? "").length),
),
);
const fmt = (row: string[]) =>
"| " +
cols.map((_, c) => (row[c] ?? "").padEnd(widths[c] ?? 0)).join(" | ") +
" |";
return "```\n" + [fmt(header), ...body.map(fmt)].join("\n") + "\n```";
}
export const renderTableTool = defineBotTool({
name: "render_table",
description:
"Render tabular data as a table posted to the conversation thread. Pass " +
"columns (each with a header and optional alignment) and rows (arrays of " +
"cell values in column order). Use for 'show as a table' — issue lists " +
"with several fields, metrics from a CSV, comparisons — when a chart " +
"isn't the right shape. Max 20 columns and 100 rows.",
parameters: schema,
async handler({ title, columns, rows }, { thread }) {
const { cols, dataRows } = clamp(columns, rows);
const table = (
<Message>
{title ? <Header>{title}</Header> : null}
<Table columns={cols}>
{dataRows.map((r) => (
<Row>
{cols.map((_, i) => (
<Cell>{String(r[i] ?? "")}</Cell>
))}
</Row>
))}
</Table>
</Message>
);
try {
await thread.post(table);
return "Rendered the table for the user.";
} catch {
// Native Table block not accepted (platform unsupported) — post the same
// data as a monospace code-fenced table via a platform-neutral <Message>
// so it still lands on any adapter.
const mono = toMonospaceTable(cols, dataRows);
const fallback = (
<Message>
{title ? <Header>{title}</Header> : null}
<Section>{mono}</Section>
</Message>
);
await thread.post(fallback);
return "Rendered the table (monospace fallback) for the user.";
}
},
});
+59
View File
@@ -0,0 +1,59 @@
/**
* 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`
* component (`<IssueCard … />` etc.) and posts it to the thread via
* `thread.post`.
*/
import { defineBotTool } from "@copilotkit/channels";
import {
IssueCard,
IssueList,
PageList,
issueCardSchema,
issueListSchema,
pageListSchema,
} from "../components/index.js";
export const issueCardTool = defineBotTool({
name: "issue_card",
description:
"Render ONE Linear issue as a rich card with a status header, " +
"the title as a link, and a metadata grid (status, assignee, priority, " +
"team, cycle, updated) plus optional description and labels. Use for a " +
"single issue, or right after creating one (set justCreated: true).",
parameters: issueCardSchema,
async handler(props, { thread }) {
await thread.post(<IssueCard {...props} />);
return "Displayed the issue card to the user.";
},
});
export const issueListTool = defineBotTool({
name: "issue_list",
description:
"Render a list of Linear issues as a card — a header plus one row per " +
"issue (status dot, linked identifier, title, and a meta line with " +
"assignee/priority/updated). Use this whenever you're showing the user " +
"multiple issues you pulled from Linear instead of writing them out as " +
"prose. For a single issue, use issue_card.",
parameters: issueListSchema,
async handler(props, { thread }) {
await thread.post(<IssueList {...props} />);
return "Displayed the issue list to the user.";
},
});
export const pageListTool = defineBotTool({
name: "page_list",
description:
"Render a list of Notion pages as a card — a header plus one row per " +
"page (linked title, a snippet, and optional last-edited). Use this " +
"whenever you're showing the user pages you found in Notion instead of " +
"writing them out as prose.",
parameters: pageListSchema,
async handler(props, { thread }) {
await thread.post(<PageList {...props} />);
return "Displayed the Notion pages to the user.";
},
});
+182
View File
@@ -0,0 +1,182 @@
/**
* Showcase render-tools — three small JSX `BotTool`s that demonstrate the
* `@copilotkit/channels-ui` vocabulary end-to-end:
*
* - `show_incident` — an interactive card whose `Acknowledge`/`Escalate`
* buttons carry inline `onClick` handlers. These are FIRE-AND-FORGET
* interactions (not `awaitChoice`): the bot dispatches the handler on click
* with no waiter, so a render-tool can bind live actions directly.
* - `show_status` — a `Fields` grid with an accent and bold field labels.
* - `show_links` — a `Section` of markdown links (`[label](url)` →
* `<url|label>` via the mrkdwn bridge).
*/
import { z } from "zod";
import {
Message,
Header,
Section,
Context,
Fields,
Field,
Actions,
Button,
} from "@copilotkit/channels-ui";
import type { InteractionContext } from "@copilotkit/channels-ui";
import { defineBotTool } from "@copilotkit/channels";
// ── show_incident ──────────────────────────────────────────────────────────
const incidentSchema = z.object({
id: z.string().describe("Incident identifier, e.g. 'INC-4821'."),
title: z.string().describe("Short incident title."),
severity: z
.enum(["SEV1", "SEV2", "SEV3"])
.describe("Severity — drives the card's accent colour."),
summary: z.string().describe("One-paragraph summary of what's happening."),
});
type IncidentProps = z.infer<typeof incidentSchema>;
export function IncidentCard({ id, title, severity, summary }: IncidentProps) {
const accent =
severity === "SEV1"
? "#EB5757"
: severity === "SEV2"
? "#F2994A"
: "#5E6AD2";
return (
<Message accent={accent}>
<Header>{`🚨 ${severity} · ${title}`}</Header>
<Section>{summary}</Section>
<Context>{`Incident ${id}`}</Context>
<Actions>
<Button
value={{ action: "ack", id }}
style="primary"
onClick={async ({ thread, user, message }: InteractionContext) => {
await thread.update(
message.ref,
<Message accent="#27AE60">
<Header>{`✅ Acknowledged · ${title}`}</Header>
<Context>{`Ack'd by ${user?.name ?? user?.id ?? "someone"}`}</Context>
</Message>,
);
}}
>
Acknowledge
</Button>
<Button
value={{ action: "escalate", id }}
style="danger"
onClick={async ({ thread }: InteractionContext) => {
await thread.post(
`🚨 Escalating *${title}* — paging the next on-call.`,
);
}}
>
Escalate
</Button>
</Actions>
</Message>
);
}
export const showIncidentTool = defineBotTool({
name: "show_incident",
description:
"Render an interactive incident card with Acknowledge/Escalate buttons. " +
"Pass id, title, severity (SEV1/SEV2/SEV3) and a one-paragraph summary. " +
"The accent colour reflects severity; clicking Acknowledge updates the " +
"card in place, clicking Escalate posts a paging notice.",
parameters: incidentSchema,
async handler(props, { thread }) {
await thread.post(<IncidentCard {...props} />);
return "Posted the incident card to the user.";
},
});
// ── show_status ────────────────────────────────────────────────────────────
const statusSchema = z.object({
heading: z.string().describe("Card heading, e.g. 'Service health'."),
fields: z
.array(
z.object({
label: z.string().describe("Field label (rendered bold)."),
value: z.string().describe("Field value."),
}),
)
.min(1)
.describe("Label/value pairs laid out as a two-column grid."),
});
type StatusProps = z.infer<typeof statusSchema>;
export function StatusCard({ heading, fields }: StatusProps) {
return (
<Message accent="#5E6AD2">
<Header>{`📊 ${heading}`}</Header>
<Fields>
{fields.map((f) => (
<Field>{`**${f.label}**\n${f.value}`}</Field>
))}
</Fields>
</Message>
);
}
export const showStatusTool = defineBotTool({
name: "show_status",
description:
"Render a status card: a heading plus a grid of label/value fields " +
"(labels shown bold). Use for service health, deploy status, or any set " +
"of small key/value metrics.",
parameters: statusSchema,
async handler(props, { thread }) {
await thread.post(<StatusCard {...props} />);
return "Posted the status card to the user.";
},
});
// ── show_links ─────────────────────────────────────────────────────────────
const linksSchema = z.object({
heading: z.string().describe("Card heading, e.g. 'Runbooks'."),
links: z
.array(
z.object({
label: z.string().describe("Link text."),
url: z.string().describe("Destination URL."),
}),
)
.min(1)
.describe("Links rendered as a single dot-separated row."),
});
type LinksProps = z.infer<typeof linksSchema>;
export 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.
return (
<Message>
<Header>{`🔗 ${heading}`}</Header>
<Section>
{links.map((l) => `[${l.label}](${l.url})`).join(" · ")}
</Section>
</Message>
);
}
export const showLinksTool = defineBotTool({
name: "show_links",
description:
"Render a card of links: a heading plus a dot-separated row of clickable " +
"links. Use to surface runbooks, dashboards, or related pages.",
parameters: linksSchema,
async handler(props, { thread }) {
await thread.post(<LinksCard {...props} />);
return "Posted the links to the user.";
},
});
+85
View File
@@ -0,0 +1,85 @@
# `e2e/` — live end-to-end test 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.
> **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.
## What's in here
```
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
# then:
pnpm e2e
```
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.
## How sampling works
For each case the harness:
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.
## What this catches that unit tests don't
- 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
## Adding cases
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.
## Limitations (current)
- 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).
+119
View File
@@ -0,0 +1,119 @@
# `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.
+495
View File
@@ -0,0 +1,495 @@
/**
* Catalog of real end-to-end test cases the harness sends as live Slack
* messages and samples back via the Slack API while the bot streams.
*
* Each case is technical-axis-flavoured (not product-flavoured) — the
* prompt is just whatever phrasing reliably triggers the dimension we
* want to measure.
*
* Fields:
* name human-readable label
* prompt user's message text — what gets sent in #ag-ui-bot-test
* sampleIntervalMs how often to poll the bot's reply during streaming
* maxWaitMs give up sampling after this long
* screenshots sample times (ms after send) to take mid-stream
* screenshots in the Slack web UI
* expectations checks to run on the final text
*
* Add cases liberally. The catalog itself is the test surface.
*/
export interface E2ECase {
name: string;
prompt: string;
sampleIntervalMs?: number;
maxWaitMs?: number;
screenshots?: number[];
/**
* Optional follow-up turn that gets sent INTO the thread that this case's
* first prompt creates. Used to test thread-continuation without
* re-mentioning the bot. The follow-up has its own prompt + expectations
* and reuses the same sampleIntervalMs / maxWaitMs.
*/
followUp?: {
prompt: string;
expectations?: E2ECase["expectations"];
};
/**
* Mid-stream interrupt: send a second user message into the SAME thread
* `afterMs` after kicking off the first prompt. Used to verify that the
* in-flight bot reply is aborted, marked as interrupted in Slack, and
* the new turn produces a fresh reply.
*/
interrupt?: {
afterMs: number;
prompt: string;
/** Expectations applied to the interrupted FIRST reply. */
firstExpectations?: E2ECase["expectations"];
/** Expectations applied to the new (second) reply. */
expectations?: E2ECase["expectations"];
};
expectations?: {
/** Bot's final response must contain these substrings (case-insensitive). */
finalContains?: string[];
/** Bot's final response must NOT contain these. */
finalNotContains?: string[];
/** Final mrkdwn must be balanced (no dangling brackets). */
balancedBrackets?: boolean;
/** Minimum reply length in chars (catches truncation regressions). */
minLength?: number;
/**
* Verifies the final text contains a monospace table whose rows all
* have the same line length — i.e. columns are aligned, not pipe-soup.
*/
monospaceAlignedTable?: boolean;
/**
* Counts how many distinct Slack messages this case produced (after
* the bot's parent message). Used to verify chunking-keeps-whole-block
* behaviour: a long fenced block should land in one message, not split.
*/
expectedChunkCount?: number;
/**
* Custom predicate run against the *full* set of bot replies in the
* thread (NOT just the first one). Useful for asserting properties
* across chunked output, e.g. "the fence opener appears at the start
* of exactly one message" or "no message text contains a dangling ```".
*/
/**
* Custom predicate. `replies` is the bot's per-message text array;
* `raw` is the full Slack message objects (with `blocks`, `ts`, etc.)
* for cases that need to inspect Block Kit structure.
*/
perReplyChecks?: (
replies: string[],
raw: Array<Record<string, any>>,
) => string[];
};
}
export const CASES: E2ECase[] = [
// ── A. Trigger surface ──────────────────────────────────────────────
{
name: "A1 — top-level @mention",
prompt: "<@U0B45V75NNR> say HOTEL in one short sentence",
expectations: { finalContains: ["HOTEL"], minLength: 5 },
},
// /agent slash command requires a real slash-command invocation —
// can't fire it via chat.postMessage. Manual-only for now.
// {
// name: "A10 — /agent slash command",
// prompt: "/agent ping reply with the word PONG",
// expectations: { finalContains: ["PONG"], minLength: 4 },
// },
// ── B. Response length / shape ─────────────────────────────────────
{
name: "B2 — single-token response (was the ECHO/AL bug)",
prompt: "<@U0B45V75NNR> reply with exactly the word ECHO and nothing else",
expectations: {
finalContains: ["ECHO"],
finalNotContains: ["…"],
minLength: 4,
},
},
{
name: "B6 — long response, multi-paragraph",
prompt:
"<@U0B45V75NNR> write 4 paragraphs about the history of the printing press. Take your time. Be detailed.",
sampleIntervalMs: 700,
maxWaitMs: 60_000,
screenshots: [1500, 3500, 7000, 14_000],
expectations: { minLength: 800, balancedBrackets: true },
},
{
name: "B7 — long response (model-bounded; ensures balanced + reasonable length)",
prompt:
"<@U0B45V75NNR> write a thorough 8-paragraph essay about agent protocols. " +
"Each paragraph 4-6 sentences. Be detailed, no apologies.",
sampleIntervalMs: 700,
maxWaitMs: 90_000,
screenshots: [2000, 5000, 12_000, 20_000],
// Lower floor — the chunking code is exercised by the unit tests; here
// we mainly want to see balanced streaming over a long emission.
expectations: { minLength: 1500, balancedBrackets: true },
},
// ── B/markdown — mrkdwn translation ───────────────────────────────
{
name: "B11 — bold/italic markers",
prompt:
"<@U0B45V75NNR> say a sentence with one **bold** word and one *italic* word. Use those exact markdown markers.",
expectations: {
// After mrkdwn translation: bold uses `*`, italic uses `_`
finalContains: ["*", "_"],
finalNotContains: ["**"],
balancedBrackets: true,
},
},
{
name: "B13 — bullet list",
prompt:
"<@U0B45V75NNR> list three programming languages as bullet points using `-` markers.",
expectations: {
finalContains: ["•"], // mrkdwn bullets
balancedBrackets: true,
},
},
{
name: "B16 — fenced code block",
prompt:
"<@U0B45V75NNR> show me a short python snippet for a fibonacci function in a fenced code block",
sampleIntervalMs: 700,
maxWaitMs: 60_000,
screenshots: [1500, 4000, 9000],
expectations: {
// The point: while streaming, the in-flight Slack message has an
// OPEN fence; auto-close keeps the rest of the message renderable.
finalContains: ["```"],
balancedBrackets: true, // dangling ``` would fail this
},
},
{
name: "B17 — table fallback to monospace, COLUMN-ALIGNED",
prompt:
"<@U0B45V75NNR> give me a 3-row markdown table comparing langgraph, ag-ui, and copilotkit (columns: name, role)",
expectations: {
finalContains: ["```", "langgraph", "ag-ui"],
balancedBrackets: true,
monospaceAlignedTable: true,
},
},
{
name: "B-chunk-spill — long fenced block should land WHOLE in one Slack message",
prompt:
"<@U0B45V75NNR> write a self-contained python script that defines 8 small utility functions in ONE fenced code block. Do not split it. Aim for 1500-2500 chars inside the block.",
sampleIntervalMs: 800,
maxWaitMs: 90_000,
expectations: {
finalContains: ["```python", "def "],
balancedBrackets: true,
perReplyChecks: (replies) => {
const errs: string[] = [];
// Among all messages, exactly one should contain ```python (the block).
const withPython = replies.filter((r) =>
r.includes("```python"),
).length;
if (withPython !== 1) {
errs.push(
`expected exactly 1 message containing \`\`\`python; got ${withPython}`,
);
}
// No message should END inside an open fence (autoCloseOpenMarkdown should
// close it, OR the boundary should have moved before the fence opener).
for (let i = 0; i < replies.length; i++) {
const r = replies[i] ?? "";
const fences = (r.match(/```/g) ?? []).length;
if (fences % 2 !== 0) {
errs.push(`message #${i} has unbalanced fences`);
}
}
return errs;
},
},
},
// ── C. Streaming dynamics ─────────────────────────────────────────
{
name: "C-stream-1 — mid-stream bracket polish (open fence)",
prompt:
"<@U0B45V75NNR> describe how python decorators work using ```python ... ``` blocks. Be thorough.",
sampleIntervalMs: 500,
maxWaitMs: 60_000,
screenshots: [1000, 2500, 6000, 12_000],
expectations: { finalContains: ["```python"], balancedBrackets: true },
},
// ── D. Conversation state ─────────────────────────────────────────
{
name: "D-state-1 — thread continuation without re-mention",
prompt: "<@U0B45V75NNR> say the single word ALPHA",
expectations: { finalContains: ["ALPHA"] },
followUp: {
prompt:
"now say the single word BRAVO. no @mention; just reply in this thread.",
expectations: { finalContains: ["BRAVO"] },
},
},
// ── Interrupt: reply mid-stream cancels the in-flight bot reply ──
{
name: "Interrupt — reply mid-stream cancels the in-flight bot reply",
prompt:
"<@U0B45V75NNR> write a really long, slow, 6-paragraph essay about agent protocols. " +
"Take your time. Be exhaustive.",
sampleIntervalMs: 700,
maxWaitMs: 30_000,
interrupt: {
afterMs: 3500,
prompt: "actually never mind. just say PONG and nothing else.",
// The first (interrupted) reply must carry the marker.
firstExpectations: {
finalContains: ["(interrupted)"],
},
// The new reply must contain the new word.
expectations: {
finalContains: ["PONG"],
minLength: 4,
},
},
},
// ── E. Frontend tools & context ───────────────────────────────────
// These exercise the Slack-side primitives (lookup_slack_user tagging,
// the issue_list/page_list components, the confirm_write HITL gate) and
// verify the context entries arrive at the LLM. The Linear/Notion cases
// need real MCP credentials (see .env.example) — without them the agent
// can chat but can't read or write, and those cases no-op.
{
name: "E-tag-1 — agent uses lookup_slack_user to tag Atai in its reply",
prompt:
'<@U0B45V75NNR> call the lookup_slack_user tool with query "atai" to get my ' +
"real Slack user ID, then reply with a friendly greeting that uses the returned " +
'`mention` string verbatim to tag me. Don\'t just write "Atai" as text.',
sampleIntervalMs: 700,
maxWaitMs: 30_000,
expectations: {
// The agent's reply must contain a real <@USERID> mention for Atai.
// U0FF2X1XXXX is just a sanity-check pattern; the real test is the
// perReplyChecks below.
perReplyChecks: (replies) => {
const errs: string[] = [];
const joined = replies.join("\n");
// 1. Some <@U…> mention appears.
if (!/<@U[A-Z0-9]+>/.test(joined)) {
errs.push("no <@USERID> mention found in any bot reply");
}
// 2. No literal "@atai" text without the angle-bracket syntax
// (would mean the agent didn't use the tool).
if (/\B@atai\b/i.test(joined.replace(/<@[UW][A-Z0-9]+>/g, ""))) {
errs.push("bot wrote `@atai` plaintext instead of using <@USERID>");
}
return errs;
},
},
},
{
name: "E-component-1 — agent renders the issue_list component as a Block Kit card",
// Needs Linear MCP creds: the agent pulls issues, then renders them via
// the issue_list component (a separate blocks message in the thread).
prompt:
"<@U0B45V75NNR> show me the open issues in the CPK team this cycle, " +
"and render them with the issue_list component.",
sampleIntervalMs: 700,
maxWaitMs: 45_000,
expectations: {
// conversations.replies includes block-only messages. We assert that
// at least one bot reply carries a section block whose mrkdwn looks
// like an issue row (a CPK-NNN identifier).
perReplyChecks: (_replies, raw) => {
const errs: string[] = [];
const allBlocks = raw.flatMap(
(m) => (m["blocks"] as Array<Record<string, any>> | undefined) ?? [],
);
const hasIssueRow = allBlocks.some(
(b) =>
b["type"] === "section" &&
/CPK-\d+/.test(
String((b["text"] as { text?: string } | undefined)?.text ?? ""),
),
);
if (!hasIssueRow) {
errs.push(
"no issue_list section block with a CPK-NNN identifier was rendered",
);
}
return errs;
},
},
},
{
name: "E-notion-1 — agent searches Notion and renders the page_list component",
// Needs Notion MCP creds.
prompt:
"<@U0B45V75NNR> find any Notion runbooks or postmortems related to a " +
"production outage and render them with the page_list component.",
sampleIntervalMs: 700,
maxWaitMs: 45_000,
expectations: {
perReplyChecks: (_replies, raw) => {
const errs: string[] = [];
const allBlocks = raw.flatMap(
(m) => (m["blocks"] as Array<Record<string, any>> | undefined) ?? [],
);
const hasPageRow = allBlocks.some(
(b) =>
b["type"] === "section" &&
String(
(b["text"] as { text?: string } | undefined)?.text ?? "",
).includes(":page_facing_up:"),
);
if (!hasPageRow) {
errs.push("no page_list section block was rendered");
}
return errs;
},
},
},
{
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.)
prompt:
'<@U0B45V75NNR> file a Linear issue titled "Checkout 500s under load". ' +
"Use the confirm_write tool to ask me to approve it first.",
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}`,
);
return errs;
}
let sawConfirmTrue = false;
for (const btn of buttons) {
if (!btn.value) {
errs.push(`button action_id=${btn.action_id} 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}`,
);
}
} catch (e) {
errs.push(
`button action_id=${btn.action_id} value isn't valid JSON: ${(e as Error).message}`,
);
}
}
if (!sawConfirmTrue) {
errs.push(
"no button encoded { confirmed: true } (the Create button)",
);
}
return errs;
},
},
},
{
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.
prompt:
'<@U0B45V75NNR> file a Linear issue titled "Test from e2e". Call the ' +
"confirm_write tool to 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;
},
},
},
{
name: "E-context-1 — Slack-usage context is delivered to the LLM",
// Asks the agent to quote from its App Context. If `runAgent({context})` is
// plumbed through and the CopilotKit middleware injects it as a system
// message, the agent will quote a recognisable phrase from
// slackUsageContext. If context isn't being plumbed, the agent has no
// way to know the exact wording.
prompt:
"<@U0B45V75NNR> in one short line: what does your App Context tell you " +
"about how to @-mention people on Slack? Quote the most relevant sentence verbatim.",
sampleIntervalMs: 700,
maxWaitMs: 20_000,
expectations: {
// Any phrase that appears verbatim in slackUsageContext is fine —
// the only way the LLM could quote these strings is from the
// context entries actually being delivered.
perReplyChecks: (replies) => {
const joined = replies.join("\n");
const witnesses = [
"<@USERID>",
"lookup_slack_user",
"<@U05PN5700P9>",
"@-mention",
];
if (witnesses.some((w) => joined.includes(w))) return [];
return [
`final reply quoted no context phrase from ${JSON.stringify(witnesses)}`,
];
},
},
},
// ── F. Loop / echo / subtype filters ──────────────────────────────
{
name: "F-edit — editing a previous message must NOT re-trigger",
prompt: "<@U0B45V75NNR> please respond just ONCE and stop",
// The harness edits the just-sent message and verifies bot does not produce a second reply.
expectations: { minLength: 2 },
},
];
+261
View File
@@ -0,0 +1,261 @@
/**
* 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);
});
+440
View File
@@ -0,0 +1,440 @@
/**
* 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);
});
+400
View File
@@ -0,0 +1,400 @@
/**
* E2E harness entrypoint — API-first.
*
* Sends real user messages to `#ag-ui-bot-test` via `chat.postMessage` with
* Atai's user token (xoxp-, in `.env` as SLACK_USER_TOKEN), then samples
* the bot's reply via the Slack API while it streams. Each sample records
* `{ elapsedMs, len, preview, balanced }` so we can see the message
* evolve over time — bracket balance is asserted at every sample so the
* auto-close streaming polish is verified mid-flight, not just at the end.
*
* No browser dependency. Run with: pnpm e2e
*/
import "dotenv/config";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { CASES } from "./cases.js";
import type { E2ECase } from "./cases.js";
import {
postAsUser,
watchForReply,
watchForNextReply,
watchForChannelReply,
channelHistory,
threadReplies,
isBalanced,
USER_TOKEN,
BOT_USER_ID,
} from "./slack-api.js";
const RESULTS_DIR = "./e2e/results";
const TEST_CHANNEL = process.env.E2E_CHANNEL ?? "C0B49MEJ1HQ"; // #ag-ui-bot-test
interface CaseResult {
name: string;
prompt: string;
status: "pass" | "fail";
errors: string[];
durationMs: number;
finalText: string | undefined;
unbalancedSamples: number;
samples: {
elapsedMs: number;
balanced: boolean;
len: number;
preview: string;
/** Full text (only stored for UNBALANCED samples to keep the report small). */
full?: string;
}[];
followUp?: CaseResult;
/** Set when the case has an `interrupt` spec — details on the second reply. */
interrupt?: {
firstReplyText: string | undefined;
secondReplyText: string | undefined;
errors: string[];
};
}
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})`,
);
}
}
async function runCase(spec: E2ECase): Promise<CaseResult> {
const errors: string[] = [];
const samples: CaseResult["samples"] = [];
const t0 = Date.now();
// Mark "now" in the channel timeline so we know which bot replies are ours.
const beforeHist = await channelHistory(TEST_CHANNEL, 1);
const sinceTs = beforeHist[0]?.ts ?? "0";
// Send the prompt as Atai.
const sent = await postAsUser(TEST_CHANNEL, spec.prompt);
const parentTs = (sent as { ts?: string }).ts ?? "";
if (!parentTs) errors.push("postAsUser returned no ts");
// The bot's reply for an @mention goes into a thread off parentTs; for
// a /agent slash command it lands flat in the channel. We can't tell
// from the prompt alone — try the thread first, then fall back to flat.
const flatMode = /^\/agent\b/.test(spec.prompt);
const sampleIntervalMs = spec.sampleIntervalMs ?? 1000;
const maxWaitMs = spec.maxWaitMs ?? 30_000;
let followUpResult: CaseResult | undefined;
// Schedule a mid-stream interrupt if the case asks for one.
let interruptTimer: NodeJS.Timeout | undefined;
if (spec.interrupt && parentTs) {
interruptTimer = setTimeout(() => {
postAsUser(TEST_CHANNEL, spec.interrupt!.prompt, {
threadTs: parentTs,
}).catch((e: Error) =>
errors.push(`interrupt send failed: ${e.message}`),
);
}, spec.interrupt.afterMs);
}
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),
// Capture full text for unbalanced samples so we can diagnose without
// having to re-run. Skipped for balanced samples to keep reports small.
...(text.length > 0 && !balanced ? { full: text } : {}),
});
};
if (interruptTimer === undefined) {
/* no-op */
}
const result = flatMode
? await watchForChannelReply({
channel: TEST_CHANNEL,
sinceTs,
intervalMs: sampleIntervalMs,
timeoutMs: maxWaitMs,
onSample,
})
: await watchForReply({
channel: TEST_CHANNEL,
parentTs,
intervalMs: sampleIntervalMs,
timeoutMs: maxWaitMs,
onSample,
});
const finalText = result.finalText;
const exp = spec.expectations ?? {};
// Skip top-level expectations on the FIRST reply if this is an interrupt
// case — the first reply is intentionally short/interrupted; the
// assertions live under `spec.interrupt.firstExpectations`.
if (!spec.interrupt) runExpectations(exp, finalText, errors);
// Cross-stream assertion: every sample with text must be balanced.
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`);
}
// Table alignment check: when wrapping a GFM table in a fence, all
// table rows inside the fence should have identical line length.
if (exp.monospaceAlignedTable && finalText) {
const tableRows = finalText
.split("\n")
.filter((l) => l.trim().startsWith("|") && l.trim().endsWith("|"));
if (tableRows.length < 2) {
errors.push("monospaceAlignedTable: didn't find ≥2 table rows");
} else {
const lengths = new Set(tableRows.map((l) => l.length));
if (lengths.size !== 1) {
errors.push(
`monospaceAlignedTable: rows not aligned (lengths: ${[...lengths].join(", ")})`,
);
}
}
}
// Per-reply checks: fetch ALL bot replies in the thread, not just the first.
if (exp.perReplyChecks && parentTs) {
try {
const all = await threadReplies(TEST_CHANNEL, parentTs);
const botRaw = all.filter((m) => m.user === BOT_USER_ID);
const botMsgs = botRaw.map((m) => m.text ?? "");
for (const e of exp.perReplyChecks(
botMsgs,
botRaw as unknown as Array<Record<string, unknown>>,
))
errors.push(e);
} catch (err) {
errors.push(`perReplyChecks fetch failed: ${(err as Error).message}`);
}
}
// Interrupt scenario: a second message is sent mid-stream and should
// abort the first reply (marker `_(interrupted)_` in the partial), then
// produce a fresh second reply in the same thread.
let interruptResult: CaseResult["interrupt"];
if (spec.interrupt && parentTs) {
const iErrors: string[] = [];
// Wait for the second bot reply to land.
await new Promise((r) => setTimeout(r, 10000));
const replies = await threadReplies(TEST_CHANNEL, parentTs);
const botReplies = replies.filter((m) => m.user === BOT_USER_ID);
// The bot may post intermediate status messages (`:warning:`, etc.)
// that aren't the actual reply we want to assert against. Filter
// those out and use first vs last as "the interrupted reply" and
// "the new reply".
const isStatus = (t: string) =>
t.startsWith(":warning:") ||
t.startsWith(":wrench:") ||
t.startsWith(":white_check_mark:");
const meaningful = botReplies.filter((m) => !isStatus(m.text ?? ""));
const firstReply = meaningful[0]?.text;
const secondReply = meaningful[meaningful.length - 1]?.text;
const sameMessage = meaningful.length === 1;
if (!firstReply) iErrors.push("no first bot reply found");
if (sameMessage)
iErrors.push(
"only one meaningful bot reply — interrupt didn't produce a new turn",
);
if (spec.interrupt.firstExpectations) {
runExpectations(
spec.interrupt.firstExpectations,
firstReply,
iErrors,
"first",
);
}
if (spec.interrupt.expectations) {
runExpectations(
spec.interrupt.expectations,
secondReply,
iErrors,
"second",
);
}
interruptResult = {
firstReplyText: firstReply,
secondReplyText: secondReply,
errors: iErrors,
};
errors.push(...iErrors);
}
// If there's a follow-up, send it as a thread reply (no @mention) into
// the same thread the first prompt created.
if (spec.followUp && parentTs && finalText) {
const followErrors: string[] = [];
const followSamples: CaseResult["samples"] = [];
const f0 = Date.now();
// Count existing bot replies BEFORE sending the follow-up so we can
// watch specifically for a NEW one.
const existing = await threadReplies(TEST_CHANNEL, parentTs);
const seenCount = existing.filter((m) => m.user === BOT_USER_ID).length;
await postAsUser(TEST_CHANNEL, spec.followUp.prompt, {
threadTs: parentTs,
}).catch((e: Error) =>
followErrors.push(`followUp send failed: ${e.message}`),
);
const f = await watchForNextReply({
channel: TEST_CHANNEL,
parentTs,
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 fexp = spec.followUp.expectations ?? {};
const followText = f.finalText;
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,
unbalancedSamples: followSamples.filter((s) => s.len > 0 && !s.balanced)
.length,
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,
unbalancedSamples,
samples,
followUp: followUpResult,
interrupt: interruptResult,
};
}
async function main() {
if (!USER_TOKEN) {
console.error(
"SLACK_USER_TOKEN missing in .env — run `pnpm exec tsx e2e/grab-user-token.ts` first.",
);
process.exit(1);
}
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[] = [];
// Optional substring filter on case name — convenient when iterating
// on a single case (e.g. `CASE_FILTER='G1 ' pnpm e2e`).
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} unbalanced=${r.unbalancedSamples}`,
);
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} unbalanced=${r.followUp.unbalancedSamples}`,
);
if (r.followUp.errors.length)
console.log(" " + r.followUp.errors.join("\n "));
}
if (r.interrupt) {
console.log(
` ↳ interrupt first_len=${r.interrupt.firstReplyText?.length ?? 0} second_len=${r.interrupt.secondReplyText?.length ?? 0}`,
);
if (r.interrupt.errors.length)
console.log(" " + r.interrupt.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,
unbalancedSamples: 0,
samples: [],
});
}
}
writeFileSync(
join(runDir, "report.json"),
JSON.stringify({ ranAt: stamp, botUserId: BOT_USER_ID, 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);
});
+264
View File
@@ -0,0 +1,264 @@
/**
* Slack API helpers used by the E2E harness. The bot token does
* read-side work (channel history, thread replies) and the optional
* USER token (xoxp-) lets us post AS Atai so the bot's loop guard
* doesn't skip the message — i.e. fully API-driven E2E with no
* browser dependency on the send path.
*/
import "dotenv/config";
const BOT_TOKEN = process.env.SLACK_BOT_TOKEN;
if (!BOT_TOKEN) throw new Error("SLACK_BOT_TOKEN missing in .env");
export const USER_TOKEN: string | undefined = process.env.SLACK_USER_TOKEN;
export const BOT_USER_ID = process.env.BOT_USER_ID ?? "U0B45V75NNR";
const ENDPOINT = "https://slack.com/api/";
async function slack(
method: string,
params: Record<string, unknown> = {},
token = BOT_TOKEN,
): Promise<Record<string, unknown> & { ok: boolean }> {
// Slack's Web API accepts form-encoded bodies on every method.
// JSON body is rejected by read endpoints like conversations.replies.
const form = new URLSearchParams();
for (const [k, v] of Object.entries(params)) form.set(k, String(v));
const res = await fetch(`${ENDPOINT}${method}`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
},
body: form.toString(),
});
const json = (await res.json()) as Record<string, unknown> & { ok: boolean };
if (!json.ok)
throw new Error(`slack ${method} failed: ${JSON.stringify(json)}`);
return json;
}
export async function postAsUser(
channel: string,
text: string,
opts: { threadTs?: string } = {},
) {
if (!USER_TOKEN) {
throw new Error(
"SLACK_USER_TOKEN missing — run `pnpm exec tsx e2e/grab-user-token.ts` first",
);
}
// `link_names: 1` makes Slack resolve `@username` (and `@here`/`@channel`)
// in the post body into real mention tokens — without this, the bot's
// `app_mention` event doesn't fire for plain-text "@CopilotKit AG-UI Bot".
const params: Record<string, unknown> = { channel, text, link_names: 1 };
if (opts.threadTs) params.thread_ts = opts.threadTs;
return slack("chat.postMessage", params, USER_TOKEN);
}
export async function channelHistory(channel: string, limit = 10) {
const r = await slack("conversations.history", { channel, limit });
return r.messages as SlackMessage[];
}
export async function threadReplies(
channel: string,
ts: string,
includeMetadata = false,
) {
const params: Record<string, string | boolean> = { channel, ts };
if (includeMetadata) params.include_all_metadata = true;
const r = await slack("conversations.replies", params);
return r.messages as SlackMessage[];
}
export interface SlackMessage {
ts: string;
user?: string;
bot_id?: string;
text?: string;
thread_ts?: string;
reply_count?: number;
blocks?: Array<Record<string, any>>;
metadata?: { event_type?: string; event_payload?: Record<string, any> };
}
/**
* Watch a thread for the bot's reply. Polls `conversations.replies` every
* `intervalMs`; calls `onSample` after each poll so the caller can record
* mid-stream snapshots. Resolves after `timeoutMs` or when the reply has
* settled (no length change across two consecutive samples).
*/
export async function watchForReply(args: {
channel: string;
parentTs: string;
intervalMs: number;
timeoutMs: number;
onSample: (sample: {
elapsedMs: number;
text: string | undefined;
message: SlackMessage | undefined;
}) => Promise<void> | void;
}): Promise<{
finalText: string | undefined;
finalMessage: SlackMessage | undefined;
}> {
const start = Date.now();
let lastMessage: SlackMessage | undefined;
let stableSamples = 0;
let lastLen = -1;
while (Date.now() - start < args.timeoutMs) {
const replies = await threadReplies(args.channel, args.parentTs);
// The first bot reply in the thread.
lastMessage = replies.find((m) => m.user === BOT_USER_ID);
const text = lastMessage?.text;
await args.onSample({
elapsedMs: Date.now() - start,
text,
message: lastMessage,
});
const len = text?.length ?? 0;
if (len === lastLen && len > 0) {
stableSamples++;
// After 3 consecutive stable samples, assume the stream has settled.
if (stableSamples >= 3) break;
} else {
stableSamples = 0;
lastLen = len;
}
await new Promise((r) => setTimeout(r, args.intervalMs));
}
return { finalText: lastMessage?.text, finalMessage: lastMessage };
}
/**
* Wait for a NEW bot reply in the thread, beyond the first `seenCount`
* replies that already exist. Used by the harness's follow-up step so it
* doesn't keep reporting the first (parent) reply.
*/
export async function watchForNextReply(args: {
channel: string;
parentTs: string;
seenCount: number;
intervalMs: number;
timeoutMs: number;
onSample: (sample: {
elapsedMs: number;
text: string | undefined;
message: SlackMessage | undefined;
}) => Promise<void> | void;
}): Promise<{
finalText: string | undefined;
finalMessage: SlackMessage | undefined;
}> {
const start = Date.now();
let target: SlackMessage | undefined;
let stable = 0;
let lastLen = -1;
while (Date.now() - start < args.timeoutMs) {
const replies = await threadReplies(args.channel, args.parentTs);
const bot = replies.filter((m) => m.user === BOT_USER_ID);
target = bot.length > args.seenCount ? bot[bot.length - 1] : 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));
}
return { finalText: target?.text, finalMessage: target };
}
/**
* Looser sibling of watchForReply for cases where the reply is in the
* channel directly (DMs / slash commands) rather than threaded.
*/
export async function watchForChannelReply(args: {
channel: string;
sinceTs: string;
intervalMs: number;
timeoutMs: number;
onSample: (sample: {
elapsedMs: number;
text: string | undefined;
message: SlackMessage | undefined;
}) => Promise<void> | void;
}): Promise<{
finalText: string | undefined;
finalMessage: SlackMessage | undefined;
}> {
const start = Date.now();
let lastMessage: SlackMessage | undefined;
let stable = 0;
let lastLen = -1;
while (Date.now() - start < args.timeoutMs) {
const history = await channelHistory(args.channel, 5);
lastMessage = history.find(
(m) => m.user === BOT_USER_ID && Number(m.ts) > Number(args.sinceTs),
);
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));
}
return { finalText: lastMessage?.text, finalMessage: lastMessage };
}
/**
* Bracket-balance check.
*
* Streaming subtlety: when the agent has *just opened* a fence
* (e.g. ``` ```python ``` with no content yet, or ``` ```python\n ```), the
* buffer has an odd number of ``` but visually that's fine — Slack
* renders it as an empty/transient code block, content fills in within
* a moment, and autoCloseOpenMarkdown intentionally does NOT close
* because adding ``` would produce a flicker.
*
* We treat such "just-opened" markers as balanced. A truly unbalanced
* fence is one with real content (non-whitespace past the optional
* language line) but no closer.
*/
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; // real content past the lang line
// else: 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; // real content past the open backtick
}
return true;
}
+413
View File
@@ -0,0 +1,413 @@
/**
* 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;
}
+174
View File
@@ -0,0 +1,174 @@
/**
* 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"] },
},
},
];
+346
View File
@@ -0,0 +1,346 @@
/**
* 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);
});
+46
View File
@@ -0,0 +1,46 @@
{
"name": "slack-example",
"version": "0.0.1",
"private": true,
"description": "Runnable demo for @copilotkit/channels-slack, @copilotkit/channels-discord, and @copilotkit/channels-telegram — an on-call triage bot (Slack, Discord, and/or Telegram from one shared app) backed by Linear + Notion MCP (query/file issues, find/write Notion pages), with a confirm-before-write HITL gate and e2e harnesses.",
"license": "MIT",
"type": "module",
"scripts": {
"dev": "tsx watch app/index.ts",
"start": "tsx app/index.ts",
"channel": "tsx app/managed.ts",
"build": "pnpm exec nx run-many -t build -p \"@copilotkit/channels*\" @copilotkit/runtime",
"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"
},
"dependencies": {
"@copilotkit/channels": "workspace:*",
"@copilotkit/channels-discord": "workspace:*",
"@copilotkit/channels-intelligence": "workspace:*",
"@copilotkit/channels-slack": "workspace:*",
"@copilotkit/channels-telegram": "workspace:*",
"@copilotkit/channels-ui": "workspace:*",
"@copilotkit/channels-whatsapp": "workspace:*",
"@copilotkit/runtime": "workspace:*",
"@tanstack/ai": "^0.32.0",
"@tanstack/ai-mcp": "^0.1.3",
"@tanstack/ai-openai": "^0.15.2",
"@slack/bolt": "^4.2.0",
"@slack/types": "^2.21.1",
"playwright": "^1.49.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@notionhq/notion-mcp-server": "^2.2.1",
"@types/node": "^22.10.0",
"dotenv": "^16.4.5",
"tsx": "^4.19.2",
"typescript": "^5.6.3",
"vitest": "^4.1.3"
}
}
+346
View File
@@ -0,0 +1,346 @@
/**
* Agent backend for the Slack triage assistant.
*
* This is the brain behind the Slack 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.
*
* What it does
* ------------
* The agent connects to **Linear** and **Notion** via their MCP servers
* and acts as an on-call / triage assistant inside Slack: 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 Slack-side primitives (read_thread, the confirm_write HITL picker,
* the issue/page Block Kit 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`):
* 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 and swallow a late failure
* so it can't surface as an unhandled rejection after we've moved on.
*/
async function connectMcp(transport: McpHttpTransport) {
const connecting = createMCPClient({ transport });
connecting.catch(() => {}); // late reject (post-timeout) must not crash the process
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() => 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(
"[slack-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 an on-call triage assistant living in a Slack 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 Slack 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 Slack 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 Slack 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 dynamic and `openaiText` types its argument to
// the known OpenAI model literals.
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);
console.error(
`[slack-runtime] MCP "${transports[i]!.name}" unavailable this turn:`,
(result.reason as Error)?.message ?? result.reason,
);
}
});
// 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 Slack 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 as never[]),
],
...(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 port = Number(process.env["PORT"] ?? 8200);
createServer(listener).listen(port, () => {
console.log(
`[slack-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(
`[slack-runtime] agent "triage" ready · MCP: ${
connected.length ? connected.join(", ") : "none"
}`,
);
});
@@ -0,0 +1,83 @@
/**
* Starts the official Notion MCP server as a Streamable-HTTP sidecar for
* the triage agent (see `runtime.ts`). Run with `pnpm notion-mcp`.
*
* Why a launcher instead of a raw npm script: the Notion server takes its
* Notion integration secret via the `NOTION_TOKEN` env var and its HTTP
* transport bearer via `AUTH_TOKEN` (or `--auth-token`). We keep the
* example's env in a single `.env` (`NOTION_TOKEN` + `NOTION_MCP_AUTH_TOKEN`)
* and map it here, so this 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 in runtime.ts
// (default http://127.0.0.1:3001/mcp).
const port = process.env["NOTION_MCP_PORT"] ?? "3001";
// Notion's REST API requires a `Notion-Version` header. Authenticate via
// OPENAPI_MCP_HEADERS (carrying BOTH Authorization and Notion-Version) rather
// than NOTION_TOKEN: when NOTION_TOKEN is set the server builds its own auth
// header and omits the version, so the API rejects every call with
// 400 `missing_version`. We deliberately do NOT pass NOTION_TOKEN below.
const notionVersion = process.env["NOTION_VERSION"] ?? "2022-06-28";
const openApiHeaders = JSON.stringify({
Authorization: `Bearer ${notionToken}`,
"Notion-Version": notionVersion,
});
// OPENAPI_MCP_HEADERS is the sole Notion auth source. NOTION_TOKEN must be
// absent from the child env (dotenv loaded it into process.env, so delete it
// after the spread) — if present, the server ignores OPENAPI_MCP_HEADERS and
// drops the Notion-Version header.
const childEnv = {
...process.env,
OPENAPI_MCP_HEADERS: openApiHeaders,
AUTH_TOKEN: authToken,
};
delete childEnv["NOTION_TOKEN"];
const child = spawn(
"npx",
[
"-y",
"@notionhq/notion-mcp-server",
"--transport",
"http",
"--port",
port,
"--auth-token",
authToken,
],
{
stdio: "inherit",
// shell:true so Windows resolves `npx` -> `npx.cmd`. Node refuses to
// spawn `.cmd`/`.bat` directly without a shell (CVE-2024-27980), which
// otherwise fails with `spawn EINVAL`.
shell: true,
env: childEnv,
},
);
child.on("exit", (code) => process.exit(code ?? 0));
process.on("SIGINT", () => child.kill("SIGINT"));
process.on("SIGTERM", () => child.kill("SIGTERM"));
+103
View File
@@ -0,0 +1,103 @@
{
"display_information": {
"name": "CopilotKit Triage",
"description": "On-call triage assistant \u2014 query/file Linear issues and find/write Notion pages from Slack.",
"background_color": "#1a1a1a"
},
"features": {
"app_home": {
"home_tab_enabled": false,
"messages_tab_enabled": true,
"messages_tab_read_only_enabled": false
},
"bot_user": {
"display_name": "CopilotKit Triage",
"always_online": true
},
"assistant_view": {
"assistant_description": "On-call triage assistant — query/file Linear issues and find/write Notion pages.",
"suggested_prompts": [
{
"title": "Triage my open issues",
"message": "Triage my open issues"
},
{
"title": "What shipped this week?",
"message": "Summarize what shipped this week"
}
]
},
"slash_commands": [
{
"command": "/agent",
"description": "Talk to the AG-UI agent from any channel",
"usage_hint": "<your message>",
"should_escape": false
},
{
"command": "/triage",
"description": "Triage this thread \u2014 summarize and propose Linear issues",
"usage_hint": "",
"should_escape": false
},
{
"command": "/preview",
"description": "Privately preview the issue I'd file",
"usage_hint": "<issue title>",
"should_escape": false
},
{
"command": "/file-issue",
"description": "Open a form to file a Linear issue",
"usage_hint": "",
"should_escape": false
}
]
},
"oauth_config": {
"scopes": {
"user": ["chat:write"],
"bot": [
"app_mentions:read",
"assistant:write",
"channels:history",
"groups:history",
"im:history",
"mpim:history",
"channels:read",
"groups:read",
"im:read",
"mpim:read",
"users:read",
"users:read.email",
"team:read",
"chat:write",
"chat:write.public",
"chat:write.customize",
"im:write",
"mpim:write",
"files:read",
"files:write",
"channels:join",
"commands"
]
}
},
"settings": {
"event_subscriptions": {
"bot_events": [
"app_mention",
"assistant_thread_started",
"assistant_thread_context_changed",
"message.im",
"message.mpim"
]
},
"interactivity": {
"is_enabled": true
},
"socket_mode_enabled": true,
"org_deploy_enabled": false,
"token_rotation_enabled": false
}
}
+88
View File
@@ -0,0 +1,88 @@
display_information:
name: CopilotKit Triage
description: On-call triage assistant — query/file Linear issues and find/write Notion pages from Slack.
background_color: "#1a1a1a"
features:
bot_user:
display_name: CopilotKit Triage
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
# been turned off").
app_home:
home_tab_enabled: false
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.
assistant_view:
assistant_description: On-call triage assistant — query/file Linear issues and find/write Notion pages.
suggested_prompts:
- title: Triage my open issues
message: Triage my open issues
- title: What shipped this week?
message: Summarize what shipped this week
slash_commands:
- command: /agent
description: Talk to the AG-UI agent from any channel
usage_hint: "<your message>"
should_escape: false
- command: /triage
description: Triage this thread — summarize and propose Linear issues
usage_hint: ""
should_escape: false
- command: /preview
description: Privately preview the issue I'd file
usage_hint: "<issue title>"
should_escape: false
- command: /file-issue
description: Open a form to file a Linear issue
usage_hint: ""
should_escape: false
oauth_config:
scopes:
user:
- chat:write # user-token write access (e.g. grab-user-token.ts for e2e)
bot:
# --- Read what the user said ---
- app_mentions:read # see @mentions of the bot
- assistant:write # assistant pane: status, title, suggested prompts
- channels:history # read messages in public channels bot is in
- groups:history # read messages in private channels bot is in
- im:history # read DMs
- mpim:history # read group DMs
- channels:read # list/lookup public channels (names, members)
- groups:read # list/lookup private channels
- im:read # list DMs
- mpim:read # list group DMs
- users:read # resolve user IDs to names for context
- users:read.email # resolve user emails to match Slack users to Linear/Notion
- team:read # workspace info
# --- Write replies and generative UI ---
- chat:write # post and edit messages in channels bot is in
- chat:write.public # post in channels bot isn't a member of
- chat:write.customize # per-message username/icon (handy for branding)
- im:write # open DMs
- mpim:write # open group DMs
# --- Files (for future image/file passing) ---
- files:read
- files:write
# --- Bot can join channels on its own ---
- channels:join
# --- Slash commands ---
- commands
settings:
event_subscriptions:
bot_events:
- app_mention
- assistant_thread_started # user opened the assistant pane
- assistant_thread_context_changed # the channel/context the user is viewing
- message.im # DMs to the bot + assistant-pane messages
- message.mpim # messages in group DMs the bot is in
# Add message.channels/message.groups only when using
# respondTo.threadReplies: "afterBotReply" for legacy plain thread continuation.
interactivity:
is_enabled: true
socket_mode_enabled: true
org_deploy_enabled: false
token_rotation_enabled: false
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"esModuleInterop": true,
"isolatedModules": true,
"module": "NodeNext",
"moduleDetection": "force",
"moduleResolution": "NodeNext",
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2022",
"noEmit": true,
"lib": ["es2022", "dom"],
"types": ["node"],
"jsx": "react-jsx",
"jsxImportSource": "@copilotkit/channels-ui"
},
"include": ["app/**/*.ts", "app/**/*.tsx", "runtime.ts"],
"exclude": ["node_modules", "agent", "e2e"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
esbuild: {
jsx: "automatic",
jsxImportSource: "@copilotkit/channels-ui",
},
test: {
include: ["app/**/*.test.ts", "app/**/*.test.tsx"],
},
});