@things-factory/board-ai 10.0.2 → 10.0.4

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.
Files changed (60) hide show
  1. package/client/components/board-ai-chat.ts +458 -19
  2. package/client/components/chat-echo-dedup.test.ts +59 -3
  3. package/client/components/chat-echo-dedup.ts +29 -0
  4. package/dist-client/client/components/board-ai-chat.d.ts +63 -0
  5. package/dist-client/client/components/board-ai-chat.js +438 -16
  6. package/dist-client/client/components/board-ai-chat.js.map +1 -1
  7. package/dist-client/client/components/chat-echo-dedup.js +28 -0
  8. package/dist-client/client/components/chat-echo-dedup.js.map +1 -1
  9. package/dist-client/client/components/chat-echo-dedup.test.js +53 -3
  10. package/dist-client/client/components/chat-echo-dedup.test.js.map +1 -1
  11. package/dist-client/server/service/agentic-loop.d.ts +15 -0
  12. package/dist-client/server/service/agentic-loop.js +70 -9
  13. package/dist-client/server/service/agentic-loop.js.map +1 -1
  14. package/dist-client/server/service/assistant.js +18 -3
  15. package/dist-client/server/service/assistant.js.map +1 -1
  16. package/dist-client/server/service/types.d.ts +23 -0
  17. package/dist-client/server/service/types.js.map +1 -1
  18. package/dist-client/tsconfig.tsbuildinfo +1 -1
  19. package/dist-server/service/agentic-loop.d.ts +15 -0
  20. package/dist-server/service/agentic-loop.js +71 -9
  21. package/dist-server/service/agentic-loop.js.map +1 -1
  22. package/dist-server/service/assistant.js +17 -2
  23. package/dist-server/service/assistant.js.map +1 -1
  24. package/dist-server/service/board-ai-resolver.d.ts +13 -0
  25. package/dist-server/service/board-ai-resolver.js +99 -1
  26. package/dist-server/service/board-ai-resolver.js.map +1 -1
  27. package/dist-server/service/chat-message/fold-history.d.ts +30 -0
  28. package/dist-server/service/chat-message/fold-history.js +29 -0
  29. package/dist-server/service/chat-message/fold-history.js.map +1 -0
  30. package/dist-server/service/chat-message/history-summary.d.ts +43 -0
  31. package/dist-server/service/chat-message/history-summary.js +77 -0
  32. package/dist-server/service/chat-message/history-summary.js.map +1 -0
  33. package/dist-server/service/chat-message/llm-history.d.ts +19 -0
  34. package/dist-server/service/chat-message/llm-history.js +31 -1
  35. package/dist-server/service/chat-message/llm-history.js.map +1 -1
  36. package/dist-server/service/chat-session/chat-session.d.ts +8 -0
  37. package/dist-server/service/chat-session/chat-session.js +5 -0
  38. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  39. package/dist-server/service/types.d.ts +23 -0
  40. package/dist-server/service/types.js.map +1 -1
  41. package/dist-server/tsconfig.tsbuildinfo +1 -1
  42. package/package.json +6 -6
  43. package/server/service/agentic-loop.test.ts +91 -0
  44. package/server/service/agentic-loop.ts +80 -9
  45. package/server/service/assistant.ts +21 -3
  46. package/server/service/board-ai-resolver.ts +108 -1
  47. package/server/service/chat-message/fold-history.test.ts +98 -0
  48. package/server/service/chat-message/fold-history.ts +60 -0
  49. package/server/service/chat-message/history-summary.test.ts +127 -0
  50. package/server/service/chat-message/history-summary.ts +100 -0
  51. package/server/service/chat-message/llm-history.test.ts +65 -0
  52. package/server/service/chat-message/llm-history.ts +48 -1
  53. package/server/service/chat-session/chat-session.ts +11 -0
  54. package/server/service/dock-contract.test.ts +305 -0
  55. package/server/service/types.ts +23 -0
  56. package/translations/en.json +14 -1
  57. package/translations/ja.json +14 -1
  58. package/translations/ko.json +13 -0
  59. package/translations/ms.json +14 -1
  60. package/translations/zh.json +14 -1
@@ -43,6 +43,34 @@ export function receiveRemoteMessage(lines, seen, m) {
43
43
  /* 내 발신 에코 흡수 — `_localId` 가 있는 줄은 이 클라이언트가 만든 것뿐이다.
44
44
  * 발신자 판정: 양쪽 email 이 있으면 같아야 하고, 방송에 발신자 email 이 없으면(단일 사용자·구형)
45
45
  * 내 것으로 본다. 다른 사용자의 메시지가 내 대기 줄을 삼키지 않도록 email 이 다르면 흡수하지 않는다. */
46
+ /* AI 답변 에코 흡수 — 사용자 발화와 **같은 이유**로 붙이지 않고 대기 줄에 흡수한다.
47
+ *
48
+ * 왜 필요한가(실제 사고): 방송은 mutation 응답보다 먼저 도착할 수 있다. 예전에는 assistant 방송을
49
+ * 새 줄로 붙였고, 뒤이어 mutation 응답이 "같은 서버 id 를 가진 다른 줄이 있다" 며 **대기 줄을 지웠다**.
50
+ * 그런데 대기 줄에만 있는 것이 있다 — **영속되지 않는 응답 데이터**(조치 제안 카드·접지 경고).
51
+ * 그래서 화면에서 실행 버튼이 사라졌다(제안 도구는 분명히 호출됐는데도).
52
+ *
53
+ * 흡수하면 서버가 보낸 본문(멘션 마커 포함)을 그 줄에 입히면서 대기 표시를 풀 수 있어, 답이 더
54
+ * 빨리 보이는 효과도 있다. */
55
+ if (m.role === 'assistant') {
56
+ const idx = lines.findIndex(l => l.role === 'assistant' && !l.id && !!l._localId);
57
+ if (idx >= 0) {
58
+ const nextLines = [...lines];
59
+ nextLines[idx] = {
60
+ ...nextLines[idx],
61
+ id: m.id,
62
+ /* 본문·영속 데이터는 서버 것으로 갱신하고, 이 줄이 들고 있던 값(제안 등)은 유지한다. */
63
+ content: m.content,
64
+ patchId: m.relatedPatchId ?? nextLines[idx].patchId,
65
+ toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : nextLines[idx].toolUsages,
66
+ groundingWarnings: Array.isArray(m.groundingWarningsJson)
67
+ ? m.groundingWarningsJson
68
+ : nextLines[idx].groundingWarnings,
69
+ pending: false
70
+ };
71
+ return { lines: nextLines, seen: nextSeen };
72
+ }
73
+ }
46
74
  if (m.role === 'user') {
47
75
  const echoEmail = m.creator?.email;
48
76
  const idx = lines.findIndex(l => l.role === 'user' &&
@@ -1 +1 @@
1
- {"version":3,"file":"chat-echo-dedup.js","sourceRoot":"","sources":["../../../client/components/chat-echo-dedup.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 그룹챗 메시지 에코 dedup — 순수 로직.\n *\n * 실시간 방송은 자기 발신 메시지도 되돌려준다(에코). 또 pubsub publish 가 mutation 응답보다\n * 먼저 도착할 수 있어(레이스) optimistic 라인과 방송 라인이 겹칠 수 있다. 이 모듈은 그 조정을\n * **id 기반**으로 결정한다 (서버가 멘션 마커로 content 를 enrich 하므로 content 매칭은 불가).\n *\n * 컴포넌트 상태(this.lines / this._seenMessageIds)에서 분리 → 단위테스트 가능.\n * (C2 의 patch-apply-gate.ts 와 동일한 \"순수 게이트\" 컨벤션.)\n */\n\n/** dedup 에 필요한 최소 라인 형태 (board-ai-chat 의 ChatLine 부분집합). */\nexport interface DedupLine {\n id?: string\n _localId?: string\n role: string\n content: string\n senderEmail?: string\n senderName?: string\n [k: string]: any\n}\n\n/** 서버 방송 메시지(필요 필드만). */\nexport interface RemoteMessage {\n id?: string\n role: string\n content: string\n relatedPatchId?: string\n toolUsagesJson?: any\n /** 접지 경고(영속) — 답이 언급했으나 근거에 없던 식별자. 나중에 읽는 사람도 같은 주의를 봐야 한다. */\n groundingWarningsJson?: any\n /** 발신자 — 표시용 name + **자기 여부 판정용 email**(내부 관리키 id 는 쓰지 않는다). */\n creator?: { name?: string; email?: string } | null\n}\n\n/** 방송 메시지 → 라인. */\nexport function remoteMessageToLine(m: RemoteMessage): DedupLine {\n return {\n id: m.id,\n role: m.role,\n content: m.content,\n patchId: m.relatedPatchId ?? undefined,\n toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : undefined,\n groundingWarnings: Array.isArray(m.groundingWarningsJson) ? m.groundingWarningsJson : undefined,\n senderEmail: m.creator?.email ?? undefined,\n senderName: m.creator?.name ?? undefined\n }\n}\n\n/**\n * 원격(방송) 메시지 수신 결과를 계산.\n * - id 없음 / 이미 seen → 변화 없음(null).\n * - **내 발신 에코**이고 아직 서버 id 를 못 받은 optimistic 라인이 있으면 → 그 라인에 id 를 입힌다.\n * - 그 밖 → seen 에 추가 + 라인 append.\n *\n * 왜 '입히기'인가: 방송은 mutation 응답보다 먼저 도착할 수 있다. 예전에는 에코를 새 줄로 붙이고\n * 중복 제거를 mutation 응답 시점(reconcileSentLine)에 했는데, 그 응답은 **LLM 이 끝난 뒤** 오므로\n * 사용자는 대기 시간 내내 자기 질문을 두 줄로 본다. 그래서 붙이지 않고 대기 중인 줄에 흡수시킨다.\n *\n * `_localId` 는 유지한다 — 이후 mutation 응답이 patch(요약·patchId 등)를 그 줄에 병합할 수 있어야 한다.\n */\nexport function receiveRemoteMessage(\n lines: DedupLine[],\n seen: Set<string>,\n m: RemoteMessage | undefined | null\n): { lines: DedupLine[]; seen: Set<string> } | null {\n if (!m || !m.id) return null\n if (seen.has(m.id)) return null\n const nextSeen = new Set(seen)\n nextSeen.add(m.id)\n\n /* 내 발신 에코 흡수 — `_localId` 가 있는 줄은 이 클라이언트가 만든 것뿐이다.\n * 발신자 판정: 양쪽 email 이 있으면 같아야 하고, 방송에 발신자 email 이 없으면(단일 사용자·구형)\n * 내 것으로 본다. 다른 사용자의 메시지가 내 대기 줄을 삼키지 않도록 email 이 다르면 흡수하지 않는다. */\n if (m.role === 'user') {\n const echoEmail = m.creator?.email\n const idx = lines.findIndex(\n l =>\n l.role === 'user' &&\n !l.id &&\n !!l._localId &&\n (!echoEmail || l.senderEmail == null || l.senderEmail === echoEmail)\n )\n if (idx >= 0) {\n const nextLines = [...lines]\n nextLines[idx] = {\n ...nextLines[idx],\n id: m.id,\n senderEmail: echoEmail ?? nextLines[idx].senderEmail,\n senderName: m.creator?.name ?? nextLines[idx].senderName\n }\n return { lines: nextLines, seen: nextSeen }\n }\n }\n\n return { lines: [...lines, remoteMessageToLine(m)], seen: nextSeen }\n}\n\n/**\n * 전송한 optimistic 라인(_localId)을 서버 id 로 reconcile.\n * - **다른 줄**이 이미 그 serverId 를 갖고 있으면(옛 append 경로) optimistic 중복 라인 제거.\n * - 아니면 serverId 부여(+ seen 등록) + patch 병합 + _localId 해제.\n * - 해당 _localId 라인이 없으면 변화 없음.\n * 반환: 새 { lines, seen } (불변).\n */\nexport function reconcileSentLine(\n lines: DedupLine[],\n seen: Set<string>,\n localId: string,\n serverId: string | undefined,\n patch: Partial<DedupLine>\n): { lines: DedupLine[]; seen: Set<string> } {\n const idx = lines.findIndex(l => l._localId === localId)\n if (idx < 0) return { lines, seen }\n\n const nextLines = [...lines]\n /* seen 만 보고 지우면, 에코를 이 줄에 **흡수**한 경우(id 가 이미 이 줄에 있다) 자기 줄을 지운다.\n * 실제 중복은 \"다른 줄이 같은 id 를 가진\" 상태일 때뿐이다. */\n if (serverId && nextLines.some((l, i) => i !== idx && l.id === serverId)) {\n nextLines.splice(idx, 1)\n return { lines: nextLines, seen }\n }\n const nextSeen = serverId ? new Set(seen).add(serverId) : seen\n nextLines[idx] = { ...nextLines[idx], ...patch, id: serverId ?? nextLines[idx].id, _localId: undefined }\n return { lines: nextLines, seen: nextSeen }\n}\n"]}
1
+ {"version":3,"file":"chat-echo-dedup.js","sourceRoot":"","sources":["../../../client/components/chat-echo-dedup.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 그룹챗 메시지 에코 dedup — 순수 로직.\n *\n * 실시간 방송은 자기 발신 메시지도 되돌려준다(에코). 또 pubsub publish 가 mutation 응답보다\n * 먼저 도착할 수 있어(레이스) optimistic 라인과 방송 라인이 겹칠 수 있다. 이 모듈은 그 조정을\n * **id 기반**으로 결정한다 (서버가 멘션 마커로 content 를 enrich 하므로 content 매칭은 불가).\n *\n * 컴포넌트 상태(this.lines / this._seenMessageIds)에서 분리 → 단위테스트 가능.\n * (C2 의 patch-apply-gate.ts 와 동일한 \"순수 게이트\" 컨벤션.)\n */\n\n/** dedup 에 필요한 최소 라인 형태 (board-ai-chat 의 ChatLine 부분집합). */\nexport interface DedupLine {\n id?: string\n _localId?: string\n role: string\n content: string\n senderEmail?: string\n senderName?: string\n [k: string]: any\n}\n\n/** 서버 방송 메시지(필요 필드만). */\nexport interface RemoteMessage {\n id?: string\n role: string\n content: string\n relatedPatchId?: string\n toolUsagesJson?: any\n /** 접지 경고(영속) — 답이 언급했으나 근거에 없던 식별자. 나중에 읽는 사람도 같은 주의를 봐야 한다. */\n groundingWarningsJson?: any\n /** 발신자 — 표시용 name + **자기 여부 판정용 email**(내부 관리키 id 는 쓰지 않는다). */\n creator?: { name?: string; email?: string } | null\n}\n\n/** 방송 메시지 → 라인. */\nexport function remoteMessageToLine(m: RemoteMessage): DedupLine {\n return {\n id: m.id,\n role: m.role,\n content: m.content,\n patchId: m.relatedPatchId ?? undefined,\n toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : undefined,\n groundingWarnings: Array.isArray(m.groundingWarningsJson) ? m.groundingWarningsJson : undefined,\n senderEmail: m.creator?.email ?? undefined,\n senderName: m.creator?.name ?? undefined\n }\n}\n\n/**\n * 원격(방송) 메시지 수신 결과를 계산.\n * - id 없음 / 이미 seen → 변화 없음(null).\n * - **내 발신 에코**이고 아직 서버 id 를 못 받은 optimistic 라인이 있으면 → 그 라인에 id 를 입힌다.\n * - 그 밖 → seen 에 추가 + 라인 append.\n *\n * 왜 '입히기'인가: 방송은 mutation 응답보다 먼저 도착할 수 있다. 예전에는 에코를 새 줄로 붙이고\n * 중복 제거를 mutation 응답 시점(reconcileSentLine)에 했는데, 그 응답은 **LLM 이 끝난 뒤** 오므로\n * 사용자는 대기 시간 내내 자기 질문을 두 줄로 본다. 그래서 붙이지 않고 대기 중인 줄에 흡수시킨다.\n *\n * `_localId` 는 유지한다 — 이후 mutation 응답이 patch(요약·patchId 등)를 그 줄에 병합할 수 있어야 한다.\n */\nexport function receiveRemoteMessage(\n lines: DedupLine[],\n seen: Set<string>,\n m: RemoteMessage | undefined | null\n): { lines: DedupLine[]; seen: Set<string> } | null {\n if (!m || !m.id) return null\n if (seen.has(m.id)) return null\n const nextSeen = new Set(seen)\n nextSeen.add(m.id)\n\n /* 내 발신 에코 흡수 — `_localId` 가 있는 줄은 이 클라이언트가 만든 것뿐이다.\n * 발신자 판정: 양쪽 email 이 있으면 같아야 하고, 방송에 발신자 email 이 없으면(단일 사용자·구형)\n * 내 것으로 본다. 다른 사용자의 메시지가 내 대기 줄을 삼키지 않도록 email 이 다르면 흡수하지 않는다. */\n /* AI 답변 에코 흡수 — 사용자 발화와 **같은 이유**로 붙이지 않고 대기 줄에 흡수한다.\n *\n * 왜 필요한가(실제 사고): 방송은 mutation 응답보다 먼저 도착할 수 있다. 예전에는 assistant 방송을\n * 새 줄로 붙였고, 뒤이어 mutation 응답이 \"같은 서버 id 를 가진 다른 줄이 있다\" 며 **대기 줄을 지웠다**.\n * 그런데 대기 줄에만 있는 것이 있다 — **영속되지 않는 응답 데이터**(조치 제안 카드·접지 경고).\n * 그래서 화면에서 실행 버튼이 사라졌다(제안 도구는 분명히 호출됐는데도).\n *\n * 흡수하면 서버가 보낸 본문(멘션 마커 포함)을 그 줄에 입히면서 대기 표시를 풀 수 있어, 답이 더\n * 빨리 보이는 효과도 있다. */\n if (m.role === 'assistant') {\n const idx = lines.findIndex(l => l.role === 'assistant' && !l.id && !!l._localId)\n if (idx >= 0) {\n const nextLines = [...lines]\n nextLines[idx] = {\n ...nextLines[idx],\n id: m.id,\n /* 본문·영속 데이터는 서버 것으로 갱신하고, 이 줄이 들고 있던 값(제안 등)은 유지한다. */\n content: m.content,\n patchId: m.relatedPatchId ?? nextLines[idx].patchId,\n toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : nextLines[idx].toolUsages,\n groundingWarnings: Array.isArray(m.groundingWarningsJson)\n ? m.groundingWarningsJson\n : nextLines[idx].groundingWarnings,\n pending: false\n }\n return { lines: nextLines, seen: nextSeen }\n }\n }\n\n if (m.role === 'user') {\n const echoEmail = m.creator?.email\n const idx = lines.findIndex(\n l =>\n l.role === 'user' &&\n !l.id &&\n !!l._localId &&\n (!echoEmail || l.senderEmail == null || l.senderEmail === echoEmail)\n )\n if (idx >= 0) {\n const nextLines = [...lines]\n nextLines[idx] = {\n ...nextLines[idx],\n id: m.id,\n senderEmail: echoEmail ?? nextLines[idx].senderEmail,\n senderName: m.creator?.name ?? nextLines[idx].senderName\n }\n return { lines: nextLines, seen: nextSeen }\n }\n }\n\n return { lines: [...lines, remoteMessageToLine(m)], seen: nextSeen }\n}\n\n/**\n * 전송한 optimistic 라인(_localId)을 서버 id 로 reconcile.\n * - **다른 줄**이 이미 그 serverId 를 갖고 있으면(옛 append 경로) optimistic 중복 라인 제거.\n * - 아니면 serverId 부여(+ seen 등록) + patch 병합 + _localId 해제.\n * - 해당 _localId 라인이 없으면 변화 없음.\n * 반환: 새 { lines, seen } (불변).\n */\nexport function reconcileSentLine(\n lines: DedupLine[],\n seen: Set<string>,\n localId: string,\n serverId: string | undefined,\n patch: Partial<DedupLine>\n): { lines: DedupLine[]; seen: Set<string> } {\n const idx = lines.findIndex(l => l._localId === localId)\n if (idx < 0) return { lines, seen }\n\n const nextLines = [...lines]\n /* seen 만 보고 지우면, 에코를 이 줄에 **흡수**한 경우(id 가 이미 이 줄에 있다) 자기 줄을 지운다.\n * 실제 중복은 \"다른 줄이 같은 id 를 가진\" 상태일 때뿐이다. */\n if (serverId && nextLines.some((l, i) => i !== idx && l.id === serverId)) {\n nextLines.splice(idx, 1)\n return { lines: nextLines, seen }\n }\n const nextSeen = serverId ? new Set(seen).add(serverId) : seen\n nextLines[idx] = { ...nextLines[idx], ...patch, id: serverId ?? nextLines[idx].id, _localId: undefined }\n return { lines: nextLines, seen: nextSeen }\n}\n"]}
@@ -133,9 +133,23 @@ describe('내 발신 에코 흡수 (대기 중 중복 렌더 방지)', () => {
133
133
  expect(r.lines[0].id).toBeUndefined(); // 내 대기 줄은 그대로
134
134
  expect(r.lines[2]).toMatchObject({ id: 'S9', senderEmail: 'other@x.com' });
135
135
  });
136
- it('assistant 방송은 흡수 대상이 아니다(append)', () => {
137
- const r = receiveRemoteMessage(pending(), new Set(), { id: 'S2', role: 'assistant', content: '답' });
138
- expect(r.lines).toHaveLength(3);
136
+ /* 이 테스트는 예전에 "assistant 방송은 흡수하지 않고 붙인다" 규칙으로 굳혀 두고 있었다.
137
+ * 그것이 실제 결함이었다 붙이면 뒤이은 mutation 응답이 대기 줄을 지우고, 줄에만 있던
138
+ * **영속되지 않는 응답 데이터**(조치 제안 카드·접지 경고)가 함께 사라진다. 규칙을 바로잡는다. */
139
+ it('assistant 방송도 대기 중인 AI 줄에 흡수한다 — 붙이면 제안 카드가 사라진다', () => {
140
+ const lines = [
141
+ { _localId: 'L1', role: 'user', content: '질문', senderEmail: 'me@x.com' },
142
+ { _localId: 'L2', role: 'assistant', content: '', pending: true }
143
+ ];
144
+ const r = receiveRemoteMessage(lines, new Set(), { id: 'S2', role: 'assistant', content: '답' });
145
+ expect(r.lines).toHaveLength(2);
146
+ expect(r.lines[1]).toMatchObject({ id: 'S2', content: '답', pending: false });
147
+ });
148
+ it('대기 중인 AI 줄이 없으면 붙인다 — 다른 참여자가 부른 답변', () => {
149
+ const settled = [{ id: 'S0', role: 'user', content: '질문' }];
150
+ const r = receiveRemoteMessage(settled, new Set(), { id: 'S2', role: 'assistant', content: '답' });
151
+ expect(r.lines).toHaveLength(2);
152
+ expect(r.lines[1].id).toBe('S2');
139
153
  });
140
154
  it('흡수한 뒤 mutation 응답 reconcile 은 그 줄을 지우지 않고 patch 를 병합한다', () => {
141
155
  const echo = receiveRemoteMessage(pending(), new Set(), {
@@ -150,4 +164,40 @@ describe('내 발신 에코 흡수 (대기 중 중복 렌더 방지)', () => {
150
164
  expect(after.lines[0]._localId).toBeUndefined();
151
165
  });
152
166
  });
167
+ describe('AI 답변 에코 흡수 — 영속되지 않는 응답 데이터를 잃지 않기', () => {
168
+ /* 실제 사고: 방송이 mutation 응답보다 먼저 와서 assistant 줄이 새로 붙고, 뒤이어 응답이 대기 줄을
169
+ * 지웠다. 대기 줄에만 있던 **조치 제안 카드**가 그때 사라졌다(도구는 분명히 호출됐는데도). */
170
+ const pendingAi = () => [
171
+ { id: 'u1', role: 'user', content: '지게차 한 대 추가해줘' },
172
+ { _localId: 'L2', role: 'assistant', content: '', pending: true, proposals: [{ command: 'resource.add' }] }
173
+ ];
174
+ it('대기 중인 AI 줄에 흡수한다 — 새 줄로 붙이지 않는다', () => {
175
+ const r = receiveRemoteMessage(pendingAi(), new Set(), {
176
+ id: 'a1',
177
+ role: 'assistant',
178
+ content: '제안해 두었습니다.'
179
+ });
180
+ expect(r.lines).toHaveLength(2);
181
+ expect(r.lines[1].id).toBe('a1');
182
+ expect(r.lines[1].content).toBe('제안해 두었습니다.');
183
+ });
184
+ it('제안 카드를 유지한다 — 영속되지 않는 값이라 잃으면 화면에서 버튼이 사라진다', () => {
185
+ const r = receiveRemoteMessage(pendingAi(), new Set(), {
186
+ id: 'a1',
187
+ role: 'assistant',
188
+ content: '제안해 두었습니다.'
189
+ });
190
+ expect(r.lines[1].proposals).toEqual([{ command: 'resource.add' }]);
191
+ });
192
+ it('대기 표시를 푼다 — 방송이 먼저 오면 답이 더 빨리 보인다', () => {
193
+ const r = receiveRemoteMessage(pendingAi(), new Set(), { id: 'a1', role: 'assistant', content: '답' });
194
+ expect(r.lines[1].pending).toBe(false);
195
+ });
196
+ it('대기 줄이 없으면(다른 참여자가 부른 AI 답변) 새 줄로 붙인다', () => {
197
+ const lines = [{ id: 'u1', role: 'user', content: '질문' }];
198
+ const r = receiveRemoteMessage(lines, new Set(), { id: 'a9', role: 'assistant', content: '답' });
199
+ expect(r.lines).toHaveLength(2);
200
+ expect(r.lines[1].id).toBe('a9');
201
+ });
202
+ });
153
203
  //# sourceMappingURL=chat-echo-dedup.test.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"chat-echo-dedup.test.js","sourceRoot":"","sources":["../../../client/components/chat-echo-dedup.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { receiveRemoteMessage, reconcileSentLine, remoteMessageToLine, type DedupLine } from './chat-echo-dedup'\n\nconst msg = (id: string, role = 'user', content = 'hi') => ({ id, role, content })\n\ndescribe('remoteMessageToLine', () => {\n it('필드 매핑 + relatedPatchId/toolUsagesJson 변환', () => {\n const line = remoteMessageToLine({\n id: 'm1',\n role: 'assistant',\n content: 'done',\n relatedPatchId: 'p1',\n toolUsagesJson: [{ name: 'x' }]\n })\n expect(line).toMatchObject({ id: 'm1', role: 'assistant', content: 'done', patchId: 'p1' })\n expect(line.toolUsages).toEqual([{ name: 'x' }])\n })\n\n it('toolUsagesJson 이 배열 아니면 undefined', () => {\n expect(remoteMessageToLine({ id: 'm', role: 'user', content: 'x', toolUsagesJson: null }).toolUsages).toBeUndefined()\n })\n\n it('creator → senderEmail/senderName (그룹챗 발신자 표시·자기 판정)', () => {\n const line = remoteMessageToLine({ id: 'm', role: 'user', content: 'x', creator: { name: 'Kim', email: 'u9@example.com' } })\n expect(line.senderEmail).toBe('u9@example.com')\n expect(line.senderName).toBe('Kim')\n })\n\n it('creator 없으면 sender 필드 undefined', () => {\n const line = remoteMessageToLine({ id: 'm', role: 'user', content: 'x', creator: null })\n expect(line.senderEmail).toBeUndefined()\n expect(line.senderName).toBeUndefined()\n })\n})\n\ndescribe('receiveRemoteMessage — 방송 수신/에코 차단', () => {\n it('신규 id → append + seen 등록', () => {\n const res = receiveRemoteMessage([], new Set(), msg('m1'))!\n expect(res).not.toBeNull()\n expect(res.lines).toHaveLength(1)\n expect(res.lines[0].id).toBe('m1')\n expect(res.seen.has('m1')).toBe(true)\n })\n\n it('이미 seen 인 id → null (에코·중복 차단)', () => {\n expect(receiveRemoteMessage([], new Set(['m1']), msg('m1'))).toBeNull()\n })\n\n it('id 없는 메시지 → null', () => {\n expect(receiveRemoteMessage([], new Set(), { role: 'user', content: 'x' } as any)).toBeNull()\n expect(receiveRemoteMessage([], new Set(), null)).toBeNull()\n })\n\n it('불변 — 원본 배열/셋 미변경', () => {\n const lines: DedupLine[] = []\n const seen = new Set<string>()\n receiveRemoteMessage(lines, seen, msg('m1'))\n expect(lines).toHaveLength(0)\n expect(seen.size).toBe(0)\n })\n\n it('다른 참여자 메시지 두 개 순차 append', () => {\n let lines: DedupLine[] = []\n let seen = new Set<string>()\n ;({ lines, seen } = receiveRemoteMessage(lines, seen, msg('a'))!)\n ;({ lines, seen } = receiveRemoteMessage(lines, seen, msg('b'))!)\n expect(lines.map(l => l.id)).toEqual(['a', 'b'])\n })\n})\n\ndescribe('reconcileSentLine — optimistic 라인 조정', () => {\n const optimistic = (): DedupLine[] => [{ _localId: 'L1', role: 'user', content: 'typed' }]\n\n it('정상(에코 전): serverId 부여 + seen 등록 + _localId 해제', () => {\n const res = reconcileSentLine(optimistic(), new Set(), 'L1', 'srv1', {})\n expect(res.lines).toHaveLength(1)\n expect(res.lines[0].id).toBe('srv1')\n expect(res.lines[0]._localId).toBeUndefined()\n expect(res.seen.has('srv1')).toBe(true)\n })\n\n it('레이스(에코 먼저): serverId 가 이미 seen → optimistic 중복 제거', () => {\n // 에코가 먼저 도착해 같은 메시지가 이미 append + seen 됨\n const lines: DedupLine[] = [\n { id: 'srv1', role: 'user', content: 'typed#enriched' }, // 방송 에코로 들어온 것\n { _localId: 'L1', role: 'user', content: 'typed' } // optimistic 중복\n ]\n const res = reconcileSentLine(lines, new Set(['srv1']), 'L1', 'srv1', {})\n expect(res.lines).toHaveLength(1)\n expect(res.lines[0].id).toBe('srv1')\n expect(res.lines.some(l => l._localId === 'L1')).toBe(false)\n })\n\n it('patch 병합 (AI 라인: pending → 실제 응답)', () => {\n const lines: DedupLine[] = [{ _localId: 'A1', role: 'assistant', content: '', pending: true }]\n const res = reconcileSentLine(lines, new Set(), 'A1', 'srv2', {\n content: 'reply',\n pending: false,\n patchId: 'p9'\n })\n expect(res.lines[0]).toMatchObject({ id: 'srv2', content: 'reply', pending: false, patchId: 'p9' })\n })\n\n it('serverId 없음(ad-hoc 세션): 라인 유지 + _localId 만 해제, seen 불변', () => {\n const seen = new Set<string>()\n const res = reconcileSentLine(optimistic(), seen, 'L1', undefined, {})\n expect(res.lines[0]._localId).toBeUndefined()\n expect(res.lines[0].id).toBeUndefined()\n expect(res.seen.size).toBe(0)\n })\n\n it('해당 _localId 없으면 변화 없음', () => {\n const lines = optimistic()\n const res = reconcileSentLine(lines, new Set(), 'NOPE', 'srv1', {})\n expect(res.lines).toBe(lines)\n })\n})\n\ndescribe('내 발신 에코 흡수 (대기 중 중복 렌더 방지)', () => {\n /* 방송은 mutation 응답보다 먼저 올 수 있고, 그 응답은 LLM 이 끝난 뒤에 온다.\n * 붙였다 나중에 지우면 사용자는 **대기 시간 내내** 자기 질문을 두 줄로 본다. */\n const pending = (): DedupLine[] => [\n { _localId: 'L1', role: 'user', content: '질문', senderEmail: 'me@x.com' },\n { _localId: 'L2', role: 'assistant', content: '', pending: true }\n ]\n\n it('내 에코는 대기 중인 내 줄에 id 를 입힌다 — 줄 수가 늘지 않는다', () => {\n const r = receiveRemoteMessage(pending(), new Set(), {\n id: 'S1',\n role: 'user',\n content: '질문',\n creator: { name: 'Me', email: 'me@x.com' }\n })!\n expect(r.lines).toHaveLength(2) // append 되지 않음\n expect(r.lines[0].id).toBe('S1')\n expect(r.lines[0]._localId).toBe('L1') // 이후 patch 병합을 위해 유지\n expect(r.seen.has('S1')).toBe(true)\n })\n\n it('발신자 email 이 없는 방송(단일 사용자·구형)도 내 줄에 흡수된다', () => {\n const r = receiveRemoteMessage(pending(), new Set(), { id: 'S1', role: 'user', content: '질문' })!\n expect(r.lines).toHaveLength(2)\n expect(r.lines[0].id).toBe('S1')\n })\n\n it('다른 사용자 메시지는 흡수하지 않고 append 한다 — 내 대기 줄을 삼키면 안 된다', () => {\n const r = receiveRemoteMessage(pending(), new Set(), {\n id: 'S9',\n role: 'user',\n content: '남의 말',\n creator: { name: 'Other', email: 'other@x.com' }\n })!\n expect(r.lines).toHaveLength(3)\n expect(r.lines[0].id).toBeUndefined() // 내 대기 줄은 그대로\n expect(r.lines[2]).toMatchObject({ id: 'S9', senderEmail: 'other@x.com' })\n })\n\n it('assistant 방송은 흡수 대상이 아니다(append)', () => {\n const r = receiveRemoteMessage(pending(), new Set(), { id: 'S2', role: 'assistant', content: '답' })!\n expect(r.lines).toHaveLength(3)\n })\n\n it('흡수한 뒤 mutation 응답 reconcile 은 그 줄을 지우지 않고 patch 를 병합한다', () => {\n const echo = receiveRemoteMessage(pending(), new Set(), {\n id: 'S1',\n role: 'user',\n content: '질문',\n creator: { email: 'me@x.com' }\n })!\n const after = reconcileSentLine(echo.lines, echo.seen, 'L1', 'S1', { content: '질문(보강)' })\n expect(after.lines).toHaveLength(2) // 자기 줄을 지우지 않음\n expect(after.lines[0]).toMatchObject({ id: 'S1', content: '질문(보강)' })\n expect(after.lines[0]._localId).toBeUndefined()\n })\n})\n"]}
1
+ {"version":3,"file":"chat-echo-dedup.test.js","sourceRoot":"","sources":["../../../client/components/chat-echo-dedup.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,mBAAmB,EAAkB,MAAM,mBAAmB,CAAA;AAEhH,MAAM,GAAG,GAAG,CAAC,EAAU,EAAE,IAAI,GAAG,MAAM,EAAE,OAAO,GAAG,IAAI,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAA;AAElF,QAAQ,CAAC,qBAAqB,EAAE,GAAG,EAAE;IACnC,EAAE,CAAC,0CAA0C,EAAE,GAAG,EAAE;QAClD,MAAM,IAAI,GAAG,mBAAmB,CAAC;YAC/B,EAAE,EAAE,IAAI;YACR,IAAI,EAAE,WAAW;YACjB,OAAO,EAAE,MAAM;YACf,cAAc,EAAE,IAAI;YACpB,cAAc,EAAE,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC;SAChC,CAAC,CAAA;QACF,MAAM,CAAC,IAAI,CAAC,CAAC,aAAa,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAA;QAC3F,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,OAAO,CAAC,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,CAAC,CAAA;IAClD,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,mCAAmC,EAAE,GAAG,EAAE;QAC3C,MAAM,CAAC,mBAAmB,CAAC,EAAE,EAAE,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,CAAC,CAAC,UAAU,CAAC,CAAC,aAAa,EAAE,CAAA;IACvH,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,qDAAqD,EAAE,GAAG,EAAE;QAC7D,MAAM,IAAI,GAAG,mBAAmB,CAAC,EAAE,EAAE,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,gBAAgB,EAAE,EAAE,CAAC,CAAA;QAC5H,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAA;QAC/C,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IACrC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,iCAAiC,EAAE,GAAG,EAAE;QACzC,MAAM,IAAI,GAAG,mBAAmB,CAAC,EAAE,EAAE,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAA;QACxF,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,aAAa,EAAE,CAAA;QACxC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,aAAa,EAAE,CAAA;IACzC,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA;AAEF,QAAQ,CAAC,oCAAoC,EAAE,GAAG,EAAE;IAClD,EAAE,CAAC,0BAA0B,EAAE,GAAG,EAAE;QAClC,MAAM,GAAG,GAAG,oBAAoB,CAAC,EAAE,EAAE,IAAI,GAAG,EAAE,EAAE,GAAG,CAAC,IAAI,CAAC,CAAE,CAAA;QAC3D,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAA;QAC1B,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;QACjC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAClC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACvC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,gCAAgC,EAAE,GAAG,EAAE;QACxC,MAAM,CAAC,oBAAoB,CAAC,EAAE,EAAE,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,EAAE,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAA;IACzE,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,kBAAkB,EAAE,GAAG,EAAE;QAC1B,MAAM,CAAC,oBAAoB,CAAC,EAAE,EAAE,IAAI,GAAG,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,EAAS,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAA;QAC7F,MAAM,CAAC,oBAAoB,CAAC,EAAE,EAAE,IAAI,GAAG,EAAE,EAAE,IAAI,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAA;IAC9D,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,kBAAkB,EAAE,GAAG,EAAE;QAC1B,MAAM,KAAK,GAAgB,EAAE,CAAA;QAC7B,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAA;QAC9B,oBAAoB,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,CAAC,CAAC,CAAA;QAC5C,MAAM,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;QAC7B,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IAC3B,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,0BAA0B,EAAE,GAAG,EAAE;QAClC,IAAI,KAAK,GAAgB,EAAE,CAAA;QAC3B,IAAI,IAAI,GAAG,IAAI,GAAG,EAAU,CAC3B;QAAA,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,oBAAoB,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,CAAC,GAAG,CAAC,CAAE,CAAC,CAChE;QAAA,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,oBAAoB,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,CAAC,GAAG,CAAC,CAAE,CAAC,CAAA;QACjE,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAA;IAClD,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA;AAEF,QAAQ,CAAC,sCAAsC,EAAE,GAAG,EAAE;IACpD,MAAM,UAAU,GAAG,GAAgB,EAAE,CAAC,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,CAAA;IAE1F,EAAE,CAAC,+CAA+C,EAAE,GAAG,EAAE;QACvD,MAAM,GAAG,GAAG,iBAAiB,CAAC,UAAU,EAAE,EAAE,IAAI,GAAG,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,CAAC,CAAA;QACxE,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;QACjC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;QACpC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,aAAa,EAAE,CAAA;QAC7C,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACzC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,mDAAmD,EAAE,GAAG,EAAE;QAC3D,wCAAwC;QACxC,MAAM,KAAK,GAAgB;YACzB,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,gBAAgB,EAAE,EAAE,eAAe;YACxE,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,gBAAgB;SACpE,CAAA;QACD,MAAM,GAAG,GAAG,iBAAiB,CAAC,KAAK,EAAE,IAAI,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,CAAC,CAAA;QACzE,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;QACjC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;QACpC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,QAAQ,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IAC9D,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,mCAAmC,EAAE,GAAG,EAAE;QAC3C,MAAM,KAAK,GAAgB,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAA;QAC9F,MAAM,GAAG,GAAG,iBAAiB,CAAC,KAAK,EAAE,IAAI,GAAG,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE;YAC5D,OAAO,EAAE,OAAO;YAChB,OAAO,EAAE,KAAK;YACd,OAAO,EAAE,IAAI;SACd,CAAC,CAAA;QACF,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAA;IACrG,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,wDAAwD,EAAE,GAAG,EAAE;QAChE,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAA;QAC9B,MAAM,GAAG,GAAG,iBAAiB,CAAC,UAAU,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE,CAAC,CAAA;QACtE,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,aAAa,EAAE,CAAA;QAC7C,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,aAAa,EAAE,CAAA;QACvC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IAC/B,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,uBAAuB,EAAE,GAAG,EAAE;QAC/B,MAAM,KAAK,GAAG,UAAU,EAAE,CAAA;QAC1B,MAAM,GAAG,GAAG,iBAAiB,CAAC,KAAK,EAAE,IAAI,GAAG,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,CAAC,CAAA;QACnE,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IAC/B,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA;AAEF,QAAQ,CAAC,4BAA4B,EAAE,GAAG,EAAE;IAC1C;uDACmD;IACnD,MAAM,OAAO,GAAG,GAAgB,EAAE,CAAC;QACjC,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,UAAU,EAAE;QACxE,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE;KAClE,CAAA;IAED,EAAE,CAAC,yCAAyC,EAAE,GAAG,EAAE;QACjD,MAAM,CAAC,GAAG,oBAAoB,CAAC,OAAO,EAAE,EAAE,IAAI,GAAG,EAAE,EAAE;YACnD,EAAE,EAAE,IAAI;YACR,IAAI,EAAE,MAAM;YACZ,OAAO,EAAE,IAAI;YACb,OAAO,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,UAAU,EAAE;SAC3C,CAAE,CAAA;QACH,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA,CAAC,eAAe;QAC/C,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAChC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA,CAAC,qBAAqB;QAC5D,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACrC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,yCAAyC,EAAE,GAAG,EAAE;QACjD,MAAM,CAAC,GAAG,oBAAoB,CAAC,OAAO,EAAE,EAAE,IAAI,GAAG,EAAE,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,CAAE,CAAA;QAChG,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;QAC/B,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAClC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,kDAAkD,EAAE,GAAG,EAAE;QAC1D,MAAM,CAAC,GAAG,oBAAoB,CAAC,OAAO,EAAE,EAAE,IAAI,GAAG,EAAE,EAAE;YACnD,EAAE,EAAE,IAAI;YACR,IAAI,EAAE,MAAM;YACZ,OAAO,EAAE,MAAM;YACf,OAAO,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,aAAa,EAAE;SACjD,CAAE,CAAA;QACH,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;QAC/B,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,aAAa,EAAE,CAAA,CAAC,cAAc;QACpD,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,aAAa,EAAE,CAAC,CAAA;IAC5E,CAAC,CAAC,CAAA;IAEF;;iEAE6D;IAC7D,EAAE,CAAC,kDAAkD,EAAE,GAAG,EAAE;QAC1D,MAAM,KAAK,GAAG;YACZ,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,UAAU,EAAE;YACxE,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE;SAClE,CAAA;QACD,MAAM,CAAC,GAAG,oBAAoB,CAAC,KAAY,EAAE,IAAI,GAAG,EAAE,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,GAAG,EAAE,CAAE,CAAA;QACvG,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;QAC/B,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAA;IAC9E,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,qCAAqC,EAAE,GAAG,EAAE;QAC7C,MAAM,OAAO,GAAG,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAA;QAC3D,MAAM,CAAC,GAAG,oBAAoB,CAAC,OAAc,EAAE,IAAI,GAAG,EAAE,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,GAAG,EAAE,CAAE,CAAA;QACzG,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;QAC/B,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAClC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,wDAAwD,EAAE,GAAG,EAAE;QAChE,MAAM,IAAI,GAAG,oBAAoB,CAAC,OAAO,EAAE,EAAE,IAAI,GAAG,EAAE,EAAE;YACtD,EAAE,EAAE,IAAI;YACR,IAAI,EAAE,MAAM;YACZ,OAAO,EAAE,IAAI;YACb,OAAO,EAAE,EAAE,KAAK,EAAE,UAAU,EAAE;SAC/B,CAAE,CAAA;QACH,MAAM,KAAK,GAAG,iBAAiB,CAAC,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,CAAA;QACzF,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA,CAAC,eAAe;QACnD,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,CAAA;QACrE,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,aAAa,EAAE,CAAA;IACjD,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA;AAEF,QAAQ,CAAC,qCAAqC,EAAE,GAAG,EAAE;IACnD;+DAC2D;IAC3D,MAAM,SAAS,GAAG,GAAG,EAAE,CAAC;QACtB,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,cAAc,EAAE;QACnD,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC,EAAE,OAAO,EAAE,cAAc,EAAE,CAAC,EAAE;KAC5G,CAAA;IAED,EAAE,CAAC,iCAAiC,EAAE,GAAG,EAAE;QACzC,MAAM,CAAC,GAAG,oBAAoB,CAAC,SAAS,EAAS,EAAE,IAAI,GAAG,EAAE,EAAE;YAC5D,EAAE,EAAE,IAAI;YACR,IAAI,EAAE,WAAW;YACjB,OAAO,EAAE,YAAY;SACtB,CAAE,CAAA;QACH,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;QAC/B,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAChC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAA;IAC/C,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,6CAA6C,EAAE,GAAG,EAAE;QACrD,MAAM,CAAC,GAAG,oBAAoB,CAAC,SAAS,EAAS,EAAE,IAAI,GAAG,EAAE,EAAE;YAC5D,EAAE,EAAE,IAAI;YACR,IAAI,EAAE,WAAW;YACjB,OAAO,EAAE,YAAY;SACtB,CAAE,CAAA;QACH,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,OAAO,CAAC,CAAC,EAAE,OAAO,EAAE,cAAc,EAAE,CAAC,CAAC,CAAA;IACrE,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,mCAAmC,EAAE,GAAG,EAAE;QAC3C,MAAM,CAAC,GAAG,oBAAoB,CAAC,SAAS,EAAS,EAAE,IAAI,GAAG,EAAE,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,GAAG,EAAE,CAAE,CAAA;QAC7G,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IACxC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,sCAAsC,EAAE,GAAG,EAAE;QAC9C,MAAM,KAAK,GAAG,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAA;QACzD,MAAM,CAAC,GAAG,oBAAoB,CAAC,KAAY,EAAE,IAAI,GAAG,EAAE,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,GAAG,EAAE,CAAE,CAAA;QACvG,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;QAC/B,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAClC,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA","sourcesContent":["import { receiveRemoteMessage, reconcileSentLine, remoteMessageToLine, type DedupLine } from './chat-echo-dedup'\n\nconst msg = (id: string, role = 'user', content = 'hi') => ({ id, role, content })\n\ndescribe('remoteMessageToLine', () => {\n it('필드 매핑 + relatedPatchId/toolUsagesJson 변환', () => {\n const line = remoteMessageToLine({\n id: 'm1',\n role: 'assistant',\n content: 'done',\n relatedPatchId: 'p1',\n toolUsagesJson: [{ name: 'x' }]\n })\n expect(line).toMatchObject({ id: 'm1', role: 'assistant', content: 'done', patchId: 'p1' })\n expect(line.toolUsages).toEqual([{ name: 'x' }])\n })\n\n it('toolUsagesJson 이 배열 아니면 undefined', () => {\n expect(remoteMessageToLine({ id: 'm', role: 'user', content: 'x', toolUsagesJson: null }).toolUsages).toBeUndefined()\n })\n\n it('creator → senderEmail/senderName (그룹챗 발신자 표시·자기 판정)', () => {\n const line = remoteMessageToLine({ id: 'm', role: 'user', content: 'x', creator: { name: 'Kim', email: 'u9@example.com' } })\n expect(line.senderEmail).toBe('u9@example.com')\n expect(line.senderName).toBe('Kim')\n })\n\n it('creator 없으면 sender 필드 undefined', () => {\n const line = remoteMessageToLine({ id: 'm', role: 'user', content: 'x', creator: null })\n expect(line.senderEmail).toBeUndefined()\n expect(line.senderName).toBeUndefined()\n })\n})\n\ndescribe('receiveRemoteMessage — 방송 수신/에코 차단', () => {\n it('신규 id → append + seen 등록', () => {\n const res = receiveRemoteMessage([], new Set(), msg('m1'))!\n expect(res).not.toBeNull()\n expect(res.lines).toHaveLength(1)\n expect(res.lines[0].id).toBe('m1')\n expect(res.seen.has('m1')).toBe(true)\n })\n\n it('이미 seen 인 id → null (에코·중복 차단)', () => {\n expect(receiveRemoteMessage([], new Set(['m1']), msg('m1'))).toBeNull()\n })\n\n it('id 없는 메시지 → null', () => {\n expect(receiveRemoteMessage([], new Set(), { role: 'user', content: 'x' } as any)).toBeNull()\n expect(receiveRemoteMessage([], new Set(), null)).toBeNull()\n })\n\n it('불변 — 원본 배열/셋 미변경', () => {\n const lines: DedupLine[] = []\n const seen = new Set<string>()\n receiveRemoteMessage(lines, seen, msg('m1'))\n expect(lines).toHaveLength(0)\n expect(seen.size).toBe(0)\n })\n\n it('다른 참여자 메시지 두 개 순차 append', () => {\n let lines: DedupLine[] = []\n let seen = new Set<string>()\n ;({ lines, seen } = receiveRemoteMessage(lines, seen, msg('a'))!)\n ;({ lines, seen } = receiveRemoteMessage(lines, seen, msg('b'))!)\n expect(lines.map(l => l.id)).toEqual(['a', 'b'])\n })\n})\n\ndescribe('reconcileSentLine — optimistic 라인 조정', () => {\n const optimistic = (): DedupLine[] => [{ _localId: 'L1', role: 'user', content: 'typed' }]\n\n it('정상(에코 전): serverId 부여 + seen 등록 + _localId 해제', () => {\n const res = reconcileSentLine(optimistic(), new Set(), 'L1', 'srv1', {})\n expect(res.lines).toHaveLength(1)\n expect(res.lines[0].id).toBe('srv1')\n expect(res.lines[0]._localId).toBeUndefined()\n expect(res.seen.has('srv1')).toBe(true)\n })\n\n it('레이스(에코 먼저): serverId 가 이미 seen → optimistic 중복 제거', () => {\n // 에코가 먼저 도착해 같은 메시지가 이미 append + seen 됨\n const lines: DedupLine[] = [\n { id: 'srv1', role: 'user', content: 'typed#enriched' }, // 방송 에코로 들어온 것\n { _localId: 'L1', role: 'user', content: 'typed' } // optimistic 중복\n ]\n const res = reconcileSentLine(lines, new Set(['srv1']), 'L1', 'srv1', {})\n expect(res.lines).toHaveLength(1)\n expect(res.lines[0].id).toBe('srv1')\n expect(res.lines.some(l => l._localId === 'L1')).toBe(false)\n })\n\n it('patch 병합 (AI 라인: pending → 실제 응답)', () => {\n const lines: DedupLine[] = [{ _localId: 'A1', role: 'assistant', content: '', pending: true }]\n const res = reconcileSentLine(lines, new Set(), 'A1', 'srv2', {\n content: 'reply',\n pending: false,\n patchId: 'p9'\n })\n expect(res.lines[0]).toMatchObject({ id: 'srv2', content: 'reply', pending: false, patchId: 'p9' })\n })\n\n it('serverId 없음(ad-hoc 세션): 라인 유지 + _localId 만 해제, seen 불변', () => {\n const seen = new Set<string>()\n const res = reconcileSentLine(optimistic(), seen, 'L1', undefined, {})\n expect(res.lines[0]._localId).toBeUndefined()\n expect(res.lines[0].id).toBeUndefined()\n expect(res.seen.size).toBe(0)\n })\n\n it('해당 _localId 없으면 변화 없음', () => {\n const lines = optimistic()\n const res = reconcileSentLine(lines, new Set(), 'NOPE', 'srv1', {})\n expect(res.lines).toBe(lines)\n })\n})\n\ndescribe('내 발신 에코 흡수 (대기 중 중복 렌더 방지)', () => {\n /* 방송은 mutation 응답보다 먼저 올 수 있고, 그 응답은 LLM 이 끝난 뒤에 온다.\n * 붙였다 나중에 지우면 사용자는 **대기 시간 내내** 자기 질문을 두 줄로 본다. */\n const pending = (): DedupLine[] => [\n { _localId: 'L1', role: 'user', content: '질문', senderEmail: 'me@x.com' },\n { _localId: 'L2', role: 'assistant', content: '', pending: true }\n ]\n\n it('내 에코는 대기 중인 내 줄에 id 를 입힌다 — 줄 수가 늘지 않는다', () => {\n const r = receiveRemoteMessage(pending(), new Set(), {\n id: 'S1',\n role: 'user',\n content: '질문',\n creator: { name: 'Me', email: 'me@x.com' }\n })!\n expect(r.lines).toHaveLength(2) // append 되지 않음\n expect(r.lines[0].id).toBe('S1')\n expect(r.lines[0]._localId).toBe('L1') // 이후 patch 병합을 위해 유지\n expect(r.seen.has('S1')).toBe(true)\n })\n\n it('발신자 email 이 없는 방송(단일 사용자·구형)도 내 줄에 흡수된다', () => {\n const r = receiveRemoteMessage(pending(), new Set(), { id: 'S1', role: 'user', content: '질문' })!\n expect(r.lines).toHaveLength(2)\n expect(r.lines[0].id).toBe('S1')\n })\n\n it('다른 사용자 메시지는 흡수하지 않고 append 한다 — 내 대기 줄을 삼키면 안 된다', () => {\n const r = receiveRemoteMessage(pending(), new Set(), {\n id: 'S9',\n role: 'user',\n content: '남의 말',\n creator: { name: 'Other', email: 'other@x.com' }\n })!\n expect(r.lines).toHaveLength(3)\n expect(r.lines[0].id).toBeUndefined() // 내 대기 줄은 그대로\n expect(r.lines[2]).toMatchObject({ id: 'S9', senderEmail: 'other@x.com' })\n })\n\n /* 이 테스트는 예전에 \"assistant 방송은 흡수하지 않고 붙인다\" 를 규칙으로 굳혀 두고 있었다.\n * 그것이 실제 결함이었다 — 붙이면 뒤이은 mutation 응답이 대기 줄을 지우고, 그 줄에만 있던\n * **영속되지 않는 응답 데이터**(조치 제안 카드·접지 경고)가 함께 사라진다. 규칙을 바로잡는다. */\n it('assistant 방송도 대기 중인 AI 줄에 흡수한다 — 붙이면 제안 카드가 사라진다', () => {\n const lines = [\n { _localId: 'L1', role: 'user', content: '질문', senderEmail: 'me@x.com' },\n { _localId: 'L2', role: 'assistant', content: '', pending: true }\n ]\n const r = receiveRemoteMessage(lines as any, new Set(), { id: 'S2', role: 'assistant', content: '답' })!\n expect(r.lines).toHaveLength(2)\n expect(r.lines[1]).toMatchObject({ id: 'S2', content: '답', pending: false })\n })\n\n it('대기 중인 AI 줄이 없으면 붙인다 — 다른 참여자가 부른 답변', () => {\n const settled = [{ id: 'S0', role: 'user', content: '질문' }]\n const r = receiveRemoteMessage(settled as any, new Set(), { id: 'S2', role: 'assistant', content: '답' })!\n expect(r.lines).toHaveLength(2)\n expect(r.lines[1].id).toBe('S2')\n })\n\n it('흡수한 뒤 mutation 응답 reconcile 은 그 줄을 지우지 않고 patch 를 병합한다', () => {\n const echo = receiveRemoteMessage(pending(), new Set(), {\n id: 'S1',\n role: 'user',\n content: '질문',\n creator: { email: 'me@x.com' }\n })!\n const after = reconcileSentLine(echo.lines, echo.seen, 'L1', 'S1', { content: '질문(보강)' })\n expect(after.lines).toHaveLength(2) // 자기 줄을 지우지 않음\n expect(after.lines[0]).toMatchObject({ id: 'S1', content: '질문(보강)' })\n expect(after.lines[0]._localId).toBeUndefined()\n })\n})\n\ndescribe('AI 답변 에코 흡수 — 영속되지 않는 응답 데이터를 잃지 않기', () => {\n /* 실제 사고: 방송이 mutation 응답보다 먼저 와서 assistant 줄이 새로 붙고, 뒤이어 응답이 대기 줄을\n * 지웠다. 대기 줄에만 있던 **조치 제안 카드**가 그때 사라졌다(도구는 분명히 호출됐는데도). */\n const pendingAi = () => [\n { id: 'u1', role: 'user', content: '지게차 한 대 추가해줘' },\n { _localId: 'L2', role: 'assistant', content: '', pending: true, proposals: [{ command: 'resource.add' }] }\n ]\n\n it('대기 중인 AI 줄에 흡수한다 — 새 줄로 붙이지 않는다', () => {\n const r = receiveRemoteMessage(pendingAi() as any, new Set(), {\n id: 'a1',\n role: 'assistant',\n content: '제안해 두었습니다.'\n })!\n expect(r.lines).toHaveLength(2)\n expect(r.lines[1].id).toBe('a1')\n expect(r.lines[1].content).toBe('제안해 두었습니다.')\n })\n\n it('제안 카드를 유지한다 — 영속되지 않는 값이라 잃으면 화면에서 버튼이 사라진다', () => {\n const r = receiveRemoteMessage(pendingAi() as any, new Set(), {\n id: 'a1',\n role: 'assistant',\n content: '제안해 두었습니다.'\n })!\n expect(r.lines[1].proposals).toEqual([{ command: 'resource.add' }])\n })\n\n it('대기 표시를 푼다 — 방송이 먼저 오면 답이 더 빨리 보인다', () => {\n const r = receiveRemoteMessage(pendingAi() as any, new Set(), { id: 'a1', role: 'assistant', content: '답' })!\n expect(r.lines[1].pending).toBe(false)\n })\n\n it('대기 줄이 없으면(다른 참여자가 부른 AI 답변) 새 줄로 붙인다', () => {\n const lines = [{ id: 'u1', role: 'user', content: '질문' }]\n const r = receiveRemoteMessage(lines as any, new Set(), { id: 'a9', role: 'assistant', content: '답' })!\n expect(r.lines).toHaveLength(2)\n expect(r.lines[1].id).toBe('a9')\n })\n})\n"]}
@@ -72,6 +72,14 @@ export interface AgenticLoopResult {
72
72
  * 잘려 나간 식별자를 "근거 없음" 으로 오판한다. 여기 담기는 것은 tool_result 로 보낸 원문 그대로다.
73
73
  */
74
74
  groundedTexts: string[];
75
+ /**
76
+ * **제안** — 도구가 "실행하지 않고 제안만 했다" 고 알린 결과들(시간순).
77
+ *
78
+ * 규약: read/external 도구의 결과에 `proposed === true` 가 있으면 그것은 조치 제안이다. 그 도구가
79
+ * 어느 도메인인지 이 루프는 모른다 — 결과를 그대로 실어 올리고, 해석·실행 확인은 대화면이 한다.
80
+ * 되돌릴 수 없는 조치를 AI 가 직접 실행하지 않게 하는 경계가 이 채널이다(사람이 버튼을 누른다).
81
+ */
82
+ proposals: any[];
75
83
  /** LLM 마지막 응답의 stopReason. provider-specific 문자열. */
76
84
  stopReason: string;
77
85
  /**
@@ -104,3 +112,10 @@ export interface AgenticLoopResult {
104
112
  * 본 함수는 *순수* — input 만 보고 결정. 외부 mutable state 없음.
105
113
  */
106
114
  export declare function runAgenticLoop(input: AgenticLoopInput): Promise<AgenticLoopResult>;
115
+ /**
116
+ * 제안의 **효과 키** — 도구·명령·대상·인자만 본다(설명 문구는 제외).
117
+ *
118
+ * 인자는 **키 순서를 정렬**해 문자열로 만든다: 같은 인자를 다른 순서로 받았을 뿐인데 다른 제안으로
119
+ * 보이면 카드가 둘이 되고, 사용자가 같은 명령을 두 번 실행할 수 있다.
120
+ */
121
+ export declare function proposalEffectKey(p: any): string;
@@ -11,6 +11,7 @@
11
11
  * 의존하지만 본 모듈은 board-ai 의 어떤 무거운 import 도 모름.
12
12
  */
13
13
  import debug from 'debug';
14
+ import { retryTransient } from '@things-factory/ai-client-base';
14
15
  const log = debug('things-factory:board-ai:loop');
15
16
  // ── Loop ─────────────────────────────────────────────────────────
16
17
  const DEFAULT_MAX_ITERATIONS = 8;
@@ -33,6 +34,8 @@ export async function runAgenticLoop(input) {
33
34
  const toolUsages = [];
34
35
  /* 접지 근거 — tool_result 로 LLM 에 보낸 원문(압축 전). 호출부가 답의 접지를 검사한다. */
35
36
  const groundedTexts = [];
37
+ /* 제안 — 도구가 실행하지 않고 제안만 한 것들. 실행 버튼은 대화면이 그린다. */
38
+ const proposals = [];
36
39
  let lastText;
37
40
  let stopReason = 'end_turn';
38
41
  let abortReason;
@@ -50,8 +53,12 @@ export async function runAgenticLoop(input) {
50
53
  log('iter %d: chat() 호출', iter);
51
54
  let result;
52
55
  try {
53
- // (2) Error boundary — provider 예외 시 graceful 종료
54
- result = await chat(conversation, tools, {
56
+ /* (2) Error boundary — provider 예외 시 graceful 종료.
57
+ *
58
+ * 일시 장애(429·5xx·네트워크)는 **다시 시도한다**: 모델 서비스는 가끔 잠깐 죽고(실제로 Gemini
59
+ * 503 으로 대화가 끊겼다), 그때 한 번도 다시 걸지 않는 것은 우리 쪽 손해다. 요청·자격 오류는
60
+ * 즉시 그대로 올린다(조용히 더 시도하면 원인이 늦게 드러난다). 판정·대기는 ai-client-base 가 소유. */
61
+ result = await retryTransient(() => chat(conversation, tools, {
55
62
  systemPrompt: options.systemPrompt,
56
63
  model: options.model,
57
64
  temperature: options.temperature,
@@ -60,6 +67,8 @@ export async function runAgenticLoop(input) {
60
67
  toolChoice: iter === 0 && options.requireToolOnFirstTurn ? 'required' : 'auto',
61
68
  allowParallelToolCalls: true,
62
69
  signal
70
+ }), {
71
+ onRetry: ({ attempt, delayMs, error }) => log('iter %d: 일시 장애 재시도 %d회 (%dms 후): %s', iter, attempt, delayMs, error?.message)
63
72
  });
64
73
  }
65
74
  catch (e) {
@@ -87,7 +96,7 @@ export async function runAgenticLoop(input) {
87
96
  // 현재 iter tool 실행을 중단하면 read tool 결과도 버려져 테스트/동작 불일치.
88
97
  // tool 결과 빌드 — read 는 즉시 실행, write 는 누적, action 은 별도 채널
89
98
  const beforeUsages = toolUsages.length;
90
- const toolResultParts = await processToolCalls(result.toolCalls, currentBoard, selectedRefids, dispatch, accumulatedWriteCalls, accumulatedActions, toolUsages);
99
+ const toolResultParts = await processToolCalls(result.toolCalls, currentBoard, selectedRefids, dispatch, accumulatedWriteCalls, accumulatedActions, toolUsages, proposals, iter);
91
100
  /* 접지 근거 누적 — 모델이 tool_result 로 받은 원문 그대로. 압축 전 값이어야 한다(위 필드 주석). */
92
101
  for (const part of toolResultParts) {
93
102
  if (typeof part?.content === 'string')
@@ -139,10 +148,29 @@ export async function runAgenticLoop(input) {
139
148
  accumulatedActions,
140
149
  toolUsages,
141
150
  groundedTexts,
151
+ proposals,
142
152
  stopReason,
143
153
  abortReason
144
154
  };
145
155
  }
156
+ /**
157
+ * 제안의 **효과 키** — 도구·명령·대상·인자만 본다(설명 문구는 제외).
158
+ *
159
+ * 인자는 **키 순서를 정렬**해 문자열로 만든다: 같은 인자를 다른 순서로 받았을 뿐인데 다른 제안으로
160
+ * 보이면 카드가 둘이 되고, 사용자가 같은 명령을 두 번 실행할 수 있다.
161
+ */
162
+ export function proposalEffectKey(p) {
163
+ return [p?.tool ?? '', p?.command ?? '', p?.instanceId ?? '', stableStringify(p?.args)].join('|');
164
+ }
165
+ /** 키 정렬 직렬화 — 순서에 흔들리지 않는 비교를 위해. */
166
+ function stableStringify(value) {
167
+ if (value === null || typeof value !== 'object')
168
+ return JSON.stringify(value ?? null);
169
+ if (Array.isArray(value))
170
+ return `[${value.map(stableStringify).join(',')}]`;
171
+ const keys = Object.keys(value).sort();
172
+ return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
173
+ }
146
174
  /**
147
175
  * 같은 tool 의 validation 실패 연속 감지 — pure helper.
148
176
  *
@@ -185,7 +213,9 @@ function detectRepeatedValidationFailure(newUsages, lastFailedToolName, consecut
185
213
  /**
186
214
  * 한 iteration 의 tool 결과를 분류·실행·누적. (loop 에서 분리해 읽기 쉽게.)
187
215
  */
188
- async function processToolCalls(toolCalls, currentBoard, selectedRefids, dispatch, accumulatedWriteCalls, accumulatedActions, toolUsages) {
216
+ async function processToolCalls(toolCalls, currentBoard, selectedRefids, dispatch, accumulatedWriteCalls, accumulatedActions, toolUsages, proposals,
217
+ /** 몇 번째 LLM 턴인가 — 기록에 남겨 화면이 단계로 읽을 수 있게 한다. */
218
+ iter) {
189
219
  const toolResultParts = [];
190
220
  for (const tc of toolCalls) {
191
221
  if (dispatch.isReadTool(tc.name)) {
@@ -193,6 +223,27 @@ async function processToolCalls(toolCalls, currentBoard, selectedRefids, dispatc
193
223
  * await 없이 JSON.stringify 하면 Promise 가 '{}' 로 직렬화되어 LLM 이 **빈 결과**를 받는다
194
224
  * (board-import 의 getImportSession/importBoardAsync, operato-twin 의 getTwinStructure 등). */
195
225
  const value = await dispatch.executeReadTool(tc, currentBoard, selectedRefids);
226
+ /* 제안 규약 — 결과가 `proposed: true` 면 조치 제안이다(도메인은 모른 채 그대로 실어 올린다).
227
+ * 이 채널이 없으면 대화면은 도구 추적(trace)을 뒤져야 하고, 실행 버튼을 만들 근거가 없다. */
228
+ /* 판정(outcome) — 화면이 단계별 배지로 보여줄 값. 조용한 무동작을 남기지 않는다. */
229
+ let outcome = 'ok';
230
+ if (value && typeof value === 'object' && value.proposed === true) {
231
+ /* **같은 조치는 한 번만** — 모델이 같은 것을 두 번 제안하는 일이 실제로 있었다(하나는 인자가
232
+ * 비어 있었다). 카드가 둘이면 사용자가 같은 명령을 두 번 실행할 수 있고, 그건 되돌릴 수 없다.
233
+ * 판정은 **효과**(도구·명령·대상·인자)로 한다 — 설명 문구(label·reason)가 달라도 같은 조치다. */
234
+ const candidate = { tool: tc.name, ...value };
235
+ const key = proposalEffectKey(candidate);
236
+ if (proposals.some(p => proposalEffectKey(p) === key)) {
237
+ outcome = 'folded'; // 접힌 사실을 남긴다 — "왜 카드가 하나뿐인가" 가 추적 가능해야 한다
238
+ }
239
+ else {
240
+ proposals.push(candidate);
241
+ outcome = 'proposed';
242
+ }
243
+ }
244
+ else if (value && typeof value === 'object' && (value.rejected === true || value.error)) {
245
+ outcome = 'rejected';
246
+ }
196
247
  toolResultParts.push({
197
248
  type: 'tool_result',
198
249
  toolUseId: tc.id,
@@ -202,7 +253,9 @@ async function processToolCalls(toolCalls, currentBoard, selectedRefids, dispatc
202
253
  name: tc.name,
203
254
  arguments: tc.arguments ?? {},
204
255
  result: dispatch.summarizeToolResult(value),
205
- kind: 'read'
256
+ kind: 'read',
257
+ iter,
258
+ outcome
206
259
  });
207
260
  }
208
261
  else if (dispatch.isWriteTool(tc.name)) {
@@ -226,7 +279,9 @@ async function processToolCalls(toolCalls, currentBoard, selectedRefids, dispatc
226
279
  name: tc.name,
227
280
  arguments: tc.arguments ?? {},
228
281
  result: errorResult,
229
- kind: 'write'
282
+ kind: 'write',
283
+ iter,
284
+ outcome: 'rejected'
230
285
  });
231
286
  // continue — 누적 안 함. LLM 이 다음 turn 에서 정정 호출.
232
287
  }
@@ -242,7 +297,9 @@ async function processToolCalls(toolCalls, currentBoard, selectedRefids, dispatc
242
297
  name: tc.name,
243
298
  arguments: tc.arguments ?? {},
244
299
  result: queuedResult,
245
- kind: 'write'
300
+ kind: 'write',
301
+ iter,
302
+ outcome: 'queued'
246
303
  });
247
304
  }
248
305
  }
@@ -264,7 +321,9 @@ async function processToolCalls(toolCalls, currentBoard, selectedRefids, dispatc
264
321
  name: tc.name,
265
322
  arguments: tc.arguments ?? {},
266
323
  result: queuedResult,
267
- kind: 'write'
324
+ kind: 'write',
325
+ iter,
326
+ outcome: 'queued'
268
327
  });
269
328
  }
270
329
  else {
@@ -279,7 +338,9 @@ async function processToolCalls(toolCalls, currentBoard, selectedRefids, dispatc
279
338
  name: tc.name,
280
339
  arguments: tc.arguments ?? {},
281
340
  result: errResult,
282
- kind: 'unknown'
341
+ kind: 'unknown',
342
+ iter,
343
+ outcome: 'error'
283
344
  });
284
345
  }
285
346
  }
@@ -1 +1 @@
1
- {"version":3,"file":"agentic-loop.js","sourceRoot":"","sources":["../../../server/service/agentic-loop.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * agentic-loop — multi-turn LLM ↔ tool 루프.\n *\n * assistant.ts 에서 추출 — loop 자체를 *순수 함수* 로 만들어:\n * 1. 단위 테스트 가능 (assistant.ts 의 board-import → @aws-sdk transitive 경로 회피)\n * 2. 관측성 (error boundary / iter log / AbortSignal / repeated-failure 조기 종료)\n * 을 한 곳에 응집\n * 3. 향후 split (Phase 3) 의 자연 경계\n *\n * 입력 — `chat` 함수와 dispatch helper 들을 의존성 주입. board-ai 본체는 본 모듈에\n * 의존하지만 본 모듈은 board-ai 의 어떤 무거운 import 도 모름.\n */\nimport debug from 'debug'\nimport type {\n AIMessage,\n AIToolCall,\n AIToolChatResult\n} from '@things-factory/ai-client-base'\nimport type { BoardModel } from '@things-factory/board-import'\nimport type { BoardActionOp, ToolUsage } from './types'\n\nconst log = debug('things-factory:board-ai:loop')\n\n// ── Public types ─────────────────────────────────────────────────\n\n/**\n * Loop 가 호출하는 dispatch 함수들. 모두 의존성 주입.\n *\n * assistant.ts 가 자기 자신의 함수 (isReadTool, executeReadTool 등) 를 그대로\n * 넘겨주는 구조. 본 모듈은 이 함수들의 *시그니처* 만 알면 됨.\n */\nexport interface ToolDispatchHelpers {\n isReadTool: (name: string) => boolean\n isWriteTool: (name: string) => boolean\n isActionTool: (name: string) => boolean\n /** read/external 도구 실행. **Promise 를 반환할 수 있다** — 호출부는 반드시 await 한다. */\n executeReadTool: (\n tc: AIToolCall,\n board: BoardModel | undefined,\n selectedRefids: number[]\n ) => any | Promise<any>\n validateWriteToolCall: (\n tc: AIToolCall,\n components: any[]\n ) => { valid: boolean; errors?: any[]; suggestion?: string }\n toolCallToBoardActionOp: (tc: AIToolCall) => BoardActionOp | null\n summarizeToolResult: (value: any) => any\n}\n\nexport interface AgenticLoopOptions {\n systemPrompt: string\n model?: string\n temperature?: number\n maxTokens?: number\n /** 최대 iteration. 기본 8. */\n maxIterations?: number\n /**\n * 사용자 abort 신호. iteration 시작 직전 / chat() 호출 후 체크.\n * 이미 abort 된 신호로 들어오면 첫 iteration 도 안 돔.\n */\n signal?: AbortSignal\n /**\n * 같은 tool 의 validation 실패가 N회 연속이면 조기 종료. 기본 3.\n * LLM 이 같은 잘못을 무한 반복하는 경우 방어.\n */\n repeatedFailureLimit?: number\n /**\n * **첫 턴에 도구 호출을 강제**한다(toolChoice: 'required'). 두 번째 턴부터는 자유(auto).\n *\n * 왜: 접지 가드는 답이 지어낸 **식별자**를 잡지만, 식별자 없이 지어낸 **상황 서술**은 잡지 못한다\n * (\"지게차들이 바쁘게 움직이고 있다\" — 실제로 이 답이 나왔다). 그건 도구를 아예 부르지 않고 답한\n * 경우이고, 사후 검사로는 막을 수 없다. 첫 턴에 무엇이든 조회하게 만들면 근거 없이 상태를 단언할\n * 길이 없어진다 — 개념·사용법 질문도 문서 조회 도구로 답하므로 막히지 않는다.\n *\n * 대화면이 정한다(라이브 상태를 다루는 면에서만 켠다) — 여기서 질문 내용을 눈치로 분류하지 않는다.\n */\n requireToolOnFirstTurn?: boolean\n}\n\nexport interface AgenticLoopInput {\n initialConversation: AIMessage[]\n tools: any[]\n options: AgenticLoopOptions\n /** LLM provider 의 chatWithTools 위임. provider-specific 구현. */\n chat: (\n messages: AIMessage[],\n tools: any[],\n options: any\n ) => Promise<AIToolChatResult>\n dispatch: ToolDispatchHelpers\n currentBoard: BoardModel | undefined\n selectedRefids: number[]\n}\n\nexport interface AgenticLoopResult {\n lastText: string | undefined\n accumulatedWriteCalls: AIToolCall[]\n accumulatedActions: BoardActionOp[]\n toolUsages: ToolUsage[]\n /**\n * LLM 에게 실제로 회신된 도구 결과 문자열(시간순) — **접지 판정의 근거집합**.\n *\n * `toolUsages[].result` 를 쓰면 안 된다: 그건 UI 표시용으로 압축된(잘린) 값이라 근거로 삼으면\n * 잘려 나간 식별자를 \"근거 없음\" 으로 오판한다. 여기 담기는 것은 tool_result 로 보낸 원문 그대로다.\n */\n groundedTexts: string[]\n /** LLM 마지막 응답의 stopReason. provider-specific 문자열. */\n stopReason: string\n /**\n * 비정상 종료 사유 — 정상 종료 시 undefined.\n * 호출자 (assistant.ts) 가 사용자 메시지에 반영 (예: '시도 중 오류가 발생했어요').\n */\n abortReason?:\n | { type: 'provider_error'; message: string; iter: number }\n | { type: 'aborted'; iter: number }\n | { type: 'max_iterations'; iter: number }\n | { type: 'repeated_validation_failure'; toolName: string; consecutive: number; iter: number }\n}\n\n// ── Loop ─────────────────────────────────────────────────────────\n\nconst DEFAULT_MAX_ITERATIONS = 8\n\n/**\n * multi-turn agentic loop.\n *\n * LLM 이 read tool 을 호출 → 서버에서 즉시 실행 → 결과 회신 → LLM 추론 반복.\n * write tool 은 누적해 client 에 patch 로 전달. 무한 루프 방지를 위해 iteration cap.\n *\n * 본 함수는 *순수* — input 만 보고 결정. 외부 mutable state 없음.\n */\nexport async function runAgenticLoop(\n input: AgenticLoopInput\n): Promise<AgenticLoopResult> {\n const {\n initialConversation,\n tools,\n options,\n chat,\n dispatch,\n currentBoard,\n selectedRefids\n } = input\n\n const maxIterations = options.maxIterations ?? DEFAULT_MAX_ITERATIONS\n const failureLimit = options.repeatedFailureLimit ?? 3\n const signal = options.signal\n\n let conversation = initialConversation\n const accumulatedWriteCalls: AIToolCall[] = []\n const accumulatedActions: BoardActionOp[] = []\n const toolUsages: ToolUsage[] = []\n /* 접지 근거 — tool_result 로 LLM 에 보낸 원문(압축 전). 호출부가 답의 접지를 검사한다. */\n const groundedTexts: string[] = []\n let lastText: string | undefined\n let stopReason: string = 'end_turn'\n let abortReason: AgenticLoopResult['abortReason']\n\n // 같은 tool 의 validation 실패 연속 카운터 — 다른 tool 호출 시 reset\n let lastFailedToolName: string | undefined\n let consecutiveFailures = 0\n\n let iter = 0\n for (; iter < maxIterations; iter++) {\n // (1) AbortSignal — iteration 시작 시 체크\n if (signal?.aborted) {\n log('iter %d: aborted by signal', iter)\n abortReason = { type: 'aborted', iter }\n break\n }\n\n log('iter %d: chat() 호출', iter)\n let result: AIToolChatResult\n try {\n // (2) Error boundary — provider 예외 시 graceful 종료\n result = await chat(conversation, tools, {\n systemPrompt: options.systemPrompt,\n model: options.model,\n temperature: options.temperature,\n maxTokens: options.maxTokens,\n /* 첫 턴만 강제 — 이후는 자유. 계속 강제하면 조회 결과를 받아 놓고도 답을 못 쓰고 도구를 또 부른다. */\n toolChoice: iter === 0 && options.requireToolOnFirstTurn ? 'required' : 'auto',\n allowParallelToolCalls: true,\n signal\n })\n } catch (e: any) {\n // AbortError — 별도 분류\n if (e?.name === 'AbortError' || signal?.aborted) {\n log('iter %d: provider AbortError', iter)\n abortReason = { type: 'aborted', iter }\n } else {\n log('iter %d: provider error: %s', iter, e?.message)\n abortReason = {\n type: 'provider_error',\n message: e?.message ?? String(e),\n iter\n }\n }\n break\n }\n\n lastText = result.text || lastText\n stopReason = result.stopReason\n\n log(\n 'iter %d: text=%s, toolCalls=%d, stopReason=%s',\n iter,\n result.text ? `\"${result.text.slice(0, 60)}...\"` : '(none)',\n result.toolCalls.length,\n result.stopReason\n )\n\n if (result.toolCalls.length === 0) break\n\n // (1b) 삭제: chat() 직후 abort 는 다음 iter 시작 시 (1) 에서 감지.\n // 현재 iter tool 실행을 중단하면 read tool 결과도 버려져 테스트/동작 불일치.\n\n // tool 결과 빌드 — read 는 즉시 실행, write 는 누적, action 은 별도 채널\n const beforeUsages = toolUsages.length\n const toolResultParts = await processToolCalls(\n result.toolCalls,\n currentBoard,\n selectedRefids,\n dispatch,\n accumulatedWriteCalls,\n accumulatedActions,\n toolUsages\n )\n\n /* 접지 근거 누적 — 모델이 tool_result 로 받은 원문 그대로. 압축 전 값이어야 한다(위 필드 주석). */\n for (const part of toolResultParts) {\n if (typeof part?.content === 'string') groundedTexts.push(part.content)\n }\n\n // (4) 같은 tool 의 validation 실패 연속 감지 — LLM 이 같은 잘못 반복 시 abort\n const newUsages = toolUsages.slice(beforeUsages)\n const repeatedFailureAbort = detectRepeatedValidationFailure(\n newUsages,\n lastFailedToolName,\n consecutiveFailures,\n failureLimit\n )\n if (repeatedFailureAbort.shouldAbort) {\n log(\n 'iter %d: repeated validation failure on %s (%d consecutive) — abort',\n iter,\n repeatedFailureAbort.toolName,\n repeatedFailureAbort.consecutive\n )\n abortReason = {\n type: 'repeated_validation_failure',\n toolName: repeatedFailureAbort.toolName,\n consecutive: repeatedFailureAbort.consecutive,\n iter\n }\n break\n }\n lastFailedToolName = repeatedFailureAbort.lastFailedToolName\n consecutiveFailures = repeatedFailureAbort.consecutive\n\n // assistant 의 tool_use turn + user 의 tool_result turn 을 conversation 에 누적\n const assistantContent: any[] = []\n if (result.text) assistantContent.push({ type: 'text', text: result.text })\n for (const tc of result.toolCalls) {\n // providerMeta (Gemini thoughtSignature 등) 를 그대로 round-trip — 안 보내면 400.\n assistantContent.push({\n type: 'tool_use',\n id: tc.id,\n name: tc.name,\n arguments: tc.arguments,\n ...(tc.providerMeta && { providerMeta: tc.providerMeta })\n })\n }\n\n conversation = [\n ...conversation,\n { role: 'assistant', content: assistantContent },\n { role: 'user', content: toolResultParts }\n ]\n }\n\n // (3) max iterations 도달 시 사용자에게 표면화\n if (iter >= maxIterations && !abortReason) {\n log('iter %d: max iterations reached', iter)\n abortReason = { type: 'max_iterations', iter }\n }\n\n return {\n lastText,\n accumulatedWriteCalls,\n accumulatedActions,\n toolUsages,\n groundedTexts,\n stopReason,\n abortReason\n }\n}\n\n/**\n * 같은 tool 의 validation 실패 연속 감지 — pure helper.\n *\n * 같은 tool 이 반복 fail 하면 LLM 이 self-correct 못 하는 상태.\n * 다른 tool 이 fail 하면 시퀀스 새로 시작 (전혀 다른 패턴이라).\n * tool 이 succeed 하면 카운터 reset.\n */\nfunction detectRepeatedValidationFailure(\n newUsages: ToolUsage[],\n lastFailedToolName: string | undefined,\n consecutiveFailures: number,\n limit: number\n): {\n shouldAbort: boolean\n toolName: string\n consecutive: number\n lastFailedToolName: string | undefined\n} {\n // 이번 iteration 에서 실패한 write tool 들 (validation 거절)\n const failed = newUsages.filter(\n u => u.kind === 'write' && (u.result as any)?.error === 'Tool args failed validation'\n )\n\n if (failed.length === 0) {\n // 실패 없음 — reset\n return {\n shouldAbort: false,\n toolName: '',\n consecutive: 0,\n lastFailedToolName: undefined\n }\n }\n\n // 모든 실패가 같은 tool 인지 확인\n const firstName = failed[0].name\n const allSame = failed.every(u => u.name === firstName)\n\n if (allSame && firstName === lastFailedToolName) {\n const consecutive = consecutiveFailures + 1\n return {\n shouldAbort: consecutive >= limit,\n toolName: firstName,\n consecutive,\n lastFailedToolName: firstName\n }\n }\n\n // 다른 tool 또는 첫 실패\n return {\n shouldAbort: false,\n toolName: firstName,\n consecutive: 1,\n lastFailedToolName: firstName\n }\n}\n\n/**\n * 한 iteration 의 tool 결과를 분류·실행·누적. (loop 에서 분리해 읽기 쉽게.)\n */\nasync function processToolCalls(\n toolCalls: AIToolCall[],\n currentBoard: BoardModel | undefined,\n selectedRefids: number[],\n dispatch: ToolDispatchHelpers,\n accumulatedWriteCalls: AIToolCall[],\n accumulatedActions: BoardActionOp[],\n toolUsages: ToolUsage[]\n): Promise<any[]> {\n const toolResultParts: any[] = []\n\n for (const tc of toolCalls) {\n if (dispatch.isReadTool(tc.name)) {\n /* await 필수 — registry 에 등록된 read/external builder 는 async 일 수 있다(DB·외부 호출).\n * await 없이 JSON.stringify 하면 Promise 가 '{}' 로 직렬화되어 LLM 이 **빈 결과**를 받는다\n * (board-import 의 getImportSession/importBoardAsync, operato-twin 의 getTwinStructure 등). */\n const value = await dispatch.executeReadTool(tc, currentBoard, selectedRefids)\n toolResultParts.push({\n type: 'tool_result',\n toolUseId: tc.id,\n content: JSON.stringify(value)\n })\n toolUsages.push({\n name: tc.name,\n arguments: tc.arguments ?? {},\n result: dispatch.summarizeToolResult(value),\n kind: 'read'\n })\n } else if (dispatch.isWriteTool(tc.name)) {\n // 사전 검증 — args 가 schema / build 함수 검증 통과해야 누적.\n // 실패 시 LLM 한테 detailed error 반환 → 다음 turn 에서 자기 회복 (retry).\n const components = (currentBoard?.components ?? []) as any[]\n const validation = dispatch.validateWriteToolCall(tc, components)\n if (!validation.valid) {\n const errorResult = {\n error: 'Tool args failed validation',\n issues: validation.errors,\n suggestion: validation.suggestion,\n note: 'Fix the args and call the tool again.'\n }\n toolResultParts.push({\n type: 'tool_result',\n toolUseId: tc.id,\n content: JSON.stringify(errorResult)\n })\n toolUsages.push({\n name: tc.name,\n arguments: tc.arguments ?? {},\n result: errorResult,\n kind: 'write'\n })\n // continue — 누적 안 함. LLM 이 다음 turn 에서 정정 호출.\n } else {\n accumulatedWriteCalls.push(tc)\n const queuedResult = { queued: true, note: 'Will be applied on the client.' }\n toolResultParts.push({\n type: 'tool_result',\n toolUseId: tc.id,\n content: JSON.stringify(queuedResult)\n })\n toolUsages.push({\n name: tc.name,\n arguments: tc.arguments ?? {},\n result: queuedResult,\n kind: 'write'\n })\n }\n } else if (dispatch.isActionTool(tc.name)) {\n // ephemeral scene 조작 — accumulatedActions 로 누적, ChatResponse.actions 로 client 전달\n const action = dispatch.toolCallToBoardActionOp(tc)\n if (action) accumulatedActions.push(action)\n const queuedResult = {\n queued: true,\n note: 'Will be executed on the modeller (ephemeral).'\n }\n toolResultParts.push({\n type: 'tool_result',\n toolUseId: tc.id,\n content: JSON.stringify(queuedResult)\n })\n toolUsages.push({\n name: tc.name,\n arguments: tc.arguments ?? {},\n result: queuedResult,\n kind: 'write'\n })\n } else {\n const errResult = { error: `Unknown tool: ${tc.name}` }\n toolResultParts.push({\n type: 'tool_result',\n toolUseId: tc.id,\n content: JSON.stringify(errResult),\n isError: true\n })\n toolUsages.push({\n name: tc.name,\n arguments: tc.arguments ?? {},\n result: errResult,\n kind: 'unknown'\n })\n }\n }\n\n return toolResultParts\n}\n"]}
1
+ {"version":3,"file":"agentic-loop.js","sourceRoot":"","sources":["../../../server/service/agentic-loop.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * agentic-loop — multi-turn LLM ↔ tool 루프.\n *\n * assistant.ts 에서 추출 — loop 자체를 *순수 함수* 로 만들어:\n * 1. 단위 테스트 가능 (assistant.ts 의 board-import → @aws-sdk transitive 경로 회피)\n * 2. 관측성 (error boundary / iter log / AbortSignal / repeated-failure 조기 종료)\n * 을 한 곳에 응집\n * 3. 향후 split (Phase 3) 의 자연 경계\n *\n * 입력 — `chat` 함수와 dispatch helper 들을 의존성 주입. board-ai 본체는 본 모듈에\n * 의존하지만 본 모듈은 board-ai 의 어떤 무거운 import 도 모름.\n */\nimport debug from 'debug'\nimport type {\n AIMessage,\n AIToolCall,\n AIToolChatResult\n} from '@things-factory/ai-client-base'\nimport { retryTransient } from '@things-factory/ai-client-base'\nimport type { BoardModel } from '@things-factory/board-import'\nimport type { BoardActionOp, ToolUsage } from './types'\n\nconst log = debug('things-factory:board-ai:loop')\n\n// ── Public types ─────────────────────────────────────────────────\n\n/**\n * Loop 가 호출하는 dispatch 함수들. 모두 의존성 주입.\n *\n * assistant.ts 가 자기 자신의 함수 (isReadTool, executeReadTool 등) 를 그대로\n * 넘겨주는 구조. 본 모듈은 이 함수들의 *시그니처* 만 알면 됨.\n */\nexport interface ToolDispatchHelpers {\n isReadTool: (name: string) => boolean\n isWriteTool: (name: string) => boolean\n isActionTool: (name: string) => boolean\n /** read/external 도구 실행. **Promise 를 반환할 수 있다** — 호출부는 반드시 await 한다. */\n executeReadTool: (\n tc: AIToolCall,\n board: BoardModel | undefined,\n selectedRefids: number[]\n ) => any | Promise<any>\n validateWriteToolCall: (\n tc: AIToolCall,\n components: any[]\n ) => { valid: boolean; errors?: any[]; suggestion?: string }\n toolCallToBoardActionOp: (tc: AIToolCall) => BoardActionOp | null\n summarizeToolResult: (value: any) => any\n}\n\nexport interface AgenticLoopOptions {\n systemPrompt: string\n model?: string\n temperature?: number\n maxTokens?: number\n /** 최대 iteration. 기본 8. */\n maxIterations?: number\n /**\n * 사용자 abort 신호. iteration 시작 직전 / chat() 호출 후 체크.\n * 이미 abort 된 신호로 들어오면 첫 iteration 도 안 돔.\n */\n signal?: AbortSignal\n /**\n * 같은 tool 의 validation 실패가 N회 연속이면 조기 종료. 기본 3.\n * LLM 이 같은 잘못을 무한 반복하는 경우 방어.\n */\n repeatedFailureLimit?: number\n /**\n * **첫 턴에 도구 호출을 강제**한다(toolChoice: 'required'). 두 번째 턴부터는 자유(auto).\n *\n * 왜: 접지 가드는 답이 지어낸 **식별자**를 잡지만, 식별자 없이 지어낸 **상황 서술**은 잡지 못한다\n * (\"지게차들이 바쁘게 움직이고 있다\" — 실제로 이 답이 나왔다). 그건 도구를 아예 부르지 않고 답한\n * 경우이고, 사후 검사로는 막을 수 없다. 첫 턴에 무엇이든 조회하게 만들면 근거 없이 상태를 단언할\n * 길이 없어진다 — 개념·사용법 질문도 문서 조회 도구로 답하므로 막히지 않는다.\n *\n * 대화면이 정한다(라이브 상태를 다루는 면에서만 켠다) — 여기서 질문 내용을 눈치로 분류하지 않는다.\n */\n requireToolOnFirstTurn?: boolean\n}\n\nexport interface AgenticLoopInput {\n initialConversation: AIMessage[]\n tools: any[]\n options: AgenticLoopOptions\n /** LLM provider 의 chatWithTools 위임. provider-specific 구현. */\n chat: (\n messages: AIMessage[],\n tools: any[],\n options: any\n ) => Promise<AIToolChatResult>\n dispatch: ToolDispatchHelpers\n currentBoard: BoardModel | undefined\n selectedRefids: number[]\n}\n\nexport interface AgenticLoopResult {\n lastText: string | undefined\n accumulatedWriteCalls: AIToolCall[]\n accumulatedActions: BoardActionOp[]\n toolUsages: ToolUsage[]\n /**\n * LLM 에게 실제로 회신된 도구 결과 문자열(시간순) — **접지 판정의 근거집합**.\n *\n * `toolUsages[].result` 를 쓰면 안 된다: 그건 UI 표시용으로 압축된(잘린) 값이라 근거로 삼으면\n * 잘려 나간 식별자를 \"근거 없음\" 으로 오판한다. 여기 담기는 것은 tool_result 로 보낸 원문 그대로다.\n */\n groundedTexts: string[]\n /**\n * **제안** — 도구가 \"실행하지 않고 제안만 했다\" 고 알린 결과들(시간순).\n *\n * 규약: read/external 도구의 결과에 `proposed === true` 가 있으면 그것은 조치 제안이다. 그 도구가\n * 어느 도메인인지 이 루프는 모른다 — 결과를 그대로 실어 올리고, 해석·실행 확인은 대화면이 한다.\n * 되돌릴 수 없는 조치를 AI 가 직접 실행하지 않게 하는 경계가 이 채널이다(사람이 버튼을 누른다).\n */\n proposals: any[]\n /** LLM 마지막 응답의 stopReason. provider-specific 문자열. */\n stopReason: string\n /**\n * 비정상 종료 사유 — 정상 종료 시 undefined.\n * 호출자 (assistant.ts) 가 사용자 메시지에 반영 (예: '시도 중 오류가 발생했어요').\n */\n abortReason?:\n | { type: 'provider_error'; message: string; iter: number }\n | { type: 'aborted'; iter: number }\n | { type: 'max_iterations'; iter: number }\n | { type: 'repeated_validation_failure'; toolName: string; consecutive: number; iter: number }\n}\n\n// ── Loop ─────────────────────────────────────────────────────────\n\nconst DEFAULT_MAX_ITERATIONS = 8\n\n/**\n * multi-turn agentic loop.\n *\n * LLM 이 read tool 을 호출 → 서버에서 즉시 실행 → 결과 회신 → LLM 추론 반복.\n * write tool 은 누적해 client 에 patch 로 전달. 무한 루프 방지를 위해 iteration cap.\n *\n * 본 함수는 *순수* — input 만 보고 결정. 외부 mutable state 없음.\n */\nexport async function runAgenticLoop(\n input: AgenticLoopInput\n): Promise<AgenticLoopResult> {\n const {\n initialConversation,\n tools,\n options,\n chat,\n dispatch,\n currentBoard,\n selectedRefids\n } = input\n\n const maxIterations = options.maxIterations ?? DEFAULT_MAX_ITERATIONS\n const failureLimit = options.repeatedFailureLimit ?? 3\n const signal = options.signal\n\n let conversation = initialConversation\n const accumulatedWriteCalls: AIToolCall[] = []\n const accumulatedActions: BoardActionOp[] = []\n const toolUsages: ToolUsage[] = []\n /* 접지 근거 — tool_result 로 LLM 에 보낸 원문(압축 전). 호출부가 답의 접지를 검사한다. */\n const groundedTexts: string[] = []\n /* 제안 — 도구가 실행하지 않고 제안만 한 것들. 실행 버튼은 대화면이 그린다. */\n const proposals: any[] = []\n let lastText: string | undefined\n let stopReason: string = 'end_turn'\n let abortReason: AgenticLoopResult['abortReason']\n\n // 같은 tool 의 validation 실패 연속 카운터 — 다른 tool 호출 시 reset\n let lastFailedToolName: string | undefined\n let consecutiveFailures = 0\n\n let iter = 0\n for (; iter < maxIterations; iter++) {\n // (1) AbortSignal — iteration 시작 시 체크\n if (signal?.aborted) {\n log('iter %d: aborted by signal', iter)\n abortReason = { type: 'aborted', iter }\n break\n }\n\n log('iter %d: chat() 호출', iter)\n let result: AIToolChatResult\n try {\n /* (2) Error boundary — provider 예외 시 graceful 종료.\n *\n * 일시 장애(429·5xx·네트워크)는 **다시 시도한다**: 모델 서비스는 가끔 잠깐 죽고(실제로 Gemini\n * 503 으로 대화가 끊겼다), 그때 한 번도 다시 걸지 않는 것은 우리 쪽 손해다. 요청·자격 오류는\n * 즉시 그대로 올린다(조용히 더 시도하면 원인이 늦게 드러난다). 판정·대기는 ai-client-base 가 소유. */\n result = await retryTransient(() => chat(conversation, tools, {\n systemPrompt: options.systemPrompt,\n model: options.model,\n temperature: options.temperature,\n maxTokens: options.maxTokens,\n /* 첫 턴만 강제 — 이후는 자유. 계속 강제하면 조회 결과를 받아 놓고도 답을 못 쓰고 도구를 또 부른다. */\n toolChoice: iter === 0 && options.requireToolOnFirstTurn ? 'required' : 'auto',\n allowParallelToolCalls: true,\n signal\n }), {\n onRetry: ({ attempt, delayMs, error }) =>\n log('iter %d: 일시 장애 재시도 %d회 (%dms 후): %s', iter, attempt, delayMs, error?.message)\n })\n } catch (e: any) {\n // AbortError — 별도 분류\n if (e?.name === 'AbortError' || signal?.aborted) {\n log('iter %d: provider AbortError', iter)\n abortReason = { type: 'aborted', iter }\n } else {\n log('iter %d: provider error: %s', iter, e?.message)\n abortReason = {\n type: 'provider_error',\n message: e?.message ?? String(e),\n iter\n }\n }\n break\n }\n\n lastText = result.text || lastText\n stopReason = result.stopReason\n\n log(\n 'iter %d: text=%s, toolCalls=%d, stopReason=%s',\n iter,\n result.text ? `\"${result.text.slice(0, 60)}...\"` : '(none)',\n result.toolCalls.length,\n result.stopReason\n )\n\n if (result.toolCalls.length === 0) break\n\n // (1b) 삭제: chat() 직후 abort 는 다음 iter 시작 시 (1) 에서 감지.\n // 현재 iter tool 실행을 중단하면 read tool 결과도 버려져 테스트/동작 불일치.\n\n // tool 결과 빌드 — read 는 즉시 실행, write 는 누적, action 은 별도 채널\n const beforeUsages = toolUsages.length\n const toolResultParts = await processToolCalls(\n result.toolCalls,\n currentBoard,\n selectedRefids,\n dispatch,\n accumulatedWriteCalls,\n accumulatedActions,\n toolUsages,\n proposals,\n iter\n )\n\n /* 접지 근거 누적 — 모델이 tool_result 로 받은 원문 그대로. 압축 전 값이어야 한다(위 필드 주석). */\n for (const part of toolResultParts) {\n if (typeof part?.content === 'string') groundedTexts.push(part.content)\n }\n\n // (4) 같은 tool 의 validation 실패 연속 감지 — LLM 이 같은 잘못 반복 시 abort\n const newUsages = toolUsages.slice(beforeUsages)\n const repeatedFailureAbort = detectRepeatedValidationFailure(\n newUsages,\n lastFailedToolName,\n consecutiveFailures,\n failureLimit\n )\n if (repeatedFailureAbort.shouldAbort) {\n log(\n 'iter %d: repeated validation failure on %s (%d consecutive) — abort',\n iter,\n repeatedFailureAbort.toolName,\n repeatedFailureAbort.consecutive\n )\n abortReason = {\n type: 'repeated_validation_failure',\n toolName: repeatedFailureAbort.toolName,\n consecutive: repeatedFailureAbort.consecutive,\n iter\n }\n break\n }\n lastFailedToolName = repeatedFailureAbort.lastFailedToolName\n consecutiveFailures = repeatedFailureAbort.consecutive\n\n // assistant 의 tool_use turn + user 의 tool_result turn 을 conversation 에 누적\n const assistantContent: any[] = []\n if (result.text) assistantContent.push({ type: 'text', text: result.text })\n for (const tc of result.toolCalls) {\n // providerMeta (Gemini thoughtSignature 등) 를 그대로 round-trip — 안 보내면 400.\n assistantContent.push({\n type: 'tool_use',\n id: tc.id,\n name: tc.name,\n arguments: tc.arguments,\n ...(tc.providerMeta && { providerMeta: tc.providerMeta })\n })\n }\n\n conversation = [\n ...conversation,\n { role: 'assistant', content: assistantContent },\n { role: 'user', content: toolResultParts }\n ]\n }\n\n // (3) max iterations 도달 시 사용자에게 표면화\n if (iter >= maxIterations && !abortReason) {\n log('iter %d: max iterations reached', iter)\n abortReason = { type: 'max_iterations', iter }\n }\n\n return {\n lastText,\n accumulatedWriteCalls,\n accumulatedActions,\n toolUsages,\n groundedTexts,\n proposals,\n stopReason,\n abortReason\n }\n}\n\n/**\n * 제안의 **효과 키** — 도구·명령·대상·인자만 본다(설명 문구는 제외).\n *\n * 인자는 **키 순서를 정렬**해 문자열로 만든다: 같은 인자를 다른 순서로 받았을 뿐인데 다른 제안으로\n * 보이면 카드가 둘이 되고, 사용자가 같은 명령을 두 번 실행할 수 있다.\n */\nexport function proposalEffectKey(p: any): string {\n return [p?.tool ?? '', p?.command ?? '', p?.instanceId ?? '', stableStringify(p?.args)].join('|')\n}\n\n/** 키 정렬 직렬화 — 순서에 흔들리지 않는 비교를 위해. */\nfunction stableStringify(value: any): string {\n if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null)\n if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`\n const keys = Object.keys(value).sort()\n return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`\n}\n\n/**\n * 같은 tool 의 validation 실패 연속 감지 — pure helper.\n *\n * 같은 tool 이 반복 fail 하면 LLM 이 self-correct 못 하는 상태.\n * 다른 tool 이 fail 하면 시퀀스 새로 시작 (전혀 다른 패턴이라).\n * tool 이 succeed 하면 카운터 reset.\n */\nfunction detectRepeatedValidationFailure(\n newUsages: ToolUsage[],\n lastFailedToolName: string | undefined,\n consecutiveFailures: number,\n limit: number\n): {\n shouldAbort: boolean\n toolName: string\n consecutive: number\n lastFailedToolName: string | undefined\n} {\n // 이번 iteration 에서 실패한 write tool 들 (validation 거절)\n const failed = newUsages.filter(\n u => u.kind === 'write' && (u.result as any)?.error === 'Tool args failed validation'\n )\n\n if (failed.length === 0) {\n // 실패 없음 — reset\n return {\n shouldAbort: false,\n toolName: '',\n consecutive: 0,\n lastFailedToolName: undefined\n }\n }\n\n // 모든 실패가 같은 tool 인지 확인\n const firstName = failed[0].name\n const allSame = failed.every(u => u.name === firstName)\n\n if (allSame && firstName === lastFailedToolName) {\n const consecutive = consecutiveFailures + 1\n return {\n shouldAbort: consecutive >= limit,\n toolName: firstName,\n consecutive,\n lastFailedToolName: firstName\n }\n }\n\n // 다른 tool 또는 첫 실패\n return {\n shouldAbort: false,\n toolName: firstName,\n consecutive: 1,\n lastFailedToolName: firstName\n }\n}\n\n/**\n * 한 iteration 의 tool 결과를 분류·실행·누적. (loop 에서 분리해 읽기 쉽게.)\n */\nasync function processToolCalls(\n toolCalls: AIToolCall[],\n currentBoard: BoardModel | undefined,\n selectedRefids: number[],\n dispatch: ToolDispatchHelpers,\n accumulatedWriteCalls: AIToolCall[],\n accumulatedActions: BoardActionOp[],\n toolUsages: ToolUsage[],\n proposals: any[],\n /** 몇 번째 LLM 턴인가 — 기록에 남겨 화면이 단계로 읽을 수 있게 한다. */\n iter: number\n): Promise<any[]> {\n const toolResultParts: any[] = []\n\n for (const tc of toolCalls) {\n if (dispatch.isReadTool(tc.name)) {\n /* await 필수 — registry 에 등록된 read/external builder 는 async 일 수 있다(DB·외부 호출).\n * await 없이 JSON.stringify 하면 Promise 가 '{}' 로 직렬화되어 LLM 이 **빈 결과**를 받는다\n * (board-import 의 getImportSession/importBoardAsync, operato-twin 의 getTwinStructure 등). */\n const value = await dispatch.executeReadTool(tc, currentBoard, selectedRefids)\n /* 제안 규약 — 결과가 `proposed: true` 면 조치 제안이다(도메인은 모른 채 그대로 실어 올린다).\n * 이 채널이 없으면 대화면은 도구 추적(trace)을 뒤져야 하고, 실행 버튼을 만들 근거가 없다. */\n /* 판정(outcome) — 화면이 단계별 배지로 보여줄 값. 조용한 무동작을 남기지 않는다. */\n let outcome: ToolUsage['outcome'] = 'ok'\n if (value && typeof value === 'object' && (value as any).proposed === true) {\n /* **같은 조치는 한 번만** — 모델이 같은 것을 두 번 제안하는 일이 실제로 있었다(하나는 인자가\n * 비어 있었다). 카드가 둘이면 사용자가 같은 명령을 두 번 실행할 수 있고, 그건 되돌릴 수 없다.\n * 판정은 **효과**(도구·명령·대상·인자)로 한다 — 설명 문구(label·reason)가 달라도 같은 조치다. */\n const candidate = { tool: tc.name, ...(value as any) }\n const key = proposalEffectKey(candidate)\n if (proposals.some(p => proposalEffectKey(p) === key)) {\n outcome = 'folded' // 접힌 사실을 남긴다 — \"왜 카드가 하나뿐인가\" 가 추적 가능해야 한다\n } else {\n proposals.push(candidate)\n outcome = 'proposed'\n }\n } else if (value && typeof value === 'object' && ((value as any).rejected === true || (value as any).error)) {\n outcome = 'rejected'\n }\n toolResultParts.push({\n type: 'tool_result',\n toolUseId: tc.id,\n content: JSON.stringify(value)\n })\n toolUsages.push({\n name: tc.name,\n arguments: tc.arguments ?? {},\n result: dispatch.summarizeToolResult(value),\n kind: 'read',\n iter,\n outcome\n })\n } else if (dispatch.isWriteTool(tc.name)) {\n // 사전 검증 — args 가 schema / build 함수 검증 통과해야 누적.\n // 실패 시 LLM 한테 detailed error 반환 → 다음 turn 에서 자기 회복 (retry).\n const components = (currentBoard?.components ?? []) as any[]\n const validation = dispatch.validateWriteToolCall(tc, components)\n if (!validation.valid) {\n const errorResult = {\n error: 'Tool args failed validation',\n issues: validation.errors,\n suggestion: validation.suggestion,\n note: 'Fix the args and call the tool again.'\n }\n toolResultParts.push({\n type: 'tool_result',\n toolUseId: tc.id,\n content: JSON.stringify(errorResult)\n })\n toolUsages.push({\n name: tc.name,\n arguments: tc.arguments ?? {},\n result: errorResult,\n kind: 'write',\n iter,\n outcome: 'rejected'\n })\n // continue — 누적 안 함. LLM 이 다음 turn 에서 정정 호출.\n } else {\n accumulatedWriteCalls.push(tc)\n const queuedResult = { queued: true, note: 'Will be applied on the client.' }\n toolResultParts.push({\n type: 'tool_result',\n toolUseId: tc.id,\n content: JSON.stringify(queuedResult)\n })\n toolUsages.push({\n name: tc.name,\n arguments: tc.arguments ?? {},\n result: queuedResult,\n kind: 'write',\n iter,\n outcome: 'queued'\n })\n }\n } else if (dispatch.isActionTool(tc.name)) {\n // ephemeral scene 조작 — accumulatedActions 로 누적, ChatResponse.actions 로 client 전달\n const action = dispatch.toolCallToBoardActionOp(tc)\n if (action) accumulatedActions.push(action)\n const queuedResult = {\n queued: true,\n note: 'Will be executed on the modeller (ephemeral).'\n }\n toolResultParts.push({\n type: 'tool_result',\n toolUseId: tc.id,\n content: JSON.stringify(queuedResult)\n })\n toolUsages.push({\n name: tc.name,\n arguments: tc.arguments ?? {},\n result: queuedResult,\n kind: 'write',\n iter,\n outcome: 'queued'\n })\n } else {\n const errResult = { error: `Unknown tool: ${tc.name}` }\n toolResultParts.push({\n type: 'tool_result',\n toolUseId: tc.id,\n content: JSON.stringify(errResult),\n isError: true\n })\n toolUsages.push({\n name: tc.name,\n arguments: tc.arguments ?? {},\n result: errResult,\n kind: 'unknown',\n iter,\n outcome: 'error'\n })\n }\n }\n\n return toolResultParts\n}\n"]}