@copilotkit/channels-slack 0.1.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.
Files changed (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +353 -0
  3. package/dist/__tests__/assistant.test.d.ts +2 -0
  4. package/dist/__tests__/assistant.test.d.ts.map +1 -0
  5. package/dist/__tests__/assistant.test.js +153 -0
  6. package/dist/__tests__/auto-close-streaming.test.d.ts +2 -0
  7. package/dist/__tests__/auto-close-streaming.test.d.ts.map +1 -0
  8. package/dist/__tests__/auto-close-streaming.test.js +139 -0
  9. package/dist/__tests__/built-ins.test.d.ts +2 -0
  10. package/dist/__tests__/built-ins.test.d.ts.map +1 -0
  11. package/dist/__tests__/built-ins.test.js +67 -0
  12. package/dist/__tests__/chunked-message-stream.test.d.ts +2 -0
  13. package/dist/__tests__/chunked-message-stream.test.d.ts.map +1 -0
  14. package/dist/__tests__/chunked-message-stream.test.js +226 -0
  15. package/dist/__tests__/conversation-store.test.d.ts +2 -0
  16. package/dist/__tests__/conversation-store.test.d.ts.map +1 -0
  17. package/dist/__tests__/conversation-store.test.js +209 -0
  18. package/dist/__tests__/download-files.test.d.ts +2 -0
  19. package/dist/__tests__/download-files.test.d.ts.map +1 -0
  20. package/dist/__tests__/download-files.test.js +142 -0
  21. package/dist/__tests__/ephemeral.test.d.ts +2 -0
  22. package/dist/__tests__/ephemeral.test.d.ts.map +1 -0
  23. package/dist/__tests__/ephemeral.test.js +85 -0
  24. package/dist/__tests__/event-renderer.test.d.ts +2 -0
  25. package/dist/__tests__/event-renderer.test.d.ts.map +1 -0
  26. package/dist/__tests__/event-renderer.test.js +521 -0
  27. package/dist/__tests__/markdown-to-mrkdwn.test.d.ts +2 -0
  28. package/dist/__tests__/markdown-to-mrkdwn.test.d.ts.map +1 -0
  29. package/dist/__tests__/markdown-to-mrkdwn.test.js +80 -0
  30. package/dist/__tests__/message-stream.test.d.ts +2 -0
  31. package/dist/__tests__/message-stream.test.d.ts.map +1 -0
  32. package/dist/__tests__/message-stream.test.js +116 -0
  33. package/dist/__tests__/native-renderer.test.d.ts +2 -0
  34. package/dist/__tests__/native-renderer.test.d.ts.map +1 -0
  35. package/dist/__tests__/native-renderer.test.js +236 -0
  36. package/dist/__tests__/native-stream.test.d.ts +2 -0
  37. package/dist/__tests__/native-stream.test.d.ts.map +1 -0
  38. package/dist/__tests__/native-stream.test.js +235 -0
  39. package/dist/__tests__/reactions.test.d.ts +2 -0
  40. package/dist/__tests__/reactions.test.d.ts.map +1 -0
  41. package/dist/__tests__/reactions.test.js +193 -0
  42. package/dist/__tests__/slack-listener.test.d.ts +2 -0
  43. package/dist/__tests__/slack-listener.test.d.ts.map +1 -0
  44. package/dist/__tests__/slack-listener.test.js +559 -0
  45. package/dist/__tests__/views.test.d.ts +2 -0
  46. package/dist/__tests__/views.test.d.ts.map +1 -0
  47. package/dist/__tests__/views.test.js +143 -0
  48. package/dist/adapter.d.ts +188 -0
  49. package/dist/adapter.d.ts.map +1 -0
  50. package/dist/adapter.js +859 -0
  51. package/dist/adapter.test.d.ts +2 -0
  52. package/dist/adapter.test.d.ts.map +1 -0
  53. package/dist/adapter.test.js +368 -0
  54. package/dist/assistant.d.ts +19 -0
  55. package/dist/assistant.d.ts.map +1 -0
  56. package/dist/assistant.js +124 -0
  57. package/dist/auto-close-streaming.d.ts +62 -0
  58. package/dist/auto-close-streaming.d.ts.map +1 -0
  59. package/dist/auto-close-streaming.js +273 -0
  60. package/dist/built-in-context.d.ts +22 -0
  61. package/dist/built-in-context.d.ts.map +1 -0
  62. package/dist/built-in-context.js +65 -0
  63. package/dist/built-in-tools.d.ts +23 -0
  64. package/dist/built-in-tools.d.ts.map +1 -0
  65. package/dist/built-in-tools.js +45 -0
  66. package/dist/chunked-message-stream.d.ts +67 -0
  67. package/dist/chunked-message-stream.d.ts.map +1 -0
  68. package/dist/chunked-message-stream.js +134 -0
  69. package/dist/codec.d.ts +18 -0
  70. package/dist/codec.d.ts.map +1 -0
  71. package/dist/codec.js +18 -0
  72. package/dist/codec.test.d.ts +2 -0
  73. package/dist/codec.test.d.ts.map +1 -0
  74. package/dist/codec.test.js +19 -0
  75. package/dist/conversation-store.d.ts +92 -0
  76. package/dist/conversation-store.d.ts.map +1 -0
  77. package/dist/conversation-store.js +196 -0
  78. package/dist/download-files.d.ts +59 -0
  79. package/dist/download-files.d.ts.map +1 -0
  80. package/dist/download-files.js +116 -0
  81. package/dist/event-renderer.d.ts +98 -0
  82. package/dist/event-renderer.d.ts.map +1 -0
  83. package/dist/event-renderer.js +493 -0
  84. package/dist/index.d.ts +34 -0
  85. package/dist/index.d.ts.map +1 -0
  86. package/dist/index.js +23 -0
  87. package/dist/ingress-normalize.d.ts +75 -0
  88. package/dist/ingress-normalize.d.ts.map +1 -0
  89. package/dist/ingress-normalize.js +153 -0
  90. package/dist/ingress-normalize.test.d.ts +2 -0
  91. package/dist/ingress-normalize.test.d.ts.map +1 -0
  92. package/dist/ingress-normalize.test.js +187 -0
  93. package/dist/interaction.d.ts +30 -0
  94. package/dist/interaction.d.ts.map +1 -0
  95. package/dist/interaction.js +219 -0
  96. package/dist/interaction.test.d.ts +2 -0
  97. package/dist/interaction.test.d.ts.map +1 -0
  98. package/dist/interaction.test.js +137 -0
  99. package/dist/markdown-to-mrkdwn.d.ts +21 -0
  100. package/dist/markdown-to-mrkdwn.d.ts.map +1 -0
  101. package/dist/markdown-to-mrkdwn.js +99 -0
  102. package/dist/message-stream.d.ts +44 -0
  103. package/dist/message-stream.d.ts.map +1 -0
  104. package/dist/message-stream.js +70 -0
  105. package/dist/native-stream.d.ts +131 -0
  106. package/dist/native-stream.d.ts.map +1 -0
  107. package/dist/native-stream.js +208 -0
  108. package/dist/render/block-kit.d.ts +35 -0
  109. package/dist/render/block-kit.d.ts.map +1 -0
  110. package/dist/render/block-kit.js +399 -0
  111. package/dist/render/block-kit.test.d.ts +2 -0
  112. package/dist/render/block-kit.test.d.ts.map +1 -0
  113. package/dist/render/block-kit.test.js +289 -0
  114. package/dist/render/budget.d.ts +24 -0
  115. package/dist/render/budget.d.ts.map +1 -0
  116. package/dist/render/budget.js +30 -0
  117. package/dist/render/budget.test.d.ts +2 -0
  118. package/dist/render/budget.test.d.ts.map +1 -0
  119. package/dist/render/budget.test.js +49 -0
  120. package/dist/render/modal.d.ts +13 -0
  121. package/dist/render/modal.d.ts.map +1 -0
  122. package/dist/render/modal.js +108 -0
  123. package/dist/render/transport.d.ts +42 -0
  124. package/dist/render/transport.d.ts.map +1 -0
  125. package/dist/render/transport.js +1 -0
  126. package/dist/render.d.ts +23 -0
  127. package/dist/render.d.ts.map +1 -0
  128. package/dist/render.js +20 -0
  129. package/dist/sanitizing-http-agent.d.ts +28 -0
  130. package/dist/sanitizing-http-agent.d.ts.map +1 -0
  131. package/dist/sanitizing-http-agent.js +51 -0
  132. package/dist/slack-listener.d.ts +73 -0
  133. package/dist/slack-listener.d.ts.map +1 -0
  134. package/dist/slack-listener.js +126 -0
  135. package/dist/slack-listener.test.d.ts +2 -0
  136. package/dist/slack-listener.test.d.ts.map +1 -0
  137. package/dist/slack-listener.test.js +115 -0
  138. package/dist/types.d.ts +153 -0
  139. package/dist/types.d.ts.map +1 -0
  140. package/dist/types.js +20 -0
  141. package/package.json +73 -0
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { autoCloseOpenMarkdown } from "../auto-close-streaming.js";
3
+ describe("autoCloseOpenMarkdown", () => {
4
+ // ── No-op cases ──────────────────────────────────────────────────
5
+ it("returns empty string unchanged", () => {
6
+ expect(autoCloseOpenMarkdown("")).toBe("");
7
+ });
8
+ it("returns plain text unchanged", () => {
9
+ expect(autoCloseOpenMarkdown("just plain text 123")).toBe("just plain text 123");
10
+ });
11
+ it("does not modify already-balanced markdown", () => {
12
+ expect(autoCloseOpenMarkdown("**bold** and *italic* and ~~strike~~")).toBe("**bold** and *italic* and ~~strike~~");
13
+ });
14
+ // ── Bold ──────────────────────────────────────────────────────────
15
+ it("closes an unbalanced **opening — `**hello` → `**hello**`", () => {
16
+ expect(autoCloseOpenMarkdown("**hello")).toBe("**hello**");
17
+ });
18
+ it("does NOT close a `**` with nothing after it", () => {
19
+ // Just opened — closing would render as `****`, which looks worse.
20
+ expect(autoCloseOpenMarkdown("hi **")).toBe("hi **");
21
+ });
22
+ it("closes `__bold` as `__bold__`", () => {
23
+ expect(autoCloseOpenMarkdown("__bold")).toBe("__bold__");
24
+ });
25
+ // ── Italic ───────────────────────────────────────────────────────
26
+ it("closes `*italic` → `*italic*`", () => {
27
+ expect(autoCloseOpenMarkdown("*italic")).toBe("*italic*");
28
+ });
29
+ it("closes `_italic` → `_italic_`", () => {
30
+ expect(autoCloseOpenMarkdown("_italic")).toBe("_italic_");
31
+ });
32
+ it("does NOT close a `*` with nothing after it", () => {
33
+ expect(autoCloseOpenMarkdown("text *")).toBe("text *");
34
+ });
35
+ // ── Strike ───────────────────────────────────────────────────────
36
+ it("closes `~~strike` → `~~strike~~`", () => {
37
+ expect(autoCloseOpenMarkdown("~~strike")).toBe("~~strike~~");
38
+ });
39
+ // ── Inline code ──────────────────────────────────────────────────
40
+ it("closes `` `code `` → `` `code` ``", () => {
41
+ expect(autoCloseOpenMarkdown("`code")).toBe("`code`");
42
+ });
43
+ it("doesn't touch content inside paired inline code", () => {
44
+ // `code with *stars*` — the stars are inside code, so no italic close.
45
+ expect(autoCloseOpenMarkdown("`code *stars*` and more")).toBe("`code *stars*` and more");
46
+ });
47
+ it("treats unbalanced markers INSIDE inline code as opaque", () => {
48
+ // `**foo` is inside an unclosed inline → we close the backtick, but
49
+ // don't try to close the `**` inside code.
50
+ expect(autoCloseOpenMarkdown("see `**foo")).toBe("see `**foo`");
51
+ });
52
+ // ── Fenced code ──────────────────────────────────────────────────
53
+ it("closes an open fence — adds `\\n```` after the content", () => {
54
+ const input = "```py\ndef foo():";
55
+ const out = autoCloseOpenMarkdown(input);
56
+ expect(out.startsWith(input)).toBe(true);
57
+ expect(out.trim().endsWith("```")).toBe(true);
58
+ });
59
+ it("does NOT close a bare ``` with nothing after it (just opened)", () => {
60
+ expect(autoCloseOpenMarkdown("```")).toBe("```");
61
+ expect(autoCloseOpenMarkdown("```py")).toBe("```py"); // language tag alone
62
+ });
63
+ it("balanced fences left untouched", () => {
64
+ const input = "before\n```\ncode\n```\nafter";
65
+ expect(autoCloseOpenMarkdown(input)).toBe(input);
66
+ });
67
+ it("balanced fence + unbalanced markers in surrounding text close correctly", () => {
68
+ // The fence is paired; the trailing `**hel` outside the fence needs close.
69
+ const input = "**hel ```code``` more text";
70
+ const out = autoCloseOpenMarkdown(input);
71
+ expect(out).toContain("**hel");
72
+ expect(out).toContain("```code```");
73
+ expect(out).toContain("**"); // close
74
+ });
75
+ it("markers inside paired fences don't count toward balance", () => {
76
+ // Inside the fence we have `**oops`, but the markdown text is balanced.
77
+ const input = "```\n**oops never closes\n``` after";
78
+ expect(autoCloseOpenMarkdown(input)).toBe(input);
79
+ });
80
+ // ── Nesting ──────────────────────────────────────────────────────
81
+ it("closes innermost first — `**bold _italic` → `**bold _italic_**`", () => {
82
+ expect(autoCloseOpenMarkdown("**bold _italic")).toBe("**bold _italic_**");
83
+ });
84
+ it("doesn't double-close already-balanced inner markers", () => {
85
+ expect(autoCloseOpenMarkdown("**bold _italic_ tail")).toBe("**bold _italic_ tail**");
86
+ });
87
+ // ── Stream-evolution: simulate consecutive deltas; the agent's eventual
88
+ // close should NOT cause double-closes in the produced text. ──
89
+ it("stream-evolution: agent eventually closes; auto-close adds nothing at that point", () => {
90
+ const states = [
91
+ "**h",
92
+ "**he",
93
+ "**hel",
94
+ "**hell",
95
+ "**hello",
96
+ "**hello*",
97
+ "**hello**",
98
+ ];
99
+ const out = states.map(autoCloseOpenMarkdown);
100
+ // Intermediate states are closed
101
+ expect(out[0]).toBe("**h**");
102
+ expect(out[4]).toBe("**hello**");
103
+ // Final state is unchanged (already balanced)
104
+ expect(out[out.length - 1]).toBe("**hello**");
105
+ });
106
+ it("stream-evolution for fenced code: closes mid-stream; goes silent on real close", () => {
107
+ const states = [
108
+ "```py\n",
109
+ "```py\ndef ",
110
+ "```py\ndef foo():",
111
+ "```py\ndef foo():\n pass\n```",
112
+ ];
113
+ const out = states.map(autoCloseOpenMarkdown);
114
+ // First state: just opened — no content yet, don't auto-close
115
+ expect(out[0]).toBe("```py\n");
116
+ // Middle states: closed
117
+ expect(out[1].trim().endsWith("```")).toBe(true);
118
+ expect(out[2].trim().endsWith("```")).toBe(true);
119
+ // Final: agent emitted the close, balanced, no change
120
+ expect(out[3]).toBe(states[3]);
121
+ });
122
+ // ── Combined ────────────────────────────────────────────────────
123
+ it("closes bold before an unclosed fence opener, and closes the fence too", () => {
124
+ // `**bold ` is open bold then a space; `\`\`\`code\n` opens a fence with
125
+ // content. We close bold (with closer inserted before the trailing
126
+ // space, so the space stays adjacent to the fence) and close the fence.
127
+ const input = "**bold ```py\ndef foo():";
128
+ const out = autoCloseOpenMarkdown(input);
129
+ expect(out).toContain("**bold**"); // bold closed before fence
130
+ expect(out).toContain("def foo():"); // fence content preserved
131
+ expect(out.trim().endsWith("```")).toBe(true); // fence closed
132
+ });
133
+ // ── Sanity: long input ─────────────────────────────────────────
134
+ it("handles a long buffer with mixed structures", () => {
135
+ const lots = "**Bold opener** and `code` and *italic* and `unclosed inline";
136
+ const out = autoCloseOpenMarkdown(lots);
137
+ expect(out).toBe("**Bold opener** and `code` and *italic* and `unclosed inline`");
138
+ });
139
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=built-ins.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"built-ins.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/built-ins.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { lookupSlackUserTool, defaultSlackTools } from "../built-in-tools.js";
3
+ import { defaultSlackContext, slackTaggingContext, slackFormattingContext, slackConversationModelContext, } from "../built-in-context.js";
4
+ /**
5
+ * Build a minimal handler ctx whose `thread.lookupUser` is the only capability
6
+ * the lookup tool touches. The fake resolves to whatever `user` we hand it.
7
+ */
8
+ function makeCtx(user) {
9
+ const lookupUser = vi.fn(async (_query) => user);
10
+ const thread = { lookupUser };
11
+ return {
12
+ ctx: { thread, platform: "slack" },
13
+ lookupUser,
14
+ };
15
+ }
16
+ describe("lookup_slack_user", () => {
17
+ it("resolves a user via thread.lookupUser and returns a <@USERID> mention", async () => {
18
+ const { ctx, lookupUser } = makeCtx({
19
+ id: "U001",
20
+ name: "Atai Barkai",
21
+ handle: "atai",
22
+ email: "atai@copilotkit.ai",
23
+ });
24
+ const r = (await lookupSlackUserTool.handler({ query: "atai" }, ctx));
25
+ expect(lookupUser).toHaveBeenCalledWith("atai");
26
+ expect(r.found).toBe(true);
27
+ expect(r.userId).toBe("U001");
28
+ expect(r.name).toBe("Atai Barkai");
29
+ expect(r.email).toBe("atai@copilotkit.ai");
30
+ expect(r.mention).toBe("<@U001>");
31
+ });
32
+ it("returns found:false (with the query echoed) when no user resolves", async () => {
33
+ const { ctx } = makeCtx(undefined);
34
+ const r = (await lookupSlackUserTool.handler({ query: "Nobody von Nope" }, ctx));
35
+ expect(r.found).toBe(false);
36
+ expect(r.query).toBe("Nobody von Nope");
37
+ });
38
+ it("returns a raw object (NOT a JSON string) for the run-loop to serialize", async () => {
39
+ const { ctx } = makeCtx({ id: "U002" });
40
+ const r = await lookupSlackUserTool.handler({ query: "sarah" }, ctx);
41
+ expect(typeof r).toBe("object");
42
+ });
43
+ });
44
+ describe("default consts", () => {
45
+ it("defaultSlackTools is just the lookup tool", () => {
46
+ expect(defaultSlackTools.map((t) => t.name)).toEqual(["lookup_slack_user"]);
47
+ });
48
+ it("defaultSlackContext is the tagging + formatting + conversation-model entries", () => {
49
+ expect(defaultSlackContext).toEqual([
50
+ slackTaggingContext,
51
+ slackFormattingContext,
52
+ slackConversationModelContext,
53
+ ]);
54
+ });
55
+ it("tagging entry references the lookup tool by name", () => {
56
+ expect(slackTaggingContext.value).toContain("lookup_slack_user");
57
+ expect(slackTaggingContext.value).toContain("<@USERID>");
58
+ });
59
+ it("formatting entry talks about Markdown -> mrkdwn", () => {
60
+ expect(slackFormattingContext.value).toContain("Markdown");
61
+ expect(slackFormattingContext.value).toContain("mrkdwn");
62
+ });
63
+ it("conversation-model entry talks about threads and DMs", () => {
64
+ expect(slackConversationModelContext.value).toContain("thread");
65
+ expect(slackConversationModelContext.value.toLowerCase()).toContain("dm");
66
+ });
67
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=chunked-message-stream.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chunked-message-stream.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/chunked-message-stream.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,226 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { ChunkedMessageStream } from "../chunked-message-stream.js";
3
+ function makeFakeSlack(updateDurationMs = 0) {
4
+ const posts = [];
5
+ const updates = [];
6
+ let counter = 0;
7
+ return {
8
+ posts,
9
+ updates,
10
+ postPlaceholder: async (text) => {
11
+ counter++;
12
+ const ts = `${counter}.0`;
13
+ posts.push({ ts, text });
14
+ return ts;
15
+ },
16
+ updateAt: async (ts, text) => {
17
+ if (updateDurationMs > 0)
18
+ await new Promise((r) => setTimeout(r, updateDurationMs));
19
+ updates.push({ ts, text });
20
+ },
21
+ };
22
+ }
23
+ const longString = (n) => "lorem ipsum ".repeat(Math.ceil(n / 12)).slice(0, n);
24
+ describe("ChunkedMessageStream", () => {
25
+ it("stays as a single message when buffer fits in one chunk", async () => {
26
+ const slack = makeFakeSlack();
27
+ const s = new ChunkedMessageStream({
28
+ ...slack,
29
+ limit: 100,
30
+ minIntervalMs: 0,
31
+ });
32
+ s.append("short reply");
33
+ await s.finish();
34
+ expect(slack.posts).toHaveLength(1);
35
+ expect(slack.posts[0].text).toBe("_thinking…_");
36
+ expect(slack.updates.at(-1)?.text).toBe("short reply");
37
+ });
38
+ it("splits into multiple messages when buffer exceeds the limit", async () => {
39
+ const slack = makeFakeSlack();
40
+ const s = new ChunkedMessageStream({
41
+ ...slack,
42
+ limit: 50,
43
+ minIntervalMs: 0,
44
+ });
45
+ s.append(longString(200));
46
+ await s.finish();
47
+ expect(slack.posts.length).toBeGreaterThanOrEqual(4);
48
+ // First placeholder = thinking, rest = continued
49
+ expect(slack.posts[0].text).toBe("_thinking…_");
50
+ expect(slack.posts[1].text).toBe("_…(continued)_");
51
+ });
52
+ it("frozen boundaries don't move — already-posted chunks never shrink", async () => {
53
+ const slack = makeFakeSlack();
54
+ const s = new ChunkedMessageStream({
55
+ ...slack,
56
+ limit: 50,
57
+ minIntervalMs: 0,
58
+ });
59
+ s.append(longString(60));
60
+ await s.finish();
61
+ const chunkAfter1 = slack.updates.filter((u) => u.ts === "1.0").at(-1)?.text ?? "";
62
+ // Now feed a fresh stream the same prefix and see that chunk 1 keeps
63
+ // the same content even when extra growth happens.
64
+ const slack2 = makeFakeSlack();
65
+ const s2 = new ChunkedMessageStream({
66
+ ...slack2,
67
+ limit: 50,
68
+ minIntervalMs: 0,
69
+ });
70
+ s2.append(longString(60));
71
+ // Let the first dispatch settle by yielding briefly.
72
+ await new Promise((r) => setTimeout(r, 5));
73
+ s2.append(longString(120));
74
+ await s2.finish();
75
+ const chunkAfter1_b = slack2.updates.filter((u) => u.ts === "1.0").at(-1)?.text ?? "";
76
+ expect(chunkAfter1).toBe(chunkAfter1_b);
77
+ });
78
+ it("prefers to break at newlines, then spaces", async () => {
79
+ const slack = makeFakeSlack();
80
+ const s = new ChunkedMessageStream({
81
+ ...slack,
82
+ limit: 30,
83
+ minIntervalMs: 0,
84
+ });
85
+ s.append("first paragraph here.\nsecond paragraph here.\nthird here.");
86
+ await s.finish();
87
+ const chunk1 = slack.updates.filter((u) => u.ts === "1.0").at(-1)?.text ?? "";
88
+ // Should not contain a partial word — last char should be a space or newline boundary.
89
+ expect(chunk1.endsWith("\n") || chunk1.endsWith(" ") || /\.[\s]?$/.test(chunk1)).toBe(true);
90
+ });
91
+ it("concatenated chunks equal the full buffer (no characters lost)", async () => {
92
+ const slack = makeFakeSlack();
93
+ const s = new ChunkedMessageStream({
94
+ ...slack,
95
+ limit: 40,
96
+ minIntervalMs: 0,
97
+ });
98
+ const full = longString(300);
99
+ s.append(full);
100
+ await s.finish();
101
+ // Take the last update per ts in posted order
102
+ const final = {};
103
+ for (const u of slack.updates)
104
+ final[u.ts] = u.text;
105
+ const concatenated = Object.entries(final)
106
+ .sort(([a], [b]) => Number(a) - Number(b))
107
+ .map(([, v]) => v)
108
+ .join("");
109
+ expect(concatenated).toBe(full);
110
+ });
111
+ it("applies the transform per chunk before chat.update", async () => {
112
+ const slack = makeFakeSlack();
113
+ const s = new ChunkedMessageStream({
114
+ ...slack,
115
+ limit: 100,
116
+ minIntervalMs: 0,
117
+ transform: (t) => t.toUpperCase(),
118
+ });
119
+ s.append("hello world");
120
+ await s.finish();
121
+ expect(slack.updates.at(-1)?.text).toBe("HELLO WORLD");
122
+ });
123
+ it("block-keeps-whole: a fenced block that fits in a new chunk goes whole into the next message", async () => {
124
+ // 200 chars of prose + a 60-char fenced block. With limit=120, the
125
+ // ideal boundary would land inside the fence. The block-keeps-whole
126
+ // logic should pull the boundary back to BEFORE the fence so chunk 2
127
+ // contains the entire fence cleanly.
128
+ const slack = makeFakeSlack();
129
+ const s = new ChunkedMessageStream({
130
+ ...slack,
131
+ limit: 120,
132
+ minIntervalMs: 0,
133
+ });
134
+ // Prose just under the limit, so we end up with exactly two chunks.
135
+ const prose = "lorem ipsum dolor sit amet ".repeat(4); // 108 chars
136
+ const block = "```python\nprint('hi')\nprint('bye')\n```";
137
+ const fullText = prose + block;
138
+ s.append(fullText);
139
+ await s.finish();
140
+ expect(slack.posts.length).toBeGreaterThanOrEqual(2);
141
+ // Find the FINAL state of each chunk message
142
+ const finals = {};
143
+ for (const u of slack.updates)
144
+ finals[u.ts] = u.text;
145
+ const chunk1 = finals["1.0"] ?? "";
146
+ const chunk2 = finals["2.0"] ?? "";
147
+ // Chunk 1 must not contain the fence at all — the block moved to chunk 2.
148
+ expect(chunk1.includes("```")).toBe(false);
149
+ // Chunk 2 must contain the whole block.
150
+ expect(chunk2.includes("```python")).toBe(true);
151
+ expect(chunk2.trim().endsWith("```")).toBe(true);
152
+ expect(chunk2.includes("print('hi')")).toBe(true);
153
+ expect(chunk2.includes("print('bye')")).toBe(true);
154
+ });
155
+ it("fallback: a block too big to fit in one chunk uses the re-opener path", async () => {
156
+ // Make a block bigger than the limit so the boundary can't move
157
+ // back without leaving chunk N empty. Re-opener prefix is used instead.
158
+ const slack = makeFakeSlack();
159
+ const s = new ChunkedMessageStream({
160
+ ...slack,
161
+ limit: 80,
162
+ minIntervalMs: 0,
163
+ });
164
+ const code = "x = 1\n".repeat(40); // ~240 chars of code
165
+ const fullText = "preamble. ```python\n" + code + "```";
166
+ s.append(fullText);
167
+ await s.finish();
168
+ expect(slack.posts.length).toBeGreaterThanOrEqual(2);
169
+ const finals = {};
170
+ for (const u of slack.updates)
171
+ finals[u.ts] = u.text;
172
+ const chunk2 = finals["2.0"] ?? "";
173
+ // Chunk 2 must begin with a fence opener (re-opener path).
174
+ expect(chunk2.startsWith("```python\n")).toBe(true);
175
+ });
176
+ it("continuation chunk re-opens a fence when the boundary is inside ```python", async () => {
177
+ // Reproduces the user-reported python decorators bug: a long fenced
178
+ // code block split across two Slack messages → chunk 2 must start
179
+ // with the fence opener (and the chunk 1 will close with ``` thanks
180
+ // to autoCloseOpenMarkdown applied via the transform).
181
+ const slack = makeFakeSlack();
182
+ const s = new ChunkedMessageStream({
183
+ ...slack,
184
+ limit: 80,
185
+ minIntervalMs: 0,
186
+ });
187
+ // Synthesise enough code in one fence to overflow the limit.
188
+ const longCode = Array.from({ length: 12 }, (_, i) => `print(${i})`).join("\n");
189
+ const fullText = "```python\n" + longCode + "\n```";
190
+ s.append(fullText);
191
+ await s.finish();
192
+ expect(slack.posts.length).toBeGreaterThan(1); // multiple messages
193
+ // The text of each subsequent message (after applying our manual mrkdwn-free transform here)
194
+ // is checked via slack.updates. Examine the SECOND chunk's last text:
195
+ const lastForSecondTs = [...slack.updates]
196
+ .reverse()
197
+ .find((u) => u.ts === "2.0");
198
+ expect(lastForSecondTs?.text.startsWith("```python\n")).toBe(true);
199
+ });
200
+ it("continuation chunk re-opens a fence without language when none was specified", async () => {
201
+ const slack = makeFakeSlack();
202
+ const s = new ChunkedMessageStream({
203
+ ...slack,
204
+ limit: 60,
205
+ minIntervalMs: 0,
206
+ });
207
+ const fullText = "```\n" + "abcdefghij\n".repeat(10) + "```";
208
+ s.append(fullText);
209
+ await s.finish();
210
+ expect(slack.posts.length).toBeGreaterThan(1);
211
+ const second = [...slack.updates].reverse().find((u) => u.ts === "2.0");
212
+ expect(second?.text.startsWith("```\n")).toBe(true);
213
+ });
214
+ it("finish() is idempotent and safe to call without any appends", async () => {
215
+ const slack = makeFakeSlack();
216
+ const s = new ChunkedMessageStream({
217
+ ...slack,
218
+ limit: 100,
219
+ minIntervalMs: 0,
220
+ });
221
+ await s.finish();
222
+ await s.finish();
223
+ expect(slack.posts).toHaveLength(0);
224
+ expect(slack.updates).toHaveLength(0);
225
+ });
226
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=conversation-store.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"conversation-store.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/conversation-store.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,209 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { SlackConversationStore } from "../conversation-store.js";
3
+ import { DM_SCOPE } from "../types.js";
4
+ const BOT = "UBOT0001";
5
+ const ATAI = "UATAI001";
6
+ function makeFakeClient(opts) {
7
+ const client = {
8
+ conversations: {
9
+ replies: vi.fn(async (_args) => {
10
+ if (opts.throwOnReplies)
11
+ throw new Error("api down");
12
+ return { ok: true, messages: opts.replies ?? [] };
13
+ }),
14
+ history: vi.fn(async (_args) => ({
15
+ ok: true,
16
+ messages: opts.history ?? [],
17
+ })),
18
+ },
19
+ };
20
+ return client;
21
+ }
22
+ function makeAgent() {
23
+ return {
24
+ messages: [],
25
+ threadId: "",
26
+ };
27
+ }
28
+ describe("SlackConversationStore", () => {
29
+ it("mints a unique threadId per turn under a stable conversation prefix", async () => {
30
+ const client = makeFakeClient({ replies: [] });
31
+ const store = new SlackConversationStore({
32
+ client: client,
33
+ botUserId: BOT,
34
+ botToken: "xoxb-test",
35
+ });
36
+ const s1 = await store.getOrCreate({ channelId: "C1", scope: "100.0" }, { channel: "C1", threadTs: "100.0" }, () => makeAgent());
37
+ const s2 = await store.getOrCreate({ channelId: "C1", scope: "100.0" }, { channel: "C1", threadTs: "100.0" }, () => makeAgent());
38
+ // Threads are unique per turn so the server-side LangGraph thread never
39
+ // accumulates internal messages across turns (the "Message not found"
40
+ // balloon). Slack is the durable history; each turn rebuilds from it.
41
+ expect(s1.threadId).not.toBe(s2.threadId);
42
+ // …but both carry the stable conversation prefix so the turn's origin
43
+ // stays identifiable (and recovery can fall back to it).
44
+ expect(s1.threadId).toMatch(/^slack-C1-100\.0-/);
45
+ expect(s2.threadId).toMatch(/^slack-C1-100\.0-/);
46
+ });
47
+ it("populates agent.messages from the thread history fetched via Slack", async () => {
48
+ const client = makeFakeClient({
49
+ replies: [
50
+ { ts: "100.0", user: ATAI, text: `<@${BOT}> hello` },
51
+ { ts: "100.5", user: BOT, text: "Hi back!" },
52
+ { ts: "101.0", user: ATAI, text: "follow-up" },
53
+ ],
54
+ });
55
+ const store = new SlackConversationStore({
56
+ client: client,
57
+ botUserId: BOT,
58
+ botToken: "xoxb-test",
59
+ });
60
+ const s = await store.getOrCreate({ channelId: "C1", scope: "100.0" }, { channel: "C1", threadTs: "100.0" }, () => makeAgent());
61
+ const msgs = s.agent.messages;
62
+ expect(msgs.map((m) => ({ r: m.role, c: m.content }))).toEqual([
63
+ { r: "user", c: "hello" }, // bot mention stripped
64
+ { r: "assistant", c: "Hi back!" },
65
+ { r: "user", c: "follow-up" },
66
+ ]);
67
+ });
68
+ it("folds consecutive bot messages into one assistant turn (chunked replies)", async () => {
69
+ const client = makeFakeClient({
70
+ replies: [
71
+ { ts: "100.0", user: ATAI, text: `<@${BOT}> long question` },
72
+ { ts: "100.5", user: BOT, text: "First chunk of the answer." },
73
+ { ts: "100.6", user: BOT, text: "Second chunk." },
74
+ { ts: "100.7", user: BOT, text: "Third and final chunk." },
75
+ ],
76
+ });
77
+ const store = new SlackConversationStore({
78
+ client: client,
79
+ botUserId: BOT,
80
+ botToken: "xoxb-test",
81
+ });
82
+ const s = await store.getOrCreate({ channelId: "C1", scope: "100.0" }, { channel: "C1", threadTs: "100.0" }, () => makeAgent());
83
+ const msgs = s.agent.messages;
84
+ expect(msgs).toHaveLength(2);
85
+ expect(msgs[1].role).toBe("assistant");
86
+ expect(msgs[1].content).toContain("First chunk");
87
+ expect(msgs[1].content).toContain("Second chunk.");
88
+ expect(msgs[1].content).toContain("Third and final chunk.");
89
+ });
90
+ it("skips status messages and the thinking placeholder", async () => {
91
+ const client = makeFakeClient({
92
+ replies: [
93
+ { ts: "100.0", user: ATAI, text: `<@${BOT}> please search` },
94
+ { ts: "100.5", user: BOT, text: "_thinking…_" },
95
+ { ts: "100.6", user: BOT, text: ":wrench: Calling `search`…" },
96
+ { ts: "100.7", user: BOT, text: ":white_check_mark: `search`" },
97
+ { ts: "100.8", user: BOT, text: "Here are the results." },
98
+ ],
99
+ });
100
+ const store = new SlackConversationStore({
101
+ client: client,
102
+ botUserId: BOT,
103
+ botToken: "xoxb-test",
104
+ });
105
+ const s = await store.getOrCreate({ channelId: "C1", scope: "100.0" }, { channel: "C1", threadTs: "100.0" }, () => makeAgent());
106
+ const msgs = s.agent.messages;
107
+ expect(msgs).toHaveLength(2);
108
+ expect(msgs[1].content).toBe("Here are the results.");
109
+ });
110
+ it("ignores subtyped messages (edits, joins, etc.)", async () => {
111
+ const client = makeFakeClient({
112
+ replies: [
113
+ { ts: "100.0", user: ATAI, text: "<@" + BOT + "> hi" },
114
+ { ts: "100.1", subtype: "channel_join", text: "joined" },
115
+ { ts: "100.2", subtype: "message_changed", user: ATAI, text: "edited" },
116
+ ],
117
+ });
118
+ const store = new SlackConversationStore({
119
+ client: client,
120
+ botUserId: BOT,
121
+ botToken: "xoxb-test",
122
+ });
123
+ const s = await store.getOrCreate({ channelId: "C1", scope: "100.0" }, { channel: "C1", threadTs: "100.0" }, () => makeAgent());
124
+ const msgs = s.agent.messages;
125
+ expect(msgs).toHaveLength(1);
126
+ });
127
+ it("DM scope uses conversations.history (chronological after reverse) instead of replies", async () => {
128
+ // Slack returns history newest-first; the store reverses it.
129
+ const client = makeFakeClient({
130
+ history: [
131
+ { ts: "200.0", user: ATAI, text: "third" },
132
+ { ts: "100.0", user: BOT, text: "second" },
133
+ { ts: "50.0", user: ATAI, text: "first" },
134
+ ],
135
+ });
136
+ const store = new SlackConversationStore({
137
+ client: client,
138
+ botUserId: BOT,
139
+ botToken: "xoxb-test",
140
+ });
141
+ const s = await store.getOrCreate({ channelId: "D1", scope: DM_SCOPE }, { channel: "D1" }, () => makeAgent());
142
+ const msgs = s.agent.messages;
143
+ expect(msgs.map((m) => m.content)).toEqual(["first", "second", "third"]);
144
+ });
145
+ it("has() returns true after the bot has replied; uses Slack lookup on cache miss", async () => {
146
+ const client = makeFakeClient({
147
+ replies: [
148
+ { ts: "100.0", user: ATAI, text: "<@" + BOT + "> hi" },
149
+ { ts: "100.5", user: BOT, text: "Hello!" },
150
+ ],
151
+ });
152
+ const store = new SlackConversationStore({
153
+ client: client,
154
+ botUserId: BOT,
155
+ botToken: "xoxb-test",
156
+ });
157
+ // Fresh store, no in-process cache — must hit Slack to discover ownership.
158
+ expect(await store.has({ channelId: "C1", scope: "100.0" })).toBe(true);
159
+ // Second call should be cached (no extra Slack call).
160
+ expect(await store.has({ channelId: "C1", scope: "100.0" })).toBe(true);
161
+ expect(client.conversations.replies.mock.calls
162
+ .length).toBe(1);
163
+ });
164
+ it("has() returns false for threads we've never replied in", async () => {
165
+ const client = makeFakeClient({
166
+ replies: [{ ts: "100.0", user: ATAI, text: "hey everyone" }],
167
+ });
168
+ const store = new SlackConversationStore({
169
+ client: client,
170
+ botUserId: BOT,
171
+ botToken: "xoxb-test",
172
+ });
173
+ expect(await store.has({ channelId: "C1", scope: "100.0" })).toBe(false);
174
+ });
175
+ it("has() returns false gracefully when Slack API fails", async () => {
176
+ const client = makeFakeClient({ throwOnReplies: true });
177
+ const store = new SlackConversationStore({
178
+ client: client,
179
+ botUserId: BOT,
180
+ botToken: "xoxb-test",
181
+ });
182
+ expect(await store.has({ channelId: "C1", scope: "100.0" })).toBe(false);
183
+ });
184
+ it("getOrCreate marks the conversation as owned even when Slack history doesn't yet include the bot's reply", async () => {
185
+ // After an @mention, the bot will reply within seconds. A follow-up
186
+ // arriving before the bot's reply lands shouldn't be silently dropped.
187
+ const client = makeFakeClient({
188
+ replies: [{ ts: "100.0", user: ATAI, text: "<@" + BOT + "> hi" }],
189
+ });
190
+ const store = new SlackConversationStore({
191
+ client: client,
192
+ botUserId: BOT,
193
+ botToken: "xoxb-test",
194
+ });
195
+ await store.getOrCreate({ channelId: "C1", scope: "100.0" }, { channel: "C1", threadTs: "100.0" }, () => makeAgent());
196
+ // Now has() should be true even though the bot hasn't replied to Slack
197
+ // yet — getOrCreate eagerly marked it.
198
+ expect(await store.has({ channelId: "C1", scope: "100.0" })).toBe(true);
199
+ });
200
+ it("save() is a no-op (Slack is the source of truth)", () => {
201
+ const client = makeFakeClient({ replies: [] });
202
+ const store = new SlackConversationStore({
203
+ client: client,
204
+ botUserId: BOT,
205
+ botToken: "xoxb-test",
206
+ });
207
+ expect(() => store.save({ channelId: "C1", scope: "100.0" }, {})).not.toThrow();
208
+ });
209
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=download-files.test.d.ts.map