Skip to content

Retire elicitation cards when the question is answered - #1116

Merged
alexeyzimarev merged 2 commits into
mainfrom
nortonandreev/ai-2899-user-declined-to-answer-question-keeps-elicitation-question
Sep 23, 2026
Merged

alexeyzimarev merged 2 commits into
mainfrom
nortonandreev/ai-2899-user-declined-to-answer-question-keeps-elicitation-question

Conversation

@nortonandreev

Copy link
Copy Markdown
Contributor

Closes #1114 — AI-2899

What & why

Sending a message instead of answering AskUserQuestion leaves the desktop question card up, including a second copy of the same question. The permission hook carries no tool-use id, so the card never matched the tool result that records the decline. The chat now retires a question card whose text matches the latest settled ask. A server-lane twin has no withdraw frame, so it is dropped locally. A later ask of the same text stays until its own result.

Where to look

A card requested after the result is left alone, and an open second ask is not cleared by the earlier result. A prompt that is not a question, and has no tool-use id, is still left alone.

Verification

dotnet run --project test/Capacitor.App.Tests.Unit/Capacitor.App.Tests.Unit.csproj -- --treenode-filter "/*/*/ChatTabViewModelTests/*" — 73 passed.

dotnet run --project test/Capacitor.App.Tests.Unit/Capacitor.App.Tests.Unit.csproj -- --treenode-filter "/*/*/PermissionServiceTests/*" — 43 passed.

The permission hook carries no tool-use id, so the card is matched on
question text to the latest ask, and only after that ask has a result.
@linear-code

linear-code Bot commented Sep 22, 2026

Copy link
Copy Markdown

AI-2899

@nortonandreev nortonandreev self-assigned this Sep 22, 2026
@nortonandreev

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ebbe42e63e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

AskedQuestion? latest = null;
foreach (var ask in _questionAsks)
if (request.OverlapsQuestion(ask.Fingerprints)) latest = ask;
return latest is { SettledAt: { } settled } && request.RequestedAt <= settled;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not withdraw requests with unknown timestamps

When a structurally accepted PermissionPendingDto has a nonempty but unparsable requested_at value, PendingPermissionRequest converts it to DateTimeOffset.MinValue (the shared PermissionWire.IsPendingStructurallyValid only requires that the field be nonempty). This comparison then treats the request as older than every settled matching question, so a current card with the same text can be withdrawn—and, for a server card, tombstoned—despite its actual ordering being unknown. Skip the text-based retirement when the request timestamp cannot be parsed rather than treating it as the oldest possible request.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

An unparsed requested_at becomes DateTimeOffset.MinValue, and text matching treated that as older than the settled ask. A card with that stamp is now left in place. Covered by A_question_whose_time_did_not_parse_is_not_retired_by_a_settled_ask.

An unparsed requested_at is the minimum timestamp, which would look
older than every settled ask of the same question.
@nortonandreev
nortonandreev marked this pull request as ready for review September 22, 2026 19:05
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Retire answered elicitation cards without tool-use IDs

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Retires question cards by matching prompt fingerprints to the latest settled ask.
• Preserves newer, open, unrelated, and unparseable-timestamp cards to prevent false retirement.
• Drops stale server-lane cards locally when no withdrawal frame exists.
Diagram

sequenceDiagram
    participant Feed as Transcript Feed
    participant Chat as Chat Tab
    participant Card as Question Card
    participant Perm as Permission Service
    participant Daemon as Local Daemon
    participant Cache as Server Cache
    Feed->>Chat: Record ask fingerprints
    Card->>Chat: Publish pending question
    Feed->>Chat: Record settled result
    Chat->>Chat: Match latest ask and time
    Chat->>Perm: Withdraw stale card
    alt Local lane
        Perm->>Daemon: Send withdraw frame
    else Server lane
        Perm->>Cache: Conclude locally
    end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Propagate a stable tool-use ID
  • ➕ Provides exact correlation without text or timestamp heuristics
  • ➕ Naturally distinguishes repeated questions
  • ➖ Requires the permission hook or vendor protocol to expose the identifier
  • ➖ May require coordinated wire, daemon, and compatibility changes
2. Correlate requests in the daemon
  • ➕ Centralizes lifecycle reconciliation outside the UI
  • ➕ Could give all clients consistent retirement behavior
  • ➖ The daemon lacks enough identity data for exact matching today
  • ➖ Adds protocol and persistence complexity for a targeted desktop issue

Recommendation: Use the PR's text-and-time correlation as the practical compatibility fix because the affected hook supplies no tool-use ID. Its latest-ask rule and conservative timestamp handling limit false matches; stable ID propagation would be preferable if the upstream protocol later exposes one.

Files changed (7) +189 / -14

Bug fix (3) +82 / -8
PendingPermissionRequest.csAdd question fingerprint extraction and overlap matching +23/-0

Add question fingerprint extraction and overlap matching

• Extracts normalized question and header combinations from AskUserQuestion payloads. Adds overlap matching so identifier-less cards can be correlated with transcript asks.

src/Capacitor.App/Services/PendingPermissionRequest.cs

PermissionService.csConclude server-lane withdrawals locally +10/-4

Conclude server-lane withdrawals locally

• Changes server-lane withdrawal from an unsupported transport failure to a successful local conclusion. Replayed server requests with the same ID remain suppressed without a daemon round trip.

src/Capacitor.App/Services/PermissionService.cs

ChatTabViewModel.csCorrelate question cards with settled transcript asks +49/-4

Correlate question cards with settled transcript asks

• Tracks AskUserQuestion fingerprints and settlement timestamps while reading the transcript. Identifier-less cards are withdrawn only when they match the latest ask and were requested no later than its result; unknown timestamps, newer cards, and open repeated asks remain visible.

src/Capacitor.App/ViewModels/ChatTabViewModel.cs

Tests (3) +105 / -5
ChatTabViewModelTests.csCover identifier-less question retirement edge cases +80/-0

Cover identifier-less question retirement edge cases

• Tests matching duplicate cards, unrelated and plain prompts, cards created after settlement, repeated open questions, and malformed request timestamps.

test/Capacitor.App.Tests.Unit/ChatTabViewModelTests.cs

FakePermissionService.csMirror local server-lane retirement in the permission fake +6/-5

Mirror local server-lane retirement in the permission fake

• Updates the test double to remove server-lane cards locally and report success without consuming a scripted daemon outcome.

test/Capacitor.App.Tests.Unit/FakePermissionService.cs

PermissionServiceTests.csVerify server-lane cards are retired without transport +19/-0

Verify server-lane cards are retired without transport

• Confirms server-lane withdrawal succeeds, performs no daemon or response call, removes the cached card, and suppresses replay of the same request.

test/Capacitor.App.Tests.Unit/PermissionServiceTests.cs

Documentation (1) +2 / -1
IPermissionService.csDocument lane-specific withdrawal behavior +2/-1

Document lane-specific withdrawal behavior

• Clarifies that local requests conclude after an acknowledgement while server-lane requests are dropped locally because no withdrawal frame exists.

src/Capacitor.App/Services/IPermissionService.cs

@qodo-code-review

qodo-code-review Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (1) 🔗 Cross-repo conflicts (2) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Clock skew retires live questions 🔗 Cross-repo conflict ≡ Correctness
Description
ShouldWithdraw compares a transcript result's SettledAt timestamp with RequestedAt, even
though live server requests synthesize RequestedAt from the desktop's receipt time rather than the
question's authoritative creation time. Clock skew or notification delay can invert the ordering, so
a matching same-text card may be removed before its tool call reaches the transcript feed or remain
open after its result has already arrived.
Code

src/Capacitor.App/ViewModels/ChatTabViewModel.cs[R897-899]

+        foreach (var ask in _questionAsks)
+            if (request.OverlapsQuestion(ask.Fingerprints)) latest = ask;
+        return latest is { SettledAt: { } settled } && request.RequestedAt <= settled;
Relevance

●●● Strong

Recent history accepts ChatTab lifecycle and timing fixes where stale or mismatched state causes
incorrect UI reconciliation.

PR-#889
PR-#979

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The withdrawal predicate requires RequestedAt <= SettledAt, where SettledAt comes from the
transcript event timestamp, but kcap-server's permission payload contains only the session ID,
request ID, tool name, input, and suggestions. Because ServerPermissionFeed fills the missing
RequestedAt with TimeProvider.GetUtcNow() when the subscription delivers the notification, the
comparison orders timestamps from different clocks and lifecycle points; delivery delay or desktop
clock skew can therefore invert the relationship even when the request belongs to the completed ask.

src/Capacitor.App/ViewModels/ChatTabViewModel.cs[893-917]
src/Capacitor.App/Services/ServerPermissionFeed.cs[28-33]
src/Capacitor.App/Services/ServerConnectionService.cs[262-264]
src/Capacitor.App/Services/ServerPermissionFeed.cs[39-44]
src/Capacitor.App/Services/PendingPermissionRequest.cs[36-64]
External repo: kurrent-io/kcap-server, src/Capacitor.Server/Hooks/SessionHookHandlers.cs [2216-2221]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Server permission pushes do not include the question's creation timestamp, so server-lane requests use the desktop's local receipt time as `RequestedAt` and compare it with a transcript result timestamp from another producer. Clock skew or delayed delivery can therefore cause incorrect retirement ordering.

## Fix Focus Areas
- src/Capacitor.App/ViewModels/ChatTabViewModel.cs[893-917]
- src/Capacitor.App/Services/ServerPermissionFeed.cs[28-44]
- src/Capacitor.App/Services/ServerConnectionService.cs[262-264]
- src/Capacitor.App/Services/PendingPermissionRequest.cs[55-64]
- /cross_repos/kcap-server/src/Capacitor.Server/Hooks/SessionHookHandlers.cs[2216-2221]

## Recommended Fix
Extend kcap-server's live permission and elicitation events with an authoritative request creation timestamp generated on the transcript timeline, carry it through `ServerPermissionRequest`, and pass it into `PendingPermissionRequest.FromServer` instead of using `TimeProvider.GetUtcNow()`. Use that timestamp for result ordering, and treat pushes from older servers that omit the field as unordered rather than withdrawing them based on client receipt time; only compare ordering when both timestamps originate from the request and transcript event timeline.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Claude parsing bypasses its harness 📘 Rule violation ⌂ Architecture
Description
QuestionTexts directly invokes ClaudeElicitation.TryParse, while Apply recognizes the same
vendor tool through ClaudeElicitation.ToolName in the desktop app assembly. When Claude's question
format or matching semantics change, maintainers must update both Capacitor.App and the designated
Claude harness instead of keeping vendor behavior within the established isolation boundary.
Code

src/Capacitor.App/Services/PendingPermissionRequest.cs[R127-128]

+    internal static HashSet<string>? QuestionTexts(string? toolInputJson) {
+        if (ClaudeElicitation.TryParse(toolInputJson) is not { } parsed) return null;
Relevance

●● Moderate

Architecture concern is plausible, but history lacks a close precedent enforcing vendor parsing
isolation here.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 2762984 requires vendor-specific parsing and chat behavior to live under the corresponding
Harness/<Vendor>/ directory. The changed app code directly parses Claude payloads and recognizes
Claude's tool name, while the repository already locates comparable Claude chat behavior in
Harness/Claude.

Rule 2762984: Place vendor-specific harness code only in the correct Harness/&lt;Vendor&gt;/ assembly and directory
src/Capacitor.App/Services/PendingPermissionRequest.cs[119-140]
src/Capacitor.App/ViewModels/ChatTabViewModel.cs[768-770]
src/Capacitor.Cli.Core/Harness/Claude/ClaudeChatRules.cs[8-18]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Claude-specific AskUserQuestion parsing and matching were added directly to the desktop app, outside the repository's designated vendor harness structure.

## Fix Focus Areas
- src/Capacitor.App/Services/PendingPermissionRequest.cs[119-140]
- src/Capacitor.App/ViewModels/ChatTabViewModel.cs[768-770]
- src/Capacitor.App/ViewModels/ChatTabViewModel.cs[893-923]
- src/Capacitor.Cli.Core/Harness/Claude/ClaudeChatRules.cs[8-18]

## Recommended Fix
Move Claude tool recognition, payload parsing, and question fingerprint extraction behind a vendor-neutral interface implemented under `Capacitor.Cli.Core/Harness/Claude`. Keep only generic reconciliation state and withdrawal orchestration in `Capacitor.App`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Restarts hide new permission requests 🔗 Cross-repo conflict ≡ Correctness
Description
WithdrawAsync calls ConcludeServerKey, permanently tombstoning an opaque server request ID even
though kcap-server generates that ID from a process-local counter. When kcap-server restarts and
reuses the same session-and-counter ID, UpsertServer also rejects the new actionable request, so
the desktop never presents it.
Code

src/Capacitor.App/Services/PermissionService.cs[R138-140]

+        lock (_lock) {
+            if (!_disposed) ConcludeServerKey(target.RequestId);
+        }
Relevance

●● Moderate

Restart ID reuse is a credible lifecycle risk, but no closely matching repository precedent confirms
this tombstone behavior.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The changed branch permanently concludes the server key, and the app rejects every later server
request carrying a tombstoned key. Kcap-server constructs these keys from a session ID and an
in-memory singleton counter, which resets when the server process restarts.

src/Capacitor.App/Services/PermissionService.cs[133-141]
src/Capacitor.App/Services/PermissionService.cs[335-344]
External repo: kurrent-io/kcap-server, src/Capacitor.Server/PermissionRequestTracker.cs [12-33]
External repo: kurrent-io/kcap-server, src/Capacitor.Server/Program.cs [325-334]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Server-lane withdrawal permanently tombstones request IDs, but kcap-server can reuse those IDs after its in-memory tracker restarts, causing later valid requests to be suppressed.

## Fix Focus Areas
- src/Capacitor.App/Services/PermissionService.cs[133-141]
- src/Capacitor.App/Services/PermissionService.cs[335-344]
- /cross_repos/kcap-server/src/Capacitor.Server/PermissionRequestTracker.cs[12-33]

## Recommended Fix
Coordinate with kcap-server to make permission request IDs globally unique across process lifetimes, such as by including a random or startup-epoch component. Until that contract is available, do not retain server-lane tombstones beyond a server lifecycle boundary.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 64 rules
✅ Cross-repo context — repo relationships
  Explored: repo: kurrent-io/kcap-server (sha: 29a0b354)
Review mode: ⚖️ Balanced: This is a behavior-changing, cross-file lifecycle and matching change affecting permission handling and chat state, so it warrants a complete single-pass review.

Grey Divider

Tip of the day
💡 Did you know, you can keep summaries lean with Finding overflow, which tucks the rest behind 'View more'

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +127 to +128
internal static HashSet<string>? QuestionTexts(string? toolInputJson) {
if (ClaudeElicitation.TryParse(toolInputJson) is not { } parsed) return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

2. Claude parsing bypasses its harness 📘 Rule violation ⌂ Architecture

Rule: Rule 2762984: Place vendor-specific harness code only in the correct Harness/&lt;Vendor&gt;/ assembly and directory

QuestionTexts directly invokes ClaudeElicitation.TryParse, while Apply recognizes the same
vendor tool through ClaudeElicitation.ToolName in the desktop app assembly. When Claude's question
format or matching semantics change, maintainers must update both Capacitor.App and the designated
Claude harness instead of keeping vendor behavior within the established isolation boundary.
Agent Prompt
## Issue description
Claude-specific AskUserQuestion parsing and matching were added directly to the desktop app, outside the repository's designated vendor harness structure.

## Fix Focus Areas
- src/Capacitor.App/Services/PendingPermissionRequest.cs[119-140]
- src/Capacitor.App/ViewModels/ChatTabViewModel.cs[768-770]
- src/Capacitor.App/ViewModels/ChatTabViewModel.cs[893-923]
- src/Capacitor.Cli.Core/Harness/Claude/ClaudeChatRules.cs[8-18]

## Recommended Fix
Move Claude tool recognition, payload parsing, and question fingerprint extraction behind a vendor-neutral interface implemented under `Capacitor.Cli.Core/Harness/Claude`. Keep only generic reconciliation state and withdrawal orchestration in `Capacitor.App`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

QuestionTexts calls ClaudeElicitation.TryParse, which is already how the desktop reads an AskUserQuestion card. A new interface would move that existing parser.

Comment on lines +138 to +140
lock (_lock) {
if (!_disposed) ConcludeServerKey(target.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.

Remediation recommended

3. Restarts hide new permission requests 🔗 Cross-repo conflict ≡ Correctness

WithdrawAsync calls ConcludeServerKey, permanently tombstoning an opaque server request ID even
though kcap-server generates that ID from a process-local counter. When kcap-server restarts and
reuses the same session-and-counter ID, UpsertServer also rejects the new actionable request, so
the desktop never presents it.
Agent Prompt
## Issue description
Server-lane withdrawal permanently tombstones request IDs, but kcap-server can reuse those IDs after its in-memory tracker restarts, causing later valid requests to be suppressed.

## Fix Focus Areas
- src/Capacitor.App/Services/PermissionService.cs[133-141]
- src/Capacitor.App/Services/PermissionService.cs[335-344]
- /cross_repos/kcap-server/src/Capacitor.Server/PermissionRequestTracker.cs[12-33]

## Recommended Fix
Coordinate with kcap-server to make permission request IDs globally unique across process lifetimes, such as by including a random or startup-epoch component. Until that contract is available, do not retain server-lane tombstones beyond a server lifecycle boundary.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The id is session plus a counter that starts over when the server process does. ConcludeServerKey already tombstones that id when a server card is answered or the server reports it settled. This withdraw uses the same key so a replay of the declined question stays dropped. Dropping the tombstone would show the card again. An id that survives a restart is a server change, and it already applies to every concluded request.

Comment on lines +897 to +899
foreach (var ask in _questionAsks)
if (request.OverlapsQuestion(ask.Fingerprints)) latest = ask;
return latest is { SettledAt: { } settled } && request.RequestedAt <= settled;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

1. Clock skew retires live questions 🔗 Cross-repo conflict ≡ Correctness

ShouldWithdraw compares a transcript result's SettledAt timestamp with RequestedAt, even
though live server requests synthesize RequestedAt from the desktop's receipt time rather than the
question's authoritative creation time. Clock skew or notification delay can invert the ordering, so
a matching same-text card may be removed before its tool call reaches the transcript feed or remain
open after its result has already arrived.
Agent Prompt
## Issue description
Server permission pushes do not include the question's creation timestamp, so server-lane requests use the desktop's local receipt time as `RequestedAt` and compare it with a transcript result timestamp from another producer. Clock skew or delayed delivery can therefore cause incorrect retirement ordering.

## Fix Focus Areas
- src/Capacitor.App/ViewModels/ChatTabViewModel.cs[893-917]
- src/Capacitor.App/Services/ServerPermissionFeed.cs[28-44]
- src/Capacitor.App/Services/ServerConnectionService.cs[262-264]
- src/Capacitor.App/Services/PendingPermissionRequest.cs[55-64]
- /cross_repos/kcap-server/src/Capacitor.Server/Hooks/SessionHookHandlers.cs[2216-2221]

## Recommended Fix
Extend kcap-server's live permission and elicitation events with an authoritative request creation timestamp generated on the transcript timeline, carry it through `ServerPermissionRequest`, and pass it into `PendingPermissionRequest.FromServer` instead of using `TimeProvider.GetUtcNow()`. Use that timestamp for result ordering, and treat pushes from older servers that omit the field as unordered rather than withdrawing them based on client receipt time; only compare ordering when both timestamps originate from the request and transcript event timeline.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

A local card takes requested_at from the hook, on the same machine as the transcript. A stamp that did not parse is already left in place. A server push has no creation time, so its stamp is the desktop receipt time. A receipt after the result fails the comparison and the card stays; the server responded event still concludes that id. A receipt before the result is the card that was on screen when the question was answered.

@alexeyzimarev
alexeyzimarev merged commit e87ebf8 into main Sep 23, 2026
8 checks passed
@alexeyzimarev
alexeyzimarev deleted the nortonandreev/ai-2899-user-declined-to-answer-question-keeps-elicitation-question branch September 23, 2026 15:30
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.

Desktop chat keeps an elicitation question after the user declines it

2 participants