Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
30d7e03
test(e2e): migrate messaging compatible endpoint
cv Jun 12, 2026
fe649c6
test(e2e): address messaging endpoint review
cv Jun 12, 2026
825a2af
test(e2e): harden messaging compatible endpoint checks
cv Jun 12, 2026
9f0d468
test(e2e): tighten messaging endpoint assertions
cv Jun 12, 2026
c316b75
test(e2e): prove messaging endpoint rate-limit source
cv Jun 12, 2026
03fdfc0
test(e2e): narrow messaging endpoint rate-limit skip
cv Jun 12, 2026
2956eb5
test(e2e): fail closed for messaging endpoint migration
cv Jun 12, 2026
20475f1
test(e2e): guard transient provider classifier
cv Jun 12, 2026
c934235
test(e2e): avoid newline args in network policy probes
cv Jun 12, 2026
86b87a7
test(e2e): accept blocked slack fetch errors
cv Jun 12, 2026
bc3b9da
test(e2e): preserve network policy shell quoting
cv Jun 12, 2026
c97fd9d
test(e2e): cover messaging endpoint helpers
cv Jun 12, 2026
c1d0f58
test(e2e): fix network policy preset selector
cv Jun 12, 2026
1a9e586
test(e2e): refine live scenario helpers
cv Jun 12, 2026
e09fde8
chore: merge main into messaging compatible endpoint PR
cv Jun 13, 2026
6bf2bad
Merge remote-tracking branch 'origin/main' into codex/5098-messaging-…
cv Jun 13, 2026
073f89b
Merge branch 'main' into codex/5098-messaging-compatible-endpoint
cv Jun 13, 2026
34a648e
Merge remote-tracking branch 'origin/main' into codex/5098-messaging-…
cv Jun 13, 2026
c6ed241
Merge remote-tracking branch 'origin/main' into codex/5098-messaging-…
cv Jun 13, 2026
eca4368
Merge remote-tracking branch 'origin/main' into codex/5098-messaging-…
cv Jun 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/e2e-vitest-scenarios.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1865,6 +1865,61 @@ jobs:
if-no-files-found: ignore
retention-days: 14

messaging-compatible-endpoint-vitest:
needs: generate-matrix
if: ${{ (inputs.jobs == '' && inputs.scenarios == '') || contains(format(',{0},', inputs.jobs), ',messaging-compatible-endpoint-vitest,') || contains(format(',{0},', inputs.scenarios), ',messaging-compatible-endpoint,') }}
runs-on: ubuntu-latest
timeout-minutes: 45
env:
FREE_STANDING_VITEST_JOB: "1"
FREE_STANDING_SCENARIO_ID: "messaging-compatible-endpoint"
E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/vitest/messaging-compatible-endpoint
NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js
NEMOCLAW_RUN_E2E_SCENARIOS: "1"
NEMOCLAW_SANDBOX_NAME: "e2e-msg-compat"
OPENSHELL_GATEWAY: "nemoclaw"
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.0.0
with:
node-version: 22
cache: npm

- name: Install root dependencies
run: npm ci --ignore-scripts

- name: Build CLI
run: npm run build:cli

- name: Run messaging compatible endpoint live test
# Migrated from test/e2e/test-messaging-compatible-endpoint.sh.
# Preserves the fake OpenAI-compatible endpoint, Telegram messaging
# config, inference.local, OpenClaw agent-turn, and proxy hop-header
# strip boundaries without relying on real messaging/provider secrets.
env:
NEMOCLAW_COMPAT_MOCK_API_KEY: "fake-compatible-key-e2e"
TELEGRAM_ALLOWED_IDS: "123456789"
TELEGRAM_BOT_TOKEN: "test-fake-telegram-token-e2e"
run: |
set -euo pipefail
npx vitest run --project e2e-scenarios-live \
test/e2e-scenario/live/messaging-compatible-endpoint.test.ts \
--silent=false --reporter=default

- name: Upload messaging compatible endpoint artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-vitest-scenarios-messaging-compatible-endpoint
path: e2e-artifacts/vitest/messaging-compatible-endpoint/
include-hidden-files: false
if-no-files-found: ignore
retention-days: 14

messaging-providers-vitest:
needs: generate-matrix
if: ${{ (inputs.jobs == '' && inputs.scenarios == '') || contains(format(',{0},', inputs.jobs), ',messaging-providers-vitest,') || contains(format(',{0},', inputs.scenarios), ',messaging-providers,') }}
Expand Down Expand Up @@ -2653,6 +2708,7 @@ jobs:
sandbox-rebuild-vitest,
state-backup-restore-vitest,
token-rotation-vitest,
messaging-compatible-endpoint-vitest,
messaging-providers-vitest,
launchable-smoke-vitest,
double-onboard-vitest,
Expand Down
199 changes: 199 additions & 0 deletions test/e2e-scenario/live/messaging-compatible-endpoint-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import path from "node:path";

import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts";
import type { HostCliClient } from "../fixtures/clients/host.ts";

const REPO_ROOT = path.resolve(import.meta.dirname, "../../..");
const CLI_ENTRYPOINT = path.join(REPO_ROOT, "bin", "nemoclaw.js");

export function commandEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
return {
...buildAvailabilityProbeEnv(),
...extra,
NEMOCLAW_NON_INTERACTIVE: "1",
NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1",
OPENSHELL_GATEWAY: process.env.OPENSHELL_GATEWAY ?? "nemoclaw",
};
}

async function bestEffort(run: () => Promise<unknown>): Promise<void> {
try {
await run();
} catch {
// Best-effort cleanup mirrors the legacy shell teardown.
// Narrow this once NemoClaw/OpenShell/gateway teardown treats missing
// resources as successful cleanup.
}
}

export async function stopGatewayRuntime(host: HostCliClient, artifactName: string): Promise<void> {
await bestEffort(() =>
host.command(
"bash",
[
"-lc",
[
"set +e",
"openshell forward stop 18789 >/dev/null 2>&1",
"openshell gateway stop -g nemoclaw >/dev/null 2>&1",
'pid_file="$HOME/.local/state/nemoclaw/openshell-docker-gateway/openshell-gateway.pid"',
'if [ -f "$pid_file" ]; then',
' pid="$(tr -d "[:space:]" <"$pid_file" 2>/dev/null || true)"',
' if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then',
' kill "$pid" 2>/dev/null || true',
" for _ in $(seq 1 10); do",
' kill -0 "$pid" 2>/dev/null || break',
" sleep 1",
" done",
' kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null || true',
" fi",
"fi",
'cid="$(docker ps -qf "name=openshell-cluster-nemoclaw" 2>/dev/null | head -1)"',
'if [ -n "$cid" ]; then docker stop "$cid" >/dev/null 2>&1 || true; fi',
"openshell gateway remove nemoclaw >/dev/null 2>&1",
"openshell gateway destroy -g nemoclaw >/dev/null 2>&1",
"exit 0",
].join("\n"),
],
{
artifactName,
env: commandEnv(),
timeoutMs: 90_000,
},
),
);
}

export async function cleanupMessagingState(
host: HostCliClient,
sandboxName: string,
): Promise<void> {
// Endpoint-validation skips can happen before the sandbox exists. Keep
// teardown non-throwing so "Sandbox ... does not exist" stays a normal
// pre-contract cleanup outcome instead of masking the original evidence.
await bestEffort(() =>
host.command("node", [CLI_ENTRYPOINT, sandboxName, "destroy", "--yes"], {
artifactName: `cleanup-nemoclaw-destroy-${sandboxName}`,
env: commandEnv(),
timeoutMs: 120_000,
}),
);
await bestEffort(() =>
host.command("openshell", ["sandbox", "delete", sandboxName], {
artifactName: `cleanup-openshell-sandbox-delete-${sandboxName}`,
env: commandEnv(),
timeoutMs: 60_000,
}),
);
await stopGatewayRuntime(host, "cleanup-openshell-gateway-runtime-nemoclaw");
}

function findJsonObjectEnd(raw: string, start: number): number | null {
let depth = 0;
let inString = false;
let escaped = false;
for (let index = start; index < raw.length; index += 1) {
const char = raw[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
} else if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth === 0) return index + 1;
}
}
return null;
}

export function parseOpenClawAgentText(raw: string): string {
if (!raw.trim()) return "";
const parts: string[] = [];
const visited = new Set<unknown>();
const textKeys = new Set(["text", "content", "reasoning_content"]);
const containerKeys = new Set([
"result",
"payloads",
"payload",
"messages",
"choices",
"response",
"data",
"output",
"outputs",
"items",
"segments",
"delta",
]);

const add = (value: unknown) => {
if (typeof value === "string" && value.trim()) parts.push(value.trim());
};
const collect = (value: unknown) => {
if (visited.has(value)) return;
visited.add(value);
if (typeof value === "string") {
add(value);
return;
}
if (Array.isArray(value)) {
value.forEach(collect);
return;
}
if (!value || typeof value !== "object") return;
const record = value as Record<string, unknown>;
for (const key of textKeys) {
if (key in record) collect(record[key]);
}
const choices = record.choices;
if (Array.isArray(choices)) {
for (const choice of choices) {
if (!choice || typeof choice !== "object") continue;
collect((choice as Record<string, unknown>).message);
collect((choice as Record<string, unknown>).delta);
add((choice as Record<string, unknown>).text);
}
}
for (const key of containerKeys) {
if (key in record) collect(record[key]);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
const collectDoc = (doc: unknown) => {
if (doc && typeof doc === "object" && (doc as Record<string, unknown>).result) {
collect((doc as Record<string, unknown>).result);
} else {
collect(doc);
}
};

try {
collectDoc(JSON.parse(raw));
} catch {
for (const match of raw.matchAll(/{/g)) {
try {
const before = parts.length;
const start = match.index;
const end = findJsonObjectEnd(raw, start);
if (end === null) continue;
collectDoc(JSON.parse(raw.slice(start, end)));
if (parts.length > before) break;
} catch {
// Continue scanning for a later JSON object, matching the legacy parser.
}
}
}
return parts.join("\n");
}
Loading
Loading