Skip to content

Commit 249648d

Browse files
committed
fix(crossfire): bound Jev signal sidecars
1 parent 5c90825 commit 249648d

5 files changed

Lines changed: 95 additions & 10 deletions

File tree

‎docs/architecture.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ Client-side environment selection is a developer convenience, not authorization.
5858

5959
[`supabase/functions/ai/index.ts`](../supabase/functions/ai/index.ts) serves non-streaming structured tools and practice modes. [`supabase/functions/ai-stream/index.ts`](../supabase/functions/ai-stream/index.ts) serves Ask over server-sent events.
6060

61-
For a validated Crossfire next turn, the server returns the reply first with a short-lived signed sidecar token. The client then requests a Jev classification of only the newest completed question-answer exchange, without reserving a second daily quota unit. The optional directness, concession, and follow-up signal is attached by turn ID to the answer; a missing, slow, forged, or failed sidecar never delays or invalidates the main reply. Draft version 1 remains compatible because turn signals are optional.
61+
For a validated Crossfire next turn, the server returns the reply first with a short-lived signed sidecar token. The client then requests a Jev classification of only the newest completed question-answer exchange. The token is atomically claimed through its own one-use counter, separate from the product's daily AI quota. The optional directness, concession, and follow-up signal is attached by turn ID to the answer; a missing, slow, replayed, forged, or failed sidecar never delays or invalidates the main reply. Draft version 1 remains compatible because turn signals are optional.
6262

6363
A configured mock provider bypasses Supabase for local and test behavior. It is not authenticated backend evidence.
6464

‎src/pages/CrossfirePage.tsx‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -263,21 +263,31 @@ export default function CrossfirePage() {
263263
const [storageError, setStorageError] = useState(false);
264264
const [stateOwnerId, setStateOwnerId] = useState(user?.id);
265265
const requestRef = useRef<AbortController | null>(null);
266+
const signalRequestsRef = useRef(new Set<AbortController>());
266267
const shouldReduceMotion = useReducedMotion();
267268
const activeSourceCount = useSessionStore((session) => session.sourceLibrary.filter((source) => source.isActive).length);
268269
const { getFeatureConfig } = useSettingsStore();
269270
const modelConfig = getFeatureConfig('ask');
270271

271-
useEffect(() => () => requestRef.current?.abort(), []);
272+
const abortSignalRequests = useCallback(() => {
273+
signalRequestsRef.current.forEach((controller) => controller.abort());
274+
signalRequestsRef.current.clear();
275+
}, []);
276+
277+
useEffect(() => () => {
278+
requestRef.current?.abort();
279+
abortSignalRequests();
280+
}, [abortSignalRequests]);
272281

273282
useEffect(() => {
274283
if (stateOwnerId === user?.id) return;
275284
requestRef.current?.abort();
285+
abortSignalRequests();
276286
dispatch({ type: 'state-restored', state: restoreState(user?.id, undefined) });
277287
setStateOwnerId(user?.id);
278288
setDraftTurn('');
279289
setFormError('');
280-
}, [stateOwnerId, user?.id]);
290+
}, [abortSignalRequests, stateOwnerId, user?.id]);
281291

282292
useEffect(() => {
283293
if (!user?.id || stateOwnerId !== user.id) return;
@@ -322,16 +332,19 @@ export default function CrossfirePage() {
322332
turn: nextTurn(snapshot.turns.length + 1, 'ai', result.type, result.text),
323333
});
324334
if (result.signalToken) {
335+
const signalController = new AbortController();
336+
signalRequestsRef.current.add(signalController);
325337
void crossfireSignal({
326338
config: snapshot.config,
327339
transcript: snapshot.turns,
328340
reply,
329341
signalToken: result.signalToken,
330-
}, modelConfig, { signal: controller.signal })
342+
}, modelConfig, { signal: signalController.signal })
331343
.then((signal) => {
332-
if (signal && !controller.signal.aborted) dispatch({ type: 'signal-received', signal });
344+
if (signal && !signalController.signal.aborted) dispatch({ type: 'signal-received', signal });
333345
})
334-
.catch((): void => {});
346+
.catch((): void => {})
347+
.finally(() => signalRequestsRef.current.delete(signalController));
335348
}
336349
} catch (error) {
337350
if (controller.signal.aborted) return;
@@ -445,6 +458,7 @@ export default function CrossfirePage() {
445458

446459
const restart = () => {
447460
requestRef.current?.abort();
461+
abortSignalRequests();
448462
if (user?.id) clearCrossfireDraft(user.id);
449463
dispatch({ type: 'session-reset' });
450464
setDraftTurn('');

‎src/test/pages/CrossfirePage.test.tsx‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
55
const mocks = vi.hoisted(() => ({
66
userId: 'user-a',
77
crossfire: vi.fn<() => Promise<unknown>>(() => new Promise(() => undefined)),
8-
crossfireSignal: vi.fn<() => Promise<unknown>>(() => Promise.resolve(undefined)),
8+
crossfireSignal: vi.fn<(input: unknown, config: unknown, options: { signal?: AbortSignal }) => Promise<unknown>>(() => Promise.resolve(undefined)),
99
}));
1010

1111
vi.mock('@/lib/supabase/AuthProvider', () => ({
@@ -158,4 +158,34 @@ describe('CrossfirePage', () => {
158158
expect(screen.getByText('What is the mechanism?').closest('article')).not.toHaveTextContent('Direct answer');
159159
expect(answer.closest('article')?.querySelector('[aria-label*="82%"]')).not.toBeNull();
160160
});
161+
162+
it('keeps a delayed coaching signal alive while the next turn runs', async () => {
163+
let resolveSignal: (value: unknown) => void = () => undefined;
164+
let sidecarSignal: AbortSignal | undefined;
165+
mocks.crossfire
166+
.mockResolvedValueOnce({ type: 'answer', text: 'First bounded answer.', signalToken: 'token-1' })
167+
.mockResolvedValueOnce({ type: 'answer', text: 'Second bounded answer.' });
168+
mocks.crossfireSignal.mockImplementationOnce((_input, _config, options) => {
169+
sidecarSignal = options.signal;
170+
return new Promise((resolve) => {
171+
resolveSignal = resolve;
172+
});
173+
});
174+
renderPage();
175+
fireEvent.change(screen.getByTestId('crossfire-resolution'), { target: { value: 'Resolved: test' } });
176+
fireEvent.change(screen.getByTestId('crossfire-argument'), { target: { value: 'Case text' } });
177+
fireEvent.click(screen.getByTestId('start-crossfire'));
178+
179+
fireEvent.change(screen.getByTestId('crossfire-input'), { target: { value: 'First question?' } });
180+
fireEvent.click(screen.getByTestId('submit-turn'));
181+
const firstAnswer = await screen.findByText('First bounded answer.');
182+
183+
fireEvent.change(screen.getByTestId('crossfire-input'), { target: { value: 'Second question?' } });
184+
fireEvent.click(screen.getByTestId('submit-turn'));
185+
expect(await screen.findByText('Second bounded answer.')).toBeInTheDocument();
186+
expect(sidecarSignal?.aborted).toBe(false);
187+
188+
resolveSignal({ answerTurnId: 'T2', answerQuality: 'direct', answerQualityConfidence: 0.91, concession: 'none', concessionConfidence: 0.88, followUpOpportunity: 0.76 });
189+
await waitFor(() => expect(firstAnswer.closest('article')).toHaveTextContent('Direct answer'));
190+
});
161191
});

‎supabase/functions/ai/handler-research.test.ts‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -452,8 +452,17 @@ Deno.test("Crossfire returns newest-exchange signals without making them a reply
452452
const originalFetch = globalThis.fetch;
453453
let decisionFails = false;
454454
let decisionCalls = 0;
455+
const claimedSignalKeys = new Set<string>();
455456
globalThis.fetch = (async (input: Request | URL | string, init?: RequestInit) => {
456457
const url = new URL(input instanceof Request ? input.url : String(input));
458+
if (url.origin === "https://supabase.test" && url.pathname.includes("/rest/v1/rpc/consume_daily_quota")) {
459+
const body = JSON.parse(String(init?.body ?? "{}")) as { p_quota_key?: string };
460+
if (body.p_quota_key?.startsWith("crossfire_signal:")) {
461+
const allowed = !claimedSignalKeys.has(body.p_quota_key);
462+
claimedSignalKeys.add(body.p_quota_key);
463+
return jsonResponse([{ allowed, current_count: 1 }]);
464+
}
465+
}
457466
const supabaseResponse = supabaseTestResponse(url);
458467
if (supabaseResponse) return supabaseResponse;
459468
if (url.href === "https://openrouter.ai/api/v1/chat/completions") {
@@ -512,6 +521,8 @@ Deno.test("Crossfire returns newest-exchange signals without making them a reply
512521
const questionSignal = await requestSignal("question-ai", questionTranscript, questionAiPayload.output as { type: "answer"; text: string }, questionAiPayload.signalToken);
513522
const questionSignalPayload = await questionSignal.json() as { output?: { signal?: { answerTurnId?: string; answerQuality?: string } } };
514523
if (questionSignalPayload.output?.signal?.answerTurnId !== "T2" || questionSignalPayload.output.signal.answerQuality !== "direct") throw new Error(`question-ai signal missing: ${JSON.stringify(questionSignalPayload)}`);
524+
const replayedSignal = await requestSignal("question-ai", questionTranscript, questionAiPayload.output as { type: "answer"; text: string }, questionAiPayload.signalToken);
525+
if (replayedSignal.status !== 409 || Number(decisionCalls) !== 1) throw new Error("replayed Crossfire signal token reached Jev");
515526
const forgedSignal = await requestSignal("question-ai", questionTranscript, questionAiPayload.output as { type: "answer"; text: string }, "forged-token");
516527
if (forgedSignal.status !== 403) throw new Error("forged Crossfire signal token was accepted");
517528

@@ -522,12 +533,21 @@ Deno.test("Crossfire returns newest-exchange signals without making them a reply
522533
const aiQuestionsUser = await request("ai-questions-user", answerTranscript, "question");
523534
const aiQuestionsPayload = await aiQuestionsUser.json() as { output?: { type?: string; text?: string }; signalToken?: string };
524535
if (aiQuestionsUser.status !== 200 || aiQuestionsPayload.output?.type !== "question" || !aiQuestionsPayload.signalToken) throw new Error(`ai-questions-user reply missing: ${JSON.stringify(aiQuestionsPayload)}`);
525-
const answerSignal = await requestSignal("ai-questions-user", answerTranscript, aiQuestionsPayload.output as { type: "question"; text: string }, aiQuestionsPayload.signalToken);
526-
const answerSignalPayload = await answerSignal.json() as { output?: { signal?: { answerTurnId?: string; concession?: string } } };
536+
const decisionCallsBeforeReplayRace = decisionCalls;
537+
const racedSignals = await Promise.all(Array.from({ length: 4 }, () => requestSignal("ai-questions-user", answerTranscript, aiQuestionsPayload.output as { type: "question"; text: string }, aiQuestionsPayload.signalToken!)));
538+
if (racedSignals.filter((response) => response.status === 200).length !== 1 || racedSignals.filter((response) => response.status === 409).length !== 3) {
539+
throw new Error(`concurrent signal token claim was not atomic: ${racedSignals.map((response) => response.status).join(",")}`);
540+
}
541+
if (decisionCalls !== decisionCallsBeforeReplayRace + 1) throw new Error("concurrent Crossfire signal replays reached Jev");
542+
const answerSignalPayload = await racedSignals.find((response) => response.status === 200)!.json() as { output?: { signal?: { answerTurnId?: string; concession?: string } } };
527543
if (answerSignalPayload.output?.signal?.answerTurnId !== "T2" || answerSignalPayload.output.signal.concession !== "implicit") throw new Error(`ai-questions-user signal missing: ${JSON.stringify(answerSignalPayload)}`);
528544

529545
decisionFails = true;
530-
const fallback = await requestSignal("question-ai", questionTranscript, questionAiPayload.output as { type: "answer"; text: string }, questionAiPayload.signalToken);
546+
const fallbackTranscript = [{ id: "T1", sequence: 1, speaker: "user", kind: "question", text: "What is the fallback evidence?", createdAt: 3 }];
547+
const fallbackReply = await request("question-ai", fallbackTranscript, "answer");
548+
const fallbackReplyPayload = await fallbackReply.json() as { output?: { type: "answer"; text: string }; signalToken?: string };
549+
if (!fallbackReplyPayload.output || !fallbackReplyPayload.signalToken) throw new Error("fallback Crossfire reply token missing");
550+
const fallback = await requestSignal("question-ai", fallbackTranscript, fallbackReplyPayload.output, fallbackReplyPayload.signalToken);
531551
const fallbackPayload = await fallback.json() as { output?: { signal?: unknown } };
532552
if (fallback.status !== 200 || fallbackPayload.output?.signal !== undefined) throw new Error(`failed optional signal returned invalid output: ${JSON.stringify(fallbackPayload)}`);
533553
} finally {

‎supabase/functions/ai/index.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,18 @@ async function verifyCrossfireSignalToken(userId: string, input: CrossfireSignal
769769
return mismatch === 0;
770770
}
771771

772+
async function claimCrossfireSignalToken(userId: string, input: CrossfireSignalInput): Promise<boolean> {
773+
const tokenMac = input.signalToken.split(".")[1];
774+
const { data, error } = await supabase.rpc("consume_daily_quota", {
775+
p_user_id: userId,
776+
p_limit: 1,
777+
p_quota_key: `crossfire_signal:${tokenMac}`,
778+
});
779+
if (error) throw error;
780+
const row = Array.isArray(data) ? data[0] : data;
781+
return Boolean(row?.allowed);
782+
}
783+
772784
function boundCrossfireSources(value: unknown, enabled: boolean): { value: PromptSource[]; truncated: boolean } | { error: string } {
773785
if (!enabled || value === undefined || value === null) return { value: [], truncated: false };
774786
if (!Array.isArray(value)) return { error: "sources must be an array when Source Library is enabled" };
@@ -1223,6 +1235,15 @@ export async function handleRequest(req: Request): Promise<Response> {
12231235
if (crossfireInput?.operation === "signal") {
12241236
if (await checkUserBanned(userId)) return badRequest("Your account has been suspended. Please contact support.", corsHeaders, 403);
12251237
if (!(await verifyCrossfireSignalToken(userId, crossfireInput))) return badRequest("Invalid or expired Crossfire signal token", corsHeaders, 403);
1238+
try {
1239+
// A token-specific counter atomically bounds replays without consuming the product's AI quota.
1240+
if (!(await claimCrossfireSignalToken(userId, crossfireInput))) {
1241+
return badRequest("Crossfire signal token has already been used", corsHeaders, 409);
1242+
}
1243+
} catch (error) {
1244+
log(requestId, "ERROR", "Crossfire signal token claim failed", { error: error instanceof Error ? error.message : "Unknown error" });
1245+
return badRequest("Unable to verify Crossfire signal token", corsHeaders, 500);
1246+
}
12261247
const evaluated = await evaluateCrossfireExchange({
12271248
transcript: crossfireInput.transcript,
12281249
reply: crossfireInput.reply,

0 commit comments

Comments
 (0)