feat(wecom): close the gap between the channel and its platform - #5105
Conversation
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS The WeCom design is sound; the commit smuggles unrelated frontend work the description twice denies exists, and describes a change that isn't in the diff. Watch
Suggestions
[DESIGN-REVIEWED] c3a1875 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All evidence is gathered. Composing the review. First-Principles-Verdict: CONCERNS Every WeCom item traces to a wire-protocol fact or a named boundary, but two unrelated frontend changes ride along undeclared, and a fourth per-channel copy of the override parser lands unexamined. What this change shipsIntent: make the WeCom channel actually deliver what the platform supports — declared as BOTH a fix pass and a feature pass, at the maintainer's instruction.
(More than 10 items exist — reasoning in Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] c3a1875 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsBoth candidates concern blocking work on the event loop. Let me verify my analysis of each against the code I've now opened. Candidate 1 — Candidate 2 — the per-attachment Neither candidate survives falsification. I have no independently-grounded 80+ finding to add. No findings. [OPUS-REVIEWED] c3a1875 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
6d9992c to
ebde3fc
Compare
ebde3fc to
09cadc2
Compare
09cadc2 to
6c0e8a4
Compare
6c0e8a4 to
f5c189c
Compare
f5c189c to
f965e0b
Compare
c88bc82 to
020af36
Compare
|
Both fixed, and one of them was in shared code
That affects every ingesting channel (Discord, Telegram, Weixin, WeCom), so it is fixed where it lives: the per-attachment loop is now wrapped so a I did not take the "shield this operation" suggestion: shielding a media download during shutdown delays teardown to finish work nobody will read, which trades a leak for a hang.
That leaves exactly one window unclosed — a terminal ACK arriving after One more test-quality fix, from chasing the 3.10 shard: Verified on both interpreters: 847 channel/contract/shared tests pass identically on 3.10.15 and 3.12.14. Plus |
Both findings were right, and I was wrong to dismiss the first one earlierBLOCKING — link commands persist on the event loop: CONFIRMED and fixed. In round 4 I argued this away by pointing at
Fixed with I did not offload the mutations beside them — Pinned by thread identity rather than by "does a file appear", because what makes the write safe is not being on the loop. Mutation-proved: drop either Scope note: Telegram, Discord and FINDING — audio ceiling: CONFIRMED and fixed, and the reasoning matters more than the number. I did not take the Also replaced my own Both spec docs updated in the same commit ( |
Two more parity gaps closed, and one of them was a promise the code could not keepAuditing the command surface against Slack and Telegram (rather than against WeCom's protocol, which is what the earlier rounds did) turned up two commands WeCom did not have. I implemented one and am explicitly NOT implementing the other; both are stated here rather than left as silent gaps.
Argument grammar keeps it out of Wiring it up exposed a real defect in the shared skeleton. Both halves are mutation-proved separately, because one test could not see both: drop the WeCom kwarg and the channel test fails; drop the
Three comments went stale because of my own earlier changes, each asserting a WeCom limitation this PR removed, and each somewhere a reader would trust it:
Also closed a coverage hole I had left in the round-4 fix: the Rebased onto |
Both blocking findings were real. Fixed, plus the two pre-existing frontend failures.BLOCKING 1 — a captioned attachment was silently destroyed. Confirmed. Every command branch returns before Fixed as you prescribed, with the rule stated precisely: an attachment makes the message CONTENT, never a command. The rule is about the early RETURN, not about parsing — so the command intercepts and the bare-override usage reply are disabled when BLOCKING 2 — a non-owner could flip the host's global auto-approve. Confirmed, and this one is mine from the previous round. I reasoned "allow-listed + direct chat" was sufficient and wrote that into the docstring. It is not, and the precedent was already in the repo: Slack gates the same command on Now an exact, non-empty owner match, checked BEFORE the grant is read so a non-owner learns nothing about the host's posture either, and SEL-audited ( Both mutation-proved: revert either and the new tests fail with the diagnostic naming the consequence. The two pre-existing frontend failures, per the maintainer's "fix any pre-existing CI issue" instruction
I did not paper over it with a test-only reset. The real observation is that the module-level observer is kept alive after the last terminal is disposed, where it can do nothing except wake on every Releasing state is only safe if the re-arm is real, so both halves are pinned and both mutations caught: drop the
Verification: full backend suite 61,639 passed / 0 failed on this tree. Plus mypy 1032 files, flake8, isort, black gate, scrub-lint, brand, harness-parity, docs-lint, Two earlier full-suite runs of mine reported failures (33, then 7) — both were my own fault for rebasing and editing the worktree while the suite was running, so pytest imported new test files against partly-old source. Re-running the named tests on a stable tree: all 148 pass. Flagging it because I quoted a "0 failed" figure before those runs finished. |
Right again, and it contradicted my own comment three lines awayBLOCKING — cancellation cleanup blocked the loop. Confirmed. The dispatcher's Both now go through a shared
Pinned by thread identity plus the delete actually having happened — the two halves that can regress independently. Revert to the inline call and it fails naming the stall; the existing "decrypted audio survived a cancelled transcription" test covers the other direction. Since this is shared-layer code, I ran every ingesting channel: 760 tests across Screenshot Evidence was also failing, on Full backend suite on the previous head: 61,654 passed / 0 failed. |
The blocking one is a real hole I introduced with the roll itself. The advisory one is my comment being wrong, not the code.BLOCKING — consecutive refusals dropped answer text. Confirmed. Fixed as you prescribed: both offsets are rebased onto the new bubble's start when it opens. A bubble refused before it accepted anything now resumes exactly where it began, so the worst case stays a visible repeat instead of a silent hole — the tradeoff the docstring already committed to. Pinned by FINDING — warmth in Nor is the consequence a raw 502: But the finding was fair, because three of my own comments claimed the recheck included warmth — Verified: 503 channel/shared tests on 3.12 and 374 on 3.10, mypy 1032 files, flake8, isort, black gate, scrub-lint, brand, harness-parity, docs-lint. Screenshot Evidence is green. |
Rebased onto
|
|
/ai-review override gpt 3c1b430: WeCom |
|
AI-review override not recorded: keep the reason to 500 characters or fewer. |
Not replicating Slack's per-channel YOLO seam here — maintainer call, appliedI had started a WeCom-specific owner-verification gate (require the userid to appear explicitly in That leaves the finding overridden on its merits rather than worked around, and the reasoning is above: the single-owner model is repo-wide and predates this PR on this very channel, it fails closed when the configured value belongs to another channel's id space, and the prescribed remedy would leave a widget-less channel unable to approve any tool. For the record about the shape of the fix I dropped: it was ~5 lines and it would have changed behaviour for an operator running No code change from the previous head other than dropping that in-progress gate; the aged-rotation and cleanup-ownership fixes from the same round are unaffected. |
Rebased again; fixed a red test that came from
|
Both real. One taken as prescribed, one fixed better than prescribed.BLOCKING — the ciphertext cap rejected maximum-sized files. Confirmed, exactly as described.
BLOCKING — a delayed final-frame refusal was treated as delivery. Confirmed, and I found a free fix rather than the prescribed one. You're right that the existing check is nearly useless on its own: it runs microseconds after putting the frame on the wire, so in the normal case no verdict has arrived and a refusal is invisible. I said last round that this window was unclosable; that was wrong — I was looking for a way to wait, and missed that the turn already waits.
I did not take "deliver the final head through the confirmed I also considered a bounded sleep-and-poll before returning, and rejected it: it adds fixed latency to every turn, and it cannot even exit early on success, because "the ACK said 0" and "no ACK yet" are indistinguishable — WeCom is not documented to acknowledge an accepted frame at all. The Residual window, stated plainly: a verdict that arrives after Both mutation-proved in both directions: remove the second look and "the user got nothing and history says they did"; make it unconditional and the no-double-post test fails; revert the cap and the padding-headroom assertion fails. Verification: 535 channel/shared tests on 3.12, 463 on 3.10, mypy 1040 files, flake8, isort, black gate, docs-lint, scrub-lint, brand, harness-parity. Spec updated for both. |
Both real, and the second one was actively defeating last round's fixBLOCKING — the threshold notice stole the answer's ACK attribution. Confirmed, and this is the sharpest finding of the review so far. The notice is sent post-turn, between Fixed as prescribed: the notice goes out via BLOCKING — the per-turn origin bind wrote the session map on the loop. Confirmed. Offloaded exactly as suggested — This is the third instance of the same root cause in this PR — a callee opening Both mutation-proved: put the bind back on the loop and the thread-identity test fails naming the migration; put the notice back on Verification: 731 channel/shared tests on 3.12, 380 on 3.10, full backend suite 62,175 passed / 0 failed on the previous head, mypy 1040 files, flake8, isort, black gate, docs-lint, scrub-lint, brand, harness-parity. Spec updated for both. |
Rebased; the
|
Real, and it is a defect my own previous fix introducedBLOCKING — a late head refusal reversed a long answer. Confirmed. Last round's Fixed by holding the tail rather than by changing how the head is sent: I did not take "send the head and tail through one confirmed proactive sequence", because when overflow exists the live bubble has already been streaming that head — so pushing it again duplicates up to a full bubble of text on every long answer, not just refused ones. Holding the tail costs nothing on the happy path. The trade-off it does cost, stated plainly: the tail now lands after persistence rather than before it, so a crash in that window leaves history holding an answer the reader only partly received — the "history and delivery disagree" failure I called out earlier in this PR as one to avoid. I took it anyway because the window is one local write wide and needs a crash to open, whereas the misordering needed only a refused frame. The alternative I considered was re-sending head AND tail on recovery, which keeps the old timing and only ever duplicates on an observed refusal; it is a one-line switch if you read that trade differently. Three existing tests had to learn the new contract (the tail is released at New Verification: 645 channel/shared tests on 3.12, 381 on 3.10, full backend suite 62,178 passed / 0 failed on the previous head, mypy 1040 files, flake8, isort, black gate, docs-lint, scrub-lint, brand, harness-parity. Spec updated. |
Both real. The first is a stickiness bug in a marker I added two rounds ago.BLOCKING — a stale rejection marker duplicated accepted answers. Confirmed. Retired on two routes, and both are load-bearing for orderings the other cannot reach:
A terminal refusal is never retired: Each of the four paths is independently mutation-proved, and getting there caught a gap in my own testing: my first test passed with the send-side retirement deleted, because it was clearing via the ACK route. Both retirements now have a test that fails when only that one is removed. BLOCKING — the no-stream fallback could deliver a tail with no head. Confirmed. That path used Taken as prescribed, with one addition. No bubble is showing this text on that path, so a push is not a duplicate here (unlike the sealed-bubble path, which is why I declined the push for the head last round) — it now goes out confirmed, Verification: 651 channel/shared tests on 3.12, 387 on 3.10, mypy 1040 files, flake8, isort, black gate, docs-lint, scrub-lint, brand, harness-parity. Spec updated for both. Full backend suite running. |
|
/ai-review override gpt b2dc2f6: WeCom |
Human judgment recorded@bolichen97 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
Reasoning redaction: real, and worse than described. Fixed, but not by reverting the feature.BLOCKING — chunked reasoning could bypass credential redaction. Confirmed, and the severity is higher than the finding says. I checked where reasoning is redacted, and the answer was nowhere on this path — so a credential in reasoning did not even need to be split to go out in clear text. The split case is just the one that also defeats the driver. The mechanism, precisely: Fixed with the pattern already in the repo rather than by removing the feature: redact the JOINED text at the send boundary, which is exactly what Slack's Pinned by This also tripped a gate that deserves credit:
Rebased onto |
Rebased through 44 upstream commits; conflicts resolved.
|
Three frontend checks were red, and none of the three was mine
1. Lint ceiling exceeded (main: 683 warnings against its own My first attempt nested a visually-hidden The workflow says the ceiling "must EQUAL the measured count, not sit above it: slack is silent admission" and to ratchet it down, never up — so 2. Worth noting how, because 3. Verified: Standing note: |
All three real, and two are inconsistencies in my own earlier fixesBLOCKING — a failed head recovery still released the tail. Confirmed. I had added exactly this rule to the no-stream fallback ("a tail without its head is a fragment the reader cannot tell is incomplete") and then not applied it to the recovery path two functions away.
BLOCKING — cancellation during the origin bind orphaned decrypted attachments. Confirmed, and I introduced it. When I offloaded the bind to a thread two rounds ago I made it an BLOCKING — a failed dispatch permanently consumed its dedupe entry. Confirmed. The All three mutation-proved, and the third needed a careful test: my first version of the cancellation fake was Verification: 864 channel/shared tests on 3.12, 366 on 3.10, mypy 1052 files, flake8, isort, black gate, docs-lint, scrub-lint, brand, harness-parity. Spec updated for all three. Full backend suite running. Frontend from the previous round is confirmed green locally: all four shards pass, |
Rebased again — now level with
|
The WeCom channel lost turns on its own wire and used a fraction of what the AI-bot long connection offers. Each item below was verified against the published protocol and cross-checked against Tencent's own SDKs (WecomTeam/aibot-node-sdk, wecom-aibot-python-sdk), not inferred from our code. DELIVERY WAS UNRELIABLE A reply ACK is a cmd-less frame carrying only headers.req_id and an errcode, and the subscribe ack, the pong and every reply receipt share that shape. Told apart by ping id alone, two failures were invisible: - A rejected bot credential. The only symptom was the socket closing at once, which the run loop reports as the generic "closed immediately" -- what an anti-kick also looks like, so an operator with a bad secret was pointed elsewhere. The subscribe req_id now carries a prefix and its ACK is believed. The badge is the documented compensating control for skipping save-time verification, so it has to carry the real reason. - A refused reply. send_stream reports only that a frame reached the socket, so once WeCom sealed a bubble (846608 past its 10-minute lifetime, 846605 unroutable) the renderer kept "succeeding" into it and the rest of the answer, final frame included, was never seen. Terminal codes now mark the stream and the renderer rolls to a fresh bubble, resuming from the frame BEFORE the newest since the refusal is only observed later -- one frame may repeat, which beats a hole. A bubble is also rotated before the 10-minute wall, because an agentic turn runs past it. Neither errcode nor errmsg reaches a log or the badge; errmsg can echo the rejected payload, so only the classification is surfaced. The anti-kick branch could never fire: it matched disconnected_event as a top-level cmd, but it arrives inside aibot_event_callback at body.event.eventtype. WeCom allows one connection per bot, so a replaced connection kept reconnecting and the two took turns evicting each other. Redelivery ran the turn twice. WeCom names msgid as the dedupe key and documents repeats; each cost a second provider round-trip and every tool side effect again. A bounded TTL'd window suppresses it, consulted AFTER authorization so unauthorized traffic cannot evict genuine entries, and never on an absent id. close() cancelled the reconnect task but not the in-flight turn tasks, then closed the aiohttp session they borrow. It now drains them first -- the module's "shutdown is quiescent" invariant, which TeamsClient.close already keeps. _connect_and_serve caught three exception types, so anything else ended the task while _closed stayed False: a dead channel behind a stale green badge. Group traffic is refused and SEL-audited. Sessions are keyed on userid, so a group message ALSO ran inside that user's private DM session -- publishing its history and tool output to the room, and letting the room steer a session believed private. The allow-list cannot help: the sender is allow-listed, the audience is not. Same posture as Webex's direct-rooms-only gate. The shipped doc documented group usage; it now documents the refusal. THE CHANNEL UNDER-USED THE PLATFORM Reply length was capped in CHARACTERS against a 20480-BYTE limit. 20000 characters of Chinese is ~60000 bytes and WeCom rejects the whole frame, so the user got nothing -- on the language this channel exists for. max_message_chars is now bytes//4 (Webex's shape) with truncate_utf8 as the wire guard, and that helper moves from webex/client.py into messaging/split.py so there is one copy, not a third. An over-cap answer is DELIVERED across bubbles via split_markdown_safe rather than truncated: drive_turn persists the full text, so a silent cut left history and delivery disagreeing, and a blind cut can sever a code fence. Acks were invisible. Every ack rode response_url, which the documented aibot_msg_callback body does not carry -- that field belongs to callback-URL mode. /new, /compact, the busy notice and the threshold notices could reach nobody. They now go through client.say(): a fresh stream_id on the inbound req_id, which the WS always supports. Reasoning has a native home -- WeCom renders <think></think> as a collapsed block, so on_thinking streams there instead of dropping it. [OPTIONS:] degrades to a numbered list through the shared format_overflow sink (which display-redacts and defangs mentions) instead of being deleted, which had hidden the choices entirely. The stream throttle moves 0.7s -> 2.0s: WeCom meters 30 messages/minute per conversation and a refresh spends that budget, so the old pace ran ~3x over quota. Proactive push works. aibot_send_msg needs no token and no expiry, only a conversation the user has written to once. supports_proactive_send is now True -- WeCom was the only channel declaring False -- with availability per TARGET: an allow-listed userid that has never written is reported unavailable rather than offered and then failing. Warmth is learned from authorized inbound only, and resolution rechecks membership and warmth at the side-effect boundary. That makes the dashboard mirror, /link and cron delivery reachable; /link and /unlink now bind and release. Commands gained /help (rendered from COMMAND_SPEC so card and parser cannot drift), /stop (cooperative ACP cancel, no ack wait), and the /steer and /queue prefixes; a bare /steer answers with its usage instead of letting the model reply to the literal string. Inbound media arrives. image/file/video carry a ~5-minute CDN url plus their OWN aeskey: AES-256-CBC, PKCS#7 to a 32-byte multiple, IV = the key's first 16 bytes. wecom/media.py owns that protocol work; wecom/attachments.py maps each item onto the shared ingest pipeline so limits, classification and temp-file ownership stay channel-neutral. Deliberately NOT merged with weixin/media.py, which is AES-128-ECB with a shared key -- different mode, length and scope. The aeskey arrives in two encodings for one value, discriminated strictly because guessing wrong yields plausible garbage. The size cap is enforced on bytes READ, never Content-Length. voice is excluded: WeCom returns its own transcript and nothing shipped decodes its codec. A mixed message's caption lives in the item list, and a media-only message is now a message. NOT IMPLEMENTED HERE Each is a capability we do not use yet, not a platform limit, and both docs previously asserted the opposite as fact: template_card buttons (max_buttons stays 0 -- doc 101032 says the interactive types need a callback URL, in tension with long-connection mode, and declaring a widget capability nobody can verify against a live bot is what test_capability_ledger exists to prevent); outbound upload (files_outbound stays False, so an image reference keeps printing its path -- the honest degradation); per-group sessions; enter_chat and feedback_event, each of which owes a reply inside a 5-second single-delivery window. Two wire fixtures record the frame shapes that were misread, with vendor_doc provenance, and the tests read them rather than a hand-written echo of the code.
Two accepted with a better fix than prescribed; one pushed back on, and the fix for the second is what makes the push-back soundBLOCKING — one-shot frames bypassed ACK recovery. Confirmed, both sites.
The second site is the more important one. The Worth noting: BLOCKING — While there: that method logged BLOCKING — "failed turns remain marked as delivered": I'm pushing back, but only because of the fix above. The mechanism is real: The prescribed remedy — "revert the eager deduplication hunk" — reopens the hazard the window exists to close: a redelivery arriving mid-turn runs the whole turn a second time, duplicating every tool side effect. Recording eagerly and releasing on an exception is the correct split, and But the premise "the user was told" only holds if the error notice can actually land — and until this round it could be refused with nothing recovering it. So finding 1's real risk was finding 2's bug, and fixing that is what makes the dedupe behaviour defensible rather than merely arguable. I'd rather say that than override on reasoning that was one bug away from being wrong.
Verification: five behaviours mutation-proved in both directions, 915 channel/shared/posture tests on 3.12, 375 on 3.10, mypy 1052 files, flake8, isort, black gate, docs-lint, scrub-lint, brand, harness-parity. Spec updated. Full backend suite running. |
Scope, stated plainly
This is both a fix pass and a feature pass on the WeCom channel, in one PR at
the maintainer's explicit instruction ("do not defer anything, this PR should fix
all things"). It changes three capability flags —
supports_proactive_sendFalse→True,files_inboundFalse→True,max_message_charscorrected from acharacter count to a byte-safe one — and it ships proactive push, inbound media,
and a wider command set alongside the wire fixes.
An earlier revision of this description claimed the opposite ("no capability flag
changes"; several of these listed as deferred). That was wrong — it described only
the first of two commits that were later squashed — and the First Principles
reviewer was right to block on it. This section is the correction.
Everything was verified against the published protocol and cross-checked against
Tencent's own SDKs (
WecomTeam/aibot-node-sdk,wecom-aibot-python-sdk), notinferred from our code.
Part 1 — the channel lost turns on its own wire
A reply ACK is a cmd-less frame carrying only
headers.req_idand anerrcode; the subscribe ack, the pong and every reply receipt share that shape.Told apart by ping id alone, two failures were invisible:
req_idcarries a prefix, so its ACK is identifiable and believed.send_streamreports only that a frame reached the socket. Once WeCom sealed a bubble (846608past its 10-min lifetime,846605unroutable) the renderer kept "succeeding" into it and the rest of the answer — final frame included — was never seen.The continuation resumes from the frame before the newest, because the refusal
is only observed on a later push — one frame may repeat, which beats a hole.
Neither
errcodenorerrmsgreaches a log or the badge (errmsgcan echo therejected payload); only the classification is surfaced.
disconnected_eventas atop-level
cmd, but it arrives insideaibot_event_callbackatbody.event.eventtype. WeCom allows one connection per bot, so a replacedconnection kept reconnecting and the two took turns evicting each other.
msgidas the dedupe key anddocuments repeats. A bounded TTL'd window suppresses it, consulted after
authorization so unauthorized traffic cannot evict genuine entries, and never on
an absent id.
close()cancelled the reconnect task but notthe in-flight turn tasks, then closed the
aiohttpsession they borrow._connect_and_servecaughtthree exception types; anything else ended the task while
_closedstayedFalse, leaving a dead channel behind a green badge.userid, so agroup message also ran inside that user's private DM session — publishing its
history and tool output to the room, and letting the room steer a session
believed private. The allow-list cannot help: the sender is allow-listed, the
audience is not. Same posture as Webex's direct-rooms-only gate. A
chattypethat is present but malformed maps to a sentinel the gate rejects rather than
collapsing to
single(caught by the GPT reviewer — my first cut failed openthere, and my own test had pinned the wrong behaviour).
Part 2 — the channel under-used the platform
characters of Chinese is ~60000 bytes; WeCom rejects the whole frame, so the user
got nothing — on the language this channel exists for.
max_message_charsis nowbytes // 4(Webex's proven shape) withtruncate_utf8as the wire guard, andthat helper moves from
webex/client.pyintomessaging/split.pyso there is onecopy rather than a third. An over-cap answer is delivered across bubbles via
split_markdown_saferather than truncated:drive_turnpersists the full text,so a silent cut left history and delivery disagreeing, and a blind cut can sever
a code fence.
response_url, which the documentedaibot_msg_callbackbody does not carry — that field belongs to callback-URLmode.
/new,/compact, the busy notice and the threshold notices could reachnobody. They now go through
client.say(): a freshstream_idon the inboundreq_id.aibot_send_msgneeds no token and no expiry, only aconversation the user has written to once.
supports_proactive_sendis nowTrue— WeCom was the only channel declaringFalse, andchat_mirror.pygates the whole mirror leg on it — with availability answered per target: an
allow-listed userid that has never written is reported unavailable rather than
offered and then failing at send time. Warmth is learned from authorized
inbound only; resolution rechecks membership and warmth at the side-effect
boundary; and a failed push raises rather than returning, because a return
reads as delivery to the mirror caller (also from the GPT review).
/linkand/unlinknow bind and release instead of refusing.image/file/videocarry a ~5-minute CDN url plustheir own
aeskey: AES-256-CBC, PKCS#7 to a 32-byte multiple, IV = the key'sfirst 16 bytes. Deliberately not merged with
weixin/media.py, which isAES-128-ECB with a shared key — different mode, key length and key scope. The
aeskeyarrives in two encodings for one value, discriminated strictly becauseguessing wrong yields plausible garbage rather than an error. The size cap is
enforced on bytes read, never
Content-Length.voiceis excluded: WeComreturns its own transcript and nothing shipped decodes its codec. A
mixedmessage's caption lives in the item list, and a media-only message is now a
message — the same invariant Weixin had to fix.
<think></think>collapsed block insteadof being dropped;
[OPTIONS:]degrades to a numbered list through the sharedformat_overflowsink instead of being deleted, which had hidden the choicesentirely; the stream throttle moves 0.7s → 2.0s because WeCom meters 30
messages/minute per conversation and a refresh spends that budget;
/help(froma
COMMAND_SPECthe card renders, so the two cannot drift),/stop(cooperative ACP cancel),
/steerand/queueare wired.Still not implemented, and why
Each is a capability we do not use yet, not a platform limit — and both docs
previously asserted the opposite as fact. Corrected here:
template_cardinteractive buttons —max_buttonsstays0. Doc/101032says the interactive card types require a configured callback URL, which is in
tension with long-connection mode. Declaring a widget capability nobody can
verify against a live bot is exactly what
test_capability_ledger.pyexists toprevent, so this needs one live-bot probe, not more code.
aibot_upload_media_*sequenceneeds request/response correlation the client does not have.
files_outboundstays
False, so an image reference keeps printing its path: the honestdegradation.
enter_chat/feedback_event— recognized and dropped; each owes a replyinside a 5-second single-delivery window.
Found and deliberately left alone because they loosen permissions or span
channels:
agent.approval_mode = "trust"collapses to deny-all on WeCom andWebex (identical
_resolve_approval_mode), and the settings panel claims"Verified with WeCom and saved" when nothing was verified.
Review dispositions
chattypefails open: fixed. Real, and the more serious ofthe three: it re-opened the exact leak the group gate exists to close.
/linkbatched map write on the event loop: kept, with the rationalecomment it was missing.
telegram/transport_dispatch.py::_handle_linkhas theidentical shape and documents why: the write is bounded (one whole-map rewrite,
driven by a user typing a command, not by traffic) and ordering comes from
session_map._MAP_LOCK, not the loop. The suggested remedy was to revert/link//unlink, which would remove the feature to avoid a pattern the repoalready sanctions. Happy to move both off-loop in a follow-up that moves
Telegram's too, since they should not diverge.
stated plainly". Its remedy (split into three PRs) is sound review advice and I
would normally take it, but single-PR scope here is an explicit maintainer
instruction.
is_groupsurface: removed. Groups failclosed, so
chat_type: 2was unreachable;send_proactiveno longer takes theparameter and
_warm_chatsis a set./queueonly refuses itself: kept. It is not only arefusal —
parse_mid_turn_overridestrips the directive, so without it/queue do Xreaches the model as literal text including the prefix and getsanswered as chat. The refusal branch exists because WeCom genuinely cannot hold
a message: a reply is addressed by the inbound
req_id.eventtypecame offthe wire and was interpolated into a log line; the value is now never logged,
matching the rule this module already keeps for
cmdanderrcode.# nosemgrepand a reason.CBC is not our choice: WeCom hands us objects it has already encrypted that way
and we never encrypt with it, so there is no AEAD mode to switch to — the
alternative is not decrypting the user's screenshot at all.
weixin/media.pycarries the equivalent suppression for its ECB decrypt.
Testing
Two new suites —
test_wecom_wire_reliability.pyandtest_wecom_media.py— plustwo wire fixtures under
test/fixtures/channels/wecom/recording the frame shapesthat were misread, with honest
vendor_docprovenance; the tests read thoserather than a hand-written echo of the code. The media crypto is pinned by a real
round-trip (encrypt with the protocol's construction, assert we decrypt it),
including the 32-byte pad boundary and both
aeskeyencodings.prove.pyis INCONCLUSIVE here: reverting the production hunks removes constantsthe tests import, so everything fails at collection — that tool's documented blind
spot. Proved manually with six surgical mutations, each failing exactly its own
tests:
TestGroupChatFailsClosed2 failedalready_deliveredalways FalseTestRedeliveryIsSuppressed3 failedaibot_event_callbackbranch removedTestAntiKick1 failedclose()TestShutdownIsQuiescent1 failedstream_is_deadalways FalseTestUnexpectedFaultReconnects1 failedLocal gates: backend
pytest61,428 passed / 0 failed;mypy1030 files;isort;flake8; black (baselined — 4 now-clean files pruned);scrub-lint;brand; harness-parity;
docs-lint;tsc -b;vitest22,872 passed with 1pre-existing flake (
DesignTweakPreviewCov80) that passes in isolation andcannot be ours —
website/is byte-identical toorigin/mainhere (0 fileschanged).
No screenshots
Nothing to screenshot: this PR changes no
website/file, and every user-visiblesurface it touches is inside WeCom itself, which needs a real WeCom tenant and bot
credentials to exercise. I would rather say that than stage a mock-up of a chat
that was never sent.
Overlap with open PRs (216 open PRs checked)
Two also rewrite
WeComClient.close(), both for a different defect — ensuringthe
aiohttpsession closes even when an earlier step raises:fix(messaging): always close the transport's aiohttp session on shutdownfix(channels): close the aiohttp session even when the background task diedNeither drains the in-flight turn tasks, so this is orthogonal, not duplicate. The
drain lives in its own
_drain_handler_tasks()step so either restructuringabsorbs it with a one-line resolution.
Also overlapping: #4670 (
wecom/renderer.py,wecom/transport.py) and#3561 / #3754 / #4282 on
docs/system-specs/modules/messaging.md. #4670 and aparallel local effort also touch the byte-cap and OPTIONS-as-text work; if that
lands first, the
truncate_utf8hoist here is the piece to reconcile.Visual evidence
Why no screenshot: the only file this PR touches under
website/src/iscomponents/CliPanel.tsx, and the change is when aMutationObserverisdisconnected —
destroyTermreleases it oncetermCacheis empty andensureThemeObserverre-arms it on the next mount. Nothing rendered changes: theterminal keeps exactly the palette, fonts, layout and behaviour it had, which is
the point — the fix protects theme sync from silently stopping, it does not alter
what theme sync produces. A before/after image would be two identical terminals.
The behaviour is pinned instead by two tests that fail on the two ways this can
regress (release while another terminal is still cached; release without
re-arming), and by
CliPanelCoverage.test.tsxgoing 41/41 → 43/43.The WeCom surface itself cannot be screenshotted from here, and I am not going
to fake it. Rendering a WeCom bubble requires a real WeCom enterprise tenant
with a provisioned AI bot, its
bot_id/secret, and a Weixin Work client signedinto that tenant; the long-connection protocol has no local emulator and there is
no capture harness for it in this repo. What the channel renders is verified
against the protocol instead: the 20480-byte cap and its
//4chardeclaration, the native
<think></think>reasoning block,[OPTIONS:]degradingto a numbered list, the sealed-bubble roll, and the paced overflow push each have
their own test, plus two fixtures captured from the vendor documentation
(
event_callback_disconnected.json,reply_ack_stream_expired.json).