fix(remote-questions): race local TUI against remote channel instead of remote-only routing

When a remote channel (Discord/Slack/Telegram) is configured, ask_user_questions
now races the local TUI against the remote dispatch. The first response wins and
the loser is cancelled. Previously, remote completely preempted the local TUI,
meaning terminal users never saw the question prompt when remote was configured.

Closes #3801
This commit is contained in:
Jeremy 2026-04-08 15:58:06 -05:00
parent 6dfc422990
commit dadb0b136e
2 changed files with 112 additions and 11 deletions

View file

@ -107,6 +107,65 @@ export function resetAskUserQuestionsCache(): void {
turnCache.clear();
}
// ─── Race helper ─────────────────────────────────────────────────────────────
interface RaceableResult {
content: { type: "text"; text: string }[];
details?: unknown;
}
/**
* Race a remote channel dispatch against the local TUI. The first to produce
* a valid (non-error, non-timeout) result wins. The loser is cancelled via
* the shared AbortController.
*
* If the local TUI responds first, the remote poll is aborted (the message
* stays in Discord/Slack but polling stops). If remote responds first, the
* local TUI prompt is cancelled.
*
* Returns null only when both sides fail or are cancelled.
*/
async function raceRemoteAndLocal(
startRemote: () => Promise<RaceableResult | null>,
startLocal: () => Promise<RoundResult | null | undefined>,
controller: AbortController,
questions: Question[],
): Promise<RaceableResult | null> {
// Wrap local TUI result into the same shape as remote results
const localPromise = startLocal().then((result): RaceableResult | null => {
if (!result || Object.keys(result.answers).length === 0) return null;
return {
content: [{ type: "text" as const, text: formatForLLM(result) }],
details: { questions, response: result, cancelled: false } satisfies LocalResultDetails,
};
}).catch(() => null);
const remotePromise = startRemote().then((result): RaceableResult | null => {
if (!result) return null;
const details = result.details as Record<string, unknown> | undefined;
// Treat timeouts and errors as non-wins — let the local TUI win instead
if (details?.timed_out || details?.error) return null;
return result;
}).catch(() => null);
// Race: first non-null result wins
const winner = await Promise.race([
localPromise.then((r) => r ? { source: "local" as const, result: r } : null),
remotePromise.then((r) => r ? { source: "remote" as const, result: r } : null),
]);
if (winner) {
// Cancel the loser
controller.abort();
return winner.result;
}
// First to resolve was null — wait for the other
const [localResult, remoteResult] = await Promise.all([localPromise, remotePromise]);
controller.abort();
return localResult ?? remoteResult;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const OTHER_OPTION_LABEL = "None of the above";
@ -180,20 +239,53 @@ export default function AskUserQuestions(pi: ExtensionAPI) {
}
}
// Try remote first if configured (works in both interactive and headless modes).
// tryRemoteQuestions returns null when no remote channel is configured, so
// this is a no-op when the user has not set up Slack/Discord/Telegram.
const { tryRemoteQuestions } = await import("./remote-questions/manager.js");
const remoteResult = await tryRemoteQuestions(params.questions, signal);
if (remoteResult) {
// Cache successful remote results to prevent duplicate Discord dispatches
const remoteDetails = remoteResult.details as Record<string, unknown> | undefined;
if (remoteDetails && !remoteDetails.timed_out && !remoteDetails.error) {
turnCache.set(sig, remoteResult as unknown as CachedResult);
// ── Routing: race remote + local, remote-only, or local-only ────────
const { tryRemoteQuestions, isRemoteConfigured } = await import("./remote-questions/manager.js");
const hasRemote = isRemoteConfigured();
// Case 1: Both remote and local UI available — race them.
// The first response wins; the loser is cancelled via AbortController.
if (hasRemote && ctx.hasUI) {
const raceController = new AbortController();
// Merge the parent signal so external cancellation propagates.
const onParentAbort = () => raceController.abort();
signal?.addEventListener("abort", onParentAbort, { once: true });
const raceSignal = raceController.signal;
const raceResult = await raceRemoteAndLocal(
() => tryRemoteQuestions(params.questions, raceSignal),
() => showInterviewRound(params.questions, {}, ctx as any),
raceController,
params.questions,
);
signal?.removeEventListener("abort", onParentAbort);
if (raceResult) {
const details = raceResult.details as Record<string, unknown> | undefined;
if (details && !details.timed_out && !details.error && !details.cancelled) {
turnCache.set(sig, raceResult as unknown as CachedResult);
}
return { ...raceResult, details: raceResult.details as unknown };
}
return { ...remoteResult, details: remoteResult.details as unknown };
// Both sides failed/cancelled — fall through to error
return errorResult("ask_user_questions: no response received from local UI or remote channel", params.questions);
}
// Case 2: Remote configured but no local UI (headless) — remote only.
if (hasRemote && !ctx.hasUI) {
const remoteResult = await tryRemoteQuestions(params.questions, signal);
if (remoteResult) {
const remoteDetails = remoteResult.details as Record<string, unknown> | undefined;
if (remoteDetails && !remoteDetails.timed_out && !remoteDetails.error) {
turnCache.set(sig, remoteResult as unknown as CachedResult);
}
return { ...remoteResult, details: remoteResult.details as unknown };
}
return errorResult("Error: remote channel configured but returned no result", params.questions);
}
// Case 3: No remote — local UI only.
if (!ctx.hasUI) {
return errorResult("Error: UI not available (non-interactive mode)", params.questions);
}

View file

@ -24,6 +24,15 @@ interface QuestionInput {
allowMultiple?: boolean;
}
/**
* Check whether a remote channel is configured without triggering any
* side effects (no HTTP requests, no prompt records). Used by the race
* logic to decide routing before committing to a remote dispatch.
*/
export function isRemoteConfigured(): boolean {
return resolveRemoteConfig() !== null;
}
export async function tryRemoteQuestions(
questions: QuestionInput[],
signal?: AbortSignal,