chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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.`;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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.";
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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.";
|
||||
},
|
||||
});
|
||||
@@ -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.";
|
||||
},
|
||||
});
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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 },
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"] },
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user