Skip to content

Commit d3798ef

Browse files
committed
fix(slack): address 6-agent PR review
Critical: - Declare @archon/workflows as an explicit workspace dep on @archon/adapters (same class of fix as the providers one). Resolves today via hoisting but breaks under stricter installs. - Split workflow-bridge.test.ts into its own bun test invocation so its irreversible mock.module() calls on @archon/core and @archon/workflows/event-emitter cannot leak into the slack/telegram batch. - Fix "trailing-edge" debounce comments — the implementation is leading-edge. Document the Slack chat.update rate limit as the 500ms rationale. Important: - Wire slackBridge.detach() into the server graceful shutdown path so the event subscription doesn't leak and a pending chat.update can't fire against a closed Bolt socket. - Drop dead `comment` plumbing through handleApprovalDecision / applyResolutionEdit / buildApprovalResolutionBlocks — Block Kit buttons have no UI to capture it. - Widen the action-handler try/catch to also cover applyResolutionEdit so block-builder or chat.update failures don't bubble as unhandled rejections. - Cancel-click with missing run state now logs and posts an ephemeral acknowledgement (using the button message's channel/ts) so the user isn't left wondering whether the click registered. - Use Bolt's BlockButtonAction / ButtonAction types directly on the app.action() registrations instead of the ad-hoc ActionBody / ActionElement aliases. Test coverage: - Slash command silent-rejection of unauthorized users. - triggeringMessages 1000-entry FIFO eviction at the cap boundary. - Slash command seed-post failure → ephemeral error + handler not called. - Single-chunk message path skips the _part i/n_ footer. - rejectWorkflow → { cancelled: true, maxAttemptsReached: true } branch. Docs: - architecture.md IPlatformAdapter listing includes sendResultFooter. - approval-nodes.md mentions the Slack in-thread Approve button. - CLAUDE.md test-isolation batch count for @archon/adapters updated to 6 (was 3 — pre-existing drift, now also accounts for workflow-bridge). Polish: - removeReactionSafe gets the same intentional-fallback comment as addReactionSafe (no_reaction is a normal terminal-state interleave). - IPlatformAdapter.sendResultFooter signature uses TokenUsage directly. - Drop "for v1" tag on the unhandled-event comment. - Remove what-comments from blocks.ts / blocks.test.ts / adapter.ts.
1 parent 7f88112 commit d3798ef

13 files changed

Lines changed: 305 additions & 105 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ bun test --watch # Watch mode (single package)
130130
bun test packages/core/src/handlers/command-handler.test.ts # Single file
131131
```
132132

133-
**Test isolation (mock.module pollution):** Bun's `mock.module()` permanently replaces modules in the process-wide cache — `mock.restore()` does NOT undo it ([oven-sh/bun#7823](https://github.com/oven-sh/bun/issues/7823)). To prevent cross-file pollution, packages that have conflicting `mock.module()` calls split their tests into separate `bun test` invocations: `@archon/core` (7 batches), `@archon/workflows` (5), `@archon/adapters` (3), `@archon/isolation` (3). See each package's `package.json` for the exact splits.
133+
**Test isolation (mock.module pollution):** Bun's `mock.module()` permanently replaces modules in the process-wide cache — `mock.restore()` does NOT undo it ([oven-sh/bun#7823](https://github.com/oven-sh/bun/issues/7823)). To prevent cross-file pollution, packages that have conflicting `mock.module()` calls split their tests into separate `bun test` invocations: `@archon/core` (7 batches), `@archon/workflows` (5), `@archon/adapters` (6), `@archon/isolation` (3). See each package's `package.json` for the exact splits.
134134

135135
**Do NOT run `bun test` from the repo root** — it discovers all test files across all packages and runs them in one process, causing ~135 mock pollution failures. Always use `bun run test` (which uses `bun --filter '*' test` for per-package isolation).
136136

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/adapters/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"./community/forge/gitlab": "./src/community/forge/gitlab/index.ts"
1212
},
1313
"scripts": {
14-
"test": "bun test src/chat/ src/community/chat/ src/community/forge/gitlab/auth.test.ts src/forge/github/auth.test.ts src/utils/ && bun test src/forge/github/adapter.test.ts && bun test src/forge/github/context.test.ts && bun test src/community/forge/gitea/adapter.test.ts && bun test src/community/forge/gitlab/adapter.test.ts",
14+
"test": "bun test src/chat/slack/adapter.test.ts src/chat/slack/auth.test.ts src/chat/slack/blocks.test.ts src/chat/telegram/ src/community/chat/ src/community/forge/gitlab/auth.test.ts src/forge/github/auth.test.ts src/utils/ && bun test src/chat/slack/workflow-bridge.test.ts && bun test src/forge/github/adapter.test.ts && bun test src/forge/github/context.test.ts && bun test src/community/forge/gitea/adapter.test.ts && bun test src/community/forge/gitlab/adapter.test.ts",
1515
"type-check": "bun x tsc --noEmit"
1616
},
1717
"dependencies": {
@@ -20,6 +20,7 @@
2020
"@archon/isolation": "workspace:*",
2121
"@archon/paths": "workspace:*",
2222
"@archon/providers": "workspace:*",
23+
"@archon/workflows": "workspace:*",
2324
"@octokit/rest": "^22.0.0",
2425
"@slack/bolt": "^4.6.0",
2526
"discord.js": "^14.16.0",

packages/adapters/src/chat/slack/adapter.test.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ const mockReplies = mock(() => Promise.resolve({ messages: [] }));
2929
const mockEvent = mock(() => {});
3030
const mockStart = mock(() => Promise.resolve(undefined));
3131
const mockStop = mock(() => Promise.resolve(undefined));
32+
const mockCommand = mock(() => {});
33+
const mockAction = mock(() => {});
3234

3335
const mockApp = {
3436
client: {
@@ -40,6 +42,8 @@ const mockApp = {
4042
},
4143
},
4244
event: mockEvent,
45+
command: mockCommand,
46+
action: mockAction,
4347
start: mockStart,
4448
stop: mockStop,
4549
};
@@ -365,5 +369,149 @@ describe('SlackAdapter', () => {
365369
})
366370
);
367371
});
372+
373+
test('single-chunk message is sent without _part footer', async () => {
374+
await adapter.sendMessage('C1:111.0', 'short message');
375+
expect(mockPostMessage).toHaveBeenCalledTimes(1);
376+
const blocks = (mockPostMessage as Mock<typeof mockPostMessage>).mock.calls[0][0].blocks;
377+
expect(blocks[0].text).toBe('short message');
378+
expect(blocks[0].text).not.toContain('_part ');
379+
});
380+
381+
test('multi-chunk message annotates each part with _part i/n_', async () => {
382+
const paragraph1 = 'a'.repeat(10000);
383+
const paragraph2 = 'b'.repeat(10000);
384+
await adapter.sendMessage('C1:111.0', `${paragraph1}\n\n${paragraph2}`);
385+
expect(mockPostMessage).toHaveBeenCalledTimes(2);
386+
const calls = (mockPostMessage as Mock<typeof mockPostMessage>).mock.calls;
387+
expect(calls[0][0].blocks[0].text).toContain('_part 1/2_');
388+
expect(calls[1][0].blocks[0].text).toContain('_part 2/2_');
389+
});
390+
});
391+
392+
describe('triggering message tracking', () => {
393+
test('FIFO evicts oldest entry past the cap', async () => {
394+
// SlackAdapter caps the in-memory map at 1000 entries. To keep this
395+
// test fast we exercise the eviction trigger by pumping app_mention
396+
// events through the registered handler.
397+
mockEvent.mockClear();
398+
const adapter = new SlackAdapter('xoxb-fake', 'xapp-fake');
399+
adapter.onMessage(async () => {
400+
/* no-op handler; we only care about the trigger map side-effect */
401+
});
402+
await adapter.start();
403+
// Find the app_mention handler we just registered.
404+
const calls = (mockEvent as unknown as Mock<(t: string, h: unknown) => void>).mock.calls;
405+
const mentionReg = calls.find(c => c[0] === 'app_mention');
406+
expect(mentionReg).toBeDefined();
407+
const handler = mentionReg![1] as (args: { event: SlackMessageEvent }) => Promise<void>;
408+
409+
const CAP = 1000;
410+
for (let i = 0; i < CAP + 5; i++) {
411+
await handler({
412+
event: { text: 'hi', user: 'U1', channel: 'C1', ts: `${i}.0` },
413+
});
414+
}
415+
416+
// Oldest 5 should be evicted (channel:ts pairs 0.0 .. 4.0)
417+
expect(adapter.getTriggeringMessage('C1:0.0')).toBeUndefined();
418+
expect(adapter.getTriggeringMessage('C1:4.0')).toBeUndefined();
419+
// Boundary: entry 5 is the new oldest and must still be tracked.
420+
expect(adapter.getTriggeringMessage('C1:5.0')).toEqual({ channel: 'C1', ts: '5.0' });
421+
// Newest entry must be present.
422+
expect(adapter.getTriggeringMessage(`C1:${CAP + 4}.0`)).toEqual({
423+
channel: 'C1',
424+
ts: `${CAP + 4}.0`,
425+
});
426+
});
427+
});
428+
429+
describe('slash commands', () => {
430+
function findCommandHandler(name: string): (args: unknown) => Promise<void> {
431+
const calls = (mockCommand as unknown as Mock<(n: string, h: unknown) => void>).mock.calls;
432+
const reg = calls.find(c => c[0] === name);
433+
if (!reg) throw new Error(`no handler registered for ${name}`);
434+
return reg[1] as (args: unknown) => Promise<void>;
435+
}
436+
437+
function makeSlashArgs(overrides: Record<string, unknown> = {}) {
438+
const ack = mock(async () => {});
439+
const respond = mock(async () => {});
440+
const fakeClient = { chat: { postMessage: mockPostMessage } };
441+
return {
442+
ack,
443+
respond,
444+
client: fakeClient,
445+
command: {
446+
user_id: 'U123',
447+
text: 'list',
448+
channel_id: 'C1',
449+
trigger_id: 't1',
450+
...overrides,
451+
},
452+
};
453+
}
454+
455+
test('unauthorized user is silently rejected — no respond, no seed post', async () => {
456+
mockPostMessage.mockClear();
457+
mockCommand.mockClear();
458+
const original = process.env.SLACK_ALLOWED_USER_IDS;
459+
process.env.SLACK_ALLOWED_USER_IDS = 'U1ALICE';
460+
try {
461+
const adapter = new SlackAdapter('xoxb-fake', 'xapp-fake');
462+
adapter.onMessage(async () => {});
463+
await adapter.start();
464+
465+
const handler = findCommandHandler('/archon-workflow');
466+
const args = makeSlashArgs({ user_id: 'U2BOB' });
467+
await handler(args);
468+
469+
// ack always fires (Slack 3s deadline).
470+
expect((args.ack as Mock<() => Promise<void>>).mock.calls.length).toBe(1);
471+
// But the unauthorized branch must NOT speak back to the user
472+
// (mirrors the app_mention silent-rejection policy).
473+
expect((args.respond as Mock<() => Promise<void>>).mock.calls.length).toBe(0);
474+
expect(mockPostMessage).not.toHaveBeenCalled();
475+
} finally {
476+
if (original === undefined) delete process.env.SLACK_ALLOWED_USER_IDS;
477+
else process.env.SLACK_ALLOWED_USER_IDS = original;
478+
}
479+
});
480+
481+
test('seed-post failure surfaces ephemeral error and skips message handler', async () => {
482+
mockPostMessage.mockClear();
483+
mockCommand.mockClear();
484+
const adapter = new SlackAdapter('xoxb-fake', 'xapp-fake');
485+
const onMessage = mock(async () => {});
486+
adapter.onMessage(onMessage);
487+
await adapter.start();
488+
489+
// Reject the seed post — adapter must surface an ephemeral error and
490+
// never invoke the message handler with an undefined ts.
491+
const seedError = new Error('not_in_channel');
492+
const failingPost = mock(async () => Promise.reject(seedError));
493+
const fakeClient = { chat: { postMessage: failingPost } };
494+
const ack = mock(async () => {});
495+
const respond = mock(async () => {});
496+
497+
const handler = findCommandHandler('/archon');
498+
await handler({
499+
ack,
500+
respond,
501+
client: fakeClient,
502+
command: {
503+
user_id: 'U123',
504+
text: 'hello',
505+
channel_id: 'C_HIDDEN',
506+
trigger_id: 't1',
507+
},
508+
});
509+
510+
expect(failingPost).toHaveBeenCalledTimes(1);
511+
expect(onMessage).not.toHaveBeenCalled();
512+
const respondCalls = (respond as Mock<() => Promise<void>>).mock.calls;
513+
expect(respondCalls.length).toBe(1);
514+
expect((respondCalls[0]![0] as { response_type: string }).response_type).toBe('ephemeral');
515+
});
368516
});
369517
});

packages/adapters/src/chat/slack/adapter.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,6 @@ export class SlackAdapter implements IPlatformAdapter {
391391
}
392392
});
393393

394-
// Slash commands: /archon (general) and /archon-workflow (workflow control)
395394
this.app.command('/archon', async ({ command, ack, respond, client }) => {
396395
await ack();
397396
await this.handleSlashCommand(command, respond, client, 'archon');

packages/adapters/src/chat/slack/blocks.test.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
/**
2-
* Unit tests for Slack Block Kit builders and cost footer formatter.
3-
*/
41
import {
52
buildApprovalBlocks,
63
buildApprovalResolutionBlocks,
@@ -99,7 +96,6 @@ describe('buildApprovalResolutionBlocks', () => {
9996
decision: 'approved',
10097
actorUserId: 'U123ALICE',
10198
originalMessage: 'Approve the migration?',
102-
comment: 'looks good',
10399
outcomeNote: 'workflow resumed',
104100
});
105101

@@ -108,7 +104,6 @@ describe('buildApprovalResolutionBlocks', () => {
108104
expect(text).toContain('Approved');
109105
expect(text).toContain('<@U123ALICE>');
110106
expect(text).toContain('Approve the migration?');
111-
expect(text).toContain('> looks good');
112107
expect(text).toContain('workflow resumed');
113108
});
114109

packages/adapters/src/chat/slack/blocks.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
/**
2-
* Pure Block Kit builders + cost footer formatter for the Slack adapter.
3-
*
4-
* Kept side-effect-free so they can be unit-tested without touching the Bolt
5-
* client. The Slack adapter and workflow bridge import these and feed the
6-
* results into `chat.postMessage` / `chat.update`.
2+
* Side-effect-free so callers can unit-test these without spinning up a
3+
* Bolt client. Consumed by `adapter.ts` (`sendResultFooter`) and the
4+
* workflow bridge, both of which feed the output into `chat.postMessage`
5+
* / `chat.update`.
76
*/
87
import type { types } from '@slack/bolt';
98
import type { TokenUsage } from '@archon/providers/types';
@@ -148,7 +147,6 @@ export function buildApprovalResolutionBlocks(input: {
148147
decision: 'approved' | 'rejected';
149148
actorUserId: string;
150149
originalMessage: string;
151-
comment?: string;
152150
outcomeNote?: string;
153151
}): { blocks: KnownBlock[]; fallbackText: string } {
154152
const icon = input.decision === 'approved' ? ':white_check_mark:' : ':x:';
@@ -158,9 +156,6 @@ export function buildApprovalResolutionBlocks(input: {
158156
'',
159157
input.originalMessage,
160158
];
161-
if (input.comment) {
162-
lines.push('', `> ${input.comment}`);
163-
}
164159
if (input.outcomeNote) {
165160
lines.push('', `_${input.outcomeNote}_`);
166161
}

packages/adapters/src/chat/slack/workflow-bridge.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,38 @@ describe('SlackWorkflowBridge', () => {
327327
expect(text).toContain('will retry');
328328
});
329329

330+
test('reject button at max attempts notes the run was cancelled', async () => {
331+
const { adapter, updated, triggerMap, dispatchAction } = makeFakeAdapter();
332+
triggerMap.set('C1:111.0', { channel: 'C1', ts: '111.0' });
333+
mockGetConversationId.mockReturnValue('C1:111.0');
334+
mockRejectWorkflow.mockResolvedValue({ cancelled: true, maxAttemptsReached: true });
335+
336+
new SlackWorkflowBridge(adapter as never).attach();
337+
await dispatchEvent({
338+
type: 'workflow_started',
339+
runId: 'r1',
340+
workflowName: 'assist',
341+
conversationId: 'conv-db-uuid',
342+
});
343+
await dispatchEvent({
344+
type: 'approval_pending',
345+
runId: 'r1',
346+
nodeId: 'review',
347+
message: 'Approve?',
348+
});
349+
350+
await dispatchAction('reject:r1:review', {
351+
user: { id: 'U999' },
352+
channel: { id: 'C1' },
353+
message: { ts: '2.000' },
354+
});
355+
356+
expect(mockRejectWorkflow).toHaveBeenCalledTimes(1);
357+
const text = (updated[0]?.blocks?.[0] as { text?: { text?: string } } | undefined)?.text?.text;
358+
expect(text).toContain('Rejected');
359+
expect(text).toContain('max reject attempts reached');
360+
});
361+
330362
test('cancel button calls abandonWorkflow', async () => {
331363
const { adapter, triggerMap, dispatchAction } = makeFakeAdapter();
332364
triggerMap.set('C1:111.0', { channel: 'C1', ts: '111.0' });

0 commit comments

Comments
 (0)