mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 07:32:44 +00:00
fix: wire 9 unwired plugin hooks to core code (openclaw#14882) thanks @shtse8
Verified: - GitHub CI checks green (non-skipped) Co-authored-by: shtse8 <8020099+shtse8@users.noreply.github.com>
This commit is contained in:
13998
package-lock.json
generated
Normal file
13998
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import type { AgentEvent } from "@mariozechner/pi-agent-core";
|
|||||||
import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js";
|
import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js";
|
||||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||||
import { createInlineCodeState } from "../markdown/code-spans.js";
|
import { createInlineCodeState } from "../markdown/code-spans.js";
|
||||||
|
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
||||||
|
|
||||||
export function handleAgentStart(ctx: EmbeddedPiSubscribeContext) {
|
export function handleAgentStart(ctx: EmbeddedPiSubscribeContext) {
|
||||||
ctx.log.debug(`embedded run agent start: runId=${ctx.params.runId}`);
|
ctx.log.debug(`embedded run agent start: runId=${ctx.params.runId}`);
|
||||||
@@ -33,6 +34,21 @@ export function handleAutoCompactionStart(ctx: EmbeddedPiSubscribeContext) {
|
|||||||
stream: "compaction",
|
stream: "compaction",
|
||||||
data: { phase: "start" },
|
data: { phase: "start" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Run before_compaction plugin hook (fire-and-forget)
|
||||||
|
const hookRunner = getGlobalHookRunner();
|
||||||
|
if (hookRunner?.hasHooks("before_compaction")) {
|
||||||
|
void hookRunner
|
||||||
|
.runBeforeCompaction(
|
||||||
|
{
|
||||||
|
messageCount: ctx.params.session.messages?.length ?? 0,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
.catch((err) => {
|
||||||
|
ctx.log.warn(`before_compaction hook failed: ${String(err)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleAutoCompactionEnd(
|
export function handleAutoCompactionEnd(
|
||||||
@@ -57,6 +73,24 @@ export function handleAutoCompactionEnd(
|
|||||||
stream: "compaction",
|
stream: "compaction",
|
||||||
data: { phase: "end", willRetry },
|
data: { phase: "end", willRetry },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Run after_compaction plugin hook (fire-and-forget)
|
||||||
|
if (!willRetry) {
|
||||||
|
const hookRunnerEnd = getGlobalHookRunner();
|
||||||
|
if (hookRunnerEnd?.hasHooks("after_compaction")) {
|
||||||
|
void hookRunnerEnd
|
||||||
|
.runAfterCompaction(
|
||||||
|
{
|
||||||
|
messageCount: ctx.params.session.messages?.length ?? 0,
|
||||||
|
compactedCount: ctx.getCompactionCount(),
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
.catch((err) => {
|
||||||
|
ctx.log.warn(`after_compaction hook failed: ${String(err)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) {
|
export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { AgentEvent } from "@mariozechner/pi-agent-core";
|
import type { AgentEvent } from "@mariozechner/pi-agent-core";
|
||||||
import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js";
|
import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js";
|
||||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||||
|
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
||||||
import { normalizeTextForComparison } from "./pi-embedded-helpers.js";
|
import { normalizeTextForComparison } from "./pi-embedded-helpers.js";
|
||||||
import { isMessagingTool, isMessagingToolSendAction } from "./pi-embedded-messaging.js";
|
import { isMessagingTool, isMessagingToolSendAction } from "./pi-embedded-messaging.js";
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +14,8 @@ import {
|
|||||||
import { inferToolMetaFromArgs } from "./pi-embedded-utils.js";
|
import { inferToolMetaFromArgs } from "./pi-embedded-utils.js";
|
||||||
import { normalizeToolName } from "./tool-policy.js";
|
import { normalizeToolName } from "./tool-policy.js";
|
||||||
|
|
||||||
|
/** Track tool execution start times and args for after_tool_call hook */
|
||||||
|
const toolStartData = new Map<string, { startTime: number; args: unknown }>();
|
||||||
function extendExecMeta(toolName: string, args: unknown, meta?: string): string | undefined {
|
function extendExecMeta(toolName: string, args: unknown, meta?: string): string | undefined {
|
||||||
const normalized = toolName.trim().toLowerCase();
|
const normalized = toolName.trim().toLowerCase();
|
||||||
if (normalized !== "exec" && normalized !== "bash") {
|
if (normalized !== "exec" && normalized !== "bash") {
|
||||||
@@ -51,6 +54,9 @@ export async function handleToolExecutionStart(
|
|||||||
const toolCallId = String(evt.toolCallId);
|
const toolCallId = String(evt.toolCallId);
|
||||||
const args = evt.args;
|
const args = evt.args;
|
||||||
|
|
||||||
|
// Track start time and args for after_tool_call hook
|
||||||
|
toolStartData.set(toolCallId, { startTime: Date.now(), args });
|
||||||
|
|
||||||
if (toolName === "read") {
|
if (toolName === "read") {
|
||||||
const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
|
const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
|
||||||
const filePath = typeof record.path === "string" ? record.path.trim() : "";
|
const filePath = typeof record.path === "string" ? record.path.trim() : "";
|
||||||
@@ -226,4 +232,36 @@ export function handleToolExecutionEnd(
|
|||||||
ctx.emitToolOutput(toolName, meta, outputText);
|
ctx.emitToolOutput(toolName, meta, outputText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run after_tool_call plugin hook (fire-and-forget)
|
||||||
|
const hookRunner = getGlobalHookRunner();
|
||||||
|
if (hookRunner?.hasHooks("after_tool_call")) {
|
||||||
|
const startData = toolStartData.get(toolCallId);
|
||||||
|
toolStartData.delete(toolCallId);
|
||||||
|
const durationMs = startData?.startTime != null ? Date.now() - startData.startTime : undefined;
|
||||||
|
const toolArgs = startData?.args;
|
||||||
|
void hookRunner
|
||||||
|
.runAfterToolCall(
|
||||||
|
{
|
||||||
|
toolName,
|
||||||
|
params: (toolArgs && typeof toolArgs === "object" ? toolArgs : {}) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>,
|
||||||
|
result: sanitizedResult,
|
||||||
|
error: isToolError ? extractToolErrorMessage(sanitizedResult) : undefined,
|
||||||
|
durationMs,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
toolName,
|
||||||
|
agentId: undefined,
|
||||||
|
sessionKey: undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.catch((err) => {
|
||||||
|
ctx.log.warn(`after_tool_call hook failed: tool=${toolName} error=${String(err)}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toolStartData.delete(toolCallId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
updateSessionStore,
|
updateSessionStore,
|
||||||
} from "../../config/sessions.js";
|
} from "../../config/sessions.js";
|
||||||
import { deliverSessionMaintenanceWarning } from "../../infra/session-maintenance-warning.js";
|
import { deliverSessionMaintenanceWarning } from "../../infra/session-maintenance-warning.js";
|
||||||
|
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||||
import { normalizeMainKey } from "../../routing/session-key.js";
|
import { normalizeMainKey } from "../../routing/session-key.js";
|
||||||
import { normalizeSessionDeliveryFields } from "../../utils/delivery-context.js";
|
import { normalizeSessionDeliveryFields } from "../../utils/delivery-context.js";
|
||||||
import { resolveCommandAuthorization } from "../command-auth.js";
|
import { resolveCommandAuthorization } from "../command-auth.js";
|
||||||
@@ -382,6 +383,46 @@ export async function initSessionState(params: {
|
|||||||
IsNewSession: isNewSession ? "true" : "false",
|
IsNewSession: isNewSession ? "true" : "false",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Run session plugin hooks (fire-and-forget)
|
||||||
|
const hookRunner = getGlobalHookRunner();
|
||||||
|
if (hookRunner && isNewSession) {
|
||||||
|
const effectiveSessionId = sessionId ?? "";
|
||||||
|
|
||||||
|
// If replacing an existing session, fire session_end for the old one
|
||||||
|
if (previousSessionEntry?.sessionId && previousSessionEntry.sessionId !== effectiveSessionId) {
|
||||||
|
if (hookRunner.hasHooks("session_end")) {
|
||||||
|
void hookRunner
|
||||||
|
.runSessionEnd(
|
||||||
|
{
|
||||||
|
sessionId: previousSessionEntry.sessionId,
|
||||||
|
messageCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sessionId: previousSessionEntry.sessionId,
|
||||||
|
agentId: resolveSessionAgentId({ sessionKey, config: cfg }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire session_start for the new session
|
||||||
|
if (hookRunner.hasHooks("session_start")) {
|
||||||
|
void hookRunner
|
||||||
|
.runSessionStart(
|
||||||
|
{
|
||||||
|
sessionId: effectiveSessionId,
|
||||||
|
resumedFrom: previousSessionEntry?.sessionId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sessionId: effectiveSessionId,
|
||||||
|
agentId: resolveSessionAgentId({ sessionKey, config: cfg }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionCtx,
|
sessionCtx,
|
||||||
sessionEntry,
|
sessionEntry,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js";
|
import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js";
|
||||||
import { startDiagnosticHeartbeat, stopDiagnosticHeartbeat } from "../logging/diagnostic.js";
|
import { startDiagnosticHeartbeat, stopDiagnosticHeartbeat } from "../logging/diagnostic.js";
|
||||||
import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js";
|
import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js";
|
||||||
|
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
||||||
import { runOnboardingWizard } from "../wizard/onboarding.js";
|
import { runOnboardingWizard } from "../wizard/onboarding.js";
|
||||||
import { startGatewayConfigReloader } from "./config-reload.js";
|
import { startGatewayConfigReloader } from "./config-reload.js";
|
||||||
import { ExecApprovalManager } from "./exec-approval-manager.js";
|
import { ExecApprovalManager } from "./exec-approval-manager.js";
|
||||||
@@ -558,6 +559,16 @@ export async function startGatewayServer(
|
|||||||
logBrowser,
|
logBrowser,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Run gateway_start plugin hook (fire-and-forget)
|
||||||
|
{
|
||||||
|
const hookRunner = getGlobalHookRunner();
|
||||||
|
if (hookRunner?.hasHooks("gateway_start")) {
|
||||||
|
void hookRunner.runGatewayStart({ port }, { port }).catch((err) => {
|
||||||
|
log.warn(`gateway_start hook failed: ${String(err)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { applyHotReload, requestGatewayRestart } = createGatewayReloadHandlers({
|
const { applyHotReload, requestGatewayRestart } = createGatewayReloadHandlers({
|
||||||
deps,
|
deps,
|
||||||
broadcast,
|
broadcast,
|
||||||
@@ -624,6 +635,20 @@ export async function startGatewayServer(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
close: async (opts) => {
|
close: async (opts) => {
|
||||||
|
// Run gateway_stop plugin hook before shutdown
|
||||||
|
{
|
||||||
|
const hookRunner = getGlobalHookRunner();
|
||||||
|
if (hookRunner?.hasHooks("gateway_stop")) {
|
||||||
|
try {
|
||||||
|
await hookRunner.runGatewayStop(
|
||||||
|
{ reason: opts?.reason ?? "gateway stopping" },
|
||||||
|
{ port },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(`gateway_stop hook failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (diagnosticsEnabled) {
|
if (diagnosticsEnabled) {
|
||||||
stopDiagnosticHeartbeat();
|
stopDiagnosticHeartbeat();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
appendAssistantMessageToSessionTranscript,
|
appendAssistantMessageToSessionTranscript,
|
||||||
resolveMirroredTranscriptText,
|
resolveMirroredTranscriptText,
|
||||||
} from "../../config/sessions.js";
|
} from "../../config/sessions.js";
|
||||||
|
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||||
import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js";
|
import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js";
|
||||||
import { sendMessageSignal } from "../../signal/send.js";
|
import { sendMessageSignal } from "../../signal/send.js";
|
||||||
import { throwIfAborted } from "./abort.js";
|
import { throwIfAborted } from "./abort.js";
|
||||||
@@ -337,6 +338,7 @@ export async function deliverOutboundPayloads(params: {
|
|||||||
const normalized = normalizeWhatsAppPayload(payload);
|
const normalized = normalizeWhatsAppPayload(payload);
|
||||||
return normalized ? [normalized] : [];
|
return normalized ? [normalized] : [];
|
||||||
});
|
});
|
||||||
|
const hookRunner = getGlobalHookRunner();
|
||||||
for (const payload of normalizedPayloads) {
|
for (const payload of normalizedPayloads) {
|
||||||
const payloadSummary: NormalizedOutboundPayload = {
|
const payloadSummary: NormalizedOutboundPayload = {
|
||||||
text: payload.text ?? "",
|
text: payload.text ?? "",
|
||||||
@@ -345,9 +347,37 @@ export async function deliverOutboundPayloads(params: {
|
|||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
throwIfAborted(abortSignal);
|
throwIfAborted(abortSignal);
|
||||||
|
|
||||||
|
// Run message_sending plugin hook (may modify content or cancel)
|
||||||
|
let effectivePayload = payload;
|
||||||
|
if (hookRunner?.hasHooks("message_sending")) {
|
||||||
|
try {
|
||||||
|
const sendingResult = await hookRunner.runMessageSending(
|
||||||
|
{
|
||||||
|
to,
|
||||||
|
content: payloadSummary.text,
|
||||||
|
metadata: { channel, accountId, mediaUrls: payloadSummary.mediaUrls },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channelId: channel,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (sendingResult?.cancel) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (sendingResult?.content != null) {
|
||||||
|
effectivePayload = { ...payload, text: sendingResult.content };
|
||||||
|
payloadSummary.text = sendingResult.content;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Don't block delivery on hook failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
params.onPayload?.(payloadSummary);
|
params.onPayload?.(payloadSummary);
|
||||||
if (handler.sendPayload && payload.channelData) {
|
if (handler.sendPayload && effectivePayload.channelData) {
|
||||||
results.push(await handler.sendPayload(payload));
|
results.push(await handler.sendPayload(effectivePayload));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (payloadSummary.mediaUrls.length === 0) {
|
if (payloadSummary.mediaUrls.length === 0) {
|
||||||
@@ -370,7 +400,40 @@ export async function deliverOutboundPayloads(params: {
|
|||||||
results.push(await handler.sendMedia(caption, url));
|
results.push(await handler.sendMedia(caption, url));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Run message_sent plugin hook (fire-and-forget) on success
|
||||||
|
if (hookRunner?.hasHooks("message_sent")) {
|
||||||
|
void hookRunner
|
||||||
|
.runMessageSent(
|
||||||
|
{
|
||||||
|
to,
|
||||||
|
content: payloadSummary.text,
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channelId: channel,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Run message_sent plugin hook on failure (fire-and-forget)
|
||||||
|
if (hookRunner?.hasHooks("message_sent")) {
|
||||||
|
void hookRunner
|
||||||
|
.runMessageSent(
|
||||||
|
{
|
||||||
|
to,
|
||||||
|
content: payloadSummary.text,
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channelId: channel,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
if (!params.bestEffort) {
|
if (!params.bestEffort) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
200
src/plugins/wired-hooks-after-tool-call.test.ts
Normal file
200
src/plugins/wired-hooks-after-tool-call.test.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* Test: after_tool_call hook wiring (pi-embedded-subscribe.handlers.tools.ts)
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const hookMocks = vi.hoisted(() => ({
|
||||||
|
runner: {
|
||||||
|
hasHooks: vi.fn(() => false),
|
||||||
|
runAfterToolCall: vi.fn(async () => {}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../plugins/hook-runner-global.js", () => ({
|
||||||
|
getGlobalHookRunner: () => hookMocks.runner,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock agent events (used by handlers)
|
||||||
|
vi.mock("../infra/agent-events.js", () => ({
|
||||||
|
emitAgentEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("after_tool_call hook wiring", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
hookMocks.runner.hasHooks.mockReset();
|
||||||
|
hookMocks.runner.hasHooks.mockReturnValue(false);
|
||||||
|
hookMocks.runner.runAfterToolCall.mockReset();
|
||||||
|
hookMocks.runner.runAfterToolCall.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls runAfterToolCall in handleToolExecutionEnd when hook is registered", async () => {
|
||||||
|
hookMocks.runner.hasHooks.mockReturnValue(true);
|
||||||
|
|
||||||
|
const { handleToolExecutionEnd, handleToolExecutionStart } =
|
||||||
|
await import("../agents/pi-embedded-subscribe.handlers.tools.js");
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
params: {
|
||||||
|
runId: "test-run-1",
|
||||||
|
session: { messages: [] },
|
||||||
|
agentId: "main",
|
||||||
|
sessionKey: "test-session",
|
||||||
|
onBlockReplyFlush: undefined,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
toolMetaById: new Map<string, string | undefined>(),
|
||||||
|
toolMetas: [] as Array<{ toolName?: string; meta?: string }>,
|
||||||
|
toolSummaryById: new Set<string>(),
|
||||||
|
lastToolError: undefined,
|
||||||
|
pendingMessagingTexts: new Map<string, string>(),
|
||||||
|
pendingMessagingTargets: new Map<string, unknown>(),
|
||||||
|
messagingToolSentTexts: [] as string[],
|
||||||
|
messagingToolSentTextsNormalized: [] as string[],
|
||||||
|
messagingToolSentTargets: [] as unknown[],
|
||||||
|
blockBuffer: "",
|
||||||
|
},
|
||||||
|
log: { debug: vi.fn(), warn: vi.fn() },
|
||||||
|
flushBlockReplyBuffer: vi.fn(),
|
||||||
|
shouldEmitToolResult: () => false,
|
||||||
|
shouldEmitToolOutput: () => false,
|
||||||
|
emitToolSummary: vi.fn(),
|
||||||
|
emitToolOutput: vi.fn(),
|
||||||
|
trimMessagingToolSent: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await handleToolExecutionStart(
|
||||||
|
ctx as never,
|
||||||
|
{
|
||||||
|
type: "tool_execution_start",
|
||||||
|
toolName: "read",
|
||||||
|
toolCallId: "call-1",
|
||||||
|
args: { path: "/tmp/file.txt" },
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
handleToolExecutionEnd(
|
||||||
|
ctx as never,
|
||||||
|
{
|
||||||
|
type: "tool_execution_end",
|
||||||
|
toolName: "read",
|
||||||
|
toolCallId: "call-1",
|
||||||
|
isError: false,
|
||||||
|
result: { content: [{ type: "text", text: "file contents" }] },
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(hookMocks.runner.runAfterToolCall).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [event, context] = hookMocks.runner.runAfterToolCall.mock.calls[0];
|
||||||
|
expect(event.toolName).toBe("read");
|
||||||
|
expect(event.params).toEqual({ path: "/tmp/file.txt" });
|
||||||
|
expect(event.error).toBeUndefined();
|
||||||
|
expect(typeof event.durationMs).toBe("number");
|
||||||
|
expect(context.toolName).toBe("read");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes error in after_tool_call event on tool failure", async () => {
|
||||||
|
hookMocks.runner.hasHooks.mockReturnValue(true);
|
||||||
|
|
||||||
|
const { handleToolExecutionEnd, handleToolExecutionStart } =
|
||||||
|
await import("../agents/pi-embedded-subscribe.handlers.tools.js");
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
params: {
|
||||||
|
runId: "test-run-2",
|
||||||
|
session: { messages: [] },
|
||||||
|
onBlockReplyFlush: undefined,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
toolMetaById: new Map<string, string | undefined>(),
|
||||||
|
toolMetas: [] as Array<{ toolName?: string; meta?: string }>,
|
||||||
|
toolSummaryById: new Set<string>(),
|
||||||
|
lastToolError: undefined,
|
||||||
|
pendingMessagingTexts: new Map<string, string>(),
|
||||||
|
pendingMessagingTargets: new Map<string, unknown>(),
|
||||||
|
messagingToolSentTexts: [] as string[],
|
||||||
|
messagingToolSentTextsNormalized: [] as string[],
|
||||||
|
messagingToolSentTargets: [] as unknown[],
|
||||||
|
blockBuffer: "",
|
||||||
|
},
|
||||||
|
log: { debug: vi.fn(), warn: vi.fn() },
|
||||||
|
flushBlockReplyBuffer: vi.fn(),
|
||||||
|
shouldEmitToolResult: () => false,
|
||||||
|
shouldEmitToolOutput: () => false,
|
||||||
|
emitToolSummary: vi.fn(),
|
||||||
|
emitToolOutput: vi.fn(),
|
||||||
|
trimMessagingToolSent: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await handleToolExecutionStart(
|
||||||
|
ctx as never,
|
||||||
|
{
|
||||||
|
type: "tool_execution_start",
|
||||||
|
toolName: "exec",
|
||||||
|
toolCallId: "call-err",
|
||||||
|
args: { command: "fail" },
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
handleToolExecutionEnd(
|
||||||
|
ctx as never,
|
||||||
|
{
|
||||||
|
type: "tool_execution_end",
|
||||||
|
toolName: "exec",
|
||||||
|
toolCallId: "call-err",
|
||||||
|
isError: true,
|
||||||
|
result: { status: "error", error: "command failed" },
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(hookMocks.runner.runAfterToolCall).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [event] = hookMocks.runner.runAfterToolCall.mock.calls[0];
|
||||||
|
expect(event.error).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call runAfterToolCall when no hooks registered", async () => {
|
||||||
|
hookMocks.runner.hasHooks.mockReturnValue(false);
|
||||||
|
|
||||||
|
const { handleToolExecutionEnd } =
|
||||||
|
await import("../agents/pi-embedded-subscribe.handlers.tools.js");
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
params: { runId: "r", session: { messages: [] } },
|
||||||
|
state: {
|
||||||
|
toolMetaById: new Map<string, string | undefined>(),
|
||||||
|
toolMetas: [] as Array<{ toolName?: string; meta?: string }>,
|
||||||
|
toolSummaryById: new Set<string>(),
|
||||||
|
lastToolError: undefined,
|
||||||
|
pendingMessagingTexts: new Map<string, string>(),
|
||||||
|
pendingMessagingTargets: new Map<string, unknown>(),
|
||||||
|
messagingToolSentTexts: [] as string[],
|
||||||
|
messagingToolSentTextsNormalized: [] as string[],
|
||||||
|
messagingToolSentTargets: [] as unknown[],
|
||||||
|
},
|
||||||
|
log: { debug: vi.fn(), warn: vi.fn() },
|
||||||
|
shouldEmitToolResult: () => false,
|
||||||
|
shouldEmitToolOutput: () => false,
|
||||||
|
emitToolSummary: vi.fn(),
|
||||||
|
emitToolOutput: vi.fn(),
|
||||||
|
trimMessagingToolSent: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
handleToolExecutionEnd(
|
||||||
|
ctx as never,
|
||||||
|
{
|
||||||
|
type: "tool_execution_end",
|
||||||
|
toolName: "exec",
|
||||||
|
toolCallId: "call-2",
|
||||||
|
isError: false,
|
||||||
|
result: {},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(hookMocks.runner.runAfterToolCall).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
113
src/plugins/wired-hooks-compaction.test.ts
Normal file
113
src/plugins/wired-hooks-compaction.test.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Test: before_compaction & after_compaction hook wiring
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const hookMocks = vi.hoisted(() => ({
|
||||||
|
runner: {
|
||||||
|
hasHooks: vi.fn(() => false),
|
||||||
|
runBeforeCompaction: vi.fn(async () => {}),
|
||||||
|
runAfterCompaction: vi.fn(async () => {}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../plugins/hook-runner-global.js", () => ({
|
||||||
|
getGlobalHookRunner: () => hookMocks.runner,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../infra/agent-events.js", () => ({
|
||||||
|
emitAgentEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("compaction hook wiring", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
hookMocks.runner.hasHooks.mockReset();
|
||||||
|
hookMocks.runner.hasHooks.mockReturnValue(false);
|
||||||
|
hookMocks.runner.runBeforeCompaction.mockReset();
|
||||||
|
hookMocks.runner.runBeforeCompaction.mockResolvedValue(undefined);
|
||||||
|
hookMocks.runner.runAfterCompaction.mockReset();
|
||||||
|
hookMocks.runner.runAfterCompaction.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls runBeforeCompaction in handleAutoCompactionStart", async () => {
|
||||||
|
hookMocks.runner.hasHooks.mockReturnValue(true);
|
||||||
|
|
||||||
|
const { handleAutoCompactionStart } =
|
||||||
|
await import("../agents/pi-embedded-subscribe.handlers.lifecycle.js");
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
params: { runId: "r1", session: { messages: [1, 2, 3] } },
|
||||||
|
state: { compactionInFlight: false },
|
||||||
|
log: { debug: vi.fn(), warn: vi.fn() },
|
||||||
|
incrementCompactionCount: vi.fn(),
|
||||||
|
ensureCompactionPromise: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
handleAutoCompactionStart(ctx as never);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(hookMocks.runner.runBeforeCompaction).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [event] = hookMocks.runner.runBeforeCompaction.mock.calls[0];
|
||||||
|
expect(event.messageCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls runAfterCompaction when willRetry is false", async () => {
|
||||||
|
hookMocks.runner.hasHooks.mockReturnValue(true);
|
||||||
|
|
||||||
|
const { handleAutoCompactionEnd } =
|
||||||
|
await import("../agents/pi-embedded-subscribe.handlers.lifecycle.js");
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
params: { runId: "r2", session: { messages: [1, 2] } },
|
||||||
|
state: { compactionInFlight: true },
|
||||||
|
log: { debug: vi.fn(), warn: vi.fn() },
|
||||||
|
maybeResolveCompactionWait: vi.fn(),
|
||||||
|
getCompactionCount: () => 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleAutoCompactionEnd(
|
||||||
|
ctx as never,
|
||||||
|
{
|
||||||
|
type: "auto_compaction_end",
|
||||||
|
willRetry: false,
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(hookMocks.runner.runAfterCompaction).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [event] = hookMocks.runner.runAfterCompaction.mock.calls[0];
|
||||||
|
expect(event.messageCount).toBe(2);
|
||||||
|
expect(event.compactedCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call runAfterCompaction when willRetry is true", async () => {
|
||||||
|
hookMocks.runner.hasHooks.mockReturnValue(true);
|
||||||
|
|
||||||
|
const { handleAutoCompactionEnd } =
|
||||||
|
await import("../agents/pi-embedded-subscribe.handlers.lifecycle.js");
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
params: { runId: "r3", session: { messages: [] } },
|
||||||
|
state: { compactionInFlight: true },
|
||||||
|
log: { debug: vi.fn(), warn: vi.fn() },
|
||||||
|
noteCompactionRetry: vi.fn(),
|
||||||
|
resetForCompactionRetry: vi.fn(),
|
||||||
|
getCompactionCount: () => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleAutoCompactionEnd(
|
||||||
|
ctx as never,
|
||||||
|
{
|
||||||
|
type: "auto_compaction_end",
|
||||||
|
willRetry: true,
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
expect(hookMocks.runner.runAfterCompaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
64
src/plugins/wired-hooks-gateway.test.ts
Normal file
64
src/plugins/wired-hooks-gateway.test.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Test: gateway_start & gateway_stop hook wiring (server.impl.ts)
|
||||||
|
*
|
||||||
|
* Since startGatewayServer is heavily integrated, we test the hook runner
|
||||||
|
* calls at the unit level by verifying the hook runner functions exist
|
||||||
|
* and validating the integration pattern.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { PluginRegistry } from "./registry.js";
|
||||||
|
import { createHookRunner } from "./hooks.js";
|
||||||
|
|
||||||
|
function createMockRegistry(
|
||||||
|
hooks: Array<{ hookName: string; handler: (...args: unknown[]) => unknown }>,
|
||||||
|
): PluginRegistry {
|
||||||
|
return {
|
||||||
|
hooks: hooks as never[],
|
||||||
|
typedHooks: hooks.map((h) => ({
|
||||||
|
pluginId: "test-plugin",
|
||||||
|
hookName: h.hookName,
|
||||||
|
handler: h.handler,
|
||||||
|
priority: 0,
|
||||||
|
source: "test",
|
||||||
|
})),
|
||||||
|
tools: [],
|
||||||
|
httpHandlers: [],
|
||||||
|
httpRoutes: [],
|
||||||
|
channelRegistrations: [],
|
||||||
|
gatewayHandlers: {},
|
||||||
|
cliRegistrars: [],
|
||||||
|
services: [],
|
||||||
|
providers: [],
|
||||||
|
commands: [],
|
||||||
|
} as unknown as PluginRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("gateway hook runner methods", () => {
|
||||||
|
it("runGatewayStart invokes registered gateway_start hooks", async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const registry = createMockRegistry([{ hookName: "gateway_start", handler }]);
|
||||||
|
const runner = createHookRunner(registry);
|
||||||
|
|
||||||
|
await runner.runGatewayStart({ port: 18789 }, { port: 18789 });
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith({ port: 18789 }, { port: 18789 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runGatewayStop invokes registered gateway_stop hooks", async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const registry = createMockRegistry([{ hookName: "gateway_stop", handler }]);
|
||||||
|
const runner = createHookRunner(registry);
|
||||||
|
|
||||||
|
await runner.runGatewayStop({ reason: "test shutdown" }, { port: 18789 });
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith({ reason: "test shutdown" }, { port: 18789 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasHooks returns true for registered gateway hooks", () => {
|
||||||
|
const registry = createMockRegistry([{ hookName: "gateway_start", handler: vi.fn() }]);
|
||||||
|
const runner = createHookRunner(registry);
|
||||||
|
|
||||||
|
expect(runner.hasHooks("gateway_start")).toBe(true);
|
||||||
|
expect(runner.hasHooks("gateway_stop")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
98
src/plugins/wired-hooks-message.test.ts
Normal file
98
src/plugins/wired-hooks-message.test.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Test: message_sending & message_sent hook wiring
|
||||||
|
*
|
||||||
|
* Tests the hook runner methods directly since outbound delivery is deeply integrated.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { PluginRegistry } from "./registry.js";
|
||||||
|
import { createHookRunner } from "./hooks.js";
|
||||||
|
|
||||||
|
function createMockRegistry(
|
||||||
|
hooks: Array<{ hookName: string; handler: (...args: unknown[]) => unknown }>,
|
||||||
|
): PluginRegistry {
|
||||||
|
return {
|
||||||
|
hooks: hooks as never[],
|
||||||
|
typedHooks: hooks.map((h) => ({
|
||||||
|
pluginId: "test-plugin",
|
||||||
|
hookName: h.hookName,
|
||||||
|
handler: h.handler,
|
||||||
|
priority: 0,
|
||||||
|
source: "test",
|
||||||
|
})),
|
||||||
|
tools: [],
|
||||||
|
httpHandlers: [],
|
||||||
|
httpRoutes: [],
|
||||||
|
channelRegistrations: [],
|
||||||
|
gatewayHandlers: {},
|
||||||
|
cliRegistrars: [],
|
||||||
|
services: [],
|
||||||
|
providers: [],
|
||||||
|
commands: [],
|
||||||
|
} as unknown as PluginRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("message_sending hook runner", () => {
|
||||||
|
it("runMessageSending invokes registered hooks and returns modified content", async () => {
|
||||||
|
const handler = vi.fn().mockReturnValue({ content: "modified content" });
|
||||||
|
const registry = createMockRegistry([{ hookName: "message_sending", handler }]);
|
||||||
|
const runner = createHookRunner(registry);
|
||||||
|
|
||||||
|
const result = await runner.runMessageSending(
|
||||||
|
{ to: "user-123", content: "original content" },
|
||||||
|
{ channelId: "telegram" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith(
|
||||||
|
{ to: "user-123", content: "original content" },
|
||||||
|
{ channelId: "telegram" },
|
||||||
|
);
|
||||||
|
expect(result?.content).toBe("modified content");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runMessageSending can cancel message delivery", async () => {
|
||||||
|
const handler = vi.fn().mockReturnValue({ cancel: true });
|
||||||
|
const registry = createMockRegistry([{ hookName: "message_sending", handler }]);
|
||||||
|
const runner = createHookRunner(registry);
|
||||||
|
|
||||||
|
const result = await runner.runMessageSending(
|
||||||
|
{ to: "user-123", content: "blocked" },
|
||||||
|
{ channelId: "telegram" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result?.cancel).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("message_sent hook runner", () => {
|
||||||
|
it("runMessageSent invokes registered hooks with success=true", async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const registry = createMockRegistry([{ hookName: "message_sent", handler }]);
|
||||||
|
const runner = createHookRunner(registry);
|
||||||
|
|
||||||
|
await runner.runMessageSent(
|
||||||
|
{ to: "user-123", content: "hello", success: true },
|
||||||
|
{ channelId: "telegram" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith(
|
||||||
|
{ to: "user-123", content: "hello", success: true },
|
||||||
|
{ channelId: "telegram" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runMessageSent invokes registered hooks with error on failure", async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const registry = createMockRegistry([{ hookName: "message_sent", handler }]);
|
||||||
|
const runner = createHookRunner(registry);
|
||||||
|
|
||||||
|
await runner.runMessageSent(
|
||||||
|
{ to: "user-123", content: "hello", success: false, error: "timeout" },
|
||||||
|
{ channelId: "telegram" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith(
|
||||||
|
{ to: "user-123", content: "hello", success: false, error: "timeout" },
|
||||||
|
{ channelId: "telegram" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
74
src/plugins/wired-hooks-session.test.ts
Normal file
74
src/plugins/wired-hooks-session.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Test: session_start & session_end hook wiring
|
||||||
|
*
|
||||||
|
* Tests the hook runner methods directly since session init is deeply integrated.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { PluginRegistry } from "./registry.js";
|
||||||
|
import { createHookRunner } from "./hooks.js";
|
||||||
|
|
||||||
|
function createMockRegistry(
|
||||||
|
hooks: Array<{ hookName: string; handler: (...args: unknown[]) => unknown }>,
|
||||||
|
): PluginRegistry {
|
||||||
|
return {
|
||||||
|
hooks: hooks as never[],
|
||||||
|
typedHooks: hooks.map((h) => ({
|
||||||
|
pluginId: "test-plugin",
|
||||||
|
hookName: h.hookName,
|
||||||
|
handler: h.handler,
|
||||||
|
priority: 0,
|
||||||
|
source: "test",
|
||||||
|
})),
|
||||||
|
tools: [],
|
||||||
|
httpHandlers: [],
|
||||||
|
httpRoutes: [],
|
||||||
|
channelRegistrations: [],
|
||||||
|
gatewayHandlers: {},
|
||||||
|
cliRegistrars: [],
|
||||||
|
services: [],
|
||||||
|
providers: [],
|
||||||
|
commands: [],
|
||||||
|
} as unknown as PluginRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("session hook runner methods", () => {
|
||||||
|
it("runSessionStart invokes registered session_start hooks", async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const registry = createMockRegistry([{ hookName: "session_start", handler }]);
|
||||||
|
const runner = createHookRunner(registry);
|
||||||
|
|
||||||
|
await runner.runSessionStart(
|
||||||
|
{ sessionId: "abc-123", resumedFrom: "old-session" },
|
||||||
|
{ sessionId: "abc-123", agentId: "main" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith(
|
||||||
|
{ sessionId: "abc-123", resumedFrom: "old-session" },
|
||||||
|
{ sessionId: "abc-123", agentId: "main" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runSessionEnd invokes registered session_end hooks", async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const registry = createMockRegistry([{ hookName: "session_end", handler }]);
|
||||||
|
const runner = createHookRunner(registry);
|
||||||
|
|
||||||
|
await runner.runSessionEnd(
|
||||||
|
{ sessionId: "abc-123", messageCount: 42 },
|
||||||
|
{ sessionId: "abc-123", agentId: "main" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith(
|
||||||
|
{ sessionId: "abc-123", messageCount: 42 },
|
||||||
|
{ sessionId: "abc-123", agentId: "main" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasHooks returns true for registered session hooks", () => {
|
||||||
|
const registry = createMockRegistry([{ hookName: "session_start", handler: vi.fn() }]);
|
||||||
|
const runner = createHookRunner(registry);
|
||||||
|
|
||||||
|
expect(runner.hasHooks("session_start")).toBe(true);
|
||||||
|
expect(runner.hasHooks("session_end")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user