From 9878aa6984803591b6a93bf69f7095a68f1398d7 Mon Sep 17 00:00:00 2001 From: Noodle Date: Wed, 1 Apr 2026 15:10:29 -0400 Subject: [PATCH] feat: add system identity/message support --- README.md | 1 + openapi.yaml | 12 ++++++++++ packages/server/src/durable-objects/agent.ts | 6 +++-- .../src/engine/__tests__/wsTransform.test.ts | 14 +++++++++++ packages/server/src/engine/dm.ts | 11 +++++++-- packages/server/src/engine/groupDm.ts | 3 ++- packages/server/src/engine/message.ts | 9 ++++++-- packages/server/src/engine/thread.ts | 12 ++++++---- packages/server/src/engine/wsTransform.ts | 5 ++++ .../server/src/routes/__tests__/agent.test.ts | 23 +++++++++++++++++++ .../src/routes/__tests__/fanout.test.ts | 4 ++++ packages/types/src/__tests__/types.test.ts | 1 + packages/types/src/dm.ts | 1 + packages/types/src/message.ts | 3 +++ 14 files changed, 94 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index fa4937ce..4e5ca639 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ Relaycast is the messaging backbone: - Workspace key (`rk_live_*`): admin token for managing workspace resources - Agent token (`at_live_*`): token an individual agent uses to participate - Identity types: `agent` (AI worker), `human` (person), `system` (automation/service actor) +- Message payloads and realtime message events include optional `agent_type` so clients can distinguish agent, human, and system senders without extra identity lookups. - Channel: shared room for team/agent communication - Message: post in channel/DM/thread, with optional files and reactions diff --git a/openapi.yaml b/openapi.yaml index d6edbc0e..c3ec279d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -136,6 +136,10 @@ components: type: string from_name: type: string + agent_type: + type: string + enum: [agent, human, system] + description: Sender identity type for the message actor text: type: string blocks: @@ -183,6 +187,10 @@ components: type: string agent_name: type: string + agent_type: + type: string + enum: [agent, human, system] + description: Sender identity type for the DM message actor text: type: string injection_mode: @@ -230,6 +238,10 @@ components: type: string agent_name: type: string + agent_type: + type: string + enum: [agent, human, system] + description: Sender identity type for the message actor text: type: string injection_mode: diff --git a/packages/server/src/durable-objects/agent.ts b/packages/server/src/durable-objects/agent.ts index 857d673b..6ecf449d 100644 --- a/packages/server/src/durable-objects/agent.ts +++ b/packages/server/src/durable-objects/agent.ts @@ -178,7 +178,7 @@ export class AgentDO implements DurableObject { // Channel messages (including thread replies) after `since`. const channelRows = await db.all>(sql` SELECT m.id, m.channel_id, m.agent_id, m.body, m.thread_id, - m.created_at, c.name AS channel_name, a.name AS agent_name + m.created_at, c.name AS channel_name, a.name AS agent_name, a.type AS agent_type FROM messages m JOIN channel_members cm ON cm.channel_id = m.channel_id AND cm.agent_id = ${agentId} JOIN channels c ON c.id = m.channel_id @@ -196,6 +196,7 @@ export class AgentDO implements DurableObject { channel_name: row.channel_name, agent_id: row.agent_id, from_name: row.agent_name, + agent_type: row.agent_type, text: row.body, thread_id: row.thread_id, created_at: new Date((row.created_at as number) * 1000).toISOString(), @@ -211,7 +212,7 @@ export class AgentDO implements DurableObject { // DM + group DM messages after `since`. const dmRows = await db.all>(sql` SELECT m.id, m.channel_id, m.agent_id, m.body, m.created_at, - a.name AS agent_name, dc.id AS conversation_id, dc.dm_type + a.name AS agent_name, a.type AS agent_type, dc.id AS conversation_id, dc.dm_type FROM dm_conversations dc JOIN dm_participants dp ON dp.conversation_id = dc.id AND dp.agent_id = ${agentId} AND dp.left_at IS NULL JOIN messages m ON m.channel_id = dc.channel_id @@ -230,6 +231,7 @@ export class AgentDO implements DurableObject { agent_id: row.agent_id, from_agent_id: row.agent_id, from_name: row.agent_name, + agent_type: row.agent_type, text: row.body, created_at: new Date((row.created_at as number) * 1000).toISOString(), } as Record; diff --git a/packages/server/src/engine/__tests__/wsTransform.test.ts b/packages/server/src/engine/__tests__/wsTransform.test.ts index f4cca4ec..55eb49b3 100644 --- a/packages/server/src/engine/__tests__/wsTransform.test.ts +++ b/packages/server/src/engine/__tests__/wsTransform.test.ts @@ -17,6 +17,7 @@ describe('transformForClient', () => { id: 'msg_1', channel_name: 'general', agent_id: 'agent_1', + agent_type: 'system', from_name: 'Bot', text: 'hi', attachments: [{ file_id: 'f1', filename: 'a.txt', content_type: 'text/plain', size_bytes: 1 }], @@ -29,6 +30,7 @@ describe('transformForClient', () => { id: 'msg_1', agent_id: 'agent_1', agent_name: 'Bot', + agent_type: 'system', text: 'hi', attachments: [{ file_id: 'f1', filename: 'a.txt', content_type: 'text/plain', size_bytes: 1 }], }, @@ -40,6 +42,7 @@ describe('transformForClient', () => { id: 'msg_2', channel_name: 'general', agent_id: 'agent_1', + agent_type: 'human', from_name: 'Bot', text: 'edit', }, 'ch_1'); @@ -51,6 +54,7 @@ describe('transformForClient', () => { id: 'msg_2', agent_id: 'agent_1', agent_name: 'Bot', + agent_type: 'human', text: 'edit', }, }); @@ -61,6 +65,7 @@ describe('transformForClient', () => { id: 'msg_3', channel_name: 'general', agent_id: 'agent_2', + agent_type: 'agent', from_name: 'Alice', text: 'reply', thread_id: 'msg_root', @@ -74,6 +79,7 @@ describe('transformForClient', () => { id: 'msg_3', agent_id: 'agent_2', agent_name: 'Alice', + agent_type: 'agent', text: 'reply', }, }); @@ -114,6 +120,7 @@ describe('transformForClient', () => { id: 'msg_6', conversation_id: 'dm_1', from_agent_id: 'agent_3', + agent_type: 'human', from_name: 'Cara', text: 'dm', }); @@ -125,6 +132,7 @@ describe('transformForClient', () => { id: 'msg_6', agent_id: 'agent_3', agent_name: 'Cara', + agent_type: 'human', text: 'dm', }, }); @@ -137,6 +145,7 @@ describe('transformForClient', () => { id: 'msg_6b', agent_id: 'agent_3', agent_name: 'Cara', + agent_type: 'system', text: 'dm nested', injection_mode: 'steer', attachments: [ @@ -152,6 +161,7 @@ describe('transformForClient', () => { id: 'msg_6b', agent_id: 'agent_3', agent_name: 'Cara', + agent_type: 'system', text: 'dm nested', injection_mode: 'steer', attachments: [ @@ -166,6 +176,7 @@ describe('transformForClient', () => { id: 'msg_7', conversation_id: 'gdm_1', agent_id: 'agent_4', + agent_type: 'system', from_name: 'Dan', text: 'group', }); @@ -177,6 +188,7 @@ describe('transformForClient', () => { id: 'msg_7', agent_id: 'agent_4', agent_name: 'Dan', + agent_type: 'system', text: 'group', }, }); @@ -189,6 +201,7 @@ describe('transformForClient', () => { id: 'msg_7b', agent_id: 'agent_4', agent_name: 'Dan', + agent_type: 'human', text: 'group nested', attachments: [ { file_id: 'f2', filename: 'b.txt', content_type: 'text/plain', size_bytes: 2 }, @@ -203,6 +216,7 @@ describe('transformForClient', () => { id: 'msg_7b', agent_id: 'agent_4', agent_name: 'Dan', + agent_type: 'human', text: 'group nested', attachments: [ { file_id: 'f2', filename: 'b.txt', content_type: 'text/plain', size_bytes: 2 }, diff --git a/packages/server/src/engine/dm.ts b/packages/server/src/engine/dm.ts index 8875e7e9..5ae04396 100644 --- a/packages/server/src/engine/dm.ts +++ b/packages/server/src/engine/dm.ts @@ -1,6 +1,6 @@ import crypto from 'node:crypto'; import { eq, and, sql, lt, gt, isNull, inArray, asc } from 'drizzle-orm'; -import type { DmMessage } from '@relaycast/types'; +import type { AgentType, DmMessage } from '@relaycast/types'; import type { getDb } from '../db/index.js'; import { messages, @@ -18,6 +18,7 @@ import { logMessage } from './console.js'; type Db = ReturnType; type AttachmentRow = { file_id: string; filename: string; content_type: string; size_bytes: number }; +type SenderType = AgentType | undefined; interface SendDmOptions { skipA2aIntercept?: boolean; @@ -233,7 +234,7 @@ export async function sendDm( } const [fromAgent] = await db - .select({ name: agents.name }) + .select({ name: agents.name, type: agents.type }) .from(agents) .where(and(eq(agents.workspaceId, workspaceId), eq(agents.id, fromAgentId))); @@ -306,6 +307,7 @@ export async function sendDm( id: message.id, agent_id: message.agentId, agent_name: fromAgent.name, + agent_type: fromAgent.type as SenderType, text: message.body, injection_mode: injectionMode, attachments, @@ -377,10 +379,12 @@ export async function listConversations(db: Db, workspaceId: string, agentId: st id: messages.id, channelId: messages.channelId, agentId: messages.agentId, + agentType: agents.type, body: messages.body, createdAt: messages.createdAt, }) .from(messages) + .leftJoin(agents, eq(messages.agentId, agents.id)) .where(inArray(messages.id, lastIds)) : []; @@ -411,6 +415,7 @@ export async function listConversations(db: Db, workspaceId: string, agentId: st id: lastMessage.id, text: lastMessage.body, agent_id: lastMessage.agentId, + agent_type: (lastMessage.agentType as SenderType) || undefined, created_at: lastMessage.createdAt.toISOString(), } : null, @@ -478,6 +483,7 @@ export async function getDmMessages( id: messages.id, agentId: messages.agentId, agentName: agents.name, + agentType: agents.type, body: messages.body, metadata: messages.metadata, createdAt: messages.createdAt, @@ -494,6 +500,7 @@ export async function getDmMessages( id: r.id, agent_id: r.agentId, agent_name: r.agentName, + agent_type: (r.agentType as SenderType) || undefined, text: r.body, injection_mode: (r.metadata as Record | null)?.injection_mode as 'wait' | 'steer' | undefined, attachments: attachmentMap.get(r.id) || [], diff --git a/packages/server/src/engine/groupDm.ts b/packages/server/src/engine/groupDm.ts index 5318de26..9cb0cc85 100644 --- a/packages/server/src/engine/groupDm.ts +++ b/packages/server/src/engine/groupDm.ts @@ -143,7 +143,7 @@ export async function postGroupMessage( } const [fromAgent] = await db - .select({ name: agents.name }) + .select({ name: agents.name, type: agents.type }) .from(agents) .where(and(eq(agents.workspaceId, workspaceId), eq(agents.id, agentId))); @@ -217,6 +217,7 @@ export async function postGroupMessage( id: message.id, agent_id: message.agentId, agent_name: fromAgent.name, + agent_type: fromAgent.type, text: message.body, injection_mode: injectionMode, attachments, diff --git a/packages/server/src/engine/message.ts b/packages/server/src/engine/message.ts index 00f18c2c..ad442d24 100644 --- a/packages/server/src/engine/message.ts +++ b/packages/server/src/engine/message.ts @@ -84,10 +84,10 @@ export async function postMessage( await db.insert(messageAttachments).values(attachmentValues); } - // Fetch attachment details and agent name + // Fetch attachment details and sender identity const [attachmentMap, [agent]] = await Promise.all([ hasAttachments ? fetchAttachmentsBatch(db, [messageId]) : Promise.resolve(new Map()), - db.select({ name: agents.name }).from(agents).where(eq(agents.id, agentId)), + db.select({ name: agents.name, type: agents.type }).from(agents).where(eq(agents.id, agentId)), ]); const attachments = attachmentMap.get(messageId) || []; @@ -113,6 +113,7 @@ export async function postMessage( channel_id: message.channelId, agent_id: message.agentId, agent_name: agent?.name || 'unknown', + agent_type: agent?.type || undefined, text: message.body, blocks: (message.blocks as unknown[] | null) || null, metadata: (message.metadata as Record) || {}, @@ -153,6 +154,7 @@ export async function getMessages( channelId: messages.channelId, agentId: messages.agentId, agentName: agents.name, + agentType: agents.type, threadId: messages.threadId, body: messages.body, blocks: messages.blocks, @@ -234,6 +236,7 @@ export async function getMessages( channel_id: row.channelId, agent_id: row.agentId, agent_name: row.agentName || 'unknown', + agent_type: row.agentType || undefined, text: row.body, blocks: (row.blocks as unknown[] | null) || null, metadata: (row.metadata as Record) || {}, @@ -255,6 +258,7 @@ export async function getMessage(db: Db, workspaceId: string, messageId: string) channelId: messages.channelId, agentId: messages.agentId, agentName: agents.name, + agentType: agents.type, threadId: messages.threadId, body: messages.body, blocks: messages.blocks, @@ -295,6 +299,7 @@ export async function getMessage(db: Db, workspaceId: string, messageId: string) channel_id: row.channelId, agent_id: row.agentId, agent_name: row.agentName || 'unknown', + agent_type: row.agentType || undefined, text: row.body, blocks: (row.blocks as unknown[] | null) || null, metadata: (row.metadata as Record) || {}, diff --git a/packages/server/src/engine/thread.ts b/packages/server/src/engine/thread.ts index 391aef2c..74511454 100644 --- a/packages/server/src/engine/thread.ts +++ b/packages/server/src/engine/thread.ts @@ -45,8 +45,8 @@ export async function postReply( }) .returning(); - // Resolve agent name - const [agent] = await db.select({ name: agents.name }).from(agents).where(eq(agents.id, agentId)); + // Resolve sender identity + const [agent] = await db.select({ name: agents.name, type: agents.type }).from(agents).where(eq(agents.id, agentId)); return { id: reply.id, @@ -54,6 +54,7 @@ export async function postReply( channel_name: ch?.name, agent_id: reply.agentId, agent_name: agent?.name || 'unknown', + agent_type: agent?.type || undefined, thread_id: reply.threadId, text: reply.body, blocks: (reply.blocks as unknown[] | null) || null, @@ -71,13 +72,14 @@ export async function getThread( ) { const limit = Math.min(Math.max(opts.limit || 50, 1), 100); - // Get the parent message with agent name + // Get the parent message with sender identity const [parent] = await db .select({ id: messages.id, channelId: messages.channelId, agentId: messages.agentId, agentName: agents.name, + agentType: agents.type, threadId: messages.threadId, body: messages.body, blocks: messages.blocks, @@ -101,7 +103,6 @@ export async function getThread( .from(messages) .where(eq(messages.threadId, parentId)); - // Get replies with pagination const conditions = [ eq(messages.threadId, parentId), eq(messages.workspaceId, workspaceId), @@ -120,6 +121,7 @@ export async function getThread( channelId: messages.channelId, agentId: messages.agentId, agentName: agents.name, + agentType: agents.type, threadId: messages.threadId, body: messages.body, blocks: messages.blocks, @@ -139,6 +141,7 @@ export async function getThread( channel_id: parent.channelId, agent_id: parent.agentId, agent_name: parent.agentName || 'unknown', + agent_type: parent.agentType || undefined, text: parent.body, blocks: (parent.blocks as unknown[] | null) || null, metadata: (parent.metadata as Record) || {}, @@ -152,6 +155,7 @@ export async function getThread( channel_id: r.channelId, agent_id: r.agentId, agent_name: r.agentName || 'unknown', + agent_type: r.agentType || undefined, thread_id: r.threadId, text: r.body, blocks: (r.blocks as unknown[] | null) || null, diff --git a/packages/server/src/engine/wsTransform.ts b/packages/server/src/engine/wsTransform.ts index 6e4ee86a..31b552dd 100644 --- a/packages/server/src/engine/wsTransform.ts +++ b/packages/server/src/engine/wsTransform.ts @@ -23,6 +23,7 @@ export function transformForClient(event: WsEvent): Record { id: d.id as string, agent_id: d.agent_id as string, agent_name: d.from_name as string, + agent_type: d.agent_type as 'agent' | 'human' | 'system' | undefined, text: d.text as string, attachments: (d.attachments as unknown[]) ?? [], injection_mode: d.injection_mode as 'wait' | 'steer' | undefined, @@ -37,6 +38,7 @@ export function transformForClient(event: WsEvent): Record { id: d.id as string, agent_id: d.agent_id as string, agent_name: d.from_name as string, + agent_type: d.agent_type as 'agent' | 'human' | 'system' | undefined, text: d.text as string, }, }; @@ -50,6 +52,7 @@ export function transformForClient(event: WsEvent): Record { id: d.id as string, agent_id: d.agent_id as string, agent_name: d.from_name as string, + agent_type: d.agent_type as 'agent' | 'human' | 'system' | undefined, text: d.text as string, }, }; @@ -81,6 +84,7 @@ export function transformForClient(event: WsEvent): Record { id: (msg.id ?? d.id) as string, agent_id: (msg.agent_id ?? d.from_agent_id ?? d.agent_id) as string, agent_name: (msg.agent_name ?? d.from_name) as string, + agent_type: (msg.agent_type ?? d.agent_type) as 'agent' | 'human' | 'system' | undefined, text: (msg.text ?? d.text) as string, ...(injectionMode ? { injection_mode: injectionMode } : {}), ...(attachments.length ? { attachments } : {}), @@ -99,6 +103,7 @@ export function transformForClient(event: WsEvent): Record { id: (msg.id ?? d.id) as string, agent_id: (msg.agent_id ?? d.agent_id) as string, agent_name: (msg.agent_name ?? d.from_name) as string, + agent_type: (msg.agent_type ?? d.agent_type) as 'agent' | 'human' | 'system' | undefined, text: (msg.text ?? d.text) as string, ...(injectionMode ? { injection_mode: injectionMode } : {}), ...(attachments.length ? { attachments } : {}), diff --git a/packages/server/src/routes/__tests__/agent.test.ts b/packages/server/src/routes/__tests__/agent.test.ts index a78ce8cb..9c6a86a3 100644 --- a/packages/server/src/routes/__tests__/agent.test.ts +++ b/packages/server/src/routes/__tests__/agent.test.ts @@ -70,6 +70,29 @@ describe('POST /v1/agents', () => { expect(body.data.token).toContain('at_live_'); }); + it('passes through first-class system identity registration', async () => { + vi.mocked(agentEngine.registerAgent).mockResolvedValue({ + id: 'agent_system', + name: 'System', + token: 'at_live_systemtoken123', + status: 'online', + created_at: '2025-01-01T00:00:00.000Z', + }); + + const res = await app.request('/v1/agents', { + method: 'POST', + headers: wsAuthHeaders(), + body: JSON.stringify({ name: 'System', type: 'system' }), + }, bindings); + + expect(res.status).toBe(201); + expect(agentEngine.registerAgent).toHaveBeenCalledWith( + expect.anything(), + 'ws_123', + expect.objectContaining({ name: 'System', type: 'system' }), + ); + }); + it('returns 400 when name is missing', async () => { const res = await app.request('/v1/agents', { method: 'POST', diff --git a/packages/server/src/routes/__tests__/fanout.test.ts b/packages/server/src/routes/__tests__/fanout.test.ts index 002a72f3..a53e18d7 100644 --- a/packages/server/src/routes/__tests__/fanout.test.ts +++ b/packages/server/src/routes/__tests__/fanout.test.ts @@ -45,6 +45,7 @@ describe('fanout helpers', () => { id: 'msg_1', channel_name: 'general', agent_id: 'agent_1', + agent_type: 'system', from_name: 'Bot', text: 'hi', attachments: [], @@ -62,6 +63,7 @@ describe('fanout helpers', () => { id: 'msg_1', agent_id: 'agent_1', agent_name: 'Bot', + agent_type: 'system', text: 'hi', attachments: [], }, @@ -81,6 +83,7 @@ describe('fanout helpers', () => { id: 'msg_2', conversation_id: 'dm_1', from_agent_id: 'a1', + agent_type: 'human', from_name: 'Bot', text: 'hello', }); @@ -91,6 +94,7 @@ describe('fanout helpers', () => { expect(body.type).toBe('dm.received'); expect(body.workspaceId).toBe(FAKE_WORKSPACE.id); expect(['a1', 'a2']).toContain(body.agentId); + expect(body.message.agent_type).toBe('human'); }); it('fanoutToWorkspace queries PresenceDO then delivers to agents', async () => { diff --git a/packages/types/src/__tests__/types.test.ts b/packages/types/src/__tests__/types.test.ts index 9f13eb0a..d526f69d 100644 --- a/packages/types/src/__tests__/types.test.ts +++ b/packages/types/src/__tests__/types.test.ts @@ -232,6 +232,7 @@ describe('Type definitions', () => { it('DmMessage includes sender identity fields', () => { expectTypeOf().toHaveProperty('agent_id'); expectTypeOf().toHaveProperty('agent_name'); + expectTypeOf().toHaveProperty('agent_type'); }); // ============================================ diff --git a/packages/types/src/dm.ts b/packages/types/src/dm.ts index f784a9be..925e0f93 100644 --- a/packages/types/src/dm.ts +++ b/packages/types/src/dm.ts @@ -48,6 +48,7 @@ export const DmLastMessageSchema = z.object({ id: z.string(), text: z.string(), agent_id: z.string(), + agent_type: z.enum(['agent', 'human', 'system']).optional(), created_at: z.string(), }); export type DmLastMessage = z.infer; diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index 4fad1614..9af9c4da 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { FileAttachmentSchema } from './file.js'; import { ReactionGroupSchema } from './reaction.js'; +import { AgentTypeSchema } from './agent.js'; // Rich Message Blocks @@ -73,6 +74,7 @@ export const CoreMessagePayloadSchema = z.object({ id: z.string(), agent_id: z.string(), agent_name: z.string(), + agent_type: AgentTypeSchema.optional(), text: z.string(), injection_mode: MessageInjectionModeSchema.optional(), attachments: z.array(FileAttachmentSchema).optional(), @@ -84,6 +86,7 @@ export const MessageWithMetaSchema = z.object({ channel_id: z.string(), agent_id: z.string(), agent_name: z.string(), + agent_type: AgentTypeSchema.optional(), text: z.string(), blocks: z.array(MessageBlockSchema).nullable(), metadata: z.record(z.string(), z.unknown()).optional(),