Files
kandev/apps/web/lib/utils/pending-clarification.ts
T
2026-05-13 20:57:03 +01:00

99 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ClarificationRequestMetadata, Message } from "@/lib/types/http";
export function isPendingClarificationMessage(message: Message): boolean {
if (message.type !== "clarification_request") return false;
const metadata = message.metadata as ClarificationRequestMetadata | undefined;
return !metadata?.status || metadata.status === "pending";
}
export function findPendingClarification(messages?: readonly Message[] | null): Message | null {
if (!messages) return null;
for (let i = messages.length - 1; i >= 0; i--) {
if (isPendingClarificationMessage(messages[i])) return messages[i];
}
return null;
}
// findPendingClarificationGroup returns every clarification_request message
// that shares the latest pending message's pending_id, ordered by chat position.
// Multi-question bundles emit one message per question; the chat panel uses this
// list to render every pending question card together.
//
// Gates on `question_total` from metadata: returns an empty array until the
// number of messages received equals the expected bundle size. This prevents
// a user from acting on a partially-arrived bundle (clicking an option before
// the rest of the N messages have been streamed in via the WS), which would
// otherwise trigger a 400 from the backend's all-required gate.
export function findPendingClarificationGroup(messages?: readonly Message[] | null): Message[] {
if (!messages) return [];
const last = findPendingClarification(messages);
if (!last) return [];
const meta = last.metadata as ClarificationRequestMetadata | undefined;
const pendingID = meta?.pending_id;
if (!pendingID) return [last];
const group = messages.filter((m) => {
if (m.type !== "clarification_request") return false;
const mMeta = m.metadata as ClarificationRequestMetadata | undefined;
return mMeta?.pending_id === pendingID;
});
const expectedTotal = meta?.question_total;
if (typeof expectedTotal === "number" && expectedTotal > 0 && group.length < expectedTotal) {
return [];
}
return group;
}
export function hasPendingClarification(messages?: readonly Message[] | null): boolean {
return findPendingClarification(messages) !== null;
}
export function hasPendingClarificationForSession(
messagesBySession: Record<string, readonly Message[] | undefined>,
sessionId?: string | null,
): boolean {
if (!sessionId) return false;
return hasPendingClarification(messagesBySession[sessionId]);
}
// --- Permission request helpers ---
export function isPendingPermissionMessage(message: Message): boolean {
if (message.type !== "permission_request") return false;
const metadata = message.metadata as { status?: string } | undefined;
return !metadata?.status || metadata.status === "pending";
}
// hasPendingPermissionRequest reports whether the *current turn* is blocked
// on a permission_request. Scope:
// - Scans backwards from the end and stops at the first permission_request
// it sees — only the latest one drives the UI. A stale pending row left
// behind by an earlier crash followed by a newer approved one must not
// light up the amber icon (the agent is no longer blocked on the old row).
// - Honours turn boundaries: when the latest message has a turn_id, walking
// back to any row that doesn't share it ends the scan (including legacy
// rows with null/undefined turn_id). Old turns' permissions can never
// leak into the indicator and the scan stays bounded by the current turn
// size (typically 5–50 messages) regardless of total session length.
// - When the latest message itself has no turn_id (entirely pre-turn-scope
// data), boundary enforcement is disabled and we fall back to plain
// latest-only semantics across the whole array.
export function hasPendingPermissionRequest(messages?: readonly Message[] | null): boolean {
if (!messages?.length) return false;
const latestTurnId = messages[messages.length - 1].turn_id;
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (latestTurnId && m.turn_id !== latestTurnId) break;
if (m.type !== "permission_request") continue;
return isPendingPermissionMessage(m);
}
return false;
}
export function hasPendingPermissionForSession(
messagesBySession: Record<string, readonly Message[] | undefined>,
sessionId?: string | null,
): boolean {
if (!sessionId) return false;
return hasPendingPermissionRequest(messagesBySession[sessionId]);
}