Skip to content

Commit fb0feed

Browse files
committed
Auto-merge upstream openclaw/openclaw
2 parents 649a50b + 2b6b627 commit fb0feed

72 files changed

Lines changed: 3960 additions & 2378 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@ Docs: https://docs.openclaw.ai
1010

1111
### Fixes
1212

13+
- QQBot/media-tags: support HTML entity-encoded angle brackets (`&lt;`/`&gt;`) in media-tag regexes so entity-escaped `<qqimg>` tags from upstream are correctly parsed and normalized. (#60493) Thanks @ylc0919.
1314
- Slack/media: preserve bearer auth across same-origin `files.slack.com` redirects while still stripping it on cross-origin Slack CDN hops, so `url_private_download` image attachments load again. (#62960) Thanks @vincentkoc.
1415
- Control UI: guard stale session-history reloads during fast session switches so the selected session and rendered transcript stay in sync. (#62975) Thanks @scoootscooob.
16+
- Agents/failover: classify Z.ai vendor code `1311` as billing and `1113` as auth, including long wrapped `1311` payloads, so these errors stop falling through to generic failover handling. (#49552) Thanks @1bcMax.
17+
- npm packaging: mirror bundled Slack, Telegram, Discord, and Feishu channel runtime deps at the root and harden published-install verification so fresh installs fail fast on manifest drift instead of missing-module crashes. (#63065) Thanks @scoootscooob.
1518

1619
## 2026.4.8
1720

extensions/discord/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@
6060
"bundle": {
6161
"stageRuntimeDependencies": true
6262
},
63+
"releaseChecks": {
64+
"rootDependencyMirrorAllowlist": [
65+
"@buape/carbon",
66+
"@discordjs/opus",
67+
"https-proxy-agent",
68+
"opusscript"
69+
]
70+
},
6371
"release": {
6472
"publishToClawHub": true,
6573
"publishToNpm": true

extensions/feishu/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@
5151
"bundle": {
5252
"stageRuntimeDependencies": true
5353
},
54+
"releaseChecks": {
55+
"rootDependencyMirrorAllowlist": [
56+
"@larksuiteoapi/node-sdk"
57+
]
58+
},
5459
"release": {
5560
"publishToClawHub": true,
5661
"publishToNpm": true

extensions/qa-channel/src/channel.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ function createMockQaRuntime(): PluginRuntime {
6767
}
6868

6969
describe("qa-channel plugin", () => {
70-
it("roundtrips inbound DM traffic through the qa bus", async () => {
70+
it("roundtrips inbound DM traffic through the qa bus", { timeout: 20_000 }, async () => {
7171
const state = createQaBusState();
7272
const bus = await startQaBusServer({ state });
7373
setQaChannelRuntime(createMockQaRuntime());
@@ -106,7 +106,7 @@ describe("qa-channel plugin", () => {
106106
kind: "message-text",
107107
textIncludes: "qa-echo: hello",
108108
direction: "outbound",
109-
timeoutMs: 5_000,
109+
timeoutMs: 15_000,
110110
});
111111
expect("text" in outbound && outbound.text).toContain("qa-echo: hello");
112112
} finally {

extensions/qa-lab/src/gateway-child.ts

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,20 @@ const QA_MOCK_BLOCKED_ENV_VARS = Object.freeze([
4848
"VOYAGE_API_KEY",
4949
]);
5050

51+
const QA_MOCK_BLOCKED_ENV_KEY_PATTERNS = Object.freeze([
52+
/^DISCORD_/i,
53+
/^TELEGRAM_/i,
54+
/^SLACK_/i,
55+
/^MATRIX_/i,
56+
/^SIGNAL_/i,
57+
/^WHATSAPP_/i,
58+
/^IMESSAGE_/i,
59+
/^ZALO/i,
60+
/^TWILIO_/i,
61+
/^PLIVO_/i,
62+
/^NGROK_/i,
63+
]);
64+
5165
async function getFreePort() {
5266
return await new Promise<number>((resolve, reject) => {
5367
const server = net.createServer();
@@ -71,6 +85,11 @@ export function normalizeQaProviderModeEnv(
7185
for (const key of QA_MOCK_BLOCKED_ENV_VARS) {
7286
delete env[key];
7387
}
88+
for (const key of Object.keys(env)) {
89+
if (QA_MOCK_BLOCKED_ENV_KEY_PATTERNS.some((pattern) => pattern.test(key))) {
90+
delete env[key];
91+
}
92+
}
7493
return env;
7594
}
7695

@@ -250,14 +269,24 @@ async function createQaBundledPluginsDir(params: {
250269
await fs.rm(stagedRoot, { recursive: true, force: true });
251270
await fs.mkdir(stagedRoot, { recursive: true });
252271
const stagedTreeRoot = path.join(stagedRoot, path.basename(sourceTreeRoot));
253-
await fs.cp(sourceTreeRoot, stagedTreeRoot, { recursive: true });
254-
const stagedExtensionsDir = path.join(stagedTreeRoot, "extensions");
255-
for (const entry of await fs.readdir(stagedExtensionsDir, { withFileTypes: true })) {
256-
if (!entry.isDirectory() || params.allowedPluginIds.includes(entry.name)) {
272+
await fs.mkdir(stagedTreeRoot, { recursive: true });
273+
for (const entry of await fs.readdir(sourceTreeRoot, { withFileTypes: true })) {
274+
const sourcePath = path.join(sourceTreeRoot, entry.name);
275+
const targetPath = path.join(stagedTreeRoot, entry.name);
276+
if (entry.name === "extensions") {
277+
await fs.mkdir(targetPath, { recursive: true });
278+
for (const pluginId of params.allowedPluginIds) {
279+
const sourceDir = path.join(sourceRoot, pluginId);
280+
if (!existsSync(sourceDir)) {
281+
throw new Error(`qa bundled plugin not found: ${pluginId} (${sourceDir})`);
282+
}
283+
await fs.cp(sourceDir, path.join(targetPath, pluginId), { recursive: true });
284+
}
257285
continue;
258286
}
259-
await fs.rm(path.join(stagedExtensionsDir, entry.name), { recursive: true, force: true });
287+
await fs.symlink(sourcePath, targetPath);
260288
}
289+
const stagedExtensionsDir = path.join(stagedTreeRoot, "extensions");
261290
return {
262291
bundledPluginsDir: stagedExtensionsDir,
263292
stagedRoot,
@@ -386,8 +415,12 @@ export async function startQaGatewayChild(params: {
386415
controlUiEnabled: params.controlUiEnabled,
387416
});
388417
await fs.writeFile(configPath, `${JSON.stringify(cfg, null, 2)}\n`, "utf8");
389-
const allowedPluginIds = (cfg.plugins?.allow ?? []).filter(
390-
(pluginId): pluginId is string => typeof pluginId === "string" && pluginId.length > 0,
418+
const allowedPluginIds = [...(cfg.plugins?.allow ?? []), "openai"].filter(
419+
(pluginId, index, array): pluginId is string => {
420+
return (
421+
typeof pluginId === "string" && pluginId.length > 0 && array.indexOf(pluginId) === index
422+
);
423+
},
391424
);
392425
const bundledPluginsSourceRoot = resolveQaBundledPluginsSourceRoot(params.repoRoot);
393426
const { bundledPluginsDir, stagedRoot: stagedBundledPluginsRoot } =

extensions/qa-lab/src/mock-openai-server.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,62 @@ describe("qa mock openai server", () => {
169169
]);
170170
});
171171

172+
it("supports exact reply memory prompts and embeddings requests", async () => {
173+
const server = await startQaMockOpenAiServer({
174+
host: "127.0.0.1",
175+
port: 0,
176+
});
177+
cleanups.push(async () => {
178+
await server.stop();
179+
});
180+
181+
const remember = await fetch(`${server.baseUrl}/v1/responses`, {
182+
method: "POST",
183+
headers: {
184+
"content-type": "application/json",
185+
},
186+
body: JSON.stringify({
187+
stream: false,
188+
input: [
189+
{
190+
role: "user",
191+
content: [
192+
{
193+
type: "input_text",
194+
text: "Please remember this fact for later: the QA canary code is ALPHA-7. Reply exactly `Remembered ALPHA-7.` once stored.",
195+
},
196+
],
197+
},
198+
],
199+
}),
200+
});
201+
expect(remember.status).toBe(200);
202+
const rememberPayload = (await remember.json()) as {
203+
output?: Array<{ content?: Array<{ text?: string }> }>;
204+
};
205+
expect(rememberPayload.output?.[0]?.content?.[0]?.text).toBe("Remembered ALPHA-7.");
206+
207+
const embeddings = await fetch(`${server.baseUrl}/v1/embeddings`, {
208+
method: "POST",
209+
headers: {
210+
"content-type": "application/json",
211+
},
212+
body: JSON.stringify({
213+
model: "text-embedding-3-small",
214+
input: ["Project Nebula ORBIT-10", "Project Nebula ORBIT-9"],
215+
}),
216+
});
217+
expect(embeddings.status).toBe(200);
218+
const embeddingPayload = (await embeddings.json()) as {
219+
data?: Array<{ embedding?: number[]; index?: number }>;
220+
model?: string;
221+
};
222+
expect(embeddingPayload.model).toBe("text-embedding-3-small");
223+
expect(embeddingPayload.data).toHaveLength(2);
224+
expect(embeddingPayload.data?.[0]?.index).toBe(0);
225+
expect(embeddingPayload.data?.[0]?.embedding?.length).toBeGreaterThan(0);
226+
});
227+
172228
it("requests non-threaded subagent handoff for QA channel runs", async () => {
173229
const server = await startQaMockOpenAiServer({
174230
host: "127.0.0.1",

extensions/qa-lab/src/mock-openai-server.ts

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,40 @@ function writeSse(res: ServerResponse, events: StreamEvent[]) {
6666
res.end(body);
6767
}
6868

69+
function countApproxTokens(text: string) {
70+
const trimmed = text.trim();
71+
if (!trimmed) {
72+
return 0;
73+
}
74+
return Math.max(1, Math.ceil(trimmed.length / 4));
75+
}
76+
77+
function extractEmbeddingInputTexts(input: unknown): string[] {
78+
if (typeof input === "string") {
79+
return [input];
80+
}
81+
if (Array.isArray(input)) {
82+
return input.flatMap((entry) => extractEmbeddingInputTexts(entry));
83+
}
84+
if (
85+
input &&
86+
typeof input === "object" &&
87+
typeof (input as { text?: unknown }).text === "string"
88+
) {
89+
return [(input as { text: string }).text];
90+
}
91+
return [];
92+
}
93+
94+
function buildDeterministicEmbedding(text: string, dimensions = 16) {
95+
const values = Array.from({ length: dimensions }, () => 0);
96+
for (let index = 0; index < text.length; index += 1) {
97+
values[index % dimensions] += text.charCodeAt(index) / 255;
98+
}
99+
const magnitude = Math.hypot(...values) || 1;
100+
return values.map((value) => Number((value / magnitude).toFixed(8)));
101+
}
102+
69103
function extractLastUserText(input: ResponsesInputItem[]) {
70104
for (let index = input.length - 1; index >= 0; index -= 1) {
71105
const item = input[index];
@@ -286,8 +320,21 @@ function extractOrbitCode(text: string) {
286320
}
287321

288322
function extractExactReplyDirective(text: string) {
289-
const match = /reply with exactly:\s*([^\n]+)/i.exec(text);
290-
return match?.[1]?.trim() || null;
323+
const colonMatch = /reply(?: with)? exactly:\s*([^\n]+)/i.exec(text);
324+
if (colonMatch?.[1]) {
325+
return colonMatch[1].trim();
326+
}
327+
const backtickedMatch = /reply(?: with)? exactly\s+`([^`]+)`/i.exec(text);
328+
return backtickedMatch?.[1]?.trim() || null;
329+
}
330+
331+
function extractExactMarkerDirective(text: string) {
332+
const backtickedMatch = /exact marker:\s*`([^`]+)`/i.exec(text);
333+
if (backtickedMatch?.[1]) {
334+
return backtickedMatch[1].trim();
335+
}
336+
const plainMatch = /exact marker:\s*([^\s`.,;:!?]+(?:-[^\s`.,;:!?]+)*)/i.exec(text);
337+
return plainMatch?.[1]?.trim() || null;
291338
}
292339

293340
function isHeartbeatPrompt(text: string) {
@@ -311,11 +358,15 @@ function buildAssistantText(input: ResponsesInputItem[], body: Record<string, un
311358
const orbitCode = extractOrbitCode(memorySnippet);
312359
const mediaPath = /MEDIA:([^\n]+)/.exec(toolOutput)?.[1]?.trim();
313360
const exactReplyDirective = extractExactReplyDirective(allInputText);
361+
const exactMarkerDirective = extractExactMarkerDirective(allInputText);
314362
const imageInputCount = countImageInputs(input);
315363

316364
if (/what was the qa canary code/i.test(prompt) && rememberedFact) {
317365
return `Protocol note: the QA canary code was ${rememberedFact}.`;
318366
}
367+
if (/remember this fact/i.test(prompt) && exactReplyDirective) {
368+
return exactReplyDirective;
369+
}
319370
if (/remember this fact/i.test(prompt) && rememberedFact) {
320371
return `Protocol note: acknowledged. I will remember ${rememberedFact}.`;
321372
}
@@ -328,6 +379,9 @@ function buildAssistantText(input: ResponsesInputItem[], body: Record<string, un
328379
if (/\bmarker\b/i.test(prompt) && exactReplyDirective) {
329380
return exactReplyDirective;
330381
}
382+
if (/\bmarker\b/i.test(prompt) && exactMarkerDirective) {
383+
return exactMarkerDirective;
384+
}
331385
if (/visible skill marker/i.test(prompt)) {
332386
return "VISIBLE-SKILL-OK";
333387
}
@@ -377,7 +431,8 @@ function buildAssistantText(input: ResponsesInputItem[], body: Record<string, un
377431
return "Protocol note: delegated fanout complete. Alpha=ALPHA-OK. Beta=BETA-OK.";
378432
}
379433
if (toolOutput && (/\bdelegate\b/i.test(prompt) || /subagent handoff/i.test(prompt))) {
380-
return `Protocol note: delegated result acknowledged. The bounded subagent task returned and is folded back into the main thread.`;
434+
const compact = toolOutput.replace(/\s+/g, " ").trim() || "no delegated output";
435+
return `Delegated task:\n- Inspect the QA workspace via a bounded subagent.\nResult:\n- ${compact}\nEvidence:\n- The child result was folded back into the main thread exactly once.`;
381436
}
382437
if (toolOutput && /worked, failed, blocked|worked\/failed\/blocked|follow-up/i.test(prompt)) {
383438
return `Worked:\n- Read seeded QA material.\n- Expanded the report structure.\nFailed:\n- None observed in mock mode.\nBlocked:\n- No live provider evidence in this lane.\nFollow-up:\n- Re-run with a real model for qualitative coverage.`;
@@ -647,6 +702,7 @@ export async function startQaMockOpenAiServer(params?: { host?: string; port?: n
647702
{ id: "gpt-5.4", object: "model" },
648703
{ id: "gpt-5.4-alt", object: "model" },
649704
{ id: "gpt-image-1", object: "model" },
705+
{ id: "text-embedding-3-small", object: "model" },
650706
],
651707
});
652708
return;
@@ -680,6 +736,28 @@ export async function startQaMockOpenAiServer(params?: { host?: string; port?: n
680736
});
681737
return;
682738
}
739+
if (req.method === "POST" && url.pathname === "/v1/embeddings") {
740+
const raw = await readBody(req);
741+
const body = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
742+
const inputs = extractEmbeddingInputTexts(body.input);
743+
writeJson(res, 200, {
744+
object: "list",
745+
data: inputs.map((text, index) => ({
746+
object: "embedding",
747+
index,
748+
embedding: buildDeterministicEmbedding(text),
749+
})),
750+
model:
751+
typeof body.model === "string" && body.model.trim()
752+
? body.model
753+
: "text-embedding-3-small",
754+
usage: {
755+
prompt_tokens: inputs.reduce((sum, text) => sum + countApproxTokens(text), 0),
756+
total_tokens: inputs.reduce((sum, text) => sum + countApproxTokens(text), 0),
757+
},
758+
});
759+
return;
760+
}
683761
if (req.method === "POST" && url.pathname === "/v1/responses") {
684762
const raw = await readBody(req);
685763
const body = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, it } from "vitest";
2+
import { renderQaMarkdownReport } from "./report.js";
3+
4+
describe("renderQaMarkdownReport", () => {
5+
it("renders multiline scenario details in fenced blocks", () => {
6+
const report = renderQaMarkdownReport({
7+
title: "QA",
8+
startedAt: new Date("2026-04-08T10:00:00.000Z"),
9+
finishedAt: new Date("2026-04-08T10:00:02.000Z"),
10+
scenarios: [
11+
{
12+
name: "Character vibes: Gollum improv",
13+
status: "pass",
14+
steps: [
15+
{
16+
name: "records transcript",
17+
status: "pass",
18+
details: "USER Alice: hello\n\nASSISTANT OpenClaw: my precious build",
19+
},
20+
],
21+
},
22+
],
23+
});
24+
25+
expect(report).toContain("```text");
26+
expect(report).toContain("USER Alice: hello");
27+
expect(report).toContain("ASSISTANT OpenClaw: my precious build");
28+
});
29+
});

0 commit comments

Comments
 (0)