From 1cf51a338e516f47ebce97fd66a1ac779145775c Mon Sep 17 00:00:00 2001 From: Genie Automagik Date: Wed, 3 Jun 2026 05:35:39 +0000 Subject: [PATCH] fix(api): promote canonical KHAL reset to homolog --- .claude-plugin/marketplace.json | 2 +- apps/ui/package.json | 2 +- bun.lock | 36 ++-- package.json | 2 +- packages/api/package.json | 2 +- .../plugins/__tests__/session-cleaner.test.ts | 47 ++++++ packages/api/src/plugins/agent-dispatcher.ts | 47 +++++- packages/api/src/plugins/session-cleaner.ts | 99 +++++++++-- .../__tests__/agent-session-identity.test.ts | 61 +++++++ .../src/services/agent-session-identity.ts | 159 ++++++++++++++++++ packages/channel-a2a/package.json | 2 +- packages/channel-discord/package.json | 2 +- packages/channel-gupshup/package.json | 2 +- packages/channel-internal/package.json | 2 +- packages/channel-sdk/package.json | 2 +- packages/channel-slack/package.json | 2 +- packages/channel-telegram/package.json | 2 +- packages/channel-twilio-whatsapp/package.json | 2 +- packages/channel-whatsapp/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- .../providers/__tests__/agno-client.test.ts | 27 +++ packages/core/src/providers/agno-client.ts | 9 +- packages/core/src/providers/types.ts | 9 +- packages/db/package.json | 2 +- packages/media-processing/package.json | 2 +- packages/plugin-openclaw/package.json | 2 +- packages/sdk/package.json | 2 +- packages/voice-client/package.json | 2 +- plugins/omni/.claude-plugin/plugin.json | 2 +- 30 files changed, 468 insertions(+), 68 deletions(-) create mode 100644 packages/api/src/plugins/__tests__/session-cleaner.test.ts create mode 100644 packages/api/src/services/__tests__/agent-session-identity.test.ts create mode 100644 packages/api/src/services/agent-session-identity.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index aacac010b..4512bfd2e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "omni", "description": "Full Omni platform control — multichannel messaging, automations, events, batch ops via the omni CLI", - "version": "2.260603.1", + "version": "2.260603.3", "author": { "name": "Automagik" }, diff --git a/apps/ui/package.json b/apps/ui/package.json index 8bba8ba60..4de68aa5d 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -1,6 +1,6 @@ { "name": "@omni/ui", - "version": "2.260603.1", + "version": "2.260603.3", "private": true, "type": "module", "scripts": { diff --git a/bun.lock b/bun.lock index e083c32f5..3520c99c2 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ }, "apps/ui": { "name": "@omni/ui", - "version": "2.260603.1", + "version": "2.260603.3", "dependencies": { "@omni/sdk": "workspace:*", "@radix-ui/react-dialog": "^1.1.15", @@ -51,7 +51,7 @@ }, "packages/api": { "name": "@omni/api", - "version": "2.260603.1", + "version": "2.260603.3", "dependencies": { "@google/genai": "^1.0.0", "@hono/swagger-ui": "^0.4.1", @@ -91,7 +91,7 @@ }, "packages/channel-a2a": { "name": "@omni/channel-a2a", - "version": "2.260603.1", + "version": "2.260603.3", "dependencies": { "@omni/channel-sdk": "workspace:*", "@omni/core": "workspace:*", @@ -107,7 +107,7 @@ }, "packages/channel-discord": { "name": "@omni/channel-discord", - "version": "2.260603.1", + "version": "2.260603.3", "dependencies": { "@omni/channel-sdk": "workspace:*", "@omni/core": "workspace:*", @@ -124,7 +124,7 @@ }, "packages/channel-gupshup": { "name": "@omni/channel-gupshup", - "version": "2.260603.1", + "version": "2.260603.3", "dependencies": { "@omni/channel-sdk": "workspace:*", "@omni/core": "workspace:*", @@ -140,7 +140,7 @@ }, "packages/channel-internal": { "name": "@omni/channel-internal", - "version": "2.260603.1", + "version": "2.260603.3", "dependencies": { "@omni/channel-sdk": "workspace:*", "@omni/core": "workspace:*", @@ -155,7 +155,7 @@ }, "packages/channel-sdk": { "name": "@omni/channel-sdk", - "version": "2.260603.1", + "version": "2.260603.3", "dependencies": { "@omni/core": "workspace:*", }, @@ -166,7 +166,7 @@ }, "packages/channel-slack": { "name": "@omni/channel-slack", - "version": "2.260603.1", + "version": "2.260603.3", "dependencies": { "@omni/channel-sdk": "workspace:*", "@omni/core": "workspace:*", @@ -184,7 +184,7 @@ }, "packages/channel-telegram": { "name": "@omni/channel-telegram", - "version": "2.260603.1", + "version": "2.260603.3", "dependencies": { "@omni/channel-sdk": "workspace:*", "@omni/core": "workspace:*", @@ -200,7 +200,7 @@ }, "packages/channel-twilio-whatsapp": { "name": "@omni/channel-twilio-whatsapp", - "version": "2.260603.1", + "version": "2.260603.3", "dependencies": { "@omni/channel-sdk": "workspace:*", "@omni/core": "workspace:*", @@ -215,7 +215,7 @@ }, "packages/channel-whatsapp": { "name": "@omni/channel-whatsapp", - "version": "2.260603.1", + "version": "2.260603.3", "dependencies": { "@hapi/boom": "^10.0.1", "@omni/channel-sdk": "workspace:*", @@ -236,7 +236,7 @@ }, "packages/cli": { "name": "@automagik/omni", - "version": "2.260603.1", + "version": "2.260603.3", "bin": { "omni": "./bin/omni", }, @@ -272,7 +272,7 @@ }, "packages/core": { "name": "@omni/core", - "version": "2.260603.1", + "version": "2.260603.3", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.62", "@opentelemetry/api": "^1.9.1", @@ -288,7 +288,7 @@ }, "packages/db": { "name": "@omni/db", - "version": "2.260603.1", + "version": "2.260603.3", "dependencies": { "@omni/core": "workspace:*", "drizzle-orm": "^0.38.4", @@ -302,7 +302,7 @@ }, "packages/media-processing": { "name": "@omni/media-processing", - "version": "2.260603.1", + "version": "2.260603.3", "dependencies": { "@google/generative-ai": "^0.21.0", "@omni/core": "workspace:*", @@ -321,7 +321,7 @@ }, "packages/plugin-openclaw": { "name": "@omni/plugin-openclaw", - "version": "2.260603.1", + "version": "2.260603.3", "devDependencies": { "@types/bun": "latest", "typescript": "^5.7.3", @@ -329,7 +329,7 @@ }, "packages/sdk": { "name": "@omni/sdk", - "version": "2.260603.1", + "version": "2.260603.3", "dependencies": { "openapi-fetch": "^0.13.6", }, @@ -343,7 +343,7 @@ }, "packages/voice-client": { "name": "@omni/voice-client", - "version": "2.260603.1", + "version": "2.260603.3", "dependencies": { "@snazzah/davey": "^0.1.11", "libsodium-wrappers": "^0.7.15", diff --git a/package.json b/package.json index f41e2b24a..d56b636b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omni-v2", - "version": "2.260603.1", + "version": "2.260603.3", "private": true, "type": "module", "workspaces": ["packages/*", "apps/*"], diff --git a/packages/api/package.json b/packages/api/package.json index b836ec9b2..18900dadb 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@omni/api", - "version": "2.260603.1", + "version": "2.260603.3", "type": "module", "exports": { ".": { diff --git a/packages/api/src/plugins/__tests__/session-cleaner.test.ts b/packages/api/src/plugins/__tests__/session-cleaner.test.ts new file mode 100644 index 000000000..0d3d37fac --- /dev/null +++ b/packages/api/src/plugins/__tests__/session-cleaner.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'bun:test'; +import { clearAgentSession } from '../session-cleaner'; + +function makeDbWithAgentProvider(providerId: string) { + return { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => [{ agentProviderId: providerId }], + }), + }), + }), + } as never; +} + +describe('session-cleaner canonical KHAL reset guard', () => { + it('fails closed for Agno/KHAL resets when canonical person identity cannot be resolved', async () => { + const services = { + agentRunner: { + getInstanceWithProvider: async () => ({ + id: 'inst-1', + agentId: 'agent-1', + channel: 'whatsapp-gupshup', + agentSessionStrategy: 'per_chat', + }), + }, + providers: { + getById: async () => ({ + id: 'provider-1', + schema: 'agno', + baseUrl: 'http://agno.invalid', + apiKey: '', + defaultTimeout: 1, + }), + }, + chats: { + findByExternalIdSmart: async () => null, + }, + } as never; + + await expect( + clearAgentSession(services, makeDbWithAgentProvider('provider-1'), 'inst-1', '5547996094523', '5547996094523', { + rawPayload: { headers: { 'x-khal-env': 'hml' } }, + }), + ).rejects.toThrow('Canonical KHAL session resolution failed'); + }); +}); diff --git a/packages/api/src/plugins/agent-dispatcher.ts b/packages/api/src/plugins/agent-dispatcher.ts index ba305e948..7a8cd947d 100644 --- a/packages/api/src/plugins/agent-dispatcher.ts +++ b/packages/api/src/plugins/agent-dispatcher.ts @@ -82,6 +82,7 @@ import { getSplitDelayConfig, shouldAgentReply, } from '../services/agent-runner'; +import { resolveKhalSessionId } from '../services/agent-session-identity'; import { buildWhatsAppMessageContext, extractPhoneFromJid } from '../services/message-context'; import type { ResolvedRoute } from '../services/route-resolver'; import { publishTurnOpen } from '../services/turn-events'; @@ -1776,11 +1777,20 @@ async function dispatchViaStreamingProvider( if (!messageTexts.length && mediaFiles.length) messageTexts.push('[Media message]'); const rawThreadId = extractThreadId(messages); - const explicitKhalSessionId = extractKhalSessionId(messages); - const sessionId = - explicitKhalSessionId ?? - computeSessionId(instance.agentSessionStrategy ?? 'per_chat', senderId, chatId, rawThreadId); const rawPl = (messages[0]?.payload.rawPayload ?? {}) as Record; + const sessionIdentity = resolveKhalSessionId({ + providerSchema: resolved.provider.schema, + sessionStrategy: instance.agentSessionStrategy ?? 'per_chat', + from: senderId, + chatId, + channel, + instanceId: instance.id, + personId, + rawPayload: rawPl, + threadId: rawThreadId, + }); + const sessionId = sessionIdentity.sessionId; + const explicitKhalSessionId = sessionIdentity.canonicalSessionId; const replyToId = messages[0]?.payload.replyToId ?? messages[0]?.payload.externalId; const currentMessageIds = messages.map((msg) => msg.payload.externalId).filter((id): id is string => !!id); @@ -2205,10 +2215,20 @@ async function dispatchViaProvider( } const rawThreadId = extractThreadId(messages); - const explicitKhalSessionId = extractKhalSessionId(messages); - const sessionId = - explicitKhalSessionId ?? - computeSessionId(instance.agentSessionStrategy ?? 'per_chat', senderId, chatId, rawThreadId); + const rawPl = (messages[0]?.payload.rawPayload ?? {}) as Record; + const sessionIdentity = resolveKhalSessionId({ + providerSchema: provider.schema, + sessionStrategy: instance.agentSessionStrategy ?? 'per_chat', + from: senderId, + chatId, + channel, + instanceId: instance.id, + personId, + rawPayload: rawPl, + threadId: rawThreadId, + }); + const sessionId = sessionIdentity.sessionId; + const explicitKhalSessionId = sessionIdentity.canonicalSessionId; // Build context messages for group and DM conversations (messages since last bot response) const currentMessageIds = messages.map((msg) => msg.payload.externalId).filter((id): id is string => !!id); @@ -3917,7 +3937,16 @@ async function processReactionTrigger( // Build AgentTrigger for the provider const effectivePersonId = reactionPersonId ?? metadata.personId; const senderName = await services.agentRunner.getSenderName(effectivePersonId, undefined); - const sessionId = computeSessionId(instance.agentSessionStrategy ?? 'per_chat', payload.from, externalChatId); + const sessionId = resolveKhalSessionId({ + providerSchema: provider.schema, + sessionStrategy: instance.agentSessionStrategy ?? 'per_chat', + from: payload.from, + chatId: externalChatId, + channel, + instanceId: instance.id, + personId: effectivePersonId, + rawPayload: payload.rawPayload as Record | undefined, + }).sessionId; const customerContext = await resolveCustomerContext(services, effectivePersonId); const trigger: AgentTrigger = { diff --git a/packages/api/src/plugins/session-cleaner.ts b/packages/api/src/plugins/session-cleaner.ts index 5530dff16..824e21708 100644 --- a/packages/api/src/plugins/session-cleaner.ts +++ b/packages/api/src/plugins/session-cleaner.ts @@ -10,11 +10,11 @@ import type { EventBus, TypedOmniEvent } from '@omni/core'; import { createAgnoClient, createLogger } from '@omni/core'; import type { ChannelType, Database } from '@omni/db'; -import { agents } from '@omni/db'; -import { eq } from 'drizzle-orm'; +import { agents, chatParticipants } from '@omni/db'; +import { and, eq } from 'drizzle-orm'; import { withIdempotency } from '../lib/idempotency'; import type { Services } from '../services'; -import { computeSessionId } from '../services/agent-runner'; +import { type ResolvedAgentSessionIdentity, resolveKhalSessionId } from '../services/agent-session-identity'; import { applyAgentFkOverrides, resolveProvider } from './agent-dispatcher'; import { getPlugin } from './loader'; @@ -55,13 +55,36 @@ async function sendMessage(services: Services, instanceId: string, chatId: strin * Tries IAgentProvider.resetSession() first (supports OpenClaw, Webhook, etc.), * falls back to direct AgnoOS client for legacy. */ +async function resolveCleanupPersonId( + services: Services, + db: Database, + instanceId: string, + chatId: string, + from: string, + metadataPersonId?: string, +): Promise { + if (metadataPersonId?.trim()) return metadataPersonId.trim(); + + const dbChat = await services.chats.findByExternalIdSmart(instanceId, chatId); + if (!dbChat?.id) return undefined; + + const [participant] = await db + .select({ personId: chatParticipants.personId }) + .from(chatParticipants) + .where(and(eq(chatParticipants.chatId, dbChat.id), eq(chatParticipants.platformUserId, from))) + .limit(1); + + return participant?.personId ?? undefined; +} + export async function clearAgentSession( services: Services, db: Database, instanceId: string, from: string, chatId: string, -): Promise<{ sessionId: string; sessionStrategy: string }> { + options: { personId?: string; rawPayload?: Record } = {}, +): Promise { // Get instance with provider const instance = await services.agentRunner.getInstanceWithProvider(instanceId); @@ -83,9 +106,22 @@ export async function clearAgentSession( // Get provider record from DB const providerRecord = await services.providers.getById(agentRow.agentProviderId); - // Compute session ID using the same strategy as agent-runner - const sessionStrategy = instance.agentSessionStrategy ?? 'per_chat'; - const sessionId = computeSessionId(sessionStrategy, from, chatId); + const personId = await resolveCleanupPersonId(services, db, instanceId, chatId, from, options.personId); + const identity = resolveKhalSessionId({ + providerSchema: providerRecord.schema, + sessionStrategy: instance.agentSessionStrategy ?? 'per_chat', + from, + chatId, + channel: instance.channel, + instanceId, + personId, + rawPayload: options.rawPayload, + }); + const { sessionId, legacySessionId } = identity; + const hasKhalContext = !!identity.canonicalSessionId || !!identity.environment || !!options.rawPayload?.khalSessionId; + if (providerRecord.schema === 'agno' && hasKhalContext && identity.source === 'legacy') { + throw new Error('Canonical KHAL session resolution failed; refusing blind legacy Agno reset'); + } // Try IAgentProvider.resetSession() first (covers OpenClaw, Agno, Claude, etc.) // Pass chatId so providers that build their own key format (e.g. OpenClaw) @@ -100,7 +136,10 @@ export async function clearAgentSession( const agentProvider = resolveProvider(providerRecord, dispatchInstance, db); if (agentProvider?.resetSession) { await agentProvider.resetSession(sessionId, chatId, instanceId); - return { sessionId, sessionStrategy }; + if (legacySessionId !== sessionId) { + await agentProvider.resetSession(legacySessionId, chatId, instanceId); + } + return identity; } // Fallback: direct AgnoOS client @@ -114,9 +153,19 @@ export async function clearAgentSession( defaultTimeoutMs: (providerRecord.defaultTimeout ?? 60) * 1000, }); - await client.deleteSession?.(sessionId); + const primaryDelete = await client.deleteSession?.(sessionId); + const legacyDelete = legacySessionId !== sessionId ? await client.deleteSession?.(legacySessionId) : undefined; + log.info('Agno session delete verified', { + instanceId, + sessionId, + legacySessionId, + primaryStatus: primaryDelete?.status, + primaryExisted: primaryDelete?.existed, + legacyStatus: legacyDelete?.status, + legacyExisted: legacyDelete?.existed, + }); - return { sessionId, sessionStrategy }; + return identity; } /** @@ -135,14 +184,14 @@ async function handleTrashEmojiMessage( db: Database, event: TypedOmniEvent<'message.received'>, ): Promise { - const { content, chatId, from } = event.payload; + const { content, chatId } = event.payload; const { instanceId } = event.metadata; if (!instanceId || !content?.text) return; if (!isTrashEmojiOnly(content.text)) return; const result = await withIdempotency(db, event.id, 'session-cleaner', async () => { - await runTrashEmojiCleanup(services, db, instanceId, chatId, from); + await runTrashEmojiCleanup(services, db, event); }); if (!result.executed) { @@ -157,16 +206,30 @@ async function handleTrashEmojiMessage( async function runTrashEmojiCleanup( services: Services, db: Database, - instanceId: string, - chatId: string, - from: string, + event: TypedOmniEvent<'message.received'>, ): Promise { - log.info('Trash emoji detected, clearing session', { instanceId, chatId, from }); + const { chatId, from, rawPayload } = event.payload; + const { instanceId, personId } = event.metadata; + if (!instanceId) return; + + log.info('Trash emoji detected, clearing session', { instanceId, chatId, from, personId }); try { - const { sessionId, sessionStrategy } = await clearAgentSession(services, db, instanceId, from, chatId); + const identity = await clearAgentSession(services, db, instanceId, from, chatId, { personId, rawPayload }); + const { sessionId, legacySessionId, sessionStrategy, source, canonicalSessionId, environment, channelSegment } = + identity; - log.info('Session cleared successfully', { instanceId, sessionId, sessionStrategy }); + log.info('Session cleared successfully', { + instanceId, + sessionId, + legacySessionId, + sessionStrategy, + source, + canonicalSessionId, + personId: identity.personId, + environment, + channelSegment, + }); // Disarm any active follow-up sequence — clearing the session means the // user has explicitly reset the conversation; queued follow-ups referencing diff --git a/packages/api/src/services/__tests__/agent-session-identity.test.ts b/packages/api/src/services/__tests__/agent-session-identity.test.ts new file mode 100644 index 000000000..02370b66d --- /dev/null +++ b/packages/api/src/services/__tests__/agent-session-identity.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'bun:test'; +import { + buildCanonicalKhalSessionId, + extractKhalSessionIdFromRawPayload, + resolveKhalSessionId, +} from '../agent-session-identity'; + +describe('agent session identity', () => { + it('extracts explicit KHAL session ids from rawPayload and headers', () => { + expect(extractKhalSessionIdFromRawPayload({ khalSessionId: ' khal-session-123 ' })).toBe('khal-session-123'); + expect(extractKhalSessionIdFromRawPayload({ headers: { 'x-khal-session-id': ' khal-session-456 ' } })).toBe( + 'khal-session-456', + ); + }); + + it('builds canonical HML Gupshup session ids for Agno/KHAL dispatch and cleanup', () => { + const resolved = resolveKhalSessionId({ + providerSchema: 'agno', + sessionStrategy: 'per_chat', + from: '5547996094523', + chatId: '5547996094523', + channel: 'whatsapp-gupshup', + instanceId: 'c88f18fd-3e0a-49ed-9835-efd2c2be3988', + personId: '8e0b8253-7221-4756-af64-dece4f25a71d', + rawPayload: { headers: { 'x-khal-env': 'hml' } }, + }); + + expect(resolved.source).toBe('canonical-khal'); + expect(resolved.sessionId).toBe( + 'khal:hml:omni:c88f18fd-3e0a-49ed-9835-efd2c2be3988:gupshup:8e0b8253-7221-4756-af64-dece4f25a71d', + ); + expect(resolved.legacySessionId).toBe('5547996094523'); + }); + + it('builds prod namespace canonical session ids from the same resolver', () => { + expect( + buildCanonicalKhalSessionId({ + environment: 'prod', + instanceId: 'prod-instance', + channelSegment: 'gupshup', + personId: 'person-123', + }), + ).toBe('khal:prod:omni:prod-instance:gupshup:person-123'); + }); + + it('preserves legacy computed ids for non-KHAL/non-Agno providers', () => { + const resolved = resolveKhalSessionId({ + providerSchema: 'openclaw', + sessionStrategy: 'per_chat', + from: '5511999999999', + chatId: '5511999999999', + channel: 'whatsapp-gupshup', + instanceId: 'inst-1', + personId: 'person-1', + rawPayload: { headers: { 'x-khal-env': 'hml' } }, + }); + + expect(resolved.source).toBe('legacy'); + expect(resolved.sessionId).toBe('5511999999999'); + }); +}); diff --git a/packages/api/src/services/agent-session-identity.ts b/packages/api/src/services/agent-session-identity.ts new file mode 100644 index 000000000..82f5032a8 --- /dev/null +++ b/packages/api/src/services/agent-session-identity.ts @@ -0,0 +1,159 @@ +import type { ChannelType, ProviderSchema } from '@omni/db'; + +declare const process: { env: Record }; + +type AgentSessionStrategyLike = 'per_user' | 'per_chat' | 'per_thread' | string; + +function computeLegacySessionId( + strategy: AgentSessionStrategyLike, + userId: string, + chatId: string, + threadId?: string, +): string { + switch (strategy) { + case 'per_user': + return userId; + case 'per_chat': + return chatId; + case 'per_thread': + return `thread:${chatId}:${threadId ?? chatId}`; + default: + return `${userId}:${chatId}`; + } +} + +export interface KhalSessionIdentityInput { + providerSchema?: ProviderSchema | string; + sessionStrategy?: string; + from: string; + chatId: string; + channel?: ChannelType | string; + instanceId: string; + personId?: string | null; + rawPayload?: Record | null; + environment?: string | null; + threadId?: string; +} + +export interface ResolvedAgentSessionIdentity { + sessionId: string; + legacySessionId: string; + sessionStrategy: string; + canonicalSessionId?: string; + personId?: string; + environment?: string; + channelSegment?: string; + source: 'explicit-khal' | 'canonical-khal' | 'legacy'; +} + +function readHeader(headers: unknown, name: string): string | undefined { + if (!headers || typeof headers !== 'object') return undefined; + const headerMap = headers as Record; + const lowerName = name.toLowerCase(); + for (const [key, value] of Object.entries(headerMap)) { + if (key.toLowerCase() === lowerName && typeof value === 'string' && value.trim()) return value.trim(); + } + return undefined; +} + +function readStringField(source: Record | null | undefined, names: string[]): string | undefined { + if (!source) return undefined; + for (const name of names) { + const value = source[name]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return undefined; +} + +export function extractKhalSessionIdFromRawPayload(rawPayload?: Record | null): string | undefined { + const direct = readStringField(rawPayload, ['khalSessionId', 'khal_session_id', 'session_id']); + if (direct) return direct; + return readHeader(rawPayload?.headers, 'x-khal-session-id'); +} + +function resolveKhalEnvironment( + rawPayload?: Record | null, + explicitEnvironment?: string | null, +): string | undefined { + const explicit = explicitEnvironment?.trim(); + if (explicit) return explicit; + + const fromPayload = readStringField(rawPayload, ['khalEnv', 'khalEnvironment', 'environment', 'env']); + if (fromPayload) return fromPayload; + + return ( + readHeader(rawPayload?.headers, 'x-khal-env') ?? + readHeader(rawPayload?.headers, 'x-khal-environment') ?? + process.env.KHAL_SESSION_ENV?.trim() ?? + process.env.KHAL_ENV?.trim() ?? + process.env.OMNI_ENV?.trim() + ); +} + +function channelToKhalSessionSegment(channel?: ChannelType | string): string | undefined { + if (!channel) return undefined; + if (channel === 'whatsapp-gupshup') return 'gupshup'; + if (channel === 'whatsapp-cloud') return 'whatsapp'; + if (channel === 'whatsapp-baileys') return 'whatsapp'; + return String(channel).replace(/^channel-/, ''); +} + +export function buildCanonicalKhalSessionId(input: { + environment: string; + instanceId: string; + channelSegment: string; + personId: string; +}): string { + return `khal:${input.environment}:omni:${input.instanceId}:${input.channelSegment}:${input.personId}`; +} + +export function resolveKhalSessionId(input: KhalSessionIdentityInput): ResolvedAgentSessionIdentity { + const sessionStrategy = input.sessionStrategy ?? 'per_chat'; + const legacySessionId = computeLegacySessionId(sessionStrategy, input.from, input.chatId, input.threadId); + const explicitKhalSessionId = extractKhalSessionIdFromRawPayload(input.rawPayload); + if (explicitKhalSessionId) { + return { + sessionId: explicitKhalSessionId, + legacySessionId, + sessionStrategy, + canonicalSessionId: explicitKhalSessionId, + personId: input.personId ?? undefined, + environment: resolveKhalEnvironment(input.rawPayload, input.environment), + channelSegment: channelToKhalSessionSegment(input.channel), + source: 'explicit-khal', + }; + } + + const environment = resolveKhalEnvironment(input.rawPayload, input.environment); + const channelSegment = channelToKhalSessionSegment(input.channel); + const personId = input.personId?.trim(); + + if (input.providerSchema === 'agno' && environment && channelSegment && personId) { + const canonicalSessionId = buildCanonicalKhalSessionId({ + environment, + instanceId: input.instanceId, + channelSegment, + personId, + }); + return { + sessionId: canonicalSessionId, + legacySessionId, + sessionStrategy, + canonicalSessionId, + personId, + environment, + channelSegment, + source: 'canonical-khal', + }; + } + + return { + sessionId: legacySessionId, + legacySessionId, + sessionStrategy, + personId: personId ?? undefined, + environment, + channelSegment, + source: 'legacy', + }; +} diff --git a/packages/channel-a2a/package.json b/packages/channel-a2a/package.json index c6ffa61d2..454aacd3b 100644 --- a/packages/channel-a2a/package.json +++ b/packages/channel-a2a/package.json @@ -1,6 +1,6 @@ { "name": "@omni/channel-a2a", - "version": "2.260603.1", + "version": "2.260603.3", "description": "A2A protocol server channel for Omni", "type": "module", "main": "src/index.ts", diff --git a/packages/channel-discord/package.json b/packages/channel-discord/package.json index 06e7df6dd..2900a5d2e 100644 --- a/packages/channel-discord/package.json +++ b/packages/channel-discord/package.json @@ -1,6 +1,6 @@ { "name": "@omni/channel-discord", - "version": "2.260603.1", + "version": "2.260603.3", "description": "Discord channel plugin for Omni using discord.js", "type": "module", "main": "src/index.ts", diff --git a/packages/channel-gupshup/package.json b/packages/channel-gupshup/package.json index 7a7def365..00f3049fd 100644 --- a/packages/channel-gupshup/package.json +++ b/packages/channel-gupshup/package.json @@ -1,6 +1,6 @@ { "name": "@omni/channel-gupshup", - "version": "2.260603.1", + "version": "2.260603.3", "description": "Gupshup WhatsApp BSP channel plugin for Omni", "type": "module", "main": "src/index.ts", diff --git a/packages/channel-internal/package.json b/packages/channel-internal/package.json index fde7ced40..3f8facc0c 100644 --- a/packages/channel-internal/package.json +++ b/packages/channel-internal/package.json @@ -1,6 +1,6 @@ { "name": "@omni/channel-internal", - "version": "2.260603.1", + "version": "2.260603.3", "description": "Internal agent-to-agent routing channel for Omni", "type": "module", "main": "src/index.ts", diff --git a/packages/channel-sdk/package.json b/packages/channel-sdk/package.json index 11c4c06b8..c484ffcab 100644 --- a/packages/channel-sdk/package.json +++ b/packages/channel-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@omni/channel-sdk", - "version": "2.260603.1", + "version": "2.260603.3", "type": "module", "exports": { ".": { diff --git a/packages/channel-slack/package.json b/packages/channel-slack/package.json index ed6ff2991..0839d834e 100644 --- a/packages/channel-slack/package.json +++ b/packages/channel-slack/package.json @@ -1,6 +1,6 @@ { "name": "@omni/channel-slack", - "version": "2.260603.1", + "version": "2.260603.3", "description": "Slack channel plugin for Omni using Bolt.js with Socket Mode", "type": "module", "main": "src/index.ts", diff --git a/packages/channel-telegram/package.json b/packages/channel-telegram/package.json index 9eb707dca..22f93ecd4 100644 --- a/packages/channel-telegram/package.json +++ b/packages/channel-telegram/package.json @@ -1,6 +1,6 @@ { "name": "@omni/channel-telegram", - "version": "2.260603.1", + "version": "2.260603.3", "description": "Telegram channel plugin for Omni using grammy", "type": "module", "main": "src/index.ts", diff --git a/packages/channel-twilio-whatsapp/package.json b/packages/channel-twilio-whatsapp/package.json index 2dbca03c8..ba7bc15fa 100644 --- a/packages/channel-twilio-whatsapp/package.json +++ b/packages/channel-twilio-whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@omni/channel-twilio-whatsapp", - "version": "2.260603.1", + "version": "2.260603.3", "description": "Twilio WhatsApp channel plugin for Omni", "type": "module", "main": "src/index.ts", diff --git a/packages/channel-whatsapp/package.json b/packages/channel-whatsapp/package.json index edbfb5069..6b2d55075 100644 --- a/packages/channel-whatsapp/package.json +++ b/packages/channel-whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@omni/channel-whatsapp", - "version": "2.260603.1", + "version": "2.260603.3", "description": "WhatsApp channel plugin for Omni using Baileys", "type": "module", "main": "src/index.ts", diff --git a/packages/cli/package.json b/packages/cli/package.json index 6252e112d..b56438869 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@automagik/omni", - "version": "2.260603.1", + "version": "2.260603.3", "description": "LLM-optimized CLI for Omni", "type": "module", "bin": { diff --git a/packages/core/package.json b/packages/core/package.json index 69bae631a..9a5522de4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@omni/core", - "version": "2.260603.1", + "version": "2.260603.3", "type": "module", "exports": { ".": { diff --git a/packages/core/src/providers/__tests__/agno-client.test.ts b/packages/core/src/providers/__tests__/agno-client.test.ts index 899ed4f11..2852df45e 100644 --- a/packages/core/src/providers/__tests__/agno-client.test.ts +++ b/packages/core/src/providers/__tests__/agno-client.test.ts @@ -637,6 +637,33 @@ describe('AgnoClient', () => { }); }); + // --- IAgentClient interface: deleteSession() --- + + describe('deleteSession', () => { + it('returns a verified delete result on success', async () => { + mockImpl.mockResolvedValueOnce(new Response(null, { status: 204 })); + + const client = new AgnoClient(config); + const result = await client.deleteSession('khal:hml:omni:inst:gupshup:person'); + + expect(result).toEqual({ + ok: true, + status: 204, + sessionId: 'khal:hml:omni:inst:gupshup:person', + existed: true, + }); + }); + + it('treats a missing session as a verified no-op instead of throwing', async () => { + mockImpl.mockResolvedValueOnce(new Response('missing', { status: 404 })); + + const client = new AgnoClient(config); + const result = await client.deleteSession('legacy-phone-session'); + + expect(result).toEqual({ ok: true, status: 404, sessionId: 'legacy-phone-session', existed: false }); + }); + }); + // --- Authentication --- describe('authentication', () => { diff --git a/packages/core/src/providers/agno-client.ts b/packages/core/src/providers/agno-client.ts index 046b39b1d..079bc059b 100644 --- a/packages/core/src/providers/agno-client.ts +++ b/packages/core/src/providers/agno-client.ts @@ -17,6 +17,7 @@ import { ProviderError, type ProviderRequest, type ProviderResponse, + type SessionDeleteResult, type StreamChunk, } from './types'; @@ -519,7 +520,7 @@ export class AgnoClient implements IAgentClient { // --- Session Management --- - async deleteSession(sessionId: string): Promise { + async deleteSession(sessionId: string): Promise { const url = `${this.baseUrl}/sessions/${encodeURIComponent(sessionId)}`; const response = await this.fetchWithTimeout( url, @@ -527,9 +528,15 @@ export class AgnoClient implements IAgentClient { this.defaultTimeoutMs, ); + if (response.status === 404) { + return { ok: true, status: response.status, sessionId, existed: false }; + } + if (!response.ok) { this.handleErrorResponse(response, `deleting session ${sessionId}`); } + + return { ok: true, status: response.status, sessionId, existed: true }; } } diff --git a/packages/core/src/providers/types.ts b/packages/core/src/providers/types.ts index e7f45b3b9..35964c799 100644 --- a/packages/core/src/providers/types.ts +++ b/packages/core/src/providers/types.ts @@ -210,6 +210,13 @@ export interface AgnoWorkflow { description?: string; } +export interface SessionDeleteResult { + ok: boolean; + status: number; + sessionId: string; + existed: boolean | undefined; +} + /** * Generic agent client interface * @@ -230,7 +237,7 @@ export interface IAgentClient { checkHealth(): Promise; /** Optional: delete a session (clear conversation history) */ - deleteSession?(sessionId: string): Promise; + deleteSession?(sessionId: string): Promise; } /** diff --git a/packages/db/package.json b/packages/db/package.json index ebe91c3da..e633a7583 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@omni/db", - "version": "2.260603.1", + "version": "2.260603.3", "type": "module", "exports": { ".": { diff --git a/packages/media-processing/package.json b/packages/media-processing/package.json index bb815fb8c..5d75c869c 100644 --- a/packages/media-processing/package.json +++ b/packages/media-processing/package.json @@ -1,6 +1,6 @@ { "name": "@omni/media-processing", - "version": "2.260603.1", + "version": "2.260603.3", "type": "module", "exports": { ".": { diff --git a/packages/plugin-openclaw/package.json b/packages/plugin-openclaw/package.json index 2af86ee42..134031476 100644 --- a/packages/plugin-openclaw/package.json +++ b/packages/plugin-openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@omni/plugin-openclaw", - "version": "2.260603.1", + "version": "2.260603.3", "description": "Expose Omni as a native messaging channel in OpenClaw", "type": "module", "main": "src/index.ts", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index e2897c071..4ba8495ff 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@omni/sdk", - "version": "2.260603.1", + "version": "2.260603.3", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/voice-client/package.json b/packages/voice-client/package.json index 09d069666..e1a4bb6fe 100644 --- a/packages/voice-client/package.json +++ b/packages/voice-client/package.json @@ -1,6 +1,6 @@ { "name": "@omni/voice-client", - "version": "2.260603.1", + "version": "2.260603.3", "type": "module", "exports": { ".": { diff --git a/plugins/omni/.claude-plugin/plugin.json b/plugins/omni/.claude-plugin/plugin.json index 6919bb287..05c7ed995 100644 --- a/plugins/omni/.claude-plugin/plugin.json +++ b/plugins/omni/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "omni", - "version": "2.260603.1", + "version": "2.260603.3", "description": "Full Omni platform control — three-tier skill system for agents (omni-agent), first-time setup (omni-setup), and platform ops (omni-ops)", "author": { "name": "Automagik"