mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-11 01:01:13 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
110
src/auto-reply/reply/session-updates.lifecycle.test.ts
Normal file
110
src/auto-reply/reply/session-updates.lifecycle.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export {
|
||||
archiveFileOnDisk,
|
||||
archiveSessionTranscriptsDetailed,
|
||||
archiveSessionTranscripts,
|
||||
cleanupArchivedSessionTranscripts,
|
||||
resolveStableSessionEndTranscript,
|
||||
} from "./session-transcript-files.fs.js";
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export {
|
||||
archiveSessionTranscriptsDetailed,
|
||||
archiveSessionTranscripts,
|
||||
cleanupArchivedSessionTranscripts,
|
||||
resolveStableSessionEndTranscript,
|
||||
} from "./session-archive.fs.js";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user