@app-vitals/agent-runtime 0.0.1 → 0.1.0

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 (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +38 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. package/README.md +0 -3
@@ -0,0 +1,118 @@
1
+ /**
2
+ * agent/src/runtime/slack-thread-status-tracker.unit.test.ts
3
+ *
4
+ * Unit tests for ThreadStatusTracker — the per-thread reference counter used
5
+ * around SlackProgress.start()/finish() calls so a burst of overlapping
6
+ * messages on the same Slack thread reports the 0->1 transition (first
7
+ * enter) and the 1->0 transition (last exit) accurately, instead of
8
+ * flapping as soon as the first of several queued runs finishes.
9
+ *
10
+ * Pure logic, no I/O — the tracker itself never touches a Slack client.
11
+ */
12
+
13
+ import { describe, expect, test } from "bun:test";
14
+ import { ThreadStatusTracker } from "./slack-thread-status-tracker.ts";
15
+
16
+ describe("ThreadStatusTracker — enter()", () => {
17
+ test("returns true on the first enter for a key (0->1)", () => {
18
+ const tracker = new ThreadStatusTracker();
19
+ expect(tracker.enter("thread-1")).toBe(true);
20
+ });
21
+
22
+ test("returns false on a second overlapping enter for the same key (1->2)", () => {
23
+ const tracker = new ThreadStatusTracker();
24
+ tracker.enter("thread-1");
25
+ expect(tracker.enter("thread-1")).toBe(false);
26
+ });
27
+
28
+ test("returns false on a third overlapping enter for the same key (2->3)", () => {
29
+ const tracker = new ThreadStatusTracker();
30
+ tracker.enter("thread-1");
31
+ tracker.enter("thread-1");
32
+ expect(tracker.enter("thread-1")).toBe(false);
33
+ });
34
+
35
+ test("different keys are independent — each gets its own 0->1 transition", () => {
36
+ const tracker = new ThreadStatusTracker();
37
+ expect(tracker.enter("thread-1")).toBe(true);
38
+ expect(tracker.enter("thread-2")).toBe(true);
39
+ });
40
+
41
+ test("re-entering after a full drain (exit back to 0) returns true again", () => {
42
+ const tracker = new ThreadStatusTracker();
43
+ tracker.enter("thread-1");
44
+ tracker.exit("thread-1");
45
+ expect(tracker.enter("thread-1")).toBe(true);
46
+ });
47
+ });
48
+
49
+ describe("ThreadStatusTracker — exit()", () => {
50
+ test("returns true when the count drops from 1 to 0", () => {
51
+ const tracker = new ThreadStatusTracker();
52
+ tracker.enter("thread-1");
53
+ expect(tracker.exit("thread-1")).toBe(true);
54
+ });
55
+
56
+ test("returns false when the count drops from 2 to 1 (still in flight)", () => {
57
+ const tracker = new ThreadStatusTracker();
58
+ tracker.enter("thread-1");
59
+ tracker.enter("thread-1");
60
+ expect(tracker.exit("thread-1")).toBe(false);
61
+ });
62
+
63
+ test("returns false and does not throw when called on a key already at 0", () => {
64
+ const tracker = new ThreadStatusTracker();
65
+ expect(() => tracker.exit("never-entered")).not.toThrow();
66
+ expect(tracker.exit("never-entered")).toBe(false);
67
+ });
68
+
69
+ test("returns false and does not throw on a double-exit below zero", () => {
70
+ const tracker = new ThreadStatusTracker();
71
+ tracker.enter("thread-1");
72
+ expect(tracker.exit("thread-1")).toBe(true);
73
+ expect(() => tracker.exit("thread-1")).not.toThrow();
74
+ expect(tracker.exit("thread-1")).toBe(false);
75
+ });
76
+
77
+ test("different keys are independent — exiting one does not affect another", () => {
78
+ const tracker = new ThreadStatusTracker();
79
+ tracker.enter("thread-1");
80
+ tracker.enter("thread-2");
81
+ expect(tracker.exit("thread-1")).toBe(true);
82
+ // thread-2's count is still 1 — exiting it should be the 1->0 transition.
83
+ expect(tracker.exit("thread-2")).toBe(true);
84
+ });
85
+ });
86
+
87
+ describe("ThreadStatusTracker — overlapping burst simulation", () => {
88
+ test("a burst of 3 overlapping runs on one thread: only the first enter and the last exit are true", () => {
89
+ const tracker = new ThreadStatusTracker();
90
+ const key = "thread-1";
91
+
92
+ const entries = [
93
+ tracker.enter(key),
94
+ tracker.enter(key),
95
+ tracker.enter(key),
96
+ ];
97
+ expect(entries).toEqual([true, false, false]);
98
+
99
+ // Runs complete in arbitrary order — only the exit that brings the count
100
+ // to 0 (the last one) should report true.
101
+ const exits = [tracker.exit(key), tracker.exit(key), tracker.exit(key)];
102
+ expect(exits).toEqual([false, false, true]);
103
+ });
104
+
105
+ test("map entry is cleaned up after draining to 0 (no unbounded growth)", () => {
106
+ const tracker = new ThreadStatusTracker();
107
+ const key = "thread-1";
108
+ tracker.enter(key);
109
+ tracker.exit(key);
110
+ // Internal cleanup isn't directly observable from the public API, but a
111
+ // fresh enter() after a full drain must behave like a brand-new key
112
+ // (0->1 => true), which would still hold even without cleanup. The real
113
+ // guarantee here is exercised via exit() below: a stray exit on the
114
+ // drained key is a no-op, matching "never entered" behavior exactly.
115
+ expect(tracker.exit(key)).toBe(false);
116
+ expect(tracker.enter(key)).toBe(true);
117
+ });
118
+ });
@@ -0,0 +1,269 @@
1
+ /**
2
+ * agent/src/runtime/slack-trial-expiry.unit.test.ts
3
+ *
4
+ * Unit tests for ATE-3.1's trial-expiry Slack gate: the pure isTrialExpired()
5
+ * predicate, plus handler-level gating for all three inbound Slack event
6
+ * handlers (message, app_mention, reaction_added).
7
+ *
8
+ * Builds its own minimal MockApp harness (mirroring slack.unit.test.ts's own
9
+ * "membership gating" harness) so this file is self-contained and
10
+ * slack.unit.test.ts stays untouched.
11
+ */
12
+
13
+ import { describe, expect, mock, test } from "bun:test";
14
+ import {
15
+ type AgentTrialExpiryRef,
16
+ createAgentTrialExpiryRef,
17
+ } from "./agent-trial-expiry-ref.ts";
18
+ import { createSlackApp as _createSlackApp, isTrialExpired } from "./slack.ts";
19
+
20
+ // ─── isTrialExpired ─────────────────────────────────────────────────────────
21
+
22
+ describe("isTrialExpired", () => {
23
+ const NOW = () => new Date("2026-06-15T00:00:00.000Z");
24
+
25
+ test("fails open (not expired) when the ref has never synced", () => {
26
+ const ref = createAgentTrialExpiryRef();
27
+ expect(isTrialExpired(ref, NOW)).toBe(false);
28
+ });
29
+
30
+ test("fails open (not expired) when synced but trialExpiresAt is null", () => {
31
+ const ref = createAgentTrialExpiryRef();
32
+ ref.set(null);
33
+ expect(isTrialExpired(ref, NOW)).toBe(false);
34
+ });
35
+
36
+ test("not expired when trialExpiresAt is in the future", () => {
37
+ const ref = createAgentTrialExpiryRef();
38
+ ref.set(new Date("2026-07-01T00:00:00.000Z"));
39
+ expect(isTrialExpired(ref, NOW)).toBe(false);
40
+ });
41
+
42
+ test("expired when trialExpiresAt is in the past", () => {
43
+ const ref = createAgentTrialExpiryRef();
44
+ ref.set(new Date("2026-06-01T00:00:00.000Z"));
45
+ expect(isTrialExpired(ref, NOW)).toBe(true);
46
+ });
47
+
48
+ test("trialExpiresAt exactly equal to now is NOT expired (strictly-past only)", () => {
49
+ const ref = createAgentTrialExpiryRef();
50
+ ref.set(NOW());
51
+ expect(isTrialExpired(ref, NOW)).toBe(false);
52
+ });
53
+
54
+ test("defaults `now` to the real current time when omitted", () => {
55
+ const ref = createAgentTrialExpiryRef();
56
+ ref.set(new Date("2099-01-01T00:00:00.000Z"));
57
+ expect(isTrialExpired(ref)).toBe(false);
58
+
59
+ ref.set(new Date("2000-01-01T00:00:00.000Z"));
60
+ expect(isTrialExpired(ref)).toBe(true);
61
+ });
62
+ });
63
+
64
+ // ─── Handler-level trial-expiry gating ─────────────────────────────────────
65
+
66
+ type HandlerFn = (...args: unknown[]) => Promise<void>;
67
+
68
+ let capturedMessageHandler: HandlerFn | null = null;
69
+ let capturedMentionHandler: HandlerFn | null = null;
70
+ let capturedReactionAddedHandler: HandlerFn | null = null;
71
+
72
+ class MockApp {
73
+ constructor(_args: Record<string, unknown>) {
74
+ capturedMessageHandler = null;
75
+ capturedMentionHandler = null;
76
+ capturedReactionAddedHandler = null;
77
+ }
78
+
79
+ message(handler: HandlerFn) {
80
+ capturedMessageHandler = handler;
81
+ }
82
+
83
+ event(type: string, handler: HandlerFn) {
84
+ if (type === "app_mention") capturedMentionHandler = handler;
85
+ if (type === "reaction_added") capturedReactionAddedHandler = handler;
86
+ }
87
+ }
88
+
89
+ const mockGatingSlackConfig = {
90
+ botToken: "xoxb-test-token",
91
+ appToken: "xapp-test-token",
92
+ signingSecret: "test-secret",
93
+ };
94
+
95
+ function makeMockClient() {
96
+ return {
97
+ chat: {
98
+ postMessage: mock(async (_args: unknown) => ({ ts: "resp.ts.1" })),
99
+ startStream: mock(async (_args: unknown) => ({ ts: "stream.ts.1" })),
100
+ appendStream: mock(async (_args: unknown) => {}),
101
+ stopStream: mock(async (_args: unknown) => {}),
102
+ },
103
+ users: {
104
+ info: mock(async (_args: unknown) => ({
105
+ user: {
106
+ profile: { display_name: "Test User", email: "member@example.com" },
107
+ name: "testuser",
108
+ },
109
+ })),
110
+ },
111
+ };
112
+ }
113
+
114
+ function makeSay() {
115
+ return mock(async (_args: unknown) => ({ ts: "reply.ts.1" }));
116
+ }
117
+
118
+ function setupGatingApp(overrides: {
119
+ trialExpiryRef: AgentTrialExpiryRef;
120
+ runner?: (
121
+ message: string,
122
+ sessionKey?: string,
123
+ ) => Promise<{
124
+ result: string;
125
+ sessionId?: string;
126
+ streamIncomplete?: boolean;
127
+ }>;
128
+ getSessionFn?: (key: string) => Promise<string | undefined>;
129
+ }) {
130
+ const runner =
131
+ overrides.runner ??
132
+ mock(async (_msg: string, _key?: string) => ({
133
+ result: "Claude response",
134
+ sessionId: "sess-1",
135
+ }));
136
+
137
+ _createSlackApp(
138
+ runner,
139
+ (text: string) => text,
140
+ (channel: string, ts: string) => `${channel}:${ts}`,
141
+ // biome-ignore lint/suspicious/noExplicitAny: mock factory for tests
142
+ (cfg) => new MockApp(cfg as Record<string, unknown>) as any,
143
+ mockGatingSlackConfig,
144
+ undefined, // sentryClient — default noop
145
+ async () => null, // fileDownloaderFn
146
+ {}, // voiceConfig
147
+ async () => null, // transcribeAudioFn
148
+ async () => null, // synthesizeSpeechFn
149
+ async (userId: string) => userId, // resolveUserFn (display name) — identity
150
+ "UBOT123", // botUserId
151
+ async () => ({ messages: [] }), // conversationsRepliesFn
152
+ overrides.getSessionFn ?? (async () => undefined), // getSessionFn
153
+ undefined, // blocksConverter — default
154
+ undefined, // chatTokenReporter — default noop
155
+ async () => undefined, // resolveUserEmailFn — default (irrelevant here)
156
+ undefined, // membershipRef — default (irrelevant here)
157
+ overrides.trialExpiryRef,
158
+ );
159
+
160
+ return { runner };
161
+ }
162
+
163
+ describe("trial-expiry gating — message handler", () => {
164
+ async function invokeDM(trialExpiryRef: AgentTrialExpiryRef) {
165
+ const { runner } = setupGatingApp({ trialExpiryRef });
166
+ const client = makeMockClient();
167
+ const say = makeSay();
168
+ const message = {
169
+ channel: "D123",
170
+ ts: "111.222",
171
+ text: "Hello bot",
172
+ channel_type: "im",
173
+ user: "U-SENDER",
174
+ };
175
+ await capturedMessageHandler?.({ message, say, client });
176
+ return { runner, say, client };
177
+ }
178
+
179
+ test("unsynced ref: processed as today (runner called, no notice)", async () => {
180
+ const ref = createAgentTrialExpiryRef();
181
+ const { runner, say } = await invokeDM(ref);
182
+ expect(runner).toHaveBeenCalledTimes(1);
183
+ expect(say).not.toHaveBeenCalled();
184
+ });
185
+
186
+ test("trial not yet expired: processed as today (runner called)", async () => {
187
+ const ref = createAgentTrialExpiryRef();
188
+ ref.set(new Date(Date.now() + 60 * 60 * 1000));
189
+ const { runner, say } = await invokeDM(ref);
190
+ expect(runner).toHaveBeenCalledTimes(1);
191
+ expect(say).not.toHaveBeenCalled();
192
+ });
193
+
194
+ test("trial expired: runner not called, a single trial-ended notice is replied", async () => {
195
+ const ref = createAgentTrialExpiryRef();
196
+ ref.set(new Date(Date.now() - 60 * 60 * 1000));
197
+ const { runner, say } = await invokeDM(ref);
198
+ expect(runner).not.toHaveBeenCalled();
199
+ expect(say).toHaveBeenCalledTimes(1);
200
+ const call = say.mock.calls[0]?.[0] as { text: string };
201
+ expect(call.text.toLowerCase()).toContain("trial");
202
+ });
203
+ });
204
+
205
+ describe("trial-expiry gating — app_mention handler", () => {
206
+ async function invokeMention(trialExpiryRef: AgentTrialExpiryRef) {
207
+ const { runner } = setupGatingApp({ trialExpiryRef });
208
+ const client = makeMockClient();
209
+ const say = makeSay();
210
+ const event = {
211
+ text: "<@UBOT> do something",
212
+ channel: "C999",
213
+ ts: "222.333",
214
+ user: "U-SENDER",
215
+ };
216
+ await capturedMentionHandler?.({ event, say, client });
217
+ return { runner, say, client };
218
+ }
219
+
220
+ test("unsynced ref: processed as today (runner called, no notice)", async () => {
221
+ const ref = createAgentTrialExpiryRef();
222
+ const { runner, say } = await invokeMention(ref);
223
+ expect(runner).toHaveBeenCalledTimes(1);
224
+ expect(say).not.toHaveBeenCalled();
225
+ });
226
+
227
+ test("trial expired: runner not called, a single trial-ended notice is replied", async () => {
228
+ const ref = createAgentTrialExpiryRef();
229
+ ref.set(new Date(Date.now() - 60 * 60 * 1000));
230
+ const { runner, say } = await invokeMention(ref);
231
+ expect(runner).not.toHaveBeenCalled();
232
+ expect(say).toHaveBeenCalledTimes(1);
233
+ const call = say.mock.calls[0]?.[0] as { text: string };
234
+ expect(call.text.toLowerCase()).toContain("trial");
235
+ });
236
+ });
237
+
238
+ describe("trial-expiry gating — reaction_added handler", () => {
239
+ async function invokeReaction(trialExpiryRef: AgentTrialExpiryRef) {
240
+ const { runner } = setupGatingApp({ trialExpiryRef });
241
+ const client = makeMockClient();
242
+ const event = {
243
+ reaction: "thumbsup",
244
+ item: { type: "message", channel: "D1", ts: "100.1" },
245
+ item_user: "UBOT123",
246
+ user: "U-SENDER",
247
+ };
248
+ await capturedReactionAddedHandler?.({ event, client });
249
+ return { runner, client };
250
+ }
251
+
252
+ test("unsynced ref: processed as today (runner called, no notice)", async () => {
253
+ const ref = createAgentTrialExpiryRef();
254
+ const { runner, client } = await invokeReaction(ref);
255
+ expect(runner).toHaveBeenCalledTimes(1);
256
+ expect(client.chat.postMessage).not.toHaveBeenCalled();
257
+ });
258
+
259
+ test("trial expired: runner not called, a single trial-ended notice is posted via client.chat.postMessage", async () => {
260
+ const ref = createAgentTrialExpiryRef();
261
+ ref.set(new Date(Date.now() - 60 * 60 * 1000));
262
+ const { runner, client } = await invokeReaction(ref);
263
+ expect(runner).not.toHaveBeenCalled();
264
+ expect(client.chat.postMessage).toHaveBeenCalledTimes(1);
265
+ const call = (client.chat.postMessage as ReturnType<typeof mock>).mock
266
+ .calls[0]?.[0] as { text: string };
267
+ expect(call.text.toLowerCase()).toContain("trial");
268
+ });
269
+ });