perf(im): parallelize reactions, thread_replies, and merge_forward fetches#1146
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughDeduplicate IDs and batch fetch reactions with a single-batch fast path or bounded-concurrency multi-batch fan-out (serialized warnings). ExpandThreadReplies now plans per-thread limits, fetches replies concurrently with a cap, formats sequentially, resolves names in one batch, and attaches results. Tests and docs updated. ChangesReactions batching and concurrency
Thread replies planning and concurrent fetch
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@shortcuts/common/runner.go`:
- Around line 818-825: The checkScopePrereqs call can still panic because
checkScopePrereqs assumes f.Credential is non-nil; update the pre-check in
checkShortcutScopes (the block that currently returns when f==nil ||
config==nil) to also return nil when f.Credential == nil so scope prechecks are
skipped for unwired credential resolvers, or alternatively add the same
nil-guard at the start of checkScopePrereqs to avoid dereferencing f.Credential
in checkScopePrereqs (which calls f.Credential.ResolveToken).
In `@shortcuts/im/im_chat_messages_list.go`:
- Around line 101-113: The current helper buildChatMessageListRequest silently
normalizes invalid --page-size values (e.g. non-integers or out-of-range
numbers); change buildChatMessageListRequest so it explicitly parses
runtime.String("page-size") with strconv.Atoi and returns a clear error if
parsing fails or the value is outside the allowed min/max instead of clamping
it, and keep Validate (which calls buildChatMessageListRequest) returning that
error so callers are rejected rather than run with a different page size.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: fb5d326f-722f-41b1-b19b-e3340c4086b7
📒 Files selected for processing (8)
shortcuts/common/runner.goshortcuts/im/convert_lib/reactions.goshortcuts/im/convert_lib/reactions_test.goshortcuts/im/im_chat_messages_list.goshortcuts/im/im_messages_mget.goshortcuts/im/im_messages_search.goshortcuts/im/im_threads_messages_list.goskills/lark-im/references/lark-im-message-enrichment.md
3bf3526 to
1455dd8
Compare
1455dd8 to
3e1e371
Compare
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
skills/lark-im/references/lark-im-message-enrichment.md (1)
13-13:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winScope requirement text is now stale versus
--no-reactionsbehavior.This line still says the reactions scope is pre-flight enforced via unconditional
UserScopes/BotScopes, but this PR’s objective is conditional enforcement (scope check only when--no-reactionsis false). Please update this section to avoid contradicting runtime behavior.Suggested doc fix
-The default enrichment requires `im:message.reactions:read`, already declared in each shortcut's `UserScopes` / `BotScopes` (or `Scopes` for the user-only search command), so the framework's pre-flight check surfaces a `missing_scope` error before the request is sent. +When reactions enrichment is enabled (default), shortcuts require `im:message.reactions:read` and the pre-flight check can surface `missing_scope` before sending the reactions request. With `--no-reactions`, this scope is not required.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@skills/lark-im/references/lark-im-message-enrichment.md` at line 13, The documentation incorrectly states that the reactions scope (im:message.reactions:read) is always pre-flight enforced via UserScopes/BotScopes; update the text in Lark IM message enrichment to reflect that scope enforcement is conditional based on the CLI flag --no-reactions — i.e., explain that the framework will check for im:message.reactions:read only when --no-reactions is false and that pre-flight missing_scope errors will only occur in that case, while also keeping a note that bots registered before the scope was added still require incremental authorization in the Feishu developer console and users can run the shown command for authorization.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@skills/lark-im/references/lark-im-message-enrichment.md`:
- Line 13: The documentation incorrectly states that the reactions scope
(im:message.reactions:read) is always pre-flight enforced via
UserScopes/BotScopes; update the text in Lark IM message enrichment to reflect
that scope enforcement is conditional based on the CLI flag --no-reactions —
i.e., explain that the framework will check for im:message.reactions:read only
when --no-reactions is false and that pre-flight missing_scope errors will only
occur in that case, while also keeping a note that bots registered before the
scope was added still require incremental authorization in the Feishu developer
console and users can run the shown command for authorization.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: ce954f56-8d6e-4005-aecd-b2b0e6db99d0
📒 Files selected for processing (3)
shortcuts/im/convert_lib/reactions.goshortcuts/im/convert_lib/reactions_test.goskills/lark-im/references/lark-im-message-enrichment.md
🚧 Files skipped from review as they are similar to previous changes (2)
- shortcuts/im/convert_lib/reactions.go
- shortcuts/im/convert_lib/reactions_test.go
3e1e371 to
fc9b28c
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@skills/lark-im/references/lark-im-message-enrichment.md`:
- Line 7: The examples mention batching for reactions fetched via
im.reactions.batch_query and thread_replies using a 20-ID server cap but the
math is off: for “page 50 + ~500 expanded thread replies” (~550 IDs) the count
should be 28 batches (550 / 20 = 27.5 → 28), not ~25; update the example text to
say "28 batches" (or adjust the example numbers to match 25 batches) so the
described batching for reactions and thread_replies aligns with the 20-ID cap
and bounded concurrency behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: d30576e6-7e01-4241-9fc8-54bcdd74ab47
📒 Files selected for processing (3)
shortcuts/im/convert_lib/reactions.goshortcuts/im/convert_lib/reactions_test.goskills/lark-im/references/lark-im-message-enrichment.md
🚧 Files skipped from review as they are similar to previous changes (1)
- shortcuts/im/convert_lib/reactions_test.go
fc9b28c to
195a55c
Compare
195a55c to
85490b6
Compare
85490b6 to
f087c67
Compare
0233b07 to
ce33169
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1146 +/- ##
==========================================
+ Coverage 68.63% 68.66% +0.03%
==========================================
Files 625 625
Lines 58386 58514 +128
==========================================
+ Hits 40071 40177 +106
- Misses 15027 15046 +19
- Partials 3288 3291 +3 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
🚀 PR Preview Install Guide🧰 CLI updatenpm i -g https://pkg.pr.new/larksuite/cli/@larksuite/cli@72a8421933135553961977361996cf54518ff257🧩 Skill updatenpx skills add sammi-bytedance/larksuite-cli#perf/im-reactions-parallel-batches -y -g |
…tches
Five related optimizations to the auto-enrichment side-fetches that run
on every +chat-messages-list / +messages-mget / +messages-search /
+threads-messages-list invocation. Each targets the same
latency-multiplier pattern: independent per-resource HTTP calls
dispatched in a strictly-serial loop, where each call has ~700ms-1s RTT
regardless of payload, so N resources turn into N × ~1s of stall before
the main message output can render. On busy chats the cumulative cost
pushed --no-reactions wall time above 14s — enough to trigger wall-clock
timeouts in wrapper agents even though the CLI itself never errored.
1) EnrichReactions: bounded-concurrency batches (cap = 4 in flight).
im.reactions.batch_query is server-capped at 20 queries[] per call,
so the id set is split into batches of <=20 and the batches dispatch
under bounded concurrency. Stays at cap=4 because this endpoint has
an explicit gateway-layer rate limit (50/s + 1000/min). Each batch
only writes to its own message-id's map entries — concurrent batches
never contend. Safety invariant documented inline: collectMessageNodes
dedups ids on first-seen, so every batch sub-slice carries each id at
most once, hence different batches operate on disjoint idIndex buckets
and therefore disjoint message-map pointers. Stderr warnings serialize
through a mutex so partial lines don't interleave. Single-batch fast
path preserved.
2) ExpandThreadReplies: bounded-concurrency per-thread fetches (cap = 8
in flight) + batched sender resolution + thread-reply merge_forward
prefetch + correct totalLimit accounting.
Phase 1 plans every unique thread_id (no upfront budget deduction),
then concurrently fetches each thread's replies. Phase 2 stays
single-threaded: 2a-pre) post-fetch budget enforcement against
ACTUAL returned counts (not planned ceilings — see below); 2a)
pre-fetches merge_forward sub-messages for any thread reply that is
itself a merge_forward, then FormatMessageItem per reply with that
prefetch attached; 2b) ONE batched ResolveSenderNames across every
plan's replies (was previously called per-thread, fanning out the
contact API request); 2c) AttachSenderNames + write thread_replies
to each plan's host outer message. Semantics preserved: first outer
message per thread_id remains the host; thread_replies_error: true
on fetch failure unchanged; thread_has_more: true propagated AND
set on threads truncated by the budget so consumers know more
replies exist server-side.
totalLimit budget correctness: an earlier draft deducted the
PLANNED per-thread ceiling (perThread, default 50) from totalLimit
upfront, which silently dropped later threads in chats with many
short threads. Concretely: 12 threads × 3 actual replies each =
36 total, but planned-ceiling accounting (12 × 50 = 600) would have
exceeded the default totalLimit=500 after just 10 threads and
silently skipped the last two. New code budgets against actual
returned counts: threads past a fully-drained budget have their
slice cleared to an empty (non-nil) slice so the attach loop leaves
the host alone; threads whose count straddles the budget boundary
get truncated with thread_has_more flagged. Empty-slice vs nil-slice
is the discriminator between "budget-skipped (success)" and "fetch
error (mark thread_replies_error)". Regression covered by
TestExpandThreadRepliesTotalLimitUsesActualCounts and
TestExpandThreadRepliesTruncatesOnBudgetBoundary. Cost: under
budget overflow, threads beyond the boundary are still fetched and
then discarded — bounded by perThread sub-message records each, an
acceptable price for not losing data silently.
3) merge_forward sub-message expansion: shortcut-level concurrent
prefetch (cap = 8 in flight) + batched sub-item sender resolution
+ a fix for the error-masking bug + correct prefetch-failure
fallback.
The previous mergeForwardConverter.Convert issued one GET per
merge_forward message inline inside FormatMessageItem — so N
merge_forwards in a page took N × ~1.7s serial (measured: 5
merge_forwards × 1.7s = 8.5s of stall, 67% of total --no-reactions
wall time).
New PrefetchMergeForwardSubItems pre-scans rawItems, picks out
merge_forward message_ids, and concurrently fetches each sub-tree.
The result map is threaded into FormatMessageItem via the new
FormatMessageItemWithMergePrefetch variant and ConvertContext's
new MergeForwardSubItems field. Convert first checks the prefetch
cache (fast path), falling back to its pre-existing inline GET
when no cache is present.
Prefetch-failure handling: when a prefetch GET fails, the failed
id is NOT inserted into the result map. This is the discriminator
for Convert's fallback path: a missing key triggers the inline
re-fetch slow path, which (a) gets a second attempt at the GET and
(b) if that also fails, produces the same "[Merged forward: fetch
failed: ...]" user-visible string the old code produced. An
earlier draft inserted result[id] = nil on failure, which made
Convert's "if cached, ok := m[id]; ok { renderTree(cached) }"
path hit ok=true, cached=nil and emit an empty <forwarded_messages>
tree — silently swallowing the user-visible failure signal.
Regression covered by
TestPrefetchMergeForwardSubItemsFailureFallsThroughToInlineFetch.
Crucially, PrefetchMergeForwardSubItems also takes a nameCache and
runs ONE batched ResolveSenderNames across every sub-item it
fetched before returning. Without this step, each per-merge_forward
render in the FormatMessageItem loop would issue its own contact
API request for any uncached sender — re-introducing an N × ~400ms
serial stall (measured at 5 × ~400ms = ~2s extra in the
FormatMessageItem loop). Pre-populating the cache makes those
per-render ResolveSenderNames calls effective no-ops; the loop
dropped from ~2.3s to ~2ms on the same test chat.
Also switched fetchMergeForwardSubMessages from runtime.DoAPI to
runtime.DoAPIJSON. The old path checked only the presence of "data"
in the unmarshalled response, so a server response like
{"code": 2200, "msg": "Internal Error", ...} was reported as a
generic "empty data" string, hiding the real failure. DoAPIJSON
inspects the envelope's code and surfaces the real msg (and log_id
when present) as an ErrAPI, so merge_forward fetch failures now
produce content like "[Merged forward: fetch failed: API error
2200: Internal Error]" instead of the cryptic "empty data".
4) Empirical confirmation that mget can NOT batch-expand merge_forward:
passing 5 merge_forward ids to GET /open-apis/im/v1/messages/mget
returns exactly 5 items (msg_type=merge_forward each, no children
attached). The flat sub-tree only comes back from the per-id
GET /messages/{merge_forward_id} endpoint, so concurrent per-id
GETs (above) is the best the Feishu API surface allows.
5) Concurrency caps rationale: im.reactions.batch_query stays at 4 to
stay well under its explicit gateway 50/s + 1000/min ceiling. The
two GET-based endpoints (thread expansion and merge_forward) sit
at 8 — no published per-app rate-limit at these levels.
Tests:
- Reactions: TestEnrichReactions_BatchSize made order-tolerant; new
TestEnrichReactions_MultiBatchCorrectness (65 msgs → 4 batches
under bounded fan-out).
- Threads: pre-existing tests pass unchanged; new
TestExpandThreadRepliesMultiThreadConcurrent (5 distinct thread
roots, tagged-by-thread_id anti-contamination check); new
TestExpandThreadRepliesTotalLimitUsesActualCounts (12 short
threads, verifies all attach when actual counts fit under the
budget); new TestExpandThreadRepliesTruncatesOnBudgetBoundary
(3 threads × 4 replies vs totalLimit=10, verifies last thread
truncated to 2 + thread_has_more=true).
- merge_forward: replaced single "empty data" test with two targeted
subtests — empty_data_treated_as_no_children and
non-zero_code_surfaces_real_error (regression for the code:2200
production bug). New TestPrefetchMergeForwardSubItems (mixed input
of 5 merge_forwards + 2 non-merge_forwards, path-derived child
tags defeat goroutine cross-contamination). New
TestPrefetchMergeForwardSubItemsFailureFallsThroughToInlineFetch
(regression for the silent-empty-tree bug: confirms failed ids
are absent from the prefetch map AND Convert produces the proper
error string via inline retry).
- `go test -race -count=1 ./shortcuts/im/convert_lib/` clean.
- `gofmt -l .` / `go vet ./...` clean.
Docs:
- skills/lark-im/references/lark-im-message-enrichment.md: notes the
bounded concurrency in the reactions bullet.
Live measurement (group chat, page-size 50, 39 outer messages, 5
merge_forwards, 4 thread roots, 14 nested thread replies, 15 messages
with reactions; median of 3 runs):
before after delta
reactions ON 15.6s 8.1s -7.5s (-48%)
reactions OFF (no enrich) 14.3s 6.6s -7.7s (-54%)
Stage breakdown of the 14.3s --no-reactions baseline (one-off
instrumentation, then reverted):
before after
GET /messages ~1.7s ~1.7s (server)
Prefetch merge_forward + sender resolve - ~2.5s (new)
FormatMessageItem loop ~8.5s ~2ms (was: 5 × serial merge_forward + per-render contact API)
outer ResolveSenderNames + Attach ~0.5s ~0.5s
ExpandThreadReplies ~4s ~2.0s (was: 4 × serial thread GET + per-thread contact API)
Scope:
- shortcuts/im/convert_lib/reactions.go, thread.go, merge.go,
content_convert.go + their tests; 4 shortcut Execute hooks
(chat-messages-list, mget, search, threads-messages-list) to call
PrefetchMergeForwardSubItems and route through
FormatMessageItemWithMergePrefetch; one docs update.
- No framework-level changes.
Out of scope (separate follow-ups when needed):
- Decoupling --no-reactions from the im:message.reactions:read scope
pre-flight (currently required even when the flag opts out) — UX
change, not perf.
- Running PrefetchMergeForwardSubItems and ExpandThreadReplies in
parallel (they're independent and currently sequential, adding
~1.5s of avoidable sequencing). Adds nameCache-locking complexity;
defer until needed.
Change-Id: I0206d10ad204382170bd42aec67f82578923736e
ce33169 to
72a8421
Compare
Summary
Follow-up to #1095. The reactions auto-enrichment shipped, but on busy chats the strictly-serial per-resource fetches in
EnrichReactions,ExpandThreadReplies, andmerge_forwardexpansion stretched the command's wall time above 14s — enough that wrapper agents (30–60s wall-clock budgets) saw timeouts even though the CLI itself never errored. This PR parallelizes all three with the same bounded-concurrency pattern, batches the follow-up contact-API sender resolution so it doesn't fan back out into a serial stall, and fixes two correctness bugs that surfaced during review. Scoped toconvert_lib/{reactions,thread,merge,content_convert}.go+ tests + the 4 shortcut Execute hooks + the reference doc.Changes
1.
EnrichReactions— bounded-concurrency batches (cap = 4)im.reactions.batch_queryis server-capped at 20 queries[] per call. Batches now dispatch under bounded concurrency. Cap stays at 4 because this endpoint has an explicit gateway-layer rate limit (50/s + 1000/min).Safety invariant documented inline:
collectMessageNodesdedups ids on first-seen, so every batch sub-slice carries each id at most once. Different batches therefore operate on disjointidIndexbuckets → disjoint message-map pointers → no concurrent writes to the same map. Race detector verifies;TestEnrichReactions_DuplicateMessageID+TestEnrichReactions_MultiBatchCorrectnesscover the round-trip.2.
ExpandThreadReplies— bounded-concurrency fetches (cap = 8) + batched sender resolution + correcttotalLimitaccountingTwo-phase: Phase 1 plans every unique
thread_idand concurrently fetches all of them. Phase 2 is single-threaded: pre-fetch merge_forward sub-messages for thread replies, format, one batchedResolveSenderNamesacross every reply, attach.totalLimitcorrectness (regression fix): an earlier draft deducted the planned per-thread ceiling (perThread, default 50) fromtotalLimitupfront, which silently dropped later threads in chats with many short threads. Concretely: 12 threads × 3 actual replies each = 36 total, but planned-ceiling accounting (12 × 50 = 600) would have exceeded the defaulttotalLimit=500after just 10 threads and silently skipped the last two. New code budgets against actual returned counts post-fetch: threads past a fully-drained budget have their slice cleared to an empty (non-nil) slice so the attach loop leaves the host alone; threads whose count straddles the budget boundary get truncated withthread_has_moreflagged. Empty-slice vs nil-slice is the discriminator between "budget-skipped (success)" and "fetch error (markthread_replies_error)". Regression covered byTestExpandThreadRepliesTotalLimitUsesActualCountsandTestExpandThreadRepliesTruncatesOnBudgetBoundary. Cost: under budget overflow, threads beyond the boundary are still fetched then discarded — bounded byperThreadrecords each, an acceptable price for not losing data silently.3. merge_forward — shortcut-level concurrent prefetch (cap = 8) + batched sub-item sender resolution + error-masking fix + correct prefetch-failure fallback
The previous
Convertissued one GET per merge_forward inline insideFormatMessageItem— N merge_forwards × ~1.7s serial (5 merge_forwards = ~8.5s, 67% of total--no-reactionswall time).PrefetchMergeForwardSubItemsnow pre-scansrawItems, picks out merge_forwardmessage_ids, and concurrently fetches each sub-tree. Threaded intoFormatMessageItemvia the newFormatMessageItemWithMergePrefetchvariant +ConvertContext.MergeForwardSubItems.Convertchecks the prefetch cache first; missing key falls through to the inline GET so non-shortcut callers (event subscribers, ad-hoc tests) keep working.Prefetch-failure handling (regression fix): an earlier draft inserted
result[id] = nilon failure, which madeConvert'sif cached, ok := m[id]; ok { renderTree(cached) }path hitok=true, cached=niland emit an empty<forwarded_messages>tree — silently swallowing the user-visible[Merged forward: fetch failed: ...]string that the inline path used to produce. The new code does not insert the key at all on failure;ok=falsetriggers the inline-fetch slow path which (a) gets a second attempt at the GET, (b) if that also fails, produces the proper error string. Regression covered byTestPrefetchMergeForwardSubItemsFailureFallsThroughToInlineFetch.Critical perf refinement:
PrefetchMergeForwardSubItemsalso takes anameCacheand runs one batchedResolveSenderNamesacross every sub-item it fetched before returning. Without this, each per-merge_forward render in the loop would issue its own contact API request for any uncached sender — re-introducing an N × ~400ms serial stall (~2s extra inFormatMessageItem). Pre-populating the cache makes those per-render calls no-ops; theFormatMessageItemloop dropped from ~2.3s → ~2ms on the same test chat.Also: switched
fetchMergeForwardSubMessagesfrom low-levelruntime.DoAPItoruntime.DoAPIJSON. The old path only checked for the presence ofdatain the body, so a real server error like{"code": 2200, "msg": "Internal Error", ...}was reported as a generic"empty data"string.DoAPIJSONsurfaces the realmsg(andlog_id) —[Merged forward: fetch failed: API error 2200: Internal Error]instead.4. Confirmed:
mgetcannot batch-expand merge_forwardEmpirically verified —
GET /open-apis/im/v1/messages/mgetwith 5 merge_forward IDs returns exactly 5 items (allmsg_type=merge_forward, no children attached). The flat sub-tree only comes back from the per-idGET /messages/{merge_forward_id}endpoint, so concurrent per-id GETs is the best the Feishu API surface allows.Concurrency caps
im.reactions.batch_queryGET /messages(thread expansion)GET /messages/{id}(merge_forward)Test Plan
TestEnrichReactions_BatchSizemade order-tolerant; newTestEnrichReactions_MultiBatchCorrectness(65 msgs → 4 batches, every msg correctly attributed).TestExpandThreadRepliesMultiThreadConcurrent(5 distinct thread roots, tagged-by-thread_id anti-contamination); newTestExpandThreadRepliesTotalLimitUsesActualCounts(12 short threads × 3 replies vstotalLimit=100— all 12 must attach); newTestExpandThreadRepliesTruncatesOnBudgetBoundary(3 threads × 4 replies vstotalLimit=10— third thread truncated to 2 +thread_has_more=true)."empty data"test withempty_data_treated_as_no_children+non-zero_code_surfaces_real_error(regression for the code:2200 production bug); newTestPrefetchMergeForwardSubItems(mixed 5 merge_forwards + 2 non-merge_forwards, path-derived child tags defeat goroutine cross-contamination); newTestPrefetchMergeForwardSubItemsFailureFallsThroughToInlineFetch(regression for the silent-empty-tree bug — confirms failed ids are absent from the prefetch map ANDConvertproduces the proper error string via inline retry).go test -race -count=1 ./shortcuts/im/convert_lib/clean.gofmt -l ./go vet ./...clean.reactions_partial_failedwarning on stderr.Wall-time, median of 3 runs
reactions ON--no-reactionsStage breakdown (one-off instrumentation, then reverted)
GET /messagesPrefetchMergeForwardSubItems(5 GETs + batched contact API)FormatMessageItemloopResolveSenderNames+ AttachExpandThreadReplies(4 thread GETs + per-thread contact)Related Issues
--no-reactionsfrom theim:message.reactions:readscope pre-flight (currently required even when the flag opts out) — UX change, not perf.PrefetchMergeForwardSubItemsandExpandThreadRepliesin parallel (they're independent and currently sequential, adding ~1.5s of avoidable sequencing). Adds nameCache-locking complexity; defer until needed.