hermes-agent/apps/desktop/src/store/clarify.ts
Brooklyn Nicholson 58eb473baa fix(desktop): surface background-session clarify prompts instead of hanging
clarify.request is a one-shot blocking event: the gateway turn blocks on
clarify.respond. The desktop handler dropped it for any non-focused session
(`if (!isActiveEvent) return`) and stored at most one request in a single
global atom, so a background session that asked a clarifying question hung
forever and re-focusing it could never recover (the event was already gone).

- store/clarify.ts: key pending requests by runtime session id; expose the
  active session's request via a focus-scoped computed view (ClarifyTool is
  unchanged). clearClarifyRequest takes an optional session id for targeted
  clears, with a request-id fallback.
- use-message-stream.ts: park every session's clarify (drop the isActiveEvent
  early return); toast when one lands for a background session since the row
  otherwise just keeps spinning like normal work.
- clarify-tool.tsx: clear by session id so answering one chat can't wipe
  another's pending request.
- store/clarify.test.ts: concurrent independence, focus-scoped view,
  targeted/stale/fallback clears.
2026-06-03 21:07:33 -05:00

69 lines
2.2 KiB
TypeScript

import { atom, computed } from 'nanostores'
import { $activeSessionId } from './session'
export interface ClarifyRequest {
requestId: string
question: string
choices: string[] | null
sessionId: string | null
}
// Pending clarify requests keyed by the runtime session id that raised them.
// Storing per-session (instead of one shared slot) lets a *background* session
// park its clarify request while the user is looking at a different chat, then
// resolve it once they switch over — without a second concurrent clarify
// clobbering the first. A request with no session id lands under the empty key.
const keyFor = (sessionId: string | null | undefined): string => sessionId ?? ''
export const $clarifyRequests = atom<Record<string, ClarifyRequest>>({})
// The clarify request for the currently-viewed session. The inline ClarifyTool
// only ever mounts inside the active session's transcript, so it reads this
// focus-scoped view rather than reaching into the whole map.
export const $clarifyRequest = computed(
[$clarifyRequests, $activeSessionId],
(requests, activeId) => requests[keyFor(activeId)] ?? null
)
export function setClarifyRequest(request: ClarifyRequest): void {
$clarifyRequests.set({ ...$clarifyRequests.get(), [keyFor(request.sessionId)]: request })
}
export function clearClarifyRequest(requestId?: string, sessionId?: string | null): void {
const requests = $clarifyRequests.get()
// Targeted clear when the caller knows the session (the common path from the
// inline ClarifyTool answering its own request).
if (sessionId !== undefined) {
const key = keyFor(sessionId)
const current = requests[key]
if (!current || (requestId && current.requestId !== requestId)) {
return
}
const next = { ...requests }
delete next[key]
$clarifyRequests.set(next)
return
}
// Fallback with no session hint: drop every entry matching the request id
// (or clear all when none is given).
const next: Record<string, ClarifyRequest> = {}
let changed = false
for (const [key, value] of Object.entries(requests)) {
if (requestId && value.requestId !== requestId) {
next[key] = value
} else {
changed = true
}
}
if (changed) {
$clarifyRequests.set(next)
}
}