diff --git a/src/slack/monitor/slash.test.ts b/src/slack/monitor/slash.test.ts index eb647e1ade5..8b2aee9e946 100644 --- a/src/slack/monitor/slash.test.ts +++ b/src/slack/monitor/slash.test.ts @@ -210,6 +210,14 @@ function findFirstActionsBlock(payload: { blocks?: Array<{ type: string }> }) { | undefined; } +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + function createArgMenusHarness() { const commands = new Map Promise>(); const actions = new Map Promise>(); @@ -876,4 +884,30 @@ describe("slack slash command session metadata", () => { expect(call.ctx?.OriginatingChannel).toBe("slack"); expect(call.sessionKey).toBeDefined(); }); + + it("awaits session metadata persistence before dispatch", async () => { + const deferred = createDeferred(); + recordSessionMetaFromInboundMock.mockReset().mockReturnValue(deferred.promise); + + const harness = createPolicyHarness({ groupPolicy: "open" }); + await registerCommands(harness.ctx, harness.account); + + const runPromise = runSlashHandler({ + commands: harness.commands, + command: { + channel_id: harness.channelId, + channel_name: harness.channelName, + }, + }); + + await vi.waitFor(() => { + expect(recordSessionMetaFromInboundMock).toHaveBeenCalledTimes(1); + }); + expect(dispatchMock).not.toHaveBeenCalled(); + + deferred.resolve(); + await runPromise; + + expect(dispatchMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index 191a6b0c2c4..4b98b0bbcc6 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -613,13 +613,15 @@ export async function registerSlackMonitorSlashCommands(params: { const storePath = resolveStorePath(cfg.session?.store, { agentId: route.agentId, }); - void recordSessionMetaFromInbound({ - storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - ctx: ctxPayload, - }).catch((err) => { + try { + await recordSessionMetaFromInbound({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + }); + } catch (err) { runtime.error?.(danger(`slack slash: failed updating session meta: ${String(err)}`)); - }); + } const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ cfg, diff --git a/src/telegram/bot-native-commands.session-meta.test.ts b/src/telegram/bot-native-commands.session-meta.test.ts index 05939f304be..5f7e2b55022 100644 --- a/src/telegram/bot-native-commands.session-meta.test.ts +++ b/src/telegram/bot-native-commands.session-meta.test.ts @@ -10,6 +10,9 @@ const sessionMocks = vi.hoisted(() => ({ recordSessionMetaFromInbound: vi.fn(), resolveStorePath: vi.fn(), })); +const replyMocks = vi.hoisted(() => ({ + dispatchReplyWithBufferedBlockDispatcher: vi.fn(async () => undefined), +})); vi.mock("../config/sessions.js", () => ({ recordSessionMetaFromInbound: sessionMocks.recordSessionMetaFromInbound, @@ -22,7 +25,7 @@ vi.mock("../auto-reply/reply/inbound-context.js", () => ({ finalizeInboundContext: vi.fn((ctx: unknown) => ctx), })); vi.mock("../auto-reply/reply/provider-dispatcher.js", () => ({ - dispatchReplyWithBufferedBlockDispatcher: vi.fn(async () => undefined), + dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher, })); vi.mock("../channels/reply-prefix.js", () => ({ createReplyPrefixOptions: vi.fn(() => ({ onModelSelected: () => {} })), @@ -69,6 +72,14 @@ const buildParams = (cfg: OpenClawConfig, accountId = "default") => ({ opts: { token: "token" }, }); +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + describe("registerTelegramNativeCommands — session metadata", () => { it("calls recordSessionMetaFromInbound after a native slash command", async () => { sessionMocks.recordSessionMetaFromInbound.mockReset().mockResolvedValue(undefined); @@ -112,4 +123,51 @@ describe("registerTelegramNativeCommands — session metadata", () => { expect(call?.ctx?.OriginatingChannel).toBe("telegram"); expect(call?.sessionKey).toBeDefined(); }); + + it("awaits session metadata persistence before dispatch", async () => { + const deferred = createDeferred(); + sessionMocks.recordSessionMetaFromInbound.mockReset().mockReturnValue(deferred.promise); + sessionMocks.resolveStorePath.mockReset().mockReturnValue("/tmp/openclaw-sessions.json"); + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockReset().mockResolvedValue(undefined); + + const commandHandlers = new Map Promise>(); + const cfg: OpenClawConfig = {}; + + registerTelegramNativeCommands({ + ...buildParams(cfg), + allowFrom: ["*"], + bot: { + api: { + setMyCommands: vi.fn().mockResolvedValue(undefined), + sendMessage: vi.fn().mockResolvedValue(undefined), + }, + command: vi.fn((name: string, cb: (ctx: unknown) => Promise) => { + commandHandlers.set(name, cb); + }), + } as unknown as Parameters[0]["bot"], + }); + + const handler = commandHandlers.get("status"); + expect(handler).toBeTruthy(); + + const runPromise = handler?.({ + match: "", + message: { + message_id: 1, + date: Math.floor(Date.now() / 1000), + chat: { id: 100, type: "private" }, + from: { id: 200, username: "bob" }, + }, + }); + + await vi.waitFor(() => { + expect(sessionMocks.recordSessionMetaFromInbound).toHaveBeenCalledTimes(1); + }); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + + deferred.resolve(); + await runPromise; + + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index ec73c3f0af7..8bb4d4a9517 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -598,13 +598,15 @@ export const registerTelegramNativeCommands = ({ const storePath = resolveStorePath(cfg.session?.store, { agentId: route.agentId, }); - void recordSessionMetaFromInbound({ - storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - ctx: ctxPayload, - }).catch((err) => { + try { + await recordSessionMetaFromInbound({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + }); + } catch (err) { runtime.error?.(danger(`telegram slash: failed updating session meta: ${String(err)}`)); - }); + } const disableBlockStreaming = typeof telegramCfg.blockStreaming === "boolean"