@mercury-fw/channel-google-chat 0.7.0 → 0.8.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.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b2f8cfb: - A confirmation can offer several choices (for example "only this time" / "always for this host"): a plugin tool stages them with the new `stageChoices` of `SessionToolContext`, and gets one token per choice. The person takes one; the others die with it, and they all expire together.
8
+ - The paper-trail note of a group of choices is one, under its first token, and says which choice was taken.
9
+ - `detectPendingConfirmation` returns the `choices` a staging offers; a plain staging is one choice labelled `Conferma`.
10
+ - A confirmed action can hand its result back to the model (`resume` on a choice): instead of showing the person the raw result, the channel resumes the turn and the model answers on it, the result reaching it as the tool result of the call that staged it, never stored in the history.
11
+ - A resumed turn answers the person who confirmed even in a shared space, and the line standing for the confirmation isn't archived as something they said. A result nobody resumes within five minutes (the life of a confirmation token) is forgotten, and a restart loses it.
12
+ - `InboundTurn.resumeAfterConfirm` and `RESUME_AFTER_CONFIRM` (channel contract) carry the resumed turn; `ConfirmOutcome` has a `resume` status.
13
+ - Terminal: every choice is printed on its own line with its token; a confirmation that resumes runs the turn again.
14
+ - Google Chat: the confirmation card has one button per choice; a typed token or a clicked choice that resumes runs the turn again, in the session's queue. The channel now needs channel-types 0.51.0 or later.
15
+ - HTTP: the `pending` event carries `choices`; a token sent to `POST /turn` that resumes streams the model's answer on the same request; `POST /confirm` answers `resume: true`, and `POST /turn { resume: true, conversationId }` runs that turn. The channel now needs channel-types 0.51.0 or later.
16
+
3
17
  ## 0.7.0
4
18
 
5
19
  ### Minor Changes
@@ -58,10 +58,12 @@ export type ParsedCardClickEvent = {
58
58
  sender: string;
59
59
  senderEmail: string | undefined;
60
60
  parameters: Record<string, string>;
61
+ /** As on a message (see `ParsedMessageEvent.isDirectMessage`): for the turn a click resumes. */
62
+ isDirectMessage: boolean;
61
63
  };
62
64
  /** Parses one decoded Pub/Sub event, or `null` if it isn't a kind this provider acts on. */
63
65
  export declare function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardClickEvent | null;
64
- export type CardClickHandler = (params: Record<string, string>, space: string, sender: string, senderEmail?: string) => Promise<void>;
66
+ export type CardClickHandler = (params: Record<string, string>, space: string, sender: string, senderEmail?: string, isDirectMessage?: boolean) => Promise<void>;
65
67
  export type GoogleChatProviderDeps = {
66
68
  credentials: ServiceAccountCredentials;
67
69
  subscription: string;
@@ -36,6 +36,7 @@ import {
36
36
  detectPendingConfirmation,
37
37
  loginLinkText,
38
38
  PENDING_CONFIRMATION_NOTE,
39
+ RESUME_AFTER_CONFIRM,
39
40
  NO_REPLY,
40
41
  type LoginRequest,
41
42
  type PendingConfirmation,
@@ -48,7 +49,8 @@ import {
48
49
 
49
50
  /**
50
51
  * Builds the confirmation card sent when a step stages an irreversible
51
- * command (see `pending-confirmation.ts`). The button's parameters carry
52
+ * command (see `pending-confirmation.ts`), one button per choice it offers
53
+ * (a single `Conferma` for a plain staging). Each button's parameters carry
52
54
  * the token, so a click routes straight into the same `tryConfirm` path
53
55
  * a bare token typed on the terminal uses (see `onCardClick`'s `confirm`
54
56
  * case, below) — the user never has to see or type the token themselves.
@@ -62,12 +64,10 @@ function buildConfirmCard(pending: PendingConfirmation): ChatCard {
62
64
  { textParagraph: { text: `\`${pending.summary}\`` } },
63
65
  {
64
66
  buttonList: {
65
- buttons: [
66
- {
67
- text: "Conferma",
68
- onClick: { action: { function: "confirm", parameters: [{ key: "token", value: pending.token }] } },
69
- },
70
- ],
67
+ buttons: pending.choices.map((choice) => ({
68
+ text: choice.label,
69
+ onClick: { action: { function: "confirm", parameters: [{ key: "token", value: choice.token }] } },
70
+ })),
71
71
  },
72
72
  },
73
73
  ],
@@ -196,7 +196,7 @@ type RawChatEvent = {
196
196
  sender?: RawChatSender;
197
197
  };
198
198
  action?: { actionMethodName?: string; parameters?: Array<{ key?: string; value?: string }> };
199
- space?: { name?: string };
199
+ space?: { name?: string; type?: string };
200
200
  user?: RawChatSender;
201
201
  };
202
202
 
@@ -225,6 +225,8 @@ export type ParsedCardClickEvent = {
225
225
  sender: string;
226
226
  senderEmail: string | undefined;
227
227
  parameters: Record<string, string>;
228
+ /** As on a message (see `ParsedMessageEvent.isDirectMessage`): for the turn a click resumes. */
229
+ isDirectMessage: boolean;
228
230
  };
229
231
 
230
232
  /** The email Google vouches for on `sender`: a person's only (`HUMAN`), never a bot's or an unknown kind's. */
@@ -260,12 +262,25 @@ export function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardCli
260
262
  parameters[p.key] = p.value;
261
263
  }
262
264
  }
263
- return { kind: "card-click", space: event.space.name, sender: event.user.name, senderEmail: emailOf(event.user), parameters };
265
+ return {
266
+ kind: "card-click",
267
+ space: event.space.name,
268
+ sender: event.user.name,
269
+ senderEmail: emailOf(event.user),
270
+ parameters,
271
+ isDirectMessage: event.space.type === "DM",
272
+ };
264
273
  }
265
274
  return null;
266
275
  }
267
276
 
268
- export type CardClickHandler = (params: Record<string, string>, space: string, sender: string, senderEmail?: string) => Promise<void>;
277
+ export type CardClickHandler = (
278
+ params: Record<string, string>,
279
+ space: string,
280
+ sender: string,
281
+ senderEmail?: string,
282
+ isDirectMessage?: boolean,
283
+ ) => Promise<void>;
269
284
 
270
285
  export type GoogleChatProviderDeps = {
271
286
  credentials: ServiceAccountCredentials;
@@ -319,7 +334,50 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
319
334
  // concurrently. Scoped by sessionKey only (not global), so a slow turn
320
335
  // for one user/space never blocks a different one's.
321
336
  const busySessions = new Set<string>();
322
- const queuedEvents = new Map<string, ParsedMessageEvent[]>();
337
+ const queuedTurns = new Map<string, Array<() => Promise<void>>>();
338
+ // The turn handler `start` was given, for a turn a card click resumes.
339
+ let activeHandleTurn: HandleTurn | undefined;
340
+
341
+ /** Runs `turn` for `sessionKey` now, or after the turns already queued for it. */
342
+ async function runInSession(sessionKey: string, turn: () => Promise<void>): Promise<void> {
343
+ if (busySessions.has(sessionKey)) {
344
+ const queue = queuedTurns.get(sessionKey) ?? [];
345
+ queue.push(turn);
346
+ queuedTurns.set(sessionKey, queue);
347
+ return;
348
+ }
349
+ busySessions.add(sessionKey);
350
+ try {
351
+ await turn();
352
+ } finally {
353
+ busySessions.delete(sessionKey);
354
+ const queue = queuedTurns.get(sessionKey);
355
+ const next = queue?.shift();
356
+ if (queue && queue.length === 0) queuedTurns.delete(sessionKey);
357
+ if (next) {
358
+ runInSession(sessionKey, next).catch((err) => log(`[chat] queued turn failed: ${String(err)}`));
359
+ }
360
+ }
361
+ }
362
+
363
+ /** Resumes the session's turn after a confirmation whose result went back to the model (`RESUME_AFTER_CONFIRM`). */
364
+ function resumeTurn(handleTurn: HandleTurn, space: string, principal: Principal, isDirectMessage: boolean): Promise<void> {
365
+ const sessionKey = deriveSessionKey(space, principal.id);
366
+ return runInSession(sessionKey, () =>
367
+ handleTurn(
368
+ {
369
+ channel: "google-chat",
370
+ multiUser: !isDirectMessage,
371
+ text: "",
372
+ sessionKey,
373
+ principal,
374
+ logPrefix: `[chat:${space}:${principal.id}] `,
375
+ resumeAfterConfirm: true,
376
+ },
377
+ createSink(space, { user: principal.id, isDirectMessage }),
378
+ ),
379
+ );
380
+ }
323
381
 
324
382
  /**
325
383
  * Default `onCardClick`: the confirm button's token routes through the exact
@@ -329,17 +387,22 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
329
387
  */
330
388
  const onCardClick: CardClickHandler =
331
389
  deps.onCardClick ??
332
- (async (params, space, sender, senderEmail) => {
390
+ (async (params, space, sender, senderEmail, isDirectMessage = false) => {
333
391
  const token = params.token;
334
392
  if (!token) {
335
393
  log(`[chat] card click with no token parameter`);
336
394
  return;
337
395
  }
338
- const reply = await deps.confirm(token, deriveSessionKey(space, sender), {
396
+ const principal: Principal = {
339
397
  id: sender,
340
398
  provider: "google-chat",
341
399
  ...(senderEmail === undefined ? {} : { claims: { email: senderEmail } }),
342
- });
400
+ };
401
+ const reply = await deps.confirm(token, deriveSessionKey(space, sender), principal);
402
+ if (reply === RESUME_AFTER_CONFIRM && activeHandleTurn) {
403
+ await resumeTurn(activeHandleTurn, space, principal, isDirectMessage);
404
+ return;
405
+ }
343
406
  if (reply !== null) {
344
407
  log(`[chat:${space}] [out] ${reply}`);
345
408
  const sent = await sendMessageFn(space, reply, clientDeps);
@@ -528,6 +591,10 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
528
591
  };
529
592
 
530
593
  const confirmReply = await deps.confirm(event.text, sessionKey, principal);
594
+ if (confirmReply === RESUME_AFTER_CONFIRM) {
595
+ await resumeTurn(handleTurn, event.space, principal, event.isDirectMessage);
596
+ return;
597
+ }
531
598
  if (confirmReply !== null) {
532
599
  log(`[chat:${event.space}] [out] ${confirmReply}`);
533
600
  const sent = await sendMessageFn(event.space, confirmReply, clientDeps);
@@ -538,17 +605,8 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
538
605
  // Only the actual model turn needs serializing — tryConfirm above
539
606
  // never touches SessionHistory, so it's always safe to run right
540
607
  // away regardless of whether this session is mid-turn.
541
- if (busySessions.has(sessionKey)) {
542
- const queue = queuedEvents.get(sessionKey) ?? [];
543
- queue.push(event);
544
- queuedEvents.set(sessionKey, queue);
545
- return;
546
- }
547
-
548
- busySessions.add(sessionKey);
549
- try {
550
- const sink = createSink(event.space, { user: event.sender, isDirectMessage: event.isDirectMessage });
551
- await handleTurn(
608
+ await runInSession(sessionKey, () =>
609
+ handleTurn(
552
610
  {
553
611
  channel: "google-chat",
554
612
  multiUser: !event.isDirectMessage,
@@ -557,17 +615,9 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
557
615
  principal,
558
616
  logPrefix: `[chat:${event.space}:${event.sender}] `,
559
617
  },
560
- sink,
561
- );
562
- } finally {
563
- busySessions.delete(sessionKey);
564
- const queue = queuedEvents.get(sessionKey);
565
- const next = queue?.shift();
566
- if (queue && queue.length === 0) queuedEvents.delete(sessionKey);
567
- if (next) {
568
- processMessageEvent(next, handleTurn).catch((err) => log(`[chat] queued event handling failed: ${String(err)}`));
569
- }
570
- }
618
+ createSink(event.space, { user: event.sender, isDirectMessage: event.isDirectMessage }),
619
+ ),
620
+ );
571
621
  }
572
622
 
573
623
  /**
@@ -598,7 +648,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
598
648
  if (parsed.kind === "message") {
599
649
  await processMessageEvent(parsed, handleTurn);
600
650
  } else {
601
- await onCardClick(parsed.parameters, parsed.space, parsed.sender, parsed.senderEmail);
651
+ await onCardClick(parsed.parameters, parsed.space, parsed.sender, parsed.senderEmail, parsed.isDirectMessage);
602
652
  }
603
653
  } catch (err) {
604
654
  log(`[chat] event handling failed: ${String(err)}`);
@@ -607,6 +657,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
607
657
 
608
658
  return {
609
659
  async start(handleTurn: HandleTurn): Promise<void> {
660
+ activeHandleTurn = handleTurn;
610
661
  const sub = subscriptionFn(deps.credentials, deps.subscription);
611
662
  activeSubscription = sub;
612
663
  sub.on("message", (message) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/channel-google-chat",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,10 +31,10 @@
31
31
  "@google-cloud/pubsub": "^5.3.1"
32
32
  },
33
33
  "peerDependencies": {
34
- "@mercury-fw/channel-types": ">=0.46.0 <1.0.0"
34
+ "@mercury-fw/channel-types": ">=0.51.0 <1.0.0"
35
35
  },
36
36
  "devDependencies": {
37
- "@mercury-fw/channel-types": "0.46.0",
37
+ "@mercury-fw/channel-types": "0.51.0",
38
38
  "@mercury-fw/typescript-config": "*",
39
39
  "@types/bun": "^1.4.2",
40
40
  "typescript": "^6.0.3"