Skip to content

Commit 108bd73

Browse files
authored
Merge branch 'main' into vincentkoc-code/ollama-context-window-unified
2 parents 0575745 + 70a4f25 commit 108bd73

File tree

7 files changed

+228
-386
lines changed

7 files changed

+228
-386
lines changed

CHANGELOG.md

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

1616
### Fixes
1717

18+
- Feishu/Reply media attachments: send Feishu reply `mediaUrl`/`mediaUrls` payloads as attachments alongside text/streamed replies in the reply dispatcher, including legacy fallback when `mediaUrls` is empty. (#28959)
1819
- Feishu/Typing backoff: re-throw Feishu typing add/remove rate-limit and quota errors (`429`, `99991400`, `99991403`) and detect SDK non-throwing backoff responses so the typing keepalive circuit breaker can stop retries instead of looping indefinitely. (#28494)
1920
- Feishu/Probe status caching: cache successful `probeFeishu()` bot-info results for 10 minutes (bounded cache with per-account keying) to reduce repeated status/onboarding probe API calls, while bypassing cache for failures and exceptions. (#28907) Thanks @Glucksberg.
2021
- Feishu/Opus media send type: send `.opus` attachments with `msg_type: "audio"` (instead of `"media"`) so Feishu voice messages deliver correctly while `.mp4` remains `msg_type: "media"` and documents remain `msg_type: "file"`. (#28269) Thanks @Glucksberg.

extensions/feishu/src/reply-dispatcher.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
44
const getFeishuRuntimeMock = vi.hoisted(() => vi.fn());
55
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
66
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
7+
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
78
const createFeishuClientMock = vi.hoisted(() => vi.fn());
89
const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
910
const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn());
@@ -15,6 +16,7 @@ vi.mock("./send.js", () => ({
1516
sendMessageFeishu: sendMessageFeishuMock,
1617
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
1718
}));
19+
vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock }));
1820
vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock }));
1921
vi.mock("./targets.js", () => ({ resolveReceiveIdType: resolveReceiveIdTypeMock }));
2022
vi.mock("./streaming-card.js", () => ({
@@ -41,6 +43,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
4143
beforeEach(() => {
4244
vi.clearAllMocks();
4345
streamingInstances.length = 0;
46+
sendMediaFeishuMock.mockResolvedValue(undefined);
4447

4548
resolveFeishuAccountMock.mockReturnValue({
4649
accountId: "main",
@@ -113,4 +116,74 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
113116
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
114117
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
115118
});
119+
120+
it("sends media-only payloads as attachments", async () => {
121+
createFeishuReplyDispatcher({
122+
cfg: {} as never,
123+
agentId: "agent",
124+
runtime: {} as never,
125+
chatId: "oc_chat",
126+
});
127+
128+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
129+
await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
130+
131+
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
132+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
133+
expect.objectContaining({
134+
to: "oc_chat",
135+
mediaUrl: "https://example.com/a.png",
136+
}),
137+
);
138+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
139+
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
140+
});
141+
142+
it("falls back to legacy mediaUrl when mediaUrls is an empty array", async () => {
143+
createFeishuReplyDispatcher({
144+
cfg: {} as never,
145+
agentId: "agent",
146+
runtime: {} as never,
147+
chatId: "oc_chat",
148+
});
149+
150+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
151+
await options.deliver(
152+
{ text: "caption", mediaUrl: "https://example.com/a.png", mediaUrls: [] },
153+
{ kind: "final" },
154+
);
155+
156+
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
157+
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
158+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
159+
expect.objectContaining({
160+
mediaUrl: "https://example.com/a.png",
161+
}),
162+
);
163+
});
164+
165+
it("sends attachments after streaming final markdown replies", async () => {
166+
createFeishuReplyDispatcher({
167+
cfg: {} as never,
168+
agentId: "agent",
169+
runtime: { log: vi.fn(), error: vi.fn() } as never,
170+
chatId: "oc_chat",
171+
});
172+
173+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
174+
await options.deliver(
175+
{ text: "```ts\nconst x = 1\n```", mediaUrls: ["https://example.com/a.png"] },
176+
{ kind: "final" },
177+
);
178+
179+
expect(streamingInstances).toHaveLength(1);
180+
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
181+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
182+
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
183+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
184+
expect.objectContaining({
185+
mediaUrl: "https://example.com/a.png",
186+
}),
187+
);
188+
});
116189
});

extensions/feishu/src/reply-dispatcher.ts

Lines changed: 69 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from "openclaw/plugin-sdk";
99
import { resolveFeishuAccount } from "./accounts.js";
1010
import { createFeishuClient } from "./client.js";
11+
import { sendMediaFeishu } from "./media.js";
1112
import type { MentionTarget } from "./mention.js";
1213
import { buildMentionedCardContent } from "./mention.js";
1314
import { getFeishuRuntime } from "./runtime.js";
@@ -138,60 +139,83 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
138139
},
139140
deliver: async (payload: ReplyPayload, info) => {
140141
const text = payload.text ?? "";
141-
if (!text.trim()) {
142+
const mediaList =
143+
payload.mediaUrls && payload.mediaUrls.length > 0
144+
? payload.mediaUrls
145+
: payload.mediaUrl
146+
? [payload.mediaUrl]
147+
: [];
148+
const hasText = Boolean(text.trim());
149+
const hasMedia = mediaList.length > 0;
150+
151+
if (!hasText && !hasMedia) {
142152
return;
143153
}
144154

145-
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
155+
if (hasText) {
156+
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
146157

147-
if ((info?.kind === "block" || info?.kind === "final") && streamingEnabled && useCard) {
148-
startStreaming();
149-
if (streamingStartPromise) {
150-
await streamingStartPromise;
158+
if ((info?.kind === "block" || info?.kind === "final") && streamingEnabled && useCard) {
159+
startStreaming();
160+
if (streamingStartPromise) {
161+
await streamingStartPromise;
162+
}
151163
}
152-
}
153164

154-
if (streaming?.isActive()) {
155-
if (info?.kind === "final") {
156-
streamText = text;
157-
await closeStreaming();
165+
if (streaming?.isActive()) {
166+
if (info?.kind === "final") {
167+
streamText = text;
168+
await closeStreaming();
169+
}
170+
// Send media even when streaming handled the text
171+
if (hasMedia) {
172+
for (const mediaUrl of mediaList) {
173+
await sendMediaFeishu({ cfg, to: chatId, mediaUrl, replyToMessageId, accountId });
174+
}
175+
}
176+
return;
158177
}
159-
return;
160-
}
161178

162-
let first = true;
163-
if (useCard) {
164-
for (const chunk of core.channel.text.chunkTextWithMode(
165-
text,
166-
textChunkLimit,
167-
chunkMode,
168-
)) {
169-
await sendMarkdownCardFeishu({
170-
cfg,
171-
to: chatId,
172-
text: chunk,
173-
replyToMessageId,
174-
mentions: first ? mentionTargets : undefined,
175-
accountId,
176-
});
177-
first = false;
179+
let first = true;
180+
if (useCard) {
181+
for (const chunk of core.channel.text.chunkTextWithMode(
182+
text,
183+
textChunkLimit,
184+
chunkMode,
185+
)) {
186+
await sendMarkdownCardFeishu({
187+
cfg,
188+
to: chatId,
189+
text: chunk,
190+
replyToMessageId,
191+
mentions: first ? mentionTargets : undefined,
192+
accountId,
193+
});
194+
first = false;
195+
}
196+
} else {
197+
const converted = core.channel.text.convertMarkdownTables(text, tableMode);
198+
for (const chunk of core.channel.text.chunkTextWithMode(
199+
converted,
200+
textChunkLimit,
201+
chunkMode,
202+
)) {
203+
await sendMessageFeishu({
204+
cfg,
205+
to: chatId,
206+
text: chunk,
207+
replyToMessageId,
208+
mentions: first ? mentionTargets : undefined,
209+
accountId,
210+
});
211+
first = false;
212+
}
178213
}
179-
} else {
180-
const converted = core.channel.text.convertMarkdownTables(text, tableMode);
181-
for (const chunk of core.channel.text.chunkTextWithMode(
182-
converted,
183-
textChunkLimit,
184-
chunkMode,
185-
)) {
186-
await sendMessageFeishu({
187-
cfg,
188-
to: chatId,
189-
text: chunk,
190-
replyToMessageId,
191-
mentions: first ? mentionTargets : undefined,
192-
accountId,
193-
});
194-
first = false;
214+
}
215+
216+
if (hasMedia) {
217+
for (const mediaUrl of mediaList) {
218+
await sendMediaFeishu({ cfg, to: chatId, mediaUrl, replyToMessageId, accountId });
195219
}
196220
}
197221
},

src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
66
import type { SessionEntry } from "../../config/sessions.js";
77
import { loadSessionStore, saveSessionStore } from "../../config/sessions.js";
88
import { onAgentEvent } from "../../infra/agent-events.js";
9+
import { peekSystemEvents, resetSystemEventsForTest } from "../../infra/system-events.js";
910
import type { TemplateContext } from "../templating.js";
1011
import type { FollowupRun, QueueSettings } from "./queue.js";
1112
import { createMockTypingController } from "./test-helpers.js";
@@ -79,6 +80,7 @@ beforeEach(() => {
7980
runCliAgentMock.mockClear();
8081
runWithModelFallbackMock.mockClear();
8182
runtimeErrorMock.mockClear();
83+
resetSystemEventsForTest();
8284

8385
// Default: no provider switch; execute the chosen provider+model.
8486
runWithModelFallbackMock.mockImplementation(
@@ -92,6 +94,7 @@ beforeEach(() => {
9294

9395
afterEach(() => {
9496
vi.useRealTimers();
97+
resetSystemEventsForTest();
9598
});
9699

97100
describe("runReplyAgent onAgentRunStart", () => {
@@ -328,6 +331,8 @@ describe("runReplyAgent auto-compaction token update", () => {
328331
storePath: string;
329332
sessionEntry: Record<string, unknown>;
330333
config?: Record<string, unknown>;
334+
sessionFile?: string;
335+
workspaceDir?: string;
331336
}) {
332337
const typing = createMockTypingController();
333338
const sessionCtx = {
@@ -347,8 +352,8 @@ describe("runReplyAgent auto-compaction token update", () => {
347352
sessionId: "session",
348353
sessionKey: "main",
349354
messageProvider: "whatsapp",
350-
sessionFile: "/tmp/session.jsonl",
351-
workspaceDir: "/tmp",
355+
sessionFile: params.sessionFile ?? "/tmp/session.jsonl",
356+
workspaceDir: params.workspaceDir ?? "/tmp",
352357
config: params.config ?? {},
353358
skillsSnapshot: {},
354359
provider: "anthropic",
@@ -495,6 +500,84 @@ describe("runReplyAgent auto-compaction token update", () => {
495500
// totalTokens should use lastCallUsage (55k), not accumulated (75k)
496501
expect(stored[sessionKey].totalTokens).toBe(55_000);
497502
});
503+
504+
it("does not enqueue legacy post-compaction audit warnings", async () => {
505+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-no-audit-warning-"));
506+
const workspaceDir = path.join(tmp, "workspace");
507+
await fs.mkdir(workspaceDir, { recursive: true });
508+
const sessionFile = path.join(tmp, "session.jsonl");
509+
await fs.writeFile(
510+
sessionFile,
511+
`${JSON.stringify({ type: "message", message: { role: "assistant", content: [] } })}\n`,
512+
"utf-8",
513+
);
514+
515+
const storePath = path.join(tmp, "sessions.json");
516+
const sessionKey = "main";
517+
const sessionEntry = {
518+
sessionId: "session",
519+
updatedAt: Date.now(),
520+
totalTokens: 10_000,
521+
compactionCount: 0,
522+
};
523+
524+
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
525+
526+
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
527+
params.onAgentEvent?.({ stream: "compaction", data: { phase: "start" } });
528+
params.onAgentEvent?.({ stream: "compaction", data: { phase: "end", willRetry: false } });
529+
return {
530+
payloads: [{ text: "done" }],
531+
meta: {
532+
agentMeta: {
533+
usage: { input: 11_000, output: 500, total: 11_500 },
534+
lastCallUsage: { input: 10_500, output: 500, total: 11_000 },
535+
compactionCount: 1,
536+
},
537+
},
538+
};
539+
});
540+
541+
const config = {
542+
agents: { defaults: { compaction: { memoryFlush: { enabled: false } } } },
543+
};
544+
const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({
545+
storePath,
546+
sessionEntry,
547+
config,
548+
sessionFile,
549+
workspaceDir,
550+
});
551+
552+
await runReplyAgent({
553+
commandBody: "hello",
554+
followupRun,
555+
queueKey: "main",
556+
resolvedQueue,
557+
shouldSteer: false,
558+
shouldFollowup: false,
559+
isActive: false,
560+
isStreaming: false,
561+
typing,
562+
sessionCtx,
563+
sessionEntry,
564+
sessionStore: { [sessionKey]: sessionEntry },
565+
sessionKey,
566+
storePath,
567+
defaultModel: "anthropic/claude-opus-4-5",
568+
agentCfgContextTokens: 200_000,
569+
resolvedVerboseLevel: "off",
570+
isNewSession: false,
571+
blockStreamingEnabled: false,
572+
resolvedBlockStreamingBreak: "message_end",
573+
shouldInjectGroupIntro: false,
574+
typingMode: "instant",
575+
});
576+
577+
const queuedSystemEvents = peekSystemEvents(sessionKey);
578+
expect(queuedSystemEvents.some((event) => event.includes("Post-Compaction Audit"))).toBe(false);
579+
expect(queuedSystemEvents.some((event) => event.includes("WORKFLOW_AUTO.md"))).toBe(false);
580+
});
498581
});
499582

500583
describe("runReplyAgent block streaming", () => {

0 commit comments

Comments
 (0)