* fix(feishu): catch unhandled promise rejection in streaming card flush timer
## What Problem This Solves
The scheduled flush update in FeishuStreamingSession called
`this.update(pending)` without catching potential rejections, which
could cause unhandled promise rejections when card content updates
fail due to transient network errors.
## Why This Change Was Made
Add a `.catch()` with diagnostic logging to the scheduled flush
timer's `update()` call so transient card update failures are visible
without causing unhandled promise rejections.
## User Impact
Scheduled streaming-card updates that fail transiently will log a
diagnostic instead of producing an unhandled promise rejection.
## Evidence
node scripts/run-vitest.mjs extensions/feishu/src/media.test.ts
Test Files 1 passed (1)
Tests 44 passed (44)
pnpm exec oxfmt --check ...
All matched files use the correct format.
node scripts/run-oxlint.mjs ...
oxlint_exit=0
git diff --check
exit=0
Co-Authored-By: Claude <noreply@anthropic.com>
* test(feishu): cover rejected scheduled card flush
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(qqbot): allow scoped sandbox media sends
* fix(qqbot): thread scoped media access through all outbound paths
Cover the seven QQBot outbound media paths that dropped core scoped
sandbox media access: gateway block/tool/streaming deliver, direct
text qqmedia tags, structured QQBOT_PAYLOAD, and the scoped workspace
relative/missing-voice path resolution. The low-level sendPhoto /
sendVideoMsg / sendDocument now consume core mediaAccess.readFile via
the shared outbound loader with explicit localRoots guard, so
host-mediated workspace media can be sent without widening QQBot FS
access. Voice stays on the existing SILK transcode path to avoid
bypassing QQ-required format conversion.
* fix(qqbot): thread workspaceDir into gateway reply media context
Gateway dispatchOutbound previously built gatewayMediaContext with only
mediaLocalRoots, so a sandboxed block reply that carried a relative
media tag (e.g. <qqmedia>report.docx</qqmedia>) reached the downstream
sender without mediaAccess.workspaceDir and was rejected by containment
checks before the file could be resolved against the agent workspace.
Resolve the agent workspace dir via the public plugin-sdk agent-runtime
helper and pass it through mediaAccess.workspaceDir alongside the
existing mediaLocalRoots so the gateway block reply, tool forwarding,
QQBOT_PAYLOAD, and official C2C streaming paths all share the same
scoped workspace resolution as direct outbound sends. No public plugin
SDK boundary is widened; the helper is an existing export.
Add a regression test that exercises a relative <qqmedia> tag in a
gateway block reply and confirms it resolves against the configured
agent workspace.
* fix(qqbot): wrap host-read media sends with structured error handling
* fix(qqbot): preserve host-read media quota errors
* fix(qqbot): preserve host-read quota fallback path
* fix(qqbot): resolve host-read quota fallback path
* test(qqbot): satisfy host-read media lint
* fix(qqbot): cover host-read voice media
* fix(qqbot): satisfy host-read voice lint
* Drop incidental formatting from PR merge
Keep the conflict-resolution update scoped to the QQBot media send files.
* fix(qqbot): map sandbox workspace media paths
* fix(qqbot): preserve host-read media roots
* fix(qqbot): scope sandbox media delivery roots
* fix(qqbot): map virtual media roots before host read
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(qqbot): scope structured payload media roots
* fix(qqbot): keep auto media helper internal
* docs(changelog): note QQBot scoped sandbox media fix (#92872) (thanks @zhangguiping-xydt)
---------
Co-authored-by: sliverp <870080352@qq.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Ambient transcript watermarks now carry the transcript session id, resolve only for the current session entry, and skip stale room-event hooks that no longer match the prepared transcript session.
This protects Telegram group prompt windows after reset by backfilling rows that are no longer present in the new session transcript, while preserving steady-state watermark filtering within one session.
Fixes#99373
Release-note: fixes Telegram group context loss after session reset when ambient transcript watermarks outlived the transcript they referenced.
Persist room-event observations as durable bare user transcript rows and carry an ambient transcript watermark through session state so Telegram chat windows only include the unpersisted gap.
Fixes#99257
ClawSweeper review finding: F3 only wrapped the cleanup-fallback bar send.
The normal path applyProgressCollapseSummary awaited an unguarded durable
send when finalizeToPreview could not edit in place, and sendPayload throws
durable.error on delivery failure — so a cosmetic summary-bar flood-wait
could propagate and fail an otherwise-complete turn (merge-risk:
message-delivery).
Route BOTH cosmetic-bar sends through one shared guarded helper
postCosmeticSummaryBar (swallow + logVerbose), so neither the cleanup path
nor the finalizeToPreview-miss path can fail turn delivery. Add the missing
regression: no-live-message fallback bar send throwing keeps the turn alive
and the final answer delivered.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Post-rebase onto main, check-test-types/lint failed on our touched test
files (runtime unaffected):
- core's onVerboseProgressVisibility now takes a thunk (isActive: () =>
boolean); the dispatch test passed a bare boolean. Pass () => true.
- our TelegramDraftStream additions (finalizeToPreview,
rotateToNewMessageDeferringDelete) are required members; the QA-e2e
mock streams omitted them. Add both to every mock stream.
- drop a redundant 'as string' cast (oxlint no-unnecessary-type-assertion).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses five codex red-team findings on the streamed progress-window
collapse bar. Channel-side only; no core src/** changes.
F1 (transcript pollution): the collapse bar went through
sendPayload({durable:true}), which unconditionally passed the transcript
mirror to the deliverReplies fallback, writing the cosmetic digest
("💬 1 note · 🛠️ 1 tool call · ⏱️ 4s") into the session transcript so the
model read it back as its own prior turn. Added a mirrorTranscript option to
sendPayload; the bar now sends durably with mirrorTranscript:false. Discord
parity: its summary bar (monitor/reply-delivery.ts deliverDiscordReply →
sendDurableMessageBatch) has no transcript-mirror seam at all. Real finals
keep mirroring.
F4 (tool overcount): progressSummary.noteToolCall() fired for ANY start-phase
tool, but the compositor renders a line only for work tools
(isChannelProgressDraftWorkToolName) and only when toolProgress is on, so
codex/message_tool_only turns showed "🛠️ 1 tool call" with no tool line. The
count is now gated by the same public work-tool-name check plus
streamToolProgressEnabled; non-counting tool starts still close the
reasoning/commentary bursts as a boundary.
F2 (silent collapse drop): finalizeToPreview ignored a false return from the
in-place edit (flood-wait 429 / terminal error), so applyProgressCollapseSummary
assumed "edited", cleared state, posted no bar, and left the tall window.
finalizeToPreview now returns undefined when the edit did not apply, so the
dispatch falls back to the existing durable-post path.
F3 (cosmetic send fails the turn): the cleanup-time deliverProgressCollapseSummary
could throw (429/network) and propagate out of dispatch after the real final
already delivered. The bar send is now wrapped: failures log via logVerbose and
never fail the turn; the once-guard is preserved.
F5 (ghost-preview race): rotateToNewMessageDeferringDelete rewound while a FIRST
send was still in flight; the late send landed as superseded {retain:true}, which
the dispatch handler kept as an orphaned stale bubble. A reposition now records
the in-flight generation and deletes its late-landing message (deferred), while
forceNewMessage's retain-as-durable-chunk contract is unchanged.
Tests: added unit tests for each invariant (bar delivered but absent from the
transcript mirror; message-tool start → no 🛠️ count and toolProgress-off → no
count; failed edit → undefined; bar send throw → turn still succeeds; reposition
race → superseded send deleted not retained). Telegram suites green
(bot-message-dispatch 165, draft-stream 58, progress-summary 16); extensions
tsgo clean for telegram.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Progress mode's streaming window is a pure activity log (Discord parity): it
carries only 🧠/💬/🛠️ progress lines, and the answer appears once, as the final
message below the collapsed bar. Answer PARTIALS were already suppressed
(updateDraftFromPartial early-returns for the answer lane), but intermediate
answer BLOCKS (info.kind === "block", before the final) still streamed into the
window via deliverLaneText, so interim assistant prose flashed inside the
working bubble mid-turn.
Fix: in the deliver() answer-block branch, suppress plain interim answer blocks
in progress mode — buffer them (like the existing skipTextOnlyBlock path) so they
still feed the final/collapse, and skip the draft-stream delivery. Media,
approval, and button blocks are not plain interim prose and fall through to
normal delivery. With plain blocks now suppressed upstream, the block-branch's
progress rotation guard only guards those remaining media/button blocks; comment
updated to say so.
This is the product decision Peter approved (interim answer-block visibility in
the window); it completes the single-message model so the window truly only ever
shows progress lines.
Tests: added "never streams an interim answer block into the progress window
(Discord parity)" — an interim block never reaches update/updatePreview and never
appears in delivered replies, while the final answer is delivered below. 233
green across bot-message-dispatch, draft-stream, progress-summary;
extensions/telegram typechecks clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Progress mode now keeps ONE window message per turn, edited through every lane
handover and edited into the bar only at collapse — matching Discord's
single-message model. Previously the tool-progress window was rotated/repositioned
to a fresh bubble on every interim answer chunk and on the tools->text handover,
even though progress mode never renders interim answer text into the window
(updateDraftFromPartial already returns early for the answer lane). Each rotation
spawned a new message; that churn was the on-off jump's underlying source.
Fix — progress-mode-gated so the rotation branches fire only where they must
(block/partial); block/partial and finalized-rotation paths are untouched:
- prepareAnswerLaneForText returns early in progress mode — no rotate/reposition
for interim answer text that never displays. (bot-message-dispatch.ts:1288)
- prepareAnswerLaneForToolProgress no longer rotates in progress mode; the tool
lane always edits the same message (rotate stays for block/partial where answer
text streamed first). (bot-message-dispatch.ts:1064)
- the interim answer-block path no longer rotates the window to a new bubble in
progress mode. (bot-message-dispatch.ts:2356)
Rotation/delete paths REMAIN (all post-first / deferred-delete per cc9c2b13a9)
only for: block mode, forceNewMessage after a finalized answer (new turn), error
teardown with nothing to summarize, and superseded-generation cleanup. Collapse
uses task-9 order (final first, then edit the one window into the bar in place);
zero deleteMessage in the happy path. The end-of-turn forceNewMessage-after-
collapse is unchanged (end-of-turn, not mid-turn churn).
LOC: net +~24 prod lines, all justified — three small streamMode==="progress"
guards plus the invariant comments AGENTS.md requires for intentional caller
differences. The rotation branches are shared with block/partial and cannot be
deleted, only gated.
Tests: removed the now-obsolete task-8 "repositions the tool-progress window"
test (that path no longer runs in progress mode); added "keeps a single
stationary window when text follows durable reasoning" and a multi-boundary turn
"uses one stationary window message across a multi-boundary turn
(commentary->tool->commentary->tool->final)" asserting one window message id used
throughout, edited N times, zero deletes, one bar edit last, final posted before
the bar edit. 231 green across bot-message-dispatch, draft-stream, progress-summary;
extensions/telegram typechecks clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live off-off finding (no reposition involved): the final answer posted below the
viewport. Cause: collapseProgressWindowIntoSummary edited the tall window bubble
DOWN to a one-line bar BEFORE the final was sent; shrinking content under the
viewport breaks the Telegram client's at-bottom follow, so the freshly sent final
landed off-screen.
Fix — swap the order in deliverProgressModeFinalAnswer:
- Snapshot the bar line (resolveProgressCollapseSummaryLine, which also consumes
the once-guard) BEFORE the final send, so the final's own delivery cannot
perturb the counts/timer.
- Send the final answer FIRST (it lands at the bottom of the anchored viewport),
THEN collapse the window above it. The bar is an edit of an already-posted
message that sits above the final, so shrinking it is harmless.
- Failure path: if the final send skips/fails, still collapse to the bar (or tear
the window down when there is nothing to summarize) so no stale window lingers.
- Mark answerLane.finalized / markProgressFinalDelivered LAST, after the collapse
resets lane state, so end-of-turn cleanup sees a finalized lane (stop(), not a
spurious clear()).
Renamed collapseProgressWindowIntoSummary -> applyProgressCollapseSummary (takes
the pre-resolved line; "edited" | "posted").
Tests: updated the collapse tests to assert the new order (final delivered before
finalizeToPreview); added "sends the final answer before collapsing the window
into the bar" (order + counter snapshot) and "still collapses the window when the
final answer send is skipped". 230 green across bot-message-dispatch, draft-stream,
progress-summary; extensions/telegram typechecks clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Peter isolated the on-off focus-jump precisely: when a durable 🧠 posts BELOW
the streaming window, the window (now above the reasoning) is repositioned to
stay newest by DELETING it and reposting below. Telegram cannot move messages,
so the delete-then-repost order scroll-jumps the client.
Root cause: rotateAnswerLaneAfterToolProgress rewound the tool-progress window
with stream.clear(), which deletes the old message IMMEDIATELY when it has been
on screen past the dwell — before the replacement message is sent. Delete-first,
post-second.
Fix — invert the order, preserving arrival order:
- draft-stream: new rotateToNewMessageDeferringDelete() rewinds the stream so the
NEXT update creates a fresh message, and schedules the superseded message's
delete for AFTER it (detached, floored at 1.5s so the new message lands first).
Extracted the shared deferred-delete scheduler (scheduleDetachedDelete) used by
both clear() and the reposition. (draft-stream.ts)
- dispatch: rotateAnswerLaneAfterToolProgress now repositions via that method
instead of clear()+forceNewMessage, so no window reposition deletes before the
replacement lands. (bot-message-dispatch.ts:1259)
Tests: draft-stream unit tests prove the sequencing (new message sent before the
old is deleted; delete deferred; no-op with no live message). Added a dispatch
repro (durable 🧠 then answer text mid-turn -> reposition, no clear). Updated the
predating tool-progress-rotation tests to assert the deferred-delete reposition
and that any deliverer-cleanup clear() runs only AFTER the rewind. 228 green
across bot-message-dispatch, draft-stream, progress-summary; extensions/telegram
typechecks clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live on-off finding: the collapse path was inconsistent. Edit-in-place worked
in off-off/stream-off, but in on-off (tool-progress-only window, reasoning
durable) finalizeToPreview returned undefined and the caller fell into a bare
clear() -> deleteMessage -> Telegram focus-jump; a sibling sub-branch dropped
the bar entirely. Two divergent outcomes from one fallback.
Root cause: a throttled tool-progress preview could still be pending (coalesced,
never sent) when the turn ended, so the window had no message id even though it
had "rendered". finalizeToPreview gave up and the dispatch fallback deleted the
(late-landing) window.
Fixes:
- draft-stream finalizeToPreview: settle the stream, then MATERIALIZE a still-
pending preview (send it, as a final flush would) so the window message exists
and can be edited in place. Only when no message could be established does it
return undefined. (draft-stream.ts:597)
- dispatch collapse: one deterministic path returning "edited" | "posted" |
"none". A bar is ALWAYS surfaced when one exists — edited in place, or posted
durably with ZERO deleteMessage. clear()/delete now runs ONLY for the "none"
case (error final or nothing to summarize), never when a bar exists, so no
collapse path can focus-jump. Split into resolveProgressCollapseSummaryLine /
collapseProgressWindowIntoSummary / resetAnswerLaneAfterCollapse /
teardownProgressWindow for a readable branch. (bot-message-dispatch.ts:1949)
Tests: added on-off dispatch repro (tool-progress-only + durable reasoning +
mid-turn rotation + final -> edits into bar, no clear, exactly one bar), the
no-live-message durable-bar-without-delete case, and three draft-stream
finalizeToPreview cases (edit-in-place, pending-materialize, no-window).
225 green across bot-message-dispatch, draft-stream, progress-summary;
extensions/telegram typechecks clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Five progress-window fixes for the Telegram streaming lane, aligning it with
the Discord reference surface:
- FIX 2: /reasoning on + /verbose off no longer kills the progress window.
forceBlockStreamingForReasoning is now scoped to non-progress modes, so
durable reasoning removes only the 🧠 lane; commentary/tools still stream and
the collapse bar still posts.
- FIX 3: durable thoughts render behind the 🧠 marker instead of the literal
"Thinking" header. The core formatReasoningMessage output is rewritten
channel-side in reasoning-lane-coordinator (no core change), keeping the
italic body.
- FIX 4: /verbose on no longer duplicates tool calls. canPushStreamToolProgress
now yields under verbose so the durable verbose lane owns every progress
surface (invariant: persistent message XOR window).
- FIX 1: the progress window collapses by EDITING the existing message in place
into the summary bar (draft-stream finalizeToPreview), then posts the final
below — no delete + repost, which scroll-jumped the client. Falls back to
clearing the window when there is no bar to collapse into.
- FIX 5: message_tool_only/codex finals that bypass the in-band answer path now
post the collapse bar from a cleanup-time fallback (sawProgressFinal from the
dispatch counts).
Tests: adapted predating dispatch/reasoning tests to the new 🧠 marker and
collapse-by-edit behavior; added coverage for FIX 2 (window alive under
/reasoning on), FIX 4 (no window tool dup under verbose), and FIX 5
(message_tool_only collapse bar). 220 green across bot-message-dispatch,
draft-stream, progress-summary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port the dev-beta2 progress-summary work onto the compositor-era dispatch:
- Post a one-line activity digest (🧠 N thoughts · 💬 N notes · 🛠️ N tool
calls · ⏱️ Ns, Discord parity) as a durable message when the progress
window collapses at end-of-turn, before the final answer. Counted
per-burst channel-side; only window-streamed activity feeds the bar
(durable rv/verbose items never do — persistent message XOR bar count),
no bar for error finals or turns where the window never rendered.
- Render multi-line commentary with its line structure intact (per-line
markdown conversion, joined as line breaks) instead of collapsing to
one run-on line; per-line conversion also keeps block markdown (setext
headings) from forming across lines.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The streaming "gerund" progress box was deleteMessage'd immediately at
teardown, so on fast turns it flashed and vanished before it could be read,
and the delete could race a just-persisted message (intermittently dropping
the first verbose commentary). Add MIN_PREVIEW_DWELL_MS (4000ms) and schedule
the delete DETACHED via setTimeout for max(0, 4000 - timeVisible), measured
from when the box first appeared. The delete never awaits, so turn teardown
is never stalled.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Progress drafts join their rendered lines with <br>, but the Bot API
parse_mode=HTML entity set has no <br> tag — line breaks must be literal
newlines. Telegram therefore rejected every multi-line preview edit with
"can't parse entities", and the parse-error fallback silently downgraded
the whole streamed progress draft (all lanes) to unformatted plain text.
Convert <br> to newlines in the parse-mode transport branch, matching what
the rich-message branch already does.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Streamed progress on Telegram rendered reasoning and commentary
indistinguishably and leaked the model's raw markdown: the compositor
refactor dropped the per-channel lane markers and the markdown rendering
that the pre-compositor path had.
- Pass the 🧠/💬 lane prefixes and commentaryItalics:false to the shared
progress-draft compositor (mirroring Discord), so reasoning renders
italic and commentary plain, each behind its own marker.
- Render lane bodies through renderTelegramHtmlText — the parse_mode=HTML
safe converter — so **bold**, inline code, and _italic_ render as
intended rather than leaking raw markers. Lane lines collapse to one
line first: multi-line commentary otherwise forms block markdown (a
`\n\n---\n\n` separator turns the paragraph above it into a setext
<h2>), which Telegram rejects, dropping the whole preview (all lanes)
to unformatted plain text.
- Clip long lane lines INSIDE the whole-line _…_ wrapper: clipping the
assembled line chopped the closing underscore, silently degrading every
long reasoning line (the common case) from italic to plain text.
Generic item payloads keep their monospace styling. Matters most for
deepseek, which narrates progress almost entirely through its
markdown-heavy reasoning lane.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move Telegram group history out of the message body fold and into the structured chat-window context used by the inbound metadata renderer. Preserve outbound self entries, post-self watermarks, recovered forum-topic routing, and richer cached reply/media fields while dropping stale recovered-topic windows.\n\nFixes #99218
* fix(interactive): preserve button command values in fallback text for degraded approval UX
* fix(interactive): keep callback values private in fallback text and narrow Feishu interactive detection
- P1: Skip rendering action.type === "callback" values in
renderMessagePresentationFallbackText to avoid leaking opaque
channel/plugin data into user-visible text. Command and legacy
values are still rendered.
- P2: Replace hasMessagePresentationBlocks/hasInteractiveReplyBlocks
with isMessagePresentationInteractiveBlock so Feishu comment
guidance only appears when the presentation actually contains
buttons or selects, not for text-only blocks.
- Update tests: callback button now shows label-only; all 137 tests pass.
* fix(interactive): only render typed command values in fallback text, keep legacy value private
* fix(feishu): gate document-comment command guidance on actual command action
* docs(message-presentation): document command/callback value fallback visibility
* fix(feishu): omit command guidance when URL overrides fallback command text
* docs: regenerate docs_map.md
* fix(interactive): exclude disabled buttons from fallback command rendering and guidance
* fix(interactive): extract hasRenderedCommandAction, exclude disabled buttons from command fallback
* fix(feishu): preserve command guidance marker through core presentation rendering
* fix(feishu): type-narrow channelData.feishu with isRecord before reading rendered-command marker
* fix(feishu): move hasRenderedCommandAction from public SDK into Feishu plugin as local helper
Keep the helper local to the only caller (Feishu outbound) instead of
adding a new public plugin SDK API contract. The shared fallback renderer
in renderMessagePresentationFallbackText already inlines the same
command-visibility logic; a local helper is sufficient for the Feishu
comment-thread guidance gate.
* refactor(feishu): tighten fallback command marker
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(anthropic): restore Fable 5 Vertex simple completions
* test(agents): satisfy custom API model types
* test(ci): route reliability test from temp helper
* test(agents): satisfy custom API model types