Quoted message prefix was prepended directly to Content, making
the first character not '/' and breaking command parsing. Move the
quoted prefix to ExtraContent which is merged after command detection.
Made-with: Cursor
Implement Weibo private message platform via the Open IM WebSocket
protocol (open-im.api.weibo.com). Supports app_id/app_secret auth,
automatic token refresh, heartbeat keep-alive, reconnect with
exponential backoff, message dedup, and chunked sending for long
messages (2000 char limit).
Includes plugin registration, build tag (no_weibo), unit tests,
setup documentation, and cross-references in all platform docs.
Made-with: Cursor
Trace parent_id links up to 5 levels deep when a user replies to a message,
providing the full conversation chain as context for the agent.
- Add fetchSingleMessage() to retrieve a single message with parent_id
- Add fetchReplyChain() for iterative chain traversal with cycle detection
- Add formatReplyChain() with backward-compatible single-message format
- Refactor fetchQuotedMessage() as a thin wrapper
- Show 'Bot' for app senders, 'User' for unresolved user names
Co-authored-by: MeiShaoqing <meishaoqing@xiaomi.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the agent's response is delivered via in-place card update (Feishu
streaming preview), the user gets no push notification. This adds a
configurable done_emoji option (e.g. "Done") that adds a reaction after
the "OnIt" reaction is removed, so the user knows the agent finished.
Only activates in quiet mode when the preview card was updated in-place
at least once (via UpdateMessage). Non-quiet mode is unaffected as
intermediate messages already trigger push notifications. Single-round
responses that only use SendPreviewStart are also unaffected, as the
initial send already triggers a push notification.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
buildReplyContent previously used a three-tier strategy: card only for
"complex" markdown (code blocks/tables), post for everything else. This
caused most markdown content (headings, blockquotes, links, dividers,
lists) to be rendered via post format, which has poor markdown support —
links aren't clickable, blockquotes don't render, dividers are ignored.
Simplify to two paths:
- Has markdown → card (best rendering, supports all markdown features)
- No markdown → text
- Fallback to post md tag only when table count exceeds card limit (>5)
Removes the hasComplexMarkdown gate and the buildPostJSON path from
the rendering decision. hasComplexMarkdown and buildPostJSON are kept
as functions for backward compatibility but are no longer called by
buildReplyContent.
Co-authored-by: leoliang <leoliang@futunn.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
H1: Prevent workspace path traversal via ../
Add resolveLocalDirPath() that validates resolved path stays under
baseDir, expands ~/... via os.UserHomeDir, rejects escaping paths.
M2: Sanitize sender_name in inject_sender header
Replace quotes with single quotes, strip newlines/CR to prevent
prompt injection via display names.
M3: Warn on unset env var placeholders in config
resolveEnvPlaceholders now logs slog.Warn when ${VAR} references
an environment variable that is not set.
M4: Validate WeCom api_base_url
Reject non-http(s) or malformed URLs at New() to prevent credential
exfiltration via misconfigured base URL.
M5: Quote systemd Environment= values
Wrap values in double quotes to handle spaces, #, and special chars
in proxy URLs and paths.
M7: Discord table detection now requires separator row
Only wrap pipe-delimited blocks that contain a markdown separator
row (| --- | --- |), reducing false positives on decorative pipes.
Made-with: Cursor
H1: Prevent double-Close on concurrent cleanupInteractiveState calls
by nil'ing state.agentSession under the lock before closing.
H2: Weixin StartTyping sends typingStatusStop on ctx.Done() and on
repeat-failure exit, preventing stuck "typing" indicator.
M3: Feishu @mention resolution: downgrade PII log to Debug, HTML-escape
display names in at tags, skip ambiguous (duplicate) names.
M4: Narrow segmentStart==0 condition in EventResult to only apply when
tool messages are hidden (!e.display.ToolMessages).
M5: Log warning when cron workspace agent creation fails instead of
silently falling back to global agent.
M7: Discord file download capped at 50 MiB via io.LimitReader.
M8: Weixin getConfig now checks ret/errcode fields and returns error
on non-zero values.
Made-with: Cursor
When resolve_mentions is enabled in feishu platform options, outgoing
messages are scanned for @displayName and replaced with native Feishu
at tags by reverse-matching against the group member list.
- New config option: resolve_mentions (default false)
- Lazy-load group members via chat members API with 1h cache TTL
- Match names longest-first to avoid partial matches
- Auto-select at syntax based on message type (text vs card)
- No special markup needed — just @name in natural text
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Weixin platform now implements core.AudioSender interface, enabling TTS
audio output for WeChat personal chat via ilink API.
- Added ConvertAudioToAMR in core/speech.go for audio conversion
- Implemented SendAudio in platform/weixin/media_outbound.go
- Converts WAV/MP3/TTS output to AMR format (Weixin voice codec)
- Uploads via CDN and sends as voice message type
Fixes#587: TTS audio was generated but silently dropped because
weixin did not implement AudioSender interface.
Test plan:
- go build ./... passes
- go test ./... passes
- Unit tests for SendAudio edge cases (empty audio, invalid context)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Synchronous resolveUserName/resolveChatName calls in the SDK dispatcher
callback blocked the event loop for 300-700ms on cache misses, causing
intermittent message drops in WebSocket mode.
The fix moves name resolution into the dispatchMessage goroutine, ensuring
the SDK dispatcher can process events without blocking. The dedup and
old-message checks remain synchronous for correctness.
Fixes#542
Co-authored-by: Claude <noreply@anthropic.com>
* feat(observe): add --observe CLI flag and engine config
Add observe fields to the Engine struct and a SetObserveConfig setter
to enable terminal session observation in a later task. Define --observe
and --observe-channel CLI flags (wiring deferred).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(observe): add ObserverTarget interface, implement for Slack
Define ObserverTarget optional interface in core/ for platforms that can
receive terminal observation messages. Implement SendObservation on Slack
platform using PostMessageContext with link unfurl disabled.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(observe): implement JSONL session file watcher and parser
Adds sessionObserver to core/observer.go which tails ~/.claude/projects/{projectKey}/*.jsonl
files, parses user/assistant events, filters out sdk-cli sessions (cc-connect's own),
and forwards new messages to ObserverTarget. Includes full test coverage.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(observe): wire observer into engine startup and shutdown
Add findObserverTarget to locate the first ObserverTarget platform,
startObserver to launch the JSONL watcher goroutine when configured,
wired into Engine.Start() and cancelled in Engine.Stop().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(observe): wire --observe and --observe-channel flags in CLI
Removes the TODO suppressor and connects the --observe/--observe-channel
CLI flags to engine.SetObserveConfig(), resolving the Claude Code project
directory from the configured work_dir via the new resolveClaudeProjectDir helper.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(observe): add config.toml support for observe settings
Add ObserveConfig struct to ProjectConfig so users can configure terminal
session observation via [projects.observe] in config.toml, with CLI flags
(--observe, --observe-channel) taking precedence over config file values.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(observe): rune-safe truncation, robust offset tracking, interface assertion
- Use file size for offset tracking instead of line-length calculation
to avoid drift from \r\n line endings or partial final lines
- Truncate messages at valid rune boundaries to prevent garbled UTF-8
- Add compile-time ObserverTarget interface assertion for Slack
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Leigh Stillard <leigh@stillard.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Implement the core.TypingIndicator interface for the Weixin (personal
WeChat) platform, showing a "typing..." bubble while the agent is
processing.
- Add getConfig API call to fetch typing_ticket from ilink
- Add sendTyping API call (start/stop) to ilink/bot/sendtyping
- Cache typing tickets per peer with 10-minute TTL
- Proactively refresh ticket on inbound message receipt
- Repeat typing status every 5 seconds (matching Telegram behavior)
- Send best-effort stop signal when processing completes
Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
Previously the Discord platform only handled audio and image attachments
from incoming messages. Non-image, non-audio attachments (PDFs, documents,
code files, etc.) were silently dropped. This adds an else branch in the
attachment loop to capture them as core.FileAttachment, matching the
behavior already present in the Telegram platform.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(feishu): format TodoWrite tool input as readable list
Parse TodoWrite JSON input and display as a formatted list with status
icons (✅ completed, 🔄 in_progress, ⏳ pending) instead of raw JSON.
- Added isTodoWriteToolName() to detect TodoWrite tool
- Added formatTodoWriteInput() to parse and format todos
- Shows content, status icon, and activeForm for each todo
- Falls back to default formatting if JSON parsing fails
Fixes task t-20260401-3c8no7
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(feishu): TodoWrite fallback uses text block instead of inline code
When formatTodoWriteInput returns empty (invalid JSON or empty todos),
return a ```text block directly instead of falling through to the
generic formatter which uses inline backticks for short strings.
Fixes CI test failures for TestFormatProgressToolInput.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
When ilink API returns ret=-2, it typically indicates:
- context_token expired or invalid
- temporary rate limiting
This fix adds:
- Retry mechanism (3 attempts) for sendMessage when ret=-2 is returned
- context_token refresh between retries
- Delay between message chunks (100ms) to avoid rate limiting
- Delay between retries (500ms) for temporary failures
- More detailed logging including content_len for debugging
Fixes#365
Co-authored-by: Claude <noreply@anthropic.com>
Discord allows at most 100 application commands per bulk overwrite
(guild or global). The previous limit of 200 caused silent HTTP 400
failures when users had >100 commands. Now truncates at 100 and logs
the count of dropped commands.
Closes#504
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add reply enrichment: when user replies to a message, the original
message content (text, image, voice, file, location) is included
as context for the AI agent
- Add location message support: convert Telegram location attachments
to text content that AI agents can understand
- Add ExtraContent field to core.Message for platform-enriched context
- Add LocationAttachment type to core.Message
- Refactor dispatchMessage to support platform-specific enrichment
When Feishu WebSocket connections experience rapid resets, HTTP API calls
for sending messages also fail with transient network errors (EOF,
connection reset, timeout). Previously these errors were not retried,
causing messages to be silently lost.
Add withTransientRetry wrapper with exponential backoff + jitter
(500ms→1s→2s, max 3 retries, +25% jitter) around all Feishu API calls:
reply, create, patch, delete, upload image/file/audio, and preview.
Transient detection uses typed syscall checks (ECONNRESET, EPIPE),
net.Error timeout, io.EOF, and string matching as fallback.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(weixin): handle upload_full_url in getUploadUrl response
WeChat iLink API changed the getUploadUrl response format: it now
returns `upload_full_url` (a complete CDN URL with `encrypted_query_param`
as a query parameter) instead of the old `upload_param` field.
This caused all file/image uploads to fail with "empty upload_param"
because cc-connect only checked the old field name.
Fix: fallback to parsing `encrypted_query_param` from `upload_full_url`
when `upload_param` is empty.
Fixes#369
Ref: wong2/weixin-agent-sdk#21, hao-ji-xing/openclaw-weixin#11
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(weixin): fix CDN upload with three issues
1. Handle upload_full_url in getUploadUrl response
WeChat iLink API returns upload_full_url instead of upload_param.
Extract encrypted_query_param as fallback, and use upload_full_url
directly for CDN uploads to avoid URL re-encoding issues.
2. Use dedicated no-proxy HTTP client for CDN uploads
WeChat CDN (novac2c.cdn.weixin.qq.com) is a China-domestic service
that times out when accessed through a proxy. Create a separate
HTTP client with Proxy: nil for CDN operations.
3. Fix AES key encoding in sendMessage
The aes_key field must be base64(hex_string), not base64(raw_bytes).
WeChat client expects base64-encoded hex representation of the key,
matching the official openclaw-weixin plugin behavior.
Fixes#369
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address adversarial review findings
1. Make upload_full_url a standalone success path in getUploadURL.
No longer require extracting encrypted_query_param from
upload_full_url — if the server returns an opaque/signed URL,
it is accepted as-is. Only fail when both upload_param and
upload_full_url are empty.
2. Respect configured proxy for non-WeChat CDN hosts.
Only bypass proxy when upload_full_url points to a known WeChat
domestic CDN domain (*.weixin.qq.com, *.wechat.com). For custom
cdn_base_url or other hosts, the configured httpClient (with
proxy settings) is preserved.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: simplify CDN upload — remove duplicate function, extract helper
- Remove uploadBufferToCDNDirect (50-line duplicate of uploadBufferToCDN)
- Unify uploadBufferToCDN to accept a pre-built URL (caller handles URL construction)
- Extract formatAesKeyForAPI() helper for the base64(hex(key)) encoding
- Add cdnHttpClient comment clarifying why env proxy bypass is needed
Net -43 lines.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test(weixin): add unit tests for CDN upload fixes
- TestFormatAesKeyForAPI: verify base64(hex) output matches Python SDK
- TestFormatAesKeyForAPI_NotRawBase64: regression guard against old bug
- TestFormatAesKeyForAPI (round-trip): verify formatAesKeyForAPI ↔ parseAesKey
- TestIsWeixinCDNHost: table-driven test for WeChat CDN domain detection
- TestGetUploadURLResponse_Validation: table-driven test for upload_param/upload_full_url acceptance logic
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test(weixin): add unit tests for CDN upload fixes
- TestFormatAesKeyForAPI: verify base64(hex(key)) output matches Python SDK
- TestFormatAesKeyForAPI_NotRawBase64: ensure old bug format is rejected
- TestIsWeixinCDNHost: table-driven test for CDN host detection
- TestGetUploadURLResponse_Validation: verify both-empty-fails, either-present-passes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: jiangbowen <robin@jiangbowendeMacBook-Air.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- PR #445: auto-compress skips when agent has no CompressCommand;
--max-context-tokens is now configurable via agent options instead
of being hardcoded to 900000
- PR #448: codex app-server RPC timeout cleans pending map entry;
emit() uses non-blocking send to prevent readLoop stall;
readLoop uses scanner with 10MB max line size
- PR #462: restore backward-compatible quiet config — maps to
display.thinking_messages/tool_messages when [display] not set
- PR #479: Gemini STT sends API key via x-goog-api-key header
instead of URL query param; model name is URL-escaped
- PR #489: feishu fetchQuotedMessage uses caller context instead
of context.Background; extractPostPlainText includes code_block
- PR #447/#474: SendToSessionWithAttachments uses workspace-prefix
stripping fallback matching cron/heartbeat logic
- PR #446: PATH prepend for custom commands limited to Windows only
All changes are backward-compatible; new behavior gated by config.
Tests added for quiet config, workspace prefix, Gemini header, and
feishu code_block extraction.
Made-with: Cursor
Adds SendFile to the Slack platform, mirroring SendImage. Both use
Slack's UploadFileV2Context, which already handles arbitrary file
types. The engine routes attachments from `cc-connect send --file`
(invoked by agents via the system-prompt-documented CLI) to this
method via the core.FileSender interface assertion.
https://claude.ai/code/session_01WyqXEst7Z8tiMbzeh4rTfX
Co-authored-by: Claude <noreply@anthropic.com>
* feat(feishu): include replied-to message content in agent context
When a user replies to a specific message in a Lark/Feishu chat,
the bot now fetches the parent message content via Im.Message.Get
and prepends it as a quoted context block, so the agent can see
what the user is referring to.
- Add fetchQuotedMessage() that retrieves and formats the parent
message content for text, post (rich text), and other message types
- Add extractPostPlainText() helper for parsing Lark post JSON
(supports both flat and locale-wrapped formats)
- Graceful degradation: on any API failure the quote is silently
skipped and the user's message is delivered as before
- Only triggers when ParentId is non-empty (reply scenario)
- Uses the same Im.Message.Get API already used by parseMergeForward
Closes#470
* feat(feishu): extract full interactive card content in quoted messages
Use raw API call with card_msg_content_type=raw_card_content to retrieve
the complete card JSON instead of the simplified final state. Add
extractInteractiveCardText and extractCardElements to recursively parse
schema 2.0 card structures including code blocks, markdown, divs,
text tags, and nested elements. Falls back to legacy format (direct
title + flat elements) for older cards.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(feishu): handle table, list and simplified body format in card extraction
The Feishu server transforms markdown card content into structured
schema 2.0 elements. Tables become "table" elements with columns/rows,
lists become "list" elements with items, and the body may use direct
"elements" instead of "property.elements". Also support top-level
"content" field on elements (simplified card format from buildCardJSON).
- Add extractCardTable: reconstructs markdown table from columns/rows
- Add extractCardListItems: extracts list items with bullet points
- Handle body.elements (simplified) in addition to body.property.elements
- Read elem.Content as fallback when elem.Property.Content is empty
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Cigarrr <cigarrr@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
When a user sends a message, the bot immediately reacts with ⚡ to
signal that the message has been received and is being processed.
Changes:
- Add SetMessageReaction to telegramBot interface
- Add reactToMessage helper that calls setMessageReaction asynchronously
- Call reactToMessage in handleMessage after building reply context
- Add setReactionCalls counter to stubTelegramBot in tests
* fix(feishu): respond to @all messages in group chats
Feishu @all sends {"text":"@_all"} with 0 mentions, which was being
ignored when group_reply_all=false. Add respond_to_at_everyone_and_here
option to allow bots to respond to @all messages.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(relay): prevent agent session corruption on timeout
Three issues caused agents to become unresponsive after a relay timeout:
1. StartSession received the relay timeout context -- when the deadline
fired, exec.CommandContext killed the Claude process mid-turn,
corrupting the session state. Now uses the engine context instead.
2. defer agentSession.Close() killed the process immediately on return.
Now the session drains in a background goroutine (drainRelaySession)
so the agent can finish its turn and save state cleanly.
3. No fallback when resuming a corrupted session. Added retry logic that
clears the stale session ID and starts fresh, matching the existing
interactive message flow.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: pinxi_mei <pinxi_mei@lebo.cn>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Discord Gateway and API connections can now be routed through an HTTP
proxy, configured via `proxy`, `proxy_username`, and `proxy_password`
options — matching the existing Telegram proxy support.
Made-with: Cursor
* feat(telegram): migrate to go-telegram/bot and add forum topic support
Migrate the Telegram platform from the unmaintained go-telegram-bot-api/v5
(last release 2021) to go-telegram/bot v1.20.0, which is actively maintained
and supports the full Telegram Bot API including forum topics.
Key changes:
- **Library migration**: Replace go-telegram-bot-api/v5 with go-telegram/bot.
Redesign the telegramBot interface around the new library's typed API methods
(SendMessage, SendPhoto, EditMessageText, etc.) instead of the old generic
Send/Request pattern. The *bot.Bot type satisfies the interface directly.
- **Forum topic support**: Add threadID to replyContext and session key.
Messages in forum groups (IsForum=true) now include the MessageThreadID
in the session key (telegram:{chatID}:{threadID}:{userID}), giving each
topic its own independent agent session. All send methods propagate
MessageThreadID so replies land in the correct topic. Non-forum groups
and private chats are unaffected.
- **Per-topic workspace binding**: Add ChannelKey field to core.Message so
platforms can provide an explicit channel identifier for workspace binding.
Telegram sets this to "chatID:threadID" for forum topics, enabling
multi-workspace mode to bind different topics to different project
directories. Add effectiveChannelID/effectiveWorkspaceChannelKey helpers
and update all workspace resolution call sites in the engine.
- **Skill command registration fix**: GetAllCommands now respects
disabled_commands for skills, matching the existing behavior for
built-in commands. This prevents disabled skills from being registered
as Telegram bot menu commands.
- **Connection management**: Simplify the reconnect architecture.
bot.New() handles initial validation, bot.Start(ctx) manages long
polling internally. Remove the stale bot detection (generation
comparison in handlers) since the new library's blocking Start()
ensures handlers are never called from a superseded bot instance.
Signed-off-by: Buqian Zheng <zhengbuqian@gmail.com>
* fix: resolve lint errors exposed by engine.go and telegram_test.go changes
- Remove unused assignment to `s` in cmdNew (SA4006)
- Remove unused stubBotFactory and failingBotFactory helpers in telegram tests
Signed-off-by: Buqian Zheng <zhengbuqian@gmail.com>
---------
Signed-off-by: Buqian Zheng <zhengbuqian@gmail.com>
* feat(wecom): handle inbound file messages over HTTP callback
Parse MsgType=file (FileName + MediaId), download via media/get API,
and forward as core.FileAttachment for agents. Add MIME inference helper
and unit tests. Document inbound capabilities in config.example.toml.
WebSocket AI bot mode unchanged; file receive there depends on WeChat policy.
* chore(wecom): add Info/Warn logs for callback diagnostics
Log every POST (size, query flags), parsed msg_type/msg_id/from_user,
duplicate/old/allow_from drops, unsupported types at Warn, and Debug
preview of decrypted XML (truncated). Helps trace missing file pushes.
* feat(wecom-ws): receive image, file, mixed and quote media over AI bot WebSocket
Parse msgtype image/file/mixed and quote (text, voice, image, file, mixed)
per WeCom smart-bot protocol. Download encrypted media from signed URLs
and decrypt with AES-256-CBC (IV = first 16 bytes of base64 key), matching
@wecom/aibot-node-sdk. Voice accepts both content and legacy text fields.
Adds wsCollectInboundParts, deliverWSMediaInbound, unit tests for collect,
Content-Disposition parsing, and round-trip decryption.
* fix(wecom-ws): handle file in mixed payloads and normalize aeskey decoding
- Collect file items inside mixed.msg_item and merge top-level file/image
when msgtype is file/image but a non-empty mixed block is also present.
- decodeWeComAESKey: trim whitespace, URL-safe base64, padding, optional hex.
- Add regression tests for mixed+file and aeskey variants.
* fix: satisfy golangci-lint (gosimple, staticcheck)
- wecom: use := for walkMixed/walkQuote closures (S1021).
- engine: drop unused assignment from sessions.NewSession in cmdNew (SA4006).
---------
Co-authored-by: Claude <noreply@anthropic.com>
Feishu interactive cards support at most 5 table components.
When the agent response contains more than 5 markdown tables,
buildReplyContent now falls back to post (md tag) format instead
of card format, preventing API error 11310 (card table number
over limit).
Add countMarkdownTables helper and corresponding tests.