@shipfox/api-integration-slack 10.2.0 → 12.0.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-integration-slack",
3
3
  "license": "MIT",
4
- "version": "10.2.0",
4
+ "version": "12.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -15,22 +15,26 @@
15
15
  ".": {
16
16
  "types": "./dist/index.d.ts",
17
17
  "default": "./dist/index.js"
18
+ },
19
+ "./agent-tools": {
20
+ "types": "./dist/core/agent-tools.d.ts",
21
+ "default": "./dist/core/agent-tools.js"
18
22
  }
19
23
  },
20
24
  "dependencies": {
21
25
  "drizzle-orm": "^0.45.2",
22
26
  "ky": "^2.0.0",
23
27
  "zod": "^4.4.3",
24
- "@shipfox/api-auth-context": "10.2.0",
25
- "@shipfox/api-integration-spi": "0.2.2",
26
- "@shipfox/api-integration-slack-dto": "9.0.2",
27
- "@shipfox/api-workspaces-dto": "10.2.0",
28
+ "@shipfox/api-auth-context": "12.0.0",
29
+ "@shipfox/api-integration-spi": "1.0.0",
30
+ "@shipfox/api-integration-slack-dto": "12.0.0",
31
+ "@shipfox/api-workspaces-dto": "12.0.0",
28
32
  "@shipfox/config": "1.2.4",
29
- "@shipfox/inter-module": "0.2.2",
30
- "@shipfox/node-drizzle": "0.3.4",
31
- "@shipfox/node-fastify": "0.4.0",
33
+ "@shipfox/inter-module": "0.2.3",
34
+ "@shipfox/node-drizzle": "0.3.5",
35
+ "@shipfox/node-fastify": "0.4.1",
32
36
  "@shipfox/node-opentelemetry": "0.6.3",
33
- "@shipfox/node-postgres": "0.4.4"
37
+ "@shipfox/node-postgres": "0.5.0"
34
38
  },
35
39
  "imports": {
36
40
  "#*": "./dist/*"
@@ -92,17 +92,17 @@ describe('SlackAgentToolsProvider', () => {
92
92
  it('dispatches a read method and returns the Slack response as structured content', async () => {
93
93
  const body = {ok: true, messages: [{text: 'Hello'}]};
94
94
  const options = providerOptions(async () => body);
95
- const session = await openSession(options, ['conversations_history']);
95
+ const session = await openSession(options, ['read_channel']);
96
96
 
97
97
  const result = await session.call({
98
- toolId: 'conversations_history',
99
- arguments: {channel: 'C123', limit: 10},
98
+ toolId: 'read_channel',
99
+ arguments: {channel_id: 'C123', limit: 10},
100
100
  });
101
101
 
102
102
  expect(options.slack.callMethod).toHaveBeenCalledWith({
103
103
  method: 'conversations.history',
104
104
  token: 'xoxb-token',
105
- arguments: {channel: 'C123', limit: 10},
105
+ arguments: expect.objectContaining({channel: 'C123', limit: 10}),
106
106
  });
107
107
  expect(result).toEqual({
108
108
  content: [{type: 'text', text: JSON.stringify(body)}],
@@ -110,58 +110,105 @@ describe('SlackAgentToolsProvider', () => {
110
110
  });
111
111
  });
112
112
 
113
- it('passes a thread timestamp through to the message-posting method', async () => {
113
+ it('translates message parameters to their Slack Web API names', async () => {
114
114
  const options = providerOptions(async () => ({ok: true, ts: '456.000'}));
115
- const session = await openSession(options, ['chat_postMessage']);
115
+ const session = await openSession(options, ['send_message']);
116
116
 
117
117
  await session.call({
118
- toolId: 'chat_postMessage',
119
- arguments: {channel: 'C123', text: 'Reply', thread_ts: '123.000'},
118
+ toolId: 'send_message',
119
+ arguments: {channel_id: 'C123', message: 'Reply', thread_ts: '123.000'},
120
120
  });
121
121
 
122
122
  expect(options.slack.callMethod).toHaveBeenCalledWith({
123
123
  method: 'chat.postMessage',
124
124
  token: 'xoxb-token',
125
- arguments: {channel: 'C123', text: 'Reply', thread_ts: '123.000'},
125
+ arguments: {
126
+ channel: 'C123',
127
+ text: 'Reply',
128
+ blocks: [{type: 'markdown', text: 'Reply'}],
129
+ thread_ts: '123.000',
130
+ reply_broadcast: undefined,
131
+ },
126
132
  });
127
133
  });
128
134
 
135
+ it('applies the tool result mapping before returning content to the agent', async () => {
136
+ const body = {
137
+ ok: true,
138
+ channels: [
139
+ {id: 'C1', name: 'deploy-prod'},
140
+ {id: 'C2', name: 'design'},
141
+ ],
142
+ };
143
+ const options = providerOptions(async () => body);
144
+ const session = await openSession(options, ['search_channels']);
145
+
146
+ const result = await session.call({
147
+ toolId: 'search_channels',
148
+ arguments: {query: 'deploy'},
149
+ });
150
+
151
+ expect(result.structuredContent).toEqual({...body, channels: [body.channels[0]]});
152
+ });
153
+
129
154
  it('rejects a tool that was not selected for the session', async () => {
130
155
  const options = providerOptions(async () => ({ok: true}));
131
- const session = await openSession(options, ['conversations_history']);
156
+ const session = await openSession(options, ['read_channel']);
132
157
 
133
- const result = await session.call({toolId: 'users_info', arguments: {user: 'U123'}});
158
+ const result = await session.call({
159
+ toolId: 'read_user_profile',
160
+ arguments: {user_id: 'U123'},
161
+ });
134
162
 
135
163
  expect(result).toEqual({
136
164
  isError: true,
137
- content: [{type: 'text', text: 'Unknown Slack tool: users_info'}],
165
+ content: [{type: 'text', text: 'Unknown Slack tool: read_user_profile'}],
138
166
  });
139
167
  expect(options.slack.callMethod).not.toHaveBeenCalled();
140
168
  });
141
169
 
142
170
  it('rejects a call missing a required parameter', async () => {
143
171
  const options = providerOptions(async () => ({ok: true}));
144
- const session = await openSession(options, ['conversations_replies']);
172
+ const session = await openSession(options, ['read_thread']);
145
173
 
146
174
  const result = await session.call({
147
- toolId: 'conversations_replies',
148
- arguments: {channel: 'C123'},
175
+ toolId: 'read_thread',
176
+ arguments: {channel_id: 'C123'},
149
177
  });
150
178
 
151
179
  expect(result).toEqual({
152
180
  isError: true,
153
- content: [{type: 'text', text: 'Missing required parameter: ts'}],
181
+ content: [{type: 'text', text: 'Missing required parameter: message_ts'}],
182
+ });
183
+ expect(options.slack.callMethod).not.toHaveBeenCalled();
184
+ });
185
+
186
+ it('rejects a message over the Slack Markdown block limit before calling Slack', async () => {
187
+ const options = providerOptions(async () => ({ok: true, ts: '456.000'}));
188
+ const session = await openSession(options, ['send_message']);
189
+
190
+ const result = await session.call({
191
+ toolId: 'send_message',
192
+ arguments: {channel_id: 'C123', message: 'a'.repeat(12_001)},
193
+ });
194
+
195
+ expect(result).toMatchObject({
196
+ isError: true,
197
+ content: [
198
+ {type: 'text', text: expect.stringContaining('12,000-character Markdown block limit')},
199
+ ],
200
+ structuredContent: {code: 'content-too-large'},
154
201
  });
155
202
  expect(options.slack.callMethod).not.toHaveBeenCalled();
156
203
  });
157
204
 
158
205
  it('returns a Slack application error to the agent', async () => {
159
206
  const options = providerOptions(async () => ({ok: false, error: 'channel_not_found'}));
160
- const session = await openSession(options, ['conversations_history']);
207
+ const session = await openSession(options, ['read_channel']);
161
208
 
162
209
  const result = await session.call({
163
- toolId: 'conversations_history',
164
- arguments: {channel: 'C123'},
210
+ toolId: 'read_channel',
211
+ arguments: {channel_id: 'C123'},
165
212
  });
166
213
 
167
214
  expect(result).toEqual({
@@ -172,11 +219,11 @@ describe('SlackAgentToolsProvider', () => {
172
219
 
173
220
  it('returns auth failures with a stable access-denied code and logs only metadata', async () => {
174
221
  const options = providerOptions(async () => ({ok: false, error: 'invalid_auth'}));
175
- const session = await openSession(options, ['conversations_history']);
222
+ const session = await openSession(options, ['read_channel']);
176
223
 
177
224
  const result = await session.call({
178
- toolId: 'conversations_history',
179
- arguments: {channel: 'C123'},
225
+ toolId: 'read_channel',
226
+ arguments: {channel_id: 'C123'},
180
227
  });
181
228
 
182
229
  expect(result).toMatchObject({
@@ -193,9 +240,9 @@ describe('SlackAgentToolsProvider', () => {
193
240
 
194
241
  it('returns an HTTP-200 rate-limit response with a stable code', async () => {
195
242
  const options = providerOptions(async () => ({ok: false, error: 'ratelimited'}));
196
- const session = await openSession(options, ['conversations_list']);
243
+ const session = await openSession(options, ['search_channels']);
197
244
 
198
- const result = await session.call({toolId: 'conversations_list', arguments: {}});
245
+ const result = await session.call({toolId: 'search_channels', arguments: {query: 'deploy'}});
199
246
 
200
247
  expect(result).toMatchObject({
201
248
  isError: true,
@@ -207,9 +254,9 @@ describe('SlackAgentToolsProvider', () => {
207
254
  const options = providerOptions(() =>
208
255
  Promise.reject(new SlackIntegrationProviderError('rate-limited', 'Try again later', 19)),
209
256
  );
210
- const session = await openSession(options, ['conversations_list']);
257
+ const session = await openSession(options, ['search_channels']);
211
258
 
212
- const result = await session.call({toolId: 'conversations_list', arguments: {}});
259
+ const result = await session.call({toolId: 'search_channels', arguments: {query: 'deploy'}});
213
260
 
214
261
  expect(result).toMatchObject({
215
262
  isError: true,
@@ -224,11 +271,11 @@ describe('SlackAgentToolsProvider', () => {
224
271
  new SlackIntegrationProviderError('content-too-large', 'Slack content was too large'),
225
272
  ),
226
273
  );
227
- const session = await openSession(options, ['chat_postMessage']);
274
+ const session = await openSession(options, ['send_message']);
228
275
 
229
276
  const result = await session.call({
230
- toolId: 'chat_postMessage',
231
- arguments: {channel: 'C123', blocks: []},
277
+ toolId: 'send_message',
278
+ arguments: {channel_id: 'C123', message: 'Too large'},
232
279
  });
233
280
 
234
281
  expect(result).toMatchObject({
@@ -246,7 +293,7 @@ describe('SlackAgentToolsProvider', () => {
246
293
 
247
294
  const result = provider.openSession({
248
295
  connection: slackConnection(),
249
- tools: [catalogTool('conversations_list')],
296
+ tools: [catalogTool('search_channels')],
250
297
  scope: {provider: 'slack'},
251
298
  });
252
299
 
@@ -7,10 +7,11 @@ import type {
7
7
  import {logger} from '@shipfox/node-opentelemetry';
8
8
  import type {SlackApiClient, SlackWebApiResponse} from '#api/client.js';
9
9
  import {
10
- SLACK_TOOL_METHODS,
10
+ SLACK_TOOL_OPERATIONS,
11
11
  type SlackAgentToolCatalogEntry,
12
12
  type SlackAgentToolId,
13
13
  type SlackAgentToolRequiredScope,
14
+ type SlackToolOperation,
14
15
  slackAgentToolCatalog,
15
16
  slackAgentToolSelectionCatalog,
16
17
  } from '#core/agent-tools.js';
@@ -62,19 +63,23 @@ export class SlackAgentToolsProvider
62
63
  call: async (call) => {
63
64
  const tool = input.tools.find((candidate) => candidate.id === call.toolId);
64
65
  if (!tool) return slackToolError(`Unknown Slack tool: ${call.toolId}`);
65
- const method = slackToolMethod(tool.id);
66
- if (!method) return slackToolError(`Unknown Slack tool method: ${tool.id}`);
66
+ const operation = slackToolOperation(tool.id);
67
+ if (!operation) return slackToolError(`Unknown Slack tool method: ${tool.id}`);
67
68
  const missingParameter = missingRequiredParameter(tool, call.arguments);
68
69
  if (missingParameter) {
69
70
  return slackToolError(`Missing required parameter: ${missingParameter}`);
70
71
  }
72
+ const validationError = operation.validate?.(call.arguments);
73
+ if (validationError) {
74
+ return slackToolError(validationError.message, {code: validationError.code});
75
+ }
71
76
 
72
77
  let body: SlackWebApiResponse;
73
78
  try {
74
79
  body = await this.options.slack.callMethod({
75
- method,
80
+ method: operation.method,
76
81
  token,
77
- arguments: call.arguments,
82
+ arguments: operation.mapArguments(call.arguments),
78
83
  });
79
84
  } catch (error) {
80
85
  if (error instanceof SlackIntegrationProviderError) {
@@ -86,7 +91,9 @@ export class SlackAgentToolsProvider
86
91
  throw error;
87
92
  }
88
93
 
89
- if (body.ok) return slackToolResult(body);
94
+ if (body.ok) {
95
+ return slackToolResult(operation.mapOutput?.(body, call.arguments) ?? body);
96
+ }
90
97
  const slackError = typeof body.error === 'string' ? body.error : 'Slack request failed';
91
98
  if (isSlackAccessError(slackError)) {
92
99
  logger().warn(
@@ -105,8 +112,8 @@ export class SlackAgentToolsProvider
105
112
  }
106
113
  }
107
114
 
108
- function slackToolMethod(toolId: string): string | undefined {
109
- return SLACK_TOOL_METHODS[toolId as SlackAgentToolId];
115
+ function slackToolOperation(toolId: string): SlackToolOperation | undefined {
116
+ return SLACK_TOOL_OPERATIONS[toolId as SlackAgentToolId];
110
117
  }
111
118
 
112
119
  function missingRequiredParameter(
@@ -1,22 +1,36 @@
1
1
  import {
2
- SLACK_TOOL_METHODS,
2
+ SLACK_TOOL_OPERATIONS,
3
3
  type SlackAgentToolId,
4
+ type SlackToolOperation,
4
5
  slackAgentToolCatalog,
5
6
  slackAgentToolSelectionCatalog,
6
7
  } from './agent-tools.js';
7
8
 
8
9
  const expectedTools = [
9
- {id: 'conversations_history', sensitivity: 'read', requiredScope: 'read'},
10
- {id: 'conversations_replies', sensitivity: 'read', requiredScope: 'read'},
11
- {id: 'conversations_list', sensitivity: 'read', requiredScope: 'read'},
12
- {id: 'users_info', sensitivity: 'read', requiredScope: 'read'},
13
- {id: 'chat_postMessage', sensitivity: 'write', requiredScope: 'write'},
14
- {id: 'chat_update', sensitivity: 'write', requiredScope: 'write'},
15
- {id: 'reactions_add', sensitivity: 'write', requiredScope: 'write'},
10
+ {id: 'read_channel', sensitivity: 'read', requiredScope: 'read'},
11
+ {id: 'read_thread', sensitivity: 'read', requiredScope: 'read'},
12
+ {id: 'read_channel_info', sensitivity: 'read', requiredScope: 'read'},
13
+ {id: 'read_channel_members', sensitivity: 'read', requiredScope: 'read'},
14
+ {id: 'read_user_profile', sensitivity: 'read', requiredScope: 'read'},
15
+ {id: 'search_channels', sensitivity: 'read', requiredScope: 'read'},
16
+ {id: 'send_message', sensitivity: 'write', requiredScope: 'write'},
17
+ {id: 'schedule_message', sensitivity: 'write', requiredScope: 'write'},
18
+ {id: 'update_message', sensitivity: 'write', requiredScope: 'write'},
19
+ {id: 'add_reaction', sensitivity: 'write', requiredScope: 'write'},
20
+ {id: 'create_canvas', sensitivity: 'write', requiredScope: 'write'},
16
21
  ] as const;
17
22
 
23
+ function operation(id: SlackAgentToolId): SlackToolOperation {
24
+ return SLACK_TOOL_OPERATIONS[id];
25
+ }
26
+
27
+ function channelIdDescription(inputSchema: Record<string, unknown>): string | undefined {
28
+ const properties = inputSchema.properties as Record<string, {description?: string}> | undefined;
29
+ return properties?.channel_id?.description;
30
+ }
31
+
18
32
  describe('slackAgentToolCatalog', () => {
19
- it('defines the seven Slack tools with their access requirements', () => {
33
+ it('defines the Slack tools with their access requirements', () => {
20
34
  const tools = slackAgentToolCatalog.map(({id, sensitivity, requiredScope, sensitive}) => ({
21
35
  id,
22
36
  sensitivity,
@@ -33,46 +47,203 @@ describe('slackAgentToolCatalog', () => {
33
47
  type: inputSchema.type,
34
48
  }));
35
49
 
36
- expect(schemas).toHaveLength(7);
50
+ expect(schemas).toHaveLength(expectedTools.length);
37
51
  expect(
38
52
  schemas.every(({description, type}) => description.length > 0 && type === 'object'),
39
53
  ).toBe(true);
40
54
  });
41
55
 
42
- it('models Block Kit messages and Slack scalar parameters without narrowing valid calls', () => {
43
- const postMessage = slackAgentToolCatalog.find(({id}) => id === 'chat_postMessage');
44
- const listConversations = slackAgentToolCatalog.find(({id}) => id === 'conversations_list');
45
- const history = slackAgentToolCatalog.find(({id}) => id === 'conversations_history');
56
+ it('names message parameters the way the Slack MCP server does', () => {
57
+ const sendMessage = slackAgentToolCatalog.find(({id}) => id === 'send_message');
58
+ const readThread = slackAgentToolCatalog.find(({id}) => id === 'read_thread');
46
59
 
47
- expect(postMessage?.inputSchema).toMatchObject({
48
- required: ['channel'],
49
- properties: {
50
- blocks: {type: 'array', items: {type: 'object'}},
51
- thread_ts: {type: 'string'},
52
- },
53
- });
54
- expect(listConversations?.inputSchema).toMatchObject({
55
- properties: {types: {type: 'string'}, limit: {type: 'number'}},
60
+ expect(sendMessage?.inputSchema).toMatchObject({
61
+ required: ['channel_id', 'message'],
62
+ properties: {message: {type: 'string', maxLength: 12_000}, thread_ts: {type: 'string'}},
56
63
  });
57
- expect(history?.inputSchema).toMatchObject({
58
- properties: {oldest: {type: 'string'}, latest: {type: 'string'}},
64
+ expect(readThread?.inputSchema).toMatchObject({
65
+ required: ['channel_id', 'message_ts'],
66
+ properties: {limit: {type: 'integer'}, cursor: {type: 'string'}},
59
67
  });
60
68
  });
61
69
 
70
+ it('offers user-ID targeting only on the tools whose Slack method resolves one', () => {
71
+ const targetDescriptions = new Map(
72
+ slackAgentToolCatalog
73
+ .filter(({inputSchema}) => channelIdDescription(inputSchema) !== undefined)
74
+ .map(({id, inputSchema}) => [id, channelIdDescription(inputSchema) ?? '']),
75
+ );
76
+ const acceptsUserId = [...targetDescriptions]
77
+ .filter(([, description]) => description.includes('Pass a user ID'))
78
+ .map(([id]) => id);
79
+
80
+ expect(acceptsUserId).toEqual(['send_message', 'schedule_message']);
81
+ expect(targetDescriptions.get('read_channel')).toContain('not accepted');
82
+ });
83
+
62
84
  it('maps every tool id to its dotted Slack Web API method', () => {
63
85
  const methods = Object.fromEntries(
64
- slackAgentToolCatalog.map(({id}) => [id, SLACK_TOOL_METHODS[id as SlackAgentToolId]]),
86
+ slackAgentToolCatalog.map(({id}) => [id, operation(id).method]),
65
87
  );
66
88
 
67
89
  expect(methods).toEqual({
68
- conversations_history: 'conversations.history',
69
- conversations_replies: 'conversations.replies',
70
- conversations_list: 'conversations.list',
71
- users_info: 'users.info',
72
- chat_postMessage: 'chat.postMessage',
73
- chat_update: 'chat.update',
74
- reactions_add: 'reactions.add',
90
+ read_channel: 'conversations.history',
91
+ read_thread: 'conversations.replies',
92
+ read_channel_info: 'conversations.info',
93
+ read_channel_members: 'conversations.members',
94
+ read_user_profile: 'users.info',
95
+ search_channels: 'conversations.list',
96
+ send_message: 'chat.postMessage',
97
+ schedule_message: 'chat.scheduleMessage',
98
+ update_message: 'chat.update',
99
+ add_reaction: 'reactions.add',
100
+ create_canvas: 'canvases.create',
101
+ });
102
+ });
103
+
104
+ it('translates channel and message identifiers to Slack Web API parameters', () => {
105
+ expect(
106
+ operation('read_channel').mapArguments({channel_id: 'C123', limit: 10, cursor: 'next'}),
107
+ ).toMatchObject({channel: 'C123', limit: 10, cursor: 'next'});
108
+ expect(
109
+ operation('read_thread').mapArguments({channel_id: 'C123', message_ts: '123.000'}),
110
+ ).toMatchObject({channel: 'C123', ts: '123.000'});
111
+ expect(operation('read_user_profile').mapArguments({user_id: 'U123'})).toMatchObject({
112
+ user: 'U123',
113
+ });
114
+ expect(operation('read_channel_info').mapArguments({channel_id: 'C123'})).toMatchObject({
115
+ channel: 'C123',
116
+ });
117
+ expect(
118
+ operation('read_channel_members').mapArguments({channel_id: 'C123', cursor: 'next'}),
119
+ ).toMatchObject({channel: 'C123', cursor: 'next'});
120
+ expect(
121
+ operation('add_reaction').mapArguments({
122
+ channel_id: 'C123',
123
+ message_ts: '123.000',
124
+ emoji: 'tada',
125
+ }),
126
+ ).toMatchObject({channel: 'C123', timestamp: '123.000', name: 'tada'});
127
+ });
128
+
129
+ it('sends message content as a Markdown block with a stripped plain-text fallback', () => {
130
+ const args = {channel_id: 'C123', message: '**Deployed** to production'};
131
+
132
+ expect(operation('send_message').mapArguments(args)).toMatchObject({
133
+ channel: 'C123',
134
+ text: 'Deployed to production',
135
+ blocks: [{type: 'markdown', text: '**Deployed** to production'}],
75
136
  });
137
+ expect(
138
+ operation('update_message').mapArguments({...args, message_ts: '123.000'}),
139
+ ).toMatchObject({ts: '123.000', blocks: [{type: 'markdown', text: args.message}]});
140
+ });
141
+
142
+ it('strips Markdown syntax from the notification fallback but not the Markdown block', () => {
143
+ const message = '**bold** _italic_ ~~gone~~ `code` [docs](https://example.com/x)\n> a quote';
144
+
145
+ const {text, blocks} = operation('send_message').mapArguments({channel_id: 'C123', message});
146
+
147
+ expect(text).toBe('bold italic gone code docs (https://example.com/x)\na quote');
148
+ expect(blocks).toEqual([{type: 'markdown', text: message}]);
149
+ });
150
+
151
+ it('preserves Markdown-looking characters inside code spans in the fallback', () => {
152
+ const message = 'run `*args, **kwargs` then\n```py\n**not bold**\n```\ndone';
153
+
154
+ const {text} = operation('send_message').mapArguments({channel_id: 'C123', message});
155
+
156
+ expect(text).toBe('run *args, **kwargs then\n**not bold**\ndone');
157
+ });
158
+
159
+ it('does not let a NUL-delimited numeric sequence in the message collide with the internal code-span placeholder', () => {
160
+ const nul = String.fromCharCode(0);
161
+ const message = `call me at ${nul}0${nul} please and \`use code\``;
162
+
163
+ const {text} = operation('send_message').mapArguments({channel_id: 'C123', message});
164
+
165
+ expect(text).toBe('call me at 0 please and use code');
166
+ });
167
+
168
+ it('schedules a message with its send time and thread target', () => {
169
+ expect(
170
+ operation('schedule_message').mapArguments({
171
+ channel_id: 'C123',
172
+ message: 'Standup in 5',
173
+ post_at: 1700000000,
174
+ thread_ts: '123.000',
175
+ reply_broadcast: true,
176
+ }),
177
+ ).toMatchObject({
178
+ channel: 'C123',
179
+ text: 'Standup in 5',
180
+ blocks: [{type: 'markdown', text: 'Standup in 5'}],
181
+ post_at: 1700000000,
182
+ thread_ts: '123.000',
183
+ reply_broadcast: true,
184
+ });
185
+ });
186
+
187
+ it('rejects a message over the Slack Markdown block limit', () => {
188
+ const oversized = 'a'.repeat(12_001);
189
+ const fits = 'a'.repeat(12_000);
190
+
191
+ expect(operation('send_message').validate?.({message: oversized})).toMatchObject({
192
+ message: expect.stringContaining('12,000-character Markdown block limit'),
193
+ code: 'content-too-large',
194
+ });
195
+ expect(operation('schedule_message').validate?.({message: oversized})).toBeDefined();
196
+ expect(operation('update_message').validate?.({message: oversized})).toBeDefined();
197
+ expect(operation('send_message').validate?.({message: fits})).toBeUndefined();
198
+ });
199
+
200
+ it('counts Unicode code points, not UTF-16 code units, against the length limit', () => {
201
+ const emoji = '\u{1F600}';
202
+ // Each 😀 is a surrogate pair (2 UTF-16 units) but 1 code point: 6,001 code points fits the
203
+ // 12,000-character schema limit even though .length would report 12,002.
204
+ const sixThousandOneEmoji = emoji.repeat(6001);
205
+
206
+ expect(operation('send_message').validate?.({message: sixThousandOneEmoji})).toBeUndefined();
207
+ expect(operation('send_message').validate?.({message: emoji.repeat(12_001)})).toBeDefined();
208
+ });
209
+
210
+ it('wraps canvas content as a Markdown document', () => {
211
+ expect(operation('create_canvas').mapArguments({title: 'Runbook', content: '# Steps'})).toEqual(
212
+ {
213
+ title: 'Runbook',
214
+ document_content: {type: 'markdown', markdown: '# Steps'},
215
+ },
216
+ );
217
+ });
218
+
219
+ it('excludes archived channels unless the caller asks for them', () => {
220
+ expect(operation('search_channels').mapArguments({query: 'deploy'})).toMatchObject({
221
+ exclude_archived: true,
222
+ });
223
+ expect(
224
+ operation('search_channels').mapArguments({query: 'deploy', include_archived: true}),
225
+ ).toMatchObject({exclude_archived: false});
226
+ });
227
+
228
+ it('keeps only the channels matching every search term', () => {
229
+ const body = {
230
+ ok: true,
231
+ channels: [
232
+ {id: 'C1', name: 'deploy-prod', topic: {value: 'Release coordination'}},
233
+ {id: 'C2', name: 'deploy-staging', topic: {value: 'Scratch'}},
234
+ {id: 'C3', name: 'design', purpose: {value: 'Release notes'}},
235
+ ],
236
+ };
237
+
238
+ const result = operation('search_channels').mapOutput?.(body, {query: 'Deploy release'});
239
+
240
+ expect(result).toEqual({...body, channels: [body.channels[0]]});
241
+ });
242
+
243
+ it('returns the Slack response unchanged when the search query is empty', () => {
244
+ const body = {ok: true, channels: [{id: 'C1', name: 'deploy-prod'}]};
245
+
246
+ expect(operation('search_channels').mapOutput?.(body, {query: ' '})).toBe(body);
76
247
  });
77
248
 
78
249
  it('exposes one standalone selector per tool with matching sensitivity', () => {