@aleph-alpha/chat-kit 5.5.0 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/{CkHistory.vue_vue_type_script_setup_true_lang-C1busuyf.js → CkHistory.vue_vue_type_script_setup_true_lang-DHzZgEn-.js} +26 -8
  2. package/dist/adapters/responses-api.d.ts +3 -1
  3. package/dist/adapters/responses-api.d.ts.map +1 -1
  4. package/dist/adapters/responses-api.js +158 -3
  5. package/dist/components/base/CkConversationLayout/CkConversationLayout.vue.d.ts +6 -0
  6. package/dist/components/base/CkConversationLayout/CkConversationLayout.vue.d.ts.map +1 -1
  7. package/dist/components/composed/CkConversation/CkConversation.vue.d.ts +6 -0
  8. package/dist/components/composed/CkConversation/CkConversation.vue.d.ts.map +1 -1
  9. package/dist/components/index.js +1 -1
  10. package/dist/composables/index.js +1 -1
  11. package/dist/handlers/index.js +1 -1
  12. package/dist/handlers/useMessageHandler.d.ts +7 -2
  13. package/dist/handlers/useMessageHandler.d.ts.map +1 -1
  14. package/dist/helpers/conversationTree.d.ts +44 -4
  15. package/dist/helpers/conversationTree.d.ts.map +1 -1
  16. package/dist/{index-CyrVnbS9.js → index-Hcwv9Hby.js} +3 -2
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -3
  20. package/dist/services/types.d.ts +7 -0
  21. package/dist/services/types.d.ts.map +1 -1
  22. package/dist/types.d.ts +38 -0
  23. package/dist/types.d.ts.map +1 -1
  24. package/dist/{useMessageHandler-N50Yj-NU.js → useMessageHandler-Tellov3w.js} +112 -30
  25. package/package.json +2 -2
  26. package/src/adapters/arena.spec.ts +86 -0
  27. package/src/adapters/responses-api.ts +276 -2
  28. package/src/components/base/CkConversationLayout/CkConversationLayout.vue +28 -2
  29. package/src/components/composed/CkConversation/CkConversation.vue +10 -0
  30. package/src/composables/useConversations.spec.ts +2 -0
  31. package/src/composables/useConversations.ts +1 -0
  32. package/src/handlers/useMessageHandler.ts +46 -6
  33. package/src/helpers/comparison.spec.ts +114 -0
  34. package/src/helpers/conversationTree.spec.ts +98 -0
  35. package/src/helpers/conversationTree.ts +211 -26
  36. package/src/index.ts +2 -0
  37. package/src/services/types.ts +7 -0
  38. package/src/types.ts +40 -0
package/dist/types.d.ts CHANGED
@@ -89,11 +89,49 @@ export interface TreeMessage {
89
89
  status: MessageStatus;
90
90
  createdAt: Date;
91
91
  metadata?: MessageMetadata;
92
+ /**
93
+ * When set, this node's `childrenIds` are a **comparison** (a preference
94
+ * pair / draft set rendered side-by-side) rather than ordinary regenerate
95
+ * alternatives. Set when the comparison is opened and left set once voted —
96
+ * the comparison stays a distinct, labelled view until the user continues
97
+ * past the winner, at which point it collapses to linear history.
98
+ */
99
+ comparing?: boolean;
100
+ /**
101
+ * The child id chosen when a `comparing` node is voted on (mirrored to
102
+ * `activeChildIndex`). Absent = not yet voted. Its presence flips the
103
+ * comparison from the blind "awaiting vote" state to the revealed "resolved"
104
+ * state; once the winner branch gains children the comparison is history.
105
+ */
106
+ comparisonWinnerId?: string;
92
107
  }
93
108
  export interface ConversationTree {
94
109
  nodes: Record<string, TreeMessage>;
95
110
  rootId: string | null;
96
111
  }
112
+ /**
113
+ * One side of an active comparison: the head child that defines the lane plus
114
+ * that lane's full active sub-path (length 1 for a fresh pair, longer once the
115
+ * conversation has continued inside the lane).
116
+ */
117
+ export interface ComparisonLane {
118
+ branchId: string;
119
+ path: BaseMessage[];
120
+ }
121
+ /**
122
+ * The single active comparison surfaced alongside the linear `messages` path.
123
+ * Non-null only while the active path's tail fork is unresolved (blind) or
124
+ * voted-but-not-continued (revealed). Its `forkParentId` is the last item of
125
+ * `messages`; everything below the fork lives in `lanes`.
126
+ */
127
+ export interface ActiveComparison {
128
+ forkParentId: string;
129
+ /** false = awaiting vote (blind); true = voted, not yet continued (revealed). */
130
+ resolved: boolean;
131
+ /** The chosen lane's head id once resolved. */
132
+ winnerBranchId: string | null;
133
+ lanes: ComparisonLane[];
134
+ }
97
135
  /**
98
136
  * High-level conversation record consumed by `CkHistory`.
99
137
  *
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,WAAW,GAAG,QAAQ,CAAC;AAE1D,MAAM,MAAM,aAAa,GAAG,SAAS,GAAG,SAAS,GAAG,WAAW,GAAG,OAAO,CAAC;AAE1E;;;;;GAKG;AACH,MAAM,MAAM,oBAAoB,GAAG,aAAa,GAAG,WAAW,GAAG,YAAY,CAAC;AAE9E;;;GAGG;AACH,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,oBAAoB,CAAC;CAC9B;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,WAAW,CAAC;IAClB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,oBAAoB,CAAC;CAC9B;AAED;;GAEG;AACH,MAAM,MAAM,cAAc,GACtB,kBAAkB,GAClB,kBAAkB,GAClB,uBAAuB,CAAC;AAE5B;;;;;;;;;;GAUG;AACH,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEtD,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,WAAW,CAAC;IAClB,OAAO,EAAE,cAAc,EAAE,CAAC;IAC1B,SAAS,EAAE,IAAI,CAAC;IAChB,MAAM,EAAE,aAAa,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,eAAe,CAAC;CAC5B;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,gBAAgB,EAAE,MAAM,CAAC;IACzB,IAAI,EAAE,WAAW,CAAC;IAClB,OAAO,EAAE,cAAc,EAAE,CAAC;IAC1B,MAAM,EAAE,aAAa,CAAC;IACtB,SAAS,EAAE,IAAI,CAAC;IAChB,QAAQ,CAAC,EAAE,eAAe,CAAC;CAC5B;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IACnC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,WAAW,GAAG,QAAQ,CAAC;AAE1D,MAAM,MAAM,aAAa,GAAG,SAAS,GAAG,SAAS,GAAG,WAAW,GAAG,OAAO,CAAC;AAE1E;;;;;GAKG;AACH,MAAM,MAAM,oBAAoB,GAAG,aAAa,GAAG,WAAW,GAAG,YAAY,CAAC;AAE9E;;;GAGG;AACH,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,oBAAoB,CAAC;CAC9B;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,WAAW,CAAC;IAClB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,oBAAoB,CAAC;CAC9B;AAED;;GAEG;AACH,MAAM,MAAM,cAAc,GACtB,kBAAkB,GAClB,kBAAkB,GAClB,uBAAuB,CAAC;AAE5B;;;;;;;;;;GAUG;AACH,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEtD,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,WAAW,CAAC;IAClB,OAAO,EAAE,cAAc,EAAE,CAAC;IAC1B,SAAS,EAAE,IAAI,CAAC;IAChB,MAAM,EAAE,aAAa,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,eAAe,CAAC;CAC5B;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,gBAAgB,EAAE,MAAM,CAAC;IACzB,IAAI,EAAE,WAAW,CAAC;IAClB,OAAO,EAAE,cAAc,EAAE,CAAC;IAC1B,MAAM,EAAE,aAAa,CAAC;IACtB,SAAS,EAAE,IAAI,CAAC;IAChB,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;;OAKG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IACnC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,WAAW,EAAE,CAAC;CACrB;AAED;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B,YAAY,EAAE,MAAM,CAAC;IACrB,iFAAiF;IACjF,QAAQ,EAAE,OAAO,CAAC;IAClB,+CAA+C;IAC/C,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,KAAK,EAAE,cAAc,EAAE,CAAC;CACzB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB"}
@@ -3,47 +3,98 @@ import { reactive, ref, computed, readonly } from "vue";
3
3
  function createEmptyTree() {
4
4
  return { nodes: {}, rootId: null };
5
5
  }
6
- function getLinearPath(tree) {
7
- const { nodes, rootId } = tree;
8
- if (!rootId || !nodes[rootId]) return [];
6
+ function toBaseMessage(nodes, node) {
7
+ const base = {
8
+ id: node.id,
9
+ role: node.role,
10
+ content: node.content,
11
+ createdAt: node.createdAt,
12
+ status: node.status,
13
+ ...node.metadata !== void 0 ? { metadata: node.metadata } : {}
14
+ };
15
+ if (node.parentId !== null) {
16
+ const parent = nodes[node.parentId];
17
+ if (parent && parent.childrenIds.length > 1) {
18
+ base.siblingIds = [...parent.childrenIds];
19
+ base.siblingIndex = parent.childrenIds.indexOf(node.id);
20
+ }
21
+ }
22
+ return base;
23
+ }
24
+ function isActiveComparison(nodes, node) {
25
+ if (!node.comparing || node.childrenIds.length < 2) return false;
26
+ const winnerId = node.comparisonWinnerId;
27
+ if (winnerId == null) return true;
28
+ const winner = nodes[winnerId];
29
+ return !winner || winner.childrenIds.length === 0;
30
+ }
31
+ function laneWalk(nodes, headId) {
9
32
  const path = [];
33
+ let current = headId;
34
+ while (current !== null) {
35
+ const node = nodes[current];
36
+ if (!node) break;
37
+ path.push(toBaseMessage(nodes, node));
38
+ if (node.childrenIds.length === 0) break;
39
+ current = node.childrenIds[node.activeChildIndex];
40
+ }
41
+ return path;
42
+ }
43
+ function projectActivePath(tree) {
44
+ const { nodes, rootId } = tree;
45
+ const trunk = [];
46
+ if (!rootId || !nodes[rootId]) return { trunk, activeComparison: null };
10
47
  let current = rootId;
11
48
  while (current !== null) {
12
49
  const node = nodes[current];
13
50
  if (!node) break;
14
- const base = {
15
- id: node.id,
16
- role: node.role,
17
- content: node.content,
18
- createdAt: node.createdAt,
19
- status: node.status,
20
- ...node.metadata !== void 0 ? { metadata: node.metadata } : {}
21
- };
22
- if (node.parentId !== null) {
23
- const parent = nodes[node.parentId];
24
- if (parent && parent.childrenIds.length > 1) {
25
- base.siblingIds = [...parent.childrenIds];
26
- base.siblingIndex = parent.childrenIds.indexOf(node.id);
27
- }
51
+ trunk.push(toBaseMessage(nodes, node));
52
+ if (isActiveComparison(nodes, node)) {
53
+ const lanes = node.childrenIds.map((branchId) => ({
54
+ branchId,
55
+ path: laneWalk(nodes, branchId)
56
+ }));
57
+ const winnerBranchId = node.comparisonWinnerId ?? null;
58
+ return {
59
+ trunk,
60
+ activeComparison: {
61
+ forkParentId: node.id,
62
+ resolved: winnerBranchId !== null,
63
+ winnerBranchId,
64
+ lanes
65
+ }
66
+ };
28
67
  }
29
- path.push(base);
30
68
  if (node.childrenIds.length === 0) break;
31
69
  current = node.childrenIds[node.activeChildIndex];
32
70
  }
33
- return path;
71
+ return { trunk, activeComparison: null };
34
72
  }
35
- function addNode(tree, node) {
73
+ function addNode(tree, node, opts = { activate: true }) {
36
74
  tree.nodes[node.id] = node;
37
75
  if (node.parentId !== null) {
38
76
  const parent = tree.nodes[node.parentId];
39
77
  if (parent && !parent.childrenIds.includes(node.id)) {
40
78
  parent.childrenIds.push(node.id);
41
- parent.activeChildIndex = parent.childrenIds.length - 1;
79
+ if (opts.activate)
80
+ parent.activeChildIndex = parent.childrenIds.length - 1;
42
81
  }
43
82
  } else if (tree.rootId === null) {
44
83
  tree.rootId = node.id;
45
84
  }
46
85
  }
86
+ function markComparison(tree, parentId) {
87
+ const parent = tree.nodes[parentId];
88
+ if (parent) parent.comparing = true;
89
+ }
90
+ function voteComparison(tree, parentId, winnerBranchId) {
91
+ const parent = tree.nodes[parentId];
92
+ if (!parent) return;
93
+ const idx = parent.childrenIds.indexOf(winnerBranchId);
94
+ if (idx === -1) return;
95
+ parent.comparisonWinnerId = winnerBranchId;
96
+ parent.activeChildIndex = idx;
97
+ }
47
98
  function updateNode(tree, id, updates) {
48
99
  const node = tree.nodes[id];
49
100
  if (!node) return;
@@ -81,12 +132,18 @@ function deleteSubtrees(tree, ids) {
81
132
  delete tree.nodes[currentId];
82
133
  }
83
134
  }
84
- function cutBranchAfter(tree, id) {
135
+ function cutBranchAfter(tree, id, keepChildId) {
85
136
  const node = tree.nodes[id];
86
- if (!node) return;
87
- deleteSubtrees(tree, node.childrenIds);
88
- node.childrenIds = [];
137
+ if (!node || !Object.prototype.hasOwnProperty.call(tree.nodes, id)) return;
138
+ const kept = keepChildId !== void 0 && node.childrenIds.includes(keepChildId) ? keepChildId : null;
139
+ deleteSubtrees(
140
+ tree,
141
+ kept === null ? node.childrenIds : node.childrenIds.filter((childId) => childId !== kept)
142
+ );
143
+ node.childrenIds = kept === null ? [] : [kept];
89
144
  node.activeChildIndex = 0;
145
+ delete node.comparing;
146
+ delete node.comparisonWinnerId;
90
147
  }
91
148
  function cutBranchAt(tree, id) {
92
149
  const node = tree.nodes[id];
@@ -106,6 +163,14 @@ function cutBranchAt(tree, id) {
106
163
  if (parent.activeChildIndex >= parent.childrenIds.length) {
107
164
  parent.activeChildIndex = Math.max(0, parent.childrenIds.length - 1);
108
165
  }
166
+ if (parent.comparing) {
167
+ if (parent.childrenIds.length < 2) {
168
+ delete parent.comparing;
169
+ delete parent.comparisonWinnerId;
170
+ } else if (parent.comparisonWinnerId === id) {
171
+ delete parent.comparisonWinnerId;
172
+ }
173
+ }
109
174
  }
110
175
  function navigate(tree, targetId) {
111
176
  const node = tree.nodes[targetId];
@@ -119,9 +184,14 @@ function navigate(tree, targetId) {
119
184
  const useMessageHandler = () => {
120
185
  const tree = reactive(createEmptyTree());
121
186
  const currentUserMessage = ref("");
122
- const messages = computed(() => getLinearPath(tree));
187
+ const projection = computed(() => projectActivePath(tree));
188
+ const messages = computed(() => projection.value.trunk);
189
+ const activeComparison = computed(
190
+ () => projection.value.activeComparison
191
+ );
123
192
  const currentLeafId = computed(() => {
124
- const list = messages.value;
193
+ if (projection.value.activeComparison) return null;
194
+ const list = projection.value.trunk;
125
195
  return list.length > 0 ? list[list.length - 1].id : null;
126
196
  });
127
197
  function resolveParentId(parentId) {
@@ -171,6 +241,9 @@ const useMessageHandler = () => {
171
241
  function updateMessage(message) {
172
242
  const existing = tree.nodes[message.id];
173
243
  if (existing) {
244
+ if (message.comparing && existing.parentId) {
245
+ markComparison(tree, existing.parentId);
246
+ }
174
247
  updateNode(tree, message.id, {
175
248
  content: message.content,
176
249
  role: message.role,
@@ -178,9 +251,13 @@ const useMessageHandler = () => {
178
251
  ...message.metadata !== void 0 ? { metadata: message.metadata } : {}
179
252
  });
180
253
  } else {
254
+ const parentId = resolveParentId(message.parentId);
255
+ if (message.comparing && parentId) {
256
+ markComparison(tree, parentId);
257
+ }
181
258
  const node = {
182
259
  id: message.id,
183
- parentId: resolveParentId(message.parentId),
260
+ parentId,
184
261
  childrenIds: [],
185
262
  activeChildIndex: 0,
186
263
  role: message.role,
@@ -218,8 +295,8 @@ const useMessageHandler = () => {
218
295
  function navigate$1(targetId) {
219
296
  navigate(tree, targetId);
220
297
  }
221
- function cutBranchAfter$1(messageId) {
222
- cutBranchAfter(tree, messageId);
298
+ function cutBranchAfter$1(messageId, keepChildId) {
299
+ cutBranchAfter(tree, messageId, keepChildId);
223
300
  }
224
301
  function cutBranchAt$1(messageId) {
225
302
  cutBranchAt(tree, messageId);
@@ -227,8 +304,12 @@ const useMessageHandler = () => {
227
304
  function getTree() {
228
305
  return readonly(tree);
229
306
  }
307
+ function voteComparison$1(parentId, winnerBranchId) {
308
+ voteComparison(tree, parentId, winnerBranchId);
309
+ }
230
310
  return {
231
311
  messages,
312
+ activeComparison,
232
313
  currentUserMessage: readonly(currentUserMessage),
233
314
  currentLeafId,
234
315
  updateMessage,
@@ -242,6 +323,7 @@ const useMessageHandler = () => {
242
323
  navigate: navigate$1,
243
324
  cutBranchAfter: cutBranchAfter$1,
244
325
  cutBranchAt: cutBranchAt$1,
326
+ voteComparison: voteComparison$1,
245
327
  getTree
246
328
  };
247
329
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aleph-alpha/chat-kit",
3
- "version": "5.5.0",
3
+ "version": "5.6.0",
4
4
  "description": "Chat kit package for building chat interfaces with Vue",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -107,7 +107,7 @@
107
107
  "wait-on": "9.0.3",
108
108
  "@aleph-alpha/eslint-config-frontend": "0.7.0",
109
109
  "@aleph-alpha/prettier-config-frontend": "2.1.0",
110
- "@aleph-alpha/tsconfig-frontend": "0.7.0"
110
+ "@aleph-alpha/tsconfig-frontend": "0.7.1"
111
111
  },
112
112
  "scripts": {
113
113
  "build": "vite build && vue-tsc --project tsconfig.build.json --declaration --emitDeclarationOnly --outDir dist",
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { responsesToTree, type StoredResponse } from './responses-api';
4
+
5
+ function arenaResponse(
6
+ id: string,
7
+ matchupId: string,
8
+ slot: 'a' | 'b',
9
+ modelId: string,
10
+ answer: string,
11
+ ): StoredResponse {
12
+ return {
13
+ id,
14
+ object: 'response',
15
+ created_at: 1,
16
+ completed_at: 2,
17
+ status: 'completed',
18
+ model: modelId,
19
+ previous_response_id: null,
20
+ request_input: [{ role: 'user', content: 'prompt' }],
21
+ output: [
22
+ {
23
+ type: 'message',
24
+ id: `msg-${id}`,
25
+ status: 'completed',
26
+ role: 'assistant',
27
+ content: [{ type: 'output_text', text: answer }],
28
+ },
29
+ ],
30
+ metadata: {
31
+ arena: JSON.stringify({
32
+ matchup_id: matchupId,
33
+ slot,
34
+ model_id: modelId,
35
+ variant: 'king-of-the-hill',
36
+ }),
37
+ },
38
+ } as StoredResponse;
39
+ }
40
+
41
+ describe('responsesToTree arena collapse', () => {
42
+ it('folds an unvoted pair into one comparison node with two lanes', () => {
43
+ const tree = responsesToTree([
44
+ arenaResponse('respA', 'm1', 'a', 'phoenix-latest', 'A'),
45
+ arenaResponse('respB', 'm1', 'b', 'kimi-k2.6', 'B'),
46
+ ]);
47
+
48
+ // Single root user node, marked comparing, with two assistant children.
49
+ const root = tree.nodes[tree.rootId!]!;
50
+ expect(root.role).toBe('user');
51
+ expect(root.comparing).toBe(true);
52
+ expect(root.comparisonWinnerId).toBeUndefined();
53
+ expect(root.childrenIds).toHaveLength(2);
54
+
55
+ const children = root.childrenIds.map((id) => tree.nodes[id]!);
56
+ expect(children.every((c) => c.role === 'assistant')).toBe(true);
57
+ // Model id stamped for lane labels; ordered slot a then b.
58
+ const slots = children.map(
59
+ (c) => (c.metadata.arena as { slot?: string }).slot,
60
+ );
61
+ expect(slots).toEqual(['a', 'b']);
62
+ });
63
+
64
+ it('marks the recorded winner when arenaWinners is supplied', () => {
65
+ const tree = responsesToTree(
66
+ [
67
+ arenaResponse('respA', 'm1', 'a', 'phoenix-latest', 'A'),
68
+ arenaResponse('respB', 'm1', 'b', 'kimi-k2.6', 'B'),
69
+ ],
70
+ { arenaWinners: { m1: 'respB' } },
71
+ );
72
+ const root = tree.nodes[tree.rootId!]!;
73
+ // Winner is respB's assistant node (its wire message id).
74
+ expect(root.comparisonWinnerId).toBe('msg-respB');
75
+ expect(root.childrenIds[root.activeChildIndex]).toBe('msg-respB');
76
+ });
77
+
78
+ it('leaves a single surviving response as an ordinary linear turn', () => {
79
+ // 7-Discard: after continue, the BE dropped the loser — one response left.
80
+ const tree = responsesToTree([
81
+ arenaResponse('respA', 'm1', 'a', 'phoenix-latest', 'A'),
82
+ ]);
83
+ const root = tree.nodes[tree.rootId!]!;
84
+ expect(root.comparing).toBeUndefined();
85
+ });
86
+ });