blob: 3c9913c39e729a58024a69c85ed1b2db0cdfd86d [file]
import {
decodeInteractionAnswer as decodeCoreInteractionAnswer,
decodeInteractionCanonicalOutcome as decodeCoreInteractionCanonicalOutcome,
decodeInteractionRequest as decodeCoreInteractionRequest,
isInteractionCanonicalOutcomeValidForRequest,
type InteractionAnswer,
type InteractionCanonicalOutcome,
type InteractionClosureReason,
type InteractionPermissionAnswer,
type InteractionPermissionDecisionFields,
type InteractionPermissionPrompt,
type InteractionQuestion,
type InteractionQuestionOption,
type InteractionRequest,
type InteractionSandboxBoundaryAnswer,
type InteractionSandboxBoundaryRequest,
} from '@maka/core';
import { assertExactKeys, requireEntityId, requireRecord } from './codec.js';
import { invalidProtocolFrame } from './errors.js';
import { defineOperation } from './operation-spec.js';
export type {
InteractionAnswer,
InteractionCanonicalOutcome,
InteractionClosureReason,
InteractionPermissionAnswer,
InteractionPermissionDecisionFields,
InteractionPermissionPrompt,
InteractionQuestion,
InteractionQuestionOption,
InteractionRequest,
InteractionSandboxBoundaryAnswer,
InteractionSandboxBoundaryRequest,
};
export const INTERACTION_SCHEMA_VERSION = 1 as const;
export const INTERACTION_PENDING_REVISION = 1 as const;
export const INTERACTION_RESOLVED_REVISION = 2 as const;
export const INTERACTION_MAX_PENDING_PER_SESSION = 16;
export type InteractionRevision =
| typeof INTERACTION_PENDING_REVISION
| typeof INTERACTION_RESOLVED_REVISION;
interface InteractionSnapshotBase {
readonly schemaVersion: typeof INTERACTION_SCHEMA_VERSION;
readonly interactionId: string;
readonly sessionId: string;
readonly turnId: string;
readonly runId: string;
readonly request: InteractionRequest;
}
export type InteractionPendingSnapshot = InteractionSnapshotBase & {
readonly revision: typeof INTERACTION_PENDING_REVISION;
readonly status: 'pending';
readonly outcome: null;
};
export type InteractionAnsweredSnapshot = InteractionSnapshotBase & {
readonly revision: typeof INTERACTION_RESOLVED_REVISION;
readonly status: 'answered';
readonly outcome: Exclude<InteractionCanonicalOutcome, { kind: 'closure' }>;
};
export type InteractionClosedSnapshot = InteractionSnapshotBase & {
readonly revision: typeof INTERACTION_RESOLVED_REVISION;
readonly status: 'closed';
readonly outcome: Extract<InteractionCanonicalOutcome, { kind: 'closure' }>;
};
export type InteractionResolvedSnapshot = InteractionAnsweredSnapshot | InteractionClosedSnapshot;
export type InteractionSnapshot = InteractionPendingSnapshot | InteractionResolvedSnapshot;
export interface SessionInteractionProjection {
readonly pending: readonly InteractionPendingSnapshot[];
}
export interface InteractionQueryInput {
readonly sessionId: string;
readonly interactionId: string;
}
export interface InteractionAnswerInput {
readonly sessionId: string;
readonly interactionId: string;
readonly answer: InteractionAnswer;
}
const COMMON_ERRORS = ['host_not_ready', 'host_draining', 'operation_unavailable'] as const;
export const INTERACTION_OPERATION_SPECS = {
'interaction.query': defineOperation({
mode: 'query',
availability: 'ready',
decodeInput: decodeInteractionQueryInput,
decodeOutput: decodeInteractionSnapshot,
errors: [...COMMON_ERRORS, 'not_found', 'internal_failure'] as const,
}),
'interaction.answer': defineOperation({
mode: 'command',
availability: 'ready',
decodeInput: decodeInteractionAnswerInput,
decodeOutput: decodeInteractionAnsweredSnapshot,
errors: [
...COMMON_ERRORS,
'not_found',
'operation_conflict',
'already_resolved',
'internal_failure',
] as const,
}),
} as const;
export function decodeInteractionRequest(value: unknown): InteractionRequest {
return decodeCoreValue(value, 'Interaction request', decodeCoreInteractionRequest);
}
export function decodeInteractionAnswer(value: unknown): InteractionAnswer {
return decodeCoreValue(value, 'Interaction answer', decodeCoreInteractionAnswer);
}
export function decodeInteractionCanonicalOutcome(value: unknown): InteractionCanonicalOutcome {
return decodeCoreValue(
value,
'Interaction canonical outcome',
decodeCoreInteractionCanonicalOutcome,
);
}
export function decodeInteractionSnapshot(value: unknown): InteractionSnapshot {
const record = requireRecord(value, 'Interaction snapshot');
assertExactKeys(record, 'Interaction snapshot', [
'schemaVersion',
'interactionId',
'sessionId',
'turnId',
'runId',
'revision',
'request',
'status',
'outcome',
]);
if (record.schemaVersion !== INTERACTION_SCHEMA_VERSION) {
throw invalidProtocolFrame('Unsupported Interaction snapshot schema');
}
const request = decodeInteractionRequest(record.request);
const revision = requireInteractionRevision(record.revision);
const base: InteractionSnapshotBase = {
schemaVersion: INTERACTION_SCHEMA_VERSION,
interactionId: requireEntityId(record.interactionId, 'interactionId'),
sessionId: requireEntityId(record.sessionId, 'sessionId'),
turnId: requireEntityId(record.turnId, 'turnId'),
runId: requireEntityId(record.runId, 'runId'),
request,
};
if (record.status === 'pending') {
if (revision !== INTERACTION_PENDING_REVISION || record.outcome !== null) {
throw invalidProtocolFrame('Invalid pending Interaction snapshot');
}
return { ...base, revision, status: 'pending', outcome: null };
}
if (record.status !== 'answered' && record.status !== 'closed') {
throw invalidProtocolFrame('Invalid Interaction status');
}
if (revision !== INTERACTION_RESOLVED_REVISION) {
throw invalidProtocolFrame('Invalid resolved Interaction revision');
}
const outcome = decodeInteractionCanonicalOutcome(record.outcome);
if (!isInteractionCanonicalOutcomeValidForRequest(request, outcome)) {
throw invalidProtocolFrame('Interaction outcome does not match its request');
}
if (record.status === 'answered') {
if (outcome.kind === 'closure') {
throw invalidProtocolFrame('Answered Interaction has a closure outcome');
}
return { ...base, revision, status: 'answered', outcome };
}
if (outcome.kind !== 'closure') {
throw invalidProtocolFrame('Closed Interaction has an answer outcome');
}
return { ...base, revision, status: 'closed', outcome };
}
export function decodeInteractionAnsweredSnapshot(value: unknown): InteractionAnsweredSnapshot {
const snapshot = decodeInteractionSnapshot(value);
if (snapshot.status !== 'answered') {
throw invalidProtocolFrame('Interaction answer output is not answered');
}
return snapshot;
}
export function decodeSessionInteractionProjection(
value: unknown,
sessionId: string,
): SessionInteractionProjection {
const record = requireRecord(value, 'Session Interaction projection');
assertExactKeys(record, 'Session Interaction projection', ['pending']);
if (
!Array.isArray(record.pending) ||
record.pending.length > INTERACTION_MAX_PENDING_PER_SESSION
) {
throw invalidProtocolFrame('Invalid pending Interactions');
}
const identities = new Set<string>();
const pending = record.pending.map((candidate) => {
const snapshot = decodeInteractionSnapshot(candidate);
if (snapshot.status !== 'pending') {
throw invalidProtocolFrame('Pending Interaction projection contains a resolved Interaction');
}
if (snapshot.sessionId !== sessionId) {
throw invalidProtocolFrame('Interaction projection belongs to a different Session');
}
if (identities.has(snapshot.interactionId)) {
throw invalidProtocolFrame('Session Interaction projection repeats an Interaction identity');
}
identities.add(snapshot.interactionId);
return snapshot;
});
return { pending };
}
function decodeInteractionQueryInput(value: unknown): InteractionQueryInput {
const record = requireRecord(value, 'interaction.query input');
assertExactKeys(record, 'interaction.query input', ['sessionId', 'interactionId']);
return {
sessionId: requireEntityId(record.sessionId, 'sessionId'),
interactionId: requireEntityId(record.interactionId, 'interactionId'),
};
}
function decodeInteractionAnswerInput(value: unknown): InteractionAnswerInput {
const record = requireRecord(value, 'interaction.answer input');
assertExactKeys(record, 'interaction.answer input', ['sessionId', 'interactionId', 'answer']);
return {
sessionId: requireEntityId(record.sessionId, 'sessionId'),
interactionId: requireEntityId(record.interactionId, 'interactionId'),
answer: decodeInteractionAnswer(record.answer),
};
}
function decodeCoreValue<T>(value: unknown, label: string, decode: (candidate: unknown) => T): T {
try {
return decode(value);
} catch {
throw invalidProtocolFrame(`Invalid ${label}`);
}
}
function requireInteractionRevision(value: unknown): InteractionRevision {
if (value === INTERACTION_PENDING_REVISION || value === INTERACTION_RESOLVED_REVISION) {
return value;
}
throw invalidProtocolFrame('Invalid Interaction revision');
}