@lobehub/lobehub 2.0.0-next.31 → 2.0.0-next.33

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 (83) hide show
  1. package/.github/workflows/test.yml +1 -0
  2. package/CHANGELOG.md +58 -0
  3. package/apps/desktop/package.json +1 -1
  4. package/apps/desktop/src/main/core/infrastructure/UpdaterManager.ts +23 -2
  5. package/changelog/v1.json +21 -0
  6. package/docker-compose/local/.env.example +3 -0
  7. package/docs/self-hosting/server-database/docker-compose.mdx +29 -0
  8. package/docs/self-hosting/server-database/docker-compose.zh-CN.mdx +29 -0
  9. package/package.json +1 -1
  10. package/packages/const/src/hotkeys.ts +3 -3
  11. package/packages/const/src/models.ts +2 -2
  12. package/packages/const/src/utils/merge.ts +3 -3
  13. package/packages/conversation-flow/package.json +13 -0
  14. package/packages/conversation-flow/src/__tests__/fixtures/index.ts +48 -0
  15. package/packages/conversation-flow/src/__tests__/fixtures/inputs/assistant-chain-with-followup.json +56 -0
  16. package/packages/conversation-flow/src/__tests__/fixtures/inputs/assistant-with-tools.json +144 -0
  17. package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/active-index-1.json +131 -0
  18. package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/assistant-branch.json +96 -0
  19. package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/assistant-user-branch.json +123 -0
  20. package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/conversation.json +128 -0
  21. package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/index.ts +14 -0
  22. package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/nested.json +179 -0
  23. package/packages/conversation-flow/src/__tests__/fixtures/inputs/compare/index.ts +8 -0
  24. package/packages/conversation-flow/src/__tests__/fixtures/inputs/compare/simple.json +85 -0
  25. package/packages/conversation-flow/src/__tests__/fixtures/inputs/compare/with-tools.json +169 -0
  26. package/packages/conversation-flow/src/__tests__/fixtures/inputs/complex-scenario.json +107 -0
  27. package/packages/conversation-flow/src/__tests__/fixtures/inputs/index.ts +14 -0
  28. package/packages/conversation-flow/src/__tests__/fixtures/inputs/linear-conversation.json +59 -0
  29. package/packages/conversation-flow/src/__tests__/fixtures/outputs/assistant-chain-with-followup.json +135 -0
  30. package/packages/conversation-flow/src/__tests__/fixtures/outputs/assistant-with-tools.json +340 -0
  31. package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/active-index-1.json +242 -0
  32. package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/assistant-branch.json +208 -0
  33. package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/assistant-user-branch.json +254 -0
  34. package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/conversation.json +260 -0
  35. package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/index.ts +14 -0
  36. package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/nested.json +389 -0
  37. package/packages/conversation-flow/src/__tests__/fixtures/outputs/compare/index.ts +8 -0
  38. package/packages/conversation-flow/src/__tests__/fixtures/outputs/compare/simple.json +224 -0
  39. package/packages/conversation-flow/src/__tests__/fixtures/outputs/compare/with-tools.json +418 -0
  40. package/packages/conversation-flow/src/__tests__/fixtures/outputs/complex-scenario.json +239 -0
  41. package/packages/conversation-flow/src/__tests__/fixtures/outputs/linear-conversation.json +138 -0
  42. package/packages/conversation-flow/src/__tests__/parse.test.ts +97 -0
  43. package/packages/conversation-flow/src/index.ts +17 -0
  44. package/packages/conversation-flow/src/indexing.ts +58 -0
  45. package/packages/conversation-flow/src/parse.ts +53 -0
  46. package/packages/conversation-flow/src/structuring.ts +38 -0
  47. package/packages/conversation-flow/src/transformation/BranchResolver.ts +66 -0
  48. package/packages/conversation-flow/src/transformation/ContextTreeBuilder.ts +292 -0
  49. package/packages/conversation-flow/src/transformation/FlatListBuilder.ts +421 -0
  50. package/packages/conversation-flow/src/transformation/MessageCollector.ts +166 -0
  51. package/packages/conversation-flow/src/transformation/MessageTransformer.ts +177 -0
  52. package/packages/conversation-flow/src/transformation/__tests__/BranchResolver.test.ts +151 -0
  53. package/packages/conversation-flow/src/transformation/__tests__/ContextTreeBuilder.test.ts +385 -0
  54. package/packages/conversation-flow/src/transformation/__tests__/FlatListBuilder.test.ts +511 -0
  55. package/packages/conversation-flow/src/transformation/__tests__/MessageCollector.test.ts +220 -0
  56. package/packages/conversation-flow/src/transformation/__tests__/MessageTransformer.test.ts +287 -0
  57. package/packages/conversation-flow/src/transformation/index.ts +78 -0
  58. package/packages/conversation-flow/src/types/contextTree.ts +65 -0
  59. package/packages/conversation-flow/src/types/flatMessageList.ts +66 -0
  60. package/packages/conversation-flow/src/types/shared.ts +63 -0
  61. package/packages/conversation-flow/src/types.ts +36 -0
  62. package/packages/conversation-flow/vitest.config.mts +10 -0
  63. package/packages/types/src/message/common/metadata.ts +5 -1
  64. package/src/app/[variants]/(main)/chat/@session/features/SessionListContent/List/Item/index.tsx +3 -4
  65. package/src/envs/__tests__/app.test.ts +47 -13
  66. package/src/envs/app.ts +6 -0
  67. package/src/server/modules/S3/index.test.ts +379 -0
  68. package/src/server/routers/async/__tests__/caller.test.ts +333 -0
  69. package/src/server/routers/async/caller.ts +2 -1
  70. package/src/server/routers/lambda/__tests__/integration/message.integration.test.ts +57 -57
  71. package/src/server/routers/lambda/message.ts +2 -2
  72. package/src/server/services/message/__tests__/index.test.ts +4 -4
  73. package/src/server/services/message/index.ts +1 -1
  74. package/src/services/message/index.ts +2 -3
  75. package/src/store/chat/slices/aiChat/actions/__tests__/generateAIChat.test.ts +8 -8
  76. package/src/store/chat/slices/aiChat/actions/__tests__/generateAIChatV2.test.ts +8 -8
  77. package/src/store/chat/slices/aiChat/actions/__tests__/helpers.ts +1 -1
  78. package/src/store/chat/slices/aiChat/actions/generateAIChatV2.ts +1 -1
  79. package/src/store/chat/slices/message/action.test.ts +7 -7
  80. package/src/store/chat/slices/message/action.ts +2 -2
  81. package/src/store/chat/slices/plugin/action.test.ts +7 -7
  82. package/src/store/chat/slices/plugin/action.ts +1 -1
  83. package/packages/context-engine/ARCHITECTURE.md +0 -425
@@ -0,0 +1,177 @@
1
+ import type { AssistantContentBlock, ModelPerformance, ModelUsage } from '@lobechat/types';
2
+
3
+ import type { Message } from '../types';
4
+
5
+ /**
6
+ * MessageTransformer - Handles message transformation utilities
7
+ *
8
+ * Provides utilities for:
9
+ * 1. Converting Message to AssistantContentBlock
10
+ * 2. Splitting metadata into usage and performance
11
+ * 3. Aggregating metadata from multiple messages
12
+ */
13
+ export class MessageTransformer {
14
+ /**
15
+ * Convert a Message to AssistantContentBlock
16
+ */
17
+ messageToContentBlock(message: Message): AssistantContentBlock {
18
+ const { usage, performance } = this.splitMetadata(message.metadata);
19
+
20
+ return {
21
+ content: message.content || '',
22
+ error: message.error,
23
+ id: message.id,
24
+ imageList: message.imageList,
25
+ performance,
26
+ reasoning: message.reasoning || undefined,
27
+ tools: message.tools as any,
28
+ usage,
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Split metadata into usage and performance objects
34
+ */
35
+ splitMetadata(metadata?: any): {
36
+ performance?: ModelPerformance;
37
+ usage?: ModelUsage;
38
+ } {
39
+ if (!metadata) return {};
40
+
41
+ const usage: ModelUsage = {};
42
+ const performance: ModelPerformance = {};
43
+
44
+ const usageFields = [
45
+ 'acceptedPredictionTokens',
46
+ 'cost',
47
+ 'inputAudioTokens',
48
+ 'inputCacheMissTokens',
49
+ 'inputCachedTokens',
50
+ 'inputCitationTokens',
51
+ 'inputImageTokens',
52
+ 'inputTextTokens',
53
+ 'inputWriteCacheTokens',
54
+ 'outputAudioTokens',
55
+ 'outputImageTokens',
56
+ 'outputReasoningTokens',
57
+ 'outputTextTokens',
58
+ 'rejectedPredictionTokens',
59
+ 'totalInputTokens',
60
+ 'totalOutputTokens',
61
+ 'totalTokens',
62
+ ] as const;
63
+
64
+ let hasUsage = false;
65
+ usageFields.forEach((field) => {
66
+ if (metadata[field] !== undefined) {
67
+ (usage as any)[field] = metadata[field];
68
+ hasUsage = true;
69
+ }
70
+ });
71
+
72
+ const performanceFields = ['duration', 'latency', 'tps', 'ttft'] as const;
73
+ let hasPerformance = false;
74
+ performanceFields.forEach((field) => {
75
+ if (metadata[field] !== undefined) {
76
+ (performance as any)[field] = metadata[field];
77
+ hasPerformance = true;
78
+ }
79
+ });
80
+
81
+ return {
82
+ performance: hasPerformance ? performance : undefined,
83
+ usage: hasUsage ? usage : undefined,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Aggregate metadata from multiple children
89
+ * - Sums token counts and costs
90
+ * - Takes first ttft
91
+ * - Averages tps
92
+ * - Sums duration and latency
93
+ */
94
+ aggregateMetadata(children: AssistantContentBlock[]): {
95
+ performance?: ModelPerformance;
96
+ usage?: ModelUsage;
97
+ } {
98
+ const usage: ModelUsage = {};
99
+ const performance: ModelPerformance = {};
100
+ let hasUsageData = false;
101
+ let hasPerformanceData = false;
102
+ let tpsSum = 0;
103
+ let tpsCount = 0;
104
+
105
+ children.forEach((child) => {
106
+ if (child.usage) {
107
+ const tokenFields = [
108
+ 'acceptedPredictionTokens',
109
+ 'inputAudioTokens',
110
+ 'inputCacheMissTokens',
111
+ 'inputCachedTokens',
112
+ 'inputCitationTokens',
113
+ 'inputImageTokens',
114
+ 'inputTextTokens',
115
+ 'inputWriteCacheTokens',
116
+ 'outputAudioTokens',
117
+ 'outputImageTokens',
118
+ 'outputReasoningTokens',
119
+ 'outputTextTokens',
120
+ 'rejectedPredictionTokens',
121
+ 'totalInputTokens',
122
+ 'totalOutputTokens',
123
+ 'totalTokens',
124
+ ] as const;
125
+
126
+ tokenFields.forEach((field) => {
127
+ if (typeof child.usage![field] === 'number') {
128
+ (usage as any)[field] = ((usage as any)[field] || 0) + child.usage![field]!;
129
+ hasUsageData = true;
130
+ }
131
+ });
132
+
133
+ if (typeof child.usage.cost === 'number') {
134
+ usage.cost = (usage.cost || 0) + child.usage.cost;
135
+ hasUsageData = true;
136
+ }
137
+ }
138
+
139
+ if (child.performance) {
140
+ // Take first ttft (time to first token)
141
+ if (child.performance.ttft !== undefined && performance.ttft === undefined) {
142
+ performance.ttft = child.performance.ttft;
143
+ hasPerformanceData = true;
144
+ }
145
+
146
+ // Average tps (tokens per second)
147
+ if (typeof child.performance.tps === 'number') {
148
+ tpsSum += child.performance.tps;
149
+ tpsCount += 1;
150
+ hasPerformanceData = true;
151
+ }
152
+
153
+ // Sum duration
154
+ if (child.performance.duration !== undefined) {
155
+ performance.duration = (performance.duration || 0) + child.performance.duration;
156
+ hasPerformanceData = true;
157
+ }
158
+
159
+ // Sum latency
160
+ if (child.performance.latency !== undefined) {
161
+ performance.latency = (performance.latency || 0) + child.performance.latency;
162
+ hasPerformanceData = true;
163
+ }
164
+ }
165
+ });
166
+
167
+ // Calculate average tps
168
+ if (tpsCount > 0) {
169
+ performance.tps = tpsSum / tpsCount;
170
+ }
171
+
172
+ return {
173
+ performance: hasPerformanceData ? performance : undefined,
174
+ usage: hasUsageData ? usage : undefined,
175
+ };
176
+ }
177
+ }
@@ -0,0 +1,151 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import type { IdNode, Message } from '../../types';
4
+ import { BranchResolver } from '../BranchResolver';
5
+
6
+ describe('BranchResolver', () => {
7
+ const resolver = new BranchResolver();
8
+
9
+ describe('getActiveBranchId', () => {
10
+ it('should return branch by activeBranchIndex from metadata when present', () => {
11
+ const message: Message = {
12
+ content: 'test',
13
+ createdAt: 0,
14
+ id: 'msg-1',
15
+ meta: {},
16
+ metadata: { activeBranchIndex: 1 },
17
+ role: 'user',
18
+ updatedAt: 0,
19
+ };
20
+
21
+ const idNode: IdNode = {
22
+ children: [
23
+ { children: [], id: 'msg-2' },
24
+ { children: [], id: 'msg-3' },
25
+ ],
26
+ id: 'msg-1',
27
+ };
28
+
29
+ expect(resolver.getActiveBranchId(message, idNode)).toBe('msg-3');
30
+ });
31
+
32
+ it('should infer active branch from which branch has children', () => {
33
+ const message: Message = {
34
+ content: 'test',
35
+ createdAt: 0,
36
+ id: 'msg-1',
37
+ meta: {},
38
+ role: 'user',
39
+ updatedAt: 0,
40
+ };
41
+
42
+ const idNode: IdNode = {
43
+ children: [
44
+ { children: [], id: 'msg-2' },
45
+ { children: [{ children: [], id: 'msg-4' }], id: 'msg-3' },
46
+ ],
47
+ id: 'msg-1',
48
+ };
49
+
50
+ expect(resolver.getActiveBranchId(message, idNode)).toBe('msg-3');
51
+ });
52
+
53
+ it('should default to first branch when no hints available', () => {
54
+ const message: Message = {
55
+ content: 'test',
56
+ createdAt: 0,
57
+ id: 'msg-1',
58
+ meta: {},
59
+ role: 'user',
60
+ updatedAt: 0,
61
+ };
62
+
63
+ const idNode: IdNode = {
64
+ children: [
65
+ { children: [], id: 'msg-2' },
66
+ { children: [], id: 'msg-3' },
67
+ ],
68
+ id: 'msg-1',
69
+ };
70
+
71
+ expect(resolver.getActiveBranchId(message, idNode)).toBe('msg-2');
72
+ });
73
+
74
+ it('should ignore invalid activeBranchIndex', () => {
75
+ const message: Message = {
76
+ content: 'test',
77
+ createdAt: 0,
78
+ id: 'msg-1',
79
+ meta: {},
80
+ metadata: { activeBranchIndex: 5 }, // out of bounds
81
+ role: 'user',
82
+ updatedAt: 0,
83
+ };
84
+
85
+ const idNode: IdNode = {
86
+ children: [
87
+ { children: [], id: 'msg-2' },
88
+ { children: [], id: 'msg-3' },
89
+ ],
90
+ id: 'msg-1',
91
+ };
92
+
93
+ // Should default to first branch
94
+ expect(resolver.getActiveBranchId(message, idNode)).toBe('msg-2');
95
+ });
96
+ });
97
+
98
+ describe('getActiveBranchIdFromMetadata', () => {
99
+ it('should return branch by activeBranchIndex from metadata', () => {
100
+ const message: Message = {
101
+ content: 'test',
102
+ createdAt: 0,
103
+ id: 'msg-1',
104
+ meta: {},
105
+ metadata: { activeBranchIndex: 1 },
106
+ role: 'user',
107
+ updatedAt: 0,
108
+ };
109
+
110
+ const childIds = ['msg-2', 'msg-3'];
111
+ const childrenMap = new Map<string | null, string[]>();
112
+
113
+ expect(resolver.getActiveBranchIdFromMetadata(message, childIds, childrenMap)).toBe('msg-3');
114
+ });
115
+
116
+ it('should infer active branch from which child has descendants', () => {
117
+ const message: Message = {
118
+ content: 'test',
119
+ createdAt: 0,
120
+ id: 'msg-1',
121
+ meta: {},
122
+ role: 'user',
123
+ updatedAt: 0,
124
+ };
125
+
126
+ const childIds = ['msg-2', 'msg-3'];
127
+ const childrenMap = new Map<string | null, string[]>([
128
+ ['msg-2', []],
129
+ ['msg-3', ['msg-4']],
130
+ ]);
131
+
132
+ expect(resolver.getActiveBranchIdFromMetadata(message, childIds, childrenMap)).toBe('msg-3');
133
+ });
134
+
135
+ it('should default to first child when no hints available', () => {
136
+ const message: Message = {
137
+ content: 'test',
138
+ createdAt: 0,
139
+ id: 'msg-1',
140
+ meta: {},
141
+ role: 'user',
142
+ updatedAt: 0,
143
+ };
144
+
145
+ const childIds = ['msg-2', 'msg-3'];
146
+ const childrenMap = new Map<string | null, string[]>();
147
+
148
+ expect(resolver.getActiveBranchIdFromMetadata(message, childIds, childrenMap)).toBe('msg-2');
149
+ });
150
+ });
151
+ });
@@ -0,0 +1,385 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import type { IdNode, Message, MessageGroupMetadata } from '../../types';
4
+ import { BranchResolver } from '../BranchResolver';
5
+ import { ContextTreeBuilder } from '../ContextTreeBuilder';
6
+ import { MessageCollector } from '../MessageCollector';
7
+
8
+ describe('ContextTreeBuilder', () => {
9
+ const createBuilder = (
10
+ messageMap: Map<string, Message>,
11
+ messageGroupMap: Map<string, MessageGroupMetadata> = new Map(),
12
+ ) => {
13
+ const childrenMap = new Map<string | null, string[]>();
14
+ const branchResolver = new BranchResolver();
15
+ const messageCollector = new MessageCollector(messageMap, childrenMap);
16
+ let nodeIdCounter = 0;
17
+ const generateNodeId = (prefix: string, messageId: string) =>
18
+ `${prefix}-${messageId}-${nodeIdCounter++}`;
19
+
20
+ return new ContextTreeBuilder(
21
+ messageMap,
22
+ messageGroupMap,
23
+ branchResolver,
24
+ messageCollector,
25
+ generateNodeId,
26
+ );
27
+ };
28
+
29
+ describe('transformAll', () => {
30
+ it('should transform regular message nodes', () => {
31
+ const messageMap = new Map<string, Message>([
32
+ [
33
+ 'msg-1',
34
+ {
35
+ content: 'Hello',
36
+ createdAt: 0,
37
+ id: 'msg-1',
38
+ meta: {},
39
+ role: 'user',
40
+ updatedAt: 0,
41
+ },
42
+ ],
43
+ [
44
+ 'msg-2',
45
+ {
46
+ content: 'Hi',
47
+ createdAt: 0,
48
+ id: 'msg-2',
49
+ meta: {},
50
+ role: 'assistant',
51
+ updatedAt: 0,
52
+ },
53
+ ],
54
+ ]);
55
+
56
+ const builder = createBuilder(messageMap);
57
+ const idNodes: IdNode[] = [
58
+ {
59
+ children: [{ children: [], id: 'msg-2' }],
60
+ id: 'msg-1',
61
+ },
62
+ ];
63
+
64
+ const result = builder.transformAll(idNodes);
65
+
66
+ expect(result).toHaveLength(2);
67
+ expect(result[0]).toEqual({ id: 'msg-1', type: 'message' });
68
+ expect(result[1]).toEqual({ id: 'msg-2', type: 'message' });
69
+ });
70
+
71
+ it('should create branch node for multiple children', () => {
72
+ const messageMap = new Map<string, Message>([
73
+ [
74
+ 'msg-1',
75
+ {
76
+ content: 'Hello',
77
+ createdAt: 0,
78
+ id: 'msg-1',
79
+ meta: {},
80
+ metadata: { activeBranchIndex: 0 },
81
+ role: 'user',
82
+ updatedAt: 0,
83
+ },
84
+ ],
85
+ [
86
+ 'msg-2',
87
+ {
88
+ content: 'Response 1',
89
+ createdAt: 0,
90
+ id: 'msg-2',
91
+ meta: {},
92
+ role: 'assistant',
93
+ updatedAt: 0,
94
+ },
95
+ ],
96
+ [
97
+ 'msg-3',
98
+ {
99
+ content: 'Response 2',
100
+ createdAt: 0,
101
+ id: 'msg-3',
102
+ meta: {},
103
+ role: 'assistant',
104
+ updatedAt: 0,
105
+ },
106
+ ],
107
+ ]);
108
+
109
+ const builder = createBuilder(messageMap);
110
+ const idNodes: IdNode[] = [
111
+ {
112
+ children: [
113
+ { children: [], id: 'msg-2' },
114
+ { children: [], id: 'msg-3' },
115
+ ],
116
+ id: 'msg-1',
117
+ },
118
+ ];
119
+
120
+ const result = builder.transformAll(idNodes);
121
+
122
+ expect(result).toHaveLength(2);
123
+ expect(result[0]).toEqual({ id: 'msg-1', type: 'message' });
124
+ expect(result[1]).toMatchObject({
125
+ activeBranchIndex: 0,
126
+ branches: [[{ id: 'msg-2', type: 'message' }], [{ id: 'msg-3', type: 'message' }]],
127
+ parentMessageId: 'msg-1',
128
+ type: 'branch',
129
+ });
130
+ });
131
+
132
+ it('should create assistant group node for assistant with tools', () => {
133
+ const messageMap = new Map<string, Message>([
134
+ [
135
+ 'msg-1',
136
+ {
137
+ content: 'Assistant with tools',
138
+ createdAt: 0,
139
+ id: 'msg-1',
140
+ meta: {},
141
+ role: 'assistant',
142
+ tools: [
143
+ {
144
+ apiName: 'test',
145
+ arguments: '{}',
146
+ id: 'tool-1',
147
+ identifier: 'test',
148
+ type: 'default',
149
+ },
150
+ ],
151
+ updatedAt: 0,
152
+ },
153
+ ],
154
+ [
155
+ 'tool-1',
156
+ {
157
+ content: 'Tool result',
158
+ createdAt: 0,
159
+ id: 'tool-1',
160
+ meta: {},
161
+ role: 'tool',
162
+ updatedAt: 0,
163
+ },
164
+ ],
165
+ ]);
166
+
167
+ const builder = createBuilder(messageMap);
168
+ const idNodes: IdNode[] = [
169
+ {
170
+ children: [{ children: [], id: 'tool-1' }],
171
+ id: 'msg-1',
172
+ },
173
+ ];
174
+
175
+ const result = builder.transformAll(idNodes);
176
+
177
+ expect(result).toHaveLength(1);
178
+ expect(result[0]).toMatchObject({
179
+ children: [{ id: 'msg-1', tools: ['tool-1'], type: 'message' }],
180
+ id: 'msg-1',
181
+ type: 'assistantGroup',
182
+ });
183
+ });
184
+
185
+ it('should create compare node from message group', () => {
186
+ const messageMap = new Map<string, Message>([
187
+ [
188
+ 'msg-1',
189
+ {
190
+ content: 'Compare 1',
191
+ createdAt: 0,
192
+ groupId: 'group-1',
193
+ id: 'msg-1',
194
+ meta: {},
195
+ metadata: { activeColumn: true },
196
+ role: 'assistant',
197
+ updatedAt: 0,
198
+ },
199
+ ],
200
+ [
201
+ 'msg-2',
202
+ {
203
+ content: 'Compare 2',
204
+ createdAt: 0,
205
+ groupId: 'group-1',
206
+ id: 'msg-2',
207
+ meta: {},
208
+ role: 'assistant',
209
+ updatedAt: 0,
210
+ },
211
+ ],
212
+ ]);
213
+
214
+ const messageGroupMap = new Map<string, MessageGroupMetadata>([
215
+ ['group-1', { id: 'group-1', mode: 'compare' }],
216
+ ]);
217
+
218
+ const builder = createBuilder(messageMap, messageGroupMap);
219
+ const idNodes: IdNode[] = [{ children: [], id: 'msg-1' }];
220
+
221
+ const result = builder.transformAll(idNodes);
222
+
223
+ expect(result).toHaveLength(1);
224
+ expect(result[0]).toMatchObject({
225
+ activeColumnId: 'msg-1',
226
+ columns: [[{ id: 'msg-1', type: 'message' }], [{ id: 'msg-2', type: 'message' }]],
227
+ messageId: 'msg-1',
228
+ type: 'compare',
229
+ });
230
+ });
231
+
232
+ it('should create compare node from user message metadata', () => {
233
+ const messageMap = new Map<string, Message>([
234
+ [
235
+ 'msg-1',
236
+ {
237
+ content: 'User message',
238
+ createdAt: 0,
239
+ id: 'msg-1',
240
+ meta: {},
241
+ metadata: { compare: true },
242
+ role: 'user',
243
+ updatedAt: 0,
244
+ },
245
+ ],
246
+ [
247
+ 'msg-2',
248
+ {
249
+ content: 'Assistant 1',
250
+ createdAt: 0,
251
+ id: 'msg-2',
252
+ meta: {},
253
+ metadata: { activeColumn: true },
254
+ role: 'assistant',
255
+ updatedAt: 0,
256
+ },
257
+ ],
258
+ [
259
+ 'msg-3',
260
+ {
261
+ content: 'Assistant 2',
262
+ createdAt: 0,
263
+ id: 'msg-3',
264
+ meta: {},
265
+ role: 'assistant',
266
+ updatedAt: 0,
267
+ },
268
+ ],
269
+ ]);
270
+
271
+ const builder = createBuilder(messageMap);
272
+ const idNodes: IdNode[] = [
273
+ {
274
+ children: [
275
+ { children: [], id: 'msg-2' },
276
+ { children: [], id: 'msg-3' },
277
+ ],
278
+ id: 'msg-1',
279
+ },
280
+ ];
281
+
282
+ const result = builder.transformAll(idNodes);
283
+
284
+ expect(result).toHaveLength(2);
285
+ expect(result[0]).toEqual({ id: 'msg-1', type: 'message' });
286
+ expect(result[1]).toMatchObject({
287
+ activeColumnId: 'msg-2',
288
+ columns: [[{ id: 'msg-2', type: 'message' }], [{ id: 'msg-3', type: 'message' }]],
289
+ id: 'compare-msg-1-msg-2-msg-3',
290
+ messageId: 'msg-1',
291
+ type: 'compare',
292
+ });
293
+ });
294
+
295
+ it('should handle empty node list', () => {
296
+ const messageMap = new Map<string, Message>();
297
+ const builder = createBuilder(messageMap);
298
+
299
+ const result = builder.transformAll([]);
300
+
301
+ expect(result).toHaveLength(0);
302
+ });
303
+
304
+ it('should handle missing message in map', () => {
305
+ const messageMap = new Map<string, Message>();
306
+ const builder = createBuilder(messageMap);
307
+ const idNodes: IdNode[] = [{ children: [], id: 'missing' }];
308
+
309
+ const result = builder.transformAll(idNodes);
310
+
311
+ expect(result).toHaveLength(0);
312
+ });
313
+
314
+ it('should continue with active column children in compare mode', () => {
315
+ const messageMap = new Map<string, Message>([
316
+ [
317
+ 'msg-1',
318
+ {
319
+ content: 'User',
320
+ createdAt: 0,
321
+ id: 'msg-1',
322
+ meta: {},
323
+ metadata: { compare: true },
324
+ role: 'user',
325
+ updatedAt: 0,
326
+ },
327
+ ],
328
+ [
329
+ 'msg-2',
330
+ {
331
+ content: 'Assistant 1',
332
+ createdAt: 0,
333
+ id: 'msg-2',
334
+ meta: {},
335
+ metadata: { activeColumn: true },
336
+ role: 'assistant',
337
+ updatedAt: 0,
338
+ },
339
+ ],
340
+ [
341
+ 'msg-3',
342
+ {
343
+ content: 'Assistant 2',
344
+ createdAt: 0,
345
+ id: 'msg-3',
346
+ meta: {},
347
+ role: 'assistant',
348
+ updatedAt: 0,
349
+ },
350
+ ],
351
+ [
352
+ 'msg-4',
353
+ {
354
+ content: 'Follow-up',
355
+ createdAt: 0,
356
+ id: 'msg-4',
357
+ meta: {},
358
+ role: 'user',
359
+ updatedAt: 0,
360
+ },
361
+ ],
362
+ ]);
363
+
364
+ const builder = createBuilder(messageMap);
365
+ const idNodes: IdNode[] = [
366
+ {
367
+ children: [
368
+ { children: [{ children: [], id: 'msg-4' }], id: 'msg-2' },
369
+ { children: [], id: 'msg-3' },
370
+ ],
371
+ id: 'msg-1',
372
+ },
373
+ ];
374
+
375
+ const result = builder.transformAll(idNodes);
376
+
377
+ expect(result).toHaveLength(3);
378
+ expect(result[0]).toEqual({ id: 'msg-1', type: 'message' });
379
+ expect(result[1]).toMatchObject({
380
+ type: 'compare',
381
+ });
382
+ expect(result[2]).toEqual({ id: 'msg-4', type: 'message' });
383
+ });
384
+ });
385
+ });