@stratusagent/channel-slack 0.11.3 → 0.11.5
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/README.md +56 -5
- package/dist/index.d.ts +32 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +379 -358
- package/dist/index.js.map +1 -1
- package/dist/mrkdwn.d.ts +43 -0
- package/dist/mrkdwn.d.ts.map +1 -0
- package/dist/mrkdwn.js +739 -0
- package/dist/mrkdwn.js.map +1 -0
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -2,8 +2,14 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { filePathsOf, latestTurnReply, SENDER_TRUST_METADATA_KEY, sessionWriteTrust, imageDimensions, isImageAttachmentMediaType, IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENT_MAX_DIMENSION, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES, } from '@stratusagent/core';
|
|
5
|
+
import { filePathsOf, latestTurnReply, SENDER_TRUST_METADATA_KEY, sessionWriteTrust, imageDimensions, isImageAttachmentMediaType, IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENT_MAX_DIMENSION, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES, escapeControlCharacters, } from '@stratusagent/core';
|
|
6
6
|
import { channelSessionKey, } from '@stratusagent/channels';
|
|
7
|
+
import { toSlackMrkdwn } from "./mrkdwn.js";
|
|
8
|
+
/**
|
|
9
|
+
* The longest display name the model is shown. Slack caps profile names
|
|
10
|
+
* well under this; the bound is for a profile that is not a name.
|
|
11
|
+
*/
|
|
12
|
+
const MAX_DISPLAY_NAME_LENGTH = 80;
|
|
7
13
|
const SLACK_MAX_MESSAGE_CHARS = 4000;
|
|
8
14
|
const DEFAULT_EDIT_INTERVAL_MS = 1000;
|
|
9
15
|
const DEDUPE_CAPACITY = 2000;
|
|
@@ -46,6 +52,15 @@ const THREAD_HANDOVER_DEPTH = 32;
|
|
|
46
52
|
*/
|
|
47
53
|
const MAX_UNRENDERED_FILES = 20;
|
|
48
54
|
const PLACEHOLDER_TEXT = '…';
|
|
55
|
+
/** What the loading status says while a turn works, and while it runs a tool. */
|
|
56
|
+
const THINKING_STATUS = 'is thinking…';
|
|
57
|
+
const toolStatus = (toolName) => `is running ${toolName}…`;
|
|
58
|
+
/**
|
|
59
|
+
* How often a loading status is set again while its turn runs. Slack drops
|
|
60
|
+
* one after two minutes with no message, and a long turn — a tool, an
|
|
61
|
+
* approval wait — is exactly the one whose status must not vanish.
|
|
62
|
+
*/
|
|
63
|
+
const STATUS_REFRESH_MS = 60_000;
|
|
49
64
|
/**
|
|
50
65
|
* How long an agent that judges stays attentive after it last answered a
|
|
51
66
|
* message that addressed it: this many messages, or this many minutes,
|
|
@@ -151,10 +166,13 @@ const uploadPlace = (result, channel) => {
|
|
|
151
166
|
return undefined;
|
|
152
167
|
};
|
|
153
168
|
/**
|
|
154
|
-
* Renders one turn's reply into Slack
|
|
155
|
-
*
|
|
156
|
-
* lines into throttled edits, and finalize with the
|
|
157
|
-
* (
|
|
169
|
+
* Renders one turn's reply into Slack, one of two ways. Streaming (the
|
|
170
|
+
* `stream` reply mode): post `…` immediately, fold streaming deltas and
|
|
171
|
+
* tool status lines into throttled edits, and finalize with the
|
|
172
|
+
* authoritative reply. Otherwise (`final`, the default): no placeholder —
|
|
173
|
+
* Slack's loading status says the agent is working, and the finished reply
|
|
174
|
+
* is posted once, so the notification carries the answer rather than a
|
|
175
|
+
* `…`. Either way a reply that outgrows one message is split.
|
|
158
176
|
*/
|
|
159
177
|
class ReplyRenderer {
|
|
160
178
|
/**
|
|
@@ -220,6 +238,47 @@ class ReplyRenderer {
|
|
|
220
238
|
* had nothing to add is a message the decision cannot take back.
|
|
221
239
|
*/
|
|
222
240
|
lazy;
|
|
241
|
+
/**
|
|
242
|
+
* Whether this turn streams into a placeholder. When it does not, there
|
|
243
|
+
* is never a placeholder: `finalize` finds none and posts the reply as
|
|
244
|
+
* messages of its own, and a handover has nothing to hand over and says
|
|
245
|
+
* so (`untaken`), so the recovery's reply is posted ahead of this one
|
|
246
|
+
* the ordinary way — both already the paths for a placeholder that
|
|
247
|
+
* could not be opened.
|
|
248
|
+
*/
|
|
249
|
+
streaming;
|
|
250
|
+
/**
|
|
251
|
+
* The thread the loading status belongs to: the reply's thread, or the
|
|
252
|
+
* message itself in a DM, whose reply has no thread to key one on.
|
|
253
|
+
*/
|
|
254
|
+
statusThread;
|
|
255
|
+
/** Shared by a connection's renderers, so a status Slack refuses is said once, not per message. */
|
|
256
|
+
statusWarned;
|
|
257
|
+
loading = false;
|
|
258
|
+
/**
|
|
259
|
+
* Status calls, in order, so the last one Slack applies is the last one
|
|
260
|
+
* sent — and so `finalize` can wait for them: a status that reached
|
|
261
|
+
* Slack after the reply would stand over a finished turn until Slack's
|
|
262
|
+
* own timeout, since the post that clears it came first.
|
|
263
|
+
*/
|
|
264
|
+
statusChain = Promise.resolve();
|
|
265
|
+
/**
|
|
266
|
+
* The turn this one was queued behind has posted everything it will
|
|
267
|
+
* (see `posted`). A streaming turn holds its place with the placeholder
|
|
268
|
+
* it opened at intake; a `final` one has no placeholder, so it waits for
|
|
269
|
+
* this before putting text or files in the thread — or a quick answer
|
|
270
|
+
* would land above the slow one ahead of it.
|
|
271
|
+
*/
|
|
272
|
+
after;
|
|
273
|
+
settle;
|
|
274
|
+
/** Resolves once this turn has put in the thread everything it will. */
|
|
275
|
+
posted = new Promise((resolve) => {
|
|
276
|
+
this.settle = resolve;
|
|
277
|
+
});
|
|
278
|
+
statusText;
|
|
279
|
+
statusTimer;
|
|
280
|
+
/** The tool the turn is running, for the status line; the streamed `toolLine` is the other mode's. */
|
|
281
|
+
runningTool;
|
|
223
282
|
web;
|
|
224
283
|
channel;
|
|
225
284
|
threadTs;
|
|
@@ -234,6 +293,83 @@ class ReplyRenderer {
|
|
|
234
293
|
this.warn = warn;
|
|
235
294
|
this.lazy = options.lazy ?? false;
|
|
236
295
|
this.now = options.now ?? Date.now;
|
|
296
|
+
this.streaming = options.streaming ?? true;
|
|
297
|
+
this.statusThread = options.statusThread ?? threadTs;
|
|
298
|
+
this.statusWarned = options.statusWarned ?? { value: false };
|
|
299
|
+
this.after = options.after ?? Promise.resolve();
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Show Slack's loading status for this turn, and keep it up until the
|
|
303
|
+
* turn is finalized. Not for a streaming turn, which has its placeholder,
|
|
304
|
+
* nor for one nobody asked for: "is thinking…" over a turn that may
|
|
305
|
+
* decide to say nothing is the interruption silence exists to avoid.
|
|
306
|
+
*/
|
|
307
|
+
showLoading(head) {
|
|
308
|
+
if (this.streaming || this.lazy || this.loading || this.finalized) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
this.loading = true;
|
|
312
|
+
// A thread has one status per app, so a turn queued behind another
|
|
313
|
+
// must not publish its own: it would overwrite what the running turn
|
|
314
|
+
// says it is doing. It publishes when it reaches the head (`beginTurn`,
|
|
315
|
+
// or `refreshLoading` once the turn ahead has posted).
|
|
316
|
+
if (head) {
|
|
317
|
+
this.publishLoading();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
publishLoading() {
|
|
321
|
+
if (this.statusTimer || this.finalized) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
this.setStatus(this.statusText ?? THINKING_STATUS);
|
|
325
|
+
this.statusTimer = setInterval(() => this.refreshLoading(), STATUS_REFRESH_MS);
|
|
326
|
+
this.statusTimer.unref?.();
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Set the status again. Slack clears a thread's status whenever the app
|
|
330
|
+
* posts in it, so a reply or file from the turn ahead of this one — or
|
|
331
|
+
* this turn's own upload — takes it down while this turn is still
|
|
332
|
+
* working; the adapter calls this once that post has landed.
|
|
333
|
+
*/
|
|
334
|
+
refreshLoading() {
|
|
335
|
+
if (!this.loading || this.finalized) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (!this.statusTimer) {
|
|
339
|
+
// Queued until now: this is the renderer at the head at last.
|
|
340
|
+
this.publishLoading();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
this.setStatus(this.statusText ?? THINKING_STATUS);
|
|
344
|
+
}
|
|
345
|
+
stopLoading() {
|
|
346
|
+
if (this.statusTimer) {
|
|
347
|
+
clearInterval(this.statusTimer);
|
|
348
|
+
this.statusTimer = undefined;
|
|
349
|
+
}
|
|
350
|
+
this.loading = false;
|
|
351
|
+
}
|
|
352
|
+
setStatus(text) {
|
|
353
|
+
const setStatus = this.web.assistant?.threads.setStatus;
|
|
354
|
+
if (!setStatus || this.statusThread === undefined) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
this.statusText = text;
|
|
358
|
+
const thread = this.statusThread;
|
|
359
|
+
// Not awaited by the caller: the turn never waits on a status. Only
|
|
360
|
+
// `finalize` does, for the ordering reason on `statusChain`. A refusal
|
|
361
|
+
// — a workspace or plan without it, a token Slack will not honour for
|
|
362
|
+
// it — is said once and then left alone; the reply still arrives,
|
|
363
|
+
// just without the status ahead of it.
|
|
364
|
+
this.statusChain = this.statusChain
|
|
365
|
+
.then(() => setStatus.call(this.web.assistant?.threads, { channel_id: this.channel, thread_ts: thread, status: text }))
|
|
366
|
+
.then(() => undefined, (error) => {
|
|
367
|
+
if (!this.statusWarned.value) {
|
|
368
|
+
this.statusWarned.value = true;
|
|
369
|
+
this.warn(`slack: could not show a loading status (${error instanceof Error ? error.message : String(error)}); `
|
|
370
|
+
+ 'replies still post when they are ready, without one');
|
|
371
|
+
}
|
|
372
|
+
});
|
|
237
373
|
}
|
|
238
374
|
/**
|
|
239
375
|
* The gateway has begun this renderer's turn. Whatever was streamed here
|
|
@@ -250,8 +386,17 @@ class ReplyRenderer {
|
|
|
250
386
|
}
|
|
251
387
|
this.buffer = '';
|
|
252
388
|
this.toolLine = undefined;
|
|
389
|
+
this.runningTool = undefined;
|
|
253
390
|
this.turnBreakPending = false;
|
|
254
391
|
this.generation += 1;
|
|
392
|
+
// Queued until now, this turn publishes its status as it starts; one
|
|
393
|
+
// already showing may be showing a tool the turn ahead of it ran.
|
|
394
|
+
if (this.loading && !this.statusTimer) {
|
|
395
|
+
this.publishLoading();
|
|
396
|
+
}
|
|
397
|
+
else if (this.loading && this.statusText !== THINKING_STATUS) {
|
|
398
|
+
this.setStatus(THINKING_STATUS);
|
|
399
|
+
}
|
|
255
400
|
}
|
|
256
401
|
async open() {
|
|
257
402
|
const posted = await this.web.chat.postMessage({
|
|
@@ -300,6 +445,7 @@ class ReplyRenderer {
|
|
|
300
445
|
// the text after it — without it they fuse in the live message.
|
|
301
446
|
this.turnBreakPending = true;
|
|
302
447
|
this.toolLine = `⚙ ${event.call.toolName}…`;
|
|
448
|
+
this.runningTool = event.call.toolName;
|
|
303
449
|
this.scheduleEdit();
|
|
304
450
|
return;
|
|
305
451
|
}
|
|
@@ -309,12 +455,14 @@ class ReplyRenderer {
|
|
|
309
455
|
// message would keep claiming a refused tool is running, and the
|
|
310
456
|
// model's follow-up would fuse with the text before the attempt.
|
|
311
457
|
this.toolLine = undefined;
|
|
458
|
+
this.runningTool = undefined;
|
|
312
459
|
this.turnBreakPending = true;
|
|
313
460
|
this.scheduleEdit();
|
|
314
461
|
return;
|
|
315
462
|
}
|
|
316
463
|
if (event.type === 'tool.completed') {
|
|
317
464
|
this.toolLine = undefined;
|
|
465
|
+
this.runningTool = undefined;
|
|
318
466
|
// Also a boundary, and not only for symmetry: a call rejected before
|
|
319
467
|
// execution settles as tool.completed without ever having emitted
|
|
320
468
|
// tool.called, so this is the only mark that attempt leaves.
|
|
@@ -348,8 +496,10 @@ class ReplyRenderer {
|
|
|
348
496
|
// it, and an upload that then waited for that handover would be a
|
|
349
497
|
// cycle nothing breaks (see `queueEdit`).
|
|
350
498
|
const handover = this.handover;
|
|
499
|
+
const after = this.streaming ? undefined : this.after;
|
|
351
500
|
this.uploadChain = this.uploadChain
|
|
352
501
|
.then(() => handover)
|
|
502
|
+
.then(() => after)
|
|
353
503
|
// Read as file DATA inside the chain: the Web API takes contents,
|
|
354
504
|
// not a path, and a missing file surfaces as this upload's own
|
|
355
505
|
// failure instead of an unhandled stream error.
|
|
@@ -372,6 +522,9 @@ class ReplyRenderer {
|
|
|
372
522
|
...(this.threadTs ? { thread_ts: this.threadTs } : {}),
|
|
373
523
|
});
|
|
374
524
|
this.uploaded = true;
|
|
525
|
+
// The upload is a post of the app's in this thread, which takes
|
|
526
|
+
// the status down with it while the turn is still working.
|
|
527
|
+
this.refreshLoading();
|
|
375
528
|
const placed = uploadPlace(result, this.channel) ?? before;
|
|
376
529
|
if (this.uploadedAt === undefined || placed > this.uploadedAt) {
|
|
377
530
|
this.uploadedAt = placed;
|
|
@@ -384,6 +537,15 @@ class ReplyRenderer {
|
|
|
384
537
|
return parts.length > 0 ? parts.join('\n\n') : PLACEHOLDER_TEXT;
|
|
385
538
|
}
|
|
386
539
|
scheduleEdit() {
|
|
540
|
+
if (!this.streaming) {
|
|
541
|
+
// Nothing to edit: what the turn is doing goes into the status line,
|
|
542
|
+
// and only once the turn is this renderer's own (see `beginTurn`).
|
|
543
|
+
const status = this.runningTool !== undefined ? toolStatus(this.runningTool) : THINKING_STATUS;
|
|
544
|
+
if (this.loading && this.statusTimer && this.turnStarted && !this.finalized && status !== this.statusText) {
|
|
545
|
+
this.setStatus(status);
|
|
546
|
+
}
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
387
549
|
// Not gated on having a placeholder: during a handover there is none
|
|
388
550
|
// for a moment, and the edit reads the fresh one when its turn comes.
|
|
389
551
|
if (this.pendingEdit) {
|
|
@@ -464,8 +626,25 @@ class ReplyRenderer {
|
|
|
464
626
|
* refused sentence is still the last thing said, for the thread rule.
|
|
465
627
|
*/
|
|
466
628
|
async finalize(reply) {
|
|
629
|
+
try {
|
|
630
|
+
return await this.finalizeInOrder(reply);
|
|
631
|
+
}
|
|
632
|
+
finally {
|
|
633
|
+
this.settle();
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
async finalizeInOrder(reply) {
|
|
467
637
|
await this.handover;
|
|
638
|
+
// Shown, not merely wanted: a turn still queued behind another never
|
|
639
|
+
// published one, and a clear from it would take down the running
|
|
640
|
+
// turn's status instead.
|
|
641
|
+
const hadStatus = this.statusTimer !== undefined;
|
|
642
|
+
this.stopLoading();
|
|
468
643
|
this.finalized = true;
|
|
644
|
+
if (!this.streaming) {
|
|
645
|
+
await this.after;
|
|
646
|
+
await this.statusChain;
|
|
647
|
+
}
|
|
469
648
|
if (this.pendingEdit) {
|
|
470
649
|
clearTimeout(this.pendingEdit);
|
|
471
650
|
this.pendingEdit = undefined;
|
|
@@ -523,6 +702,15 @@ class ReplyRenderer {
|
|
|
523
702
|
this.placeText(this.ref?.ts);
|
|
524
703
|
}
|
|
525
704
|
}
|
|
705
|
+
// A reply posted clears the status itself; one Slack refused would
|
|
706
|
+
// leave "is thinking…" standing over a turn that has ended.
|
|
707
|
+
if (hadStatus && !landed) {
|
|
708
|
+
this.setStatus('');
|
|
709
|
+
// Awaited: the turn queued behind this one re-shows its own status
|
|
710
|
+
// once this renderer is done, and a clear that reached Slack after
|
|
711
|
+
// that would take the next turn's status down with it.
|
|
712
|
+
await this.statusChain;
|
|
713
|
+
}
|
|
526
714
|
return this.outcome(published, landed || this.edited || this.uploaded || stranded);
|
|
527
715
|
}
|
|
528
716
|
placeText(ts) {
|
|
@@ -709,343 +897,6 @@ const safeCutIndex = (text, index) => {
|
|
|
709
897
|
const truncateForSlack = (text) => text.length <= SLACK_MAX_MESSAGE_CHARS
|
|
710
898
|
? text
|
|
711
899
|
: `${text.slice(0, safeCutIndex(text, SLACK_MAX_MESSAGE_CHARS - 1))}…`;
|
|
712
|
-
/**
|
|
713
|
-
* Where the run of exactly `length` backticks that closes an open one
|
|
714
|
-
* begins, or -1. Exactly: a longer run is not a closer, which is the whole
|
|
715
|
-
* point of writing a span as ``code with a ` in it`` and a fence as ````
|
|
716
|
-
* when the block itself contains ```.
|
|
717
|
-
*/
|
|
718
|
-
const closingBacktickRun = (text, from, length, limit) => {
|
|
719
|
-
const run = '`'.repeat(length);
|
|
720
|
-
for (let at = text.indexOf(run, from); at !== -1 && at + length <= limit; at = text.indexOf(run, at + 1)) {
|
|
721
|
-
if (text[at - 1] !== '`' && text[at + length] !== '`') {
|
|
722
|
-
return at;
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
return -1;
|
|
726
|
-
};
|
|
727
|
-
/**
|
|
728
|
-
* The text as alternating prose and code — even indices prose, odd code.
|
|
729
|
-
*
|
|
730
|
-
* Code is the one place a reader means the characters themselves: `**` in a
|
|
731
|
-
* shell snippet is the snippet, so nothing below rewrites it. A run of N
|
|
732
|
-
* backticks opens code and the next run of exactly N closes it, which is
|
|
733
|
-
* the same rule for a span and for a fence and is why a longer delimiter
|
|
734
|
-
* can carry a shorter one inside it.
|
|
735
|
-
*
|
|
736
|
-
* A span may close on a later line, and is protected the whole way. That
|
|
737
|
-
* looks wasteful — two stray backticks paragraphs apart become one long
|
|
738
|
-
* "span" whose contents are left unconverted — and it was briefly changed
|
|
739
|
-
* to end a span at its line for exactly that reason. That was wrong, and
|
|
740
|
-
* the reason is the rule this whole file answers to: what Slack does with
|
|
741
|
-
* the message decides, not what Markdown says. Slack pairs those backticks
|
|
742
|
-
* too, so the text between them is what it renders as code, and rewriting
|
|
743
|
-
* a `**bold**` in there would alter the contents of somebody's snippet.
|
|
744
|
-
* Leaving prose unconverted is a cosmetic loss; changing what a reader is
|
|
745
|
-
* told is code is not, so the doubt resolves toward protecting more.
|
|
746
|
-
*
|
|
747
|
-
* A fence with no closer runs to the end on purpose: a model that forgets
|
|
748
|
-
* to close one, and every partially streamed block on its way to being
|
|
749
|
-
* closed, would otherwise have its contents rewritten as prose. One or two
|
|
750
|
-
* unmatched backticks are the opposite case — literal text, since "use the
|
|
751
|
-
* ` character" must not swallow what follows.
|
|
752
|
-
*/
|
|
753
|
-
const proseAndCode = (text) => {
|
|
754
|
-
const segments = [];
|
|
755
|
-
let prose = 0;
|
|
756
|
-
let cursor = 0;
|
|
757
|
-
while (cursor < text.length) {
|
|
758
|
-
const open = text.indexOf('`', cursor);
|
|
759
|
-
if (open === -1) {
|
|
760
|
-
break;
|
|
761
|
-
}
|
|
762
|
-
let length = 1;
|
|
763
|
-
while (text[open + length] === '`') {
|
|
764
|
-
length += 1;
|
|
765
|
-
}
|
|
766
|
-
const close = closingBacktickRun(text, open + length, length, text.length);
|
|
767
|
-
if (close === -1) {
|
|
768
|
-
if (length < 3) {
|
|
769
|
-
cursor = open + length;
|
|
770
|
-
continue;
|
|
771
|
-
}
|
|
772
|
-
segments.push(text.slice(prose, open), text.slice(open));
|
|
773
|
-
return segments;
|
|
774
|
-
}
|
|
775
|
-
segments.push(text.slice(prose, open), text.slice(open, close + length));
|
|
776
|
-
prose = close + length;
|
|
777
|
-
cursor = prose;
|
|
778
|
-
}
|
|
779
|
-
segments.push(text.slice(prose));
|
|
780
|
-
return segments;
|
|
781
|
-
};
|
|
782
|
-
/**
|
|
783
|
-
* The spellings that differ, applied to prose only, in an order that matters.
|
|
784
|
-
*
|
|
785
|
-
* Single-asterisk emphasis goes first and is barred from crossing a `*`, so
|
|
786
|
-
* `**bold**` cannot be read as an italic run that happens to begin with one.
|
|
787
|
-
* Bold then rewrites what is left, and its output is never rescanned.
|
|
788
|
-
*
|
|
789
|
-
* Each delimiter has to hug its content — `3 * 4 * 5` is arithmetic, not
|
|
790
|
-
* emphasis — and none may span a line, so one stray marker cannot italicize
|
|
791
|
-
* the rest of a message.
|
|
792
|
-
*/
|
|
793
|
-
const MRKDWN_REWRITES = [
|
|
794
|
-
// Both at once, before either rule can take half of it and leave the
|
|
795
|
-
// other half as literal asterisks. Its output is why the italic rule
|
|
796
|
-
// below refuses content that already opens with an underscore.
|
|
797
|
-
[/\*\*\*([^\s*][^*\n]*[^\s*]|[^\s*])\*\*\*/g, '*_$1_*'],
|
|
798
|
-
[/(^|[^*])\*([^\s*_][^*\n]*[^\s*]|[^\s*_])\*(?!\*)/g, '$1_$2_'],
|
|
799
|
-
[/\*\*([^\s*][^*\n]*[^\s*]|[^\s*])\*\*/g, '*$1*'],
|
|
800
|
-
[/(^|[^_])__([^\s_][^_\n]*[^\s_]|[^\s_])__(?!_)/g, '$1*$2*'],
|
|
801
|
-
[/~~([^\s~][^~\n]*[^\s~]|[^\s~])~~/g, '~$1~'],
|
|
802
|
-
// The one construct Slack spells backwards. A scheme is required: a
|
|
803
|
-
// bare `[1](2)` in prose is not a link anybody meant to follow.
|
|
804
|
-
[/\[([^\]\n]+)\]\(((?:https?:\/\/|mailto:)[^()\s]+)\)/g, '<$2|$1>'],
|
|
805
|
-
];
|
|
806
|
-
/**
|
|
807
|
-
* `## Heading`, which mrkdwn has no spelling for at all.
|
|
808
|
-
*
|
|
809
|
-
* The optional closing hashes have to be spaced off the text, as Markdown
|
|
810
|
-
* requires: without that, `# C#` is a heading whose name loses its last
|
|
811
|
-
* character.
|
|
812
|
-
*
|
|
813
|
-
* What they and any trailing spaces cover is captured rather than dropped,
|
|
814
|
-
* because dropping is only right when they are the heading's own syntax. A
|
|
815
|
-
* heading that opens a code span reaches its end inside one, and there the
|
|
816
|
-
* same characters are the snippet: `` # show `value # `` has a hash that
|
|
817
|
-
* belongs to whoever wrote the code.
|
|
818
|
-
*/
|
|
819
|
-
const MARKDOWN_HEADING = /^ {0,3}#{1,6}[ \t]+(.+?)((?:[ \t]+#+)?[ \t]*)$/gm;
|
|
820
|
-
const convertInline = (segment) => {
|
|
821
|
-
let converted = segment;
|
|
822
|
-
for (const [pattern, replacement] of MRKDWN_REWRITES) {
|
|
823
|
-
converted = converted.replace(pattern, replacement);
|
|
824
|
-
}
|
|
825
|
-
return converted;
|
|
826
|
-
};
|
|
827
|
-
/**
|
|
828
|
-
* Headings, found on the text's own lines rather than inside each prose
|
|
829
|
-
* fragment.
|
|
830
|
-
*
|
|
831
|
-
* A fragment is not a line: inline code cuts one in two, and the halves
|
|
832
|
-
* begin wherever the code ended. Matched per fragment, `^` then lied in
|
|
833
|
-
* both directions — the tail of `` `status` # not a heading `` looked like a
|
|
834
|
-
* line of its own and lost its hash, and ``## Run `npm test` now`` was only
|
|
835
|
-
* ever half a line, so only the half before the code was wrapped.
|
|
836
|
-
*
|
|
837
|
-
* So the whole text is matched instead, with the code spans it now contains
|
|
838
|
-
* passed in: a `#` whose line begins inside one is a comment in somebody's
|
|
839
|
-
* snippet, not a heading. A heading that merely *contains* code is a
|
|
840
|
-
* heading, and its code goes along untouched.
|
|
841
|
-
*
|
|
842
|
-
* Emphasis inside is already converted by here, so the line is only wrapped
|
|
843
|
-
* when nothing in it can pair with the wrapper. Slack has one bold
|
|
844
|
-
* delimiter and no way to nest it: wrapping `*Run* the thing` again yields
|
|
845
|
-
* `**Run* the thing*`, which it renders as literal asterisks rather than as
|
|
846
|
-
* anything bold. A heading that already carries emphasis therefore keeps
|
|
847
|
-
* exactly the emphasis it has — `## **Title**` is bold by now and needs
|
|
848
|
-
* nothing more, and `# glob *.ts` keeps an asterisk that was never a marker
|
|
849
|
-
* at all. An unbolded heading reads fine; a heading full of stray asterisks
|
|
850
|
-
* does not, and one with characters removed to make room is worse than both.
|
|
851
|
-
*
|
|
852
|
-
* An asterisk inside a code span is not one of those: Slack does not read
|
|
853
|
-
* markup there, so it cannot pair with anything, and ``## Match `*.ts` files``
|
|
854
|
-
* is bolded like any other heading.
|
|
855
|
-
*
|
|
856
|
-
* Both walks are cursors rather than searches: `replace` reports matches in
|
|
857
|
-
* increasing offset, and the spans were collected in that order too, so
|
|
858
|
-
* neither has to be looked at twice. The same work as a scan per heading,
|
|
859
|
-
* without its quadratic on a reply that is mostly headings and snippets.
|
|
860
|
-
*/
|
|
861
|
-
/** What could pair with a heading's wrapper, or with something out of sight. */
|
|
862
|
-
const PAIRABLE_MARKER = /[*`]/g;
|
|
863
|
-
const convertHeadings = (text, code) => {
|
|
864
|
-
let span = 0;
|
|
865
|
-
const startsInside = (position, from) => position >= (code[from]?.[0] ?? Number.POSITIVE_INFINITY);
|
|
866
|
-
return text.replace(MARKDOWN_HEADING, (line, body, tail, offset) => {
|
|
867
|
-
while (span < code.length && (code[span]?.[1] ?? 0) <= offset) {
|
|
868
|
-
span += 1;
|
|
869
|
-
}
|
|
870
|
-
// A `#` whose line begins inside a span is a comment in somebody's code.
|
|
871
|
-
if (startsInside(offset, span)) {
|
|
872
|
-
return line;
|
|
873
|
-
}
|
|
874
|
-
const heading = body.trim();
|
|
875
|
-
if (heading.length === 0) {
|
|
876
|
-
return line;
|
|
877
|
-
}
|
|
878
|
-
// Where everything this would drop from the line's end begins: the
|
|
879
|
-
// closing hashes and spaces the pattern captured, and whatever else
|
|
880
|
-
// `trimEnd` would take with them. The two are not the same set — the
|
|
881
|
-
// pattern knows only spaces and tabs, while trimming also takes a
|
|
882
|
-
// no-break space, an ideographic space, and the rest of what
|
|
883
|
-
// ECMAScript counts as whitespace — and the difference is exactly
|
|
884
|
-
// where a character goes missing.
|
|
885
|
-
//
|
|
886
|
-
// Dropping any of it is the heading's own syntax being removed, but
|
|
887
|
-
// only while it is the heading's: a line that opens a span reaches its
|
|
888
|
-
// end inside one, and there the same characters are somebody's
|
|
889
|
-
// snippet. Nothing on this line is safe to strip then, so it is left
|
|
890
|
-
// exactly as written. (The front needs no such care: the body starts
|
|
891
|
-
// where the hashes stop, which this line has already been shown to
|
|
892
|
-
// reach in prose.)
|
|
893
|
-
const dropped = tail.length + (body.length - body.trimEnd().length);
|
|
894
|
-
if (dropped > 0) {
|
|
895
|
-
const droppedAt = offset + line.length - dropped;
|
|
896
|
-
let over = span;
|
|
897
|
-
while (over < code.length && (code[over]?.[1] ?? 0) <= droppedAt) {
|
|
898
|
-
over += 1;
|
|
899
|
-
}
|
|
900
|
-
if (startsInside(droppedAt, over)) {
|
|
901
|
-
return line;
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
// Anything left on the line that a delimiter could pair with, walked
|
|
905
|
-
// from where the heading cursor already stands rather than from the
|
|
906
|
-
// beginning. An asterisk is the obvious one — it would pair with the
|
|
907
|
-
// wrapper. A backtick counts too: one this side found no partner for is
|
|
908
|
-
// a literal character here, but Slack parses the message itself and may
|
|
909
|
-
// pair it with a backtick further down, and the wrapper's closing `*`
|
|
910
|
-
// would then be written inside what Slack reads as code, where it is
|
|
911
|
-
// ignored — leaving the opening one with nothing to close it. Both are
|
|
912
|
-
// spent characters inside a span, which is why the ranges are consulted
|
|
913
|
-
// rather than the text alone.
|
|
914
|
-
let within = span;
|
|
915
|
-
for (const marker of line.matchAll(PAIRABLE_MARKER)) {
|
|
916
|
-
const position = offset + (marker.index ?? 0);
|
|
917
|
-
while (within < code.length && (code[within]?.[1] ?? 0) <= position) {
|
|
918
|
-
within += 1;
|
|
919
|
-
}
|
|
920
|
-
if (!startsInside(position, within)) {
|
|
921
|
-
return heading;
|
|
922
|
-
}
|
|
923
|
-
}
|
|
924
|
-
// Both markers have to land in prose, not just the opening one. A fence
|
|
925
|
-
// opened on this line — by a model mid-answer, or by a block still being
|
|
926
|
-
// streamed — runs past its end, so the closing `*` would be written
|
|
927
|
-
// inside the code and ignored there, leaving the opening one unpaired.
|
|
928
|
-
// The line began in prose or this never ran, so it is the end that is
|
|
929
|
-
// in question: where the marker would go, one past the line's last
|
|
930
|
-
// character, and not that character itself — a span that closes exactly
|
|
931
|
-
// at the line's end leaves the marker just outside it, which is fine.
|
|
932
|
-
const closer = offset + line.length;
|
|
933
|
-
while (within < code.length && (code[within]?.[1] ?? 0) <= closer) {
|
|
934
|
-
within += 1;
|
|
935
|
-
}
|
|
936
|
-
return startsInside(closer, within) ? heading : `*${heading}*`;
|
|
937
|
-
});
|
|
938
|
-
};
|
|
939
|
-
/**
|
|
940
|
-
* A stand-in for one code segment while the prose around it is rewritten.
|
|
941
|
-
*
|
|
942
|
-
* Emphasis was converted one prose fragment at a time until this existed,
|
|
943
|
-
* and a fragment is not a run: inline code cuts one in two, so the `**`
|
|
944
|
-
* opening a bold run and the `**` closing it land in different fragments
|
|
945
|
-
* whenever a snippet sits between them, and neither half ever finds its
|
|
946
|
-
* partner. ``**the `fs.read` tool**`` reached a reader as four literal
|
|
947
|
-
* asterisks around a snippet, and so did every link, italic and
|
|
948
|
-
* strikethrough written the same way. It is the defect #142 found in
|
|
949
|
-
* headings, in the rule right next to it, and it has the same answer:
|
|
950
|
-
* rewrite the whole text, with code held out of the rewrite rather than
|
|
951
|
-
* cut out of the text.
|
|
952
|
-
*
|
|
953
|
-
* A mask names its segment rather than merely standing in for one, because
|
|
954
|
-
* a rewrite is allowed to move it: the link rule writes its two captures
|
|
955
|
-
* back in the other order, so `[use `a`](https://host/`b`)` hands the URL's
|
|
956
|
-
* snippet to the label and the label's to the URL, and a snippet that
|
|
957
|
-
* arrives somewhere else is the same defect as one that is rewritten. Named,
|
|
958
|
-
* it comes back to the place holding its own name. No rewrite can damage
|
|
959
|
-
* the name itself: none of the delimiters they look for appear in it, and
|
|
960
|
-
* every rule copies what it matched through to its replacement.
|
|
961
|
-
*
|
|
962
|
-
* A mask also carries whether its segment holds whitespace, because three
|
|
963
|
-
* rules refuse to cross it — a destination is `[^()\s]+`, a label and an
|
|
964
|
-
* emphasis run stop at a newline — and a name alone has none to stop them.
|
|
965
|
-
* `[label](https://host/`a b`)` is not a destination and was never meant to
|
|
966
|
-
* convert; masked shapelessly it did, and came back rearranged into a link
|
|
967
|
-
* Slack cannot render. Only whitespace is carried, and only ever so a rule
|
|
968
|
-
* refuses: a `*` in a snippet must still not act like one. Both ends of a
|
|
969
|
-
* mask stay the mask character, so a run that has to hug its content still
|
|
970
|
-
* hugs it — a segment opens and closes with a backtick, so that is not a
|
|
971
|
-
* lie about its ends.
|
|
972
|
-
*
|
|
973
|
-
* The character is chosen against the text rather than fixed, because a
|
|
974
|
-
* reply that already contained it would have a snippet spliced in where
|
|
975
|
-
* the writer's own character stood — the one thing this conversion must
|
|
976
|
-
* never do. A reply holding all 6400 of the private-use characters has no
|
|
977
|
-
* mask to be given and is left in Markdown instead: prose that reads as it
|
|
978
|
-
* was written is a cosmetic loss, and a snippet spliced somewhere else is
|
|
979
|
-
* not.
|
|
980
|
-
*/
|
|
981
|
-
const maskFor = (text) => {
|
|
982
|
-
for (let point = 0xe000; point <= 0xf8ff; point += 1) {
|
|
983
|
-
const candidate = String.fromCharCode(point);
|
|
984
|
-
if (!text.includes(candidate)) {
|
|
985
|
-
return candidate;
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
return undefined;
|
|
989
|
-
};
|
|
990
|
-
/**
|
|
991
|
-
* Markdown as a model writes it, in the spelling Slack actually renders.
|
|
992
|
-
*
|
|
993
|
-
* Slack's mrkdwn is not Markdown, and the gap is not cosmetic: `**bold**`
|
|
994
|
-
* reaches a reader as four literal asterisks, and `## Heading` as a line
|
|
995
|
-
* that starts with two hashes. Models write Markdown because that is what
|
|
996
|
-
* every other surface here renders, and a soul should not have to know
|
|
997
|
-
* which channel is carrying it, so the translation happens at the edge that
|
|
998
|
-
* does know.
|
|
999
|
-
*
|
|
1000
|
-
* Only what differs is touched. Lists, block quotes, and inline code
|
|
1001
|
-
* already mean in mrkdwn what they mean in Markdown; rewriting them would
|
|
1002
|
-
* add ways to be wrong and fix nothing.
|
|
1003
|
-
*/
|
|
1004
|
-
const toSlackMrkdwn = (text) => {
|
|
1005
|
-
const mask = maskFor(text);
|
|
1006
|
-
if (mask === undefined) {
|
|
1007
|
-
return text;
|
|
1008
|
-
}
|
|
1009
|
-
const segments = proseAndCode(text);
|
|
1010
|
-
const code = [];
|
|
1011
|
-
let masked = '';
|
|
1012
|
-
for (const [index, segment] of segments.entries()) {
|
|
1013
|
-
if (index % 2 === 1) {
|
|
1014
|
-
const shape = /\n/.test(segment) ? '\n' : /\s/.test(segment) ? ' ' : '';
|
|
1015
|
-
masked += `${mask}${code.length}${shape}${mask}`;
|
|
1016
|
-
code.push(segment);
|
|
1017
|
-
continue;
|
|
1018
|
-
}
|
|
1019
|
-
masked += segment;
|
|
1020
|
-
}
|
|
1021
|
-
// A code segment that ends the text is the unterminated fence: every
|
|
1022
|
-
// closed run is followed by a prose slice, even an empty one, so only an
|
|
1023
|
-
// unclosed one leaves the segments ending on a code index. Named, not
|
|
1024
|
-
// last: a rewrite may have moved it, and it is the segment that ran to
|
|
1025
|
-
// the text's end when it was cut, wherever it now sits.
|
|
1026
|
-
const unterminated = segments.length % 2 === 0 ? code.length - 1 : -1;
|
|
1027
|
-
// Where each code segment ends up in the converted text, which is not
|
|
1028
|
-
// where it started: the prose around it changes length as it is
|
|
1029
|
-
// rewritten, and the masks are the only record of where it went. Split
|
|
1030
|
-
// on the names, so the pieces alternate prose and the name of the
|
|
1031
|
-
// segment that follows it.
|
|
1032
|
-
const pieces = convertInline(masked).split(new RegExp(`${mask}(\\d+)[ \\n]?${mask}`));
|
|
1033
|
-
const spans = [];
|
|
1034
|
-
let converted = pieces[0] ?? '';
|
|
1035
|
-
for (let piece = 1; piece < pieces.length; piece += 2) {
|
|
1036
|
-
const named = Number(pieces[piece]);
|
|
1037
|
-
const segment = code[named] ?? '';
|
|
1038
|
-
// The unterminated fence has no end to be past, so nothing may be
|
|
1039
|
-
// appended after it either — recording the end as it looks would say a
|
|
1040
|
-
// marker placed at the text's end was safely outside.
|
|
1041
|
-
const ends = named === unterminated
|
|
1042
|
-
? Number.POSITIVE_INFINITY
|
|
1043
|
-
: converted.length + segment.length;
|
|
1044
|
-
spans.push([converted.length, ends]);
|
|
1045
|
-
converted += `${segment}${pieces[piece + 1] ?? ''}`;
|
|
1046
|
-
}
|
|
1047
|
-
return convertHeadings(converted, spans);
|
|
1048
|
-
};
|
|
1049
900
|
const splitForSlack = (text) => {
|
|
1050
901
|
if (text.length <= SLACK_MAX_MESSAGE_CHARS) {
|
|
1051
902
|
return [text];
|
|
@@ -1452,7 +1303,9 @@ const approvalBlocks = (agentName, toolName, risk, requestId, input, origin, one
|
|
|
1452
1303
|
* Session keys are `slack:<agent>:<team>:<channel>:<thread_ts ?? ts>`
|
|
1453
1304
|
* (DMs: the DM channel id alone), so threads are resumable conversations
|
|
1454
1305
|
* and two agents sharing a thread keep fully separate sessions. Replies
|
|
1455
|
-
*
|
|
1306
|
+
* post once, finished, behind Slack's loading status — or, in the `stream`
|
|
1307
|
+
* reply mode, stream via placeholder-then-edit, throttled for chat.update
|
|
1308
|
+
* limits.
|
|
1456
1309
|
*
|
|
1457
1310
|
* A mention starts a thread conversation; inside one, the agent keeps
|
|
1458
1311
|
* listening without being tagged again — see `couldBeFollowUp` and
|
|
@@ -1480,6 +1333,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1480
1333
|
// to the FIRST registered renderer (the running turn), never a later
|
|
1481
1334
|
// message's placeholder. Each handler removes exactly its own renderer.
|
|
1482
1335
|
const renderers = new Map();
|
|
1336
|
+
/**
|
|
1337
|
+
* Per session, what the newest `final` reply has left to post. A `final`
|
|
1338
|
+
* turn holds no place in the thread until it posts, so each waits for
|
|
1339
|
+
* the one queued ahead of it — see `ReplyRenderer.after`.
|
|
1340
|
+
*/
|
|
1341
|
+
const replyOrder = new Map();
|
|
1483
1342
|
/**
|
|
1484
1343
|
* Files a turn nobody here rendered produced — a recovery's screenshot,
|
|
1485
1344
|
* say — keyed by session, waiting for the turn's outcome to be posted so
|
|
@@ -1630,6 +1489,39 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1630
1489
|
return userId;
|
|
1631
1490
|
}
|
|
1632
1491
|
};
|
|
1492
|
+
/**
|
|
1493
|
+
* A display name as the model may read it: one line, bounded. It is text
|
|
1494
|
+
* its owner typed into their profile, and it lands in the speaker
|
|
1495
|
+
* position of a user turn (`Name: text`) or as `@Name` inside one — where
|
|
1496
|
+
* a newline, or a name long enough to be a paragraph, would let its owner
|
|
1497
|
+
* speak in a place nobody vouched for. Escaped rather than dropped, the
|
|
1498
|
+
* way memory entries are, so a strange name still reads as strange.
|
|
1499
|
+
*/
|
|
1500
|
+
const boundedDisplayName = (name) => {
|
|
1501
|
+
// Code points, not UTF-16 units: an emoji is one character of a name,
|
|
1502
|
+
// and a cut inside a surrogate pair is a malformed speaker.
|
|
1503
|
+
const characters = Array.from(escapeControlCharacters(name).trim());
|
|
1504
|
+
return characters.length > MAX_DISPLAY_NAME_LENGTH
|
|
1505
|
+
? `${characters.slice(0, MAX_DISPLAY_NAME_LENGTH - 1).join('')}…`
|
|
1506
|
+
: characters.join('');
|
|
1507
|
+
};
|
|
1508
|
+
/**
|
|
1509
|
+
* Who is speaking, as the model reads it. A principal's profile is
|
|
1510
|
+
* trusted the way their messages are, so it is their display name. With
|
|
1511
|
+
* a principals list in force everyone else is their stable id, as a
|
|
1512
|
+
* mention of them already is: named, not quoted. With no list at all
|
|
1513
|
+
* there is nobody to prefer, so every author keeps their name — the turn
|
|
1514
|
+
* is labelled `unknown` either way. "No list" is the key being absent:
|
|
1515
|
+
* an explicit empty list is how an agent is excluded from a shared one,
|
|
1516
|
+
* and it means nobody is preferred, not that nobody has been named.
|
|
1517
|
+
*/
|
|
1518
|
+
const authorFor = async (connection, userId) => {
|
|
1519
|
+
const principals = connection.config.principals;
|
|
1520
|
+
if (principals !== undefined && !principals.includes(userId)) {
|
|
1521
|
+
return userId;
|
|
1522
|
+
}
|
|
1523
|
+
return boundedDisplayName(await displayNameFor(connection, userId));
|
|
1524
|
+
};
|
|
1633
1525
|
// Mentions arrive as <@U123> markup; the model should read names.
|
|
1634
1526
|
/**
|
|
1635
1527
|
* `<@U…>` mentions become `@name` — for principals only. A display name
|
|
@@ -1648,12 +1540,25 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1648
1540
|
let result = withoutBot;
|
|
1649
1541
|
for (const id of new Set(ids)) {
|
|
1650
1542
|
if (principals.has(id)) {
|
|
1651
|
-
result = result.replaceAll(`<@${id}>`, `@${await displayNameFor(connection, id)}`);
|
|
1543
|
+
result = result.replaceAll(`<@${id}>`, `@${boundedDisplayName(await displayNameFor(connection, id))}`);
|
|
1652
1544
|
}
|
|
1653
1545
|
}
|
|
1654
1546
|
return result.trim();
|
|
1655
1547
|
};
|
|
1656
1548
|
const connectionFor = (agentId) => connections.find((candidate) => candidate.config.agentId === agentId);
|
|
1549
|
+
/** An agent's configuration, whether or not its socket came up. */
|
|
1550
|
+
const agentConfigFor = (agentId) => options.agents.find((candidate) => candidate.agentId === agentId);
|
|
1551
|
+
/**
|
|
1552
|
+
* Whether an agent takes a message from this sender at all — judged on
|
|
1553
|
+
* its configuration, not on a live connection: an agent whose socket
|
|
1554
|
+
* failed to start is still recognizable in a mention, and its door is
|
|
1555
|
+
* still its own. An agent this adapter was never configured for is
|
|
1556
|
+
* nobody's to refuse here, so it admits — the answer from before the
|
|
1557
|
+
* door existed.
|
|
1558
|
+
*/
|
|
1559
|
+
const admitsSender = (config, userId) => config === undefined
|
|
1560
|
+
|| config.admit !== 'principals'
|
|
1561
|
+
|| (config.principals ?? []).includes(userId);
|
|
1657
1562
|
/**
|
|
1658
1563
|
* How an agent listens, from its soul — read per message, so a roster
|
|
1659
1564
|
* reload takes effect on the next one. `thread` when the soul says
|
|
@@ -2116,7 +2021,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2116
2021
|
: event.reason === 'decided' && event.answer === 'always'
|
|
2117
2022
|
? alwaysOutcome(post.always, post.agentName, post.agentId)
|
|
2118
2023
|
: OUTCOME_TEXT[`${event.reason}:${event.answer}`] ?? 'Resolved';
|
|
2119
|
-
|
|
2024
|
+
// A mention only for an actor this post knows as a Slack user — its
|
|
2025
|
+
// approvers, which is the only set a click can come from. An approval
|
|
2026
|
+
// decided through the control API arrives as `api:…` or `dashboard`,
|
|
2027
|
+
// and `<@api:ops-bot>` is not a mention, it is a broken one; escaped
|
|
2028
|
+
// plain text says who it was.
|
|
2029
|
+
const by = event.actor
|
|
2030
|
+
? ` by ${post.approvers.has(event.actor) ? `<@${event.actor}>` : escapeSlackText(event.actor)}`
|
|
2031
|
+
: '';
|
|
2120
2032
|
const text = `*${escapeSlackText(post.agentName)}* — \`${escapeSlackText(post.toolName)}\` (${post.risk}): ${outcome}${by}.`;
|
|
2121
2033
|
try {
|
|
2122
2034
|
await post.connection.web.chat.update({
|
|
@@ -2472,10 +2384,17 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2472
2384
|
* so is one whose `auth.test` itself failed, since its bot id was never
|
|
2473
2385
|
* learned.
|
|
2474
2386
|
*/
|
|
2475
|
-
const agentNamedIn = (text, team
|
|
2387
|
+
const agentNamedIn = (text, team,
|
|
2388
|
+
/**
|
|
2389
|
+
* Whether a named agent would take this message at all. A message
|
|
2390
|
+
* naming two agents hands the thread to one that admits the sender,
|
|
2391
|
+
* not to the first one named: the agent that refuses will not answer,
|
|
2392
|
+
* and a handover to it is a thread nobody holds.
|
|
2393
|
+
*/
|
|
2394
|
+
admits = () => true) => {
|
|
2476
2395
|
let offline;
|
|
2477
2396
|
for (const [agentId, identity] of botIdentities) {
|
|
2478
|
-
if (identity.teamId !== team || !mentions(text, identity.botUserId)) {
|
|
2397
|
+
if (identity.teamId !== team || !mentions(text, identity.botUserId) || !admits(agentId)) {
|
|
2479
2398
|
continue;
|
|
2480
2399
|
}
|
|
2481
2400
|
if (connections.some((live) => live.config.agentId === agentId)) {
|
|
@@ -2664,6 +2583,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2664
2583
|
if (event.user === connection.botUserId) {
|
|
2665
2584
|
return undefined;
|
|
2666
2585
|
}
|
|
2586
|
+
// Bound once the guard above has run: the closures below cannot see
|
|
2587
|
+
// that narrowing.
|
|
2588
|
+
const sender = event.user;
|
|
2667
2589
|
const isDm = event.channel_type === 'im';
|
|
2668
2590
|
const team = args.body?.team_id ?? connection.teamId;
|
|
2669
2591
|
// A top-level mention has no thread_ts; its own ts roots the reply
|
|
@@ -2684,9 +2606,76 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2684
2606
|
// handover, Ava could take the next untagged reply while Bea's socket
|
|
2685
2607
|
// was still behind, and answer a question that had just been handed
|
|
2686
2608
|
// away. The message Ava must record is exactly the one Ava ignores.
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2609
|
+
// Who the message is for, judged on the named agents' own terms rather
|
|
2610
|
+
// than this one's: agents may list different principals, and a sender
|
|
2611
|
+
// Ava admits but Bea refuses must not be able to hand Bea the thread
|
|
2612
|
+
// from Ava's socket — Bea's own socket refuses the same message, and
|
|
2613
|
+
// the map is shared. A message naming Bea and Cy is Cy's, who will
|
|
2614
|
+
// answer it and hold the thread. One naming only agents that refuse
|
|
2615
|
+
// the sender is still theirs and not this agent's to answer — so the
|
|
2616
|
+
// refusing agent stays `named`, and only the handover is withheld.
|
|
2617
|
+
const handoverTo = agentNamedIn(text, team, (agentId) => admitsSender(agentConfigFor(agentId), sender));
|
|
2618
|
+
const named = handoverTo ?? agentNamedIn(text, team);
|
|
2619
|
+
// The key one Slack MESSAGE is known by, whichever delivery carried
|
|
2620
|
+
// it; see the dedupe below for why it is not the event id.
|
|
2621
|
+
const eventKey = `${connection.config.agentId}:${event.channel}:${event.ts}`;
|
|
2622
|
+
// Recorded ahead of this agent's own refusal below, and on the named
|
|
2623
|
+
// agent's admission rather than this one's: a sender Ava refuses and
|
|
2624
|
+
// Bea admits has handed Bea the thread, and Ava's socket may be the
|
|
2625
|
+
// one that hears it first. If Ava withheld the record, a fast untagged
|
|
2626
|
+
// follow-up from someone Ava does admit would go to the old holder on
|
|
2627
|
+
// Ava's socket and to Bea once Bea's socket caught up — two answers to
|
|
2628
|
+
// one message. The refused message itself still takes no place in
|
|
2629
|
+
// Ava's transcript; only the map learns who holds the thread.
|
|
2630
|
+
if (threadKey && handoverTo) {
|
|
2631
|
+
rememberAddressee(threadKey, handoverTo, event.ts);
|
|
2632
|
+
}
|
|
2633
|
+
// Who may speak at all, judged before anything below remembers this
|
|
2634
|
+
// message. The adapter's own checks establish nothing about who is
|
|
2635
|
+
// typing; under `admit: 'principals'` the operator's list is the door,
|
|
2636
|
+
// not a label, and a sender not on it gets neither a turn nor a place
|
|
2637
|
+
// in a transcript the agent reads — nor a say in who holds the thread
|
|
2638
|
+
// beyond what the named agent's own admission above allowed.
|
|
2639
|
+
// Logged rather than answered — a reply is a conversation with someone
|
|
2640
|
+
// the operator chose not to have one with — and logged only for a
|
|
2641
|
+
// message this agent would have taken up: a mention, or an untagged
|
|
2642
|
+
// reply inside a thread this agent is known to hold. A channel
|
|
2643
|
+
// subscribed for thread follow-through delivers every post and every
|
|
2644
|
+
// reply in every thread, including the threads this agent was never
|
|
2645
|
+
// part of, and a line per stranger per reply is a log with nothing
|
|
2646
|
+
// left to read. Known from what this process has seen, as the
|
|
2647
|
+
// admitted path below judges it; a thread nothing here remembers is
|
|
2648
|
+
// one the sessions would have to be asked about, and a refused message
|
|
2649
|
+
// does not get a place in that queue. Once per MESSAGE, on the same key
|
|
2650
|
+
// the admitted path dedupes on: a mention arrives twice under both
|
|
2651
|
+
// subscriptions, and a delivery Slack did not see acknowledged arrives
|
|
2652
|
+
// again, and each copy would otherwise write its own line during
|
|
2653
|
+
// exactly the traffic an operator reads the log to understand.
|
|
2654
|
+
// Consuming the key here is safe because a refused message records
|
|
2655
|
+
// nothing a later copy could be needed for.
|
|
2656
|
+
if (!admitsSender(connection.config, sender)) {
|
|
2657
|
+
const refusal = `slack: ${connection.config.agentId} refused a message from ${sender}: not a listed principal, and admit is "principals"`;
|
|
2658
|
+
const held = threadKey !== undefined ? holderAt(threadKey, event.ts) : undefined;
|
|
2659
|
+
if (addressed || held === connection.config.agentId) {
|
|
2660
|
+
if (!alreadySeen(eventKey)) {
|
|
2661
|
+
log(refusal);
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
else if (held === undefined && event.thread_ts !== undefined && !alreadySeen(eventKey)) {
|
|
2665
|
+
// Nothing in memory says whose thread this is — after a restart, or
|
|
2666
|
+
// once the record was evicted — but the sessions may: the durable
|
|
2667
|
+
// half of the question the admitted path asks, and the same
|
|
2668
|
+
// verdict. Off the intake chain, because a refused message gets no
|
|
2669
|
+
// place in that queue, and the line is an audit, not a turn; `track`
|
|
2670
|
+
// holds it so `stop()` drains it like any other background work.
|
|
2671
|
+
const thread = event.thread_ts;
|
|
2672
|
+
track(followUpWinner({ team, conversation: event.channel, thread }, event.ts).then((winner) => {
|
|
2673
|
+
if (winner === connection.config.agentId) {
|
|
2674
|
+
log(refusal);
|
|
2675
|
+
}
|
|
2676
|
+
}));
|
|
2677
|
+
}
|
|
2678
|
+
return undefined;
|
|
2690
2679
|
}
|
|
2691
2680
|
if (!addressed) {
|
|
2692
2681
|
if (event.thread_ts === undefined) {
|
|
@@ -2708,7 +2697,6 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2708
2697
|
// the one a connection sees second. And before every stand-down below,
|
|
2709
2698
|
// because standing down is no longer nothing: an agent that hears a
|
|
2710
2699
|
// message it is not answering must hear it once.
|
|
2711
|
-
const eventKey = `${connection.config.agentId}:${event.channel}:${event.ts}`;
|
|
2712
2700
|
if (alreadySeen(eventKey)) {
|
|
2713
2701
|
return undefined;
|
|
2714
2702
|
}
|
|
@@ -2889,7 +2877,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2889
2877
|
if (cleaned.length === 0 && images.length === 0) {
|
|
2890
2878
|
return undefined;
|
|
2891
2879
|
}
|
|
2892
|
-
const author = await
|
|
2880
|
+
const author = await authorFor(connection, userId);
|
|
2893
2881
|
// In shared channels the model should know who is speaking; a DM is
|
|
2894
2882
|
// unambiguous. A bare image in a channel still says who sent it.
|
|
2895
2883
|
const spoken = isDm ? cleaned : (cleaned.length > 0 ? `${author}: ${cleaned}` : `${author}:`);
|
|
@@ -2934,9 +2922,28 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2934
2922
|
};
|
|
2935
2923
|
}
|
|
2936
2924
|
// A turn nobody asked for opens its placeholder on its first text,
|
|
2937
|
-
// if it ever has any — see `ReplyRenderer.lazy`.
|
|
2938
|
-
|
|
2939
|
-
|
|
2925
|
+
// if it ever has any — see `ReplyRenderer.lazy`. One the agent's
|
|
2926
|
+
// reply mode does not stream opens none at all, and shows the
|
|
2927
|
+
// loading status instead — see `ReplyRenderer.streaming`.
|
|
2928
|
+
const streaming = connection.config.replies === 'stream';
|
|
2929
|
+
const ahead = streaming ? undefined : replyOrder.get(sessionId);
|
|
2930
|
+
const renderer = new ReplyRenderer(connection.web, event.channel, thread, editIntervalMs, warn, {
|
|
2931
|
+
lazy: judged,
|
|
2932
|
+
now,
|
|
2933
|
+
streaming,
|
|
2934
|
+
statusThread: thread ?? event.ts,
|
|
2935
|
+
statusWarned: connection.statusWarned,
|
|
2936
|
+
...(ahead ? { after: ahead } : {}),
|
|
2937
|
+
});
|
|
2938
|
+
if (!streaming) {
|
|
2939
|
+
replyOrder.set(sessionId, renderer.posted);
|
|
2940
|
+
void renderer.posted.then(() => {
|
|
2941
|
+
if (replyOrder.get(sessionId) === renderer.posted) {
|
|
2942
|
+
replyOrder.delete(sessionId);
|
|
2943
|
+
}
|
|
2944
|
+
});
|
|
2945
|
+
}
|
|
2946
|
+
if (!judged && streaming) {
|
|
2940
2947
|
try {
|
|
2941
2948
|
await renderer.open();
|
|
2942
2949
|
}
|
|
@@ -2951,6 +2958,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2951
2958
|
const queue = renderers.get(sessionId) ?? [];
|
|
2952
2959
|
queue.push(renderer);
|
|
2953
2960
|
renderers.set(sessionId, queue);
|
|
2961
|
+
renderer.showLoading(queue.length === 1);
|
|
2954
2962
|
const turn = gateway.dispatch({
|
|
2955
2963
|
sessionId,
|
|
2956
2964
|
agentId: connection.config.agentId,
|
|
@@ -3031,6 +3039,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3031
3039
|
// running, which the other may therefore still take.
|
|
3032
3040
|
rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
|
|
3033
3041
|
}
|
|
3042
|
+
// The reply just posted took the thread's status down with it, and a
|
|
3043
|
+
// turn queued behind this one is still working.
|
|
3044
|
+
renderers.get(sessionId)?.[0]?.refreshLoading();
|
|
3034
3045
|
await heard;
|
|
3035
3046
|
}
|
|
3036
3047
|
else {
|
|
@@ -3039,6 +3050,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3039
3050
|
// A file it posted before breaking is still the last thing said.
|
|
3040
3051
|
rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
|
|
3041
3052
|
}
|
|
3053
|
+
renderers.get(sessionId)?.[0]?.refreshLoading();
|
|
3042
3054
|
}
|
|
3043
3055
|
};
|
|
3044
3056
|
return {
|
|
@@ -3055,7 +3067,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3055
3067
|
return;
|
|
3056
3068
|
}
|
|
3057
3069
|
if (event.type === 'tool.approval-resolved') {
|
|
3058
|
-
|
|
3070
|
+
// The question was posted into the turn's thread, which took the
|
|
3071
|
+
// loading status down with it; the turn works on once answered.
|
|
3072
|
+
track(retractApprovalRequest(event).finally(() => renderers.get(event.sessionId)?.[0]?.refreshLoading()));
|
|
3059
3073
|
return;
|
|
3060
3074
|
}
|
|
3061
3075
|
// A turn ends here in one of two hands. One this adapter started
|
|
@@ -3126,12 +3140,18 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3126
3140
|
// connection — never leaves the queued reply stalled behind it.
|
|
3127
3141
|
if (event.type === 'session.failed' && !rendered) {
|
|
3128
3142
|
const claim = head?.reserveHandover();
|
|
3129
|
-
track(reportUnrenderedFailure(event, claim, takeUnrenderedFiles(event.sessionId)).finally(() =>
|
|
3143
|
+
track(reportUnrenderedFailure(event, claim, takeUnrenderedFiles(event.sessionId)).finally(() => {
|
|
3144
|
+
claim?.release();
|
|
3145
|
+
head?.refreshLoading();
|
|
3146
|
+
}));
|
|
3130
3147
|
return;
|
|
3131
3148
|
}
|
|
3132
3149
|
if (event.type === 'session.completed' && !rendered) {
|
|
3133
3150
|
const claim = head?.reserveHandover();
|
|
3134
|
-
track(reportUnrenderedReply(event, claim, takeUnrenderedFiles(event.sessionId)).finally(() =>
|
|
3151
|
+
track(reportUnrenderedReply(event, claim, takeUnrenderedFiles(event.sessionId)).finally(() => {
|
|
3152
|
+
claim?.release();
|
|
3153
|
+
head?.refreshLoading();
|
|
3154
|
+
}));
|
|
3135
3155
|
return;
|
|
3136
3156
|
}
|
|
3137
3157
|
if ('sessionId' in event) {
|
|
@@ -3166,6 +3186,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3166
3186
|
socket: createSocket(config.appToken),
|
|
3167
3187
|
botUserId,
|
|
3168
3188
|
teamId,
|
|
3189
|
+
statusWarned: { value: false },
|
|
3169
3190
|
});
|
|
3170
3191
|
}
|
|
3171
3192
|
catch (error) {
|