diff --git a/src/client.ts b/src/client.ts index 0ad1208..b7ebbae 100644 --- a/src/client.ts +++ b/src/client.ts @@ -394,7 +394,16 @@ export class Tuteliq { if (typeof context === 'string') { return { platform: Tuteliq.resolvePlatform(context) }; } - return { ...context, platform: Tuteliq.resolvePlatform(context.platform) }; + // priorMessages (idiomatic camelCase, matching every other field on + // ContextInput) is translated to the wire's prior_messages here -- + // the API only recognizes the snake_case key. Destructured out so + // the spread below never forwards the untranslated camelCase name. + const { priorMessages, ...rest } = context; + return { + ...rest, + platform: Tuteliq.resolvePlatform(context.platform), + ...(priorMessages && { prior_messages: priorMessages }), + }; } /** @@ -834,6 +843,8 @@ export class Tuteliq { ...(input.customer_id && { customer_id: input.customer_id }), ...(input.metadata && { metadata: input.metadata }), ...(input.incident_moderation_enabled !== undefined && { incident_moderation_enabled: input.incident_moderation_enabled }), + ...(input.continuationToken && { continuation_token: input.continuationToken }), + ...(input.resetConversation && { reset_conversation: true }), ...(Object.keys(options).length > 0 && { options }), } ); diff --git a/src/types/safety.ts b/src/types/safety.ts index 7dabe23..b151bf7 100644 --- a/src/types/safety.ts +++ b/src/types/safety.ts @@ -100,6 +100,18 @@ export function strongestAction( return best; } +/** + * One prior turn of a conversation, for `ContextInput.priorMessages`. + */ +export interface PriorMessage { + /** Sender's role in this conversation (e.g. "user", "contact", "adult", "child") */ + role: string; + /** Message text */ + text: string; + /** ISO 8601 timestamp of the message (optional) */ + timestamp?: string; +} + /** * Context type - can be a string shorthand or detailed object */ @@ -119,6 +131,18 @@ export type ContextInput = string | { platform?: string; /** ISO 3166-1 alpha-2 country code (e.g., "GB", "US") for geo-localised helpline data */ country?: string; + /** + * Prior turns of this conversation, oldest first, submitted for THIS + * request only — never stored server-side, used once to build the + * analysis prompt for this call and then discarded. Lets a single call + * see the trajectory a gradually-building risk needs, without calling + * once per message. Supported by `detectUnsafe` and the fraud/coercive + * control/distress-signals endpoint family; ignored (harmlessly) by + * endpoints with no multi-turn content field of their own, e.g. + * `detectBullying` (use its `continuationToken` instead) and + * `detectGrooming` (which takes a required `messages` array directly). + */ + priorMessages?: PriorMessage[]; }; // ============================================================================= @@ -500,6 +524,20 @@ export interface DetectUnsafeInput extends TrackingFields { context?: ContextInput; /** Minimum severity to show crisis support resources (default: 'high'). Critical always shows. */ supportThreshold?: 'low' | 'medium' | 'high' | 'critical'; + /** + * Opaque signed token returned by a prior /unsafe call. Carries derived + * conversation-trajectory state (category counts, severity history) into + * the next call without storing any user content server-side. Pass back + * verbatim to maintain multi-turn awareness across calls -- the + * alternative to submitting the whole conversation via + * `context.priorMessages` on every call. + */ + continuationToken?: string; + /** + * If true, discard any provided continuationToken and start a fresh + * conversation. Useful when starting a new chat in the same session. + */ + resetConversation?: boolean; /** * Fast mode. When true, the response omits any per-message * `message_analysis` breakdown and returns only the verdict. Lower latency @@ -572,6 +610,29 @@ export interface UnsafeResult { customer_id?: string; /** Echo of provided metadata (if any) */ metadata?: Record; + /** + * Opaque signed token carrying derived analysis state to the next call. + * Pass back as `continuationToken` on the next /unsafe request to + * preserve multi-turn awareness without server-side content storage. + */ + continuation_token?: string; + /** ISO 8601 expiry timestamp of the continuation_token. */ + continuation_expires_at?: string; + /** + * How prior state was sourced: "token" (decoded from a continuationToken), + * "fresh" (no prior state), "reset" (resetConversation forced a restart). + */ + state_source?: 'token' | 'fresh' | 'reset'; + /** + * Conversation-level risk (0-1), derived from the signed continuation + * token. Distinct from `risk_score`, which scores only this request's + * content. Absent on the first turn of a fresh conversation. + */ + trajectory_risk?: number; + /** Direction of travel across the conversation so far. Absent alongside `trajectory_risk`. */ + trajectory?: ConversationTrajectory; + /** Per-turn severity, oldest first — the evidence behind `trajectory_risk`. */ + severity_series?: number[]; /** * True when a coded-term match pushed severity toward critical but this * endpoint's corroboration-cap logic held `recommended_action` below diff --git a/tests/client.test.ts b/tests/client.test.ts index f9c0be1..d3e0110 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -186,6 +186,71 @@ describe('Tuteliq', () => { expect(result.unsafe).toBe(true); expect(result.categories).toContain('self_harm'); }); + + it('should translate context.priorMessages to prior_messages on the wire', async () => { + const mockResponse = { unsafe: false, categories: [] }; + + vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(mockResponse)); + + await tuteliq.detectUnsafe({ + content: "anyway it's whatever, not a big deal", + context: { + ageGroup: '14-17', + priorMessages: [ + { role: 'user', text: 'nothing I do matters anymore' }, + { role: 'user', text: 'been avoiding everyone' }, + ], + }, + }); + + const call = vi.mocked(fetch).mock.calls[0]; + const body = JSON.parse(call[1]?.body as string); + expect(body.context.prior_messages).toEqual([ + { role: 'user', text: 'nothing I do matters anymore' }, + { role: 'user', text: 'been avoiding everyone' }, + ]); + // The idiomatic camelCase name must never leak onto the wire — + // the API only recognizes the snake_case key. + expect(body.context.priorMessages).toBeUndefined(); + expect(body.context.ageGroup).toBe('14-17'); + }); + + it('omits prior_messages entirely when priorMessages is not provided', async () => { + const mockResponse = { unsafe: false, categories: [] }; + + vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(mockResponse)); + + await tuteliq.detectUnsafe({ content: 'a plain message' }); + + const call = vi.mocked(fetch).mock.calls[0]; + const body = JSON.parse(call[1]?.body as string); + expect(body.context).not.toHaveProperty('prior_messages'); + }); + + it('passes continuationToken and resetConversation through to the request body', async () => { + const mockResponse = { + unsafe: false, + categories: [], + continuation_token: 'signed-token-abc', + trajectory_risk: 0.4, + trajectory: 'stable', + }; + + vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(mockResponse)); + + const result = await tuteliq.detectUnsafe({ + content: 'a follow-up message', + continuationToken: 'prior-token-xyz', + resetConversation: true, + }); + + const call = vi.mocked(fetch).mock.calls[0]; + const body = JSON.parse(call[1]?.body as string); + expect(body.continuation_token).toBe('prior-token-xyz'); + expect(body.reset_conversation).toBe(true); + expect(result.continuation_token).toBe('signed-token-abc'); + expect(result.trajectory_risk).toBe(0.4); + }); }); describe('analyze', () => {