{"repo":"NousResearch/hermes-agent","public_id":"41693","display_label":"Desktop tapClientLookup crash and error boundary recovery","summary":"Multiple bug reports and fix attempts address a recurring 'tapClientLookup: Index out of bounds' crash in the desktop renderer, along with efforts to recover gracefully via the root error boundary and an assistant-ui dependency upgrade.","subsystem":"apps/desktop","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":52704},"resolution":{"state":"partially_resolved","by":"pr-52704"},"freshness":{"state":"verified","last_verified_at":"2026-06-25T21:17:18+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.27733333333333327},"fix_edges":[{"issue_number":41693,"pr_number":52704,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":41693,"title":"[Bug]: Hermes Desktop renderer crashes with \"tapClientLookup: Index N out of bounds\" → root error-boundary forces \"Reload window\"","url":null,"state":"closed"},{"kind":"issue","number":44562,"title":"[BUG] Frontend crash : tapClientLookup Index out of bounds when tool returns unexpected data","url":null,"state":"closed"},{"kind":"issue","number":45403,"title":"bug: Desktop app crashes with 'tapClientLookup: Index out of bounds' error boundary","url":null,"state":"closed"},{"kind":"issue","number":61958,"title":"# Bug Report: `tapClientLookup` out-of-bounds renderer crash (Windows Desktop)","url":null,"state":"closed"},{"kind":"pr","number":41787,"title":"fix(desktop): recover root boundary from transient render races","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52704","number":52704},{"kind":"pr","value":"#52704","number":52704}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":52704,"title":"fix(desktop): recover root error boundary from transient render races (salvage #41787)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/src/components/error-boundary.test.tsx","apps/desktop/src/components/error-boundary.tsx"],"fix_hunks":[{"file":"apps/desktop/src/components/error-boundary.test.tsx","hunks":["@@ -0,0 +1,114 @@\n+import { cleanup, render, screen, waitFor } from '@testing-library/react'\n+import { afterEach, describe, expect, it, vi } from 'vitest'\n+\n+import { ErrorBoundary } from './error-boundary'\n+\n+// The real assistant-ui stale-index throw the root boundary must survive\n+// (open chat / session switch render race), reproduced verbatim so the\n+// recoverable-pattern match is exercised against the actual error text.\n+const TAP_ERROR = 'tapClientLookup: Index 23 out of bounds (length: 18)'\n+\n+// Throws purely from `box.error` so a render replay (React dev) throws\n+// identically; the test mutates the box only from timers, never during render —\n+// modelling a transient race that clears once the boundary remounts against\n+// fresh state.\n+function makeBomb(box: { error: Error | null }) {\n+  return function Bomb() {\n+    if (box.error) {\n+      throw box.error\n+    }\n+\n+    return <div>recovered</div>\n+  }\n+}\n+\n+const RELOAD_WINDOW = { name: 'Reload window', role: 'button' } as const\n+\n+const countRecoverWarnings = (calls: unknown[][]) =>\n+  calls.filter(call => call.some(value => String(value).includes('auto-recovering from transient render error'))).length\n+\n+describe('ErrorBoundary root auto-recovery', () => {\n+  afterEach(() => {\n+    cleanup()\n+    vi.restoreAllMocks()\n+  })\n+\n+  it('recovers the root boundary from a transient stale-index render race', async () => {\n+    vi.spyOn(console, 'error').mockImplementation(() => {})\n+    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})\n+    const box: { error: Error | null } = { error: new Error(TAP_ERROR) }\n+    const Bomb = makeBomb(box)\n+\n+    // Disarm before the scheduled next-tick reset re-renders the subtree, so the\n+    // race genuinely resolves on recovery instead of throwing forever.\n+    queueMicrotask(() => {\n+      box.error = null\n+    })\n+\n+    render(\n+      <ErrorBoundary label=\"root\">\n+        <Bomb />\n+      </ErrorBoundary>\n+    )\n+\n+    await waitFor(() => expect(screen.getByText('recovered')).toBeTruthy())\n+    expect(screen.queryByRole(RELOAD_WINDOW.role, { name: RELOAD_WINDOW.name })).toBeNull()\n+    expect(countRecoverWarnings(warnSpy.mock.calls)).toBeGreaterThanOrEqual(1)\n+  })\n+\n+  it('stops auto-recovering a persistent error after the cap and leaves the fallback up', async () => {\n+    vi.spyOn(console, 'error').mockImplementation(() => {})\n+    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})\n+    // Never disarmed: the boundary must not spin a reset -> throw -> reset loop\n+    // forever — it caps recovery and surfaces the fallback to the user.\n+    const box: { error: Error | null } = { error: new Error(TAP_ERROR) }\n+    const Bomb = makeBomb(box)\n+\n+    render(\n+      <ErrorBoundary label=\"root\">\n+        <Bomb />\n+      </ErrorBoundary>\n+    )\n+\n+    // The fallback showing up at all IS the cap working: with unbounded recovery\n+    // the boundary would reset -> throw -> reset forever and 'Reload window'\n+    // would never render (this waitFor would hang). The recovery attempts are\n+    // bounded by MAX_RECOVERIES (3), never an unbounded storm.\n+    await waitFor(() => expect(screen.getByRole(RELOAD_WINDOW.role, { name: RELOAD_WINDOW.name })).toBeTruthy())\n+    const warnings = countRecoverWarnings(warnSpy.mock.calls)\n+    expect(warnings).toBeGreaterThanOrEqual(1)\n+    expect(warnings).toBeLessThanOrEqual(3)\n+  })\n+\n+  it('does not auto-recover a non-root boundary', async () => {\n+    vi.spyOn(console, 'error').mockImplementation(() => {})\n+    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})\n+    const box: { error: Error | null } = { error: new Error(TAP_ERROR) }\n+    const Bomb = makeBomb(box)\n+\n+    render(\n+      <ErrorBoundary fallback={() => <div>scoped-fallback</div>} label=\"thread\">\n+        <Bomb />\n+      </ErrorBoundary>\n+    )\n+\n+    await waitFor(() => expect(screen.getByText('scoped-fallback')).toBeTruthy())\n+    expect(countRecoverWarnings(warnSpy.mock.calls)).toBe(0)\n+  })\n+\n+  it('does not auto-recover an unrecognized error even at the root', async () => {\n+    vi.spyOn(console, 'error').mockImplementation(() => {})\n+    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})\n+    const box: { error: Error | null } = { error: new Error('some unrelated application error') }\n+    const Bomb = makeBomb(box)\n+\n+    render(\n+      <ErrorBoundary label=\"root\">\n+        <Bomb />\n+      </ErrorBoundary>\n+    )\n+\n+    await waitFor(() => expect(screen.getByRole(RELOAD_WINDOW.role, { name: RELOAD_WINDOW.name })).toBeTruthy())\n+    expect(countRecoverWarnings(warnSpy.mock.calls)).toBe(0)\n+  })\n+})"]},{"file":"apps/desktop/src/components/error-boundary.tsx","hunks":["@@ -20,8 +20,31 @@ interface ErrorBoundaryState {\n   error: Error | null\n }\n \n+// assistant-ui can momentarily render a stale message index against a thread\n+// that just shrank (session switch / teardown), throwing a render-race error\n+// that latches the WHOLE app on the root \"Reload window\" screen. These throws\n+// clear themselves on the next render against fresh state, so the root boundary\n+// recovers itself once the storm settles instead of stranding the user.\n+const RECOVERABLE_ERROR_PATTERNS = [\n+  /tapClientLookup: Index \\d+\\s+out of bounds \\(length:\\s*\\d+\\)/i,\n+  /Cannot read properties of undefined \\(reading 'type'\\)/i,\n+  /Tried to unmount a fiber that is already unmounted/i\n+]\n+\n+const isRecoverableDesktopRenderError = (error: Error): boolean =>\n+  RECOVERABLE_ERROR_PATTERNS.some(pattern => pattern.test(error.message))\n+\n+// Bound auto-recovery so a *persistent* (non-transient) error can't spin the\n+// boundary in a reset -> throw -> reset loop: at most MAX_RECOVERIES attempts\n+// inside RECOVERY_WINDOW_MS, after which the fallback is left up for the user.\n+const MAX_RECOVERIES = 3\n+const RECOVERY_WINDOW_MS = 5_000\n+\n export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {\n   state: ErrorBoundaryState = { error: null }\n+  private recoverTimer: null | number = null\n+  private recoverCount = 0\n+  private recoverWindowStart = 0\n \n   static getDerivedStateFromError(error: Error): ErrorBoundaryState {\n     return { error }","@@ -31,9 +54,55 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt\n     const tag = this.props.label ? `[error-boundary:${this.props.label}]` : '[error-boundary]'\n     console.error(tag, error, info.componentStack)\n     this.props.onError?.(error, info)\n+\n+    if (this.props.label === 'root' && isRecoverableDesktopRenderError(error) && this.canRecover()) {\n+      console.warn(`${tag} auto-recovering from transient render error`, error.message)\n+      this.scheduleRecover()\n+    }\n+  }\n+\n+  componentWillUnmount() {\n+    this.clearRecoverTimer()\n   }\n \n   reset = () => {\n+    this.clearRecoverTimer()\n+    // A manual retry (button) starts a clean recovery budget.\n+    this.recoverCount = 0\n+    this.recoverWindowStart = 0\n+    this.setState({ error: null })\n+  }\n+\n+  // True while the boundary still has recovery budget. Each storm gets a fresh\n+  // window; auto-recovery (autoReset) deliberately does NOT reset the count, so\n+  // a tight reset -> throw loop is capped at MAX_RECOVERIES and then falls back.\n+  private canRecover(): boolean {\n+    const now = Date.now()\n+\n+    if (now - this.recoverWindowStart > RECOVERY_WINDOW_MS) {\n+      this.recoverWindowStart = now\n+      this.recoverCount = 0\n+    }\n+\n+    this.recoverCount += 1\n+\n+    return this.recoverCount <= MAX_RECOVERIES\n+  }\n+\n+  private clearRecoverTimer() {\n+    if (this.recoverTimer !== null) {\n+      window.clearTimeout(this.recoverTimer)\n+      this.recoverTimer = null\n+    }\n+  }\n+\n+  private scheduleRecover() {\n+    this.clearRecoverTimer()\n+    this.recoverTimer = window.setTimeout(this.autoReset, 0)\n+  }\n+\n+  private autoReset = () => {\n+    this.recoverTimer = null\n     this.setState({ error: null })\n   }\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}