myavana-bot-test-core 2.2.0 → 2.3.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.
@@ -0,0 +1,151 @@
1
+ const mockQuery = jest.fn();
2
+ const mockRedisGet = jest.fn();
3
+ const mockRedisSet = jest.fn();
4
+
5
+ jest.mock('../database', () => ({
6
+ pgClient: { query: mockQuery },
7
+ redisClient: { get: mockRedisGet, set: mockRedisSet },
8
+ }));
9
+
10
+ jest.mock('../ai', () => ({
11
+ chat: jest.fn(),
12
+ }));
13
+
14
+ jest.mock('@genkit-ai/google-genai', () => ({
15
+ googleAI: { model: jest.fn(() => 'mocked-model') },
16
+ }));
17
+
18
+ const {
19
+ saveConversationSummary,
20
+ getAllConversationsSummary,
21
+ getConversationHistory,
22
+ saveConversation,
23
+ } = require('../conversation');
24
+
25
+ beforeEach(() => {
26
+ jest.clearAllMocks();
27
+ });
28
+
29
+ describe('saveConversationSummary', () => {
30
+ test('inserts/updates DB and caches in Redis', async () => {
31
+ mockQuery.mockResolvedValue({ rows: [{ conversation_id: 'c1', summary: 'sum' }] });
32
+ mockRedisSet.mockResolvedValue('OK');
33
+
34
+ const result = await saveConversationSummary('c1', 'u1', 'sum');
35
+
36
+ expect(mockQuery).toHaveBeenCalledWith(
37
+ expect.stringContaining('INSERT INTO conversations'),
38
+ ['c1', 'u1', 'sum']
39
+ );
40
+ expect(mockRedisSet).toHaveBeenCalledWith('summary:u1', 'sum', { EX: 3600 });
41
+ expect(result).toEqual({ conversation_id: 'c1', summary: 'sum' });
42
+ });
43
+
44
+ test('throws on DB error', async () => {
45
+ mockQuery.mockRejectedValue(new Error('DB error'));
46
+ await expect(saveConversationSummary('c1', 'u1', 'sum')).rejects.toThrow('DB error');
47
+ });
48
+ });
49
+
50
+ describe('getAllConversationsSummary', () => {
51
+ test('returns cached summary from Redis', async () => {
52
+ mockRedisGet.mockResolvedValue('cached summary');
53
+
54
+ const result = await getAllConversationsSummary('u1');
55
+
56
+ expect(result).toBe('cached summary');
57
+ expect(mockQuery).not.toHaveBeenCalled();
58
+ });
59
+
60
+ test('fetches from DB on Redis miss and caches result', async () => {
61
+ mockRedisGet.mockResolvedValue(null);
62
+ mockQuery.mockResolvedValue({ rows: [{ summary: 'db summary' }] });
63
+ mockRedisSet.mockResolvedValue('OK');
64
+
65
+ const result = await getAllConversationsSummary('u1');
66
+
67
+ expect(result).toBe('db summary');
68
+ expect(mockRedisSet).toHaveBeenCalledWith('summary:u1', 'db summary', { EX: 3600 });
69
+ });
70
+
71
+ test('returns fallback string when no summary exists', async () => {
72
+ mockRedisGet.mockResolvedValue(null);
73
+ mockQuery.mockResolvedValue({ rows: [] });
74
+
75
+ const result = await getAllConversationsSummary('u1');
76
+
77
+ expect(result).toBe('conversation summary not available.');
78
+ });
79
+
80
+ test('returns fallback string on error', async () => {
81
+ mockRedisGet.mockRejectedValue(new Error('Redis down'));
82
+
83
+ const result = await getAllConversationsSummary('u1');
84
+
85
+ expect(result).toBe('conversation summary not available.');
86
+ });
87
+ });
88
+
89
+ describe('getConversationHistory', () => {
90
+ test('returns parsed history from Redis cache', async () => {
91
+ const history = [{ role: 'user', content: 'hi' }];
92
+ mockRedisGet.mockResolvedValue(JSON.stringify({ chatHistory: history }));
93
+
94
+ const result = await getConversationHistory('conv1', 'u1');
95
+
96
+ expect(result).toEqual(history);
97
+ expect(mockQuery).not.toHaveBeenCalled();
98
+ });
99
+
100
+ test('fetches from DB on Redis miss and caches result', async () => {
101
+ const history = [{ role: 'user', content: 'hi' }];
102
+ mockRedisGet.mockResolvedValue(null);
103
+ mockQuery.mockResolvedValue({ rows: [{ chat_history: history }] });
104
+ mockRedisSet.mockResolvedValue('OK');
105
+
106
+ const result = await getConversationHistory('conv1', 'u1');
107
+
108
+ expect(result).toEqual(history);
109
+ expect(mockRedisSet).toHaveBeenCalledWith(
110
+ 'conversation:conv1',
111
+ JSON.stringify({ chatHistory: history })
112
+ );
113
+ });
114
+
115
+ test('returns empty array for new conversation', async () => {
116
+ mockRedisGet.mockResolvedValue(null);
117
+ mockQuery.mockResolvedValue({ rows: [] });
118
+
119
+ const result = await getConversationHistory('conv1', 'u1');
120
+
121
+ expect(result).toEqual([]);
122
+ });
123
+
124
+ test('returns empty array on error', async () => {
125
+ mockRedisGet.mockRejectedValue(new Error('Redis down'));
126
+
127
+ const result = await getConversationHistory('conv1', 'u1');
128
+
129
+ expect(result).toEqual([]);
130
+ });
131
+ });
132
+
133
+ describe('saveConversation', () => {
134
+ test('saves to DB and updates Redis cache', async () => {
135
+ const history = [{ role: 'user', content: 'hi' }];
136
+ mockQuery.mockResolvedValue({ rows: [{ conversation_id: 'c1' }] });
137
+ mockRedisSet.mockResolvedValue('OK');
138
+
139
+ const result = await saveConversation('c1', 'u1', history, 'summary');
140
+
141
+ expect(mockQuery).toHaveBeenCalledWith(
142
+ expect.stringContaining('INSERT INTO conversations'),
143
+ ['c1', 'u1', JSON.stringify(history), 'summary']
144
+ );
145
+ expect(mockRedisSet).toHaveBeenCalledWith(
146
+ 'conversation:c1',
147
+ JSON.stringify({ chatHistory: history })
148
+ );
149
+ expect(result).toEqual({ conversation_id: 'c1' });
150
+ });
151
+ });
@@ -0,0 +1,233 @@
1
+ const {
2
+ inferStage,
3
+ analyzeAIQuestions,
4
+ getDepthCategory,
5
+ buildDrivingDirective,
6
+ getConversationDirective,
7
+ STAGES,
8
+ } = require('../conversationDriver');
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Test helpers
12
+ // ---------------------------------------------------------------------------
13
+
14
+ const user = (content) => ({ role: 'user', content });
15
+ const ai = (content) => ({ role: 'model', content });
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // inferStage
19
+ // ---------------------------------------------------------------------------
20
+
21
+ describe('inferStage', () => {
22
+ test('detects stage 5 from desperation keywords', () => {
23
+ expect(inferStage([user('I am so overwhelmed and frustrated with my hair')])).toBe(5);
24
+ });
25
+
26
+ test('detects stage 6 from damage keywords', () => {
27
+ expect(inferStage([user('I have so much heat damage and breakage')])).toBe(6);
28
+ });
29
+
30
+ test('detects stage 1 from big chop keywords', () => {
31
+ expect(inferStage([user('I just did my big chop yesterday!')])).toBe(1);
32
+ });
33
+
34
+ test('detects stage 8 from nirvana keywords', () => {
35
+ expect(inferStage([user('I love my routine and my hair is healthy and happy')])).toBe(8);
36
+ });
37
+
38
+ test('returns null when no keywords match', () => {
39
+ expect(inferStage([user('Hello there')])).toBeNull();
40
+ });
41
+
42
+ test('returns null for empty history', () => {
43
+ expect(inferStage([])).toBeNull();
44
+ });
45
+
46
+ test('only looks at last 4 user messages (old keywords ignored)', () => {
47
+ const old = user('I just did my big chop'); // stage 1
48
+ const recent = user('I am overwhelmed'); // stage 5
49
+ const history = [old, ai('response'), ai('r2'), ai('r3'), ai('r4'), recent];
50
+ // Recent stage 5 wins over old stage 1
51
+ expect(inferStage(history)).toBe(5);
52
+ });
53
+ });
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // analyzeAIQuestions
57
+ // ---------------------------------------------------------------------------
58
+
59
+ describe('analyzeAIQuestions', () => {
60
+ test('counts turns with questions correctly', () => {
61
+ const history = [
62
+ user('hi'),
63
+ ai('Hello! What is your hair type?'),
64
+ user('4c'),
65
+ ai('Great! How long have you been natural?'),
66
+ user('2 years'),
67
+ ai('Wonderful. Here are some tips.'),
68
+ ];
69
+ const stats = analyzeAIQuestions(history, 3);
70
+ expect(stats.total).toBe(3);
71
+ expect(stats.withQuestion).toBe(2);
72
+ expect(stats.lastTurnHadQuestion).toBe(false);
73
+ });
74
+
75
+ test('lastTurnHadQuestion is true when last AI turn has a question', () => {
76
+ const history = [user('hi'), ai('What is your hair type?')];
77
+ const stats = analyzeAIQuestions(history);
78
+ expect(stats.lastTurnHadQuestion).toBe(true);
79
+ });
80
+
81
+ test('returns zeros for empty history', () => {
82
+ const stats = analyzeAIQuestions([]);
83
+ expect(stats.total).toBe(0);
84
+ expect(stats.withQuestion).toBe(0);
85
+ expect(stats.lastTurnHadQuestion).toBe(false);
86
+ });
87
+ });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // getDepthCategory
91
+ // ---------------------------------------------------------------------------
92
+
93
+ describe('getDepthCategory', () => {
94
+ test('opening: 0 user turns', () => {
95
+ expect(getDepthCategory([])).toBe('opening');
96
+ });
97
+
98
+ test('opening: 1 user turn', () => {
99
+ expect(getDepthCategory([user('hi')])).toBe('opening');
100
+ });
101
+
102
+ test('exploring: 2-3 user turns', () => {
103
+ expect(getDepthCategory([user('a'), user('b')])).toBe('exploring');
104
+ expect(getDepthCategory([user('a'), user('b'), user('c')])).toBe('exploring');
105
+ });
106
+
107
+ test('deepening: 4-7 user turns', () => {
108
+ const h = Array.from({ length: 5 }, () => user('msg'));
109
+ expect(getDepthCategory(h)).toBe('deepening');
110
+ });
111
+
112
+ test('action-ready: 8+ user turns', () => {
113
+ const h = Array.from({ length: 8 }, () => user('msg'));
114
+ expect(getDepthCategory(h)).toBe('action-ready');
115
+ });
116
+ });
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // buildDrivingDirective
120
+ // ---------------------------------------------------------------------------
121
+
122
+ describe('buildDrivingDirective', () => {
123
+ test('opening with no stage → ask about hair type', () => {
124
+ const result = buildDrivingDirective({
125
+ stage: null, depth: 'opening',
126
+ questionStats: { total: 0, withQuestion: 0, lastTurnHadQuestion: false },
127
+ personaType: null,
128
+ });
129
+ expect(result).toMatch(/ask one open question/);
130
+ });
131
+
132
+ test('exploring, AI did not ask last turn → prompt to ask follow-up', () => {
133
+ const result = buildDrivingDirective({
134
+ stage: null, depth: 'exploring',
135
+ questionStats: { total: 1, withQuestion: 0, lastTurnHadQuestion: false },
136
+ personaType: null,
137
+ });
138
+ expect(result).toMatch(/follow-up question/);
139
+ });
140
+
141
+ test('exploring, AI already asked → includes stage context when stage known', () => {
142
+ const result = buildDrivingDirective({
143
+ stage: 3, depth: 'exploring',
144
+ questionStats: { total: 1, withQuestion: 1, lastTurnHadQuestion: true },
145
+ personaType: null,
146
+ });
147
+ expect(result).toMatch(/Experimenting/);
148
+ });
149
+
150
+ test('deepening, AI never asked → surfaces momentum directive', () => {
151
+ const result = buildDrivingDirective({
152
+ stage: null, depth: 'deepening',
153
+ questionStats: { total: 3, withQuestion: 0, lastTurnHadQuestion: false },
154
+ personaType: null,
155
+ });
156
+ expect(result).toMatch(/forward momentum/);
157
+ });
158
+
159
+ test('deepening, high-need stage (5) → introduces Myavana service', () => {
160
+ const result = buildDrivingDirective({
161
+ stage: 5, depth: 'deepening',
162
+ questionStats: { total: 3, withQuestion: 2, lastTurnHadQuestion: true },
163
+ personaType: null,
164
+ });
165
+ expect(result).toMatch(/Myavana service/);
166
+ });
167
+
168
+ test('action-ready, stage 8 → celebrate and maintenance', () => {
169
+ const result = buildDrivingDirective({
170
+ stage: 8, depth: 'action-ready',
171
+ questionStats: { total: 5, withQuestion: 3, lastTurnHadQuestion: true },
172
+ personaType: null,
173
+ });
174
+ expect(result).toMatch(/celebrate/);
175
+ });
176
+
177
+ test('overwhelmed persona softens action directive', () => {
178
+ const result = buildDrivingDirective({
179
+ stage: 6, depth: 'deepening',
180
+ questionStats: { total: 3, withQuestion: 2, lastTurnHadQuestion: true },
181
+ personaType: 'overwhelmed',
182
+ });
183
+ expect(result).toMatch(/empathy first/);
184
+ expect(result).not.toMatch(/introduce a relevant Myavana service/);
185
+ });
186
+
187
+ test('returns empty string when no directive applies', () => {
188
+ // exploring + already asked → no stage context when stage is null
189
+ const result = buildDrivingDirective({
190
+ stage: null, depth: 'exploring',
191
+ questionStats: { total: 1, withQuestion: 1, lastTurnHadQuestion: true },
192
+ personaType: null,
193
+ });
194
+ expect(result).toBe('');
195
+ });
196
+ });
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // getConversationDirective (integration)
200
+ // ---------------------------------------------------------------------------
201
+
202
+ describe('getConversationDirective', () => {
203
+ test('returns first-message directive for empty history', () => {
204
+ const result = getConversationDirective([]);
205
+ expect(result).toMatch(/first message/);
206
+ });
207
+
208
+ test('returns non-empty string for a real conversation', () => {
209
+ const history = [
210
+ user('My hair is so damaged from bleaching'),
211
+ ai('I understand, bleach can really stress your strands. How long ago did you bleach?'),
212
+ user('About 3 months ago. It keeps breaking off'),
213
+ ai('Breakage after bleach is very common. Are you using any protein treatments?'),
214
+ user('No, I had no idea I needed that'),
215
+ ];
216
+ const result = getConversationDirective(history, null);
217
+ expect(typeof result).toBe('string');
218
+ expect(result.length).toBeGreaterThan(0);
219
+ });
220
+
221
+ test('wraps directive in bracket format', () => {
222
+ const result = getConversationDirective([user('hi')], null);
223
+ if (result) {
224
+ expect(result).toMatch(/^\[Conversation driver:/);
225
+ expect(result).toMatch(/\]\n$/);
226
+ }
227
+ });
228
+
229
+ test('does not throw when personaType is null or undefined', () => {
230
+ expect(() => getConversationDirective([user('hello')], null)).not.toThrow();
231
+ expect(() => getConversationDirective([user('hello')], undefined)).not.toThrow();
232
+ });
233
+ });
@@ -0,0 +1,192 @@
1
+ jest.mock('../personaClassifier', () => ({
2
+ getUserPersona: jest.fn(),
3
+ buildContextBlock: jest.fn(),
4
+ }));
5
+
6
+ jest.mock('../conversationDriver', () => ({
7
+ getConversationDirective: jest.fn().mockReturnValue(''),
8
+ inferStage: jest.fn().mockReturnValue(null),
9
+ extractTrendSummary: jest.fn().mockReturnValue(null),
10
+ }));
11
+
12
+ jest.mock('../trendManager', () => ({
13
+ getTrendContext: jest.fn().mockResolvedValue(''),
14
+ }));
15
+
16
+ jest.mock('../agentMemory', () => ({
17
+ retrieveRelevantMemories: jest.fn().mockResolvedValue([]),
18
+ }));
19
+
20
+ const { getUserPersona, buildContextBlock } = require('../personaClassifier');
21
+ const { getConversationDirective, inferStage, extractTrendSummary } = require('../conversationDriver');
22
+ const { getTrendContext } = require('../trendManager');
23
+ const { retrieveRelevantMemories } = require('../agentMemory');
24
+ const { buildMessageContext } = require('../messageContextBuilder');
25
+
26
+ beforeEach(() => {
27
+ jest.clearAllMocks();
28
+ // resetMocks (jest.config.js) wipes the jest.mock() factory default before each test,
29
+ // so give every test a harmless empty-memory default; tests exercising memory override it.
30
+ retrieveRelevantMemories.mockResolvedValue([]);
31
+ });
32
+
33
+ describe('buildMessageContext', () => {
34
+ test('returns persona block when persona exists and no trend or driver', async () => {
35
+ const persona = { personaType: 'overwhelmed', communicationStyle: 'nurturing', toneDirectives: [] };
36
+ getUserPersona.mockResolvedValue(persona);
37
+ buildContextBlock.mockReturnValue('[User persona: overwhelmed, style: nurturing]\n');
38
+ getTrendContext.mockResolvedValue('');
39
+ getConversationDirective.mockReturnValue('');
40
+
41
+ const result = await buildMessageContext('u1', []);
42
+ expect(result).toBe('[User persona: overwhelmed, style: nurturing]\n');
43
+ // hasTrends=false because trendContext is empty
44
+ expect(buildContextBlock).toHaveBeenCalledWith(persona, false);
45
+ });
46
+
47
+ test('passes hasTrends=true to buildContextBlock when trend context exists', async () => {
48
+ const persona = { personaType: 'researcher', communicationStyle: 'professional', toneDirectives: [] };
49
+ getUserPersona.mockResolvedValue(persona);
50
+ buildContextBlock.mockReturnValue('[User persona: researcher, style: professional]\n');
51
+ getTrendContext.mockResolvedValue('[Trending in hair care: scalp detox]\n');
52
+ getConversationDirective.mockReturnValue('');
53
+
54
+ await buildMessageContext('u1', []);
55
+ expect(buildContextBlock).toHaveBeenCalledWith(persona, true);
56
+ });
57
+
58
+ test('combines all three blocks when all present', async () => {
59
+ const persona = { personaType: 'explorer', communicationStyle: 'casual', toneDirectives: [] };
60
+ getUserPersona.mockResolvedValue(persona);
61
+ buildContextBlock.mockReturnValue('[User persona: explorer, style: casual]\n');
62
+ getTrendContext.mockResolvedValue('[Trending in hair care: rice water]\n');
63
+ getConversationDirective.mockReturnValue('[Conversation driver: ask a follow-up]\n');
64
+
65
+ const result = await buildMessageContext('u1', [{ role: 'user', content: 'hi' }]);
66
+ expect(result).toBe(
67
+ '[User persona: explorer, style: casual]\n' +
68
+ '[Trending in hair care: rice water]\n' +
69
+ '[Conversation driver: ask a follow-up]\n'
70
+ );
71
+ });
72
+
73
+ test('returns driver directive alone when no persona saved', async () => {
74
+ getUserPersona.mockResolvedValue(null);
75
+ getTrendContext.mockResolvedValue('');
76
+ getConversationDirective.mockReturnValue('[Conversation driver: first message directive]\n');
77
+
78
+ const result = await buildMessageContext('u1');
79
+ expect(result).toBe('[Conversation driver: first message directive]\n');
80
+ expect(buildContextBlock).not.toHaveBeenCalled();
81
+ });
82
+
83
+ test('returns empty string when all sources return nothing', async () => {
84
+ getUserPersona.mockResolvedValue(null);
85
+ getTrendContext.mockResolvedValue('');
86
+ getConversationDirective.mockReturnValue('');
87
+
88
+ expect(await buildMessageContext('u1')).toBe('');
89
+ });
90
+
91
+ test('passes inferred stage and personaType to getTrendContext', async () => {
92
+ const persona = { personaType: 'overwhelmed', communicationStyle: 'nurturing', toneDirectives: [] };
93
+ getUserPersona.mockResolvedValue(persona);
94
+ buildContextBlock.mockReturnValue('');
95
+ inferStage.mockReturnValue(5);
96
+ getTrendContext.mockResolvedValue('');
97
+ getConversationDirective.mockReturnValue('');
98
+
99
+ await buildMessageContext('u1', [{ role: 'user', content: 'help' }]);
100
+ expect(getTrendContext).toHaveBeenCalledWith(5, 'overwhelmed');
101
+ });
102
+
103
+ test('passes trendSummary and personaType to getConversationDirective', async () => {
104
+ getUserPersona.mockResolvedValue(null);
105
+ getTrendContext.mockResolvedValue('[Trending in hair care: scalp detox]\n');
106
+ extractTrendSummary.mockReturnValue('scalp detox');
107
+ getConversationDirective.mockReturnValue('');
108
+
109
+ const history = [{ role: 'user', content: 'help me' }];
110
+ await buildMessageContext('u1', history);
111
+ expect(getConversationDirective).toHaveBeenCalledWith(history, null, 'scalp detox');
112
+ });
113
+
114
+ test('does not throw when getUserPersona fails — still runs trend and driver', async () => {
115
+ getUserPersona.mockRejectedValue(new Error('Redis down'));
116
+ getTrendContext.mockResolvedValue('');
117
+ getConversationDirective.mockReturnValue('[Conversation driver: directive]\n');
118
+
119
+ const result = await buildMessageContext('u1');
120
+ expect(result).toBe('[Conversation driver: directive]\n');
121
+ });
122
+
123
+ test('does not throw when trendManager fails — still returns persona and driver', async () => {
124
+ const persona = { personaType: 'engaged', communicationStyle: 'casual', toneDirectives: [] };
125
+ getUserPersona.mockResolvedValue(persona);
126
+ buildContextBlock.mockReturnValue('[User persona: engaged, style: casual]\n');
127
+ getTrendContext.mockRejectedValue(new Error('trend error'));
128
+ getConversationDirective.mockReturnValue('');
129
+
130
+ const result = await buildMessageContext('u1');
131
+ expect(result).toBe('[User persona: engaged, style: casual]\n');
132
+ });
133
+
134
+ test('does not throw when conversationDriver fails — still returns persona and trend', async () => {
135
+ const persona = { personaType: 'engaged', communicationStyle: 'casual', toneDirectives: [] };
136
+ getUserPersona.mockResolvedValue(persona);
137
+ buildContextBlock.mockReturnValue('[User persona: engaged, style: casual]\n');
138
+ getTrendContext.mockResolvedValue('[Trending in hair care: rice water]\n');
139
+ getConversationDirective.mockImplementation(() => { throw new Error('driver error'); });
140
+
141
+ const result = await buildMessageContext('u1');
142
+ expect(result).toBe('[User persona: engaged, style: casual]\n[Trending in hair care: rice water]\n');
143
+ });
144
+
145
+ test('appends a memory block when relevant memories are found', async () => {
146
+ getUserPersona.mockResolvedValue(null);
147
+ getTrendContext.mockResolvedValue('');
148
+ getConversationDirective.mockReturnValue('');
149
+ retrieveRelevantMemories.mockResolvedValue(['prefers sulfate-free products', 'goal: length retention']);
150
+
151
+ const result = await buildMessageContext('u1', [{ role: 'user', content: 'what should I use?' }]);
152
+ expect(result).toContain('Relevant memory:');
153
+ expect(result).toContain('- prefers sulfate-free products');
154
+ expect(result).toContain('- goal: length retention');
155
+ });
156
+
157
+ test('passes currentMessage to retrieveRelevantMemories when provided', async () => {
158
+ getUserPersona.mockResolvedValue(null);
159
+ getTrendContext.mockResolvedValue('');
160
+ getConversationDirective.mockReturnValue('');
161
+ retrieveRelevantMemories.mockResolvedValue([]);
162
+
163
+ await buildMessageContext('u1', [], 'what oils should I use?');
164
+ expect(retrieveRelevantMemories).toHaveBeenCalledWith('u1', 'what oils should I use?');
165
+ });
166
+
167
+ test('falls back to the last user turn when currentMessage is omitted', async () => {
168
+ getUserPersona.mockResolvedValue(null);
169
+ getTrendContext.mockResolvedValue('');
170
+ getConversationDirective.mockReturnValue('');
171
+ retrieveRelevantMemories.mockResolvedValue([]);
172
+
173
+ const history = [
174
+ { role: 'assistant', content: 'hi there' },
175
+ { role: 'user', content: 'my hair is dry' },
176
+ ];
177
+ await buildMessageContext('u1', history);
178
+ expect(retrieveRelevantMemories).toHaveBeenCalledWith('u1', 'my hair is dry');
179
+ });
180
+
181
+ test('does not throw when memory retrieval fails — still returns other blocks', async () => {
182
+ const persona = { personaType: 'engaged', communicationStyle: 'casual', toneDirectives: [] };
183
+ getUserPersona.mockResolvedValue(persona);
184
+ buildContextBlock.mockReturnValue('[User persona: engaged, style: casual]\n');
185
+ getTrendContext.mockResolvedValue('');
186
+ getConversationDirective.mockReturnValue('');
187
+ retrieveRelevantMemories.mockRejectedValue(new Error('pgvector error'));
188
+
189
+ const result = await buildMessageContext('u1');
190
+ expect(result).toBe('[User persona: engaged, style: casual]\n');
191
+ });
192
+ });