bereach-openclaw 1.5.11 → 1.6.1

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 (33) hide show
  1. package/node_modules/@bereach/tools/package.json +8 -3
  2. package/node_modules/@bereach/tools/src/__tests__/cost-estimation.test.ts +198 -0
  3. package/node_modules/@bereach/tools/src/__tests__/enforcement-helpers.test.ts +202 -0
  4. package/node_modules/@bereach/tools/src/__tests__/enforcement-rules.test.ts +179 -0
  5. package/node_modules/@bereach/tools/src/__tests__/llm-errors-and-whitelist.test.ts +205 -0
  6. package/node_modules/@bereach/tools/src/__tests__/parse-utils.test.ts +122 -0
  7. package/node_modules/@bereach/tools/src/__tests__/tool-definitions.test.ts +150 -0
  8. package/node_modules/@bereach/tools/src/__tests__/utils.test.ts +254 -0
  9. package/node_modules/@bereach/tools/src/cache-types.ts +2 -0
  10. package/node_modules/@bereach/tools/src/cost-estimation.ts +1 -1
  11. package/node_modules/@bereach/tools/src/definitions.ts +70 -3
  12. package/node_modules/@bereach/tools/src/enforcement-types.ts +9 -4
  13. package/node_modules/@bereach/tools/src/llm-errors.ts +1 -1
  14. package/node_modules/@bereach/tools/src/task-tool-whitelist.ts +2 -3
  15. package/openclaw.plugin.json +1 -12
  16. package/package.json +1 -1
  17. package/skills/bereach/SKILL.md +28 -45
  18. package/skills/bereach/workspace/soul-template.md +11 -3
  19. package/src/auto-detect-models.ts +80 -0
  20. package/src/commands/setup.ts +3 -32
  21. package/src/env.ts +2 -24
  22. package/src/hooks/cache.ts +4 -2
  23. package/src/hooks/detect-task-mode.ts +3 -4
  24. package/src/hooks/enforcement.ts +16 -0
  25. package/src/hooks/lifecycle.ts +4 -239
  26. package/src/index.ts +4 -122
  27. package/src/soul-template-content.ts +2 -2
  28. package/src/commands/connector/api.ts +0 -113
  29. package/src/commands/connector/execution.ts +0 -155
  30. package/src/commands/connector/index.ts +0 -388
  31. package/src/commands/connector/types.ts +0 -79
  32. package/src/connector/manager.ts +0 -351
  33. package/src/connector-cli.ts +0 -72
@@ -16,11 +16,16 @@
16
16
  "./cost-estimation": "./src/cost-estimation.ts",
17
17
  "./llm-errors": "./src/llm-errors.ts"
18
18
  },
19
- "files": ["src/"],
19
+ "files": [
20
+ "src/"
21
+ ],
20
22
  "scripts": {
21
- "typecheck": "tsc --noEmit"
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "vitest run",
25
+ "test:watch": "vitest"
22
26
  },
23
27
  "devDependencies": {
24
- "typescript": "^6.0.2"
28
+ "typescript": "^6.0.2",
29
+ "vitest": "^4.1.4"
25
30
  }
26
31
  }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Tests for LLM cost estimation functions.
3
+ * Pure function tests - no mocking.
4
+ */
5
+
6
+ import { describe, it, expect } from "vitest";
7
+ import {
8
+ estimateTaskCost,
9
+ extractTokenUsage,
10
+ MODEL_PRICING,
11
+ } from "../cost-estimation.js";
12
+
13
+ // ─── MODEL_PRICING ─────────────────────────────────────────────────
14
+
15
+ describe("MODEL_PRICING", () => {
16
+ it("has haiku, sonnet, flash, pro models", () => {
17
+ expect(MODEL_PRICING).toHaveProperty("haiku");
18
+ expect(MODEL_PRICING).toHaveProperty("sonnet");
19
+ expect(MODEL_PRICING).toHaveProperty("flash");
20
+ expect(MODEL_PRICING).toHaveProperty("pro");
21
+ });
22
+
23
+ it("haiku is cheaper than sonnet", () => {
24
+ expect(MODEL_PRICING.haiku.input).toBeLessThan(MODEL_PRICING.sonnet.input);
25
+ expect(MODEL_PRICING.haiku.output).toBeLessThan(MODEL_PRICING.sonnet.output);
26
+ });
27
+
28
+ it("all models have cacheRead < input pricing", () => {
29
+ for (const [, pricing] of Object.entries(MODEL_PRICING)) {
30
+ expect(pricing.cacheRead).toBeLessThan(pricing.input);
31
+ }
32
+ });
33
+ });
34
+
35
+ // ─── estimateTaskCost ──────────────────────────────────────────────
36
+
37
+ describe("estimateTaskCost", () => {
38
+ it("returns 0 for zero tokens", () => {
39
+ expect(estimateTaskCost(0, 0, 0)).toBe(0);
40
+ });
41
+
42
+ it("defaults to haiku pricing when no model provided", () => {
43
+ const cost = estimateTaskCost(1000, 500, 0);
44
+ // haiku: 1000 * 1.0/1M + 500 * 5.0/1M = 0.001 + 0.0025 = 0.0035
45
+ expect(cost).toBeCloseTo(0.0035, 6);
46
+ });
47
+
48
+ it("matches haiku for claude-3-5-haiku model slug", () => {
49
+ const cost = estimateTaskCost(1000, 500, 0, "claude-3-5-haiku-20241022");
50
+ expect(cost).toBeCloseTo(0.0035, 6);
51
+ });
52
+
53
+ it("matches sonnet for claude-sonnet-4-20250514 model slug", () => {
54
+ const cost = estimateTaskCost(1000, 500, 0, "claude-sonnet-4-20250514");
55
+ // sonnet: 1000 * 3.0/1M + 500 * 15.0/1M = 0.003 + 0.0075 = 0.0105
56
+ expect(cost).toBeCloseTo(0.0105, 6);
57
+ });
58
+
59
+ it("subtracts cached tokens from input cost", () => {
60
+ // 1000 input, 500 output, 800 cached
61
+ // uncached = max(0, 1000-800) = 200
62
+ // haiku: 200 * 1.0/1M + 800 * 0.1/1M + 500 * 5.0/1M = 0.0002 + 0.00008 + 0.0025 = 0.00278
63
+ const cost = estimateTaskCost(1000, 500, 800);
64
+ expect(cost).toBeCloseTo(0.00278, 6);
65
+ });
66
+
67
+ it("handles cacheRead > inputTokens (uncached = 0)", () => {
68
+ // edge case: more cached reads than input (prompt caching overlap)
69
+ const cost = estimateTaskCost(500, 100, 800);
70
+ // uncached = max(0, 500-800) = 0
71
+ // haiku: 0 + 800 * 0.1/1M + 100 * 5.0/1M = 0 + 0.00008 + 0.0005 = 0.00058
72
+ expect(cost).toBeCloseTo(0.00058, 6);
73
+ });
74
+
75
+ it("includes cache write cost", () => {
76
+ // haiku cacheWrite = 1.25/1M
77
+ const costWithout = estimateTaskCost(1000, 500, 0, "haiku", 0);
78
+ const costWith = estimateTaskCost(1000, 500, 0, "haiku", 10000);
79
+ expect(costWith).toBeGreaterThan(costWithout);
80
+ expect(costWith - costWithout).toBeCloseTo(10000 * 1.25 / 1_000_000, 8);
81
+ });
82
+
83
+ it("defaults to haiku for null model slug", () => {
84
+ expect(estimateTaskCost(1000, 500, 0, null)).toBeCloseTo(0.0035, 6);
85
+ });
86
+
87
+ it("defaults to haiku for undefined model slug", () => {
88
+ expect(estimateTaskCost(1000, 500, 0, undefined)).toBeCloseTo(0.0035, 6);
89
+ });
90
+
91
+ it("defaults to haiku for unknown model slug", () => {
92
+ expect(estimateTaskCost(1000, 500, 0, "gpt-4o-mini")).toBeCloseTo(0.0035, 6);
93
+ });
94
+
95
+ it("matches flash for gemini-flash slug", () => {
96
+ const cost = estimateTaskCost(1000, 500, 0, "gemini-2.0-flash");
97
+ // flash: 1000 * 0.3/1M + 500 * 2.5/1M = 0.0003 + 0.00125 = 0.00155
98
+ expect(cost).toBeCloseTo(0.00155, 6);
99
+ });
100
+ });
101
+
102
+ // ─── extractTokenUsage ─────────────────────────────────────────────
103
+
104
+ describe("extractTokenUsage", () => {
105
+ it("returns null when input and output are both 0", () => {
106
+ expect(extractTokenUsage({})).toBeNull();
107
+ expect(extractTokenUsage({ agentMeta: {} })).toBeNull();
108
+ });
109
+
110
+ it("extracts from agentMeta.lastCallUsage (camelCase)", () => {
111
+ const result = extractTokenUsage({
112
+ agentMeta: {
113
+ lastCallUsage: { input: 1000, output: 500 },
114
+ model: "claude-3-5-haiku",
115
+ },
116
+ });
117
+ expect(result).toEqual({
118
+ usage: { inputTokens: 1000, outputTokens: 500 },
119
+ model: "claude-3-5-haiku",
120
+ });
121
+ });
122
+
123
+ it("extracts from agentMeta.lastCallUsage (snake_case)", () => {
124
+ const result = extractTokenUsage({
125
+ agentMeta: {
126
+ lastCallUsage: { input_tokens: 2000, output_tokens: 800 },
127
+ },
128
+ });
129
+ expect(result?.usage.inputTokens).toBe(2000);
130
+ expect(result?.usage.outputTokens).toBe(800);
131
+ });
132
+
133
+ it("extracts from meta.cost path", () => {
134
+ const result = extractTokenUsage({
135
+ cost: { inputTokens: 500, outputTokens: 200 },
136
+ });
137
+ expect(result?.usage.inputTokens).toBe(500);
138
+ expect(result?.usage.outputTokens).toBe(200);
139
+ });
140
+
141
+ it("extracts from meta.usage path", () => {
142
+ const result = extractTokenUsage({
143
+ usage: { input: 300, output: 150 },
144
+ });
145
+ expect(result?.usage.inputTokens).toBe(300);
146
+ expect(result?.usage.outputTokens).toBe(150);
147
+ });
148
+
149
+ it("includes cacheRead when present", () => {
150
+ const result = extractTokenUsage({
151
+ agentMeta: {
152
+ lastCallUsage: { input: 1000, output: 500, cacheRead: 800 },
153
+ },
154
+ });
155
+ expect(result?.usage.cacheReadTokens).toBe(800);
156
+ });
157
+
158
+ it("omits cacheRead when 0", () => {
159
+ const result = extractTokenUsage({
160
+ agentMeta: {
161
+ lastCallUsage: { input: 1000, output: 500, cacheRead: 0 },
162
+ },
163
+ });
164
+ expect(result?.usage.cacheReadTokens).toBeUndefined();
165
+ });
166
+
167
+ it("includes cacheWrite when present", () => {
168
+ const result = extractTokenUsage({
169
+ agentMeta: {
170
+ lastCallUsage: { input: 1000, output: 500, cacheWrite: 200 },
171
+ },
172
+ });
173
+ expect(result?.usage.cacheWriteTokens).toBe(200);
174
+ });
175
+
176
+ it("resolves model from agentMeta first, then meta", () => {
177
+ const result = extractTokenUsage({
178
+ agentMeta: { lastCallUsage: { input: 1, output: 1 }, model: "from-agent" },
179
+ model: "from-meta",
180
+ });
181
+ expect(result?.model).toBe("from-agent");
182
+ });
183
+
184
+ it("falls back to meta.model when agentMeta.model missing", () => {
185
+ const result = extractTokenUsage({
186
+ agentMeta: { lastCallUsage: { input: 1, output: 1 } },
187
+ model: "from-meta",
188
+ });
189
+ expect(result?.model).toBe("from-meta");
190
+ });
191
+
192
+ it("handles snake_case cache fields", () => {
193
+ const result = extractTokenUsage({
194
+ usage: { input: 1000, output: 500, cache_read_input_tokens: 300 },
195
+ });
196
+ expect(result?.usage.cacheReadTokens).toBe(300);
197
+ });
198
+ });
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Tests for enforcement helper functions and constants.
3
+ * Pure function tests - no mocking.
4
+ */
5
+
6
+ import { describe, it, expect } from "vitest";
7
+ import {
8
+ ACTION_MAP,
9
+ trackBlock,
10
+ clearBlock,
11
+ blockMsg,
12
+ } from "../enforcement-helpers.js";
13
+ import {
14
+ createSessionState,
15
+ READ_TOOLS,
16
+ WRITE_TOOLS,
17
+ FREE_TOOLS,
18
+ PACED_FREE_TOOLS,
19
+ } from "../enforcement-types.js";
20
+
21
+ // ─── ACTION_MAP ────────────────────────────────────────────────────
22
+
23
+ describe("ACTION_MAP", () => {
24
+ it("maps visit_profile to profile_visit", () => {
25
+ expect(ACTION_MAP.bereach_visit_profile).toBe("profile_visit");
26
+ });
27
+
28
+ it("maps bulk_visit_profiles to profile_visit", () => {
29
+ expect(ACTION_MAP.bereach_bulk_visit_profiles).toBe("profile_visit");
30
+ });
31
+
32
+ it("maps connect_profile to connection_request", () => {
33
+ expect(ACTION_MAP.bereach_connect_profile).toBe("connection_request");
34
+ });
35
+
36
+ it("maps send_message to message", () => {
37
+ expect(ACTION_MAP.bereach_send_message).toBe("message");
38
+ });
39
+
40
+ it("maps accept_invitation to accept_invitation", () => {
41
+ expect(ACTION_MAP.bereach_accept_invitation).toBe("accept_invitation");
42
+ });
43
+
44
+ it("maps all scraping tools to scraping", () => {
45
+ const scrapingTools = [
46
+ "bereach_unified_search",
47
+ "bereach_search_sales_nav",
48
+ "bereach_collect_likes",
49
+ "bereach_collect_comments",
50
+ "bereach_collect_posts",
51
+ ];
52
+ for (const tool of scrapingTools) {
53
+ expect(ACTION_MAP[tool]).toBe("scraping");
54
+ }
55
+ });
56
+
57
+ it("returns undefined for free/contact tools", () => {
58
+ expect(ACTION_MAP.bereach_contacts_search).toBeUndefined();
59
+ expect(ACTION_MAP.bereach_context_set).toBeUndefined();
60
+ expect(ACTION_MAP.bereach_list_tasks).toBeUndefined();
61
+ });
62
+ });
63
+
64
+ // ─── trackBlock / clearBlock ───────────────────────────────────────
65
+
66
+ describe("trackBlock", () => {
67
+ it("returns 1 on first block", () => {
68
+ const state = createSessionState();
69
+ expect(trackBlock(state, "bereach_send_message", "credits")).toBe(1);
70
+ });
71
+
72
+ it("increments on repeated blocks", () => {
73
+ const state = createSessionState();
74
+ trackBlock(state, "bereach_send_message", "credits");
75
+ trackBlock(state, "bereach_send_message", "credits");
76
+ expect(trackBlock(state, "bereach_send_message", "credits")).toBe(3);
77
+ });
78
+
79
+ it("tracks different tools independently", () => {
80
+ const state = createSessionState();
81
+ trackBlock(state, "bereach_send_message", "credits");
82
+ trackBlock(state, "bereach_send_message", "credits");
83
+ expect(trackBlock(state, "bereach_visit_profile", "credits")).toBe(1);
84
+ });
85
+
86
+ it("tracks different reasons independently", () => {
87
+ const state = createSessionState();
88
+ trackBlock(state, "bereach_send_message", "credits");
89
+ expect(trackBlock(state, "bereach_send_message", "rate_limit")).toBe(1);
90
+ });
91
+ });
92
+
93
+ describe("clearBlock", () => {
94
+ it("clears all block reasons for a tool", () => {
95
+ const state = createSessionState();
96
+ trackBlock(state, "bereach_send_message", "credits");
97
+ trackBlock(state, "bereach_send_message", "rate_limit");
98
+ clearBlock(state, "bereach_send_message");
99
+ // After clearing, next block should return 1 again
100
+ expect(trackBlock(state, "bereach_send_message", "credits")).toBe(1);
101
+ });
102
+
103
+ it("does not affect other tools", () => {
104
+ const state = createSessionState();
105
+ trackBlock(state, "bereach_send_message", "credits");
106
+ trackBlock(state, "bereach_visit_profile", "credits");
107
+ clearBlock(state, "bereach_send_message");
108
+ expect(trackBlock(state, "bereach_visit_profile", "credits")).toBe(2);
109
+ });
110
+ });
111
+
112
+ // ─── blockMsg ──────────────────────────────────────────────────────
113
+
114
+ describe("blockMsg", () => {
115
+ it("returns BLOCKED message for count < 3", () => {
116
+ const msg = blockMsg(1, "bereach_send_message", "Credit limit", "Upgrade needed");
117
+ expect(msg).toMatch(/^BLOCKED:/);
118
+ expect(msg).toContain("Credit limit");
119
+ });
120
+
121
+ it("returns STOP RETRYING message for count >= 3", () => {
122
+ const msg = blockMsg(3, "bereach_send_message", "Credit limit", "Stop all work");
123
+ expect(msg).toMatch(/^STOP RETRYING:/);
124
+ expect(msg).toContain("Stop all work");
125
+ });
126
+
127
+ it("escalates at exactly 3", () => {
128
+ expect(blockMsg(2, "tool", "reason", "d1")).toMatch(/^BLOCKED:/);
129
+ expect(blockMsg(3, "tool", "reason", "d2")).toMatch(/^STOP RETRYING:/);
130
+ });
131
+ });
132
+
133
+ // ─── Tool categories: no overlap ───────────────────────────────────
134
+
135
+ describe("tool category sets", () => {
136
+ it("READ_TOOLS and WRITE_TOOLS do not overlap", () => {
137
+ for (const tool of READ_TOOLS) {
138
+ expect(WRITE_TOOLS.has(tool)).toBe(false);
139
+ }
140
+ });
141
+
142
+ it("FREE_TOOLS and READ_TOOLS do not overlap", () => {
143
+ for (const tool of FREE_TOOLS) {
144
+ expect(READ_TOOLS.has(tool)).toBe(false);
145
+ }
146
+ });
147
+
148
+ it("FREE_TOOLS and WRITE_TOOLS do not overlap", () => {
149
+ for (const tool of FREE_TOOLS) {
150
+ expect(WRITE_TOOLS.has(tool)).toBe(false);
151
+ }
152
+ });
153
+
154
+ it("PACED_FREE_TOOLS and FREE_TOOLS do not overlap", () => {
155
+ for (const tool of PACED_FREE_TOOLS) {
156
+ expect(FREE_TOOLS.has(tool)).toBe(false);
157
+ }
158
+ });
159
+
160
+ it("new monitoring tools are in FREE_TOOLS", () => {
161
+ expect(FREE_TOOLS.has("bereach_list_tasks")).toBe(true);
162
+ expect(FREE_TOOLS.has("bereach_cancel_task")).toBe(true);
163
+ expect(FREE_TOOLS.has("bereach_cancel_chain")).toBe(true);
164
+ expect(FREE_TOOLS.has("bereach_campaign_health")).toBe(true);
165
+ });
166
+
167
+ it.each([
168
+ "bereach_events_feed",
169
+ "bereach_review_drafts",
170
+ "bereach_global_activities",
171
+ "bereach_bulk_visit_batch_status",
172
+ ])("'%s' is in FREE_TOOLS (read-only management tool)", (tool) => {
173
+ expect(FREE_TOOLS.has(tool)).toBe(true);
174
+ });
175
+ });
176
+
177
+ // ─── SessionState initialization ───────────────────────────────────
178
+
179
+ describe("createSessionState", () => {
180
+ it("initializes all required fields", () => {
181
+ const state = createSessionState();
182
+ expect(state.visitedProfiles).toBeInstanceOf(Set);
183
+ expect(state.dmHistoryChecked).toBeInstanceOf(Set);
184
+ expect(state.targetedContacts).toBeInstanceOf(Set);
185
+ expect(state.blockedReasons).toBeInstanceOf(Map);
186
+ expect(state.visitCount).toBe(0);
187
+ expect(state.creditsUsedThisSession).toBe(0);
188
+ expect(state.toolCallCount).toBe(0);
189
+ expect(state.lastDmSentAt).toBe(0);
190
+ expect(state.postsThisSession).toBe(0);
191
+ expect(state.currentTaskMode).toBeNull();
192
+ });
193
+
194
+ it("creates independent instances (no shared state)", () => {
195
+ const s1 = createSessionState();
196
+ const s2 = createSessionState();
197
+ s1.visitCount = 10;
198
+ s1.visitedProfiles.add("test");
199
+ expect(s2.visitCount).toBe(0);
200
+ expect(s2.visitedProfiles.size).toBe(0);
201
+ });
202
+ });
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Tests for shared enforcement rules used by both MCP and OpenClaw.
3
+ * Pure function tests - no mocking.
4
+ */
5
+
6
+ import { describe, it, expect } from "vitest";
7
+ import {
8
+ dmCampaignGuard,
9
+ contextScopeGuard,
10
+ warmupActionGuard,
11
+ creditCapGuard,
12
+ } from "../enforcement-rules.js";
13
+ import type { TaskModeInfo } from "../enforcement-types.js";
14
+
15
+ // ─── Factory helpers ───────────────────────────────────────────────
16
+
17
+ function taskMode(overrides: Partial<TaskModeInfo> = {}): TaskModeInfo {
18
+ return {
19
+ taskType: "outreach-batch",
20
+ taskId: "task-1",
21
+ campaignId: "camp-1",
22
+ maxCredits: 30,
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ // ─── dmCampaignGuard ───────────────────────────────────────────────
28
+
29
+ describe("dmCampaignGuard", () => {
30
+ it("passes in interactive mode (no task mode)", () => {
31
+ expect(dmCampaignGuard("bereach_send_message", null)).toBeNull();
32
+ });
33
+
34
+ it("passes for non-DM tools in task mode", () => {
35
+ expect(dmCampaignGuard("bereach_visit_profile", taskMode())).toBeNull();
36
+ });
37
+
38
+ it("passes for DM tools with a campaign in task mode", () => {
39
+ expect(dmCampaignGuard("bereach_send_message", taskMode())).toBeNull();
40
+ expect(dmCampaignGuard("bereach_scheduled_message_create", taskMode())).toBeNull();
41
+ expect(dmCampaignGuard("bereach_draft_schedule", taskMode())).toBeNull();
42
+ });
43
+
44
+ it("blocks DMs in task mode without campaign", () => {
45
+ const tm = taskMode({ campaignId: undefined as unknown as string });
46
+ expect(dmCampaignGuard("bereach_send_message", tm)).toContain("BLOCKED");
47
+ });
48
+
49
+ it("blocks scheduled messages in task mode without campaign", () => {
50
+ const tm = taskMode({ campaignId: "" });
51
+ expect(dmCampaignGuard("bereach_scheduled_message_create", tm)).toContain("BLOCKED");
52
+ });
53
+ });
54
+
55
+ // ─── contextScopeGuard ─────────────────────────────────────────────
56
+
57
+ describe("contextScopeGuard", () => {
58
+ it("passes for non-context tools", () => {
59
+ expect(contextScopeGuard("bereach_visit_profile", {}, taskMode())).toBeNull();
60
+ });
61
+
62
+ it("passes in interactive mode", () => {
63
+ expect(
64
+ contextScopeGuard("bereach_context_set", { type: "icp", scope: "user" }, null),
65
+ ).toBeNull();
66
+ });
67
+
68
+ it("passes when scope matches campaign in task mode", () => {
69
+ expect(
70
+ contextScopeGuard(
71
+ "bereach_context_set",
72
+ { type: "icp", scope: "campaign:camp-1" },
73
+ taskMode(),
74
+ ),
75
+ ).toBeNull();
76
+ });
77
+
78
+ it("blocks campaign-specific context saved as global in task mode", () => {
79
+ const result = contextScopeGuard(
80
+ "bereach_context_set",
81
+ { type: "icp", scope: "user" },
82
+ taskMode(),
83
+ );
84
+ expect(result).toContain("BLOCKED");
85
+ expect(result).toContain("campaign:camp-1");
86
+ });
87
+
88
+ it.each(["icp", "playbook", "tone-voice", "product-pitch"])(
89
+ "blocks '%s' context type saved as global scope in task mode",
90
+ (type) => {
91
+ expect(
92
+ contextScopeGuard("bereach_context_set", { type, scope: "user" }, taskMode()),
93
+ ).toContain("BLOCKED");
94
+ },
95
+ );
96
+
97
+ it("passes for non-campaign context types (e.g. dm_pacing_minutes) even in task mode", () => {
98
+ expect(
99
+ contextScopeGuard(
100
+ "bereach_context_set",
101
+ { type: "dm_pacing_minutes", scope: "user" },
102
+ taskMode(),
103
+ ),
104
+ ).toBeNull();
105
+ });
106
+
107
+ it("passes when no scope is provided but no campaign in task mode", () => {
108
+ expect(
109
+ contextScopeGuard(
110
+ "bereach_context_set",
111
+ { type: "icp" },
112
+ taskMode({ campaignId: "" }),
113
+ ),
114
+ ).toBeNull();
115
+ });
116
+ });
117
+
118
+ // ─── warmupActionGuard ─────────────────────────────────────────────
119
+
120
+ describe("warmupActionGuard", () => {
121
+ it("passes in interactive mode", () => {
122
+ expect(warmupActionGuard("bereach_send_message", null)).toBeNull();
123
+ });
124
+
125
+ it("passes for non-warmup tasks", () => {
126
+ expect(warmupActionGuard("bereach_send_message", taskMode())).toBeNull();
127
+ });
128
+
129
+ it("passes for allowed warmup actions (like, comment, visit)", () => {
130
+ const tm = taskMode({ taskType: "warmup-engage" });
131
+ expect(warmupActionGuard("bereach_like_post", tm)).toBeNull();
132
+ expect(warmupActionGuard("bereach_visit_profile", tm)).toBeNull();
133
+ expect(warmupActionGuard("bereach_comment_on_post", tm)).toBeNull();
134
+ expect(warmupActionGuard("bereach_accept_invitation", tm)).toBeNull();
135
+ });
136
+
137
+ it("blocks DMs during warmup", () => {
138
+ const tm = taskMode({ taskType: "warmup-engage" });
139
+ expect(warmupActionGuard("bereach_send_message", tm)).toContain("BLOCKED");
140
+ expect(warmupActionGuard("bereach_scheduled_message_create", tm)).toContain("BLOCKED");
141
+ expect(warmupActionGuard("bereach_draft_schedule", tm)).toContain("BLOCKED");
142
+ });
143
+
144
+ it("blocks connection requests during warmup", () => {
145
+ const tm = taskMode({ taskType: "warmup-network" });
146
+ expect(warmupActionGuard("bereach_connect_profile", tm)).toContain("BLOCKED");
147
+ });
148
+ });
149
+
150
+ // ─── creditCapGuard ────────────────────────────────────────────────
151
+
152
+ describe("creditCapGuard", () => {
153
+ it("passes in interactive mode", () => {
154
+ expect(creditCapGuard("bereach_visit_profile", 100, null, false)).toBeNull();
155
+ });
156
+
157
+ it("passes for free tools even in task mode", () => {
158
+ expect(creditCapGuard("bereach_contacts_search", 100, taskMode({ maxCredits: 10 }), true)).toBeNull();
159
+ });
160
+
161
+ it("passes when under budget", () => {
162
+ expect(creditCapGuard("bereach_visit_profile", 5, taskMode({ maxCredits: 30 }), false)).toBeNull();
163
+ });
164
+
165
+ it("passes when exactly at budget", () => {
166
+ expect(creditCapGuard("bereach_visit_profile", 30, taskMode({ maxCredits: 30 }), false)).toBeNull();
167
+ });
168
+
169
+ it("blocks when over budget", () => {
170
+ const result = creditCapGuard("bereach_visit_profile", 31, taskMode({ maxCredits: 30 }), false);
171
+ expect(result).toContain("BLOCKED");
172
+ expect(result).toContain("Credit budget exhausted");
173
+ });
174
+
175
+ it("includes correct count in message (credits - 1 for display)", () => {
176
+ const result = creditCapGuard("bereach_visit_profile", 31, taskMode({ maxCredits: 30 }), false);
177
+ expect(result).toContain("30/30");
178
+ });
179
+ });