crewly 1.11.6 → 1.12.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 (142) hide show
  1. package/config/skills/agent/onboarding/synthesize-hierarchy/SKILL.md +65 -0
  2. package/config/skills/agent/onboarding/synthesize-hierarchy/execute.sh +61 -0
  3. package/config/skills/agent/web-search/SKILL.md +70 -0
  4. package/config/skills/agent/web-search/execute.sh +170 -0
  5. package/config/skills/agent/web-search/skill.json +23 -0
  6. package/dist/backend/backend/src/constants.d.ts +12 -0
  7. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  8. package/dist/backend/backend/src/constants.js +12 -0
  9. package/dist/backend/backend/src/constants.js.map +1 -1
  10. package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts +22 -0
  11. package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts.map +1 -1
  12. package/dist/backend/backend/src/controllers/cloud/cloud.controller.js +58 -0
  13. package/dist/backend/backend/src/controllers/cloud/cloud.controller.js.map +1 -1
  14. package/dist/backend/backend/src/controllers/cloud/cloud.routes.d.ts.map +1 -1
  15. package/dist/backend/backend/src/controllers/cloud/cloud.routes.js +3 -1
  16. package/dist/backend/backend/src/controllers/cloud/cloud.routes.js.map +1 -1
  17. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.d.ts +27 -0
  18. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.d.ts.map +1 -1
  19. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.js +108 -0
  20. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.js.map +1 -1
  21. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.d.ts +6 -2
  22. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.d.ts.map +1 -1
  23. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.js +9 -3
  24. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.js.map +1 -1
  25. package/dist/backend/backend/src/index.d.ts.map +1 -1
  26. package/dist/backend/backend/src/index.js +36 -2
  27. package/dist/backend/backend/src/index.js.map +1 -1
  28. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts +18 -0
  29. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts.map +1 -1
  30. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js +24 -2
  31. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js.map +1 -1
  32. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts +102 -0
  33. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -0
  34. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +164 -0
  35. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -0
  36. package/dist/backend/backend/src/services/fission/fission-guard.service.d.ts +21 -0
  37. package/dist/backend/backend/src/services/fission/fission-guard.service.d.ts.map +1 -1
  38. package/dist/backend/backend/src/services/fission/fission-guard.service.js +30 -0
  39. package/dist/backend/backend/src/services/fission/fission-guard.service.js.map +1 -1
  40. package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.d.ts +4 -0
  41. package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.d.ts.map +1 -1
  42. package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.js +8 -0
  43. package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.js.map +1 -1
  44. package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.d.ts +79 -58
  45. package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.d.ts.map +1 -1
  46. package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.js +140 -65
  47. package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.js.map +1 -1
  48. package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.d.ts +117 -0
  49. package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.d.ts.map +1 -0
  50. package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.js +189 -0
  51. package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.js.map +1 -0
  52. package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.d.ts.map +1 -1
  53. package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.js +1 -0
  54. package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.js.map +1 -1
  55. package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.d.ts.map +1 -1
  56. package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.js +2 -0
  57. package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.js.map +1 -1
  58. package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.d.ts.map +1 -1
  59. package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.js +17 -1
  60. package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.js.map +1 -1
  61. package/dist/backend/backend/src/services/reconciler/reconcile-rules.d.ts +50 -0
  62. package/dist/backend/backend/src/services/reconciler/reconcile-rules.d.ts.map +1 -1
  63. package/dist/backend/backend/src/services/reconciler/reconcile-rules.js +71 -0
  64. package/dist/backend/backend/src/services/reconciler/reconcile-rules.js.map +1 -1
  65. package/dist/backend/backend/src/services/reconciler/reconciler.service.d.ts +18 -0
  66. package/dist/backend/backend/src/services/reconciler/reconciler.service.d.ts.map +1 -1
  67. package/dist/backend/backend/src/services/reconciler/reconciler.service.js +75 -1
  68. package/dist/backend/backend/src/services/reconciler/reconciler.service.js.map +1 -1
  69. package/dist/backend/backend/src/services/session/pty/pty-session-backend.d.ts +115 -0
  70. package/dist/backend/backend/src/services/session/pty/pty-session-backend.d.ts.map +1 -1
  71. package/dist/backend/backend/src/services/session/pty/pty-session-backend.js +189 -3
  72. package/dist/backend/backend/src/services/session/pty/pty-session-backend.js.map +1 -1
  73. package/dist/backend/backend/src/services/session/pty/pty-session.d.ts +28 -0
  74. package/dist/backend/backend/src/services/session/pty/pty-session.d.ts.map +1 -1
  75. package/dist/backend/backend/src/services/session/pty/pty-session.js +61 -1
  76. package/dist/backend/backend/src/services/session/pty/pty-session.js.map +1 -1
  77. package/dist/backend/backend/src/services/template/template.service.d.ts.map +1 -1
  78. package/dist/backend/backend/src/services/template/template.service.js +67 -2
  79. package/dist/backend/backend/src/services/template/template.service.js.map +1 -1
  80. package/dist/backend/backend/src/services/v3/cascade-request-status.d.ts +19 -1
  81. package/dist/backend/backend/src/services/v3/cascade-request-status.d.ts.map +1 -1
  82. package/dist/backend/backend/src/services/v3/cascade-request-status.js +39 -2
  83. package/dist/backend/backend/src/services/v3/cascade-request-status.js.map +1 -1
  84. package/dist/backend/backend/src/services/v3/escalation-router.service.d.ts +41 -0
  85. package/dist/backend/backend/src/services/v3/escalation-router.service.d.ts.map +1 -1
  86. package/dist/backend/backend/src/services/v3/escalation-router.service.js +169 -0
  87. package/dist/backend/backend/src/services/v3/escalation-router.service.js.map +1 -1
  88. package/dist/backend/backend/src/services/v3/request-cascade.subscriber.d.ts +4 -1
  89. package/dist/backend/backend/src/services/v3/request-cascade.subscriber.d.ts.map +1 -1
  90. package/dist/backend/backend/src/services/v3/request-cascade.subscriber.js +21 -0
  91. package/dist/backend/backend/src/services/v3/request-cascade.subscriber.js.map +1 -1
  92. package/dist/backend/backend/src/types/intent-task.types.d.ts.map +1 -1
  93. package/dist/backend/backend/src/types/intent-task.types.js +8 -0
  94. package/dist/backend/backend/src/types/intent-task.types.js.map +1 -1
  95. package/dist/backend/backend/src/types/v2/request.types.d.ts +1 -1
  96. package/dist/backend/backend/src/types/v2/request.types.d.ts.map +1 -1
  97. package/dist/backend/backend/src/types/v2/request.types.js +1 -0
  98. package/dist/backend/backend/src/types/v2/request.types.js.map +1 -1
  99. package/dist/cli/backend/src/constants.d.ts +12 -0
  100. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  101. package/dist/cli/backend/src/constants.js +12 -0
  102. package/dist/cli/backend/src/constants.js.map +1 -1
  103. package/package.json +9 -3
  104. package/packages/crewly-agent/README.md +27 -0
  105. package/packages/crewly-agent/bin/crewly-agent +33 -0
  106. package/packages/crewly-agent/package.json +39 -0
  107. package/packages/crewly-agent/src/cli.ts +168 -0
  108. package/packages/crewly-agent/src/runtime/agent-runner.service.test.ts +2355 -0
  109. package/packages/crewly-agent/src/runtime/agent-runner.service.ts +1827 -0
  110. package/packages/crewly-agent/src/runtime/agent-stream.service.test.ts +153 -0
  111. package/packages/crewly-agent/src/runtime/agent-stream.service.ts +225 -0
  112. package/packages/crewly-agent/src/runtime/agent-worker.test.ts +171 -0
  113. package/packages/crewly-agent/src/runtime/agent-worker.ts +193 -0
  114. package/packages/crewly-agent/src/runtime/api-client.ts +143 -0
  115. package/packages/crewly-agent/src/runtime/approval-queue.service.ts +307 -0
  116. package/packages/crewly-agent/src/runtime/audit-log.service.test.ts +208 -0
  117. package/packages/crewly-agent/src/runtime/audit-log.service.ts +332 -0
  118. package/packages/crewly-agent/src/runtime/audit-trail.service.test.ts +178 -0
  119. package/packages/crewly-agent/src/runtime/audit-trail.service.ts +151 -0
  120. package/packages/crewly-agent/src/runtime/auditor-tools.test.ts +274 -0
  121. package/packages/crewly-agent/src/runtime/auditor-tools.ts +311 -0
  122. package/packages/crewly-agent/src/runtime/cloud-config.ts +67 -0
  123. package/packages/crewly-agent/src/runtime/deepseek-sse-transform.test.ts +165 -0
  124. package/packages/crewly-agent/src/runtime/deepseek-sse-transform.ts +168 -0
  125. package/packages/crewly-agent/src/runtime/env-isolation.service.ts +246 -0
  126. package/packages/crewly-agent/src/runtime/in-process-log-buffer.test.ts +280 -0
  127. package/packages/crewly-agent/src/runtime/in-process-log-buffer.ts +317 -0
  128. package/packages/crewly-agent/src/runtime/index.ts +38 -0
  129. package/packages/crewly-agent/src/runtime/mcp-tool-bridge.test.ts +352 -0
  130. package/packages/crewly-agent/src/runtime/mcp-tool-bridge.ts +244 -0
  131. package/packages/crewly-agent/src/runtime/model-manager.test.ts +326 -0
  132. package/packages/crewly-agent/src/runtime/model-manager.ts +363 -0
  133. package/packages/crewly-agent/src/runtime/output-filter.service.ts +175 -0
  134. package/packages/crewly-agent/src/runtime/prompt-guard.service.ts +303 -0
  135. package/packages/crewly-agent/src/runtime/rate-limiter.test.ts +228 -0
  136. package/packages/crewly-agent/src/runtime/rate-limiter.ts +353 -0
  137. package/packages/crewly-agent/src/runtime/tool-registry.test.ts +2510 -0
  138. package/packages/crewly-agent/src/runtime/tool-registry.ts +2104 -0
  139. package/packages/crewly-agent/src/runtime/types.test.ts +519 -0
  140. package/packages/crewly-agent/src/runtime/types.ts +637 -0
  141. package/packages/crewly-agent/src/runtime/web-search.tool.test.ts +131 -0
  142. package/packages/crewly-agent/src/runtime/web-search.tool.ts +140 -0
@@ -0,0 +1,2510 @@
1
+ // Tool registry tests — tools, sensitivity levels, markdown conversion, glob matching
2
+ import { describe, it, expect, beforeEach, vi, afterEach, type Mocked, type MockInstance } from 'vitest';
3
+ import { createTools, getToolNames, TOOL_SENSITIVITY, stripNotifyMarkers, convertMarkdownToSlackMrkdwn, globToRegExp, walkAndMatch, searchFileContents } from './tool-registry.js';
4
+ import { CrewlyApiClient } from './api-client.js';
5
+ import type { AuditEntry, ToolCallbacks, CompactionResult, AuditLogFilters } from './types.js';
6
+ import { WRITE_TOOLS } from './types.js';
7
+
8
+ describe('Tool Registry', () => {
9
+ let mockClient: Mocked<CrewlyApiClient>;
10
+ let tools: ReturnType<typeof createTools>;
11
+
12
+ beforeEach(() => {
13
+ mockClient = {
14
+ get: vi.fn<any>().mockResolvedValue({ success: false, data: null, status: 404 }),
15
+ post: vi.fn<any>(),
16
+ delete: vi.fn<any>(),
17
+ } as any;
18
+ tools = createTools(mockClient, 'crewly-orc', '/test/project');
19
+ });
20
+
21
+ describe('getToolNames', () => {
22
+ it('should return all tool names including glob and grep', () => {
23
+ const names = getToolNames();
24
+ // 32 base tools + web_search (cloud-backed; standalone runtime only)
25
+ expect(names).toHaveLength(33);
26
+ expect(names).toContain('web_search');
27
+ expect(names).toContain('delegate_task');
28
+ expect(names).toContain('send_message');
29
+ expect(names).toContain('get_agent_status');
30
+ expect(names).toContain('get_team_status');
31
+ expect(names).toContain('get_agent_logs');
32
+ expect(names).toContain('reply_slack');
33
+ expect(names).toContain('schedule_check');
34
+ expect(names).toContain('cancel_schedule');
35
+ expect(names).toContain('get_scheduled_checks');
36
+ expect(names).toContain('start_agent');
37
+ expect(names).toContain('stop_agent');
38
+ expect(names).toContain('subscribe_event');
39
+ expect(names).toContain('recall_memory');
40
+ expect(names).toContain('remember');
41
+ expect(names).toContain('heartbeat');
42
+ expect(names).toContain('get_tasks');
43
+ expect(names).toContain('complete_task');
44
+ expect(names).toContain('broadcast');
45
+ expect(names).toContain('handle_agent_failure');
46
+ expect(names).toContain('edit_file');
47
+ expect(names).toContain('read_file');
48
+ expect(names).toContain('write_file');
49
+ expect(names).toContain('register_self');
50
+ expect(names).toContain('get_project_overview');
51
+ expect(names).toContain('report_status');
52
+ expect(names).toContain('compact_memory');
53
+ expect(names).toContain('get_audit_log');
54
+ expect(names).toContain('glob');
55
+ expect(names).toContain('grep');
56
+ });
57
+ });
58
+
59
+ describe('createTools', () => {
60
+ it('should create all tools with descriptions and parameters', () => {
61
+ const toolNames = Object.keys(tools);
62
+ expect(toolNames.length).toBeGreaterThanOrEqual(30);
63
+ for (const name of toolNames) {
64
+ const t = tools[name] as any;
65
+ expect(t).toBeDefined();
66
+ }
67
+ });
68
+ });
69
+
70
+ describe('delegate_task', () => {
71
+ it('should deliver task message and create tracking entry', async () => {
72
+ mockClient.post.mockResolvedValueOnce({ success: true, data: {}, status: 200 }); // deliver
73
+ // V3 task-pool/add response shape: `{ data: { id, ... } }`. The
74
+ // legacy v1 `taskId` field is gone — see spec
75
+ // 2026-05-06-task-management-v1-deprecation.md.
76
+ mockClient.post.mockResolvedValueOnce({ success: true, data: { id: 'task-1' }, status: 201 }); // task create
77
+ mockClient.post.mockResolvedValueOnce({ success: true, data: { id: 'sub-1' }, status: 201 }); // subscribe
78
+
79
+ const result = await (tools.delegate_task as any).execute({
80
+ to: 'agent-sam',
81
+ task: 'Build feature X',
82
+ priority: 'high',
83
+ projectPath: '/path/to/project',
84
+ });
85
+
86
+ expect(result.success).toBe(true);
87
+ expect(result.delegatedTo).toBe('agent-sam');
88
+ expect(result.taskId).toBe('task-1');
89
+ expect(mockClient.post).toHaveBeenCalledTimes(3);
90
+ });
91
+
92
+ it('should fall back to force delivery on initial failure', async () => {
93
+ mockClient.post
94
+ .mockResolvedValueOnce({ success: false, error: 'not ready', status: 503 }) // deliver fails
95
+ .mockResolvedValueOnce({ success: true, data: {}, status: 200 }) // force deliver
96
+ .mockResolvedValueOnce({ success: true, data: {}, status: 201 }); // subscribe (no projectPath)
97
+
98
+ const result = await (tools.delegate_task as any).execute({
99
+ to: 'agent-sam',
100
+ task: 'Build feature X',
101
+ priority: 'normal',
102
+ });
103
+
104
+ expect(result.success).toBe(true);
105
+ expect(mockClient.post).toHaveBeenCalledTimes(3);
106
+ });
107
+
108
+ it('should return error if both delivery attempts fail', async () => {
109
+ mockClient.post
110
+ .mockResolvedValueOnce({ success: false, error: 'not ready', status: 503 })
111
+ .mockResolvedValueOnce({ success: false, error: 'session gone', status: 404 });
112
+
113
+ const result = await (tools.delegate_task as any).execute({
114
+ to: 'agent-sam',
115
+ task: 'Build feature X',
116
+ priority: 'normal',
117
+ });
118
+
119
+ expect(result.success).toBe(false);
120
+ expect(result.error).toContain('agent-sam');
121
+ });
122
+ });
123
+
124
+ describe('send_message', () => {
125
+ it('should deliver message with waitForReady', async () => {
126
+ mockClient.post.mockResolvedValue({ success: true, data: {}, status: 200 });
127
+
128
+ const result = await (tools.send_message as any).execute({
129
+ sessionName: 'agent-sam',
130
+ message: 'Hello',
131
+ force: false,
132
+ });
133
+
134
+ expect(result.success).toBe(true);
135
+ expect(mockClient.post).toHaveBeenCalledWith(
136
+ '/terminal/agent-sam/deliver',
137
+ expect.objectContaining({ message: 'Hello', waitForReady: true }),
138
+ );
139
+ });
140
+
141
+ it('should force deliver when force=true', async () => {
142
+ mockClient.post.mockResolvedValue({ success: true, data: {}, status: 200 });
143
+
144
+ await (tools.send_message as any).execute({
145
+ sessionName: 'agent-sam',
146
+ message: 'Urgent',
147
+ force: true,
148
+ });
149
+
150
+ expect(mockClient.post).toHaveBeenCalledWith(
151
+ '/terminal/agent-sam/deliver',
152
+ expect.objectContaining({ message: 'Urgent', force: true }),
153
+ );
154
+ });
155
+ });
156
+
157
+ describe('get_agent_status', () => {
158
+ it('should find agent in team members', async () => {
159
+ mockClient.get.mockResolvedValue({
160
+ success: true,
161
+ data: [
162
+ { members: [{ sessionName: 'agent-sam', status: 'active' }] },
163
+ ],
164
+ status: 200,
165
+ });
166
+
167
+ const result = await (tools.get_agent_status as any).execute({
168
+ sessionName: 'agent-sam',
169
+ });
170
+
171
+ expect(result.sessionName).toBe('agent-sam');
172
+ expect(result.status).toBe('active');
173
+ });
174
+
175
+ it('should return error when agent not found', async () => {
176
+ mockClient.get.mockResolvedValue({
177
+ success: true,
178
+ data: [{ members: [] }],
179
+ status: 200,
180
+ });
181
+
182
+ const result = await (tools.get_agent_status as any).execute({
183
+ sessionName: 'nonexistent',
184
+ });
185
+
186
+ expect(result.error).toBe('Agent not found');
187
+ });
188
+ });
189
+
190
+ describe('get_team_status', () => {
191
+ it('should return all teams', async () => {
192
+ mockClient.get.mockResolvedValue({ success: true, data: [{ name: 'Team A' }], status: 200 });
193
+
194
+ const result = await (tools.get_team_status as any).execute({});
195
+
196
+ expect(result).toEqual([{ name: 'Team A' }]);
197
+ });
198
+ });
199
+
200
+ describe('get_agent_logs', () => {
201
+ it('should fetch terminal output', async () => {
202
+ mockClient.get.mockResolvedValue({ success: true, data: 'line 1\nline 2', status: 200 });
203
+
204
+ const result = await (tools.get_agent_logs as any).execute({
205
+ sessionName: 'agent-sam',
206
+ lines: 20,
207
+ });
208
+
209
+ expect(result).toBe('line 1\nline 2');
210
+ expect(mockClient.get).toHaveBeenCalledWith('/terminal/agent-sam/output?lines=20');
211
+ });
212
+ });
213
+
214
+ describe('reply_slack', () => {
215
+ it('should send slack message with thread', async () => {
216
+ mockClient.post.mockResolvedValue({ success: true, data: {}, status: 200 });
217
+
218
+ const result = await (tools.reply_slack as any).execute({
219
+ channelId: 'C123',
220
+ text: 'Hello team',
221
+ threadTs: '123.456',
222
+ });
223
+
224
+ expect(result.success).toBe(true);
225
+ expect(mockClient.post).toHaveBeenCalledWith('/slack/send', {
226
+ channelId: 'C123',
227
+ text: '[Orc] Hello team',
228
+ senderSessionName: 'crewly-orc',
229
+ threadTs: '123.456',
230
+ });
231
+ });
232
+
233
+ it('should send without threadTs', async () => {
234
+ mockClient.post.mockResolvedValue({ success: true, data: {}, status: 200 });
235
+
236
+ await (tools.reply_slack as any).execute({
237
+ channelId: 'C123',
238
+ text: 'Hello',
239
+ });
240
+
241
+ expect(mockClient.post).toHaveBeenCalledWith('/slack/send', {
242
+ channelId: 'C123',
243
+ text: '[Orc] Hello',
244
+ senderSessionName: 'crewly-orc',
245
+ });
246
+ });
247
+
248
+ it('should strip [NOTIFY] markers from text before sending (Bug 6)', async () => {
249
+ mockClient.post.mockResolvedValue({ success: true, data: {}, status: 200 });
250
+
251
+ await (tools.reply_slack as any).execute({
252
+ channelId: 'C123',
253
+ text: '[NOTIFY]\nconversationId: conv-123\n---\n## Task Done\nAll tasks completed.\n[/NOTIFY]',
254
+ threadTs: '123.456',
255
+ });
256
+
257
+ // After stripping NOTIFY markers, text starts with "##" not "[", so prefix is added
258
+ expect(mockClient.post).toHaveBeenCalledWith('/slack/send', {
259
+ channelId: 'C123',
260
+ text: '[Orc] ## Task Done\nAll tasks completed.',
261
+ senderSessionName: 'crewly-orc',
262
+ threadTs: '123.456',
263
+ });
264
+ });
265
+
266
+ it('should handle text with no NOTIFY markers unchanged', async () => {
267
+ mockClient.post.mockResolvedValue({ success: true, data: {}, status: 200 });
268
+
269
+ await (tools.reply_slack as any).execute({
270
+ channelId: 'C123',
271
+ text: 'Plain message without markers',
272
+ });
273
+
274
+ expect(mockClient.post).toHaveBeenCalledWith('/slack/send', {
275
+ channelId: 'C123',
276
+ text: '[Orc] Plain message without markers',
277
+ senderSessionName: 'crewly-orc',
278
+ });
279
+ });
280
+
281
+ it('should not double-prefix text already starting with [', async () => {
282
+ mockClient.post.mockResolvedValue({ success: true, data: {}, status: 200 });
283
+
284
+ await (tools.reply_slack as any).execute({
285
+ channelId: 'C123',
286
+ text: '[Sam] Already prefixed message',
287
+ senderSessionName: 'crewly-orc',
288
+ });
289
+
290
+ expect(mockClient.post).toHaveBeenCalledWith('/slack/send', {
291
+ channelId: 'C123',
292
+ text: '[Sam] Already prefixed message',
293
+ senderSessionName: 'crewly-orc',
294
+ });
295
+ });
296
+
297
+ it('should upload image when imagePath is provided', async () => {
298
+ mockClient.post.mockResolvedValue({ success: true, data: { fileId: 'F123' }, status: 200 });
299
+
300
+ const result = await (tools.reply_slack as any).execute({
301
+ channelId: 'C123',
302
+ text: 'Here is the screenshot',
303
+ imagePath: '/tmp/screenshot.png',
304
+ });
305
+
306
+ expect(result.success).toBe(true);
307
+ expect(result.uploaded).toBe(true);
308
+ expect(result.filePath).toBe('/tmp/screenshot.png');
309
+ expect(mockClient.post).toHaveBeenCalledWith('/slack/upload-image', {
310
+ channelId: 'C123',
311
+ filePath: '/tmp/screenshot.png',
312
+ initialComment: '[Orc] Here is the screenshot',
313
+ });
314
+ });
315
+
316
+ it('should upload image with threadTs', async () => {
317
+ mockClient.post.mockResolvedValue({ success: true, data: { fileId: 'F123' }, status: 200 });
318
+
319
+ await (tools.reply_slack as any).execute({
320
+ channelId: 'C123',
321
+ text: 'Screenshot',
322
+ threadTs: '123.456',
323
+ imagePath: '/tmp/shot.png',
324
+ });
325
+
326
+ expect(mockClient.post).toHaveBeenCalledWith('/slack/upload-image', {
327
+ channelId: 'C123',
328
+ filePath: '/tmp/shot.png',
329
+ initialComment: '[Orc] Screenshot',
330
+ threadTs: '123.456',
331
+ });
332
+ });
333
+
334
+ it('should return error when image upload fails', async () => {
335
+ mockClient.post.mockResolvedValue({ success: false, data: null, status: 404, error: 'File not found: /tmp/missing.png' });
336
+
337
+ const result = await (tools.reply_slack as any).execute({
338
+ channelId: 'C123',
339
+ text: 'Missing image',
340
+ imagePath: '/tmp/missing.png',
341
+ });
342
+
343
+ expect(result.success).toBe(false);
344
+ expect(result.error).toBe('File not found: /tmp/missing.png');
345
+ });
346
+
347
+ it('should use Slack context channelId for image upload when channelId omitted', async () => {
348
+ mockClient.post.mockResolvedValue({ success: true, data: { fileId: 'F456' }, status: 200 });
349
+
350
+ const toolsWithContext = createTools(mockClient, 'crewly-orc', '/test/project', undefined, undefined, { channelId: 'C-FROM-CONTEXT', threadTs: '999.888' });
351
+
352
+ const result = await (toolsWithContext.reply_slack as any).execute({
353
+ text: 'Image from context',
354
+ imagePath: '/tmp/ctx.png',
355
+ });
356
+
357
+ expect(result.success).toBe(true);
358
+ expect(result.uploaded).toBe(true);
359
+ expect(mockClient.post).toHaveBeenCalledWith('/slack/upload-image', {
360
+ channelId: 'C-FROM-CONTEXT',
361
+ filePath: '/tmp/ctx.png',
362
+ initialComment: '[Orc] Image from context',
363
+ threadTs: '999.888',
364
+ });
365
+ });
366
+
367
+ it('should return error when no channelId for image upload', async () => {
368
+ const toolsNoContext = createTools(mockClient, 'crewly-orc', '/test/project');
369
+
370
+ const result = await (toolsNoContext.reply_slack as any).execute({
371
+ text: 'Image without channel',
372
+ imagePath: '/tmp/no-channel.png',
373
+ });
374
+
375
+ expect(result.success).toBe(false);
376
+ expect(result.error).toContain('No channelId');
377
+ });
378
+
379
+ it('should skip dedup and throttle for image uploads', async () => {
380
+ mockClient.post.mockResolvedValue({ success: true, data: {}, status: 200 });
381
+
382
+ // Send a text message first to populate dedup and throttle state
383
+ await (tools.reply_slack as any).execute({
384
+ channelId: 'C123',
385
+ text: 'Duplicate text',
386
+ });
387
+
388
+ // Image upload with same text should NOT be deduped or throttled
389
+ const result = await (tools.reply_slack as any).execute({
390
+ channelId: 'C123',
391
+ text: 'Duplicate text',
392
+ imagePath: '/tmp/image.png',
393
+ });
394
+
395
+ expect(result.success).toBe(true);
396
+ expect(result.uploaded).toBe(true);
397
+ // Should have called upload-image, not been throttled/deduped
398
+ expect(mockClient.post).toHaveBeenCalledWith('/slack/upload-image', expect.objectContaining({
399
+ filePath: '/tmp/image.png',
400
+ }));
401
+ });
402
+ });
403
+
404
+ describe('stripNotifyMarkers', () => {
405
+ it('should extract body after --- separator', () => {
406
+ const input = '[NOTIFY]\nconversationId: conv-123\ntype: task_completed\n---\n## Done\nTask finished.\n[/NOTIFY]';
407
+ expect(stripNotifyMarkers(input)).toBe('## Done\nTask finished.');
408
+ });
409
+
410
+ it('should return inner content when no --- separator', () => {
411
+ const input = '[NOTIFY]\nHello world\n[/NOTIFY]';
412
+ expect(stripNotifyMarkers(input)).toBe('Hello world');
413
+ });
414
+
415
+ it('should handle text mixed with NOTIFY blocks', () => {
416
+ const input = 'Before [NOTIFY]\n---\nExtracted\n[/NOTIFY] After';
417
+ expect(stripNotifyMarkers(input)).toBe('Before Extracted After');
418
+ });
419
+
420
+ it('should handle multiple NOTIFY blocks', () => {
421
+ const input = '[NOTIFY]\n---\nFirst\n[/NOTIFY] and [NOTIFY]\n---\nSecond\n[/NOTIFY]';
422
+ expect(stripNotifyMarkers(input)).toBe('First and Second');
423
+ });
424
+
425
+ it('should return text unchanged when no markers present', () => {
426
+ expect(stripNotifyMarkers('No markers here')).toBe('No markers here');
427
+ });
428
+
429
+ it('should be case-insensitive', () => {
430
+ const input = '[notify]\n---\nContent\n[/notify]';
431
+ expect(stripNotifyMarkers(input)).toBe('Content');
432
+ });
433
+ });
434
+
435
+ describe('#181: convertMarkdownToSlackMrkdwn', () => {
436
+ it('should convert **bold** to *bold*', () => {
437
+ expect(convertMarkdownToSlackMrkdwn('This is **bold** text')).toBe('This is *bold* text');
438
+ });
439
+
440
+ it('should convert multiple **bold** segments', () => {
441
+ expect(convertMarkdownToSlackMrkdwn('**one** and **two**')).toBe('*one* and *two*');
442
+ });
443
+
444
+ it('should convert [text](url) to <url|text>', () => {
445
+ expect(convertMarkdownToSlackMrkdwn('Visit [Google](https://google.com) now'))
446
+ .toBe('Visit <https://google.com|Google> now');
447
+ });
448
+
449
+ it('should escape & < > to HTML entities', () => {
450
+ expect(convertMarkdownToSlackMrkdwn('A & B < C > D'))
451
+ .toBe('A &amp; B &lt; C &gt; D');
452
+ });
453
+
454
+ it('should strip language hints from fenced code blocks', () => {
455
+ const input = '```typescript\nconst x = 1;\n```';
456
+ const expected = '```\nconst x = 1;\n```';
457
+ expect(convertMarkdownToSlackMrkdwn(input)).toBe(expected);
458
+ });
459
+
460
+ it('should preserve plain code blocks without language hint', () => {
461
+ const input = '```\ncode here\n```';
462
+ expect(convertMarkdownToSlackMrkdwn(input)).toBe('```\ncode here\n```');
463
+ });
464
+
465
+ it('should handle combined formatting', () => {
466
+ const input = '**Important**: See [docs](https://docs.io) for A & B';
467
+ const result = convertMarkdownToSlackMrkdwn(input);
468
+ expect(result).toContain('*Important*');
469
+ expect(result).toContain('<https://docs.io|docs>');
470
+ expect(result).toContain('A &amp; B');
471
+ });
472
+
473
+ it('should not touch single asterisks (italic)', () => {
474
+ expect(convertMarkdownToSlackMrkdwn('This is *italic* text')).toBe('This is *italic* text');
475
+ });
476
+
477
+ it('should return empty string unchanged', () => {
478
+ expect(convertMarkdownToSlackMrkdwn('')).toBe('');
479
+ });
480
+
481
+ it('should handle text with no markdown', () => {
482
+ expect(convertMarkdownToSlackMrkdwn('Plain text')).toBe('Plain text');
483
+ });
484
+ });
485
+
486
+ describe('schedule_check', () => {
487
+ it('should schedule a one-time check', async () => {
488
+ mockClient.post.mockResolvedValue({ success: true, data: { checkId: 'sched-1' }, status: 201 });
489
+
490
+ const result = await (tools.schedule_check as any).execute({
491
+ minutes: 10,
492
+ message: 'Check progress',
493
+ recurring: false,
494
+ });
495
+
496
+ expect(result.checkId).toBe('sched-1');
497
+ expect(mockClient.post).toHaveBeenCalledWith('/schedule', expect.objectContaining({
498
+ targetSession: 'crewly-orc',
499
+ minutes: 10,
500
+ message: 'Check progress',
501
+ }));
502
+ });
503
+
504
+ it('should schedule a recurring check', async () => {
505
+ mockClient.post.mockResolvedValue({ success: true, data: { checkId: 'sched-2' }, status: 201 });
506
+
507
+ await (tools.schedule_check as any).execute({
508
+ minutes: 5,
509
+ message: 'Recurring check',
510
+ recurring: true,
511
+ maxOccurrences: 3,
512
+ });
513
+
514
+ expect(mockClient.post).toHaveBeenCalledWith('/schedule', expect.objectContaining({
515
+ isRecurring: true,
516
+ intervalMinutes: 5,
517
+ maxOccurrences: 3,
518
+ }));
519
+ });
520
+ });
521
+
522
+ describe('heartbeat', () => {
523
+ it('should fetch teams, projects, and queue in parallel', async () => {
524
+ mockClient.get
525
+ .mockResolvedValueOnce({ success: true, data: [{ name: 'Team A' }], status: 200 })
526
+ .mockResolvedValueOnce({ success: true, data: [{ name: 'Project 1' }], status: 200 })
527
+ .mockResolvedValueOnce({ success: true, data: { pending: 0 }, status: 200 });
528
+
529
+ const result = await (tools.heartbeat as any).execute({});
530
+
531
+ expect(result.status).toBe('ok');
532
+ expect(result.teams).toEqual([{ name: 'Team A' }]);
533
+ expect(result.projects).toEqual([{ name: 'Project 1' }]);
534
+ expect(result.queue).toEqual({ pending: 0 });
535
+ });
536
+
537
+ it('should handle partial failures gracefully', async () => {
538
+ mockClient.get
539
+ .mockResolvedValueOnce({ success: true, data: [], status: 200 })
540
+ .mockResolvedValueOnce({ success: false, error: 'unavailable', status: 500 })
541
+ .mockResolvedValueOnce({ success: true, data: {}, status: 200 });
542
+
543
+ const result = await (tools.heartbeat as any).execute({});
544
+
545
+ expect(result.status).toBe('ok');
546
+ expect(result.projects).toBe('unavailable');
547
+ });
548
+ });
549
+
550
+ describe('start_agent', () => {
551
+ it('should start agent via API when not already active', async () => {
552
+ // Pre-check returns team with inactive member
553
+ mockClient.get.mockResolvedValueOnce({
554
+ success: true,
555
+ data: { members: [{ id: 'member-1', agentStatus: 'inactive', sessionName: '' }] },
556
+ status: 200,
557
+ });
558
+ mockClient.post.mockResolvedValue({ success: true, data: { started: true }, status: 200 });
559
+
560
+ const result = await (tools.start_agent as any).execute({
561
+ teamId: 'team-1',
562
+ memberId: 'member-1',
563
+ });
564
+
565
+ expect(result.started).toBe(true);
566
+ expect(mockClient.post).toHaveBeenCalledWith('/teams/team-1/members/member-1/start', {});
567
+ });
568
+
569
+ it('should return already_active without calling start if agent is active', async () => {
570
+ mockClient.get.mockResolvedValueOnce({
571
+ success: true,
572
+ data: { members: [{ id: 'member-1', agentStatus: 'active', sessionName: 'session-1', name: 'Sam' }] },
573
+ status: 200,
574
+ });
575
+
576
+ const result = await (tools.start_agent as any).execute({
577
+ teamId: 'team-1',
578
+ memberId: 'member-1',
579
+ });
580
+
581
+ expect(result.status).toBe('already_active');
582
+ expect(result.sessionName).toBe('session-1');
583
+ expect(mockClient.post).not.toHaveBeenCalled();
584
+ });
585
+
586
+ it('should proceed with start if pre-check fails', async () => {
587
+ mockClient.get.mockResolvedValueOnce({ success: false, error: 'not found', status: 404 });
588
+ mockClient.post.mockResolvedValue({ success: true, data: { started: true }, status: 200 });
589
+
590
+ const result = await (tools.start_agent as any).execute({
591
+ teamId: 'team-1',
592
+ memberId: 'member-1',
593
+ });
594
+
595
+ expect(result.started).toBe(true);
596
+ });
597
+ });
598
+
599
+ describe('stop_agent', () => {
600
+ it('should stop agent via API', async () => {
601
+ mockClient.post.mockResolvedValue({ success: true, data: { stopped: true }, status: 200 });
602
+
603
+ const result = await (tools.stop_agent as any).execute({
604
+ teamId: 'team-1',
605
+ memberId: 'member-1',
606
+ });
607
+
608
+ expect(result.stopped).toBe(true);
609
+ });
610
+ });
611
+
612
+ describe('handle_agent_failure', () => {
613
+ it('should restart agent by stopping then starting', async () => {
614
+ mockClient.post.mockResolvedValue({ success: true, data: {}, status: 200 });
615
+
616
+ const result = await (tools.handle_agent_failure as any).execute({
617
+ teamId: 'team-1',
618
+ memberId: 'member-1',
619
+ sessionName: 'agent-sam',
620
+ action: 'restart',
621
+ });
622
+
623
+ expect(result.action).toBe('restarted');
624
+ expect(result.success).toBe(true);
625
+ expect(mockClient.post).toHaveBeenCalledTimes(2); // stop + start
626
+ });
627
+
628
+ it('should handle escalation', async () => {
629
+ const result = await (tools.handle_agent_failure as any).execute({
630
+ teamId: 'team-1',
631
+ memberId: 'member-1',
632
+ sessionName: 'agent-sam',
633
+ action: 'escalate',
634
+ reason: 'Agent stuck',
635
+ });
636
+
637
+ expect(result.action).toBe('escalated');
638
+ expect(result.reason).toBe('Agent stuck');
639
+ });
640
+ });
641
+
642
+ describe('recall_memory', () => {
643
+ it('should call memory recall API with auto-injected projectPath', async () => {
644
+ mockClient.post.mockResolvedValue({ success: true, data: { memories: [] }, status: 200 });
645
+
646
+ const result = await (tools.recall_memory as any).execute({
647
+ context: 'deployment process',
648
+ scope: 'project',
649
+ });
650
+
651
+ expect(result.memories).toEqual([]);
652
+ expect(mockClient.post).toHaveBeenCalledWith('/memory/recall', {
653
+ agentId: 'crewly-orc',
654
+ context: 'deployment process',
655
+ scope: 'project',
656
+ projectPath: '/test/project',
657
+ });
658
+ });
659
+
660
+ it('should auto-inject projectPath for scope=both', async () => {
661
+ mockClient.post.mockResolvedValue({ success: true, data: { memories: [] }, status: 200 });
662
+
663
+ await (tools.recall_memory as any).execute({
664
+ context: 'OKR goals',
665
+ scope: 'both',
666
+ });
667
+
668
+ expect(mockClient.post).toHaveBeenCalledWith('/memory/recall', expect.objectContaining({
669
+ projectPath: '/test/project',
670
+ scope: 'both',
671
+ }));
672
+ });
673
+
674
+ it('should not inject projectPath for scope=agent', async () => {
675
+ mockClient.post.mockResolvedValue({ success: true, data: { memories: [] }, status: 200 });
676
+
677
+ await (tools.recall_memory as any).execute({
678
+ context: 'my preferences',
679
+ scope: 'agent',
680
+ });
681
+
682
+ expect(mockClient.post).toHaveBeenCalledWith('/memory/recall', {
683
+ agentId: 'crewly-orc',
684
+ context: 'my preferences',
685
+ scope: 'agent',
686
+ });
687
+ });
688
+
689
+ it('should prefer explicit projectPath over auto-injected', async () => {
690
+ mockClient.post.mockResolvedValue({ success: true, data: {}, status: 200 });
691
+
692
+ await (tools.recall_memory as any).execute({
693
+ context: 'test',
694
+ scope: 'project',
695
+ projectPath: '/explicit/path',
696
+ });
697
+
698
+ expect(mockClient.post).toHaveBeenCalledWith('/memory/recall', expect.objectContaining({
699
+ projectPath: '/explicit/path',
700
+ }));
701
+ });
702
+ });
703
+
704
+ describe('remember', () => {
705
+ it('should store knowledge via API with auto-injected projectPath', async () => {
706
+ mockClient.post.mockResolvedValue({ success: true, data: { id: 'mem-1' }, status: 201 });
707
+
708
+ const result = await (tools.remember as any).execute({
709
+ content: 'Always use async/await',
710
+ category: 'pattern',
711
+ scope: 'project',
712
+ });
713
+
714
+ expect(result.id).toBe('mem-1');
715
+ expect(mockClient.post).toHaveBeenCalledWith('/memory/remember', expect.objectContaining({
716
+ projectPath: '/test/project',
717
+ }));
718
+ });
719
+ });
720
+
721
+ describe('get_tasks', () => {
722
+ it('should fetch tasks with project path', async () => {
723
+ mockClient.get.mockResolvedValue({ success: true, data: [], status: 200 });
724
+
725
+ await (tools.get_tasks as any).execute({
726
+ projectPath: '/path/to/project',
727
+ status: 'in_progress',
728
+ });
729
+
730
+ // V3-only: pool is global, projectPath is no longer a filter. See
731
+ // spec/2026-05-06-task-management-v1-deprecation.md.
732
+ expect(mockClient.get).toHaveBeenCalledWith('/task-pool/items?status=in_progress');
733
+ });
734
+ });
735
+
736
+ describe('broadcast', () => {
737
+ it('should send message to all sessions except self', async () => {
738
+ mockClient.get.mockResolvedValue({
739
+ success: true,
740
+ data: [{ name: 'agent-sam' }, { name: 'agent-leo' }, { name: 'crewly-orc' }],
741
+ status: 200,
742
+ });
743
+ mockClient.post.mockResolvedValue({ success: true, data: {}, status: 200 });
744
+
745
+ const result = await (tools.broadcast as any).execute({ message: 'Hello all' });
746
+
747
+ expect(result.sent).toBe(2); // sam + leo, skip crewly-orc (self)
748
+ expect(result.failed).toBe(0);
749
+ });
750
+ });
751
+
752
+ describe('edit_file', () => {
753
+ let mockReadFile: MockInstance<typeof import('fs').promises.readFile>;
754
+ let mockWriteFile: MockInstance<typeof import('fs').promises.writeFile>;
755
+
756
+ beforeEach(async () => {
757
+ const fs = await import('fs');
758
+ mockReadFile = vi.spyOn(fs.promises, 'readFile') as any;
759
+ mockWriteFile = vi.spyOn(fs.promises, 'writeFile') as any;
760
+ });
761
+
762
+ afterEach(() => {
763
+ vi.restoreAllMocks();
764
+ });
765
+
766
+ it('should replace unique string in file', async () => {
767
+ mockReadFile.mockResolvedValue('Hello World\nGoodbye World' as any);
768
+ mockWriteFile.mockResolvedValue(undefined as any);
769
+
770
+ const result = await (tools.edit_file as any).execute({
771
+ file_path: '/test/file.ts',
772
+ old_string: 'Hello World',
773
+ new_string: 'Hi World',
774
+ replace_all: false,
775
+ });
776
+
777
+ expect(result.success).toBe(true);
778
+ expect(result.replacements).toBe(1);
779
+ expect(mockWriteFile).toHaveBeenCalledWith(
780
+ '/test/file.ts',
781
+ 'Hi World\nGoodbye World',
782
+ 'utf8',
783
+ );
784
+ });
785
+
786
+ it('should fail when old_string not found', async () => {
787
+ mockReadFile.mockResolvedValue('Hello World' as any);
788
+
789
+ const result = await (tools.edit_file as any).execute({
790
+ file_path: '/test/file.ts',
791
+ old_string: 'Not Found',
792
+ new_string: 'Replacement',
793
+ replace_all: false,
794
+ });
795
+
796
+ expect(result.success).toBe(false);
797
+ expect(result.error).toContain('not found');
798
+ });
799
+
800
+ it('should fail when old_string has multiple matches and replace_all is false', async () => {
801
+ mockReadFile.mockResolvedValue('foo bar foo baz foo' as any);
802
+
803
+ const result = await (tools.edit_file as any).execute({
804
+ file_path: '/test/file.ts',
805
+ old_string: 'foo',
806
+ new_string: 'qux',
807
+ replace_all: false,
808
+ });
809
+
810
+ expect(result.success).toBe(false);
811
+ expect(result.error).toContain('3 times');
812
+ expect(result.occurrences).toBe(3);
813
+ });
814
+
815
+ it('should replace all occurrences when replace_all is true', async () => {
816
+ mockReadFile.mockResolvedValue('foo bar foo baz foo' as any);
817
+ mockWriteFile.mockResolvedValue(undefined as any);
818
+
819
+ const result = await (tools.edit_file as any).execute({
820
+ file_path: '/test/file.ts',
821
+ old_string: 'foo',
822
+ new_string: 'qux',
823
+ replace_all: true,
824
+ });
825
+
826
+ expect(result.success).toBe(true);
827
+ expect(result.replacements).toBe(3);
828
+ expect(mockWriteFile).toHaveBeenCalledWith(
829
+ '/test/file.ts',
830
+ 'qux bar qux baz qux',
831
+ 'utf8',
832
+ );
833
+ });
834
+
835
+ it('should handle single occurrence with replace_all true', async () => {
836
+ mockReadFile.mockResolvedValue('Hello World\nGoodbye World' as any);
837
+ mockWriteFile.mockResolvedValue(undefined as any);
838
+
839
+ const result = await (tools.edit_file as any).execute({
840
+ file_path: '/test/file.ts',
841
+ old_string: 'Hello World',
842
+ new_string: 'Hi World',
843
+ replace_all: true,
844
+ });
845
+
846
+ expect(result.success).toBe(true);
847
+ expect(result.replacements).toBe(1);
848
+ });
849
+
850
+ it('should handle file not found error', async () => {
851
+ mockReadFile.mockRejectedValue(new Error('ENOENT: no such file'));
852
+
853
+ const result = await (tools.edit_file as any).execute({
854
+ file_path: '/nonexistent/file.ts',
855
+ old_string: 'foo',
856
+ new_string: 'bar',
857
+ replace_all: false,
858
+ });
859
+
860
+ expect(result.success).toBe(false);
861
+ expect(result.error).toContain('not found');
862
+ });
863
+ });
864
+
865
+ describe('read_file', () => {
866
+ let mockReadFile: MockInstance<typeof import('fs').promises.readFile>;
867
+
868
+ beforeEach(async () => {
869
+ const fs = await import('fs');
870
+ mockReadFile = vi.spyOn(fs.promises, 'readFile') as any;
871
+ });
872
+
873
+ afterEach(() => {
874
+ vi.restoreAllMocks();
875
+ });
876
+
877
+ it('should read entire file with line numbers', async () => {
878
+ mockReadFile.mockResolvedValue('line 1\nline 2\nline 3' as any);
879
+
880
+ const result = await (tools.read_file as any).execute({
881
+ file_path: '/test/file.ts',
882
+ });
883
+
884
+ expect(result.success).toBe(true);
885
+ expect(result.totalLines).toBe(3);
886
+ expect(result.content).toContain('1\tline 1');
887
+ expect(result.content).toContain('3\tline 3');
888
+ });
889
+
890
+ it('should support offset and limit', async () => {
891
+ mockReadFile.mockResolvedValue('a\nb\nc\nd\ne' as any);
892
+
893
+ const result = await (tools.read_file as any).execute({
894
+ file_path: '/test/file.ts',
895
+ offset: 2,
896
+ limit: 2,
897
+ });
898
+
899
+ expect(result.success).toBe(true);
900
+ expect(result.content).toContain('2\tb');
901
+ expect(result.content).toContain('3\tc');
902
+ expect(result.content).not.toContain('4\td');
903
+ });
904
+
905
+ it('should handle file not found', async () => {
906
+ mockReadFile.mockRejectedValue(new Error('ENOENT'));
907
+
908
+ const result = await (tools.read_file as any).execute({
909
+ file_path: '/nonexistent',
910
+ });
911
+
912
+ expect(result.success).toBe(false);
913
+ expect(result.error).toContain('not found');
914
+ });
915
+
916
+ it('should return base64 image data for PNG files', async () => {
917
+ const fakeImageBuffer = Buffer.from('fake-png-data');
918
+ mockReadFile.mockResolvedValue(fakeImageBuffer as any);
919
+
920
+ const result = await (tools.read_file as any).execute({
921
+ file_path: '/test/screenshot.png',
922
+ });
923
+
924
+ expect(result.success).toBe(true);
925
+ expect(result.type).toBe('image');
926
+ expect(result.mimeType).toBe('image/png');
927
+ expect(result.data).toBe(fakeImageBuffer.toString('base64'));
928
+ expect(result.sizeBytes).toBe(fakeImageBuffer.length);
929
+ expect(result.file).toBe('/test/screenshot.png');
930
+ });
931
+
932
+ it('should return base64 image data for JPEG files', async () => {
933
+ const fakeImageBuffer = Buffer.from('fake-jpg-data');
934
+ mockReadFile.mockResolvedValue(fakeImageBuffer as any);
935
+
936
+ const result = await (tools.read_file as any).execute({
937
+ file_path: '/test/photo.jpg',
938
+ });
939
+
940
+ expect(result.success).toBe(true);
941
+ expect(result.type).toBe('image');
942
+ expect(result.mimeType).toBe('image/jpeg');
943
+ });
944
+
945
+ it('should return base64 image data for WebP files', async () => {
946
+ const fakeImageBuffer = Buffer.from('fake-webp-data');
947
+ mockReadFile.mockResolvedValue(fakeImageBuffer as any);
948
+
949
+ const result = await (tools.read_file as any).execute({
950
+ file_path: '/test/image.webp',
951
+ });
952
+
953
+ expect(result.success).toBe(true);
954
+ expect(result.type).toBe('image');
955
+ expect(result.mimeType).toBe('image/webp');
956
+ });
957
+
958
+ it('should return base64 image data for SVG files', async () => {
959
+ const fakeSvgBuffer = Buffer.from('<svg></svg>');
960
+ mockReadFile.mockResolvedValue(fakeSvgBuffer as any);
961
+
962
+ const result = await (tools.read_file as any).execute({
963
+ file_path: '/test/icon.svg',
964
+ });
965
+
966
+ expect(result.success).toBe(true);
967
+ expect(result.type).toBe('image');
968
+ expect(result.mimeType).toBe('image/svg+xml');
969
+ });
970
+
971
+ it('should read text files normally even with image-like names', async () => {
972
+ mockReadFile.mockResolvedValue('text content' as any);
973
+
974
+ const result = await (tools.read_file as any).execute({
975
+ file_path: '/test/data.json',
976
+ });
977
+
978
+ expect(result.success).toBe(true);
979
+ expect(result.type).toBeUndefined();
980
+ expect(result.content).toContain('text content');
981
+ });
982
+
983
+ it('should handle image file not found', async () => {
984
+ mockReadFile.mockRejectedValue(new Error('ENOENT'));
985
+
986
+ const result = await (tools.read_file as any).execute({
987
+ file_path: '/nonexistent/image.png',
988
+ });
989
+
990
+ expect(result.success).toBe(false);
991
+ expect(result.error).toContain('not found');
992
+ });
993
+
994
+ it('should truncate files exceeding 2000 lines when no limit specified', async () => {
995
+ // Generate a file with 3000 lines
996
+ const lines = Array.from({ length: 3000 }, (_, i) => `Line ${i + 1} content here`);
997
+ const largeContent = lines.join('\n');
998
+ mockReadFile.mockResolvedValue(largeContent as any);
999
+
1000
+ const result = await (tools.read_file as any).execute({
1001
+ file_path: '/test/large-file.ts',
1002
+ });
1003
+
1004
+ expect(result.success).toBe(true);
1005
+ expect(result.totalLines).toBe(3000);
1006
+ expect(result.truncated).toBe(true);
1007
+ expect(result.shownLines).toBe(2000);
1008
+ // Should only contain first 2000 lines
1009
+ const outputLines = result.content.split('\n');
1010
+ expect(outputLines.length).toBeLessThanOrEqual(2001); // 2000 lines + possible truncation msg
1011
+ });
1012
+
1013
+ it('should not truncate small files', async () => {
1014
+ const smallContent = 'line1\nline2\nline3';
1015
+ mockReadFile.mockResolvedValue(smallContent as any);
1016
+
1017
+ const result = await (tools.read_file as any).execute({
1018
+ file_path: '/test/small-file.ts',
1019
+ });
1020
+
1021
+ expect(result.success).toBe(true);
1022
+ expect(result.totalLines).toBe(3);
1023
+ expect(result.truncated).toBeUndefined();
1024
+ });
1025
+ });
1026
+
1027
+ describe('write_file', () => {
1028
+ let mockWriteFile: MockInstance<typeof import('fs').promises.writeFile>;
1029
+ let mockMkdir: MockInstance<typeof import('fs').promises.mkdir>;
1030
+
1031
+ beforeEach(async () => {
1032
+ const fs = await import('fs');
1033
+ mockWriteFile = vi.spyOn(fs.promises, 'writeFile') as any;
1034
+ mockMkdir = vi.spyOn(fs.promises, 'mkdir') as any;
1035
+ mockWriteFile.mockResolvedValue(undefined as any);
1036
+ mockMkdir.mockResolvedValue(undefined as any);
1037
+ });
1038
+
1039
+ afterEach(() => {
1040
+ vi.restoreAllMocks();
1041
+ });
1042
+
1043
+ it('should write file and return byte count', async () => {
1044
+ const result = await (tools.write_file as any).execute({
1045
+ file_path: '/test/new-file.ts',
1046
+ content: 'export const x = 1;\n',
1047
+ });
1048
+
1049
+ expect(result.success).toBe(true);
1050
+ expect(result.file).toBe('/test/new-file.ts');
1051
+ expect(result.bytes).toBeGreaterThan(0);
1052
+ expect(mockMkdir).toHaveBeenCalledWith('/test', { recursive: true });
1053
+ expect(mockWriteFile).toHaveBeenCalledWith('/test/new-file.ts', 'export const x = 1;\n', 'utf8');
1054
+ });
1055
+
1056
+ it('should handle write errors', async () => {
1057
+ mockWriteFile.mockRejectedValue(new Error('EACCES'));
1058
+
1059
+ const result = await (tools.write_file as any).execute({
1060
+ file_path: '/protected/file.ts',
1061
+ content: 'test',
1062
+ });
1063
+
1064
+ expect(result.success).toBe(false);
1065
+ expect(result.error).toContain('EACCES');
1066
+ });
1067
+ });
1068
+
1069
+ describe('read_file bash fallback', () => {
1070
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1071
+ const os = require('os') as typeof import('os');
1072
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1073
+ const fs = require('fs') as typeof import('fs');
1074
+ const tmpDir: string = os.tmpdir();
1075
+
1076
+ afterEach(() => {
1077
+ vi.restoreAllMocks();
1078
+ });
1079
+
1080
+ it('should return error when fs.readFile fails with non-ENOENT error', async () => {
1081
+ const tmpFile = `${tmpDir}/crewly-test-read-fallback-${Date.now()}.txt`;
1082
+ fs.writeFileSync(tmpFile, 'line one\nline two\n');
1083
+
1084
+ // Mock fs.promises.readFile to fail with EPERM (not ENOENT)
1085
+ const mockReadFile = vi.spyOn(fs.promises, 'readFile') as any;
1086
+ mockReadFile.mockRejectedValue(new Error('EPERM: operation not permitted'));
1087
+
1088
+ try {
1089
+ const result = await (tools.read_file as any).execute({
1090
+ file_path: tmpFile,
1091
+ });
1092
+
1093
+ expect(result.success).toBe(false);
1094
+ expect(result.error).toContain('EPERM');
1095
+ } finally {
1096
+ fs.unlinkSync(tmpFile);
1097
+ }
1098
+ });
1099
+
1100
+ it('should not fallback for ENOENT errors', async () => {
1101
+ const result = await (tools.read_file as any).execute({
1102
+ file_path: '/nonexistent/crewly-test-no-such-file.ts',
1103
+ });
1104
+
1105
+ expect(result.success).toBe(false);
1106
+ expect(result.error).toContain('not found');
1107
+ expect(result.fallback).toBeUndefined();
1108
+ });
1109
+
1110
+ it('should return original error when both fs and bash fail', async () => {
1111
+ // Mock readFile to fail with EPERM, use a non-existent file so bash cat also fails
1112
+ const mockReadFile = vi.spyOn(fs.promises, 'readFile') as any;
1113
+ mockReadFile.mockRejectedValue(new Error('EPERM'));
1114
+
1115
+ const result = await (tools.read_file as any).execute({
1116
+ file_path: '/nonexistent/crewly-test-both-fail.ts',
1117
+ });
1118
+
1119
+ expect(result.success).toBe(false);
1120
+ expect(result.error).toContain('EPERM');
1121
+ });
1122
+ });
1123
+
1124
+ describe('write_file bash fallback', () => {
1125
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1126
+ const os = require('os') as typeof import('os');
1127
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1128
+ const fs = require('fs') as typeof import('fs');
1129
+ const tmpDir: string = os.tmpdir();
1130
+
1131
+ afterEach(() => {
1132
+ vi.restoreAllMocks();
1133
+ });
1134
+
1135
+ it('should return error when fs.writeFile fails', async () => {
1136
+ const tmpFile = `${tmpDir}/crewly-test-write-fallback-${Date.now()}.txt`;
1137
+ const content = 'export const x = 1;\n';
1138
+
1139
+ // Mock writeFile to fail
1140
+ const mockWriteFile = vi.spyOn(fs.promises, 'writeFile') as any;
1141
+ mockWriteFile.mockRejectedValue(new Error('EPERM: operation not permitted'));
1142
+
1143
+ try {
1144
+ const result = await (tools.write_file as any).execute({
1145
+ file_path: tmpFile,
1146
+ content,
1147
+ });
1148
+
1149
+ expect(result.success).toBe(false);
1150
+ expect(result.error).toContain('EPERM');
1151
+ } finally {
1152
+ try { fs.unlinkSync(tmpFile); } catch { /* ignore */ }
1153
+ }
1154
+ });
1155
+
1156
+ it('should return original error when both fs and bash fail', async () => {
1157
+ // Mock writeFile to fail; use an invalid path so bash cat > also fails
1158
+ const mockWriteFile = vi.spyOn(fs.promises, 'writeFile') as any;
1159
+ const mockMkdir = vi.spyOn(fs.promises, 'mkdir') as any;
1160
+ mockWriteFile.mockRejectedValue(new Error('EACCES'));
1161
+ mockMkdir.mockResolvedValue(undefined as any);
1162
+
1163
+ const result = await (tools.write_file as any).execute({
1164
+ file_path: '/proc/nonexistent/crewly-test-both-fail.ts',
1165
+ content: 'test',
1166
+ });
1167
+
1168
+ expect(result.success).toBe(false);
1169
+ expect(result.error).toContain('EACCES');
1170
+ });
1171
+ });
1172
+
1173
+ // ===== Bug 1: Tilde path expansion tests =====
1174
+
1175
+ describe('read_file tilde expansion', () => {
1176
+ let mockReadFile: MockInstance<typeof import('fs').promises.readFile>;
1177
+
1178
+ beforeEach(async () => {
1179
+ const fs = await import('fs');
1180
+ mockReadFile = vi.spyOn(fs.promises, 'readFile') as any;
1181
+ });
1182
+
1183
+ afterEach(() => {
1184
+ vi.restoreAllMocks();
1185
+ });
1186
+
1187
+ it('should expand ~ to home directory', async () => {
1188
+ mockReadFile.mockResolvedValue('file content' as any);
1189
+
1190
+ await (tools.read_file as any).execute({
1191
+ file_path: '~/.crewly/skills/SKILLS_CATALOG.md',
1192
+ });
1193
+
1194
+ const calledPath = mockReadFile.mock.calls[0][0] as string;
1195
+ expect(calledPath).not.toContain('~');
1196
+ expect(calledPath).toMatch(/^\//); // absolute path
1197
+ expect(calledPath).toContain('.crewly/skills/SKILLS_CATALOG.md');
1198
+ });
1199
+
1200
+ it('should expand $HOME to home directory', async () => {
1201
+ mockReadFile.mockResolvedValue('file content' as any);
1202
+
1203
+ await (tools.read_file as any).execute({
1204
+ file_path: '$HOME/.config/test.json',
1205
+ });
1206
+
1207
+ const calledPath = mockReadFile.mock.calls[0][0] as string;
1208
+ expect(calledPath).not.toContain('$HOME');
1209
+ expect(calledPath).toMatch(/^\//);
1210
+ expect(calledPath).toContain('.config/test.json');
1211
+ });
1212
+
1213
+ it('should not modify absolute paths', async () => {
1214
+ mockReadFile.mockResolvedValue('file content' as any);
1215
+
1216
+ await (tools.read_file as any).execute({
1217
+ file_path: '/usr/local/test.txt',
1218
+ });
1219
+
1220
+ expect(mockReadFile).toHaveBeenCalledWith('/usr/local/test.txt', 'utf8');
1221
+ });
1222
+ });
1223
+
1224
+ describe('edit_file tilde expansion', () => {
1225
+ let mockReadFile: MockInstance<typeof import('fs').promises.readFile>;
1226
+ let mockWriteFile: MockInstance<typeof import('fs').promises.writeFile>;
1227
+
1228
+ beforeEach(async () => {
1229
+ const fs = await import('fs');
1230
+ mockReadFile = vi.spyOn(fs.promises, 'readFile') as any;
1231
+ mockWriteFile = vi.spyOn(fs.promises, 'writeFile') as any;
1232
+ });
1233
+
1234
+ afterEach(() => {
1235
+ vi.restoreAllMocks();
1236
+ });
1237
+
1238
+ it('should expand ~ in edit_file path', async () => {
1239
+ mockReadFile.mockResolvedValue('old content' as any);
1240
+ mockWriteFile.mockResolvedValue(undefined as any);
1241
+
1242
+ await (tools.edit_file as any).execute({
1243
+ file_path: '~/test.ts',
1244
+ old_string: 'old content',
1245
+ new_string: 'new content',
1246
+ replace_all: false,
1247
+ });
1248
+
1249
+ const readPath = mockReadFile.mock.calls[0][0] as string;
1250
+ expect(readPath).not.toContain('~');
1251
+ expect(readPath).toMatch(/^\//);
1252
+ });
1253
+ });
1254
+
1255
+ // ===== Bug 3: New tool tests =====
1256
+
1257
+ describe('register_self', () => {
1258
+ it('should register agent with the backend', async () => {
1259
+ mockClient.post.mockResolvedValue({
1260
+ success: true,
1261
+ data: { sessionName: 'crewly-orc', status: 'active' },
1262
+ status: 200,
1263
+ });
1264
+
1265
+ const result = await (tools.register_self as any).execute({
1266
+ role: 'developer',
1267
+ });
1268
+
1269
+ expect(result.sessionName).toBe('crewly-orc');
1270
+ expect(result.status).toBe('active');
1271
+ expect(mockClient.post).toHaveBeenCalledWith('/teams/members/register', {
1272
+ role: 'developer',
1273
+ sessionName: 'crewly-orc',
1274
+ });
1275
+ });
1276
+
1277
+ it('should return error on failure', async () => {
1278
+ mockClient.post.mockResolvedValue({
1279
+ success: false,
1280
+ error: 'Agent not found',
1281
+ status: 404,
1282
+ });
1283
+
1284
+ const result = await (tools.register_self as any).execute({
1285
+ role: 'developer',
1286
+ });
1287
+
1288
+ expect(result.error).toBe('Agent not found');
1289
+ });
1290
+ });
1291
+
1292
+ describe('get_project_overview', () => {
1293
+ it('should return all projects', async () => {
1294
+ const projects = [{ name: 'crewly', path: '/path' }];
1295
+ mockClient.get.mockResolvedValue({ success: true, data: projects, status: 200 });
1296
+
1297
+ const result = await (tools.get_project_overview as any).execute({});
1298
+
1299
+ expect(result).toEqual(projects);
1300
+ expect(mockClient.get).toHaveBeenCalledWith('/projects');
1301
+ });
1302
+
1303
+ it('should return error on failure', async () => {
1304
+ mockClient.get.mockResolvedValue({ success: false, error: 'Server error', status: 500 });
1305
+
1306
+ const result = await (tools.get_project_overview as any).execute({});
1307
+
1308
+ expect(result.error).toBe('Server error');
1309
+ });
1310
+ });
1311
+
1312
+ describe('report_status', () => {
1313
+ it('should send status via chat API with formatted message', async () => {
1314
+ mockClient.post.mockResolvedValue({
1315
+ success: true,
1316
+ data: { acknowledged: true },
1317
+ status: 200,
1318
+ });
1319
+
1320
+ const result = await (tools.report_status as any).execute({
1321
+ status: 'done',
1322
+ summary: 'Task completed',
1323
+ });
1324
+
1325
+ expect(result.acknowledged).toBe(true);
1326
+ expect(mockClient.post).toHaveBeenCalledWith('/chat/agent-response', {
1327
+ content: '[DONE] Agent crewly-orc: Task completed',
1328
+ senderName: 'crewly-orc',
1329
+ senderType: 'agent',
1330
+ });
1331
+ });
1332
+
1333
+ // V3-only as of spec 2026-05-06-task-management-v1-deprecation.md.
1334
+ // Auto-complete now resolves the agent's running WI from the pool and
1335
+ // calls `/task-pool/complete/:id`. Replaces v1 `/task-management/complete-by-session`.
1336
+ //
1337
+ // Hygiene #4 (2026-05-09): the body shape is the canonical
1338
+ // `{agentId, result:{summary}}` required by task-pool.controller.ts
1339
+ // `completeItem`. `agentId` here is the session whose status=done
1340
+ // message triggered this auto-complete path.
1341
+ it('should auto-complete the running WorkItem when status is done — canonical body shape', async () => {
1342
+ mockClient.post.mockResolvedValue({ success: true, data: { acknowledged: true }, status: 200 });
1343
+ mockClient.get.mockResolvedValueOnce({
1344
+ success: true,
1345
+ data: { workItems: [{ id: 'wi-running-1' }] },
1346
+ status: 200,
1347
+ });
1348
+
1349
+ await (tools.report_status as any).execute({
1350
+ status: 'done',
1351
+ summary: 'Feature implemented',
1352
+ });
1353
+
1354
+ expect(mockClient.get).toHaveBeenCalledWith(
1355
+ expect.stringMatching(/^\/task-pool\/items\?status=running&target=/),
1356
+ );
1357
+ // Canonical body shape per Hygiene #4 — `{agentId, result:{summary}}`.
1358
+ // The `crewly-orc` literal here is the createTools sessionName arg
1359
+ // used in the test fixture (see top-of-file `createTools(mockClient, 'crewly-orc', ...)`).
1360
+ expect(mockClient.post).toHaveBeenCalledWith(
1361
+ '/task-pool/complete/wi-running-1',
1362
+ { agentId: 'crewly-orc', result: { summary: 'Feature implemented' } },
1363
+ );
1364
+ });
1365
+
1366
+ it('should not auto-complete tasks when status is in_progress', async () => {
1367
+ mockClient.post.mockResolvedValue({ success: true, data: { acknowledged: true }, status: 200 });
1368
+
1369
+ await (tools.report_status as any).execute({
1370
+ status: 'in_progress',
1371
+ summary: 'Working on it',
1372
+ });
1373
+
1374
+ expect(mockClient.post).not.toHaveBeenCalledWith(
1375
+ expect.stringMatching(/^\/task-pool\/complete\//),
1376
+ expect.anything(),
1377
+ );
1378
+ });
1379
+ });
1380
+
1381
+ // ===== F13: Autonomous Context Compaction =====
1382
+
1383
+ describe('compact_memory', () => {
1384
+ it('should call onCompactMemory callback when available', async () => {
1385
+ const mockCompact = vi.fn<() => Promise<CompactionResult>>().mockResolvedValue({
1386
+ compacted: true,
1387
+ messagesBefore: 50,
1388
+ messagesAfter: 11,
1389
+ });
1390
+ const callbacks: ToolCallbacks = { onCompactMemory: mockCompact };
1391
+ const toolsWithCallbacks = createTools(mockClient, 'crewly-orc', '/test/project', callbacks);
1392
+
1393
+ const result = await (toolsWithCallbacks.compact_memory as any).execute({});
1394
+
1395
+ expect(result.success).toBe(true);
1396
+ expect(result.compacted).toBe(true);
1397
+ expect(result.messagesBefore).toBe(50);
1398
+ expect(result.messagesAfter).toBe(11);
1399
+ expect(mockCompact).toHaveBeenCalledTimes(1);
1400
+ });
1401
+
1402
+ it('should return error when no callback configured', async () => {
1403
+ const result = await (tools.compact_memory as any).execute({});
1404
+
1405
+ expect(result.success).toBe(false);
1406
+ expect(result.error).toContain('not available');
1407
+ });
1408
+
1409
+ it('should pass through skipped compaction result', async () => {
1410
+ const mockCompact = vi.fn<() => Promise<CompactionResult>>().mockResolvedValue({
1411
+ compacted: false,
1412
+ messagesBefore: 5,
1413
+ messagesAfter: 5,
1414
+ reason: 'Too few messages to compact',
1415
+ });
1416
+ const callbacks: ToolCallbacks = { onCompactMemory: mockCompact };
1417
+ const toolsWithCallbacks = createTools(mockClient, 'crewly-orc', '/test/project', callbacks);
1418
+
1419
+ const result = await (toolsWithCallbacks.compact_memory as any).execute({});
1420
+
1421
+ expect(result.success).toBe(false);
1422
+ expect(result.reason).toContain('Too few');
1423
+ });
1424
+ });
1425
+
1426
+ describe('get_context_budget', () => {
1427
+ it('should return budget status when callback is configured', async () => {
1428
+ const mockBudget = vi.fn<any>().mockReturnValue({
1429
+ totalTokensUsed: 50000,
1430
+ contextWindowSize: 200000,
1431
+ usagePercent: 0.25,
1432
+ level: 'normal',
1433
+ messageCount: 20,
1434
+ compactionPending: false,
1435
+ summary: '25.0% of context budget used (50,000/200,000 tokens, 20 messages)',
1436
+ });
1437
+ const callbacks: ToolCallbacks = { onGetContextBudget: mockBudget };
1438
+ const toolsWithCallbacks = createTools(mockClient, 'crewly-orc', '/test/project', callbacks);
1439
+
1440
+ const result = await (toolsWithCallbacks.get_context_budget as any).execute({});
1441
+
1442
+ expect(result.success).toBe(true);
1443
+ expect(result.totalTokensUsed).toBe(50000);
1444
+ expect(result.contextWindowSize).toBe(200000);
1445
+ expect(result.usagePercent).toBe(0.25);
1446
+ expect(result.level).toBe('normal');
1447
+ expect(mockBudget).toHaveBeenCalledTimes(1);
1448
+ });
1449
+
1450
+ it('should return error when no callback configured', async () => {
1451
+ const result = await (tools.get_context_budget as any).execute({});
1452
+
1453
+ expect(result.success).toBe(false);
1454
+ expect(result.error).toContain('not available');
1455
+ });
1456
+
1457
+ it('should return warning level when approaching threshold', async () => {
1458
+ const mockBudget = vi.fn<any>().mockReturnValue({
1459
+ totalTokensUsed: 140000,
1460
+ contextWindowSize: 200000,
1461
+ usagePercent: 0.7,
1462
+ level: 'warning',
1463
+ messageCount: 80,
1464
+ compactionPending: false,
1465
+ summary: '70.0% of context budget used — WARNING: approaching compaction threshold',
1466
+ });
1467
+ const callbacks: ToolCallbacks = { onGetContextBudget: mockBudget };
1468
+ const toolsWithCallbacks = createTools(mockClient, 'crewly-orc', '/test/project', callbacks);
1469
+
1470
+ const result = await (toolsWithCallbacks.get_context_budget as any).execute({});
1471
+
1472
+ expect(result.success).toBe(true);
1473
+ expect(result.level).toBe('warning');
1474
+ expect(result.summary).toContain('WARNING');
1475
+ });
1476
+
1477
+ it('should be classified as safe', () => {
1478
+ expect(TOOL_SENSITIVITY.get_context_budget).toBe('safe');
1479
+ });
1480
+ });
1481
+
1482
+ // ===== F27: Security Audit Trail & Hardening =====
1483
+
1484
+ describe('get_audit_log', () => {
1485
+ it('should return error when no callback configured', async () => {
1486
+ const result = await (tools.get_audit_log as any).execute({
1487
+ limit: 20,
1488
+ sensitivity: 'destructive',
1489
+ });
1490
+
1491
+ expect(result.success).toBe(false);
1492
+ expect(result.error).toContain('not available');
1493
+ });
1494
+
1495
+ it('should return actual audit entries via onGetAuditLog callback', async () => {
1496
+ const mockEntries: AuditEntry[] = [
1497
+ { timestamp: '2026-01-01T00:00:00Z', toolName: 'edit_file', sensitivity: 'destructive', args: {}, success: true, durationMs: 10 },
1498
+ { timestamp: '2026-01-01T00:01:00Z', toolName: 'get_team_status', sensitivity: 'safe', args: {}, success: true, durationMs: 5 },
1499
+ ];
1500
+ const callbacks: ToolCallbacks = {
1501
+ onGetAuditLog: (filters: AuditLogFilters) => {
1502
+ let entries = [...mockEntries];
1503
+ if (filters.sensitivity) entries = entries.filter(e => e.sensitivity === filters.sensitivity);
1504
+ if (filters.toolName) entries = entries.filter(e => e.toolName === filters.toolName);
1505
+ return entries.slice(0, filters.limit);
1506
+ },
1507
+ };
1508
+ const toolsWithAudit = createTools(mockClient, 'crewly-orc', '/test/project', callbacks);
1509
+
1510
+ const result = await (toolsWithAudit.get_audit_log as any).execute({
1511
+ limit: 20,
1512
+ sensitivity: 'destructive',
1513
+ });
1514
+
1515
+ expect(result.success).toBe(true);
1516
+ expect(result.totalEntries).toBe(1);
1517
+ expect(result.entries[0].toolName).toBe('edit_file');
1518
+ expect(result.filters.limit).toBe(20);
1519
+ expect(result.filters.sensitivity).toBe('destructive');
1520
+ });
1521
+
1522
+ it('should use defaults when no filters provided', async () => {
1523
+ const callbacks: ToolCallbacks = {
1524
+ onGetAuditLog: () => [],
1525
+ };
1526
+ const toolsWithAudit = createTools(mockClient, 'crewly-orc', '/test/project', callbacks);
1527
+
1528
+ const result = await (toolsWithAudit.get_audit_log as any).execute({});
1529
+
1530
+ expect(result.success).toBe(true);
1531
+ expect(result.filters.limit).toBe(50);
1532
+ expect(result.filters.sensitivity).toBe('all');
1533
+ expect(result.filters.toolName).toBe('all');
1534
+ });
1535
+ });
1536
+
1537
+ describe('TOOL_SENSITIVITY', () => {
1538
+ it('should classify read-only tools as safe', () => {
1539
+ expect(TOOL_SENSITIVITY.get_agent_status).toBe('safe');
1540
+ expect(TOOL_SENSITIVITY.get_team_status).toBe('safe');
1541
+ expect(TOOL_SENSITIVITY.get_agent_logs).toBe('safe');
1542
+ expect(TOOL_SENSITIVITY.heartbeat).toBe('safe');
1543
+ expect(TOOL_SENSITIVITY.get_tasks).toBe('safe');
1544
+ expect(TOOL_SENSITIVITY.read_file).toBe('safe');
1545
+ expect(TOOL_SENSITIVITY.recall_memory).toBe('safe');
1546
+ expect(TOOL_SENSITIVITY.get_project_overview).toBe('safe');
1547
+ expect(TOOL_SENSITIVITY.get_scheduled_checks).toBe('safe');
1548
+ });
1549
+
1550
+ it('should classify communication tools as sensitive', () => {
1551
+ expect(TOOL_SENSITIVITY.delegate_task).toBe('sensitive');
1552
+ expect(TOOL_SENSITIVITY.send_message).toBe('sensitive');
1553
+ expect(TOOL_SENSITIVITY.reply_slack).toBe('sensitive');
1554
+ expect(TOOL_SENSITIVITY.broadcast).toBe('sensitive');
1555
+ expect(TOOL_SENSITIVITY.report_status).toBe('sensitive');
1556
+ expect(TOOL_SENSITIVITY.remember).toBe('sensitive');
1557
+ });
1558
+
1559
+ it('should classify high-impact tools as destructive', () => {
1560
+ expect(TOOL_SENSITIVITY.start_agent).toBe('destructive');
1561
+ expect(TOOL_SENSITIVITY.stop_agent).toBe('destructive');
1562
+ expect(TOOL_SENSITIVITY.handle_agent_failure).toBe('destructive');
1563
+ expect(TOOL_SENSITIVITY.edit_file).toBe('destructive');
1564
+ expect(TOOL_SENSITIVITY.write_file).toBe('destructive');
1565
+ });
1566
+
1567
+ it('should have classifications for all tool names', () => {
1568
+ const allToolNames = getToolNames();
1569
+ for (const name of allToolNames) {
1570
+ expect(TOOL_SENSITIVITY[name]).toBeDefined();
1571
+ }
1572
+ });
1573
+ });
1574
+
1575
+ describe('audit wrapping', () => {
1576
+ it('should call onAuditLog for each tool invocation', async () => {
1577
+ const auditEntries: AuditEntry[] = [];
1578
+ const callbacks: ToolCallbacks = {
1579
+ onAuditLog: (entry) => auditEntries.push(entry),
1580
+ };
1581
+ const toolsWithAudit = createTools(mockClient, 'crewly-orc', '/test/project', callbacks);
1582
+
1583
+ mockClient.get.mockResolvedValue({ success: true, data: [], status: 200 });
1584
+ await (toolsWithAudit.get_team_status as any).execute({});
1585
+
1586
+ expect(auditEntries).toHaveLength(1);
1587
+ expect(auditEntries[0].toolName).toBe('get_team_status');
1588
+ expect(auditEntries[0].sensitivity).toBe('safe');
1589
+ expect(auditEntries[0].success).toBe(true);
1590
+ expect(auditEntries[0].durationMs).toBeGreaterThanOrEqual(0);
1591
+ });
1592
+
1593
+ it('should record failure in audit log', async () => {
1594
+ const auditEntries: AuditEntry[] = [];
1595
+ const callbacks: ToolCallbacks = {
1596
+ onAuditLog: (entry) => auditEntries.push(entry),
1597
+ };
1598
+ const toolsWithAudit = createTools(mockClient, 'crewly-orc', '/test/project', callbacks);
1599
+
1600
+ mockClient.post.mockResolvedValue({ success: false, error: 'Not found', status: 404 });
1601
+ await (toolsWithAudit.send_message as any).execute({
1602
+ sessionName: 'agent-sam',
1603
+ message: 'Hello',
1604
+ force: false,
1605
+ });
1606
+
1607
+ expect(auditEntries).toHaveLength(1);
1608
+ expect(auditEntries[0].toolName).toBe('send_message');
1609
+ expect(auditEntries[0].success).toBe(false);
1610
+ expect(auditEntries[0].error).toContain('Not found');
1611
+ });
1612
+
1613
+ it('should record audit on tool exception', async () => {
1614
+ const auditEntries: AuditEntry[] = [];
1615
+ const callbacks: ToolCallbacks = {
1616
+ onAuditLog: (entry) => auditEntries.push(entry),
1617
+ };
1618
+ const toolsWithAudit = createTools(mockClient, 'crewly-orc', '/test/project', callbacks);
1619
+
1620
+ mockClient.get.mockRejectedValue(new Error('Network failure'));
1621
+
1622
+ await expect(
1623
+ (toolsWithAudit.get_team_status as any).execute({}),
1624
+ ).rejects.toThrow('Network failure');
1625
+
1626
+ expect(auditEntries).toHaveLength(1);
1627
+ expect(auditEntries[0].success).toBe(false);
1628
+ expect(auditEntries[0].error).toBe('Network failure');
1629
+ });
1630
+
1631
+ it('should redact sensitive fields in audit args', async () => {
1632
+ const auditEntries: AuditEntry[] = [];
1633
+ const callbacks: ToolCallbacks = {
1634
+ onAuditLog: (entry) => auditEntries.push(entry),
1635
+ };
1636
+ const toolsWithAudit = createTools(mockClient, 'crewly-orc', '/test/project', callbacks);
1637
+
1638
+ // Use send_message which has simple fields; inject args that include a sensitive-named key
1639
+ // The audit wrapper sanitizes the raw args object before logging
1640
+ mockClient.post.mockResolvedValue({ success: true, data: {}, status: 200 });
1641
+ await (toolsWithAudit.send_message as any).execute({
1642
+ sessionName: 'agent-sam',
1643
+ message: 'Hello',
1644
+ force: false,
1645
+ authorization_token: 'bearer-secret-123',
1646
+ });
1647
+
1648
+ expect(auditEntries).toHaveLength(1);
1649
+ // authorization_token contains 'token' which is a sensitive key
1650
+ expect(auditEntries[0].args.authorization_token).toBe('[REDACTED]');
1651
+ expect(auditEntries[0].args.message).toBe('Hello');
1652
+ });
1653
+
1654
+ it('should truncate long argument values', async () => {
1655
+ const auditEntries: AuditEntry[] = [];
1656
+ const callbacks: ToolCallbacks = {
1657
+ onAuditLog: (entry) => auditEntries.push(entry),
1658
+ };
1659
+ const toolsWithAudit = createTools(mockClient, 'crewly-orc', '/test/project', callbacks);
1660
+
1661
+ mockClient.post.mockResolvedValue({ success: true, data: {}, status: 200 });
1662
+ await (toolsWithAudit.remember as any).execute({
1663
+ content: 'x'.repeat(1000),
1664
+ category: 'pattern',
1665
+ scope: 'project',
1666
+ });
1667
+
1668
+ expect(auditEntries).toHaveLength(1);
1669
+ const contentArg = auditEntries[0].args.content as string;
1670
+ // sanitizeArgs truncates at 2000 chars, so 1000-char input should NOT be truncated
1671
+ expect(contentArg).toBe('x'.repeat(1000));
1672
+ });
1673
+
1674
+ it('should truncate argument values exceeding 2000 chars', async () => {
1675
+ const auditEntries: AuditEntry[] = [];
1676
+ const callbacks: ToolCallbacks = {
1677
+ onAuditLog: (entry) => auditEntries.push(entry),
1678
+ };
1679
+ const toolsWithAudit = createTools(mockClient, 'crewly-orc', '/test/project', callbacks);
1680
+
1681
+ mockClient.post.mockResolvedValue({ success: true, data: {}, status: 200 });
1682
+ await (toolsWithAudit.remember as any).execute({
1683
+ content: 'x'.repeat(3000),
1684
+ category: 'pattern',
1685
+ scope: 'project',
1686
+ });
1687
+
1688
+ expect(auditEntries).toHaveLength(1);
1689
+ const contentArg = auditEntries[0].args.content as string;
1690
+ expect(contentArg.length).toBeLessThan(2100);
1691
+ expect(contentArg).toContain('[truncated]');
1692
+ });
1693
+
1694
+ it('should assign sensitivity to all created tools', () => {
1695
+ for (const [name, tool] of Object.entries(tools)) {
1696
+ expect((tool as any).sensitivity).toBeDefined();
1697
+ expect(['safe', 'sensitive', 'destructive']).toContain((tool as any).sensitivity);
1698
+ }
1699
+ });
1700
+ });
1701
+
1702
+ // ===== F27: Approval Mode & Blocked Tools Enforcement =====
1703
+
1704
+ describe('approval mode enforcement', () => {
1705
+ it('should block tool when onCheckApproval returns not allowed (blocked)', async () => {
1706
+ const auditEntries: AuditEntry[] = [];
1707
+ const callbacks: ToolCallbacks = {
1708
+ onAuditLog: (entry) => auditEntries.push(entry),
1709
+ onCheckApproval: (toolName) => {
1710
+ if (toolName === 'stop_agent') {
1711
+ return { allowed: false, blocked: true, reason: "Tool 'stop_agent' is blocked by security policy" };
1712
+ }
1713
+ return { allowed: true };
1714
+ },
1715
+ };
1716
+ const toolsWithApproval = createTools(mockClient, 'crewly-orc', '/test/project', callbacks);
1717
+
1718
+ const result = await (toolsWithApproval.stop_agent as any).execute({
1719
+ teamId: 'team-1',
1720
+ memberId: 'member-1',
1721
+ });
1722
+
1723
+ expect(result.success).toBe(false);
1724
+ expect(result.blocked).toBe(true);
1725
+ expect(result.error).toContain('blocked');
1726
+ // Should NOT have called the API
1727
+ expect(mockClient.post).not.toHaveBeenCalled();
1728
+ // Should still log the blocked attempt
1729
+ expect(auditEntries).toHaveLength(1);
1730
+ expect(auditEntries[0].success).toBe(false);
1731
+ expect(auditEntries[0].error).toContain('blocked');
1732
+ });
1733
+
1734
+ it('should block tool when sensitivity requires approval', async () => {
1735
+ const auditEntries: AuditEntry[] = [];
1736
+ const callbacks: ToolCallbacks = {
1737
+ onAuditLog: (entry) => auditEntries.push(entry),
1738
+ onCheckApproval: (_toolName, sensitivity) => {
1739
+ if (sensitivity === 'destructive') {
1740
+ return { allowed: false, blocked: false, reason: 'Destructive tools require approval' };
1741
+ }
1742
+ return { allowed: true };
1743
+ },
1744
+ };
1745
+ const toolsWithApproval = createTools(mockClient, 'crewly-orc', '/test/project', callbacks);
1746
+
1747
+ const result = await (toolsWithApproval.edit_file as any).execute({
1748
+ file_path: '/test/file.ts',
1749
+ old_string: 'foo',
1750
+ new_string: 'bar',
1751
+ replace_all: false,
1752
+ });
1753
+
1754
+ expect(result.success).toBe(false);
1755
+ expect(result.requiresApproval).toBe(true);
1756
+ expect(result.blocked).toBe(false);
1757
+ expect(result.error).toContain('approval');
1758
+ });
1759
+
1760
+ it('should allow safe tools when only destructive requires approval', async () => {
1761
+ const callbacks: ToolCallbacks = {
1762
+ onCheckApproval: (_toolName, sensitivity) => {
1763
+ if (sensitivity === 'destructive') {
1764
+ return { allowed: false, blocked: false, reason: 'Requires approval' };
1765
+ }
1766
+ return { allowed: true };
1767
+ },
1768
+ };
1769
+ const toolsWithApproval = createTools(mockClient, 'crewly-orc', '/test/project', callbacks);
1770
+
1771
+ mockClient.get.mockResolvedValue({ success: true, data: [], status: 200 });
1772
+ const result = await (toolsWithApproval.get_team_status as any).execute({});
1773
+
1774
+ expect(result).toEqual([]);
1775
+ expect(mockClient.get).toHaveBeenCalled();
1776
+ });
1777
+
1778
+ it('should work without onCheckApproval callback (no enforcement)', async () => {
1779
+ const callbacks: ToolCallbacks = {
1780
+ onAuditLog: () => {},
1781
+ };
1782
+ const toolsNoApproval = createTools(mockClient, 'crewly-orc', '/test/project', callbacks);
1783
+
1784
+ mockClient.post.mockResolvedValue({ success: true, data: { stopped: true }, status: 200 });
1785
+ const result = await (toolsNoApproval.stop_agent as any).execute({
1786
+ teamId: 'team-1',
1787
+ memberId: 'member-1',
1788
+ });
1789
+
1790
+ expect(result.stopped).toBe(true);
1791
+ });
1792
+
1793
+ it('should enforce approval without audit logger', async () => {
1794
+ const callbacks: ToolCallbacks = {
1795
+ onCheckApproval: (toolName) => {
1796
+ if (toolName === 'write_file') {
1797
+ return { allowed: false, blocked: true, reason: 'Blocked' };
1798
+ }
1799
+ return { allowed: true };
1800
+ },
1801
+ };
1802
+ const toolsApprovalOnly = createTools(mockClient, 'crewly-orc', '/test/project', callbacks);
1803
+
1804
+ const result = await (toolsApprovalOnly.write_file as any).execute({
1805
+ file_path: '/test/file.ts',
1806
+ content: 'hello',
1807
+ });
1808
+
1809
+ expect(result.success).toBe(false);
1810
+ expect(result.blocked).toBe(true);
1811
+ });
1812
+ });
1813
+
1814
+ describe('get_scheduled_checks', () => {
1815
+ it('should fetch all scheduled checks', async () => {
1816
+ mockClient.get.mockResolvedValue({ success: true, data: [{ id: 'chk-1', isRecurring: true }], status: 200 });
1817
+
1818
+ const result = await (tools.get_scheduled_checks as any).execute({});
1819
+
1820
+ expect(mockClient.get).toHaveBeenCalledWith('/schedule');
1821
+ expect(result).toEqual([{ id: 'chk-1', isRecurring: true }]);
1822
+ });
1823
+
1824
+ it('should filter by session when provided', async () => {
1825
+ mockClient.get.mockResolvedValue({ success: true, data: [], status: 200 });
1826
+
1827
+ await (tools.get_scheduled_checks as any).execute({ session: 'agent-sam' });
1828
+
1829
+ expect(mockClient.get).toHaveBeenCalledWith('/schedule?session=agent-sam');
1830
+ });
1831
+
1832
+ it('should return error on failure', async () => {
1833
+ mockClient.get.mockResolvedValue({ success: false, error: 'Server error', status: 500 });
1834
+
1835
+ const result = await (tools.get_scheduled_checks as any).execute({});
1836
+
1837
+ expect(result).toEqual({ error: 'Server error' });
1838
+ });
1839
+ });
1840
+
1841
+ describe('schedule_check with taskId', () => {
1842
+ it('should pass taskId to the schedule API', async () => {
1843
+ mockClient.post.mockResolvedValue({ success: true, data: { checkId: 'sched-3' }, status: 201 });
1844
+
1845
+ await (tools.schedule_check as any).execute({
1846
+ minutes: 5,
1847
+ message: 'Check Sam progress',
1848
+ recurring: true,
1849
+ taskId: 'task-42',
1850
+ });
1851
+
1852
+ expect(mockClient.post).toHaveBeenCalledWith('/schedule', expect.objectContaining({
1853
+ taskId: 'task-42',
1854
+ isRecurring: true,
1855
+ intervalMinutes: 5,
1856
+ }));
1857
+ });
1858
+
1859
+ it('should not include taskId when not provided', async () => {
1860
+ mockClient.post.mockResolvedValue({ success: true, data: { checkId: 'sched-4' }, status: 201 });
1861
+
1862
+ await (tools.schedule_check as any).execute({
1863
+ minutes: 10,
1864
+ message: 'Check progress',
1865
+ recurring: false,
1866
+ });
1867
+
1868
+ const postArgs = mockClient.post.mock.calls[0][1] as Record<string, unknown>;
1869
+ expect(postArgs.taskId).toBeUndefined();
1870
+ });
1871
+ });
1872
+
1873
+ describe('complete_task with check cleanup', () => {
1874
+ it('should cancel recurring checks for the completing agent', async () => {
1875
+ mockClient.post.mockResolvedValue({ success: true, data: { completed: true }, status: 200 });
1876
+ mockClient.get.mockResolvedValue({
1877
+ success: true,
1878
+ data: [
1879
+ { id: 'chk-1', isRecurring: true },
1880
+ { id: 'chk-2', isRecurring: false },
1881
+ { id: 'chk-3', isRecurring: true },
1882
+ ],
1883
+ status: 200,
1884
+ });
1885
+ mockClient.delete.mockResolvedValue({ success: true, data: {}, status: 200 });
1886
+
1887
+ const result = await (tools.complete_task as any).execute({
1888
+ workItemId: 'wi-1',
1889
+ sessionName: 'agent-sam',
1890
+ summary: 'Done',
1891
+ });
1892
+
1893
+ expect(result.completed).toBe(true);
1894
+ expect(result.cancelledChecks).toBe(2); // Only recurring checks
1895
+ expect(mockClient.delete).toHaveBeenCalledWith('/schedule/chk-1');
1896
+ expect(mockClient.delete).toHaveBeenCalledWith('/schedule/chk-3');
1897
+ expect(mockClient.delete).not.toHaveBeenCalledWith('/schedule/chk-2');
1898
+ });
1899
+
1900
+ // Hygiene #4 (2026-05-09): assert the canonical body shape on the
1901
+ // /task-pool/complete POST. Prior shape `{summary}` 400'd because the
1902
+ // controller looks at `result.summary` and requires non-empty `agentId`.
1903
+ it('emits canonical body shape `{agentId, result:{summary}}`', async () => {
1904
+ mockClient.post.mockResolvedValue({ success: true, data: { completed: true }, status: 200 });
1905
+ mockClient.get.mockResolvedValue({ success: true, data: [], status: 200 });
1906
+
1907
+ await (tools.complete_task as any).execute({
1908
+ workItemId: 'wi-shape-1',
1909
+ sessionName: 'agent-quinn',
1910
+ summary: 'Implemented hygiene #4',
1911
+ });
1912
+
1913
+ expect(mockClient.post).toHaveBeenCalledWith(
1914
+ '/task-pool/complete/wi-shape-1',
1915
+ { agentId: 'agent-quinn', result: { summary: 'Implemented hygiene #4' } },
1916
+ );
1917
+ });
1918
+
1919
+ it('should still complete task even if check cleanup fails', async () => {
1920
+ mockClient.post.mockResolvedValue({ success: true, data: { completed: true }, status: 200 });
1921
+ mockClient.get.mockRejectedValue(new Error('Network error'));
1922
+
1923
+ const result = await (tools.complete_task as any).execute({
1924
+ absoluteTaskPath: '/tasks/task-1.md',
1925
+ sessionName: 'agent-sam',
1926
+ summary: 'Done',
1927
+ });
1928
+
1929
+ expect(result.completed).toBe(true);
1930
+ expect(result.cancelledChecks).toBe(0);
1931
+ });
1932
+ });
1933
+
1934
+ describe('WRITE_TOOLS', () => {
1935
+ it('should include all destructive and sensitive tools that modify state', () => {
1936
+ expect(WRITE_TOOLS).toContain('edit_file');
1937
+ expect(WRITE_TOOLS).toContain('write_file');
1938
+ expect(WRITE_TOOLS).toContain('start_agent');
1939
+ expect(WRITE_TOOLS).toContain('stop_agent');
1940
+ expect(WRITE_TOOLS).toContain('delegate_task');
1941
+ expect(WRITE_TOOLS).toContain('send_message');
1942
+ expect(WRITE_TOOLS).toContain('reply_slack');
1943
+ expect(WRITE_TOOLS).toContain('broadcast');
1944
+ });
1945
+
1946
+ it('should not include read-only tools', () => {
1947
+ expect(WRITE_TOOLS).not.toContain('get_agent_status');
1948
+ expect(WRITE_TOOLS).not.toContain('get_team_status');
1949
+ expect(WRITE_TOOLS).not.toContain('read_file');
1950
+ expect(WRITE_TOOLS).not.toContain('recall_memory');
1951
+ expect(WRITE_TOOLS).not.toContain('heartbeat');
1952
+ expect(WRITE_TOOLS).not.toContain('get_audit_log');
1953
+ expect(WRITE_TOOLS).not.toContain('compact_memory');
1954
+ });
1955
+
1956
+ it('should only contain valid tool names from the registry', () => {
1957
+ const validNames = getToolNames();
1958
+ for (const writeTool of WRITE_TOOLS) {
1959
+ expect(validNames).toContain(writeTool);
1960
+ }
1961
+ });
1962
+ });
1963
+
1964
+ describe('git_status', () => {
1965
+ it.skip('should parse git status output correctly (CJS require, ESM-incompatible)', async () => {
1966
+ vi.spyOn(require('child_process'), 'exec').mockImplementation((...args: unknown[]) => {
1967
+ const cmd = String(args[0]);
1968
+ const callback = args[args.length - 1] as Function;
1969
+ if (cmd.includes('rev-parse')) { callback(null, 'main\n', ''); return; }
1970
+ if (cmd.includes('status --porcelain')) { callback(null, 'M staged.ts\n M unstaged.ts\n?? new.ts\n', ''); return; }
1971
+ callback(null, '', '');
1972
+ });
1973
+
1974
+ const result = await (tools.git_status as any).execute({ projectPath: '/test/project' });
1975
+
1976
+ expect(result.success).toBe(true);
1977
+ expect(result.branch).toBe('main');
1978
+ expect(result.staged).toContain('staged.ts');
1979
+ expect(result.unstaged).toContain('unstaged.ts');
1980
+ expect(result.untracked).toContain('new.ts');
1981
+
1982
+ vi.restoreAllMocks();
1983
+ });
1984
+
1985
+ it.skip('should handle errors gracefully (CJS require)', async () => {
1986
+ vi.spyOn(require('child_process'), 'exec').mockImplementation((...args: unknown[]) => {
1987
+ const callback = args[args.length - 1] as Function;
1988
+ callback(new Error('not a git repository'));
1989
+ });
1990
+
1991
+ const result = await (tools.git_status as any).execute({ projectPath: '/not/a/repo' });
1992
+
1993
+ expect(result.success).toBe(false);
1994
+ expect(result.error).toContain('not a git repository');
1995
+
1996
+ vi.restoreAllMocks();
1997
+ });
1998
+ });
1999
+
2000
+ describe('git_diff', () => {
2001
+ it.skip('should return unstaged diff by default (CJS require)', async () => {
2002
+ vi.spyOn(require('child_process'), 'exec').mockImplementation((...args: unknown[]) => {
2003
+ const cmd = String(args[0]);
2004
+ const callback = args[args.length - 1] as Function;
2005
+ if (cmd === 'git diff') { callback(null, 'diff --git a/file.ts\n+added line\n', ''); return; }
2006
+ callback(null, '', '');
2007
+ });
2008
+
2009
+ const result = await (tools.git_diff as any).execute({ projectPath: '/test/project', staged: false });
2010
+
2011
+ expect(result.success).toBe(true);
2012
+ expect(result.diff).toContain('+added line');
2013
+ expect(result.truncated).toBe(false);
2014
+
2015
+ vi.restoreAllMocks();
2016
+ });
2017
+
2018
+ it.skip('should return staged diff when staged=true (CJS require)', async () => {
2019
+ vi.spyOn(require('child_process'), 'exec').mockImplementation((...args: unknown[]) => {
2020
+ const cmd = String(args[0]);
2021
+ const callback = args[args.length - 1] as Function;
2022
+ if (cmd === 'git diff --cached') { callback(null, 'staged diff output\n', ''); return; }
2023
+ callback(null, '', '');
2024
+ });
2025
+
2026
+ const result = await (tools.git_diff as any).execute({ projectPath: '/test/project', staged: true });
2027
+
2028
+ expect(result.success).toBe(true);
2029
+ expect(result.diff).toContain('staged diff output');
2030
+
2031
+ vi.restoreAllMocks();
2032
+ });
2033
+
2034
+ it.skip('should truncate long diffs to 5000 chars (CJS require)', async () => {
2035
+ const longDiff = 'x'.repeat(6000);
2036
+ vi.spyOn(require('child_process'), 'exec').mockImplementation((...args: unknown[]) => {
2037
+ const callback = args[args.length - 1] as Function;
2038
+ callback(null, longDiff, '');
2039
+ });
2040
+
2041
+ const result = await (tools.git_diff as any).execute({ projectPath: '/test/project', staged: false });
2042
+
2043
+ expect(result.success).toBe(true);
2044
+ expect(result.truncated).toBe(true);
2045
+ expect(result.diff.length).toBeLessThanOrEqual(5020);
2046
+ expect(result.totalLength).toBe(6000);
2047
+
2048
+ vi.restoreAllMocks();
2049
+ });
2050
+ });
2051
+
2052
+ describe('git_commit', () => {
2053
+ it.skip('should stage all and commit when no files specified (CJS require)', async () => {
2054
+ const calls: string[] = [];
2055
+ vi.spyOn(require('child_process'), 'exec').mockImplementation((...args: unknown[]) => {
2056
+ const cmd = String(args[0]);
2057
+ calls.push(cmd);
2058
+ const callback = args[args.length - 1] as Function;
2059
+ if (cmd.includes('rev-parse HEAD')) { callback(null, 'abc123def\n', ''); return; }
2060
+ callback(null, '', '');
2061
+ });
2062
+
2063
+ const result = await (tools.git_commit as any).execute({
2064
+ projectPath: '/test/project',
2065
+ message: 'feat: add feature',
2066
+ });
2067
+
2068
+ expect(result.success).toBe(true);
2069
+ expect(result.commitHash).toBe('abc123def');
2070
+ expect(calls).toContain('git add -A');
2071
+ expect(calls.some((c: string) => c.includes('git commit'))).toBe(true);
2072
+
2073
+ vi.restoreAllMocks();
2074
+ });
2075
+
2076
+ it.skip('should stage specific files when provided (CJS require)', async () => {
2077
+ const calls: string[] = [];
2078
+ vi.spyOn(require('child_process'), 'exec').mockImplementation((...args: unknown[]) => {
2079
+ const cmd = String(args[0]);
2080
+ calls.push(cmd);
2081
+ const callback = args[args.length - 1] as Function;
2082
+ if (cmd.includes('rev-parse HEAD')) { callback(null, 'def456\n', ''); return; }
2083
+ callback(null, '', '');
2084
+ });
2085
+
2086
+ const result = await (tools.git_commit as any).execute({
2087
+ projectPath: '/test/project',
2088
+ message: 'fix: bug',
2089
+ files: ['src/a.ts', 'src/b.ts'],
2090
+ });
2091
+
2092
+ expect(result.success).toBe(true);
2093
+ expect(result.commitHash).toBe('def456');
2094
+ expect(calls.some((c: string) => c.includes('git add') && c.includes('src/a.ts'))).toBe(true);
2095
+ expect(calls.some((c: string) => c.includes('git add') && c.includes('src/b.ts'))).toBe(true);
2096
+ expect(calls).not.toContain('git add -A');
2097
+
2098
+ vi.restoreAllMocks();
2099
+ });
2100
+
2101
+ it.skip('should handle commit errors gracefully (CJS require)', async () => {
2102
+ vi.spyOn(require('child_process'), 'exec').mockImplementation((...args: unknown[]) => {
2103
+ const cmd = String(args[0]);
2104
+ const callback = args[args.length - 1] as Function;
2105
+ if (cmd.includes('git commit')) { callback(new Error('nothing to commit')); return; }
2106
+ callback(null, '', '');
2107
+ });
2108
+
2109
+ const result = await (tools.git_commit as any).execute({
2110
+ projectPath: '/test/project',
2111
+ message: 'empty commit',
2112
+ });
2113
+
2114
+ expect(result.success).toBe(false);
2115
+ expect(result.error).toContain('nothing to commit');
2116
+
2117
+ vi.restoreAllMocks();
2118
+ });
2119
+
2120
+ it('should have sensitive classification', () => {
2121
+ expect(TOOL_SENSITIVITY.git_commit).toBe('sensitive');
2122
+ });
2123
+ });
2124
+
2125
+ describe('git tool sensitivity', () => {
2126
+ it('should classify git_status as safe', () => {
2127
+ expect(TOOL_SENSITIVITY.git_status).toBe('safe');
2128
+ });
2129
+
2130
+ it('should classify git_diff as safe', () => {
2131
+ expect(TOOL_SENSITIVITY.git_diff).toBe('safe');
2132
+ });
2133
+
2134
+ it('should classify git_commit as sensitive', () => {
2135
+ expect(TOOL_SENSITIVITY.git_commit).toBe('sensitive');
2136
+ });
2137
+ });
2138
+
2139
+ describe('getToolNames includes git tools', () => {
2140
+ it('should include all 3 git tools', () => {
2141
+ const names = getToolNames();
2142
+ expect(names).toContain('git_status');
2143
+ expect(names).toContain('git_diff');
2144
+ expect(names).toContain('git_commit');
2145
+ });
2146
+ });
2147
+
2148
+ describe('validateBashCommand', () => {
2149
+ let validateFn: typeof import('./tool-registry.js')['validateBashCommand'];
2150
+
2151
+ beforeEach(async () => {
2152
+ const mod = await import('./tool-registry.js');
2153
+ validateFn = mod.validateBashCommand;
2154
+ });
2155
+
2156
+ it('should allow safe commands', () => {
2157
+ expect(validateFn('ls -la')).toBeNull();
2158
+ expect(validateFn('npm run build')).toBeNull();
2159
+ expect(validateFn('git status')).toBeNull();
2160
+ expect(validateFn('cat package.json')).toBeNull();
2161
+ expect(validateFn('echo "hello world"')).toBeNull();
2162
+ expect(validateFn('node -e "console.log(1)"')).toBeNull();
2163
+ });
2164
+
2165
+ it('should block kill commands', () => {
2166
+ expect(validateFn('kill 1234')).not.toBeNull();
2167
+ expect(validateFn('kill -9 $$')).not.toBeNull();
2168
+ expect(validateFn('killall node')).not.toBeNull();
2169
+ expect(validateFn('pkill -f crewly')).not.toBeNull();
2170
+ });
2171
+
2172
+ it('should block system commands', () => {
2173
+ expect(validateFn('shutdown -h now')).not.toBeNull();
2174
+ expect(validateFn('reboot')).not.toBeNull();
2175
+ expect(validateFn('launchctl unload com.crewly')).not.toBeNull();
2176
+ expect(validateFn('systemctl stop crewly')).not.toBeNull();
2177
+ });
2178
+
2179
+ it('should block destructive disk commands', () => {
2180
+ expect(validateFn('mkfs /dev/sda1')).not.toBeNull();
2181
+ expect(validateFn('dd if=/dev/zero of=/dev/sda')).not.toBeNull();
2182
+ });
2183
+
2184
+ it('should allow rm for specific files (not root wipe)', () => {
2185
+ expect(validateFn('rm file.txt')).toBeNull();
2186
+ expect(validateFn('rm -rf ./dist')).toBeNull();
2187
+ expect(validateFn('rm -rf node_modules')).toBeNull();
2188
+ });
2189
+ });
2190
+
2191
+ describe('bash_exec tool', () => {
2192
+ let bashTools: ReturnType<typeof createTools>;
2193
+
2194
+ beforeEach(() => {
2195
+ // Use a real directory so spawnSync can set cwd
2196
+ bashTools = createTools(mockClient, 'crewly-orc', '/tmp');
2197
+ });
2198
+
2199
+ it('should execute simple commands successfully', async () => {
2200
+ const result = await bashTools.bash_exec.execute({ command: 'echo "hello"' }) as any;
2201
+ expect(result.success).toBe(true);
2202
+ expect(result.stdout).toContain('hello');
2203
+ });
2204
+
2205
+ it('should block dangerous commands', async () => {
2206
+ const result = await bashTools.bash_exec.execute({ command: 'kill 1234' }) as any;
2207
+ expect(result.success).toBe(false);
2208
+ expect(result.exitCode).toBe(126);
2209
+ expect(result.error).toContain('blocked');
2210
+ });
2211
+
2212
+ it('should handle command failure gracefully', async () => {
2213
+ const result = await bashTools.bash_exec.execute({ command: 'false' }) as any;
2214
+ expect(result.success).toBe(false);
2215
+ expect(result.exitCode).not.toBe(0);
2216
+ });
2217
+
2218
+ it('should respect timeout', async () => {
2219
+ const result = await bashTools.bash_exec.execute({ command: 'sleep 30', timeout: 1000 }) as any;
2220
+ expect(result.success).toBe(false);
2221
+ }, 10000);
2222
+
2223
+ it('should use process isolation (spawnSync, not execSync)', async () => {
2224
+ const result = await bashTools.bash_exec.execute({ command: 'echo $$' }) as any;
2225
+ expect(result.success).toBe(true);
2226
+ const childPid = parseInt(result.stdout.trim(), 10);
2227
+ expect(childPid).not.toBe(process.pid);
2228
+ });
2229
+ });
2230
+
2231
+ // ===== globToRegExp unit tests =====
2232
+
2233
+ describe('globToRegExp', () => {
2234
+ it('should match simple wildcards', () => {
2235
+ const re = globToRegExp('*.ts');
2236
+ expect(re.test('foo.ts')).toBe(true);
2237
+ expect(re.test('bar.js')).toBe(false);
2238
+ expect(re.test('src/foo.ts')).toBe(false); // * should not match /
2239
+ });
2240
+
2241
+ it('should match ** for recursive paths', () => {
2242
+ const re = globToRegExp('**/*.ts');
2243
+ expect(re.test('foo.ts')).toBe(true);
2244
+ expect(re.test('src/foo.ts')).toBe(true);
2245
+ expect(re.test('src/deep/nested/foo.ts')).toBe(true);
2246
+ expect(re.test('foo.js')).toBe(false);
2247
+ });
2248
+
2249
+ it('should match ? for single characters', () => {
2250
+ const re = globToRegExp('?.ts');
2251
+ expect(re.test('a.ts')).toBe(true);
2252
+ expect(re.test('ab.ts')).toBe(false);
2253
+ });
2254
+
2255
+ it('should match brace alternatives', () => {
2256
+ const re = globToRegExp('*.{ts,tsx}');
2257
+ expect(re.test('foo.ts')).toBe(true);
2258
+ expect(re.test('foo.tsx')).toBe(true);
2259
+ expect(re.test('foo.js')).toBe(false);
2260
+ });
2261
+
2262
+ it('should match character classes', () => {
2263
+ const re = globToRegExp('[abc].ts');
2264
+ expect(re.test('a.ts')).toBe(true);
2265
+ expect(re.test('d.ts')).toBe(false);
2266
+ });
2267
+
2268
+ it('should escape regex special characters in literal parts', () => {
2269
+ const re = globToRegExp('file.test.ts');
2270
+ expect(re.test('file.test.ts')).toBe(true);
2271
+ expect(re.test('filextest.ts')).toBe(false); // dot should be literal
2272
+ });
2273
+
2274
+ it('should handle src/**/*.test.ts pattern', () => {
2275
+ const re = globToRegExp('src/**/*.test.ts');
2276
+ expect(re.test('src/foo.test.ts')).toBe(true);
2277
+ expect(re.test('src/deep/bar.test.ts')).toBe(true);
2278
+ expect(re.test('lib/foo.test.ts')).toBe(false);
2279
+ });
2280
+ });
2281
+
2282
+ // ===== walkAndMatch unit tests =====
2283
+
2284
+ describe('walkAndMatch', () => {
2285
+ const fs = require('fs').promises;
2286
+ const os = require('os');
2287
+ const path = require('path');
2288
+ let tmpDir: string;
2289
+
2290
+ beforeEach(async () => {
2291
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'crewly-glob-test-'));
2292
+ // Create test file structure
2293
+ await fs.mkdir(path.join(tmpDir, 'src'), { recursive: true });
2294
+ await fs.mkdir(path.join(tmpDir, 'src', 'utils'), { recursive: true });
2295
+ await fs.mkdir(path.join(tmpDir, 'node_modules', 'pkg'), { recursive: true });
2296
+ await fs.writeFile(path.join(tmpDir, 'index.ts'), 'export {};');
2297
+ await fs.writeFile(path.join(tmpDir, 'src', 'app.ts'), 'const app = 1;');
2298
+ await fs.writeFile(path.join(tmpDir, 'src', 'app.test.ts'), 'test("app", () => {});');
2299
+ await fs.writeFile(path.join(tmpDir, 'src', 'utils', 'helper.ts'), 'export function help() {}');
2300
+ await fs.writeFile(path.join(tmpDir, 'src', 'style.css'), 'body {}');
2301
+ await fs.writeFile(path.join(tmpDir, 'node_modules', 'pkg', 'index.js'), 'module.exports = {};');
2302
+ });
2303
+
2304
+ afterEach(async () => {
2305
+ await fs.rm(tmpDir, { recursive: true, force: true });
2306
+ });
2307
+
2308
+ it('should find all .ts files recursively', async () => {
2309
+ const re = globToRegExp('**/*.ts');
2310
+ const results = await walkAndMatch(tmpDir, re, new Set(['node_modules', '.git']), 100);
2311
+ expect(results.length).toBe(4);
2312
+ expect(results.some(f => f.endsWith('index.ts'))).toBe(true);
2313
+ expect(results.some(f => f.endsWith('app.ts'))).toBe(true);
2314
+ expect(results.some(f => f.endsWith('app.test.ts'))).toBe(true);
2315
+ expect(results.some(f => f.endsWith('helper.ts'))).toBe(true);
2316
+ });
2317
+
2318
+ it('should ignore node_modules by default', async () => {
2319
+ const re = globToRegExp('**/*.js');
2320
+ const results = await walkAndMatch(tmpDir, re, new Set(['node_modules']), 100);
2321
+ expect(results.length).toBe(0); // Only .js is in node_modules
2322
+ });
2323
+
2324
+ it('should respect maxResults limit', async () => {
2325
+ const re = globToRegExp('**/*');
2326
+ const results = await walkAndMatch(tmpDir, re, new Set(['node_modules']), 2);
2327
+ expect(results.length).toBe(2);
2328
+ });
2329
+
2330
+ it('should match specific subdirectory patterns', async () => {
2331
+ const re = globToRegExp('src/**/*.ts');
2332
+ const results = await walkAndMatch(tmpDir, re, new Set(['node_modules']), 100);
2333
+ expect(results.length).toBe(3); // app.ts, app.test.ts, helper.ts
2334
+ expect(results.every(f => f.includes('/src/'))).toBe(true);
2335
+ });
2336
+ });
2337
+
2338
+ // ===== searchFileContents unit tests =====
2339
+
2340
+ describe('searchFileContents', () => {
2341
+ const fs = require('fs').promises;
2342
+ const os = require('os');
2343
+ const path = require('path');
2344
+ let tmpFile: string;
2345
+
2346
+ beforeEach(async () => {
2347
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'crewly-grep-test-'));
2348
+ tmpFile = path.join(tmpDir, 'test.ts');
2349
+ await fs.writeFile(tmpFile, [
2350
+ 'import { foo } from "./foo";',
2351
+ 'import { bar } from "./bar";',
2352
+ '',
2353
+ 'export function hello() {',
2354
+ ' return "hello world";',
2355
+ '}',
2356
+ '',
2357
+ 'export function goodbye() {',
2358
+ ' return "goodbye world";',
2359
+ '}',
2360
+ ].join('\n'));
2361
+ });
2362
+
2363
+ it('should find matching lines with line numbers', async () => {
2364
+ const matches = await searchFileContents(tmpFile, /export function/, 0);
2365
+ expect(matches.length).toBe(2);
2366
+ expect(matches[0].line).toBe(4);
2367
+ expect(matches[0].content).toContain('hello');
2368
+ expect(matches[1].line).toBe(8);
2369
+ expect(matches[1].content).toContain('goodbye');
2370
+ });
2371
+
2372
+ it('should return context lines when requested', async () => {
2373
+ const matches = await searchFileContents(tmpFile, /hello\(\)/, 1);
2374
+ expect(matches.length).toBe(1);
2375
+ expect(matches[0].line).toBe(4);
2376
+ expect(matches[0].context).toBeDefined();
2377
+ expect(matches[0].context!.length).toBe(3); // 1 before + match + 1 after
2378
+ });
2379
+
2380
+ it('should return empty array when no matches', async () => {
2381
+ const matches = await searchFileContents(tmpFile, /nonexistent/, 0);
2382
+ expect(matches.length).toBe(0);
2383
+ });
2384
+
2385
+ it('should handle regex special characters in content', async () => {
2386
+ const matches = await searchFileContents(tmpFile, /from "\.\/foo"/, 0);
2387
+ expect(matches.length).toBe(1);
2388
+ expect(matches[0].line).toBe(1);
2389
+ });
2390
+ });
2391
+
2392
+ // ===== glob tool integration tests =====
2393
+
2394
+ describe('glob tool', () => {
2395
+ it('should exist in createTools output', () => {
2396
+ expect(tools.glob).toBeDefined();
2397
+ expect((tools.glob as any).description).toContain('file pattern matching');
2398
+ });
2399
+
2400
+ it('should find files in the project directory', async () => {
2401
+ const result = await (tools.glob as any).execute({
2402
+ pattern: '**/*.ts',
2403
+ path: __dirname,
2404
+ }) as any;
2405
+ expect(result.success).toBe(true);
2406
+ expect(result.matchCount).toBeGreaterThan(0);
2407
+ expect(result.files.some((f: string) => f.endsWith('tool-registry.ts'))).toBe(true);
2408
+ });
2409
+
2410
+ it('should return error for non-existent directory', async () => {
2411
+ const result = await (tools.glob as any).execute({
2412
+ pattern: '**/*.ts',
2413
+ path: '/nonexistent/path/xyz',
2414
+ }) as any;
2415
+ expect(result.success).toBe(false);
2416
+ });
2417
+
2418
+ it('should respect custom ignore patterns', async () => {
2419
+ const result = await (tools.glob as any).execute({
2420
+ pattern: '**/*.ts',
2421
+ path: __dirname,
2422
+ ignore: ['__snapshots__'],
2423
+ }) as any;
2424
+ expect(result.success).toBe(true);
2425
+ });
2426
+
2427
+ it('should have safe sensitivity', () => {
2428
+ expect(TOOL_SENSITIVITY.glob).toBe('safe');
2429
+ });
2430
+ });
2431
+
2432
+ // ===== grep tool integration tests =====
2433
+
2434
+ describe('grep tool', () => {
2435
+ it('should exist in createTools output', () => {
2436
+ expect(tools.grep).toBeDefined();
2437
+ expect((tools.grep as any).description).toContain('Search file contents');
2438
+ });
2439
+
2440
+ it('should find pattern matches in files', async () => {
2441
+ const result = await (tools.grep as any).execute({
2442
+ pattern: 'export function createTools',
2443
+ path: __dirname,
2444
+ file_pattern: '**/*.ts',
2445
+ }) as any;
2446
+ expect(result.success).toBe(true);
2447
+ expect(result.matchCount).toBeGreaterThan(0);
2448
+ // Match may come from source or test file — both contain the string
2449
+ expect(result.matches.some((m: any) => m.file.includes('tool-registry'))).toBe(true);
2450
+ });
2451
+
2452
+ it('should support case insensitive search', async () => {
2453
+ const result = await (tools.grep as any).execute({
2454
+ pattern: 'EXPORT FUNCTION CREATETOOLS',
2455
+ path: __dirname,
2456
+ file_pattern: 'tool-registry.ts',
2457
+ case_insensitive: true,
2458
+ }) as any;
2459
+ expect(result.success).toBe(true);
2460
+ expect(result.matchCount).toBeGreaterThan(0);
2461
+ });
2462
+
2463
+ it('should return context lines when requested', async () => {
2464
+ const result = await (tools.grep as any).execute({
2465
+ pattern: 'export function createTools',
2466
+ path: __dirname,
2467
+ file_pattern: 'tool-registry.ts',
2468
+ context_lines: 2,
2469
+ }) as any;
2470
+ expect(result.success).toBe(true);
2471
+ expect(result.matches[0].context).toBeDefined();
2472
+ expect(result.matches[0].context.length).toBeGreaterThanOrEqual(3);
2473
+ });
2474
+
2475
+ it('should return error for invalid regex', async () => {
2476
+ const result = await (tools.grep as any).execute({
2477
+ pattern: '[invalid',
2478
+ path: __dirname,
2479
+ }) as any;
2480
+ expect(result.success).toBe(false);
2481
+ expect(result.error).toContain('Invalid regex');
2482
+ });
2483
+
2484
+ it('should search a single file when path is a file', async () => {
2485
+ const filePath = require('path').join(__dirname, 'tool-registry.ts');
2486
+ const result = await (tools.grep as any).execute({
2487
+ pattern: 'glob:',
2488
+ path: filePath,
2489
+ }) as any;
2490
+ expect(result.success).toBe(true);
2491
+ expect(result.matchCount).toBeGreaterThan(0);
2492
+ });
2493
+
2494
+ it('should have safe sensitivity', () => {
2495
+ expect(TOOL_SENSITIVITY.grep).toBe('safe');
2496
+ });
2497
+
2498
+ it('should respect max_matches limit', async () => {
2499
+ const result = await (tools.grep as any).execute({
2500
+ pattern: 'const|let|var',
2501
+ path: __dirname,
2502
+ file_pattern: '**/*.ts',
2503
+ max_matches: 3,
2504
+ }) as any;
2505
+ expect(result.success).toBe(true);
2506
+ expect(result.matchCount).toBeLessThanOrEqual(3);
2507
+ expect(result.truncated).toBe(true);
2508
+ });
2509
+ });
2510
+ });