File size: 10,713 Bytes
5cb63c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
// Plugin-shape coherence contract for bundled channel plugins.
//
// Catalog routing keys off plugin ids, docs surfaces render `meta.docsPath`,
// and capability flags gate feature discovery, so every bundled channel must
// keep identity metadata aligned with its catalog id and capability flags
// coherent with the adapters that implement them. This suite already loads
// every bundled plugin, so it also owns loaded-plugin parity for lightweight
// artifacts while the artifact shards pin inventory, exports, and invocation.
//
// Capability rules verified against every bundled channel before pinning.
// Dropped for legitimate exceptions rather than special-casing:
// - threads=true does not imply a threading adapter (clickclack/tlon bind
//   threads through conversationBindings only).
// - nativeCommands=true does not imply a commands adapter (mattermost serves
//   slash commands through gateway HTTP routes).
// - blockStreaming=true does not imply a streaming adapter (coalesce tuning
//   is optional).
import { beforeAll, describe, expect, it } from "vitest";
import { listBundledPackageChannelMetadata } from "../../../plugins/bundled-package-channel-metadata.js";
import {
  getBundledChannelGatewayAuthArtifactAsync,
  getBundledChannelMessageToolArtifactAsync,
  getBundledChannelPluginAsync,
  getBundledChannelSessionKeyArtifactAsync,
  getBundledChannelThreadBindingArtifactAsync,
  listBundledChannelPluginIds,
} from "./test-helpers/bundled-channel-plugin-loader.js";

const CHAT_TYPES = new Set(["direct", "group", "channel", "thread"]);
const bundledChannelPluginIds = listBundledChannelPluginIds();
const packageMetadataById = new Map(
  listBundledPackageChannelMetadata().map((channel) => [channel.id, channel]),
);
const SHARED_SANITIZER_CHANNEL_IDS = [
  "nextcloud-talk",
  "zalo",
  "irc",
  "feishu",
  "signal",
  "twitch",
  "matrix",
  "slack",
] as const;
const MESSAGE_TOOL_ARTIFACT_PLUGIN_IDS = ["imessage", "slack"] as const;
const SESSION_CONVERSATION_ARTIFACT_PLUGIN_IDS = ["feishu", "telegram"] as const;
const THREAD_BINDING_ARTIFACT_PLUGIN_IDS = ["discord", "matrix"] as const;
const PROVIDER_OWNED_READ_GATE_PLUGINS = [
  ["discord", true],
  ["feishu", true],
  ["matrix", true],
  ["msteams", true],
  ["slack", true],
  ["mattermost", ["read"]],
  ["telegram", ["react", "edit", "delete", "emoji-list"]],
] as const;

type ExplicitSessionKeyNormalizer = (
  sessionKey: string,
  ctx: { ChatType?: string; From?: string; SenderId?: string },
) => string;

describe("bundled channel plugin shape coherence", () => {
  const plugins = new Map<string, Awaited<ReturnType<typeof getBundledChannelPluginAsync>>>();
  const messageToolArtifacts = new Map<
    string,
    Awaited<ReturnType<typeof getBundledChannelMessageToolArtifactAsync>>
  >();
  const sessionKeyArtifacts = new Map<
    string,
    Awaited<ReturnType<typeof getBundledChannelSessionKeyArtifactAsync>>
  >();
  const threadBindingArtifacts = new Map<
    string,
    Awaited<ReturnType<typeof getBundledChannelThreadBindingArtifactAsync>>
  >();
  let gatewayAuthArtifact: Awaited<ReturnType<typeof getBundledChannelGatewayAuthArtifactAsync>> =
    null;

  beforeAll(async () => {
    for (const id of bundledChannelPluginIds) {
      plugins.set(id, await getBundledChannelPluginAsync(id));
    }
    for (const id of MESSAGE_TOOL_ARTIFACT_PLUGIN_IDS) {
      messageToolArtifacts.set(id, await getBundledChannelMessageToolArtifactAsync(id));
    }
    gatewayAuthArtifact = await getBundledChannelGatewayAuthArtifactAsync("mattermost");
    for (const id of ["discord", ...SESSION_CONVERSATION_ARTIFACT_PLUGIN_IDS] as const) {
      sessionKeyArtifacts.set(id, await getBundledChannelSessionKeyArtifactAsync(id));
    }
    for (const id of THREAD_BINDING_ARTIFACT_PLUGIN_IDS) {
      threadBindingArtifacts.set(id, await getBundledChannelThreadBindingArtifactAsync(id));
    }
  });

  it("discovers bundled channel plugins from the catalog", () => {
    expect(bundledChannelPluginIds.length).toBeGreaterThan(0);
  });

  it.each(SHARED_SANITIZER_CHANNEL_IDS)(
    "%s applies shared sanitizer semantics to outbound text",
    (id) => {
      const sanitizeText = plugins.get(id)?.outbound?.sanitizeText;
      if (!sanitizeText) {
        throw new Error(`Missing outbound sanitizeText hook for ${id}`);
      }
      const visible = `Visible answer: ${id}`;
      const text = [
        '<invoke name="read">payload</invoke></minimax:tool_call>',
        '<tool_result>{"output":"hidden"}</tool_result>',
        "[Tool Call: read (ID: toolu_1)]",
        'Arguments: {"path":"/tmp/x"}',
        "<think>secret</think>",
        visible,
      ].join("\n");
      const expected = sanitizeAssistantVisibleText(text);

      expect(expected).toBe(visible);
      expect(sanitizeText({ text, payload: { text } })).toBe(expected);
    },
  );

  it.each(MESSAGE_TOOL_ARTIFACT_PLUGIN_IDS)(
    "keeps the %s message-tool artifact identical to the loaded plugin action surface",
    (id) => {
      const artifactDescribeMessageTool = messageToolArtifacts.get(id)?.describeMessageTool;
      const pluginDescribeMessageTool = plugins.get(id)?.actions?.describeMessageTool;

      expect(typeof artifactDescribeMessageTool).toBe("function");
      expect(typeof pluginDescribeMessageTool).toBe("function");
      expect(artifactDescribeMessageTool).toBe(pluginDescribeMessageTool);
    },
  );

  it("keeps the mattermost gateway-auth artifact identical to the loaded plugin gateway surface", () => {
    const artifactResolveGatewayAuthBypassPaths =
      gatewayAuthArtifact?.resolveGatewayAuthBypassPaths;
    const pluginResolveGatewayAuthBypassPaths =
      plugins.get("mattermost")?.gateway?.resolveGatewayAuthBypassPaths;

    expect(typeof artifactResolveGatewayAuthBypassPaths).toBe("function");
    expect(typeof pluginResolveGatewayAuthBypassPaths).toBe("function");
    expect(artifactResolveGatewayAuthBypassPaths).toBe(pluginResolveGatewayAuthBypassPaths);
  });

  it.each(SESSION_CONVERSATION_ARTIFACT_PLUGIN_IDS)(
    "keeps the %s session-conversation artifact identical to the loaded plugin messaging hook",
    (id) => {
      const artifactResolveSessionConversation =
        sessionKeyArtifacts.get(id)?.resolveSessionConversation;
      const pluginResolveSessionConversation =
        plugins.get(id)?.messaging?.resolveSessionConversation;

      expect(typeof artifactResolveSessionConversation).toBe("function");
      expect(typeof pluginResolveSessionConversation).toBe("function");
      expect(artifactResolveSessionConversation).toBe(pluginResolveSessionConversation);
    },
  );

  it("keeps the discord session-key artifact behavior aligned with the loaded plugin adapter", () => {
    const normalize = sessionKeyArtifacts.get("discord")?.normalizeExplicitDiscordSessionKey as
      | ExplicitSessionKeyNormalizer
      | undefined;
    const pluginNormalize = plugins.get("discord")?.messaging?.normalizeExplicitSessionKey;

    expect(typeof normalize).toBe("function");
    expect(typeof pluginNormalize).toBe("function");
    if (typeof normalize !== "function" || typeof pluginNormalize !== "function") {
      throw new Error("Missing discord session-key normalizer parity surface");
    }

    // The plugin hook adapts core's params-object shape onto the artifact's
    // positional export, so pin behavioral parity over representative keys.
    const cases = [
      { sessionKey: "discord:channel:123", ctx: { ChatType: "direct", SenderId: "123" } },
      { sessionKey: "discord:dm:42", ctx: { ChatType: "dm", From: "discord:42" } },
      { sessionKey: "agent:m:discord:channel:9", ctx: { ChatType: "direct", From: "discord:9" } },
      { sessionKey: "Discord:Channel:77", ctx: { ChatType: "group", SenderId: "77" } },
    ] as const;
    for (const { sessionKey, ctx } of cases) {
      expect(pluginNormalize({ sessionKey, ctx })).toBe(normalize(sessionKey, ctx));
    }
  });

  it.each(THREAD_BINDING_ARTIFACT_PLUGIN_IDS)(
    "keeps the %s thread-binding artifact equal to the loaded plugin default",
    (id) => {
      const artifactPlacement = threadBindingArtifacts.get(id)?.defaultTopLevelPlacement;
      const pluginPlacement = plugins.get(id)?.conversationBindings?.defaultTopLevelPlacement;

      expect(["current", "child"]).toContain(artifactPlacement);
      expect(["current", "child"]).toContain(pluginPlacement);
      expect(artifactPlacement).toBe(pluginPlacement);
    },
  );

  it.each(PROVIDER_OWNED_READ_GATE_PLUGINS)(
    "keeps the %s provider-owned read gate declaration on its registered plugin surface",
    (id, expected) => {
      expect(plugins.get(id)?.actions?.providerOwnedReadGates).toEqual(expected);
    },
  );

  describe.each(bundledChannelPluginIds)("%s", (id) => {
    it("keeps plugin identity aligned with the catalog id", () => {
      const plugin = plugins.get(id);
      if (!plugin) {
        throw new Error(`Missing bundled channel plugin for ${id}`);
      }
      expect(plugin.id).toBe(id);
      expect(plugin.meta.id).toBe(id);
    });

    it("ships non-empty docs metadata", () => {
      const plugin = plugins.get(id);
      expect(plugin?.meta.docsPath.trim()).toBeTruthy();
    });

    it("keeps runtime and lazy setup metadata on the same channel-owned contract", () => {
      const plugin = plugins.get(id);
      const packageSetup = packageMetadataById.get(id)?.setup;
      if (!plugin?.setup && !plugin?.setupContract && !packageSetup) {
        return;
      }
      expect(plugin?.setupContract, `${id} must expose setupContract`).toBeDefined();
      expect(
        plugin?.setup,
        `${id} must not duplicate the released legacy setup adapter`,
      ).toBeUndefined();
      expect(packageSetup, `${id} must expose package setup metadata`).toBeDefined();
      expect(plugin?.setupContract?.metadata).toEqual(packageSetup);
    });

    it("declares known chat types", () => {
      const chatTypes = plugins.get(id)?.capabilities.chatTypes ?? [];
      expect(chatTypes.length).toBeGreaterThan(0);
      expect(chatTypes.filter((chatType) => !CHAT_TYPES.has(chatType))).toEqual([]);
    });

    it("backs declared reactions with a message action surface", () => {
      const plugin = plugins.get(id);
      if (!plugin?.capabilities.reactions) {
        return;
      }
      // Reactions are delivered through the shared `message` tool, so a channel
      // declaring the capability without an actions adapter ships a dead flag.
      expect(plugin.actions).toBeDefined();
      expect(typeof plugin.actions?.describeMessageTool).toBe("function");
    });
  });
});