Files
openclaw/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts
Mert Başar c240e718e9 Feat/main session durable delivery pr (#75280)
* feat: generalize pending-final-delivery for subagents and main session

(cherry picked from commit 677fcbfaf87c8cd6de8b5bd02099b29b7d49e916)

* feat(agents): implement Phase 2 durable final delivery for main sessions

(cherry picked from commit b4e39f0ddf6dbd3f0d3b9226df8e714ad722f751)

* fix(agents): narrow heartbeat deferral to pending final delivery

* fix(agents): clear final delivery after dispatch

* fix(agents): gate durable delivery retry capture

---------

Co-authored-by: Mert Basar <MertBasar0@users.noreply.github.com>
2026-05-05 01:44:11 +08:00

217 lines
8.7 KiB
TypeScript

import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginHookReplyDispatchResult } from "../../plugins/hooks.js";
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
import {
acpManagerRuntimeMocks,
acpMocks,
agentEventMocks,
createDispatcher,
createHookCtx,
diagnosticMocks,
emptyConfig,
hookMocks,
internalHookMocks,
mocks,
resetPluginTtsAndThreadMocks,
runtimePluginMocks,
sessionBindingMocks,
sessionStoreMocks,
setDiscordTestRegistry,
} from "./dispatch-from-config.shared.test-harness.js";
let dispatchReplyFromConfig: typeof import("./dispatch-from-config.js").dispatchReplyFromConfig;
let resetInboundDedupe: typeof import("./inbound-dedupe.js").resetInboundDedupe;
describe("dispatchReplyFromConfig reply_dispatch hook", () => {
beforeAll(async () => {
({ dispatchReplyFromConfig } = await import("./dispatch-from-config.js"));
({ resetInboundDedupe } = await import("./inbound-dedupe.js"));
});
beforeEach(() => {
setDiscordTestRegistry();
resetInboundDedupe();
mocks.routeReply.mockReset().mockResolvedValue({ ok: true, messageId: "mock" });
mocks.tryFastAbortFromMessage.mockReset().mockResolvedValue({
handled: false,
aborted: false,
});
hookMocks.runner.hasHooks.mockReset();
hookMocks.runner.hasHooks.mockImplementation(
(hookName?: string) => hookName === "reply_dispatch",
);
hookMocks.runner.runInboundClaim.mockReset().mockResolvedValue(undefined);
hookMocks.runner.runInboundClaimForPlugin.mockReset().mockResolvedValue(undefined);
hookMocks.runner.runInboundClaimForPluginOutcome.mockReset().mockResolvedValue({
status: "no_handler",
});
hookMocks.runner.runMessageReceived.mockReset().mockResolvedValue(undefined);
hookMocks.runner.runBeforeDispatch.mockReset().mockResolvedValue(undefined);
hookMocks.runner.runReplyDispatch.mockReset().mockResolvedValue(undefined);
internalHookMocks.createInternalHookEvent.mockReset();
internalHookMocks.createInternalHookEvent.mockImplementation(createInternalHookEventPayload);
internalHookMocks.triggerInternalHook.mockReset().mockResolvedValue(undefined);
acpMocks.listAcpSessionEntries.mockReset().mockResolvedValue([]);
acpMocks.readAcpSessionEntry.mockReset().mockReturnValue(null);
acpMocks.upsertAcpSessionMeta.mockReset().mockResolvedValue(null);
acpMocks.requireAcpRuntimeBackend.mockReset();
sessionBindingMocks.listBySession.mockReset().mockReturnValue([]);
sessionBindingMocks.resolveByConversation.mockReset().mockReturnValue(null);
sessionBindingMocks.touch.mockReset();
sessionStoreMocks.currentEntry = undefined;
sessionStoreMocks.loadSessionStore.mockReset().mockReturnValue({});
sessionStoreMocks.resolveStorePath.mockReset().mockReturnValue("/tmp/mock-sessions.json");
sessionStoreMocks.resolveSessionStoreEntry.mockReset().mockReturnValue({ existing: undefined });
sessionStoreMocks.updateSessionStoreEntry.mockClear();
acpManagerRuntimeMocks.getAcpSessionManager.mockReset();
acpManagerRuntimeMocks.getAcpSessionManager.mockImplementation(() => ({
resolveSession: () => ({ kind: "none" as const }),
getObservabilitySnapshot: () => ({
runtimeCache: { activeSessions: 0, idleTtlMs: 0, evictedTotal: 0 },
turns: {
active: 0,
queueDepth: 0,
completed: 0,
failed: 0,
averageLatencyMs: 0,
maxLatencyMs: 0,
},
errorsByCode: {},
}),
runTurn: vi.fn(),
}));
agentEventMocks.emitAgentEvent.mockReset();
agentEventMocks.onAgentEvent.mockReset().mockImplementation(() => () => {});
diagnosticMocks.logMessageQueued.mockReset();
diagnosticMocks.logMessageProcessed.mockReset();
diagnosticMocks.logSessionStateChange.mockReset();
diagnosticMocks.markDiagnosticSessionProgress.mockReset();
runtimePluginMocks.ensureRuntimePluginsLoaded.mockReset();
resetPluginTtsAndThreadMocks();
});
it("returns handled dispatch results from plugins", async () => {
hookMocks.runner.runReplyDispatch.mockResolvedValue({
handled: true,
queuedFinal: true,
counts: { tool: 1, block: 2, final: 3 },
});
const result = await dispatchReplyFromConfig({
ctx: createHookCtx(),
cfg: emptyConfig,
dispatcher: createDispatcher(),
fastAbortResolver: async () => ({ handled: false, aborted: false }),
formatAbortReplyTextResolver: () => "⚙️ Agent was aborted.",
replyResolver: async () => ({ text: "model reply" }),
});
expect(runtimePluginMocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({
config: emptyConfig,
workspaceDir: expect.any(String),
});
expect(hookMocks.runner.runReplyDispatch).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:test:session",
sendPolicy: "allow",
inboundAudio: false,
}),
expect.objectContaining({
cfg: emptyConfig,
}),
);
expect(result).toEqual({
queuedFinal: true,
counts: { tool: 1, block: 2, final: 3 },
});
});
it("still applies send-policy deny after an unhandled plugin dispatch", async () => {
hookMocks.runner.runReplyDispatch.mockResolvedValue({
handled: false,
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
} satisfies PluginHookReplyDispatchResult);
const result = await dispatchReplyFromConfig({
ctx: createHookCtx(),
cfg: {
...emptyConfig,
session: {
sendPolicy: { default: "deny" },
},
},
dispatcher: createDispatcher(),
replyResolver: async () => ({ text: "model reply" }),
});
expect(hookMocks.runner.runReplyDispatch).toHaveBeenCalled();
expect(result).toEqual({
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
});
});
it("clears pending final delivery after final dispatch succeeds", async () => {
hookMocks.runner.hasHooks.mockReturnValue(false);
sessionStoreMocks.currentEntry = {
sessionKey: "agent:test:session",
pendingFinalDelivery: true,
pendingFinalDeliveryText: "durable reply",
pendingFinalDeliveryCreatedAt: 1,
pendingFinalDeliveryLastAttemptAt: 2,
pendingFinalDeliveryAttemptCount: 3,
pendingFinalDeliveryLastError: "previous failure",
pendingFinalDeliveryContext: { source: "heartbeat" },
};
sessionStoreMocks.resolveSessionStoreEntry.mockReturnValue({
existing: sessionStoreMocks.currentEntry,
});
mocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock" });
const result = await dispatchReplyFromConfig({
ctx: createHookCtx(),
cfg: emptyConfig,
dispatcher: createDispatcher(),
replyResolver: async () => ({ text: "durable reply" }),
});
expect(result.queuedFinal).toBe(true);
expect(sessionStoreMocks.updateSessionStoreEntry).toHaveBeenCalledOnce();
expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toBeUndefined();
expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryText).toBeUndefined();
expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryCreatedAt).toBeUndefined();
expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryLastAttemptAt).toBeUndefined();
expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryAttemptCount).toBeUndefined();
expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryLastError).toBeUndefined();
expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryContext).toBeUndefined();
});
it("preserves pending final delivery when final dispatch fails", async () => {
hookMocks.runner.hasHooks.mockReturnValue(false);
sessionStoreMocks.currentEntry = {
sessionKey: "agent:test:session",
pendingFinalDelivery: true,
pendingFinalDeliveryText: "durable reply",
pendingFinalDeliveryCreatedAt: 1,
};
sessionStoreMocks.resolveSessionStoreEntry.mockReturnValue({
existing: sessionStoreMocks.currentEntry,
});
const dispatcher = createDispatcher();
vi.mocked(dispatcher.sendFinalReply).mockReturnValue(false);
const result = await dispatchReplyFromConfig({
ctx: createHookCtx(),
cfg: emptyConfig,
dispatcher,
replyResolver: async () => ({ text: "durable reply" }),
});
expect(result.queuedFinal).toBe(false);
expect(sessionStoreMocks.updateSessionStoreEntry).not.toHaveBeenCalled();
expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toBe(true);
expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryText).toBe("durable reply");
expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryCreatedAt).toBe(1);
});
});