@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.
@@ -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,
@@ -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,