@mercury-fw/channel-google-chat 0.9.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,36 @@
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
+
17
+ ## 0.10.0
18
+
19
+ ### Minor Changes
20
+
21
+ - 1126106: Every turn tells the model when and where the person wrote it.
22
+
23
+ - Each message reaches the model after a line with the weekday, date, time and time zone it was written in (`[Wednesday 2026-10-21 14:32 Europe/Rome]`), and the system prompt says what that line is. The conversation's archive and what a UI shows of it keep the message as written.
24
+ - A resumed conversation's messages come back stamped from the archive's timestamps.
25
+ - The Layer 1 summary keeps when things were said; the episodic summary quotes a date only from those lines, never guessing one. The semantic-fact extractor and the capture card's preview leave the line out.
26
+ - The time zone, always an IANA name (`Europe/Rome`), is the one the person saved, else the one their client is in when the channel passes it on, else the new `i18n.timeZone` in `mercury.config.ts`, else the machine's. A `timeZone` that isn't a zone's name stops the start.
27
+ - The person saves theirs by telling the assistant (the new `set_time_zone` tool), or from a channel's settings.
28
+ - Channel contract: `InboundTurn.timeZoneHint`, and `ctx.settings.setTimeZone` for a channel with a settings area; `reads.me` also returns the saved `timeZone`. Both are optional additions: the contract version stays 6.
29
+ - HTTP channel: the `X-Time-Zone` header on `POST /turn` (allowed by CORS), `timeZone` in `GET /me`, and `PATCH /me` with `{ timeZone }` to save it. Needs the framework at 0.56 or later; an older core ignores the header and offers no `PATCH /me`.
30
+ - Google Chat channel: the client's time zone (`common.timeZone`), when the event carries it, on messages and on the turn a click resumes.
31
+
32
+ To set a default for an app's people, add `timeZone: "Europe/Rome"` (their zone) to `i18n` in `mercury.config.ts`; without it the machine's zone is used, often UTC inside a container.
33
+
3
34
  ## 0.9.0
4
35
 
5
36
  ### Minor Changes
package/README.md CHANGED
@@ -26,14 +26,16 @@ 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.
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
 
33
- Every text the channel shows a person (the cards' titles and status lines, the confirm button, the replies to a confirmation) is in their language: the one they asked the assistant for, else the language of their Chat client when the event carries it, else the app's default. The cards' own texts are `google-chat.` keys an app translates in its language files.
33
+ Every text the channel shows a person (the cards' titles and status lines, the confirm button, the replies to a confirmation) is in their language: the one they asked the assistant for, else the language of their Chat client when the event carries it, else the app's default. The cards' own texts are `google-chat.` keys an app translates in its language files. The time zone of their Chat client, when the event carries it (`common.timeZone`), goes to the core the same way: the model reads each message with the time there, unless the person saved a zone of their own.
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
 
@@ -53,6 +53,8 @@ export type ParsedMessageEvent = {
53
53
  isDirectMessage: boolean;
54
54
  /** The language of the sender's Chat client (`common.userLocale`), when the event carries it: the turn's language hint. */
55
55
  locale?: string;
56
+ /** The time zone of the sender's Chat client (`common.timeZone.id`), when the event carries it: the turn's time zone hint. */
57
+ timeZone?: string;
56
58
  };
57
59
  export type ParsedCardClickEvent = {
58
60
  kind: "card-click";
@@ -66,12 +68,34 @@ export type ParsedCardClickEvent = {
66
68
  isDirectMessage: boolean;
67
69
  /** As on a message (see `ParsedMessageEvent.locale`). */
68
70
  locale?: string;
71
+ /** As on a message (see `ParsedMessageEvent.timeZone`). */
72
+ timeZone?: string;
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;
69
91
  };
70
92
  /** Parses one decoded Pub/Sub event, or `null` if it isn't a kind this provider acts on. */
71
- export declare function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardClickEvent | null;
93
+ export declare function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardClickEvent | ParsedStopEvent | null;
72
94
  export type CardClickHandler = (params: Record<string, string>, space: string, sender: string, senderEmail?: string, isDirectMessage?: boolean, messageName?: string,
73
95
  /** The clicking person's client language (`ParsedCardClickEvent.locale`). */
74
- locale?: string) => Promise<void>;
96
+ locale?: string,
97
+ /** The clicking person's client time zone (`ParsedCardClickEvent.timeZone`). */
98
+ timeZone?: string) => Promise<void>;
75
99
  export type GoogleChatProviderDeps = {
76
100
  credentials: ServiceAccountCredentials;
77
101
  subscription: 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,14 +253,18 @@ 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 };
228
265
  user?: RawChatSender;
229
- /** What the person's Chat client says about them; `userLocale` is their language (`en-US`), when the event carries it. */
230
- common?: { userLocale?: string };
266
+ /** What the person's Chat client says about them, when the event carries it: `userLocale` is their language (`en-US`), `timeZone.id` the zone they're in (`Europe/Rome`). */
267
+ common?: { userLocale?: string; timeZone?: { id?: string; offset?: number } };
231
268
  };
232
269
 
233
270
  export type ParsedMessageEvent = {
@@ -249,6 +286,8 @@ export type ParsedMessageEvent = {
249
286
  isDirectMessage: boolean;
250
287
  /** The language of the sender's Chat client (`common.userLocale`), when the event carries it: the turn's language hint. */
251
288
  locale?: string;
289
+ /** The time zone of the sender's Chat client (`common.timeZone.id`), when the event carries it: the turn's time zone hint. */
290
+ timeZone?: string;
252
291
  };
253
292
 
254
293
  export type ParsedCardClickEvent = {
@@ -263,28 +302,77 @@ export type ParsedCardClickEvent = {
263
302
  isDirectMessage: boolean;
264
303
  /** As on a message (see `ParsedMessageEvent.locale`). */
265
304
  locale?: string;
305
+ /** As on a message (see `ParsedMessageEvent.timeZone`). */
306
+ timeZone?: string;
307
+ };
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;
266
326
  };
267
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
+
268
338
  /** The language the event says the person's client is in, when it says. */
269
339
  function localeOf(event: RawChatEvent | null | undefined): string | undefined {
270
340
  const locale = event?.common?.userLocale;
271
341
  return typeof locale === "string" && locale.trim() !== "" ? locale.trim() : undefined;
272
342
  }
273
343
 
344
+ /** The time zone the event says the person's client is in, when it says. */
345
+ function timeZoneOf(event: RawChatEvent | null | undefined): string | undefined {
346
+ const id = event?.common?.timeZone?.id;
347
+ return typeof id === "string" && id.trim() !== "" ? id.trim() : undefined;
348
+ }
349
+
274
350
  /** The email Google vouches for on `sender`: a person's only (`HUMAN`), never a bot's or an unknown kind's. */
275
351
  function emailOf(sender: RawChatSender | undefined): string | undefined {
276
352
  return typeof sender?.email === "string" && sender.email !== "" && sender.type === "HUMAN" ? sender.email : undefined;
277
353
  }
278
354
 
279
355
  /** Parses one decoded Pub/Sub event, or `null` if it isn't a kind this provider acts on. */
280
- export function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardClickEvent | null {
356
+ export function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardClickEvent | ParsedStopEvent | null {
281
357
  const event = raw as RawChatEvent;
282
358
  const locale = localeOf(event);
359
+ const timeZone = timeZoneOf(event);
283
360
  if (event?.type === "MESSAGE") {
284
361
  const m = event.message;
285
362
  if (!m || typeof m.name !== "string" || typeof m.text !== "string") return null;
286
363
  if (typeof m.space?.name !== "string") return null;
287
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
+ }
288
376
  return {
289
377
  kind: "message",
290
378
  text: m.text,
@@ -295,6 +383,7 @@ export function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardCli
295
383
  senderEmail: emailOf(m.sender),
296
384
  isDirectMessage: m.space.type === "DM",
297
385
  ...(locale === undefined ? {} : { locale }),
386
+ ...(timeZone === undefined ? {} : { timeZone }),
298
387
  };
299
388
  }
300
389
  if (event?.type === "CARD_CLICKED") {
@@ -306,6 +395,18 @@ export function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardCli
306
395
  parameters[p.key] = p.value;
307
396
  }
308
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
+ }
309
410
  return {
310
411
  kind: "card-click",
311
412
  ...(typeof event.message?.name === "string" ? { messageName: event.message.name } : {}),
@@ -315,6 +416,7 @@ export function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardCli
315
416
  parameters,
316
417
  isDirectMessage: event.space.type === "DM",
317
418
  ...(locale === undefined ? {} : { locale }),
419
+ ...(timeZone === undefined ? {} : { timeZone }),
318
420
  };
319
421
  }
320
422
  return null;
@@ -329,6 +431,8 @@ export type CardClickHandler = (
329
431
  messageName?: string,
330
432
  /** The clicking person's client language (`ParsedCardClickEvent.locale`). */
331
433
  locale?: string,
434
+ /** The clicking person's client time zone (`ParsedCardClickEvent.timeZone`). */
435
+ timeZone?: string,
332
436
  ) => Promise<void>;
333
437
 
334
438
  export type GoogleChatProviderDeps = {
@@ -371,6 +475,15 @@ export type GoogleChatProviderDeps = {
371
475
  log?: (msg: string) => void;
372
476
  };
373
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
+
374
487
  export type GoogleChatProvider = Provider & {
375
488
  stop(): Promise<void>;
376
489
  };
@@ -396,12 +509,14 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
396
509
  // concurrently. Scoped by sessionKey only (not global), so a slow turn
397
510
  // for one user/space never blocks a different one's.
398
511
  const busySessions = new Set<string>();
399
- 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 }>();
400
515
  // The turn handler `start` was given, for a turn a card click resumes.
401
516
  let activeHandleTurn: HandleTurn | undefined;
402
517
 
403
- /** Runs `turn` for `sessionKey` now, or after the turns already queued for it. */
404
- 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> {
405
520
  if (busySessions.has(sessionKey)) {
406
521
  const queue = queuedTurns.get(sessionKey) ?? [];
407
522
  queue.push(turn);
@@ -409,9 +524,13 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
409
524
  return;
410
525
  }
411
526
  busySessions.add(sessionKey);
527
+ const controller = new AbortController();
528
+ const id = crypto.randomUUID();
529
+ runningTurns.set(sessionKey, { id, controller });
412
530
  try {
413
- await turn();
531
+ await turn(controller.signal, id);
414
532
  } finally {
533
+ runningTurns.delete(sessionKey);
415
534
  busySessions.delete(sessionKey);
416
535
  const queue = queuedTurns.get(sessionKey);
417
536
  const next = queue?.shift();
@@ -422,6 +541,40 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
422
541
  }
423
542
  }
424
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
+
425
578
  /** Resumes the session's turn after a confirmation whose result went back to the model (`ConfirmReply.resume`). */
426
579
  async function resumeTurn(
427
580
  handleTurn: HandleTurn,
@@ -429,11 +582,13 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
429
582
  principal: Principal,
430
583
  isDirectMessage: boolean,
431
584
  locale: string | undefined,
585
+ timeZone: string | undefined,
432
586
  ): Promise<void> {
433
587
  const sessionKey = deriveSessionKey(space, principal.id);
434
588
  const t = await textsFor(principal, locale);
435
- return runInSession(sessionKey, () =>
436
- handleTurn(
589
+ return runInSession(sessionKey, (signal, turnId) =>
590
+ runTurn(
591
+ handleTurn,
437
592
  {
438
593
  channel: "google-chat",
439
594
  multiUser: !isDirectMessage,
@@ -443,8 +598,10 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
443
598
  logPrefix: `[chat:${space}:${principal.id}] `,
444
599
  resumeAfterConfirm: true,
445
600
  ...(locale === undefined ? {} : { languageHint: locale }),
601
+ ...(timeZone === undefined ? {} : { timeZoneHint: timeZone }),
446
602
  },
447
- createSink(space, { user: principal.id, isDirectMessage }, t),
603
+ createSink(space, { user: principal.id, isDirectMessage }, t, signal, turnId),
604
+ signal,
448
605
  ),
449
606
  );
450
607
  }
@@ -460,7 +617,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
460
617
  */
461
618
  const onCardClick: CardClickHandler =
462
619
  deps.onCardClick ??
463
- (async (params, space, sender, senderEmail, isDirectMessage = false, messageName, locale) => {
620
+ (async (params, space, sender, senderEmail, isDirectMessage = false, messageName, locale, timeZone) => {
464
621
  const token = params.token;
465
622
  if (!token) {
466
623
  log(`[chat] card click with no token parameter`);
@@ -495,7 +652,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
495
652
  reply = await deps.confirm(token, sessionKey, principal, locale);
496
653
  }
497
654
  if (reply?.resume && activeHandleTurn) {
498
- await resumeTurn(activeHandleTurn, space, principal, isDirectMessage, locale);
655
+ await resumeTurn(activeHandleTurn, space, principal, isDirectMessage, locale, timeZone);
499
656
  return;
500
657
  }
501
658
  if (reply !== null) {
@@ -510,9 +667,17 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
510
667
  * Per-turn output sink — same responsibilities as the retired `ChatStreamer`,
511
668
  * rebuilt against the new client. `viewer` is the person the turn is for;
512
669
  * outside a direct message, what only they may see is sent to them alone.
513
- * `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.
514
673
  */
515
- 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 {
516
681
  let chain: Promise<void> = Promise.resolve();
517
682
 
518
683
  function enqueue(fn: () => Promise<void>): void {
@@ -552,9 +717,23 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
552
717
 
553
718
  // A tool asking again in the same turn hands back the same pending login.
554
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
+ }
555
734
  enqueue(async () => {
556
735
  log(`[chat:${space}] [out] receiving card`);
557
- const sent = await sendCardFn(space, buildReceivingCard(t), clientDeps);
736
+ const sent = await sendCardFn(space, buildReceivingCard(t, turn), clientDeps);
558
737
  sentMessageNames.add(sent.name);
559
738
  receivingCardName = sent.name;
560
739
  });
@@ -566,7 +745,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
566
745
  return;
567
746
  }
568
747
  enqueue(async () => {
569
- const card = buildToolCallCard(label, detail ?? "", "loading", t);
748
+ const card = buildToolCallCard(label, detail ?? "", "loading", t, turn);
570
749
  log(`[chat:${space}] [out] tool card: ${label}`);
571
750
  const sent = await sendCardFn(space, card, clientDeps);
572
751
  sentMessageNames.add(sent.name);
@@ -580,7 +759,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
580
759
  log(`[chat:${space}] onToolFinish for unknown toolCallId ${toolCallId}`);
581
760
  return;
582
761
  }
583
- const card = buildToolCallCard(entry.label, entry.detail, outcome, t);
762
+ const card = buildToolCallCard(entry.label, entry.detail, outcome, t, turn);
584
763
  log(`[chat:${space}] [out] patching ${entry.name} to "${outcome}"`);
585
764
  const patched = await updateCardFn(entry.name, card, clientDeps);
586
765
  log(`[chat:${space}] [out] patched ${patched.name}`);
@@ -597,7 +776,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
597
776
  entry = { name: undefined, buffer: chunk };
598
777
  reasoningCards.set(id, entry);
599
778
  enqueue(async () => {
600
- const card = buildToolCallCard(t("google-chat.thinking"), "", "loading", t);
779
+ const card = buildToolCallCard(t("google-chat.thinking"), "", "loading", t, turn);
601
780
  if (receivingCardName !== undefined) {
602
781
  // Replace the "Status" placeholder in place instead of
603
782
  // sending a second message.
@@ -625,8 +804,9 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
625
804
  if (!entry) return; // no reasoning happened for this id — no card to close
626
805
  enqueue(async () => {
627
806
  if (entry.name === undefined) return; // shouldn't happen given enqueue's own FIFO ordering, but stays defensive
628
- const status = failed ? "failed" : "success";
629
- 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);
630
810
  log(`[chat:${space}] [out] reasoning patch (${id}): status=${status} bufferLen=${entry.buffer.length}`);
631
811
  await updateCardFn(entry.name, card, clientDeps);
632
812
  reasoningCards.delete(id);
@@ -663,6 +843,10 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
663
843
  const note = info?.pendingNote;
664
844
  const shown = note !== undefined && finalText.startsWith(note) ? finalText.slice(note.length) : finalText;
665
845
  const trimmed = shown.trim();
846
+ answered = true;
847
+ enqueue(async () => {
848
+ await closeReceivingCard("answered");
849
+ });
666
850
  if (trimmed.length > 0 && trimmed !== NO_REPLY) {
667
851
  for (const message of splitForSendLimit(trimmed)) {
668
852
  sendPlain(message);
@@ -671,6 +855,27 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
671
855
  await chain;
672
856
  },
673
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
+ },
674
879
  };
675
880
  }
676
881
 
@@ -692,7 +897,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
692
897
 
693
898
  const reply = await deps.confirm(event.text, sessionKey, principal, event.locale);
694
899
  if (reply?.resume) {
695
- await resumeTurn(handleTurn, event.space, principal, event.isDirectMessage, event.locale);
900
+ await resumeTurn(handleTurn, event.space, principal, event.isDirectMessage, event.locale, event.timeZone);
696
901
  return;
697
902
  }
698
903
  if (reply !== null) {
@@ -706,8 +911,9 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
706
911
  // Only the actual model turn needs serializing — the confirmation above
707
912
  // never touches SessionHistory, so it's always safe to run right
708
913
  // away regardless of whether this session is mid-turn.
709
- await runInSession(sessionKey, () =>
710
- handleTurn(
914
+ await runInSession(sessionKey, (signal, turnId) =>
915
+ runTurn(
916
+ handleTurn,
711
917
  {
712
918
  channel: "google-chat",
713
919
  multiUser: !event.isDirectMessage,
@@ -716,12 +922,39 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
716
922
  principal,
717
923
  logPrefix: `[chat:${event.space}:${event.sender}] `,
718
924
  ...(event.locale === undefined ? {} : { languageHint: event.locale }),
925
+ ...(event.timeZone === undefined ? {} : { timeZoneHint: event.timeZone }),
719
926
  },
720
- 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,
721
929
  ),
722
930
  );
723
931
  }
724
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
+
725
958
  /**
726
959
  * Acks each message immediately on arrival — before `handleTurn` (or
727
960
  * `onCardClick`) ever runs, not after it resolves. Same pattern the
@@ -738,7 +971,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
738
971
  */
739
972
  async function handleMessage(message: StreamMessage, handleTurn: HandleTurn): Promise<void> {
740
973
  message.ack();
741
- let parsed: ParsedMessageEvent | ParsedCardClickEvent | null;
974
+ let parsed: ParsedMessageEvent | ParsedCardClickEvent | ParsedStopEvent | null;
742
975
  try {
743
976
  parsed = parseChatEvent(JSON.parse(message.data.toString("utf-8")));
744
977
  } catch (err) {
@@ -749,8 +982,10 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
749
982
  try {
750
983
  if (parsed.kind === "message") {
751
984
  await processMessageEvent(parsed, handleTurn);
985
+ } else if (parsed.kind === "stop") {
986
+ await processStopEvent(parsed);
752
987
  } else {
753
- await onCardClick(parsed.parameters, parsed.space, parsed.sender, parsed.senderEmail, parsed.isDirectMessage, parsed.messageName, parsed.locale);
988
+ await onCardClick(parsed.parameters, parsed.space, parsed.sender, parsed.senderEmail, parsed.isDirectMessage, parsed.messageName, parsed.locale, parsed.timeZone);
754
989
  }
755
990
  } catch (err) {
756
991
  log(`[chat] event handling failed: ${String(err)}`);
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.9.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.54.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"