Skip to content

fix: harden feishu plugin error handling - #58

Closed
NeverMore93 wants to merge 6 commits into
mainfrom
codex/release-1.7.8
Closed

fix: harden feishu plugin error handling#58
NeverMore93 wants to merge 6 commits into
mainfrom
codex/release-1.7.8

Conversation

@NeverMore93

@NeverMore93 NeverMore93 commented Apr 9, 2026

Copy link
Copy Markdown
Owner

Summary

  • lower entropy across the Feishu/OpenCode handler pipeline and remove dead or duplicated code paths
  • add consistent error-level logging for exception handling and sender failure paths
  • bump the package version to 1.7.8

Changes

File / Area Change
src/handler/* Simplified action/event flow, improved session queueing, and hardened chat/interactive error handling
src/feishu/* Removed duplicate send/content logic, added comments, and added error logging around CardKit, sender, resources, history, quote, and identity paths
src/session.ts, src/types.ts Tightened shared session/config/type contracts and removed stale or unused paths
package.json, package-lock.json Bumped release version to 1.7.8

Test Plan

  • npm run typecheck
  • npm run build
  • npm publish --dry-run

Summary by CodeRabbit

  • New Features

    • Unified per-session FIFO for direct & group chats; configurable idle "nudge" injects synthetic prompts; session invalidation and richer session→chat info.
  • Bug Fixes

    • Improved error reporting and logging, more robust message parsing/deduplication, stricter resource download size checks, safer placeholder handling and recovery retries.
  • Documentation

    • Clarified configuration, nudge/idle semantics, history intake, system-prompt fallback, and message-flow behavior.
  • Chores

    • Package version bumped.

@NeverMore93

Copy link
Copy Markdown
Owner Author

/gemini review

@coderabbitai

coderabbitai Bot commented Apr 9, 2026

Copy link
Copy Markdown

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8b2c3760-0f70-43ed-913a-e12233bcbf10

📥 Commits

Reviewing files that changed from the base of the PR and between 76c4072 and 4ac8205.

📒 Files selected for processing (2)
  • src/feishu/gateway.ts
  • src/handler/interactive.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/feishu/gateway.ts

📝 Walkthrough

Walkthrough

Unifies Feishu message handling to a per-sessionKey FIFO queue, moves post-idle prompting to an event-driven nudge, tightens logging/error handling, threads LogFn broadly, adds session-poison detection/invalidation, and updates extraction, download, sender, and session mapping behaviors and docs.

Changes

Cohort / File(s) Summary
Docs & Manifest
AGENTS.md, CLAUDE.md, README.md, package.json
Docs updated to describe unified per-session FIFO queue, nudge replacing autoPrompt, dedup/dedupTtl/maxHistoryMessages/poll semantics clarified; package version bumped to 1.7.8.
Session Queue & Core Chat
src/handler/session-queue.ts, src/handler/chat.ts, src/handler/event.ts, src/handler/action-bus.ts
Implements per-sessionKey FIFO queue, queue bypass for shouldReply===false, resilient drain-loop (per-item error handling), emit(..., log?), EventDeps requires nudge, adds isSessionPoisoned and session invalidation flows.
Interactive Actions & Gateway
src/handler/interactive.ts, src/feishu/gateway.ts
Adds parseCardActionValue typed payload parsing, centralizes request-card send with dedupe/rollback, gateway reconciles send_message payload/chatId, resolves chatType, synthesizes FeishuMessageContext, and uses buildCallbackResponse(action, log).
Sender, CardKit & Streaming Card
src/feishu/sender.ts, src/feishu/cardkit.ts, src/feishu/streaming-card.ts
Thread log into send APIs and wrapSendCall; consolidate chat message creation via helper; CardKit createCard extracts HTTP body for richer errors; streaming-card passes log on sends/deletes and removes public currentMessageId getter.
Content Extraction, Resource Download & Markdown
src/feishu/content-extractor.ts, src/feishu/resource.ts, src/feishu/markdown.ts, src/feishu/quote.ts, src/feishu/history.ts
Refactors extraction to dispatch+normalization, raises several logs to error, stream-based resource download with early abort on oversize and richer DownloadResult, UTF-8 byte-accurate markdown truncation preserving code fences; history/quote now pass log.
Session Management & Mapping
src/session.ts, src/feishu/session-chat-map.ts
Removes getCachedSession, adds invalidateSession, getOrCreateSession now prefers remote title-prefix lookup before create; session-chat-map stores chatType and exposes getChatInfoBySession.
Error Recovery & Diagnostics
src/handler/error-recovery.ts, src/handler/event.ts
Clarifies recovery flow and retry bookkeeping, tryModelRecovery early-returns for non-model errors, clears cached SSE errors before retrying, elevates error logging and documents recovery semantics.
Utilities: TTL, Dedup, User & Group Helpers
src/utils/ttl-map.ts, src/feishu/dedup.ts, src/feishu/user-name.ts, src/feishu/group-filter.ts
Expanded TTL docs, timer hygiene (unref/clear), dedup TTL documented (default 10m) and init semantics, username resolution now cache-first with 24h TTL and error logging at error, bot-mention doc clarified.
Tools & Send-Card DSL
src/tools/send-card.ts, src/feishu/cardkit.ts
createSendCardTool forwards log to sender, failure logging escalated; button payloads use structured actionPayload for normalized parsing.
Types & Entrypoint
src/types.ts, src/handler/action-bus.ts, src/index.ts
Expanded JSDoc for NudgeSchema/config, FeishuConfigSchema and Feishu types; trimmed ProcessedAction union and removed some emitted fields; index adds config loading, env placeholder resolution, and system-prompt injection logic.

Sequence Diagram

sequenceDiagram
    participant Gateway as Feishu Gateway
    participant Queue as Session Queue
    participant Chat as Chat Handler
    participant ActionBus as Action Bus
    participant Event as Event Handler
    participant Nudge as Nudge (idle)

    Gateway->>Queue: enqueueMessage(ctx, deps)
    alt ctx.shouldReply === false
        Queue->>Chat: handleChat(ctx, deps, signal?)
    else reply required
        Queue->>Queue: push into FIFO per sessionKey
        Queue->>Queue: start drainLoop if not running
        Queue->>Chat: handleQueuedMessage (drain iteration)
    end

    Chat->>ActionBus: emit(sessionId, action, log?)
    ActionBus->>Event: notify subscribers
    Event->>Event: handleV2Event / handleMessagePartUpdated

    alt session.idle detected
        Event->>Nudge: check nudge config & iterations
        Nudge->>Chat: handleChat(nudge synthetic payload, deps, signal?)
        Chat->>ActionBus: emit(sessionId, { type: "session-idle" }, log?)
    end
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly Related PRs

Poem

🐰 I nibble logs and tidy queues,

FIFO hops replace old cues.
Idle nudges whisper, not shout,
Errors flagged, the bugs peep out.
One-session-at-a-time—order sprouts!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: harden feishu plugin error handling' directly describes the main objective of the PR, which is to strengthen error handling across the Feishu plugin with consistent error-level logging, improved exception handling, and code cleanup.
Docstring Coverage ✅ Passed Docstring coverage is 99.03% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/release-1.7.8

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request significantly improves the robustness, observability, and error handling of the Feishu plugin. Key changes include migrating from warn to error logs for critical failures, implementing a session poisoning detection mechanism to automatically reset corrupted sessions, and refining the streaming card lifecycle management. I have reviewed the changes and suggest upgrading the log level for session poisoning detection to error to improve visibility into this critical failure mode.

Comment thread src/handler/chat.ts Outdated
Comment on lines +321 to +323
log("warn", "检测到 session 历史数据中毒,创建新 session", {
sessionKey, oldSessionId: session.id, error: sessionError.message,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

为了与本次 PR 的目标“加强错误处理和日志记录”保持一致,建议将此处的日志级别从 warn 提升到 error。会话历史中毒并因此强制创建新会话是一个值得关注的异常情况,使用 error 级别能更好地在日志系统中突出显示此类事件,便于后续追踪和分析。

Suggested change
log("warn", "检测到 session 历史数据中毒,创建新 session", {
sessionKey, oldSessionId: session.id, error: sessionError.message,
})
log("error", "检测到 session 历史数据中毒,创建新 session", {
sessionKey, oldSessionId: session.id, error: sessionError.message,
})

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/feishu/content-extractor.ts (1)

29-73: ⚠️ Potential issue | 🟠 Major

Normalize empty extractor results before returning.

This function documents that it returns at least one PromptPart, but some branches now return [] (extractText() for blank text, and the new extractPost() parse-failure path). That can silently drop the Feishu message entirely and may violate downstream request validation that expects a non-empty parts array.

Proposed fix
 export async function extractParts(
   feishuClient: InstanceType<typeof Lark.Client>,
   messageId: string,
   messageType: string,
   rawContent: string,
   log: LogFn,
   maxResourceSize: number,
 ): Promise<PromptPart[]> {
   try {
+    let parts: PromptPart[]
+
     // 先按消息类型分发到专用提取函数,再由各函数处理自己的细节。
     switch (messageType) {
       case "text":
-        return extractText(rawContent)
+        parts = extractText(rawContent)
+        break
       case "image":
-        return await extractImage(feishuClient, messageId, rawContent, log, maxResourceSize)
+        parts = await extractImage(feishuClient, messageId, rawContent, log, maxResourceSize)
+        break
       case "post":
-        return await extractPost(feishuClient, messageId, rawContent, log, maxResourceSize)
+        parts = await extractPost(feishuClient, messageId, rawContent, log, maxResourceSize)
+        break
       case "file":
-        return await extractFile(feishuClient, messageId, rawContent, log, maxResourceSize)
+        parts = await extractFile(feishuClient, messageId, rawContent, log, maxResourceSize)
+        break
       case "audio":
-        return await extractAudio(feishuClient, messageId, rawContent, log, maxResourceSize)
+        parts = await extractAudio(feishuClient, messageId, rawContent, log, maxResourceSize)
+        break
       case "media":
-        return extractMediaFallback()
+        parts = extractMediaFallback()
+        break
       case "sticker":
-        return [{ type: "text", text: "[表情包]" }]
+        parts = [{ type: "text", text: "[表情包]" }]
+        break
       case "interactive":
-        return extractInteractive(rawContent, log)
+        parts = extractInteractive(rawContent, log)
+        break
       case "share_chat":
-        return extractShareChat(rawContent, log)
+        parts = extractShareChat(rawContent, log)
+        break
       case "share_user":
-        return [{ type: "text", text: "[分享了一个用户名片]" }]
+        parts = [{ type: "text", text: "[分享了一个用户名片]" }]
+        break
       case "merge_forward":
-        return [{ type: "text", text: "[合并转发消息]" }]
+        parts = [{ type: "text", text: "[合并转发消息]" }]
+        break
       default:
-        return [{ type: "text", text: `[不支持的消息类型: ${messageType}]` }]
+        parts = [{ type: "text", text: `[不支持的消息类型: ${messageType}]` }]
     }
+
+    return parts.length
+      ? parts
+      : [{ type: "text", text: `[消息内容为空或解析失败: ${messageType}]` }]
   } catch (err) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/feishu/content-extractor.ts` around lines 29 - 73, The extractParts
function can return an empty PromptPart[] (e.g., extractText() for blank text or
extractPost() on parse failure), which breaks callers expecting at least one
part; update extractParts to normalize any empty array result into a single
fallback text part like [{ type: "text", text: `[消息内容为空: ${messageType}]` }];
specifically, after each awaited branch (or centrally before returning from
extractParts) check if the value is an array with length === 0 and replace it
with the fallback, referencing extractParts, extractText, and extractPost to
locate the affected branches and ensure downstream validation always receives a
non-empty parts array.
src/handler/session-queue.ts (2)

98-117: ⚠️ Potential issue | 🟠 Major

This drops the Phase-2 idle auto-prompt flow for group chats.

handleChat() is supposed to hand queue state back so the drain loop can keep running idle auto-prompt iterations once user messages are drained. drainLoop() ignores the return value and exits as soon as state.queue becomes empty, so the documented group idle phase never runs.

Based on learnings, src/handler/session-queue.ts should transition from Phase 1 user-message processing to the idle auto-prompt phase when the queue becomes empty.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handler/session-queue.ts` around lines 98 - 117, drainLoop currently
stops as soon as state.queue is empty, which drops the Phase-2 idle auto-prompt
flow; change drainLoop to observe the return value of handleChat (e.g., a
boolean or a sentinel on QueueState) and keep the loop running for idle
iterations when handleChat signals "continue idle". Specifically, in drainLoop
(which currently shifts items from state.queue and calls handleChat(item.ctx,
item.deps)), capture handleChat's return (or read a flag on QueueState that
handleChat sets) and use that to extend the loop condition from merely
state.queue.length > 0 to while (state.queue.length > 0 || shouldContinueIdle)
so cleanupStateIfIdle and state.processing=false only run after idle phase
finishes; update handleChat (or QueueState) to return or expose the
continue-idle signal accordingly.

57-90: ⚠️ Potential issue | 🔴 Critical

Don't block the gateway on the full drain loop.

enqueueMessage() sits under the gateway's await onMessage(ctx) path. Awaiting drainLoop() here means the websocket handler stays blocked until the current reply fully finishes, so later messages cannot enqueue promptly, cannot interrupt a P2P run, and end up behind a head-of-line stall.

Based on learnings, src/feishu/gateway.ts awaits onMessage(ctx) and src/handler/session-queue.ts should keep AbortController-based interruption for P2P instead of a blocking unified FIFO.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handler/session-queue.ts` around lines 57 - 90, enqueueMessage currently
blocks the gateway because handleQueuedMessage awaits drainLoop; change
handleQueuedMessage so after state.queue.push it does not await drainLoop but
instead sets state.processing = true and starts drainLoop asynchronously (e.g.,
void drainLoop(sessionKey, state)) to avoid blocking onMessage; also ensure
drainLoop itself clears state.processing when finished and that the session
state includes an AbortController (created/stored in getOrCreateState) so P2P
runs can be interrupted via that controller rather than relying on a blocking
FIFO.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.github/workflows/codex-code-review.yml:
- Around line 10-13: In the codex job's permissions block remove the unused
"pull-requests: write" entry to follow least-privilege; keep "contents: read"
and any existing "outputs" unchanged. Ensure the "post_feedback" job continues
to use "issues: write" for PR commenting (do not move permissions there), and
run the workflow to verify no steps in the codex job require pull-requests write
access.
- Around line 8-35: Add a guard to skip the codex job when the OPENAI_API_KEY
secret is not present: add an if condition on the codex job (the job with
id/name "codex") such as if: ${{ secrets.OPENAI_API_KEY }} so the job is not run
for forked PRs lacking the secret, ensuring the step that runs the action (step
id "run_codex" which uses openai/codex-action@v1 and references the
OPENAI_API_KEY secret) is never executed when the secret is absent.

In `@src/feishu/gateway.ts`:
- Around line 180-190: Don't trust parsedAction.chatId for routing: when
building syntheticCtx in the send_message branch (the code around
parseCardActionValue and syntheticCtx / FeishuMessageContext), set
syntheticCtx.chatId from the callback event's authoritative chat id (use
action.chatId / context open_chat_id) rather than parsedAction.chatId; if
parsedAction.chatId exists but differs, emit a warning log mentioning both IDs
and continue routing using action.chatId. Also keep using parsedAction.text for
content and parsedAction.chatType only if it matches or is absent, but never let
parsedAction.chatId override the event's chat id.

In `@src/feishu/markdown.ts`:
- Around line 49-62: The code currently strips HTML tags before ensuring code
fences are closed, which can corrupt trailing/unfinished code blocks; fix
cleanMarkdown by calling closeCodeBlocks on the raw text before calling
extractCodeBlocks and before applying HTML_TAG_RE to segments (or alternatively
call closeCodeBlocks immediately after replacing <br> and before
extractCodeBlocks), then proceed to extractCodeBlocks(…), strip HTML from
non-code segments using HTML_TAG_RE, re-insert codeBlocks, and finally return
the result; reference functions/idents: cleanMarkdown, closeCodeBlocks,
extractCodeBlocks, HTML_TAG_RE, segments, codeBlocks.

In `@src/handler/action-bus.ts`:
- Around line 32-33: The module removed the per-session bulk cleanup API; add
back and export an unsubscribeAll(sessionId: string) function that locates the
subscribers Map entry for the given sessionId, iterates/clears the
Set<ActionCallback> (or simply delete the map entry) and releases resources so
the session's callbacks are removed; ensure unsubscribeAll is consistent with
subscribe(sessionId, cb) and emit(sessionId, action) semantics and is exported
from the module so callers can force per-session cleanup.

In `@src/handler/chat.ts`:
- Around line 229-247: The timer callback can race with the main flow: if
sendTextMessage(...) completes after the main flow set done = true, the code
currently returns and leaves the sent "正在思考…" message orphaned because
placeholderId/registerPending are never set; fix by, after awaiting
sender.sendTextMessage(feishuClient, chatId, ...), capture res.messageId and if
done is true then proactively delete that message (e.g., call the
sender.deleteMessage or the appropriate message deletion API with feishuClient,
chatId and res.messageId and log) and return; otherwise continue with the
existing flow that sets placeholderId and calls registerPending(activeSessionId,
{ placeholderId, feishuClient }).

In `@src/handler/interactive.ts`:
- Around line 144-162: The code currently calls markSeen(requestId) before the
async sendInteractiveCard, which causes failed sends to be permanently treated
as seen; change the flow so requestId is only added to seenRequestIds (i.e.,
call markSeen(requestId)) after a successful send (when
sender.sendInteractiveCard(...) returns res.ok === true), and ensure that on
res.ok === false or in the catch block you do not mark the id (or you rollback
by removing it from seenRequestIds/unmarkSeen); update the success branch to
call markSeen and keep the error logging in the failure branches (referencing
requestId, sender.sendInteractiveCard, markSeen, seenRequestIds, deps.log) so
retries/SSE replays can retry failed sends.

---

Outside diff comments:
In `@src/feishu/content-extractor.ts`:
- Around line 29-73: The extractParts function can return an empty PromptPart[]
(e.g., extractText() for blank text or extractPost() on parse failure), which
breaks callers expecting at least one part; update extractParts to normalize any
empty array result into a single fallback text part like [{ type: "text", text:
`[消息内容为空: ${messageType}]` }]; specifically, after each awaited branch (or
centrally before returning from extractParts) check if the value is an array
with length === 0 and replace it with the fallback, referencing extractParts,
extractText, and extractPost to locate the affected branches and ensure
downstream validation always receives a non-empty parts array.

In `@src/handler/session-queue.ts`:
- Around line 98-117: drainLoop currently stops as soon as state.queue is empty,
which drops the Phase-2 idle auto-prompt flow; change drainLoop to observe the
return value of handleChat (e.g., a boolean or a sentinel on QueueState) and
keep the loop running for idle iterations when handleChat signals "continue
idle". Specifically, in drainLoop (which currently shifts items from state.queue
and calls handleChat(item.ctx, item.deps)), capture handleChat's return (or read
a flag on QueueState that handleChat sets) and use that to extend the loop
condition from merely state.queue.length > 0 to while (state.queue.length > 0 ||
shouldContinueIdle) so cleanupStateIfIdle and state.processing=false only run
after idle phase finishes; update handleChat (or QueueState) to return or expose
the continue-idle signal accordingly.
- Around line 57-90: enqueueMessage currently blocks the gateway because
handleQueuedMessage awaits drainLoop; change handleQueuedMessage so after
state.queue.push it does not await drainLoop but instead sets state.processing =
true and starts drainLoop asynchronously (e.g., void drainLoop(sessionKey,
state)) to avoid blocking onMessage; also ensure drainLoop itself clears
state.processing when finished and that the session state includes an
AbortController (created/stored in getOrCreateState) so P2P runs can be
interrupted via that controller rather than relying on a blocking FIFO.
🪄 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: 2a9e7fbf-3822-4865-853a-2aba809aff1e

📥 Commits

Reviewing files that changed from the base of the PR and between ba08d34 and 559f5aa.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (29)
  • .github/codex/prompts/review.md
  • .github/workflows/codex-code-review.yml
  • AGENTS.md
  • CLAUDE.md
  • package.json
  • src/feishu/cardkit.ts
  • src/feishu/content-extractor.ts
  • src/feishu/dedup.ts
  • src/feishu/gateway.ts
  • src/feishu/group-filter.ts
  • src/feishu/history.ts
  • src/feishu/markdown.ts
  • src/feishu/quote.ts
  • src/feishu/resource.ts
  • src/feishu/sender.ts
  • src/feishu/session-chat-map.ts
  • src/feishu/streaming-card.ts
  • src/feishu/user-name.ts
  • src/handler/action-bus.ts
  • src/handler/chat.ts
  • src/handler/error-recovery.ts
  • src/handler/event.ts
  • src/handler/interactive.ts
  • src/handler/session-queue.ts
  • src/index.ts
  • src/session.ts
  • src/tools/send-card.ts
  • src/types.ts
  • src/utils/ttl-map.ts

Comment thread .github/workflows/codex-code-review.yml Outdated
Comment thread .github/workflows/codex-code-review.yml Outdated
Comment thread src/feishu/gateway.ts
Comment thread src/feishu/markdown.ts
Comment thread src/handler/action-bus.ts
Comment on lines +32 to 33
/** sessionId → 订阅回调集合。 */
const subscribers = new Map<string, Set<ActionCallback>>()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add unsubscribeAll(sessionId) back to preserve cleanup contract.

当前模块缺少批量清理入口,不利于会话级强制回收;建议补回 unsubscribeAll 导出以维持 action-bus API 完整性。

♻️ Suggested patch
 const subscribers = new Map<string, Set<ActionCallback>>()

+export function unsubscribeAll(sessionId: string): void {
+  subscribers.delete(sessionId)
+}
+
 /**
  * 向指定 session 的订阅者广播事件。
  *
  * 这里采用 fire-and-forget:

As per coding guidelines src/handler/action-bus.ts: “Implement per-session event pub-sub: subscribe(sessionId, cb) returns unsubscribe function, emit(sessionId, action) fire-and-forget routing, unsubscribeAll(sessionId) cleanup.”

Also applies to: 40-66, 90-90

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handler/action-bus.ts` around lines 32 - 33, The module removed the
per-session bulk cleanup API; add back and export an unsubscribeAll(sessionId:
string) function that locates the subscribers Map entry for the given sessionId,
iterates/clears the Set<ActionCallback> (or simply delete the map entry) and
releases resources so the session's callbacks are removed; ensure unsubscribeAll
is consistent with subscribe(sessionId, cb) and emit(sessionId, action)
semantics and is exported from the module so callers can force per-session
cleanup.

Comment thread src/handler/chat.ts
Comment thread src/handler/interactive.ts

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors and improves the Feishu plugin, focusing on better error handling, logging, and code documentation. Key changes include adding a more robust error handling mechanism for resource downloads, improving the StreamingCard lifecycle management, and enhancing the ActionBus with better error reporting. I have identified an issue in the file size calculation logic where the data URL prefix is incorrectly included in the size estimation, and provided a suggestion to fix it.

Comment thread src/feishu/content-extractor.ts Outdated
Comment on lines +358 to +359
const sizeMB = (result.resource.dataUrl.length * 0.75 / (1024 * 1024)).toFixed(1)
return [{ type: "text", text: `[文件: ${fileName}, ${sizeMB}MB]` }]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

文件大小计算不准确。

当前计算文件大小的方式 result.resource.dataUrl.length * 0.75 会将 data URL 的前缀 data:<mime>;base64, 也计算在内,导致最终显示的文件大小不准确,会比实际值偏大。

建议先提取出 Base64 数据部分,再进行计算,以获得更准确的文件大小。

  const commaIndex = result.resource.dataUrl.indexOf(',');
  const base64Data = commaIndex >= 0 ? result.resource.dataUrl.slice(commaIndex + 1) : "";
  const sizeInBytes = base64Data.length * 0.75;
  const sizeMB = (sizeInBytes / (1024 * 1024)).toFixed(1);
  return [{ type: "text", text: `[文件: ${fileName}, ${sizeMB}MB]` }]

@NeverMore93

Copy link
Copy Markdown
Owner Author

/gemini review

@gemini-code-assist

Copy link
Copy Markdown

Warning

Gemini is experiencing higher than usual traffic and was unable to create the review. Please try again in a few hours by commenting /gemini review.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/feishu/gateway.ts (1)

58-60: ⚠️ Potential issue | 🟠 Major

不要把代理 URL 原样写进日志。

这里会把 proxyUrl 全量打到日志里;如果环境变量里带了 user:pass@host,凭据会直接落盘。

🔐 建议修复
   if (proxyUrl) {
     wsAgent = new HttpsProxyAgent(proxyUrl)
-    log("info", "WS proxy enabled", { proxy: proxyUrl })
+    let proxyForLog = proxyUrl
+    try {
+      const parsed = new URL(proxyUrl)
+      if (parsed.username || parsed.password) {
+        parsed.username = "***"
+        parsed.password = "***"
+      }
+      proxyForLog = parsed.toString()
+    } catch {
+      proxyForLog = "[invalid-proxy-url]"
+    }
+    log("info", "WS proxy enabled", { proxy: proxyForLog })
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/feishu/gateway.ts` around lines 58 - 60, The code logs the full proxyUrl
(used to construct wsAgent via new HttpsProxyAgent(proxyUrl)), which may contain
credentials; change the logging to avoid writing sensitive user:pass into logs
by parsing proxyUrl and redacting credentials or logging only the
hostname/port/scheme. In the block that constructs wsAgent (refer to wsAgent,
HttpsProxyAgent, and log("info", "WS proxy enabled", { proxy: proxyUrl })),
replace the logged value with a sanitized string (e.g., remove url.username and
url.password or use url.host) and log that instead.
♻️ Duplicate comments (1)
src/feishu/gateway.ts (1)

191-198: ⚠️ Potential issue | 🟠 Major

这里仍然会把部分群聊按钮误路由成 p2p 会话。

parseCardActionValue()send_message 分支里会把缺失的 chatType 默认成 "p2p",而这里又直接把它写进 syntheticCtx。这样旧卡片或外部构造的按钮只要没带 chatType,即使 chatId 已经改成回调上下文,后续 sessionKey 仍会按 p2p 生成,导致群会话复用和队列语义出错。

请把 chatType 保持为“缺失可见”的状态,再在这里基于回调上下文兜底;如果拿不到权威值,宁可拒绝该按钮,也不要静默默认成 p2p

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/feishu/gateway.ts` around lines 191 - 198, The parsed card action is
currently defaulting missing chatType to "p2p" (in parseCardActionValue's
send_message branch), which then gets written into syntheticCtx and corrupts
sessionKey generation; instead, preserve a missing/undefined
parsedAction.chatType (do not default to "p2p"), and in this handler use the
callback context to fill chatType only when the callback provides an
authoritative value (e.g., if callbackChatId && callbackChatType exist use
that); if after consulting callback context chatType is still unavailable,
reject the button action (return an error or no-op) rather than silently
defaulting to "p2p" so sessionKey/session routing remains correct.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/feishu/gateway.ts`:
- Around line 58-60: The code logs the full proxyUrl (used to construct wsAgent
via new HttpsProxyAgent(proxyUrl)), which may contain credentials; change the
logging to avoid writing sensitive user:pass into logs by parsing proxyUrl and
redacting credentials or logging only the hostname/port/scheme. In the block
that constructs wsAgent (refer to wsAgent, HttpsProxyAgent, and log("info", "WS
proxy enabled", { proxy: proxyUrl })), replace the logged value with a sanitized
string (e.g., remove url.username and url.password or use url.host) and log that
instead.

---

Duplicate comments:
In `@src/feishu/gateway.ts`:
- Around line 191-198: The parsed card action is currently defaulting missing
chatType to "p2p" (in parseCardActionValue's send_message branch), which then
gets written into syntheticCtx and corrupts sessionKey generation; instead,
preserve a missing/undefined parsedAction.chatType (do not default to "p2p"),
and in this handler use the callback context to fill chatType only when the
callback provides an authoritative value (e.g., if callbackChatId &&
callbackChatType exist use that); if after consulting callback context chatType
is still unavailable, reject the button action (return an error or no-op) rather
than silently defaulting to "p2p" so sessionKey/session routing remains correct.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dccb0c2a-e500-4977-95e2-ea0cc0865bd8

📥 Commits

Reviewing files that changed from the base of the PR and between 559f5aa and fa6ed15.

📒 Files selected for processing (8)
  • AGENTS.md
  • CLAUDE.md
  • src/feishu/content-extractor.ts
  • src/feishu/gateway.ts
  • src/feishu/markdown.ts
  • src/handler/chat.ts
  • src/handler/interactive.ts
  • src/handler/session-queue.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/feishu/markdown.ts
  • src/handler/session-queue.ts
  • src/handler/interactive.ts

@NeverMore93

Copy link
Copy Markdown
Owner Author

/gemini review

@gemini-code-assist

Copy link
Copy Markdown

Warning

Gemini is experiencing higher than usual traffic and was unable to create the review. Please try again in a few hours by commenting /gemini review.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/handler/interactive.ts (1)

25-52: 把这几个 payload 形状改成 interface 更贴合仓库约定。

这里只有 ParsedCardActionValue 需要保留联合 typePermissionReplyActionValueQuestionReplyActionValueSendMessageActionValue 都是纯对象契约,用 interface 更一致,也更符合后续扩展习惯。 As per coding guidelines, **/*.{ts,tsx}: Prefer interface for defining object shapes in TypeScript.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handler/interactive.ts` around lines 25 - 52, Replace the three object
type aliases PermissionReplyActionValue, QuestionReplyActionValue, and
SendMessageActionValue with equivalent interfaces (keeping their property
shapes, literal "action" values, optional chatType, and existing comments), and
keep ParsedCardActionValue as the discriminated union type that references those
interface names; ensure exported visibility remains the same and that no
property names or literal types are changed so existing usages still type-check.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/feishu/gateway.ts`:
- Around line 193-203: The current check treats any missing
parsedAction.chatType as an expired card and returns a warning, which breaks
legacy default send_message buttons that omit chatType; change this to a
backward-compatible branch: when parsedAction.action === "send_message" and
parsedAction.chatType is missing, do not return—log a compatibility warning and
allow processing to continue (or set a safe inferred chatType derived from
callbackChatId/payloadChatId if needed) so onMessage() still runs; only reject
when chatType is present but invalid or when it's genuinely ambiguous for
routing.

---

Nitpick comments:
In `@src/handler/interactive.ts`:
- Around line 25-52: Replace the three object type aliases
PermissionReplyActionValue, QuestionReplyActionValue, and SendMessageActionValue
with equivalent interfaces (keeping their property shapes, literal "action"
values, optional chatType, and existing comments), and keep
ParsedCardActionValue as the discriminated union type that references those
interface names; ensure exported visibility remains the same and that no
property names or literal types are changed so existing usages still type-check.
🪄 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: 11cf2547-4a22-4421-bae6-c690ed858355

📥 Commits

Reviewing files that changed from the base of the PR and between fa6ed15 and 0f17215.

📒 Files selected for processing (2)
  • src/feishu/gateway.ts
  • src/handler/interactive.ts

Comment thread src/feishu/gateway.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/feishu/gateway.ts (1)

266-310: ⚠️ Potential issue | 🟠 Major

Move chatType resolution into detached async path to unblock the callback response.

The code awaits resolveCardActionChatType() (which calls larkClient.im.chat.get()) before returning the callback response. If that lookup is slow, the click will miss Feishu's fixed 3-second response window and trigger a retry, causing flaky and potentially duplicate button handling.

Wrap the async work in an IIFE and return buildCallbackResponse(action, log) immediately:

♻️ Suggested shape
-          const resolvedChatType = await resolveCardActionChatType({
-            chatId: targetChatId,
-            payloadChatType: parsedAction.chatType,
-            // 旧卡片缺 chatType 或 payload chatId 已明显过期时,必须查飞书权威会话信息再继续。
-            requireAuthoritativeLookup: !parsedAction.chatType || chatIdMismatch,
-            larkClient,
-            log,
-          })
-          if (!resolvedChatType) {
-            log("error", "send_message 按钮 chatType 无法确定,已拒绝处理", {
-              callbackChatId,
-              payloadChatId: parsedAction.chatId,
-              payloadChatType: parsedAction.chatType ?? "",
-              targetChatId,
-              operatorId: action.operatorId,
-            })
-            return {
-              toast: { type: "warning", content: "⚠️ 卡片已过期,请重新触发" },
-            }
-          }
-          if (!parsedAction.chatType) {
-            log("warn", "send_message 按钮缺少 chatType,已按飞书会话信息兼容推断", {
-              callbackChatId,
-              payloadChatId: parsedAction.chatId,
-              targetChatId,
-              resolvedChatType,
-            })
-          }
-          const syntheticCtx: FeishuMessageContext = {
-            chatId: targetChatId,
-            messageId: `btn-${randomUUID()}`,
-            messageType: "text",
-            content: parsedAction.text,
-            rawContent: JSON.stringify({ text: parsedAction.text }),
-            chatType: resolvedChatType,
-            senderId: action.operatorId ?? "",
-            shouldReply: true,
-          }
-          void Promise.resolve(onMessage(syntheticCtx)).catch((err: unknown) => {
+          void (async () => {
+            const resolvedChatType = await resolveCardActionChatType({
+              chatId: targetChatId,
+              payloadChatType: parsedAction.chatType,
+              requireAuthoritativeLookup: !parsedAction.chatType || chatIdMismatch,
+              larkClient,
+              log,
+            })
+            if (!resolvedChatType) {
+              log("error", "send_message 按钮 chatType 无法确定,已拒绝处理", {
+                callbackChatId,
+                payloadChatId: parsedAction.chatId,
+                payloadChatType: parsedAction.chatType ?? "",
+                targetChatId,
+                operatorId: action.operatorId,
+              })
+              return
+            }
+
+            const syntheticCtx: FeishuMessageContext = {
+              chatId: targetChatId,
+              messageId: `btn-${randomUUID()}`,
+              messageType: "text",
+              content: parsedAction.text,
+              rawContent: JSON.stringify({ text: parsedAction.text }),
+              chatType: resolvedChatType,
+              senderId: action.operatorId ?? "",
+              shouldReply: true,
+            }
+            await onMessage(syntheticCtx)
+          })().catch((err: unknown) => {
             log("error", "send_message 按钮处理失败", {
               error: err instanceof Error ? err.message : String(err),
             })
           })
           // 即使后台还没处理完,也要马上给飞书回一个 toast。
           return buildCallbackResponse(action, log)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/feishu/gateway.ts` around lines 266 - 310, The current flow awaits
resolveCardActionChatType before returning, which can exceed Feishu's 3s
callback window; detach the chatType lookup and downstream onMessage handling
into an async background task (e.g., an immediately-invoked async function or
Promise.resolve().then(async () => {...})) that performs
resolveCardActionChatType(...) using larkClient, builds the synthetic
FeishuMessageContext (chatId: targetChatId, messageId: `btn-${randomUUID()}`,
messageType/text/rawContent/chatType from resolved value, senderId:
action.operatorId, shouldReply), calls onMessage(syntheticCtx) and logs errors,
while the outer function returns buildCallbackResponse(action, log) immediately;
keep logging for missing chatType and authoritative lookup logic inside the
detached task so the callback returns without awaiting network I/O.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/feishu/gateway.ts`:
- Around line 266-310: The current flow awaits resolveCardActionChatType before
returning, which can exceed Feishu's 3s callback window; detach the chatType
lookup and downstream onMessage handling into an async background task (e.g., an
immediately-invoked async function or Promise.resolve().then(async () => {...}))
that performs resolveCardActionChatType(...) using larkClient, builds the
synthetic FeishuMessageContext (chatId: targetChatId, messageId:
`btn-${randomUUID()}`, messageType/text/rawContent/chatType from resolved value,
senderId: action.operatorId, shouldReply), calls onMessage(syntheticCtx) and
logs errors, while the outer function returns buildCallbackResponse(action, log)
immediately; keep logging for missing chatType and authoritative lookup logic
inside the detached task so the callback returns without awaiting network I/O.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e7e65f7d-12f8-44f5-9922-ebdc4cc08f11

📥 Commits

Reviewing files that changed from the base of the PR and between 76c4072 and 4ac8205.

📒 Files selected for processing (2)
  • src/feishu/gateway.ts
  • src/handler/interactive.ts

@NeverMore93

Copy link
Copy Markdown
Owner Author

@codex review

@NeverMore93

Copy link
Copy Markdown
Owner Author

@claude review

@NeverMore93

Copy link
Copy Markdown
Owner Author

/gemini review

@NeverMore93

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Apr 10, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@claude

claude Bot commented Apr 10, 2026

Copy link
Copy Markdown

Claude finished @NeverMore93's task in 2m 36s —— View job


PR Review: fix: harden feishu plugin error handling

Tasks:

  • Read PR context and understand the changes
  • Analyze git diff for the full changeset
  • Review key changed files
  • Provide comprehensive review feedback

Overall Assessment

This is a solid hardening PR. The core improvements are well-executed: log-level escalation, session-poisoning detection, markSeen/unmarkSeen correctness, the cleanMarkdown ordering fix, proxy-URL redaction, and the timer race-condition fix in chat.ts are all quality improvements. A few issues deserve attention before merge.


🔴 Critical: Gateway handler can block all WebSocket events

File: src/handler/session-queue.ts:87, src/feishu/gateway.ts

handleQueuedMessage awaits drainLoop for the first message in a session. The Lark SDK event handler awaits onMessage(ctx), which awaits enqueueMessage, which awaits drainLoop. If the Lark SDK dispatches WebSocket messages sequentially (one handler at a time), the first message in any session will block all incoming events for up to timeout ms (default: 120 seconds × N queued messages).

// session-queue.ts — drainLoop blocks the first caller
async function handleQueuedMessage(...) {
  state.queue.push({ ctx, deps })
  if (state.processing) return     // ← subsequent messages return immediately
  await drainLoop(sessionKey, state)  // ← first caller is blocked here
}

Fix: Start drainLoop as a fire-and-forget background task:

if (state.processing) return
// Don't await — just start the loop and return immediately
void drainLoop(sessionKey, state).catch(err => ...)

This is especially important because the old P2P interruptible strategy was explicitly removed, so there's no other way to avoid head-of-line blocking.

Fix this →


🟠 High: resolveCardActionChatType can bust Feishu's 3-second callback window

File: src/feishu/gateway.ts:266-305

When a send_message button has no chatType (old cards) or has a chatId mismatch, resolveCardActionChatType calls larkClient.im.chat.get(...) — a network round-trip — before buildCallbackResponse is returned. This means the callback response can arrive after Feishu's 3-second hard deadline, causing the click to appear failed and potentially retrying (duplicate handling).

// Gateway waits for network call before returning toast
const resolvedChatType = await resolveCardActionChatType({ requireAuthoritativeLookup: true, ... })
// ↑ network I/O — could be slow
...
return buildCallbackResponse(action, log)  // ← may arrive after 3s timeout

Fix: Detach the lookup into a background IIFE and return the toast immediately:

void (async () => {
  const resolvedChatType = await resolveCardActionChatType(...)
  if (!resolvedChatType) { log("error", ...); return }
  const syntheticCtx = { ... }
  await onMessage(syntheticCtx)
})().catch(err => log("error", ...))

return buildCallbackResponse(action, log)  // Returns immediately

Fix this →


🟡 Medium: PendingReplyPayload lost chatId — update fallback no longer possible from event.ts

File: src/handler/event.ts:280-292

The chatId field was removed from PendingReplyPayload. Previously, if updateMessage failed in handleMessagePartUpdated, it could fall back to sending a new text message. Now it only logs the error. The practical impact depends on how often real-time updates fail, but users will silently lose streaming updates without seeing them.

// event.ts — update fails, no fallback (chatId no longer available)
const res = await sender.updateMessage(payload.feishuClient, payload.placeholderId, ...)
if (!res.ok) {
  log("error", "更新飞书占位消息失败", ...)  // ← nothing else can be done
}

This is acceptable if streaming updates are considered best-effort (the final finalizeReply in chat.ts will still deliver the result). Worth a comment documenting this intent.


🟡 Medium: File size estimation includes data URL prefix

File: src/feishu/content-extractor.ts (in extractFile / extractAudio)

When displaying file size from a data: URL, the prefix data:<mime>;base64, is included in the length calculation, inflating the displayed size. While not a correctness bug, users see inaccurate file sizes.

// Inaccurate: includes the "data:application/pdf;base64," prefix
const sizeInBytes = result.resource.dataUrl.length * 0.75

Fix:

const commaIdx = result.resource.dataUrl.indexOf(",")
const base64Data = commaIdx >= 0 ? result.resource.dataUrl.slice(commaIdx + 1) : ""
const sizeInBytes = base64Data.length * 0.75

✅ Things done well

interactive.tsunmarkSeen on send failure — The new sendRequestCard helper correctly rolls back the markSeen when send fails, allowing SSE replays to retry. This is the right pattern.

content-extractor.tsnormalizeExtractedParts — Good catch on the empty parts issue. The normalizeExtractedParts guard prevents silent message drops.

markdown.tscloseCodeBlocks order fix — Calling closeCodeBlocks before extractCodeBlocks is the correct fix for the HTML-stripping corrupting unfinished code fences.

gateway.ts — proxy URL redaction — The redactProxyUrlForLog function properly masks credentials, with graceful fallback for invalid URLs.

chat.ts — timer race condition — The new if (done) branch with deleteMessage on the late placeholder send correctly handles the race between the thinking timer and the main flow completing.

session.tsinvalidateSession + poisoning detection — Session poisoning detection with isSessionPoisoned and the invalidateSession flow is a solid improvement over the previous "retry forever" behavior.

error-recovery.ts — early return for non-model errorstryModelRecovery now correctly skips the retry path for non-model errors, making the control flow clearer.


Minor / Nits

  • action-bus.ts: unsubscribeAll(sessionId) is still absent. The individual subscribe() unsubscribe functions clean up themselves when the Set is empty, so this isn't a leak, but the CLAUDE.md architecture doc still mentions it as part of the action-bus contract.
  • session-queue.ts comment: The comment // while 条件已经保证数组非空,因此这里的非空断言是安全的。 is slightly misleading — the guard is the while condition checking state.queue.length > 0, not a length assertion. Minor phrasing.
  • src/index.ts: The import section comments (e.g., // 飞书 SDK 内置 token 管理...) are helpful context for new contributors but add significant line count. Consider moving to a CONTRIBUTING doc instead if the file grows further.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces significant improvements to the Feishu plugin, including enhanced error handling, session management, and resource extraction logic. Key changes include a more robust FIFO message queue, better handling of streaming card updates, and improved error recovery mechanisms. The review feedback highlights a critical issue where invalidating a 'poisoned' session only clears the local cache, allowing the plugin to re-fetch the same invalid session from the server. I have included the suggested changes to implement a blacklist mechanism for sessions to ensure they are not reused after being marked as invalid.

Comment thread src/session.ts
Comment on lines +50 to 52
export function invalidateSession(sessionKey: string): void {
sessionCache.delete(sessionKey)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The invalidateSession(sessionKey) call only clears the local cache. However, getOrCreateSession searches for existing sessions on the server using a title prefix and picks the latest one. If a session is "poisoned" due to its history (as detected in chat.ts), simply invalidating the local cache will result in getOrCreateSession picking up the same poisoned session again from the server in the next request. To truly reset the session, the plugin should either rename the poisoned session on the server or getOrCreateSession should be modified to avoid reusing sessions that have been marked as invalid.

const blacklistedSessions = new TtlMap<true>(24 * 60 * 60 * 1_000)

/**
 * 主动使指定逻辑会话失效。
 *
 * 下次 `getOrCreateSession()` 将重新去 OpenCode 侧查找或创建。
 */
export function invalidateSession(sessionKey: string, sessionId?: string): void {
  sessionCache.delete(sessionKey)
  if (sessionId) {
    blacklistedSessions.set(sessionId, true)
  }
}

Comment thread src/session.ts
Comment on lines 78 to 80
const candidates = sessions.filter(
(s) => s.title && s.title.startsWith(titlePrefix),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When filtering for candidate sessions to reuse, we should exclude those that have been explicitly blacklisted (e.g., due to session history poisoning).

Suggested change
const candidates = sessions.filter(
(s) => s.title && s.title.startsWith(titlePrefix),
)
const candidates = sessions.filter(
(s) => s.title && s.title.startsWith(titlePrefix) && !blacklistedSessions.has(s.id),
)

Comment thread src/handler/chat.ts
log("error", "检测到 session 历史数据中毒,创建新 session", {
sessionKey, oldSessionId: session.id, error: sessionError.message,
})
invalidateSession(sessionKey)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Pass the session.id to invalidateSession so it can be blacklisted and not reused by getOrCreateSession in subsequent requests.

Suggested change
invalidateSession(sessionKey)
invalidateSession(sessionKey, session.id)

@NeverMore93

Copy link
Copy Markdown
Owner Author

Closing this PR and recreating a fresh one after a soft reset to origin/main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant