mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 17:00:21 +00:00
fix(qa): stabilize docker gateway bootstrap
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
// Keep this barrel helper-only so plugin-sdk facades do not pull the full
|
||||
// channel plugin (and its runtime state) into tests or other shared surfaces.
|
||||
export { mattermostPlugin } from "./src/channel.js";
|
||||
export { isMattermostSenderAllowed } from "./src/mattermost/monitor-auth.js";
|
||||
|
||||
@@ -18,7 +18,7 @@ export default defineBundledChannelEntry({
|
||||
description: "Mattermost channel plugin",
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./runtime-api.js",
|
||||
specifier: "./api.js",
|
||||
exportName: "mattermostPlugin",
|
||||
},
|
||||
runtime: {
|
||||
|
||||
@@ -57,7 +57,6 @@ export {
|
||||
resolveDmGroupAccessWithLists,
|
||||
resolveEffectiveAllowFromLists,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
export { mattermostPlugin } from "./src/channel.js";
|
||||
export { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
|
||||
export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr
|
||||
export default defineBundledChannelSetupEntry({
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./runtime-api.js",
|
||||
specifier: "./api.js",
|
||||
exportName: "mattermostPlugin",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ describe("qa docker harness", () => {
|
||||
);
|
||||
expect(compose).toContain("OPENCLAW_CONFIG_PATH: /tmp/openclaw/openclaw.json");
|
||||
expect(compose).toContain("OPENCLAW_STATE_DIR: /tmp/openclaw/state");
|
||||
expect(compose).toContain('OPENCLAW_NO_RESPAWN: "1"');
|
||||
|
||||
const envExample = await readFile(path.join(outputDir, ".env.example"), "utf8");
|
||||
expect(envExample).toContain("OPENCLAW_GATEWAY_TOKEN=qa-token");
|
||||
@@ -80,6 +81,9 @@ describe("qa docker harness", () => {
|
||||
"utf8",
|
||||
);
|
||||
expect(kickoff).toContain("Lobster Invaders");
|
||||
|
||||
const readme = await readFile(path.join(outputDir, "README.md"), "utf8");
|
||||
expect(readme).toContain("in-process restarts inside Docker");
|
||||
});
|
||||
|
||||
it("builds the reusable QA image with bundled QA extensions", async () => {
|
||||
|
||||
@@ -117,6 +117,7 @@ ${imageBlock} pull_policy: never
|
||||
environment:
|
||||
OPENCLAW_CONFIG_PATH: /tmp/openclaw/openclaw.json
|
||||
OPENCLAW_STATE_DIR: /tmp/openclaw/state
|
||||
OPENCLAW_NO_RESPAWN: "1"
|
||||
OPENCLAW_SKIP_GMAIL_WATCHER: "1"
|
||||
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1"
|
||||
OPENCLAW_SKIP_CANVAS_HOST: "1"
|
||||
@@ -202,6 +203,8 @@ Gateway:
|
||||
- Mock OpenAI: internal \`http://qa-mock-openai:44080/v1\`
|
||||
|
||||
This scaffold uses localhost Control UI insecure-auth compatibility for QA only.
|
||||
The gateway runs with in-process restarts inside Docker so restart actions do not
|
||||
kill the container by detaching a replacement child.
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,4 +44,44 @@ describe("qa mock openai server", () => {
|
||||
expect(body).toContain('"type":"response.output_item.added"');
|
||||
expect(body).toContain('"name":"read"');
|
||||
});
|
||||
|
||||
it("prefers path-like refs over generic quoted keys in prompts", async () => {
|
||||
const server = await startQaMockOpenAiServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
});
|
||||
cleanups.push(async () => {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
const response = await fetch(`${server.baseUrl}/v1/responses`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
stream: true,
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: 'Please inspect "message_id" metadata first, then read `./QA_KICKOFF_TASK.md`.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.text();
|
||||
expect(body).toContain('"arguments":"{\\"path\\":\\"QA_KICKOFF_TASK.md\\"}"');
|
||||
|
||||
const debugResponse = await fetch(`${server.baseUrl}/debug/last-request`);
|
||||
expect(debugResponse.status).toBe(200);
|
||||
expect(await debugResponse.json()).toMatchObject({
|
||||
prompt: 'Please inspect "message_id" metadata first, then read `./QA_KICKOFF_TASK.md`.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,13 @@ type StreamEvent =
|
||||
};
|
||||
};
|
||||
|
||||
type MockOpenAiRequestSnapshot = {
|
||||
raw: string;
|
||||
body: Record<string, unknown>;
|
||||
prompt: string;
|
||||
toolOutput: string;
|
||||
};
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -84,11 +91,41 @@ function extractToolOutput(input: ResponsesInputItem[]) {
|
||||
return "";
|
||||
}
|
||||
|
||||
function readTargetFromPrompt(prompt: string) {
|
||||
const quoted = /"([^"]+)"/.exec(prompt)?.[1]?.trim();
|
||||
if (quoted) {
|
||||
return quoted;
|
||||
function normalizePromptPathCandidate(candidate: string) {
|
||||
const trimmed = candidate.trim().replace(/^`+|`+$/g, "");
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const normalized = trimmed.replace(/^\.\//, "");
|
||||
if (
|
||||
normalized.includes("/") ||
|
||||
/\.(?:md|json|ts|tsx|js|mjs|cjs|txt|yaml|yml)$/i.test(normalized)
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readTargetFromPrompt(prompt: string) {
|
||||
const backtickedMatches = Array.from(prompt.matchAll(/`([^`]+)`/g))
|
||||
.map((match) => normalizePromptPathCandidate(match[1] ?? ""))
|
||||
.filter((value): value is string => !!value);
|
||||
if (backtickedMatches.length > 0) {
|
||||
return backtickedMatches[0];
|
||||
}
|
||||
|
||||
const quotedMatches = Array.from(prompt.matchAll(/"([^"]+)"/g))
|
||||
.map((match) => normalizePromptPathCandidate(match[1] ?? ""))
|
||||
.filter((value): value is string => !!value);
|
||||
if (quotedMatches.length > 0) {
|
||||
return quotedMatches[0];
|
||||
}
|
||||
|
||||
const repoScoped = /\b(?:repo\/[^\s`",)]+|QA_[A-Z_]+\.md)\b/.exec(prompt)?.[0]?.trim();
|
||||
if (repoScoped) {
|
||||
return repoScoped;
|
||||
}
|
||||
|
||||
if (/\bdocs?\b/i.test(prompt)) {
|
||||
return "repo/docs/help/testing.md";
|
||||
}
|
||||
@@ -203,6 +240,7 @@ function buildResponsesPayload(input: ResponsesInputItem[]) {
|
||||
|
||||
export async function startQaMockOpenAiServer(params?: { host?: string; port?: number }) {
|
||||
const host = params?.host ?? "127.0.0.1";
|
||||
let lastRequest: MockOpenAiRequestSnapshot | null = null;
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (req.method === "GET" && (url.pathname === "/healthz" || url.pathname === "/readyz")) {
|
||||
@@ -218,10 +256,20 @@ export async function startQaMockOpenAiServer(params?: { host?: string; port?: n
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/debug/last-request") {
|
||||
writeJson(res, 200, lastRequest ?? { ok: false, error: "no request recorded" });
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/v1/responses") {
|
||||
const raw = await readBody(req);
|
||||
const body = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
||||
const input = Array.isArray(body.input) ? (body.input as ResponsesInputItem[]) : [];
|
||||
lastRequest = {
|
||||
raw,
|
||||
body,
|
||||
prompt: extractLastUserText(input),
|
||||
toolOutput: extractToolOutput(input),
|
||||
};
|
||||
const events = buildResponsesPayload(input);
|
||||
if (body.stream === false) {
|
||||
const completion = events.at(-1);
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { zaloPlugin } from "./src/channel.js";
|
||||
export * from "./setup-api.js";
|
||||
|
||||
15
extensions/zalo/index.test.ts
Normal file
15
extensions/zalo/index.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import entry from "./index.js";
|
||||
import setupEntry from "./setup-entry.js";
|
||||
|
||||
describe("zalo bundled entries", () => {
|
||||
it("loads the channel plugin without a runtime-barrel cycle", () => {
|
||||
const plugin = entry.loadChannelPlugin();
|
||||
expect(plugin.id).toBe("zalo");
|
||||
});
|
||||
|
||||
it("loads the setup plugin without a runtime-barrel cycle", () => {
|
||||
const plugin = setupEntry.loadSetupPlugin();
|
||||
expect(plugin.id).toBe("zalo");
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ export default defineBundledChannelEntry({
|
||||
description: "Zalo channel plugin",
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./runtime-api.js",
|
||||
specifier: "./api.js",
|
||||
exportName: "zaloPlugin",
|
||||
},
|
||||
runtime: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Private runtime barrel for the bundled Zalo extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export { zaloPlugin } from "./src/channel.js";
|
||||
// Keep this barrel thin and free of local plugin self-imports so the bundled
|
||||
// entry loader can resolve the channel plugin without re-entering this module.
|
||||
export * from "./api.js";
|
||||
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
export type { OpenClawConfig, GroupPolicy } from "openclaw/plugin-sdk/config-runtime";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr
|
||||
export default defineBundledChannelSetupEntry({
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./runtime-api.js",
|
||||
specifier: "./api.js",
|
||||
exportName: "zaloPlugin",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user