fix: enrich session_end lifecycle hooks (#59715)

Merged via squash.

Prepared head SHA: b3ef62b973
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
Josh Lehman
2026-04-03 00:16:14 -07:00
committed by GitHub
parent 52d8dc5b56
commit 2b28e75822
19 changed files with 801 additions and 20 deletions

View File

@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
- Plugins/runtime: reuse compatible active registries for `web_search` and `web_fetch` provider snapshot resolution so repeated runtime reads do not re-import the same bundled plugin set on each agent message. Related #48380.
- Infra/tailscale: ignore `OPENCLAW_TEST_TAILSCALE_BINARY` outside explicit test environments and block it from workspace `.env`, so test-only binary overrides cannot be injected through trusted repository state. (#58468) Thanks @eleqtrizit.
- Agents/tool policy: preserve restrictive plugin-only allowlists instead of silently widening access to core tools, and keep allowlist warnings aligned with the enforced policy. (#58476) Thanks @eleqtrizit.
- Hooks/session_end: preserve deterministic reason metadata for custom reset aliases and overlapping idle-plus-daily rollovers so plugins can rely on lifecycle reason reporting. (#59715) Thanks @jalehman.
## 2026.4.2

View File

@@ -434,6 +434,7 @@ export async function runPreflightCompactionIfNeeded(params: {
}
await incrementCompactionCount({
cfg: params.cfg,
sessionEntry: entry,
sessionStore: params.sessionStore,
sessionKey: params.sessionKey,
@@ -729,6 +730,7 @@ export async function runMemoryFlushIfNeeded(params: {
if (memoryCompactionCompleted) {
const previousSessionId = activeSessionEntry?.sessionId ?? params.followupRun.run.sessionId;
const nextCount = await incrementCompactionCount({
cfg: params.cfg,
sessionEntry: activeSessionEntry,
sessionStore: activeSessionStore,
sessionKey: params.sessionKey,

View File

@@ -731,6 +731,7 @@ export async function runReplyAgent(params: {
if (autoCompactionCount > 0) {
const previousSessionId = activeSessionEntry?.sessionId ?? followupRun.run.sessionId;
const count = await incrementRunCompactionCount({
cfg,
sessionEntry: activeSessionEntry,
sessionStore: activeSessionStore,
sessionKey,

View File

@@ -154,6 +154,7 @@ export const handleCompactCommand: CommandHandler = async (params) => {
: "Compaction failed";
if (result.ok && result.compacted) {
await incrementCompactionCount({
cfg: params.cfg,
sessionEntry: params.sessionEntry,
sessionStore: params.sessionStore,
sessionKey: params.sessionKey,

View File

@@ -366,6 +366,7 @@ export function createFollowupRunner(params: {
if (autoCompactionCount > 0) {
const previousSessionId = queued.run.sessionId;
const count = await incrementRunCompactionCount({
cfg: queued.run.config,
sessionEntry,
sessionStore,
sessionKey,

View File

@@ -27,6 +27,24 @@ async function writeStore(
await fs.writeFile(storePath, JSON.stringify(store), "utf-8");
}
async function writeTranscript(
storePath: string,
sessionId: string,
text = "hello",
): Promise<string> {
const transcriptPath = path.join(path.dirname(storePath), `${sessionId}.jsonl`);
await fs.writeFile(
transcriptPath,
`${JSON.stringify({
type: "message",
id: `${sessionId}-m1`,
message: { role: "user", content: text },
})}\n`,
"utf-8",
);
return transcriptPath;
}
describe("session hook context wiring", () => {
beforeEach(async () => {
vi.resetModules();
@@ -75,9 +93,11 @@ describe("session hook context wiring", () => {
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");
const transcriptPath = await writeTranscript(storePath, "old-session");
await writeStore(storePath, {
[sessionKey]: {
sessionId: "old-session",
sessionFile: transcriptPath,
updatedAt: Date.now(),
},
});
@@ -92,11 +112,179 @@ describe("session hook context wiring", () => {
expect(hookRunnerMocks.runSessionEnd).toHaveBeenCalledTimes(1);
expect(hookRunnerMocks.runSessionStart).toHaveBeenCalledTimes(1);
const [event, context] = hookRunnerMocks.runSessionEnd.mock.calls[0] ?? [];
expect(event).toMatchObject({ sessionKey });
expect(event).toMatchObject({
sessionKey,
reason: "new",
transcriptArchived: true,
});
expect(context).toMatchObject({ sessionKey, agentId: "main" });
expect(context).toMatchObject({ sessionId: event?.sessionId });
expect(event?.sessionFile).toContain(".jsonl.reset.");
const [startEvent] = hookRunnerMocks.runSessionStart.mock.calls[0] ?? [];
const [startEvent, startContext] = hookRunnerMocks.runSessionStart.mock.calls[0] ?? [];
expect(startEvent).toMatchObject({ resumedFrom: "old-session" });
expect(event?.nextSessionId).toBe(startEvent?.sessionId);
expect(startContext).toMatchObject({ sessionId: startEvent?.sessionId });
});
it("marks explicit /reset rollovers with reason reset", async () => {
const sessionKey = "agent:main:telegram:direct:456";
const storePath = await createStorePath("openclaw-session-hook-explicit-reset");
const transcriptPath = await writeTranscript(storePath, "reset-session", "reset me");
await writeStore(storePath, {
[sessionKey]: {
sessionId: "reset-session",
sessionFile: transcriptPath,
updatedAt: Date.now(),
},
});
const cfg = { session: { store: storePath } } as OpenClawConfig;
await initSessionState({
ctx: { Body: "/reset", SessionKey: sessionKey },
cfg,
commandAuthorized: true,
});
const [event] = hookRunnerMocks.runSessionEnd.mock.calls[0] ?? [];
expect(event).toMatchObject({ reason: "reset" });
});
it("maps custom reset trigger aliases to the new-session reason", async () => {
const sessionKey = "agent:main:telegram:direct:alias";
const storePath = await createStorePath("openclaw-session-hook-reset-alias");
const transcriptPath = await writeTranscript(storePath, "alias-session", "alias me");
await writeStore(storePath, {
[sessionKey]: {
sessionId: "alias-session",
sessionFile: transcriptPath,
updatedAt: Date.now(),
},
});
const cfg = {
session: {
store: storePath,
resetTriggers: ["/fresh"],
},
} as OpenClawConfig;
await initSessionState({
ctx: { Body: "/fresh", SessionKey: sessionKey },
cfg,
commandAuthorized: true,
});
const [event] = hookRunnerMocks.runSessionEnd.mock.calls[0] ?? [];
expect(event).toMatchObject({ reason: "new" });
});
it("marks daily stale rollovers and exposes the archived transcript path", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
const sessionKey = "agent:main:telegram:direct:daily";
const storePath = await createStorePath("openclaw-session-hook-daily");
const transcriptPath = await writeTranscript(storePath, "daily-session", "daily");
await writeStore(storePath, {
[sessionKey]: {
sessionId: "daily-session",
sessionFile: transcriptPath,
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
},
});
const cfg = { session: { store: storePath } } as OpenClawConfig;
await initSessionState({
ctx: { Body: "hello", SessionKey: sessionKey },
cfg,
commandAuthorized: true,
});
const [event] = hookRunnerMocks.runSessionEnd.mock.calls[0] ?? [];
const [startEvent] = hookRunnerMocks.runSessionStart.mock.calls[0] ?? [];
expect(event).toMatchObject({
reason: "daily",
transcriptArchived: true,
});
expect(event?.sessionFile).toContain(".jsonl.reset.");
expect(event?.nextSessionId).toBe(startEvent?.sessionId);
} finally {
vi.useRealTimers();
}
});
it("marks idle stale rollovers with reason idle", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
const sessionKey = "agent:main:telegram:direct:idle";
const storePath = await createStorePath("openclaw-session-hook-idle");
const transcriptPath = await writeTranscript(storePath, "idle-session", "idle");
await writeStore(storePath, {
[sessionKey]: {
sessionId: "idle-session",
sessionFile: transcriptPath,
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
},
});
const cfg = {
session: {
store: storePath,
reset: {
mode: "idle",
idleMinutes: 30,
},
},
} as OpenClawConfig;
await initSessionState({
ctx: { Body: "hello", SessionKey: sessionKey },
cfg,
commandAuthorized: true,
});
const [event] = hookRunnerMocks.runSessionEnd.mock.calls[0] ?? [];
expect(event).toMatchObject({ reason: "idle" });
} finally {
vi.useRealTimers();
}
});
it("prefers idle over daily when both rollover conditions are true", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0));
const sessionKey = "agent:main:telegram:direct:overlap";
const storePath = await createStorePath("openclaw-session-hook-overlap");
const transcriptPath = await writeTranscript(storePath, "overlap-session", "overlap");
await writeStore(storePath, {
[sessionKey]: {
sessionId: "overlap-session",
sessionFile: transcriptPath,
updatedAt: new Date(2026, 0, 18, 4, 45, 0).getTime(),
},
});
const cfg = {
session: {
store: storePath,
reset: {
mode: "daily",
atHour: 4,
idleMinutes: 30,
},
},
} as OpenClawConfig;
await initSessionState({
ctx: { Body: "hello", SessionKey: sessionKey },
cfg,
commandAuthorized: true,
});
const [event] = hookRunnerMocks.runSessionEnd.mock.calls[0] ?? [];
expect(event).toMatchObject({ reason: "idle" });
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -1,5 +1,10 @@
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import type { OpenClawConfig } from "../../config/config.js";
import type {
PluginHookSessionEndEvent,
PluginHookSessionEndReason,
PluginHookSessionStartEvent,
} from "../../plugins/types.js";
export type SessionHookContext = {
sessionId: string;
@@ -25,7 +30,7 @@ export function buildSessionStartHookPayload(params: {
cfg: OpenClawConfig;
resumedFrom?: string;
}): {
event: { sessionId: string; sessionKey: string; resumedFrom?: string };
event: PluginHookSessionStartEvent;
context: SessionHookContext;
} {
return {
@@ -47,8 +52,14 @@ export function buildSessionEndHookPayload(params: {
sessionKey: string;
cfg: OpenClawConfig;
messageCount?: number;
durationMs?: number;
reason?: PluginHookSessionEndReason;
sessionFile?: string;
transcriptArchived?: boolean;
nextSessionId?: string;
nextSessionKey?: string;
}): {
event: { sessionId: string; sessionKey: string; messageCount: number };
event: PluginHookSessionEndEvent;
context: SessionHookContext;
} {
return {
@@ -56,6 +67,12 @@ export function buildSessionEndHookPayload(params: {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
messageCount: params.messageCount ?? 0,
durationMs: params.durationMs,
reason: params.reason,
sessionFile: params.sessionFile,
transcriptArchived: params.transcriptArchived,
nextSessionId: params.nextSessionId,
nextSessionKey: params.nextSessionKey,
},
context: buildSessionHookContext({
sessionId: params.sessionId,

View File

@@ -1,4 +1,5 @@
import { deriveSessionTotalTokens, type NormalizedUsage } from "../../agents/usage.js";
import type { OpenClawConfig } from "../../config/config.js";
import { incrementCompactionCount } from "./session-updates.js";
import { persistSessionUsageUpdate } from "./session-usage.js";
@@ -9,6 +10,7 @@ type IncrementRunCompactionCountParams = Omit<
"tokensAfter"
> & {
amount?: number;
cfg?: OpenClawConfig;
lastCallUsage?: NormalizedUsage;
contextTokensUsed?: number;
newSessionId?: string;
@@ -32,6 +34,7 @@ export async function incrementRunCompactionCount(
sessionStore: params.sessionStore,
sessionKey: params.sessionKey,
storePath: params.storePath,
cfg: params.cfg,
amount: params.amount,
tokensAfter: tokensAfterCompaction,
newSessionId: params.newSessionId,

View File

@@ -0,0 +1,110 @@
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<HookRunner["hasHooks"]>(),
runSessionEnd: vi.fn<HookRunner["runSessionEnd"]>(),
runSessionStart: vi.fn<HookRunner["runSessionStart"]>(),
}));
let incrementCompactionCount: typeof import("./session-updates.js").incrementCompactionCount;
const tempDirs: string[] = [];
async function createFixture() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-updates-"));
tempDirs.push(root);
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:telegram:direct:compaction";
const transcriptPath = path.join(root, "s1.jsonl");
await fs.writeFile(transcriptPath, '{"type":"message"}\n', "utf-8");
const entry = {
sessionId: "s1",
sessionFile: transcriptPath,
updatedAt: Date.now(),
compactionCount: 0,
} as SessionEntry;
const sessionStore: Record<string, SessionEntry> = {
[sessionKey]: entry,
};
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
return { storePath, sessionKey, sessionStore, entry, transcriptPath };
}
describe("session-updates lifecycle hooks", () => {
beforeEach(async () => {
vi.resetModules();
vi.doMock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () =>
({
hasHooks: hookRunnerMocks.hasHooks,
runSessionEnd: hookRunnerMocks.runSessionEnd,
runSessionStart: hookRunnerMocks.runSessionStart,
}) as unknown as HookRunner,
}));
hookRunnerMocks.hasHooks.mockReset();
hookRunnerMocks.runSessionEnd.mockReset();
hookRunnerMocks.runSessionStart.mockReset();
hookRunnerMocks.hasHooks.mockImplementation(
(hookName) => hookName === "session_end" || hookName === "session_start",
);
hookRunnerMocks.runSessionEnd.mockResolvedValue(undefined);
hookRunnerMocks.runSessionStart.mockResolvedValue(undefined);
({ incrementCompactionCount } = await import("./session-updates.js"));
});
afterEach(async () => {
vi.restoreAllMocks();
await Promise.all(
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
it("emits compaction lifecycle hooks when newSessionId replaces the session", async () => {
const { storePath, sessionKey, sessionStore, entry, transcriptPath } = await createFixture();
const cfg = { session: { store: storePath } } as OpenClawConfig;
await incrementCompactionCount({
cfg,
sessionEntry: entry,
sessionStore,
sessionKey,
storePath,
newSessionId: "s2",
});
expect(hookRunnerMocks.runSessionEnd).toHaveBeenCalledTimes(1);
expect(hookRunnerMocks.runSessionStart).toHaveBeenCalledTimes(1);
const [endEvent, endContext] = hookRunnerMocks.runSessionEnd.mock.calls[0] ?? [];
const [startEvent, startContext] = hookRunnerMocks.runSessionStart.mock.calls[0] ?? [];
expect(endEvent).toMatchObject({
sessionId: "s1",
sessionKey,
reason: "compaction",
transcriptArchived: false,
});
expect(endEvent?.sessionFile).toBe(await fs.realpath(transcriptPath));
expect(endContext).toMatchObject({
sessionId: "s1",
sessionKey,
agentId: "main",
});
expect(endEvent?.nextSessionId).toBe(startEvent?.sessionId);
expect(startEvent).toMatchObject({
sessionId: "s2",
sessionKey,
resumedFrom: "s1",
});
expect(startContext).toMatchObject({
sessionId: "s2",
sessionKey,
agentId: "main",
});
});
});

View File

@@ -10,8 +10,12 @@ import {
type SessionEntry,
updateSessionStore,
} from "../../config/sessions.js";
import { resolveStableSessionEndTranscript } from "../../gateway/session-transcript-files.fs.js";
import { logVerbose } from "../../globals.js";
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { buildSessionEndHookPayload, buildSessionStartHookPayload } from "./session-hooks.js";
export { drainFormattedSystemEvents } from "./session-system-events.js";
async function persistSessionEntryUpdate(params: {
@@ -35,6 +39,52 @@ async function persistSessionEntryUpdate(params: {
});
}
function emitCompactionSessionLifecycleHooks(params: {
cfg: OpenClawConfig;
sessionKey: string;
storePath?: string;
previousEntry: SessionEntry;
nextEntry: SessionEntry;
}) {
const hookRunner = getGlobalHookRunner();
if (!hookRunner) {
return;
}
if (hookRunner.hasHooks("session_end")) {
const transcript = resolveStableSessionEndTranscript({
sessionId: params.previousEntry.sessionId,
storePath: params.storePath,
sessionFile: params.previousEntry.sessionFile,
agentId: resolveAgentIdFromSessionKey(params.sessionKey),
});
const payload = buildSessionEndHookPayload({
sessionId: params.previousEntry.sessionId,
sessionKey: params.sessionKey,
cfg: params.cfg,
reason: "compaction",
sessionFile: transcript.sessionFile,
transcriptArchived: transcript.transcriptArchived,
nextSessionId: params.nextEntry.sessionId,
});
void hookRunner.runSessionEnd(payload.event, payload.context).catch((err) => {
logVerbose(`session_end hook failed: ${String(err)}`);
});
}
if (hookRunner.hasHooks("session_start")) {
const payload = buildSessionStartHookPayload({
sessionId: params.nextEntry.sessionId,
sessionKey: params.sessionKey,
cfg: params.cfg,
resumedFrom: params.previousEntry.sessionId,
});
void hookRunner.runSessionStart(payload.event, payload.context).catch((err) => {
logVerbose(`session_start hook failed: ${String(err)}`);
});
}
}
export async function ensureSkillSnapshot(params: {
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
@@ -151,6 +201,7 @@ export async function incrementCompactionCount(params: {
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
cfg?: OpenClawConfig;
now?: number;
amount?: number;
/** Token count after compaction - if provided, updates session token counts */
@@ -163,6 +214,7 @@ export async function incrementCompactionCount(params: {
sessionStore,
sessionKey,
storePath,
cfg,
now = Date.now(),
amount = 1,
tokensAfter,
@@ -213,6 +265,15 @@ export async function incrementCompactionCount(params: {
};
});
}
if (newSessionId && newSessionId !== entry.sessionId && cfg) {
emitCompactionSessionLifecycleHooks({
cfg,
sessionKey,
storePath,
previousEntry: entry,
nextEntry: sessionStore[sessionKey],
});
}
return nextCount;
}

View File

@@ -16,6 +16,7 @@ import {
resolveSessionResetPolicy,
resolveSessionResetType,
resolveThreadFlag,
type SessionFreshness,
} from "../../config/sessions/reset.js";
import { resolveAndPersistSessionFile } from "../../config/sessions/session-file.js";
import { resolveSessionKey } from "../../config/sessions/session-key.js";
@@ -31,6 +32,7 @@ import { getSessionBindingService } from "../../infra/outbound/session-binding-s
import { deliverSessionMaintenanceWarning } from "../../infra/session-maintenance-warning.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import type { PluginHookSessionEndReason } from "../../plugins/types.js";
import { normalizeMainKey } from "../../routing/session-key.js";
import { normalizeSessionDeliveryFields } from "../../utils/delivery-context.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js";
@@ -58,6 +60,33 @@ function loadSessionArchiveRuntime() {
return sessionArchiveRuntimePromise;
}
function resolveExplicitSessionEndReason(
matchedResetTriggerLower?: string,
): PluginHookSessionEndReason {
return matchedResetTriggerLower === "/reset" ? "reset" : "new";
}
function resolveStaleSessionEndReason(params: {
entry: SessionEntry | undefined;
freshness?: SessionFreshness;
now: number;
}): PluginHookSessionEndReason | undefined {
if (!params.entry || !params.freshness) {
return undefined;
}
const staleDaily =
params.freshness.dailyResetAt != null && params.entry.updatedAt < params.freshness.dailyResetAt;
const staleIdle =
params.freshness.idleExpiresAt != null && params.now > params.freshness.idleExpiresAt;
if (staleIdle) {
return "idle";
}
if (staleDaily) {
return "daily";
}
return undefined;
}
export type SessionInitResult = {
sessionCtx: TemplateContext;
sessionEntry: SessionEntry;
@@ -304,6 +333,7 @@ export async function initSessionState(params: {
// "/NEW" etc. Match case-insensitively while keeping the original casing for any stripped body.
const trimmedBodyLower = trimmedBody.toLowerCase();
const strippedForResetLower = strippedForReset.toLowerCase();
let matchedResetTriggerLower: string | undefined;
for (const trigger of resetTriggers) {
if (!trigger) {
@@ -323,6 +353,7 @@ export async function initSessionState(params: {
isNewSession = true;
bodyStripped = "";
resetTriggered = true;
matchedResetTriggerLower = triggerLower;
break;
}
const triggerPrefixLower = `${triggerLower} `;
@@ -336,6 +367,7 @@ export async function initSessionState(params: {
isNewSession = true;
bodyStripped = strippedForReset.slice(trigger.length).trimStart();
resetTriggered = true;
matchedResetTriggerLower = triggerLower;
break;
}
}
@@ -389,16 +421,24 @@ export async function initSessionState(params: {
// See #58409 for details on silent session reset bug.
const isSystemEvent =
ctx.Provider === "heartbeat" || ctx.Provider === "cron-event" || ctx.Provider === "exec-event";
const freshEntry = entry
const entryFreshness = entry
? isSystemEvent
? true
: evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy }).fresh
: false;
? ({ fresh: true } satisfies SessionFreshness)
: evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy })
: undefined;
const freshEntry = entryFreshness?.fresh ?? false;
// Capture the current session entry before any reset so its transcript can be
// archived afterward. We need to do this for both explicit resets (/new, /reset)
// and for scheduled/daily resets where the session has become stale (!freshEntry).
// Without this, daily-reset transcripts are left as orphaned files on disk (#35481).
const previousSessionEntry = (resetTriggered || !freshEntry) && entry ? { ...entry } : undefined;
const previousSessionEndReason = resetTriggered
? resolveExplicitSessionEndReason(matchedResetTriggerLower)
: resolveStaleSessionEndReason({
entry,
freshness: entryFreshness,
now,
});
clearBootstrapSnapshotOnSessionRollover({
sessionKey,
previousSessionId: previousSessionEntry?.sessionId,
@@ -640,15 +680,27 @@ export async function initSessionState(params: {
);
// Archive old transcript so it doesn't accumulate on disk (#14869).
let previousSessionTranscript: {
sessionFile?: string;
transcriptArchived?: boolean;
} = {};
if (previousSessionEntry?.sessionId) {
const { archiveSessionTranscripts } = await loadSessionArchiveRuntime();
archiveSessionTranscripts({
const { archiveSessionTranscriptsDetailed, resolveStableSessionEndTranscript } =
await loadSessionArchiveRuntime();
const archivedTranscripts = archiveSessionTranscriptsDetailed({
sessionId: previousSessionEntry.sessionId,
storePath,
sessionFile: previousSessionEntry.sessionFile,
agentId,
reason: "reset",
});
previousSessionTranscript = resolveStableSessionEndTranscript({
sessionId: previousSessionEntry.sessionId,
storePath,
sessionFile: previousSessionEntry.sessionFile,
agentId,
archivedTranscripts,
});
await disposeSessionMcpRuntime(previousSessionEntry.sessionId).catch((error) => {
log.warn(
`failed to dispose bundle MCP runtime for session ${previousSessionEntry.sessionId}`,
@@ -688,6 +740,10 @@ export async function initSessionState(params: {
sessionId: previousSessionEntry.sessionId,
sessionKey,
cfg,
reason: previousSessionEndReason,
sessionFile: previousSessionTranscript.sessionFile,
transcriptArchived: previousSessionTranscript.transcriptArchived,
nextSessionId: effectiveSessionId,
});
void hookRunner.runSessionEnd(payload.event, payload.context).catch(() => {});
}

View File

@@ -48,8 +48,9 @@ import {
validateSessionsSendParams,
} from "../protocol/index.js";
import {
archiveSessionTranscriptsForSession,
archiveSessionTranscriptsForSessionDetailed,
cleanupSessionBeforeMutation,
emitGatewaySessionEndPluginHook,
emitSessionUnboundLifecycleEvent,
performGatewaySessionReset,
} from "../session-reset-service.js";
@@ -1061,9 +1062,9 @@ export const sessionsHandlers: GatewayRequestHandlers = {
return hadEntry;
});
const archived =
const archivedTranscripts =
deleted && deleteTranscript
? archiveSessionTranscriptsForSession({
? archiveSessionTranscriptsForSessionDetailed({
sessionId,
storePath,
sessionFile: entry?.sessionFile,
@@ -1071,7 +1072,18 @@ export const sessionsHandlers: GatewayRequestHandlers = {
reason: "deleted",
})
: [];
const archived = archivedTranscripts.map((entry) => entry.archivedPath);
if (deleted) {
emitGatewaySessionEndPluginHook({
cfg,
sessionKey: target.canonicalKey ?? key,
sessionId,
storePath,
sessionFile: entry?.sessionFile,
agentId: target.agentId,
reason: "deleted",
archivedTranscripts,
});
const emitLifecycleHooks = p.emitLifecycleHooks !== false;
await emitSessionUnboundLifecycleEvent({
targetSessionKey: target.canonicalKey ?? key,

View File

@@ -46,6 +46,11 @@ const beforeResetHookMocks = vi.hoisted(() => ({
runBeforeReset: vi.fn(async () => {}),
}));
const sessionLifecycleHookMocks = vi.hoisted(() => ({
runSessionEnd: vi.fn(async () => {}),
runSessionStart: vi.fn(async () => {}),
}));
const subagentLifecycleHookMocks = vi.hoisted(() => ({
runSubagentEnded: vi.fn(async () => {}),
}));
@@ -54,6 +59,11 @@ const beforeResetHookState = vi.hoisted(() => ({
hasBeforeResetHook: false,
}));
const sessionLifecycleHookState = vi.hoisted(() => ({
hasSessionEndHook: true,
hasSessionStartHook: true,
}));
const subagentLifecycleHookState = vi.hoisted(() => ({
hasSubagentEndedHook: true,
}));
@@ -121,8 +131,12 @@ vi.mock("../plugins/hook-runner-global.js", async (importOriginal) => {
getGlobalHookRunner: vi.fn(() => ({
hasHooks: (hookName: string) =>
(hookName === "subagent_ended" && subagentLifecycleHookState.hasSubagentEndedHook) ||
(hookName === "before_reset" && beforeResetHookState.hasBeforeResetHook),
(hookName === "before_reset" && beforeResetHookState.hasBeforeResetHook) ||
(hookName === "session_end" && sessionLifecycleHookState.hasSessionEndHook) ||
(hookName === "session_start" && sessionLifecycleHookState.hasSessionStartHook),
runBeforeReset: beforeResetHookMocks.runBeforeReset,
runSessionEnd: sessionLifecycleHookMocks.runSessionEnd,
runSessionStart: sessionLifecycleHookMocks.runSessionStart,
runSubagentEnded: subagentLifecycleHookMocks.runSubagentEnded,
})),
};
@@ -278,6 +292,10 @@ describe("gateway server sessions", () => {
sessionHookMocks.triggerInternalHook.mockClear();
beforeResetHookMocks.runBeforeReset.mockClear();
beforeResetHookState.hasBeforeResetHook = false;
sessionLifecycleHookMocks.runSessionEnd.mockClear();
sessionLifecycleHookMocks.runSessionStart.mockClear();
sessionLifecycleHookState.hasSessionEndHook = true;
sessionLifecycleHookState.hasSessionStartHook = true;
subagentLifecycleHookMocks.runSubagentEnded.mockClear();
subagentLifecycleHookState.hasSubagentEndedHook = true;
threadBindingMocks.unbindThreadBindingsBySessionKey.mockClear();
@@ -1896,6 +1914,61 @@ describe("gateway server sessions", () => {
ws.close();
});
test("sessions.delete emits session_end with deleted reason and no replacement", async () => {
const { dir } = await createSessionStoreDir();
await writeSingleLineSession(dir, "sess-main", "hello");
const transcriptPath = path.join(dir, "sess-delete.jsonl");
await fs.writeFile(
transcriptPath,
`${JSON.stringify({
type: "message",
id: "m-delete",
message: { role: "user", content: "delete me" },
})}\n`,
"utf-8",
);
await writeSessionStore({
entries: {
main: { sessionId: "sess-main", updatedAt: Date.now() },
"discord:group:delete": {
sessionId: "sess-delete",
sessionFile: transcriptPath,
updatedAt: Date.now(),
},
},
});
const { ws } = await openClient();
const deleted = await rpcReq<{ ok: true; deleted: boolean }>(ws, "sessions.delete", {
key: "discord:group:delete",
});
expect(deleted.ok).toBe(true);
expect(deleted.payload?.deleted).toBe(true);
expect(sessionLifecycleHookMocks.runSessionEnd).toHaveBeenCalledTimes(1);
expect(sessionLifecycleHookMocks.runSessionStart).not.toHaveBeenCalled();
const [event, context] = (
sessionLifecycleHookMocks.runSessionEnd.mock.calls as unknown as Array<[unknown, unknown]>
)[0] ?? [undefined, undefined];
expect(event).toMatchObject({
sessionId: "sess-delete",
sessionKey: "agent:main:discord:group:delete",
reason: "deleted",
transcriptArchived: true,
});
expect((event as { sessionFile?: string } | undefined)?.sessionFile).toContain(
".jsonl.deleted.",
);
expect((event as { nextSessionId?: string } | undefined)?.nextSessionId).toBeUndefined();
expect(context).toMatchObject({
sessionId: "sess-delete",
sessionKey: "agent:main:discord:group:delete",
agentId: "main",
});
ws.close();
});
test("sessions.delete does not emit lifecycle events when nothing was deleted", async () => {
const { dir } = await createSessionStoreDir();
await writeSingleLineSession(dir, "sess-main", "hello");
@@ -2361,6 +2434,74 @@ describe("gateway server sessions", () => {
ws.close();
});
test("sessions.reset emits enriched session_end and session_start hooks", async () => {
const { dir } = await createSessionStoreDir();
const transcriptPath = path.join(dir, "sess-main.jsonl");
await fs.writeFile(
transcriptPath,
`${JSON.stringify({
type: "message",
id: "m1",
message: { role: "user", content: "hello from transcript" },
})}\n`,
"utf-8",
);
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
sessionFile: transcriptPath,
updatedAt: Date.now(),
},
},
});
const { ws } = await openClient();
const reset = await rpcReq<{ ok: true; key: string }>(ws, "sessions.reset", {
key: "main",
reason: "new",
});
expect(reset.ok).toBe(true);
expect(sessionLifecycleHookMocks.runSessionEnd).toHaveBeenCalledTimes(1);
expect(sessionLifecycleHookMocks.runSessionStart).toHaveBeenCalledTimes(1);
const [endEvent, endContext] = (
sessionLifecycleHookMocks.runSessionEnd.mock.calls as unknown as Array<[unknown, unknown]>
)[0] ?? [undefined, undefined];
const [startEvent, startContext] = (
sessionLifecycleHookMocks.runSessionStart.mock.calls as unknown as Array<[unknown, unknown]>
)[0] ?? [undefined, undefined];
expect(endEvent).toMatchObject({
sessionId: "sess-main",
sessionKey: "agent:main:main",
reason: "new",
transcriptArchived: true,
});
expect((endEvent as { sessionFile?: string } | undefined)?.sessionFile).toContain(
".jsonl.reset.",
);
expect((endEvent as { nextSessionId?: string } | undefined)?.nextSessionId).toBe(
(startEvent as { sessionId?: string } | undefined)?.sessionId,
);
expect(endContext).toMatchObject({
sessionId: "sess-main",
sessionKey: "agent:main:main",
agentId: "main",
});
expect(startEvent).toMatchObject({
sessionKey: "agent:main:main",
resumedFrom: "sess-main",
});
expect(startContext).toMatchObject({
sessionId: (startEvent as { sessionId?: string } | undefined)?.sessionId,
sessionKey: "agent:main:main",
agentId: "main",
});
ws.close();
});
test("sessions.reset returns unavailable when active run does not stop", async () => {
const { dir, storePath } = await seedActiveMainSession();
const waitCallCountAtSnapshotClear: number[] = [];

View File

@@ -1,5 +1,7 @@
export {
archiveFileOnDisk,
archiveSessionTranscriptsDetailed,
archiveSessionTranscripts,
cleanupArchivedSessionTranscripts,
resolveStableSessionEndTranscript,
} from "./session-transcript-files.fs.js";

View File

@@ -1,4 +1,6 @@
export {
archiveSessionTranscriptsDetailed,
archiveSessionTranscripts,
cleanupArchivedSessionTranscripts,
resolveStableSessionEndTranscript,
} from "./session-archive.fs.js";

View File

@@ -8,6 +8,10 @@ import { clearBootstrapSnapshot } from "../agents/bootstrap-cache.js";
import { abortEmbeddedPiRun, waitForEmbeddedPiRunEnd } from "../agents/pi-embedded.js";
import { stopSubagentsForRequester } from "../auto-reply/reply/abort.js";
import { clearSessionQueues } from "../auto-reply/reply/queue.js";
import {
buildSessionEndHookPayload,
buildSessionStartHookPayload,
} from "../auto-reply/reply/session-hooks.js";
import { loadConfig } from "../config/config.js";
import {
snapshotSessionOrigin,
@@ -27,7 +31,11 @@ import {
} from "../routing/session-key.js";
import { ErrorCodes, errorShape } from "./protocol/index.js";
import {
archiveSessionTranscripts,
archiveSessionTranscriptsDetailed,
resolveStableSessionEndTranscript,
type ArchivedSessionTranscript,
} from "./session-transcript-files.fs.js";
import {
loadSessionEntry,
migrateAndPruneGatewaySessionStoreKey,
readSessionMessages,
@@ -63,10 +71,20 @@ export function archiveSessionTranscriptsForSession(params: {
agentId?: string;
reason: "reset" | "deleted";
}): string[] {
return archiveSessionTranscriptsForSessionDetailed(params).map((entry) => entry.archivedPath);
}
export function archiveSessionTranscriptsForSessionDetailed(params: {
sessionId: string | undefined;
storePath: string;
sessionFile?: string;
agentId?: string;
reason: "reset" | "deleted";
}): ArchivedSessionTranscript[] {
if (!params.sessionId) {
return [];
}
return archiveSessionTranscripts({
return archiveSessionTranscriptsDetailed({
sessionId: params.sessionId,
storePath: params.storePath,
sessionFile: params.sessionFile,
@@ -75,6 +93,71 @@ export function archiveSessionTranscriptsForSession(params: {
});
}
export function emitGatewaySessionEndPluginHook(params: {
cfg: ReturnType<typeof loadConfig>;
sessionKey: string;
sessionId?: string;
storePath: string;
sessionFile?: string;
agentId?: string;
reason: "new" | "reset" | "idle" | "daily" | "compaction" | "deleted" | "unknown";
archivedTranscripts?: ArchivedSessionTranscript[];
nextSessionId?: string;
nextSessionKey?: string;
}): void {
if (!params.sessionId) {
return;
}
const hookRunner = getGlobalHookRunner();
if (!hookRunner?.hasHooks("session_end")) {
return;
}
const transcript = resolveStableSessionEndTranscript({
sessionId: params.sessionId,
storePath: params.storePath,
sessionFile: params.sessionFile,
agentId: params.agentId,
archivedTranscripts: params.archivedTranscripts,
});
const payload = buildSessionEndHookPayload({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
cfg: params.cfg,
reason: params.reason,
sessionFile: transcript.sessionFile,
transcriptArchived: transcript.transcriptArchived,
nextSessionId: params.nextSessionId,
nextSessionKey: params.nextSessionKey,
});
void hookRunner.runSessionEnd(payload.event, payload.context).catch((err) => {
logVerbose(`session_end hook failed: ${String(err)}`);
});
}
export function emitGatewaySessionStartPluginHook(params: {
cfg: ReturnType<typeof loadConfig>;
sessionKey: string;
sessionId?: string;
resumedFrom?: string;
}): void {
if (!params.sessionId) {
return;
}
const hookRunner = getGlobalHookRunner();
if (!hookRunner?.hasHooks("session_start")) {
return;
}
const payload = buildSessionStartHookPayload({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
cfg: params.cfg,
resumedFrom: params.resumedFrom,
});
void hookRunner.runSessionStart(payload.event, payload.context).catch((err) => {
logVerbose(`session_start hook failed: ${String(err)}`);
});
}
export async function emitSessionUnboundLifecycleEvent(params: {
targetSessionKey: string;
reason: "session-reset" | "session-delete";
@@ -445,7 +528,7 @@ export async function performGatewaySessionReset(params: {
reason: params.reason,
});
archiveSessionTranscriptsForSession({
const archivedTranscripts = archiveSessionTranscriptsForSessionDetailed({
sessionId: oldSessionId,
storePath,
sessionFile: oldSessionFile,
@@ -466,6 +549,23 @@ export async function performGatewaySessionReset(params: {
mode: 0o600,
});
}
emitGatewaySessionEndPluginHook({
cfg,
sessionKey: target.canonicalKey ?? params.key,
sessionId: oldSessionId,
storePath,
sessionFile: oldSessionFile,
agentId: target.agentId,
reason: params.reason,
archivedTranscripts,
nextSessionId: next.sessionId,
});
emitGatewaySessionStartPluginHook({
cfg,
sessionKey: target.canonicalKey ?? params.key,
sessionId: next.sessionId,
resumedFrom: oldSessionId,
});
if (hadExistingEntry) {
await emitSessionUnboundLifecycleEvent({
targetSessionKey: target.canonicalKey ?? params.key,

View File

@@ -12,6 +12,10 @@ import {
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
export type ArchiveFileReason = SessionArchiveReason;
export type ArchivedSessionTranscript = {
sourcePath: string;
archivedPath: string;
};
function classifySessionTranscriptCandidate(
sessionId: string,
@@ -136,7 +140,22 @@ export function archiveSessionTranscripts(opts: {
*/
restrictToStoreDir?: boolean;
}): string[] {
const archived: string[] = [];
return archiveSessionTranscriptsDetailed(opts).map((entry) => entry.archivedPath);
}
export function archiveSessionTranscriptsDetailed(opts: {
sessionId: string;
storePath: string | undefined;
sessionFile?: string;
agentId?: string;
reason: "reset" | "deleted";
/**
* When true, only archive files resolved under the session store directory.
* This prevents maintenance operations from mutating paths outside the agent sessions dir.
*/
restrictToStoreDir?: boolean;
}): ArchivedSessionTranscript[] {
const archived: ArchivedSessionTranscript[] = [];
const storeDir =
opts.restrictToStoreDir && opts.storePath
? canonicalizePathForComparison(path.dirname(opts.storePath))
@@ -158,7 +177,10 @@ export function archiveSessionTranscripts(opts: {
continue;
}
try {
archived.push(archiveFileOnDisk(candidatePath, opts.reason));
archived.push({
sourcePath: candidatePath,
archivedPath: archiveFileOnDisk(candidatePath, opts.reason),
});
} catch {
// Best-effort.
}
@@ -166,6 +188,45 @@ export function archiveSessionTranscripts(opts: {
return archived;
}
export function resolveStableSessionEndTranscript(params: {
sessionId: string;
storePath: string | undefined;
sessionFile?: string;
agentId?: string;
archivedTranscripts?: ArchivedSessionTranscript[];
}): { sessionFile?: string; transcriptArchived?: boolean } {
const archivedTranscripts = params.archivedTranscripts ?? [];
if (archivedTranscripts.length > 0) {
const preferredPath = params.sessionFile?.trim()
? canonicalizePathForComparison(params.sessionFile)
: undefined;
const archivedMatch =
preferredPath == null
? undefined
: archivedTranscripts.find(
(entry) => canonicalizePathForComparison(entry.sourcePath) === preferredPath,
);
const archivedPath = archivedMatch?.archivedPath ?? archivedTranscripts[0]?.archivedPath;
if (archivedPath) {
return { sessionFile: archivedPath, transcriptArchived: true };
}
}
for (const candidate of resolveSessionTranscriptCandidates(
params.sessionId,
params.storePath,
params.sessionFile,
params.agentId,
)) {
const candidatePath = canonicalizePathForComparison(candidate);
if (fs.existsSync(candidatePath)) {
return { sessionFile: candidatePath, transcriptArchived: false };
}
}
return {};
}
export async function cleanupArchivedSessionTranscripts(opts: {
directories: string[];
olderThanMs: number;

View File

@@ -2482,11 +2482,25 @@ export type PluginHookSessionStartEvent = {
};
// session_end hook
export type PluginHookSessionEndReason =
| "new"
| "reset"
| "idle"
| "daily"
| "compaction"
| "deleted"
| "unknown";
export type PluginHookSessionEndEvent = {
sessionId: string;
sessionKey?: string;
messageCount: number;
durationMs?: number;
reason?: PluginHookSessionEndReason;
sessionFile?: string;
transcriptArchived?: boolean;
nextSessionId?: string;
nextSessionKey?: string;
};
// Subagent context

View File

@@ -40,7 +40,15 @@ describe("session hook runner methods", () => {
{
name: "runSessionEnd invokes registered session_end hooks",
hookName: "session_end" as const,
event: { sessionId: "abc-123", sessionKey: "agent:main:abc", messageCount: 42 },
event: {
sessionId: "abc-123",
sessionKey: "agent:main:abc",
messageCount: 42,
reason: "daily" as const,
sessionFile: "/tmp/abc-123.jsonl.reset.2026-04-02T10-00-00.000Z",
transcriptArchived: true,
nextSessionId: "def-456",
},
},
] as const)("$name", async ({ hookName, event }) => {
await expectSessionHookCall({ hookName, event, sessionCtx });