From 08497489341d8e90afca0b3b1a1b634cb56122a8 Mon Sep 17 00:00:00 2001 From: Sergei Arutiunian Date: Tue, 12 May 2026 18:49:03 +0200 Subject: [PATCH] fix(ask): require fresh response in completion branches (no stale-turn answers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each completion branch in `comet_ask` returned `status.response` whenever it looked plausible: - explicit `status.status === 'completed'` + saw-new-response - `status.isStable` + no stop button - idle timeout > 6s + substantial response What's missing in all three is "this response is actually the answer to the prompt we just sent". `extractAgentStatus`, even after the `lastIndexOf` fix in the companion correctness PR, can still pick up the PREVIOUS turn's answer when: - the new prompt is sent into the same chat tab - Perplexity has not yet visibly overwritten the prose / steps marker - the stop-button hasn't appeared yet (slow render) - `sawNewResponse` is already true because a prose count went up briefly during the "Thinking…" placeholder render Each branch therefore happily returned the prior turn's text as the answer to the new question. Silent wrong-answer — the single most dangerous correctness defect in the polling logic. Fix: snapshot the full `extractAgentStatus().response` BEFORE sending the prompt, and require `status.response !== oldResponseSnapshot` for all three completion branches plus the max-timeout fallback. If everything we see is still the old answer, fall through to the "in progress" branch and let the caller poll again. `oldResponseSnapshot` is captured in a `try { … } catch { }` so a failing pre-send status check doesn't break the prompt flow — the guard simply degrades to "any non-empty response is fresh". Co-Authored-By: Claude Opus 4.7 (1M context) --- src/index.ts | 41 +++++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index e0ab6fb..089d85a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -273,9 +273,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { // Reset stability tracking for new prompt cometAI.resetStabilityTracking(); - // Capture old response state BEFORE sending prompt (for follow-up detection) + // Capture old response state BEFORE sending prompt (for follow-up detection). + // We snapshot BOTH the cheap prose-count summary AND the full + // `extractAgentStatus().response` — the latter is what each + // completion branch returns, so comparing to it is the only way + // to be sure we're not handing back the previous turn's answer + // when Perplexity has not yet visibly updated the page. const oldStateResult = await cometClient.evaluate(`(${readProseState.toString()})()`); const oldState = oldStateResult.result.value as ProseState; + let oldResponseSnapshot = ''; + try { + const oldStatus = await cometAI.getAgentStatus(); + oldResponseSnapshot = oldStatus.response || ''; + } catch { + // Pre-send status check is best-effort; leaving oldResponseSnapshot + // empty means the freshness check below simply requires a non-empty + // response (still strictly stronger than no check at all). + } // Send the prompt await cometAI.sendPrompt(prompt); @@ -340,23 +354,33 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { // Track steps in session state sessionState.steps = stepsCollected; + // Stale-answer guard: a response equal to the snapshot taken + // BEFORE we sent the new prompt is, by definition, the previous + // turn's answer (Perplexity has not yet overwritten the DOM). + // Required by every completion branch — without it the polling + // loop can hand back the previous answer for the new question + // when `extractAgentStatus` matches stale markers still in the + // scroll buffer. + const responseIsFresh = + !!status.response && status.response !== oldResponseSnapshot; + // COMPLETION CONDITIONS (return immediately when any are met): // 1. Explicit completion detected by status checker - if (status.status === 'completed' && sawNewResponse && status.response) { + if (status.status === 'completed' && sawNewResponse && responseIsFresh) { completeTask(status.response); return { content: [{ type: "text", text: status.response }] }; } // 2. Response is stable (same content for 2+ polls) and no stop button - if (status.isStable && sawNewResponse && status.response && !status.hasStopButton) { + if (status.isStable && sawNewResponse && responseIsFresh && !status.hasStopButton) { completeTask(status.response); return { content: [{ type: "text", text: status.response }] }; } // 3. Idle timeout - no activity for 6s but we have a substantial response const idleTime = Date.now() - lastActivityTime; - if (idleTime > IDLE_TIMEOUT && sawNewResponse && status.response && + if (idleTime > IDLE_TIMEOUT && sawNewResponse && responseIsFresh && status.response.length > 100 && !status.hasStopButton) { completeTask(status.response); return { content: [{ type: "text", text: status.response }] }; @@ -391,9 +415,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } } - // Max timeout reached - return whatever we have + // Max timeout reached - return whatever we have. + // Same stale-answer guard: only return text that actually changed + // since we sent the prompt. If everything is stale we fall through + // to the "in progress" branch below, which tells the caller to + // keep polling rather than handing back the previous answer. const finalStatus = await cometAI.getAgentStatus(); - if (finalStatus.response && finalStatus.response.length > 50) { + if (finalStatus.response && finalStatus.response.length > 50 && + finalStatus.response !== oldResponseSnapshot) { completeTask(finalStatus.response); return { content: [{ type: "text", text: finalStatus.response }] }; }