Skip to content

Commit 4de5449

Browse files
dcramerCodex GPT-5
andauthored
fix(slack): Handle stale history cursors (#218)
Handle stale Slack history cursors Slack conversations.history can return invalid_cursor for expired cursors from earlier tool results. That currently fails the whole tool call and bubbles a raw Slack API failure into the turn. This change maps invalid_cursor into a recoverable tool result that tells the caller to retry from the newest page instead. I initially explored Slack-side auto-linking for bare channel references in the same PR, but Slack's documented auto-parsing knobs broaden mention handling. That change was removed so this PR stays scoped to the verified cursor failure and does not add extra Slack API traffic. Refs JUNIOR-1R --------- Co-authored-by: Codex GPT-5 <noreply@example.com>
1 parent 9b8c96e commit 4de5449

4 files changed

Lines changed: 74 additions & 9 deletions

File tree

packages/junior/src/chat/slack/client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ function mapSlackError(error: unknown): SlackActionError {
236236
return new SlackActionError(message, "invalid_arguments", baseOptions);
237237
}
238238

239+
if (apiError === "invalid_cursor") {
240+
return new SlackActionError(message, "invalid_arguments", baseOptions);
241+
}
242+
239243
if (apiError === "invalid_name") {
240244
return new SlackActionError(message, "invalid_arguments", baseOptions);
241245
}

packages/junior/src/chat/tools/slack/channel-list-messages.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Type } from "@sinclair/typebox";
2+
import { SlackActionError } from "@/chat/slack/client";
23
import { listChannelMessages } from "@/chat/slack/channel";
34
import { tool } from "@/chat/tools/definition";
45
import type { ToolRuntimeContext } from "@/chat/tools/types";
@@ -67,15 +68,30 @@ export function createSlackChannelListMessagesTool(
6768
};
6869
}
6970

70-
const result = await listChannelMessages({
71-
channelId: targetChannelId,
72-
limit: limit ?? 100,
73-
cursor,
74-
oldest,
75-
latest,
76-
inclusive,
77-
maxPages: max_pages,
78-
});
71+
let result;
72+
try {
73+
result = await listChannelMessages({
74+
channelId: targetChannelId,
75+
limit: limit ?? 100,
76+
cursor,
77+
oldest,
78+
latest,
79+
inclusive,
80+
maxPages: max_pages,
81+
});
82+
} catch (error) {
83+
if (
84+
error instanceof SlackActionError &&
85+
error.apiError === "invalid_cursor"
86+
) {
87+
return {
88+
ok: false,
89+
error:
90+
"The supplied Slack history cursor is no longer valid. Retry the lookup without `cursor` to start from the newest page again.",
91+
};
92+
}
93+
throw error;
94+
}
7995

8096
return {
8197
ok: true,

packages/junior/tests/integration/slack-channel-tools.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,33 @@ describe("slack channel tools", () => {
243243
expect(String(historyCalls[1]?.params.limit)).toBe("1");
244244
});
245245

246+
it("returns a recoverable tool error when Slack rejects a stale history cursor", async () => {
247+
queueSlackApiError("conversations.history", {
248+
error: "invalid_cursor",
249+
});
250+
const tool = createSlackChannelListMessagesTool(
251+
createContext("list channel messages"),
252+
);
253+
254+
const result = await executeTool(tool, {
255+
cursor: "expired-cursor",
256+
limit: 10,
257+
});
258+
259+
expect(result).toEqual({
260+
ok: false,
261+
error:
262+
"The supplied Slack history cursor is no longer valid. Retry the lookup without `cursor` to start from the newest page again.",
263+
});
264+
265+
const historyCalls = getCapturedSlackApiCalls("conversations.history");
266+
expect(historyCalls).toHaveLength(1);
267+
expect(historyCalls[0]?.params).toMatchObject({
268+
channel: "C123",
269+
cursor: "expired-cursor",
270+
});
271+
});
272+
246273
it("adds a reaction to the implicitly targeted inbound message", async () => {
247274
queueSlackApiResponse("reactions.add", {
248275
body: reactionsAddOk(),

packages/junior/tests/unit/slack/slack-client-retries.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,24 @@ describe("withSlackRetries", () => {
133133
expect(task).toHaveBeenCalledTimes(1);
134134
});
135135

136+
it("maps invalid_cursor as invalid_arguments while preserving the API error", async () => {
137+
const task = vi.fn<() => Promise<string>>().mockRejectedValue({
138+
data: {
139+
error: "invalid_cursor",
140+
},
141+
message: "An API error occurred: invalid_cursor",
142+
});
143+
144+
await expect(withSlackRetries(task, 3)).rejects.toEqual(
145+
expect.objectContaining<Partial<SlackActionError>>({
146+
name: "SlackActionError",
147+
code: "invalid_arguments",
148+
apiError: "invalid_cursor",
149+
}),
150+
);
151+
expect(task).toHaveBeenCalledTimes(1);
152+
});
153+
136154
it("maps already_reacted as a dedicated Slack action error", async () => {
137155
const task = vi.fn<() => Promise<string>>().mockRejectedValue({
138156
data: {

0 commit comments

Comments
 (0)