fix(openclaw): register reply correlation from runtime dispatch

This commit is contained in:
GeonWoo Jeon (Jay)
2026-04-07 22:49:23 +09:00
parent 4241227227
commit efde63f733
2 changed files with 180 additions and 0 deletions
@@ -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")
})
})
+89
View File
@@ -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<string, string> = {
"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<WakeResult | null> {
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
}