@vibexnpm/talkx 4.0.0 → 5.1.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.
@@ -70,11 +70,73 @@ class ChatClient extends EventEmitter {
70
70
  this._assistantTypingUserId = '__assistant__';
71
71
  this._assistantTypingUserName = 'AI';
72
72
 
73
- // AI 진행 표시(생각중/검색중/작성중) — assistant-stream 채널의 PHASE/DONE 수신 상태.
74
- // roomId → { streamId, personaId, phase, timer }. typing 과 별개의 가산 레이어(서버가 둘 다 발행).
75
- // 자동 해제 3경로: (1) DONE 수신 (2) 어시 메시지 도착 (3) TTL(expiresAt) 만료 — typing 타이머와 동형.
76
- // 현재는 PM_BACKSTAGE(방당 단일 PM) 만 phase 를 발행하므로 방 단위 1개 진행으로 추적한다.
73
+ // AI 진행 표시(생각중/검색중/작성중) — assistant-stream 채널의 PHASE/DELTA/DONE 수신 상태.
74
+ //
75
+ // roomId → Map(streamId → { personaId, phase, text, lastSeq, textAvailable, updatedAt, timer })
76
+ //
77
+ // ⚠️ v5.0.0 에서 <b>방 단위 1슬롯 → stream 단위 다중 슬롯</b>으로 바꿨다(설계 §7.1). 한 방에 동시
78
+ // 스트림이 <b>실재</b>하기 때문이다 — 어시 리스너가 @Async 이고 방 단위 직렬화 락이 없어서, PM 방에
79
+ // 트리거가 겹치면 각각 streamId 를 발급받아 동시에 산다. 1슬롯이면 나중 것이 앞 것을 덮어 한쪽이
80
+ // 통째로 사라진다(그게 v4.x 의 "stale 무시" 가드가 하던 일이다).
81
+ //
82
+ // 자동 해제는 **2경로**다: (1) DONE 수신 (2) TTL(expiresAt) 만료.
83
+ // ⚠️ 어시 메시지 도착은 해제 경로가 <b>아니다</b>(v4.2.0, 설계 §7.3) — 서버가 MESSAGE_CREATED 를
84
+ // DONE 보다 먼저 보내 그 시점엔 messageId → streamId 를 알 수 없다.
85
+ //
86
+ // lastSeq/textAvailable = seq 가드용 커서(설계 §5.5.0). 커서 초기값은 -1 이다 — 첫 청크가 seq=0.
77
87
  this._assistantProgress = new Map();
88
+
89
+ // 종료된 streamId 의 묘비 — roomId → Map(streamId → expiresAt).
90
+ //
91
+ // <b>다중 슬롯 전환의 대가다.</b> 1슬롯 시절엔 "streamId 가 다르면 버린다"가 늦은 이벤트를 막는
92
+ // race 차단 장치였는데, 슬롯이 여러 개가 되면 그 가드가 사라진다. 그러면 DONE 으로 닫힌 스트림의
93
+ // 늦은 DELTA 가 <b>새 슬롯을 만들어 유령 버블로 되살아난다</b>. 서버 gate 가 terminal 직후 제거를
94
+ // 금지하고 TTL 로만 회수하는 것과 같은 이유이고 같은 해법이다(설계 §7.2).
95
+ //
96
+ // ⚠️ 등록 트리거는 <b>DONE 하나뿐</b>이다. 클라 TTL 만료는 "표시가 낡았다"이지 서버의 확정 종료가
97
+ // 아니라서, 그걸로 묘비를 세우면 뒤늦게 오는 정상 DELTA 가 영영 막힌다.
98
+ //
99
+ // 회수는 <b>lazy</b> 다 — 만료분은 같은 방에 다음 DONE 이 오거나 그 streamId 이벤트가 올 때 지워진다.
100
+ // 별도 타이머를 두지 않는 이유: 항목이 문자열+숫자뿐이고 방 구독을 끊으면 방 단위로 통째 삭제되므로
101
+ // 상한이 자연히 걸린다. 타이머 하나를 더 관리하는 비용이 이득보다 크다.
102
+ this._assistantStreamTombstones = new Map();
103
+ this._assistantTombstoneTtlMs = 120000;
104
+ // ⚠️ <b>상한이 아니라 경보 임계</b>다. 넘어도 유효한 묘비를 지우지 않는다(지우면 유령이 되살아난다).
105
+ this._assistantTombstoneWarnThreshold = 32;
106
+ /** 임계 초과 경보를 방마다 1회만 내기 위한 표식 — 넘은 동안 DONE 마다 짖지 않게. @private */
107
+ this._assistantTombstoneWarned = new Set();
108
+
109
+ // 슬롯 갱신 순번 — "가장 최근에 갱신된 stream" 판정용.
110
+ // ⚠️ 벽시계(Date.now)로는 안 된다. 밀리초 해상도라 연속 갱신이 <b>동률</b>이 되고, 동률이면
111
+ // 순회 순서가 승자를 정해 <b>다른 stream 의 본문이 방 버블에 뜬다</b>. 단조 카운터가 유일하게 안전하다.
112
+ this._assistantSlotSeq = 0;
113
+
114
+ // 방별 세대 카운터 — 구독/해제마다 증가. 스냅샷 응답이 "아직 같은 세대인가"를 판정하는 축이다
115
+ // (늦게 도착한 응답이 이미 떠난 방의 슬롯을 되살리는 것을 막는다, 설계 §6.1-a).
116
+ this._assistantRoomGeneration = new Map();
117
+
118
+ // ── 스냅샷 재개 (설계 §6.1, R-4) ──
119
+ //
120
+ // 스트림은 Pub/Sub 라 <b>구독 시점 이후만</b> 온다. 재입장·중간입장 사용자가 앞부분을 받는 유일한
121
+ // 경로가 서버 스냅샷이고, 그걸 라이브와 이어붙이는 순서가 이 세 자료구조에 걸려 있다.
122
+ //
123
+ // roomId → { generation, events: [] } : 스냅샷 도착 전 라이브 이벤트 보류함.
124
+ //
125
+ // ⚠️ <b>구독보다 먼저</b> 켜야 한다. 구독 호출과 보류 플래그 사이에 DELTA 가 오면 그건 보류되지
126
+ // 않고 그대로 렌더돼, 스냅샷 병합 전에 화면이 잘린 본문으로 먼저 그려진다.
127
+ //
128
+ // ⚠️ 큐는 <b>방 단위 단일 배열</b>이다. stream 별로 쪼개면 A0→B0→A1 로 도착한 것이 A0→A1→B0 로
129
+ // 풀려 "가장 최근 stream" 판정이 뒤집히고 방 집계 버블이 엉뚱한 본문을 보여준다 — R-3 이 단조
130
+ // 카운터로 세운 정합성을 drain 이 다시 깨는 자리다.
131
+ //
132
+ // ⚠️ 세대를 함께 들고 있어야 한다. 안 그러면 이전 세대의 늦은 응답이 <b>현재 세대의 보류분을
133
+ // 통째로 삭제</b>한다.
134
+ this._assistantSnapshotPending = new Map();
135
+ // roomId → { generation, task } : 진행 중인 스냅샷 요청. <b>같은 세대일 때만</b> 재사용한다 —
136
+ // 다른 세대 요청을 재사용하면 그 응답이 세대 불일치로 버려지면서 이번 요청까지 함께 사라진다.
137
+ this._assistantSnapshotInFlight = new Map();
138
+ // 스냅샷 재개 사용 여부 — 서버가 구버전이면 404/빈 배열이라 자동으로 무해하지만, 끄고 싶을 때를 위해.
139
+ this.assistantStreamReplay = options.assistantStreamReplay !== false;
78
140
  // phase → 기본 표시 라벨(설계 §2 — 서버는 의미만, 라벨 텍스트는 SDK 매핑). 소비자는 payload.phase 로 커스텀 가능.
79
141
  this._assistantPhaseLabels = {
80
142
  THINKING: '생각 중…',
@@ -134,13 +196,20 @@ class ChatClient extends EventEmitter {
134
196
  _installSyncTriggers() {
135
197
  if (!this.connectionManager || typeof this.connectionManager.on !== 'function') return;
136
198
 
199
+ // ⚠️ 어시 스트림 재개는 <b>메시지 갭 복구와 별도 경로</b>다(설계 §7.4). syncAfterReconnect 는
200
+ // syncOnReconnect 옵션 + anchor 있는 방이라는 두 겹 필터가 걸려 있어서, 거기 얹으면 옵션이
201
+ // 꺼져 있거나 그 방에서 메시지를 받은 적이 없을 때 replay 가 조용히 빠진다.
137
202
  this._onReconnectedForSync = ({ attempts } = {}) => {
138
203
  this.syncAfterReconnect(`reconnected:${attempts ?? 0}`)
139
204
  .catch((error) => this.logger.warn('Gap sync after reconnect failed:', error));
205
+ this.resumeAssistantStreams(`reconnected:${attempts ?? 0}`)
206
+ .catch((error) => this.logger.warn('Assistant stream replay after reconnect failed:', error));
140
207
  };
141
208
  this._onResumedForSync = ({ reason } = {}) => {
142
209
  this.syncAfterReconnect(`resumed:${reason ?? 'unknown'}`)
143
210
  .catch((error) => this.logger.warn('Gap sync after resume failed:', error));
211
+ this.resumeAssistantStreams(`resumed:${reason ?? 'unknown'}`)
212
+ .catch((error) => this.logger.warn('Assistant stream replay after resume failed:', error));
144
213
  };
145
214
 
146
215
  this.connectionManager.on('reconnected', this._onReconnectedForSync);
@@ -1344,6 +1413,9 @@ class ChatClient extends EventEmitter {
1344
1413
  * @param {Object[]} [data.fileInfos] - 파일 메타데이터 배열. 각 원소는 <b>UUID {@code fileId} 필수</b>
1345
1414
  * ({@link #uploadFile} 반환값은 이미 포함). 외부 스토리지 직접 업로드 연동은 직접 채워야 한다.
1346
1415
  * @param {string} [data.replyToMessageId] - 답글 대상 원본 메시지 ID (옵션)
1416
+ * @param {Object} [data.decisionResponse] - 의사결정 카드의 선택 — {@code { optionId }} (옵션).
1417
+ * 카드 버튼을 누르면 <b>평범한 답글</b>로 보낸다: {@code replyToMessageId} = 카드의 messageId,
1418
+ * {@code message} = 고른 선택지의 {@code label}, 여기에 {@code optionId}. 전용 endpoint 는 없다.
1347
1419
  * @returns {Promise<{duplicate: boolean, messages: Object[]}>}
1348
1420
  * - {@code duplicate}: 멱등 재시도로 기존 결과를 반환한 경우 {@code true}
1349
1421
  * - {@code messages}: 저장된 메시지 배열. {@code messageId} 는 서버가 부여한 ID.
@@ -1361,6 +1433,15 @@ class ChatClient extends EventEmitter {
1361
1433
  * });
1362
1434
  *
1363
1435
  * @example
1436
+ * // 의사결정 카드의 선택 — 답글 하나로 나간다 (새 API 없음)
1437
+ * const option = card.decisionRequest.options[0];
1438
+ * await chat.sendMessage('room-id', {
1439
+ * message: option.label, // 폴백 가독성 — 카드를 못 그리는 화면에서도 대화가 읽힌다
1440
+ * replyToMessageId: card.messageId,
1441
+ * decisionResponse: { optionId: option.id }
1442
+ * });
1443
+ *
1444
+ * @example
1364
1445
  * // 첨부 3장 + 캡션 — 메시지 1건
1365
1446
  * const { messages } = await chat.sendMessage('room-id', {
1366
1447
  * message: '사진 보냅니다',
@@ -1439,7 +1520,10 @@ class ChatClient extends EventEmitter {
1439
1520
  messageId,
1440
1521
  message: data.message,
1441
1522
  fileInfos: data.fileInfos,
1442
- replyToMessageId: data.replyToMessageId
1523
+ replyToMessageId: data.replyToMessageId,
1524
+ // 의사결정 카드의 선택. 🔴 이 줄이 없으면 호출자가 넘긴 값이 <b>조용히 유실</b>된다 —
1525
+ // 이 payload 는 화이트리스트라 data 에 있어도 전달되지 않는다.
1526
+ decisionResponse: data.decisionResponse
1443
1527
  });
1444
1528
 
1445
1529
  const result = this._unwrapSuccessResponse(response);
@@ -1735,6 +1819,44 @@ class ChatClient extends EventEmitter {
1735
1819
  return this.sendMessageOptimistic(roomId, { message, replyToMessageId });
1736
1820
  }
1737
1821
 
1822
+ /**
1823
+ * 의사결정 카드의 선택 전송 (간편 버전).
1824
+ *
1825
+ * <p>세 가지를 <b>한 번에 맞춰</b> 보낸다 — 셋 중 하나만 빠져도 계약이 깨지므로 직접 조립하기보다
1826
+ * 이 메서드를 쓰는 편이 안전하다:</p>
1827
+ * <ul>
1828
+ * <li>{@code replyToMessageId} = 카드 messageId — 어느 질문에 답한 것인지</li>
1829
+ * <li>{@code message} = 고른 선택지의 {@code label} — <b>가독 폴백</b>. 빈 본문이면 대화에
1830
+ * 빈 칸이 남아 나중에 읽을 때 무엇을 골랐는지 알 수 없다</li>
1831
+ * <li>{@code decisionResponse.optionId} — 서버가 보는 <b>권위</b> 값</li>
1832
+ * </ul>
1833
+ *
1834
+ * <p>전용 endpoint 가 아니라 <b>평범한 답글</b>이다. 그래서 선택이 대화에 메시지로 남고, 카드를
1835
+ * 못 그리는 화면에서도 흐름이 읽힌다.</p>
1836
+ *
1837
+ * @param {string} roomId - 채팅방 ID
1838
+ * @param {Object} card - 카드 메시지 ({@code decisionRequest} 를 가진 그 메시지)
1839
+ * @param {Object} option - 사용자가 고른 {@code card.decisionRequest.options[i]}
1840
+ * @returns {Promise<{duplicate: boolean, messages: Object[]}>}
1841
+ *
1842
+ * @example
1843
+ * const card = messages.find(m => m.decisionRequest);
1844
+ * await chat.sendDecisionChoice('room-id', card, card.decisionRequest.options[1]);
1845
+ */
1846
+ async sendDecisionChoice(roomId, card, option) {
1847
+ if (!card || !card.messageId) {
1848
+ throw new Error('sendDecisionChoice: card with messageId is required');
1849
+ }
1850
+ if (!option || !option.id) {
1851
+ throw new Error('sendDecisionChoice: option with id is required');
1852
+ }
1853
+ return this.sendMessage(roomId, {
1854
+ message: option.label,
1855
+ replyToMessageId: card.messageId,
1856
+ decisionResponse: { optionId: option.id }
1857
+ });
1858
+ }
1859
+
1738
1860
  /**
1739
1861
  * 메시지 삭제.
1740
1862
  *
@@ -2031,10 +2153,20 @@ class ChatClient extends EventEmitter {
2031
2153
  });
2032
2154
 
2033
2155
  // AI 진행 표시(생각중/검색중/작성중) 구독 — typing 과 별개 채널(가산 UX). 구버전 SDK 는 미구독 → 기존 typing 만.
2156
+ //
2157
+ // ⚠️ 보류함을 <b>구독보다 먼저</b> 연다(설계 §6.1 ①). 구독 호출과 보류 플래그 사이에 DELTA 가
2158
+ // 도착하면 그건 보류되지 않고 그대로 렌더돼, 스냅샷 병합 전에 잘린 본문이 화면에 먼저 그려진다.
2159
+ const snapshotGeneration = this.assistantStreamReplay ? this._armAssistantSnapshot(roomId) : null;
2034
2160
  const assistantStreamDestination = WebSocketPaths.getChatAssistantStreamDestination(roomId);
2035
2161
  await this.connectionManager.subscribe(assistantStreamDestination, (event) => {
2036
2162
  this._handleAssistantStreamEvent(roomId, event);
2037
2163
  });
2164
+ if (snapshotGeneration !== null) {
2165
+ // 구독 직후 스냅샷 — 응답을 기다리지 않는다. 구독은 이미 걸렸으므로 그 사이 이벤트는 보류함에 쌓이고,
2166
+ // 병합이 끝나면 순서대로 풀린다. 실패해도 보류분은 풀리므로 라이브만으로 계속 동작한다.
2167
+ this._loadAssistantSnapshot(roomId, snapshotGeneration)
2168
+ .catch((error) => this.logger.warn(`Assistant snapshot merge failed: room=${roomId}`, error));
2169
+ }
2038
2170
 
2039
2171
  // Rolling 누적 요약 완료 알림 구독 — refreshSummary 완료/실패를 summaryUpdate 이벤트로 통지(transient).
2040
2172
  const summaryDestination = WebSocketPaths.getChatSummaryDestination(roomId);
@@ -2116,8 +2248,8 @@ class ChatClient extends EventEmitter {
2116
2248
  // 타이핑 타이머 정리 (outgoing + incoming assistant 둘 다)
2117
2249
  this._clearTypingTimer(roomId);
2118
2250
  this._clearAssistantTypingTimer(roomId);
2119
- // AI 진행 표시 상태/타이머 정리 (emit 없이 — 구독 해제 중)
2120
- this._clearAssistantProgress(roomId, false);
2251
+ // AI 진행 표시 상태/타이머/묘비 정리 + 세대 증가 (emit 없이 — 구독 해제 중)
2252
+ this._clearAssistantProgress(roomId);
2121
2253
 
2122
2254
  // 현재 보고 있는 방이면 activeRoom 해제
2123
2255
  if (this._activeRoomId === roomId) {
@@ -2333,9 +2465,16 @@ class ChatClient extends EventEmitter {
2333
2465
  typing: false,
2334
2466
  senderType: 'ASSISTANT'
2335
2467
  });
2336
- // AI 진행 표시도 해제 — 최종 메시지 도착 = 진행 종료(설계 §3.1·§4.1, 메시지=진실).
2337
- // DONE 이 유실/지연돼도 메시지로 수렴. active:false emit.
2338
- this._clearAssistantProgress(roomId, true, 'message');
2468
+ // ⚠️ AI 진행 표시(assistantProgress)는 <b>여기서 끄지 않는다</b>
2469
+ // (ASSISTANT_STREAM_REPLAY_DESIGN §7.3).
2470
+ //
2471
+ // 서버 발행 순서가 메시지 → DONE 이라(ChatMessagePublicationWorkflow 가 MESSAGE_CREATED 를
2472
+ // 먼저 내고, InterveneInChatService 가 그 뒤에 DONE(streamId, messageId) 을 낸다) 이 시점의
2473
+ // 클라는 <b>messageId → streamId 관계를 아직 모른다</b>. 그래서 "이 메시지에 해당하는 stream 만
2474
+ // 끈다" 가 불가능하고, 방 단위로 끄면 같은 방의 <b>다른 살아 있는 stream</b> 까지 사라진다.
2475
+ //
2476
+ // 종료 SSOT 는 DONE 이고, DONE 유실 대비 안전망은 TTL(_startAssistantProgressTimer) 이다.
2477
+ // DONE 은 메시지 post 직후에 나오므로 표시가 늦게 꺼지는 체감도 사실상 없다.
2339
2478
  }
2340
2479
 
2341
2480
  // 상대방 메시지일 때만 newMessage + 자동 읽음 처리
@@ -2660,76 +2799,128 @@ class ChatClient extends EventEmitter {
2660
2799
  * 없으면 {@code label}(단계) 표시, {@code active:false} 면 해제. PHASE→DELTA 흐름으로 "작성 중" 라벨이
2661
2800
  * 점진 렌더 텍스트로 자연 전환된다. 서버가 typing 도 이중발행하므로 구버전 SDK 는 본 채널 미구독 → 기존
2662
2801
  * typing 만으로 정상 동작(graceful).</p>
2802
+ *
2803
+ * <p><b>seq 가드</b>(ASSISTANT_STREAM_REPLAY_DESIGN §5.5.0/§7.1-a): 스트림은 Pub/Sub 라 <b>구독 시점
2804
+ * 이후만</b> 수신된다. 방을 나갔다 재입장하거나 WS 가 순단되면 앞부분을 받은 적이 없는데, 예전 구현은
2805
+ * 그 상태에서 도착한 청크를 그대로 누적본으로 렌더해 <b>잘린 본문</b>을 보여줬다. 이제 {@code seq} 로
2806
+ * "앞이 비었다"를 스스로 판정해 본문 대신 {@code label} 로 물러선다({@code textAvailable=false}).
2807
+ * 최종 메시지가 도착하면 어차피 본문이 확정되므로, 잘린 본문을 보여주는 것보다 라벨이 낫다.</p>
2663
2808
  * @private
2664
2809
  */
2665
2810
  _handleAssistantStreamEvent(roomId, event) {
2666
2811
  if (!event || !event.type) return;
2667
2812
 
2813
+ const streamId = event.streamId || null;
2814
+ if (!streamId) {
2815
+ // streamId 없는 이벤트는 슬롯을 키잉할 수 없다. 서버 계약상 always non-null 이라 도달하지 않는다.
2816
+ this.logger.debug(`Assistant stream event without streamId ignored: room=${roomId}, type=${event.type}`);
2817
+ return;
2818
+ }
2819
+ // 종료된 stream 의 늦은 이벤트 — 되살리지 않는다(§7.2). DONE 자체도 중복 도착하면 여기서 걸린다.
2820
+ if (this._isAssistantStreamClosed(roomId, streamId)) {
2821
+ this.logger.debug(`Assistant event for closed stream ignored: room=${roomId}, stream=${streamId}, type=${event.type}`);
2822
+ return;
2823
+ }
2824
+ // 스냅샷 대기 중이면 보류 — 병합 뒤에 seq 규칙으로 이어붙인다(§6.1). 렌더는 아직 하지 않는다.
2825
+ //
2826
+ // ⚠️ <b>DONE 만은 즉시 처리한다</b>(§6.2). 큐에 넣으면 스냅샷의 ACTIVE 가 먼저 적용돼
2827
+ // {@code active:true} 버블이 한 번 떴다가 곧바로 꺼진다. DONE 은 "서버가 끝났다고 명시한" 신호라
2828
+ // 지금 tombstone 을 세워야 스냅샷이 그 stream 을 되살리지 못한다. 같은 stream 의 이미 보류된
2829
+ // PHASE/DELTA 는 drain 때 그 tombstone 에 걸려 자연히 무시된다.
2830
+ const pending = this._assistantSnapshotPending.get(roomId);
2831
+ if (pending && event.type !== 'DONE') {
2832
+ pending.events.push(event);
2833
+ return;
2834
+ }
2835
+
2668
2836
  if (event.type === 'PHASE') {
2837
+ // PHASE 에는 seq 가 없다 — <b>시각이 유일한 순서 축</b>이다. 스냅샷의 phaseUpdatedAt 보다
2838
+ // 오래된 pending PHASE 를 적용하면 라벨이 WRITING → SEARCHING 으로 역행한다(§6.2).
2839
+ const prevSlot = this._assistantSlot(roomId, streamId);
2840
+ const createdAt = typeof event.createdAt === 'number' ? event.createdAt : null;
2841
+ // ⚠️ 등호도 stale 이다. 계약이 "더 최신일 때만 적용"이라 같은 시각이면 적용하면 안 된다 —
2842
+ // 같은 밀리초에 발행된 pending PHASE 가 스냅샷 phase 를 덮어 라벨이 역행한다.
2843
+ if (prevSlot && createdAt !== null && createdAt <= (prevSlot.phaseUpdatedAt || 0)) {
2844
+ this.logger.debug(`Stale assistant PHASE ignored: room=${roomId}, stream=${streamId}, createdAt=${createdAt}, applied=${prevSlot.phaseUpdatedAt}`);
2845
+ return;
2846
+ }
2669
2847
  const phase = event.phase || null;
2670
- const label = (phase && this._assistantPhaseLabels[phase]) || null;
2671
- // 같은 stream 의 PHASE 재발행(B-3 synthesis heartbeat 등)이면 누적 텍스트 보존, 새 stream 이면 리셋.
2672
- const prev = this._assistantProgress.get(roomId);
2673
- const text = (prev && prev.streamId === event.streamId) ? (prev.text || '') : '';
2674
- // 진행 상태 갱신 + TTL 재무장(heartbeat 재수신마다 연장). 같은 방의 이전 타이머는 reset.
2675
- this._startAssistantProgressTimer(roomId, event, { phase, text });
2676
- this.emit('assistantProgress', {
2677
- roomId,
2678
- streamId: event.streamId || null,
2679
- personaId: event.personaId || null,
2848
+ // 같은 stream 의 PHASE 재발행(B-3 synthesis heartbeat 등)이면 누적 상태를 보존한다.
2849
+ // ⚠️ 새 stream 의 커서는 <b>-1</b> 로 연다. DeltaCoalescer 의 첫 청크가 seq=0 이라 0 으로 열면
2850
+ // 첫 글자가 "중복"으로 버려진다 (설계 §5.5.0).
2851
+ const prev = prevSlot;
2852
+ const slot = this._setAssistantSlot(roomId, streamId, event, {
2680
2853
  phase,
2681
- label,
2854
+ phaseUpdatedAt: createdAt !== null ? createdAt : (prev ? prev.phaseUpdatedAt : 0),
2855
+ text: prev ? (prev.text || '') : '',
2856
+ lastSeq: prev ? this._seqOf(prev.lastSeq) : -1,
2857
+ textAvailable: prev ? prev.textAvailable !== false : true
2858
+ });
2859
+ this._emitAssistantStream(roomId, streamId, slot, {
2682
2860
  active: true,
2683
- text: text || null
2861
+ text: slot.textAvailable ? (slot.text || null) : null
2684
2862
  });
2685
2863
  return;
2686
2864
  }
2687
2865
 
2688
2866
  if (event.type === 'DELTA') {
2689
2867
  // 토큰 청크(B) — 누적 텍스트에 이어붙여 점진 렌더용으로 emit. WRITING 단계에서 흐른다.
2690
- const prev = this._assistantProgress.get(roomId);
2691
- // stale DELTA 무시 — 이전 stream 의 늦은 토큰이 새 stream 표시를 오염시키지 않게(DONE 가드와 동형).
2692
- if (prev?.streamId && event.streamId && prev.streamId !== event.streamId) {
2693
- this.logger.debug(`Stale assistant DELTA ignored: room=${roomId}, delta=${event.streamId}, active=${prev.streamId}`);
2868
+ const prev = this._assistantSlot(roomId, streamId);
2869
+ const phase = (prev && prev.phase) || 'WRITING';
2870
+ const seq = typeof event.seq === 'number' ? event.seq : null;
2871
+
2872
+ let lastSeq = prev ? this._seqOf(prev.lastSeq) : -1;
2873
+ let textAvailable = prev ? prev.textAvailable !== false : true;
2874
+ let text = prev ? (prev.text || '') : '';
2875
+
2876
+ // ── seq 취급 규칙 (설계 §5.5.0 SSOT — 소비자 열. 서버 스냅샷 병합도 같은 표를 쓴다) ──
2877
+ // seq <= lastSeq : 중복·지연 도착. <b>장애가 아니다</b> — 조용히 무시.
2878
+ // seq == lastSeq + 1 : 정상 append.
2879
+ // seq > lastSeq + 1 : 실제 gap. 앞이 비었으니 본문을 포기하고 라벨로 물러선다.
2880
+ // 슬롯이 없는 상태의 late join(재입장 직후 seq>0)도 lastSeq=-1 기준으로 마지막 분기에 걸린다.
2881
+ //
2882
+ // ⚠️ <b>커서 판정은 degrade 여부와 무관하다.</b> textAvailable=false 가 멈추는 것은 <b>본문 누적
2883
+ // 뿐</b>이다. 커서까지 멈추면 (1) 이후 중복이 걸러지지 않아 같은 청크가 다시 emit 되고,
2884
+ // (2) lastSeq 가 옛 값에 갇혀 R-4 스냅샷 병합이 잘못된 지점에서 절단한다.
2885
+ if (seq === null) {
2886
+ // 서버가 seq 를 안 실은 경우(구버전 서버) — 판정 불가라 기존 동작(그대로 append)을 유지한다.
2887
+ if (textAvailable) text += event.delta || '';
2888
+ } else if (seq <= lastSeq) {
2889
+ this.logger.debug(`Duplicate assistant DELTA ignored: room=${roomId}, stream=${streamId}, seq=${seq}, lastSeq=${lastSeq}`);
2694
2890
  return;
2891
+ } else if (seq === lastSeq + 1) {
2892
+ // 포기한 슬롯이면 커서만 전진시키고 본문은 비운 채 둔다 — 베이스가 없는데 이어붙이면 오염이다.
2893
+ if (textAvailable) text += event.delta || '';
2894
+ lastSeq = seq;
2895
+ } else {
2896
+ this.logger.debug(`Assistant DELTA gap — text dropped: room=${roomId}, stream=${streamId}, seq=${seq}, lastSeq=${lastSeq}`);
2897
+ textAvailable = false;
2898
+ text = '';
2899
+ lastSeq = seq;
2695
2900
  }
2696
- const phase = (prev && prev.phase) || 'WRITING';
2697
- const text = (prev ? (prev.text || '') : '') + (event.delta || '');
2698
- this._startAssistantProgressTimer(roomId, event, { phase, text });
2699
- this.emit('assistantProgress', {
2700
- roomId,
2701
- streamId: event.streamId || null,
2702
- personaId: event.personaId || null,
2703
- phase,
2704
- label: this._assistantPhaseLabels[phase] || null,
2901
+
2902
+ const slot = this._setAssistantSlot(roomId, streamId, event, { phase, text, lastSeq, textAvailable });
2903
+ this._emitAssistantStream(roomId, streamId, slot, {
2705
2904
  active: true,
2706
- text, // 지금까지 누적된 전체 텍스트(버블 렌더용)
2707
- delta: event.delta || null, // 이번 청크(append 렌더 선호 시)
2708
- seq: typeof event.seq === 'number' ? event.seq : null
2905
+ text: slot.textAvailable ? slot.text : null, // 누적 전체(버블 렌더용)
2906
+ delta: slot.textAvailable ? (event.delta || null) : null, // 이번 청크(append 렌더 선호 시)
2907
+ seq
2709
2908
  });
2710
2909
  return;
2711
2910
  }
2712
2911
 
2713
2912
  if (event.type === 'DONE') {
2714
- // 종료(성공·실패 공통) — 상태/타이머 해제 + active:false emit(status/messageId 동봉, 권위 신호).
2715
- const state = this._assistantProgress.get(roomId);
2716
- // 늦게 도착한 이전 stream 의 DONE 무시 — 다음 stream 의 PHASE 이후 도착 시 새 진행 표시를 끄는 race 차단.
2717
- // 메시지/assistant-stream 채널은 ordering 이 묶여 있지 않다. streamId 가 둘 다 있고 다를 때만 stale 판정
2718
- // (UI 계약이 "active 만 보면 됨"이므로 SDK 가 stale false 를 막는다).
2719
- if (state?.streamId && event.streamId && state.streamId !== event.streamId) {
2720
- this.logger.debug(`Stale assistant DONE ignored: room=${roomId}, done=${event.streamId}, active=${state.streamId}`);
2721
- return;
2722
- }
2723
- // 메시지 도착으로 먼저 해제됐어도 DONE 은 한 번 더 발행될 수 있으나 active:false 는 idempotent(설계 §4.1).
2724
- if (state && state.timer) clearTimeout(state.timer);
2725
- this._assistantProgress.delete(roomId);
2726
- this.emit('assistantProgress', {
2727
- roomId,
2728
- streamId: event.streamId || null,
2729
- personaId: event.personaId || null,
2913
+ // 종료(성공·실패 공통) — <b>이 stream 만</b> 닫는다. 같은 방의 다른 stream 은 계속 산다.
2914
+ const slot = this._assistantSlot(roomId, streamId);
2915
+ if (slot && slot.timer) clearTimeout(slot.timer);
2916
+ this._deleteAssistantSlot(roomId, streamId);
2917
+ // 서버가 "끝났다"고 명시한 유일한 신호 — 묘비는 여기서만 세운다(§7.2).
2918
+ this._tombstoneAssistantStream(roomId, streamId);
2919
+ this._emitAssistantStream(roomId, streamId, slot, {
2920
+ active: false,
2921
+ personaId: event.personaId || (slot && slot.personaId) || null,
2730
2922
  phase: null,
2731
2923
  label: null,
2732
- active: false,
2733
2924
  status: event.status || null,
2734
2925
  messageId: event.messageId || null
2735
2926
  });
@@ -2737,6 +2928,236 @@ class ChatClient extends EventEmitter {
2737
2928
  // 그 외 미지 type 은 무시(forward-compat).
2738
2929
  }
2739
2930
 
2931
+ /**
2932
+ * 진행 중 스트림 스냅샷을 받아 라이브와 병합한다 (설계 §6.1, R-4).
2933
+ *
2934
+ * <pre>
2935
+ * 0. gen 발급 + pending ON ← 구독보다 <b>먼저</b> (호출자가 한다)
2936
+ * 1. SUBSCRIBE ← 호출자가 한다
2937
+ * 2. GET /assistant/streams
2938
+ * 3. 세대 검증 — 다르면 응답 통째 폐기
2939
+ * 4. 병합: 스냅샷을 베이스로, 보류분을 §5.5.0 규칙으로 이어붙임
2940
+ * 5. pending OFF + 렌더
2941
+ * </pre>
2942
+ *
2943
+ * <p><b>왜 구독이 먼저인가</b>: 반대로 하면 스냅샷 생성과 구독 사이에 발행된 청크가 <b>유실</b>된다.
2944
+ * 구독을 먼저 걸면 최악이 중복인데, 중복은 {@code seq} 로 정확히 잘라낼 수 있다.</p>
2945
+ *
2946
+ * <p><b>실패해도 보류분은 반드시 푼다</b> — 안 그러면 라이브 이벤트가 큐에 갇혀 진행 표시가 통째로
2947
+ * 멈춘다. 스냅샷 실패는 "복구 포기"일 뿐 채팅을 막지 않는다(§6.2).</p>
2948
+ * @private
2949
+ */
2950
+ async _loadAssistantSnapshot(roomId, generation) {
2951
+ // 같은 <b>세대</b>의 요청이 이미 떠 있으면 재사용한다. 다른 세대 것은 재사용하면 안 된다 —
2952
+ // 그 응답은 세대 불일치로 폐기되는데, 재사용했다면 이번 세대의 요청까지 함께 사라진다(그리고
2953
+ // 이번 세대가 쌓아둔 보류분이 통째로 버려진다).
2954
+ const inFlight = this._assistantSnapshotInFlight.get(roomId);
2955
+ if (inFlight && inFlight.generation === generation) return inFlight.task;
2956
+
2957
+ const task = (async () => {
2958
+ let streams = null;
2959
+ try {
2960
+ const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/assistant/streams`);
2961
+ const data = this._unwrapSuccessResponse(response);
2962
+ streams = (data && Array.isArray(data.streams)) ? data.streams : [];
2963
+ } catch (error) {
2964
+ // 구버전 서버(404)·네트워크·5xx 모두 여기로 온다. 사용자에게 노출하지 않는다.
2965
+ this.logger.warn(`Assistant stream snapshot failed (live-only fallback): room=${roomId}`, error);
2966
+ }
2967
+
2968
+ if (this._assistantGeneration(roomId) !== generation) {
2969
+ // 응답 전에 방을 떠났거나 재구독했다 — 이 스냅샷은 이미 다른 세대의 것이다.
2970
+ // ⚠️ <b>내 세대의 보류함만</b> 정리한다. 무조건 지우면 그 사이 열린 현재 세대의 보류분이
2971
+ // 통째로 사라져 라이브 이벤트가 증발한다.
2972
+ this.logger.debug(`Assistant snapshot discarded (stale generation): room=${roomId}`);
2973
+ this._deleteAssistantPendingIfGeneration(roomId, generation);
2974
+ return;
2975
+ }
2976
+ if (streams) this._applyAssistantSnapshot(roomId, streams);
2977
+ this._drainAssistantPending(roomId, generation);
2978
+ })();
2979
+
2980
+ this._assistantSnapshotInFlight.set(roomId, { generation, task });
2981
+ try {
2982
+ await task;
2983
+ } finally {
2984
+ // 내가 등록한 항목일 때만 지운다 — 그 사이 새 세대가 자기 요청을 올려놨을 수 있다.
2985
+ const current = this._assistantSnapshotInFlight.get(roomId);
2986
+ if (current && current.generation === generation) {
2987
+ this._assistantSnapshotInFlight.delete(roomId);
2988
+ }
2989
+ }
2990
+ }
2991
+
2992
+ /** @private */
2993
+ _deleteAssistantPendingIfGeneration(roomId, generation) {
2994
+ const pending = this._assistantSnapshotPending.get(roomId);
2995
+ if (pending && pending.generation === generation) {
2996
+ this._assistantSnapshotPending.delete(roomId);
2997
+ }
2998
+ }
2999
+
3000
+ /** 스냅샷을 슬롯에 심는다 — 보류분을 풀기 <b>전에</b> 끝나야 베이스가 선다. @private */
3001
+ _applyAssistantSnapshot(roomId, streams) {
3002
+ streams.forEach((s) => {
3003
+ const streamId = s && s.streamId;
3004
+ if (!streamId) return;
3005
+ // 이미 DONE 을 라이브로 받아 닫은 stream — 스냅샷이 그걸 되살리면 안 된다(§6.2).
3006
+ if (this._isAssistantStreamClosed(roomId, streamId)) return;
3007
+
3008
+ if (s.status === 'DONE') {
3009
+ // 유예 구간에 걸린 종료분. 진행 표시를 켜지 않고 최종 메시지로 바로 확정시킨다.
3010
+ this._tombstoneAssistantStream(roomId, streamId);
3011
+ this._emitAssistantStream(roomId, streamId, null, {
3012
+ active: false, phase: null, label: null, personaId: s.personaId || null,
3013
+ status: s.doneStatus || null, messageId: s.messageId || null, resumed: true
3014
+ });
3015
+ return;
3016
+ }
3017
+
3018
+ const textAvailable = s.textAvailable !== false;
3019
+ const slot = this._setAssistantSlot(roomId, streamId, {
3020
+ personaId: s.personaId, expiresAt: undefined
3021
+ }, {
3022
+ phase: s.phase || null,
3023
+ phaseUpdatedAt: typeof s.phaseUpdatedAt === 'number' ? s.phaseUpdatedAt : 0,
3024
+ text: textAvailable ? (s.text || '') : '',
3025
+ lastSeq: this._seqOf(s.lastSeq),
3026
+ textAvailable,
3027
+ resumed: true
3028
+ });
3029
+ this._emitAssistantStream(roomId, streamId, slot, {
3030
+ active: true,
3031
+ text: slot.textAvailable ? (slot.text || null) : null,
3032
+ resumed: true
3033
+ });
3034
+ });
3035
+ }
3036
+
3037
+ /**
3038
+ * 보류분을 <b>도착 순서 그대로</b> 흘려보낸다. 이 시점부터 §5.5.0 규칙이 스냅샷 베이스 위에서 돈다.
3039
+ *
3040
+ * <p>순서가 중요하다 — stream 별로 몰아서 풀면 방 집계의 "가장 최근 stream" 판정이 실제 도착 순서와
3041
+ * 달라져 엉뚱한 본문이 버블에 뜬다.</p>
3042
+ * @private
3043
+ */
3044
+ _drainAssistantPending(roomId, generation) {
3045
+ const pending = this._assistantSnapshotPending.get(roomId);
3046
+ if (!pending) return;
3047
+ if (generation !== undefined && pending.generation !== generation) return;
3048
+ this._assistantSnapshotPending.delete(roomId); // 먼저 지워야 재귀로 다시 큐에 들어가지 않는다
3049
+ pending.events.forEach((event) => this._handleAssistantStreamEvent(roomId, event));
3050
+ }
3051
+
3052
+ /** @private */
3053
+ _assistantGeneration(roomId) {
3054
+ return this._assistantRoomGeneration.get(roomId) || 0;
3055
+ }
3056
+
3057
+ /** 세대를 넘기고 보류함을 연다 — <b>구독 직전</b>에 부른다(§6.1 ①). @private */
3058
+ _armAssistantSnapshot(roomId) {
3059
+ const generation = this._assistantGeneration(roomId) + 1;
3060
+ this._assistantRoomGeneration.set(roomId, generation);
3061
+ const previous = this._assistantSnapshotPending.get(roomId);
3062
+ // ⚠️ <b>보류분은 세대를 넘겨 이어받는다.</b> 세대는 <b>스냅샷 응답</b>의 유효성을 가르는 축이지
3063
+ // 큐 내용의 소유권이 아니다. 큐에 있는 건 아직 렌더되지 않은 <b>정상 라이브 이벤트</b>라, 새 세대를
3064
+ // 빈 배열로 열면 그대로 유실된다(GET 대기 중 재개가 한 번 더 불리면 그 사이 도착한 DELTA 가 통째로
3065
+ // 사라진다). 방을 떠날 때 버리는 건 별개 경로다({@link #_clearAssistantProgress}).
3066
+ this._assistantSnapshotPending.set(roomId, {
3067
+ generation,
3068
+ events: previous ? previous.events : []
3069
+ });
3070
+ return generation;
3071
+ }
3072
+
3073
+ /**
3074
+ * 재연결·복귀 시 <b>구독 중인 모든 방</b>의 스냅샷을 다시 받는다 (설계 §7.4).
3075
+ *
3076
+ * <p>⚠️ {@link #syncAfterReconnect} 에 얹으면 안 된다. 그쪽은 {@code syncOnReconnect} 옵션과
3077
+ * <b>anchor 가 있는 방</b>이라는 두 겹 필터가 걸려 있어서, 옵션이 꺼져 있거나 그 방에서 메시지를 받은
3078
+ * 적이 없으면 replay 가 조용히 빠진다. 어시 재개는 메시지 갭 복구와 발동 조건이 다르다.</p>
3079
+ */
3080
+ async resumeAssistantStreams(reason = 'manual') {
3081
+ if (!this.assistantStreamReplay) return;
3082
+ const rooms = Array.from(this.subscribedRooms.keys());
3083
+ if (rooms.length === 0) return;
3084
+ this.logger.debug(`Assistant stream replay: rooms=${rooms.length}, reason=${reason}`);
3085
+ await Promise.all(rooms.map((roomId) => {
3086
+ const generation = this._armAssistantSnapshot(roomId);
3087
+ return this._loadAssistantSnapshot(roomId, generation)
3088
+ .catch((error) => this.logger.warn(`Assistant stream replay failed: room=${roomId}`, error));
3089
+ }));
3090
+ }
3091
+
3092
+ /** 방의 stream 슬롯 맵 — 없으면 생성. @private */
3093
+ _assistantSlots(roomId) {
3094
+ let slots = this._assistantProgress.get(roomId);
3095
+ if (!slots) {
3096
+ slots = new Map();
3097
+ this._assistantProgress.set(roomId, slots);
3098
+ }
3099
+ return slots;
3100
+ }
3101
+
3102
+ /** @private */
3103
+ _assistantSlot(roomId, streamId) {
3104
+ const slots = this._assistantProgress.get(roomId);
3105
+ return slots ? slots.get(streamId) : undefined;
3106
+ }
3107
+
3108
+ /** @private */
3109
+ _deleteAssistantSlot(roomId, streamId) {
3110
+ const slots = this._assistantProgress.get(roomId);
3111
+ if (!slots) return;
3112
+ slots.delete(streamId);
3113
+ if (slots.size === 0) this._assistantProgress.delete(roomId);
3114
+ }
3115
+
3116
+ /** 종료된 stream 인가 — 늦은 이벤트가 슬롯을 되살리는 것을 막는다(§7.2). @private */
3117
+ _isAssistantStreamClosed(roomId, streamId) {
3118
+ const graves = this._assistantStreamTombstones.get(roomId);
3119
+ if (!graves) return false;
3120
+ const expiresAt = graves.get(streamId);
3121
+ if (expiresAt === undefined) return false;
3122
+ if (expiresAt <= Date.now()) {
3123
+ graves.delete(streamId); // 만료된 묘비는 회수 — 그 뒤엔 새 stream 취급이 맞다.
3124
+ return false;
3125
+ }
3126
+ return true;
3127
+ }
3128
+
3129
+ /**
3130
+ * 묘비 등록 — <b>회수는 시간으로만</b> 한다.
3131
+ *
3132
+ * <p>⚠️ 임계를 넘어도 <b>아직 유효한 묘비는 지우지 않는다.</b> 지우는 순간 그 stream 의 늦은 DELTA 가
3133
+ * 슬롯을 되살려 유령 버블이 되고, 그건 "DONE 이후 절대 되살리지 않는다"는 이 장치의 존재 이유 자체를
3134
+ * 깬다. 서버 gate 가 상한 초과 시에도 pruning 대신 경보만 내는 것과 <b>같은 규칙</b>이다 —
3135
+ * 계약 파손이 메모리보다 훨씬 비싸다(항목당 문자열+숫자다).</p>
3136
+ * @private
3137
+ */
3138
+ _tombstoneAssistantStream(roomId, streamId) {
3139
+ let graves = this._assistantStreamTombstones.get(roomId);
3140
+ if (!graves) {
3141
+ graves = new Map();
3142
+ this._assistantStreamTombstones.set(roomId, graves);
3143
+ }
3144
+ const now = Date.now();
3145
+ graves.forEach((expiresAt, id) => {
3146
+ if (expiresAt <= now) graves.delete(id); // 만료분만 회수(lazy)
3147
+ });
3148
+ graves.set(streamId, now + this._assistantTombstoneTtlMs);
3149
+
3150
+ // 경보는 임계를 <b>넘는 순간 1회</b>만 — 넘은 동안 DONE 마다 짖으면 로그가 신호를 덮는다.
3151
+ if (graves.size > this._assistantTombstoneWarnThreshold) {
3152
+ if (!this._assistantTombstoneWarned.has(roomId)) {
3153
+ this._assistantTombstoneWarned.add(roomId);
3154
+ this.logger.warn(`Assistant tombstones above threshold — NOT pruning (TTL only): room=${roomId}, size=${graves.size}, threshold=${this._assistantTombstoneWarnThreshold}`);
3155
+ }
3156
+ } else {
3157
+ this._assistantTombstoneWarned.delete(roomId);
3158
+ }
3159
+ }
3160
+
2740
3161
  /**
2741
3162
  * Rolling 누적 요약 완료 알림(summary 채널) 수신 처리 — status 를 그대로 {@code summaryUpdate} 로 emit.
2742
3163
  *
@@ -2763,13 +3184,24 @@ class ChatClient extends EventEmitter {
2763
3184
  }
2764
3185
 
2765
3186
  /**
2766
- * AI 진행 상태 갱신 + TTL 타이머 재무장 — {@code expiresAt}(epochMillis) 기반, 누락/이상 시 fallback.
2767
- * {@code fields}={phase, text}(PHASE/DELTA 가 해소해 전달 — DELTA 는 직전 phase 유지 + 누적 text).
2768
- * 만료 = DONE·메시지 유실로 진행이 끊긴 것 → UI 가 영구 표시에 갇히지 않게 {@code active:false} 자동 emit.
3187
+ * seq 커서 정규화 — 값이 없으면 {@code -1}(DELTA 0건). 첫 청크가 {@code seq=0} 이라 기본값이 0 이면
3188
+ * 첫 글자를 중복으로 버린다(설계 §5.5.0).
3189
+ * @private
3190
+ */
3191
+ _seqOf(value) {
3192
+ return typeof value === 'number' ? value : -1;
3193
+ }
3194
+
3195
+ /**
3196
+ * stream 슬롯 갱신 + TTL 타이머 재무장 — {@code expiresAt}(epochMillis) 기반, 누락/이상 시 fallback.
3197
+ *
3198
+ * <p>만료 = DONE 유실로 진행이 끊긴 것 → UI 가 영구 표시에 갇히지 않게 {@code active:false} 를 자동
3199
+ * emit 한다. ⚠️ 만료는 <b>묘비를 세우지 않는다</b> — "표시가 낡았다"는 것이지 서버의 확정 종료가
3200
+ * 아니라서, 뒤늦게 정상 DELTA 가 오면 다시 살아나야 맞다(§7.2).</p>
2769
3201
  * @private
2770
3202
  */
2771
- _startAssistantProgressTimer(roomId, event, fields) {
2772
- const prev = this._assistantProgress.get(roomId);
3203
+ _setAssistantSlot(roomId, streamId, event, fields) {
3204
+ const prev = this._assistantSlot(roomId, streamId);
2773
3205
  if (prev && prev.timer) clearTimeout(prev.timer);
2774
3206
 
2775
3207
  let ttl = this._assistantProgressFallbackTtlMs;
@@ -2778,39 +3210,114 @@ class ChatClient extends EventEmitter {
2778
3210
  ttl = Math.min(120000, Math.max(5000, event.expiresAt - Date.now()));
2779
3211
  }
2780
3212
  const timer = setTimeout(() => {
2781
- this._clearAssistantProgress(roomId, true, 'timeout');
2782
- this.logger.debug(`Assistant progress auto-cleared (timeout): room=${roomId}`);
3213
+ this._expireAssistantStream(roomId, streamId);
2783
3214
  }, ttl);
2784
- this._assistantProgress.set(roomId, {
2785
- streamId: event.streamId || null,
2786
- personaId: event.personaId || null,
3215
+
3216
+ const slot = {
3217
+ personaId: event.personaId || (prev && prev.personaId) || null,
2787
3218
  phase: fields.phase,
3219
+ // 마지막으로 <b>적용된</b> PHASE 의 발행 시각 — pending PHASE 역행 판정 커서(§6.2).
3220
+ phaseUpdatedAt: fields.phaseUpdatedAt !== undefined
3221
+ ? fields.phaseUpdatedAt
3222
+ : (prev ? (prev.phaseUpdatedAt || 0) : 0),
2788
3223
  text: fields.text || '',
3224
+ lastSeq: this._seqOf(fields.lastSeq),
3225
+ textAvailable: fields.textAvailable !== false,
3226
+ resumed: fields.resumed === true || (prev ? prev.resumed === true : false),
3227
+ updatedAt: Date.now(),
3228
+ order: ++this._assistantSlotSeq, // 최신 판정 축 — updatedAt 은 진단용일 뿐이다
2789
3229
  timer
3230
+ };
3231
+ this._assistantSlots(roomId).set(streamId, slot);
3232
+ return slot;
3233
+ }
3234
+
3235
+ /** TTL 만료 — 이 stream 만 끈다. 묘비는 세우지 않는다. @private */
3236
+ _expireAssistantStream(roomId, streamId) {
3237
+ const slot = this._assistantSlot(roomId, streamId);
3238
+ if (!slot) return;
3239
+ if (slot.timer) clearTimeout(slot.timer);
3240
+ this._deleteAssistantSlot(roomId, streamId);
3241
+ this.logger.debug(`Assistant progress auto-cleared (timeout): room=${roomId}, stream=${streamId}`);
3242
+ this._emitAssistantStream(roomId, streamId, slot, {
3243
+ active: false, phase: null, label: null, reason: 'timeout'
2790
3244
  });
2791
3245
  }
2792
3246
 
2793
3247
  /**
2794
- * AI 진행 표시 상태/타이머 해제. {@code emit=true} 면 {@code active:false} 를 발행(메시지 도착/타임아웃 해제용).
2795
- * 구독 해제 시엔 {@code emit=false} 로 조용히 정리.
3248
+ * {@code assistantProgress}(stream 단위) + {@code assistantRoomProgress}(방 단위 집계)를 함께 발행한다.
3249
+ *
3250
+ * <p><b>왜 둘인가</b>: 진짜 상태는 stream 단위지만, 기존 프론트는 "방마다 임시 버블 하나"를 전제로
3251
+ * 짜여 있다. 그 코드에 stream 단위 {@code active:false} 가 들어가면 <b>같은 방의 다른 살아 있는
3252
+ * 버블까지 숨긴다</b>. 그래서 이행용 다리로 방 단위 집계를 별도 이벤트로 함께 낸다(설계 §7.5 (b)).
3253
+ * 프론트는 리스너 이름만 바꿔 기존 렌더를 유지하다가, 준비되면 stream 단위로 넘어오면 된다.</p>
2796
3254
  * @private
2797
3255
  */
2798
- _clearAssistantProgress(roomId, emit, reason) {
2799
- const state = this._assistantProgress.get(roomId);
2800
- if (!state) return;
2801
- if (state.timer) clearTimeout(state.timer);
2802
- this._assistantProgress.delete(roomId);
2803
- if (emit) {
2804
- this.emit('assistantProgress', {
2805
- roomId,
2806
- streamId: state.streamId || null,
2807
- personaId: state.personaId || null,
2808
- phase: null,
2809
- label: null,
2810
- active: false,
2811
- reason: reason || null
3256
+ _emitAssistantStream(roomId, streamId, slot, overrides) {
3257
+ const phase = 'phase' in overrides ? overrides.phase : (slot ? slot.phase : null);
3258
+ this.emit('assistantProgress', {
3259
+ roomId,
3260
+ streamId,
3261
+ personaId: slot ? slot.personaId : null,
3262
+ phase,
3263
+ label: phase ? (this._assistantPhaseLabels[phase] || null) : null,
3264
+ textAvailable: slot ? slot.textAvailable : true,
3265
+ lastSeq: slot ? slot.lastSeq : -1,
3266
+ ...overrides
3267
+ });
3268
+ this._emitAssistantRoomProgress(roomId);
3269
+ }
3270
+
3271
+ /**
3272
+ * 방 단위 집계 — 살아 있는 stream 이 하나라도 있으면 {@code active:true}.
3273
+ * 표시 내용은 <b>가장 최근에 갱신된</b> stream 을 따른다(단일 버블 UI 가 자연스럽게 최신을 보여준다).
3274
+ * @private
3275
+ */
3276
+ _emitAssistantRoomProgress(roomId) {
3277
+ const slots = this._assistantProgress.get(roomId);
3278
+ const alive = slots ? Array.from(slots.entries()) : [];
3279
+ if (alive.length === 0) {
3280
+ this.emit('assistantRoomProgress', {
3281
+ roomId, active: false, streamCount: 0,
3282
+ streamId: null, personaId: null, phase: null, label: null, text: null, textAvailable: true
2812
3283
  });
3284
+ return;
3285
+ }
3286
+ const [streamId, slot] = alive.reduce((a, b) => (b[1].order > a[1].order ? b : a));
3287
+ this.emit('assistantRoomProgress', {
3288
+ roomId,
3289
+ active: true,
3290
+ streamCount: alive.length,
3291
+ streamId,
3292
+ personaId: slot.personaId,
3293
+ phase: slot.phase,
3294
+ label: slot.phase ? (this._assistantPhaseLabels[slot.phase] || null) : null,
3295
+ text: slot.textAvailable ? (slot.text || null) : null,
3296
+ textAvailable: slot.textAvailable
3297
+ });
3298
+ }
3299
+
3300
+ /**
3301
+ * 방의 모든 stream 상태/타이머 정리 — 구독 해제·destroy 용. 조용히 지운다(emit 없음).
3302
+ *
3303
+ * <p>묘비도 함께 지운다. 방을 떠나면 되살아날 유령 자체가 없고, 재입장은 새 세대이기 때문이다.</p>
3304
+ * @private
3305
+ */
3306
+ _clearAssistantProgress(roomId) {
3307
+ const slots = this._assistantProgress.get(roomId);
3308
+ if (slots) {
3309
+ slots.forEach((slot) => slot.timer && clearTimeout(slot.timer));
3310
+ this._assistantProgress.delete(roomId);
2813
3311
  }
3312
+ this._assistantStreamTombstones.delete(roomId);
3313
+ // 경고 표식도 묘비와 <b>같은 수명</b>이어야 한다. 남겨두면 재구독 후 임계를 다시 넘어도
3314
+ // "이미 알렸다"로 판정해 경고가 영영 안 나오고, Set 에 죽은 roomId 가 쌓인다.
3315
+ this._assistantTombstoneWarned.delete(roomId);
3316
+ // 보류함도 비운다 — 방을 떠났는데 큐가 남아 있으면 재구독 때 옛 이벤트가 섞여 들어간다.
3317
+ // (진행 중 스냅샷 응답은 아래 세대 증가로 무효화되므로 별도 취소가 필요 없다.)
3318
+ this._assistantSnapshotPending.delete(roomId);
3319
+ // 진행 중이던 스냅샷 응답이 뒤늦게 도착해도 적용되지 않도록 세대를 넘긴다(§6.1-a, R-4 가 소비).
3320
+ this._assistantRoomGeneration.set(roomId, (this._assistantRoomGeneration.get(roomId) || 0) + 1);
2814
3321
  }
2815
3322
 
2816
3323
  // ==================== 유틸리티 ====================
@@ -2861,8 +3368,14 @@ class ChatClient extends EventEmitter {
2861
3368
  this._assistantTypingTimers.clear();
2862
3369
 
2863
3370
  // AI 진행 표시 상태/타이머도 전체 cleanup (구독 외 경로로 남은 roomId 방어).
2864
- this._assistantProgress.forEach((state) => state.timer && clearTimeout(state.timer));
3371
+ // 슬롯이 방 → stream 2단이라 안쪽까지 순회해야 타이머가 남지 않는다(v5.0.0).
3372
+ this._assistantProgress.forEach((slots) => slots.forEach((slot) => slot.timer && clearTimeout(slot.timer)));
2865
3373
  this._assistantProgress.clear();
3374
+ this._assistantStreamTombstones.clear();
3375
+ this._assistantTombstoneWarned.clear();
3376
+ this._assistantRoomGeneration.clear();
3377
+ this._assistantSnapshotPending.clear();
3378
+ this._assistantSnapshotInFlight.clear();
2866
3379
 
2867
3380
  // dedup bucket 전체 clear — unsubscribeRoom 이 방별로 정리하지만 구독 외 경로로 누적된 bucket
2868
3381
  // (예: 구독 안 한 방의 push/listener) 이 있을 수 있어 안전하게 명시 정리.