fix(hooks): backfill reset command hooks for native /new path

This commit is contained in:
chilu18
2026-02-24 13:48:12 +00:00
committed by Peter Steinberger
parent bbdf895d42
commit aec41a588b
5 changed files with 406 additions and 70 deletions

View File

@@ -39,6 +39,96 @@ import { routeReply } from "./route-reply.js";
let HANDLERS: CommandHandler[] | null = null;
export type ResetCommandAction = "new" | "reset";
export async function emitResetCommandHooks(params: {
action: ResetCommandAction;
ctx: HandleCommandsParams["ctx"];
cfg: HandleCommandsParams["cfg"];
command: Pick<
HandleCommandsParams["command"],
"surface" | "senderId" | "channel" | "from" | "to" | "resetHookTriggered"
>;
sessionKey?: string;
sessionEntry?: HandleCommandsParams["sessionEntry"];
previousSessionEntry?: HandleCommandsParams["previousSessionEntry"];
workspaceDir: string;
}): Promise<void> {
const hookEvent = createInternalHookEvent("command", params.action, params.sessionKey ?? "", {
sessionEntry: params.sessionEntry,
previousSessionEntry: params.previousSessionEntry,
commandSource: params.command.surface,
senderId: params.command.senderId,
cfg: params.cfg, // Pass config for LLM slug generation
});
await triggerInternalHook(hookEvent);
params.command.resetHookTriggered = true;
// Send hook messages immediately if present
if (hookEvent.messages.length > 0) {
// Use OriginatingChannel/To if available, otherwise fall back to command channel/from
// oxlint-disable-next-line typescript/no-explicit-any
const channel = params.ctx.OriginatingChannel || (params.command.channel as any);
// For replies, use 'from' (the sender) not 'to' (which might be the bot itself)
const to = params.ctx.OriginatingTo || params.command.from || params.command.to;
if (channel && to) {
const hookReply = { text: hookEvent.messages.join("\n\n") };
await routeReply({
payload: hookReply,
channel: channel,
to: to,
sessionKey: params.sessionKey,
accountId: params.ctx.AccountId,
threadId: params.ctx.MessageThreadId,
cfg: params.cfg,
});
}
}
// Fire before_reset plugin hook — extract memories before session history is lost
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("before_reset")) {
const prevEntry = params.previousSessionEntry;
const sessionFile = prevEntry?.sessionFile;
// Fire-and-forget: read old session messages and run hook
void (async () => {
try {
const messages: unknown[] = [];
if (sessionFile) {
const content = await fs.readFile(sessionFile, "utf-8");
for (const line of content.split("\n")) {
if (!line.trim()) {
continue;
}
try {
const entry = JSON.parse(line);
if (entry.type === "message" && entry.message) {
messages.push(entry.message);
}
} catch {
// skip malformed lines
}
}
} else {
logVerbose("before_reset: no session file available, firing hook with empty messages");
}
await hookRunner.runBeforeReset(
{ sessionFile, messages, reason: params.action },
{
agentId: params.sessionKey?.split(":")[0] ?? "main",
sessionKey: params.sessionKey,
sessionId: prevEntry?.sessionId,
workspaceDir: params.workspaceDir,
},
);
} catch (err: unknown) {
logVerbose(`before_reset hook failed: ${String(err)}`);
}
})();
}
}
export async function handleCommands(params: HandleCommandsParams): Promise<CommandHandlerResult> {
if (HANDLERS === null) {
HANDLERS = [
@@ -79,79 +169,17 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
// Trigger internal hook for reset/new commands
if (resetRequested && params.command.isAuthorizedSender) {
const commandAction = resetMatch?.[1] ?? "new";
const hookEvent = createInternalHookEvent("command", commandAction, params.sessionKey ?? "", {
const commandAction: ResetCommandAction = resetMatch?.[1] === "reset" ? "reset" : "new";
await emitResetCommandHooks({
action: commandAction,
ctx: params.ctx,
cfg: params.cfg,
command: params.command,
sessionKey: params.sessionKey,
sessionEntry: params.sessionEntry,
previousSessionEntry: params.previousSessionEntry,
commandSource: params.command.surface,
senderId: params.command.senderId,
cfg: params.cfg, // Pass config for LLM slug generation
workspaceDir: params.workspaceDir,
});
await triggerInternalHook(hookEvent);
// Send hook messages immediately if present
if (hookEvent.messages.length > 0) {
// Use OriginatingChannel/To if available, otherwise fall back to command channel/from
// oxlint-disable-next-line typescript/no-explicit-any
const channel = params.ctx.OriginatingChannel || (params.command.channel as any);
// For replies, use 'from' (the sender) not 'to' (which might be the bot itself)
const to = params.ctx.OriginatingTo || params.command.from || params.command.to;
if (channel && to) {
const hookReply = { text: hookEvent.messages.join("\n\n") };
await routeReply({
payload: hookReply,
channel: channel,
to: to,
sessionKey: params.sessionKey,
accountId: params.ctx.AccountId,
threadId: params.ctx.MessageThreadId,
cfg: params.cfg,
});
}
}
// Fire before_reset plugin hook — extract memories before session history is lost
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("before_reset")) {
const prevEntry = params.previousSessionEntry;
const sessionFile = prevEntry?.sessionFile;
// Fire-and-forget: read old session messages and run hook
void (async () => {
try {
const messages: unknown[] = [];
if (sessionFile) {
const content = await fs.readFile(sessionFile, "utf-8");
for (const line of content.split("\n")) {
if (!line.trim()) {
continue;
}
try {
const entry = JSON.parse(line);
if (entry.type === "message" && entry.message) {
messages.push(entry.message);
}
} catch {
// skip malformed lines
}
}
} else {
logVerbose("before_reset: no session file available, firing hook with empty messages");
}
await hookRunner.runBeforeReset(
{ sessionFile, messages, reason: commandAction },
{
agentId: params.sessionKey?.split(":")[0] ?? "main",
sessionKey: params.sessionKey,
sessionId: prevEntry?.sessionId,
workspaceDir: params.workspaceDir,
},
);
} catch (err: unknown) {
logVerbose(`before_reset hook failed: ${String(err)}`);
}
})();
}
}
const allowTextCommands = shouldHandleTextCommands({

View File

@@ -20,6 +20,8 @@ export type CommandContext = {
commandBodyNormalized: string;
from?: string;
to?: string;
/** Internal marker to prevent duplicate reset-hook emission across command pipelines. */
resetHookTriggered?: boolean;
};
export type HandleCommandsParams = {

View File

@@ -890,6 +890,37 @@ describe("handleCommands hooks", () => {
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ type: "command", action: "new" }));
spy.mockRestore();
});
it("triggers hooks for native /new routed to target sessions", async () => {
const cfg = {
commands: { text: true },
channels: { telegram: { allowFrom: ["*"] } },
} as OpenClawConfig;
const params = buildParams("/new", cfg, {
Provider: "telegram",
Surface: "telegram",
CommandSource: "native",
CommandTargetSessionKey: "agent:main:telegram:direct:123",
SessionKey: "telegram:slash:123",
SenderId: "123",
From: "telegram:123",
To: "slash:123",
CommandAuthorized: true,
});
params.sessionKey = "agent:main:telegram:direct:123";
const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue();
await handleCommands(params);
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
type: "command",
action: "new",
sessionKey: "agent:main:telegram:direct:123",
}),
);
spy.mockRestore();
});
});
describe("handleCommands context", () => {

View File

@@ -0,0 +1,251 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MsgContext } from "../templating.js";
const mocks = vi.hoisted(() => ({
resolveReplyDirectives: vi.fn(),
handleInlineActions: vi.fn(),
emitResetCommandHooks: vi.fn(),
initSessionState: vi.fn(),
}));
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentDir: vi.fn(() => "/tmp/agent"),
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
resolveSessionAgentId: vi.fn(() => "main"),
resolveAgentSkillsFilter: vi.fn(() => undefined),
}));
vi.mock("../../agents/model-selection.js", () => ({
resolveModelRefFromString: vi.fn(() => null),
}));
vi.mock("../../agents/timeout.js", () => ({
resolveAgentTimeoutMs: vi.fn(() => 60000),
}));
vi.mock("../../agents/workspace.js", () => ({
DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/workspace",
ensureAgentWorkspace: vi.fn(async () => ({ dir: "/tmp/workspace" })),
}));
vi.mock("../../channels/model-overrides.js", () => ({
resolveChannelModelOverride: vi.fn(() => undefined),
}));
vi.mock("../../config/config.js", () => ({
loadConfig: vi.fn(() => ({})),
}));
vi.mock("../../link-understanding/apply.js", () => ({
applyLinkUnderstanding: vi.fn(async () => undefined),
}));
vi.mock("../../media-understanding/apply.js", () => ({
applyMediaUnderstanding: vi.fn(async () => undefined),
}));
vi.mock("../../runtime.js", () => ({
defaultRuntime: { log: vi.fn() },
}));
vi.mock("../command-auth.js", () => ({
resolveCommandAuthorization: vi.fn(() => ({ isAuthorizedSender: true })),
}));
vi.mock("./commands-core.js", () => ({
emitResetCommandHooks: (...args: unknown[]) => mocks.emitResetCommandHooks(...args),
}));
vi.mock("./directive-handling.js", () => ({
resolveDefaultModel: vi.fn(() => ({
defaultProvider: "openai",
defaultModel: "gpt-4o-mini",
aliasIndex: new Map(),
})),
}));
vi.mock("./get-reply-directives.js", () => ({
resolveReplyDirectives: (...args: unknown[]) => mocks.resolveReplyDirectives(...args),
}));
vi.mock("./get-reply-inline-actions.js", () => ({
handleInlineActions: (...args: unknown[]) => mocks.handleInlineActions(...args),
}));
vi.mock("./get-reply-run.js", () => ({
runPreparedReply: vi.fn(async () => undefined),
}));
vi.mock("./inbound-context.js", () => ({
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
}));
vi.mock("./session-reset-model.js", () => ({
applyResetModelOverride: vi.fn(async () => undefined),
}));
vi.mock("./session.js", () => ({
initSessionState: (...args: unknown[]) => mocks.initSessionState(...args),
}));
vi.mock("./stage-sandbox-media.js", () => ({
stageSandboxMedia: vi.fn(async () => undefined),
}));
vi.mock("./typing.js", () => ({
createTypingController: vi.fn(() => ({
onReplyStart: async () => undefined,
startTypingLoop: async () => undefined,
startTypingOnText: async () => undefined,
refreshTypingTtl: () => undefined,
isActive: () => false,
markRunComplete: () => undefined,
markDispatchIdle: () => undefined,
cleanup: () => undefined,
})),
}));
const { getReplyFromConfig } = await import("./get-reply.js");
function buildNativeResetContext(): MsgContext {
return {
Provider: "telegram",
Surface: "telegram",
ChatType: "direct",
Body: "/new",
RawBody: "/new",
CommandBody: "/new",
CommandSource: "native",
CommandAuthorized: true,
SessionKey: "telegram:slash:123",
CommandTargetSessionKey: "agent:main:telegram:direct:123",
From: "telegram:123",
To: "slash:123",
};
}
describe("getReplyFromConfig reset-hook fallback", () => {
beforeEach(() => {
mocks.resolveReplyDirectives.mockReset();
mocks.handleInlineActions.mockReset();
mocks.emitResetCommandHooks.mockReset();
mocks.initSessionState.mockReset();
mocks.initSessionState.mockResolvedValue({
sessionCtx: buildNativeResetContext(),
sessionEntry: {},
previousSessionEntry: {},
sessionStore: {},
sessionKey: "agent:main:telegram:direct:123",
sessionId: "session-1",
isNewSession: true,
resetTriggered: true,
systemSent: false,
abortedLastRun: false,
storePath: "/tmp/sessions.json",
sessionScope: "per-sender",
groupResolution: undefined,
isGroup: false,
triggerBodyNormalized: "/new",
bodyStripped: "",
});
mocks.resolveReplyDirectives.mockResolvedValue({
kind: "continue",
result: {
commandSource: "/new",
command: {
surface: "telegram",
channel: "telegram",
channelId: "telegram",
ownerList: [],
senderIsOwner: true,
isAuthorizedSender: true,
senderId: "123",
abortKey: "telegram:slash:123",
rawBodyNormalized: "/new",
commandBodyNormalized: "/new",
from: "telegram:123",
to: "slash:123",
resetHookTriggered: false,
},
allowTextCommands: true,
skillCommands: [],
directives: {},
cleanedBody: "/new",
elevatedEnabled: false,
elevatedAllowed: false,
elevatedFailures: [],
defaultActivation: "always",
resolvedThinkLevel: undefined,
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolvedElevatedLevel: "off",
execOverrides: undefined,
blockStreamingEnabled: false,
blockReplyChunking: undefined,
resolvedBlockStreamingBreak: undefined,
provider: "openai",
model: "gpt-4o-mini",
modelState: {
resolveDefaultThinkingLevel: async () => undefined,
},
contextTokens: 0,
inlineStatusRequested: false,
directiveAck: undefined,
perMessageQueueMode: undefined,
perMessageQueueOptions: undefined,
},
});
});
it("emits reset hooks when inline actions return early without marking resetHookTriggered", async () => {
mocks.handleInlineActions.mockResolvedValue({ kind: "reply", reply: undefined });
await getReplyFromConfig(buildNativeResetContext(), undefined, {});
expect(mocks.emitResetCommandHooks).toHaveBeenCalledTimes(1);
expect(mocks.emitResetCommandHooks).toHaveBeenCalledWith(
expect.objectContaining({
action: "new",
sessionKey: "agent:main:telegram:direct:123",
}),
);
});
it("does not emit fallback hooks when resetHookTriggered is already set", async () => {
mocks.handleInlineActions.mockResolvedValue({ kind: "reply", reply: undefined });
mocks.resolveReplyDirectives.mockResolvedValue({
kind: "continue",
result: {
commandSource: "/new",
command: {
surface: "telegram",
channel: "telegram",
channelId: "telegram",
ownerList: [],
senderIsOwner: true,
isAuthorizedSender: true,
senderId: "123",
abortKey: "telegram:slash:123",
rawBodyNormalized: "/new",
commandBodyNormalized: "/new",
from: "telegram:123",
to: "slash:123",
resetHookTriggered: true,
},
allowTextCommands: true,
skillCommands: [],
directives: {},
cleanedBody: "/new",
elevatedEnabled: false,
elevatedAllowed: false,
elevatedFailures: [],
defaultActivation: "always",
resolvedThinkLevel: undefined,
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolvedElevatedLevel: "off",
execOverrides: undefined,
blockStreamingEnabled: false,
blockReplyChunking: undefined,
resolvedBlockStreamingBreak: undefined,
provider: "openai",
model: "gpt-4o-mini",
modelState: {
resolveDefaultThinkingLevel: async () => undefined,
},
contextTokens: 0,
inlineStatusRequested: false,
directiveAck: undefined,
perMessageQueueMode: undefined,
perMessageQueueOptions: undefined,
},
});
await getReplyFromConfig(buildNativeResetContext(), undefined, {});
expect(mocks.emitResetCommandHooks).not.toHaveBeenCalled();
});
});

View File

@@ -16,6 +16,7 @@ import { resolveCommandAuthorization } from "../command-auth.js";
import type { MsgContext } from "../templating.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { emitResetCommandHooks, type ResetCommandAction } from "./commands-core.js";
import { resolveDefaultModel } from "./directive-handling.js";
import { resolveReplyDirectives } from "./get-reply-directives.js";
import { handleInlineActions } from "./get-reply-inline-actions.js";
@@ -272,6 +273,27 @@ export async function getReplyFromConfig(
provider = resolvedProvider;
model = resolvedModel;
const maybeEmitMissingResetHooks = async () => {
if (!resetTriggered || !command.isAuthorizedSender || command.resetHookTriggered) {
return;
}
const resetMatch = command.commandBodyNormalized.match(/^\/(new|reset)(?:\s|$)/);
if (!resetMatch) {
return;
}
const action: ResetCommandAction = resetMatch[1] === "reset" ? "reset" : "new";
await emitResetCommandHooks({
action,
ctx,
cfg,
command,
sessionKey,
sessionEntry,
previousSessionEntry,
workspaceDir,
});
};
const inlineActionResult = await handleInlineActions({
ctx,
sessionCtx,
@@ -311,8 +333,10 @@ export async function getReplyFromConfig(
skillFilter: mergedSkillFilter,
});
if (inlineActionResult.kind === "reply") {
await maybeEmitMissingResetHooks();
return inlineActionResult.reply;
}
await maybeEmitMissingResetHooks();
directives = inlineActionResult.directives;
abortedLastRun = inlineActionResult.abortedLastRun ?? abortedLastRun;