diff --git a/src/openclaw/__tests__/runtime-dispatch.test.ts b/src/openclaw/__tests__/runtime-dispatch.test.ts new file mode 100644 index 000000000..941253520 --- /dev/null +++ b/src/openclaw/__tests__/runtime-dispatch.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import * as openclawModule from "../index" +import * as sessionRegistryModule from "../session-registry" +import { dispatchOpenClawEvent } from "../runtime-dispatch" +import type { OpenClawConfig } from "../types" + +function createConfig(hooks: OpenClawConfig["hooks"]): OpenClawConfig { + return { + enabled: true, + gateways: { + gateway: { + type: "http", + url: "https://example.com", + method: "POST", + }, + }, + hooks, + } +} + +afterEach(() => { + mock.restore() +}) + +describe("dispatchOpenClawEvent", () => { + test("falls back from raw session.created to canonical session-start", async () => { + const wakeSpy = spyOn(openclawModule, "wakeOpenClaw") + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ gateway: "gateway", success: true }) + + await dispatchOpenClawEvent({ + config: createConfig({ + "session-start": { enabled: true, gateway: "gateway", instruction: "hi" }, + }), + rawEvent: "session.created", + context: { sessionId: "ses-1", projectPath: "/tmp/project", tmuxPaneId: "%1", tmuxSession: "main" }, + }) + + expect(wakeSpy.mock.calls.map((call) => call[1])).toEqual(["session.created", "session-start"]) + }) + + test("registers reply correlation when wake returns outbound metadata", async () => { + spyOn(openclawModule, "wakeOpenClaw").mockResolvedValue({ + gateway: "gateway", + success: true, + messageId: "msg-1", + platform: "discord", + channelId: "chan-1", + threadId: "thread-1", + }) + const registerSpy = spyOn(sessionRegistryModule, "registerMessage").mockReturnValue(true) + + await dispatchOpenClawEvent({ + config: createConfig({ + "session.created": { enabled: true, gateway: "gateway", instruction: "hi" }, + }), + rawEvent: "session.created", + context: { + sessionId: "ses-1", + projectPath: "/tmp/project", + tmuxPaneId: "%7", + tmuxSession: "session-1", + }, + }) + + const [mapping] = registerSpy.mock.calls[0] ?? [] + expect(mapping).toMatchObject({ + sessionId: "ses-1", + tmuxPaneId: "%7", + tmuxSession: "session-1", + projectPath: "/tmp/project", + platform: "discord-bot", + messageId: "msg-1", + channelId: "chan-1", + threadId: "thread-1", + }) + }) + + test("cleans up session mappings on session.deleted", async () => { + spyOn(openclawModule, "wakeOpenClaw").mockResolvedValue(null) + const removeSpy = spyOn(sessionRegistryModule, "removeSession").mockImplementation(() => {}) + + await dispatchOpenClawEvent({ + config: createConfig({}), + rawEvent: "session.deleted", + context: { sessionId: "ses-2", projectPath: "/tmp/project" }, + }) + + expect(removeSpy).toHaveBeenCalledWith("ses-2") + }) +}) diff --git a/src/openclaw/runtime-dispatch.ts b/src/openclaw/runtime-dispatch.ts new file mode 100644 index 000000000..79500d343 --- /dev/null +++ b/src/openclaw/runtime-dispatch.ts @@ -0,0 +1,89 @@ +import * as openclaw from "./index" +import { registerMessage, removeSession } from "./session-registry" +import { getCurrentTmuxSession } from "./tmux" +import type { OpenClawConfig, WakeResult } from "./types" + +interface DispatchOpenClawContext { + sessionId?: string + projectPath?: string + tmuxPaneId?: string + tmuxSession?: string + replyChannel?: string + replyTarget?: string + replyThread?: string +} + +interface DispatchOpenClawEventParams { + config: OpenClawConfig + rawEvent: string + context: DispatchOpenClawContext +} + +function mapRawEventToOpenClawEvents(rawEvent: string): string[] { + const aliases: Record = { + "session.created": "session-start", + "session.deleted": "session-end", + "session.idle": "stop", + } + + const mapped = aliases[rawEvent] + return Array.from(new Set([rawEvent, mapped].filter((value): value is string => Boolean(value)))) +} + +function normalizePlatform(platform?: string): string | undefined { + if (!platform) return undefined + if (platform === "discord") return "discord-bot" + return platform +} + +function shouldRegisterReplyCorrelation(result: WakeResult, params: DispatchOpenClawEventParams): boolean { + if (params.rawEvent === "session.deleted") return false + if (!result.success) return false + if (!result.messageId || !result.platform) return false + if (!params.context.sessionId || !params.context.projectPath || !params.context.tmuxPaneId) return false + return true +} + +export async function dispatchOpenClawEvent( + params: DispatchOpenClawEventParams, +): Promise { + let result: WakeResult | null = null + + if (params.config.enabled) { + for (const event of mapRawEventToOpenClawEvents(params.rawEvent)) { + result = await openclaw.wakeOpenClaw(params.config, event, { + sessionId: params.context.sessionId, + projectPath: params.context.projectPath, + tmuxSession: params.context.tmuxSession, + replyChannel: params.context.replyChannel, + replyTarget: params.context.replyTarget, + replyThread: params.context.replyThread, + }) + if (result !== null) break + } + } + + if (shouldRegisterReplyCorrelation(result ?? { gateway: "", success: false }, params)) { + const tmuxSession = params.context.tmuxSession ?? getCurrentTmuxSession() + const platform = normalizePlatform(result?.platform) + if (tmuxSession && platform && params.context.sessionId && params.context.projectPath && params.context.tmuxPaneId) { + registerMessage({ + sessionId: params.context.sessionId, + tmuxSession, + tmuxPaneId: params.context.tmuxPaneId, + projectPath: params.context.projectPath, + platform, + messageId: result!.messageId!, + channelId: result?.channelId, + threadId: result?.threadId, + createdAt: new Date().toISOString(), + }) + } + } + + if (params.rawEvent === "session.deleted" && params.context.sessionId) { + removeSession(params.context.sessionId) + } + + return result +}