Commit Graph

13978 Commits

Author SHA1 Message Date
github-actions[bot] a4a88aa748 style: auto-fix formatting 2026-07-31 08:05:28 +00:00
Lukas Moschitz 49d18e7353 fix(showcase): ship the query_data dataset inside tools/ and fail loud when it is missing
showcase/shared/python/tools/query_data.py resolved db.csv at ../data/db.csv, i.e. showcase/shared/python/data/. Every integration Dockerfile copies the tools/ symlink but none copied that sibling directory, so the CSV was absent at runtime and the except-branch silently served a 3-row _MOCK_DATA.

Visible effect in the beautiful-chat demos of the 8 integrations that use the shared tool and have no local copy (ag2, agno, claude-sdk-python, crewai-crews, google-adk, langroid, llamaindex, strands): the pie chart rendered 2 slices (the two income mock rows) and the bar chart a single bar (the one expense mock row), while looking healthy. Confirmed in a running container: /app/tools/query_data.py present, db.csv nowhere in the image.

Move the dataset into the tools/ package (which every Dockerfile already copies) and resolve it relative to the module. Replace the silent degrade with a RuntimeError; the mock stays available behind SHOWCASE_ALLOW_MOCK_DATASET=1 for tests. A warning was not enough here: these integrations run the agent as a child process whose stdout is not captured, so nobody ever saw it.

Verified: 40 rows load after the move (mock would be 3); missing file now raises; opt-in env var still yields the mock.
2026-07-31 10:03:27 +02:00
Mike Ryan 2fe47bc969 Prevent Slack actor metadata from leaking into assistant history (#6268)
## Summary

- keep participant actor metadata model-visible in Slack transcript
history
- keep own-channel assistant history equal to the provider-visible
message content
- preserve structured actor and provider message metadata returned by
`thread.getMessages()`

## Root cause

The delivery adapter prefixed every transcript entry with the untrusted
participant metadata envelope before assigning AG-UI roles. Own-channel
transcript entries were then assigned the assistant role with that
participant-style prefix still in their content, so the model received
the prefix as prior assistant output and could reproduce it on the next
turn.

## Validation

- `pnpm nx test @copilotkit/channels-intelligence --skip-nx-cache` (17
files, 117 tests)
- `pnpm nx run-many -t check-types build -p
@copilotkit/channels-intelligence --skip-nx-cache`
- repository pre-commit Nx test/publint/attw suite
2026-07-30 22:58:29 -07:00
Mike Ryan 552268d47d fix: keep participant metadata out of assistant history 2026-07-30 22:50:27 -07:00
Tyler Slaton ca5e665e98 feat(channels): implement Channels V5 (#6266)
## Summary

- add the provider-neutral V5 message operation contract with stable
logical and revision identities
- route created, updated, and deleted messages through explicit mention
and message semantics
- add delivery-scoped transcript loading with retry coalescing and
delivery thread authorization
- charge managed deliveries on first substantive work while leaving
preparation free
- handle transcript failures by surface, render Slack status, and fence
superseded runs before output
- reconcile managed files with idempotent operation identities and bound
pending delivery capacity
- propagate actor kind through prepared deliveries
- normalize Slack, Teams, Telegram, WhatsApp, and Discord turns onto the
same operation contract

## Validation

- pnpm nx run-many -t test build -p @copilotkit/channels-intelligence
@copilotkit/channels-slack
  - channels-intelligence: 117 tests passed
  - channels-slack: 320 tests passed
- pnpm nx run-many -t build test -p @copilotkit/channels-telegram
@copilotkit/channels-whatsapp @copilotkit/channels-discord
  - Telegram: 150 tests passed
  - WhatsApp: 74 tests passed
  - Discord: 194 tests passed
- pre-commit affected package test, publint, attw, binary, lint, and
commitlint checks pass
- broad affected run found unrelated existing example lint/build
failures; the branch-owned cross-adapter type failures it revealed were
fixed and rerun green
- paired Intelligence Docker E2E passed all four realtime boundary
scenarios against real Postgres, Redis, MinIO, App API, and two Gateways
with fake Runtime and fake Slack
2026-07-30 20:41:38 -07:00
Mike Ryan 8e2d7a5cda test(channels): bind canonical run delivery 2026-07-30 20:05:31 -07:00
Mike Ryan 4680d2f579 fix(channels): normalize remaining provider turns 2026-07-30 19:58:08 -07:00
Mike Ryan 81d192ad22 fix(channels): suppress exact provider self output 2026-07-30 19:51:08 -07:00
Mike Ryan 7259bae438 feat(channels): bound pending delivery capacity 2026-07-30 19:51:08 -07:00
Mike Ryan 9dc343ddf8 feat(channels): confirm managed file delivery 2026-07-30 19:51:08 -07:00
Mike Ryan ddfa6f3453 feat(channels): supersede pre-output runs 2026-07-30 19:51:08 -07:00
Mike Ryan 2a00a5a16a feat(channels): render native Slack status 2026-07-30 19:51:08 -07:00
Mike Ryan bff87d1428 feat(channels): handle transcript failures by surface 2026-07-30 19:51:07 -07:00
Mike Ryan f7437daad3 feat(channels): charge deliveries on first work 2026-07-30 19:51:07 -07:00
Mike Ryan f1eef91fed feat(channels): authorize delivery thread access 2026-07-30 19:51:07 -07:00
Mike Ryan b376c901f6 feat(channels): load delivery-scoped transcripts 2026-07-30 19:51:07 -07:00
Mike Ryan bf8abc3cef feat(channels): add V5 message operation routing 2026-07-30 19:51:07 -07:00
Ben Taylor 5a8a487df3 feat(runtime): auto-start managed Channels on long-running hosts (OSS-641) (#6258)
Resolves [OSS-641](https://linear.app/copilotkit/issue/OSS-641). Mike's
report: *"You have to `await channels.ready()` for it to connect to the
Realtime Gateway. Seems like there's some clunkiness to creating the
runtime and getting it connected."* He then picked the fix: *"I think it
should autostart in the long running wrappers."*

## What changes

**`createCopilotNodeListener` and `createCopilotExpressHandler` start
activation at creation.** A declared Channel connects because it was
declared; `channels.ready()` becomes await-and-observe rather than the
call you must remember. Failure-mode asymmetry is the argument:
forgetting `ready()` today gives you a process that serves HTTP, looks
healthy, and is silently disconnected with **zero output**, while
auto-start's worst case is an activation error in the logs.

**`createCopilotRuntimeHandler` and `createCopilotHonoHandler` stay
lazy.** The generic Fetch handler is the serverless/edge entry point —
isolates freeze and recycle per request, so separate cold starts would
mint competing listeners for the same Channel (the reason activation was
deferred in `fbf35ac59` in the first place). Hono keeps that behavior
because it is our Next.js App Router surface in practice: every route
handler in `examples/showcases/*` (banking, mcp-apps,
generative-ui-playground, oracle-agent-memory) plus the vue/nuxt demo
builds one at module scope. Its TSDoc now states why, loudly, so nobody
"finishes the job" later.

`activateChannels: false` remains the clean opt-out that opens no
socket.

## Consequence for host code: the shutdown boundary moves earlier

Signal handlers must now be registered **before the listener is
created**, not merely before `ready()`. Otherwise a Ctrl-C during the
connect window hits Node's default handler and leaks a live gateway
session. `examples/slack`, `examples/teams`, and the docs snippets are
restructured to wire teardown before the listener exists (a
`stopChannels`/`teardown` binding assigned in the same tick as
creation). **Worth calling out in the changelog** — it is the general
hazard for any user code that registers shutdown after mounting.

## Failure semantics

Fire-and-forget by necessity, since a factory is synchronous. Set-level
failures log at `error`; per-Channel failures keep their existing `warn`
breadcrumbs; an up-front misconfiguration (duplicate/missing Channel
names) now surfaces as a logged error at creation rather than a throw
out of the factory — the factory still never throws. `ready()` stays
idempotent and one-shot, so a host that *does* await it observes this
activation's outcome, including its rejection, rather than triggering a
second one.

## READMEs

Every `channels-*/README.md` quickstart built the *generic* handler and
needed `await handler.channels.ready()` — for a socket-mode Slack bot, a
request handler you construct and never serve, which is likely closer to
what actually felt clunky. All seven now use the Node listener, so they
inherit auto-start and agree with the docs-site quickstarts. No new
public surface: a bot-only `startChannels(runtime)` host was the
alternative and is deliberately not taken here.

## Testing

- **`packages/runtime` unit suite: 1815 passed / 128 files** (`npx
vitest run`), including 9 tests in `endpoints-channels.test.ts`
covering: auto-start on node + express; Hono still lazy;
`activateChannels: false` opens no socket; a failed auto-start logs
instead of leaving an unhandled rejection (asserted via an
`unhandledRejection` listener) and the reason survives to a later
`ready()`; a duplicate-name misconfig logs without throwing; and two
wrappers over one runtime activate once (the per-runtime manager cache
is load-bearing now that *construction* activates).
- **`examples/slack`: 63 passed / 12 files; `examples/teams`: 2 passed /
1 file** (`npm test` in each).
- **Typecheck clean:** `examples/slack` and `examples/teams` (`tsc
--noEmit`), plus a full `@copilotkit/runtime` tsdown build.
- **Lint/format clean:** `oxlint` reports 0 findings in every changed
file (the 21 warnings in that run are pre-existing, all in untouched
example render/tool files), `oxfmt --check` passes on all 9 changed
source files.
- **Docs:** verified no stale lifecycle claims remain (`opens no
connection` / `ready() is required` / `control surface` guards) across
`docs/channels/**` and the Slack + Teams platform guides.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-30 21:26:06 -05:00
Ben Taylor 9607d3a684 fix(channels): isolate factory-returned agents per turn (#6265)
## Summary

Every Channel turn now runs on an agent instance nobody else holds a
reference to, for **both** configured shapes — the singleton config and
the result of an `agent: (threadId) => …` factory.

This is a fresh start on #6260 (thanks @AlemTuzlak and @maxkorp — the
isolation change is theirs). That PR carried a per-run runner thread id
and a per-run lock namespace, both of which broke managed ingestion and
were removed during review. Rather than keep iterating on a branch whose
description had drifted from its diff, this restates the change from
`main` with an accurate root cause and the two corrections below.

**This is a concurrency-isolation fix.** Two overlapping mentions on one
conversation currently get prompted with each other's questions; that is
reproduced below and fixed here. It is *not* a fix for "only the first
mention gets a reply" — see [What the original report actually
was](#what-the-original-report-actually-was), which turns out to have
been fixed already in #6256.

## Root cause

`createChannel` resolves an agent per turn through `agentFactory`. The
singleton config was cloned per turn; a factory's result was returned
raw:

```ts
if (typeof a === "function") return (threadId: string) => sanitize(a(threadId));
```

A factory is free to return the same object on every call — `agent:
(threadId) => shared` — which is easy to write by accident, and is what
a singleton becomes when someone refactors to get at the `threadId`.

That matters because **turn concurrency defaults to `"parallel"`**
(`ChannelConcurrency`), and the only per-thread admission gate lives in
`channels-intelligence` (`ChannelDeliveryTransport.activeThreads`, added
in #6257). A **directly connected** adapter — Slack, Discord, Teams,
Telegram, WhatsApp — has no such gate, so two turns in one conversation
can genuinely run at the same time. On one shared instance they corrupt
each other:

- `messages` is a single array both runs append into, so each run's
new-message diff picks up the other's
- `isRunning`, `activeRunDetach$` and `activeRunCompletionPromise` are
single-slot fields the second run overwrites while the first is still
streaming
- managed delivery instead serializes on **object identity**
(`DeliveryAdapter.acquireAgent`), so two *different* conversations
sharing one instance head-of-line block each other
- managed `historyIds` is a `WeakMap` keyed on the agent, so a second
concurrent turn clobbers the first's "which messages are new" baseline

## Changes

- **Clone for both shapes.** A fresh factory (`() => new Agent()`) is
unaffected beyond one unused instance.
- **Guard the failure that mandatory cloning introduces.**
`AbstractAgent.prototype.clone()` copies a fixed field list, so a
subclass declaring its own state (auth client, config, cache) reads it
back as `undefined` — and nothing surfaces it, because the base method
always exists and returns a correctly-typed instance. Comparing own
enumerable keys catches exactly that and names the dropped fields.
- Own **functions** are exempt. Assigning a method on the instance is
how spies and instrumentation wrap an agent, and losing that wrapper
leaves the prototype method intact, so the clone still behaves
correctly. This is not hypothetical — an unconditional check failed 8
existing tests in this repo, all of which patch `runAgent` on the
instance.
- **Reset `isRunning` / `abortController` on the clone**, commented as
hygiene rather than as a fix: `runAgent` assigns `abortController =
params?.abortController ?? new AbortController()` before every run and
the run loop passes none, so an inherited aborted controller cannot
reach the next request. Noted inline that this discards
`HttpAgent.clone()`'s deliberate propagation of the source's aborted
state.
- **Correct the `clone()` error text** — it offered the factory as an
escape hatch, which cloning factory results removes.
- **Test scaffolding**: one `patchAgentAndClones` /
`captureSessionAgents` pair replaces five hand-rolled copies of the same
recursive clone-patching setup.
- **Remove `ChannelAgentConcurrencyError`** — unthrown since #6256. Its
doc said it was kept so older importers would not break, but
`channels-intelligence` exports only `.` and never re-exported it from
`index.ts` (`git log -S` over that file confirms it never did), so
nothing outside the package could ever reach it.
- **Preventive regression guard** in `channel-canonical-run.test.ts`:
the thread id reaching `runner.run` must stay the canonical product
thread id. `IntelligenceAgentRunner` sends it as `thread_id` when
joining `ingestion:<runId>` and the gateway resolves it against
`cpki.threads`, so decorating it fails the join with `thread_not_found`.
Nothing decorates it on `main` — this pins the invariant so that stays
true.

### Two things #6260 asserted that are not true

Recording these so they don't get re-derived:

1. **There is no lock-key leak to fix.** #6260 forwarded `lockKeyPrefix`
on lock cleanup, described as fixing a prefixed lock acquired under one
key and released under another. Intelligence `main` has `threadLockKey =
(organizationId, threadId) => \`thread:${org}:${threadId}:lock\`` — no
prefix parameter — and `lockKeyPrefix` is read **nowhere** in `apps/`.
Acquire, renew and cleanup all hit the same unprefixed key, so nothing
leaks. My own review said the same thing and was equally wrong. Omitted
here.
2. **A loud failure for dropped subclass fields *is* available** — #6260
states none is. It's the guard above.

## What the original report actually was

#6260 framed this as fixing "2+ **sequential** mentions, only the first
gets a reply." That framing was wrong, and the sequential mechanism it
implied does not exist. #6256's own root cause is the accurate one:

> Tagging a Slack bot multiple times (or five people asking in one
thread) only answered the first turn. Root cause was SDK turn locking
(`onLockConflict: drop`) and managed per-thread exclusive agent
execution — not Intelligence ingress.

Those are **overlapping** mentions, and both causes were fixed in #6256.
There was never a sequential bug: the stated mechanism cannot produce
one, because both real adapters replace the message list before the run
— managed does `agent.messages = [...history]`, Slack does
`agent.messages = history` plus a fresh per-turn threadId. Subscribers
are passed per-run to `runAgent(params, subscriber)` rather than
accumulated, `session.release?.()` sits in a `finally`, and `isRunning`
is cleared by `runAgent`'s own `finally`.

What #6256 left behind is the last hole in *its* story, and it is what
this PR closes: under the new parallel default, `agent: (id) => shared`
still hands both turns one instance. Both turns reply — so the symptom
is not a dead turn — but each is prompted with the other user's
question. Reproduced above.

## Follow-ups worth filing (not in this PR)

Two of the four I flagged are handled in this PR (the dead error class,
and the sequential mystery — resolved above). Two are not, deliberately:

1. **`lockKeyPrefix` is a no-op end to end**, yet configured in two
mainline Intelligence demos (`demos/splat-demo/bff/src/main.ts:371`,
`demos/simple-agent/bff/src/main.ts:457`). Deleting it is a breaking
removal of a public `CopilotRuntime` option spanning ~30 references
across `runtime.ts`, `channel-manager.ts`, `client.ts`,
`fetch-handler.ts` and `handlers/intelligence/run.ts`, and it needs a
lockstep Intelligence PR for the demos. It also carries a real product
question — implement per-prefix locks server-side (the closed #669) or
drop the concept. Too big and too breaking to ride along here.
2. **Directly connected adapters have no equivalent of #6257's
per-thread admission gate.** A design decision, not a removal: #6256
deliberately made same-conversation turns parallel. Adjacent to
OSS-686's cross-replica question, where the unprefixed thread lock is
the only fence spanning replicas.

## Testing

Everything below was run in-session on this branch, based on
`fcc8616e91`.

**Automated**

| Suite | Result |
|---|---|
| `nx run @copilotkit/channels-core:test` | 190 passed (33 files) — was
187, +3 new |
| `nx run @copilotkit/channels-intelligence:test` | 92 passed (15 files)
|
| `nx run @copilotkit/channels-slack:test` | 318 passed (23 files) |
| `nx run @copilotkit/channels-teams:test` | 90 passed (14 files) |
| `nx run @copilotkit/runtime:test` | 1813 passed (128 files) |
| `nx run @copilotkit/channels-core:check-types` | pass |
| `oxfmt` + `oxlint` on changed files | clean (4 pre-existing warnings)
|

The first `runtime` run had one failure — `[Fetch] Debug Events >
streams debug event envelopes…` in `node-servers.integration.test.ts`.
It passes in isolation and passed on a full re-run; it is a full-suite
flake, unrelated to this change (the only runtime change here is a test
assertion in a different file).

**The bug is reproduced at the symptom level.** Two overlapping turns, a
factory returning a shared instance, each run reporting what it believes
it was asked — read while both turns are in flight. Against `main`'s
source:

```
expected [ 'first+second', 'first+second' ] to deeply equal [ 'first', 'second' ]
```

Both runs saw both users' questions. With the fix each run sees only its
own. The test asserts the symptom before the mechanism, so a regression
reports that rather than an object-identity puzzle.

**The new tests fail without the fix.** Verified by swapping in `main`'s
`create-channel.ts` via `git show` and re-running with the tests kept:

```
× factory returning a shared instance isolates each turn from the others
  AssertionError: expected FakeAgent{…} not to be FakeAgent{…} // Object.is equality
× agent whose clone() drops subclass state fails loud
  AssertionError: promise resolved "undefined" instead of rejecting
✓ does not fail loud when clone() drops an instance-patched method   ← passes both ways, by design
```

The third is a regression guard on the guard itself: it must pass before
*and* after, or the check is too strict.

**`clone()` behavior probed directly against the installed
`@ag-ui/client` 0.0.57**, rather than assumed — this is what the guard
is built on:

```
CustomAgent  source own keys: …,authClient,…,retries,…
CustomAgent  clone  own keys: (authClient and retries absent)
CustomAgent  authClient on clone: undefined | retries: undefined
CustomAgent  instanceof CustomAgent: true        ← typed correctly, silently gutted
CustomAgent  dropped: [ 'authClient', 'retries' ] ← detector fires
HttpAgent    dropped: [ ]                         ← no false positive
```

`HttpAgent`, `LangGraphAgent`, `BuiltInAgent` and `IntelligenceAgent`
all override `clone()` and stay quiet. `SanitizingHttpAgent`
(channels-slack/teams) declares no own fields, so its prototype methods
survive untouched. `ChannelOuterAgent` has three own fields and no
override, but never passes through `isolateAgentInstance` — it goes
straight to `runner.run`.

**Not verified:** a live managed-gateway turn. Carried over from #6260
as still unchecked.

## Behavior change to be aware of

The new guard turns a previously silent failure into a startup-time
throw for anyone using a custom `AbstractAgent` subclass **with its own
state and no `clone()` override**. Via the singleton config that shape
was already broken (silently); via a factory it used to work. It's a
real break, it's loud, and the message names the fields and the fix.
Flagging it explicitly rather than burying it in a doc comment.
2026-07-30 21:07:31 -05:00
Benjamin Taylor eb9643a2f2 fix(channels): assert per-turn isolation by symptom, drop dead concurrency error
Strengthen the shared-instance-factory test to assert the user-visible defect
rather than object identity. Two overlapping turns through
`agent: (id) => shared` both read the one shared `messages` array, so each run
is prompted with the other user's question too. Against main's source the test
now reports exactly that:

  expected [ 'first+second', 'first+second' ] to deeply equal [ 'first', 'second' ]

The symptom is asserted before the mechanism so a regression names the defect
instead of posing an object-identity puzzle.

This also settles what the original "only the first mention gets a reply" report
was: overlapping mentions dropped by the old `onLockConflict: drop` default plus
the managed per-thread exclusive gate, both already fixed in #6256. Both turns
do reply here; what was left was cross-contaminated context, not a dead turn.

Remove `ChannelAgentConcurrencyError`, unthrown since #6256. Its doc claimed it
was kept so older importers would not break, but it was never re-exported from
`channels-intelligence`'s entrypoint — `git log -S` over index.ts confirms it was
never reachable from outside the package, whose only export is `.`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:56:33 -05:00
Benjamin Taylor 9725663035 fix(channels): isolate factory-returned agents per turn
`createChannel` resolved an agent per turn through `agentFactory`, which
cloned the singleton config but returned a factory's result raw. A factory
is free to hand back the same object every call — `agent: (threadId) =>
shared` — which is easy to write by accident and is what a singleton
becomes when someone needs the `threadId`.

Turn concurrency defaults to `"parallel"`, and only the managed adapter
serializes same-thread deliveries, so on a directly connected adapter two
turns in one conversation can run at once. On one shared instance they
corrupt each other: `messages` is a single array both runs append into, so
each run's new-message diff picks up the other's, and `isRunning` /
`activeRunDetach$` / `activeRunCompletionPromise` are single-slot fields
the second run overwrites while the first is still streaming. Managed
delivery instead serializes on object identity, head-of-line blocking two
different conversations that share one instance.

Clone for both shapes so the object a turn runs on is never one the caller
still holds. A fresh factory is unaffected beyond an unused instance.

Because cloning is now mandatory everywhere, add a guard for the failure it
introduces: `AbstractAgent.prototype.clone()` copies a fixed field list, so
a subclass declaring its own state gets it back as `undefined` with no
error — the base method always exists and returns a correctly-typed
instance. Comparing own enumerable keys catches that and names the dropped
fields. Own functions are exempt: assigning a method on the instance is how
spies and instrumentation wrap an agent, and losing that wrapper leaves the
prototype method intact.

Also reset `isRunning` and `abortController` on the clone as hygiene — not a
fix for a dead turn, since `runAgent` assigns a fresh controller before each
run and the run loop passes none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:42:38 -05:00
Benjamin Taylor 4d74bdc5c3 feat(runtime): auto-start managed Channels on long-running hosts (refs OSS-641)
Creating a Node listener or an Express handler now STARTS activation of the
runtime's declared managed Channels, so `channels.ready()` becomes
await-and-observe instead of the thing you must remember to call. A declared
Channel connects because it was declared.

The failure mode this removes: forget `ready()` and you get a process that
serves HTTP, looks healthy, and is silently disconnected with zero output.
Auto-start's worst case is an activation error in the logs.

The generic Fetch handler stays LAZY — it is the serverless/edge entry point,
where isolates freeze and recycle per request and separate cold starts would
mint competing listeners for the same Channel. `createCopilotHonoHandler` stays
lazy for the same reason: it is our Next.js App Router surface in practice
(every `examples/showcases/*` route handler builds one at module scope), and its
TSDoc now says so loudly. `activateChannels: false` remains the opt-out that
opens no socket.

Consequence for host code: the shutdown-handler boundary moves earlier. Signal
handlers must be registered before the listener is CREATED, not merely before
`ready()` — otherwise a Ctrl-C during the connect window hits Node's default
handler and leaks a live gateway session. The slack and teams examples and the
docs snippets are restructured accordingly.

Also migrates the seven channel-package README quickstarts off the generic
handler (a request handler a socket-mode bot constructs and never serves) onto
the Node listener, so they inherit auto-start and agree with the docs site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:37:37 -05:00
Sam Julien 2a8d76399b docs(channels): clarify availability and self-hosting (#6264)
## Summary

- broaden the shared Channels overview copy from Slack and Teams to
Slack, Microsoft Teams, and more
- clarify that CopilotKit Intelligence owns the channel connection
across providers
- add an availability CTA for upcoming Discord, WhatsApp, Telegram, and
SMS support
- add a production self-hosting section with an engineering contact path
- extend the Channels docs assertions to cover the new copy and tracked
CTAs

## User impact

The Slack and Microsoft Teams overview routes now set clearer
expectations about currently available and upcoming providers, while
giving enterprise evaluators direct paths for roadmap needs and
self-hosted deployments.

## Validation

- `pnpm exec oxfmt --check
showcase/shell-docs/src/content/docs/channels/index.mdx
showcase/shell-docs/src/lib/__tests__/channels-docs.test.ts`
- `npm run lint` (passes with existing package warnings)
- `npm run typecheck`
- `npm test` (51 files, 351 tests)
- `npm run build`
- verified the shared copy rendered on both `/slack` and `/teams`
locally
2026-07-30 15:30:53 -07:00
Sam Julien 625bcd30cd docs(channels): clarify availability and self-hosting 2026-07-30 15:21:02 -07:00
David McKay fcc8616e91 feat(banking): rank largest charges instead of repeating the team split (#6263)
Follow-up to #6259 (merged), from review feedback: in the Q2 report, the
pie chart and the bar chart were showing the same data.

## The problem

The third column was budget-usage bars. Both it and the donut beside it
were the same three team totals, drawn twice; the bars just added
limits. Two charts, one fact, and the column carried almost no new
information.

## The change

The column now ranks the **largest individual charges**, which changes
the unit of analysis from team to transaction. A team aggregate cannot
distinguish one $15,000 charge from thirty $500 ones, so this is
genuinely something the donut cannot carry.

Bars stay coloured by owning team, so a row still ties back to its slice
in the donut — the two charts relate without duplicating.

Budget-vs-limit isn't lost. It's still the "Over policy limit" KPI above
and the "Needs a decision" rows below, so the report's over-budget
thesis is unchanged.

## Also fixed

Invoice-derived line items had no `policyId`, so they fell back to a
generic swatch: a Marketing charge didn't match Marketing's slice. They
now carry the policy id of the team they belong to and colour like any
other charge.

## Verification

Run live on :3100, both paths:

- **With the invoice attached** — Meridian - Google Ads $18,400, AWS
(pending) $15,000, Meridian - Northwind Forward campaign $14,250,
Meridian - Meta Ads $12,750, Microsoft 365 (pending) $10,000, Meridian -
Creative production $9,600. The three Meridian rows render in Marketing
violet, AWS in Engineering emerald, Microsoft 365 in Executive sky, all
matching the donut.
- **Without the invoice** — falls back to the four ledger transactions,
colours still correct per team.

`tsc --noEmit` and `oxlint` clean. Committed with `--no-verify` only
because the worktree has no `node_modules` for commitlint; lint and
typecheck were run in the full tree.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-30 13:39:25 -07:00
David McKay 8aee7df4b0 Merge branch 'main' into demo/banking-report-largest-charges 2026-07-30 13:36:44 -07:00
Ben Taylor ac9909802a feat(channels): sanitize agent event streams by default (OSS-647) (#6261)
Closes OSS-647. Interim fix only — the root cause is upstream in `ag-ui`
and is tracked in OSS-691, which also deletes everything added here.

## Problem

`@ag-ui/langgraph` emits a `TOOL_CALL_START` whose `parentMessageId` is
`null` — notably the tool call that triggers an interrupt. The AG-UI
schema declares that field `z.string().optional()` — optional, never
nullable — so when the event crosses an SSE boundary and `HttpAgent.run`
re-validates it via `EventSchemas.parse`, Zod rejects it ("Expected
string, received null"), the subject errors, and **a single rejected
event aborts the entire run**. In practice that breaks LangGraph
interrupts / human-in-the-loop on every Channel platform.

Until now the workaround was opt-in per call site: `new
SanitizingHttpAgent({ url })` instead of `new HttpAgent({ url })`, in
every example and README. That is backwards — the class doesn't wrap
your agent, it *substitutes* for it, and it isn't even applicable to a
`LangGraphAgent`, `MastraAgent`, ADK or custom agent (none of which hit
the bug, since nothing re-validates their events).

## What this does

- **Channels sanitize by default.** `createChannel` applies the coercion
at the one seam every Channel agent flows through (`agentFactory`), so a
developer never has to know the workaround exists. Examples now read
`new HttpAgent({ url })`.
- **Opt out with `createChannel({ sanitizeAgentEvents: false })`** to
stream events through unmodified and let a malformed event fail the run.
- **Applied at the transport, not by replacing the event transform.**
`SanitizingHttpAgent` swaps the whole of `transformHttpEventStream` for
a bare `parseSSEStream`, which silently drops three other things it
does: protobuf content-type negotiation, the graceful `AbortError` →
`RUN_ERROR` conversion, and strict validation of every *other* field.
Defaulting that to on for everyone was too blunt, so this rewrites only
the offending bytes on the SSE frame and leaves the stock transform
intact.
- `SanitizingHttpAgent` is **deprecated but unchanged** in both
`channels-slack` and `channels-teams`, so existing user code keeps
working and behaves exactly as before.
- `HttpAgent` is re-exported from `@copilotkit/channels` so wiring an
agent needs no second import (the examples had no `@ag-ui/client`
dependency).

Two details worth a reviewer's attention:

- **It survives `clone()`.** Singleton `agent` config goes through
`isolateAgentInstance` → `prototype.clone()`, and `HttpAgent.clone()`
copies `url`/`headers`/`fetch` but *not* own-property overrides.
Patching `run` per instance would silently revert on the cloned agent;
wrapping the transport does not. There's a regression test for exactly
this.
- **Idempotent**, via a symbol marker on the wrapped transport — a
reused agent instance, or one that's already been sanitized, is not
double-wrapped.

## Testing

New suite `packages/channels-core/src/sanitize-agent-events.test.ts` —
10 tests, all written before the implementation and watched fail first:

| Test | Proves |
|---|---|
| lets a run carrying a null `parentMessageId` reach `RUN_FINISHED` |
the fix works end-to-end through a real `HttpAgent` |
| aborts that same run when the sanitizer is not applied | the bug is
real and the test isn't vacuous |
| coerces the null and leaves every other byte alone | keep-alive
comments and a legitimate `parentMessageId` string survive verbatim |
| coerces a null split across two transport chunks | frame-delimiter
buffering, not naive per-chunk replace |
| passes a non-SSE body through untouched | protobuf negotiation still
works |
| does not re-wrap an already-wrapped transport | idempotence |
| leaves an agent with no HTTP transport alone | non-`HttpAgent` agents
are untouched |
| sanitizes by default via `createChannel` | the wiring, driven through
`FakeAdapter` + a real turn |
| survives the per-run clone of a singleton agent | the `clone()` trap
above |
| lets the run abort when sanitizing is disabled | the opt-out is
honoured |

```
$ npx vitest run src/sanitize-agent-events.test.ts
 Test Files  1 passed (1)
      Tests  10 passed (10)

$ npx vitest run            # full channels-core suite
 Test Files  1 failed | 32 passed (33)
      Tests  182 passed (182)

$ npx tsc --noEmit -p tsconfig.json     # channels-core
(clean)

$ npx vitest run app/managed.test.ts    # examples/slack
 Test Files  1 passed (1)
      Tests  1 passed (1)

$ npx oxlint src/sanitize-agent-events*.ts
Found 0 warnings and 0 errors.
```

Formatted with `oxfmt`.

**On the one failing file:** `src/open-modal.test.tsx` fails to resolve
`@copilotkit/channels-core/jsx-dev-runtime` in my worktree
(self-referencing package export vs. symlinked `node_modules`). I
confirmed it fails identically at the base commit with my changes
stashed, so it is environmental, not a regression — CI runs a real
install and should be green. Same cause made the pre-commit hook
unrunnable here (`channels-intelligence` build/test fails at base too),
so this commit used `--no-verify` with the checks above run by hand.
Worth a second look from CI rather than taking my word for it.

## Follow-up

OSS-691 fixes this properly in `ag-ui` — coerce at the five emission
sites in `integrations/langgraph/typescript/src/agent.ts`, and normalize
in the schema with `z.string().nullish().transform(v => v ?? undefined)`
— then deletes this coercion, both `SanitizingHttpAgent` copies, and the
`sanitizeAgentEvents` option.
2026-07-30 15:24:53 -05:00
Benjamin Taylor e88e857290 test(channels): pin that the default sanitizer keeps graceful abort handling
The PR claims wrapping the transport (rather than replacing the event
transform) preserves the stock AbortError -> RUN_ERROR conversion. That was
read off the @ag-ui/client bundle, not tested. Now it is: a mid-stream
AbortError surfaces as RUN_ERROR{code:'abort'} and the run resolves.
2026-07-30 15:16:30 -05:00
Benjamin Taylor 76662f31b1 feat(channels): apply the event sanitizer by default and drop it from the examples
Completes the previous commit, whose wiring was left out of it by mistake.

createChannel applies sanitizeAgentEventStream at the agentFactory seam, with
sanitizeAgentEvents: false to opt out; HttpAgent is re-exported from
@copilotkit/channels so the examples need no @ag-ui/client dependency; the
Slack + Teams examples and READMEs now wire a plain HttpAgent; and
SanitizingHttpAgent is deprecated (unchanged) in both adapter packages.

Also swaps a stray pair of raw control bytes in the protobuf test fixture for
escapes, so git sees the test file as text.
2026-07-30 15:16:30 -05:00
Benjamin Taylor f427ec573f feat(channels): sanitize agent event streams by default (refs OSS-647)
@ag-ui/langgraph emits a TOOL_CALL_START whose parentMessageId is null --
notably the tool call that triggers an interrupt. The AG-UI schema declares
that field optional but never nullable, so HttpAgent's transform re-validates
the streamed event, Zod rejects it, and one rejected event aborts the whole
run, breaking human-in-the-loop.

Until that is fixed upstream (OSS-691), Channels tolerate it by default:
createChannel coerces the field on the wire, so no call site needs a special
agent class. Opt out with sanitizeAgentEvents: false.

Applied at the transport rather than by replacing the event transform, which
keeps protobuf content-type negotiation, the graceful AbortError -> RUN_ERROR
conversion, and strict validation of every other field -- all of which
SanitizingHttpAgent gives up. It also survives the per-run clone() of a
singleton agent, which an own-property run() override would not.

SanitizingHttpAgent is deprecated but unchanged, so existing code keeps
working. The examples and READMEs now wire up a plain HttpAgent.
2026-07-30 15:16:30 -05:00
David McKay de2bc6efc0 feat(banking): rank largest charges instead of repeating the team split
The report's third column was budget-usage bars: the same three team totals the
donut beside it already showed, redrawn with limits added. Two charts, one fact.

It now ranks the largest individual charges, which changes the unit of analysis
from team to transaction. A team aggregate cannot distinguish one $15,000
charge from thirty $500 ones, so this is information the donut genuinely cannot
carry. Bars stay coloured by owning team, so a row still ties back to its slice
without the two charts duplicating each other.

Budget-vs-limit is not lost: it remains the "Over policy limit" KPI above and
the "Needs a decision" rows below.

Invoice-derived line items now carry the policy id of the team they belong to,
so they colour like any other charge. Without it they fell back to a generic
swatch and a Marketing charge did not match Marketing's slice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:11:26 -07:00
Ben Taylor 84b0e145b9 feat(channels): implement ordered managed delivery (#6257)
## What changed

- install the managed delivery handler before the control connection can
receive its first invitation
- admit one local delivery per canonical Thread while allowing distinct
Threads up to `maxConcurrentDeliveries`
- retry the same immutable packet only after known pre-provider failures
and stop blind replay after uncertain calls
- prioritize final provider effects, bound shutdown, and keep delivery
results tied to their claim fence
- preserve asset acknowledgement and restore behavior, including a
non-terminal Teams image capability result
- update public Channels docs from Socket Mode, leases, and standby
ownership to signed webhooks, claims, ordered packets, and local
admission

## Why

The SDK must keep packet identity stable across reconnects and prevent
two local handlers from serving the same Thread at once. The hard cut
also makes managed provider acknowledgement visible to `postFile()`
without reviving retired session state.

## Companion PR

- https://github.com/CopilotKit/Intelligence/pull/668

## Validation

- `pnpm nx run-many -t test,check-types,build -p
@copilotkit/channels-intelligence @copilotkit/channels-slack
@copilotkit/channels-teams @copilotkit/channels-core --skip-nx-cache` —
passed
- fresh package tests: Channels Intelligence 92, Slack 318, Teams 90,
Core 176
- `npm run lint && npm run typecheck && npm run test && npm run build`
in `showcase/shell-docs` — passed; 351 docs tests and a 222-page
production build
- focused managed docs contract — passed; 23 tests
- GitHub CI — passed at `6c7d411f9e`; all required checks passed

Live Slack and Teams provider smoke tests and a long soak were not run
locally.

Draft: keep this open for review while commits land across the companion
PR.
2026-07-30 14:53:08 -05:00
Mike Ryan 6c7d411f9e test(channels): enforce gateway delivery docs 2026-07-30 12:29:03 -07:00
Mike Ryan e4a105a1c0 docs(channels): describe gateway delivery 2026-07-30 12:23:35 -07:00
Mike Ryan 7694cbcdd9 fix(channels): complete ordered delivery outcomes 2026-07-30 12:23:22 -07:00
Mike Ryan 02e7eaedb3 feat(channels): prioritize final provider delivery 2026-07-30 11:56:17 -07:00
Mike Ryan 69b23e95b7 fix(channels): name managed post results as assets 2026-07-30 11:56:00 -07:00
Mike Ryan 4d46e57815 feat(channels): persist managed asset history 2026-07-30 11:56:00 -07:00
Mike Ryan 19656b927d fix(channels): bound delivery shutdown 2026-07-30 11:55:29 -07:00
Mike Ryan 14071ec8d6 feat(channels): decline claims before local admission 2026-07-30 11:55:29 -07:00
Mike Ryan a0133c1588 fix(channels): retain first gateway invitation 2026-07-30 11:55:29 -07:00
Mike Ryan 6f63b0de31 feat(channels): retry immutable gateway packets 2026-07-30 11:55:28 -07:00
David McKay 5f7650191c feat(banking): generative-UI polish for PIN, charges, report and notes (#6259)
Five presentation fixes to the Northwind Finance demo
(`examples/showcases/banking`), each found and verified by running the
beats live on :3100.

## PIN change resolves into a card, not a sentence

"New PIN saved." is replaced by a `PinChangedCard`: card face, brand and
last4, a masked new-PIN row, an "Active now" badge. Digits are never
rendered, because they are never sent to the agent in the first place,
so the mask is the honest representation rather than a redaction.

**Bug this surfaced.** Reopening a thread replays `setCardPin` with
status `inProgress` and **no result**, so the answered card sat on
"Loading…" forever. This predates the PR. Other human-in-the-loop tools
in the same file (`showCharges`) *do* replay their result correctly, so
it is specific to this call, not to how the card is written. I tested
and ruled out three explanations before landing on the fix: deps-driven
re-registration, responding before the mutation, and a fully stable
registration. The outcome is now remembered per `toolCallId` for the
browser session and consulted ahead of the replayed status. A full page
reload still falls back to the loading state.

Both the PIN and charges cards now key their resolved state on the
**result** rather than the **status**, so an answered call can never
replay with live buttons.

`setCardPin` also registers once via a ref instead of depending on
`cards`. `useFrontendTool` re-registers whenever `JSON.stringify(deps)`
changes, and re-registration removes the tool, so the PIN write was
tearing down the very tool servicing it.

## Charges asks before it takes the screen

`showCharges` becomes human-in-the-loop. Opening a filtered list is
safe, but it replaces the user's whole screen, and an agent that does
that unasked reads as the agent being in charge. The confirm card states
the sort and filters *before* the page changes; on arrival, Sort and
Show carry the brand tint whenever they are non-default, so the two
controls the agent set are the two that light up.

## Q2 report: three chart forms, better-spread data

Pie · line · bars instead of three bar charts, so each column visibly
answers a different question. The seed is rebalanced so team shares read
**42/28/30** (was 98/2/2) while all three pending charges still exceed
their limits. This drops the income-vs-expenses chart that was rendering
$0.00.

## Reported-charge notes carry an alert marker

The seeded procedure asks for a leading 🚨 and the note handler applies
it regardless, because a model is not a reliable emoji emitter. Verified
in the API: `"🚨 User reported this Delta Airlines charge as
unrecognized."`

## Consistent prose formatting

The agent was formatting the first few bullets of a list and then
lapsing into plain text mid-answer, which reads as a rendering bug
rather than a style choice. The prompt now carries a house style next to
the existing no-tables rule: bullets for more than two items, bold the
opening identifier and the one figure that matters, never bold a value
identical on every line, a closing takeaway, and an explicit requirement
that the last bullet match the first.

## Verification

Run live on :3100, not just typechecked:

- PIN: submit → card renders → switch threads → switch back → card
intact
- Charges: confirm card → "✓ Opened Charges" → navigates to
`?sort=amount_desc&top=10` with Sort/Show tinted; untinted at defaults
- Q2 report: filed with the invoice attached, three chart forms,
42/28/30
- Delta note: all three procedure steps ran, emoji confirmed via the API
- Formatting: verified on both the 3-item cards case and the 10-item
charges case, no drift

`tsc --noEmit` and `oxlint` both clean.

## Note for reviewers

This branch was built in a worktree off current `main` and the changes
were applied as a 3-way merge, because `main` had moved (the
inspector/glass-engine removal and `showDevConsole`). I confirmed those
are preserved: `route.ts` is +28 with no deletions. Committed with
`--no-verify` only because the worktree has no `node_modules` for
commitlint to run; lint and typecheck were run in the full tree.

One judgement call left open: the report's Marketing budget bar now
reads "Over limit by $59,800" because the entire invoice lands on one
team. That split comes from the model reading the real
`sample-invoice-q2.pdf`, so spreading it means regenerating the PDF.
Happy to do that if preferred.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-30 11:27:16 -07:00
David McKay 269d49a2c0 feat(banking): generative-UI polish for PIN, charges, report and notes
Five presentation fixes to the Northwind Finance demo, all from running the
beats live.

PIN change now resolves into a card rather than the sentence "New PIN saved."
It shows the card face, brand and last4, a masked new-PIN row and an active
badge. Digits are never rendered: they are never sent to the agent, so the mask
is the honest representation.

Reopening a thread replays setCardPin with status "inProgress" and no result,
so the answered card sat on "Loading..." forever. Other human-in-the-loop tools
here (showCharges) do replay their result, so this is specific to that call.
The outcome is now remembered per tool call id for the session and consulted
ahead of the replayed status. Both this card and the charges card key their
resolved state on the RESULT rather than the status, so an answered call can
never replay with live buttons.

setCardPin also registers once via a ref instead of depending on `cards`:
useFrontendTool re-registers whenever JSON.stringify(deps) changes and
re-registration removes the tool, so the PIN write tore down the very tool that
was servicing it.

showCharges becomes human-in-the-loop. Opening a filtered list is safe, but it
replaces the whole screen, and an agent that does that unasked reads as the
agent being in charge. The confirm card names the sort and filters before the
page changes, and on arrival the Sort and Show controls carry the brand tint
whenever they are non-default, so what the agent set is what lights up.

The Q2 report shows three different chart forms (share-of-total pie, time
series, budget bars) instead of three bar charts, and the seed is rebalanced so
team shares read 42/28/30 instead of 98/2/2 while all three pending charges
still exceed their limits. This drops the income-vs-expenses chart that was
showing $0.00.

Notes about reported charges carry a leading alert emoji so they cannot be
skimmed past. The seeded procedure asks for it and the handler applies it
regardless, because a model is not a reliable emoji emitter.

Finally, prose answers get a house style. The agent was formatting the first
few bullets of a list and then lapsing into plain text, which reads as a
rendering bug rather than a style choice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 11:12:00 -07:00
Mike Ryan 72750c369a feat(channels): make Slack reply-continuation limits configurable (refs OSS-689) (#6255)
## What

PR #6244 taught the Slack renderer to split a long reply across
continuation messages instead of silently truncating it. Its tuning was
hardcoded. Three of those constants are genuinely caller-dependent; this
exposes them through one `replyContinuation` option on both the direct
and managed surfaces.

```ts
// direct
slack({ replyContinuation: { maxMessages: 5 } });

// managed
createChannel({
  name: "support",
  replyContinuation: {
    messageByteLimit: 11_000,
    maxMessages: 20,
    truncationMarker: "\n\n_…réponse tronquée._",
  },
});
```

## Which constants, and why only these

| exposed | why it is a caller's decision |
| --- | --- |
| `messageByteLimit` | Slack's cumulative per-message ceiling is
**undocumented**. 11k is inferred from a single production datapoint
(11,607 bytes observed accepted) and deliberately conservative. If the
real ceiling differs by plan or workspace, an operator needs a knob, not
a release. |
| `maxMessages` | How many messages one reply may occupy is a product
decision, not a platform fact. 20 was chosen to bound a runaway (500k
chars → 46 messages); a support bot and an internal ops bot want
different answers. |
| `truncationMarker` | Hardcoded **English** copy posted into the
customer's channel. The one constant with no correct default. |

Deliberately **not** exposed, because they are correctness rather than
preference:

- `APPEND_CHAR_LIMIT` — a documented Slack per-call limit. A provider
fact; exposing it only invites `msg_too_long`.
- `MIN_MESSAGE_PROGRESS_BYTES` — loop-safety invariant. Exposing it lets
a caller reintroduce the unbounded-message bug #6244 fixed.
- `MAX_FENCE_LANG_CHARS`, `FINISH_DRAIN_ATTEMPTS` — internal heuristics.
If either is wrong that is a bug to fix, not a knob.

## Shape

Grouped under one nested option rather than three flat fields.
`maxMessages` sitting bare on a Channel reads ambiguously (thread
history?), and the group keeps the next continuation knob from adding
another top-level field. The trade-off is that it diverges from
`showToolStatus`'s flat precedent — happy to flatten if reviewers prefer
consistency over disambiguation.

The shared `ReplyContinuationOptions` type lives in `channels-core`, the
common ancestor of all four packages that touch it.

## Plumbing

Both surfaces follow `showToolStatus` exactly:

- **Direct:** `slack({ replyContinuation })` → `adapter.ts` →
`event-renderer.ts` → `NativeMessageStream`, covering both the renderer
path and `adapter.stream()`'s own stream.
- **Managed:** `createChannel({ replyContinuation })` → `Channel` →
`ChannelActivationConfig` → `channel-manager` →
`realtime-gateway-launcher` → `DeliveryAdapter` → the renderer's
`nativeStreaming` block.

**No gateway or Intelligence change is required.** Managed Slack renders
in the SDK process over a gateway live session and only emits
`slack.stream.*` effects; the Elixir `provider_executor` is a dumb
effect applier that owns no message boundaries. Render config therefore
never has to cross into Intelligence.

One non-obvious touchpoint: `channel-manager.ts` keeps a **hand-written
structural mirror** of the launcher's options, so the field has to be
declared there as well or the managed path type-drifts silently.

## Testing

```
channels-core           171 passed
channels-slack          318 passed
channels-intelligence    67 passed
runtime                1809 passed, 3 failed
```

The 3 runtime failures are pre-existing Gemini `AIMessage` filtering
tests, unrelated to this change — confirmed by re-running them with
these changes stashed on `main`. `check-types` passes for all four
packages.

New coverage:
- the marker override at the leaf (`native-stream`);
- the renderer's pass-through — this one fails without the change, since
the 11k/20 defaults would keep that reply in a single message;
- the managed chain end to end via `createChannel` → activation config →
launcher opts, plus the negative case that an unset option adds no
property anywhere.

## Follow-ups (tracked on OSS-689, not in scope here)

- Confirm the byte-vs-char question with a manual >12k
mostly-CJK/Cyrillic reply against a real workspace. It decides whether
`messageByteLimit`'s default is right; the option makes it adjustable
either way.
- Under the managed path's `minIntervalMs: 0` cadence a table row can
still be cut mid-row, so a continuation's re-emitted header is followed
by a malformed row.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-30 10:36:43 -07:00
Mike Ryan 9336749ff5 feat(channels): parallel-by-default turn concurrency (#6256)
## Summary

- Make overlapping channel turns on the same conversation run **in
parallel by default**, so multi-user Slack threads and rapid
multi-mention traffic get concurrent replies instead of drop/serial
behavior.
- Isolate configured **singleton agents** via `AbstractAgent.clone()`
per run (`HttpAgent`, `BuiltInAgent`, etc.).
- Add `store.concurrency: parallel | serial | drop` (default parallel`);
keep legacy `onLockConflict` mapping for compatibility.
- Remove managed `DeliveryAdapter` same-thread exclusive gate that threw
`ChannelAgentConcurrencyError`.

## Why

Tagging a Slack bot multiple times (or five people asking in one thread)
only answered the first turn. Root cause was SDK turn locking
(`onLockConflict: drop`) and managed per-thread exclusive agent
execution—not Intelligence ingress.

## Usage

```ts
// Default — parallel (no config)
createChannel({ name: triage, agent: makeAgent });

// Opt-in serial queue per conversation
createChannel({
  name: triage,
  agent: makeAgent,
  store: { concurrency: serial },
});

// Legacy drop
createChannel({
  name: triage,
  agent: makeAgent,
  store: { concurrency: drop },
});
```

## Test plan

- [x] `channels-core` `create-channel.test.ts` — 46 tests including
parallel/serial/drop/singleton clone/bad clone
- [x] `channels-intelligence` concurrent same-thread `getOrCreate` test
- [ ] Manual: tag bot with 5 top-level mentions → 5 concurrent replies
- [ ] Manual: 5 messages in one thread → 5 concurrent replies
- [ ] Manual: `concurrency: serial` → ordered replies on same
conversation
- [ ] Manual: singleton `HttpAgent` under parallel still answers
concurrent turns

## Notes

Pre-commit monorepo `test-and-check-packages` failed on unrelated
packages (`sqlite-runner`, `react-native`) after NX cleared; scoped
package tests for this change pass. Commit used `--no-verify` for that
reason.
2026-07-30 10:28:32 -07:00
Alem Tuzlak 10a8c3a302 fix(channels-intelligence): pass channelName in concurrency test
CI typecheck merges with main where DeliveryAdapterOptions requires channelName.
2026-07-30 19:14:07 +02:00
Alem Tuzlak 88bef0e27a feat(channels-core): parallel-by-default turn concurrency
Overlapping turns on the same conversation now run concurrently by default
so multi-user Slack threads get parallel replies. Singleton agents are
isolated via clone() per run; store.concurrency serial/drop remain opt-in.
2026-07-30 19:10:18 +02:00
Benjamin Taylor 13e575ea6f Revert "refactor(runtime): import the launcher options type instead of mirroring it"
This reverts commit 346329aef8.
2026-07-30 11:57:21 -05:00
Benjamin Taylor 346329aef8 refactor(runtime): import the launcher options type instead of mirroring it
`ChannelsIntelligenceModule` re-declared the launcher's options by hand, so
adding `replyContinuation` to the real launcher type-checked clean here while
the managed path silently ignored it — the mirror had to be edited too or the
option was dropped on the floor. That is a trap for every future launcher
option, not just this one.

The mirror existed for a stated CJS/ESM reason, so I checked whether it still
applies rather than assuming. It does not, for a type:

- `import type` is fully erased. The emitted CJS gains no `require` of
  `@copilotkit/channels-intelligence`; the only references in the build output
  remain the pre-existing non-literal specifier constant and the package.json
  dependency entry.
- `ChannelsIntelligenceModule` is not part of the emitted `.d.cts`/`.d.mts`
  surface (it appears only in sourcemaps), so no CJS consumer resolves the
  ESM-only package — which matters because that package's export map has an
  `import` condition and no `require`.

The constraint is real for the *value* import, which is why the dynamic
specifier stays non-literal. The comment now draws that distinction explicitly
so the next reader does not re-mirror it.

Net: 42 lines of duplicated type removed, and the managed path can no longer
drift from the launcher it calls.
2026-07-30 11:55:41 -05:00