Skip to content

Commit e1ebd3d

Browse files
committed
fix(slack): roll expired native streams
1 parent d4c52ca commit e1ebd3d

5 files changed

Lines changed: 456 additions & 95 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@chat-adapter/slack": patch
3+
---
4+
5+
Recover Slack native streams by rolling to a fresh streamed message when Slack expires the current stream.

packages/adapter-slack/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
"dependencies": {
4646
"@chat-adapter/shared": "workspace:*",
4747
"@slack/socket-mode": "^2.0.5",
48-
"@slack/web-api": "^7.14.0",
48+
"@slack/web-api": "^7.17.0",
4949
"chat": "workspace:*"
5050
},
5151
"devDependencies": {

packages/adapter-slack/src/index.test.ts

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ import { createSlackAdapter, SlackAdapter } from "./index";
1717

1818
const FILE_ID_PATTERN = /^file-/;
1919

20+
function createStreamExpiredError(): Error & {
21+
code: string;
22+
data: { error: string; ok: false };
23+
} {
24+
return Object.assign(new Error("message_not_in_streaming_state"), {
25+
code: "slack_webapi_platform_error",
26+
data: { error: "message_not_in_streaming_state", ok: false as const },
27+
});
28+
}
29+
2030
// Mock @slack/socket-mode
2131
const mockSocketStart = vi.fn().mockResolvedValue({});
2232
const mockSocketDisconnect = vi.fn().mockResolvedValue(undefined);
@@ -7977,6 +7987,7 @@ describe("stream with empty threadTs", () => {
79777987
await adapter.stream("slack:D123:1234567890.000000", stream());
79787988

79797989
expect(chatStream).toHaveBeenCalledWith({
7990+
buffer_size: 1,
79807991
channel: "D123",
79817992
thread_ts: "1234567890.000000",
79827993
});
@@ -8061,6 +8072,196 @@ describe("stream with empty threadTs", () => {
80618072
expect.objectContaining({ token: "xoxb-test-token" })
80628073
);
80638074
});
8075+
8076+
it("rolls to a fresh stream and retries only the pending markdown delta", async () => {
8077+
const adapter = createSlackAdapter({
8078+
botToken: "xoxb-test-token",
8079+
signingSecret: "test-signing-secret",
8080+
logger: mockLogger,
8081+
});
8082+
const firstAppend = vi
8083+
.fn()
8084+
.mockResolvedValueOnce({ ok: true })
8085+
.mockRejectedValueOnce(createStreamExpiredError());
8086+
const firstStop = vi.fn();
8087+
const secondAppend = vi.fn().mockResolvedValue({ ok: true });
8088+
const secondStop = vi.fn().mockResolvedValue({
8089+
ok: true,
8090+
ts: "1234567890.222222",
8091+
});
8092+
const chatStream = vi
8093+
.fn()
8094+
.mockReturnValueOnce({
8095+
append: firstAppend,
8096+
stop: firstStop,
8097+
ts: "1234567890.111111",
8098+
})
8099+
.mockReturnValueOnce({
8100+
append: secondAppend,
8101+
stop: secondStop,
8102+
ts: "1234567890.222222",
8103+
});
8104+
mockClientMethod(adapter, "chatStream", chatStream);
8105+
8106+
async function* textStream() {
8107+
yield "first\n";
8108+
yield "second\n";
8109+
}
8110+
8111+
const result = await adapter.stream(
8112+
"slack:C123:1234567890.000000",
8113+
textStream(),
8114+
{
8115+
recipientUserId: "U123",
8116+
recipientTeamId: "T123",
8117+
}
8118+
);
8119+
8120+
expect(chatStream).toHaveBeenCalledTimes(2);
8121+
expect(firstAppend).toHaveBeenNthCalledWith(
8122+
1,
8123+
expect.objectContaining({ markdown_text: "first\n" })
8124+
);
8125+
expect(firstAppend).toHaveBeenNthCalledWith(
8126+
2,
8127+
expect.objectContaining({ markdown_text: "second\n" })
8128+
);
8129+
expect(secondAppend).toHaveBeenCalledTimes(1);
8130+
expect(secondAppend).toHaveBeenCalledWith(
8131+
expect.objectContaining({ markdown_text: "second\n" })
8132+
);
8133+
expect(firstStop).not.toHaveBeenCalled();
8134+
expect(secondStop).toHaveBeenCalledTimes(1);
8135+
expect(result?.id).toBe("1234567890.222222");
8136+
});
8137+
8138+
it("re-seeds the latest structured chunks when rolling streams", async () => {
8139+
const adapter = createSlackAdapter({
8140+
botToken: "xoxb-test-token",
8141+
signingSecret: "test-signing-secret",
8142+
logger: mockLogger,
8143+
});
8144+
const taskChunk = {
8145+
details: "working",
8146+
id: "task-1",
8147+
status: "in_progress" as const,
8148+
title: "Task one",
8149+
type: "task_update" as const,
8150+
};
8151+
const firstAppend = vi
8152+
.fn()
8153+
.mockResolvedValueOnce({ ok: true })
8154+
.mockResolvedValueOnce({ ok: true })
8155+
.mockRejectedValueOnce(createStreamExpiredError());
8156+
const secondAppend = vi.fn().mockResolvedValue({ ok: true });
8157+
const secondStop = vi.fn().mockResolvedValue({
8158+
ok: true,
8159+
ts: "1234567890.222222",
8160+
});
8161+
mockClientMethod(
8162+
adapter,
8163+
"chatStream",
8164+
vi
8165+
.fn()
8166+
.mockReturnValueOnce({
8167+
append: firstAppend,
8168+
stop: vi.fn(),
8169+
ts: "1234567890.111111",
8170+
})
8171+
.mockReturnValueOnce({
8172+
append: secondAppend,
8173+
stop: secondStop,
8174+
ts: "1234567890.222222",
8175+
})
8176+
);
8177+
8178+
async function* chunkStream() {
8179+
yield taskChunk;
8180+
yield "hello\n";
8181+
yield "world\n";
8182+
}
8183+
8184+
await adapter.stream("slack:C123:1234567890.000000", chunkStream(), {
8185+
recipientUserId: "U123",
8186+
recipientTeamId: "T123",
8187+
});
8188+
8189+
expect(secondAppend).toHaveBeenNthCalledWith(
8190+
1,
8191+
expect.objectContaining({ chunks: [taskChunk] })
8192+
);
8193+
expect(secondAppend).toHaveBeenNthCalledWith(
8194+
2,
8195+
expect.objectContaining({ markdown_text: "world\n" })
8196+
);
8197+
});
8198+
8199+
it("does not reject when Slack expires the stream before stop", async () => {
8200+
const adapter = createSlackAdapter({
8201+
botToken: "xoxb-test-token",
8202+
signingSecret: "test-signing-secret",
8203+
logger: mockLogger,
8204+
});
8205+
const append = vi.fn().mockResolvedValue({ ok: true });
8206+
const stop = vi.fn().mockRejectedValue(createStreamExpiredError());
8207+
const update = vi.fn();
8208+
mockClientMethod(
8209+
adapter,
8210+
"chatStream",
8211+
vi.fn().mockReturnValue({
8212+
append,
8213+
stop,
8214+
ts: "1234567890.111111",
8215+
})
8216+
);
8217+
mockClientMethod(adapter, "chat.update", update);
8218+
8219+
async function* textStream() {
8220+
yield "hello\n";
8221+
}
8222+
8223+
const result = await adapter.stream(
8224+
"slack:C123:1234567890.000000",
8225+
textStream(),
8226+
{
8227+
recipientUserId: "U123",
8228+
recipientTeamId: "T123",
8229+
}
8230+
);
8231+
8232+
expect(stop).toHaveBeenCalledTimes(1);
8233+
expect(update).not.toHaveBeenCalled();
8234+
expect(result?.id).toBe("1234567890.111111");
8235+
});
8236+
8237+
it("still rejects non-expiry stream append errors", async () => {
8238+
const adapter = createSlackAdapter({
8239+
botToken: "xoxb-test-token",
8240+
signingSecret: "test-signing-secret",
8241+
logger: mockLogger,
8242+
});
8243+
const append = vi.fn().mockRejectedValue(new Error("boom"));
8244+
mockClientMethod(
8245+
adapter,
8246+
"chatStream",
8247+
vi.fn().mockReturnValue({
8248+
append,
8249+
stop: vi.fn(),
8250+
ts: "1234567890.111111",
8251+
})
8252+
);
8253+
8254+
async function* textStream() {
8255+
yield "hello\n";
8256+
}
8257+
8258+
await expect(
8259+
adapter.stream("slack:C123:1234567890.000000", textStream(), {
8260+
recipientUserId: "U123",
8261+
recipientTeamId: "T123",
8262+
})
8263+
).rejects.toThrow("boom");
8264+
});
80648265
});
80658266

80668267
describe("scheduleMessage with empty threadTs", () => {

0 commit comments

Comments
 (0)