diff --git a/packages/agent-harness/src/contracts.test.ts b/packages/agent-harness/src/contracts.test.ts new file mode 100644 index 0000000000..05a420f308 --- /dev/null +++ b/packages/agent-harness/src/contracts.test.ts @@ -0,0 +1,410 @@ +import { expect, it } from 'vitest'; +import * as c from './contracts'; + +const id = '11111111-1111-4111-8111-111111111111'; +const time = '2026-08-28T07:00:00.000Z'; +const context = { type: 'organization', organizationId: id }; +const error = { code: 'access_revoked', message: 'Access removed', retryable: false }; +const conversation = { + id, + ownerUserId: 'auth0|old-user', + context, + permissionMode: 'ask', + permissionRevision: 0, +}; +const run = { + id, + conversationId: id, + inputMessageId: id, + originClientId: id, + modelId: 'test/model', + variant: 'fast', +}; +const call = { + id, + runId: id, + name: 'app.notifications', + definitionVersion: '1', + arguments: { nested: ['value'] }, + context, + effect: 'side_effect', + executionTarget: { kind: 'client', clientId: id }, + approval: null, + state: 'waiting', + result: null, +}; +const grant = { + id, + conversationId: id, + ownerUserId: conversation.ownerUserId, + clientId: id, + toolCallId: id, + context, + definitionVersion: '1', + inputDigest: 'digest', + generation: 0, + expiresAt: time, +}; +const interaction = { id, kind: 'approval', toolCall: call, resolution: null }; +const action = { toolCall: call, grant, reason: 'locked' }; +const text = { + id, + role: 'assistant', + content: 'Historical answer', + clientId: null, + createdAt: time, +}; +const snapshot = { + protocolVersion: 1, + conversation, + recentMessages: [], + historyCursor: null, + activeRun: null, + queuedRuns: [], + unresolvedInteractions: [interaction], + pendingClientActions: [action], + eventCursor: 42, +}; +const serialized = (value: unknown): unknown => JSON.parse(JSON.stringify(value)); +const completedRead = { + ...call, + id: '22222222-2222-4222-8222-222222222222', + runId: '33333333-3333-4333-8333-333333333333', + name: 'test.read', + effect: 'read', + executionTarget: { kind: 'backend' }, + state: 'settled', + result: { status: 'succeeded', output: { records: [{ id: 'record-7', value: 21 }] } }, +}; +const toolMessage = { + ...text, + provenance: 'harness', + protocolVersion: 1, + runId: completedRead.runId, + parts: [{ type: 'tool_call', toolCall: completedRead }], + incomplete: false, +}; +const completedSnapshot = { + ...snapshot, + recentMessages: [toolMessage], + unresolvedInteractions: [], + pendingClientActions: [], +}; +const completedEvent = { + protocolVersion: 1, + conversationId: id, + sequence: 43, + event: { type: 'message', message: toolMessage }, +}; + +it.each([ + ['snapshot', c.SnapshotSchema, completedSnapshot], + ['history message', c.MessageSchema, toolMessage], + ['message event', c.EventEnvelopeSchema, completedEvent], +] as const)( + 'restores a completed backend read from a %s without pending work', + (_name, schema, input) => { + expect(schema.parse(serialized(schema.parse(input)))).toEqual(input); + } +); +it('restores immutable tool inputs and targets from a historical message', () => { + const restored = c.MessageSchema.parse(serialized(c.MessageSchema.parse(toolMessage))); + const part = restored.parts[0]; + if (part.type !== 'tool_call') throw new Error('Missing tool record'); + expect(Reflect.set(part.toolCall, 'definitionVersion', '2')).toBe(false); + expect(Reflect.set(part.toolCall.context, 'organizationId', 'another-org')).toBe(false); + expect(Reflect.set(part.toolCall.executionTarget, 'kind', 'client')).toBe(false); + const nested = part.toolCall.arguments.nested; + if (typeof nested !== 'object' || nested === null) throw new Error('Missing nested arguments'); + expect(Reflect.set(nested, '0', 'changed')).toBe(false); + expect(part.toolCall).toEqual(completedRead); +}); +it('restores the approval and unknown outcome after a resolved interaction leaves the snapshot', () => { + const approvedCall = { + ...completedRead, + name: 'test.write', + effect: 'side_effect', + approval: { interactionId: id, commandId: id, decision: 'approve' }, + result: { status: 'outcome_unknown', reason: 'Lost reply', providerReference: 'operation-1' }, + }; + const input = { + ...completedSnapshot, + recentMessages: [{ ...toolMessage, parts: [{ type: 'tool_call', toolCall: approvedCall }] }], + }; + expect(c.SnapshotSchema.parse(serialized(c.SnapshotSchema.parse(input)))).toEqual(input); +}); +it.each(['id', 'arguments', 'result'])( + 'rejects a recorded call missing %s across synchronization payloads', + field => { + const message = { + ...toolMessage, + parts: [{ type: 'tool_call', toolCall: { ...completedRead, [field]: undefined } }], + }; + expect(c.MessageSchema.safeParse(serialized(message)).success).toBe(false); + expect( + c.SnapshotSchema.safeParse(serialized({ ...completedSnapshot, recentMessages: [message] })) + .success + ).toBe(false); + expect( + c.EventEnvelopeSchema.safeParse( + serialized({ ...completedEvent, event: { type: 'message', message } }) + ).success + ).toBe(false); + } +); + +it.each([ + {}, + { provenance: 'legacy' }, + { parts: [{ type: 'tool_call', toolCallId: id }], runId: id }, + { parts: toolMessage.parts, runId: completedRead.runId }, + { provenance: 'legacy', parts: toolMessage.parts, runId: completedRead.runId }, +])('normalizes legacy text without authority: %j', additions => { + for (const role of ['user', 'assistant']) { + expect(c.MessageSchema.parse({ ...text, role, ...additions })).toEqual({ + ...text, + role, + provenance: 'legacy', + parts: [{ type: 'text', text: text.content }], + }); + } +}); +it('forces legacy append rows to text even when a caller forges harness provenance', () => { + expect(c.LegacyMessageSchema.parse(toolMessage)).toEqual({ + ...text, + provenance: 'legacy', + parts: [{ type: 'text', text: text.content }], + }); +}); +it.each([{ type: 'personal' }, context])( + 'defaults missing settings without replacing context: %j', + scope => { + expect( + c.ConversationSchema.parse({ id, ownerUserId: conversation.ownerUserId, context: scope }) + ).toEqual({ ...conversation, context: scope }); + expect( + c.ConversationSchema.parse({ ...conversation, permissionMode: 'yolo', permissionRevision: 7 }) + ).toMatchObject({ permissionMode: 'yolo', permissionRevision: 7 }); + } +); +const parts = [ + { type: 'text', text: 'Result' }, + { type: 'tool_call', toolCall: call }, + { type: 'citation', title: 'Source', url: 'https://example.com/source' }, +]; +it.each([ + { input: undefined, expected: [{ type: 'text', text: 'Historical answer' }] }, + { input: parts, expected: parts }, +])('preserves new parts or supplies the text fallback: %j', ({ input, expected }) => { + const message = { + ...text, + provenance: 'harness', + protocolVersion: 1, + runId: id, + incomplete: true, + }; + expect(serialized(c.MessageSchema.parse({ ...message, parts: input }))).toEqual({ + ...message, + parts: expected, + }); +}); + +it.each([ + ...['queued', 'running', 'stopping', 'completed', 'cancelled'].map(status => ({ status })), + ...['approval', 'question', 'client', 'reconciliation'].map(reason => ({ + status: 'waiting', + waiting: { toolCallId: id, reason }, + })), + { status: 'failed', error }, +])('retains the run state in ordered events: %j', state => { + const event = { + protocolVersion: 1, + conversationId: id, + sequence: 43, + event: { type: 'run', run: { ...run, state } }, + }; + expect(serialized(c.EventEnvelopeSchema.parse(event))).toEqual(event); + expect(c.RunStateSchema.safeParse({ ...state, unexpected: true }).success).toBe(false); +}); +it.each([ + { status: 'succeeded', output: { values: ['ok', 1, true, null] } }, + { status: 'failed', error }, + { status: 'denied' }, + { status: 'cancelled' }, + { status: 'outcome_unknown', reason: 'Lost reply', providerReference: 'operation-1' }, +])('retains each settled tool outcome without converting uncertainty: %j', result => { + const settled = { ...call, state: 'settled', result }; + expect(serialized(c.ToolCallSchema.parse(settled))).toEqual(settled); + expect(c.ToolOutcomeSchema.safeParse({ ...result, unexpected: true }).success).toBe(false); +}); +it.each(['browser', 'mobile'])('preserves the registered %s client and arbitrary user ID', kind => { + const client = { + id, + ownerUserId: conversation.ownerUserId, + kind, + supportedTools: [{ name: call.name, version: '1' }], + revokedAt: null, + }; + expect(serialized(c.ClientSchema.parse(client))).toEqual(client); +}); +it.each([ + interaction, + { ...interaction, resolution: { interactionId: id, commandId: id, decision: 'approve' } }, + { ...interaction, resolution: { interactionId: id, commandId: id, decision: 'deny' } }, + { ...interaction, kind: 'question', questionId: 'q1', resolution: null }, + { + ...interaction, + kind: 'question', + questionId: 'q1', + resolution: { kind: 'answer', choiceIds: ['c1'], text: 'Other' }, + }, + { ...interaction, kind: 'question', questionId: 'q1', resolution: { kind: 'dismiss' } }, +])('retains durable interaction identity and resolution: %j', record => { + expect(serialized(c.InteractionSchema.parse(record))).toEqual(record); +}); +it.each(['offline', 'background', 'locked', 'gesture', 'unavailable', 'reconciliation'])( + 'retains %s client actions and interactions outside an empty history page', + reason => { + const input = { ...snapshot, pendingClientActions: [{ ...action, reason }] }; + expect(serialized(c.SnapshotSchema.parse(input))).toEqual(input); + } +); +it.each(Object.keys(snapshot))('rejects a snapshot missing %s', field => { + const input = { ...snapshot }; + Reflect.deleteProperty(input, field); + expect(c.SnapshotSchema.safeParse(input).success).toBe(false); +}); +it('prevents changing normalized context, arguments, definitions, and designated targets', () => { + const parsed = c.ToolCallSchema.parse(call); + expect(Reflect.set(parsed, 'context', { type: 'personal' })).toBe(false); + expect(Reflect.set(parsed.context, 'organizationId', 'another-org')).toBe(false); + expect(Reflect.set(parsed, 'definitionVersion', '2')).toBe(false); + expect(Reflect.set(parsed.executionTarget, 'clientId', 'another-client')).toBe(false); + const nested = parsed.arguments.nested; + if (typeof nested !== 'object' || nested === null) throw new Error('Missing nested arguments'); + expect(Reflect.set(nested, '0', 'changed')).toBe(false); + expect(serialized(parsed)).toEqual(call); +}); + +it.each([ + { provenance: 'legacy' }, + { provenance: 'harness', protocolVersion: 1, runId: id, incomplete: false }, +])('restores missing producer fields before serialization: %j', fields => { + const input = { ...text, ...fields }; + Reflect.deleteProperty(input, 'clientId'); + Reflect.deleteProperty(input, 'incomplete'); + expect(serialized(c.MessageSchema.parse(input))).toEqual({ + ...text, + ...fields, + parts: [{ type: 'text', text: text.content }], + }); +}); +it.each([ + { state: 'pending', effect: 'read', executionTarget: { kind: 'backend' } }, + { state: 'executing', effect: 'unknown', executionTarget: { kind: 'client', clientId: id } }, + { state: 'waiting', effect: 'side_effect', executionTarget: { kind: 'interaction' } }, +])('retains unsettled calls and their execution targets: %j', fields => { + const input = { ...call, ...fields }; + expect(serialized(c.ToolCallSchema.parse(input))).toEqual(input); +}); +it.each([ + { status: 'running' }, + { status: 'waiting', waiting: { toolCallId: id, reason: 'approval' } }, + { status: 'stopping' }, +])('retains active and queued runs with an opaque history cursor: %j', state => { + const input = { + ...snapshot, + historyCursor: 'before:message-1', + activeRun: { ...run, state }, + queuedRuns: [{ ...run, state: { status: 'queued' } }], + }; + expect(serialized(c.SnapshotSchema.parse(input))).toEqual(input); +}); +const envelope = { + protocolVersion: 1, + conversationId: id, + sequence: 43, + event: { type: 'conversation', conversation }, +}; +it.each([ + envelope.event, + { + type: 'message', + message: { + ...text, + provenance: 'harness', + protocolVersion: 1, + runId: id, + parts, + incomplete: false, + }, + }, + { type: 'interaction', interaction }, + { type: 'client_action', toolCallId: id, action }, + { type: 'client_action', toolCallId: id, action: null }, +])('preserves each non-run event payload: %j', event => { + const input = { ...envelope, event }; + expect(serialized(c.EventEnvelopeSchema.parse(input))).toEqual(input); +}); + +it.each([ + [c.RunStateSchema, { status: 'waiting' }], + [c.RunStateSchema, { status: 'failed' }], + [c.RunStateSchema, { status: 'paused' }], + [c.WaitingSchema, { toolCallId: id, reason: 'offline' }], + [c.RunSchema, { ...run, variant: 1, state: { status: 'queued' } }], + [c.ToolOutcomeSchema, { status: 'succeeded' }], + [c.ToolOutcomeSchema, { status: 'failed' }], + [c.ToolOutcomeSchema, { status: 'outcome_unknown' }], + [c.ToolOutcomeSchema, { status: 'timeout' }], + [c.ToolCallSchema, { ...call, state: 'settled' }], + [c.ToolCallSchema, { ...call, result: { status: 'cancelled' } }], + [c.ExecutionTargetSchema, { kind: 'client' }], + [c.ExecutionTargetSchema, { kind: 'backend', clientId: id }], + [c.InteractionSchema, { ...interaction, kind: 'question' }], + [c.ExecutionGrantSchema, { ...grant, clientId: undefined }], + [c.ConversationSchema, { ...conversation, context: { type: 'organization' } }], + [c.ConversationSchema, { ...conversation, permissionMode: 'auto' }], + [c.ConversationSchema, { ...conversation, permissionRevision: -1 }], + [c.MessageSchema, { ...text, provenance: 'harness', runId: id }], + [c.MessageSchema, { ...text, provenance: 'harness', protocolVersion: 2, runId: id }], + [c.MessageSchema, { ...text, protocolVersion: 2 }], + [c.MessageSchema, { ...text, provenance: 'unknown' }], + [c.MessagePartSchema, { type: 'citation', title: 'Unsafe', url: 'javascript:alert(1)' }], + [c.MessagePartSchema, { type: 'tool_call' }], + [c.MessagePartSchema, { type: 'tool_call', toolCallId: completedRead.id }], + [ + c.MessageSchema, + { ...toolMessage, parts: [{ type: 'tool_call', toolCallId: completedRead.id }] }, + ], + ...[0, 2, undefined].map( + protocolVersion => [c.SnapshotSchema, { ...snapshot, protocolVersion }] as const + ), + ...[-1, 1.5, Number.MAX_SAFE_INTEGER + 1].map( + eventCursor => [c.SnapshotSchema, { ...snapshot, eventCursor }] as const + ), + [c.SnapshotSchema, { ...snapshot, activeRun: { ...run, state: { status: 'completed' } } }], + [c.SnapshotSchema, { ...snapshot, queuedRuns: [{ ...run, state: { status: 'running' } }] }], + [ + c.SnapshotSchema, + { + ...snapshot, + unresolvedInteractions: [ + { ...interaction, resolution: { interactionId: id, commandId: id, decision: 'approve' } }, + ], + }, + ], + [ + c.PendingClientActionSchema, + { ...action, toolCall: { ...call, executionTarget: { kind: 'backend' } } }, + ], + ...[0, 2, undefined].map( + protocolVersion => [c.EventEnvelopeSchema, { ...envelope, protocolVersion }] as const + ), + ...[0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1].map( + sequence => [c.EventEnvelopeSchema, { ...envelope, sequence }] as const + ), + [c.EventEnvelopeSchema, { ...envelope, event: { type: 'unknown' } }], +] as const)('rejects invalid contract input %#', (schema, input) => { + expect(schema.safeParse(input).success).toBe(false); +}); diff --git a/packages/agent-harness/src/contracts.ts b/packages/agent-harness/src/contracts.ts new file mode 100644 index 0000000000..1db28107c3 --- /dev/null +++ b/packages/agent-harness/src/contracts.ts @@ -0,0 +1,271 @@ +// The MCP barrel includes Web globals. Use this source entry until it has a portable public export. +import { GatewayExecutionContextSchema } from '../../mcp-gateway/src/types'; +import { z } from 'zod'; +import { AGENT_HARNESS_PROTOCOL_VERSION } from './version'; + +const Id = z.uuid(); +const Name = z.string().min(1); +const Timestamp = z.iso.datetime(); +export const ProtocolVersionSchema = z.literal(AGENT_HARNESS_PROTOCOL_VERSION); +export const ContextSchema = GatewayExecutionContextSchema.readonly(); +export const PermissionModeSchema = z.enum(['ask', 'yolo']); +export const EventCursorSchema = z.int().nonnegative(); +// Keep the deployed opaque keyset cursor; it is not a durable event sequence. +export const HistoryCursorSchema = Name; +export const ErrorSchema = z.strictObject({ + code: z.enum([ + 'stale_revision', + 'command_conflict', + 'access_revoked', + 'retired', + 'storage_unavailable', + 'unsupported_protocol', + 'unavailable_tool', + 'reauthorization_required', + 'invalid_input', + 'invalid_output', + 'limit_exceeded', + 'cancelled', + 'outcome_unknown', + ]), + message: z.string(), + retryable: z.boolean(), +}); + +// Legacy stored settings omit mode/revision. Keep these defaults until old writers and records are gone. +export const ConversationSchema = z + .strictObject({ + id: Id, + ownerUserId: z.string(), + context: ContextSchema, + permissionMode: PermissionModeSchema.default('ask'), + permissionRevision: EventCursorSchema.default(0), + }) + .readonly(); +export const WaitingSchema = z.strictObject({ + toolCallId: Id, + reason: z.enum(['approval', 'question', 'client', 'reconciliation']), +}); +export const RunStateSchema = z.discriminatedUnion('status', [ + z.strictObject({ + status: z.literal(['queued', 'running', 'stopping', 'completed', 'cancelled']), + }), + z.strictObject({ status: z.literal('waiting'), waiting: WaitingSchema }), + z.strictObject({ status: z.literal('failed'), error: ErrorSchema }), +]); +export const RunSchema = z + .strictObject({ + id: Id, + conversationId: Id, + inputMessageId: Id, + originClientId: Id, + modelId: Name, + variant: Name.optional(), + state: RunStateSchema, + }) + .readonly(); + +// Readonly containers also protect nested validated arguments, not just the call's outer record. +type JsonValue = + | string + | number + | boolean + | null + | readonly JsonValue[] + | { readonly [key: string]: JsonValue }; +const JsonValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(JsonValueSchema).readonly(), + z.record(z.string(), JsonValueSchema).readonly(), + ]) +); +export const ToolOutcomeSchema = z.discriminatedUnion('status', [ + z.strictObject({ status: z.literal('succeeded'), output: JsonValueSchema }), + z.strictObject({ status: z.literal('failed'), error: ErrorSchema }), + z.strictObject({ status: z.literal(['denied', 'cancelled']) }), + z.strictObject({ + status: z.literal('outcome_unknown'), + reason: Name, + providerReference: Name.optional(), + }), +]); +export const ExecutionTargetSchema = z + .discriminatedUnion('kind', [ + z.strictObject({ kind: z.literal('backend') }), + z.strictObject({ kind: z.literal('client'), clientId: Id }), + z.strictObject({ kind: z.literal('interaction') }), + ]) + .readonly(); +export const ApprovalRecordSchema = z + .strictObject({ + interactionId: Id, + commandId: Id, + decision: z.enum(['approve', 'deny']), + }) + .readonly(); +export const ToolCallSchema = z + .strictObject({ + id: Id, + runId: Id, + name: Name, + definitionVersion: Name, + arguments: z.record(z.string(), JsonValueSchema).readonly(), + context: ContextSchema, + effect: z.enum(['read', 'side_effect', 'unknown']), + executionTarget: ExecutionTargetSchema, + approval: ApprovalRecordSchema.nullable(), + state: z.enum(['pending', 'waiting', 'executing', 'settled']), + result: ToolOutcomeSchema.nullable(), + }) + .refine(call => (call.state === 'settled') === (call.result !== null)) + .readonly(); +export const InteractionSchema = z + .discriminatedUnion('kind', [ + z.strictObject({ + id: Id, + kind: z.literal('approval'), + toolCall: ToolCallSchema, + resolution: ApprovalRecordSchema.nullable(), + }), + z.strictObject({ + id: Id, + kind: z.literal('question'), + toolCall: ToolCallSchema, + questionId: Name, + resolution: z + .discriminatedUnion('kind', [ + z.strictObject({ + kind: z.literal('answer'), + choiceIds: z.array(Name), + text: z.string().optional(), + }), + z.strictObject({ kind: z.literal('dismiss') }), + ]) + .nullable(), + }), + ]) + .readonly(); +export const ClientSchema = z + .strictObject({ + id: Id, + ownerUserId: z.string(), + kind: z.enum(['browser', 'mobile']), + supportedTools: z.array(z.strictObject({ name: Name, version: Name })), + revokedAt: Timestamp.nullable(), + }) + .readonly(); +export const ExecutionGrantSchema = z + .strictObject({ + id: Id, + conversationId: Id, + ownerUserId: z.string(), + clientId: Id, + toolCallId: Id, + context: ContextSchema, + definitionVersion: Name, + inputDigest: Name, + generation: EventCursorSchema, + expiresAt: Timestamp, + }) + .readonly(); +export const PendingClientActionSchema = z + .strictObject({ + toolCall: ToolCallSchema, + grant: ExecutionGrantSchema.nullable(), + reason: z.enum(['offline', 'background', 'locked', 'gesture', 'unavailable', 'reconciliation']), + }) + .refine(action => action.toolCall.executionTarget.kind === 'client'); + +export const MessagePartSchema = z.discriminatedUnion('type', [ + z.strictObject({ type: z.literal('text'), text: z.string() }), + // Snapshots, history pages, and message events must carry the record without a separate call lookup. + z.strictObject({ type: z.literal('tool_call'), toolCall: ToolCallSchema }), + z.strictObject({ + type: z.literal('citation'), + url: z.url({ protocol: /^https?$/ }), + title: Name, + }), +]); +const MessageFieldsSchema = z.object({ + id: Id, + role: z.enum(['user', 'assistant']), + content: z.string(), + clientId: z.string().nullable().default(null), + createdAt: Timestamp, +}); +// Old append rows are text only, even with attached authority fields. Remove only after old clients/records are gone. +export const LegacyMessageSchema = MessageFieldsSchema.transform(message => ({ + ...message, + provenance: 'legacy' as const, + parts: [{ type: 'text' as const, text: message.content }], +})); +// Parsing is not authentication. Only trusted producers can supply harness provenance. +// Old producers omit provenance/parts; consumers always get both. Retain until old clients/records are gone. +export const MessageSchema = z.union([ + MessageFieldsSchema.extend({ + provenance: z.literal('harness'), + protocolVersion: ProtocolVersionSchema, + runId: Id, + parts: z.array(MessagePartSchema).optional(), + incomplete: z.boolean().default(false), + }) + .strict() + .transform(message => ({ + ...message, + parts: message.parts ?? [{ type: 'text' as const, text: message.content }], + })), + MessageFieldsSchema.extend({ + provenance: z.literal('legacy').optional(), + protocolVersion: ProtocolVersionSchema.optional(), + // Old producers can attach ID-only parts. Discard them until old clients and records are gone. + parts: z.unknown().optional(), + }).transform(message => LegacyMessageSchema.parse(message)), +]); +export const SnapshotSchema = z.strictObject({ + protocolVersion: ProtocolVersionSchema, + conversation: ConversationSchema, + recentMessages: z.array(MessageSchema), + historyCursor: HistoryCursorSchema.nullable(), + activeRun: RunSchema.refine(run => + ['running', 'waiting', 'stopping'].includes(run.state.status) + ).nullable(), + queuedRuns: z.array(RunSchema.refine(run => run.state.status === 'queued')), + unresolvedInteractions: z.array( + InteractionSchema.refine(interaction => interaction.resolution === null) + ), + pendingClientActions: z.array(PendingClientActionSchema), + eventCursor: EventCursorSchema, +}); +export const EventEnvelopeSchema = z.strictObject({ + protocolVersion: ProtocolVersionSchema, + conversationId: Id, + sequence: EventCursorSchema.positive(), + event: z.discriminatedUnion('type', [ + z.strictObject({ type: z.literal('conversation'), conversation: ConversationSchema }), + z.strictObject({ type: z.literal('message'), message: MessageSchema }), + z.strictObject({ type: z.literal('run'), run: RunSchema }), + z.strictObject({ type: z.literal('interaction'), interaction: InteractionSchema }), + z.strictObject({ + type: z.literal('client_action'), + toolCallId: Id, + action: PendingClientActionSchema.nullable(), + }), + ]), +}); + +export type ConversationProducer = z.input; +export type Conversation = z.output; +export type MessageProducer = z.input; +export type Message = z.output; +export type Run = z.infer; +export type ToolCall = z.infer; +export type ToolOutcome = z.infer; +export type Interaction = z.infer; +export type Client = z.infer; +export type ExecutionGrant = z.infer; +export type Snapshot = z.infer; +export type EventEnvelope = z.infer;