diff --git a/docs/help/testing.md b/docs/help/testing.md index d8e777d16d8b..6a7b7b26f457 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -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`) diff --git a/src/gateway/gateway-acp-bind.live.test.ts b/src/gateway/gateway-acp-bind.live.test.ts index 616ddaf81d06..4fb818ec8093 100644 --- a/src/gateway/gateway-acp-bind.live.test.ts +++ b/src/gateway/gateway-acp-bind.live.test.ts @@ -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();