@xmanrui/dsh-im 4.19.0 → 4.19.2

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.
@@ -28,12 +28,9 @@ import {
28
28
  validHarnessQuestion,
29
29
  } from '../shared/harness-question.mjs';
30
30
  import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
31
- import { textFromHarnessContent } from '../shared/harness-client.mjs';
32
- import { hasActiveHarnessInteractionOwner } from '../shared/harness-client.mjs';
33
- import {
34
- claimSessionSyncMirror,
35
- releaseSessionSyncMirror,
36
- } from '../shared/session-sync-registry.mjs';
31
+ import { AssistantTextAccumulator, textFromHarnessContent } from '../shared/harness-client.mjs';
32
+ import { registerSessionSyncMirror } from '../shared/session-sync-registry.mjs';
33
+ import { extractCompletedTurnAnswer } from '../shared/deferred-delivery.mjs';
37
34
  import {
38
35
  BatchInputManager,
39
36
  batchInputBusyMessage,
@@ -195,8 +192,8 @@ const STEP_PUSH_POST_CHUNK_MAX_BYTES = 24_000;
195
192
  /** Streaming-card mode coalesces card renders behind one PATCH per interval —
196
193
  * patching the same message is far more rate sensitive than posting. */
197
194
  const STEP_STREAM_PATCH_MIN_INTERVAL_MS = 1_000;
198
- /** Mux doesn't forward turn/end; this idle gap seals the mirror card. */
199
- const MIRROR_IDLE_SEAL_MS = 90_000;
195
+ /** Confirm missed boundaries from history; elapsed time is never completion. */
196
+ const MIRROR_CHECK_MS = 30_000;
200
197
  /** One answer chunk inside the streaming card: small enough that the block
201
198
  * splitter can always distribute blocks across sealed/live cards. */
202
199
  const STEP_STREAM_ANSWER_CHUNK_MAX_BYTES = 18_000;
@@ -644,14 +641,9 @@ export class FeishuHarnessBridge {
644
641
  #failedWatchSeqs = new Map();
645
642
  /** Host resolver: sessionId -> synced DM targets [{ openId, botId }]. */
646
643
  #sessionSyncTargetsFor = null;
647
- /** sessionId -> openId for live session-sync mirrors. */
648
- #sessionSyncTargets = new Map();
649
- /** In-flight adopt lookups, deduped per session. */
650
- #sessionSyncAdopting = new Set();
651
- /** Latest interim assistant text per mirrored session (folded on tool). */
652
- #sessionSyncPendingStep = new Map();
653
- /** Idle-seal timers: no events for a while = the turn ended (mux may
654
- * not forward turn/end), so seal the mirror card with what it has. */
644
+ /** Per-turn mirrors and recent delivery receipts, scoped to this bot. */
645
+ #sessionSyncTurns = new Map();
646
+ #sessionSyncCurrentTurns = new Map();
655
647
  #sessionSyncIdleTimers = new Map();
656
648
  /** Conversation keys with an IM ask in flight (set BEFORE the turn starts). */
657
649
  #imTurnKeys = new Set();
@@ -743,87 +735,63 @@ export class FeishuHarnessBridge {
743
735
  harness, state, signal, logger, watch: false,
744
736
  deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
745
737
  });
746
- // Persisted watches must resume at runtime start, not on the first
747
- // message. Older hosts without the mux watcher simply skip this.
738
+ if (this.#sessionSyncTargetsFor && this.#botId && !this.#signal?.aborted) {
739
+ const unregister = registerSessionSyncMirror({ channel: 'feishu', botId: this.#botId },
740
+ (request) => this.#deliverSessionSyncMirror(request));
741
+ this.#signal?.addEventListener('abort', () => {
742
+ unregister();
743
+ for (const timer of this.#sessionSyncIdleTimers.values()) clearTimeout(timer);
744
+ this.#sessionSyncIdleTimers.clear();
745
+ }, { once: true });
746
+ }
747
+ // Persisted watches and mirrors resume without waiting for an IM message.
748
748
  if (typeof this.#harness?.watchHarnessEvents === 'function') {
749
749
  queueMicrotask(() => {
750
750
  this.#ensureEventWatcher();
751
- // Delay the recovery: a restart lands while healthy turns may still
752
- // be streaming; sealing them at t=0 would wipe live cards. 90s gives
753
- // the turn's own events a window to re-adopt and finish normally.
754
- setTimeout(() => { void this.#sealOrphanMirrors(); }, 90_000);
751
+ void this.#sealOrphanMirrors();
755
752
  });
756
753
  }
757
754
  }
758
755
 
759
- /**
760
- * Seal mirrors left running by a previous process: a restart kills the
761
- * turn without a turn/end event, so the persisted card would stay
762
- * "running" forever. Mark each orphan sealed (stopped) on delivery.
763
- */
756
+ /** Recover only a known finished turn, using its last successful card JSON. */
764
757
  async #sealOrphanMirrors() {
765
- const entries = typeof this.#state.mirrorEntries === 'function'
766
- ? this.#state.mirrorEntries()
767
- : [];
768
- // Only claim cards older than the threshold: a restart lands while a
769
- // healthy turn may still be streaming, and sealing it would wipe the
770
- // live card. Anything older than a turn could plausibly run is orphaned.
771
- const ORPHAN_AFTER_MS = 3 * 60_000;
772
- for (const [sessionId, entry] of entries) {
773
- if (!entry?.chatId || !Array.isArray(entry.cardIds)) continue;
774
- if (typeof entry.claimedAt === 'number' && Date.now() - entry.claimedAt < ORPHAN_AFTER_MS) {
775
- this.#logger.warn?.('[dsh-feishu] mirror entry too fresh to be orphaned; leaving untouched:', sessionId);
776
- continue;
777
- }
778
- // The live card's last delivered content is persisted with the mirror:
779
- // re-patch the live card with a stopped status line, keeping every
780
- // panel intact. Sealed earlier chunks already carry no status line and
781
- // keep their original content untouched.
782
- let sealContent = null;
783
- if (typeof entry.lastContent === 'string' && entry.lastContent) {
758
+ for (const [key, entry] of this.#state.mirrorEntries?.() ?? []) {
759
+ if (!entry?.chatId || !entry.sessionId || !Number.isSafeInteger(entry.turn)
760
+ || !Array.isArray(entry.cardIds) || !entry.cardIds.length) continue;
761
+ await this.#queueEventTask(entry.sessionId, async () => {
762
+ if (this.#signal?.aborted) return;
763
+ // An adopted turn now owns this record and will finish through its queue.
764
+ if (this.#sessionSyncTurns.has(key)) return;
784
765
  try {
785
- const parsed = JSON.parse(entry.lastContent);
786
- const elements = parsed?.body?.elements;
787
- if (Array.isArray(elements) && elements.length > 0) {
788
- const last = elements[elements.length - 1];
789
- if (last?.tag === 'markdown' && typeof last.content === 'string'
790
- && last.content.startsWith('_') && last.content.endsWith('_')) {
791
- last.content = `_${stepStatusText('stopped')}_`;
792
- } else {
793
- elements.push({ tag: 'markdown', content: `_${stepStatusText('stopped')}_` });
794
- }
795
- sealContent = parsed;
766
+ const events = await this.#mirrorHistory(entry.sessionId, entry.turn);
767
+ const outcome = extractCompletedTurnAnswer(events, { turn: entry.turn });
768
+ if (outcome.endSeq < 0) {
769
+ this.#scheduleMirrorCheck(`recovery\0${key}`, () => this.#sealOrphanMirrors());
770
+ return;
796
771
  }
797
- } catch { /* fall through to an empty stopped card */ }
798
- }
799
- for (let index = 0; index < entry.cardIds.length; index += 1) {
800
- const content = index === entry.cardIds.length - 1 && sealContent
801
- ? JSON.stringify(sealContent)
802
- : null;
803
- if (content === null) continue;
804
- await this.#patchStepCard(entry.cardIds[index], content)
805
- .catch((error) => {
806
- this.#logger.warn?.('[dsh-feishu] orphan mirror seal failed:', error?.message ?? error);
807
- });
808
- }
809
- for (let index = 0; index < entry.cardIds.length; index += 1) {
810
- const isLive = index === entry.cardIds.length - 1;
811
- const content = isLive
812
- ? (sealContent
813
- ? JSON.stringify({ ...sealContent, data: JSON.stringify({
814
- ...(JSON.parse(entry.lastContent).data ? JSON.parse(entry.lastContent).data : {}),
815
- }) })
816
- : stepStreamCard([], { status: 'stopped' }))
817
- : stepStreamCard([], { status: 'stopped' });
818
- await this.#patchStepCard(entry.cardIds[index], content)
819
- .catch((error) => {
820
- this.#logger.warn?.('[dsh-feishu] orphan mirror seal failed:', error?.message ?? error);
821
- });
822
- }
823
- await this.#state.clearMirror?.(sessionId);
824
- this.#logger.warn?.(
825
- `[dsh-feishu] sealed ${entry.cardIds.length} orphan mirror card(s) for ${sessionId}`,
826
- );
772
+ if (this.#restoreMirrorCard(key, entry)) {
773
+ const result = await this.#finishStepCard(key, {
774
+ answerText: outcome.text, stopped: outcome.reason !== 'completed',
775
+ });
776
+ if (!result?.ok) throw new Error('Recovered mirror could not deliver its final answer');
777
+ await this.#state.clearMirror?.(key);
778
+ return;
779
+ }
780
+ const content = JSON.parse(entry.lastContent);
781
+ const elements = content?.body?.elements;
782
+ if (!Array.isArray(elements)) return;
783
+ const last = elements.at(-1);
784
+ const status = `_${stepStatusText(outcome.reason === 'completed' ? 'completed' : 'stopped')}_`;
785
+ if (last?.tag === 'markdown' && /^_.*_$/s.test(last.content ?? '')) last.content = status;
786
+ else elements.push({ tag: 'markdown', content: status });
787
+ // Earlier chunks are sealed history; update only the live card.
788
+ await this.#patchStepCard(entry.cardIds.at(-1), JSON.stringify(content));
789
+ await this.#state.clearMirror?.(key);
790
+ } catch (error) {
791
+ this.#logger.warn?.('[dsh-feishu] mirror recovery failed:', error?.message ?? error);
792
+ this.#scheduleMirrorCheck(`recovery\0${key}`, () => this.#sealOrphanMirrors());
793
+ }
794
+ });
827
795
  }
828
796
  }
829
797
 
@@ -3354,12 +3322,14 @@ export class FeishuHarnessBridge {
3354
3322
  this.#eventWatcher = this.#harness.watchHarnessEvents({
3355
3323
  signal,
3356
3324
  onSessionEvent: (payload) => {
3357
- console.error('[ss-final] mux:', payload?.sessionId?.slice(-12), payload?.event?.type);
3358
3325
  this.#onHarnessEvent(payload);
3359
3326
  },
3360
3327
  onReconnect: () => {
3361
3328
  void this.#compensateMissedEvents();
3362
3329
  void this.#deferred.resume();
3330
+ for (const mirror of this.#sessionSyncTurns.values()) {
3331
+ if (!mirror.finishedAt) void this.#checkMirror(mirror);
3332
+ }
3363
3333
  },
3364
3334
  });
3365
3335
  Promise.resolve(this.#eventWatcher).catch((error) => {
@@ -3698,179 +3668,177 @@ export class FeishuHarnessBridge {
3698
3668
  );
3699
3669
  }
3700
3670
 
3701
- /** Queue live turn completions behind any reconnect compensation. */
3702
- /**
3703
- * Mirror one Harness session event into the session-sync process card for
3704
- * this session (a Web/CLI-initiated turn with a synced DM target). Events
3705
- * are translated into the same update shapes the ask callbacks produce, so
3706
- * the regular #stepCards ladder renders them identically: tool/call ->
3707
- * tool block, assistant/message -> live answer draft, turn/end -> sealed
3708
- * terminal card with the final answer.
3709
- */
3710
- /** True when this session's running turn was opened by one of OUR asks. */
3671
+ /** True while this bridge owns the IM ask; capture before queuing events. */
3711
3672
  #isImTurn(sessionId) {
3712
- for (const imKey of this.#imTurnKeys) {
3713
- if (this.#state.sessionFor?.(imKey) === sessionId) return true;
3673
+ for (const key of this.#imTurnKeys) {
3674
+ if (this.#state.sessionFor?.(key) === sessionId) return true;
3714
3675
  }
3715
3676
  return false;
3716
3677
  }
3717
3678
 
3718
- #beginImTurn(key) {
3719
- this.#imTurnKeys.add(key);
3720
- }
3679
+ #beginImTurn(key) { this.#imTurnKeys.add(key); }
3680
+ #endImTurn(key) { this.#imTurnKeys.delete(key); }
3721
3681
 
3722
- #endImTurn(key) {
3723
- this.#imTurnKeys.delete(key);
3682
+ #mirrorKey(sessionId, turn) { return `session-sync\0${sessionId}\0${turn}`; }
3683
+
3684
+ #restoreMirrorCard(key, entry) {
3685
+ if (!Array.isArray(entry?.blocks) || !entry.cardIds?.length) return false;
3686
+ const card = this.#ensureStepCard(key, entry.chatId, null);
3687
+ Object.assign(card, {
3688
+ blocks: structuredClone(entry.blocks), cardIds: [...entry.cardIds],
3689
+ messageId: entry.cardIds.at(-1), chunkCount: entry.cardIds.length,
3690
+ answerStart: entry.answerStart ?? null, answerEnd: entry.answerEnd ?? null,
3691
+ deliveryViaOpenId: true,
3692
+ });
3693
+ return true;
3724
3694
  }
3725
3695
 
3726
- /**
3727
- * The session-event mux forwards surfaced events (user/tool/assistant) but
3728
- * NOT turn/start|turn/end, so the mirror cannot observe the turn boundary
3729
- * directly. Instead, arm an idle timer on every event: when no event
3730
- * arrives for MIRROR_IDLE_SEAL_MS, the turn is over — seal the card with
3731
- * its current content (answer draft included) as completed.
3732
- */
3733
- #armSessionSyncIdleTimer(sessionId, key, openId) {
3734
- const previous = this.#sessionSyncIdleTimers.get(sessionId);
3735
- if (previous) clearTimeout(previous);
3696
+ #scheduleMirrorCheck(key, task) {
3697
+ if (this.#signal?.aborted || this.#sessionSyncIdleTimers.has(key)) return;
3736
3698
  const timer = setTimeout(() => {
3737
- this.#sessionSyncIdleTimers.delete(sessionId);
3738
- const card = this.#stepCards.get(key);
3739
- if (!card || card.broken) return;
3740
- void this.#finishStepCard(key, { stopped: false, answerText: null })
3741
- .then(() => this.#state.clearMirror?.(sessionId))
3742
- .catch((error) => {
3743
- console.error('[ss-final] idle seal failed:', error?.message ?? error);
3744
- });
3745
- }, MIRROR_IDLE_SEAL_MS);
3746
- this.#sessionSyncIdleTimers.set(sessionId, timer);
3699
+ this.#sessionSyncIdleTimers.delete(key);
3700
+ if (!this.#signal?.aborted) void task();
3701
+ }, MIRROR_CHECK_MS);
3702
+ timer.unref?.();
3703
+ this.#sessionSyncIdleTimers.set(key, timer);
3747
3704
  }
3748
3705
 
3749
- async #feedSessionSyncTurn(sessionId, event) {
3750
- if (this.#signal?.aborted) return;
3751
- const key = `session-sync\0${sessionId}`;
3752
- const type = event?.type;
3753
- const target = this.#sessionSyncTargets.get(sessionId);
3754
- const openId = typeof target === 'string' ? target : target?.openId;
3755
- this.#armSessionSyncIdleTimer(sessionId, key, openId);
3756
-
3757
- if (type === 'turn/start') {
3758
- if (this.#stepCards.has(key)) {
3759
- return;
3706
+ async #mirrorHistory(sessionId, turn) {
3707
+ if (typeof this.#harness.rpc !== 'function') return null;
3708
+ const events = [];
3709
+ let beforeSeq;
3710
+ for (let page = 0; page < 10; page += 1) {
3711
+ const history = await this.#harness.rpc('session.history', {
3712
+ sessionId, maxMessages: 100,
3713
+ ...(beforeSeq === undefined ? {} : { beforeSeq }),
3714
+ }, 10_000, { signal: this.#signal });
3715
+ const batch = orderedHistoryEvents(history);
3716
+ events.unshift(...batch);
3717
+ if (!history?.hasMore || batch.some((event) => event.type === 'turn/start' && event.data?.turn === turn)) {
3718
+ return events;
3760
3719
  }
3761
- if (this.#isImTurn(sessionId)) {
3762
- return;
3763
- }
3764
- const targets = await this.#sessionSyncTargetsFor?.(sessionId);
3765
- const owned = (Array.isArray(targets) ? targets : [])
3766
- .find((target) => target.botId === this.#botId);
3767
- if (!owned?.openId) {
3768
- return;
3769
- }
3770
- this.#sessionSyncTargets.set(sessionId, owned);
3771
- // chatId carries the openId; #sendCard branches on the delivery marker.
3772
- this.#ensureStepCard(key, owned.openId, null);
3773
- this.#stepCards.get(key).deliveryViaOpenId = true;
3774
- this.#stepCards.get(key).sessionSyncSessionId = sessionId;
3775
- this.#stepCards.get(key).sessionSyncTargetId = owned.targetId ?? '';
3776
- // Persist the mirror so a restart can seal an orphaned running card.
3777
- await this.#state.setMirror?.(sessionId, { chatId: owned.openId, targetId: owned.targetId ?? '', cardIds: [], claimedAt: Date.now() });
3778
- // Claim only THIS target: the coordinator suppresses its plain text for
3779
- // the mirrored target while other synced targets keep their delivery.
3780
- claimSessionSyncMirror(sessionId, owned.targetId ?? '');
3781
- return;
3720
+ const oldest = batch[0]?.seq;
3721
+ if (!validEventSeq(oldest) || oldest === beforeSeq) break;
3722
+ beforeSeq = oldest;
3782
3723
  }
3724
+ return null; // A truncated history cannot prove the final answer is complete.
3725
+ }
3783
3726
 
3784
- const card = this.#stepCards.get(key);
3785
- if (!card) {
3786
- // The bridge (re)started mid-turn: adopt the running turn on its first
3787
- // visible event so the mirror still renders from here on.
3788
- if ((type === 'assistant/message' || type === 'tool/call')
3789
- && !this.#sessionSyncAdopting.has(sessionId)
3790
- && !this.#isImTurn(sessionId)) {
3791
- this.#sessionSyncAdopting.add(sessionId);
3792
- Promise.resolve()
3793
- .then(() => this.#sessionSyncTargetsFor?.(sessionId))
3794
- .then((targets) => {
3795
- const owned = (Array.isArray(targets) ? targets : [])
3796
- .find((target) => target.botId === this.#botId);
3797
- if (!owned?.openId) return null;
3798
- this.#sessionSyncTargets.set(sessionId, owned);
3799
- // Adopt = open the mirror card NOW, then handle this event.
3800
- this.#ensureStepCard(key, owned.openId, null);
3801
- this.#stepCards.get(key).deliveryViaOpenId = true;
3802
- this.#stepCards.get(key).sessionSyncSessionId = sessionId;
3803
- this.#stepCards.get(key).sessionSyncTargetId = owned.targetId ?? '';
3804
- void this.#state.setMirror?.(sessionId, { chatId: owned.openId, targetId: owned.targetId ?? '', cardIds: [], claimedAt: Date.now() });
3805
- claimSessionSyncMirror(sessionId, owned.targetId ?? '');
3806
- return this.#feedSessionSyncTurn(sessionId, event);
3807
- })
3808
- .catch((error) => {
3809
- this.#logger.warn?.('[dsh-feishu] session-sync adopt failed:', error?.message ?? error);
3810
- })
3811
- .finally(() => this.#sessionSyncAdopting.delete(sessionId));
3727
+ #checkMirror(mirror) {
3728
+ return this.#queueEventTask(mirror.sessionId, async () => {
3729
+ if (mirror.finishedAt || this.#signal?.aborted) return;
3730
+ try {
3731
+ const events = await this.#mirrorHistory(mirror.sessionId, mirror.turn);
3732
+ const outcome = extractCompletedTurnAnswer(events, { turn: mirror.turn });
3733
+ if (outcome.endSeq >= 0) {
3734
+ await this.#finishSessionSyncMirror(mirror, outcome.text ?? mirror.assistant.text, outcome.reason);
3735
+ }
3736
+ } catch (error) {
3737
+ this.#logger.warn?.('[dsh-feishu] mirror history check failed:', error?.message ?? error);
3812
3738
  }
3813
- return;
3814
- }
3815
- // A broken card must not swallow the turn boundary: turn/end still needs
3816
- // to release the mirror claim and clear the state so later turns and the
3817
- // plain-text fallback work again.
3818
- if (card.broken && type !== 'turn/end') return;
3739
+ if (!mirror.finishedAt) this.#scheduleMirrorCheck(mirror.key, () => this.#checkMirror(mirror));
3740
+ });
3741
+ }
3819
3742
 
3820
- if (type === 'user/message' && event?.surfaceOp === 'append') {
3821
- // The coordinator's plain-text user echo is suppressed for mirrored
3822
- // turns, so the card carries the question itself: a quoted block at
3823
- // the top keeps the DM self-contained and readable in history.
3824
- const text = textFromHarnessContent(event?.data?.content);
3743
+ async #finishSessionSyncMirror(mirror, text, reason = 'completed') {
3744
+ if (mirror.finishedAt) return mirror.result?.ok === true && mirror.result.text === text;
3745
+ const recovering = mirror.recovered;
3746
+ if (mirror.recovered) {
3747
+ const events = await this.#mirrorHistory(mirror.sessionId, mirror.turn);
3748
+ const outcome = extractCompletedTurnAnswer(events, { turn: mirror.turn });
3749
+ if (outcome.endSeq < 0) return false;
3750
+ text = outcome.text ?? text;
3751
+ reason = outcome.reason;
3752
+ mirror.recovered = false;
3753
+ }
3754
+ clearTimeout(this.#sessionSyncIdleTimers.get(mirror.key));
3755
+ this.#sessionSyncIdleTimers.delete(mirror.key);
3756
+ const result = await this.#finishStepCard(mirror.key, {
3757
+ stopped: reason !== 'completed', answerText: text,
3758
+ });
3759
+ mirror.result = { ok: result?.ok === true, text };
3760
+ mirror.finishedAt = Date.now();
3761
+ // Keep the last successful snapshot on failure so recovery can preserve it.
3762
+ if (result?.ok) await this.#state.clearMirror?.(mirror.key);
3763
+ else if (recovering) {
3764
+ this.#sessionSyncTurns.delete(mirror.key);
3765
+ this.#scheduleMirrorCheck(`recovery\0${mirror.key}`, () => this.#sealOrphanMirrors());
3766
+ }
3767
+ return result?.ok === true;
3768
+ }
3769
+
3770
+ async #deliverSessionSyncMirror({ target, sessionId, turn, text }) {
3771
+ if (this.#signal?.aborted) return false;
3772
+ return await this.#queueEventTask(sessionId, async () => {
3773
+ const mirror = this.#sessionSyncTurns.get(this.#mirrorKey(sessionId, turn));
3774
+ if (!mirror || mirror.target.targetId !== target.targetId) return false;
3775
+ return this.#finishSessionSyncMirror(mirror, text);
3776
+ }) === true;
3777
+ }
3778
+
3779
+ async #feedSessionSyncTurn(sessionId, event, imOwned = false) {
3780
+ if (this.#signal?.aborted || imOwned) return;
3781
+ const type = event.type;
3782
+ // Retain recent receipts for coordinator callbacks that lag the event mux.
3783
+ for (const [key, prior] of this.#sessionSyncTurns) {
3784
+ if (prior.finishedAt && Date.now() - prior.finishedAt > 300_000) this.#sessionSyncTurns.delete(key);
3785
+ }
3786
+ let turn = event.data?.turn;
3787
+ if (!Number.isSafeInteger(turn)) turn = this.#sessionSyncCurrentTurns.get(sessionId);
3788
+ if (!Number.isSafeInteger(turn)) return;
3789
+ const key = this.#mirrorKey(sessionId, turn);
3790
+ let mirror = this.#sessionSyncTurns.get(key);
3791
+ if (!mirror) {
3792
+ if (!['turn/start', 'user/message', 'assistant/message', 'tool/call'].includes(type)) return;
3793
+ const targets = await this.#sessionSyncTargetsFor(sessionId);
3794
+ const target = (Array.isArray(targets) ? targets : []).find((item) => item.botId === this.#botId);
3795
+ if (!target?.openId || !target?.targetId || this.#signal?.aborted) return;
3796
+ mirror = { key, sessionId, turn, target, assistant: new AssistantTextAccumulator(), pendingStep: null, lastSeq: -1 };
3797
+ this.#sessionSyncTurns.set(key, mirror);
3798
+ this.#sessionSyncCurrentTurns.set(sessionId, turn);
3799
+ const saved = this.#state.mirrorEntries?.().find(([entryKey]) => entryKey === key)?.[1];
3800
+ if (saved?.targetId === target.targetId && saved.chatId === target.openId
3801
+ && this.#restoreMirrorCard(key, saved)) {
3802
+ mirror.recovered = true;
3803
+ mirror.lastSeq = saved.lastSeq ?? -1;
3804
+ mirror.pendingStep = saved.pendingStep ?? null;
3805
+ }
3806
+ const card = this.#ensureStepCard(key, target.openId, null);
3807
+ Object.assign(card, {
3808
+ deliveryViaOpenId: true, sessionSyncSessionId: sessionId,
3809
+ sessionSyncTargetId: target.targetId, sessionSyncTurn: turn, sessionSyncKey: key,
3810
+ });
3811
+ }
3812
+ if (mirror.finishedAt || event.seq <= mirror.lastSeq) return;
3813
+ mirror.lastSeq = event.seq;
3814
+ this.#scheduleMirrorCheck(key, () => this.#checkMirror(mirror));
3815
+ const openId = mirror.target.openId;
3816
+ if (type === 'user/message' && event.surfaceOp === 'append') {
3817
+ const text = textFromHarnessContent(event.data?.content);
3825
3818
  if (text.trim()) {
3826
3819
  const excerpt = text.length > 400 ? `${text.slice(0, 399)}…` : text;
3827
- await this.#appendStepCardUpdate(
3828
- key, openId, null,
3829
- { kind: 'message', text: `> 👤 **我问:**${excerpt.replaceAll('\n', '\n> ')}` },
3830
- { billable: false },
3831
- );
3832
- }
3833
- return;
3834
- }
3835
- if (type === 'tool/call') {
3836
- // Align with the ask-callback semantics: a draft proven interim by a
3837
- // tool call folds into the thinking panel instead of being overwritten
3838
- // by the next draft.
3839
- if (this.#sessionSyncPendingStep.has(sessionId)) {
3840
- this.#morphStepCardAnswerToNote(key, this.#sessionSyncPendingStep.get(sessionId));
3841
- this.#sessionSyncPendingStep.delete(sessionId);
3842
- }
3843
- await this.#appendStepCardUpdate(
3844
- key, openId, null,
3845
- this.#stepCardToolBlock({
3846
- name: event?.data?.name ?? '',
3847
- arguments: typeof event?.data?.arguments === 'string' ? event.data.arguments : '',
3848
- }),
3849
- { billable: false },
3850
- );
3851
- return;
3852
- }
3853
- if (type === 'assistant/message') {
3854
- const text = textFromHarnessContent(event?.data?.message?.content);
3820
+ await this.#appendStepCardUpdate(key, openId, null,
3821
+ { kind: 'message', text: `> 👤 **我问:**${excerpt.replaceAll('\n', '\n> ')}` }, { billable: false });
3822
+ }
3823
+ } else if (type === 'tool/call') {
3824
+ if (mirror.pendingStep) {
3825
+ this.#morphStepCardAnswerToNote(key, mirror.pendingStep);
3826
+ mirror.pendingStep = null;
3827
+ }
3828
+ await this.#appendStepCardUpdate(key, openId, null, this.#stepCardToolBlock({
3829
+ name: event.data?.name ?? '',
3830
+ arguments: typeof event.data?.arguments === 'string' ? event.data.arguments : '',
3831
+ }), { billable: false });
3832
+ } else if (type === 'assistant/message' && event.surfaceOp === 'append' && event.data?.interrupted !== true) {
3833
+ const text = textFromHarnessContent(event.data?.message?.content);
3855
3834
  if (text.trim()) {
3856
- this.#sessionSyncPendingStep.set(sessionId, text);
3835
+ mirror.assistant.setCanonical(event.data?.step, text);
3836
+ mirror.pendingStep = text;
3857
3837
  this.#streamStepCardAnswer(key, openId, null, text);
3858
3838
  }
3859
- return;
3860
- }
3861
- if (type === 'turn/end') {
3862
- this.#sessionSyncTargets.delete(sessionId);
3863
- this.#sessionSyncPendingStep.delete(sessionId);
3864
- releaseSessionSyncMirror(sessionId, card?.sessionSyncTargetId ?? '');
3865
- await this.#finishStepCard(key, {
3866
- stopped: event?.data?.reason?.kind === 'aborted',
3867
- answerText: null,
3868
- });
3869
- // Clear AFTER the seal: the render chain may still write mirror state
3870
- // while it finishes, so clearing earlier would be resurrected by the
3871
- // trailing setMirror from the last successful render.
3872
- await this.#state.clearMirror?.(sessionId);
3873
- return;
3839
+ } else if (type === 'turn/end') {
3840
+ const reason = typeof event.data?.reason === 'string' ? event.data.reason : event.data?.reason?.kind;
3841
+ await this.#finishSessionSyncMirror(mirror, mirror.assistant.text, reason);
3874
3842
  }
3875
3843
  }
3876
3844
 
@@ -3888,14 +3856,8 @@ export class FeishuHarnessBridge {
3888
3856
  // IM-opened turns are skipped — they already own their card via the ask
3889
3857
  // callbacks. turn/end ALSO continues below for watch completions.
3890
3858
  if (this.#sessionSyncTargetsFor) {
3891
- if (event.type === 'turn/end') {
3892
- console.error('[ss-final] turn/end reached dispatcher:', sessionId);
3893
- }
3894
- void this.#queueEventTask(`session-sync\0${sessionId}`, async () => {
3895
- await this.#feedSessionSyncTurn(sessionId, event);
3896
- }).catch((error) => {
3897
- console.error('[ss-final] mirror task failed:', event?.type, error?.message ?? error);
3898
- });
3859
+ const imOwned = this.#isImTurn(sessionId);
3860
+ void this.#queueEventTask(sessionId, () => this.#feedSessionSyncTurn(sessionId, event, imOwned));
3899
3861
  if (event.type !== 'turn/end') return;
3900
3862
  }
3901
3863
  if (event.type !== 'turn/end') return;
@@ -4370,14 +4332,18 @@ export class FeishuHarnessBridge {
4370
4332
  * their lifecycle is owned by the ask path, not the mirror recovery.
4371
4333
  * lastContent keeps stepStreamCard's raw JSON string (single-encoded).
4372
4334
  */
4373
- #persistMirrorState(card, liveBlocks, status) {
4335
+ async #persistMirrorState(card, liveBlocks, status) {
4374
4336
  const sessionId = card.sessionSyncSessionId;
4375
4337
  if (!sessionId || typeof this.#state.setMirror !== 'function') return;
4376
- void this.#state.setMirror(sessionId, {
4338
+ await this.#state.setMirror(card.sessionSyncKey, {
4339
+ sessionId, turn: card.sessionSyncTurn, targetId: card.sessionSyncTargetId,
4377
4340
  chatId: card.chatId,
4378
- cardIds: card.cardIds,
4341
+ cardIds: [...card.cardIds],
4379
4342
  claimedAt: Date.now(),
4380
4343
  lastContent: stepStreamCard(liveBlocks, { status }),
4344
+ blocks: structuredClone(card.blocks), answerStart: card.answerStart, answerEnd: card.answerEnd,
4345
+ lastSeq: this.#sessionSyncTurns.get(card.sessionSyncKey)?.lastSeq ?? -1,
4346
+ pendingStep: this.#sessionSyncTurns.get(card.sessionSyncKey)?.pendingStep ?? null,
4381
4347
  });
4382
4348
  }
4383
4349
 
@@ -4405,7 +4371,7 @@ export class FeishuHarnessBridge {
4405
4371
  if (isLive) card.messageId = id;
4406
4372
  }
4407
4373
  card.chunkCount = chunks.length;
4408
- this.#persistMirrorState(card, live, 'running');
4374
+ await this.#persistMirrorState(card, live, 'running');
4409
4375
  card.lastRenderAt = this.#stepPushClock.now();
4410
4376
  card.renderedAnswerVersion = card.answerVersion ?? 0;
4411
4377
  return;
@@ -4431,10 +4397,10 @@ export class FeishuHarnessBridge {
4431
4397
  if (isLive) card.messageId = id;
4432
4398
  }
4433
4399
  card.chunkCount = chunks.length;
4434
- this.#persistMirrorState(card, live, 'running');
4435
4400
  } else {
4436
4401
  await this.#patchStepCard(card.messageId, stepStreamCard(live, { status: 'running' }));
4437
4402
  }
4403
+ await this.#persistMirrorState(card, live, 'running');
4438
4404
  card.lastRenderAt = this.#stepPushClock.now();
4439
4405
  card.renderedAnswerVersion = card.answerVersion ?? 0;
4440
4406
  } catch (error) {
@@ -4446,15 +4412,6 @@ export class FeishuHarnessBridge {
4446
4412
  if (card.deliveryViaOpenId) {
4447
4413
  this.#logger.warn?.('[dsh-feishu] session-sync mirror card render failed:',
4448
4414
  error?.message ?? String(error));
4449
- // Release the mirror claim at the FIRST failure so the plain-text
4450
- // coordinator takes over delivery for the rest of the turn (its
4451
- // user-echo suppression lifts immediately, and its recipients stay
4452
- // usable for the final answer fallback).
4453
- const sessionId = card.sessionSyncSessionId;
4454
- if (sessionId) {
4455
- releaseSessionSyncMirror(sessionId, card.sessionSyncTargetId ?? '');
4456
- void this.#state.clearMirror?.(sessionId);
4457
- }
4458
4415
  }
4459
4416
  }
4460
4417
  }
@@ -5,4 +5,6 @@ export default {
5
5
  'The Discord Gateway Intents are misconfigured. Please check the Bot settings in the Developer Portal.',
6
6
  'Discord机器人': 'Discord Bot',
7
7
  ' Gateway 长连接': ' Gateway long-lived connection',
8
+ 'Thread 创建结果暂时无法确认。若已创建,请在对应 Thread 中重试;若未创建,请稍后重新 @机器人。':
9
+ 'The Thread creation result cannot be confirmed yet. If the Thread was created, retry inside it; if it was not, mention the bot again shortly.',
8
10
  };
@@ -1,5 +1,10 @@
1
1
  // English translations (telegram area). Keys are exact Chinese literals passed to t().
2
2
  export default {
3
+ // Terminal status written back over a placeholder whose in-place edit was
4
+ // rejected, so it is the last thing a reader sees on a degraded reply.
5
+ '回复已发送。': 'The reply was sent.',
6
+ '回复发送结果未能确认。': 'The reply delivery result could not be confirmed.',
7
+ '消息发送失败,请稍后重试。': 'The message could not be sent. Try again later.',
3
8
  '开启一个全新会话': 'Start a brand-new Session',
4
9
  '压缩当前会话的较早上下文': 'Compact the earlier context of the current Session',
5
10
  '切换工作区': 'Switch Workspace',