mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-14 12:50:44 +00:00
* 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>
217 lines
8.7 KiB
TypeScript
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);
|
|
});
|
|
});
|