kimi-agent-module-api 1.2.0__tar.gz → 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {kimi_agent_module_api-1.2.0/src/kimi_agent_module_api.egg-info → kimi_agent_module_api-1.3.0}/PKG-INFO +13 -1
  2. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/README.md +12 -0
  3. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/pyproject.toml +8 -1
  4. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/contracts.py +211 -0
  5. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/events.py +2 -0
  6. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/testing.py +216 -4
  7. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/tools.py +4 -0
  8. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0/src/kimi_agent_module_api.egg-info}/PKG-INFO +13 -1
  9. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api.egg-info/SOURCES.txt +4 -1
  10. kimi_agent_module_api-1.3.0/tests/test_contracts.py +187 -0
  11. kimi_agent_module_api-1.3.0/tests/test_public_api.py +43 -0
  12. kimi_agent_module_api-1.3.0/tests/test_testing.py +114 -0
  13. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/LICENSE +0 -0
  14. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/setup.cfg +0 -0
  15. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/__init__.py +0 -0
  16. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/images.py +0 -0
  17. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/py.typed +0 -0
  18. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/settings.py +0 -0
  19. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/trust.py +0 -0
  20. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api.egg-info/dependency_links.txt +0 -0
  21. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api.egg-info/requires.txt +0 -0
  22. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kimi-agent-module-api
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Stable contracts for community-built assistant modules
5
5
  Author: Webhead
6
6
  License-Expression: MIT
@@ -59,3 +59,15 @@ Version 1.2 adds typed modal forms and a narrow Components V2 layout model. Once
59
59
  that layout model, Discord requires every later edit of the same message to remain a layout.
60
60
  Modules using them should depend on `kimi-agent-module-api>=1.2,<2` and require
61
61
  `discord.modals.v1` and/or `discord.components_v2.v1`.
62
+
63
+ Version 1.3 adds cached author classification to message-deletion events:
64
+ `MessageDeleteEvent.author_is_bot` and `MessageBulkDeleteEvent.bot_message_ids`.
65
+ The values remain unknown for messages that were absent from Discord's cache.
66
+
67
+ ## Testing the SDK
68
+
69
+ From this package directory, run its tests without installing the Kimi application:
70
+
71
+ ```console
72
+ uv run --isolated --group test python -m pytest -q
73
+ ```
@@ -42,3 +42,15 @@ Version 1.2 adds typed modal forms and a narrow Components V2 layout model. Once
42
42
  that layout model, Discord requires every later edit of the same message to remain a layout.
43
43
  Modules using them should depend on `kimi-agent-module-api>=1.2,<2` and require
44
44
  `discord.modals.v1` and/or `discord.components_v2.v1`.
45
+
46
+ Version 1.3 adds cached author classification to message-deletion events:
47
+ `MessageDeleteEvent.author_is_bot` and `MessageBulkDeleteEvent.bot_message_ids`.
48
+ The values remain unknown for messages that were absent from Discord's cache.
49
+
50
+ ## Testing the SDK
51
+
52
+ From this package directory, run its tests without installing the Kimi application:
53
+
54
+ ```console
55
+ uv run --isolated --group test python -m pytest -q
56
+ ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kimi-agent-module-api"
3
- version = "1.2.0"
3
+ version = "1.3.0"
4
4
  description = "Stable contracts for community-built assistant modules"
5
5
  requires-python = ">=3.14"
6
6
  authors = [{ name = "Webhead" }]
@@ -18,6 +18,9 @@ Repository = "https://github.com/webhead2oo9/kimi-agent"
18
18
  # kimi_agent_module_api.testing.MemoryStorage runs real SQL in unit tests.
19
19
  testing = ["aiosqlite"]
20
20
 
21
+ [dependency-groups]
22
+ test = ["aiosqlite", "pytest", "pytest-asyncio"]
23
+
21
24
  [build-system]
22
25
  requires = ["setuptools>=84"]
23
26
  build-backend = "setuptools.build_meta"
@@ -27,3 +30,7 @@ where = ["src"]
27
30
 
28
31
  [tool.setuptools.package-data]
29
32
  kimi_agent_module_api = ["py.typed"]
33
+
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
36
+ asyncio_default_fixture_loop_scope = "function"
@@ -12,6 +12,7 @@ owner manifest and enforced through the ports below; they are not a sandbox.
12
12
  from __future__ import annotations
13
13
 
14
14
  import json
15
+ import keyword
15
16
  import math
16
17
  import re
17
18
  from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
@@ -326,6 +327,13 @@ CORE_TOPIC_PREFIX = "discord"
326
327
  _CORE_RESERVED_MODULE_NAMES = frozenset({CORE_TOPIC_PREFIX, "proposals"})
327
328
  CUSTOM_ID_PREFIX = "m"
328
329
  CUSTOM_ID_MAX_LENGTH = 100
330
+ # A modal ID also carries a fixed-width per-open suffix so two people opening the
331
+ # same form do not share one entry in the host's modal table. That suffix is not
332
+ # the module's to spend, so a modal's declared ID has a smaller budget than a
333
+ # button's. Fixed width on purpose: a variable one would shrink the budget as the
334
+ # host ran, letting a modal open successfully and then fail later.
335
+ MODAL_NONCE_CHARS = 8
336
+ MODAL_CUSTOM_ID_MAX_LENGTH = CUSTOM_ID_MAX_LENGTH - 1 - MODAL_NONCE_CHARS
329
337
 
330
338
 
331
339
  def table_prefix(module_name: str) -> str:
@@ -987,6 +995,7 @@ class TrustLookup(Protocol):
987
995
 
988
996
 
989
997
  type CommandOptionKind = Literal["string", "integer", "boolean", "user", "channel", "role"]
998
+ _OPTION_KINDS = frozenset({"string", "integer", "boolean", "user", "channel", "role"})
990
999
 
991
1000
 
992
1001
  @dataclass(frozen=True, slots=True)
@@ -1147,6 +1156,208 @@ class ModalSpec:
1147
1156
  parts: tuple[str, ...] = ()
1148
1157
 
1149
1158
 
1159
+ _OPTIONS_WITH_CHOICES = frozenset({"string", "integer"})
1160
+ # Keep command and group name validation in the SDK so the public fakes reject
1161
+ # the same payloads discord.py rejects while constructing the live tree.
1162
+ _COMMAND_NAME_RE = re.compile(
1163
+ r"^[-_\w\u0e31-\u0e3a\u0e47-\u0e4e\u0900-\u0903\u093a\u093b\u093c"
1164
+ r"\u093e\u093f\u0940-\u094f\u0955\u0956\u0957\u0962\u0963]{1,32}$"
1165
+ )
1166
+ _BUTTON_STYLES = frozenset({"primary", "secondary", "success", "danger"})
1167
+ _DISCORD_INTEGER_MAX = (1 << 53) - 1
1168
+ _DISCORD_INTEGER_MIN = -_DISCORD_INTEGER_MAX
1169
+
1170
+
1171
+ def validate_command_spec(spec: CommandSpec) -> None:
1172
+ """Validate the Discord limits a command payload must satisfy.
1173
+
1174
+ A whole ``tree.sync()`` is one bulk PUT, so a single malformed command
1175
+ rejects every command in that scope. Discord.py checks command and group
1176
+ *names* itself and reorders required options ahead of optional ones; these
1177
+ are the limits nothing else enforces before the payload reaches Discord.
1178
+ """
1179
+
1180
+ for label, name in (("command", spec.name), ("command group", spec.group)):
1181
+ if name is None:
1182
+ continue
1183
+ if (
1184
+ not isinstance(name, str)
1185
+ or _COMMAND_NAME_RE.fullmatch(name) is None
1186
+ or name.lower() != name
1187
+ ):
1188
+ raise ModuleContractError(f"invalid {label} name {name!r}")
1189
+ if not isinstance(spec.description, str) or not 1 <= len(spec.description) <= 100:
1190
+ raise ModuleContractError("a command description must contain 1 to 100 characters")
1191
+ if spec.group is not None and (
1192
+ not isinstance(spec.group_description, str) or len(spec.group_description) > 100
1193
+ ):
1194
+ raise ModuleContractError("a command group description cannot exceed 100 characters")
1195
+ if len(spec.options) > 25:
1196
+ raise ModuleContractError("a command cannot declare more than 25 options")
1197
+
1198
+ names: set[str] = set()
1199
+ for option in spec.options:
1200
+ # Discord applies the slash-command name grammar to option names, while
1201
+ # the host also builds each option into a Python parameter. Enforce the
1202
+ # intersection here: this keeps valid leading underscores and lowercase
1203
+ # Unicode identifiers while rejecting Discord-only names such as ``a-b``.
1204
+ if (
1205
+ not isinstance(option.name, str)
1206
+ or _COMMAND_NAME_RE.fullmatch(option.name) is None
1207
+ or option.name.lower() != option.name
1208
+ or not option.name.isidentifier()
1209
+ or keyword.iskeyword(option.name)
1210
+ ):
1211
+ raise ModuleContractError(f"invalid command option name {option.name!r}")
1212
+ if option.name in names:
1213
+ raise ModuleContractError("command option names must be unique")
1214
+ names.add(option.name)
1215
+ if option.kind not in _OPTION_KINDS:
1216
+ raise ModuleContractError(f"unsupported command option kind {option.kind!r}")
1217
+ if not isinstance(option.description, str) or not 1 <= len(option.description) <= 100:
1218
+ raise ModuleContractError(
1219
+ f"the description for option {option.name!r} must contain 1 to 100 characters"
1220
+ )
1221
+ if option.choices and option.autocomplete:
1222
+ # Discord rejects a payload carrying both, and discord.py emits both.
1223
+ raise ModuleContractError(
1224
+ f"option {option.name!r} cannot use choices and autocomplete together"
1225
+ )
1226
+ if (option.choices or option.autocomplete) and option.kind not in _OPTIONS_WITH_CHOICES:
1227
+ raise ModuleContractError(
1228
+ f"option {option.name!r} cannot use choices or autocomplete on a "
1229
+ f"{option.kind} option"
1230
+ )
1231
+ if len(option.choices) > 25:
1232
+ raise ModuleContractError(f"option {option.name!r} cannot declare more than 25 choices")
1233
+ values: set[str | int] = set()
1234
+ for name, value in option.choices:
1235
+ if not isinstance(name, str) or not 1 <= len(name) <= 100:
1236
+ raise ModuleContractError(f"invalid choice name {name!r} on option {option.name!r}")
1237
+ if isinstance(value, bool) or not isinstance(value, str | int):
1238
+ raise ModuleContractError(
1239
+ f"invalid choice value {value!r} on option {option.name!r}"
1240
+ )
1241
+ # A choice whose value type disagrees with its option is a 400 on the
1242
+ # whole payload, and nothing downstream cross-checks the two.
1243
+ if not isinstance(value, str if option.kind == "string" else int):
1244
+ raise ModuleContractError(
1245
+ f"choice values on {option.kind} option {option.name!r} must be "
1246
+ f"{'strings' if option.kind == 'string' else 'integers'}"
1247
+ )
1248
+ if (
1249
+ option.kind == "integer"
1250
+ and isinstance(value, int)
1251
+ and not _DISCORD_INTEGER_MIN <= value <= _DISCORD_INTEGER_MAX
1252
+ ):
1253
+ raise ModuleContractError(
1254
+ f"an integer choice value on option {option.name!r} must be between "
1255
+ f"{_DISCORD_INTEGER_MIN} and {_DISCORD_INTEGER_MAX}"
1256
+ )
1257
+ if isinstance(value, str) and not 1 <= len(value) <= 100:
1258
+ raise ModuleContractError(
1259
+ f"a choice value on option {option.name!r} must contain 1 to 100 characters"
1260
+ )
1261
+ if value in values:
1262
+ raise ModuleContractError(f"choice values on option {option.name!r} must be unique")
1263
+ values.add(value)
1264
+ for bound in (option.min_value, option.max_value):
1265
+ if bound is None:
1266
+ continue
1267
+ if isinstance(bound, bool) or not isinstance(bound, int):
1268
+ raise ModuleContractError(f"option {option.name!r} bounds must be integers")
1269
+ if option.kind != "integer":
1270
+ # Only integer options carry the bounds through to Discord, so a
1271
+ # bound anywhere else would be silently dropped.
1272
+ raise ModuleContractError(
1273
+ f"option {option.name!r} cannot set min_value or max_value on a "
1274
+ f"{option.kind} option"
1275
+ )
1276
+ if not _DISCORD_INTEGER_MIN <= bound <= _DISCORD_INTEGER_MAX:
1277
+ raise ModuleContractError(
1278
+ f"option {option.name!r} bounds must be between "
1279
+ f"{_DISCORD_INTEGER_MIN} and {_DISCORD_INTEGER_MAX}"
1280
+ )
1281
+ if (
1282
+ option.min_value is not None
1283
+ and option.max_value is not None
1284
+ and option.min_value > option.max_value
1285
+ ):
1286
+ raise ModuleContractError(f"option {option.name!r} min_value cannot exceed max_value")
1287
+
1288
+
1289
+ def validate_button_spec(button: ButtonSpec) -> None:
1290
+ """Validate Discord's hard button limits."""
1291
+
1292
+ if not isinstance(button.key, str) or not _TOPIC_SEGMENT_RE.fullmatch(button.key):
1293
+ raise ModuleContractError(f"invalid button key {button.key!r}")
1294
+ if any(not isinstance(part, str) or ":" in part for part in button.parts):
1295
+ raise ModuleContractError("button custom_id parts must be strings without ':'")
1296
+ if not isinstance(button.label, str) or len(button.label) > 80:
1297
+ raise ModuleContractError("a button label cannot exceed 80 characters")
1298
+ if button.style not in _BUTTON_STYLES:
1299
+ raise ModuleContractError(f"invalid button style {button.style!r}")
1300
+ if button.emoji is not None and (not isinstance(button.emoji, str) or not button.emoji):
1301
+ raise ModuleContractError("a button emoji must be a non-empty string")
1302
+ if not button.label and button.emoji is None:
1303
+ raise ModuleContractError("a button must provide a label or emoji")
1304
+
1305
+
1306
+ def validate_component_spec(component: Any) -> None:
1307
+ """Validate one interactive component, whichever kind it is."""
1308
+
1309
+ if isinstance(component, ButtonSpec):
1310
+ validate_button_spec(component)
1311
+ elif isinstance(component, SelectSpec):
1312
+ validate_select_spec(component)
1313
+ else:
1314
+ raise ModuleContractError(f"unsupported component {component!r}")
1315
+
1316
+
1317
+ def validate_select_spec(select: SelectSpec) -> None:
1318
+ """Validate Discord's hard select-menu limits."""
1319
+
1320
+ if not isinstance(select.key, str) or not _TOPIC_SEGMENT_RE.fullmatch(select.key):
1321
+ raise ModuleContractError(f"invalid select key {select.key!r}")
1322
+ if any(not isinstance(part, str) or ":" in part for part in select.parts):
1323
+ raise ModuleContractError("select custom_id parts must be strings without ':'")
1324
+ if select.placeholder is not None and (
1325
+ not isinstance(select.placeholder, str) or len(select.placeholder) > 150
1326
+ ):
1327
+ raise ModuleContractError("a select placeholder cannot exceed 150 characters")
1328
+ if not 1 <= len(select.options) <= 25:
1329
+ raise ModuleContractError("a select must contain between one and 25 options")
1330
+
1331
+ values: set[str] = set()
1332
+ for label, value, description in select.options:
1333
+ if not isinstance(label, str) or not 1 <= len(label) <= 100:
1334
+ raise ModuleContractError("a select option label must contain 1 to 100 characters")
1335
+ if not isinstance(value, str) or not 1 <= len(value) <= 100:
1336
+ raise ModuleContractError("a select option value must contain 1 to 100 characters")
1337
+ if value in values:
1338
+ raise ModuleContractError("select option values must be unique")
1339
+ values.add(value)
1340
+ if description is not None and (
1341
+ not isinstance(description, str) or not 1 <= len(description) <= 100
1342
+ ):
1343
+ raise ModuleContractError(
1344
+ "a select option description must contain 1 to 100 characters"
1345
+ )
1346
+
1347
+ for name, bound, low in (
1348
+ ("min_values", select.min_values, 0),
1349
+ ("max_values", select.max_values, 1),
1350
+ ):
1351
+ if isinstance(bound, bool) or not isinstance(bound, int) or not low <= bound <= 25:
1352
+ raise ModuleContractError(f"select {name} must be between {low} and 25")
1353
+ if select.min_values > select.max_values:
1354
+ raise ModuleContractError("select min_values cannot exceed max_values")
1355
+ # max_values may exceed the option count (Discord clamps it), but a
1356
+ # minimum above the count can never be satisfied, so the select is dead.
1357
+ if select.min_values > len(select.options):
1358
+ raise ModuleContractError("select min_values cannot exceed the number of options")
1359
+
1360
+
1150
1361
  def validate_modal_spec(modal: ModalSpec) -> None:
1151
1362
  """Validate Discord's hard modal and text-input limits."""
1152
1363
  if not isinstance(modal.key, str) or not _TOPIC_SEGMENT_RE.fullmatch(modal.key):
@@ -69,11 +69,13 @@ class MessageDeleteEvent:
69
69
  author_id: int | None
70
70
  cached_content: str | None
71
71
  cached_attachments: tuple[AttachmentSnapshot, ...]
72
+ author_is_bot: bool | None = None
72
73
 
73
74
 
74
75
  @dataclass(frozen=True, slots=True)
75
76
  class MessageBulkDeleteEvent:
76
77
  refs: tuple[MessageRef, ...]
78
+ bot_message_ids: tuple[int, ...] = ()
77
79
 
78
80
 
79
81
  @dataclass(frozen=True, slots=True)
@@ -26,7 +26,9 @@ from kimi_agent_module_api.trust import TrustTier
26
26
 
27
27
  from kimi_agent_module_api.contracts import (
28
28
  ALL_DISCORD_ACTIONS,
29
+ MODAL_CUSTOM_ID_MAX_LENGTH,
29
30
  Backoff,
31
+ ButtonSpec,
30
32
  ChannelSnapshot,
31
33
  CommandSpec,
32
34
  GuildCommand,
@@ -58,10 +60,14 @@ from kimi_agent_module_api.contracts import (
58
60
  ProposalState,
59
61
  RoleSnapshot,
60
62
  ScopedModuleMigration,
63
+ SelectSpec,
61
64
  ServiceUnavailable,
62
65
  TrustTierName,
63
66
  UndeclaredDiscordAction,
64
67
  build_custom_id,
68
+ parse_custom_id,
69
+ validate_command_spec,
70
+ validate_component_spec,
65
71
  validate_modal_spec,
66
72
  validate_layout_components,
67
73
  validate_outgoing_layout,
@@ -590,6 +596,7 @@ class FakeInteraction:
590
596
  guild_name: str | None = "Test Guild",
591
597
  message: MessageRef | None = None,
592
598
  message_uses_layout: bool = False,
599
+ module_name: str | None = None,
593
600
  ) -> None:
594
601
  self._message = message
595
602
  self._guild_name = guild_name
@@ -604,6 +611,31 @@ class FakeInteraction:
604
611
  self.shown_modals: list[ModalSpec] = []
605
612
  self.deferred: bool | None = None
606
613
  self._original_uses_layout = message_uses_layout
614
+ parsed_custom_id = parse_custom_id(custom_id or "")
615
+ self._module_name = module_name or (
616
+ parsed_custom_id[0] if parsed_custom_id is not None else None
617
+ )
618
+
619
+ def _component_module_name(self) -> str:
620
+ if self._module_name is None:
621
+ raise ModuleContractError(
622
+ "FakeInteraction needs module_name to validate component custom_ids; "
623
+ "use FakeInteractions.interaction() or pass module_name explicitly"
624
+ )
625
+ return self._module_name
626
+
627
+ def _validate_components(
628
+ self, components: Sequence[Any], *, layout: OutgoingLayout | None
629
+ ) -> None:
630
+ for component in components:
631
+ validate_component_spec(component)
632
+ if isinstance(component, ButtonSpec | SelectSpec):
633
+ build_custom_id(
634
+ self._component_module_name(),
635
+ component.key,
636
+ *component.parts,
637
+ )
638
+ validate_layout_components(components, layout=layout)
607
639
 
608
640
  @property
609
641
  def guild_id(self) -> int:
@@ -652,9 +684,9 @@ class FakeInteraction:
652
684
  ) -> None:
653
685
  if layout is not None and (content is not None or embed is not None):
654
686
  raise ModuleContractError("layout cannot be combined with content or embed")
687
+ self._validate_components(components, layout=layout)
655
688
  if layout is not None:
656
689
  validate_outgoing_layout(layout)
657
- validate_layout_components(components, layout=layout)
658
690
  self.responses.append(
659
691
  FakeResponse(content, embed, ephemeral, tuple(components), "respond", layout=layout)
660
692
  )
@@ -665,6 +697,11 @@ class FakeInteraction:
665
697
 
666
698
  async def show_modal(self, modal: ModalSpec) -> None:
667
699
  validate_modal_spec(modal)
700
+ declared = build_custom_id(self._component_module_name(), modal.key, *modal.parts)
701
+ if len(declared) > MODAL_CUSTOM_ID_MAX_LENGTH:
702
+ raise ModuleContractError(
703
+ f"modal custom_id exceeds {MODAL_CUSTOM_ID_MAX_LENGTH} characters"
704
+ )
668
705
  self.shown_modals.append(modal)
669
706
 
670
707
  async def edit_original(
@@ -681,9 +718,9 @@ class FakeInteraction:
681
718
  )
682
719
  if layout is not None and (content is not None or embed is not None):
683
720
  raise ModuleContractError("layout cannot be combined with content or embed")
721
+ self._validate_components(components, layout=layout)
684
722
  if layout is not None:
685
723
  validate_outgoing_layout(layout)
686
- validate_layout_components(components, layout=layout)
687
724
  self.responses.append(
688
725
  FakeResponse(content, embed, False, tuple(components), "edit", layout=layout)
689
726
  )
@@ -700,17 +737,107 @@ class FakeInteraction:
700
737
  return self.responses[-1]
701
738
 
702
739
 
740
+ @dataclass(slots=True)
741
+ class FakeInteractionOwnership:
742
+ """Shared top-level command ownership for a multi-module test host."""
743
+
744
+ routers: list[FakeInteractions] = field(default_factory=list)
745
+
746
+ def register(self, router: FakeInteractions) -> None:
747
+ self.routers.append(router)
748
+
749
+ def unregister(self, router: FakeInteractions) -> None:
750
+ if router in self.routers:
751
+ self.routers.remove(router)
752
+
753
+ def global_top_names(self) -> set[str]:
754
+ return {top_name for router in self.routers for top_name in router._global_top_kinds()}
755
+
756
+ def guild_top_names(
757
+ self,
758
+ guild_id: int,
759
+ *,
760
+ excluding: FakeInteractions | None = None,
761
+ ) -> set[str]:
762
+ return {
763
+ spec.group or spec.name
764
+ for router in self.routers
765
+ if router is not excluding
766
+ for spec, _handler in router.guild_commands.get(guild_id, {}).values()
767
+ }
768
+
769
+ def global_owner(self, top_name: str) -> tuple[FakeInteractions, str] | None:
770
+ for router in self.routers:
771
+ kind = router._global_top_kinds().get(top_name)
772
+ if kind is not None:
773
+ return router, kind
774
+ return None
775
+
776
+ def guild_owner(self, top_name: str, *, guild_id: int | None = None) -> FakeInteractions | None:
777
+ for router in self.routers:
778
+ guild_sets = (
779
+ (router.guild_commands.get(guild_id, {}),)
780
+ if guild_id is not None
781
+ else tuple(router.guild_commands.values())
782
+ )
783
+ for commands in guild_sets:
784
+ if any(
785
+ (spec.group or spec.name) == top_name for spec, _handler in commands.values()
786
+ ):
787
+ return router
788
+ return None
789
+
790
+
703
791
  class FakeInteractions:
704
792
  """Records command and component registrations; tests invoke handlers directly."""
705
793
 
706
- def __init__(self, module_name: str) -> None:
794
+ def __init__(
795
+ self,
796
+ module_name: str,
797
+ *,
798
+ ownership: FakeInteractionOwnership | None = None,
799
+ is_guild_active: Callable[[int], bool] | None = None,
800
+ ) -> None:
707
801
  self.module_name = module_name
802
+ self.ownership = ownership or FakeInteractionOwnership()
803
+ self.ownership.register(self)
804
+ self._is_guild_active = (
805
+ is_guild_active if is_guild_active is not None else lambda _guild_id: True
806
+ )
708
807
  self.commands: dict[str, tuple[CommandSpec, Callable[..., Any]]] = {}
709
808
  self.guild_commands: dict[int, dict[str, tuple[CommandSpec, Callable[..., Any]]]] = {}
710
809
  self.guild_autocompletes: dict[int, dict[str, Callable[..., Any]]] = {}
711
810
  self.components: dict[tuple[str, str], Callable[..., Any]] = {}
712
811
  self.component_min_tiers: dict[tuple[str, str], TrustTierName] = {}
713
812
  self.autocompletes: dict[str, Callable[..., Any]] = {}
813
+ self._closed = False
814
+
815
+ def _ensure_open(self) -> None:
816
+ if self._closed:
817
+ raise RuntimeError(f"module {self.module_name!r} interactions are closed")
818
+
819
+ def interaction(self, **kwargs: Any) -> FakeInteraction:
820
+ """Create an interaction bound to this router's exact custom-ID namespace."""
821
+
822
+ self._ensure_open()
823
+ supplied = kwargs.pop("module_name", self.module_name)
824
+ if supplied != self.module_name:
825
+ raise ValueError(
826
+ f"interaction module_name {supplied!r} does not match {self.module_name!r}"
827
+ )
828
+ return FakeInteraction(module_name=self.module_name, **kwargs)
829
+
830
+ def _global_top_kinds(self) -> dict[str, str]:
831
+ """Top-level names the host tree would hold, derived so close() cannot drift.
832
+
833
+ Commands are stored by qualified name, but Discord and the host tree own
834
+ the *top* name, so every collision check has to look at that instead.
835
+ """
836
+
837
+ return {
838
+ (spec.group or spec.name): ("group" if spec.group else "command")
839
+ for spec, _handler in self.commands.values()
840
+ }
714
841
 
715
842
  def add_command(
716
843
  self,
@@ -719,11 +846,45 @@ class FakeInteractions:
719
846
  *,
720
847
  autocomplete: Callable[..., Any] | None = None,
721
848
  ) -> _Closable:
849
+ self._ensure_open()
850
+ validate_command_spec(spec)
851
+ if autocomplete is None and any(option.autocomplete for option in spec.options):
852
+ raise ModuleContractError(
853
+ f"module {self.module_name!r} command {spec.name!r} declares an autocomplete "
854
+ "option but supplied no autocomplete handler"
855
+ )
722
856
  qualified = f"{spec.group}.{spec.name}" if spec.group else spec.name
723
857
  if qualified in self.commands:
724
858
  raise ModuleContractError(
725
859
  f"module {self.module_name!r} command {qualified!r} is already registered"
726
860
  )
861
+ top_name = spec.group or spec.name
862
+ kind = "group" if spec.group else "command"
863
+ existing_global = self.ownership.global_owner(top_name)
864
+ if existing_global is not None and existing_global[0] is not self:
865
+ raise ModuleContractError(
866
+ f"module {self.module_name!r} command {top_name!r} is already owned"
867
+ )
868
+ if existing_global is not None and existing_global[1] != kind:
869
+ raise ModuleContractError(
870
+ f"module {self.module_name!r} command {top_name!r} is both a command and a group"
871
+ )
872
+ if self.ownership.guild_owner(top_name) is not None:
873
+ raise ModuleContractError(
874
+ f"module {self.module_name!r} global command {top_name!r} "
875
+ "would shadow a guild command"
876
+ )
877
+ if spec.group is not None:
878
+ group_size = sum(
879
+ existing.group == spec.group for existing, _handler in self.commands.values()
880
+ )
881
+ if group_size >= 25:
882
+ raise ModuleContractError(
883
+ f"module {self.module_name!r} command group {spec.group!r} "
884
+ "cannot contain more than 25 commands"
885
+ )
886
+ if existing_global is None and len(self.ownership.global_top_names()) >= 100:
887
+ raise ModuleContractError("Discord allows at most 100 global slash commands")
727
888
  self.commands[qualified] = (spec, handler)
728
889
  if autocomplete is not None:
729
890
  self.autocompletes[qualified] = autocomplete
@@ -736,25 +897,53 @@ class FakeInteractions:
736
897
  guild_id: int,
737
898
  commands: Sequence[GuildCommand],
738
899
  ) -> None:
900
+ self._ensure_open()
739
901
  if guild_id <= 0:
740
902
  raise ModuleContractError("guild_id must be a positive Discord snowflake")
903
+ if commands and not self._is_guild_active(guild_id):
904
+ raise ModuleContractError(
905
+ f"module {self.module_name!r} is not active in guild {guild_id}"
906
+ )
741
907
  desired: dict[str, tuple[CommandSpec, Callable[..., Any]]] = {}
742
908
  autocompletes: dict[str, Callable[..., Any]] = {}
743
909
  top_kinds: dict[str, str] = {}
910
+ group_sizes: dict[str, int] = {}
911
+ for command in commands:
912
+ validate_command_spec(command.spec)
913
+ if command.autocomplete is None and any(
914
+ option.autocomplete for option in command.spec.options
915
+ ):
916
+ raise ModuleContractError(
917
+ f"module {self.module_name!r} command {command.spec.name!r} declares an "
918
+ "autocomplete option but supplied no autocomplete handler"
919
+ )
744
920
  for command in commands:
745
921
  top_name = command.spec.group or command.spec.name
746
922
  kind = "group" if command.spec.group else "command"
747
- if top_name in self.commands:
923
+ if self.ownership.global_owner(top_name) is not None:
748
924
  raise ModuleContractError(
749
925
  f"module {self.module_name!r} guild command {top_name!r} "
750
926
  "would shadow a global command"
751
927
  )
928
+ guild_owner = self.ownership.guild_owner(top_name, guild_id=guild_id)
929
+ if guild_owner is not None and guild_owner is not self:
930
+ raise ModuleContractError(
931
+ f"module {self.module_name!r} guild command {top_name!r} is already owned"
932
+ )
752
933
  previous_kind = top_kinds.setdefault(top_name, kind)
753
934
  if previous_kind != kind:
754
935
  raise ModuleContractError(
755
936
  f"module {self.module_name!r} guild command {top_name!r} "
756
937
  "is both a command and a group"
757
938
  )
939
+ if command.spec.group is not None:
940
+ group_size = group_sizes.get(command.spec.group, 0) + 1
941
+ if group_size > 25:
942
+ raise ModuleContractError(
943
+ f"module {self.module_name!r} guild command group "
944
+ f"{command.spec.group!r} cannot contain more than 25 commands"
945
+ )
946
+ group_sizes[command.spec.group] = group_size
758
947
  qualified = (
759
948
  f"{command.spec.group}.{command.spec.name}"
760
949
  if command.spec.group
@@ -768,6 +957,9 @@ class FakeInteractions:
768
957
  desired[qualified] = (command.spec, command.handler)
769
958
  if command.autocomplete is not None:
770
959
  autocompletes[qualified] = command.autocomplete
960
+ other_top_names = self.ownership.guild_top_names(guild_id, excluding=self)
961
+ if len(other_top_names | set(top_kinds)) > 100:
962
+ raise ModuleContractError(f"guild {guild_id} would have more than 100 slash commands")
771
963
  if desired:
772
964
  self.guild_commands[guild_id] = desired
773
965
  self.guild_autocompletes[guild_id] = autocompletes
@@ -784,6 +976,7 @@ class FakeInteractions:
784
976
  expires_after_seconds: float | None = None,
785
977
  min_tier: TrustTierName = "member",
786
978
  ) -> _Closable:
979
+ self._ensure_open()
787
980
  if kind not in ("button", "select", "modal"):
788
981
  raise ModuleContractError(f"unsupported component kind {kind!r}")
789
982
  build_custom_id(self.module_name, key)
@@ -802,8 +995,23 @@ class FakeInteractions:
802
995
  )
803
996
 
804
997
  def custom_id(self, key: str, *parts: str) -> str:
998
+ self._ensure_open()
805
999
  return build_custom_id(self.module_name, key, *parts)
806
1000
 
1001
+ def close(self) -> None:
1002
+ """Release fake registrations and shared ownership like the live router."""
1003
+
1004
+ if self._closed:
1005
+ return
1006
+ self._closed = True
1007
+ self.commands.clear()
1008
+ self.guild_commands.clear()
1009
+ self.guild_autocompletes.clear()
1010
+ self.components.clear()
1011
+ self.component_min_tiers.clear()
1012
+ self.autocompletes.clear()
1013
+ self.ownership.unregister(self)
1014
+
807
1015
 
808
1016
  class FakeGuildSettings:
809
1017
  def __init__(
@@ -1038,6 +1246,7 @@ class RecordedTool:
1038
1246
  owner_only: bool
1039
1247
  guild_only: bool
1040
1248
  guild_ids: frozenset[int] | None
1249
+ untrusted: bool
1041
1250
 
1042
1251
 
1043
1252
  class RecordingToolRegistry:
@@ -1058,6 +1267,7 @@ class RecordingToolRegistry:
1058
1267
  owner_only: bool = False,
1059
1268
  guild_only: bool = True,
1060
1269
  guild_ids: frozenset[int] | None = None,
1270
+ untrusted: bool = True,
1061
1271
  ) -> None:
1062
1272
  self.tools[name] = RecordedTool(
1063
1273
  description,
@@ -1068,6 +1278,7 @@ class RecordingToolRegistry:
1068
1278
  owner_only,
1069
1279
  guild_only,
1070
1280
  guild_ids,
1281
+ untrusted,
1071
1282
  )
1072
1283
 
1073
1284
 
@@ -1110,6 +1321,7 @@ __all__ = [
1110
1321
  "FakeHealth",
1111
1322
  "FakeHttp",
1112
1323
  "FakeInteraction",
1324
+ "FakeInteractionOwnership",
1113
1325
  "FakeInteractions",
1114
1326
  "FakeProposals",
1115
1327
  "FakeResponse",
@@ -43,6 +43,9 @@ class ModuleToolRegistry(Protocol):
43
43
  ``guild_only`` (the default) it is also hidden from DMs and personal chat,
44
44
  so its handler always sees a guild. ``guild_ids`` further scopes a tool to
45
45
  specific guilds (``None`` is everywhere; an empty set is nowhere).
46
+ Results default to untrusted because module tools commonly return Discord,
47
+ network, or user-authored data. Set ``untrusted=False`` only for output that
48
+ is wholly controlled by the installed module.
46
49
  """
47
50
 
48
51
  def register(
@@ -57,6 +60,7 @@ class ModuleToolRegistry(Protocol):
57
60
  owner_only: bool = False,
58
61
  guild_only: bool = True,
59
62
  guild_ids: frozenset[int] | None = None,
63
+ untrusted: bool = True,
60
64
  ) -> None: ...
61
65
 
62
66
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kimi-agent-module-api
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Stable contracts for community-built assistant modules
5
5
  Author: Webhead
6
6
  License-Expression: MIT
@@ -59,3 +59,15 @@ Version 1.2 adds typed modal forms and a narrow Components V2 layout model. Once
59
59
  that layout model, Discord requires every later edit of the same message to remain a layout.
60
60
  Modules using them should depend on `kimi-agent-module-api>=1.2,<2` and require
61
61
  `discord.modals.v1` and/or `discord.components_v2.v1`.
62
+
63
+ Version 1.3 adds cached author classification to message-deletion events:
64
+ `MessageDeleteEvent.author_is_bot` and `MessageBulkDeleteEvent.bot_message_ids`.
65
+ The values remain unknown for messages that were absent from Discord's cache.
66
+
67
+ ## Testing the SDK
68
+
69
+ From this package directory, run its tests without installing the Kimi application:
70
+
71
+ ```console
72
+ uv run --isolated --group test python -m pytest -q
73
+ ```
@@ -14,4 +14,7 @@ src/kimi_agent_module_api.egg-info/PKG-INFO
14
14
  src/kimi_agent_module_api.egg-info/SOURCES.txt
15
15
  src/kimi_agent_module_api.egg-info/dependency_links.txt
16
16
  src/kimi_agent_module_api.egg-info/requires.txt
17
- src/kimi_agent_module_api.egg-info/top_level.txt
17
+ src/kimi_agent_module_api.egg-info/top_level.txt
18
+ tests/test_contracts.py
19
+ tests/test_public_api.py
20
+ tests/test_testing.py
@@ -0,0 +1,187 @@
1
+ """Representative contract tests owned by the standalone SDK package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ from typing import Any
7
+
8
+ import pytest
9
+ from pydantic_settings import BaseSettings
10
+
11
+ from kimi_agent_module_api import (
12
+ BASELINE_CAPABILITIES,
13
+ MODULE_API_VERSION,
14
+ ModuleCapabilities,
15
+ ModuleLoadContext,
16
+ ModulePermissions,
17
+ ModuleRuntimeContext,
18
+ ModuleSpec,
19
+ ModuleToolContext,
20
+ TrustTier,
21
+ render_guild_settings,
22
+ )
23
+ from kimi_agent_module_api.contracts import (
24
+ Backoff,
25
+ ButtonSpec,
26
+ CommandSpec,
27
+ EventTopicError,
28
+ GuildSettingField,
29
+ GuildSettingsSchema,
30
+ ModuleContractError,
31
+ ServiceDeclaration,
32
+ ServiceRequirement,
33
+ build_custom_id,
34
+ parse_custom_id,
35
+ split_topic,
36
+ table_prefix,
37
+ validate_command_spec,
38
+ validate_component_spec,
39
+ validate_guild_settings_schema,
40
+ validate_module_name,
41
+ validate_permissions,
42
+ validate_publish_topic,
43
+ validate_services,
44
+ validate_subscription,
45
+ )
46
+ from kimi_agent_module_api.events import CORE_TOPICS
47
+ from kimi_agent_module_api.images import looks_like_image_attachment, sniff_image_media_type
48
+ from kimi_agent_module_api.testing import load_context
49
+
50
+
51
+ class DemoSettings(BaseSettings):
52
+ greeting: str = "hello"
53
+
54
+
55
+ class OtherSettings(BaseSettings):
56
+ pass
57
+
58
+
59
+ def _spec(name: str = "demo") -> ModuleSpec:
60
+ def create(_ctx: ModuleLoadContext) -> object:
61
+ raise AssertionError("not called")
62
+
63
+ return ModuleSpec(name=name, version="1.0.0", create=create) # type: ignore[arg-type]
64
+
65
+
66
+ def test_spec_and_runtime_context_keep_stable_defaults() -> None:
67
+ spec = _spec()
68
+ assert spec.api_version == MODULE_API_VERSION == 1
69
+ assert spec.permissions == ModulePermissions()
70
+ assert spec.dependencies == ()
71
+ required = {
72
+ field.name
73
+ for field in dataclasses.fields(ModuleRuntimeContext)
74
+ if field.default is dataclasses.MISSING
75
+ }
76
+ assert {"events", "scheduler", "storage", "discord", "interactions", "services"} <= required
77
+
78
+
79
+ def test_load_context_exercises_public_create_helpers() -> None:
80
+ settings = DemoSettings(greeting="hi")
81
+ context, recorder = load_context(settings)
82
+
83
+ async def handler(_arguments: dict[str, Any], _ctx: ModuleToolContext) -> str:
84
+ return "ok"
85
+
86
+ assert context.settings_for(DemoSettings) is settings
87
+ context.registry.register("demo", "Demo", {"type": "object"}, handler)
88
+ context.register_tool_labels({"demo": "Doing a demo"})
89
+ context.declare_surface_tools("default", ("demo",))
90
+
91
+ assert recorder.labels == {"demo": "Doing a demo"}
92
+ assert recorder.surfaces == {"default": ("demo",)}
93
+ assert recorder.registry.tools["demo"].untrusted is True
94
+ assert context.capabilities.available == BASELINE_CAPABILITIES
95
+ with pytest.raises(TypeError, match="prepared module settings"):
96
+ context.settings_for(OtherSettings)
97
+
98
+
99
+ def test_capabilities_and_trust_tiers_fail_closed() -> None:
100
+ capabilities = ModuleCapabilities(BASELINE_CAPABILITIES, False, False)
101
+ capabilities.require("discord.history.v1")
102
+ with pytest.raises(RuntimeError, match="does not provide"):
103
+ capabilities.require("discord.guild_commands.v1")
104
+ assert TrustTier.STAFF > TrustTier.REGULAR > TrustTier.MEMBER
105
+
106
+
107
+ @pytest.mark.parametrize("name", ["Bad", "two words", "discord", "proposals"])
108
+ def test_module_names_reject_reserved_or_malformed_values(name: str) -> None:
109
+ with pytest.raises(ModuleContractError):
110
+ validate_module_name(name)
111
+
112
+
113
+ def test_topics_and_component_ids_enforce_module_namespaces() -> None:
114
+ assert table_prefix("audit-log") == "audit_log"
115
+ assert split_topic("discord.message") == ("discord", "message")
116
+ validate_publish_topic("case-manager", "case_manager.changed")
117
+ validate_subscription(
118
+ "case_audit",
119
+ ModulePermissions(event_topics=("case_manager.*",)),
120
+ "case_manager.changed",
121
+ )
122
+ custom_id = build_custom_id("case_manager", "approve", "123")
123
+ assert parse_custom_id(custom_id) == ("case_manager", "approve", ("123",))
124
+ with pytest.raises(EventTopicError):
125
+ validate_publish_topic("case-manager", "other.changed")
126
+ with pytest.raises(ModuleContractError):
127
+ build_custom_id("case_manager", "approve", "bad:part")
128
+
129
+
130
+ def test_permissions_services_and_guild_settings_validate_together() -> None:
131
+ permissions = ModulePermissions(
132
+ discord_actions=frozenset({"send_message"}),
133
+ event_topics=("discord.message",),
134
+ )
135
+ validate_permissions("demo", permissions)
136
+ validate_services(
137
+ "consumer",
138
+ ("provider",),
139
+ (),
140
+ (ServiceRequirement("records.cases", 1, provider="provider"),),
141
+ )
142
+ validate_services("provider", (), (ServiceDeclaration("records.cases", 1),), ())
143
+ schema = GuildSettingsSchema(
144
+ fields=(GuildSettingField("mode", "enum", choices=("safe", "fast"), default="safe"),)
145
+ )
146
+ validate_guild_settings_schema("demo", schema)
147
+
148
+ with pytest.raises(ModuleContractError):
149
+ validate_permissions("demo", ModulePermissions(discord_actions=frozenset({"nuke"})))
150
+ with pytest.raises(ModuleContractError):
151
+ validate_guild_settings_schema(
152
+ "demo",
153
+ GuildSettingsSchema(fields=(GuildSettingField("Bad", "int"),)),
154
+ )
155
+
156
+
157
+ def test_command_and_component_validation_match_discord_limits() -> None:
158
+ validate_command_spec(CommandSpec(name="ping", description="Ping the module"))
159
+ validate_component_spec(ButtonSpec("confirm", "Confirm"))
160
+ with pytest.raises(ModuleContractError):
161
+ validate_command_spec(CommandSpec(name="Bad", description="invalid name"))
162
+ with pytest.raises(ModuleContractError):
163
+ validate_component_spec(ButtonSpec("confirm", ""))
164
+
165
+
166
+ def test_scheduler_backoff_rejects_non_finite_or_non_positive_values() -> None:
167
+ assert Backoff(base_seconds=2, max_seconds=8, multiplier=2) == Backoff(2, 8, 2)
168
+ for invalid in (0, -1, float("nan"), float("inf")):
169
+ with pytest.raises(ModuleContractError, match="backoff"):
170
+ Backoff(base_seconds=invalid)
171
+
172
+
173
+ def test_rendered_settings_are_deterministic_and_safe() -> None:
174
+ assert render_guild_settings({"b": True, "a": [1, 2], "c": "x: y", "empty": None}) == (
175
+ '---\na: [1, 2]\nb: true\nc: "x: y"\n---\n'
176
+ )
177
+ with pytest.raises(ValueError, match="invalid guild setting name"):
178
+ render_guild_settings({"bad:key": True})
179
+
180
+
181
+ def test_event_and_image_helpers_remain_host_independent() -> None:
182
+ assert CORE_TOPICS
183
+ assert all(split_topic(topic)[0] == "discord" for topic in CORE_TOPICS)
184
+ assert looks_like_image_attachment("photo.PNG", None)
185
+ assert looks_like_image_attachment(None, "image/jpeg")
186
+ assert sniff_image_media_type(b"\x89PNG\r\n\x1a\nrest") == "image/png"
187
+ assert sniff_image_media_type(b"not an image") is None
@@ -0,0 +1,43 @@
1
+ """The published SDK stays importable without the Kimi application runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import importlib.util
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import kimi_agent_module_api as api
11
+
12
+ SDK_ROOT = Path(__file__).resolve().parents[1] / "src" / "kimi_agent_module_api"
13
+
14
+
15
+ def test_public_exports_resolve() -> None:
16
+ assert api.__all__
17
+ assert all(getattr(api, name, None) is not None for name in api.__all__)
18
+
19
+
20
+ def test_standalone_environment_has_no_core_runtime() -> None:
21
+ """CI runs this package from an isolated environment, outside core's import root."""
22
+ assert importlib.util.find_spec("app") is None
23
+ assert importlib.util.find_spec("discord") is None
24
+
25
+
26
+ def test_sdk_imports_only_declared_dependencies() -> None:
27
+ allowed = set(sys.stdlib_module_names) | {
28
+ "aiosqlite",
29
+ "kimi_agent_module_api",
30
+ "pydantic_settings",
31
+ }
32
+ unexpected: dict[str, set[str]] = {}
33
+ for path in SDK_ROOT.glob("*.py"):
34
+ tree = ast.parse(path.read_text(encoding="utf-8"))
35
+ imported: set[str] = set()
36
+ for node in ast.walk(tree):
37
+ if isinstance(node, ast.Import):
38
+ imported.update(alias.name.split(".")[0] for alias in node.names)
39
+ elif isinstance(node, ast.ImportFrom) and node.module:
40
+ imported.add(node.module.split(".")[0])
41
+ if outside := imported - allowed:
42
+ unexpected[path.name] = outside
43
+ assert unexpected == {}
@@ -0,0 +1,114 @@
1
+ """The SDK's protocol fakes work without importing host implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from kimi_agent_module_api.contracts import (
8
+ Backoff,
9
+ Event,
10
+ JobRun,
11
+ MessageRef,
12
+ MigrationContext,
13
+ ServiceUnavailable,
14
+ UndeclaredDiscordAction,
15
+ )
16
+ from kimi_agent_module_api.testing import (
17
+ FakeDiscordActions,
18
+ FakeEvents,
19
+ FakeInteraction,
20
+ FakeScheduler,
21
+ FakeServiceRegistry,
22
+ MemoryStorage,
23
+ )
24
+
25
+
26
+ @pytest.mark.asyncio
27
+ async def test_fake_events_deliver_only_matching_subscriptions() -> None:
28
+ events = FakeEvents("demo")
29
+ seen: list[str] = []
30
+
31
+ async def handler(event: Event) -> None:
32
+ seen.append(event.topic)
33
+
34
+ registration = events.subscribe("discord.*", handler)
35
+ assert await events.deliver("discord.message", {"id": 1}) == 1
36
+ registration.close()
37
+ assert await events.deliver("discord.message_edit", {"id": 1}) == 0
38
+ assert seen == ["discord.message"]
39
+
40
+
41
+ @pytest.mark.asyncio
42
+ async def test_fake_scheduler_retries_then_settles_jobs() -> None:
43
+ scheduler = FakeScheduler()
44
+ attempts: list[int] = []
45
+
46
+ async def flaky(run: JobRun) -> None:
47
+ attempts.append(run.attempt)
48
+ if run.attempt == 1:
49
+ raise RuntimeError("retry")
50
+
51
+ scheduler.register("work", flaky)
52
+ await scheduler.run_every(
53
+ "job",
54
+ 60,
55
+ "work",
56
+ backoff=Backoff(base_seconds=5, max_seconds=10, multiplier=2),
57
+ )
58
+
59
+ assert await scheduler.run_due(0) == 1
60
+ assert scheduler.jobs["job"].run_at == 5
61
+ assert await scheduler.run_due(5) == 1
62
+ assert scheduler.jobs["job"].run_at == 65
63
+ assert attempts == [1, 2]
64
+
65
+
66
+ @pytest.mark.asyncio
67
+ async def test_fake_discord_actions_enforce_declared_permissions() -> None:
68
+ actions = FakeDiscordActions("demo", frozenset({"send_message"}))
69
+ ref = await actions.send_message(42, "hello")
70
+
71
+ assert isinstance(ref, MessageRef)
72
+ assert actions.calls_for("send_message")[0].args[:2] == (42, "hello")
73
+ with pytest.raises(UndeclaredDiscordAction):
74
+ await actions.fetch_message(ref)
75
+
76
+
77
+ @pytest.mark.asyncio
78
+ async def test_fake_interaction_records_responses() -> None:
79
+ interaction = FakeInteraction(module_name="demo")
80
+ await interaction.respond("done", ephemeral=True)
81
+ await interaction.follow_up("more")
82
+
83
+ assert interaction.responses[0].content == "done"
84
+ assert interaction.responses[0].ephemeral is True
85
+ assert interaction.last.content == "more"
86
+
87
+
88
+ def test_fake_service_proxy_closes_with_its_registration() -> None:
89
+ class Board:
90
+ def answer(self) -> int:
91
+ return 42
92
+
93
+ registry = FakeServiceRegistry()
94
+ registration = registry.provide("records.board", 1, Board())
95
+ proxy = registry.get("records.board", 1, Board)
96
+
97
+ assert proxy.answer() == 42
98
+ registration.close()
99
+ with pytest.raises(ServiceUnavailable):
100
+ proxy.answer()
101
+
102
+
103
+ @pytest.mark.asyncio
104
+ async def test_memory_storage_runs_scoped_migrations_and_transactions() -> None:
105
+ async def create(ctx: MigrationContext) -> None:
106
+ await ctx.connection.execute(f"CREATE TABLE {ctx.table('rows')} (value INTEGER)")
107
+
108
+ async with MemoryStorage.open("my-module") as storage:
109
+ assert storage.table("rows") == '"my_module_rows"'
110
+ await storage.migrate((("001", create),))
111
+ async with storage.write_transaction() as connection:
112
+ await connection.execute('INSERT INTO "my_module_rows" (value) VALUES (7)')
113
+ cursor = await storage.connection.execute('SELECT value FROM "my_module_rows"')
114
+ assert await cursor.fetchone() == (7,)