Repository navigation
fix(think): messenger delivery, prompt truncation and media eviction - #2387
Conversation
…review fixes - keep Chat SDK thread locks alive (agents/chat-sdk lockHeartbeat) and give messenger queue entries a 30 minute TTL - drain queued messages after an interrupted reply is recovered - refresh the typing indicator until first text, tolerate its failure, and never post a blank reply before the apology - detect mentions on skipped burst messages, including unsubscribed threads - strip raw payloads/bytes from persisted metadata; map inline attachment data - keep start metadata when a recovery continuation extends a message - truncate inline media/text file items in older tool results and match results by message position - align media eviction with the stepped truncation cutoff; truncationStep Infinity disables truncation - resolve the default model lazily; reject async getGateway clearly Co-authored-by: Cursor <cursoragent@cursor.com>
🦋 Changeset detectedLatest commit: ef6c108 The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
| if ( | ||
| typeof internals.getLockKey !== "function" || | ||
| typeof internals.drainQueue !== "function" | ||
| ) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
🔍 Recovery draining relies on private SDK methods
The recovery drain calls private Chat SDK methods. If either method disappears, the feature check silently skips draining and queued messages wait for another webhook. Consider testing this path against the supported SDK range.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Added a guard test in messengers.test.ts (165344b) asserting Chat.prototype.getLockKey and drainQueue exist with the expected arity, so a Chat SDK upgrade that drops them fails CI rather than silently skipping the drain. Runtime feature check kept.
| Think keeps the thread lock alive for as long as a reply runs, and keeps queued | ||
| messages for 30 minutes (the Chat SDK default is 90 seconds), so a message that | ||
| arrives during a slow reply is still answered after it. Set `queueEntryTtlMs` | ||
| in a `ConcurrencyConfig` to change that. Messages still queued when the Durable | ||
| Object restarts are answered once the interrupted reply is recovered. |
There was a problem hiding this comment.
Acknowledged — the matching cloudflare-docs update will go in a separate PR there.
🟡 agents import sizes: 1 entry point grew
Changed exports (1)
How this worksEach runtime export is bundled on its own, minified, and gzipped. Changes smaller than 100 B, or smaller than 1% and 1 KiB, are ignored. Growth over 10% or 5 KiB is marked 🔴. This report is informational and does not fail CI. The workflow artifact contains every measurement. Compared |
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
…p lock heartbeats on disconnect - Extension beforeTurn snapshots use the subclass model override instead of reading ctx.model, which lazily resolved the default model (and its AI binding/gateway) even when unused. - ChatSdkStateAdapter.disconnect() clears lock heartbeat timers, and heartbeats never start or tick while disconnected. - Test that the installed chat package still has the private getLockKey/drainQueue methods the recovery drain relies on. Co-authored-by: Cursor <cursoragent@cursor.com>
A failed messenger reply recovery that the Tasks run will replay no longer starts the queue drain, so follow-ups queued behind it are not answered ahead of the original reply. Success and terminal failures still drain. Co-authored-by: Cursor <cursoragent@cursor.com>
| export function messengerContextFromEvent( | ||
| event: MessengerEvent | ||
| ): MessengerContext { | ||
| const serializable = serializableMessengerEvent(event); |
There was a problem hiding this comment.
🔴 Live messenger attachments lose their bytes
During live turns, messengerContextFromEvent strips data and fetch from attachments. Hooks and tools reading getMessengerContext() can no longer open them.
Learn more
A messenger event contains live attachment data and download functions. Previously the persisted user-message metadata used the live message objects, so getMessengerContext exposed those attachment capabilities during a turn. Now the metadata is serialized before storage, and deliverMessengerReply also passes a serialized event into the live turn. Both paths leave hooks and tools with attachment descriptions but no way to read the bytes.
Example: A custom adapter sends a file with data: Buffer.from("report") and no URL or fetch metadata. A tool reads getMessengerContext()?.message?.attachments[0]; its data and fetch are both undefined, although the webhook supplied the bytes.
Recommended fix: Keep the persisted metadata sanitized, but pass the original live event to chatWithMessengerContext and let the turn-scoped context expose it while the turn is active. Ensure recovered turns still use the serializable snapshot.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Not a regression from this PR. On main the runtime already built snapshotEvent = serializableMessengerEvent(event) in both enqueueReply and answer (chat-sdk.ts ~521/557) and passed that snapshot to chatWithMessengerContext; serializableMessengerMessage already dropped data/fetch/raw. getMessengerContext() returns the active turn context first, so tools/hooks in a live turn never saw the bytes. This PR only sanitizes the metadata fallback, which previously exposed live objects only via the in-memory cache until rehydration (they became {} once persisted). Exposing bytes during a turn would be a new capability, would diverge live vs recovered turns, and can't cross the sub-agent hop — code that needs bytes can use the live event in toEvent/conversation, or fetchMetadata. Leaving as-is.
…sults convertToModelMessages emits provider-executed results in the assistant message, so they never occupy a position among tool-message results. Clarify producesToolResult and cover the case with the real converter. Co-authored-by: Cursor <cursoragent@cursor.com>
…rompt-review Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # packages/think/src/tests/agents/messengers.ts
| return chunk; | ||
| } | ||
| const messageMetadata = Object.fromEntries( | ||
| Object.entries(chunk.messageMetadata).filter(([key]) => !(key in existing)) |
There was a problem hiding this comment.
🟡 Recovery drops new metadata keys
When a recovery continuation adds toString metadata, withoutOverwrittenStartMetadata drops it despite no existing value. The in check matches inherited object properties, so the new key never reaches the assistant message.
| Object.entries(chunk.messageMetadata).filter(([key]) => !(key in existing)) | |
| Object.entries(chunk.messageMetadata).filter( | |
| ([key]) => !Object.prototype.hasOwnProperty.call(existing, key) | |
| ) |
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Valid — fixed in follow-up #2389: the filter now uses Object.prototype.hasOwnProperty.call(existing, key), with a regression test for toString/constructor metadata keys.
| if (options.surface.startTyping && typingRefreshMs > 0) { | ||
| typingTimer = setInterval(() => void sendTyping(), typingRefreshMs); | ||
| } |
There was a problem hiding this comment.
🟡 Typing indicator reappears after reply
If startTyping takes longer than a refresh interval, sendTyping leaves requests in flight when the first text posts. Those requests can show typing after the answer has arrived.
Learn more
A refresh tick starts a new typing request without waiting for the previous request. Stopping the interval prevents new ticks but cannot stop one already in flight. If that request finishes after the first reply text is posted, the provider displays a stale typing indicator.
Example: A refresh starts at 4 seconds and finishes at 8 seconds, while the first reply text posts at 5 seconds. The indicator appears again at 8 seconds, after the answer.
Recommended fix: Serialize refresh requests and coordinate any pending startTyping call with the first post. Where the provider supports cancellation, abort pending indicator calls when visible delivery begins.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Valid — fixed in follow-up #2389: typing requests are serialized, no new ones start once posting begins, and the first post waits (bounded) for any in-flight request, with an ordering regression test.
Follow-ups from a review of recently landed Think messenger/prompt PRs (#2356, #2339, #2355, #2354, #2325, #2324, #2323, #2321, #1839). Each fix has a regression test.
Messengers
lockHeartbeatoption (re-extends held locks every TTL/3), messengers enable it, and queued entries now last 30 minutes.getLockKey,drainQueue), feature-checked at runtime.startTypingno longer stops the turn; typing is refreshed every 4s until first text; no blank message before the apology on an interrupted empty turn.isMention; unsubscribed threads get answered and subscribed.data(Buffer, ArrayBuffer, typed array, Blob) is used when there's nofetchData; a full-buffer view is returned without copying.Prompt construction
continuation, and a recovery continuation no longer overwrites existing metadata such ascreatedAt(think: write assistant-message metadata via forwarded messageMetadata (#1873) #2321).beforeTurndoesn't override it;getGateway()returning a Promise fails with a clear error.truncationStep = Infinitymeans truncation is off (previously became 1). Documented alongside the hydration byte-budget caveat.Verified on a combined branch with the other review follow-ups:
pnpm run check, agents chat/workers/react, ai-chat, and Think suites all pass.Conflicts expected with the sandbox/model PR (
resolveModel,hooks.test.ts) and the recovery PR (messenger test agent); both sides should be kept.