From 97c51241a0de0602bce193d45439b634de392ebe Mon Sep 17 00:00:00 2001 From: Tyler Slaton Date: Mon, 27 Jul 2026 14:26:03 -0400 Subject: [PATCH 01/19] Migrate Channels runtime to canonical server --- .env.example | 88 ++-- app/agent.test.ts | 91 ++++ app/agent.ts | 48 ++ app/channel.test.ts | 186 ++++++++ app/channel.ts | 78 ++++ app/commands/__tests__/commands.test.ts | 17 +- app/commands/index.ts | 42 +- app/components/__tests__/components.test.tsx | 115 +---- app/components/_status.ts | 2 +- app/components/index.ts | 2 +- app/components/issue-card.tsx | 8 +- app/components/issue-list.tsx | 13 +- app/components/page-list.tsx | 16 +- app/context/app-context.ts | 6 +- app/env.test.ts | 95 ++++ app/env.ts | 67 +++ .../__tests__/confirm-write-tool.test.tsx | 6 +- .../__tests__/confirm-write.test.tsx | 25 +- app/human-in-the-loop/confirm-write-tool.tsx | 4 +- app/human-in-the-loop/confirm-write.tsx | 4 +- app/index.ts | 308 ------------ app/managed.test.ts | 137 ------ app/managed.ts | 244 ---------- app/modals/__tests__/file-issue.test.tsx | 13 +- app/modals/file-issue.tsx | 24 +- app/platforms.test.ts | 85 ++++ app/platforms.ts | 72 +++ app/runtime-host.test.ts | 63 +++ app/runtime-host.ts | 37 ++ app/sender-context.test.ts | 16 +- app/sender-context.ts | 10 +- app/server.test.ts | 132 ++++++ app/tools/__tests__/read-thread.test.ts | 6 +- app/tools/__tests__/render-table.test.tsx | 4 +- app/tools/__tests__/render-tools.test.ts | 6 +- app/tools/__tests__/render-tools.test.tsx | 4 +- app/tools/__tests__/showcase-tools.test.tsx | 22 +- app/tools/index.ts | 19 +- app/tools/read-thread.ts | 8 +- app/tools/render-chart.tsx | 5 +- app/tools/render-diagram.tsx | 5 +- app/tools/render-table.tsx | 8 +- app/tools/render-tools.tsx | 12 +- app/tools/showcase-tools.tsx | 16 +- e2e/cases.ts | 10 +- e2e/restart-recovery.ts | 440 ------------------ e2e/telegram-api.ts | 413 ---------------- e2e/telegram-cases.ts | 174 ------- e2e/telegram-run.ts | 346 -------------- package.json | 28 +- pnpm-lock.yaml | 358 ++++++++++---- runtime.ts | 14 +- server.ts | 203 ++++++++ tsconfig.json | 10 +- vitest.config.ts | 8 +- 55 files changed, 1671 insertions(+), 2502 deletions(-) create mode 100644 app/agent.test.ts create mode 100644 app/agent.ts create mode 100644 app/channel.test.ts create mode 100644 app/channel.ts create mode 100644 app/env.test.ts create mode 100644 app/env.ts delete mode 100644 app/index.ts delete mode 100644 app/managed.test.ts delete mode 100644 app/managed.ts create mode 100644 app/platforms.test.ts create mode 100644 app/platforms.ts create mode 100644 app/runtime-host.test.ts create mode 100644 app/runtime-host.ts create mode 100644 app/server.test.ts delete mode 100644 e2e/restart-recovery.ts delete mode 100644 e2e/telegram-api.ts delete mode 100644 e2e/telegram-cases.ts delete mode 100644 e2e/telegram-run.ts create mode 100644 server.ts diff --git a/.env.example b/.env.example index 02996b2..5bb0048 100644 --- a/.env.example +++ b/.env.example @@ -1,56 +1,34 @@ -# Set SLACK_*, DISCORD_*, TELEGRAM_BOT_TOKEN, and/or WHATSAPP_* — the bot starts -# an adapter for each platform whose secrets are present (any one, or several at once). - -# ── 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-... +# ── Canonical Channel host (`pnpm start`) ─────────────────────────────── +# These are the only two required variables. +# AG-UI endpoint for the OpenTag agent. The local alternate TypeScript backend +# (`pnpm runtime`) exposes the example route below. +AGENT_URL=http://localhost:8200/api/copilotkit/agent/triage/run +# AGENT_AUTH_HEADER=Bearer ... +INTELLIGENCE_API_KEY=cpk-... -# ── 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= +# Production defaults; override only for another Intelligence environment. +# INTELLIGENCE_API_URL=https://api.intelligence.copilotkit.ai +# INTELLIGENCE_GATEWAY_WS_URL=wss://realtime.intelligence.copilotkit.ai +# Project-unique Channel name. Defaults to "opentag"; production @kite uses "kite". +# INTELLIGENCE_CHANNEL_NAME=opentag +# HTTP port for the CopilotRuntime listener. Defaults to 3000. +# PORT=3000 -# ── 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= +# ── Optional direct adapters ──────────────────────────────────────────── +# With no direct credentials, Intelligence owns managed Slack delivery. +# Supplying a complete credential pair starts that direct adapter inside the +# same Intelligence-owned runtime. Slack and Teams can run together. -# ── Agent backend (runtime.ts) ────────────────────────────────────────── -# The AG-UI endpoint the bridge POSTs to. REQUIRED — the bridge exits at -# startup if this is unset; there is no code-level fallback. The value below -# is a template pointing at the local CopilotKit runtime started by -# `pnpm runtime`; change it if you deploy the runtime elsewhere. -AGENT_URL=http://localhost:8200/api/copilotkit/agent/triage/run -# Optional auth header forwarded to the agent (for a deployed runtime). -# AGENT_AUTH_HEADER=Bearer ... +# Slack Socket Mode (both values required). +# SLACK_BOT_TOKEN=xoxb-... +# SLACK_APP_TOKEN=xapp-... -# ── Intelligence channel mode — app/managed.ts (`pnpm channel`) ────────── -# Runs the SAME bot over the CopilotKit Intelligence Realtime Gateway instead -# of a native adapter — this process holds NO Slack tokens (Intelligence owns -# the Slack edge). The agent brain reuses AGENT_URL / AGENT_AUTH_HEADER above. -# All values come from your CopilotKit Intelligence project + channel setup. -# Gateway runner WebSocket URL (the /runner endpoint). -# INTELLIGENCE_GATEWAY_WS_URL=wss://dev.intelligence.copilotkit.ai/runner -# Project runtime API key (cpk-{projectId}_...), presented as the socket auth token. -# INTELLIGENCE_API_KEY=cpk-... -# Authoritative org/project/channel scope (from the Intelligence dashboard): -# INTELLIGENCE_ORG_ID=org_... -# INTELLIGENCE_PROJECT_ID=123 -# INTELLIGENCE_CHANNEL_ID=channel_... -# The registered channel name (lowercase kebab). Defaults to "kitebot". -# INTELLIGENCE_CHANNEL_NAME=kitebot -# Optional stable runtime instance id; auto-generated (rti_...) if unset. -# INTELLIGENCE_RUNTIME_INSTANCE_ID=rti_... +# Microsoft Teams / Azure Bot credentials (both values required). +# TEAMS_CLIENT_ID= +# TEAMS_CLIENT_SECRET= +# Optional tenant restriction and direct-adapter HTTP port (default 3978). +# TEAMS_TENANT_ID= +# TEAMS_PORT=3978 # Model for the BuiltInAgent. The runtime is OpenAI-only (it uses OpenAI's # Responses API via TanStack AI's `openaiText` adapter, needed for the @@ -107,15 +85,3 @@ NOTION_MCP_AUTH_TOKEN=choose-a-strong-shared-secret # Notion-Version header the sidecar sends to the Notion API. Defaults to # 2022-06-28; override only if you need a newer Notion API version. # NOTION_VERSION=2022-06-28 - -# ── WhatsApp Cloud API (optional — omit to run Slack-only) ────────────── -# From your Meta App → WhatsApp → API Setup. Leave WHATSAPP_ACCESS_TOKEN blank -# to disable the WhatsApp adapter entirely. Use a System User token in prod -# (the temporary token expires in 24h). -WHATSAPP_ACCESS_TOKEN= -WHATSAPP_PHONE_NUMBER_ID= -WHATSAPP_APP_SECRET= -WHATSAPP_VERIFY_TOKEN=choose-any-string-and-paste-it-in-the-webhook-config -# Webhook path (default /webhook). The server listens on $PORT — Railway injects -# it and routes the service's public domain there; locally it defaults to 3000. -# WHATSAPP_PATH=/webhook diff --git a/app/agent.test.ts b/app/agent.test.ts new file mode 100644 index 0000000..1d6aae8 --- /dev/null +++ b/app/agent.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import type { AgentContentPart, IncomingMessage } from "@copilotkit/channels"; +import { SanitizingHttpAgent } from "@copilotkit/channels/slack"; +import { + buildAgentHeaders, + createAgentFactory, + mentionRunInput, + promptFromMessage, +} from "./agent.js"; + +const message = ( + overrides: Partial = {}, +): IncomingMessage => ({ + text: "hello", + user: { id: "U1", name: "Ada" }, + ref: { id: "m1" }, + platform: "slack", + ...overrides, +}); + +describe("promptFromMessage", () => { + it("prefers non-empty content parts", () => { + const parts: AgentContentPart[] = [{ type: "text", text: "hello" }]; + expect(promptFromMessage(message({ contentParts: parts }))).toBe(parts); + }); + + it("falls back to text when content parts are absent or empty", () => { + expect(promptFromMessage(message())).toBe("hello"); + expect(promptFromMessage(message({ contentParts: [] }))).toBe("hello"); + }); +}); + +describe("mentionRunInput", () => { + it("passes the current managed message explicitly and uses its source platform for sender context", () => { + const parts: AgentContentPart[] = [{ type: "text", text: "from parts" }]; + + expect( + mentionRunInput(message({ contentParts: parts }), "intelligence"), + ).toEqual({ + prompt: parts, + context: [ + { + description: "Requesting slack user", + value: "Ada (slack id U1)", + }, + ], + }); + }); + + it("does not duplicate the current prompt for direct adapter ingress", () => { + expect(mentionRunInput(message(), "slack")).toEqual({ + context: [ + { + description: "Requesting slack user", + value: "Ada (slack id U1)", + }, + ], + }); + }); +}); + +describe("buildAgentHeaders", () => { + it("adds an Authorization header only when configured", () => { + expect(buildAgentHeaders(undefined)).toBeUndefined(); + expect(buildAgentHeaders("Bearer secret")).toEqual({ + Authorization: "Bearer secret", + }); + }); +}); + +describe("createAgentFactory", () => { + it("creates a fresh sanitizing AG-UI agent per thread with optional auth", () => { + const factory = createAgentFactory({ + url: "http://localhost:8123/", + authHeader: "Bearer secret", + }); + + const first = factory("thread-1"); + const second = factory("thread-2"); + + expect(first).toBeInstanceOf(SanitizingHttpAgent); + expect(second).toBeInstanceOf(SanitizingHttpAgent); + expect(second).not.toBe(first); + expect(first).toMatchObject({ + url: "http://localhost:8123/", + headers: { Authorization: "Bearer secret" }, + threadId: "thread-1", + }); + expect(second.threadId).toBe("thread-2"); + }); +}); diff --git a/app/agent.ts b/app/agent.ts new file mode 100644 index 0000000..343157f --- /dev/null +++ b/app/agent.ts @@ -0,0 +1,48 @@ +import type { + AgentContentPart, + ContextEntry, + IncomingMessage, +} from "@copilotkit/channels"; +import { SanitizingHttpAgent } from "@copilotkit/channels/slack"; +import { senderContext } from "./sender-context.js"; + +export function promptFromMessage( + message: Pick, +): string | AgentContentPart[] { + return message.contentParts?.length ? message.contentParts : message.text; +} + +export function buildAgentHeaders( + authHeader?: string, +): { Authorization: string } | undefined { + return authHeader ? { Authorization: authHeader } : undefined; +} + +export function createAgentFactory(options: { + url: string; + authHeader?: string; +}): (threadId: string) => SanitizingHttpAgent { + return (threadId) => { + const agent = new SanitizingHttpAgent({ + url: options.url, + headers: buildAgentHeaders(options.authHeader), + }); + agent.threadId = threadId; + return agent; + }; +} + +export function mentionRunInput( + message: IncomingMessage, + transportPlatform: string, +): { + prompt?: string | AgentContentPart[]; + context: ContextEntry[]; +} { + return { + ...(transportPlatform === "intelligence" + ? { prompt: promptFromMessage(message) } + : {}), + context: senderContext(message.user, message.platform), + }; +} diff --git a/app/channel.test.ts b/app/channel.test.ts new file mode 100644 index 0000000..8bb9aab --- /dev/null +++ b/app/channel.test.ts @@ -0,0 +1,186 @@ +import type { + AgentSubscriber, + RunAgentParameters, + RunAgentResult, +} from "@ag-ui/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { + defineChannelTool, + FakeAdapter, + FakeAgent, + type Channel, + type ContextEntry, +} from "@copilotkit/channels"; +import { z } from "zod"; +import { appContext } from "./context/app-context.js"; +import { appTools } from "./tools/index.js"; +import { createOpenTagChannel } from "./channel.js"; + +class CapturingAgent extends FakeAgent { + readonly calls: Array = []; + + override async runAgent( + parameters?: RunAgentParameters, + subscriber?: AgentSubscriber, + ): Promise { + this.calls.push(parameters); + return super.runAgent(parameters, subscriber); + } +} + +const channels: Channel[] = []; + +afterEach(async () => { + await Promise.all(channels.splice(0).map((channel) => channel.ɵruntime.stop())); +}); + +function makeChannel(options: { + platform: "intelligence" | "slack"; + agent?: FakeAgent; +}) { + const adapter = new FakeAdapter({ platform: options.platform }); + const platformTool = defineChannelTool({ + name: "platform_lookup", + description: "Test platform lookup.", + parameters: z.object({}), + handler: () => "ok", + }); + const platformContext: ContextEntry = { + description: "Platform guidance", + value: "Use native formatting.", + }; + const agent = options.agent ?? new CapturingAgent(); + const channel = createOpenTagChannel({ + name: "opentag", + adapters: [adapter], + agent, + platformTools: [platformTool], + platformContext: [platformContext], + }); + channels.push(channel); + return { adapter, agent, channel, platformContext, platformTool }; +} + +describe("createOpenTagChannel", () => { + it("declares managed Slack and retains app commands", () => { + const channel = createOpenTagChannel({ + name: "custom-channel", + adapters: [], + agent: new FakeAgent(), + platformTools: [], + platformContext: [], + }); + channels.push(channel); + + expect(channel.name).toBe("custom-channel"); + expect(channel.provider).toBe("slack"); + expect(channel.adapters).toEqual([]); + expect(channel.commandNames.sort()).toEqual([ + "agent", + "file_issue", + "preview", + "triage", + ]); + }); + + it("registers app and platform tools/context on agent runs", async () => { + const { adapter, agent, channel, platformContext, platformTool } = + makeChannel({ platform: "slack" }); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "hello", + platform: "slack", + user: { id: "U1", name: "Ada" }, + }); + + const call = (agent as CapturingAgent).calls[0]; + expect(call?.tools?.map(({ name }) => name).sort()).toEqual( + [...appTools.map(({ name }) => name), platformTool.name].sort(), + ); + expect(call?.context).toEqual([ + ...appContext, + platformContext, + { + description: "Requesting slack user", + value: "Ada (slack id U1)", + }, + ]); + }); + + it("injects managed content parts as the current agent prompt", async () => { + const { adapter, agent, channel } = makeChannel({ + platform: "intelligence", + }); + const parts = [{ type: "text" as const, text: "from content parts" }]; + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "fallback text", + contentParts: parts, + platform: "slack", + user: { id: "U1" }, + }); + + expect(agent.messages).toContainEqual( + expect.objectContaining({ role: "user", content: parts }), + ); + }); + + it("does not inject a duplicate prompt for direct adapter ingress", async () => { + const { adapter, agent, channel } = makeChannel({ platform: "slack" }); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "already in direct history", + platform: "slack", + user: { id: "U1" }, + }); + + expect(agent.messages).toEqual([]); + }); + + it("personalizes suggested prompts when a thread starts", async () => { + const { adapter, channel } = makeChannel({ platform: "slack" }); + + await channel.ɵruntime.start(); + await adapter.emitThreadStarted({ + conversationKey: "c1", + replyTarget: {}, + user: { id: "U1", name: "Ada" }, + }); + + expect(adapter.suggestedPromptsCalls[0]?.prompts[0]?.title).toBe( + "Triage Ada's issues", + ); + }); + + it("posts a user-facing error when the agent run fails", async () => { + const error = new Error("agent unavailable"); + const { adapter, channel } = makeChannel({ + platform: "slack", + agent: new FakeAgent([ + () => { + throw error; + }, + ]), + }); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "hello", + platform: "slack", + user: { id: "U1" }, + }); + + expect(JSON.stringify(adapter.posted)).toMatch(/sorry.*error/i); + }); +}); diff --git a/app/channel.ts b/app/channel.ts new file mode 100644 index 0000000..be1157d --- /dev/null +++ b/app/channel.ts @@ -0,0 +1,78 @@ +import type { AbstractAgent } from "@ag-ui/client"; +import { + createChannel, + type Channel, + type ChannelTool, + type ContextEntry, + type PlatformAdapter, +} from "@copilotkit/channels"; +import { mentionRunInput } from "./agent.js"; +import { appCommands } from "./commands/index.js"; +import { IssueCard, IssueList, PageList } from "./components/index.js"; +import { appContext } from "./context/app-context.js"; +import { ConfirmWrite } from "./human-in-the-loop/index.js"; +import { FILE_ISSUE_CALLBACK, fileIssueSubmit } from "./modals/file-issue.js"; +import { appTools } from "./tools/index.js"; + +export interface CreateOpenTagChannelOptions { + name: string; + adapters: PlatformAdapter[]; + agent: AbstractAgent | ((threadId: string) => AbstractAgent); + platformTools: ReadonlyArray; + platformContext: ReadonlyArray; +} + +/** + * Build the single Channel shared by managed Intelligence delivery and + * developer-owned direct adapters. + */ +export function createOpenTagChannel( + options: CreateOpenTagChannelOptions, +): Channel { + const channel = createChannel({ + name: options.name, + provider: "slack", + adapters: options.adapters, + agent: options.agent, + tools: [...appTools, ...options.platformTools], + context: [...appContext, ...options.platformContext], + commands: appCommands, + components: [IssueCard, IssueList, PageList, ConfirmWrite], + }); + + channel.onMention(async ({ thread, message }) => { + try { + await thread.runAgent(mentionRunInput(message, thread.platform)); + } catch (error) { + console.error("[channel] agent run failed", error); + await thread + .post("Sorry — I hit an error handling that. Please try again.") + .catch((postError: unknown) => + console.error("[channel] failed to post agent error", postError), + ); + } + }); + + channel.onModalSubmit(FILE_ISSUE_CALLBACK, fileIssueSubmit); + + channel.onThreadStarted(async ({ thread, user }) => { + if (!user?.name) return; + + try { + await thread.setSuggestedPrompts([ + { + title: `Triage ${user.name}'s issues`, + message: "Triage my open issues", + }, + { + title: "What shipped this week?", + message: "Summarize what shipped this week", + }, + ]); + } catch (error) { + console.error("[channel] onThreadStarted failed", error); + } + }); + + return channel; +} diff --git a/app/commands/__tests__/commands.test.ts b/app/commands/__tests__/commands.test.ts index 7a4a446..8ae0511 100644 --- a/app/commands/__tests__/commands.test.ts +++ b/app/commands/__tests__/commands.test.ts @@ -1,14 +1,13 @@ import { describe, it, expect, vi } from "vitest"; -import { renderToIR } from "@copilotkit/channels-ui"; -import type { BotNode } from "@copilotkit/channels-ui"; +import { renderToIR, type ChannelNode } from "@copilotkit/channels"; import { appCommands } from "../index.js"; import type { CommandContext } from "@copilotkit/channels"; -function tags(node: BotNode | unknown, acc: string[] = []): string[] { +function tags(node: ChannelNode | unknown, acc: string[] = []): string[] { if (!node || typeof node !== "object") return acc; - const n = node as BotNode; + const n = node as ChannelNode; if (typeof n.type === "string") acc.push(n.type); - for (const c of (n.props?.children as BotNode[] | undefined) ?? []) { + for (const c of (n.props?.children as ChannelNode[] | undefined) ?? []) { tags(c, acc); } return acc; @@ -211,8 +210,8 @@ describe("example slash commands", () => { text: "", options: {}, user: { id: "U1" }, - platform: "telegram", - openModal: undefined, // Telegram: no modal trigger + platform: "teams", + openModal: undefined, } as never); expect(post).toHaveBeenCalledWith( expect.stringMatching(/aren.t supported|chat/i), @@ -254,7 +253,7 @@ describe("example slash commands", () => { text: "Login broken", options: {}, user: { id: "U1", name: "Ada" }, - platform: "discord", + platform: "teams", } as never); expect(postEphemeral).toHaveBeenCalledTimes(1); expect(post).toHaveBeenCalledTimes(1); @@ -273,7 +272,7 @@ describe("example slash commands", () => { text: "Login broken", options: {}, user: { id: "U1", name: "Ada" }, - platform: "discord", + platform: "teams", } as never); expect(postEphemeral).toHaveBeenCalledTimes(1); expect(post).toHaveBeenCalledTimes(1); diff --git a/app/commands/index.ts b/app/commands/index.ts index aa0c216..3d8b599 100644 --- a/app/commands/index.ts +++ b/app/commands/index.ts @@ -1,19 +1,20 @@ /** * Slash commands for this bot. Each is registered with the engine via - * `createBot({ commands })`; the Slack adapter forwards every `/command` it + * `createChannel({ commands })`; the Slack adapter forwards every `/command` it * receives and the engine routes by name (ignoring unregistered ones). * * NOTE: a slash command only fires if it's also declared in the Slack app * config ("Slash Commands" / manifest) with the same name — Slack won't * deliver an unregistered command, even over Socket Mode. * - * Args arrive as free text (`ctx.text`) on Slack; `ctx.options` is for - * surfaces with native structured args (e.g. Discord). The `options` schema - * is optional and used there for registration/typing. + * Args arrive as free text (`ctx.text`) on Slack; `ctx.options` is available + * to surfaces that deliver native structured arguments. */ -import { defineBotCommand } from "@copilotkit/channels"; -import type { BotCommand } from "@copilotkit/channels"; -import type { Thread as BotThread } from "@copilotkit/channels-ui"; +import { + defineChannelCommand, + type ChannelCommand, + type Thread as ChannelThread, +} from "@copilotkit/channels"; import { senderContext } from "../sender-context.js"; import { IssueCard } from "../components/index.js"; import { FileIssueModal } from "../modals/file-issue.js"; @@ -27,8 +28,8 @@ import { FileIssueModal } from "../modals/file-issue.js"; */ async function runAgentSafely( commandName: string, - thread: Pick, - input: Parameters[0], + thread: Pick, + input: Parameters[0], ): Promise { try { await thread.runAgent(input); @@ -42,12 +43,12 @@ async function runAgentSafely( } } -export const appCommands: BotCommand[] = [ +export const appCommands: ChannelCommand[] = [ // `/agent ` — a mention-free entry point. (Previously hardcoded in the // adapter; now an ordinary, app-owned command.) Runs the agent with the // command text as the user prompt, since slash-command args are never // posted to the channel for the agent to read from history. - defineBotCommand({ + defineChannelCommand({ name: "agent", description: "Ask the triage agent anything (no @mention needed).", async handler({ thread, text, user }) { @@ -64,7 +65,7 @@ export const appCommands: BotCommand[] = [ // `/triage [note]` — summarize the current channel/thread and propose Linear // issues to file. Demonstrates a command with its own intent. - defineBotCommand({ + defineChannelCommand({ name: "triage", description: "Summarize the conversation and propose Linear issues to file.", @@ -81,11 +82,9 @@ export const appCommands: BotCommand[] = [ // `/preview ` — ephemeral demo. Show the invoker a private draft of the // issue we'd file BEFORE anything is posted publicly or written to Linear. - // `postEphemeral` is capability-gated with an explicit DM fallback: Slack shows - // a native only-you message; Discord and Telegram have no ephemeral surface, so - // `fallbackToDM: true` sends it as a direct message instead. We narrate which - // path was taken so the degradation is visible, never silent. - defineBotCommand({ + // `postEphemeral` is capability-gated with an explicit DM fallback. Slack + // shows a native only-you message; other surfaces report their actual support. + defineChannelCommand({ name: "preview", description: "Privately preview the issue I'd file (only you see it).", async handler({ thread, text, user, platform }) { @@ -125,12 +124,9 @@ export const appCommands: BotCommand[] = [ // `/file-issue` — modal demo. Open a structured issue form, or degrade // honestly where modals aren't available. - // - Slack → rich modal (dropdowns + radio). - // - Discord → text-only modal (discord.js modals take only text inputs); the - // dropdowns/radio drop and defaults apply (see FileIssueModal). - // - Telegram→ no modal trigger at all (`ctx.openModal` is undefined), so we - // say so and continue the same job conversationally via the agent. - defineBotCommand({ + // Slack opens the rich modal. Teams currently has no modal trigger, so we say + // so and continue the same job conversationally via the agent. + defineChannelCommand({ name: "file-issue", description: "Open a form to file a Linear issue.", async handler({ thread, openModal, platform, user }) { diff --git a/app/components/__tests__/components.test.tsx b/app/components/__tests__/components.test.tsx index b3a9681..2741d5b 100644 --- a/app/components/__tests__/components.test.tsx +++ b/app/components/__tests__/components.test.tsx @@ -1,6 +1,6 @@ /** * Block Kit parity tests for the JSX render components. Each component is a - * `@copilotkit/channels-ui` `ComponentFn`; we assert the full + * `@copilotkit/channels` `ComponentFn`; we assert the full * `renderSlackMessage(renderToIR(<… />))` output — both the `blocks` and the * attachment `accent` — against the legacy `defineSlackComponent` shapes. * @@ -12,12 +12,12 @@ * the old `defineSlackComponent` code produced. * * Status/priority glyphs are now platform-neutral unicode (✅ 🔵 🚨 🔴 etc.) - * so they render identically on both Slack and Telegram. + * so they render identically on both Slack and Teams. */ import { describe, it, expect } from "vitest"; -import { renderToIR } from "@copilotkit/channels-ui"; -import { renderSlackMessage } from "@copilotkit/channels-slack"; -import { renderTelegram } from "@copilotkit/channels-telegram"; +import { renderToIR } from "@copilotkit/channels"; +import { renderSlackMessage } from "@copilotkit/channels/slack"; +import { renderAdaptiveCard } from "@copilotkit/channels/teams"; import { IssueList } from "../issue-list.js"; import { IssueCard } from "../issue-card.js"; import { PageList } from "../page-list.js"; @@ -335,119 +335,38 @@ describe("PageList component", () => { }); }); -// ── Telegram parity tests ──────────────────────────────────────────────────── -// These tests render the same IR through renderTelegram and assert that the -// unicode status/priority glyphs appear correctly (no Slack `:shortcode:` -// strings that Telegram would not expand). - -describe("IssueCard Telegram parity", () => { - it("renders unicode status and priority glyphs in Telegram output", () => { - const payload = renderTelegram( +describe("Microsoft Teams rendering parity", () => { + it("renders rich issue and page content as an Adaptive Card", () => { + const issue = renderAdaptiveCard( renderToIR( <IssueCard identifier="CPK-101" title="Checkout 500s under load" - url="https://linear.app/copilotkit/issue/CPK-101" state="In Progress" - assignee="Alem" priority="Urgent" - team="CPK" />, ), ); - // renderTelegram returns a TelegramPayload with a `text` field (HTML string) - // and `parseMode: "HTML"` — confirmed from telegram.test.ts line: - // expect(out.parseMode).toBe("HTML"); - // expect(out.text).toContain("<b>Status</b>"); - expect(typeof payload.text).toBe("string"); - // In-progress maps to the blue dot unicode glyph. - expect(payload.text).toContain("🔵"); - // Urgent priority maps to the siren glyph. - expect(payload.text).toContain("🚨"); - // Identifier and title text must appear in the output. - expect(payload.text).toContain("CPK-101"); - expect(payload.text).toContain("Checkout 500s under load"); - // No Slack mrkdwn shortcodes must appear. - expect(payload.text).not.toContain(":large_blue_circle:"); - expect(payload.text).not.toContain(":rotating_light:"); - }); - - it("renders 'done' unicode glyph for justCreated issue in Telegram output", () => { - const payload = renderTelegram( - renderToIR( - <IssueCard identifier="CPK-200" title="New bug" justCreated />, - ), - ); - expect(typeof payload.text).toBe("string"); - // justCreated uses the check-mark glyph. - expect(payload.text).toContain("✅"); - expect(payload.text).toContain("CPK-200"); - expect(payload.text).toContain("New bug"); - }); -}); - -describe("IssueList Telegram parity", () => { - it("renders unicode status glyphs for each issue in Telegram output", () => { - const payload = renderTelegram( - 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( + const pages = renderAdaptiveCard( renderToIR( <PageList heading="Runbooks" pages={[ { title: "Auth outage runbook", - url: "https://www.notion.so/abc", snippet: "Steps to mitigate auth provider downtime.", - edited: "3d ago", }, ]} />, ), ); - expect(typeof payload.text).toBe("string"); - expect(payload.text).toContain("Auth outage runbook"); - expect(payload.text).toContain("Steps to mitigate auth provider downtime."); + + expect(issue.type).toBe("AdaptiveCard"); + expect(JSON.stringify(issue)).toContain("🔵 CPK-101"); + expect(JSON.stringify(issue)).toContain("🚨 Urgent"); + expect(JSON.stringify(pages)).toContain("Auth outage runbook"); + expect(JSON.stringify(pages)).toContain( + "Steps to mitigate auth provider downtime.", + ); }); }); diff --git a/app/components/_status.ts b/app/components/_status.ts index ccd9238..1dd410b 100644 --- a/app/components/_status.ts +++ b/app/components/_status.ts @@ -1,7 +1,7 @@ /** * Shared status/priority → glyph mapping for the Linear components. The * functions return unicode glyphs (not Slack `:shortcode:` strings), so the - * components render identically on Slack and Telegram. + * components render consistently on Slack and Teams. */ /** Unicode glyph for a Linear workflow-state name. */ diff --git a/app/components/index.ts b/app/components/index.ts index 823bf9d..d9d3faa 100644 --- a/app/components/index.ts +++ b/app/components/index.ts @@ -1,6 +1,6 @@ /** * App-specific render components — agent-renderable Block Kit cards authored - * with the `@copilotkit/channels-ui` JSX vocabulary. + * with the `@copilotkit/channels` 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 diff --git a/app/components/issue-card.tsx b/app/components/issue-card.tsx index fbe9f2f..bf3ba9c 100644 --- a/app/components/issue-card.tsx +++ b/app/components/issue-card.tsx @@ -7,7 +7,7 @@ * Use it for one issue — when the user asks about a specific issue, or * right after creating one (it doubles as the "filed!" confirmation). * - * Authored with the `@copilotkit/channels-ui` JSX vocabulary; the Block Kit + * Authored with the `@copilotkit/channels` JSX vocabulary; the Block Kit * shapes are produced by `renderSlackMessage(renderToIR(<IssueCard .../>))`. */ import { z } from "zod"; @@ -19,8 +19,8 @@ import { Header, Message, Section, -} from "@copilotkit/channels-ui"; -import type { BotNode } from "@copilotkit/channels-ui"; + type ChannelNode, +} from "@copilotkit/channels"; import { accentForIssue, priorityGlyph, stateGlyph } from "./_status.js"; export const issueCardSchema = z.object({ @@ -51,7 +51,7 @@ export const issueCardSchema = z.object({ export type IssueCardProps = z.infer<typeof issueCardSchema>; /** Render ONE Linear issue as a rich Block Kit card. */ -export function IssueCard(issue: IssueCardProps): BotNode { +export function IssueCard(issue: IssueCardProps): ChannelNode { const titleText = issue.url ? `[**${issue.title}**](${issue.url})` : `**${issue.title}**`; diff --git a/app/components/issue-list.tsx b/app/components/issue-list.tsx index 9a3c71f..09785d2 100644 --- a/app/components/issue-list.tsx +++ b/app/components/issue-list.tsx @@ -13,11 +13,16 @@ * it wants shown; the Slack formatting lives here. For a single issue (or * right after creating one) prefer `issue_card`, which shows a full grid. * - * Authored with the `@copilotkit/channels-ui` JSX vocabulary. + * Authored with the `@copilotkit/channels` JSX vocabulary. */ import { z } from "zod"; -import { Context, Header, Message, Section } from "@copilotkit/channels-ui"; -import type { BotNode } from "@copilotkit/channels-ui"; +import { + Context, + Header, + Message, + Section, + type ChannelNode, +} from "@copilotkit/channels"; import { accentForIssues, stateGlyph } from "./_status.js"; const issueSchema = z.object({ @@ -59,7 +64,7 @@ const MAX = 15; const TITLE_MAX = 70; /** Render a list of Linear issues as a compact, fixed-size Block Kit card. */ -export function IssueList({ heading, issues }: IssueListProps): BotNode { +export function IssueList({ heading, issues }: IssueListProps): ChannelNode { const lines = issues.slice(0, MAX).map((issue: Issue) => { const idLink = issue.url ? `[**${issue.identifier}**](${issue.url})` diff --git a/app/components/page-list.tsx b/app/components/page-list.tsx index f7dca33..91fc048 100644 --- a/app/components/page-list.tsx +++ b/app/components/page-list.tsx @@ -13,11 +13,17 @@ * The agent searches Notion via MCP and passes the pages it wants to * surface; the Slack formatting lives here. * - * Authored with the `@copilotkit/channels-ui` JSX vocabulary. + * Authored with the `@copilotkit/channels` JSX vocabulary. */ import { z } from "zod"; -import { Context, Divider, Header, Message, Section } from "@copilotkit/channels-ui"; -import type { BotNode } from "@copilotkit/channels-ui"; +import { + Context, + Divider, + Header, + Message, + Section, + type ChannelNode, +} from "@copilotkit/channels"; import { ACCENT } from "./_status.js"; const pageSchema = z.object({ @@ -49,9 +55,9 @@ type Page = z.infer<typeof pageSchema>; const MAX = 15; /** Render a list of Notion pages as a Block Kit card. */ -export function PageList({ heading, pages }: PageListProps): BotNode { +export function PageList({ heading, pages }: PageListProps): ChannelNode { const shown = pages.slice(0, MAX); - const rows: BotNode[] = []; + const rows: ChannelNode[] = []; shown.forEach((page: Page, i: number) => { const titleLink = page.url ? `[**${page.title}**](${page.url})` diff --git a/app/context/app-context.ts b/app/context/app-context.ts index ebeea11..b0c2a36 100644 --- a/app/context/app-context.ts +++ b/app/context/app-context.ts @@ -1,9 +1,7 @@ /** * App-specific context entries — bot identity, tone, policy. - * Platform tagging/formatting/thread-model guidance ships in each adapter's - * default context (`defaultSlackContext` / `defaultTelegramContext`) and is - * spread per-bot in `app/index.ts`; this file holds platform-neutral identity - * and triage policy only. + * Platform tagging/formatting/thread-model guidance comes from the selected + * platform setup; this file holds platform-neutral identity and triage policy. * * Each entry is `{description, value}`. The SDK forwards them as AG-UI * `context` on every turn; the agent backend surfaces them as a diff --git a/app/env.test.ts b/app/env.test.ts new file mode 100644 index 0000000..b1f9ad2 --- /dev/null +++ b/app/env.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_INTELLIGENCE_API_URL, + DEFAULT_INTELLIGENCE_CHANNEL_NAME, + DEFAULT_INTELLIGENCE_GATEWAY_WS_URL, + parsePort, + readEnvironment, +} from "./env.js"; + +const requiredEnvironment = { + AGENT_URL: "http://localhost:8123/", + INTELLIGENCE_API_KEY: "cpk_test", +}; + +describe("readEnvironment", () => { + it("requires AGENT_URL", () => { + expect(() => + readEnvironment({ INTELLIGENCE_API_KEY: "cpk_test" }), + ).toThrow("Missing required env var: AGENT_URL"); + }); + + it("requires INTELLIGENCE_API_KEY", () => { + expect(() => + readEnvironment({ AGENT_URL: "http://localhost:8123/" }), + ).toThrow("Missing required env var: INTELLIGENCE_API_KEY"); + }); + + it("uses the Intelligence, channel-name, and port defaults", () => { + expect(readEnvironment(requiredEnvironment)).toMatchObject({ + agentUrl: "http://localhost:8123/", + intelligenceApiKey: "cpk_test", + intelligenceApiUrl: DEFAULT_INTELLIGENCE_API_URL, + intelligenceGatewayWsUrl: DEFAULT_INTELLIGENCE_GATEWAY_WS_URL, + channelName: DEFAULT_INTELLIGENCE_CHANNEL_NAME, + port: 3000, + teamsPort: 3978, + }); + }); + + it("honors Intelligence URL and channel-name overrides", () => { + expect( + readEnvironment({ + ...requiredEnvironment, + INTELLIGENCE_API_URL: "https://intelligence.example.test", + INTELLIGENCE_GATEWAY_WS_URL: "wss://realtime.example.test", + INTELLIGENCE_CHANNEL_NAME: "custom-channel", + }), + ).toMatchObject({ + intelligenceApiUrl: "https://intelligence.example.test", + intelligenceGatewayWsUrl: "wss://realtime.example.test", + channelName: "custom-channel", + }); + }); + + it("reads direct Teams credentials and validates its listener port", () => { + expect( + readEnvironment({ + ...requiredEnvironment, + TEAMS_CLIENT_ID: "teams-client", + TEAMS_CLIENT_SECRET: "teams-secret", + TEAMS_TENANT_ID: "teams-tenant", + TEAMS_PORT: "4978", + }), + ).toMatchObject({ + teamsClientId: "teams-client", + teamsClientSecret: "teams-secret", + teamsTenantId: "teams-tenant", + teamsPort: 4978, + }); + + expect(() => + readEnvironment({ + ...requiredEnvironment, + TEAMS_PORT: "bad", + }), + ).toThrow('Invalid TEAMS_PORT: "bad"'); + }); +}); + +describe("parsePort", () => { + it("defaults to 3000", () => { + expect(parsePort(undefined)).toBe(3000); + }); + + it("accepts a valid integer port", () => { + expect(parsePort("4242")).toBe(4242); + }); + + it.each(["", "0", "65536", "12.5", "abc"])( + "rejects invalid PORT %j", + (raw) => { + expect(() => parsePort(raw)).toThrow(`Invalid PORT: "${raw}"`); + }, + ); +}); diff --git a/app/env.ts b/app/env.ts new file mode 100644 index 0000000..cf63316 --- /dev/null +++ b/app/env.ts @@ -0,0 +1,67 @@ +export const DEFAULT_INTELLIGENCE_API_URL = + "https://api.intelligence.copilotkit.ai"; +export const DEFAULT_INTELLIGENCE_GATEWAY_WS_URL = + "wss://realtime.intelligence.copilotkit.ai"; +export const DEFAULT_INTELLIGENCE_CHANNEL_NAME = "opentag"; + +export interface AppEnvironment { + agentUrl: string; + agentAuthHeader?: string; + intelligenceApiKey: string; + intelligenceApiUrl: string; + intelligenceGatewayWsUrl: string; + channelName: string; + port: number; + slackBotToken?: string; + slackAppToken?: string; + teamsClientId?: string; + teamsClientSecret?: string; + teamsTenantId?: string; + teamsPort: number; +} + +function required(env: NodeJS.ProcessEnv, name: string): string { + const value = env[name]; + if (!value) { + throw new Error(`Missing required env var: ${name}`); + } + return value; +} + +export function parsePort( + raw: string | undefined, + defaultPort = 3000, + name = "PORT", +): number { + if (raw === undefined) return defaultPort; + + const port = Number(raw); + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error(`Invalid ${name}: "${raw}"`); + } + return port; +} + +export function readEnvironment( + env: NodeJS.ProcessEnv = process.env, +): AppEnvironment { + return { + agentUrl: required(env, "AGENT_URL"), + agentAuthHeader: env.AGENT_AUTH_HEADER, + intelligenceApiKey: required(env, "INTELLIGENCE_API_KEY"), + intelligenceApiUrl: + env.INTELLIGENCE_API_URL ?? DEFAULT_INTELLIGENCE_API_URL, + intelligenceGatewayWsUrl: + env.INTELLIGENCE_GATEWAY_WS_URL ?? + DEFAULT_INTELLIGENCE_GATEWAY_WS_URL, + channelName: + env.INTELLIGENCE_CHANNEL_NAME ?? DEFAULT_INTELLIGENCE_CHANNEL_NAME, + port: parsePort(env.PORT), + slackBotToken: env.SLACK_BOT_TOKEN, + slackAppToken: env.SLACK_APP_TOKEN, + teamsClientId: env.TEAMS_CLIENT_ID, + teamsClientSecret: env.TEAMS_CLIENT_SECRET, + teamsTenantId: env.TEAMS_TENANT_ID, + teamsPort: parsePort(env.TEAMS_PORT, 3978, "TEAMS_PORT"), + }; +} diff --git a/app/human-in-the-loop/__tests__/confirm-write-tool.test.tsx b/app/human-in-the-loop/__tests__/confirm-write-tool.test.tsx index d223b44..9250415 100644 --- a/app/human-in-the-loop/__tests__/confirm-write-tool.test.tsx +++ b/app/human-in-the-loop/__tests__/confirm-write-tool.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from "vitest"; -import { renderToIR, type BotNode } from "@copilotkit/channels-ui"; -import { renderSlackMessage } from "@copilotkit/channels-slack"; +import { renderToIR, type ChannelNode } from "@copilotkit/channels"; +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. */ @@ -32,7 +32,7 @@ describe("confirm_write tool", () => { // 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), + renderToIR(awaited[0] as ChannelNode), ); expect(accent).toBe("#E2B340"); const header = blocks.find((b) => b.type === "header") as diff --git a/app/human-in-the-loop/__tests__/confirm-write.test.tsx b/app/human-in-the-loop/__tests__/confirm-write.test.tsx index fbe98bd..fb2f1dd 100644 --- a/app/human-in-the-loop/__tests__/confirm-write.test.tsx +++ b/app/human-in-the-loop/__tests__/confirm-write.test.tsx @@ -1,35 +1,38 @@ import { describe, it, expect, vi } from "vitest"; import { renderToIR, - type BotNode, + type ChannelNode, type InteractionContext, type ClickHandler, -} from "@copilotkit/channels-ui"; -import { renderSlackMessage } from "@copilotkit/channels-slack"; +} from "@copilotkit/channels"; +import { renderSlackMessage } from "@copilotkit/channels/slack"; import { ConfirmWrite } from "../confirm-write.js"; /** Children of an IR node as an array (empty if none). */ -function childNodes(node: BotNode): BotNode[] { +function childNodes(node: ChannelNode): ChannelNode[] { const children = node.props?.children; - if (Array.isArray(children)) return children as BotNode[]; + if (Array.isArray(children)) return children as ChannelNode[]; if ( children && typeof children === "object" && "type" in (children as object) ) { - return [children as BotNode]; + return [children as ChannelNode]; } return []; } /** Concatenate the text of all descendant `text` nodes (depth-first). */ -function collectText(node: BotNode): string { +function collectText(node: ChannelNode): string { if (node.type === "text") return String(node.props?.value ?? ""); return childNodes(node).map(collectText).join(""); } /** Walk the whole tree to find the first node of a given intrinsic type. */ -function findByType(nodes: BotNode[], type: string): BotNode | undefined { +function findByType( + nodes: ChannelNode[], + type: string, +): ChannelNode | undefined { for (const n of nodes) { if (n.type === type) return n; const hit = findByType(childNodes(n), type); @@ -39,8 +42,8 @@ function findByType(nodes: BotNode[], type: string): BotNode | undefined { } /** All button nodes in the tree. */ -function findButtons(nodes: BotNode[]): BotNode[] { - const out: BotNode[] = []; +function findButtons(nodes: ChannelNode[]): ChannelNode[] { + const out: ChannelNode[] = []; for (const n of nodes) { if (n.type === "button") out.push(n); out.push(...findButtons(childNodes(n))); @@ -48,7 +51,7 @@ function findButtons(nodes: BotNode[]): BotNode[] { return out; } -function buttonByText(ir: BotNode[], text: string): BotNode { +function buttonByText(ir: ChannelNode[], text: string): ChannelNode { const btn = findButtons(ir).find((b) => collectText(b) === text); if (!btn) throw new Error(`button "${text}" not found`); return btn; diff --git a/app/human-in-the-loop/confirm-write-tool.tsx b/app/human-in-the-loop/confirm-write-tool.tsx index 71fa7b1..d47e3a3 100644 --- a/app/human-in-the-loop/confirm-write-tool.tsx +++ b/app/human-in-the-loop/confirm-write-tool.tsx @@ -10,7 +10,7 @@ * performs the write once this returns `{ confirmed: true }`. */ import { z } from "zod"; -import { defineBotTool } from "@copilotkit/channels"; +import { defineChannelTool } from "@copilotkit/channels"; import { ConfirmWrite } from "./confirm-write.js"; export const confirmWriteSchema = z.object({ @@ -27,7 +27,7 @@ export const confirmWriteSchema = z.object({ ), }); -export const confirmWriteTool = defineBotTool({ +export const confirmWriteTool = defineChannelTool({ name: "confirm_write", description: "Ask the user to approve a write before you perform it. Posts a " + diff --git a/app/human-in-the-loop/confirm-write.tsx b/app/human-in-the-loop/confirm-write.tsx index e180aed..be003bb 100644 --- a/app/human-in-the-loop/confirm-write.tsx +++ b/app/human-in-the-loop/confirm-write.tsx @@ -22,8 +22,8 @@ import { Context, Actions, Button, -} from "@copilotkit/channels-ui"; -import type { InteractionContext } from "@copilotkit/channels-ui"; +} from "@copilotkit/channels"; +import type { InteractionContext } from "@copilotkit/channels"; export interface ConfirmWriteProps { /** Short imperative title of the write, e.g. 'Create Linear issue'. */ diff --git a/app/index.ts b/app/index.ts deleted file mode 100644 index c2758a0..0000000 --- a/app/index.ts +++ /dev/null @@ -1,308 +0,0 @@ -/** - * The bot _application_ — user-land code, not SDK code. The companion - * `runtime.ts` holds the AG-UI agent backend (a CopilotKit `BuiltInAgent` - * wired to the Linear + Notion MCP servers); this directory holds everything - * that runs on the chat-platform side of the bot for this deployment. - * - * MULTI-PLATFORM: this single app drives Slack, Discord, Telegram, and/or - * WhatsApp from one process. `@copilotkit/channels`'s `createBot` accepts an array - * of adapters and starts them all, so we include each platform's adapter only - * when its secrets are present. Drop in `SLACK_*` to run Slack, `DISCORD_*` for - * Discord, `TELEGRAM_BOT_TOKEN` for Telegram, `WHATSAPP_*` for WhatsApp — or any - * combination to run them at once. The rest of `app/` (tools, components, HITL, - * rendering) is platform-agnostic and shared verbatim. - * - * Defaults are not auto-applied — you spread them explicitly. That's - * deliberate: there's no hidden behavior, and the canonical pattern is right - * here in the file you copy from to start a new bot. - */ -import "dotenv/config"; -import { createBot } from "@copilotkit/channels"; -import type { PlatformAdapter, BotTool, ContextEntry } from "@copilotkit/channels"; -import { - slack, - defaultSlackTools, - defaultSlackContext, - SanitizingHttpAgent, -} from "@copilotkit/channels-slack"; -import { - discord, - defaultDiscordTools, - defaultDiscordContext, -} from "@copilotkit/channels-discord"; -import { - telegram, - defaultTelegramTools, - defaultTelegramContext, -} from "@copilotkit/channels-telegram"; -import { - whatsapp, - defaultWhatsAppTools, - defaultWhatsAppContext, -} from "@copilotkit/channels-whatsapp"; -import { appTools } from "./tools/index.js"; -import { appContext } from "./context/app-context.js"; -import { appCommands } from "./commands/index.js"; -import { senderContext } from "./sender-context.js"; -import { fileIssueSubmit, FILE_ISSUE_CALLBACK } from "./modals/file-issue.js"; -import { closeBrowser } from "./render/browser.js"; - -const required = (name: string): string => { - const v = process.env[name]; - if (!v) { - console.error(`Missing required env var: ${name}`); - process.exit(1); - } - return v; -}; - -/** True only when every named env var is set and non-empty. */ -const have = (...names: string[]): boolean => - names.every((n) => Boolean(process.env[n])); - -async function main() { - const agentUrl = required("AGENT_URL"); - const agentHeaders = process.env.AGENT_AUTH_HEADER - ? { Authorization: process.env.AGENT_AUTH_HEADER } - : undefined; - - // Build the platform list from whichever secrets are present. Each adapter - // contributes its own built-in tools (e.g. `lookup_slack_user` / - // `lookup_discord_user` / `lookup_telegram_user`) and context (tagging + - // formatting guidance), added only when that platform is active so the model - // isn't handed a different platform's conventions. - const adapters: PlatformAdapter[] = []; - const tools: BotTool[] = [...appTools]; - const context: ContextEntry[] = [...appContext]; - - if (have("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN")) { - adapters.push( - slack({ - botToken: required("SLACK_BOT_TOKEN"), - appToken: required("SLACK_APP_TOKEN"), - // Don't surface tool-call progress in the UI (no task_update timeline, - // `:wrench:` rows, or pane "is using `tool`…" status). Tools still run; - // only the display is hidden. - showToolStatus: false, - // KiteBot keeps DMs conversational and responds to explicit app mentions - // in channels/threads. Plain channel thread replies stay quiet unless - // they mention KiteBot again. - respondTo: { - directMessages: true, - appMentions: { reply: "thread" }, - threadReplies: "mentionsOnly", - }, - // Assistant-pane behavior is ON by default; this just customizes it. - // The greeting + chips show when a user opens the pane (matching the - // app manifest's `assistant_view`); native streaming + status need no - // config. Pass `assistant: false` / `streaming: "legacy"` to opt out. - assistant: { - greeting: "Hi! I can triage issues, search docs, and more.", - suggestedPrompts: [ - { - title: "Triage my open issues", - message: "Triage my open issues", - }, - { - title: "What shipped this week?", - message: "Summarize what shipped this week", - }, - ], - }, - }), - ); - tools.push(...defaultSlackTools); - context.push(...defaultSlackContext); - } - - if (have("DISCORD_BOT_TOKEN", "DISCORD_APP_ID")) { - adapters.push( - discord({ - botToken: required("DISCORD_BOT_TOKEN"), - appId: required("DISCORD_APP_ID"), - // Optional: register slash commands to one guild instantly during dev - // (global commands can take up to ~1h to propagate). Omit in prod. - guildId: process.env.DISCORD_GUILD_ID, - }), - ); - tools.push(...defaultDiscordTools); - context.push(...defaultDiscordContext); - } - - if (have("TELEGRAM_BOT_TOKEN")) { - // Telegram long-polls by default (no public URL / webhook setup needed). - // No greeting/suggestedPrompts: Telegram has no assistant-pane surface. - adapters.push(telegram({ token: required("TELEGRAM_BOT_TOKEN") })); - tools.push(...defaultTelegramTools); - context.push(...defaultTelegramContext); - } - - if ( - have( - "WHATSAPP_ACCESS_TOKEN", - "WHATSAPP_PHONE_NUMBER_ID", - "WHATSAPP_APP_SECRET", - "WHATSAPP_VERIFY_TOKEN", - ) - ) { - // Unlike Slack/Discord (outbound), WhatsApp adds an INBOUND webhook HTTP - // server. It listens on Railway's injected `$PORT` (the public domain - // routes there); locally it defaults to 3000. Fail loud on a malformed - // PORT rather than letting `Number("abc")` → NaN reach `server.listen()`. - const port = process.env.PORT ? Number(process.env.PORT) : 3000; - if (!Number.isInteger(port) || port < 1 || port > 65535) { - console.error( - `Invalid PORT: "${process.env.PORT}" is not a valid port number (must be an integer between 1 and 65535)`, - ); - process.exit(1); - } - adapters.push( - whatsapp({ - accessToken: required("WHATSAPP_ACCESS_TOKEN"), - phoneNumberId: required("WHATSAPP_PHONE_NUMBER_ID"), - appSecret: required("WHATSAPP_APP_SECRET"), - verifyToken: required("WHATSAPP_VERIFY_TOKEN"), - port, - path: process.env.WHATSAPP_PATH ?? "/webhook", - }), - ); - tools.push(...defaultWhatsAppTools); - context.push(...defaultWhatsAppContext); - } - - if (adapters.length === 0) { - console.error( - "No platform secrets found. Set SLACK_BOT_TOKEN + SLACK_APP_TOKEN, " + - "DISCORD_BOT_TOKEN + DISCORD_APP_ID, TELEGRAM_BOT_TOKEN, " + - "and/or the WHATSAPP_* vars (see README).", - ); - process.exit(1); - } - - const bot = createBot({ - adapters, - // One AG-UI agent per conversation. The backend is a CopilotKit - // `BuiltInAgent` (CopilotSseRuntime), which does NOT require a UUID-format - // threadId, so the raw conversation thread id is fine. - // `SanitizingHttpAgent` is a lenient superset of `HttpAgent` (tolerates a - // null `parentMessageId` from `@ag-ui/langgraph`); it's safe for every - // platform, so one factory covers Slack, Discord, Telegram, and WhatsApp alike. - agent: (threadId) => { - const a = new SanitizingHttpAgent({ - url: agentUrl, - headers: agentHeaders, - }); - a.threadId = threadId; - return a; - }, - // `appTools` adds this bot's tools (read_thread, render_*, issue/page - // cards); the per-platform `default*Tools` add `lookup_*_user`. All are - // plain `BotTool`s — the active adapter supplies `thread`/`message`/`user` - // per call. `default*Context` ships tagging/formatting/thread-model - // guidance; `appContext` adds identity + triage policy. - tools, - context, - // Slash commands (`/agent`, `/triage`, `/preview`, `/file-issue`). For Slack - // each must ALSO be declared in the app config (or paste the manifest); Discord - // and Telegram register them up front. The engine routes by name; adapters that - // can't take commands ignore them. - commands: appCommands, - }); - - // The turn handler. Each adapter pre-filters ingress to the turns this bot - // should answer — DMs, explicit mentions, and every WhatsApp message. - // createBot is mention-preferred: a single handler covers them across every - // active platform. `senderContext` names the - // requesting user per `thread.platform`, so the label is correct on whichever - // surface the turn arrived from. Additional feature demos below add their own - // handlers for modal submissions and assistant-pane thread starts. Wrap the - // turn so a failed run (agent backend down, network/auth error) is logged - // and surfaced to the user instead of crashing the process or vanishing - // silently. - bot.onMention(async ({ thread, message }) => { - try { - await thread.runAgent({ - context: senderContext(message.user, thread.platform), - }); - } catch (err) { - console.error("[bot] agent run failed", err); - await thread - .post("Sorry — I hit an error handling that. Please try again.") - .catch((postErr: unknown) => - console.error("[bot] failed to post agent error", postErr), - ); - } - }); - - // Modal demo (cont.) — handle the /file-issue submission. The handler lives in - // `modals/file-issue.tsx` (extracted + unit-tested): it validates, then - // fire-and-forgets the agent run so the submission can be ack'd within Slack's - // ~3s view_submission deadline (awaiting the run blows it → Slack double-files). - bot.onModalSubmit(FILE_ISSUE_CALLBACK, fileIssueSubmit); - - // Slack-only nicety: personalize the assistant-pane prompt chips for the - // opener. Harmless elsewhere — `onThreadStarted` only fires from adapters - // that emit it (Discord/Telegram/WhatsApp have no assistant pane), and - // platforms without suggested-prompt support no-op. - bot.onThreadStarted(async ({ thread, user }) => { - if (!user?.name) return; - try { - await thread.setSuggestedPrompts([ - { - title: `Triage ${user.name}'s issues`, - message: "Triage my open issues", - }, - { - title: "What shipped this week?", - message: "Summarize what shipped this week", - }, - ]); - } catch (err) { - console.error("[bot] onThreadStarted failed", err); - } - }); - - await bot.start(); - console.log( - `[bot] started on: ${adapters.map((a) => a.platform).join(", ")}`, - ); - - const shutdown = async (signal: string) => { - console.log(`\n[bot] received ${signal}, stopping…`); - let exitCode = 0; - try { - await bot.stop(); - } catch (err) { - console.error("[bot] error stopping bot", err); - exitCode = 1; - } - // Tear down the shared headless browser used for chart/diagram rendering. - await closeBrowser().catch((err: unknown) => - console.error("[bot] browser cleanup failed (continuing shutdown)", err), - ); - process.exit(exitCode); - }; - const runShutdown = (signal: string): void => { - shutdown(signal).catch((err: unknown) => { - console.error(`[bot] fatal during ${signal} shutdown`, err); - process.exit(1); - }); - }; - process.on("SIGINT", () => runShutdown("SIGINT")); - process.on("SIGTERM", () => runShutdown("SIGTERM")); -} - -// Fail loud, not silent: surface any stray async error (e.g. a throw deep in an -// interaction/callback path) instead of letting it kill the process with no -// log. Log and keep running — one bad turn shouldn't take the bot down. -process.on("unhandledRejection", (reason) => { - console.error("[bot] unhandledRejection:", reason); -}); -process.on("uncaughtException", (err) => { - console.error("[bot] uncaughtException:", err); -}); - -main().catch((err) => { - console.error("[bot] fatal", err); - process.exit(1); -}); diff --git a/app/managed.test.ts b/app/managed.test.ts deleted file mode 100644 index 6d92d2e..0000000 --- a/app/managed.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, it, expect } from "vitest"; -import type { AgentContentPart } from "@copilotkit/channels-ui"; -import { - createKiteBot, - promptFromMessage, - buildAgentHeaders, - parseProjectId, -} from "./managed.js"; - -describe("createKiteBot", () => { - it("declares the kitebot channel name", () => { - const bot = createKiteBot({ agentUrl: "http://localhost:8200" }); - expect(bot.name).toBe("kitebot"); - }); - - it("registers the app's slash commands on the bot", () => { - const bot = createKiteBot({ agentUrl: "http://localhost:8200" }); - // createBot normalizes slash-command names (hyphens -> underscores): - // app/commands declares "file-issue"; commandNames reports "file_issue". - expect(bot.commandNames).toContain("file_issue"); - }); - - it("honors a custom channel name", () => { - const bot = createKiteBot({ - agentUrl: "http://localhost:8200", - channelName: "kite-bot", - }); - expect(bot.name).toBe("kite-bot"); - }); -}); - -describe("promptFromMessage", () => { - it("returns contentParts when present", () => { - const parts: AgentContentPart[] = [{ type: "text", text: "hi" }]; - expect( - promptFromMessage({ contentParts: parts, text: "hi" }), - ).toBe(parts); - }); - - it("falls back to text when contentParts is empty", () => { - expect(promptFromMessage({ contentParts: [], text: "hello" })).toBe( - "hello", - ); - }); - - it("falls back to text when contentParts is absent", () => { - expect(promptFromMessage({ text: "hello" })).toBe("hello"); - }); -}); - -describe("buildAgentHeaders", () => { - it("returns undefined when no auth header is given", () => { - expect(buildAgentHeaders(undefined)).toBeUndefined(); - }); - - it("wraps the auth header value in an Authorization object", () => { - expect(buildAgentHeaders("Bearer abc123")).toEqual({ - Authorization: "Bearer abc123", - }); - }); -}); - -describe("parseProjectId", () => { - it("parses a valid positive integer string", () => { - expect(parseProjectId("42")).toBe(42); - }); - - it("throws on \"0\"", () => { - expect(() => parseProjectId("0")).toThrow( - /Invalid INTELLIGENCE_PROJECT_ID/, - ); - }); - - it("throws on a non-numeric string", () => { - expect(() => parseProjectId("abc")).toThrow( - /Invalid INTELLIGENCE_PROJECT_ID/, - ); - }); - - it("throws when undefined", () => { - expect(() => parseProjectId(undefined)).toThrow( - /Invalid INTELLIGENCE_PROJECT_ID/, - ); - }); - - it("throws on a negative integer string", () => { - expect(() => parseProjectId("-5")).toThrow( - /Invalid INTELLIGENCE_PROJECT_ID/, - ); - }); - - it("throws on a non-integer float string", () => { - expect(() => parseProjectId("1.5")).toThrow( - /Invalid INTELLIGENCE_PROJECT_ID/, - ); - }); - - it("throws on an empty string", () => { - expect(() => parseProjectId("")).toThrow( - /Invalid INTELLIGENCE_PROJECT_ID/, - ); - }); - - it("throws on a whitespace-only string", () => { - expect(() => parseProjectId(" ")).toThrow( - /Invalid INTELLIGENCE_PROJECT_ID/, - ); - }); - - // Number("1e3") === 1000, Number("0x10") === 16, and Number("0b11") === 3 - // all pass Number.isInteger(n) && n > 0 — so a naive Number(raw) parse - // would silently accept a typo'd env var as the wrong project id instead - // of throwing. Only plain decimal-digit strings are valid. - it("throws on exponential notation", () => { - expect(() => parseProjectId("1e3")).toThrow( - /Invalid INTELLIGENCE_PROJECT_ID/, - ); - }); - - it("throws on hexadecimal notation", () => { - expect(() => parseProjectId("0x10")).toThrow( - /Invalid INTELLIGENCE_PROJECT_ID/, - ); - }); - - it("throws on binary notation", () => { - expect(() => parseProjectId("0b11")).toThrow( - /Invalid INTELLIGENCE_PROJECT_ID/, - ); - }); - - it("throws on a decimal float string", () => { - expect(() => parseProjectId("12.5")).toThrow( - /Invalid INTELLIGENCE_PROJECT_ID/, - ); - }); -}); diff --git a/app/managed.ts b/app/managed.ts deleted file mode 100644 index e0af66f..0000000 --- a/app/managed.ts +++ /dev/null @@ -1,244 +0,0 @@ -/** - * Intelligence channel host for KiteBot — the "managed" run mode. - * - * Unlike app/index.ts (self-hosted: holds Slack tokens, talks to Slack - * directly), this process holds NO platform credentials. It runs the SAME bot - * over the CopilotKit Intelligence Realtime Gateway: it declares one channel - * ("kitebot") to the gateway and streams render frames back. Intelligence owns - * the Slack edge (signed ingress + Connector Outbox egress). - * - * The bot's brain is an external AG-UI agent reached over HTTP at AGENT_URL — - * for now the runtime.ts triage backend; in Phase 2 a LangGraph deep agent. - * intelligenceAdapter is exclusive, so the bot is created WITHOUT a native - * adapter and startChannelsOverRealtimeGateway attaches the channel transport. - * - * Run: `pnpm channel` with INTELLIGENCE_* + AGENT_URL set (see .env.example). - */ -import "dotenv/config"; -import { randomUUID } from "node:crypto"; -import { createBot } from "@copilotkit/channels"; -import type { Bot } from "@copilotkit/channels"; -import type { AgentContentPart } from "@copilotkit/channels-ui"; -import { - SanitizingHttpAgent, - defaultSlackTools, - defaultSlackContext, -} from "@copilotkit/channels-slack"; -import { startChannelsOverRealtimeGateway } from "@copilotkit/channels-intelligence"; -import { appTools } from "./tools/index.js"; -import { appContext } from "./context/app-context.js"; -import { appCommands } from "./commands/index.js"; -import { senderContext } from "./sender-context.js"; -import { fileIssueSubmit, FILE_ISSUE_CALLBACK } from "./modals/file-issue.js"; -import { closeBrowser } from "./render/browser.js"; - -const required = (name: string): string => { - const v = process.env[name]; - if (!v) { - console.error(`Missing required env var: ${name}`); - process.exit(1); - } - return v; -}; - -export interface CreateKiteBotOptions { - /** AG-UI agent endpoint the bot's HttpAgent posts to. */ - agentUrl: string; - /** Optional Authorization header value forwarded to the agent. */ - agentAuthHeader?: string; - /** Intelligence channel name (lowercase kebab). Defaults to "kitebot". */ - channelName?: string; -} - -/** - * Pick the prompt to send to the agent for the current turn. Managed history - * does NOT include the in-flight turn, so the current message is always - * passed explicitly — preferring multimodal parts when present. - */ -export function promptFromMessage(message: { - contentParts?: AgentContentPart[]; - text: string; -}): string | AgentContentPart[] { - return message.contentParts?.length ? message.contentParts : message.text; -} - -/** Build the Authorization header object forwarded to the agent, if any. */ -export function buildAgentHeaders( - authHeader?: string, -): { Authorization: string } | undefined { - return authHeader ? { Authorization: authHeader } : undefined; -} - -/** - * Parse and validate INTELLIGENCE_PROJECT_ID, throwing on any invalid value. - * - * Only plain decimal-digit strings (e.g. "42") are accepted. `Number(raw)` - * alone would also accept hex ("0x10"), exponential ("1e3"), and binary - * ("0b11") notation — all of which pass `Number.isInteger(n) && n > 0` — - * so a typo'd env var could silently resolve to the wrong project. The - * `/^\d+$/` guard rejects anything that isn't a bare non-negative integer - * before handing it to `Number(...)`. - */ -export function parseProjectId(raw: string | undefined): number { - if (raw === undefined || !/^\d+$/.test(raw.trim())) { - throw new Error(`Invalid INTELLIGENCE_PROJECT_ID: "${raw}"`); - } - const n = Number(raw.trim()); - if (!Number.isInteger(n) || n <= 0) { - throw new Error(`Invalid INTELLIGENCE_PROJECT_ID: "${raw}"`); - } - return n; -} - -/** - * Build the KiteBot bot: same tools/context/commands/handlers as the native - * bot, minus any platform adapter (the intelligenceAdapter is attached at - * activation by startChannelsOverRealtimeGateway). Pure — no network, no env - * reads — so it is unit-testable. - */ -export function createKiteBot(opts: CreateKiteBotOptions): Bot { - const channelName = opts.channelName ?? "kitebot"; - const agentHeaders = buildAgentHeaders(opts.agentAuthHeader); - - const bot = createBot({ - name: channelName, - agent: (threadId: string) => { - const a = new SanitizingHttpAgent({ - url: opts.agentUrl, - headers: agentHeaders, - }); - a.threadId = threadId; - return a; - }, - tools: [...appTools, ...defaultSlackTools], - context: [...appContext, ...defaultSlackContext], - commands: appCommands, - }); - - // Managed history does NOT include the in-flight turn, so pass the current - // message explicitly as `prompt` (prefer multimodal parts). Mirrors the - // native bot's onMention otherwise. - bot.onMention(async ({ thread, message }) => { - try { - await thread.runAgent({ - prompt: promptFromMessage(message), - context: senderContext(message.user, thread.platform), - }); - } catch (err) { - console.error("[channel] agent run failed", err); - await thread - .post("Sorry — I hit an error handling that. Please try again.") - .catch((postErr: unknown) => - console.error("[channel] failed to post agent error", postErr), - ); - } - }); - - bot.onModalSubmit(FILE_ISSUE_CALLBACK, fileIssueSubmit); - - bot.onThreadStarted(async ({ thread, user }) => { - if (!user?.name) return; - try { - await thread.setSuggestedPrompts([ - { - title: `Triage ${user.name}'s issues`, - message: "Triage my open issues", - }, - { - title: "What shipped this week?", - message: "Summarize what shipped this week", - }, - ]); - } catch (err) { - console.error("[channel] onThreadStarted failed", err); - } - }); - - return bot; -} - -async function main() { - const channelName = process.env.INTELLIGENCE_CHANNEL_NAME ?? "kitebot"; - - const bot = createKiteBot({ - agentUrl: required("AGENT_URL"), - agentAuthHeader: process.env.AGENT_AUTH_HEADER, - channelName, - }); - - let projectId: number; - try { - projectId = parseProjectId(process.env.INTELLIGENCE_PROJECT_ID); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - console.error(msg); - process.exit(1); - } - - const handle = await startChannelsOverRealtimeGateway([bot], { - wsUrl: required("INTELLIGENCE_GATEWAY_WS_URL"), - apiKey: required("INTELLIGENCE_API_KEY"), - scope: { - organizationId: required("INTELLIGENCE_ORG_ID"), - projectId, - channelId: required("INTELLIGENCE_CHANNEL_ID"), - channelName, - }, - runtimeInstanceId: - process.env.INTELLIGENCE_RUNTIME_INSTANCE_ID ?? - `rti_${randomUUID().replace(/-/g, "")}`, - adapter: "slack", - // meta can contain message content, so it is gated behind CHANNEL_DEBUG - // and omitted from stdout/logs by default. - log: (msg, meta) => - console.log( - `[channel] ${msg}`, - process.env.CHANNEL_DEBUG ? (meta ?? "") : "", - ), - }); - console.log( - `[channel] KiteBot channel "${channelName}" started over Realtime Gateway on project ${projectId}`, - ); - - const shutdown = async (signal: string) => { - console.log(`\n[channel] received ${signal}, stopping…`); - let exitCode = 0; - try { - await handle.stop(); - } catch (err) { - console.error("[channel] error stopping channel runtime", err); - exitCode = 1; - } - await closeBrowser().catch((err: unknown) => - console.error( - "[channel] browser cleanup failed (continuing shutdown)", - err, - ), - ); - process.exit(exitCode); - }; - const runShutdown = (signal: string): void => { - shutdown(signal).catch((err: unknown) => { - console.error(`[channel] fatal during ${signal} shutdown`, err); - process.exit(1); - }); - }; - process.on("SIGINT", () => runShutdown("SIGINT")); - process.on("SIGTERM", () => runShutdown("SIGTERM")); -} - -process.on("unhandledRejection", (reason) => { - console.error("[channel] unhandledRejection:", reason); -}); -process.on("uncaughtException", (err) => { - console.error("[channel] uncaughtException:", err); -}); - -// Only start the gateway connection when executed directly (`pnpm channel`), -// not when the test imports `createKiteBot`. -if (process.argv[1] && process.argv[1].endsWith("managed.ts")) { - main().catch((err: unknown) => { - console.error("[channel] fatal: failed to start channel runtime", err); - process.exit(1); - }); -} diff --git a/app/modals/__tests__/file-issue.test.tsx b/app/modals/__tests__/file-issue.test.tsx index 066073a..35aa6f0 100644 --- a/app/modals/__tests__/file-issue.test.tsx +++ b/app/modals/__tests__/file-issue.test.tsx @@ -1,19 +1,18 @@ import { describe, it, expect, vi } from "vitest"; -import { renderToIR } from "@copilotkit/channels-ui"; +import { renderToIR, type ChannelNode } from "@copilotkit/channels"; import { FileIssueModal, fileIssueSubmit, issueFromValues, FILE_ISSUE_CALLBACK, } from "../file-issue.js"; -import type { BotNode } from "@copilotkit/channels-ui"; import { senderContext } from "../../sender-context.js"; -function tags(node: BotNode | unknown, acc: string[] = []): string[] { +function tags(node: ChannelNode | unknown, acc: string[] = []): string[] { if (!node || typeof node !== "object") return acc; - const n = node as BotNode; + const n = node as ChannelNode; if (typeof n.type === "string") acc.push(n.type); - for (const c of (n.props?.children as BotNode[] | undefined) ?? []) { + for (const c of (n.props?.children as ChannelNode[] | undefined) ?? []) { tags(c, acc); } return acc; @@ -31,7 +30,7 @@ describe("FileIssueModal", () => { expect(t).toContain("modal_radio"); }); - it("text-only variant (Discord) drops selects/radios, ≤5 inputs", () => { + it("text-only variant drops selects and radios", () => { const ir = renderToIR(FileIssueModal({ rich: false })); const root = ir[0]!; const t = tags(root); @@ -58,7 +57,7 @@ describe("issueFromValues", () => { }); }); - it("applies defaults when controls were absent (Discord text-only)", () => { + it("applies defaults when optional controls were absent", () => { expect(issueFromValues({ title: "X", description: "Y" })).toEqual({ title: "X", description: "Y", diff --git a/app/modals/file-issue.tsx b/app/modals/file-issue.tsx index cebf48a..d6d72dd 100644 --- a/app/modals/file-issue.tsx +++ b/app/modals/file-issue.tsx @@ -2,12 +2,9 @@ * Modal demo — a structured "file a Linear issue" form. Beats parsing free text: * the fields come back typed and validated by the platform. * - * Per-platform honesty (the modal vocabulary degrades, it never lies): - * - Slack → the rich form: text inputs + team/priority dropdowns + a type radio. - * - Discord→ text-only, ≤5 inputs (discord.js modals take only text inputs), so - * the dropdowns/radio drop out and `issueFromValues` applies defaults. - * - Telegram→ no modals at all; the `/file-issue` command (Task 5) detects the - * missing trigger and falls back to a conversational flow. + * Slack receives the rich form. Platforms without modal support (including the + * current direct Teams adapter) are detected by the `/file-issue` command and + * fall back to the same conversational filing flow. */ import { Modal, @@ -15,9 +12,8 @@ import { ModalSelect, ModalSelectOption, RadioButtons, -} from "@copilotkit/channels-ui"; -import type { ModalView } from "@copilotkit/channels-ui"; -import type { ModalSubmitHandler } from "@copilotkit/channels"; +} from "@copilotkit/channels"; +import type { ModalSubmitHandler, ModalView } from "@copilotkit/channels"; import { senderContext } from "../sender-context.js"; export const FILE_ISSUE_CALLBACK = "file_issue"; @@ -30,7 +26,7 @@ export function issueFromValues(values: Record<string, unknown>) { return { title: str(values.title), description: str(values.description), - type: str(values.type, "bug"), // absent on Discord text-only → default + type: str(values.type, "bug"), priority: str(values.priority, "Medium"), }; } @@ -39,7 +35,7 @@ export function issueFromValues(values: Record<string, unknown>) { * Handle a `/file-issue` modal submission: validate, then file via the agent * (Linear MCP) so the existing confirm-before-write + filed-card flow is reused. * Returning `{ errors }` keeps the modal open with a field error (Slack); on - * text-only Discord, `type`/`priority` default in. + * submissions without `type`/`priority` use safe defaults. * * CRITICAL — Slack's view_submission ack deadline (~3s): the adapter awaits this * handler before it can `ack()` the submission, and Slack expects that ack within @@ -83,7 +79,7 @@ export const fileIssueSubmit: ModalSubmitHandler = async ({ /** * The form. `rich` controls whether the structured controls (selects/radio) are - * present; pass `false` on text-only surfaces (Discord). + * present; pass `false` when a future text-only modal surface needs it. */ export function FileIssueModal({ rich }: { rich: boolean }): ModalView { return ( @@ -120,8 +116,8 @@ export function FileIssueModal({ rich }: { rich: boolean }): ModalView { </RadioButtons> ) : null} </Modal> - // JSX expressions type as `JSX.Element` (= `BotNode`, not `ModalView`) per - // the channels-ui jsx-runtime, so the narrower literal `type: "modal"` needs + // JSX expressions type as `JSX.Element` (= `ChannelNode`, not `ModalView`) per + // the Channels JSX runtime, so the narrower literal `type: "modal"` needs // an explicit assertion even though `Modal` itself returns `ModalView`. ) as ModalView; } diff --git a/app/platforms.test.ts b/app/platforms.test.ts new file mode 100644 index 0000000..f2ece8a --- /dev/null +++ b/app/platforms.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { + defaultSlackContext, + defaultSlackTools, +} from "@copilotkit/channels/slack"; +import { readEnvironment } from "./env.js"; +import { resolvePlatforms } from "./platforms.js"; + +const environment = ( + overrides: NodeJS.ProcessEnv = {}, +) => + readEnvironment({ + AGENT_URL: "http://localhost:8123/", + INTELLIGENCE_API_KEY: "cpk_test", + ...overrides, + }); + +describe("resolvePlatforms", () => { + it("uses managed Slack with Slack defaults when no direct credentials exist", () => { + const setup = resolvePlatforms(environment()); + + expect(setup.adapters).toEqual([]); + expect(setup.tools).toEqual(defaultSlackTools); + expect(setup.context).toEqual(defaultSlackContext); + }); + + it("constructs direct Slack only when both Slack credentials exist", () => { + const setup = resolvePlatforms( + environment({ + SLACK_BOT_TOKEN: "xoxb-test", + SLACK_APP_TOKEN: "xapp-test", + }), + ); + + expect(setup.adapters.map(({ platform }) => platform)).toEqual(["slack"]); + expect(setup.tools).toEqual(defaultSlackTools); + expect(setup.context).toEqual(defaultSlackContext); + }); + + it("constructs direct Teams from its complete credential pair without inventing defaults", () => { + const setup = resolvePlatforms( + environment({ + TEAMS_CLIENT_ID: "teams-client", + TEAMS_CLIENT_SECRET: "teams-secret", + TEAMS_TENANT_ID: "teams-tenant", + TEAMS_PORT: "4978", + }), + ); + + expect(setup.adapters.map(({ platform }) => platform)).toEqual(["teams"]); + expect(setup.tools).toEqual([]); + expect(setup.context).toEqual([]); + }); + + it("combines direct Slack and Teams adapters in one platform setup", () => { + const setup = resolvePlatforms( + environment({ + SLACK_BOT_TOKEN: "xoxb-test", + SLACK_APP_TOKEN: "xapp-test", + TEAMS_CLIENT_ID: "teams-client", + TEAMS_CLIENT_SECRET: "teams-secret", + }), + ); + + expect(setup.adapters.map(({ platform }) => platform)).toEqual([ + "slack", + "teams", + ]); + expect(setup.tools).toEqual(defaultSlackTools); + expect(setup.context).toEqual(defaultSlackContext); + }); + + it("ignores incomplete direct credential pairs", () => { + const setup = resolvePlatforms( + environment({ + SLACK_BOT_TOKEN: "xoxb-test", + TEAMS_CLIENT_ID: "teams-client", + }), + ); + + expect(setup.adapters).toEqual([]); + expect(setup.tools).toEqual(defaultSlackTools); + expect(setup.context).toEqual(defaultSlackContext); + }); +}); diff --git a/app/platforms.ts b/app/platforms.ts new file mode 100644 index 0000000..d7678fe --- /dev/null +++ b/app/platforms.ts @@ -0,0 +1,72 @@ +import type { + ChannelTool, + ContextEntry, + PlatformAdapter, +} from "@copilotkit/channels"; +import { + defaultSlackContext, + defaultSlackTools, + slack, +} from "@copilotkit/channels/slack"; +import { teams } from "@copilotkit/channels/teams"; +import type { AppEnvironment } from "./env.js"; + +export interface PlatformSetup { + adapters: PlatformAdapter[]; + tools: ChannelTool[]; + context: ContextEntry[]; +} + +export function resolvePlatforms(env: AppEnvironment): PlatformSetup { + const adapters: PlatformAdapter[] = []; + const tools: ChannelTool[] = []; + const context: ContextEntry[] = []; + + if (env.slackBotToken && env.slackAppToken) { + adapters.push( + slack({ + botToken: env.slackBotToken, + appToken: env.slackAppToken, + showToolStatus: false, + respondTo: { + directMessages: true, + appMentions: { reply: "thread" }, + threadReplies: "mentionsOnly", + }, + 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 (env.teamsClientId && env.teamsClientSecret) { + adapters.push( + teams({ + clientId: env.teamsClientId, + clientSecret: env.teamsClientSecret, + tenantId: env.teamsTenantId, + port: env.teamsPort, + }), + ); + } + + if (adapters.length === 0) { + tools.push(...defaultSlackTools); + context.push(...defaultSlackContext); + } + + return { adapters, tools, context }; +} diff --git a/app/runtime-host.test.ts b/app/runtime-host.test.ts new file mode 100644 index 0000000..0ef9980 --- /dev/null +++ b/app/runtime-host.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; +import { FakeAdapter, FakeAgent } from "@copilotkit/channels"; +import { createOpenTagChannel } from "./channel.js"; +import type { AppEnvironment } from "./env.js"; +import { + OPENTAG_SERVICE_USER, + createOpenTagRuntime, +} from "./runtime-host.js"; + +const environment: AppEnvironment = { + agentUrl: "http://agent.internal", + agentAuthHeader: "Bearer agent-secret", + intelligenceApiKey: "intelligence-secret", + intelligenceApiUrl: "https://intelligence.example.test", + intelligenceGatewayWsUrl: "wss://gateway.example.test", + channelName: "opentag", + port: 3000, + teamsPort: 3978, +}; + +describe("createOpenTagRuntime", () => { + it("registers the Channel in an Intelligence runtime and Node listener", async () => { + const slackAdapter = new FakeAdapter({ platform: "slack" }); + const teamsAdapter = new FakeAdapter({ platform: "teams" }); + const stopSlack = vi.spyOn(slackAdapter, "stop"); + const stopTeams = vi.spyOn(teamsAdapter, "stop"); + const channel = createOpenTagChannel({ + name: environment.channelName, + adapters: [slackAdapter, teamsAdapter], + agent: new FakeAgent(), + platformTools: [], + platformContext: [], + }); + + const { intelligence, listener, runtime } = createOpenTagRuntime({ + environment, + channel, + }); + + expect(runtime.mode).toBe("intelligence"); + expect(runtime.channels).toEqual([channel]); + expect(intelligence.ɵgetApiUrl()).toBe(environment.intelligenceApiUrl); + expect(intelligence.ɵgetClientWsUrl()).toContain( + "gateway.example.test", + ); + expect( + await runtime.identifyUser?.(new Request("http://localhost")), + ).toEqual(OPENTAG_SERVICE_USER); + + expect(listener.channels).toBeDefined(); + await listener.channels?.ready({ timeoutMs: 500 }); + expect(listener.channels?.status()).toEqual({ + overall: "online", + channels: { opentag: "online" }, + }); + expect(slackAdapter.started).toBe(true); + expect(teamsAdapter.started).toBe(true); + + await listener.channels?.stop(); + expect(stopSlack).toHaveBeenCalledOnce(); + expect(stopTeams).toHaveBeenCalledOnce(); + }); +}); diff --git a/app/runtime-host.ts b/app/runtime-host.ts new file mode 100644 index 0000000..b054699 --- /dev/null +++ b/app/runtime-host.ts @@ -0,0 +1,37 @@ +import type { Channel } from "@copilotkit/channels"; +import { + CopilotKitIntelligence, + CopilotRuntime, +} from "@copilotkit/runtime/v2"; +import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node"; +import type { AppEnvironment } from "./env.js"; + +export const OPENTAG_SERVICE_USER = { + id: "opentag-service", + name: "OpenTag Channel Service", +} as const; + +export function createOpenTagRuntime(options: { + environment: AppEnvironment; + channel: Channel; +}) { + const intelligence = new CopilotKitIntelligence({ + apiUrl: options.environment.intelligenceApiUrl, + wsUrl: options.environment.intelligenceGatewayWsUrl, + apiKey: options.environment.intelligenceApiKey, + }); + + const runtime = new CopilotRuntime({ + agents: {}, + intelligence, + identifyUser: () => OPENTAG_SERVICE_USER, + channels: [options.channel], + }); + + const listener = createCopilotNodeListener({ + runtime, + basePath: "/api/copilotkit", + }); + + return { intelligence, listener, runtime }; +} diff --git a/app/sender-context.test.ts b/app/sender-context.test.ts index c1b9319..287d1b0 100644 --- a/app/sender-context.test.ts +++ b/app/sender-context.test.ts @@ -19,22 +19,22 @@ describe("senderContext", () => { ]); }); - it("labels a whatsapp user (no email) with the platform", () => { - const out = senderContext({ id: "15551230000", name: "Bob" }, "whatsapp"); + it("labels a Teams user without an email with the platform", () => { + const out = senderContext({ id: "teams-user", name: "Bob" }, "teams"); expect(out).toEqual([ { - description: "Requesting whatsapp user", - value: "Bob (whatsapp id 15551230000)", + description: "Requesting teams user", + value: "Bob (teams id teams-user)", }, ]); }); - it("falls back to the id when the user has no name (e.g. a WhatsApp sender)", () => { - const out = senderContext({ id: "15551230000" }, "whatsapp"); + it("falls back to the id when a Teams user has no name", () => { + const out = senderContext({ id: "teams-user" }, "teams"); expect(out).toEqual([ { - description: "Requesting whatsapp user", - value: "15551230000 (whatsapp id 15551230000)", + description: "Requesting teams user", + value: "teams-user (teams id teams-user)", }, ]); }); diff --git a/app/sender-context.ts b/app/sender-context.ts index 87e3d88..7cc35bf 100644 --- a/app/sender-context.ts +++ b/app/sender-context.ts @@ -1,19 +1,17 @@ -import type { ContextEntry } from "@copilotkit/channels"; -import type { PlatformUser } from "@copilotkit/channels-ui"; +import type { ContextEntry, PlatformUser } from "@copilotkit/channels"; /** * Build the per-turn context naming the requesting user, so the agent can act * "as" them (filter Linear by their email, @-mention/tag them). The platform * adapter resolves `{ id, name?, email? }` per turn; if it's absent there's - * nothing to attribute, so we add no entry. `platform` is the surface the turn - * came from (`thread.platform`), so the label is correct across Slack, Discord, - * Telegram, and WhatsApp alike. + * nothing to attribute, so we add no entry. `platform` is the source surface, + * so the label is correct for managed and direct Slack/Teams turns alike. */ export function senderContext( user: PlatformUser | undefined, platform: string, ): ContextEntry[] { - // `createBot` substitutes `{ id: "" }` for an unresolved sender (a truthy + // `createChannel` substitutes `{ id: "" }` for an unresolved sender (a truthy // object), so guard on a usable id — not mere object presence — otherwise we // emit a "Requesting <platform> user (... id )" entry with nothing to attribute. if (!user?.id) return []; diff --git a/app/server.test.ts b/app/server.test.ts new file mode 100644 index 0000000..3751e4f --- /dev/null +++ b/app/server.test.ts @@ -0,0 +1,132 @@ +import { EventEmitter } from "node:events"; +import type { RequestListener } from "node:http"; +import { describe, expect, it, vi } from "vitest"; +import type { ChannelsControl } from "@copilotkit/runtime/v2"; +import { + startOpenTagServer, + type HttpServerLike, + type RuntimeListener, +} from "../server.js"; + +class FakeServer extends EventEmitter implements HttpServerLike { + listening = false; + listenCalls: Array<{ port: number; host: string }> = []; + closeCalls = 0; + + listen(port: number, host: string, callback: () => void): this { + this.listenCalls.push({ port, host }); + this.listening = true; + callback(); + return this; + } + + close(callback: (error?: Error) => void): this { + this.closeCalls += 1; + this.listening = false; + callback(); + return this; + } +} + +function makeControls(overrides: Partial<ChannelsControl> = {}) { + return { + ready: vi.fn(async () => undefined), + status: vi.fn(() => ({ + overall: "online" as const, + channels: { opentag: "online" as const }, + })), + stop: vi.fn(async () => undefined), + ...overrides, + } satisfies ChannelsControl; +} + +function makeListener(controls: ChannelsControl): RuntimeListener { + const requestListener: RequestListener = (_request, response) => { + response.end(); + }; + return Object.assign(requestListener, { channels: controls }); +} + +describe("startOpenTagServer", () => { + it("awaits runtime-owned Channel readiness before listening", async () => { + const calls: string[] = []; + const controls = makeControls({ + ready: vi.fn(async () => { + calls.push("ready"); + }), + }); + const server = new FakeServer(); + server.listen = vi.fn( + (port: number, host: string, callback: () => void) => { + calls.push("listen"); + server.listenCalls.push({ port, host }); + server.listening = true; + callback(); + return server; + }, + ); + + const running = await startOpenTagServer({ + listener: makeListener(controls), + port: 4321, + closeBrowser: vi.fn(async () => undefined), + createHttpServer: () => server, + signalTarget: new EventEmitter(), + }); + + expect(calls).toEqual(["ready", "listen"]); + expect(controls.ready).toHaveBeenCalledWith({ timeoutMs: 15_000 }); + expect(server.listenCalls).toEqual([{ port: 4321, host: "::" }]); + + await running.shutdown(); + }); + + it("rejects startup, cleans up, and never listens when readiness fails", async () => { + const failure = new Error("gateway unavailable"); + const controls = makeControls({ + ready: vi.fn(async () => { + throw failure; + }), + }); + const server = new FakeServer(); + const closeBrowser = vi.fn(async () => undefined); + const createHttpServer = vi.fn(() => server); + + await expect( + startOpenTagServer({ + listener: makeListener(controls), + port: 3000, + closeBrowser, + createHttpServer, + signalTarget: new EventEmitter(), + }), + ).rejects.toBe(failure); + + expect(createHttpServer).not.toHaveBeenCalled(); + expect(controls.stop).toHaveBeenCalledOnce(); + expect(closeBrowser).toHaveBeenCalledOnce(); + }); + + it("stops every owned resource exactly once across repeated shutdowns", async () => { + const controls = makeControls(); + const server = new FakeServer(); + const closeBrowser = vi.fn(async () => undefined); + const signalTarget = new EventEmitter(); + + const running = await startOpenTagServer({ + listener: makeListener(controls), + port: 3000, + closeBrowser, + createHttpServer: () => server, + signalTarget, + }); + + signalTarget.emit("SIGINT"); + signalTarget.emit("SIGTERM"); + await Promise.all([running.shutdown(), running.shutdown()]); + + expect(controls.stop).toHaveBeenCalledOnce(); + expect(server.closeCalls).toBe(1); + expect(closeBrowser).toHaveBeenCalledOnce(); + }); +}); diff --git a/app/tools/__tests__/read-thread.test.ts b/app/tools/__tests__/read-thread.test.ts index 60198d2..9e3b727 100644 --- a/app/tools/__tests__/read-thread.test.ts +++ b/app/tools/__tests__/read-thread.test.ts @@ -1,13 +1,13 @@ import { describe, it, expect, vi } from "vitest"; import { readThreadTool } from "../read-thread.js"; -import type { ThreadMessage } from "@copilotkit/channels-ui"; +import type { ThreadMessage } from "@copilotkit/channels"; -/** The ctx a BotTool handler receives. */ +/** The ctx a ChannelTool handler receives. */ type HandlerCtx = Parameters<typeof readThreadTool.handler>[1]; /** * Build a fake handler ctx. The handler only touches - * `thread.getMessages()`; the other `BotToolContext` fields are unused by + * `thread.getMessages()`; the other `ChannelToolContext` fields are unused by * this tool, so we cast the minimal literal to the handler ctx type. */ function makeCtx(messages: ThreadMessage[]): HandlerCtx { diff --git a/app/tools/__tests__/render-table.test.tsx b/app/tools/__tests__/render-table.test.tsx index afef3f9..c558504 100644 --- a/app/tools/__tests__/render-table.test.tsx +++ b/app/tools/__tests__/render-table.test.tsx @@ -5,8 +5,8 @@ * `renderToIR` → `renderSlackMessage` yields the expected Block Kit shape. */ import { describe, it, expect } from "vitest"; -import { renderToIR } from "@copilotkit/channels-ui"; -import { renderSlackMessage } from "@copilotkit/channels-slack"; +import { renderToIR } from "@copilotkit/channels"; +import { renderSlackMessage } from "@copilotkit/channels/slack"; import { renderTableTool, toMonospaceTable, clamp } from "../render-table.js"; type HandlerCtx = Parameters<typeof renderTableTool.handler>[1]; diff --git a/app/tools/__tests__/render-tools.test.ts b/app/tools/__tests__/render-tools.test.ts index 76383fb..40b8715 100644 --- a/app/tools/__tests__/render-tools.test.ts +++ b/app/tools/__tests__/render-tools.test.ts @@ -8,8 +8,8 @@ * separately in render-tools.test.tsx. */ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderToIR } from "@copilotkit/channels-ui"; -import { renderSlackMessage } from "@copilotkit/channels-slack"; +import { renderToIR } from "@copilotkit/channels"; +import { renderSlackMessage } from "@copilotkit/channels/slack"; // The exact PNG buffer instance `renderChart` resolves with, so tests can // assert `postFile` was handed the real render output — not just a filename. @@ -25,7 +25,7 @@ vi.mock("../../render/diagram.js", () => ({ renderDiagram })); const { renderChartTool } = await import("../render-chart.js"); const { renderDiagramTool } = await import("../render-diagram.js"); -/** The ctx a BotTool handler receives. */ +/** The ctx a ChannelTool handler receives. */ type HandlerCtx = Parameters<typeof renderChartTool.handler>[1]; function makeCtx(opts?: { diff --git a/app/tools/__tests__/render-tools.test.tsx b/app/tools/__tests__/render-tools.test.tsx index aff26ee..48832f3 100644 --- a/app/tools/__tests__/render-tools.test.tsx +++ b/app/tools/__tests__/render-tools.test.tsx @@ -6,8 +6,8 @@ * shape — i.e. the tool posted the right component. */ import { describe, it, expect } from "vitest"; -import { renderToIR } from "@copilotkit/channels-ui"; -import { renderSlackMessage } from "@copilotkit/channels-slack"; +import { renderToIR } from "@copilotkit/channels"; +import { renderSlackMessage } from "@copilotkit/channels/slack"; import { issueCardTool, issueListTool, pageListTool } from "../render-tools.js"; /** A fake `thread` that records each posted Renderable. */ diff --git a/app/tools/__tests__/showcase-tools.test.tsx b/app/tools/__tests__/showcase-tools.test.tsx index 9a70b23..220bddd 100644 --- a/app/tools/__tests__/showcase-tools.test.tsx +++ b/app/tools/__tests__/showcase-tools.test.tsx @@ -7,13 +7,13 @@ * updates the message in place with a green "Acknowledged" card. */ import { describe, it, expect, vi } from "vitest"; -import { renderToIR } from "@copilotkit/channels-ui"; +import { renderToIR } from "@copilotkit/channels"; import type { - BotNode, + ChannelNode, InteractionContext, ClickHandler, -} from "@copilotkit/channels-ui"; -import { renderSlackMessage } from "@copilotkit/channels-slack"; +} from "@copilotkit/channels"; +import { renderSlackMessage } from "@copilotkit/channels/slack"; import { showIncidentTool, showStatusTool, @@ -43,29 +43,29 @@ function fakeThread() { /** Depth-first: find the first IR node whose `type` matches and that has the named prop. */ function findWithProp( - nodes: BotNode[], + nodes: ChannelNode[], type: string, prop: string, -): BotNode | undefined { +): ChannelNode | undefined { return findAllWithProp(nodes, type, prop)[0]; } /** Depth-first: find every IR node whose `type` matches and that has the named prop. */ function findAllWithProp( - nodes: BotNode[], + nodes: ChannelNode[], type: string, prop: string, -): BotNode[] { - const matches: BotNode[] = []; +): ChannelNode[] { + const matches: ChannelNode[] = []; for (const node of nodes) { if (node.type === type && node.props && prop in node.props) { matches.push(node); } const children = node.props?.children; const childArr = Array.isArray(children) - ? (children as BotNode[]) + ? (children as ChannelNode[]) : children && typeof children === "object" - ? [children as BotNode] + ? [children as ChannelNode] : []; matches.push(...findAllWithProp(childArr, type, prop)); } diff --git a/app/tools/index.ts b/app/tools/index.ts index 4e4371f..5be0b6c 100644 --- a/app/tools/index.ts +++ b/app/tools/index.ts @@ -1,12 +1,9 @@ /** - * App-specific frontend tools — anything that's bot-specific, not - * universal-Slack. Universal-Slack stuff (tagging, formatting, - * conversation model) lives in the SDK and is auto-included by - * `defaultSlackTools` (spread in `app/index.ts`). + * App-specific frontend tools. Platform defaults are selected separately in + * `app/platforms.ts`. * * Add new tools here and re-export them through `appTools`. Wire the - * array into `createBot({tools: [...defaultSlackTools, ...appTools]})` - * in `app/index.ts`. + * array into `createChannel({ tools })`. */ import { readThreadTool } from "./read-thread.js"; import { renderChartTool } from "./render-chart.js"; @@ -19,17 +16,17 @@ import { showLinksTool, } from "./showcase-tools.js"; import { confirmWriteTool } from "../human-in-the-loop/index.js"; -import type { BotTool } from "@copilotkit/channels"; +import type { ChannelTool } from "@copilotkit/channels"; /** - * Every tool is a plain `BotTool`: its handler receives the generic - * `BotToolContext` (`{ thread, message?, user?, signal?, platform }`) the + * Every tool is a plain `ChannelTool`: its handler receives the generic + * `ChannelToolContext` (`{ thread, message?, user?, signal?, platform }`) the * adapter supplies at call time. Platform power (post/stream/postFile, * `thread.getMessages()`, `thread.lookupUser()`, …) is reached via the * `thread` methods, so there's no per-adapter context and no cast needed — - * the array assigns straight into `createBot({ tools })`. + * the array assigns straight into `createChannel({ tools })`. */ -export const appTools: BotTool[] = [ +export const appTools: ChannelTool[] = [ readThreadTool, renderChartTool, renderDiagramTool, diff --git a/app/tools/read-thread.ts b/app/tools/read-thread.ts index 7249acf..c397053 100644 --- a/app/tools/read-thread.ts +++ b/app/tools/read-thread.ts @@ -1,19 +1,19 @@ /** - * `read_thread` — an app-side `BotTool` that hands the agent the full + * `read_thread` — an app-side `ChannelTool` that hands the agent the full * conversation thread it's replying in. This is what makes "write this * incident thread up as a postmortem" possible: the agent calls * `read_thread`, gets the actual messages, and summarizes those instead * of inventing content. * - * It's a worked example of a `BotTool` that reads conversation history + * It's a worked example of a `ChannelTool` that reads conversation history * via the platform-agnostic `ctx.thread.getMessages()` capability — * the adapter targets the current thread, so no channel/ts plumbing is * needed. */ import { z } from "zod"; -import { defineBotTool } from "@copilotkit/channels"; +import { defineChannelTool } from "@copilotkit/channels"; -export const readThreadTool = defineBotTool({ +export const readThreadTool = defineChannelTool({ name: "read_thread", description: "Fetch the messages in the current conversation thread so you can " + diff --git a/app/tools/render-chart.tsx b/app/tools/render-chart.tsx index 61d3255..20f6145 100644 --- a/app/tools/render-chart.tsx +++ b/app/tools/render-chart.tsx @@ -7,8 +7,7 @@ * (`<Context>`) so the tool doubles as a render-tool demo. */ import { z } from "zod"; -import { Context, Message } from "@copilotkit/channels-ui"; -import { defineBotTool } from "@copilotkit/channels"; +import { Context, Message, defineChannelTool } from "@copilotkit/channels"; import { renderChart } from "../render/chart.js"; const schema = z.object({ @@ -63,7 +62,7 @@ function slug(s: string): string { ); } -export const renderChartTool = defineBotTool({ +export const renderChartTool = defineChannelTool({ name: "render_chart", description: "Render a chart as an image and post it to the conversation thread. Pass " + diff --git a/app/tools/render-diagram.tsx b/app/tools/render-diagram.tsx index f302c87..9dff65e 100644 --- a/app/tools/render-diagram.tsx +++ b/app/tools/render-diagram.tsx @@ -7,8 +7,7 @@ * render-tool demo. */ import { z } from "zod"; -import { Context, Message } from "@copilotkit/channels-ui"; -import { defineBotTool } from "@copilotkit/channels"; +import { Context, Message, defineChannelTool } from "@copilotkit/channels"; import { renderDiagram } from "../render/diagram.js"; const schema = z.object({ @@ -33,7 +32,7 @@ function slug(s: string): string { ); } -export const renderDiagramTool = defineBotTool({ +export const renderDiagramTool = defineChannelTool({ name: "render_diagram", description: "Render a Mermaid diagram as an image and post it to the conversation " + diff --git a/app/tools/render-table.tsx b/app/tools/render-table.tsx index ecba6c4..fbde82e 100644 --- a/app/tools/render-table.tsx +++ b/app/tools/render-table.tsx @@ -4,7 +4,7 @@ * issues with several fields, metrics parsed from an uploaded CSV, side-by-side * comparisons — anything where a chart isn't the right shape. * - * Authored as JSX over `@copilotkit/channels-ui`'s `<Table>/<Row>/<Cell>` vocabulary + * Authored as JSX over `@copilotkit/channels`'s `<Table>/<Row>/<Cell>` vocabulary * and posted via `thread.post`. If the platform rejects the native Table block, * we fall back to a column-aligned monospace (code-fenced) table posted as a * platform-neutral `<Message>` so the data always lands — the same look the @@ -19,8 +19,8 @@ import { Row, Cell, Context, -} from "@copilotkit/channels-ui"; -import { defineBotTool } from "@copilotkit/channels"; +} from "@copilotkit/channels"; +import { defineChannelTool } from "@copilotkit/channels"; const schema = z.object({ title: z @@ -120,7 +120,7 @@ export function toMonospaceTable(cols: Column[], dataRows: string[][]): string { return "```\n" + [fmt(header), ...body.map(fmt)].join("\n") + "\n```"; } -export const renderTableTool = defineBotTool({ +export const renderTableTool = defineChannelTool({ name: "render_table", description: "Render tabular data as a table posted to the conversation thread. Pass " + diff --git a/app/tools/render-tools.tsx b/app/tools/render-tools.tsx index c821a35..5726c42 100644 --- a/app/tools/render-tools.tsx +++ b/app/tools/render-tools.tsx @@ -1,11 +1,11 @@ /** * Render-tools — the agent-facing wrappers that turn the JSX render - * components into `BotTool`s. The agent calls `issue_card` / `issue_list` / - * `page_list`; each handler renders the finished `@copilotkit/channels-ui` + * components into `ChannelTool`s. The agent calls `issue_card` / `issue_list` / + * `page_list`; each handler renders the finished `@copilotkit/channels` * component (`<IssueCard … />` etc.) and posts it to the thread via * `thread.post`. */ -import { defineBotTool } from "@copilotkit/channels"; +import { defineChannelTool } from "@copilotkit/channels"; import { IssueCard, IssueList, @@ -15,7 +15,7 @@ import { pageListSchema, } from "../components/index.js"; -export const issueCardTool = defineBotTool({ +export const issueCardTool = defineChannelTool({ name: "issue_card", description: "Render ONE Linear issue as a rich card with a status header, " + @@ -29,7 +29,7 @@ export const issueCardTool = defineBotTool({ }, }); -export const issueListTool = defineBotTool({ +export const issueListTool = defineChannelTool({ name: "issue_list", description: "Render a list of Linear issues as a card — a header plus one row per " + @@ -44,7 +44,7 @@ export const issueListTool = defineBotTool({ }, }); -export const pageListTool = defineBotTool({ +export const pageListTool = defineChannelTool({ name: "page_list", description: "Render a list of Notion pages as a card — a header plus one row per " + diff --git a/app/tools/showcase-tools.tsx b/app/tools/showcase-tools.tsx index 4e0b537..72ed3e5 100644 --- a/app/tools/showcase-tools.tsx +++ b/app/tools/showcase-tools.tsx @@ -1,6 +1,6 @@ /** - * Showcase render-tools — three small JSX `BotTool`s that demonstrate the - * `@copilotkit/channels-ui` vocabulary end-to-end: + * Showcase render-tools — three small JSX `ChannelTool`s that demonstrate the + * `@copilotkit/channels` vocabulary end-to-end: * * - `show_incident` — an interactive card whose `Acknowledge`/`Escalate` * buttons carry inline `onClick` handlers. These are FIRE-AND-FORGET @@ -20,9 +20,9 @@ import { Field, Actions, Button, -} from "@copilotkit/channels-ui"; -import type { InteractionContext } from "@copilotkit/channels-ui"; -import { defineBotTool } from "@copilotkit/channels"; +} from "@copilotkit/channels"; +import type { InteractionContext } from "@copilotkit/channels"; +import { defineChannelTool } from "@copilotkit/channels"; // ── show_incident ────────────────────────────────────────────────────────── @@ -89,7 +89,7 @@ export function IncidentCard({ id, title, severity, summary }: IncidentProps) { ); } -export const showIncidentTool = defineBotTool({ +export const showIncidentTool = defineChannelTool({ name: "show_incident", description: "Render an interactive incident card with Acknowledge/Escalate buttons. " + @@ -133,7 +133,7 @@ export function StatusCard({ heading, fields }: StatusProps) { ); } -export const showStatusTool = defineBotTool({ +export const showStatusTool = defineChannelTool({ name: "show_status", description: "Render a status card: a heading plus a grid of label/value fields " + @@ -177,7 +177,7 @@ export function LinksCard({ heading, links }: LinksProps) { ); } -export const showLinksTool = defineBotTool({ +export const showLinksTool = defineChannelTool({ name: "show_links", description: "Render a card of links: a heading plus a dot-separated row of clickable " + diff --git a/e2e/cases.ts b/e2e/cases.ts index 95b3a51..06007ca 100644 --- a/e2e/cases.ts +++ b/e2e/cases.ts @@ -359,9 +359,8 @@ export const CASES: E2ECase[] = [ // 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.) + // That's what Slack stores and what the Channel decodes on a stale click + // after a restart — the durable-action story. prompt: '<@U0B45V75NNR> file a Linear issue titled "Checkout 500s under load". ' + "Use the confirm_write tool to ask me to approve it first.", @@ -433,9 +432,8 @@ export const CASES: E2ECase[] = [ // 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. + // is covered by the Channels unit tests. 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.", diff --git a/e2e/restart-recovery.ts b/e2e/restart-recovery.ts deleted file mode 100644 index 4f35024..0000000 --- a/e2e/restart-recovery.ts +++ /dev/null @@ -1,440 +0,0 @@ -/** - * End-to-end test: bridge-restart-recovery for HITL components. - * - * Verifies that after a bridge restart between picker-post and click, - * the picker is still actionable. Slack is the source of truth for - * both the bound resume value (`button.value`) AND the dispatch - * context (`message.metadata.event_payload.{handler, ...}`). - * - * Scenario: - * - * • **HITL** — `defineHumanInTheLoop` frontend tool (`confirm_write`). - * Resume mechanism: `runAgent({forwardedProps:{command:{resume}}})`, - * which the CopilotKit middleware turns into a tool-result message - * for the intercepted frontend-tool call. This is the "approve the - * write 20 minutes later, after a deploy restarted the bot" story. - * - * Flow: - * - * 1. Spawn bridge instance #1 (in-process). Start it. - * 2. Post a user prompt that triggers the picker. - * 3. Poll Slack until the picker lands; assert metadata + per-button - * encoded values are present. - * 4. **Stop** instance #1 — its in-memory `HumanInTheLoopRegistry` - * is discarded. - * 5. Spawn bridge instance #2. - * 6. Inject a synthetic Slack `block_actions` event into instance - * #2's Bolt app via `app.processEvent`. - * 7. Poll Slack: assert the picker has been replaced in-place by - * the resolved-state render AND the agent's natural-language - * reply lands in the same thread. - * 8. Tear down instance #2. - * - * Run: `pnpm e2e:restart` - */ -import "dotenv/config"; -import type { ReceiverEvent } from "@slack/bolt"; -import { - createSlackBridge, - defaultSlackContext, - defaultSlackTools, -} from "@copilotkit/slack"; -import type { SlackBridge } from "@copilotkit/slack"; -import { appComponents } from "../app/components/index.js"; -import { appContext } from "../app/context/app-context.js"; -import { appHitl } from "../app/human-in-the-loop/index.js"; -import { appTools } from "../app/tools/index.js"; -import { postAsUser, threadReplies, BOT_USER_ID } from "./slack-api.js"; - -const TEST_CHANNEL = process.env.E2E_CHANNEL ?? "C0B49MEJ1HQ"; // #ag-ui-bot-test - -function required(name: string): string { - const v = process.env[name]; - if (!v) { - console.error(`missing required env var ${name}`); - process.exit(1); - } - return v; -} - -function makeBridge() { - return createSlackBridge({ - agentUrl: required("AGENT_URL"), - slackBotToken: required("SLACK_BOT_TOKEN"), - slackAppToken: required("SLACK_APP_TOKEN"), - tools: [...defaultSlackTools, ...appTools], - context: [...defaultSlackContext, ...appContext], - components: appComponents, - humanInTheLoopComponents: appHitl, - }); -} - -const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -async function waitForPicker( - parentTs: string, - expectedEventType: string, -): Promise<{ - ts: string; - buttons: Array<{ action_id: string; value: string; text: string }>; - metadata: { event_type?: string; event_payload?: Record<string, unknown> }; -}> { - const t0 = Date.now(); - while (Date.now() - t0 < 30_000) { - await wait(1500); - const r = await threadReplies(TEST_CHANNEL, parentTs, true); - const picker = r.find((m) => { - const md = (m as { metadata?: { event_type?: string } }).metadata; - return m.user === BOT_USER_ID && md?.event_type === expectedEventType; - }) as - | { - ts?: string; - blocks?: Array<Record<string, unknown>>; - metadata?: { - event_type?: string; - event_payload?: Record<string, unknown>; - }; - } - | undefined; - if (!picker) continue; - const buttons: Array<{ action_id: string; value: string; text: string }> = - []; - // confirm_write wraps its blocks in a colored attachment, so the action - // buttons live under attachments[].blocks; older pickers use top-level - // blocks. Scan both. - const pickerAtt = ( - picker as { - attachments?: Array<{ blocks?: Array<Record<string, unknown>> }>; - } - ).attachments; - const allBlocks = [ - ...(picker.blocks ?? []), - ...(pickerAtt ?? []).flatMap((a) => a.blocks ?? []), - ]; - for (const b of allBlocks) { - if ( - b.type === "actions" && - Array.isArray((b as { elements?: unknown[] }).elements) - ) { - for (const el of ( - b as { - elements: Array<{ - type?: string; - action_id?: string; - value?: string; - text?: { text?: string }; - }>; - } - ).elements) { - if (el.type === "button" && el.action_id && el.value) { - buttons.push({ - action_id: el.action_id, - value: el.value, - text: el.text?.text ?? "", - }); - } - } - } - } - return { - ts: picker.ts!, - buttons, - metadata: picker.metadata ?? {}, - }; - } - throw new Error(`never saw picker with event_type=${expectedEventType}`); -} - -async function waitForAgentReply( - parentTs: string, - sinceCount: number, - regex: RegExp, - timeoutMs = 30_000, -): Promise<string> { - const t0 = Date.now(); - while (Date.now() - t0 < timeoutMs) { - await wait(1500); - const r = await threadReplies(TEST_CHANNEL, parentTs); - const replies = r.filter((m) => m.user === BOT_USER_ID); - for (let i = sinceCount; i < replies.length; i++) { - const txt = replies[i]?.text ?? ""; - if (regex.test(txt)) return txt; - } - } - throw new Error(`never saw agent reply matching ${regex}`); -} - -interface BlockActionsBody { - type: "block_actions"; - user: { id: string; username: string; name: string; team_id: string }; - api_app_id: string; - token: string; - container: { - type: "message"; - message_ts: string; - channel_id: string; - thread_ts?: string; - }; - trigger_id: string; - team: { id: string; domain: string }; - channel: { id: string; name: string }; - message: { - ts: string; - type: "message"; - user: string; - text: string; - blocks: unknown[]; - thread_ts?: string; - }; - state: { values: Record<string, unknown> }; - response_url: string; - actions: Array<{ - action_id: string; - block_id: string; - text: { type: "plain_text"; text: string; emoji: boolean }; - type: "button"; - value: string; - action_ts: string; - }>; -} - -function synthesiseBlockActions(args: { - pickerTs: string; - parentTs: string; - actionId: string; - value: string; - buttonText: string; -}): BlockActionsBody { - return { - type: "block_actions", - user: { - id: "U05PN5700P9", - username: "atai", - name: "atai", - team_id: "T05QFA4BW9X", - }, - api_app_id: "A0B49763Y66", - token: "synthetic-token", - container: { - type: "message", - message_ts: args.pickerTs, - channel_id: TEST_CHANNEL, - thread_ts: args.parentTs, - }, - trigger_id: "synthetic-trigger", - team: { id: "T05QFA4BW9X", domain: "copilotkit" }, - channel: { id: TEST_CHANNEL, name: "ag-ui-bot-test" }, - message: { - ts: args.pickerTs, - type: "message", - user: BOT_USER_ID, - text: "", - blocks: [], - thread_ts: args.parentTs, - }, - state: { values: {} }, - response_url: "", - actions: [ - { - action_id: args.actionId, - block_id: "synthetic-block", - text: { type: "plain_text", text: args.buttonText, emoji: true }, - type: "button", - value: args.value, - action_ts: `${Date.now() / 1000}`, - }, - ], - }; -} - -async function injectClick( - bridge: SlackBridge, - ev: BlockActionsBody, -): Promise<void> { - let acked = false; - const fakeEvent: ReceiverEvent = { - body: ev, - ack: async () => { - acked = true; - }, - }; - await bridge.app.processEvent(fakeEvent); - if (!acked) throw new Error("Bolt didn't ack the synthetic event"); -} - -/** - * One full restart-recovery cycle. Returns nothing; throws on any - * assertion failure (the caller decides whether to continue running - * the remaining scenarios). - */ -async function runScenario(args: { - label: string; - prompt: string; - pickerEventType: string; - pickButton: ( - buttons: Array<{ action_id: string; value: string; text: string }>, - ) => { action_id: string; value: string; text: string }; - /** - * Pattern the agent's natural-language reply must match. For - * interrupts the graph is paused and resume produces a fresh - * reply — set this to the expected text. For HITL the graph is - * already finished; pass `undefined` to skip the reply check. - */ - replyRegex?: RegExp; - resolvedTextRegex: RegExp; -}): Promise<void> { - console.log(`\n══════ ${args.label} ══════`); - console.log(`[${args.label}] starting bridge instance #1…`); - const b1 = makeBridge(); - await b1.start(); - console.log(`[${args.label}] instance #1 up`); - - const sent = await postAsUser(TEST_CHANNEL, args.prompt); - const parentTs = (sent as { ts?: string }).ts!; - console.log(`[${args.label}] posted prompt, parent ts:`, parentTs); - - const picker = await waitForPicker(parentTs, args.pickerEventType); - console.log( - `[${args.label}] picker landed at ts=%s with %d buttons`, - picker.ts, - picker.buttons.length, - ); - - // Verify metadata. - const evType = picker.metadata.event_type; - if (evType !== args.pickerEventType) { - throw new Error( - `picker has event_type=${evType}, expected ${args.pickerEventType}`, - ); - } - const ep = picker.metadata.event_payload as { handler?: string } | undefined; - if (!ep?.handler) throw new Error("picker metadata missing handler"); - console.log(`[${args.label}] ✓ picker metadata: handler=%s`, ep.handler); - - for (const btn of picker.buttons) JSON.parse(btn.value); // throws if malformed - console.log( - `[${args.label}] ✓ all %d buttons carry JSON-encoded values`, - picker.buttons.length, - ); - - const existing = await threadReplies(TEST_CHANNEL, parentTs); - const seenCount = existing.filter((m) => m.user === BOT_USER_ID).length; - - console.log(`[${args.label}] stopping bridge instance #1…`); - await b1.stop(); - - console.log( - `[${args.label}] starting bridge instance #2 (fresh in-memory registry)…`, - ); - const b2 = makeBridge(); - await b2.start(); - - const chosen = args.pickButton(picker.buttons); - console.log( - `[${args.label}] simulating click on action_id=%s text="%s" value=%s`, - chosen.action_id, - chosen.text, - chosen.value.slice(0, 80), - ); - try { - await injectClick( - b2, - synthesiseBlockActions({ - pickerTs: picker.ts, - parentTs, - actionId: chosen.action_id, - value: chosen.value, - buttonText: chosen.text, - }), - ); - console.log(`[${args.label}] ✓ synthetic block_actions processed`); - - if (args.replyRegex) { - const reply = await waitForAgentReply( - parentTs, - seenCount, - args.replyRegex, - 30_000, - ); - console.log(`[${args.label}] ✓ agent reply landed: %s`, reply); - } else { - // HITL: no agent reply on this turn — the LangGraph thread is - // already finished; the resolved-render replacement IS the - // visible outcome. Wait briefly to give chat.update time to land. - await wait(2000); - console.log( - `[${args.label}] ✓ (no agent reply expected — HITL graph is already RUN_FINISHED)`, - ); - } - - // Verify picker replaced in-place. - const after = await threadReplies(TEST_CHANNEL, parentTs); - const replacedPicker = after.find((m) => m.ts === picker.ts) as - | { blocks?: Array<Record<string, unknown>> } - | undefined; - if (!replacedPicker) - throw new Error("original picker message vanished entirely"); - const stillHasButtons = (replacedPicker.blocks ?? []).some( - (b) => - b.type === "actions" && - Array.isArray((b as { elements?: unknown[] }).elements) && - ( - (b as { elements: unknown[] }).elements as Array<{ type?: string }> - ).some((e) => e.type === "button"), - ); - if (stillHasButtons) { - throw new Error( - "picker still has buttons — resolved render didn't replace", - ); - } - const sectionText = ( - (replacedPicker.blocks ?? []).find((b) => b.type === "section") as - | { text?: { text?: string } } - | undefined - )?.text?.text; - if (!sectionText || !args.resolvedTextRegex.test(sectionText)) { - throw new Error( - `resolved render didn't match ${args.resolvedTextRegex}; got: ${sectionText}`, - ); - } - console.log( - `[${args.label}] ✓ picker replaced in-place by resolved render: %s`, - sectionText.slice(0, 100), - ); - } finally { - await b2.stop(); - } -} - -async function main() { - await runScenario({ - label: "hitl-restart", - prompt: `<@${BOT_USER_ID}> file a Linear issue titled "Checkout 500s under load" with a one-line description. Use the confirm_write tool to ask me to approve it first.`, - pickerEventType: "copilotkit_slack_hitl", - pickButton: (buttons) => { - const b = buttons.find((btn) => { - try { - const v = JSON.parse(btn.value); - return v && typeof v === "object" && v.confirmed === true; - } catch { - return false; - } - }); - if (!b) throw new Error("no Create (confirmed:true) button found"); - return b; - }, - // After the user approves, the agent goes on to perform the write and - // reply — but the resolved-render replacement of the picker is the - // deterministic signal this test asserts on. - replyRegex: undefined, - resolvedTextRegex: /approved|declined/i, - }); - - console.log("\n══════ ALL GREEN ══════"); - console.log("HITL restart-recovery scenario passed."); -} - -main().catch((err) => { - console.error("[restart-e2e] fatal:", err); - process.exit(1); -}); diff --git a/e2e/telegram-api.ts b/e2e/telegram-api.ts deleted file mode 100644 index 58abdbf..0000000 --- a/e2e/telegram-api.ts +++ /dev/null @@ -1,413 +0,0 @@ -/** - * Telegram Bot API helpers used by the E2E harness. - * - * ## Chosen approach: (b) MANUAL-TRIGGER smoke - * - * Unlike Slack, the Telegram Bot API does NOT allow impersonating a human - * user to send messages programmatically. The Bot API only lets a bot send - * messages AS ITSELF. This creates a bootstrapping problem: - * - * - We cannot "send a message as a test user" purely via the Bot API. - * - A bot can call `sendMessage` into a chat, but the CopilotKit bot's - * loop guard intentionally ignores messages originating from bots - * (including itself) to prevent infinite loops. - * - The MTProto (TDLib / Telegram Desktop) approach — driving a REAL user - * account programmatically — requires a separate phone-number-verified - * account, a registered Telegram API App (api_id + api_hash), a session - * file, and far more infra than is practical here. - * - * Therefore this harness uses a DOCUMENTED MANUAL-TRIGGER flow: - * - * 1. The operator opens the Telegram chat with the bot and sends the test - * prompt manually (the exact text logged by the harness before each case). - * 2. The harness polls `getUpdates` (or `getMessages` via a stored - * `offset`) until it sees the bot's reply in that chat, then runs the - * expectations against the reply text. - * - * ### Path to full automation (approach a) - * - * Full automation IS achievable by adding a second lightweight Telegram bot - * ("sender bot") and a test supergroup: - * - Add both the main bot AND the sender bot to a supergroup. - * - The sender bot calls `sendMessage` into the group; the main bot's - * listener fires on group messages (not from itself), processes them, - * and replies back into the group. - * - The harness drives the sender bot, polls `getUpdates` on the main - * bot token for the group replies, and validates them. - * - * Set TELEGRAM_SENDER_BOT_TOKEN in .env to enable automatic sending when a - * sender bot is available. When it's missing, the harness falls back to the - * manual-trigger flow and logs a clear prompt for the operator. - * - * ### NOTE on coverage - * - * The manual-trigger flow DOES NOT reduce assertion coverage — all - * expectations (finalContains, balancedBrackets, minLength, followUp) are - * evaluated on the real bot reply. What it reduces is automation: the - * operator must type (or paste) each prompt. The harness logs the exact text - * to send and waits up to `maxWaitMs` for a reply before timing out. - */ -import "dotenv/config"; - -// ── Env ────────────────────────────────────────────────────────────────────── - -const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN; -if (!BOT_TOKEN) throw new Error("TELEGRAM_BOT_TOKEN missing in .env"); - -/** - * The numeric chat ID of the test chat where the bot is a member. - * For DMs this is the user's numeric Telegram ID (positive integer). - * For groups/supergroups it is the negative chat ID. - */ -export const TEST_CHAT_ID: string = process.env.TELEGRAM_TEST_CHAT_ID ?? ""; - -/** - * Optional second bot token. When set, the harness sends prompts - * programmatically via this "sender bot" (approach a). When absent, - * the harness falls back to the manual-trigger flow (approach b). - */ -export const SENDER_BOT_TOKEN: string | undefined = - process.env.TELEGRAM_SENDER_BOT_TOKEN; - -// ── Raw Bot API helper ──────────────────────────────────────────────────────── - -const TELEGRAM_API = "https://api.telegram.org/bot"; - -async function tgApi<T = Record<string, unknown>>( - token: string, - method: string, - params: Record<string, unknown> = {}, -): Promise<T> { - const url = `${TELEGRAM_API}${token}/${method}`; - const res = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(params), - }); - const json = (await res.json()) as { - ok: boolean; - result?: T; - description?: string; - }; - if (!json.ok) { - throw new Error( - `Telegram ${method} failed: ${json.description ?? JSON.stringify(json)}`, - ); - } - return json.result as T; -} - -// ── Types ───────────────────────────────────────────────────────────────────── - -export interface TelegramMessage { - message_id: number; - from?: { - id: number; - is_bot: boolean; - username?: string; - first_name?: string; - }; - chat: { id: number; type: string }; - date: number; - text?: string; - reply_to_message?: TelegramMessage; -} - -export interface TelegramUpdate { - update_id: number; - message?: TelegramMessage; - edited_message?: TelegramMessage; -} - -// ── Sending ─────────────────────────────────────────────────────────────────── - -/** - * Send a message into `chatId` using the sender bot token (approach a). - * Returns the sent message (includes its `message_id`). - * - * IMPORTANT: this triggers the main CopilotKit bot only when: - * (a) the chat is a group/supergroup with BOTH the sender bot and the main - * bot as members, OR - * (b) the main bot's listener is configured to also handle messages from - * other bots (non-default — requires explicit allow-bot config). - * - * In a DM context (TELEGRAM_TEST_CHAT_ID is the operator's personal ID) this - * call would fail unless the operator's chat id is also the sender bot's - * user id, which doesn't make sense. Use group chats for automated mode. - */ -export async function sendMessageAsSenderBot( - chatId: string | number, - text: string, - opts: { replyToMessageId?: number } = {}, -): Promise<TelegramMessage> { - if (!SENDER_BOT_TOKEN) { - throw new Error( - "TELEGRAM_SENDER_BOT_TOKEN not set — automated send unavailable", - ); - } - const params: Record<string, unknown> = { chat_id: chatId, text }; - if (opts.replyToMessageId) params.reply_to_message_id = opts.replyToMessageId; - return tgApi<TelegramMessage>(SENDER_BOT_TOKEN, "sendMessage", params); -} - -// ── Polling helpers ─────────────────────────────────────────────────────────── - -/** - * Fetch a page of updates from the main bot since `offset`. - * Uses long-poll with a short timeout so we don't block indefinitely. - */ -export async function getUpdates( - offset: number, - limit = 20, -): Promise<TelegramUpdate[]> { - return tgApi<TelegramUpdate[]>(BOT_TOKEN!, "getUpdates", { - offset, - limit, - timeout: 5, - // Include both new messages and edits so we can observe streamed replies. - // The example bot streams by posting a placeholder and then editing it - // (chunked-edit mode), so we must subscribe to edited_message to see the - // final text. - allowed_updates: ["message", "edited_message"], - }); -} - -/** - * Drain any pending updates from the bot's queue (advances the offset without - * acting on them). Call this BEFORE sending a test prompt so we know the next - * update we see is the bot's reply to our case — not a stale message from a - * previous run. - * - * Returns the update_id to use as the "drain fence": poll for updates with - * `offset > drainFence` after this call. - */ -export async function drainUpdates(): Promise<number> { - let highestUpdateId = -1; - // Keep fetching until we get an empty page (queue exhausted). - for (;;) { - const updates = await getUpdates(highestUpdateId + 1, 100); - if (updates.length === 0) break; - for (const u of updates) { - if (u.update_id > highestUpdateId) highestUpdateId = u.update_id; - } - } - return highestUpdateId; -} - -/** - * Poll the bot's updates for a message FROM THE BOT in `chatId` after - * `sinceUpdateId`. Calls `onSample` after each poll so the caller can record - * mid-stream snapshots. - * - * NOTE: The example bot uses chunked-edit streaming — it posts a placeholder - * message (`_thinking…_`) and then edits it repeatedly as chunks arrive. This - * function subscribes to both `message` and `edited_message` updates (see - * `getUpdates`) and tracks the LATEST text for each bot `message_id`, so - * `finalText` reflects the last edit rather than the initial placeholder. - * - * Returns the highest `update_id` consumed (`reachedUpdateId`) so callers can - * pass it as the baseline for a follow-up `watchForNextReply` call. - */ -export async function watchForReply(args: { - chatId: string | number; - sinceUpdateId: number; - intervalMs: number; - timeoutMs: number; - onSample: (sample: { - elapsedMs: number; - text: string | undefined; - message: TelegramMessage | undefined; - }) => Promise<void> | void; -}): Promise<{ - finalText: string | undefined; - finalMessage: TelegramMessage | undefined; - reachedUpdateId: number; -}> { - const start = Date.now(); - let offset = args.sinceUpdateId + 1; - // Map from message_id → latest known TelegramMessage (tracks edits). - const botMessageMap = new Map<number, TelegramMessage>(); - let stable = 0; - let lastLen = -1; - // Track the highest update_id we have consumed so callers can use it as the - // next baseline without re-delivering already-confirmed updates. - let reachedUpdateId = args.sinceUpdateId; - - while (Date.now() - start < args.timeoutMs) { - const updates = await getUpdates(offset); - for (const u of updates) { - if (u.update_id >= offset) offset = u.update_id + 1; - if (u.update_id > reachedUpdateId) reachedUpdateId = u.update_id; - // Accept both new messages and edits. - const msg = u.message ?? u.edited_message; - if (!msg) continue; - if (String(msg.chat.id) !== String(args.chatId)) continue; - // Track the latest text for each bot message_id. - if (msg.from?.is_bot) { - botMessageMap.set(msg.message_id, msg); - } - } - // The "last" bot message is the one with the highest message_id. - let lastMessage: TelegramMessage | undefined; - for (const msg of botMessageMap.values()) { - if (!lastMessage || msg.message_id > lastMessage.message_id) { - lastMessage = msg; - } - } - const text = lastMessage?.text; - await args.onSample({ - elapsedMs: Date.now() - start, - text, - message: lastMessage, - }); - const len = text?.length ?? 0; - if (len === lastLen && len > 0) { - stable++; - if (stable >= 3) break; - } else { - stable = 0; - lastLen = len; - } - await new Promise((r) => setTimeout(r, args.intervalMs)); - } - - let lastMessage: TelegramMessage | undefined; - for (const msg of botMessageMap.values()) { - if (!lastMessage || msg.message_id > lastMessage.message_id) { - lastMessage = msg; - } - } - return { - finalText: lastMessage?.text, - finalMessage: lastMessage, - reachedUpdateId, - }; -} - -/** - * Watch for a SUBSEQUENT bot reply in the same chat after `seenCount` distinct - * bot message_ids have already been observed. Used by the follow-up step. - * - * Like `watchForReply`, this function tracks both `message` and - * `edited_message` updates and keeps the latest text per `message_id` so edits - * (chunked-edit streaming) are reflected in `finalText`. - * - * `sinceUpdateId` should be the `reachedUpdateId` returned by the preceding - * `watchForReply` call — NOT the original drain fence — because `getUpdates` - * destructively advances the server-side offset and prior updates will not - * reappear. - * - * Returns the highest `update_id` consumed (`reachedUpdateId`). - */ -export async function watchForNextReply(args: { - chatId: string | number; - sinceUpdateId: number; - seenCount: number; - intervalMs: number; - timeoutMs: number; - onSample: (sample: { - elapsedMs: number; - text: string | undefined; - message: TelegramMessage | undefined; - }) => Promise<void> | void; -}): Promise<{ - finalText: string | undefined; - finalMessage: TelegramMessage | undefined; - reachedUpdateId: number; -}> { - const start = Date.now(); - let offset = args.sinceUpdateId + 1; - // Map from message_id → latest known TelegramMessage (tracks edits). - const botMessageMap = new Map<number, TelegramMessage>(); - let stable = 0; - let lastLen = -1; - let reachedUpdateId = args.sinceUpdateId; - - while (Date.now() - start < args.timeoutMs) { - const updates = await getUpdates(offset); - for (const u of updates) { - if (u.update_id >= offset) offset = u.update_id + 1; - if (u.update_id > reachedUpdateId) reachedUpdateId = u.update_id; - // Accept both new messages and edits. - const msg = u.message ?? u.edited_message; - if (!msg) continue; - if (String(msg.chat.id) !== String(args.chatId)) continue; - if (msg.from?.is_bot) { - botMessageMap.set(msg.message_id, msg); - } - } - // Collect distinct bot message_ids in insertion order (Map preserves it). - const distinctMessages = Array.from(botMessageMap.values()).sort( - (a, b) => a.message_id - b.message_id, - ); - // Target is the (seenCount+1)-th distinct message, i.e. the first NEW one. - const target = - distinctMessages.length > args.seenCount - ? distinctMessages[args.seenCount] - : undefined; - const text = target?.text; - await args.onSample({ - elapsedMs: Date.now() - start, - text, - message: target, - }); - const len = text?.length ?? 0; - if (target && len === lastLen && len > 0) { - stable++; - if (stable >= 3) break; - } else { - stable = 0; - lastLen = len; - } - await new Promise((r) => setTimeout(r, args.intervalMs)); - } - - const distinctMessages = Array.from(botMessageMap.values()).sort( - (a, b) => a.message_id - b.message_id, - ); - const target = - distinctMessages.length > args.seenCount - ? distinctMessages[args.seenCount] - : undefined; - return { finalText: target?.text, finalMessage: target, reachedUpdateId }; -} - -// ── Bracket balance ──────────────────────────────────────────────────────────── - -/** - * Check that the text has balanced Markdown code fences and inline backticks. - * - * Telegram uses MarkdownV2 / HTML formatting — but the bot's text field in - * `getUpdates` is the raw text the bot sent, which uses Markdown-style fences - * (the telegram-html module converts them before sending to Telegram). We - * assert on the raw text from the bot's perspective (what the LLM produced) - * before the HTML renderer processes it. - * - * Note: The Telegram harness observes edits via `edited_message` updates, so - * it tracks the latest text of each bot message. The `balancedBrackets` check - * in `telegram-run.ts` is applied to the final (most recently edited) text. - */ -export function isBalanced(text: string): boolean { - if (!text) return true; - - // ── Fences ───────────────────────────────────────────────── - const fences = (text.match(/```/g) || []).length; - if (fences % 2 !== 0) { - const lastFenceIdx = text.lastIndexOf("```"); - const tail = text.slice(lastFenceIdx + 3); - const nl = tail.indexOf("\n"); - const codeBody = nl >= 0 ? tail.slice(nl + 1) : ""; - if (/\S/.test(codeBody)) return false; - // just-opened fence; treat as balanced - } - - // ── Inline backticks (outside fences) ────────────────────── - const noFence = text.replace(/```[\s\S]*?```/g, ""); - const inline = (noFence.match(/`/g) || []).length; - if (inline % 2 !== 0) { - const lastBt = noFence.lastIndexOf("`"); - const after = noFence.slice(lastBt + 1); - if (/\S/.test(after)) return false; - } - return true; -} diff --git a/e2e/telegram-cases.ts b/e2e/telegram-cases.ts deleted file mode 100644 index 9bd228c..0000000 --- a/e2e/telegram-cases.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * Catalog of end-to-end test cases for the Telegram bot harness. - * - * Each case describes a prompt to send and expectations to assert on the - * bot's reply. The shape mirrors `examples/slack/e2e/cases.ts` with - * Telegram-specific adaptations: - * - * - No Block Kit assertions (Telegram uses HTML/MarkdownV2 rendering). - * - No Slack mrkdwn format (`*bold*` → Telegram uses `**bold**` before - * the HTML converter, or `<b>` after). - * - No @mention syntax in prompts (Telegram uses @username or /commands). - * - Bullet list assertion checks for `•` or `-` markers in plain text - * (not Slack's translated `•`). - * - * Fields: - * name human-readable label - * prompt text to send (operator pastes this or sender-bot posts it) - * sampleIntervalMs how often to poll for the bot's reply - * maxWaitMs give up after this long (default 30 s) - * expectations checks on the final reply text - * followUp optional second turn in the same thread - */ - -export interface E2ECase { - name: string; - prompt: string; - sampleIntervalMs?: number; - maxWaitMs?: number; - /** - * Optional follow-up turn: after the first reply lands, this prompt is - * sent into the same reply chain. Used to test conversation continuity. - * In the manual-trigger flow the operator sends this second prompt too; - * in automated mode the sender bot posts it as a reply to the bot's - * previous message. - */ - followUp?: { - prompt: string; - expectations?: E2ECase["expectations"]; - }; - expectations?: { - /** Bot's final reply must contain all of these substrings (case-insensitive). */ - finalContains?: string[]; - /** Bot's final reply must NOT contain any of these. */ - finalNotContains?: string[]; - /** Final text must have balanced code fences and backticks. */ - balancedBrackets?: boolean; - /** Minimum reply length in characters (catches truncation regressions). */ - minLength?: number; - /** - * Custom predicate run against all bot messages collected for this case. - * `replies` is the array of text strings; return an array of error - * strings (empty = pass). - */ - perReplyChecks?: (replies: string[]) => string[]; - }; -} - -export const CASES: E2ECase[] = [ - // ── A. Basic response ────────────────────────────────────────────────────── - { - name: "A1 — single-word echo", - // Confirms the bot responds and loop-guard doesn't swallow the reply. - prompt: "Reply with exactly the word HOTEL and nothing else", - expectations: { - finalContains: ["HOTEL"], - minLength: 5, - }, - }, - { - name: "A2 — single-token response (regression: was ECHO/AL truncation bug)", - prompt: "Reply with exactly the word ECHO and nothing else", - expectations: { - finalContains: ["ECHO"], - finalNotContains: ["…"], - minLength: 4, - }, - }, - - // ── B. Response length / shape ───────────────────────────────────────────── - { - name: "B1 — multi-paragraph prose", - prompt: - "Write 4 paragraphs about the history of the printing press. " + - "Take your time. Be detailed.", - sampleIntervalMs: 1000, - maxWaitMs: 60_000, - expectations: { - minLength: 600, - balancedBrackets: true, - }, - }, - { - name: "B2 — long response balanced fences", - prompt: - "Write a thorough 6-paragraph essay about agent protocols. " + - "Each paragraph 4-6 sentences. Be detailed, no apologies.", - sampleIntervalMs: 1000, - maxWaitMs: 90_000, - expectations: { - minLength: 1000, - balancedBrackets: true, - }, - }, - - // ── B/markdown — Telegram formatting ────────────────────────────────────── - { - name: "B11 — fenced code block (Python snippet)", - // Confirms the LLM emits a fenced block and the bot doesn't corrupt it. - prompt: - "Show me a short Python snippet for a Fibonacci function in a fenced code block.", - sampleIntervalMs: 700, - maxWaitMs: 45_000, - expectations: { - finalContains: ["```"], - balancedBrackets: true, - }, - }, - { - name: "B12 — bullet list", - prompt: - "List three programming languages as bullet points using - markers.", - expectations: { - perReplyChecks: (replies) => { - const joined = replies.join("\n"); - // Expect at least one line starting with "-" or "•" - const hasBullets = /^[-•]/m.test(joined); - if (!hasBullets) { - return ["no bullet-point line found in bot reply"]; - } - return []; - }, - balancedBrackets: true, - }, - }, - - // ── C. Triage / agentic prompts ──────────────────────────────────────────── - { - name: "C1 — triage prompt (structured summary expected)", - // Core use-case for the on-call triage bot. - prompt: "Triage my open issues and give me a structured summary.", - sampleIntervalMs: 1000, - maxWaitMs: 60_000, - expectations: { - // The bot should produce a non-trivial response. - minLength: 100, - balancedBrackets: true, - }, - }, - { - name: "C2 — render table prompt (text/markdown table expected)", - // Unlike Slack (which renders a monospace-aligned table in a code fence), - // Telegram may emit a plain markdown table or a pre-formatted block. - // We assert the key names appear in the output and fences are balanced. - prompt: - "Give me a 3-row table comparing LangGraph, AG-UI, and CopilotKit " + - "(columns: name, role). Use plain text or a code block.", - expectations: { - finalContains: ["langgraph", "ag-ui", "copilotkit"], - balancedBrackets: true, - }, - }, - - // ── D. Conversation continuity ───────────────────────────────────────────── - { - name: "D1 — thread continuation (two-turn conversation)", - // Sends a first prompt, then a follow-up in the same reply chain. - prompt: "Say the single word ALPHA", - expectations: { finalContains: ["ALPHA"] }, - followUp: { - prompt: "Now say the single word BRAVO.", - expectations: { finalContains: ["BRAVO"] }, - }, - }, -]; diff --git a/e2e/telegram-run.ts b/e2e/telegram-run.ts deleted file mode 100644 index 5f24f86..0000000 --- a/e2e/telegram-run.ts +++ /dev/null @@ -1,346 +0,0 @@ -/** - * E2E harness entrypoint for the Telegram bot. - * - * Control flow mirrors `examples/slack/e2e/run.ts`, adapted for the - * Telegram Bot API polling model. - * - * ## Send mode - * - * The harness detects which send mode is available at startup: - * - * AUTOMATED (approach a) - * Requires: TELEGRAM_SENDER_BOT_TOKEN set in .env. - * The sender bot posts each prompt into TELEGRAM_TEST_CHAT_ID; the - * main bot (TELEGRAM_BOT_TOKEN) sees it, processes it, and replies. - * The harness polls getUpdates on the MAIN bot token for the reply. - * - * MANUAL-TRIGGER (approach b — fallback) - * No TELEGRAM_SENDER_BOT_TOKEN needed. - * The harness prints each prompt and waits for the operator to send it - * in the test chat. It then polls getUpdates on the main bot token for - * the bot's reply. Coverage is identical; only the trigger step is manual. - * - * Run with: pnpm e2e:telegram - * - * Optional env: - * CASE_FILTER substring filter on case name (e.g. CASE_FILTER='C1' pnpm e2e:telegram) - */ -import "dotenv/config"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { CASES } from "./telegram-cases.js"; -import type { E2ECase } from "./telegram-cases.js"; -import { - drainUpdates, - sendMessageAsSenderBot, - watchForReply, - watchForNextReply, - isBalanced, - SENDER_BOT_TOKEN, - TEST_CHAT_ID, -} from "./telegram-api.js"; - -const RESULTS_DIR = "./e2e/results"; - -// ── Startup checks ──────────────────────────────────────────────────────────── - -if (!TEST_CHAT_ID) { - console.error( - "TELEGRAM_TEST_CHAT_ID missing in .env — set it to the numeric chat ID " + - "of the chat where the bot is a member.", - ); - process.exit(1); -} - -const AUTOMATED = !!SENDER_BOT_TOKEN; -if (AUTOMATED) { - console.log( - "[e2e] Mode: AUTOMATED — sender bot will post prompts automatically.", - ); -} else { - console.log( - "[e2e] Mode: MANUAL-TRIGGER — you will need to send each prompt manually.\n" + - " (Set TELEGRAM_SENDER_BOT_TOKEN in .env for full automation.)", - ); -} - -// ── Result types ────────────────────────────────────────────────────────────── - -interface CaseResult { - name: string; - prompt: string; - status: "pass" | "fail"; - errors: string[]; - durationMs: number; - finalText: string | undefined; - samples: { - elapsedMs: number; - balanced: boolean; - len: number; - preview: string; - full?: string; - }[]; - followUp?: CaseResult; -} - -// ── Expectations runner ─────────────────────────────────────────────────────── - -function runExpectations( - exp: NonNullable<E2ECase["expectations"]>, - finalText: string | undefined, - errors: string[], - prefix = "", -): void { - const tag = prefix ? `${prefix}: ` : ""; - if (exp.finalContains) { - for (const needle of exp.finalContains) { - if (!(finalText ?? "").toLowerCase().includes(needle.toLowerCase())) { - errors.push(`${tag}missing: ${JSON.stringify(needle)}`); - } - } - } - if (exp.finalNotContains) { - for (const needle of exp.finalNotContains) { - if ((finalText ?? "").toLowerCase().includes(needle.toLowerCase())) { - errors.push(`${tag}contained forbidden: ${JSON.stringify(needle)}`); - } - } - } - if (exp.balancedBrackets && finalText && !isBalanced(finalText)) { - errors.push(`${tag}text has unbalanced brackets`); - } - if (exp.minLength && (finalText?.length ?? 0) < exp.minLength) { - errors.push( - `${tag}too short (${finalText?.length ?? 0} < ${exp.minLength})`, - ); - } -} - -// ── Case runner ─────────────────────────────────────────────────────────────── - -/** - * Wait for the operator to send a prompt (manual-trigger mode). - * Prints the prompt text and waits `promptWaitMs` for the user to act. - */ -async function waitForOperator( - prompt: string, - promptWaitMs: number, -): Promise<void> { - console.log( - `\n [MANUAL] Please send the following message in the test chat:\n` + - ` ┌──────────────────────────────────────────────────────────┐\n` + - ` │ ${prompt.slice(0, 56).padEnd(56)} │\n` + - ` └──────────────────────────────────────────────────────────┘\n` + - ` Waiting up to ${Math.round(promptWaitMs / 1000)}s for your send…`, - ); - await new Promise((r) => setTimeout(r, promptWaitMs)); -} - -async function runCase(spec: E2ECase): Promise<CaseResult> { - const errors: string[] = []; - const samples: CaseResult["samples"] = []; - const t0 = Date.now(); - - const sampleIntervalMs = spec.sampleIntervalMs ?? 1000; - const maxWaitMs = spec.maxWaitMs ?? 30_000; - - // Drain stale updates so we don't accidentally match a previous run's reply. - const drainFence = await drainUpdates(); - - if (AUTOMATED) { - // Automated mode: sender bot sends the prompt. - await sendMessageAsSenderBot(TEST_CHAT_ID, spec.prompt).catch((e: Error) => - errors.push(`send failed: ${e.message}`), - ); - } else { - // Manual-trigger mode: give the operator 15 s to send the prompt manually. - // This wait is BEFORE we start polling — the bot won't have replied yet. - await waitForOperator(spec.prompt, 15_000); - } - - const onSample = (s: { elapsedMs: number; text: string | undefined }) => { - const text = s.text ?? ""; - const balanced = isBalanced(text); - samples.push({ - elapsedMs: s.elapsedMs, - balanced, - len: text.length, - preview: text.slice(0, 100), - ...(text.length > 0 && !balanced ? { full: text } : {}), - }); - }; - - const result = await watchForReply({ - chatId: TEST_CHAT_ID, - sinceUpdateId: drainFence, - intervalMs: sampleIntervalMs, - timeoutMs: maxWaitMs, - onSample, - }); - - // Capture the highest update_id consumed so the follow-up baseline is - // correct. getUpdates is destructive (advancing the offset confirms/deletes - // prior updates server-side), so we must NOT reuse drainFence here. - const firstReplyFence = result.reachedUpdateId; - - const finalText = result.finalText; - const exp = spec.expectations ?? {}; - runExpectations(exp, finalText, errors); - - const unbalancedSamples = samples.filter( - (s) => s.len > 0 && !s.balanced, - ).length; - if (exp.balancedBrackets && unbalancedSamples > 0) { - errors.push(`${unbalancedSamples} mid-stream samples were not balanced`); - } - - if (exp.perReplyChecks && finalText !== undefined) { - for (const e of exp.perReplyChecks([finalText])) { - errors.push(e); - } - } - - // ── Follow-up turn ────────────────────────────────────────────────────────── - let followUpResult: CaseResult | undefined; - if (spec.followUp && finalText) { - const followErrors: string[] = []; - const followSamples: CaseResult["samples"] = []; - const f0 = Date.now(); - - // Since getUpdates is destructive, the first reply's updates are already - // confirmed (gone from the server queue). The follow-up watcher starts from - // firstReplyFence and will see only NEW updates, so seenCount = 0. - const seenCount = 0; - - if (AUTOMATED && result.finalMessage) { - await sendMessageAsSenderBot(TEST_CHAT_ID, spec.followUp.prompt, { - replyToMessageId: result.finalMessage.message_id, - }).catch((e: Error) => - followErrors.push(`followUp send failed: ${e.message}`), - ); - } else { - await waitForOperator(spec.followUp.prompt, 15_000); - } - - const fResult = await watchForNextReply({ - chatId: TEST_CHAT_ID, - sinceUpdateId: firstReplyFence, - seenCount, - intervalMs: sampleIntervalMs, - timeoutMs: maxWaitMs, - onSample: (s) => { - const text = s.text ?? ""; - followSamples.push({ - elapsedMs: s.elapsedMs, - balanced: isBalanced(text), - len: text.length, - preview: text.slice(0, 100), - }); - }, - }); - - const followText = fResult.finalText; - const fexp = spec.followUp.expectations ?? {}; - if (fexp.finalContains) { - for (const needle of fexp.finalContains) { - if (!(followText ?? "").toLowerCase().includes(needle.toLowerCase())) { - followErrors.push(`followUp missing: ${JSON.stringify(needle)}`); - } - } - } - if (fexp.minLength && (followText?.length ?? 0) < fexp.minLength) { - followErrors.push("followUp too short"); - } - - followUpResult = { - name: `${spec.name} → followUp`, - prompt: spec.followUp.prompt, - status: followErrors.length === 0 ? "pass" : "fail", - errors: followErrors, - durationMs: Date.now() - f0, - finalText: followText, - samples: followSamples, - }; - } - - return { - name: spec.name, - prompt: spec.prompt, - status: - errors.length === 0 && (followUpResult?.status ?? "pass") === "pass" - ? "pass" - : "fail", - errors, - durationMs: Date.now() - t0, - finalText, - samples, - followUp: followUpResult, - }; -} - -// ── Main ─────────────────────────────────────────────────────────────────────── - -async function main() { - mkdirSync(RESULTS_DIR, { recursive: true }); - const stamp = new Date().toISOString().replace(/[:.]/g, "-"); - const runDir = join(RESULTS_DIR, stamp); - mkdirSync(runDir, { recursive: true }); - - const results: CaseResult[] = []; - const filter = process.env["CASE_FILTER"]; - const selected = filter - ? CASES.filter((c) => c.name.includes(filter)) - : CASES; - - for (const spec of selected) { - process.stdout.write(`\n──── ${spec.name} ────\n`); - try { - const r = await runCase(spec); - const flag = r.status === "pass" ? "✓" : "✗"; - console.log( - ` ${flag} ${r.durationMs}ms len=${r.finalText?.length ?? 0} samples=${r.samples.length}`, - ); - if (r.errors.length) console.log(" " + r.errors.join("\n ")); - if (r.followUp) { - const fflag = r.followUp.status === "pass" ? "✓" : "✗"; - console.log( - ` ↳ followUp ${fflag} ${r.followUp.durationMs}ms len=${r.followUp.finalText?.length ?? 0} samples=${r.followUp.samples.length}`, - ); - if (r.followUp.errors.length) { - console.log(" " + r.followUp.errors.join("\n ")); - } - } - results.push(r); - } catch (err) { - console.log(` ✗ exception: ${(err as Error).message}`); - results.push({ - name: spec.name, - prompt: spec.prompt, - status: "fail", - errors: [(err as Error).message], - durationMs: 0, - finalText: undefined, - samples: [], - }); - } - } - - writeFileSync( - join(runDir, "report.json"), - JSON.stringify( - { ranAt: stamp, mode: AUTOMATED ? "automated" : "manual", results }, - null, - 2, - ), - ); - const pass = results.filter((r) => r.status === "pass").length; - console.log( - `\n${pass}/${results.length} cases passed. Report: ${runDir}/report.json`, - ); - process.exit(pass === results.length ? 0 : 1); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/package.json b/package.json index de0de42..d6b4a6b 100644 --- a/package.json +++ b/package.json @@ -2,34 +2,25 @@ "name": "opentag", "version": "0.2.0", "private": true, - "description": "OpenTag — an open-source, self-hosted alternative to Claude in Slack: run your own AI agent on your infrastructure, with your model and your tools. Built on @copilotkit/channels; also runs on Discord, Telegram & WhatsApp.", + "description": "OpenTag — an open-source, self-hosted AI agent for Slack and Microsoft Teams, powered by CopilotKit Channels.", "license": "MIT", "type": "module", "scripts": { - "dev": "tsx watch app/index.ts", - "start": "tsx app/index.ts", - "channel": "tsx app/managed.ts", + "dev": "tsx watch server.ts", + "start": "tsx server.ts", + "channel": "tsx server.ts", "agent": "cd agent && uv run python main.py", "runtime": "tsx runtime.ts", "notion-mcp": "tsx scripts/start-notion-mcp.ts", "check-types": "tsc --noEmit -p tsconfig.json", "test": "vitest run", - "e2e": "tsx e2e/run.ts", - "e2e:restart": "tsx e2e/restart-recovery.ts", - "e2e:telegram": "tsx e2e/telegram-run.ts" + "e2e": "tsx e2e/run.ts" }, "dependencies": { - "@copilotkit/channels": "^0.1.1", - "@copilotkit/channels-discord": "^0.0.3", - "@copilotkit/channels-intelligence": "^0.1.1", - "@copilotkit/channels-slack": "^0.1.2", - "@copilotkit/channels-telegram": "^0.0.4", - "@copilotkit/channels-ui": "^0.1.1", - "@copilotkit/channels-whatsapp": "^0.0.2", - "@copilotkit/runtime": "^1.62.3", + "@ag-ui/client": "^0.0.57", + "@copilotkit/channels": "0.2.2-canary.rc-1", + "@copilotkit/runtime": "1.63.3-canary.rc-1", "@notionhq/notion-mcp-server": "^2.4.0", - "@slack/bolt": "^4.2.0", - "@slack/types": "^2.21.1", "@tanstack/ai": "^0.32.0", "@tanstack/ai-mcp": "^0.1.3", "@tanstack/ai-openai": "^0.15.2", @@ -48,5 +39,6 @@ "overrides": { "rxjs": "^7.8.2" } - } + }, + "packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 383baff..c670b5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,39 +11,18 @@ importers: .: dependencies: + '@ag-ui/client': + specifier: ^0.0.57 + version: 0.0.57 '@copilotkit/channels': - specifier: ^0.1.1 - version: 0.1.1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) - '@copilotkit/channels-discord': - specifier: ^0.0.3 - version: 0.0.3(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) - '@copilotkit/channels-intelligence': - specifier: ^0.1.1 - version: 0.1.1(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) - '@copilotkit/channels-slack': - specifier: ^0.1.2 - version: 0.1.2(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) - '@copilotkit/channels-telegram': - specifier: ^0.0.4 - version: 0.0.4(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) - '@copilotkit/channels-ui': - specifier: ^0.1.1 - version: 0.1.1(@ag-ui/core@0.0.57) - '@copilotkit/channels-whatsapp': - specifier: ^0.0.2 - version: 0.0.2(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + specifier: 0.2.2-canary.rc-1 + version: 0.2.2-canary.rc-1(@ag-ui/core@0.0.57)(@opentelemetry/api@1.9.1)(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) '@copilotkit/runtime': - specifier: ^1.62.3 - version: 1.62.3(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@6.47.0(ws@8.21.1)(zod@3.25.76)) + specifier: 1.63.3-canary.rc-1 + version: 1.63.3-canary.rc-1(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) '@notionhq/notion-mcp-server': specifier: ^2.4.0 version: 2.4.1(@cfworker/json-schema@4.1.1)(js-yaml@4.3.0) - '@slack/bolt': - specifier: ^4.2.0 - version: 4.7.3(@types/express@5.0.6) - '@slack/types': - specifier: ^2.21.1 - version: 2.22.0 '@tanstack/ai': specifier: ^0.32.0 version: 0.32.0(@opentelemetry/api@1.9.1) @@ -207,49 +186,80 @@ packages: resolution: {integrity: sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA==} engines: {node: '>=18'} + '@azure/abort-controller@2.2.0': + resolution: {integrity: sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==} + engines: {node: '>=22.0.0'} + + '@azure/core-auth@1.10.1': + resolution: {integrity: sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==} + engines: {node: '>=20.0.0'} + + '@azure/core-util@1.14.0': + resolution: {integrity: sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==} + engines: {node: '>=22.0.0'} + + '@azure/msal-common@16.5.2': + resolution: {integrity: sha512-GkDEL6TYo3HgT3UuqakdgE9PZfc1hMki6+Hwgy1uddb/EauvAKfu85vVhuofRSo22D1xTnWt8Ucwfg4vSCVwvA==} + engines: {node: '>=0.8.0'} + + '@azure/msal-node@5.1.5': + resolution: {integrity: sha512-ObTeMoNPmq19X3z40et9Xvs4ZoWVeJg43PZMRLG5iwVL+2nCtAerG3YTDItqPp1CfXNwmCXBbg8jn1DOx65c3g==} + engines: {node: '>=20'} + '@bufbuild/protobuf@2.12.1': resolution: {integrity: sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==} '@cfworker/json-schema@4.1.1': resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} - '@copilotkit/channels-discord@0.0.3': - resolution: {integrity: sha512-UXkWHcQJ9kLDkkGMXC3NyHuYwdVxWW09uyn1wm9er5tcL5/ZK12gvqgnj7G5j5pF7VI0qWXQ+ZvO3uZENo7o0A==} + '@copilotkit/channels-core@0.2.2-canary.rc-1': + resolution: {integrity: sha512-e1o6AGi67ylOHIFS/GgKWwv879sD4fe4Fuy42PhEDjj83/wQ8Ya5Tdv/X3A3g7elnJDa9nX1UVJvJMbztN3O5Q==} + peerDependencies: + vitest: ^4.0.0 + peerDependenciesMeta: + vitest: + optional: true + + '@copilotkit/channels-discord@0.2.2-canary.rc-1': + resolution: {integrity: sha512-fqjWRhSjRMdh0Ghlisk0gnT8tTyrPR47RpywDUoMxKQfkvObNx+GmgEqc/0c3LmT7yz3+W1z7Lab4AahrAFm8w==} - '@copilotkit/channels-intelligence@0.1.1': - resolution: {integrity: sha512-lS3vzbuRaeLTE+ucdsinBd9YHdm4lqUjvA57wowGMs+/WUZKzKCLGiE9BYOErQzo0PaM8MTx8TomTzNSny287w==} + '@copilotkit/channels-intelligence@0.2.2-canary.rc-1': + resolution: {integrity: sha512-LmjgGB/yuwqvDIiEXK3ZJ4xbaafxT0xuRPMyA7ChrR53y1R1Jta134n+oD4DauI7iHKI5mJ9Bbc0sPPQ9gp+yQ==} - '@copilotkit/channels-slack@0.1.2': - resolution: {integrity: sha512-h8VR+raH1GvdNxEuNcpGMe7TayZxzj33xobq6RCCU4hLu15jadCyYj9CXArYIcwyManlbWE5jqi6n8cla23E0g==} + '@copilotkit/channels-slack@0.2.2-canary.rc-1': + resolution: {integrity: sha512-9Du0gAtoMBEpg0EgCVsM+RZrF+LzIrc7R0m430f5njZ4bIncdFzUNcQ9SPfn64OuIQUl1+WfeTHT4qpZpN4m0g==} - '@copilotkit/channels-telegram@0.0.4': - resolution: {integrity: sha512-LyzGU9wHVPPc7MsR9+w1kBXcEqotI3k8XRJLola99ktzEpAtXTwCmBlnyh+j7UTCMXsRTjKz9eC6ULxDy82GbQ==} + '@copilotkit/channels-teams@0.2.2-canary.rc-1': + resolution: {integrity: sha512-bsswD71VQHh48x03hNEYEq1CFjTb9BSDA7F1bRBkF8LLCEcL4BslYwOL23H+Nn6vvDhz8OSSa+7Tih/jYO7svQ==} - '@copilotkit/channels-ui@0.1.1': - resolution: {integrity: sha512-wQ099E62DEhIuKz8O9Iqy1VP4qCyLtS8d5EwfF2MK8m8amWtuyyTUxV8vC1q9ecJMgAwCtKmeW/fYQR8NsLiXQ==} + '@copilotkit/channels-telegram@0.2.2-canary.rc-1': + resolution: {integrity: sha512-w98CTf53pIAEZ5z+lEZSLWceO3gjVjNCbTaghFuXf45WSx4FAdZeY4eINbOm/OHbWhrXMhWhExYDfQD3vNaawg==} - '@copilotkit/channels-whatsapp@0.0.2': - resolution: {integrity: sha512-T2eCeY6NXSAZbdQsARhhpLGsXP667wWqgsWpMPXy+dULvGhemw/ueyijV5/kEzMnxlaBoZ4TuGClXweMtDxZfw==} + '@copilotkit/channels-ui@0.2.2-canary.rc-1': + resolution: {integrity: sha512-S6z5OzXfrQs5XsQOUF5cGF3qMjzHdcb+rcqvOnruDLqLWdr3PQjHuPpqgjMxELxN1ru+bZsraFBTVROm0zcY9Q==} - '@copilotkit/channels@0.1.1': - resolution: {integrity: sha512-v/cOFfRZWKXstrj06YZmR5kikE9XhXluuJa81hZEhZ4I9C3U+Lnao1K5pL5DtmhUbowynMvBedVUNqKwGFLCvQ==} + '@copilotkit/channels-whatsapp@0.2.2-canary.rc-1': + resolution: {integrity: sha512-aKPxgR3EJ7RI7Mr4Pey+LZp0s7S8If9n1S8/Y1RRqJCqhRFkLcvXAeDCVp79k4p/HzFxzKzLpHhaLSGkOOOwAQ==} + + '@copilotkit/channels@0.2.2-canary.rc-1': + resolution: {integrity: sha512-hSc9Cvpak9M4UYLRcDTe2IkypKa87XytBhoMTBc/YLSXWudTtZMuLqlNnw2Ig+v4KYL7zo1h06pAlhGM4HLqXQ==} peerDependencies: vitest: ^4.0.0 peerDependenciesMeta: vitest: optional: true - '@copilotkit/core@1.62.3': - resolution: {integrity: sha512-Iw0XTJDylh4DTL1qqqoDSvMeL4lXGWfn8UAunnvYtVZ2BF3gs6Lw6K/yOQ0gGQKMqIxeQmgLVclErxwBcaJ8KQ==} + '@copilotkit/core@1.63.3-canary.rc-1': + resolution: {integrity: sha512-0jjmGj0WqkAui6l9SjjXeV9Kx8womeLtdGeMsjSfQKiuEPi+JE2fG4I08pd7D/H7ijEXlL10u5DSbLO+hYeuaQ==} engines: {node: '>=18'} '@copilotkit/license-verifier@0.5.0': resolution: {integrity: sha512-vrwKtIpYwF0FT9ZoYASH8owa2cGV0dhDvJGaCRaRMStwDxpc6DRdydKkhx8cWZXyBRxEYcq/Vygv4JvevhQQdQ==} - '@copilotkit/runtime@1.62.3': - resolution: {integrity: sha512-pOyQbd0IGH/rmyZ7yoGodc2FRicO3AXBI6y7lQDGjbat2lI9SLhsGQve7O3QfeYH0UZ4T9019EgnnKtSN7ImUQ==} + '@copilotkit/runtime@1.63.3-canary.rc-1': + resolution: {integrity: sha512-j8jATXoqM8P3EmhMyWm4D6l3DUQSDWZwTeR0c3SgnljMgc3TAHD40m4c7pQ7xJ0XMXSvZqHDQYyAJjmltzYbXA==} peerDependencies: - '@anthropic-ai/sdk': ^0.57.0 + '@anthropic-ai/sdk': '>=0.57.0' '@langchain/aws': '>=0.1.9' '@langchain/community': '>=0.3.58' '@langchain/core': '>=0.3.66' @@ -279,8 +289,8 @@ packages: openai: optional: true - '@copilotkit/shared@1.62.3': - resolution: {integrity: sha512-dfqaYjfJzTIjMBUMOHGZGoZ+v9pumsBuM1FMi5Sb66WkzxbtIKN0CCbYXXIHd0xsEo1pa8u1vZSN30sBtJuQ7g==} + '@copilotkit/shared@1.63.3-canary.rc-1': + resolution: {integrity: sha512-IGriuU7Z7jKQLLfqezo2L1DrJXWSUw33H65PLf4M0AfESaUhlsfcHom8YmA+E1v+PuXCr3MiWKH66fI1em42Dw==} peerDependencies: '@ag-ui/core': '>=0.0.48' @@ -604,6 +614,26 @@ packages: resolution: {integrity: sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==} engines: {node: '>=8'} + '@microsoft/agents-activity@1.7.1': + resolution: {integrity: sha512-jbomRRTOWh1fxaEt4zb5ZTCVSBMMlH+/A7ws2RBEiCF3yGFioWbuLPHmwW+KAU+VkE8979IAbtagXXjd5x+XkQ==} + engines: {node: '>=20.0.0'} + + '@microsoft/agents-hosting@1.7.1': + resolution: {integrity: sha512-sWxAlNMpRHtuLfs5Z454HGpl/caFXksyo3MdJm5QBnkgM2cCETUAD5TaHzxYCnYM9DDW45wHJbzDOAX5mZnSoQ==} + engines: {node: '>=20.0.0'} + + '@microsoft/agents-telemetry@1.7.1': + resolution: {integrity: sha512-zCtoJaqefNSUzHrv7gBxy9AHdoMsXT5fIS+/43UEN3e1CPZjaYIdGU6eMkX/LGWUaVJaPpIlMoV79sKvecn2Ow==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@opentelemetry/api': '1' + '@opentelemetry/api-logs': '>=0.214.0 <1' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/api-logs': + optional: true + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -918,6 +948,10 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typespec/ts-http-runtime@0.3.7': + resolution: {integrity: sha512-JVUD8X2tfDMWjcjLs4yVxxVrS8yR5vnh386GAXT9Qj79nBxxXSaHFQZg5FweLmT8HlPQ3kii6noUB+Z9RN7DvQ==} + engines: {node: '>=22.0.0'} + '@vercel/oidc@3.2.0': resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} @@ -1489,6 +1523,10 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -1580,6 +1618,10 @@ packages: jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + jwks-rsa@4.0.1: + resolution: {integrity: sha512-poXwUA8S4cP9P5N8tZS3xnUDJH8WmwSGfKK9gIaRPdjLHyJtd9iX/cngX9CUIe0Caof5JhK2EbN7N5lnnaf9NA==} + engines: {node: ^20.19.0 || ^22.12.0 || >= 23.0.0} + jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} @@ -1686,6 +1728,12 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + limiter@1.1.5: + resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} + + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash.get@4.4.2: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. @@ -1723,6 +1771,13 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lru-memoizer@3.0.0: + resolution: {integrity: sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==} + magic-bytes.js@1.13.0: resolution: {integrity: sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==} @@ -2397,6 +2452,9 @@ packages: zod@3.24.1: resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} + zod@3.25.75: + resolution: {integrity: sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg==} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -2596,15 +2654,56 @@ snapshots: dependencies: json-schema: 0.4.0 + '@azure/abort-controller@2.2.0': + dependencies: + tslib: 2.8.1 + + '@azure/core-auth@1.10.1': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-util@1.14.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@typespec/ts-http-runtime': 0.3.7 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/msal-common@16.5.2': {} + + '@azure/msal-node@5.1.5': + dependencies: + '@azure/msal-common': 16.5.2 + jsonwebtoken: 9.0.3 + '@bufbuild/protobuf@2.12.1': {} '@cfworker/json-schema@4.1.1': {} - '@copilotkit/channels-discord@0.0.3(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': + '@copilotkit/channels-core@0.2.2-canary.rc-1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': dependencies: '@ag-ui/client': 0.0.57 - '@copilotkit/channels': 0.1.1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) - '@copilotkit/channels-ui': 0.1.1(@ag-ui/core@0.0.57) + '@ag-ui/core': 0.0.57 + '@copilotkit/channels-ui': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57) + '@copilotkit/core': 1.63.3-canary.rc-1(@ag-ui/core@0.0.57)(zod@3.25.76) + '@copilotkit/shared': 1.63.3-canary.rc-1(@ag-ui/core@0.0.57) + zod-to-json-schema: 3.25.2(zod@3.25.76) + optionalDependencies: + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)) + transitivePeerDependencies: + - encoding + - zod + + '@copilotkit/channels-discord@0.2.2-canary.rc-1(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': + dependencies: + '@ag-ui/client': 0.0.57 + '@copilotkit/channels-core': 0.2.2-canary.rc-1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-ui': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57) discord.js: 14.27.0 zod: 3.25.76 transitivePeerDependencies: @@ -2614,11 +2713,11 @@ snapshots: - utf-8-validate - vitest - '@copilotkit/channels-intelligence@0.1.1(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': + '@copilotkit/channels-intelligence@0.2.2-canary.rc-1(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': dependencies: '@ag-ui/client': 0.0.57 - '@copilotkit/channels': 0.1.1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) - '@copilotkit/channels-ui': 0.1.1(@ag-ui/core@0.0.57) + '@copilotkit/channels-core': 0.2.2-canary.rc-1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-ui': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57) phoenix: 1.8.9 transitivePeerDependencies: - '@ag-ui/core' @@ -2626,14 +2725,14 @@ snapshots: - vitest - zod - '@copilotkit/channels-slack@0.1.2(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': + '@copilotkit/channels-slack@0.2.2-canary.rc-1(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': dependencies: '@ag-ui/client': 0.0.57 '@ag-ui/core': 0.0.57 - '@copilotkit/channels': 0.1.1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) - '@copilotkit/channels-ui': 0.1.1(@ag-ui/core@0.0.57) - '@copilotkit/core': 1.62.3(@ag-ui/core@0.0.57)(zod@3.25.76) - '@copilotkit/shared': 1.62.3(@ag-ui/core@0.0.57) + '@copilotkit/channels-core': 0.2.2-canary.rc-1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-ui': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57) + '@copilotkit/core': 1.63.3-canary.rc-1(@ag-ui/core@0.0.57)(zod@3.25.76) + '@copilotkit/shared': 1.63.3-canary.rc-1(@ag-ui/core@0.0.57) '@slack/bolt': 4.7.3(@types/express@5.0.6) '@slack/types': 2.22.0 '@slack/web-api': 7.19.0 @@ -2649,11 +2748,32 @@ snapshots: - utf-8-validate - vitest - '@copilotkit/channels-telegram@0.0.4(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': + '@copilotkit/channels-teams@0.2.2-canary.rc-1(@opentelemetry/api@1.9.1)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': dependencies: '@ag-ui/client': 0.0.57 - '@copilotkit/channels': 0.1.1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) - '@copilotkit/channels-ui': 0.1.1(@ag-ui/core@0.0.57) + '@ag-ui/core': 0.0.57 + '@copilotkit/channels-core': 0.2.2-canary.rc-1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-ui': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57) + '@copilotkit/core': 1.63.3-canary.rc-1(@ag-ui/core@0.0.57)(zod@3.25.76) + '@copilotkit/shared': 1.63.3-canary.rc-1(@ag-ui/core@0.0.57) + '@microsoft/agents-activity': 1.7.1 + '@microsoft/agents-hosting': 1.7.1(@opentelemetry/api@1.9.1) + express: 4.22.2 + rxjs: 7.8.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/api-logs' + - encoding + - supports-color + - vitest + + '@copilotkit/channels-telegram@0.2.2-canary.rc-1(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': + dependencies: + '@ag-ui/client': 0.0.57 + '@copilotkit/channels-core': 0.2.2-canary.rc-1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-ui': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57) grammy: 1.44.0 zod: 3.25.76 transitivePeerDependencies: @@ -2662,42 +2782,52 @@ snapshots: - supports-color - vitest - '@copilotkit/channels-ui@0.1.1(@ag-ui/core@0.0.57)': + '@copilotkit/channels-ui@0.2.2-canary.rc-1(@ag-ui/core@0.0.57)': dependencies: - '@copilotkit/shared': 1.62.3(@ag-ui/core@0.0.57) + '@copilotkit/shared': 1.63.3-canary.rc-1(@ag-ui/core@0.0.57) transitivePeerDependencies: - '@ag-ui/core' - encoding - '@copilotkit/channels-whatsapp@0.0.2(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': + '@copilotkit/channels-whatsapp@0.2.2-canary.rc-1(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': dependencies: '@ag-ui/client': 0.0.57 - '@copilotkit/channels': 0.1.1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) - '@copilotkit/channels-ui': 0.1.1(@ag-ui/core@0.0.57) + '@copilotkit/channels-core': 0.2.2-canary.rc-1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-ui': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57) transitivePeerDependencies: - '@ag-ui/core' - encoding - vitest - zod - '@copilotkit/channels@0.1.1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': + '@copilotkit/channels@0.2.2-canary.rc-1(@ag-ui/core@0.0.57)(@opentelemetry/api@1.9.1)(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': dependencies: - '@ag-ui/client': 0.0.57 - '@ag-ui/core': 0.0.57 - '@copilotkit/channels-ui': 0.1.1(@ag-ui/core@0.0.57) - '@copilotkit/core': 1.62.3(@ag-ui/core@0.0.57)(zod@3.25.76) - '@copilotkit/shared': 1.62.3(@ag-ui/core@0.0.57) - zod-to-json-schema: 3.25.2(zod@3.25.76) + '@copilotkit/channels-core': 0.2.2-canary.rc-1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-discord': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) + '@copilotkit/channels-intelligence': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-slack': 0.2.2-canary.rc-1(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) + '@copilotkit/channels-teams': 0.2.2-canary.rc-1(@opentelemetry/api@1.9.1)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) + '@copilotkit/channels-telegram': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) + '@copilotkit/channels-ui': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57) + '@copilotkit/channels-whatsapp': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) optionalDependencies: vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)) transitivePeerDependencies: + - '@ag-ui/core' + - '@opentelemetry/api' + - '@opentelemetry/api-logs' + - '@types/express' + - bufferutil + - debug - encoding + - supports-color + - utf-8-validate - zod - '@copilotkit/core@1.62.3(@ag-ui/core@0.0.57)(zod@3.25.76)': + '@copilotkit/core@1.63.3-canary.rc-1(@ag-ui/core@0.0.57)(zod@3.25.76)': dependencies: '@ag-ui/client': 0.0.57 - '@copilotkit/shared': 1.62.3(@ag-ui/core@0.0.57) + '@copilotkit/shared': 1.63.3-canary.rc-1(@ag-ui/core@0.0.57) '@tanstack/pacer': 0.20.1 phoenix: 1.8.9 rxjs: 7.8.2 @@ -2709,7 +2839,7 @@ snapshots: '@copilotkit/license-verifier@0.5.0': {} - '@copilotkit/runtime@1.62.3(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@6.47.0(ws@8.21.1)(zod@3.25.76))': + '@copilotkit/runtime@1.63.3-canary.rc-1(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': dependencies: '@ag-ui/a2ui-middleware': 0.0.10(@ag-ui/client@0.0.57)(rxjs@7.8.2) '@ag-ui/client': 0.0.57 @@ -2723,8 +2853,10 @@ snapshots: '@ai-sdk/google-vertex': 3.0.150(zod@3.25.76) '@ai-sdk/mcp': 1.0.62(zod@3.25.76) '@ai-sdk/openai': 3.0.85(zod@3.25.76) + '@copilotkit/channels-core': 0.2.2-canary.rc-1(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-intelligence': 0.2.2-canary.rc-1(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) '@copilotkit/license-verifier': 0.5.0 - '@copilotkit/shared': 1.62.3(@ag-ui/core@0.0.57) + '@copilotkit/shared': 1.63.3-canary.rc-1(@ag-ui/core@0.0.57) '@graphql-yoga/plugin-defer-stream': 3.21.2(graphql-yoga@5.21.2(graphql@16.14.2))(graphql@16.14.2) '@hono/node-server': 1.19.14(hono@4.12.30) '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) @@ -2768,9 +2900,10 @@ snapshots: - supports-color - svelte - utf-8-validate + - vitest - vue - '@copilotkit/shared@1.62.3(@ag-ui/core@0.0.57)': + '@copilotkit/shared@1.63.3-canary.rc-1(@ag-ui/core@0.0.57)': dependencies: '@ag-ui/client': 0.0.57 '@ag-ui/core': 0.0.57 @@ -3071,6 +3204,33 @@ snapshots: dependencies: '@lukeed/csprng': 1.1.0 + '@microsoft/agents-activity@1.7.1': + dependencies: + zod: 3.25.75 + + '@microsoft/agents-hosting@1.7.1(@opentelemetry/api@1.9.1)': + dependencies: + '@azure/core-auth': 1.10.1 + '@azure/msal-node': 5.1.5 + '@microsoft/agents-activity': 1.7.1 + '@microsoft/agents-telemetry': 1.7.1(@opentelemetry/api@1.9.1) + jsonwebtoken: 9.0.3 + jwks-rsa: 4.0.1 + zod: 3.25.75 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/api-logs' + - supports-color + + '@microsoft/agents-telemetry@1.7.1(@opentelemetry/api@1.9.1)': + dependencies: + '@microsoft/agents-activity': 1.7.1 + debug: 4.4.3 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + transitivePeerDependencies: + - supports-color + '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@3.24.1)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.30) @@ -3463,6 +3623,14 @@ snapshots: dependencies: '@types/node': 22.20.1 + '@typespec/ts-http-runtime@0.3.7': + dependencies: + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@vercel/oidc@3.2.0': {} '@vitest/expect@4.1.10': @@ -4136,6 +4304,13 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -4223,6 +4398,16 @@ snapshots: ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 + jwks-rsa@4.0.1: + dependencies: + '@types/jsonwebtoken': 9.0.10 + debug: 4.4.3 + jose: 6.2.3 + limiter: 1.1.5 + lru-memoizer: 3.0.0 + transitivePeerDependencies: + - supports-color + jws@4.0.1: dependencies: jwa: 2.0.1 @@ -4305,6 +4490,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + limiter@1.1.5: {} + + lodash.clonedeep@4.5.0: {} + lodash.get@4.4.2: {} lodash.includes@4.3.0: {} @@ -4329,6 +4518,13 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.5.2: {} + + lru-memoizer@3.0.0: + dependencies: + lodash.clonedeep: 4.5.0 + lru-cache: 11.5.2 + magic-bytes.js@1.13.0: {} magic-string@0.30.21: @@ -4931,4 +5127,6 @@ snapshots: zod@3.24.1: {} + zod@3.25.75: {} + zod@3.25.76: {} diff --git a/runtime.ts b/runtime.ts index 32a673e..5bb1bcf 100644 --- a/runtime.ts +++ b/runtime.ts @@ -1,5 +1,5 @@ /** - * Agent backend for the KiteBot triage assistant. + * Alternate TypeScript agent backend for OpenTag. * * This is the brain behind the chat bridge: a single CopilotKit * `BuiltInAgent` (LLM + MCP) served over AG-UI by a `CopilotSseRuntime`. @@ -7,10 +7,9 @@ * is no Python, no `langgraph dev`, no A2UI middleware. Everything is a * few dozen lines of TypeScript. * - * The bridge (`app/index.ts`) fronts this same backend with the - * platform-specific `@copilotkit/channels-*` packages (Slack, Discord, - * Telegram, WhatsApp) and cross-platform `@copilotkit/channels-ui` - * components, so this runtime itself has no platform-specific code. + * The canonical Channel host (`server.ts`) fronts this same backend with the + * `@copilotkit/channels` umbrella package and keeps this runtime free of + * platform-specific code. * * What it does * ------------ @@ -22,9 +21,8 @@ * search/create pages) from each server at runtime. * * The chat-side primitives (read_thread, the confirm_write HITL picker, - * the issue/page @copilotkit/channels-ui components) are forwarded to the - * agent as client-provided tools by the bridge on every run — see - * `app/index.ts`. + * the issue/page Channels components) are forwarded to the agent as + * client-provided tools by the Channel on every run — see `app/channel.ts`. * * Auth & deployment * ----------------- diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..bcdbbf0 --- /dev/null +++ b/server.ts @@ -0,0 +1,203 @@ +import "dotenv/config"; +import { + createServer, + type RequestListener, + type Server, +} from "node:http"; +import { pathToFileURL } from "node:url"; +import type { ChannelsControl } from "@copilotkit/runtime/v2"; +import { createAgentFactory } from "./app/agent.js"; +import { createOpenTagChannel } from "./app/channel.js"; +import { readEnvironment, type AppEnvironment } from "./app/env.js"; +import { resolvePlatforms } from "./app/platforms.js"; +import { closeBrowser } from "./app/render/browser.js"; +import { createOpenTagRuntime } from "./app/runtime-host.js"; + +export type RuntimeListener = RequestListener & { + channels?: ChannelsControl; +}; + +export interface HttpServerLike { + readonly listening: boolean; + listen(port: number, host: string, callback: () => void): unknown; + close(callback: (error?: Error) => void): unknown; + once(event: "error", listener: (error: Error) => void): unknown; + off(event: "error", listener: (error: Error) => void): unknown; +} + +export interface SignalTarget { + once(signal: NodeJS.Signals, listener: () => void): unknown; + off(signal: NodeJS.Signals, listener: () => void): unknown; +} + +export interface RunningOpenTagServer { + server: HttpServerLike; + shutdown(): Promise<void>; +} + +export interface StartOpenTagServerOptions { + listener: RuntimeListener; + port: number; + closeBrowser: () => Promise<void>; + createHttpServer?: (listener: RequestListener) => HttpServerLike; + signalTarget?: SignalTarget; + readinessTimeoutMs?: number; + onShutdownError?: (error: unknown) => void; +} + +function listen(server: HttpServerLike, port: number): Promise<void> { + return new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off("error", onError); + reject(error); + }; + + server.once("error", onError); + server.listen(port, "::", () => { + server.off("error", onError); + resolve(); + }); + }); +} + +function close(server: HttpServerLike): Promise<void> { + if (!server.listening) return Promise.resolve(); + + return new Promise((resolve, reject) => { + server.close((error?: Error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +async function settleCleanup( + operations: ReadonlyArray<() => Promise<void>>, +): Promise<void> { + const results = await Promise.allSettled( + operations.map((operation) => operation()), + ); + for (const result of results) { + if (result.status === "rejected") { + console.error("[opentag] startup cleanup failed", result.reason); + } + } +} + +export async function startOpenTagServer( + options: StartOpenTagServerOptions, +): Promise<RunningOpenTagServer> { + const controls = options.listener.channels; + if (!controls) { + throw new Error( + "The CopilotRuntime listener has no Channels lifecycle control.", + ); + } + + let server: HttpServerLike | undefined; + + try { + await controls.ready({ + timeoutMs: options.readinessTimeoutMs ?? 15_000, + }); + + const createHttpServer = + options.createHttpServer ?? + ((listener: RequestListener): Server => createServer(listener)); + server = createHttpServer(options.listener); + await listen(server, options.port); + } catch (error) { + const failedServer = server; + await settleCleanup([ + () => controls.stop(), + ...(failedServer ? [() => close(failedServer)] : []), + options.closeBrowser, + ]); + throw error; + } + + const startedServer = server; + const signalTarget = options.signalTarget ?? process; + const onShutdownError = + options.onShutdownError ?? + ((error: unknown) => { + console.error("[opentag] shutdown failed", error); + process.exitCode = 1; + }); + let shutdownPromise: Promise<void> | undefined; + + const shutdown = (): Promise<void> => { + if (shutdownPromise) return shutdownPromise; + + shutdownPromise = (async () => { + signalTarget.off("SIGINT", onSignal); + signalTarget.off("SIGTERM", onSignal); + + const results = await Promise.allSettled([ + controls.stop(), + close(startedServer), + options.closeBrowser(), + ]); + const errors = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (errors.length > 0) { + throw new AggregateError(errors, "OpenTag shutdown failed"); + } + })(); + + return shutdownPromise; + }; + + const onSignal = (): void => { + void shutdown().catch(onShutdownError); + }; + signalTarget.once("SIGINT", onSignal); + signalTarget.once("SIGTERM", onSignal); + + return { server: startedServer, shutdown }; +} + +export function createOpenTagApplication( + environment: AppEnvironment = readEnvironment(), +) { + const platform = resolvePlatforms(environment); + const channel = createOpenTagChannel({ + name: environment.channelName, + adapters: platform.adapters, + agent: createAgentFactory({ + url: environment.agentUrl, + authHeader: environment.agentAuthHeader, + }), + platformTools: platform.tools, + platformContext: platform.context, + }); + const runtimeHost = createOpenTagRuntime({ environment, channel }); + + return { channel, environment, ...runtimeHost }; +} + +export async function main(): Promise<RunningOpenTagServer> { + const application = createOpenTagApplication(); + const running = await startOpenTagServer({ + listener: application.listener, + port: application.environment.port, + closeBrowser, + }); + + console.log( + `[opentag] channel "${application.environment.channelName}" listening on [::]:${application.environment.port}`, + ); + return running; +} + +const isMain = + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isMain) { + void main().catch((error: unknown) => { + console.error("[opentag] startup failed", error); + process.exitCode = 1; + }); +} diff --git a/tsconfig.json b/tsconfig.json index e736d85..d26261d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,8 +14,14 @@ "lib": ["es2022", "dom"], "types": ["node"], "jsx": "react-jsx", - "jsxImportSource": "@copilotkit/channels-ui" + "jsxImportSource": "@copilotkit/channels" }, - "include": ["app/**/*.ts", "app/**/*.tsx", "runtime.ts", "scripts/**/*.ts"], + "include": [ + "app/**/*.ts", + "app/**/*.tsx", + "runtime.ts", + "server.ts", + "scripts/**/*.ts" + ], "exclude": ["node_modules", "e2e"] } diff --git a/vitest.config.ts b/vitest.config.ts index b03a7fd..c7f2558 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,11 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ - esbuild: { - jsx: "automatic", - jsxImportSource: "@copilotkit/channels-ui", + oxc: { + jsx: { + runtime: "automatic", + importSource: "@copilotkit/channels", + }, }, test: { include: ["app/**/*.test.ts", "app/**/*.test.tsx"], From 9236c337466bffdc7c5ea7d18172bbcebb70b7bc Mon Sep 17 00:00:00 2001 From: Tyler Slaton <tyler@copilotkit.ai> Date: Mon, 27 Jul 2026 15:02:34 -0400 Subject: [PATCH 02/19] Fix platform scoping and interrupt confirmation --- agent/agent.py | 13 +- agent/main.py | 15 +- agent/tests/test_health.py | 34 +++ agent/tests/test_tools.py | 32 +++ agent/tools.py | 19 ++ app/agent.test.ts | 10 +- app/agent.ts | 28 +- app/channel.test.ts | 244 ++++++++++++++++-- app/{channel.ts => channel.tsx} | 27 +- app/commands/__tests__/commands.test.ts | 52 +++- app/commands/index.ts | 17 +- app/env.test.ts | 17 ++ app/env.ts | 13 + .../__tests__/confirm-write-tool.test.tsx | 74 ------ .../__tests__/confirm-write.test.tsx | 51 +++- app/human-in-the-loop/confirm-write-tool.tsx | 52 ---- app/human-in-the-loop/confirm-write.tsx | 54 +++- app/human-in-the-loop/index.ts | 11 +- app/modals/__tests__/file-issue.test.tsx | 32 ++- app/modals/file-issue.tsx | 19 +- app/platforms.test.ts | 37 +-- app/platforms.ts | 25 +- app/runtime-host.test.ts | 2 - app/tools/index.ts | 3 - server.ts | 2 - 25 files changed, 618 insertions(+), 265 deletions(-) rename app/{channel.ts => channel.tsx} (80%) delete mode 100644 app/human-in-the-loop/__tests__/confirm-write-tool.test.tsx delete mode 100644 app/human-in-the-loop/confirm-write-tool.tsx diff --git a/agent/agent.py b/agent/agent.py index 2656a7d..12b753b 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -13,7 +13,7 @@ from langgraph.checkpoint.memory import MemorySaver from copilotkit import CopilotKitMiddleware -from tools import research, internal_source_tools +from tools import confirm_write, research, internal_source_tools load_dotenv() @@ -33,6 +33,11 @@ frontend offers them, rather than large blocks of raw markdown - For internal or company-specific questions, prefer the team's Notion/Linear (internal sources) first; use the web for external questions +- CRITICAL: Immediately before every Linear or Notion create or update tool + call, call confirm_write with the exact action and draft details +- Proceed with that write only when the resumed result says confirmed is true; + otherwise acknowledge the decision and stop +- Reads and rendering never require confirmation Your workflow: 1. PLAN: Create a research plan using write_todos with clear, actionable steps @@ -106,7 +111,11 @@ def build_agent(): # research tool wraps an internal Deep Agent that runs via .invoke() so # its text doesn't stream to the frontend. internal_tools = internal_source_tools() - main_tools = [research, *internal_tools] if has_research else [*internal_tools] + main_tools = ( + [confirm_write, research, *internal_tools] + if has_research + else [confirm_write, *internal_tools] + ) system_prompt = BASE_SYSTEM_PROMPT + ( RESEARCH_TOOL_ADDENDUM if has_research else NO_RESEARCH_TOOL_ADDENDUM diff --git a/agent/main.py b/agent/main.py index 8b57c5a..864df5b 100644 --- a/agent/main.py +++ b/agent/main.py @@ -57,13 +57,14 @@ def health(): agent_graph = build_agent() # Note: we intentionally do NOT pass an emit_tool_calls allowlist here. - # KiteBot's channel bridge forwards generative-UI + HITL tools (issue_card, - # render_chart, render_table, show_links, confirm_write, ...) that must be - # emitted to the frontend for cards to render and the write-gate to surface. - # An allowlist would filter those out. The recursion limit for complex - # research tasks (6+ research calls + file operations) is already applied - # to the graph itself via `.with_config({"recursion_limit": 100})` in - # agent.py, so no additional AG-UI config is needed here. + # KiteBot's channel bridge forwards generative-UI tools (issue_card, + # render_chart, render_table, show_links, ...) that must be emitted to the + # frontend for cards to render. The backend-owned confirm_write tool emits + # an interrupt through the same stream. An allowlist could filter these + # events out. The recursion limit for complex research tasks (6+ research + # calls + file operations) is already applied to the graph itself via + # `.with_config({"recursion_limit": 100})` in agent.py, so no additional + # AG-UI config is needed here. # Add AG-UI endpoint at root path for CopilotKit frontend add_langgraph_fastapi_endpoint( diff --git a/agent/tests/test_health.py b/agent/tests/test_health.py index a451da9..aa05996 100644 --- a/agent/tests/test_health.py +++ b/agent/tests/test_health.py @@ -68,3 +68,37 @@ def test_build_agent_requires_openai(monkeypatch): import pytest with pytest.raises(RuntimeError, match="OPENAI_API_KEY"): agent_mod.build_agent() + + +def test_build_agent_registers_confirm_write(monkeypatch): + captured = {} + + class FakeGraph: + def with_config(self, config): + captured["config"] = config + return self + + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.delenv("TAVILY_API_KEY", raising=False) + monkeypatch.setattr(agent_mod, "ChatOpenAI", lambda **_kwargs: object()) + monkeypatch.setattr(agent_mod, "internal_source_tools", lambda: []) + + def fake_create_deep_agent(**kwargs): + captured["tools"] = kwargs["tools"] + return FakeGraph() + + monkeypatch.setattr(agent_mod, "create_deep_agent", fake_create_deep_agent) + + agent_mod.build_agent() + + assert [tool.name for tool in captured["tools"]] == ["confirm_write"] + + +def test_system_prompt_requires_confirmation_only_for_writes(): + prompt = agent_mod.BASE_SYSTEM_PROMPT + + assert "CRITICAL:" in prompt + assert "confirm_write" in prompt + assert "Linear or Notion create or update" in prompt + assert "only when" in prompt and "confirmed" in prompt + assert "Reads and rendering never require confirmation" in prompt diff --git a/agent/tests/test_tools.py b/agent/tests/test_tools.py index 68021b1..c739958 100644 --- a/agent/tests/test_tools.py +++ b/agent/tests/test_tools.py @@ -55,3 +55,35 @@ async def get_tools(self): assert len(result) > 0 assert set(result) == {"tool-for-linear", "tool-for-notion"} + + +def test_confirm_write_interrupts_with_structured_action(monkeypatch): + calls = [] + expected_answer = '{"confirmed": true}' + expected_response = {"confirmed": True} + + def fake_interrupt(*, action, args): + calls.append({"action": action, "args": args}) + return expected_answer, expected_response + + monkeypatch.setattr(tools, "copilotkit_interrupt", fake_interrupt, raising=False) + confirm_write = getattr(tools, "confirm_write", None) + + assert confirm_write is not None + result = confirm_write.invoke( + { + "action": "Create Linear issue", + "detail": "CPK-9: Checkout 500s", + } + ) + + assert calls == [ + { + "action": "confirm_write", + "args": { + "action": "Create Linear issue", + "detail": "CPK-9: Checkout 500s", + }, + } + ] + assert result == expected_answer diff --git a/agent/tools.py b/agent/tools.py index a1fdbd7..7ce85bb 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -17,6 +17,25 @@ from langchain_core.messages import HumanMessage from tavily import TavilyClient from langchain_mcp_adapters.client import MultiServerMCPClient +from copilotkit.langgraph import copilotkit_interrupt + + +@tool +def confirm_write(action: str, detail: str | None = None): + """Pause before a Linear or Notion write and ask the user to approve it. + + Args: + action: One-line summary of exactly what will be written. + detail: Optional drafted title, description, or outline. + + Returns: + The user's resume response after the confirmation interrupt. + """ + answer, _response = copilotkit_interrupt( + action="confirm_write", + args={"action": action, "detail": detail}, + ) + return answer def _do_internet_search(query: str, max_results: int = 5) -> list[dict[str, Any]]: diff --git a/app/agent.test.ts b/app/agent.test.ts index 1d6aae8..666089d 100644 --- a/app/agent.test.ts +++ b/app/agent.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { AgentContentPart, IncomingMessage } from "@copilotkit/channels"; -import { SanitizingHttpAgent } from "@copilotkit/channels/slack"; +import { + defaultSlackContext, + defaultSlackTools, + SanitizingHttpAgent, +} from "@copilotkit/channels/slack"; import { buildAgentHeaders, createAgentFactory, @@ -38,7 +42,9 @@ describe("mentionRunInput", () => { mentionRunInput(message({ contentParts: parts }), "intelligence"), ).toEqual({ prompt: parts, + tools: defaultSlackTools, context: [ + ...defaultSlackContext, { description: "Requesting slack user", value: "Ada (slack id U1)", @@ -49,7 +55,9 @@ describe("mentionRunInput", () => { it("does not duplicate the current prompt for direct adapter ingress", () => { expect(mentionRunInput(message(), "slack")).toEqual({ + tools: defaultSlackTools, context: [ + ...defaultSlackContext, { description: "Requesting slack user", value: "Ada (slack id U1)", diff --git a/app/agent.ts b/app/agent.ts index 343157f..e5b2a9a 100644 --- a/app/agent.ts +++ b/app/agent.ts @@ -1,9 +1,15 @@ import type { AgentContentPart, + ChannelTool, ContextEntry, IncomingMessage, + PlatformUser, } from "@copilotkit/channels"; -import { SanitizingHttpAgent } from "@copilotkit/channels/slack"; +import { + defaultSlackContext, + defaultSlackTools, + SanitizingHttpAgent, +} from "@copilotkit/channels/slack"; import { senderContext } from "./sender-context.js"; export function promptFromMessage( @@ -37,12 +43,30 @@ export function mentionRunInput( transportPlatform: string, ): { prompt?: string | AgentContentPart[]; + tools?: ChannelTool[]; context: ContextEntry[]; } { return { ...(transportPlatform === "intelligence" ? { prompt: promptFromMessage(message) } : {}), - context: senderContext(message.user, message.platform), + ...platformRunInput(message.platform, message.user), + }; +} + +export function platformRunInput( + platform: string, + user: PlatformUser | undefined, +): { + tools?: ChannelTool[]; + context: ContextEntry[]; +} { + const slack = platform === "slack"; + return { + ...(slack ? { tools: [...defaultSlackTools] } : {}), + context: [ + ...(slack ? defaultSlackContext : []), + ...senderContext(user, platform), + ], }; } diff --git a/app/channel.test.ts b/app/channel.test.ts index 8bb9aab..4e9c88d 100644 --- a/app/channel.test.ts +++ b/app/channel.test.ts @@ -3,15 +3,20 @@ import type { RunAgentParameters, RunAgentResult, } from "@ag-ui/client"; -import { afterEach, describe, expect, it } from "vitest"; +import { EventType } from "@ag-ui/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { - defineChannelTool, FakeAdapter, FakeAgent, + MemoryStore, type Channel, - type ContextEntry, + type ChannelNode, } from "@copilotkit/channels"; -import { z } from "zod"; +import { + defaultSlackContext, + defaultSlackTools, + renderSlackMessage, +} from "@copilotkit/channels/slack"; import { appContext } from "./context/app-context.js"; import { appTools } from "./tools/index.js"; import { createOpenTagChannel } from "./channel.js"; @@ -30,35 +35,44 @@ class CapturingAgent extends FakeAgent { const channels: Channel[] = []; +function findButton( + nodes: ChannelNode[], + confirmed: boolean, +): ChannelNode | undefined { + for (const node of nodes) { + if ( + node.type === "button" && + (node.props.value as { confirmed?: boolean } | undefined)?.confirmed === + confirmed + ) { + return node; + } + const children = node.props.children; + if (Array.isArray(children)) { + const found = findButton(children as ChannelNode[], confirmed); + if (found) return found; + } + } + return undefined; +} + afterEach(async () => { await Promise.all(channels.splice(0).map((channel) => channel.ɵruntime.stop())); }); function makeChannel(options: { - platform: "intelligence" | "slack"; + platform: "intelligence" | "slack" | "teams"; agent?: FakeAgent; }) { const adapter = new FakeAdapter({ platform: options.platform }); - const platformTool = defineChannelTool({ - name: "platform_lookup", - description: "Test platform lookup.", - parameters: z.object({}), - handler: () => "ok", - }); - const platformContext: ContextEntry = { - description: "Platform guidance", - value: "Use native formatting.", - }; const agent = options.agent ?? new CapturingAgent(); const channel = createOpenTagChannel({ name: "opentag", adapters: [adapter], agent, - platformTools: [platformTool], - platformContext: [platformContext], }); channels.push(channel); - return { adapter, agent, channel, platformContext, platformTool }; + return { adapter, agent, channel }; } describe("createOpenTagChannel", () => { @@ -67,8 +81,6 @@ describe("createOpenTagChannel", () => { name: "custom-channel", adapters: [], agent: new FakeAgent(), - platformTools: [], - platformContext: [], }); channels.push(channel); @@ -81,11 +93,13 @@ describe("createOpenTagChannel", () => { "preview", "triage", ]); + expect(appTools.map(({ name }) => name)).not.toContain("confirm_write"); }); - it("registers app and platform tools/context on agent runs", async () => { - const { adapter, agent, channel, platformContext, platformTool } = - makeChannel({ platform: "slack" }); + it("injects Slack defaults per managed Slack run", async () => { + const { adapter, agent, channel } = makeChannel({ + platform: "intelligence", + }); await channel.ɵruntime.start(); await adapter.getSink().onTurn({ @@ -98,11 +112,11 @@ describe("createOpenTagChannel", () => { const call = (agent as CapturingAgent).calls[0]; expect(call?.tools?.map(({ name }) => name).sort()).toEqual( - [...appTools.map(({ name }) => name), platformTool.name].sort(), + [...appTools, ...defaultSlackTools].map(({ name }) => name).sort(), ); expect(call?.context).toEqual([ ...appContext, - platformContext, + ...defaultSlackContext, { description: "Requesting slack user", value: "Ada (slack id U1)", @@ -110,6 +124,39 @@ describe("createOpenTagChannel", () => { ]); }); + it("does not leak Slack defaults into Teams when both direct adapters share the Channel", async () => { + const slackAdapter = new FakeAdapter({ platform: "slack" }); + const teamsAdapter = new FakeAdapter({ platform: "teams" }); + const agent = new CapturingAgent(); + const channel = createOpenTagChannel({ + name: "opentag", + adapters: [slackAdapter, teamsAdapter], + agent, + }); + channels.push(channel); + + await channel.ɵruntime.start(); + await teamsAdapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "hello", + platform: "teams", + user: { id: "T1", name: "Ada" }, + }); + + const call = agent.calls[0]; + expect(call?.tools?.map(({ name }) => name).sort()).toEqual( + appTools.map(({ name }) => name).sort(), + ); + expect(call?.context).toEqual([ + ...appContext, + { + description: "Requesting teams user", + value: "Ada (teams id T1)", + }, + ]); + }); + it("injects managed content parts as the current agent prompt", async () => { const { adapter, agent, channel } = makeChannel({ platform: "intelligence", @@ -183,4 +230,151 @@ describe("createOpenTagChannel", () => { expect(JSON.stringify(adapter.posted)).toMatch(/sorry.*error/i); }); + + it("posts a valid confirm_write interrupt card and returns immediately", async () => { + const agent = new FakeAgent([ + (subscriber) => { + subscriber.onCustomEvent?.({ + event: { + type: EventType.CUSTOM, + name: "on_interrupt", + value: { + action: "confirm_write", + args: { + action: "Create Linear issue", + detail: "CPK-9: Checkout 500s", + }, + }, + }, + } as never); + }, + ]); + const { adapter, channel } = makeChannel({ + platform: "intelligence", + agent, + }); + + await channel.ɵruntime.start(); + const turn = adapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "file this", + platform: "slack", + user: { id: "U1" }, + }); + const result = await Promise.race([ + Promise.resolve(turn).then(() => "returned"), + new Promise<string>((resolve) => + setTimeout(() => resolve("blocked"), 100), + ), + ]); + + expect(result).toBe("returned"); + expect(adapter.posted).toHaveLength(1); + const { blocks, accent } = renderSlackMessage(adapter.posted[0]!); + expect(accent).toBe("#E2B340"); + expect(JSON.stringify(blocks)).toContain("Create Linear issue"); + expect(JSON.stringify(blocks)).toContain("CPK-9: Checkout 500s"); + }); + + it("rejects malformed confirm_write interrupt payloads", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const agent = new FakeAgent([ + (subscriber) => { + subscriber.onCustomEvent?.({ + event: { + type: EventType.CUSTOM, + name: "on_interrupt", + value: { + action: "unexpected_action", + args: { action: "Injected write" }, + }, + }, + } as never); + }, + ]); + const { adapter, channel } = makeChannel({ + platform: "intelligence", + agent, + }); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "file this", + platform: "slack", + user: { id: "U1" }, + }); + + expect(JSON.stringify(adapter.posted)).toMatch(/sorry.*error/i); + expect(JSON.stringify(adapter.posted)).not.toContain("Injected write"); + consoleError.mockRestore(); + }); + + it("preserves confirm_write button actions across a Channel restart", async () => { + const sharedState = new MemoryStore(); + const firstAdapter = new FakeAdapter({ platform: "intelligence" }); + firstAdapter.stateStore = sharedState; + const firstAgent = new FakeAgent([ + (subscriber) => { + subscriber.onCustomEvent?.({ + event: { + type: EventType.CUSTOM, + name: "on_interrupt", + value: { + action: "confirm_write", + args: { action: "Create Linear issue", detail: "CPK-9" }, + }, + }, + } as never); + }, + ]); + const firstChannel = createOpenTagChannel({ + name: "opentag", + adapters: [firstAdapter], + agent: firstAgent, + }); + channels.push(firstChannel); + await firstChannel.ɵruntime.start(); + await firstAdapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "file this", + platform: "slack", + user: { id: "U1" }, + }); + + const createButton = findButton(firstAdapter.posted[0]!, true); + expect(createButton).toBeDefined(); + const actionId = ( + createButton?.props.onClick as { id?: string } | undefined + )?.id; + expect(actionId).toMatch(/^ck:/); + await firstChannel.ɵruntime.stop(); + + const secondAdapter = new FakeAdapter({ platform: "intelligence" }); + secondAdapter.stateStore = sharedState; + const secondAgent = new FakeAgent(); + const secondChannel = createOpenTagChannel({ + name: "opentag", + adapters: [secondAdapter], + agent: secondAgent, + }); + channels.push(secondChannel); + await secondChannel.ɵruntime.start(); + await secondAdapter.getSink().onInteraction({ + id: actionId!, + conversationKey: "c1", + replyTarget: {}, + messageRef: { id: "msg-1" }, + value: { confirmed: true }, + }); + + expect(secondAdapter.updated).toHaveLength(1); + expect(JSON.stringify(secondAdapter.updated)).toContain("Approved"); + expect(secondAgent.runAgentCalls).toBe(1); + }); }); diff --git a/app/channel.ts b/app/channel.tsx similarity index 80% rename from app/channel.ts rename to app/channel.tsx index be1157d..b524ca0 100644 --- a/app/channel.ts +++ b/app/channel.tsx @@ -1,9 +1,8 @@ import type { AbstractAgent } from "@ag-ui/client"; +import { z } from "zod"; import { createChannel, type Channel, - type ChannelTool, - type ContextEntry, type PlatformAdapter, } from "@copilotkit/channels"; import { mentionRunInput } from "./agent.js"; @@ -14,12 +13,18 @@ import { ConfirmWrite } from "./human-in-the-loop/index.js"; import { FILE_ISSUE_CALLBACK, fileIssueSubmit } from "./modals/file-issue.js"; import { appTools } from "./tools/index.js"; +const confirmWriteInterruptSchema = z.object({ + action: z.literal("confirm_write"), + args: z.object({ + action: z.string().min(1), + detail: z.string().nullish(), + }), +}); + export interface CreateOpenTagChannelOptions { name: string; adapters: PlatformAdapter[]; agent: AbstractAgent | ((threadId: string) => AbstractAgent); - platformTools: ReadonlyArray<ChannelTool>; - platformContext: ReadonlyArray<ContextEntry>; } /** @@ -34,8 +39,8 @@ export function createOpenTagChannel( provider: "slack", adapters: options.adapters, agent: options.agent, - tools: [...appTools, ...options.platformTools], - context: [...appContext, ...options.platformContext], + tools: appTools, + context: [...appContext], commands: appCommands, components: [IssueCard, IssueList, PageList, ConfirmWrite], }); @@ -55,6 +60,16 @@ export function createOpenTagChannel( channel.onModalSubmit(FILE_ISSUE_CALLBACK, fileIssueSubmit); + channel.onInterrupt("on_interrupt", async ({ payload, thread }) => { + const { args } = confirmWriteInterruptSchema.parse(payload); + await thread.post( + <ConfirmWrite + action={args.action} + detail={args.detail ?? undefined} + />, + ); + }); + channel.onThreadStarted(async ({ thread, user }) => { if (!user?.name) return; diff --git a/app/commands/__tests__/commands.test.ts b/app/commands/__tests__/commands.test.ts index 8ae0511..0f6c016 100644 --- a/app/commands/__tests__/commands.test.ts +++ b/app/commands/__tests__/commands.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, vi } from "vitest"; import { renderToIR, type ChannelNode } from "@copilotkit/channels"; +import { + defaultSlackContext, + defaultSlackTools, +} from "@copilotkit/channels/slack"; import { appCommands } from "../index.js"; import type { CommandContext } from "@copilotkit/channels"; @@ -23,7 +27,11 @@ const byName = (name: string) => { function fakeThread() { return { runAgent: vi.fn( - async (_input?: { prompt?: string; context?: unknown }) => undefined, + async (_input?: { + prompt?: string; + tools?: unknown; + context?: unknown; + }) => undefined, ), post: vi.fn(async (_ui?: unknown) => ({ id: "m1" })), }; @@ -121,6 +129,40 @@ describe("example slash commands", () => { }); }); + it.each([ + ["agent", "why is prod down"], + ["triage", "checkout is failing"], + ])( + "managed /%s uses its Slack command origin for defaults and sender context", + async (command, text) => { + const thread = { + ...fakeThread(), + platform: "intelligence", + }; + + await byName(command).handler( + ctx({ + command, + text, + thread: thread as never, + platform: "slack", + user: { id: "U1", name: "Ada" }, + }), + ); + + const input = thread.runAgent.mock.calls[0]![0]; + expect(input?.tools).toEqual(defaultSlackTools); + expect(input?.context).toEqual([ + ...defaultSlackContext, + { + description: "Requesting slack user", + value: "Ada (slack id U1)", + }, + ]); + expect(JSON.stringify(input)).not.toContain("intelligence user"); + }, + ); + it("/preview posts an ephemeral draft on the native (non-fallback) path", async () => { const preview = appCommands.find((c) => c.name === "preview")!; expect(preview).toBeDefined(); @@ -196,6 +238,7 @@ describe("example slash commands", () => { const capturedView = openModal.mock.calls[0]![0]; const ir = renderToIR(capturedView); const t = tags(ir[0]); + expect(ir[0]?.props.privateMetadata).toBe("slack"); expect(t).toContain("modal_select"); expect(t).toContain("modal_radio"); }); @@ -217,6 +260,13 @@ describe("example slash commands", () => { expect.stringMatching(/aren.t supported|chat/i), ); expect(runAgent).toHaveBeenCalledTimes(1); + expect(runAgent.mock.calls[0]![0]?.tools).toBeUndefined(); + expect(runAgent.mock.calls[0]![0]?.context).toEqual([ + { + description: "Requesting teams user", + value: "U1 (teams id U1)", + }, + ]); }); it("/file-issue posts an error message when openModal resolves { ok: false }", async () => { diff --git a/app/commands/index.ts b/app/commands/index.ts index 3d8b599..bb64fb1 100644 --- a/app/commands/index.ts +++ b/app/commands/index.ts @@ -15,7 +15,7 @@ import { type ChannelCommand, type Thread as ChannelThread, } from "@copilotkit/channels"; -import { senderContext } from "../sender-context.js"; +import { platformRunInput } from "../agent.js"; import { IssueCard } from "../components/index.js"; import { FileIssueModal } from "../modals/file-issue.js"; @@ -51,14 +51,14 @@ export const appCommands: ChannelCommand[] = [ defineChannelCommand({ name: "agent", description: "Ask the triage agent anything (no @mention needed).", - async handler({ thread, text, user }) { + async handler({ thread, text, user, platform }) { if (!text) { await thread.post("Usage: `/agent <your question>`"); return; } await runAgentSafely("agent", thread, { prompt: text, - context: senderContext(user, thread.platform), + ...platformRunInput(platform, user), }); }, }), @@ -69,13 +69,13 @@ export const appCommands: ChannelCommand[] = [ name: "triage", description: "Summarize the conversation and propose Linear issues to file.", - async handler({ thread, text, user }) { + async handler({ thread, text, user, platform }) { const prompt = text ? `Triage this and propose Linear issues to file: ${text}` : "Triage the current conversation: summarize it and propose Linear issues to file."; await runAgentSafely("triage", thread, { prompt, - context: senderContext(user, thread.platform), + ...platformRunInput(platform, user), }); }, }), @@ -139,12 +139,15 @@ export const appCommands: ChannelCommand[] = [ prompt: "The user wants to file a Linear issue but this platform has no modal form. " + "Ask them for a title and description, then (after the usual confirm) file it.", - context: senderContext(user, platform), + ...platformRunInput(platform, user), }); return; } const res = await openModal( - FileIssueModal({ rich: platform === "slack" }), + FileIssueModal({ + rich: platform === "slack", + sourcePlatform: platform, + }), ); if (!res.ok) { await thread.post( diff --git a/app/env.test.ts b/app/env.test.ts index b1f9ad2..2a660c3 100644 --- a/app/env.test.ts +++ b/app/env.test.ts @@ -75,6 +75,23 @@ describe("readEnvironment", () => { }), ).toThrow('Invalid TEAMS_PORT: "bad"'); }); + + it.each([ + ["SLACK_BOT_TOKEN", { SLACK_BOT_TOKEN: "xoxb-test" }], + ["SLACK_APP_TOKEN", { SLACK_APP_TOKEN: "xapp-test" }], + ["TEAMS_CLIENT_ID", { TEAMS_CLIENT_ID: "teams-client" }], + ["TEAMS_CLIENT_SECRET", { TEAMS_CLIENT_SECRET: "teams-secret" }], + ])( + "rejects an incomplete direct-adapter credential pair containing only %s", + (_name, partialCredentials) => { + expect(() => + readEnvironment({ + ...requiredEnvironment, + ...partialCredentials, + }), + ).toThrow(/must be set together/i); + }, + ); }); describe("parsePort", () => { diff --git a/app/env.ts b/app/env.ts index cf63316..07178b4 100644 --- a/app/env.ts +++ b/app/env.ts @@ -28,6 +28,16 @@ function required(env: NodeJS.ProcessEnv, name: string): string { return value; } +function requireTogether( + env: NodeJS.ProcessEnv, + first: string, + second: string, +): void { + if (Boolean(env[first]) !== Boolean(env[second])) { + throw new Error(`${first} and ${second} must be set together`); + } +} + export function parsePort( raw: string | undefined, defaultPort = 3000, @@ -45,6 +55,9 @@ export function parsePort( export function readEnvironment( env: NodeJS.ProcessEnv = process.env, ): AppEnvironment { + requireTogether(env, "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"); + requireTogether(env, "TEAMS_CLIENT_ID", "TEAMS_CLIENT_SECRET"); + return { agentUrl: required(env, "AGENT_URL"), agentAuthHeader: env.AGENT_AUTH_HEADER, diff --git a/app/human-in-the-loop/__tests__/confirm-write-tool.test.tsx b/app/human-in-the-loop/__tests__/confirm-write-tool.test.tsx deleted file mode 100644 index 9250415..0000000 --- a/app/human-in-the-loop/__tests__/confirm-write-tool.test.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { renderToIR, type ChannelNode } from "@copilotkit/channels"; -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 ChannelNode), - ); - expect(accent).toBe("#E2B340"); - const header = blocks.find((b) => b.type === "header") as - | { text: { text: string } } - | undefined; - expect(header?.text.text).toContain("Create Linear issue"); - }); - - it("returns {confirmed:false} when the user declines", async () => { - const { thread } = fakeThread({ confirmed: false }); - - const result = await confirmWriteTool.handler( - { action: "Create Linear issue" }, - { thread, platform: "slack" } as never, - ); - - expect(result).toBe( - "The user DECLINED — do not write; acknowledge and stop.", - ); - }); - - it("returns a NO-RESPONSE message and logs when awaitChoice resolves null (timeout/dismissed)", async () => { - const { thread } = fakeThread(null); - const consoleError = vi - .spyOn(console, "error") - .mockImplementation(() => undefined); - - const result = await confirmWriteTool.handler( - { action: "Create Linear issue" }, - { thread, platform: "slack" } as never, - ); - - expect(result).toMatch(/no response/i); - expect(result).not.toMatch(/APPROVED/); - expect(consoleError).toHaveBeenCalled(); - - consoleError.mockRestore(); - }); -}); diff --git a/app/human-in-the-loop/__tests__/confirm-write.test.tsx b/app/human-in-the-loop/__tests__/confirm-write.test.tsx index fb2f1dd..daeae79 100644 --- a/app/human-in-the-loop/__tests__/confirm-write.test.tsx +++ b/app/human-in-the-loop/__tests__/confirm-write.test.tsx @@ -105,24 +105,29 @@ describe("ConfirmWrite", () => { expect(blocks.some((b) => b.type === "section")).toBe(false); }); - it("approve onClick updates the picker in place to the resolved (green) state", async () => { + it("approve onClick updates the picker and resumes the interrupted agent", async () => { const ir = renderToIR( <ConfirmWrite action="Create Linear issue" detail="CPK-9: ..." />, ); const create = buttonByText(ir, "Create"); - // `value` survives on the button props — that's what awaitChoice resolves to. + // `value` survives on the button props and native interaction payload. expect(create.props.value).toEqual({ confirmed: true }); const update = vi.fn(async () => ({ id: "m1" })); + const resume = vi.fn(async () => ({ id: "m2" })); const ctx = { - thread: { update }, + thread: { update, resume }, message: { ref: { id: "m1" } }, } as unknown as InteractionContext; await (create.props.onClick as ClickHandler)(ctx); expect(update).toHaveBeenCalledTimes(1); + expect(resume).toHaveBeenCalledWith({ confirmed: true }); + expect(update.mock.invocationCallOrder[0]).toBeLessThan( + resume.mock.invocationCallOrder[0]!, + ); const [ref, renderable] = update.mock.calls[0] as unknown as [ { id: string }, Parameters<typeof renderToIR>[0], @@ -141,7 +146,7 @@ describe("ConfirmWrite", () => { expect(context?.elements[0]?.text).toContain("Approved"); }); - it("cancel onClick updates the picker in place to the declined (red) state", async () => { + it("cancel onClick updates the picker and resumes the interrupted agent", async () => { const ir = renderToIR( <ConfirmWrite action="Create Linear issue" detail="CPK-9: ..." />, ); @@ -150,14 +155,19 @@ describe("ConfirmWrite", () => { expect(cancel.props.value).toEqual({ confirmed: false }); const update = vi.fn(async () => ({ id: "m1" })); + const resume = vi.fn(async () => ({ id: "m2" })); const ctx = { - thread: { update }, + thread: { update, resume }, message: { ref: { id: "m1" } }, } as unknown as InteractionContext; await (cancel.props.onClick as ClickHandler)(ctx); expect(update).toHaveBeenCalledTimes(1); + expect(resume).toHaveBeenCalledWith({ confirmed: false }); + expect(update.mock.invocationCallOrder[0]).toBeLessThan( + resume.mock.invocationCallOrder[0]!, + ); const [ref, renderable] = update.mock.calls[0] as unknown as [ { id: string }, Parameters<typeof renderToIR>[0], @@ -175,4 +185,35 @@ describe("ConfirmWrite", () => { | undefined; expect(context?.elements[0]?.text).toContain("Declined"); }); + + it("replaces the optimistic card with a retry state when resume fails", async () => { + const ir = renderToIR( + <ConfirmWrite action="Create Linear issue" detail="CPK-9: ..." />, + ); + const create = buttonByText(ir, "Create"); + const failure = new Error("resume unavailable"); + const update = vi.fn(async () => ({ id: "m1" })); + const resume = vi.fn(async () => { + throw failure; + }); + const ctx = { + thread: { update, resume }, + message: { ref: { id: "m1" } }, + } as unknown as InteractionContext; + + await expect((create.props.onClick as ClickHandler)(ctx)).rejects.toBe( + failure, + ); + + expect(update).toHaveBeenCalledTimes(2); + const [, failedRenderable] = update.mock.calls[1] as unknown as [ + { id: string }, + Parameters<typeof renderToIR>[0], + ]; + const { blocks, accent } = renderSlackMessage( + renderToIR(failedRenderable), + ); + expect(accent).toBe("#EB5757"); + expect(JSON.stringify(blocks)).toMatch(/couldn.t resume|retry/i); + }); }); diff --git a/app/human-in-the-loop/confirm-write-tool.tsx b/app/human-in-the-loop/confirm-write-tool.tsx deleted file mode 100644 index d47e3a3..0000000 --- a/app/human-in-the-loop/confirm-write-tool.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/** - * `confirm_write` — the agent-facing write-gate TOOL. - * - * The migration kept the {@link ConfirmWrite} JSX card but this tool is what - * makes the system prompt's contract real: "call the confirm_write tool before - * any Linear/Notion write". In the new model HITL is a BLOCKING FRONTEND TOOL — - * the handler calls `await thread.awaitChoice(<ConfirmWrite .../>)`, which posts - * the picker and BLOCKS until the user clicks Create/Cancel, then resolves to - * the clicked button's `value` (`{ confirmed: boolean }`). The agent only - * performs the write once this returns `{ confirmed: true }`. - */ -import { z } from "zod"; -import { defineChannelTool } 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 = defineChannelTool({ - name: "confirm_write", - description: - "Ask the user to approve a write before you perform it. Posts a " + - "confirm/cancel card and BLOCKS until the user clicks; returns " + - "{confirmed: boolean}. You MUST call this before creating or modifying " + - "anything in Linear or Notion. Reads never need confirmation.", - parameters: confirmWriteSchema, - async handler({ action, detail }, { thread }) { - const choice = await thread.awaitChoice<{ confirmed?: boolean }>( - <ConfirmWrite action={action} detail={detail} />, - ); - if (choice == null) { - console.error( - "[confirm-write] no choice resolved (timeout/dismissed)", - ); - return "NO RESPONSE — the confirmation card was dismissed or timed out; do not write."; - } - return choice.confirmed - ? "The user APPROVED the write — proceed." - : "The user DECLINED — do not write; acknowledge and stop."; - }, -}); diff --git a/app/human-in-the-loop/confirm-write.tsx b/app/human-in-the-loop/confirm-write.tsx index be003bb..86752a6 100644 --- a/app/human-in-the-loop/confirm-write.tsx +++ b/app/human-in-the-loop/confirm-write.tsx @@ -1,11 +1,10 @@ /** * `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 }`. + * `agent/agent.py`) to confirm BEFORE creating an issue or a page: a tool handler + * pauses through a LangGraph interrupt. The Channel interrupt handler posts + * this card and returns immediately. A click updates the card, then resumes + * the paused graph with `{ confirmed: true | false }`. * * Each button also carries an `onClick` that updates the picker in place to a * resolved / declined state — so the card reflects the decision the moment it's @@ -32,6 +31,35 @@ export interface ConfirmWriteProps { detail?: string; } +async function resumeOrShowFailure( + thread: InteractionContext["thread"], + messageRef: InteractionContext["message"]["ref"], + action: string, + confirmed: boolean, +): Promise<void> { + try { + await thread.resume({ confirmed }); + } catch (error) { + try { + await thread.update( + messageRef, + <Message accent="#EB5757"> + <Header>{`⚠️ ${action} paused`}</Header> + <Context> + {"I couldn't resume the agent. Please retry the action."} + </Context> + </Message>, + ); + } catch (updateError) { + console.error( + "[confirm-write] failed to show resume error", + updateError, + ); + } + throw error; + } +} + export function ConfirmWrite({ action, detail }: ConfirmWriteProps) { return ( <Message accent="#E2B340"> @@ -52,8 +80,14 @@ export function ConfirmWrite({ action, detail }: ConfirmWriteProps) { </Message>, ); } catch (err) { - console.error("[confirm-write] onClick failed", err); + console.error("[confirm-write] approval update failed", err); } + await resumeOrShowFailure( + thread, + message.ref, + action, + true, + ); }} > Create @@ -71,8 +105,14 @@ export function ConfirmWrite({ action, detail }: ConfirmWriteProps) { </Message>, ); } catch (err) { - console.error("[confirm-write] onClick failed", err); + console.error("[confirm-write] decline update failed", err); } + await resumeOrShowFailure( + thread, + message.ref, + action, + false, + ); }} > Cancel diff --git a/app/human-in-the-loop/index.ts b/app/human-in-the-loop/index.ts index 31c01e0..a69a38c 100644 --- a/app/human-in-the-loop/index.ts +++ b/app/human-in-the-loop/index.ts @@ -1,13 +1,10 @@ /** * App-specific human-in-the-loop components — interactive Block Kit cards the - * agent can render to ask the user a structured question and wait for the - * answer. + * Channel interrupt handlers can render to ask the user a structured question + * before resuming the paused agent. * - * `confirm_write` is now a JSX component (`ConfirmWrite`) used as a BLOCKING - * FRONTEND TOOL: a tool handler calls `await thread.awaitChoice(<ConfirmWrite - * .../>)` (wired in a later wave), which posts the picker and resolves to the - * clicked button's `value`. Add new HITL components here and re-export them. + * `confirm_write` is a backend LangGraph tool. Its `on_interrupt` event posts + * `ConfirmWrite`; the card's buttons call `thread.resume(...)`. */ export { ConfirmWrite } from "./confirm-write.js"; export type { ConfirmWriteProps } from "./confirm-write.js"; -export { confirmWriteTool, confirmWriteSchema } from "./confirm-write-tool.js"; diff --git a/app/modals/__tests__/file-issue.test.tsx b/app/modals/__tests__/file-issue.test.tsx index 35aa6f0..3521cfa 100644 --- a/app/modals/__tests__/file-issue.test.tsx +++ b/app/modals/__tests__/file-issue.test.tsx @@ -1,12 +1,15 @@ import { describe, it, expect, vi } from "vitest"; import { renderToIR, type ChannelNode } from "@copilotkit/channels"; +import { + defaultSlackContext, + defaultSlackTools, +} from "@copilotkit/channels/slack"; import { FileIssueModal, fileIssueSubmit, issueFromValues, FILE_ISSUE_CALLBACK, } from "../file-issue.js"; -import { senderContext } from "../../sender-context.js"; function tags(node: ChannelNode | unknown, acc: string[] = []): string[] { if (!node || typeof node !== "object") return acc; @@ -20,18 +23,23 @@ function tags(node: ChannelNode | unknown, acc: string[] = []): string[] { describe("FileIssueModal", () => { it("rich variant (Slack) includes selects and radios", () => { - const ir = renderToIR(FileIssueModal({ rich: true })); + const ir = renderToIR( + FileIssueModal({ rich: true, sourcePlatform: "slack" }), + ); const root = ir[0]!; const t = tags(root); expect(root.type).toBe("modal"); expect(root.props.callbackId).toBe(FILE_ISSUE_CALLBACK); + expect(root.props.privateMetadata).toBe("slack"); expect(t).toContain("modal_text_input"); expect(t).toContain("modal_select"); expect(t).toContain("modal_radio"); }); it("text-only variant drops selects and radios", () => { - const ir = renderToIR(FileIssueModal({ rich: false })); + const ir = renderToIR( + FileIssueModal({ rich: false, sourcePlatform: "teams" }), + ); const root = ir[0]!; const t = tags(root); expect(t).not.toContain("modal_select"); @@ -114,12 +122,12 @@ describe("fileIssueSubmit", () => { ).resolves.toBeUndefined(); }); - it("runs the agent with the interpolated prompt and sender context on a valid submit", async () => { + it("uses the command's managed Slack origin for defaults and sender context on submit", async () => { const runAgent = vi.fn( - (_input?: { prompt: string; context: unknown }) => + (_input?: { prompt: string; tools?: unknown; context: unknown }) => new Promise<void>(() => {}), ); - const thread = { runAgent, platform: "slack" }; + const thread = { runAgent, platform: "intelligence" }; const user = { id: "U1", name: "Ada Lovelace", email: "ada@example.com" }; await fileIssueSubmit({ values: { @@ -130,6 +138,7 @@ describe("fileIssueSubmit", () => { }, thread, user, + privateMetadata: "slack", } as never); expect(runAgent).toHaveBeenCalledTimes(1); @@ -138,7 +147,16 @@ describe("fileIssueSubmit", () => { expect(call.prompt).toContain("- Type: bug"); expect(call.prompt).toContain("- Priority: High"); expect(call.prompt).toContain("- Description: 500 on submit"); - expect(call.context).toEqual(senderContext(user, thread.platform)); + expect(call.prompt).toContain("confirm_write"); + expect(call.prompt).not.toContain("already confirmed"); + expect(call.tools).toEqual(defaultSlackTools); + expect(call.context).toEqual([ + ...defaultSlackContext, + { + description: "Requesting slack user", + value: "Ada Lovelace <ada@example.com> (slack id U1)", + }, + ]); }); it("posts a failure message to the thread when runAgent rejects", async () => { diff --git a/app/modals/file-issue.tsx b/app/modals/file-issue.tsx index d6d72dd..8ca2ed6 100644 --- a/app/modals/file-issue.tsx +++ b/app/modals/file-issue.tsx @@ -14,7 +14,7 @@ import { RadioButtons, } from "@copilotkit/channels"; import type { ModalSubmitHandler, ModalView } from "@copilotkit/channels"; -import { senderContext } from "../sender-context.js"; +import { platformRunInput } from "../agent.js"; export const FILE_ISSUE_CALLBACK = "file_issue"; @@ -50,6 +50,7 @@ export const fileIssueSubmit: ModalSubmitHandler = async ({ values, thread, user, + privateMetadata, }) => { const issue = issueFromValues(values); if (!issue.title.trim()) { @@ -61,11 +62,12 @@ export const fileIssueSubmit: ModalSubmitHandler = async ({ void thread .runAgent({ prompt: - `File a Linear issue now (this was already confirmed via the form):\n` + + `Draft this Linear issue from the submitted form:\n` + `- Title: ${issue.title}\n- Type: ${issue.type}\n- Priority: ${issue.priority}\n` + `- Description: ${issue.description || "(none)"}\n` + - `After filing, show the issue card.`, - context: senderContext(user, thread.platform), + `Call confirm_write with these exact details. File it only when the ` + + `resumed result confirms approval, then show the issue card.`, + ...platformRunInput(privateMetadata ?? thread.platform, user), }) .catch((err) => { console.error("[bot] file-issue modal run failed", err); @@ -81,12 +83,19 @@ export const fileIssueSubmit: ModalSubmitHandler = async ({ * The form. `rich` controls whether the structured controls (selects/radio) are * present; pass `false` when a future text-only modal surface needs it. */ -export function FileIssueModal({ rich }: { rich: boolean }): ModalView { +export function FileIssueModal({ + rich, + sourcePlatform, +}: { + rich: boolean; + sourcePlatform: string; +}): ModalView { return ( <Modal callbackId={FILE_ISSUE_CALLBACK} title="File an issue" submitLabel="File" + privateMetadata={sourcePlatform} > <TextInput id="title" diff --git a/app/platforms.test.ts b/app/platforms.test.ts index f2ece8a..dce4220 100644 --- a/app/platforms.test.ts +++ b/app/platforms.test.ts @@ -1,8 +1,4 @@ import { describe, expect, it } from "vitest"; -import { - defaultSlackContext, - defaultSlackTools, -} from "@copilotkit/channels/slack"; import { readEnvironment } from "./env.js"; import { resolvePlatforms } from "./platforms.js"; @@ -16,12 +12,10 @@ const environment = ( }); describe("resolvePlatforms", () => { - it("uses managed Slack with Slack defaults when no direct credentials exist", () => { + it("leaves managed delivery adapter-free", () => { const setup = resolvePlatforms(environment()); - expect(setup.adapters).toEqual([]); - expect(setup.tools).toEqual(defaultSlackTools); - expect(setup.context).toEqual(defaultSlackContext); + expect(setup).toEqual({ adapters: [] }); }); it("constructs direct Slack only when both Slack credentials exist", () => { @@ -33,11 +27,11 @@ describe("resolvePlatforms", () => { ); expect(setup.adapters.map(({ platform }) => platform)).toEqual(["slack"]); - expect(setup.tools).toEqual(defaultSlackTools); - expect(setup.context).toEqual(defaultSlackContext); + expect(setup).not.toHaveProperty("tools"); + expect(setup).not.toHaveProperty("context"); }); - it("constructs direct Teams from its complete credential pair without inventing defaults", () => { + it("constructs direct Teams from its complete credential pair", () => { const setup = resolvePlatforms( environment({ TEAMS_CLIENT_ID: "teams-client", @@ -48,8 +42,8 @@ describe("resolvePlatforms", () => { ); expect(setup.adapters.map(({ platform }) => platform)).toEqual(["teams"]); - expect(setup.tools).toEqual([]); - expect(setup.context).toEqual([]); + expect(setup).not.toHaveProperty("tools"); + expect(setup).not.toHaveProperty("context"); }); it("combines direct Slack and Teams adapters in one platform setup", () => { @@ -66,20 +60,7 @@ describe("resolvePlatforms", () => { "slack", "teams", ]); - expect(setup.tools).toEqual(defaultSlackTools); - expect(setup.context).toEqual(defaultSlackContext); - }); - - it("ignores incomplete direct credential pairs", () => { - const setup = resolvePlatforms( - environment({ - SLACK_BOT_TOKEN: "xoxb-test", - TEAMS_CLIENT_ID: "teams-client", - }), - ); - - expect(setup.adapters).toEqual([]); - expect(setup.tools).toEqual(defaultSlackTools); - expect(setup.context).toEqual(defaultSlackContext); + expect(setup).not.toHaveProperty("tools"); + expect(setup).not.toHaveProperty("context"); }); }); diff --git a/app/platforms.ts b/app/platforms.ts index d7678fe..d232bae 100644 --- a/app/platforms.ts +++ b/app/platforms.ts @@ -1,26 +1,14 @@ -import type { - ChannelTool, - ContextEntry, - PlatformAdapter, -} from "@copilotkit/channels"; -import { - defaultSlackContext, - defaultSlackTools, - slack, -} from "@copilotkit/channels/slack"; +import type { PlatformAdapter } from "@copilotkit/channels"; +import { slack } from "@copilotkit/channels/slack"; import { teams } from "@copilotkit/channels/teams"; import type { AppEnvironment } from "./env.js"; export interface PlatformSetup { adapters: PlatformAdapter[]; - tools: ChannelTool[]; - context: ContextEntry[]; } export function resolvePlatforms(env: AppEnvironment): PlatformSetup { const adapters: PlatformAdapter[] = []; - const tools: ChannelTool[] = []; - const context: ContextEntry[] = []; if (env.slackBotToken && env.slackAppToken) { adapters.push( @@ -48,8 +36,6 @@ export function resolvePlatforms(env: AppEnvironment): PlatformSetup { }, }), ); - tools.push(...defaultSlackTools); - context.push(...defaultSlackContext); } if (env.teamsClientId && env.teamsClientSecret) { @@ -63,10 +49,5 @@ export function resolvePlatforms(env: AppEnvironment): PlatformSetup { ); } - if (adapters.length === 0) { - tools.push(...defaultSlackTools); - context.push(...defaultSlackContext); - } - - return { adapters, tools, context }; + return { adapters }; } diff --git a/app/runtime-host.test.ts b/app/runtime-host.test.ts index 0ef9980..ade2144 100644 --- a/app/runtime-host.test.ts +++ b/app/runtime-host.test.ts @@ -28,8 +28,6 @@ describe("createOpenTagRuntime", () => { name: environment.channelName, adapters: [slackAdapter, teamsAdapter], agent: new FakeAgent(), - platformTools: [], - platformContext: [], }); const { intelligence, listener, runtime } = createOpenTagRuntime({ diff --git a/app/tools/index.ts b/app/tools/index.ts index 5be0b6c..55c32e0 100644 --- a/app/tools/index.ts +++ b/app/tools/index.ts @@ -15,7 +15,6 @@ import { showStatusTool, showLinksTool, } from "./showcase-tools.js"; -import { confirmWriteTool } from "../human-in-the-loop/index.js"; import type { ChannelTool } from "@copilotkit/channels"; /** @@ -37,7 +36,6 @@ export const appTools: ChannelTool[] = [ showIncidentTool, showStatusTool, showLinksTool, - confirmWriteTool, ]; export { @@ -51,5 +49,4 @@ export { showIncidentTool, showStatusTool, showLinksTool, - confirmWriteTool, }; diff --git a/server.ts b/server.ts index bcdbbf0..47a1b7a 100644 --- a/server.ts +++ b/server.ts @@ -169,8 +169,6 @@ export function createOpenTagApplication( url: environment.agentUrl, authHeader: environment.agentAuthHeader, }), - platformTools: platform.tools, - platformContext: platform.context, }); const runtimeHost = createOpenTagRuntime({ environment, channel }); From 81ecb611ad442a3de9d0d3ab7756a27bb638b7a8 Mon Sep 17 00:00:00 2001 From: Tyler Slaton <tyler@copilotkit.ai> Date: Mon, 27 Jul 2026 15:23:12 -0400 Subject: [PATCH 03/19] Finish OpenTag launch configuration --- .env.example | 93 ++------ .npmrc | 7 - .railway/railway.ts | 101 ++------- README.md | 335 ++++++++++++--------------- agent/.env.example | 4 +- agent/agent.py | 8 +- agent/main.py | 17 +- agent/pyproject.toml | 4 +- agent/tests/test_health.py | 22 +- agent/tests/test_metadata.py | 10 + agent/tools.py | 13 +- agent/uv.lock | 86 +++---- app/context/app-context.ts | 2 +- e2e/README.md | 104 +++------ e2e/TELEGRAM-README.md | 121 +--------- package.json | 4 - pnpm-lock.yaml | 126 +---------- runtime.ts | 374 ------------------------------ scripts/start-notion-mcp.ts | 16 +- setup.md | 426 ++++++++++++++++------------------- slack-app-manifest.json | 8 +- slack-app-manifest.yaml | 10 +- tsconfig.json | 1 - 23 files changed, 535 insertions(+), 1357 deletions(-) delete mode 100644 .npmrc create mode 100644 agent/tests/test_metadata.py delete mode 100644 runtime.ts diff --git a/.env.example b/.env.example index 5bb0048..8722bec 100644 --- a/.env.example +++ b/.env.example @@ -1,87 +1,44 @@ -# ── Canonical Channel host (`pnpm start`) ─────────────────────────────── -# These are the only two required variables. -# AG-UI endpoint for the OpenTag agent. The local alternate TypeScript backend -# (`pnpm runtime`) exposes the example route below. -AGENT_URL=http://localhost:8200/api/copilotkit/agent/triage/run -# AGENT_AUTH_HEADER=Bearer ... +# ── Canonical Intelligence Channel host (`pnpm channel`) ─────────────── +# Both variables are required. The local Python agent listens on :8123. +AGENT_URL=http://localhost:8123/ INTELLIGENCE_API_KEY=cpk-... # Production defaults; override only for another Intelligence environment. # INTELLIGENCE_API_URL=https://api.intelligence.copilotkit.ai # INTELLIGENCE_GATEWAY_WS_URL=wss://realtime.intelligence.copilotkit.ai -# Project-unique Channel name. Defaults to "opentag"; production @kite uses "kite". +# Defaults to "opentag". The production Railway service sets this to "kite" +# so the existing Slack app keeps its @kite handle. # INTELLIGENCE_CHANNEL_NAME=opentag # HTTP port for the CopilotRuntime listener. Defaults to 3000. # PORT=3000 +# Optional authorization header forwarded to the AG-UI agent. +# AGENT_AUTH_HEADER=Bearer ... -# ── Optional direct adapters ──────────────────────────────────────────── -# With no direct credentials, Intelligence owns managed Slack delivery. -# Supplying a complete credential pair starts that direct adapter inside the -# same Intelligence-owned runtime. Slack and Teams can run together. +# Attach managed Slack and Microsoft Teams integrations in Intelligence. The +# optional credentials below are only for direct local-development adapters +# inside this same Intelligence-owned runtime. -# Slack Socket Mode (both values required). +# Slack Socket Mode: set both values or neither. # SLACK_BOT_TOKEN=xoxb-... # SLACK_APP_TOKEN=xapp-... -# Microsoft Teams / Azure Bot credentials (both values required). -# TEAMS_CLIENT_ID= -# TEAMS_CLIENT_SECRET= -# Optional tenant restriction and direct-adapter HTTP port (default 3978). -# TEAMS_TENANT_ID= +# Microsoft Teams / Azure Bot: set both values or neither. +# TEAMS_CLIENT_ID=... +# TEAMS_CLIENT_SECRET=... +# TEAMS_TENANT_ID=... # TEAMS_PORT=3978 -# Model for the BuiltInAgent. The runtime is OpenAI-only (it uses OpenAI's -# Responses API via TanStack AI's `openaiText` adapter, needed for the -# `web_search` provider tool) — there is no multi-provider resolution. -# AGENT_MODEL only accepts an OpenAI model id, optionally prefixed with -# "openai/" (the prefix is stripped); defaults to openai/gpt-5.5. -# AGENT_MODEL=openai/gpt-5.5 -OPENAI_API_KEY=sk-... -# ANTHROPIC_API_KEY / GOOGLE_API_KEY are NOT supported by this runtime today -# (runtime.ts never reads them) — do not set AGENT_MODEL to an anthropic/... or -# google/... value. - -# ── Deep-research agent (agent/) — alternative brain to runtime.ts ────── -# A standalone Python (deepagents/LangGraph) service served over AG-UI on -# :8123. To use it instead of the TS runtime above, point the bridge at it: -# AGENT_URL=http://localhost:8123/ -# Only OPENAI_API_KEY (above) is required to run it — it still chats and -# generates UI components without Tavily. -# OPTIONAL — web research for the agent/ service. Without it the `research` -# web tool is simply not loaded (chat + UI-component generation still work, -# answering from the model's own knowledge); with it, live web research -# turns on. Get a key at https://tavily.com. This is scoped to agent/, not -# to runtime.ts, which has no research tool. -# TAVILY_API_KEY=tvly-... -# Model used by the agent/ service (its own env, separate from AGENT_MODEL -# above which is read by runtime.ts). Defaults to gpt-5.5. -# OPENAI_MODEL=gpt-5.5 +# Discord, Telegram, and WhatsApp are coming soon. They have no active +# configuration in this launch. -# ── 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 +# The Python service loads `agent/.env`; copy `agent/.env.example` there for +# OPENAI_API_KEY and optional Tavily/Linear/Notion research variables. -# ── 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 remains available for local development through the optional +# `pnpm notion-mcp` sidecar. These root variables configure that sidecar; put +# the same NOTION_MCP_AUTH_TOKEN in `agent/.env`. Notion is not a Railway +# service or launch blocker. +# NOTION_TOKEN=ntn_... +# NOTION_MCP_AUTH_TOKEN=choose-a-strong-shared-secret # NOTION_MCP_URL=http://127.0.0.1:3001/mcp -# Port the `pnpm notion-mcp` sidecar listens on (scripts/start-notion-mcp.ts). -# Must agree with the port in NOTION_MCP_URL above. Defaults to 3001. # NOTION_MCP_PORT=3001 -# Notion-Version header the sidecar sends to the Notion API. Defaults to -# 2022-06-28; override only if you need a newer Notion API version. -# NOTION_VERSION=2022-06-28 diff --git a/.npmrc b/.npmrc deleted file mode 100644 index ab3e21a..0000000 --- a/.npmrc +++ /dev/null @@ -1,7 +0,0 @@ -# @tanstack/ai-openai declares a peer dependency on zod@^4, but the CopilotKit -# bot stack (@copilotkit/bot-slack, @copilotkit/bot-ui, …) is built on zod@^3 — -# and that is the version this app uses. tanstack never receives our zod objects -# directly (tool schemas are converted to JSON before the LLM call), so zod 3 is -# correct at runtime. `legacy-peer-deps` lets `npm install` proceed past that -# advisory peer mismatch — the same thing pnpm does by default in the monorepo. -legacy-peer-deps=true diff --git a/.railway/railway.ts b/.railway/railway.ts index de623ed..2340fee 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -1,66 +1,16 @@ import { defineRailway, github, preserve, project, service } from "railway/iac"; -// KiteBot on CopilotKit Intelligence — one-click Railway topology. -// Three services build from this repo; the Python agent uses rootDirectory "agent". -// Inter-service URLs use Railway reference variables (${{svc.VAR}}), resolved at -// deploy over private networking. SECRETS are declared with preserve() so applying -// never clobbers deployer-set values — set their actual values in the Railway UI -// (see README "Deploy to Railway"). -// -// Two topology invariants this file encodes: -// 1. Ports are pinned as explicit service variables (NOT left to Railway's -// auto-injected $PORT) so each service's listen port and the ${{svc.PORT}} -// its peers dial always agree. -// 2. Services reached over Railway private networking must bind :: (all -// interfaces) — private DNS (RAILWAY_PRIVATE_DOMAIN) resolves to IPv6, and -// legacy environments are IPv6-only. A service bound to 127.0.0.1/0.0.0.0 -// is unreachable by its peers. const REPO = "CopilotKit/OpenTag"; +const BRANCH = "main"; export default defineRailway(() => { - // Notion MCP sidecar — streamable-HTTP MCP server. Its launcher - // (scripts/start-notion-mcp.ts) binds NOTION_MCP_PORT (default 3001) and does - // NOT read Railway's injected $PORT, so we pin NOTION_MCP_PORT here and have - // the agent dial that same variable. We also set NOTION_MCP_HOST=:: so the - // sidecar binds all interfaces (its upstream default is 127.0.0.1, which is - // unreachable across containers on the private network). - // - // Notion is an OPTIONAL research source: the agent runs fine (chat + UI + web - // research) without it and drops the Notion tools if this sidecar is - // unreachable. But if you deploy this service it REQUIRES NOTION_TOKEN and - // NOTION_MCP_AUTH_TOKEN — its launcher exits non-zero without them. To skip - // Notion entirely, remove this service from `resources` below and delete the - // agent's NOTION_MCP_URL / NOTION_MCP_AUTH_TOKEN wiring. - const notionMcp = service("notion-mcp", { - source: github(REPO), - start: "pnpm notion-mcp", - deploy: { - restartPolicyType: "ON_FAILURE", - restartPolicyMaxRetries: 5, - }, - env: { - NOTION_MCP_PORT: "3001", - NOTION_MCP_HOST: "::", - // secrets (set in Railway UI): - NOTION_TOKEN: preserve(), - NOTION_MCP_AUTH_TOKEN: preserve(), - }, - }); - - // Python deep-research agent — deepagents over AG-UI (uvicorn, /health). - // This service owns its build + deploy config here (single source of truth): - // there is deliberately NO agent/railway.toml, because Railway forbids a - // service being managed by both IaC and config-as-code at once. const agent = service("agent", { - source: github(REPO, { rootDirectory: "agent" }), + source: github(REPO, { + branch: BRANCH, + rootDirectory: "agent", + }), build: { builder: "NIXPACKS" }, deploy: { - // Bind :: so the agent is reachable over Railway private networking (see - // invariant 2 above). The Railway startCommand runs `uvicorn main:app` - // directly, so agent/main.py's own __main__ port logic does NOT run here — - // the port comes solely from this --port arg. The ${PORT:-8123} fallback - // keeps the bind valid even if PORT were unset; PORT is pinned in env below - // so this and ${{agent.PORT}} (dialed by channel) always agree. startCommand: "uvicorn main:app --host :: --port ${PORT:-8123}", healthcheckPath: "/health", healthcheckTimeout: 300, @@ -68,54 +18,31 @@ export default defineRailway(() => { restartPolicyMaxRetries: 5, }, env: { - // Pin PORT so uvicorn's bind and ${{agent.PORT}} (dialed by channel) agree. PORT: "8123", - // internal research source (optional Notion), wired to the sidecar on its - // pinned NOTION_MCP_PORT over private networking. The shared bearer is - // referenced from notion-mcp so it has a single source of truth — set - // NOTION_MCP_AUTH_TOKEN once, on the notion-mcp service. - NOTION_MCP_URL: - "http://${{notion-mcp.RAILWAY_PRIVATE_DOMAIN}}:${{notion-mcp.NOTION_MCP_PORT}}/mcp", - NOTION_MCP_AUTH_TOKEN: "${{notion-mcp.NOTION_MCP_AUTH_TOKEN}}", - // OPENAI_MODEL is intentionally NOT set here: the agent defaults to - // gpt-5.5 (agent/agent.py), and leaving it unmanaged means a deployer can - // override it in the Railway UI without a later `config apply` clobbering - // the change. Set OPENAI_MODEL in the agent service's Variables to change it. - // - // secrets (set in Railway UI): OPENAI_API_KEY required; others optional: OPENAI_API_KEY: preserve(), TAVILY_API_KEY: preserve(), LINEAR_API_KEY: preserve(), }, }); - // KiteBot channel host — runs the bot over the Intelligence Realtime Gateway. const channel = service("channel", { - source: github(REPO), + source: github(REPO, { branch: BRANCH }), start: "pnpm channel", deploy: { - // Parity with agent/notion-mcp: restart the long-running channel host on - // crash instead of leaving KiteBot silently offline. restartPolicyType: "ON_FAILURE", restartPolicyMaxRetries: 5, }, env: { - // brain: points at the agent service over private networking. The agent - // pins PORT=8123, so ${{agent.PORT}} resolves to the port uvicorn binds. - AGENT_URL: "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/", - // INTELLIGENCE_CHANNEL_NAME is intentionally NOT set here: app/managed.ts - // already defaults it to "kitebot", and leaving it unmanaged means a - // deployer can set their own channel name in the Railway UI without a - // later `config apply` clobbering it. Set it in the channel service's - // Variables if your Intelligence channel is named something else. - // secrets (set in Railway UI): - INTELLIGENCE_GATEWAY_WS_URL: preserve(), + PORT: "3000", + AGENT_URL: + "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/", INTELLIGENCE_API_KEY: preserve(), - INTELLIGENCE_ORG_ID: preserve(), - INTELLIGENCE_PROJECT_ID: preserve(), - INTELLIGENCE_CHANNEL_ID: preserve(), + INTELLIGENCE_API_URL: "https://api.intelligence.copilotkit.ai", + INTELLIGENCE_GATEWAY_WS_URL: + "wss://realtime.intelligence.copilotkit.ai", + INTELLIGENCE_CHANNEL_NAME: "kite", }, }); - return project("kitebot", { resources: [notionMcp, agent, channel] }); + return project("opentag", { resources: [agent, channel] }); }); diff --git a/README.md b/README.md index a817c8d..04b2978 100644 --- a/README.md +++ b/README.md @@ -1,242 +1,197 @@ -# OpenTag: an open-source alternative to Claude in Slack - -Run your own AI agent inside Slack: it reads a thread, answers, calls your tools, and -renders rich results right in the conversation. Think of it as having Claude in your -workspace, except **open-source and self-hosted**: you own the runtime, bring your own -model, and wire it to your own tools. No per-seat pricing, no lock-in. - -It's built on **[`@copilotkit/channels`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels)** — -CopilotKit's open SDK for chat-platform agents (Slack first; the same code also runs on -Discord, Telegram, and WhatsApp). Clone it, point it at your model and tools, and you own -the whole stack. - -## See it in action +# OpenTag + +OpenTag is an open-source research and triage agent for Slack and Microsoft +Teams. It runs on CopilotKit Channels, connects to CopilotKit Intelligence, and +uses a Python LangGraph deep agent over AG-UI. + +The launch supports: + +- Slack through Intelligence, including the existing production `@kite` app. +- Microsoft Teams through Intelligence. +- Optional direct Slack and Teams adapters for local development. + +Discord, Telegram, and WhatsApp are coming soon. + +## Architecture + +```text +Slack / Microsoft Teams + │ + ▼ +CopilotKit Intelligence + │ realtime + ▼ +channel (Node + CopilotRuntime + Channels) + │ AG-UI + ▼ +agent (Python + LangGraph deepagents) + ├── OpenAI + ├── Tavily (optional) + ├── Linear MCP (optional) + └── Notion MCP (optional local sidecar) +``` -https://github.com/user-attachments/assets/a74fa1cb-add0-463e-a23c-aa09b95d5135 +There is one canonical Channel host: [`server.ts`](./server.ts). It creates one +`CopilotKitIntelligence`, one `CopilotRuntime`, and one OpenTag Channel. Platform +attachments are normally configured in Intelligence. Supplying local Slack or +Teams credentials adds a direct adapter to that same runtime; it does not create +a separate lifecycle. -▶️ **[Watch the demo](https://github.com/user-attachments/assets/a74fa1cb-add0-463e-a23c-aa09b95d5135)** (~50s) — a KiteBot agent working a Slack thread: it renders a breakdown, a table, and a bar chart inline (**generative UI**) and files a ticket only after an **Approve** gate (**human-in-the-loop**). +The launch pins the requested canaries: -> **Two ways to run it:** **host it on your own** with the open-source SDK below — or skip the ops and **[sign up for the managed service →](https://go.copilotkit.ai/opentag-managed-gh)** coming soon from CopilotKit. The managed service will be part of our Enterprise Intelligence platform. You'll be able to use our cloud-hosting or enterprises can host it on their own infra. -> -> Note: the **Intelligence Gateway** mode below is part of "host it on your own" — you run that -> process yourself and bring your own CopilotKit Intelligence project. It's distinct from the -> fully-hosted **managed service** above, which is still on the waitlist. +- `@copilotkit/channels@0.2.2-canary.rc-1` +- `@copilotkit/runtime@1.63.3-canary.rc-1` ## Quick start -OpenTag's packages are published on npm — a standalone `pnpm install` in this repo pulls in -everything you need, no monorepo required. +Prerequisites: Node.js 22+, pnpm, Python 3.12, and +[`uv`](https://docs.astral.sh/uv/). -You'll run two processes: the **agent backend** (`pnpm runtime`) and **the bot**. For the bot, -pick one of two modes: +1. Install dependencies. -- **Intelligence Gateway — recommended.** `pnpm channel` runs the bot over the CopilotKit - Intelligence Realtime Gateway. This process never holds a Slack token — Intelligence owns - the Slack edge — so there's less for you to run and secure. You still run this process - yourself and bring your own CopilotKit Intelligence project — it's not the fully-hosted - managed service described below. -- **Self-hosted.** `pnpm dev` (or `pnpm start`) runs the bot locally and talks to Slack (and - Discord/Telegram/WhatsApp) directly with your own platform tokens. + ```bash + pnpm install --frozen-lockfile + cd agent && uv sync && cd .. + ``` -Both modes talk to the same agent backend over AG-UI. +2. Configure the Python agent. -### The packages + ```bash + cp agent/.env.example agent/.env + ``` -OpenTag is a thin layer on top of a handful of CopilotKit packages. The `pnpm install` in step 3 installs all of them for you — this is what each one does, so you know what you're running and which ones are optional. + Set `OPENAI_API_KEY`. Tavily, Linear, and Notion are optional. -**Required** — every OpenTag install needs these four: +3. Configure the Channel host. -| Package | Role | -| --- | --- | -| [`@copilotkit/channels`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels) | The platform-agnostic bot engine — threading, tool calls, the human-in-the-loop gate. | -| [`@copilotkit/runtime`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/runtime) | The AG-UI agent backend that runs your LLM and tools. | -| [`@copilotkit/channels-ui`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-ui) | Cross-platform JSX for rich messages (Block Kit on Slack, Components V2 on Discord, HTML on Telegram). | -| [`@copilotkit/channels-slack`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-slack) | The Slack adapter — or swap it for the platform you're targeting (below). | + ```bash + cp .env.example .env + ``` -**Optional** — add only what you use: + Set: -| Package | When you need it | -| --- | --- | -| [`@copilotkit/channels-discord`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-discord) · [`-telegram`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-telegram) · [`-whatsapp`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-whatsapp) | Running on a platform other than Slack — one adapter per platform. | -| [`@copilotkit/channels-intelligence`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-intelligence) | Runs the bot over the CopilotKit Intelligence Realtime Gateway instead of holding platform tokens — see `app/managed.ts`. **Required for the recommended `pnpm channel` (Intelligence Gateway) mode**; omit it only if you run self-hosted mode exclusively. | + ```dotenv + AGENT_URL=http://localhost:8123/ + INTELLIGENCE_API_KEY=cpk-... + ``` -**1. Create a Slack app.** At [api.slack.com/apps](https://api.slack.com/apps?new_app=1) → -*From a manifest* → paste [`slack-app-manifest.yaml`](./slack-app-manifest.yaml). Install it, -then grab the **Bot User OAuth Token** (`xoxb-…`) and an **App-Level Token** (`xapp-…`, with the -`connections:write` scope) — needed for self-hosted mode, or to register the app with your -CopilotKit Intelligence project for Intelligence mode. Step-by-step in -[setup.md](./setup.md#1-create-a-slack-app). + `INTELLIGENCE_API_URL` and `INTELLIGENCE_GATEWAY_WS_URL` default to the + production Intelligence endpoints. `INTELLIGENCE_CHANNEL_NAME` defaults to + `opentag`. -**2. Set your secrets** in `.env` (`cp .env.example .env`): +4. In Intelligence, create the Channel that matches + `INTELLIGENCE_CHANNEL_NAME` and attach managed Slack and/or Microsoft Teams. -```bash -OPENAI_API_KEY=sk-... # the TS runtime (runtime.ts) uses OpenAI's Responses API for web search; - # the Python agent/ uses Tavily instead (see "Deep research" below) +5. Run both services. -# Optional integrations (see setup.md): -LINEAR_API_KEY=lin_api_... # optional — lets the agent file Linear tickets + ```bash + pnpm agent + pnpm channel + ``` -# Self-hosted mode: -SLACK_BOT_TOKEN=xoxb-... -SLACK_APP_TOKEN=xapp-... +The Channel waits for its Intelligence connection to become ready before its +HTTP listener accepts traffic. -# Intelligence Gateway mode — full list in .env.example: -INTELLIGENCE_GATEWAY_WS_URL=wss://... -INTELLIGENCE_API_KEY=cpk-... -INTELLIGENCE_ORG_ID=org_... -INTELLIGENCE_PROJECT_ID=... -INTELLIGENCE_CHANNEL_ID=channel_... -``` +## What OpenTag includes -**3. Run it:** +- Mentions and app-owned commands. +- Sender-aware context and Slack tools scoped to Slack turns. +- Rich issue, page, status, incident, link, table, chart, and diagram output. +- File-aware prompts. +- A LangGraph interrupt and resumable confirmation card before Linear or Notion + writes. +- Graceful, idempotent shutdown for Channels, HTTP, and the rendering browser. +- Nullable parent-message ID normalization through `SanitizingHttpAgent`. -```bash -pnpm install -pnpm runtime # the agent backend, on :8200 +The Python agent is the only supported backend. Its identity and behavior live +in [`agent/agent.py`](./agent/agent.py); the Channel UI and behavior live under +[`app/`](./app/). -pnpm channel # recommended — the bot over the Intelligence Gateway -# or -pnpm dev # alternative — the bot, self-hosted -``` +## Platform setup -**4. Talk to it.** @mention the bot in any channel thread: +### Intelligence (recommended) -> @KiteBot summarize this thread and file it as a bug +Use the normal Intelligence flow for both launch platforms: -That's the whole loop. To wire up Linear, Notion, inline charts, or to run -on Discord / Telegram / WhatsApp, see **[setup.md](./setup.md)**. +1. Create one OpenTag project and Channel in Intelligence. +2. Issue a runtime API key. +3. Configure the Slack and/or Microsoft Teams attachment in Intelligence. +4. Run one `pnpm channel` process with that Channel name and key. -We won't lie to you, though. Setting up hosting for chat agents is not easy. To skip all of that heartache, go [join the waitlist](https://go.copilotkit.ai/opentag-managed-gh) for the CopilotKit managed service as part of our Intelligence platform, both cloud-hosted or self-hosted. +No organization, project, Channel ID, or runtime-instance ID environment +variables are required. -## Make it your own +### Optional direct adapters -OpenTag is deliberately small and hackable: +For local development, set either complete pair in the root `.env`: -- **Change what it does.** The agent's behavior is steered by a single system prompt in - [`runtime.ts`](./runtime.ts) — rewrite it and you have a different agent. -- **Copy `app/` to start your own bot.** It's the platform-agnostic bot (tools, components, the - human-in-the-loop gate). `runtime.ts` is the agent backend: one CopilotKit `BuiltInAgent` (an - LLM + optional MCP tools — no Python, no LangGraph), served over AG-UI. -- **One platform, or all of them.** `createBot` takes an array of adapters; set the secrets for - whichever platform(s) you want and the bot starts an adapter for each. +```dotenv +SLACK_BOT_TOKEN=xoxb-... +SLACK_APP_TOKEN=xapp-... -The full architecture, the file-by-file map, and every integration live in -**[setup.md](./setup.md)**. +TEAMS_CLIENT_ID=... +TEAMS_CLIENT_SECRET=... +``` -## Deep research (LangGraph deep agent) +`TEAMS_TENANT_ID` and `TEAMS_PORT` are optional. Incomplete credential pairs +fail at startup instead of silently disabling an adapter. The Intelligence +connection remains the owner of the runtime. -`agent/` is an alternative agent backend to `runtime.ts` — a Python -[`deepagents`](https://github.com/langchain-ai/deepagents) (LangGraph) planner with a virtual -filesystem and OPTIONAL Tavily web research, served over AG-UI on `:8123`. Instead of a single -system-prompted LLM call, it plans with `write_todos`, reads/writes its own virtual files, and -(when configured) researches the web before synthesizing an answer — while still calling -KiteBot's forwarded generative-UI tools like the TS runtime does. +### Slack manifests -Only `OPENAI_API_KEY` is required. `TAVILY_API_KEY` is **optional** — without it, chat and UI -generation still work (the agent answers from its own knowledge); with it, live web research -turns on. +[`slack-app-manifest.yaml`](./slack-app-manifest.yaml) and +[`slack-app-manifest.json`](./slack-app-manifest.json) describe OpenTag for new +installations. -To run it: +For the production migration, **do not recreate or reinstall the existing +Slack app**. Reusing it preserves the bot user, workspace installation, and +`@kite` handle. Stop the old Socket Mode consumer before attaching its existing +`xapp` and `xoxb` tokens in Intelligence so only one consumer is active. -```bash -cd agent && uv sync # requires uv: https://docs.astral.sh/uv/ -pnpm agent # cd agent && uv run python main.py — serves over AG-UI on :8123 - # (port from PORT/SERVER_PORT env, default 8123) -``` +## Optional research sources -Then point the bot at it instead of `runtime.ts` by setting in `.env`: +- `TAVILY_API_KEY` enables live web research. Without it, OpenTag still chats, + plans, uses its virtual filesystem, and renders UI from model knowledge. +- `LINEAR_API_KEY` enables the hosted Linear MCP. +- Notion can be used locally with `pnpm notion-mcp`; copy the same + `NOTION_MCP_AUTH_TOKEN` into the root `.env` and `agent/.env`. The Railway + launch intentionally has no Notion sidecar. -```bash -AGENT_URL=http://localhost:8123/ -``` +All Linear and Notion creates or updates go through `confirm_write`; reads and +rendering do not. -With `agent/` in the mix, the local setup is now three pieces: the deep-research agent -(`pnpm agent`, `:8123`), the bot (`pnpm channel` or `pnpm dev`), and whichever brain -`AGENT_URL` points at — the Python deep agent above, or the TS `runtime.ts` (`pnpm runtime`, -`:8200`) as before. `agent` and `runtime` are alternative brains for the same bot; run one or -the other depending on what `AGENT_URL` targets. +## Railway -## Deploy to Railway (one-click) +[`.railway/railway.ts`](./.railway/railway.ts) defines exactly two services, +both sourced from `CopilotKit/OpenTag` on `main`: -The whole KiteBot stack deploys to [Railway](https://railway.com) as **three services**, all -built from this one repo and wired together automatically over Railway's private networking: +| Service | Root | Start | Health | +| --- | --- | --- | --- | +| `agent` | `agent` | `uvicorn main:app --host :: --port ${PORT:-8123}` | `/health` | +| `channel` | repository root | `pnpm channel` | Channels readiness before listen | -| Service | What it is | Build | -| --- | --- | --- | -| `agent` | the Python deep-research backend (`agent/`) | nixpacks, `uvicorn`, `/health` (root dir `agent/`) | -| `notion-mcp` | the Notion MCP sidecar (`pnpm notion-mcp`) | Node | -| `channel` | the KiteBot channel host (`pnpm channel`) | Node | +The Channel reaches the agent over Railway private networking. Railway sets +`INTELLIGENCE_CHANNEL_NAME=kite` for the production cutover and preserves the +runtime API key. OpenAI is required on `agent`; Tavily and Linear secrets are +optional. Connecting both services to `main` enables GitHub-triggered +deployments after merges. -`channel` reaches `agent` via `AGENT_URL`, and `agent` reaches `notion-mcp` via `NOTION_MCP_URL` -— both wired with Railway reference variables in [`.railway/railway.ts`](./.railway/railway.ts), -so you don't set them by hand. +The repository configuration does not mutate the existing production Railway +project. Inventory and cutover should happen after Railway authentication. -**Deploy via Infrastructure-as-Code (recommended):** +## Verification ```bash -pnpm install # installs the `railway` SDK the CLI uses to evaluate .railway/railway.ts -npm i -g @railway/cli # or: brew install railway -railway login -railway init # create a new Railway project (or `railway link` to select an existing one) -railway config apply # from the repo root: provisions agent + notion-mcp + channel from .railway/railway.ts +pnpm install --frozen-lockfile +pnpm check-types +pnpm test +(cd agent && uv run pytest) +node node_modules/railway/dist/iac/bin.js ``` -**Set the secrets** (Railway → each service → *Variables*). The IaC declares them with -`preserve()` and never stores values — you fill them in: - -- **`agent`** — `OPENAI_API_KEY` (required); `TAVILY_API_KEY` (optional — enables web research); - `LINEAR_API_KEY` (optional). (`NOTION_MCP_AUTH_TOKEN` is **not** set here — the agent references - it from `notion-mcp`, so you set it once, below.) -- **`notion-mcp`** — `NOTION_TOKEN` and `NOTION_MCP_AUTH_TOKEN` (any strong string; the agent - reads the same value via a reference variable). **Both are required** for this service to - start — its launcher exits if either is missing. Don't want Notion? Remove the `notion-mcp` - service from `resources` in [`.railway/railway.ts`](./.railway/railway.ts) and delete the - agent's `NOTION_MCP_URL` / `NOTION_MCP_AUTH_TOKEN` lines; the agent still runs (chat, UI, and — - with `TAVILY_API_KEY` — web research). -- **`channel`** — `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY`, `INTELLIGENCE_ORG_ID`, - `INTELLIGENCE_PROJECT_ID`, `INTELLIGENCE_CHANNEL_ID` (from your CopilotKit Intelligence - project + channel). - -The inter-service URLs/ports are wired for you in [`.railway/railway.ts`](./.railway/railway.ts). -Two values are left unmanaged by the IaC so a UI override survives `railway config apply`: the -agent's `OPENAI_MODEL` (defaults to `gpt-5.5`) and the channel's `INTELLIGENCE_CHANNEL_NAME` -(defaults to `kitebot`). Set either in that service's *Variables* to change it — e.g. set -`INTELLIGENCE_CHANNEL_NAME` if your Intelligence channel isn't named `kitebot`. - -Applying the config creates the services and their wiring; **KiteBot goes live only once the -secrets are set and the `channel` service connects** — that's when your Intelligence dashboard -flips *Waiting for runtime → live*. - -> **Cold-start note (Notion):** the `agent` loads its Notion tools once, at startup. On a first -> cold deploy the `agent` can finish booting before `notion-mcp` is accepting connections, in -> which case Notion research is unavailable for that deployment — the agent logs a `WARNING` on -> its stderr (chat, UI, and Tavily web research still work). If KiteBot can't reach Notion after -> the first deploy, **redeploy the `agent` service** once `notion-mcp` is up. (A lazy/retrying -> MCP load would remove this race — tracked as a follow-up.) - -**"Deploy on Railway" button (optional):** to get a literal one-click button, publish this repo -as a [Railway template](https://docs.railway.com/templates/create) from your Railway account, -then drop the generated button in: - -```md -[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/new/template/<your-template-id>) -``` - -## Don't want to host it yourself? - -Self-hosting means you run and scale the runtime, persistence, and inspection tooling yourself. -A **managed CopilotKit service** is on its way. It's the same agent, without the ops: durable -threads, persistence, hosted inspection, and agents that improve from feedback (**Continuous -Learning from Human Feedback**). - -- **[Join the waitlist →](https://go.copilotkit.ai/opentag-managed-gh)** — be first in when the managed service opens. -- **[Talk to an engineer →](https://copilotkit.ai/talk-to-an-engineer)** — building something real on this? We'd love to help you ship it. - -## Learn more - -The **[CopilotKit Slack quickstart](https://docs.copilotkit.ai/slack)** is the canonical guide -to building a Slack agent — read it alongside this starter. Detailed setup and configuration -lives in **[setup.md](./setup.md)**. +The Slack live harness is documented in [`e2e/README.md`](./e2e/README.md). +Production acceptance is one end-to-end `@kite` mention that returns the +OpenTag persona through the Python agent. ## License diff --git a/agent/.env.example b/agent/.env.example index e835ee8..402155b 100644 --- a/agent/.env.example +++ b/agent/.env.example @@ -1,5 +1,5 @@ -# KiteBot deep-research agent (agent/) — served over AG-UI on SERVER_PORT. -# The channel host / self-hosted bot points AGENT_URL at http://localhost:8123/ +# OpenTag deep-research agent (agent/) — served over AG-UI on SERVER_PORT. +# The OpenTag Channel host points AGENT_URL at http://localhost:8123/ # Only OPENAI_API_KEY is required; the agent chats + generates UI components without Tavily. OPENAI_API_KEY=sk-... # OPTIONAL — web research. Without it the agent still works (chat + UI components, diff --git a/agent/agent.py b/agent/agent.py index 12b753b..c39616a 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -1,5 +1,5 @@ """ -KiteBot Deep Research Agent +OpenTag Deep Research Agent A Deep Agents-powered research assistant that demonstrates CopilotKit's planning, filesystem, and subagent capabilities, with optional Tavily-backed @@ -20,9 +20,9 @@ # Base system prompt - always applies, regardless of whether web research is # available. The agent chats, plans with write_todos, uses its virtual -# filesystem, and renders results as UI components (via KiteBot's forwarded +# filesystem, and renders results as UI components (via OpenTag's forwarded # generative-UI tools) even with no research tool loaded. -BASE_SYSTEM_PROMPT = """You are KiteBot's Deep Research Assistant, an expert at planning and +BASE_SYSTEM_PROMPT = """You are OpenTag's Deep Research Assistant, an expert at planning and executing comprehensive research on any topic. Hard rules (ALWAYS follow): @@ -131,7 +131,7 @@ def build_agent(): checkpointer=MemorySaver(), ) - print(f"[AGENT] KiteBot Deep Research Agent created with model={model_name}") + print(f"[AGENT] OpenTag Deep Research Agent created with model={model_name}") print(f"[AGENT] research: {'enabled' if has_research else 'disabled'}") print(f"[AGENT] internal-source tools: {len(internal_tools)}") print(f"[AGENT] Main tools: {[t.name for t in main_tools]}") diff --git a/agent/main.py b/agent/main.py index 864df5b..23202f5 100644 --- a/agent/main.py +++ b/agent/main.py @@ -1,5 +1,5 @@ """ -Deep Research Assistant - FastAPI Server +OpenTag Deep Research Agent - FastAPI Server Serves the Deep Research Agent via AG-UI protocol for CopilotKit integration. The agent uses Deep Agents for planning and filesystem operations, with optional Tavily web research. @@ -18,11 +18,16 @@ load_dotenv() app = FastAPI( - title="KiteBot Deep Research Agent", + title="OpenTag Deep Research Agent", description="A research assistant powered by Deep Agents and CopilotKit", version="0.1.0", ) +AGENT_NAME = "opentag_research" +AGENT_DESCRIPTION = ( + "OpenTag deep research assistant — plans, searches, and synthesizes cited briefs" +) + # Enable CORS for frontend communication. Defaults to "*" (any origin) for # local/demo use; on Railway the agent is reached only over private networking # by the channel service, so this is not a credential vector (allow_credentials @@ -49,7 +54,7 @@ @app.get("/health") def health(): """Health check endpoint for monitoring and Railway deployments""" - return {"status": "ok", "service": "kitebot-research-agent", "version": "0.1.0"} + return {"status": "ok", "service": "opentag-research-agent", "version": "0.1.0"} # Build and register the Deep Research Agent @@ -57,7 +62,7 @@ def health(): agent_graph = build_agent() # Note: we intentionally do NOT pass an emit_tool_calls allowlist here. - # KiteBot's channel bridge forwards generative-UI tools (issue_card, + # OpenTag's channel bridge forwards generative-UI tools (issue_card, # render_chart, render_table, show_links, ...) that must be emitted to the # frontend for cards to render. The backend-owned confirm_write tool emits # an interrupt through the same stream. An allowlist could filter these @@ -70,8 +75,8 @@ def health(): add_langgraph_fastapi_endpoint( app=app, agent=LangGraphAGUIAgent( - name="kitebot_research", - description="KiteBot deep research assistant — plans, searches, and synthesizes cited briefs", + name=AGENT_NAME, + description=AGENT_DESCRIPTION, graph=agent_graph, ), path="/", diff --git a/agent/pyproject.toml b/agent/pyproject.toml index 1830e95..dbc1e85 100644 --- a/agent/pyproject.toml +++ b/agent/pyproject.toml @@ -1,7 +1,7 @@ [project] -name = "kitebot-research-agent" +name = "opentag-research-agent" version = "0.1.0" -description = "KiteBot Deep Research Agent — CopilotKit Deep Agents (LangGraph) over AG-UI" +description = "OpenTag Deep Research Agent — CopilotKit Deep Agents (LangGraph) over AG-UI" requires-python = ">=3.12" dependencies = [ "ag-ui-langgraph>=0.0.23", diff --git a/agent/tests/test_health.py b/agent/tests/test_health.py index aa05996..371c0ee 100644 --- a/agent/tests/test_health.py +++ b/agent/tests/test_health.py @@ -29,7 +29,23 @@ def test_health_ok(monkeypatch): client = TestClient(main.app) r = client.get("/health") assert r.status_code == 200 - assert r.json()["status"] == "ok" + assert r.json() == { + "status": "ok", + "service": "opentag-research-agent", + "version": "0.1.0", + } + + +def test_server_exposes_opentag_metadata(monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.delenv("TAVILY_API_KEY", raising=False) + monkeypatch.delenv("LINEAR_API_KEY", raising=False) + monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) + import main + + assert main.app.title == "OpenTag Deep Research Agent" + assert main.AGENT_NAME == "opentag_research" + assert main.AGENT_DESCRIPTION.startswith("OpenTag deep research assistant") def test_build_agent_without_tavily(monkeypatch, capsys): @@ -102,3 +118,7 @@ def test_system_prompt_requires_confirmation_only_for_writes(): assert "Linear or Notion create or update" in prompt assert "only when" in prompt and "confirmed" in prompt assert "Reads and rendering never require confirmation" in prompt + + +def test_system_prompt_uses_opentag_persona(): + assert "OpenTag's Deep Research Assistant" in agent_mod.BASE_SYSTEM_PROMPT diff --git a/agent/tests/test_metadata.py b/agent/tests/test_metadata.py new file mode 100644 index 0000000..8b15f50 --- /dev/null +++ b/agent/tests/test_metadata.py @@ -0,0 +1,10 @@ +import tomllib +from pathlib import Path + + +def test_python_package_uses_opentag_identity(): + pyproject_path = Path(__file__).parents[1] / "pyproject.toml" + project = tomllib.loads(pyproject_path.read_text())["project"] + + assert project["name"] == "opentag-research-agent" + assert project["description"].startswith("OpenTag Deep Research Agent") diff --git a/agent/tools.py b/agent/tools.py index 7ce85bb..b3fd858 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -190,12 +190,11 @@ def internal_source_tools() -> list: """Notion + Linear MCP tools, included only when their env is present. Reuses OpenTag's Phase-1 MCP servers so the agent can research the team's - own docs/issues alongside the web. Mirrors the transports `runtime.ts`'s - `mcpTransports()` describes: Linear's hosted MCP server (bearer - `LINEAR_API_KEY`, URL from `LINEAR_MCP_URL` or the `mcp.linear.app` - default) and the Notion MCP sidecar (bearer `NOTION_MCP_AUTH_TOKEN`, URL - from `NOTION_MCP_URL` or the localhost default). Each server is entirely - optional and gated independently - set neither, one, or both. + own docs/issues alongside the web. Linear uses its hosted MCP server + (`LINEAR_API_KEY`, with `LINEAR_MCP_URL` defaulting to `mcp.linear.app`); + Notion uses the optional local MCP sidecar (`NOTION_MCP_AUTH_TOKEN`, with + `NOTION_MCP_URL` defaulting to localhost). Each server is independently + optional. `langchain_mcp_adapters`'s `MultiServerMCPClient.get_tools()` is async-only; this function runs it to completion on a short-lived event @@ -239,7 +238,7 @@ async def _load_all() -> list: # Bound connect time so a hung MCP server (accepts the # socket but never responds) can't stall build_agent() - # and thus the whole app, including /health - at import - # time. Mirrors runtime.ts's MCP_CONNECT_TIMEOUT_MS=8000. + # time. server_tools = await asyncio.wait_for( server_client.get_tools(), timeout=8.0 ) diff --git a/agent/uv.lock b/agent/uv.lock index 6310844..e76b41f 100644 --- a/agent/uv.lock +++ b/agent/uv.lock @@ -677,49 +677,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] -[[package]] -name = "kitebot-research-agent" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "ag-ui-langgraph" }, - { name = "copilotkit" }, - { name = "deepagents" }, - { name = "fastapi" }, - { name = "langchain" }, - { name = "langchain-mcp-adapters" }, - { name = "langchain-openai" }, - { name = "python-dotenv" }, - { name = "tavily-python" }, - { name = "uvicorn", extra = ["standard"] }, -] - -[package.dev-dependencies] -dev = [ - { name = "httpx" }, - { name = "pytest" }, -] - -[package.metadata] -requires-dist = [ - { name = "ag-ui-langgraph", specifier = ">=0.0.23" }, - { name = "copilotkit", specifier = ">=0.1.76" }, - { name = "deepagents", specifier = ">=0.3.5" }, - { name = "fastapi", specifier = ">=0.115.14" }, - { name = "langchain", specifier = ">=1.2.4" }, - { name = "langchain-mcp-adapters", specifier = ">=0.3.0" }, - { name = "langchain-openai", specifier = ">=1.1.7" }, - { name = "python-dotenv", specifier = ">=1.2.1" }, - { name = "tavily-python", specifier = ">=0.3.0" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.40.0" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "httpx", specifier = ">=0.27.0" }, - { name = "pytest", specifier = ">=8.0.0" }, -] - [[package]] name = "langchain" version = "1.3.13" @@ -951,6 +908,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/b0/2291689e3ec4723fbf5bbf3b54afcd7b160f9ddc98ca7aedfd0132af5677/openai-2.45.0-py3-none-any.whl", hash = "sha256:5df105f5f8c9b711fcb9d06d2d3888cebc82506db216484c14a4e53cdf651777", size = 1629470, upload-time = "2026-07-09T18:02:42.21Z" }, ] +[[package]] +name = "opentag-research-agent" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "ag-ui-langgraph" }, + { name = "copilotkit" }, + { name = "deepagents" }, + { name = "fastapi" }, + { name = "langchain" }, + { name = "langchain-mcp-adapters" }, + { name = "langchain-openai" }, + { name = "python-dotenv" }, + { name = "tavily-python" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "ag-ui-langgraph", specifier = ">=0.0.23" }, + { name = "copilotkit", specifier = ">=0.1.76" }, + { name = "deepagents", specifier = ">=0.3.5" }, + { name = "fastapi", specifier = ">=0.115.14" }, + { name = "langchain", specifier = ">=1.2.4" }, + { name = "langchain-mcp-adapters", specifier = ">=0.3.0" }, + { name = "langchain-openai", specifier = ">=1.1.7" }, + { name = "python-dotenv", specifier = ">=1.2.1" }, + { name = "tavily-python", specifier = ">=0.3.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.40.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pytest", specifier = ">=8.0.0" }, +] + [[package]] name = "orjson" version = "3.11.9" diff --git a/app/context/app-context.ts b/app/context/app-context.ts index b0c2a36..8ed4c7c 100644 --- a/app/context/app-context.ts +++ b/app/context/app-context.ts @@ -13,7 +13,7 @@ export const appContext: ReadonlyArray<ContextEntry> = [ { description: "Bot identity & tone", value: [ - "You are KiteBot, the team's on-call triage assistant. Be concise and action-", + "You are OpenTag, the team's on-call triage assistant. Be concise and action-", "oriented — responders are mid-incident. Lead with the answer, then any", "links. Prefer rendering issues/pages as cards over long prose.", ].join("\n"), diff --git a/e2e/README.md b/e2e/README.md index 2fca77f..5bb4aee 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -1,85 +1,49 @@ -# `e2e/` — live end-to-end test harness +# Slack live end-to-end harness -True end-to-end coverage for the Slack bridge: send real user messages -in a real Slack workspace, sample the bot's reply _while it's streaming_, -take screenshots in the middle of long streams, and verify what landed. +This API-first harness sends real user messages to a Slack test channel, polls +OpenTag replies while they stream, and writes a JSON report under +`e2e/results/`. -> **Why this exists.** Unit tests (under `src/__tests__/`) lock in the -> internal contracts of each module — they don't catch issues that only -> surface end-to-end: an open code fence leaking through the rest of the -> Slack message during streaming, a mrkdwn translation that _looks_ right -> in tests but renders weird in Slack's actual client, a Block Kit limit -> we forgot about, a Bolt event that doesn't fire under some setting. -> -> The catalog at `e2e/cases.ts` is the source of truth for what -> "feature-complete" means. +It complements unit tests by exercising the deployed Slack path, including +mentions, threads, streaming text, rich blocks, follow-up turns, and +interruptions. -## What's in here +## Configure -``` -e2e/ -├── README.md this -├── cases.ts catalog of test cases (technical axes; expand liberally) -├── slack-api.ts Slack Web API helpers (history, thread replies, sampling) -├── run.ts harness entrypoint — sends prompts, samples, screenshots -└── results/ per-run output: screenshots + JSON report -``` +Set these only in your local root `.env`: -## 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 +```dotenv +SLACK_BOT_TOKEN=xoxb-... +SLACK_USER_TOKEN=xoxp-... +BOT_USER_ID=U... +E2E_CHANNEL=C... ``` -The runner expects `.env` to already contain `SLACK_BOT_TOKEN` (used for -polling the channel history while the bot streams). Sending the user -message happens through the playwright-driven Slack UI using Atai's -session cookies from the persistent profile. +- `SLACK_BOT_TOKEN` reads channel history and thread replies. +- `SLACK_USER_TOKEN` posts as a real user so Slack emits the user event. +- `BOT_USER_ID` identifies the OpenTag bot in replies. +- `E2E_CHANNEL` is the test channel ID. -## How sampling works +The harness never provisions or reinstalls an app. Do not run it against the +production `@kite` app while another Socket Mode consumer owns the same token. -For each case the harness: +## Run -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. +Start or deploy the OpenTag agent and Channel, then: -## 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 +```bash +pnpm e2e +``` -## Adding cases +Run a subset by case-name substring: -Edit `cases.ts`. The bar is low — anything you'd want to _see_ working in -Slack belongs in the catalog. Don't be afraid of duplication with -unit tests; the unit test proves the code is internally correct, the E2E -proves Slack actually renders it that way. +```bash +CASE_FILTER='A1' pnpm e2e +``` -## Limitations (current) +Cases live in [`cases.ts`](./cases.ts). The harness entrypoint is +[`run.ts`](./run.ts), and Slack API helpers are in +[`slack-api.ts`](./slack-api.ts). -- Sending the user message still relies on UI automation (no user - token), so a one-time signin in the persistent profile is required. -- A future enhancement: a long-lived user OAuth token would let us skip - the browser entirely for the _send_ step (screenshots still need the - browser, but sampling already uses pure API). +Microsoft Teams is supported for launch through Intelligence, but this folder +does not yet contain a Teams live harness. diff --git a/e2e/TELEGRAM-README.md b/e2e/TELEGRAM-README.md index f5dfd47..b73f25d 100644 --- a/e2e/TELEGRAM-README.md +++ b/e2e/TELEGRAM-README.md @@ -1,119 +1,6 @@ -# `e2e/telegram-*` — live end-to-end test harness for the Telegram bot +# Telegram end-to-end testing — coming soon -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. +Telegram is not an active OpenTag launch surface. Its adapter, configuration, +and live end-to-end harness will be documented when support is ready. -> **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. +For this launch, use Slack or Microsoft Teams through CopilotKit Intelligence. diff --git a/package.json b/package.json index d6b4a6b..febc5ad 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,6 @@ "start": "tsx server.ts", "channel": "tsx server.ts", "agent": "cd agent && uv run python main.py", - "runtime": "tsx runtime.ts", "notion-mcp": "tsx scripts/start-notion-mcp.ts", "check-types": "tsc --noEmit -p tsconfig.json", "test": "vitest run", @@ -21,9 +20,6 @@ "@copilotkit/channels": "0.2.2-canary.rc-1", "@copilotkit/runtime": "1.63.3-canary.rc-1", "@notionhq/notion-mcp-server": "^2.4.0", - "@tanstack/ai": "^0.32.0", - "@tanstack/ai-mcp": "^0.1.3", - "@tanstack/ai-openai": "^0.15.2", "dotenv": "^16.4.5", "playwright": "^1.49.0", "tsx": "^4.19.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c670b5b..354acee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,15 +23,6 @@ importers: '@notionhq/notion-mcp-server': specifier: ^2.4.0 version: 2.4.1(@cfworker/json-schema@4.1.1)(js-yaml@4.3.0) - '@tanstack/ai': - specifier: ^0.32.0 - version: 0.32.0(@opentelemetry/api@1.9.1) - '@tanstack/ai-mcp': - specifier: ^0.1.3 - version: 0.1.10(@cfworker/json-schema@4.1.1)(@opentelemetry/api@1.9.1)(zod@3.25.76) - '@tanstack/ai-openai': - specifier: ^0.15.2 - version: 0.15.10(@tanstack/ai@0.32.0(@opentelemetry/api@1.9.1))(ws@8.21.1)(zod@3.25.76) dotenv: specifier: ^16.4.5 version: 16.6.1 @@ -75,9 +66,6 @@ packages: '@ag-ui/client@0.0.57': resolution: {integrity: sha512-Xap2alG9Z0/j5kb3x4D7oTpe2sw1dfrC9rgJJr2NZu5vKcm8dzIPNd31mF2B4zS3BKqYIu245yxKPhEtT30MHw==} - '@ag-ui/core@0.0.52': - resolution: {integrity: sha512-Xo0bUaNV56EqylzcrAuhUkQX7et7+SZIrqZZtEByGwEq/I1EHny6ZMkWHLkKR7UNi0FJZwJyhKYmKJS3B2SEgA==} - '@ag-ui/core@0.0.54': resolution: {integrity: sha512-Ilx31OvRQaZfU7jSArGqz06JZKOsAt8zWiCPJljyp9zR6Tzl18oyfx8o6FsuGfAktGRe50GI9SCCxNXXysZwtA==} @@ -826,55 +814,11 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@tanstack/ai-event-client@0.6.3': - resolution: {integrity: sha512-aQb+v9a4T+uDS3VuDGKOdhwPvr2iwjHVweroXHwbasmvClFqV6WJhoekv74VJrhJrlwz8wVMEPcwlKHxnSAzLQ==} - peerDependencies: - '@tanstack/ai': 0.32.0 - - '@tanstack/ai-event-client@0.6.8': - resolution: {integrity: sha512-h7/HLz9u2LF9ba6uKFMnSZkFlkzQONJ5u3Hi+4qGxpsHyvcGRtW6v186jaFLoklndhEwyC49ytkC4eafi7Qq8w==} - - '@tanstack/ai-mcp@0.1.10': - resolution: {integrity: sha512-J6PDw9KOpc5C6/L5IYq4SBnzhTUqjfSHcyOuE+ESVbhTeEctnKTH2IT50JZ46sVinmGUSgQ67j2YuItLZoJRWw==} - hasBin: true - - '@tanstack/ai-openai@0.15.10': - resolution: {integrity: sha512-S/mbplx7sf3lhJrMZBTIBteaaWID6LuNBW8MkOk1I12x0fCmHxZaisf4fKILekcrZ6WniqVNyOLKrJnDD+T2PQ==} - peerDependencies: - '@tanstack/ai': ^0.39.0 - zod: ^4.0.0 - - '@tanstack/ai-utils@0.3.1': - resolution: {integrity: sha512-fgbjd5DohL3k5rTWr/KInauLVYMiHVm0cnmTsNrzL3cr4wWhEld5vmFSrjiwUWACAuONjZa+uBXuS+ZSO8fwDQ==} - - '@tanstack/ai@0.32.0': - resolution: {integrity: sha512-8Saiyws4irNkxPcyLOucquYaEFEGUVBCrBJmfr1W6z1oDVDYF+pwb31rOLjN0/xqbGCHzjOaXc4TLAZS3kPS4g==} - engines: {node: '>=18'} - peerDependencies: - '@opentelemetry/api': '>=1.9.0' - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - - '@tanstack/ai@0.37.0': - resolution: {integrity: sha512-5vM8XCBUyk6whQZ7gAiM1nX+NqvOAOZqAJrFxlo6g+vIvIfqfNqcD378E7kmZIIt5idFkzA8/IH/00oQDsZRyg==} - engines: {node: '>=18'} - peerDependencies: - '@opentelemetry/api': '>=1.9.0' - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@tanstack/devtools-event-client@0.4.4': resolution: {integrity: sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==} engines: {node: '>=18'} hasBin: true - '@tanstack/openai-base@0.9.6': - resolution: {integrity: sha512-vHF5VTKrLrb0c+fLm4hdd5xOKsYUI1/Qt3bLaFYMeUogfYq1LIZlEGUb4EJaFaMYLrjIuep+exjpaHx0TY9kMQ==} - peerDependencies: - '@tanstack/ai': ^0.39.0 - '@tanstack/pacer@0.20.1': resolution: {integrity: sha512-ZNQ1bIL6eUXVKdic0tiImvBVkWrg/IoSK6VIacTrO3d3HAGnd70qFJNJagR/YOJIOw4EKGWnodwpYZkN1pWuVQ==} engines: {node: '>=18'} @@ -2495,10 +2439,6 @@ snapshots: uuid: 11.1.1 zod: 3.25.76 - '@ag-ui/core@0.0.52': - dependencies: - zod: 3.25.76 - '@ag-ui/core@0.0.54': dependencies: zod: 3.25.76 @@ -3473,73 +3413,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@tanstack/ai-event-client@0.6.3(@tanstack/ai@0.32.0(@opentelemetry/api@1.9.1))': - dependencies: - '@tanstack/ai': 0.32.0(@opentelemetry/api@1.9.1) - '@tanstack/devtools-event-client': 0.4.4 - - '@tanstack/ai-event-client@0.6.8': - dependencies: - '@tanstack/devtools-event-client': 0.4.4 - - '@tanstack/ai-mcp@0.1.10(@cfworker/json-schema@4.1.1)(@opentelemetry/api@1.9.1)(zod@3.25.76)': - dependencies: - '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76) - '@tanstack/ai': 0.37.0(@opentelemetry/api@1.9.1) - transitivePeerDependencies: - - '@cfworker/json-schema' - - '@opentelemetry/api' - - supports-color - - zod - - '@tanstack/ai-openai@0.15.10(@tanstack/ai@0.32.0(@opentelemetry/api@1.9.1))(ws@8.21.1)(zod@3.25.76)': - dependencies: - '@tanstack/ai': 0.32.0(@opentelemetry/api@1.9.1) - '@tanstack/ai-utils': 0.3.1 - '@tanstack/openai-base': 0.9.6(@tanstack/ai@0.32.0(@opentelemetry/api@1.9.1))(ws@8.21.1)(zod@3.25.76) - openai: 6.47.0(ws@8.21.1)(zod@3.25.76) - zod: 3.25.76 - transitivePeerDependencies: - - '@aws-sdk/credential-provider-node' - - '@smithy/hash-node' - - '@smithy/signature-v4' - - ws - - '@tanstack/ai-utils@0.3.1': {} - - '@tanstack/ai@0.32.0(@opentelemetry/api@1.9.1)': - dependencies: - '@ag-ui/core': 0.0.52 - '@standard-schema/spec': 1.1.0 - '@tanstack/ai-event-client': 0.6.3(@tanstack/ai@0.32.0(@opentelemetry/api@1.9.1)) - partial-json: 0.1.7 - optionalDependencies: - '@opentelemetry/api': 1.9.1 - - '@tanstack/ai@0.37.0(@opentelemetry/api@1.9.1)': - dependencies: - '@ag-ui/core': 0.0.52 - '@standard-schema/spec': 1.1.0 - '@tanstack/ai-event-client': 0.6.8 - '@tanstack/ai-utils': 0.3.1 - partial-json: 0.1.7 - optionalDependencies: - '@opentelemetry/api': 1.9.1 - '@tanstack/devtools-event-client@0.4.4': {} - '@tanstack/openai-base@0.9.6(@tanstack/ai@0.32.0(@opentelemetry/api@1.9.1))(ws@8.21.1)(zod@3.25.76)': - dependencies: - '@tanstack/ai': 0.32.0(@opentelemetry/api@1.9.1) - '@tanstack/ai-utils': 0.3.1 - openai: 6.47.0(ws@8.21.1)(zod@3.25.76) - transitivePeerDependencies: - - '@aws-sdk/credential-provider-node' - - '@smithy/hash-node' - - '@smithy/signature-v4' - - ws - - zod - '@tanstack/pacer@0.20.1': dependencies: '@tanstack/devtools-event-client': 0.4.4 @@ -4603,6 +4478,7 @@ snapshots: optionalDependencies: ws: 8.21.1 zod: 3.25.76 + optional: true openapi-client-axios@7.9.0(axios@1.18.1)(js-yaml@4.3.0): dependencies: diff --git a/runtime.ts b/runtime.ts deleted file mode 100644 index 5bb1bcf..0000000 --- a/runtime.ts +++ /dev/null @@ -1,374 +0,0 @@ -/** - * Alternate TypeScript agent backend for OpenTag. - * - * This is the brain behind the chat bridge: a single CopilotKit - * `BuiltInAgent` (LLM + MCP) served over AG-UI by a `CopilotSseRuntime`. - * It replaces the old vendored Python/LangGraph showcase backend — there - * is no Python, no `langgraph dev`, no A2UI middleware. Everything is a - * few dozen lines of TypeScript. - * - * The canonical Channel host (`server.ts`) fronts this same backend with the - * `@copilotkit/channels` umbrella package and keeps this runtime free of - * platform-specific code. - * - * What it does - * ------------ - * The agent connects to **Linear** and **Notion** via their MCP servers - * and acts as an on-call / triage assistant inside chat: it pulls and - * files Linear issues, finds Notion runbooks, and writes incident - * threads up as Notion postmortems. The data access is entirely MCP — - * the agent discovers the available tools (list/search/create issues, - * search/create pages) from each server at runtime. - * - * The chat-side primitives (read_thread, the confirm_write HITL picker, - * the issue/page Channels components) are forwarded to the agent as - * client-provided tools by the Channel on every run — see `app/channel.ts`. - * - * Auth & deployment - * ----------------- - * Every connection is env-driven, so the same process runs locally and - * deployed — only the env differs (see `.env.example`): - * - * - Linear: the hosted MCP accepts a raw API key as a bearer token, so - * we connect straight to `LINEAR_MCP_URL` with `LINEAR_API_KEY`. - * - Notion: run the official `@notionhq/notion-mcp-server` as a - * Streamable-HTTP sidecar (`pnpm notion-mcp` locally, a second - * process/container in prod) and point `NOTION_MCP_URL` / - * `NOTION_MCP_AUTH_TOKEN` at it. - * - * A server is only wired up when its credentials are present, so the bot - * runs Linear-only, Notion-only, or both. - * - * Exposed route (the bridge's `AGENT_URL`, shared by every platform): - * POST http://localhost:8200/api/copilotkit/agent/triage/run - */ -import "dotenv/config"; -import { createServer } from "node:http"; -import { - BuiltInAgent, - CopilotSseRuntime, - convertInputToTanStackAI, -} from "@copilotkit/runtime/v2"; -import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node"; -import { chat } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; -import { webSearchTool } from "@tanstack/ai-openai/tools"; -import { createMCPClient } from "@tanstack/ai-mcp"; - -const LINEAR_TEAM_KEY = process.env["LINEAR_TEAM_KEY"] ?? "CPK"; - -/** - * HTTP MCP transports (Linear hosted + Notion sidecar), each carrying a static - * `Authorization: Bearer`. TanStack AI's `chat()` connects these per run and - * closes them when the run ends (its `mcp.connection: "close"` default), so we - * just describe the transports here and create fresh clients inside the agent - * factory on each turn. - */ -interface McpHttpTransport { - type: "http"; - url: string; - headers: Record<string, string>; -} - -/** A transport plus the human label we surface when it's up or down. */ -interface LabeledTransport { - name: string; - transport: McpHttpTransport; -} - -function mcpTransports(): LabeledTransport[] { - const transports: LabeledTransport[] = []; - if (process.env["LINEAR_API_KEY"]) { - transports.push({ - name: "Linear", - transport: { - type: "http", - url: process.env["LINEAR_MCP_URL"] ?? "https://mcp.linear.app/mcp", - headers: { Authorization: `Bearer ${process.env["LINEAR_API_KEY"]}` }, - }, - }); - } - if (process.env["NOTION_MCP_AUTH_TOKEN"]) { - transports.push({ - name: "Notion", - transport: { - type: "http", - url: process.env["NOTION_MCP_URL"] ?? "http://127.0.0.1:3001/mcp", - headers: { - Authorization: `Bearer ${process.env["NOTION_MCP_AUTH_TOKEN"]}`, - }, - }, - }); - } - return transports; -} - -/** Max time to wait for an MCP server to connect before giving up on it. */ -const MCP_CONNECT_TIMEOUT_MS = 8000; - -/** - * Connect one MCP client without ever taking the run down with it. A server - * that's misconfigured (bad key), down (sidecar not running), or hanging must - * NOT abort the turn — the agent should keep working with whatever else is - * available. We race the connect against a timeout. - * - * `connecting` can settle after we've already given up on it: a late - * rejection must not surface as an unhandled rejection, and a late - * *success* hands back a live `MCPClient` that nothing else will ever - * close — left alone that's a leaked MCP connection every time a server is - * merely slow instead of down. So once the timeout wins the race, we attach - * a follow-up that closes whatever client `connecting` eventually produces; - * closing is itself best-effort (swallow errors from `close()`) since there's - * no one left to hand a close failure to. - */ -async function connectMcp(transport: McpHttpTransport) { - const connecting = createMCPClient({ transport }); - let timedOut = false; - connecting.then( - (client) => { - if (timedOut) void client.close().catch(() => {}); // leaked otherwise: nothing else closes it - }, - () => {}, // late reject (post-timeout) must not crash the process - ); - let timer: ReturnType<typeof setTimeout> | undefined; - const timeout = new Promise<never>((_, reject) => { - timer = setTimeout(() => { - timedOut = true; - reject(new Error(`timed out after ${MCP_CONNECT_TIMEOUT_MS}ms`)); - }, MCP_CONNECT_TIMEOUT_MS); - timer.unref?.(); // don't keep the process alive on the timer alone - }); - try { - return await Promise.race([connecting, timeout]); - } finally { - if (timer) clearTimeout(timer); - } -} - -if (mcpTransports().length === 0) { - console.warn( - "[runtime] No MCP servers configured. Set LINEAR_API_KEY and/or " + - "NOTION_MCP_AUTH_TOKEN in .env — without them the bot can chat and " + - "search the web but can't read or write Linear/Notion.", - ); -} - -const SYSTEM_PROMPT = [ - "You are KiteBot, an on-call triage assistant living in your team's chat workspace. You help", - "an engineering team turn incident chatter into tracked work: you pull and", - "file Linear issues, find Notion runbooks, and write incident threads up as", - "Notion postmortems.", - "", - "Data access:", - "- Linear and Notion are connected via MCP. Use those tools to search, read,", - ` and create issues and pages. The default Linear team is "${LINEAR_TEAM_KEY}"`, - " unless the user names another team.", - "", - "Linear tool tips (the filters are picky — follow these to avoid empty results):", - `- Pass the team KEY directly to list_issues, e.g. {team: "${LINEAR_TEAM_KEY}"}. Do`, - " NOT call list_teams to look a team up by its key — list_teams matches the", - ' team\'s full NAME, not its key, so a key like "CPK" returns nothing. If you', - " must resolve a team, use get_team with the key.", - '- For "my issues" / "assigned to me": set assignee to the requesting user\'s', - ' email (it\'s in your context) or the literal "me" — both work.', - "- The state filter takes a Linear state TYPE (backlog, unstarted, started,", - ' completed, canceled) or a specific state name — NOT "open" or "closed". For', - ' "open" issues, OMIT the state filter entirely (state:"open" returns nothing).', - '- There is no cycle:"current"/"active" value. For "this cycle", just list the', - " team's issues (omit the cycle filter) unless the user names a cycle number.", - "- QUERY ONCE. Call list_issues a SINGLE time with the team key + any needed", - " filter. Do NOT paginate or re-run it with different filter combinations to", - " gather every issue — one query is enough. If the result set is large, render", - " the ~15 most recent and note the rest (e.g. 'showing 15 of 39') instead of", - " dumping the whole backlog; a 39-row card is noise, not an answer.", - "- Use get_issue for one issue; render it with issue_card.", - "- To act on a chat conversation (e.g. 'write this thread up'), call the", - " read_thread tool to fetch the messages first — never invent thread content.", - "", - "Files & visuals: uploaded files arrive in the message as content you can", - "read — images and PDFs directly, and CSV/JSON/text as decoded text. When a", - "user uploads data and wants a chart, parse it and call render_chart with a", - "Chart.js config OBJECT — pick a sensible type (bar/line/pie) and inline the", - "data. When the user wants the data itself shown as a table (not a chart),", - "call render_table with columns + rows (each row an array of cell values in", - "column order; set a column's align to 'right' for numeric columns). When", - "asked to diagram a flow/architecture/timeline, call render_diagram with", - "Mermaid source. render_chart and render_diagram post an image; render_table", - "posts a table. If render_diagram returns an error, fix the Mermaid and", - "retry. These are read/reply actions — no confirm_write needed.", - "- render_chart / render_diagram post a TITLED image themselves (a caption", - " header followed by the image). Do NOT narrate the act with a separate", - ' "Charting `file.csv`…" line or a "rendered above/below" sentence — that', - " text lands AFTER the image and reads out of order. Let the titled image be", - " the answer; if you must reply, ONE short past-tense clause naming the file", - ' is enough (e.g. "Charted `incidents-2026.csv`.").', - "- If more than one file is in the thread and the request doesn't make clear", - " which one to use, ASK which file (list them by name) instead of guessing.", - "", - "Acting per-user: each turn's context names the requesting user, with", - 'their name and email. When someone says "my issues", "assigned to me", or', - '"file this for me", use that email/name to find their Linear user, then:', - "- Querying: filter Linear by that person (assignee), so each user gets THEIR", - " issues — not everyone's.", - "- Creating: set the new issue's assignee to that person and @mention them.", - " (Heads up: issues are still authored by the bot's API key, so the Linear", - " 'creator' is the bot — assignee is how you attribute work to the requester.)", - "Never assume every request is from the same person; always use the requester", - "named in context. If their email isn't in context, say so rather than guessing.", - "", - "RENDERING — THIS IS A HARD RULE. Whenever your answer contains structured", - "output, you MUST call the matching render tool and let IT draw the card. Do", - "NOT reproduce that content as Markdown bullets, a table, or prose — a hand-", - "written list/table/card is a BUG, not an answer. Map the request to a tool and", - "call it FIRST, then add at most one short sentence around it:", - "- Several Linear issues -> issue_list", - "- A single Linear issue -> issue_card (and right after you create one, justCreated: true)", - "- Notion pages -> page_list", - "- Tabular data / 'as a table' -> render_table (columns + rows)", - "- A status / metrics / health summary (counts, KPIs, label/value pairs)", - " -> show_status (heading + fields:[{label,value}])", - "- An incident / outage -> show_incident (id, title, severity SEV1|SEV2|SEV3,", - " summary) — an interactive card with Acknowledge/Escalate", - "- A set of links / runbooks -> show_links (heading + links:[{label,url}])", - "- A chart from data -> render_chart; a flow/architecture/timeline -> render_diagram", - "If the user explicitly asks for a card/table/incident/status/links, calling the", - "tool IS the whole answer — never describe what the card 'would' contain in prose.", - "Your text message alongside a rendered card MUST be empty or ONE short line (e.g.", - '"Open CPK issues:"). NEVER restate the issues/rows/fields as text after rendering', - "— the card already shows them, and a duplicate text wall is the single most", - "annoying thing you can do. Render, then stop.", - "- ALWAYS populate each issue's state and priority as plain strings (e.g.", - ' state:"In Progress", priority:"High") on the component props — the cards', - " use them for the status dot and the colored border. The Linear MCP returns", - ' priority as an object {value, name}; pass its NAME string (e.g. "High"),', - " not the object. Map the issue's workflow status into state. Include", - " assignee, url, and updated too when you have them.", - "", - "WRITE GATING: a 'write' is CREATING or MODIFYING something in Linear or Notion", - "(create_issue, update_issue, create_page, …). ONLY before such a write, call the", - "confirm_write tool with a one-line summary and wait for approval; perform the", - "write only if confirmed. Rendering a card/table (issue_list, issue_card,", - "show_incident, show_status, show_links, render_table, render_chart/diagram) and", - "any read (search/list/get) are NOT writes — never gate them, and never add an", - "'I'll need approval' disclaimer to a pure render or read.", -].join("\n"); - -// OpenAI-only here: web search is an OpenAI hosted (provider) tool, so this -// agent runs on the OpenAI Responses API via TanStack AI's `openaiText` -// adapter. Override the model with AGENT_MODEL (a bare OpenAI id, or -// "openai/<id>" — the prefix is stripped); defaults to gpt-5.5. The cast is -// needed because AGENT_MODEL is a dynamic (env-sourced) string while -// `openaiText` types its argument to the known OpenAI model literal union — -// the cast is a trapdoor around that check, so the env value is NOT validated -// against the union; an unsupported/misspelled model id will only surface as -// an OpenAI API error at request time, not a compile-time or startup check. -const model = (process.env["AGENT_MODEL"] ?? "openai/gpt-5.5").replace( - /^openai\//, - "", -) as Parameters<typeof openaiText>[0]; - -// Factory mode: we own the LLM call (TanStack AI `chat()`); BuiltInAgent owns -// the AG-UI run lifecycle and converts TanStack's stream into AG-UI events. -// `chat()` runs the multi-turn tool loop, the OpenAI `web_search` provider -// tool, and the MCP tools — discovering MCP tools and closing the connections -// when the run ends. The big triage prompt is prepended as a system prompt, -// ahead of any system/context/state prompts derived from the run input. -const agent = new BuiltInAgent({ - type: "tanstack", - factory: async (ctx) => { - const { - messages, - systemPrompts, - tools: clientTools, - } = convertInputToTanStackAI(ctx.input); - - // Connect each MCP server independently so one bad/unreachable server can't - // kill the turn. Failures are dropped (the agent runs with whatever else is - // up) and noted so the model only tells the user a source is down if they - // actually ask for it — see `availabilityNote` below. - const transports = mcpTransports(); - const settled = await Promise.allSettled( - transports.map((t) => connectMcp(t.transport)), - ); - const clients: Array<Awaited<ReturnType<typeof connectMcp>>> = []; - const unavailable: string[] = []; - settled.forEach((result, i) => { - if (result.status === "fulfilled") { - clients.push(result.value); - } else { - unavailable.push(transports[i]!.name); - const msg = - result.reason instanceof Error - ? result.reason.message - : String(result.reason); - console.error( - `[runtime] MCP "${transports[i]!.name}" unavailable this turn:`, - msg, - ); - } - }); - - // Tell the model which sources are down THIS turn so it degrades gracefully: - // keep answering with everything that works, and only surface the outage if - // the user's request needs the missing source (never invent data). - const isAre = unavailable.length > 1 ? "are" : "is"; - const itsTheir = unavailable.length > 1 ? "their" : "its"; - const availabilityNote = - unavailable.length > 0 - ? `\n\nDATA SOURCE STATUS: ${unavailable.join(" and ")} ${isAre} ` + - `temporarily UNAVAILABLE this turn (connection failed), so ${itsTheir} ` + - `tools are not loaded. Everything else — web search, rendering cards/` + - `charts, reading the thread — still works normally. ONLY if the ` + - `user asks for something that needs ${unavailable.join(" or ")}, tell ` + - `them that source is temporarily unreachable and to try again shortly; ` + - `never invent data or claim a write/read succeeded.` - : ""; - - return chat({ - adapter: openaiText(model), - messages, - systemPrompts: [SYSTEM_PROMPT + availabilityNote, ...systemPrompts], - // `web_search` is an OpenAI provider tool (run server-side by OpenAI); - // `clientTools` are the bot's frontend tools (issue/page cards, charts, - // confirm_write HITL) forwarded on every run — passed as client-side - // tools so the model can call them and the bot renders/gates them via - // the AG-UI client-tool round-trip. MCP tools come in via `mcp` below. - tools: [webSearchTool({ type: "web_search" }), ...clientTools], - ...(clients.length > 0 ? { mcp: { clients } } : {}), - // TanStack AI needs the full AbortController (not just the signal). - abortController: ctx.abortController, - }); - }, -}); - -const runtime = new CopilotSseRuntime({ - agents: { triage: agent }, -}); - -const listener = createCopilotNodeListener({ - runtime, - basePath: "/api/copilotkit", - cors: true, -}); - -const rawPort = process.env["PORT"]; -const port = - rawPort && rawPort.trim() !== "" ? Number(rawPort) : 8200; -if (!Number.isInteger(port) || port < 1 || port > 65535) { - console.error(`Invalid PORT: "${rawPort}" is not a valid port number`); - process.exit(1); -} -createServer(listener).listen(port, () => { - console.log( - `[runtime] listening on http://localhost:${port}/api/copilotkit/agent/triage/run`, - ); - const connected = [ - process.env["LINEAR_API_KEY"] ? "Linear" : null, - process.env["NOTION_MCP_AUTH_TOKEN"] ? "Notion" : null, - ].filter(Boolean); - console.log( - `[runtime] agent "triage" ready · MCP: ${ - connected.length ? connected.join(", ") : "none" - }`, - ); -}); diff --git a/scripts/start-notion-mcp.ts b/scripts/start-notion-mcp.ts index eced358..359d5e1 100644 --- a/scripts/start-notion-mcp.ts +++ b/scripts/start-notion-mcp.ts @@ -1,8 +1,7 @@ /** * Starts the official Notion MCP server as a Streamable-HTTP sidecar for the - * agent backends — the TS runtime (`runtime.ts`) and the Python deep-research - * agent (`agent/`); on Railway the Python agent is the sole consumer (see - * `.railway/railway.ts`). Run with `pnpm notion-mcp`. + * OpenTag Python deep-research agent (`agent/`). Run with `pnpm notion-mcp` + * for optional local Notion access. * * Why a launcher instead of a raw npm script: the two `.env` values need to be * mapped to the two DIFFERENT things the Notion server expects — the Notion @@ -34,9 +33,9 @@ if (!notionToken) { process.exit(1); } -// Port the sidecar listens on. Must agree with NOTION_MCP_URL as dialed by -// runtime.ts (TS backend) AND by the Python agent/ backend on Railway — both -// default to http://127.0.0.1:3001/mcp. Validated up front because it's passed +// Port the sidecar listens on. Must agree with NOTION_MCP_URL as dialed by the +// Python agent; both default to http://127.0.0.1:3001/mcp. Validated up front +// because it's passed // as a `--port` arg to the spawn below, which uses a shell on Windows // (`shell: isWindows`) — an unvalidated value with spaces/shell metacharacters // could mangle or inject the command there. An empty string (a bare @@ -58,10 +57,7 @@ if (!Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535) { const port = String(portNumber); // Host the sidecar binds to. Defaults to loopback for local dev (matches the -// upstream server's own default). On Railway the `notion-mcp` service sets -// NOTION_MCP_HOST=:: so the sidecar listens on all interfaces and is reachable -// over Railway's (IPv6) private network — without it the server binds 127.0.0.1 -// and the agent's cross-container connection is refused. Validated because it's +// upstream server's own default). Validated because it's // passed as a `--host` arg to the spawn below (which uses a shell on Windows, // `shell: isWindows`), and to prevent a leading `-` being read as a flag. const rawHost = process.env["NOTION_MCP_HOST"] || undefined; diff --git a/setup.md b/setup.md index 7ec428a..1de7af3 100644 --- a/setup.md +++ b/setup.md @@ -1,301 +1,269 @@ -# OpenTag — setup & configuration - -Everything beyond the [quick start](./README.md#quick-start): the full Slack app walkthrough, -the complete environment reference, running standalone vs. from the monorepo, the Intelligence -Gateway channel mode, wiring up Linear / Notion / inline charts, the other chat platforms, -slash commands, tests, and how the pieces fit together. - -- [How it fits together](#how-it-fits-together) -- [Running it](#running-it) — monorepo or standalone, self-hosted or Intelligence Gateway -- [Deep research (LangGraph deep agent)](#deep-research-langgraph-deep-agent) -- [Intelligence channel mode](#intelligence-channel-mode) -- [1. Create a Slack app](#1-create-a-slack-app) -- [2. Environment variables](#2-environment-variables) -- [3. Integrations](#3-integrations) — Linear, Notion, charts -- [Other platforms](#other-platforms) — Discord, Telegram, WhatsApp -- [Slash commands](#slash-commands) -- [Files → charts, diagrams & tables](#files--charts-diagrams--tables) -- [Tests](#tests) - -## How it fits together +# OpenTag setup -``` -Slack / Discord / Telegram / WhatsApp ──@mention──▶ KiteBot (app/) ──AG-UI──▶ runtime (runtime.ts) - │ BuiltInAgent (LLM) - ├── Linear MCP (hosted) - └── Notion MCP (sidecar) -``` +This guide covers the canonical OpenTag deployment: one Python deep-agent +service and one Node Channel service connected to CopilotKit Intelligence. -Three moving parts: the **chat-platform app(s)** in `app/`, the **agent** (`runtime.ts`), and — -if you use Notion — a small **Notion MCP sidecar**. KiteBot speaks to the agent over -[AG-UI](https://docs.ag-ui.com); the agent is one CopilotKit `BuiltInAgent` (an LLM plus -optional MCP tools — no Python, no LangGraph). - -KiteBot runs in one of two modes: **self-hosted** (`pnpm dev` → `app/index.ts`, holds the Slack -tokens directly) or **Intelligence Gateway** (`pnpm channel` → `app/managed.ts`, over the -CopilotKit Intelligence Realtime Gateway — see [Intelligence channel -mode](#intelligence-channel-mode)). Both modes talk to the same agent backend -(`pnpm runtime` → `runtime.ts`) via `AGENT_URL`. - -| Concept | Where | -| -------------------------------------------------------------------- | ------------------------------------------------------------------ | -| `createBot({ adapters, agent, tools, context, commands })` | [`app/index.ts`](./app/index.ts) | -| Multi-adapter wiring (Slack/Discord/Telegram/WhatsApp, secret-gated) | [`app/index.ts`](./app/index.ts) | -| `read_thread` — grounds the agent in the real conversation | [`app/tools/read-thread.ts`](./app/tools/read-thread.ts) | -| Render-tools + JSX components (issue card/list, Notion pages) | [`app/tools/render-tools.tsx`](./app/tools/render-tools.tsx), [`app/components/`](./app/components/) | -| Chart / diagram rendering (Playwright → PNG) | [`app/tools/render-chart.tsx`](./app/tools/render-chart.tsx), `render-diagram.tsx`, [`app/render/`](./app/render/) | -| Table rendering (native `<Table>` block, monospace fallback) | [`app/tools/render-table.tsx`](./app/tools/render-table.tsx) | -| Status / incident / links showcase cards | [`app/tools/showcase-tools.tsx`](./app/tools/showcase-tools.tsx), [`app/components/_status.ts`](./app/components/_status.ts) | -| Blocking **human-in-the-loop** gate (`confirm_write`) | [`app/human-in-the-loop/confirm-write.tsx`](./app/human-in-the-loop/confirm-write.tsx) | -| Slash commands (`/agent`, `/triage`, `/preview`, `/file-issue`) | [`app/commands/index.ts`](./app/commands/index.ts) | -| A Block Kit **modal** (`/file-issue`) | [`app/modals/file-issue.tsx`](./app/modals/file-issue.tsx) | -| The agent backend — one `BuiltInAgent` (LLM + Linear/Notion MCP) | [`runtime.ts`](./runtime.ts) | - -- **`app/`** is the platform-agnostic KiteBot code. **This is the directory you copy to start your own bot.** -- **`runtime.ts`** is the agent backend, served over AG-UI. -- **`e2e/`** holds live test harnesses (the Slack harness is being migrated to the new - `createBot` API; the Telegram harness is a working manual-trigger smoke test — see - [`e2e/TELEGRAM-README.md`](./e2e/TELEGRAM-README.md)). - -It's built on: - -- **[`@copilotkit/channels`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels)** — the platform-agnostic bot engine. -- **[`@copilotkit/channels-slack`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-slack)** / **[`-discord`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-discord)** / **[`-telegram`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-telegram)** / **[`-whatsapp`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-whatsapp)** — the platform adapters. -- **[`@copilotkit/channels-ui`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-ui)** — a cross-platform JSX vocabulary for rich messages (Block Kit on Slack, Components V2 on Discord, HTML on Telegram). -- **[`@copilotkit/channels-intelligence`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-intelligence)** — runs the same KiteBot over the CopilotKit Intelligence Realtime Gateway (Intelligence Gateway mode, no platform tokens in this process). -- **[`@copilotkit/runtime`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/runtime)** — the AG-UI agent backend. - -## Running it - -### From the monorepo - -If you're working inside the [CopilotKit monorepo](https://github.com/CopilotKit/CopilotKit), -this code runs there as `examples/slack`, whose `package.json` name is `slack-example` (this -standalone repo's `package.json` name is `opentag` — the `--filter` below only resolves inside -the monorepo), building the `@copilotkit/channels*` adapters from source: +Slack and Microsoft Teams are supported for this launch. Discord, Telegram, +and WhatsApp are coming soon. -```bash -pnpm install # repo root -pnpm --filter slack-example notion-mcp # only if using Notion → http://127.0.0.1:3001/mcp -pnpm --filter slack-example runtime # CopilotKit runtime on :8200, agent "triage" -pnpm --filter slack-example dev # KiteBot (tsx watch app/index.ts) -``` +## Components + +| Component | Location | Responsibility | +| --- | --- | --- | +| Channel entrypoint | [`server.ts`](./server.ts) | Environment, readiness, HTTP lifecycle, and shutdown | +| Channel definition | [`app/channel.tsx`](./app/channel.tsx) | Mentions, commands, components, modals, and interrupts | +| Intelligence runtime | [`app/runtime-host.ts`](./app/runtime-host.ts) | One `CopilotKitIntelligence` and one `CopilotRuntime` | +| Platform adapters | [`app/platforms.ts`](./app/platforms.ts) | Optional direct Slack and Teams adapters for local development | +| Python agent | [`agent/`](./agent) | LangGraph deep agent served over AG-UI | +| Railway topology | [`.railway/railway.ts`](./.railway/railway.ts) | Two services sourced from OpenTag `main` | -### Standalone (npm) +The Channel always uses the Intelligence-owned runtime. Managed Slack and +Microsoft Teams attachments are configured in Intelligence. Direct adapters +are optional additions to the same runtime, not an alternate non-Intelligence +mode. -The `@copilotkit/channels*` packages are published on npm, so a plain `npm install` here works -as-is — no monorepo required: +## Install + +Prerequisites: + +- Node.js 22+ +- pnpm +- Python 3.12 +- [`uv`](https://docs.astral.sh/uv/) +- A CopilotKit Intelligence project, Channel, and runtime API key +- An OpenAI API key for the Python agent + +Install both dependency sets: ```bash -npm install -npm run notion-mcp # terminal 1 — only if using Notion -npm run runtime # terminal 2 — the agent backend on :8200 -npm run dev # terminal 3 — KiteBot, self-hosted (holds the Slack tokens) +pnpm install --frozen-lockfile +cd agent +uv sync +cd .. ``` -The chart/diagram renderers need a Chromium binary: `npx playwright install chromium`. - -## Deep research (LangGraph deep agent) +The Channels and Runtime packages are intentionally pinned to canaries: -[`agent/`](./agent) is an alternative agent backend to `runtime.ts`: a Python -[`deepagents`](https://github.com/langchain-ai/deepagents) (LangGraph) planner with a virtual -filesystem and OPTIONAL Tavily web research, served over AG-UI on `:8123`. Instead of -`runtime.ts`'s single system-prompted `BuiltInAgent` call, it plans with `write_todos`, -reads/writes its own virtual files, and — when configured — researches the web before -synthesizing an answer, all while still calling KiteBot's forwarded generative-UI tools the same -way the TS runtime does. +```text +@copilotkit/channels 0.2.2-canary.rc-1 +@copilotkit/runtime 1.63.3-canary.rc-1 +``` -**Setup** — requires [`uv`](https://docs.astral.sh/uv/) and Python 3.12: +## Configure the Python agent ```bash -cd agent && uv sync +cp agent/.env.example agent/.env ``` -Copy `agent/.env.example` to `agent/.env` and fill it in: +| Variable | Required | Purpose | +| --- | --- | --- | +| `OPENAI_API_KEY` | Yes | Model access | +| `OPENAI_MODEL` | No | Defaults to `gpt-5.5` | +| `TAVILY_API_KEY` | No | Enables live web research | +| `LINEAR_API_KEY` | No | Enables the hosted Linear MCP | +| `LINEAR_MCP_URL` | No | Overrides the hosted Linear MCP URL | +| `NOTION_MCP_AUTH_TOKEN` | No | Authenticates to a local Notion MCP sidecar | +| `NOTION_MCP_URL` | No | Defaults to `http://127.0.0.1:3001/mcp` | +| `SERVER_HOST` | No | Local bind host; defaults to `0.0.0.0` | +| `SERVER_PORT` / `PORT` | No | Local port; defaults to `8123` | -| Variable | What it's for | -| --- | --- | -| `OPENAI_API_KEY` | **Required** — the model. | -| `TAVILY_API_KEY` | **Optional** — turns on live web research. Without it the agent still chats and generates UI components, answering from its own knowledge. | -| `OPENAI_MODEL` | Defaults to `gpt-5.5`, matching the rest of OpenTag. | -| `SERVER_HOST` / `SERVER_PORT` | Defaults to `0.0.0.0:8123`. | +Only `OPENAI_API_KEY` is required. Without Tavily or internal-source +credentials, the agent still chats, plans, writes virtual files, and renders +supported UI components. -**Run it:** +Run it: ```bash -pnpm agent # cd agent && uv run python main.py (port from SERVER_PORT/PORT env, default 8123) +pnpm agent ``` -Then point the bot at it instead of `runtime.ts` by setting in the root `.env`: +The AG-UI endpoint is `http://localhost:8123/`; `/health` reports the +`opentag-research-agent` service. + +## Configure Intelligence + +In [CopilotKit Intelligence](https://intelligence.copilotkit.ai): + +1. Create or select the OpenTag project. +2. Create one Channel. Use `opentag` for a normal installation or `kite` for + the production migration. +3. Issue a runtime API key. +4. Attach managed Slack, Microsoft Teams, or both to that Channel. + +Then configure the root environment: ```bash -AGENT_URL=http://localhost:8123/ +cp .env.example .env ``` -With the deep agent in the mix, a local setup is three processes: `pnpm agent` (the Python -deep-research brain, `:8123`), the bot (`pnpm channel` or `pnpm dev`), and — if you're using -`runtime.ts` instead — `pnpm runtime` (`:8200`). `agent` and `runtime` are two alternative brains -for the same bot; run whichever one `AGENT_URL` points at. +| Variable | Required | Purpose | +| --- | --- | --- | +| `AGENT_URL` | Yes | Python AG-UI endpoint, locally `http://localhost:8123/` | +| `INTELLIGENCE_API_KEY` | Yes | Runtime authentication | +| `INTELLIGENCE_API_URL` | No | Defaults to `https://api.intelligence.copilotkit.ai` | +| `INTELLIGENCE_GATEWAY_WS_URL` | No | Defaults to `wss://realtime.intelligence.copilotkit.ai` | +| `INTELLIGENCE_CHANNEL_NAME` | No | Defaults to `opentag`; Railway sets `kite` | +| `AGENT_AUTH_HEADER` | No | Authorization header forwarded to the agent | +| `PORT` | No | Channel HTTP port; defaults to `3000` | -## Intelligence channel mode +Legacy organization, project, Channel ID, and runtime-instance ID variables are +not used. -`pnpm channel` (`app/managed.ts`) runs the same KiteBot over the **CopilotKit Intelligence -Realtime Gateway** instead of a native platform adapter — this process holds **no Slack tokens**; -Intelligence owns the Slack edge (signed ingress + Connector Outbox egress) and streams render -frames back over `@copilotkit/channels-intelligence`. It's the Intelligence Gateway counterpart to -the self-hosted `pnpm dev` mode described above — you still run this process yourself and bring -your own CopilotKit Intelligence project. +Start the Channel: ```bash -npm run runtime # terminal 1 — the agent backend on :8200 (same as self-hosted) -npm run channel # terminal 2 — the Intelligence Gateway KiteBot (tsx app/managed.ts) +pnpm channel ``` -Configure it with: +Use `pnpm dev` for watch mode. `pnpm start` and `pnpm channel` both run the +same canonical entrypoint. Startup waits for `listener.channels.ready()` before +opening HTTP. SIGINT and SIGTERM stop Channels, HTTP, and the rendering browser +once, even if shutdown is requested more than once. -| Variable | What it's for | -| --- | --- | -| `INTELLIGENCE_GATEWAY_WS_URL` | The Intelligence Realtime Gateway websocket endpoint. | -| `INTELLIGENCE_API_KEY` | Auth for the gateway connection. | -| `INTELLIGENCE_ORG_ID` / `INTELLIGENCE_PROJECT_ID` / `INTELLIGENCE_CHANNEL_ID` | Scopes the connection to your Intelligence org/project/channel. | -| `INTELLIGENCE_CHANNEL_NAME` | The registered channel name (lowercase kebab). Defaults to `kitebot`. | +## Slack -The agent backend is still required in this mode — `pnpm runtime` (`runtime.ts`) — the Intelligence -channel host points its `AGENT_URL` at it exactly like the self-hosted KiteBot does. `AGENT_URL` -itself is required in every mode (the process exits at startup if it's unset); `.env.example` ships -it pre-filled with the local runtime URL as a template, not as a code-level default. See -[`.env.example`](./.env.example) for the full annotated list. +### Existing production `@kite` -## 1. Create a Slack app +Do not create, reinstall, or replace the current production Slack app. The +existing app owns the bot user, workspace installation, and `@kite` handle. -1. Go to <https://api.slack.com/apps?new_app=1> → **From a manifest** → paste - [`slack-app-manifest.yaml`](./slack-app-manifest.yaml). The manifest declares all four slash - commands, the assistant pane, the `users:read.email` scope, and **Socket Mode** (so KiteBot - connects outbound — no public URL needed). -2. **OAuth & Permissions** → **Install to Workspace** → copy the `xoxb-` **Bot User OAuth - Token** → this is your `SLACK_BOT_TOKEN`. -3. **Basic Information → App-Level Tokens** → generate one with the `connections:write` scope → - copy the `xapp-` token → this is your `SLACK_APP_TOKEN`. +For cutover: -(Discord, Telegram, and WhatsApp setup is documented inline in [`.env.example`](./.env.example) -and summarized under [Other platforms](#other-platforms).) +1. Stop the old Kite Socket Mode runtime so there is only one consumer. +2. Enter the existing `xapp` and `xoxb` tokens directly into the Slack + attachment in Intelligence. Do not put them in source control or chat. +3. Start the OpenTag `channel` service with + `INTELLIGENCE_CHANNEL_NAME=kite`. +4. Send one `@kite` mention and verify the reply uses the OpenTag persona and + Python deep agent. -## 2. Environment variables +If the cutover fails, stop the new Channel before restoring the prior Railway +deployment. -Copy the template and fill in the platform(s) and integrations you want — KiteBot starts an -adapter for each platform whose secrets are present, and the agent wires up whichever data -sources have credentials. (Running in [Intelligence channel mode](#intelligence-channel-mode) -instead uses the `INTELLIGENCE_*` variables in place of the platform tokens below.) +### New Slack installations -```bash -cp .env.example .env +The JSON and YAML Slack manifests in this repository describe OpenTag for +future installations. Create a new Slack app from either manifest, install it +to the workspace, and attach its app-level and bot tokens in Intelligence. + +Those manifests are not a production migration step for `@kite`. + +## Microsoft Teams + +Managed Microsoft Teams is supported. Configure the Teams attachment on the +same Channel in Intelligence; no second runtime or provider-routing +configuration is required. + +For direct local-adapter testing only, set: + +```dotenv +TEAMS_CLIENT_ID=... +TEAMS_CLIENT_SECRET=... +# TEAMS_TENANT_ID=... +# TEAMS_PORT=3978 ``` -| Variable | What it's for | -| --- | --- | -| `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` | Run on Slack (see [step 1](#1-create-a-slack-app)). | -| `OPENAI_API_KEY` | The model. Required — the runtime is OpenAI-only (it runs on the OpenAI Responses API, needed for `web_search`); `ANTHROPIC_API_KEY` / `GOOGLE_API_KEY` are not read by this runtime. | -| `AGENT_MODEL` | OpenAI model id override, optionally prefixed `openai/` (stripped). Defaults to `openai/gpt-5.5`. | -| `LINEAR_API_KEY` / `LINEAR_TEAM_KEY` | Wire up Linear (linear.app → Settings → API → Personal API keys). | -| `NOTION_TOKEN` / `NOTION_MCP_AUTH_TOKEN` | Wire up Notion (see [Notion](#notion)). | -| `DISCORD_BOT_TOKEN` / `DISCORD_APP_ID` | Run on Discord. | -| `TELEGRAM_BOT_TOKEN` | Run on Telegram. | -| `WHATSAPP_ACCESS_TOKEN` (+ siblings) | Run on WhatsApp Cloud API. | -| `INTELLIGENCE_GATEWAY_WS_URL` / `INTELLIGENCE_API_KEY` / `INTELLIGENCE_ORG_ID` / `INTELLIGENCE_PROJECT_ID` / `INTELLIGENCE_CHANNEL_ID` / `INTELLIGENCE_CHANNEL_NAME` | Run in [Intelligence channel mode](#intelligence-channel-mode) instead of holding platform tokens directly. | -| `AGENT_URL` | Where KiteBot POSTs. **Required** — the process exits at startup if unset; the template value points at the local runtime (`…/agent/triage/run`). | +`TEAMS_CLIENT_ID` and `TEAMS_CLIENT_SECRET` must be set together. -Every integration is independent — set only what you need. The full annotated list, including the -WhatsApp webhook details, is in [`.env.example`](./.env.example). +## Optional direct Slack adapter -## 3. Integrations +For direct local Socket Mode testing, add both variables to the root `.env`: -### Linear +```dotenv +SLACK_BOT_TOKEN=xoxb-... +SLACK_APP_TOKEN=xapp-... +``` + +Both are required as a pair. The direct adapter runs inside the same +Intelligence-owned Channel process. Never run it concurrently with another +consumer of the same production app token. -The hosted Linear MCP accepts a raw API key as a bearer token (no OAuth dance). Create one at -**linear.app → Settings → API → Personal API keys**, set `LINEAR_API_KEY`, and optionally -`LINEAR_TEAM_KEY` (the default team to file/query against). Leave `LINEAR_API_KEY` blank to run -without Linear. With it set, the agent can: +## Tools, commands, and UI -- **Query Linear** — _"what's open in CPK this cycle?"_ → renders the issues as a rich card. -- **File a Linear issue** — _"file this thread as a bug"_ → drafts it, asks you to **confirm**, then creates it. +OpenTag registers: -### Notion +- `/agent <text>` to run a mention-free prompt. +- `/triage [note]` to summarize and propose Linear issues. +- `/preview <title>` to preview an issue privately where supported. +- `/file-issue` to open a form where supported, with a conversational fallback. -Notion runs as a small **Streamable-HTTP sidecar** wrapping the official -[`@notionhq/notion-mcp-server`](https://www.npmjs.com/package/@notionhq/notion-mcp-server). Start -it with `pnpm notion-mcp` (or `npm run notion-mcp`). +The Channel also forwards sender context, Slack-specific tools on Slack turns, +file content, and rich issue/page/table/chart/diagram/status/incident/link +components. -- `NOTION_TOKEN` — the Notion integration secret the sidecar uses to call the Notion API - (notion.so → Settings → Connections → develop integrations). -- `NOTION_MCP_AUTH_TOKEN` — a bearer the sidecar requires on its HTTP transport; pick any strong - string and set the same value here and when starting the sidecar. Leave it blank to run without - Notion. +Before a Linear or Notion create or update, the Python agent calls +`confirm_write`. LangGraph emits an interrupt, the Channel posts an approval +card, and the button resumes the graph with the user's decision. Reads and UI +rendering are never gated. -With it set, the agent can **find pages** (_"find the runbook for the auth outage"_) and -**write a postmortem** (_"write this thread up as a Notion doc"_ → reads, summarizes, -**confirms**, then creates the page). +## Optional sources -### The human-in-the-loop write gate +### Tavily -Every write — Linear or Notion — goes through a blocking **`confirm_write`** gate: the agent must -call that tool and wait for a **Create / Cancel** click before it performs the write. See -[`app/human-in-the-loop/confirm-write.tsx`](./app/human-in-the-loop/confirm-write.tsx). +Set `TAVILY_API_KEY` in `agent/.env` to enable live web research. The `research` +tool is not registered when the key is absent. -### Charts, diagrams & tables +### Linear + +Set `LINEAR_API_KEY` in `agent/.env`. OpenTag connects to the hosted Linear MCP +by default. Railway preserves this optional secret on the `agent` service. -The chart/diagram libraries load from a CDN into a **local** headless browser (override -`CHART_JS_URL` / `MERMAID_URL`) — your data is rendered locally and never sent to a rendering -service. Requires a Chromium binary: `npx playwright install chromium`. +### Notion for local development -## Other platforms +The Railway launch has no Notion sidecar. For local development: -The same `app/` code runs on every platform — `createBot` takes an array of adapters, and -`app/index.ts` starts one for each platform whose secrets are present. Everything else (tools, -components, the HITL gate, rendering) is shared verbatim. +1. Put `NOTION_TOKEN` and a strong `NOTION_MCP_AUTH_TOKEN` in the root `.env`. +2. Put the same `NOTION_MCP_AUTH_TOKEN` in `agent/.env`. +3. Run `pnpm notion-mcp`. +4. Restart `pnpm agent` so it discovers the Notion tools. -- **Discord** — set `DISCORD_BOT_TOKEN` + `DISCORD_APP_ID` (and optionally `DISCORD_GUILD_ID` for - instant slash-command registration in dev). Enable the **Message Content** and **Server - Members** privileged intents. -- **Telegram** — message [@BotFather](https://t.me/BotFather) → `/newbot` → set `TELEGRAM_BOT_TOKEN`. - Long-polling is the default ingress (no public URL needed). -- **WhatsApp** — set `WHATSAPP_ACCESS_TOKEN` + siblings from your Meta App → WhatsApp → API Setup. - The server listens on `$PORT` for the webhook. +Notion is optional and is not a deployment blocker. -Per-platform details are documented inline in [`.env.example`](./.env.example). +## Railway -## Slash commands +The IaC file declares exactly: -Four app-owned commands, registered via `createBot({ commands })` -([`app/commands/index.ts`](./app/commands/index.ts)): +- `agent`: `CopilotKit/OpenTag`, branch `main`, root `agent`, Nixpacks, + `/health`, port `8123`. +- `channel`: `CopilotKit/OpenTag`, branch `main`, repository root, + `pnpm channel`, port `3000`. -- **`/agent <text>`** — a mention-free entry point; runs the agent with the command text. -- **`/triage [note]`** — summarizes the conversation and proposes issues to file. -- **`/preview <title>`** — privately previews the issue KiteBot would file (only you see it); - degrades to a DM where ephemerals aren't supported. -- **`/file-issue`** — opens a structured issue **modal**; degrades to a conversational flow on - platforms without modals (e.g. Telegram). +`channel.AGENT_URL` references the agent's Railway private domain and port. +Production Intelligence URLs are literal configuration, the API key is +preserved, and the Channel name is `kite`. `OPENAI_API_KEY` is required on +`agent`; Tavily and Linear are optional preserved secrets. -On Slack, all four must be declared under **Slash Commands** — the manifest already does this. +Evaluate the configuration locally without applying it: -## Files → charts, diagrams & tables +```bash +node node_modules/railway/dist/iac/bin.js +``` -Upload a file and KiteBot analyzes it: images and **PDFs** go straight to the model; CSV/JSON/text -are decoded and handed over as text. Then ask it to visualize: +The live Railway migration still requires authenticated access: inventory the +existing project, reuse its current Kite service as `channel`, add `agent`, +connect both sources to OpenTag `main`, and enable GitHub autodeploys. -> chart revenue by month · diagram this incident flow · show it as a table +## Coming soon -> **PDFs and images need a vision/document-capable model.** The runtime is OpenAI-only, and the -> default `openai/gpt-5.5` reads both natively. If you override `AGENT_MODEL`, pick another -> vision/document-capable OpenAI model — non-OpenAI model ids (Claude, Gemini, etc.) are not -> supported by this runtime. +Discord, Telegram, and WhatsApp are intentionally not configured for this +launch. Their adapters and setup instructions will be added after launch +support is ready. ## Tests ```bash -npm test # unit: read_thread, render tools, components, confirm_write, modals, commands -npm run check-types # tsc --noEmit +pnpm install --frozen-lockfile +pnpm check-types +pnpm test +cd agent && uv run pytest +``` + +The Slack API live harness is separate from unit tests: + +```bash +pnpm e2e ``` -The live-Slack e2e harness (`npm run e2e`) is being migrated to the new `createBot` API and -doesn't run against this code as-is. The Telegram harness (`npm run e2e:telegram`) is a working -manual-trigger smoke test — see [`e2e/TELEGRAM-README.md`](./e2e/TELEGRAM-README.md). +See [`e2e/README.md`](./e2e/README.md) for its required workspace credentials. +There is no launch-blocking Teams E2E harness; production acceptance is the +single `@kite` round trip described above. diff --git a/slack-app-manifest.json b/slack-app-manifest.json index eadac88..bebd608 100644 --- a/slack-app-manifest.json +++ b/slack-app-manifest.json @@ -1,7 +1,7 @@ { "display_information": { - "name": "KiteBot", - "description": "On-call triage assistant \u2014 query/file Linear issues and find/write Notion pages from Slack.", + "name": "OpenTag", + "description": "OpenTag research and triage assistant for Slack.", "background_color": "#1a1a1a" }, "features": { @@ -11,11 +11,11 @@ "messages_tab_read_only_enabled": false }, "bot_user": { - "display_name": "KiteBot", + "display_name": "OpenTag", "always_online": true }, "assistant_view": { - "assistant_description": "On-call triage assistant — query/file Linear issues and find/write Notion pages.", + "assistant_description": "OpenTag research and triage assistant.", "suggested_prompts": [ { "title": "Triage my open issues", diff --git a/slack-app-manifest.yaml b/slack-app-manifest.yaml index 97e216a..c046359 100644 --- a/slack-app-manifest.yaml +++ b/slack-app-manifest.yaml @@ -1,10 +1,10 @@ display_information: - name: KiteBot - description: On-call triage assistant — query/file Linear issues and find/write Notion pages from Slack. + name: OpenTag + description: OpenTag research and triage assistant for Slack. background_color: "#1a1a1a" features: bot_user: - display_name: KiteBot + display_name: OpenTag always_online: true # App Home: enable the Messages tab so users can DM the bot (and so the tab # isn't read-only — otherwise Slack shows "Sending messages to this app has @@ -14,9 +14,9 @@ features: messages_tab_enabled: true messages_tab_read_only_enabled: false # Enables the assistant pane ("Agents & AI Apps"). The greeting + chips here - # mirror the `assistant` option in app/index.ts. + # mirror the `assistant` option in app/platforms.ts. assistant_view: - assistant_description: On-call triage assistant — query/file Linear issues and find/write Notion pages. + assistant_description: OpenTag research and triage assistant. suggested_prompts: - title: Triage my open issues message: Triage my open issues diff --git a/tsconfig.json b/tsconfig.json index d26261d..90cd3aa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,7 +19,6 @@ "include": [ "app/**/*.ts", "app/**/*.tsx", - "runtime.ts", "server.ts", "scripts/**/*.ts" ], From 925ceed44b5d63a0aa8861f0eb8f941b1e0dc382 Mon Sep 17 00:00:00 2001 From: Tyler Slaton <tyler@copilotkit.ai> Date: Mon, 27 Jul 2026 15:47:35 -0400 Subject: [PATCH 04/19] Fix launch blockers from review --- .railway/railway.ts | 10 ++ agent/tests/test_tools.py | 34 ++++-- app/channel.test.ts | 52 ++++++--- app/channel.tsx | 12 +- app/e2e-cases.test.ts | 71 ++++++++++++ app/interrupt.test.ts | 78 +++++++++++++ app/interrupt.ts | 19 ++++ app/modals/__tests__/file-issue.test.tsx | 8 ++ app/modals/file-issue.tsx | 2 +- app/railway.test.ts | 109 ++++++++++++++++++ e2e/cases.ts | 138 +++++++++++++---------- 11 files changed, 435 insertions(+), 98 deletions(-) create mode 100644 app/e2e-cases.test.ts create mode 100644 app/interrupt.test.ts create mode 100644 app/interrupt.ts create mode 100644 app/railway.test.ts diff --git a/.railway/railway.ts b/.railway/railway.ts index 2340fee..5899088 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -28,6 +28,13 @@ export default defineRailway(() => { const channel = service("channel", { source: github(REPO, { branch: BRANCH }), start: "pnpm channel", + // render_chart and render_diagram launch Playwright's Chromium binary. + // Keep the browser in the deploy artifact and install its shared libraries + // in the runtime image; Railpack otherwise drops build-layer apt packages. + build: { + builder: "RAILPACK", + buildCommand: "pnpm exec playwright install chromium", + }, deploy: { restartPolicyType: "ON_FAILURE", restartPolicyMaxRetries: 5, @@ -41,6 +48,9 @@ export default defineRailway(() => { INTELLIGENCE_GATEWAY_WS_URL: "wss://realtime.intelligence.copilotkit.ai", INTELLIGENCE_CHANNEL_NAME: "kite", + PLAYWRIGHT_BROWSERS_PATH: "0", + RAILPACK_DEPLOY_APT_PACKAGES: + "fonts-liberation fonts-noto-color-emoji fonts-unifont libasound2 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 libcairo2 libcups2 libdbus-1-3 libdrm2 libexpat1 libfontconfig1 libfreetype6 libgbm1 libglib2.0-0 libnspr4 libnss3 libpango-1.0-0 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 libxrender1 libxshmfence1", }, }); diff --git a/agent/tests/test_tools.py b/agent/tests/test_tools.py index c739958..a51a8d9 100644 --- a/agent/tests/test_tools.py +++ b/agent/tests/test_tools.py @@ -1,4 +1,5 @@ import tools +import copilotkit.langgraph def test_do_internet_search_formats_results(monkeypatch): @@ -59,14 +60,13 @@ async def get_tools(self): def test_confirm_write_interrupts_with_structured_action(monkeypatch): calls = [] - expected_answer = '{"confirmed": true}' expected_response = {"confirmed": True} - def fake_interrupt(*, action, args): - calls.append({"action": action, "args": args}) - return expected_answer, expected_response + def fake_interrupt(value): + calls.append(value) + return expected_response - monkeypatch.setattr(tools, "copilotkit_interrupt", fake_interrupt, raising=False) + monkeypatch.setattr(copilotkit.langgraph, "interrupt", fake_interrupt) confirm_write = getattr(tools, "confirm_write", None) assert confirm_write is not None @@ -77,13 +77,29 @@ def fake_interrupt(*, action, args): } ) - assert calls == [ + assert len(calls) == 1 + assert set(calls[0]) == { + "__copilotkit_interrupt_value__", + "__copilotkit_messages__", + } + assert calls[0]["__copilotkit_interrupt_value__"] == { + "action": "confirm_write", + "args": { + "action": "Create Linear issue", + "detail": "CPK-9: Checkout 500s", + }, + } + assert len(calls[0]["__copilotkit_messages__"]) == 1 + interrupt_message = calls[0]["__copilotkit_messages__"][0] + assert interrupt_message.tool_calls == [ { - "action": "confirm_write", + "name": "confirm_write", "args": { "action": "Create Linear issue", "detail": "CPK-9: Checkout 500s", }, - } + "id": interrupt_message.tool_calls[0]["id"], + "type": "tool_call", + }, ] - assert result == expected_answer + assert result == '{"confirmed": true}' diff --git a/app/channel.test.ts b/app/channel.test.ts index 4e9c88d..18e75c9 100644 --- a/app/channel.test.ts +++ b/app/channel.test.ts @@ -35,6 +35,32 @@ class CapturingAgent extends FakeAgent { const channels: Channel[] = []; +function confirmWriteEnvelope( + action = "Create Linear issue", + detail: string | null = "CPK-9: Checkout 500s", +) { + return { + __copilotkit_interrupt_value__: { + action: "confirm_write", + args: { action, detail }, + }, + __copilotkit_messages__: [ + { + content: "", + type: "ai", + tool_calls: [ + { + id: "tool-confirm-write", + name: "confirm_write", + args: { action, detail }, + type: "tool_call", + }, + ], + }, + ], + }; +} + function findButton( nodes: ChannelNode[], confirmed: boolean, @@ -231,20 +257,14 @@ describe("createOpenTagChannel", () => { expect(JSON.stringify(adapter.posted)).toMatch(/sorry.*error/i); }); - it("posts a valid confirm_write interrupt card and returns immediately", async () => { + it("posts a real JSON-stringified confirm_write interrupt card and returns immediately", async () => { const agent = new FakeAgent([ (subscriber) => { subscriber.onCustomEvent?.({ event: { type: EventType.CUSTOM, name: "on_interrupt", - value: { - action: "confirm_write", - args: { - action: "Create Linear issue", - detail: "CPK-9: Checkout 500s", - }, - }, + value: JSON.stringify(confirmWriteEnvelope()), }, } as never); }, @@ -287,10 +307,13 @@ describe("createOpenTagChannel", () => { event: { type: EventType.CUSTOM, name: "on_interrupt", - value: { - action: "unexpected_action", - args: { action: "Injected write" }, - }, + value: JSON.stringify({ + ...confirmWriteEnvelope("Injected write"), + __copilotkit_interrupt_value__: { + action: "unexpected_action", + args: { action: "Injected write" }, + }, + }), }, } as never); }, @@ -324,10 +347,7 @@ describe("createOpenTagChannel", () => { event: { type: EventType.CUSTOM, name: "on_interrupt", - value: { - action: "confirm_write", - args: { action: "Create Linear issue", detail: "CPK-9" }, - }, + value: confirmWriteEnvelope("Create Linear issue", "CPK-9"), }, } as never); }, diff --git a/app/channel.tsx b/app/channel.tsx index b524ca0..f3fbf14 100644 --- a/app/channel.tsx +++ b/app/channel.tsx @@ -1,5 +1,4 @@ import type { AbstractAgent } from "@ag-ui/client"; -import { z } from "zod"; import { createChannel, type Channel, @@ -10,17 +9,10 @@ import { appCommands } from "./commands/index.js"; import { IssueCard, IssueList, PageList } from "./components/index.js"; import { appContext } from "./context/app-context.js"; import { ConfirmWrite } from "./human-in-the-loop/index.js"; +import { parseConfirmWriteInterrupt } from "./interrupt.js"; import { FILE_ISSUE_CALLBACK, fileIssueSubmit } from "./modals/file-issue.js"; import { appTools } from "./tools/index.js"; -const confirmWriteInterruptSchema = z.object({ - action: z.literal("confirm_write"), - args: z.object({ - action: z.string().min(1), - detail: z.string().nullish(), - }), -}); - export interface CreateOpenTagChannelOptions { name: string; adapters: PlatformAdapter[]; @@ -61,7 +53,7 @@ export function createOpenTagChannel( channel.onModalSubmit(FILE_ISSUE_CALLBACK, fileIssueSubmit); channel.onInterrupt("on_interrupt", async ({ payload, thread }) => { - const { args } = confirmWriteInterruptSchema.parse(payload); + const { args } = parseConfirmWriteInterrupt(payload); await thread.post( <ConfirmWrite action={args.action} diff --git a/app/e2e-cases.test.ts b/app/e2e-cases.test.ts new file mode 100644 index 0000000..154c3dc --- /dev/null +++ b/app/e2e-cases.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { CASES } from "../e2e/cases.js"; + +const pendingConfirmWriteMessage = { + attachments: [ + { + color: "#E2B340", + blocks: [ + { + type: "header", + text: { type: "plain_text", text: "📝 Create Linear issue?" }, + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: "🔒 Nothing is written until you click *Create*.", + }, + ], + }, + { + type: "actions", + elements: [ + { + type: "button", + action_id: "ck:create", + text: { type: "plain_text", text: "Create" }, + value: JSON.stringify({ confirmed: true }), + }, + { + type: "button", + action_id: "ck:cancel", + text: { type: "plain_text", text: "Cancel" }, + value: JSON.stringify({ confirmed: false }), + }, + ], + }, + ], + }, + ], +}; + +function caseWithPrefix(prefix: string) { + const found = CASES.find(({ name }) => name.startsWith(prefix)); + if (!found) throw new Error(`missing E2E case ${prefix}`); + return found; +} + +describe("confirm_write E2E cases", () => { + it("recognizes the actual initial Create/Cancel Block Kit card", () => { + const hitl = caseWithPrefix("E-hitl-1"); + expect( + hitl.expectations?.perReplyChecks?.( + ["📝 Create Linear issue?"], + [pendingConfirmWriteMessage], + ), + ).toEqual([]); + }); + + it("describes and validates serialized action IDs and resume values without claiming a restart", () => { + const durable = caseWithPrefix("E-durable-1"); + expect(durable.name).not.toMatch(/restart|surviv/i); + expect( + durable.expectations?.perReplyChecks?.( + ["📝 Create Linear issue?"], + [pendingConfirmWriteMessage], + ), + ).toEqual([]); + }); +}); diff --git a/app/interrupt.test.ts b/app/interrupt.test.ts new file mode 100644 index 0000000..2b7ade9 --- /dev/null +++ b/app/interrupt.test.ts @@ -0,0 +1,78 @@ +import { EventType } from "@ag-ui/client"; +import { createRunRenderer } from "@copilotkit/channels/slack/render"; +import { describe, expect, it, vi } from "vitest"; +import { parseConfirmWriteInterrupt } from "./interrupt.js"; + +const realEnvelope = { + __copilotkit_interrupt_value__: { + action: "confirm_write", + args: { + action: "Create Linear issue", + detail: "CPK-9: Checkout 500s", + }, + }, + __copilotkit_messages__: [ + { + content: "", + type: "ai", + tool_calls: [ + { + id: "tool-confirm-write", + name: "confirm_write", + args: { + action: "Create Linear issue", + detail: "CPK-9: Checkout 500s", + }, + type: "tool_call", + }, + ], + }, + ], +}; + +describe("parseConfirmWriteInterrupt", () => { + it("parses ag_ui_langgraph's JSON-stringified interrupt envelope", () => { + expect(parseConfirmWriteInterrupt(JSON.stringify(realEnvelope))).toEqual( + realEnvelope.__copilotkit_interrupt_value__, + ); + }); + + it("parses the object produced by the canary Slack renderer", () => { + const renderer = createRunRenderer({ + transport: { + setStatus: vi.fn(async () => undefined), + postMessage: vi.fn(async () => ({ ts: "1.0" })), + updateMessage: vi.fn(async () => undefined), + }, + target: { channel: "C1", threadTs: "1.0" }, + }); + renderer.subscriber.onCustomEvent?.({ + event: { + type: EventType.CUSTOM, + name: "on_interrupt", + value: JSON.stringify(realEnvelope), + }, + } as never); + + const renderedPayload = renderer.getPendingInterrupt()?.value; + expect(renderedPayload).toEqual(realEnvelope); + expect(parseConfirmWriteInterrupt(renderedPayload)).toEqual( + realEnvelope.__copilotkit_interrupt_value__, + ); + }); + + it("rejects malformed envelopes and other actions", () => { + expect(() => + parseConfirmWriteInterrupt( + JSON.stringify({ + ...realEnvelope, + __copilotkit_interrupt_value__: { + action: "delete_without_confirmation", + args: { action: "Delete everything" }, + }, + }), + ), + ).toThrow(/confirm_write/); + expect(() => parseConfirmWriteInterrupt("{broken")).toThrow(); + }); +}); diff --git a/app/interrupt.ts b/app/interrupt.ts new file mode 100644 index 0000000..25f18f0 --- /dev/null +++ b/app/interrupt.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +const confirmWriteInterruptSchema = z.object({ + __copilotkit_interrupt_value__: z.object({ + action: z.literal("confirm_write"), + args: z.object({ + action: z.string().min(1), + detail: z.string().nullish(), + }), + }), + __copilotkit_messages__: z.array(z.unknown()), +}); + +export function parseConfirmWriteInterrupt(payload: unknown) { + const normalized = + typeof payload === "string" ? JSON.parse(payload) : payload; + return confirmWriteInterruptSchema.parse(normalized) + .__copilotkit_interrupt_value__; +} diff --git a/app/modals/__tests__/file-issue.test.tsx b/app/modals/__tests__/file-issue.test.tsx index 3521cfa..e429d58 100644 --- a/app/modals/__tests__/file-issue.test.tsx +++ b/app/modals/__tests__/file-issue.test.tsx @@ -160,6 +160,9 @@ describe("fileIssueSubmit", () => { }); it("posts a failure message to the thread when runAgent rejects", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); const post = vi.fn().mockResolvedValue({ id: "m1" }); const thread = { runAgent: vi.fn(() => Promise.reject(new Error("LLM timeout"))), @@ -175,5 +178,10 @@ describe("fileIssueSubmit", () => { expect(post).toHaveBeenCalledWith( expect.stringMatching(/couldn.t file|try again/i), ); + expect(consoleError).toHaveBeenCalledWith( + "[opentag] file-issue modal run failed", + expect.any(Error), + ); + consoleError.mockRestore(); }); }); diff --git a/app/modals/file-issue.tsx b/app/modals/file-issue.tsx index 8ca2ed6..2ad6a98 100644 --- a/app/modals/file-issue.tsx +++ b/app/modals/file-issue.tsx @@ -70,7 +70,7 @@ export const fileIssueSubmit: ModalSubmitHandler = async ({ ...platformRunInput(privateMetadata ?? thread.platform, user), }) .catch((err) => { - console.error("[bot] file-issue modal run failed", err); + console.error("[opentag] file-issue modal run failed", err); void thread .post("Sorry — I couldn't file that issue. Please try again.") .catch((postErr: unknown) => diff --git a/app/railway.test.ts b/app/railway.test.ts new file mode 100644 index 0000000..d0b66bf --- /dev/null +++ b/app/railway.test.ts @@ -0,0 +1,109 @@ +import { execFileSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +interface RailwayVariable { + type: "literal" | "preserve"; + value?: string; +} + +interface RailwayResource { + name: string; + source?: { + repo?: string; + branch?: string; + rootDirectory?: string; + }; + build?: { + builder?: string; + buildCommand?: string; + }; + deploy?: { + startCommand?: string; + healthcheckPath?: string; + }; + variables?: Record<string, RailwayVariable>; +} + +function evaluateRailwayGraph(): RailwayResource[] { + const stdout = execFileSync( + process.execPath, + ["node_modules/railway/dist/iac/bin.js"], + { + cwd: process.cwd(), + encoding: "utf8", + }, + ); + const result = JSON.parse(stdout) as { + ok: boolean; + diagnostics: unknown[]; + graph: { resources: RailwayResource[] }; + }; + expect(result.ok).toBe(true); + expect(result.diagnostics).toEqual([]); + return result.graph.resources; +} + +describe("Railway deployment graph", () => { + it("ships exactly the Python agent and Chromium-capable channel services", () => { + const resources = evaluateRailwayGraph(); + expect(resources.map(({ name }) => name).sort()).toEqual([ + "agent", + "channel", + ]); + + const agent = resources.find(({ name }) => name === "agent"); + expect(agent).toMatchObject({ + source: { + repo: "CopilotKit/OpenTag", + branch: "main", + rootDirectory: "agent", + }, + deploy: { + healthcheckPath: "/health", + }, + }); + + const channel = resources.find(({ name }) => name === "channel"); + expect(channel).toMatchObject({ + source: { + repo: "CopilotKit/OpenTag", + branch: "main", + }, + build: { + builder: "RAILPACK", + buildCommand: "pnpm exec playwright install chromium", + }, + deploy: { + startCommand: "pnpm channel", + }, + variables: { + AGENT_URL: { + type: "literal", + value: + "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/", + }, + INTELLIGENCE_API_KEY: { type: "preserve" }, + INTELLIGENCE_API_URL: { + type: "literal", + value: "https://api.intelligence.copilotkit.ai", + }, + INTELLIGENCE_GATEWAY_WS_URL: { + type: "literal", + value: "wss://realtime.intelligence.copilotkit.ai", + }, + INTELLIGENCE_CHANNEL_NAME: { + type: "literal", + value: "kite", + }, + PLAYWRIGHT_BROWSERS_PATH: { + type: "literal", + value: "0", + }, + RAILPACK_DEPLOY_APT_PACKAGES: { + type: "literal", + value: expect.stringContaining("libnss3"), + }, + }, + }); + }); +}); diff --git a/e2e/cases.ts b/e2e/cases.ts index 06007ca..9ec8e5a 100644 --- a/e2e/cases.ts +++ b/e2e/cases.ts @@ -86,6 +86,50 @@ export interface E2ECase { }; } +function confirmWriteCard( + raw: Array<Record<string, any>>, +): { + buttons: Array<Record<string, any>>; + errors: string[]; +} { + const blocks = raw.flatMap((message) => [ + ...((message["blocks"] as Array<Record<string, any>> | undefined) ?? []), + ...( + (message["attachments"] as + | Array<{ blocks?: Array<Record<string, any>> }> + | undefined) ?? [] + ).flatMap((attachment) => attachment.blocks ?? []), + ]); + const header = blocks.find((block) => block["type"] === "header"); + const headerText = String( + (header?.["text"] as { text?: string } | undefined)?.text ?? "", + ); + const buttons = blocks + .filter((block) => block["type"] === "actions") + .flatMap( + (block) => + (block["elements"] as Array<Record<string, any>> | undefined) ?? [], + ) + .filter((element) => element["type"] === "button"); + const buttonLabels = buttons.map((button) => + String( + (button["text"] as { text?: string } | undefined)?.text ?? "", + ), + ); + const errors: string[] = []; + + if (!headerText.trim()) { + errors.push("confirm_write card has no Block Kit header"); + } + for (const expected of ["Create", "Cancel"]) { + if (!buttonLabels.includes(expected)) { + errors.push(`confirm_write card has no ${expected} button`); + } + } + + return { buttons, errors }; +} + export const CASES: E2ECase[] = [ // ── A. Trigger surface ────────────────────────────────────────────── { @@ -355,12 +399,11 @@ export const CASES: E2ECase[] = [ }, }, { - name: "E-restart-1 — confirm_write picker has resume values encoded in button.value (survives bridge restart)", - // The agent must call confirm_write before any write. Once the picker - // lands, we read it back via conversations.replies and verify each - // button carries a JSON-encoded resume payload in its `value` field. - // That's what Slack stores and what the Channel decodes on a stale click - // after a restart — the durable-action story. + name: "E-durable-1 — confirm_write buttons serialize action IDs and resume values", + // This live case does not restart the process. It reads the initial picker + // back through conversations.replies and verifies Slack stored durable + // action IDs plus JSON resume values. The actual stop/start/rehydrate/click + // behavior is covered by the Channel restart test in app/channel.test.ts. prompt: '<@U0B45V75NNR> file a Linear issue titled "Checkout 500s under load". ' + "Use the confirm_write tool to ask me to approve it first.", @@ -368,62 +411,41 @@ export const CASES: E2ECase[] = [ maxWaitMs: 20_000, expectations: { perReplyChecks: (_replies, raw) => { - const errs: string[] = []; - const buttons: Array<{ action_id?: string; value?: string }> = []; - for (const m of raw) { - // confirm_write wraps its blocks in a colored attachment, so the - // buttons live under attachments[].blocks; scan both. - const blocks = [ - ...(m.blocks ?? []), - ...( - (m.attachments as Array<{ blocks?: any[] }> | undefined) ?? [] - ).flatMap((a) => a.blocks ?? []), - ]; - for (const b of blocks) { - if (b.type === "actions" && Array.isArray(b.elements)) { - for (const el of b.elements) { - if (el?.type === "button") buttons.push(el); - } - } - } - } - if (buttons.length < 2) { - errs.push( - `expected ≥2 confirm_write buttons (Create/Cancel); got ${buttons.length}`, + const { buttons, errors } = confirmWriteCard(raw); + for (const [label, confirmed] of [ + ["Create", true], + ["Cancel", false], + ] as const) { + const btn = buttons.find( + (button) => + (button["text"] as { text?: string } | undefined)?.text === + label, ); - return errs; - } - let sawConfirmTrue = false; - for (const btn of buttons) { + if (!btn) continue; + if ( + typeof btn["action_id"] !== "string" || + !btn["action_id"].startsWith("ck:") + ) { + errors.push(`${label} button has no durable ck: action_id`); + } if (!btn.value) { - errs.push(`button action_id=${btn.action_id} has no value field`); + errors.push(`${label} button has no value field`); continue; } try { const decoded = JSON.parse(btn.value); - if ( - decoded && - typeof decoded === "object" && - "confirmed" in decoded - ) { - if (decoded.confirmed === true) sawConfirmTrue = true; - } else { - errs.push( - `button action_id=${btn.action_id} decoded to unexpected shape: ${btn.value}`, + if (decoded?.confirmed !== confirmed) { + errors.push( + `${label} button decoded to unexpected resume value: ${btn.value}`, ); } } catch (e) { - errs.push( - `button action_id=${btn.action_id} value isn't valid JSON: ${(e as Error).message}`, + errors.push( + `${label} button value isn't valid JSON: ${(e as Error).message}`, ); } } - if (!sawConfirmTrue) { - errs.push( - "no button encoded { confirmed: true } (the Create button)", - ); - } - return errs; + return errors; }, }, }, @@ -431,24 +453,16 @@ export const CASES: E2ECase[] = [ name: "E-hitl-1 — agent renders the confirm_write HITL Block Kit message", // Verifies the human-in-the-loop gate renders into the thread. We // can't simulate the button click via Slack's API, so this case only - // asserts that the Block Kit message lands; the click→resolve flow - // is covered by the Channels unit tests. The agent's run will be left - // dangling on the HITL wait for up to the component's timeoutMs. + // asserts that the initial Block Kit card lands with Create/Cancel. + // Click→resume and process-restart rehydration are covered by the Channel + // unit tests. prompt: '<@U0B45V75NNR> file a Linear issue titled "Test from e2e". Call the ' + "confirm_write tool to ask me to approve it before creating anything.", sampleIntervalMs: 700, maxWaitMs: 20_000, expectations: { - perReplyChecks: (replies) => { - const errs: string[] = []; - const joined = replies.join("\n").toLowerCase(); - // The HITL fallback for confirm_write is "Approve: <action>". - if (!joined.includes("approve")) { - errs.push("no bot reply contained the confirm_write 'Approve' text"); - } - return errs; - }, + perReplyChecks: (_replies, raw) => confirmWriteCard(raw).errors, }, }, { From 42f778a5997bbaf708b934a0388a7e5e10ab99b8 Mon Sep 17 00:00:00 2001 From: Tyler Slaton <tyler@copilotkit.ai> Date: Mon, 27 Jul 2026 18:07:14 -0400 Subject: [PATCH 05/19] Enforce write approvals and isolate direct channels --- README.md | 17 ++- agent/agent.py | 13 +- agent/main.py | 12 +- agent/tests/test_health.py | 12 +- agent/tests/test_tools.py | 166 +++++++++++++++++++++++- agent/tools.py | 88 ++++++++++++- app/channel.test.ts | 2 +- app/channel.tsx | 4 +- app/human-in-the-loop/confirm-write.tsx | 9 +- app/human-in-the-loop/index.ts | 4 +- app/modals/file-issue.tsx | 5 +- app/runtime-host.test.ts | 21 ++- app/runtime-host.ts | 4 +- app/server.test.ts | 38 ++++++ e2e/cases.ts | 15 ++- server.ts | 28 +++- setup.md | 17 +-- 17 files changed, 384 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 04b2978..16becb8 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,11 @@ agent (Python + LangGraph deepagents) ``` There is one canonical Channel host: [`server.ts`](./server.ts). It creates one -`CopilotKitIntelligence`, one `CopilotRuntime`, and one OpenTag Channel. Platform -attachments are normally configured in Intelligence. Supplying local Slack or -Teams credentials adds a direct adapter to that same runtime; it does not create -a separate lifecycle. +`CopilotKitIntelligence`, one `CopilotRuntime`, and an adapter-free managed +OpenTag Channel. Platform attachments are normally configured in Intelligence. +Supplying local Slack or Teams credentials adds a separately named direct +Channel to that same runtime; it does not create a separate lifecycle or +disable managed delivery. The launch pins the requested canaries: @@ -135,7 +136,8 @@ TEAMS_CLIENT_SECRET=... `TEAMS_TENANT_ID` and `TEAMS_PORT` are optional. Incomplete credential pairs fail at startup instead of silently disabling an adapter. The Intelligence -connection remains the owner of the runtime. +connection remains the owner of the runtime, while each direct adapter stays +isolated from the managed Channel. ### Slack manifests @@ -157,8 +159,9 @@ Slack app**. Reusing it preserves the bot user, workspace installation, and `NOTION_MCP_AUTH_TOKEN` into the root `.env` and `agent/.env`. The Railway launch intentionally has no Notion sidecar. -All Linear and Notion creates or updates go through `confirm_write`; reads and -rendering do not. +Every Linear and Notion mutation is intercepted in code before the MCP request +runs. The interceptor emits `confirm_write` and proceeds only after approval; +reads and rendering do not pause. ## Railway diff --git a/agent/agent.py b/agent/agent.py index c39616a..1195c53 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -13,7 +13,7 @@ from langgraph.checkpoint.memory import MemorySaver from copilotkit import CopilotKitMiddleware -from tools import confirm_write, research, internal_source_tools +from tools import research, internal_source_tools load_dotenv() @@ -33,10 +33,9 @@ frontend offers them, rather than large blocks of raw markdown - For internal or company-specific questions, prefer the team's Notion/Linear (internal sources) first; use the web for external questions -- CRITICAL: Immediately before every Linear or Notion create or update tool - call, call confirm_write with the exact action and draft details -- Proceed with that write only when the resumed result says confirmed is true; - otherwise acknowledge the decision and stop +- CRITICAL: Every Linear or Notion mutation tool automatically pauses with its + exact action and draft details. Call the mutation once; it runs only after + the user grants approval, and otherwise no write occurs - Reads and rendering never require confirmation Your workflow: @@ -112,9 +111,9 @@ def build_agent(): # its text doesn't stream to the frontend. internal_tools = internal_source_tools() main_tools = ( - [confirm_write, research, *internal_tools] + [research, *internal_tools] if has_research - else [confirm_write, *internal_tools] + else [*internal_tools] ) system_prompt = BASE_SYSTEM_PROMPT + ( diff --git a/agent/main.py b/agent/main.py index 23202f5..441bf8f 100644 --- a/agent/main.py +++ b/agent/main.py @@ -64,12 +64,12 @@ def health(): # Note: we intentionally do NOT pass an emit_tool_calls allowlist here. # OpenTag's channel bridge forwards generative-UI tools (issue_card, # render_chart, render_table, show_links, ...) that must be emitted to the - # frontend for cards to render. The backend-owned confirm_write tool emits - # an interrupt through the same stream. An allowlist could filter these - # events out. The recursion limit for complex research tasks (6+ research - # calls + file operations) is already applied to the graph itself via - # `.with_config({"recursion_limit": 100})` in agent.py, so no additional - # AG-UI config is needed here. + # frontend for cards to render. The backend-owned write interceptor emits + # confirm_write interrupts through the same stream. An allowlist could + # filter these events out. The recursion limit for complex research tasks + # (6+ research calls + file operations) is already applied to the graph + # itself via `.with_config({"recursion_limit": 100})` in agent.py, so no + # additional AG-UI config is needed here. # Add AG-UI endpoint at root path for CopilotKit frontend add_langgraph_fastapi_endpoint( diff --git a/agent/tests/test_health.py b/agent/tests/test_health.py index 371c0ee..226a6c6 100644 --- a/agent/tests/test_health.py +++ b/agent/tests/test_health.py @@ -86,7 +86,9 @@ def test_build_agent_requires_openai(monkeypatch): agent_mod.build_agent() -def test_build_agent_registers_confirm_write(monkeypatch): +def test_build_agent_does_not_expose_a_bypassable_manual_confirmation_tool( + monkeypatch, +): captured = {} class FakeGraph: @@ -107,16 +109,16 @@ def fake_create_deep_agent(**kwargs): agent_mod.build_agent() - assert [tool.name for tool in captured["tools"]] == ["confirm_write"] + assert captured["tools"] == [] def test_system_prompt_requires_confirmation_only_for_writes(): prompt = agent_mod.BASE_SYSTEM_PROMPT assert "CRITICAL:" in prompt - assert "confirm_write" in prompt - assert "Linear or Notion create or update" in prompt - assert "only when" in prompt and "confirmed" in prompt + assert "automatically pauses" in prompt + assert "Linear or Notion mutation" in prompt + assert "only after" in prompt and "approval" in prompt assert "Reads and rendering never require confirmation" in prompt diff --git a/agent/tests/test_tools.py b/agent/tests/test_tools.py index a51a8d9..1030a60 100644 --- a/agent/tests/test_tools.py +++ b/agent/tests/test_tools.py @@ -1,5 +1,10 @@ +import asyncio + import tools import copilotkit.langgraph +import pytest +from langchain_core.tools import StructuredTool +from langchain_mcp_adapters.interceptors import MCPToolCallRequest def test_do_internet_search_formats_results(monkeypatch): @@ -39,14 +44,26 @@ def test_internal_source_tools_empty_without_env(monkeypatch): def test_internal_source_tools_loads_when_env_set(monkeypatch): + clients = [] + class FakeMCPClient: """Stub replacing MultiServerMCPClient - no real network calls.""" - def __init__(self, connections): + def __init__(self, connections, *, tool_interceptors): self.connections = connections + self.tool_interceptors = tool_interceptors + clients.append(self) async def get_tools(self): - return [f"tool-for-{name}" for name in self.connections] + return [ + StructuredTool.from_function( + func=lambda: name, + name=f"tool-for-{name}", + description="test tool", + metadata={"readOnlyHint": True}, + ) + for name in self.connections + ] monkeypatch.setenv("LINEAR_API_KEY", "lin_api_test") monkeypatch.setenv("NOTION_MCP_AUTH_TOKEN", "notion-test-token") @@ -55,7 +72,14 @@ async def get_tools(self): result = tools.internal_source_tools() assert len(result) > 0 - assert set(result) == {"tool-for-linear", "tool-for-notion"} + assert {tool.name for tool in result} == { + "tool-for-linear", + "tool-for-notion", + } + assert all( + isinstance(client.tool_interceptors[0], tools.WriteConfirmationInterceptor) + for client in clients + ) def test_confirm_write_interrupts_with_structured_action(monkeypatch): @@ -103,3 +127,139 @@ def fake_interrupt(value): }, ] assert result == '{"confirmed": true}' + + +def test_write_confirmation_interceptor_leaves_annotated_reads_unguarded( + monkeypatch, +): + interrupt_calls = [] + handler_calls = [] + + async def read_issue(issue_id: str): + return issue_id + + read_tool = StructuredTool.from_function( + coroutine=read_issue, + name="get_issue", + description="Read an issue", + metadata={"readOnlyHint": True}, + ) + interceptor = tools.WriteConfirmationInterceptor() + interceptor.register_tools([read_tool]) + monkeypatch.setattr( + tools, + "copilotkit_interrupt", + lambda **kwargs: interrupt_calls.append(kwargs), + ) + + async def handler(request): + handler_calls.append(request) + return "read-result" + + result = asyncio.run( + interceptor( + MCPToolCallRequest( + name="get_issue", + args={"issue_id": "CPK-9"}, + server_name="linear", + ), + handler, + ) + ) + + assert result == "read-result" + assert len(handler_calls) == 1 + assert interrupt_calls == [] + + +def test_write_confirmation_interceptor_blocks_a_declined_mutation(monkeypatch): + interrupt_calls = [] + handler_calls = [] + interceptor = tools.WriteConfirmationInterceptor() + + def decline(**kwargs): + interrupt_calls.append(kwargs) + return '{"confirmed": false}', {"confirmed": False} + + monkeypatch.setattr(tools, "copilotkit_interrupt", decline) + + async def handler(request): + handler_calls.append(request) + return "write-result" + + result = asyncio.run( + interceptor( + MCPToolCallRequest( + name="create_issue", + args={"title": "Checkout 500s"}, + server_name="linear", + ), + handler, + ) + ) + + assert handler_calls == [] + assert interrupt_calls == [ + { + "action": "confirm_write", + "args": { + "action": "Create issue", + "detail": '{"title": "Checkout 500s"}', + }, + } + ] + assert result.content[0].text == "Write cancelled by the user; no changes were made." + + +def test_write_confirmation_interceptor_runs_an_approved_mutation(monkeypatch): + handler_calls = [] + interceptor = tools.WriteConfirmationInterceptor() + monkeypatch.setattr( + tools, + "copilotkit_interrupt", + lambda **_kwargs: ('{"confirmed": true}', {"confirmed": True}), + ) + + async def handler(request): + handler_calls.append(request) + return "write-result" + + request = MCPToolCallRequest( + name="update_issue", + args={"id": "CPK-9", "title": "Checkout 500s"}, + server_name="linear", + ) + result = asyncio.run(interceptor(request, handler)) + + assert result == "write-result" + assert handler_calls == [request] + + +def test_write_confirmation_interceptor_rejects_a_malformed_resume( + monkeypatch, +): + handler_calls = [] + interceptor = tools.WriteConfirmationInterceptor() + monkeypatch.setattr( + tools, + "copilotkit_interrupt", + lambda **_kwargs: ("unexpected", {"unexpected": True}), + ) + + async def handler(request): + handler_calls.append(request) + return "write-result" + + with pytest.raises(RuntimeError, match="confirmed"): + asyncio.run( + interceptor( + MCPToolCallRequest( + name="create_issue", + args={"title": "Checkout 500s"}, + server_name="linear", + ), + handler, + ) + ) + + assert handler_calls == [] diff --git a/agent/tools.py b/agent/tools.py index b3fd858..6028b8b 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -11,13 +11,19 @@ import os import sys import asyncio +import json from typing import Any from concurrent.futures import ThreadPoolExecutor -from langchain_core.tools import tool +from langchain_core.tools import BaseTool, tool from langchain_core.messages import HumanMessage from tavily import TavilyClient from langchain_mcp_adapters.client import MultiServerMCPClient +from langchain_mcp_adapters.interceptors import ( + MCPToolCallRequest, + MCPToolCallResult, +) from copilotkit.langgraph import copilotkit_interrupt +from mcp.types import CallToolResult, TextContent @tool @@ -38,6 +44,79 @@ def confirm_write(action: str, detail: str | None = None): return answer +class WriteConfirmationInterceptor: + """Enforce confirmation inside every mutating internal-source tool call. + + MCP tool annotations are the authority for read-only calls. Anything not + explicitly marked read-only fails closed and pauses before the remote tool + handler can run. The two Notion search operations below use POST while + remaining read-only, so its OpenAPI bridge cannot infer their annotation. + """ + + _KNOWN_READ_ONLY_TOOLS = { + "API-post-search", + "API-query-data-source", + } + + def __init__(self): + self._read_only_tools = set(self._KNOWN_READ_ONLY_TOOLS) + + def register_tools(self, tools: list[BaseTool]) -> None: + for source_tool in tools: + metadata = source_tool.metadata or {} + if metadata.get("readOnlyHint") is True: + self._read_only_tools.add(source_tool.name) + + async def __call__( + self, + request: MCPToolCallRequest, + handler, + ) -> MCPToolCallResult: + if request.name in self._read_only_tools: + return await handler(request) + + action = request.name.replace("_", " ").replace("-", " ").strip() + action = action[:1].upper() + action[1:] + detail = json.dumps( + request.args, + ensure_ascii=False, + sort_keys=True, + default=str, + ) + _answer, response = copilotkit_interrupt( + action="confirm_write", + args={"action": action, "detail": detail}, + ) + + if isinstance(response, str): + try: + response = json.loads(response) + except json.JSONDecodeError: + response = None + + if ( + not isinstance(response, dict) + or not isinstance(response.get("confirmed"), bool) + ): + raise RuntimeError( + "confirm_write resume must contain a boolean `confirmed` value" + ) + + if response["confirmed"] is False: + return CallToolResult( + content=[ + TextContent( + type="text", + text=( + "Write cancelled by the user; no changes were made." + ), + ) + ] + ) + + return await handler(request) + + def _do_internet_search(query: str, max_results: int = 5) -> list[dict[str, Any]]: """Core search logic - callable as regular function. @@ -234,7 +313,11 @@ async def _load_all() -> list: loaded: list = [] for name, connection in connections.items(): try: - server_client = MultiServerMCPClient({name: connection}) + confirmation = WriteConfirmationInterceptor() + server_client = MultiServerMCPClient( + {name: connection}, + tool_interceptors=[confirmation], + ) # Bound connect time so a hung MCP server (accepts the # socket but never responds) can't stall build_agent() - # and thus the whole app, including /health - at import @@ -242,6 +325,7 @@ async def _load_all() -> list: server_tools = await asyncio.wait_for( server_client.get_tools(), timeout=8.0 ) + confirmation.register_tools(server_tools) loaded.extend(server_tools) print( f"[TOOLS] internal_source_tools: loaded {len(server_tools)} " diff --git a/app/channel.test.ts b/app/channel.test.ts index 18e75c9..3b3c382 100644 --- a/app/channel.test.ts +++ b/app/channel.test.ts @@ -337,7 +337,7 @@ describe("createOpenTagChannel", () => { consoleError.mockRestore(); }); - it("preserves confirm_write button actions across a Channel restart", async () => { + it("re-registers confirm_write actions when a new Channel uses the same store", async () => { const sharedState = new MemoryStore(); const firstAdapter = new FakeAdapter({ platform: "intelligence" }); firstAdapter.stateStore = sharedState; diff --git a/app/channel.tsx b/app/channel.tsx index f3fbf14..0653c0b 100644 --- a/app/channel.tsx +++ b/app/channel.tsx @@ -20,8 +20,8 @@ export interface CreateOpenTagChannelOptions { } /** - * Build the single Channel shared by managed Intelligence delivery and - * developer-owned direct adapters. + * Build an OpenTag Channel with the same handlers and UI for either managed + * Intelligence delivery or one developer-owned direct adapter. */ export function createOpenTagChannel( options: CreateOpenTagChannelOptions, diff --git a/app/human-in-the-loop/confirm-write.tsx b/app/human-in-the-loop/confirm-write.tsx index 86752a6..c193653 100644 --- a/app/human-in-the-loop/confirm-write.tsx +++ b/app/human-in-the-loop/confirm-write.tsx @@ -1,10 +1,9 @@ /** * `confirm_write` — the human-in-the-loop gate in front of every Linear / - * Notion write. The agent is instructed (see the system prompt in - * `agent/agent.py`) to confirm BEFORE creating an issue or a page: a tool handler - * pauses through a LangGraph interrupt. The Channel interrupt handler posts - * this card and returns immediately. A click updates the card, then resumes - * the paused graph with `{ confirmed: true | false }`. + * Notion write. The Python MCP interceptor pauses before invoking a mutating + * tool. The Channel interrupt handler posts this card and returns immediately. + * A click updates the card, then resumes the paused graph with + * `{ confirmed: true | false }`. * * Each button also carries an `onClick` that updates the picker in place to a * resolved / declined state — so the card reflects the decision the moment it's diff --git a/app/human-in-the-loop/index.ts b/app/human-in-the-loop/index.ts index a69a38c..201b6bb 100644 --- a/app/human-in-the-loop/index.ts +++ b/app/human-in-the-loop/index.ts @@ -3,8 +3,8 @@ * Channel interrupt handlers can render to ask the user a structured question * before resuming the paused agent. * - * `confirm_write` is a backend LangGraph tool. Its `on_interrupt` event posts - * `ConfirmWrite`; the card's buttons call `thread.resume(...)`. + * The backend MCP write interceptor emits `confirm_write`. Its `on_interrupt` + * event posts `ConfirmWrite`; the card's buttons call `thread.resume(...)`. */ export { ConfirmWrite } from "./confirm-write.js"; export type { ConfirmWriteProps } from "./confirm-write.js"; diff --git a/app/modals/file-issue.tsx b/app/modals/file-issue.tsx index 2ad6a98..5868124 100644 --- a/app/modals/file-issue.tsx +++ b/app/modals/file-issue.tsx @@ -65,8 +65,9 @@ export const fileIssueSubmit: ModalSubmitHandler = async ({ `Draft this Linear issue from the submitted form:\n` + `- Title: ${issue.title}\n- Type: ${issue.type}\n- Priority: ${issue.priority}\n` + `- Description: ${issue.description || "(none)"}\n` + - `Call confirm_write with these exact details. File it only when the ` + - `resumed result confirms approval, then show the issue card.`, + `Call the Linear create tool with these exact details. Its protected ` + + `MCP handler emits confirm_write and files only after approval. Then ` + + `show the issue card.`, ...platformRunInput(privateMetadata ?? thread.platform, user), }) .catch((err) => { diff --git a/app/runtime-host.test.ts b/app/runtime-host.test.ts index ade2144..3090086 100644 --- a/app/runtime-host.test.ts +++ b/app/runtime-host.test.ts @@ -24,19 +24,25 @@ describe("createOpenTagRuntime", () => { const teamsAdapter = new FakeAdapter({ platform: "teams" }); const stopSlack = vi.spyOn(slackAdapter, "stop"); const stopTeams = vi.spyOn(teamsAdapter, "stop"); - const channel = createOpenTagChannel({ - name: environment.channelName, - adapters: [slackAdapter, teamsAdapter], + const slackChannel = createOpenTagChannel({ + name: "opentag-direct-slack", + adapters: [slackAdapter], agent: new FakeAgent(), }); + const teamsChannel = createOpenTagChannel({ + name: "opentag-direct-teams", + adapters: [teamsAdapter], + agent: new FakeAgent(), + }); + const channels = [slackChannel, teamsChannel]; const { intelligence, listener, runtime } = createOpenTagRuntime({ environment, - channel, + channels, }); expect(runtime.mode).toBe("intelligence"); - expect(runtime.channels).toEqual([channel]); + expect(runtime.channels).toEqual(channels); expect(intelligence.ɵgetApiUrl()).toBe(environment.intelligenceApiUrl); expect(intelligence.ɵgetClientWsUrl()).toContain( "gateway.example.test", @@ -49,7 +55,10 @@ describe("createOpenTagRuntime", () => { await listener.channels?.ready({ timeoutMs: 500 }); expect(listener.channels?.status()).toEqual({ overall: "online", - channels: { opentag: "online" }, + channels: { + "opentag-direct-slack": "online", + "opentag-direct-teams": "online", + }, }); expect(slackAdapter.started).toBe(true); expect(teamsAdapter.started).toBe(true); diff --git a/app/runtime-host.ts b/app/runtime-host.ts index b054699..d3b9ac7 100644 --- a/app/runtime-host.ts +++ b/app/runtime-host.ts @@ -13,7 +13,7 @@ export const OPENTAG_SERVICE_USER = { export function createOpenTagRuntime(options: { environment: AppEnvironment; - channel: Channel; + channels: Channel[]; }) { const intelligence = new CopilotKitIntelligence({ apiUrl: options.environment.intelligenceApiUrl, @@ -25,7 +25,7 @@ export function createOpenTagRuntime(options: { agents: {}, intelligence, identifyUser: () => OPENTAG_SERVICE_USER, - channels: [options.channel], + channels: options.channels, }); const listener = createCopilotNodeListener({ diff --git a/app/server.test.ts b/app/server.test.ts index 3751e4f..3a7fd91 100644 --- a/app/server.test.ts +++ b/app/server.test.ts @@ -3,10 +3,12 @@ import type { RequestListener } from "node:http"; import { describe, expect, it, vi } from "vitest"; import type { ChannelsControl } from "@copilotkit/runtime/v2"; import { + createOpenTagApplication, startOpenTagServer, type HttpServerLike, type RuntimeListener, } from "../server.js"; +import type { AppEnvironment } from "./env.js"; class FakeServer extends EventEmitter implements HttpServerLike { listening = false; @@ -130,3 +132,39 @@ describe("startOpenTagServer", () => { expect(closeBrowser).toHaveBeenCalledOnce(); }); }); + +describe("createOpenTagApplication", () => { + it("keeps the managed Channel adapter-free and isolates optional direct adapters", () => { + const environment: AppEnvironment = { + agentUrl: "http://agent.internal/", + intelligenceApiKey: "cpk-1_test", + intelligenceApiUrl: "https://api.intelligence.test", + intelligenceGatewayWsUrl: "wss://realtime.intelligence.test", + channelName: "kite", + port: 3000, + slackBotToken: "xoxb-test", + slackAppToken: "xapp-test", + teamsClientId: "teams-client", + teamsClientSecret: "teams-secret", + teamsPort: 3978, + }; + + const application = createOpenTagApplication(environment); + + expect(application.channel.name).toBe("kite"); + expect(application.channel.adapters).toEqual([]); + expect( + application.directChannels.map((channel) => ({ + name: channel.name, + platforms: channel.adapters.map(({ platform }) => platform), + })), + ).toEqual([ + { name: "opentag-direct-slack", platforms: ["slack"] }, + { name: "opentag-direct-teams", platforms: ["teams"] }, + ]); + expect(application.runtime.channels).toEqual([ + application.channel, + ...application.directChannels, + ]); + }); +}); diff --git a/e2e/cases.ts b/e2e/cases.ts index 9ec8e5a..65199b7 100644 --- a/e2e/cases.ts +++ b/e2e/cases.ts @@ -402,11 +402,12 @@ export const CASES: E2ECase[] = [ name: "E-durable-1 — confirm_write buttons serialize action IDs and resume values", // This live case does not restart the process. It reads the initial picker // back through conversations.replies and verifies Slack stored durable - // action IDs plus JSON resume values. The actual stop/start/rehydrate/click - // behavior is covered by the Channel restart test in app/channel.test.ts. + // action IDs plus JSON resume values. Handler re-registration with a shared + // store is covered in app/channel.test.ts; this does not claim persistence + // across an operating-system process restart. prompt: '<@U0B45V75NNR> file a Linear issue titled "Checkout 500s under load". ' + - "Use the confirm_write tool to ask me to approve it first.", + "The protected write must ask me to approve it before MCP runs.", sampleIntervalMs: 700, maxWaitMs: 20_000, expectations: { @@ -454,11 +455,11 @@ export const CASES: E2ECase[] = [ // 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 initial Block Kit card lands with Create/Cancel. - // Click→resume and process-restart rehydration are covered by the Channel - // unit tests. + // Click→resume handling and shared-store action re-registration are covered + // by the Channel unit tests. prompt: - '<@U0B45V75NNR> file a Linear issue titled "Test from e2e". Call the ' + - "confirm_write tool to ask me to approve it before creating anything.", + '<@U0B45V75NNR> file a Linear issue titled "Test from e2e". The ' + + "protected write must ask me to approve it before creating anything.", sampleIntervalMs: 700, maxWaitMs: 20_000, expectations: { diff --git a/server.ts b/server.ts index 47a1b7a..9a10b3f 100644 --- a/server.ts +++ b/server.ts @@ -162,17 +162,33 @@ export function createOpenTagApplication( environment: AppEnvironment = readEnvironment(), ) { const platform = resolvePlatforms(environment); + const agent = createAgentFactory({ + url: environment.agentUrl, + authHeader: environment.agentAuthHeader, + }); const channel = createOpenTagChannel({ name: environment.channelName, - adapters: platform.adapters, - agent: createAgentFactory({ - url: environment.agentUrl, - authHeader: environment.agentAuthHeader, + adapters: [], + agent, + }); + const directChannels = platform.adapters.map((adapter) => + createOpenTagChannel({ + name: `opentag-direct-${adapter.platform}`, + adapters: [adapter], + agent, }), + ); + const runtimeHost = createOpenTagRuntime({ + environment, + channels: [channel, ...directChannels], }); - const runtimeHost = createOpenTagRuntime({ environment, channel }); - return { channel, environment, ...runtimeHost }; + return { + channel, + directChannels, + environment, + ...runtimeHost, + }; } export async function main(): Promise<RunningOpenTagServer> { diff --git a/setup.md b/setup.md index 1de7af3..13f7d84 100644 --- a/setup.md +++ b/setup.md @@ -19,8 +19,9 @@ and WhatsApp are coming soon. The Channel always uses the Intelligence-owned runtime. Managed Slack and Microsoft Teams attachments are configured in Intelligence. Direct adapters -are optional additions to the same runtime, not an alternate non-Intelligence -mode. +are optional, separately named Channels in the same runtime, not an alternate +non-Intelligence mode. Keeping the managed Channel adapter-free ensures local +credentials cannot disable managed activation. ## Install @@ -174,8 +175,8 @@ SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... ``` -Both are required as a pair. The direct adapter runs inside the same -Intelligence-owned Channel process. Never run it concurrently with another +Both are required as a pair. The direct adapter runs as its own Channel inside +the same Intelligence-owned process. Never run it concurrently with another consumer of the same production app token. ## Tools, commands, and UI @@ -191,10 +192,10 @@ The Channel also forwards sender context, Slack-specific tools on Slack turns, file content, and rich issue/page/table/chart/diagram/status/incident/link components. -Before a Linear or Notion create or update, the Python agent calls -`confirm_write`. LangGraph emits an interrupt, the Channel posts an approval -card, and the button resumes the graph with the user's decision. Reads and UI -rendering are never gated. +Before a Linear or Notion mutation reaches MCP, a Python interceptor emits +`confirm_write`. The Channel posts an approval card, and the button resumes the +graph with the user's decision. The MCP handler runs only after approval. +Reads and UI rendering are never gated. ## Optional sources From 6664d62ca05da8eee3aacae7d341e8ecb03d091c Mon Sep 17 00:00:00 2001 From: Tyler Slaton <tyler@copilotkit.ai> Date: Mon, 27 Jul 2026 18:24:29 -0400 Subject: [PATCH 06/19] Surface recoverable integration failures --- agent/tests/test_tools.py | 75 +++++++++++++++++++++++++++++++++ agent/tools.py | 87 ++++++++++++++++++++++++++------------- app/channel.test.ts | 44 ++++++++++++++++++++ app/channel.tsx | 39 +++++++++++++++--- server.ts | 8 +++- 5 files changed, 216 insertions(+), 37 deletions(-) diff --git a/agent/tests/test_tools.py b/agent/tests/test_tools.py index 1030a60..01cef3e 100644 --- a/agent/tests/test_tools.py +++ b/agent/tests/test_tools.py @@ -1,4 +1,6 @@ import asyncio +import logging +from datetime import datetime import tools import copilotkit.langgraph @@ -82,6 +84,79 @@ async def get_tools(self): ) +@pytest.mark.parametrize( + ("failure", "expected_error"), + [ + ( + asyncio.TimeoutError(), + "Configured MCP server timed out while loading tools", + ), + ( + RuntimeError("connection details must not reach logs"), + "Configured MCP server was unavailable while loading tools", + ), + ], +) +def test_internal_source_tools_emits_structured_errors_for_configured_sources( + monkeypatch, + caplog, + failure, + expected_error, +): + class FailingMCPClient: + def __init__(self, _connections, *, tool_interceptors): pass + + async def get_tools(self): + raise failure + + monkeypatch.setenv("LINEAR_API_KEY", "lin_api_test") + monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) + monkeypatch.setattr(tools, "MultiServerMCPClient", FailingMCPClient) + + with caplog.at_level(logging.ERROR, logger=tools.__name__): + result = tools.internal_source_tools() + + assert result == [] + assert len(caplog.records) == 1 + event = caplog.records[0].msg + assert event["error"] == expected_error + assert event["context"] == { + "component": "internal_source_tools", + "exception_type": type(failure).__name__, + "recovery": "skip_optional_integration", + "source": "linear", + } + assert datetime.fromisoformat(event["timestamp"]).tzinfo is not None + + +def test_internal_source_tools_emits_structured_event_loop_errors( + monkeypatch, + caplog, +): + def fail_run(coroutine): + coroutine.close() + raise RuntimeError("loop details must not reach logs") + + monkeypatch.setenv("LINEAR_API_KEY", "lin_api_test") + monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) + monkeypatch.setattr(tools.asyncio, "run", fail_run) + + with caplog.at_level(logging.ERROR, logger=tools.__name__): + result = tools.internal_source_tools() + + assert result == [] + assert len(caplog.records) == 1 + event = caplog.records[0].msg + assert event["error"] == "Failed to initialize MCP tool loading" + assert event["context"] == { + "component": "internal_source_tools", + "exception_type": "RuntimeError", + "recovery": "skip_optional_integrations", + "source": "all", + } + assert datetime.fromisoformat(event["timestamp"]).tzinfo is not None + + def test_confirm_write_interrupts_with_structured_action(monkeypatch): calls = [] expected_response = {"confirmed": True} diff --git a/agent/tools.py b/agent/tools.py index 6028b8b..8198607 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -9,9 +9,10 @@ """ import os -import sys import asyncio import json +import logging +from datetime import datetime, timezone from typing import Any from concurrent.futures import ThreadPoolExecutor from langchain_core.tools import BaseTool, tool @@ -26,6 +27,31 @@ from mcp.types import CallToolResult, TextContent +logger = logging.getLogger(__name__) + + +def _emit_internal_source_error( + error: BaseException, + *, + message: str, + source: str, + recovery: str, +) -> None: + """Emit an observable error without logging credentials or server details.""" + logger.error( + { + "error": message, + "context": { + "component": "internal_source_tools", + "exception_type": type(error).__name__, + "recovery": recovery, + "source": source, + }, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + ) + + @tool def confirm_write(action: str, detail: str | None = None): """Pause before a Linear or Notion write and ask the user to approve it. @@ -279,14 +305,14 @@ def internal_source_tools() -> list: async-only; this function runs it to completion on a short-lived event loop (`asyncio.run`) so it can be called synchronously from `build_agent()`. Each server is connected individually so one server - failing to load never drops the other's tools, and a down or - misconfigured MCP server is logged and skipped rather than raised - - this must never break agent startup. + failing to load never drops the other's tools. These integrations are + optional deployment enhancements, so a down or misconfigured server emits + a structured error event and is skipped instead of blocking agent startup. Returns: - list: LangChain tools from the configured MCP server(s), or an - empty list when neither `LINEAR_API_KEY` nor - `NOTION_MCP_AUTH_TOKEN` is set (or all configured servers fail). + list: LangChain tools from the configured MCP server(s), or an empty + list when neither integration is configured (or every configured + optional integration emitted an error and was skipped). """ connections: dict[str, dict[str, Any]] = {} @@ -331,33 +357,36 @@ async def _load_all() -> list: f"[TOOLS] internal_source_tools: loaded {len(server_tools)} " f"tool(s) from {name}" ) - except asyncio.TimeoutError: - # This source WAS configured (its token/URL is set) but is - # unreachable — a real, silent degradation (the agent runs - # without its tools). Warn loudly on stderr so it's not confused - # with a source the operator intentionally left disabled. On - # Railway a cold-start race can cause this; redeploy once the - # sidecar is up (see README "Cold-start note"). - print( - f"[TOOLS] WARNING: {name} is configured but its MCP server " - f"timed out after 8s — running WITHOUT {name} tools", - file=sys.stderr, + except asyncio.TimeoutError as error: + # Optional MCP integrations must not block the core agent, but + # a configured source failing is distinct from an intentionally + # disabled source and must remain observable. + _emit_internal_source_error( + error, + message="Configured MCP server timed out while loading tools", + source=name, + recovery="skip_optional_integration", ) - except Exception as e: - print( - f"[TOOLS] WARNING: {name} is configured but its MCP server " - f"is unavailable — running WITHOUT {name} tools ({e})", - file=sys.stderr, + except Exception as error: + _emit_internal_source_error( + error, + message=( + "Configured MCP server was unavailable while loading tools" + ), + source=name, + recovery="skip_optional_integration", ) return loaded try: return asyncio.run(_load_all()) - except Exception as e: - # Belt-and-suspenders: even a failure in the event-loop plumbing - # itself (not just an individual server) must not break startup. - print( - f"[TOOLS] WARNING: failed to load internal-source MCP tools ({e})", - file=sys.stderr, + except Exception as error: + # Loading optional integrations is recoverable for the core agent, but + # event-loop failures still surface as structured operational errors. + _emit_internal_source_error( + error, + message="Failed to initialize MCP tool loading", + source="all", + recovery="skip_optional_integrations", ) return [] diff --git a/app/channel.test.ts b/app/channel.test.ts index 3b3c382..2021883 100644 --- a/app/channel.test.ts +++ b/app/channel.test.ts @@ -84,6 +84,7 @@ function findButton( afterEach(async () => { await Promise.all(channels.splice(0).map((channel) => channel.ɵruntime.stop())); + vi.restoreAllMocks(); }); function makeChannel(options: { @@ -234,8 +235,40 @@ describe("createOpenTagChannel", () => { ); }); + it("surfaces a structured recoverable error when suggested prompts fail", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const { adapter, channel } = makeChannel({ platform: "slack" }); + adapter.setSuggestedPrompts = vi.fn(async () => { + throw new Error("suggested prompts unavailable"); + }); + + await channel.ɵruntime.start(); + await adapter.emitThreadStarted({ + conversationKey: "c1", + replyTarget: {}, + user: { id: "U1", name: "Ada" }, + }); + + expect(consoleError).toHaveBeenCalledWith( + "[channel] recoverable error", + expect.objectContaining({ + error: expect.any(Error), + context: { + operation: "set_suggested_prompts", + recovery: "continue_without_suggested_prompts", + }, + timestamp: expect.any(String), + }), + ); + }); + it("posts a user-facing error when the agent run fails", async () => { const error = new Error("agent unavailable"); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); const { adapter, channel } = makeChannel({ platform: "slack", agent: new FakeAgent([ @@ -255,6 +288,17 @@ describe("createOpenTagChannel", () => { }); expect(JSON.stringify(adapter.posted)).toMatch(/sorry.*error/i); + expect(consoleError).toHaveBeenCalledWith( + "[channel] recoverable error", + expect.objectContaining({ + error, + context: { + operation: "run_agent", + recovery: "posted_user_facing_error", + }, + timestamp: expect.any(String), + }), + ); }); it("posts a real JSON-stringified confirm_write interrupt card and returns immediately", async () => { diff --git a/app/channel.tsx b/app/channel.tsx index 0653c0b..7498492 100644 --- a/app/channel.tsx +++ b/app/channel.tsx @@ -13,6 +13,17 @@ import { parseConfirmWriteInterrupt } from "./interrupt.js"; import { FILE_ISSUE_CALLBACK, fileIssueSubmit } from "./modals/file-issue.js"; import { appTools } from "./tools/index.js"; +function reportRecoverableError( + error: unknown, + context: { operation: string; recovery: string }, +): void { + console.error("[channel] recoverable error", { + error: error instanceof Error ? error : new Error(String(error)), + context, + timestamp: new Date().toISOString(), + }); +} + export interface CreateOpenTagChannelOptions { name: string; adapters: PlatformAdapter[]; @@ -41,12 +52,23 @@ export function createOpenTagChannel( try { await thread.runAgent(mentionRunInput(message, thread.platform)); } catch (error) { - console.error("[channel] agent run failed", error); - await thread - .post("Sorry — I hit an error handling that. Please try again.") - .catch((postError: unknown) => - console.error("[channel] failed to post agent error", postError), + try { + await thread.post( + "Sorry — I hit an error handling that. Please try again.", ); + } catch (postError) { + throw new AggregateError( + [error, postError], + "The agent run and its user-facing error reply both failed", + ); + } + + // A failed turn is isolated from future turns. Once the user receives an + // explicit failure response, the Channel can safely remain available. + reportRecoverableError(error, { + operation: "run_agent", + recovery: "posted_user_facing_error", + }); } }); @@ -77,7 +99,12 @@ export function createOpenTagChannel( }, ]); } catch (error) { - console.error("[channel] onThreadStarted failed", error); + // Suggested prompts are an optional affordance; their absence does not + // affect message delivery, agent execution, or later thread turns. + reportRecoverableError(error, { + operation: "set_suggested_prompts", + recovery: "continue_without_suggested_prompts", + }); } }); diff --git a/server.ts b/server.ts index 9a10b3f..4115259 100644 --- a/server.ts +++ b/server.ts @@ -150,6 +150,8 @@ export async function startOpenTagServer( }; const onSignal = (): void => { + // Signal callbacks cannot be awaited by EventEmitter. The terminal catch + // reports cleanup failure and sets a non-zero process exit code. void shutdown().catch(onShutdownError); }; signalTarget.once("SIGINT", onSignal); @@ -210,8 +212,10 @@ const isMain = import.meta.url === pathToFileURL(process.argv[1]).href; if (isMain) { - void main().catch((error: unknown) => { + try { + await main(); + } catch (error) { console.error("[opentag] startup failed", error); process.exitCode = 1; - }); + } } From b130b0eb2b47976e2be11ffe70d32dd1decbb912 Mon Sep 17 00:00:00 2001 From: Tyler Slaton <tyler@copilotkit.ai> Date: Mon, 27 Jul 2026 19:09:09 -0400 Subject: [PATCH 07/19] Address OpenTag migration review comments --- .env.example | 42 ++-- .railway/railway.ts | 6 + README.md | 78 +++---- agent/.env.example | 26 --- agent/agent.py | 6 +- agent/internal_sources.py | 114 +++++++++ agent/main.py | 38 +-- agent/tests/test_health.py | 17 +- agent/tests/test_internal_sources.py | 134 +++++++++++ agent/tests/test_tools.py | 311 +------------------------ agent/tests/test_write_confirmation.py | 184 +++++++++++++++ agent/tools.py | 230 +----------------- agent/write_confirmation.py | 82 +++++++ app/agent.test.ts | 99 -------- app/channel-helpers.test.ts | 69 ++++++ app/{agent.ts => channel-helpers.ts} | 54 ++--- app/channel.test.ts | 88 +++---- app/channel.tsx | 44 ++-- app/commands/index.ts | 2 +- app/components/index.ts | 5 +- app/env.test.ts | 46 +--- app/env.ts | 25 -- app/index.ts | 30 +++ app/modals/file-issue.tsx | 7 +- app/platforms.test.ts | 66 ------ app/platforms.ts | 53 ----- app/runtime-host.test.ts | 27 +-- app/sender-context.ts | 2 +- app/server.test.ts | 25 +- app/tools/index.ts | 4 +- e2e/README.md | 5 +- server.ts | 43 +--- setup.md | 84 +++---- slack-app-manifest.yaml | 2 +- 34 files changed, 856 insertions(+), 1192 deletions(-) delete mode 100644 agent/.env.example create mode 100644 agent/internal_sources.py create mode 100644 agent/tests/test_internal_sources.py create mode 100644 agent/tests/test_write_confirmation.py create mode 100644 agent/write_confirmation.py delete mode 100644 app/agent.test.ts create mode 100644 app/channel-helpers.test.ts rename app/{agent.ts => channel-helpers.ts} (52%) create mode 100644 app/index.ts delete mode 100644 app/platforms.test.ts delete mode 100644 app/platforms.ts diff --git a/.env.example b/.env.example index 8722bec..ed851de 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# ── Canonical Intelligence Channel host (`pnpm channel`) ─────────────── +# ── Intelligence Channel host (`pnpm channel`) ───────────────────────── # Both variables are required. The local Python agent listens on :8123. AGENT_URL=http://localhost:8123/ INTELLIGENCE_API_KEY=cpk-... @@ -6,38 +6,32 @@ INTELLIGENCE_API_KEY=cpk-... # Production defaults; override only for another Intelligence environment. # INTELLIGENCE_API_URL=https://api.intelligence.copilotkit.ai # INTELLIGENCE_GATEWAY_WS_URL=wss://realtime.intelligence.copilotkit.ai -# Defaults to "opentag". The production Railway service sets this to "kite" -# so the existing Slack app keeps its @kite handle. +# Base Channel name. OpenTag declares this Slack Channel plus a Teams Channel +# suffixed with "-teams". Railway sets the base to "kite". # INTELLIGENCE_CHANNEL_NAME=opentag # HTTP port for the CopilotRuntime listener. Defaults to 3000. # PORT=3000 # Optional authorization header forwarded to the AG-UI agent. # AGENT_AUTH_HEADER=Bearer ... -# Attach managed Slack and Microsoft Teams integrations in Intelligence. The -# optional credentials below are only for direct local-development adapters -# inside this same Intelligence-owned runtime. +# Slack and Microsoft Teams credentials belong in CopilotKit Intelligence, not +# this runtime environment. Discord, Telegram, and WhatsApp are coming soon. -# Slack Socket Mode: set both values or neither. -# SLACK_BOT_TOKEN=xoxb-... -# SLACK_APP_TOKEN=xapp-... +# ── Python deep-research agent (`pnpm agent`) ─────────────────────────── +# The Python service also loads this root .env during local development. +OPENAI_API_KEY=sk-... +# Optional live web research. Without Tavily the agent still chats, plans, +# uses its virtual filesystem, and renders UI components. +# TAVILY_API_KEY=tvly-... +# OPENAI_MODEL=gpt-5.5 +# SERVER_HOST=0.0.0.0 +# SERVER_PORT=8123 -# Microsoft Teams / Azure Bot: set both values or neither. -# TEAMS_CLIENT_ID=... -# TEAMS_CLIENT_SECRET=... -# TEAMS_TENANT_ID=... -# TEAMS_PORT=3978 +# ── Internal research sources (optional) ──────────────────────────────── +# LINEAR_API_KEY=lin_api_... +# LINEAR_MCP_URL=https://mcp.linear.app/mcp -# Discord, Telegram, and WhatsApp are coming soon. They have no active -# configuration in this launch. - -# The Python service loads `agent/.env`; copy `agent/.env.example` there for -# OPENAI_API_KEY and optional Tavily/Linear/Notion research variables. - -# Notion remains available for local development through the optional -# `pnpm notion-mcp` sidecar. These root variables configure that sidecar; put -# the same NOTION_MCP_AUTH_TOKEN in `agent/.env`. Notion is not a Railway -# service or launch blocker. +# The Notion MCP sidecar is local-only and not a Railway service. # NOTION_TOKEN=ntn_... # NOTION_MCP_AUTH_TOKEN=choose-a-strong-shared-secret # NOTION_MCP_URL=http://127.0.0.1:3001/mcp diff --git a/.railway/railway.ts b/.railway/railway.ts index 5899088..885ebca 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -1,3 +1,9 @@ +/** + * Reconciles OpenTag's production Railway topology: one Python agent and one + * Intelligence-connected Channel host, both deployed from `main`. Platform + * credentials and attachments stay in Intelligence, so Railway owns only the + * application services and their private AG-UI connection. + */ import { defineRailway, github, preserve, project, service } from "railway/iac"; const REPO = "CopilotKit/OpenTag"; diff --git a/README.md b/README.md index 16becb8..6b0f58e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ The launch supports: - Slack through Intelligence, including the existing production `@kite` app. - Microsoft Teams through Intelligence. -- Optional direct Slack and Teams adapters for local development. Discord, Telegram, and WhatsApp are coming soon. @@ -31,12 +30,11 @@ agent (Python + LangGraph deepagents) └── Notion MCP (optional local sidecar) ``` -There is one canonical Channel host: [`server.ts`](./server.ts). It creates one -`CopilotKitIntelligence`, one `CopilotRuntime`, and an adapter-free managed -OpenTag Channel. Platform attachments are normally configured in Intelligence. -Supplying local Slack or Teams credentials adds a separately named direct -Channel to that same runtime; it does not create a separate lifecycle or -disable managed delivery. +There is one canonical Channel host: [`server.ts`](./server.ts). +[`app/index.ts`](./app/index.ts) composes one `CopilotKitIntelligence`, one +`CopilotRuntime`, and two adapter-free managed Channels: the configured base +name for Slack and `<base>-teams` for Microsoft Teams. Intelligence owns all +platform credentials and attachments. The launch pins the requested canaries: @@ -55,15 +53,7 @@ Prerequisites: Node.js 22+, pnpm, Python 3.12, and cd agent && uv sync && cd .. ``` -2. Configure the Python agent. - - ```bash - cp agent/.env.example agent/.env - ``` - - Set `OPENAI_API_KEY`. Tavily, Linear, and Notion are optional. - -3. Configure the Channel host. +2. Configure both services from the shared root environment. ```bash cp .env.example .env @@ -72,18 +62,23 @@ Prerequisites: Node.js 22+, pnpm, Python 3.12, and Set: ```dotenv + OPENAI_API_KEY=sk-... AGENT_URL=http://localhost:8123/ INTELLIGENCE_API_KEY=cpk-... ``` + Tavily, Linear, and Notion are optional. Both Node and Python load this root + `.env`; Railway supplies the same values as service variables. + `INTELLIGENCE_API_URL` and `INTELLIGENCE_GATEWAY_WS_URL` default to the production Intelligence endpoints. `INTELLIGENCE_CHANNEL_NAME` defaults to `opentag`. -4. In Intelligence, create the Channel that matches - `INTELLIGENCE_CHANNEL_NAME` and attach managed Slack and/or Microsoft Teams. +3. In Intelligence, create two managed Channels: `opentag` for Slack and + `opentag-teams` for Microsoft Teams. If you change the base name, use that + name and `<base>-teams`. -5. Run both services. +4. Run both services. ```bash pnpm agent @@ -110,34 +105,18 @@ in [`agent/agent.py`](./agent/agent.py); the Channel UI and behavior live under ## Platform setup -### Intelligence (recommended) +### Intelligence Use the normal Intelligence flow for both launch platforms: -1. Create one OpenTag project and Channel in Intelligence. -2. Issue a runtime API key. -3. Configure the Slack and/or Microsoft Teams attachment in Intelligence. -4. Run one `pnpm channel` process with that Channel name and key. +1. Create one OpenTag project. +2. Create the base Channel for Slack and `<base>-teams` for Microsoft Teams. +3. Issue a runtime API key. +4. Configure each platform attachment on its matching Channel. +5. Run one `pnpm channel` process with the base Channel name and key. No organization, project, Channel ID, or runtime-instance ID environment -variables are required. - -### Optional direct adapters - -For local development, set either complete pair in the root `.env`: - -```dotenv -SLACK_BOT_TOKEN=xoxb-... -SLACK_APP_TOKEN=xapp-... - -TEAMS_CLIENT_ID=... -TEAMS_CLIENT_SECRET=... -``` - -`TEAMS_TENANT_ID` and `TEAMS_PORT` are optional. Incomplete credential pairs -fail at startup instead of silently disabling an adapter. The Intelligence -connection remains the owner of the runtime, while each direct adapter stays -isolated from the managed Channel. +variables—or Slack/Teams credentials—are required by the runtime. ### Slack manifests @@ -155,9 +134,9 @@ Slack app**. Reusing it preserves the bot user, workspace installation, and - `TAVILY_API_KEY` enables live web research. Without it, OpenTag still chats, plans, uses its virtual filesystem, and renders UI from model knowledge. - `LINEAR_API_KEY` enables the hosted Linear MCP. -- Notion can be used locally with `pnpm notion-mcp`; copy the same - `NOTION_MCP_AUTH_TOKEN` into the root `.env` and `agent/.env`. The Railway - launch intentionally has no Notion sidecar. +- Notion can be used locally with `pnpm notion-mcp`; its token and shared + `NOTION_MCP_AUTH_TOKEN` live in the root `.env`. The Railway launch + intentionally has no Notion sidecar. Every Linear and Notion mutation is intercepted in code before the MCP request runs. The interceptor emits `confirm_write` and proceeds only after approval; @@ -174,10 +153,11 @@ both sourced from `CopilotKit/OpenTag` on `main`: | `channel` | repository root | `pnpm channel` | Channels readiness before listen | The Channel reaches the agent over Railway private networking. Railway sets -`INTELLIGENCE_CHANNEL_NAME=kite` for the production cutover and preserves the -runtime API key. OpenAI is required on `agent`; Tavily and Linear secrets are -optional. Connecting both services to `main` enables GitHub-triggered -deployments after merges. +`INTELLIGENCE_CHANNEL_NAME=kite`, so the runtime declares the managed `kite` +Slack Channel and `kite-teams` Teams Channel. It preserves the runtime API key. +OpenAI is required on `agent`; Tavily and Linear secrets are optional. +Connecting both services to `main` enables GitHub-triggered deployments after +merges. The repository configuration does not mutate the existing production Railway project. Inventory and cutover should happen after Railway authentication. diff --git a/agent/.env.example b/agent/.env.example deleted file mode 100644 index 402155b..0000000 --- a/agent/.env.example +++ /dev/null @@ -1,26 +0,0 @@ -# OpenTag deep-research agent (agent/) — served over AG-UI on SERVER_PORT. -# The OpenTag Channel host points AGENT_URL at http://localhost:8123/ -# Only OPENAI_API_KEY is required; the agent chats + generates UI components without Tavily. -OPENAI_API_KEY=sk-... -# OPTIONAL — web research. Without it the agent still works (chat + UI components, -# answers from its own knowledge); with it, live web research turns on. https://tavily.com -# TAVILY_API_KEY=tvly-... -# OpenAI model (defaults to gpt-5.5, matching the rest of OpenTag). -# OPENAI_MODEL=gpt-5.5 -# Server bind (defaults 0.0.0.0:8123). -# SERVER_HOST=0.0.0.0 -# SERVER_PORT=8123 - -# ── Internal research sources (optional) ──────────────────────────────── -# Setting LINEAR_API_KEY and/or NOTION_MCP_AUTH_TOKEN lets the agent research -# the team's Linear issues / Notion docs alongside (or instead of) the web — -# see internal_source_tools() in tools.py. Leave both blank to research the -# web only. -# Hosted Linear MCP bearer token (linear.app → Settings → API → Personal API -# keys). LINEAR_MCP_URL defaults to the hosted Linear MCP if unset. -# LINEAR_API_KEY=lin_api_... -# LINEAR_MCP_URL=https://mcp.linear.app/mcp -# Bearer token for the local Notion MCP sidecar (`pnpm notion-mcp`). -# NOTION_MCP_URL defaults to that local sidecar if unset. -# NOTION_MCP_AUTH_TOKEN=choose-a-strong-shared-secret -# NOTION_MCP_URL=http://127.0.0.1:3001/mcp diff --git a/agent/agent.py b/agent/agent.py index 1195c53..a9ee280 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -7,15 +7,17 @@ """ import os +from pathlib import Path from dotenv import load_dotenv from langchain_openai import ChatOpenAI from deepagents import create_deep_agent from langgraph.checkpoint.memory import MemorySaver from copilotkit import CopilotKitMiddleware -from tools import research, internal_source_tools +from internal_sources import internal_source_tools +from tools import research -load_dotenv() +load_dotenv(Path(__file__).resolve().parent.parent / ".env") # Base system prompt - always applies, regardless of whether web research is diff --git a/agent/internal_sources.py b/agent/internal_sources.py new file mode 100644 index 0000000..fbea53d --- /dev/null +++ b/agent/internal_sources.py @@ -0,0 +1,114 @@ +"""Optional Linear and Notion MCP integrations.""" + +import asyncio +import logging +import os +from datetime import datetime, timezone +from typing import Any + +from langchain_mcp_adapters.client import MultiServerMCPClient + +from write_confirmation import WriteConfirmationInterceptor + + +logger = logging.getLogger(__name__) + + +def _emit_internal_source_error( + error: BaseException, + *, + message: str, + source: str, + recovery: str, +) -> None: + """Emit an observable error without logging credentials or server details.""" + logger.error( + { + "error": message, + "context": { + "component": "internal_source_tools", + "exception_type": type(error).__name__, + "recovery": recovery, + "source": source, + }, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + ) + + +def internal_source_tools() -> list: + """Load independently optional Linear and Notion tools. + + Each configured server gets its own client so one unavailable integration + cannot remove the other's tools. Tool discovery is async-only, so this + synchronous build hook runs it on a short-lived event loop and bounds each + connection attempt. Every loaded mutation is guarded at execution time by + ``WriteConfirmationInterceptor``. + """ + connections: dict[str, dict[str, Any]] = {} + + linear_api_key = os.environ.get("LINEAR_API_KEY") + if linear_api_key: + connections["linear"] = { + "transport": "streamable_http", + "url": os.environ.get("LINEAR_MCP_URL", "https://mcp.linear.app/mcp"), + "headers": {"Authorization": f"Bearer {linear_api_key}"}, + } + + notion_auth_token = os.environ.get("NOTION_MCP_AUTH_TOKEN") + if notion_auth_token: + connections["notion"] = { + "transport": "streamable_http", + "url": os.environ.get("NOTION_MCP_URL", "http://127.0.0.1:3001/mcp"), + "headers": {"Authorization": f"Bearer {notion_auth_token}"}, + } + + if not connections: + return [] + + async def _load_all() -> list: + loaded: list = [] + for name, connection in connections.items(): + try: + confirmation = WriteConfirmationInterceptor() + server_client = MultiServerMCPClient( + {name: connection}, + tool_interceptors=[confirmation], + ) + server_tools = await asyncio.wait_for( + server_client.get_tools(), timeout=8.0 + ) + confirmation.register_tools(server_tools) + loaded.extend(server_tools) + print( + f"[TOOLS] internal_source_tools: loaded {len(server_tools)} " + f"tool(s) from {name}" + ) + except asyncio.TimeoutError as error: + _emit_internal_source_error( + error, + message="Configured MCP server timed out while loading tools", + source=name, + recovery="skip_optional_integration", + ) + except Exception as error: + _emit_internal_source_error( + error, + message=( + "Configured MCP server was unavailable while loading tools" + ), + source=name, + recovery="skip_optional_integration", + ) + return loaded + + try: + return asyncio.run(_load_all()) + except Exception as error: + _emit_internal_source_error( + error, + message="Failed to initialize MCP tool loading", + source="all", + recovery="skip_optional_integrations", + ) + return [] diff --git a/agent/main.py b/agent/main.py index 441bf8f..0ca55f6 100644 --- a/agent/main.py +++ b/agent/main.py @@ -7,16 +7,14 @@ import os import sys +from collections.abc import Mapping from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from dotenv import load_dotenv from ag_ui_langgraph import add_langgraph_fastapi_endpoint from copilotkit import LangGraphAGUIAgent from agent import build_agent -load_dotenv() - app = FastAPI( title="OpenTag Deep Research Agent", description="A research assistant powered by Deep Agents and CopilotKit", @@ -57,6 +55,21 @@ def health(): return {"status": "ok", "service": "opentag-research-agent", "version": "0.1.0"} +def local_server_port(env: Mapping[str, str] = os.environ) -> int: + """Resolve the local agent port without consuming the Channel's `PORT`.""" + raw_port = env.get("SERVER_PORT", "8123") + try: + port = int(raw_port) + if not (1 <= port <= 65535): + raise ValueError("out of range") + except ValueError as error: + raise ValueError( + f'Invalid SERVER_PORT: "{raw_port}" — ' + "must be an integer between 1 and 65535" + ) from error + return port + + # Build and register the Deep Research Agent try: agent_graph = build_agent() @@ -99,20 +112,13 @@ def main(): # defaulting to :: here: on macOS/BSD a `::` socket may not accept IPv4, so # a local client dialing 127.0.0.1 could be refused.) Override with SERVER_HOST. host = os.getenv("SERVER_HOST") or "0.0.0.0" - # Local-dev entrypoint only: on Railway the startCommand runs `uvicorn - # main:app` directly, so this block is bypassed and the port comes from the - # startCommand's `--port ${PORT:-8123}`. Prefer PORT then SERVER_PORT here so - # `pnpm agent` matches that Railway behavior. - raw_port = os.getenv("PORT") or os.getenv("SERVER_PORT") or "8123" + # Local-dev entrypoint only: Railway runs `uvicorn main:app` with its + # service-specific PORT directly. Ignore root PORT here because that value + # belongs to the Node Channel when both services share one local `.env`. try: - port = int(raw_port) - if not (1 <= port <= 65535): - raise ValueError("out of range") - except ValueError: - print( - f'[ERROR] Invalid PORT/SERVER_PORT: "{raw_port}" — must be an integer between 1 and 65535', - file=sys.stderr, - ) + port = local_server_port() + except ValueError as error: + print(f"[ERROR] {error}", file=sys.stderr) sys.exit(1) reload = os.getenv("AGENT_RELOAD", "").lower() in ("1", "true", "yes") diff --git a/agent/tests/test_health.py b/agent/tests/test_health.py index 226a6c6..e2f0327 100644 --- a/agent/tests/test_health.py +++ b/agent/tests/test_health.py @@ -6,7 +6,7 @@ # reload/re-import is ever needed - re-importing later would just be a no-op # (Python caches modules in sys.modules), but doing it here makes that # explicit and guarantees these tests are order- and .env-independent: a -# developer's local agent/.env cannot repopulate a key a test just deleted. +# developer's root .env cannot repopulate a key a test just deleted. # # NOTE: `main` is intentionally NOT imported here. Unlike agent.py (whose # module-level code only calls load_dotenv()), main.py's module-level code @@ -48,6 +48,21 @@ def test_server_exposes_opentag_metadata(monkeypatch): assert main.AGENT_DESCRIPTION.startswith("OpenTag deep research assistant") +def test_local_agent_port_ignores_the_shared_channel_port(): + import main + + assert main.local_server_port({"PORT": "3000", "SERVER_PORT": "8124"}) == 8124 + assert main.local_server_port({"PORT": "3000"}) == 8123 + + +def test_local_agent_port_rejects_invalid_server_port(): + import main + import pytest + + with pytest.raises(ValueError, match="SERVER_PORT"): + main.local_server_port({"SERVER_PORT": "70000"}) + + def test_build_agent_without_tavily(monkeypatch, capsys): # The core requirement: the agent builds with OPENAI_API_KEY alone (no Tavily), # and the research web tool is NOT loaded in that case. build_agent() reads diff --git a/agent/tests/test_internal_sources.py b/agent/tests/test_internal_sources.py new file mode 100644 index 0000000..d3a2db6 --- /dev/null +++ b/agent/tests/test_internal_sources.py @@ -0,0 +1,134 @@ +import asyncio +import logging +from datetime import datetime + +import internal_sources +import pytest +from langchain_core.tools import StructuredTool +from write_confirmation import WriteConfirmationInterceptor + + +def test_internal_source_tools_empty_without_env(monkeypatch): + monkeypatch.delenv("LINEAR_API_KEY", raising=False) + monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) + assert internal_sources.internal_source_tools() == [] + + +def test_internal_source_tools_loads_when_env_set(monkeypatch): + clients = [] + + class FakeMCPClient: + """Stub replacing MultiServerMCPClient - no real network calls.""" + + def __init__(self, connections, *, tool_interceptors): + self.connections = connections + self.tool_interceptors = tool_interceptors + clients.append(self) + + async def get_tools(self): + return [ + StructuredTool.from_function( + func=lambda: name, + name=f"tool-for-{name}", + description="test tool", + metadata={"readOnlyHint": True}, + ) + for name in self.connections + ] + + monkeypatch.setenv("LINEAR_API_KEY", "lin_api_test") + monkeypatch.setenv("NOTION_MCP_AUTH_TOKEN", "notion-test-token") + monkeypatch.setattr( + internal_sources, + "MultiServerMCPClient", + FakeMCPClient, + ) + + result = internal_sources.internal_source_tools() + + assert len(result) > 0 + assert {tool.name for tool in result} == { + "tool-for-linear", + "tool-for-notion", + } + assert all( + isinstance(client.tool_interceptors[0], WriteConfirmationInterceptor) + for client in clients + ) + + +@pytest.mark.parametrize( + ("failure", "expected_error"), + [ + ( + asyncio.TimeoutError(), + "Configured MCP server timed out while loading tools", + ), + ( + RuntimeError("connection details must not reach logs"), + "Configured MCP server was unavailable while loading tools", + ), + ], +) +def test_internal_source_tools_emits_structured_errors_for_configured_sources( + monkeypatch, + caplog, + failure, + expected_error, +): + class FailingMCPClient: + def __init__(self, _connections, *, tool_interceptors): pass + + async def get_tools(self): + raise failure + + monkeypatch.setenv("LINEAR_API_KEY", "lin_api_test") + monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) + monkeypatch.setattr( + internal_sources, + "MultiServerMCPClient", + FailingMCPClient, + ) + + with caplog.at_level(logging.ERROR, logger=internal_sources.__name__): + result = internal_sources.internal_source_tools() + + assert result == [] + assert len(caplog.records) == 1 + event = caplog.records[0].msg + assert event["error"] == expected_error + assert event["context"] == { + "component": "internal_source_tools", + "exception_type": type(failure).__name__, + "recovery": "skip_optional_integration", + "source": "linear", + } + assert datetime.fromisoformat(event["timestamp"]).tzinfo is not None + + +def test_internal_source_tools_emits_structured_event_loop_errors( + monkeypatch, + caplog, +): + def fail_run(coroutine): + coroutine.close() + raise RuntimeError("loop details must not reach logs") + + monkeypatch.setenv("LINEAR_API_KEY", "lin_api_test") + monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) + monkeypatch.setattr(internal_sources.asyncio, "run", fail_run) + + with caplog.at_level(logging.ERROR, logger=internal_sources.__name__): + result = internal_sources.internal_source_tools() + + assert result == [] + assert len(caplog.records) == 1 + event = caplog.records[0].msg + assert event["error"] == "Failed to initialize MCP tool loading" + assert event["context"] == { + "component": "internal_source_tools", + "exception_type": "RuntimeError", + "recovery": "skip_optional_integrations", + "source": "all", + } + assert datetime.fromisoformat(event["timestamp"]).tzinfo is not None diff --git a/agent/tests/test_tools.py b/agent/tests/test_tools.py index 01cef3e..4770d40 100644 --- a/agent/tests/test_tools.py +++ b/agent/tests/test_tools.py @@ -1,12 +1,5 @@ -import asyncio -import logging -from datetime import datetime - import tools -import copilotkit.langgraph import pytest -from langchain_core.tools import StructuredTool -from langchain_mcp_adapters.interceptors import MCPToolCallRequest def test_do_internet_search_formats_results(monkeypatch): @@ -24,12 +17,11 @@ def search(self, **kwargs): def test_do_internet_search_missing_key(monkeypatch): monkeypatch.delenv("TAVILY_API_KEY", raising=False) - import pytest with pytest.raises(RuntimeError, match="TAVILY_API_KEY"): tools._do_internet_search("q") -def test_do_internet_search_swallows_errors(monkeypatch): +def test_do_internet_search_returns_a_discriminated_error(monkeypatch): class BoomClient: def __init__(self, api_key): pass def search(self, **kwargs): raise ValueError("boom") @@ -37,304 +29,3 @@ def search(self, **kwargs): raise ValueError("boom") monkeypatch.setattr(tools, "TavilyClient", BoomClient) out = tools._do_internet_search("q") assert out == [{"error": "boom"}] - - -def test_internal_source_tools_empty_without_env(monkeypatch): - monkeypatch.delenv("LINEAR_API_KEY", raising=False) - monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) - assert tools.internal_source_tools() == [] - - -def test_internal_source_tools_loads_when_env_set(monkeypatch): - clients = [] - - class FakeMCPClient: - """Stub replacing MultiServerMCPClient - no real network calls.""" - - def __init__(self, connections, *, tool_interceptors): - self.connections = connections - self.tool_interceptors = tool_interceptors - clients.append(self) - - async def get_tools(self): - return [ - StructuredTool.from_function( - func=lambda: name, - name=f"tool-for-{name}", - description="test tool", - metadata={"readOnlyHint": True}, - ) - for name in self.connections - ] - - monkeypatch.setenv("LINEAR_API_KEY", "lin_api_test") - monkeypatch.setenv("NOTION_MCP_AUTH_TOKEN", "notion-test-token") - monkeypatch.setattr(tools, "MultiServerMCPClient", FakeMCPClient) - - result = tools.internal_source_tools() - - assert len(result) > 0 - assert {tool.name for tool in result} == { - "tool-for-linear", - "tool-for-notion", - } - assert all( - isinstance(client.tool_interceptors[0], tools.WriteConfirmationInterceptor) - for client in clients - ) - - -@pytest.mark.parametrize( - ("failure", "expected_error"), - [ - ( - asyncio.TimeoutError(), - "Configured MCP server timed out while loading tools", - ), - ( - RuntimeError("connection details must not reach logs"), - "Configured MCP server was unavailable while loading tools", - ), - ], -) -def test_internal_source_tools_emits_structured_errors_for_configured_sources( - monkeypatch, - caplog, - failure, - expected_error, -): - class FailingMCPClient: - def __init__(self, _connections, *, tool_interceptors): pass - - async def get_tools(self): - raise failure - - monkeypatch.setenv("LINEAR_API_KEY", "lin_api_test") - monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) - monkeypatch.setattr(tools, "MultiServerMCPClient", FailingMCPClient) - - with caplog.at_level(logging.ERROR, logger=tools.__name__): - result = tools.internal_source_tools() - - assert result == [] - assert len(caplog.records) == 1 - event = caplog.records[0].msg - assert event["error"] == expected_error - assert event["context"] == { - "component": "internal_source_tools", - "exception_type": type(failure).__name__, - "recovery": "skip_optional_integration", - "source": "linear", - } - assert datetime.fromisoformat(event["timestamp"]).tzinfo is not None - - -def test_internal_source_tools_emits_structured_event_loop_errors( - monkeypatch, - caplog, -): - def fail_run(coroutine): - coroutine.close() - raise RuntimeError("loop details must not reach logs") - - monkeypatch.setenv("LINEAR_API_KEY", "lin_api_test") - monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) - monkeypatch.setattr(tools.asyncio, "run", fail_run) - - with caplog.at_level(logging.ERROR, logger=tools.__name__): - result = tools.internal_source_tools() - - assert result == [] - assert len(caplog.records) == 1 - event = caplog.records[0].msg - assert event["error"] == "Failed to initialize MCP tool loading" - assert event["context"] == { - "component": "internal_source_tools", - "exception_type": "RuntimeError", - "recovery": "skip_optional_integrations", - "source": "all", - } - assert datetime.fromisoformat(event["timestamp"]).tzinfo is not None - - -def test_confirm_write_interrupts_with_structured_action(monkeypatch): - calls = [] - expected_response = {"confirmed": True} - - def fake_interrupt(value): - calls.append(value) - return expected_response - - monkeypatch.setattr(copilotkit.langgraph, "interrupt", fake_interrupt) - confirm_write = getattr(tools, "confirm_write", None) - - assert confirm_write is not None - result = confirm_write.invoke( - { - "action": "Create Linear issue", - "detail": "CPK-9: Checkout 500s", - } - ) - - assert len(calls) == 1 - assert set(calls[0]) == { - "__copilotkit_interrupt_value__", - "__copilotkit_messages__", - } - assert calls[0]["__copilotkit_interrupt_value__"] == { - "action": "confirm_write", - "args": { - "action": "Create Linear issue", - "detail": "CPK-9: Checkout 500s", - }, - } - assert len(calls[0]["__copilotkit_messages__"]) == 1 - interrupt_message = calls[0]["__copilotkit_messages__"][0] - assert interrupt_message.tool_calls == [ - { - "name": "confirm_write", - "args": { - "action": "Create Linear issue", - "detail": "CPK-9: Checkout 500s", - }, - "id": interrupt_message.tool_calls[0]["id"], - "type": "tool_call", - }, - ] - assert result == '{"confirmed": true}' - - -def test_write_confirmation_interceptor_leaves_annotated_reads_unguarded( - monkeypatch, -): - interrupt_calls = [] - handler_calls = [] - - async def read_issue(issue_id: str): - return issue_id - - read_tool = StructuredTool.from_function( - coroutine=read_issue, - name="get_issue", - description="Read an issue", - metadata={"readOnlyHint": True}, - ) - interceptor = tools.WriteConfirmationInterceptor() - interceptor.register_tools([read_tool]) - monkeypatch.setattr( - tools, - "copilotkit_interrupt", - lambda **kwargs: interrupt_calls.append(kwargs), - ) - - async def handler(request): - handler_calls.append(request) - return "read-result" - - result = asyncio.run( - interceptor( - MCPToolCallRequest( - name="get_issue", - args={"issue_id": "CPK-9"}, - server_name="linear", - ), - handler, - ) - ) - - assert result == "read-result" - assert len(handler_calls) == 1 - assert interrupt_calls == [] - - -def test_write_confirmation_interceptor_blocks_a_declined_mutation(monkeypatch): - interrupt_calls = [] - handler_calls = [] - interceptor = tools.WriteConfirmationInterceptor() - - def decline(**kwargs): - interrupt_calls.append(kwargs) - return '{"confirmed": false}', {"confirmed": False} - - monkeypatch.setattr(tools, "copilotkit_interrupt", decline) - - async def handler(request): - handler_calls.append(request) - return "write-result" - - result = asyncio.run( - interceptor( - MCPToolCallRequest( - name="create_issue", - args={"title": "Checkout 500s"}, - server_name="linear", - ), - handler, - ) - ) - - assert handler_calls == [] - assert interrupt_calls == [ - { - "action": "confirm_write", - "args": { - "action": "Create issue", - "detail": '{"title": "Checkout 500s"}', - }, - } - ] - assert result.content[0].text == "Write cancelled by the user; no changes were made." - - -def test_write_confirmation_interceptor_runs_an_approved_mutation(monkeypatch): - handler_calls = [] - interceptor = tools.WriteConfirmationInterceptor() - monkeypatch.setattr( - tools, - "copilotkit_interrupt", - lambda **_kwargs: ('{"confirmed": true}', {"confirmed": True}), - ) - - async def handler(request): - handler_calls.append(request) - return "write-result" - - request = MCPToolCallRequest( - name="update_issue", - args={"id": "CPK-9", "title": "Checkout 500s"}, - server_name="linear", - ) - result = asyncio.run(interceptor(request, handler)) - - assert result == "write-result" - assert handler_calls == [request] - - -def test_write_confirmation_interceptor_rejects_a_malformed_resume( - monkeypatch, -): - handler_calls = [] - interceptor = tools.WriteConfirmationInterceptor() - monkeypatch.setattr( - tools, - "copilotkit_interrupt", - lambda **_kwargs: ("unexpected", {"unexpected": True}), - ) - - async def handler(request): - handler_calls.append(request) - return "write-result" - - with pytest.raises(RuntimeError, match="confirmed"): - asyncio.run( - interceptor( - MCPToolCallRequest( - name="create_issue", - args={"title": "Checkout 500s"}, - server_name="linear", - ), - handler, - ) - ) - - assert handler_calls == [] diff --git a/agent/tests/test_write_confirmation.py b/agent/tests/test_write_confirmation.py new file mode 100644 index 0000000..0f84d70 --- /dev/null +++ b/agent/tests/test_write_confirmation.py @@ -0,0 +1,184 @@ +import asyncio + +import copilotkit.langgraph +import pytest +import write_confirmation +from langchain_core.tools import StructuredTool +from langchain_mcp_adapters.interceptors import MCPToolCallRequest + + +def test_write_confirmation_emits_the_copilotkit_interrupt_envelope(monkeypatch): + calls = [] + handler_calls = [] + + def fake_interrupt(value): + calls.append(value) + return {"confirmed": True} + + monkeypatch.setattr(copilotkit.langgraph, "interrupt", fake_interrupt) + + async def handler(request): + handler_calls.append(request) + return "write-result" + + request = MCPToolCallRequest( + name="create_issue", + args={"title": "Checkout 500s"}, + server_name="linear", + ) + result = asyncio.run( + write_confirmation.WriteConfirmationInterceptor()(request, handler) + ) + + assert result == "write-result" + assert handler_calls == [request] + assert len(calls) == 1 + assert set(calls[0]) == { + "__copilotkit_interrupt_value__", + "__copilotkit_messages__", + } + assert calls[0]["__copilotkit_interrupt_value__"] == { + "action": "confirm_write", + "args": { + "action": "Create issue", + "detail": '{"title": "Checkout 500s"}', + }, + } + + +def test_write_confirmation_interceptor_leaves_annotated_reads_unguarded( + monkeypatch, +): + interrupt_calls = [] + handler_calls = [] + + async def read_issue(issue_id: str): + return issue_id + + read_tool = StructuredTool.from_function( + coroutine=read_issue, + name="get_issue", + description="Read an issue", + metadata={"readOnlyHint": True}, + ) + interceptor = write_confirmation.WriteConfirmationInterceptor() + interceptor.register_tools([read_tool]) + monkeypatch.setattr( + write_confirmation, + "copilotkit_interrupt", + lambda **kwargs: interrupt_calls.append(kwargs), + ) + + async def handler(request): + handler_calls.append(request) + return "read-result" + + result = asyncio.run( + interceptor( + MCPToolCallRequest( + name="get_issue", + args={"issue_id": "CPK-9"}, + server_name="linear", + ), + handler, + ) + ) + + assert result == "read-result" + assert len(handler_calls) == 1 + assert interrupt_calls == [] + + +def test_write_confirmation_interceptor_blocks_a_declined_mutation(monkeypatch): + interrupt_calls = [] + handler_calls = [] + interceptor = write_confirmation.WriteConfirmationInterceptor() + + def decline(**kwargs): + interrupt_calls.append(kwargs) + return '{"confirmed": false}', {"confirmed": False} + + monkeypatch.setattr(write_confirmation, "copilotkit_interrupt", decline) + + async def handler(request): + handler_calls.append(request) + return "write-result" + + result = asyncio.run( + interceptor( + MCPToolCallRequest( + name="create_issue", + args={"title": "Checkout 500s"}, + server_name="linear", + ), + handler, + ) + ) + + assert handler_calls == [] + assert interrupt_calls == [ + { + "action": "confirm_write", + "args": { + "action": "Create issue", + "detail": '{"title": "Checkout 500s"}', + }, + } + ] + assert result.content[0].text == ( + "Write cancelled by the user; no changes were made." + ) + + +def test_write_confirmation_interceptor_runs_an_approved_mutation(monkeypatch): + handler_calls = [] + interceptor = write_confirmation.WriteConfirmationInterceptor() + monkeypatch.setattr( + write_confirmation, + "copilotkit_interrupt", + lambda **_kwargs: ('{"confirmed": true}', {"confirmed": True}), + ) + + async def handler(request): + handler_calls.append(request) + return "write-result" + + request = MCPToolCallRequest( + name="update_issue", + args={"id": "CPK-9", "title": "Checkout 500s"}, + server_name="linear", + ) + result = asyncio.run(interceptor(request, handler)) + + assert result == "write-result" + assert handler_calls == [request] + + +def test_write_confirmation_interceptor_rejects_a_malformed_resume( + monkeypatch, +): + handler_calls = [] + interceptor = write_confirmation.WriteConfirmationInterceptor() + monkeypatch.setattr( + write_confirmation, + "copilotkit_interrupt", + lambda **_kwargs: ("unexpected", {"unexpected": True}), + ) + + async def handler(request): + handler_calls.append(request) + return "write-result" + + with pytest.raises(RuntimeError, match="confirmed"): + asyncio.run( + interceptor( + MCPToolCallRequest( + name="create_issue", + args={"title": "Checkout 500s"}, + server_name="linear", + ), + handler, + ) + ) + + assert handler_calls == [] diff --git a/agent/tools.py b/agent/tools.py index 8198607..29f7423 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -9,138 +9,11 @@ """ import os -import asyncio -import json -import logging -from datetime import datetime, timezone from typing import Any from concurrent.futures import ThreadPoolExecutor -from langchain_core.tools import BaseTool, tool +from langchain_core.tools import tool from langchain_core.messages import HumanMessage from tavily import TavilyClient -from langchain_mcp_adapters.client import MultiServerMCPClient -from langchain_mcp_adapters.interceptors import ( - MCPToolCallRequest, - MCPToolCallResult, -) -from copilotkit.langgraph import copilotkit_interrupt -from mcp.types import CallToolResult, TextContent - - -logger = logging.getLogger(__name__) - - -def _emit_internal_source_error( - error: BaseException, - *, - message: str, - source: str, - recovery: str, -) -> None: - """Emit an observable error without logging credentials or server details.""" - logger.error( - { - "error": message, - "context": { - "component": "internal_source_tools", - "exception_type": type(error).__name__, - "recovery": recovery, - "source": source, - }, - "timestamp": datetime.now(timezone.utc).isoformat(), - } - ) - - -@tool -def confirm_write(action: str, detail: str | None = None): - """Pause before a Linear or Notion write and ask the user to approve it. - - Args: - action: One-line summary of exactly what will be written. - detail: Optional drafted title, description, or outline. - - Returns: - The user's resume response after the confirmation interrupt. - """ - answer, _response = copilotkit_interrupt( - action="confirm_write", - args={"action": action, "detail": detail}, - ) - return answer - - -class WriteConfirmationInterceptor: - """Enforce confirmation inside every mutating internal-source tool call. - - MCP tool annotations are the authority for read-only calls. Anything not - explicitly marked read-only fails closed and pauses before the remote tool - handler can run. The two Notion search operations below use POST while - remaining read-only, so its OpenAPI bridge cannot infer their annotation. - """ - - _KNOWN_READ_ONLY_TOOLS = { - "API-post-search", - "API-query-data-source", - } - - def __init__(self): - self._read_only_tools = set(self._KNOWN_READ_ONLY_TOOLS) - - def register_tools(self, tools: list[BaseTool]) -> None: - for source_tool in tools: - metadata = source_tool.metadata or {} - if metadata.get("readOnlyHint") is True: - self._read_only_tools.add(source_tool.name) - - async def __call__( - self, - request: MCPToolCallRequest, - handler, - ) -> MCPToolCallResult: - if request.name in self._read_only_tools: - return await handler(request) - - action = request.name.replace("_", " ").replace("-", " ").strip() - action = action[:1].upper() + action[1:] - detail = json.dumps( - request.args, - ensure_ascii=False, - sort_keys=True, - default=str, - ) - _answer, response = copilotkit_interrupt( - action="confirm_write", - args={"action": action, "detail": detail}, - ) - - if isinstance(response, str): - try: - response = json.loads(response) - except json.JSONDecodeError: - response = None - - if ( - not isinstance(response, dict) - or not isinstance(response.get("confirmed"), bool) - ): - raise RuntimeError( - "confirm_write resume must contain a boolean `confirmed` value" - ) - - if response["confirmed"] is False: - return CallToolResult( - content=[ - TextContent( - type="text", - text=( - "Write cancelled by the user; no changes were made." - ), - ) - ] - ) - - return await handler(request) def _do_internet_search(query: str, max_results: int = 5) -> list[dict[str, Any]]: @@ -289,104 +162,3 @@ def internet_search(query: str, max_results: int = 5): print(f"[TOOL] research: completed with {len(result['sources'])} sources") return result - - -def internal_source_tools() -> list: - """Notion + Linear MCP tools, included only when their env is present. - - Reuses OpenTag's Phase-1 MCP servers so the agent can research the team's - own docs/issues alongside the web. Linear uses its hosted MCP server - (`LINEAR_API_KEY`, with `LINEAR_MCP_URL` defaulting to `mcp.linear.app`); - Notion uses the optional local MCP sidecar (`NOTION_MCP_AUTH_TOKEN`, with - `NOTION_MCP_URL` defaulting to localhost). Each server is independently - optional. - - `langchain_mcp_adapters`'s `MultiServerMCPClient.get_tools()` is - async-only; this function runs it to completion on a short-lived event - loop (`asyncio.run`) so it can be called synchronously from - `build_agent()`. Each server is connected individually so one server - failing to load never drops the other's tools. These integrations are - optional deployment enhancements, so a down or misconfigured server emits - a structured error event and is skipped instead of blocking agent startup. - - Returns: - list: LangChain tools from the configured MCP server(s), or an empty - list when neither integration is configured (or every configured - optional integration emitted an error and was skipped). - """ - connections: dict[str, dict[str, Any]] = {} - - linear_api_key = os.environ.get("LINEAR_API_KEY") - if linear_api_key: - connections["linear"] = { - "transport": "streamable_http", - "url": os.environ.get("LINEAR_MCP_URL", "https://mcp.linear.app/mcp"), - "headers": {"Authorization": f"Bearer {linear_api_key}"}, - } - - notion_auth_token = os.environ.get("NOTION_MCP_AUTH_TOKEN") - if notion_auth_token: - connections["notion"] = { - "transport": "streamable_http", - "url": os.environ.get("NOTION_MCP_URL", "http://127.0.0.1:3001/mcp"), - "headers": {"Authorization": f"Bearer {notion_auth_token}"}, - } - - if not connections: - return [] - - async def _load_all() -> list: - loaded: list = [] - for name, connection in connections.items(): - try: - confirmation = WriteConfirmationInterceptor() - server_client = MultiServerMCPClient( - {name: connection}, - tool_interceptors=[confirmation], - ) - # Bound connect time so a hung MCP server (accepts the - # socket but never responds) can't stall build_agent() - - # and thus the whole app, including /health - at import - # time. - server_tools = await asyncio.wait_for( - server_client.get_tools(), timeout=8.0 - ) - confirmation.register_tools(server_tools) - loaded.extend(server_tools) - print( - f"[TOOLS] internal_source_tools: loaded {len(server_tools)} " - f"tool(s) from {name}" - ) - except asyncio.TimeoutError as error: - # Optional MCP integrations must not block the core agent, but - # a configured source failing is distinct from an intentionally - # disabled source and must remain observable. - _emit_internal_source_error( - error, - message="Configured MCP server timed out while loading tools", - source=name, - recovery="skip_optional_integration", - ) - except Exception as error: - _emit_internal_source_error( - error, - message=( - "Configured MCP server was unavailable while loading tools" - ), - source=name, - recovery="skip_optional_integration", - ) - return loaded - - try: - return asyncio.run(_load_all()) - except Exception as error: - # Loading optional integrations is recoverable for the core agent, but - # event-loop failures still surface as structured operational errors. - _emit_internal_source_error( - error, - message="Failed to initialize MCP tool loading", - source="all", - recovery="skip_optional_integrations", - ) - return [] diff --git a/agent/write_confirmation.py b/agent/write_confirmation.py new file mode 100644 index 0000000..d8a3c7c --- /dev/null +++ b/agent/write_confirmation.py @@ -0,0 +1,82 @@ +"""Approval enforcement for mutating MCP tools.""" + +import json + +from copilotkit.langgraph import copilotkit_interrupt +from langchain_core.tools import BaseTool +from langchain_mcp_adapters.interceptors import ( + MCPToolCallRequest, + MCPToolCallResult, +) +from mcp.types import CallToolResult, TextContent + + +class WriteConfirmationInterceptor: + """Require an explicit user confirmation before any MCP mutation runs. + + MCP annotations identify read-only operations. Unknown or unannotated + operations fail closed and pause before their remote handler is invoked. + The two Notion search operations below use POST while remaining read-only, + so the OpenAPI bridge cannot infer their annotation. + """ + + _KNOWN_READ_ONLY_TOOLS = { + "API-post-search", + "API-query-data-source", + } + + def __init__(self): + self._read_only_tools = set(self._KNOWN_READ_ONLY_TOOLS) + + def register_tools(self, tools: list[BaseTool]) -> None: + for source_tool in tools: + metadata = source_tool.metadata or {} + if metadata.get("readOnlyHint") is True: + self._read_only_tools.add(source_tool.name) + + async def __call__( + self, + request: MCPToolCallRequest, + handler, + ) -> MCPToolCallResult: + if request.name in self._read_only_tools: + return await handler(request) + + action = request.name.replace("_", " ").replace("-", " ").strip() + action = action[:1].upper() + action[1:] + detail = json.dumps( + request.args, + ensure_ascii=False, + sort_keys=True, + default=str, + ) + _answer, response = copilotkit_interrupt( + action="confirm_write", + args={"action": action, "detail": detail}, + ) + + if isinstance(response, str): + try: + response = json.loads(response) + except json.JSONDecodeError: + response = None + + if ( + not isinstance(response, dict) + or not isinstance(response.get("confirmed"), bool) + ): + raise RuntimeError( + "confirm_write resume must contain a boolean `confirmed` value" + ) + + if response["confirmed"] is False: + return CallToolResult( + content=[ + TextContent( + type="text", + text="Write cancelled by the user; no changes were made.", + ) + ] + ) + + return await handler(request) diff --git a/app/agent.test.ts b/app/agent.test.ts deleted file mode 100644 index 666089d..0000000 --- a/app/agent.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { AgentContentPart, IncomingMessage } from "@copilotkit/channels"; -import { - defaultSlackContext, - defaultSlackTools, - SanitizingHttpAgent, -} from "@copilotkit/channels/slack"; -import { - buildAgentHeaders, - createAgentFactory, - mentionRunInput, - promptFromMessage, -} from "./agent.js"; - -const message = ( - overrides: Partial<IncomingMessage> = {}, -): IncomingMessage => ({ - text: "hello", - user: { id: "U1", name: "Ada" }, - ref: { id: "m1" }, - platform: "slack", - ...overrides, -}); - -describe("promptFromMessage", () => { - it("prefers non-empty content parts", () => { - const parts: AgentContentPart[] = [{ type: "text", text: "hello" }]; - expect(promptFromMessage(message({ contentParts: parts }))).toBe(parts); - }); - - it("falls back to text when content parts are absent or empty", () => { - expect(promptFromMessage(message())).toBe("hello"); - expect(promptFromMessage(message({ contentParts: [] }))).toBe("hello"); - }); -}); - -describe("mentionRunInput", () => { - it("passes the current managed message explicitly and uses its source platform for sender context", () => { - const parts: AgentContentPart[] = [{ type: "text", text: "from parts" }]; - - expect( - mentionRunInput(message({ contentParts: parts }), "intelligence"), - ).toEqual({ - prompt: parts, - tools: defaultSlackTools, - context: [ - ...defaultSlackContext, - { - description: "Requesting slack user", - value: "Ada (slack id U1)", - }, - ], - }); - }); - - it("does not duplicate the current prompt for direct adapter ingress", () => { - expect(mentionRunInput(message(), "slack")).toEqual({ - tools: defaultSlackTools, - context: [ - ...defaultSlackContext, - { - description: "Requesting slack user", - value: "Ada (slack id U1)", - }, - ], - }); - }); -}); - -describe("buildAgentHeaders", () => { - it("adds an Authorization header only when configured", () => { - expect(buildAgentHeaders(undefined)).toBeUndefined(); - expect(buildAgentHeaders("Bearer secret")).toEqual({ - Authorization: "Bearer secret", - }); - }); -}); - -describe("createAgentFactory", () => { - it("creates a fresh sanitizing AG-UI agent per thread with optional auth", () => { - const factory = createAgentFactory({ - url: "http://localhost:8123/", - authHeader: "Bearer secret", - }); - - const first = factory("thread-1"); - const second = factory("thread-2"); - - expect(first).toBeInstanceOf(SanitizingHttpAgent); - expect(second).toBeInstanceOf(SanitizingHttpAgent); - expect(second).not.toBe(first); - expect(first).toMatchObject({ - url: "http://localhost:8123/", - headers: { Authorization: "Bearer secret" }, - threadId: "thread-1", - }); - expect(second.threadId).toBe("thread-2"); - }); -}); diff --git a/app/channel-helpers.test.ts b/app/channel-helpers.test.ts new file mode 100644 index 0000000..8474ab2 --- /dev/null +++ b/app/channel-helpers.test.ts @@ -0,0 +1,69 @@ +import type { AgentContentPart, IncomingMessage } from "@copilotkit/channels"; +import { describe, expect, it } from "vitest"; +import { + defaultSlackContext, + defaultSlackTools, +} from "@copilotkit/channels/slack"; +import { + managedRunInput, + promptFromMessage, +} from "./channel-helpers.js"; + +const message = ( + overrides: Partial<IncomingMessage> = {}, +): IncomingMessage => ({ + text: "hello", + user: { id: "U1", name: "Ada" }, + ref: { id: "m1" }, + platform: "slack", + ...overrides, +}); + +describe("promptFromMessage", () => { + it("prefers non-empty content parts", () => { + const parts: AgentContentPart[] = [{ type: "text", text: "hello" }]; + expect(promptFromMessage(message({ contentParts: parts }))).toBe(parts); + }); + + it("falls back to text when content parts are absent or empty", () => { + expect(promptFromMessage(message())).toBe("hello"); + expect(promptFromMessage(message({ contentParts: [] }))).toBe("hello"); + }); +}); + +describe("managedRunInput", () => { + it("adds the in-flight prompt and Slack-specific defaults", () => { + const parts: AgentContentPart[] = [{ type: "text", text: "from parts" }]; + + expect(managedRunInput(message({ contentParts: parts }))).toEqual({ + prompt: parts, + tools: defaultSlackTools, + context: [ + ...defaultSlackContext, + { + description: "Requesting slack user", + value: "Ada (slack id U1)", + }, + ], + }); + }); + + it("keeps managed Teams free of Slack-specific defaults", () => { + expect( + managedRunInput( + message({ + platform: "teams", + user: { id: "T1", name: "Ada" }, + }), + ), + ).toEqual({ + prompt: "hello", + context: [ + { + description: "Requesting teams user", + value: "Ada (teams id T1)", + }, + ], + }); + }); +}); diff --git a/app/agent.ts b/app/channel-helpers.ts similarity index 52% rename from app/agent.ts rename to app/channel-helpers.ts index e5b2a9a..d946859 100644 --- a/app/agent.ts +++ b/app/channel-helpers.ts @@ -8,48 +8,26 @@ import type { import { defaultSlackContext, defaultSlackTools, - SanitizingHttpAgent, } from "@copilotkit/channels/slack"; import { senderContext } from "./sender-context.js"; +/** + * Managed history does not include the in-flight turn, so pass it explicitly. + * Multimodal content parts take precedence over the text fallback. + */ export function promptFromMessage( message: Pick<IncomingMessage, "contentParts" | "text">, ): string | AgentContentPart[] { return message.contentParts?.length ? message.contentParts : message.text; } -export function buildAgentHeaders( - authHeader?: string, -): { Authorization: string } | undefined { - return authHeader ? { Authorization: authHeader } : undefined; -} - -export function createAgentFactory(options: { - url: string; - authHeader?: string; -}): (threadId: string) => SanitizingHttpAgent { - return (threadId) => { - const agent = new SanitizingHttpAgent({ - url: options.url, - headers: buildAgentHeaders(options.authHeader), - }); - agent.threadId = threadId; - return agent; - }; -} - -export function mentionRunInput( - message: IncomingMessage, - transportPlatform: string, -): { - prompt?: string | AgentContentPart[]; - tools?: ChannelTool[]; - context: ContextEntry[]; -} { +/** + * Add only the source platform's defaults. Intelligence is the transport; + * `message.platform` is the originating provider (`slack` or `teams`). + */ +export function managedRunInput(message: IncomingMessage) { return { - ...(transportPlatform === "intelligence" - ? { prompt: promptFromMessage(message) } - : {}), + prompt: promptFromMessage(message), ...platformRunInput(message.platform, message.user), }; } @@ -62,6 +40,7 @@ export function platformRunInput( context: ContextEntry[]; } { const slack = platform === "slack"; + return { ...(slack ? { tools: [...defaultSlackTools] } : {}), context: [ @@ -70,3 +49,14 @@ export function platformRunInput( ], }; } + +export function reportRecoverableError( + error: unknown, + context: { operation: string; recovery: string }, +): void { + console.error("[channel] recoverable error", { + error: error instanceof Error ? error : new Error(String(error)), + context, + timestamp: new Date().toISOString(), + }); +} diff --git a/app/channel.test.ts b/app/channel.test.ts index 2021883..6c47347 100644 --- a/app/channel.test.ts +++ b/app/channel.test.ts @@ -88,27 +88,24 @@ afterEach(async () => { }); function makeChannel(options: { - platform: "intelligence" | "slack" | "teams"; + provider: "slack" | "teams"; agent?: FakeAgent; }) { - const adapter = new FakeAdapter({ platform: options.platform }); + const adapter = new FakeAdapter({ platform: "intelligence" }); const agent = options.agent ?? new CapturingAgent(); - const channel = createOpenTagChannel({ - name: "opentag", - adapters: [adapter], - agent, - }); + const channel = createOpenTagChannel("opentag", options.provider, agent); + channel.ɵruntime.addAdapter(adapter); channels.push(channel); return { adapter, agent, channel }; } describe("createOpenTagChannel", () => { it("declares managed Slack and retains app commands", () => { - const channel = createOpenTagChannel({ - name: "custom-channel", - adapters: [], - agent: new FakeAgent(), - }); + const channel = createOpenTagChannel( + "custom-channel", + "slack", + new FakeAgent(), + ); channels.push(channel); expect(channel.name).toBe("custom-channel"); @@ -125,7 +122,7 @@ describe("createOpenTagChannel", () => { it("injects Slack defaults per managed Slack run", async () => { const { adapter, agent, channel } = makeChannel({ - platform: "intelligence", + provider: "slack", }); await channel.ɵruntime.start(); @@ -151,19 +148,13 @@ describe("createOpenTagChannel", () => { ]); }); - it("does not leak Slack defaults into Teams when both direct adapters share the Channel", async () => { - const slackAdapter = new FakeAdapter({ platform: "slack" }); - const teamsAdapter = new FakeAdapter({ platform: "teams" }); - const agent = new CapturingAgent(); - const channel = createOpenTagChannel({ - name: "opentag", - adapters: [slackAdapter, teamsAdapter], - agent, + it("does not inject Slack defaults into managed Teams", async () => { + const { adapter, agent, channel } = makeChannel({ + provider: "teams", }); - channels.push(channel); await channel.ɵruntime.start(); - await teamsAdapter.getSink().onTurn({ + await adapter.getSink().onTurn({ conversationKey: "c1", replyTarget: {}, userText: "hello", @@ -171,7 +162,7 @@ describe("createOpenTagChannel", () => { user: { id: "T1", name: "Ada" }, }); - const call = agent.calls[0]; + const call = (agent as CapturingAgent).calls[0]; expect(call?.tools?.map(({ name }) => name).sort()).toEqual( appTools.map(({ name }) => name).sort(), ); @@ -186,7 +177,7 @@ describe("createOpenTagChannel", () => { it("injects managed content parts as the current agent prompt", async () => { const { adapter, agent, channel } = makeChannel({ - platform: "intelligence", + provider: "slack", }); const parts = [{ type: "text" as const, text: "from content parts" }]; @@ -205,23 +196,8 @@ describe("createOpenTagChannel", () => { ); }); - it("does not inject a duplicate prompt for direct adapter ingress", async () => { - const { adapter, agent, channel } = makeChannel({ platform: "slack" }); - - await channel.ɵruntime.start(); - await adapter.getSink().onTurn({ - conversationKey: "c1", - replyTarget: {}, - userText: "already in direct history", - platform: "slack", - user: { id: "U1" }, - }); - - expect(agent.messages).toEqual([]); - }); - it("personalizes suggested prompts when a thread starts", async () => { - const { adapter, channel } = makeChannel({ platform: "slack" }); + const { adapter, channel } = makeChannel({ provider: "slack" }); await channel.ɵruntime.start(); await adapter.emitThreadStarted({ @@ -239,7 +215,7 @@ describe("createOpenTagChannel", () => { const consoleError = vi .spyOn(console, "error") .mockImplementation(() => undefined); - const { adapter, channel } = makeChannel({ platform: "slack" }); + const { adapter, channel } = makeChannel({ provider: "slack" }); adapter.setSuggestedPrompts = vi.fn(async () => { throw new Error("suggested prompts unavailable"); }); @@ -270,7 +246,7 @@ describe("createOpenTagChannel", () => { .spyOn(console, "error") .mockImplementation(() => undefined); const { adapter, channel } = makeChannel({ - platform: "slack", + provider: "slack", agent: new FakeAgent([ () => { throw error; @@ -314,7 +290,7 @@ describe("createOpenTagChannel", () => { }, ]); const { adapter, channel } = makeChannel({ - platform: "intelligence", + provider: "slack", agent, }); @@ -363,7 +339,7 @@ describe("createOpenTagChannel", () => { }, ]); const { adapter, channel } = makeChannel({ - platform: "intelligence", + provider: "slack", agent, }); @@ -396,11 +372,12 @@ describe("createOpenTagChannel", () => { } as never); }, ]); - const firstChannel = createOpenTagChannel({ - name: "opentag", - adapters: [firstAdapter], - agent: firstAgent, - }); + const firstChannel = createOpenTagChannel( + "opentag", + "slack", + firstAgent, + ); + firstChannel.ɵruntime.addAdapter(firstAdapter); channels.push(firstChannel); await firstChannel.ɵruntime.start(); await firstAdapter.getSink().onTurn({ @@ -422,11 +399,12 @@ describe("createOpenTagChannel", () => { const secondAdapter = new FakeAdapter({ platform: "intelligence" }); secondAdapter.stateStore = sharedState; const secondAgent = new FakeAgent(); - const secondChannel = createOpenTagChannel({ - name: "opentag", - adapters: [secondAdapter], - agent: secondAgent, - }); + const secondChannel = createOpenTagChannel( + "opentag", + "slack", + secondAgent, + ); + secondChannel.ɵruntime.addAdapter(secondAdapter); channels.push(secondChannel); await secondChannel.ɵruntime.start(); await secondAdapter.getSink().onInteraction({ diff --git a/app/channel.tsx b/app/channel.tsx index 7498492..a8b347a 100644 --- a/app/channel.tsx +++ b/app/channel.tsx @@ -1,10 +1,13 @@ -import type { AbstractAgent } from "@ag-ui/client"; import { createChannel, type Channel, - type PlatformAdapter, + type CreateChannelOptions, + type ManagedChannelProvider, } from "@copilotkit/channels"; -import { mentionRunInput } from "./agent.js"; +import { + managedRunInput, + reportRecoverableError, +} from "./channel-helpers.js"; import { appCommands } from "./commands/index.js"; import { IssueCard, IssueList, PageList } from "./components/index.js"; import { appContext } from "./context/app-context.js"; @@ -13,35 +16,18 @@ import { parseConfirmWriteInterrupt } from "./interrupt.js"; import { FILE_ISSUE_CALLBACK, fileIssueSubmit } from "./modals/file-issue.js"; import { appTools } from "./tools/index.js"; -function reportRecoverableError( - error: unknown, - context: { operation: string; recovery: string }, -): void { - console.error("[channel] recoverable error", { - error: error instanceof Error ? error : new Error(String(error)), - context, - timestamp: new Date().toISOString(), - }); -} - -export interface CreateOpenTagChannelOptions { - name: string; - adapters: PlatformAdapter[]; - agent: AbstractAgent | ((threadId: string) => AbstractAgent); -} +type ChannelAgent = NonNullable<CreateChannelOptions["agent"]>; -/** - * Build an OpenTag Channel with the same handlers and UI for either managed - * Intelligence delivery or one developer-owned direct adapter. - */ +/** Build one managed OpenTag Channel for an Intelligence-owned provider. */ export function createOpenTagChannel( - options: CreateOpenTagChannelOptions, + name: string, + provider: ManagedChannelProvider, + agent: ChannelAgent, ): Channel { const channel = createChannel({ - name: options.name, - provider: "slack", - adapters: options.adapters, - agent: options.agent, + name, + provider, + agent, tools: appTools, context: [...appContext], commands: appCommands, @@ -50,7 +36,7 @@ export function createOpenTagChannel( channel.onMention(async ({ thread, message }) => { try { - await thread.runAgent(mentionRunInput(message, thread.platform)); + await thread.runAgent(managedRunInput(message)); } catch (error) { try { await thread.post( diff --git a/app/commands/index.ts b/app/commands/index.ts index bb64fb1..79f4548 100644 --- a/app/commands/index.ts +++ b/app/commands/index.ts @@ -15,7 +15,7 @@ import { type ChannelCommand, type Thread as ChannelThread, } from "@copilotkit/channels"; -import { platformRunInput } from "../agent.js"; +import { platformRunInput } from "../channel-helpers.js"; import { IssueCard } from "../components/index.js"; import { FileIssueModal } from "../modals/file-issue.js"; diff --git a/app/components/index.ts b/app/components/index.ts index d9d3faa..5a324f3 100644 --- a/app/components/index.ts +++ b/app/components/index.ts @@ -1,10 +1,9 @@ /** * App-specific render components — agent-renderable Block Kit cards authored - * with the `@copilotkit/channels` JSX vocabulary. + * with the umbrella `@copilotkit/channels` package's JSX vocabulary. * * Each component is a plain `ComponentFn` returning a `<Message>` tree; its - * exported zod prop schema doubles as the render-tool input schema. Render a - * component with `renderSlackMessage(renderToIR(<IssueCard {...props} />))`. + * exported zod prop schema doubles as the render-tool input schema. */ export { IssueCard, issueCardSchema } from "./issue-card.js"; export type { IssueCardProps } from "./issue-card.js"; diff --git a/app/env.test.ts b/app/env.test.ts index 2a660c3..6ea12e4 100644 --- a/app/env.test.ts +++ b/app/env.test.ts @@ -33,7 +33,6 @@ describe("readEnvironment", () => { intelligenceGatewayWsUrl: DEFAULT_INTELLIGENCE_GATEWAY_WS_URL, channelName: DEFAULT_INTELLIGENCE_CHANNEL_NAME, port: 3000, - teamsPort: 3978, }); }); @@ -52,46 +51,17 @@ describe("readEnvironment", () => { }); }); - it("reads direct Teams credentials and validates its listener port", () => { - expect( - readEnvironment({ - ...requiredEnvironment, - TEAMS_CLIENT_ID: "teams-client", - TEAMS_CLIENT_SECRET: "teams-secret", - TEAMS_TENANT_ID: "teams-tenant", - TEAMS_PORT: "4978", - }), - ).toMatchObject({ - teamsClientId: "teams-client", - teamsClientSecret: "teams-secret", - teamsTenantId: "teams-tenant", - teamsPort: 4978, + it("does not expose platform credentials owned by Intelligence", () => { + const environment = readEnvironment({ + ...requiredEnvironment, + SLACK_BOT_TOKEN: "xoxb-unused", + TEAMS_CLIENT_ID: "teams-unused", }); - expect(() => - readEnvironment({ - ...requiredEnvironment, - TEAMS_PORT: "bad", - }), - ).toThrow('Invalid TEAMS_PORT: "bad"'); + expect(environment).not.toHaveProperty("slackBotToken"); + expect(environment).not.toHaveProperty("teamsClientId"); + expect(environment).not.toHaveProperty("teamsPort"); }); - - it.each([ - ["SLACK_BOT_TOKEN", { SLACK_BOT_TOKEN: "xoxb-test" }], - ["SLACK_APP_TOKEN", { SLACK_APP_TOKEN: "xapp-test" }], - ["TEAMS_CLIENT_ID", { TEAMS_CLIENT_ID: "teams-client" }], - ["TEAMS_CLIENT_SECRET", { TEAMS_CLIENT_SECRET: "teams-secret" }], - ])( - "rejects an incomplete direct-adapter credential pair containing only %s", - (_name, partialCredentials) => { - expect(() => - readEnvironment({ - ...requiredEnvironment, - ...partialCredentials, - }), - ).toThrow(/must be set together/i); - }, - ); }); describe("parsePort", () => { diff --git a/app/env.ts b/app/env.ts index 07178b4..a19742e 100644 --- a/app/env.ts +++ b/app/env.ts @@ -12,12 +12,6 @@ export interface AppEnvironment { intelligenceGatewayWsUrl: string; channelName: string; port: number; - slackBotToken?: string; - slackAppToken?: string; - teamsClientId?: string; - teamsClientSecret?: string; - teamsTenantId?: string; - teamsPort: number; } function required(env: NodeJS.ProcessEnv, name: string): string { @@ -28,16 +22,6 @@ function required(env: NodeJS.ProcessEnv, name: string): string { return value; } -function requireTogether( - env: NodeJS.ProcessEnv, - first: string, - second: string, -): void { - if (Boolean(env[first]) !== Boolean(env[second])) { - throw new Error(`${first} and ${second} must be set together`); - } -} - export function parsePort( raw: string | undefined, defaultPort = 3000, @@ -55,9 +39,6 @@ export function parsePort( export function readEnvironment( env: NodeJS.ProcessEnv = process.env, ): AppEnvironment { - requireTogether(env, "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"); - requireTogether(env, "TEAMS_CLIENT_ID", "TEAMS_CLIENT_SECRET"); - return { agentUrl: required(env, "AGENT_URL"), agentAuthHeader: env.AGENT_AUTH_HEADER, @@ -70,11 +51,5 @@ export function readEnvironment( channelName: env.INTELLIGENCE_CHANNEL_NAME ?? DEFAULT_INTELLIGENCE_CHANNEL_NAME, port: parsePort(env.PORT), - slackBotToken: env.SLACK_BOT_TOKEN, - slackAppToken: env.SLACK_APP_TOKEN, - teamsClientId: env.TEAMS_CLIENT_ID, - teamsClientSecret: env.TEAMS_CLIENT_SECRET, - teamsTenantId: env.TEAMS_TENANT_ID, - teamsPort: parsePort(env.TEAMS_PORT, 3978, "TEAMS_PORT"), }; } diff --git a/app/index.ts b/app/index.ts new file mode 100644 index 0000000..a2e755d --- /dev/null +++ b/app/index.ts @@ -0,0 +1,30 @@ +import { SanitizingHttpAgent } from "@copilotkit/channels/slack"; +import { createOpenTagChannel } from "./channel.js"; +import { readEnvironment, type AppEnvironment } from "./env.js"; +import { createOpenTagRuntime } from "./runtime-host.js"; + +export function createOpenTagApplication( + environment: AppEnvironment = readEnvironment(), +) { + // Channels agents are stateful, so each conversation gets its own SDK agent. + const agent = (threadId: string) => { + const instance = new SanitizingHttpAgent({ + url: environment.agentUrl, + headers: environment.agentAuthHeader + ? { Authorization: environment.agentAuthHeader } + : undefined, + }); + instance.threadId = threadId; + return instance; + }; + + // Managed providers are declared per Channel. Intelligence owns the Slack and + // Teams credentials and routes each provider to its matching declaration. + const channels = [ + createOpenTagChannel(environment.channelName, "slack", agent), + createOpenTagChannel(`${environment.channelName}-teams`, "teams", agent), + ]; + const runtimeHost = createOpenTagRuntime({ environment, channels }); + + return { channels, environment, ...runtimeHost }; +} diff --git a/app/modals/file-issue.tsx b/app/modals/file-issue.tsx index 5868124..97af3f2 100644 --- a/app/modals/file-issue.tsx +++ b/app/modals/file-issue.tsx @@ -2,9 +2,8 @@ * Modal demo — a structured "file a Linear issue" form. Beats parsing free text: * the fields come back typed and validated by the platform. * - * Slack receives the rich form. Platforms without modal support (including the - * current direct Teams adapter) are detected by the `/file-issue` command and - * fall back to the same conversational filing flow. + * Slack receives the rich form. Platforms without modal support are detected + * by the `/file-issue` command and fall back to the same conversational flow. */ import { Modal, @@ -14,7 +13,7 @@ import { RadioButtons, } from "@copilotkit/channels"; import type { ModalSubmitHandler, ModalView } from "@copilotkit/channels"; -import { platformRunInput } from "../agent.js"; +import { platformRunInput } from "../channel-helpers.js"; export const FILE_ISSUE_CALLBACK = "file_issue"; diff --git a/app/platforms.test.ts b/app/platforms.test.ts deleted file mode 100644 index dce4220..0000000 --- a/app/platforms.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { readEnvironment } from "./env.js"; -import { resolvePlatforms } from "./platforms.js"; - -const environment = ( - overrides: NodeJS.ProcessEnv = {}, -) => - readEnvironment({ - AGENT_URL: "http://localhost:8123/", - INTELLIGENCE_API_KEY: "cpk_test", - ...overrides, - }); - -describe("resolvePlatforms", () => { - it("leaves managed delivery adapter-free", () => { - const setup = resolvePlatforms(environment()); - - expect(setup).toEqual({ adapters: [] }); - }); - - it("constructs direct Slack only when both Slack credentials exist", () => { - const setup = resolvePlatforms( - environment({ - SLACK_BOT_TOKEN: "xoxb-test", - SLACK_APP_TOKEN: "xapp-test", - }), - ); - - expect(setup.adapters.map(({ platform }) => platform)).toEqual(["slack"]); - expect(setup).not.toHaveProperty("tools"); - expect(setup).not.toHaveProperty("context"); - }); - - it("constructs direct Teams from its complete credential pair", () => { - const setup = resolvePlatforms( - environment({ - TEAMS_CLIENT_ID: "teams-client", - TEAMS_CLIENT_SECRET: "teams-secret", - TEAMS_TENANT_ID: "teams-tenant", - TEAMS_PORT: "4978", - }), - ); - - expect(setup.adapters.map(({ platform }) => platform)).toEqual(["teams"]); - expect(setup).not.toHaveProperty("tools"); - expect(setup).not.toHaveProperty("context"); - }); - - it("combines direct Slack and Teams adapters in one platform setup", () => { - const setup = resolvePlatforms( - environment({ - SLACK_BOT_TOKEN: "xoxb-test", - SLACK_APP_TOKEN: "xapp-test", - TEAMS_CLIENT_ID: "teams-client", - TEAMS_CLIENT_SECRET: "teams-secret", - }), - ); - - expect(setup.adapters.map(({ platform }) => platform)).toEqual([ - "slack", - "teams", - ]); - expect(setup).not.toHaveProperty("tools"); - expect(setup).not.toHaveProperty("context"); - }); -}); diff --git a/app/platforms.ts b/app/platforms.ts deleted file mode 100644 index d232bae..0000000 --- a/app/platforms.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { PlatformAdapter } from "@copilotkit/channels"; -import { slack } from "@copilotkit/channels/slack"; -import { teams } from "@copilotkit/channels/teams"; -import type { AppEnvironment } from "./env.js"; - -export interface PlatformSetup { - adapters: PlatformAdapter[]; -} - -export function resolvePlatforms(env: AppEnvironment): PlatformSetup { - const adapters: PlatformAdapter[] = []; - - if (env.slackBotToken && env.slackAppToken) { - adapters.push( - slack({ - botToken: env.slackBotToken, - appToken: env.slackAppToken, - showToolStatus: false, - respondTo: { - directMessages: true, - appMentions: { reply: "thread" }, - threadReplies: "mentionsOnly", - }, - 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", - }, - ], - }, - }), - ); - } - - if (env.teamsClientId && env.teamsClientSecret) { - adapters.push( - teams({ - clientId: env.teamsClientId, - clientSecret: env.teamsClientSecret, - tenantId: env.teamsTenantId, - port: env.teamsPort, - }), - ); - } - - return { adapters }; -} diff --git a/app/runtime-host.test.ts b/app/runtime-host.test.ts index 3090086..3e91524 100644 --- a/app/runtime-host.test.ts +++ b/app/runtime-host.test.ts @@ -15,7 +15,6 @@ const environment: AppEnvironment = { intelligenceGatewayWsUrl: "wss://gateway.example.test", channelName: "opentag", port: 3000, - teamsPort: 3978, }; describe("createOpenTagRuntime", () => { @@ -24,16 +23,18 @@ describe("createOpenTagRuntime", () => { const teamsAdapter = new FakeAdapter({ platform: "teams" }); const stopSlack = vi.spyOn(slackAdapter, "stop"); const stopTeams = vi.spyOn(teamsAdapter, "stop"); - const slackChannel = createOpenTagChannel({ - name: "opentag-direct-slack", - adapters: [slackAdapter], - agent: new FakeAgent(), - }); - const teamsChannel = createOpenTagChannel({ - name: "opentag-direct-teams", - adapters: [teamsAdapter], - agent: new FakeAgent(), - }); + const slackChannel = createOpenTagChannel( + "opentag", + "slack", + new FakeAgent(), + ); + slackChannel.ɵruntime.addAdapter(slackAdapter); + const teamsChannel = createOpenTagChannel( + "opentag-teams", + "teams", + new FakeAgent(), + ); + teamsChannel.ɵruntime.addAdapter(teamsAdapter); const channels = [slackChannel, teamsChannel]; const { intelligence, listener, runtime } = createOpenTagRuntime({ @@ -56,8 +57,8 @@ describe("createOpenTagRuntime", () => { expect(listener.channels?.status()).toEqual({ overall: "online", channels: { - "opentag-direct-slack": "online", - "opentag-direct-teams": "online", + opentag: "online", + "opentag-teams": "online", }, }); expect(slackAdapter.started).toBe(true); diff --git a/app/sender-context.ts b/app/sender-context.ts index 7cc35bf..abdcd1d 100644 --- a/app/sender-context.ts +++ b/app/sender-context.ts @@ -5,7 +5,7 @@ import type { ContextEntry, PlatformUser } from "@copilotkit/channels"; * "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 source surface, - * so the label is correct for managed and direct Slack/Teams turns alike. + * so the label is correct for managed Slack and Teams turns alike. */ export function senderContext( user: PlatformUser | undefined, diff --git a/app/server.test.ts b/app/server.test.ts index 3a7fd91..7a53fae 100644 --- a/app/server.test.ts +++ b/app/server.test.ts @@ -3,12 +3,12 @@ import type { RequestListener } from "node:http"; import { describe, expect, it, vi } from "vitest"; import type { ChannelsControl } from "@copilotkit/runtime/v2"; import { - createOpenTagApplication, startOpenTagServer, type HttpServerLike, type RuntimeListener, } from "../server.js"; import type { AppEnvironment } from "./env.js"; +import { createOpenTagApplication } from "./index.js"; class FakeServer extends EventEmitter implements HttpServerLike { listening = false; @@ -134,7 +134,7 @@ describe("startOpenTagServer", () => { }); describe("createOpenTagApplication", () => { - it("keeps the managed Channel adapter-free and isolates optional direct adapters", () => { + it("declares adapter-free managed Slack and Teams Channels", () => { const environment: AppEnvironment = { agentUrl: "http://agent.internal/", intelligenceApiKey: "cpk-1_test", @@ -142,29 +142,20 @@ describe("createOpenTagApplication", () => { intelligenceGatewayWsUrl: "wss://realtime.intelligence.test", channelName: "kite", port: 3000, - slackBotToken: "xoxb-test", - slackAppToken: "xapp-test", - teamsClientId: "teams-client", - teamsClientSecret: "teams-secret", - teamsPort: 3978, }; const application = createOpenTagApplication(environment); - expect(application.channel.name).toBe("kite"); - expect(application.channel.adapters).toEqual([]); expect( - application.directChannels.map((channel) => ({ + application.channels.map((channel) => ({ name: channel.name, - platforms: channel.adapters.map(({ platform }) => platform), + provider: channel.provider, + adapters: channel.adapters, })), ).toEqual([ - { name: "opentag-direct-slack", platforms: ["slack"] }, - { name: "opentag-direct-teams", platforms: ["teams"] }, - ]); - expect(application.runtime.channels).toEqual([ - application.channel, - ...application.directChannels, + { name: "kite", provider: "slack", adapters: [] }, + { name: "kite-teams", provider: "teams", adapters: [] }, ]); + expect(application.runtime.channels).toEqual(application.channels); }); }); diff --git a/app/tools/index.ts b/app/tools/index.ts index 55c32e0..70dec6e 100644 --- a/app/tools/index.ts +++ b/app/tools/index.ts @@ -1,6 +1,6 @@ /** - * App-specific frontend tools. Platform defaults are selected separately in - * `app/platforms.ts`. + * App-specific frontend tools. Provider defaults are added per turn by + * `app/channel-helpers.ts`. * * Add new tools here and re-export them through `appTools`. Wire the * array into `createChannel({ tools })`. diff --git a/e2e/README.md b/e2e/README.md index 5bb4aee..2399a87 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -24,8 +24,9 @@ E2E_CHANNEL=C... - `BOT_USER_ID` identifies the OpenTag bot in replies. - `E2E_CHANNEL` is the test channel ID. -The harness never provisions or reinstalls an app. Do not run it against the -production `@kite` app while another Socket Mode consumer owns the same token. +These are test-harness credentials, not Channel-runtime configuration; +Intelligence still owns the deployed Slack attachment. The harness never +provisions or reinstalls an app. ## Run diff --git a/server.ts b/server.ts index 4115259..1511b3b 100644 --- a/server.ts +++ b/server.ts @@ -6,12 +6,8 @@ import { } from "node:http"; import { pathToFileURL } from "node:url"; import type { ChannelsControl } from "@copilotkit/runtime/v2"; -import { createAgentFactory } from "./app/agent.js"; -import { createOpenTagChannel } from "./app/channel.js"; -import { readEnvironment, type AppEnvironment } from "./app/env.js"; -import { resolvePlatforms } from "./app/platforms.js"; +import { createOpenTagApplication } from "./app/index.js"; import { closeBrowser } from "./app/render/browser.js"; -import { createOpenTagRuntime } from "./app/runtime-host.js"; export type RuntimeListener = RequestListener & { channels?: ChannelsControl; @@ -160,39 +156,6 @@ export async function startOpenTagServer( return { server: startedServer, shutdown }; } -export function createOpenTagApplication( - environment: AppEnvironment = readEnvironment(), -) { - const platform = resolvePlatforms(environment); - const agent = createAgentFactory({ - url: environment.agentUrl, - authHeader: environment.agentAuthHeader, - }); - const channel = createOpenTagChannel({ - name: environment.channelName, - adapters: [], - agent, - }); - const directChannels = platform.adapters.map((adapter) => - createOpenTagChannel({ - name: `opentag-direct-${adapter.platform}`, - adapters: [adapter], - agent, - }), - ); - const runtimeHost = createOpenTagRuntime({ - environment, - channels: [channel, ...directChannels], - }); - - return { - channel, - directChannels, - environment, - ...runtimeHost, - }; -} - export async function main(): Promise<RunningOpenTagServer> { const application = createOpenTagApplication(); const running = await startOpenTagServer({ @@ -202,7 +165,9 @@ export async function main(): Promise<RunningOpenTagServer> { }); console.log( - `[opentag] channel "${application.environment.channelName}" listening on [::]:${application.environment.port}`, + `[opentag] channels ${application.channels + .map(({ name }) => `"${name}"`) + .join(", ")} listening on [::]:${application.environment.port}`, ); return running; } diff --git a/setup.md b/setup.md index 13f7d84..3935c76 100644 --- a/setup.md +++ b/setup.md @@ -11,17 +11,16 @@ and WhatsApp are coming soon. | Component | Location | Responsibility | | --- | --- | --- | | Channel entrypoint | [`server.ts`](./server.ts) | Environment, readiness, HTTP lifecycle, and shutdown | +| Application composition | [`app/index.ts`](./app/index.ts) | SDK agent factory, managed Slack/Teams Channels, and runtime | | Channel definition | [`app/channel.tsx`](./app/channel.tsx) | Mentions, commands, components, modals, and interrupts | | Intelligence runtime | [`app/runtime-host.ts`](./app/runtime-host.ts) | One `CopilotKitIntelligence` and one `CopilotRuntime` | -| Platform adapters | [`app/platforms.ts`](./app/platforms.ts) | Optional direct Slack and Teams adapters for local development | | Python agent | [`agent/`](./agent) | LangGraph deep agent served over AG-UI | | Railway topology | [`.railway/railway.ts`](./.railway/railway.ts) | Two services sourced from OpenTag `main` | -The Channel always uses the Intelligence-owned runtime. Managed Slack and -Microsoft Teams attachments are configured in Intelligence. Direct adapters -are optional, separately named Channels in the same runtime, not an alternate -non-Intelligence mode. Keeping the managed Channel adapter-free ensures local -credentials cannot disable managed activation. +The host always uses the Intelligence-owned runtime. It declares an +adapter-free Slack Channel using the configured base name and an adapter-free +Teams Channel named `<base>-teams`. Their credentials and attachments are +configured only in Intelligence. ## Install @@ -31,7 +30,7 @@ Prerequisites: - pnpm - Python 3.12 - [`uv`](https://docs.astral.sh/uv/) -- A CopilotKit Intelligence project, Channel, and runtime API key +- A CopilotKit Intelligence project, Channels, and runtime API key - An OpenAI API key for the Python agent Install both dependency sets: @@ -50,10 +49,10 @@ The Channels and Runtime packages are intentionally pinned to canaries: @copilotkit/runtime 1.63.3-canary.rc-1 ``` -## Configure the Python agent +## Configure the environment ```bash -cp agent/.env.example agent/.env +cp .env.example .env ``` | Variable | Required | Purpose | @@ -70,7 +69,9 @@ cp agent/.env.example agent/.env Only `OPENAI_API_KEY` is required. Without Tavily or internal-source credentials, the agent still chats, plans, writes virtual files, and renders -supported UI components. +supported UI components. The Python agent explicitly loads this root `.env` +for local development; Railway service variables work normally without a +checked-in environment file. Run it: @@ -86,16 +87,11 @@ The AG-UI endpoint is `http://localhost:8123/`; `/health` reports the In [CopilotKit Intelligence](https://intelligence.copilotkit.ai): 1. Create or select the OpenTag project. -2. Create one Channel. Use `opentag` for a normal installation or `kite` for - the production migration. +2. Create two Channels. For a normal installation use `opentag` with provider + Slack and `opentag-teams` with provider Microsoft Teams. For production use + `kite` and `kite-teams`. 3. Issue a runtime API key. -4. Attach managed Slack, Microsoft Teams, or both to that Channel. - -Then configure the root environment: - -```bash -cp .env.example .env -``` +4. Configure each platform attachment on its matching Channel. | Variable | Required | Purpose | | --- | --- | --- | @@ -108,7 +104,8 @@ cp .env.example .env | `PORT` | No | Channel HTTP port; defaults to `3000` | Legacy organization, project, Channel ID, and runtime-instance ID variables are -not used. +not used. Slack and Teams credentials also do not belong in this environment; +Intelligence owns them. Start the Channel: @@ -152,32 +149,9 @@ Those manifests are not a production migration step for `@kite`. ## Microsoft Teams Managed Microsoft Teams is supported. Configure the Teams attachment on the -same Channel in Intelligence; no second runtime or provider-routing -configuration is required. - -For direct local-adapter testing only, set: - -```dotenv -TEAMS_CLIENT_ID=... -TEAMS_CLIENT_SECRET=... -# TEAMS_TENANT_ID=... -# TEAMS_PORT=3978 -``` - -`TEAMS_CLIENT_ID` and `TEAMS_CLIENT_SECRET` must be set together. - -## Optional direct Slack adapter - -For direct local Socket Mode testing, add both variables to the root `.env`: - -```dotenv -SLACK_BOT_TOKEN=xoxb-... -SLACK_APP_TOKEN=xapp-... -``` - -Both are required as a pair. The direct adapter runs as its own Channel inside -the same Intelligence-owned process. Never run it concurrently with another -consumer of the same production app token. +`<base>-teams` Channel in Intelligence. The same Node process and runtime host +both platform Channels; there is no direct adapter or Railway platform +credential. ## Tools, commands, and UI @@ -201,22 +175,21 @@ Reads and UI rendering are never gated. ### Tavily -Set `TAVILY_API_KEY` in `agent/.env` to enable live web research. The `research` -tool is not registered when the key is absent. +Set `TAVILY_API_KEY` in the root `.env` to enable live web research. The +`research` tool is not registered when the key is absent. ### Linear -Set `LINEAR_API_KEY` in `agent/.env`. OpenTag connects to the hosted Linear MCP -by default. Railway preserves this optional secret on the `agent` service. +Set `LINEAR_API_KEY` in the root `.env`. OpenTag connects to the hosted Linear +MCP by default. Railway preserves this optional secret on the `agent` service. ### Notion for local development The Railway launch has no Notion sidecar. For local development: 1. Put `NOTION_TOKEN` and a strong `NOTION_MCP_AUTH_TOKEN` in the root `.env`. -2. Put the same `NOTION_MCP_AUTH_TOKEN` in `agent/.env`. -3. Run `pnpm notion-mcp`. -4. Restart `pnpm agent` so it discovers the Notion tools. +2. Run `pnpm notion-mcp`. +3. Restart `pnpm agent` so it discovers the Notion tools. Notion is optional and is not a deployment blocker. @@ -231,8 +204,9 @@ The IaC file declares exactly: `channel.AGENT_URL` references the agent's Railway private domain and port. Production Intelligence URLs are literal configuration, the API key is -preserved, and the Channel name is `kite`. `OPENAI_API_KEY` is required on -`agent`; Tavily and Linear are optional preserved secrets. +preserved, and the base Channel name is `kite` (with `kite-teams` derived by +the application). `OPENAI_API_KEY` is required on `agent`; Tavily and Linear +are optional preserved secrets. Evaluate the configuration locally without applying it: diff --git a/slack-app-manifest.yaml b/slack-app-manifest.yaml index c046359..9a8df32 100644 --- a/slack-app-manifest.yaml +++ b/slack-app-manifest.yaml @@ -14,7 +14,7 @@ features: messages_tab_enabled: true messages_tab_read_only_enabled: false # Enables the assistant pane ("Agents & AI Apps"). The greeting + chips here - # mirror the `assistant` option in app/platforms.ts. + # mirror the suggestions registered when an OpenTag thread starts. assistant_view: assistant_description: OpenTag research and triage assistant. suggested_prompts: From ac0a3334dd8ec4b1513125ea457838a22944f065 Mon Sep 17 00:00:00 2001 From: Tyler Slaton <tyler@copilotkit.ai> Date: Mon, 27 Jul 2026 19:18:37 -0400 Subject: [PATCH 08/19] Fix packaging and terminal error reporting --- agent/pyproject.toml | 8 +++- agent/tests/test_packaging.py | 15 ++++++++ agent/tests/test_tools.py | 7 ++-- agent/tools.py | 5 +-- app/commands/__tests__/commands.test.ts | 34 ++++++++++++++++- app/commands/index.ts | 26 +++++++++---- app/modals/__tests__/file-issue.test.tsx | 47 ++++++++++++++++++++++-- app/modals/file-issue.tsx | 33 +++++++++++++---- 8 files changed, 149 insertions(+), 26 deletions(-) create mode 100644 agent/tests/test_packaging.py diff --git a/agent/pyproject.toml b/agent/pyproject.toml index dbc1e85..b7b3e19 100644 --- a/agent/pyproject.toml +++ b/agent/pyproject.toml @@ -20,7 +20,13 @@ dependencies = [ dev = ["pytest>=8.0.0", "httpx>=0.27.0"] [tool.setuptools] -py-modules = ["agent", "main", "tools"] +py-modules = [ + "agent", + "internal_sources", + "main", + "tools", + "write_confirmation", +] [tool.pytest.ini_options] pythonpath = ["."] diff --git a/agent/tests/test_packaging.py b/agent/tests/test_packaging.py new file mode 100644 index 0000000..8e0d613 --- /dev/null +++ b/agent/tests/test_packaging.py @@ -0,0 +1,15 @@ +from pathlib import Path +import tomllib + + +def test_wheel_includes_every_runtime_module(): + agent_root = Path(__file__).resolve().parent.parent + project = tomllib.loads((agent_root / "pyproject.toml").read_text()) + packaged_modules = set(project["tool"]["setuptools"]["py-modules"]) + runtime_modules = { + path.stem + for path in agent_root.glob("*.py") + if path.name != "__init__.py" + } + + assert packaged_modules == runtime_modules diff --git a/agent/tests/test_tools.py b/agent/tests/test_tools.py index 4770d40..c0ad8b6 100644 --- a/agent/tests/test_tools.py +++ b/agent/tests/test_tools.py @@ -21,11 +21,12 @@ def test_do_internet_search_missing_key(monkeypatch): tools._do_internet_search("q") -def test_do_internet_search_returns_a_discriminated_error(monkeypatch): +def test_do_internet_search_raises_when_tavily_fails(monkeypatch): class BoomClient: def __init__(self, api_key): pass def search(self, **kwargs): raise ValueError("boom") monkeypatch.setenv("TAVILY_API_KEY", "tvly-test") monkeypatch.setattr(tools, "TavilyClient", BoomClient) - out = tools._do_internet_search("q") - assert out == [{"error": "boom"}] + with pytest.raises(RuntimeError, match="Tavily search failed") as error: + tools._do_internet_search("q") + assert isinstance(error.value.__cause__, ValueError) diff --git a/agent/tools.py b/agent/tools.py index 29f7423..a734b92 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -57,9 +57,8 @@ def _do_internet_search(query: str, max_results: int = 5) -> list[dict[str, Any] print(f"[TOOL] internet_search: found {len(formatted_results)} results") return formatted_results - except Exception as e: - print(f"[TOOL] internet_search error: {e}") - return [{"error": str(e)}] + except Exception as error: + raise RuntimeError("Tavily search failed") from error @tool diff --git a/app/commands/__tests__/commands.test.ts b/app/commands/__tests__/commands.test.ts index 0f6c016..356afc9 100644 --- a/app/commands/__tests__/commands.test.ts +++ b/app/commands/__tests__/commands.test.ts @@ -81,7 +81,7 @@ describe("example slash commands", () => { expect(thread.post).toHaveBeenCalledTimes(1); }); - it("/agent logs and posts an apology when runAgent rejects", async () => { + it("/agent reports a recoverable error after posting an apology", async () => { const thread = fakeThread(); thread.runAgent.mockRejectedValueOnce(new Error("backend unavailable")); const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -94,7 +94,17 @@ describe("example slash commands", () => { }), ); - expect(consoleError).toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledWith( + "[channel] recoverable error", + expect.objectContaining({ + error: expect.any(Error), + context: { + operation: "command_agent_run_agent", + recovery: "posted_user_facing_error", + }, + timestamp: expect.any(String), + }), + ); expect(thread.post).toHaveBeenCalledWith( expect.stringMatching(/sorry.*error/i), ); @@ -102,6 +112,26 @@ describe("example slash commands", () => { consoleError.mockRestore(); }); + it("/agent rejects loudly when its run and apology both fail", async () => { + const thread = fakeThread(); + const runError = new Error("backend unavailable"); + const postError = new Error("Slack unavailable"); + thread.runAgent.mockRejectedValueOnce(runError); + thread.post.mockRejectedValueOnce(postError); + + await expect( + byName("agent").handler( + ctx({ + command: "agent", + text: "why is prod down", + thread: thread as never, + }), + ), + ).rejects.toMatchObject({ + errors: [runError, postError], + }); + }); + it("/triage runs the agent with a triage prompt", async () => { const thread = fakeThread(); await byName("triage").handler( diff --git a/app/commands/index.ts b/app/commands/index.ts index 79f4548..f0da70f 100644 --- a/app/commands/index.ts +++ b/app/commands/index.ts @@ -15,7 +15,10 @@ import { type ChannelCommand, type Thread as ChannelThread, } from "@copilotkit/channels"; -import { platformRunInput } from "../channel-helpers.js"; +import { + platformRunInput, + reportRecoverableError, +} from "../channel-helpers.js"; import { IssueCard } from "../components/index.js"; import { FileIssueModal } from "../modals/file-issue.js"; @@ -33,13 +36,22 @@ async function runAgentSafely( ): Promise<void> { try { await thread.runAgent(input); - } catch (err) { - console.error(`[command] ${commandName} run failed`, err); - await thread - .post("Sorry — I hit an error handling that. Please try again.") - .catch((postErr: unknown) => - console.error(`[command] ${commandName} failed to post error`, postErr), + } catch (error) { + try { + await thread.post( + "Sorry — I hit an error handling that. Please try again.", + ); + } catch (postError) { + throw new AggregateError( + [error, postError], + `The /${commandName} agent run and its error reply both failed`, ); + } + + reportRecoverableError(error, { + operation: `command_${commandName}_run_agent`, + recovery: "posted_user_facing_error", + }); } } diff --git a/app/modals/__tests__/file-issue.test.tsx b/app/modals/__tests__/file-issue.test.tsx index e429d58..f06b20c 100644 --- a/app/modals/__tests__/file-issue.test.tsx +++ b/app/modals/__tests__/file-issue.test.tsx @@ -159,7 +159,7 @@ describe("fileIssueSubmit", () => { ]); }); - it("posts a failure message to the thread when runAgent rejects", async () => { + it("posts and reports a recoverable failure when runAgent rejects", async () => { const consoleError = vi .spyOn(console, "error") .mockImplementation(() => undefined); @@ -179,8 +179,49 @@ describe("fileIssueSubmit", () => { expect.stringMatching(/couldn.t file|try again/i), ); expect(consoleError).toHaveBeenCalledWith( - "[opentag] file-issue modal run failed", - expect.any(Error), + "[channel] recoverable error", + expect.objectContaining({ + error: expect.any(Error), + context: { + operation: "file_issue_modal_run_agent", + recovery: "posted_user_facing_error", + }, + timestamp: expect.any(String), + }), + ); + consoleError.mockRestore(); + }); + + it("reports a structured terminal error when the apology also fails", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const runError = new Error("LLM timeout"); + const postError = new Error("Slack unavailable"); + const thread = { + runAgent: vi.fn(() => Promise.reject(runError)), + post: vi.fn(() => Promise.reject(postError)), + }; + + await fileIssueSubmit({ + values: { title: "T", description: "D", type: "bug", priority: "High" }, + thread, + user: { id: "U1" }, + } as never); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(consoleError).toHaveBeenCalledWith( + "[channel] recoverable error", + expect.objectContaining({ + error: expect.objectContaining({ + errors: [runError, postError], + }), + context: { + operation: "file_issue_modal_run_agent", + recovery: "terminal_failure", + }, + timestamp: expect.any(String), + }), ); consoleError.mockRestore(); }); diff --git a/app/modals/file-issue.tsx b/app/modals/file-issue.tsx index 97af3f2..8764008 100644 --- a/app/modals/file-issue.tsx +++ b/app/modals/file-issue.tsx @@ -13,7 +13,10 @@ import { RadioButtons, } from "@copilotkit/channels"; import type { ModalSubmitHandler, ModalView } from "@copilotkit/channels"; -import { platformRunInput } from "../channel-helpers.js"; +import { + platformRunInput, + reportRecoverableError, +} from "../channel-helpers.js"; export const FILE_ISSUE_CALLBACK = "file_issue"; @@ -69,13 +72,29 @@ export const fileIssueSubmit: ModalSubmitHandler = async ({ `show the issue card.`, ...platformRunInput(privateMetadata ?? thread.platform, user), }) - .catch((err) => { - console.error("[opentag] file-issue modal run failed", err); - void thread - .post("Sorry — I couldn't file that issue. Please try again.") - .catch((postErr: unknown) => - console.error("[file-issue] failed to post error", postErr), + .catch(async (error) => { + try { + await thread.post( + "Sorry — I couldn't file that issue. Please try again.", + ); + } catch (postError) { + throw new AggregateError( + [error, postError], + "The file-issue agent run and its error reply both failed", ); + } + + reportRecoverableError(error, { + operation: "file_issue_modal_run_agent", + recovery: "posted_user_facing_error", + }); + }) + .catch((error) => { + // This is the terminal observer for the intentionally detached promise. + reportRecoverableError(error, { + operation: "file_issue_modal_run_agent", + recovery: "terminal_failure", + }); }); }; From 8d0682ef8732a860f0a5bc619bdd48ec57c7a274 Mon Sep 17 00:00:00 2001 From: Tyler Slaton <tyler@copilotkit.ai> Date: Mon, 27 Jul 2026 19:43:18 -0400 Subject: [PATCH 09/19] Simplify OpenTag example configuration --- .env.example | 37 +++----- .railway/railway.ts | 4 +- agent/agent.py | 101 +++------------------- agent/internal_sources.py | 122 ++++++++++++++------------- agent/main.py | 55 +++--------- agent/prompts/__init__.py | 18 ++++ agent/prompts/research.py | 33 ++++++++ agent/prompts/system.py | 24 ++++++ agent/prompts/tools.py | 9 ++ agent/pyproject.toml | 1 + agent/tests/test_health.py | 31 +------ agent/tests/test_internal_sources.py | 68 +++++++++++++++ agent/tests/test_packaging.py | 3 +- agent/tests/test_prompts.py | 15 ++++ agent/tools.py | 98 ++++----------------- agent/write_confirmation.py | 9 +- 16 files changed, 288 insertions(+), 340 deletions(-) create mode 100644 agent/prompts/__init__.py create mode 100644 agent/prompts/research.py create mode 100644 agent/prompts/system.py create mode 100644 agent/prompts/tools.py create mode 100644 agent/tests/test_prompts.py diff --git a/.env.example b/.env.example index ed851de..4fb996e 100644 --- a/.env.example +++ b/.env.example @@ -1,38 +1,21 @@ -# ── Intelligence Channel host (`pnpm channel`) ───────────────────────── -# Both variables are required. The local Python agent listens on :8123. +# -- Intelligence Variables -- +# Get an API key at https://intelligence.copilotkit.ai. +# Both variables are required. The included agent listens on port 8123. AGENT_URL=http://localhost:8123/ INTELLIGENCE_API_KEY=cpk-... -# Production defaults; override only for another Intelligence environment. -# INTELLIGENCE_API_URL=https://api.intelligence.copilotkit.ai -# INTELLIGENCE_GATEWAY_WS_URL=wss://realtime.intelligence.copilotkit.ai -# Base Channel name. OpenTag declares this Slack Channel plus a Teams Channel -# suffixed with "-teams". Railway sets the base to "kite". +# Optional. Defaults to "opentag"; production uses "kite". # INTELLIGENCE_CHANNEL_NAME=opentag -# HTTP port for the CopilotRuntime listener. Defaults to 3000. -# PORT=3000 -# Optional authorization header forwarded to the AG-UI agent. -# AGENT_AUTH_HEADER=Bearer ... -# Slack and Microsoft Teams credentials belong in CopilotKit Intelligence, not -# this runtime environment. Discord, Telegram, and WhatsApp are coming soon. - -# ── Python deep-research agent (`pnpm agent`) ─────────────────────────── -# The Python service also loads this root .env during local development. +# -- Agent Variables -- +# Get an API key at https://platform.openai.com/api-keys. OPENAI_API_KEY=sk-... -# Optional live web research. Without Tavily the agent still chats, plans, -# uses its virtual filesystem, and renders UI components. + +# Optional web research and model override. # TAVILY_API_KEY=tvly-... # OPENAI_MODEL=gpt-5.5 -# SERVER_HOST=0.0.0.0 -# SERVER_PORT=8123 -# ── Internal research sources (optional) ──────────────────────────────── +# -- Internal Sources (Optional) -- # LINEAR_API_KEY=lin_api_... -# LINEAR_MCP_URL=https://mcp.linear.app/mcp - -# The Notion MCP sidecar is local-only and not a Railway service. # NOTION_TOKEN=ntn_... -# NOTION_MCP_AUTH_TOKEN=choose-a-strong-shared-secret -# NOTION_MCP_URL=http://127.0.0.1:3001/mcp -# NOTION_MCP_PORT=3001 +# NOTION_MCP_AUTH_TOKEN=choose-a-shared-secret diff --git a/.railway/railway.ts b/.railway/railway.ts index 885ebca..b47092b 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -34,9 +34,7 @@ export default defineRailway(() => { const channel = service("channel", { source: github(REPO, { branch: BRANCH }), start: "pnpm channel", - // render_chart and render_diagram launch Playwright's Chromium binary. - // Keep the browser in the deploy artifact and install its shared libraries - // in the runtime image; Railpack otherwise drops build-layer apt packages. + // Rich rendering needs Chromium and its runtime libraries. build: { builder: "RAILPACK", buildCommand: "pnpm exec playwright install chromium", diff --git a/agent/agent.py b/agent/agent.py index a9ee280..b14d7ad 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -1,116 +1,38 @@ -""" -OpenTag Deep Research Agent - -A Deep Agents-powered research assistant that demonstrates CopilotKit's -planning, filesystem, and subagent capabilities, with optional Tavily-backed -web research. -""" +"""OpenTag's Deep Agents research assistant.""" import os from pathlib import Path + +from copilotkit import CopilotKitMiddleware +from deepagents import create_deep_agent from dotenv import load_dotenv from langchain_openai import ChatOpenAI -from deepagents import create_deep_agent from langgraph.checkpoint.memory import MemorySaver -from copilotkit import CopilotKitMiddleware from internal_sources import internal_source_tools +from prompts import ( + BASE_SYSTEM_PROMPT, + NO_RESEARCH_TOOL_ADDENDUM, + RESEARCH_TOOL_ADDENDUM, +) from tools import research load_dotenv(Path(__file__).resolve().parent.parent / ".env") -# Base system prompt - always applies, regardless of whether web research is -# available. The agent chats, plans with write_todos, uses its virtual -# filesystem, and renders results as UI components (via OpenTag's forwarded -# generative-UI tools) even with no research tool loaded. -BASE_SYSTEM_PROMPT = """You are OpenTag's Deep Research Assistant, an expert at planning and -executing comprehensive research on any topic. - -Hard rules (ALWAYS follow): -- NEVER output raw JSON, data structures, or code blocks in your messages -- Communicate with the user only in natural, readable prose -- When you receive data from research or from your own knowledge, synthesize it into insights -- Prefer rendering results as UI components (tables, cards, links, etc.) when the - frontend offers them, rather than large blocks of raw markdown -- For internal or company-specific questions, prefer the team's Notion/Linear - (internal sources) first; use the web for external questions -- CRITICAL: Every Linear or Notion mutation tool automatically pauses with its - exact action and draft details. Call the mutation once; it runs only after - the user grants approval, and otherwise no write occurs -- Reads and rendering never require confirmation - -Your workflow: -1. PLAN: Create a research plan using write_todos with clear, actionable steps -2. INVESTIGATE: Work through each step, drawing on the tools available to you -3. SYNTHESIZE: Write a final report to /reports/final_report.md using write_file - -Important guidelines: -- Always start by creating a research plan with write_todos -- You write all files - compile findings into a comprehensive report -- Update todos as you complete each step -- Always maintain a professional, comprehensive research style""" - - -# Appended only when TAVILY_API_KEY is configured and the research() tool is loaded. -RESEARCH_TOOL_ADDENDUM = """ - -Live web research is available via the research(query) tool: -- Call research() for each distinct research question that needs current or - external information -- The research tool returns prose summaries of findings - synthesize them, - don't just relay them -- Example workflow: - 1. write_todos(["Research topic A", "Research topic B", "Synthesize findings"]) - 2. research("Find information about topic A") -> receives prose summary - 3. research("Find information about topic B") -> receives prose summary - 4. write_file("/reports/final_report.md", "# Research Report\\n\\n...")""" - - -# Appended only when TAVILY_API_KEY is NOT configured, so the agent doesn't -# imply it can browse the live web. -NO_RESEARCH_TOOL_ADDENDUM = """ - -You do NOT have a live web research tool available right now. Answer from your -own knowledge, state plainly when you cannot look up current or external -information, and never claim to have searched the web. You can still plan -with write_todos, read/write files, and render findings as UI components.""" - - def build_agent(): - """Build the Deep Research Agent with CopilotKit integration. - - Creates a main research coordinator agent. Web research via the - research() tool is included only when TAVILY_API_KEY is configured; - otherwise the agent still chats, plans, uses its filesystem, and - generates UI components from its own knowledge. - - Returns: - The compiled research graph (from `create_deep_agent`), bound with - a `recursion_limit=100` run config via `.with_config(...)` - a - RunnableBinding wrapping the compiled StateGraph, not the bare - graph itself. - """ + """Build the OpenTag research graph.""" api_key = os.environ.get("OPENAI_API_KEY") if not api_key: raise RuntimeError("Missing OPENAI_API_KEY environment variable") - # TAVILY_API_KEY is optional. Without it, the research() tool is simply - # not loaded - the agent still works (chat + UI-component generation), - # it just can't do live web lookups. has_research = bool(os.environ.get("TAVILY_API_KEY")) - - # Initialize LLM - use model from env or default to gpt-5.5 model_name = os.environ.get("OPENAI_MODEL", "gpt-5.5") llm = ChatOpenAI( model=model_name, api_key=api_key, ) - # Main agent gets the research tool plus built-in Deep Agents tools - # (write_todos, read_file, write_file) when Tavily is configured; the - # research tool wraps an internal Deep Agent that runs via .invoke() so - # its text doesn't stream to the frontend. internal_tools = internal_source_tools() main_tools = ( [research, *internal_tools] @@ -122,8 +44,6 @@ def build_agent(): RESEARCH_TOOL_ADDENDUM if has_research else NO_RESEARCH_TOOL_ADDENDUM ) - # Create the Deep Agent with CopilotKit middleware. - # No subagents - research() tool (when present) handles web search internally. agent_graph = create_deep_agent( model=llm, system_prompt=system_prompt, @@ -137,5 +57,4 @@ def build_agent(): print(f"[AGENT] internal-source tools: {len(internal_tools)}") print(f"[AGENT] Main tools: {[t.name for t in main_tools]}") - # Configure recursion limit for complex research tasks return agent_graph.with_config({"recursion_limit": 100}) diff --git a/agent/internal_sources.py b/agent/internal_sources.py index fbea53d..c120009 100644 --- a/agent/internal_sources.py +++ b/agent/internal_sources.py @@ -3,6 +3,7 @@ import asyncio import logging import os +from collections.abc import Mapping from datetime import datetime, timezone from typing import Any @@ -11,6 +12,20 @@ from write_confirmation import WriteConfirmationInterceptor +MCP_SERVERS = { + "linear": { + "token_env": "LINEAR_API_KEY", + "url_env": "LINEAR_MCP_URL", + "default_url": "https://mcp.linear.app/mcp", + }, + "notion": { + "token_env": "NOTION_MCP_AUTH_TOKEN", + "url_env": "NOTION_MCP_URL", + "default_url": "http://127.0.0.1:3001/mcp", + }, +} +MCP_LOAD_TIMEOUT_SECONDS = 8.0 + logger = logging.getLogger(__name__) @@ -36,74 +51,65 @@ def _emit_internal_source_error( ) -def internal_source_tools() -> list: - """Load independently optional Linear and Notion tools. - - Each configured server gets its own client so one unavailable integration - cannot remove the other's tools. Tool discovery is async-only, so this - synchronous build hook runs it on a short-lived event loop and bounds each - connection attempt. Every loaded mutation is guarded at execution time by - ``WriteConfirmationInterceptor``. - """ +def _configured_connections( + env: Mapping[str, str], +) -> dict[str, dict[str, Any]]: connections: dict[str, dict[str, Any]] = {} + for name, config in MCP_SERVERS.items(): + token = env.get(config["token_env"]) + if not token: + continue - linear_api_key = os.environ.get("LINEAR_API_KEY") - if linear_api_key: - connections["linear"] = { + connections[name] = { "transport": "streamable_http", - "url": os.environ.get("LINEAR_MCP_URL", "https://mcp.linear.app/mcp"), - "headers": {"Authorization": f"Bearer {linear_api_key}"}, + "url": env.get(config["url_env"], config["default_url"]), + "headers": {"Authorization": f"Bearer {token}"}, } + return connections - notion_auth_token = os.environ.get("NOTION_MCP_AUTH_TOKEN") - if notion_auth_token: - connections["notion"] = { - "transport": "streamable_http", - "url": os.environ.get("NOTION_MCP_URL", "http://127.0.0.1:3001/mcp"), - "headers": {"Authorization": f"Bearer {notion_auth_token}"}, - } +async def _load_tools(connections: dict[str, dict[str, Any]]) -> list: + loaded = [] + for name, connection in connections.items(): + try: + confirmation = WriteConfirmationInterceptor() + client = MultiServerMCPClient( + {name: connection}, + tool_interceptors=[confirmation], + ) + tools = await asyncio.wait_for( + client.get_tools(), + timeout=MCP_LOAD_TIMEOUT_SECONDS, + ) + confirmation.register_tools(tools) + loaded.extend(tools) + print(f"[TOOLS] loaded {len(tools)} tool(s) from {name}") + except asyncio.TimeoutError as error: + _emit_internal_source_error( + error, + message="Configured MCP server timed out while loading tools", + source=name, + recovery="skip_optional_integration", + ) + except Exception as error: + _emit_internal_source_error( + error, + message="Configured MCP server was unavailable while loading tools", + source=name, + recovery="skip_optional_integration", + ) + return loaded + + +def internal_source_tools() -> list: + """Load optional MCP tools for the configured internal sources.""" + connections = _configured_connections(os.environ) if not connections: return [] - async def _load_all() -> list: - loaded: list = [] - for name, connection in connections.items(): - try: - confirmation = WriteConfirmationInterceptor() - server_client = MultiServerMCPClient( - {name: connection}, - tool_interceptors=[confirmation], - ) - server_tools = await asyncio.wait_for( - server_client.get_tools(), timeout=8.0 - ) - confirmation.register_tools(server_tools) - loaded.extend(server_tools) - print( - f"[TOOLS] internal_source_tools: loaded {len(server_tools)} " - f"tool(s) from {name}" - ) - except asyncio.TimeoutError as error: - _emit_internal_source_error( - error, - message="Configured MCP server timed out while loading tools", - source=name, - recovery="skip_optional_integration", - ) - except Exception as error: - _emit_internal_source_error( - error, - message=( - "Configured MCP server was unavailable while loading tools" - ), - source=name, - recovery="skip_optional_integration", - ) - return loaded - + # MCP discovery is async; agent construction is synchronous. try: - return asyncio.run(_load_all()) + return asyncio.run(_load_tools(connections)) except Exception as error: _emit_internal_source_error( error, diff --git a/agent/main.py b/agent/main.py index 0ca55f6..9b99eb8 100644 --- a/agent/main.py +++ b/agent/main.py @@ -1,17 +1,13 @@ -""" -OpenTag Deep Research Agent - FastAPI Server - -Serves the Deep Research Agent via AG-UI protocol for CopilotKit integration. -The agent uses Deep Agents for planning and filesystem operations, with optional Tavily web research. -""" +"""FastAPI server for the OpenTag research agent.""" +from collections.abc import Mapping import os import sys -from collections.abc import Mapping -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware + from ag_ui_langgraph import add_langgraph_fastapi_endpoint from copilotkit import LangGraphAGUIAgent +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware from agent import build_agent @@ -26,15 +22,7 @@ "OpenTag deep research assistant — plans, searches, and synthesizes cited briefs" ) -# Enable CORS for frontend communication. Defaults to "*" (any origin) for -# local/demo use; on Railway the agent is reached only over private networking -# by the channel service, so this is not a credential vector (allow_credentials -# is False). Set CORS_ALLOW_ORIGINS to a comma-separated allowlist to lock it -# down if the service is ever exposed publicly. -# Trailing `or ["*"]` so ANY blank CORS_ALLOW_ORIGINS — unset, empty, or a -# whitespace/comma-only value a deployer left while "resetting" — falls back to -# the permissive default rather than an empty allowlist that would block every -# origin. +# Allow all origins locally, or set CORS_ALLOW_ORIGINS to restrict access. _cors_origins = [ o.strip() for o in (os.getenv("CORS_ALLOW_ORIGINS") or "*").split(",") @@ -51,7 +39,7 @@ @app.get("/health") def health(): - """Health check endpoint for monitoring and Railway deployments""" + """Return service health.""" return {"status": "ok", "service": "opentag-research-agent", "version": "0.1.0"} @@ -70,21 +58,8 @@ def local_server_port(env: Mapping[str, str] = os.environ) -> int: return port -# Build and register the Deep Research Agent try: agent_graph = build_agent() - - # Note: we intentionally do NOT pass an emit_tool_calls allowlist here. - # OpenTag's channel bridge forwards generative-UI tools (issue_card, - # render_chart, render_table, show_links, ...) that must be emitted to the - # frontend for cards to render. The backend-owned write interceptor emits - # confirm_write interrupts through the same stream. An allowlist could - # filter these events out. The recursion limit for complex research tasks - # (6+ research calls + file operations) is already applied to the graph - # itself via `.with_config({"recursion_limit": 100})` in agent.py, so no - # additional AG-UI config is needed here. - - # Add AG-UI endpoint at root path for CopilotKit frontend add_langgraph_fastapi_endpoint( app=app, agent=LangGraphAGUIAgent( @@ -96,25 +71,17 @@ def local_server_port(env: Mapping[str, str] = os.environ) -> int: ) print("[SERVER] Deep Research Agent registered at /") -except Exception as e: - print(f"[ERROR] Failed to build agent: {e}", file=sys.stderr) +except Exception as error: + print(f"[ERROR] Failed to build agent: {error}", file=sys.stderr) raise def main(): - """Run the server with uvicorn""" + """Run the local development server.""" import uvicorn - # Local-dev default 0.0.0.0 (IPv4 all-interfaces) — accepts 127.0.0.1/ - # localhost clients on every platform. This __main__ path runs only for - # local `pnpm agent`; on Railway the startCommand binds `--host ::` for the - # IPv6 private network, so this default does not affect the deploy. (Avoid - # defaulting to :: here: on macOS/BSD a `::` socket may not accept IPv4, so - # a local client dialing 127.0.0.1 could be refused.) Override with SERVER_HOST. + # Railway uses its own uvicorn command; these are local defaults. host = os.getenv("SERVER_HOST") or "0.0.0.0" - # Local-dev entrypoint only: Railway runs `uvicorn main:app` with its - # service-specific PORT directly. Ignore root PORT here because that value - # belongs to the Node Channel when both services share one local `.env`. try: port = local_server_port() except ValueError as error: diff --git a/agent/prompts/__init__.py b/agent/prompts/__init__.py new file mode 100644 index 0000000..fda5e64 --- /dev/null +++ b/agent/prompts/__init__.py @@ -0,0 +1,18 @@ +"""OpenTag agent prompts.""" + +from .research import ( + NO_RESEARCH_TOOL_ADDENDUM, + RESEARCH_TOOL_ADDENDUM, + RESEARCHER_SYSTEM_PROMPT, +) +from .system import SYSTEM_PROMPT, WORKFLOW_PROMPT +from .tools import TOOLS_PROMPT + +BASE_SYSTEM_PROMPT = SYSTEM_PROMPT + TOOLS_PROMPT + WORKFLOW_PROMPT + +__all__ = [ + "BASE_SYSTEM_PROMPT", + "NO_RESEARCH_TOOL_ADDENDUM", + "RESEARCH_TOOL_ADDENDUM", + "RESEARCHER_SYSTEM_PROMPT", +] diff --git a/agent/prompts/research.py b/agent/prompts/research.py new file mode 100644 index 0000000..037a3a6 --- /dev/null +++ b/agent/prompts/research.py @@ -0,0 +1,33 @@ +"""Prompts for optional web research.""" + +RESEARCH_TOOL_ADDENDUM = """ + +Live web research is available via the research(query) tool: +- Call research() for each distinct research question that needs current or + external information +- The research tool returns prose summaries of findings - synthesize them, + don't just relay them +- Example workflow: + 1. write_todos(["Research topic A", "Research topic B", "Synthesize findings"]) + 2. research("Find information about topic A") -> receives prose summary + 3. research("Find information about topic B") -> receives prose summary + 4. write_file("/reports/final_report.md", "# Research Report\\n\\n...") +""" + +NO_RESEARCH_TOOL_ADDENDUM = """ + +You do NOT have a live web research tool available right now. Answer from your +own knowledge, state plainly when you cannot look up current or external +information, and never claim to have searched the web. You can still plan +with write_todos, read/write files, and render findings as UI components. +""" + +RESEARCHER_SYSTEM_PROMPT = """You are a Research Specialist. + +Use internet_search to find information and return a prose summary. + +Rules: +- Call internet_search ONCE with a focused query +- Analyze the returned content +- Return a brief summary (2-3 sentences) of key findings +- No JSON, no code blocks, just prose""" diff --git a/agent/prompts/system.py b/agent/prompts/system.py new file mode 100644 index 0000000..c0e16c6 --- /dev/null +++ b/agent/prompts/system.py @@ -0,0 +1,24 @@ +"""Core OpenTag persona and workflow.""" + +SYSTEM_PROMPT = """You are OpenTag's Deep Research Assistant, an expert at planning and +executing comprehensive research on any topic. + +Hard rules (ALWAYS follow): +- NEVER output raw JSON, data structures, or code blocks in your messages +- Communicate with the user only in natural, readable prose +- When you receive data from research or from your own knowledge, synthesize it into insights +- Prefer rendering results as UI components (tables, cards, links, etc.) when the + frontend offers them, rather than large blocks of raw markdown +""" + +WORKFLOW_PROMPT = """ +Your workflow: +1. PLAN: Create a research plan using write_todos with clear, actionable steps +2. INVESTIGATE: Work through each step, drawing on the tools available to you +3. SYNTHESIZE: Write a final report to /reports/final_report.md using write_file + +Important guidelines: +- Always start by creating a research plan with write_todos +- You write all files - compile findings into a comprehensive report +- Update todos as you complete each step +- Always maintain a professional, comprehensive research style""" diff --git a/agent/prompts/tools.py b/agent/prompts/tools.py new file mode 100644 index 0000000..f348fca --- /dev/null +++ b/agent/prompts/tools.py @@ -0,0 +1,9 @@ +"""Guidance for internal sources and write tools.""" + +TOOLS_PROMPT = """- For internal or company-specific questions, prefer the team's Notion/Linear + (internal sources) first; use the web for external questions +- CRITICAL: Every Linear or Notion mutation tool automatically pauses with its + exact action and draft details. Call the mutation once; it runs only after + the user grants approval, and otherwise no write occurs +- Reads and rendering never require confirmation +""" diff --git a/agent/pyproject.toml b/agent/pyproject.toml index b7b3e19..01c7148 100644 --- a/agent/pyproject.toml +++ b/agent/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ dev = ["pytest>=8.0.0", "httpx>=0.27.0"] [tool.setuptools] +packages = ["prompts"] py-modules = [ "agent", "internal_sources", diff --git a/agent/tests/test_health.py b/agent/tests/test_health.py index e2f0327..8697cbd 100644 --- a/agent/tests/test_health.py +++ b/agent/tests/test_health.py @@ -1,26 +1,10 @@ from fastapi.testclient import TestClient -# Imported at module top-level (not inside test functions) so that agent.py's -# module-level load_dotenv() runs exactly once, at collection time, before any -# test mutates os.environ. build_agent() reads os.environ at call time, so no -# reload/re-import is ever needed - re-importing later would just be a no-op -# (Python caches modules in sys.modules), but doing it here makes that -# explicit and guarantees these tests are order- and .env-independent: a -# developer's root .env cannot repopulate a key a test just deleted. -# -# NOTE: `main` is intentionally NOT imported here. Unlike agent.py (whose -# module-level code only calls load_dotenv()), main.py's module-level code -# eagerly calls build_agent() and re-raises on failure. Importing it at -# collection time would require a real OPENAI_API_KEY to already be present -# in the environment (before any test's monkeypatch.setenv runs), which -# breaks collection on a clean checkout with no .env and no key exported. -# So `import main` stays function-scoped in test_health_ok, after the -# OPENAI_API_KEY monkeypatch.setenv below - same as before this fix. +# Import before tests mutate environment variables. import agent as agent_mod # noqa: E402 def test_health_ok(monkeypatch): - # Only OPENAI_API_KEY set — TAVILY intentionally absent (it's optional). monkeypatch.setenv("OPENAI_API_KEY", "sk-test") monkeypatch.delenv("TAVILY_API_KEY", raising=False) monkeypatch.delenv("LINEAR_API_KEY", raising=False) @@ -64,31 +48,22 @@ def test_local_agent_port_rejects_invalid_server_port(): def test_build_agent_without_tavily(monkeypatch, capsys): - # The core requirement: the agent builds with OPENAI_API_KEY alone (no Tavily), - # and the research web tool is NOT loaded in that case. build_agent() reads - # os.environ at call time, so no reload of agent_mod is needed here (see the - # module-level import comment above for why). monkeypatch.setenv("OPENAI_API_KEY", "sk-test") monkeypatch.delenv("TAVILY_API_KEY", raising=False) monkeypatch.delenv("LINEAR_API_KEY", raising=False) monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) - graph = agent_mod.build_agent() # must NOT raise + graph = agent_mod.build_agent() assert graph is not None - # Verify the research tool is actually gated off, not just that build - # succeeded - build_agent() logs its gating decision via print(). out = capsys.readouterr().out assert "[AGENT] research: disabled" in out def test_build_agent_with_tavily(monkeypatch, capsys): - # Mirror of the above: when TAVILY_API_KEY IS configured, the research - # tool must be enabled. Together these two tests pin down both branches - # of the has_research gate rather than just asserting the graph exists. monkeypatch.setenv("OPENAI_API_KEY", "sk-test") monkeypatch.setenv("TAVILY_API_KEY", "tvly-test") monkeypatch.delenv("LINEAR_API_KEY", raising=False) monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) - graph = agent_mod.build_agent() # must NOT raise + graph = agent_mod.build_agent() assert graph is not None out = capsys.readouterr().out assert "[AGENT] research: enabled" in out diff --git a/agent/tests/test_internal_sources.py b/agent/tests/test_internal_sources.py index d3a2db6..103527a 100644 --- a/agent/tests/test_internal_sources.py +++ b/agent/tests/test_internal_sources.py @@ -8,6 +8,21 @@ from write_confirmation import WriteConfirmationInterceptor +def test_mcp_servers_are_configured_in_one_place(): + assert internal_sources.MCP_SERVERS == { + "linear": { + "token_env": "LINEAR_API_KEY", + "url_env": "LINEAR_MCP_URL", + "default_url": "https://mcp.linear.app/mcp", + }, + "notion": { + "token_env": "NOTION_MCP_AUTH_TOKEN", + "url_env": "NOTION_MCP_URL", + "default_url": "http://127.0.0.1:3001/mcp", + }, + } + + def test_internal_source_tools_empty_without_env(monkeypatch): monkeypatch.delenv("LINEAR_API_KEY", raising=False) monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) @@ -37,7 +52,9 @@ async def get_tools(self): ] monkeypatch.setenv("LINEAR_API_KEY", "lin_api_test") + monkeypatch.setenv("LINEAR_MCP_URL", "https://linear.example.test/mcp") monkeypatch.setenv("NOTION_MCP_AUTH_TOKEN", "notion-test-token") + monkeypatch.setenv("NOTION_MCP_URL", "https://notion.example.test/mcp") monkeypatch.setattr( internal_sources, "MultiServerMCPClient", @@ -55,6 +72,57 @@ async def get_tools(self): isinstance(client.tool_interceptors[0], WriteConfirmationInterceptor) for client in clients ) + assert [client.connections for client in clients] == [ + { + "linear": { + "transport": "streamable_http", + "url": "https://linear.example.test/mcp", + "headers": {"Authorization": "Bearer lin_api_test"}, + } + }, + { + "notion": { + "transport": "streamable_http", + "url": "https://notion.example.test/mcp", + "headers": {"Authorization": "Bearer notion-test-token"}, + } + }, + ] + + +def test_one_unavailable_source_does_not_remove_the_other( + monkeypatch, + caplog, +): + class PartialMCPClient: + def __init__(self, connections, *, tool_interceptors): + self.name = next(iter(connections)) + + async def get_tools(self): + if self.name == "linear": + raise RuntimeError("unavailable") + return [ + StructuredTool.from_function( + func=lambda: "notion", + name="notion-search", + description="test tool", + metadata={"readOnlyHint": True}, + ) + ] + + monkeypatch.setenv("LINEAR_API_KEY", "lin_api_test") + monkeypatch.setenv("NOTION_MCP_AUTH_TOKEN", "notion-test-token") + monkeypatch.setattr( + internal_sources, + "MultiServerMCPClient", + PartialMCPClient, + ) + + with caplog.at_level(logging.ERROR, logger=internal_sources.__name__): + result = internal_sources.internal_source_tools() + + assert [tool.name for tool in result] == ["notion-search"] + assert caplog.records[0].msg["context"]["source"] == "linear" @pytest.mark.parametrize( diff --git a/agent/tests/test_packaging.py b/agent/tests/test_packaging.py index 8e0d613..b2921ab 100644 --- a/agent/tests/test_packaging.py +++ b/agent/tests/test_packaging.py @@ -1,5 +1,5 @@ -from pathlib import Path import tomllib +from pathlib import Path def test_wheel_includes_every_runtime_module(): @@ -13,3 +13,4 @@ def test_wheel_includes_every_runtime_module(): } assert packaged_modules == runtime_modules + assert project["tool"]["setuptools"]["packages"] == ["prompts"] diff --git a/agent/tests/test_prompts.py b/agent/tests/test_prompts.py new file mode 100644 index 0000000..7f204b4 --- /dev/null +++ b/agent/tests/test_prompts.py @@ -0,0 +1,15 @@ +from prompts import ( + BASE_SYSTEM_PROMPT, + NO_RESEARCH_TOOL_ADDENDUM, + RESEARCH_TOOL_ADDENDUM, + RESEARCHER_SYSTEM_PROMPT, +) + + +def test_prompt_modules_preserve_agent_behavior(): + assert "OpenTag's Deep Research Assistant" in BASE_SYSTEM_PROMPT + assert "CRITICAL:" in BASE_SYSTEM_PROMPT + assert "Linear or Notion mutation" in BASE_SYSTEM_PROMPT + assert "research(query)" in RESEARCH_TOOL_ADDENDUM + assert "do NOT have a live web research tool" in NO_RESEARCH_TOOL_ADDENDUM + assert "Call internet_search ONCE" in RESEARCHER_SYSTEM_PROMPT diff --git a/agent/tools.py b/agent/tools.py index a734b92..0f861cc 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -1,31 +1,18 @@ -""" -Tavily-based Tools for Deep Research Agent - -Provides web search with content using the Tavily API. -The search returns full page content, eliminating the need for separate scraping. - -The research() tool wraps an internal Deep Agent that runs in a separate thread -to prevent subagent text from leaking to the frontend via LangChain callback propagation. -""" +"""Tavily-backed web research tools.""" +from concurrent.futures import ThreadPoolExecutor import os from typing import Any -from concurrent.futures import ThreadPoolExecutor -from langchain_core.tools import tool + from langchain_core.messages import HumanMessage +from langchain_core.tools import tool from tavily import TavilyClient +from prompts import RESEARCHER_SYSTEM_PROMPT -def _do_internet_search(query: str, max_results: int = 5) -> list[dict[str, Any]]: - """Core search logic - callable as regular function. - - Args: - query: The search query string - max_results: Maximum number of results to return (default: 5) - Returns: - List of dicts with url, title, and content for each result - """ +def _do_internet_search(query: str, max_results: int = 5) -> list[dict[str, Any]]: + """Search Tavily and normalize its results.""" print(f"[TOOL] internet_search: query='{query}', max_results={max_results}") tavily_key = os.environ.get("TAVILY_API_KEY") @@ -37,20 +24,17 @@ def _do_internet_search(query: str, max_results: int = 5) -> list[dict[str, Any] results = client.search( query=query, max_results=max_results, - include_raw_content=False, # Disable raw content for performance + include_raw_content=False, topic="general", ) - # Format results for agent consumption formatted_results = [] for r in results.get("results", []): formatted_results.append( { "url": r.get("url", ""), "title": r.get("title", ""), - "content": (r.get("content") or "")[ - :3000 - ], # Truncate to 3000 chars + "content": (r.get("content") or "")[:3000], } ) @@ -63,49 +47,17 @@ def _do_internet_search(query: str, max_results: int = 5) -> list[dict[str, Any] @tool def research(query: str) -> dict: - """ - Research a topic using web search. Returns structured data with sources. - - This tool creates an internal Deep Agent that runs in a SEPARATE THREAD to prevent - LangChain callback propagation. The thread has isolated execution context, so the - internal agent's events don't leak to the parent's astream_events() stream. - - Args: - query: The research query/topic to investigate - - Returns: - dict: { - "summary": str - Prose summary of findings, - "sources": list[dict] - [{url, title, content, status}, ...] - } - """ + """Research a topic and return a prose summary with sources.""" print(f"[TOOL] research: query='{query}' (using thread isolation)") from deepagents import create_deep_agent from langchain_openai import ChatOpenAI def _run_research_isolated(): - """ - Runs in separate thread with no inherited LangChain context. - This breaks callback propagation at the OS level. - """ - # Capture internet_search results search_results = [] - # Wrapper to capture results while passing through to agent. - # Named `internet_search` (not `internet_search_tracked`) because - # LangChain derives the tool name the model sees from `__name__`, - # and `researcher_prompt` below refers to the tool as `internet_search`. def internet_search(query: str, max_results: int = 5): - """Search the web and return results with content. - - Args: - query: The search query string - max_results: Maximum number of results to return (default: 5) - - Returns: - List of dicts with url, title, and content for each result - """ + """Search the web and capture sources for the final response.""" results = _do_internet_search(query, max_results) search_results.extend(results) return results @@ -116,48 +68,32 @@ def internet_search(query: str, max_results: int = 5): api_key=os.environ.get("OPENAI_API_KEY"), ) - # System prompt for the internal researcher - researcher_prompt = """You are a Research Specialist. - -Use internet_search to find information. Return a prose summary of findings. - -Rules: -- Call internet_search ONCE with a focused query -- Analyze the returned content -- Return a brief summary (2-3 sentences) of key findings -- No JSON, no code blocks, just prose""" - research_agent = create_deep_agent( model=llm, - system_prompt=researcher_prompt, - tools=[internet_search], # Use tracked version - # No middleware - this runs in isolated thread + system_prompt=RESEARCHER_SYSTEM_PROMPT, + tools=[internet_search], ) - # Run in isolated thread context - no callback inheritance possible result = research_agent.invoke({"messages": [HumanMessage(content=query)]}) - summary = result["messages"][-1].content - # Format sources for frontend sources = [ { "url": r["url"], "title": r.get("title", ""), - "content": r.get("content", "")[:3000], # Include content preview + "content": r.get("content", "")[:3000], "status": "found", } for r in search_results - if "url" in r and not r.get("error") + if "url" in r ] return {"summary": summary, "sources": sources} - # Run in thread pool to isolate from parent async context - # This blocks the tool execution until research completes, which is acceptable + # The separate thread prevents callbacks from leaking into the parent run. with ThreadPoolExecutor(max_workers=1) as executor: future = executor.submit(_run_research_isolated) - result = future.result() # Blocks until complete + result = future.result() print(f"[TOOL] research: completed with {len(result['sources'])} sources") return result diff --git a/agent/write_confirmation.py b/agent/write_confirmation.py index d8a3c7c..0dbba5e 100644 --- a/agent/write_confirmation.py +++ b/agent/write_confirmation.py @@ -12,14 +12,9 @@ class WriteConfirmationInterceptor: - """Require an explicit user confirmation before any MCP mutation runs. - - MCP annotations identify read-only operations. Unknown or unannotated - operations fail closed and pause before their remote handler is invoked. - The two Notion search operations below use POST while remaining read-only, - so the OpenAPI bridge cannot infer their annotation. - """ + """Require approval for every MCP tool not marked read-only.""" + # These Notion search endpoints use POST but do not mutate data. _KNOWN_READ_ONLY_TOOLS = { "API-post-search", "API-query-data-source", From 83a89567ee68f2cc81278ff9ada1354fa4da52cf Mon Sep 17 00:00:00 2001 From: Tyler Slaton <tyler@copilotkit.ai> Date: Mon, 27 Jul 2026 19:58:19 -0400 Subject: [PATCH 10/19] fix(deploy): restore Notion MCP sidecar --- .railway/railway.ts | 27 ++++++++++++++++++++++----- README.md | 24 +++++++++++++----------- app/railway.test.ts | 37 ++++++++++++++++++++++++++++++++++++- setup.md | 36 ++++++++++++++++++++++++------------ 4 files changed, 95 insertions(+), 29 deletions(-) diff --git a/.railway/railway.ts b/.railway/railway.ts index b47092b..941e30b 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -1,8 +1,7 @@ /** - * Reconciles OpenTag's production Railway topology: one Python agent and one - * Intelligence-connected Channel host, both deployed from `main`. Platform - * credentials and attachments stay in Intelligence, so Railway owns only the - * application services and their private AG-UI connection. + * Reconciles OpenTag's production Railway topology: a Python agent, its Notion + * MCP sidecar, and an Intelligence-connected Channel host, all deployed from + * `main`. Platform credentials and attachments stay in Intelligence. */ import { defineRailway, github, preserve, project, service } from "railway/iac"; @@ -10,6 +9,21 @@ const REPO = "CopilotKit/OpenTag"; const BRANCH = "main"; export default defineRailway(() => { + const notionMcp = service("notion-mcp", { + source: github(REPO, { branch: BRANCH }), + start: "pnpm notion-mcp", + deploy: { + restartPolicyType: "ON_FAILURE", + restartPolicyMaxRetries: 5, + }, + env: { + NOTION_MCP_PORT: "3001", + NOTION_MCP_HOST: "::", + NOTION_TOKEN: preserve(), + NOTION_MCP_AUTH_TOKEN: preserve(), + }, + }); + const agent = service("agent", { source: github(REPO, { branch: BRANCH, @@ -28,6 +42,9 @@ export default defineRailway(() => { OPENAI_API_KEY: preserve(), TAVILY_API_KEY: preserve(), LINEAR_API_KEY: preserve(), + NOTION_MCP_URL: + "http://${{notion-mcp.RAILWAY_PRIVATE_DOMAIN}}:${{notion-mcp.NOTION_MCP_PORT}}/mcp", + NOTION_MCP_AUTH_TOKEN: "${{notion-mcp.NOTION_MCP_AUTH_TOKEN}}", }, }); @@ -58,5 +75,5 @@ export default defineRailway(() => { }, }); - return project("opentag", { resources: [agent, channel] }); + return project("opentag", { resources: [notionMcp, agent, channel] }); }); diff --git a/README.md b/README.md index 6b0f58e..b2f3d59 100644 --- a/README.md +++ b/README.md @@ -134,9 +134,9 @@ Slack app**. Reusing it preserves the bot user, workspace installation, and - `TAVILY_API_KEY` enables live web research. Without it, OpenTag still chats, plans, uses its virtual filesystem, and renders UI from model knowledge. - `LINEAR_API_KEY` enables the hosted Linear MCP. -- Notion can be used locally with `pnpm notion-mcp`; its token and shared - `NOTION_MCP_AUTH_TOKEN` live in the root `.env`. The Railway launch - intentionally has no Notion sidecar. +- Notion runs through the `notion-mcp` sidecar in Railway. For local + development, put its token and shared `NOTION_MCP_AUTH_TOKEN` in the root + `.env` and run `pnpm notion-mcp`. Every Linear and Notion mutation is intercepted in code before the MCP request runs. The interceptor emits `confirm_write` and proceeds only after approval; @@ -144,20 +144,22 @@ reads and rendering do not pause. ## Railway -[`.railway/railway.ts`](./.railway/railway.ts) defines exactly two services, -both sourced from `CopilotKit/OpenTag` on `main`: +[`.railway/railway.ts`](./.railway/railway.ts) defines exactly three services, +all sourced from `CopilotKit/OpenTag` on `main`: | Service | Root | Start | Health | | --- | --- | --- | --- | +| `notion-mcp` | repository root | `pnpm notion-mcp` | process restart policy | | `agent` | `agent` | `uvicorn main:app --host :: --port ${PORT:-8123}` | `/health` | | `channel` | repository root | `pnpm channel` | Channels readiness before listen | -The Channel reaches the agent over Railway private networking. Railway sets -`INTELLIGENCE_CHANNEL_NAME=kite`, so the runtime declares the managed `kite` -Slack Channel and `kite-teams` Teams Channel. It preserves the runtime API key. -OpenAI is required on `agent`; Tavily and Linear secrets are optional. -Connecting both services to `main` enables GitHub-triggered deployments after -merges. +The Channel reaches the agent, and the agent reaches the Notion sidecar, over +Railway private networking. Railway sets `INTELLIGENCE_CHANNEL_NAME=kite`, so +the runtime declares the managed `kite` Slack Channel and `kite-teams` Teams +Channel. It preserves the runtime API key. OpenAI is required on `agent`; +Tavily and Linear secrets are optional. `NOTION_TOKEN` and +`NOTION_MCP_AUTH_TOKEN` must be set on `notion-mcp`. Connecting all three +services to `main` enables GitHub-triggered deployments after merges. The repository configuration does not mutate the existing production Railway project. Inventory and cutover should happen after Railway authentication. diff --git a/app/railway.test.ts b/app/railway.test.ts index d0b66bf..6d6b681 100644 --- a/app/railway.test.ts +++ b/app/railway.test.ts @@ -44,11 +44,12 @@ function evaluateRailwayGraph(): RailwayResource[] { } describe("Railway deployment graph", () => { - it("ships exactly the Python agent and Chromium-capable channel services", () => { + it("ships the Python agent, Notion MCP, and Chromium-capable channel services", () => { const resources = evaluateRailwayGraph(); expect(resources.map(({ name }) => name).sort()).toEqual([ "agent", "channel", + "notion-mcp", ]); const agent = resources.find(({ name }) => name === "agent"); @@ -61,6 +62,40 @@ describe("Railway deployment graph", () => { deploy: { healthcheckPath: "/health", }, + variables: { + NOTION_MCP_URL: { + type: "literal", + value: + "http://${{notion-mcp.RAILWAY_PRIVATE_DOMAIN}}:${{notion-mcp.NOTION_MCP_PORT}}/mcp", + }, + NOTION_MCP_AUTH_TOKEN: { + type: "literal", + value: "${{notion-mcp.NOTION_MCP_AUTH_TOKEN}}", + }, + }, + }); + + const notionMcp = resources.find(({ name }) => name === "notion-mcp"); + expect(notionMcp).toMatchObject({ + source: { + repo: "CopilotKit/OpenTag", + branch: "main", + }, + deploy: { + startCommand: "pnpm notion-mcp", + }, + variables: { + NOTION_MCP_PORT: { + type: "literal", + value: "3001", + }, + NOTION_MCP_HOST: { + type: "literal", + value: "::", + }, + NOTION_TOKEN: { type: "preserve" }, + NOTION_MCP_AUTH_TOKEN: { type: "preserve" }, + }, }); const channel = resources.find(({ name }) => name === "channel"); diff --git a/setup.md b/setup.md index 3935c76..bd691c4 100644 --- a/setup.md +++ b/setup.md @@ -1,7 +1,8 @@ # OpenTag setup This guide covers the canonical OpenTag deployment: one Python deep-agent -service and one Node Channel service connected to CopilotKit Intelligence. +service, one Notion MCP sidecar, and one Node Channel service connected to +CopilotKit Intelligence. Slack and Microsoft Teams are supported for this launch. Discord, Telegram, and WhatsApp are coming soon. @@ -15,7 +16,7 @@ and WhatsApp are coming soon. | Channel definition | [`app/channel.tsx`](./app/channel.tsx) | Mentions, commands, components, modals, and interrupts | | Intelligence runtime | [`app/runtime-host.ts`](./app/runtime-host.ts) | One `CopilotKitIntelligence` and one `CopilotRuntime` | | Python agent | [`agent/`](./agent) | LangGraph deep agent served over AG-UI | -| Railway topology | [`.railway/railway.ts`](./.railway/railway.ts) | Two services sourced from OpenTag `main` | +| Railway topology | [`.railway/railway.ts`](./.railway/railway.ts) | Three services sourced from OpenTag `main` | The host always uses the Intelligence-owned runtime. It declares an adapter-free Slack Channel using the configured base name and an adapter-free @@ -62,7 +63,7 @@ cp .env.example .env | `TAVILY_API_KEY` | No | Enables live web research | | `LINEAR_API_KEY` | No | Enables the hosted Linear MCP | | `LINEAR_MCP_URL` | No | Overrides the hosted Linear MCP URL | -| `NOTION_MCP_AUTH_TOKEN` | No | Authenticates to a local Notion MCP sidecar | +| `NOTION_MCP_AUTH_TOKEN` | No | Authenticates to the Notion MCP sidecar | | `NOTION_MCP_URL` | No | Defaults to `http://127.0.0.1:3001/mcp` | | `SERVER_HOST` | No | Local bind host; defaults to `0.0.0.0` | | `SERVER_PORT` / `PORT` | No | Local port; defaults to `8123` | @@ -183,30 +184,40 @@ Set `TAVILY_API_KEY` in the root `.env` to enable live web research. The Set `LINEAR_API_KEY` in the root `.env`. OpenTag connects to the hosted Linear MCP by default. Railway preserves this optional secret on the `agent` service. -### Notion for local development +### Notion -The Railway launch has no Notion sidecar. For local development: +Railway deploys the Notion MCP sidecar and connects it to the agent over the +private network. Set `NOTION_TOKEN` and `NOTION_MCP_AUTH_TOKEN` on the +`notion-mcp` Railway service. The shared auth token is referenced by the agent +automatically. + +For local development: 1. Put `NOTION_TOKEN` and a strong `NOTION_MCP_AUTH_TOKEN` in the root `.env`. 2. Run `pnpm notion-mcp`. 3. Restart `pnpm agent` so it discovers the Notion tools. -Notion is optional and is not a deployment blocker. +The agent can run without Notion, but both secrets are required when the +`notion-mcp` service is deployed. ## Railway The IaC file declares exactly: +- `notion-mcp`: `CopilotKit/OpenTag`, branch `main`, repository root, + `pnpm notion-mcp`, port `3001`. - `agent`: `CopilotKit/OpenTag`, branch `main`, root `agent`, Nixpacks, `/health`, port `8123`. - `channel`: `CopilotKit/OpenTag`, branch `main`, repository root, `pnpm channel`, port `3000`. `channel.AGENT_URL` references the agent's Railway private domain and port. -Production Intelligence URLs are literal configuration, the API key is -preserved, and the base Channel name is `kite` (with `kite-teams` derived by -the application). `OPENAI_API_KEY` is required on `agent`; Tavily and Linear -are optional preserved secrets. +The agent's Notion URL and shared bearer reference `notion-mcp` over the same +private network. Production Intelligence URLs are literal configuration, the +API key is preserved, and the base Channel name is `kite` (with `kite-teams` +derived by the application). `OPENAI_API_KEY` is required on `agent`; Tavily +and Linear are optional preserved secrets. `NOTION_TOKEN` and +`NOTION_MCP_AUTH_TOKEN` are required on `notion-mcp`. Evaluate the configuration locally without applying it: @@ -215,8 +226,9 @@ node node_modules/railway/dist/iac/bin.js ``` The live Railway migration still requires authenticated access: inventory the -existing project, reuse its current Kite service as `channel`, add `agent`, -connect both sources to OpenTag `main`, and enable GitHub autodeploys. +existing project, reuse its current Kite service as `channel`, add `agent` and +`notion-mcp`, connect all three sources to OpenTag `main`, and enable GitHub +autodeploys. ## Coming soon From 3d2e109576e2d96074715f6e5f3a4fd2deb8d80a Mon Sep 17 00:00:00 2001 From: Tyler Slaton <tyler@copilotkit.ai> Date: Mon, 27 Jul 2026 20:43:49 -0400 Subject: [PATCH 11/19] fix(deploy): make Railway agent deployable --- .railway/railway.ts | 4 ++-- README.md | 2 +- app/railway.test.ts | 5 +++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.railway/railway.ts b/.railway/railway.ts index 941e30b..949be66 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -29,9 +29,9 @@ export default defineRailway(() => { branch: BRANCH, rootDirectory: "agent", }), - build: { builder: "NIXPACKS" }, + build: { builder: "RAILPACK" }, deploy: { - startCommand: "uvicorn main:app --host :: --port ${PORT:-8123}", + startCommand: 'uvicorn main:app --host "" --port ${PORT:-8123}', healthcheckPath: "/health", healthcheckTimeout: 300, restartPolicyType: "ON_FAILURE", diff --git a/README.md b/README.md index b2f3d59..8da3159 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ all sourced from `CopilotKit/OpenTag` on `main`: | Service | Root | Start | Health | | --- | --- | --- | --- | | `notion-mcp` | repository root | `pnpm notion-mcp` | process restart policy | -| `agent` | `agent` | `uvicorn main:app --host :: --port ${PORT:-8123}` | `/health` | +| `agent` | `agent` | `uvicorn main:app --host "" --port ${PORT:-8123}` | `/health` | | `channel` | repository root | `pnpm channel` | Channels readiness before listen | The Channel reaches the agent, and the agent reaches the Notion sidecar, over diff --git a/app/railway.test.ts b/app/railway.test.ts index 6d6b681..c7981dc 100644 --- a/app/railway.test.ts +++ b/app/railway.test.ts @@ -59,7 +59,12 @@ describe("Railway deployment graph", () => { branch: "main", rootDirectory: "agent", }, + build: { + builder: "RAILPACK", + }, deploy: { + startCommand: + 'uvicorn main:app --host "" --port ${PORT:-8123}', healthcheckPath: "/health", }, variables: { From 5a3b711108e50097a5f52a14bc75962508cb6ef8 Mon Sep 17 00:00:00 2001 From: Tyler Slaton <tyler@copilotkit.ai> Date: Mon, 27 Jul 2026 20:54:38 -0400 Subject: [PATCH 12/19] refactor(deploy): embed channels in runtime --- .railway/railway.ts | 32 +++++++-------------------- README.md | 35 ++++++++++++++---------------- app/railway.test.ts | 51 +++++++------------------------------------ package.json | 2 +- setup.md | 53 +++++++++++++++++---------------------------- 5 files changed, 53 insertions(+), 120 deletions(-) diff --git a/.railway/railway.ts b/.railway/railway.ts index 949be66..a527092 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -1,7 +1,7 @@ /** - * Reconciles OpenTag's production Railway topology: a Python agent, its Notion - * MCP sidecar, and an Intelligence-connected Channel host, all deployed from - * `main`. Platform credentials and attachments stay in Intelligence. + * Reconciles OpenTag's production Railway topology: a Python agent and the + * CopilotRuntime process that embeds Channels. Platform credentials and + * attachments stay in Intelligence. */ import { defineRailway, github, preserve, project, service } from "railway/iac"; @@ -9,21 +9,6 @@ const REPO = "CopilotKit/OpenTag"; const BRANCH = "main"; export default defineRailway(() => { - const notionMcp = service("notion-mcp", { - source: github(REPO, { branch: BRANCH }), - start: "pnpm notion-mcp", - deploy: { - restartPolicyType: "ON_FAILURE", - restartPolicyMaxRetries: 5, - }, - env: { - NOTION_MCP_PORT: "3001", - NOTION_MCP_HOST: "::", - NOTION_TOKEN: preserve(), - NOTION_MCP_AUTH_TOKEN: preserve(), - }, - }); - const agent = service("agent", { source: github(REPO, { branch: BRANCH, @@ -42,21 +27,20 @@ export default defineRailway(() => { OPENAI_API_KEY: preserve(), TAVILY_API_KEY: preserve(), LINEAR_API_KEY: preserve(), - NOTION_MCP_URL: - "http://${{notion-mcp.RAILWAY_PRIVATE_DOMAIN}}:${{notion-mcp.NOTION_MCP_PORT}}/mcp", - NOTION_MCP_AUTH_TOKEN: "${{notion-mcp.NOTION_MCP_AUTH_TOKEN}}", }, }); - const channel = service("channel", { + const runtime = service("runtime", { source: github(REPO, { branch: BRANCH }), - start: "pnpm channel", + start: "pnpm runtime", // Rich rendering needs Chromium and its runtime libraries. build: { builder: "RAILPACK", buildCommand: "pnpm exec playwright install chromium", }, deploy: { + healthcheckPath: "/api/copilotkit/info", + healthcheckTimeout: 300, restartPolicyType: "ON_FAILURE", restartPolicyMaxRetries: 5, }, @@ -75,5 +59,5 @@ export default defineRailway(() => { }, }); - return project("opentag", { resources: [notionMcp, agent, channel] }); + return project("opentag", { resources: [agent, runtime] }); }); diff --git a/README.md b/README.md index 8da3159..47f8b9d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Slack / Microsoft Teams CopilotKit Intelligence │ realtime ▼ -channel (Node + CopilotRuntime + Channels) +runtime (Node + CopilotRuntime with embedded Channels) │ AG-UI ▼ agent (Python + LangGraph deepagents) @@ -30,7 +30,7 @@ agent (Python + LangGraph deepagents) └── Notion MCP (optional local sidecar) ``` -There is one canonical Channel host: [`server.ts`](./server.ts). +There is one canonical runtime host: [`server.ts`](./server.ts). [`app/index.ts`](./app/index.ts) composes one `CopilotKitIntelligence`, one `CopilotRuntime`, and two adapter-free managed Channels: the configured base name for Slack and `<base>-teams` for Microsoft Teams. Intelligence owns all @@ -82,10 +82,10 @@ Prerequisites: Node.js 22+, pnpm, Python 3.12, and ```bash pnpm agent - pnpm channel + pnpm runtime ``` -The Channel waits for its Intelligence connection to become ready before its +The runtime waits for its Intelligence connection to become ready before its HTTP listener accepts traffic. ## What OpenTag includes @@ -113,7 +113,7 @@ Use the normal Intelligence flow for both launch platforms: 2. Create the base Channel for Slack and `<base>-teams` for Microsoft Teams. 3. Issue a runtime API key. 4. Configure each platform attachment on its matching Channel. -5. Run one `pnpm channel` process with the base Channel name and key. +5. Run one `pnpm runtime` process with the base Channel name and key. No organization, project, Channel ID, or runtime-instance ID environment variables—or Slack/Teams credentials—are required by the runtime. @@ -134,9 +134,8 @@ Slack app**. Reusing it preserves the bot user, workspace installation, and - `TAVILY_API_KEY` enables live web research. Without it, OpenTag still chats, plans, uses its virtual filesystem, and renders UI from model knowledge. - `LINEAR_API_KEY` enables the hosted Linear MCP. -- Notion runs through the `notion-mcp` sidecar in Railway. For local - development, put its token and shared `NOTION_MCP_AUTH_TOKEN` in the root - `.env` and run `pnpm notion-mcp`. +- Notion is optional for local development: put its token and shared + `NOTION_MCP_AUTH_TOKEN` in the root `.env` and run `pnpm notion-mcp`. Every Linear and Notion mutation is intercepted in code before the MCP request runs. The interceptor emits `confirm_write` and proceeds only after approval; @@ -144,22 +143,20 @@ reads and rendering do not pause. ## Railway -[`.railway/railway.ts`](./.railway/railway.ts) defines exactly three services, +[`.railway/railway.ts`](./.railway/railway.ts) defines exactly two services, all sourced from `CopilotKit/OpenTag` on `main`: | Service | Root | Start | Health | | --- | --- | --- | --- | -| `notion-mcp` | repository root | `pnpm notion-mcp` | process restart policy | | `agent` | `agent` | `uvicorn main:app --host "" --port ${PORT:-8123}` | `/health` | -| `channel` | repository root | `pnpm channel` | Channels readiness before listen | - -The Channel reaches the agent, and the agent reaches the Notion sidecar, over -Railway private networking. Railway sets `INTELLIGENCE_CHANNEL_NAME=kite`, so -the runtime declares the managed `kite` Slack Channel and `kite-teams` Teams -Channel. It preserves the runtime API key. OpenAI is required on `agent`; -Tavily and Linear secrets are optional. `NOTION_TOKEN` and -`NOTION_MCP_AUTH_TOKEN` must be set on `notion-mcp`. Connecting all three -services to `main` enables GitHub-triggered deployments after merges. +| `runtime` | repository root | `pnpm runtime` | `/api/copilotkit/info` | + +The runtime reaches the agent over Railway private networking and embeds both +managed Channels. Railway sets `INTELLIGENCE_CHANNEL_NAME=kite`, so the runtime +declares `kite` for Slack and `kite-teams` for Teams. It preserves the runtime +API key. OpenAI is required on `agent`; Tavily and Linear secrets are optional. +Connecting both services to `main` enables GitHub-triggered deployments after +merges. The repository configuration does not mutate the existing production Railway project. Inventory and cutover should happen after Railway authentication. diff --git a/app/railway.test.ts b/app/railway.test.ts index c7981dc..7209869 100644 --- a/app/railway.test.ts +++ b/app/railway.test.ts @@ -44,13 +44,9 @@ function evaluateRailwayGraph(): RailwayResource[] { } describe("Railway deployment graph", () => { - it("ships the Python agent, Notion MCP, and Chromium-capable channel services", () => { + it("ships the Python agent and Chromium-capable runtime services", () => { const resources = evaluateRailwayGraph(); - expect(resources.map(({ name }) => name).sort()).toEqual([ - "agent", - "channel", - "notion-mcp", - ]); + expect(resources.map(({ name }) => name).sort()).toEqual(["agent", "runtime"]); const agent = resources.find(({ name }) => name === "agent"); expect(agent).toMatchObject({ @@ -67,44 +63,12 @@ describe("Railway deployment graph", () => { 'uvicorn main:app --host "" --port ${PORT:-8123}', healthcheckPath: "/health", }, - variables: { - NOTION_MCP_URL: { - type: "literal", - value: - "http://${{notion-mcp.RAILWAY_PRIVATE_DOMAIN}}:${{notion-mcp.NOTION_MCP_PORT}}/mcp", - }, - NOTION_MCP_AUTH_TOKEN: { - type: "literal", - value: "${{notion-mcp.NOTION_MCP_AUTH_TOKEN}}", - }, - }, - }); - - const notionMcp = resources.find(({ name }) => name === "notion-mcp"); - expect(notionMcp).toMatchObject({ - source: { - repo: "CopilotKit/OpenTag", - branch: "main", - }, - deploy: { - startCommand: "pnpm notion-mcp", - }, - variables: { - NOTION_MCP_PORT: { - type: "literal", - value: "3001", - }, - NOTION_MCP_HOST: { - type: "literal", - value: "::", - }, - NOTION_TOKEN: { type: "preserve" }, - NOTION_MCP_AUTH_TOKEN: { type: "preserve" }, - }, }); + expect(agent?.variables).not.toHaveProperty("NOTION_MCP_URL"); + expect(agent?.variables).not.toHaveProperty("NOTION_MCP_AUTH_TOKEN"); - const channel = resources.find(({ name }) => name === "channel"); - expect(channel).toMatchObject({ + const runtime = resources.find(({ name }) => name === "runtime"); + expect(runtime).toMatchObject({ source: { repo: "CopilotKit/OpenTag", branch: "main", @@ -114,7 +78,8 @@ describe("Railway deployment graph", () => { buildCommand: "pnpm exec playwright install chromium", }, deploy: { - startCommand: "pnpm channel", + startCommand: "pnpm runtime", + healthcheckPath: "/api/copilotkit/info", }, variables: { AGENT_URL: { diff --git a/package.json b/package.json index febc5ad..349f3ac 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "scripts": { "dev": "tsx watch server.ts", "start": "tsx server.ts", - "channel": "tsx server.ts", + "runtime": "tsx server.ts", "agent": "cd agent && uv run python main.py", "notion-mcp": "tsx scripts/start-notion-mcp.ts", "check-types": "tsc --noEmit -p tsconfig.json", diff --git a/setup.md b/setup.md index bd691c4..bf8458e 100644 --- a/setup.md +++ b/setup.md @@ -1,8 +1,7 @@ # OpenTag setup This guide covers the canonical OpenTag deployment: one Python deep-agent -service, one Notion MCP sidecar, and one Node Channel service connected to -CopilotKit Intelligence. +service and one Node CopilotRuntime service with Channels embedded. Slack and Microsoft Teams are supported for this launch. Discord, Telegram, and WhatsApp are coming soon. @@ -11,12 +10,12 @@ and WhatsApp are coming soon. | Component | Location | Responsibility | | --- | --- | --- | -| Channel entrypoint | [`server.ts`](./server.ts) | Environment, readiness, HTTP lifecycle, and shutdown | +| Runtime entrypoint | [`server.ts`](./server.ts) | Environment, Channels readiness, HTTP lifecycle, and shutdown | | Application composition | [`app/index.ts`](./app/index.ts) | SDK agent factory, managed Slack/Teams Channels, and runtime | | Channel definition | [`app/channel.tsx`](./app/channel.tsx) | Mentions, commands, components, modals, and interrupts | | Intelligence runtime | [`app/runtime-host.ts`](./app/runtime-host.ts) | One `CopilotKitIntelligence` and one `CopilotRuntime` | | Python agent | [`agent/`](./agent) | LangGraph deep agent served over AG-UI | -| Railway topology | [`.railway/railway.ts`](./.railway/railway.ts) | Three services sourced from OpenTag `main` | +| Railway topology | [`.railway/railway.ts`](./.railway/railway.ts) | Two services sourced from OpenTag `main` | The host always uses the Intelligence-owned runtime. It declares an adapter-free Slack Channel using the configured base name and an adapter-free @@ -108,13 +107,13 @@ Legacy organization, project, Channel ID, and runtime-instance ID variables are not used. Slack and Teams credentials also do not belong in this environment; Intelligence owns them. -Start the Channel: +Start the runtime: ```bash -pnpm channel +pnpm runtime ``` -Use `pnpm dev` for watch mode. `pnpm start` and `pnpm channel` both run the +Use `pnpm dev` for watch mode. `pnpm start` and `pnpm runtime` both run the same canonical entrypoint. Startup waits for `listener.channels.ready()` before opening HTTP. SIGINT and SIGTERM stop Channels, HTTP, and the rendering browser once, even if shutdown is requested more than once. @@ -131,7 +130,7 @@ For cutover: 1. Stop the old Kite Socket Mode runtime so there is only one consumer. 2. Enter the existing `xapp` and `xoxb` tokens directly into the Slack attachment in Intelligence. Do not put them in source control or chat. -3. Start the OpenTag `channel` service with +3. Start the OpenTag `runtime` service with `INTELLIGENCE_CHANNEL_NAME=kite`. 4. Send one `@kite` mention and verify the reply uses the OpenTag persona and Python deep agent. @@ -186,38 +185,28 @@ MCP by default. Railway preserves this optional secret on the `agent` service. ### Notion -Railway deploys the Notion MCP sidecar and connects it to the agent over the -private network. Set `NOTION_TOKEN` and `NOTION_MCP_AUTH_TOKEN` on the -`notion-mcp` Railway service. The shared auth token is referenced by the agent -automatically. - -For local development: +Notion is optional and is not a production Railway service. Its hosted MCP +requires interactive OAuth and token storage, so the included bearer-token +server is only a local development option: 1. Put `NOTION_TOKEN` and a strong `NOTION_MCP_AUTH_TOKEN` in the root `.env`. 2. Run `pnpm notion-mcp`. 3. Restart `pnpm agent` so it discovers the Notion tools. -The agent can run without Notion, but both secrets are required when the -`notion-mcp` service is deployed. - ## Railway The IaC file declares exactly: -- `notion-mcp`: `CopilotKit/OpenTag`, branch `main`, repository root, - `pnpm notion-mcp`, port `3001`. -- `agent`: `CopilotKit/OpenTag`, branch `main`, root `agent`, Nixpacks, +- `agent`: `CopilotKit/OpenTag`, branch `main`, root `agent`, Railpack, `/health`, port `8123`. -- `channel`: `CopilotKit/OpenTag`, branch `main`, repository root, - `pnpm channel`, port `3000`. +- `runtime`: `CopilotKit/OpenTag`, branch `main`, repository root, + `pnpm runtime`, `/api/copilotkit/info`, port `3000`. -`channel.AGENT_URL` references the agent's Railway private domain and port. -The agent's Notion URL and shared bearer reference `notion-mcp` over the same -private network. Production Intelligence URLs are literal configuration, the -API key is preserved, and the base Channel name is `kite` (with `kite-teams` -derived by the application). `OPENAI_API_KEY` is required on `agent`; Tavily -and Linear are optional preserved secrets. `NOTION_TOKEN` and -`NOTION_MCP_AUTH_TOKEN` are required on `notion-mcp`. +`runtime.AGENT_URL` references the agent's Railway private domain and port. +Production Intelligence URLs are literal configuration, the API key is +preserved, and the base Channel name is `kite` (with `kite-teams` derived by +the application). `OPENAI_API_KEY` is required on `agent`; Tavily and Linear +are optional preserved secrets. Evaluate the configuration locally without applying it: @@ -225,10 +214,8 @@ Evaluate the configuration locally without applying it: node node_modules/railway/dist/iac/bin.js ``` -The live Railway migration still requires authenticated access: inventory the -existing project, reuse its current Kite service as `channel`, add `agent` and -`notion-mcp`, connect all three sources to OpenTag `main`, and enable GitHub -autodeploys. +The live Railway migration reuses the existing Kite `runtime` service, adds the +`agent`, connects both to OpenTag, and enables GitHub autodeploys. ## Coming soon From 885cef4a8099a74ddc739f753536cd12fce58723 Mon Sep 17 00:00:00 2001 From: Tyler Slaton <tyler@copilotkit.ai> Date: Mon, 27 Jul 2026 21:21:12 -0400 Subject: [PATCH 13/19] fix(channels): use one Intelligence channel --- .railway/railway.ts | 2 +- README.md | 30 ++++++++++++------------- app/channel.test.ts | 48 ++++++++++------------------------------ app/channel.tsx | 5 +---- app/env.ts | 2 +- app/index.ts | 8 ++----- app/railway.test.ts | 2 +- app/runtime-host.test.ts | 22 +++++------------- app/server.test.ts | 7 +++--- setup.md | 33 ++++++++++++--------------- 10 files changed, 55 insertions(+), 104 deletions(-) diff --git a/.railway/railway.ts b/.railway/railway.ts index a527092..04efad6 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -52,7 +52,7 @@ export default defineRailway(() => { INTELLIGENCE_API_URL: "https://api.intelligence.copilotkit.ai", INTELLIGENCE_GATEWAY_WS_URL: "wss://realtime.intelligence.copilotkit.ai", - INTELLIGENCE_CHANNEL_NAME: "kite", + INTELLIGENCE_CHANNEL_NAME: "open-tag", PLAYWRIGHT_BROWSERS_PATH: "0", RAILPACK_DEPLOY_APT_PACKAGES: "fonts-liberation fonts-noto-color-emoji fonts-unifont libasound2 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 libcairo2 libcups2 libdbus-1-3 libdrm2 libexpat1 libfontconfig1 libfreetype6 libgbm1 libglib2.0-0 libnspr4 libnss3 libpango-1.0-0 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 libxrender1 libxshmfence1", diff --git a/README.md b/README.md index 47f8b9d..1c35c33 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,8 @@ agent (Python + LangGraph deepagents) There is one canonical runtime host: [`server.ts`](./server.ts). [`app/index.ts`](./app/index.ts) composes one `CopilotKitIntelligence`, one -`CopilotRuntime`, and two adapter-free managed Channels: the configured base -name for Slack and `<base>-teams` for Microsoft Teams. Intelligence owns all -platform credentials and attachments. +`CopilotRuntime`, and one adapter-free managed Channel. Intelligence owns its +Slack and Microsoft Teams adapters, credentials, and attachments. The launch pins the requested canaries: @@ -72,11 +71,10 @@ Prerequisites: Node.js 22+, pnpm, Python 3.12, and `INTELLIGENCE_API_URL` and `INTELLIGENCE_GATEWAY_WS_URL` default to the production Intelligence endpoints. `INTELLIGENCE_CHANNEL_NAME` defaults to - `opentag`. + `open-tag`. -3. In Intelligence, create two managed Channels: `opentag` for Slack and - `opentag-teams` for Microsoft Teams. If you change the base name, use that - name and `<base>-teams`. +3. In Intelligence, create one managed Channel named `open-tag` and configure + its Slack and Microsoft Teams adapters. 4. Run both services. @@ -110,10 +108,10 @@ in [`agent/agent.py`](./agent/agent.py); the Channel UI and behavior live under Use the normal Intelligence flow for both launch platforms: 1. Create one OpenTag project. -2. Create the base Channel for Slack and `<base>-teams` for Microsoft Teams. +2. Create one Channel named `open-tag`. 3. Issue a runtime API key. -4. Configure each platform attachment on its matching Channel. -5. Run one `pnpm runtime` process with the base Channel name and key. +4. Configure the Slack and Microsoft Teams adapters on that Channel. +5. Run one `pnpm runtime` process with that Channel name and key. No organization, project, Channel ID, or runtime-instance ID environment variables—or Slack/Teams credentials—are required by the runtime. @@ -151,12 +149,12 @@ all sourced from `CopilotKit/OpenTag` on `main`: | `agent` | `agent` | `uvicorn main:app --host "" --port ${PORT:-8123}` | `/health` | | `runtime` | repository root | `pnpm runtime` | `/api/copilotkit/info` | -The runtime reaches the agent over Railway private networking and embeds both -managed Channels. Railway sets `INTELLIGENCE_CHANNEL_NAME=kite`, so the runtime -declares `kite` for Slack and `kite-teams` for Teams. It preserves the runtime -API key. OpenAI is required on `agent`; Tavily and Linear secrets are optional. -Connecting both services to `main` enables GitHub-triggered deployments after -merges. +The runtime reaches the agent over Railway private networking and embeds the +managed `open-tag` Channel. Railway sets +`INTELLIGENCE_CHANNEL_NAME=open-tag`; Intelligence owns both platform +adapters. The runtime API key is preserved. OpenAI is required on `agent`; +Tavily and Linear secrets are optional. Connecting both services to `main` +enables GitHub-triggered deployments after merges. The repository configuration does not mutate the existing production Railway project. Inventory and cutover should happen after Railway authentication. diff --git a/app/channel.test.ts b/app/channel.test.ts index 6c47347..cd6577c 100644 --- a/app/channel.test.ts +++ b/app/channel.test.ts @@ -87,29 +87,22 @@ afterEach(async () => { vi.restoreAllMocks(); }); -function makeChannel(options: { - provider: "slack" | "teams"; - agent?: FakeAgent; -}) { +function makeChannel(options: { agent?: FakeAgent } = {}) { const adapter = new FakeAdapter({ platform: "intelligence" }); const agent = options.agent ?? new CapturingAgent(); - const channel = createOpenTagChannel("opentag", options.provider, agent); + const channel = createOpenTagChannel("opentag", agent); channel.ɵruntime.addAdapter(adapter); channels.push(channel); return { adapter, agent, channel }; } describe("createOpenTagChannel", () => { - it("declares managed Slack and retains app commands", () => { - const channel = createOpenTagChannel( - "custom-channel", - "slack", - new FakeAgent(), - ); + it("declares one managed Channel and retains app commands", () => { + const channel = createOpenTagChannel("custom-channel", new FakeAgent()); channels.push(channel); expect(channel.name).toBe("custom-channel"); - expect(channel.provider).toBe("slack"); + expect(channel.provider).toBeUndefined(); expect(channel.adapters).toEqual([]); expect(channel.commandNames.sort()).toEqual([ "agent", @@ -121,9 +114,7 @@ describe("createOpenTagChannel", () => { }); it("injects Slack defaults per managed Slack run", async () => { - const { adapter, agent, channel } = makeChannel({ - provider: "slack", - }); + const { adapter, agent, channel } = makeChannel(); await channel.ɵruntime.start(); await adapter.getSink().onTurn({ @@ -149,9 +140,7 @@ describe("createOpenTagChannel", () => { }); it("does not inject Slack defaults into managed Teams", async () => { - const { adapter, agent, channel } = makeChannel({ - provider: "teams", - }); + const { adapter, agent, channel } = makeChannel(); await channel.ɵruntime.start(); await adapter.getSink().onTurn({ @@ -176,9 +165,7 @@ describe("createOpenTagChannel", () => { }); it("injects managed content parts as the current agent prompt", async () => { - const { adapter, agent, channel } = makeChannel({ - provider: "slack", - }); + const { adapter, agent, channel } = makeChannel(); const parts = [{ type: "text" as const, text: "from content parts" }]; await channel.ɵruntime.start(); @@ -197,7 +184,7 @@ describe("createOpenTagChannel", () => { }); it("personalizes suggested prompts when a thread starts", async () => { - const { adapter, channel } = makeChannel({ provider: "slack" }); + const { adapter, channel } = makeChannel(); await channel.ɵruntime.start(); await adapter.emitThreadStarted({ @@ -215,7 +202,7 @@ describe("createOpenTagChannel", () => { const consoleError = vi .spyOn(console, "error") .mockImplementation(() => undefined); - const { adapter, channel } = makeChannel({ provider: "slack" }); + const { adapter, channel } = makeChannel(); adapter.setSuggestedPrompts = vi.fn(async () => { throw new Error("suggested prompts unavailable"); }); @@ -246,7 +233,6 @@ describe("createOpenTagChannel", () => { .spyOn(console, "error") .mockImplementation(() => undefined); const { adapter, channel } = makeChannel({ - provider: "slack", agent: new FakeAgent([ () => { throw error; @@ -290,7 +276,6 @@ describe("createOpenTagChannel", () => { }, ]); const { adapter, channel } = makeChannel({ - provider: "slack", agent, }); @@ -339,7 +324,6 @@ describe("createOpenTagChannel", () => { }, ]); const { adapter, channel } = makeChannel({ - provider: "slack", agent, }); @@ -372,11 +356,7 @@ describe("createOpenTagChannel", () => { } as never); }, ]); - const firstChannel = createOpenTagChannel( - "opentag", - "slack", - firstAgent, - ); + const firstChannel = createOpenTagChannel("opentag", firstAgent); firstChannel.ɵruntime.addAdapter(firstAdapter); channels.push(firstChannel); await firstChannel.ɵruntime.start(); @@ -399,11 +379,7 @@ describe("createOpenTagChannel", () => { const secondAdapter = new FakeAdapter({ platform: "intelligence" }); secondAdapter.stateStore = sharedState; const secondAgent = new FakeAgent(); - const secondChannel = createOpenTagChannel( - "opentag", - "slack", - secondAgent, - ); + const secondChannel = createOpenTagChannel("opentag", secondAgent); secondChannel.ɵruntime.addAdapter(secondAdapter); channels.push(secondChannel); await secondChannel.ɵruntime.start(); diff --git a/app/channel.tsx b/app/channel.tsx index a8b347a..1a3f979 100644 --- a/app/channel.tsx +++ b/app/channel.tsx @@ -2,7 +2,6 @@ import { createChannel, type Channel, type CreateChannelOptions, - type ManagedChannelProvider, } from "@copilotkit/channels"; import { managedRunInput, @@ -18,15 +17,13 @@ import { appTools } from "./tools/index.js"; type ChannelAgent = NonNullable<CreateChannelOptions["agent"]>; -/** Build one managed OpenTag Channel for an Intelligence-owned provider. */ +/** Build the managed OpenTag Channel; Intelligence owns its platform adapters. */ export function createOpenTagChannel( name: string, - provider: ManagedChannelProvider, agent: ChannelAgent, ): Channel { const channel = createChannel({ name, - provider, agent, tools: appTools, context: [...appContext], diff --git a/app/env.ts b/app/env.ts index a19742e..30942d3 100644 --- a/app/env.ts +++ b/app/env.ts @@ -2,7 +2,7 @@ export const DEFAULT_INTELLIGENCE_API_URL = "https://api.intelligence.copilotkit.ai"; export const DEFAULT_INTELLIGENCE_GATEWAY_WS_URL = "wss://realtime.intelligence.copilotkit.ai"; -export const DEFAULT_INTELLIGENCE_CHANNEL_NAME = "opentag"; +export const DEFAULT_INTELLIGENCE_CHANNEL_NAME = "open-tag"; export interface AppEnvironment { agentUrl: string; diff --git a/app/index.ts b/app/index.ts index a2e755d..0cf4e44 100644 --- a/app/index.ts +++ b/app/index.ts @@ -18,12 +18,8 @@ export function createOpenTagApplication( return instance; }; - // Managed providers are declared per Channel. Intelligence owns the Slack and - // Teams credentials and routes each provider to its matching declaration. - const channels = [ - createOpenTagChannel(environment.channelName, "slack", agent), - createOpenTagChannel(`${environment.channelName}-teams`, "teams", agent), - ]; + // Intelligence owns the Slack and Teams adapters for this logical Channel. + const channels = [createOpenTagChannel(environment.channelName, agent)]; const runtimeHost = createOpenTagRuntime({ environment, channels }); return { channels, environment, ...runtimeHost }; diff --git a/app/railway.test.ts b/app/railway.test.ts index 7209869..600dbcf 100644 --- a/app/railway.test.ts +++ b/app/railway.test.ts @@ -98,7 +98,7 @@ describe("Railway deployment graph", () => { }, INTELLIGENCE_CHANNEL_NAME: { type: "literal", - value: "kite", + value: "open-tag", }, PLAYWRIGHT_BROWSERS_PATH: { type: "literal", diff --git a/app/runtime-host.test.ts b/app/runtime-host.test.ts index 3e91524..36a1cbf 100644 --- a/app/runtime-host.test.ts +++ b/app/runtime-host.test.ts @@ -13,7 +13,7 @@ const environment: AppEnvironment = { intelligenceApiKey: "intelligence-secret", intelligenceApiUrl: "https://intelligence.example.test", intelligenceGatewayWsUrl: "wss://gateway.example.test", - channelName: "opentag", + channelName: "open-tag", port: 3000, }; @@ -23,19 +23,10 @@ describe("createOpenTagRuntime", () => { const teamsAdapter = new FakeAdapter({ platform: "teams" }); const stopSlack = vi.spyOn(slackAdapter, "stop"); const stopTeams = vi.spyOn(teamsAdapter, "stop"); - const slackChannel = createOpenTagChannel( - "opentag", - "slack", - new FakeAgent(), - ); - slackChannel.ɵruntime.addAdapter(slackAdapter); - const teamsChannel = createOpenTagChannel( - "opentag-teams", - "teams", - new FakeAgent(), - ); - teamsChannel.ɵruntime.addAdapter(teamsAdapter); - const channels = [slackChannel, teamsChannel]; + const channel = createOpenTagChannel("open-tag", new FakeAgent()); + channel.ɵruntime.addAdapter(slackAdapter); + channel.ɵruntime.addAdapter(teamsAdapter); + const channels = [channel]; const { intelligence, listener, runtime } = createOpenTagRuntime({ environment, @@ -57,8 +48,7 @@ describe("createOpenTagRuntime", () => { expect(listener.channels?.status()).toEqual({ overall: "online", channels: { - opentag: "online", - "opentag-teams": "online", + "open-tag": "online", }, }); expect(slackAdapter.started).toBe(true); diff --git a/app/server.test.ts b/app/server.test.ts index 7a53fae..45ad551 100644 --- a/app/server.test.ts +++ b/app/server.test.ts @@ -134,13 +134,13 @@ describe("startOpenTagServer", () => { }); describe("createOpenTagApplication", () => { - it("declares adapter-free managed Slack and Teams Channels", () => { + it("declares one adapter-free managed Channel", () => { const environment: AppEnvironment = { agentUrl: "http://agent.internal/", intelligenceApiKey: "cpk-1_test", intelligenceApiUrl: "https://api.intelligence.test", intelligenceGatewayWsUrl: "wss://realtime.intelligence.test", - channelName: "kite", + channelName: "open-tag", port: 3000, }; @@ -153,8 +153,7 @@ describe("createOpenTagApplication", () => { adapters: channel.adapters, })), ).toEqual([ - { name: "kite", provider: "slack", adapters: [] }, - { name: "kite-teams", provider: "teams", adapters: [] }, + { name: "open-tag", provider: undefined, adapters: [] }, ]); expect(application.runtime.channels).toEqual(application.channels); }); diff --git a/setup.md b/setup.md index bf8458e..a4b732c 100644 --- a/setup.md +++ b/setup.md @@ -11,16 +11,15 @@ and WhatsApp are coming soon. | Component | Location | Responsibility | | --- | --- | --- | | Runtime entrypoint | [`server.ts`](./server.ts) | Environment, Channels readiness, HTTP lifecycle, and shutdown | -| Application composition | [`app/index.ts`](./app/index.ts) | SDK agent factory, managed Slack/Teams Channels, and runtime | +| Application composition | [`app/index.ts`](./app/index.ts) | SDK agent factory, managed Channel, and runtime | | Channel definition | [`app/channel.tsx`](./app/channel.tsx) | Mentions, commands, components, modals, and interrupts | | Intelligence runtime | [`app/runtime-host.ts`](./app/runtime-host.ts) | One `CopilotKitIntelligence` and one `CopilotRuntime` | | Python agent | [`agent/`](./agent) | LangGraph deep agent served over AG-UI | | Railway topology | [`.railway/railway.ts`](./.railway/railway.ts) | Two services sourced from OpenTag `main` | -The host always uses the Intelligence-owned runtime. It declares an -adapter-free Slack Channel using the configured base name and an adapter-free -Teams Channel named `<base>-teams`. Their credentials and attachments are -configured only in Intelligence. +The host always uses the Intelligence-owned runtime. It declares one +adapter-free Channel using the configured name. Its Slack and Microsoft Teams +adapters, credentials, and attachments are configured only in Intelligence. ## Install @@ -30,7 +29,7 @@ Prerequisites: - pnpm - Python 3.12 - [`uv`](https://docs.astral.sh/uv/) -- A CopilotKit Intelligence project, Channels, and runtime API key +- A CopilotKit Intelligence project, Channel, and runtime API key - An OpenAI API key for the Python agent Install both dependency sets: @@ -87,11 +86,9 @@ The AG-UI endpoint is `http://localhost:8123/`; `/health` reports the In [CopilotKit Intelligence](https://intelligence.copilotkit.ai): 1. Create or select the OpenTag project. -2. Create two Channels. For a normal installation use `opentag` with provider - Slack and `opentag-teams` with provider Microsoft Teams. For production use - `kite` and `kite-teams`. +2. Create one Channel named `open-tag`. 3. Issue a runtime API key. -4. Configure each platform attachment on its matching Channel. +4. Configure the Slack and Microsoft Teams adapters on that Channel. | Variable | Required | Purpose | | --- | --- | --- | @@ -99,7 +96,7 @@ In [CopilotKit Intelligence](https://intelligence.copilotkit.ai): | `INTELLIGENCE_API_KEY` | Yes | Runtime authentication | | `INTELLIGENCE_API_URL` | No | Defaults to `https://api.intelligence.copilotkit.ai` | | `INTELLIGENCE_GATEWAY_WS_URL` | No | Defaults to `wss://realtime.intelligence.copilotkit.ai` | -| `INTELLIGENCE_CHANNEL_NAME` | No | Defaults to `opentag`; Railway sets `kite` | +| `INTELLIGENCE_CHANNEL_NAME` | No | Defaults to `open-tag`; Railway uses `open-tag` | | `AGENT_AUTH_HEADER` | No | Authorization header forwarded to the agent | | `PORT` | No | Channel HTTP port; defaults to `3000` | @@ -131,7 +128,7 @@ For cutover: 2. Enter the existing `xapp` and `xoxb` tokens directly into the Slack attachment in Intelligence. Do not put them in source control or chat. 3. Start the OpenTag `runtime` service with - `INTELLIGENCE_CHANNEL_NAME=kite`. + `INTELLIGENCE_CHANNEL_NAME=open-tag`. 4. Send one `@kite` mention and verify the reply uses the OpenTag persona and Python deep agent. @@ -148,10 +145,9 @@ Those manifests are not a production migration step for `@kite`. ## Microsoft Teams -Managed Microsoft Teams is supported. Configure the Teams attachment on the -`<base>-teams` Channel in Intelligence. The same Node process and runtime host -both platform Channels; there is no direct adapter or Railway platform -credential. +Managed Microsoft Teams is supported. Configure the Teams adapter on the same +`open-tag` Channel in Intelligence. The same Node process and runtime host both +platforms; there is no direct adapter or Railway platform credential. ## Tools, commands, and UI @@ -204,9 +200,8 @@ The IaC file declares exactly: `runtime.AGENT_URL` references the agent's Railway private domain and port. Production Intelligence URLs are literal configuration, the API key is -preserved, and the base Channel name is `kite` (with `kite-teams` derived by -the application). `OPENAI_API_KEY` is required on `agent`; Tavily and Linear -are optional preserved secrets. +preserved, and the Channel name is `open-tag`. `OPENAI_API_KEY` is required on +`agent`; Tavily and Linear are optional preserved secrets. Evaluate the configuration locally without applying it: From c6877e7657ff09e3b7fc94139572e2e6be9a63c5 Mon Sep 17 00:00:00 2001 From: Tyler Slaton <tyler@copilotkit.ai> Date: Mon, 27 Jul 2026 21:25:02 -0400 Subject: [PATCH 14/19] fix(deploy): watch all runtime source changes --- .railway/railway.ts | 2 ++ app/railway.test.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.railway/railway.ts b/.railway/railway.ts index 04efad6..b4a48e9 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -37,6 +37,8 @@ export default defineRailway(() => { build: { builder: "RAILPACK", buildCommand: "pnpm exec playwright install chromium", + // The production runtime must follow every push to its connected branch. + watchPatterns: ["/**"], }, deploy: { healthcheckPath: "/api/copilotkit/info", diff --git a/app/railway.test.ts b/app/railway.test.ts index 600dbcf..12b96bf 100644 --- a/app/railway.test.ts +++ b/app/railway.test.ts @@ -16,6 +16,7 @@ interface RailwayResource { build?: { builder?: string; buildCommand?: string; + watchPatterns?: string[]; }; deploy?: { startCommand?: string; @@ -76,6 +77,7 @@ describe("Railway deployment graph", () => { build: { builder: "RAILPACK", buildCommand: "pnpm exec playwright install chromium", + watchPatterns: ["/**"], }, deploy: { startCommand: "pnpm runtime", From 0e4b4a4c2648236a21215bfe705e6554526549bc Mon Sep 17 00:00:00 2001 From: Tyler Slaton <tyler@copilotkit.ai> Date: Mon, 27 Jul 2026 21:28:27 -0400 Subject: [PATCH 15/19] fix(deploy): clear runtime watch filters --- .railway/railway.ts | 4 ++-- app/railway.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.railway/railway.ts b/.railway/railway.ts index b4a48e9..085a6fa 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -37,8 +37,8 @@ export default defineRailway(() => { build: { builder: "RAILPACK", buildCommand: "pnpm exec playwright install chromium", - // The production runtime must follow every push to its connected branch. - watchPatterns: ["/**"], + // No watch-path filter: deploy every push to the connected branch. + watchPatterns: [], }, deploy: { healthcheckPath: "/api/copilotkit/info", diff --git a/app/railway.test.ts b/app/railway.test.ts index 12b96bf..dd48088 100644 --- a/app/railway.test.ts +++ b/app/railway.test.ts @@ -16,7 +16,7 @@ interface RailwayResource { build?: { builder?: string; buildCommand?: string; - watchPatterns?: string[]; + watchPatterns?: string[] | null; }; deploy?: { startCommand?: string; @@ -77,7 +77,7 @@ describe("Railway deployment graph", () => { build: { builder: "RAILPACK", buildCommand: "pnpm exec playwright install chromium", - watchPatterns: ["/**"], + watchPatterns: [], }, deploy: { startCommand: "pnpm runtime", From bafeb2221646b5b347de70a9a12aecf2643ac554 Mon Sep 17 00:00:00 2001 From: Tyler Slaton <tyler@copilotkit.ai> Date: Mon, 27 Jul 2026 21:59:09 -0400 Subject: [PATCH 16/19] fix(channels): surface action failures --- .env.example | 4 +- app/env.test.ts | 17 +++++ app/env.ts | 15 +++- .../__tests__/confirm-write.test.tsx | 69 ++++++++++++++++++ app/human-in-the-loop/confirm-write.tsx | 42 +++++------ app/tools/__tests__/showcase-tools.test.tsx | 73 +++++++++++++++++++ app/tools/showcase-tools.tsx | 28 +++---- scripts/start-notion-mcp.ts | 24 +++--- 8 files changed, 213 insertions(+), 59 deletions(-) diff --git a/.env.example b/.env.example index 4fb996e..e6259eb 100644 --- a/.env.example +++ b/.env.example @@ -4,8 +4,8 @@ AGENT_URL=http://localhost:8123/ INTELLIGENCE_API_KEY=cpk-... -# Optional. Defaults to "opentag"; production uses "kite". -# INTELLIGENCE_CHANNEL_NAME=opentag +# Optional. Defaults to "open-tag"; set this to the Channel name in Intelligence. +# INTELLIGENCE_CHANNEL_NAME=open-tag # -- Agent Variables -- # Get an API key at https://platform.openai.com/api-keys. diff --git a/app/env.test.ts b/app/env.test.ts index 6ea12e4..b45f9cf 100644 --- a/app/env.test.ts +++ b/app/env.test.ts @@ -73,6 +73,23 @@ describe("parsePort", () => { expect(parsePort("4242")).toBe(4242); }); + it("can require an unpadded decimal string", () => { + expect(() => + parsePort(" 4242", 3001, "NOTION_MCP_PORT", { + strictDecimal: true, + }), + ).toThrow('Invalid NOTION_MCP_PORT: " 4242"'); + }); + + it("can treat an empty value as the configured default", () => { + expect( + parsePort("", 3001, "NOTION_MCP_PORT", { + emptyIsDefault: true, + strictDecimal: true, + }), + ).toBe(3001); + }); + it.each(["", "0", "65536", "12.5", "abc"])( "rejects invalid PORT %j", (raw) => { diff --git a/app/env.ts b/app/env.ts index 30942d3..e37c11f 100644 --- a/app/env.ts +++ b/app/env.ts @@ -26,12 +26,21 @@ export function parsePort( raw: string | undefined, defaultPort = 3000, name = "PORT", + options: { + emptyIsDefault?: boolean; + strictDecimal?: boolean; + } = {}, ): number { - if (raw === undefined) return defaultPort; + const value = options.emptyIsDefault && raw === "" ? undefined : raw; + if (value === undefined) return defaultPort; - const port = Number(raw); + if (options.strictDecimal && !/^\d+$/.test(value)) { + throw new Error(`Invalid ${name}: "${value}"`); + } + + const port = Number(value); if (!Number.isInteger(port) || port < 1 || port > 65_535) { - throw new Error(`Invalid ${name}: "${raw}"`); + throw new Error(`Invalid ${name}: "${value}"`); } return port; } diff --git a/app/human-in-the-loop/__tests__/confirm-write.test.tsx b/app/human-in-the-loop/__tests__/confirm-write.test.tsx index daeae79..56dd9a3 100644 --- a/app/human-in-the-loop/__tests__/confirm-write.test.tsx +++ b/app/human-in-the-loop/__tests__/confirm-write.test.tsx @@ -146,6 +146,25 @@ describe("ConfirmWrite", () => { expect(context?.elements[0]?.text).toContain("Approved"); }); + it("does not resume approval when the status update fails", async () => { + const ir = renderToIR(<ConfirmWrite action="Create Linear issue" />); + const create = buttonByText(ir, "Create"); + const failure = new Error("status update unavailable"); + const update = vi.fn(async () => { + throw failure; + }); + const resume = vi.fn(async () => ({ id: "m2" })); + const ctx = { + thread: { update, resume }, + message: { ref: { id: "m1" } }, + } as unknown as InteractionContext; + + await expect( + (create.props.onClick as ClickHandler)(ctx), + ).rejects.toBe(failure); + expect(resume).not.toHaveBeenCalled(); + }); + it("cancel onClick updates the picker and resumes the interrupted agent", async () => { const ir = renderToIR( <ConfirmWrite action="Create Linear issue" detail="CPK-9: ..." />, @@ -186,6 +205,25 @@ describe("ConfirmWrite", () => { expect(context?.elements[0]?.text).toContain("Declined"); }); + it("does not resume a decline when the status update fails", async () => { + const ir = renderToIR(<ConfirmWrite action="Create Linear issue" />); + const cancel = buttonByText(ir, "Cancel"); + const failure = new Error("status update unavailable"); + const update = vi.fn(async () => { + throw failure; + }); + const resume = vi.fn(async () => ({ id: "m2" })); + const ctx = { + thread: { update, resume }, + message: { ref: { id: "m1" } }, + } as unknown as InteractionContext; + + await expect( + (cancel.props.onClick as ClickHandler)(ctx), + ).rejects.toBe(failure); + expect(resume).not.toHaveBeenCalled(); + }); + it("replaces the optimistic card with a retry state when resume fails", async () => { const ir = renderToIR( <ConfirmWrite action="Create Linear issue" detail="CPK-9: ..." />, @@ -216,4 +254,35 @@ describe("ConfirmWrite", () => { expect(accent).toBe("#EB5757"); expect(JSON.stringify(blocks)).toMatch(/couldn.t resume|retry/i); }); + + it("surfaces both resume and retry-card failures", async () => { + const ir = renderToIR(<ConfirmWrite action="Create Linear issue" />); + const create = buttonByText(ir, "Create"); + const resumeFailure = new Error("resume unavailable"); + const updateFailure = new Error("retry card unavailable"); + const update = vi + .fn() + .mockResolvedValueOnce({ id: "m1" }) + .mockRejectedValueOnce(updateFailure); + const resume = vi.fn(async () => { + throw resumeFailure; + }); + const ctx = { + thread: { update, resume }, + message: { ref: { id: "m1" } }, + } as unknown as InteractionContext; + + let thrown: unknown; + try { + await (create.props.onClick as ClickHandler)(ctx); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(AggregateError); + expect((thrown as AggregateError).errors).toEqual([ + resumeFailure, + updateFailure, + ]); + }); }); diff --git a/app/human-in-the-loop/confirm-write.tsx b/app/human-in-the-loop/confirm-write.tsx index c193653..c787c38 100644 --- a/app/human-in-the-loop/confirm-write.tsx +++ b/app/human-in-the-loop/confirm-write.tsx @@ -50,9 +50,9 @@ async function resumeOrShowFailure( </Message>, ); } catch (updateError) { - console.error( - "[confirm-write] failed to show resume error", - updateError, + throw new AggregateError( + [error, updateError], + `Failed to resume "${action}" and show its retry state`, ); } throw error; @@ -70,17 +70,13 @@ export function ConfirmWrite({ action, detail }: ConfirmWriteProps) { value={{ confirmed: true }} style="primary" onClick={async ({ thread, message }: InteractionContext) => { - try { - await thread.update( - message.ref, - <Message accent="#27AE60"> - <Header>{`✅ ${action}`}</Header> - <Context>{"✅ Approved — writing now."}</Context> - </Message>, - ); - } catch (err) { - console.error("[confirm-write] approval update failed", err); - } + await thread.update( + message.ref, + <Message accent="#27AE60"> + <Header>{`✅ ${action}`}</Header> + <Context>{"✅ Approved — writing now."}</Context> + </Message>, + ); await resumeOrShowFailure( thread, message.ref, @@ -95,17 +91,13 @@ export function ConfirmWrite({ action, detail }: ConfirmWriteProps) { value={{ confirmed: false }} style="danger" onClick={async ({ thread, message }: InteractionContext) => { - try { - await thread.update( - message.ref, - <Message accent="#EB5757"> - <Header>{`🚫 ${action}`}</Header> - <Context>{"🚫 Declined — nothing was written."}</Context> - </Message>, - ); - } catch (err) { - console.error("[confirm-write] decline update failed", err); - } + await thread.update( + message.ref, + <Message accent="#EB5757"> + <Header>{`🚫 ${action}`}</Header> + <Context>{"🚫 Declined — nothing was written."}</Context> + </Message>, + ); await resumeOrShowFailure( thread, message.ref, diff --git a/app/tools/__tests__/showcase-tools.test.tsx b/app/tools/__tests__/showcase-tools.test.tsx index 220bddd..b5976cb 100644 --- a/app/tools/__tests__/showcase-tools.test.tsx +++ b/app/tools/__tests__/showcase-tools.test.tsx @@ -142,6 +142,41 @@ describe("show_incident render-tool", () => { expect(JSON.stringify(blocks)).toContain("Ack'd by Alem"); }); + it("propagates Acknowledge update failures", async () => { + const { posts, thread } = fakeThread(); + await showIncidentTool.handler( + { + id: "INC-1", + title: "Checkout 500s", + severity: "SEV2", + summary: "Latency creeping up.", + }, + { thread } as unknown as IncidentCtx, + ); + const failure = new Error("message update unavailable"); + thread.update.mockRejectedValueOnce(failure); + + const ir = renderToIR(posts[0] as never); + const button = findWithProp(ir, "button", "onClick"); + const onClick = button?.props.onClick as ClickHandler; + + await expect( + onClick({ + thread, + message: { + ref: { id: "m1" }, + text: "", + user: { id: "U1" }, + platform: "slack", + }, + user: { id: "U1", name: "Alem" }, + action: { id: "a1" }, + values: {}, + platform: "slack", + } as unknown as InteractionContext), + ).rejects.toBe(failure); + }); + it("the Escalate button's onClick posts a paging notice", async () => { const { posts, thread } = fakeThread(); await showIncidentTool.handler( @@ -184,6 +219,44 @@ describe("show_incident render-tool", () => { ); }); + it("propagates Escalate post failures", async () => { + const { posts, thread } = fakeThread(); + await showIncidentTool.handler( + { + id: "INC-1", + title: "Checkout 500s", + severity: "SEV1", + summary: "Error rate spiking on /checkout.", + }, + { thread } as unknown as IncidentCtx, + ); + const failure = new Error("message post unavailable"); + thread.post.mockRejectedValueOnce(failure); + + const ir = renderToIR(posts[0] as never); + const escalateButton = findAllWithProp(ir, "button", "onClick").find( + (button) => + (button.props?.value as { action?: string })?.action === "escalate", + ); + const onClick = escalateButton?.props.onClick as ClickHandler; + + await expect( + onClick({ + thread, + message: { + ref: { id: "m1" }, + text: "", + user: { id: "U1" }, + platform: "slack", + }, + user: { id: "U1", name: "Alem" }, + action: { id: "a1" }, + values: {}, + platform: "slack", + } as unknown as InteractionContext), + ).rejects.toBe(failure); + }); + it("uses the SEV2 accent (#F2994A)", async () => { const { posts, thread } = fakeThread(); await showIncidentTool.handler( diff --git a/app/tools/showcase-tools.tsx b/app/tools/showcase-tools.tsx index 72ed3e5..2aa4ce3 100644 --- a/app/tools/showcase-tools.tsx +++ b/app/tools/showcase-tools.tsx @@ -54,17 +54,13 @@ export function IncidentCard({ id, title, severity, summary }: IncidentProps) { value={{ action: "ack", id }} style="primary" onClick={async ({ thread, user, message }: InteractionContext) => { - try { - await thread.update( - message.ref, - <Message accent="#27AE60"> - <Header>{`✅ Acknowledged · ${title}`}</Header> - <Context>{`Ack'd by ${user?.name ?? user?.id ?? "someone"}`}</Context> - </Message>, - ); - } catch (err) { - console.error("[showcase] onClick failed", err); - } + await thread.update( + message.ref, + <Message accent="#27AE60"> + <Header>{`✅ Acknowledged · ${title}`}</Header> + <Context>{`Ack'd by ${user?.name ?? user?.id ?? "someone"}`}</Context> + </Message>, + ); }} > Acknowledge @@ -73,13 +69,9 @@ export function IncidentCard({ id, title, severity, summary }: IncidentProps) { value={{ action: "escalate", id }} style="danger" onClick={async ({ thread }: InteractionContext) => { - try { - await thread.post( - `🚨 Escalating *${title}* — paging the next on-call.`, - ); - } catch (err) { - console.error("[showcase] onClick failed", err); - } + await thread.post( + `🚨 Escalating *${title}* — paging the next on-call.`, + ); }} > Escalate diff --git a/scripts/start-notion-mcp.ts b/scripts/start-notion-mcp.ts index 359d5e1..29c30d2 100644 --- a/scripts/start-notion-mcp.ts +++ b/scripts/start-notion-mcp.ts @@ -14,6 +14,7 @@ */ import "dotenv/config"; import { spawn } from "node:child_process"; +import { parsePort } from "../app/env.js"; const authToken = process.env["NOTION_MCP_AUTH_TOKEN"]; const notionToken = process.env["NOTION_TOKEN"]; @@ -40,18 +41,19 @@ if (!notionToken) { // (`shell: isWindows`) — an unvalidated value with spaces/shell metacharacters // could mangle or inject the command there. An empty string (a bare // `NOTION_MCP_PORT=` in .env) is treated as unset → default. -const rawPort = process.env["NOTION_MCP_PORT"] || undefined; -if (rawPort !== undefined && !/^\d+$/.test(rawPort)) { - console.error( - `[notion-mcp] NOTION_MCP_PORT must be a positive integer (1–65535), got: ${JSON.stringify(rawPort)}`, - ); - process.exit(1); -} -const portNumber = rawPort === undefined ? 3001 : Number(rawPort); -if (!Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535) { - console.error( - `[notion-mcp] NOTION_MCP_PORT must be an integer between 1 and 65535, got: ${JSON.stringify(rawPort)}`, +let portNumber: number; +try { + portNumber = parsePort( + process.env["NOTION_MCP_PORT"], + 3001, + "NOTION_MCP_PORT", + { + emptyIsDefault: true, + strictDecimal: true, + }, ); +} catch (error) { + console.error(`[notion-mcp] ${(error as Error).message}`); process.exit(1); } const port = String(portNumber); From 4ac1a7a4888ecd8de28e5cf679597b9a71a35064 Mon Sep 17 00:00:00 2001 From: Tyler Slaton <tyler@copilotkit.ai> Date: Mon, 27 Jul 2026 23:08:07 -0400 Subject: [PATCH 17/19] feat(agent): make OpenTag triage-first --- agent/agent.py | 72 +++++++++++-- agent/main.py | 13 +-- agent/prompts/__init__.py | 2 - agent/prompts/research.py | 28 ++--- agent/prompts/system.py | 27 ++--- agent/pyproject.toml | 4 +- agent/tests/test_agent_configuration.py | 132 ++++++++++++++++++++++++ agent/tests/test_health.py | 12 +-- agent/tests/test_metadata.py | 4 +- agent/tests/test_prompts.py | 17 ++- agent/tests/test_tools.py | 24 +++++ agent/tools.py | 60 +---------- agent/uv.lock | 2 +- 13 files changed, 271 insertions(+), 126 deletions(-) create mode 100644 agent/tests/test_agent_configuration.py diff --git a/agent/agent.py b/agent/agent.py index b14d7ad..0c7a775 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -1,10 +1,15 @@ -"""OpenTag's Deep Agents research assistant.""" +"""OpenTag's triage-first Deep Agent.""" import os from pathlib import Path from copilotkit import CopilotKitMiddleware -from deepagents import create_deep_agent +from deepagents import ( + GeneralPurposeSubagentProfile, + HarnessProfile, + create_deep_agent, + register_harness_profile, +) from dotenv import load_dotenv from langchain_openai import ChatOpenAI from langgraph.checkpoint.memory import MemorySaver @@ -15,33 +20,75 @@ NO_RESEARCH_TOOL_ADDENDUM, RESEARCH_TOOL_ADDENDUM, ) -from tools import research +from tools import web_search load_dotenv(Path(__file__).resolve().parent.parent / ".env") +VALID_REASONING_EFFORTS = frozenset( + {"none", "minimal", "low", "medium", "high", "xhigh", "max"} +) +VALID_VERBOSITY_LEVELS = frozenset({"low", "medium", "high"}) + +# Deep Agents adds shell execution and a general-purpose delegation tool by +# default. OpenTag has no sandbox for execute, and delegating routine turns to +# another agent adds latency without improving triage. +register_harness_profile( + "openai", + HarnessProfile( + excluded_tools=frozenset({"execute"}), + general_purpose_subagent=GeneralPurposeSubagentProfile(enabled=False), + ), +) + + +def _validated_openai_setting( + name: str, + *, + default: str, + allowed: frozenset[str], +) -> str: + """Read and validate a non-secret OpenAI tuning setting.""" + value = os.environ.get(name, default).strip().lower() + if value not in allowed: + choices = ", ".join(sorted(allowed)) + raise RuntimeError(f"Invalid {name}: expected one of {choices}") + return value + def build_agent(): - """Build the OpenTag research graph.""" + """Build the OpenTag triage graph.""" api_key = os.environ.get("OPENAI_API_KEY") if not api_key: raise RuntimeError("Missing OPENAI_API_KEY environment variable") - has_research = bool(os.environ.get("TAVILY_API_KEY")) + reasoning_effort = _validated_openai_setting( + "OPENAI_REASONING_EFFORT", + default="low", + allowed=VALID_REASONING_EFFORTS, + ) + verbosity = _validated_openai_setting( + "OPENAI_VERBOSITY", + default="low", + allowed=VALID_VERBOSITY_LEVELS, + ) + has_web_search = bool(os.environ.get("TAVILY_API_KEY")) model_name = os.environ.get("OPENAI_MODEL", "gpt-5.5") llm = ChatOpenAI( model=model_name, api_key=api_key, + reasoning_effort=reasoning_effort, + verbosity=verbosity, ) internal_tools = internal_source_tools() main_tools = ( - [research, *internal_tools] - if has_research + [web_search, *internal_tools] + if has_web_search else [*internal_tools] ) system_prompt = BASE_SYSTEM_PROMPT + ( - RESEARCH_TOOL_ADDENDUM if has_research else NO_RESEARCH_TOOL_ADDENDUM + RESEARCH_TOOL_ADDENDUM if has_web_search else NO_RESEARCH_TOOL_ADDENDUM ) agent_graph = create_deep_agent( @@ -52,9 +99,12 @@ def build_agent(): checkpointer=MemorySaver(), ) - print(f"[AGENT] OpenTag Deep Research Agent created with model={model_name}") - print(f"[AGENT] research: {'enabled' if has_research else 'disabled'}") + print( + "[AGENT] OpenTag Agent created " + f"with model={model_name}, reasoning={reasoning_effort}, verbosity={verbosity}" + ) + print(f"[AGENT] web search: {'enabled' if has_web_search else 'disabled'}") print(f"[AGENT] internal-source tools: {len(internal_tools)}") print(f"[AGENT] Main tools: {[t.name for t in main_tools]}") - return agent_graph.with_config({"recursion_limit": 100}) + return agent_graph.with_config({"recursion_limit": 25}) diff --git a/agent/main.py b/agent/main.py index 9b99eb8..fcbba0f 100644 --- a/agent/main.py +++ b/agent/main.py @@ -1,4 +1,4 @@ -"""FastAPI server for the OpenTag research agent.""" +"""FastAPI server for the OpenTag triage agent.""" from collections.abc import Mapping import os @@ -12,14 +12,15 @@ from agent import build_agent app = FastAPI( - title="OpenTag Deep Research Agent", - description="A research assistant powered by Deep Agents and CopilotKit", + title="OpenTag Agent", + description="An on-call triage assistant powered by Deep Agents and CopilotKit", version="0.1.0", ) AGENT_NAME = "opentag_research" AGENT_DESCRIPTION = ( - "OpenTag deep research assistant — plans, searches, and synthesizes cited briefs" + "OpenTag on-call triage assistant for incidents, research, and " + "Linear/Notion workflows" ) # Allow all origins locally, or set CORS_ALLOW_ORIGINS to restrict access. @@ -40,7 +41,7 @@ @app.get("/health") def health(): """Return service health.""" - return {"status": "ok", "service": "opentag-research-agent", "version": "0.1.0"} + return {"status": "ok", "service": "opentag-agent", "version": "0.1.0"} def local_server_port(env: Mapping[str, str] = os.environ) -> int: @@ -70,7 +71,7 @@ def local_server_port(env: Mapping[str, str] = os.environ) -> int: path="/", ) - print("[SERVER] Deep Research Agent registered at /") + print("[SERVER] OpenTag Agent registered at /") except Exception as error: print(f"[ERROR] Failed to build agent: {error}", file=sys.stderr) raise diff --git a/agent/prompts/__init__.py b/agent/prompts/__init__.py index fda5e64..6ada848 100644 --- a/agent/prompts/__init__.py +++ b/agent/prompts/__init__.py @@ -3,7 +3,6 @@ from .research import ( NO_RESEARCH_TOOL_ADDENDUM, RESEARCH_TOOL_ADDENDUM, - RESEARCHER_SYSTEM_PROMPT, ) from .system import SYSTEM_PROMPT, WORKFLOW_PROMPT from .tools import TOOLS_PROMPT @@ -14,5 +13,4 @@ "BASE_SYSTEM_PROMPT", "NO_RESEARCH_TOOL_ADDENDUM", "RESEARCH_TOOL_ADDENDUM", - "RESEARCHER_SYSTEM_PROMPT", ] diff --git a/agent/prompts/research.py b/agent/prompts/research.py index 037a3a6..95a5392 100644 --- a/agent/prompts/research.py +++ b/agent/prompts/research.py @@ -2,32 +2,16 @@ RESEARCH_TOOL_ADDENDUM = """ -Live web research is available via the research(query) tool: -- Call research() for each distinct research question that needs current or - external information -- The research tool returns prose summaries of findings - synthesize them, - don't just relay them -- Example workflow: - 1. write_todos(["Research topic A", "Research topic B", "Synthesize findings"]) - 2. research("Find information about topic A") -> receives prose summary - 3. research("Find information about topic B") -> receives prose summary - 4. write_file("/reports/final_report.md", "# Research Report\\n\\n...") +Live web research is available via web_search(query, max_results=5): +- Use it when a request needs current or external information +- Start with one focused query and search again only when a material gap remains +- The tool returns source snippets with URLs; synthesize the evidence and cite + the useful sources rather than dumping raw results """ NO_RESEARCH_TOOL_ADDENDUM = """ You do NOT have a live web research tool available right now. Answer from your own knowledge, state plainly when you cannot look up current or external -information, and never claim to have searched the web. You can still plan -with write_todos, read/write files, and render findings as UI components. +information, and never claim to have searched the web. """ - -RESEARCHER_SYSTEM_PROMPT = """You are a Research Specialist. - -Use internet_search to find information and return a prose summary. - -Rules: -- Call internet_search ONCE with a focused query -- Analyze the returned content -- Return a brief summary (2-3 sentences) of key findings -- No JSON, no code blocks, just prose""" diff --git a/agent/prompts/system.py b/agent/prompts/system.py index c0e16c6..1616c24 100644 --- a/agent/prompts/system.py +++ b/agent/prompts/system.py @@ -1,24 +1,25 @@ """Core OpenTag persona and workflow.""" -SYSTEM_PROMPT = """You are OpenTag's Deep Research Assistant, an expert at planning and -executing comprehensive research on any topic. +SYSTEM_PROMPT = """You are OpenTag, your team's on-call triage assistant for fast +incident support, research, and Linear/Notion workflows. Hard rules (ALWAYS follow): - NEVER output raw JSON, data structures, or code blocks in your messages - Communicate with the user only in natural, readable prose -- When you receive data from research or from your own knowledge, synthesize it into insights +- Lead with the answer and keep routine replies concise and action-oriented +- When you receive source data or use your own knowledge, synthesize it into insights - Prefer rendering results as UI components (tables, cards, links, etc.) when the frontend offers them, rather than large blocks of raw markdown """ WORKFLOW_PROMPT = """ -Your workflow: -1. PLAN: Create a research plan using write_todos with clear, actionable steps -2. INVESTIGATE: Work through each step, drawing on the tools available to you -3. SYNTHESIZE: Write a final report to /reports/final_report.md using write_file - -Important guidelines: -- Always start by creating a research plan with write_todos -- You write all files - compile findings into a comprehensive report -- Update todos as you complete each step -- Always maintain a professional, comprehensive research style""" +Operating mode: +- Answer ordinary requests directly. Do not turn them into research projects +- Use the smallest number of tool calls needed to answer reliably +- Use write_todos only for explicitly substantial, multi-step work where a + visible plan helps the user +- Use filesystem tools only when the user asks for an artifact or when + substantial, multi-step work needs durable notes +- Do not write a report by default +- When a request is ambiguous in a way that changes the action, ask one focused + question; otherwise make a reasonable, stated assumption and proceed""" diff --git a/agent/pyproject.toml b/agent/pyproject.toml index 01c7148..532d3ca 100644 --- a/agent/pyproject.toml +++ b/agent/pyproject.toml @@ -1,7 +1,7 @@ [project] -name = "opentag-research-agent" +name = "opentag-agent" version = "0.1.0" -description = "OpenTag Deep Research Agent — CopilotKit Deep Agents (LangGraph) over AG-UI" +description = "OpenTag on-call triage agent — CopilotKit Deep Agents (LangGraph) over AG-UI" requires-python = ">=3.12" dependencies = [ "ag-ui-langgraph>=0.0.23", diff --git a/agent/tests/test_agent_configuration.py b/agent/tests/test_agent_configuration.py new file mode 100644 index 0000000..4a870a9 --- /dev/null +++ b/agent/tests/test_agent_configuration.py @@ -0,0 +1,132 @@ +import agent as agent_mod +import pytest +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import AIMessage, BaseMessage +from langchain_core.outputs import ChatGeneration, ChatResult +from pydantic import Field + + +class FakeGraph: + def __init__(self, captured): + self.captured = captured + + def with_config(self, config): + self.captured["config"] = config + return self + + +def build_with_captured_configuration(monkeypatch): + captured = {} + + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.delenv("TAVILY_API_KEY", raising=False) + monkeypatch.delenv("LINEAR_API_KEY", raising=False) + monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) + monkeypatch.setattr(agent_mod, "internal_source_tools", lambda: []) + + def fake_chat_openai(**kwargs): + captured["model"] = kwargs + return object() + + def fake_create_deep_agent(**kwargs): + captured["agent"] = kwargs + return FakeGraph(captured) + + monkeypatch.setattr(agent_mod, "ChatOpenAI", fake_chat_openai) + monkeypatch.setattr(agent_mod, "create_deep_agent", fake_create_deep_agent) + + graph = agent_mod.build_agent() + return graph, captured + + +def test_build_agent_defaults_to_low_reasoning_and_verbosity(monkeypatch): + monkeypatch.delenv("OPENAI_REASONING_EFFORT", raising=False) + monkeypatch.delenv("OPENAI_VERBOSITY", raising=False) + + _, captured = build_with_captured_configuration(monkeypatch) + + assert captured["model"]["reasoning_effort"] == "low" + assert captured["model"]["verbosity"] == "low" + + +def test_build_agent_accepts_valid_reasoning_and_verbosity_overrides(monkeypatch): + monkeypatch.setenv("OPENAI_REASONING_EFFORT", "high") + monkeypatch.setenv("OPENAI_VERBOSITY", "medium") + + _, captured = build_with_captured_configuration(monkeypatch) + + assert captured["model"]["reasoning_effort"] == "high" + assert captured["model"]["verbosity"] == "medium" + + +@pytest.mark.parametrize( + ("name", "value"), + [ + ("OPENAI_REASONING_EFFORT", "extreme"), + ("OPENAI_VERBOSITY", "verbose"), + ], +) +def test_build_agent_rejects_invalid_openai_tuning(monkeypatch, name, value): + monkeypatch.setenv(name, value) + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + + with pytest.raises(RuntimeError, match=name): + agent_mod.build_agent() + + +def test_build_agent_bounds_graph_recursion(monkeypatch): + _, captured = build_with_captured_configuration(monkeypatch) + + assert captured["config"] == {"recursion_limit": 25} + + +def test_openai_harness_excludes_unusable_delegation_tools(monkeypatch): + class RecordingOpenAIModel(BaseChatModel): + model_name: str = "gpt-5.5" + seen_tool_names: list[str] = Field(default_factory=list) + + @property + def _llm_type(self): + return "recording-openai" + + def _get_ls_params(self, **_kwargs): + return { + "ls_provider": "openai", + "ls_model_name": self.model_name, + "ls_model_type": "chat", + } + + def bind_tools(self, tools, **_kwargs): + self.seen_tool_names = [tool.name for tool in tools] + return self + + def _generate( + self, + messages: list[BaseMessage], + stop=None, + run_manager=None, + **_kwargs, + ): + del messages, stop, run_manager + return ChatResult( + generations=[ChatGeneration(message=AIMessage(content="done"))] + ) + + model = RecordingOpenAIModel() + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.delenv("TAVILY_API_KEY", raising=False) + monkeypatch.delenv("LINEAR_API_KEY", raising=False) + monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) + monkeypatch.setattr(agent_mod, "ChatOpenAI", lambda **_kwargs: model) + monkeypatch.setattr(agent_mod, "internal_source_tools", lambda: []) + + graph = agent_mod.build_agent() + graph.invoke( + {"messages": [{"role": "user", "content": "hello"}]}, + config={"configurable": {"thread_id": "tool-availability-test"}}, + ) + tool_names = set(model.seen_tool_names) + + assert "execute" not in tool_names + assert "task" not in tool_names + assert {"write_todos", "read_file", "write_file"}.issubset(tool_names) diff --git a/agent/tests/test_health.py b/agent/tests/test_health.py index 8697cbd..7a882dd 100644 --- a/agent/tests/test_health.py +++ b/agent/tests/test_health.py @@ -15,7 +15,7 @@ def test_health_ok(monkeypatch): assert r.status_code == 200 assert r.json() == { "status": "ok", - "service": "opentag-research-agent", + "service": "opentag-agent", "version": "0.1.0", } @@ -27,9 +27,9 @@ def test_server_exposes_opentag_metadata(monkeypatch): monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) import main - assert main.app.title == "OpenTag Deep Research Agent" + assert main.app.title == "OpenTag Agent" assert main.AGENT_NAME == "opentag_research" - assert main.AGENT_DESCRIPTION.startswith("OpenTag deep research assistant") + assert main.AGENT_DESCRIPTION.startswith("OpenTag on-call triage assistant") def test_local_agent_port_ignores_the_shared_channel_port(): @@ -55,7 +55,7 @@ def test_build_agent_without_tavily(monkeypatch, capsys): graph = agent_mod.build_agent() assert graph is not None out = capsys.readouterr().out - assert "[AGENT] research: disabled" in out + assert "[AGENT] web search: disabled" in out def test_build_agent_with_tavily(monkeypatch, capsys): @@ -66,7 +66,7 @@ def test_build_agent_with_tavily(monkeypatch, capsys): graph = agent_mod.build_agent() assert graph is not None out = capsys.readouterr().out - assert "[AGENT] research: enabled" in out + assert "[AGENT] web search: enabled" in out def test_build_agent_requires_openai(monkeypatch): @@ -113,4 +113,4 @@ def test_system_prompt_requires_confirmation_only_for_writes(): def test_system_prompt_uses_opentag_persona(): - assert "OpenTag's Deep Research Assistant" in agent_mod.BASE_SYSTEM_PROMPT + assert "OpenTag, your team's on-call triage assistant" in agent_mod.BASE_SYSTEM_PROMPT diff --git a/agent/tests/test_metadata.py b/agent/tests/test_metadata.py index 8b15f50..54efb26 100644 --- a/agent/tests/test_metadata.py +++ b/agent/tests/test_metadata.py @@ -6,5 +6,5 @@ def test_python_package_uses_opentag_identity(): pyproject_path = Path(__file__).parents[1] / "pyproject.toml" project = tomllib.loads(pyproject_path.read_text())["project"] - assert project["name"] == "opentag-research-agent" - assert project["description"].startswith("OpenTag Deep Research Agent") + assert project["name"] == "opentag-agent" + assert project["description"].startswith("OpenTag on-call triage agent") diff --git a/agent/tests/test_prompts.py b/agent/tests/test_prompts.py index 7f204b4..a760161 100644 --- a/agent/tests/test_prompts.py +++ b/agent/tests/test_prompts.py @@ -2,14 +2,21 @@ BASE_SYSTEM_PROMPT, NO_RESEARCH_TOOL_ADDENDUM, RESEARCH_TOOL_ADDENDUM, - RESEARCHER_SYSTEM_PROMPT, ) -def test_prompt_modules_preserve_agent_behavior(): - assert "OpenTag's Deep Research Assistant" in BASE_SYSTEM_PROMPT +def test_prompt_uses_triage_first_behavior(): + assert "on-call triage assistant" in BASE_SYSTEM_PROMPT assert "CRITICAL:" in BASE_SYSTEM_PROMPT assert "Linear or Notion mutation" in BASE_SYSTEM_PROMPT - assert "research(query)" in RESEARCH_TOOL_ADDENDUM + assert "Answer ordinary requests directly" in BASE_SYSTEM_PROMPT + assert "substantial, multi-step work" in BASE_SYSTEM_PROMPT + assert "Do not write a report by default" in BASE_SYSTEM_PROMPT + assert "Always start by creating a research plan" not in BASE_SYSTEM_PROMPT + assert "/reports/final_report.md" not in BASE_SYSTEM_PROMPT + + +def test_prompt_describes_direct_optional_web_search(): + assert "web_search(query, max_results=5)" in RESEARCH_TOOL_ADDENDUM + assert "source snippets" in RESEARCH_TOOL_ADDENDUM assert "do NOT have a live web research tool" in NO_RESEARCH_TOOL_ADDENDUM - assert "Call internet_search ONCE" in RESEARCHER_SYSTEM_PROMPT diff --git a/agent/tests/test_tools.py b/agent/tests/test_tools.py index c0ad8b6..dc50a36 100644 --- a/agent/tests/test_tools.py +++ b/agent/tests/test_tools.py @@ -30,3 +30,27 @@ def search(self, **kwargs): raise ValueError("boom") with pytest.raises(RuntimeError, match="Tavily search failed") as error: tools._do_internet_search("q") assert isinstance(error.value.__cause__, ValueError) + + +def test_web_search_returns_tavily_results_directly(monkeypatch): + calls = [] + expected = [ + { + "url": "https://example.com", + "title": "Example", + "content": "Current source material", + } + ] + + def fake_search(query, max_results): + calls.append((query, max_results)) + return expected + + monkeypatch.setattr(tools, "_do_internet_search", fake_search) + + result = tools.web_search.invoke( + {"query": "current incident guidance", "max_results": 3} + ) + + assert result == expected + assert calls == [("current incident guidance", 3)] diff --git a/agent/tools.py b/agent/tools.py index 0f861cc..d40127f 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -1,15 +1,11 @@ -"""Tavily-backed web research tools.""" +"""Tavily-backed web search tools.""" -from concurrent.futures import ThreadPoolExecutor import os from typing import Any -from langchain_core.messages import HumanMessage from langchain_core.tools import tool from tavily import TavilyClient -from prompts import RESEARCHER_SYSTEM_PROMPT - def _do_internet_search(query: str, max_results: int = 5) -> list[dict[str, Any]]: """Search Tavily and normalize its results.""" @@ -46,54 +42,6 @@ def _do_internet_search(query: str, max_results: int = 5) -> list[dict[str, Any] @tool -def research(query: str) -> dict: - """Research a topic and return a prose summary with sources.""" - print(f"[TOOL] research: query='{query}' (using thread isolation)") - - from deepagents import create_deep_agent - from langchain_openai import ChatOpenAI - - def _run_research_isolated(): - search_results = [] - - def internet_search(query: str, max_results: int = 5): - """Search the web and capture sources for the final response.""" - results = _do_internet_search(query, max_results) - search_results.extend(results) - return results - - model_name = os.environ.get("OPENAI_MODEL", "gpt-5.5") - llm = ChatOpenAI( - model=model_name, - api_key=os.environ.get("OPENAI_API_KEY"), - ) - - research_agent = create_deep_agent( - model=llm, - system_prompt=RESEARCHER_SYSTEM_PROMPT, - tools=[internet_search], - ) - - result = research_agent.invoke({"messages": [HumanMessage(content=query)]}) - summary = result["messages"][-1].content - - sources = [ - { - "url": r["url"], - "title": r.get("title", ""), - "content": r.get("content", "")[:3000], - "status": "found", - } - for r in search_results - if "url" in r - ] - - return {"summary": summary, "sources": sources} - - # The separate thread prevents callbacks from leaking into the parent run. - with ThreadPoolExecutor(max_workers=1) as executor: - future = executor.submit(_run_research_isolated) - result = future.result() - - print(f"[TOOL] research: completed with {len(result['sources'])} sources") - return result +def web_search(query: str, max_results: int = 5) -> list[dict[str, Any]]: + """Search the live web and return source URLs with concise content snippets.""" + return _do_internet_search(query, max_results) diff --git a/agent/uv.lock b/agent/uv.lock index e76b41f..13378a6 100644 --- a/agent/uv.lock +++ b/agent/uv.lock @@ -909,7 +909,7 @@ wheels = [ ] [[package]] -name = "opentag-research-agent" +name = "opentag-agent" version = "0.1.0" source = { virtual = "." } dependencies = [ From 303b6bbd7f6c346abdfae7a458c11f71bceb8967 Mon Sep 17 00:00:00 2001 From: Tyler Slaton <tyler@copilotkit.ai> Date: Mon, 27 Jul 2026 23:19:06 -0400 Subject: [PATCH 18/19] feat(channels): restore rich UI and remove dead paths --- .env.example | 9 +- .railway/railway.ts | 2 + README.md | 19 +- agent/agent.py | 8 +- agent/internal_sources.py | 21 +- agent/prompts/__init__.py | 12 +- agent/prompts/{research.py => web_search.py} | 6 +- agent/pyproject.toml | 2 +- agent/tests/test_internal_sources.py | 17 +- agent/tests/test_metadata.py | 7 + agent/tests/test_prompts.py | 10 +- agent/tests/test_tools.py | 14 +- agent/tools.py | 8 +- agent/uv.lock | 2 +- app/channel.test.ts | 94 +++++++ app/channel.tsx | 3 +- app/cleanup.test.ts | 34 +++ app/components/__tests__/components.test.tsx | 19 +- app/components/index.ts | 3 - app/components/issue-card.tsx | 2 +- app/components/issue-list.tsx | 2 +- app/components/page-list.tsx | 2 +- app/context/app-context.test.ts | 31 +++ app/context/app-context.ts | 22 ++ app/env.test.ts | 17 -- app/env.ts | 15 +- .../__tests__/confirm-write.test.tsx | 19 ++ app/human-in-the-loop/confirm-write.tsx | 2 +- app/human-in-the-loop/index.ts | 1 - app/railway.test.ts | 9 +- app/tools/__tests__/render-table.test.tsx | 16 ++ app/tools/__tests__/showcase-tools.test.tsx | 56 ++++ app/tools/index.ts | 17 +- app/tools/showcase-tools.tsx | 4 +- e2e/README.md | 4 + e2e/TELEGRAM-README.md | 6 - e2e/grab-user-token.ts | 261 ------------------ e2e/run.ts | 2 +- e2e/slack-api.ts | 2 +- package.json | 2 - pnpm-lock.yaml | 243 ---------------- scripts/start-notion-mcp.ts | 175 ------------ setup.md | 37 +-- slack-app-manifest.yaml | 2 +- 44 files changed, 428 insertions(+), 811 deletions(-) rename agent/prompts/{research.py => web_search.py} (82%) create mode 100644 app/cleanup.test.ts create mode 100644 app/context/app-context.test.ts delete mode 100644 e2e/TELEGRAM-README.md delete mode 100644 e2e/grab-user-token.ts delete mode 100644 scripts/start-notion-mcp.ts diff --git a/.env.example b/.env.example index e6259eb..a5901ae 100644 --- a/.env.example +++ b/.env.example @@ -11,11 +11,14 @@ INTELLIGENCE_API_KEY=cpk-... # Get an API key at https://platform.openai.com/api-keys. OPENAI_API_KEY=sk-... -# Optional web research and model override. +# Optional web research and model behavior overrides. # TAVILY_API_KEY=tvly-... # OPENAI_MODEL=gpt-5.5 +# OPENAI_REASONING_EFFORT=low +# OPENAI_VERBOSITY=low # -- Internal Sources (Optional) -- # LINEAR_API_KEY=lin_api_... -# NOTION_TOKEN=ntn_... -# NOTION_MCP_AUTH_TOKEN=choose-a-shared-secret +# Remote Notion requires both values; no local sidecar is included. +# NOTION_MCP_URL=https://your-notion-mcp.example.com/mcp +# NOTION_MCP_AUTH_TOKEN=your-remote-mcp-bearer-token diff --git a/.railway/railway.ts b/.railway/railway.ts index 085a6fa..5ad431d 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -27,6 +27,8 @@ export default defineRailway(() => { OPENAI_API_KEY: preserve(), TAVILY_API_KEY: preserve(), LINEAR_API_KEY: preserve(), + NOTION_MCP_URL: preserve(), + NOTION_MCP_AUTH_TOKEN: preserve(), }, }); diff --git a/README.md b/README.md index 1c35c33..02613c1 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # OpenTag -OpenTag is an open-source research and triage agent for Slack and Microsoft -Teams. It runs on CopilotKit Channels, connects to CopilotKit Intelligence, and -uses a Python LangGraph deep agent over AG-UI. +OpenTag is an open-source on-call triage assistant for Slack and Microsoft +Teams, with research available on demand. It runs on CopilotKit Channels, +connects to CopilotKit Intelligence, and uses a Python LangGraph agent over +AG-UI. The launch supports: @@ -27,7 +28,7 @@ agent (Python + LangGraph deepagents) ├── OpenAI ├── Tavily (optional) ├── Linear MCP (optional) - └── Notion MCP (optional local sidecar) + └── Notion MCP (optional remote server) ``` There is one canonical runtime host: [`server.ts`](./server.ts). @@ -130,10 +131,10 @@ Slack app**. Reusing it preserves the bot user, workspace installation, and ## Optional research sources - `TAVILY_API_KEY` enables live web research. Without it, OpenTag still chats, - plans, uses its virtual filesystem, and renders UI from model knowledge. + triages requests and renders UI from model knowledge. - `LINEAR_API_KEY` enables the hosted Linear MCP. -- Notion is optional for local development: put its token and shared - `NOTION_MCP_AUTH_TOKEN` in the root `.env` and run `pnpm notion-mcp`. +- Notion is optional and remote-only. Set both `NOTION_MCP_URL` and + `NOTION_MCP_AUTH_TOKEN`; setting only one disables the integration. Every Linear and Notion mutation is intercepted in code before the MCP request runs. The interceptor emits `confirm_write` and proceeds only after approval; @@ -153,8 +154,8 @@ The runtime reaches the agent over Railway private networking and embeds the managed `open-tag` Channel. Railway sets `INTELLIGENCE_CHANNEL_NAME=open-tag`; Intelligence owns both platform adapters. The runtime API key is preserved. OpenAI is required on `agent`; -Tavily and Linear secrets are optional. Connecting both services to `main` -enables GitHub-triggered deployments after merges. +Tavily, Linear, and remote Notion settings are optional. Connecting both +services to `main` enables GitHub-triggered deployments after merges. The repository configuration does not mutate the existing production Railway project. Inventory and cutover should happen after Railway authentication. diff --git a/agent/agent.py b/agent/agent.py index 0c7a775..7d242ef 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -17,8 +17,8 @@ from internal_sources import internal_source_tools from prompts import ( BASE_SYSTEM_PROMPT, - NO_RESEARCH_TOOL_ADDENDUM, - RESEARCH_TOOL_ADDENDUM, + NO_WEB_SEARCH_TOOL_ADDENDUM, + WEB_SEARCH_TOOL_ADDENDUM, ) from tools import web_search @@ -88,7 +88,9 @@ def build_agent(): ) system_prompt = BASE_SYSTEM_PROMPT + ( - RESEARCH_TOOL_ADDENDUM if has_web_search else NO_RESEARCH_TOOL_ADDENDUM + WEB_SEARCH_TOOL_ADDENDUM + if has_web_search + else NO_WEB_SEARCH_TOOL_ADDENDUM ) agent_graph = create_deep_agent( diff --git a/agent/internal_sources.py b/agent/internal_sources.py index c120009..eac2f86 100644 --- a/agent/internal_sources.py +++ b/agent/internal_sources.py @@ -21,7 +21,7 @@ "notion": { "token_env": "NOTION_MCP_AUTH_TOKEN", "url_env": "NOTION_MCP_URL", - "default_url": "http://127.0.0.1:3001/mcp", + "default_url": None, }, } MCP_LOAD_TIMEOUT_SECONDS = 8.0 @@ -57,12 +57,29 @@ def _configured_connections( connections: dict[str, dict[str, Any]] = {} for name, config in MCP_SERVERS.items(): token = env.get(config["token_env"]) + configured_url = env.get(config["url_env"]) + url = configured_url or config["default_url"] if not token: + if configured_url: + logger.warning( + "[TOOLS] skipping %s: %s must be set with %s", + name, + config["token_env"], + config["url_env"], + ) + continue + if not url: + logger.warning( + "[TOOLS] skipping %s: %s must be set with %s", + name, + config["url_env"], + config["token_env"], + ) continue connections[name] = { "transport": "streamable_http", - "url": env.get(config["url_env"], config["default_url"]), + "url": url, "headers": {"Authorization": f"Bearer {token}"}, } return connections diff --git a/agent/prompts/__init__.py b/agent/prompts/__init__.py index 6ada848..ed93195 100644 --- a/agent/prompts/__init__.py +++ b/agent/prompts/__init__.py @@ -1,16 +1,16 @@ """OpenTag agent prompts.""" -from .research import ( - NO_RESEARCH_TOOL_ADDENDUM, - RESEARCH_TOOL_ADDENDUM, -) from .system import SYSTEM_PROMPT, WORKFLOW_PROMPT from .tools import TOOLS_PROMPT +from .web_search import ( + NO_WEB_SEARCH_TOOL_ADDENDUM, + WEB_SEARCH_TOOL_ADDENDUM, +) BASE_SYSTEM_PROMPT = SYSTEM_PROMPT + TOOLS_PROMPT + WORKFLOW_PROMPT __all__ = [ "BASE_SYSTEM_PROMPT", - "NO_RESEARCH_TOOL_ADDENDUM", - "RESEARCH_TOOL_ADDENDUM", + "NO_WEB_SEARCH_TOOL_ADDENDUM", + "WEB_SEARCH_TOOL_ADDENDUM", ] diff --git a/agent/prompts/research.py b/agent/prompts/web_search.py similarity index 82% rename from agent/prompts/research.py rename to agent/prompts/web_search.py index 95a5392..8f9fd7a 100644 --- a/agent/prompts/research.py +++ b/agent/prompts/web_search.py @@ -1,6 +1,6 @@ -"""Prompts for optional web research.""" +"""Prompt guidance for optional direct web search.""" -RESEARCH_TOOL_ADDENDUM = """ +WEB_SEARCH_TOOL_ADDENDUM = """ Live web research is available via web_search(query, max_results=5): - Use it when a request needs current or external information @@ -9,7 +9,7 @@ the useful sources rather than dumping raw results """ -NO_RESEARCH_TOOL_ADDENDUM = """ +NO_WEB_SEARCH_TOOL_ADDENDUM = """ You do NOT have a live web research tool available right now. Answer from your own knowledge, state plainly when you cannot look up current or external diff --git a/agent/pyproject.toml b/agent/pyproject.toml index 532d3ca..4cf27e4 100644 --- a/agent/pyproject.toml +++ b/agent/pyproject.toml @@ -6,7 +6,7 @@ requires-python = ">=3.12" dependencies = [ "ag-ui-langgraph>=0.0.23", "copilotkit>=0.1.76", - "deepagents>=0.3.5", + "deepagents>=0.6.12", "fastapi>=0.115.14", "langchain>=1.2.4", "langchain-mcp-adapters>=0.3.0", diff --git a/agent/tests/test_internal_sources.py b/agent/tests/test_internal_sources.py index 103527a..e9a7094 100644 --- a/agent/tests/test_internal_sources.py +++ b/agent/tests/test_internal_sources.py @@ -18,7 +18,7 @@ def test_mcp_servers_are_configured_in_one_place(): "notion": { "token_env": "NOTION_MCP_AUTH_TOKEN", "url_env": "NOTION_MCP_URL", - "default_url": "http://127.0.0.1:3001/mcp", + "default_url": None, }, } @@ -29,6 +29,17 @@ def test_internal_source_tools_empty_without_env(monkeypatch): assert internal_sources.internal_source_tools() == [] +@pytest.mark.parametrize( + "env", + [ + {"NOTION_MCP_AUTH_TOKEN": "notion-test-token"}, + {"NOTION_MCP_URL": "https://notion.example.test/mcp"}, + ], +) +def test_remote_notion_requires_both_url_and_auth_token(env): + assert internal_sources._configured_connections(env) == {} + + def test_internal_source_tools_loads_when_env_set(monkeypatch): clients = [] @@ -112,6 +123,10 @@ async def get_tools(self): monkeypatch.setenv("LINEAR_API_KEY", "lin_api_test") monkeypatch.setenv("NOTION_MCP_AUTH_TOKEN", "notion-test-token") + monkeypatch.setenv( + "NOTION_MCP_URL", + "https://notion.example.test/mcp", + ) monkeypatch.setattr( internal_sources, "MultiServerMCPClient", diff --git a/agent/tests/test_metadata.py b/agent/tests/test_metadata.py index 54efb26..88f5f8a 100644 --- a/agent/tests/test_metadata.py +++ b/agent/tests/test_metadata.py @@ -8,3 +8,10 @@ def test_python_package_uses_opentag_identity(): assert project["name"] == "opentag-agent" assert project["description"].startswith("OpenTag on-call triage agent") + + +def test_deepagents_minimum_supports_harness_profiles(): + pyproject_path = Path(__file__).parents[1] / "pyproject.toml" + project = tomllib.loads(pyproject_path.read_text())["project"] + + assert "deepagents>=0.6.12" in project["dependencies"] diff --git a/agent/tests/test_prompts.py b/agent/tests/test_prompts.py index a760161..935bd10 100644 --- a/agent/tests/test_prompts.py +++ b/agent/tests/test_prompts.py @@ -1,7 +1,7 @@ from prompts import ( BASE_SYSTEM_PROMPT, - NO_RESEARCH_TOOL_ADDENDUM, - RESEARCH_TOOL_ADDENDUM, + NO_WEB_SEARCH_TOOL_ADDENDUM, + WEB_SEARCH_TOOL_ADDENDUM, ) @@ -17,6 +17,6 @@ def test_prompt_uses_triage_first_behavior(): def test_prompt_describes_direct_optional_web_search(): - assert "web_search(query, max_results=5)" in RESEARCH_TOOL_ADDENDUM - assert "source snippets" in RESEARCH_TOOL_ADDENDUM - assert "do NOT have a live web research tool" in NO_RESEARCH_TOOL_ADDENDUM + assert "web_search(query, max_results=5)" in WEB_SEARCH_TOOL_ADDENDUM + assert "source snippets" in WEB_SEARCH_TOOL_ADDENDUM + assert "do NOT have a live web research tool" in NO_WEB_SEARCH_TOOL_ADDENDUM diff --git a/agent/tests/test_tools.py b/agent/tests/test_tools.py index dc50a36..13be38c 100644 --- a/agent/tests/test_tools.py +++ b/agent/tests/test_tools.py @@ -2,7 +2,7 @@ import pytest -def test_do_internet_search_formats_results(monkeypatch): +def test_search_tavily_formats_results(monkeypatch): class FakeClient: def __init__(self, api_key): pass def search(self, **kwargs): @@ -11,24 +11,24 @@ def search(self, **kwargs): ]} monkeypatch.setenv("TAVILY_API_KEY", "tvly-test") monkeypatch.setattr(tools, "TavilyClient", FakeClient) - out = tools._do_internet_search("q", max_results=1) + out = tools._search_tavily("q", max_results=1) assert out == [{"url": "https://x.com", "title": "X", "content": "c" * 3000}] -def test_do_internet_search_missing_key(monkeypatch): +def test_search_tavily_missing_key(monkeypatch): monkeypatch.delenv("TAVILY_API_KEY", raising=False) with pytest.raises(RuntimeError, match="TAVILY_API_KEY"): - tools._do_internet_search("q") + tools._search_tavily("q") -def test_do_internet_search_raises_when_tavily_fails(monkeypatch): +def test_search_tavily_raises_when_tavily_fails(monkeypatch): class BoomClient: def __init__(self, api_key): pass def search(self, **kwargs): raise ValueError("boom") monkeypatch.setenv("TAVILY_API_KEY", "tvly-test") monkeypatch.setattr(tools, "TavilyClient", BoomClient) with pytest.raises(RuntimeError, match="Tavily search failed") as error: - tools._do_internet_search("q") + tools._search_tavily("q") assert isinstance(error.value.__cause__, ValueError) @@ -46,7 +46,7 @@ def fake_search(query, max_results): calls.append((query, max_results)) return expected - monkeypatch.setattr(tools, "_do_internet_search", fake_search) + monkeypatch.setattr(tools, "_search_tavily", fake_search) result = tools.web_search.invoke( {"query": "current incident guidance", "max_results": 3} diff --git a/agent/tools.py b/agent/tools.py index d40127f..1d8c70e 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -7,9 +7,9 @@ from tavily import TavilyClient -def _do_internet_search(query: str, max_results: int = 5) -> list[dict[str, Any]]: +def _search_tavily(query: str, max_results: int = 5) -> list[dict[str, Any]]: """Search Tavily and normalize its results.""" - print(f"[TOOL] internet_search: query='{query}', max_results={max_results}") + print(f"[TOOL] web_search: query='{query}', max_results={max_results}") tavily_key = os.environ.get("TAVILY_API_KEY") if not tavily_key: @@ -34,7 +34,7 @@ def _do_internet_search(query: str, max_results: int = 5) -> list[dict[str, Any] } ) - print(f"[TOOL] internet_search: found {len(formatted_results)} results") + print(f"[TOOL] web_search: found {len(formatted_results)} results") return formatted_results except Exception as error: @@ -44,4 +44,4 @@ def _do_internet_search(query: str, max_results: int = 5) -> list[dict[str, Any] @tool def web_search(query: str, max_results: int = 5) -> list[dict[str, Any]]: """Search the live web and return source URLs with concise content snippets.""" - return _do_internet_search(query, max_results) + return _search_tavily(query, max_results) diff --git a/agent/uv.lock b/agent/uv.lock index 13378a6..0302438 100644 --- a/agent/uv.lock +++ b/agent/uv.lock @@ -935,7 +935,7 @@ dev = [ requires-dist = [ { name = "ag-ui-langgraph", specifier = ">=0.0.23" }, { name = "copilotkit", specifier = ">=0.1.76" }, - { name = "deepagents", specifier = ">=0.3.5" }, + { name = "deepagents", specifier = ">=0.6.12" }, { name = "fastapi", specifier = ">=0.115.14" }, { name = "langchain", specifier = ">=1.2.4" }, { name = "langchain-mcp-adapters", specifier = ">=0.3.0" }, diff --git a/app/channel.test.ts b/app/channel.test.ts index cd6577c..fc2d492 100644 --- a/app/channel.test.ts +++ b/app/channel.test.ts @@ -82,6 +82,26 @@ function findButton( return undefined; } +function findIncidentButton( + nodes: ChannelNode[], + action: "ack" | "escalate", +): ChannelNode | undefined { + for (const node of nodes) { + if ( + node.type === "button" && + (node.props.value as { action?: string } | undefined)?.action === action + ) { + return node; + } + const children = node.props.children; + if (Array.isArray(children)) { + const found = findIncidentButton(children as ChannelNode[], action); + if (found) return found; + } + } + return undefined; +} + afterEach(async () => { await Promise.all(channels.splice(0).map((channel) => channel.ɵruntime.stop())); vi.restoreAllMocks(); @@ -110,6 +130,18 @@ describe("createOpenTagChannel", () => { "preview", "triage", ]); + expect(appTools.map(({ name }) => name).sort()).toEqual([ + "issue_card", + "issue_list", + "page_list", + "read_thread", + "render_chart", + "render_diagram", + "render_table", + "show_incident", + "show_links", + "show_status", + ]); expect(appTools.map(({ name }) => name)).not.toContain("confirm_write"); }); @@ -395,4 +427,66 @@ describe("createOpenTagChannel", () => { expect(JSON.stringify(secondAdapter.updated)).toContain("Approved"); expect(secondAgent.runAgentCalls).toBe(1); }); + + it("re-registers incident actions when a new Channel uses the same store", async () => { + const sharedState = new MemoryStore(); + const firstAdapter = new FakeAdapter({ platform: "intelligence" }); + firstAdapter.stateStore = sharedState; + const firstAgent = new FakeAgent([ + (subscriber) => { + subscriber.onToolCallEndEvent?.({ + event: { toolCallId: "show-incident-1" }, + toolCallName: "show_incident", + toolCallArgs: { + id: "INC-42", + title: "Checkout unavailable", + severity: "SEV1", + summary: "Requests are returning 500.", + }, + } as never); + subscriber.onRunFinishedEvent?.({ event: {} } as never); + }, + (subscriber) => + subscriber.onRunFinishedEvent?.({ event: {} } as never), + ]); + const firstChannel = createOpenTagChannel("opentag", firstAgent); + firstChannel.ɵruntime.addAdapter(firstAdapter); + channels.push(firstChannel); + await firstChannel.ɵruntime.start(); + await firstAdapter.getSink().onTurn({ + conversationKey: "incident-thread", + replyTarget: {}, + userText: "show the incident", + platform: "slack", + user: { id: "U1" }, + }); + + const acknowledge = findIncidentButton(firstAdapter.posted[0]!, "ack"); + const actionId = ( + acknowledge?.props.onClick as { id?: string } | undefined + )?.id; + expect(actionId).toMatch(/^ck:/); + await firstChannel.ɵruntime.stop(); + + const secondAdapter = new FakeAdapter({ platform: "intelligence" }); + secondAdapter.stateStore = sharedState; + const secondChannel = createOpenTagChannel("opentag", new FakeAgent()); + secondChannel.ɵruntime.addAdapter(secondAdapter); + channels.push(secondChannel); + await secondChannel.ɵruntime.start(); + await secondAdapter.getSink().onInteraction({ + id: actionId!, + conversationKey: "incident-thread", + replyTarget: {}, + messageRef: { id: "incident-message" }, + user: { id: "U2", name: "Ada" }, + value: { action: "ack", id: "INC-42" }, + }); + + expect(secondAdapter.updated).toHaveLength(1); + expect(JSON.stringify(secondAdapter.updated)).toContain( + "Acknowledged · Checkout unavailable", + ); + expect(JSON.stringify(secondAdapter.updated)).toContain("Ack'd by Ada"); + }); }); diff --git a/app/channel.tsx b/app/channel.tsx index 1a3f979..4bb7be0 100644 --- a/app/channel.tsx +++ b/app/channel.tsx @@ -13,6 +13,7 @@ import { appContext } from "./context/app-context.js"; import { ConfirmWrite } from "./human-in-the-loop/index.js"; import { parseConfirmWriteInterrupt } from "./interrupt.js"; import { FILE_ISSUE_CALLBACK, fileIssueSubmit } from "./modals/file-issue.js"; +import { IncidentCard } from "./tools/showcase-tools.js"; import { appTools } from "./tools/index.js"; type ChannelAgent = NonNullable<CreateChannelOptions["agent"]>; @@ -28,7 +29,7 @@ export function createOpenTagChannel( tools: appTools, context: [...appContext], commands: appCommands, - components: [IssueCard, IssueList, PageList, ConfirmWrite], + components: [IssueCard, IssueList, PageList, IncidentCard, ConfirmWrite], }); channel.onMention(async ({ thread, message }) => { diff --git a/app/cleanup.test.ts b/app/cleanup.test.ts new file mode 100644 index 0000000..0f55db3 --- /dev/null +++ b/app/cleanup.test.ts @@ -0,0 +1,34 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const root = process.cwd(); +const packageJson = JSON.parse( + readFileSync(resolve(root, "package.json"), "utf8"), +) as { + scripts: Record<string, string>; + dependencies: Record<string, string>; +}; + +describe("launch dependency and cleanup contract", () => { + it("keeps the exact Channels and Runtime canaries", () => { + expect(packageJson.dependencies["@copilotkit/channels"]).toBe( + "0.2.2-canary.rc-1", + ); + expect(packageJson.dependencies["@copilotkit/runtime"]).toBe( + "1.63.3-canary.rc-1", + ); + }); + + it("has no local Notion sidecar or token-grabbing browser automation", () => { + expect(packageJson.scripts).not.toHaveProperty("notion-mcp"); + expect(packageJson.dependencies).not.toHaveProperty( + "@notionhq/notion-mcp-server", + ); + expect(existsSync(resolve(root, "scripts/start-notion-mcp.ts"))).toBe( + false, + ); + expect(existsSync(resolve(root, "e2e/grab-user-token.ts"))).toBe(false); + expect(existsSync(resolve(root, "e2e/TELEGRAM-README.md"))).toBe(false); + }); +}); diff --git a/app/components/__tests__/components.test.tsx b/app/components/__tests__/components.test.tsx index 2741d5b..405d38a 100644 --- a/app/components/__tests__/components.test.tsx +++ b/app/components/__tests__/components.test.tsx @@ -336,7 +336,7 @@ describe("PageList component", () => { }); describe("Microsoft Teams rendering parity", () => { - it("renders rich issue and page content as an Adaptive Card", () => { + it("renders rich issue and page content as Adaptive Cards", () => { const issue = renderAdaptiveCard( renderToIR( <IssueCard @@ -347,6 +347,21 @@ describe("Microsoft Teams rendering parity", () => { />, ), ); + const issues = renderAdaptiveCard( + renderToIR( + <IssueList + heading="Open incidents" + issues={[ + { + identifier: "CPK-102", + title: "Login redirect loop", + state: "Todo", + priority: "High", + }, + ]} + />, + ), + ); const pages = renderAdaptiveCard( renderToIR( <PageList @@ -364,6 +379,8 @@ describe("Microsoft Teams rendering parity", () => { expect(issue.type).toBe("AdaptiveCard"); expect(JSON.stringify(issue)).toContain("🔵 CPK-101"); expect(JSON.stringify(issue)).toContain("🚨 Urgent"); + expect(JSON.stringify(issues)).toContain("Open incidents"); + expect(JSON.stringify(issues)).toContain("CPK-102"); expect(JSON.stringify(pages)).toContain("Auth outage runbook"); expect(JSON.stringify(pages)).toContain( "Steps to mitigate auth provider downtime.", diff --git a/app/components/index.ts b/app/components/index.ts index 5a324f3..2662747 100644 --- a/app/components/index.ts +++ b/app/components/index.ts @@ -6,10 +6,7 @@ * exported zod prop schema doubles as the render-tool input schema. */ export { IssueCard, issueCardSchema } from "./issue-card.js"; -export type { IssueCardProps } from "./issue-card.js"; export { IssueList, issueListSchema } from "./issue-list.js"; -export type { IssueListProps } from "./issue-list.js"; export { PageList, pageListSchema } from "./page-list.js"; -export type { PageListProps } from "./page-list.js"; diff --git a/app/components/issue-card.tsx b/app/components/issue-card.tsx index bf3ba9c..8853c79 100644 --- a/app/components/issue-card.tsx +++ b/app/components/issue-card.tsx @@ -48,7 +48,7 @@ export const issueCardSchema = z.object({ ), }); -export type IssueCardProps = z.infer<typeof issueCardSchema>; +type IssueCardProps = z.infer<typeof issueCardSchema>; /** Render ONE Linear issue as a rich Block Kit card. */ export function IssueCard(issue: IssueCardProps): ChannelNode { diff --git a/app/components/issue-list.tsx b/app/components/issue-list.tsx index 09785d2..39efd2e 100644 --- a/app/components/issue-list.tsx +++ b/app/components/issue-list.tsx @@ -55,7 +55,7 @@ export const issueListSchema = z.object({ issues: z.array(issueSchema).min(1).describe("The issues to render."), }); -export type IssueListProps = z.infer<typeof issueListSchema>; +type IssueListProps = z.infer<typeof issueListSchema>; type Issue = z.infer<typeof issueSchema>; /** Max issues rendered inline; the rest are summarized in the footer. */ diff --git a/app/components/page-list.tsx b/app/components/page-list.tsx index 91fc048..ff4d73b 100644 --- a/app/components/page-list.tsx +++ b/app/components/page-list.tsx @@ -48,7 +48,7 @@ export const pageListSchema = z.object({ pages: z.array(pageSchema).min(1).describe("The pages to render."), }); -export type PageListProps = z.infer<typeof pageListSchema>; +type PageListProps = z.infer<typeof pageListSchema>; type Page = z.infer<typeof pageSchema>; /** Max pages rendered inline; the rest are summarized in the footer. */ diff --git a/app/context/app-context.test.ts b/app/context/app-context.test.ts new file mode 100644 index 0000000..d88255f --- /dev/null +++ b/app/context/app-context.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { appContext } from "./app-context.js"; + +describe("appContext rich-rendering policy", () => { + it("maps every structured response shape to exactly one render tool", () => { + const context = appContext.map(({ value }) => value).join("\n"); + + expect(context).toContain("Several Linear issues -> issue_list"); + expect(context).toContain("A single Linear issue -> issue_card"); + expect(context).toContain("Notion pages -> page_list"); + expect(context).toContain("Tabular data -> render_table"); + expect(context).toContain("Status or metrics -> show_status"); + expect(context).toContain("Incident or outage -> show_incident"); + expect(context).toContain("Links or runbooks -> show_links"); + expect(context).toContain("Chart from data -> render_chart"); + expect(context).toContain( + "Flow, architecture, or timeline -> render_diagram", + ); + }); + + it("makes rendering mandatory and forbids duplicate prose", () => { + const context = appContext.map(({ value }) => value).join("\n"); + + expect(context).toContain("CRITICAL: RENDERING IS A HARD RULE"); + expect(context).toContain("MUST call the matching render tool"); + expect(context).toContain("call it first"); + expect(context).toContain("empty or one short line"); + expect(context).toContain("Never restate"); + expect(context).toContain("Render, then stop."); + }); +}); diff --git a/app/context/app-context.ts b/app/context/app-context.ts index 8ed4c7c..e88e74e 100644 --- a/app/context/app-context.ts +++ b/app/context/app-context.ts @@ -27,4 +27,26 @@ export const appContext: ReadonlyArray<ContextEntry> = [ "platform's tagging procedure when you know who they are.", ].join("\n"), }, + { + description: "Rich rendering policy", + value: [ + "CRITICAL: RENDERING IS A HARD RULE. Whenever an answer contains structured", + "output, you MUST call the matching render tool and let it draw the", + "result. Do not reproduce the same content as Markdown bullets, a", + "hand-written table, or prose. Choose exactly one matching tool:", + "- Several Linear issues -> issue_list", + "- A single Linear issue -> issue_card (use justCreated after creation)", + "- Notion pages -> page_list", + "- Tabular data -> render_table", + "- Status or metrics -> show_status", + "- Incident or outage -> show_incident", + "- Links or runbooks -> show_links", + "- Chart from data -> render_chart", + "- Flow, architecture, or timeline -> render_diagram", + "When the user explicitly asks for one of these structured outputs, call it first;", + "the tool output is the answer. Text alongside a rendered", + "result must be empty or one short line. Never restate issues, rows,", + "fields, links, or image contents after rendering. Render, then stop.", + ].join("\n"), + }, ]; diff --git a/app/env.test.ts b/app/env.test.ts index b45f9cf..6ea12e4 100644 --- a/app/env.test.ts +++ b/app/env.test.ts @@ -73,23 +73,6 @@ describe("parsePort", () => { expect(parsePort("4242")).toBe(4242); }); - it("can require an unpadded decimal string", () => { - expect(() => - parsePort(" 4242", 3001, "NOTION_MCP_PORT", { - strictDecimal: true, - }), - ).toThrow('Invalid NOTION_MCP_PORT: " 4242"'); - }); - - it("can treat an empty value as the configured default", () => { - expect( - parsePort("", 3001, "NOTION_MCP_PORT", { - emptyIsDefault: true, - strictDecimal: true, - }), - ).toBe(3001); - }); - it.each(["", "0", "65536", "12.5", "abc"])( "rejects invalid PORT %j", (raw) => { diff --git a/app/env.ts b/app/env.ts index e37c11f..30942d3 100644 --- a/app/env.ts +++ b/app/env.ts @@ -26,21 +26,12 @@ export function parsePort( raw: string | undefined, defaultPort = 3000, name = "PORT", - options: { - emptyIsDefault?: boolean; - strictDecimal?: boolean; - } = {}, ): number { - const value = options.emptyIsDefault && raw === "" ? undefined : raw; - if (value === undefined) return defaultPort; + if (raw === undefined) return defaultPort; - if (options.strictDecimal && !/^\d+$/.test(value)) { - throw new Error(`Invalid ${name}: "${value}"`); - } - - const port = Number(value); + const port = Number(raw); if (!Number.isInteger(port) || port < 1 || port > 65_535) { - throw new Error(`Invalid ${name}: "${value}"`); + throw new Error(`Invalid ${name}: "${raw}"`); } return port; } diff --git a/app/human-in-the-loop/__tests__/confirm-write.test.tsx b/app/human-in-the-loop/__tests__/confirm-write.test.tsx index 56dd9a3..8be2f8f 100644 --- a/app/human-in-the-loop/__tests__/confirm-write.test.tsx +++ b/app/human-in-the-loop/__tests__/confirm-write.test.tsx @@ -6,6 +6,7 @@ import { type ClickHandler, } from "@copilotkit/channels"; import { renderSlackMessage } from "@copilotkit/channels/slack"; +import { renderAdaptiveCard } from "@copilotkit/channels/teams"; import { ConfirmWrite } from "../confirm-write.js"; /** Children of an IR node as an array (empty if none). */ @@ -105,6 +106,24 @@ describe("ConfirmWrite", () => { expect(blocks.some((b) => b.type === "section")).toBe(false); }); + it("renders Create and Cancel actions as a Teams Adaptive Card", () => { + const card = renderAdaptiveCard( + renderToIR( + <ConfirmWrite + action="Create Linear issue" + detail="CPK-9: Checkout 500s" + />, + ), + ); + const json = JSON.stringify(card); + + expect(card.type).toBe("AdaptiveCard"); + expect(json).toContain("Create Linear issue"); + expect(json).toContain("Create"); + expect(json).toContain("Cancel"); + expect(json).toContain("Action.Submit"); + }); + it("approve onClick updates the picker and resumes the interrupted agent", async () => { const ir = renderToIR( <ConfirmWrite action="Create Linear issue" detail="CPK-9: ..." />, diff --git a/app/human-in-the-loop/confirm-write.tsx b/app/human-in-the-loop/confirm-write.tsx index c787c38..065b249 100644 --- a/app/human-in-the-loop/confirm-write.tsx +++ b/app/human-in-the-loop/confirm-write.tsx @@ -23,7 +23,7 @@ import { } from "@copilotkit/channels"; import type { InteractionContext } from "@copilotkit/channels"; -export interface ConfirmWriteProps { +interface ConfirmWriteProps { /** Short imperative title of the write, e.g. 'Create Linear issue'. */ action: string; /** The specifics being approved — issue title + one-line description, etc. */ diff --git a/app/human-in-the-loop/index.ts b/app/human-in-the-loop/index.ts index 201b6bb..d20645f 100644 --- a/app/human-in-the-loop/index.ts +++ b/app/human-in-the-loop/index.ts @@ -7,4 +7,3 @@ * event posts `ConfirmWrite`; the card's buttons call `thread.resume(...)`. */ export { ConfirmWrite } from "./confirm-write.js"; -export type { ConfirmWriteProps } from "./confirm-write.js"; diff --git a/app/railway.test.ts b/app/railway.test.ts index dd48088..6a5c5e9 100644 --- a/app/railway.test.ts +++ b/app/railway.test.ts @@ -65,8 +65,13 @@ describe("Railway deployment graph", () => { healthcheckPath: "/health", }, }); - expect(agent?.variables).not.toHaveProperty("NOTION_MCP_URL"); - expect(agent?.variables).not.toHaveProperty("NOTION_MCP_AUTH_TOKEN"); + expect(agent?.variables).toMatchObject({ + OPENAI_API_KEY: { type: "preserve" }, + TAVILY_API_KEY: { type: "preserve" }, + LINEAR_API_KEY: { type: "preserve" }, + NOTION_MCP_URL: { type: "preserve" }, + NOTION_MCP_AUTH_TOKEN: { type: "preserve" }, + }); const runtime = resources.find(({ name }) => name === "runtime"); expect(runtime).toMatchObject({ diff --git a/app/tools/__tests__/render-table.test.tsx b/app/tools/__tests__/render-table.test.tsx index c558504..89cfbd6 100644 --- a/app/tools/__tests__/render-table.test.tsx +++ b/app/tools/__tests__/render-table.test.tsx @@ -7,6 +7,7 @@ import { describe, it, expect } from "vitest"; import { renderToIR } from "@copilotkit/channels"; import { renderSlackMessage } from "@copilotkit/channels/slack"; +import { renderAdaptiveCard } from "@copilotkit/channels/teams"; import { renderTableTool, toMonospaceTable, clamp } from "../render-table.js"; type HandlerCtx = Parameters<typeof renderTableTool.handler>[1]; @@ -119,6 +120,21 @@ describe("render_table tool", () => { ]); }); + it("renders tabular data as a Teams Adaptive Card", async () => { + const { posts, ctx } = fakeThread(); + await renderTableTool.handler( + { title: "Open issues", columns: COLS, rows: ROWS }, + ctx, + ); + + const card = renderAdaptiveCard(renderToIR(posts[0] as never)); + const json = JSON.stringify(card); + expect(card.type).toBe("AdaptiveCard"); + expect(json).toContain("Open issues"); + expect(json).toContain("CPK-1"); + expect(json).toContain("High"); + }); + it("falls back to a monospace table when the native post is rejected", async () => { const { posts, ctx } = fakeThread([1]); const out = (await renderTableTool.handler( diff --git a/app/tools/__tests__/showcase-tools.test.tsx b/app/tools/__tests__/showcase-tools.test.tsx index b5976cb..954647d 100644 --- a/app/tools/__tests__/showcase-tools.test.tsx +++ b/app/tools/__tests__/showcase-tools.test.tsx @@ -14,6 +14,7 @@ import type { ClickHandler, } from "@copilotkit/channels"; import { renderSlackMessage } from "@copilotkit/channels/slack"; +import { renderAdaptiveCard } from "@copilotkit/channels/teams"; import { showIncidentTool, showStatusTool, @@ -100,6 +101,27 @@ describe("show_incident render-tool", () => { expect(text).toContain("Escalate"); }); + it("renders the incident and its actions as a Teams Adaptive Card", async () => { + const { posts, thread } = fakeThread(); + await showIncidentTool.handler( + { + id: "INC-1", + title: "Checkout 500s", + severity: "SEV1", + summary: "Error rate spiking on /checkout.", + }, + { thread } as unknown as IncidentCtx, + ); + + const card = renderAdaptiveCard(renderToIR(posts[0] as never)); + const json = JSON.stringify(card); + expect(card.type).toBe("AdaptiveCard"); + expect(json).toContain("Checkout 500s"); + expect(json).toContain("Acknowledge"); + expect(json).toContain("Escalate"); + expect(json).toContain("Action.Submit"); + }); + it("the Acknowledge button's onClick updates the message with a green card", async () => { const { posts, updates, thread } = fakeThread(); await showIncidentTool.handler( @@ -315,6 +337,22 @@ describe("show_status render-tool", () => { expect(text).toContain("*Queue depth*"); expect(text).toContain("operational"); }); + + it("renders status fields as a Teams Adaptive Card", async () => { + const { posts, thread } = fakeThread(); + await showStatusTool.handler( + { + heading: "Service health", + fields: [{ label: "API", value: "operational" }], + }, + { thread } as unknown as StatusCtx, + ); + + const card = renderAdaptiveCard(renderToIR(posts[0] as never)); + expect(card.type).toBe("AdaptiveCard"); + expect(JSON.stringify(card)).toContain("Service health"); + expect(JSON.stringify(card)).toContain("operational"); + }); }); describe("show_links render-tool", () => { @@ -339,4 +377,22 @@ describe("show_links render-tool", () => { // No leftover markdown link syntax. expect(text).not.toContain("](http"); }); + + it("renders links as a Teams Adaptive Card", async () => { + const { posts, thread } = fakeThread(); + await showLinksTool.handler( + { + heading: "Runbooks", + links: [ + { label: "Auth outage", url: "https://example.com/auth" }, + ], + }, + { thread } as unknown as LinksCtx, + ); + + const card = renderAdaptiveCard(renderToIR(posts[0] as never)); + expect(card.type).toBe("AdaptiveCard"); + expect(JSON.stringify(card)).toContain("Runbooks"); + expect(JSON.stringify(card)).toContain("https://example.com/auth"); + }); }); diff --git a/app/tools/index.ts b/app/tools/index.ts index 70dec6e..c6b91d4 100644 --- a/app/tools/index.ts +++ b/app/tools/index.ts @@ -2,8 +2,8 @@ * App-specific frontend tools. Provider defaults are added per turn by * `app/channel-helpers.ts`. * - * Add new tools here and re-export them through `appTools`. Wire the - * array into `createChannel({ tools })`. + * Add new tools here and include them in `appTools`. Wire the array into + * `createChannel({ tools })`. */ import { readThreadTool } from "./read-thread.js"; import { renderChartTool } from "./render-chart.js"; @@ -37,16 +37,3 @@ export const appTools: ChannelTool[] = [ showStatusTool, showLinksTool, ]; - -export { - readThreadTool, - renderChartTool, - renderDiagramTool, - renderTableTool, - issueCardTool, - issueListTool, - pageListTool, - showIncidentTool, - showStatusTool, - showLinksTool, -}; diff --git a/app/tools/showcase-tools.tsx b/app/tools/showcase-tools.tsx index 2aa4ce3..d95e989 100644 --- a/app/tools/showcase-tools.tsx +++ b/app/tools/showcase-tools.tsx @@ -112,7 +112,7 @@ const statusSchema = z.object({ type StatusProps = z.infer<typeof statusSchema>; -export function StatusCard({ heading, fields }: StatusProps) { +function StatusCard({ heading, fields }: StatusProps) { return ( <Message accent="#5E6AD2"> <Header>{`📊 ${heading}`}</Header> @@ -155,7 +155,7 @@ const linksSchema = z.object({ type LinksProps = z.infer<typeof linksSchema>; -export function LinksCard({ heading, links }: LinksProps) { +function LinksCard({ heading, links }: LinksProps) { // `[label](url)` is rewritten to Slack's `<url|label>` link form by // `markdownToMrkdwn`; authoring the raw `<url|label>` here would have its // inner text mangled, so we author markdown links instead. diff --git a/e2e/README.md b/e2e/README.md index 2399a87..3e66ae0 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -28,6 +28,10 @@ These are test-harness credentials, not Channel-runtime configuration; Intelligence still owns the deployed Slack attachment. The harness never provisions or reinstalls an app. +Obtain the user token through your approved Slack app-management flow and add +it to `.env` yourself. The repository intentionally contains no browser +automation that edits the manifest, reinstalls the app, or extracts tokens. + ## Run Start or deploy the OpenTag agent and Channel, then: diff --git a/e2e/TELEGRAM-README.md b/e2e/TELEGRAM-README.md deleted file mode 100644 index b73f25d..0000000 --- a/e2e/TELEGRAM-README.md +++ /dev/null @@ -1,6 +0,0 @@ -# Telegram end-to-end testing — coming soon - -Telegram is not an active OpenTag launch surface. Its adapter, configuration, -and live end-to-end harness will be documented when support is ready. - -For this launch, use Slack or Microsoft Teams through CopilotKit Intelligence. diff --git a/e2e/grab-user-token.ts b/e2e/grab-user-token.ts deleted file mode 100644 index f94212d..0000000 --- a/e2e/grab-user-token.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * One-off: grab the User OAuth Token (xoxp-) for the bot's Slack app by: - * 1) updating the manifest to declare `chat:write` user scope - * 2) saving the manifest - * 3) reinstalling the app (which approves the new user scope) - * 4) reading the User OAuth Token from the OAuth & Permissions page - * 5) writing SLACK_USER_TOKEN into .env - * - * Uses the persistent playwright profile under ./e2e/.chrome-profile/. - */ -import "dotenv/config"; -import { chromium } from "playwright"; -import type { Page } from "playwright"; -import { readFileSync, writeFileSync } from "node:fs"; - -const PROFILE_DIR = "./e2e/.chrome-profile"; -const APP_ID = "A0B49763Y66"; -const TEAM_ID = "T05QFA4BW9X"; - -const MANIFEST_PATH = "./slack-app-manifest.json"; -const ENV_PATH = "./.env"; - -async function setCodeMirrorValue(page: Page, value: string): Promise<void> { - // CodeMirror v5: set the value via the instance, dispatch a synthetic - // "change" so React (the Slack dashboard) notices the dirty state and - // enables Save Changes. - await page.evaluate((val) => { - const cm = ( - document.querySelector(".CodeMirror") as unknown as { - CodeMirror?: { - setValue(s: string): void; - getValue(): string; - }; - } - )?.CodeMirror; - if (!cm) throw new Error("CodeMirror instance not found"); - cm.setValue(val); - }, value); -} - -async function waitForVisibleEnabledButton( - page: Page, - label: string, - timeoutMs = 15000, -): Promise<void> { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const found = await page.evaluate((lab) => { - const btn = Array.from(document.querySelectorAll("button")).find( - (b) => - b.textContent?.trim() === lab && - !b.disabled && - (b as HTMLElement).offsetParent !== null, - ); - return !!btn; - }, label); - if (found) return; - await page.waitForTimeout(300); - } - throw new Error(`Timed out waiting for enabled button: "${label}"`); -} - -async function clickButtonByText(page: Page, label: string): Promise<void> { - await page.evaluate((lab) => { - const btn = Array.from(document.querySelectorAll("button")).find( - (b) => b.textContent?.trim() === lab && !b.disabled, - ) as HTMLButtonElement | undefined; - if (!btn) throw new Error(`No enabled button "${lab}"`); - btn.click(); - }, label); -} - -async function main() { - const manifestJson = readFileSync(MANIFEST_PATH, "utf8"); - const context = await chromium.launchPersistentContext(PROFILE_DIR, { - headless: false, - }); - const page = context.pages()[0] ?? (await context.newPage()); - - // ── 1+2. Save manifest ──────────────────────────────────────────── - console.log("[grab-token] → manifest editor"); - await page.goto( - `https://app.slack.com/app-settings/${TEAM_ID}/${APP_ID}/app-manifest`, - { waitUntil: "networkidle" }, - ); - await page.waitForSelector(".CodeMirror", { timeout: 15000 }); - await page.waitForTimeout(1000); - - await setCodeMirrorValue(page, manifestJson); - await page.waitForTimeout(800); - - console.log("[grab-token] waiting for Save Changes to enable"); - try { - await waitForVisibleEnabledButton(page, "Save Changes", 10_000); - } catch { - // Already saved? Pull the current editor contents and compare. - const currentVal = await page.evaluate(() => { - const cm = ( - document.querySelector(".CodeMirror") as unknown as { - CodeMirror?: { getValue(): string }; - } - )?.CodeMirror; - return cm?.getValue() ?? ""; - }); - if (currentVal.includes('"user"') && currentVal.includes('"chat:write"')) { - console.log( - "[grab-token] manifest already has user scope, no save needed", - ); - } else { - throw new Error( - "Save Changes button did not enable and current manifest doesn't have user scope", - ); - } - } - - // Try save (may already be saved) - const saveEnabled = await page.evaluate(() => { - const btn = Array.from(document.querySelectorAll("button")).find( - (b) => b.textContent?.trim() === "Save Changes", - ) as HTMLButtonElement | undefined; - return !!(btn && !btn.disabled); - }); - if (saveEnabled) { - console.log("[grab-token] clicking Save Changes"); - await clickButtonByText(page, "Save Changes"); - // Some manifest changes trigger a confirmation modal (esp. scope changes). - await page.waitForTimeout(2000); - // Click any confirmation buttons that pop up. - const confirmed = await page.evaluate(() => { - const labels = ["Save", "Yes, save changes", "Continue", "Save Changes"]; - for (const lab of labels) { - const btn = Array.from(document.querySelectorAll("button")).find( - (b) => - b.textContent?.trim() === lab && - !b.disabled && - (b as HTMLElement).offsetParent !== null, - ) as HTMLButtonElement | undefined; - if (btn) { - btn.click(); - return lab; - } - } - return null; - }); - if (confirmed) console.log(`[grab-token] confirmation: "${confirmed}"`); - await page.waitForTimeout(3000); - } - - // ── 3. Reinstall (will OAuth-approve the new user scope) ────────── - console.log("[grab-token] → install page"); - await page.goto(`https://api.slack.com/apps/${APP_ID}/install-on-team`, { - waitUntil: "networkidle", - }); - await page.waitForTimeout(2000); - - const installLink = await page.evaluate(() => { - const a = Array.from(document.querySelectorAll("a")).find( - (el) => - /^(re)?install to/i.test((el.textContent ?? "").trim()) && - el.href.includes("/oauth/v2/authorize"), - ); - return a?.href ?? null; - }); - if (!installLink) throw new Error("Couldn't find Install/Reinstall link"); - console.log("[grab-token] → install link →", installLink.slice(0, 80) + "…"); - await page.goto(installLink, { waitUntil: "networkidle" }); - await page.waitForTimeout(2500); - - // ── 4. Approve OAuth — Allow button on the consent page ─────────── - console.log("[grab-token] looking for Allow button"); - try { - await page.waitForFunction( - () => - !!Array.from(document.querySelectorAll("button")).find( - (b) => b.textContent?.trim() === "Allow", - ), - { timeout: 8000 }, - ); - // Real click via DOM event submission (the Allow button is a real <button type=submit>). - await page.evaluate(() => { - const btn = Array.from(document.querySelectorAll("button")).find( - (b) => b.textContent?.trim() === "Allow" && !b.disabled, - ) as HTMLButtonElement | undefined; - if (!btn) throw new Error("Allow button not found / disabled"); - // Submit the form (Slack's Allow is type=submit inside a form). - if (btn.form) btn.form.submit(); - else btn.click(); - }); - console.log("[grab-token] Allow submitted; waiting for redirect"); - await page.waitForLoadState("networkidle", { timeout: 15000 }); - await page.waitForTimeout(2000); - } catch (err) { - console.log( - "[grab-token] no Allow button (maybe already approved?):", - (err as Error).message, - ); - } - - // ── 5. Read both tokens from OAuth & Permissions ────────────────── - console.log("[grab-token] → OAuth & Permissions"); - await page.goto(`https://api.slack.com/apps/${APP_ID}/oauth`, { - waitUntil: "networkidle", - }); - await page.waitForTimeout(2500); - - const tokens = await page.evaluate(() => { - const fields = Array.from( - document.querySelectorAll("input[readonly]"), - ) as HTMLInputElement[]; - const found: { kind: string; value: string }[] = []; - for (const f of fields) { - const v = f.value ?? ""; - if (v.startsWith("xoxb-")) found.push({ kind: "bot", value: v }); - else if (v.startsWith("xoxp-")) found.push({ kind: "user", value: v }); - } - return found; - }); - console.log( - "[grab-token] found tokens:", - tokens.map((t) => t.kind), - ); - - const bot = tokens.find((t) => t.kind === "bot")?.value; - const user = tokens.find((t) => t.kind === "user")?.value; - if (!user) { - // Dump some page state to help debug. - const debugInfo = await page.evaluate(() => ({ - title: document.title, - url: location.href, - readonlyValues: Array.from(document.querySelectorAll("input[readonly]")) - .map((i) => (i as HTMLInputElement).value) - .slice(0, 8), - buttons: Array.from(document.querySelectorAll("button")) - .map((b) => b.textContent?.trim()) - .filter((t) => t && t.length < 40), - })); - console.log("[grab-token] debug:", JSON.stringify(debugInfo, null, 2)); - throw new Error("Did not find xoxp- user token"); - } - - // ── 6. Write to .env (preserve existing keys) ───────────────────── - const envText = readFileSync(ENV_PATH, "utf8"); - let updated = upsertEnv(envText, "SLACK_USER_TOKEN", user); - if (bot) updated = upsertEnv(updated, "SLACK_BOT_TOKEN", bot); - writeFileSync(ENV_PATH, updated); - console.log("[grab-token] wrote tokens to .env"); - - await context.close(); -} - -function upsertEnv(text: string, key: string, value: string): string { - if (!value) return text; - const re = new RegExp(`^${key}=.*$`, "m"); - if (re.test(text)) return text.replace(re, `${key}=${value}`); - return text + (text.endsWith("\n") ? "" : "\n") + `${key}=${value}\n`; -} - -main().catch((err) => { - console.error("[grab-token] failed:", err); - process.exit(1); -}); diff --git a/e2e/run.ts b/e2e/run.ts index 96c5b70..70c2128 100644 --- a/e2e/run.ts +++ b/e2e/run.ts @@ -326,7 +326,7 @@ async function runCase(spec: E2ECase): Promise<CaseResult> { async function main() { if (!USER_TOKEN) { console.error( - "SLACK_USER_TOKEN missing in .env — run `pnpm exec tsx e2e/grab-user-token.ts` first.", + "SLACK_USER_TOKEN missing in .env — add an xoxp token with chat:write; see e2e/README.md.", ); process.exit(1); } diff --git a/e2e/slack-api.ts b/e2e/slack-api.ts index a5bdda8..5234703 100644 --- a/e2e/slack-api.ts +++ b/e2e/slack-api.ts @@ -45,7 +45,7 @@ export async function postAsUser( ) { if (!USER_TOKEN) { throw new Error( - "SLACK_USER_TOKEN missing — run `pnpm exec tsx e2e/grab-user-token.ts` first", + "SLACK_USER_TOKEN missing — add an xoxp token with chat:write to .env; see e2e/README.md", ); } // `link_names: 1` makes Slack resolve `@username` (and `@here`/`@channel`) diff --git a/package.json b/package.json index 349f3ac..8fe787c 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,6 @@ "start": "tsx server.ts", "runtime": "tsx server.ts", "agent": "cd agent && uv run python main.py", - "notion-mcp": "tsx scripts/start-notion-mcp.ts", "check-types": "tsc --noEmit -p tsconfig.json", "test": "vitest run", "e2e": "tsx e2e/run.ts" @@ -19,7 +18,6 @@ "@ag-ui/client": "^0.0.57", "@copilotkit/channels": "0.2.2-canary.rc-1", "@copilotkit/runtime": "1.63.3-canary.rc-1", - "@notionhq/notion-mcp-server": "^2.4.0", "dotenv": "^16.4.5", "playwright": "^1.49.0", "tsx": "^4.19.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 354acee..4e33de4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,9 +20,6 @@ importers: '@copilotkit/runtime': specifier: 1.63.3-canary.rc-1 version: 1.63.3-canary.rc-1(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) - '@notionhq/notion-mcp-server': - specifier: ^2.4.0 - version: 2.4.1(@cfworker/json-schema@4.1.1)(js-yaml@4.3.0) dotenv: specifier: ^16.4.5 version: 16.6.1 @@ -638,10 +635,6 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@notionhq/notion-mcp-server@2.4.1': - resolution: {integrity: sha512-D/LixSjTgWi3LrE386yFp+/JHQg768stP5AnWR7SY7p9anDi1NR8Wl6RA4bIKVfLf1kReBVKMbPaDKpM4rm89A==} - hasBin: true - '@opentelemetry/api@1.9.1': resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} @@ -983,14 +976,6 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - ajv-formats@2.1.1: - resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -1002,17 +987,10 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} @@ -1033,9 +1011,6 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - bath-es5@3.0.3: - resolution: {integrity: sha512-PdCioDToH3t84lP40kUFCKWCOCH389Dl1kbC8FGoqOwamxsmqxxnJSXdkTOsPoNHXjem4+sJ+bbNoQm5zeCqxg==} - bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} @@ -1083,10 +1058,6 @@ packages: class-validator@0.14.4: resolution: {integrity: sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==} - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1178,9 +1149,6 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - dereference-json-schema@0.2.2: - resolution: {integrity: sha512-w8dUsJyrzH4Zsj8W/tKcjLsmcTKXfdNf+n3BBm1SAfnqpaCodgEUWqQGJ+pNb9NOqPwYMGvUnZZ8nQfeFjJlbQ==} - destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -1214,9 +1182,6 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -1248,10 +1213,6 @@ packages: engines: {node: '>=18'} hasBin: true - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} @@ -1391,10 +1352,6 @@ packages: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -1504,10 +1461,6 @@ packages: is-electron@2.2.2: resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==} - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - is-network-error@1.3.2: resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==} engines: {node: '>=16'} @@ -1522,10 +1475,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isexe@3.1.5: - resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} - engines: {node: '>=18'} - jose@5.10.0: resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} @@ -1539,10 +1488,6 @@ packages: js-tiktoken@1.0.21: resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} - hasBin: true - json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} @@ -1700,9 +1645,6 @@ packages: lodash.isstring@4.0.1: resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} @@ -1859,18 +1801,6 @@ packages: zod: optional: true - openapi-client-axios@7.9.0: - resolution: {integrity: sha512-1VRBbbNQTz6pAWFALXrqr88GclEb+LirqbzmLnFzqOlnCFC2Ao5Gv4JYf783+A8PQbEAu5Or4Rg32RaetllnwA==} - peerDependencies: - axios: '>=0.25.0' - js-yaml: ^4.1.0 - - openapi-schema-validator@12.1.3: - resolution: {integrity: sha512-xTHOmxU/VQGUgo7Cm0jhwbklOKobXby+/237EG967+3TQEYJztMgX9Q5UE2taZKwyKPUq0j11dngpGjUuxz1hQ==} - - openapi-types@12.1.3: - resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} - p-finally@1.0.0: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} engines: {node: '>=4'} @@ -2018,10 +1948,6 @@ packages: reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -2127,17 +2053,9 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -2347,20 +2265,11 @@ packages: engines: {node: '>= 8'} hasBin: true - which@5.0.0: - resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} - engines: {node: ^18.17.0 || >=20.5.0} - hasBin: true - why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -2376,26 +2285,11 @@ packages: utf-8-validate: optional: true - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@17.7.3: - resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} - engines: {node: '>=12'} - zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: zod: ^3.25.28 || ^4 - zod@3.24.1: - resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} - zod@3.25.75: resolution: {integrity: sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg==} @@ -3171,30 +3065,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@3.24.1)': - dependencies: - '@hono/node-server': 1.19.14(hono@4.12.30) - ajv: 8.20.0 - ajv-formats: 3.0.1(ajv@8.20.0) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.1.0 - express: 5.2.1 - express-rate-limit: 8.5.2(express@5.2.1) - hono: 4.12.30 - jose: 6.2.3 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 3.24.1 - zod-to-json-schema: 3.25.2(zod@3.24.1) - optionalDependencies: - '@cfworker/json-schema': 4.1.1 - transitivePeerDependencies: - - supports-color - '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.30) @@ -3226,26 +3096,6 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@notionhq/notion-mcp-server@2.4.1(@cfworker/json-schema@4.1.1)(js-yaml@4.3.0)': - dependencies: - '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@3.24.1) - axios: 1.18.1 - express: 4.22.2 - form-data: 4.0.6 - mustache: 4.2.0 - node-fetch: 3.3.2 - openapi-client-axios: 7.9.0(axios@1.18.1)(js-yaml@4.3.0) - openapi-schema-validator: 12.1.3 - openapi-types: 12.1.3 - which: 5.0.0 - yargs: 17.7.3 - zod: 3.24.1 - transitivePeerDependencies: - - '@cfworker/json-schema' - - debug - - js-yaml - - supports-color - '@opentelemetry/api@1.9.1': {} '@oxc-project/types@0.139.0': {} @@ -3614,10 +3464,6 @@ snapshots: '@opentelemetry/api': 1.9.1 zod: 3.25.76 - ajv-formats@2.1.1(ajv@8.20.0): - optionalDependencies: - ajv: 8.20.0 - ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -3629,14 +3475,10 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - ansi-regex@5.0.1: {} - ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 - argparse@2.0.1: {} - array-flatten@1.1.1: {} assertion-error@2.0.1: {} @@ -3657,8 +3499,6 @@ snapshots: base64-js@1.5.1: {} - bath-es5@3.0.3: {} - bignumber.js@9.3.1: {} body-parser@1.20.6: @@ -3728,12 +3568,6 @@ snapshots: libphonenumber-js: 1.13.8 validator: 13.15.35 - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3797,8 +3631,6 @@ snapshots: depd@2.0.0: {} - dereference-json-schema@0.2.2: {} - destroy@1.2.0: {} detect-libc@2.1.2: {} @@ -3840,8 +3672,6 @@ snapshots: ee-first@1.1.1: {} - emoji-regex@8.0.0: {} - encodeurl@2.0.0: {} end-of-stream@1.4.5: @@ -3894,8 +3724,6 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 - escalade@3.2.0: {} - escape-html@1.0.3: {} estree-walker@3.0.3: @@ -4082,8 +3910,6 @@ snapshots: transitivePeerDependencies: - supports-color - get-caller-file@2.0.5: {} - get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4218,8 +4044,6 @@ snapshots: is-electron@2.2.2: {} - is-fullwidth-code-point@3.0.0: {} - is-network-error@1.3.2: {} is-promise@4.0.0: {} @@ -4228,8 +4052,6 @@ snapshots: isexe@2.0.0: {} - isexe@3.1.5: {} - jose@5.10.0: {} jose@6.2.3: {} @@ -4240,10 +4062,6 @@ snapshots: dependencies: base64-js: 1.5.1 - js-yaml@4.3.0: - dependencies: - argparse: 2.0.1 - json-bigint@1.0.0: dependencies: bignumber.js: 9.3.1 @@ -4383,8 +4201,6 @@ snapshots: lodash.isstring@4.0.1: {} - lodash.merge@4.6.2: {} - lodash.once@4.1.1: {} lodash.snakecase@4.1.1: {} @@ -4480,23 +4296,6 @@ snapshots: zod: 3.25.76 optional: true - openapi-client-axios@7.9.0(axios@1.18.1)(js-yaml@4.3.0): - dependencies: - axios: 1.18.1 - bath-es5: 3.0.3 - dereference-json-schema: 0.2.2 - js-yaml: 4.3.0 - openapi-types: 12.1.3 - - openapi-schema-validator@12.1.3: - dependencies: - ajv: 8.20.0 - ajv-formats: 2.1.1(ajv@8.20.0) - lodash.merge: 4.6.2 - openapi-types: 12.1.3 - - openapi-types@12.1.3: {} - p-finally@1.0.0: {} p-queue@6.6.2: @@ -4654,8 +4453,6 @@ snapshots: reflect-metadata@0.2.2: {} - require-directory@2.1.1: {} - require-from-string@2.0.2: {} retry@0.13.1: {} @@ -4809,20 +4606,10 @@ snapshots: std-env@4.2.0: {} - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - strip-json-comments@3.1.1: {} supports-color@7.2.0: @@ -4960,49 +4747,19 @@ snapshots: dependencies: isexe: 2.0.0 - which@5.0.0: - dependencies: - isexe: 3.1.5 - why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrappy@1.0.2: {} ws@8.21.1: {} - y18n@5.0.8: {} - - yargs-parser@21.1.1: {} - - yargs@17.7.3: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - - zod-to-json-schema@3.25.2(zod@3.24.1): - dependencies: - zod: 3.24.1 - zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76 - zod@3.24.1: {} - zod@3.25.75: {} zod@3.25.76: {} diff --git a/scripts/start-notion-mcp.ts b/scripts/start-notion-mcp.ts deleted file mode 100644 index 29c30d2..0000000 --- a/scripts/start-notion-mcp.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Starts the official Notion MCP server as a Streamable-HTTP sidecar for the - * OpenTag Python deep-research agent (`agent/`). Run with `pnpm notion-mcp` - * for optional local Notion access. - * - * Why a launcher instead of a raw npm script: the two `.env` values need to be - * mapped to the two DIFFERENT things the Notion server expects — the Notion - * integration secret (`NOTION_TOKEN`) becomes the OUTBOUND `Authorization: - * Bearer` the server sends to Notion's API (passed via `OPENAPI_MCP_HEADERS`, - * see below), and `NOTION_MCP_AUTH_TOKEN` becomes the server's INBOUND HTTP - * transport bearer (`AUTH_TOKEN` / `--auth-token`) that the agent presents to - * reach this sidecar. Doing the mapping here works identically on - * Windows/macOS/Linux without shell-specific env interpolation. - */ -import "dotenv/config"; -import { spawn } from "node:child_process"; -import { parsePort } from "../app/env.js"; - -const authToken = process.env["NOTION_MCP_AUTH_TOKEN"]; -const notionToken = process.env["NOTION_TOKEN"]; - -if (!authToken) { - console.error( - "[notion-mcp] NOTION_MCP_AUTH_TOKEN is required — it's the bearer the " + - "agent uses to reach this sidecar. Set it in .env (any strong string).", - ); - process.exit(1); -} -if (!notionToken) { - console.error( - "[notion-mcp] NOTION_TOKEN is required — the Notion integration secret " + - "(ntn_...). Create one at notion.so → Settings → Connections.", - ); - process.exit(1); -} - -// Port the sidecar listens on. Must agree with NOTION_MCP_URL as dialed by the -// Python agent; both default to http://127.0.0.1:3001/mcp. Validated up front -// because it's passed -// as a `--port` arg to the spawn below, which uses a shell on Windows -// (`shell: isWindows`) — an unvalidated value with spaces/shell metacharacters -// could mangle or inject the command there. An empty string (a bare -// `NOTION_MCP_PORT=` in .env) is treated as unset → default. -let portNumber: number; -try { - portNumber = parsePort( - process.env["NOTION_MCP_PORT"], - 3001, - "NOTION_MCP_PORT", - { - emptyIsDefault: true, - strictDecimal: true, - }, - ); -} catch (error) { - console.error(`[notion-mcp] ${(error as Error).message}`); - process.exit(1); -} -const port = String(portNumber); - -// Host the sidecar binds to. Defaults to loopback for local dev (matches the -// upstream server's own default). Validated because it's -// passed as a `--host` arg to the spawn below (which uses a shell on Windows, -// `shell: isWindows`), and to prevent a leading `-` being read as a flag. -const rawHost = process.env["NOTION_MCP_HOST"] || undefined; -// Must start with an alphanumeric or ":" (for IPv6 like "::") — this both keeps -// it shell-inert and prevents a leading-"-" value being consumed as a flag by -// the child CLI rather than as the host. -if (rawHost !== undefined && !/^[A-Za-z0-9:][A-Za-z0-9.:_-]*$/.test(rawHost)) { - console.error( - `[notion-mcp] NOTION_MCP_HOST must be a hostname or IP address starting ` + - `with a letter, digit, or ":" (e.g. "::", "0.0.0.0", "localhost"), got: ` + - `${JSON.stringify(rawHost)}`, - ); - process.exit(1); -} -const host = rawHost ?? "127.0.0.1"; - -// Carry the Notion secret as the outbound `Authorization: Bearer` via -// OPENAPI_MCP_HEADERS. We deliberately do NOT set `Notion-Version` here: the -// server (@notionhq/notion-mcp-server ≥ 2.4) sources the API version -// per-operation from its bundled OpenAPI spec, and a globally-configured -// `Notion-Version` header would OVERRIDE those per-operation defaults, pinning -// every call to one (soon-stale) version and breaking newer endpoints. Leaving -// it out lets each operation use the version its schema was written for. (Set -// NOTION_VERSION only if you must force a specific version for an older server.) -const forcedVersion = process.env["NOTION_VERSION"]; -const openApiHeaders = JSON.stringify({ - Authorization: `Bearer ${notionToken}`, - ...(forcedVersion ? { "Notion-Version": forcedVersion } : {}), -}); - -// OPENAPI_MCP_HEADERS is our Notion auth source. It takes precedence anyway -// (the server's parseHeadersFromEnv checks OPENAPI_MCP_HEADERS before falling -// back to NOTION_TOKEN), so deleting NOTION_TOKEN from the child env is -// defensive cleanup: it removes the unused fallback and suppresses the server's -// NOTION_TOKEN startup diagnostic (a /v1/users/me probe). dotenv loaded it into -// process.env, so delete it after the spread. -const childEnv: NodeJS.ProcessEnv = { - ...process.env, - OPENAPI_MCP_HEADERS: openApiHeaders, - AUTH_TOKEN: authToken, -}; -delete childEnv["NOTION_TOKEN"]; - -const isWindows = process.platform === "win32"; -const child = spawn( - "npx", - [ - "-y", - "@notionhq/notion-mcp-server", - "--transport", - "http", - "--host", - host, - "--port", - port, - ], - { - stdio: "inherit", - // Windows only: shell:true so `npx` resolves to `npx.cmd`. Node refuses to - // spawn `.cmd`/`.bat` without a shell (CVE-2024-27980) → `spawn EINVAL`. On - // POSIX we spawn `npx` directly (no shell), which both removes the shell - // wrapper entirely and lets `detached` put the server in its own process - // group so we can signal the WHOLE tree on shutdown (see killTree below). - shell: isWindows, - detached: !isWindows, - env: childEnv, - }, -); - -// Set when WE forward a shutdown signal (below), so the exit handler can tell a -// clean, operator/platform-initiated stop from an abnormal termination. -let shuttingDown = false; -child.on("exit", (code, signal) => { - // A shutdown WE initiated (SIGINT/SIGTERM forwarded below) is clean. - if (shuttingDown) process.exit(0); - // Anything else is an unexpected termination of a long-running server — a - // FAILURE even on code 0 (e.g. the server exits after an unknown flag). Exit - // non-zero and log so Railway's ON_FAILURE restart fires and it's visible; - // preserve the child's own non-zero code when it has one. - console.error( - `[notion-mcp] sidecar exited unexpectedly (code=${code}, signal=${signal ?? "none"}) — treating as failure`, - ); - process.exit(code || 1); -}); -// Without this, a failed spawn (e.g. `npx` not on PATH -> ENOENT, or EACCES) -// surfaces as an uncaught 'error' event with a raw Node stack trace instead -// of the clean, actionable messages this script prints for every other -// misconfiguration. -child.on("error", (err) => { - console.error("[notion-mcp] failed to start the sidecar:", err.message); - process.exit(1); -}); -// Signal the whole child tree. On POSIX the child is a detached group leader, so -// process.kill(-pid) reaches npx AND the underlying node server; on Windows we -// fall back to child.kill (no POSIX process groups). Wrapped because the group -// may already be gone (ESRCH) by the time the escalation fires. -const killTree = (sig: NodeJS.Signals) => { - try { - if (!isWindows && child.pid) process.kill(-child.pid, sig); - else child.kill(sig); - } catch { - // already exited — nothing to signal - } -}; -// Forward shutdown signals, then escalate to SIGKILL if the tree hasn't exited -// within a grace window. `.unref()` so this timer never keeps us alive. -const shutdown = (sig: NodeJS.Signals) => { - shuttingDown = true; - killTree(sig); - setTimeout(() => killTree("SIGKILL"), 5000).unref(); -}; -process.on("SIGINT", () => shutdown("SIGINT")); -process.on("SIGTERM", () => shutdown("SIGTERM")); diff --git a/setup.md b/setup.md index a4b732c..3bc711a 100644 --- a/setup.md +++ b/setup.md @@ -1,7 +1,7 @@ # OpenTag setup -This guide covers the canonical OpenTag deployment: one Python deep-agent -service and one Node CopilotRuntime service with Channels embedded. +This guide covers the canonical OpenTag deployment: one Python agent service +and one Node CopilotRuntime service with Channels embedded. Slack and Microsoft Teams are supported for this launch. Discord, Telegram, and WhatsApp are coming soon. @@ -58,19 +58,22 @@ cp .env.example .env | --- | --- | --- | | `OPENAI_API_KEY` | Yes | Model access | | `OPENAI_MODEL` | No | Defaults to `gpt-5.5` | +| `OPENAI_REASONING_EFFORT` | No | Defaults to `low` | +| `OPENAI_VERBOSITY` | No | Defaults to `low` | | `TAVILY_API_KEY` | No | Enables live web research | | `LINEAR_API_KEY` | No | Enables the hosted Linear MCP | | `LINEAR_MCP_URL` | No | Overrides the hosted Linear MCP URL | -| `NOTION_MCP_AUTH_TOKEN` | No | Authenticates to the Notion MCP sidecar | -| `NOTION_MCP_URL` | No | Defaults to `http://127.0.0.1:3001/mcp` | +| `NOTION_MCP_AUTH_TOKEN` | No | Bearer token for a remote Notion MCP; requires `NOTION_MCP_URL` | +| `NOTION_MCP_URL` | No | Remote Notion MCP endpoint; requires `NOTION_MCP_AUTH_TOKEN` | | `SERVER_HOST` | No | Local bind host; defaults to `0.0.0.0` | | `SERVER_PORT` / `PORT` | No | Local port; defaults to `8123` | Only `OPENAI_API_KEY` is required. Without Tavily or internal-source -credentials, the agent still chats, plans, writes virtual files, and renders -supported UI components. The Python agent explicitly loads this root `.env` -for local development; Railway service variables work normally without a -checked-in environment file. +credentials, the agent still chats, triages, and renders supported UI +components. Planning and virtual files remain available for explicitly +substantial work. The Python agent explicitly loads this root `.env` for local +development; Railway service variables work normally without a checked-in +environment file. Run it: @@ -79,7 +82,7 @@ pnpm agent ``` The AG-UI endpoint is `http://localhost:8123/`; `/health` reports the -`opentag-research-agent` service. +`opentag-agent` service. ## Configure Intelligence @@ -172,7 +175,7 @@ Reads and UI rendering are never gated. ### Tavily Set `TAVILY_API_KEY` in the root `.env` to enable live web research. The -`research` tool is not registered when the key is absent. +`web_search` tool is not registered when the key is absent. ### Linear @@ -181,13 +184,10 @@ MCP by default. Railway preserves this optional secret on the `agent` service. ### Notion -Notion is optional and is not a production Railway service. Its hosted MCP -requires interactive OAuth and token storage, so the included bearer-token -server is only a local development option: - -1. Put `NOTION_TOKEN` and a strong `NOTION_MCP_AUTH_TOKEN` in the root `.env`. -2. Run `pnpm notion-mcp`. -3. Restart `pnpm agent` so it discovers the Notion tools. +Notion is optional and is not a separate Railway service. Configure an existing +remote MCP endpoint by setting both `NOTION_MCP_URL` and +`NOTION_MCP_AUTH_TOKEN`, then restart `pnpm agent` so it discovers the tools. +If either value is absent, OpenTag skips Notion without blocking startup. ## Railway @@ -201,7 +201,8 @@ The IaC file declares exactly: `runtime.AGENT_URL` references the agent's Railway private domain and port. Production Intelligence URLs are literal configuration, the API key is preserved, and the Channel name is `open-tag`. `OPENAI_API_KEY` is required on -`agent`; Tavily and Linear are optional preserved secrets. +`agent`; Tavily, Linear, and the paired remote Notion variables are optional +preserved settings. Evaluate the configuration locally without applying it: diff --git a/slack-app-manifest.yaml b/slack-app-manifest.yaml index 9a8df32..94b38c0 100644 --- a/slack-app-manifest.yaml +++ b/slack-app-manifest.yaml @@ -42,7 +42,7 @@ features: oauth_config: scopes: user: - - chat:write # user-token write access (e.g. grab-user-token.ts for e2e) + - chat:write # user-token write access for the optional live E2E harness bot: # --- Read what the user said --- - app_mentions:read # see @mentions of the bot From cd1e0c7a625d17b9ff3424087c3dbf9371c40a65 Mon Sep 17 00:00:00 2001 From: Tyler Slaton <tyler@copilotkit.ai> Date: Tue, 28 Jul 2026 13:14:56 -0400 Subject: [PATCH 19/19] fix: use Responses API for gpt-5.5 tools --- agent/agent.py | 1 + agent/tests/test_agent_configuration.py | 365 +++++++++++++++++++++++- 2 files changed, 365 insertions(+), 1 deletion(-) diff --git a/agent/agent.py b/agent/agent.py index 7d242ef..26abca1 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -78,6 +78,7 @@ def build_agent(): api_key=api_key, reasoning_effort=reasoning_effort, verbosity=verbosity, + use_responses_api=True, ) internal_tools = internal_source_tools() diff --git a/agent/tests/test_agent_configuration.py b/agent/tests/test_agent_configuration.py index 4a870a9..330b4ae 100644 --- a/agent/tests/test_agent_configuration.py +++ b/agent/tests/test_agent_configuration.py @@ -1,11 +1,30 @@ +import asyncio +import json + import agent as agent_mod +import httpx import pytest from langchain_core.language_models.chat_models import BaseChatModel -from langchain_core.messages import AIMessage, BaseMessage +from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage from langchain_core.outputs import ChatGeneration, ChatResult +from langchain_openai import ChatOpenAI as RealChatOpenAI from pydantic import Field +RENDER_TABLE_ARGUMENTS = { + "title": "Open issues", + "columns": [ + {"header": "Issue"}, + {"header": "Priority", "align": "center"}, + {"header": "Age (days)", "align": "right"}, + ], + "rows": [ + ["OSS-631", "High", "2"], + ["OSS-615", "Medium", "11"], + ], +} + + class FakeGraph: def __init__(self, captured): self.captured = captured @@ -47,6 +66,7 @@ def test_build_agent_defaults_to_low_reasoning_and_verbosity(monkeypatch): assert captured["model"]["reasoning_effort"] == "low" assert captured["model"]["verbosity"] == "low" + assert captured["model"]["use_responses_api"] is True def test_build_agent_accepts_valid_reasoning_and_verbosity_overrides(monkeypatch): @@ -130,3 +150,346 @@ def _generate( assert "execute" not in tool_names assert "task" not in tool_names assert {"write_todos", "read_file", "write_file"}.issubset(tool_names) + + +def _configure_minimal_environment(monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.delenv("OPENAI_MODEL", raising=False) + monkeypatch.delenv("OPENAI_REASONING_EFFORT", raising=False) + monkeypatch.delenv("OPENAI_VERBOSITY", raising=False) + monkeypatch.delenv("TAVILY_API_KEY", raising=False) + monkeypatch.delenv("LINEAR_API_KEY", raising=False) + monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) + + +def _response_payload(output, response_id): + return { + "id": response_id, + "object": "response", + "created_at": 0, + "status": "completed", + "error": None, + "incomplete_details": None, + "instructions": None, + "max_output_tokens": None, + "model": "gpt-5.5", + "output": output, + "parallel_tool_calls": True, + "previous_response_id": None, + "reasoning": {"effort": "low", "summary": None}, + "store": True, + "temperature": None, + "text": {"format": {"type": "text"}, "verbosity": "low"}, + "tool_choice": "auto", + "tools": [], + "top_p": None, + "truncation": "disabled", + "usage": { + "input_tokens": 10, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens": 5, + "output_tokens_details": {"reasoning_tokens": 0}, + "total_tokens": 15, + }, + "metadata": {}, + } + + +def _render_table_action(): + return { + "type": "function", + "function": { + "name": "render_table", + "description": ( + "Render tabular data as a table posted to the conversation " + "thread." + ), + "parameters": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "columns": { + "type": "array", + "items": { + "type": "object", + "properties": { + "header": {"type": "string"}, + "align": { + "type": "string", + "enum": ["left", "center", "right"], + }, + }, + "required": ["header"], + }, + }, + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {"type": "string"}, + }, + }, + }, + "required": ["columns", "rows"], + }, + }, + } + + +def _sse_response(events): + content = "".join( + f"event: {event['type']}\ndata: {json.dumps(event)}\n\n" + for event in events + ) + content += "data: [DONE]\n\n" + return httpx.Response( + 200, + content=content, + headers={"content-type": "text/event-stream"}, + ) + + +def test_async_streaming_responses_round_trip_for_render_table(monkeypatch): + _configure_minimal_environment(monkeypatch) + requests = [] + + async def handle_openai_request(request): + assert request.url.path == "/v1/responses" + body = json.loads(request.content) + requests.append(body) + + if len(requests) == 1: + arguments = json.dumps( + RENDER_TABLE_ARGUMENTS, + separators=(",", ":"), + ) + completed_item = { + "id": "fc_render_table", + "type": "function_call", + "status": "completed", + "arguments": arguments, + "call_id": "call_render_table", + "name": "render_table", + } + created_response = _response_payload([], "resp_tool_call") + created_response["status"] = "in_progress" + created_response["usage"] = None + return _sse_response( + [ + { + "type": "response.created", + "sequence_number": 0, + "response": created_response, + }, + { + "type": "response.output_item.added", + "sequence_number": 1, + "output_index": 0, + "item": { + **completed_item, + "status": "in_progress", + "arguments": "", + }, + }, + { + "type": "response.function_call_arguments.delta", + "sequence_number": 2, + "output_index": 0, + "item_id": "fc_render_table", + "delta": arguments, + }, + { + "type": "response.completed", + "sequence_number": 3, + "response": _response_payload( + [completed_item], + "resp_tool_call", + ), + }, + ] + ) + + completed_item = { + "id": "msg_final", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Rendered the table.", + "annotations": [], + "logprobs": [], + } + ], + } + created_response = _response_payload([], "resp_final") + created_response["status"] = "in_progress" + created_response["usage"] = None + return _sse_response( + [ + { + "type": "response.created", + "sequence_number": 0, + "response": created_response, + }, + { + "type": "response.output_item.added", + "sequence_number": 1, + "output_index": 0, + "item": { + **completed_item, + "status": "in_progress", + "content": [], + }, + }, + { + "type": "response.output_text.delta", + "sequence_number": 2, + "output_index": 0, + "content_index": 0, + "item_id": "msg_final", + "delta": "Rendered the table.", + "logprobs": [], + }, + { + "type": "response.output_text.done", + "sequence_number": 3, + "output_index": 0, + "content_index": 0, + "item_id": "msg_final", + "text": "Rendered the table.", + "logprobs": [], + }, + { + "type": "response.completed", + "sequence_number": 4, + "response": _response_payload( + [completed_item], + "resp_final", + ), + }, + ] + ) + + async def run_round_trip(): + transport = httpx.MockTransport(handle_openai_request) + async with httpx.AsyncClient(transport=transport) as http_client: + + def chat_openai_with_mock_transport(**options): + return RealChatOpenAI( + **options, + http_async_client=http_client, + ) + + monkeypatch.setattr( + agent_mod, + "ChatOpenAI", + chat_openai_with_mock_transport, + ) + graph = agent_mod.build_agent() + action = _render_table_action() + config = { + "configurable": {"thread_id": "streaming-responses-render-table"} + } + + first_events = [ + event + async for event in graph.astream_events( + { + "messages": [ + HumanMessage(content="Show open issues as a table.") + ], + "copilotkit": { + "actions": [action], + "context": [], + }, + }, + config=config, + version="v2", + ) + ] + first_snapshot = await graph.aget_state(config) + + second_events = [ + event + async for event in graph.astream_events( + { + "messages": [ + ToolMessage( + content="Rendered the table for the user.", + tool_call_id="call_render_table", + name="render_table", + ) + ], + "copilotkit": { + "actions": [action], + "context": [], + }, + }, + config=config, + version="v2", + ) + ] + final_snapshot = await graph.aget_state(config) + + return ( + first_events, + first_snapshot.values, + second_events, + final_snapshot.values, + ) + + first_events, first_state, second_events, final_state = asyncio.run( + run_round_trip() + ) + + assert len(requests) == 2 + assert all(request["stream"] is True for request in requests) + assert requests[0]["reasoning"] == {"effort": "low"} + assert requests[0]["text"] == {"verbosity": "low"} + render_table_schema = next( + tool + for tool in requests[0]["tools"] + if tool["name"] == "render_table" + ) + column_schema = render_table_schema["parameters"]["properties"]["columns"][ + "items" + ] + assert column_schema["properties"]["header"] == {"type": "string"} + assert ( + column_schema["properties"]["align"]["enum"] + == ["left", "center", "right"] + ) + tool_call_message = first_state["messages"][-1] + assert isinstance(tool_call_message, AIMessage) + assert tool_call_message.tool_calls == [ + { + "name": "render_table", + "args": RENDER_TABLE_ARGUMENTS, + "id": "call_render_table", + "type": "tool_call", + } + ] + assert "on_chat_model_stream" in { + event["event"] for event in first_events + } + assert any( + item["type"] == "function_call" + and item["call_id"] == "call_render_table" + for item in requests[1]["input"] + ) + assert any( + item == { + "type": "function_call_output", + "call_id": "call_render_table", + "output": "Rendered the table for the user.", + } + for item in requests[1]["input"] + ) + assert "on_chat_model_stream" in { + event["event"] for event in second_events + } + final_message = final_state["messages"][-1] + assert isinstance(final_message, AIMessage) + assert final_message.content[0]["text"] == "Rendered the table."