@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 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,
@@ -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: "loading" | ToolOutcome, t: Translate): string {
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: "loading" | ToolOutcome, t: Translate): ChatCard {
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
- * simply left as the last status before the turn's real answer.
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
- return { sections: [{ header: t("google-chat.receiving.title"), widgets: [{ textParagraph: { text: t("google-chat.receiving.text") } }] }] };
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
- handleTurn(
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(space: string, viewer: { user: string; isDirectMessage: boolean }, t: Translate): TurnSink {
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 ? "failed" : "success";
646
- const card = buildToolCallCard(t("google-chat.thinking"), tailTruncate(entry.buffer, REASONING_TAIL_CHARS), status, t);
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
- handleTurn(
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.10.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.56.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"