messages.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. import { describe, expect, test } from "bun:test";
  2. import {
  3. deriveShortMessageId,
  4. INTERRUPT_MESSAGE,
  5. INTERRUPT_MESSAGE_FOR_TOOL_USE,
  6. CANCEL_MESSAGE,
  7. REJECT_MESSAGE,
  8. NO_RESPONSE_REQUESTED,
  9. SYNTHETIC_MESSAGES,
  10. isSyntheticMessage,
  11. getLastAssistantMessage,
  12. hasToolCallsInLastAssistantTurn,
  13. createAssistantMessage,
  14. createAssistantAPIErrorMessage,
  15. createUserMessage,
  16. createUserInterruptionMessage,
  17. prepareUserContent,
  18. createToolResultStopMessage,
  19. extractTag,
  20. isNotEmptyMessage,
  21. deriveUUID,
  22. normalizeMessages,
  23. isClassifierDenial,
  24. buildYoloRejectionMessage,
  25. buildClassifierUnavailableMessage,
  26. AUTO_REJECT_MESSAGE,
  27. DONT_ASK_REJECT_MESSAGE,
  28. SYNTHETIC_MODEL,
  29. } from "../messages";
  30. import type { Message, AssistantMessage, UserMessage } from "../../types/message";
  31. // ─── Helpers ─────────────────────────────────────────────────────────────
  32. function makeAssistantMsg(
  33. contentBlocks: Array<{ type: string; text?: string; [key: string]: any }>
  34. ): AssistantMessage {
  35. return createAssistantMessage({
  36. content: contentBlocks as any,
  37. });
  38. }
  39. function makeUserMsg(text: string): UserMessage {
  40. return createUserMessage({ content: text });
  41. }
  42. // ─── deriveShortMessageId ───────────────────────────────────────────────
  43. describe("deriveShortMessageId", () => {
  44. test("returns 6-char string", () => {
  45. const id = deriveShortMessageId("550e8400-e29b-41d4-a716-446655440000");
  46. expect(id).toHaveLength(6);
  47. });
  48. test("is deterministic for same input", () => {
  49. const uuid = "a0b1c2d3-e4f5-6789-abcd-ef0123456789";
  50. expect(deriveShortMessageId(uuid)).toBe(deriveShortMessageId(uuid));
  51. });
  52. test("produces different IDs for different UUIDs", () => {
  53. const id1 = deriveShortMessageId("00000000-0000-0000-0000-000000000001");
  54. const id2 = deriveShortMessageId("ffffffff-ffff-ffff-ffff-ffffffffffff");
  55. expect(id1).not.toBe(id2);
  56. });
  57. });
  58. // ─── Constants ──────────────────────────────────────────────────────────
  59. describe("message constants", () => {
  60. test("SYNTHETIC_MESSAGES contains expected messages", () => {
  61. expect(SYNTHETIC_MESSAGES.has(INTERRUPT_MESSAGE)).toBe(true);
  62. expect(SYNTHETIC_MESSAGES.has(INTERRUPT_MESSAGE_FOR_TOOL_USE)).toBe(true);
  63. expect(SYNTHETIC_MESSAGES.has(CANCEL_MESSAGE)).toBe(true);
  64. expect(SYNTHETIC_MESSAGES.has(REJECT_MESSAGE)).toBe(true);
  65. expect(SYNTHETIC_MESSAGES.has(NO_RESPONSE_REQUESTED)).toBe(true);
  66. });
  67. test("SYNTHETIC_MODEL is <synthetic>", () => {
  68. expect(SYNTHETIC_MODEL).toBe("<synthetic>");
  69. });
  70. });
  71. // ─── Message factories ──────────────────────────────────────────────────
  72. describe("createAssistantMessage", () => {
  73. test("creates assistant message with string content", () => {
  74. const msg = createAssistantMessage({ content: "hello" });
  75. expect(msg.type).toBe("assistant");
  76. expect(msg.message.role).toBe("assistant");
  77. expect(msg.message.content).toHaveLength(1);
  78. expect((msg.message.content[0] as any).text).toBe("hello");
  79. });
  80. test("creates assistant message with content blocks", () => {
  81. const blocks = [{ type: "text" as const, text: "hello" }];
  82. const msg = createAssistantMessage({ content: blocks as any });
  83. expect(msg.type).toBe("assistant");
  84. expect(msg.message.content).toHaveLength(1);
  85. });
  86. test("generates unique uuid per call", () => {
  87. const msg1 = createAssistantMessage({ content: "a" });
  88. const msg2 = createAssistantMessage({ content: "b" });
  89. expect(msg1.uuid).not.toBe(msg2.uuid);
  90. });
  91. test("has isApiErrorMessage false", () => {
  92. const msg = createAssistantMessage({ content: "test" });
  93. expect(msg.isApiErrorMessage).toBe(false);
  94. });
  95. });
  96. describe("createAssistantAPIErrorMessage", () => {
  97. test("sets isApiErrorMessage to true", () => {
  98. const msg = createAssistantAPIErrorMessage({ content: "error" });
  99. expect(msg.isApiErrorMessage).toBe(true);
  100. });
  101. test("includes error details", () => {
  102. const msg = createAssistantAPIErrorMessage({
  103. content: "fail",
  104. errorDetails: "rate limited",
  105. });
  106. expect(msg.errorDetails).toBe("rate limited");
  107. });
  108. });
  109. describe("createUserMessage", () => {
  110. test("creates user message with string content", () => {
  111. const msg = createUserMessage({ content: "hello" });
  112. expect(msg.type).toBe("user");
  113. expect(msg.message.role).toBe("user");
  114. expect(msg.message.content).toBe("hello");
  115. });
  116. test("generates unique uuid", () => {
  117. const msg1 = createUserMessage({ content: "a" });
  118. const msg2 = createUserMessage({ content: "b" });
  119. expect(msg1.uuid).not.toBe(msg2.uuid);
  120. });
  121. test("uses provided uuid when given", () => {
  122. const msg = createUserMessage({
  123. content: "test",
  124. uuid: "custom-uuid-1234-5678-abcd-ef0123456789",
  125. });
  126. expect(msg.uuid).toBe("custom-uuid-1234-5678-abcd-ef0123456789");
  127. });
  128. test("sets isMeta flag", () => {
  129. const msg = createUserMessage({ content: "test", isMeta: true });
  130. expect(msg.isMeta).toBe(true);
  131. });
  132. });
  133. describe("createUserInterruptionMessage", () => {
  134. test("creates interrupt message without tool use", () => {
  135. const msg = createUserInterruptionMessage({});
  136. expect(msg.type).toBe("user");
  137. expect((msg.message.content as any)[0].text).toBe(INTERRUPT_MESSAGE);
  138. });
  139. test("creates interrupt message with tool use", () => {
  140. const msg = createUserInterruptionMessage({ toolUse: true });
  141. expect((msg.message.content as any)[0].text).toBe(
  142. INTERRUPT_MESSAGE_FOR_TOOL_USE
  143. );
  144. });
  145. });
  146. describe("prepareUserContent", () => {
  147. test("returns string when no preceding blocks", () => {
  148. const result = prepareUserContent({
  149. inputString: "hello",
  150. precedingInputBlocks: [],
  151. });
  152. expect(result).toBe("hello");
  153. });
  154. test("returns array when preceding blocks exist", () => {
  155. const blocks = [{ type: "image" as const, source: {} } as any];
  156. const result = prepareUserContent({
  157. inputString: "describe this",
  158. precedingInputBlocks: blocks,
  159. });
  160. expect(Array.isArray(result)).toBe(true);
  161. expect((result as any[]).length).toBe(2);
  162. expect((result as any[])[1].text).toBe("describe this");
  163. });
  164. });
  165. describe("createToolResultStopMessage", () => {
  166. test("creates tool result with error flag", () => {
  167. const result = createToolResultStopMessage("tool-123");
  168. expect(result.type).toBe("tool_result");
  169. expect(result.is_error).toBe(true);
  170. expect(result.tool_use_id).toBe("tool-123");
  171. expect(result.content).toBe(CANCEL_MESSAGE);
  172. });
  173. });
  174. // ─── isSyntheticMessage ─────────────────────────────────────────────────
  175. describe("isSyntheticMessage", () => {
  176. test("identifies interrupt message as synthetic", () => {
  177. const msg: any = {
  178. type: "user",
  179. message: { content: [{ type: "text", text: INTERRUPT_MESSAGE }] },
  180. };
  181. expect(isSyntheticMessage(msg)).toBe(true);
  182. });
  183. test("identifies cancel message as synthetic", () => {
  184. const msg: any = {
  185. type: "user",
  186. message: { content: [{ type: "text", text: CANCEL_MESSAGE }] },
  187. };
  188. expect(isSyntheticMessage(msg)).toBe(true);
  189. });
  190. test("returns false for normal user message", () => {
  191. const msg: any = {
  192. type: "user",
  193. message: { content: [{ type: "text", text: "hello" }] },
  194. };
  195. expect(isSyntheticMessage(msg)).toBe(false);
  196. });
  197. test("returns false for progress message", () => {
  198. const msg: any = {
  199. type: "progress",
  200. message: { content: [{ type: "text", text: INTERRUPT_MESSAGE }] },
  201. };
  202. expect(isSyntheticMessage(msg)).toBe(false);
  203. });
  204. test("returns false for string content", () => {
  205. const msg: any = {
  206. type: "user",
  207. message: { content: INTERRUPT_MESSAGE },
  208. };
  209. expect(isSyntheticMessage(msg)).toBe(false);
  210. });
  211. });
  212. // ─── getLastAssistantMessage ────────────────────────────────────────────
  213. describe("getLastAssistantMessage", () => {
  214. test("returns last assistant message", () => {
  215. const a1 = makeAssistantMsg([{ type: "text", text: "first" }]);
  216. const u = makeUserMsg("mid");
  217. const a2 = makeAssistantMsg([{ type: "text", text: "last" }]);
  218. const result = getLastAssistantMessage([a1, u, a2]);
  219. expect(result).toBe(a2);
  220. });
  221. test("returns undefined for empty array", () => {
  222. expect(getLastAssistantMessage([])).toBeUndefined();
  223. });
  224. test("returns undefined when no assistant messages", () => {
  225. const u = makeUserMsg("hello");
  226. expect(getLastAssistantMessage([u])).toBeUndefined();
  227. });
  228. });
  229. // ─── hasToolCallsInLastAssistantTurn ────────────────────────────────────
  230. describe("hasToolCallsInLastAssistantTurn", () => {
  231. test("returns true when last assistant has tool_use", () => {
  232. const msg = makeAssistantMsg([
  233. { type: "text", text: "let me check" },
  234. { type: "tool_use", id: "t1", name: "Bash", input: {} },
  235. ]);
  236. expect(hasToolCallsInLastAssistantTurn([msg])).toBe(true);
  237. });
  238. test("returns false when last assistant has only text", () => {
  239. const msg = makeAssistantMsg([{ type: "text", text: "done" }]);
  240. expect(hasToolCallsInLastAssistantTurn([msg])).toBe(false);
  241. });
  242. test("returns false for empty messages", () => {
  243. expect(hasToolCallsInLastAssistantTurn([])).toBe(false);
  244. });
  245. });
  246. // ─── extractTag ─────────────────────────────────────────────────────────
  247. describe("extractTag", () => {
  248. test("extracts simple tag content", () => {
  249. expect(extractTag("<foo>bar</foo>", "foo")).toBe("bar");
  250. });
  251. test("extracts tag with attributes", () => {
  252. expect(extractTag('<foo class="a">bar</foo>', "foo")).toBe("bar");
  253. });
  254. test("handles multiline content", () => {
  255. expect(extractTag("<foo>\nline1\nline2\n</foo>", "foo")).toBe(
  256. "\nline1\nline2\n"
  257. );
  258. });
  259. test("returns null for missing tag", () => {
  260. expect(extractTag("<foo>bar</foo>", "baz")).toBeNull();
  261. });
  262. test("returns null for empty html", () => {
  263. expect(extractTag("", "foo")).toBeNull();
  264. });
  265. test("returns null for empty tagName", () => {
  266. expect(extractTag("<foo>bar</foo>", "")).toBeNull();
  267. });
  268. test("is case-insensitive", () => {
  269. expect(extractTag("<FOO>bar</FOO>", "foo")).toBe("bar");
  270. });
  271. });
  272. // ─── isNotEmptyMessage ──────────────────────────────────────────────────
  273. describe("isNotEmptyMessage", () => {
  274. test("returns true for message with text content", () => {
  275. const msg: any = {
  276. type: "user",
  277. message: { content: "hello" },
  278. };
  279. expect(isNotEmptyMessage(msg)).toBe(true);
  280. });
  281. test("returns false for empty string content", () => {
  282. const msg: any = {
  283. type: "user",
  284. message: { content: " " },
  285. };
  286. expect(isNotEmptyMessage(msg)).toBe(false);
  287. });
  288. test("returns false for empty content array", () => {
  289. const msg: any = {
  290. type: "user",
  291. message: { content: [] },
  292. };
  293. expect(isNotEmptyMessage(msg)).toBe(false);
  294. });
  295. test("returns true for progress message", () => {
  296. const msg: any = {
  297. type: "progress",
  298. message: { content: [] },
  299. };
  300. expect(isNotEmptyMessage(msg)).toBe(true);
  301. });
  302. test("returns true for multi-block content", () => {
  303. const msg: any = {
  304. type: "user",
  305. message: {
  306. content: [
  307. { type: "text", text: "a" },
  308. { type: "text", text: "b" },
  309. ],
  310. },
  311. };
  312. expect(isNotEmptyMessage(msg)).toBe(true);
  313. });
  314. test("returns true for non-text block", () => {
  315. const msg: any = {
  316. type: "user",
  317. message: {
  318. content: [{ type: "tool_use", id: "t1", name: "Bash", input: {} }],
  319. },
  320. };
  321. expect(isNotEmptyMessage(msg)).toBe(true);
  322. });
  323. });
  324. // ─── deriveUUID ─────────────────────────────────────────────────────────
  325. describe("deriveUUID", () => {
  326. test("produces deterministic output", () => {
  327. const parent = "550e8400-e29b-41d4-a716-446655440000" as any;
  328. expect(deriveUUID(parent, 0)).toBe(deriveUUID(parent, 0));
  329. });
  330. test("produces different output for different indices", () => {
  331. const parent = "550e8400-e29b-41d4-a716-446655440000" as any;
  332. expect(deriveUUID(parent, 0)).not.toBe(deriveUUID(parent, 1));
  333. });
  334. test("preserves UUID-like length", () => {
  335. const parent = "550e8400-e29b-41d4-a716-446655440000" as any;
  336. const derived = deriveUUID(parent, 5);
  337. expect(derived.length).toBe(parent.length);
  338. });
  339. });
  340. // ─── isClassifierDenial ─────────────────────────────────────────────────
  341. describe("isClassifierDenial", () => {
  342. test("returns true for classifier denial prefix", () => {
  343. expect(
  344. isClassifierDenial(
  345. "Permission for this action has been denied. Reason: unsafe"
  346. )
  347. ).toBe(true);
  348. });
  349. test("returns false for normal content", () => {
  350. expect(isClassifierDenial("hello world")).toBe(false);
  351. });
  352. });
  353. // ─── Message builder functions ──────────────────────────────────────────
  354. describe("AUTO_REJECT_MESSAGE", () => {
  355. test("includes tool name", () => {
  356. const msg = AUTO_REJECT_MESSAGE("Bash");
  357. expect(msg).toContain("Bash");
  358. expect(msg).toContain("denied");
  359. });
  360. });
  361. describe("DONT_ASK_REJECT_MESSAGE", () => {
  362. test("includes tool name and dont ask mode", () => {
  363. const msg = DONT_ASK_REJECT_MESSAGE("Write");
  364. expect(msg).toContain("Write");
  365. expect(msg).toContain("don't ask mode");
  366. });
  367. });
  368. describe("buildYoloRejectionMessage", () => {
  369. test("includes reason", () => {
  370. const msg = buildYoloRejectionMessage("potentially destructive");
  371. expect(msg).toContain("potentially destructive");
  372. expect(msg).toContain("denied");
  373. });
  374. });
  375. describe("buildClassifierUnavailableMessage", () => {
  376. test("includes tool name and model", () => {
  377. const msg = buildClassifierUnavailableMessage("Bash", "classifier-v1");
  378. expect(msg).toContain("Bash");
  379. expect(msg).toContain("classifier-v1");
  380. expect(msg).toContain("unavailable");
  381. });
  382. });
  383. // ─── normalizeMessages ──────────────────────────────────────────────────
  384. describe("normalizeMessages", () => {
  385. test("splits multi-block assistant message into individual messages", () => {
  386. const msg = makeAssistantMsg([
  387. { type: "text", text: "first" },
  388. { type: "text", text: "second" },
  389. ]);
  390. const normalized = normalizeMessages([msg]);
  391. expect(normalized.length).toBe(2);
  392. });
  393. test("handles empty array", () => {
  394. const result = normalizeMessages([] as AssistantMessage[]);
  395. expect(result).toEqual([]);
  396. });
  397. test("preserves single-block message", () => {
  398. const msg = makeAssistantMsg([{ type: "text", text: "hello" }]);
  399. const normalized = normalizeMessages([msg]);
  400. expect(normalized.length).toBe(1);
  401. });
  402. });