@indigoai-us/hq-cli 5.68.2 → 5.70.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.
@@ -18,6 +18,7 @@ vi.mock("../utils/cognito-session.js", async (importOriginal) => {
18
18
  });
19
19
 
20
20
  import { Command } from "commander";
21
+ import chalk from "chalk";
21
22
  import {
22
23
  detectRecipient,
23
24
  parseDuration,
@@ -29,8 +30,18 @@ import {
29
30
  channelSlug,
30
31
  parseChannelName,
31
32
  matchChannelsByName,
33
+ senderLabel,
34
+ formatRelativeTime,
35
+ isUnread,
36
+ unreadEventIds,
37
+ filterUnread,
38
+ firstLine,
39
+ buildThreadQuery,
40
+ formatInboxEvent,
41
+ formatThreadMessage,
32
42
  type ConnectionRequest,
33
43
  type ChannelSummary,
44
+ type DmInboxEvent,
34
45
  } from "./dm.js";
35
46
 
36
47
  describe("parseGroupRecipients", () => {
@@ -262,6 +273,127 @@ describe("buildConnectionActionBody", () => {
262
273
  // Action handlers (HTTP/auth mocked)
263
274
  // ---------------------------------------------------------------------------
264
275
 
276
+ describe("senderLabel", () => {
277
+ it("prefers display name, then email, then uid", () => {
278
+ expect(
279
+ senderLabel({ fromDisplayName: "Jonathan", fromEmail: "j@x.com", fromPersonUid: "prs_1" }),
280
+ ).toBe("Jonathan");
281
+ expect(senderLabel({ fromEmail: "j@x.com", fromPersonUid: "prs_1" })).toBe("j@x.com");
282
+ expect(senderLabel({ fromPersonUid: "prs_1" })).toBe("prs_1");
283
+ expect(senderLabel({})).toBe("unknown");
284
+ });
285
+ });
286
+
287
+ describe("formatRelativeTime", () => {
288
+ const now = Date.parse("2026-07-16T12:00:00.000Z");
289
+ it("renders sub-minute as 'just now'", () => {
290
+ expect(formatRelativeTime("2026-07-16T11:59:30.000Z", now)).toBe("just now");
291
+ });
292
+ it("renders minutes, hours, and days", () => {
293
+ expect(formatRelativeTime("2026-07-16T11:57:00.000Z", now)).toBe("3m ago");
294
+ expect(formatRelativeTime("2026-07-16T10:00:00.000Z", now)).toBe("2h ago");
295
+ expect(formatRelativeTime("2026-07-11T12:00:00.000Z", now)).toBe("5d ago");
296
+ });
297
+ it("falls back to a date for anything older than a week", () => {
298
+ expect(formatRelativeTime("2026-06-01T09:00:00.000Z", now)).toBe("2026-06-01");
299
+ });
300
+ it("echoes an unparseable input", () => {
301
+ expect(formatRelativeTime("not-a-date", now)).toBe("not-a-date");
302
+ });
303
+ });
304
+
305
+ describe("isUnread / unreadEventIds / filterUnread", () => {
306
+ const events: DmInboxEvent[] = [
307
+ { eventId: "a", body: "x", createdAt: "2026-07-16T11:00:00.000Z" },
308
+ {
309
+ eventId: "b",
310
+ body: "y",
311
+ createdAt: "2026-07-16T11:30:00.000Z",
312
+ acknowledgedAt: "2026-07-16T11:31:00.000Z",
313
+ },
314
+ ];
315
+ it("treats a message with no acknowledgedAt as unread", () => {
316
+ expect(isUnread(events[0])).toBe(true);
317
+ expect(isUnread(events[1])).toBe(false);
318
+ });
319
+ it("collects unread event ids", () => {
320
+ expect(unreadEventIds(events)).toEqual(["a"]);
321
+ });
322
+ it("filters to unread only", () => {
323
+ expect(filterUnread(events).map((e) => e.eventId)).toEqual(["a"]);
324
+ });
325
+ });
326
+
327
+ describe("firstLine", () => {
328
+ it("collapses whitespace to a single trimmed line", () => {
329
+ expect(firstLine(" hello\n world \t there ")).toBe("hello world there");
330
+ });
331
+ it("truncates with an ellipsis past the cap", () => {
332
+ const out = firstLine("abcdefghij", 5);
333
+ expect(out).toBe("abcd…");
334
+ expect(out.length).toBe(5);
335
+ });
336
+ });
337
+
338
+ describe("buildThreadQuery", () => {
339
+ it("routes an email through withEmail (lowercased)", () => {
340
+ expect(buildThreadQuery("Jonathan@GetIndigo.ai")).toEqual({
341
+ withEmail: "jonathan@getindigo.ai",
342
+ });
343
+ });
344
+ it("routes a personUid / agentUid through withPersonUid", () => {
345
+ expect(buildThreadQuery("prs_123")).toEqual({ withPersonUid: "prs_123" });
346
+ expect(buildThreadQuery("agt_123")).toEqual({ withPersonUid: "agt_123" });
347
+ });
348
+ it("rejects a bare name", () => {
349
+ expect(() => buildThreadQuery("Jonathan")).toThrow(/Resolve a name first/);
350
+ });
351
+ });
352
+
353
+ describe("format helpers (rendering)", () => {
354
+ beforeEach(() => {
355
+ chalk.level = 0; // plain text so assertions read the rendered content
356
+ });
357
+ const now = Date.parse("2026-07-16T12:00:00.000Z");
358
+ it("marks an unread inbox row with a bullet and shows the sender + body", () => {
359
+ const row = formatInboxEvent(
360
+ {
361
+ eventId: "e1",
362
+ fromDisplayName: "Jonathan Bach",
363
+ fromEmail: "jonathan@getindigo.ai",
364
+ body: "grp_jb_agent is created",
365
+ createdAt: "2026-07-16T11:57:00.000Z",
366
+ },
367
+ now,
368
+ );
369
+ expect(row).toContain("●");
370
+ expect(row).toContain("Jonathan Bach");
371
+ expect(row).toContain("<jonathan@getindigo.ai>");
372
+ expect(row).toContain("3m ago");
373
+ expect(row).toContain("grp_jb_agent is created");
374
+ });
375
+ it("tags thread direction: outgoing as 'you', incoming by sender", () => {
376
+ const out = formatThreadMessage(
377
+ { eventId: "o", body: "hey", createdAt: "2026-07-16T11:00:00.000Z", direction: "out" },
378
+ now,
379
+ );
380
+ expect(out).toContain("→");
381
+ expect(out).toContain("you");
382
+ const inbound = formatThreadMessage(
383
+ {
384
+ eventId: "i",
385
+ fromDisplayName: "Jonathan",
386
+ body: "thanks",
387
+ createdAt: "2026-07-16T11:30:00.000Z",
388
+ direction: "in",
389
+ },
390
+ now,
391
+ );
392
+ expect(inbound).toContain("←");
393
+ expect(inbound).toContain("Jonathan");
394
+ });
395
+ });
396
+
265
397
  function jsonResponse(status: number, body: unknown): Response {
266
398
  return new Response(JSON.stringify(body), {
267
399
  status,
@@ -480,4 +612,137 @@ describe("dm command actions", () => {
480
612
  expect(body).toEqual({ withEmail: "spammer@example.com" });
481
613
  expect(logged()).toMatch(/Blocked — spammer@example\.com\./);
482
614
  });
615
+
616
+ // ---- receive side: inbox / thread / channel ----
617
+
618
+ it("inbox: lists incoming messages from GET /v1/notify/inbox", async () => {
619
+ fetchSpy.mockResolvedValueOnce(
620
+ jsonResponse(200, {
621
+ events: [
622
+ {
623
+ eventId: "e1",
624
+ fromDisplayName: "Jonathan Bach",
625
+ fromEmail: "jonathan@getindigo.ai",
626
+ body: "grp_jb_agent is created",
627
+ createdAt: "2026-07-16T11:57:00.000Z",
628
+ },
629
+ ],
630
+ }),
631
+ );
632
+ await program.parseAsync(["dm", "inbox"], { from: "user" });
633
+ expect(String(fetchSpy.mock.calls[0][0])).toContain("/v1/notify/inbox");
634
+ expect(logged()).toContain("Jonathan Bach");
635
+ expect(logged()).toContain("grp_jb_agent is created");
636
+ expect(errSpy).not.toHaveBeenCalled();
637
+ });
638
+
639
+ it("inbox --unread --mark-read: acks only the unread ids", async () => {
640
+ fetchSpy
641
+ .mockResolvedValueOnce(
642
+ jsonResponse(200, {
643
+ events: [
644
+ { eventId: "u1", body: "new", createdAt: "2026-07-16T11:00:00.000Z" },
645
+ {
646
+ eventId: "r1",
647
+ body: "old",
648
+ createdAt: "2026-07-16T10:00:00.000Z",
649
+ acknowledgedAt: "2026-07-16T10:01:00.000Z",
650
+ },
651
+ ],
652
+ }),
653
+ )
654
+ .mockResolvedValueOnce(jsonResponse(200, { acknowledged: 1 }));
655
+ await program.parseAsync(["dm", "inbox", "--unread", "--mark-read"], {
656
+ from: "user",
657
+ });
658
+ const ackCall = fetchSpy.mock.calls.find((c) =>
659
+ String(c[0]).includes("/v1/notify/inbox/ack"),
660
+ );
661
+ expect(ackCall).toBeTruthy();
662
+ expect(JSON.parse((ackCall![1]?.body as string) ?? "{}")).toEqual({
663
+ eventIds: ["u1"],
664
+ });
665
+ });
666
+
667
+ it("thread <email>: reads via withEmail and acks incoming messages", async () => {
668
+ fetchSpy
669
+ .mockResolvedValueOnce(
670
+ jsonResponse(200, {
671
+ messages: [
672
+ {
673
+ eventId: "in1",
674
+ fromDisplayName: "Jonathan",
675
+ body: "thanks!",
676
+ createdAt: "2026-07-16T11:30:00.000Z",
677
+ direction: "in",
678
+ },
679
+ {
680
+ eventId: "out1",
681
+ body: "done",
682
+ createdAt: "2026-07-16T11:00:00.000Z",
683
+ direction: "out",
684
+ },
685
+ ],
686
+ }),
687
+ )
688
+ .mockResolvedValueOnce(jsonResponse(200, { acknowledged: 1 }));
689
+ await program.parseAsync(["dm", "thread", "jonathan@getindigo.ai"], {
690
+ from: "user",
691
+ });
692
+ expect(String(fetchSpy.mock.calls[0][0])).toContain("/v1/notify/thread");
693
+ expect(String(fetchSpy.mock.calls[0][0])).toContain("withEmail=");
694
+ const ackCall = fetchSpy.mock.calls.find((c) =>
695
+ String(c[0]).includes("/v1/notify/inbox/ack"),
696
+ );
697
+ expect(ackCall).toBeTruthy();
698
+ expect(JSON.parse((ackCall![1]?.body as string) ?? "{}")).toEqual({
699
+ eventIds: ["in1"],
700
+ });
701
+ expect(logged()).toContain("thanks!");
702
+ });
703
+
704
+ it("thread --no-ack: reads without marking anything read", async () => {
705
+ fetchSpy.mockResolvedValueOnce(
706
+ jsonResponse(200, {
707
+ messages: [
708
+ {
709
+ eventId: "in1",
710
+ body: "hi",
711
+ createdAt: "2026-07-16T11:30:00.000Z",
712
+ direction: "in",
713
+ },
714
+ ],
715
+ }),
716
+ );
717
+ await program.parseAsync(["dm", "thread", "prs_x", "--no-ack"], {
718
+ from: "user",
719
+ });
720
+ expect(
721
+ fetchSpy.mock.calls.some((c) => String(c[0]).includes("/inbox/ack")),
722
+ ).toBe(false);
723
+ expect(String(fetchSpy.mock.calls[0][0])).toContain("withPersonUid=prs_x");
724
+ });
725
+
726
+ it("channel <name>: resolves the channel then reads its messages", async () => {
727
+ fetchSpy
728
+ .mockResolvedValueOnce(jsonResponse(200, channelsPayload))
729
+ .mockResolvedValueOnce(
730
+ jsonResponse(200, {
731
+ messages: [
732
+ {
733
+ eventId: "m1",
734
+ fromDisplayName: "Stefan",
735
+ body: "ship it",
736
+ createdAt: "2026-07-16T11:00:00.000Z",
737
+ },
738
+ ],
739
+ }),
740
+ );
741
+ await program.parseAsync(["dm", "channel", "vyg-dev"], { from: "user" });
742
+ expect(String(fetchSpy.mock.calls[0][0])).toContain("/v1/notify/channels");
743
+ expect(String(fetchSpy.mock.calls[1][0])).toContain(
744
+ "/v1/notify/channels/chn_v/messages",
745
+ );
746
+ expect(logged()).toContain("ship it");
747
+ });
483
748
  });