@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,116 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { MessageStream } from "../message-stream.js";
3
+ /** A fake Slack `chat.update` that records every call in order and lets the
4
+ * test control how long each takes — useful for proving the in-flight /
5
+ * finish race that bit the original implementation can't happen here. */
6
+ function makeFakeSlack(updateDurationMs = 0) {
7
+ const calls = [];
8
+ let started = 0;
9
+ let finished = 0;
10
+ const update = async (text) => {
11
+ started++;
12
+ if (updateDurationMs > 0) {
13
+ await new Promise((r) => setTimeout(r, updateDurationMs));
14
+ }
15
+ finished++;
16
+ calls.push({ text, finishedAt: Date.now() });
17
+ };
18
+ return {
19
+ update,
20
+ calls,
21
+ get inFlight() {
22
+ return started - finished;
23
+ },
24
+ };
25
+ }
26
+ describe("MessageStream", () => {
27
+ it("flushes the final buffer on finish() even when end races an in-flight update", async () => {
28
+ // Reproduces the ALPHA → AL bug: a slow chat.update for "AL" is in
29
+ // flight when the stream finishes with "ALPHA". The old implementation
30
+ // could let the slow "AL" update overwrite "ALPHA" because chat.update
31
+ // calls weren't serialised. With the queue, finish() must observe the
32
+ // final buffer regardless of in-flight state.
33
+ const slack = makeFakeSlack(50);
34
+ const stream = new MessageStream({
35
+ update: slack.update,
36
+ minIntervalMs: 0,
37
+ });
38
+ stream.append("A");
39
+ stream.append("AL");
40
+ // Let the timer fire and the first flush kick off (in-flight)
41
+ await new Promise((r) => setTimeout(r, 5));
42
+ expect(slack.inFlight).toBeGreaterThanOrEqual(0); // best effort
43
+ stream.append("ALP");
44
+ stream.append("ALPH");
45
+ stream.append("ALPHA");
46
+ await stream.finish();
47
+ expect(slack.calls.length).toBeGreaterThan(0);
48
+ // The LAST chat.update — i.e. the final state of the Slack message — must be ALPHA
49
+ expect(slack.calls.at(-1)?.text).toBe("ALPHA");
50
+ // And no concurrent updates were ever in flight (queue invariant)
51
+ expect(slack.inFlight).toBe(0);
52
+ });
53
+ it("never has more than one update in flight at a time", async () => {
54
+ const slack = makeFakeSlack(20);
55
+ let maxInFlight = 0;
56
+ const sampler = setInterval(() => {
57
+ if (slack.inFlight > maxInFlight)
58
+ maxInFlight = slack.inFlight;
59
+ }, 1);
60
+ const stream = new MessageStream({
61
+ update: slack.update,
62
+ minIntervalMs: 0,
63
+ });
64
+ for (let i = 1; i <= 20; i++) {
65
+ stream.append("x".repeat(i));
66
+ await new Promise((r) => setTimeout(r, 2));
67
+ }
68
+ await stream.finish();
69
+ clearInterval(sampler);
70
+ expect(maxInFlight).toBeLessThanOrEqual(1);
71
+ });
72
+ it("throttles flushes to roughly minIntervalMs between completions", async () => {
73
+ const slack = makeFakeSlack(0);
74
+ const stream = new MessageStream({
75
+ update: slack.update,
76
+ minIntervalMs: 50,
77
+ });
78
+ const start = Date.now();
79
+ for (let i = 0; i < 10; i++) {
80
+ stream.append(`buf${i}`);
81
+ await new Promise((r) => setTimeout(r, 5));
82
+ }
83
+ await stream.finish();
84
+ // 10 rapid appends should NOT have produced 10 chat.update calls; the
85
+ // throttle should have coalesced them. Exact count varies by timing but
86
+ // should be well under 10.
87
+ expect(slack.calls.length).toBeLessThan(10);
88
+ // Final state must still match the last append
89
+ expect(slack.calls.at(-1)?.text).toBe("buf9");
90
+ // And the test ran for at least one throttle window
91
+ expect(Date.now() - start).toBeGreaterThanOrEqual(0);
92
+ });
93
+ it("collapses duplicate appends into zero updates", async () => {
94
+ const slack = makeFakeSlack(0);
95
+ const stream = new MessageStream({
96
+ update: slack.update,
97
+ minIntervalMs: 0,
98
+ });
99
+ stream.append("hello");
100
+ stream.append("hello");
101
+ stream.append("hello");
102
+ await stream.finish();
103
+ // One actual update, not three
104
+ expect(slack.calls).toHaveLength(1);
105
+ expect(slack.calls[0]?.text).toBe("hello");
106
+ });
107
+ it("is a no-op when finish() is called before any append", async () => {
108
+ const slack = makeFakeSlack(0);
109
+ const stream = new MessageStream({
110
+ update: slack.update,
111
+ minIntervalMs: 0,
112
+ });
113
+ await stream.finish();
114
+ expect(slack.calls).toHaveLength(0);
115
+ });
116
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=native-renderer.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"native-renderer.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/native-renderer.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,236 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { createRunRenderer } from "../event-renderer.js";
3
+ /** Fake render transport for the non-native side channels (legacy/error posts). */
4
+ function makeFakeClient() {
5
+ const posts = [];
6
+ const updates = [];
7
+ let counter = 0;
8
+ const transport = {
9
+ setStatus: vi.fn(async () => { }),
10
+ postMessage: vi.fn(async (args) => {
11
+ counter++;
12
+ const ts = `${counter}.000`;
13
+ posts.push({ ...args, ts });
14
+ return { ts };
15
+ }),
16
+ updateMessage: vi.fn(async (args) => {
17
+ updates.push(args);
18
+ }),
19
+ };
20
+ return { transport, posts, updates };
21
+ }
22
+ /** Fake native streaming transport recording every streamed message's lifecycle. */
23
+ function makeFakeNativeTransport(opts) {
24
+ const messages = [];
25
+ let counter = 0;
26
+ const transport = {
27
+ startStream: vi.fn(async () => {
28
+ counter++;
29
+ const ts = `S${counter}`;
30
+ messages.push({ ts, events: [], stopped: false });
31
+ return ts;
32
+ }),
33
+ appendText: vi.fn(async (ts, md) => {
34
+ messages
35
+ .find((m) => m.ts === ts)
36
+ ?.events.push({ kind: "text", value: md });
37
+ }),
38
+ appendChunks: vi.fn(async (ts, chunks) => {
39
+ if (opts?.failChunks)
40
+ throw new Error("chunks unsupported");
41
+ messages
42
+ .find((m) => m.ts === ts)
43
+ ?.events.push({ kind: "chunks", value: chunks });
44
+ }),
45
+ stopStream: vi.fn(async (ts, blocks) => {
46
+ const m = messages.find((x) => x.ts === ts);
47
+ if (m) {
48
+ m.stopped = true;
49
+ m.stopBlocks = blocks;
50
+ }
51
+ }),
52
+ };
53
+ return { transport, messages };
54
+ }
55
+ const textOf = (events) => events
56
+ .filter((e) => e.kind === "text")
57
+ .map((e) => e.value)
58
+ .join("");
59
+ const chunksOf = (events) => events
60
+ .filter((e) => e.kind === "chunks")
61
+ .flatMap((e) => e.value);
62
+ const tick = () => new Promise((r) => setTimeout(r, 0));
63
+ describe("createRunRenderer — native streaming", () => {
64
+ it("streams multiple AG-UI messages into ONE turn message, separated by a blank line", async () => {
65
+ const fake = makeFakeClient();
66
+ const nt = makeFakeNativeTransport();
67
+ const { subscriber: sub, finish } = createRunRenderer({
68
+ transport: fake.transport,
69
+ target: { channel: "C1", threadTs: "100.0" },
70
+ nativeStreaming: { transport: nt.transport },
71
+ });
72
+ await sub.onTextMessageStartEvent({ event: { messageId: "m1" } });
73
+ sub.onTextMessageContentEvent({
74
+ event: { messageId: "m1", delta: "Hello" },
75
+ });
76
+ await sub.onTextMessageEndEvent({ event: { messageId: "m1" } });
77
+ await sub.onTextMessageStartEvent({ event: { messageId: "m2" } });
78
+ sub.onTextMessageContentEvent({
79
+ event: { messageId: "m2", delta: "World" },
80
+ });
81
+ await sub.onTextMessageEndEvent({ event: { messageId: "m2" } });
82
+ await finish();
83
+ expect(nt.transport.startStream).toHaveBeenCalledTimes(1);
84
+ expect(nt.messages).toHaveLength(1);
85
+ expect(textOf(nt.messages[0].events)).toBe("Hello\n\nWorld");
86
+ expect(nt.messages[0].stopped).toBe(true);
87
+ });
88
+ it("surfaces tool calls as in-message task_update chunks (no :wrench: rows)", async () => {
89
+ const fake = makeFakeClient();
90
+ const nt = makeFakeNativeTransport();
91
+ const { subscriber: sub, finish } = createRunRenderer({
92
+ transport: fake.transport,
93
+ target: { channel: "C1", threadTs: "100.0" },
94
+ nativeStreaming: { transport: nt.transport },
95
+ });
96
+ sub.onTextMessageContentEvent({
97
+ event: { messageId: "m1", delta: "thinking…" },
98
+ });
99
+ await sub.onToolCallStartEvent({
100
+ event: { toolCallId: "t1", toolCallName: "search" },
101
+ });
102
+ await sub.onToolCallEndEvent({
103
+ event: { toolCallId: "t1" },
104
+ toolCallName: "search",
105
+ toolCallArgs: {},
106
+ });
107
+ await finish();
108
+ // No legacy :wrench: rows posted to the channel.
109
+ expect(fake.posts.filter((p) => p.text.includes(":wrench:"))).toHaveLength(0);
110
+ const chunks = chunksOf(nt.messages[0].events);
111
+ expect(chunks).toEqual([
112
+ {
113
+ type: "task_update",
114
+ id: "t1",
115
+ title: "Using `search`",
116
+ status: "in_progress",
117
+ },
118
+ {
119
+ type: "task_update",
120
+ id: "t1",
121
+ title: "Used `search`",
122
+ status: "complete",
123
+ },
124
+ ]);
125
+ });
126
+ it("showToolStatus:false suppresses tool progress entirely (no chunks, no rows)", async () => {
127
+ const fake = makeFakeClient();
128
+ const nt = makeFakeNativeTransport();
129
+ const { subscriber: sub, finish } = createRunRenderer({
130
+ transport: fake.transport,
131
+ target: { channel: "C1", threadTs: "100.0" },
132
+ nativeStreaming: { transport: nt.transport },
133
+ showToolStatus: false,
134
+ });
135
+ sub.onTextMessageContentEvent({
136
+ event: { messageId: "m1", delta: "answer" },
137
+ });
138
+ await sub.onToolCallStartEvent({
139
+ event: { toolCallId: "t1", toolCallName: "search" },
140
+ });
141
+ await sub.onToolCallEndEvent({
142
+ event: { toolCallId: "t1" },
143
+ toolCallName: "search",
144
+ toolCallArgs: {},
145
+ });
146
+ await finish();
147
+ // No task_update chunks and no :wrench: rows — only the text answer.
148
+ expect(chunksOf(nt.messages[0].events)).toHaveLength(0);
149
+ expect(fake.posts.filter((p) => p.text.includes(":wrench:"))).toHaveLength(0);
150
+ expect(textOf(nt.messages[0].events)).toBe("answer");
151
+ });
152
+ it("attaches the feedback row at finish only when text was streamed", async () => {
153
+ const blocks = [
154
+ { type: "context_actions", elements: [] },
155
+ ];
156
+ // (a) with text → feedback attached.
157
+ const withText = makeFakeNativeTransport();
158
+ const r1 = createRunRenderer({
159
+ transport: makeFakeClient().transport,
160
+ target: { channel: "C1", threadTs: "100.0" },
161
+ nativeStreaming: { transport: withText.transport },
162
+ feedbackBlocks: blocks,
163
+ });
164
+ r1.subscriber.onTextMessageContentEvent({
165
+ event: { messageId: "m1", delta: "answer" },
166
+ });
167
+ await r1.finish();
168
+ expect(withText.messages[0].stopBlocks).toBe(blocks);
169
+ // (b) no text (tool-only run) → no stream, nothing to attach.
170
+ const noText = makeFakeNativeTransport();
171
+ const r2 = createRunRenderer({
172
+ transport: makeFakeClient().transport,
173
+ target: { channel: "C1", threadTs: "100.0" },
174
+ nativeStreaming: { transport: noText.transport },
175
+ feedbackBlocks: blocks,
176
+ });
177
+ await r2.finish();
178
+ expect(noText.messages).toHaveLength(0);
179
+ });
180
+ it("degrades to :wrench: rows after a chunk append fails", async () => {
181
+ const fake = makeFakeClient();
182
+ const nt = makeFakeNativeTransport({ failChunks: true });
183
+ const onChunkFailure = vi.fn();
184
+ const { subscriber: sub, finish } = createRunRenderer({
185
+ transport: fake.transport,
186
+ target: { channel: "C1", threadTs: "100.0" },
187
+ nativeStreaming: { transport: nt.transport, onChunkFailure },
188
+ });
189
+ // First tool call tries a chunk, which fails and flips the degradation flag.
190
+ await sub.onToolCallStartEvent({
191
+ event: { toolCallId: "t1", toolCallName: "search" },
192
+ });
193
+ await tick(); // let the queued chunk append run and fail
194
+ expect(onChunkFailure).toHaveBeenCalled();
195
+ // Second tool call now falls back to a :wrench: status row.
196
+ await sub.onToolCallStartEvent({
197
+ event: { toolCallId: "t2", toolCallName: "lookup" },
198
+ });
199
+ await finish();
200
+ expect(fake.posts.some((p) => p.text.includes(":wrench:"))).toBe(true);
201
+ });
202
+ it("does NOT attach the feedback row to an interrupted partial reply", async () => {
203
+ const nt = makeFakeNativeTransport();
204
+ const blocks = [
205
+ { type: "context_actions", elements: [] },
206
+ ];
207
+ const { subscriber: sub, markInterrupted } = createRunRenderer({
208
+ transport: makeFakeClient().transport,
209
+ target: { channel: "C1", threadTs: "100.0" },
210
+ nativeStreaming: { transport: nt.transport },
211
+ feedbackBlocks: blocks,
212
+ });
213
+ sub.onTextMessageContentEvent({
214
+ event: { messageId: "m1", delta: "partial" },
215
+ });
216
+ await markInterrupted();
217
+ expect(nt.messages[0].stopped).toBe(true);
218
+ expect(nt.messages[0].stopBlocks).toBeUndefined();
219
+ });
220
+ it("markInterrupted finalizes the single turn stream with the interrupted marker", async () => {
221
+ const fake = makeFakeClient();
222
+ const nt = makeFakeNativeTransport();
223
+ const { subscriber: sub, markInterrupted } = createRunRenderer({
224
+ transport: fake.transport,
225
+ target: { channel: "C1", threadTs: "100.0" },
226
+ nativeStreaming: { transport: nt.transport },
227
+ });
228
+ sub.onTextMessageContentEvent({
229
+ event: { messageId: "m1", delta: "Once upon a time" },
230
+ });
231
+ await markInterrupted();
232
+ expect(nt.messages).toHaveLength(1);
233
+ expect(nt.messages[0].stopped).toBe(true);
234
+ expect(textOf(nt.messages[0].events)).toContain("_(interrupted)_");
235
+ });
236
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=native-stream.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"native-stream.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/native-stream.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,235 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { NativeMessageStream } from "../native-stream.js";
3
+ /**
4
+ * A fake `chat.startStream/appendStream/stopStream` transport that records the
5
+ * lifecycle of every streamed message in order: text appends and chunk appends
6
+ * interleaved, plus the stop call and any trailing blocks.
7
+ */
8
+ function makeFakeTransport(opts) {
9
+ const messages = [];
10
+ let counter = 0;
11
+ const transport = {
12
+ startStream: vi.fn(async () => {
13
+ if (opts?.failStart)
14
+ throw new Error("startStream unavailable");
15
+ counter++;
16
+ const ts = `S${counter}`;
17
+ messages.push({ ts, events: [], stopped: false });
18
+ return ts;
19
+ }),
20
+ appendText: vi.fn(async (ts, md) => {
21
+ messages
22
+ .find((m) => m.ts === ts)
23
+ ?.events.push({ kind: "text", value: md });
24
+ }),
25
+ appendChunks: vi.fn(async (ts, chunks) => {
26
+ if (opts?.failChunks)
27
+ throw new Error("chunks unsupported");
28
+ messages
29
+ .find((m) => m.ts === ts)
30
+ ?.events.push({ kind: "chunks", value: chunks });
31
+ }),
32
+ stopStream: vi.fn(async (ts, blocks) => {
33
+ const m = messages.find((x) => x.ts === ts);
34
+ if (m) {
35
+ m.stopped = true;
36
+ m.stopBlocks = blocks;
37
+ }
38
+ }),
39
+ };
40
+ return { transport, messages };
41
+ }
42
+ /** A legacy fallback sink that records the accumulated text it sees. */
43
+ function makeFakeFallback() {
44
+ let buf = "";
45
+ let finished = false;
46
+ return {
47
+ append(fullText) {
48
+ buf = fullText;
49
+ },
50
+ async finish() {
51
+ finished = true;
52
+ },
53
+ last: () => buf,
54
+ get finished() {
55
+ return finished;
56
+ },
57
+ };
58
+ }
59
+ const textOf = (events) => events
60
+ .filter((e) => e.kind === "text")
61
+ .map((e) => e.value)
62
+ .join("");
63
+ describe("NativeMessageStream", () => {
64
+ it("starts one stream and appends only the deltas, in order", async () => {
65
+ const { transport, messages } = makeFakeTransport();
66
+ const stream = new NativeMessageStream({
67
+ transport,
68
+ fallback: makeFakeFallback,
69
+ minIntervalMs: 0,
70
+ });
71
+ stream.append("A");
72
+ stream.append("AL");
73
+ stream.append("ALPHA");
74
+ await stream.finish();
75
+ expect(transport.startStream).toHaveBeenCalledTimes(1);
76
+ expect(messages).toHaveLength(1);
77
+ expect(textOf(messages[0].events)).toBe("ALPHA");
78
+ expect(messages[0].stopped).toBe(true);
79
+ expect(stream.firstTs).toBe("S1");
80
+ });
81
+ it("never starts a stream when nothing is appended", async () => {
82
+ const { transport, messages } = makeFakeTransport();
83
+ const stream = new NativeMessageStream({
84
+ transport,
85
+ fallback: makeFakeFallback,
86
+ minIntervalMs: 0,
87
+ });
88
+ await stream.finish();
89
+ expect(transport.startStream).not.toHaveBeenCalled();
90
+ expect(messages).toHaveLength(0);
91
+ });
92
+ it("keeps a long reply in ONE message, chunking appends under the 12k per-call cap", async () => {
93
+ const { transport, messages } = makeFakeTransport();
94
+ const stream = new NativeMessageStream({
95
+ transport,
96
+ fallback: makeFakeFallback,
97
+ minIntervalMs: 0,
98
+ });
99
+ const text = "x".repeat(25_000); // > 2× the 12k per-append limit
100
+ stream.append(text);
101
+ await stream.finish();
102
+ // One streamed message (no continuation splitting), finalized.
103
+ expect(messages).toHaveLength(1);
104
+ expect(messages[0].stopped).toBe(true);
105
+ // Reconstructs exactly, and every append is <= 12k chars.
106
+ const appends = messages[0].events.filter((e) => e.kind === "text");
107
+ expect(appends.length).toBeGreaterThan(1);
108
+ for (const a of appends)
109
+ expect(a.value.length).toBeLessThanOrEqual(12_000);
110
+ expect(textOf(messages[0].events)).toBe(text);
111
+ });
112
+ it("appendChunk flushes pending text FIRST, then sends the chunk", async () => {
113
+ const { transport, messages } = makeFakeTransport();
114
+ const stream = new NativeMessageStream({
115
+ transport,
116
+ fallback: makeFakeFallback,
117
+ minIntervalMs: 0,
118
+ });
119
+ stream.append("hello");
120
+ const chunk = {
121
+ type: "task_update",
122
+ id: "t1",
123
+ title: "Using `search`",
124
+ status: "in_progress",
125
+ };
126
+ stream.appendChunk(chunk);
127
+ await stream.finish();
128
+ const events = messages[0].events;
129
+ // Text "hello" must land before the chunk.
130
+ const textIdx = events.findIndex((e) => e.kind === "text");
131
+ const chunkIdx = events.findIndex((e) => e.kind === "chunks");
132
+ expect(textIdx).toBeGreaterThanOrEqual(0);
133
+ expect(chunkIdx).toBeGreaterThan(textIdx);
134
+ expect(events[chunkIdx].value).toEqual([chunk]);
135
+ });
136
+ it("starts the stream when the first thing emitted is a chunk (no text yet)", async () => {
137
+ const { transport, messages } = makeFakeTransport();
138
+ const stream = new NativeMessageStream({
139
+ transport,
140
+ fallback: makeFakeFallback,
141
+ minIntervalMs: 0,
142
+ });
143
+ stream.appendChunk({
144
+ type: "task_update",
145
+ id: "t1",
146
+ title: "Using `search`",
147
+ status: "in_progress",
148
+ });
149
+ await stream.finish();
150
+ expect(transport.startStream).toHaveBeenCalledTimes(1);
151
+ expect(messages[0].events[0]?.kind).toBe("chunks");
152
+ });
153
+ it("finish(blocks) finalizes the message carrying trailing blocks", async () => {
154
+ const { transport, messages } = makeFakeTransport();
155
+ const stream = new NativeMessageStream({
156
+ transport,
157
+ fallback: makeFakeFallback,
158
+ minIntervalMs: 0,
159
+ });
160
+ stream.append("done");
161
+ const blocks = [
162
+ { type: "context_actions", elements: [] },
163
+ ];
164
+ await stream.finish(blocks);
165
+ expect(messages[0].stopped).toBe(true);
166
+ expect(messages[0].stopBlocks).toBe(blocks);
167
+ });
168
+ it("falls back to the legacy transport when the first startStream fails", async () => {
169
+ const { transport } = makeFakeTransport({ failStart: true });
170
+ const fallback = makeFakeFallback();
171
+ const onStartFailure = vi.fn();
172
+ const stream = new NativeMessageStream({
173
+ transport,
174
+ fallback: () => fallback,
175
+ onStartFailure,
176
+ minIntervalMs: 0,
177
+ });
178
+ stream.append("hello");
179
+ stream.append("hello world");
180
+ await stream.finish();
181
+ expect(onStartFailure).toHaveBeenCalledTimes(1);
182
+ expect(transport.appendText).not.toHaveBeenCalled();
183
+ expect(fallback.last()).toBe("hello world");
184
+ expect(fallback.finished).toBe(true);
185
+ });
186
+ it("fires onChunkFailure and degrades when a chunk append fails", async () => {
187
+ const { transport, messages } = makeFakeTransport({ failChunks: true });
188
+ const onChunkFailure = vi.fn();
189
+ const stream = new NativeMessageStream({
190
+ transport,
191
+ fallback: makeFakeFallback,
192
+ onChunkFailure,
193
+ minIntervalMs: 0,
194
+ });
195
+ stream.append("text");
196
+ stream.appendChunk({
197
+ type: "task_update",
198
+ id: "t1",
199
+ title: "Using `x`",
200
+ status: "in_progress",
201
+ });
202
+ // A second chunk after the failure must NOT retry (chunks disabled).
203
+ stream.appendChunk({
204
+ type: "task_update",
205
+ id: "t1",
206
+ title: "Used `x`",
207
+ status: "complete",
208
+ });
209
+ await stream.finish();
210
+ expect(onChunkFailure).toHaveBeenCalledTimes(1);
211
+ // Text still streamed and the message finalized — degradation, not failure.
212
+ expect(textOf(messages[0].events)).toBe("text");
213
+ expect(messages[0].stopped).toBe(true);
214
+ });
215
+ it("appendChunk on an already-failed-over (legacy) stream fires onChunkFailure once, no-op", async () => {
216
+ const { transport } = makeFakeTransport({ failStart: true });
217
+ const onChunkFailure = vi.fn();
218
+ const stream = new NativeMessageStream({
219
+ transport,
220
+ fallback: makeFakeFallback,
221
+ onChunkFailure,
222
+ minIntervalMs: 0,
223
+ });
224
+ stream.append("hi");
225
+ await stream.finish(); // triggers failover to legacy
226
+ stream.appendChunk({
227
+ type: "task_update",
228
+ id: "t1",
229
+ title: "x",
230
+ status: "in_progress",
231
+ });
232
+ expect(onChunkFailure).toHaveBeenCalledTimes(1);
233
+ expect(transport.appendChunks).not.toHaveBeenCalled();
234
+ });
235
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=reactions.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reactions.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/reactions.test.ts"],"names":[],"mappings":""}