import { atom, computed } from 'nanostores' import { $gateway } from './gateway' import { $activeSessionId } from './session' export interface ClarifyQuestion { /** Server-generated wire id (q0..qN) — clarify.respond keys answers by it. */ qid: string question: string choices: string[] | null multiSelect: boolean } export interface ClarifyRequest { requestId: string question: string choices: string[] | null multiSelect: boolean /** Local receipt time (Unix seconds), used to reject stale resume cleanup. */ receivedAt?: number sessionId: string | null /** Batch (multi-question) clarify: present instead of question/choices. */ questions?: ClarifyQuestion[] /** Answers already locked server-side (reconnect replay): qid → answer. */ lockedAnswers?: Record } /** * The backend labels the agent's recommended option by appending this to the * first choice (`tools/clarify_tool.py::mark_recommended`). The renderer never * writes it — it only styles it, and discounts it when measuring a choice so a * long option isn't dropped for length the label added. */ export const RECOMMENDED_LABEL = '(Recommended)' export const bareChoice = (choice: string): string => choice.endsWith(RECOMMENDED_LABEL) ? choice.slice(0, -RECOMMENDED_LABEL.length).trim() : choice /** * Validate and normalize a choices array. * * Keeps non-blank, newline-free strings of length ≤ 200; drops everything else * and returns an empty array when nothing usable survives — the caller then * falls back to a free-text answer instead of dead buttons. */ export function normalizeChoices(choices: unknown): string[] { if (!Array.isArray(choices)) { return [] } return choices.filter( (c): c is string => typeof c === 'string' && c.trim().length > 0 && bareChoice(c).length <= 200 && !c.includes('\n') ) } /** * Structured warning for a clarify payload that arrived with choices but had * them all normalized away — keeps the remaining #69122 "no selectable choices" * triggers diagnosable in the field without dead constant fields. */ export function warnDroppedChoices(source: 'gateway' | 'tool_args', question: string, rawChoices: unknown): void { console.warn('[clarify] choices dropped after normalization', { choices_count: Array.isArray(rawChoices) ? rawChoices.length : 0, question_length: question.length, source }) } /** * Validate and normalize a batch clarify payload's `questions` array. * * Keeps entries with a non-blank string `qid` and `question`; per-question * choices go through `normalizeChoices` (all-blank → open-ended) and * multi_select is only honored alongside surviving choices. Returns an empty * array when nothing usable remains — the caller treats that as "not a * batch" instead of rendering an unanswerable form. */ export function normalizeQuestions(questions: unknown): ClarifyQuestion[] { if (!Array.isArray(questions)) { return [] } const normalized: ClarifyQuestion[] = [] for (const entry of questions) { if (typeof entry !== 'object' || entry === null) { continue } const row = entry as Record const qid = typeof row.qid === 'string' ? row.qid.trim() : '' const question = typeof row.question === 'string' ? row.question.trim() : '' if (!qid || !question) { continue } const choices = normalizeChoices(row.choices) normalized.push({ choices: choices.length > 0 ? choices : null, multiSelect: row.multi_select === true && choices.length > 0, qid, question }) } return normalized } // 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>({}) // 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 ) /** The clarify request for one specific session — the tile counterpart of the * active-session `$clarifyRequest` view (same map, fixed key). */ export const sessionClarifyRequest = (sessionId: string | null) => computed($clarifyRequests, requests => requests[keyFor(sessionId)] ?? 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 = {} 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) } } /** Whether `sessionId` has a clarify parked on it right now (imperative read — * the composer checks this on Enter, not on every render). */ export const hasClarifyRequest = (sessionId: string | null | undefined): boolean => Boolean($clarifyRequests.get()[keyFor(sessionId)]) /** * Answer `sessionId`'s pending clarify with an empty answer (a skip) and drop it * locally, resolving to whether there was one to skip. * * The composer uses this when the user types a real message instead of picking * an option: a clarify blocks the agent inside its tool batch, so leaving it * unanswered would park the follow-up until the server-side clarify timeout * (default 5 min) — the message looks sent and nothing happens. Skipping lets * the tool return and the turn carry on with the user's actual words. * * An empty answer is the same thing the card's own Skip button sends, and * `clarify.respond` is `allow_expired`, so racing the timeout is harmless. */ export async function skipClarifyRequest(sessionId: string | null | undefined): Promise { const request = $clarifyRequests.get()[keyFor(sessionId)] if (!request) { return false } // Clear first: the answer is already decided, and an in-flight RPC must not // leave a live card the user can answer a second time. clearClarifyRequest(request.requestId, request.sessionId) try { await $gateway.get()?.request('clarify.respond', { request_id: request.requestId, answer: '' }) } catch { // The tool times out on its own; a failed skip must never swallow the // message the user is actually sending. } return true }