@mercury-fw/channel-google-chat 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +4 -1
- package/dist/google-chat-provider.d.ts +19 -1
- package/google-chat-provider.ts +242 -25
- package/messages.ts +4 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @mercury-fw/channel-google-chat
|
|
2
2
|
|
|
3
|
+
## 0.11.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- e3eb49d: Stop an answer from Google Chat.
|
|
8
|
+
|
|
9
|
+
- Every card still in progress (the receiving card, a reasoning card, a tool's card) has a Stop button, in a section of its own so it's visible without expanding the card; the card's final state has none.
|
|
10
|
+
- `/stop` does the same: the registered slash command, or a message reading exactly `/stop`. With nothing running, the reply says there's nothing to stop.
|
|
11
|
+
- A stop aborts the person's running turn and discards the messages they queued behind it. No partial answer is sent; the card in progress says "Stopped." (or a short message does, when no card was in progress). A command a tool already ran stays done.
|
|
12
|
+
- Only the person who started the answer can stop it: in a shared space, someone else's stop doesn't touch it.
|
|
13
|
+
- New texts: `google-chat.status.stopped`, `google-chat.stop.button`, `google-chat.stop.nothing`.
|
|
14
|
+
|
|
15
|
+
Needs the framework at 0.58 or later; an older core stops the model too, but sends the half answer it had. To have Chat suggest `/stop`, and to let it reach the app in a space without a mention, add it in the Chat app's configuration (Cloud Console, Google Chat API → Configuration → Commands): type Slash command, name `/stop`, any free command id.
|
|
16
|
+
|
|
3
17
|
## 0.10.0
|
|
4
18
|
|
|
5
19
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ channels: [googleChatChannel],
|
|
|
26
26
|
|
|
27
27
|
Each message reaches the core as `google-chat:users/<id>`, the sender's Chat user, with their email when Google vouches for it (a person's, never a bot's), which a user directory can join on: the ZITADEL directory of `@mercury-fw/plugin-zitadel` does.
|
|
28
28
|
|
|
29
|
-
This version is written for version 6 of the channel contract, the one that brings the channels' texts in the person's language, and needs the framework at 0.54 or later: a core with an older contract skips it, and the log says why. The client's time zone reaches the model from the framework at 0.56 on; an older core ignores it.
|
|
29
|
+
This version is written for version 6 of the channel contract, the one that brings the channels' texts in the person's language, and needs the framework at 0.54 or later: a core with an older contract skips it, and the log says why. The client's time zone reaches the model from the framework at 0.56 on; an older core ignores it. Stopping an answer needs the framework at 0.58 or later: an older core stops the model as well, but then sends the half answer it had.
|
|
30
30
|
|
|
31
31
|
When a plugin that acts as the person (Jira, Bitbucket) needs them to log in, the channel sends a card with a button that opens the service's login, visible only to that person: in a direct message the space is already theirs, in a shared space the card is a private message to whoever asked, since anyone else opening it would log their own account in as them. The service sends the browser back to the HTTP channel's `/login/callback`, so the app needs [`@mercury-fw/channel-http`](https://www.npmjs.com/package/@mercury-fw/channel-http) with `HTTP_SURFACE_PUBLIC_URL` too; without it Mercury answers that nobody can log in on this instance.
|
|
32
32
|
|
|
@@ -34,6 +34,8 @@ Every text the channel shows a person (the cards' titles and status lines, the c
|
|
|
34
34
|
|
|
35
35
|
When a tool stages an action that needs confirming, the channel sends a card with the action and a button per choice the tool offers: one "Confirm" for a plain staging (deleting a Jira issue, say), more when there's a choice to make (the web search plugin offers "Only this time" and "Always for \<host\>", in the person's language, before reading a page on a host nobody allowed). Clicking a button confirms that choice and the others die with it, and the card shows the choice taken instead of the buttons; typing the token in the conversation does the same, leaving the card as it is. A click that confirms nothing (the request expired, or in a shared space it's someone else's) leaves the card for whoever has to choose. When the confirmed action's result goes back to the model (a page read after the person allowed it), the channel runs the conversation's turn again instead of sending the raw result, so the answer comes from the model, built on what came back.
|
|
36
36
|
|
|
37
|
+
While it works, every card still in progress (the one that says the message arrived, a reasoning card, a tool's card) has a Stop button, and sending `/stop` does the same. A stop ends the answer the person was waiting for and throws away the messages they had queued behind it: nothing of the half answer is sent, the card in progress says it stopped (or a short message does, when no card was in progress), and the conversation keeps their message with a note that the answer was stopped, so whatever they write next still has its context. A command a tool already ran stays done, since the stop cuts the model, not the command. Only the person who started the answer can stop it: in a shared space someone else's Stop or `/stop` finds nothing of theirs running, and `/stop` with nothing running says so. The button works as soon as the channel is installed, while `/stop` typed as a message works too, the registered slash command (step 7, item 8) only adds it to the commands Chat suggests, and lets it reach the app in a space without mentioning it.
|
|
38
|
+
|
|
37
39
|
## Table of contents
|
|
38
40
|
|
|
39
41
|
- [Setting up the Chat app](#setting-up-the-chat-app)
|
|
@@ -145,6 +147,7 @@ Open the Chat API's configuration page for the project, `https://console.develop
|
|
|
145
147
|
5. **Connection settings**: pick **Cloud Pub/Sub** and paste the topic's full name, `projects/<PROJECT_ID>/topics/<TOPIC>`.
|
|
146
148
|
6. **Visibility**: check **Make this Google Chat app available to specific people and groups in <your domain>** and enter who can use it (people or a group).
|
|
147
149
|
7. **Logs**: check **Log errors to Logging**, so a delivery error from Chat shows up in the project's logs.
|
|
150
|
+
8. **Commands**: add a command of type **Slash command**, named `/stop`, with any free **Command ID** (`1` on a new app) and a description such as "Stop the answer in progress". The channel recognises it by its name, so the id is up to you.
|
|
148
151
|
|
|
149
152
|
Click **Save**. The field names here come from Google's own Pub/Sub quickstart; Console moves things around now and then, so trust the meaning over the exact position.
|
|
150
153
|
|
|
@@ -71,8 +71,26 @@ export type ParsedCardClickEvent = {
|
|
|
71
71
|
/** As on a message (see `ParsedMessageEvent.timeZone`). */
|
|
72
72
|
timeZone?: string;
|
|
73
73
|
};
|
|
74
|
+
/**
|
|
75
|
+
* The person asked to stop their running turn: `/stop` (`source: "command"`,
|
|
76
|
+
* the registered slash command or a message reading exactly that), or a click
|
|
77
|
+
* on a card's Stop button (`source: "button"`).
|
|
78
|
+
*/
|
|
79
|
+
export type ParsedStopEvent = {
|
|
80
|
+
kind: "stop";
|
|
81
|
+
source: "command" | "button";
|
|
82
|
+
space: string;
|
|
83
|
+
sender: string;
|
|
84
|
+
senderEmail: string | undefined;
|
|
85
|
+
/** As on a message (see `ParsedMessageEvent.isDirectMessage`). */
|
|
86
|
+
isDirectMessage: boolean;
|
|
87
|
+
/** As on a message (see `ParsedMessageEvent.locale`): for the reply when there's nothing to stop. */
|
|
88
|
+
locale?: string;
|
|
89
|
+
/** For a click, the turn whose card carried the button: only that turn is stopped. */
|
|
90
|
+
turn?: string;
|
|
91
|
+
};
|
|
74
92
|
/** Parses one decoded Pub/Sub event, or `null` if it isn't a kind this provider acts on. */
|
|
75
|
-
export declare function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardClickEvent | null;
|
|
93
|
+
export declare function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardClickEvent | ParsedStopEvent | null;
|
|
76
94
|
export type CardClickHandler = (params: Record<string, string>, space: string, sender: string, senderEmail?: string, isDirectMessage?: boolean, messageName?: string,
|
|
77
95
|
/** The clicking person's client language (`ParsedCardClickEvent.locale`). */
|
|
78
96
|
locale?: string,
|
package/google-chat-provider.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
type ServiceAccountCredentials,
|
|
29
29
|
type TokenSource,
|
|
30
30
|
type ChatCard,
|
|
31
|
+
type ChatCardSection,
|
|
31
32
|
} from "./google-chat-app-client.ts";
|
|
32
33
|
import { openSubscription, type PubSubSubscription, type StreamMessage } from "./google-chat-pubsub-stream.ts";
|
|
33
34
|
import { splitForSendLimit } from "./google-chat-message-buffer.ts";
|
|
@@ -113,11 +114,35 @@ function buildLoginCard(login: LoginRequest, t: Translate): ChatCard {
|
|
|
113
114
|
};
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
/** Where a status card stands: in progress, ended as a tool ends, or stopped by the person. */
|
|
118
|
+
type CardStatus = "loading" | ToolOutcome | "stopped";
|
|
119
|
+
|
|
116
120
|
/** A card's status line, in the person's language. */
|
|
117
|
-
function statusLine(status:
|
|
121
|
+
function statusLine(status: CardStatus, t: Translate): string {
|
|
118
122
|
return t(`google-chat.status.${status}`);
|
|
119
123
|
}
|
|
120
124
|
|
|
125
|
+
/**
|
|
126
|
+
* The section that carries the Stop button on a card in progress: a section of
|
|
127
|
+
* its own, outside the collapsible one, so it's visible without expanding
|
|
128
|
+
* anything. A click comes back as `CARD_CLICKED` with the `stop` parameter
|
|
129
|
+
* set to `turn`, the id of the turn the card belongs to (`parseChatEvent`), so
|
|
130
|
+
* a button left on an older card never stops a newer turn. A card's final
|
|
131
|
+
* patch rebuilds it without this section, so the button goes away with the
|
|
132
|
+
* turn.
|
|
133
|
+
*/
|
|
134
|
+
function stopSection(t: Translate, turn: string): ChatCardSection {
|
|
135
|
+
return {
|
|
136
|
+
widgets: [
|
|
137
|
+
{
|
|
138
|
+
buttonList: {
|
|
139
|
+
buttons: [{ text: t("google-chat.stop.button"), onClick: { action: { function: "stop", parameters: [{ key: "stop", value: turn }] } } }],
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
121
146
|
/**
|
|
122
147
|
* A tool-call/reasoning status card: `label` is a section `header`
|
|
123
148
|
* (Google Chat's native accordion title, always visible regardless of
|
|
@@ -146,7 +171,7 @@ function statusLine(status: "loading" | ToolOutcome, t: Translate): string {
|
|
|
146
171
|
* nothing patches the message again, so the native toggle can never be
|
|
147
172
|
* reset out from under the user again either.
|
|
148
173
|
*/
|
|
149
|
-
function buildToolCallCard(label: string, detail: string, status:
|
|
174
|
+
function buildToolCallCard(label: string, detail: string, status: CardStatus, t: Translate, turn: string): ChatCard {
|
|
150
175
|
return {
|
|
151
176
|
sections: [
|
|
152
177
|
{
|
|
@@ -155,6 +180,7 @@ function buildToolCallCard(label: string, detail: string, status: "loading" | To
|
|
|
155
180
|
uncollapsibleWidgetsCount: 0,
|
|
156
181
|
widgets: [{ textParagraph: { text: detail } }, { textParagraph: { text: statusLine(status, t) } }],
|
|
157
182
|
},
|
|
183
|
+
...(status === "loading" ? [stopSection(t, turn)] : []),
|
|
158
184
|
],
|
|
159
185
|
};
|
|
160
186
|
}
|
|
@@ -182,10 +208,17 @@ const REASONING_TAIL_CHARS = 4000;
|
|
|
182
208
|
* in this file. If the turn's first reasoning burst arrives,
|
|
183
209
|
* `onReasoningChunk` patches this exact card into the "Thinking…"
|
|
184
210
|
* card in place, rather than sending a second message; otherwise it's
|
|
185
|
-
*
|
|
211
|
+
* left as the last status before the turn's real answer, patched only to
|
|
212
|
+
* take its Stop button away, or to say the person stopped the turn.
|
|
186
213
|
*/
|
|
187
|
-
function buildReceivingCard(t: Translate): ChatCard {
|
|
188
|
-
|
|
214
|
+
function buildReceivingCard(t: Translate, turn: string, state: "loading" | "answered" | "stopped" = "loading"): ChatCard {
|
|
215
|
+
const text = state === "stopped" ? statusLine("stopped", t) : t("google-chat.receiving.text");
|
|
216
|
+
return {
|
|
217
|
+
sections: [
|
|
218
|
+
{ header: t("google-chat.receiving.title"), widgets: [{ textParagraph: { text } }] },
|
|
219
|
+
...(state === "loading" ? [stopSection(t, turn)] : []),
|
|
220
|
+
],
|
|
221
|
+
};
|
|
189
222
|
}
|
|
190
223
|
|
|
191
224
|
/**
|
|
@@ -220,8 +253,12 @@ type RawChatEvent = {
|
|
|
220
253
|
message?: {
|
|
221
254
|
name?: string;
|
|
222
255
|
text?: string;
|
|
256
|
+
/** The text without the leading @mention of the app, in a space. */
|
|
257
|
+
argumentText?: string;
|
|
223
258
|
space?: { name?: string; type?: string };
|
|
224
259
|
sender?: RawChatSender;
|
|
260
|
+
/** A slash command the message invoked shows up here, with its name. */
|
|
261
|
+
annotations?: Array<{ type?: string; slashCommand?: { commandName?: string } }>;
|
|
225
262
|
};
|
|
226
263
|
action?: { actionMethodName?: string; parameters?: Array<{ key?: string; value?: string }> };
|
|
227
264
|
space?: { name?: string; type?: string };
|
|
@@ -269,6 +306,35 @@ export type ParsedCardClickEvent = {
|
|
|
269
306
|
timeZone?: string;
|
|
270
307
|
};
|
|
271
308
|
|
|
309
|
+
/**
|
|
310
|
+
* The person asked to stop their running turn: `/stop` (`source: "command"`,
|
|
311
|
+
* the registered slash command or a message reading exactly that), or a click
|
|
312
|
+
* on a card's Stop button (`source: "button"`).
|
|
313
|
+
*/
|
|
314
|
+
export type ParsedStopEvent = {
|
|
315
|
+
kind: "stop";
|
|
316
|
+
source: "command" | "button";
|
|
317
|
+
space: string;
|
|
318
|
+
sender: string;
|
|
319
|
+
senderEmail: string | undefined;
|
|
320
|
+
/** As on a message (see `ParsedMessageEvent.isDirectMessage`). */
|
|
321
|
+
isDirectMessage: boolean;
|
|
322
|
+
/** As on a message (see `ParsedMessageEvent.locale`): for the reply when there's nothing to stop. */
|
|
323
|
+
locale?: string;
|
|
324
|
+
/** For a click, the turn whose card carried the button: only that turn is stopped. */
|
|
325
|
+
turn?: string;
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
/** The command that stops the session's running turn. */
|
|
329
|
+
const STOP_COMMAND = "/stop";
|
|
330
|
+
|
|
331
|
+
/** Whether a message asks to stop: it invoked the `/stop` slash command, or its text (after any @mention) is exactly `/stop`. */
|
|
332
|
+
function isStopMessage(m: NonNullable<RawChatEvent["message"]>): boolean {
|
|
333
|
+
if (m.annotations?.some((a) => a.type === "SLASH_COMMAND" && a.slashCommand?.commandName === STOP_COMMAND)) return true;
|
|
334
|
+
const text = typeof m.argumentText === "string" ? m.argumentText : m.text;
|
|
335
|
+
return text?.trim() === STOP_COMMAND;
|
|
336
|
+
}
|
|
337
|
+
|
|
272
338
|
/** The language the event says the person's client is in, when it says. */
|
|
273
339
|
function localeOf(event: RawChatEvent | null | undefined): string | undefined {
|
|
274
340
|
const locale = event?.common?.userLocale;
|
|
@@ -287,7 +353,7 @@ function emailOf(sender: RawChatSender | undefined): string | undefined {
|
|
|
287
353
|
}
|
|
288
354
|
|
|
289
355
|
/** Parses one decoded Pub/Sub event, or `null` if it isn't a kind this provider acts on. */
|
|
290
|
-
export function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardClickEvent | null {
|
|
356
|
+
export function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardClickEvent | ParsedStopEvent | null {
|
|
291
357
|
const event = raw as RawChatEvent;
|
|
292
358
|
const locale = localeOf(event);
|
|
293
359
|
const timeZone = timeZoneOf(event);
|
|
@@ -296,6 +362,17 @@ export function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardCli
|
|
|
296
362
|
if (!m || typeof m.name !== "string" || typeof m.text !== "string") return null;
|
|
297
363
|
if (typeof m.space?.name !== "string") return null;
|
|
298
364
|
if (typeof m.sender?.name !== "string") return null;
|
|
365
|
+
if (isStopMessage(m)) {
|
|
366
|
+
return {
|
|
367
|
+
kind: "stop",
|
|
368
|
+
source: "command",
|
|
369
|
+
space: m.space.name,
|
|
370
|
+
sender: m.sender.name,
|
|
371
|
+
senderEmail: emailOf(m.sender),
|
|
372
|
+
isDirectMessage: m.space.type === "DM",
|
|
373
|
+
...(locale === undefined ? {} : { locale }),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
299
376
|
return {
|
|
300
377
|
kind: "message",
|
|
301
378
|
text: m.text,
|
|
@@ -318,6 +395,18 @@ export function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardCli
|
|
|
318
395
|
parameters[p.key] = p.value;
|
|
319
396
|
}
|
|
320
397
|
}
|
|
398
|
+
if (parameters.stop !== undefined) {
|
|
399
|
+
return {
|
|
400
|
+
kind: "stop",
|
|
401
|
+
source: "button",
|
|
402
|
+
turn: parameters.stop,
|
|
403
|
+
space: event.space.name,
|
|
404
|
+
sender: event.user.name,
|
|
405
|
+
senderEmail: emailOf(event.user),
|
|
406
|
+
isDirectMessage: event.space.type === "DM",
|
|
407
|
+
...(locale === undefined ? {} : { locale }),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
321
410
|
return {
|
|
322
411
|
kind: "card-click",
|
|
323
412
|
...(typeof event.message?.name === "string" ? { messageName: event.message.name } : {}),
|
|
@@ -386,6 +475,15 @@ export type GoogleChatProviderDeps = {
|
|
|
386
475
|
log?: (msg: string) => void;
|
|
387
476
|
};
|
|
388
477
|
|
|
478
|
+
/**
|
|
479
|
+
* A turn's sink, plus what the provider does once the turn has let go:
|
|
480
|
+
* after a stop, `settle` makes the cards say so (the receiving card, or a
|
|
481
|
+
* short message when no card in progress could); after a turn that `failed`
|
|
482
|
+
* (threw, so never reached `finalize`), it takes the receiving card's Stop
|
|
483
|
+
* button away; after an answer, it does nothing.
|
|
484
|
+
*/
|
|
485
|
+
type StopAwareSink = TurnSink & { settle(failed: boolean): Promise<void> };
|
|
486
|
+
|
|
389
487
|
export type GoogleChatProvider = Provider & {
|
|
390
488
|
stop(): Promise<void>;
|
|
391
489
|
};
|
|
@@ -411,12 +509,14 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
411
509
|
// concurrently. Scoped by sessionKey only (not global), so a slow turn
|
|
412
510
|
// for one user/space never blocks a different one's.
|
|
413
511
|
const busySessions = new Set<string>();
|
|
414
|
-
const queuedTurns = new Map<string, Array<() => Promise<void>>>();
|
|
512
|
+
const queuedTurns = new Map<string, Array<(signal: AbortSignal, turnId: string) => Promise<void>>>();
|
|
513
|
+
// The running turn, per session: its id (on its cards' Stop buttons) and the abort a stop fires.
|
|
514
|
+
const runningTurns = new Map<string, { id: string; controller: AbortController }>();
|
|
415
515
|
// The turn handler `start` was given, for a turn a card click resumes.
|
|
416
516
|
let activeHandleTurn: HandleTurn | undefined;
|
|
417
517
|
|
|
418
|
-
/** Runs `turn` for `sessionKey` now, or after the turns already queued for it. */
|
|
419
|
-
async function runInSession(sessionKey: string, turn: () => Promise<void>): Promise<void> {
|
|
518
|
+
/** Runs `turn` for `sessionKey` now, or after the turns already queued for it, with a signal `stopSession` aborts. */
|
|
519
|
+
async function runInSession(sessionKey: string, turn: (signal: AbortSignal, turnId: string) => Promise<void>): Promise<void> {
|
|
420
520
|
if (busySessions.has(sessionKey)) {
|
|
421
521
|
const queue = queuedTurns.get(sessionKey) ?? [];
|
|
422
522
|
queue.push(turn);
|
|
@@ -424,9 +524,13 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
424
524
|
return;
|
|
425
525
|
}
|
|
426
526
|
busySessions.add(sessionKey);
|
|
527
|
+
const controller = new AbortController();
|
|
528
|
+
const id = crypto.randomUUID();
|
|
529
|
+
runningTurns.set(sessionKey, { id, controller });
|
|
427
530
|
try {
|
|
428
|
-
await turn();
|
|
531
|
+
await turn(controller.signal, id);
|
|
429
532
|
} finally {
|
|
533
|
+
runningTurns.delete(sessionKey);
|
|
430
534
|
busySessions.delete(sessionKey);
|
|
431
535
|
const queue = queuedTurns.get(sessionKey);
|
|
432
536
|
const next = queue?.shift();
|
|
@@ -437,6 +541,40 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
437
541
|
}
|
|
438
542
|
}
|
|
439
543
|
|
|
544
|
+
/**
|
|
545
|
+
* Stops `sessionKey`'s running turn and discards every turn queued behind
|
|
546
|
+
* it: they haven't sent anything yet, so they just never run. With `turnId`
|
|
547
|
+
* (a button's), only when that's the turn running: a click on an older
|
|
548
|
+
* card stops nothing. Returns whether there was a turn to stop.
|
|
549
|
+
*/
|
|
550
|
+
function stopSession(sessionKey: string, turnId?: string): boolean {
|
|
551
|
+
const running = runningTurns.get(sessionKey);
|
|
552
|
+
if (running === undefined || running.controller.signal.aborted) return false;
|
|
553
|
+
if (turnId !== undefined && turnId !== running.id) return false;
|
|
554
|
+
queuedTurns.delete(sessionKey);
|
|
555
|
+
running.controller.abort();
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Runs one turn on `handleTurn` with the session's stop signal, then
|
|
561
|
+
* settles its cards once it has let go (`StopAwareSink.settle`).
|
|
562
|
+
*/
|
|
563
|
+
async function runTurn(
|
|
564
|
+
handleTurn: HandleTurn,
|
|
565
|
+
turn: Parameters<HandleTurn>[0],
|
|
566
|
+
sink: StopAwareSink,
|
|
567
|
+
signal: AbortSignal,
|
|
568
|
+
): Promise<void> {
|
|
569
|
+
try {
|
|
570
|
+
await handleTurn({ ...turn, abortSignal: signal }, sink);
|
|
571
|
+
} catch (err) {
|
|
572
|
+
await sink.settle(true);
|
|
573
|
+
throw err;
|
|
574
|
+
}
|
|
575
|
+
await sink.settle(false);
|
|
576
|
+
}
|
|
577
|
+
|
|
440
578
|
/** Resumes the session's turn after a confirmation whose result went back to the model (`ConfirmReply.resume`). */
|
|
441
579
|
async function resumeTurn(
|
|
442
580
|
handleTurn: HandleTurn,
|
|
@@ -448,8 +586,9 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
448
586
|
): Promise<void> {
|
|
449
587
|
const sessionKey = deriveSessionKey(space, principal.id);
|
|
450
588
|
const t = await textsFor(principal, locale);
|
|
451
|
-
return runInSession(sessionKey, () =>
|
|
452
|
-
|
|
589
|
+
return runInSession(sessionKey, (signal, turnId) =>
|
|
590
|
+
runTurn(
|
|
591
|
+
handleTurn,
|
|
453
592
|
{
|
|
454
593
|
channel: "google-chat",
|
|
455
594
|
multiUser: !isDirectMessage,
|
|
@@ -461,7 +600,8 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
461
600
|
...(locale === undefined ? {} : { languageHint: locale }),
|
|
462
601
|
...(timeZone === undefined ? {} : { timeZoneHint: timeZone }),
|
|
463
602
|
},
|
|
464
|
-
createSink(space, { user: principal.id, isDirectMessage }, t),
|
|
603
|
+
createSink(space, { user: principal.id, isDirectMessage }, t, signal, turnId),
|
|
604
|
+
signal,
|
|
465
605
|
),
|
|
466
606
|
);
|
|
467
607
|
}
|
|
@@ -527,9 +667,17 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
527
667
|
* Per-turn output sink — same responsibilities as the retired `ChatStreamer`,
|
|
528
668
|
* rebuilt against the new client. `viewer` is the person the turn is for;
|
|
529
669
|
* outside a direct message, what only they may see is sent to them alone.
|
|
530
|
-
* `t` gives the cards' texts in their language.
|
|
670
|
+
* `t` gives the cards' texts in their language. `signal` is the turn's
|
|
671
|
+
* stop: once it aborted, a reasoning block cut short ends as "Stopped"
|
|
672
|
+
* rather than "Failed". `turn` is the turn's id, on its Stop buttons.
|
|
531
673
|
*/
|
|
532
|
-
function createSink(
|
|
674
|
+
function createSink(
|
|
675
|
+
space: string,
|
|
676
|
+
viewer: { user: string; isDirectMessage: boolean },
|
|
677
|
+
t: Translate,
|
|
678
|
+
signal: AbortSignal,
|
|
679
|
+
turn: string,
|
|
680
|
+
): StopAwareSink {
|
|
533
681
|
let chain: Promise<void> = Promise.resolve();
|
|
534
682
|
|
|
535
683
|
function enqueue(fn: () => Promise<void>): void {
|
|
@@ -569,9 +717,23 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
569
717
|
|
|
570
718
|
// A tool asking again in the same turn hands back the same pending login.
|
|
571
719
|
const shownLogins = new Set<string>();
|
|
720
|
+
// Whether a card already says the person stopped the turn.
|
|
721
|
+
let stopShown = false;
|
|
722
|
+
// Set once the answer is on its way: a stop after it has nothing left to say.
|
|
723
|
+
let answered = false;
|
|
724
|
+
|
|
725
|
+
/** Patches the receiving card no reasoning claimed, to `state`: it has nothing else that would ever take its Stop button away. */
|
|
726
|
+
async function closeReceivingCard(state: "answered" | "stopped"): Promise<boolean> {
|
|
727
|
+
if (receivingCardName === undefined) return false;
|
|
728
|
+
const name = receivingCardName;
|
|
729
|
+
receivingCardName = undefined;
|
|
730
|
+
log(`[chat:${space}] [out] receiving card ${name}: ${state}`);
|
|
731
|
+
await updateCardFn(name, buildReceivingCard(t, turn, state), clientDeps);
|
|
732
|
+
return true;
|
|
733
|
+
}
|
|
572
734
|
enqueue(async () => {
|
|
573
735
|
log(`[chat:${space}] [out] receiving card`);
|
|
574
|
-
const sent = await sendCardFn(space, buildReceivingCard(t), clientDeps);
|
|
736
|
+
const sent = await sendCardFn(space, buildReceivingCard(t, turn), clientDeps);
|
|
575
737
|
sentMessageNames.add(sent.name);
|
|
576
738
|
receivingCardName = sent.name;
|
|
577
739
|
});
|
|
@@ -583,7 +745,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
583
745
|
return;
|
|
584
746
|
}
|
|
585
747
|
enqueue(async () => {
|
|
586
|
-
const card = buildToolCallCard(label, detail ?? "", "loading", t);
|
|
748
|
+
const card = buildToolCallCard(label, detail ?? "", "loading", t, turn);
|
|
587
749
|
log(`[chat:${space}] [out] tool card: ${label}`);
|
|
588
750
|
const sent = await sendCardFn(space, card, clientDeps);
|
|
589
751
|
sentMessageNames.add(sent.name);
|
|
@@ -597,7 +759,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
597
759
|
log(`[chat:${space}] onToolFinish for unknown toolCallId ${toolCallId}`);
|
|
598
760
|
return;
|
|
599
761
|
}
|
|
600
|
-
const card = buildToolCallCard(entry.label, entry.detail, outcome, t);
|
|
762
|
+
const card = buildToolCallCard(entry.label, entry.detail, outcome, t, turn);
|
|
601
763
|
log(`[chat:${space}] [out] patching ${entry.name} to "${outcome}"`);
|
|
602
764
|
const patched = await updateCardFn(entry.name, card, clientDeps);
|
|
603
765
|
log(`[chat:${space}] [out] patched ${patched.name}`);
|
|
@@ -614,7 +776,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
614
776
|
entry = { name: undefined, buffer: chunk };
|
|
615
777
|
reasoningCards.set(id, entry);
|
|
616
778
|
enqueue(async () => {
|
|
617
|
-
const card = buildToolCallCard(t("google-chat.thinking"), "", "loading", t);
|
|
779
|
+
const card = buildToolCallCard(t("google-chat.thinking"), "", "loading", t, turn);
|
|
618
780
|
if (receivingCardName !== undefined) {
|
|
619
781
|
// Replace the "Status" placeholder in place instead of
|
|
620
782
|
// sending a second message.
|
|
@@ -642,8 +804,9 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
642
804
|
if (!entry) return; // no reasoning happened for this id — no card to close
|
|
643
805
|
enqueue(async () => {
|
|
644
806
|
if (entry.name === undefined) return; // shouldn't happen given enqueue's own FIFO ordering, but stays defensive
|
|
645
|
-
const status = failed ? "
|
|
646
|
-
|
|
807
|
+
const status = !failed ? "success" : signal.aborted ? "stopped" : "failed";
|
|
808
|
+
if (status === "stopped") stopShown = true;
|
|
809
|
+
const card = buildToolCallCard(t("google-chat.thinking"), tailTruncate(entry.buffer, REASONING_TAIL_CHARS), status, t, turn);
|
|
647
810
|
log(`[chat:${space}] [out] reasoning patch (${id}): status=${status} bufferLen=${entry.buffer.length}`);
|
|
648
811
|
await updateCardFn(entry.name, card, clientDeps);
|
|
649
812
|
reasoningCards.delete(id);
|
|
@@ -680,6 +843,10 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
680
843
|
const note = info?.pendingNote;
|
|
681
844
|
const shown = note !== undefined && finalText.startsWith(note) ? finalText.slice(note.length) : finalText;
|
|
682
845
|
const trimmed = shown.trim();
|
|
846
|
+
answered = true;
|
|
847
|
+
enqueue(async () => {
|
|
848
|
+
await closeReceivingCard("answered");
|
|
849
|
+
});
|
|
683
850
|
if (trimmed.length > 0 && trimmed !== NO_REPLY) {
|
|
684
851
|
for (const message of splitForSendLimit(trimmed)) {
|
|
685
852
|
sendPlain(message);
|
|
@@ -688,6 +855,27 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
688
855
|
await chain;
|
|
689
856
|
},
|
|
690
857
|
dispose: () => {},
|
|
858
|
+
settle: async (failed) => {
|
|
859
|
+
if (answered) return;
|
|
860
|
+
if (!signal.aborted) {
|
|
861
|
+
if (!failed) return;
|
|
862
|
+
enqueue(async () => {
|
|
863
|
+
await closeReceivingCard("answered");
|
|
864
|
+
});
|
|
865
|
+
await chain;
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
enqueue(async () => {
|
|
869
|
+
if (await closeReceivingCard("stopped")) stopShown = true;
|
|
870
|
+
if (stopShown) return;
|
|
871
|
+
stopShown = true;
|
|
872
|
+
const text = statusLine("stopped", t);
|
|
873
|
+
log(`[chat:${space}] [out] ${text}`);
|
|
874
|
+
const sent = await sendMessageFn(space, text, clientDeps);
|
|
875
|
+
sentMessageNames.add(sent.name);
|
|
876
|
+
});
|
|
877
|
+
await chain;
|
|
878
|
+
},
|
|
691
879
|
};
|
|
692
880
|
}
|
|
693
881
|
|
|
@@ -723,8 +911,9 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
723
911
|
// Only the actual model turn needs serializing — the confirmation above
|
|
724
912
|
// never touches SessionHistory, so it's always safe to run right
|
|
725
913
|
// away regardless of whether this session is mid-turn.
|
|
726
|
-
await runInSession(sessionKey, () =>
|
|
727
|
-
|
|
914
|
+
await runInSession(sessionKey, (signal, turnId) =>
|
|
915
|
+
runTurn(
|
|
916
|
+
handleTurn,
|
|
728
917
|
{
|
|
729
918
|
channel: "google-chat",
|
|
730
919
|
multiUser: !event.isDirectMessage,
|
|
@@ -735,11 +924,37 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
735
924
|
...(event.locale === undefined ? {} : { languageHint: event.locale }),
|
|
736
925
|
...(event.timeZone === undefined ? {} : { timeZoneHint: event.timeZone }),
|
|
737
926
|
},
|
|
738
|
-
createSink(event.space, { user: event.sender, isDirectMessage: event.isDirectMessage }, t),
|
|
927
|
+
createSink(event.space, { user: event.sender, isDirectMessage: event.isDirectMessage }, t, signal, turnId),
|
|
928
|
+
signal,
|
|
739
929
|
),
|
|
740
930
|
);
|
|
741
931
|
}
|
|
742
932
|
|
|
933
|
+
/**
|
|
934
|
+
* A stop from the person: aborts their running turn in this space and
|
|
935
|
+
* discards what's queued behind it (`stopSession`), ahead of the queue it
|
|
936
|
+
* would otherwise wait in. Only their own session: in a shared space,
|
|
937
|
+
* someone else's stop finds nothing of theirs running. `/stop` with nothing
|
|
938
|
+
* to stop says so; a click on a card whose turn is over does nothing.
|
|
939
|
+
*/
|
|
940
|
+
async function processStopEvent(event: ParsedStopEvent): Promise<void> {
|
|
941
|
+
const sessionKey = deriveSessionKey(event.space, event.sender);
|
|
942
|
+
if (stopSession(sessionKey, event.turn)) {
|
|
943
|
+
log(`[chat:${event.space}:${event.sender}] [in] stop (${event.source})`);
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
log(`[chat:${event.space}:${event.sender}] [in] stop (${event.source}), nothing running`);
|
|
947
|
+
if (event.source !== "command") return;
|
|
948
|
+
const principal: Principal = {
|
|
949
|
+
id: event.sender,
|
|
950
|
+
provider: "google-chat",
|
|
951
|
+
...(event.senderEmail === undefined ? {} : { claims: { email: event.senderEmail } }),
|
|
952
|
+
};
|
|
953
|
+
const t = await textsFor(principal, event.locale);
|
|
954
|
+
const sent = await sendMessageFn(event.space, t("google-chat.stop.nothing"), clientDeps);
|
|
955
|
+
sentMessageNames.add(sent.name);
|
|
956
|
+
}
|
|
957
|
+
|
|
743
958
|
/**
|
|
744
959
|
* Acks each message immediately on arrival — before `handleTurn` (or
|
|
745
960
|
* `onCardClick`) ever runs, not after it resolves. Same pattern the
|
|
@@ -756,7 +971,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
756
971
|
*/
|
|
757
972
|
async function handleMessage(message: StreamMessage, handleTurn: HandleTurn): Promise<void> {
|
|
758
973
|
message.ack();
|
|
759
|
-
let parsed: ParsedMessageEvent | ParsedCardClickEvent | null;
|
|
974
|
+
let parsed: ParsedMessageEvent | ParsedCardClickEvent | ParsedStopEvent | null;
|
|
760
975
|
try {
|
|
761
976
|
parsed = parseChatEvent(JSON.parse(message.data.toString("utf-8")));
|
|
762
977
|
} catch (err) {
|
|
@@ -767,6 +982,8 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
767
982
|
try {
|
|
768
983
|
if (parsed.kind === "message") {
|
|
769
984
|
await processMessageEvent(parsed, handleTurn);
|
|
985
|
+
} else if (parsed.kind === "stop") {
|
|
986
|
+
await processStopEvent(parsed);
|
|
770
987
|
} else {
|
|
771
988
|
await onCardClick(parsed.parameters, parsed.space, parsed.sender, parsed.senderEmail, parsed.isDirectMessage, parsed.messageName, parsed.locale, parsed.timeZone);
|
|
772
989
|
}
|
package/messages.ts
CHANGED
|
@@ -17,10 +17,14 @@ export const GOOGLE_CHAT_MESSAGES: Messages = {
|
|
|
17
17
|
"google-chat.status.success": "Done.",
|
|
18
18
|
"google-chat.status.failed": "Failed.",
|
|
19
19
|
"google-chat.status.pending": "Waiting for confirmation.",
|
|
20
|
+
"google-chat.status.stopped": "Stopped.",
|
|
20
21
|
"google-chat.thinking": "Thinking…",
|
|
21
22
|
// The card sent as soon as a message arrives (`buildReceivingCard`).
|
|
22
23
|
"google-chat.receiving.title": "Status",
|
|
23
24
|
"google-chat.receiving.text": "Message received…",
|
|
25
|
+
// The Stop button on a card in progress (`stopSection`), and `/stop` with nothing running.
|
|
26
|
+
"google-chat.stop.button": "Stop",
|
|
27
|
+
"google-chat.stop.nothing": "Nothing to stop.",
|
|
24
28
|
};
|
|
25
29
|
|
|
26
30
|
/** These texts in English, for a provider built without the core's (`ChannelI18n`); any other key comes back as itself. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mercury-fw/channel-google-chat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"@mercury-fw/channel-types": ">=0.54.0 <1.0.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
|
-
"@mercury-fw/channel-types": "0.
|
|
37
|
+
"@mercury-fw/channel-types": "0.58.0",
|
|
38
38
|
"@mercury-fw/typescript-config": "*",
|
|
39
39
|
"@types/bun": "^1.4.2",
|
|
40
40
|
"typescript": "^6.0.3"
|