@wix/himalaya-cli 0.971.0 → 0.972.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.
@@ -34,11 +34,23 @@ export declare function isGenieChatError(e: unknown): e is GenieChatError;
34
34
  declare function wixCall<T>(method: string, path: string, body?: unknown): Promise<T>;
35
35
  export type MessageRole = "UNKNOWN" | "USER" | "ASSISTANT";
36
36
  export type CommunicationMode = "UNKNOWN" | "POLLING" | "REAL_TIME";
37
+ /** Where the service is with one message. `STATUS_PENDING` on an ASSISTANT row
38
+ * means the agent is still writing it: whatever `content` holds so far is a
39
+ * draft, not the answer. Open-ended because the wire may grow values.
40
+ *
41
+ * Taken from the service's published message entity (the generated
42
+ * `genie-chat-service-v1` client in `wix-private/aria-native-apps`), where the
43
+ * native Aria app ends a turn ONLY on a polled `STATUS_COMPLETED`, and resumes a
44
+ * conversation whose newest assistant row is still pending. Not observed on
45
+ * this repo's own captures, which is why it is optional and every consumer
46
+ * must treat an absent status as "settled". */
47
+ export type MessageStatus = "STATUS_UNSPECIFIED" | "STATUS_PENDING" | "STATUS_COMPLETED" | (string & {});
37
48
  export interface ConversationMessage {
38
49
  id?: string;
39
50
  content?: string;
40
51
  role?: MessageRole;
41
52
  createdDate?: string;
53
+ status?: MessageStatus;
42
54
  }
43
55
  /** Which assistant the conversation talks to. The real dashboard sends a
44
56
  * `unifiedAssistantReference`; helper `ariaAssistantConfig()` builds it. */
package/dist/cli.mjs CHANGED
@@ -5290,7 +5290,6 @@ function resolveTranslation(value, catalog, sourceCatalog) {
5290
5290
  var BINDING_SIGILS, BINDING_SIGIL_RE, TRANSLATION_PREFIX, TRANSLATION_KEY_RE;
5291
5291
  var init_bindings = __esm({
5292
5292
  "../../core/ts/src/bindings.ts"() {
5293
- "use strict";
5294
5293
  BINDING_SIGILS = ["service", "flag", "item", "t"];
5295
5294
  BINDING_SIGIL_RE = new RegExp(
5296
5295
  `^\\$(?:${BINDING_SIGILS.filter((s) => s !== "t").join("|")})\\b|^\\$t:`
@@ -16696,11 +16696,17 @@
16696
16696
  "jsdoc": "",
16697
16697
  "decl": "export type CommunicationMode = \"UNKNOWN\" | \"POLLING\" | \"REAL_TIME\";"
16698
16698
  },
16699
+ {
16700
+ "name": "MessageStatus",
16701
+ "kind": "type",
16702
+ "jsdoc": "/** Where the service is with one message. `STATUS_PENDING` on an ASSISTANT row\n * means the agent is still writing it: whatever `content` holds so far is a\n * draft, not the answer. Open-ended because the wire may grow values.\n *\n * Taken from the service's published message entity (the generated\n * `genie-chat-service-v1` client in `wix-private/aria-native-apps`), where the\n * native Aria app ends a turn ONLY on a polled `STATUS_COMPLETED`, and resumes a\n * conversation whose newest assistant row is still pending. Not observed on\n * this repo's own captures, which is why it is optional and every consumer\n * must treat an absent status as \"settled\". */",
16703
+ "decl": "export type MessageStatus = \"STATUS_UNSPECIFIED\" | \"STATUS_PENDING\" | \"STATUS_COMPLETED\" | (string & {});"
16704
+ },
16699
16705
  {
16700
16706
  "name": "ConversationMessage",
16701
16707
  "kind": "interface",
16702
16708
  "jsdoc": "",
16703
- "decl": "export interface ConversationMessage {\n id?: string;\n content?: string;\n role?: MessageRole;\n createdDate?: string;\n}"
16709
+ "decl": "export interface ConversationMessage {\n id?: string;\n content?: string;\n role?: MessageRole;\n createdDate?: string;\n status?: MessageStatus;\n}"
16704
16710
  },
16705
16711
  {
16706
16712
  "name": "AssistantConfig",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.971.0",
3
+ "version": "0.972.0",
4
4
  "type": "module",
5
5
  "description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
6
6
  "keywords": [
@@ -64,5 +64,5 @@
64
64
  "artifactId": "himalaya-cli"
65
65
  }
66
66
  },
67
- "falconPackageHash": "dc35d80213a50f83892eb491d6ec9afd8cf09def367013281612b0aa"
67
+ "falconPackageHash": "9d8d2a86205140b8694ab07b45d09c35b40bfb375a3baf1f123ffacf"
68
68
  }
@@ -585,7 +585,10 @@ export function toBubbles(msgs: ConversationMessage[], assistantName: string, yo
585
585
  // way, and an aborted turn consists of nothing else. Then envelopes, then
586
586
  // the markdown stripper — each stage handing on only what a reader should
587
587
  // actually see.
588
- const prose = role === "USER" ? raw : proseOfParts(scanEnvelopes(stripHidden(raw), 0, true).parts);
588
+ // A row the service is still writing is scanned as STREAMING: an
589
+ // envelope whose `>>` has not arrived yet is held back rather than
590
+ // painted as raw JSON until the row finishes.
591
+ const prose = role === "USER" ? raw : proseOfParts(scanEnvelopes(stripHidden(raw), 0, !stillWriting(m)).parts);
589
592
  return {
590
593
  id: m.id || `m${i}`,
591
594
  role,
@@ -689,6 +692,29 @@ function answerText(m: ConversationMessage): string {
689
692
  return stripHidden(messageText(m)).trim();
690
693
  }
691
694
 
695
+ /** Is the service still WRITING this assistant row?
696
+ *
697
+ * The chat-service stamps every message with a status, and an ASSISTANT row
698
+ * that is `STATUS_PENDING` holds a draft: the agent has started the reply and
699
+ * not finished it. Whatever text it carries is worth PAINTING — it is the only
700
+ * view of a reply in progress a worker has, since the words themselves stream
701
+ * over a socket no host exposes (see the genie-chat client's header) — but it
702
+ * is not the answer, and ending the turn on it is the bug this exists for: the
703
+ * typing line clears, Stop disappears while the agent is still writing, and the
704
+ * ladder drops to its idle rung, so the rest of the reply sits unseen for a
705
+ * whole idle interval. The native Aria app ends a turn only on
706
+ * `STATUS_COMPLETED`, for the same reason.
707
+ *
708
+ * Only an explicit PENDING counts. An absent or unrecognised status is
709
+ * "settled": no capture in this repo has ever shown the field, and a gate that
710
+ * demanded `STATUS_COMPLETED` would hang every turn on a service that simply
711
+ * does not send it. Callers apply it to ASSISTANT rows only: a USER row is
712
+ * counted to retire the optimistic bubble, and holding one back would paint
713
+ * the owner's words twice. */
714
+ function stillWriting(m: ConversationMessage): boolean {
715
+ return m.status === "STATUS_PENDING";
716
+ }
717
+
692
718
  export function repliedTo(msgs: ConversationMessage[], turnId: string): boolean | null {
693
719
  if (!turnId) return null;
694
720
  const at = msgs.findIndex((m) => m.id === turnId);
@@ -701,7 +727,9 @@ export function repliedTo(msgs: ConversationMessage[], turnId: string): boolean
701
727
  for (let i = at + 1; i < msgs.length; i++) {
702
728
  const m = msgs[i];
703
729
  if ((m.role || "ASSISTANT") !== "ASSISTANT") continue;
704
- if (answerText(m)) return true;
730
+ // A row still being written is not the answer yet — keep looking, since
731
+ // the finished answer can land in a later row of the same turn.
732
+ if (answerText(m) && !stillWriting(m)) return true;
705
733
  }
706
734
  return false;
707
735
  }
@@ -736,6 +764,26 @@ export function countTurns(msgs: ConversationMessage[], role: string): number {
736
764
  return n;
737
765
  }
738
766
 
767
+ /** How many FINISHED assistant answers the transcript holds — `countTurns`
768
+ * minus the rows the service is still writing (`stillWriting`).
769
+ *
770
+ * The counted path compares THIS against a mark taken with `countTurns`, and
771
+ * the asymmetry is the point. The mark includes drafts, so a follow-up sent
772
+ * while an earlier reply is still being written is marked AFTER that reply;
773
+ * the earlier reply finishing then leaves this count equal to the mark rather
774
+ * than past it, and cannot end the new turn. Were the mark taken without
775
+ * drafts too, that finish would read as the new turn's answer. And a draft
776
+ * of the turn's OWN answer is not counted here, so it does not end the turn
777
+ * either — the same rule `repliedTo` applies on the exact path. */
778
+ export function countSettledAnswers(msgs: ConversationMessage[]): number {
779
+ let n = 0;
780
+ for (const m of msgs) {
781
+ if ((m.role || "ASSISTANT") !== "ASSISTANT") continue;
782
+ if (answerText(m) && !stillWriting(m)) n++;
783
+ }
784
+ return n;
785
+ }
786
+
739
787
  /** Has the turn stored under `userMessageId` already been answered?
740
788
  *
741
789
  * Per-TURN, and that is the entire point. A COUNT of assistant replies says
@@ -785,6 +833,12 @@ export function turnAnswered(msgs: ConversationMessage[], userMessageId: string)
785
833
  // exactly these two, and the two scanners read the same transcript: if they
786
834
  // disagree, one of them ends the turn while the other leaves the ladder
787
835
  // running at the fast cadence.
836
+ //
837
+ // They DO part on one case, deliberately: a row the service is still
838
+ // writing (`stillWriting`). That does not end a turn, but it IS proof the
839
+ // trigger landed — the agent is answering — so re-triggering the stored id
840
+ // here would have it answer the same turn twice. The ladder never reads
841
+ // this scanner, so the split cannot pin it fast.
788
842
  if (role === "ASSISTANT" && answerText(m)) return true;
789
843
  }
790
844
  return false;
@@ -1155,7 +1209,10 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1155
1209
  // both; the widget path did neither.
1156
1210
  widget: carrySelection(
1157
1211
  activeWidgetOf(
1158
- msgs.map((m) => ({ role: m.role, content: stripHidden(messageText(m)) })),
1212
+ // A draft offers no controls: the agent has not finished asking, and
1213
+ // an answer tapped now would start a second turn on top of this one.
1214
+ // An empty newest row is NO_WIDGET — the placeholder rule.
1215
+ msgs.map((m) => ({ role: m.role, content: stillWriting(m) ? "" : stripHidden(messageText(m)) })),
1159
1216
  disclosedLabel,
1160
1217
  o.subtree,
1161
1218
  (url) => isWixMediaHost(hostOf(url)),
@@ -1220,7 +1277,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1220
1277
  // widget-only reply is, even though it leaves no bubble to inspect. Asking
1221
1278
  // the last BUBBLE stalled this loop on exactly that reply, the same way it
1222
1279
  // stalled `tick`.
1223
- if (repliedTo(raw, s._turnId) ?? countTurns(raw, "ASSISTANT") > s._replyAfter) {
1280
+ if (repliedTo(raw, s._turnId) ?? countSettledAnswers(raw) > s._replyAfter) {
1224
1281
  return { ...s, status: "ready", _convId: convId, messages: bubbles, prompts: [], input: "", statusText: "", error: "", _pending: "", ...observed(raw, s.widget), ok: false };
1225
1282
  }
1226
1283
  }
@@ -1804,7 +1861,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1804
1861
  // Counted over RAW messages, so a widget-only answer still ends the turn
1805
1862
  // instead of pinning the ladder to its fast rung forever (`countTurns`).
1806
1863
  // Exact when the service named the turn, counted when it did not.
1807
- const replied = repliedTo(raw, s._turnId) ?? countTurns(raw, "ASSISTANT") > s._replyAfter;
1864
+ const replied = repliedTo(raw, s._turnId) ?? countSettledAnswers(raw) > s._replyAfter;
1808
1865
  const next: AriaChatState = {
1809
1866
  ...s,
1810
1867
  status: replied ? "ready" : s.status,
@@ -585,7 +585,10 @@ export function toBubbles(msgs: ConversationMessage[], assistantName: string, yo
585
585
  // way, and an aborted turn consists of nothing else. Then envelopes, then
586
586
  // the markdown stripper — each stage handing on only what a reader should
587
587
  // actually see.
588
- const prose = role === "USER" ? raw : proseOfParts(scanEnvelopes(stripHidden(raw), 0, true).parts);
588
+ // A row the service is still writing is scanned as STREAMING: an
589
+ // envelope whose `>>` has not arrived yet is held back rather than
590
+ // painted as raw JSON until the row finishes.
591
+ const prose = role === "USER" ? raw : proseOfParts(scanEnvelopes(stripHidden(raw), 0, !stillWriting(m)).parts);
589
592
  return {
590
593
  id: m.id || `m${i}`,
591
594
  role,
@@ -689,6 +692,29 @@ function answerText(m: ConversationMessage): string {
689
692
  return stripHidden(messageText(m)).trim();
690
693
  }
691
694
 
695
+ /** Is the service still WRITING this assistant row?
696
+ *
697
+ * The chat-service stamps every message with a status, and an ASSISTANT row
698
+ * that is `STATUS_PENDING` holds a draft: the agent has started the reply and
699
+ * not finished it. Whatever text it carries is worth PAINTING — it is the only
700
+ * view of a reply in progress a worker has, since the words themselves stream
701
+ * over a socket no host exposes (see the genie-chat client's header) — but it
702
+ * is not the answer, and ending the turn on it is the bug this exists for: the
703
+ * typing line clears, Stop disappears while the agent is still writing, and the
704
+ * ladder drops to its idle rung, so the rest of the reply sits unseen for a
705
+ * whole idle interval. The native Aria app ends a turn only on
706
+ * `STATUS_COMPLETED`, for the same reason.
707
+ *
708
+ * Only an explicit PENDING counts. An absent or unrecognised status is
709
+ * "settled": no capture in this repo has ever shown the field, and a gate that
710
+ * demanded `STATUS_COMPLETED` would hang every turn on a service that simply
711
+ * does not send it. Callers apply it to ASSISTANT rows only: a USER row is
712
+ * counted to retire the optimistic bubble, and holding one back would paint
713
+ * the owner's words twice. */
714
+ function stillWriting(m: ConversationMessage): boolean {
715
+ return m.status === "STATUS_PENDING";
716
+ }
717
+
692
718
  export function repliedTo(msgs: ConversationMessage[], turnId: string): boolean | null {
693
719
  if (!turnId) return null;
694
720
  const at = msgs.findIndex((m) => m.id === turnId);
@@ -701,7 +727,9 @@ export function repliedTo(msgs: ConversationMessage[], turnId: string): boolean
701
727
  for (let i = at + 1; i < msgs.length; i++) {
702
728
  const m = msgs[i];
703
729
  if ((m.role || "ASSISTANT") !== "ASSISTANT") continue;
704
- if (answerText(m)) return true;
730
+ // A row still being written is not the answer yet — keep looking, since
731
+ // the finished answer can land in a later row of the same turn.
732
+ if (answerText(m) && !stillWriting(m)) return true;
705
733
  }
706
734
  return false;
707
735
  }
@@ -736,6 +764,26 @@ export function countTurns(msgs: ConversationMessage[], role: string): number {
736
764
  return n;
737
765
  }
738
766
 
767
+ /** How many FINISHED assistant answers the transcript holds — `countTurns`
768
+ * minus the rows the service is still writing (`stillWriting`).
769
+ *
770
+ * The counted path compares THIS against a mark taken with `countTurns`, and
771
+ * the asymmetry is the point. The mark includes drafts, so a follow-up sent
772
+ * while an earlier reply is still being written is marked AFTER that reply;
773
+ * the earlier reply finishing then leaves this count equal to the mark rather
774
+ * than past it, and cannot end the new turn. Were the mark taken without
775
+ * drafts too, that finish would read as the new turn's answer. And a draft
776
+ * of the turn's OWN answer is not counted here, so it does not end the turn
777
+ * either — the same rule `repliedTo` applies on the exact path. */
778
+ export function countSettledAnswers(msgs: ConversationMessage[]): number {
779
+ let n = 0;
780
+ for (const m of msgs) {
781
+ if ((m.role || "ASSISTANT") !== "ASSISTANT") continue;
782
+ if (answerText(m) && !stillWriting(m)) n++;
783
+ }
784
+ return n;
785
+ }
786
+
739
787
  /** Has the turn stored under `userMessageId` already been answered?
740
788
  *
741
789
  * Per-TURN, and that is the entire point. A COUNT of assistant replies says
@@ -785,6 +833,12 @@ export function turnAnswered(msgs: ConversationMessage[], userMessageId: string)
785
833
  // exactly these two, and the two scanners read the same transcript: if they
786
834
  // disagree, one of them ends the turn while the other leaves the ladder
787
835
  // running at the fast cadence.
836
+ //
837
+ // They DO part on one case, deliberately: a row the service is still
838
+ // writing (`stillWriting`). That does not end a turn, but it IS proof the
839
+ // trigger landed — the agent is answering — so re-triggering the stored id
840
+ // here would have it answer the same turn twice. The ladder never reads
841
+ // this scanner, so the split cannot pin it fast.
788
842
  if (role === "ASSISTANT" && answerText(m)) return true;
789
843
  }
790
844
  return false;
@@ -1155,7 +1209,10 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1155
1209
  // both; the widget path did neither.
1156
1210
  widget: carrySelection(
1157
1211
  activeWidgetOf(
1158
- msgs.map((m) => ({ role: m.role, content: stripHidden(messageText(m)) })),
1212
+ // A draft offers no controls: the agent has not finished asking, and
1213
+ // an answer tapped now would start a second turn on top of this one.
1214
+ // An empty newest row is NO_WIDGET — the placeholder rule.
1215
+ msgs.map((m) => ({ role: m.role, content: stillWriting(m) ? "" : stripHidden(messageText(m)) })),
1159
1216
  disclosedLabel,
1160
1217
  o.subtree,
1161
1218
  (url) => isWixMediaHost(hostOf(url)),
@@ -1220,7 +1277,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1220
1277
  // widget-only reply is, even though it leaves no bubble to inspect. Asking
1221
1278
  // the last BUBBLE stalled this loop on exactly that reply, the same way it
1222
1279
  // stalled `tick`.
1223
- if (repliedTo(raw, s._turnId) ?? countTurns(raw, "ASSISTANT") > s._replyAfter) {
1280
+ if (repliedTo(raw, s._turnId) ?? countSettledAnswers(raw) > s._replyAfter) {
1224
1281
  return { ...s, status: "ready", _convId: convId, messages: bubbles, prompts: [], input: "", statusText: "", error: "", _pending: "", ...observed(raw, s.widget), ok: false };
1225
1282
  }
1226
1283
  }
@@ -1804,7 +1861,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1804
1861
  // Counted over RAW messages, so a widget-only answer still ends the turn
1805
1862
  // instead of pinning the ladder to its fast rung forever (`countTurns`).
1806
1863
  // Exact when the service named the turn, counted when it did not.
1807
- const replied = repliedTo(raw, s._turnId) ?? countTurns(raw, "ASSISTANT") > s._replyAfter;
1864
+ const replied = repliedTo(raw, s._turnId) ?? countSettledAnswers(raw) > s._replyAfter;
1808
1865
  const next: AriaChatState = {
1809
1866
  ...s,
1810
1867
  status: replied ? "ready" : s.status,
@@ -585,7 +585,10 @@ export function toBubbles(msgs: ConversationMessage[], assistantName: string, yo
585
585
  // way, and an aborted turn consists of nothing else. Then envelopes, then
586
586
  // the markdown stripper — each stage handing on only what a reader should
587
587
  // actually see.
588
- const prose = role === "USER" ? raw : proseOfParts(scanEnvelopes(stripHidden(raw), 0, true).parts);
588
+ // A row the service is still writing is scanned as STREAMING: an
589
+ // envelope whose `>>` has not arrived yet is held back rather than
590
+ // painted as raw JSON until the row finishes.
591
+ const prose = role === "USER" ? raw : proseOfParts(scanEnvelopes(stripHidden(raw), 0, !stillWriting(m)).parts);
589
592
  return {
590
593
  id: m.id || `m${i}`,
591
594
  role,
@@ -689,6 +692,29 @@ function answerText(m: ConversationMessage): string {
689
692
  return stripHidden(messageText(m)).trim();
690
693
  }
691
694
 
695
+ /** Is the service still WRITING this assistant row?
696
+ *
697
+ * The chat-service stamps every message with a status, and an ASSISTANT row
698
+ * that is `STATUS_PENDING` holds a draft: the agent has started the reply and
699
+ * not finished it. Whatever text it carries is worth PAINTING — it is the only
700
+ * view of a reply in progress a worker has, since the words themselves stream
701
+ * over a socket no host exposes (see the genie-chat client's header) — but it
702
+ * is not the answer, and ending the turn on it is the bug this exists for: the
703
+ * typing line clears, Stop disappears while the agent is still writing, and the
704
+ * ladder drops to its idle rung, so the rest of the reply sits unseen for a
705
+ * whole idle interval. The native Aria app ends a turn only on
706
+ * `STATUS_COMPLETED`, for the same reason.
707
+ *
708
+ * Only an explicit PENDING counts. An absent or unrecognised status is
709
+ * "settled": no capture in this repo has ever shown the field, and a gate that
710
+ * demanded `STATUS_COMPLETED` would hang every turn on a service that simply
711
+ * does not send it. Callers apply it to ASSISTANT rows only: a USER row is
712
+ * counted to retire the optimistic bubble, and holding one back would paint
713
+ * the owner's words twice. */
714
+ function stillWriting(m: ConversationMessage): boolean {
715
+ return m.status === "STATUS_PENDING";
716
+ }
717
+
692
718
  export function repliedTo(msgs: ConversationMessage[], turnId: string): boolean | null {
693
719
  if (!turnId) return null;
694
720
  const at = msgs.findIndex((m) => m.id === turnId);
@@ -701,7 +727,9 @@ export function repliedTo(msgs: ConversationMessage[], turnId: string): boolean
701
727
  for (let i = at + 1; i < msgs.length; i++) {
702
728
  const m = msgs[i];
703
729
  if ((m.role || "ASSISTANT") !== "ASSISTANT") continue;
704
- if (answerText(m)) return true;
730
+ // A row still being written is not the answer yet — keep looking, since
731
+ // the finished answer can land in a later row of the same turn.
732
+ if (answerText(m) && !stillWriting(m)) return true;
705
733
  }
706
734
  return false;
707
735
  }
@@ -736,6 +764,26 @@ export function countTurns(msgs: ConversationMessage[], role: string): number {
736
764
  return n;
737
765
  }
738
766
 
767
+ /** How many FINISHED assistant answers the transcript holds — `countTurns`
768
+ * minus the rows the service is still writing (`stillWriting`).
769
+ *
770
+ * The counted path compares THIS against a mark taken with `countTurns`, and
771
+ * the asymmetry is the point. The mark includes drafts, so a follow-up sent
772
+ * while an earlier reply is still being written is marked AFTER that reply;
773
+ * the earlier reply finishing then leaves this count equal to the mark rather
774
+ * than past it, and cannot end the new turn. Were the mark taken without
775
+ * drafts too, that finish would read as the new turn's answer. And a draft
776
+ * of the turn's OWN answer is not counted here, so it does not end the turn
777
+ * either — the same rule `repliedTo` applies on the exact path. */
778
+ export function countSettledAnswers(msgs: ConversationMessage[]): number {
779
+ let n = 0;
780
+ for (const m of msgs) {
781
+ if ((m.role || "ASSISTANT") !== "ASSISTANT") continue;
782
+ if (answerText(m) && !stillWriting(m)) n++;
783
+ }
784
+ return n;
785
+ }
786
+
739
787
  /** Has the turn stored under `userMessageId` already been answered?
740
788
  *
741
789
  * Per-TURN, and that is the entire point. A COUNT of assistant replies says
@@ -785,6 +833,12 @@ export function turnAnswered(msgs: ConversationMessage[], userMessageId: string)
785
833
  // exactly these two, and the two scanners read the same transcript: if they
786
834
  // disagree, one of them ends the turn while the other leaves the ladder
787
835
  // running at the fast cadence.
836
+ //
837
+ // They DO part on one case, deliberately: a row the service is still
838
+ // writing (`stillWriting`). That does not end a turn, but it IS proof the
839
+ // trigger landed — the agent is answering — so re-triggering the stored id
840
+ // here would have it answer the same turn twice. The ladder never reads
841
+ // this scanner, so the split cannot pin it fast.
788
842
  if (role === "ASSISTANT" && answerText(m)) return true;
789
843
  }
790
844
  return false;
@@ -1155,7 +1209,10 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1155
1209
  // both; the widget path did neither.
1156
1210
  widget: carrySelection(
1157
1211
  activeWidgetOf(
1158
- msgs.map((m) => ({ role: m.role, content: stripHidden(messageText(m)) })),
1212
+ // A draft offers no controls: the agent has not finished asking, and
1213
+ // an answer tapped now would start a second turn on top of this one.
1214
+ // An empty newest row is NO_WIDGET — the placeholder rule.
1215
+ msgs.map((m) => ({ role: m.role, content: stillWriting(m) ? "" : stripHidden(messageText(m)) })),
1159
1216
  disclosedLabel,
1160
1217
  o.subtree,
1161
1218
  (url) => isWixMediaHost(hostOf(url)),
@@ -1220,7 +1277,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1220
1277
  // widget-only reply is, even though it leaves no bubble to inspect. Asking
1221
1278
  // the last BUBBLE stalled this loop on exactly that reply, the same way it
1222
1279
  // stalled `tick`.
1223
- if (repliedTo(raw, s._turnId) ?? countTurns(raw, "ASSISTANT") > s._replyAfter) {
1280
+ if (repliedTo(raw, s._turnId) ?? countSettledAnswers(raw) > s._replyAfter) {
1224
1281
  return { ...s, status: "ready", _convId: convId, messages: bubbles, prompts: [], input: "", statusText: "", error: "", _pending: "", ...observed(raw, s.widget), ok: false };
1225
1282
  }
1226
1283
  }
@@ -1804,7 +1861,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1804
1861
  // Counted over RAW messages, so a widget-only answer still ends the turn
1805
1862
  // instead of pinning the ladder to its fast rung forever (`countTurns`).
1806
1863
  // Exact when the service named the turn, counted when it did not.
1807
- const replied = repliedTo(raw, s._turnId) ?? countTurns(raw, "ASSISTANT") > s._replyAfter;
1864
+ const replied = repliedTo(raw, s._turnId) ?? countSettledAnswers(raw) > s._replyAfter;
1808
1865
  const next: AriaChatState = {
1809
1866
  ...s,
1810
1867
  status: replied ? "ready" : s.status,
@@ -585,7 +585,10 @@ export function toBubbles(msgs: ConversationMessage[], assistantName: string, yo
585
585
  // way, and an aborted turn consists of nothing else. Then envelopes, then
586
586
  // the markdown stripper — each stage handing on only what a reader should
587
587
  // actually see.
588
- const prose = role === "USER" ? raw : proseOfParts(scanEnvelopes(stripHidden(raw), 0, true).parts);
588
+ // A row the service is still writing is scanned as STREAMING: an
589
+ // envelope whose `>>` has not arrived yet is held back rather than
590
+ // painted as raw JSON until the row finishes.
591
+ const prose = role === "USER" ? raw : proseOfParts(scanEnvelopes(stripHidden(raw), 0, !stillWriting(m)).parts);
589
592
  return {
590
593
  id: m.id || `m${i}`,
591
594
  role,
@@ -689,6 +692,29 @@ function answerText(m: ConversationMessage): string {
689
692
  return stripHidden(messageText(m)).trim();
690
693
  }
691
694
 
695
+ /** Is the service still WRITING this assistant row?
696
+ *
697
+ * The chat-service stamps every message with a status, and an ASSISTANT row
698
+ * that is `STATUS_PENDING` holds a draft: the agent has started the reply and
699
+ * not finished it. Whatever text it carries is worth PAINTING — it is the only
700
+ * view of a reply in progress a worker has, since the words themselves stream
701
+ * over a socket no host exposes (see the genie-chat client's header) — but it
702
+ * is not the answer, and ending the turn on it is the bug this exists for: the
703
+ * typing line clears, Stop disappears while the agent is still writing, and the
704
+ * ladder drops to its idle rung, so the rest of the reply sits unseen for a
705
+ * whole idle interval. The native Aria app ends a turn only on
706
+ * `STATUS_COMPLETED`, for the same reason.
707
+ *
708
+ * Only an explicit PENDING counts. An absent or unrecognised status is
709
+ * "settled": no capture in this repo has ever shown the field, and a gate that
710
+ * demanded `STATUS_COMPLETED` would hang every turn on a service that simply
711
+ * does not send it. Callers apply it to ASSISTANT rows only: a USER row is
712
+ * counted to retire the optimistic bubble, and holding one back would paint
713
+ * the owner's words twice. */
714
+ function stillWriting(m: ConversationMessage): boolean {
715
+ return m.status === "STATUS_PENDING";
716
+ }
717
+
692
718
  export function repliedTo(msgs: ConversationMessage[], turnId: string): boolean | null {
693
719
  if (!turnId) return null;
694
720
  const at = msgs.findIndex((m) => m.id === turnId);
@@ -701,7 +727,9 @@ export function repliedTo(msgs: ConversationMessage[], turnId: string): boolean
701
727
  for (let i = at + 1; i < msgs.length; i++) {
702
728
  const m = msgs[i];
703
729
  if ((m.role || "ASSISTANT") !== "ASSISTANT") continue;
704
- if (answerText(m)) return true;
730
+ // A row still being written is not the answer yet — keep looking, since
731
+ // the finished answer can land in a later row of the same turn.
732
+ if (answerText(m) && !stillWriting(m)) return true;
705
733
  }
706
734
  return false;
707
735
  }
@@ -736,6 +764,26 @@ export function countTurns(msgs: ConversationMessage[], role: string): number {
736
764
  return n;
737
765
  }
738
766
 
767
+ /** How many FINISHED assistant answers the transcript holds — `countTurns`
768
+ * minus the rows the service is still writing (`stillWriting`).
769
+ *
770
+ * The counted path compares THIS against a mark taken with `countTurns`, and
771
+ * the asymmetry is the point. The mark includes drafts, so a follow-up sent
772
+ * while an earlier reply is still being written is marked AFTER that reply;
773
+ * the earlier reply finishing then leaves this count equal to the mark rather
774
+ * than past it, and cannot end the new turn. Were the mark taken without
775
+ * drafts too, that finish would read as the new turn's answer. And a draft
776
+ * of the turn's OWN answer is not counted here, so it does not end the turn
777
+ * either — the same rule `repliedTo` applies on the exact path. */
778
+ export function countSettledAnswers(msgs: ConversationMessage[]): number {
779
+ let n = 0;
780
+ for (const m of msgs) {
781
+ if ((m.role || "ASSISTANT") !== "ASSISTANT") continue;
782
+ if (answerText(m) && !stillWriting(m)) n++;
783
+ }
784
+ return n;
785
+ }
786
+
739
787
  /** Has the turn stored under `userMessageId` already been answered?
740
788
  *
741
789
  * Per-TURN, and that is the entire point. A COUNT of assistant replies says
@@ -785,6 +833,12 @@ export function turnAnswered(msgs: ConversationMessage[], userMessageId: string)
785
833
  // exactly these two, and the two scanners read the same transcript: if they
786
834
  // disagree, one of them ends the turn while the other leaves the ladder
787
835
  // running at the fast cadence.
836
+ //
837
+ // They DO part on one case, deliberately: a row the service is still
838
+ // writing (`stillWriting`). That does not end a turn, but it IS proof the
839
+ // trigger landed — the agent is answering — so re-triggering the stored id
840
+ // here would have it answer the same turn twice. The ladder never reads
841
+ // this scanner, so the split cannot pin it fast.
788
842
  if (role === "ASSISTANT" && answerText(m)) return true;
789
843
  }
790
844
  return false;
@@ -1155,7 +1209,10 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1155
1209
  // both; the widget path did neither.
1156
1210
  widget: carrySelection(
1157
1211
  activeWidgetOf(
1158
- msgs.map((m) => ({ role: m.role, content: stripHidden(messageText(m)) })),
1212
+ // A draft offers no controls: the agent has not finished asking, and
1213
+ // an answer tapped now would start a second turn on top of this one.
1214
+ // An empty newest row is NO_WIDGET — the placeholder rule.
1215
+ msgs.map((m) => ({ role: m.role, content: stillWriting(m) ? "" : stripHidden(messageText(m)) })),
1159
1216
  disclosedLabel,
1160
1217
  o.subtree,
1161
1218
  (url) => isWixMediaHost(hostOf(url)),
@@ -1220,7 +1277,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1220
1277
  // widget-only reply is, even though it leaves no bubble to inspect. Asking
1221
1278
  // the last BUBBLE stalled this loop on exactly that reply, the same way it
1222
1279
  // stalled `tick`.
1223
- if (repliedTo(raw, s._turnId) ?? countTurns(raw, "ASSISTANT") > s._replyAfter) {
1280
+ if (repliedTo(raw, s._turnId) ?? countSettledAnswers(raw) > s._replyAfter) {
1224
1281
  return { ...s, status: "ready", _convId: convId, messages: bubbles, prompts: [], input: "", statusText: "", error: "", _pending: "", ...observed(raw, s.widget), ok: false };
1225
1282
  }
1226
1283
  }
@@ -1804,7 +1861,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1804
1861
  // Counted over RAW messages, so a widget-only answer still ends the turn
1805
1862
  // instead of pinning the ladder to its fast rung forever (`countTurns`).
1806
1863
  // Exact when the service named the turn, counted when it did not.
1807
- const replied = repliedTo(raw, s._turnId) ?? countTurns(raw, "ASSISTANT") > s._replyAfter;
1864
+ const replied = repliedTo(raw, s._turnId) ?? countSettledAnswers(raw) > s._replyAfter;
1808
1865
  const next: AriaChatState = {
1809
1866
  ...s,
1810
1867
  status: replied ? "ready" : s.status,