Commit Graph

2394 Commits

Author SHA1 Message Date
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
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
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
github-actions[bot] a85760e06b style: auto-fix formatting 2026-07-30 16:45:15 +00:00
Benjamin Taylor b2fc4c0706 feat(channels): make Slack reply-continuation limits configurable (refs OSS-689)
PR #6244 taught the Slack renderer to split a long reply across continuation
messages, but its tuning was hardcoded. Three of those constants are genuinely
caller-dependent and are now configurable through a single `replyContinuation`
option; the rest stay internal on purpose.

Exposed:
- `messageByteLimit` — Slack's cumulative per-message ceiling is undocumented.
  11k is inferred from one production datapoint and deliberately conservative;
  operators need a knob if the real ceiling differs rather than a release.
- `maxMessages` — how many messages one reply may occupy is a product decision,
  not a platform fact. 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),
`MIN_MESSAGE_PROGRESS_BYTES` (loop-safety invariant — exposing it lets a caller
reintroduce the unbounded-message bug #6244 fixed), `MAX_FENCE_LANG_CHARS`, and
`FINISH_DRAIN_ATTEMPTS`.

Grouped under one nested option rather than three flat fields: `maxMessages` on
a Channel reads ambiguously on its own (thread history?), and the group keeps
the next continuation knob from adding another top-level field.

Both surfaces are wired, following `showToolStatus` exactly:
- direct: `slack({ replyContinuation })` → adapter → event-renderer → stream,
  covering both the renderer path and `adapter.stream()`.
- managed: `createChannel({ replyContinuation })` → `Channel` →
  `ChannelActivationConfig` → channel-manager → launcher → `DeliveryAdapter` →
  the renderer's `nativeStreaming` block.

No gateway or Intelligence change is needed. Managed Slack renders in the SDK
process over a gateway live session and only emits `slack.stream.*` effects, so
render config never has to cross into Intelligence — the Elixir provider
executor is a dumb effect applier that owns no message boundaries.

`channel-manager.ts` carries a hand-written structural mirror of the launcher
signature, so the new field is declared there too or the managed path silently
type-drifts.

Tests: the marker override at the leaf, the renderer's pass-through (fails
without it — the defaults would keep that reply in one message), and the managed
chain end to end via `createChannel` → activation config → launcher opts, plus
the negative case that an unset option adds no properties anywhere.
2026-07-30 11:43:04 -05:00
Tyler Slaton 7113c8f932 fix(channels): use channel name for thread lock (#6254)
## Problem

Managed Channel deliveries can request a canonical thread lock with the
inner agent ID, or `default` when that ID is unset. Intelligence owns
the thread under the declared Channel name, so the lock fails with
`THREAD_AGENT_MISMATCH`.

## Why

The SDK treated the agent object identity as the managed Channel
identity. Those values are independent: `createChannel({ name })`
declares the managed thread owner, while an agent ID is optional and may
differ.

## Fix

Pass the declared Channel name into the managed delivery adapter and use
it for canonical runs. Add a public `thread.runAgent()` regression test
that proves an agent with a different ID still runs under the Channel
name.

Validated with the Channels Intelligence test suite, type check, and
build; the runtime Channel manager tests, type check, and build; repo
lint; and the affected-package pre-commit checks.
2026-07-30 08:59:24 -07:00
Tyler Slaton 0c06dbf908 fix(channels): use channel name for thread lock 2026-07-30 10:50:57 -04:00
github-actions[bot] 051833fff8 style: auto-fix formatting 2026-07-30 14:03:24 +00:00
Benjamin Taylor ab53a77c06 test(channels-slack): close native-stream coverage gaps
Coverage on native-stream.ts was 87.5% stmts / 80.45% branches; now 92.7% /
88.5%. The gaps were concentrated in code this branch added, and two of them
were places I had claimed coverage that did not exist.

Genuinely uncovered logic, now tested:
- `renderContextCloser`'s inline-code and emphasis closers, and the
  `hasFenceCodeContent` mirror added in the previous commit — the closer is
  appended at every boundary and none of its three branches were exercised.
- `tableHeaderToReopen`'s "no longer inside the rows" guard, so a boundary landing
  after a table has ended does not inject a stray header.
- The 2-byte UTF-8 width class (Cyrillic). CJK covered 3-byte and emoji 4-byte;
  the class between them was untested.
- The `callEnd < byteEnd` branch. The test that claimed it never reached it: with
  a 40k budget and 30k of ASCII the whole reply fits, so the first branch won and
  the per-call path was never entered. Retargeted with multi-byte text.
- Rollover `stopStream` failure, non-strict (proceeds) and strict-with-a-failed
  closer (reports the closer, not the consequence).
- Truncation marker failure, non-strict (logged) and strict (reported).
- A legacy tail fallback that itself fails.
- Chunk behaviour around a rollover: delivered to the current message, degraded
  when the continuation cannot be opened, and skipped entirely when a strict text
  failure rejects the shared flush queue — the last of which corrected my
  assumption that `flushChunk` still runs in that case. It does not: `.then()` on
  a rejected promise skips its callback.

Four guards are unreachable through the public API and are now commented as
deliberately defensive rather than left looking like missing tests: the
`roomBytes <= 0` and "not even one code point fits" rollover checks (every
filling branch rolls over in the same iteration), `appendSlice`'s empty-delta
stall guard (both boundary helpers return an index strictly greater than
`curPosted`), and `truncate`'s own re-entry check (`appendPending` short-circuits
first). Kept as loop-safety invariants.

The residue is pre-existing or upstream: `append()`'s legacy forwarding, the
zero-interval `scheduleFlush` path, `flushChunk`'s start-failure degradation, and
`flushTextInline`'s strict rethrow.
2026-07-30 09:01:43 -05:00
Benjamin Taylor 1a5d6678ae Merge branch 'main' into ben1/slack-native-stream-message-rollover
Resolves against three main commits that touched this streamer after the branch
point: `strict` mode (f1c4b6ca79), the always-reach-stopStream drain
(9250f22d88), and the zero-interval flush path (5ddcb14233).

`native-stream.ts` auto-merged textually but not semantically — my rollover,
truncation and finish-drain paths were all added after `strict` existed and did
not honour it, so the merge as-generated would have quietly defeated it:

- `rollOver` swallowed both its closer append and its `stopStream`. Now records
  the closer failure, still reaches `stopStream` (main's guarantee: never leave a
  native stream open), then rethrows under strict with the same earliest-error
  precedence `finish()` uses.
- `truncate` swallowed the marker append; now rethrows under strict, with its
  terminal state settled *before* the append so a throw cannot re-enter it.
- `finish()`'s drain retries awaited `this.queue` unguarded, which under strict
  threw before `stopStream` — reintroducing exactly the bug 9250f22d88 fixed.
  Errors are now captured into `queueError` like the initial drain. The retry
  also resets `this.queue` first: a strict rejection settles it rejected, and
  `.then()` on a rejected promise skips its callback, so the retry would have
  re-raised the settled error instead of running a flush.
- The legacy tail fallback is now gated on `!strict`. Strict disables the legacy
  fallback by design, so an undelivered tail is surfaced as an error instead of
  being routed around it.

Test file conflicted on the fake transport's `appendText`, where main added
`failAppend` and this branch added the cumulative byte cap; kept both.

Two tests added for the strict/rollover interaction, which main's strict tests
predate and so do not cover: a rejected boundary closer surfaces under strict
while the message is still finalized, and an undelivered tail surfaces rather
than engaging the legacy sink.
2026-07-30 08:42:20 -05:00
Alem Tuzlak 552fac131b fix(channels): never soft-complete after postFile effect failure
Rethrow all session.effect failures from postFile; soft-return only
upload/config errors. Prevents claimAndHandle false complete terminals
for join/claim/TypeError protocol failures that the rethrow allowlist
previously missed.
2026-07-30 13:12:30 +02:00
Alem Tuzlak 9250f22d88 fix(channels): stream cleanup, digest parity, postFile fail-loud
CR r5 bucket (a): always stop native Slack streams on failure (thread
finish + NativeMessageStream queue drain), advance append/replace text
only after apply, rethrow permanent postFile gateway errors, exclude
stream.stop from provider-output tracking, classify errors by message,
validate prepared turn fields per kind, and stop unit tests from hitting
live lock cleanup HTTP.
2026-07-30 12:55:03 +02:00
Alem Tuzlak 2f7082817c fix(channels): allow stream.stop after effect path seals
Keep provider stream cleanup sendable after non-terminal failures; only
seal effects for terminal provider statuses; join then leave on reconnect;
leave after invalid prepared join reply; advance stream text only after ack.
2026-07-30 12:37:06 +02:00
Alem Tuzlak d64ca464dd fix(channels): stream stop, provider error propagation, lock prefix
Stop Slack streams in finally; rethrow ChannelProviderDeliveryError from
postFile; treat delivery join failures as permanent; plumb lockKeyPrefix
into channel canonical locks; export resolveChannelActivationEnv and
treat blank env as unset; align deploy URL guidance.
2026-07-30 12:25:55 +02:00
github-actions[bot] fbbee17cd3 style: auto-fix formatting 2026-07-30 10:11:41 +00:00
Alem Tuzlak e2a5dc9219 fix(channels): seal packet path without blocking terminal recovery
Allow a failed/uncertain terminal after effect or complete-terminal push
failures; seal only after a successful terminal apply. Leave Phoenix child
channels on failed join, re-arm delivery handlers on restart, replay
onStateChange health, skip empty Teams stream deltas, and align docs/tests.
2026-07-30 12:09:32 +02:00
Alem Tuzlak baf8778eed fix(channels): correct RealtimeGatewayPushError args in transport test
Typecheck caught a 2-arg constructor call; PushError requires event, code, reason.
2026-07-30 11:42:03 +02:00
github-actions[bot] 28980abc2f style: auto-fix formatting 2026-07-30 09:22:33 +00:00
Alem Tuzlak 155d488bb7 fix(channels): harden delivery protocol and lock cleanup
Close packet path after permanent push/ack failures so a later effect
cannot mint a new effectId on the same seq. Refresh owner generation on
join_token reconnect, add reconnect backoff, require claimed on claim
assert, reject unknown turn kinds, skip empty Slack stream deltas, and
surface missing file-client attachments instead of dropping them.

Always release the product thread lock after a Channel canonical run.
Align connectTimeoutMs docs, projectId validation, ops error guidance,
and test fixtures with the delivery ID contract.

Note: local lefthook skipped (no node_modules in this worktree); CI will
validate. CR findings addressed from PR #6249 review.
2026-07-30 11:20:44 +02:00
Mike Ryan 8f166577ce feat(channels): replace live sessions with realtime boundary 2026-07-29 22:23:30 -07:00
Benjamin Taylor 0f2f020666 fix(channels-slack): make truncation terminal, align table breaks, mirror fence rule
Addresses maxkorp's third pass, including a regression in the maxMessages cap
added in the previous commit.

1. `truncate()` is now terminal and idempotent. It set `curPosted =
   buffer.length` and appended the marker but left `curTs` set and recorded no
   terminal state, so text still arriving grew the buffer, the next flush saw
   undelivered text on an already-full message, and re-entered — one marker per
   flush for the rest of the turn. Reproduced at production defaults on a 400k
   reply: markerCount=36, last message 12,908 bytes against an 11,000 budget.
   Against a transport enforcing a real 12k ceiling, 18 markers land and then 28
   appends are rejected, spending exactly the error the incident was about. This
   was the spam the cap exists to prevent, relocated inside one message.

   A `truncated` flag short-circuits `appendPending` and both `finish()` paths,
   and the marker is now charged to `curMessageBytes` — it was the one append
   path that neither checked nor updated the budget.

2. Table boundaries prefer a row break. Inside a table the space fallback left
   the continuation starting mid-row, so the re-emitted header was followed by a
   malformed row. Fixes single-append boundaries completely; under incremental
   cadence a fill append can still land mid-row before the room runs out, which
   this does not address (see the PR thread).

3. `renderContextCloser`'s fence check now mirrors `hasFenceCodeContent`
   exactly: non-whitespace after the language line, not merely a newline, so
   ```py\n no longer gets a closer. The comment claimed the mirror; now it holds.

Also trimmed the rationale at the boundary-closer catch site. It repeated the
"first append a wrong headroom assumption rejects, two things had to be right"
argument, which the reviewer measured and retracted: under byte counting the
fill append dies first and rollOver is never reached, so the two modes are
sequential rather than compounding, and the trigger window is only a few bytes
wide. The guard stays — an unguarded append that wedges rollover, directly above
a guarded one that doesn't, is worth three lines on the asymmetry alone — but the
codebase should not preserve reasoning that has been disproven.
2026-07-29 20:33:02 -05:00
github-actions[bot] 7d893c8d5c style: auto-fix formatting 2026-07-30 01:07:09 +00:00
Benjamin Taylor 0f7325ff5b fix(channels-slack): guard the boundary closer, clamp fence langs, cap continuations
Addresses maxkorp's second pass. All three reproduced locally first.

1. The boundary closer append was unguarded, and its failure disabled rollover
   entirely (the worst of the set). It threw out of `rollOver` with `curTs` still
   set, so the next flush recomputed a full message, re-entered `rollOver`, and
   failed on the same closer forever. Probe: a transport rejecting closer-only
   deltas delivered 11,000 of 37,410 chars across ONE message with zero
   rollovers. The control confirms the asymmetry — the same input with
   `stopStream` failing instead delivers everything, because that call was
   already guarded.

   This compounds with the unit question: the closer is appended exactly when the
   message has just filled, so it is the FIRST append a wrong headroom
   assumption rejects — and its rejection switched the preventive fix off before
   it could fire. Now guarded with the reasoning already written for
   `stopStream`: degraded markdown on one message beats losing the remainder.

2. Implausibly long fenced-code "languages" are clamped. `detectOpenContext`
   reports everything up to the first newline after ``` as the language, so a
   minified blob became a multi-kilobyte preamble re-injected into every
   continuation: 5,004 chars of duplicated preamble atop six consecutive
   messages, 54% useful, and a final message 1% useful. Past
   MAX_FENCE_LANG_CHARS we re-open with a bare fence, which still preserves code
   formatting where dropping the opener would not. Re-injected preamble drops
   from 5,004 chars to 4.

3. Continuation count is bounded. A 500k-char reply became 46 Slack messages,
   uncapped. Not a regression (the legacy chunker is uncapped too), but this PR
   exists because a runaway became repeated copies in a channel, and silently
   turning one over-long reply into dozens of real messages is that failure in a
   different hat. DEFAULT_MAX_MESSAGES with a visible truncation marker, so the
   cut is stated rather than silent.

Also removed the write-only `finished` field the review spotted.

Deferred, tracked in the PR thread: the emphasis closer's flanking position
(needs a real-workspace check, since Slack is not strictly CommonMark) and
`flushChunk`'s permanent chunk-disable after a transient continuation failure.
2026-07-29 20:04:51 -05:00
github-actions[bot] fe23c585c5 style: auto-fix formatting 2026-07-30 00:40:40 +00:00
Benjamin Taylor d1b289a226 fix(channels-slack): budget rollover in UTF-8 bytes, drain the tail, re-open tables
Addresses maxkorp's review. All three findings reproduced locally before fixing.

1. Budget is now UTF-8 bytes, not chars. The "safe whichever way Slack counts"
   claim covered char-cap vs per-call-cap but not chars vs BYTES, and the
   incident datapoint was English, where the two are 1:1. Under a byte-denominated
   cap a char budget never fires: 漢字 x 10,000 delivered 4,000 of 20,000 chars
   with ZERO rollovers and 33 swallowed msg_too_long — the original bug, intact,
   for every non-Latin-script user. Cyrillic/Greek/Hebrew/Arabic break above ~6k
   chars, CJK above ~4k. UTF-8 byte length is always >= char count, so an 11k-byte
   budget is under a 12k ceiling in either unit. Costs extra messages for
   non-Latin replies if the ceiling turns out to be chars; correct side to err on.

   `spanWithinBudget` walks whole code points, which subsumes the previous
   `avoidSurrogateSplit` guard for every boundary rather than patching each one.

2. `finish()` no longer drops the tail. It enqueues exactly one flush, so a
   continuation `startStream` that throws there had no later flush to retry it:
   probed at 11,000 of 25,000 chars delivered, no fallback, first message
   finalized cleanly, so the turn reported success and the platform never
   replayed it — a fresh silent-truncation path in a file whose purpose is to
   stop silently truncating. Now drains with bounded retries, then hands the
   remainder to the legacy transport seeded with ONLY the undelivered tail (not
   the whole buffer, which is what makes failing over mid-reply duplicate text).
   `onStartFailure` is deliberately not fired: a transient continuation failure
   should not mark the whole workspace legacy.

3. Tables are re-opened across a boundary. `detectOpenContext` models fences,
   inline code, and emphasis but not tables, so continuations started mid-row with
   no delimiter above them and Slack rendered literal pipes — a regression in the
   native-table rendering that motivates this transport, and long generated tables
   are a common way to exceed the cap. `tableHeaderToReopen` re-emits the header
   and delimiter rows.

Also from the review: the fence closer now mirrors `hasFenceCodeContent` so a
boundary just after ```lang no longer emits an empty code block; the
fits-in-one-message guard uses 10,900 rather than 5,000 so it actually pins the
boundary; and the config-only per-call branch has a test.

Deferred: the emphasis closer can land after whitespace and so not right-flank
under CommonMark. Slack's renderer is not strictly CommonMark, so this wants
confirming against a real workspace in the same manual pass as the cap unit.
2026-07-29 19:38:36 -05:00
Mike Ryan d545eba10e fix(channels): preserve live-session run semantics 2026-07-29 17:22:56 -07:00
Benjamin Taylor 6d5fbf3e3f fix(channels-slack): bound rollover and keep boundaries surrogate-safe
Two defects found by adversarially probing the rollover added in the previous
commit. Both were reachable at the default 11k cap.

1. Unbounded message creation. The continuation re-opener is synthetic text
   charged against the new message's budget, so a context whose opener is itself
   larger than the cap filled each fresh message with nothing but its own
   preamble and rolled over again — forever, never advancing `curPosted`.
   `detectOpenContext` reports everything up to the first newline after ``` as
   the fence "language", so an agent emitting a >11k whitespace-free blob
   (minified JSON, base64, a long log line) straight after a fence triggers it.
   Probed at the default cap: `posted` frozen at 11,000 while `startStream` was
   called 41+ times, bounded only by the probe's own budget. In production that
   is an unbounded stream of Slack messages — worse than the truncation it
   replaced. The opener is now dropped when it cannot leave
   MIN_MESSAGE_PROGRESS_CHARS of room, so every message carries text and the
   loop always terminates.

2. Surrogate pairs split across boundaries. Whitespace-free text reaches
   `breakPoint`'s hard-cut fallback routinely (CJK, emoji runs, base64), and
   cutting at an arbitrary UTF-16 offset left a lone high surrogate ending one
   message and an orphaned low surrogate starting the next — a broken glyph per
   boundary. Concatenating the messages still compares equal, which is why the
   existing preservation assertions passed straight through it. All boundaries
   now step back off a pair via `avoidSurrogateSplit`, including the per-call
   12k slices, which had the same latent exposure before rollover ever existed.

Both are pinned by tests that fail against the previous commit: a runaway guard
that turns a rollover loop into a failed test rather than a hung one, and a
per-message surrogate-edge assertion (the join-and-compare check cannot see it).
2026-07-29 19:13:55 -05:00
Benjamin Taylor 23b67bd4a2 fix(channels-slack): split long native-stream replies across messages
Slack enforces a cumulative text cap per streamed message, not just the
documented 12k-per-`markdown_text`-call cap. `NativeMessageStream` only honored
the per-call cap and kept appending to a single message, so once a reply passed
the cumulative ceiling every further `chat.appendStream` was rejected with
`msg_too_long` — permanently, for the rest of the run.

Observed in dev: a ~23k-char reply streamed 11,607 chars into one message and
then failed every subsequent append. The remaining text was silently dropped
(the append error is swallowed by design), the turn never reached finalize, and
the egress operation replayed from its uncheckpointed cursor — posting a fresh
truncated copy per attempt until it hit max_attempts.

`flushText` now rolls over: when a message fills and text remains, the boundary
is frozen on a line (else word) break, any open markdown construct is closed,
the message is finalized, and the reply continues in a new message that
re-opens that construct. Both append loops share one rollover-aware path, and a
continuation `startStream` failure no longer falls back to the legacy transport
— that sink replays the whole buffer, which would duplicate everything already
streamed.

The soft per-message limit is 11k, below the smallest total Slack was observed
to accept, leaving headroom for the closers a boundary appends.

The test transport did not model the cumulative cap at all, which is why this
survived the suite; it does now, and the case that asserted "keeps a long reply
in ONE message" was asserting the bug.
2026-07-29 19:04:14 -05:00
Mike Ryan d5beb8e5de fix(channels): enforce live-session delivery boundaries 2026-07-29 16:42:17 -07:00
Mike Ryan 538277ef4c fix(channels): hide reaction provider references 2026-07-29 16:42:17 -07:00
Mike Ryan c07b8312db fix(channels): close managed provider effect surface 2026-07-29 16:42:16 -07:00
Mike Ryan f1c4b6ca79 fix(channels): keep managed Slack replies atomic 2026-07-29 16:42:04 -07:00
Mike Ryan 74b38ecdfb fix(channels): await delivery thread operations 2026-07-29 16:42:04 -07:00
Mike Ryan f658564f7f fix(channels): declare gateway session protocol 2026-07-29 16:42:04 -07:00
Mike Ryan 624bf3c360 fix(channels): cancel drained canonical runs 2026-07-29 16:42:03 -07:00
Mike Ryan ccf8ac5ea4 fix(channels): preserve managed interaction references 2026-07-29 16:42:03 -07:00
Mike Ryan 66ac03f770 fix(channels): reject managed slash runs and awaitChoice 2026-07-29 16:42:03 -07:00
Mike Ryan 78747d622d fix(channels): settle effects before closing runs 2026-07-29 16:42:03 -07:00
Mike Ryan c67458b9a2 fix(channels): present delivery join code 2026-07-29 16:42:02 -07:00
Mike Ryan 243caa2ed7 fix(channels): preserve one canonical run lifecycle 2026-07-29 16:42:02 -07:00
Mike Ryan e60518ee2c fix(channels): harden live-session transport retries 2026-07-29 16:42:02 -07:00
Mike Ryan cd6fb9e6ce fix(channels): isolate managed render imports 2026-07-29 16:42:02 -07:00
Mike Ryan 5ddcb14233 refactor(channels): use gateway live sessions 2026-07-29 16:42:01 -07:00
Mike Ryan af67df039e feat(channels): define live-session protocol 2026-07-29 16:40:47 -07:00