Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions app/composables/useInterviewSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
try {
await candidateFetch('/candidate/interview/utterance', {
const body = await candidateFetch<unknown>('/candidate/interview/utterance', {
method: 'POST',
body: {
session_id: dbSessionId,
Expand All @@ -800,14 +805,15 @@ 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
// terminal — never falls into the "non-fatal, keep going" path below.
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
Expand All @@ -816,6 +822,7 @@ export function useInterviewSession(
if (status !== 409) {
console.warn('[useInterviewSession] /utterance error (non-fatal):', err)
}
return false
}
}

Expand Down Expand Up @@ -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) => {
Expand Down
17 changes: 16 additions & 1 deletion openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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).",
Expand Down
149 changes: 149 additions & 0 deletions tests/unit/use-interview-session-continuation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -594,3 +596,150 @@ 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)
})

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)
})
})
7 changes: 6 additions & 1 deletion types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down
Loading