parser.test.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. import { describe, expect, test } from 'bun:test';
  2. import {
  3. buildFallbackState,
  4. findLatestAssistantState,
  5. flattenMessage,
  6. parseAssistantState,
  7. } from './parser';
  8. describe('parseAssistantState', () => {
  9. test('parses valid interview state with questions', () => {
  10. const text =
  11. 'Here are questions.\n<interview_state>\n{"summary":"Test app","questions":[{"id":"q-1","question":"Platform?","options":["Web","Mobile"],"suggested":"Web"}]}\n</interview_state>';
  12. const result = parseAssistantState(text, 2);
  13. expect(result.state).not.toBeNull();
  14. expect(result.state?.summary).toBe('Test app');
  15. expect(result.state?.questions).toHaveLength(1);
  16. expect(result.state?.questions[0].id).toBe('q-1');
  17. expect(result.state?.questions[0].question).toBe('Platform?');
  18. expect(result.state?.questions[0].options).toEqual(['Web', 'Mobile']);
  19. expect(result.state?.questions[0].suggested).toBe('Web');
  20. });
  21. test('parses state with title field', () => {
  22. const text =
  23. '<interview_state>\n{"summary":"Building X","title":"my-project","questions":[]}\n</interview_state>';
  24. const result = parseAssistantState(text);
  25. expect(result.state).not.toBeNull();
  26. expect(result.state?.title).toBe('my-project');
  27. });
  28. test('returns null when no interview_state block', () => {
  29. const result = parseAssistantState('No state block here.');
  30. expect(result.state).toBeNull();
  31. expect(result.error).toBeUndefined();
  32. });
  33. test('returns error for invalid JSON inside block', () => {
  34. const text = '<interview_state>\n{not valid json}\n</interview_state>';
  35. const result = parseAssistantState(text);
  36. expect(result.state).toBeNull();
  37. expect(result.error).toBeDefined();
  38. });
  39. test('handles malformed question objects gracefully', () => {
  40. const text =
  41. '<interview_state>\n{"summary":"Test","questions":[null,123,"string",{"question":"Valid?"}]}\n</interview_state>';
  42. const result = parseAssistantState(text, 5);
  43. expect(result.state).not.toBeNull();
  44. // Only the valid question should survive Zod validation
  45. expect(result.state?.questions).toHaveLength(1);
  46. expect(result.state?.questions[0].question).toBe('Valid?');
  47. });
  48. test('respects maxQuestions limit', () => {
  49. const text =
  50. '<interview_state>\n{"summary":"Test","questions":[{"id":"q-1","question":"Q1?"},{"id":"q-2","question":"Q2?"},{"id":"q-3","question":"Q3?"},{"id":"q-4","question":"Q4?"}]}\n</interview_state>';
  51. const result = parseAssistantState(text, 2);
  52. expect(result.state?.questions).toHaveLength(2);
  53. });
  54. test('handles empty questions array', () => {
  55. const text =
  56. '<interview_state>\n{"summary":"Waiting","questions":[]}\n</interview_state>';
  57. const result = parseAssistantState(text);
  58. expect(result.state).not.toBeNull();
  59. expect(result.state?.questions).toHaveLength(0);
  60. expect(result.state?.summary).toBe('Waiting');
  61. });
  62. test('strips whitespace from question text', () => {
  63. const text =
  64. '<interview_state>\n{"summary":"Test","questions":[{"question":" Spaced question ","options":[" A "," B "]}]}\n</interview_state>';
  65. const result = parseAssistantState(text);
  66. expect(result.state?.questions[0].question).toBe('Spaced question');
  67. expect(result.state?.questions[0].options).toEqual(['A', 'B']);
  68. });
  69. test('generates fallback ID when question has no id', () => {
  70. const text =
  71. '<interview_state>\n{"summary":"Test","questions":[{"question":"No ID?"}]}\n</interview_state>';
  72. const result = parseAssistantState(text);
  73. expect(result.state?.questions[0].id).toBe('q-1');
  74. });
  75. test('trims options to max 4', () => {
  76. const text =
  77. '<interview_state>\n{"summary":"Test","questions":[{"question":"Q?","options":["A","B","C","D","E","F"]}]}\n</interview_state>';
  78. const result = parseAssistantState(text);
  79. expect(result.state?.questions[0].options).toHaveLength(4);
  80. });
  81. test('filters out non-string options', () => {
  82. const text =
  83. '<interview_state>\n{"summary":"Test","questions":[{"question":"Q?","options":["A",42,true,null,"B"]}]}\n</interview_state>';
  84. const result = parseAssistantState(text);
  85. expect(result.state?.questions[0].options).toEqual(['A', 'B']);
  86. });
  87. test('handles non-string summary gracefully', () => {
  88. const text =
  89. '<interview_state>\n{"summary":123,"questions":[]}\n</interview_state>';
  90. const result = parseAssistantState(text);
  91. expect(result.state).not.toBeNull();
  92. expect(result.state?.summary).toBe('');
  93. });
  94. test('handles non-string title gracefully', () => {
  95. const text =
  96. '<interview_state>\n{"summary":"Test","title":456,"questions":[]}\n</interview_state>';
  97. const result = parseAssistantState(text);
  98. expect(result.state).not.toBeNull();
  99. expect(result.state?.title).toBeUndefined();
  100. });
  101. test('handles missing questions field', () => {
  102. const text =
  103. '<interview_state>\n{"summary":"No questions field"}\n</interview_state>';
  104. const result = parseAssistantState(text);
  105. expect(result.state).not.toBeNull();
  106. expect(result.state?.questions).toHaveLength(0);
  107. });
  108. test('handles question with empty question text', () => {
  109. const text =
  110. '<interview_state>\n{"summary":"Test","questions":[{"question":" "}]}\n</interview_state>';
  111. const result = parseAssistantState(text);
  112. expect(result.state?.questions).toHaveLength(0);
  113. });
  114. test('is case-insensitive for interview_state tag', () => {
  115. const text =
  116. '<INTERVIEW_STATE>\n{"summary":"Upper","questions":[]}\n</INTERVIEW_STATE>';
  117. const result = parseAssistantState(text);
  118. expect(result.state).not.toBeNull();
  119. expect(result.state?.summary).toBe('Upper');
  120. });
  121. test('handles deeply nested unexpected objects', () => {
  122. const text =
  123. '<interview_state>\n{"summary":"Test","questions":[{"question":"Q?","options":[{"nested":true}]}]}\n</interview_state>';
  124. const result = parseAssistantState(text);
  125. expect(result.state?.questions[0].options).toEqual([]);
  126. });
  127. test('handles extremely large input gracefully', () => {
  128. const questions = Array.from({ length: 100 }, (_, i) => ({
  129. id: `q-${i}`,
  130. question: `Question ${i}?`,
  131. }));
  132. const text = `<interview_state>\n${JSON.stringify({ summary: 'Large', questions })}\n</interview_state>`;
  133. const result = parseAssistantState(text, 5);
  134. expect(result.state?.questions).toHaveLength(5);
  135. });
  136. });
  137. describe('flattenMessage', () => {
  138. test('joins text parts with newline', () => {
  139. const message = {
  140. parts: [
  141. { type: 'text', text: 'Hello' },
  142. { type: 'text', text: 'World' },
  143. ],
  144. };
  145. expect(flattenMessage(message as any)).toBe('Hello\nWorld');
  146. });
  147. test('handles missing parts', () => {
  148. expect(flattenMessage({} as any)).toBe('');
  149. });
  150. test('handles parts without text', () => {
  151. const message = {
  152. parts: [{ type: 'image' }, { type: 'text', text: 'only this' }],
  153. };
  154. expect(flattenMessage(message as any)).toBe('only this');
  155. });
  156. });
  157. describe('buildFallbackState', () => {
  158. test('returns waiting message for no answers', () => {
  159. const state = buildFallbackState([]);
  160. expect(state.summary).toContain('Waiting');
  161. expect(state.questions).toHaveLength(0);
  162. });
  163. test('returns in-progress message when answers exist', () => {
  164. const messages = [
  165. { info: { role: 'user' } },
  166. { info: { role: 'assistant' } },
  167. ];
  168. const state = buildFallbackState(messages as any);
  169. expect(state.summary).toContain('in progress');
  170. });
  171. });
  172. describe('findLatestAssistantState', () => {
  173. test('finds state in last assistant message', () => {
  174. const messages = [
  175. {
  176. info: { role: 'assistant' },
  177. parts: [
  178. {
  179. type: 'text',
  180. text: '<interview_state>\n{"summary":"First","questions":[]}\n</interview_state>',
  181. },
  182. ],
  183. },
  184. { info: { role: 'user' }, parts: [{ type: 'text', text: 'reply' }] },
  185. {
  186. info: { role: 'assistant' },
  187. parts: [
  188. {
  189. type: 'text',
  190. text: '<interview_state>\n{"summary":"Latest","questions":[]}\n</interview_state>',
  191. },
  192. ],
  193. },
  194. ];
  195. const result = findLatestAssistantState(messages as any);
  196. expect(result.state).not.toBeNull();
  197. expect(result.state?.summary).toBe('Latest');
  198. });
  199. test('returns null when no assistant messages', () => {
  200. const messages = [
  201. { info: { role: 'user' }, parts: [{ type: 'text', text: 'hello' }] },
  202. ];
  203. const result = findLatestAssistantState(messages as any);
  204. expect(result.state).toBeNull();
  205. });
  206. test('captures parse error from earlier messages', () => {
  207. const messages = [
  208. {
  209. info: { role: 'assistant' },
  210. parts: [
  211. {
  212. type: 'text',
  213. text: '<interview_state>\n{bad json}\n</interview_state>',
  214. },
  215. ],
  216. },
  217. {
  218. info: { role: 'assistant' },
  219. parts: [
  220. {
  221. type: 'text',
  222. text: '<interview_state>\n{"summary":"Recovery","questions":[]}\n</interview_state>',
  223. },
  224. ],
  225. },
  226. ];
  227. const result = findLatestAssistantState(messages as any);
  228. expect(result.state).not.toBeNull();
  229. expect(result.state?.summary).toBe('Recovery');
  230. });
  231. test('returns latest error when no valid state found', () => {
  232. const messages = [
  233. {
  234. info: { role: 'assistant' },
  235. parts: [{ type: 'text', text: 'No state block here.' }],
  236. },
  237. ];
  238. const result = findLatestAssistantState(messages as any);
  239. expect(result.state).toBeNull();
  240. expect(result.latestAssistantError).toContain('Missing');
  241. });
  242. });