diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d0e2ba835f..673883e848d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Plugin SDK/channel extensibility: expose `channelRuntime` on `ChannelGatewayContext` so external channel plugins can access shared runtime helpers (reply/routing/session/text/media/commands) without internal imports. (#25462) Thanks @guxiaobo. - Plugin runtime/events: expose `runtime.events.onAgentEvent` and `runtime.events.onSessionTranscriptUpdate` for extension-side subscriptions, and isolate transcript-listener failures so one faulty listener cannot break the entire update fanout. (#16044) Thanks @scifantastic. - Plugin runtime/system: expose `runtime.system.requestHeartbeatNow(...)` so extensions can wake targeted sessions immediately after enqueueing system events. (#19464) Thanks @AustinEral. +- Plugin hooks/session lifecycle: include `sessionKey` in `session_start`/`session_end` hook events and contexts so plugins can correlate lifecycle callbacks with routing identity. (#26394) Thanks @tempeste. - Sessions/Attachments: add inline file attachment support for `sessions_spawn` (subagent runtime only) with base64/utf8 encoding, transcript content redaction, lifecycle cleanup, and configurable limits via `tools.sessions_spawn.attachments`. (#16761) Thanks @napetrov. - Tools/PDF analysis: add a first-class `pdf` tool with native Anthropic and Google PDF provider support, extraction fallback for non-native models, configurable defaults (`agents.defaults.pdfModel`, `pdfMaxBytesMb`, `pdfMaxPages`), and docs/tests covering routing, validation, and registration. (#31319) Thanks @tyler6204. - Zalo Personal plugin (`@openclaw/zalouser`): rebuilt channel runtime to use native `zca-js` integration in-process, removing external CLI transport usage and keeping QR/login + send/listen flows fully inside OpenClaw. diff --git a/src/auto-reply/reply/session-hooks-context.test.ts b/src/auto-reply/reply/session-hooks-context.test.ts new file mode 100644 index 00000000000..c3b0ae6cc2d --- /dev/null +++ b/src/auto-reply/reply/session-hooks-context.test.ts @@ -0,0 +1,95 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { SessionEntry } from "../../config/sessions.js"; +import type { HookRunner } from "../../plugins/hooks.js"; + +const hookRunnerMocks = vi.hoisted(() => ({ + hasHooks: vi.fn(), + runSessionStart: vi.fn(), + runSessionEnd: vi.fn(), +})); + +vi.mock("../../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => + ({ + hasHooks: hookRunnerMocks.hasHooks, + runSessionStart: hookRunnerMocks.runSessionStart, + runSessionEnd: hookRunnerMocks.runSessionEnd, + }) as unknown as HookRunner, +})); + +const { initSessionState } = await import("./session.js"); + +async function createStorePath(prefix: string): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), `${prefix}-`)); + return path.join(root, "sessions.json"); +} + +async function writeStore( + storePath: string, + store: Record>, +): Promise { + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify(store), "utf-8"); +} + +describe("session hook context wiring", () => { + beforeEach(() => { + hookRunnerMocks.hasHooks.mockReset(); + hookRunnerMocks.runSessionStart.mockReset(); + hookRunnerMocks.runSessionEnd.mockReset(); + hookRunnerMocks.runSessionStart.mockResolvedValue(undefined); + hookRunnerMocks.runSessionEnd.mockResolvedValue(undefined); + hookRunnerMocks.hasHooks.mockImplementation( + (hookName) => hookName === "session_start" || hookName === "session_end", + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("passes sessionKey to session_start hook context", async () => { + const sessionKey = "agent:main:telegram:direct:123"; + const storePath = await createStorePath("openclaw-session-hook-start"); + await writeStore(storePath, {}); + const cfg = { session: { store: storePath } } as OpenClawConfig; + + await initSessionState({ + ctx: { Body: "hello", SessionKey: sessionKey }, + cfg, + commandAuthorized: true, + }); + + await vi.waitFor(() => expect(hookRunnerMocks.runSessionStart).toHaveBeenCalledTimes(1)); + const [event, context] = hookRunnerMocks.runSessionStart.mock.calls[0] ?? []; + expect(event).toMatchObject({ sessionKey }); + expect(context).toMatchObject({ sessionKey }); + }); + + it("passes sessionKey to session_end hook context on reset", async () => { + const sessionKey = "agent:main:telegram:direct:123"; + const storePath = await createStorePath("openclaw-session-hook-end"); + await writeStore(storePath, { + [sessionKey]: { + sessionId: "old-session", + updatedAt: Date.now(), + }, + }); + const cfg = { session: { store: storePath } } as OpenClawConfig; + + await initSessionState({ + ctx: { Body: "/new", SessionKey: sessionKey }, + cfg, + commandAuthorized: true, + }); + + await vi.waitFor(() => expect(hookRunnerMocks.runSessionEnd).toHaveBeenCalledTimes(1)); + const [event, context] = hookRunnerMocks.runSessionEnd.mock.calls[0] ?? []; + expect(event).toMatchObject({ sessionKey }); + expect(context).toMatchObject({ sessionKey }); + }); +});