Skip to content

Commit 5c90825

Browse files
committed
fix(ai): harden Jev fallback and Crossfire signals
1 parent 6dad7ec commit 5c90825

20 files changed

Lines changed: 371 additions & 115 deletions

‎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 may ask Jev to classify only the newest completed question-answer exchange. The optional directness, concession, and follow-up signal is attached by turn ID to the answer; a missing or failed Jev decision never invalidates the main Crossfire 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, 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.
6262

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

‎e2e/crossfire.spec.ts‎

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import { setLocale, stubSupabaseSession } from './helpers/auth';
44
type CrossfireRequest = {
55
feature: string;
66
input: {
7-
operation: 'next-turn' | 'debrief';
7+
operation: 'next-turn' | 'debrief' | 'signal';
88
expectedReply?: 'question' | 'answer';
9+
reply?: { type: 'question' | 'answer'; text: string };
10+
signalToken?: string;
911
config: { mode: 'question-ai' | 'ai-questions-user'; useSourceLibrary: boolean };
1012
transcript: Array<{ id: string; kind: 'question' | 'answer'; text: string }>;
1113
};
@@ -43,6 +45,21 @@ async function installDeterministicBackend(page: Page, requests: CrossfireReques
4345
if (url.pathname.endsWith('/functions/v1/ai')) {
4446
const body = route.request().postDataJSON() as CrossfireRequest;
4547
requests.push(body);
48+
if (body.input.operation === 'signal') {
49+
const answerTurnId = body.input.reply?.type === 'answer'
50+
? `T${body.input.transcript.length + 1}`
51+
: body.input.transcript.at(-1)?.kind === 'answer' ? body.input.transcript.at(-1)?.id : undefined;
52+
const signal = answerTurnId ? {
53+
answerTurnId,
54+
answerQuality: body.input.reply?.type === 'answer' ? 'direct' : 'partial',
55+
answerQualityConfidence: 0.91,
56+
concession: body.input.reply?.type === 'answer' ? 'none' : 'implicit',
57+
concessionConfidence: 0.76,
58+
followUpOpportunity: 0.82,
59+
} : undefined;
60+
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ output: signal ? { signal } : {} }) });
61+
return;
62+
}
4663
if (body.input.operation === 'next-turn') {
4764
if (failFirstAnswer && !answerFailed && body.input.expectedReply === 'answer') {
4865
answerFailed = true;
@@ -60,18 +77,8 @@ async function installDeterministicBackend(page: Page, requests: CrossfireReques
6077
? 'What evidence connects fewer private cars to safer streets?'
6178
: 'How would you answer the strongest enforcement objection?',
6279
};
63-
const answerTurnId = body.input.expectedReply === 'answer'
64-
? `T${body.input.transcript.length + 1}`
65-
: body.input.transcript.at(-1)?.kind === 'answer' ? body.input.transcript.at(-1)?.id : undefined;
66-
const signal = answerTurnId ? {
67-
answerTurnId,
68-
answerQuality: body.input.expectedReply === 'answer' ? 'direct' : 'partial',
69-
answerQualityConfidence: 0.91,
70-
concession: body.input.expectedReply === 'answer' ? 'none' : 'implicit',
71-
concessionConfidence: 0.76,
72-
followUpOpportunity: 0.82,
73-
} : undefined;
74-
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ output, ...(signal ? { signal } : {}) }) });
80+
const hasCompletedExchange = output.type === 'answer' || body.input.transcript.at(-1)?.kind === 'answer';
81+
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ output, ...(hasCompletedExchange ? { signalToken: 'e2e-signal-token' } : {}) }) });
7582
return;
7683
}
7784

@@ -151,7 +158,7 @@ test('Let the AI question me alternates roles and completes a debrief', async ({
151158
await expect(page.getByTestId('crossfire-debrief')).toBeVisible();
152159
await expect(page.getByText('Partial answer')).toBeVisible();
153160
expect(requests[0].input).toMatchObject({ operation: 'next-turn', expectedReply: 'question', config: { mode: 'ai-questions-user', useSourceLibrary: false } });
154-
expect(requests[1].input.transcript.at(-1)?.text).toBe('Traffic exposure falls when trips shift to transit and walking.');
161+
expect(requests.filter((request) => request.input.operation === 'next-turn')[1].input.transcript.at(-1)?.text).toBe('Traffic exposure falls when trips shift to transit and walking.');
155162
expect(requests.at(-1)?.input.operation).toBe('debrief');
156163
await page.screenshot({ path: testInfo.outputPath('crossfire-ai-questions-user-final.png'), fullPage: true });
157164
});

‎src/lib/ai/client.ts‎

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,13 @@ import {
3939
CrossfireAIInputSchema,
4040
CrossfireDebriefSchema,
4141
CrossfireReplySchema,
42+
CrossfireSignalInputSchema,
4243
CrossfireTurnSignalSchema,
4344
collectDebriefTurnIds,
4445
type CrossfireAIInput,
4546
type CrossfireDebrief,
4647
type CrossfireReply,
48+
type CrossfireSignalInput,
4749
type CrossfireTurnSignal,
4850
} from '@/lib/crossfire/schemas';
4951

@@ -739,7 +741,7 @@ export async function crossfire(
739741
rawInput: CrossfireAIInput,
740742
config: ProviderConfig,
741743
options: Pick<AIRequestOptions, 'signal'> = {},
742-
): Promise<(CrossfireReply & { signal?: CrossfireTurnSignal }) | CrossfireDebrief> {
744+
): Promise<(CrossfireReply & { signalToken?: string }) | CrossfireDebrief> {
743745
const input = CrossfireAIInputSchema.safeParse(rawInput);
744746
if (!input.success) {
745747
throw new AIResponseError('Crossfire request did not match the expected schema.', {
@@ -757,7 +759,7 @@ export async function crossfire(
757759
timeoutMs: CROSSFIRE_REQUEST_TIMEOUT_MS,
758760
signal: options.signal,
759761
transformResponse: (rawData) => {
760-
const data = rawData as { output: unknown; signal?: unknown };
762+
const data = rawData as { output: unknown; signalToken?: unknown };
761763
if (input.data.operation === 'next-turn') {
762764
const reply = CrossfireReplySchema.safeParse(data.output);
763765
if (!reply.success || reply.data.type !== input.data.expectedReply) {
@@ -767,27 +769,11 @@ export async function crossfire(
767769
: { issues: reply.error.issues },
768770
});
769771
}
770-
if (data.signal === undefined) return reply.data;
771-
const signal = CrossfireTurnSignalSchema.safeParse(data.signal);
772-
if (!signal.success) {
773-
throw new AIResponseError('Crossfire signal did not match the expected schema.', { details: { issues: signal.error.issues } });
772+
if (data.signalToken === undefined) return reply.data;
773+
if (typeof data.signalToken !== 'string' || !data.signalToken.trim() || data.signalToken.length > 128) {
774+
throw new AIResponseError('Crossfire signal token was invalid.');
774775
}
775-
const answerTurns = [
776-
...input.data.transcript,
777-
{
778-
id: `T${input.data.transcript.length + 1}`,
779-
sequence: input.data.transcript.length + 1,
780-
speaker: 'ai' as const,
781-
kind: reply.data.type,
782-
text: reply.data.text,
783-
createdAt: 0,
784-
},
785-
];
786-
const target = answerTurns.find((turn) => turn.id === signal.data.answerTurnId);
787-
if (!target || target.kind !== 'answer') {
788-
throw new AIResponseError('Crossfire signal referenced an unknown or non-answer turn.');
789-
}
790-
return { ...reply.data, signal: signal.data };
776+
return { ...reply.data, signalToken: data.signalToken };
791777
}
792778

793779
const debrief = CrossfireDebriefSchema.safeParse(data.output);
@@ -803,6 +789,41 @@ export async function crossfire(
803789
);
804790
}
805791

792+
export async function crossfireSignal(
793+
rawInput: CrossfireSignalInput,
794+
config: ProviderConfig,
795+
options: Pick<AIRequestOptions, 'signal'> = {},
796+
): Promise<CrossfireTurnSignal | undefined> {
797+
const input = CrossfireSignalInputSchema.safeParse(rawInput);
798+
if (!input.success) {
799+
throw new AIResponseError('Crossfire signal request did not match the expected schema.', { details: { issues: input.error.issues } });
800+
}
801+
return await callSupabaseAI<CrossfireTurnSignal | undefined>(
802+
'crossfire',
803+
withOutputLanguage({ operation: 'signal', ...input.data }),
804+
config,
805+
{
806+
includeActiveSources: false,
807+
includeCustomSystemPrompt: false,
808+
timeoutMs: 5_000,
809+
signal: options.signal,
810+
transformResponse: (rawData) => {
811+
const output = (rawData as { output?: unknown }).output;
812+
if (!output || typeof output !== 'object' || Array.isArray(output) || !('signal' in output)) return undefined;
813+
const parsed = CrossfireTurnSignalSchema.safeParse((output as { signal: unknown }).signal);
814+
if (!parsed.success) throw new AIResponseError('Crossfire signal did not match the expected schema.', { details: { issues: parsed.error.issues } });
815+
const turns = [
816+
...input.data.transcript,
817+
{ id: `T${input.data.transcript.length + 1}`, kind: input.data.reply.type },
818+
];
819+
const target = turns.find((turn) => turn.id === parsed.data.answerTurnId);
820+
if (!target || target.kind !== 'answer') throw new AIResponseError('Crossfire signal referenced an unknown or non-answer turn.');
821+
return parsed.data;
822+
},
823+
},
824+
);
825+
}
826+
806827
export function mapAIError(
807828
error: { message: string; context?: unknown },
808829
requestId: string,

‎src/lib/crossfire/reducer.ts‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export type CrossfireAction =
1919
| { type: 'session-started'; sessionId: string; now: number }
2020
| { type: 'user-turn-submitted'; turn: CrossfireTurn; expectedReply: 'question' | 'answer' }
2121
| { type: 'ai-turn-received'; turn: CrossfireTurn; signal?: CrossfireTurnSignal }
22+
| { type: 'signal-received'; signal: CrossfireTurnSignal }
2223
| {
2324
type: 'request-failed';
2425
expectedReply: 'question' | 'answer';
@@ -67,6 +68,16 @@ function appendTurn(turns: CrossfireTurn[], turn: CrossfireTurn): CrossfireTurn[
6768
return [...turns, parsed.data];
6869
}
6970

71+
function attachSignal(turns: CrossfireTurn[], value: CrossfireTurnSignal): CrossfireTurn[] | null {
72+
const signal = CrossfireTurnSignalSchema.safeParse(value);
73+
if (!signal.success) return null;
74+
const targetIndex = turns.findIndex((turn) => turn.id === signal.data.answerTurnId && turn.kind === 'answer');
75+
if (targetIndex < 0) return null;
76+
const target = CrossfireTurnSchema.safeParse({ ...turns[targetIndex], signal: signal.data });
77+
if (!target.success) return null;
78+
return turns.map((turn, index) => index === targetIndex ? target.data : turn);
79+
}
80+
7081
export function crossfireReducer(state: CrossfireState, action: CrossfireAction): CrossfireState {
7182
switch (action.type) {
7283
case 'config-patched': {
@@ -111,16 +122,16 @@ export function crossfireReducer(state: CrossfireState, action: CrossfireAction)
111122
let turns = appendTurn(state.turns, action.turn);
112123
if (!turns) return state;
113124
if (action.signal) {
114-
const signal = CrossfireTurnSignalSchema.safeParse(action.signal);
115-
if (!signal.success) return state;
116-
const targetIndex = turns.findIndex((turn) => turn.id === signal.data.answerTurnId && turn.kind === 'answer');
117-
if (targetIndex < 0) return state;
118-
const target = CrossfireTurnSchema.safeParse({ ...turns[targetIndex], signal: signal.data });
119-
if (!target.success) return state;
120-
turns = turns.map((turn, index) => index === targetIndex ? target.data : turn);
125+
turns = attachSignal(turns, action.signal);
126+
if (!turns) return state;
121127
}
122128
return { ...state, turns, request: { status: 'idle' } };
123129
}
130+
case 'signal-received': {
131+
if (state.phase === 'setup') return state;
132+
const turns = attachSignal(state.turns, action.signal);
133+
return turns ? { ...state, turns } : state;
134+
}
124135
case 'request-failed': {
125136
if (state.phase !== 'active' || state.request.status !== 'pending') return state;
126137
if (action.expectedReply !== state.request.expectedReply) return state;

‎src/lib/crossfire/schemas.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,14 @@ export const CrossfireAIInputSchema = z.discriminatedUnion('operation', [
150150
]);
151151
export type CrossfireAIInput = z.infer<typeof CrossfireAIInputSchema>;
152152

153+
export const CrossfireSignalInputSchema = z.object({
154+
config: CrossfireConfigSchema,
155+
transcript: CrossfireTranscriptSchema,
156+
reply: CrossfireReplySchema,
157+
signalToken: z.string().trim().min(1).max(128),
158+
}).strict();
159+
export type CrossfireSignalInput = z.infer<typeof CrossfireSignalInputSchema>;
160+
153161
export const CrossfireRequestStateSchema = z.discriminatedUnion('status', [
154162
z.object({ status: z.literal('idle') }).strict(),
155163
z.object({

‎src/pages/CrossfirePage.tsx‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
2222
import { Checkbox } from '@/components/ui/checkbox';
2323
import { Label } from '@/components/ui/label';
2424
import { Textarea } from '@/components/ui/textarea';
25-
import { crossfire, AIResponseError, isTimeoutError } from '@/lib/ai/client';
25+
import { crossfire, crossfireSignal, AIResponseError, isTimeoutError } from '@/lib/ai/client';
2626
import { getUserFacingAIError } from '@/lib/ai/error-display';
2727
import {
2828
canEndCrossfireSession,
@@ -316,11 +316,23 @@ export default function CrossfirePage() {
316316
});
317317
return;
318318
}
319+
const reply = { type: result.type, text: result.text };
319320
dispatch({
320321
type: 'ai-turn-received',
321322
turn: nextTurn(snapshot.turns.length + 1, 'ai', result.type, result.text),
322-
signal: result.signal,
323323
});
324+
if (result.signalToken) {
325+
void crossfireSignal({
326+
config: snapshot.config,
327+
transcript: snapshot.turns,
328+
reply,
329+
signalToken: result.signalToken,
330+
}, modelConfig, { signal: controller.signal })
331+
.then((signal) => {
332+
if (signal && !controller.signal.aborted) dispatch({ type: 'signal-received', signal });
333+
})
334+
.catch((): void => {});
335+
}
324336
} catch (error) {
325337
if (controller.signal.aborted) return;
326338
const classified = classifyError(error);

‎src/test/ai/crossfire-client.test.ts‎

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ vi.mock('@/lib/stores/session-store', () => ({
3434

3535
vi.mock('@/hooks/use-toast', () => ({ toast: vi.fn() }));
3636

37-
import { AIResponseError, crossfire } from '@/lib/ai/client';
37+
import { AIResponseError, crossfire, crossfireSignal } from '@/lib/ai/client';
3838

3939
const config: ProviderConfig = {
4040
provider: 'google',
@@ -150,19 +150,20 @@ describe('Crossfire AI client', () => {
150150

151151
it('parses a valid live signal and rejects unknown or non-answer targets', async () => {
152152
const signal = { answerTurnId: 'T2', answerQuality: 'direct', answerQualityConfidence: 0.9, concession: 'none', concessionConfidence: 0.8, followUpOpportunity: 0.75 };
153-
mocks.invoke.mockResolvedValueOnce({ data: { output: { type: 'answer', text: 'Answer.' }, signal }, error: null });
154-
await expect(crossfire(nextTurnInput(), config)).resolves.toEqual({ type: 'answer', text: 'Answer.', signal });
153+
mocks.invoke.mockResolvedValueOnce({ data: { output: { type: 'answer', text: 'Answer.' }, signalToken: 'signed-token' }, error: null });
154+
await expect(crossfire(nextTurnInput(), config)).resolves.toEqual({ type: 'answer', text: 'Answer.', signalToken: 'signed-token' });
155155

156-
mocks.invoke.mockResolvedValueOnce({ data: { output: { type: 'answer', text: 'Answer.' }, signal: { ...signal, answerTurnId: 'T99' } }, error: null });
157-
await expect(crossfire(nextTurnInput(), config)).rejects.toBeInstanceOf(AIResponseError);
156+
const next = nextTurnInput();
157+
if (next.operation !== 'next-turn') throw new Error('Unexpected fixture');
158+
const signalInput = { config: next.config, transcript: next.transcript, reply: { type: 'answer' as const, text: 'Answer.' }, signalToken: 'signed-token' };
159+
mocks.invoke.mockResolvedValueOnce({ data: { output: { signal } }, error: null });
160+
await expect(crossfireSignal(signalInput, config)).resolves.toEqual(signal);
161+
162+
mocks.invoke.mockResolvedValueOnce({ data: { output: { signal: { ...signal, answerTurnId: 'T99' } } }, error: null });
163+
await expect(crossfireSignal(signalInput, config)).rejects.toBeInstanceOf(AIResponseError);
158164

159-
mocks.invoke.mockResolvedValueOnce({ data: { output: { type: 'question', text: 'Next?' }, signal: { ...signal, answerTurnId: 'T1' } }, error: null });
160-
const aiQuestionInput = nextTurnInput();
161-
if (aiQuestionInput.operation !== 'next-turn') throw new Error('Unexpected fixture');
162-
aiQuestionInput.config.mode = 'ai-questions-user';
163-
aiQuestionInput.transcript = [];
164-
aiQuestionInput.expectedReply = 'question';
165-
await expect(crossfire(aiQuestionInput, config)).rejects.toBeInstanceOf(AIResponseError);
165+
mocks.invoke.mockResolvedValueOnce({ data: { output: { signal: { ...signal, answerTurnId: 'T1' } } }, error: null });
166+
await expect(crossfireSignal(signalInput, config)).rejects.toBeInstanceOf(AIResponseError);
166167
});
167168

168169
it('validates debrief shape and every transcript reference', async () => {

‎src/test/crossfire/reducer.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ describe('crossfireReducer', () => {
7474
let state = crossfireReducer(configured(), { type: 'session-started', sessionId: 'signal-session', now: 1 });
7575
state = crossfireReducer(state, { type: 'user-turn-submitted', turn: turn(1, 'user', 'question'), expectedReply: 'answer' });
7676
const signal = { answerTurnId: 'T2', answerQuality: 'direct' as const, answerQualityConfidence: 0.9, concession: 'none' as const, concessionConfidence: 0.8, followUpOpportunity: 0.75 };
77-
state = crossfireReducer(state, { type: 'ai-turn-received', turn: turn(2, 'ai', 'answer'), signal });
77+
state = crossfireReducer(state, { type: 'ai-turn-received', turn: turn(2, 'ai', 'answer') });
78+
expect(state.phase === 'active' && state.turns[1].signal).toBeUndefined();
79+
state = crossfireReducer(state, { type: 'signal-received', signal });
7880
expect(state.phase === 'active' && state.turns[0].signal).toBeUndefined();
7981
expect(state.phase === 'active' && state.turns[1].signal).toEqual(signal);
8082

@@ -90,7 +92,8 @@ describe('crossfireReducer', () => {
9092
state = crossfireReducer(state, { type: 'ai-turn-received', turn: turn(1, 'ai', 'question') });
9193
state = crossfireReducer(state, { type: 'user-turn-submitted', turn: turn(2, 'user', 'answer'), expectedReply: 'question' });
9294
const signal = { answerTurnId: 'T2', answerQuality: 'partial' as const, answerQualityConfidence: 0.8, concession: 'implicit' as const, concessionConfidence: 0.7, followUpOpportunity: 0.9 };
93-
state = crossfireReducer(state, { type: 'ai-turn-received', turn: turn(3, 'ai', 'question'), signal });
95+
state = crossfireReducer(state, { type: 'ai-turn-received', turn: turn(3, 'ai', 'question') });
96+
state = crossfireReducer(state, { type: 'signal-received', signal });
9497
expect(state.phase === 'active' && state.turns[1].signal).toEqual(signal);
9598
expect(state.phase === 'active' && state.turns[2].signal).toBeUndefined();
9699
});

0 commit comments

Comments
 (0)