From 3ddfc9c8067ba4e9262da42dca102efc4b547570 Mon Sep 17 00:00:00 2001 From: Micio Dev Date: Sat, 10 Oct 2026 15:08:51 +0100 Subject: [PATCH 1/3] chore(frontend): sync the client with the boundary_due contract --- openapi.json | 17 ++++++++++++++++- types/api.ts | 7 ++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/openapi.json b/openapi.json index a2d26a0..f121f68 100644 --- a/openapi.json +++ b/openapi.json @@ -13421,7 +13421,22 @@ }, "responses": { "202": { - "description": "" + "description": "Utterance persisted. `boundary_due` is true once the competency has met its turn budget.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "boundary_due": { + "type": "boolean" + } + }, + "required": [ + "boundary_due" + ] + } + } + } }, "409": { "description": "Session was no longer in_corso at INSERT time (atomic rejection).\n409 Conflict is the canonical signal for \"session no longer in_corso\".\nThe client MUST treat 409 as a no-op (the interview has ended).", diff --git a/types/api.ts b/types/api.ts index 200b88e..bdb477c 100644 --- a/types/api.ts +++ b/types/api.ts @@ -10777,11 +10777,16 @@ export interface operations { }; }; responses: { + /** @description Utterance persisted. `boundary_due` is true once the competency has met its turn budget. */ 202: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + boundary_due: boolean; + }; + }; }; 401: components["responses"]["AuthenticationException"]; 404: components["responses"]["ModelNotFoundException"]; From a3fbdf624a01417f702dc90ad12c4f6075ad97c8 Mon Sep 17 00:00:00 2001 From: Micio Dev Date: Sat, 10 Oct 2026 15:10:36 +0100 Subject: [PATCH 2/3] feat(frontend): advance on the server's boundary signal --- app/composables/useInterviewSession.ts | 25 ++++++-- ...use-interview-session-continuation.spec.ts | 64 +++++++++++++++++++ 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/app/composables/useInterviewSession.ts b/app/composables/useInterviewSession.ts index 374c775..53bd0ed 100644 --- a/app/composables/useInterviewSession.ts +++ b/app/composables/useInterviewSession.ts @@ -789,9 +789,14 @@ export function useInterviewSession( } } - async function sendUtterance(dbSessionId: number, text: string, speaker: 'candidate' | 'avatar') { + /** Resolves true only when the 202 body carries a literal `boundary_due: true`. */ + async function sendUtterance( + dbSessionId: number, + text: string, + speaker: 'candidate' | 'avatar' + ): Promise { try { - await candidateFetch('/candidate/interview/utterance', { + const body = await candidateFetch('/candidate/interview/utterance', { method: 'POST', body: { session_id: dbSessionId, @@ -800,6 +805,7 @@ export function useInterviewSession( ts: new Date().toISOString(), }, }) + return (body as { boundary_due?: unknown } | null | undefined)?.boundary_due === true } catch (err) { // 401 → the stored session has already expired or been cleared // (candidateFetch clears it before throwing). Distinct, non-retryable @@ -807,7 +813,7 @@ export function useInterviewSession( if (err instanceof CandidateUnauthorizedError) { terminalReason.value = 'session_expired' transitionTo('terminal') - return + return false } // 409 = silently dropped; any other error is also non-fatal for utterance @@ -816,6 +822,7 @@ export function useInterviewSession( if (status !== 409) { console.warn('[useInterviewSession] /utterance error (non-fatal):', err) } + return false } } @@ -1125,11 +1132,17 @@ export function useInterviewSession( // that moves between two lines must attribute each to its own row. // Tracked, not merely fired: `callEnd()` drains this set before it runs, // so the tail cannot be 409-dropped by its own /end. - const inFlight = sendUtterance(handle.attribution.current, entry.text, speaker).catch( - () => {} - ) + const sent = sendUtterance(handle.attribution.current, entry.text, speaker).catch(() => false) + const inFlight = sent.then(() => {}) inFlightUtterances.add(inFlight) void inFlight.finally(() => inFlightUtterances.delete(inFlight)) + // Third boundary input (N9). Chained off `sent`, NOT the tracked promise: + // assertBoundary -> callEnd drains the tracked set and would wait on itself. + // assertBoundary gates on steerability and is idempotent against the other + // two inputs, so the flag is inert on every other path. + void sent.then((due) => { + if (due && handle === activeSession.value) void assertBoundary('completed') + }) }) handle.provider.on('error', (payload) => { diff --git a/tests/unit/use-interview-session-continuation.spec.ts b/tests/unit/use-interview-session-continuation.spec.ts index 124e9e7..d1843f6 100644 --- a/tests/unit/use-interview-session-continuation.spec.ts +++ b/tests/unit/use-interview-session-continuation.spec.ts @@ -594,3 +594,67 @@ describe('steering_failed handling', () => { expect(ends()[1]![1]).toMatchObject({ body: { session_id: B, ended_reason: 'timeout' } }) }) }) + +describe('boundary_due on the /utterance 202 (FE-05)', () => { + const say = () => providers[0]!._emit('transcript', { role: 'user', text: 'my answer', ts: 1 }) + + it('asserts the boundary when the server says it is due, without any closing phrase', async () => { + const session = await liveSession() + queue('/candidate/interview/utterance', { boundary_due: true }) + queue('/candidate/interview/end', END_CONTINUE) + queue('/candidate/interview/start', continuation(B)) + + say() + await flush() + + expect(ends()).toHaveLength(1) + expect(ends()[0]![1]).toMatchObject({ body: { session_id: A, ended_reason: 'completed' } }) + expect(session.sessionId.value).toBe(B) + }) + + it('the spoken phrase and boundary_due in one tick cause ONE boundary', async () => { + const session = await liveSession() + queue('/candidate/interview/utterance', { boundary_due: true }) + queue('/candidate/interview/end', END_CONTINUE) + queue('/candidate/interview/start', continuation(B)) + + say() + providers[0]!._emit('state', 'complete') + await flush() + + expect(ends()).toHaveLength(1) + expect(providers[0]!.sendBoundary).toHaveBeenCalledTimes(1) + expect(session.sessionId.value).toBe(B) + }) + + it.each([ + ['boundary_due false', { boundary_due: false }], + ['an empty body', undefined], + ['null', null], + ['a string', 'boundary_due'], + ['an empty object', {}], + ['a non-boolean flag', { boundary_due: 'true' }], + ])('%s does nothing', async (_label, body) => { + const session = await liveSession() + queue('/candidate/interview/utterance', body) + + say() + await flush() + + expect(ends()).toHaveLength(0) + expect(session.sessionId.value).toBe(A) + expect(session.state.value).toBe('live') + }) + + it('is ignored for a handle that cannot be steered (HeyGen/mock shape)', async () => { + steerableProviders = false + const session = await liveSession() + queue('/candidate/interview/utterance', { boundary_due: true }) + + say() + await flush() + + expect(ends()).toHaveLength(0) + expect(session.sessionId.value).toBe(A) + }) +}) From b2f59351a31298ad7dfe675ff05df287131295eb Mon Sep 17 00:00:00 2001 From: Micio Dev Date: Sat, 10 Oct 2026 16:03:01 +0100 Subject: [PATCH 3/3] test(frontend): pin the boundary_due failure paths --- ...use-interview-session-continuation.spec.ts | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/tests/unit/use-interview-session-continuation.spec.ts b/tests/unit/use-interview-session-continuation.spec.ts index d1843f6..77e8f15 100644 --- a/tests/unit/use-interview-session-continuation.spec.ts +++ b/tests/unit/use-interview-session-continuation.spec.ts @@ -31,6 +31,8 @@ vi.mock('~/app/composables/useCandidateSession', () => ({ // eslint-disable-next-line import/first import { useInterviewSession, isValidStartResponse } from '~/app/composables/useInterviewSession' +// eslint-disable-next-line import/first +import { CandidateUnauthorizedError } from '~/app/utils/candidate-api' type EventCallback = (payload: unknown) => void type SteeringResult = { ok: true } | { ok: false; reason: string } @@ -657,4 +659,87 @@ describe('boundary_due on the /utterance 202 (FE-05)', () => { expect(ends()).toHaveLength(0) expect(session.sessionId.value).toBe(A) }) + + it('a 401 on /utterance is the terminal session_expired and asserts no boundary', async () => { + const session = await liveSession() + queue('/candidate/interview/utterance', new Thrown(new CandidateUnauthorizedError())) + + say() + await flush() + + expect(ends()).toHaveLength(0) + expect(providers[0]!.sendBoundary).not.toHaveBeenCalled() + expect(session.state.value).toBe('terminal') + expect(session.terminalReason.value).toBe('session_expired') + expect(session.sessionId.value).toBe(A) + }) + + it('a network error on /utterance is non-fatal: no boundary, state unchanged', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const session = await liveSession() + queue('/candidate/interview/utterance', new Thrown(new TypeError('Failed to fetch'))) + + say() + await flush() + + expect(ends()).toHaveLength(0) + expect(providers[0]!.sendBoundary).not.toHaveBeenCalled() + expect(session.state.value).toBe('live') + expect(session.sessionId.value).toBe(A) + expect(warn).toHaveBeenCalled() + }) + + it('a send that REJECTS (even its error handler throws) resolves false: no boundary, no unhandled rejection', async () => { + // sendUtterance swallows fetch errors itself; the `.catch(() => false)` wrapper + // is the last line of defence for anything that escapes it, e.g. a throwing logger. + vi.spyOn(console, 'warn').mockImplementation(() => { + throw new Error('logger exploded') + }) + const unhandled = vi.fn() + process.on('unhandledRejection', unhandled) + try { + const session = await liveSession() + queue('/candidate/interview/utterance', new Thrown(new TypeError('Failed to fetch'))) + + say() + await flush() + await vi.advanceTimersByTimeAsync(0) + + expect(unhandled).not.toHaveBeenCalled() + expect(ends()).toHaveLength(0) + expect(providers[0]!.sendBoundary).not.toHaveBeenCalled() + expect(session.state.value).toBe('live') + expect(session.sessionId.value).toBe(A) + } finally { + process.off('unhandledRejection', unhandled) + } + }) + + it('boundary_due arriving while a user-initiated /end is in flight causes no second /end (R3-001)', async () => { + const session = await liveSession() + let releaseEnd!: (v: unknown) => void + queue( + '/candidate/interview/end', + new Promise((resolve) => { + releaseEnd = resolve + }) + ) + queue('/candidate/interview/start', continuation(B)) + queue('/candidate/interview/utterance', { boundary_due: true }) + + void session.endQuestion('timeout') + await flush() + expect(ends()).toHaveLength(1) + + say() // the 202 now says boundary_due while the user-initiated /end is pending + await flush() + expect(ends()).toHaveLength(1) + + releaseEnd(END_CONTINUE) + await flush() + + expect(ends()).toHaveLength(1) + expect(providers[0]!.sendBoundary).toHaveBeenCalledTimes(1) + expect(session.sessionId.value).toBe(B) + }) })