Quellcodebibliothek Statistik Leitseite products/Sources/formale Sprachen/Java/Openclaw/src/gateway/server-methods/   (KI Agentensystem Version 22©)  Datei vom 26.3.2026 mit Größe 36 kB image not shown  

Quelle  send.test.ts

  Sprache: JAVA
 

Spracherkennung für: .ts vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]

import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { jsonResult } from "../../agents/tools/common.js";
import type { ChannelPlugin } from "../../channels/plugins/types.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import type { GatewayRequestContext } from "./types.js";

type ResolveOutboundTarget = typeof import("../../infra/outbound/targets.js").resolveOutboundTarget;

const mocks = vi.hoisted(() => ({
  deliverOutboundPayloads: vi.fn(),
  appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
  recordSessionMetaFromInbound: vi.fn(async () => ({ ok: true })),
  resolveOutboundTarget: vi.fn<ResolveOutboundTarget>(() => ({ ok: true, to: "resolved" })),
  resolveOutboundSessionRoute: vi.fn(),
  ensureOutboundSessionEntry: vi.fn(async () => undefined),
  resolveMessageChannelSelection: vi.fn(),
  sendPoll: vi.fn<
    () => Promise<{
      messageId: string;
      toJid?: string;
      channelId?: string;
      conversationId?: string;
      pollId?: string;
    }>
  >(async () => ({ messageId: "poll-1" })),
  getChannelPlugin: vi.fn(),
  loadOpenClawPlugins: vi.fn(),
  applyPluginAutoEnable: vi.fn(),
}));

vi.mock("../../config/config.js", async () => {
  const actual =
    await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
  return {
    ...actual,
    loadConfig: () => ({}),
  };
});

vi.mock("../../channels/plugins/index.js", () => ({
  getLoadedChannelPlugin: mocks.getChannelPlugin,
  getChannelPlugin: mocks.getChannelPlugin,
  normalizeChannelId: (value: string) => (value === "webchat" ? null : value),
}));

const TEST_AGENT_WORKSPACE = "/tmp/openclaw-test-workspace";
let sendHandlers: typeof import("./send.js").sendHandlers;

function resolveAgentIdFromSessionKeyForTests(params: { sessionKey?: string }): string {
  if (typeof params.sessionKey === "string") {
    const match = params.sessionKey.match(/^agent:([^:]+)/i);
    if (match?.[1]) {
      return match[1];
    }
  }
  return "main";
}

vi.mock("../../agents/agent-scope.js", () => ({
  resolveSessionAgentId: ({
    sessionKey,
  }: {
    sessionKey?: string;
    config?: unknown;
    agentId?: string;
  }) => resolveAgentIdFromSessionKeyForTests({ sessionKey }),
  resolveDefaultAgentId: () => "main",
  resolveAgentWorkspaceDir: () => TEST_AGENT_WORKSPACE,
}));

vi.mock("../../config/plugin-auto-enable.js", () => ({
  applyPluginAutoEnable: ({ config, env }: { config: unknown; env?: unknown }) =>
    mocks.applyPluginAutoEnable({ config, env }),
}));

vi.mock("../../plugins/loader.js", () => ({
  loadOpenClawPlugins: mocks.loadOpenClawPlugins,
  resolveRuntimePluginRegistry: vi.fn(),
}));

vi.mock("../../infra/outbound/channel-bootstrap.runtime.js", () => ({
  bootstrapOutboundChannelPlugin: vi.fn(),
  resetOutboundChannelBootstrapStateForTests: vi.fn(),
}));

vi.mock("../../infra/outbound/targets.js", () => ({
  resolveOutboundTarget: mocks.resolveOutboundTarget,
}));

vi.mock("../../infra/outbound/outbound-session.js", () => ({
  resolveOutboundSessionRoute: mocks.resolveOutboundSessionRoute,
  ensureOutboundSessionEntry: mocks.ensureOutboundSessionEntry,
}));

vi.mock("../../infra/outbound/channel-selection.js", () => ({
  resolveMessageChannelSelection: mocks.resolveMessageChannelSelection,
}));

vi.mock("../../infra/outbound/deliver.js", () => ({
  deliverOutboundPayloads: mocks.deliverOutboundPayloads,
}));

vi.mock("../../config/sessions.js", async () => {
  const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
    "../../config/sessions.js",
  );
  return {
    ...actual,
    appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript,
    recordSessionMetaFromInbound: mocks.recordSessionMetaFromInbound,
  };
});

async function loadSendHandlersForTest() {
  ({ sendHandlers } = await import("./send.js"));
}

const makeContext = (): GatewayRequestContext =>
  ({
    dedupe: new Map(),
  }) as unknown as GatewayRequestContext;

async function runSend(params: Record<string, unknown>) {
  return await runSendWithClient(params);
}

async function runSendWithClient(
  params: Record<string, unknown>,
  client?: { connect?: { scopes?: string[] } } | null,
) {
  const respond = vi.fn();
  await sendHandlers.send({
    params: params as never,
    respond,
    context: makeContext(),
    req: { type: "req", id: "1", method: "send" },
    client: (client ?? null) as never,
    isWebchatConnect: () => false,
  });
  return { respond };
}

async function runPoll(params: Record<string, unknown>) {
  return await runPollWithClient(params);
}

async function runPollWithClient(
  params: Record<string, unknown>,
  client?: { connect?: { scopes?: string[] } } | null,
) {
  const respond = vi.fn();
  await sendHandlers.poll({
    params: params as never,
    respond,
    context: makeContext(),
    req: { type: "req", id: "1", method: "poll" },
    client: (client ?? null) as never,
    isWebchatConnect: () => false,
  });
  return { respond };
}

async function runMessageActionRequest(
  params: Record<string, unknown>,
  client?: { connect?: { scopes?: string[] } } | null,
) {
  const respond = vi.fn();
  await sendHandlers["message.action"]({
    params: params as never,
    respond,
    context: makeContext(),
    req: { type: "req", id: "1", method: "message.action" },
    client: (client ?? null) as never,
    isWebchatConnect: () => false,
  });
  return { respond };
}

function expectDeliverySessionMirror(params: { agentId: string; sessionKey: string }) {
  expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
    expect.objectContaining({
      session: expect.objectContaining({
        agentId: params.agentId,
        key: params.sessionKey,
      }),
      mirror: expect.objectContaining({
        sessionKey: params.sessionKey,
        agentId: params.agentId,
      }),
    }),
  );
}

function mockDeliverySuccess(messageId: string) {
  mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId, channel: "slack" }]);
}

describe("gateway send mirroring", () => {
  let registrySeq = 0;

  beforeAll(async () => {
    await loadSendHandlersForTest();
  });

  beforeEach(async () => {
    vi.clearAllMocks();
    registrySeq += 1;
    setActivePluginRegistry(createTestRegistry([]), `send-test-${registrySeq}`);
    mocks.applyPluginAutoEnable.mockImplementation(({ config }) => ({
      config,
      changes: [],
      autoEnabledReasons: {},
    }));
    mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "resolved" });
    mocks.resolveOutboundSessionRoute.mockImplementation(
      async ({ agentId, channel }: { agentId?: string; channel?: string }) => ({
        sessionKey:
          channel === "slack"
            ? `agent:${agentId ?? "main"}:slack:channel:resolved`
            : `agent:${agentId ?? "main"}:${channel ?? "main"}:resolved`,
      }),
    );
    mocks.resolveMessageChannelSelection.mockResolvedValue({
      channel: "slack",
      configured: ["slack"],
    });
    mocks.sendPoll.mockResolvedValue({ messageId: "poll-1" });
    mocks.getChannelPlugin.mockReturnValue({ outbound: { sendPoll: mocks.sendPoll } });
  });

  it("accepts media-only sends without message", async () => {
    mockDeliverySuccess("m-media");

    const { respond } = await runSend({
      to: "channel:C1",
      mediaUrl: "https://example.com/a.png",
      channel: "slack",
      idempotencyKey: "idem-media-only",
    });

    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        payloads: [{ text: "", mediaUrl: "https://example.com/a.png", mediaUrls: undefined }],
      }),
    );
    expect(respond).toHaveBeenCalledWith(
      true,
      expect.objectContaining({ messageId: "m-media" }),
      undefined,
      expect.objectContaining({ channel: "slack" }),
    );
  });

  it("passes outbound session context for gateway media sends", async () => {
    mockDeliverySuccess("m-whatsapp-media");

    await runSend({
      to: "+15551234567",
      message: "caption",
      mediaUrl: "file:///tmp/workspace/photo.png",
      channel: "whatsapp",
      agentId: "work",
      idempotencyKey: "idem-whatsapp-media",
    });

    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        channel: "whatsapp",
        payloads: [
          {
            text: "caption",
            mediaUrl: "file:///tmp/workspace/photo.png",
            mediaUrls: undefined,
          },
        ],
        session: expect.objectContaining({
          agentId: "work",
          key: "agent:work:whatsapp:resolved",
        }),
      }),
    );
  });

  it("forwards gateway client scopes into outbound delivery", async () => {
    mockDeliverySuccess("m-scope");

    await runSendWithClient(
      {
        to: "channel:C1",
        message: "hi",
        channel: "slack",
        idempotencyKey: "idem-scope",
      },
      { connect: { scopes: ["operator.write"] } },
    );

    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        channel: "slack",
        gatewayClientScopes: ["operator.write"],
      }),
    );
  });

  it("forwards an empty gateway scope array into outbound delivery", async () => {
    mockDeliverySuccess("m-empty-scope");

    await runSendWithClient(
      {
        to: "channel:C1",
        message: "hi",
        channel: "slack",
        idempotencyKey: "idem-empty-scope",
      },
      { connect: { scopes: [] } },
    );

    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        channel: "slack",
        gatewayClientScopes: [],
      }),
    );
  });

  it("rejects empty sends when neither text nor media is present", async () => {
    const { respond } = await runSend({
      to: "channel:C1",
      message: "   ",
      channel: "slack",
      idempotencyKey: "idem-empty",
    });

    expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
    expect(respond).toHaveBeenCalledWith(
      false,
      undefined,
      expect.objectContaining({
        message: expect.stringContaining("text or media is required"),
      }),
    );
  });

  it("returns actionable guidance when channel is internal webchat", async () => {
    const { respond } = await runSend({
      to: "x",
      message: "hi",
      channel: "webchat",
      idempotencyKey: "idem-webchat",
    });

    expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
    expect(respond).toHaveBeenCalledWith(
      false,
      undefined,
      expect.objectContaining({
        message: expect.stringContaining("unsupported channel: webchat"),
      }),
    );
    expect(respond).toHaveBeenCalledWith(
      false,
      undefined,
      expect.objectContaining({
        message: expect.stringContaining("Use `chat.send`"),
      }),
    );
  });

  it("auto-picks the single configured channel for send", async () => {
    mockDeliverySuccess("m-single-send");

    const { respond } = await runSend({
      to: "x",
      message: "hi",
      idempotencyKey: "idem-missing-channel",
    });

    expect(mocks.resolveMessageChannelSelection).toHaveBeenCalled();
    expect(mocks.deliverOutboundPayloads).toHaveBeenCalled();
    expect(respond).toHaveBeenCalledWith(
      true,
      expect.objectContaining({ messageId: "m-single-send" }),
      undefined,
      expect.objectContaining({ channel: "slack" }),
    );
  });

  it("auto-picks the single configured channel from the auto-enabled config snapshot for send", async () => {
    const autoEnabledConfig = { channels: { slack: {} }, plugins: { allow: ["slack"] } };
    mocks.applyPluginAutoEnable.mockReturnValue({
      config: autoEnabledConfig,
      changes: [],
      autoEnabledReasons: {},
    });
    mockDeliverySuccess("m-single-send-auto");

    const { respond } = await runSend({
      to: "x",
      message: "hi",
      idempotencyKey: "idem-missing-channel-auto-enabled",
    });

    expect(mocks.applyPluginAutoEnable).toHaveBeenCalledWith({
      config: {},
      env: process.env,
    });
    expect(mocks.resolveMessageChannelSelection).toHaveBeenCalledWith({
      cfg: autoEnabledConfig,
    });
    expect(respond).toHaveBeenCalledWith(
      true,
      expect.objectContaining({ messageId: "m-single-send-auto" }),
      undefined,
      expect.objectContaining({ channel: "slack" }),
    );
  });

  it("returns invalid request when send channel selection is ambiguous", async () => {
    mocks.resolveMessageChannelSelection.mockRejectedValueOnce(
      new Error("Channel is required when multiple channels are configured: telegram, slack"),
    );

    const { respond } = await runSend({
      to: "x",
      message: "hi",
      idempotencyKey: "idem-missing-channel-ambiguous",
    });

    expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
    expect(respond).toHaveBeenCalledWith(
      false,
      undefined,
      expect.objectContaining({
        message: expect.stringContaining("Channel is required"),
      }),
    );
  });

  it("forwards gateway client scopes into outbound poll delivery", async () => {
    await runPollWithClient(
      {
        to: "channel:C1",
        question: "Q?",
        options: ["A", "B"],
        channel: "slack",
        idempotencyKey: "idem-poll-scope",
      },
      { connect: { scopes: ["operator.admin"] } },
    );

    expect(mocks.sendPoll).toHaveBeenCalledWith(
      expect.objectContaining({
        cfg: expect.any(Object),
        to: "resolved",
        gatewayClientScopes: ["operator.admin"],
      }),
    );
  });

  it("forwards an empty gateway scope array into outbound poll delivery", async () => {
    await runPollWithClient(
      {
        to: "channel:C1",
        question: "Q?",
        options: ["A", "B"],
        channel: "slack",
        idempotencyKey: "idem-poll-empty-scope",
      },
      { connect: { scopes: [] } },
    );

    expect(mocks.sendPoll).toHaveBeenCalledWith(
      expect.objectContaining({
        cfg: expect.any(Object),
        to: "resolved",
        gatewayClientScopes: [],
      }),
    );
  });

  it("includes optional poll delivery identifiers in the gateway payload", async () => {
    mocks.sendPoll.mockResolvedValue({
      messageId: "poll-rich",
      channelId: "C123",
      conversationId: "conv-1",
      toJid: "jid-1",
      pollId: "poll-meta-1",
    });

    const { respond } = await runPoll({
      to: "channel:C1",
      question: "Q?",
      options: ["A", "B"],
      channel: "slack",
      idempotencyKey: "idem-poll-rich",
    });

    expect(respond).toHaveBeenCalledWith(
      true,
      expect.objectContaining({
        runId: "idem-poll-rich",
        messageId: "poll-rich",
        channel: "slack",
        channelId: "C123",
        conversationId: "conv-1",
        toJid: "jid-1",
        pollId: "poll-meta-1",
      }),
      undefined,
      expect.objectContaining({ channel: "slack" }),
    );
  });

  it("auto-picks the single configured channel for poll", async () => {
    const { respond } = await runPoll({
      to: "x",
      question: "Q?",
      options: ["A", "B"],
      idempotencyKey: "idem-poll-missing-channel",
    });

    expect(mocks.resolveMessageChannelSelection).toHaveBeenCalled();
    expect(respond).toHaveBeenCalledWith(true, expect.any(Object), undefined, {
      channel: "slack",
    });
  });

  it("returns invalid request when poll channel selection is ambiguous", async () => {
    mocks.resolveMessageChannelSelection.mockRejectedValueOnce(
      new Error("Channel is required when multiple channels are configured: telegram, slack"),
    );

    const { respond } = await runPoll({
      to: "x",
      question: "Q?",
      options: ["A", "B"],
      idempotencyKey: "idem-poll-missing-channel-ambiguous",
    });

    expect(respond).toHaveBeenCalledWith(
      false,
      undefined,
      expect.objectContaining({
        message: expect.stringContaining("Channel is required"),
      }),
    );
  });

  it("does not mirror when delivery returns no results", async () => {
    mocks.deliverOutboundPayloads.mockResolvedValue([]);

    await runSend({
      to: "channel:C1",
      message: "hi",
      channel: "slack",
      idempotencyKey: "idem-1",
      sessionKey: "agent:main:main",
    });

    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        mirror: expect.objectContaining({
          sessionKey: "agent:main:main",
        }),
      }),
    );
  });

  it("mirrors media filenames when delivery succeeds", async () => {
    mockDeliverySuccess("m1");

    await runSend({
      to: "channel:C1",
      message: "caption",
      mediaUrl: "https://example.com/files/report.pdf?sig=1",
      channel: "slack",
      idempotencyKey: "idem-2",
      sessionKey: "agent:main:main",
    });

    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        mirror: expect.objectContaining({
          sessionKey: "agent:main:main",
          text: "caption",
          mediaUrls: ["https://example.com/files/report.pdf?sig=1"],
          idempotencyKey: "idem-2",
        }),
      }),
    );
  });

  it("mirrors MEDIA tags as attachments", async () => {
    mockDeliverySuccess("m2");

    await runSend({
      to: "channel:C1",
      message: "Here\nMEDIA:https://example.com/image.png",
      channel: "slack",
      idempotencyKey: "idem-3",
      sessionKey: "agent:main:main",
    });

    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        mirror: expect.objectContaining({
          sessionKey: "agent:main:main",
          text: "Here",
          mediaUrls: ["https://example.com/image.png"],
        }),
      }),
    );
  });

  it("lowercases provided session keys for mirroring", async () => {
    mockDeliverySuccess("m-lower");

    await runSend({
      to: "channel:C1",
      message: "hi",
      channel: "slack",
      idempotencyKey: "idem-lower",
      sessionKey: "agent:main:slack:channel:C123",
    });

    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        mirror: expect.objectContaining({
          sessionKey: "agent:main:slack:channel:c123",
        }),
      }),
    );
  });

  it("derives a target session key when none is provided", async () => {
    mockDeliverySuccess("m3");

    await runSend({
      to: "channel:C1",
      message: "hello",
      channel: "slack",
      idempotencyKey: "idem-4",
    });

    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        mirror: expect.objectContaining({
          sessionKey: "agent:main:slack:channel:resolved",
          agentId: "main",
        }),
      }),
    );
  });

  it("uses explicit agentId for delivery when sessionKey is not provided", async () => {
    mockDeliverySuccess("m-agent");

    await runSend({
      to: "channel:C1",
      message: "hello",
      channel: "slack",
      agentId: "work",
      idempotencyKey: "idem-agent-explicit",
    });

    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        session: expect.objectContaining({
          agentId: "work",
          key: "agent:work:slack:channel:resolved",
        }),
        mirror: expect.objectContaining({
          sessionKey: "agent:work:slack:channel:resolved",
          agentId: "work",
        }),
      }),
    );
  });

  it("uses sessionKey agentId when explicit agentId is omitted", async () => {
    mockDeliverySuccess("m-session-agent");

    await runSend({
      to: "channel:C1",
      message: "hello",
      channel: "slack",
      sessionKey: "agent:work:slack:channel:c1",
      idempotencyKey: "idem-session-agent",
    });

    expectDeliverySessionMirror({
      agentId: "work",
      sessionKey: "agent:work:slack:channel:c1",
    });
  });

  it("still resolves outbound routing metadata when a sessionKey is provided", async () => {
    mockDeliverySuccess("m-matrix-session-route");
    mocks.resolveOutboundSessionRoute.mockResolvedValueOnce({
      sessionKey: "agent:main:matrix:channel:!dm:example.org",
      baseSessionKey: "agent:main:matrix:channel:!dm:example.org",
      peer: { kind: "channel", id: "!dm:example.org" },
      chatType: "direct",
      from: "matrix:@alice:example.org",
      to: "room:!dm:example.org",
    });

    await runSend({
      to: "@alice:example.org",
      message: "hello",
      channel: "matrix",
      sessionKey: "agent:main:matrix:channel:!dm:example.org",
      idempotencyKey: "idem-matrix-session-route",
    });

    expect(mocks.resolveOutboundSessionRoute).toHaveBeenCalledWith(
      expect.objectContaining({
        channel: "matrix",
        target: "resolved",
        currentSessionKey: "agent:main:matrix:channel:!dm:example.org",
      }),
    );
    expect(mocks.ensureOutboundSessionEntry).toHaveBeenCalledWith(
      expect.objectContaining({
        route: expect.objectContaining({
          sessionKey: "agent:main:matrix:channel:!dm:example.org",
          baseSessionKey: "agent:main:matrix:channel:!dm:example.org",
          to: "room:!dm:example.org",
        }),
      }),
    );
    expectDeliverySessionMirror({
      agentId: "main",
      sessionKey: "agent:main:matrix:channel:!dm:example.org",
    });
  });

  it("falls back to the provided sessionKey when outbound route lookup returns null", async () => {
    mockDeliverySuccess("m-session-fallback");
    mocks.resolveOutboundSessionRoute.mockResolvedValueOnce(null);

    await runSend({
      to: "channel:C1",
      message: "hello",
      channel: "slack",
      sessionKey: "agent:work:slack:channel:c1",
      idempotencyKey: "idem-session-fallback",
    });

    expect(mocks.ensureOutboundSessionEntry).not.toHaveBeenCalled();
    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        session: expect.objectContaining({
          agentId: "work",
          key: "agent:work:slack:channel:c1",
        }),
        mirror: expect.objectContaining({
          sessionKey: "agent:work:slack:channel:c1",
          agentId: "work",
        }),
      }),
    );
  });

  it("prefers explicit agentId over sessionKey agent for delivery and mirror", async () => {
    mockDeliverySuccess("m-agent-precedence");

    await runSend({
      to: "channel:C1",
      message: "hello",
      channel: "slack",
      agentId: "work",
      sessionKey: "agent:main:slack:channel:c1",
      idempotencyKey: "idem-agent-precedence",
    });

    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        session: expect.objectContaining({
          agentId: "work",
          key: "agent:main:slack:channel:c1",
        }),
        mirror: expect.objectContaining({
          sessionKey: "agent:main:slack:channel:c1",
          agentId: "work",
        }),
      }),
    );
  });

  it("ignores blank explicit agentId and falls back to sessionKey agent", async () => {
    mockDeliverySuccess("m-agent-blank");

    await runSend({
      to: "channel:C1",
      message: "hello",
      channel: "slack",
      agentId: "   ",
      sessionKey: "agent:work:slack:channel:c1",
      idempotencyKey: "idem-agent-blank",
    });

    expectDeliverySessionMirror({
      agentId: "work",
      sessionKey: "agent:work:slack:channel:c1",
    });
  });

  it("forwards threadId to outbound delivery when provided", async () => {
    mockDeliverySuccess("m-thread");

    await runSend({
      to: "channel:C1",
      message: "hi",
      channel: "slack",
      threadId: "1710000000.9999",
      idempotencyKey: "idem-thread",
    });

    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        threadId: "1710000000.9999",
      }),
    );
  });

  it("updates mirror session keys and delivery thread ids when Slack routing derives a thread", async () => {
    mockDeliverySuccess("m-thread-derived");
    mocks.resolveOutboundSessionRoute.mockResolvedValueOnce({
      sessionKey: "agent:main:slack:channel:c1:thread:1710000000.9999",
      baseSessionKey: "agent:main:slack:channel:c1",
      peer: { kind: "channel", id: "c1" },
      chatType: "channel",
      from: "slack:channel:C1",
      to: "channel:C1",
      threadId: "1710000000.9999",
    });

    await runSend({
      to: "channel:C1",
      message: "threaded",
      channel: "slack",
      sessionKey: "agent:main:slack:channel:c1",
      idempotencyKey: "idem-thread-derived",
    });

    expect(mocks.ensureOutboundSessionEntry).toHaveBeenCalledWith(
      expect.objectContaining({
        route: expect.objectContaining({
          sessionKey: "agent:main:slack:channel:c1:thread:1710000000.9999",
          baseSessionKey: "agent:main:slack:channel:c1",
          threadId: "1710000000.9999",
        }),
      }),
    );
    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        threadId: "1710000000.9999",
        mirror: expect.objectContaining({
          sessionKey: "agent:main:slack:channel:c1:thread:1710000000.9999",
        }),
      }),
    );
  });

  it("preserves the provided session when Slack derives a thread for a different base session", async () => {
    mockDeliverySuccess("m-thread-mismatch");
    mocks.resolveOutboundSessionRoute.mockResolvedValueOnce({
      sessionKey: "agent:main:slack:channel:c2:thread:1710000000.9999",
      baseSessionKey: "agent:main:slack:channel:c2",
      peer: { kind: "channel", id: "c2" },
      chatType: "channel",
      from: "slack:channel:C2",
      to: "channel:C2",
      threadId: "1710000000.9999",
    });

    await runSend({
      to: "channel:C2",
      message: "threaded",
      channel: "slack",
      sessionKey: "agent:main:slack:channel:c1",
      threadId: "1710000000.9999",
      idempotencyKey: "idem-thread-mismatch",
    });

    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        threadId: "1710000000.9999",
        session: expect.objectContaining({
          key: "agent:main:slack:channel:c1",
        }),
        mirror: expect.objectContaining({
          sessionKey: "agent:main:slack:channel:c1",
        }),
      }),
    );
  });

  it("preserves derived thread delivery for existing thread-scoped Slack session keys", async () => {
    mockDeliverySuccess("m-thread-session");
    mocks.resolveOutboundSessionRoute.mockResolvedValueOnce({
      sessionKey: "agent:main:slack:channel:c1:thread:1710000000.9999",
      baseSessionKey: "agent:main:slack:channel:c1",
      peer: { kind: "channel", id: "c1" },
      chatType: "channel",
      from: "slack:channel:C1",
      to: "channel:C1",
      threadId: "1710000000.9999",
    });

    await runSend({
      to: "channel:C1",
      message: "threaded",
      channel: "slack",
      sessionKey: "agent:main:slack:channel:c1:thread:1710000000.9999",
      idempotencyKey: "idem-thread-session",
    });

    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        threadId: "1710000000.9999",
        session: expect.objectContaining({
          key: "agent:main:slack:channel:c1:thread:1710000000.9999",
        }),
      }),
    );
  });

  it("preserves numeric derived thread ids for non-Slack channels", async () => {
    mockDeliverySuccess("m-topic-derived");
    mocks.resolveOutboundSessionRoute.mockResolvedValueOnce({
      sessionKey: "agent:main:telegram:group:-100123:thread:77",
      baseSessionKey: "agent:main:telegram:group:-100123",
      peer: { kind: "group", id: "-100123" },
      chatType: "group",
      from: "telegram:group:-100123",
      to: "channel:-100123",
      threadId: 77,
    });

    await runSend({
      to: "-100123:topic:77",
      message: "topic message",
      channel: "telegram",
      idempotencyKey: "idem-topic-derived",
    });

    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        threadId: 77,
      }),
    );
  });

  it("returns invalid request when outbound target resolution fails", async () => {
    mocks.resolveOutboundTarget.mockReturnValue({
      ok: false,
      error: new Error("target not found"),
    });

    const { respond } = await runSend({
      to: "channel:C1",
      message: "hi",
      channel: "slack",
      idempotencyKey: "idem-target-fail",
    });

    expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
    expect(respond).toHaveBeenCalledWith(
      false,
      undefined,
      expect.objectContaining({
        message: expect.stringContaining("target not found"),
      }),
      expect.objectContaining({
        channel: "slack",
      }),
    );
  });

  it("recovers cold plugin resolution for threaded sends", async () => {
    mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "123" });
    mocks.deliverOutboundPayloads.mockResolvedValue([
      { messageId: "m-threaded", channel: "slack" },
    ]);
    const outboundPlugin = { outbound: { sendPoll: mocks.sendPoll } };
    mocks.getChannelPlugin
      .mockReturnValueOnce(undefined)
      .mockReturnValueOnce(outboundPlugin)
      .mockReturnValue(outboundPlugin);

    const { respond } = await runSend({
      to: "123",
      message: "threaded completion",
      channel: "slack",
      threadId: "1710000000.9999",
      idempotencyKey: "idem-cold-thread",
    });

    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        channel: "slack",
        to: "123",
        threadId: "1710000000.9999",
      }),
    );
    expect(respond).toHaveBeenCalledWith(
      true,
      expect.objectContaining({ messageId: "m-threaded" }),
      undefined,
      expect.objectContaining({ channel: "slack" }),
    );
  });

  it("forwards replyToId on gateway sends", async () => {
    mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "123" });
    mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId: "m-reply", channel: "slack" }]);
    const outboundPlugin = { outbound: { sendPoll: mocks.sendPoll } };
    mocks.getChannelPlugin.mockReturnValue(outboundPlugin);

    const { respond } = await runSend({
      to: "123",
      message: "threaded completion",
      channel: "slack",
      replyToId: "wamid.42",
      idempotencyKey: "idem-reply-to",
    });

    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        channel: "slack",
        to: "123",
        replyToId: "wamid.42",
      }),
    );
    expect(mocks.resolveOutboundSessionRoute).toHaveBeenCalledWith(
      expect.objectContaining({
        channel: "slack",
        target: "123",
        replyToId: "wamid.42",
      }),
    );
    expect(respond).toHaveBeenCalledWith(
      true,
      expect.objectContaining({ messageId: "m-reply" }),
      undefined,
      expect.objectContaining({ channel: "slack" }),
    );
  });

  it("dispatches message actions through the gateway for plugin-owned channels", async () => {
    const reactPlugin: ChannelPlugin = {
      id: "whatsapp",
      meta: {
        id: "whatsapp",
        label: "WhatsApp",
        selectionLabel: "WhatsApp",
        docsPath: "/channels/whatsapp",
        blurb: "WhatsApp action dispatch test plugin.",
      },
      capabilities: { chatTypes: ["direct"], reactions: true },
      config: {
        listAccountIds: () => ["default"],
        resolveAccount: () => ({ enabled: true }),
        isConfigured: () => true,
      },
      actions: {
        describeMessageTool: () => ({ actions: ["react"] }),
        supportsAction: ({ action }) => action === "react",
        handleAction: async ({ params, requesterSenderId, toolContext }) =>
          jsonResult({
            ok: true,
            messageId: params.messageId,
            requesterSenderId,
            currentMessageId: toolContext?.currentMessageId,
            currentGraphChannelId: toolContext?.currentGraphChannelId,
            replyToMode: toolContext?.replyToMode,
            hasRepliedRef: toolContext?.hasRepliedRef?.value,
            skipCrossContextDecoration: toolContext?.skipCrossContextDecoration,
          }),
      },
    };
    mocks.getChannelPlugin.mockReturnValue(reactPlugin);
    setActivePluginRegistry(
      createTestRegistry([
        {
          pluginId: "whatsapp",
          source: "test",
          plugin: reactPlugin,
        },
      ]),
      "send-test-message-action",
    );

    const { respond } = await runMessageActionRequest({
      channel: "whatsapp",
      action: "react",
      params: {
        chatJid: "+15551234567",
        messageId: "wamid.1",
        emoji: "✅",
      },
      requesterSenderId: "trusted-user",
      toolContext: {
        currentGraphChannelId: "graph:team/chan",
        currentChannelProvider: "whatsapp",
        currentMessageId: "wamid.1",
        replyToMode: "first",
        hasRepliedRef: { value: true },
        skipCrossContextDecoration: true,
      },
      idempotencyKey: "idem-message-action",
    });

    expect(respond).toHaveBeenCalledWith(
      true,
      {
        ok: true,
        messageId: "wamid.1",
        requesterSenderId: "trusted-user",
        currentMessageId: "wamid.1",
        currentGraphChannelId: "graph:team/chan",
        replyToMode: "first",
        hasRepliedRef: true,
        skipCrossContextDecoration: true,
      },
      undefined,
      { channel: "whatsapp" },
    );
  });

  it("forces senderIsOwner=false for narrowly-scoped callers but honors it for full operators", async () => {
    const capture = { senderIsOwner: undefined as boolean | undefined };
    const reactPlugin: ChannelPlugin = {
      id: "whatsapp",
      meta: {
        id: "whatsapp",
        label: "WhatsApp",
        selectionLabel: "WhatsApp",
        docsPath: "/channels/whatsapp",
        blurb: "WhatsApp owner-derivation test plugin.",
      },
      capabilities: { chatTypes: ["direct"], reactions: true },
      config: {
        listAccountIds: () => ["default"],
        resolveAccount: () => ({ enabled: true }),
        isConfigured: () => true,
      },
      actions: {
        describeMessageTool: () => ({ actions: ["react"] }),
        supportsAction: ({ action }) => action === "react",
        handleAction: async ({ senderIsOwner }) => {
          capture.senderIsOwner = senderIsOwner;
          return jsonResult({ ok: true });
        },
      },
    };
    mocks.getChannelPlugin.mockReturnValue(reactPlugin);

    // Narrowly-scoped caller (e.g. gateway-forwarding least-privilege path
    // that only requests operator.write): wire senderIsOwner=true must be
    // forced to false so a non-admin scoped caller cannot unlock owner-only
    // channel actions.
    setActivePluginRegistry(
      createTestRegistry([{ pluginId: "whatsapp", source: "test", plugin: reactPlugin }]),
      "send-test-owner-derive-non-admin",
    );
    await runMessageActionRequest(
      {
        channel: "whatsapp",
        action: "react",
        params: { chatJid: "+15551234567", messageId: "wamid.x", emoji: "✅" },
        senderIsOwner: true,
        idempotencyKey: "idem-owner-derive-non-admin",
      },
      { connect: { scopes: ["operator.write"] } },
    );
    expect(capture.senderIsOwner).toBe(false);

    // Full operator (admin-scoped): the trusted runtime is allowed to
    // forward the real channel-sender ownership bit. Wire true → true.
    setActivePluginRegistry(
      createTestRegistry([{ pluginId: "whatsapp", source: "test", plugin: reactPlugin }]),
      "send-test-owner-derive-admin-true",
    );
    await runMessageActionRequest(
      {
        channel: "whatsapp",
        action: "react",
        params: { chatJid: "+15551234567", messageId: "wamid.y", emoji: "✅" },
        senderIsOwner: true,
        idempotencyKey: "idem-owner-derive-admin-true",
      },
      { connect: { scopes: ["operator.admin"] } },
    );
    expect(capture.senderIsOwner).toBe(true);

    // Full operator forwarding a non-owner sender: wire false → false
    // (admin scope does not inflate ownership on its own).
    setActivePluginRegistry(
      createTestRegistry([{ pluginId: "whatsapp", source: "test", plugin: reactPlugin }]),
      "send-test-owner-derive-admin-false",
    );
    await runMessageActionRequest(
      {
        channel: "whatsapp",
        action: "react",
        params: { chatJid: "+15551234567", messageId: "wamid.z", emoji: "✅" },
        senderIsOwner: false,
        idempotencyKey: "idem-owner-derive-admin-false",
      },
      { connect: { scopes: ["operator.admin"] } },
    );
    expect(capture.senderIsOwner).toBe(false);
  });
});

¤ Dauer der Verarbeitung: 0.28 Sekunden  (vorverarbeitet am  2026-04-27) ¤

*© Formatika GbR, Deutschland






Wurzel

Suchen

Beweissystem der NASA

Beweissystem Isabelle

NIST Cobol Testsuite

Cephes Mathematical Library

Wiener Entwicklungsmethode

Haftungshinweis

Die Informationen auf dieser Webseite wurden nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit, noch Qualität der bereit gestellten Informationen zugesichert.

Bemerkung:

Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.