Skip to content

fix: use plugin server URL and add question fallback#86

Open
chenhaipeng wants to merge 3 commits into
NeverMore93:mainfrom
chenhaipeng:fix/use-server-url-for-v2-client
Open

fix: use plugin server URL and add question fallback#86
chenhaipeng wants to merge 3 commits into
NeverMore93:mainfrom
chenhaipeng:fix/use-server-url-for-v2-client

Conversation

@chenhaipeng

@chenhaipeng chenhaipeng commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

  • use the plugin-provided OpenCode server URL when creating the v2 client
  • register pending question requests and fall back to plain text when Feishu interactive cards fail
  • resolve pending questions from direct text replies like 1/2/continue/cancel before they enter the per-session queue

Verification

  • npm run build
  • npm run typecheck

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@chenhaipeng, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bce2fa3f-e556-4914-a22b-4ab739d98164

📥 Commits

Reviewing files that changed from the base of the PR and between ab0dc3a and 19e6a6d.

📒 Files selected for processing (1)
  • src/index.ts
📝 Walkthrough

Walkthrough

Adds explicit v2 client config derivation from the server URL, introduces pending-question tracking and reply resolution, and routes eligible text messages through the pending-question resolver before normal queue handling.

Changes

V2 Client Config Derivation

Layer / File(s) Summary
buildV2ClientConfig helper and wiring
src/index.ts
Adds exported buildV2ClientConfig that sets baseUrl from serverUrl.toString() and maps falsy directory to undefined; updates v2 client construction to call it with ctx.serverUrl and resolvedConfig.directory.
Regression tests for buildV2ClientConfig
tests/index-v2-client.test.ts
New test suite asserts baseUrl matches the stringified server URL and directory is preserved or becomes undefined when empty.

Pending Question Reply Flow

Layer / File(s) Summary
Pending question state and reply resolution
src/handler/pending-questions.ts
Stores pending questions by chat and request id, builds fallback prompt text, parses replies, submits answers through v2Client.question.reply, and emits session phase updates and Feishu responses.
Interactive question request and reply cleanup
src/handler/interactive.ts
Registers pending questions when sending question cards, adds fallback text handling on card send failure, and clears pending state around question_reply processing and callback responses.
Text reply routing
src/handler/session-queue.ts
Intercepts eligible text messages and attempts pending question resolution before the normal enqueue path continues.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SessionQueue
  participant PendingQuestions
  participant FeishuClient
  participant OpencodeClient

  SessionQueue->>PendingQuestions: tryResolvePendingQuestionText(chatId, content)
  PendingQuestions->>PendingQuestions: parsePendingQuestionReply
  PendingQuestions->>OpencodeClient: question.reply(answers)
  PendingQuestions->>FeishuClient: send confirmation or retry message
Loading

Possibly related PRs

  • NeverMore93/opencode-feishu#26: Modifies src/handler/session-queue.ts’s enqueue and short-circuit behavior in the same message-routing path.
  • NeverMore93/opencode-feishu#27: Touches src/handler/interactive.ts question-request flow and v2 question handling, which this PR extends with pending-question registration.
  • NeverMore93/opencode-feishu#62: Updates the question_reply handling path in src/handler/interactive.ts, which this PR also changes around pending-question cleanup.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: binding the v2 client to the plugin server URL and adding question fallback handling.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@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 a helper function buildV2ClientConfig to configure the OpenCode v2 client using the dynamic server URL (ctx.serverUrl) provided by the plugin host, along with regression tests. The review feedback suggests making serverUrl optional in this helper to ensure backward compatibility and prevent potential runtime crashes if the host CLI does not provide a server URL.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/index.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.

🧹 Nitpick comments (2)
tests/index-v2-client.test.ts (1)

1-10: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Rename the test file to map to src/index.ts.

The helper under test lives in src/index.ts, so this should be placed at tests/index.test.ts or another path that mirrors the source path. As per coding guidelines, “Test file paths must map to corresponding source file paths.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/index-v2-client.test.ts` around lines 1 - 10, The test file location
does not mirror the source module it covers, so rename this regression test to
match src/index.ts (for example, tests/index.test.ts) and keep
buildV2ClientConfig as the symbol under test. Update any referenced test path in
the header comment or run instructions so the test path aligns with the source
path mapping guideline.

Source: Coding guidelines

src/index.ts (1)

88-88: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the exported helper input shape into an interface.

buildV2ClientConfig exposes an object-shaped parameter inline; define a small interface to align with the TypeScript style rule.

Proposed refactor
+interface BuildV2ClientConfigInput {
+  serverUrl: URL
+  directory: string
+}
+
-export function buildV2ClientConfig(input: { serverUrl: URL; directory: string }): NonNullable<Parameters<typeof createOpencodeClient>[0]> {
+export function buildV2ClientConfig(input: BuildV2ClientConfigInput): NonNullable<Parameters<typeof createOpencodeClient>[0]> {

As per coding guidelines, “Prefer TypeScript interface for defining object shapes.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/index.ts` at line 88, The exported helper buildV2ClientConfig currently
uses an inline object type for its input parameter, which should be extracted to
a named interface to match the TypeScript style rule. Define a small interface
for the input shape used by buildV2ClientConfig, then update the function
signature to reference that interface instead of the inline object type, keeping
the existing serverUrl and directory fields intact.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/index.ts`:
- Line 88: The exported helper buildV2ClientConfig currently uses an inline
object type for its input parameter, which should be extracted to a named
interface to match the TypeScript style rule. Define a small interface for the
input shape used by buildV2ClientConfig, then update the function signature to
reference that interface instead of the inline object type, keeping the existing
serverUrl and directory fields intact.

In `@tests/index-v2-client.test.ts`:
- Around line 1-10: The test file location does not mirror the source module it
covers, so rename this regression test to match src/index.ts (for example,
tests/index.test.ts) and keep buildV2ClientConfig as the symbol under test.
Update any referenced test path in the header comment or run instructions so the
test path aligns with the source path mapping guideline.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ed4c4ca6-e13c-41f2-939f-f3e194ab38a2

📥 Commits

Reviewing files that changed from the base of the PR and between 6c33bb6 and d7c39c1.

📒 Files selected for processing (2)
  • src/index.ts
  • tests/index-v2-client.test.ts

@chenhaipeng chenhaipeng changed the title fix: use plugin server URL for v2 client fix: use plugin server URL and add question fallback Jul 2, 2026
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

@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: 3

🧹 Nitpick comments (1)
src/handler/pending-questions.ts (1)

71-108: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding unit tests for parsePendingQuestionReply.

This function drives real user-facing answer submission (numeric selection, continue/cancel keyword matching, out-of-range handling). It's pure and easily testable but currently has no test coverage in the reviewed files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/handler/pending-questions.ts` around lines 71 - 108, Add unit tests for
parsePendingQuestionReply to cover its user-facing parsing paths, including
numeric selection, continue/cancel keyword matching, and out-of-range or invalid
input handling. Use parsePendingQuestionReply together with normalizeOptions
behavior through a minimal QuestionRequest fixture so the tests verify returned
answers and displayText for valid selections and undefined for unsupported
input. Include cases for empty trimmed content, a valid numeric index, a numeric
value beyond options length, continue text resolving via isContinueText and
findOption, and cancel text resolving via isCancelText.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/handler/interactive.ts`:
- Around line 442-477: The pending question is only being unmarked on delivery
failure, but the pending entry itself is never cleared when both the interactive
card and the fallback text fail or when the async send flow throws. Update the
error handling in interactive.ts around registerPendingQuestion,
sendInteractiveCard, sendTextMessage, and the IIFE catch so that
clearPendingQuestionByRequestId(requestId) is invoked in every full-failure
path, alongside unmarkSeen(requestId), to avoid leaving stale pending state
behind.

In `@src/handler/pending-questions.ts`:
- Around line 28-52: `registerPendingQuestion` and
`clearPendingQuestionByRequestId` can let an old requestId delete a newer
pending question for the same chat. Update the
`pendingByChatId`/`chatIdByRequestId` bookkeeping so each chat only has one
active requestId mapping at a time, and before clearing in
`clearPendingQuestionByRequestId` verify the stored `PendingQuestion.requestId`
still matches the requestId being cleared. Use the existing `PendingQuestion`,
`registerPendingQuestion`, and `clearPendingQuestionByRequestId` symbols to make
the stale-entry cleanup safe without removing a newer pending question.

In `@src/handler/session-queue.ts`:
- Around line 62-75: The pending-question fast path in enqueueMessage bypasses
the per-sessionKey FIFO serialization and can double-submit the same answer.
Update tryResolvePendingQuestionText to claim the pending entry or add a
markSeen/unmarkSeen-style guard keyed by requestId before awaiting
v2Client.question.reply, so concurrent calls for the same chat cannot both
resolve the same pending question. Keep the fix aligned with the existing
session-queue and pending-questions flow so the fast path remains safe under
races.

---

Nitpick comments:
In `@src/handler/pending-questions.ts`:
- Around line 71-108: Add unit tests for parsePendingQuestionReply to cover its
user-facing parsing paths, including numeric selection, continue/cancel keyword
matching, and out-of-range or invalid input handling. Use
parsePendingQuestionReply together with normalizeOptions behavior through a
minimal QuestionRequest fixture so the tests verify returned answers and
displayText for valid selections and undefined for unsupported input. Include
cases for empty trimmed content, a valid numeric index, a numeric value beyond
options length, continue text resolving via isContinueText and findOption, and
cancel text resolving via isCancelText.
🪄 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: 0197362e-a364-439a-b132-5ece398bc9a7

📥 Commits

Reviewing files that changed from the base of the PR and between d7c39c1 and ab0dc3a.

📒 Files selected for processing (3)
  • src/handler/interactive.ts
  • src/handler/pending-questions.ts
  • src/handler/session-queue.ts

Comment on lines +442 to 477
void (async () => {
const res = await sender.sendInteractiveCard(
deps.feishuClient,
chatId,
buildQuestionCardDSL(request, chatId, chatType, sessionId),
deps.log,
)
if (res.ok) return

deps.log("error", "发送问答卡片失败,回退纯文本问题", {
requestId,
chatId,
error: res.error ?? "unknown",
})
const fallback = await sender.sendTextMessage(
deps.feishuClient,
chatId,
buildQuestionFallbackText(request),
deps.log,
)
if (!fallback.ok) {
unmarkSeen(requestId)
deps.log("error", "发送问答纯文本 fallback 失败", {
requestId,
chatId,
error: fallback.error ?? "unknown",
})
}
})().catch((err) => {
unmarkSeen(requestId)
deps.log("error", "发送问答卡片失败", {
requestId,
chatId,
error: err instanceof Error ? err.message : String(err),
})
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Pending question isn't cleared when both card and fallback text delivery fail.

registerPendingQuestion runs at Line 440 before any delivery attempt. If the interactive card fails (Line 449) and the fallback text also fails (Line 462), or the whole IIFE throws (Line 470), only unmarkSeen(requestId) is called — clearPendingQuestionByRequestId(requestId) is never invoked. The pending entry then lingers (up to the 10-minute TTL) for a question the user never actually saw, so an unrelated later text message in that chat can be misinterpreted as an answer to it.

🐛 Clear pending state on full delivery failure
     if (!fallback.ok) {
       unmarkSeen(requestId)
+      clearPendingQuestionByRequestId(requestId)
       deps.log("error", "发送问答纯文本 fallback 失败", {
         requestId,
         chatId,
         error: fallback.error ?? "unknown",
       })
     }
   })().catch((err) => {
     unmarkSeen(requestId)
+    clearPendingQuestionByRequestId(requestId)
     deps.log("error", "发送问答卡片失败", {
       requestId,
       chatId,
       error: err instanceof Error ? err.message : String(err),
     })
   })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
void (async () => {
const res = await sender.sendInteractiveCard(
deps.feishuClient,
chatId,
buildQuestionCardDSL(request, chatId, chatType, sessionId),
deps.log,
)
if (res.ok) return
deps.log("error", "发送问答卡片失败,回退纯文本问题", {
requestId,
chatId,
error: res.error ?? "unknown",
})
const fallback = await sender.sendTextMessage(
deps.feishuClient,
chatId,
buildQuestionFallbackText(request),
deps.log,
)
if (!fallback.ok) {
unmarkSeen(requestId)
deps.log("error", "发送问答纯文本 fallback 失败", {
requestId,
chatId,
error: fallback.error ?? "unknown",
})
}
})().catch((err) => {
unmarkSeen(requestId)
deps.log("error", "发送问答卡片失败", {
requestId,
chatId,
error: err instanceof Error ? err.message : String(err),
})
})
void (async () => {
const res = await sender.sendInteractiveCard(
deps.feishuClient,
chatId,
buildQuestionCardDSL(request, chatId, chatType, sessionId),
deps.log,
)
if (res.ok) return
deps.log("error", "发送问答卡片失败,回退纯文本问题", {
requestId,
chatId,
error: res.error ?? "unknown",
})
const fallback = await sender.sendTextMessage(
deps.feishuClient,
chatId,
buildQuestionFallbackText(request),
deps.log,
)
if (!fallback.ok) {
unmarkSeen(requestId)
clearPendingQuestionByRequestId(requestId)
deps.log("error", "发送问答纯文本 fallback 失败", {
requestId,
chatId,
error: fallback.error ?? "unknown",
})
}
})().catch((err) => {
unmarkSeen(requestId)
clearPendingQuestionByRequestId(requestId)
deps.log("error", "发送问答卡片失败", {
requestId,
chatId,
error: err instanceof Error ? err.message : String(err),
})
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/handler/interactive.ts` around lines 442 - 477, The pending question is
only being unmarked on delivery failure, but the pending entry itself is never
cleared when both the interactive card and the fallback text fail or when the
async send flow throws. Update the error handling in interactive.ts around
registerPendingQuestion, sendInteractiveCard, sendTextMessage, and the IIFE
catch so that clearPendingQuestionByRequestId(requestId) is invoked in every
full-failure path, alongside unmarkSeen(requestId), to avoid leaving stale
pending state behind.

Comment on lines +28 to +52
const pendingByChatId = new TtlMap<PendingQuestion>(PENDING_QUESTION_TTL_MS)
const chatIdByRequestId = new TtlMap<string>(PENDING_QUESTION_TTL_MS)

export function registerPendingQuestion(params: {
request: QuestionRequest
chatId: string
sessionId: string
}): void {
const requestId = String(params.request.id ?? "")
if (!requestId) return

pendingByChatId.set(params.chatId, {
requestId,
sessionId: params.sessionId,
chatId: params.chatId,
request: params.request,
})
chatIdByRequestId.set(requestId, params.chatId)
}

export function clearPendingQuestionByRequestId(requestId: string): void {
const chatId = chatIdByRequestId.get(requestId)
if (chatId) pendingByChatId.delete(chatId)
chatIdByRequestId.delete(requestId)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Stale requestId can wipe a newer pending question for the same chat.

registerPendingQuestion overwrites pendingByChatId.set(chatId, ...) on every call for a chat, but the previous requestId → chatId entry in chatIdByRequestId is never removed. If a second question is registered for the same chat before the first is cleared (e.g. a late card callback, a retried SSE event, or a race with tryResolvePendingQuestionText), calling clearPendingQuestionByRequestId(oldRequestId) will still resolve to the same chatId and delete the current (newer) pending entry — even though the newer question's requestId doesn't match.

This can silently cancel a live pending question and, downstream in interactive.ts (Line 606) and session-queue.ts, cause replies to be dropped or misrouted.

🐛 Verify requestId ownership before clearing
 export function clearPendingQuestionByRequestId(requestId: string): void {
   const chatId = chatIdByRequestId.get(requestId)
-  if (chatId) pendingByChatId.delete(chatId)
+  if (chatId) {
+    const current = pendingByChatId.get(chatId)
+    // Only clear if the chat's current pending entry still belongs to this requestId;
+    // otherwise a stale/late clear would wipe out a newer pending question.
+    if (current?.requestId === requestId) pendingByChatId.delete(chatId)
+  }
   chatIdByRequestId.delete(requestId)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const pendingByChatId = new TtlMap<PendingQuestion>(PENDING_QUESTION_TTL_MS)
const chatIdByRequestId = new TtlMap<string>(PENDING_QUESTION_TTL_MS)
export function registerPendingQuestion(params: {
request: QuestionRequest
chatId: string
sessionId: string
}): void {
const requestId = String(params.request.id ?? "")
if (!requestId) return
pendingByChatId.set(params.chatId, {
requestId,
sessionId: params.sessionId,
chatId: params.chatId,
request: params.request,
})
chatIdByRequestId.set(requestId, params.chatId)
}
export function clearPendingQuestionByRequestId(requestId: string): void {
const chatId = chatIdByRequestId.get(requestId)
if (chatId) pendingByChatId.delete(chatId)
chatIdByRequestId.delete(requestId)
}
const pendingByChatId = new TtlMap<PendingQuestion>(PENDING_QUESTION_TTL_MS)
const chatIdByRequestId = new TtlMap<string>(PENDING_QUESTION_TTL_MS)
export function registerPendingQuestion(params: {
request: QuestionRequest
chatId: string
sessionId: string
}): void {
const requestId = String(params.request.id ?? "")
if (!requestId) return
pendingByChatId.set(params.chatId, {
requestId,
sessionId: params.sessionId,
chatId: params.chatId,
request: params.request,
})
chatIdByRequestId.set(requestId, params.chatId)
}
export function clearPendingQuestionByRequestId(requestId: string): void {
const chatId = chatIdByRequestId.get(requestId)
if (chatId) {
const current = pendingByChatId.get(chatId)
// Only clear if the chat's current pending entry still belongs to this requestId;
// otherwise a stale/late clear would wipe out a newer pending question.
if (current?.requestId === requestId) pendingByChatId.delete(chatId)
}
chatIdByRequestId.delete(requestId)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/handler/pending-questions.ts` around lines 28 - 52,
`registerPendingQuestion` and `clearPendingQuestionByRequestId` can let an old
requestId delete a newer pending question for the same chat. Update the
`pendingByChatId`/`chatIdByRequestId` bookkeeping so each chat only has one
active requestId mapping at a time, and before clearing in
`clearPendingQuestionByRequestId` verify the stored `PendingQuestion.requestId`
still matches the requestId being cleared. Use the existing `PendingQuestion`,
`registerPendingQuestion`, and `clearPendingQuestionByRequestId` symbols to make
the stale-entry cleanup safe without removing a newer pending question.

Comment on lines 62 to +75
export async function enqueueMessage(ctx: FeishuMessageContext, deps: ChatDeps): Promise<void> {
if (
ctx.shouldReply &&
ctx.messageType === "text" &&
deps.interactiveDeps &&
await tryResolvePendingQuestionText({
chatId: ctx.chatId,
content: ctx.content,
deps: deps.interactiveDeps,
})
) {
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Pending-question fast path bypasses per-sessionKey serialization and can double-submit an answer.

This early-return path runs tryResolvePendingQuestionText outside the FIFO queue, before handleQueuedMessage/drainLoop ever gets involved. tryResolvePendingQuestionText (see src/handler/pending-questions.ts, Lines 110-151) reads pendingByChatId.get(chatId) synchronously and only clears the entry after awaiting v2Client.question.reply(...). If two text messages for the same chat race in close together (double send, retry, multi-device), both can read the same pending entry before either clears it, resulting in two calls to v2Client.question.reply for the same requestId — a non-idempotent external write without a dedup guard.

As per path instructions, src/handler/session-queue.ts is meant to "Implement per-sessionKey FIFO queue to prevent message race conditions and ensure serial ordering," but this new fast path sits ahead of that queue and doesn't inherit its serialization guarantee.

🔒 Guard against concurrent double-submission

Reuse the existing markSeen/unmarkSeen-style dedup guard (already used in interactive.ts) keyed by the pending requestId inside tryResolvePendingQuestionText, or claim/clear the pending entry synchronously before the await on v2Client.question.reply, so a second concurrent caller for the same chat sees no pending entry to act on.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/handler/session-queue.ts` around lines 62 - 75, The pending-question fast
path in enqueueMessage bypasses the per-sessionKey FIFO serialization and can
double-submit the same answer. Update tryResolvePendingQuestionText to claim the
pending entry or add a markSeen/unmarkSeen-style guard keyed by requestId before
awaiting v2Client.question.reply, so concurrent calls for the same chat cannot
both resolve the same pending question. Keep the fix aligned with the existing
session-queue and pending-questions flow so the fast path remains safe under
races.

Source: Path instructions

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.

2 participants