From 2dcfc25b4c9718d459b691c2fb225bfa019c9744 Mon Sep 17 00:00:00 2001 From: Mark Fogle Date: Wed, 8 Jul 2026 21:31:02 +0000 Subject: [PATCH 01/40] ci(showcase): guard against dead-on-load demos (runtime-route wiring check) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OSS-451 shipped because nothing linked a demo page's CopilotKit runtimeUrl to the existence of the /api route it names. The only automatic pre-merge gate for showcase/** is a Docker build, which compiles a page that references a non-existent route just fine (runtimeUrl is an unchecked string) — so the page-404-on-load class was invisible. Add a static validator (validate-runtime-routes.ts) that, for every SHIPPED demo (a demo listed in its integration's manifest `features`), asserts its runtimeUrl resolves to a real route dir under src/app/api. Unshipped / experimental demos (not in `features`) and not_supported_features are skipped, so incomplete placeholders don't fail the gate — but promoting one into `features` immediately starts enforcing it. A baseline file can grandfather pre-existing violations; the fleet is currently clean (0). Wire it into a new pre-merge workflow (showcase_validate-wiring.yml) that runs on every showcase/integrations PR alongside the build check. Add it to branch-protection required checks to make it blocking. Regression test proves it flags the exact OSS-451 shape (shipped demo, missing route) while passing existing/base routes and skipping unshipped. Verified: npm run validate-routes -> clean fleet-wide; removing the 3 OSS-451 routes -> flags exactly those 3; full showcase/scripts vitest suite (2151 tests) green. Refs OSS-451 --- .../workflows/showcase_validate-wiring.yml | 57 ++++ .../fixtures/route-wiring/manifest.yaml | 7 + .../app/api/copilotkit-shipped-ok/route.ts | 1 + .../src/app/api/copilotkit/route.ts | 1 + .../src/app/demos/base-route/page.tsx | 4 + .../src/app/demos/shipped-broken/page.tsx | 4 + .../src/app/demos/shipped-ok/page.tsx | 4 + .../src/app/demos/unshipped-broken/page.tsx | 4 + .../__tests__/validate-runtime-routes.test.ts | 42 +++ showcase/scripts/package.json | 1 + showcase/scripts/validate-runtime-routes.ts | 254 ++++++++++++++++++ 11 files changed, 379 insertions(+) create mode 100644 .github/workflows/showcase_validate-wiring.yml create mode 100644 showcase/scripts/__tests__/fixtures/route-wiring/manifest.yaml create mode 100644 showcase/scripts/__tests__/fixtures/route-wiring/src/app/api/copilotkit-shipped-ok/route.ts create mode 100644 showcase/scripts/__tests__/fixtures/route-wiring/src/app/api/copilotkit/route.ts create mode 100644 showcase/scripts/__tests__/fixtures/route-wiring/src/app/demos/base-route/page.tsx create mode 100644 showcase/scripts/__tests__/fixtures/route-wiring/src/app/demos/shipped-broken/page.tsx create mode 100644 showcase/scripts/__tests__/fixtures/route-wiring/src/app/demos/shipped-ok/page.tsx create mode 100644 showcase/scripts/__tests__/fixtures/route-wiring/src/app/demos/unshipped-broken/page.tsx create mode 100644 showcase/scripts/__tests__/validate-runtime-routes.test.ts create mode 100644 showcase/scripts/validate-runtime-routes.ts diff --git a/.github/workflows/showcase_validate-wiring.yml b/.github/workflows/showcase_validate-wiring.yml new file mode 100644 index 00000000000..8356a2d2027 --- /dev/null +++ b/.github/workflows/showcase_validate-wiring.yml @@ -0,0 +1,57 @@ +name: "Showcase: Runtime-Route Wiring (PR)" + +# Pre-merge guard for the OSS-451 failure class: a demo page whose CopilotKit +# `runtimeUrl` points at an `/api/copilotkit-` route that does not exist, +# so the page 404s on load (runtime_info_fetch_failed) and never mounts. +# +# The existing Showcase Build Check (showcase_build_check.yml) is a Docker +# *build* — it compiles a page that references a non-existent route just fine, +# because `runtimeUrl` is an opaque string with no build-time link to the +# route's existence. That blind spot is exactly how OSS-451 shipped. This +# static check runs alongside the build check and asserts that every SHIPPED +# demo (a demo listed in its integration's manifest `features`) wires its +# `runtimeUrl` to a route that actually exists. +# +# Fast, no Docker, no secrets. Should be added to the branch-protection +# required checks so it blocks merge like the build check does. + +on: + pull_request: + paths: + - "showcase/integrations/**" + - "showcase/scripts/validate-runtime-routes.ts" + - "showcase/scripts/validate-runtime-routes.baseline.json" + - "showcase/scripts/package.json" + - ".github/workflows/showcase_validate-wiring.yml" + +concurrency: + group: showcase-validate-wiring-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + validate-runtime-routes: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + + - name: Install validator deps + working-directory: showcase/scripts + run: npm ci --no-audit --no-fund --ignore-scripts + + - name: Validate runtime-route wiring (OSS-451 guard) + working-directory: showcase/scripts + run: npm run validate-routes diff --git a/showcase/scripts/__tests__/fixtures/route-wiring/manifest.yaml b/showcase/scripts/__tests__/fixtures/route-wiring/manifest.yaml new file mode 100644 index 00000000000..a37c69b9e2c --- /dev/null +++ b/showcase/scripts/__tests__/fixtures/route-wiring/manifest.yaml @@ -0,0 +1,7 @@ +name: Fixture Integration +slug: fixture-int +features: + - shipped-ok + - shipped-broken + - base-route +not_supported_features: [] diff --git a/showcase/scripts/__tests__/fixtures/route-wiring/src/app/api/copilotkit-shipped-ok/route.ts b/showcase/scripts/__tests__/fixtures/route-wiring/src/app/api/copilotkit-shipped-ok/route.ts new file mode 100644 index 00000000000..1802e7acf8c --- /dev/null +++ b/showcase/scripts/__tests__/fixtures/route-wiring/src/app/api/copilotkit-shipped-ok/route.ts @@ -0,0 +1 @@ +export const POST = async () => new Response(null); diff --git a/showcase/scripts/__tests__/fixtures/route-wiring/src/app/api/copilotkit/route.ts b/showcase/scripts/__tests__/fixtures/route-wiring/src/app/api/copilotkit/route.ts new file mode 100644 index 00000000000..1802e7acf8c --- /dev/null +++ b/showcase/scripts/__tests__/fixtures/route-wiring/src/app/api/copilotkit/route.ts @@ -0,0 +1 @@ +export const POST = async () => new Response(null); diff --git a/showcase/scripts/__tests__/fixtures/route-wiring/src/app/demos/base-route/page.tsx b/showcase/scripts/__tests__/fixtures/route-wiring/src/app/demos/base-route/page.tsx new file mode 100644 index 00000000000..1b97464fc34 --- /dev/null +++ b/showcase/scripts/__tests__/fixtures/route-wiring/src/app/demos/base-route/page.tsx @@ -0,0 +1,4 @@ +"use client"; +export default function P() { + return ; +} diff --git a/showcase/scripts/__tests__/fixtures/route-wiring/src/app/demos/shipped-broken/page.tsx b/showcase/scripts/__tests__/fixtures/route-wiring/src/app/demos/shipped-broken/page.tsx new file mode 100644 index 00000000000..0330646698a --- /dev/null +++ b/showcase/scripts/__tests__/fixtures/route-wiring/src/app/demos/shipped-broken/page.tsx @@ -0,0 +1,4 @@ +"use client"; +export default function P() { + return ; +} diff --git a/showcase/scripts/__tests__/fixtures/route-wiring/src/app/demos/shipped-ok/page.tsx b/showcase/scripts/__tests__/fixtures/route-wiring/src/app/demos/shipped-ok/page.tsx new file mode 100644 index 00000000000..7b48da2a649 --- /dev/null +++ b/showcase/scripts/__tests__/fixtures/route-wiring/src/app/demos/shipped-ok/page.tsx @@ -0,0 +1,4 @@ +"use client"; +export default function P() { + return ; +} diff --git a/showcase/scripts/__tests__/fixtures/route-wiring/src/app/demos/unshipped-broken/page.tsx b/showcase/scripts/__tests__/fixtures/route-wiring/src/app/demos/unshipped-broken/page.tsx new file mode 100644 index 00000000000..96a4d24a94f --- /dev/null +++ b/showcase/scripts/__tests__/fixtures/route-wiring/src/app/demos/unshipped-broken/page.tsx @@ -0,0 +1,4 @@ +"use client"; +export default function P() { + return ; +} diff --git a/showcase/scripts/__tests__/validate-runtime-routes.test.ts b/showcase/scripts/__tests__/validate-runtime-routes.test.ts new file mode 100644 index 00000000000..e5909e58863 --- /dev/null +++ b/showcase/scripts/__tests__/validate-runtime-routes.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import path from "path"; +import { validateIntegration } from "../validate-runtime-routes.js"; + +// A fixture integration under fixtures/route-wiring models the OSS-451 shape: +// - shipped-ok (in features) → route exists → OK +// - base-route (in features) → runtimeUrl "/api/copilotkit" exists → OK +// - shipped-broken (in features) → route MISSING → VIOLATION (the OSS-451 bug) +// - unshipped-broken (NOT in features) → route MISSING → skipped (unshipped) +const FIXTURE = path.resolve(__dirname, "fixtures", "route-wiring"); + +describe("runtime-route wiring validator", () => { + const violations = validateIntegration(FIXTURE); + + it("flags exactly the shipped demo whose route is missing (the OSS-451 class)", () => { + expect(violations).toHaveLength(1); + expect(violations[0].demo).toBe("shipped-broken"); + expect(violations[0].runtimeUrl).toBe("/api/copilotkit-shipped-broken"); + expect(violations[0].integration).toBe("route-wiring"); + }); + + it("does NOT flag a shipped demo whose dedicated route exists", () => { + expect(violations.some((v) => v.demo === "shipped-ok")).toBe(false); + }); + + it("does NOT flag a shipped demo pointing at the shared /api/copilotkit route", () => { + expect(violations.some((v) => v.demo === "base-route")).toBe(false); + }); + + it("skips unshipped demos (not in manifest features) even when their route is missing", () => { + // unshipped-broken has a missing route but is intentionally not gated — + // it is not claimed to work. Promoting it into `features` would start + // enforcing it, catching the break at that PR. + expect(violations.some((v) => v.demo === "unshipped-broken")).toBe(false); + }); + + it("emits a stable baseline key for each violation", () => { + expect(violations[0].key).toBe( + "route-wiring:shipped-broken:/api/copilotkit-shipped-broken", + ); + }); +}); diff --git a/showcase/scripts/package.json b/showcase/scripts/package.json index a694d404daa..a978ad47ccb 100644 --- a/showcase/scripts/package.json +++ b/showcase/scripts/package.json @@ -10,6 +10,7 @@ "probe-claude-quickstarts": "tsx probe-claude-quickstarts.ts", "check-claude-quickstarts:runtime": "tsx check-claude-quickstarts-runtime.ts", "validate-manifests": "tsx generate-registry.ts --validate-only", + "validate-routes": "tsx validate-runtime-routes.ts --all", "showcase:audit": "tsx audit.ts", "create-integration": "tsx create-integration/index.ts", "bundle-content": "tsx bundle-demo-content.ts", diff --git a/showcase/scripts/validate-runtime-routes.ts b/showcase/scripts/validate-runtime-routes.ts new file mode 100644 index 00000000000..ed235f487ef --- /dev/null +++ b/showcase/scripts/validate-runtime-routes.ts @@ -0,0 +1,254 @@ +/** + * Runtime-Route Wiring Validator (OSS-451 guard) + * + * Every showcase demo page wires a CopilotKit provider with + * `runtimeUrl="/api/copilotkit[-]"`. If that Next.js route handler does + * not exist, the client's runtime-info fetch 404s, the provider never + * initializes (`runtime_info_fetch_failed`) and the page is dead on load. + * + * That is exactly how OSS-451 shipped: three Mastra demo pages were mirrored + * from the langgraph-python north-star (which has per-demo routes) without + * porting the route handlers. Nothing linked a page's `runtimeUrl` string to + * the existence of the route it names, so the drift was invisible to the only + * automatic pre-merge gate (a Docker *build*, which compiles a page that + * references a non-existent route just fine). + * + * This validator closes that gap. For every SHIPPED demo — a demo directory + * whose slug is listed in the integration's `manifest.yaml` `features` — it + * asserts that every `runtimeUrl` the demo declares resolves to a real route + * directory under `src/app/api/`. Demos not listed in `features` (unshipped / + * experimental placeholders) and `not_supported_features` are skipped: they + * are not claimed to work, so they are not gated — but the moment one is + * promoted into `features`, this guard starts enforcing it. + * + * A `validate-runtime-routes.baseline.json` grandfathers known pre-existing + * violations (same idea as the pin-drift `fail-baseline.json`) so wiring this + * into CI fails only on NEW drift, not on unrelated debt. Stale baseline + * entries (no longer violations) are reported so the baseline can shrink. + * + * Usage: + * npx tsx showcase/scripts/validate-runtime-routes.ts --all + * npx tsx showcase/scripts/validate-runtime-routes.ts [ ...] + * npx tsx showcase/scripts/validate-runtime-routes.ts --all --json + * + * Exit code 0 = clean (no non-baselined violations); 1 = violations found. + */ + +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import yaml from "yaml"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SHOWCASE_ROOT = path.resolve(__dirname, ".."); +const INTEGRATIONS_DIR = path.join(SHOWCASE_ROOT, "integrations"); +const BASELINE_PATH = path.join( + __dirname, + "validate-runtime-routes.baseline.json", +); + +export interface Violation { + /** integration slug, e.g. "mastra" */ + integration: string; + /** demo directory / slug, e.g. "a2ui-fixed-schema" */ + demo: string; + /** the runtimeUrl the page declares, e.g. "/api/copilotkit-a2ui-fixed-schema" */ + runtimeUrl: string; + /** repo-relative route dir that was expected to exist but does not */ + expectedRouteDir: string; + /** stable key used for baselining */ + key: string; +} + +interface Manifest { + slug?: string; + features?: string[]; + not_supported_features?: string[]; +} + +/** All `runtimeUrl="..."` / `runtimeUrl='...'` string literals in a file. */ +function extractRuntimeUrls(source: string): string[] { + const urls = new Set(); + const re = /runtimeUrl\s*=\s*(?:\{\s*)?["'`]([^"'`]+)["'`]/g; + let m: RegExpExecArray | null; + while ((m = re.exec(source)) !== null) { + urls.add(m[1]); + } + return [...urls]; +} + +/** + * Map a runtimeUrl to the route directory that must exist for it to resolve. + * "/api/copilotkit-foo" -> "/src/app/api/copilotkit-foo". + * Returns null for non-local URLs (absolute http(s), env-driven) which this + * static check cannot resolve. + */ +function routeDirForUrl(integrationDir: string, url: string): string | null { + if (!url.startsWith("/api/")) return null; + // Strip query/hash and any trailing slash; keep the first path segment + // after /api/ as the route directory (catch-all handlers live in a nested + // [[...slug]] dir, but the top-level route dir is what we assert exists). + const cleaned = url.split(/[?#]/)[0].replace(/\/+$/, ""); + const seg = cleaned.slice("/api/".length).split("/")[0]; + if (!seg) return null; + return path.join(integrationDir, "src", "app", "api", seg); +} + +function loadManifest(integrationDir: string): Manifest | null { + const p = path.join(integrationDir, "manifest.yaml"); + if (!fs.existsSync(p)) return null; + try { + return (yaml.parse(fs.readFileSync(p, "utf-8")) as Manifest) ?? null; + } catch { + return null; + } +} + +function listDemoDirs(integrationDir: string): string[] { + const demosDir = path.join(integrationDir, "src", "app", "demos"); + if (!fs.existsSync(demosDir)) return []; + return fs + .readdirSync(demosDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name); +} + +/** All .ts/.tsx source under a demo directory (recursively). */ +function demoSourceFiles(demoDir: string): string[] { + const out: string[] = []; + const walk = (dir: string) => { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, e.name); + if (e.isDirectory()) walk(full); + else if (/\.(t|j)sx?$/.test(e.name)) out.push(full); + } + }; + walk(demoDir); + return out; +} + +/** Validate a single integration; returns its violations. */ +export function validateIntegration(integrationDir: string): Violation[] { + const slug = path.basename(integrationDir); + const manifest = loadManifest(integrationDir); + if (!manifest) return []; + const features = new Set(manifest.features ?? []); + const notSupported = new Set(manifest.not_supported_features ?? []); + const violations: Violation[] = []; + + const demosDir = path.join(integrationDir, "src", "app", "demos"); + for (const demo of listDemoDirs(integrationDir)) { + // Only gate SHIPPED demos: slug present in `features` and not explicitly + // marked unsupported. Unshipped placeholders are intentionally skipped. + if (!features.has(demo) || notSupported.has(demo)) continue; + + const demoDir = path.join(demosDir, demo); + const urls = new Set(); + for (const f of demoSourceFiles(demoDir)) { + for (const u of extractRuntimeUrls(fs.readFileSync(f, "utf-8"))) { + urls.add(u); + } + } + + for (const url of urls) { + const routeDir = routeDirForUrl(integrationDir, url); + if (routeDir === null) continue; // non-local (absolute/env) — not gated + if (!fs.existsSync(routeDir)) { + const expectedRouteDir = path.relative( + path.resolve(SHOWCASE_ROOT, ".."), + routeDir, + ); + violations.push({ + integration: slug, + demo, + runtimeUrl: url, + expectedRouteDir, + key: `${slug}:${demo}:${url}`, + }); + } + } + } + return violations; +} + +function loadBaseline(): Set { + if (!fs.existsSync(BASELINE_PATH)) return new Set(); + try { + const parsed = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf-8")); + const keys: string[] = Array.isArray(parsed) ? parsed : (parsed.keys ?? []); + return new Set(keys); + } catch { + return new Set(); + } +} + +function resolveTargets(args: string[]): string[] { + const slugs = args.filter((a) => !a.startsWith("--")); + if (args.includes("--all") || slugs.length === 0) { + return fs + .readdirSync(INTEGRATIONS_DIR, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => path.join(INTEGRATIONS_DIR, e.name)); + } + return slugs.map((s) => path.join(INTEGRATIONS_DIR, s)); +} + +function main() { + const args = process.argv.slice(2); + const asJson = args.includes("--json"); + const targets = resolveTargets(args); + + const all: Violation[] = []; + for (const dir of targets) { + if (!fs.existsSync(dir)) { + console.error(`✖ unknown integration: ${path.basename(dir)}`); + process.exit(2); + } + all.push(...validateIntegration(dir)); + } + + const baseline = loadBaseline(); + const fresh = all.filter((v) => !baseline.has(v.key)); + const baselinedHit = new Set( + all.filter((v) => baseline.has(v.key)).map((v) => v.key), + ); + const staleBaseline = [...baseline].filter((k) => !baselinedHit.has(k)); + + if (asJson) { + console.log(JSON.stringify({ violations: fresh, staleBaseline }, null, 2)); + } else { + if (fresh.length === 0) { + console.log( + `✔ runtime-route wiring OK — every shipped demo's runtimeUrl resolves to a route (${all.length} baselined).`, + ); + } else { + console.error( + `✖ ${fresh.length} shipped demo(s) point runtimeUrl at a route that does not exist:\n`, + ); + for (const v of fresh) { + console.error( + ` • ${v.integration}/${v.demo}: runtimeUrl "${v.runtimeUrl}" → missing ${v.expectedRouteDir}`, + ); + } + console.error( + `\nThis is the OSS-451 failure class: the page will 404 on load (runtime_info_fetch_failed).\n` + + `Fix by adding the route handler, or repointing the page at an existing route.\n` + + `If a violation is known/intentional, add its key to validate-runtime-routes.baseline.json.`, + ); + } + if (staleBaseline.length > 0) { + console.warn( + `\nℹ ${staleBaseline.length} stale baseline entr(y/ies) no longer violate — remove them:\n` + + staleBaseline.map((k) => ` • ${k}`).join("\n"), + ); + } + } + + process.exit(fresh.length > 0 ? 1 : 0); +} + +// Only run as CLI when invoked directly (not when imported by tests). +const invokedDirectly = + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invokedDirectly) main(); From 57b6b17dd49b95fa1d202e3bc77e4d141dffe02e Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Thu, 9 Jul 2026 16:52:19 -0700 Subject: [PATCH 02/40] feat(web-inspector): add threads example telemetry --- packages/web-inspector/src/lib/telemetry.ts | 61 +++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/web-inspector/src/lib/telemetry.ts b/packages/web-inspector/src/lib/telemetry.ts index a852499bdff..8c122c08f66 100644 --- a/packages/web-inspector/src/lib/telemetry.ts +++ b/packages/web-inspector/src/lib/telemetry.ts @@ -38,6 +38,14 @@ export const TELEMETRY_EVENTS = { talkToEngineerClicked: "oss.inspector.talk_to_engineer_clicked", threadsEmptyEnabledViewed: "oss.inspector.threads_empty_enabled_viewed", threadsEnabledViewed: "oss.inspector.threads_enabled_viewed", + threadsExampleViewed: "oss.inspector.threads_example_viewed", + threadsExampleSelected: "oss.inspector.threads_example_selected", + threadsExampleTourStarted: "oss.inspector.threads_example_tour_started", + threadsExampleTourStepViewed: + "oss.inspector.threads_example_tour_step_viewed", + threadsExampleTourDismissed: "oss.inspector.threads_example_tour_dismissed", + threadsExampleTourCompleted: "oss.inspector.threads_example_tour_completed", + threadsExampleTourReopened: "oss.inspector.threads_example_tour_reopened", memoriesTabClicked: "oss.inspector.memories_tab_clicked", } as const; @@ -70,6 +78,13 @@ function isThreadsTelemetryEvent(event: TelemetryEvent): boolean { event === TELEMETRY_EVENTS.talkToEngineerClicked || event === TELEMETRY_EVENTS.threadsEmptyEnabledViewed || event === TELEMETRY_EVENTS.threadsEnabledViewed || + event === TELEMETRY_EVENTS.threadsExampleViewed || + event === TELEMETRY_EVENTS.threadsExampleSelected || + event === TELEMETRY_EVENTS.threadsExampleTourStarted || + event === TELEMETRY_EVENTS.threadsExampleTourStepViewed || + event === TELEMETRY_EVENTS.threadsExampleTourDismissed || + event === TELEMETRY_EVENTS.threadsExampleTourCompleted || + event === TELEMETRY_EVENTS.threadsExampleTourReopened || event === TELEMETRY_EVENTS.memoriesTabClicked ); } @@ -203,6 +218,10 @@ export type InspectorThreadTelemetryProps = { cta?: "signup" | "talk_to_engineer"; telemetry_disabled?: boolean; thread_count?: number; + example_thread_id?: string; + tour_step?: number; + tour_tab?: "timeline" | "raw-events" | "state"; + dismiss_method?: "skip" | "done"; }; export function trackThreadsTabClicked( @@ -247,6 +266,48 @@ export function trackThreadsEnabledViewed( track(TELEMETRY_EVENTS.threadsEnabledViewed, props); } +export function trackThreadsExampleViewed( + props: InspectorThreadTelemetryProps, +): void { + track(TELEMETRY_EVENTS.threadsExampleViewed, props); +} + +export function trackThreadsExampleSelected( + props: InspectorThreadTelemetryProps, +): void { + track(TELEMETRY_EVENTS.threadsExampleSelected, props); +} + +export function trackThreadsExampleTourStarted( + props: InspectorThreadTelemetryProps, +): void { + track(TELEMETRY_EVENTS.threadsExampleTourStarted, props); +} + +export function trackThreadsExampleTourStepViewed( + props: InspectorThreadTelemetryProps, +): void { + track(TELEMETRY_EVENTS.threadsExampleTourStepViewed, props); +} + +export function trackThreadsExampleTourDismissed( + props: InspectorThreadTelemetryProps, +): void { + track(TELEMETRY_EVENTS.threadsExampleTourDismissed, props); +} + +export function trackThreadsExampleTourCompleted( + props: InspectorThreadTelemetryProps, +): void { + track(TELEMETRY_EVENTS.threadsExampleTourCompleted, props); +} + +export function trackThreadsExampleTourReopened( + props: InspectorThreadTelemetryProps, +): void { + track(TELEMETRY_EVENTS.threadsExampleTourReopened, props); +} + export type InspectorMemoryTelemetryProps = { package_name?: typeof PACKAGE_NAME; package_version?: string; From 962132e1704d0171888702a5ea9712054085eacf Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Thu, 9 Jul 2026 16:52:24 -0700 Subject: [PATCH 03/40] feat(web-inspector): add threads empty-state onboarding --- .../src/__tests__/web-inspector.spec.ts | 301 ++++++- packages/web-inspector/src/index.ts | 767 +++++++++++++++++- 2 files changed, 1025 insertions(+), 43 deletions(-) diff --git a/packages/web-inspector/src/__tests__/web-inspector.spec.ts b/packages/web-inspector/src/__tests__/web-inspector.spec.ts index 4dea5c36e67..4fd29819cbf 100644 --- a/packages/web-inspector/src/__tests__/web-inspector.spec.ts +++ b/packages/web-inspector/src/__tests__/web-inspector.spec.ts @@ -32,6 +32,15 @@ type InspectorThreadViewInternals = { agentId: string; updatedAt?: string | null; }>; + _threadsByAgent: Map< + string, + Array<{ + id: string; + name?: string | null; + agentId: string; + updatedAt?: string | null; + }> + >; }; type InspectorContextInternals = { @@ -1530,6 +1539,9 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { properties: Record; }; }); + const threadListText = (inspector: WebInspectorElement) => + inspector.shadowRoot?.querySelector("cpk-thread-list")?.shadowRoot + ?.textContent ?? ""; beforeEach(() => { document.body.innerHTML = ""; @@ -1698,6 +1710,10 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { "Talk to an Engineer", "Sign up for Intelligence", ]); + const engineer = inspector.shadowRoot?.querySelector( + 'a[href^="https://www.copilotkit.ai/talk-to-an-engineer"]', + ); + expect(engineer?.closest("#cpk-main-scroll")).toBeNull(); expect(text).not.toContain("No threads yet"); expect( fetchMock.mock.calls.some((call) => String(call[0]).includes("/threads")), @@ -1795,7 +1811,7 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { } }); - it("keeps the enabled empty Threads state when thread listing is available", async () => { + it("renders example threads and the deselected overview when enabled thread history is empty", async () => { const { agent } = createMockAgent("alpha"); const harness = createHeaderMockCore({ alpha: agent }, {}, {}, true); @@ -1812,13 +1828,294 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { internals.handleMenuSelect("threads"); await inspector.updateComplete; - expect(inspector.shadowRoot?.textContent ?? "").toContain("No threads yet"); + await vi.waitFor(() => { + const text = threadListText(inspector); + expect(text).toContain("Realtime thread sync"); + expect(text).toContain("Manage saved conversations"); + expect(text).toContain("Inspect durable run history"); + }); + + const text = inspector.shadowRoot?.textContent ?? ""; + expect(text).toContain("Threads are persistent, inspectable conversations"); + expect(text).toContain( + "Take a tour with the example threads in the sidebar.", + ); + const threadsDocs = inspector.shadowRoot?.querySelector( + 'a[href="https://docs.copilotkit.ai/threads"]', + ); + expect(threadsDocs?.textContent?.trim()).toBe("Learn how Threads work"); + const selfHosted = + inspector.shadowRoot?.querySelector( + 'a[href="https://docs.copilotkit.ai/premium/self-hosting"]', + ); + expect(selfHosted?.textContent?.trim()).toBe( + "Explore self-hosted Intelligence", + ); + expect(threadListText(inspector)).toContain("Example"); + expect(text).not.toContain("No threads yet"); + expect( + inspector.shadowRoot?.querySelector("cpk-thread-details"), + ).toBeNull(); + expect( + (inspector as unknown as InspectorThreadViewInternals).selectedThreadId, + ).toBeNull(); + const engineer = inspector.shadowRoot?.querySelector( 'a[href^="https://www.copilotkit.ai/talk-to-an-engineer"]', ); expect(engineer?.href).toBe( "https://www.copilotkit.ai/talk-to-an-engineer?ref=cpk-inspector-threads", ); + expect(engineer?.closest("#cpk-main-scroll")).toBeNull(); + }); + + it("does not render example threads once real threads are present", async () => { + const inspector = new WebInspectorElement(); + document.body.appendChild(inspector); + inspector.core = createHeaderMockCore({}, {}, {}, true) + .core as unknown as WebInspectorElement["core"]; + + const internals = inspector as unknown as InspectorThreadViewInternals; + internals.isOpen = true; + internals.selectedMenu = "threads"; + internals._threads = [ + { + id: "real-thread", + name: "Real customer thread", + agentId: "alpha", + updatedAt: "2026-06-25T10:00:00.000Z", + }, + ]; + internals._threadsByAgent = new Map([["alpha", internals._threads]]); + inspector.requestUpdate(); + await inspector.updateComplete; + + const text = threadListText(inspector); + expect(text).toContain("Real customer thread"); + expect(text).not.toContain("Realtime thread sync"); + expect(text).not.toContain("Example"); + }); + + it("selects an example thread, shows the tour, and toggles back to the overview on second click", async () => { + const { agent } = createMockAgent("alpha"); + const harness = createHeaderMockCore({ alpha: agent }, {}, {}, true); + + const inspector = new WebInspectorElement(); + document.body.appendChild(inspector); + inspector.core = harness.core as unknown as WebInspectorElement["core"]; + harness.emitAgentsChanged(); + + const internals = inspector as unknown as { + isOpen: boolean; + handleMenuSelect: (key: "threads") => void; + selectedThreadId: string | null; + }; + internals.isOpen = true; + internals.handleMenuSelect("threads"); + await inspector.updateComplete; + + await vi.waitFor(() => { + expect(threadListText(inspector)).toContain("Realtime thread sync"); + }); + + const threadList = inspector.shadowRoot?.querySelector("cpk-thread-list"); + const firstRow = + threadList?.shadowRoot?.querySelector(".cpk-tl__item"); + expect(firstRow).toBeDefined(); + + firstRow!.click(); + await inspector.updateComplete; + await vi.waitFor(() => { + expect(internals.selectedThreadId).toBe("example-realtime-sync"); + expect(inspector.shadowRoot?.querySelector("cpk-thread-details")).not.toBe( + null, + ); + expect(inspector.shadowRoot?.textContent ?? "").toContain( + "Read the run as a story", + ); + }); + + firstRow!.click(); + await inspector.updateComplete; + + await vi.waitFor(() => { + expect(internals.selectedThreadId).toBeNull(); + expect(inspector.shadowRoot?.querySelector("cpk-thread-details")).toBeNull(); + expect(inspector.shadowRoot?.textContent ?? "").toContain( + "Threads are persistent, inspectable conversations", + ); + }); + }); + + it("persists example tour dismissal so it does not auto-open again", async () => { + const stored = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => stored.get(key) ?? null, + setItem: (key: string, value: string) => stored.set(key, value), + removeItem: (key: string) => stored.delete(key), + clear: () => stored.clear(), + get length() { + return stored.size; + }, + key: (index: number) => Array.from(stored.keys())[index] ?? null, + }); + + const { agent } = createMockAgent("alpha"); + const harness = createHeaderMockCore({ alpha: agent }, {}, {}, false); + + const inspector = new WebInspectorElement(); + document.body.appendChild(inspector); + inspector.core = harness.core as unknown as WebInspectorElement["core"]; + harness.emitAgentsChanged(); + + const internals = inspector as unknown as { + isOpen: boolean; + handleMenuSelect: (key: "threads") => void; + }; + internals.isOpen = true; + internals.handleMenuSelect("threads"); + await inspector.updateComplete; + + await vi.waitFor(() => { + expect(threadListText(inspector)).toContain("Realtime thread sync"); + }); + + const firstRow = inspector.shadowRoot + ?.querySelector("cpk-thread-list") + ?.shadowRoot?.querySelector(".cpk-tl__item"); + firstRow!.click(); + await inspector.updateComplete; + + await vi.waitFor(() => { + expect(inspector.shadowRoot?.textContent ?? "").toContain( + "Read the run as a story", + ); + }); + + const skip = Array.from( + inspector.shadowRoot?.querySelectorAll("button") ?? [], + ).find((button) => button.textContent?.trim() === "Skip"); + expect(skip).toBeDefined(); + skip!.click(); + await inspector.updateComplete; + + expect( + stored.get("cpk:inspector:threads-example-tour:v1"), + ).toContain('"dismissed":true'); + + const secondInspector = new WebInspectorElement(); + document.body.appendChild(secondInspector); + secondInspector.core = harness.core as unknown as WebInspectorElement["core"]; + harness.emitAgentsChanged(); + const secondInternals = secondInspector as unknown as { + isOpen: boolean; + handleMenuSelect: (key: "threads") => void; + }; + secondInternals.isOpen = true; + secondInternals.handleMenuSelect("threads"); + await secondInspector.updateComplete; + + await vi.waitFor(() => { + expect(threadListText(secondInspector)).toContain( + "Realtime thread sync", + ); + }); + + const secondRow = secondInspector.shadowRoot + ?.querySelector("cpk-thread-list") + ?.shadowRoot?.querySelector(".cpk-tl__item"); + secondRow!.click(); + await secondInspector.updateComplete; + + await vi.waitFor(() => { + expect(secondInspector.shadowRoot?.querySelector("cpk-thread-details")).not + .toBeNull(); + }); + expect(secondInspector.shadowRoot?.textContent ?? "").not.toContain( + "Read the run as a story", + ); + expect(secondInspector.shadowRoot?.textContent ?? "").toContain("Show tour"); + + const showTour = Array.from( + secondInspector.shadowRoot?.querySelectorAll( + "button", + ) ?? [], + ).find((button) => button.textContent?.trim() === "Show tour"); + expect(showTour).toBeDefined(); + showTour!.click(); + await secondInspector.updateComplete; + + await vi.waitFor(() => { + expect(secondInspector.shadowRoot?.textContent ?? "").toContain( + "Read the run as a story", + ); + }); + }); + + it("tracks example thread selection and tour dismissal telemetry", async () => { + const { agent } = createMockAgent("alpha"); + const harness = createHeaderMockCore({ alpha: agent }, {}, {}, false); + + const inspector = new WebInspectorElement(); + document.body.appendChild(inspector); + inspector.core = harness.core as unknown as WebInspectorElement["core"]; + harness.emitAgentsChanged(); + + const internals = inspector as unknown as { + isOpen: boolean; + handleMenuSelect: (key: "threads") => void; + }; + internals.isOpen = true; + internals.handleMenuSelect("threads"); + await inspector.updateComplete; + + await vi.waitFor(() => { + expect(threadListText(inspector)).toContain("Realtime thread sync"); + }); + + const firstRow = inspector.shadowRoot + ?.querySelector("cpk-thread-list") + ?.shadowRoot?.querySelector(".cpk-tl__item"); + firstRow!.click(); + await inspector.updateComplete; + + await vi.waitFor(() => { + expect(inspector.shadowRoot?.textContent ?? "").toContain( + "Read the run as a story", + ); + }); + + const skip = Array.from( + inspector.shadowRoot?.querySelectorAll("button") ?? [], + ).find((button) => button.textContent?.trim() === "Skip"); + skip!.click(); + await inspector.updateComplete; + + const posts = telemetryPosts(); + expect( + posts.some( + (post) => post.event === "oss.inspector.threads_example_selected", + ), + ).toBe(true); + expect( + posts.some( + (post) => post.event === "oss.inspector.threads_example_tour_started", + ), + ).toBe(true); + const stepViewed = posts.find( + (post) => post.event === "oss.inspector.threads_example_tour_step_viewed", + ); + expect(stepViewed?.properties).toMatchObject({ + example_thread_id: "example-realtime-sync", + tour_step: 1, + }); + const dismissed = posts.find( + (post) => post.event === "oss.inspector.threads_example_tour_dismissed", + ); + expect(dismissed?.properties).toMatchObject({ + example_thread_id: "example-realtime-sync", + dismiss_method: "skip", + }); }); }); diff --git a/packages/web-inspector/src/index.ts b/packages/web-inspector/src/index.ts index 0c391d0aa99..7d290d87f50 100644 --- a/packages/web-inspector/src/index.ts +++ b/packages/web-inspector/src/index.ts @@ -65,6 +65,13 @@ import { trackTalkToEngineerClicked, trackThreadsEmptyEnabledViewed, trackThreadsEnabledViewed, + trackThreadsExampleSelected, + trackThreadsExampleTourCompleted, + trackThreadsExampleTourDismissed, + trackThreadsExampleTourReopened, + trackThreadsExampleTourStarted, + trackThreadsExampleTourStepViewed, + trackThreadsExampleViewed, trackThreadsIntelligenceSignupClicked, trackThreadsLockedViewed, trackMemoriesTabClicked, @@ -113,6 +120,12 @@ const MAX_AGENT_EVENTS = 200; const MAX_TOTAL_EVENTS = 500; const INTELLIGENCE_SIGNUP_URL = "https://go.copilotkit.ai/intelligence-signup"; const TALK_TO_ENGINEER_URL = "https://www.copilotkit.ai/talk-to-an-engineer"; +const THREADS_DOCS_URL = "https://docs.copilotkit.ai/threads"; +const SELF_HOSTED_INTELLIGENCE_URL = + "https://docs.copilotkit.ai/premium/self-hosting"; +const THREADS_EXAMPLE_TOUR_STORAGE_KEY = + "cpk:inspector:threads-example-tour:v1"; +const THREADS_EXAMPLE_AGENT_ID = "threads-feature"; type ThreadServiceStatus = "available" | "unavailable" | "unknown" | "error"; @@ -376,6 +389,235 @@ type RuntimeStateFetchResult = | { status: "available"; state: Record | null } | { status: "not-available" }; +type ExampleThread = ɵThread & { isExample: true }; + +type ExampleThreadDetails = { + messages: ThreadDebuggerMessage[]; + events: ThreadDebuggerEvent[]; + state: Record; +}; + +const THREADS_EXAMPLE_THREADS: ExampleThread[] = [ + { + id: "example-realtime-sync", + name: "Realtime thread sync", + agentId: THREADS_EXAMPLE_AGENT_ID, + organizationId: "example-organization", + createdById: "example-user", + archived: false, + createdAt: "2026-07-08T16:00:00.000Z", + updatedAt: "2026-07-08T16:30:00.000Z", + isExample: true, + }, + { + id: "example-manage-history", + name: "Manage saved conversations", + agentId: THREADS_EXAMPLE_AGENT_ID, + organizationId: "example-organization", + createdById: "example-user", + archived: false, + createdAt: "2026-07-07T17:45:00.000Z", + updatedAt: "2026-07-07T18:15:00.000Z", + isExample: true, + }, + { + id: "example-inspect-runs", + name: "Inspect durable run history", + agentId: THREADS_EXAMPLE_AGENT_ID, + organizationId: "example-organization", + createdById: "example-user", + archived: false, + createdAt: "2026-07-06T20:15:00.000Z", + updatedAt: "2026-07-06T20:45:00.000Z", + isExample: true, + }, +]; + +const THREADS_EXAMPLE_DETAILS: Record = { + "example-realtime-sync": { + messages: [ + { + id: "example-sync-user", + role: "user", + content: "Resume the checkout support thread from yesterday.", + }, + { + id: "example-sync-assistant", + role: "assistant", + content: + "I found the saved thread, restored the cart state, and continued from the latest user message.", + }, + ], + events: [ + { + type: "RUN_STARTED", + timestamp: "2026-07-08T16:30:00.000Z", + payload: { + threadId: "example-realtime-sync", + agentId: THREADS_EXAMPLE_AGENT_ID, + }, + }, + { + type: "MESSAGES_SNAPSHOT", + timestamp: "2026-07-08T16:30:01.000Z", + payload: { + messageCount: 6, + source: "thread-history", + }, + }, + { + type: "STATE_SNAPSHOT", + timestamp: "2026-07-08T16:30:02.000Z", + payload: { + cartId: "cart_demo_42", + checkoutStep: "shipping", + resumed: true, + }, + }, + { + type: "RUN_FINISHED", + timestamp: "2026-07-08T16:30:04.000Z", + payload: { + status: "completed", + }, + }, + ], + state: { + cartId: "cart_demo_42", + checkoutStep: "shipping", + userIntent: "resume_previous_checkout", + persistedThread: true, + }, + }, + "example-manage-history": { + messages: [ + { + id: "example-history-user", + role: "user", + content: "Rename this saved support conversation for the handoff.", + }, + { + id: "example-history-assistant", + role: "assistant", + content: + "Renamed the thread and kept the prior messages available for the next session.", + }, + ], + events: [ + { + type: "RUN_STARTED", + timestamp: "2026-07-07T18:15:00.000Z", + payload: { + threadId: "example-manage-history", + agentId: THREADS_EXAMPLE_AGENT_ID, + }, + }, + { + type: "CUSTOM_EVENT", + timestamp: "2026-07-07T18:15:01.000Z", + payload: { + action: "thread_renamed", + previousName: "Untitled", + name: "Billing escalation handoff", + }, + }, + { + type: "RUN_FINISHED", + timestamp: "2026-07-07T18:15:03.000Z", + payload: { + status: "completed", + }, + }, + ], + state: { + name: "Billing escalation handoff", + savedMessages: 14, + lastHandoff: "support-team", + }, + }, + "example-inspect-runs": { + messages: [ + { + id: "example-inspect-user", + role: "user", + content: "Why did the assistant recommend the enterprise plan?", + }, + { + id: "example-inspect-assistant", + role: "assistant", + content: + "The recommendation came from the account size, SSO requirement, and audit-log constraint in state.", + }, + ], + events: [ + { + type: "RUN_STARTED", + timestamp: "2026-07-06T20:45:00.000Z", + payload: { + threadId: "example-inspect-runs", + agentId: THREADS_EXAMPLE_AGENT_ID, + }, + }, + { + type: "TOOL_CALL_START", + timestamp: "2026-07-06T20:45:01.000Z", + payload: { + toolCallId: "call_account_lookup", + toolName: "lookupAccount", + }, + }, + { + type: "TOOL_CALL_RESULT", + timestamp: "2026-07-06T20:45:02.000Z", + payload: { + toolCallId: "call_account_lookup", + seats: 220, + requiresSso: true, + }, + }, + { + type: "RUN_FINISHED", + timestamp: "2026-07-06T20:45:04.000Z", + payload: { + status: "completed", + }, + }, + ], + state: { + accountTier: "growth", + seats: 220, + requiresSso: true, + auditLogsRequired: true, + }, + }, +}; + +const THREADS_EXAMPLE_TOUR_STEPS: ReadonlyArray<{ + tab: ThreadDetailsTab; + label: string; + title: string; + body: string; +}> = [ + { + tab: "timeline", + label: "Timeline", + title: "Read the run as a story", + body: "The timeline turns messages, tool calls, state changes, and run markers into a scannable debugging trail.", + }, + { + tab: "raw-events", + label: "Raw AG-UI Events", + title: "Drop into the protocol payloads", + body: "Raw events show the exact AG-UI stream behind the timeline when you need to verify ordering or payload shape.", + }, + { + tab: "state", + label: "State", + title: "Check the durable state", + body: "The state tab shows the saved values that make a thread resumable across sessions.", + }, +]; + // ─── JSON syntax highlighter ───────────────────────────────────────────────── // Inline-styled so shadow DOM encapsulation preserves colors when the output // is injected via unsafeHTML. Only for structured data — never raw user HTML. @@ -602,6 +844,11 @@ class CpkThreadList extends LitElement { color: #57575b; } + .cpk-tl__pill--example { + background: rgba(133, 236, 206, 0.22); + color: #189370; + } + /* ── Empty state ── */ .cpk-tl__empty { padding: 32px 16px; @@ -697,6 +944,13 @@ class CpkThreadList extends LitElement {
${thread.agentId} + ${ + (thread as Partial).isExample + ? html`Example` + : nothing + }
`, @@ -973,6 +1227,10 @@ export class CpkThreadInspector extends LitElement { this.maybeFetchTabData(id); } + selectTab(id: ThreadDetailsTab): void { + this.activateTab(id); + } + private maybeFetchTabData(id: ThreadDetailsTab): void { // Lazy-trigger the events / state fetches so their (potentially huge) // JSON.parse only blocks the main thread after the user has shown @@ -3937,6 +4195,15 @@ export class WebInspectorElement extends LitElement { // don't inflate funnel counts beyond one signal per intent type per banner. private clickedBannerIds: Set = new Set(); private viewedThreadsTelemetryStates: Set = new Set(); + private viewedExampleThreadIds: Set = new Set(); + private selectedExampleThreadIds: Set = new Set(); + private viewedExampleTourSteps: Set = new Set(); + private exampleThreadProviders: Map = + new Map(); + private exampleTourDismissed = false; + private exampleTourActive = false; + private exampleTourStep = 0; + private exampleTourAutoShown = false; get core(): CopilotKitCore | null { return this._core; @@ -4114,6 +4381,7 @@ export class WebInspectorElement extends LitElement { if (this._threads.length === 0) return; const stillValid = this.selectedThreadId != null && + !this.isExampleThreadId(this.selectedThreadId) && this._threads.some((t) => t.id === this.selectedThreadId); if (!stillValid) { // Threads are sorted most-recently-updated first @@ -5885,6 +6153,7 @@ ${argsString} `; })} + ${ + this.selectedMenu === "threads" + ? html` + + Talk to an Engineer + + ` + : nothing + }
@@ -7566,6 +7850,421 @@ ${argsString} thread.id === threadId); + } + + private getExampleThreadProvider(threadId: string): ThreadDebuggerProvider { + const cached = this.exampleThreadProviders.get(threadId); + if (cached) return cached; + const thread = THREADS_EXAMPLE_THREADS.find((item) => item.id === threadId); + const details = THREADS_EXAMPLE_DETAILS[threadId]; + const provider: ThreadDebuggerProvider = { + getThreadMetadata: async () => + thread + ? { + id: thread.id, + name: thread.name, + agentId: thread.agentId, + endUserId: "example-user", + status: "completed", + updatedAt: thread.updatedAt, + } + : null, + getMessages: async () => details?.messages ?? [], + getEvents: async () => details?.events ?? [], + getState: async () => details?.state ?? null, + }; + this.exampleThreadProviders.set(threadId, provider); + return provider; + } + + private trackThreadsExampleViewedOnce(): void { + if (this.core?.telemetryDisabled) return; + for (const thread of THREADS_EXAMPLE_THREADS) { + if (this.viewedExampleThreadIds.has(thread.id)) continue; + this.viewedExampleThreadIds.add(thread.id); + trackThreadsExampleViewed( + this.getThreadsTelemetryProps({ + example_thread_id: thread.id, + thread_count: 0, + }), + ); + } + } + + private trackThreadsExampleSelectedOnce(threadId: string): void { + if (this.core?.telemetryDisabled) return; + if (this.selectedExampleThreadIds.has(threadId)) return; + this.selectedExampleThreadIds.add(threadId); + trackThreadsExampleSelected( + this.getThreadsTelemetryProps({ + example_thread_id: threadId, + thread_count: 0, + }), + ); + } + + private getCurrentExampleTourProps(): InspectorThreadTelemetryProps { + const step = + THREADS_EXAMPLE_TOUR_STEPS[this.exampleTourStep] ?? + THREADS_EXAMPLE_TOUR_STEPS[0]!; + return this.getThreadsTelemetryProps({ + example_thread_id: this.selectedThreadId ?? undefined, + thread_count: 0, + tour_step: this.exampleTourStep + 1, + tour_tab: step?.tab, + }); + } + + private trackThreadsExampleTourStepViewedOnce(): void { + if (this.core?.telemetryDisabled || !this.selectedThreadId) return; + const step = + THREADS_EXAMPLE_TOUR_STEPS[this.exampleTourStep] ?? + THREADS_EXAMPLE_TOUR_STEPS[0]!; + if (!step) return; + const key = `${this.selectedThreadId}:${this.exampleTourStep}`; + if (this.viewedExampleTourSteps.has(key)) return; + this.viewedExampleTourSteps.add(key); + trackThreadsExampleTourStepViewed(this.getCurrentExampleTourProps()); + } + + private syncExampleTourTab(): void { + const step = + THREADS_EXAMPLE_TOUR_STEPS[this.exampleTourStep] ?? + THREADS_EXAMPLE_TOUR_STEPS[0]!; + if (!step) return; + void this.updateComplete.then(() => { + const details = this.shadowRoot?.querySelector("cpk-thread-details") as + | (ɵCpkThreadDetails & { selectTab?: (id: ThreadDetailsTab) => void }) + | null; + details?.selectTab?.(step.tab); + }); + } + + private startExampleTour(autoStarted: boolean): void { + if (!this.selectedThreadId) return; + this.exampleTourActive = true; + this.exampleTourStep = 0; + if (autoStarted) { + this.exampleTourAutoShown = true; + if (!this.core?.telemetryDisabled) { + trackThreadsExampleTourStarted(this.getCurrentExampleTourProps()); + } + } else if (!this.core?.telemetryDisabled) { + trackThreadsExampleTourReopened(this.getCurrentExampleTourProps()); + } + this.trackThreadsExampleTourStepViewedOnce(); + this.syncExampleTourTab(); + this.requestUpdate(); + } + + private setExampleTourStep(nextStep: number): void { + this.exampleTourStep = Math.max( + 0, + Math.min(THREADS_EXAMPLE_TOUR_STEPS.length - 1, nextStep), + ); + this.trackThreadsExampleTourStepViewedOnce(); + this.syncExampleTourTab(); + this.requestUpdate(); + } + + private dismissExampleTour(method: "skip" | "done"): void { + if (!this.selectedThreadId) return; + const props = this.getThreadsTelemetryProps({ + ...this.getCurrentExampleTourProps(), + dismiss_method: method, + }); + this.exampleTourActive = false; + this.exampleTourDismissed = true; + this.writeThreadsExampleTourDismissed(); + if (!this.core?.telemetryDisabled) { + if (method === "done") { + trackThreadsExampleTourCompleted(props); + } else { + trackThreadsExampleTourDismissed(props); + } + } + this.requestUpdate(); + } + + private handleThreadsThreadSelected( + threadId: string, + showingExamples: boolean, + ): void { + if (showingExamples && this.selectedThreadId === threadId) { + this.selectedThreadId = null; + this.exampleTourActive = false; + this.requestUpdate(); + return; + } + + this.selectedThreadId = threadId; + if (showingExamples && this.isExampleThreadId(threadId)) { + this.trackThreadsExampleSelectedOnce(threadId); + if (!this.exampleTourDismissed && !this.exampleTourAutoShown) { + this.startExampleTour(true); + } else { + this.exampleTourActive = false; + } + } else { + this.exampleTourActive = false; + } + this.requestUpdate(); + } + + private readThreadsExampleTourDismissed(): boolean { + if (typeof window === "undefined") return false; + try { + const raw = window.localStorage.getItem(THREADS_EXAMPLE_TOUR_STORAGE_KEY); + if (!raw) return false; + const value = JSON.parse(raw) as { dismissed?: unknown }; + return value.dismissed === true; + } catch { + return false; + } + } + + private writeThreadsExampleTourDismissed(): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + THREADS_EXAMPLE_TOUR_STORAGE_KEY, + JSON.stringify({ dismissed: true }), + ); + } catch { + // Persistence is best-effort; the inspector should keep working without it. + } + } + + private renderThreadsExampleOverview() { + return html` +
+
+

+ Threads are persistent, inspectable conversations +

+

+ Take a tour with the example threads in the sidebar. Then, start + chatting in your app to create the first real thread. +

+ +
+
+ `; + } + + private renderThreadsExampleTour() { + if (!this.selectedThreadId || !this.isExampleThreadId(this.selectedThreadId)) { + return nothing; + } + + if (!this.exampleTourActive) { + return html` + + `; + } + + const step = + THREADS_EXAMPLE_TOUR_STEPS[this.exampleTourStep] ?? + THREADS_EXAMPLE_TOUR_STEPS[0]!; + const isFirst = this.exampleTourStep === 0; + const isLast = this.exampleTourStep === THREADS_EXAMPLE_TOUR_STEPS.length - 1; + + return html` +
+
+ ${this.exampleTourStep + 1}/${THREADS_EXAMPLE_TOUR_STEPS.length} + ${step.label} +
+
+ ${step.title} +
+
+ ${step.body} +
+
+ +
+ + +
+
+
+ `; + } + private renderThreadsLockedBackgroundMockup() { const threadRows = [ { width: 74, accent: true }, @@ -7812,28 +8511,6 @@ ${argsString} - - Talk to an Engineer - t.id === this.selectedThreadId) ?? null) + ? (visibleThreads.find((t) => t.id === this.selectedThreadId) ?? null) : null; + const selectedThreadIsExample = this.isExampleThreadId(selectedThread?.id); if (!threadsErrorMessage) { this.trackThreadsViewStateOnce( @@ -8240,19 +8930,6 @@ ${argsString} -
) => { - this.selectedThreadId = e.detail; - this.requestUpdate(); + this.handleThreadsThreadSelected(e.detail, showingExamples); }} >
@@ -8280,16 +8956,22 @@ ${argsString}
-
+
${ selectedThread ? html`` + > + ${selectedThreadIsExample ? this.renderThreadsExampleTour() : nothing}` + : showingExamples + ? this.renderThreadsExampleOverview() : html`
Date: Thu, 9 Jul 2026 23:53:43 +0000 Subject: [PATCH 04/40] style: auto-fix formatting --- .../src/__tests__/web-inspector.spec.ts | 39 ++++++++++--------- packages/web-inspector/src/index.ts | 16 +++++--- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/packages/web-inspector/src/__tests__/web-inspector.spec.ts b/packages/web-inspector/src/__tests__/web-inspector.spec.ts index 4fd29819cbf..5b0956a0da0 100644 --- a/packages/web-inspector/src/__tests__/web-inspector.spec.ts +++ b/packages/web-inspector/src/__tests__/web-inspector.spec.ts @@ -1844,10 +1844,9 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { 'a[href="https://docs.copilotkit.ai/threads"]', ); expect(threadsDocs?.textContent?.trim()).toBe("Learn how Threads work"); - const selfHosted = - inspector.shadowRoot?.querySelector( - 'a[href="https://docs.copilotkit.ai/premium/self-hosting"]', - ); + const selfHosted = inspector.shadowRoot?.querySelector( + 'a[href="https://docs.copilotkit.ai/premium/self-hosting"]', + ); expect(selfHosted?.textContent?.trim()).toBe( "Explore self-hosted Intelligence", ); @@ -1927,9 +1926,9 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { await inspector.updateComplete; await vi.waitFor(() => { expect(internals.selectedThreadId).toBe("example-realtime-sync"); - expect(inspector.shadowRoot?.querySelector("cpk-thread-details")).not.toBe( - null, - ); + expect( + inspector.shadowRoot?.querySelector("cpk-thread-details"), + ).not.toBe(null); expect(inspector.shadowRoot?.textContent ?? "").toContain( "Read the run as a story", ); @@ -1940,7 +1939,9 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { await vi.waitFor(() => { expect(internals.selectedThreadId).toBeNull(); - expect(inspector.shadowRoot?.querySelector("cpk-thread-details")).toBeNull(); + expect( + inspector.shadowRoot?.querySelector("cpk-thread-details"), + ).toBeNull(); expect(inspector.shadowRoot?.textContent ?? "").toContain( "Threads are persistent, inspectable conversations", ); @@ -1999,13 +2000,14 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { skip!.click(); await inspector.updateComplete; - expect( - stored.get("cpk:inspector:threads-example-tour:v1"), - ).toContain('"dismissed":true'); + expect(stored.get("cpk:inspector:threads-example-tour:v1")).toContain( + '"dismissed":true', + ); const secondInspector = new WebInspectorElement(); document.body.appendChild(secondInspector); - secondInspector.core = harness.core as unknown as WebInspectorElement["core"]; + secondInspector.core = + harness.core as unknown as WebInspectorElement["core"]; harness.emitAgentsChanged(); const secondInternals = secondInspector as unknown as { isOpen: boolean; @@ -2016,9 +2018,7 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { await secondInspector.updateComplete; await vi.waitFor(() => { - expect(threadListText(secondInspector)).toContain( - "Realtime thread sync", - ); + expect(threadListText(secondInspector)).toContain("Realtime thread sync"); }); const secondRow = secondInspector.shadowRoot @@ -2028,13 +2028,16 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { await secondInspector.updateComplete; await vi.waitFor(() => { - expect(secondInspector.shadowRoot?.querySelector("cpk-thread-details")).not - .toBeNull(); + expect( + secondInspector.shadowRoot?.querySelector("cpk-thread-details"), + ).not.toBeNull(); }); expect(secondInspector.shadowRoot?.textContent ?? "").not.toContain( "Read the run as a story", ); - expect(secondInspector.shadowRoot?.textContent ?? "").toContain("Show tour"); + expect(secondInspector.shadowRoot?.textContent ?? "").toContain( + "Show tour", + ); const showTour = Array.from( secondInspector.shadowRoot?.querySelectorAll( diff --git a/packages/web-inspector/src/index.ts b/packages/web-inspector/src/index.ts index 7d290d87f50..38f5fd6e3e2 100644 --- a/packages/web-inspector/src/index.ts +++ b/packages/web-inspector/src/index.ts @@ -946,9 +946,9 @@ class CpkThreadList extends LitElement { ${thread.agentId} ${ (thread as Partial).isExample - ? html`Example` + ? html` + Example + ` : nothing }
@@ -8144,7 +8144,10 @@ ${argsString} { + const v = process.env[name]; + if (!v) { + console.error(`Missing required env var: ${name}`); + process.exit(1); + } + return v; +}; + +async function main() { + const agentUrl = required("AGENT_URL"); + const agentHeaders = process.env.AGENT_AUTH_HEADER + ? { Authorization: process.env.AGENT_AUTH_HEADER } + : undefined; + + const projectId = Number(required("INTELLIGENCE_PROJECT_ID")); + if (!Number.isInteger(projectId) || projectId < 0) { + console.error( + `Invalid INTELLIGENCE_PROJECT_ID: "${process.env.INTELLIGENCE_PROJECT_ID}"`, + ); + process.exit(1); + } + const botName = required("INTELLIGENCE_BOT_NAME"); + + // Same bot as the native example, minus the adapter: the managed transport is + // attached by startManagedBotsOverPhoenix. Slack is the only managed provider + // here, so it always ships the Slack tools/context (the native example adds + // these conditionally per active adapter). + const bot = createBot({ + name: botName, + agent: (threadId) => { + const a = new SanitizingHttpAgent({ url: agentUrl, headers: agentHeaders }); + a.threadId = threadId; + return a; + }, + tools: [...appTools, ...defaultSlackTools], + context: [...appContext, ...defaultSlackContext], + commands: appCommands, + }); + + // Turn + feature handlers — identical to the native example (app/index.ts). + bot.onMention(async ({ thread, message }) => { + try { + 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(() => {}); + } + }); + bot.onModalSubmit(FILE_ISSUE_CALLBACK, fileIssueSubmit); + bot.onThreadStarted(async ({ thread, user }) => { + if (!user?.name) return; + await thread.setSuggestedPrompts([ + { title: `Triage ${user.name}'s issues`, message: "Triage my open issues" }, + { title: "What shipped this week?", message: "Summarize what shipped this week" }, + ]); + }); + + const handle = await startManagedBotsOverPhoenix([bot], { + wsUrl: required("INTELLIGENCE_GATEWAY_WS_URL"), + apiKey: required("INTELLIGENCE_API_KEY"), + scope: { + organizationId: required("INTELLIGENCE_ORG_ID"), + projectId, + botId: required("INTELLIGENCE_BOT_ID"), + botName, + }, + runtimeInstanceId: + process.env.INTELLIGENCE_RUNTIME_INSTANCE_ID ?? + `rti_${randomUUID().replace(/-/g, "")}`, + adapter: "slack", + log: (msg, meta) => console.log(`[managed] ${msg}`, meta ?? ""), + }); + console.log(`[bot] started managed (Phoenix) as "${botName}" on project ${projectId}`); + + const shutdown = async (signal: string) => { + console.log(`\n[bot] received ${signal}, stopping…`); + await handle.stop(); + await closeBrowser().catch(() => {}); + process.exit(0); + }; + process.on("SIGINT", () => void shutdown("SIGINT")); + process.on("SIGTERM", () => void shutdown("SIGTERM")); +} + +void main(); diff --git a/examples/slack/package.json b/examples/slack/package.json index 594cf83a89b..a4575b9d760 100644 --- a/examples/slack/package.json +++ b/examples/slack/package.json @@ -8,6 +8,7 @@ "scripts": { "dev": "tsx watch app/index.ts", "start": "tsx app/index.ts", + "managed": "tsx app/managed.ts", "build": "pnpm exec nx run-many -t build -p \"@copilotkit/channels*\" @copilotkit/runtime", "runtime": "tsx runtime.ts", "notion-mcp": "tsx scripts/start-notion-mcp.ts", @@ -20,6 +21,7 @@ "dependencies": { "@copilotkit/channels": "workspace:*", "@copilotkit/channels-discord": "workspace:*", + "@copilotkit/channels-intelligence": "workspace:*", "@copilotkit/channels-slack": "workspace:*", "@copilotkit/channels-telegram": "workspace:*", "@copilotkit/channels-ui": "workspace:*", diff --git a/packages/channels-intelligence/src/index.ts b/packages/channels-intelligence/src/index.ts index 7f30085a362..f771bfe60de 100644 --- a/packages/channels-intelligence/src/index.ts +++ b/packages/channels-intelligence/src/index.ts @@ -52,6 +52,16 @@ export type { PhoenixConnectConfig, ConnectedHostedBotChannel, } from "./phoenix-channel.js"; +// The managed-over-Phoenix launcher (OSS-406): the composition that runs a +// coworker over the realtime path (connect → transport → startManagedBots). +export { + startManagedBotsOverPhoenix, + startManagedBotsOnChannel, +} from "./phoenix-launcher.js"; +export type { + ManagedPhoenixConfig, + ManagedBotsOnChannelOptions, +} from "./phoenix-launcher.js"; // Undocumented fallbacks: the default HTTP transports + config resolver that // `intelligenceAdapter()` builds when no transports are injected. Not a public diff --git a/packages/channels-intelligence/src/phoenix-launcher.test.ts b/packages/channels-intelligence/src/phoenix-launcher.test.ts new file mode 100644 index 00000000000..c78a575c83f --- /dev/null +++ b/packages/channels-intelligence/src/phoenix-launcher.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from "vitest"; +import { createBot, FakeAgent } from "@copilotkit/channels"; +import { Section } from "@copilotkit/channels-ui"; +import { startManagedBotsOnChannel } from "./phoenix-launcher.js"; +import type { HostedBotChannel } from "./phoenix-transport.js"; + +const scope = { + organizationId: "org_1", + projectId: 7, + botId: "bot_1", + botName: "opentag", +}; + +/** Fake gateway channel: records pushes, replies `render_accepted`, and exposes + * the server-push handlers so a test can simulate `delivery.available`. */ +function makeFakeChannel() { + const pushes: { event: string; payload: unknown }[] = []; + const handlers = new Map void>(); + const channel: HostedBotChannel = { + push: async (event, payload) => { + pushes.push({ event, payload }); + if (event === "hosted_bot.render_event.v1") { + const p = (payload as { payload: Record }).payload; + return { + type: "hosted_bot.render_accepted.v1", + occurredAt: "2026-07-09T00:00:00.000Z", + payload: { + idempotencyKey: p.idempotencyKey, + acceptance: "accepted", + ...(p.event && (p.event as { kind: string }).kind === "finalize" + ? { egressOperationId: "eop_1" } + : {}), + }, + }; + } + return { status: "ok" }; + }, + on: (event, handler) => { + handlers.set(event, handler); + }, + }; + return { channel, pushes, handlers }; +} + +/** Simulate one leased text-turn delivery arriving over the channel. */ +function deliverText(handlers: Map void>) { + handlers.get("hosted_bot.delivery.available.v1")?.({ + payload: { + delivery: { + id: "dlv_1", + leaseToken: "lease_1", + adapter: "slack", + bot: { id: "bot_1", name: "opentag" }, + turn: { + id: "turn_1", + eventId: "evt_1", + replyTarget: { adapter: "slack", teamId: "T1", channel: "C1" }, + input: { kind: "text", text: "hi" }, + }, + }, + }, + }); +} + +/** The channel `delivery.available` handler is fire-and-forget, so poll until + * the async dispatch→render→ack chain has produced the terminal event. */ +async function waitFor(pred: () => boolean, tries = 50): Promise { + for (let i = 0; i < tries; i++) { + if (pred()) return; + await new Promise((r) => setTimeout(r, 0)); + } + throw new Error("waitFor: condition not met within the poll window"); +} + +describe("startManagedBotsOnChannel — managed runtime over Phoenix (OSS-406)", () => { + it("runs a delivered turn end-to-end: handler → render frame → completion intent, never self-ack", async () => { + const fake = makeFakeChannel(); + let ran = false; + const bot = createBot({ name: "opentag", agent: () => new FakeAgent() }); + bot.onMessage(async ({ thread }) => { + ran = true; + await thread.post(Section({ children: "reply" })); + }); + + const handle = await startManagedBotsOnChannel([bot], { + channel: fake.channel, + scope, + runtimeInstanceId: "rti_1", + }); + + deliverText(fake.handlers); + await waitFor(() => + fake.pushes.some( + (p) => p.event === "hosted_bot.delivery.complete_requested.v1", + ), + ); + + const events = fake.pushes.map((p) => p.event); + expect(ran).toBe(true); // the bot's handler ran off a Phoenix-delivered turn + expect(events).toContain("hosted_bot.render_event.v1"); // rendered over the channel + expect(events).toContain("hosted_bot.delivery.complete_requested.v1"); // completion INTENT + expect(events).not.toContain("hosted_bot.delivery.ack.v1"); // SDK never commits the ack + + await handle.stop(); + }); + + it("nacks (fail intent) when the handler throws — no completion intent, no self-ack", async () => { + const fake = makeFakeChannel(); + const bot = createBot({ name: "opentag", agent: () => new FakeAgent() }); + bot.onMessage(async () => { + throw new Error("boom"); + }); + + const handle = await startManagedBotsOnChannel([bot], { + channel: fake.channel, + scope, + runtimeInstanceId: "rti_1", + }); + + deliverText(fake.handlers); + await waitFor(() => + fake.pushes.some((p) => p.event === "hosted_bot.delivery.fail.v1"), + ); + + const events = fake.pushes.map((p) => p.event); + expect(events).toContain("hosted_bot.delivery.fail.v1"); + expect(events).not.toContain("hosted_bot.delivery.complete_requested.v1"); + expect(events).not.toContain("hosted_bot.delivery.ack.v1"); + + await handle.stop(); + }); +}); diff --git a/packages/channels-intelligence/src/phoenix-launcher.ts b/packages/channels-intelligence/src/phoenix-launcher.ts new file mode 100644 index 00000000000..633ba4aac12 --- /dev/null +++ b/packages/channels-intelligence/src/phoenix-launcher.ts @@ -0,0 +1,132 @@ +import type { Bot } from "@copilotkit/channels"; +import { startManagedBots } from "./runtime.js"; +import type { ManagedBotsHandle } from "./runtime.js"; +import { connectPhoenixHostedBotChannel } from "./phoenix-channel.js"; +import { PhoenixRealtimeTransport } from "./phoenix-transport.js"; +import type { + HostedBotChannel, + HostedBotRealtimeScope, +} from "./phoenix-transport.js"; +import type { EgressSink } from "./transports.js"; + +/** + * Phoenix-path egress is vestigial: with a render sink wired (the transport + * itself), the adapter routes every `post`/`update` and the run-render stream + * through the render sink and never through the generic {@link EgressSink}. Fail + * loud if that invariant is ever broken, rather than silently dropping an op. + */ +const phoenixEgress: EgressSink = { + emit: async () => { + throw new Error( + "startManagedBotsOverPhoenix: EgressSink.emit was called, but the Phoenix " + + "path routes all egress through the render sink — this indicates a " + + "wiring bug (the render sink was not set on the adapter).", + ); + }, +}; + +/** Options for {@link startManagedBotsOnChannel} — an already-connected channel. */ +export interface ManagedBotsOnChannelOptions { + /** The joined realtime-gateway bot-IO channel (`hosted_bots:project:`). */ + channel: HostedBotChannel; + /** Authoritative org/project/bot scope echoed on every SDK→gateway envelope. */ + scope: HostedBotRealtimeScope; + /** Stable runtime instance id (`rti_…`), echoed on every envelope. */ + runtimeInstanceId: string; + /** Diagnostic sink for dropped deliveries / transport events. */ + log?: (message: string, meta?: unknown) => void; +} + +/** + * Compose the managed runtime over an already-connected Phoenix channel: wrap + * the channel in a {@link PhoenixRealtimeTransport} (delivery source + render + * sink) and start the declared bots against it via {@link startManagedBots}. + * + * Split out from {@link startManagedBotsOverPhoenix} so the composition — the + * part with behavior — is unit-testable against a fake channel, leaving the + * socket connect as thin glue. `intelligenceAdapter` is exclusive, so the + * Phoenix transport is each bot's ONLY adapter; egress is served by the render + * sink, not the generic {@link EgressSink} (see {@link phoenixEgress}). + */ +export async function startManagedBotsOnChannel( + bots: Bot[], + opts: ManagedBotsOnChannelOptions, +): Promise { + const transport = new PhoenixRealtimeTransport({ + scope: opts.scope, + runtimeInstanceId: opts.runtimeInstanceId, + channel: opts.channel, + ...(opts.log ? { log: opts.log } : {}), + }); + return startManagedBots({ + bots, + resolveTransport: () => ({ + source: transport, + renderSink: transport, + egress: phoenixEgress, + }), + }); +} + +/** Config for {@link startManagedBotsOverPhoenix}. */ +export interface ManagedPhoenixConfig { + /** Gateway runner WebSocket URL — the `/runner` socket hosting the + * `hosted_bots:project:` channel. */ + wsUrl: string; + /** Project runtime API key (`cpk-…`), presented as the socket `authToken`. */ + apiKey: string; + /** Authoritative org/project/bot scope echoed on every SDK→gateway envelope. */ + scope: HostedBotRealtimeScope; + /** Stable runtime instance id (`rti_…`). */ + runtimeInstanceId: string; + /** Adapter kind declared to the gateway on join (default `"slack"`). */ + adapter?: string; + /** Join timeout in ms. */ + timeoutMs?: number; + /** Injectable `WebSocket` ctor (non-global hosts / tests). */ + webSocket?: unknown; + /** Diagnostic sink for dropped deliveries / transport events. */ + log?: (message: string, meta?: unknown) => void; +} + +/** + * The managed-over-Phoenix launcher (OSS-406): connect the realtime-gateway + * bot-IO channel, then run the declared bots against it via + * {@link startManagedBotsOnChannel}. This is the composition that runs a + * managed bot over the realtime path — the transport primitives are unit-tested; + * this wires them for a live run. The returned handle's `stop()` stops the bots + * and then disconnects the socket. + */ +export async function startManagedBotsOverPhoenix( + bots: Bot[], + config: ManagedPhoenixConfig, +): Promise { + const adapter = config.adapter ?? "slack"; + const channel = await connectPhoenixHostedBotChannel({ + wsUrl: config.wsUrl, + apiKey: config.apiKey, + projectId: config.scope.projectId, + join: { + runtimeInstanceId: config.runtimeInstanceId, + declaredBots: bots.map((bot) => ({ botName: bot.name!, adapter })), + observedAt: new Date().toISOString(), + }, + ...(config.timeoutMs !== undefined ? { timeoutMs: config.timeoutMs } : {}), + ...(config.webSocket !== undefined ? { webSocket: config.webSocket } : {}), + }); + const handle = await startManagedBotsOnChannel(bots, { + channel, + scope: config.scope, + runtimeInstanceId: config.runtimeInstanceId, + ...(config.log ? { log: config.log } : {}), + }); + return { + ...handle, + stop: async () => { + await handle.stop(); + // The launcher owns the connection, so it closes it — the transport is + // handed the channel and doesn't disconnect it itself. + channel.disconnect(); + }, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c90b8ca4c9..5ff88b6c0c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -414,6 +414,9 @@ importers: '@copilotkit/channels-discord': specifier: workspace:* version: link:../../packages/channels-discord + '@copilotkit/channels-intelligence': + specifier: workspace:* + version: link:../../packages/channels-intelligence '@copilotkit/channels-slack': specifier: workspace:* version: link:../../packages/channels-slack @@ -25036,8 +25039,8 @@ packages: vue-component-type-helpers@3.3.1: resolution: {integrity: sha512-pu58kqxmVyEH6VfNYW1UyEfR3XAnJ27ZXT3yzXxxpjLxVzAbyC35Zk/nm/RMs7ijWnJNSd9fWkeex2OhUsx3MA==} - vue-component-type-helpers@3.3.6: - resolution: {integrity: sha512-FkljacAwJ9BUoSUdpFe3VDy0sGigNlTH9+2zcXUWmZOjN8swiCkl3t48wOJun0OsUd2cEIda1l04tsxMiKIIrQ==} + vue-component-type-helpers@3.3.7: + resolution: {integrity: sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg==} vue-devtools-stub@0.1.0: resolution: {integrity: sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==} @@ -38075,7 +38078,7 @@ snapshots: storybook: 10.1.11(@testing-library/dom@10.4.0)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) type-fest: 2.19.0 vue: 3.5.34(typescript@5.8.2) - vue-component-type-helpers: 3.3.6 + vue-component-type-helpers: 3.3.7 '@swc/core-darwin-arm64@1.15.8': optional: true @@ -55797,7 +55800,7 @@ snapshots: vue-component-type-helpers@3.3.1: {} - vue-component-type-helpers@3.3.6: {} + vue-component-type-helpers@3.3.7: {} vue-devtools-stub@0.1.0: {} From f63578df21ab972f10706c8ace37a3c990a74f4c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:14:35 +0000 Subject: [PATCH 06/40] style: auto-fix formatting --- examples/slack/app/managed.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/examples/slack/app/managed.ts b/examples/slack/app/managed.ts index 0f639077576..199afdc1623 100644 --- a/examples/slack/app/managed.ts +++ b/examples/slack/app/managed.ts @@ -67,7 +67,10 @@ async function main() { const bot = createBot({ name: botName, agent: (threadId) => { - const a = new SanitizingHttpAgent({ url: agentUrl, headers: agentHeaders }); + const a = new SanitizingHttpAgent({ + url: agentUrl, + headers: agentHeaders, + }); a.threadId = threadId; return a; }, @@ -93,8 +96,14 @@ async function main() { bot.onThreadStarted(async ({ thread, user }) => { if (!user?.name) return; await thread.setSuggestedPrompts([ - { title: `Triage ${user.name}'s issues`, message: "Triage my open issues" }, - { title: "What shipped this week?", message: "Summarize what shipped this week" }, + { + title: `Triage ${user.name}'s issues`, + message: "Triage my open issues", + }, + { + title: "What shipped this week?", + message: "Summarize what shipped this week", + }, ]); }); @@ -113,7 +122,9 @@ async function main() { adapter: "slack", log: (msg, meta) => console.log(`[managed] ${msg}`, meta ?? ""), }); - console.log(`[bot] started managed (Phoenix) as "${botName}" on project ${projectId}`); + console.log( + `[bot] started managed (Phoenix) as "${botName}" on project ${projectId}`, + ); const shutdown = async (signal: string) => { console.log(`\n[bot] received ${signal}, stopping…`); From 7207332d10b50211545c11ad1f4ccd3e1176b7d8 Mon Sep 17 00:00:00 2001 From: Benjamin Taylor Date: Thu, 9 Jul 2026 16:16:06 -0500 Subject: [PATCH 07/40] fix(examples/slack): managed mode must pass the current message as prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Managed getHistory (app-api /api/bots/history) doesn't include the in-flight turn — unlike native adapters whose getHistory rebuilds the live thread — so runAgent({context}) alone runs the agent with zero messages (→ provider 400). Pass the current message (contentParts ?? text) as `prompt`, the sanctioned mechanism for input not in the adapter's reconstructed history. Verified live: the managed Slack bot now returns a real answer over the Phoenix loop. --- examples/slack/app/managed.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/examples/slack/app/managed.ts b/examples/slack/app/managed.ts index 199afdc1623..e1eb8f7a08e 100644 --- a/examples/slack/app/managed.ts +++ b/examples/slack/app/managed.ts @@ -82,7 +82,14 @@ async function main() { // Turn + feature handlers — identical to the native example (app/index.ts). bot.onMention(async ({ thread, message }) => { try { + // Managed history (app-api /api/bots/history) does NOT include the + // in-flight turn (unlike native adapters whose getHistory rebuilds the + // live thread), so pass the current message explicitly as `prompt` — + // otherwise runAgent runs with zero messages. Prefer multimodal parts. await thread.runAgent({ + prompt: message.contentParts?.length + ? message.contentParts + : message.text, context: senderContext(message.user, thread.platform), }); } catch (err) { From f35f2829b80e68b1ff82c53576f8dd1bbfc33783 Mon Sep 17 00:00:00 2001 From: Benjamin Taylor Date: Thu, 9 Jul 2026 16:33:38 -0500 Subject: [PATCH 08/40] docs(channels-intelligence): drop deprecated 'coworker' term from launcher comment --- packages/channels-intelligence/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/channels-intelligence/src/index.ts b/packages/channels-intelligence/src/index.ts index f771bfe60de..f203a1ad1bf 100644 --- a/packages/channels-intelligence/src/index.ts +++ b/packages/channels-intelligence/src/index.ts @@ -53,7 +53,7 @@ export type { ConnectedHostedBotChannel, } from "./phoenix-channel.js"; // The managed-over-Phoenix launcher (OSS-406): the composition that runs a -// coworker over the realtime path (connect → transport → startManagedBots). +// managed bot over the realtime path (connect → transport → startManagedBots). export { startManagedBotsOverPhoenix, startManagedBotsOnChannel, From fec701f7312195bb280fcbb49c59a308b6447ae8 Mon Sep 17 00:00:00 2001 From: Benjamin Taylor Date: Thu, 9 Jul 2026 17:01:37 -0500 Subject: [PATCH 09/40] ci(showcase): raise shell-script-tests timeout to 10m to stop timeout-race flake --- .github/workflows/showcase_validate.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/showcase_validate.yml b/.github/workflows/showcase_validate.yml index dd9818879e5..e9e22ab8028 100644 --- a/.github/workflows/showcase_validate.yml +++ b/.github/workflows/showcase_validate.yml @@ -557,7 +557,10 @@ jobs: # These tests gate the promote-fleet.sh best-effort loop + succeeded_csv # export that the promote → verify-prod handoff depends on. runs-on: ubuntu-latest - timeout-minutes: 5 + # The bats suite runs ~4.5min and keeps growing; at the old 5min job cap it + # raced the deadline and intermittently got cancelled mid-suite (all steps + # passing) rather than reported. Give headroom so a green suite reports green. + timeout-minutes: 10 permissions: contents: read steps: From df7d3e348fe88f411c557ccf664501bbf5afe0c8 Mon Sep 17 00:00:00 2001 From: Benjamin Taylor Date: Fri, 10 Jul 2026 10:44:24 -0500 Subject: [PATCH 10/40] fix(channels-intelligence): fail-fast bot names + richer activation join + stop() cleanup (OSS-406 review) --- examples/slack/app/managed.ts | 4 ++ .../src/phoenix-launcher.test.ts | 31 +++++++- .../src/phoenix-launcher.ts | 70 +++++++++++++++++-- 3 files changed, 97 insertions(+), 8 deletions(-) diff --git a/examples/slack/app/managed.ts b/examples/slack/app/managed.ts index e1eb8f7a08e..ac3affcde7d 100644 --- a/examples/slack/app/managed.ts +++ b/examples/slack/app/managed.ts @@ -127,6 +127,10 @@ async function main() { process.env.INTELLIGENCE_RUNTIME_INSTANCE_ID ?? `rti_${randomUUID().replace(/-/g, "")}`, adapter: "slack", + // DEBUG-ONLY logging. `meta` (and the raw `err` in the onMention catch + // above) can contain message content/payloads — the design says telemetry + // must not include raw message text. In production, drop this `log` or trim + // `meta` to safe fields (ids, counts) before emitting. log: (msg, meta) => console.log(`[managed] ${msg}`, meta ?? ""), }); console.log( diff --git a/packages/channels-intelligence/src/phoenix-launcher.test.ts b/packages/channels-intelligence/src/phoenix-launcher.test.ts index c78a575c83f..1f6d255a6c4 100644 --- a/packages/channels-intelligence/src/phoenix-launcher.test.ts +++ b/packages/channels-intelligence/src/phoenix-launcher.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect } from "vitest"; import { createBot, FakeAgent } from "@copilotkit/channels"; import { Section } from "@copilotkit/channels-ui"; -import { startManagedBotsOnChannel } from "./phoenix-launcher.js"; +import { + startManagedBotsOnChannel, + startManagedBotsOverPhoenix, +} from "./phoenix-launcher.js"; import type { HostedBotChannel } from "./phoenix-transport.js"; const scope = { @@ -130,3 +133,29 @@ describe("startManagedBotsOnChannel — managed runtime over Phoenix (OSS-406)", await handle.stop(); }); }); + +describe("startManagedBotsOverPhoenix — fail-fast validation (OSS-406)", () => { + it("rejects a bad bot name before opening a socket (no leaked connection)", async () => { + let socketConstructed = false; + class NeverWebSocket { + constructor() { + socketConstructed = true; + throw new Error("startManagedBotsOverPhoenix should not have connected"); + } + } + const a = createBot({ name: "dupe", agent: () => new FakeAgent() }); + const b = createBot({ name: "dupe", agent: () => new FakeAgent() }); + + await expect( + startManagedBotsOverPhoenix([a, b], { + wsUrl: "wss://gateway.example/socket", + apiKey: "cpk-test", + scope, + runtimeInstanceId: "rti_1", + webSocket: NeverWebSocket, + }), + ).rejects.toThrow(/duplicate managed bot name/i); + + expect(socketConstructed).toBe(false); + }); +}); diff --git a/packages/channels-intelligence/src/phoenix-launcher.ts b/packages/channels-intelligence/src/phoenix-launcher.ts index 633ba4aac12..9ed18670685 100644 --- a/packages/channels-intelligence/src/phoenix-launcher.ts +++ b/packages/channels-intelligence/src/phoenix-launcher.ts @@ -1,6 +1,11 @@ import type { Bot } from "@copilotkit/channels"; -import { startManagedBots } from "./runtime.js"; -import type { ManagedBotsHandle } from "./runtime.js"; +import { + startManagedBots, + assertValidBotNames, + buildActivationMetadata, + resolveActivationEnv, +} from "./runtime.js"; +import type { ManagedBotsHandle, ActivationEnv } from "./runtime.js"; import { connectPhoenixHostedBotChannel } from "./phoenix-channel.js"; import { PhoenixRealtimeTransport } from "./phoenix-transport.js"; import type { @@ -33,6 +38,10 @@ export interface ManagedBotsOnChannelOptions { scope: HostedBotRealtimeScope; /** Stable runtime instance id (`rti_…`), echoed on every envelope. */ runtimeInstanceId: string; + /** Activation env overrides forwarded to the runtime (so `handle.metadata` + * matches what the caller declared on join); omitted fields are gathered from + * the process. */ + env?: Partial; /** Diagnostic sink for dropped deliveries / transport events. */ log?: (message: string, meta?: unknown) => void; } @@ -65,6 +74,7 @@ export async function startManagedBotsOnChannel( renderSink: transport, egress: phoenixEgress, }), + ...(opts.env ? { env: opts.env } : {}), }); } @@ -81,6 +91,10 @@ export interface ManagedPhoenixConfig { runtimeInstanceId: string; /** Adapter kind declared to the gateway on join (default `"slack"`). */ adapter?: string; + /** Activation env overrides (package versions, runtimeEnv); omitted fields + * are gathered from the process. Included in the join's `runtimeMetadata` and + * in `handle.metadata`. */ + env?: Partial; /** Join timeout in ms. */ timeoutMs?: number; /** Injectable `WebSocket` ctor (non-global hosts / tests). */ @@ -102,13 +116,50 @@ export async function startManagedBotsOverPhoenix( config: ManagedPhoenixConfig, ): Promise { const adapter = config.adapter ?? "slack"; + + // Fail fast BEFORE opening the socket: a missing/duplicate name would + // otherwise send a broken join (`botName: undefined`) and — because the same + // check inside startManagedBots runs only after we've connected — throw with + // the socket already open and never closed (a leak). Validating here means a + // bad declaration never opens a connection at all. + assertValidBotNames(bots); + + // Build activation metadata up front so the join carries the Runtime + // Activation data Intelligence's health view expects (runtime env, node + // version, per-bot commands) rather than just name+adapter. The same + // `envOverrides` is forwarded to startManagedBots so `handle.metadata` agrees + // with what we declared on join. + const envOverrides: Partial = { + runtimeInstanceId: config.runtimeInstanceId, + ...(config.env ?? {}), + }; + const activation = buildActivationMetadata(bots, resolveActivationEnv(envOverrides)); + const channel = await connectPhoenixHostedBotChannel({ wsUrl: config.wsUrl, apiKey: config.apiKey, projectId: config.scope.projectId, join: { runtimeInstanceId: config.runtimeInstanceId, - declaredBots: bots.map((bot) => ({ botName: bot.name!, adapter })), + declaredBots: activation.declaredBots.map((b) => ({ + botName: b.name, + adapter, + // renderCapabilities: reserved — bots don't expose capabilities yet + // (tracked with the richer per-bot metadata in OSS-377). + })), + runtimeMetadata: { + runtimeEnv: activation.runtimeEnv, + ...(activation.nodeVersion ? { nodeVersion: activation.nodeVersion } : {}), + ...(activation.runtimePackageVersion + ? { runtimePackageVersion: activation.runtimePackageVersion } + : {}), + ...(activation.botPackageVersion + ? { botPackageVersion: activation.botPackageVersion } + : {}), + commands: Object.fromEntries( + activation.declaredBots.map((b) => [b.name, b.commands]), + ), + }, observedAt: new Date().toISOString(), }, ...(config.timeoutMs !== undefined ? { timeoutMs: config.timeoutMs } : {}), @@ -118,15 +169,20 @@ export async function startManagedBotsOverPhoenix( channel, scope: config.scope, runtimeInstanceId: config.runtimeInstanceId, + env: envOverrides, ...(config.log ? { log: config.log } : {}), }); return { ...handle, stop: async () => { - await handle.stop(); - // The launcher owns the connection, so it closes it — the transport is - // handed the channel and doesn't disconnect it itself. - channel.disconnect(); + // Always close the connection even if stopping the bots throws — the + // launcher owns the socket (the transport is handed the channel and does + // not disconnect it itself). + try { + await handle.stop(); + } finally { + channel.disconnect(); + } }, }; } From 1441768b73d5de543dc2a41bd84286f99b14c0c5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:42:07 +0000 Subject: [PATCH 11/40] style: auto-fix formatting --- .../channels-intelligence/src/phoenix-launcher.test.ts | 4 +++- packages/channels-intelligence/src/phoenix-launcher.ts | 9 +++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/channels-intelligence/src/phoenix-launcher.test.ts b/packages/channels-intelligence/src/phoenix-launcher.test.ts index 1f6d255a6c4..fa3da573784 100644 --- a/packages/channels-intelligence/src/phoenix-launcher.test.ts +++ b/packages/channels-intelligence/src/phoenix-launcher.test.ts @@ -140,7 +140,9 @@ describe("startManagedBotsOverPhoenix — fail-fast validation (OSS-406)", () => class NeverWebSocket { constructor() { socketConstructed = true; - throw new Error("startManagedBotsOverPhoenix should not have connected"); + throw new Error( + "startManagedBotsOverPhoenix should not have connected", + ); } } const a = createBot({ name: "dupe", agent: () => new FakeAgent() }); diff --git a/packages/channels-intelligence/src/phoenix-launcher.ts b/packages/channels-intelligence/src/phoenix-launcher.ts index 9ed18670685..82baf722460 100644 --- a/packages/channels-intelligence/src/phoenix-launcher.ts +++ b/packages/channels-intelligence/src/phoenix-launcher.ts @@ -133,7 +133,10 @@ export async function startManagedBotsOverPhoenix( runtimeInstanceId: config.runtimeInstanceId, ...(config.env ?? {}), }; - const activation = buildActivationMetadata(bots, resolveActivationEnv(envOverrides)); + const activation = buildActivationMetadata( + bots, + resolveActivationEnv(envOverrides), + ); const channel = await connectPhoenixHostedBotChannel({ wsUrl: config.wsUrl, @@ -149,7 +152,9 @@ export async function startManagedBotsOverPhoenix( })), runtimeMetadata: { runtimeEnv: activation.runtimeEnv, - ...(activation.nodeVersion ? { nodeVersion: activation.nodeVersion } : {}), + ...(activation.nodeVersion + ? { nodeVersion: activation.nodeVersion } + : {}), ...(activation.runtimePackageVersion ? { runtimePackageVersion: activation.runtimePackageVersion } : {}), From 852bd04a134ca3b1b4f381b03895416249ce9ba1 Mon Sep 17 00:00:00 2001 From: Benjamin Taylor Date: Fri, 10 Jul 2026 11:48:21 -0500 Subject: [PATCH 12/40] fix(channels-intelligence): single-bot guard + startup/join socket cleanup + authoritative runtimeInstanceId (OSS-406 review r2) --- examples/slack/.env.example | 19 ++++++ .../src/phoenix-channel.ts | 16 +++-- .../src/phoenix-launcher.test.ts | 58 +++++++++++++++++++ .../src/phoenix-launcher.ts | 56 ++++++++++++++---- 4 files changed, 132 insertions(+), 17 deletions(-) diff --git a/examples/slack/.env.example b/examples/slack/.env.example index 501d3b17c58..d6bb9f89d8e 100644 --- a/examples/slack/.env.example +++ b/examples/slack/.env.example @@ -39,6 +39,25 @@ OPENAI_API_KEY=sk-... # ANTHROPIC_API_KEY=sk-ant-... # GOOGLE_API_KEY=... +# ── Managed (Intelligence-hosted) mode — app/managed.ts (`pnpm managed`) ── +# The managed variant runs the SAME bot over the Intelligence realtime-gateway +# instead of a native adapter — it holds NO Slack tokens (Intelligence owns the +# Slack edge). Set these to run `pnpm managed`; the native `pnpm dev` ignores +# them. The agent brain reuses AGENT_URL / AGENT_AUTH_HEADER above. +# Gateway runner WebSocket URL (the /runner socket hosting the bot-IO channel). +# INTELLIGENCE_GATEWAY_WS_URL=wss://gateway.intelligence.example/runner +# Project runtime API key (cpk-…) — presented as the socket authToken. +# INTELLIGENCE_API_KEY=cpk-... +# Product organization id (as issued by Intelligence). +# INTELLIGENCE_ORG_ID=org_... +# Numeric project id (the channel topic is hosted_bots:project:). +# INTELLIGENCE_PROJECT_ID=123 +# Bot id + immutable project-unique bot name (from Intelligence bot setup). +# INTELLIGENCE_BOT_ID=bot_... +# INTELLIGENCE_BOT_NAME=triage +# Optional: stable runtime instance id; a random rti_… is generated if unset. +# INTELLIGENCE_RUNTIME_INSTANCE_ID=rti_... + # ── Linear MCP ────────────────────────────────────────────────────────── # The hosted Linear MCP accepts a raw API key as a bearer token (no OAuth # dance). Create one at linear.app → Settings → API → Personal API keys. diff --git a/packages/channels-intelligence/src/phoenix-channel.ts b/packages/channels-intelligence/src/phoenix-channel.ts index 0584154659a..32f963e728a 100644 --- a/packages/channels-intelligence/src/phoenix-channel.ts +++ b/packages/channels-intelligence/src/phoenix-channel.ts @@ -78,14 +78,18 @@ export async function connectPhoenixHostedBotChannel( channel .join(timeout) .receive("ok", () => resolve()) - .receive("error", (reason: unknown) => + .receive("error", (reason: unknown) => { + // The join failed, so the caller never gets a channel it could + // disconnect — tear the socket down here rather than leak it. + socket.disconnect(); reject( new Error(`hosted-bot channel join failed: ${safeReason(reason)}`), - ), - ) - .receive("timeout", () => - reject(new Error("hosted-bot channel join timed out")), - ); + ); + }) + .receive("timeout", () => { + socket.disconnect(); + reject(new Error("hosted-bot channel join timed out")); + }); }); return { diff --git a/packages/channels-intelligence/src/phoenix-launcher.test.ts b/packages/channels-intelligence/src/phoenix-launcher.test.ts index fa3da573784..97008aa5085 100644 --- a/packages/channels-intelligence/src/phoenix-launcher.test.ts +++ b/packages/channels-intelligence/src/phoenix-launcher.test.ts @@ -160,4 +160,62 @@ describe("startManagedBotsOverPhoenix — fail-fast validation (OSS-406)", () => expect(socketConstructed).toBe(false); }); + + it("rejects >1 bot before opening a socket (Phase 1 is single-bot per channel)", async () => { + let socketConstructed = false; + class NeverWebSocket { + constructor() { + socketConstructed = true; + throw new Error("startManagedBotsOverPhoenix should not have connected"); + } + } + const a = createBot({ name: "one", agent: () => new FakeAgent() }); + const b = createBot({ name: "two", agent: () => new FakeAgent() }); + + await expect( + startManagedBotsOverPhoenix([a, b], { + wsUrl: "wss://gateway.example/socket", + apiKey: "cpk-test", + scope, + runtimeInstanceId: "rti_1", + webSocket: NeverWebSocket, + }), + ).rejects.toThrow(/exactly one bot per channel/i); + + expect(socketConstructed).toBe(false); + }); + + it("startManagedBotsOnChannel also rejects >1 bot (shared-transport misrouting guard)", async () => { + const fake = makeFakeChannel(); + const a = createBot({ name: "one", agent: () => new FakeAgent() }); + const b = createBot({ name: "two", agent: () => new FakeAgent() }); + + await expect( + startManagedBotsOnChannel([a, b], { + channel: fake.channel, + scope, + runtimeInstanceId: "rti_1", + }), + ).rejects.toThrow(/exactly one bot per channel/i); + }); +}); + +describe("startManagedBotsOnChannel — activation metadata (OSS-406)", () => { + it("forwards env overrides into handle.metadata (keeps join ↔ metadata in sync)", async () => { + const fake = makeFakeChannel(); + const bot = createBot({ name: "opentag", agent: () => new FakeAgent() }); + + const handle = await startManagedBotsOnChannel([bot], { + channel: fake.channel, + scope, + runtimeInstanceId: "rti_1", + env: { runtimeEnv: "production", runtimePackageVersion: "9.9.9" }, + }); + + expect(handle.metadata.runtimeEnv).toBe("production"); + expect(handle.metadata.runtimePackageVersion).toBe("9.9.9"); + expect(handle.metadata.declaredBotNames).toEqual(["opentag"]); + + await handle.stop(); + }); }); diff --git a/packages/channels-intelligence/src/phoenix-launcher.ts b/packages/channels-intelligence/src/phoenix-launcher.ts index 82baf722460..0d83d6ffb5e 100644 --- a/packages/channels-intelligence/src/phoenix-launcher.ts +++ b/packages/channels-intelligence/src/phoenix-launcher.ts @@ -30,6 +30,24 @@ const phoenixEgress: EgressSink = { }, }; +/** + * Phase 1 runs exactly one bot per channel. {@link PhoenixRealtimeTransport} is + * a single-delivery-callback object bound to one channel; attaching more than + * one bot's `intelligenceAdapter` to the same transport would make every + * delivery dispatch through the last bot's callback (and earlier bots receive + * nothing). Multi-bot routing over a shared channel is Phase 2 (OSS-459) — until + * then, run one bot per channel/runner and fail loudly on more. + */ +function assertSingleBotForPhase1(bots: readonly Bot[]): void { + if (bots.length !== 1) { + throw new Error( + `managed Phoenix runtime supports exactly one bot per channel, got ${bots.length} — ` + + "multi-bot routing over a shared PhoenixRealtimeTransport is not implemented yet (OSS-459); " + + "run one bot per channel/runner", + ); + } +} + /** Options for {@link startManagedBotsOnChannel} — an already-connected channel. */ export interface ManagedBotsOnChannelOptions { /** The joined realtime-gateway bot-IO channel (`hosted_bots:project:`). */ @@ -61,6 +79,7 @@ export async function startManagedBotsOnChannel( bots: Bot[], opts: ManagedBotsOnChannelOptions, ): Promise { + assertSingleBotForPhase1(bots); const transport = new PhoenixRealtimeTransport({ scope: opts.scope, runtimeInstanceId: opts.runtimeInstanceId, @@ -93,8 +112,10 @@ export interface ManagedPhoenixConfig { adapter?: string; /** Activation env overrides (package versions, runtimeEnv); omitted fields * are gathered from the process. Included in the join's `runtimeMetadata` and - * in `handle.metadata`. */ - env?: Partial; + * in `handle.metadata`. `runtimeInstanceId` is intentionally excluded — the + * required top-level {@link ManagedPhoenixConfig.runtimeInstanceId} is + * authoritative for both the join and `handle.metadata` (they must agree). */ + env?: Partial>; /** Join timeout in ms. */ timeoutMs?: number; /** Injectable `WebSocket` ctor (non-global hosts / tests). */ @@ -123,15 +144,19 @@ export async function startManagedBotsOverPhoenix( // the socket already open and never closed (a leak). Validating here means a // bad declaration never opens a connection at all. assertValidBotNames(bots); + // Fail fast before opening a socket for an unsupported multi-bot call. + assertSingleBotForPhase1(bots); // Build activation metadata up front so the join carries the Runtime // Activation data Intelligence's health view expects (runtime env, node // version, per-bot commands) rather than just name+adapter. The same // `envOverrides` is forwarded to startManagedBots so `handle.metadata` agrees - // with what we declared on join. + // with what we declared on join. The required `config.runtimeInstanceId` is + // spread LAST so it stays authoritative even though `config.env` cannot carry + // it (type-excluded) — belt and suspenders for the join↔metadata invariant. const envOverrides: Partial = { - runtimeInstanceId: config.runtimeInstanceId, ...(config.env ?? {}), + runtimeInstanceId: config.runtimeInstanceId, }; const activation = buildActivationMetadata( bots, @@ -170,13 +195,22 @@ export async function startManagedBotsOverPhoenix( ...(config.timeoutMs !== undefined ? { timeoutMs: config.timeoutMs } : {}), ...(config.webSocket !== undefined ? { webSocket: config.webSocket } : {}), }); - const handle = await startManagedBotsOnChannel(bots, { - channel, - scope: config.scope, - runtimeInstanceId: config.runtimeInstanceId, - env: envOverrides, - ...(config.log ? { log: config.log } : {}), - }); + // The channel is now joined. If starting the bots throws (e.g. a bot was + // already started, or a conflicting adapter), the caller never receives a + // handle — so disconnect the socket here rather than leak it, then rethrow. + let handle: ManagedBotsHandle; + try { + handle = await startManagedBotsOnChannel(bots, { + channel, + scope: config.scope, + runtimeInstanceId: config.runtimeInstanceId, + env: envOverrides, + ...(config.log ? { log: config.log } : {}), + }); + } catch (err) { + channel.disconnect(); + throw err; + } return { ...handle, stop: async () => { From 2851d83a8e5ea73be1423bce4508e782882c153c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:49:23 +0000 Subject: [PATCH 13/40] style: auto-fix formatting --- packages/channels-intelligence/src/phoenix-launcher.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/channels-intelligence/src/phoenix-launcher.test.ts b/packages/channels-intelligence/src/phoenix-launcher.test.ts index 97008aa5085..095f1f915b2 100644 --- a/packages/channels-intelligence/src/phoenix-launcher.test.ts +++ b/packages/channels-intelligence/src/phoenix-launcher.test.ts @@ -166,7 +166,9 @@ describe("startManagedBotsOverPhoenix — fail-fast validation (OSS-406)", () => class NeverWebSocket { constructor() { socketConstructed = true; - throw new Error("startManagedBotsOverPhoenix should not have connected"); + throw new Error( + "startManagedBotsOverPhoenix should not have connected", + ); } } const a = createBot({ name: "one", agent: () => new FakeAgent() }); From 3c1b5187d9ca39a195f1785ab841603d58eb9a4e Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Fri, 10 Jul 2026 09:52:10 -0700 Subject: [PATCH 14/40] feat(web-inspector): add threads onboarding motion cues --- .../src/__tests__/web-inspector.spec.ts | 128 ++++++++++- packages/web-inspector/src/index.ts | 203 ++++++++++++++++++ 2 files changed, 330 insertions(+), 1 deletion(-) diff --git a/packages/web-inspector/src/__tests__/web-inspector.spec.ts b/packages/web-inspector/src/__tests__/web-inspector.spec.ts index 5b0956a0da0..afca1eebd77 100644 --- a/packages/web-inspector/src/__tests__/web-inspector.spec.ts +++ b/packages/web-inspector/src/__tests__/web-inspector.spec.ts @@ -24,7 +24,7 @@ type InspectorInternals = { type InspectorThreadViewInternals = { isOpen: boolean; - selectedMenu: "threads"; + selectedMenu: "ag-ui-events" | "threads"; selectedThreadId: string | null; _threads: Array<{ id: string; @@ -1542,6 +1542,10 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { const threadListText = (inspector: WebInspectorElement) => inspector.shadowRoot?.querySelector("cpk-thread-list")?.shadowRoot ?.textContent ?? ""; + const menuButton = (inspector: WebInspectorElement, label: string) => + Array.from( + inspector.shadowRoot?.querySelectorAll("button") ?? [], + ).find((button) => button.textContent?.trim() === label); beforeEach(() => { document.body.innerHTML = ""; @@ -1558,6 +1562,7 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { }); afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -1868,6 +1873,127 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { expect(engineer?.closest("#cpk-main-scroll")).toBeNull(); }); + it("defers loading the empty overview video until after the overview paints", async () => { + vi.useFakeTimers(); + vi.stubGlobal("matchMedia", () => ({ matches: false })); + + const { agent } = createMockAgent("alpha"); + const harness = createHeaderMockCore({ alpha: agent }, {}, {}, false); + + const inspector = new WebInspectorElement(); + document.body.appendChild(inspector); + inspector.core = harness.core as unknown as WebInspectorElement["core"]; + harness.emitAgentsChanged(); + + const internals = inspector as unknown as { + isOpen: boolean; + handleMenuSelect: (key: "threads") => void; + }; + internals.isOpen = true; + internals.handleMenuSelect("threads"); + await inspector.updateComplete; + + expect(inspector.shadowRoot?.textContent ?? "").toContain( + "Threads are persistent, inspectable conversations", + ); + expect( + inspector.shadowRoot?.querySelector(".cpk-threads-overview-video-frame"), + ).not.toBeNull(); + expect( + inspector.shadowRoot?.querySelector(".cpk-threads-overview-video"), + ).toBeNull(); + + await vi.advanceTimersByTimeAsync(450); + await inspector.updateComplete; + + const video = inspector.shadowRoot?.querySelector( + ".cpk-threads-overview-video", + ); + expect(video?.src).toBe( + "https://www.copilotkit.ai/videos/copilotkit-generative-ui-agentic-frontend-demo.webm", + ); + }); + + it("only shimmers the Threads nav tab as a deselected locked or empty cue", async () => { + const renderWith = async ( + core: CopilotKitCore, + setup?: (internals: InspectorThreadViewInternals) => void, + ) => { + const inspector = new WebInspectorElement(); + document.body.appendChild(inspector); + inspector.core = core; + const internals = inspector as unknown as InspectorThreadViewInternals; + internals.isOpen = true; + internals.selectedMenu = "ag-ui-events"; + setup?.(internals); + inspector.requestUpdate(); + await inspector.updateComplete; + return inspector; + }; + + const locked = await renderWith( + createHeaderMockCore({}, {}, { list: false }, false) + .core as unknown as CopilotKitCore, + ); + expect(menuButton(locked, "Threads")?.className).toContain( + "cpk-tab-threads-glimmer", + ); + + menuButton(locked, "Threads")!.click(); + await locked.updateComplete; + expect(menuButton(locked, "Threads")?.className).not.toContain( + "cpk-tab-threads-glimmer", + ); + + const enabledEmpty = await renderWith( + createHeaderMockCore({}, {}, {}, false).core as unknown as CopilotKitCore, + ); + expect(menuButton(enabledEmpty, "Threads")?.className).toContain( + "cpk-tab-threads-glimmer", + ); + + const populated = await renderWith( + createHeaderMockCore({}, {}, {}, false).core as unknown as CopilotKitCore, + (internals) => { + internals._threads = [ + { + id: "real-thread", + name: "Real customer thread", + agentId: "alpha", + updatedAt: "2026-06-25T10:00:00.000Z", + }, + ]; + internals._threadsByAgent = new Map([["alpha", internals._threads]]); + }, + ); + expect(menuButton(populated, "Threads")?.className).not.toContain( + "cpk-tab-threads-glimmer", + ); + + const errored = await renderWith( + createHeaderMockCore({}, {}, {}, false).core as unknown as CopilotKitCore, + (internals) => { + ( + internals as unknown as { + _threadsErrorByAgent: Map; + } + )._threadsErrorByAgent = new Map([ + ["alpha", new Error("Thread list failed")], + ]); + }, + ); + expect(menuButton(errored, "Threads")?.className).not.toContain( + "cpk-tab-threads-glimmer", + ); + + const telemetryDisabled = await renderWith( + createHeaderMockCore({}, {}, {}, true).core as unknown as CopilotKitCore, + ); + expect(menuButton(telemetryDisabled, "Threads")?.className).not.toContain( + "cpk-tab-threads-glimmer", + ); + }); + it("does not render example threads once real threads are present", async () => { const inspector = new WebInspectorElement(); document.body.appendChild(inspector); diff --git a/packages/web-inspector/src/index.ts b/packages/web-inspector/src/index.ts index 38f5fd6e3e2..6a55ba85d14 100644 --- a/packages/web-inspector/src/index.ts +++ b/packages/web-inspector/src/index.ts @@ -123,6 +123,8 @@ const TALK_TO_ENGINEER_URL = "https://www.copilotkit.ai/talk-to-an-engineer"; const THREADS_DOCS_URL = "https://docs.copilotkit.ai/threads"; const SELF_HOSTED_INTELLIGENCE_URL = "https://docs.copilotkit.ai/premium/self-hosting"; +const THREADS_EXAMPLE_OVERVIEW_VIDEO_URL = + "https://www.copilotkit.ai/videos/copilotkit-generative-ui-agentic-frontend-demo.webm"; const THREADS_EXAMPLE_TOUR_STORAGE_KEY = "cpk:inspector:threads-example-tour:v1"; const THREADS_EXAMPLE_AGENT_ID = "threads-feature"; @@ -4204,6 +4206,10 @@ export class WebInspectorElement extends LitElement { private exampleTourActive = false; private exampleTourStep = 0; private exampleTourAutoShown = false; + private threadsExampleOverviewVideoShouldLoad = false; + private threadsExampleOverviewVideoReady = false; + private threadsExampleOverviewVideoLoadTimer: number | null = null; + private threadsExampleOverviewVideoIdleCallbackId: number | null = null; get core(): CopilotKitCore | null { return this._core; @@ -4298,6 +4304,51 @@ export class WebInspectorElement extends LitElement { return this.getThreadServiceStatus() !== "unavailable"; } + private getActiveThreadsState(): { + displayThreads: ɵThread[]; + threadsErrorMessage: string | null; + } { + const displayThreads = + this.selectedContext === "all-agents" + ? this._threads + : (this._threadsByAgent.get(this.selectedContext) ?? []); + + // Surface a thread-store load error inline. For "all-agents" we report + // the first error encountered across all agents (good enough for a + // debugging surface — the per-agent context filter narrows down the + // culprit). For a specific agent we use that agent's error directly. + let threadsErrorMessage: string | null = null; + if (this.selectedContext === "all-agents") { + const firstError = this._threadsErrorByAgent.values().next().value; + threadsErrorMessage = firstError?.message ?? null; + } else { + threadsErrorMessage = + this._threadsErrorByAgent.get(this.selectedContext)?.message ?? null; + } + + return { displayThreads, threadsErrorMessage }; + } + + private shouldShowThreadsNavGlimmer(key: MenuKey): boolean { + if (key !== "threads" || this.selectedMenu === "threads") { + return false; + } + if (this.core?.telemetryDisabled) { + return false; + } + + const threadServiceStatus = this.getThreadServiceStatus(); + if (threadServiceStatus === "unavailable") { + return true; + } + if (threadServiceStatus !== "available") { + return false; + } + + const { displayThreads, threadsErrorMessage } = this.getActiveThreadsState(); + return !threadsErrorMessage && displayThreads.length === 0; + } + private getThreadsTelemetryProps( extra: Partial = {}, options: { includeUrlAttribution?: boolean } = {}, @@ -5971,6 +6022,79 @@ ${argsString} div:first-child button { @@ -6188,6 +6312,17 @@ ${argsString} { + this.threadsExampleOverviewVideoLoadTimer = null; + this.threadsExampleOverviewVideoIdleCallbackId = null; + this.threadsExampleOverviewVideoShouldLoad = true; + this.requestUpdate(); + }; + + if (typeof window.requestIdleCallback === "function") { + this.threadsExampleOverviewVideoIdleCallbackId = + window.requestIdleCallback(loadVideo, { timeout: 1200 }); + return; + } + + this.threadsExampleOverviewVideoLoadTimer = window.setTimeout( + loadVideo, + 450, + ); + } + + private handleThreadsExampleOverviewVideoLoaded = (): void => { + this.threadsExampleOverviewVideoReady = true; + this.requestUpdate(); + }; + + private renderThreadsExampleOverviewVideo() { + this.scheduleThreadsExampleOverviewVideoLoad(); + + return html` + + `; + } + private renderThreadsExampleOverview() { return html`
Threads are persistent, inspectable conversations + ${this.renderThreadsExampleOverviewVideo()}

Date: Fri, 10 Jul 2026 16:53:55 +0000 Subject: [PATCH 15/40] style: auto-fix formatting --- packages/web-inspector/src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/web-inspector/src/index.ts b/packages/web-inspector/src/index.ts index 6a55ba85d14..cc98cd1ae6c 100644 --- a/packages/web-inspector/src/index.ts +++ b/packages/web-inspector/src/index.ts @@ -4345,7 +4345,8 @@ export class WebInspectorElement extends LitElement { return false; } - const { displayThreads, threadsErrorMessage } = this.getActiveThreadsState(); + const { displayThreads, threadsErrorMessage } = + this.getActiveThreadsState(); return !threadsErrorMessage && displayThreads.length === 0; } From b2dda71bb06a2ae114116af3059867db09805dde Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Fri, 10 Jul 2026 10:02:45 -0700 Subject: [PATCH 16/40] fix(web-inspector): remove threads nav sheen --- .../src/__tests__/web-inspector.spec.ts | 84 ------------------- packages/web-inspector/src/index.ts | 68 --------------- 2 files changed, 152 deletions(-) diff --git a/packages/web-inspector/src/__tests__/web-inspector.spec.ts b/packages/web-inspector/src/__tests__/web-inspector.spec.ts index afca1eebd77..4688402c536 100644 --- a/packages/web-inspector/src/__tests__/web-inspector.spec.ts +++ b/packages/web-inspector/src/__tests__/web-inspector.spec.ts @@ -1542,10 +1542,6 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { const threadListText = (inspector: WebInspectorElement) => inspector.shadowRoot?.querySelector("cpk-thread-list")?.shadowRoot ?.textContent ?? ""; - const menuButton = (inspector: WebInspectorElement, label: string) => - Array.from( - inspector.shadowRoot?.querySelectorAll("button") ?? [], - ).find((button) => button.textContent?.trim() === label); beforeEach(() => { document.body.innerHTML = ""; @@ -1914,86 +1910,6 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { ); }); - it("only shimmers the Threads nav tab as a deselected locked or empty cue", async () => { - const renderWith = async ( - core: CopilotKitCore, - setup?: (internals: InspectorThreadViewInternals) => void, - ) => { - const inspector = new WebInspectorElement(); - document.body.appendChild(inspector); - inspector.core = core; - const internals = inspector as unknown as InspectorThreadViewInternals; - internals.isOpen = true; - internals.selectedMenu = "ag-ui-events"; - setup?.(internals); - inspector.requestUpdate(); - await inspector.updateComplete; - return inspector; - }; - - const locked = await renderWith( - createHeaderMockCore({}, {}, { list: false }, false) - .core as unknown as CopilotKitCore, - ); - expect(menuButton(locked, "Threads")?.className).toContain( - "cpk-tab-threads-glimmer", - ); - - menuButton(locked, "Threads")!.click(); - await locked.updateComplete; - expect(menuButton(locked, "Threads")?.className).not.toContain( - "cpk-tab-threads-glimmer", - ); - - const enabledEmpty = await renderWith( - createHeaderMockCore({}, {}, {}, false).core as unknown as CopilotKitCore, - ); - expect(menuButton(enabledEmpty, "Threads")?.className).toContain( - "cpk-tab-threads-glimmer", - ); - - const populated = await renderWith( - createHeaderMockCore({}, {}, {}, false).core as unknown as CopilotKitCore, - (internals) => { - internals._threads = [ - { - id: "real-thread", - name: "Real customer thread", - agentId: "alpha", - updatedAt: "2026-06-25T10:00:00.000Z", - }, - ]; - internals._threadsByAgent = new Map([["alpha", internals._threads]]); - }, - ); - expect(menuButton(populated, "Threads")?.className).not.toContain( - "cpk-tab-threads-glimmer", - ); - - const errored = await renderWith( - createHeaderMockCore({}, {}, {}, false).core as unknown as CopilotKitCore, - (internals) => { - ( - internals as unknown as { - _threadsErrorByAgent: Map; - } - )._threadsErrorByAgent = new Map([ - ["alpha", new Error("Thread list failed")], - ]); - }, - ); - expect(menuButton(errored, "Threads")?.className).not.toContain( - "cpk-tab-threads-glimmer", - ); - - const telemetryDisabled = await renderWith( - createHeaderMockCore({}, {}, {}, true).core as unknown as CopilotKitCore, - ); - expect(menuButton(telemetryDisabled, "Threads")?.className).not.toContain( - "cpk-tab-threads-glimmer", - ); - }); - it("does not render example threads once real threads are present", async () => { const inspector = new WebInspectorElement(); document.body.appendChild(inspector); diff --git a/packages/web-inspector/src/index.ts b/packages/web-inspector/src/index.ts index cc98cd1ae6c..8e9d31dce21 100644 --- a/packages/web-inspector/src/index.ts +++ b/packages/web-inspector/src/index.ts @@ -4329,27 +4329,6 @@ export class WebInspectorElement extends LitElement { return { displayThreads, threadsErrorMessage }; } - private shouldShowThreadsNavGlimmer(key: MenuKey): boolean { - if (key !== "threads" || this.selectedMenu === "threads") { - return false; - } - if (this.core?.telemetryDisabled) { - return false; - } - - const threadServiceStatus = this.getThreadServiceStatus(); - if (threadServiceStatus === "unavailable") { - return true; - } - if (threadServiceStatus !== "available") { - return false; - } - - const { displayThreads, threadsErrorMessage } = - this.getActiveThreadsState(); - return !threadsErrorMessage && displayThreads.length === 0; - } - private getThreadsTelemetryProps( extra: Partial = {}, options: { includeUrlAttribution?: boolean } = {}, @@ -6023,50 +6002,6 @@ ${argsString} Date: Fri, 10 Jul 2026 19:06:44 +0200 Subject: [PATCH 17/40] fix(channels-intelligence): claim deliveries provider-agnostically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The managed runtime's claimOnce() declared `adapters: [this.cfg.adapter]` (defaulting to "slack"), which Intelligence used to filter claimable deliveries by provider. A runtime serving a bot with both Slack and Teams adapters would therefore never receive the bot's Teams deliveries — they stayed queued forever while Slack worked. The managed runtime is provider-agnostic: it emits abstract render frames and Intelligence renders per the delivery's own reply target. So the claim must not filter by provider. Drop the adapter filter from the claim body; one config-free `intelligenceAdapter()` now serves every channel its bot has attached. Verified end-to-end locally against managed Teams: inbound JWT -> claim -> agent -> render -> Bot Connector egress all succeed with this change. --- packages/channels-intelligence/src/http-transports.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/channels-intelligence/src/http-transports.ts b/packages/channels-intelligence/src/http-transports.ts index 8ab50e66e8e..c8f9bc6193d 100644 --- a/packages/channels-intelligence/src/http-transports.ts +++ b/packages/channels-intelligence/src/http-transports.ts @@ -376,8 +376,13 @@ export class HttpDeliverySource implements DeliverySource { const res = await this.http.post( "/api/bots/listener/claim", { + // Claim provider-agnostically: the managed runtime emits abstract render + // frames and Intelligence renders per the delivery's own reply target, so + // a single runtime serves every channel its bot has attached (Slack, + // Teams, ...). Declaring a single adapter here made Intelligence withhold + // deliveries for the bot's other channels (e.g. a Slack-declared runtime + // never received the same bot's Teams messages). runtimeInstanceId: this.cfg.runtimeInstanceId, - adapters: [this.cfg.adapter], }, ); if (!res.claimed) return { pollAfterMs: res.pollAfterMs ?? 1000 }; From bfa9df05bb7bec41fcd9a5cc7d71e66acfbe32f3 Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Fri, 10 Jul 2026 10:23:03 -0700 Subject: [PATCH 18/40] fix(web-inspector): use CDN threads overview video --- packages/web-inspector/src/__tests__/web-inspector.spec.ts | 2 +- packages/web-inspector/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web-inspector/src/__tests__/web-inspector.spec.ts b/packages/web-inspector/src/__tests__/web-inspector.spec.ts index 4688402c536..072b1accadf 100644 --- a/packages/web-inspector/src/__tests__/web-inspector.spec.ts +++ b/packages/web-inspector/src/__tests__/web-inspector.spec.ts @@ -1906,7 +1906,7 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { ".cpk-threads-overview-video", ); expect(video?.src).toBe( - "https://www.copilotkit.ai/videos/copilotkit-generative-ui-agentic-frontend-demo.webm", + "https://cdn.copilotkit.ai/corp-site/videos/copilotkit-generative-ui-agentic-frontend-demo.webm", ); }); diff --git a/packages/web-inspector/src/index.ts b/packages/web-inspector/src/index.ts index 8e9d31dce21..5f349c58f19 100644 --- a/packages/web-inspector/src/index.ts +++ b/packages/web-inspector/src/index.ts @@ -124,7 +124,7 @@ const THREADS_DOCS_URL = "https://docs.copilotkit.ai/threads"; const SELF_HOSTED_INTELLIGENCE_URL = "https://docs.copilotkit.ai/premium/self-hosting"; const THREADS_EXAMPLE_OVERVIEW_VIDEO_URL = - "https://www.copilotkit.ai/videos/copilotkit-generative-ui-agentic-frontend-demo.webm"; + "https://cdn.copilotkit.ai/corp-site/videos/copilotkit-generative-ui-agentic-frontend-demo.webm"; const THREADS_EXAMPLE_TOUR_STORAGE_KEY = "cpk:inspector:threads-example-tour:v1"; const THREADS_EXAMPLE_AGENT_ID = "threads-feature"; From 57ddcb953226b02b01c32c9ea8a23923759ea944 Mon Sep 17 00:00:00 2001 From: Benjamin Taylor Date: Fri, 10 Jul 2026 12:31:23 -0500 Subject: [PATCH 19/40] fix(channels-intelligence): enforce runtimeInstanceId on OnChannel + leak-path tests + fail-loud managed entrypoint (OSS-406 review r3) --- examples/slack/app/managed.ts | 37 ++++- .../src/phoenix-launcher.test.ts | 150 ++++++++++++++++++ .../src/phoenix-launcher.ts | 16 +- 3 files changed, 194 insertions(+), 9 deletions(-) diff --git a/examples/slack/app/managed.ts b/examples/slack/app/managed.ts index ac3affcde7d..09ce7526409 100644 --- a/examples/slack/app/managed.ts +++ b/examples/slack/app/managed.ts @@ -139,12 +139,39 @@ async function main() { const shutdown = async (signal: string) => { console.log(`\n[bot] received ${signal}, stopping…`); - await handle.stop(); - await closeBrowser().catch(() => {}); + try { + await handle.stop(); + } catch (err) { + console.error("[bot] error stopping managed runtime", err); + } + // Browser teardown is best-effort, but still surface a failure rather than + // swallow it silently. + await closeBrowser().catch((err: unknown) => + console.error("[bot] browser cleanup failed (continuing shutdown)", err), + ); process.exit(0); }; - process.on("SIGINT", () => void shutdown("SIGINT")); - process.on("SIGTERM", () => void shutdown("SIGTERM")); + // A failed shutdown must not vanish — log it and exit nonzero. + 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")); } -void main(); +// Fail loud, not silent: surface any stray async error instead of letting it +// kill the process with no log (mirrors the native entrypoint). +process.on("unhandledRejection", (reason) => { + console.error("[bot] unhandledRejection:", reason); +}); +process.on("uncaughtException", (err) => { + console.error("[bot] uncaughtException:", err); +}); + +main().catch((err: unknown) => { + console.error("[bot] fatal: failed to start managed runtime", err); + process.exit(1); +}); diff --git a/packages/channels-intelligence/src/phoenix-launcher.test.ts b/packages/channels-intelligence/src/phoenix-launcher.test.ts index 095f1f915b2..fc16ab4463c 100644 --- a/packages/channels-intelligence/src/phoenix-launcher.test.ts +++ b/packages/channels-intelligence/src/phoenix-launcher.test.ts @@ -220,4 +220,154 @@ describe("startManagedBotsOnChannel — activation metadata (OSS-406)", () => { await handle.stop(); }); + + it("keeps the required runtimeInstanceId authoritative in handle.metadata", async () => { + const fake = makeFakeChannel(); + const bot = createBot({ name: "opentag", agent: () => new FakeAgent() }); + + const handle = await startManagedBotsOnChannel([bot], { + channel: fake.channel, + scope, + runtimeInstanceId: "rti_authoritative", + env: { runtimeEnv: "staging" }, + }); + + expect(handle.metadata.runtimeInstanceId).toBe("rti_authoritative"); + + await handle.stop(); + }); +}); + +/** + * Minimal Phoenix-compatible fake WebSocket that drives the v2-serializer join + * handshake so the connector's error/timeout cleanup can be exercised without a + * real gateway. `mode` controls the phx_join reply: "ok" → joined, "error" → + * rejected, "never" → no reply (the channel join times out). Records `close()` + * so a test can assert the socket was torn down. + */ +type JoinMode = "ok" | "error" | "never"; +function makeFakeWebSocket(mode: JoinMode) { + const instances: FakeWebSocket[] = []; + class FakeWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + readyState = 0; + onopen: ((ev?: unknown) => void) | null = null; + onmessage: ((ev: { data: string }) => void) | null = null; + onerror: ((ev?: unknown) => void) | null = null; + onclose: ((ev?: unknown) => void) | null = null; + closed = false; + constructor(public readonly url: string) { + instances.push(this); + queueMicrotask(() => { + this.readyState = 1; + this.onopen?.(); + }); + } + send(data: string): void { + if (mode === "never") return; + let frame: unknown; + try { + frame = JSON.parse(data); + } catch { + return; + } + if (!Array.isArray(frame)) return; + const [joinRef, ref, topic, event] = frame as [ + string, + string, + string, + string, + ]; + if (event !== "phx_join") return; + const status = mode === "ok" ? "ok" : "error"; + const response = mode === "ok" ? {} : { reason: "unauthorized" }; + const reply = JSON.stringify([ + joinRef, + ref, + topic, + "phx_reply", + { status, response }, + ]); + queueMicrotask(() => this.onmessage?.({ data: reply })); + } + close(): void { + this.closed = true; + this.readyState = 3; + this.onclose?.(); + } + } + return { FakeWebSocket, instances }; +} + +describe("startManagedBotsOverPhoenix — socket lifecycle cleanup (OSS-406)", () => { + it("disconnects the socket when the channel join times out", async () => { + const { FakeWebSocket, instances } = makeFakeWebSocket("never"); + const bot = createBot({ name: "opentag", agent: () => new FakeAgent() }); + + await expect( + startManagedBotsOverPhoenix([bot], { + wsUrl: "wss://gateway.example/socket", + apiKey: "cpk-test", + scope, + runtimeInstanceId: "rti_1", + webSocket: FakeWebSocket, + timeoutMs: 20, + }), + ).rejects.toThrow(/timed out/i); + + expect(instances.length).toBe(1); + expect(instances[0]!.closed).toBe(true); + }); + + it("disconnects the socket when the channel join is rejected", async () => { + const { FakeWebSocket, instances } = makeFakeWebSocket("error"); + const bot = createBot({ name: "opentag", agent: () => new FakeAgent() }); + + await expect( + startManagedBotsOverPhoenix([bot], { + wsUrl: "wss://gateway.example/socket", + apiKey: "cpk-test", + scope, + runtimeInstanceId: "rti_1", + webSocket: FakeWebSocket, + timeoutMs: 1000, + }), + ).rejects.toThrow(/join failed/i); + + expect(instances.length).toBe(1); + expect(instances[0]!.closed).toBe(true); + }); + + it("disconnects the socket when bot startup fails after the channel joined", async () => { + const { FakeWebSocket, instances } = makeFakeWebSocket("ok"); + // Pre-start the bot so startManagedBots' addAdapter() throws ("adapter added + // after start") during the post-join startup — the exact failure the + // launcher's try/catch must clean up after. + const started = makeFakeChannel(); + const bot = createBot({ name: "opentag", agent: () => new FakeAgent() }); + const first = await startManagedBotsOnChannel([bot], { + channel: started.channel, + scope, + runtimeInstanceId: "rti_pre", + }); + + await expect( + startManagedBotsOverPhoenix([bot], { + wsUrl: "wss://gateway.example/socket", + apiKey: "cpk-test", + scope, + runtimeInstanceId: "rti_1", + webSocket: FakeWebSocket, + timeoutMs: 1000, + }), + ).rejects.toThrow(); + + expect(instances.length).toBe(1); + expect(instances[0]!.closed).toBe(true); + + await first.stop(); + }); }); diff --git a/packages/channels-intelligence/src/phoenix-launcher.ts b/packages/channels-intelligence/src/phoenix-launcher.ts index 0d83d6ffb5e..a9f2679a8fb 100644 --- a/packages/channels-intelligence/src/phoenix-launcher.ts +++ b/packages/channels-intelligence/src/phoenix-launcher.ts @@ -58,8 +58,11 @@ export interface ManagedBotsOnChannelOptions { runtimeInstanceId: string; /** Activation env overrides forwarded to the runtime (so `handle.metadata` * matches what the caller declared on join); omitted fields are gathered from - * the process. */ - env?: Partial; + * the process. `runtimeInstanceId` is excluded — the required + * {@link ManagedBotsOnChannelOptions.runtimeInstanceId} above is authoritative + * and is merged in, so the transport (which stamps it on every envelope) and + * `handle.metadata` always report the same id. */ + env?: Partial>; /** Diagnostic sink for dropped deliveries / transport events. */ log?: (message: string, meta?: unknown) => void; } @@ -93,7 +96,10 @@ export async function startManagedBotsOnChannel( renderSink: transport, egress: phoenixEgress, }), - ...(opts.env ? { env: opts.env } : {}), + // The required runtimeInstanceId is authoritative: merge it in LAST so + // `handle.metadata` reports the same id the transport stamps on every + // envelope, regardless of any `env` overrides (which cannot carry it). + env: { ...(opts.env ?? {}), runtimeInstanceId: opts.runtimeInstanceId }, }); } @@ -204,7 +210,9 @@ export async function startManagedBotsOverPhoenix( channel, scope: config.scope, runtimeInstanceId: config.runtimeInstanceId, - env: envOverrides, + // startManagedBotsOnChannel re-merges the authoritative runtimeInstanceId, + // so forward only the caller's overrides here (they cannot carry the id). + ...(config.env ? { env: config.env } : {}), ...(config.log ? { log: config.log } : {}), }); } catch (err) { From 779bb3df8d6f5199b6447f381db699ace54dc5eb Mon Sep 17 00:00:00 2001 From: Tyler Slaton Date: Fri, 10 Jul 2026 11:08:29 -0700 Subject: [PATCH 20/40] fix(examples/slack): exit nonzero when managed shutdown fails --- examples/slack/app/managed.test.ts | 93 ++++++++++++++++++++++++++++++ examples/slack/app/managed.ts | 4 +- 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 examples/slack/app/managed.test.ts diff --git a/examples/slack/app/managed.test.ts b/examples/slack/app/managed.test.ts new file mode 100644 index 00000000000..2f085559a4d --- /dev/null +++ b/examples/slack/app/managed.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const fakes = vi.hoisted(() => { + const stop = vi.fn(async () => { + throw new Error("stop failed"); + }); + return { + stop, + closeBrowser: vi.fn(async () => {}), + startManagedBotsOverPhoenix: vi.fn(async () => ({ stop })), + bot: { + onMention: vi.fn(), + onModalSubmit: vi.fn(), + onThreadStarted: vi.fn(), + }, + }; +}); + +vi.mock("@copilotkit/channels", () => ({ + createBot: vi.fn(() => fakes.bot), +})); +vi.mock("@copilotkit/channels-slack", () => ({ + defaultSlackTools: [], + defaultSlackContext: [], + SanitizingHttpAgent: class {}, +})); +vi.mock("@copilotkit/channels-intelligence", () => ({ + startManagedBotsOverPhoenix: fakes.startManagedBotsOverPhoenix, +})); +vi.mock("./tools/index.js", () => ({ appTools: [] })); +vi.mock("./context/app-context.js", () => ({ appContext: [] })); +vi.mock("./commands/index.js", () => ({ appCommands: [] })); +vi.mock("./sender-context.js", () => ({ senderContext: vi.fn() })); +vi.mock("./modals/file-issue.js", () => ({ + fileIssueSubmit: vi.fn(), + FILE_ISSUE_CALLBACK: "file-issue", +})); +vi.mock("./render/browser.js", () => ({ closeBrowser: fakes.closeBrowser })); + +const envKeys = [ + "AGENT_URL", + "INTELLIGENCE_PROJECT_ID", + "INTELLIGENCE_BOT_NAME", + "INTELLIGENCE_GATEWAY_WS_URL", + "INTELLIGENCE_API_KEY", + "INTELLIGENCE_ORG_ID", + "INTELLIGENCE_BOT_ID", +] as const; + +describe("managed entrypoint shutdown", () => { + const previousEnv = new Map(); + + afterEach(() => { + vi.restoreAllMocks(); + for (const key of envKeys) { + const value = previousEnv.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + previousEnv.clear(); + }); + + it("exits nonzero when stopping the managed runtime fails", async () => { + for (const key of envKeys) previousEnv.set(key, process.env[key]); + process.env.AGENT_URL = "http://agent.test/run"; + process.env.INTELLIGENCE_PROJECT_ID = "7"; + process.env.INTELLIGENCE_BOT_NAME = "opentag"; + process.env.INTELLIGENCE_GATEWAY_WS_URL = "wss://gateway.test/runner"; + process.env.INTELLIGENCE_API_KEY = "cpk-test"; + process.env.INTELLIGENCE_ORG_ID = "org_1"; + process.env.INTELLIGENCE_BOT_ID = "bot_1"; + + let sigterm: (() => void) | undefined; + vi.spyOn(process, "on").mockImplementation(((event, listener) => { + if (event === "SIGTERM") sigterm = listener as () => void; + return process; + }) as typeof process.on); + const exit = vi + .spyOn(process, "exit") + .mockImplementation((() => undefined as never) as typeof process.exit); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await import("./managed.js"); + await vi.waitFor(() => expect(sigterm).toBeTypeOf("function")); + sigterm!(); + await vi.waitFor(() => expect(exit).toHaveBeenCalled()); + + expect(fakes.stop).toHaveBeenCalledOnce(); + expect(fakes.closeBrowser).toHaveBeenCalledOnce(); + expect(exit).toHaveBeenCalledWith(1); + }); +}); diff --git a/examples/slack/app/managed.ts b/examples/slack/app/managed.ts index 09ce7526409..a43f00a6c72 100644 --- a/examples/slack/app/managed.ts +++ b/examples/slack/app/managed.ts @@ -139,17 +139,19 @@ async function main() { const shutdown = async (signal: string) => { console.log(`\n[bot] received ${signal}, stopping…`); + let exitCode = 0; try { await handle.stop(); } catch (err) { console.error("[bot] error stopping managed runtime", err); + exitCode = 1; } // Browser teardown is best-effort, but still surface a failure rather than // swallow it silently. await closeBrowser().catch((err: unknown) => console.error("[bot] browser cleanup failed (continuing shutdown)", err), ); - process.exit(0); + process.exit(exitCode); }; // A failed shutdown must not vanish — log it and exit nonzero. const runShutdown = (signal: string): void => { From 81c1740726d5a1751ed22202296135da636de2a2 Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Fri, 10 Jul 2026 11:32:43 -0700 Subject: [PATCH 21/40] fix(web-inspector): add inspector utm attribution --- .../src/__tests__/web-inspector.spec.ts | 35 ++++++++++-- packages/web-inspector/src/index.ts | 53 +++++++++++++++++-- 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/packages/web-inspector/src/__tests__/web-inspector.spec.ts b/packages/web-inspector/src/__tests__/web-inspector.spec.ts index 072b1accadf..e5c4d7d4d68 100644 --- a/packages/web-inspector/src/__tests__/web-inspector.spec.ts +++ b/packages/web-inspector/src/__tests__/web-inspector.spec.ts @@ -1542,6 +1542,11 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { const threadListText = (inspector: WebInspectorElement) => inspector.shadowRoot?.querySelector("cpk-thread-list")?.shadowRoot ?.textContent ?? ""; + const expectThreadsOnboardingUtmParams = (url: URL) => { + expect(url.searchParams.get("utm_source")).toBe("copilotkit_inspector"); + expect(url.searchParams.get("utm_medium")).toBe("in_product"); + expect(url.searchParams.get("utm_campaign")).toBe("threads_onboarding"); + }; beforeEach(() => { document.body.innerHTML = ""; @@ -1721,7 +1726,7 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { ).toBe(false); }); - it("adds ref and posthog distinct ID attribution to locked-state CTAs", async () => { + it("adds inspector attribution to locked-state CTAs", async () => { const { agent } = createMockAgent("alpha"); const harness = createHeaderMockCore( { alpha: agent }, @@ -1755,6 +1760,7 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { const signupUrl = new URL(signup!.href); expect(signupUrl.searchParams.get("ref")).toBe("cpk-inspector"); + expectThreadsOnboardingUtmParams(signupUrl); const distinctId = signupUrl.searchParams.get("posthog_distinct_id"); expect(distinctId).toMatch(/^[0-9a-f-]{36}$/); @@ -1762,6 +1768,7 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { expect(engineerUrl.origin).toBe("https://www.copilotkit.ai"); expect(engineerUrl.pathname).toBe("/talk-to-an-engineer"); expect(engineerUrl.searchParams.get("ref")).toBe("cpk-inspector-threads"); + expectThreadsOnboardingUtmParams(engineerUrl); expect(engineerUrl.searchParams.get("posthog_distinct_id")).toBe( distinctId, ); @@ -1842,15 +1849,29 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { "Take a tour with the example threads in the sidebar.", ); const threadsDocs = inspector.shadowRoot?.querySelector( - 'a[href="https://docs.copilotkit.ai/threads"]', + 'a[href^="https://docs.copilotkit.ai/threads"]', ); expect(threadsDocs?.textContent?.trim()).toBe("Learn how Threads work"); + const threadsDocsUrl = new URL(threadsDocs!.href); + expect(threadsDocsUrl.origin).toBe("https://docs.copilotkit.ai"); + expect(threadsDocsUrl.pathname).toBe("/threads"); + expect(threadsDocsUrl.searchParams.get("ref")).toBe( + "cpk-inspector-threads", + ); + expectThreadsOnboardingUtmParams(threadsDocsUrl); const selfHosted = inspector.shadowRoot?.querySelector( - 'a[href="https://docs.copilotkit.ai/premium/self-hosting"]', + 'a[href^="https://docs.copilotkit.ai/premium/self-hosting"]', ); expect(selfHosted?.textContent?.trim()).toBe( "Explore self-hosted Intelligence", ); + const selfHostedUrl = new URL(selfHosted!.href); + expect(selfHostedUrl.origin).toBe("https://docs.copilotkit.ai"); + expect(selfHostedUrl.pathname).toBe("/premium/self-hosting"); + expect(selfHostedUrl.searchParams.get("ref")).toBe( + "cpk-inspector-threads", + ); + expectThreadsOnboardingUtmParams(selfHostedUrl); expect(threadListText(inspector)).toContain("Example"); expect(text).not.toContain("No threads yet"); expect( @@ -1863,9 +1884,13 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { const engineer = inspector.shadowRoot?.querySelector( 'a[href^="https://www.copilotkit.ai/talk-to-an-engineer"]', ); - expect(engineer?.href).toBe( - "https://www.copilotkit.ai/talk-to-an-engineer?ref=cpk-inspector-threads", + const engineerUrl = new URL(engineer!.href); + expect(engineerUrl.origin).toBe("https://www.copilotkit.ai"); + expect(engineerUrl.pathname).toBe("/talk-to-an-engineer"); + expect(engineerUrl.searchParams.get("ref")).toBe( + "cpk-inspector-threads", ); + expectThreadsOnboardingUtmParams(engineerUrl); expect(engineer?.closest("#cpk-main-scroll")).toBeNull(); }); diff --git a/packages/web-inspector/src/index.ts b/packages/web-inspector/src/index.ts index 5f349c58f19..3b772d101f1 100644 --- a/packages/web-inspector/src/index.ts +++ b/packages/web-inspector/src/index.ts @@ -128,6 +128,11 @@ const THREADS_EXAMPLE_OVERVIEW_VIDEO_URL = const THREADS_EXAMPLE_TOUR_STORAGE_KEY = "cpk:inspector:threads-example-tour:v1"; const THREADS_EXAMPLE_AGENT_ID = "threads-feature"; +const INSPECTOR_UTM_PARAMS = { + utm_source: "copilotkit_inspector", + utm_medium: "in_product", + utm_campaign: "threads_onboarding", +} as const; type ThreadServiceStatus = "available" | "unavailable" | "unknown" | "error"; @@ -4367,11 +4372,31 @@ export class WebInspectorElement extends LitElement { } private getIntelligenceSignupUrl(): string { - return this.appendRefParam(INTELLIGENCE_SIGNUP_URL, "cpk-inspector"); + return this.appendThreadsOnboardingAttribution( + INTELLIGENCE_SIGNUP_URL, + "cpk-inspector", + ); } private getTalkToEngineerUrl(): string { - return this.appendRefParam(TALK_TO_ENGINEER_URL, "cpk-inspector-threads"); + return this.appendThreadsOnboardingAttribution( + TALK_TO_ENGINEER_URL, + "cpk-inspector-threads", + ); + } + + private getThreadsDocsUrl(): string { + return this.appendThreadsOnboardingAttribution( + THREADS_DOCS_URL, + "cpk-inspector-threads", + ); + } + + private getSelfHostedIntelligenceUrl(): string { + return this.appendThreadsOnboardingAttribution( + SELF_HOSTED_INTELLIGENCE_URL, + "cpk-inspector-threads", + ); } private subscribeToThreadStore(agentId: string, store: ɵThreadStore): void { @@ -8231,7 +8256,7 @@ ${argsString}

>, + ): string { try { const isRootRelative = href.startsWith("/") && !href.startsWith("//"); const url = new URL( @@ -11017,6 +11053,13 @@ ${prettyEvent} Date: Fri, 10 Jul 2026 18:33:39 +0000 Subject: [PATCH 22/40] style: auto-fix formatting --- .../web-inspector/src/__tests__/web-inspector.spec.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/web-inspector/src/__tests__/web-inspector.spec.ts b/packages/web-inspector/src/__tests__/web-inspector.spec.ts index e5c4d7d4d68..2d3ff8c4d49 100644 --- a/packages/web-inspector/src/__tests__/web-inspector.spec.ts +++ b/packages/web-inspector/src/__tests__/web-inspector.spec.ts @@ -1868,9 +1868,7 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { const selfHostedUrl = new URL(selfHosted!.href); expect(selfHostedUrl.origin).toBe("https://docs.copilotkit.ai"); expect(selfHostedUrl.pathname).toBe("/premium/self-hosting"); - expect(selfHostedUrl.searchParams.get("ref")).toBe( - "cpk-inspector-threads", - ); + expect(selfHostedUrl.searchParams.get("ref")).toBe("cpk-inspector-threads"); expectThreadsOnboardingUtmParams(selfHostedUrl); expect(threadListText(inspector)).toContain("Example"); expect(text).not.toContain("No threads yet"); @@ -1887,9 +1885,7 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { const engineerUrl = new URL(engineer!.href); expect(engineerUrl.origin).toBe("https://www.copilotkit.ai"); expect(engineerUrl.pathname).toBe("/talk-to-an-engineer"); - expect(engineerUrl.searchParams.get("ref")).toBe( - "cpk-inspector-threads", - ); + expect(engineerUrl.searchParams.get("ref")).toBe("cpk-inspector-threads"); expectThreadsOnboardingUtmParams(engineerUrl); expect(engineer?.closest("#cpk-main-scroll")).toBeNull(); }); From 852d41e176a80cadb788912eed1a0a185b24002a Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Fri, 10 Jul 2026 11:38:45 -0700 Subject: [PATCH 23/40] fix(web-inspector): scope threads utm links --- .../src/__tests__/web-inspector.spec.ts | 23 +++++++++++++++++++ packages/web-inspector/src/index.ts | 12 ++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/web-inspector/src/__tests__/web-inspector.spec.ts b/packages/web-inspector/src/__tests__/web-inspector.spec.ts index 2d3ff8c4d49..2f54d13cdcb 100644 --- a/packages/web-inspector/src/__tests__/web-inspector.spec.ts +++ b/packages/web-inspector/src/__tests__/web-inspector.spec.ts @@ -2462,6 +2462,29 @@ describe("WebInspectorElement memories — view states", () => { ).toBeNull(); }); + it("does not use Threads onboarding UTM attribution for locked memory CTAs", async () => { + const core = makeCoreNoIntelligence(); + const el = await mountMemories(core); + + const talkToEngineer = el.shadowRoot?.querySelector( + 'a[href^="https://www.copilotkit.ai/talk-to-an-engineer"]', + ); + const signup = el.shadowRoot?.querySelector( + 'a[href^="https://go.copilotkit.ai/intelligence-signup"]', + ); + + expect(talkToEngineer).not.toBeNull(); + expect(signup).not.toBeNull(); + + for (const href of [talkToEngineer!.href, signup!.href]) { + const url = new URL(href); + expect(url.searchParams.get("ref")).toBeTruthy(); + expect(url.searchParams.has("utm_source")).toBe(false); + expect(url.searchParams.has("utm_medium")).toBe(false); + expect(url.searchParams.has("utm_campaign")).toBe(false); + } + }); + it("renders the locked teaser when memories are unavailable", async () => { const core = makeCoreWithMemory([], { available: false }); const el = await mountMemories(core); diff --git a/packages/web-inspector/src/index.ts b/packages/web-inspector/src/index.ts index 3b772d101f1..15210266196 100644 --- a/packages/web-inspector/src/index.ts +++ b/packages/web-inspector/src/index.ts @@ -4372,6 +4372,10 @@ export class WebInspectorElement extends LitElement { } private getIntelligenceSignupUrl(): string { + return this.appendRefParam(INTELLIGENCE_SIGNUP_URL, "cpk-inspector"); + } + + private getThreadsIntelligenceSignupUrl(): string { return this.appendThreadsOnboardingAttribution( INTELLIGENCE_SIGNUP_URL, "cpk-inspector", @@ -4379,6 +4383,10 @@ export class WebInspectorElement extends LitElement { } private getTalkToEngineerUrl(): string { + return this.appendRefParam(TALK_TO_ENGINEER_URL, "cpk-inspector-threads"); + } + + private getThreadsTalkToEngineerUrl(): string { return this.appendThreadsOnboardingAttribution( TALK_TO_ENGINEER_URL, "cpk-inspector-threads", @@ -6557,7 +6565,7 @@ ${argsString} { await inspector.updateComplete; const signup = inspector.shadowRoot?.querySelector( - 'a[href^="https://go.copilotkit.ai/intelligence-signup"]', + 'a[href^="https://dashboard.operations.copilotkit.ai/sign-in"]', ); const engineer = inspector.shadowRoot?.querySelector( 'a[href^="https://www.copilotkit.ai/talk-to-an-engineer"]', @@ -1759,6 +1759,8 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { expect(engineer).not.toBeNull(); const signupUrl = new URL(signup!.href); + expect(signupUrl.origin).toBe("https://dashboard.operations.copilotkit.ai"); + expect(signupUrl.pathname).toBe("/sign-in"); expect(signupUrl.searchParams.get("ref")).toBe("cpk-inspector"); expectThreadsOnboardingUtmParams(signupUrl); const distinctId = signupUrl.searchParams.get("posthog_distinct_id"); diff --git a/packages/web-inspector/src/index.ts b/packages/web-inspector/src/index.ts index 15210266196..d391f216658 100644 --- a/packages/web-inspector/src/index.ts +++ b/packages/web-inspector/src/index.ts @@ -119,6 +119,8 @@ const DOCKED_LEFT_WIDTH = 500; // Sensible width for left dock with collapsed si const MAX_AGENT_EVENTS = 200; const MAX_TOTAL_EVENTS = 500; const INTELLIGENCE_SIGNUP_URL = "https://go.copilotkit.ai/intelligence-signup"; +const THREADS_INTELLIGENCE_SIGNIN_URL = + "https://dashboard.operations.copilotkit.ai/sign-in"; const TALK_TO_ENGINEER_URL = "https://www.copilotkit.ai/talk-to-an-engineer"; const THREADS_DOCS_URL = "https://docs.copilotkit.ai/threads"; const SELF_HOSTED_INTELLIGENCE_URL = @@ -4377,7 +4379,7 @@ export class WebInspectorElement extends LitElement { private getThreadsIntelligenceSignupUrl(): string { return this.appendThreadsOnboardingAttribution( - INTELLIGENCE_SIGNUP_URL, + THREADS_INTELLIGENCE_SIGNIN_URL, "cpk-inspector", ); } From b11fd7d8d04600e479ea299bb7223b08a729f5cf Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Fri, 10 Jul 2026 12:13:07 -0700 Subject: [PATCH 25/40] fix(web-inspector): remove threads cta utms --- .../src/__tests__/web-inspector.spec.ts | 18 +++++----- packages/web-inspector/src/index.ts | 33 +++---------------- 2 files changed, 14 insertions(+), 37 deletions(-) diff --git a/packages/web-inspector/src/__tests__/web-inspector.spec.ts b/packages/web-inspector/src/__tests__/web-inspector.spec.ts index c616b0168ec..ac400caf53b 100644 --- a/packages/web-inspector/src/__tests__/web-inspector.spec.ts +++ b/packages/web-inspector/src/__tests__/web-inspector.spec.ts @@ -1542,10 +1542,10 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { const threadListText = (inspector: WebInspectorElement) => inspector.shadowRoot?.querySelector("cpk-thread-list")?.shadowRoot ?.textContent ?? ""; - const expectThreadsOnboardingUtmParams = (url: URL) => { - expect(url.searchParams.get("utm_source")).toBe("copilotkit_inspector"); - expect(url.searchParams.get("utm_medium")).toBe("in_product"); - expect(url.searchParams.get("utm_campaign")).toBe("threads_onboarding"); + const expectNoUtmParams = (url: URL) => { + expect(url.searchParams.has("utm_source")).toBe(false); + expect(url.searchParams.has("utm_medium")).toBe(false); + expect(url.searchParams.has("utm_campaign")).toBe(false); }; beforeEach(() => { @@ -1762,7 +1762,7 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { expect(signupUrl.origin).toBe("https://dashboard.operations.copilotkit.ai"); expect(signupUrl.pathname).toBe("/sign-in"); expect(signupUrl.searchParams.get("ref")).toBe("cpk-inspector"); - expectThreadsOnboardingUtmParams(signupUrl); + expectNoUtmParams(signupUrl); const distinctId = signupUrl.searchParams.get("posthog_distinct_id"); expect(distinctId).toMatch(/^[0-9a-f-]{36}$/); @@ -1770,7 +1770,7 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { expect(engineerUrl.origin).toBe("https://www.copilotkit.ai"); expect(engineerUrl.pathname).toBe("/talk-to-an-engineer"); expect(engineerUrl.searchParams.get("ref")).toBe("cpk-inspector-threads"); - expectThreadsOnboardingUtmParams(engineerUrl); + expectNoUtmParams(engineerUrl); expect(engineerUrl.searchParams.get("posthog_distinct_id")).toBe( distinctId, ); @@ -1860,7 +1860,7 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { expect(threadsDocsUrl.searchParams.get("ref")).toBe( "cpk-inspector-threads", ); - expectThreadsOnboardingUtmParams(threadsDocsUrl); + expectNoUtmParams(threadsDocsUrl); const selfHosted = inspector.shadowRoot?.querySelector( 'a[href^="https://docs.copilotkit.ai/premium/self-hosting"]', ); @@ -1871,7 +1871,7 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { expect(selfHostedUrl.origin).toBe("https://docs.copilotkit.ai"); expect(selfHostedUrl.pathname).toBe("/premium/self-hosting"); expect(selfHostedUrl.searchParams.get("ref")).toBe("cpk-inspector-threads"); - expectThreadsOnboardingUtmParams(selfHostedUrl); + expectNoUtmParams(selfHostedUrl); expect(threadListText(inspector)).toContain("Example"); expect(text).not.toContain("No threads yet"); expect( @@ -1888,7 +1888,7 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { expect(engineerUrl.origin).toBe("https://www.copilotkit.ai"); expect(engineerUrl.pathname).toBe("/talk-to-an-engineer"); expect(engineerUrl.searchParams.get("ref")).toBe("cpk-inspector-threads"); - expectThreadsOnboardingUtmParams(engineerUrl); + expectNoUtmParams(engineerUrl); expect(engineer?.closest("#cpk-main-scroll")).toBeNull(); }); diff --git a/packages/web-inspector/src/index.ts b/packages/web-inspector/src/index.ts index d391f216658..c81b5a8fd08 100644 --- a/packages/web-inspector/src/index.ts +++ b/packages/web-inspector/src/index.ts @@ -130,11 +130,6 @@ const THREADS_EXAMPLE_OVERVIEW_VIDEO_URL = const THREADS_EXAMPLE_TOUR_STORAGE_KEY = "cpk:inspector:threads-example-tour:v1"; const THREADS_EXAMPLE_AGENT_ID = "threads-feature"; -const INSPECTOR_UTM_PARAMS = { - utm_source: "copilotkit_inspector", - utm_medium: "in_product", - utm_campaign: "threads_onboarding", -} as const; type ThreadServiceStatus = "available" | "unavailable" | "unknown" | "error"; @@ -4378,7 +4373,7 @@ export class WebInspectorElement extends LitElement { } private getThreadsIntelligenceSignupUrl(): string { - return this.appendThreadsOnboardingAttribution( + return this.appendRefParam( THREADS_INTELLIGENCE_SIGNIN_URL, "cpk-inspector", ); @@ -4389,21 +4384,21 @@ export class WebInspectorElement extends LitElement { } private getThreadsTalkToEngineerUrl(): string { - return this.appendThreadsOnboardingAttribution( + return this.appendRefParam( TALK_TO_ENGINEER_URL, "cpk-inspector-threads", ); } private getThreadsDocsUrl(): string { - return this.appendThreadsOnboardingAttribution( + return this.appendRefParam( THREADS_DOCS_URL, "cpk-inspector-threads", ); } private getSelfHostedIntelligenceUrl(): string { - return this.appendThreadsOnboardingAttribution( + return this.appendRefParam( SELF_HOSTED_INTELLIGENCE_URL, "cpk-inspector-threads", ); @@ -11040,18 +11035,7 @@ ${prettyEvent}>, - ): string { + private appendRefParam(href: string, ref = "cpk-inspector"): string { try { const isRootRelative = href.startsWith("/") && !href.startsWith("//"); const url = new URL( @@ -11063,13 +11047,6 @@ ${prettyEvent} Date: Fri, 10 Jul 2026 19:14:02 +0000 Subject: [PATCH 26/40] style: auto-fix formatting --- packages/web-inspector/src/index.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/web-inspector/src/index.ts b/packages/web-inspector/src/index.ts index c81b5a8fd08..a83408fe1b5 100644 --- a/packages/web-inspector/src/index.ts +++ b/packages/web-inspector/src/index.ts @@ -4384,17 +4384,11 @@ export class WebInspectorElement extends LitElement { } private getThreadsTalkToEngineerUrl(): string { - return this.appendRefParam( - TALK_TO_ENGINEER_URL, - "cpk-inspector-threads", - ); + return this.appendRefParam(TALK_TO_ENGINEER_URL, "cpk-inspector-threads"); } private getThreadsDocsUrl(): string { - return this.appendRefParam( - THREADS_DOCS_URL, - "cpk-inspector-threads", - ); + return this.appendRefParam(THREADS_DOCS_URL, "cpk-inspector-threads"); } private getSelfHostedIntelligenceUrl(): string { From 150164a4bdbe972b7503bba7b22c12969b5c2b64 Mon Sep 17 00:00:00 2001 From: Benjamin Taylor Date: Fri, 10 Jul 2026 14:34:09 -0500 Subject: [PATCH 27/40] fix(channels-intelligence): derive conversationKey per provider (Teams-safe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the provider-agnostic claim change on this branch. Now that the runtime claims deliveries for every provider its bot has attached, Teams deliveries flow through the same bridge — and their reply target is a distinct shape (serviceUrl/conversationId/tenantId, no teamId/channel/threadTs). Deriving conversationKey from Slack-only fields collapsed every Teams conversation onto one degenerate key, and conversationKey keys the agent/session (getOrCreate -> makeAgent), so distinct Teams conversations would share state/memory. Make replyTarget a discriminated union (slack|teams) and derive conversationKey per provider: teams:{tenantId}:{conversationId}, matching Intelligence app-api's thread_key (OSS-441 slice 2, Intelligence #511) so client and server agree on conversation identity. Unknown adapters fail loud (the claim loop's existing catch nacks, not wedges). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/http-transports.test.ts | 43 ++++++++++++++++ .../src/http-transports.ts | 50 +++++++++++++++++-- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/packages/channels-intelligence/src/http-transports.test.ts b/packages/channels-intelligence/src/http-transports.test.ts index 5a3f8cb9832..7ae3b9e813f 100644 --- a/packages/channels-intelligence/src/http-transports.test.ts +++ b/packages/channels-intelligence/src/http-transports.test.ts @@ -242,6 +242,49 @@ describe("HttpDeliverySource", () => { expect(r.env.conversationKey).toBe("slack:T1:C1:thread:1.2"); }); + it("claimOnce derives a Teams conversationKey from tenantId+conversationId (provider-agnostic claim)", async () => { + // Now that the runtime claims provider-agnostically, a Teams delivery flows + // through the same bridge. Its reply target is a different shape (no + // teamId/channel), so the conversationKey must be derived per-provider — + // otherwise every Teams conversation collapses onto one agent/session. + const { fetch } = fakeFetch(() => ({ + body: { + claimed: true, + delivery: claimedDelivery({ + adapter: "teams", + turn: { + id: "turn_9", + eventId: "evt_9", + receivedAt: "2026-06-30T00:00:00.000Z", + replyTarget: { + adapter: "teams", + serviceUrl: "https://smba.trafficmanager.net/teams", + conversationId: "19:abc@thread.tacv2", + tenantId: "tenant-1", + }, + input: { kind: "text", text: "hello" }, + }, + }), + }, + })); + const src = new HttpDeliverySource(cfg({ fetch })); + const r = await src.claimOnce(); + expect("env" in r).toBe(true); + if (!("env" in r)) throw new Error("expected env"); + expect(r.env).toMatchObject({ + kind: "turn", + platform: "teams", + text: "hello", + }); + // Matches Intelligence app-api's thread_key = teams:{tenantId}:{conversationId}. + expect(r.env.conversationKey).toBe("teams:tenant-1:19:abc@thread.tacv2"); + // Reply route is passed through verbatim for provider-agnostic egress. + expect(r.env.route).toMatchObject({ + adapter: "teams", + conversationId: "19:abc@thread.tacv2", + }); + }); + it("claimOnce maps a claimed slash command delivery to a command envelope", async () => { const { fetch } = fakeFetch(() => ({ body: { diff --git a/packages/channels-intelligence/src/http-transports.ts b/packages/channels-intelligence/src/http-transports.ts index c8f9bc6193d..6115f686a6b 100644 --- a/packages/channels-intelligence/src/http-transports.ts +++ b/packages/channels-intelligence/src/http-transports.ts @@ -156,12 +156,31 @@ function withTimeout( /** Slack reply target Intelligence mints at ingress and the sink echoes back. */ interface SlackReplyTarget { - adapter: string; + adapter: "slack"; teamId: string; channel: string; threadTs?: string; } +/** + * Teams reply target Intelligence mints at ingress (Bot Connector coordinates). + * Distinct shape from Slack — no teamId/channel/threadTs — so conversation + * identity must be derived per-provider (see {@link conversationKeyFromReplyTarget}). + */ +interface TeamsReplyTarget { + adapter: "teams"; + serviceUrl: string; + conversationId: string; + tenantId: string; +} + +/** + * The claim's reply target is provider-tagged (Intelligence app-api mints a + * discriminated union — one managed runtime serves every channel its bot has + * attached now that claims are provider-agnostic). + */ +type ReplyTarget = SlackReplyTarget | TeamsReplyTarget; + /** Successful `claim` delivery envelope (subset the bridge reads). */ interface ClaimedDelivery { id: string; @@ -173,7 +192,7 @@ interface ClaimedDelivery { turn: { id: string; eventId: string; - replyTarget: SlackReplyTarget; + replyTarget: ReplyTarget; // NB: there is intentionally no `thread_started` variant here — the claim // path only carries turn/command/reaction/interaction. `thread_started` // envelopes originate on the realtime/Phoenix path, not from `claim`, so a @@ -263,9 +282,30 @@ class IntelligenceHttp { } } -/** `slack:teamId:channel:thread:threadTs` — stable per Slack thread. */ -function conversationKeyFromReplyTarget(rt: SlackReplyTarget): string { - return `${rt.adapter}:${rt.teamId}:${rt.channel}:thread:${rt.threadTs ?? "root"}`; +/** + * Stable per-conversation key, derived per provider — it keys the agent/session + * (`getOrCreate(conversationKey)`), so distinct conversations MUST get distinct + * keys or their state bleeds together. Slack: `slack:teamId:channel:thread:threadTs`. + * Teams: `teams:tenantId:conversationId`, matching app-api's `thread_key` + * (`teams:{tenantId}:{conversationId}`) so client and server agree on identity. + * Deriving from Slack-only fields would collapse every Teams conversation to one + * key — the bug this switch prevents now that claims are provider-agnostic. + */ +function conversationKeyFromReplyTarget(rt: ReplyTarget): string { + switch (rt.adapter) { + case "slack": + return `slack:${rt.teamId}:${rt.channel}:thread:${rt.threadTs ?? "root"}`; + case "teams": + return `teams:${rt.tenantId}:${rt.conversationId}`; + default: { + // A provider we don't model yet: fail loud rather than silently collide + // distinct conversations onto a shared agent/session. + const unknown = rt as { adapter?: string }; + throw new Error( + `conversationKeyFromReplyTarget: unsupported reply-target adapter ${JSON.stringify(unknown.adapter)}`, + ); + } + } } function mapDeliveryToEnvelope(d: ClaimedDelivery): ManagedIngressEnvelope { From 67fce716906e5f3ad287e75a4f64757b403572b6 Mon Sep 17 00:00:00 2001 From: Tyler Slaton Date: Fri, 10 Jul 2026 13:25:17 -0700 Subject: [PATCH 28/40] refactor(channels-intelligence): migrate HTTP contract to channels --- .../src/http-transports.test.ts | 131 ++++++++++++++---- .../src/http-transports.ts | 69 ++++----- .../src/intelligence-adapter.test.ts | 2 +- .../src/intelligence-adapter.ts | 4 +- .../src/intelligence-state-store.test.ts | 10 +- .../src/intelligence-state-store.ts | 8 +- 6 files changed, 152 insertions(+), 72 deletions(-) diff --git a/packages/channels-intelligence/src/http-transports.test.ts b/packages/channels-intelligence/src/http-transports.test.ts index b1e1e59acab..1a52c9fef60 100644 --- a/packages/channels-intelligence/src/http-transports.test.ts +++ b/packages/channels-intelligence/src/http-transports.test.ts @@ -52,18 +52,23 @@ function fakeFetch( * `uploadFile` methods — so history tests stub `globalThis.fetch` instead. */ function stubGlobalFetch( - handler: (url: string) => { + handler: ( + url: string, + init?: RequestInit, + ) => { ok?: boolean; status?: number; json?: unknown; arrayBuffer?: ArrayBuffer; contentType?: string; }, -): { calls: string[] } { +): { calls: string[]; requests: Array<{ url: string; init?: RequestInit }> } { const calls: string[] = []; - vi.stubGlobal("fetch", async (url: string) => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => { calls.push(url); - const r = handler(url); + requests.push({ url, init }); + const r = handler(url, init); const status = r.status ?? (r.ok === false ? 500 : 200); return { ok: r.ok ?? status < 400, @@ -77,7 +82,7 @@ function stubGlobalFetch( }, }; }); - return { calls }; + return { calls, requests }; } function cfg( @@ -86,7 +91,7 @@ function cfg( return resolveTransportConfig({ baseUrl: "http://x", apiKey: "cpk-test", - botName: "opentagbot", + channelName: "opentagbot", runtimeInstanceId: "rti_test", adapter: "slack", sleep: async () => {}, @@ -102,7 +107,7 @@ const claimedDelivery = (over?: Record) => ({ attempt: 1, organizationId: "org_1", projectId: 7, - bot: { id: "bot_1", name: "opentagbot" }, + channel: { id: "channel_1", name: "opentagbot" }, adapter: "slack", leaseToken: "lease_z", leaseExpiresAt: "2026-06-30T00:00:00.000Z", @@ -125,9 +130,24 @@ describe("resolveTransportConfig", () => { it("throws loudly when required fields are missing", () => { // No overrides + (assumed) no COPILOTKIT_* env in the test runner. expect(() => - resolveTransportConfig({ baseUrl: "", apiKey: "", botName: "" }), + resolveTransportConfig({ baseUrl: "", apiKey: "", channelName: "" }), ).toThrow(/missing required transport config/); }); + + it("uses COPILOTKIT_CHANNEL_NAME and rejects the legacy bot-name environment variable", () => { + vi.stubEnv("COPILOTKIT_INTELLIGENCE_URL", "http://x"); + vi.stubEnv("COPILOTKIT_API_KEY", "cpk-test"); + vi.stubEnv("COPILOTKIT_CHANNEL_NAME", ""); + vi.stubEnv("COPILOTKIT_BOT_NAME", "legacy-bot"); + + try { + expect(() => resolveTransportConfig()).toThrow( + /channelName.*COPILOTKIT_CHANNEL_NAME/, + ); + } finally { + vi.unstubAllEnvs(); + } + }); }); describe("HttpEgressSink", () => { @@ -145,10 +165,10 @@ describe("HttpEgressSink", () => { }; const res = await sink.emit(op); expect(res).toEqual({ ok: true, ref: "eop_1" }); - expect(calls[0]!.url).toBe("http://x/api/bots/egress/messages"); + expect(calls[0]!.url).toBe("http://x/api/channels/egress/messages"); expect(calls[0]!.headers["authorization"]).toBe("Bearer cpk-test"); expect(calls[0]!.body).toMatchObject({ - botName: "opentagbot", + channelName: "opentagbot", adapter: "slack", deliveryId: "dlv_9", idempotencyKey: "turn_9:0", @@ -203,32 +223,37 @@ describe("HttpEgressSink", () => { }); describe("HttpDeliverySource", () => { - it("heartbeat declares the bot + adapter", async () => { + it("heartbeat declares the channel + adapter", async () => { const { fetch, calls } = fakeFetch(() => ({ body: { runtimeInstanceId: "rti_test", receivedAt: "t", leaseExpiresAt: "t", - bots: [], + channels: [], }, })); const src = new HttpDeliverySource(cfg({ fetch })); await src.heartbeat(); - expect(calls[0]!.url).toBe("http://x/api/bots/listener/heartbeat"); + expect(calls[0]!.url).toBe("http://x/api/channels/listener/heartbeat"); expect(calls[0]!.body).toMatchObject({ runtimeInstanceId: "rti_test", - declaredBots: [{ botName: "opentagbot", adapter: "slack" }], + declaredChannels: [{ channelName: "opentagbot", adapter: "slack" }], }); }); - it("claimOnce maps a claimed delivery to a turn envelope with a stable conversationKey", async () => { - const { fetch } = fakeFetch(() => ({ + it("claims from the channels listener and maps a delivery to a turn envelope", async () => { + const { fetch, calls } = fakeFetch(() => ({ body: { claimed: true, delivery: claimedDelivery() }, })); const src = new HttpDeliverySource(cfg({ fetch })); const r = await src.claimOnce(); expect("env" in r).toBe(true); if (!("env" in r)) throw new Error("expected env"); + expect(calls[0]!.url).toBe("http://x/api/channels/listener/claim"); + expect(calls[0]!.body).toEqual({ + runtimeInstanceId: "rti_test", + adapters: ["slack"], + }); expect(r.env).toMatchObject({ kind: "turn", deliveryId: "dlv_9", @@ -394,7 +419,9 @@ describe("HttpDeliverySource", () => { const src = new HttpDeliverySource(cfg({ fetch })); await src.claimOnce(); await src.ack("dlv_9"); - const ack = calls.find((c) => c.url.includes("/deliveries/dlv_9/ack"))!; + const ack = calls.find((c) => + c.url.endsWith("/api/channels/deliveries/dlv_9/ack"), + )!; expect(ack.body).toMatchObject({ turnId: "turn_9", leaseToken: "lease_z", @@ -413,7 +440,9 @@ describe("HttpDeliverySource", () => { const src = new HttpDeliverySource(cfg({ fetch })); await src.claimOnce(); await src.nack("dlv_9", "boom"); - const fail = calls.find((c) => c.url.includes("/deliveries/dlv_9/fail"))!; + const fail = calls.find((c) => + c.url.endsWith("/api/channels/deliveries/dlv_9/fail"), + )!; expect(fail.body["error"]).toMatchObject({ code: "runtime_error", message: "boom", @@ -499,7 +528,7 @@ describe("HttpDeliverySource.getHistory", () => { it("maps role/text and sends teamId/channel/threadTs/limit in the query", async () => { const { calls } = stubGlobalFetch((url) => { expect(url).toBe( - "http://x/api/bots/history?teamId=T1&channel=C1&threadTs=1.2&limit=5", + "http://x/api/channels/history?teamId=T1&channel=C1&threadTs=1.2&limit=5", ); return { json: { @@ -525,7 +554,7 @@ describe("HttpDeliverySource.getHistory", () => { it("hydrates a historical file ref into content parts (text inline + image data part)", async () => { const png = new Uint8Array([1, 2, 3, 4]); stubGlobalFetch((url) => { - if (url.includes("/api/bots/history")) { + if (url.includes("/api/channels/history")) { return { json: { messages: [ @@ -545,7 +574,7 @@ describe("HttpDeliverySource.getHistory", () => { }, }; } - if (url.includes("/api/bots/files/fileref_abc")) { + if (url.includes("/api/channels/files/fileref_abc")) { return { arrayBuffer: png.buffer, contentType: "image/png" }; } throw new Error(`unexpected url in test: ${url}`); @@ -636,6 +665,50 @@ describe("HttpDeliverySource.getHistory", () => { ); expect(history).toEqual([]); }); + + it("downloads inbound files from the channels file route", async () => { + const bytes = new Uint8Array([1, 2, 3]); + const { calls } = stubGlobalFetch((url) => { + expect(url).toBe("http://x/api/channels/files/fileref_abc"); + return { arrayBuffer: bytes.buffer, contentType: "application/pdf" }; + }); + const src = new HttpDeliverySource(cfg({})); + + await expect(src.fetchFile("fileref_abc")).resolves.toEqual({ + bytes, + mimeType: "application/pdf", + }); + expect(calls).toEqual(["http://x/api/channels/files/fileref_abc"]); + }); + + it("uploads outbound files to the channels delivery route", async () => { + const bytes = new Uint8Array([1, 2, 3]); + const { requests } = stubGlobalFetch(() => ({ + json: { handle: "fileref_uploaded" }, + })); + const src = new HttpDeliverySource(cfg({})); + + await expect( + src.uploadFile("dlv_9", { + bytes, + filename: "report.pdf", + title: "Report", + altText: "Quarterly report", + }), + ).resolves.toEqual({ handle: "fileref_uploaded" }); + + expect(requests[0]).toMatchObject({ + url: "http://x/api/channels/deliveries/dlv_9/files?filename=report.pdf&title=Report&altText=Quarterly+report", + init: { + method: "POST", + headers: { + authorization: "Bearer cpk-test", + "content-type": "application/octet-stream", + }, + body: bytes, + }, + }); + }); }); describe("HttpRenderEventSink", () => { @@ -668,13 +741,13 @@ describe("HttpRenderEventSink", () => { acceptance: "accepted", }); const accept = calls.find((c) => - c.url.endsWith("/deliveries/dlv_9/render-events/accept"), + c.url.endsWith("/api/channels/deliveries/dlv_9/render-events/accept"), )!; expect(accept.body).toMatchObject({ organizationId: "org_1", projectId: 7, - botId: "bot_1", - botName: "opentagbot", + channelId: "channel_1", + channelName: "opentagbot", turnId: "turn_9", runtimeInstanceId: "rti_test", slot: "main", @@ -712,7 +785,7 @@ describe("intelligenceAdapter() — config-free default transports", () => { expect(adapter.platform).toBe("intelligence"); }); - it("builds HTTP transports and takes botName from createBot({ name })", async () => { + it("builds HTTP transports and uses the bot name as the declared channel name", async () => { const { fetch, calls } = fakeFetch((c) => c.url.endsWith("/heartbeat") ? { @@ -720,7 +793,7 @@ describe("intelligenceAdapter() — config-free default transports", () => { runtimeInstanceId: "rti_test", receivedAt: "t", leaseExpiresAt: "t", - bots: [], + channels: [], }, } : { body: { claimed: false, pollAfterMs: 60000 } }, @@ -728,8 +801,8 @@ describe("intelligenceAdapter() — config-free default transports", () => { const bot = createBot({ name: "opentagbot", agent: () => new FakeAgent(), - // No source/egress injected -> default HTTP transports; no botName in - // config -> must come from createBot({ name }) via the start() context. + // No source/egress injected -> default HTTP transports; no channelName in + // config -> it comes from createBot({ name }) via the start() context. adapters: [ intelligenceAdapter({ config: { @@ -755,7 +828,7 @@ describe("intelligenceAdapter() — config-free default transports", () => { const hb = calls.find((c) => c.url.endsWith("/heartbeat"))!; expect(hb.body).toMatchObject({ - declaredBots: [{ botName: "opentagbot", adapter: "slack" }], + declaredChannels: [{ channelName: "opentagbot", adapter: "slack" }], }); expect(calls.some((c) => c.url.endsWith("/claim"))).toBe(true); }); diff --git a/packages/channels-intelligence/src/http-transports.ts b/packages/channels-intelligence/src/http-transports.ts index 5dc32e93c96..7e476a266e3 100644 --- a/packages/channels-intelligence/src/http-transports.ts +++ b/packages/channels-intelligence/src/http-transports.ts @@ -44,8 +44,8 @@ export interface IntelligenceTransportConfig { baseUrl: string; /** Project runtime API key (`cpk-…`), sent as `Authorization: Bearer`. */ apiKey: string; - /** Project-unique bot name; matches `createBot({ name })`. */ - botName: string; + /** Project-unique channel name; defaults from `createBot({ name })`. */ + channelName: string; /** Stable runtime instance id (`rti_…`); generated when omitted. */ runtimeInstanceId: string; /** Adapter kind. First slice is `"slack"`. */ @@ -70,7 +70,8 @@ export interface IntelligenceTransportConfig { /** * Resolve transport config from explicit overrides then environment, failing * loudly if a required field is missing. Env: `COPILOTKIT_INTELLIGENCE_URL`, - * `COPILOTKIT_API_KEY`, `COPILOTKIT_BOT_NAME`, `COPILOTKIT_RUNTIME_INSTANCE_ID`. + * `COPILOTKIT_API_KEY`, `COPILOTKIT_CHANNEL_NAME`, + * `COPILOTKIT_RUNTIME_INSTANCE_ID`. */ export function resolveTransportConfig( overrides: Partial = {}, @@ -83,7 +84,7 @@ export function resolveTransportConfig( overrides.baseUrl ?? env["COPILOTKIT_INTELLIGENCE_URL"] )?.replace(/\/+$/, ""); const apiKey = overrides.apiKey ?? env["COPILOTKIT_API_KEY"]; - const botName = overrides.botName ?? env["COPILOTKIT_BOT_NAME"]; + const channelName = overrides.channelName ?? env["COPILOTKIT_CHANNEL_NAME"]; const runtimeInstanceId = overrides.runtimeInstanceId ?? env["COPILOTKIT_RUNTIME_INSTANCE_ID"] ?? @@ -93,8 +94,8 @@ export function resolveTransportConfig( const missing: string[] = []; if (!baseUrl) missing.push("baseUrl (COPILOTKIT_INTELLIGENCE_URL)"); if (!apiKey) missing.push("apiKey (COPILOTKIT_API_KEY)"); - if (!botName) - missing.push("botName (createBot({ name }) / COPILOTKIT_BOT_NAME)"); + if (!channelName) + missing.push("channelName (createBot({ name }) / COPILOTKIT_CHANNEL_NAME)"); if (missing.length > 0) { throw new Error( `intelligenceAdapter: missing required transport config: ${missing.join(", ")}`, @@ -104,7 +105,7 @@ export function resolveTransportConfig( return { baseUrl: baseUrl!, apiKey: apiKey!, - botName: botName!, + channelName: channelName!, runtimeInstanceId, adapter, fetch: overrides.fetch, @@ -167,7 +168,7 @@ interface ClaimedDelivery { id: string; organizationId: string; projectId: number; - bot: { id: string; name: string }; + channel: { id: string; name: string }; adapter: string; leaseToken: string; turn: { @@ -211,12 +212,12 @@ interface ClaimedDelivery { }; } -/** Per-delivery org/project/bot scope, echoed onto render frames. */ +/** Per-delivery org/project/channel scope, echoed onto render frames. */ export interface DeliveryScope { organizationId: string; projectId: number; - botId: string; - botName: string; + channelId: string; + channelName: string; } type ClaimResponse = @@ -273,7 +274,9 @@ function mapDeliveryToEnvelope(d: ClaimedDelivery): ManagedIngressEnvelope { deliveryId: d.id, eventId: d.turn.eventId, turnId: d.turn.id, - botName: d.bot.name, + // `ManagedIngressEnvelope` remains aligned with the channels framework's + // Bot object; only the Intelligence HTTP wire contract calls this a channel. + botName: d.channel.name, platform: d.adapter, conversationKey: conversationKeyFromReplyTarget(d.turn.replyTarget), route: d.turn.replyTarget, @@ -359,11 +362,13 @@ export class HttpDeliverySource implements DeliverySource { return (this.cfg.sleep ?? defaultSleep)(ms); } - /** Declare this runtime + bot to Intelligence and keep the activation fresh. */ + /** Declare this runtime + channel to Intelligence and keep the activation fresh. */ async heartbeat(): Promise { - await this.http.post("/api/bots/listener/heartbeat", { + await this.http.post("/api/channels/listener/heartbeat", { runtimeInstanceId: this.cfg.runtimeInstanceId, - declaredBots: [{ botName: this.cfg.botName, adapter: this.cfg.adapter }], + declaredChannels: [ + { channelName: this.cfg.channelName, adapter: this.cfg.adapter }, + ], observedAt: new Date().toISOString(), }); this.lastHeartbeatAt = Date.now(); @@ -374,7 +379,7 @@ export class HttpDeliverySource implements DeliverySource { { env: ManagedIngressEnvelope } | { pollAfterMs: number } > { const res = await this.http.post( - "/api/bots/listener/claim", + "/api/channels/listener/claim", { runtimeInstanceId: this.cfg.runtimeInstanceId, adapters: [this.cfg.adapter], @@ -387,8 +392,8 @@ export class HttpDeliverySource implements DeliverySource { scope: { organizationId: res.delivery.organizationId, projectId: res.delivery.projectId, - botId: res.delivery.bot.id, - botName: res.delivery.bot.name, + channelId: res.delivery.channel.id, + channelName: res.delivery.channel.name, }, }); try { @@ -416,7 +421,7 @@ export class HttpDeliverySource implements DeliverySource { } } - /** The org/project/bot scope for a leased delivery, for render-frame egress. */ + /** The org/project/channel scope for a leased delivery, for render-frame egress. */ scopeFor(deliveryId: string): DeliveryScope | undefined { return this.leases.get(deliveryId)?.scope; } @@ -492,7 +497,7 @@ export class HttpDeliverySource implements DeliverySource { // other sees none and no-ops — exactly one of ack XOR fail reaches app-api. this.leases.delete(deliveryId); await this.http.post( - `/api/bots/deliveries/${encodeURIComponent(deliveryId)}/ack`, + `/api/channels/deliveries/${encodeURIComponent(deliveryId)}/ack`, { turnId: lease.turnId, runtimeInstanceId: this.cfg.runtimeInstanceId, @@ -515,7 +520,7 @@ export class HttpDeliverySource implements DeliverySource { // Delete-before-POST for the same reason as `ack`: single terminal signal. this.leases.delete(deliveryId); await this.http.post( - `/api/bots/deliveries/${encodeURIComponent(deliveryId)}/fail`, + `/api/channels/deliveries/${encodeURIComponent(deliveryId)}/fail`, { turnId: lease.turnId, runtimeInstanceId: this.cfg.runtimeInstanceId, @@ -545,7 +550,7 @@ export class HttpDeliverySource implements DeliverySource { "intelligenceAdapter: no global fetch available for file download", ); } - const url = `${this.cfg.baseUrl}/api/bots/files/${encodeURIComponent(handle)}`; + const url = `${this.cfg.baseUrl}/api/channels/files/${encodeURIComponent(handle)}`; const res = await gfetch(url, { method: "GET", headers: { authorization: `Bearer ${this.cfg.apiKey}` }, @@ -567,7 +572,7 @@ export class HttpDeliverySource implements DeliverySource { } /** - * Fetch prior thread turns from app-api's bot history route for + * Fetch prior thread turns from app-api's channel history route for * conversation-history seeding (parity with bot-slack/bot-discord/ * bot-whatsapp's reconstructed-history conversation stores). A root-level * turn (no `threadTs`) has no prior thread to look up, so this returns `[]` @@ -601,7 +606,7 @@ export class HttpDeliverySource implements DeliverySource { threadTs: rt.threadTs, limit: String(limit), }); - const url = `${this.cfg.baseUrl}/api/bots/history?${qs.toString()}`; + const url = `${this.cfg.baseUrl}/api/channels/history?${qs.toString()}`; const res = await gfetch(url, { method: "GET", headers: { authorization: `Bearer ${this.cfg.apiKey}` }, @@ -684,7 +689,7 @@ export class HttpDeliverySource implements DeliverySource { const qs = new URLSearchParams({ filename: args.filename }); if (args.title) qs.set("title", args.title); if (args.altText) qs.set("altText", args.altText); - const url = `${this.cfg.baseUrl}/api/bots/deliveries/${encodeURIComponent( + const url = `${this.cfg.baseUrl}/api/channels/deliveries/${encodeURIComponent( deliveryId, )}/files?${qs.toString()}`; const res = await gfetch(url, { @@ -739,9 +744,9 @@ export class HttpEgressSink implements EgressSink { try { const res = await this.http.post( - "/api/bots/egress/messages", + "/api/channels/egress/messages", { - botName: this.cfg.botName, + channelName: this.cfg.channelName, adapter: this.cfg.adapter, deliveryId: op.deliveryId, idempotencyKey: op.operationId, @@ -772,13 +777,13 @@ interface RenderAcceptedResponse { /** * @internal {@link RenderEventSink} that streams semantic render frames to * Intelligence's durable render-acceptance route - * (`/api/bots/deliveries/:id/render-events/accept`). This is the HTTP-path + * (`/api/channels/deliveries/:id/render-events/accept`). This is the HTTP-path * equivalent of the realtime {@link PhoenixRealtimeTransport}: each frame is * POSTed and the durable acceptance receipt is awaited before the next. The * gateway-side Connector Outbox then renders the accepted frames to Slack, so * this path reaches full reply-UX parity without a running realtime gateway. * - * Per-delivery org/project/bot scope is read from the {@link HttpDeliverySource} + * Per-delivery org/project/channel scope is read from the {@link HttpDeliverySource} * that leased the delivery (populated at claim). The `deliveryId` travels in the * URL only — the accept route rejects a body that also carries it. */ @@ -809,12 +814,12 @@ export class HttpRenderEventSink implements RenderEventSink { const leaseToken = this.scopeSource.leaseTokenFor(frame.deliveryId); const idempotencyKey = `${frame.turnId}:${frame.slot}:${frame.seq}`; const res = await this.http.post( - `/api/bots/deliveries/${encodeURIComponent(frame.deliveryId)}/render-events/accept`, + `/api/channels/deliveries/${encodeURIComponent(frame.deliveryId)}/render-events/accept`, { organizationId: scope.organizationId, projectId: scope.projectId, - botId: scope.botId, - botName: scope.botName, + channelId: scope.channelId, + channelName: scope.channelName, turnId: frame.turnId, runtimeInstanceId: this.cfg.runtimeInstanceId, slot: frame.slot, diff --git a/packages/channels-intelligence/src/intelligence-adapter.test.ts b/packages/channels-intelligence/src/intelligence-adapter.test.ts index 1843e2ae656..a072fa59f8f 100644 --- a/packages/channels-intelligence/src/intelligence-adapter.test.ts +++ b/packages/channels-intelligence/src/intelligence-adapter.test.ts @@ -544,7 +544,7 @@ describe("intelligenceAdapter — default store resolution", () => { expect(adapter.stateStore).toBeInstanceOf(IntelligenceStateStore); await adapter.stateStore!.kv.get("k"); - expect(calls[0]).toBe("http://intel.test/api/bots/kv/get"); + expect(calls[0]).toBe("http://intel.test/api/channels/kv/get"); }); it("skips the default store when in-memory transports are injected", () => { diff --git a/packages/channels-intelligence/src/intelligence-adapter.ts b/packages/channels-intelligence/src/intelligence-adapter.ts index a63995ad1bd..c6200a2b9f7 100644 --- a/packages/channels-intelligence/src/intelligence-adapter.ts +++ b/packages/channels-intelligence/src/intelligence-adapter.ts @@ -101,7 +101,7 @@ export interface IntelligenceAdapterOptions { /** Optional Intelligence-backed persistence the adapter exposes as `stateStore`. */ store?: StateStore; /** - * Overrides for the default-transport config (baseUrl/apiKey/botName/…). + * Overrides for the default-transport config (baseUrl/apiKey/channelName/…). * Anything omitted is resolved from env. Ignored when both `source` and * `egress` are injected. */ @@ -265,7 +265,7 @@ export class IntelligenceAdapter implements PlatformAdapter { if (!this.source || !this.egress) { const cfg = resolveTransportConfig({ ...this.opts.config, - botName: this.opts.config?.botName ?? ctx?.botName, + channelName: this.opts.config?.channelName ?? ctx?.botName, }); const source = (this.source ??= new HttpDeliverySource(cfg)); this.egress ??= new HttpEgressSink(cfg); diff --git a/packages/channels-intelligence/src/intelligence-state-store.test.ts b/packages/channels-intelligence/src/intelligence-state-store.test.ts index 5e73fbf76b0..f9d263768d2 100644 --- a/packages/channels-intelligence/src/intelligence-state-store.test.ts +++ b/packages/channels-intelligence/src/intelligence-state-store.test.ts @@ -4,7 +4,7 @@ import { IntelligenceStateStore } from "./intelligence-state-store.js"; import type { FetchLike } from "./http-transports.js"; /** - * A fake `/api/bots/kv/*` server over an in-memory Map, honoring TTL the same + * A fake `/api/channels/kv/*` server over an in-memory Map, honoring TTL the same * way app-api does (lazy expiry on read). Lets the full StateStore conformance * suite run against IntelligenceStateStore: `kv` exercises this HTTP layer; * list/lock/dedup/queue exercise the delegated in-memory MemoryStore. @@ -20,7 +20,7 @@ function fakeKvFetch(): FetchLike { ttlMs?: number; }; let payload: unknown = { ok: true }; - if (url.endsWith("/api/bots/kv/get")) { + if (url.endsWith("/api/channels/kv/get")) { const e = map.get(body.key); if (!live(e)) { map.delete(body.key); @@ -28,13 +28,15 @@ function fakeKvFetch(): FetchLike { } else { payload = { value: e!.value }; } - } else if (url.endsWith("/api/bots/kv/set")) { + } else if (url.endsWith("/api/channels/kv/set")) { map.set(body.key, { value: body.value, expiresAt: body.ttlMs ? Date.now() + body.ttlMs : undefined, }); - } else if (url.endsWith("/api/bots/kv/delete")) { + } else if (url.endsWith("/api/channels/kv/delete")) { map.delete(body.key); + } else { + throw new Error(`unexpected KV route: ${url}`); } const text = JSON.stringify(payload); return { ok: true, status: 200, text: async () => text }; diff --git a/packages/channels-intelligence/src/intelligence-state-store.ts b/packages/channels-intelligence/src/intelligence-state-store.ts index 405ff152331..f7f17fd2421 100644 --- a/packages/channels-intelligence/src/intelligence-state-store.ts +++ b/packages/channels-intelligence/src/intelligence-state-store.ts @@ -14,7 +14,7 @@ export interface IntelligenceStateStoreConfig { /** * Durable {@link StateStore} for managed bots, backed by Intelligence app-api's - * runtime-authed KV routes (`/api/bots/kv/*`). Only the `kv` facet is durable — + * runtime-authed KV routes (`/api/channels/kv/*`). Only the `kv` facet is durable — * that is what the action registry (button/`ck:` snapshots) and thread state * use, so a HITL card posted before a managed-loop restart still re-renders on * cold-cache dispatch and can be flipped in place. @@ -71,7 +71,7 @@ export class IntelligenceStateStore implements StateStore { * the StateStore contract's `undefined`. */ get: async (key: string): Promise => { const { value } = await this.post<{ value: T | null }>( - "/api/bots/kv/get", + "/api/channels/kv/get", { key }, ); // app-api returns `null` for a missing/expired key; normalize to the @@ -82,7 +82,7 @@ export class IntelligenceStateStore implements StateStore { }, /** Write a durable key, optionally with a TTL in ms (omitted → no expiry). */ set: async (key: string, value: T, ttlMs?: number): Promise => { - await this.post("/api/bots/kv/set", { + await this.post("/api/channels/kv/set", { key, value, // `!== undefined` (not truthiness) so an explicit `ttlMs: 0` is still @@ -92,7 +92,7 @@ export class IntelligenceStateStore implements StateStore { }, /** Delete a durable key (no-op if absent). */ delete: async (key: string): Promise => { - await this.post("/api/bots/kv/delete", { key }); + await this.post("/api/channels/kv/delete", { key }); }, }; From 4dddabd79f8abf6011036a5e058b36b90a62fe6f Mon Sep 17 00:00:00 2001 From: Tyler Slaton Date: Fri, 10 Jul 2026 13:44:11 -0700 Subject: [PATCH 29/40] test(channels-intelligence): align claim test with provider-agnostic flow --- packages/channels-intelligence/src/http-transports.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/channels-intelligence/src/http-transports.test.ts b/packages/channels-intelligence/src/http-transports.test.ts index 45c9c1d858d..fa81a4cb225 100644 --- a/packages/channels-intelligence/src/http-transports.test.ts +++ b/packages/channels-intelligence/src/http-transports.test.ts @@ -252,7 +252,6 @@ describe("HttpDeliverySource", () => { expect(calls[0]!.url).toBe("http://x/api/channels/listener/claim"); expect(calls[0]!.body).toEqual({ runtimeInstanceId: "rti_test", - adapters: ["slack"], }); expect(r.env).toMatchObject({ kind: "turn", From ea9910ae9371e4f3df1f1e83727667e578479a81 Mon Sep 17 00:00:00 2001 From: Tyler Slaton Date: Fri, 10 Jul 2026 14:10:30 -0700 Subject: [PATCH 30/40] refactor(channels-intelligence): introduce realtime gateway abstraction --- .../channels-intelligence/src/contracts.ts | 2 +- .../src/http-transports.ts | 2 +- packages/channels-intelligence/src/index.ts | 34 ++--- ...t.ts => realtime-gateway-launcher.test.ts} | 120 ++++++++--------- ...uncher.ts => realtime-gateway-launcher.ts} | 125 +++++++++--------- ...sport.ts => realtime-gateway-transport.ts} | 99 +++++++------- .../src/realtime-gateway.test.ts | 97 ++++++++++++++ ...phoenix-channel.ts => realtime-gateway.ts} | 60 +++++---- .../src/render-events.test.ts | 104 +++++++-------- .../channels-intelligence/src/transports.ts | 2 +- 10 files changed, 371 insertions(+), 274 deletions(-) rename packages/channels-intelligence/src/{phoenix-launcher.test.ts => realtime-gateway-launcher.test.ts} (72%) rename packages/channels-intelligence/src/{phoenix-launcher.ts => realtime-gateway-launcher.ts} (61%) rename packages/channels-intelligence/src/{phoenix-transport.ts => realtime-gateway-transport.ts} (76%) create mode 100644 packages/channels-intelligence/src/realtime-gateway.test.ts rename packages/channels-intelligence/src/{phoenix-channel.ts => realtime-gateway.ts} (58%) diff --git a/packages/channels-intelligence/src/contracts.ts b/packages/channels-intelligence/src/contracts.ts index 2ec105fc4fb..9fcd1d1fcbc 100644 --- a/packages/channels-intelligence/src/contracts.ts +++ b/packages/channels-intelligence/src/contracts.ts @@ -119,7 +119,7 @@ export type EgressResult = | { ok: false; code: string }; // ── Realtime render events (OSS-402) ────────────────────────────────────── -// Mirrors the frozen `hosted_bot.render_event.v1` contract on the Intelligence +// Mirrors the frozen `channel.render_event.v1` contract on the Intelligence // side (libs/app-api-contracts/src/hosted-bots.ts). The SDK streams these // semantic frames to the realtime-gateway; the gateway-side Connector Outbox // (OSS-404) renders them to the provider (Slack Block Kit, etc.). diff --git a/packages/channels-intelligence/src/http-transports.ts b/packages/channels-intelligence/src/http-transports.ts index 092dcdff884..0e718d6566a 100644 --- a/packages/channels-intelligence/src/http-transports.ts +++ b/packages/channels-intelligence/src/http-transports.ts @@ -823,7 +823,7 @@ interface RenderAcceptedResponse { * @internal {@link RenderEventSink} that streams semantic render frames to * Intelligence's durable render-acceptance route * (`/api/channels/deliveries/:id/render-events/accept`). This is the HTTP-path - * equivalent of the realtime {@link PhoenixRealtimeTransport}: each frame is + * equivalent of the realtime {@link RealtimeGatewayTransport}: each frame is * POSTed and the durable acceptance receipt is awaited before the next. The * gateway-side Connector Outbox then renders the accepted frames to Slack, so * this path reaches full reply-UX parity without a running realtime gateway. diff --git a/packages/channels-intelligence/src/index.ts b/packages/channels-intelligence/src/index.ts index f203a1ad1bf..1a917daa7d8 100644 --- a/packages/channels-intelligence/src/index.ts +++ b/packages/channels-intelligence/src/index.ts @@ -38,30 +38,30 @@ export { InMemoryRenderEventSink, } from "./in-memory-transports.js"; -// Realtime-gateway (Phoenix) transport — the production render/delivery path +// Realtime Gateway transport — the production render/delivery path // (OSS-402). Undocumented like the rest of the package; exported for the // managed-listener bootstrap and tests. -export { PhoenixRealtimeTransport } from "./phoenix-transport.js"; +export { RealtimeGatewayTransport } from "./realtime-gateway-transport.js"; export type { - PhoenixTransportConfig, - HostedBotChannel, + RealtimeGatewayTransportOptions, HostedBotRealtimeScope, -} from "./phoenix-transport.js"; -export { connectPhoenixHostedBotChannel } from "./phoenix-channel.js"; +} from "./realtime-gateway-transport.js"; +export { connectRealtimeGateway } from "./realtime-gateway.js"; export type { - PhoenixConnectConfig, - ConnectedHostedBotChannel, -} from "./phoenix-channel.js"; -// The managed-over-Phoenix launcher (OSS-406): the composition that runs a -// managed bot over the realtime path (connect → transport → startManagedBots). + ConnectRealtimeGatewayOptions, + RealtimeGatewaySession, + ConnectedRealtimeGatewaySession, +} from "./realtime-gateway.js"; +// The managed-over-Realtime-Gateway launcher (OSS-406): the composition that +// runs a managed channel over the realtime path. export { - startManagedBotsOverPhoenix, - startManagedBotsOnChannel, -} from "./phoenix-launcher.js"; + startChannelsOverRealtimeGateway, + startChannelsWithGatewaySession, +} from "./realtime-gateway-launcher.js"; export type { - ManagedPhoenixConfig, - ManagedBotsOnChannelOptions, -} from "./phoenix-launcher.js"; + StartChannelsOverRealtimeGatewayOptions, + StartChannelsWithGatewaySessionOptions, +} from "./realtime-gateway-launcher.js"; // Undocumented fallbacks: the default HTTP transports + config resolver that // `intelligenceAdapter()` builds when no transports are injected. Not a public diff --git a/packages/channels-intelligence/src/phoenix-launcher.test.ts b/packages/channels-intelligence/src/realtime-gateway-launcher.test.ts similarity index 72% rename from packages/channels-intelligence/src/phoenix-launcher.test.ts rename to packages/channels-intelligence/src/realtime-gateway-launcher.test.ts index fc16ab4463c..944e966fdbd 100644 --- a/packages/channels-intelligence/src/phoenix-launcher.test.ts +++ b/packages/channels-intelligence/src/realtime-gateway-launcher.test.ts @@ -2,30 +2,30 @@ import { describe, it, expect } from "vitest"; import { createBot, FakeAgent } from "@copilotkit/channels"; import { Section } from "@copilotkit/channels-ui"; import { - startManagedBotsOnChannel, - startManagedBotsOverPhoenix, -} from "./phoenix-launcher.js"; -import type { HostedBotChannel } from "./phoenix-transport.js"; + startChannelsWithGatewaySession, + startChannelsOverRealtimeGateway, +} from "./realtime-gateway-launcher.js"; +import type { RealtimeGatewaySession } from "./realtime-gateway.js"; const scope = { organizationId: "org_1", projectId: 7, - botId: "bot_1", - botName: "opentag", + channelId: "channel_1", + channelName: "opentag", }; -/** Fake gateway channel: records pushes, replies `render_accepted`, and exposes +/** Fake gateway session: records pushes, replies `render_accepted`, and exposes * the server-push handlers so a test can simulate `delivery.available`. */ -function makeFakeChannel() { +function makeFakeSession() { const pushes: { event: string; payload: unknown }[] = []; const handlers = new Map void>(); - const channel: HostedBotChannel = { + const session: RealtimeGatewaySession = { push: async (event, payload) => { pushes.push({ event, payload }); - if (event === "hosted_bot.render_event.v1") { + if (event === "channel.render_event.v1") { const p = (payload as { payload: Record }).payload; return { - type: "hosted_bot.render_accepted.v1", + type: "channel.render_accepted.v1", occurredAt: "2026-07-09T00:00:00.000Z", payload: { idempotencyKey: p.idempotencyKey, @@ -42,18 +42,18 @@ function makeFakeChannel() { handlers.set(event, handler); }, }; - return { channel, pushes, handlers }; + return { session, pushes, handlers }; } -/** Simulate one leased text-turn delivery arriving over the channel. */ +/** Simulate one leased text-turn delivery arriving over the gateway session. */ function deliverText(handlers: Map void>) { - handlers.get("hosted_bot.delivery.available.v1")?.({ + handlers.get("channel.delivery.available.v1")?.({ payload: { delivery: { id: "dlv_1", leaseToken: "lease_1", adapter: "slack", - bot: { id: "bot_1", name: "opentag" }, + channel: { id: "channel_1", name: "opentag" }, turn: { id: "turn_1", eventId: "evt_1", @@ -65,7 +65,7 @@ function deliverText(handlers: Map void>) { }); } -/** The channel `delivery.available` handler is fire-and-forget, so poll until +/** The gateway session `delivery.available` handler is fire-and-forget, so poll until * the async dispatch→render→ack chain has produced the terminal event. */ async function waitFor(pred: () => boolean, tries = 50): Promise { for (let i = 0; i < tries; i++) { @@ -75,9 +75,9 @@ async function waitFor(pred: () => boolean, tries = 50): Promise { throw new Error("waitFor: condition not met within the poll window"); } -describe("startManagedBotsOnChannel — managed runtime over Phoenix (OSS-406)", () => { +describe("startChannelsWithGatewaySession — managed runtime over Realtime Gateway (OSS-406)", () => { it("runs a delivered turn end-to-end: handler → render frame → completion intent, never self-ack", async () => { - const fake = makeFakeChannel(); + const fake = makeFakeSession(); let ran = false; const bot = createBot({ name: "opentag", agent: () => new FakeAgent() }); bot.onMessage(async ({ thread }) => { @@ -85,8 +85,8 @@ describe("startManagedBotsOnChannel — managed runtime over Phoenix (OSS-406)", await thread.post(Section({ children: "reply" })); }); - const handle = await startManagedBotsOnChannel([bot], { - channel: fake.channel, + const handle = await startChannelsWithGatewaySession([bot], { + session: fake.session, scope, runtimeInstanceId: "rti_1", }); @@ -94,54 +94,54 @@ describe("startManagedBotsOnChannel — managed runtime over Phoenix (OSS-406)", deliverText(fake.handlers); await waitFor(() => fake.pushes.some( - (p) => p.event === "hosted_bot.delivery.complete_requested.v1", + (p) => p.event === "channel.delivery.complete_requested.v1", ), ); const events = fake.pushes.map((p) => p.event); - expect(ran).toBe(true); // the bot's handler ran off a Phoenix-delivered turn - expect(events).toContain("hosted_bot.render_event.v1"); // rendered over the channel - expect(events).toContain("hosted_bot.delivery.complete_requested.v1"); // completion INTENT - expect(events).not.toContain("hosted_bot.delivery.ack.v1"); // SDK never commits the ack + expect(ran).toBe(true); // the Bot handler ran off a gateway-delivered turn + expect(events).toContain("channel.render_event.v1"); // rendered over the gateway session + expect(events).toContain("channel.delivery.complete_requested.v1"); // completion INTENT + expect(events).not.toContain("channel.delivery.ack.v1"); // SDK never commits the ack await handle.stop(); }); it("nacks (fail intent) when the handler throws — no completion intent, no self-ack", async () => { - const fake = makeFakeChannel(); + const fake = makeFakeSession(); const bot = createBot({ name: "opentag", agent: () => new FakeAgent() }); bot.onMessage(async () => { throw new Error("boom"); }); - const handle = await startManagedBotsOnChannel([bot], { - channel: fake.channel, + const handle = await startChannelsWithGatewaySession([bot], { + session: fake.session, scope, runtimeInstanceId: "rti_1", }); deliverText(fake.handlers); await waitFor(() => - fake.pushes.some((p) => p.event === "hosted_bot.delivery.fail.v1"), + fake.pushes.some((p) => p.event === "channel.delivery.fail.v1"), ); const events = fake.pushes.map((p) => p.event); - expect(events).toContain("hosted_bot.delivery.fail.v1"); - expect(events).not.toContain("hosted_bot.delivery.complete_requested.v1"); - expect(events).not.toContain("hosted_bot.delivery.ack.v1"); + expect(events).toContain("channel.delivery.fail.v1"); + expect(events).not.toContain("channel.delivery.complete_requested.v1"); + expect(events).not.toContain("channel.delivery.ack.v1"); await handle.stop(); }); }); -describe("startManagedBotsOverPhoenix — fail-fast validation (OSS-406)", () => { +describe("startChannelsOverRealtimeGateway — fail-fast validation (OSS-406)", () => { it("rejects a bad bot name before opening a socket (no leaked connection)", async () => { let socketConstructed = false; class NeverWebSocket { constructor() { socketConstructed = true; throw new Error( - "startManagedBotsOverPhoenix should not have connected", + "startChannelsOverRealtimeGateway should not have connected", ); } } @@ -149,7 +149,7 @@ describe("startManagedBotsOverPhoenix — fail-fast validation (OSS-406)", () => const b = createBot({ name: "dupe", agent: () => new FakeAgent() }); await expect( - startManagedBotsOverPhoenix([a, b], { + startChannelsOverRealtimeGateway([a, b], { wsUrl: "wss://gateway.example/socket", apiKey: "cpk-test", scope, @@ -161,13 +161,13 @@ describe("startManagedBotsOverPhoenix — fail-fast validation (OSS-406)", () => expect(socketConstructed).toBe(false); }); - it("rejects >1 bot before opening a socket (Phase 1 is single-bot per channel)", async () => { + it("rejects >1 Bot before opening a socket (Phase 1 is single-Bot per gateway session)", async () => { let socketConstructed = false; class NeverWebSocket { constructor() { socketConstructed = true; throw new Error( - "startManagedBotsOverPhoenix should not have connected", + "startChannelsOverRealtimeGateway should not have connected", ); } } @@ -175,40 +175,40 @@ describe("startManagedBotsOverPhoenix — fail-fast validation (OSS-406)", () => const b = createBot({ name: "two", agent: () => new FakeAgent() }); await expect( - startManagedBotsOverPhoenix([a, b], { + startChannelsOverRealtimeGateway([a, b], { wsUrl: "wss://gateway.example/socket", apiKey: "cpk-test", scope, runtimeInstanceId: "rti_1", webSocket: NeverWebSocket, }), - ).rejects.toThrow(/exactly one bot per channel/i); + ).rejects.toThrow(/exactly one Bot per gateway session/i); expect(socketConstructed).toBe(false); }); - it("startManagedBotsOnChannel also rejects >1 bot (shared-transport misrouting guard)", async () => { - const fake = makeFakeChannel(); + it("startChannelsWithGatewaySession also rejects >1 Bot (shared-transport misrouting guard)", async () => { + const fake = makeFakeSession(); const a = createBot({ name: "one", agent: () => new FakeAgent() }); const b = createBot({ name: "two", agent: () => new FakeAgent() }); await expect( - startManagedBotsOnChannel([a, b], { - channel: fake.channel, + startChannelsWithGatewaySession([a, b], { + session: fake.session, scope, runtimeInstanceId: "rti_1", }), - ).rejects.toThrow(/exactly one bot per channel/i); + ).rejects.toThrow(/exactly one Bot per gateway session/i); }); }); -describe("startManagedBotsOnChannel — activation metadata (OSS-406)", () => { +describe("startChannelsWithGatewaySession — activation metadata (OSS-406)", () => { it("forwards env overrides into handle.metadata (keeps join ↔ metadata in sync)", async () => { - const fake = makeFakeChannel(); + const fake = makeFakeSession(); const bot = createBot({ name: "opentag", agent: () => new FakeAgent() }); - const handle = await startManagedBotsOnChannel([bot], { - channel: fake.channel, + const handle = await startChannelsWithGatewaySession([bot], { + session: fake.session, scope, runtimeInstanceId: "rti_1", env: { runtimeEnv: "production", runtimePackageVersion: "9.9.9" }, @@ -222,11 +222,11 @@ describe("startManagedBotsOnChannel — activation metadata (OSS-406)", () => { }); it("keeps the required runtimeInstanceId authoritative in handle.metadata", async () => { - const fake = makeFakeChannel(); + const fake = makeFakeSession(); const bot = createBot({ name: "opentag", agent: () => new FakeAgent() }); - const handle = await startManagedBotsOnChannel([bot], { - channel: fake.channel, + const handle = await startChannelsWithGatewaySession([bot], { + session: fake.session, scope, runtimeInstanceId: "rti_authoritative", env: { runtimeEnv: "staging" }, @@ -239,7 +239,7 @@ describe("startManagedBotsOnChannel — activation metadata (OSS-406)", () => { }); /** - * Minimal Phoenix-compatible fake WebSocket that drives the v2-serializer join + * Minimal gateway-compatible fake WebSocket that drives the v2-serializer join * handshake so the connector's error/timeout cleanup can be exercised without a * real gateway. `mode` controls the phx_join reply: "ok" → joined, "error" → * rejected, "never" → no reply (the channel join times out). Records `close()` @@ -302,13 +302,13 @@ function makeFakeWebSocket(mode: JoinMode) { return { FakeWebSocket, instances }; } -describe("startManagedBotsOverPhoenix — socket lifecycle cleanup (OSS-406)", () => { +describe("startChannelsOverRealtimeGateway — socket lifecycle cleanup (OSS-406)", () => { it("disconnects the socket when the channel join times out", async () => { const { FakeWebSocket, instances } = makeFakeWebSocket("never"); const bot = createBot({ name: "opentag", agent: () => new FakeAgent() }); await expect( - startManagedBotsOverPhoenix([bot], { + startChannelsOverRealtimeGateway([bot], { wsUrl: "wss://gateway.example/socket", apiKey: "cpk-test", scope, @@ -327,7 +327,7 @@ describe("startManagedBotsOverPhoenix — socket lifecycle cleanup (OSS-406)", ( const bot = createBot({ name: "opentag", agent: () => new FakeAgent() }); await expect( - startManagedBotsOverPhoenix([bot], { + startChannelsOverRealtimeGateway([bot], { wsUrl: "wss://gateway.example/socket", apiKey: "cpk-test", scope, @@ -343,19 +343,19 @@ describe("startManagedBotsOverPhoenix — socket lifecycle cleanup (OSS-406)", ( it("disconnects the socket when bot startup fails after the channel joined", async () => { const { FakeWebSocket, instances } = makeFakeWebSocket("ok"); - // Pre-start the bot so startManagedBots' addAdapter() throws ("adapter added + // Pre-start the Bot so startManagedBots' addAdapter() throws ("adapter added // after start") during the post-join startup — the exact failure the // launcher's try/catch must clean up after. - const started = makeFakeChannel(); + const started = makeFakeSession(); const bot = createBot({ name: "opentag", agent: () => new FakeAgent() }); - const first = await startManagedBotsOnChannel([bot], { - channel: started.channel, + const first = await startChannelsWithGatewaySession([bot], { + session: started.session, scope, runtimeInstanceId: "rti_pre", }); await expect( - startManagedBotsOverPhoenix([bot], { + startChannelsOverRealtimeGateway([bot], { wsUrl: "wss://gateway.example/socket", apiKey: "cpk-test", scope, diff --git a/packages/channels-intelligence/src/phoenix-launcher.ts b/packages/channels-intelligence/src/realtime-gateway-launcher.ts similarity index 61% rename from packages/channels-intelligence/src/phoenix-launcher.ts rename to packages/channels-intelligence/src/realtime-gateway-launcher.ts index a9f2679a8fb..33d2efeebbd 100644 --- a/packages/channels-intelligence/src/phoenix-launcher.ts +++ b/packages/channels-intelligence/src/realtime-gateway-launcher.ts @@ -6,24 +6,22 @@ import { resolveActivationEnv, } from "./runtime.js"; import type { ManagedBotsHandle, ActivationEnv } from "./runtime.js"; -import { connectPhoenixHostedBotChannel } from "./phoenix-channel.js"; -import { PhoenixRealtimeTransport } from "./phoenix-transport.js"; -import type { - HostedBotChannel, - HostedBotRealtimeScope, -} from "./phoenix-transport.js"; +import { connectRealtimeGateway } from "./realtime-gateway.js"; +import { RealtimeGatewayTransport } from "./realtime-gateway-transport.js"; +import type { HostedBotRealtimeScope } from "./realtime-gateway-transport.js"; +import type { RealtimeGatewaySession } from "./realtime-gateway.js"; import type { EgressSink } from "./transports.js"; /** - * Phoenix-path egress is vestigial: with a render sink wired (the transport + * Realtime Gateway egress is vestigial: with a render sink wired (the transport * itself), the adapter routes every `post`/`update` and the run-render stream * through the render sink and never through the generic {@link EgressSink}. Fail * loud if that invariant is ever broken, rather than silently dropping an op. */ -const phoenixEgress: EgressSink = { +const realtimeGatewayEgress: EgressSink = { emit: async () => { throw new Error( - "startManagedBotsOverPhoenix: EgressSink.emit was called, but the Phoenix " + + "startChannelsOverRealtimeGateway: EgressSink.emit was called, but the Realtime Gateway " + "path routes all egress through the render sink — this indicates a " + "wiring bug (the render sink was not set on the adapter).", ); @@ -31,35 +29,36 @@ const phoenixEgress: EgressSink = { }; /** - * Phase 1 runs exactly one bot per channel. {@link PhoenixRealtimeTransport} is - * a single-delivery-callback object bound to one channel; attaching more than - * one bot's `intelligenceAdapter` to the same transport would make every - * delivery dispatch through the last bot's callback (and earlier bots receive - * nothing). Multi-bot routing over a shared channel is Phase 2 (OSS-459) — until - * then, run one bot per channel/runner and fail loudly on more. + * Phase 1 runs exactly one framework {@link Bot} per gateway session. + * {@link RealtimeGatewayTransport} is a single-delivery-callback object bound + * to one session; attaching more than one Bot's `intelligenceAdapter` to the + * same transport would make every delivery dispatch through the last Bot's + * callback (and earlier Bots receive + * nothing). Multi-Bot routing over a shared session is not implemented yet — + * until then, run one Bot per gateway session/runner and fail loudly on more. */ function assertSingleBotForPhase1(bots: readonly Bot[]): void { if (bots.length !== 1) { throw new Error( - `managed Phoenix runtime supports exactly one bot per channel, got ${bots.length} — ` + - "multi-bot routing over a shared PhoenixRealtimeTransport is not implemented yet (OSS-459); " + - "run one bot per channel/runner", + `managed Realtime Gateway runtime supports exactly one Bot per gateway session, got ${bots.length} — ` + + "multi-Bot routing over a shared RealtimeGatewayTransport is not implemented yet (OSS-459); " + + "run one Bot per gateway session/runner", ); } } -/** Options for {@link startManagedBotsOnChannel} — an already-connected channel. */ -export interface ManagedBotsOnChannelOptions { - /** The joined realtime-gateway bot-IO channel (`hosted_bots:project:`). */ - channel: HostedBotChannel; - /** Authoritative org/project/bot scope echoed on every SDK→gateway envelope. */ +/** Options for {@link startChannelsWithGatewaySession}. */ +export interface StartChannelsWithGatewaySessionOptions { + /** The joined Realtime Gateway session. */ + session: RealtimeGatewaySession; + /** Authoritative org/project/channel scope echoed on every SDK→gateway envelope. */ scope: HostedBotRealtimeScope; /** Stable runtime instance id (`rti_…`), echoed on every envelope. */ runtimeInstanceId: string; /** Activation env overrides forwarded to the runtime (so `handle.metadata` * matches what the caller declared on join); omitted fields are gathered from * the process. `runtimeInstanceId` is excluded — the required - * {@link ManagedBotsOnChannelOptions.runtimeInstanceId} above is authoritative + * {@link StartChannelsWithGatewaySessionOptions.runtimeInstanceId} above is authoritative * and is merged in, so the transport (which stamps it on every envelope) and * `handle.metadata` always report the same id. */ env?: Partial>; @@ -68,25 +67,25 @@ export interface ManagedBotsOnChannelOptions { } /** - * Compose the managed runtime over an already-connected Phoenix channel: wrap - * the channel in a {@link PhoenixRealtimeTransport} (delivery source + render - * sink) and start the declared bots against it via {@link startManagedBots}. + * Compose the managed runtime over an already-connected gateway session: wrap + * the session in a {@link RealtimeGatewayTransport} (delivery source + render + * sink) and start the declared Bots against it via {@link startManagedBots}. * - * Split out from {@link startManagedBotsOverPhoenix} so the composition — the - * part with behavior — is unit-testable against a fake channel, leaving the - * socket connect as thin glue. `intelligenceAdapter` is exclusive, so the - * Phoenix transport is each bot's ONLY adapter; egress is served by the render - * sink, not the generic {@link EgressSink} (see {@link phoenixEgress}). + * Split out from {@link startChannelsOverRealtimeGateway} so the composition — + * the part with behavior — is unit-testable against a fake session, leaving the + * connector as thin glue. `intelligenceAdapter` is exclusive, so the gateway + * transport is each Bot's ONLY adapter; egress is served by the render sink, + * not the generic {@link EgressSink} (see {@link realtimeGatewayEgress}). */ -export async function startManagedBotsOnChannel( +export async function startChannelsWithGatewaySession( bots: Bot[], - opts: ManagedBotsOnChannelOptions, + opts: StartChannelsWithGatewaySessionOptions, ): Promise { assertSingleBotForPhase1(bots); - const transport = new PhoenixRealtimeTransport({ + const transport = new RealtimeGatewayTransport({ scope: opts.scope, runtimeInstanceId: opts.runtimeInstanceId, - channel: opts.channel, + session: opts.session, ...(opts.log ? { log: opts.log } : {}), }); return startManagedBots({ @@ -94,23 +93,23 @@ export async function startManagedBotsOnChannel( resolveTransport: () => ({ source: transport, renderSink: transport, - egress: phoenixEgress, + egress: realtimeGatewayEgress, }), // The required runtimeInstanceId is authoritative: merge it in LAST so // `handle.metadata` reports the same id the transport stamps on every // envelope, regardless of any `env` overrides (which cannot carry it). - env: { ...(opts.env ?? {}), runtimeInstanceId: opts.runtimeInstanceId }, + env: { ...opts.env, runtimeInstanceId: opts.runtimeInstanceId }, }); } -/** Config for {@link startManagedBotsOverPhoenix}. */ -export interface ManagedPhoenixConfig { - /** Gateway runner WebSocket URL — the `/runner` socket hosting the - * `hosted_bots:project:` channel. */ +/** Config for {@link startChannelsOverRealtimeGateway}. */ +export interface StartChannelsOverRealtimeGatewayOptions { + /** Gateway runner WebSocket URL — the `/runner` endpoint hosting the + * `channels:project:` session. */ wsUrl: string; /** Project runtime API key (`cpk-…`), presented as the socket `authToken`. */ apiKey: string; - /** Authoritative org/project/bot scope echoed on every SDK→gateway envelope. */ + /** Authoritative org/project/channel scope echoed on every SDK→gateway envelope. */ scope: HostedBotRealtimeScope; /** Stable runtime instance id (`rti_…`). */ runtimeInstanceId: string; @@ -119,7 +118,7 @@ export interface ManagedPhoenixConfig { /** Activation env overrides (package versions, runtimeEnv); omitted fields * are gathered from the process. Included in the join's `runtimeMetadata` and * in `handle.metadata`. `runtimeInstanceId` is intentionally excluded — the - * required top-level {@link ManagedPhoenixConfig.runtimeInstanceId} is + * required top-level {@link StartChannelsOverRealtimeGatewayOptions.runtimeInstanceId} is * authoritative for both the join and `handle.metadata` (they must agree). */ env?: Partial>; /** Join timeout in ms. */ @@ -131,21 +130,19 @@ export interface ManagedPhoenixConfig { } /** - * The managed-over-Phoenix launcher (OSS-406): connect the realtime-gateway - * bot-IO channel, then run the declared bots against it via - * {@link startManagedBotsOnChannel}. This is the composition that runs a - * managed bot over the realtime path — the transport primitives are unit-tested; - * this wires them for a live run. The returned handle's `stop()` stops the bots - * and then disconnects the socket. + * Connect a Realtime Gateway session, then run the declared framework Bots + * against it via {@link startChannelsWithGatewaySession}. This is the + * composition that runs a managed channel over the realtime path. The returned + * handle's `stop()` stops the Bots and then disconnects the session. */ -export async function startManagedBotsOverPhoenix( +export async function startChannelsOverRealtimeGateway( bots: Bot[], - config: ManagedPhoenixConfig, + config: StartChannelsOverRealtimeGatewayOptions, ): Promise { const adapter = config.adapter ?? "slack"; // Fail fast BEFORE opening the socket: a missing/duplicate name would - // otherwise send a broken join (`botName: undefined`) and — because the same + // otherwise send a broken channel declaration and — because the same // check inside startManagedBots runs only after we've connected — throw with // the socket already open and never closed (a leak). Validating here means a // bad declaration never opens a connection at all. @@ -161,7 +158,7 @@ export async function startManagedBotsOverPhoenix( // spread LAST so it stays authoritative even though `config.env` cannot carry // it (type-excluded) — belt and suspenders for the join↔metadata invariant. const envOverrides: Partial = { - ...(config.env ?? {}), + ...config.env, runtimeInstanceId: config.runtimeInstanceId, }; const activation = buildActivationMetadata( @@ -169,14 +166,14 @@ export async function startManagedBotsOverPhoenix( resolveActivationEnv(envOverrides), ); - const channel = await connectPhoenixHostedBotChannel({ + const session = await connectRealtimeGateway({ wsUrl: config.wsUrl, apiKey: config.apiKey, projectId: config.scope.projectId, join: { runtimeInstanceId: config.runtimeInstanceId, - declaredBots: activation.declaredBots.map((b) => ({ - botName: b.name, + declaredChannels: activation.declaredBots.map((b) => ({ + channelName: b.name, adapter, // renderCapabilities: reserved — bots don't expose capabilities yet // (tracked with the richer per-bot metadata in OSS-377). @@ -201,34 +198,34 @@ export async function startManagedBotsOverPhoenix( ...(config.timeoutMs !== undefined ? { timeoutMs: config.timeoutMs } : {}), ...(config.webSocket !== undefined ? { webSocket: config.webSocket } : {}), }); - // The channel is now joined. If starting the bots throws (e.g. a bot was + // The session is now joined. If starting the Bots throws (e.g. a Bot was // already started, or a conflicting adapter), the caller never receives a // handle — so disconnect the socket here rather than leak it, then rethrow. let handle: ManagedBotsHandle; try { - handle = await startManagedBotsOnChannel(bots, { - channel, + handle = await startChannelsWithGatewaySession(bots, { + session, scope: config.scope, runtimeInstanceId: config.runtimeInstanceId, - // startManagedBotsOnChannel re-merges the authoritative runtimeInstanceId, + // The session-start helper re-merges the authoritative runtimeInstanceId, // so forward only the caller's overrides here (they cannot carry the id). ...(config.env ? { env: config.env } : {}), ...(config.log ? { log: config.log } : {}), }); } catch (err) { - channel.disconnect(); + session.disconnect(); throw err; } return { ...handle, stop: async () => { // Always close the connection even if stopping the bots throws — the - // launcher owns the socket (the transport is handed the channel and does + // launcher owns the socket (the transport is handed the session and does // not disconnect it itself). try { await handle.stop(); } finally { - channel.disconnect(); + session.disconnect(); } }, }; diff --git a/packages/channels-intelligence/src/phoenix-transport.ts b/packages/channels-intelligence/src/realtime-gateway-transport.ts similarity index 76% rename from packages/channels-intelligence/src/phoenix-transport.ts rename to packages/channels-intelligence/src/realtime-gateway-transport.ts index 953aa35d612..751e7461415 100644 --- a/packages/channels-intelligence/src/phoenix-transport.ts +++ b/packages/channels-intelligence/src/realtime-gateway-transport.ts @@ -1,21 +1,18 @@ -// Realtime-gateway (Phoenix) transport for the managed-bot SDK (OSS-402). +// Realtime Gateway transport for the managed Bot SDK (OSS-402). // // This is the production render/delivery path: the SDK joins the gateway's -// per-project bot-IO channel, receives leased deliveries, streams semantic -// `hosted_bot.render_event.v1` frames, waits for durable -// `hosted_bot.render_accepted.v1` receipts, and — only after frames are -// accepted — sends a `hosted_bot.delivery.complete_requested.v1` COMPLETION -// INTENT (never a committed `hosted_bot.delivery.ack.v1`; app-api owns ack). -// On failure it sends `hosted_bot.delivery.fail.v1`. The SDK never receives +// per-project session, receives leased deliveries, streams semantic +// `channel.render_event.v1` frames, waits for durable +// `channel.render_accepted.v1` receipts, and — only after frames are +// accepted — sends a `channel.delivery.complete_requested.v1` COMPLETION +// INTENT (never a committed `channel.delivery.ack.v1`; app-api owns ack). +// On failure it sends `channel.delivery.fail.v1`. The SDK never receives // Slack/provider credentials — rendering to the provider happens on the // gateway-side Connector Outbox. // -// The Phoenix `Socket`/`Channel` boilerplate is intentionally NOT imported -// here: the protocol (the risky part) is expressed against a minimal injected -// {@link HostedBotChannel}, so it is fully unit-testable with a fake channel. -// A deployment adapts its live Phoenix channel to this interface in a few -// lines (`push` wraps `channel.push(...).receive("ok"/"error")`; `on` wraps -// `channel.on(...)`). +// The connector implementation is intentionally not imported here: the +// protocol is expressed against an injected {@link RealtimeGatewaySession}, so +// it is fully unit-testable with a fake gateway session. // // Scope (out of this checkpoint): discrete `EgressSink` posts from // command/interaction handlers are not yet streamed as `post`/`update` render @@ -29,32 +26,22 @@ import type { RenderFrame, RenderAccepted, } from "./contracts.js"; +import type { RealtimeGatewaySession } from "./realtime-gateway.js"; -/** The org/project/bot scope every realtime envelope carries. */ +/** The org/project/channel scope every realtime envelope carries. */ export interface HostedBotRealtimeScope { organizationId: string; projectId: number; - botId: string; - botName: string; + channelId: string; + channelName: string; } -/** - * Minimal Phoenix channel surface this transport needs. A deployment adapts a - * live `phoenix` `Channel` to this: `push` resolves with the server's "ok" - * reply payload (and rejects on "error"/"timeout"); `on` subscribes to a - * server-pushed event. - */ -export interface HostedBotChannel { - push(event: string, payload: unknown): Promise; - on(event: string, handler: (payload: unknown) => void): void; -} - -export interface PhoenixTransportConfig { +export interface RealtimeGatewayTransportOptions { scope: HostedBotRealtimeScope; /** Unique per runtime instance (rti_…), echoed on every SDK->gateway event. */ runtimeInstanceId: string; - /** The joined bot-IO channel (topic `hosted_bots:project:{projectId}`). */ - channel: HostedBotChannel; + /** The joined Realtime Gateway session. */ + session: RealtimeGatewaySession; /** ISO timestamp source; injectable for deterministic tests. */ now?: () => string; /** @@ -65,44 +52,44 @@ export interface PhoenixTransportConfig { log?: (message: string, meta?: unknown) => void; } -const RENDER_EVENT = "hosted_bot.render_event.v1"; -const RENDER_ACCEPTED = "hosted_bot.render_accepted.v1"; -const DELIVERY_AVAILABLE = "hosted_bot.delivery.available.v1"; -const COMPLETE_REQUESTED = "hosted_bot.delivery.complete_requested.v1"; -const DELIVERY_FAIL = "hosted_bot.delivery.fail.v1"; +const RENDER_EVENT = "channel.render_event.v1"; +const RENDER_ACCEPTED = "channel.render_accepted.v1"; +const DELIVERY_AVAILABLE = "channel.delivery.available.v1"; +const COMPLETE_REQUESTED = "channel.delivery.complete_requested.v1"; +const DELIVERY_FAIL = "channel.delivery.fail.v1"; /** Per-delivery state the transport needs to build completion/fail intents. */ interface DeliveryState { turnId: string; /** app-api's per-delivery lease token, fences the complete/fail intent. */ leaseToken: string; - /** Authoritative org/project/bot scope from the delivery (not the transport default). */ + /** Authoritative org/project/channel scope from the delivery (not the transport default). */ scope: HostedBotRealtimeScope; /** Highest accepted `seq` per render slot (the completion high-water mark). */ accepted: Map; } /** - * Realtime-gateway transport implementing both the inbound {@link DeliverySource} + * Realtime Gateway transport implementing both the inbound {@link DeliverySource} * and the streaming {@link RenderEventSink}. `ack` maps to the completion * INTENT (`complete_requested`) and `nack` to `fail` — the SDK is never the * committed-ack authority. */ -export class PhoenixRealtimeTransport +export class RealtimeGatewayTransport implements DeliverySource, RenderEventSink { private readonly scope: HostedBotRealtimeScope; private readonly runtimeInstanceId: string; - private readonly channel: HostedBotChannel; + private readonly session: RealtimeGatewaySession; private readonly now: () => string; private readonly log?: (message: string, meta?: unknown) => void; private readonly deliveries = new Map(); private onDelivery?: (env: ManagedIngressEnvelope) => Promise; - constructor(config: PhoenixTransportConfig) { + constructor(config: RealtimeGatewayTransportOptions) { this.scope = config.scope; this.runtimeInstanceId = config.runtimeInstanceId; - this.channel = config.channel; + this.session = config.session; this.now = config.now ?? (() => new Date().toISOString()); this.log = config.log; } @@ -111,7 +98,7 @@ export class PhoenixRealtimeTransport onDelivery: (env: ManagedIngressEnvelope) => Promise, ): Promise { this.onDelivery = onDelivery; - this.channel.on(DELIVERY_AVAILABLE, (payload) => { + this.session.on(DELIVERY_AVAILABLE, (payload) => { void this.handleDeliveryAvailable(payload); }); } @@ -133,7 +120,7 @@ export class PhoenixRealtimeTransport * Map a `delivery.available.v1`'s claimed delivery to the adapter's ingress * envelope plus the authoritative `scope` and `leaseToken` the transport * stashes for later complete/fail intents. Returns `undefined` (delivery - * dropped, logged via {@link PhoenixTransportConfig.log}) when the turn + * dropped, logged via {@link RealtimeGatewayTransportOptions.log}) when the turn * identity is missing/unmodeled or the delivery carries no `leaseToken` (a * fenced intent can't be built without it). Only the text-turn shape is * handled in V1; other input kinds are ignored until modeled. @@ -163,7 +150,7 @@ export class PhoenixRealtimeTransport | undefined; if (!turn?.id || !turn.eventId) { // Malformed / unmodeled delivery shape — no turn identity to route on. - this.log?.("phoenix delivery dropped: missing turn id/eventId", { + this.log?.("realtime gateway delivery dropped: missing turn id/eventId", { deliveryId: delivery.id, }); return undefined; @@ -175,12 +162,14 @@ export class PhoenixRealtimeTransport // version-skew failure mode (gateway not yet emitting leaseToken); log it // loudly so it isn't an invisible, indefinitely re-looping outage. this.log?.( - "phoenix delivery dropped: no leaseToken on delivery.available (gateway/SDK version skew?) — will be re-leased", + "realtime gateway delivery dropped: no leaseToken on delivery.available (gateway/SDK version skew?) — will be re-leased", { deliveryId: delivery.id }, ); return undefined; } - const bot = delivery.bot as { id?: string; name?: string } | undefined; + const channel = delivery.channel as + | { id?: string; name?: string } + | undefined; const scope = { organizationId: String( delivery.organizationId ?? this.scope.organizationId, @@ -189,8 +178,8 @@ export class PhoenixRealtimeTransport typeof delivery.projectId === "number" ? delivery.projectId : this.scope.projectId, - botId: String(bot?.id ?? this.scope.botId), - botName: String(bot?.name ?? this.scope.botName), + channelId: String(channel?.id ?? this.scope.channelId), + channelName: String(channel?.name ?? this.scope.channelName), }; return { scope, @@ -200,7 +189,9 @@ export class PhoenixRealtimeTransport deliveryId: String(delivery.id), eventId: String(turn.eventId), turnId: String(turn.id), - botName: scope.botName, + // The framework's ingress envelope still routes to a Bot by name; + // Intelligence's wire contract calls that delivery entity a channel. + botName: scope.channelName, platform: String(delivery.adapter ?? "slack"), conversationKey: String(turn.id), route: turn.replyTarget, @@ -231,7 +222,7 @@ export class PhoenixRealtimeTransport sentAt: this.now(), }, }; - const reply = await this.channel.push(RENDER_EVENT, envelope); + const reply = await this.session.push(RENDER_EVENT, envelope); const receipt = this.parseAccepted(reply, idempotencyKey); // Record the completion high-water mark for this delivery/slot. if (state) { @@ -260,7 +251,7 @@ export class PhoenixRealtimeTransport const payload = r?.payload; if (r?.type !== RENDER_ACCEPTED || !payload?.acceptance) { throw new Error( - `PhoenixRealtimeTransport: expected ${RENDER_ACCEPTED} for ${idempotencyKey}, got ${r?.type ?? "no reply"}`, + `RealtimeGatewayTransport: expected ${RENDER_ACCEPTED} for ${idempotencyKey}, got ${r?.type ?? "no reply"}`, ); } return { @@ -290,7 +281,7 @@ export class PhoenixRealtimeTransport this.deliveries.delete(deliveryId); return; } - await this.channel.push(COMPLETE_REQUESTED, { + await this.session.push(COMPLETE_REQUESTED, { type: COMPLETE_REQUESTED, occurredAt: this.now(), payload: { @@ -315,7 +306,7 @@ export class PhoenixRealtimeTransport // be sent; app-api releases the delivery on lease lapse. Reachable only // for an already-terminal/evicted delivery today; log rather than drop // silently so an unexpected hit is diagnosable. - this.log?.("phoenix nack: no delivery state; dropping fail", { + this.log?.("realtime gateway nack: no delivery state; dropping fail", { deliveryId, reason, }); @@ -329,7 +320,7 @@ export class PhoenixRealtimeTransport }), ); const lastAccepted = accepted[accepted.length - 1]; - await this.channel.push(DELIVERY_FAIL, { + await this.session.push(DELIVERY_FAIL, { type: DELIVERY_FAIL, occurredAt: this.now(), payload: { diff --git a/packages/channels-intelligence/src/realtime-gateway.test.ts b/packages/channels-intelligence/src/realtime-gateway.test.ts new file mode 100644 index 00000000000..facee8ed0ea --- /dev/null +++ b/packages/channels-intelligence/src/realtime-gateway.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { connectRealtimeGateway } from "./realtime-gateway.js"; + +type JoinMode = "ok" | "error" | "never"; + +function makeFakeWebSocket(mode: JoinMode) { + const instances: FakeWebSocket[] = []; + class FakeWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + readyState = 0; + onopen: ((ev?: unknown) => void) | null = null; + onmessage: ((ev: { data: string }) => void) | null = null; + onerror: ((ev?: unknown) => void) | null = null; + onclose: ((ev?: unknown) => void) | null = null; + closed = false; + readonly frames: unknown[] = []; + + constructor(public readonly url: string) { + instances.push(this); + queueMicrotask(() => { + this.readyState = 1; + this.onopen?.(); + }); + } + + send(data: string): void { + if (mode === "never") return; + let frame: unknown; + try { + frame = JSON.parse(data); + } catch { + return; + } + this.frames.push(frame); + if (!Array.isArray(frame)) return; + const [joinRef, ref, topic, event] = frame as [ + string, + string, + string, + string, + ]; + if (event !== "phx_join") return; + const status = mode === "ok" ? "ok" : "error"; + const response = mode === "ok" ? {} : { reason: "unauthorized" }; + queueMicrotask(() => + this.onmessage?.({ + data: JSON.stringify([ + joinRef, + ref, + topic, + "phx_reply", + { status, response }, + ]), + }), + ); + } + + close(): void { + this.closed = true; + this.readyState = 3; + this.onclose?.(); + } + } + return { FakeWebSocket, instances }; +} + +describe("connectRealtimeGateway", () => { + it("joins the channel topic with declared channels and disconnects the gateway session", async () => { + const { FakeWebSocket, instances } = makeFakeWebSocket("ok"); + const join = { + runtimeInstanceId: "rti_1", + declaredChannels: [{ channelName: "opentag", adapter: "slack" }], + observedAt: "2026-07-10T00:00:00.000Z", + }; + + const session = await connectRealtimeGateway({ + wsUrl: "wss://gateway.example/socket", + apiKey: "cpk-test", + projectId: 7, + join, + webSocket: FakeWebSocket, + }); + + expect(instances).toHaveLength(1); + const joinFrame = instances[0]!.frames.find( + (frame) => Array.isArray(frame) && frame[3] === "phx_join", + ) as [string, string, string, string, unknown]; + expect(joinFrame[2]).toBe("channels:project:7"); + expect(joinFrame[4]).toEqual(join); + + session.disconnect(); + expect(instances[0]!.closed).toBe(true); + }); +}); diff --git a/packages/channels-intelligence/src/phoenix-channel.ts b/packages/channels-intelligence/src/realtime-gateway.ts similarity index 58% rename from packages/channels-intelligence/src/phoenix-channel.ts rename to packages/channels-intelligence/src/realtime-gateway.ts index 32f963e728a..2e873801748 100644 --- a/packages/channels-intelligence/src/phoenix-channel.ts +++ b/packages/channels-intelligence/src/realtime-gateway.ts @@ -1,22 +1,28 @@ import { Socket } from "phoenix"; -import type { HostedBotChannel } from "./phoenix-transport.js"; /** - * @internal Config for {@link connectPhoenixHostedBotChannel} — the live - * realtime-gateway connection behind {@link PhoenixRealtimeTransport}. + * Minimal Realtime Gateway session surface used by the delivery/render + * transport. The connector adapts its private socket implementation to this + * contract so callers never depend on a protocol client. */ -export interface PhoenixConnectConfig { +export interface RealtimeGatewaySession { + push(event: string, payload: unknown): Promise; + on(event: string, handler: (payload: unknown) => void): void; +} + +/** @internal Options for {@link connectRealtimeGateway}. */ +export interface ConnectRealtimeGatewayOptions { /** Gateway socket URL, e.g. `wss://gateway.example/socket`. */ wsUrl: string; /** Runtime API key (`cpk-…`) authenticating the socket. */ apiKey: string; - /** Numeric project id — the channel topic is `hosted_bots:project:{id}`. */ + /** Numeric project id — the session topic is `channels:project:{id}`. */ projectId: number; /** Listener declaration sent as the channel join payload. */ join: { runtimeInstanceId: string; - declaredBots: ReadonlyArray<{ - botName: string; + declaredChannels: ReadonlyArray<{ + channelName: string; adapter: string; renderCapabilities?: readonly string[]; }>; @@ -29,33 +35,31 @@ export interface PhoenixConnectConfig { webSocket?: unknown; } -/** A connected {@link HostedBotChannel} plus a `disconnect()` for shutdown. */ -export interface ConnectedHostedBotChannel extends HostedBotChannel { +/** A connected {@link RealtimeGatewaySession} plus a shutdown operation. */ +export interface ConnectedRealtimeGatewaySession extends RealtimeGatewaySession { disconnect(): void; } /** - * @internal Connect the SDK to the realtime-gateway hosted-bot IO channel and - * adapt the Phoenix `Channel` to the minimal {@link HostedBotChannel} contract - * {@link PhoenixRealtimeTransport} consumes: `push` resolves with the server's - * "ok" reply (rejects on "error"/"timeout"); `on` subscribes to a pushed event. + * @internal Connect the SDK to a Realtime Gateway session. Socket/client + * values stay private here; callers receive only {@link RealtimeGatewaySession}. * - * The channel is joined (declaring the runtime's bots) before the promise + * The session is joined (declaring the runtime's channels) before the promise * resolves, so the caller can immediately stream render frames. * * @param config - Gateway URL, auth, project scope, and join declaration. * @returns The connected channel with a `disconnect()` teardown. */ -export async function connectPhoenixHostedBotChannel( - config: PhoenixConnectConfig, -): Promise { +export async function connectRealtimeGateway( + config: ConnectRealtimeGatewayOptions, +): Promise { const timeout = config.timeoutMs ?? 10_000; const transport = config.webSocket ?? (globalThis as unknown as { WebSocket?: unknown }).WebSocket; if (!transport) { throw new Error( - "connectPhoenixHostedBotChannel: no WebSocket available — pass config.webSocket or run on Node 22+", + "connectRealtimeGateway: no WebSocket available — pass config.webSocket or run on Node 22+", ); } @@ -70,7 +74,7 @@ export async function connectPhoenixHostedBotChannel( socket.connect(); const channel = socket.channel( - `hosted_bots:project:${config.projectId}`, + `channels:project:${config.projectId}`, config.join as object, ); @@ -79,16 +83,18 @@ export async function connectPhoenixHostedBotChannel( .join(timeout) .receive("ok", () => resolve()) .receive("error", (reason: unknown) => { - // The join failed, so the caller never gets a channel it could + // The join failed, so the caller never gets a session it could // disconnect — tear the socket down here rather than leak it. socket.disconnect(); reject( - new Error(`hosted-bot channel join failed: ${safeReason(reason)}`), + new Error( + `realtime gateway session join failed: ${safeReason(reason)}`, + ), ); }) .receive("timeout", () => { socket.disconnect(); - reject(new Error("hosted-bot channel join timed out")); + reject(new Error("realtime gateway session join timed out")); }); }); @@ -99,10 +105,16 @@ export async function connectPhoenixHostedBotChannel( .push(event, payload as object, timeout) .receive("ok", (reply: unknown) => resolve(reply)) .receive("error", (reason: unknown) => - reject(new Error(`push ${event} failed: ${safeReason(reason)}`)), + reject( + new Error( + `realtime gateway session push ${event} failed: ${safeReason(reason)}`, + ), + ), ) .receive("timeout", () => - reject(new Error(`push ${event} timed out`)), + reject( + new Error(`realtime gateway session push ${event} timed out`), + ), ); }), on: (event, handler) => { diff --git a/packages/channels-intelligence/src/render-events.test.ts b/packages/channels-intelligence/src/render-events.test.ts index fdbe6d454c3..dbb23ac4a98 100644 --- a/packages/channels-intelligence/src/render-events.test.ts +++ b/packages/channels-intelligence/src/render-events.test.ts @@ -6,8 +6,8 @@ import { InMemoryEgressSink, InMemoryRenderEventSink, } from "./in-memory-transports.js"; -import { PhoenixRealtimeTransport } from "./phoenix-transport.js"; -import type { HostedBotChannel } from "./phoenix-transport.js"; +import { RealtimeGatewayTransport } from "./realtime-gateway-transport.js"; +import type { RealtimeGatewaySession } from "./realtime-gateway.js"; const target = { route: { channel: "C1", threadTs: "100.0" }, @@ -128,17 +128,17 @@ describe("run renderer — render-event streaming (OSS-402)", () => { }); }); -/** A fake Phoenix channel that records pushes and replies with render_accepted. */ -function makeFakeChannel() { +/** A fake Realtime Gateway session that records pushes and replies with render_accepted. */ +function makeFakeSession() { const pushes: { event: string; payload: unknown }[] = []; const handlers = new Map void>(); - const channel: HostedBotChannel = { + const session: RealtimeGatewaySession = { push: async (event, payload) => { pushes.push({ event, payload }); - if (event === "hosted_bot.render_event.v1") { + if (event === "channel.render_event.v1") { const p = (payload as { payload: Record }).payload; return { - type: "hosted_bot.render_accepted.v1", + type: "channel.render_accepted.v1", occurredAt: "2026-07-01T00:00:00.000Z", payload: { idempotencyKey: p.idempotencyKey, @@ -155,38 +155,38 @@ function makeFakeChannel() { handlers.set(event, handler); }, }; - return { channel, pushes, handlers }; + return { session, pushes, handlers }; } -describe("PhoenixRealtimeTransport — completion intent, never self-ack", () => { - const cfg = (channel: HostedBotChannel) => ({ +describe("RealtimeGatewayTransport — completion intent, never self-ack", () => { + const cfg = (session: RealtimeGatewaySession) => ({ scope: { organizationId: "org_1", projectId: 7, - botId: "bot_1", - botName: "support", + channelId: "channel_1", + channelName: "support", }, runtimeInstanceId: "rti_1", - channel, + session, now: () => "2026-07-01T00:00:00.000Z", }); it("streams render frames, awaits receipts, then sends complete_requested (not ack)", async () => { - const fake = makeFakeChannel(); - const t = new PhoenixRealtimeTransport(cfg(fake.channel)); + const fake = makeFakeSession(); + const t = new RealtimeGatewayTransport(cfg(fake.session)); - // Simulate a leased delivery arriving over the channel. + // Simulate a leased delivery arriving over the gateway session. let delivered = false; await t.start(async () => { delivered = true; }); - fake.handlers.get("hosted_bot.delivery.available.v1")?.({ + fake.handlers.get("channel.delivery.available.v1")?.({ payload: { delivery: { id: "dlv_d1", leaseToken: "lease_l1", adapter: "slack", - bot: { id: "bot_1", name: "support" }, + channel: { id: "channel_1", name: "support" }, turn: { id: "turn_t1", eventId: "evt_1", @@ -224,12 +224,12 @@ describe("PhoenixRealtimeTransport — completion intent, never self-ack", () => const events = fake.pushes.map((p) => p.event); // render frames first, then the completion INTENT. expect(events).toEqual([ - "hosted_bot.render_event.v1", - "hosted_bot.render_event.v1", - "hosted_bot.delivery.complete_requested.v1", + "channel.render_event.v1", + "channel.render_event.v1", + "channel.delivery.complete_requested.v1", ]); // The SDK must NEVER emit a committed delivery ack. - expect(events).not.toContain("hosted_bot.delivery.ack.v1"); + expect(events).not.toContain("channel.delivery.ack.v1"); const completion = fake.pushes.at(-1)!.payload as { payload: { @@ -244,20 +244,20 @@ describe("PhoenixRealtimeTransport — completion intent, never self-ack", () => expect(completion.payload.runtimeInstanceId).toBe("rti_1"); // OSS-446: render-accept + completion intent are both fenced on the lease. const render = fake.pushes.find( - (p) => p.event === "hosted_bot.render_event.v1", + (p) => p.event === "channel.render_event.v1", )!.payload as { payload: { leaseToken?: string } }; expect(render.payload.leaseToken).toBe("lease_l1"); expect(completion.payload.leaseToken).toBe("lease_l1"); }); it("throws if a render frame is not accepted (no silent success)", async () => { - const fake = makeFakeChannel(); + const fake = makeFakeSession(); // Override push to reply with a non-accepted envelope. - const channel: HostedBotChannel = { - push: async () => ({ type: "hosted_bot.something_else.v1" }), - on: fake.channel.on, + const session: RealtimeGatewaySession = { + push: async () => ({ type: "channel.something_else.v1" }), + on: fake.session.on, }; - const t = new PhoenixRealtimeTransport(cfg(channel)); + const t = new RealtimeGatewayTransport(cfg(session)); await expect( t.push({ deliveryId: "dlv_d1", @@ -270,16 +270,16 @@ describe("PhoenixRealtimeTransport — completion intent, never self-ack", () => }); it("nack sends a fail event, never an ack", async () => { - const fake = makeFakeChannel(); - const t = new PhoenixRealtimeTransport(cfg(fake.channel)); + const fake = makeFakeSession(); + const t = new RealtimeGatewayTransport(cfg(fake.session)); await t.start(async () => {}); - fake.handlers.get("hosted_bot.delivery.available.v1")?.({ + fake.handlers.get("channel.delivery.available.v1")?.({ payload: { delivery: { id: "dlv_d1", leaseToken: "lease_l1", adapter: "slack", - bot: { id: "bot_1", name: "support" }, + channel: { id: "channel_1", name: "support" }, turn: { id: "turn_t1", eventId: "evt_1", @@ -291,10 +291,10 @@ describe("PhoenixRealtimeTransport — completion intent, never self-ack", () => await Promise.resolve(); await t.nack("dlv_d1", "boom"); const events = fake.pushes.map((p) => p.event); - expect(events).toContain("hosted_bot.delivery.fail.v1"); - expect(events).not.toContain("hosted_bot.delivery.ack.v1"); + expect(events).toContain("channel.delivery.fail.v1"); + expect(events).not.toContain("channel.delivery.ack.v1"); const fail = fake.pushes.find( - (p) => p.event === "hosted_bot.delivery.fail.v1", + (p) => p.event === "channel.delivery.fail.v1", ); expect( (fail?.payload as { payload?: { leaseToken?: string } }).payload @@ -303,23 +303,23 @@ describe("PhoenixRealtimeTransport — completion intent, never self-ack", () => }); it("drops a delivery with no leaseToken (never fires onDelivery) and logs it", async () => { - const fake = makeFakeChannel(); + const fake = makeFakeSession(); const logs: string[] = []; - const t = new PhoenixRealtimeTransport({ - ...cfg(fake.channel), + const t = new RealtimeGatewayTransport({ + ...cfg(fake.session), log: (m) => logs.push(m), }); let delivered = false; await t.start(async () => { delivered = true; }); - fake.handlers.get("hosted_bot.delivery.available.v1")?.({ + fake.handlers.get("channel.delivery.available.v1")?.({ payload: { delivery: { id: "dlv_d1", // no leaseToken → the SDK can't build a fenced complete/fail intent adapter: "slack", - bot: { id: "bot_1", name: "support" }, + channel: { id: "channel_1", name: "support" }, turn: { id: "turn_t1", eventId: "evt_1", @@ -335,10 +335,10 @@ describe("PhoenixRealtimeTransport — completion intent, never self-ack", () => }); it("nack with no delivery state sends nothing and logs it", async () => { - const fake = makeFakeChannel(); + const fake = makeFakeSession(); const logs: string[] = []; - const t = new PhoenixRealtimeTransport({ - ...cfg(fake.channel), + const t = new RealtimeGatewayTransport({ + ...cfg(fake.session), log: (m) => logs.push(m), }); await t.start(async () => {}); @@ -350,12 +350,12 @@ describe("PhoenixRealtimeTransport — completion intent, never self-ack", () => }); it("stamps the delivery's authoritative scope (not the transport default) on render + fail", async () => { - const fake = makeFakeChannel(); - // Transport default scope is org_1 / 7 / bot_1; the delivery carries a + const fake = makeFakeSession(); + // Transport default scope is org_1 / 7 / channel_1; the delivery carries a // DIFFERENT authoritative scope, so this proves DeliveryState.scope is used. - const t = new PhoenixRealtimeTransport(cfg(fake.channel)); + const t = new RealtimeGatewayTransport(cfg(fake.session)); await t.start(async () => {}); - fake.handlers.get("hosted_bot.delivery.available.v1")?.({ + fake.handlers.get("channel.delivery.available.v1")?.({ payload: { delivery: { id: "dlv_d1", @@ -363,7 +363,7 @@ describe("PhoenixRealtimeTransport — completion intent, never self-ack", () => organizationId: "org_OTHER", projectId: 99, adapter: "slack", - bot: { id: "bot_OTHER", name: "other-bot" }, + channel: { id: "channel_OTHER", name: "other-channel" }, turn: { id: "turn_t1", eventId: "evt_1", @@ -390,13 +390,13 @@ describe("PhoenixRealtimeTransport — completion intent, never self-ack", () => } ).payload; for (const p of [ - inner("hosted_bot.render_event.v1"), - inner("hosted_bot.delivery.fail.v1"), + inner("channel.render_event.v1"), + inner("channel.delivery.fail.v1"), ]) { expect(p.organizationId).toBe("org_OTHER"); expect(p.projectId).toBe(99); - expect(p.botId).toBe("bot_OTHER"); - expect(p.botName).toBe("other-bot"); + expect(p.channelId).toBe("channel_OTHER"); + expect(p.channelName).toBe("other-channel"); } }); }); diff --git a/packages/channels-intelligence/src/transports.ts b/packages/channels-intelligence/src/transports.ts index 6c71abef4bc..2df6d1231ce 100644 --- a/packages/channels-intelligence/src/transports.ts +++ b/packages/channels-intelligence/src/transports.ts @@ -89,7 +89,7 @@ export interface EgressSink { * {@link RenderAccepted} receipt for each before proceeding — the SDK never * assumes acceptance and never commits the delivery ack (app-api owns that; * the {@link DeliverySource} completion signal is the SDK's only terminal - * intent). Implemented by the Realtime Gateway (Phoenix) client in production; + * intent). Implemented by the Realtime Gateway client in production; * headless/HTTP-fallback runs translate frames back to {@link EgressSink} * `post` operations so a Connector Outbox isn't required to see plain-text * replies. From b9091c40a4bbb7390a49762b653e6fab18429dcf Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Fri, 10 Jul 2026 14:11:41 -0700 Subject: [PATCH 31/40] fix(web-inspector): address threads onboarding review --- .../src/__tests__/web-inspector.spec.ts | 102 ++++++++++++++++++ packages/web-inspector/src/index.ts | 25 +---- 2 files changed, 107 insertions(+), 20 deletions(-) diff --git a/packages/web-inspector/src/__tests__/web-inspector.spec.ts b/packages/web-inspector/src/__tests__/web-inspector.spec.ts index ac400caf53b..e960f176a05 100644 --- a/packages/web-inspector/src/__tests__/web-inspector.spec.ts +++ b/packages/web-inspector/src/__tests__/web-inspector.spec.ts @@ -1933,6 +1933,108 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => { ); }); + it("does not load the empty overview video when reduced motion is preferred", async () => { + vi.useFakeTimers(); + vi.stubGlobal("matchMedia", () => ({ matches: true })); + + const { agent } = createMockAgent("alpha"); + const harness = createHeaderMockCore({ alpha: agent }, {}, {}, false); + + const inspector = new WebInspectorElement(); + document.body.appendChild(inspector); + inspector.core = harness.core as unknown as WebInspectorElement["core"]; + harness.emitAgentsChanged(); + + const internals = inspector as unknown as { + isOpen: boolean; + handleMenuSelect: (key: "threads") => void; + }; + internals.isOpen = true; + internals.handleMenuSelect("threads"); + await inspector.updateComplete; + + expect(inspector.shadowRoot?.textContent ?? "").toContain( + "Threads are persistent, inspectable conversations", + ); + expect( + inspector.shadowRoot?.querySelector(".cpk-threads-overview-video-frame"), + ).not.toBeNull(); + + await vi.advanceTimersByTimeAsync(1200); + await inspector.updateComplete; + + expect( + inspector.shadowRoot?.querySelector(".cpk-threads-overview-video"), + ).toBeNull(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("clears the deferred video timeout when disconnected before load", async () => { + vi.useFakeTimers(); + vi.stubGlobal("matchMedia", () => ({ matches: false })); + Object.defineProperty(window, "requestIdleCallback", { + configurable: true, + value: undefined, + }); + const clearTimeoutSpy = vi.spyOn(window, "clearTimeout"); + + const { agent } = createMockAgent("alpha"); + const harness = createHeaderMockCore({ alpha: agent }, {}, {}, false); + + const inspector = new WebInspectorElement(); + document.body.appendChild(inspector); + inspector.core = harness.core as unknown as WebInspectorElement["core"]; + harness.emitAgentsChanged(); + + const internals = inspector as unknown as { + isOpen: boolean; + handleMenuSelect: (key: "threads") => void; + }; + internals.isOpen = true; + internals.handleMenuSelect("threads"); + await inspector.updateComplete; + + expect(vi.getTimerCount()).toBe(1); + inspector.remove(); + expect(clearTimeoutSpy).toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("cancels the deferred video idle callback when disconnected before load", async () => { + vi.useFakeTimers(); + vi.stubGlobal("matchMedia", () => ({ matches: false })); + const requestIdleCallback = vi.fn(() => 123); + const cancelIdleCallback = vi.fn(); + Object.defineProperty(window, "requestIdleCallback", { + configurable: true, + value: requestIdleCallback, + }); + Object.defineProperty(window, "cancelIdleCallback", { + configurable: true, + value: cancelIdleCallback, + }); + + const { agent } = createMockAgent("alpha"); + const harness = createHeaderMockCore({ alpha: agent }, {}, {}, false); + + const inspector = new WebInspectorElement(); + document.body.appendChild(inspector); + inspector.core = harness.core as unknown as WebInspectorElement["core"]; + harness.emitAgentsChanged(); + + const internals = inspector as unknown as { + isOpen: boolean; + handleMenuSelect: (key: "threads") => void; + }; + internals.isOpen = true; + internals.handleMenuSelect("threads"); + await inspector.updateComplete; + + expect(requestIdleCallback).toHaveBeenCalledTimes(1); + inspector.remove(); + expect(cancelIdleCallback).toHaveBeenCalledWith(123); + }); + it("does not render example threads once real threads are present", async () => { const inspector = new WebInspectorElement(); document.body.appendChild(inspector); diff --git a/packages/web-inspector/src/index.ts b/packages/web-inspector/src/index.ts index a83408fe1b5..79f8888dfe3 100644 --- a/packages/web-inspector/src/index.ts +++ b/packages/web-inspector/src/index.ts @@ -6273,7 +6273,7 @@ ${argsString} Date: Fri, 10 Jul 2026 14:39:56 -0700 Subject: [PATCH 32/40] refactor(channels-intelligence): rename remaining channel APIs --- examples/slack/.env.example | 14 +-- examples/slack/app/managed.test.ts | 27 ++-- examples/slack/app/managed.ts | 61 ++++----- examples/slack/package.json | 2 +- packages/channels-intelligence/package.json | 4 +- .../src/content-parts.ts | 4 +- .../channels-intelligence/src/contracts.ts | 48 ++++--- .../src/http-transports.test.ts | 11 +- .../src/http-transports.ts | 42 +++---- .../src/in-memory-transports.ts | 10 +- packages/channels-intelligence/src/index.ts | 37 +++--- .../src/intelligence-adapter.test.ts | 22 ++-- .../src/intelligence-adapter.ts | 68 +++++----- .../src/intelligence-state-store.ts | 10 +- .../channels-intelligence/src/ir-to-text.ts | 2 +- .../src/realtime-gateway-launcher.test.ts | 33 ++++- .../src/realtime-gateway-launcher.ts | 84 ++++++++----- .../src/realtime-gateway-transport.ts | 65 +++++++--- .../src/realtime-gateway.test.ts | 24 ++++ .../src/realtime-gateway.ts | 5 + .../channels-intelligence/src/runtime.test.ts | 90 +++++++------ packages/channels-intelligence/src/runtime.ts | 118 +++++++++--------- .../channels-intelligence/src/transports.ts | 12 +- packages/channels/src/create-bot.ts | 26 ++-- packages/channels/src/index.ts | 4 +- packages/channels/src/platform-adapter.ts | 16 +-- packages/channels/src/reactions.test.ts | 2 +- .../runtime/src/v2/runtime/core/runtime.ts | 16 +-- 28 files changed, 492 insertions(+), 365 deletions(-) diff --git a/examples/slack/.env.example b/examples/slack/.env.example index d6bb9f89d8e..f54bccd4dd5 100644 --- a/examples/slack/.env.example +++ b/examples/slack/.env.example @@ -39,10 +39,10 @@ OPENAI_API_KEY=sk-... # ANTHROPIC_API_KEY=sk-ant-... # GOOGLE_API_KEY=... -# ── Managed (Intelligence-hosted) mode — app/managed.ts (`pnpm managed`) ── -# The managed variant runs the SAME bot over the Intelligence realtime-gateway +# ── Intelligence Channel mode — app/managed.ts (`pnpm channel`) ────────── +# The Channel variant runs the SAME bot over the Intelligence realtime gateway # instead of a native adapter — it holds NO Slack tokens (Intelligence owns the -# Slack edge). Set these to run `pnpm managed`; the native `pnpm dev` ignores +# Slack edge). Set these to run `pnpm channel`; the native `pnpm dev` ignores # them. The agent brain reuses AGENT_URL / AGENT_AUTH_HEADER above. # Gateway runner WebSocket URL (the /runner socket hosting the bot-IO channel). # INTELLIGENCE_GATEWAY_WS_URL=wss://gateway.intelligence.example/runner @@ -50,11 +50,11 @@ OPENAI_API_KEY=sk-... # INTELLIGENCE_API_KEY=cpk-... # Product organization id (as issued by Intelligence). # INTELLIGENCE_ORG_ID=org_... -# Numeric project id (the channel topic is hosted_bots:project:). +# Numeric project id (the channel topic is channels:project:). # INTELLIGENCE_PROJECT_ID=123 -# Bot id + immutable project-unique bot name (from Intelligence bot setup). -# INTELLIGENCE_BOT_ID=bot_... -# INTELLIGENCE_BOT_NAME=triage +# Channel id + immutable project-unique channel name (from Intelligence Channel setup). +# INTELLIGENCE_CHANNEL_ID=channel_... +# INTELLIGENCE_CHANNEL_NAME=triage # Optional: stable runtime instance id; a random rti_… is generated if unset. # INTELLIGENCE_RUNTIME_INSTANCE_ID=rti_... diff --git a/examples/slack/app/managed.test.ts b/examples/slack/app/managed.test.ts index 2f085559a4d..3f3bee93790 100644 --- a/examples/slack/app/managed.test.ts +++ b/examples/slack/app/managed.test.ts @@ -7,7 +7,7 @@ const fakes = vi.hoisted(() => { return { stop, closeBrowser: vi.fn(async () => {}), - startManagedBotsOverPhoenix: vi.fn(async () => ({ stop })), + startChannelsOverRealtimeGateway: vi.fn(async () => ({ stop })), bot: { onMention: vi.fn(), onModalSubmit: vi.fn(), @@ -22,10 +22,10 @@ vi.mock("@copilotkit/channels", () => ({ vi.mock("@copilotkit/channels-slack", () => ({ defaultSlackTools: [], defaultSlackContext: [], - SanitizingHttpAgent: class {}, + SanitizingHttpAgent: function SanitizingHttpAgent() {}, })); vi.mock("@copilotkit/channels-intelligence", () => ({ - startManagedBotsOverPhoenix: fakes.startManagedBotsOverPhoenix, + startChannelsOverRealtimeGateway: fakes.startChannelsOverRealtimeGateway, })); vi.mock("./tools/index.js", () => ({ appTools: [] })); vi.mock("./context/app-context.js", () => ({ appContext: [] })); @@ -40,14 +40,14 @@ vi.mock("./render/browser.js", () => ({ closeBrowser: fakes.closeBrowser })); const envKeys = [ "AGENT_URL", "INTELLIGENCE_PROJECT_ID", - "INTELLIGENCE_BOT_NAME", + "INTELLIGENCE_CHANNEL_NAME", "INTELLIGENCE_GATEWAY_WS_URL", "INTELLIGENCE_API_KEY", "INTELLIGENCE_ORG_ID", - "INTELLIGENCE_BOT_ID", + "INTELLIGENCE_CHANNEL_ID", ] as const; -describe("managed entrypoint shutdown", () => { +describe("channel entrypoint shutdown", () => { const previousEnv = new Map(); afterEach(() => { @@ -60,15 +60,15 @@ describe("managed entrypoint shutdown", () => { previousEnv.clear(); }); - it("exits nonzero when stopping the managed runtime fails", async () => { + it("exits nonzero when stopping the channel runtime fails", async () => { for (const key of envKeys) previousEnv.set(key, process.env[key]); process.env.AGENT_URL = "http://agent.test/run"; process.env.INTELLIGENCE_PROJECT_ID = "7"; - process.env.INTELLIGENCE_BOT_NAME = "opentag"; + process.env.INTELLIGENCE_CHANNEL_NAME = "opentag"; process.env.INTELLIGENCE_GATEWAY_WS_URL = "wss://gateway.test/runner"; process.env.INTELLIGENCE_API_KEY = "cpk-test"; process.env.INTELLIGENCE_ORG_ID = "org_1"; - process.env.INTELLIGENCE_BOT_ID = "bot_1"; + process.env.INTELLIGENCE_CHANNEL_ID = "channel_1"; let sigterm: (() => void) | undefined; vi.spyOn(process, "on").mockImplementation(((event, listener) => { @@ -83,6 +83,15 @@ describe("managed entrypoint shutdown", () => { await import("./managed.js"); await vi.waitFor(() => expect(sigterm).toBeTypeOf("function")); + expect(fakes.startChannelsOverRealtimeGateway).toHaveBeenCalledWith( + [fakes.bot], + expect.objectContaining({ + scope: expect.objectContaining({ + channelId: "channel_1", + channelName: "opentag", + }), + }), + ); sigterm!(); await vi.waitFor(() => expect(exit).toHaveBeenCalled()); diff --git a/examples/slack/app/managed.ts b/examples/slack/app/managed.ts index a43f00a6c72..24f57d70227 100644 --- a/examples/slack/app/managed.ts +++ b/examples/slack/app/managed.ts @@ -1,23 +1,23 @@ /** - * Managed (Intelligence-hosted) entrypoint for the same Slack bot as + * Intelligence Channel entrypoint for the same Slack bot as * `app/index.ts`. * * `index.ts` is the SELF-HOSTED variant: it holds the Slack bot/app tokens and * talks to Slack directly via the native `slack()` adapter. This file is the - * MANAGED variant: it holds no Slack credentials and no public endpoint — it - * connects to the Intelligence realtime-gateway over Phoenix, receives leased + * Channel variant: it holds no Slack credentials and no public endpoint — it + * connects to the Intelligence Realtime Gateway, receives leased * deliveries, and streams render frames back. Intelligence owns the Slack edge * (signed ingress → app-api, egress via the Connector Outbox). * * The bot itself — the agent, tools, context, commands, and turn handlers — is * IDENTICAL to the native bot; only the transport changes. `intelligenceAdapter` - * is exclusive, so the managed bot is created WITHOUT a native adapter and - * {@link startManagedBotsOverPhoenix} attaches the managed transport. + * is exclusive, so the Channel Bot is created WITHOUT a native adapter and + * {@link startChannelsOverRealtimeGateway} attaches the Channel transport. * * native: createBot({ adapters: [slack({ botToken, appToken }) ] }) // index.ts - * managed: startManagedBotsOverPhoenix([ createBot({ … }) ], { … }) // this file + * channel: startChannelsOverRealtimeGateway([ createBot({ … }) ], { … }) // this file * - * Run: `pnpm --filter slack-example managed` with the INTELLIGENCE_* env set + * Run: `pnpm --filter slack-example channel` with the INTELLIGENCE_* env set * (see `.env.example`). */ import "dotenv/config"; @@ -28,7 +28,7 @@ import { defaultSlackContext, SanitizingHttpAgent, } from "@copilotkit/channels-slack"; -import { startManagedBotsOverPhoenix } from "@copilotkit/channels-intelligence"; +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"; @@ -52,20 +52,20 @@ async function main() { : undefined; const projectId = Number(required("INTELLIGENCE_PROJECT_ID")); - if (!Number.isInteger(projectId) || projectId < 0) { + if (!Number.isInteger(projectId) || projectId <= 0) { console.error( `Invalid INTELLIGENCE_PROJECT_ID: "${process.env.INTELLIGENCE_PROJECT_ID}"`, ); process.exit(1); } - const botName = required("INTELLIGENCE_BOT_NAME"); + const channelName = required("INTELLIGENCE_CHANNEL_NAME"); - // Same bot as the native example, minus the adapter: the managed transport is - // attached by startManagedBotsOverPhoenix. Slack is the only managed provider + // Same Slack Bot as the native example, minus the adapter: the Channel transport is + // attached by startChannelsOverRealtimeGateway. Slack is the only Channel provider // here, so it always ships the Slack tools/context (the native example adds // these conditionally per active adapter). const bot = createBot({ - name: botName, + name: channelName, agent: (threadId) => { const a = new SanitizingHttpAgent({ url: agentUrl, @@ -82,7 +82,7 @@ async function main() { // Turn + feature handlers — identical to the native example (app/index.ts). bot.onMention(async ({ thread, message }) => { try { - // Managed history (app-api /api/bots/history) does NOT include the + // Channel history (app-api /api/channels/history) does NOT include the // in-flight turn (unlike native adapters whose getHistory rebuilds the // live thread), so pass the current message explicitly as `prompt` — // otherwise runAgent runs with zero messages. Prefer multimodal parts. @@ -93,10 +93,12 @@ async function main() { context: senderContext(message.user, thread.platform), }); } catch (err) { - console.error("[bot] agent run failed", err); + console.error("[channel] agent run failed", err); await thread .post("Sorry — I hit an error handling that. Please try again.") - .catch(() => {}); + .catch((postErr: unknown) => + console.error("[channel] failed to post agent error", postErr), + ); } }); bot.onModalSubmit(FILE_ISSUE_CALLBACK, fileIssueSubmit); @@ -114,14 +116,14 @@ async function main() { ]); }); - const handle = await startManagedBotsOverPhoenix([bot], { + const handle = await startChannelsOverRealtimeGateway([bot], { wsUrl: required("INTELLIGENCE_GATEWAY_WS_URL"), apiKey: required("INTELLIGENCE_API_KEY"), scope: { organizationId: required("INTELLIGENCE_ORG_ID"), projectId, - botId: required("INTELLIGENCE_BOT_ID"), - botName, + channelId: required("INTELLIGENCE_CHANNEL_ID"), + channelName, }, runtimeInstanceId: process.env.INTELLIGENCE_RUNTIME_INSTANCE_ID ?? @@ -131,32 +133,35 @@ async function main() { // above) can contain message content/payloads — the design says telemetry // must not include raw message text. In production, drop this `log` or trim // `meta` to safe fields (ids, counts) before emitting. - log: (msg, meta) => console.log(`[managed] ${msg}`, meta ?? ""), + log: (msg, meta) => console.log(`[channel] ${msg}`, meta ?? ""), }); console.log( - `[bot] started managed (Phoenix) as "${botName}" on project ${projectId}`, + `[channel] started over Realtime Gateway as "${channelName}" on project ${projectId}`, ); const shutdown = async (signal: string) => { - console.log(`\n[bot] received ${signal}, stopping…`); + console.log(`\n[channel] received ${signal}, stopping…`); let exitCode = 0; try { await handle.stop(); } catch (err) { - console.error("[bot] error stopping managed runtime", err); + console.error("[channel] error stopping Channel runtime", err); exitCode = 1; } // Browser teardown is best-effort, but still surface a failure rather than // swallow it silently. await closeBrowser().catch((err: unknown) => - console.error("[bot] browser cleanup failed (continuing shutdown)", err), + console.error( + "[channel] browser cleanup failed (continuing shutdown)", + err, + ), ); process.exit(exitCode); }; // A failed shutdown must not vanish — log it and exit nonzero. const runShutdown = (signal: string): void => { shutdown(signal).catch((err: unknown) => { - console.error(`[bot] fatal during ${signal} shutdown`, err); + console.error(`[channel] fatal during ${signal} shutdown`, err); process.exit(1); }); }; @@ -167,13 +172,13 @@ async function main() { // Fail loud, not silent: surface any stray async error instead of letting it // kill the process with no log (mirrors the native entrypoint). process.on("unhandledRejection", (reason) => { - console.error("[bot] unhandledRejection:", reason); + console.error("[channel] unhandledRejection:", reason); }); process.on("uncaughtException", (err) => { - console.error("[bot] uncaughtException:", err); + console.error("[channel] uncaughtException:", err); }); main().catch((err: unknown) => { - console.error("[bot] fatal: failed to start managed runtime", err); + console.error("[channel] fatal: failed to start Channel runtime", err); process.exit(1); }); diff --git a/examples/slack/package.json b/examples/slack/package.json index a4575b9d760..fb4ada7eabe 100644 --- a/examples/slack/package.json +++ b/examples/slack/package.json @@ -8,7 +8,7 @@ "scripts": { "dev": "tsx watch app/index.ts", "start": "tsx app/index.ts", - "managed": "tsx app/managed.ts", + "channel": "tsx app/managed.ts", "build": "pnpm exec nx run-many -t build -p \"@copilotkit/channels*\" @copilotkit/runtime", "runtime": "tsx runtime.ts", "notion-mcp": "tsx scripts/start-notion-mcp.ts", diff --git a/packages/channels-intelligence/package.json b/packages/channels-intelligence/package.json index 3c739942445..c77d64c7781 100644 --- a/packages/channels-intelligence/package.json +++ b/packages/channels-intelligence/package.json @@ -1,7 +1,7 @@ { "name": "@copilotkit/channels-intelligence", "version": "0.1.0", - "description": "Intelligence-delivered managed-bot adapter for CopilotKit JSX channels (@copilotkit/channels).", + "description": "Intelligence-delivered Channel adapter for CopilotKit JSX channels (@copilotkit/channels).", "license": "MIT", "repository": { "type": "git", @@ -14,7 +14,7 @@ "agent", "bot", "intelligence", - "managed-bots", + "channels", "copilotkit", "ag-ui" ], diff --git a/packages/channels-intelligence/src/content-parts.ts b/packages/channels-intelligence/src/content-parts.ts index 3ba0b9a00ae..99e29c9f3da 100644 --- a/packages/channels-intelligence/src/content-parts.ts +++ b/packages/channels-intelligence/src/content-parts.ts @@ -1,5 +1,5 @@ import type { AgentContentPart } from "@copilotkit/channels-ui"; -import type { ManagedFileRef } from "./contracts.js"; +import type { ChannelFileRef } from "./contracts.js"; /** Map a MIME type to its `AgentContentPart` media kind, or null for non-media. */ export function mediaKindForMime( @@ -27,7 +27,7 @@ export function mediaKindForMime( * historical image attachment and a live one produce the same content part. */ export async function buildContentParts( - files: ManagedFileRef[] | undefined, + files: ChannelFileRef[] | undefined, fetchFile: | ((handle: string) => Promise<{ bytes: Uint8Array; mimeType?: string }>) | undefined, diff --git a/packages/channels-intelligence/src/contracts.ts b/packages/channels-intelligence/src/contracts.ts index 9fcd1d1fcbc..605e0a661db 100644 --- a/packages/channels-intelligence/src/contracts.ts +++ b/packages/channels-intelligence/src/contracts.ts @@ -1,5 +1,5 @@ // TODO(OSS-377): replace this module with the shared -// `@copilotkit/managed-bot-contracts` package once it lands. This is a minimal, +// `@copilotkit/channel-contracts` package once it lands. This is a minimal, // self-owned placeholder: ONLY the fields the bridge adapter actually reads or // writes are typed. The rest of the OSS-377 surface — Zod schemas, the full // event-kind set, bounded failure codes, health/read models — is intentionally @@ -13,16 +13,24 @@ import type { BotNode, MessageRef } from "@copilotkit/channels-ui"; */ export type EgressRoute = unknown; -/** Fields common to every managed ingress envelope. */ -export interface ManagedIngressBase { +/** Authoritative org/project/channel scope that arrived with a delivery. */ +export interface ChannelDeliveryScope { + organizationId: string; + projectId: number; + channelId: string; + channelName: string; +} + +/** Fields common to every Intelligence Channel ingress envelope. */ +export interface ChannelIngressBase { /** Unique per delivery attempt (lease). At-least-once: may be redelivered. */ deliveryId: string; /** Stable platform event id (idempotency / dedup). */ eventId: string; /** Stable per-logical-turn id. Egress operation ids derive from it. */ turnId: string; - /** Project-unique bot identifier (matches `createBot({ name })`). */ - botName: string; + /** Channel name, which routes to the framework `Bot` named by `createBot({ name })`. */ + channelName: string; /** Originating platform (e.g. "slack"). Stamped onto the handler-facing message. */ platform: string; conversationKey: string; @@ -43,21 +51,21 @@ export interface ManagedIngressBase { * bytes). The adapter fetches the bytes lazily via * {@link DeliverySource.fetchFile} and builds `AgentContentPart`s from them. */ -export interface ManagedFileRef { +export interface ChannelFileRef { handle: string; filename: string; mimeType?: string; byteSize?: number; } -export type ManagedIngressEnvelope = - | (ManagedIngressBase & { +export type ChannelIngressEnvelope = + | (ChannelIngressBase & { kind: "turn"; text?: string; /** Inbound files attached to this turn (images, docs, …), in order. */ - files?: ManagedFileRef[]; + files?: ChannelFileRef[]; }) - | (ManagedIngressBase & { + | (ChannelIngressBase & { kind: "command"; /** Command name as invoked (leading slash / case normalized by core). */ command: string; @@ -68,7 +76,7 @@ export type ManagedIngressEnvelope = /** Structured, pre-parsed options when the surface delivers them. */ rawOptions?: Record; }) - | (ManagedIngressBase & { + | (ChannelIngressBase & { kind: "interaction"; /** Minted action id (ck:...) the rendered control carries. */ actionId: string; @@ -77,8 +85,8 @@ export type ManagedIngressEnvelope = messageRef?: MessageRef; triggerId?: string; }) - | (ManagedIngressBase & { kind: "thread_started" }) - | (ManagedIngressBase & { + | (ChannelIngressBase & { kind: "thread_started" }) + | (ChannelIngressBase & { kind: "reaction"; /** Platform-native emoji token. */ rawEmoji: string; @@ -120,15 +128,15 @@ export type EgressResult = // ── Realtime render events (OSS-402) ────────────────────────────────────── // Mirrors the frozen `channel.render_event.v1` contract on the Intelligence -// side (libs/app-api-contracts/src/hosted-bots.ts). The SDK streams these +// side (libs/app-api-contracts/src/channels.ts). The SDK streams these // semantic frames to the realtime-gateway; the gateway-side Connector Outbox // (OSS-404) renders them to the provider (Slack Block Kit, etc.). -// TODO(OSS-377): replace with the shared `@copilotkit/managed-bot-contracts` +// TODO(OSS-377): replace with the shared `@copilotkit/channel-contracts` // package; `post`/`update` content is `BotNode[]` (SDK IR) here — the frozen -// contract types it as opaque `HostedBotRenderContent`. +// contract types it as opaque `ChannelRenderContent`. /** One semantic render frame the agent run emits. Matches the frozen kinds. */ -export type HostedBotRenderEvent = +export type ChannelRenderEvent = | { kind: "run_started" } | { kind: "text_delta"; messageId: string; delta: string } | { kind: "text_end"; messageId: string } @@ -153,11 +161,11 @@ export type HostedBotRenderEvent = | { kind: "finalize" }; /** All render-event kinds, for exhaustive/ordering checks. */ -export type HostedBotRenderEventKind = HostedBotRenderEvent["kind"]; +export type ChannelRenderEventKind = ChannelRenderEvent["kind"]; /** * The adapter-facing render frame. The {@link RenderEventSink} fills in the - * org/project/bot scope, the `idempotencyKey` (`turnId:slot:seq`), the + * org/project/channel scope, the `idempotencyKey` (`turnId:slot:seq`), the * `runtimeInstanceId`, and `sentAt` before it hits the wire — the adapter only * supplies the delivery/turn identity, the render lane (`slot`), the monotonic * per-`(turn, slot)` `seq`, and the semantic `event`. @@ -169,7 +177,7 @@ export interface RenderFrame { slot: string; /** Zero-based, monotonic within `(turnId, slot)`. */ seq: number; - event: HostedBotRenderEvent; + event: ChannelRenderEvent; } /** Durable-acceptance receipt echoed back for each pushed {@link RenderFrame}. */ diff --git a/packages/channels-intelligence/src/http-transports.test.ts b/packages/channels-intelligence/src/http-transports.test.ts index fa81a4cb225..810d7eb0e89 100644 --- a/packages/channels-intelligence/src/http-transports.test.ts +++ b/packages/channels-intelligence/src/http-transports.test.ts @@ -134,11 +134,10 @@ describe("resolveTransportConfig", () => { ).toThrow(/missing required transport config/); }); - it("uses COPILOTKIT_CHANNEL_NAME and rejects the legacy bot-name environment variable", () => { + it("requires COPILOTKIT_CHANNEL_NAME when no channel name is configured", () => { vi.stubEnv("COPILOTKIT_INTELLIGENCE_URL", "http://x"); vi.stubEnv("COPILOTKIT_API_KEY", "cpk-test"); vi.stubEnv("COPILOTKIT_CHANNEL_NAME", ""); - vi.stubEnv("COPILOTKIT_BOT_NAME", "legacy-bot"); try { expect(() => resolveTransportConfig()).toThrow( @@ -258,7 +257,7 @@ describe("HttpDeliverySource", () => { deliveryId: "dlv_9", turnId: "turn_9", eventId: "evt_9", - botName: "opentagbot", + channelName: "opentagbot", platform: "slack", text: "hello", route: { teamId: "T1" }, @@ -342,7 +341,7 @@ describe("HttpDeliverySource", () => { deliveryId: "dlv_9", turnId: "turn_9", eventId: "evt_command", - botName: "opentagbot", + channelName: "opentagbot", platform: "slack", command: "/opentagbot", text: "summarize this channel", @@ -387,7 +386,7 @@ describe("HttpDeliverySource", () => { deliveryId: "dlv_9", turnId: "turn_9", eventId: "evt_reaction", - botName: "opentagbot", + channelName: "opentagbot", platform: "slack", rawEmoji: "thumbsup", added: true, @@ -433,7 +432,7 @@ describe("HttpDeliverySource", () => { deliveryId: "dlv_9", turnId: "turn_9", eventId: "evt_interaction", - botName: "opentagbot", + channelName: "opentagbot", platform: "slack", actionId: "ck:confirm_write:approve", value: { confirmed: true }, diff --git a/packages/channels-intelligence/src/http-transports.ts b/packages/channels-intelligence/src/http-transports.ts index 0e718d6566a..f3f4e7669b8 100644 --- a/packages/channels-intelligence/src/http-transports.ts +++ b/packages/channels-intelligence/src/http-transports.ts @@ -6,8 +6,9 @@ import type { AgentMessage, } from "./transports.js"; import type { - ManagedIngressEnvelope, - ManagedFileRef, + ChannelIngressEnvelope, + ChannelDeliveryScope, + ChannelFileRef, EgressRoute, EgressOperation, EgressResult, @@ -177,7 +178,7 @@ interface TeamsReplyTarget { /** * The claim's reply target is provider-tagged (Intelligence app-api mints a - * discriminated union — one managed runtime serves every channel its bot has + * discriminated union — one Channel runtime serves every channel its framework Bot has * attached now that claims are provider-agnostic). */ type ReplyTarget = SlackReplyTarget | TeamsReplyTarget; @@ -196,10 +197,10 @@ interface ClaimedDelivery { replyTarget: ReplyTarget; // NB: there is intentionally no `thread_started` variant here — the claim // path only carries turn/command/reaction/interaction. `thread_started` - // envelopes originate on the realtime/Phoenix path, not from `claim`, so a + // envelopes originate on the realtime gateway path, not from `claim`, so a // claimed delivery never maps to `kind:"thread_started"`. input?: - | { kind: "text"; text?: string; files?: ManagedFileRef[] } + | { kind: "text"; text?: string; files?: ChannelFileRef[] } | { kind: "command"; command: string; @@ -232,13 +233,6 @@ interface ClaimedDelivery { } /** Per-delivery org/project/channel scope, echoed onto render frames. */ -export interface DeliveryScope { - organizationId: string; - projectId: number; - channelId: string; - channelName: string; -} - type ClaimResponse = | { claimed: false; pollAfterMs: number } | { claimed: true; delivery: ClaimedDelivery }; @@ -309,14 +303,14 @@ function conversationKeyFromReplyTarget(rt: ReplyTarget): string { } } -function mapDeliveryToEnvelope(d: ClaimedDelivery): ManagedIngressEnvelope { +function mapDeliveryToEnvelope(d: ClaimedDelivery): ChannelIngressEnvelope { const base = { deliveryId: d.id, eventId: d.turn.eventId, turnId: d.turn.id, - // `ManagedIngressEnvelope` remains aligned with the channels framework's + // `ChannelIngressEnvelope` remains aligned with the channels framework's // Bot object; only the Intelligence HTTP wire contract calls this a channel. - botName: d.channel.name, + channelName: d.channel.name, platform: d.adapter, conversationKey: conversationKeyFromReplyTarget(d.turn.replyTarget), route: d.turn.replyTarget, @@ -378,7 +372,7 @@ function mapDeliveryToEnvelope(d: ClaimedDelivery): ManagedIngressEnvelope { interface LeaseRecord { turnId: string; leaseToken: string; - scope: DeliveryScope; + scope: ChannelDeliveryScope; } /** @@ -416,12 +410,12 @@ export class HttpDeliverySource implements DeliverySource { /** Claim a single delivery; returns the mapped envelope, or the idle backoff. */ async claimOnce(): Promise< - { env: ManagedIngressEnvelope } | { pollAfterMs: number } + { env: ChannelIngressEnvelope } | { pollAfterMs: number } > { const res = await this.http.post( "/api/channels/listener/claim", { - // Claim provider-agnostically: the managed runtime emits abstract render + // Claim provider-agnostically: the Channel runtime emits abstract render // frames and Intelligence renders per the delivery's own reply target, so // a single runtime serves every channel its bot has attached (Slack, // Teams, ...). Declaring a single adapter here made Intelligence withhold @@ -467,7 +461,7 @@ export class HttpDeliverySource implements DeliverySource { } /** The org/project/channel scope for a leased delivery, for render-frame egress. */ - scopeFor(deliveryId: string): DeliveryScope | undefined { + scopeFor(deliveryId: string): ChannelDeliveryScope | undefined { return this.leases.get(deliveryId)?.scope; } @@ -478,7 +472,7 @@ export class HttpDeliverySource implements DeliverySource { } async start( - onDelivery: (env: ManagedIngressEnvelope) => Promise, + onDelivery: (env: ChannelIngressEnvelope) => Promise, ): Promise { this.running = true; await this.heartbeat(); @@ -486,7 +480,7 @@ export class HttpDeliverySource implements DeliverySource { } private async runLoop( - onDelivery: (env: ManagedIngressEnvelope) => Promise, + onDelivery: (env: ChannelIngressEnvelope) => Promise, ): Promise { const cadence = this.cfg.heartbeatIntervalMs ?? 15000; while (this.running) { @@ -663,7 +657,7 @@ export class HttpDeliverySource implements DeliverySource { // called out distinctly — surface it loudly so it doesn't hide // forever behind the best-effort degrade-to-`[]` below. this.cfg.log?.( - `[intelligence] getHistory ${res.status} for thread history — likely a misconfigured/unauthorized history endpoint (baseUrl/route/apiKey); managed bot will run WITHOUT prior-turn history`, + `[intelligence] getHistory ${res.status} for thread history — likely a misconfigured/unauthorized history endpoint (baseUrl/route/apiKey); Channel Bot will run WITHOUT prior-turn history`, ); } else { // Transient (5xx/429) — quiet best-effort degradation, history is @@ -677,7 +671,7 @@ export class HttpDeliverySource implements DeliverySource { id: string; role: "user" | "assistant"; text: string; - files?: ManagedFileRef[]; + files?: ChannelFileRef[]; }>; }; const out: AgentMessage[] = []; @@ -838,7 +832,7 @@ export class HttpRenderEventSink implements RenderEventSink { constructor( private readonly cfg: IntelligenceTransportConfig, private readonly scopeSource: { - scopeFor(deliveryId: string): DeliveryScope | undefined; + scopeFor(deliveryId: string): ChannelDeliveryScope | undefined; leaseTokenFor(deliveryId: string): string | undefined; }, ) { diff --git a/packages/channels-intelligence/src/in-memory-transports.ts b/packages/channels-intelligence/src/in-memory-transports.ts index 30df05d30fb..8742970dae4 100644 --- a/packages/channels-intelligence/src/in-memory-transports.ts +++ b/packages/channels-intelligence/src/in-memory-transports.ts @@ -5,7 +5,7 @@ import type { AgentMessage, } from "./transports.js"; import type { - ManagedIngressEnvelope, + ChannelIngressEnvelope, EgressRoute, EgressOperation, EgressResult, @@ -65,13 +65,13 @@ export class InMemoryDeliverySource implements DeliverySource { */ history: AgentMessage[] = []; /** Every `getHistory` call, in order — asserts the adapter unwraps the - * `ManagedReplyTarget` to the raw route and threads `historyLimit` through. */ + * `ChannelReplyTarget` to the raw route and threads `historyLimit` through. */ readonly historyRequests: Array<{ replyTarget: EgressRoute; limit: number }> = []; - private onDelivery?: (env: ManagedIngressEnvelope) => Promise; + private onDelivery?: (env: ChannelIngressEnvelope) => Promise; async start( - onDelivery: (env: ManagedIngressEnvelope) => Promise, + onDelivery: (env: ChannelIngressEnvelope) => Promise, ): Promise { this.onDelivery = onDelivery; } @@ -80,7 +80,7 @@ export class InMemoryDeliverySource implements DeliverySource { * Push an envelope through the bound adapter. Resolves after the full * dispatch (handler + ack/nack) completes, so tests can assert synchronously. */ - async deliver(env: ManagedIngressEnvelope): Promise { + async deliver(env: ChannelIngressEnvelope): Promise { if (!this.onDelivery) { throw new Error( "InMemoryDeliverySource: not started — call bot.start() first", diff --git a/packages/channels-intelligence/src/index.ts b/packages/channels-intelligence/src/index.ts index 1a917daa7d8..4db9348c120 100644 --- a/packages/channels-intelligence/src/index.ts +++ b/packages/channels-intelligence/src/index.ts @@ -1,7 +1,7 @@ -// @copilotkit/channels-intelligence — Intelligence-delivered managed-bot adapter for +// @copilotkit/channels-intelligence — Intelligence-delivered Channel adapter for // @copilotkit/channels. Bridges Intelligence-delivered ingress to bot core and emits // generic egress operations over injectable transports. Not a publicly -// documented API; consumed by the runtime/managed-listener bootstrap and the +// documented API; consumed by the runtime/Channel-listener bootstrap and the // Intelligence side. export { @@ -20,14 +20,15 @@ export type { } from "./transports.js"; export type { - ManagedIngressBase, - ManagedIngressEnvelope, + ChannelIngressBase, + ChannelIngressEnvelope, + ChannelDeliveryScope, EgressOperation, EgressOp, EgressResult, EgressRoute, - HostedBotRenderEvent, - HostedBotRenderEventKind, + ChannelRenderEvent, + ChannelRenderEventKind, RenderFrame, RenderAccepted, } from "./contracts.js"; @@ -40,11 +41,11 @@ export { // Realtime Gateway transport — the production render/delivery path // (OSS-402). Undocumented like the rest of the package; exported for the -// managed-listener bootstrap and tests. +// Channel-listener bootstrap and tests. export { RealtimeGatewayTransport } from "./realtime-gateway-transport.js"; export type { RealtimeGatewayTransportOptions, - HostedBotRealtimeScope, + ChannelRealtimeScope, } from "./realtime-gateway-transport.js"; export { connectRealtimeGateway } from "./realtime-gateway.js"; export type { @@ -52,8 +53,8 @@ export type { RealtimeGatewaySession, ConnectedRealtimeGatewaySession, } from "./realtime-gateway.js"; -// The managed-over-Realtime-Gateway launcher (OSS-406): the composition that -// runs a managed channel over the realtime path. +// The Channel-over-Realtime-Gateway launcher (OSS-406): the composition that +// runs a Channel over the realtime path. export { startChannelsOverRealtimeGateway, startChannelsWithGatewaySession, @@ -79,14 +80,14 @@ export type { export { irToText } from "./ir-to-text.js"; export { - startManagedBots, - assertValidBotNames, - buildActivationMetadata, + startChannels, + assertValidChannelNames, + buildChannelActivationMetadata, } from "./runtime.js"; export type { - ManagedTransport, - ManagedBotsHandle, - StartManagedBotsOptions, - ActivationEnv, - ActivationMetadata, + ChannelTransport, + ChannelsHandle, + StartChannelsOptions, + ChannelActivationEnv, + ChannelActivationMetadata, } from "./runtime.js"; diff --git a/packages/channels-intelligence/src/intelligence-adapter.test.ts b/packages/channels-intelligence/src/intelligence-adapter.test.ts index a072fa59f8f..6fa97e04129 100644 --- a/packages/channels-intelligence/src/intelligence-adapter.test.ts +++ b/packages/channels-intelligence/src/intelligence-adapter.test.ts @@ -11,17 +11,17 @@ import { import { IntelligenceStateStore } from "./intelligence-state-store.js"; import type { FetchLike } from "./http-transports.js"; import type { - ManagedIngressEnvelope, - ManagedIngressBase, + ChannelIngressEnvelope, + ChannelIngressBase, } from "./contracts.js"; -type TurnEnvelope = Extract; -function envelope(partial?: Partial): ManagedIngressEnvelope { +type TurnEnvelope = Extract; +function envelope(partial?: Partial): ChannelIngressEnvelope { return { deliveryId: "d1", eventId: "e1", turnId: "t1", - botName: "support", + channelName: "support", platform: "slack", conversationKey: "c1", kind: "turn", @@ -32,7 +32,7 @@ function envelope(partial?: Partial): ManagedIngressEnvelope { } describe("intelligenceAdapter — ingress dispatch", () => { - it("dispatches a managed turn to the handler and emits a post egress op", async () => { + it("dispatches a channel turn to the handler and emits a post egress op", async () => { const source = new InMemoryDeliverySource(); const egress = new InMemoryEgressSink(); const bot = createBot({ @@ -242,7 +242,7 @@ describe("intelligenceAdapter — deterministic egress ids", () => { expect(egress.ops.map((o) => o.operationId)).toEqual(["t1:0", "t1:1"]); // Crash-before-ack redelivery: same turn id (and event id), new delivery id. - // The handler re-runs (no ingress dedup on the managed path) and re-emits + // The handler re-runs (no ingress dedup on the Channel path) and re-emits // the SAME op ids, so the Connector Outbox can dedupe the Slack output. egress.ops.length = 0; await source.deliver(envelope({ turnId: "t1", deliveryId: "d2" })); @@ -281,11 +281,11 @@ describe("intelligenceAdapter — run renderer", () => { }); describe("intelligenceAdapter — all ingress kinds route to bot core", () => { - const base: ManagedIngressBase = { + const base: ChannelIngressBase = { deliveryId: "d1", eventId: "e1", turnId: "t1", - botName: "support", + channelName: "support", platform: "slack", conversationKey: "c1", route: { r: 1 }, @@ -429,7 +429,7 @@ describe("intelligenceAdapter — exclusivity (V1)", () => { ).toThrow(/only adapter|alternative modes/i); }); - it("rejects adding a second adapter to a managed bot", () => { + it("rejects adding a second adapter to a Channel Bot", () => { const bot = createBot({ adapters: [ia()], agent: () => new FakeAgent() }); expect(() => bot.addAdapter(new FakeAdapter())).toThrow( /only adapter|alternative modes/i, @@ -474,7 +474,7 @@ describe("intelligenceAdapter — conversation-history seeding", () => { expect(agent.messages).toEqual(source.history); }); - it("unwraps the ManagedReplyTarget to the raw route and defaults historyLimit to 20", async () => { + it("unwraps the ChannelReplyTarget to the raw route and defaults historyLimit to 20", async () => { const source = new InMemoryDeliverySource(); const adapter = intelligenceAdapter({ source, diff --git a/packages/channels-intelligence/src/intelligence-adapter.ts b/packages/channels-intelligence/src/intelligence-adapter.ts index c6200a2b9f7..03a49394ff1 100644 --- a/packages/channels-intelligence/src/intelligence-adapter.ts +++ b/packages/channels-intelligence/src/intelligence-adapter.ts @@ -25,10 +25,10 @@ import type { AgentMessage, } from "./transports.js"; import type { - ManagedIngressEnvelope, + ChannelIngressEnvelope, EgressOp, EgressOperation, - HostedBotRenderEvent, + ChannelRenderEvent, RenderFrame, RenderAccepted, } from "./contracts.js"; @@ -43,14 +43,14 @@ import { IntelligenceStateStore } from "./intelligence-state-store.js"; import { buildContentParts } from "./content-parts.js"; /** Reply target the adapter mints during ingress and threads back to egress. */ -interface ManagedReplyTarget { +interface ChannelReplyTarget { route: unknown; turnId: string; deliveryId: string; } /** Recover the routing a minted {@link MessageRef} carries (for update/delete). */ -function targetFromRef(ref: MessageRef): ManagedReplyTarget { +function targetFromRef(ref: MessageRef): ChannelReplyTarget { if (ref.__deliveryId === undefined || ref.__turnId === undefined) { // A ref without stamped routing can't address an update/delete egress op. // This happens when a handler updates a ref that carries no delivery routing @@ -119,18 +119,18 @@ export interface IntelligenceAdapterOptions { /** * @internal Not a publicly documented API. * - * Bridges Intelligence-delivered managed ingress to bot core and emits generic + * Bridges Intelligence-delivered Channel ingress to the framework Bot core and emits generic * egress operations — pure plumbing over the injected {@link DeliverySource} / * {@link EgressSink}, with no Slack/Intelligence credentials. Production wires * the Realtime Gateway + Connector Outbox transports; tests/headless runs wire - * in-memory ones. Must be the only adapter on a bot (V1). + * in-memory ones. Must be the only adapter on a framework Bot (V1). */ export class IntelligenceAdapter implements PlatformAdapter { readonly platform = "intelligence"; - /** Marks this as the managed adapter (exclusivity guard + dedup opt-out). */ - readonly __managed = true; + /** Marks this as the Intelligence Channel adapter (exclusivity guard + dedup opt-out). */ + readonly __intelligenceChannel = true; /** - * Managed delivery is at-least-once and idempotency is enforced at egress + * Channel delivery is at-least-once and idempotency is enforced at egress * (deterministic operation ids → Connector Outbox dedupe), so bot core must * NOT drop redeliveries at ingress — that would lose a legitimate retry. */ @@ -151,12 +151,12 @@ export class IntelligenceAdapter implements PlatformAdapter { }; /** - * Seeds a fresh agent's `messages` from the managed transport's conversation + * Seeds a fresh agent's `messages` from the Channel transport's conversation * history (parity with bot-slack/bot-discord/bot-whatsapp, whose stores * rebuild `agent.messages` from platform history every turn) — an arrow * function property so `this` resolves to the adapter instance (not this * object literal) when bot core calls `adapter.conversationStore.getOrCreate(…)`. - * `replyTarget` here is the {@link ManagedReplyTarget} bot core threads + * `replyTarget` here is the {@link ChannelReplyTarget} framework core threads * through from `dispatchTo`; `.route` is the opaque {@link EgressRoute} the * transport actually needs. Best-effort: `getHistory` degrading to `[]` (no * transport support, or a fetch failure) just means the turn starts fresh. @@ -164,7 +164,7 @@ export class IntelligenceAdapter implements PlatformAdapter { readonly conversationStore: ConversationStore = { getOrCreate: async (conversationKey, replyTarget, makeAgent) => { const agent = makeAgent(conversationKey); - const route = (replyTarget as ManagedReplyTarget).route; + const route = (replyTarget as ChannelReplyTarget).route; let history: AgentMessage[] = []; try { history = @@ -192,7 +192,7 @@ export class IntelligenceAdapter implements PlatformAdapter { }, }; - /** Persistence supplied by the managed transport (Intelligence-backed); picked + /** Persistence supplied by the Channel transport (Intelligence-backed); picked * up by createBot's `resolveBackend` when no explicit `store.adapter` is set. */ readonly stateStore?: StateStore; @@ -212,7 +212,7 @@ export class IntelligenceAdapter implements PlatformAdapter { this.source = opts.source; this.egress = opts.egress; this.renderSink = opts.renderSink; - // Default to the Intelligence-backed durable KV store so managed bots + // Default to the Intelligence-backed durable KV store so Channel Bots // persist action-registry snapshots + thread state across restarts (HITL // cards survive). Skipped when a store is passed explicitly or when // in-memory transports are injected (tests) — a durable store hitting HTTP @@ -284,7 +284,7 @@ export class IntelligenceAdapter implements PlatformAdapter { await this.source?.stop(); } - private async dispatch(env: ManagedIngressEnvelope): Promise { + private async dispatch(env: ChannelIngressEnvelope): Promise { // Reset the per-turn sequence so egress ids are deterministic across // redelivery (same turn id -> same op id sequence). Assumes the // DeliverySource delivers (and awaits) one envelope per turnId at a time — @@ -302,15 +302,15 @@ export class IntelligenceAdapter implements PlatformAdapter { } finally { // Drop the per-turn counter once the turn is fully processed (renderer // chain drained inside dispatchTo) so the Map can't grow unbounded over a - // long-running managed bot. A redelivery re-seeds it at the top. + // long-running Channel Bot. A redelivery re-seeds it at the top. this.seq.delete(env.turnId); } } - private async dispatchTo(env: ManagedIngressEnvelope): Promise { + private async dispatchTo(env: ChannelIngressEnvelope): Promise { const sink = this.sink; if (!sink) throw new Error("IntelligenceAdapter: not started"); - const replyTarget: ManagedReplyTarget = { + const replyTarget: ChannelReplyTarget = { route: env.route, turnId: env.turnId, deliveryId: env.deliveryId, @@ -439,7 +439,7 @@ export class IntelligenceAdapter implements PlatformAdapter { return seq; } - private mintOp(target: ManagedReplyTarget, op: EgressOp): EgressOperation { + private mintOp(target: ChannelReplyTarget, op: EgressOp): EgressOperation { const seq = this.nextFrameSeq(target.turnId); return { operationId: `${target.turnId}:${seq}`, @@ -457,8 +457,8 @@ export class IntelligenceAdapter implements PlatformAdapter { * frame so a later update/delete can re-address it. */ private async postRenderFrame( - target: ManagedReplyTarget, - event: HostedBotRenderEvent, + target: ChannelReplyTarget, + event: ChannelRenderEvent, ): Promise { const seq = this.nextFrameSeq(target.turnId); const receipt = await this.requireRenderSink().push({ @@ -477,7 +477,7 @@ export class IntelligenceAdapter implements PlatformAdapter { } private async emit( - target: ManagedReplyTarget, + target: ChannelReplyTarget, op: EgressOp, ): Promise { const operation = this.mintOp(target, op); @@ -503,12 +503,12 @@ export class IntelligenceAdapter implements PlatformAdapter { // Connector Outbox renders full Block Kit (rich JSX preserved). Fallback // (no render sink wired — e.g. in-memory tests): the egress op path. if (this.renderSink) { - return this.postRenderFrame(target as ManagedReplyTarget, { + return this.postRenderFrame(target as ChannelReplyTarget, { kind: "post", content: ir, }); } - return this.emit(target as ManagedReplyTarget, { kind: "post", ir }); + return this.emit(target as ChannelReplyTarget, { kind: "post", ir }); } async update(ref: MessageRef, ir: BotNode[]): Promise { @@ -532,19 +532,19 @@ export class IntelligenceAdapter implements PlatformAdapter { altText?: string; }, ): Promise<{ ok: boolean; fileId?: string; error?: string }> { - const mt = target as ManagedReplyTarget; + const channelTarget = target as ChannelReplyTarget; const uploadFile = this.source?.uploadFile?.bind(this.source); if (!uploadFile || !this.renderSink) { return { ok: false, - error: "managed adapter: outbound file upload is not available", + error: "Channel adapter: outbound file upload is not available", }; } try { // Stream bytes to app-api first (durable in S3), then emit a `file` frame // referencing the handle; the Connector Outbox does the Slack uploadV2. - const { handle } = await uploadFile(mt.deliveryId, args); - await this.postRenderFrame(mt, { + const { handle } = await uploadFile(channelTarget.deliveryId, args); + await this.postRenderFrame(channelTarget, { kind: "file", handle, filename: args.filename, @@ -567,7 +567,7 @@ export class IntelligenceAdapter implements PlatformAdapter { // Non-streaming surface: accumulate the full reply and emit one post. let acc = ""; for await (const c of chunks) acc += c; - const target = _target as ManagedReplyTarget; + const target = _target as ChannelReplyTarget; if (this.renderSink) { return this.postRenderFrame(target, { kind: "post", @@ -582,7 +582,7 @@ export class IntelligenceAdapter implements PlatformAdapter { } decodeInteraction(_raw: unknown): InteractionEvent | undefined { - // TODO(OSS-377): managed interaction decoding. + // TODO(OSS-377): Channel interaction decoding. return undefined; } @@ -599,7 +599,7 @@ export class IntelligenceAdapter implements PlatformAdapter { * any un-ended text on `finalize` (the interrupt path) with the interrupted * marker. */ - private renderSinkFor(t: ManagedReplyTarget): RenderEventSink { + private renderSinkFor(t: ChannelReplyTarget): RenderEventSink { if (this.renderSink) return this.renderSink; const emit = (op: EgressOp) => this.emit(t, op); const acc = new Map(); @@ -648,7 +648,7 @@ export class IntelligenceAdapter implements PlatformAdapter { } createRunRenderer(target: ReplyTarget): RunRenderer { - const t = target as ManagedReplyTarget; + const t = target as ChannelReplyTarget; const sink = this.renderSinkFor(t); const interruptEventNames = new Set(["on_interrupt"]); const capturedToolCalls: CapturedToolCall[] = []; @@ -666,7 +666,7 @@ export class IntelligenceAdapter implements PlatformAdapter { let chain: Promise = Promise.resolve(); let pushError: unknown; - const enqueue = (event: HostedBotRenderEvent): void => { + const enqueue = (event: ChannelRenderEvent): void => { const frame: RenderFrame = { deliveryId: t.deliveryId, turnId: t.turnId, @@ -810,7 +810,7 @@ export class IntelligenceAdapter implements PlatformAdapter { } /** - * @internal Construct the managed bridge adapter. Production callers (the + * @internal Construct the Channel bridge adapter. Production callers (the * runtime) inject the Realtime Gateway + Connector Outbox transports; tests and * standalone runs inject in-memory ones. Must be the only adapter on a bot (V1). */ diff --git a/packages/channels-intelligence/src/intelligence-state-store.ts b/packages/channels-intelligence/src/intelligence-state-store.ts index f7f17fd2421..3ee422784b8 100644 --- a/packages/channels-intelligence/src/intelligence-state-store.ts +++ b/packages/channels-intelligence/src/intelligence-state-store.ts @@ -13,19 +13,19 @@ export interface IntelligenceStateStoreConfig { } /** - * Durable {@link StateStore} for managed bots, backed by Intelligence app-api's + * Durable {@link StateStore} for Channel Bots, backed by Intelligence app-api's * runtime-authed KV routes (`/api/channels/kv/*`). Only the `kv` facet is durable — * that is what the action registry (button/`ck:` snapshots) and thread state - * use, so a HITL card posted before a managed-loop restart still re-renders on + * use, so a HITL card posted before a Channel-loop restart still re-renders on * cold-cache dispatch and can be flipped in place. * * `list` / `lock` / `dedup` / `queue` delegate to an in-memory - * {@link MemoryStore}. On the managed Slack path these are not durability + * {@link MemoryStore}. On the Channel Slack path these are not durability * critical: dedup is skipped at ingress (`adapter.skipIngressDedup`), the - * per-conversation turn lock is process-local (a single managed runtime; the + * per-conversation turn lock is process-local (a single Channel runtime; the * app-api delivery lease already fences work cross-instance), and list/queue * (transcripts/proactive) are unused. // ponytail: promote these to durable KV - * only if the managed runtime is ever horizontally scaled. + * only if the Channel runtime is ever horizontally scaled. */ export class IntelligenceStateStore implements StateStore { private readonly local = new MemoryStore(); diff --git a/packages/channels-intelligence/src/ir-to-text.ts b/packages/channels-intelligence/src/ir-to-text.ts index 15920124bda..22543ed94da 100644 --- a/packages/channels-intelligence/src/ir-to-text.ts +++ b/packages/channels-intelligence/src/ir-to-text.ts @@ -5,7 +5,7 @@ import type { BotNode } from "@copilotkit/channels-ui"; * first slice, which accepts a plain `text` field only (Intelligence owns the * native platform rendering later via per-platform codecs — OSS-363/OSS-377). * - * The dominant managed path — streamed agent text — is already a single + * The dominant Channel path — streamed agent text — is already a single * `{ type: "text", props: { value } }` node (see {@link IntelligenceAdapter}'s * run renderer), so this is usually a no-op concat. Richer IR (sections, lists) * is best-effort flattened by concatenating descendant text; formatting is lost diff --git a/packages/channels-intelligence/src/realtime-gateway-launcher.test.ts b/packages/channels-intelligence/src/realtime-gateway-launcher.test.ts index 944e966fdbd..f201a75acda 100644 --- a/packages/channels-intelligence/src/realtime-gateway-launcher.test.ts +++ b/packages/channels-intelligence/src/realtime-gateway-launcher.test.ts @@ -75,7 +75,7 @@ async function waitFor(pred: () => boolean, tries = 50): Promise { throw new Error("waitFor: condition not met within the poll window"); } -describe("startChannelsWithGatewaySession — managed runtime over Realtime Gateway (OSS-406)", () => { +describe("startChannelsWithGatewaySession — Channel runtime over Realtime Gateway (OSS-406)", () => { it("runs a delivered turn end-to-end: handler → render frame → completion intent, never self-ack", async () => { const fake = makeFakeSession(); let ran = false; @@ -135,6 +135,31 @@ describe("startChannelsWithGatewaySession — managed runtime over Realtime Gate }); describe("startChannelsOverRealtimeGateway — fail-fast validation (OSS-406)", () => { + it("rejects an invalid Channel scope before opening a socket", async () => { + let socketConstructed = false; + class NeverWebSocket { + constructor() { + socketConstructed = true; + throw new Error( + "startChannelsOverRealtimeGateway should not have connected", + ); + } + } + const bot = createBot({ name: "opentag", agent: () => new FakeAgent() }); + + await expect( + startChannelsOverRealtimeGateway([bot], { + wsUrl: "wss://gateway.example/socket", + apiKey: "cpk-test", + scope: { ...scope, channelId: "bot_1" }, + runtimeInstanceId: "rti_1", + webSocket: NeverWebSocket, + }), + ).rejects.toThrow(/channel_\* channelId/i); + + expect(socketConstructed).toBe(false); + }); + it("rejects a bad bot name before opening a socket (no leaked connection)", async () => { let socketConstructed = false; class NeverWebSocket { @@ -156,7 +181,7 @@ describe("startChannelsOverRealtimeGateway — fail-fast validation (OSS-406)", runtimeInstanceId: "rti_1", webSocket: NeverWebSocket, }), - ).rejects.toThrow(/duplicate managed bot name/i); + ).rejects.toThrow(/duplicate channel name/i); expect(socketConstructed).toBe(false); }); @@ -216,7 +241,7 @@ describe("startChannelsWithGatewaySession — activation metadata (OSS-406)", () expect(handle.metadata.runtimeEnv).toBe("production"); expect(handle.metadata.runtimePackageVersion).toBe("9.9.9"); - expect(handle.metadata.declaredBotNames).toEqual(["opentag"]); + expect(handle.metadata.declaredChannelNames).toEqual(["opentag"]); await handle.stop(); }); @@ -343,7 +368,7 @@ describe("startChannelsOverRealtimeGateway — socket lifecycle cleanup (OSS-406 it("disconnects the socket when bot startup fails after the channel joined", async () => { const { FakeWebSocket, instances } = makeFakeWebSocket("ok"); - // Pre-start the Bot so startManagedBots' addAdapter() throws ("adapter added + // Pre-start the Bot so startChannels' addAdapter() throws ("adapter added // after start") during the post-join startup — the exact failure the // launcher's try/catch must clean up after. const started = makeFakeSession(); diff --git a/packages/channels-intelligence/src/realtime-gateway-launcher.ts b/packages/channels-intelligence/src/realtime-gateway-launcher.ts index 33d2efeebbd..d2402bdb5eb 100644 --- a/packages/channels-intelligence/src/realtime-gateway-launcher.ts +++ b/packages/channels-intelligence/src/realtime-gateway-launcher.ts @@ -1,14 +1,17 @@ import type { Bot } from "@copilotkit/channels"; import { - startManagedBots, - assertValidBotNames, - buildActivationMetadata, - resolveActivationEnv, + startChannels, + assertValidChannelNames, + buildChannelActivationMetadata, + resolveChannelActivationEnv, } from "./runtime.js"; -import type { ManagedBotsHandle, ActivationEnv } from "./runtime.js"; +import type { ChannelsHandle, ChannelActivationEnv } from "./runtime.js"; import { connectRealtimeGateway } from "./realtime-gateway.js"; -import { RealtimeGatewayTransport } from "./realtime-gateway-transport.js"; -import type { HostedBotRealtimeScope } from "./realtime-gateway-transport.js"; +import { + RealtimeGatewayTransport, + assertValidChannelRealtimeScope, +} from "./realtime-gateway-transport.js"; +import type { ChannelRealtimeScope } from "./realtime-gateway-transport.js"; import type { RealtimeGatewaySession } from "./realtime-gateway.js"; import type { EgressSink } from "./transports.js"; @@ -40,19 +43,33 @@ const realtimeGatewayEgress: EgressSink = { function assertSingleBotForPhase1(bots: readonly Bot[]): void { if (bots.length !== 1) { throw new Error( - `managed Realtime Gateway runtime supports exactly one Bot per gateway session, got ${bots.length} — ` + + `Channel Realtime Gateway runtime supports exactly one Bot per gateway session, got ${bots.length} — ` + "multi-Bot routing over a shared RealtimeGatewayTransport is not implemented yet (OSS-459); " + "run one Bot per gateway session/runner", ); } } +function assertScopeMatchesChannel( + bots: readonly Bot[], + scope: ChannelRealtimeScope, +): void { + assertValidChannelRealtimeScope(scope); + assertValidChannelNames(bots); + assertSingleBotForPhase1(bots); + if (bots[0]!.name !== scope.channelName) { + throw new Error( + `Channel Realtime Gateway scope channelName ${JSON.stringify(scope.channelName)} must match Bot name ${JSON.stringify(bots[0]!.name)}`, + ); + } +} + /** Options for {@link startChannelsWithGatewaySession}. */ export interface StartChannelsWithGatewaySessionOptions { /** The joined Realtime Gateway session. */ session: RealtimeGatewaySession; /** Authoritative org/project/channel scope echoed on every SDK→gateway envelope. */ - scope: HostedBotRealtimeScope; + scope: ChannelRealtimeScope; /** Stable runtime instance id (`rti_…`), echoed on every envelope. */ runtimeInstanceId: string; /** Activation env overrides forwarded to the runtime (so `handle.metadata` @@ -61,15 +78,15 @@ export interface StartChannelsWithGatewaySessionOptions { * {@link StartChannelsWithGatewaySessionOptions.runtimeInstanceId} above is authoritative * and is merged in, so the transport (which stamps it on every envelope) and * `handle.metadata` always report the same id. */ - env?: Partial>; + env?: Partial>; /** Diagnostic sink for dropped deliveries / transport events. */ log?: (message: string, meta?: unknown) => void; } /** - * Compose the managed runtime over an already-connected gateway session: wrap + * Compose the Channel runtime over an already-connected gateway session: wrap * the session in a {@link RealtimeGatewayTransport} (delivery source + render - * sink) and start the declared Bots against it via {@link startManagedBots}. + * sink) and start the declared Bots against it via {@link startChannels}. * * Split out from {@link startChannelsOverRealtimeGateway} so the composition — * the part with behavior — is unit-testable against a fake session, leaving the @@ -80,15 +97,15 @@ export interface StartChannelsWithGatewaySessionOptions { export async function startChannelsWithGatewaySession( bots: Bot[], opts: StartChannelsWithGatewaySessionOptions, -): Promise { - assertSingleBotForPhase1(bots); +): Promise { + assertScopeMatchesChannel(bots, opts.scope); const transport = new RealtimeGatewayTransport({ scope: opts.scope, runtimeInstanceId: opts.runtimeInstanceId, session: opts.session, ...(opts.log ? { log: opts.log } : {}), }); - return startManagedBots({ + return startChannels({ bots, resolveTransport: () => ({ source: transport, @@ -110,7 +127,7 @@ export interface StartChannelsOverRealtimeGatewayOptions { /** Project runtime API key (`cpk-…`), presented as the socket `authToken`. */ apiKey: string; /** Authoritative org/project/channel scope echoed on every SDK→gateway envelope. */ - scope: HostedBotRealtimeScope; + scope: ChannelRealtimeScope; /** Stable runtime instance id (`rti_…`). */ runtimeInstanceId: string; /** Adapter kind declared to the gateway on join (default `"slack"`). */ @@ -120,7 +137,7 @@ export interface StartChannelsOverRealtimeGatewayOptions { * in `handle.metadata`. `runtimeInstanceId` is intentionally excluded — the * required top-level {@link StartChannelsOverRealtimeGatewayOptions.runtimeInstanceId} is * authoritative for both the join and `handle.metadata` (they must agree). */ - env?: Partial>; + env?: Partial>; /** Join timeout in ms. */ timeoutMs?: number; /** Injectable `WebSocket` ctor (non-global hosts / tests). */ @@ -132,38 +149,36 @@ export interface StartChannelsOverRealtimeGatewayOptions { /** * Connect a Realtime Gateway session, then run the declared framework Bots * against it via {@link startChannelsWithGatewaySession}. This is the - * composition that runs a managed channel over the realtime path. The returned + * composition that runs a Channel over the realtime path. The returned * handle's `stop()` stops the Bots and then disconnects the session. */ export async function startChannelsOverRealtimeGateway( bots: Bot[], config: StartChannelsOverRealtimeGatewayOptions, -): Promise { +): Promise { const adapter = config.adapter ?? "slack"; // Fail fast BEFORE opening the socket: a missing/duplicate name would // otherwise send a broken channel declaration and — because the same - // check inside startManagedBots runs only after we've connected — throw with + // check inside startChannels runs only after we've connected — throw with // the socket already open and never closed (a leak). Validating here means a // bad declaration never opens a connection at all. - assertValidBotNames(bots); - // Fail fast before opening a socket for an unsupported multi-bot call. - assertSingleBotForPhase1(bots); + assertScopeMatchesChannel(bots, config.scope); // Build activation metadata up front so the join carries the Runtime // Activation data Intelligence's health view expects (runtime env, node // version, per-bot commands) rather than just name+adapter. The same - // `envOverrides` is forwarded to startManagedBots so `handle.metadata` agrees + // `envOverrides` is forwarded to startChannels so `handle.metadata` agrees // with what we declared on join. The required `config.runtimeInstanceId` is // spread LAST so it stays authoritative even though `config.env` cannot carry // it (type-excluded) — belt and suspenders for the join↔metadata invariant. - const envOverrides: Partial = { + const envOverrides: Partial = { ...config.env, runtimeInstanceId: config.runtimeInstanceId, }; - const activation = buildActivationMetadata( + const activation = buildChannelActivationMetadata( bots, - resolveActivationEnv(envOverrides), + resolveChannelActivationEnv(envOverrides), ); const session = await connectRealtimeGateway({ @@ -172,8 +187,8 @@ export async function startChannelsOverRealtimeGateway( projectId: config.scope.projectId, join: { runtimeInstanceId: config.runtimeInstanceId, - declaredChannels: activation.declaredBots.map((b) => ({ - channelName: b.name, + declaredChannels: activation.declaredChannels.map((channel) => ({ + channelName: channel.channelName, adapter, // renderCapabilities: reserved — bots don't expose capabilities yet // (tracked with the richer per-bot metadata in OSS-377). @@ -186,11 +201,14 @@ export async function startChannelsOverRealtimeGateway( ...(activation.runtimePackageVersion ? { runtimePackageVersion: activation.runtimePackageVersion } : {}), - ...(activation.botPackageVersion - ? { botPackageVersion: activation.botPackageVersion } + ...(activation.channelsPackageVersion + ? { channelsPackageVersion: activation.channelsPackageVersion } : {}), commands: Object.fromEntries( - activation.declaredBots.map((b) => [b.name, b.commands]), + activation.declaredChannels.map((channel) => [ + channel.channelName, + channel.commands, + ]), ), }, observedAt: new Date().toISOString(), @@ -201,7 +219,7 @@ export async function startChannelsOverRealtimeGateway( // The session is now joined. If starting the Bots throws (e.g. a Bot was // already started, or a conflicting adapter), the caller never receives a // handle — so disconnect the socket here rather than leak it, then rethrow. - let handle: ManagedBotsHandle; + let handle: ChannelsHandle; try { handle = await startChannelsWithGatewaySession(bots, { session, diff --git a/packages/channels-intelligence/src/realtime-gateway-transport.ts b/packages/channels-intelligence/src/realtime-gateway-transport.ts index 751e7461415..817e3cab5d3 100644 --- a/packages/channels-intelligence/src/realtime-gateway-transport.ts +++ b/packages/channels-intelligence/src/realtime-gateway-transport.ts @@ -1,4 +1,4 @@ -// Realtime Gateway transport for the managed Bot SDK (OSS-402). +// Realtime Gateway transport for the Channels SDK (OSS-402). // // This is the production render/delivery path: the SDK joins the gateway's // per-project session, receives leased deliveries, streams semantic @@ -22,22 +22,52 @@ import type { DeliverySource, RenderEventSink } from "./transports.js"; import type { - ManagedIngressEnvelope, + ChannelIngressEnvelope, + ChannelDeliveryScope, RenderFrame, RenderAccepted, } from "./contracts.js"; import type { RealtimeGatewaySession } from "./realtime-gateway.js"; /** The org/project/channel scope every realtime envelope carries. */ -export interface HostedBotRealtimeScope { - organizationId: string; - projectId: number; - channelId: string; - channelName: string; +export interface ChannelRealtimeScope extends ChannelDeliveryScope {} + +const CHANNEL_ID_RE = /^channel_[A-Za-z0-9_-]+$/; +const ORGANIZATION_ID_RE = /^org_[A-Za-z0-9_-]+$/; +const CHANNEL_NAME_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; + +/** @internal Validate the product scope before a realtime connection is opened. */ +export function assertValidChannelRealtimeScope( + scope: ChannelRealtimeScope, +): void { + if (!Number.isInteger(scope.projectId) || scope.projectId <= 0) { + throw new Error( + "Realtime Gateway Channel scope requires a positive projectId", + ); + } + if (!ORGANIZATION_ID_RE.test(scope.organizationId)) { + throw new Error( + `Realtime Gateway Channel scope requires an org_* organizationId, got ${JSON.stringify(scope.organizationId)}`, + ); + } + if (!CHANNEL_ID_RE.test(scope.channelId)) { + throw new Error( + `Realtime Gateway Channel scope requires a channel_* channelId, got ${JSON.stringify(scope.channelId)}`, + ); + } + if ( + scope.channelName.length < 3 || + scope.channelName.length > 64 || + !CHANNEL_NAME_RE.test(scope.channelName) + ) { + throw new Error( + `Realtime Gateway Channel scope requires a lowercase kebab-case channelName, got ${JSON.stringify(scope.channelName)}`, + ); + } } export interface RealtimeGatewayTransportOptions { - scope: HostedBotRealtimeScope; + scope: ChannelRealtimeScope; /** Unique per runtime instance (rti_…), echoed on every SDK->gateway event. */ runtimeInstanceId: string; /** The joined Realtime Gateway session. */ @@ -64,7 +94,7 @@ interface DeliveryState { /** app-api's per-delivery lease token, fences the complete/fail intent. */ leaseToken: string; /** Authoritative org/project/channel scope from the delivery (not the transport default). */ - scope: HostedBotRealtimeScope; + scope: ChannelDeliveryScope; /** Highest accepted `seq` per render slot (the completion high-water mark). */ accepted: Map; } @@ -78,15 +108,16 @@ interface DeliveryState { export class RealtimeGatewayTransport implements DeliverySource, RenderEventSink { - private readonly scope: HostedBotRealtimeScope; + private readonly scope: ChannelRealtimeScope; private readonly runtimeInstanceId: string; private readonly session: RealtimeGatewaySession; private readonly now: () => string; private readonly log?: (message: string, meta?: unknown) => void; private readonly deliveries = new Map(); - private onDelivery?: (env: ManagedIngressEnvelope) => Promise; + private onDelivery?: (env: ChannelIngressEnvelope) => Promise; constructor(config: RealtimeGatewayTransportOptions) { + assertValidChannelRealtimeScope(config.scope); this.scope = config.scope; this.runtimeInstanceId = config.runtimeInstanceId; this.session = config.session; @@ -95,7 +126,7 @@ export class RealtimeGatewayTransport } async start( - onDelivery: (env: ManagedIngressEnvelope) => Promise, + onDelivery: (env: ChannelIngressEnvelope) => Promise, ): Promise { this.onDelivery = onDelivery; this.session.on(DELIVERY_AVAILABLE, (payload) => { @@ -127,8 +158,8 @@ export class RealtimeGatewayTransport */ private toIngressEnvelope(payload: unknown): | { - env: ManagedIngressEnvelope; - scope: HostedBotRealtimeScope; + env: ChannelIngressEnvelope; + scope: ChannelDeliveryScope; leaseToken: string; } | undefined { @@ -189,9 +220,9 @@ export class RealtimeGatewayTransport deliveryId: String(delivery.id), eventId: String(turn.eventId), turnId: String(turn.id), - // The framework's ingress envelope still routes to a Bot by name; - // Intelligence's wire contract calls that delivery entity a channel. - botName: scope.channelName, + // This product-level field names the Channel; bot core attaches the + // adapter directly to the framework Bot named by `createBot({ name })`. + channelName: scope.channelName, platform: String(delivery.adapter ?? "slack"), conversationKey: String(turn.id), route: turn.replyTarget, diff --git a/packages/channels-intelligence/src/realtime-gateway.test.ts b/packages/channels-intelligence/src/realtime-gateway.test.ts index facee8ed0ea..9a3f01389ec 100644 --- a/packages/channels-intelligence/src/realtime-gateway.test.ts +++ b/packages/channels-intelligence/src/realtime-gateway.test.ts @@ -68,6 +68,30 @@ function makeFakeWebSocket(mode: JoinMode) { } describe("connectRealtimeGateway", () => { + it("rejects a non-positive project id before constructing a WebSocket", async () => { + let socketConstructed = false; + class NeverWebSocket { + constructor() { + socketConstructed = true; + } + } + + await expect( + connectRealtimeGateway({ + wsUrl: "wss://gateway.example/socket", + apiKey: "cpk-test", + projectId: 0, + join: { + runtimeInstanceId: "rti_1", + declaredChannels: [], + observedAt: "2026-07-10T00:00:00.000Z", + }, + webSocket: NeverWebSocket, + }), + ).rejects.toThrow(/projectId must be a positive integer/i); + expect(socketConstructed).toBe(false); + }); + it("joins the channel topic with declared channels and disconnects the gateway session", async () => { const { FakeWebSocket, instances } = makeFakeWebSocket("ok"); const join = { diff --git a/packages/channels-intelligence/src/realtime-gateway.ts b/packages/channels-intelligence/src/realtime-gateway.ts index 2e873801748..d2ec8835e56 100644 --- a/packages/channels-intelligence/src/realtime-gateway.ts +++ b/packages/channels-intelligence/src/realtime-gateway.ts @@ -53,6 +53,11 @@ export interface ConnectedRealtimeGatewaySession extends RealtimeGatewaySession export async function connectRealtimeGateway( config: ConnectRealtimeGatewayOptions, ): Promise { + if (!Number.isInteger(config.projectId) || config.projectId <= 0) { + throw new Error( + "connectRealtimeGateway: projectId must be a positive integer", + ); + } const timeout = config.timeoutMs ?? 10_000; const transport = config.webSocket ?? diff --git a/packages/channels-intelligence/src/runtime.test.ts b/packages/channels-intelligence/src/runtime.test.ts index d300bdd0505..6c15d2753c3 100644 --- a/packages/channels-intelligence/src/runtime.test.ts +++ b/packages/channels-intelligence/src/runtime.test.ts @@ -7,73 +7,81 @@ import { InMemoryRenderEventSink, } from "./in-memory-transports.js"; import { - assertValidBotNames, - buildActivationMetadata, - resolveActivationEnv, - startManagedBots, + assertValidChannelNames, + buildChannelActivationMetadata, + resolveChannelActivationEnv, + startChannels, } from "./runtime.js"; -describe("assertValidBotNames", () => { +describe("assertValidChannelNames", () => { it("throws when a bot has no name", () => { const bot = createBot({ agent: () => new FakeAgent() }); - expect(() => assertValidBotNames([bot])).toThrow(/name/i); + expect(() => assertValidChannelNames([bot])).toThrow(/name/i); }); - it("throws on a non-identifier name", () => { + it("throws on a non-channel name", () => { const bot = createBot({ name: "Bad Name!", agent: () => new FakeAgent() }); - expect(() => assertValidBotNames([bot])).toThrow(/identifier|invalid/i); + expect(() => assertValidChannelNames([bot])).toThrow( + /channel name|invalid/i, + ); }); it("throws on duplicate names", () => { const a = createBot({ name: "support", agent: () => new FakeAgent() }); const b = createBot({ name: "support", agent: () => new FakeAgent() }); - expect(() => assertValidBotNames([a, b])).toThrow(/duplicate/i); + expect(() => assertValidChannelNames([a, b])).toThrow(/duplicate/i); }); - it("treats duplicate names case-insensitively", () => { - const a = createBot({ name: "Support", agent: () => new FakeAgent() }); - const b = createBot({ name: "support", agent: () => new FakeAgent() }); - expect(() => assertValidBotNames([a, b])).toThrow(/duplicate/i); + it("rejects uppercase channel names", () => { + const bot = createBot({ name: "Support", agent: () => new FakeAgent() }); + expect(() => assertValidChannelNames([bot])).toThrow( + /lowercase kebab-case/i, + ); + }); + + it("rejects the reserved channels name", () => { + const bot = createBot({ name: "channels", agent: () => new FakeAgent() }); + expect(() => assertValidChannelNames([bot])).toThrow(/reserved/i); }); - it("accepts valid unique identifier-style names", () => { - const a = createBot({ name: "support_bot", agent: () => new FakeAgent() }); - const b = createBot({ name: "triage2", agent: () => new FakeAgent() }); - expect(() => assertValidBotNames([a, b])).not.toThrow(); + it("accepts valid unique channel names", () => { + const a = createBot({ name: "support-bot", agent: () => new FakeAgent() }); + const b = createBot({ name: "triage-2", agent: () => new FakeAgent() }); + expect(() => assertValidChannelNames([a, b])).not.toThrow(); }); }); -describe("buildActivationMetadata", () => { - it("includes declared bot names and the provided env", () => { +describe("buildChannelActivationMetadata", () => { + it("includes declared channel names and the provided env", () => { const a = createBot({ name: "support", agent: () => new FakeAgent() }); const b = createBot({ name: "triage", agent: () => new FakeAgent() }); - const meta = buildActivationMetadata([a, b], { + const meta = buildChannelActivationMetadata([a, b], { runtimeEnv: "production", nodeVersion: "v20", runtimePackageVersion: "1.2.3", }); - expect(meta.declaredBotNames).toEqual(["support", "triage"]); + expect(meta.declaredChannelNames).toEqual(["support", "triage"]); expect(meta.runtimeEnv).toBe("production"); expect(meta.nodeVersion).toBe("v20"); expect(meta.runtimePackageVersion).toBe("1.2.3"); }); - it("includes each bot's declared command names", () => { + it("includes each channel's declared command names", () => { const a = createBot({ name: "support", agent: () => new FakeAgent() }); a.onCommand("triage", async () => {}); - const meta = buildActivationMetadata([a], { runtimeEnv: "test" }); - expect(meta.declaredBots).toEqual([ - { name: "support", commands: ["triage"] }, + const meta = buildChannelActivationMetadata([a], { runtimeEnv: "test" }); + expect(meta.declaredChannels).toEqual([ + { channelName: "support", commands: ["triage"] }, ]); }); }); -describe("resolveActivationEnv", () => { +describe("resolveChannelActivationEnv", () => { it("prefers COPILOTKIT_RUNTIME_ENV, includes the node version, and lets overrides win", () => { const prev = process.env.COPILOTKIT_RUNTIME_ENV; process.env.COPILOTKIT_RUNTIME_ENV = "staging"; try { - const env = resolveActivationEnv({ + const env = resolveChannelActivationEnv({ runtimePackageVersion: "9.9.9", runtimeInstanceId: "inst-1", }); @@ -91,7 +99,7 @@ describe("resolveActivationEnv", () => { const prev = process.env.COPILOTKIT_RUNTIME_ENV; delete process.env.COPILOTKIT_RUNTIME_ENV; try { - expect(resolveActivationEnv().runtimeEnv).toBe( + expect(resolveChannelActivationEnv().runtimeEnv).toBe( process.env.NODE_ENV ?? "development", ); } finally { @@ -100,8 +108,8 @@ describe("resolveActivationEnv", () => { }); }); -describe("startManagedBots", () => { - it("validates, wires each bot with a managed adapter, and routes delivery per bot", async () => { +describe("startChannels", () => { + it("validates, wires each Bot with a channel adapter, and routes delivery per channel", async () => { const a = createBot({ name: "support", agent: () => new FakeAgent() }); const b = createBot({ name: "triage", agent: () => new FakeAgent() }); const aPosted: string[] = []; @@ -117,7 +125,7 @@ describe("startManagedBots", () => { const sources = new Map(); const sinks = new Map(); - const handle = await startManagedBots({ + const handle = await startChannels({ bots: [a, b], resolveTransport: (botName) => { const source = new InMemoryDeliverySource(); @@ -129,7 +137,7 @@ describe("startManagedBots", () => { env: { runtimeEnv: "test" }, }); - expect([...handle.metadata.declaredBotNames].sort()).toEqual([ + expect([...handle.metadata.declaredChannelNames].sort()).toEqual([ "support", "triage", ]); @@ -138,7 +146,7 @@ describe("startManagedBots", () => { deliveryId: "d1", eventId: "e1", turnId: "t1", - botName: "support", + channelName: "support", platform: "slack", conversationKey: "c1", route: {}, @@ -153,7 +161,7 @@ describe("startManagedBots", () => { await handle.stop(); }); - it("forwards renderSink so managed runtime streams rich render frames", async () => { + it("forwards renderSink so channel runtime streams rich render frames", async () => { const bot = createBot({ name: "support", agent: () => new FakeAgent() }); bot.onMessage(async ({ thread }) => { await thread.post(Section({ children: "A" })); @@ -162,7 +170,7 @@ describe("startManagedBots", () => { const source = new InMemoryDeliverySource(); const egress = new InMemoryEgressSink(); const renderSink = new InMemoryRenderEventSink(); - const handle = await startManagedBots({ + const handle = await startChannels({ bots: [bot], resolveTransport: () => ({ source, egress, renderSink }), env: { runtimeEnv: "test" }, @@ -172,7 +180,7 @@ describe("startManagedBots", () => { deliveryId: "d1", eventId: "e1", turnId: "t1", - botName: "support", + channelName: "support", platform: "slack", conversationKey: "c1", route: {}, @@ -189,7 +197,7 @@ describe("startManagedBots", () => { it("throws on invalid names before wiring anything", async () => { const a = createBot({ agent: () => new FakeAgent() }); // no name await expect( - startManagedBots({ + startChannels({ bots: [a], resolveTransport: () => ({ source: new InMemoryDeliverySource(), @@ -205,7 +213,7 @@ describe("startManagedBots", () => { const b = createBot({ name: "triage", agent: () => new FakeAgent() }); const stopA = vi.spyOn(a, "stop"); await expect( - startManagedBots({ + startChannels({ bots: [a, b], // First bot wires fine; second bot's transport resolution throws // AFTER `a` is already live. @@ -225,7 +233,7 @@ describe("startManagedBots", () => { it("warns (but does not throw) when called with no bots", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); try { - const handle = await startManagedBots({ + const handle = await startChannels({ bots: [], resolveTransport: () => ({ source: new InMemoryDeliverySource(), @@ -233,8 +241,8 @@ describe("startManagedBots", () => { }), env: { runtimeEnv: "test" }, }); - expect(handle.metadata.declaredBotNames).toEqual([]); - expect(warn).toHaveBeenCalledWith(expect.stringMatching(/no bots/i)); + expect(handle.metadata.declaredChannelNames).toEqual([]); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/no channels/i)); await handle.stop(); } finally { warn.mockRestore(); diff --git a/packages/channels-intelligence/src/runtime.ts b/packages/channels-intelligence/src/runtime.ts index fe3893f24d9..e157c0a7435 100644 --- a/packages/channels-intelligence/src/runtime.ts +++ b/packages/channels-intelligence/src/runtime.ts @@ -6,68 +6,68 @@ import type { } from "./transports.js"; import { intelligenceAdapter } from "./intelligence-adapter.js"; -/** Project/code identifier: starts with a letter, then letters/digits/underscore. */ -const BOT_NAME_RE = /^[A-Za-z][A-Za-z0-9_]*$/; +/** Lowercase kebab-case Channel name, 3–64 characters. */ +const CHANNEL_NAME_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; +const RESERVED_CHANNEL_NAME = "channels"; /** - * Validate the bots declared to a managed runtime: each needs a `name`, names - * must be identifier-style, and they must be unique within the runtime. Fails + * Validate the framework Bots declared to a Channel runtime: each needs a + * `name`, names must be lowercase kebab-case Channel names, and they must be + * unique within the runtime. Fails * loudly — a misconfigured declaration should never start silently. */ -export function assertValidBotNames(bots: readonly Bot[]): void { +export function assertValidChannelNames(bots: readonly Bot[]): void { const seen = new Set(); for (const bot of bots) { const name = bot.name; if (!name) { throw new Error( - "managed bot is missing a `name` — pass createBot({ name }) for Intelligence-delivered bots", + "Channel runtime Bot is missing a `name` — pass createBot({ name }) for an Intelligence Channel", ); } - if (!BOT_NAME_RE.test(name)) { + if (name.length < 3 || name.length > 64 || !CHANNEL_NAME_RE.test(name)) { throw new Error( - `managed bot name "${name}" is invalid — use a project/code identifier ` + - "(letters, digits, underscore; starting with a letter)", + `Channel name "${name}" is invalid — use lowercase kebab-case, 3–64 characters`, ); } - // Case-insensitive uniqueness: "Support" and "support" would resolve to the - // same delivery routing, so reject the collision rather than let both bots - // receive every delivery. - const key = name.toLowerCase(); - if (seen.has(key)) { + if (name === RESERVED_CHANNEL_NAME) { + throw new Error(`Channel name "${name}" is reserved`); + } + if (seen.has(name)) { throw new Error( - `duplicate managed bot name "${name}" — each bot in a runtime must be unique (case-insensitive)`, + `duplicate Channel name "${name}" — each Channel runtime Bot must be unique`, ); } - seen.add(key); + seen.add(name); } } /** Runtime environment + version metadata sent to Intelligence on activation. */ -export interface ActivationEnv { +export interface ChannelActivationEnv { runtimeInstanceId?: string; /** COPILOTKIT_RUNTIME_ENV override, else NODE_ENV, else "development". */ runtimeEnv: string; nodeEnv?: string; nodeVersion?: string; runtimePackageVersion?: string; - botPackageVersion?: string; + channelsPackageVersion?: string; } -export interface ActivationMetadata extends ActivationEnv { - declaredBotNames: string[]; - /** Per-bot declarations: name + declared slash-command names. */ - declaredBots: Array<{ name: string; commands: string[] }>; +export interface ChannelActivationMetadata extends ChannelActivationEnv { + declaredChannelNames: string[]; + /** Per-Channel declarations: name + declared slash-command names. */ + declaredChannels: Array<{ channelName: string; commands: string[] }>; } /** * Gather the process-level runtime activation env — `COPILOTKIT_RUNTIME_ENV` * (override) → `NODE_ENV` → "development", and the Node version. Caller * `overrides` win and supply what only the runtime knows: package versions - * (`runtimePackageVersion`/`botPackageVersion`) and a stable `runtimeInstanceId`. + * (`runtimePackageVersion`/`channelsPackageVersion`) and a stable `runtimeInstanceId`. */ -export function resolveActivationEnv( - overrides: Partial = {}, -): ActivationEnv { +export function resolveChannelActivationEnv( + overrides: Partial = {}, +): ChannelActivationEnv { // Guard against non-Node hosts (browser/edge) where `process` is absent. const env = typeof process !== "undefined" ? process.env : undefined; const nodeEnv = env?.NODE_ENV; @@ -81,85 +81,85 @@ export function resolveActivationEnv( /** * Build the activation metadata declared to Intelligence: the resolved - * env/versions plus per-bot declarations (name + declared command names). Pure. + * env/versions plus per-Channel declarations (name + declared command names). Pure. * - * Assumes every bot has a name — call {@link assertValidBotNames} first - * (`startManagedBots` does). A nameless bot is a programming error and throws + * Assumes every Bot has a name — call {@link assertValidChannelNames} first + * (`startChannels` does). A nameless Bot is a programming error and throws * rather than being silently filtered out of the activation set. * - * TODO(OSS-377): add richer per-bot capabilities once the bot exposes them. + * TODO(OSS-377): add richer per-Channel capabilities once the framework Bot exposes them. */ -export function buildActivationMetadata( +export function buildChannelActivationMetadata( bots: readonly Bot[], - env: ActivationEnv, -): ActivationMetadata { + env: ChannelActivationEnv, +): ChannelActivationMetadata { const names = bots.map((b) => { if (!b.name) { throw new Error( - "buildActivationMetadata: bot is missing a `name` — validate with assertValidBotNames first", + "buildChannelActivationMetadata: Bot is missing a `name` — validate with assertValidChannelNames first", ); } return b.name; }); return { ...env, - declaredBotNames: names, - declaredBots: bots.map((b, i) => ({ - name: names[i]!, + declaredChannelNames: names, + declaredChannels: bots.map((b, i) => ({ + channelName: names[i]!, commands: b.commandNames, })), }; } -/** Per-bot managed transport, resolved by the runtime (closed Gateway/Outbox). */ -export interface ManagedTransport { +/** Per-Channel transport, resolved by the runtime (closed Gateway/Outbox). */ +export interface ChannelTransport { source: DeliverySource; egress: EgressSink; renderSink?: RenderEventSink; store?: StateStore; } -export interface StartManagedBotsOptions { +export interface StartChannelsOptions { bots: Bot[]; - /** Resolve the inbound/outbound transport for a declared bot name. */ - resolveTransport: (botName: string) => ManagedTransport; + /** Resolve the inbound/outbound transport for a declared Channel name. */ + resolveTransport: (channelName: string) => ChannelTransport; /** Activation env overrides; omitted fields are gathered from the process. */ - env?: Partial; + env?: Partial; } -export interface ManagedBotsHandle { - metadata: ActivationMetadata; +export interface ChannelsHandle { + metadata: ChannelActivationMetadata; stop(): Promise; } /** - * Start the managed listener lifecycle: validate the declared bots, build the - * activation metadata, then attach an `intelligenceAdapter` to each bot (wired + * Start the Channel listener lifecycle: validate the declared framework Bots, + * build the activation metadata, then attach an `intelligenceAdapter` to each Bot (wired * to its resolved transport) and start it. Returns the metadata and a `stop`. * * The transports come from the caller (production: the Realtime Gateway + * Connector Outbox clients; tests: in-memory). This module owns no Slack * credentials, webhook ingress, or outbox persistence. */ -export async function startManagedBots( - opts: StartManagedBotsOptions, -): Promise { - assertValidBotNames(opts.bots); +export async function startChannels( + opts: StartChannelsOptions, +): Promise { + assertValidChannelNames(opts.bots); if (opts.bots.length === 0) { console.warn( - "[bot-intelligence] startManagedBots called with no bots — nothing to start. " + + "[channels-intelligence] startChannels called with no channels — nothing to start. " + "Pass `bots: [createBot({ name })]` on the Intelligence runtime.", ); } - const metadata = buildActivationMetadata( + const metadata = buildChannelActivationMetadata( opts.bots, - resolveActivationEnv(opts.env), + resolveChannelActivationEnv(opts.env), ); // Partial-start rollback: addAdapter/resolveTransport/start for bot N can - // throw AFTER bots 0..N-1 are already live. Without unwinding, those started - // bots leak (open listeners/connections) with no handle to stop them. Track + // throw AFTER Bots 0..N-1 are already live. Without unwinding, those started + // Bots leak (open listeners/connections) with no handle to stop them. Track // what started and stop it before rethrowing. - const startedBots: Bot[] = []; + const startedChannels: Bot[] = []; try { for (const bot of opts.bots) { const { source, egress, renderSink, store } = opts.resolveTransport( @@ -169,10 +169,10 @@ export async function startManagedBots( intelligenceAdapter({ source, egress, renderSink, store }), ); await bot.start(); - startedBots.push(bot); + startedChannels.push(bot); } } catch (err) { - await Promise.allSettled(startedBots.map((b) => b.stop())); + await Promise.allSettled(startedChannels.map((b) => b.stop())); throw err; } return { diff --git a/packages/channels-intelligence/src/transports.ts b/packages/channels-intelligence/src/transports.ts index 2df6d1231ce..135c8897069 100644 --- a/packages/channels-intelligence/src/transports.ts +++ b/packages/channels-intelligence/src/transports.ts @@ -1,6 +1,6 @@ import type { AgentContentPart } from "@copilotkit/channels-ui"; import type { - ManagedIngressEnvelope, + ChannelIngressEnvelope, EgressRoute, EgressOperation, EgressResult, @@ -10,7 +10,7 @@ import type { /** * A conversation-history message the adapter seeds onto a fresh agent's - * `messages` before a turn runs, giving the managed bot visibility into prior + * `messages` before a turn runs, giving the Channel runtime visibility into prior * thread turns (parity with bot-slack/bot-discord/bot-whatsapp's * reconstructed-history conversation stores). Structurally compatible with — * but intentionally looser than — the AG-UI `Message` union (which types an @@ -25,7 +25,7 @@ export interface AgentMessage { } /** - * Inbound transport for managed delivery. Implemented by the Intelligence + * Inbound transport for Intelligence Channel delivery. Implemented by the Intelligence * Realtime Gateway client in production, and by {@link InMemoryDeliverySource} * for headless/standalone runs and tests. The bridge adapter is the only * consumer — it never knows which implementation is wired. @@ -33,14 +33,14 @@ export interface AgentMessage { export interface DeliverySource { /** Begin delivering. `onDelivery` is invoked once per leased envelope. */ start( - onDelivery: (env: ManagedIngressEnvelope) => Promise, + onDelivery: (env: ChannelIngressEnvelope) => Promise, ): Promise; /** Acknowledge successful processing of a delivery (lease release). */ ack(deliveryId: string): Promise; /** Negatively acknowledge — the work will be redelivered (at-least-once). */ nack(deliveryId: string, reason: string): Promise; /** - * Fetch an inbound file's bytes by handle (managed multimodal content). + * Fetch an inbound file's bytes by handle (Channel multimodal content). * Optional: sources without a file-serve backing omit it and the adapter * skips content-part hydration (text-only turn). */ @@ -74,7 +74,7 @@ export interface DeliverySource { } /** - * Outbound transport for managed replies. Implemented by the Intelligence + * Outbound transport for Channel replies. Implemented by the Intelligence * Connector Outbox client in production, and by {@link InMemoryEgressSink} for * tests. Receives generic egress operations with deterministic ids; the real * platform render + credentialed send happen on the Intelligence side. diff --git a/packages/channels/src/create-bot.ts b/packages/channels/src/create-bot.ts index c3efeb9ed07..0e6728728ef 100644 --- a/packages/channels/src/create-bot.ts +++ b/packages/channels/src/create-bot.ts @@ -175,15 +175,15 @@ export interface CreateBotOptions< TStateSchema extends StandardSchemaV1 | undefined = undefined, > { /** - * Project-unique bot identifier. Required for managed (Intelligence-delivered) - * bots — it ties the runtime declaration to the Intelligence setup — and - * optional for local/custom adapters. Validated by the managed runtime - * (`startManagedBots`), not here. + * Project-unique Intelligence Channel name. Required for Intelligence Channel + * Bots — it ties the runtime declaration to the Intelligence setup — and + * optional for local/custom adapters. Validated by the Channel runtime + * (`startChannels`), not here. */ name?: string; /** * Adapters supplied at construction. Optional — adapters can also be attached - * before `start()` via {@link Bot.addAdapter} (the managed runtime uses this). + * before `start()` via {@link Bot.addAdapter} (the Channel runtime uses this). */ adapters?: PlatformAdapter[]; agent?: AbstractAgent | ((threadId: string) => AbstractAgent); @@ -205,9 +205,9 @@ export interface CreateBotOptions< } export interface Bot { - /** Project-unique identifier from `createBot({ name })`; used by the managed runtime. */ + /** Project-unique identifier from `createBot({ name })`; used by the Channel runtime. */ readonly name?: string; - /** Declared slash-command names (normalized). Surfaced for managed activation metadata. */ + /** Declared slash-command names (normalized). Surfaced for Channel activation metadata. */ readonly commandNames: string[]; onMention(h: BotHandler): void; onMessage(h: BotHandler): void; @@ -272,15 +272,15 @@ function msgFromTurn(turn: IncomingTurn): IncomingMessage { } /** - * Enforce V1 managed exclusivity: a managed adapter (`intelligenceAdapter`) - * must be the only adapter on a bot. Managed and direct delivery are + * Enforce V1 Intelligence Channel exclusivity: an Intelligence Channel adapter + * (`intelligenceAdapter`) must be the only adapter on a bot. Channel and direct delivery are * alternative modes per platform — Intelligence holds the platform creds, or * the runtime does, never both. */ function assertExclusive(adapters: PlatformAdapter[]): void { - if (adapters.some((a) => a.__managed) && adapters.length > 1) { + if (adapters.some((a) => a.__intelligenceChannel) && adapters.length > 1) { throw new Error( - "intelligenceAdapter() must be the only adapter on a bot — managed and " + + "intelligenceAdapter() must be the only adapter on a bot — Channel and " + "direct delivery are alternative modes. Use intelligenceAdapter() OR " + "direct adapters (slack/discord/...), not both.", ); @@ -324,7 +324,7 @@ export function createBot< } // Adapters can be supplied up front or added later via `bot.addAdapter` - // (before `start()`). The runtime uses the latter to attach managed delivery. + // (before `start()`). The runtime uses the latter to attach Channel delivery. const adapters: PlatformAdapter[] = [...(opts.adapters ?? [])]; assertExclusive(adapters); let started = false; @@ -664,7 +664,7 @@ export function createBot< // Per-message handler set via `` on the posted // message — hot cache, falling back to the durable snapshot after a // restart. Resolve by `postedMessageId` when the adapter supplies it - // (managed: the reaction arrives keyed by the provider ts, not the SDK + // (Intelligence Channel: the reaction arrives keyed by the provider ts, not the SDK // post ref the handler was persisted under); else by `messageId`. const perMessage = await registry!.resolveMessageReaction( evt.postedMessageId ?? evt.messageId, diff --git a/packages/channels/src/index.ts b/packages/channels/src/index.ts index c3915bda862..7e957f95c5c 100644 --- a/packages/channels/src/index.ts +++ b/packages/channels/src/index.ts @@ -103,8 +103,8 @@ export { mintId, stableStringify } from "./mint-id.js"; export { runAgentLoop } from "./run-loop.js"; export type { RunLoopArgs } from "./run-loop.js"; -// Pure, per-platform codec seam (shared with the managed/Connector-Outbox path). -// The Intelligence-delivered managed adapter itself lives in +// Pure, per-platform codec seam (shared with the Channel/Connector-Outbox path). +// The Intelligence Channel adapter itself lives in // `@copilotkit/channels-intelligence`. export type { PlatformCodec } from "./codec.js"; diff --git a/packages/channels/src/platform-adapter.ts b/packages/channels/src/platform-adapter.ts index 8114f7981da..783f43bd15a 100644 --- a/packages/channels/src/platform-adapter.ts +++ b/packages/channels/src/platform-adapter.ts @@ -31,7 +31,7 @@ export interface SurfaceCapabilities { /** * Whether `Thread.awaitChoice` can block synchronously for a user's click * within a single run. `true`/undefined on interactive surfaces (Slack Socket - * Mode, Discord, …). Set `false` on ack-first surfaces like the managed + * Mode, Discord, …). Set `false` on ack-first surfaces like the Intelligence * Intelligence HTTP loop, where a run must end after posting the picker and * resume on the click's separate inbound delivery (a blocking wait would * deadlock the one-delivery-at-a-time claim loop). The HITL resume flow will @@ -83,14 +83,14 @@ export interface IngressEventBase { /** * Idempotency ids carried by turn/command/interaction ingress. Set on the - * managed (Intelligence-delivered) path; local adapters omit them. + * Intelligence Channel path; local adapters omit them. */ export interface IngressIds { /** Stable platform event id for idempotency; omit if the platform provides none. */ eventId?: string; - /** Stable per-turn id (managed/Intelligence path); local adapters omit it. */ + /** Stable per-turn id (Intelligence Channel path); local adapters omit it. */ turnId?: string; - /** Lease/delivery id (managed/Intelligence path); local adapters omit it. */ + /** Lease/delivery id (Intelligence Channel path); local adapters omit it. */ deliveryId?: string; } @@ -235,7 +235,7 @@ export interface ConversationStore { /** * Optional context bot core passes to {@link PlatformAdapter.start}. Carries * the bot's declared identity so a transport that must announce itself (e.g. - * the managed Intelligence adapter's heartbeat) can do so without separate + * the Intelligence Channel adapter's heartbeat) can do so without separate * config. Local adapters ignore it. */ export interface AdapterStartContext { @@ -268,11 +268,11 @@ export interface PlatformAdapter { * {@link conversationStore}. */ readonly stateStore?: StateStore; - /** @internal Marks the managed adapter; bot core uses it for the V1 exclusivity guard. */ - readonly __managed?: boolean; + /** @internal Marks the Intelligence Channel adapter for the V1 exclusivity guard. */ + readonly __intelligenceChannel?: boolean; /** * When true, bot core skips its ingress dedup for events from this adapter. - * Set by at-least-once transports (managed delivery) that enforce + * Set by at-least-once transports (Channel delivery) that enforce * idempotency at egress instead — dropping a redelivery at ingress would lose * a legitimate retry. */ diff --git a/packages/channels/src/reactions.test.ts b/packages/channels/src/reactions.test.ts index 5fb946832ea..21525adb987 100644 --- a/packages/channels/src/reactions.test.ts +++ b/packages/channels/src/reactions.test.ts @@ -109,7 +109,7 @@ describe("bot.onReaction", () => { ]); }); - it("resolves by postedMessageId when the reaction id differs (managed path)", async () => { + it("resolves by postedMessageId when the reaction id differs (Channel path)", async () => { const fake = new FakeAdapter(); const bot = createBot({ adapters: [fake] }); const seen: string[] = []; diff --git a/packages/runtime/src/v2/runtime/core/runtime.ts b/packages/runtime/src/v2/runtime/core/runtime.ts index cfb4cef42b1..9af1bd6af98 100644 --- a/packages/runtime/src/v2/runtime/core/runtime.ts +++ b/packages/runtime/src/v2/runtime/core/runtime.ts @@ -35,8 +35,8 @@ import { IntelligenceAgentRunner } from "../runner/intelligence"; import type { CopilotKitIntelligence } from "../intelligence-platform"; // Type-only: @copilotkit/channels is pure-ESM, so a value import would break this // package's CJS output. The bots are validated + activated (wired to delivery -// transports) by `startManagedBots` from @copilotkit/channels, called by the -// managed-listener bootstrap — not here. +// transports) by `startChannels` from @copilotkit/channels-intelligence, called +// by the Channel-listener bootstrap — not here. import type { Bot } from "@copilotkit/channels"; import telemetry from "../telemetry/telemetry-client"; @@ -203,11 +203,11 @@ export interface CopilotIntelligenceRuntimeOptions extends BaseCopilotRuntimeOpt /** Interval in seconds at which the runtime renews the thread lock. Clamped to a maximum of 3000 (50 minutes). @default 15 */ lockHeartbeatIntervalSeconds?: number; /** - * Managed bots (Intelligence-delivered) declared by this runtime. Each is a + * Intelligence Channels declared by this runtime. Each is a * `createBot({ name })` instance. Only available on the Intelligence runtime - * path. Names are validated (required, identifier-style, unique) and wired to - * delivery/egress transports when activated via `startManagedBots` from - * `@copilotkit/channels` — not at construction. + * path. Names are validated (required, lowercase kebab-case, unique) and wired + * to delivery/egress transports when activated via `startChannels` from + * `@copilotkit/channels-intelligence` — not at construction. */ bots?: Bot[]; } @@ -409,8 +409,8 @@ export class CopilotIntelligenceRuntime options.lockHeartbeatIntervalSeconds ?? 15, CopilotIntelligenceRuntime.MAX_HEARTBEAT_INTERVAL_SECONDS, ); - // Declared managed bots. Full name validation (identifier shape + - // uniqueness) lives in `startManagedBots` (`assertValidBotNames`) at + // Declared Intelligence Channels. Full name validation (lowercase kebab-case + + // uniqueness) lives in `startChannels` (`assertValidChannelNames`) at // activation — it can't run here because it's a value import from the // pure-ESM `@copilotkit/channels-intelligence`, which this CJS package must not // pull in. Fail fast on the most common misconfiguration (a missing name) From 001dda539a88a8e0a8bc8f7d3eeb0063894b705d Mon Sep 17 00:00:00 2001 From: Tyler Slaton Date: Fri, 10 Jul 2026 14:47:06 -0700 Subject: [PATCH 33/40] chore(channels-intelligence): complete channel terminology sweep --- packages/channels-intelligence/package.json | 1 - packages/channels-intelligence/src/runtime.test.ts | 10 +++++----- packages/channels/src/reactions.test.ts | 2 +- packages/runtime/src/v2/runtime/core/runtime.ts | 6 +++--- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/channels-intelligence/package.json b/packages/channels-intelligence/package.json index c77d64c7781..f62e17a8ba4 100644 --- a/packages/channels-intelligence/package.json +++ b/packages/channels-intelligence/package.json @@ -12,7 +12,6 @@ "keywords": [ "ai", "agent", - "bot", "intelligence", "channels", "copilotkit", diff --git a/packages/channels-intelligence/src/runtime.test.ts b/packages/channels-intelligence/src/runtime.test.ts index 6c15d2753c3..6308cdea80b 100644 --- a/packages/channels-intelligence/src/runtime.test.ts +++ b/packages/channels-intelligence/src/runtime.test.ts @@ -127,11 +127,11 @@ describe("startChannels", () => { const sinks = new Map(); const handle = await startChannels({ bots: [a, b], - resolveTransport: (botName) => { + resolveTransport: (channelName) => { const source = new InMemoryDeliverySource(); const egress = new InMemoryEgressSink(); - sources.set(botName, source); - sinks.set(botName, egress); + sources.set(channelName, source); + sinks.set(channelName, egress); return { source, egress }; }, env: { runtimeEnv: "test" }, @@ -217,8 +217,8 @@ describe("startChannels", () => { bots: [a, b], // First bot wires fine; second bot's transport resolution throws // AFTER `a` is already live. - resolveTransport: (botName) => { - if (botName === "triage") throw new Error("boom"); + resolveTransport: (channelName) => { + if (channelName === "triage") throw new Error("boom"); return { source: new InMemoryDeliverySource(), egress: new InMemoryEgressSink(), diff --git a/packages/channels/src/reactions.test.ts b/packages/channels/src/reactions.test.ts index 21525adb987..c5d6bc10d7d 100644 --- a/packages/channels/src/reactions.test.ts +++ b/packages/channels/src/reactions.test.ts @@ -126,7 +126,7 @@ describe("bot.onReaction", () => { await bot.start(); fake.emitTurn({}); await tick(); - // Managed delivery: the reaction arrives keyed by the provider ts (NOT the + // Channel delivery: the reaction arrives keyed by the provider ts (NOT the // post ref the handler was registered under), and the adapter supplies the // reverse-mapped post ref as `postedMessageId`. Resolution must prefer it. fake.emitReaction({ diff --git a/packages/runtime/src/v2/runtime/core/runtime.ts b/packages/runtime/src/v2/runtime/core/runtime.ts index 9af1bd6af98..658e9bba023 100644 --- a/packages/runtime/src/v2/runtime/core/runtime.ts +++ b/packages/runtime/src/v2/runtime/core/runtime.ts @@ -181,7 +181,7 @@ export interface CopilotSseRuntimeOptions extends BaseCopilotRuntimeOptions { runner?: AgentRunner; intelligence?: undefined; generateThreadNames?: undefined; - /** Managed bots require the Intelligence runtime; not available in SSE mode. */ + /** Intelligence Channels require the Intelligence runtime; not available in SSE mode. */ bots?: undefined; } @@ -358,7 +358,7 @@ export class CopilotSseRuntime if (Array.isArray(bots) && bots.length > 0) { throw new Error( "`bots` requires the Intelligence runtime (pass `intelligence`); " + - "managed bots are not available in SSE mode.", + "Intelligence Channels are not available in SSE mode.", ); } super(options, options.runner ?? new InMemoryAgentRunner()); @@ -419,7 +419,7 @@ export class CopilotIntelligenceRuntime for (const b of this.bots) { if (!b.name) { throw new Error( - "managed bot is missing a `name` — pass createBot({ name }) for each bot in `bots`", + "Intelligence Channel Bot is missing a `name` — pass createBot({ name }) for each Bot in `bots`", ); } } From ea50654ec04bf26218050f481d93db6cab9ae572 Mon Sep 17 00:00:00 2001 From: tylerslaton <54378333+tylerslaton@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:44:26 +0000 Subject: [PATCH 34/40] chore: release channels v0.1.1 --- packages/channels-ui/package.json | 2 +- packages/channels/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/channels-ui/package.json b/packages/channels-ui/package.json index ba9e7405c3a..246fabf0b74 100644 --- a/packages/channels-ui/package.json +++ b/packages/channels-ui/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/channels-ui", - "version": "0.1.0", + "version": "0.1.1", "description": "JSX runtime, IR, and cross-platform component vocabulary for CopilotKit channels.", "license": "MIT", "repository": { diff --git a/packages/channels/package.json b/packages/channels/package.json index e336fcca2ff..6f3eab0718b 100644 --- a/packages/channels/package.json +++ b/packages/channels/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/channels", - "version": "0.1.0", + "version": "0.1.1", "description": "Platform-agnostic JSX channel engine for CopilotKit (createBot, Thread, PlatformAdapter, ActionStore).", "license": "MIT", "repository": { From ceae64cf1b49907f55c5cd9cd8f9ab7baa90f5cb Mon Sep 17 00:00:00 2001 From: tylerslaton <54378333+tylerslaton@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:44:42 +0000 Subject: [PATCH 35/40] chore: release channels-discord v0.0.3 --- packages/channels-discord/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/channels-discord/package.json b/packages/channels-discord/package.json index f4d8f1a5bae..81b73da8ca2 100644 --- a/packages/channels-discord/package.json +++ b/packages/channels-discord/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/channels-discord", - "version": "0.0.2", + "version": "0.0.3", "description": "Discord PlatformAdapter for @copilotkit/channels.", "license": "MIT", "repository": { From f4e19e2039249458f110d1251dd7719ab6dc6dfa Mon Sep 17 00:00:00 2001 From: tylerslaton <54378333+tylerslaton@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:44:50 +0000 Subject: [PATCH 36/40] chore: release channels-intelligence v0.1.1 --- packages/channels-intelligence/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/channels-intelligence/package.json b/packages/channels-intelligence/package.json index f62e17a8ba4..28c353d508c 100644 --- a/packages/channels-intelligence/package.json +++ b/packages/channels-intelligence/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/channels-intelligence", - "version": "0.1.0", + "version": "0.1.1", "description": "Intelligence-delivered Channel adapter for CopilotKit JSX channels (@copilotkit/channels).", "license": "MIT", "repository": { From 4ce3124c5ce5944dc91590ee87e514b37e8d6d82 Mon Sep 17 00:00:00 2001 From: tylerslaton <54378333+tylerslaton@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:44:52 +0000 Subject: [PATCH 37/40] chore: release channels-slack v0.1.2 --- packages/channels-slack/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/channels-slack/package.json b/packages/channels-slack/package.json index b963e979fd2..3ba991b33e4 100644 --- a/packages/channels-slack/package.json +++ b/packages/channels-slack/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/channels-slack", - "version": "0.1.1", + "version": "0.1.2", "description": "Slack platform adapter for CopilotKit JSX channels (@copilotkit/channels).", "license": "MIT", "repository": { From 2893feecde01ac344afbc1d1ffc82858252d771d Mon Sep 17 00:00:00 2001 From: tylerslaton <54378333+tylerslaton@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:44:52 +0000 Subject: [PATCH 38/40] chore: release channels-teams v0.1.2 --- packages/channels-teams/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/channels-teams/package.json b/packages/channels-teams/package.json index 878b18b3d0a..b11fbb293eb 100644 --- a/packages/channels-teams/package.json +++ b/packages/channels-teams/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/channels-teams", - "version": "0.1.1", + "version": "0.1.2", "description": "Microsoft Teams platform adapter for CopilotKit JSX channels (@copilotkit/channels).", "license": "MIT", "repository": { From 6281beaf6f94fe055caa3d939deed7c88f0d9d5f Mon Sep 17 00:00:00 2001 From: tylerslaton <54378333+tylerslaton@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:45:08 +0000 Subject: [PATCH 39/40] chore: release channels-telegram v0.0.4 --- packages/channels-telegram/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/channels-telegram/package.json b/packages/channels-telegram/package.json index cfcfae35f2c..699fe723ac2 100644 --- a/packages/channels-telegram/package.json +++ b/packages/channels-telegram/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/channels-telegram", - "version": "0.0.3", + "version": "0.0.4", "description": "Telegram platform adapter for CopilotKit JSX channels (@copilotkit/channels).", "license": "MIT", "repository": { From 4c04e8028ea06d17ccd9727fd876dc04524371d8 Mon Sep 17 00:00:00 2001 From: tylerslaton <54378333+tylerslaton@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:45:18 +0000 Subject: [PATCH 40/40] chore: release channels-whatsapp v0.0.2 --- packages/channels-whatsapp/package.json | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/channels-whatsapp/package.json b/packages/channels-whatsapp/package.json index d67b5e588f0..8aacf3462fe 100644 --- a/packages/channels-whatsapp/package.json +++ b/packages/channels-whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/channels-whatsapp", - "version": "0.0.1", + "version": "0.0.2", "description": "WhatsApp Cloud API platform adapter for CopilotKit JSX channels (@copilotkit/channels).", "license": "MIT", "repository": { @@ -18,13 +18,20 @@ "copilotkit", "ag-ui" ], - "publishConfig": { "access": "public" }, - "files": ["dist"], + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { - ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } }, "scripts": { "build": "tsc -p tsconfig.json",