b7d2fdcc25
Six pre-existing `@copilotkit/channels-intelligence` HTTP-transport robustness items surfaced by the pre-merge CR of #5983 (Linear OSS-497). All confirmed against `main` after #5983 merged; none introduced by it. Each is an independent, focused commit with a test. ## Fixes 1. **Heartbeat starvation mid-turn** — `HttpDeliverySource.runLoop` heartbeated only at the top of each iteration, then blocked on `onDelivery` for up to `turnTimeoutMs` (120s). With a 15s cadence, a turn longer than the cadence sent no heartbeat, so app-api could mark a healthily-working runtime stale mid-turn and withhold new deliveries. Heartbeating now runs on a standalone recurring timer, independent of the claim loop. 2. **`stop()` shutdown latency** — `stop()` set `running=false` then awaited the loop, which only rechecked after its current sleep (≤15s idle) or `onDelivery` (≤120s mid-turn); sleeps were `unref`'d but not interruptible. Added a `stopWait` promise that the poll-sleep and turn-wait both race, so shutdown is prompt. A mid-turn stop leaves the lease for app-api to re-lease and does **not** nack (the turn didn't fail); the turn's eventual settlement is always handled so a post-stop rejection never surfaces as unhandled. *(1 & 2 share the runLoop lifecycle, so they land in one commit.)* 3. **Empty-text `update` silently acked** — the `if (!text) return { ok: true }` guard ran for both `post` and `update`. An empty POST is a legit no-op, but an empty UPDATE (e.g. clearing a message body) that this post-only fallback egress can't express was acked as success. Now returns `{ ok: false, code: "empty_update" }`; empty posts still no-op. 4. **Static `adapter` on egress** — `emit` posted `this.cfg.adapter` (default `"slack"`) alongside a possibly-Teams `replyTarget`. Confirmed app-api's egress route (`sendChannelEgressMessage`) routes on `replyTarget.adapter` + `channelName` and ignores this field, so it's latent — but now derived from the delivery's own reply route to avoid the contradiction. *(Note: the listener heartbeat's `declaredChannels[].adapter` is intentionally left alone — app-api genuinely consumes it for per-adapter health/conflict via the `channel_adapter_configs.provider` join, and declaring the bot's full adapter set is a separate design change, not a robustness cleanup.)* 5. **`projectId` strict-`number` only** — the realtime scope build accepted `projectId` only as a JS `number` while org/channel were `String()`-coerced, so a numeric-string on the untyped wire silently fell back to the transport-default projectId, defeating per-delivery scope authority. Extracted a `coerceWireProjectId` helper (number or numeric-string → positive integer) with fallback. 6. **File/history client ignored injectable `fetch`** — `fetchFile`/`getHistory`/`uploadFile` hardcoded `globalThis.fetch`, so an injected `config.fetch` was honored everywhere except binary/history/upload. The transport's `FetchLike` is text-only and can't satisfy the binary client, so the file/history client got its **own** injectable full `fetch` (`typeof fetch`), resolved via a single helper with a global fallback. Plus a small `docs` commit fixing a doc-comment placement displaced by the item-5 export. ## Testing - `nx run @copilotkit/channels-intelligence:test` — **178 passed** (170 baseline + 8 new). - `nx run @copilotkit/channels-intelligence:check-types` — clean. - lefthook (full package test + typecheck) ran on every commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code)