@stratusagent/channel-slack 0.12.4 → 0.12.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 +31 -28
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +435 -82
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -2283,6 +2283,16 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2283
2283
|
* corrects it.
|
|
2284
2284
|
*/
|
|
2285
2285
|
const threadAddressee = new Map();
|
|
2286
|
+
/**
|
|
2287
|
+
* Threads whose owner this process can name (card 508): `true` for one
|
|
2288
|
+
* whose first message it saw, so the first claim in `threadAddressee` is
|
|
2289
|
+
* the owner, or the owner itself when the thread read named it after a
|
|
2290
|
+
* restart. Absent for a thread that started before this process did:
|
|
2291
|
+
* its first claim here is only the first one SEEN, and an untagged reply
|
|
2292
|
+
* in it asks the sessions and the thread (see `ownerAt`). Bounded like
|
|
2293
|
+
* the addressee map; a lost entry costs a lookup.
|
|
2294
|
+
*/
|
|
2295
|
+
const threadOrigins = new Map();
|
|
2286
2296
|
/**
|
|
2287
2297
|
* One cold verdict per message — see `followUpWinner`. Bounded, because
|
|
2288
2298
|
* an entry outlives its own resolution on purpose; an entry that has not
|
|
@@ -2996,6 +3006,73 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2996
3006
|
}
|
|
2997
3007
|
}));
|
|
2998
3008
|
};
|
|
3009
|
+
/**
|
|
3010
|
+
* An agent's own post into a thread, heard in its own session for that
|
|
3011
|
+
* thread (card 364). A scheduled report or a `message.send` reply is a
|
|
3012
|
+
* bot message, which `admit` drops on purpose, and backfill reads a
|
|
3013
|
+
* thread only when a session for it is first made, so without this an
|
|
3014
|
+
* agent already in the thread never knew it had posted there: a reply
|
|
3015
|
+
* under its report reached a session with the question and not the
|
|
3016
|
+
* report. Through `observe`, so it starts no turn and lands only in a
|
|
3017
|
+
* session that already exists; a thread the agent was never in is read
|
|
3018
|
+
* by backfill when somebody first replies there.
|
|
3019
|
+
*
|
|
3020
|
+
* Labelled as the agent's own post, at the label the SENDING session's
|
|
3021
|
+
* words carry (`speaker.trust`, `sessionWriteTrust` of that session):
|
|
3022
|
+
* a report written by a firing that read a web page carries what that
|
|
3023
|
+
* page said, so it is heard at `external`, and one written from `user`
|
|
3024
|
+
* and `agent` context is heard at `agent`. Never higher, so hearing its
|
|
3025
|
+
* own report cannot launder anything into the thread. A post nobody
|
|
3026
|
+
* vouches for (no speaker) is not heard at all, rather than guessed at.
|
|
3027
|
+
*
|
|
3028
|
+
* Not when the sender IS that thread's session: that turn already holds
|
|
3029
|
+
* the call and what it said. Claimed on the intake chain BEFORE the
|
|
3030
|
+
* first chunk goes to Slack, so a reply typed under the report, even
|
|
3031
|
+
* while a long one is still being sent, is heard after it. `posted`
|
|
3032
|
+
* settles with what Slack accepted: all of it, the chunks before one it
|
|
3033
|
+
* refused (they are in the thread, so they are heard), or nothing. Not
|
|
3034
|
+
* awaited by `post`, only by the shutdown drain, so a long turn running
|
|
3035
|
+
* in the thread never holds up the sender, and two threads posting into
|
|
3036
|
+
* each other cannot wait on each other.
|
|
3037
|
+
*/
|
|
3038
|
+
const hearOwnPost = (connection, channel, thread, posted, speaker) => {
|
|
3039
|
+
const gateway = gatewayRef;
|
|
3040
|
+
if (!gateway?.observe) {
|
|
3041
|
+
return;
|
|
3042
|
+
}
|
|
3043
|
+
const agentId = connection.config.agentId;
|
|
3044
|
+
const team = connection.teamId;
|
|
3045
|
+
const sessionId = channelSessionKey({ channel: 'slack', agentId, team, conversation: channel, thread });
|
|
3046
|
+
if (sessionId === speaker.sessionId) {
|
|
3047
|
+
return;
|
|
3048
|
+
}
|
|
3049
|
+
const metadata = {
|
|
3050
|
+
channel: 'slack',
|
|
3051
|
+
team,
|
|
3052
|
+
slackChannel: channel,
|
|
3053
|
+
slackUser: connection.botUserId,
|
|
3054
|
+
slackThread: thread,
|
|
3055
|
+
[SENDER_TRUST_METADATA_KEY]: leastTrusted('agent', speaker.trust),
|
|
3056
|
+
};
|
|
3057
|
+
const heard = onIntakeChain(sessionId, async () => {
|
|
3058
|
+
const text = await posted;
|
|
3059
|
+
if (text === undefined) {
|
|
3060
|
+
return { observed: undefined };
|
|
3061
|
+
}
|
|
3062
|
+
const author = await displayNameFor(connection, connection.botUserId);
|
|
3063
|
+
const observed = gateway.observe?.({ sessionId, agentId, message: `${author} (your own post, sent from another session): ${text}`, metadata });
|
|
3064
|
+
return { observed: observed === undefined ? undefined : placeObserve(sessionId, observed) };
|
|
3065
|
+
});
|
|
3066
|
+
// Tracked, so stop() drains it: a restart right after a report must not
|
|
3067
|
+
// close the stores while the thread's session is still to hear it.
|
|
3068
|
+
track(heard
|
|
3069
|
+
.then(async ({ observed }) => {
|
|
3070
|
+
await observed;
|
|
3071
|
+
})
|
|
3072
|
+
.catch((error) => {
|
|
3073
|
+
warn(`slack: ${agentId} could not hear its own post in ${channel} thread ${thread}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3074
|
+
}));
|
|
3075
|
+
};
|
|
2999
3076
|
/**
|
|
3000
3077
|
* The addressable outbound seam — the channel contract's first real
|
|
3001
3078
|
* `OutboundConnection` implementation. Validate-then-hand-over: every
|
|
@@ -3041,16 +3118,31 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3041
3118
|
// A reply under a message rather than a post beside it. Every chunk of
|
|
3042
3119
|
// a long one goes to the same thread, so a split reply stays together.
|
|
3043
3120
|
const thread = address.thread?.trim() || undefined;
|
|
3121
|
+
const speaker = address.speaker;
|
|
3044
3122
|
return {
|
|
3045
3123
|
async post(text) {
|
|
3046
3124
|
const chunks = messageChunks(text.trim().length > 0 ? text : '(empty message)');
|
|
3125
|
+
// Heard in the thread's session (card 364): its place is claimed
|
|
3126
|
+
// now, before Slack shows anything, and filled once we know what
|
|
3127
|
+
// Slack accepted.
|
|
3128
|
+
let accepted = () => { };
|
|
3129
|
+
if (thread && speaker && text.trim().length > 0) {
|
|
3130
|
+
hearOwnPost(connection, channelId, thread, new Promise((resolve) => { accepted = resolve; }), speaker);
|
|
3131
|
+
}
|
|
3047
3132
|
let first;
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3133
|
+
let sent = 0;
|
|
3134
|
+
try {
|
|
3135
|
+
for (const chunk of chunks) {
|
|
3136
|
+
const posted = await web.chat.postMessage({ channel: channelId, text: chunk, ...(thread ? { thread_ts: thread } : {}) });
|
|
3137
|
+
sent += 1;
|
|
3138
|
+
if (!first) {
|
|
3139
|
+
first = { channel: posted.channel ?? channelId, ts: posted.ts ?? '' };
|
|
3140
|
+
}
|
|
3052
3141
|
}
|
|
3053
3142
|
}
|
|
3143
|
+
finally {
|
|
3144
|
+
accepted(sent === chunks.length ? text : sent > 0 ? chunks.slice(0, sent).join('\n') : undefined);
|
|
3145
|
+
}
|
|
3054
3146
|
return first ?? { channel: channelId, ts: '' };
|
|
3055
3147
|
},
|
|
3056
3148
|
async edit(ref, text) {
|
|
@@ -3977,6 +4069,34 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3977
4069
|
};
|
|
3978
4070
|
/** One thread, whichever agent is talking in it. */
|
|
3979
4071
|
const threadKeyFor = (parts) => `${parts.team}:${parts.conversation}:${parts.thread}`;
|
|
4072
|
+
/**
|
|
4073
|
+
* A thread's claims cut to `THREAD_HANDOVER_DEPTH`, oldest first out, but
|
|
4074
|
+
* never the two `ownerAt` reads past the rest, so at most one over: the
|
|
4075
|
+
* first claim, which is who the thread belongs to, and the latest claim
|
|
4076
|
+
* that took the thread.
|
|
4077
|
+
* Dropping that one would hand the thread back to its owner after enough
|
|
4078
|
+
* mentions of a third agent, while the judge that took it still answers.
|
|
4079
|
+
*/
|
|
4080
|
+
const trimHandovers = (handovers) => {
|
|
4081
|
+
if (handovers.length <= THREAD_HANDOVER_DEPTH) {
|
|
4082
|
+
return handovers;
|
|
4083
|
+
}
|
|
4084
|
+
const first = handovers[0];
|
|
4085
|
+
let latestTake = -1;
|
|
4086
|
+
for (let index = handovers.length - 1; index > 0; index -= 1) {
|
|
4087
|
+
if (handovers[index]?.takes === true) {
|
|
4088
|
+
latestTake = index;
|
|
4089
|
+
break;
|
|
4090
|
+
}
|
|
4091
|
+
}
|
|
4092
|
+
const tailFrom = handovers.length - (THREAD_HANDOVER_DEPTH - 1);
|
|
4093
|
+
// The kept take sits on top of the full recent tail, one past the depth,
|
|
4094
|
+
// so `holderAt` still sees every recent claim it saw before.
|
|
4095
|
+
const tail = handovers.slice(tailFrom);
|
|
4096
|
+
return latestTake > 0 && latestTake < tailFrom
|
|
4097
|
+
? [first, handovers[latestTake], ...tail]
|
|
4098
|
+
: [first, ...tail];
|
|
4099
|
+
};
|
|
3980
4100
|
/**
|
|
3981
4101
|
* Records that `agentId` holds the thread from the message at `ts`
|
|
3982
4102
|
* onwards, wherever in the thread's history that message falls.
|
|
@@ -3989,7 +4109,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3989
4109
|
* later insertion would take the thread from a reply that sits below it.
|
|
3990
4110
|
* Only the same claim twice — the same message reaching a second
|
|
3991
4111
|
* connection — is dropped.
|
|
3992
|
-
* The oldest claims are dropped past `THREAD_HANDOVER_DEPTH
|
|
4112
|
+
* The oldest claims are dropped past `THREAD_HANDOVER_DEPTH` (but not
|
|
4113
|
+
* the first or the latest that took the thread: `trimHandovers`), and the
|
|
3993
4114
|
* least recently written thread past `THREAD_ADDRESSEE_CAPACITY`; both
|
|
3994
4115
|
* losses cost a lookup, never an answer, since the sessions still hold
|
|
3995
4116
|
* the durable version.
|
|
@@ -4003,7 +4124,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4003
4124
|
* Slack timestamps compare as strings: `seconds.microseconds`, both
|
|
4004
4125
|
* parts fixed width, so lexicographic order is chronological order.
|
|
4005
4126
|
*/
|
|
4006
|
-
const rememberAddressee = (key, agentId, ts) => {
|
|
4127
|
+
const rememberAddressee = (key, agentId, ts, takes = false) => {
|
|
4007
4128
|
const handovers = threadAddressee.get(key) ?? [];
|
|
4008
4129
|
let at = handovers.length;
|
|
4009
4130
|
while (at > 0 && handovers[at - 1].ts > ts) {
|
|
@@ -4011,17 +4132,24 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4011
4132
|
}
|
|
4012
4133
|
const before = handovers[at - 1];
|
|
4013
4134
|
if (before && before.agentId === agentId && before.ts === ts) {
|
|
4135
|
+
if (takes) {
|
|
4136
|
+
before.takes = true;
|
|
4137
|
+
}
|
|
4014
4138
|
return;
|
|
4015
4139
|
}
|
|
4016
|
-
handovers.splice(at, 0, { agentId, ts });
|
|
4140
|
+
handovers.splice(at, 0, takes ? { agentId, ts, takes } : { agentId, ts });
|
|
4017
4141
|
// Re-inserted rather than mutated in place: a Map iterates in insertion
|
|
4018
4142
|
// order, which is what makes the first key the one to evict.
|
|
4019
4143
|
threadAddressee.delete(key);
|
|
4020
|
-
threadAddressee.set(key, handovers
|
|
4144
|
+
threadAddressee.set(key, trimHandovers(handovers));
|
|
4021
4145
|
if (threadAddressee.size > THREAD_ADDRESSEE_CAPACITY) {
|
|
4022
4146
|
const oldest = threadAddressee.keys().next();
|
|
4023
4147
|
if (!oldest.done) {
|
|
4024
4148
|
threadAddressee.delete(oldest.value);
|
|
4149
|
+
// The owner goes with the record of who took the thread since: an
|
|
4150
|
+
// owner kept alone would stand in for a judge's takeover it no
|
|
4151
|
+
// longer knows of, where the thread read would see it.
|
|
4152
|
+
threadOrigins.delete(oldest.value);
|
|
4025
4153
|
}
|
|
4026
4154
|
}
|
|
4027
4155
|
};
|
|
@@ -4043,6 +4171,95 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4043
4171
|
}
|
|
4044
4172
|
return undefined;
|
|
4045
4173
|
};
|
|
4174
|
+
/**
|
|
4175
|
+
* Who an untagged reply at `ts` belongs to (card 508): the agent the
|
|
4176
|
+
* thread belongs to, not whoever spoke last. That is the first to hold
|
|
4177
|
+
* it — the agent its first message named, a home channel's owner, or the
|
|
4178
|
+
* bot (this daemon's or another's) that posted it — unless a judging
|
|
4179
|
+
* agent has since chosen to speak, which takes it (`takes`). A mention
|
|
4180
|
+
* of another agent gets that agent's answer to that message and nothing
|
|
4181
|
+
* more: the thread stays where it was, and so does every reply after.
|
|
4182
|
+
* In a thread that began before this process did, and that the thread
|
|
4183
|
+
* read has not placed yet, the first claim this process SAW stands in
|
|
4184
|
+
* for the owner: the best it has, and no worse than the last-speaker
|
|
4185
|
+
* rule it replaces. Undefined when this process has seen no claim at
|
|
4186
|
+
* all, which sends the question to the sessions and the thread.
|
|
4187
|
+
*/
|
|
4188
|
+
const ownerAt = (key, ts) => {
|
|
4189
|
+
const origin = threadOrigins.get(key);
|
|
4190
|
+
const handovers = threadAddressee.get(key) ?? [];
|
|
4191
|
+
for (let index = handovers.length - 1; index >= 0; index -= 1) {
|
|
4192
|
+
const handover = handovers[index];
|
|
4193
|
+
if (handover && handover.ts <= ts && (handover.takes === true || (index === 0 && typeof origin !== 'string'))) {
|
|
4194
|
+
return handover.agentId;
|
|
4195
|
+
}
|
|
4196
|
+
}
|
|
4197
|
+
return typeof origin === 'string' ? origin : undefined;
|
|
4198
|
+
};
|
|
4199
|
+
/**
|
|
4200
|
+
* The agent the thread `key` belongs to, whoever holds it now: the owner
|
|
4201
|
+
* the thread read settled, or else the first claim this process saw.
|
|
4202
|
+
* Answering in its own thread takes the thread back from a judge that
|
|
4203
|
+
* spoke since (card 508), so the owner is asked for here, not the holder.
|
|
4204
|
+
*/
|
|
4205
|
+
const threadOwner = (key) => {
|
|
4206
|
+
const origin = threadOrigins.get(key);
|
|
4207
|
+
return typeof origin === 'string' ? origin : threadAddressee.get(key)?.[0]?.agentId;
|
|
4208
|
+
};
|
|
4209
|
+
/** Records that this process saw the thread `key` begin, or who owns it. */
|
|
4210
|
+
const noteOrigin = (key, owner = true) => {
|
|
4211
|
+
if (owner === true && threadOrigins.has(key)) {
|
|
4212
|
+
return;
|
|
4213
|
+
}
|
|
4214
|
+
threadOrigins.delete(key);
|
|
4215
|
+
threadOrigins.set(key, owner);
|
|
4216
|
+
if (threadOrigins.size > THREAD_ADDRESSEE_CAPACITY) {
|
|
4217
|
+
const oldest = threadOrigins.keys().next();
|
|
4218
|
+
if (!oldest.done) {
|
|
4219
|
+
threadOrigins.delete(oldest.value);
|
|
4220
|
+
}
|
|
4221
|
+
}
|
|
4222
|
+
};
|
|
4223
|
+
/**
|
|
4224
|
+
* A message that starts a thread, seen by any connection: from here on
|
|
4225
|
+
* this process sees the thread's first claim, so it knows the owner. One
|
|
4226
|
+
* this daemon's own agent posted (a schedule's delivery, a message.send)
|
|
4227
|
+
* is that agent's thread.
|
|
4228
|
+
*/
|
|
4229
|
+
const noteThreadStart = (connection, args) => {
|
|
4230
|
+
const event = args.event;
|
|
4231
|
+
if (!event || (event.type !== 'message' && event.type !== 'app_mention') || event.channel_type === 'im') {
|
|
4232
|
+
return;
|
|
4233
|
+
}
|
|
4234
|
+
if (event.thread_ts !== undefined && event.thread_ts !== event.ts) {
|
|
4235
|
+
return;
|
|
4236
|
+
}
|
|
4237
|
+
if (event.subtype !== undefined && event.subtype !== 'bot_message' && !HUMAN_MESSAGE_SUBTYPES.has(event.subtype)) {
|
|
4238
|
+
return;
|
|
4239
|
+
}
|
|
4240
|
+
const team = args.body?.team_id ?? connection.teamId;
|
|
4241
|
+
const key = threadKeyFor({ team, conversation: event.channel, thread: event.ts });
|
|
4242
|
+
if (threadOrigins.has(key)) {
|
|
4243
|
+
return;
|
|
4244
|
+
}
|
|
4245
|
+
noteOrigin(key);
|
|
4246
|
+
if (event.user !== undefined) {
|
|
4247
|
+
for (const [agentId, identity] of botIdentities) {
|
|
4248
|
+
if (identity.teamId === team && identity.botUserId === event.user) {
|
|
4249
|
+
rememberAddressee(key, agentId, event.ts);
|
|
4250
|
+
}
|
|
4251
|
+
}
|
|
4252
|
+
}
|
|
4253
|
+
// A team agent on another gateway that starts a thread owns it, even
|
|
4254
|
+
// when its first message asks an agent here something: that agent
|
|
4255
|
+
// answers the question, and the thread stays the asker's. Recorded
|
|
4256
|
+
// here, ahead of the answer, since `noteForeignSpeaker` leaves out a
|
|
4257
|
+
// bot's question to an agent here.
|
|
4258
|
+
const bot = event.bot_id !== undefined || event.subtype === 'bot_message';
|
|
4259
|
+
if (bot && event.user !== undefined && !isFleetBot(team, event.user) && !startsThreadAsIntegration(team, event.user, event.thread_ts, event.ts)) {
|
|
4260
|
+
rememberAddressee(key, `${FOREIGN_SPEAKER_PREFIX}${event.user}`, event.ts);
|
|
4261
|
+
}
|
|
4262
|
+
};
|
|
4046
4263
|
/**
|
|
4047
4264
|
* Whether `user` is one of this adapter's own agents in `team`. Their
|
|
4048
4265
|
* replies reach the holder record through `ReplyOutcome.spokeAt`, and
|
|
@@ -4056,6 +4273,24 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4056
4273
|
* held it. Used live and when a thread is read back after a restart.
|
|
4057
4274
|
*/
|
|
4058
4275
|
const asksAgentHere = (team, asker, text) => asker !== undefined && [...botIdentities].some(([agentId, identity]) => identity.teamId === team && mentions(text ?? '', identity.botUserId) && answersAgentMention(agentId, team, asker));
|
|
4276
|
+
/**
|
|
4277
|
+
* Whether a bot message is another app starting a thread — an alert, a
|
|
4278
|
+
* deploy note — rather than an agent (card 508). That starts the thread
|
|
4279
|
+
* without owning it, so an agent named under it keeps the conversation it
|
|
4280
|
+
* was given. A team agent (`agentMentions.team`) starting one owns it the
|
|
4281
|
+
* way an agent here does, and any app replying inside a thread counts as
|
|
4282
|
+
* a speaker there, since an agent on a gateway nobody listed looks like
|
|
4283
|
+
* any other app.
|
|
4284
|
+
*/
|
|
4285
|
+
const startsThreadAsIntegration = (team, user, threadTs, ts) => {
|
|
4286
|
+
if (threadTs !== undefined && threadTs !== ts) {
|
|
4287
|
+
return false;
|
|
4288
|
+
}
|
|
4289
|
+
return user === undefined || ![...botIdentities].some(([agentId, identity]) => {
|
|
4290
|
+
const config = agentConfigFor(agentId);
|
|
4291
|
+
return config !== undefined && teamAgentFor(config, identity.teamId, team, user) !== undefined;
|
|
4292
|
+
});
|
|
4293
|
+
};
|
|
4059
4294
|
/**
|
|
4060
4295
|
* Records a bot this adapter does not serve as the thread's holder from
|
|
4061
4296
|
* the message it posted. An agent on another gateway answers in the same
|
|
@@ -4087,6 +4322,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4087
4322
|
if (asksAgentHere(team, event.user, event.text)) {
|
|
4088
4323
|
return;
|
|
4089
4324
|
}
|
|
4325
|
+
if (startsThreadAsIntegration(team, event.user, event.thread_ts, event.ts)) {
|
|
4326
|
+
return;
|
|
4327
|
+
}
|
|
4090
4328
|
const thread = event.thread_ts ?? event.ts;
|
|
4091
4329
|
rememberAddressee(threadKeyFor({ team, conversation: event.channel, thread }), `${FOREIGN_SPEAKER_PREFIX}${speaker}`, event.ts);
|
|
4092
4330
|
};
|
|
@@ -4119,16 +4357,30 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4119
4357
|
const botUserId = botIdentities.get(candidate.agentId)?.botUserId;
|
|
4120
4358
|
if (botUserId !== undefined) {
|
|
4121
4359
|
byBotUser.set(botUserId, candidate.agentId);
|
|
4122
|
-
held.set(candidate.agentId,
|
|
4360
|
+
held.set(candidate.agentId, Number.POSITIVE_INFINITY);
|
|
4123
4361
|
continue;
|
|
4124
4362
|
}
|
|
4125
4363
|
const spoke = candidate.lastSpokeAt === undefined ? Number.NaN : Date.parse(candidate.lastSpokeAt) / 1000;
|
|
4126
|
-
held.set(candidate.agentId, Number.isFinite(spoke) ? spoke :
|
|
4364
|
+
held.set(candidate.agentId, Number.isFinite(spoke) ? spoke : Number.POSITIVE_INFINITY);
|
|
4365
|
+
}
|
|
4366
|
+
// Where each last held it, for a stop, which is for whoever was asked
|
|
4367
|
+
// last (card 337), not for the thread's owner.
|
|
4368
|
+
const last = new Map();
|
|
4369
|
+
for (const [agentId, at] of held) {
|
|
4370
|
+
last.set(agentId, Number.isFinite(at) ? at : 0);
|
|
4127
4371
|
}
|
|
4128
4372
|
const place = (agentId, at) => {
|
|
4129
|
-
held.set(agentId, Math.
|
|
4373
|
+
held.set(agentId, Math.min(held.get(agentId) ?? Number.POSITIVE_INFINITY, at));
|
|
4374
|
+
last.set(agentId, Math.max(last.get(agentId) ?? 0, at));
|
|
4130
4375
|
};
|
|
4131
|
-
let foreign =
|
|
4376
|
+
let foreign = Number.POSITIVE_INFINITY;
|
|
4377
|
+
let lastForeign = 0;
|
|
4378
|
+
// Each of these agents' own messages, and whether the message before it
|
|
4379
|
+
// had asked it anything: one nobody asked for is a judge taking the
|
|
4380
|
+
// thread, or its owner answering in it (card 508).
|
|
4381
|
+
const posts = [];
|
|
4382
|
+
let asked = new Set();
|
|
4383
|
+
const askedIn = (text) => new Set([...byBotUser].filter(([botUserId]) => mentions(text ?? '', botUserId)).map(([, agentId]) => agentId));
|
|
4132
4384
|
try {
|
|
4133
4385
|
let cursor;
|
|
4134
4386
|
let pages = 0;
|
|
@@ -4150,14 +4402,35 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4150
4402
|
const own = message.user === undefined ? undefined : byBotUser.get(message.user);
|
|
4151
4403
|
if (own !== undefined) {
|
|
4152
4404
|
place(own, at);
|
|
4405
|
+
posts.push({ agentId: own, at, asked: asked.has(own) });
|
|
4406
|
+
// An agent here naming a colleague that answers its mentions
|
|
4407
|
+
// asks it, as `admitAgentMention` has it live, so the answer is
|
|
4408
|
+
// not a judge speaking unasked.
|
|
4409
|
+
const sender = message.user;
|
|
4410
|
+
const named = sender === undefined
|
|
4411
|
+
? []
|
|
4412
|
+
: [...askedIn(message.text)].filter((agentId) => agentId !== own && answersAgentMention(agentId, parts.team, sender));
|
|
4413
|
+
if (named.length > 0) {
|
|
4414
|
+
asked = new Set(named);
|
|
4415
|
+
}
|
|
4153
4416
|
continue;
|
|
4154
4417
|
}
|
|
4155
4418
|
const bot = message.bot_id !== undefined || message.subtype === 'bot_message';
|
|
4156
4419
|
if (bot) {
|
|
4157
4420
|
// A bot's mention hands nothing over live (admit ignores bots),
|
|
4158
4421
|
// so here it is only another app speaking, or a peer's words.
|
|
4159
|
-
|
|
4160
|
-
|
|
4422
|
+
// A team agent's question to an agent here is not it taking the
|
|
4423
|
+
// thread, unless it started the thread with it: then the thread
|
|
4424
|
+
// is its own, as it is live.
|
|
4425
|
+
const asks = asksAgentHere(parts.team, message.user, message.text);
|
|
4426
|
+
if (asks) {
|
|
4427
|
+
asked = askedIn(message.text);
|
|
4428
|
+
}
|
|
4429
|
+
if (!isFleetBot(parts.team, message.user) &&
|
|
4430
|
+
(!asks || message.ts === parts.thread) &&
|
|
4431
|
+
!startsThreadAsIntegration(parts.team, message.user, parts.thread, message.ts ?? '')) {
|
|
4432
|
+
foreign = Math.min(foreign, at);
|
|
4433
|
+
lastForeign = Math.max(lastForeign, at);
|
|
4161
4434
|
}
|
|
4162
4435
|
continue;
|
|
4163
4436
|
}
|
|
@@ -4167,10 +4440,19 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4167
4440
|
if (sender === undefined) {
|
|
4168
4441
|
continue;
|
|
4169
4442
|
}
|
|
4443
|
+
asked = askedIn(message.text);
|
|
4170
4444
|
const named = agentNamedIn(message.text ?? '', parts.team, (agentId) => admitsSender(agentConfigFor(agentId), sender));
|
|
4171
4445
|
if (named !== undefined && held.has(named) && botIdentities.get(named)?.botUserId !== undefined) {
|
|
4172
4446
|
place(named, at);
|
|
4173
4447
|
}
|
|
4448
|
+
// A root that names nobody in a home channel is its owner's, as
|
|
4449
|
+
// the live home-channel rule has it.
|
|
4450
|
+
const home = message.ts === parts.thread && named === undefined
|
|
4451
|
+
? homeOwners.get(`${parts.team}:${parts.conversation}`)
|
|
4452
|
+
: undefined;
|
|
4453
|
+
if (home !== undefined && held.has(home) && admitsSender(agentConfigFor(home), sender)) {
|
|
4454
|
+
place(home, at);
|
|
4455
|
+
}
|
|
4174
4456
|
}
|
|
4175
4457
|
cursor = page.response_metadata?.next_cursor || undefined;
|
|
4176
4458
|
} while (cursor && pages < EARLIER_THREAD_PAGES);
|
|
@@ -4182,12 +4464,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4182
4464
|
warn(`slack: could not read thread ${parts.thread} in ${parts.conversation} to see who spoke last, so it is routed as before: ${error instanceof Error ? error.message : String(error)}`);
|
|
4183
4465
|
return undefined;
|
|
4184
4466
|
}
|
|
4185
|
-
return { held, foreign };
|
|
4186
|
-
};
|
|
4187
|
-
/** Whether another app spoke in the thread after `agentId` last held it. */
|
|
4188
|
-
const foreignSpokeSince = async (agentId, parts, ts, lastSpokeAt) => {
|
|
4189
|
-
const read = await threadPositions([{ agentId, ...(lastSpokeAt !== undefined ? { lastSpokeAt } : {}) }], parts, ts);
|
|
4190
|
-
return read !== undefined && read.foreign > (read.held.get(agentId) ?? 0);
|
|
4467
|
+
return { held, last, foreign, lastForeign, posts };
|
|
4191
4468
|
};
|
|
4192
4469
|
/**
|
|
4193
4470
|
* Which of this process's agents a message names, or undefined if it
|
|
@@ -4253,25 +4530,19 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4253
4530
|
* restart forgets nothing.
|
|
4254
4531
|
*
|
|
4255
4532
|
* Whose turn it is, is the second. In a thread with several agents an
|
|
4256
|
-
* unaddressed reply belongs to
|
|
4257
|
-
*
|
|
4258
|
-
*
|
|
4259
|
-
* agent last *replied*, not when its session last changed,
|
|
4260
|
-
* saves on tool results and approval checkpoints without
|
|
4261
|
-
*
|
|
4262
|
-
* window.
|
|
4533
|
+
* unaddressed reply belongs to the agent the thread belongs to (card
|
|
4534
|
+
* 508): the first to hold it, read off the thread itself. Only when the
|
|
4535
|
+
* thread can't be read do the sessions decide, and then by `lastSpokeAt`
|
|
4536
|
+
* — when each agent last *replied*, not when its session last changed,
|
|
4537
|
+
* because a turn saves on tool results and approval checkpoints without
|
|
4538
|
+
* having said anything — since that is the only order they keep.
|
|
4263
4539
|
*
|
|
4264
4540
|
* Nobody is the answer only when the agents cannot be *ordered* — nothing
|
|
4265
|
-
* in the thread has spoken
|
|
4266
|
-
*
|
|
4267
|
-
*
|
|
4268
|
-
* would put two answers under one message. An agent that simply has not
|
|
4269
|
-
* spoken is a different thing and is skipped rather than fatal: it cannot
|
|
4270
|
-
* have spoken last, and letting it silence the agent that did would leave
|
|
4271
|
-
* a reply nobody answers. The agent alone in its thread, which is nearly
|
|
4272
|
-
* every thread, is answered without the question arising.
|
|
4541
|
+
* in the thread has spoken, or two tie — because guessing there would put
|
|
4542
|
+
* two answers under one message. The agent alone in its thread, which is
|
|
4543
|
+
* nearly every thread, is answered without the question arising.
|
|
4273
4544
|
*/
|
|
4274
|
-
const resolveFollowUpWinner = async (parts, ts) => {
|
|
4545
|
+
const resolveFollowUpWinner = async (parts, ts, mode = 'reply') => {
|
|
4275
4546
|
const gateway = gatewayRef;
|
|
4276
4547
|
if (!gateway?.sessionRouting || !parts.thread) {
|
|
4277
4548
|
return undefined;
|
|
@@ -4349,22 +4620,107 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4349
4620
|
}
|
|
4350
4621
|
for (const [agentId, identity] of botIdentities) {
|
|
4351
4622
|
if (identity.teamId === parts.team && identity.botUserId === parts.parentUser) {
|
|
4352
|
-
|
|
4623
|
+
noteOrigin(threadKeyFor(parts), agentId);
|
|
4624
|
+
return agentId;
|
|
4353
4625
|
}
|
|
4354
4626
|
}
|
|
4355
4627
|
return undefined;
|
|
4356
4628
|
}
|
|
4357
|
-
|
|
4358
|
-
|
|
4629
|
+
// The thread says who it belongs to: whichever of these agents held it
|
|
4630
|
+
// first — its first message named it, or it posted there first — or
|
|
4631
|
+
// another app that spoke before any of them did (card 508). When it
|
|
4632
|
+
// can't be read, the sessions decide: the only agent in it, or, as a
|
|
4633
|
+
// last resort, the one that spoke last, which is all they record.
|
|
4634
|
+
// The agent that posted the root takes part even with no session here:
|
|
4635
|
+
// a post made through message.send or a schedule leaves none, and a
|
|
4636
|
+
// colleague named under it since must not look like the owner.
|
|
4637
|
+
const candidates = [...engaged];
|
|
4638
|
+
if (parts.parentUser !== undefined) {
|
|
4639
|
+
for (const [agentId, identity] of botIdentities) {
|
|
4640
|
+
if (identity.teamId === parts.team && identity.botUserId === parts.parentUser && !candidates.some((entry) => entry.agentId === agentId)) {
|
|
4641
|
+
candidates.push({ agentId });
|
|
4642
|
+
}
|
|
4643
|
+
}
|
|
4644
|
+
}
|
|
4645
|
+
const read = await threadPositions(candidates, parts, ts);
|
|
4646
|
+
if (!read) {
|
|
4647
|
+
return engaged.length === 1 ? first.agentId : lastSpeaker(engaged);
|
|
4648
|
+
}
|
|
4649
|
+
if (mode === 'stop') {
|
|
4650
|
+
// A stop is for whoever was asked last (card 337), as `holderAt` has
|
|
4651
|
+
// it live: the turn it means is that agent's, not the owner's.
|
|
4652
|
+
let holder;
|
|
4653
|
+
let tiedLast = false;
|
|
4654
|
+
for (const entry of candidates) {
|
|
4655
|
+
const at = read.last.get(entry.agentId) ?? 0;
|
|
4656
|
+
if (at <= 0) {
|
|
4657
|
+
continue;
|
|
4658
|
+
}
|
|
4659
|
+
if (!holder || at > holder.at) {
|
|
4660
|
+
holder = { agentId: entry.agentId, at };
|
|
4661
|
+
tiedLast = false;
|
|
4662
|
+
}
|
|
4663
|
+
else if (at === holder.at) {
|
|
4664
|
+
tiedLast = true;
|
|
4665
|
+
}
|
|
4666
|
+
}
|
|
4667
|
+
if (!holder) {
|
|
4668
|
+
return read.lastForeign > 0 ? undefined : (engaged.length === 1 ? first.agentId : lastSpeaker(engaged));
|
|
4669
|
+
}
|
|
4670
|
+
return tiedLast || read.lastForeign > holder.at ? undefined : holder.agentId;
|
|
4359
4671
|
}
|
|
4672
|
+
let owner;
|
|
4673
|
+
let tiedAt = false;
|
|
4674
|
+
for (const entry of candidates) {
|
|
4675
|
+
const at = read.held.get(entry.agentId) ?? Number.POSITIVE_INFINITY;
|
|
4676
|
+
if (!Number.isFinite(at) || at <= 0) {
|
|
4677
|
+
continue;
|
|
4678
|
+
}
|
|
4679
|
+
if (!owner || at < owner.at) {
|
|
4680
|
+
owner = { agentId: entry.agentId, at };
|
|
4681
|
+
tiedAt = false;
|
|
4682
|
+
}
|
|
4683
|
+
else if (at === owner.at) {
|
|
4684
|
+
tiedAt = true;
|
|
4685
|
+
}
|
|
4686
|
+
}
|
|
4687
|
+
// A judging agent that spoke unasked took the thread, and the owner
|
|
4688
|
+
// answering in it took it back, as live (card 508): the latest of those
|
|
4689
|
+
// holds it. The live record of who took it is lost on a restart; the
|
|
4690
|
+
// thread keeps it. Only a judge takes by speaking unasked, as only its
|
|
4691
|
+
// turns are judged live; another agent's post there (a message.send)
|
|
4692
|
+
// takes nothing.
|
|
4693
|
+
const ownerId = owner && !tiedAt && !(read.foreign < owner.at) ? owner.agentId : undefined;
|
|
4694
|
+
let taken;
|
|
4695
|
+
for (const post of read.posts) {
|
|
4696
|
+
const takes = post.agentId === ownerId || (!post.asked && listensOf(post.agentId) === 'judge');
|
|
4697
|
+
if (takes && (!taken || post.at > taken.at)) {
|
|
4698
|
+
taken = post;
|
|
4699
|
+
}
|
|
4700
|
+
}
|
|
4701
|
+
if (taken && taken.agentId !== ownerId && (ownerId !== undefined || taken.at > read.foreign)) {
|
|
4702
|
+
return taken.agentId;
|
|
4703
|
+
}
|
|
4704
|
+
if (!owner) {
|
|
4705
|
+
return Number.isFinite(read.foreign) ? undefined : (engaged.length === 1 ? first.agentId : lastSpeaker(engaged));
|
|
4706
|
+
}
|
|
4707
|
+
if (ownerId === undefined) {
|
|
4708
|
+
return undefined;
|
|
4709
|
+
}
|
|
4710
|
+
noteOrigin(threadKeyFor(parts), ownerId);
|
|
4711
|
+
return ownerId;
|
|
4712
|
+
};
|
|
4713
|
+
/**
|
|
4714
|
+
* Of several agents in a thread, the one whose session says it spoke
|
|
4715
|
+
* last — the sessions' only ordering, used when the thread itself can't
|
|
4716
|
+
* be read. Undefined when nobody has spoken or the newest two tie, since
|
|
4717
|
+
* guessing there would put two answers under one message.
|
|
4718
|
+
*/
|
|
4719
|
+
const lastSpeaker = (engaged) => {
|
|
4360
4720
|
let latest;
|
|
4361
4721
|
let tied = false;
|
|
4362
4722
|
for (const entry of engaged) {
|
|
4363
4723
|
if (entry.lastSpokeAt === undefined) {
|
|
4364
|
-
// In the thread, but has never said anything in it — mentioned and
|
|
4365
|
-
// still on its first turn, or a turn that produced no text. It
|
|
4366
|
-
// cannot be the one who spoke last, and it must not stop the agent
|
|
4367
|
-
// that did from answering.
|
|
4368
4724
|
continue;
|
|
4369
4725
|
}
|
|
4370
4726
|
if (!latest || entry.lastSpokeAt > latest.lastSpokeAt) {
|
|
@@ -4375,34 +4731,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4375
4731
|
tied = true;
|
|
4376
4732
|
}
|
|
4377
4733
|
}
|
|
4378
|
-
|
|
4379
|
-
// Sessions date a reply by when it finished, the thread by where its
|
|
4380
|
-
// placeholder went up, and an agent on another gateway may have spoken
|
|
4381
|
-
// since: only the thread can say who holds it. When it can't be read,
|
|
4382
|
-
// the sessions decide, as before.
|
|
4383
|
-
const read = await threadPositions(engaged, parts, ts);
|
|
4384
|
-
if (!read) {
|
|
4385
|
-
return bySession;
|
|
4386
|
-
}
|
|
4387
|
-
let holder;
|
|
4388
|
-
let tiedAt = false;
|
|
4389
|
-
for (const entry of engaged) {
|
|
4390
|
-
const at = read.held.get(entry.agentId) ?? 0;
|
|
4391
|
-
if (at <= 0) {
|
|
4392
|
-
continue;
|
|
4393
|
-
}
|
|
4394
|
-
if (!holder || at > holder.at) {
|
|
4395
|
-
holder = { agentId: entry.agentId, at };
|
|
4396
|
-
tiedAt = false;
|
|
4397
|
-
}
|
|
4398
|
-
else if (at === holder.at) {
|
|
4399
|
-
tiedAt = true;
|
|
4400
|
-
}
|
|
4401
|
-
}
|
|
4402
|
-
if (!holder) {
|
|
4403
|
-
return read.foreign > 0 ? undefined : bySession;
|
|
4404
|
-
}
|
|
4405
|
-
return tiedAt || read.foreign > holder.at ? undefined : holder.agentId;
|
|
4734
|
+
return tied ? undefined : latest?.agentId;
|
|
4406
4735
|
};
|
|
4407
4736
|
/**
|
|
4408
4737
|
* `resolveFollowUpWinner`, resolved ONCE PER MESSAGE and shared by every
|
|
@@ -4422,13 +4751,15 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4422
4751
|
* this after the winner has finished, and recomputing for that agent is
|
|
4423
4752
|
* precisely the disagreement the memo exists to prevent.
|
|
4424
4753
|
*/
|
|
4425
|
-
const followUpWinner = (parts, ts) => {
|
|
4426
|
-
|
|
4754
|
+
const followUpWinner = (parts, ts, mode = 'reply') => {
|
|
4755
|
+
// A stop asks a different question (whoever was asked last), so it
|
|
4756
|
+
// gets its own verdict, the same for every agent that asks it.
|
|
4757
|
+
const key = `${threadKeyFor(parts)}:${ts}${mode === 'stop' ? ':stop' : ''}`;
|
|
4427
4758
|
const existing = coldVerdicts.get(key);
|
|
4428
4759
|
if (existing) {
|
|
4429
4760
|
return existing.verdict;
|
|
4430
4761
|
}
|
|
4431
|
-
const entry = { verdict: resolveFollowUpWinner(parts, ts), settled: false };
|
|
4762
|
+
const entry = { verdict: resolveFollowUpWinner(parts, ts, mode), settled: false };
|
|
4432
4763
|
coldVerdicts.set(key, entry);
|
|
4433
4764
|
void entry.verdict.then(() => {
|
|
4434
4765
|
entry.settled = true;
|
|
@@ -4625,6 +4956,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4625
4956
|
const admit = (connection, args) => {
|
|
4626
4957
|
const event = args.event;
|
|
4627
4958
|
if (gatewayRef) {
|
|
4959
|
+
noteThreadStart(connection, args);
|
|
4628
4960
|
noteForeignSpeaker(connection, args);
|
|
4629
4961
|
}
|
|
4630
4962
|
if (gatewayRef && event && (event.bot_id !== undefined || event.subtype === 'bot_message')) {
|
|
@@ -4772,7 +5104,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4772
5104
|
return undefined;
|
|
4773
5105
|
}
|
|
4774
5106
|
const refusal = `slack: ${connection.config.agentId} refused a message from ${sender}: not a listed principal, and admit is "principals"`;
|
|
4775
|
-
|
|
5107
|
+
// Whose message this is, as for an admitted one: the thread's owner's
|
|
5108
|
+
// (card 508), or for a stop, whoever was asked last (card 337).
|
|
5109
|
+
const refusedStop = (connection.config.approvers ?? []).includes(sender) && isStopRequest(text, connection.botUserId);
|
|
5110
|
+
const held = threadKey === undefined
|
|
5111
|
+
? undefined
|
|
5112
|
+
: (refusedStop ? holderAt(threadKey, event.ts) : undefined) ?? ownerAt(threadKey, event.ts);
|
|
4776
5113
|
if (addressed || held === connection.config.agentId) {
|
|
4777
5114
|
if (!alreadySeen(eventKey)) {
|
|
4778
5115
|
log(refusal);
|
|
@@ -4791,7 +5128,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4791
5128
|
conversation: event.channel,
|
|
4792
5129
|
thread,
|
|
4793
5130
|
...(event.parent_user_id !== undefined ? { parentUser: event.parent_user_id } : {}),
|
|
4794
|
-
}, event.ts).then((winner) => {
|
|
5131
|
+
}, event.ts, refusedStop ? 'stop' : 'reply').then((winner) => {
|
|
4795
5132
|
if (winner === connection.config.agentId) {
|
|
4796
5133
|
log(refusal);
|
|
4797
5134
|
}
|
|
@@ -4889,8 +5226,15 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4889
5226
|
}
|
|
4890
5227
|
// The record is what this process has actually seen, in the order it
|
|
4891
5228
|
// saw it — right even inside the window before a handover reaches the
|
|
4892
|
-
// store.
|
|
4893
|
-
|
|
5229
|
+
// store. Whose an untagged reply is, is the thread's owner's (card
|
|
5230
|
+
// 508), not the last speaker's — except a stop, which is for whoever
|
|
5231
|
+
// was asked last, since that is the turn it means (card 337).
|
|
5232
|
+
const stopRequest = threadKey !== undefined &&
|
|
5233
|
+
(connection.config.approvers ?? []).includes(sender) &&
|
|
5234
|
+
isStopRequest(text, connection.botUserId);
|
|
5235
|
+
const holder = threadKey === undefined
|
|
5236
|
+
? undefined
|
|
5237
|
+
: (stopRequest ? holderAt(threadKey, event.ts) : undefined) ?? ownerAt(threadKey, event.ts);
|
|
4894
5238
|
if (holder !== undefined && threadKey !== undefined) {
|
|
4895
5239
|
if (holder !== connection.config.agentId) {
|
|
4896
5240
|
// Standing down for the holder, whether or not the holder can still
|
|
@@ -5021,12 +5365,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5021
5365
|
if (!admitted.settled) {
|
|
5022
5366
|
// One verdict for this message, shared with whichever other agents
|
|
5023
5367
|
// are asking about it — see `followUpWinner`.
|
|
5368
|
+
const stop = (connection.config.approvers ?? []).includes(userId) &&
|
|
5369
|
+
isStopRequest(event.text ?? '', connection.botUserId);
|
|
5024
5370
|
const winner = await followUpWinner({
|
|
5025
5371
|
team,
|
|
5026
5372
|
conversation: event.channel,
|
|
5027
5373
|
thread: event.thread_ts ?? '',
|
|
5028
5374
|
...(event.parent_user_id !== undefined ? { parentUser: event.parent_user_id } : {}),
|
|
5029
|
-
}, event.ts);
|
|
5375
|
+
}, event.ts, stop ? 'stop' : 'reply');
|
|
5030
5376
|
if (winner !== connection.config.agentId) {
|
|
5031
5377
|
// Not this agent's to answer — and, if it is in the thread,
|
|
5032
5378
|
// this agent's to hear, same as the two warm stand-downs. Only a
|
|
@@ -5418,7 +5764,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5418
5764
|
// the reader's next untagged message is for the lower of the two.
|
|
5419
5765
|
// Late, either way, for a message typed while the turn was still
|
|
5420
5766
|
// running, which the other may therefore still take.
|
|
5421
|
-
|
|
5767
|
+
// Under the owner rule (card 508) only two replies move the thread:
|
|
5768
|
+
// a judging agent that chose to speak, and the owner answering in
|
|
5769
|
+
// its own thread, which takes it back from a judge that spoke above.
|
|
5770
|
+
// An answer to a mention is that message's answer and no more.
|
|
5771
|
+
const takes = renderer.lazy || threadOwner(threadKey) === connection.config.agentId;
|
|
5772
|
+
rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts, takes);
|
|
5422
5773
|
}
|
|
5423
5774
|
// The reply just posted took the thread's status down with it, and a
|
|
5424
5775
|
// turn queued behind this one is still working.
|
|
@@ -5437,8 +5788,10 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5437
5788
|
? await renderer.finalize(renderer.lazy ? '' : failure.message)
|
|
5438
5789
|
: await renderer.fail(failure instanceof Error ? failure.message : String(failure));
|
|
5439
5790
|
if (spoke && threadKey !== undefined) {
|
|
5440
|
-
// A file it posted before breaking
|
|
5441
|
-
|
|
5791
|
+
// A file it posted before breaking has still spoken, by the same
|
|
5792
|
+
// rule as a reply that landed.
|
|
5793
|
+
const takes = renderer.lazy || threadOwner(threadKey) === connection.config.agentId;
|
|
5794
|
+
rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts, takes);
|
|
5442
5795
|
}
|
|
5443
5796
|
renderers.get(sessionId)?.[0]?.refreshLoading();
|
|
5444
5797
|
}
|