From 89ea25c0ade9aca95fe7b30a1305f062c749d954 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 11 Jun 2026 13:42:18 -0400 Subject: [PATCH] refactor(sdk): derive messaging/delivery types from @relaycast/types Make the canonical wire contract in @relaycast/types the source of truth inside @agent-relay/sdk instead of maintaining a parallel hand-written type system: - messaging/types.ts: Relay* types now index into the canonical schemas (Agent, Channel, MessageWithMeta, InboxResponse, DeliveryStatus, event payloads, ...). Deliberate reshapes (camelCase, renames, optionals, enrichment) are kept but built on the canonical types, so wire-contract changes become compile errors instead of silent drift. Public type names and shapes are unchanged. - messaging/normalize.ts: replace per-field snake/camel probing (220 read calls, 119 of them probing multiple spellings) with a single key-folding boundary plus zod validation against canonical-derived schemas, then a deliberate typed map. Caller-defined payloads (metadata, blocks, data, value, input, output) pass through untouched. - delivery/types.ts: InjectionResult.status is the canonical DeliveryStatus; InboxItemState extends it with the relay-only queued/read states, and the delivery-status -> inbox-state map is exhaustively typed against the canonical enum. - drift fix: the canonical `message.reacted` WS event now maps to reactionAdded/reactionRemoved; previously only the non-canonical `reaction.added`/`reaction.removed` names were handled, so reaction listeners never fired against current Relaycast engines. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 + package-lock.json | 79 +- packages/sdk/package.json | 4 +- packages/sdk/src/delivery/types.ts | 5 +- packages/sdk/src/messaging/normalize.ts | 1052 +++++++++++------------ packages/sdk/src/messaging/relaycast.ts | 3 +- packages/sdk/src/messaging/types.ts | 251 +++--- 7 files changed, 708 insertions(+), 688 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 997d9c2f5..11c29d917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- `@agent-relay/sdk` messaging and delivery types now derive from the canonical `@relaycast/types` schemas: `Relay*` types index into the wire contract, `normalize.ts` validates payloads with canonical-derived zod schemas at the boundary instead of probing snake/camel field variants, and the inbox/delivery state unions (`InboxItemState`, `InjectionResult.status`) extend the canonical `DeliveryStatus` instead of redeclaring it — wire-contract changes now surface as compile errors instead of silent drift. - `codex-relay-skill` and `gemini-relay-extension` now default to `https://gateway.relaycast.dev`, matching the `agent-relay` CLI and SDK. Set `RELAY_BASE_URL` to keep using `https://api.relaycast.dev`. ### Fixed +- `@agent-relay/sdk` messaging events map the canonical `message.reacted` WebSocket event onto `reactionAdded`/`reactionRemoved`; previously only the non-canonical `reaction.added`/`reaction.removed` names were handled, so reaction listeners never fired against current Relaycast engines. - `agent-relay-broker` persists pending deliveries on shutdown and on every queue change, redelivers them on restart, reports timeout-fallback verification explicitly, and emits `delivery_dropped` when the per-worker queue cap evicts a message. ## [8.5.0] - 2026-06-11 diff --git a/package-lock.json b/package-lock.json index 3bd1975ec..f61188343 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agent-relay/monorepo", - "version": "8.4.0", + "version": "8.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agent-relay/monorepo", - "version": "8.4.0", + "version": "8.5.0", "license": "Apache-2.0", "workspaces": [ "packages/*", @@ -18703,43 +18703,43 @@ }, "packages/brand": { "name": "@agent-relay/brand", - "version": "8.4.0" + "version": "8.5.0" }, "packages/broker-darwin-arm64": { "name": "@agent-relay/broker-darwin-arm64", - "version": "8.4.0", + "version": "8.5.0", "license": "MIT" }, "packages/broker-darwin-x64": { "name": "@agent-relay/broker-darwin-x64", - "version": "8.4.0", + "version": "8.5.0", "license": "MIT" }, "packages/broker-linux-arm64": { "name": "@agent-relay/broker-linux-arm64", - "version": "8.4.0", + "version": "8.5.0", "license": "MIT" }, "packages/broker-linux-x64": { "name": "@agent-relay/broker-linux-x64", - "version": "8.4.0", + "version": "8.5.0", "license": "MIT" }, "packages/broker-win32-x64": { "name": "@agent-relay/broker-win32-x64", - "version": "8.4.0", + "version": "8.5.0", "license": "MIT" }, "packages/cli": { "name": "agent-relay", - "version": "8.4.0", + "version": "8.5.0", "license": "Apache-2.0", "dependencies": { - "@agent-relay/cloud": "8.4.0", - "@agent-relay/config": "8.4.0", - "@agent-relay/harness-driver": "8.4.0", - "@agent-relay/sdk": "8.4.0", - "@agent-relay/utils": "8.4.0", + "@agent-relay/cloud": "8.5.0", + "@agent-relay/config": "8.5.0", + "@agent-relay/harness-driver": "8.5.0", + "@agent-relay/sdk": "8.5.0", + "@agent-relay/utils": "8.5.0", "@modelcontextprotocol/sdk": "^1.0.0", "@relaycast/sdk": "^3.1.1", "@relayflows/cli": "^1.0.1", @@ -18762,9 +18762,9 @@ }, "packages/cloud": { "name": "@agent-relay/cloud", - "version": "8.4.0", + "version": "8.5.0", "dependencies": { - "@agent-relay/config": "8.4.0", + "@agent-relay/config": "8.5.0", "@aws-sdk/client-s3": "3.1020.0", "ignore": "^7.0.5", "tar": "^7.5.10" @@ -18780,7 +18780,7 @@ }, "packages/config": { "name": "@agent-relay/config", - "version": "8.4.0", + "version": "8.5.0", "dependencies": { "zod": "^3.23.8", "zod-to-json-schema": "^3.23.1" @@ -18793,35 +18793,35 @@ }, "packages/harness-driver": { "name": "@agent-relay/harness-driver", - "version": "8.4.0", + "version": "8.5.0", "license": "Apache-2.0", "dependencies": { - "@agent-relay/sdk": "8.4.0", + "@agent-relay/sdk": "8.5.0", "ws": "^8.18.3", "zod": "^3.23.8" }, "optionalDependencies": { - "@agent-relay/broker-darwin-arm64": "8.4.0", - "@agent-relay/broker-darwin-x64": "8.4.0", - "@agent-relay/broker-linux-arm64": "8.4.0", - "@agent-relay/broker-linux-x64": "8.4.0", - "@agent-relay/broker-win32-x64": "8.4.0" + "@agent-relay/broker-darwin-arm64": "8.5.0", + "@agent-relay/broker-darwin-x64": "8.5.0", + "@agent-relay/broker-linux-arm64": "8.5.0", + "@agent-relay/broker-linux-x64": "8.5.0", + "@agent-relay/broker-win32-x64": "8.5.0" } }, "packages/harnesses": { "name": "@agent-relay/harnesses", - "version": "8.4.0", + "version": "8.5.0", "license": "Apache-2.0", "dependencies": { - "@agent-relay/harness-driver": "8.4.0", - "@agent-relay/sdk": "8.4.0" + "@agent-relay/harness-driver": "8.5.0", + "@agent-relay/sdk": "8.5.0" } }, "packages/policy": { "name": "@agent-relay/policy", - "version": "8.4.0", + "version": "8.5.0", "dependencies": { - "@agent-relay/config": "8.4.0" + "@agent-relay/config": "8.5.0" }, "devDependencies": { "@types/node": "^22.19.3", @@ -18830,24 +18830,35 @@ }, "packages/sdk": { "name": "@agent-relay/sdk", - "version": "8.4.0", + "version": "8.5.0", "dependencies": { - "@relaycast/sdk": "^3.1.1" + "@relaycast/sdk": "^3.1.1", + "@relaycast/types": "^3.1.1", + "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^22.13.10" } }, + "packages/sdk/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "packages/telemetry": { "name": "@agent-relay/telemetry", - "version": "8.4.0", + "version": "8.5.0", "deprecated": "@agent-relay/telemetry is deprecated. Telemetry is now internal to the agent-relay CLI." }, "packages/utils": { "name": "@agent-relay/utils", - "version": "8.4.0", + "version": "8.5.0", "dependencies": { - "@agent-relay/config": "8.4.0", + "@agent-relay/config": "8.5.0", "compare-versions": "^6.1.1" }, "devDependencies": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 1d01fba7f..3476536ea 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -59,6 +59,8 @@ "@types/node": "^22.13.10" }, "dependencies": { - "@relaycast/sdk": "^3.1.1" + "@relaycast/sdk": "^3.1.1", + "@relaycast/types": "^3.1.1", + "zod": "^4.3.6" } } diff --git a/packages/sdk/src/delivery/types.ts b/packages/sdk/src/delivery/types.ts index 56d1f269c..ddec42bac 100644 --- a/packages/sdk/src/delivery/types.ts +++ b/packages/sdk/src/delivery/types.ts @@ -1,3 +1,5 @@ +import type { DeliveryStatus } from '@relaycast/types'; + import type { RelayMessage } from '../messaging/index.js'; import type { Unsubscribe } from '../capabilities.js'; import type { @@ -27,7 +29,8 @@ export interface InjectionContext { } export interface InjectionResult { - status: 'accepted' | 'delivered' | 'deferred' | 'failed'; + /** Canonical durable-delivery status reported by the adapter. */ + status: DeliveryStatus; injectionId?: string; availableAt?: string; reason?: string; diff --git a/packages/sdk/src/messaging/normalize.ts b/packages/sdk/src/messaging/normalize.ts index 90b1c5f08..1de745185 100644 --- a/packages/sdk/src/messaging/normalize.ts +++ b/packages/sdk/src/messaging/normalize.ts @@ -1,3 +1,28 @@ +import { + AgentPresenceInfoSchema, + AgentSchema, + AgentStatusSchema, + AgentTypeSchema, + ChannelMemberInfoSchema, + ChannelReadStatusSchema, + ChannelSchema, + CreateAgentResponseSchema, + CreateGroupDmResponseSchema, + DeliveryMessageSchema, + DeliverySchema, + DeliveryStatusSchema, + InboxResponseSchema, + MessageInjectionModeSchema, + MessageSchema, + MessageWithMetaSchema, + ReactionGroupSchema, + ReadReceiptSchema, + ReaderInfoSchema, + SearchMessageResultSchema, + type DeliveryStatus, +} from '@relaycast/types'; +import { z } from 'zod'; + import type { InboxItem, InboxItemState, @@ -5,11 +30,8 @@ import type { RelayAgentChannel, RelayAgentPresence, RelayAgentRegistration, - RelayAgentStatus, - RelayAgentType, RelayChannel, RelayChannelMember, - RelayChannelMemberRole, RelayChannelReadStatus, RelayDeliverySupportedResult, RelayGroupDirectConversation, @@ -20,9 +42,10 @@ import type { RelayMessage, RelayMessageAttachment, RelayMessageBlock, + RelayMessageChannelRef, RelayMessageKind, - RelayMessageMode, RelayMessageReaction, + RelayMessageSender, RelayMessagingEvent, RelayReadReceipt, RelaySearchResult, @@ -35,50 +58,78 @@ function isRecord(value: unknown): value is UnknownRecord { return value !== null && typeof value === 'object' && !Array.isArray(value); } -function readValue(record: UnknownRecord | undefined, ...keys: string[]): unknown { - if (!record) return undefined; - for (const key of keys) { - if (Object.hasOwn(record, key)) return record[key]; +// ── Wire boundary ──────────────────────────────────────────────────────────── +// +// `@relaycast/sdk` camelizes wire responses at runtime, while the canonical +// contract in `@relaycast/types` (and injected raw clients) is snake_case. +// Instead of probing both spellings field by field, every payload is folded +// back onto the canonical snake_case wire shape once, then validated with a +// schema derived from `@relaycast/types` and mapped deliberately. + +/** Keys whose values are caller-defined payloads that must pass through untouched. */ +const PASSTHROUGH_KEYS = new Set([ + 'metadata', + 'blocks', + 'data', + 'value', + 'input', + 'output', + 'parameters', + 'headers', +]); + +function toSnakeKey(key: string): string { + return key.replace(/[A-Z]/g, (char) => `_${char.toLowerCase()}`); +} + +function toWire(value: unknown): unknown { + if (Array.isArray(value)) return value.map(toWire); + if (!isRecord(value)) return value; + const out: UnknownRecord = {}; + for (const [key, val] of Object.entries(value)) { + const snake = toSnakeKey(key); + out[snake] = PASSTHROUGH_KEYS.has(snake) ? val : toWire(val); } - return undefined; + return out; } -function readString(record: UnknownRecord | undefined, ...keys: string[]): string | undefined { - const value = readValue(record, ...keys); - if (typeof value === 'string') return value; - if (typeof value === 'number' || typeof value === 'bigint') return String(value); - return undefined; +/** Snake-case a payload's keys and validate it against a canonical-derived schema. */ +function parseWire(schema: T, input: unknown): z.output { + const wired = toWire(input); + return schema.parse(isRecord(wired) ? wired : {}) as z.output; } -function readNullableString(record: UnknownRecord | undefined, ...keys: string[]): string | undefined { - const value = readValue(record, ...keys); - return typeof value === 'string' ? value : undefined; +/** Drop undefined values so optional fields stay absent instead of present-but-undefined. */ +function compact(value: { [K in keyof T]: T[K] | undefined }): T { + return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as T; } -function readBoolean(record: UnknownRecord | undefined, ...keys: string[]): boolean | undefined { - const value = readValue(record, ...keys); - return typeof value === 'boolean' ? value : undefined; +/** Collapse empty/null strings to undefined (absent). */ +function opt(value: string | null | undefined): string | undefined { + return value ? value : undefined; +} + +function str(record: UnknownRecord, key: string): string | undefined { + const value = record[key]; + return typeof value === 'string' ? value : undefined; } -function readNumber(record: UnknownRecord | undefined, ...keys: string[]): number | undefined { - const value = readValue(record, ...keys); +function num(record: UnknownRecord, key: string): number | undefined { + const value = record[key]; return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } -function readArray(record: UnknownRecord | undefined, ...keys: string[]): unknown[] { - const value = readValue(record, ...keys); - return Array.isArray(value) ? value : []; +function bool(record: UnknownRecord, key: string): boolean | undefined { + const value = record[key]; + return typeof value === 'boolean' ? value : undefined; } -function readRecord( - record: UnknownRecord | undefined, - ...keys: string[] -): Record | undefined { - const value = readValue(record, ...keys); - return isRecord(value) ? { ...value } : undefined; +function rec(record: UnknownRecord, key: string): UnknownRecord { + const value = record[key]; + return isRecord(value) ? value : {}; } -function normalizeOptionalChannelName(value: string | undefined): string | undefined { +function normalizeOptionalChannelName(value: string | undefined | null): string | undefined { if (!value) return undefined; return value.startsWith('#') ? value.slice(1) : value; } @@ -87,212 +138,257 @@ export function normalizeChannelName(value: string): string { return normalizeOptionalChannelName(value) ?? value; } -function normalizeAgentType(value: string | undefined): RelayAgentType { - return value === 'human' || value === 'system' ? value : 'agent'; -} - -function normalizeAgentStatus(value: string | undefined): RelayAgentStatus { - if (value === 'online' || value === 'offline' || value === 'away') return value; - return 'unknown'; -} - -function normalizeOnlineStatus(value: string | undefined): Extract { - return value === 'online' ? 'online' : 'offline'; -} - -function normalizeRole(value: string | undefined): RelayChannelMemberRole { - return value === 'owner' ? 'owner' : 'member'; -} - -function normalizeMode(value: string | undefined): RelayMessageMode | undefined { - return value === 'wait' || value === 'steer' ? value : undefined; -} - function normalizeBlocks(value: unknown): RelayMessageBlock[] { return Array.isArray(value) ? value.filter(isRecord).map((block) => ({ ...block })) : []; } -function normalizeStringArray(value: unknown): string[] { - return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; -} +// ── Boundary schemas ───────────────────────────────────────────────────────── +// +// Canonical wire schemas made tolerant of partial rows (older engines and +// trimmed payloads omit fields); every field that is present is validated +// against the canonical contract, and enum fields degrade to the relay +// fallback instead of failing the whole payload. + +const WireAgentChannelSchema = AgentSchema.shape.channels + .unwrap() + .element.partial() + .extend({ role: ChannelMemberInfoSchema.shape.role.optional().catch(undefined) }); + +const WireAgentSchema = AgentSchema.omit({ channels: true }) + .partial() + .extend({ + type: AgentTypeSchema.optional().catch(undefined), + status: AgentStatusSchema.optional().catch(undefined), + metadata: AgentSchema.shape.metadata.nullable().optional(), + channels: z.array(z.unknown()).optional(), + }); + +const WireRegistrationSchema = CreateAgentResponseSchema.partial().extend({ + status: AgentStatusSchema.optional().catch(undefined), +}); + +const WirePresenceSchema = AgentPresenceInfoSchema.partial().extend({ + status: AgentPresenceInfoSchema.shape.status.optional().catch(undefined), +}); + +const WireChannelMemberSchema = ChannelMemberInfoSchema.partial().extend({ + role: ChannelMemberInfoSchema.shape.role.optional().catch(undefined), +}); + +const WireChannelSchema = ChannelSchema.omit({ members: true }) + .partial() + .extend({ + metadata: ChannelSchema.shape.metadata.unwrap().nullable().optional(), + members: z.array(z.unknown()).optional(), + }); + +// Accepts the union of canonical message rows: `MessageWithMeta` (channel +// listings), `Message` (raw rows, where the text is `body`), the core message +// payload carried by WebSocket events, and the nullable-sender message +// embedded in delivery ledger rows. +const WireMessageSchema = MessageWithMetaSchema.omit({ + agent_id: true, + agent_name: true, + attachments: true, + blocks: true, + has_attachments: true, + injection_mode: true, + reactions: true, +}) + .partial() + .extend({ + agent_id: DeliveryMessageSchema.shape.agent_id.optional(), + agent_name: DeliveryMessageSchema.shape.agent_name.optional(), + body: MessageSchema.shape.body.optional(), + updated_at: MessageSchema.shape.updated_at.optional(), + channel_name: z.string().optional(), + conversation_id: z.string().optional(), + parent_id: z.string().optional(), + mode: MessageInjectionModeSchema.optional().catch(undefined), + injection_mode: MessageInjectionModeSchema.optional().catch(undefined), + metadata: MessageWithMetaSchema.shape.metadata.unwrap().nullable().optional(), + // Legacy alias for `metadata` on older message rows. + data: z.record(z.string(), z.unknown()).nullable().optional(), + attachments: z.array(z.unknown()).optional(), + blocks: z.unknown().optional(), + reactions: z.array(z.unknown()).optional(), + }); + +const WireInboxChannelSchema = InboxResponseSchema.shape.unread_channels.element.partial(); + +const WireInboxReactionSchema = InboxResponseSchema.shape.recent_reactions.element.partial(); + +const WireInboxDmSchema = InboxResponseSchema.shape.unread_dms.element + .omit({ last_message: true }) + .partial() + .extend({ last_message: z.unknown().optional() }); + +const WireInboxLastMessageSchema = InboxResponseSchema.shape.unread_dms.element.shape.last_message + .unwrap() + .partial(); + +const WireInboxSchema = z.object({ + unread_channels: z.array(WireInboxChannelSchema).optional(), + mentions: z.array(z.unknown()).optional(), + unread_dms: z.array(z.unknown()).optional(), + recent_reactions: z.array(z.unknown()).optional(), +}); + +const WireReadReceiptSchema = ReadReceiptSchema.extend(ReaderInfoSchema.shape).partial(); + +const WireChannelReadStatusSchema = ChannelReadStatusSchema.partial(); + +const WireSearchResultSchema = SearchMessageResultSchema.partial(); + +// A delivery ledger row, optionally carrying its embedded message payload +// (`DeliveryItem`) — transitions return the bare row. +const WireDeliverySchema = DeliverySchema.partial().extend({ + status: DeliveryStatusSchema.optional().catch(undefined), + message: DeliveryMessageSchema.partial().nullable().optional(), +}); + +const WireGroupDmSchema = CreateGroupDmResponseSchema.omit({ dm_type: true, participants: true }) + .partial() + .extend({ + conversation_id: z.string().optional(), + participants: z.array(z.unknown()).optional(), + }); + +// ── Normalizers ────────────────────────────────────────────────────────────── export function normalizeAgentChannel(input: unknown): RelayAgentChannel { - const record = isRecord(input) ? input : {}; - const name = normalizeOptionalChannelName(readString(record, 'name', 'channelName', 'channel_name')) ?? ''; - return { - id: readString(record, 'id', 'channelId', 'channel_id') ?? name, + const channel = parseWire(WireAgentChannelSchema, input); + const name = normalizeOptionalChannelName(channel.name) ?? ''; + return compact({ + id: channel.id ?? name, name, - role: normalizeRole(readString(record, 'role')), - ...(readString(record, 'joinedAt', 'joined_at') - ? { joinedAt: readString(record, 'joinedAt', 'joined_at') } - : {}), - }; + role: channel.role ?? 'member', + joinedAt: opt(channel.joined_at), + }); } export function normalizeAgent(input: unknown): RelayAgent { - const record = isRecord(input) ? input : {}; - const id = - readString(record, 'id', 'agentId', 'agent_id') ?? - readString(record, 'name', 'agentName', 'agent_name') ?? - ''; - const name = readString(record, 'name', 'agentName', 'agent_name') ?? id; - const lastSeenAt = readString(record, 'lastSeenAt', 'last_seen', 'lastSeen'); - const createdAt = readString(record, 'createdAt', 'created_at'); - const persona = readNullableString(record, 'persona'); - - return { + const agent = parseWire(WireAgentSchema, input); + const id = agent.id ?? agent.name ?? ''; + return compact({ id, - name, - type: normalizeAgentType(readString(record, 'type')), - status: normalizeAgentStatus(readString(record, 'status')), - ...(persona ? { persona } : {}), - metadata: readRecord(record, 'metadata') ?? {}, - ...(lastSeenAt ? { lastSeenAt } : {}), - ...(createdAt ? { createdAt } : {}), - channels: readArray(record, 'channels').map(normalizeAgentChannel), - }; + name: agent.name ?? id, + type: agent.type ?? 'agent', + status: agent.status ?? 'unknown', + persona: opt(agent.persona), + metadata: agent.metadata ?? {}, + lastSeenAt: opt(agent.last_seen), + createdAt: opt(agent.created_at), + channels: (agent.channels ?? []).map(normalizeAgentChannel), + }); } export function normalizeAgentRegistration(input: unknown): RelayAgentRegistration { - const record = isRecord(input) ? input : {}; - const id = readString(record, 'id', 'agentId', 'agent_id') ?? readString(record, 'name') ?? ''; - const name = readString(record, 'name', 'agentName', 'agent_name') ?? id; - const createdAt = readString(record, 'createdAt', 'created_at'); - return { + const registration = parseWire(WireRegistrationSchema, input); + const id = registration.id ?? registration.name ?? ''; + return compact({ id, - name, - token: readString(record, 'token') ?? '', - status: normalizeAgentStatus(readString(record, 'status')), - ...(createdAt ? { createdAt } : {}), - }; + name: registration.name ?? id, + token: registration.token ?? '', + status: registration.status ?? 'unknown', + createdAt: opt(registration.created_at), + }); } export function normalizeAgentPresence(input: unknown): RelayAgentPresence { - const record = isRecord(input) ? input : {}; - const agentId = readString(record, 'agentId', 'agent_id', 'id') ?? ''; - const agentName = readString(record, 'agentName', 'agent_name', 'name') ?? agentId; + const presence = parseWire(WirePresenceSchema, input); + const agentId = presence.agent_id ?? ''; return { agentId, - agentName, - status: normalizeOnlineStatus(readString(record, 'status')), + agentName: presence.agent_name ?? agentId, + status: presence.status === 'online' ? 'online' : 'offline', }; } export function normalizeChannelMember(input: unknown): RelayChannelMember { - const record = isRecord(input) ? input : {}; - const agentId = readString(record, 'agentId', 'agent_id', 'id') ?? ''; - const agentName = readString(record, 'agentName', 'agent_name', 'name') ?? agentId; - return { + const member = parseWire(WireChannelMemberSchema, input); + const agentId = member.agent_id ?? ''; + return compact({ agentId, - agentName, - role: normalizeRole(readString(record, 'role')), - ...(readString(record, 'joinedAt', 'joined_at') - ? { joinedAt: readString(record, 'joinedAt', 'joined_at') } - : {}), - muted: readBoolean(record, 'muted', 'isMuted', 'is_muted') ?? false, - }; + agentName: member.agent_name ?? agentId, + role: member.role ?? 'member', + joinedAt: opt(member.joined_at), + muted: member.is_muted ?? false, + }); } export function normalizeChannel(input: unknown): RelayChannel { - const record = isRecord(input) ? input : {}; - const name = normalizeOptionalChannelName(readString(record, 'name', 'channelName', 'channel_name')) ?? ''; - const topic = readNullableString(record, 'topic'); - const createdBy = readString(record, 'createdBy', 'created_by'); - const createdAt = readString(record, 'createdAt', 'created_at'); - const memberCount = readNumber(record, 'memberCount', 'member_count'); - - return { - id: readString(record, 'id', 'channelId', 'channel_id') ?? name, + const channel = parseWire(WireChannelSchema, input); + const name = normalizeOptionalChannelName(channel.name) ?? ''; + return compact({ + id: channel.id ?? name, name, - ...(topic ? { topic } : {}), - metadata: readRecord(record, 'metadata') ?? {}, - ...(createdBy ? { createdBy } : {}), - ...(createdAt ? { createdAt } : {}), - archived: readBoolean(record, 'archived', 'isArchived', 'is_archived') ?? false, - ...(memberCount !== undefined ? { memberCount } : {}), - members: readArray(record, 'members').map(normalizeChannelMember), - }; + topic: opt(channel.topic), + metadata: channel.metadata ?? {}, + createdBy: opt(channel.created_by), + createdAt: opt(channel.created_at), + archived: channel.is_archived ?? false, + memberCount: channel.member_count, + members: (channel.members ?? []).map(normalizeChannelMember), + }); } export function normalizeAttachment(input: unknown): RelayMessageAttachment { - const record = isRecord(input) ? input : {}; - const type = readString(record, 'type'); - if (type === 'text') { - return { - type, - text: readString(record, 'text', 'content') ?? '', - ...(readString(record, 'label') ? { label: readString(record, 'label') } : {}), - }; - } - if (type === 'image') { - return { - type, - ...(readString(record, 'url') ? { url: readString(record, 'url') } : {}), - ...(readString(record, 'data') ? { data: readString(record, 'data') } : {}), - ...(readString(record, 'mimeType', 'mime_type') - ? { mimeType: readString(record, 'mimeType', 'mime_type') } - : {}), - ...(readString(record, 'alt') ? { alt: readString(record, 'alt') } : {}), - ...(readString(record, 'label') ? { label: readString(record, 'label') } : {}), - }; - } - if (type === 'link') { - return { - type, - url: readString(record, 'url') ?? '', - ...(readString(record, 'title') ? { title: readString(record, 'title') } : {}), - ...(readString(record, 'label') ? { label: readString(record, 'label') } : {}), - }; - } - if (type === 'file') { - return { - type, - path: readString(record, 'path') ?? readString(record, 'filename', 'name') ?? '', - ...(readNumber(record, 'line') !== undefined ? { line: readNumber(record, 'line') } : {}), - ...(readString(record, 'label') ? { label: readString(record, 'label') } : {}), - }; - } - if (type === 'json') { - return { - type, - value: readValue(record, 'value'), - ...(readString(record, 'label') ? { label: readString(record, 'label') } : {}), - }; - } - if (type === 'diff') { - return { - type, - patch: readString(record, 'patch', 'diff') ?? '', - ...(readString(record, 'label') ? { label: readString(record, 'label') } : {}), - }; - } - if (type === 'artifact') { - return { - type, - id: readString(record, 'id', 'artifactId', 'artifact_id') ?? '', - ...(readString(record, 'url') ? { url: readString(record, 'url') } : {}), - ...(readString(record, 'label') ? { label: readString(record, 'label') } : {}), - }; + const wired = toWire(input); + const record = isRecord(wired) ? wired : {}; + const type = str(record, 'type'); + const label = opt(str(record, 'label')); + switch (type) { + case 'text': + return compact({ type, text: str(record, 'text') ?? str(record, 'content') ?? '', label }); + case 'image': + return compact({ + type, + url: opt(str(record, 'url')), + data: opt(str(record, 'data')), + mimeType: opt(str(record, 'mime_type')), + alt: opt(str(record, 'alt')), + label, + }); + case 'link': + return compact({ type, url: str(record, 'url') ?? '', title: opt(str(record, 'title')), label }); + case 'file': + return compact({ + type, + path: str(record, 'path') ?? str(record, 'filename') ?? '', + line: num(record, 'line'), + label, + }); + case 'json': + return compact({ type, value: record.value as unknown, label }); + case 'diff': + return compact({ type, patch: str(record, 'patch') ?? str(record, 'diff') ?? '', label }); + case 'artifact': + return compact({ + type, + id: str(record, 'id') ?? str(record, 'artifact_id') ?? '', + url: opt(str(record, 'url')), + label, + }); + default: { + // Canonical stored file attachment (`FileAttachment`). + const filename = str(record, 'filename'); + return compact({ + id: str(record, 'id') ?? str(record, 'file_id') ?? filename ?? '', + filename: opt(filename), + contentType: opt(str(record, 'content_type')), + sizeBytes: num(record, 'size_bytes'), + }); + } } - - const filename = readString(record, 'filename', 'name'); - const contentType = readString(record, 'contentType', 'content_type'); - const sizeBytes = readNumber(record, 'sizeBytes', 'size_bytes'); - - return { - id: readString(record, 'id', 'fileId', 'file_id') ?? filename ?? '', - ...(filename ? { filename } : {}), - ...(contentType ? { contentType } : {}), - ...(sizeBytes !== undefined ? { sizeBytes } : {}), - }; } export function normalizeReaction(input: unknown): RelayMessageReaction { - const record = isRecord(input) ? input : {}; + const reaction = parseWire(ReactionGroupSchema.partial(), input); return { - emoji: readString(record, 'emoji') ?? '', - count: readNumber(record, 'count') ?? 0, - agents: normalizeStringArray(readValue(record, 'agents', 'agentNames', 'agent_names')), + emoji: reaction.emoji ?? '', + count: reaction.count ?? 0, + agents: reaction.agents ?? [], }; } @@ -307,184 +403,128 @@ interface MessageContext { } export function normalizeMessage(input: unknown, context: MessageContext = {}): RelayMessage { - const record = isRecord(input) ? input : {}; - const fromRecord = isRecord(readValue(record, 'from', 'sender', 'agent')) - ? (readValue(record, 'from', 'sender', 'agent') as UnknownRecord) - : undefined; - const channelName = - context.channelName ?? - normalizeOptionalChannelName(readString(record, 'channelName', 'channel_name', 'channel')); - const channelId = context.channelId ?? readString(record, 'channelId', 'channel_id'); - const conversationId = context.conversationId ?? readString(record, 'conversationId', 'conversation_id'); - const parentId = context.parentId ?? readString(record, 'parentId', 'parent_id'); - const threadId = context.threadId ?? readNullableString(record, 'threadId', 'thread_id'); - const createdAt = readString(record, 'createdAt', 'created_at') ?? context.createdAt; - const updatedAt = readNullableString(record, 'updatedAt', 'updated_at'); - const metadata = readRecord(record, 'metadata') ?? readRecord(record, 'data'); - const replyCount = readNumber(record, 'replyCount', 'reply_count'); - const readByCount = readNumber(record, 'readByCount', 'read_by_count'); + const message = parseWire(WireMessageSchema, input); + const channelName = context.channelName ?? normalizeOptionalChannelName(message.channel_name); + const channelId = context.channelId ?? message.channel_id; + const conversationId = context.conversationId ?? message.conversation_id; + const parentId = context.parentId ?? message.parent_id; const kind = context.kind ?? (parentId ? 'thread_reply' : conversationId ? 'dm' : channelId || channelName ? 'channel' : 'unknown'); + const id = message.id ?? ''; - const id = readString(record, 'id', 'messageId', 'message_id') ?? ''; - - return { + return compact({ id, messageId: id, kind, - text: readString(record, 'text', 'body') ?? '', - from: { - ...((readString(record, 'agentId', 'agent_id', 'fromId', 'from_id') ?? - readString(fromRecord, 'id', 'agentId', 'agent_id')) - ? { - id: - readString(record, 'agentId', 'agent_id', 'fromId', 'from_id') ?? - readString(fromRecord, 'id', 'agentId', 'agent_id'), - } - : {}), - ...((readString(record, 'agentName', 'agent_name', 'fromName', 'from_name', 'from') ?? - readString(fromRecord, 'name', 'agentName', 'agent_name')) - ? { - name: - readString(record, 'agentName', 'agent_name', 'fromName', 'from_name', 'from') ?? - readString(fromRecord, 'name', 'agentName', 'agent_name'), - } - : {}), - }, - ...(channelId || channelName - ? { - channel: { - ...(channelId ? { id: channelId } : {}), - ...(channelName ? { name: channelName } : {}), - }, - } - : {}), - ...(conversationId ? { conversationId } : {}), - ...(threadId ? { threadId } : {}), - ...(parentId ? { parentId } : {}), - ...(normalizeMode(readString(record, 'mode', 'injectionMode', 'injection_mode')) - ? { - mode: normalizeMode(readString(record, 'mode', 'injectionMode', 'injection_mode')), - } - : {}), - ...(createdAt ? { createdAt } : {}), - ...(updatedAt ? { updatedAt } : {}), - ...(metadata ? { metadata } : {}), - blocks: normalizeBlocks(readValue(record, 'blocks')), - attachments: readArray(record, 'attachments').map(normalizeAttachment), - ...(replyCount !== undefined ? { replyCount } : {}), - reactions: readArray(record, 'reactions').map(normalizeReaction), - ...(readByCount !== undefined ? { readByCount } : {}), - mentions: normalizeStringArray(readValue(record, 'mentions')), - }; + text: message.text ?? message.body ?? '', + from: compact({ id: opt(message.agent_id), name: opt(message.agent_name) }), + channel: + channelId || channelName + ? compact({ id: opt(channelId), name: opt(channelName) }) + : undefined, + conversationId: opt(conversationId), + threadId: opt(context.threadId ?? message.thread_id), + parentId: opt(parentId), + mode: message.mode ?? message.injection_mode, + createdAt: opt(message.created_at ?? context.createdAt), + updatedAt: opt(message.updated_at), + metadata: message.metadata ?? message.data ?? undefined, + blocks: normalizeBlocks(message.blocks), + attachments: (message.attachments ?? []).map(normalizeAttachment), + replyCount: message.reply_count, + reactions: (message.reactions ?? []).map(normalizeReaction), + readByCount: message.read_by_count, + mentions: message.mentions ?? [], + }); } export function normalizeThread(input: unknown): RelayThread { const record = isRecord(input) ? input : {}; - const parent = normalizeMessage(readValue(record, 'parent'), { kind: 'channel' }); - const replies = readArray(record, 'replies').map((reply) => - normalizeMessage(reply, { - kind: 'thread_reply', - channelId: parent.channel?.id, - channelName: parent.channel?.name, - threadId: parent.threadId ?? parent.id, - parentId: parent.id, - }) - ); - return { parent, replies }; + const parent = normalizeMessage(record.parent, { kind: 'channel' }); + const replies = Array.isArray(record.replies) ? record.replies : []; + return { + parent, + replies: replies.map((reply) => + normalizeMessage(reply, { + kind: 'thread_reply', + channelId: parent.channel?.id, + channelName: parent.channel?.name, + threadId: parent.threadId ?? parent.id, + parentId: parent.id, + }) + ), + }; } function normalizeInboxLastMessage(input: unknown): RelayInboxLastMessage | undefined { - const record = isRecord(input) ? input : undefined; - if (!record) return undefined; - const createdAt = readString(record, 'createdAt', 'created_at'); - return { - id: readString(record, 'id', 'messageId', 'message_id') ?? '', - text: readString(record, 'text', 'body') ?? '', - ...(createdAt ? { createdAt } : {}), - }; + if (!isRecord(input)) return undefined; + const last = parseWire(WireInboxLastMessageSchema, input); + return compact({ + id: last.id ?? '', + text: last.text ?? '', + createdAt: opt(last.created_at), + }); } function normalizeInboxDirect(input: unknown): RelayInboxDirectSummary { - const record = isRecord(input) ? input : {}; - const lastMessage = normalizeInboxLastMessage(readValue(record, 'lastMessage', 'last_message')); - return { - conversationId: readString(record, 'conversationId', 'conversation_id') ?? '', - from: readString(record, 'from', 'agentName', 'agent_name') ?? '', - unreadCount: readNumber(record, 'unreadCount', 'unread_count') ?? 0, - ...(lastMessage ? { lastMessage } : {}), - }; + const dm = parseWire(WireInboxDmSchema, input); + return compact({ + conversationId: dm.conversation_id ?? '', + from: dm.from ?? '', + unreadCount: dm.unread_count ?? 0, + lastMessage: normalizeInboxLastMessage(dm.last_message), + }); } function normalizeInboxReaction(input: unknown): RelayInboxReactionSummary { - const record = isRecord(input) ? input : {}; - const createdAt = readString(record, 'createdAt', 'created_at'); - return { - messageId: readString(record, 'messageId', 'message_id') ?? '', - channelName: - normalizeOptionalChannelName(readString(record, 'channelName', 'channel_name', 'channel')) ?? '', - emoji: readString(record, 'emoji') ?? '', - agentName: readString(record, 'agentName', 'agent_name') ?? '', - ...(createdAt ? { createdAt } : {}), - }; + const reaction = parseWire(WireInboxReactionSchema, input); + return compact({ + messageId: reaction.message_id ?? '', + channelName: normalizeOptionalChannelName(reaction.channel_name) ?? '', + emoji: reaction.emoji ?? '', + agentName: reaction.agent_name ?? '', + createdAt: opt(reaction.created_at), + }); } export function normalizeInbox(input: unknown): RelayInbox { - const record = isRecord(input) ? input : {}; + const inbox = parseWire(WireInboxSchema, input); return { - unreadChannels: readArray(record, 'unreadChannels', 'unread_channels').map((item) => { - const itemRecord = isRecord(item) ? item : {}; - return { - channelName: - normalizeOptionalChannelName(readString(itemRecord, 'channelName', 'channel_name', 'channel')) ?? - '', - unreadCount: readNumber(itemRecord, 'unreadCount', 'unread_count') ?? 0, - }; - }), - mentions: readArray(record, 'mentions').map((item) => { - const itemRecord = isRecord(item) ? item : {}; - return normalizeMessage(itemRecord, { - kind: 'channel', - channelName: normalizeOptionalChannelName( - readString(itemRecord, 'channelName', 'channel_name', 'channel') - ), - }); - }), - unreadDms: readArray(record, 'unreadDms', 'unread_dms').map(normalizeInboxDirect), - recentReactions: readArray(record, 'recentReactions', 'recent_reactions').map(normalizeInboxReaction), + unreadChannels: (inbox.unread_channels ?? []).map((channel) => ({ + channelName: normalizeOptionalChannelName(channel.channel_name) ?? '', + unreadCount: channel.unread_count ?? 0, + })), + mentions: (inbox.mentions ?? []).map((mention) => normalizeMessage(mention, { kind: 'channel' })), + unreadDms: (inbox.unread_dms ?? []).map(normalizeInboxDirect), + recentReactions: (inbox.recent_reactions ?? []).map(normalizeInboxReaction), }; } export function normalizeReadReceipt(input: unknown): RelayReadReceipt { - const record = isRecord(input) ? input : {}; - const readAt = readString(record, 'readAt', 'read_at'); - return { - messageId: readString(record, 'messageId', 'message_id') ?? '', - ...(readString(record, 'agentId', 'agent_id') - ? { agentId: readString(record, 'agentId', 'agent_id') } - : {}), - ...(readString(record, 'agentName', 'agent_name') - ? { agentName: readString(record, 'agentName', 'agent_name') } - : {}), - ...(readAt ? { readAt } : {}), - }; + const receipt = parseWire(WireReadReceiptSchema, input); + return compact({ + messageId: receipt.message_id ?? '', + agentId: opt(receipt.agent_id), + agentName: opt(receipt.agent_name), + readAt: opt(receipt.read_at), + }); } export function normalizeChannelReadStatus(input: unknown): RelayChannelReadStatus { - const record = isRecord(input) ? input : {}; - const lastReadId = readNullableString(record, 'lastReadId', 'last_read_id'); - const lastReadAt = readNullableString(record, 'lastReadAt', 'last_read_at'); - return { - agentName: readString(record, 'agentName', 'agent_name') ?? '', - ...(lastReadId ? { lastReadId } : {}), - ...(lastReadAt ? { lastReadAt } : {}), - }; + const status = parseWire(WireChannelReadStatusSchema, input); + return compact({ + agentName: status.agent_name ?? '', + lastReadId: opt(status.last_read_id), + lastReadAt: opt(status.last_read_at), + }); } -// Relaycast durable delivery ledger statuses mapped onto relay inbox states. +// Canonical durable delivery statuses mapped onto relay inbox states: // `accepted` means queued for the recipient; the ledger has no `read` state. -const INBOX_STATE_BY_DELIVERY_STATUS: Record = { +// Typed against `DeliveryStatus` so a new canonical status fails to compile +// here until it is mapped. +const INBOX_STATE_BY_DELIVERY_STATUS: Record = { accepted: 'queued', delivered: 'delivered', deferred: 'deferred', @@ -492,23 +532,21 @@ const INBOX_STATE_BY_DELIVERY_STATUS: Record = { }; export function normalizeInboxItemState(value: string | undefined): InboxItemState { - return (value !== undefined ? INBOX_STATE_BY_DELIVERY_STATUS[value] : undefined) ?? 'queued'; -} - -function normalizeDeliveryMetadata(record: UnknownRecord): Record | undefined { - const metadata: Record = {}; - const mode = readString(record, 'mode'); - const reason = readNullableString(record, 'reason'); - const priority = readString(record, 'priority'); - const retryable = readBoolean(record, 'retryable'); - const error = readNullableString(record, 'error'); - const deadline = readNullableString(record, 'deadline'); - if (mode) metadata.mode = mode; - if (reason) metadata.reason = reason; - if (priority) metadata.priority = priority; - if (retryable !== undefined) metadata.retryable = retryable; - if (error) metadata.error = error; - if (deadline) metadata.deadline = deadline; + const status = DeliveryStatusSchema.safeParse(value); + return status.success ? INBOX_STATE_BY_DELIVERY_STATUS[status.data] : 'queued'; +} + +type WireDelivery = z.output; + +function normalizeDeliveryMetadata(delivery: WireDelivery): Record | undefined { + const metadata = compact>({ + mode: opt(delivery.mode), + reason: opt(delivery.reason), + priority: opt(delivery.priority), + retryable: delivery.retryable ?? undefined, + error: opt(delivery.error), + deadline: opt(delivery.deadline), + }); return Object.keys(metadata).length > 0 ? metadata : undefined; } @@ -517,32 +555,23 @@ function normalizeDeliveryMetadata(record: UnknownRecord): Record({ + id: delivery.id ?? '', + recipient: compact({ + name: context.recipientName ?? delivery.agent_id ?? '', + id: opt(delivery.agent_id), + }), + state: delivery.status ? INBOX_STATE_BY_DELIVERY_STATUS[delivery.status] : 'queued', // The relaycast ledger does not expose attempt counts. attempts: 0, - ...(availableAt ? { availableAt } : {}), + availableAt: opt(delivery.available_at), message: normalizeMessage( - messageRecord ?? { - ...(messageId ? { id: messageId } : {}), - ...(channelId ? { channel_id: channelId } : {}), - } + delivery.message ?? + compact({ id: opt(delivery.message_id), channel_id: opt(delivery.channel_id) }) ), - ...(metadata ? { metadata } : {}), - }; + metadata: normalizeDeliveryMetadata(delivery), + }); } /** @@ -553,83 +582,78 @@ export function normalizeDeliveryTransition( action: RelayDeliverySupportedResult['action'], input: unknown ): RelayDeliverySupportedResult { - const record = isRecord(input) ? input : {}; - const availableAt = readNullableString(record, 'availableAt', 'available_at'); - return { + const delivery = parseWire(WireDeliverySchema, input); + return compact({ supported: true, action, - deliveryId: readString(record, 'id', 'deliveryId', 'delivery_id') ?? '', - messageId: readString(record, 'messageId', 'message_id') ?? '', - state: normalizeInboxItemState(readString(record, 'status')), - ...(action === 'defer' && availableAt ? { deferUntil: availableAt } : {}), - }; + deliveryId: delivery.id ?? '', + messageId: delivery.message_id ?? '', + state: delivery.status ? INBOX_STATE_BY_DELIVERY_STATUS[delivery.status] : 'queued', + deferUntil: action === 'defer' ? opt(delivery.available_at) : undefined, + }); } export function normalizeSearchResult(input: unknown): RelaySearchResult { - const record = isRecord(input) ? input : {}; - const createdAt = readString(record, 'createdAt', 'created_at'); - return { - id: readString(record, 'id', 'messageId', 'message_id') ?? '', - channelName: - normalizeOptionalChannelName(readString(record, 'channelName', 'channel_name', 'channel')) ?? '', - agentName: readString(record, 'agentName', 'agent_name') ?? '', - text: readString(record, 'text', 'body') ?? '', - ...(createdAt ? { createdAt } : {}), - relevanceScore: readNumber(record, 'relevanceScore', 'relevance_score') ?? 0, - }; + const result = parseWire(WireSearchResultSchema, input); + return compact({ + id: result.id ?? '', + channelName: normalizeOptionalChannelName(result.channel_name) ?? '', + agentName: result.agent_name ?? '', + text: result.text ?? '', + createdAt: opt(result.created_at), + relevanceScore: result.relevance_score ?? 0, + }); } export function normalizeGroupDirectConversation(input: unknown): RelayGroupDirectConversation { - const record = isRecord(input) ? input : {}; - const name = readNullableString(record, 'name'); - const createdAt = readString(record, 'createdAt', 'created_at'); - return { - id: readString(record, 'id', 'conversationId', 'conversation_id') ?? '', - ...(readString(record, 'channelId', 'channel_id') - ? { channelId: readString(record, 'channelId', 'channel_id') } - : {}), - ...(name ? { name } : {}), - participants: readArray(record, 'participants') - .map((participant) => { - const participantRecord = isRecord(participant) ? participant : undefined; - return typeof participant === 'string' + const conversation = parseWire(WireGroupDmSchema, input); + return compact({ + id: conversation.id ?? conversation.conversation_id ?? '', + channelId: opt(conversation.channel_id), + name: opt(conversation.name), + participants: (conversation.participants ?? []) + .map((participant) => + typeof participant === 'string' ? participant - : readString(participantRecord, 'agentName', 'agent_name', 'agentId', 'agent_id'); - }) + : isRecord(participant) + ? (str(participant, 'agent_name') ?? str(participant, 'agent_id')) + : undefined + ) .filter((participant): participant is string => typeof participant === 'string'), - ...(createdAt ? { createdAt } : {}), - }; + createdAt: opt(conversation.created_at), + }); } +const MEMBERSHIP_EVENT_TYPES = { + 'member.joined': 'memberJoined', + 'member.left': 'memberLeft', + 'member.channel_muted': 'channelMuted', + 'member.channel_unmuted': 'channelUnmuted', +} as const; + export function normalizeMessagingEvent(input: unknown): RelayMessagingEvent { - const record = isRecord(input) ? input : {}; - const sourceType = readString(record, 'type'); + const wired = toWire(input); + const record = isRecord(wired) ? wired : {}; + const sourceType = str(record, 'type'); switch (sourceType) { - case 'message.created': { - const channel = normalizeOptionalChannelName(readString(record, 'channel')) ?? ''; - return { - type: 'messageCreated', - channel, - message: normalizeMessage(readValue(record, 'message'), { kind: 'channel', channelName: channel }), - }; - } + case 'message.created': case 'message.updated': { - const channel = normalizeOptionalChannelName(readString(record, 'channel')) ?? ''; + const channel = normalizeOptionalChannelName(str(record, 'channel')) ?? ''; return { - type: 'messageUpdated', + type: sourceType === 'message.created' ? 'messageCreated' : 'messageUpdated', channel, - message: normalizeMessage(readValue(record, 'message'), { kind: 'channel', channelName: channel }), + message: normalizeMessage(record.message, { kind: 'channel', channelName: channel }), }; } case 'thread.reply': { - const channel = normalizeOptionalChannelName(readString(record, 'channel')) ?? ''; - const parentId = readString(record, 'parentId', 'parent_id') ?? ''; + const channel = normalizeOptionalChannelName(str(record, 'channel')) ?? ''; + const parentId = str(record, 'parent_id') ?? ''; return { type: 'threadReply', channel, parentId, - message: normalizeMessage(readValue(record, 'message'), { + message: normalizeMessage(record.message, { kind: 'thread_reply', channelName: channel, parentId, @@ -637,139 +661,97 @@ export function normalizeMessagingEvent(input: unknown): RelayMessagingEvent { }), }; } - case 'dm.received': { - const conversationId = readString(record, 'conversationId', 'conversation_id') ?? ''; - return { - type: 'dmReceived', - conversationId, - message: normalizeMessage(readValue(record, 'message'), { kind: 'dm', conversationId }), - }; - } + case 'dm.received': case 'group_dm.received': { - const conversationId = readString(record, 'conversationId', 'conversation_id') ?? ''; + const conversationId = str(record, 'conversation_id') ?? ''; + const kind = sourceType === 'dm.received' ? 'dm' : 'group_dm'; return { - type: 'groupDmReceived', + type: kind === 'dm' ? 'dmReceived' : 'groupDmReceived', conversationId, - message: normalizeMessage(readValue(record, 'message'), { kind: 'group_dm', conversationId }), + message: normalizeMessage(record.message, { kind, conversationId }), }; } + // Legacy presence events emitted by older engines; current engines emit + // `agent.status.*` (surfaced as `unknown` events). case 'agent.online': - return { - type: 'agentOnline', - agent: { - name: - readString( - isRecord(readValue(record, 'agent')) - ? (readValue(record, 'agent') as UnknownRecord) - : undefined, - 'name' - ) ?? '', - }, - }; case 'agent.offline': return { - type: 'agentOffline', - agent: { - name: - readString( - isRecord(readValue(record, 'agent')) - ? (readValue(record, 'agent') as UnknownRecord) - : undefined, - 'name' - ) ?? '', - }, + type: sourceType === 'agent.online' ? 'agentOnline' : 'agentOffline', + agent: { name: str(rec(record, 'agent'), 'name') ?? '' }, }; case 'agent.spawn_requested': { - const agent = isRecord(readValue(record, 'agent')) ? (readValue(record, 'agent') as UnknownRecord) : {}; + const agent = rec(record, 'agent'); return { type: 'agentSpawnRequested', - agent: { - name: readString(agent, 'name') ?? '', - ...(readString(agent, 'cli') ? { cli: readString(agent, 'cli') } : {}), - ...(readString(agent, 'task') ? { task: readString(agent, 'task') } : {}), - ...(normalizeOptionalChannelName(readNullableString(agent, 'channel') ?? undefined) - ? { channel: normalizeOptionalChannelName(readNullableString(agent, 'channel') ?? undefined) } - : {}), - ...(readString(agent, 'model') ? { model: readString(agent, 'model') } : {}), - alreadyExisted: readBoolean(agent, 'alreadyExisted', 'already_existed') ?? false, - }, - }; - } - case 'agent.release_requested': { - const agent = isRecord(readValue(record, 'agent')) ? (readValue(record, 'agent') as UnknownRecord) : {}; - const reason = readNullableString(record, 'reason'); - return { - type: 'agentReleaseRequested', - agent: { name: readString(agent, 'name') ?? '' }, - ...(reason ? { reason } : {}), - deleted: readBoolean(record, 'deleted') ?? false, + agent: compact({ + name: str(agent, 'name') ?? '', + cli: opt(str(agent, 'cli')), + task: opt(str(agent, 'task')), + channel: normalizeOptionalChannelName(str(agent, 'channel')), + model: opt(str(agent, 'model')), + alreadyExisted: bool(agent, 'already_existed') ?? false, + }), }; } + case 'agent.release_requested': + return compact({ + type: 'agentReleaseRequested' as const, + agent: { name: str(rec(record, 'agent'), 'name') ?? '' }, + reason: opt(str(record, 'reason')), + deleted: bool(record, 'deleted') ?? false, + }); case 'channel.created': case 'channel.updated': { - const channel = isRecord(readValue(record, 'channel')) - ? (readValue(record, 'channel') as UnknownRecord) - : {}; - const topic = readNullableString(channel, 'topic'); + const channel = rec(record, 'channel'); return { type: sourceType === 'channel.created' ? 'channelCreated' : 'channelUpdated', - channel: { - name: normalizeOptionalChannelName(readString(channel, 'name')) ?? '', - ...(topic ? { topic } : {}), - }, + channel: compact({ + name: normalizeOptionalChannelName(str(channel, 'name')) ?? '', + topic: opt(str(channel, 'topic')), + }), }; } - case 'channel.archived': { - const channel = isRecord(readValue(record, 'channel')) - ? (readValue(record, 'channel') as UnknownRecord) - : {}; + case 'channel.archived': return { type: 'channelArchived', - channel: { name: normalizeOptionalChannelName(readString(channel, 'name')) ?? '' }, + channel: { name: normalizeOptionalChannelName(str(rec(record, 'channel'), 'name')) ?? '' }, }; - } case 'member.joined': case 'member.left': case 'member.channel_muted': - case 'member.channel_unmuted': { - const type = - sourceType === 'member.joined' - ? 'memberJoined' - : sourceType === 'member.left' - ? 'memberLeft' - : sourceType === 'member.channel_muted' - ? 'channelMuted' - : 'channelUnmuted'; + case 'member.channel_unmuted': return { - type, - channel: normalizeOptionalChannelName(readString(record, 'channel')) ?? '', - agentName: readString(record, 'agentName', 'agent_name') ?? '', + type: MEMBERSHIP_EVENT_TYPES[sourceType], + channel: normalizeOptionalChannelName(str(record, 'channel')) ?? '', + agentName: str(record, 'agent_name') ?? '', }; - } case 'message.read': - return { - type: 'messageRead', - messageId: readString(record, 'messageId', 'message_id') ?? '', - agentName: readString(record, 'agentName', 'agent_name') ?? '', - ...(readString(record, 'readAt', 'read_at') - ? { readAt: readString(record, 'readAt', 'read_at') } - : {}), - }; + return compact({ + type: 'messageRead' as const, + messageId: str(record, 'message_id') ?? '', + agentName: str(record, 'agent_name') ?? '', + readAt: opt(str(record, 'read_at')), + }); + // Canonical reaction event plus the legacy split pair. + case 'message.reacted': case 'reaction.added': case 'reaction.removed': return { - type: sourceType === 'reaction.added' ? 'reactionAdded' : 'reactionRemoved', - messageId: readString(record, 'messageId', 'message_id') ?? '', - emoji: readString(record, 'emoji') ?? '', - agentName: readString(record, 'agentName', 'agent_name') ?? '', + type: + sourceType === 'reaction.removed' || str(record, 'action') === 'removed' + ? 'reactionRemoved' + : 'reactionAdded', + messageId: str(record, 'message_id') ?? '', + emoji: str(record, 'emoji') ?? '', + agentName: str(record, 'agent_name') ?? '', }; case 'action.invoked': return { type: 'actionInvoked', - invocationId: readString(record, 'invocationId', 'invocation_id') ?? '', - actionName: readString(record, 'actionName', 'action_name') ?? '', - callerName: readString(record, 'callerName', 'caller_name') ?? '', - handlerAgentId: readString(record, 'handlerAgentId', 'handler_agent_id') ?? '', + invocationId: str(record, 'invocation_id') ?? '', + actionName: str(record, 'action_name') ?? '', + callerName: str(record, 'caller_name') ?? '', + handlerAgentId: str(record, 'handler_agent_id') ?? '', }; case 'open': return { type: 'connected' }; @@ -778,9 +760,9 @@ export function normalizeMessagingEvent(input: unknown): RelayMessagingEvent { case 'error': return { type: 'error' }; case 'reconnecting': - return { type: 'reconnecting', attempt: readNumber(record, 'attempt') ?? 0 }; + return { type: 'reconnecting', attempt: num(record, 'attempt') ?? 0 }; case 'permanently_disconnected': - return { type: 'permanentlyDisconnected', attempt: readNumber(record, 'attempt') ?? 0 }; + return { type: 'permanentlyDisconnected', attempt: num(record, 'attempt') ?? 0 }; default: return { type: 'unknown', ...(sourceType ? { sourceType } : {}), raw: input }; } diff --git a/packages/sdk/src/messaging/relaycast.ts b/packages/sdk/src/messaging/relaycast.ts index 714ccaeb8..5505d3029 100644 --- a/packages/sdk/src/messaging/relaycast.ts +++ b/packages/sdk/src/messaging/relaycast.ts @@ -1,5 +1,6 @@ import { RelayCast } from '@relaycast/sdk'; import type { AgentClientOptions, RelayCastOptions } from '@relaycast/sdk'; +import type { DeliveryStatus } from '@relaycast/types'; import { relaycastTelemetryOptions, type RelaycastTelemetryOptions } from '../relaycast-telemetry.js'; import { @@ -277,7 +278,7 @@ type RelaycastWorkspaceLike = { on?: { any(handler: (event: unknown) => void): () => void }; }; -type RelaycastDeliveryStatus = 'accepted' | 'delivered' | 'deferred' | 'failed'; +type RelaycastDeliveryStatus = DeliveryStatus; /** The durable delivery methods an agent client must expose for server-backed inbox state. */ type RelaycastAgentDeliverySurface = Required< diff --git a/packages/sdk/src/messaging/types.ts b/packages/sdk/src/messaging/types.ts index 97697a1de..f9ff4075d 100644 --- a/packages/sdk/src/messaging/types.ts +++ b/packages/sdk/src/messaging/types.ts @@ -1,68 +1,85 @@ -export type RelayAgentType = 'agent' | 'human' | 'system'; -export type RelayAgentStatus = 'online' | 'offline' | 'away' | 'unknown'; -export type RelayChannelMemberRole = 'owner' | 'member'; -export type RelayMessageMode = 'wait' | 'steer'; +import type * as wire from '@relaycast/types'; + +// The `Relay*` types below are derived from the canonical wire contract in +// `@relaycast/types` (snake_case zod schemas shared with the Relaycast +// engine). Where the relay surface deliberately reshapes a wire type +// (camelCase fields, renames, optional-instead-of-null, enrichment), the +// reshaped fields still index into the canonical type so a wire-contract +// change surfaces as a compile error here instead of silent drift. + +type WireAgentChannel = NonNullable[number]; +type WireInboxChannelSummary = wire.InboxResponse['unread_channels'][number]; +type WireInboxDirectSummary = wire.InboxResponse['unread_dms'][number]; +type WireInboxLastMessage = NonNullable; +type WireInboxReactionSummary = wire.InboxResponse['recent_reactions'][number]; + +export type RelayAgentType = wire.AgentType; +/** Canonical agent statuses plus the relay-only `unknown` fallback. */ +export type RelayAgentStatus = wire.AgentStatus | 'unknown'; +export type RelayChannelMemberRole = wire.ChannelMemberInfo['role']; +export type RelayMessageMode = wire.MessageInjectionMode; export type RelayMessageKind = 'channel' | 'dm' | 'group_dm' | 'thread_reply' | 'unknown'; export interface RelayAgentChannel { - id: string; - name: string; - role: RelayChannelMemberRole; - joinedAt?: string; + id: WireAgentChannel['id']; + name: WireAgentChannel['name']; + role: WireAgentChannel['role']; + joinedAt?: WireAgentChannel['joined_at']; } export interface RelayAgent { - id: string; - name: string; + id: wire.Agent['id']; + name: wire.Agent['name']; type: RelayAgentType; status: RelayAgentStatus; - persona?: string; - metadata: Record; - lastSeenAt?: string; - createdAt?: string; + persona?: NonNullable; + metadata: wire.Agent['metadata']; + lastSeenAt?: wire.Agent['last_seen']; + createdAt?: NonNullable; channels: RelayAgentChannel[]; } export interface RelayAgentRegistration { - id: string; - name: string; - token: string; + id: wire.CreateAgentResponse['id']; + name: wire.CreateAgentResponse['name']; + token: wire.CreateAgentResponse['token']; status: RelayAgentStatus; - createdAt?: string; + createdAt?: wire.CreateAgentResponse['created_at']; } export interface RelayAgentPresence { - agentId: string; - agentName: string; - status: Extract; + agentId: wire.AgentPresenceInfo['agent_id']; + agentName: wire.AgentPresenceInfo['agent_name']; + status: wire.AgentPresenceInfo['status']; } export interface RelayChannelMember { - agentId: string; - agentName: string; - role: RelayChannelMemberRole; - joinedAt?: string; - muted: boolean; + agentId: wire.ChannelMemberInfo['agent_id']; + agentName: wire.ChannelMemberInfo['agent_name']; + role: wire.ChannelMemberInfo['role']; + joinedAt?: wire.ChannelMemberInfo['joined_at']; + muted: NonNullable; } export interface RelayChannel { - id: string; - name: string; - topic?: string; - metadata: Record; - createdBy?: string; - createdAt?: string; - archived: boolean; - memberCount?: number; + id: wire.Channel['id']; + name: wire.Channel['name']; + topic?: NonNullable; + metadata: NonNullable; + createdBy?: NonNullable; + createdAt?: wire.Channel['created_at']; + archived: NonNullable; + memberCount?: wire.Channel['member_count']; members: RelayChannelMember[]; } +/** A stored file attachment; mirrors the canonical `FileAttachment` with `file_id` exposed as `id`. */ export interface RelayStoredAttachment { - id: string; + id: wire.FileAttachment['file_id']; type?: 'stored'; - filename?: string; - contentType?: string; - sizeBytes?: number; + filename?: wire.FileAttachment['filename']; + contentType?: wire.FileAttachment['content_type']; + sizeBytes?: wire.FileAttachment['size_bytes']; } export interface RelayTextAttachment { @@ -127,11 +144,7 @@ export type RelayMessageAttachmentInput = string | RelayMessageAttachment; export type RelayMessageBlock = Record; -export interface RelayMessageReaction { - emoji: string; - count: number; - agents: string[]; -} +export type RelayMessageReaction = wire.ReactionGroup; export interface RelayMessageSender { id?: string; @@ -151,27 +164,27 @@ export type RelayMessageTarget = | { kind: string; [key: string]: unknown }; export interface RelayMessage { - id: string; + id: wire.MessageWithMeta['id']; /** Public identifier for the message; mirrors `id`. */ - messageId: string; + messageId: wire.MessageWithMeta['id']; kind?: RelayMessageKind; - text: string; + text: wire.MessageWithMeta['text']; from: RelayMessageSender; target?: RelayMessageTarget; channel?: RelayMessageChannelRef; conversationId?: string; - threadId?: string; + threadId?: NonNullable; parentId?: string; mode?: RelayMessageMode; - createdAt?: string; - updatedAt?: string; - metadata?: Record; + createdAt?: wire.MessageWithMeta['created_at']; + updatedAt?: NonNullable; + metadata?: NonNullable; blocks?: RelayMessageBlock[]; attachments?: RelayMessageAttachment[]; - replyCount?: number; + replyCount?: wire.MessageWithMeta['reply_count']; reactions?: RelayMessageReaction[]; - readByCount?: number; - mentions?: string[]; + readByCount?: wire.MessageWithMeta['read_by_count']; + mentions?: NonNullable; } export interface RelayThread { @@ -180,29 +193,29 @@ export interface RelayThread { } export interface RelayInboxChannelSummary { - channelName: string; - unreadCount: number; + channelName: WireInboxChannelSummary['channel_name']; + unreadCount: WireInboxChannelSummary['unread_count']; } export interface RelayInboxLastMessage { - id: string; - text: string; - createdAt?: string; + id: WireInboxLastMessage['id']; + text: WireInboxLastMessage['text']; + createdAt?: WireInboxLastMessage['created_at']; } export interface RelayInboxDirectSummary { - conversationId: string; - from: string; - unreadCount: number; + conversationId: WireInboxDirectSummary['conversation_id']; + from: WireInboxDirectSummary['from']; + unreadCount: WireInboxDirectSummary['unread_count']; lastMessage?: RelayInboxLastMessage; } export interface RelayInboxReactionSummary { - messageId: string; - channelName: string; - emoji: string; - agentName: string; - createdAt?: string; + messageId: WireInboxReactionSummary['message_id']; + channelName: WireInboxReactionSummary['channel_name']; + emoji: WireInboxReactionSummary['emoji']; + agentName: WireInboxReactionSummary['agent_name']; + createdAt?: WireInboxReactionSummary['created_at']; } export interface RelayInbox { @@ -213,42 +226,42 @@ export interface RelayInbox { } export interface RelayReadReceipt { - messageId: string; - agentId?: string; - agentName?: string; - readAt?: string; + messageId: wire.ReadReceipt['message_id']; + agentId?: wire.ReadReceipt['agent_id']; + agentName?: wire.ReaderInfo['agent_name']; + readAt?: wire.ReadReceipt['read_at']; } export interface RelayChannelReadStatus { - agentName: string; - lastReadId?: string; - lastReadAt?: string; + agentName: wire.ChannelReadStatus['agent_name']; + lastReadId?: NonNullable; + lastReadAt?: NonNullable; } export interface RelaySearchResult { - id: string; - channelName: string; - agentName: string; - text: string; - createdAt?: string; - relevanceScore: number; + id: wire.SearchMessageResult['id']; + channelName: wire.SearchMessageResult['channel_name']; + agentName: wire.SearchMessageResult['agent_name']; + text: wire.SearchMessageResult['text']; + createdAt?: wire.SearchMessageResult['created_at']; + relevanceScore: wire.SearchMessageResult['relevance_score']; } export interface RelayListAgentsOptions { - status?: Exclude | 'all'; + status?: NonNullable; } export interface RelayRegisterAgentInput { - name: string; + name: wire.CreateAgentRequest['name']; type?: RelayAgentType; - persona?: string; - metadata?: Record; + persona?: NonNullable; + metadata?: wire.CreateAgentRequest['metadata']; } export interface RelayUpdateAgentInput { - status?: Exclude; - persona?: string | null; - metadata?: Record; + status?: wire.UpdateAgentRequest['status']; + persona?: wire.UpdateAgentRequest['persona']; + metadata?: wire.UpdateAgentRequest['metadata']; } export interface RelayListChannelsOptions { @@ -256,14 +269,14 @@ export interface RelayListChannelsOptions { } export interface RelayCreateChannelInput { - name: string; - topic?: string | null; - metadata?: Record; + name: wire.CreateChannelRequest['name']; + topic?: wire.CreateChannelRequest['topic']; + metadata?: wire.CreateChannelRequest['metadata']; } export interface RelayUpdateChannelInput { - topic?: string | null; - metadata?: Record; + topic?: wire.UpdateChannelRequest['topic']; + metadata?: wire.UpdateChannelRequest['metadata']; } export interface RelayMessageListOptions { @@ -317,11 +330,12 @@ export interface RelayCreateGroupDirectMessageInput { } export interface RelayGroupDirectConversation { - id: string; - channelId?: string; - name?: string; - participants: string[]; - createdAt?: string; + id: wire.CreateGroupDmResponse['id']; + channelId?: wire.CreateGroupDmResponse['channel_id']; + name?: NonNullable; + /** Participant agent ids, flattened from the canonical `{ agent_id }` rows. */ + participants: Array; + createdAt?: wire.CreateGroupDmResponse['created_at']; } export interface RelayDeliveryUnsupportedResult { @@ -483,7 +497,12 @@ export interface RelayWorkspaceInfo { [key: string]: unknown; } -export type InboxItemState = 'queued' | 'delivered' | 'failed' | 'deferred' | 'read'; +/** + * Relay inbox states built on the canonical delivery-status lifecycle: + * the ledger's `accepted` surfaces as `queued`, and `read` is a relay-only + * extension (the ledger itself has no read state). + */ +export type InboxItemState = Exclude | 'queued' | 'read'; export interface InboxItem { id: string; @@ -574,20 +593,20 @@ export interface RelayAgentPresenceEvent { export interface RelayAgentSpawnRequestedEvent { type: 'agentSpawnRequested'; agent: { - name: string; - cli?: string; - task?: string; - channel?: string; - model?: string; - alreadyExisted: boolean; + name: wire.AgentSpawnRequestedEvent['agent']['name']; + cli?: wire.AgentSpawnRequestedEvent['agent']['cli']; + task?: wire.AgentSpawnRequestedEvent['agent']['task']; + channel?: NonNullable; + model?: NonNullable; + alreadyExisted: wire.AgentSpawnRequestedEvent['agent']['already_existed']; }; } export interface RelayAgentReleaseRequestedEvent { type: 'agentReleaseRequested'; - agent: { name: string }; - reason?: string; - deleted: boolean; + agent: { name: wire.AgentReleaseRequestedEvent['agent']['name'] }; + reason?: NonNullable; + deleted: wire.AgentReleaseRequestedEvent['deleted']; } export interface RelayChannelChangedEvent { @@ -608,24 +627,24 @@ export interface RelayChannelMembershipEvent { export interface RelayMessageReadEvent { type: 'messageRead'; - messageId: string; - agentName: string; - readAt?: string; + messageId: wire.MessageReadEvent['message_id']; + agentName: wire.MessageReadEvent['agent_name']; + readAt?: wire.MessageReadEvent['read_at']; } export interface RelayReactionEvent { type: 'reactionAdded' | 'reactionRemoved'; - messageId: string; - emoji: string; - agentName: string; + messageId: wire.MessageReactedEvent['message_id']; + emoji: wire.MessageReactedEvent['emoji']; + agentName: wire.MessageReactedEvent['agent_name']; } export interface RelayActionInvokedEvent { type: 'actionInvoked'; - invocationId: string; - actionName: string; - callerName: string; - handlerAgentId: string; + invocationId: wire.ActionInvokedEvent['invocation_id']; + actionName: wire.ActionInvokedEvent['action_name']; + callerName: wire.ActionInvokedEvent['caller_name']; + handlerAgentId: wire.ActionInvokedEvent['handler_agent_id']; } export interface RelayConnectionEvent {