diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d3293f4c3f..67aecf377f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index 5703c5c5028..f401d07f68c 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -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, diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index ef5cbe16c31..4eb4237a98b 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -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, diff --git a/src/auto-reply/reply/commands-compact.ts b/src/auto-reply/reply/commands-compact.ts index fa4ff3b313e..48db5199a52 100644 --- a/src/auto-reply/reply/commands-compact.ts +++ b/src/auto-reply/reply/commands-compact.ts @@ -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, diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 7d1ce1a4f4c..6b195a48071 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -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, diff --git a/src/auto-reply/reply/session-hooks-context.test.ts b/src/auto-reply/reply/session-hooks-context.test.ts index 140a4442d5b..c7fe1ce5dcb 100644 --- a/src/auto-reply/reply/session-hooks-context.test.ts +++ b/src/auto-reply/reply/session-hooks-context.test.ts @@ -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 { + 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(); + } }); }); diff --git a/src/auto-reply/reply/session-hooks.ts b/src/auto-reply/reply/session-hooks.ts index 8e22dc247bc..312ff36867d 100644 --- a/src/auto-reply/reply/session-hooks.ts +++ b/src/auto-reply/reply/session-hooks.ts @@ -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, diff --git a/src/auto-reply/reply/session-run-accounting.ts b/src/auto-reply/reply/session-run-accounting.ts index 75b9966414f..6e7ba2590c5 100644 --- a/src/auto-reply/reply/session-run-accounting.ts +++ b/src/auto-reply/reply/session-run-accounting.ts @@ -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, diff --git a/src/auto-reply/reply/session-updates.lifecycle.test.ts b/src/auto-reply/reply/session-updates.lifecycle.test.ts new file mode 100644 index 00000000000..0c29b571dbe --- /dev/null +++ b/src/auto-reply/reply/session-updates.lifecycle.test.ts @@ -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(), + runSessionEnd: vi.fn(), + runSessionStart: vi.fn(), +})); + +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 = { + [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", + }); + }); +}); diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index 19565eb966a..9bc9f6a9584 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -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; @@ -151,6 +201,7 @@ export async function incrementCompactionCount(params: { sessionStore?: Record; 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; } diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 16c25129d1a..d82761682ff 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -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(() => {}); } diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 25c3e9cd3ae..0b06b729fc1 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -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, diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index c8f0a7952e3..6a74af97cc0 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -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[] = []; diff --git a/src/gateway/session-archive.fs.ts b/src/gateway/session-archive.fs.ts index 15f5171b896..5c878b9bda2 100644 --- a/src/gateway/session-archive.fs.ts +++ b/src/gateway/session-archive.fs.ts @@ -1,5 +1,7 @@ export { archiveFileOnDisk, + archiveSessionTranscriptsDetailed, archiveSessionTranscripts, cleanupArchivedSessionTranscripts, + resolveStableSessionEndTranscript, } from "./session-transcript-files.fs.js"; diff --git a/src/gateway/session-archive.runtime.ts b/src/gateway/session-archive.runtime.ts index f52f95d588c..feb7059bb66 100644 --- a/src/gateway/session-archive.runtime.ts +++ b/src/gateway/session-archive.runtime.ts @@ -1,4 +1,6 @@ export { + archiveSessionTranscriptsDetailed, archiveSessionTranscripts, cleanupArchivedSessionTranscripts, + resolveStableSessionEndTranscript, } from "./session-archive.fs.js"; diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index c5d38bb89a4..1970ccf92b2 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -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; + 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; + 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, diff --git a/src/gateway/session-transcript-files.fs.ts b/src/gateway/session-transcript-files.fs.ts index 5df99089792..4cfd2273622 100644 --- a/src/gateway/session-transcript-files.fs.ts +++ b/src/gateway/session-transcript-files.fs.ts @@ -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; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index f0f77543c0b..21a82d5cc28 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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 diff --git a/src/plugins/wired-hooks-session.test.ts b/src/plugins/wired-hooks-session.test.ts index d3c801e700f..61ec013678d 100644 --- a/src/plugins/wired-hooks-session.test.ts +++ b/src/plugins/wired-hooks-session.test.ts @@ -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 });