@vinaverse/client 0.1.0-alpha.1 → 0.1.0-alpha.3

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.
@@ -102,13 +102,17 @@ interface SystemItem {
102
102
  readonly event: SystemEvent;
103
103
  /**
104
104
  * `takeover_started`: `by`; `handoff_created`: `expected_response`; `flow_completed`: `result`, `code`;
105
- * `turn_failed` (a turn that failed before any of its reply streamed): `code`, `message`, `retryable`.
105
+ * `turn_failed` (a turn that failed before any of its reply streamed): `code`, `message`, `retryable`, `turn` (its `turn_id`), and `retry`:
106
+ * the server allows a retry and the person's text that started the turn is known, so `retryTurn(key)` sends it
107
+ * again (false once it did).
106
108
  */
107
109
  readonly data: Readonly<Record<string, string | boolean | null>>;
108
110
  }
109
111
  type TimelineItem = MessageItem | CardItem | SystemItem;
110
112
  interface ConversationView {
111
113
  readonly status: ConversationStatus;
114
+ /** The conversation the sessions belong to, once a session named it; for the tenant's own records and support. */
115
+ readonly conversationId: string | null;
112
116
  readonly banner: ConversationBanner;
113
117
  /**
114
118
  * How the conversation goes on after the session ended: `continue` on the person's next action (it resumes on its
@@ -219,6 +223,12 @@ interface ConversationOptions {
219
223
  readonly createId?: () => string;
220
224
  /** Something failed that no call returned: getting a session, an input that could not be delivered. */
221
225
  readonly onError?: (error: unknown) => void;
226
+ /**
227
+ * Close the connection while the app is in the background, and open it again in the foreground (a backend that
228
+ * mints a client secret per connection: with `refreshSecret`, a fresh one is asked for first). Default false: the
229
+ * connection stays, with no pings, until the session idles out. Nothing typed or answered is lost either way.
230
+ */
231
+ readonly closeInBackground?: boolean;
222
232
  }
223
233
  /** A text is at most 2000 code points (`TurnInput`). */
224
234
  declare const MAX_TEXT = 2000;
@@ -243,12 +253,18 @@ declare class ConversationStore {
243
253
  /** Proposals whose tenant decision threw: shown as recoverable, the person may decide again. */
244
254
  private readonly decisionErrors;
245
255
  private system;
256
+ /** The person's text that started each recent turn, by `turn_id` (`turn.started` echoes its `client_event_id`). */
257
+ private readonly turnInputs;
246
258
  /** Numbers system rows that carry no id of their own. */
247
259
  private systemSeq;
248
260
  private history;
249
261
  private historyState;
250
262
  /** A resumed session.ready carried a transcript: the server's history replaced what the device kept. */
251
263
  private transcriptSeen;
264
+ /** The session of the last session.ready: a card of another one takes no tap (§6). */
265
+ private readySession;
266
+ private cardOrder;
267
+ private kept;
252
268
  private locale;
253
269
  /** The request for a session in flight, to abort when it is no longer wanted. */
254
270
  private fetching;
@@ -284,6 +300,11 @@ declare class ConversationStore {
284
300
  setLocale(locale: string | undefined): void;
285
301
  /** The app came to the foreground or went to the background (AppState, `visibilitychange`). */
286
302
  setForeground(foreground: boolean): void;
303
+ /** In the background (`closeInBackground`): the connection is closed; the engine keeps what was sent, to resend. */
304
+ private suspended;
305
+ private suspend;
306
+ /** Back in the foreground: a new connection to the same session, with a fresh secret when the backend gives one. */
307
+ private wake;
287
308
  /** The network as the app sees it; null when unknown. Back online reconnects a connection that gave up. */
288
309
  setOnline(online: boolean | null): void;
289
310
  /** Send a typed message; returns its `client_event_id`. Typed before a session exists, it is sent once one does. */
@@ -306,6 +327,12 @@ declare class ConversationStore {
306
327
  decideProposal(cardId: string, decide: () => Promise<ProposalOutcome>): Promise<ProposalOutcome | null>;
307
328
  /** Send a "not sent" message again, as a new message (the person's action). Returns the new id. */
308
329
  retry(key: string): string | null;
330
+ /**
331
+ * Send again the person's text whose turn failed before any reply (a `turn_failed` row with `data.retry`), as a new
332
+ * message (the person's action). Once per row: its `retry` turns false. Returns the new id, or null when the row
333
+ * allows no retry.
334
+ */
335
+ retryTurn(key: string): string | null;
309
336
  /** After the conversation continued on another device: continue it here (the person's choice). */
310
337
  continueHere(): Promise<void>;
311
338
  /** Stop the turn in progress. */
@@ -323,6 +350,15 @@ declare class ConversationStore {
323
350
  private reconnectIfDown;
324
351
  private statusChanged;
325
352
  private event;
353
+ /** The person's text that started a turn, while its message is on screen. */
354
+ private turnText;
355
+ /**
356
+ * A resumed session's transcript replaced the messages, and a chat transcript carries text only: the engine drops
357
+ * the cards it drew, and draws the pending ones again at the end. Each card on screen is anchored just after the
358
+ * message it followed (by key; the transcript keeps the live keys), so a pending proposal stays where it was, and a
359
+ * card the transcript dropped stays drawn there; taking no tap when it belongs to an earlier session.
360
+ */
361
+ private anchorCards;
326
362
  /**
327
363
  * Recomputes now (getSnapshot and getStreaming are always current) and notifies from the scheduler, at most once
328
364
  * per frame, both channels in the same flush: a streamed reply that completes becomes its message in one frame.
@@ -102,13 +102,17 @@ interface SystemItem {
102
102
  readonly event: SystemEvent;
103
103
  /**
104
104
  * `takeover_started`: `by`; `handoff_created`: `expected_response`; `flow_completed`: `result`, `code`;
105
- * `turn_failed` (a turn that failed before any of its reply streamed): `code`, `message`, `retryable`.
105
+ * `turn_failed` (a turn that failed before any of its reply streamed): `code`, `message`, `retryable`, `turn` (its `turn_id`), and `retry`:
106
+ * the server allows a retry and the person's text that started the turn is known, so `retryTurn(key)` sends it
107
+ * again (false once it did).
106
108
  */
107
109
  readonly data: Readonly<Record<string, string | boolean | null>>;
108
110
  }
109
111
  type TimelineItem = MessageItem | CardItem | SystemItem;
110
112
  interface ConversationView {
111
113
  readonly status: ConversationStatus;
114
+ /** The conversation the sessions belong to, once a session named it; for the tenant's own records and support. */
115
+ readonly conversationId: string | null;
112
116
  readonly banner: ConversationBanner;
113
117
  /**
114
118
  * How the conversation goes on after the session ended: `continue` on the person's next action (it resumes on its
@@ -219,6 +223,12 @@ interface ConversationOptions {
219
223
  readonly createId?: () => string;
220
224
  /** Something failed that no call returned: getting a session, an input that could not be delivered. */
221
225
  readonly onError?: (error: unknown) => void;
226
+ /**
227
+ * Close the connection while the app is in the background, and open it again in the foreground (a backend that
228
+ * mints a client secret per connection: with `refreshSecret`, a fresh one is asked for first). Default false: the
229
+ * connection stays, with no pings, until the session idles out. Nothing typed or answered is lost either way.
230
+ */
231
+ readonly closeInBackground?: boolean;
222
232
  }
223
233
  /** A text is at most 2000 code points (`TurnInput`). */
224
234
  declare const MAX_TEXT = 2000;
@@ -243,12 +253,18 @@ declare class ConversationStore {
243
253
  /** Proposals whose tenant decision threw: shown as recoverable, the person may decide again. */
244
254
  private readonly decisionErrors;
245
255
  private system;
256
+ /** The person's text that started each recent turn, by `turn_id` (`turn.started` echoes its `client_event_id`). */
257
+ private readonly turnInputs;
246
258
  /** Numbers system rows that carry no id of their own. */
247
259
  private systemSeq;
248
260
  private history;
249
261
  private historyState;
250
262
  /** A resumed session.ready carried a transcript: the server's history replaced what the device kept. */
251
263
  private transcriptSeen;
264
+ /** The session of the last session.ready: a card of another one takes no tap (§6). */
265
+ private readySession;
266
+ private cardOrder;
267
+ private kept;
252
268
  private locale;
253
269
  /** The request for a session in flight, to abort when it is no longer wanted. */
254
270
  private fetching;
@@ -284,6 +300,11 @@ declare class ConversationStore {
284
300
  setLocale(locale: string | undefined): void;
285
301
  /** The app came to the foreground or went to the background (AppState, `visibilitychange`). */
286
302
  setForeground(foreground: boolean): void;
303
+ /** In the background (`closeInBackground`): the connection is closed; the engine keeps what was sent, to resend. */
304
+ private suspended;
305
+ private suspend;
306
+ /** Back in the foreground: a new connection to the same session, with a fresh secret when the backend gives one. */
307
+ private wake;
287
308
  /** The network as the app sees it; null when unknown. Back online reconnects a connection that gave up. */
288
309
  setOnline(online: boolean | null): void;
289
310
  /** Send a typed message; returns its `client_event_id`. Typed before a session exists, it is sent once one does. */
@@ -306,6 +327,12 @@ declare class ConversationStore {
306
327
  decideProposal(cardId: string, decide: () => Promise<ProposalOutcome>): Promise<ProposalOutcome | null>;
307
328
  /** Send a "not sent" message again, as a new message (the person's action). Returns the new id. */
308
329
  retry(key: string): string | null;
330
+ /**
331
+ * Send again the person's text whose turn failed before any reply (a `turn_failed` row with `data.retry`), as a new
332
+ * message (the person's action). Once per row: its `retry` turns false. Returns the new id, or null when the row
333
+ * allows no retry.
334
+ */
335
+ retryTurn(key: string): string | null;
309
336
  /** After the conversation continued on another device: continue it here (the person's choice). */
310
337
  continueHere(): Promise<void>;
311
338
  /** Stop the turn in progress. */
@@ -323,6 +350,15 @@ declare class ConversationStore {
323
350
  private reconnectIfDown;
324
351
  private statusChanged;
325
352
  private event;
353
+ /** The person's text that started a turn, while its message is on screen. */
354
+ private turnText;
355
+ /**
356
+ * A resumed session's transcript replaced the messages, and a chat transcript carries text only: the engine drops
357
+ * the cards it drew, and draws the pending ones again at the end. Each card on screen is anchored just after the
358
+ * message it followed (by key; the transcript keeps the live keys), so a pending proposal stays where it was, and a
359
+ * card the transcript dropped stays drawn there; taking no tap when it belongs to an earlier session.
360
+ */
361
+ private anchorCards;
326
362
  /**
327
363
  * Recomputes now (getSnapshot and getStreaming are always current) and notifies from the scheduler, at most once
328
364
  * per frame, both channels in the same flush: a streamed reply that completes becomes its message in one frame.
@@ -156,10 +156,13 @@ function buildTimeline(input, previous) {
156
156
  busy: input.busy.has(card.id),
157
157
  ...input.decisionErrors?.has(card.id) ? { decisionError: true } : {}
158
158
  },
159
- order: card.seq
159
+ order: input.cardOrder?.get(card.id) ?? card.seq
160
160
  });
161
161
  }
162
162
  for (const { item, order } of input.system) live.push({ item, order });
163
+ const held = new Set(state.cards.map((c) => c.id));
164
+ for (const { item, order } of input.kept ?? [])
165
+ if (!held.has(item.key)) live.push({ item, order });
163
166
  for (const [id, sent] of input.sent) {
164
167
  const pending = pendingState(id, input);
165
168
  if (pending === null) continue;
@@ -233,6 +236,7 @@ function persistable(item) {
233
236
 
234
237
  // src/conversation/store.ts
235
238
  var MAX_TEXT = 2e3;
239
+ var TURN_INPUTS = 50;
236
240
  var PERSIST_DELAY = 500;
237
241
  var PERSIST_VERSION = 1;
238
242
  var NO_ITEMS = [];
@@ -263,12 +267,18 @@ var ConversationStore = class {
263
267
  /** Proposals whose tenant decision threw: shown as recoverable, the person may decide again. */
264
268
  decisionErrors = /* @__PURE__ */ new Set();
265
269
  system = [];
270
+ /** The person's text that started each recent turn, by `turn_id` (`turn.started` echoes its `client_event_id`). */
271
+ turnInputs = /* @__PURE__ */ new Map();
266
272
  /** Numbers system rows that carry no id of their own. */
267
273
  systemSeq = 0;
268
274
  history = NO_ITEMS;
269
275
  historyState = "none";
270
276
  /** A resumed session.ready carried a transcript: the server's history replaced what the device kept. */
271
277
  transcriptSeen = false;
278
+ /** The session of the last session.ready: a card of another one takes no tap (§6). */
279
+ readySession = null;
280
+ cardOrder = /* @__PURE__ */ new Map();
281
+ kept = [];
272
282
  locale;
273
283
  /** The request for a session in flight, to abort when it is no longer wanted. */
274
284
  fetching = null;
@@ -350,9 +360,49 @@ var ConversationStore = class {
350
360
  /** The app came to the foreground or went to the background (AppState, `visibilitychange`). */
351
361
  setForeground(foreground) {
352
362
  this.foreground = foreground;
363
+ if (this.options.closeInBackground && !this.disposed) {
364
+ if (!foreground) {
365
+ this.suspend();
366
+ return;
367
+ }
368
+ if (this.suspended) {
369
+ void this.wake();
370
+ return;
371
+ }
372
+ }
353
373
  this.session?.setForeground(foreground);
354
374
  if (foreground) this.reconnectIfDown();
355
375
  }
376
+ /** In the background (`closeInBackground`): the connection is closed; the engine keeps what was sent, to resend. */
377
+ suspended = false;
378
+ suspend() {
379
+ const session = this.session;
380
+ if (!session || this.suspended || session.status === "ended") return;
381
+ this.suspended = true;
382
+ for (const off of this.unsubscribe) off();
383
+ this.unsubscribe = [];
384
+ session.close();
385
+ this.update();
386
+ }
387
+ /** Back in the foreground: a new connection to the same session, with a fresh secret when the backend gives one. */
388
+ async wake() {
389
+ const info = this.info;
390
+ this.suspended = false;
391
+ if (!info) return;
392
+ let next = info;
393
+ if (this.options.refreshSecret) {
394
+ try {
395
+ next = {
396
+ ...info,
397
+ client_secret: await this.options.refreshSecret(info.id)
398
+ };
399
+ } catch (error) {
400
+ this.options.onError?.(error);
401
+ }
402
+ }
403
+ if (this.disposed || this.suspended) return;
404
+ this.open(next);
405
+ }
356
406
  /** The network as the app sees it; null when unknown. Back online reconnects a connection that gave up. */
357
407
  setOnline(online) {
358
408
  this.online = online;
@@ -374,6 +424,12 @@ var ConversationStore = class {
374
424
  this.update();
375
425
  return id;
376
426
  }
427
+ if (this.suspended) {
428
+ this.early.set(id, text);
429
+ void this.wake();
430
+ this.update();
431
+ return id;
432
+ }
377
433
  if (this.nextFrom(this.engine.state()) === "continue_here") {
378
434
  this.early.set(id, text);
379
435
  void this.resumeWith("continue_here");
@@ -442,6 +498,24 @@ var ConversationStore = class {
442
498
  this.sent.delete(key);
443
499
  return this.send(sent.text);
444
500
  }
501
+ /**
502
+ * Send again the person's text whose turn failed before any reply (a `turn_failed` row with `data.retry`), as a new
503
+ * message (the person's action). Once per row: its `retry` turns false. Returns the new id, or null when the row
504
+ * allows no retry.
505
+ */
506
+ retryTurn(key) {
507
+ const at = this.system.findIndex(
508
+ (s) => s.item.key === key && s.item.event === "turn_failed"
509
+ );
510
+ const entry = this.system[at];
511
+ if (entry?.item.data.retry !== true) return null;
512
+ const text = this.turnText(String(entry.item.data.turn ?? ""));
513
+ if (text === void 0) return null;
514
+ this.system = this.system.map(
515
+ (s, i) => i === at ? { ...s, item: { ...s.item, data: { ...s.item.data, retry: false } } } : s
516
+ );
517
+ return this.send(text);
518
+ }
445
519
  /** After the conversation continued on another device: continue it here (the person's choice). */
446
520
  continueHere() {
447
521
  return this.resumeWith("continue_here");
@@ -575,10 +649,16 @@ var ConversationStore = class {
575
649
  ];
576
650
  };
577
651
  switch (frame.type) {
578
- case "session.ready":
579
- if (frame.resumed === true && Array.isArray(frame.transcript))
652
+ case "session.ready": {
653
+ const session = frame.session?.id;
654
+ const moved = typeof session === "string" && session !== this.readySession;
655
+ if (typeof session === "string") this.readySession = session;
656
+ if (frame.resumed === true && Array.isArray(frame.transcript)) {
580
657
  this.transcriptSeen = true;
658
+ this.anchorCards(moved);
659
+ }
581
660
  return;
661
+ }
582
662
  // `at` is required (v1-draft.15); the same takeover announced again after a reconnect has the same `at`, so it
583
663
  // draws one row. A frame without one still gets a row of its own.
584
664
  case "takeover.started":
@@ -608,6 +688,17 @@ var ConversationStore = class {
608
688
  code: str(frame.code)
609
689
  });
610
690
  return;
691
+ case "turn.started": {
692
+ const turn = str(frame.turn_id);
693
+ const input = str(frame.client_event_id);
694
+ if (turn === null || input === null) return;
695
+ this.turnInputs.set(turn, input);
696
+ if (this.turnInputs.size > TURN_INPUTS) {
697
+ const oldest = this.turnInputs.keys().next().value;
698
+ if (oldest !== void 0) this.turnInputs.delete(oldest);
699
+ }
700
+ return;
701
+ }
611
702
  case "turn.failed": {
612
703
  const turn = str(frame.turn_id) ?? "";
613
704
  if (this.engine.state().messages.some((m) => m.key === `a:${turn}`))
@@ -616,7 +707,9 @@ var ConversationStore = class {
616
707
  add(`failed:${turn}`, "turn_failed", {
617
708
  code: str(frame.code),
618
709
  message: str(frame.message),
619
- retryable: frame.retryable === true
710
+ retryable: frame.retryable === true,
711
+ retry: frame.retryable === true && this.turnText(turn) !== void 0,
712
+ turn
620
713
  });
621
714
  return;
622
715
  }
@@ -624,6 +717,45 @@ var ConversationStore = class {
624
717
  return;
625
718
  }
626
719
  }
720
+ /** The person's text that started a turn, while its message is on screen. */
721
+ turnText(turn) {
722
+ const input = this.turnInputs.get(turn);
723
+ if (input === void 0) return void 0;
724
+ const message = this.engine.state().messages.find((m) => m.key === input && m.role === "person");
725
+ return message?.text || void 0;
726
+ }
727
+ /**
728
+ * A resumed session's transcript replaced the messages, and a chat transcript carries text only: the engine drops
729
+ * the cards it drew, and draws the pending ones again at the end. Each card on screen is anchored just after the
730
+ * message it followed (by key; the transcript keeps the live keys), so a pending proposal stays where it was, and a
731
+ * card the transcript dropped stays drawn there; taking no tap when it belongs to an earlier session.
732
+ */
733
+ anchorCards(sessionChanged) {
734
+ const state = this.engine.state();
735
+ const seqOf = new Map(state.messages.map((m) => [m.key, m.seq]));
736
+ const first = Math.min(...state.messages.map((m) => m.seq), 0);
737
+ const held = new Set(state.cards.map((c) => c.id));
738
+ const order = /* @__PURE__ */ new Map();
739
+ const kept = [];
740
+ let anchor = first - 1;
741
+ let step = 0;
742
+ for (const item of this.view.items) {
743
+ if (item.type === "message" && seqOf.has(item.key)) {
744
+ anchor = seqOf.get(item.key);
745
+ step = 0;
746
+ } else if (item.type === "card") {
747
+ const at = anchor + 0.5 + ++step / 1e3;
748
+ order.set(item.key, at);
749
+ if (!held.has(item.key))
750
+ kept.push({
751
+ item: sessionChanged ? { ...item, busy: false, restored: true } : item,
752
+ order: at
753
+ });
754
+ }
755
+ }
756
+ this.cardOrder = order;
757
+ this.kept = kept;
758
+ }
627
759
  // ------------------------------------------------------------ the view
628
760
  /**
629
761
  * Recomputes now (getSnapshot and getStreaming are always current) and notifies from the scheduler, at most once
@@ -680,6 +812,8 @@ var ConversationStore = class {
680
812
  busy: this.busy,
681
813
  decisionErrors: this.decisionErrors,
682
814
  transcriptSeen: this.transcriptSeen,
815
+ cardOrder: this.cardOrder,
816
+ kept: this.kept,
683
817
  system: this.system
684
818
  },
685
819
  prev?.items ?? NO_ITEMS
@@ -691,6 +825,7 @@ var ConversationStore = class {
691
825
  const avatarUrl = this.info?.agent?.avatar_url ?? null;
692
826
  const view = {
693
827
  status,
828
+ conversationId: s.conversationId,
694
829
  banner: banner(status, next),
695
830
  next,
696
831
  agent: sameAgent(prev?.agent, name, avatarUrl) ? prev.agent : { name, avatarUrl },
@@ -709,6 +844,7 @@ var ConversationStore = class {
709
844
  }
710
845
  status() {
711
846
  if (this.disposed) return "closed";
847
+ if (this.suspended) return "idle";
712
848
  const session = this.session;
713
849
  if (!session) {
714
850
  if (this.failed) return "unavailable";