LibreChat/e2e/bombadil/hitl-lifecycle.specification.ts
Danny Avila 21b7f78d56
🙋 feat: Collapse Settled Question Records by Default (#15107)
* 🙋 feat: Collapse Settled Question Records by Default

The durable `ask_user_question` record rendered as a permanently open
card. Answers are frequently long, multi-paragraph text, so a settled
Q&A buried the reply that followed it.

It now reads as one collapsed tool-call line — the same `ProgressText`
primitive `ToolCall`/`SkillCall` use — naming the question (or the
batch count, reusing the keys `ToolCallGroup` already had) and opening
on demand under the existing `autoExpandTools` preference. Only the
settled record collapses; the live pause and the interim progress card
are untouched.

The expanded panel was also hard to read. Authored text rendered
without `pre-wrap`, so a numbered or paragraphed answer collapsed into
one wall; the answer ran on from its inline label; and batch items sat
flush against their divider. Line breaks are now content, the answer
sits under its own label behind a rule, and dividers have air on both
sides.

`ProgressText`'s subtitle now truncates and absorbs the flex shrink, so
arbitrary authored text ellipsizes instead of pushing the line past the
message column — this also fixes long MCP server names on tool cards.

* 🩹 fix: Address Codex Round 1 on the Collapsed Question Record

- Settle the summary tense. A live, unanswered pause returns before the
  header, so every state reaching it is settled — an abandoned pause read
  "Asking" forever, and the collapse hid the "no answer" line that used to
  qualify it. Past tense unconditionally, matching `ToolCallGroup`.

- Move the rejection announcement out of the disclosure. `useExpandCollapse`
  marks the closed panel `inert`, so the failure explanation's `role="status"`
  could never reach the accessibility tree; it is now an sr-only status
  outside the panel, carrying both the label and the explanation.

- Count records, not repeated text, in the Bombadil observation. With
  Auto-expand tool details on, one settled record shows the question in both
  its summary line and its panel, so the old selector double-counted it and
  broke the `<= 1` singularity invariant.
2026-08-21 19:50:40 -04:00

266 lines
9.2 KiB
TypeScript

import { always, eventually, extract, now } from '@antithesishq/bombadil';
import { actions } from '@antithesishq/bombadil/browser';
import type { Action, Point, State } from '@antithesishq/bombadil/browser';
import {
noConsoleErrors,
noHttpErrorCodes,
noUncaughtExceptions,
noUnhandledPromiseRejections,
} from '@antithesishq/bombadil/browser/defaults/properties';
type Target = {
name: string;
point: Point;
};
const LOGIN_EMAIL = '__BOMBADIL_E2E_USER_EMAIL__';
const LOGIN_PASSWORD = '__BOMBADIL_E2E_USER_PASSWORD__';
const ENTER_KEY_CODE = 13;
const HITL_MODEL_SPEC = 'E2E HITL';
const HITL_LABEL = 'bombadil-hitl';
const HITL_PROMPT = `E2E_ASK_USER_QUESTION:${HITL_LABEL}`;
const HITL_QUESTION = `Which environment should Bombadil use for ${HITL_LABEL}?`;
const HITL_OPTION = 'Staging';
const FINAL_REPLY = 'E2E mock reply: pong';
const COMPLETED_ANSWER_LABEL = 'You answered:';
/** The settled Q&A record: a collapsed tool-call line naming the question,
* over a panel holding the description and the answer. */
const ASK_RECORD = '[data-testid="ask-user-question-call"]';
let reloadIssued = false;
let pausedReloadIssued = false;
function visiblePoint(state: State, element: Element | null): Point | null {
if (!element) {
return null;
}
const style = state.window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
if (
style.display === 'none' ||
style.visibility === 'hidden' ||
style.pointerEvents === 'none' ||
rect.width <= 0 ||
rect.height <= 0
) {
return null;
}
const point = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
const hitElement = state.document.elementFromPoint(point.x, point.y);
if (
point.x < 0 ||
point.y < 0 ||
point.x > state.window.innerWidth ||
point.y > state.window.innerHeight ||
!hitElement ||
(hitElement !== element && !element.contains(hitElement))
) {
return null;
}
return point;
}
function target(
state: State,
selector: string,
name: string,
text?: string,
containsText = false,
): Target | null {
for (const element of state.document.querySelectorAll(selector)) {
const content = element.textContent?.trim() ?? '';
if (text != null && (containsText ? !content.includes(text) : content !== text)) {
continue;
}
if (element.matches(':disabled') || element.getAttribute('aria-disabled') === 'true') {
continue;
}
const point = visiblePoint(state, element);
if (point) {
return { name, point };
}
}
return null;
}
function visibleCount(state: State, selector: string): number {
return Array.from(state.document.querySelectorAll(selector)).filter(
(element) => visiblePoint(state, element) !== null,
).length;
}
function visibleTextCount(
state: State,
selector: string,
text: string,
containsText = false,
): number {
return Array.from(state.document.querySelectorAll(selector)).filter((element) => {
const content = element.textContent?.trim() ?? '';
return (
(containsText ? content.includes(text) : content === text) &&
visiblePoint(state, element) !== null
);
}).length;
}
function inputValue(state: State, selector: string): string {
return (
state.document.querySelector<HTMLInputElement | HTMLTextAreaElement>(selector)?.value ?? ''
);
}
function isFocused(state: State, selector: string): boolean {
return state.document.activeElement?.matches(selector) === true;
}
function clickOrWait(targetValue: Target | null): Action[] {
return targetValue ? [{ Click: targetValue }] : ['Wait'];
}
const ui = extract((state: State) => {
const messageElements = Array.from(state.document.querySelectorAll('.message-render'));
const askRecordCount = visibleCount(state, ASK_RECORD);
const messageText = messageElements.map((element) => element.textContent ?? '').join('\n');
const modelTrigger = state.document.querySelector('button[aria-label="Select a model"]');
return {
path: state.window.location.pathname,
lastAction: state.lastAction,
messageCount: messageElements.length,
messageText,
modelLabel: modelTrigger?.textContent?.trim() ?? '',
composerValue: inputValue(state, '#prompt-textarea'),
composerFocused: isFocused(state, '#prompt-textarea'),
emailValue: inputValue(state, '#email'),
emailFocused: isFocused(state, '#email'),
passwordValue: inputValue(state, '#password'),
passwordFocused: isFocused(state, '#password'),
/** Once the pause settles, the record IS the question's presentation, so
* count records rather than every node repeating their text — an
* expanded record (Auto-expand tool details) shows the question in both
* its summary line and its panel, and matching text would count one
* record twice. Before a record exists the live pause renders the
* question as a paragraph. */
questionCount:
askRecordCount > 0 ? askRecordCount : visibleTextCount(state, 'p', HITL_QUESTION),
answerOptionCount: visibleTextCount(state, 'button', HITL_OPTION, true),
finalReplyCount: messageElements.filter((element) =>
(element.textContent ?? '').includes(FINAL_REPLY),
).length,
completedAnswerCount: messageElements.filter((element) => {
const text = element.textContent ?? '';
return text.includes(COMPLETED_ANSWER_LABEL) && text.includes(HITL_OPTION);
}).length,
isSubmitting: state.document.querySelector('button[aria-label="Stop generating"]') !== null,
hasComposer: state.document.querySelector('#prompt-textarea') !== null,
loginEmail: target(state, '#email', 'Login email'),
loginPassword: target(state, '#password', 'Login password'),
loginSubmit: target(state, '[data-testid="login-button"]', 'Login'),
composer: target(state, '#prompt-textarea', 'Message input'),
modelTrigger: target(state, 'button[aria-label="Select a model"]', 'Model selector'),
hitlModelSpec: target(state, '[role="option"]', HITL_MODEL_SPEC, HITL_MODEL_SPEC),
stagingOption: target(state, 'button', 'Answer Staging', HITL_OPTION, true),
};
});
export { noConsoleErrors, noHttpErrorCodes, noUncaughtExceptions, noUnhandledPromiseRejections };
export const hitlLifecycleActions = actions((): Action[] => {
const state = ui.current;
if (state.path === '/login') {
reloadIssued = false;
pausedReloadIssued = false;
if (!state.emailFocused && state.emailValue === '') {
return clickOrWait(state.loginEmail);
}
if (state.emailFocused && state.emailValue === '') {
return [{ TypeText: { text: LOGIN_EMAIL, delayMillis: 0 } }];
}
if (!state.passwordFocused && state.passwordValue === '') {
return clickOrWait(state.loginPassword);
}
if (state.passwordFocused && state.passwordValue === '') {
return [{ TypeText: { text: LOGIN_PASSWORD, delayMillis: 0 } }];
}
return clickOrWait(state.loginSubmit);
}
if (state.stagingOption) {
if (!pausedReloadIssued) {
pausedReloadIssued = true;
return ['Reload'];
}
return clickOrWait(state.stagingOption);
}
if (state.isSubmitting || !state.hasComposer) {
return ['Wait'];
}
if (state.finalReplyCount === 1) {
if (!reloadIssued) {
reloadIssued = true;
return ['Reload'];
}
return ['Wait'];
}
const isPersistedConversation = state.path.startsWith('/c/') && state.path !== '/c/new';
if (isPersistedConversation && state.messageCount === 0) {
return ['Wait'];
}
if (state.messageCount === 0) {
if (state.modelLabel !== HITL_MODEL_SPEC) {
return state.hitlModelSpec
? clickOrWait(state.hitlModelSpec)
: clickOrWait(state.modelTrigger);
}
if (state.composerValue === '') {
return state.composerFocused
? [{ TypeText: { text: HITL_PROMPT, delayMillis: 0 } }]
: clickOrWait(state.composer);
}
return state.composerFocused
? [{ PressKey: { code: ENTER_KEY_CODE } }]
: clickOrWait(state.composer);
}
return ['Wait'];
});
/** The run must reach a real, answerable ask_user_question pause, including after reload. */
export const hitlQuestionEventuallyPauses = eventually(
() => ui.current.questionCount === 1 && ui.current.answerOptionCount === 1,
).within(25, 'seconds');
/** Answering resumes the checkpointed run and produces one terminal reply. */
export const hitlAnswerEventuallyResumes = eventually(
() =>
ui.current.finalReplyCount === 1 &&
ui.current.completedAnswerCount === 1 &&
ui.current.answerOptionCount === 0,
).within(40, 'seconds');
/** Duplicate cards or duplicate resume completions indicate a broken pause lifecycle. */
export const hitlPauseAndResumeStaySingular = always(
() =>
ui.current.questionCount <= 1 &&
ui.current.answerOptionCount <= 1 &&
ui.current.finalReplyCount <= 1 &&
ui.current.completedAnswerCount <= 1 &&
ui.current.messageCount <= 2,
);
/** After reload, the question remains an audit record without becoming answerable again. */
export const answeredHitlStateSurvivesReload = always(() =>
now(() => ui.current.lastAction === 'Reload').implies(
eventually(
() =>
ui.current.finalReplyCount === 1 &&
ui.current.completedAnswerCount === 1 &&
ui.current.questionCount === 1 &&
ui.current.answerOptionCount === 0,
).within(20, 'seconds'),
),
);