Skip to content

Commit 71ec421

Browse files
authored
feat(hooks): emit compaction lifecycle hooks (openclaw#16788)
1 parent 2f86ae7 commit 71ec421

File tree

4 files changed

+490
-46
lines changed

4 files changed

+490
-46
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
2222
- Plugins/hook policy: add `plugins.entries.<id>.hooks.allowPromptInjection`, validate unknown typed hook names at runtime, and preserve legacy `before_agent_start` model/provider overrides while stripping prompt-mutating fields when prompt injection is disabled. (#36567) thanks @gumadeiras.
2323
- Tools/Diffs guidance: restore a short system-prompt hint for enabled diffs while keeping the detailed instructions in the companion skill, so diffs usage guidance stays out of user-prompt space. (#36904) thanks @gumadeiras.
2424
- Telegram/ACP topic bindings: accept Telegram Mac Unicode dash option prefixes in `/acp spawn`, support Telegram topic thread binding (`--thread here|auto`), route bound-topic follow-ups to ACP sessions, add actionable Telegram approval buttons with prefixed approval-id resolution, and pin successful bind confirmations in-topic. (#36683) Thanks @huntharo.
25+
- Hooks/Compaction lifecycle: emit `session:compact:before` and `session:compact:after` internal events plus plugin compaction callbacks with session/count metadata, so automations can react to compaction runs consistently. (#16788) thanks @vincentkoc.
2526

2627
### Breaking
2728

docs/automation/hooks.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,14 @@ Triggered when agent commands are issued:
243243
- **`command:reset`**: When `/reset` command is issued
244244
- **`command:stop`**: When `/stop` command is issued
245245

246+
### Session Events
247+
248+
- **`session:compact:before`**: Right before compaction summarizes history
249+
- **`session:compact:after`**: After compaction completes with summary metadata
250+
251+
Internal hook payloads emit these as `type: "session"` with `action: "compact:before"` / `action: "compact:after"`; listeners subscribe with the combined keys above.
252+
Specific handler registration uses the literal key format `${type}:${action}`. For these events, register `session:compact:before` and `session:compact:after`.
253+
246254
### Agent Events
247255

248256
- **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`)
@@ -351,6 +359,13 @@ These hooks are not event-stream listeners; they let plugins synchronously adjus
351359

352360
- **`tool_result_persist`**: transform tool results before they are written to the session transcript. Must be synchronous; return the updated tool result payload or `undefined` to keep it as-is. See [Agent Loop](/concepts/agent-loop).
353361

362+
### Plugin Hook Events
363+
364+
Compaction lifecycle hooks exposed through the plugin hook runner:
365+
366+
- **`before_compaction`**: Runs before compaction with count/token metadata
367+
- **`after_compaction`**: Runs after compaction with compaction summary metadata
368+
354369
### Future Events
355370

356371
Planned event types:
Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const { hookRunner, triggerInternalHook, sanitizeSessionHistoryMock } = vi.hoisted(() => ({
4+
hookRunner: {
5+
hasHooks: vi.fn(),
6+
runBeforeCompaction: vi.fn(),
7+
runAfterCompaction: vi.fn(),
8+
},
9+
triggerInternalHook: vi.fn(),
10+
sanitizeSessionHistoryMock: vi.fn(async (params: { messages: unknown[] }) => params.messages),
11+
}));
12+
13+
vi.mock("../../plugins/hook-runner-global.js", () => ({
14+
getGlobalHookRunner: () => hookRunner,
15+
}));
16+
17+
vi.mock("../../hooks/internal-hooks.js", async () => {
18+
const actual = await vi.importActual<typeof import("../../hooks/internal-hooks.js")>(
19+
"../../hooks/internal-hooks.js",
20+
);
21+
return {
22+
...actual,
23+
triggerInternalHook,
24+
};
25+
});
26+
27+
vi.mock("@mariozechner/pi-coding-agent", () => {
28+
return {
29+
createAgentSession: vi.fn(async () => {
30+
const session = {
31+
sessionId: "session-1",
32+
messages: [
33+
{ role: "user", content: "hello", timestamp: 1 },
34+
{ role: "assistant", content: [{ type: "text", text: "hi" }], timestamp: 2 },
35+
{
36+
role: "toolResult",
37+
toolCallId: "t1",
38+
toolName: "exec",
39+
content: [{ type: "text", text: "output" }],
40+
isError: false,
41+
timestamp: 3,
42+
},
43+
],
44+
agent: {
45+
replaceMessages: vi.fn((messages: unknown[]) => {
46+
session.messages = [...(messages as typeof session.messages)];
47+
}),
48+
streamFn: vi.fn(),
49+
},
50+
compact: vi.fn(async () => {
51+
// simulate compaction trimming to a single message
52+
session.messages.splice(1);
53+
return {
54+
summary: "summary",
55+
firstKeptEntryId: "entry-1",
56+
tokensBefore: 120,
57+
details: { ok: true },
58+
};
59+
}),
60+
dispose: vi.fn(),
61+
};
62+
return { session };
63+
}),
64+
SessionManager: {
65+
open: vi.fn(() => ({})),
66+
},
67+
SettingsManager: {
68+
create: vi.fn(() => ({})),
69+
},
70+
estimateTokens: vi.fn(() => 10),
71+
};
72+
});
73+
74+
vi.mock("../session-tool-result-guard-wrapper.js", () => ({
75+
guardSessionManager: vi.fn(() => ({
76+
flushPendingToolResults: vi.fn(),
77+
})),
78+
}));
79+
80+
vi.mock("../pi-settings.js", () => ({
81+
ensurePiCompactionReserveTokens: vi.fn(),
82+
resolveCompactionReserveTokensFloor: vi.fn(() => 0),
83+
}));
84+
85+
vi.mock("../models-config.js", () => ({
86+
ensureOpenClawModelsJson: vi.fn(async () => {}),
87+
}));
88+
89+
vi.mock("../model-auth.js", () => ({
90+
getApiKeyForModel: vi.fn(async () => ({ apiKey: "test", mode: "env" })),
91+
resolveModelAuthMode: vi.fn(() => "env"),
92+
}));
93+
94+
vi.mock("../sandbox.js", () => ({
95+
resolveSandboxContext: vi.fn(async () => null),
96+
}));
97+
98+
vi.mock("../session-file-repair.js", () => ({
99+
repairSessionFileIfNeeded: vi.fn(async () => {}),
100+
}));
101+
102+
vi.mock("../session-write-lock.js", () => ({
103+
acquireSessionWriteLock: vi.fn(async () => ({ release: vi.fn(async () => {}) })),
104+
resolveSessionLockMaxHoldFromTimeout: vi.fn(() => 0),
105+
}));
106+
107+
vi.mock("../bootstrap-files.js", () => ({
108+
makeBootstrapWarn: vi.fn(() => () => {}),
109+
resolveBootstrapContextForRun: vi.fn(async () => ({ contextFiles: [] })),
110+
}));
111+
112+
vi.mock("../docs-path.js", () => ({
113+
resolveOpenClawDocsPath: vi.fn(async () => undefined),
114+
}));
115+
116+
vi.mock("../channel-tools.js", () => ({
117+
listChannelSupportedActions: vi.fn(() => undefined),
118+
resolveChannelMessageToolHints: vi.fn(() => undefined),
119+
}));
120+
121+
vi.mock("../pi-tools.js", () => ({
122+
createOpenClawCodingTools: vi.fn(() => []),
123+
}));
124+
125+
vi.mock("./google.js", () => ({
126+
logToolSchemasForGoogle: vi.fn(),
127+
sanitizeSessionHistory: sanitizeSessionHistoryMock,
128+
sanitizeToolsForGoogle: vi.fn(({ tools }: { tools: unknown[] }) => tools),
129+
}));
130+
131+
vi.mock("./tool-split.js", () => ({
132+
splitSdkTools: vi.fn(() => ({ builtInTools: [], customTools: [] })),
133+
}));
134+
135+
vi.mock("../transcript-policy.js", () => ({
136+
resolveTranscriptPolicy: vi.fn(() => ({
137+
allowSyntheticToolResults: false,
138+
validateGeminiTurns: false,
139+
validateAnthropicTurns: false,
140+
})),
141+
}));
142+
143+
vi.mock("./extensions.js", () => ({
144+
buildEmbeddedExtensionFactories: vi.fn(() => []),
145+
}));
146+
147+
vi.mock("./history.js", () => ({
148+
getDmHistoryLimitFromSessionKey: vi.fn(() => undefined),
149+
limitHistoryTurns: vi.fn((msgs: unknown[]) => msgs.slice(0, 2)),
150+
}));
151+
152+
vi.mock("../skills.js", () => ({
153+
applySkillEnvOverrides: vi.fn(() => () => {}),
154+
applySkillEnvOverridesFromSnapshot: vi.fn(() => () => {}),
155+
loadWorkspaceSkillEntries: vi.fn(() => []),
156+
resolveSkillsPromptForRun: vi.fn(() => undefined),
157+
}));
158+
159+
vi.mock("../agent-paths.js", () => ({
160+
resolveOpenClawAgentDir: vi.fn(() => "/tmp"),
161+
}));
162+
163+
vi.mock("../agent-scope.js", () => ({
164+
resolveSessionAgentIds: vi.fn(() => ({ defaultAgentId: "main", sessionAgentId: "main" })),
165+
}));
166+
167+
vi.mock("../date-time.js", () => ({
168+
formatUserTime: vi.fn(() => ""),
169+
resolveUserTimeFormat: vi.fn(() => ""),
170+
resolveUserTimezone: vi.fn(() => ""),
171+
}));
172+
173+
vi.mock("../defaults.js", () => ({
174+
DEFAULT_MODEL: "fake-model",
175+
DEFAULT_PROVIDER: "openai",
176+
}));
177+
178+
vi.mock("../utils.js", () => ({
179+
resolveUserPath: vi.fn((p: string) => p),
180+
}));
181+
182+
vi.mock("../../infra/machine-name.js", () => ({
183+
getMachineDisplayName: vi.fn(async () => "machine"),
184+
}));
185+
186+
vi.mock("../../config/channel-capabilities.js", () => ({
187+
resolveChannelCapabilities: vi.fn(() => undefined),
188+
}));
189+
190+
vi.mock("../../utils/message-channel.js", () => ({
191+
normalizeMessageChannel: vi.fn(() => undefined),
192+
}));
193+
194+
vi.mock("../pi-embedded-helpers.js", () => ({
195+
ensureSessionHeader: vi.fn(async () => {}),
196+
validateAnthropicTurns: vi.fn((m: unknown[]) => m),
197+
validateGeminiTurns: vi.fn((m: unknown[]) => m),
198+
}));
199+
200+
vi.mock("../pi-project-settings.js", () => ({
201+
createPreparedEmbeddedPiSettingsManager: vi.fn(() => ({
202+
getGlobalSettings: vi.fn(() => ({})),
203+
})),
204+
}));
205+
206+
vi.mock("./sandbox-info.js", () => ({
207+
buildEmbeddedSandboxInfo: vi.fn(() => undefined),
208+
}));
209+
210+
vi.mock("./model.js", () => ({
211+
buildModelAliasLines: vi.fn(() => []),
212+
resolveModel: vi.fn(() => ({
213+
model: { provider: "openai", api: "responses", id: "fake", input: [] },
214+
error: null,
215+
authStorage: { setRuntimeApiKey: vi.fn() },
216+
modelRegistry: {},
217+
})),
218+
}));
219+
220+
vi.mock("./session-manager-cache.js", () => ({
221+
prewarmSessionFile: vi.fn(async () => {}),
222+
trackSessionManagerAccess: vi.fn(),
223+
}));
224+
225+
vi.mock("./system-prompt.js", () => ({
226+
applySystemPromptOverrideToSession: vi.fn(),
227+
buildEmbeddedSystemPrompt: vi.fn(() => ""),
228+
createSystemPromptOverride: vi.fn(() => () => ""),
229+
}));
230+
231+
vi.mock("./utils.js", () => ({
232+
describeUnknownError: vi.fn((err: unknown) => String(err)),
233+
mapThinkingLevel: vi.fn(() => "off"),
234+
resolveExecToolDefaults: vi.fn(() => undefined),
235+
}));
236+
237+
import { compactEmbeddedPiSessionDirect } from "./compact.js";
238+
239+
const sessionHook = (action: string) =>
240+
triggerInternalHook.mock.calls.find(
241+
(call) => call[0]?.type === "session" && call[0]?.action === action,
242+
)?.[0];
243+
244+
describe("compactEmbeddedPiSessionDirect hooks", () => {
245+
beforeEach(() => {
246+
triggerInternalHook.mockClear();
247+
hookRunner.hasHooks.mockReset();
248+
hookRunner.runBeforeCompaction.mockReset();
249+
hookRunner.runAfterCompaction.mockReset();
250+
sanitizeSessionHistoryMock.mockReset();
251+
sanitizeSessionHistoryMock.mockImplementation(async (params: { messages: unknown[] }) => {
252+
return params.messages;
253+
});
254+
});
255+
256+
it("emits internal + plugin compaction hooks with counts", async () => {
257+
hookRunner.hasHooks.mockReturnValue(true);
258+
let sanitizedCount = 0;
259+
sanitizeSessionHistoryMock.mockImplementation(async (params: { messages: unknown[] }) => {
260+
const sanitized = params.messages.slice(1);
261+
sanitizedCount = sanitized.length;
262+
return sanitized;
263+
});
264+
265+
const result = await compactEmbeddedPiSessionDirect({
266+
sessionId: "session-1",
267+
sessionKey: "agent:main:session-1",
268+
sessionFile: "/tmp/session.jsonl",
269+
workspaceDir: "/tmp",
270+
messageChannel: "telegram",
271+
customInstructions: "focus on decisions",
272+
});
273+
274+
expect(result.ok).toBe(true);
275+
expect(sessionHook("compact:before")).toMatchObject({
276+
type: "session",
277+
action: "compact:before",
278+
});
279+
const beforeContext = sessionHook("compact:before")?.context;
280+
const afterContext = sessionHook("compact:after")?.context;
281+
282+
expect(beforeContext).toMatchObject({
283+
messageCount: 2,
284+
tokenCount: 20,
285+
messageCountOriginal: sanitizedCount,
286+
tokenCountOriginal: sanitizedCount * 10,
287+
});
288+
expect(afterContext).toMatchObject({
289+
messageCount: 1,
290+
compactedCount: 1,
291+
});
292+
expect(afterContext?.compactedCount).toBe(
293+
(beforeContext?.messageCountOriginal as number) - (afterContext?.messageCount as number),
294+
);
295+
296+
expect(hookRunner.runBeforeCompaction).toHaveBeenCalledWith(
297+
expect.objectContaining({
298+
messageCount: 2,
299+
tokenCount: 20,
300+
}),
301+
expect.objectContaining({ sessionKey: "agent:main:session-1", messageProvider: "telegram" }),
302+
);
303+
expect(hookRunner.runAfterCompaction).toHaveBeenCalledWith(
304+
{
305+
messageCount: 1,
306+
tokenCount: 10,
307+
compactedCount: 1,
308+
},
309+
expect.objectContaining({ sessionKey: "agent:main:session-1", messageProvider: "telegram" }),
310+
);
311+
});
312+
313+
it("uses sessionId as hook session key fallback when sessionKey is missing", async () => {
314+
hookRunner.hasHooks.mockReturnValue(true);
315+
316+
const result = await compactEmbeddedPiSessionDirect({
317+
sessionId: "session-1",
318+
sessionFile: "/tmp/session.jsonl",
319+
workspaceDir: "/tmp",
320+
customInstructions: "focus on decisions",
321+
});
322+
323+
expect(result.ok).toBe(true);
324+
expect(sessionHook("compact:before")?.sessionKey).toBe("session-1");
325+
expect(sessionHook("compact:after")?.sessionKey).toBe("session-1");
326+
expect(hookRunner.runBeforeCompaction).toHaveBeenCalledWith(
327+
expect.any(Object),
328+
expect.objectContaining({ sessionKey: "session-1" }),
329+
);
330+
expect(hookRunner.runAfterCompaction).toHaveBeenCalledWith(
331+
expect.any(Object),
332+
expect.objectContaining({ sessionKey: "session-1" }),
333+
);
334+
});
335+
336+
it("applies validated transcript before hooks even when it becomes empty", async () => {
337+
hookRunner.hasHooks.mockReturnValue(true);
338+
sanitizeSessionHistoryMock.mockResolvedValue([]);
339+
340+
const result = await compactEmbeddedPiSessionDirect({
341+
sessionId: "session-1",
342+
sessionKey: "agent:main:session-1",
343+
sessionFile: "/tmp/session.jsonl",
344+
workspaceDir: "/tmp",
345+
customInstructions: "focus on decisions",
346+
});
347+
348+
expect(result.ok).toBe(true);
349+
const beforeContext = sessionHook("compact:before")?.context;
350+
expect(beforeContext).toMatchObject({
351+
messageCountOriginal: 0,
352+
tokenCountOriginal: 0,
353+
messageCount: 0,
354+
tokenCount: 0,
355+
});
356+
});
357+
});

0 commit comments

Comments
 (0)