@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.
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/dist/__tests__/assistant.test.d.ts +2 -0
- package/dist/__tests__/assistant.test.d.ts.map +1 -0
- package/dist/__tests__/assistant.test.js +153 -0
- package/dist/__tests__/auto-close-streaming.test.d.ts +2 -0
- package/dist/__tests__/auto-close-streaming.test.d.ts.map +1 -0
- package/dist/__tests__/auto-close-streaming.test.js +139 -0
- package/dist/__tests__/built-ins.test.d.ts +2 -0
- package/dist/__tests__/built-ins.test.d.ts.map +1 -0
- package/dist/__tests__/built-ins.test.js +67 -0
- package/dist/__tests__/chunked-message-stream.test.d.ts +2 -0
- package/dist/__tests__/chunked-message-stream.test.d.ts.map +1 -0
- package/dist/__tests__/chunked-message-stream.test.js +226 -0
- package/dist/__tests__/conversation-store.test.d.ts +2 -0
- package/dist/__tests__/conversation-store.test.d.ts.map +1 -0
- package/dist/__tests__/conversation-store.test.js +209 -0
- package/dist/__tests__/download-files.test.d.ts +2 -0
- package/dist/__tests__/download-files.test.d.ts.map +1 -0
- package/dist/__tests__/download-files.test.js +142 -0
- package/dist/__tests__/ephemeral.test.d.ts +2 -0
- package/dist/__tests__/ephemeral.test.d.ts.map +1 -0
- package/dist/__tests__/ephemeral.test.js +85 -0
- package/dist/__tests__/event-renderer.test.d.ts +2 -0
- package/dist/__tests__/event-renderer.test.d.ts.map +1 -0
- package/dist/__tests__/event-renderer.test.js +521 -0
- package/dist/__tests__/markdown-to-mrkdwn.test.d.ts +2 -0
- package/dist/__tests__/markdown-to-mrkdwn.test.d.ts.map +1 -0
- package/dist/__tests__/markdown-to-mrkdwn.test.js +80 -0
- package/dist/__tests__/message-stream.test.d.ts +2 -0
- package/dist/__tests__/message-stream.test.d.ts.map +1 -0
- package/dist/__tests__/message-stream.test.js +116 -0
- package/dist/__tests__/native-renderer.test.d.ts +2 -0
- package/dist/__tests__/native-renderer.test.d.ts.map +1 -0
- package/dist/__tests__/native-renderer.test.js +236 -0
- package/dist/__tests__/native-stream.test.d.ts +2 -0
- package/dist/__tests__/native-stream.test.d.ts.map +1 -0
- package/dist/__tests__/native-stream.test.js +235 -0
- package/dist/__tests__/reactions.test.d.ts +2 -0
- package/dist/__tests__/reactions.test.d.ts.map +1 -0
- package/dist/__tests__/reactions.test.js +193 -0
- package/dist/__tests__/slack-listener.test.d.ts +2 -0
- package/dist/__tests__/slack-listener.test.d.ts.map +1 -0
- package/dist/__tests__/slack-listener.test.js +559 -0
- package/dist/__tests__/views.test.d.ts +2 -0
- package/dist/__tests__/views.test.d.ts.map +1 -0
- package/dist/__tests__/views.test.js +143 -0
- package/dist/adapter.d.ts +188 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +859 -0
- package/dist/adapter.test.d.ts +2 -0
- package/dist/adapter.test.d.ts.map +1 -0
- package/dist/adapter.test.js +368 -0
- package/dist/assistant.d.ts +19 -0
- package/dist/assistant.d.ts.map +1 -0
- package/dist/assistant.js +124 -0
- package/dist/auto-close-streaming.d.ts +62 -0
- package/dist/auto-close-streaming.d.ts.map +1 -0
- package/dist/auto-close-streaming.js +273 -0
- package/dist/built-in-context.d.ts +22 -0
- package/dist/built-in-context.d.ts.map +1 -0
- package/dist/built-in-context.js +65 -0
- package/dist/built-in-tools.d.ts +23 -0
- package/dist/built-in-tools.d.ts.map +1 -0
- package/dist/built-in-tools.js +45 -0
- package/dist/chunked-message-stream.d.ts +67 -0
- package/dist/chunked-message-stream.d.ts.map +1 -0
- package/dist/chunked-message-stream.js +134 -0
- package/dist/codec.d.ts +18 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +18 -0
- package/dist/codec.test.d.ts +2 -0
- package/dist/codec.test.d.ts.map +1 -0
- package/dist/codec.test.js +19 -0
- package/dist/conversation-store.d.ts +92 -0
- package/dist/conversation-store.d.ts.map +1 -0
- package/dist/conversation-store.js +196 -0
- package/dist/download-files.d.ts +59 -0
- package/dist/download-files.d.ts.map +1 -0
- package/dist/download-files.js +116 -0
- package/dist/event-renderer.d.ts +98 -0
- package/dist/event-renderer.d.ts.map +1 -0
- package/dist/event-renderer.js +493 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/ingress-normalize.d.ts +75 -0
- package/dist/ingress-normalize.d.ts.map +1 -0
- package/dist/ingress-normalize.js +153 -0
- package/dist/ingress-normalize.test.d.ts +2 -0
- package/dist/ingress-normalize.test.d.ts.map +1 -0
- package/dist/ingress-normalize.test.js +187 -0
- package/dist/interaction.d.ts +30 -0
- package/dist/interaction.d.ts.map +1 -0
- package/dist/interaction.js +219 -0
- package/dist/interaction.test.d.ts +2 -0
- package/dist/interaction.test.d.ts.map +1 -0
- package/dist/interaction.test.js +137 -0
- package/dist/markdown-to-mrkdwn.d.ts +21 -0
- package/dist/markdown-to-mrkdwn.d.ts.map +1 -0
- package/dist/markdown-to-mrkdwn.js +99 -0
- package/dist/message-stream.d.ts +44 -0
- package/dist/message-stream.d.ts.map +1 -0
- package/dist/message-stream.js +70 -0
- package/dist/native-stream.d.ts +131 -0
- package/dist/native-stream.d.ts.map +1 -0
- package/dist/native-stream.js +208 -0
- package/dist/render/block-kit.d.ts +35 -0
- package/dist/render/block-kit.d.ts.map +1 -0
- package/dist/render/block-kit.js +399 -0
- package/dist/render/block-kit.test.d.ts +2 -0
- package/dist/render/block-kit.test.d.ts.map +1 -0
- package/dist/render/block-kit.test.js +289 -0
- package/dist/render/budget.d.ts +24 -0
- package/dist/render/budget.d.ts.map +1 -0
- package/dist/render/budget.js +30 -0
- package/dist/render/budget.test.d.ts +2 -0
- package/dist/render/budget.test.d.ts.map +1 -0
- package/dist/render/budget.test.js +49 -0
- package/dist/render/modal.d.ts +13 -0
- package/dist/render/modal.d.ts.map +1 -0
- package/dist/render/modal.js +108 -0
- package/dist/render/transport.d.ts +42 -0
- package/dist/render/transport.d.ts.map +1 -0
- package/dist/render/transport.js +1 -0
- package/dist/render.d.ts +23 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +20 -0
- package/dist/sanitizing-http-agent.d.ts +28 -0
- package/dist/sanitizing-http-agent.d.ts.map +1 -0
- package/dist/sanitizing-http-agent.js +51 -0
- package/dist/slack-listener.d.ts +73 -0
- package/dist/slack-listener.d.ts.map +1 -0
- package/dist/slack-listener.js +126 -0
- package/dist/slack-listener.test.d.ts +2 -0
- package/dist/slack-listener.test.d.ts.map +1 -0
- package/dist/slack-listener.test.js +115 -0
- package/dist/types.d.ts +153 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +20 -0
- 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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"reactions.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/reactions.test.ts"],"names":[],"mappings":""}
|