test: share live probes with acp bind

This commit is contained in:
Peter Steinberger
2026-04-07 10:34:48 +01:00
parent c2f9de3935
commit 4d4dbe8e15
2 changed files with 148 additions and 1 deletions
+1
View File
@@ -300,6 +300,7 @@ Notes:
- The Docker runner lives at `scripts/test-live-cli-backend-docker.sh`.
- It runs the live CLI-backend smoke inside the repo Docker image as the non-root `node` user.
- It resolves CLI smoke metadata from the owning extension, then installs the matching Linux CLI package (`@anthropic-ai/claude-code`, `@openai/codex`, or `@google/gemini-cli`) into a cached writable prefix at `OPENCLAW_DOCKER_CLI_TOOLS_DIR` (default: `~/.cache/openclaw/docker-cli-tools`).
- The live CLI-backend smoke now exercises the same end-to-end flow for Claude, Codex, and Gemini: text turn, image classification turn, then MCP `cron` tool call verified through the gateway CLI.
## Live: ACP bind smoke (`/acp spawn ... --bind here`)
+147 -1
View File
@@ -17,6 +17,15 @@ import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { sleep } from "../utils.js";
import { GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { GatewayClient } from "./client.js";
import {
assertCronJobMatches,
assertCronJobVisibleViaCli,
assertLiveImageProbeReply,
buildLiveCronProbeMessage,
createLiveCronProbeSpec,
runOpenClawCliJson,
} from "./live-agent-probes.js";
import { renderCatFacePngBase64 } from "./live-image-probe.js";
import { startGatewayServer } from "./server.js";
const LIVE = isLiveTestEnabled();
@@ -330,6 +339,11 @@ async function sendChatAndWait(params: {
originatingChannel: string;
originatingTo: string;
originatingAccountId: string;
attachments?: Array<{
mimeType: string;
fileName: string;
content: string;
}>;
}) {
const started = await params.client.request<{ runId?: string; status?: string }>("chat.send", {
sessionKey: params.sessionKey,
@@ -338,6 +352,7 @@ async function sendChatAndWait(params: {
originatingChannel: params.originatingChannel,
originatingTo: params.originatingTo,
originatingAccountId: params.originatingAccountId,
attachments: params.attachments,
});
if (started?.status !== "started" || typeof started.runId !== "string") {
throw new Error(`chat.send did not start correctly: ${JSON.stringify(started)}`);
@@ -383,6 +398,40 @@ async function waitForAssistantText(params: {
);
}
async function waitForAssistantTurn(params: {
client: GatewayClient;
sessionKey: string;
minAssistantCount: number;
timeoutMs?: number;
}): Promise<{ messages: unknown[]; lastAssistantText: string }> {
const timeoutMs = params.timeoutMs ?? 30_000;
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const history = await params.client.request<{ messages?: unknown[] }>("chat.history", {
sessionKey: params.sessionKey,
limit: 16,
});
const messages = history.messages ?? [];
const assistantTexts = extractAssistantTexts(messages);
const lastAssistantText = assistantTexts.at(-1) ?? null;
if (assistantTexts.length >= params.minAssistantCount && lastAssistantText) {
return { messages, lastAssistantText };
}
await sleep(500);
}
const finalHistory = await params.client.request<{ messages?: unknown[] }>("chat.history", {
sessionKey: params.sessionKey,
limit: 16,
});
throw new Error(
`timed out waiting for assistant turn ${String(params.minAssistantCount)}: ${formatAssistantTextPreview(
extractAssistantTexts(finalHistory.messages ?? []),
)}`,
);
}
describeLive("gateway live (ACP bind)", () => {
it(
"binds a synthetic Slack DM conversation to a live ACP session and reroutes the next turn",
@@ -416,7 +465,7 @@ describeLive("gateway live (ACP bind)", () => {
process.env.OPENCLAW_STATE_DIR = tempStateDir;
process.env.OPENCLAW_SKIP_CHANNELS = "1";
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
process.env.OPENCLAW_SKIP_CRON = "1";
process.env.OPENCLAW_SKIP_CRON = "0";
process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
process.env.OPENCLAW_GATEWAY_TOKEN = token;
process.env.OPENCLAW_GATEWAY_PORT = String(port);
@@ -474,6 +523,11 @@ describeLive("gateway live (ACP bind)", () => {
},
},
},
cron: {
...cfg.cron,
enabled: true,
store: path.join(tempRoot, "cron.json"),
},
};
await fs.writeFile(tempConfigPath, `${JSON.stringify(nextCfg, null, 2)}\n`);
process.env.OPENCLAW_CONFIG_PATH = tempConfigPath;
@@ -572,6 +626,98 @@ describeLive("gateway live (ACP bind)", () => {
expect(assistantTexts.join("\n\n")).toContain(`ACP-BIND-${followupNonce}`);
expect(lastAssistantText).toContain(`ACP-BIND-MEMORY-${memoryNonce}`);
logLiveStep("bound session transcript contains the final marker token");
const markerAssistantCount = assistantTexts.length;
await sendChatAndWait({
client,
sessionKey: originalSessionKey,
idempotencyKey: `idem-image-${randomUUID()}`,
message:
"Best match for the attached image: lobster, mouse, cat, horse. " +
"Reply with one lowercase word only.",
originatingChannel: "slack",
originatingTo: conversationId,
originatingAccountId: accountId,
attachments: [
{
mimeType: "image/png",
fileName: `probe-${randomUUID()}.png`,
content: renderCatFacePngBase64(),
},
],
});
logLiveStep("image turn completed");
const imageHistory = await waitForAssistantTurn({
client,
sessionKey: spawnedSessionKey,
minAssistantCount: markerAssistantCount + 1,
timeoutMs: 60_000,
});
assertLiveImageProbeReply(imageHistory.lastAssistantText);
logLiveStep("bound session classified the probe image");
const imageAssistantCount = extractAssistantTexts(imageHistory.messages).length;
const cronProbe = createLiveCronProbeSpec();
let cronJobId: string | undefined;
for (let attempt = 0; attempt < 2; attempt += 1) {
await sendChatAndWait({
client,
sessionKey: originalSessionKey,
idempotencyKey: `idem-cron-${attempt}-${randomUUID()}`,
message: buildLiveCronProbeMessage({
agent: liveAgent,
argsJson: cronProbe.argsJson,
attempt,
exactReply: cronProbe.name,
}),
originatingChannel: "slack",
originatingTo: conversationId,
originatingAccountId: accountId,
});
logLiveStep(`cron mcp turn completed (attempt ${String(attempt + 1)})`);
const cronHistory = await waitForAssistantTurn({
client,
sessionKey: spawnedSessionKey,
minAssistantCount: imageAssistantCount + attempt + 1,
timeoutMs: 90_000,
});
const createdJob = await assertCronJobVisibleViaCli({
port,
token,
env: process.env,
expectedName: cronProbe.name,
expectedMessage: cronProbe.message,
});
if (createdJob) {
assertCronJobMatches({
job: createdJob,
expectedName: cronProbe.name,
expectedMessage: cronProbe.message,
expectedSessionKey: spawnedSessionKey,
expectedAgentId: liveAgent,
});
cronJobId = createdJob.id;
expect(cronHistory.lastAssistantText.trim().length).toBeGreaterThan(0);
break;
}
if (attempt === 1) {
throw new Error(
`acp cron cli verify could not find job ${cronProbe.name}: reply=${JSON.stringify(
cronHistory.lastAssistantText,
)}`,
);
}
}
if (!cronJobId) {
throw new Error(`acp cron cli verify did not create job ${cronProbe.name}`);
}
await runOpenClawCliJson(
["cron", "rm", cronJobId, "--json", "--url", `ws://127.0.0.1:${port}`, "--token", token],
process.env,
);
logLiveStep("bound session created cron via MCP and CLI verification passed");
} finally {
releasePinnedPluginChannelRegistry(channelRegistry);
clearRuntimeConfigSnapshot();