From aec41a588bb25c78cbff27fc8a4a69c8e6c19eaf Mon Sep 17 00:00:00 2001 From: chilu18 Date: Tue, 24 Feb 2026 13:48:12 +0000 Subject: [PATCH] fix(hooks): backfill reset command hooks for native /new path --- src/auto-reply/reply/commands-core.ts | 168 +++++++----- src/auto-reply/reply/commands-types.ts | 2 + src/auto-reply/reply/commands.test.ts | 31 +++ .../get-reply.reset-hooks-fallback.test.ts | 251 ++++++++++++++++++ src/auto-reply/reply/get-reply.ts | 24 ++ 5 files changed, 406 insertions(+), 70 deletions(-) create mode 100644 src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index 40f1d49e75b..229cf7f9eb1 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -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 { + 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 { if (HANDLERS === null) { HANDLERS = [ @@ -79,79 +169,17 @@ export async function handleCommands(params: HandleCommandsParams): Promise 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({ diff --git a/src/auto-reply/reply/commands-types.ts b/src/auto-reply/reply/commands-types.ts index 6ff476b8c20..4662bf12a22 100644 --- a/src/auto-reply/reply/commands-types.ts +++ b/src/auto-reply/reply/commands-types.ts @@ -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 = { diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 0c4d40ec7eb..921081921e0 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -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", () => { diff --git a/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts b/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts new file mode 100644 index 00000000000..3129bb61cbb --- /dev/null +++ b/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts @@ -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(); + }); +}); diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index bca4cb3ce8f..5c4edd35ac1 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -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;