Skip to content

fix: key tool-permission resolvers by call id instead of window.resolveTool#169

Merged
jmaxdev merged 2 commits intoTrixtyAI:mainfrom
matiaspalmac:fix/resolve-tool-map
Apr 21, 2026
Merged

fix: key tool-permission resolvers by call id instead of window.resolveTool#169
jmaxdev merged 2 commits intoTrixtyAI:mainfrom
matiaspalmac:fix/resolve-tool-map

Conversation

@matiaspalmac
Copy link
Copy Markdown
Contributor

[Fix]: Key tool-permission resolvers by call id instead of window.resolveTool

Description

Tool-permission prompts stored their Promise resolver on a single
global — window.resolveTool — and the UI's Allow/Deny buttons called
into that global. Because only one slot existed, two overlapping
permission prompts (two chat submissions racing, a tool loop
retrying over its own pending prompt, extension-triggered tool calls
interleaving with user submissions, etc.) silently overwrote the
first resolver. The first Promise then dangled forever, its outer
await permissionPromise never returned, and the send loop for that
message stalled without a visible error. The shape also polluted the
global Window type with an implementation detail of a single
component.

Change

apps/desktop/src/addons/builtin.ai-assistant/AiChatComponent.tsx:

  • Replaced window.resolveTool with a ref-backed
    Map<callId, resolver>. Each permission prompt registers its own
    entry under the current tool call id.
  • The Allow/Deny buttons in the pending-tool dialog now look up the
    resolver for the currently visible pendingTool.id, delete the
    entry, clear pendingTool, and fire only that resolver.
  • When a new prompt arrives while a previous one is still
    unresolved, the older entry is resolved as denied before the
    new one takes over the single-slot dialog. Denying is the
    fail-closed default: the user never actually saw the older dialog
    (the UI only renders the latest), so the earlier tool was not
    approved and should not be treated as such.
  • A cleanup effect iterates the Map on unmount and resolves every
    remaining entry as denied so awaiters don't leak past the
    component's lifetime.

apps/desktop/src/api/trixty.ts:

  • Dropped the resolveTool?: (allowed: boolean) => void entry from
    the declare global { interface Window { … } } block.

Trade-offs

  • Dialog UX unchanged. The pending-tool dialog is still
    single-slot — only the most recent prompt is shown. Stacking
    multiple dialogs would be a UX change beyond the scope of this
    fix, and the new Map accommodates it later if someone wants to
    move in that direction.
  • Auto-deny on override. A deliberately-denied older prompt is a
    visible behavior change vs. "dangles forever". It matches the
    defensive default (unseen prompts stay unapproved), aligns with the
    cleanup-on-unmount path, and surfaces the race to the calling code
    immediately instead of the old silent stall.
  • No tests. The resolver plumbing is pure React ref + Promise,
    and the existing project has no renderer-level test harness to
    drive the component. Tracing the four code paths (first register,
    override, allow, deny) by hand was enough to verify in isolation.

Verification

  • pnpm tsc --noEmit across the desktop app → clean.
  • pnpm eslint on both touched files → 0 findings.
  • Grep confirms no remaining references to window.resolveTool
    anywhere except the new explanatory comment.
  • Manual trace:
    • Single prompt → register, Allow resolves with true, Deny with
      false, dialog clears.
    • Two prompts back-to-back → older entry auto-denies, newer becomes
      the visible dialog, clicking Allow/Deny affects only the newer.
    • Component unmount mid-prompt → cleanup effect resolves every
      outstanding entry with false, no dangling Promises.

Related Issue

Fixes #72

Checklist

  • I have tested this on the latest version.
  • I have followed the project's coding guidelines.
  • My changes generate no new warnings or errors.
  • I have verified the fix on:
    • OS: Windows
    • Version: v1.0.10

Copilot AI review requested due to automatic review settings April 21, 2026 02:47
@github-actions
Copy link
Copy Markdown

Thanks for the contribution! I'll review it as soon as possible. If you have still changes, please mark this PR as draft and all reviews will be cancelled. Tests reviews will be re-run only when the PR is marked as ready for review.

@github-actions github-actions bot added the bug Something isn't working label Apr 21, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes a concurrency bug in the desktop AI assistant tool-permission flow where overlapping permission prompts could overwrite a single global resolver (window.resolveTool), leaving earlier tool calls awaiting forever.

Changes:

  • Replaced the single global resolver with a ref-backed Map<callId, resolver> so each in-flight permission prompt is tracked independently.
  • Updated the Allow/Deny handlers to resolve the currently visible pendingTool.id entry and clean up the corresponding resolver.
  • Removed the resolveTool property from the global Window type augmentation.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
apps/desktop/src/addons/builtin.ai-assistant/AiChatComponent.tsx Stores per-call permission resolvers in a Map and updates the permission dialog button handlers to resolve by call id.
apps/desktop/src/api/trixty.ts Removes resolveTool from the global Window interface extension.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread apps/desktop/src/addons/builtin.ai-assistant/AiChatComponent.tsx
Comment thread apps/desktop/src/addons/builtin.ai-assistant/AiChatComponent.tsx
Comment thread apps/desktop/src/addons/builtin.ai-assistant/AiChatComponent.tsx Outdated
matiaspalmac added a commit to matiaspalmac/ide that referenced this pull request Apr 21, 2026
…overrides

Addresses review feedback on TrixtyAI#169:

- Replace `Math.random().toString(36).substr(2, 9)` with
  `crypto.randomUUID()` (with a timestamp-plus-random fallback for
  environments where randomUUID is unavailable). The previous id had
  narrow entropy and could collide with a still-pending entry in the
  resolver Map, silently overwriting the earlier resolver and
  reintroducing the dangling-Promise bug this PR is meant to close.
  Also drops the deprecated `substr` in favor of `slice`.

- In the register path, if the new callId already has a resolver
  entry (e.g. because the provider reused an id), resolve that prior
  resolver as denied before replacing it. Pairs with the
  override-an-older-different-id behavior that was already there.

- Rewrote the Allow and Deny button handlers to:
  1. Capture the id at click time and early-return when no matching
     resolver is in the Map (stale or double-click).
  2. Clear `pendingTool` only via a functional setter that checks
     `current.id === clickedId`. A faster follow-up prompt that's
     already replaced `pendingTool` in state is no longer hidden by
     a click on the old dialog.
@matiaspalmac matiaspalmac force-pushed the fix/resolve-tool-map branch from 685e50b to 6a3f86a Compare April 21, 2026 03:08
…veTool

The previous implementation stored the in-flight permission resolver
on `window.resolveTool`, which meant two overlapping permission
prompts (two chat submissions racing, a tool loop retrying over its
own pending prompt, etc.) silently overwrote the first resolver. The
first Promise then dangled forever and its `await permissionPromise`
never returned, leaking the first tool call's state and stalling the
outer send loop. It also polluted the global `Window` type with an
implementation detail.

Changes:

- AiChatComponent keeps a ref-backed Map<callId, resolver>. Each
  permission prompt registers its own resolver by call id, so the
  Allow/Deny buttons on the UI look up the resolver for the currently
  visible `pendingTool.id` rather than a shared global.
- If a new prompt arrives while a previous one is still unresolved,
  the older entry is resolved as denied before the new one takes over
  the single-slot dialog. That's the safe default: the user never
  actually saw the older dialog (only the latest renders), so the
  earlier tool is not approved.
- A cleanup effect resolves every remaining entry as denied on
  unmount so awaiters don't leak past the component's lifetime.
- Dropped the global `resolveTool?: (allowed: boolean) => void`
  declaration from `src/api/trixty.ts`.
…overrides

Addresses review feedback on TrixtyAI#169:

- Replace `Math.random().toString(36).substr(2, 9)` with
  `crypto.randomUUID()` (with a timestamp-plus-random fallback for
  environments where randomUUID is unavailable). The previous id had
  narrow entropy and could collide with a still-pending entry in the
  resolver Map, silently overwriting the earlier resolver and
  reintroducing the dangling-Promise bug this PR is meant to close.
  Also drops the deprecated `substr` in favor of `slice`.

- In the register path, if the new callId already has a resolver
  entry (e.g. because the provider reused an id), resolve that prior
  resolver as denied before replacing it. Pairs with the
  override-an-older-different-id behavior that was already there.

- Rewrote the Allow and Deny button handlers to:
  1. Capture the id at click time and early-return when no matching
     resolver is in the Map (stale or double-click).
  2. Clear `pendingTool` only via a functional setter that checks
     `current.id === clickedId`. A faster follow-up prompt that's
     already replaced `pendingTool` in state is no longer hidden by
     a click on the old dialog.
@jmaxdev jmaxdev force-pushed the fix/resolve-tool-map branch from 6a3f86a to 0fbc1d0 Compare April 21, 2026 03:29
@jmaxdev jmaxdev merged commit e5cd398 into TrixtyAI:main Apr 21, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Global window.resolveTool is overwritten by concurrent permission requests

3 participants