test: expand regression coverage for restart sentinel and delivery context
Grows test count from 21 → 71 across three test files. Each suite now
covers the full expected-behavior matrix to catch regressions.
gateway-tool.test.ts (10 → 29 tests)
- RPC delivery context suite: happy-path full context forwarding,
agentAccountId/agentThreadId included, all three write actions
(config.apply, config.patch, update.run), partial context suppression
(missing channel or to), empty-string guards, stale heartbeat override,
same-session aliases ('main' canonicalization), cross-session / cross-agent
suppression, remote gateway suppression (explicit URL + gateway.mode=remote),
local loopback forwarding, undefined (default local) forwarding
- Restart sentinel suite: live context used/suppressed, heartbeat override,
threadId included / excluded on cross-session, cross-agent 'main' alias,
kind/status/sessionKey fields on the payload
server-restart-sentinel.test.ts (4 → 16 tests)
- Two-step delivery+resume: ordering assertion, senderIsOwner=false guard,
no-op when no sentinel file
- Fallback suite: no sessionKey, unresolvable target, missing channel/to,
agentCommand throws (notice already delivered), deliverOutboundPayloads
throws (resume still runs)
- Thread routing: Slack replyToId mapping, non-Slack threadId passthrough,
agentCommand receives threadId, sentinel threadId beats session-derived
- Context priority: sentinel beats stale heartbeat store, session-store
fallback when sentinel has no deliveryContext
restart-request.test.ts (7 → 26 tests)
- Reject absent/null/non-object deliveryContext
- Reject partial contexts (channel-only, to-only, empty strings, whitespace)
- Reject non-string field types (number, boolean)
- Accept full contexts with all combinations of optional fields
- Whitespace trimming for all four fields
- undefined returned for empty-after-trim optional fields
- Extra/unknown fields are ignored
This commit is contained in:
parent
fd1dd6fa80
commit
93f4a3a7f7
@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isRestartEnabled: vi.fn(() => true),
|
||||
@ -52,46 +52,54 @@ function getCallArg<T>(mockFn: { mock: { calls: unknown[] } }, callIdx: number,
|
||||
return calls[callIdx]?.[argIdx] as T;
|
||||
}
|
||||
|
||||
describe("createGatewayTool – live delivery context guard", () => {
|
||||
it("does not forward liveDeliveryContextForRpc when agentTo is missing", async () => {
|
||||
mocks.callGatewayTool.mockClear();
|
||||
const tool = createGatewayTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "discord",
|
||||
agentTo: undefined, // intentionally missing
|
||||
});
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers to build common test fixtures
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
await execTool(tool, {
|
||||
action: "config.patch",
|
||||
raw: '{"key":"value"}',
|
||||
baseHash: "abc123",
|
||||
sessionKey: "agent:main:main",
|
||||
note: "test patch",
|
||||
});
|
||||
function makePatchParams(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
action: "config.patch",
|
||||
raw: '{"key":"value"}',
|
||||
baseHash: "abc123",
|
||||
sessionKey: "agent:main:main",
|
||||
note: "test patch",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const forwardedParams = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
// deliveryContext should be undefined — falling back to server-side extractDeliveryInfo
|
||||
expect(forwardedParams?.deliveryContext).toBeUndefined();
|
||||
function makeTool(
|
||||
opts: {
|
||||
agentSessionKey?: string;
|
||||
agentChannel?: string;
|
||||
agentTo?: string;
|
||||
agentThreadId?: string;
|
||||
agentAccountId?: string;
|
||||
} = {},
|
||||
) {
|
||||
return createGatewayTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "discord",
|
||||
agentTo: "123456789",
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Suite 1 — Live delivery context for RPC actions (config.apply / config.patch / update.run)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("createGatewayTool – RPC delivery context forwarding", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("forwards liveDeliveryContextForRpc when both agentChannel and agentTo are present", async () => {
|
||||
mocks.callGatewayTool.mockClear();
|
||||
const tool = createGatewayTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "discord",
|
||||
agentTo: "123456789",
|
||||
});
|
||||
// ── Happy path: full live context forwarded ──────────────────────────────
|
||||
|
||||
await execTool(tool, {
|
||||
action: "config.patch",
|
||||
raw: '{"key":"value"}',
|
||||
baseHash: "abc123",
|
||||
sessionKey: "agent:main:main",
|
||||
note: "test patch",
|
||||
});
|
||||
it("forwards liveDeliveryContext when agentChannel and agentTo are both present", async () => {
|
||||
await execTool(makeTool(), makePatchParams());
|
||||
|
||||
const forwardedParams = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(forwardedParams?.deliveryContext).toEqual({
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toEqual({
|
||||
channel: "discord",
|
||||
to: "123456789",
|
||||
accountId: undefined,
|
||||
@ -99,207 +107,343 @@ describe("createGatewayTool – live delivery context guard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("includes threadId in liveDeliveryContextForRpc when agentThreadId is present", async () => {
|
||||
mocks.callGatewayTool.mockClear();
|
||||
const tool = createGatewayTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "slack",
|
||||
agentTo: "C012AB3CD",
|
||||
agentThreadId: "1234567890.123456",
|
||||
});
|
||||
it("includes agentAccountId in forwarded context when provided", async () => {
|
||||
await execTool(makeTool({ agentAccountId: "acct-99" }), makePatchParams());
|
||||
|
||||
await execTool(tool, {
|
||||
action: "config.patch",
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect((p?.deliveryContext as Record<string, unknown>)?.accountId).toBe("acct-99");
|
||||
});
|
||||
|
||||
it("includes agentThreadId in forwarded context when provided", async () => {
|
||||
await execTool(
|
||||
makeTool({ agentChannel: "slack", agentTo: "C012AB3CD", agentThreadId: "1234567890.123" }),
|
||||
makePatchParams({ sessionKey: "agent:main:main" }),
|
||||
);
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect((p?.deliveryContext as Record<string, unknown>)?.threadId).toBe("1234567890.123");
|
||||
expect((p?.deliveryContext as Record<string, unknown>)?.channel).toBe("slack");
|
||||
});
|
||||
|
||||
it("forwards live context for config.apply as well as config.patch", async () => {
|
||||
await execTool(makeTool(), {
|
||||
action: "config.apply",
|
||||
raw: '{"key":"value"}',
|
||||
baseHash: "abc123",
|
||||
sessionKey: "agent:main:main",
|
||||
note: "test patch",
|
||||
});
|
||||
|
||||
const forwardedParams = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(forwardedParams?.deliveryContext).toEqual({
|
||||
channel: "slack",
|
||||
to: "C012AB3CD",
|
||||
accountId: undefined,
|
||||
threadId: "1234567890.123456",
|
||||
});
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeDefined();
|
||||
expect((p?.deliveryContext as Record<string, unknown>)?.channel).toBe("discord");
|
||||
});
|
||||
|
||||
it("does not forward live restart context when agentTo is missing", async () => {
|
||||
mocks.writeRestartSentinel.mockClear();
|
||||
it("forwards live context for update.run", async () => {
|
||||
await execTool(makeTool(), {
|
||||
action: "update.run",
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeDefined();
|
||||
expect((p?.deliveryContext as Record<string, unknown>)?.channel).toBe("discord");
|
||||
});
|
||||
|
||||
// ── Partial live context — must be suppressed ────────────────────────────
|
||||
|
||||
it("suppresses deliveryContext when agentTo is missing", async () => {
|
||||
await execTool(makeTool({ agentTo: undefined }), makePatchParams());
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("suppresses deliveryContext when agentChannel is missing", async () => {
|
||||
await execTool(makeTool({ agentChannel: undefined }), makePatchParams());
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("suppresses deliveryContext when agentChannel is an empty string", async () => {
|
||||
await execTool(makeTool({ agentChannel: "" }), makePatchParams());
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to server extractDeliveryInfo when live context is suppressed", async () => {
|
||||
// Confirm the RPC call still goes through — server side will use extractDeliveryInfo
|
||||
await execTool(makeTool({ agentTo: undefined }), makePatchParams());
|
||||
|
||||
expect(mocks.callGatewayTool).toHaveBeenCalled();
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Stale heartbeat override prevention ─────────────────────────────────
|
||||
|
||||
it("overrides stale heartbeat deliveryContext from extractDeliveryInfo with live context", async () => {
|
||||
// extractDeliveryInfo returning heartbeat sink — must not win over live context
|
||||
mocks.extractDeliveryInfo.mockReturnValueOnce({
|
||||
deliveryContext: { channel: "telegram", to: "+19995550001", accountId: undefined },
|
||||
deliveryContext: { channel: "webchat", to: "heartbeat", accountId: undefined },
|
||||
threadId: undefined,
|
||||
});
|
||||
|
||||
const tool = createGatewayTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "discord",
|
||||
agentTo: undefined, // intentionally missing
|
||||
});
|
||||
await execTool(makeTool(), makePatchParams());
|
||||
|
||||
await execTool(tool, { action: "restart" });
|
||||
|
||||
const sentinelPayload = getCallArg<{ deliveryContext?: { channel?: string; to?: string } }>(
|
||||
mocks.writeRestartSentinel,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
// Should fall back to extractDeliveryInfo() result, not the incomplete live context
|
||||
expect(sentinelPayload?.deliveryContext?.channel).toBe("telegram");
|
||||
expect(sentinelPayload?.deliveryContext?.to).toBe("+19995550001");
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect((p?.deliveryContext as Record<string, unknown>)?.channel).toBe("discord");
|
||||
expect((p?.deliveryContext as Record<string, unknown>)?.to).toBe("123456789");
|
||||
});
|
||||
|
||||
it("uses live restart context when both agentChannel and agentTo are present", async () => {
|
||||
mocks.writeRestartSentinel.mockClear();
|
||||
// ── Session key targeting: same-session ─────────────────────────────────
|
||||
|
||||
const tool = createGatewayTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "discord",
|
||||
agentTo: "123456789",
|
||||
});
|
||||
|
||||
await execTool(tool, { action: "restart" });
|
||||
|
||||
const sentinelPayload = getCallArg<{ deliveryContext?: { channel?: string; to?: string } }>(
|
||||
mocks.writeRestartSentinel,
|
||||
0,
|
||||
0,
|
||||
it("forwards live context when sessionKey matches own session key exactly", async () => {
|
||||
await execTool(
|
||||
makeTool({ agentSessionKey: "agent:main:main" }),
|
||||
makePatchParams({ sessionKey: "agent:main:main" }),
|
||||
);
|
||||
expect(sentinelPayload?.deliveryContext?.channel).toBe("discord");
|
||||
expect(sentinelPayload?.deliveryContext?.to).toBe("123456789");
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not forward live RPC delivery context when gateway.mode=remote is configured (no URL override)", async () => {
|
||||
// When gateway.mode=remote is set in config, callGatewayTool() routes to
|
||||
// gateway.remote.url without an explicit gatewayUrl param. resolveGatewayTarget
|
||||
// must return "remote" in this case so deliveryContext is suppressed, preventing
|
||||
// the remote sentinel from being stamped with the local chat route.
|
||||
mocks.callGatewayTool.mockClear();
|
||||
// No gatewayUrl override — config-based remote mode
|
||||
it("forwards live context when 'main' alias resolves to own default-agent session", async () => {
|
||||
// agentSessionKey is "agent:main:main"; sessionKey "main" should canonicalize to the same
|
||||
await execTool(
|
||||
makeTool({ agentSessionKey: "agent:main:main" }),
|
||||
makePatchParams({ sessionKey: "main" }),
|
||||
);
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeDefined();
|
||||
expect((p?.deliveryContext as Record<string, unknown>)?.channel).toBe("discord");
|
||||
});
|
||||
|
||||
it("forwards live context when sessionKey is omitted (defaults to own session)", async () => {
|
||||
await execTool(makeTool(), makePatchParams({ sessionKey: undefined }));
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeDefined();
|
||||
});
|
||||
|
||||
// ── Session key targeting: cross-session / cross-agent ───────────────────
|
||||
|
||||
it("suppresses deliveryContext when sessionKey targets a different session", async () => {
|
||||
await execTool(
|
||||
makeTool({ agentSessionKey: "agent:main:main" }),
|
||||
makePatchParams({ sessionKey: "agent:other-claw:main" }),
|
||||
);
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("suppresses deliveryContext when non-default agent passes sessionKey='main' (cross-agent alias)", async () => {
|
||||
// "main" resolves to "agent:main:main" (default), not "agent:shopping-claw:main"
|
||||
await execTool(
|
||||
makeTool({ agentSessionKey: "agent:shopping-claw:main" }),
|
||||
makePatchParams({ sessionKey: "main" }),
|
||||
);
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Remote gateway targeting ─────────────────────────────────────────────
|
||||
|
||||
it("suppresses deliveryContext when resolveGatewayTarget returns 'remote' (explicit URL)", async () => {
|
||||
mocks.readGatewayCallOptions.mockReturnValueOnce({ gatewayUrl: "wss://remote.example.com" });
|
||||
mocks.resolveGatewayTarget.mockReturnValueOnce("remote");
|
||||
|
||||
await execTool(
|
||||
makeTool(),
|
||||
makePatchParams({ gatewayUrl: "wss://remote.example.com", sessionKey: "agent:main:main" }),
|
||||
);
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("suppresses deliveryContext when resolveGatewayTarget returns 'remote' (config gateway.mode=remote)", async () => {
|
||||
mocks.readGatewayCallOptions.mockReturnValueOnce({});
|
||||
mocks.resolveGatewayTarget.mockReturnValueOnce("remote");
|
||||
const tool = createGatewayTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "discord",
|
||||
agentTo: "123456789",
|
||||
});
|
||||
|
||||
await execTool(tool, {
|
||||
action: "config.patch",
|
||||
raw: '{"key":"value"}',
|
||||
baseHash: "abc123",
|
||||
sessionKey: "agent:main:main",
|
||||
note: "config-remote patch (no URL override)",
|
||||
});
|
||||
await execTool(makeTool(), makePatchParams({ sessionKey: "agent:main:main" }));
|
||||
|
||||
const forwardedParams = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(forwardedParams?.deliveryContext).toBeUndefined();
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not forward live RPC delivery context when gatewayUrl targets a remote gateway", async () => {
|
||||
// A remote gateway has its own extractDeliveryInfo(sessionKey) — forwarding
|
||||
// the local agent's deliveryContext would write a sentinel with the wrong
|
||||
// destination on the remote host.
|
||||
mocks.callGatewayTool.mockClear();
|
||||
mocks.readGatewayCallOptions.mockReturnValueOnce({ gatewayUrl: "wss://remote-gw.example.com" });
|
||||
mocks.resolveGatewayTarget.mockReturnValueOnce("remote");
|
||||
const tool = createGatewayTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "discord",
|
||||
agentTo: "123456789",
|
||||
});
|
||||
|
||||
await execTool(tool, {
|
||||
action: "config.patch",
|
||||
raw: '{"key":"value"}',
|
||||
baseHash: "abc123",
|
||||
sessionKey: "agent:main:main",
|
||||
note: "remote patch",
|
||||
gatewayUrl: "wss://remote-gw.example.com",
|
||||
});
|
||||
|
||||
const forwardedParams = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(forwardedParams?.deliveryContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards live RPC delivery context when gatewayUrl is a local loopback override", async () => {
|
||||
// A gatewayUrl pointing to 127.0.0.1/localhost/[::1] is still the local server;
|
||||
// deliveryContext must be forwarded so restart sentinels use the correct chat destination.
|
||||
mocks.callGatewayTool.mockClear();
|
||||
mocks.readGatewayCallOptions.mockReturnValueOnce({
|
||||
gatewayUrl: "ws://127.0.0.1:18789",
|
||||
});
|
||||
it("forwards deliveryContext when resolveGatewayTarget returns 'local' (loopback URL)", async () => {
|
||||
mocks.readGatewayCallOptions.mockReturnValueOnce({ gatewayUrl: "ws://127.0.0.1:18789" });
|
||||
mocks.resolveGatewayTarget.mockReturnValueOnce("local");
|
||||
const tool = createGatewayTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "discord",
|
||||
agentTo: "123456789",
|
||||
});
|
||||
|
||||
await execTool(tool, {
|
||||
action: "config.patch",
|
||||
raw: '{"key":"value"}',
|
||||
baseHash: "abc123",
|
||||
sessionKey: "agent:main:main",
|
||||
note: "local loopback patch",
|
||||
gatewayUrl: "ws://127.0.0.1:18789",
|
||||
});
|
||||
await execTool(
|
||||
makeTool(),
|
||||
makePatchParams({ gatewayUrl: "ws://127.0.0.1:18789", sessionKey: "agent:main:main" }),
|
||||
);
|
||||
|
||||
const forwardedParams = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(forwardedParams?.deliveryContext).toEqual({
|
||||
channel: "discord",
|
||||
to: "123456789",
|
||||
accountId: undefined,
|
||||
});
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeDefined();
|
||||
expect((p?.deliveryContext as Record<string, unknown>)?.channel).toBe("discord");
|
||||
});
|
||||
|
||||
it("does not forward live RPC delivery context when a non-default agent passes sessionKey='main'", async () => {
|
||||
// "main" resolves to "agent:main:main" (default agent), which differs from the
|
||||
// current session "agent:shopping-claw:main". Live context must NOT be forwarded.
|
||||
mocks.callGatewayTool.mockClear();
|
||||
const tool = createGatewayTool({
|
||||
agentSessionKey: "agent:shopping-claw:main",
|
||||
agentChannel: "discord",
|
||||
agentTo: "123456789",
|
||||
});
|
||||
it("forwards deliveryContext when resolveGatewayTarget returns undefined (default local)", async () => {
|
||||
mocks.resolveGatewayTarget.mockReturnValueOnce(undefined);
|
||||
|
||||
await execTool(tool, {
|
||||
action: "config.patch",
|
||||
raw: '{"key":"value"}',
|
||||
baseHash: "abc123",
|
||||
sessionKey: "main", // targets default agent — different from current shopping-claw session
|
||||
note: "cross-agent patch",
|
||||
});
|
||||
await execTool(makeTool(), makePatchParams({ sessionKey: "agent:main:main" }));
|
||||
|
||||
const forwardedParams = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(forwardedParams?.deliveryContext).toBeUndefined();
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Suite 2 — Restart sentinel context (local restart action)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("createGatewayTool – restart sentinel delivery context", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not forward live restart context when a non-default agent passes sessionKey='main'", async () => {
|
||||
// "main" resolves to "agent:main:main" (default agent), which differs from the
|
||||
// current session "agent:shopping-claw:main". Sentinel must not carry this session's thread.
|
||||
mocks.writeRestartSentinel.mockClear();
|
||||
it("uses live context when both agentChannel and agentTo are present", async () => {
|
||||
await execTool(makeTool(), { action: "restart" });
|
||||
|
||||
const p = getCallArg<{ deliveryContext?: Record<string, unknown> }>(
|
||||
mocks.writeRestartSentinel,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
expect(p?.deliveryContext?.channel).toBe("discord");
|
||||
expect(p?.deliveryContext?.to).toBe("123456789");
|
||||
});
|
||||
|
||||
it("falls back to extractDeliveryInfo when agentTo is missing", async () => {
|
||||
mocks.extractDeliveryInfo.mockReturnValueOnce({
|
||||
deliveryContext: { channel: "telegram", to: "+19995550001", accountId: undefined },
|
||||
threadId: undefined,
|
||||
});
|
||||
|
||||
const tool = createGatewayTool({
|
||||
agentSessionKey: "agent:shopping-claw:main",
|
||||
agentChannel: "discord",
|
||||
agentTo: "123456789",
|
||||
});
|
||||
await execTool(makeTool({ agentTo: undefined }), { action: "restart" });
|
||||
|
||||
await execTool(tool, { action: "restart", sessionKey: "main" });
|
||||
|
||||
const sentinelPayload = getCallArg<{ deliveryContext?: { channel?: string; to?: string } }>(
|
||||
const p = getCallArg<{ deliveryContext?: Record<string, unknown> }>(
|
||||
mocks.writeRestartSentinel,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
// Should fall back to extractDeliveryInfo() for the targeted session, not current session's live context
|
||||
expect(sentinelPayload?.deliveryContext?.channel).toBe("telegram");
|
||||
expect(sentinelPayload?.deliveryContext?.to).toBe("+19995550001");
|
||||
expect(p?.deliveryContext?.channel).toBe("telegram");
|
||||
expect(p?.deliveryContext?.to).toBe("+19995550001");
|
||||
});
|
||||
|
||||
it("falls back to extractDeliveryInfo when agentChannel is missing", async () => {
|
||||
mocks.extractDeliveryInfo.mockReturnValueOnce({
|
||||
deliveryContext: { channel: "whatsapp", to: "+10000000001", accountId: undefined },
|
||||
threadId: undefined,
|
||||
});
|
||||
|
||||
await execTool(makeTool({ agentChannel: undefined }), { action: "restart" });
|
||||
|
||||
const p = getCallArg<{ deliveryContext?: Record<string, unknown> }>(
|
||||
mocks.writeRestartSentinel,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
expect(p?.deliveryContext?.channel).toBe("whatsapp");
|
||||
});
|
||||
|
||||
it("overrides stale heartbeat context from extractDeliveryInfo with live context", async () => {
|
||||
mocks.extractDeliveryInfo.mockReturnValueOnce({
|
||||
deliveryContext: { channel: "webchat", to: "heartbeat", accountId: undefined },
|
||||
threadId: undefined,
|
||||
});
|
||||
|
||||
await execTool(makeTool(), { action: "restart" });
|
||||
|
||||
const p = getCallArg<{ deliveryContext?: Record<string, unknown> }>(
|
||||
mocks.writeRestartSentinel,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
expect(p?.deliveryContext?.channel).toBe("discord");
|
||||
expect(p?.deliveryContext?.to).toBe("123456789");
|
||||
});
|
||||
|
||||
it("includes threadId in sentinel when agentThreadId is provided (same session)", async () => {
|
||||
await execTool(makeTool({ agentThreadId: "ts.123456" }), { action: "restart" });
|
||||
|
||||
const p = getCallArg<{ threadId?: string }>(mocks.writeRestartSentinel, 0, 0);
|
||||
expect(p?.threadId).toBe("ts.123456");
|
||||
});
|
||||
|
||||
it("uses extractDeliveryInfo threadId when targeting a different session", async () => {
|
||||
mocks.extractDeliveryInfo.mockReturnValueOnce({
|
||||
deliveryContext: { channel: "telegram", to: "+19995550001", accountId: undefined },
|
||||
threadId: "extracted-thread",
|
||||
});
|
||||
|
||||
await execTool(makeTool({ agentThreadId: "local-thread" }), {
|
||||
action: "restart",
|
||||
sessionKey: "agent:other-claw:main",
|
||||
});
|
||||
|
||||
const p = getCallArg<{ threadId?: string }>(mocks.writeRestartSentinel, 0, 0);
|
||||
expect(p?.threadId).toBe("extracted-thread");
|
||||
});
|
||||
|
||||
it("suppresses live context and uses extractDeliveryInfo when sessionKey targets another session", async () => {
|
||||
mocks.extractDeliveryInfo.mockReturnValueOnce({
|
||||
deliveryContext: { channel: "signal", to: "+15550001", accountId: undefined },
|
||||
threadId: undefined,
|
||||
});
|
||||
|
||||
await execTool(makeTool(), { action: "restart", sessionKey: "agent:other-agent:main" });
|
||||
|
||||
const p = getCallArg<{ deliveryContext?: Record<string, unknown> }>(
|
||||
mocks.writeRestartSentinel,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
expect(p?.deliveryContext?.channel).toBe("signal");
|
||||
expect(p?.deliveryContext?.to).toBe("+15550001");
|
||||
});
|
||||
|
||||
it("suppresses live context when non-default agent targets sessionKey='main' (cross-agent alias)", async () => {
|
||||
mocks.extractDeliveryInfo.mockReturnValueOnce({
|
||||
deliveryContext: { channel: "telegram", to: "+19995550001", accountId: undefined },
|
||||
threadId: undefined,
|
||||
});
|
||||
|
||||
await execTool(makeTool({ agentSessionKey: "agent:shopping-claw:main" }), {
|
||||
action: "restart",
|
||||
sessionKey: "main", // resolves to "agent:main:main" — different agent
|
||||
});
|
||||
|
||||
const p = getCallArg<{ deliveryContext?: Record<string, unknown> }>(
|
||||
mocks.writeRestartSentinel,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
expect(p?.deliveryContext?.channel).toBe("telegram");
|
||||
expect(p?.deliveryContext?.to).toBe("+19995550001");
|
||||
});
|
||||
|
||||
it("sets status=ok and kind=restart on the sentinel payload", async () => {
|
||||
await execTool(makeTool(), { action: "restart" });
|
||||
|
||||
const p = getCallArg<{ kind?: string; status?: string }>(mocks.writeRestartSentinel, 0, 0);
|
||||
expect(p?.kind).toBe("restart");
|
||||
expect(p?.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("includes sessionKey in sentinel payload", async () => {
|
||||
await execTool(makeTool({ agentSessionKey: "agent:main:main" }), {
|
||||
action: "restart",
|
||||
});
|
||||
|
||||
const p = getCallArg<{ sessionKey?: string }>(mocks.writeRestartSentinel, 0, 0);
|
||||
expect(p?.sessionKey).toBe("agent:main:main");
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,27 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseDeliveryContextFromParams } from "./restart-request.js";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// parseDeliveryContextFromParams
|
||||
// Validates that only complete, routable delivery contexts are accepted
|
||||
// and that partial or malformed inputs are rejected.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("parseDeliveryContextFromParams", () => {
|
||||
// ── No context present ────────────────────────────────────────────────────
|
||||
|
||||
it("returns undefined when deliveryContext is absent", () => {
|
||||
expect(parseDeliveryContextFromParams({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when both channel and to are missing", () => {
|
||||
it("returns undefined when deliveryContext is null", () => {
|
||||
expect(parseDeliveryContextFromParams({ deliveryContext: null })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when deliveryContext is a non-object (string)", () => {
|
||||
expect(parseDeliveryContextFromParams({ deliveryContext: "discord" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when deliveryContext is a non-object (number)", () => {
|
||||
expect(parseDeliveryContextFromParams({ deliveryContext: 42 })).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Partial context — must be rejected (prevents routing ambiguity) ───────
|
||||
|
||||
it("returns undefined when both channel and to are absent", () => {
|
||||
expect(parseDeliveryContextFromParams({ deliveryContext: {} })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when only channel is present (partial context rejected)", () => {
|
||||
it("returns undefined when only channel is present (partial context)", () => {
|
||||
expect(
|
||||
parseDeliveryContextFromParams({ deliveryContext: { channel: "discord" } }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when only to is present (partial context rejected)", () => {
|
||||
it("returns undefined when only to is present (partial context)", () => {
|
||||
expect(
|
||||
parseDeliveryContextFromParams({ deliveryContext: { to: "123456789" } }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when channel is present but to is an empty string", () => {
|
||||
expect(
|
||||
parseDeliveryContextFromParams({ deliveryContext: { channel: "discord", to: "" } }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when to is present but channel is an empty string", () => {
|
||||
expect(
|
||||
parseDeliveryContextFromParams({ deliveryContext: { channel: "", to: "123456789" } }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when channel is whitespace-only", () => {
|
||||
expect(
|
||||
parseDeliveryContextFromParams({ deliveryContext: { channel: " ", to: "123456789" } }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when to is whitespace-only", () => {
|
||||
expect(
|
||||
parseDeliveryContextFromParams({ deliveryContext: { channel: "discord", to: " " } }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Non-string field types ────────────────────────────────────────────────
|
||||
|
||||
it("returns undefined when channel is a number (type coercion not allowed)", () => {
|
||||
expect(
|
||||
parseDeliveryContextFromParams({ deliveryContext: { channel: 42, to: "123" } }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when to is a boolean", () => {
|
||||
expect(
|
||||
parseDeliveryContextFromParams({ deliveryContext: { channel: "discord", to: true } }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Complete context ──────────────────────────────────────────────────────
|
||||
|
||||
it("returns full context when both channel and to are present", () => {
|
||||
expect(
|
||||
parseDeliveryContextFromParams({
|
||||
@ -30,7 +92,21 @@ describe("parseDeliveryContextFromParams", () => {
|
||||
).toEqual({ channel: "discord", to: "123456789", accountId: undefined, threadId: undefined });
|
||||
});
|
||||
|
||||
it("includes accountId and threadId when present", () => {
|
||||
it("includes accountId when present", () => {
|
||||
const result = parseDeliveryContextFromParams({
|
||||
deliveryContext: { channel: "discord", to: "123456789", accountId: "acct-1" },
|
||||
});
|
||||
expect(result?.accountId).toBe("acct-1");
|
||||
});
|
||||
|
||||
it("includes threadId when present", () => {
|
||||
const result = parseDeliveryContextFromParams({
|
||||
deliveryContext: { channel: "slack", to: "C012AB3CD", threadId: "1234567890.123456" },
|
||||
});
|
||||
expect(result?.threadId).toBe("1234567890.123456");
|
||||
});
|
||||
|
||||
it("includes all four fields when all are present", () => {
|
||||
expect(
|
||||
parseDeliveryContextFromParams({
|
||||
deliveryContext: {
|
||||
@ -48,11 +124,84 @@ describe("parseDeliveryContextFromParams", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("trims whitespace from all string fields", () => {
|
||||
// ── Whitespace trimming ───────────────────────────────────────────────────
|
||||
|
||||
it("trims leading/trailing whitespace from channel", () => {
|
||||
const result = parseDeliveryContextFromParams({
|
||||
deliveryContext: { channel: " discord ", to: "123456789" },
|
||||
});
|
||||
expect(result?.channel).toBe("discord");
|
||||
});
|
||||
|
||||
it("trims leading/trailing whitespace from to", () => {
|
||||
const result = parseDeliveryContextFromParams({
|
||||
deliveryContext: { channel: "discord", to: " 123456789 " },
|
||||
});
|
||||
expect(result?.to).toBe("123456789");
|
||||
});
|
||||
|
||||
it("trims leading/trailing whitespace from threadId", () => {
|
||||
const result = parseDeliveryContextFromParams({
|
||||
deliveryContext: { channel: "discord", to: "123", threadId: " ts.1 " },
|
||||
});
|
||||
expect(result?.threadId).toBe("ts.1");
|
||||
});
|
||||
|
||||
it("trims all string fields simultaneously", () => {
|
||||
expect(
|
||||
parseDeliveryContextFromParams({
|
||||
deliveryContext: { channel: " discord ", to: " 123 ", threadId: " ts.1 " },
|
||||
deliveryContext: {
|
||||
channel: " discord ",
|
||||
to: " 123 ",
|
||||
accountId: " acct ",
|
||||
threadId: " ts.1 ",
|
||||
},
|
||||
}),
|
||||
).toEqual({ channel: "discord", to: "123", accountId: undefined, threadId: "ts.1" });
|
||||
).toEqual({ channel: "discord", to: "123", accountId: "acct", threadId: "ts.1" });
|
||||
});
|
||||
|
||||
// ── Optional fields absent / undefined ───────────────────────────────────
|
||||
|
||||
it("returns undefined for accountId when not provided", () => {
|
||||
const result = parseDeliveryContextFromParams({
|
||||
deliveryContext: { channel: "discord", to: "123456789" },
|
||||
});
|
||||
expect(result?.accountId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for threadId when not provided", () => {
|
||||
const result = parseDeliveryContextFromParams({
|
||||
deliveryContext: { channel: "discord", to: "123456789" },
|
||||
});
|
||||
expect(result?.threadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for accountId when value is empty string after trim", () => {
|
||||
const result = parseDeliveryContextFromParams({
|
||||
deliveryContext: { channel: "discord", to: "123456789", accountId: " " },
|
||||
});
|
||||
expect(result?.accountId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for threadId when value is empty string after trim", () => {
|
||||
const result = parseDeliveryContextFromParams({
|
||||
deliveryContext: { channel: "discord", to: "123456789", threadId: " " },
|
||||
});
|
||||
expect(result?.threadId).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Extra/unknown fields are ignored ─────────────────────────────────────
|
||||
|
||||
it("ignores unknown extra fields in deliveryContext", () => {
|
||||
const result = parseDeliveryContextFromParams({
|
||||
deliveryContext: { channel: "discord", to: "123456789", unknownField: "ignored" },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
channel: "discord",
|
||||
to: "123456789",
|
||||
accountId: undefined,
|
||||
threadId: undefined,
|
||||
});
|
||||
expect((result as Record<string, unknown>)?.unknownField).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -93,15 +93,19 @@ vi.mock("../infra/system-events.js", () => ({
|
||||
|
||||
const { scheduleRestartSentinelWake } = await import("./server-restart-sentinel.js");
|
||||
|
||||
describe("scheduleRestartSentinelWake", () => {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Suite 1 — Core two-step delivery + resume flow
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("scheduleRestartSentinelWake – two-step delivery + resume", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("delivers restart notice directly then resumes agent after restart", async () => {
|
||||
it("delivers restart notice directly (model-independent) then resumes agent", async () => {
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
// Step 1: deterministic delivery (model-independent)
|
||||
// Step 1: deterministic delivery
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "whatsapp",
|
||||
@ -112,7 +116,7 @@ describe("scheduleRestartSentinelWake", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// Step 2: agent resume turn
|
||||
// Step 2: agent resume
|
||||
expect(mocks.agentCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "restart message",
|
||||
@ -128,26 +132,56 @@ describe("scheduleRestartSentinelWake", () => {
|
||||
{},
|
||||
);
|
||||
|
||||
// Verify delivery happened before resume
|
||||
const deliverOrder = mocks.deliverOutboundPayloads.mock.invocationCallOrder[0];
|
||||
const agentOrder = mocks.agentCommand.mock.invocationCallOrder[0];
|
||||
expect(deliverOrder).toBeLessThan(agentOrder);
|
||||
|
||||
expect(mocks.enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to enqueueSystemEvent when agentCommand throws", async () => {
|
||||
mocks.agentCommand.mockRejectedValueOnce(new Error("agent failed"));
|
||||
it("delivers notification before triggering agent resume (ordering guarantee)", async () => {
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
const deliverOrder = mocks.deliverOutboundPayloads.mock.invocationCallOrder[0];
|
||||
const agentOrder = mocks.agentCommand.mock.invocationCallOrder[0];
|
||||
expect(deliverOrder).toBeLessThan(agentOrder);
|
||||
});
|
||||
|
||||
it("passes senderIsOwner=false to agentCommand (no privilege escalation)", async () => {
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
const opts = getArg<Record<string, unknown>>(mocks.agentCommand, 0);
|
||||
expect(opts.senderIsOwner).toBe(false);
|
||||
});
|
||||
|
||||
it("no-ops when there is no sentinel file", async () => {
|
||||
mocks.consumeRestartSentinel.mockResolvedValueOnce(null);
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
expect.stringContaining("restart summary"),
|
||||
{ sessionKey: "agent:main:main" },
|
||||
);
|
||||
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
|
||||
expect(mocks.agentCommand).not.toHaveBeenCalled();
|
||||
expect(mocks.enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Suite 2 — Fallback paths
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("scheduleRestartSentinelWake – fallback to enqueueSystemEvent", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("falls back to enqueueSystemEvent when channel cannot be resolved (no channel in origin)", async () => {
|
||||
it("falls back to enqueueSystemEvent on main session key when sentinel has no sessionKey", async () => {
|
||||
mocks.consumeRestartSentinel.mockResolvedValueOnce({ payload: { sessionKey: "" } } as never);
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledWith("restart message", {
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
expect(mocks.agentCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to enqueueSystemEvent when outbound target cannot be resolved", async () => {
|
||||
mocks.resolveOutboundTarget.mockReturnValueOnce({
|
||||
ok: false,
|
||||
error: new Error("no-target"),
|
||||
@ -161,13 +195,187 @@ describe("scheduleRestartSentinelWake", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to enqueueSystemEvent on main session key when sentinel has no sessionKey", async () => {
|
||||
mocks.consumeRestartSentinel.mockResolvedValueOnce({ payload: { sessionKey: "" } } as never);
|
||||
it("falls back to enqueueSystemEvent when channel is missing from merged delivery context", async () => {
|
||||
// mergeDeliveryContext is called twice (inner + outer merge); mock the outer to drop channel
|
||||
mocks.mergeDeliveryContext
|
||||
.mockReturnValueOnce(undefined) // inner: sessionDeliveryContext merge
|
||||
.mockReturnValueOnce({ to: "+15550002" }); // outer: sentinelContext wins, no channel
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
expect(mocks.agentCommand).not.toHaveBeenCalled();
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledWith("restart message", {
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to enqueueSystemEvent when to is missing from merged delivery context", async () => {
|
||||
// Mock outer merge to return a context with no `to`
|
||||
mocks.mergeDeliveryContext
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValueOnce({ channel: "whatsapp" });
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
expect(mocks.agentCommand).not.toHaveBeenCalled();
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledWith("restart message", {
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to enqueueSystemEvent (with error) when agentCommand throws", async () => {
|
||||
mocks.agentCommand.mockRejectedValueOnce(new Error("agent failed"));
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
// Direct delivery should still have been attempted
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalled();
|
||||
// Then fallback for the resume
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
expect.stringContaining("restart summary"),
|
||||
{ sessionKey: "agent:main:main" },
|
||||
);
|
||||
});
|
||||
|
||||
it("still calls agentCommand even if deliverOutboundPayloads throws (bestEffort guard)", async () => {
|
||||
// bestEffort: true means it shouldn't normally throw, but even if it does, resume continues
|
||||
mocks.deliverOutboundPayloads.mockRejectedValueOnce(new Error("deliver failed"));
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
expect(mocks.agentCommand).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Suite 3 — Thread routing (Slack vs non-Slack)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("scheduleRestartSentinelWake – thread routing", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("maps threadId to replyToId and clears threadId for Slack deliverOutboundPayloads", async () => {
|
||||
mocks.consumeRestartSentinel.mockResolvedValueOnce({
|
||||
payload: {
|
||||
sessionKey: "agent:main:main",
|
||||
deliveryContext: { channel: "slack", to: "C012AB3CD", accountId: undefined },
|
||||
threadId: "1234567890.123456",
|
||||
},
|
||||
});
|
||||
mocks.normalizeChannelId.mockReturnValueOnce("slack");
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
const deliverArgs = getArg<Record<string, unknown>>(mocks.deliverOutboundPayloads, 0);
|
||||
expect(deliverArgs.replyToId).toBe("1234567890.123456");
|
||||
expect(deliverArgs.threadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes threadId directly (not replyToId) for non-Slack channels", async () => {
|
||||
mocks.consumeRestartSentinel.mockResolvedValueOnce({
|
||||
payload: {
|
||||
sessionKey: "agent:main:main",
|
||||
deliveryContext: { channel: "discord", to: "123456789", accountId: undefined },
|
||||
threadId: "discord-thread-id",
|
||||
},
|
||||
});
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
const deliverArgs = getArg<Record<string, unknown>>(mocks.deliverOutboundPayloads, 0);
|
||||
expect(deliverArgs.threadId).toBe("discord-thread-id");
|
||||
expect(deliverArgs.replyToId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes threadId to agentCommand for non-Slack threading", async () => {
|
||||
mocks.consumeRestartSentinel.mockResolvedValueOnce({
|
||||
payload: {
|
||||
sessionKey: "agent:main:main",
|
||||
deliveryContext: { channel: "discord", to: "123456789", accountId: undefined },
|
||||
threadId: "discord-thread-id",
|
||||
},
|
||||
});
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
const agentOpts = getArg<Record<string, unknown>>(mocks.agentCommand, 0);
|
||||
expect(agentOpts.threadId).toBe("discord-thread-id");
|
||||
});
|
||||
|
||||
it("sentinel payload threadId takes precedence over session-derived threadId", async () => {
|
||||
mocks.consumeRestartSentinel.mockResolvedValueOnce({
|
||||
payload: {
|
||||
sessionKey: "agent:main:main",
|
||||
deliveryContext: { channel: "whatsapp", to: "+15550002" },
|
||||
threadId: "sentinel-thread",
|
||||
},
|
||||
});
|
||||
// parseSessionThreadInfo would derive a different threadId from the session key
|
||||
mocks.parseSessionThreadInfo.mockReturnValueOnce({
|
||||
baseSessionKey: null,
|
||||
threadId: "session-thread",
|
||||
});
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
const agentOpts = getArg<Record<string, unknown>>(mocks.agentCommand, 0);
|
||||
expect(agentOpts.threadId).toBe("sentinel-thread");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Suite 4 — Delivery context priority: sentinel > session store > parsed target
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("scheduleRestartSentinelWake – delivery context priority", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("prefers sentinel deliveryContext over session store (handles heartbeat-overwritten store)", async () => {
|
||||
// Session store has been overwritten with heartbeat sink
|
||||
mocks.deliveryContextFromSession.mockReturnValueOnce({
|
||||
channel: "webchat",
|
||||
to: "heartbeat",
|
||||
});
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
// agentCommand should use the sentinel's whatsapp/+15550002, not webchat/heartbeat
|
||||
const agentOpts = getArg<Record<string, unknown>>(mocks.agentCommand, 0);
|
||||
expect(agentOpts.channel).toBe("whatsapp");
|
||||
expect(agentOpts.to).toBe("+15550002");
|
||||
});
|
||||
|
||||
it("falls back to session store when sentinel has no deliveryContext", async () => {
|
||||
mocks.consumeRestartSentinel.mockResolvedValueOnce({
|
||||
payload: { sessionKey: "agent:main:main" }, // no deliveryContext
|
||||
});
|
||||
mocks.deliveryContextFromSession.mockReturnValueOnce({
|
||||
channel: "telegram",
|
||||
to: "+19990001",
|
||||
});
|
||||
// Mock both merge calls: inner produces session ctx; outer passes it through
|
||||
mocks.mergeDeliveryContext
|
||||
.mockReturnValueOnce({ channel: "telegram", to: "+19990001" }) // inner
|
||||
.mockReturnValueOnce({ channel: "telegram", to: "+19990001" }); // outer
|
||||
// resolveOutboundTarget must reflect the session-store to value
|
||||
mocks.resolveOutboundTarget.mockReturnValueOnce({ ok: true as const, to: "+19990001" });
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
const agentOpts = getArg<Record<string, unknown>>(mocks.agentCommand, 0);
|
||||
expect(agentOpts.channel).toBe("telegram");
|
||||
expect(agentOpts.to).toBe("+19990001");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getArg<T>(mockFn: { mock: { calls: unknown[][] } }, argIdx: number): T {
|
||||
return mockFn.mock.calls[0]?.[argIdx] as T;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user