Skip to content

Commit 729ba20

Browse files
authored
chore(changelog): defer Feishu release note
1 parent 46e3f4f commit 729ba20

6 files changed

Lines changed: 172 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ Docs: https://docs.openclaw.ai
2323

2424
### Fixes
2525

26-
- **Feishu Drive folder pagination:** accept continuation tokens and bounded page sizes for shared-folder listings so agents can retrieve pages beyond the first without forwarding invalid root cursors. (#101249) Thanks @zhangguiping-xydt.
2726
- **Agent wait hard-timeout snapshots:** preserve canonical hard-timeout phase and timestamps when the outer `agent.wait` timer wins the retry-grace race, while leaving queue, draining, and restart-cancelled waits correctable. (#89367) Thanks @Pick-cat.
2827
- **Control UI typed approvals:** send `/approve` commands immediately through the authorized Gateway command path while an agent run is blocked instead of queueing the command behind that run. (#77672) Thanks @vincentkoc.
2928
- **Microsoft Teams Graph response bounds:** cap successful file-upload and chat JSON reads so oversized Microsoft Graph responses cannot be buffered without limit. (#97784) Thanks @Alix-007.
@@ -138,6 +137,7 @@ Docs: https://docs.openclaw.ai
138137
- **iOS Watch replies:** persist queued quick replies in the gateway-scoped chat outbox and submit them through idempotent chat delivery, preventing losses, duplicates, and cross-gateway sends after reconnects. (#100031) Thanks @NianJiuZst.
139138
- **iOS Gateway auth retry:** restrict stored device-token retry to parsed loopback hosts and reject wildcard bind addresses, preventing remote lookalike hostnames from receiving trusted retry credentials. (#99859) Thanks @ly85206559.
140139
- **Bedrock Mantle discovery:** bound model-catalog fetch time and response size, and release rejected response bodies so stalled, oversized, or failed provider responses fall back safely. (#99961) Thanks @zhangguiping-xydt.
140+
- **Discord thread-title prompts:** truncate generated-title message and channel context on UTF-16 boundaries so emoji cannot leave malformed model prompt text. (#101551) Thanks @Alix-007.
141141

142142
## 2026.7.1
143143

apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatLinkPreview.kt

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ internal const val LINK_PREVIEW_IMAGE_MAX_DIMENSION = 600
3838
private const val LINK_PREVIEW_MAX_REDIRECTS = 3
3939
private const val LINK_PREVIEW_TIMEOUT_MILLIS = 6_000L
4040
private const val LINK_PREVIEW_CACHE_ENTRIES = 64
41+
private const val LINK_PREVIEW_IMAGE_CACHE_MAX_BYTES = 8 * 1024 * 1024
4142
private const val LINK_PREVIEW_IMAGE_CACHE_ENTRIES = 32
4243
private const val LINK_PREVIEW_ACCEPT = "text/html, application/xhtml+xml;q=0.9"
4344
private const val LINK_PREVIEW_IMAGE_ACCEPT = "image/*"
@@ -324,9 +325,22 @@ internal class LinkPreviewStore(
324325

325326
internal class LinkPreviewImageStore(
326327
private val fetcher: suspend (String) -> LinkPreviewImageResult,
327-
maxEntries: Int = LINK_PREVIEW_IMAGE_CACHE_ENTRIES,
328+
maxBytes: Int = LINK_PREVIEW_IMAGE_CACHE_MAX_BYTES,
328329
) {
329-
private val cache = LruCache<String, LinkPreviewImageResult>(maxEntries)
330+
// Every result pays at least one entry share, preserving the entry cap while loaded bitmaps
331+
// also pay their full backing allocation.
332+
private val minimumResultBytes = max(1, maxBytes / LINK_PREVIEW_IMAGE_CACHE_ENTRIES)
333+
private val cache =
334+
object : LruCache<String, LinkPreviewImageResult>(maxBytes) {
335+
override fun sizeOf(
336+
key: String,
337+
value: LinkPreviewImageResult,
338+
): Int =
339+
when (value) {
340+
is LinkPreviewImageResult.Loaded -> value.bitmap.allocationByteCount.coerceAtLeast(minimumResultBytes)
341+
LinkPreviewImageResult.Failed -> minimumResultBytes
342+
}
343+
}
330344

331345
suspend fun get(url: String): LinkPreviewImageResult {
332346
cache.get(url)?.let { return it }

apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatLinkPreviewTest.kt

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,84 @@ class ChatLinkPreviewTest {
318318
assertEquals("image/*", server.takeRequest().getHeader("Accept"))
319319
}
320320

321+
@Test
322+
fun imageCacheEvictsLeastRecentlyUsedBitmapByAllocatedBytes() =
323+
runBlocking {
324+
val first = Bitmap.createBitmap(20, 20, Bitmap.Config.ARGB_8888)
325+
val second = Bitmap.createBitmap(20, 20, Bitmap.Config.ARGB_8888)
326+
val fetchCounts = mutableMapOf<String, Int>()
327+
val store =
328+
LinkPreviewImageStore(
329+
fetcher = { url ->
330+
fetchCounts[url] = fetchCounts.getOrDefault(url, 0) + 1
331+
LinkPreviewImageResult.Loaded(if (url == "first") first else second)
332+
},
333+
maxBytes = first.allocationByteCount,
334+
)
335+
336+
try {
337+
assertTrue(store.get("first") is LinkPreviewImageResult.Loaded)
338+
assertTrue(store.get("second") is LinkPreviewImageResult.Loaded)
339+
assertTrue(store.get("first") is LinkPreviewImageResult.Loaded)
340+
341+
assertEquals(2, fetchCounts["first"])
342+
assertEquals(1, fetchCounts["second"])
343+
} finally {
344+
first.recycle()
345+
second.recycle()
346+
}
347+
}
348+
349+
@Test
350+
fun imageCacheBoundsNegativeResults() =
351+
runBlocking {
352+
val fetchCounts = mutableMapOf<String, Int>()
353+
val store =
354+
LinkPreviewImageStore(
355+
fetcher = { url ->
356+
fetchCounts[url] = fetchCounts.getOrDefault(url, 0) + 1
357+
LinkPreviewImageResult.Failed
358+
},
359+
maxBytes = 2,
360+
)
361+
362+
assertSame(LinkPreviewImageResult.Failed, store.get("first"))
363+
assertSame(LinkPreviewImageResult.Failed, store.get("second"))
364+
assertSame(LinkPreviewImageResult.Failed, store.get("third"))
365+
assertSame(LinkPreviewImageResult.Failed, store.get("first"))
366+
367+
assertEquals(2, fetchCounts["first"])
368+
assertEquals(1, fetchCounts["second"])
369+
assertEquals(1, fetchCounts["third"])
370+
}
371+
372+
@Test
373+
fun imageCacheBoundsTinyLoadedResultsByEntryCount() =
374+
runBlocking {
375+
val tiny = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
376+
val maxEntries = 32
377+
val fetchCounts = mutableMapOf<String, Int>()
378+
val store =
379+
LinkPreviewImageStore(
380+
fetcher = { url ->
381+
fetchCounts[url] = fetchCounts.getOrDefault(url, 0) + 1
382+
LinkPreviewImageResult.Loaded(tiny)
383+
},
384+
maxBytes = tiny.allocationByteCount * maxEntries * 2,
385+
)
386+
387+
try {
388+
repeat(maxEntries + 1) { index ->
389+
assertTrue(store.get("image-$index") is LinkPreviewImageResult.Loaded)
390+
}
391+
assertTrue(store.get("image-0") is LinkPreviewImageResult.Loaded)
392+
393+
assertEquals(2, fetchCounts["image-0"])
394+
} finally {
395+
tiny.recycle()
396+
}
397+
}
398+
321399
@Test
322400
fun imageContentTypeAllowlistAndBodyCapAreEnforced() =
323401
withServer { server ->

src/gateway/mcp-http.handlers.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Implements initialize, tools/list, tools/call, and notification handling.
33
import crypto from "node:crypto";
44
import { ContentBlockSchema, type ContentBlock } from "@modelcontextprotocol/sdk/types.js";
5+
import { isRecord } from "@openclaw/normalization-core/record-coerce";
56
import { runBeforeToolCallHook, type HookContext } from "../agents/agent-tools.before-tool-call.js";
67
import {
78
formatToolExecutionErrorMessage,
@@ -99,7 +100,11 @@ export async function handleMcpJsonRpc(params: {
99100
return jsonRpcResult(id, { tools: params.toolSchema });
100101
case "tools/call": {
101102
const toolName = typeof methodParams?.name === "string" ? methodParams.name.trim() : "";
102-
const toolArgs = (methodParams?.arguments ?? {}) as Record<string, unknown>;
103+
const rawToolArgs = methodParams?.arguments;
104+
if (rawToolArgs !== undefined && !isRecord(rawToolArgs)) {
105+
return jsonRpcError(id, -32602, "Invalid params: tools/call arguments must be an object");
106+
}
107+
const toolArgs = rawToolArgs ?? {};
103108
if (!toolName) {
104109
return jsonRpcResult(id, {
105110
content: [{ type: "text", text: "Tool not available: unknown" }],

src/gateway/mcp-http.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1153,21 +1153,88 @@ describe("mcp loopback server", () => {
11531153
});
11541154

11551155
it("executes tools for loopback callers", async () => {
1156-
const cronExecute = vi.fn(async () => ({
1156+
const cronExecute = vi.fn<MockGatewayTool["execute"]>(async () => ({
11571157
content: [{ type: "text", text: "CRON_EXECUTED" }],
11581158
}));
1159+
const args = { action: "status" };
11591160
mockScopedTools([makeMessageTool(), makeCronTool({ execute: cronExecute })]);
11601161
const { runtime } = await startLoopbackServerForTest();
11611162

11621163
const payload = await callMainSessionTool({
11631164
token: runtime?.ownerToken,
11641165
name: "cron",
1166+
args,
11651167
});
11661168

11671169
expect(cronExecute).toHaveBeenCalledTimes(1);
1170+
expect(getBeforeToolCallHookInput(0).params).toEqual(args);
1171+
expect(cronExecute.mock.calls[0]?.[1]).toEqual(args);
11681172
expectMcpResultText(payload, "CRON_EXECUTED");
11691173
});
11701174

1175+
it.each([
1176+
["null", null],
1177+
["array", []],
1178+
["string", "bad"],
1179+
])("rejects %s tool call arguments before hooks or execution", async (_label, badArguments) => {
1180+
const execute = vi.fn<MockGatewayTool["execute"]>(async () => ({
1181+
content: [{ type: "text", text: "EXECUTED" }],
1182+
}));
1183+
mockScopedTools([makeMessageTool({ execute })]);
1184+
const { runtime, port } = await startLoopbackServerForTest();
1185+
1186+
const response = await sendRaw({
1187+
port,
1188+
token: runtime.ownerToken,
1189+
headers: jsonHeaders(),
1190+
body: JSON.stringify({
1191+
jsonrpc: "2.0",
1192+
id: 1,
1193+
method: "tools/call",
1194+
params: { name: "message", arguments: badArguments },
1195+
}),
1196+
});
1197+
1198+
expect(response.status).toBe(200);
1199+
expect(await readMcpPayload(response)).toEqual({
1200+
jsonrpc: "2.0",
1201+
id: 1,
1202+
error: {
1203+
code: -32602,
1204+
message: "Invalid params: tools/call arguments must be an object",
1205+
},
1206+
});
1207+
expect(runBeforeToolCallHookMock).not.toHaveBeenCalled();
1208+
expect(execute).not.toHaveBeenCalled();
1209+
});
1210+
1211+
it("keeps omitted tool call arguments as an empty object", async () => {
1212+
const execute = vi.fn<MockGatewayTool["execute"]>(async () => ({
1213+
content: [{ type: "text", text: "EXECUTED" }],
1214+
}));
1215+
mockScopedTools([makeMessageTool({ execute })]);
1216+
const { runtime, port } = await startLoopbackServerForTest();
1217+
1218+
const response = await sendRaw({
1219+
port,
1220+
token: runtime.ownerToken,
1221+
headers: jsonHeaders(),
1222+
body: JSON.stringify({
1223+
jsonrpc: "2.0",
1224+
id: 1,
1225+
method: "tools/call",
1226+
params: { name: "message" },
1227+
}),
1228+
});
1229+
1230+
expect(response.status).toBe(200);
1231+
expectMcpResultText(await readMcpPayload(response), "EXECUTED");
1232+
expect(runBeforeToolCallHookMock).toHaveBeenCalledTimes(1);
1233+
expect(getBeforeToolCallHookInput(0).params).toEqual({});
1234+
expect(execute).toHaveBeenCalledTimes(1);
1235+
expect(execute.mock.calls[0]?.[1]).toEqual({});
1236+
});
1237+
11711238
it("preserves valid MCP content blocks returned by loopback tools", async () => {
11721239
const content = [
11731240
{ type: "text", text: "caption", annotations: { audience: ["user"] } },

src/tui/tui-pty-local.e2e.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht
44
import { tmpdir } from "node:os";
55
import path from "node:path";
66
import { pathToFileURL } from "node:url";
7-
import { afterEach, describe, expect, it } from "vitest";
7+
import { afterAll, describe, expect, it } from "vitest";
88
import { createOpenClawTestInstance } from "../../test/helpers/openclaw-test-instance.js";
99
import type { OpenClawConfig } from "../config/types.openclaw.js";
1010
import { GatewayChatClient } from "./gateway-chat.js";
@@ -450,8 +450,8 @@ async function startGatewayModeTui(params: {
450450
}
451451
}
452452

453-
describe("TUI PTY real backends", () => {
454-
afterEach(async () => {
453+
describe.concurrent("TUI PTY real backends", () => {
454+
afterAll(() => {
455455
for (const run of activeRuns.splice(0)) {
456456
run.dispose();
457457
}

0 commit comments

Comments
 (0)