fix(slash): persist channel metadata from slash command sessions (#23065)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 29fa20c7d7
Co-authored-by: hydro13 <6640526+hydro13@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
This commit is contained in:
Robin Waslander
2026-02-22 05:59:06 +01:00
committed by GitHub
parent 6d11b46994
commit daf036a4f6
6 changed files with 271 additions and 1 deletions

View File

@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
- Telegram/Streaming: preserve archived draft preview mapping after flush and clean superseded reasoning preview bubbles so multi-message preview finals no longer cross-edit or orphan stale messages under send/rotation races. (#23202) Thanks @obviyus.
- Slack/Slash commands: preserve the Bolt app receiver when registering external select options handlers so monitor startup does not crash on runtimes that require bound `app.options` calls. (#23209) Thanks @0xgaia.
- Slack/Telegram slash sessions: await session metadata persistence before dispatch so first-turn native slash runs do not race session-origin metadata updates. (#23065) thanks @hydro13.
- Agents/Ollama: preserve unsafe integer tool-call arguments as exact strings during NDJSON parsing, preventing large numeric IDs from being rounded before tool execution. (#23170) Thanks @BestJoester.
- Cron/Gateway: keep `cron.list` and `cron.status` responsive during startup catch-up by avoiding a long-held cron lock while missed jobs execute. (#23106) Thanks @jayleekr.
- Gateway/Config reload: compare array-valued config paths structurally during diffing so unchanged `memory.qmd.paths` and `memory.qmd.scope.rules` no longer trigger false restart-required reloads. (#23185) Thanks @rex05ai.

View File

@@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({
finalizeInboundContextMock: vi.fn(),
resolveConversationLabelMock: vi.fn(),
createReplyPrefixOptionsMock: vi.fn(),
recordSessionMetaFromInboundMock: vi.fn(),
resolveStorePathMock: vi.fn(),
}));
vi.mock("../../auto-reply/reply/provider-dispatcher.js", () => ({
@@ -35,6 +37,12 @@ vi.mock("../../channels/reply-prefix.js", () => ({
createReplyPrefixOptions: (...args: unknown[]) => mocks.createReplyPrefixOptionsMock(...args),
}));
vi.mock("../../config/sessions.js", () => ({
recordSessionMetaFromInbound: (...args: unknown[]) =>
mocks.recordSessionMetaFromInboundMock(...args),
resolveStorePath: (...args: unknown[]) => mocks.resolveStorePathMock(...args),
}));
type SlashHarnessMocks = {
dispatchMock: ReturnType<typeof vi.fn>;
readAllowFromStoreMock: ReturnType<typeof vi.fn>;
@@ -43,6 +51,8 @@ type SlashHarnessMocks = {
finalizeInboundContextMock: ReturnType<typeof vi.fn>;
resolveConversationLabelMock: ReturnType<typeof vi.fn>;
createReplyPrefixOptionsMock: ReturnType<typeof vi.fn>;
recordSessionMetaFromInboundMock: ReturnType<typeof vi.fn>;
resolveStorePathMock: ReturnType<typeof vi.fn>;
};
export function getSlackSlashMocks(): SlashHarnessMocks {
@@ -61,4 +71,6 @@ export function resetSlackSlashMocks() {
mocks.finalizeInboundContextMock.mockReset().mockImplementation((ctx: unknown) => ctx);
mocks.resolveConversationLabelMock.mockReset().mockReturnValue(undefined);
mocks.createReplyPrefixOptionsMock.mockReset().mockReturnValue({ onModelSelected: () => {} });
mocks.recordSessionMetaFromInboundMock.mockReset().mockResolvedValue(undefined);
mocks.resolveStorePathMock.mockReset().mockReturnValue("/tmp/openclaw-sessions.json");
}

View File

@@ -210,6 +210,14 @@ function findFirstActionsBlock(payload: { blocks?: Array<{ type: string }> }) {
| undefined;
}
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
function createArgMenusHarness() {
const commands = new Map<string, (args: unknown) => Promise<void>>();
const actions = new Map<string, (args: unknown) => Promise<void>>();
@@ -859,3 +867,47 @@ describe("slack slash commands access groups", () => {
expectUnauthorizedResponse(respond);
});
});
describe("slack slash command session metadata", () => {
const { recordSessionMetaFromInboundMock } = getSlackSlashMocks();
it("calls recordSessionMetaFromInbound after dispatching a slash command", async () => {
const harness = createPolicyHarness({ groupPolicy: "open" });
await registerAndRunPolicySlash({ harness });
expect(dispatchMock).toHaveBeenCalledTimes(1);
expect(recordSessionMetaFromInboundMock).toHaveBeenCalledTimes(1);
const call = recordSessionMetaFromInboundMock.mock.calls[0]?.[0] as {
sessionKey?: string;
ctx?: { OriginatingChannel?: string };
};
expect(call.ctx?.OriginatingChannel).toBe("slack");
expect(call.sessionKey).toBeDefined();
});
it("awaits session metadata persistence before dispatch", async () => {
const deferred = createDeferred<void>();
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);
});
});

View File

@@ -539,9 +539,14 @@ export async function registerSlackMonitorSlashCommands(params: {
import("../../auto-reply/reply/inbound-context.js"),
import("../../auto-reply/reply/provider-dispatcher.js"),
]);
const [{ resolveConversationLabel }, { createReplyPrefixOptions }] = await Promise.all([
const [
{ resolveConversationLabel },
{ createReplyPrefixOptions },
{ recordSessionMetaFromInbound, resolveStorePath },
] = await Promise.all([
import("../../channels/conversation-label.js"),
import("../../channels/reply-prefix.js"),
import("../../config/sessions.js"),
]);
const route = resolveAgentRoute({
@@ -605,6 +610,19 @@ export async function registerSlackMonitorSlashCommands(params: {
OriginatingTo: `user:${command.user_id}`,
});
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
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,
agentId: route.agentId,

View File

@@ -0,0 +1,173 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { TelegramAccountConfig } from "../config/types.js";
import type { RuntimeEnv } from "../runtime.js";
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
// All mocks scoped to this file only — does not affect bot-native-commands.test.ts
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,
resolveStorePath: sessionMocks.resolveStorePath,
}));
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: vi.fn(async () => []),
}));
vi.mock("../auto-reply/reply/inbound-context.js", () => ({
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
}));
vi.mock("../auto-reply/reply/provider-dispatcher.js", () => ({
dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher,
}));
vi.mock("../channels/reply-prefix.js", () => ({
createReplyPrefixOptions: vi.fn(() => ({ onModelSelected: () => {} })),
}));
vi.mock("../auto-reply/skill-commands.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../auto-reply/skill-commands.js")>();
return { ...actual, listSkillCommandsForAgents: vi.fn(() => []) };
});
vi.mock("../plugins/commands.js", () => ({
getPluginCommandSpecs: vi.fn(() => []),
matchPluginCommand: vi.fn(() => null),
executePluginCommand: vi.fn(async () => ({ text: "ok" })),
}));
vi.mock("./bot/delivery.js", () => ({
deliverReplies: vi.fn(async () => ({ delivered: true })),
}));
const buildParams = (cfg: OpenClawConfig, accountId = "default") => ({
bot: {
api: {
setMyCommands: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue(undefined),
},
command: vi.fn(),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
cfg,
runtime: {} as unknown as RuntimeEnv,
accountId,
telegramCfg: {} as TelegramAccountConfig,
allowFrom: [],
groupAllowFrom: [],
replyToMode: "off" as const,
textLimit: 4096,
useAccessGroups: false,
nativeEnabled: true,
nativeSkillsEnabled: true,
nativeDisabledExplicit: false,
resolveGroupPolicy: () => ({ allowlistEnabled: false, allowed: true }),
resolveTelegramGroupConfig: () => ({
groupConfig: undefined,
topicConfig: undefined,
}),
shouldSkipUpdate: () => false,
opts: { token: "token" },
});
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
describe("registerTelegramNativeCommands — session metadata", () => {
it("calls recordSessionMetaFromInbound after a native slash command", async () => {
sessionMocks.recordSessionMetaFromInbound.mockReset().mockResolvedValue(undefined);
sessionMocks.resolveStorePath.mockReset().mockReturnValue("/tmp/openclaw-sessions.json");
const commandHandlers = new Map<string, (ctx: unknown) => Promise<void>>();
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<void>) => {
commandHandlers.set(name, cb);
}),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
});
const handler = commandHandlers.get("status");
expect(handler).toBeTruthy();
await handler?.({
match: "",
message: {
message_id: 1,
date: Math.floor(Date.now() / 1000),
chat: { id: 100, type: "private" },
from: { id: 200, username: "bob" },
},
});
expect(sessionMocks.recordSessionMetaFromInbound).toHaveBeenCalledTimes(1);
const call = (
sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array<
[{ sessionKey?: string; ctx?: { OriginatingChannel?: string } }]
>
)[0]?.[0];
expect(call?.ctx?.OriginatingChannel).toBe("telegram");
expect(call?.sessionKey).toBeDefined();
});
it("awaits session metadata persistence before dispatch", async () => {
const deferred = createDeferred<void>();
sessionMocks.recordSessionMetaFromInbound.mockReset().mockReturnValue(deferred.promise);
sessionMocks.resolveStorePath.mockReset().mockReturnValue("/tmp/openclaw-sessions.json");
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockReset().mockResolvedValue(undefined);
const commandHandlers = new Map<string, (ctx: unknown) => Promise<void>>();
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<void>) => {
commandHandlers.set(name, cb);
}),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[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);
});
});

View File

@@ -17,6 +17,7 @@ import { createReplyPrefixOptions } from "../channels/reply-prefix.js";
import type { OpenClawConfig } from "../config/config.js";
import type { ChannelGroupPolicy } from "../config/group-policy.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { recordSessionMetaFromInbound, resolveStorePath } from "../config/sessions.js";
import {
normalizeTelegramCommandName,
resolveTelegramCustomCommands,
@@ -594,6 +595,19 @@ export const registerTelegramNativeCommands = ({
OriginatingTo: `telegram:${chatId}`,
});
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
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"
? !telegramCfg.blockStreaming