Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions traces/src/components/ui/tool-status-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { TriangleAlert } from 'lucide-react'
import { useEffect, useState } from 'react'
import { onToolChange } from '@/lib/webmcp/tool-change'
import type { RegistrationResult } from '@/lib/webmcp/register-tools'

interface ToolStatusBannerProps {
Expand Down Expand Up @@ -71,7 +72,9 @@ export function ToolStatusBanner({ registration }: ToolStatusBannerProps) {
/**
* `toolchange` is how a surface that grew a tool mid-investigation shows up here without a reload —
* the promoted-hypothesis tool from `registerDynamicTool` is the case worth demoing. The event fires
* on `document.modelContext`, which is why the draft has it extend `EventTarget`.
* on `document.modelContext`, which is why the draft has it extend `EventTarget`. Not every host
* honours that, and one of them is ChatGPT Desktop; `onToolChange` explains what subscribing there
* used to cost. When the host has no events this counter simply never moves.
*
* What is shown is the number of changes, not a recomputed total, and that is on purpose. `getTools()`
* is declared and could be awaited here, but this banner's verdict has one source — the `registration`
Expand All @@ -86,10 +89,9 @@ export function ToolStatusBanner({ registration }: ToolStatusBannerProps) {
if (!context) return

setChanges(0)
const onToolChange = () => setChanges((n) => n + 1)
const bump = () => setChanges((n) => n + 1)

context.addEventListener('toolchange', onToolChange)
return () => context.removeEventListener('toolchange', onToolChange)
return onToolChange(context, bump)
}, [registration])

if (!registration) return null
Expand Down
5 changes: 3 additions & 2 deletions traces/src/components/ui/webmcp-badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { ChevronDown, ChevronUp, TriangleAlert } from 'lucide-react'
import { useEffect, useState } from 'react'
import { allTools } from '@/lib/webmcp/register-tools'
import { onToolChange } from '@/lib/webmcp/tool-change'
import type { RegistrationResult } from '@/lib/webmcp/register-tools'

/**
Expand Down Expand Up @@ -149,10 +150,10 @@ export function WebMcpBadge({ registration }: { registration: RegistrationResult
}

read()
context.addEventListener('toolchange', read)
const unsubscribe = onToolChange(context, read)
return () => {
active = false
context.removeEventListener('toolchange', read)
unsubscribe()
}
}, [registration])

Expand Down
95 changes: 95 additions & 0 deletions traces/src/lib/webmcp/tool-change.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { onToolChange, resetToolChangeWarnings } from './tool-change'

/**
* A host without events must not be able to take the page down.
*
* This exists because of a specific production failure rather than for coverage. `ModelContext` is
* declared `: EventTarget` in the spec, so `types/webmcp.d.ts` types the event methods as present and
* both `tool-status-banner.tsx` and `webmcp-badge.tsx` called them directly. ChatGPT Desktop's in-app
* browser exposes a `document.modelContext` that is not an `EventTarget`, so the call threw
* `TypeError: e.addEventListener is not a function` out of a client effect, React escalated it, and the
* production build served its global error page — the whole of Traces replaced by "Application error"
* in the only browser with native WebMCP, which is the browser the challenge rules point judges at.
*
* So the assertions below are about *not throwing* and about the subscription being optional. A version
* of `onToolChange` that propagates the TypeError passes nothing here.
*/

/** A host with `registerTool` and `getTools` and no event methods at all — ChatGPT Desktop's shape. */
function eventlessHost(): ModelContext {
return {
async registerTool(): Promise<void> {},
async getTools(): Promise<RegisteredTool[]> {
return []
},
} as unknown as ModelContext
}

afterEach(() => {
resetToolChangeWarnings()
vi.restoreAllMocks()
})

describe('onToolChange', () => {
it('does not throw on a host that is not an EventTarget, and its unsubscribe is safe to call', () => {
// Arrange: the host shape that crashed production, and a listener that must never be reached.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const listener = vi.fn()

// Act
const unsubscribe = onToolChange(eventlessHost(), listener)

// Assert: the page survives, nothing was subscribed, and cleanup is still callable.
expect(() => unsubscribe()).not.toThrow()
expect(listener).not.toHaveBeenCalled()
expect(warn).toHaveBeenCalledOnce()
})

it('delivers toolchange and stops after unsubscribe on a host that is an EventTarget', () => {
// Arrange: a spec-shaped host, so the happy path is proven and not merely assumed.
const host = Object.assign(new EventTarget(), {
async registerTool(): Promise<void> {},
async getTools(): Promise<RegisteredTool[]> {
return []
},
}) as ModelContext
const listener = vi.fn()

// Act
const unsubscribe = onToolChange(host, listener)
host.dispatchEvent(new Event('toolchange'))
unsubscribe()
host.dispatchEvent(new Event('toolchange'))

// Assert: exactly one delivery — the second event lands after cleanup.
expect(listener).toHaveBeenCalledOnce()
})

it('survives a host whose addEventListener throws', () => {
// Arrange: the same class of fault one method over, which the typeof guard alone would not catch.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const host = {
addEventListener() {
throw new TypeError('Illegal invocation')
},
removeEventListener() {},
} as unknown as ModelContext

// Act & Assert
expect(() => onToolChange(host, vi.fn())).not.toThrow()
expect(warn).toHaveBeenCalledOnce()
})

it('warns once for a repeated fault rather than once per subscriber', () => {
// Arrange: two components subscribe, and both remount under React 19's double invoke.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})

// Act
onToolChange(eventlessHost(), vi.fn())
onToolChange(eventlessHost(), vi.fn())

// Assert: four identical lines would read as four separate faults.
expect(warn).toHaveBeenCalledOnce()
})
})
68 changes: 68 additions & 0 deletions traces/src/lib/webmcp/tool-change.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Subscribe to the host's `toolchange` event, on a host that may have no events at all.
*
* The spec declares `ModelContext : EventTarget` (`index.bs:604`), so `addEventListener` looks
* guaranteed and `types/webmcp.d.ts` types it that way. ChatGPT Desktop's in-app browser ships a
* `document.modelContext` that is **not** an `EventTarget`, and calling one of those methods there
* throws `TypeError: e.addEventListener is not a function`. Thrown from a client effect, React
* escalates it to the nearest error boundary, and in a production build that is Next.js's global one:
* the entire page replaced by "Application error: a client-side exception has occurred" — in the one
* browser with native WebMCP, which is the browser the challenge rules ask judges to use.
*
* So the subscription is optional and its absence is silent in the UI. Both callers render a real list
* without it; all that is lost is live updates when the surface changes shape mid-session. Losing that
* is a footnote. Losing the page is the submission.
*
* Guarded by `typeof` **and** wrapped, because the lesson of that crash is not "this one method was
* missing" — it is that a draft API on a host we do not control must never be able to take the page
* down. Returns the unsubscribe, which is a no-op when there was nothing to subscribe to.
*/
export function onToolChange(context: ModelContext, listener: () => void): () => void {
const noop = () => {}

if (typeof context.addEventListener !== 'function') {
warnOnce('this host exposes no toolchange events; the tool list will not live-update')
return noop
}

try {
context.addEventListener('toolchange', listener)
} catch (error: unknown) {
warnOnce(`toolchange subscription failed: ${message(error)}`)
return noop
}

return () => {
if (typeof context.removeEventListener !== 'function') return
try {
context.removeEventListener('toolchange', listener)
} catch {
// A host that cannot remove a listener it accepted is not worth a second diagnostic. The
// component is unmounting either way, and its own `active` flag already makes the callback inert.
}
}
}

function message(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}

/**
* One line per page load, not one per subscriber.
*
* Two components subscribe, and both remount under React 19's double invoke in development, so an
* unguarded warning prints four times and reads like four separate faults.
*/
const warned = new Set<string>()

function warnOnce(text: string): void {
if (warned.has(text)) return
warned.add(text)
// eslint-disable-next-line no-console -- a surface that silently stops updating is invisible otherwise
console.warn(`[traces] ${text}`)
}

/** Test seam: the warning set is module state, and a suite asserting on it needs it empty. */
export function resetToolChangeWarnings(): void {
warned.clear()
}
6 changes: 6 additions & 0 deletions traces/src/types/webmcp.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ interface RegisteredTool {
annotations?: ToolAnnotations
}

/**
* `extends EventTarget` follows the spec, and the spec is not the whole truth here: ChatGPT Desktop's
* in-app browser exposes a `modelContext` without those methods, so TypeScript will let you write an
* `addEventListener` call that throws on the one host with native WebMCP. Subscribe through
* `lib/webmcp/tool-change.ts` rather than touching the event methods directly.
*/
interface ModelContext extends EventTarget {
/**
* Resolves once the tool is live, and **rejects** for every failure: `InvalidStateError` for a
Expand Down