Skip to content

Commit c7b2314

Browse files
committed
MS Teams: stabilize thread reply pagination
1 parent 7d7f17e commit c7b2314

3 files changed

Lines changed: 75 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ Docs: https://docs.openclaw.ai
187187

188188
### Fixes
189189

190+
- MS Teams: paginate Graph thread replies and keep the most recent reply context for long Teams conversations. (#69428) Thanks @hkalkoti1.
190191
- Cron/agents: recognize same-target `edit`↔`write` recovery in `isSameToolMutationAction`, so a successful `write` to a path clears an earlier failed `edit` on the same path. Stops cron from reporting fatal failures when an agent self-heals across `edit` and `write`, while preserving same-tool fingerprint matching, blocking different-target writes, and excluding tools (including `apply_patch`) whose real call args do not produce a stable `path` fingerprint segment. Fixes #79024. Thanks @RenzoMXD.
191192
- Gateway/Tailscale: add opt-in `gateway.tailscale.preserveFunnel` so when `tailscale.mode = "serve"` and an externally configured Tailscale Funnel route already covers the gateway port, OpenClaw skips re-applying `tailscale serve` on startup and skips the `resetOnExit` teardown for that run, keeping operator-managed Funnel exposure alive across gateway restarts. Fixes #57241. Thanks @RenzoMXD.
192193
- Agents/compaction: keep the recent tail after manual `/compact` when Pi returns an empty or no-op compaction summary, preventing blank checkpoints from replacing the live context.

extensions/msteams/src/graph-thread.test.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,17 +162,26 @@ describe("fetchThreadReplies", () => {
162162

163163
it("follows pagination and keeps the most recent replies", async () => {
164164
vi.mocked(fetchGraphJson).mockResolvedValueOnce({
165-
value: [{ id: "reply-1" }, { id: "reply-2" }],
165+
value: [
166+
{ id: "reply-newest", createdDateTime: "2026-05-08T12:04:00Z" },
167+
{ id: "reply-older", createdDateTime: "2026-05-08T12:01:00Z" },
168+
],
166169
"@odata.nextLink":
167170
"https://graph.microsoft.com/v1.0/teams/group-1/channels/channel-1/messages/msg-1/replies?$skiptoken=page2",
168171
} as never);
169172
vi.mocked(fetchGraphAbsoluteUrl).mockResolvedValueOnce({
170-
value: [{ id: "reply-3" }, { id: "reply-4" }],
173+
value: [
174+
{ id: "reply-oldest", createdDateTime: "2026-05-08T12:00:00Z" },
175+
{ id: "reply-newer", createdDateTime: "2026-05-08T12:03:00Z" },
176+
],
171177
} as never);
172178

173179
const result = await fetchThreadReplies("tok", "group-1", "channel-1", "msg-1", 2);
174180

175-
expect(result).toEqual([{ id: "reply-3" }, { id: "reply-4" }]);
181+
expect(result).toEqual([
182+
{ id: "reply-newer", createdDateTime: "2026-05-08T12:03:00Z" },
183+
{ id: "reply-newest", createdDateTime: "2026-05-08T12:04:00Z" },
184+
]);
176185
expect(fetchGraphAbsoluteUrl).toHaveBeenCalledWith({
177186
token: "tok",
178187
url: "https://graph.microsoft.com/v1.0/teams/group-1/channels/channel-1/messages/msg-1/replies?$skiptoken=page2",

extensions/msteams/src/graph-thread.ts

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fetchGraphAbsoluteUrl, fetchGraphJson, type GraphPagedResponse } from "./graph.js";
1+
import { fetchGraphAbsoluteUrl, fetchGraphJson } from "./graph.js";
22

33
export type GraphThreadMessage = {
44
id?: string;
@@ -10,6 +10,11 @@ export type GraphThreadMessage = {
1010
createdDateTime?: string;
1111
};
1212

13+
type GraphThreadRepliesResponse = {
14+
value?: GraphThreadMessage[];
15+
"@odata.nextLink"?: string;
16+
};
17+
1318
/** Maximum number of reply pages to follow so thread enrichment stays bounded. */
1419
const THREAD_REPLIES_MAX_PAGES = 10;
1520

@@ -96,10 +101,9 @@ export async function fetchChannelMessage(
96101
/**
97102
* Fetch thread replies for a channel message, ordered chronologically.
98103
*
99-
* Graph returns replies oldest-first and does not support `$orderby`, so this helper
100-
* follows `@odata.nextLink` pagination under a hard page cap, then keeps the most
101-
* recent `limit` replies from the collected window. This favors the newest thread
102-
* context without turning one inbound message into unbounded Graph traffic.
104+
* Graph does not support `$orderby` for replies, so this helper follows
105+
* `@odata.nextLink` pagination under a hard page cap, then keeps the most
106+
* recent `limit` replies by timestamp from the collected window.
103107
*/
104108
export async function fetchThreadReplies(
105109
token: string,
@@ -112,7 +116,7 @@ export async function fetchThreadReplies(
112116
const path = `/teams/${encodeURIComponent(groupId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}/replies?$top=${top}&$select=id,from,body,createdDateTime`;
113117
const replies: GraphThreadMessage[] = [];
114118

115-
let res = await fetchGraphJson<GraphPagedResponse<GraphThreadMessage>>({ token, path });
119+
let res = await fetchGraphJson<GraphThreadRepliesResponse>({ token, path });
116120
let pages = 1;
117121

118122
while (true) {
@@ -124,7 +128,7 @@ export async function fetchThreadReplies(
124128
}
125129

126130
try {
127-
res = await fetchGraphAbsoluteUrl<GraphPagedResponse<GraphThreadMessage>>({
131+
res = await fetchGraphAbsoluteUrl<GraphThreadRepliesResponse>({
128132
token,
129133
url: nextLink,
130134
});
@@ -139,7 +143,57 @@ export async function fetchThreadReplies(
139143
if (replies.length <= top) {
140144
return replies;
141145
}
142-
return replies.slice(-top);
146+
return selectRecentThreadReplies(replies, top);
147+
}
148+
149+
function selectRecentThreadReplies(
150+
replies: GraphThreadMessage[],
151+
limit: number,
152+
): GraphThreadMessage[] {
153+
return replies
154+
.map((reply, index) => ({
155+
reply,
156+
index,
157+
createdAt: parseGraphTimestamp(reply.createdDateTime),
158+
}))
159+
.toSorted(compareRecentReplies)
160+
.slice(0, limit)
161+
.toSorted(compareThreadContextOrder)
162+
.map(({ reply }) => reply);
163+
}
164+
165+
function parseGraphTimestamp(value: string | undefined): number | undefined {
166+
if (!value) {
167+
return undefined;
168+
}
169+
const parsed = Date.parse(value);
170+
return Number.isFinite(parsed) ? parsed : undefined;
171+
}
172+
173+
type RankedThreadReply = {
174+
reply: GraphThreadMessage;
175+
index: number;
176+
createdAt: number | undefined;
177+
};
178+
179+
function compareRecentReplies(a: RankedThreadReply, b: RankedThreadReply): number {
180+
if (a.createdAt !== undefined && b.createdAt !== undefined && a.createdAt !== b.createdAt) {
181+
return b.createdAt - a.createdAt;
182+
}
183+
if (a.createdAt !== undefined && b.createdAt === undefined) {
184+
return -1;
185+
}
186+
if (a.createdAt === undefined && b.createdAt !== undefined) {
187+
return 1;
188+
}
189+
return b.index - a.index;
190+
}
191+
192+
function compareThreadContextOrder(a: RankedThreadReply, b: RankedThreadReply): number {
193+
if (a.createdAt !== undefined && b.createdAt !== undefined && a.createdAt !== b.createdAt) {
194+
return a.createdAt - b.createdAt;
195+
}
196+
return a.index - b.index;
143197
}
144198

145199
/**

0 commit comments

Comments
 (0)