Commit Graph

2394 Commits

Author SHA1 Message Date
Benjamin Taylor 98598bdd69 fix(runtime): idempotent channel teardown + activate-before-cache; optional peer dep for channels-intelligence (OSS-473)
Centralize the stop-vs-settle race class in ChannelManager behind one guarded,
idempotent teardown path instead of per-branch patches:

- ChannelEntry gains a private `handleStopped` flag; new private `stopEntry()`
  sets status="stopped" and stops the handle AT MOST once. Both settle handlers
  and stop() route through it.
- RC5: a rejection arriving AFTER stop() now keeps the entry "stopped" and
  resolves settled (no error/setup_required, no rejectSettled), so a late
  connect failure can't resurrect a stopped channel or reject a later ready().
- RC7: stop() runs `Promise.allSettled` over per-entry stopEntry() calls; the
  handleStopped guard means a handle assigned in the same tick as stop() is
  stopped exactly once even when both stop() and the success handler reach it.
- RC9 (fetch-handler): getOrCreateChannelManager now calls activate() BEFORE
  inserting into the WeakMap, so a synchronous throw (duplicate/missing names)
  caches nothing and every retry re-throws instead of returning an inert
  manager that falsely reports "online".
- RC8: reconcile class + ready() docstrings — activation throws synchronously
  (ChannelConfigError) only on up-front misconfiguration; all other failures
  are recorded as channel status.
- assertUniqueChannelNames checks missing/empty name FIRST so two nameless
  channels get the accurate "missing name" error, not a spurious "undefined"
  duplicate.
- Remove the dead ChannelEntry.promise field (unread residue of the removed
  reconnect path).
- RC4 (packaging): move @copilotkit/channels-intelligence from
  optionalDependencies (auto-installed, force-pulls the pure-ESM package into
  every OSS consumer) to an optional peerDependency, mirroring the other
  optional integrations.
- Test nits: clear the dangling stop()-hang setTimeout; drop the redundant
  not.toBe("reconnecting") assertion.

Call sites of changed symbols:
- stopEntry (new private): channel-manager.ts only — success handler, reject
  handler, and stop(); no external callers.
- ChannelEntry.promise (removed): grep confirms no reads anywhere in the repo
  (the only .promise reads are unrelated test signals).
- getOrCreateChannelManager (reordered, no signature change): single caller at
  fetch-handler.ts createCopilotRuntimeHandler.

TDD: RC5 and RC9 red-green verified against prior code (RC5 reported "error"
not "stopped"; RC9 retry returned an inert healthy manager). RC7 pins the
single-stop guarantee for the new idempotent design.
2026-07-15 07:15:03 -05:00
Benjamin Taylor 3c718faa08 fix(runtime): delegate channel reconnect to Phoenix; resilient stop; redact key in error (OSS-473)
RC1 — remove manager-level re-activation reconnect (delegate to Phoenix). The
ChannelManager's supervised reconnect re-invoked the activation engine on the
SAME already-started Channel, which throws in channel.addAdapter (started=true)
— so it could never succeed on the real launcher. It was also redundant:
Phoenix's Socket auto-reconnects and auto-rejoins, re-sending the join
declaration; the gateway's join/3 re-runs record_heartbeat (re-registers the
listener) and terminate/2 releases the dead socket's leases (verified against
Intelligence #511 sdk_channel.ex). Removed: runReconnect, reconnectLoops,
onChannelClosed, the RECONNECT_BASE_DELAY_MS / RECONNECT_MAX_DELAY_MS /
RECONNECT_MAX_ATTEMPTS constants, the injectable sleep arg + defaultSleep, and
stoppedSignal/resolveStopped. onClose is now a log-only breadcrumb (no state
mutation, no re-activation). Once a channel activates it stays online; a
transient drop is invisible (Phoenix self-heals). ChannelStatus keeps
"reconnecting" in the union marked reserved to avoid churning the public type;
computeOverall no longer assigns it.

RC2 — stop() no longer aborts teardown on a throwing handle.stop(). The real
launcher's stop() rethrows after session.disconnect(), so Promise.all rejected
and skipped the status loop; with stopped already set, a retry no-oped, leaving
the manager permanently un-torn-down. Switched to Promise.allSettled so every
handle attempts teardown and every entry is marked "stopped". Red-green test
added.

RC3 — parseProjectIdFromApiKey no longer echoes the full cpk-… secret in
ChannelConfigError (it is logged and surfaced via ready()'s AggregateError).
Message now includes only a short non-sensitive prefix; test asserts the format
hint is present but the full key is not.

Reconnect unit test rewritten to the new contract (a drop makes no further
engine call, does not throw, manager stays usable/coherent); obsolete
backoff-growth / give-up-to-error / cancel-pending-backoff cases removed.
Integration test step 5 updated: a drop stays online with no extra engine call.

Call sites cleared: grep over packages/runtime/src for runReconnect,
reconnectLoops, RECONNECT_BASE_DELAY_MS, RECONNECT_MAX_DELAY_MS,
RECONNECT_MAX_ATTEMPTS, onChannelClosed, stoppedSignal, resolveStopped,
defaultSleep, and the injectable ChannelManager sleep arg returns no matches;
fetch-handler exposes no sleep/reconnect seam. Nothing external referenced the
removed symbols.
2026-07-15 07:15:03 -05:00
Benjamin Taylor 1f6446caca fix(channels-intelligence): preserve session receiver in onClose passthrough; clarify V1 text-turn comment (OSS-473) 2026-07-15 07:15:03 -05:00
Benjamin Taylor b0cb79f83e fix(runtime): fail-loud on duplicate channel names, non-hanging stop, reconnect ordering (OSS-473)
A1: ChannelManager.activate() now asserts unique channel names before any
engine call (entries keyed by name silently leaked the first session on a
duplicate). Throws ChannelConfigError naming the dup. Reworded the stale
runtime.ts comment that claimed startChannels validates uniqueness — the
managed path activates one Channel per launcher call, so uniqueness is
enforced by ChannelManager.activate().

A4: stop() no longer awaits pending activations (a hung connect that
ready({timeoutMs}) tolerates would hang teardown/SIGTERM forever). It stops
only handles that already exist; a post-settle guard on the initial-activation
path tears down any handle arriving after stop(), mirroring the reconnect
loop's guard. Idempotent.

A3: reconnect success clears the reconnectLoops marker BEFORE re-arming
onClose, so a synchronous onClose re-fire on the fresh handle starts a new loop
instead of leaving the Channel stuck reconnecting with no loop.

B1/B3: doc fixes — onClose seam is present-tense (launcher delegates to
session.onClose); parseProjectIdFromApiKey @throws no longer describes an
unreachable empty-segment case.

Call sites reviewed (behavior holds at each):
- activate() throws on dup: fetch-handler.ts getOrCreateChannelManager (l.187),
  reached from createCopilotRuntimeHandler at handler-creation time → now fails
  loud at startup instead of leaking; channel-manager ready() (l.424) surfaces
  the throw as a rejected promise.
- stop() prompt-resolve: examples/slack/app/managed.ts:174 SIGTERM shutdown
  await listener.channels?.stop() — the exact hang this fixes. Endpoint
  adapters (node/express/hono) only attach .channels; no direct stop callers.

Tests: channel-manager.test.ts + channel-manager-reconnect.test.ts 17 passed
(2 new + 1 new, red→green); channel-activation-config + fetch-handler green;
@copilotkit/runtime:check-types clean.
2026-07-15 07:15:03 -05:00
Benjamin Taylor 7015837530 fix(runtime): declare channels-intelligence optional dep + export channels control types (OSS-473) 2026-07-15 07:15:02 -05:00
Benjamin Taylor 0e9f90cdba test(runtime): lock public handler.channels contract (OSS-473) 2026-07-15 07:15:02 -05:00
Benjamin Taylor 2e33d0cbbb test(runtime): integration proof of handler-owned channel activation (OSS-473) 2026-07-15 07:15:02 -05:00
Benjamin Taylor ea66c9328a feat(runtime): expose handler.channels through node/express/hono endpoints (OSS-473) 2026-07-15 07:15:01 -05:00
Benjamin Taylor b3081637c1 feat(runtime): handler owns managed channel activation and exposes handler.channels (OSS-473) 2026-07-15 07:15:01 -05:00
Benjamin Taylor 2a9b760c3a feat(channels-intelligence): expose onClose on managed session/handle for reconnect (OSS-473) 2026-07-15 07:15:01 -05:00
Benjamin Taylor a41da177de feat(runtime): supervised reconnect with bounded backoff for channels (OSS-473) 2026-07-15 07:15:01 -05:00
Benjamin Taylor 8f5185953f feat(runtime): add ChannelManager activation/readiness/stop lifecycle (OSS-473) 2026-07-15 07:15:01 -05:00
Benjamin Taylor b4f17e84b3 feat(runtime): derive channel activation config from intelligence config (OSS-473) 2026-07-15 07:15:00 -05:00
Benjamin Taylor 49c58bf081 feat(runtime): rename bots option to channels on the Intelligence runtime (OSS-473) 2026-07-15 07:15:00 -05:00
Benjamin Taylor c9f2119de3 refactor(channels-intelligence): make realtime scope org/channel ids optional (OSS-473) 2026-07-15 07:15:00 -05:00
Benjamin Taylor 229f3f1460 refactor(channels): rename Bot/createBot public API to Channel/createChannel across the workspace (OSS-473) 2026-07-15 07:14:59 -05:00
Alem Tuzlak 3adea157a4 test(channels-intelligence): cover getMessages mapping + address review
Addresses review on #5969:
- Add getMessages tests: string content, content-part array (text parts joined,
  non-text contributes ""), and role→isBot/user derivation; plus the no-getHistory
  transport case returning [].
- Log on the unexpected getMessages catch (matches conversationStore's seeding
  path) instead of degrading to [] silently — HttpDeliverySource already swallows
  its own fetch failures, so this outer catch only fires on a real throw.
- Fold the duplicated role boolean (isBot computed once, drives both fields).
2026-07-15 11:44:32 +02:00
Maxim 8efdc49e6e fix(runtime): validate recall limit as positive integer and trim query in parseRecallBody
parseRecallBody under-validated two inputs of the /memories/recall endpoint:

- `limit`: the `typeof limit !== "number"` guard let NaN, Infinity, -5, and
  3.5 through. Infinity/NaN then JSON-serialize to null (silently corrupting
  the forwarded platform request); negatives/fractions are nonsensical.
  Now require a finite positive integer (Number.isInteger && > 0) when
  provided, else 400.
- `query`: the `length === 0` guard missed whitespace-only queries ("   "),
  forwarding a useless query to the platform (reachable from the inspector
  recall box). Now trim and reject when empty, forwarding the trimmed value.

`scope` validation is unchanged; docstring updated to match.

Added behavioral tests asserting 400 for limit = Infinity/NaN/-1/2.5 and for
a whitespace-only query, plus a valid { query, limit } case that passes 200
with the trimmed query forwarded.

Call sites: parseRecallBody is called only by handleRecallMemories (same file).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 17:03:45 -07:00
Maxim c2184f0729 test(core): lock in NUL separator in capabilityKey against tool-identity collision
The reported bug (capabilityKey joining agentId+name with a SPACE) is NOT
present: run-handler.ts already joins with a NUL byte (0x00), matching the
"NUL-separated" docstring. The space appearance was a rendering artifact —
a NUL byte displays as an invisible/space-like gap in editors and grep.

This adds the mandatory covering regression test so a future edit back to a
space separator cannot regress silently. It registers two agent-scoped tools
whose (name, agentId) pairs collide under a space separator but are distinct
under NUL — A {name:"b c", agentId:"a"} and B {name:"c", agentId:"a b"}, both
"a b c" with a space — disables only A, and asserts B stays enabled in
isToolEnabled and buildFrontendTools. Red/green verified: fails against a space
separator, passes against the shipped NUL separator.

capabilityKey callers (setToolEnabled/isToolEnabled/buildFrontendTools) are all
internal and all rely on this collision-free identity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:43:39 -07:00
Maxim 7f7e39b4b7 feat(web-inspector): wire memory-store recall into memory view recall UI (OSS memory B3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:59:59 -07:00
Maxim a8727a4936 feat(web-inspector): recall section, relevance bars, scope badges in CpkMemoryList (OSS memory B3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:58:45 -07:00
Maxim 379c80e1c9 feat(web-inspector): add pure recall relevance helpers + tests (OSS memory B3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:57:14 -07:00
Maxim 7308f34055 feat(web-inspector): rename Learning tab to Memory (OSS memory B3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:55:10 -07:00
Maxim 6c36e0f7bc Merge slot B0t6 into integration 2026-07-14 14:21:23 -07:00
Maxim 742e5c908d Merge slot A3 into integration 2026-07-14 14:21:22 -07:00
Maxim 895e2dd2d8 feat(react-core): enforce A2UI catalog component toggling on both context and render paths
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:19:28 -07:00
Maxim 23a56400bc chore(web-inspector): regenerate Tailwind CSS for Capabilities tab utilities (A3) 2026-07-14 14:17:45 -07:00
Maxim 0b14899dbb feat(web-inspector): mark fired capabilities with an active dot (A3 task 7, optional) 2026-07-14 14:17:14 -07:00
Maxim d785693062 test(web-inspector): cover Capabilities view render + toggles (A3 task 6) 2026-07-14 14:16:01 -07:00
Maxim eb31768a00 feat(core): open project_meta realtime channel and surface project memories
Parse optional projectJoinToken/projectJoinCode from /memories/subscribe.
When present, open a second project_meta:memories:<code> realtime channel
alongside the user channel, feeding the same id-keyed reducer. Both channels
share the same session stamp so the reducer's superseded-context guard drops
stale deltas from both identically (the D4 landmine). Absent project creds ->
user-only, silent degrade, realtimeStatus unaffected.

Remove the user-scope snapshot filter now that project rows stay in sync via
the project channel, and invert the snapshot test to expect project rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:13:50 -07:00
Maxim 557ba14ebd feat(web-inspector): implement Capabilities view + toggles (A3 task 5) 2026-07-14 14:13:32 -07:00
Maxim 1300afd067 feat(web-inspector): dispatch Capabilities view from renderMainContent (A3 task 4) 2026-07-14 14:12:37 -07:00
Maxim ad7c052af2 feat(web-inspector): add Capabilities nav item gated on tools/catalog (A3 task 3) 2026-07-14 14:11:40 -07:00
Maxim 201d4477c0 feat(web-inspector): add capability row model + pure helper with tests (A3 task 2) 2026-07-14 14:10:57 -07:00
Maxim 6ac2a9e032 feat(web-inspector): add Capabilities tab label + MenuKey (A3 task 1) 2026-07-14 14:09:01 -07:00
Maxim 597b9e0725 Merge slot B1b into integration 2026-07-14 13:57:18 -07:00
Maxim a78de81eaa Merge slot B2 into integration 2026-07-14 13:57:18 -07:00
Maxim 9084f29cd5 feat(core): expose catalogComponents + enable/disable via RunHandler delegation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 13:55:43 -07:00
Maxim 227059ad48 feat(core): add catalog-component state + methods to RunHandler
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 13:55:43 -07:00
Maxim 491675037b feat(core): add recall() to the memory store with score projection
Adds recall(query, opts?) to the MemoryStore (POST /memories/recall,
hybrid RAG), a score? field on Memory, the recallResponseToMemory
projector, MEMORIES_RECALL_PATH, and the MEMORY_RECALL_FAILED error
code. The user-scope list filter is intentionally retained (its
removal is relocated to a later slot co-landing with the project
realtime channel).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 13:52:30 -07:00
Maxim d62df4b3d1 feat(runtime): thread project join creds through /memories/subscribe proxy
Extend the memory subscribe passthrough so the response carries the
optional project-scoped realtime credentials { projectJoinToken?,
projectJoinCode? } alongside the existing user { joinToken, joinCode }.

- Widen SubscribeToMemoriesResponse with optional projectJoinToken /
  projectJoinCode; the platform client passes them through verbatim.
- handleSubscribeToMemories forwards both fields only when present and
  omits them when absent (silent-degrade contract; client then opens
  only the user channel). Field names match Intelligence B0a verbatim.
- Tests: handler forwards project creds when present and omits when
  absent (no present-with-undefined keys); client passes them through.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 13:51:47 -07:00
Maxim 3568dc1326 Merge slot B1a into integration 2026-07-14 13:41:37 -07:00
Maxim 70d1c3ae9f Merge slot A2r into integration 2026-07-14 13:41:37 -07:00
Maxim f01bba7378 chore: WIP preserve (round 2, UNVERIFIED — verify+squash pending) 2026-07-14 13:00:02 -07:00
Maxim 5db6ded719 chore: WIP preserve (round 2, UNVERIFIED — verify+squash pending) 2026-07-14 13:00:02 -07:00
Maxim bd21ce8365 chore: WIP preserve (round 2, UNVERIFIED — verify+squash pending) 2026-07-14 13:00:01 -07:00
Alem Tuzlak ecb91c8770 fix(channels-intelligence): teams thread history via getHistory + getMessages
getHistory was Slack-only: it keyed off threadTs and returned [] for any route
without one, so a Teams route ({adapter:'teams', tenantId, conversationId})
never hit /api/channels/history. That starved BOTH history paths on Teams --
agent.messages seeding (conversationStore.getOrCreate) and the read_thread tool
-- so the agent always reported "no earlier messages" / "no image in thread".

- getHistory is now adapter-aware (mirrors conversationKeyFromReplyTarget's
  per-adapter switch): Slack keys off teamId/channel/threadTs; Teams sends
  adapter=teams + tenantId + conversationId, matching app-api's
  teams:{tenantId}:{conversationId} thread_key. The /api/channels/history route
  already accepts this shape. Slack query order is unchanged.
- Add getMessages to the adapter so thread.getMessages() (the read_thread tool)
  reads reconstructed history via the transport and maps it to ThreadMessage[].
  Without it Thread.getMessages() returns [] and thread-reading tools see
  nothing even when history exists.

Adds tests for the Teams getHistory query and the missing-id short-circuit.
2026-07-14 19:35:08 +02:00
Benjamin Taylor 2f72e5d720 fix(core): don't surface a user-initiated Stop as an agent error (#5966)
Follow-up to #5812/#5885. When the user presses Stop, an agent may emit a
terminal RUN_ERROR (often code "abort") as its cancellation signal. The client's
onRunErrorEvent surfaced that unconditionally via emitAgentError →
AGENT_RUN_ERROR_EVENT → the default chat error banner ("This operation was
aborted"), even though a user stop is expected, not a failure.

Suppress the error emission in onRunErrorEvent when the run was user-aborted,
mirroring the local-abort suppression already on the runAgent/connectAgent
paths. Prefer the client's own _runAbortController.signal.aborted (robust) over
the agent-supplied code, with code === "abort" as a secondary signal. Normal
RUN_ERRORs are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:14:29 -05:00
Ben Taylor 855446e1ab fix(runtime): stop finalizeRunEvents emitting events after a terminal (#5812) (#5885)
## Summary

Pressing **Stop** while an assistant message is streaming
(CopilotRuntime + `HttpAgent` proxy) crashed the chat with:

```
Cannot send event type 'TEXT_MESSAGE_END': The run has already errored with 'RUN_ERROR'. No further events can be sent.
```

Root cause: `finalizeRunEvents` appended a trailing `TEXT_MESSAGE_END`
**after** the `RUN_ERROR` that the aborted agent had already emitted.

Fixes #5812.

## Root cause

When the upstream agent (e.g. pydantic-ai's `AGUIAdapter`) is aborted
mid-stream it emits a live `RUN_ERROR` while a text message is still
open — it does **not** close the message first. All runners
(`in-memory`, `intelligence`, `sqlite`) stream `finalizeRunEvents`'
output *after* everything the agent already emitted, so the appended
closer landed past the terminal:

| | outgoing event order |
|---|---|
| **Before** | `… TEXT_MESSAGE_CONTENT → RUN_ERROR → TEXT_MESSAGE_END` ❌
verifier throws |
| **After** | `… TEXT_MESSAGE_CONTENT → RUN_ERROR` ✅ terminal closes the
message client-side |

Per the AG-UI invariant: at most one terminal event per run, and no
sub-events after it. I confirmed against the real `@ag-ui/client`
`verifyEvents` (the verifier the browser runs) that a terminal arriving
with a message still open is valid — the terminal implicitly closes it.

## Fix

`finalizeRunEvents` (in `@copilotkit/shared`) now returns early and
appends **nothing** when the stream already contains a terminal event
(`RUN_FINISHED` or `RUN_ERROR`). The abrupt-end path (no terminal →
close open streams + synthesize a terminal, in the correct order) is
unchanged. No API/signature change; the in-memory, intelligence, and
sqlite runners all inherit the fix.

## Testing

RED→GREEN verified — each new/updated assertion was confirmed to fail
against the pre-fix code:

- **`finalize-events.test.ts`** — terminal-present appends nothing
(parametrized over `RUN_FINISHED` and `RUN_ERROR`) + a named #5812 case.
- **`in-memory-runner.test.ts`** — end-to-end mid-stream-stop
regression: a fake `HttpAgent`-style agent is stopped between
`TEXT_MESSAGE_START` and `TEXT_MESSAGE_END`; asserts no events follow
`RUN_ERROR` **and** that the collected stream passes `verifyEvents`
(before the fix this threw the exact browser error).
- **`intelligence-runner.test.ts`** — corrected a pre-existing assertion
that had encoded the buggy post-terminal `TEXT_MESSAGE_END`.

Green: full `@copilotkit/runtime` suite, `@copilotkit/sqlite-runner`,
`@copilotkit/shared`, `check-types`, `oxlint` (0 errors), and build.

## Reviewer notes

- The behavior change is a single early-return in `finalize-events.ts`;
the `terminalEventMissing` guards simplify away because they're only
reachable when no terminal exists.
- Diff is +204/−55 across 4 files, the bulk of it tests.
2026-07-14 09:03:15 -05:00
Ben Taylor c266142354 fix(react-core): scope streamdown table action controls in packaged CSS (#5775) (#5944)
Fixes #5775.

## Problem

After #5099 scoped the streamdown markdown/table styles under
`[data-copilotkit] [data-streamdown="…"]`, the **table action controls**
(copy / download) are still unstyled for hosts that import
`@copilotkit/react-core/v2/styles.css` but don't also ship streamdown's
raw Tailwind utilities. streamdown renders the controls row, per-button
wrappers, trigger buttons, dropdown popovers and menu items with
unprefixed utilities (`flex`, `items-center`, `justify-end`, `gap-1`,
`cursor-pointer`, `p-1`, …) and **no stable `data-streamdown`
attribute**, so CopilotKit's packaged CSS didn't cover them — the
controls rendered as vertically stacked plain icons instead of a
right-aligned row.

## Fix

Add scoped fallback selectors under `[data-copilotkit]
[data-streamdown="table-wrapper"]`, targeting the controls chrome
**structurally** (since it has no `data-streamdown` hook):

- controls row → `> div:first-child:not(:last-child)` (flex,
right-aligned, gap)
- per-button wrapper → `… > div` (relative, positions the popover)
- trigger buttons → `… > div > button` (matches the code-block
copy/download button styling)
- dropdown popover → `… > div > div`
- popover menu items → `… > div > div > button`

The controls row is `table-wrapper`'s first child **only when controls
are enabled**; `:not(:last-child)` leaves a control-less table (whose
single child is the scroll container, already styled by #5099)
untouched.

## Verification

- **Compiles.** Built `globals.css` through the Tailwind v4 CLI — every
`@apply` resolves (e.g. `bg-background` → `var(--background)`,
`shadow-lg` → the shadow vars, `min-w-[120px]` → `min-width:120px`) and
all five rules emit with correct values.
- **Selectors match the real DOM.** A new DOM test
(`streamdown-table-controls.test.tsx`) renders a real `<Streamdown>`
table and asserts the controls row is `table-wrapper`'s first-non-only
child, carries no `data-streamdown` attribute, and contains the trigger
buttons under `> div > button` — i.e. the scoped selectors target real
elements. This also guards against streamdown markup drift.
- **Selector presence** guarded by `streamdown-styles.test.ts`
(whitespace-robust).
- Full `styles/__tests__` suite green; `oxlint`/`oxfmt` clean.

## Note (out of scope, discovered while fixing)

At runtime in streamdown `1.6.11` the `<table>` element is stamped
`data-streamdown="table-wrapper"` (not `"table"`): `MarkdownTable`
passes `data-streamdown="table-wrapper"` as a prop that leaks through
`...rest` onto the `<table>`, overriding the intended `"table"`. So
#5099's `[data-streamdown="table"]` selector currently matches nothing,
and the table also matches the `table-wrapper` rules. The controls fix
here is unaffected (its child-combinator selectors don't match the
table's `thead`/`tbody` children), but the `[data-streamdown="table"]`
selector is worth a separate follow-up / upstream report.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-13 17:23:33 -05:00