@cydm/happy-elves 0.1.0-beta.488 → 0.1.0-beta.489

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-cli",
3
- "version": "0.1.0-beta.488",
3
+ "version": "0.1.0-beta.489",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -2,6 +2,7 @@ export declare const configDir: string;
2
2
  export declare const configPath: string;
3
3
  export declare const auditLogPath: string;
4
4
  export declare const relayOutboxPath: string;
5
+ export declare const relayRetainedOutboxPath: string;
5
6
  export declare const inboundPromptAdmissionStorePath: string;
6
7
  export declare const sessionProjectionStorePath: string;
7
8
  export declare const loopStorePath: string;
@@ -7,6 +7,7 @@ export const configDir = process.env.HAPPY_ELVES_HOME
7
7
  export const configPath = path.join(configDir, "daemon.json");
8
8
  export const auditLogPath = path.join(configDir, "audit.jsonl");
9
9
  export const relayOutboxPath = path.join(configDir, "outbox.jsonl");
10
+ export const relayRetainedOutboxPath = path.join(configDir, "retained-outbox.jsonl");
10
11
  export const inboundPromptAdmissionStorePath = path.join(configDir, "inbound-prompt-admissions.json");
11
12
  export const sessionProjectionStorePath = path.join(configDir, "sessions.json");
12
13
  export const loopStorePath = path.join(configDir, "loops.json");
@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
3
3
  import { encryptJson } from "../../../../packages/shared/dist/index.js";
4
4
  import { activeCommandRequests, completedCommandResponses, sessions, sessionTurns } from "../state.js";
5
5
  import { appendAudit } from "../audit.js";
6
- import { configDir, relayOutboxPath } from "../paths.js";
6
+ import { configDir, relayOutboxPath, relayRetainedOutboxPath } from "../paths.js";
7
7
  const criticalMessageTypes = new Set([
8
8
  "machine:sessionReady",
9
9
  "machine:event",
@@ -37,7 +37,14 @@ let outboxFlushTimer;
37
37
  const outbox = new Map();
38
38
  const directSockets = new WeakSet();
39
39
  const retainedVersions = new Map();
40
- const retainedWriteQueues = new Map();
40
+ const retainedOutbox = new Map();
41
+ const retainedInFlightKeys = new Set();
42
+ let retainedOutboxLoaded = false;
43
+ let retainedOutboxLoadPromise = null;
44
+ let retainedOutboxWritePromise = Promise.resolve();
45
+ let retainedOutboxDrainPromise = null;
46
+ let retainedOutboxFlushTimer;
47
+ let retainedOutboxFlushAt;
41
48
  const transcriptWindows = new Map();
42
49
  const controllerProjectionChannel = "product:controllers";
43
50
  const retainedTranscriptWindowSize = 200;
@@ -50,6 +57,13 @@ const outboxAckTimeoutMs = 15_000;
50
57
  const outboxRetryBaseMs = 1_000;
51
58
  const outboxRetryMaxMs = 30_000;
52
59
  const relayStoredTtlMs = 24 * 60 * 60 * 1000;
60
+ const configuredRetainedHttpTimeoutMs = Number(process.env.HAPPY_ELVES_RELAY_RETAINED_HTTP_TIMEOUT_MS);
61
+ const retainedHttpTimeoutMs = Number.isFinite(configuredRetainedHttpTimeoutMs) && configuredRetainedHttpTimeoutMs >= 25
62
+ ? Math.min(60_000, Math.floor(configuredRetainedHttpTimeoutMs))
63
+ : 10_000;
64
+ const retainedDrainConcurrency = 4;
65
+ const retainedOutboxRetryBaseMs = 1_000;
66
+ const retainedOutboxRetryMaxMs = 30_000;
53
67
  export function markDirectSocket(ws) {
54
68
  directSockets.add(ws);
55
69
  }
@@ -73,6 +87,9 @@ export function send(ws, message) {
73
87
  }
74
88
  export function configureRelaySender(config) {
75
89
  relayConfig = config;
90
+ void ensureRetainedOutboxLoaded().then(() => scheduleRetainedOutboxFlush(0)).catch((error) => {
91
+ console.error(`Relay retained outbox load failed: ${error instanceof Error ? error.message : String(error)}`);
92
+ });
76
93
  }
77
94
  export function setActiveRelaySocket(ws) {
78
95
  activeRelaySocket = ws;
@@ -157,6 +174,15 @@ export async function clearRelayOutboxForTests() {
157
174
  outboxLoaded = true;
158
175
  outboxLoadPromise = null;
159
176
  await persistOutbox();
177
+ if (retainedOutboxFlushTimer) {
178
+ clearTimeout(retainedOutboxFlushTimer);
179
+ retainedOutboxFlushTimer = undefined;
180
+ }
181
+ retainedOutboxFlushAt = undefined;
182
+ retainedOutbox.clear();
183
+ retainedOutboxLoaded = true;
184
+ retainedOutboxLoadPromise = null;
185
+ await persistRetainedOutbox();
160
186
  }
161
187
  export async function sendReliable(message, ws) {
162
188
  if (ws && directSockets.has(ws)) {
@@ -515,32 +541,195 @@ async function sendPacket(socket, payload, options) {
515
541
  encryptedPayload: await encryptJson(relayConfig.accountSecret, `packet:${options.packetId}`, payload),
516
542
  },
517
543
  };
544
+ // Retained projections are durable and independently retried. Persisting
545
+ // the latest projection is local-only work; the WAN/CAS write must never
546
+ // hold up a control packet or its stored ACK.
547
+ await sendRetainedProjection(payload);
518
548
  socket.send(JSON.stringify(packet));
519
- await sendRetainedProjection(socket, payload);
520
549
  }
521
- async function sendRetainedProjection(socket, payload) {
550
+ async function sendRetainedProjection(payload) {
522
551
  if (!relayConfig)
523
552
  return;
524
- void socket;
525
553
  const retainedProjection = retainedProjectionForPayload(relayConfig.machineId, payload);
526
554
  if (!retainedProjection)
527
555
  return;
528
- const { retainedKey } = retainedProjection;
529
- const previous = retainedWriteQueues.get(retainedKey) ?? Promise.resolve();
530
- const next = previous.catch(() => undefined).then(async () => {
531
- if (!relayConfig)
556
+ await enqueueRetainedProjection(retainedProjection.retainedKey, retainedProjection.payload);
557
+ }
558
+ async function enqueueRetainedProjection(retainedKey, payload) {
559
+ await ensureRetainedOutboxLoaded();
560
+ const now = new Date().toISOString();
561
+ const existing = retainedOutbox.get(retainedKey);
562
+ const record = existing ?? {
563
+ retainedKey,
564
+ payload,
565
+ createdAt: now,
566
+ updatedAt: now,
567
+ generation: randomUUID(),
568
+ attemptCount: 0,
569
+ };
570
+ record.payload = payload;
571
+ record.updatedAt = now;
572
+ record.generation = randomUUID();
573
+ // A newer projection supersedes a retry deadline from an older payload.
574
+ // The per-key in-flight fence still allows the older HTTP request to finish
575
+ // without letting it delete or overwrite this newer record.
576
+ record.nextAttemptAt = undefined;
577
+ if (!existing)
578
+ retainedOutbox.set(retainedKey, record);
579
+ await persistRetainedOutbox();
580
+ scheduleRetainedOutboxFlush(0);
581
+ }
582
+ function scheduleRetainedOutboxFlush(delayOverrideMs) {
583
+ void ensureRetainedOutboxLoaded().then(() => {
584
+ if (retainedOutbox.size === 0)
532
585
  return;
533
- const version = await putRetainedProjection(retainedKey, retainedProjection.payload);
534
- retainedVersions.set(retainedKey, version);
586
+ const now = Date.now();
587
+ const dueAt = nextRetainedOutboxFlushAt(now);
588
+ if (dueAt === undefined && delayOverrideMs === undefined)
589
+ return;
590
+ const delay = delayOverrideMs ?? Math.max(0, (dueAt ?? now) - now);
591
+ const targetAt = now + Math.max(0, delay);
592
+ if (retainedOutboxFlushTimer && retainedOutboxFlushAt !== undefined && retainedOutboxFlushAt <= targetAt)
593
+ return;
594
+ if (retainedOutboxFlushTimer)
595
+ clearTimeout(retainedOutboxFlushTimer);
596
+ retainedOutboxFlushAt = targetAt;
597
+ retainedOutboxFlushTimer = setTimeout(() => {
598
+ retainedOutboxFlushTimer = undefined;
599
+ retainedOutboxFlushAt = undefined;
600
+ void flushRetainedOutbox().catch((error) => {
601
+ console.error(`Relay retained outbox flush failed: ${error instanceof Error ? error.message : String(error)}`);
602
+ });
603
+ }, Math.max(0, delay));
604
+ retainedOutboxFlushTimer.unref?.();
605
+ }).catch((error) => {
606
+ console.error(`Relay retained outbox schedule failed: ${error instanceof Error ? error.message : String(error)}`);
607
+ });
608
+ }
609
+ function flushRetainedOutbox() {
610
+ retainedOutboxDrainPromise ??= flushRetainedOutboxOnce().finally(() => {
611
+ retainedOutboxDrainPromise = null;
612
+ scheduleRetainedOutboxFlush();
535
613
  });
536
- retainedWriteQueues.set(retainedKey, next);
614
+ return retainedOutboxDrainPromise;
615
+ }
616
+ async function flushRetainedOutboxOnce() {
617
+ await ensureRetainedOutboxLoaded();
618
+ const now = Date.now();
619
+ const candidates = [...retainedOutbox.values()]
620
+ .filter((record) => !retainedInFlightKeys.has(record.retainedKey))
621
+ .filter((record) => !record.nextAttemptAt || Date.parse(record.nextAttemptAt) <= now)
622
+ .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt))
623
+ .slice(0, retainedDrainConcurrency);
624
+ await Promise.all(candidates.map((record) => attemptRetainedOutboxRecord(record)));
625
+ }
626
+ function nextRetainedOutboxFlushAt(now) {
627
+ let next;
628
+ for (const record of retainedOutbox.values()) {
629
+ if (retainedInFlightKeys.has(record.retainedKey))
630
+ continue;
631
+ const dueAt = record.nextAttemptAt ? Date.parse(record.nextAttemptAt) : now;
632
+ if (!Number.isFinite(dueAt))
633
+ continue;
634
+ next = next === undefined ? dueAt : Math.min(next, dueAt);
635
+ }
636
+ return next;
637
+ }
638
+ async function attemptRetainedOutboxRecord(record) {
639
+ retainedInFlightKeys.add(record.retainedKey);
640
+ const generation = record.generation;
641
+ record.attemptCount += 1;
642
+ record.nextAttemptAt = new Date(Date.now() + retainedRetryDelayMs(record.attemptCount)).toISOString();
643
+ await persistRetainedOutbox();
537
644
  try {
538
- await next;
645
+ const version = await putRetainedProjection(record.retainedKey, record.payload);
646
+ retainedVersions.set(record.retainedKey, version);
647
+ const current = retainedOutbox.get(record.retainedKey);
648
+ if (current === record && current.generation === generation) {
649
+ retainedOutbox.delete(record.retainedKey);
650
+ }
651
+ await persistRetainedOutbox();
652
+ }
653
+ catch (error) {
654
+ const current = retainedOutbox.get(record.retainedKey);
655
+ if (current === record && current.generation === generation) {
656
+ current.nextAttemptAt = new Date(Date.now() + retainedRetryDelayMs(current.attemptCount)).toISOString();
657
+ await persistRetainedOutbox();
658
+ }
659
+ await appendRetainedOutboxAudit(record, "send-failed", error);
539
660
  }
540
661
  finally {
541
- if (retainedWriteQueues.get(retainedKey) === next)
542
- retainedWriteQueues.delete(retainedKey);
662
+ retainedInFlightKeys.delete(record.retainedKey);
663
+ }
664
+ }
665
+ function retainedRetryDelayMs(attemptCount) {
666
+ const exponent = Math.max(0, Math.min(attemptCount - 1, 5));
667
+ return Math.min(retainedOutboxRetryMaxMs, retainedOutboxRetryBaseMs * (2 ** exponent));
668
+ }
669
+ async function ensureRetainedOutboxLoaded() {
670
+ if (retainedOutboxLoaded)
671
+ return;
672
+ retainedOutboxLoadPromise ??= loadRetainedOutbox();
673
+ await retainedOutboxLoadPromise;
674
+ }
675
+ async function loadRetainedOutbox() {
676
+ retainedOutbox.clear();
677
+ try {
678
+ const text = await fs.readFile(relayRetainedOutboxPath, "utf8");
679
+ for (const line of text.split(/\n/u)) {
680
+ if (!line.trim())
681
+ continue;
682
+ const parsed = JSON.parse(line);
683
+ if (typeof parsed.retainedKey === "string" && parsed.retainedKey
684
+ && parsed.payload && typeof parsed.payload === "object"
685
+ && typeof parsed.createdAt === "string"
686
+ && typeof parsed.updatedAt === "string"
687
+ && typeof parsed.attemptCount === "number") {
688
+ retainedOutbox.set(parsed.retainedKey, {
689
+ retainedKey: parsed.retainedKey,
690
+ payload: parsed.payload,
691
+ createdAt: parsed.createdAt,
692
+ updatedAt: parsed.updatedAt,
693
+ generation: typeof parsed.generation === "string" && parsed.generation ? parsed.generation : randomUUID(),
694
+ attemptCount: parsed.attemptCount,
695
+ ...(typeof parsed.nextAttemptAt === "string" ? { nextAttemptAt: parsed.nextAttemptAt } : {}),
696
+ });
697
+ }
698
+ }
699
+ }
700
+ catch (error) {
701
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT")
702
+ throw error;
543
703
  }
704
+ retainedOutboxLoaded = true;
705
+ }
706
+ async function persistRetainedOutbox() {
707
+ retainedOutboxWritePromise = retainedOutboxWritePromise.then(writeRetainedOutbox, writeRetainedOutbox);
708
+ await retainedOutboxWritePromise;
709
+ }
710
+ async function writeRetainedOutbox() {
711
+ await fs.mkdir(configDir, { recursive: true });
712
+ const text = [...retainedOutbox.values()].map((record) => JSON.stringify(record)).join("\n");
713
+ const next = text ? `${text}\n` : "";
714
+ const tempPath = `${relayRetainedOutboxPath}.tmp`;
715
+ await fs.writeFile(tempPath, next, { mode: 0o600 });
716
+ await fs.rename(tempPath, relayRetainedOutboxPath);
717
+ await fs.chmod(relayRetainedOutboxPath, 0o600).catch(() => undefined);
718
+ }
719
+ async function appendRetainedOutboxAudit(record, status, error) {
720
+ if (!relayConfig)
721
+ return;
722
+ await appendAudit({
723
+ machineId: relayConfig.machineId,
724
+ actor: "system",
725
+ action: `relay.retained-outbox.${status}`,
726
+ summary: "Retained relay projection retry scheduled",
727
+ evidence: {
728
+ retainedKey: record.retainedKey,
729
+ attemptCount: record.attemptCount,
730
+ error: error instanceof Error ? error.message : error ? String(error) : undefined,
731
+ },
732
+ });
544
733
  }
545
734
  export async function putEncryptedRetainedPayload(retainedKey, payload) {
546
735
  if (!relayConfig)
@@ -549,26 +738,32 @@ export async function putEncryptedRetainedPayload(retainedKey, payload) {
549
738
  const baseVersion = retainedVersions.get(retainedKey) ?? await fetchRetainedVersion(retainedKey);
550
739
  retainedVersions.set(retainedKey, baseVersion);
551
740
  const encryptedPayload = await encryptJson(relayConfig.accountSecret, `retained:${retainedKey}:${baseVersion + 1}`, payload);
552
- const response = await fetch(`${relayConfig.relayUrl}/api/retained/${encodeURIComponent(retainedKey)}`, {
741
+ const timed = await fetchWithTimeout(`${relayConfig.relayUrl}/api/retained/${encodeURIComponent(retainedKey)}`, {
553
742
  method: "PUT",
554
743
  headers: {
555
744
  authorization: `Bearer ${relayConfig.machineToken}`,
556
745
  "content-type": "application/json",
557
746
  },
558
747
  body: JSON.stringify({ baseVersion, encryptedPayload }),
559
- });
560
- if (response.status === 409) {
561
- retainedVersions.delete(retainedKey);
562
- continue;
748
+ }, retainedHttpTimeoutMs);
749
+ const response = timed.response;
750
+ try {
751
+ if (response.status === 409) {
752
+ retainedVersions.delete(retainedKey);
753
+ continue;
754
+ }
755
+ if (!response.ok) {
756
+ throw new Error(`Retained packet write failed for ${retainedKey}: HTTP ${response.status}`);
757
+ }
758
+ const body = await readResponseJsonWithTimeout(response, retainedHttpTimeoutMs, timed.abort);
759
+ const version = body.retainedPacket?.version;
760
+ if (typeof version === "number" && Number.isSafeInteger(version) && version > 0)
761
+ return version;
762
+ throw new Error(`Retained packet write did not return a version for ${retainedKey}`);
563
763
  }
564
- if (!response.ok) {
565
- throw new Error(`Retained packet write failed for ${retainedKey}: HTTP ${response.status}`);
764
+ finally {
765
+ timed.cleanup();
566
766
  }
567
- const body = await response.json();
568
- const version = body.retainedPacket?.version;
569
- if (typeof version === "number" && Number.isSafeInteger(version) && version > 0)
570
- return version;
571
- throw new Error(`Retained packet write did not return a version for ${retainedKey}`);
572
767
  }
573
768
  throw new Error(`Retained packet CAS conflict for ${retainedKey}`);
574
769
  }
@@ -579,19 +774,69 @@ async function fetchRetainedVersion(retainedKey) {
579
774
  if (!relayConfig)
580
775
  return 0;
581
776
  try {
582
- const response = await fetch(`${relayConfig.relayUrl}/api/retained/${encodeURIComponent(retainedKey)}`, {
777
+ const timed = await fetchWithTimeout(`${relayConfig.relayUrl}/api/retained/${encodeURIComponent(retainedKey)}`, {
583
778
  headers: { authorization: `Bearer ${relayConfig.machineToken}` },
584
- });
585
- if (!response.ok)
586
- return 0;
587
- const body = await response.json();
588
- const version = body.retainedPacket?.version;
589
- return typeof version === "number" && Number.isSafeInteger(version) && version >= 0 ? version : 0;
779
+ }, retainedHttpTimeoutMs);
780
+ const response = timed.response;
781
+ try {
782
+ if (!response.ok)
783
+ return 0;
784
+ const body = await readResponseJsonWithTimeout(response, retainedHttpTimeoutMs, timed.abort);
785
+ const version = body.retainedPacket?.version;
786
+ return typeof version === "number" && Number.isSafeInteger(version) && version >= 0 ? version : 0;
787
+ }
788
+ finally {
789
+ timed.cleanup();
790
+ }
590
791
  }
591
792
  catch {
592
793
  return 0;
593
794
  }
594
795
  }
796
+ async function fetchWithTimeout(input, init, timeoutMs) {
797
+ const controller = new AbortController();
798
+ const timeout = setTimeout(() => controller.abort(new Error("retained HTTP request timed out")), timeoutMs);
799
+ timeout.unref?.();
800
+ try {
801
+ const response = await fetch(input, { ...init, signal: controller.signal });
802
+ clearTimeout(timeout);
803
+ return {
804
+ response,
805
+ abort: (reason) => controller.abort(reason),
806
+ cleanup: () => {
807
+ clearTimeout(timeout);
808
+ // Closing the body also releases the socket when the caller exits
809
+ // early on a non-2xx response. A body timeout calls abort() first;
810
+ // abort is idempotent and fences the underlying fetch stream.
811
+ if (!controller.signal.aborted)
812
+ controller.abort(new Error("retained HTTP request finished"));
813
+ },
814
+ };
815
+ }
816
+ catch (error) {
817
+ clearTimeout(timeout);
818
+ throw error;
819
+ }
820
+ }
821
+ async function readResponseJsonWithTimeout(response, timeoutMs, abort) {
822
+ let timer;
823
+ try {
824
+ return await Promise.race([
825
+ response.json(),
826
+ new Promise((_, reject) => {
827
+ timer = setTimeout(() => {
828
+ abort?.(new Error("retained HTTP response body timed out"));
829
+ reject(new Error("retained HTTP response body timed out"));
830
+ }, timeoutMs);
831
+ timer.unref?.();
832
+ }),
833
+ ]);
834
+ }
835
+ finally {
836
+ if (timer)
837
+ clearTimeout(timer);
838
+ }
839
+ }
595
840
  function retainedProjectionForPayload(machineId, payload) {
596
841
  if (!payload || typeof payload !== "object" || Array.isArray(payload))
597
842
  return undefined;
@@ -41,3 +41,4 @@ export declare function limitHistoricalEventsByBytes(events: RuntimeHistoricalEv
41
41
  export declare function terminalHistoricalEventFromTurn(turn: Turn & {
42
42
  status: "completed" | "interrupted" | "failed";
43
43
  }): RuntimeHistoricalEvent;
44
+ export declare function historicalEventsFromThread(items: ThreadItem[], turns: Turn[], limit: number | undefined, runtimeEvents?: HeadlessRuntimeEvent[]): RuntimeHistoricalEvent[];
@@ -48,12 +48,20 @@ export function mapHeadlessRuntimeEvent(event, state = createHeadlessRuntimeEven
48
48
  if (!item)
49
49
  return [];
50
50
  if (item.type === "agentMessage") {
51
+ // A repaired/replayed provider log can contain more than one terminal
52
+ // snapshot for the same item. The item id is the event identity; do not
53
+ // dedupe by text because two distinct items are allowed to say the same
54
+ // thing.
55
+ if (state.textItems.has(item.id))
56
+ return [];
51
57
  if ((!state.authoritativeTextItems.has(item.id) && (state.deltaItems.has(item.id) || state.textItems.has(item.id))) || !item.text)
52
58
  return [];
53
59
  state.textItems.add(item.id);
54
60
  return [{ type: "text_delta", text: item.text, stream: "output", ...itemIdentity }];
55
61
  }
56
62
  if (item.type === "reasoning") {
63
+ if (state.textItems.has(item.id))
64
+ return [];
57
65
  if ((!state.authoritativeTextItems.has(item.id) && (state.deltaItems.has(item.id) || state.textItems.has(item.id))) || !item.text)
58
66
  return [];
59
67
  state.textItems.add(item.id);
@@ -786,7 +794,7 @@ function historicalEventFromItem(item) {
786
794
  }
787
795
  return undefined;
788
796
  }
789
- function historicalEventsFromThread(items, turns, limit, runtimeEvents = []) {
797
+ export function historicalEventsFromThread(items, turns, limit, runtimeEvents = []) {
790
798
  const events = [];
791
799
  const userPromptKeys = new Set();
792
800
  const addEvent = (event) => {
@@ -808,6 +816,16 @@ function historicalEventsFromThread(items, turns, limit, runtimeEvents = []) {
808
816
  if (event)
809
817
  addEvent(event);
810
818
  }
819
+ // Server requests are not represented by a canonical ThreadItem until
820
+ // they are resolved (and some providers never materialize them as items at
821
+ // all). Preserve the raw option lifecycle while the canonical item/turn
822
+ // projection supplies text, tools, and terminal state. This keeps an
823
+ // authoritative turn rebuild usable for live needs-user attention without
824
+ // reintroducing duplicate assistant snapshots.
825
+ for (const event of historicalEventsFromRuntimeEvents(runtimeEvents)) {
826
+ if (event.payload.type === "option_request" || event.payload.type === "option_response")
827
+ addEvent(event);
828
+ }
811
829
  for (const turn of turns) {
812
830
  if (!isTerminalTurn(turn))
813
831
  continue;
@@ -1,11 +1,11 @@
1
- import { limitHistoricalEventsByBytes, mapHeadlessRuntimeEventsToHistoricalEvents, selectThreadEventsAfterItem, selectThreadEventsBeforeCursor, selectThreadEventsBeforeItem, selectThreadEventsForTurn, } from "./headless-event-projector.js";
1
+ import { limitHistoricalEventsByBytes, historicalEventsFromThread, mapHeadlessRuntimeEventsToHistoricalEvents, selectThreadEventsAfterItem, selectThreadEventsBeforeCursor, selectThreadEventsBeforeItem, selectThreadEventsForTurn, } from "./headless-event-projector.js";
2
2
  const defaultEventLimit = 200;
3
3
  export async function readHeadlessHistoricalEventPage(input) {
4
4
  assertValidHeadlessEventCursor(input.before);
5
5
  const limitItems = clampLimit(input.limitItems);
6
6
  const selection = await selectHeadlessRuntimeEvents(input, limitItems);
7
7
  const runtimeEvents = selection.events;
8
- const allEvents = mapHeadlessRuntimeEventsToHistoricalEvents(runtimeEvents);
8
+ const allEvents = await authoritativeTurnEvents(input, runtimeEvents) ?? mapHeadlessRuntimeEventsToHistoricalEvents(runtimeEvents);
9
9
  const limited = limitHistoricalEventsByBytes(allEvents, input.maxBytes);
10
10
  const includeSessionHead = !input.turnId && !input.before && !input.beforeItemId;
11
11
  const head = includeSessionHead ? latestHeadFromEvents(allEvents) : {};
@@ -19,6 +19,53 @@ export async function readHeadlessHistoricalEventPage(input) {
19
19
  ...(head.lastTurnId ? { lastTurnId: head.lastTurnId } : {}),
20
20
  };
21
21
  }
22
+ async function authoritativeTurnEvents(input, runtimeEvents) {
23
+ // A turn result is a rebuild operation, not a best-effort concatenation of
24
+ // every provider notification. Read the provider's canonical item/turn
25
+ // projections when there is no paging anchor; this repairs older logs that
26
+ // contain a delta plus a repeated terminal snapshot for the same item.
27
+ // Identity is itemId/turnId, never text, so legitimate repeated answers
28
+ // from distinct items remain visible.
29
+ if (!input.turnId || input.before || input.beforeItemId || input.afterItemId)
30
+ return undefined;
31
+ // A few internal readers (and older provider adapters during rolling
32
+ // upgrades) only expose the event reader. Canonical rebuild is an
33
+ // enhancement, not a reason to turn an otherwise reachable runtime into an
34
+ // unavailable one; fall back to the event projection when the optional
35
+ // canonical methods are absent.
36
+ if (typeof input.client.listThreadItems !== "function" || typeof input.client.listThreadTurns !== "function")
37
+ return undefined;
38
+ const [items, turns] = await Promise.all([
39
+ listAllThreadItems(input.client, input.threadId, input.turnId),
40
+ listAllThreadTurns(input.client, input.threadId),
41
+ ]);
42
+ const turn = turns.find((candidate) => candidate.id === input.turnId);
43
+ return historicalEventsFromThread(items, turn ? [turn] : [], undefined, runtimeEvents);
44
+ }
45
+ async function listAllThreadItems(client, threadId, turnId) {
46
+ const items = [];
47
+ let afterCursor;
48
+ for (let pageIndex = 0; pageIndex < 64; pageIndex += 1) {
49
+ const page = await client.listThreadItems({ threadId, turnId, order: "asc", limit: 200, ...(afterCursor ? { afterCursor } : {}) });
50
+ items.push(...page.items);
51
+ if (!page.nextCursor || page.items.length === 0)
52
+ break;
53
+ afterCursor = page.nextCursor;
54
+ }
55
+ return items;
56
+ }
57
+ async function listAllThreadTurns(client, threadId) {
58
+ const turns = [];
59
+ let afterCursor;
60
+ for (let pageIndex = 0; pageIndex < 64; pageIndex += 1) {
61
+ const page = await client.listThreadTurns({ threadId, order: "asc", limit: 200, ...(afterCursor ? { afterCursor } : {}) });
62
+ turns.push(...page.turns);
63
+ if (!page.nextCursor || page.turns.length === 0)
64
+ break;
65
+ afterCursor = page.nextCursor;
66
+ }
67
+ return turns;
68
+ }
22
69
  async function selectHeadlessRuntimeEvents(input, limitItems) {
23
70
  if (input.turnId) {
24
71
  return await selectThreadEventsForTurn(input.client, input.threadId, input.turnId, limitItems);
@@ -1,11 +1,11 @@
1
1
  import { type MachineCommand } from "../../../../packages/shared/dist/index.js";
2
2
  import type { DaemonConfig, SessionFallback, SessionState } from "../types.js";
3
3
  import { type HistoricalBackfillMode, type HistoricalBackfillOptions, type HistoricalBackfillResult } from "./history-backfill.js";
4
+ export { refreshNativeHistoryForLoadedSession } from "./native-history-refresh.js";
4
5
  export declare function handleCreateSession(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;
5
6
  export declare function handleImportHistoricalSession(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;
6
7
  export declare function handleListHistoricalSessions(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;
7
8
  export declare function resumeSession(ws: WebSocket, config: DaemonConfig, sessionId: string, requestId: string, fallback?: SessionFallback, announceSuccess?: boolean, syncDirection?: HistoricalBackfillMode, syncOptions?: Pick<HistoricalBackfillOptions, "afterItemId" | "beforeItemId" | "limitItems" | "maxBytes">): Promise<SessionState | null>;
8
- export declare function refreshNativeHistoryForLoadedSession(session: SessionState, config: DaemonConfig, requestId: string): Promise<SessionState>;
9
9
  export declare function rehydrateRuntimeSession(session: SessionState): Promise<SessionState>;
10
10
  export declare function usesAuthoritativeTranscript(session: SessionState): boolean;
11
11
  export declare function replaceAuthoritativeTranscript(ws: WebSocket, config: DaemonConfig, session: SessionState, runtimeSessionId?: string, input?: {
@@ -18,6 +18,8 @@ import { backfillStateWithSessionHead, completedBackfillResult, historicalBackfi
18
18
  import { commandSessionMetadata, encryptedSessionMetadata, sessionFallbackFromCommand } from "./metadata.js";
19
19
  import { closeSessionProjection, persistSessionProjection, sessionReadyProjectionFields } from "./store.js";
20
20
  import { nativeHandoffIdentity } from "./native-identity.js";
21
+ import { refreshNativeHistoryForLoadedSession } from "./native-history-refresh.js";
22
+ export { refreshNativeHistoryForLoadedSession } from "./native-history-refresh.js";
21
23
  export async function handleCreateSession(ws, config, command) {
22
24
  if (command.type !== "machine:createSession")
23
25
  return;
@@ -782,90 +784,6 @@ export async function resumeSession(ws, config, sessionId, requestId, fallback,
782
784
  return null;
783
785
  }
784
786
  }
785
- export async function refreshNativeHistoryForLoadedSession(session, config, requestId) {
786
- if (session.source !== "native")
787
- return session;
788
- const manager = getHeadlessRuntimeManager(session.cwd);
789
- let lastError;
790
- for (let attempt = 0; attempt < 2; attempt += 1) {
791
- try {
792
- if (attempt > 0)
793
- await manager.invalidateClientForBinding(session.runtimeBinding, session.cwd);
794
- const entry = await manager.clientForBinding(session.runtimeBinding, session.cwd);
795
- if (typeof entry.client.syncNativeHistory !== "function")
796
- return session;
797
- const expectedNativeIdentity = nativeHandoffIdentity({
798
- machineId: config.machineId,
799
- provider: entry.provider,
800
- profile: entry.profile,
801
- nativeDataRootFingerprint: entry.nativeDataRootFingerprint,
802
- cwd: session.cwd,
803
- providerSessionId: session.providerSessionId ?? session.runtimeBinding.threadId,
804
- });
805
- const synced = await entry.client.syncNativeHistory({
806
- threadId: session.runtimeBinding.threadId,
807
- nativeIdentity: expectedNativeIdentity,
808
- });
809
- const providerMetadata = synced.thread.providerMetadata ?? {};
810
- const externallyChanged = providerMetadata.nativeHistoryChangedExternally === true;
811
- // Native handoff identities are derived from the current daemon/provider
812
- // binding. A session persisted by an earlier host build may carry an
813
- // identity computed before the binding included the machine/root
814
- // fingerprint. Read-only sync has already verified the provider thread
815
- // and its history, so refresh the derived identity here and require a
816
- // fresh explicit handoff confirmation instead of rejecting the session
817
- // forever with a stale proof.
818
- const nextProof = externallyChanged
819
- ? undefined
820
- : session.manualHandoffProof && typeof providerMetadata.nativeHistoryVersion === "string"
821
- ? { ...session.manualHandoffProof, historyVersion: providerMetadata.nativeHistoryVersion }
822
- : session.manualHandoffProof;
823
- const next = {
824
- ...session,
825
- nativeIdentity: expectedNativeIdentity,
826
- ...(typeof providerMetadata.nativeHistoryVersion === "string" ? { nativeHistoryVersion: providerMetadata.nativeHistoryVersion } : {}),
827
- ...(externallyChanged ? { manualHandoffProof: undefined } : nextProof ? { manualHandoffProof: nextProof } : {}),
828
- ...(session.nativeHistorySyncError ? { nativeHistorySyncError: undefined } : {}),
829
- };
830
- if (nextProof && typeof providerMetadata.nativeHistoryVersion === "string" && nextProof.historyVersion !== providerMetadata.nativeHistoryVersion) {
831
- next.manualHandoffProof = undefined;
832
- }
833
- sessions.set(session.sessionId, next);
834
- await persistSessionProjection(next);
835
- return next;
836
- }
837
- catch (error) {
838
- lastError = error;
839
- if (attempt === 0 && isNativeRuntimeTransportFailure(error))
840
- continue;
841
- break;
842
- }
843
- }
844
- const code = errorCode(lastError) ?? "NATIVE_HISTORY_SYNC_FAILED";
845
- const message = lastError instanceof Error ? lastError.message : String(lastError);
846
- const failed = {
847
- ...session,
848
- nativeHistorySyncError: { code, message, checkedAt: new Date().toISOString() },
849
- };
850
- sessions.set(session.sessionId, failed);
851
- await persistSessionProjection(failed);
852
- await appendAudit({
853
- sessionId: session.sessionId,
854
- machineId: config.machineId,
855
- actor: "system",
856
- action: "session.native_history.refresh_failed",
857
- summary: `Read-only native history refresh did not change session ${session.sessionId}`,
858
- evidence: { requestId, code, error: message },
859
- });
860
- return failed;
861
- }
862
- function isNativeRuntimeTransportFailure(error) {
863
- const code = errorCode(error);
864
- if (code && /^(ECONN|EPIPE|RUNTIME_(?:DISPOSED|UNAVAILABLE)|HOST_(?:START|SHUTDOWN)|REQUEST_TIMEOUT)$/u.test(code))
865
- return true;
866
- const message = error instanceof Error ? error.message : String(error);
867
- return /(headless runtime (?:socket|request|exited)|cannot write to headless runtime|socket (?:closed|read|error)|connection (?:reset|lost|refused)|timed out)/iu.test(message);
868
- }
869
787
  function runtimeBindingsMatch(left, right) {
870
788
  return left.runtime === right.runtime &&
871
789
  left.provider === right.provider &&
@@ -0,0 +1,12 @@
1
+ import type { DaemonConfig, SessionState } from "../types.js";
2
+ /**
3
+ * Reconcile provider-native history before a loaded session is projected.
4
+ *
5
+ * Native sessions have always used this path. Legacy Crew sessions were
6
+ * persisted as `source:"orchestration"` but still carry a provider session
7
+ * id and native-history metadata. Those sessions must take the same
8
+ * read-only reconciliation path after restart or before runtime truth reads;
9
+ * otherwise an old same-turn duplicate remains visible until an internal
10
+ * caller manually invokes the runtime operation.
11
+ */
12
+ export declare function refreshNativeHistoryForLoadedSession(session: SessionState, config: DaemonConfig | undefined, requestId: string): Promise<SessionState>;
@@ -0,0 +1,183 @@
1
+ import { appendAudit } from "../audit.js";
2
+ import { getHeadlessRuntimeManager } from "../runtime.js";
3
+ import { sessions } from "../state.js";
4
+ import { persistSessionProjection } from "./store.js";
5
+ import { nativeHandoffIdentity } from "./native-identity.js";
6
+ const orchestrationRefreshes = new Map();
7
+ const orchestrationRefreshCompleted = new Set();
8
+ const orchestrationRefreshRetryAt = new Map();
9
+ const orchestrationRefreshRetryDelayMs = 1_000;
10
+ /**
11
+ * Reconcile provider-native history before a loaded session is projected.
12
+ *
13
+ * Native sessions have always used this path. Legacy Crew sessions were
14
+ * persisted as `source:"orchestration"` but still carry a provider session
15
+ * id and native-history metadata. Those sessions must take the same
16
+ * read-only reconciliation path after restart or before runtime truth reads;
17
+ * otherwise an old same-turn duplicate remains visible until an internal
18
+ * caller manually invokes the runtime operation.
19
+ */
20
+ export async function refreshNativeHistoryForLoadedSession(session, config, requestId) {
21
+ const isNative = session.source === "native";
22
+ const isLegacyCrew = session.source === "orchestration" && Boolean(session.providerSessionId);
23
+ if (!isNative && !isLegacyCrew)
24
+ return session;
25
+ const refreshKey = `${session.runtimeBinding.profile}\0${session.runtimeBinding.threadId}`;
26
+ if (isLegacyCrew) {
27
+ if (orchestrationRefreshCompleted.has(refreshKey))
28
+ return session;
29
+ const retryAt = orchestrationRefreshRetryAt.get(refreshKey);
30
+ if (retryAt !== undefined) {
31
+ if (retryAt > Date.now())
32
+ return session;
33
+ orchestrationRefreshRetryAt.delete(refreshKey);
34
+ }
35
+ const active = orchestrationRefreshes.get(refreshKey);
36
+ if (active)
37
+ return await active;
38
+ const operation = refreshNativeHistory(session, config, requestId, false, refreshKey);
39
+ orchestrationRefreshes.set(refreshKey, operation);
40
+ try {
41
+ return await operation;
42
+ }
43
+ finally {
44
+ if (orchestrationRefreshes.get(refreshKey) === operation)
45
+ orchestrationRefreshes.delete(refreshKey);
46
+ }
47
+ }
48
+ return await refreshNativeHistory(session, config, requestId, true);
49
+ }
50
+ async function refreshNativeHistory(session, config, requestId, strict, orchestrationRefreshKey) {
51
+ const manager = getHeadlessRuntimeManager(session.cwd);
52
+ let lastError;
53
+ for (let attempt = 0; attempt < 2; attempt += 1) {
54
+ try {
55
+ if (attempt > 0)
56
+ await manager.invalidateClientForBinding(session.runtimeBinding, session.cwd);
57
+ const entry = await manager.clientForBinding(session.runtimeBinding, session.cwd);
58
+ if (typeof entry.client.syncNativeHistory !== "function") {
59
+ if (orchestrationRefreshKey)
60
+ orchestrationRefreshCompleted.add(orchestrationRefreshKey);
61
+ return session;
62
+ }
63
+ const providerSessionId = session.providerSessionId ?? session.runtimeBinding.threadId;
64
+ const expectedNativeIdentity = nativeHandoffIdentity({
65
+ ...(config?.machineId ? { machineId: config.machineId } : {}),
66
+ provider: entry.provider,
67
+ profile: entry.profile,
68
+ nativeDataRootFingerprint: entry.nativeDataRootFingerprint,
69
+ cwd: session.cwd,
70
+ providerSessionId,
71
+ });
72
+ if (orchestrationRefreshKey && typeof entry.client.readThread === "function" && typeof entry.client.adoptThread === "function") {
73
+ const current = await entry.client.readThread({ threadId: session.runtimeBinding.threadId });
74
+ const metadata = current.thread.providerMetadata ?? {};
75
+ // Legacy Crew threads may have provider history metadata but no
76
+ // explicit adoption status because they were created before the
77
+ // native-history state machine existed. Complete that migration on
78
+ // the normal load/read path, preserving the existing HE thread id.
79
+ if (metadata.headlessNativeAdoptionStatus !== "complete" && Array.isArray(metadata.nativeHistoryTurnIds)) {
80
+ await entry.client.adoptThread({
81
+ threadId: session.runtimeBinding.threadId,
82
+ providerSessionId,
83
+ cwd: session.cwd,
84
+ provider: entry.provider,
85
+ nativeIdentity: expectedNativeIdentity,
86
+ manualHandoff: true,
87
+ });
88
+ }
89
+ }
90
+ const synced = await entry.client.syncNativeHistory({
91
+ threadId: session.runtimeBinding.threadId,
92
+ nativeIdentity: expectedNativeIdentity,
93
+ });
94
+ const providerMetadata = synced.thread.providerMetadata ?? {};
95
+ const externallyChanged = providerMetadata.nativeHistoryChangedExternally === true;
96
+ // A provider thread already verified by read-only sync can refresh a
97
+ // stale proof produced by an earlier host build. Native sessions retain
98
+ // the existing safety behavior; legacy Crew sessions only update the
99
+ // durable observation and never become native-handoff sessions.
100
+ const nextProof = externallyChanged
101
+ ? undefined
102
+ : session.manualHandoffProof && typeof providerMetadata.nativeHistoryVersion === "string"
103
+ ? { ...session.manualHandoffProof, historyVersion: providerMetadata.nativeHistoryVersion }
104
+ : session.manualHandoffProof;
105
+ const next = {
106
+ ...session,
107
+ ...(strict && isUsableString(expectedNativeIdentity) ? { nativeIdentity: expectedNativeIdentity } : {}),
108
+ ...(typeof providerMetadata.nativeHistoryVersion === "string" ? { nativeHistoryVersion: providerMetadata.nativeHistoryVersion } : {}),
109
+ ...(strict && externallyChanged
110
+ ? { manualHandoffProof: undefined }
111
+ : strict && nextProof
112
+ ? { manualHandoffProof: nextProof }
113
+ : {}),
114
+ ...(strict && session.nativeHistorySyncError ? { nativeHistorySyncError: undefined } : {}),
115
+ };
116
+ sessions.set(session.sessionId, next);
117
+ await persistSessionProjection(next);
118
+ if (orchestrationRefreshKey) {
119
+ orchestrationRefreshRetryAt.delete(orchestrationRefreshKey);
120
+ orchestrationRefreshCompleted.add(orchestrationRefreshKey);
121
+ }
122
+ return next;
123
+ }
124
+ catch (error) {
125
+ lastError = error;
126
+ if (attempt === 0 && isNativeRuntimeTransportFailure(error))
127
+ continue;
128
+ break;
129
+ }
130
+ }
131
+ if (!strict) {
132
+ // Legacy Crew reconciliation is best-effort. The authoritative HE event
133
+ // log remains readable when a provider cannot perform native refresh; do
134
+ // not turn a harmless compatibility probe into a session failure.
135
+ if (orchestrationRefreshKey) {
136
+ if (isDefinitelyUnsupportedLegacyRefreshError(lastError))
137
+ orchestrationRefreshCompleted.add(orchestrationRefreshKey);
138
+ else
139
+ orchestrationRefreshRetryAt.set(orchestrationRefreshKey, Date.now() + orchestrationRefreshRetryDelayMs);
140
+ }
141
+ return session;
142
+ }
143
+ const code = errorCode(lastError) ?? "NATIVE_HISTORY_SYNC_FAILED";
144
+ const message = lastError instanceof Error ? lastError.message : String(lastError);
145
+ const failed = {
146
+ ...session,
147
+ nativeHistorySyncError: { code, message, checkedAt: new Date().toISOString() },
148
+ };
149
+ sessions.set(session.sessionId, failed);
150
+ await persistSessionProjection(failed);
151
+ if (config) {
152
+ await appendAudit({
153
+ sessionId: session.sessionId,
154
+ machineId: config.machineId,
155
+ actor: "system",
156
+ action: "session.native_history.refresh_failed",
157
+ summary: `Read-only native history refresh did not change session ${session.sessionId}`,
158
+ evidence: { requestId, code, error: message },
159
+ });
160
+ }
161
+ return failed;
162
+ }
163
+ function isUsableString(value) {
164
+ return typeof value === "string" && value.length > 0;
165
+ }
166
+ function isDefinitelyUnsupportedLegacyRefreshError(error) {
167
+ const code = errorCode(error);
168
+ return code === "CAPABILITY_UNSUPPORTED";
169
+ }
170
+ function errorCode(error) {
171
+ if (!error || typeof error !== "object")
172
+ return undefined;
173
+ const code = error.code;
174
+ return typeof code === "string" ? code : undefined;
175
+ }
176
+ function isNativeRuntimeTransportFailure(error) {
177
+ const code = errorCode(error);
178
+ if (code && /^(ECONN|EPIPE|RUNTIME_(?:DISPOSED|UNAVAILABLE)|HOST_(?:START|SHUTDOWN)|REQUEST_TIMEOUT)$/u.test(code))
179
+ return true;
180
+ const message = error instanceof Error ? error.message : String(error);
181
+ return /(headless runtime (?:socket|request|exited)|cannot write to headless runtime|socket (?:closed|read|error)|connection (?:reset|lost|refused)|timed out)/iu.test(message);
182
+ }
183
+ //# sourceMappingURL=native-history-refresh.js.map
@@ -5,13 +5,19 @@ import { runtimeTurnIdForCrewTurn } from "../orchestration/store.js";
5
5
  import { runtimeTurnIdForInboundPrompt } from "../relay/prompt-admission-store.js";
6
6
  import { loadSessionProjections } from "./store.js";
7
7
  import { runtimeSessionIdForSession } from "./he-official/common.js";
8
+ import { refreshNativeHistoryForLoadedSession } from "./native-history-refresh.js";
8
9
  export async function runtimeRead(config, input) {
9
- void config;
10
10
  let runtimeSessionId;
11
11
  try {
12
- const session = await sessionStateForRuntimeTruth(input.sessionId);
12
+ let session = await sessionStateForRuntimeTruth(input.sessionId);
13
13
  if (!session)
14
14
  return unreachable(input, `Session ${input.sessionId} is not loaded on this daemon`, undefined, "SESSION_NOT_FOUND");
15
+ // Legacy Crew sessions were persisted as source=orchestration even though
16
+ // their Headless thread already carries provider-native history metadata.
17
+ // Reconcile that history before projecting turn.result/status so an
18
+ // upgrade/restart repairs old same-turn duplicates through the official
19
+ // read path, without requiring an internal syncNativeHistory call.
20
+ session = await refreshNativeHistoryForLoadedSession(session, config, `runtime_truth_refresh_${input.requestId}`);
15
21
  runtimeSessionId = runtimeSessionIdForSession(session);
16
22
  if (!runtimeSessionId)
17
23
  return unreachable(input, `Session ${input.sessionId} does not have a runtime session binding`, undefined, "SESSION_BINDING_MISSING");
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-daemon",
3
- "version": "0.1.0-beta.488",
3
+ "version": "0.1.0-beta.489",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves",
3
- "version": "0.1.0-beta.488",
3
+ "version": "0.1.0-beta.489",
4
4
  "description": "Remote controller for local coding agents with hosted or self-hosted relay support.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -514,6 +514,28 @@ export class HeadlessRuntimeCore {
514
514
  changed = true;
515
515
  }
516
516
  const canonicalMapping = mapNativeItemsToProjection(sourceTurn.items, canonicalItems, sourceItemBindings, sourceTurn.id);
517
+ // A pre-native-history projection can contain multiple HE text items for
518
+ // one provider-native item (the old Codex/Claude resume path emitted a
519
+ // second completion item in the same turn). Once the provider gives us
520
+ // the authoritative ordered item list, remove only unmapped text items
521
+ // whose type and exact text are already represented by a mapped native
522
+ // item. We deliberately do not dedupe by text alone: a native turn may
523
+ // legitimately contain two equal assistant messages, and tool/status
524
+ // items outside the native text list must remain visible.
525
+ if (canonicalMapping.valid && canonicalMapping.itemIds.every((itemId) => typeof itemId === "string")) {
526
+ const mappedSourceItems = sourceTurn.items.filter((_, index) => canonicalMapping.itemIds[index]);
527
+ const mappedItemIds = new Set(canonicalMapping.itemIds);
528
+ const duplicateTextItemIds = canonicalItems
529
+ .filter((item) => !mappedItemIds.has(item.id))
530
+ .filter((item) => item.type === "agentMessage" || item.type === "userMessage")
531
+ .filter((item) => mappedSourceItems.some((sourceItem) => (sourceItem.kind === "assistant" ? item.type === "agentMessage" : item.type === "userMessage")
532
+ && item.text === sourceItem.text))
533
+ .map((item) => item.id);
534
+ if (duplicateTextItemIds.length > 0) {
535
+ removedItemIds.push(...duplicateTextItemIds);
536
+ changed = true;
537
+ }
538
+ }
517
539
  const duplicateMapping = duplicateTurn
518
540
  ? mapNativeItemsToProjection(sourceTurn.items, duplicateItems, sourceItemBindings, sourceTurn.id)
519
541
  : undefined;