@things-factory/board-ai 10.0.1 → 10.0.3

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 (90) hide show
  1. package/client/components/board-ai-chat.ts +565 -21
  2. package/client/components/chat-echo-dedup.test.ts +59 -3
  3. package/client/components/chat-echo-dedup.ts +32 -0
  4. package/client/components/chat-input-builder.ts +6 -0
  5. package/dist-client/client/components/board-ai-chat.d.ts +80 -0
  6. package/dist-client/client/components/board-ai-chat.js +540 -18
  7. package/dist-client/client/components/board-ai-chat.js.map +1 -1
  8. package/dist-client/client/components/chat-echo-dedup.d.ts +2 -0
  9. package/dist-client/client/components/chat-echo-dedup.js +29 -0
  10. package/dist-client/client/components/chat-echo-dedup.js.map +1 -1
  11. package/dist-client/client/components/chat-echo-dedup.test.js +53 -3
  12. package/dist-client/client/components/chat-echo-dedup.test.js.map +1 -1
  13. package/dist-client/client/components/chat-input-builder.d.ts +5 -0
  14. package/dist-client/client/components/chat-input-builder.js +1 -0
  15. package/dist-client/client/components/chat-input-builder.js.map +1 -1
  16. package/dist-client/server/service/agentic-loop.d.ts +33 -0
  17. package/dist-client/server/service/agentic-loop.js +80 -10
  18. package/dist-client/server/service/agentic-loop.js.map +1 -1
  19. package/dist-client/server/service/assistant.js +32 -5
  20. package/dist-client/server/service/assistant.js.map +1 -1
  21. package/dist-client/server/service/grounding.d.ts +17 -0
  22. package/dist-client/server/service/grounding.js +42 -0
  23. package/dist-client/server/service/grounding.js.map +1 -0
  24. package/dist-client/server/service/types.d.ts +39 -0
  25. package/dist-client/server/service/types.js.map +1 -1
  26. package/dist-client/tsconfig.tsbuildinfo +1 -1
  27. package/dist-server/service/agentic-loop.d.ts +33 -0
  28. package/dist-server/service/agentic-loop.js +81 -10
  29. package/dist-server/service/agentic-loop.js.map +1 -1
  30. package/dist-server/service/assistant.js +31 -4
  31. package/dist-server/service/assistant.js.map +1 -1
  32. package/dist-server/service/board-ai-resolver.d.ts +15 -0
  33. package/dist-server/service/board-ai-resolver.js +121 -2
  34. package/dist-server/service/board-ai-resolver.js.map +1 -1
  35. package/dist-server/service/chat-message/chat-message.d.ts +12 -0
  36. package/dist-server/service/chat-message/chat-message.js +23 -0
  37. package/dist-server/service/chat-message/chat-message.js.map +1 -1
  38. package/dist-server/service/chat-message/fold-history.d.ts +30 -0
  39. package/dist-server/service/chat-message/fold-history.js +29 -0
  40. package/dist-server/service/chat-message/fold-history.js.map +1 -0
  41. package/dist-server/service/chat-message/history-summary.d.ts +43 -0
  42. package/dist-server/service/chat-message/history-summary.js +77 -0
  43. package/dist-server/service/chat-message/history-summary.js.map +1 -0
  44. package/dist-server/service/chat-message/llm-history.d.ts +19 -0
  45. package/dist-server/service/chat-message/llm-history.js +31 -1
  46. package/dist-server/service/chat-message/llm-history.js.map +1 -1
  47. package/dist-server/service/chat-session/chat-session.d.ts +8 -0
  48. package/dist-server/service/chat-session/chat-session.js +5 -0
  49. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  50. package/dist-server/service/chat-session/session-inbox.d.ts +26 -0
  51. package/dist-server/service/chat-session/session-inbox.js +41 -0
  52. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  53. package/dist-server/service/chat-session-participant/chat-session-participant.d.ts +11 -0
  54. package/dist-server/service/chat-session-participant/chat-session-participant.js +17 -1
  55. package/dist-server/service/chat-session-participant/chat-session-participant.js.map +1 -1
  56. package/dist-server/service/chat-session-resolver.d.ts +44 -1
  57. package/dist-server/service/chat-session-resolver.js +306 -6
  58. package/dist-server/service/chat-session-resolver.js.map +1 -1
  59. package/dist-server/service/grounding.d.ts +17 -0
  60. package/dist-server/service/grounding.js +46 -0
  61. package/dist-server/service/grounding.js.map +1 -0
  62. package/dist-server/service/types.d.ts +39 -0
  63. package/dist-server/service/types.js.map +1 -1
  64. package/dist-server/tsconfig.tsbuildinfo +1 -1
  65. package/package.json +6 -6
  66. package/server/service/agentic-loop.test.ts +154 -0
  67. package/server/service/agentic-loop.ts +108 -10
  68. package/server/service/assistant.ts +36 -5
  69. package/server/service/board-ai-resolver.ts +131 -2
  70. package/server/service/chat-message/chat-message.ts +26 -0
  71. package/server/service/chat-message/fold-history.test.ts +98 -0
  72. package/server/service/chat-message/fold-history.ts +60 -0
  73. package/server/service/chat-message/history-summary.test.ts +127 -0
  74. package/server/service/chat-message/history-summary.ts +100 -0
  75. package/server/service/chat-message/llm-history.test.ts +65 -0
  76. package/server/service/chat-message/llm-history.ts +48 -1
  77. package/server/service/chat-session/chat-session.ts +11 -0
  78. package/server/service/chat-session/session-inbox.test.ts +69 -1
  79. package/server/service/chat-session/session-inbox.ts +45 -0
  80. package/server/service/chat-session-participant/chat-session-participant.ts +14 -0
  81. package/server/service/chat-session-resolver.ts +297 -5
  82. package/server/service/dock-contract.test.ts +305 -0
  83. package/server/service/grounding.test.ts +55 -0
  84. package/server/service/grounding.ts +53 -0
  85. package/server/service/types.ts +39 -0
  86. package/translations/en.json +16 -1
  87. package/translations/ja.json +16 -1
  88. package/translations/ko.json +15 -0
  89. package/translations/ms.json +16 -1
  90. package/translations/zh.json +16 -1
@@ -14,7 +14,7 @@ import { In } from 'typeorm'
14
14
 
15
15
  import { ChatSession } from './chat-session/chat-session.js'
16
16
  import { anchorColumns, anchorWhere } from './chat-session/session-anchor.js'
17
- import { buildInbox, activityPatch } from './chat-session/session-inbox.js'
17
+ import { buildInbox, applyHidden, activityPatch, searchInbox, INBOX_SCAN_CAP } from './chat-session/session-inbox.js'
18
18
  import { publishSessionActivity } from './chat-session/session-activity-publish.js'
19
19
  import { ChatMessage } from './chat-message/chat-message.js'
20
20
  import { ChatSessionParticipant } from './chat-session-participant/chat-session-participant.js'
@@ -56,6 +56,15 @@ class ChatSessionInboxEntry {
56
56
 
57
57
  @Field({ description: 'Whether this viewer is a participant of the session (non-participants get no unread state).' })
58
58
  participant!: boolean
59
+
60
+ /* 삭제는 되돌릴 수 없으므로 **만든 사람**만 한다 — 클라이언트가 버튼을 보일지 판단하는 근거.
61
+ * (권한 판정은 서버가 다시 한다. 이 값은 UI 힌트일 뿐이다.) */
62
+ @Field({ description: 'Whether this viewer created the session — only the creator may delete it.' })
63
+ mine!: boolean
64
+
65
+ /* 숨김은 나에게만 적용되고 되돌릴 수 있다 — 필터를 켠 사용자가 무엇이 숨겨진 것인지 알아야 한다. */
66
+ @Field({ nullable: true, description: 'Whether this viewer hid the session from their own list (only returned when showHidden is on).' })
67
+ hidden?: boolean
59
68
  }
60
69
 
61
70
  @Resolver()
@@ -118,23 +127,190 @@ export class ChatSessionResolver {
118
127
  */
119
128
  @Query(() => [ChatSessionInboxEntry], {
120
129
  description:
121
- "List sessions anchored to a target with this viewer's unread state, ordered by recent activity. Uses the denormalized last-activity timestamp compared against the viewer's read point — no per-session aggregation."
130
+ "List sessions anchored to a target with this viewer's unread state, most recent activity first. Supports free-text search over name and last preview, a mine-only filter, hidden inclusion, and paging. Uses the denormalized last-activity timestamp compared against the viewer's read point — no per-session aggregation."
122
131
  })
123
132
  @Directive('@privilege(category: "board-ai", privilege: "query")')
124
133
  async chatSessionInbox(
134
+ @Arg('anchorType') anchorType: string,
135
+ @Arg('anchorId') anchorId: string,
136
+ @Ctx() context: ResolverContext,
137
+ /* 숨긴 대화 보기 — 숨김이 한 번 넣으면 못 꺼내는 함정이 되지 않도록 필터를 준다. */
138
+ @Arg('showHidden', { nullable: true }) showHidden?: boolean,
139
+ /* 대량 목록 대응 — 검색·소유 필터·페이지. 목록 UI 는 처음부터 이걸 갖고 있어야 한다. */
140
+ @Arg('search', { nullable: true }) search?: string,
141
+ @Arg('mineOnly', { nullable: true }) mineOnly?: boolean,
142
+ @Arg('limit', () => Int, { nullable: true }) limit?: number,
143
+ @Arg('offset', () => Int, { nullable: true }) offset?: number
144
+ ): Promise<ChatSessionInboxEntry[]> {
145
+ const filtered = await this._inboxFiltered(anchorType, anchorId, context, { showHidden, search, mineOnly })
146
+ const from = Math.max(0, offset ?? 0)
147
+ const size = Math.min(Math.max(1, limit ?? 50), 200)
148
+ return filtered.slice(from, from + size) as any
149
+ }
150
+
151
+ /**
152
+ * 필터를 통과한 **전체 개수** — "더 보기" 가 남았는지, 검색 결과가 몇 건인지.
153
+ *
154
+ * 목록과 같은 요청에 담아 가져가므로 왕복은 늘지 않는다. 개수를 모르면 사용자는 목록의 끝이
155
+ * 페이지의 끝인지 자료의 끝인지 구별할 수 없다.
156
+ */
157
+ @Query(() => Int, {
158
+ description:
159
+ 'Total number of sessions matching the same filters as chatSessionInbox (before paging). Request it alongside the list so the UI can tell "end of page" from "end of data".'
160
+ })
161
+ @Directive('@privilege(category: "board-ai", privilege: "query")')
162
+ async chatSessionInboxTotal(
163
+ @Arg('anchorType') anchorType: string,
164
+ @Arg('anchorId') anchorId: string,
165
+ @Ctx() context: ResolverContext,
166
+ @Arg('showHidden', { nullable: true }) showHidden?: boolean,
167
+ @Arg('search', { nullable: true }) search?: string,
168
+ @Arg('mineOnly', { nullable: true }) mineOnly?: boolean
169
+ ): Promise<number> {
170
+ return (await this._inboxFiltered(anchorType, anchorId, context, { showHidden, search, mineOnly })).length
171
+ }
172
+
173
+ /** 필터 적용(페이지 전) — 목록·개수가 **같은 규칙**을 쓰도록 한 곳에 둔다(어긋나면 "더 보기" 가 거짓말이 된다). */
174
+ private async _inboxFiltered(
175
+ anchorType: string,
176
+ anchorId: string,
177
+ context: ResolverContext,
178
+ opts: { showHidden?: boolean; search?: string; mineOnly?: boolean }
179
+ ): Promise<ChatSessionInboxEntry[]> {
180
+ const all = await this._inboxEntries(anchorType, anchorId, context)
181
+ let entries = opts.showHidden ? all : all.filter(e => !e.hidden)
182
+ if (opts.mineOnly) entries = entries.filter(e => (e as any).mine)
183
+ return searchInbox(entries as any, opts.search) as any
184
+ }
185
+
186
+ /**
187
+ * 내가 숨긴 대화 수 — 필터에 "몇 개가 접혀 있는지" 를 보여주기 위한 값.
188
+ *
189
+ * 숨긴 것이 몇 개인지 모르면 필터를 눌러 볼 이유가 생기지 않는다(숨긴 사실 자체를 잊는다).
190
+ * 목록 조회와 **같은 요청**에 담아 가져갈 수 있으므로 왕복은 늘지 않는다.
191
+ */
192
+ @Query(() => Int, {
193
+ description:
194
+ "Number of sessions on this anchor that the caller has hidden and that have had no activity since. Meant to be requested alongside chatSessionInbox in the same operation so the filter can say how many are folded away."
195
+ })
196
+ @Directive('@privilege(category: "board-ai", privilege: "query")')
197
+ async hiddenSessionCount(
125
198
  @Arg('anchorType') anchorType: string,
126
199
  @Arg('anchorId') anchorId: string,
127
200
  @Ctx() context: ResolverContext
201
+ ): Promise<number> {
202
+ const all = await this._inboxEntries(anchorType, anchorId, context)
203
+ return all.filter(e => e.hidden).length
204
+ }
205
+
206
+ /** 목록 조립 — 숨김 표시까지 붙인 **전체**. 목록·개수 두 진입점이 같은 규칙을 쓰도록 한 곳에 둔다. */
207
+ private async _inboxEntries(
208
+ anchorType: string,
209
+ anchorId: string,
210
+ context: ResolverContext
128
211
  ): Promise<ChatSessionInboxEntry[]> {
129
212
  const { domain, user } = context.state
213
+ /* 조회 상한 — 한 앵커의 대화가 아무리 늘어도 한 번에 이만큼만 훑는다. 최근 활동순으로 자른다
214
+ * (오래된 대화가 잘리는 것이 최근 대화가 잘리는 것보다 낫다). 정렬·검색·페이지는 순수 로직이 한다. */
130
215
  const sessions = await getRepository(ChatSession).find({
131
- where: anchorWhere(domain.id, anchorType, anchorId)
216
+ where: anchorWhere(domain.id, anchorType, anchorId),
217
+ order: { lastMessageAt: 'DESC' },
218
+ take: INBOX_SCAN_CAP
132
219
  })
133
220
  if (sessions.length === 0) return []
134
- const mine = await getRepository(ChatSessionParticipant).find({
221
+ const rows = await getRepository(ChatSessionParticipant).find({
135
222
  where: { domain: { id: domain.id } as any, user: { id: user.id } as any, session: { id: In(sessions.map(s => s.id!)) } as any }
136
223
  })
137
- return buildInbox(sessions as any, mine as any) as any
224
+ /* 'viewer' 는 대화에 낀 사람이 아니다 — 숨김을 기록하려고 만든 행이므로 읽음·안 읽음 판정에서 뺀다.
225
+ * (참여자가 아닌 사람에게 남의 협의 배지를 띄우지 않는다.) */
226
+ const participations = rows.filter(r => r.role !== 'viewer')
227
+ const hiddenAt = new Map(rows.map(r => [(r as any).sessionId as string, r.hiddenAt]))
228
+ /* 내가 만든 대화인지 표시 — 삭제 버튼을 보일지 결정하는 UI 힌트(권한은 서버가 다시 판정한다). */
229
+ const createdByMe = new Set(sessions.filter(s => (s as any).creatorId === user?.id).map(s => s.id))
230
+ const entries = buildInbox(sessions as any, participations as any).map(entry => ({
231
+ ...entry,
232
+ mine: createdByMe.has(entry.id)
233
+ }))
234
+ /* showHidden=true 로 불러 **전체**를 얻는다 — 숨김 여부 판정을 두 곳에 복제하지 않기 위함. */
235
+ return applyHidden(entries as any, hiddenAt, true) as any
236
+ }
237
+
238
+ /**
239
+ * 세션 삭제 — **만든 사람만.**
240
+ *
241
+ * 협의는 여럿의 기록이고 삭제는 화면에서 되돌릴 수 없다. 그래서 권한을 좁힌다(이름 변경은 되돌릴 수
242
+ * 있어서 누구나 할 수 있는 것과 대비). 권한이 없는 사람에게는 `hideAISession`(내 목록에서만 숨김)이
243
+ * 있다 — 남의 기록을 지우지 않고 자기 목록을 정리하는 길.
244
+ *
245
+ * 소프트 삭제(deletedAt)다 — 메시지·패치는 세션을 통해서만 도달하므로 함께 사라지고, 잘못 지운 경우
246
+ * 데이터로는 남아 있다. 다른 참여자 목록에서도 사라져야 하므로 meta 활동으로 방송한다.
247
+ */
248
+ @Mutation(() => Boolean, {
249
+ description:
250
+ 'Delete a chat session — creator only, since a shared deliberation cannot be un-deleted from the UI. Participants who are not the creator can hide it from their own list instead (hideAISession). Soft delete; broadcast as meta activity so every list drops it.'
251
+ })
252
+ @Directive('@privilege(category: "board-ai", privilege: "mutation")')
253
+ async deleteAISession(@Arg('sessionId') sessionId: string, @Ctx() context: ResolverContext): Promise<boolean> {
254
+ const { domain, user } = context.state
255
+ const repo = getRepository(ChatSession)
256
+ const session = await repo.findOneBy({ id: sessionId, domain: { id: domain.id } as any })
257
+ if (!session) return false
258
+ if (!user?.id || (session as any).creatorId !== user.id) {
259
+ /* 조용한 무동작이 아니라 명시 실패 — 지워진 줄 알고 넘어가면 안 된다. */
260
+ throw new Error('Only the user who created this session may delete it. Hide it from your own list instead.')
261
+ }
262
+ await repo.softDelete(sessionId)
263
+ publishSessionActivity({
264
+ domainId: domain.id,
265
+ sessionId,
266
+ kind: 'meta',
267
+ anchorType: (session as any).anchorType,
268
+ anchorId: (session as any).anchorId,
269
+ actorId: user?.id
270
+ })
271
+ return true
272
+ }
273
+
274
+ /**
275
+ * 내 목록에서 숨기기·되돌리기 — **나에게만** 적용된다(대화·다른 참여자 목록은 그대로).
276
+ *
277
+ * 참여자가 아니어도 숨길 수 있어야 한다 — 목록에는 같은 공간의 남의 협의도 보이고, 그것을 정리하는
278
+ * 것은 남에게 아무 영향이 없다. 그 경우 'viewer' 역할로 기록만 남긴다(참여자 명부·배지에는 들지 않는다).
279
+ */
280
+ @Mutation(() => Boolean, {
281
+ description:
282
+ 'Hide (or unhide) a session from the calling user\'s own list. Per-user and reversible — the session and other participants are unaffected. Activity newer than the hide makes it reappear; the inbox filter (showHidden) reveals hidden ones.'
283
+ })
284
+ @Directive('@privilege(category: "board-ai", privilege: "mutation")')
285
+ async hideAISession(
286
+ @Arg('sessionId') sessionId: string,
287
+ @Ctx() context: ResolverContext,
288
+ @Arg('hidden', { nullable: true }) hidden?: boolean
289
+ ): Promise<boolean> {
290
+ const { domain, user } = context.state
291
+ if (!user?.id) return false
292
+ const session = await getRepository(ChatSession).findOneBy({ id: sessionId, domain: { id: domain.id } as any })
293
+ if (!session) return false
294
+ const repo = getRepository(ChatSessionParticipant)
295
+ const existing = await repo.findOne({
296
+ where: { domain: { id: domain.id } as any, session: { id: sessionId } as any, user: { id: user.id } as any }
297
+ })
298
+ const hiddenAt = hidden === false ? null : new Date()
299
+ if (existing) {
300
+ await repo.update(existing.id!, { hiddenAt, updater: user } as any)
301
+ } else {
302
+ if (hidden === false) return true // 숨긴 적이 없으면 되돌릴 것도 없다
303
+ await repo.save({
304
+ domain: { id: domain.id } as any,
305
+ session: { id: sessionId } as any,
306
+ user: { id: user.id } as any,
307
+ role: 'viewer', // 대화에 낀 것이 아니라 숨김만 기록한다
308
+ hiddenAt,
309
+ creator: user,
310
+ updater: user
311
+ } as any)
312
+ }
313
+ return true
138
314
  }
139
315
 
140
316
  /**
@@ -407,6 +583,122 @@ export class ChatSessionResolver {
407
583
  return true
408
584
  }
409
585
 
586
+ @Directive('@transaction')
587
+ @Mutation(() => Boolean, {
588
+ description:
589
+ 'Append a system note to a session — context that did not come from a participant typing (e.g. a conversation carried over from another surface, an external state change). The AI sees it as part of the shared history.'
590
+ })
591
+ @Directive('@privilege(category: "board-ai", privilege: "mutation")')
592
+ async appendSessionNote(
593
+ @Arg('sessionId') sessionId: string,
594
+ @Arg('content') content: string,
595
+ @Ctx() context: ResolverContext
596
+ ): Promise<boolean> {
597
+ const { domain, user, tx } = context.state
598
+ const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
599
+ if (!session) throw new Error(`ChatSession ${sessionId} not found`)
600
+ /* 프롬프트가 한 기록으로 폭증하지 않도록 자른다 — 맥락 전달이 목적이고 전문 보존이 아니다. */
601
+ const body = (content ?? '').trim().slice(0, 4000)
602
+ if (!body) return false
603
+
604
+ const previousLast = await getRepository(ChatMessage, tx).findOne({
605
+ where: { session: { id: session.id } as any },
606
+ order: { createdAt: 'DESC' }
607
+ })
608
+ const sysMsg = await getRepository(ChatMessage, tx).save({
609
+ session: { id: session.id } as any,
610
+ role: 'system',
611
+ content: body,
612
+ creator: user,
613
+ updater: user,
614
+ parentMessage: previousLast ? ({ id: previousLast.id } as any) : undefined
615
+ } as any)
616
+
617
+ /* 활동 갱신 + 방송 — 다른 참여자 목록·안 읽음과 열려 있는 화면의 transcript 일관성. best-effort. */
618
+ try {
619
+ await getRepository(ChatSession, tx).update(session.id!, activityPatch(body) as any)
620
+ } catch {
621
+ /* 기록 자체를 막지 않는다 */
622
+ }
623
+ publishSessionActivity({
624
+ domainId: domain.id,
625
+ sessionId: session.id!,
626
+ kind: 'message',
627
+ anchorType: (session as any).anchorType,
628
+ anchorId: (session as any).anchorId,
629
+ preview: body,
630
+ actorId: user?.id
631
+ })
632
+ publishChatMessage({ domainId: domain.id, sessionId: session.id, message: sysMsg })
633
+ return true
634
+ }
635
+
636
+ @Directive('@transaction')
637
+ @Mutation(() => Boolean, {
638
+ description:
639
+ 'Carry a conversation from another surface into this session as its own turns (user / assistant), preserving order. Use when a lightweight exchange is promoted into a persistent discussion — the participants should read it as the conversation continuing, not as a machine dump.'
640
+ })
641
+ @Directive('@privilege(category: "board-ai", privilege: "mutation")')
642
+ async appendSessionTurns(
643
+ @Arg('sessionId') sessionId: string,
644
+ @Arg('turns', () => GraphQLJSON) turns: { role?: string; content?: string }[],
645
+ @Ctx() context: ResolverContext
646
+ ): Promise<boolean> {
647
+ const { domain, user, tx } = context.state
648
+ const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
649
+ if (!session) throw new Error(`ChatSession ${sessionId} not found`)
650
+
651
+ /* 옮겨온 대화는 **그 대화의 발언 그대로** 들어간다 — 한 덩어리 기록으로 뭉치면 사람이 읽을 수 없고
652
+ * (화면 하나를 잡아먹는 이탤릭 덩어리), 마크다운도 죽는다. 대신 개수·길이는 제한한다: 맥락 전달이
653
+ * 목적이고 전문 보존이 아니다. */
654
+ const rows = (Array.isArray(turns) ? turns : [])
655
+ .map(t => ({
656
+ role: t?.role === 'ai' || t?.role === 'assistant' ? 'assistant' : t?.role === 'user' ? 'user' : 'system',
657
+ content: (t?.content ?? '').trim().slice(0, 4000)
658
+ }))
659
+ .filter(t => t.content)
660
+ .slice(-40)
661
+ if (!rows.length) return false
662
+
663
+ let previous = await getRepository(ChatMessage, tx).findOne({
664
+ where: { session: { id: session.id } as any },
665
+ order: { createdAt: 'DESC' }
666
+ })
667
+ const saved: ChatMessage[] = []
668
+ for (const row of rows) {
669
+ previous = await getRepository(ChatMessage, tx).save({
670
+ session: { id: session.id } as any,
671
+ role: row.role,
672
+ content: row.content,
673
+ creator: user,
674
+ updater: user,
675
+ parentMessage: previous ? ({ id: previous.id } as any) : undefined
676
+ } as any)
677
+ saved.push(previous!)
678
+ }
679
+
680
+ /* 활동은 마지막 발언 기준 한 번만 — 옮긴 줄마다 목록을 흔들 이유가 없다. */
681
+ const last = rows[rows.length - 1]
682
+ try {
683
+ await getRepository(ChatSession, tx).update(session.id!, activityPatch(last.content) as any)
684
+ } catch {
685
+ /* best-effort — 옮기기 자체를 막지 않는다 */
686
+ }
687
+ publishSessionActivity({
688
+ domainId: domain.id,
689
+ sessionId: session.id!,
690
+ kind: 'message',
691
+ anchorType: (session as any).anchorType,
692
+ anchorId: (session as any).anchorId,
693
+ preview: last.content,
694
+ actorId: user?.id
695
+ })
696
+ for (const message of saved) {
697
+ publishChatMessage({ domainId: domain.id, sessionId: session.id, message })
698
+ }
699
+ return true
700
+ }
701
+
410
702
  @Directive('@transaction')
411
703
  @Mutation(() => PatchEntry, {
412
704
  description: 'Record a patch from user direct edit. Adds a system message so AI sees the change next turn.'
@@ -0,0 +1,305 @@
1
+ /**
2
+ * 협의 도크 계약 — **어시스턴트 경계의 배선**을 못박는다.
3
+ *
4
+ * 왜 이 파일이 따로 필요한가: 루프의 거동(도구 강제·근거 수집·제안 수집)은 agentic-loop.test 가,
5
+ * 접지 판정 규칙은 grounding.test 가 이미 잠근다. 비어 있던 것은 **그 둘을 잇는 한 줄들**이었고,
6
+ * 그 줄들이 끊어질 때 나타나는 증상이 실제로 겪은 실패와 1:1 로 대응한다:
7
+ *
8
+ * · hostContext → ctx.state.host 끊기면 → AI 가 사용자에게 "어느 트윈이냐" 를 되묻는다
9
+ * · requireGroundingTools → 루프 끊기면 → 도구 없이 상태를 단언한다("바쁘게 움직이고 있다")
10
+ * · 접지 근거 4갈래 끊기면 → 정상 답에 경고가 붙거나(오탐) 없는 대상이 통과한다
11
+ * · proposals → 응답 끊기면 → 실행 카드가 사라지고 제안이 도구 추적에 묻힌다
12
+ *
13
+ * 여기서 검증하지 **않는** 것: 실제 모델이 어느 도구를 고르는지. 그건 가짜 LLM 으로 알 수 없고,
14
+ * 도크의 도구 사용 패널이 보여준다. 이 파일의 값은 "배선이 살아 있다" 를 영구히 지키는 것이다.
15
+ */
16
+ import type { AIClient } from '@things-factory/ai-client-base'
17
+ import { registerToolCategory, clearToolRegistry } from '@things-factory/ai-client-base'
18
+
19
+ import { DefaultBoardAIAssistant } from './assistant'
20
+
21
+ /** 스크립트된 턴을 돌려주는 가짜 provider — 호출 옵션도 기록한다(toolChoice 확인용). */
22
+ function mockClient(turns: Array<{ text?: string; toolCalls: any[] }>) {
23
+ let i = 0
24
+ const calls: any[] = []
25
+ const client: AIClient = {
26
+ id: 'mock:dock',
27
+ chat: async () => '',
28
+ generateJSON: async () => ({}),
29
+ chatWithTools: async (messages: any[], _tools: any[], options: any) => {
30
+ calls.push({ messages, options })
31
+ const turn = turns[i++] ?? { toolCalls: [] }
32
+ return {
33
+ text: turn.text,
34
+ toolCalls: turn.toolCalls,
35
+ stopReason: turn.toolCalls.length > 0 ? 'tool_use' : 'end_turn'
36
+ }
37
+ }
38
+ } as any
39
+ return { client, calls }
40
+ }
41
+
42
+ /** 트윈 도구 대역 — 실제 twin 카테고리와 같은 모양(대상을 문맥에서 해소하고 결과를 돌려준다). */
43
+ function registerFakeTwinTools(seen: { host?: any; args?: any }) {
44
+ registerToolCategory({
45
+ name: 'twin-ops-fake',
46
+ description: 'fake ops tools for contract test',
47
+ specs: [
48
+ {
49
+ kind: 'read',
50
+ name: 'getSpaceStatus',
51
+ description: 'status of the twins running in the space this surface is looking at',
52
+ schema: { type: 'object', properties: { instanceId: { type: 'string' } } },
53
+ builder: async (args: any, ctx: any) => {
54
+ /* 대상을 **문맥에서** 정한다 — 사용자에게 되묻지 않는 경로가 이것이다. */
55
+ seen.host = ctx?.state?.host
56
+ seen.args = args
57
+ const target = args?.instanceId || ctx?.state?.host?.spaceId
58
+ return { instanceId: 'busan-wms', spaceId: target, nodes: [{ id: 'dock-1', occupancy: 0 }] }
59
+ }
60
+ } as any,
61
+ {
62
+ kind: 'read',
63
+ name: 'proposeHold',
64
+ description: 'record a proposed hold (never executes it)',
65
+ schema: { type: 'object', properties: { orderId: { type: 'string' } } },
66
+ builder: async (args: any) => ({
67
+ proposed: true,
68
+ command: 'order.hold',
69
+ args: { orderId: args?.orderId },
70
+ label: `${args?.orderId} 보류`,
71
+ instanceId: 'busan-wms'
72
+ })
73
+ } as any
74
+ ]
75
+ })
76
+ }
77
+
78
+ const TC = (name: string, args: any = {}, id = name) => ({ id, name, arguments: args })
79
+
80
+ beforeEach(() => clearToolRegistry())
81
+ afterEach(() => clearToolRegistry())
82
+
83
+ describe('도크 계약 — 대화면 문맥(hostContext)', () => {
84
+ it('hostContext 가 도구의 ctx.state.host 로 도달한다 — 이게 끊기면 AI 가 식별자를 되묻는다', async () => {
85
+ const seen: { host?: any; args?: any } = {}
86
+ registerFakeTwinTools(seen)
87
+ const { client } = mockClient([{ toolCalls: [TC('getSpaceStatus', {})] }, { text: '여유롭습니다.', toolCalls: [] }])
88
+
89
+ await new DefaultBoardAIAssistant(client).chat([{ role: 'user', content: '지금 상태 어때?' }], undefined, {
90
+ toolCategories: ['twin-ops-fake'],
91
+ boardTools: false,
92
+ hostContext: { spaceId: 'busan' },
93
+ toolCallContext: { domain: { id: 'dom-1' } }
94
+ })
95
+
96
+ expect(seen.host).toEqual({ spaceId: 'busan' })
97
+ })
98
+
99
+ it('서버 문맥(domain)과 호스트 문맥이 함께 전달된다 — 권한은 서버 것, 범위는 호스트 것', async () => {
100
+ let state: any
101
+ registerToolCategory({
102
+ name: 'twin-ops-fake',
103
+ description: 'x',
104
+ specs: [
105
+ {
106
+ kind: 'read',
107
+ name: 'peek',
108
+ description: 'peek',
109
+ schema: { type: 'object', properties: {} },
110
+ builder: async (_a: any, ctx: any) => {
111
+ state = ctx?.state
112
+ return { ok: true }
113
+ }
114
+ } as any
115
+ ]
116
+ })
117
+ const { client } = mockClient([{ toolCalls: [TC('peek')] }, { text: 'ok', toolCalls: [] }])
118
+ await new DefaultBoardAIAssistant(client).chat([{ role: 'user', content: 'x' }], undefined, {
119
+ toolCategories: ['twin-ops-fake'],
120
+ boardTools: false,
121
+ hostContext: { spaceId: 'busan' },
122
+ toolCallContext: { domain: { id: 'dom-1' }, user: { id: 'u1' } }
123
+ })
124
+ expect(state.domain).toEqual({ id: 'dom-1' })
125
+ expect(state.host).toEqual({ spaceId: 'busan' })
126
+ })
127
+ })
128
+
129
+ describe('도크 계약 — 첫 턴 도구 강제', () => {
130
+ it('requireGroundingTools 를 켜면 첫 호출이 required, 이후 auto', async () => {
131
+ const seen: any = {}
132
+ registerFakeTwinTools(seen)
133
+ const { client, calls } = mockClient([
134
+ { toolCalls: [TC('getSpaceStatus', {})] },
135
+ { text: 'dock-1 은 여유롭습니다.', toolCalls: [] }
136
+ ])
137
+ await new DefaultBoardAIAssistant(client).chat([{ role: 'user', content: '상태?' }], undefined, {
138
+ toolCategories: ['twin-ops-fake'],
139
+ boardTools: false,
140
+ requireGroundingTools: true,
141
+ hostContext: { spaceId: 'busan' },
142
+ toolCallContext: { domain: { id: 'dom-1' } }
143
+ })
144
+ expect(calls.map(c => c.options.toolChoice)).toEqual(['required', 'auto'])
145
+ })
146
+
147
+ it('끄면 첫 호출도 auto — 라이브 상태를 다루지 않는 대화면은 강제하지 않는다', async () => {
148
+ const seen: any = {}
149
+ registerFakeTwinTools(seen)
150
+ const { client, calls } = mockClient([{ text: '네', toolCalls: [] }])
151
+ await new DefaultBoardAIAssistant(client).chat([{ role: 'user', content: '고마워' }], undefined, {
152
+ toolCategories: ['twin-ops-fake'],
153
+ boardTools: false,
154
+ toolCallContext: { domain: { id: 'dom-1' } }
155
+ })
156
+ expect(calls[0].options.toolChoice).toBe('auto')
157
+ })
158
+ })
159
+
160
+ describe('도크 계약 — 접지 경고', () => {
161
+ it('도구가 돌려준 식별자로 답하면 경고가 없다 — 도구 결과가 근거집합에 들어간다', async () => {
162
+ const seen: any = {}
163
+ registerFakeTwinTools(seen)
164
+ const { client } = mockClient([
165
+ { toolCalls: [TC('getSpaceStatus', {})] },
166
+ { text: 'dock-1 은 여유롭고 busan-wms 는 정상입니다.', toolCalls: [] }
167
+ ])
168
+ const r = await new DefaultBoardAIAssistant(client).chat([{ role: 'user', content: '상태?' }], undefined, {
169
+ toolCategories: ['twin-ops-fake'],
170
+ boardTools: false,
171
+ hostContext: { spaceId: 'busan' },
172
+ toolCallContext: { domain: { id: 'dom-1' } }
173
+ })
174
+ expect(r.groundingWarnings).toBeUndefined()
175
+ })
176
+
177
+ it('근거 밖의 대상을 지목하면 경고가 붙는다 — 답은 그대로 둔다(비파괴)', async () => {
178
+ const seen: any = {}
179
+ registerFakeTwinTools(seen)
180
+ const { client } = mockClient([
181
+ { toolCalls: [TC('getSpaceStatus', {})] },
182
+ { text: 'dock-9 에 적체가 있습니다.', toolCalls: [] }
183
+ ])
184
+ const r = await new DefaultBoardAIAssistant(client).chat([{ role: 'user', content: '상태?' }], undefined, {
185
+ toolCategories: ['twin-ops-fake'],
186
+ boardTools: false,
187
+ hostContext: { spaceId: 'busan' },
188
+ toolCallContext: { domain: { id: 'dom-1' } }
189
+ })
190
+ expect(r.groundingWarnings).toEqual(['dock-9'])
191
+ expect(r.reply).toContain('dock-9') // 경고는 붙이지만 답을 고치지 않는다
192
+ })
193
+
194
+ it('사용자가 말한 식별자를 되받는 것은 근거 있는 것이다 — 이력도 근거집합', async () => {
195
+ const seen: any = {}
196
+ registerFakeTwinTools(seen)
197
+ const { client } = mockClient([
198
+ { toolCalls: [TC('getSpaceStatus', {})] },
199
+ { text: 'rack-77 은 확인이 필요합니다.', toolCalls: [] }
200
+ ])
201
+ const r = await new DefaultBoardAIAssistant(client).chat(
202
+ [{ role: 'user', content: 'rack-77 어때?' }],
203
+ undefined,
204
+ {
205
+ toolCategories: ['twin-ops-fake'],
206
+ boardTools: false,
207
+ hostContext: { spaceId: 'busan' },
208
+ toolCallContext: { domain: { id: 'dom-1' } }
209
+ }
210
+ )
211
+ expect(r.groundingWarnings).toBeUndefined()
212
+ })
213
+ })
214
+
215
+ describe('도크 계약 — 조치 제안', () => {
216
+ it('proposed:true 도구 결과가 응답의 proposals 로 올라온다 — 실행 카드의 근거', async () => {
217
+ const seen: any = {}
218
+ registerFakeTwinTools(seen)
219
+ const { client } = mockClient([
220
+ { toolCalls: [TC('proposeHold', { orderId: 'o7' })] },
221
+ { text: '아래에서 확인해 주세요.', toolCalls: [] }
222
+ ])
223
+ const r = await new DefaultBoardAIAssistant(client).chat([{ role: 'user', content: 'o7 보류해줘' }], undefined, {
224
+ toolCategories: ['twin-ops-fake'],
225
+ boardTools: false,
226
+ hostContext: { spaceId: 'busan' },
227
+ toolCallContext: { domain: { id: 'dom-1' } }
228
+ })
229
+ expect(r.proposals).toHaveLength(1)
230
+ expect(r.proposals![0]).toMatchObject({ tool: 'proposeHold', command: 'order.hold', instanceId: 'busan-wms' })
231
+ /* 제안은 실행이 아니다 — 보드 패치도 액션도 만들어지지 않는다. */
232
+ expect(r.patch).toBeUndefined()
233
+ expect(r.actions).toBeUndefined()
234
+ })
235
+
236
+ it('조회만 한 응답에는 proposals 가 없다 — 규약은 플래그 하나뿐', async () => {
237
+ const seen: any = {}
238
+ registerFakeTwinTools(seen)
239
+ const { client } = mockClient([{ toolCalls: [TC('getSpaceStatus', {})] }, { text: '정상입니다.', toolCalls: [] }])
240
+ const r = await new DefaultBoardAIAssistant(client).chat([{ role: 'user', content: '상태?' }], undefined, {
241
+ toolCategories: ['twin-ops-fake'],
242
+ boardTools: false,
243
+ hostContext: { spaceId: 'busan' },
244
+ toolCallContext: { domain: { id: 'dom-1' } }
245
+ })
246
+ expect(r.proposals).toBeUndefined()
247
+ })
248
+ })
249
+
250
+ describe('도크 계약 — 능력 경계', () => {
251
+ it('보드 편집 도구를 끈 대화에서는 보드 편집이 실행되지 않는다 — 되돌릴 수 없는 면의 안전선', async () => {
252
+ const seen: any = {}
253
+ registerFakeTwinTools(seen)
254
+ const { client } = mockClient([
255
+ { toolCalls: [TC('addComponent', { type: 'rect', left: 0, top: 0, width: 10, height: 10 })] },
256
+ { text: '...', toolCalls: [] }
257
+ ])
258
+ const r = await new DefaultBoardAIAssistant(client).chat([{ role: 'user', content: 'rect 추가' }], undefined, {
259
+ toolCategories: ['twin-ops-fake'],
260
+ boardTools: false,
261
+ knownTypes: ['rect'],
262
+ toolCallContext: { domain: { id: 'dom-1' } }
263
+ })
264
+ expect(r.patch).toBeUndefined()
265
+ })
266
+ })
267
+
268
+ describe('도크 계약 — 도구 사용 규율이 프롬프트에 실린다', () => {
269
+ it('노출된 카테고리의 규율이 시스템 프롬프트에 붙는다 — 목록만 보내면 "언제 불러야 하는지" 가 빠진다', async () => {
270
+ registerToolCategory({
271
+ name: 'twin-ops-fake',
272
+ description: 'x',
273
+ guidance: '- 조치 요청에는 반드시 proposeHold 를 호출한다. 호출 없이 제안했다고 쓰지 말 것.',
274
+ specs: [
275
+ {
276
+ kind: 'read',
277
+ name: 'proposeHold',
278
+ description: 'propose',
279
+ schema: { type: 'object', properties: {} },
280
+ builder: async () => ({ proposed: true, command: 'order.hold' })
281
+ } as any
282
+ ]
283
+ })
284
+ const { client, calls } = mockClient([{ text: '네', toolCalls: [] }])
285
+ await new DefaultBoardAIAssistant(client).chat([{ role: 'user', content: 'o7 보류' }], undefined, {
286
+ toolCategories: ['twin-ops-fake'],
287
+ boardTools: false,
288
+ toolCallContext: { domain: { id: 'dom-1' } }
289
+ })
290
+ expect(calls[0].options.systemPrompt).toContain('반드시 proposeHold 를 호출한다')
291
+ })
292
+
293
+ it('노출하지 않은 카테고리의 규율은 새지 않는다 — 대화면마다 능력과 규율이 함께 좁혀진다', async () => {
294
+ registerToolCategory({ name: 'twin-ops-fake', guidance: 'OPS-RULE', specs: [] })
295
+ registerToolCategory({ name: 'other-fake', guidance: 'OTHER-RULE', specs: [] })
296
+ const { client, calls } = mockClient([{ text: '네', toolCalls: [] }])
297
+ await new DefaultBoardAIAssistant(client).chat([{ role: 'user', content: 'x' }], undefined, {
298
+ toolCategories: ['twin-ops-fake'],
299
+ boardTools: false,
300
+ toolCallContext: { domain: { id: 'dom-1' } }
301
+ })
302
+ expect(calls[0].options.systemPrompt).toContain('OPS-RULE')
303
+ expect(calls[0].options.systemPrompt).not.toContain('OTHER-RULE')
304
+ })
305
+ })