Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
48 changes: 1 addition & 47 deletions .github/workflows/regression-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ on:
jobs:
description: >-
Comma-separated regression job names to run (empty = all).
Valid: dashboard-remote-bind-e2e,gateway-health-honest-e2e,gateway-drift-preflight-e2e,openshell-version-pin-e2e,onboard-inference-smoke-e2e,strict-tool-call-probe-e2e,model-router-provider-routed-inference-e2e,openclaw-plugin-runtime-exdev-e2e,whatsapp-qr-compact-e2e
Valid: dashboard-remote-bind-e2e,gateway-health-honest-e2e,gateway-drift-preflight-e2e,openshell-version-pin-e2e,onboard-inference-smoke-e2e,model-router-provider-routed-inference-e2e,openclaw-plugin-runtime-exdev-e2e,whatsapp-qr-compact-e2e
required: false
type: string
default: ""
Expand Down Expand Up @@ -49,7 +49,6 @@ jobs:
gateway_drift_preflight: ${{ steps.select.outputs.gateway_drift_preflight }}
openshell_version_pin: ${{ steps.select.outputs.openshell_version_pin }}
onboard_inference_smoke: ${{ steps.select.outputs.onboard_inference_smoke }}
strict_tool_call_probe: ${{ steps.select.outputs.strict_tool_call_probe }}
model_router_provider_routed_inference: ${{ steps.select.outputs.model_router_provider_routed_inference }}
openclaw_plugin_runtime_exdev: ${{ steps.select.outputs.openclaw_plugin_runtime_exdev }}
whatsapp_qr_compact: ${{ steps.select.outputs.whatsapp_qr_compact }}
Expand Down Expand Up @@ -98,12 +97,6 @@ jobs:
echo "onboard_inference_smoke=false" >> "$GITHUB_OUTPUT"
fi

if [ -z "$normalized" ] || includes_job "strict-tool-call-probe-e2e"; then
echo "strict_tool_call_probe=true" >> "$GITHUB_OUTPUT"
else
echo "strict_tool_call_probe=false" >> "$GITHUB_OUTPUT"
fi

if [ -z "$normalized" ] || includes_job "model-router-provider-routed-inference-e2e"; then
echo "model_router_provider_routed_inference=true" >> "$GITHUB_OUTPUT"
else
Expand Down Expand Up @@ -261,45 +254,6 @@ jobs:
path: /tmp/nemoclaw-traces/
if-no-files-found: ignore

# ── Strict Chat Completions tool-call probe E2E ──────────────
# Coverage guard for #4537. Local Ollama onboarding requires structured
# Chat Completions tool calls; this hermetic mock asserts the bounded
# validation payload and retry behavior without GPU/Ollama infrastructure.
strict-tool-call-probe-e2e:
needs: select_regression_jobs
if: >-
github.repository == 'NVIDIA/NemoClaw' &&
needs.select_regression_jobs.outputs.strict_tool_call_probe == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: "22"

- name: Run strict tool-call probe E2E test
env:
NEMOCLAW_TEST_NO_SLEEP: "1"
run: bash test/e2e/test-strict-tool-call-probe.sh

- name: Upload strict tool-call probe logs on failure
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: strict-tool-call-probe-logs
path: |
/tmp/nemoclaw-e2e-strict-tool-call-probe.log
/tmp/nemoclaw-e2e-strict-tool-call-probe-node.log
if-no-files-found: ignore

# ── Gateway drift preflight E2E ─────────────────────────────
# Coverage guard for #3399 / #3423. A stale OpenShell gateway image can
# make sandbox-state RPCs fail with protobuf invalid-wire decode errors.
Expand Down
1 change: 0 additions & 1 deletion test/e2e-script-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ const LEGACY_E2E_SHELL_ALLOWLIST = [
"test/e2e/test-snapshot-commands.sh",
"test/e2e/test-spark-install.sh",
"test/e2e/test-state-backup-restore.sh",
"test/e2e/test-strict-tool-call-probe.sh",
"test/e2e/test-telegram-injection.sh",
"test/e2e/test-token-rotation.sh",
"test/e2e/test-tunnel-lifecycle.sh",
Expand Down
108 changes: 45 additions & 63 deletions test/e2e/test-strict-tool-call-probe.sh → ...fixtures/strict-tool-call-probe-driver.ts
100755 → 100644
Original file line number Diff line number Diff line change
@@ -1,60 +1,46 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Coverage guard for #4537. The Local Ollama onboarding path is the only
# current caller that requires strict Chat Completions tool calls. This
# hermetic E2E exercises that validation path against an OpenAI-compatible
# mock endpoint so payload-shape and retry regressions do not require a GPU
# Ollama runner to catch.

set -euo pipefail

LOG_FILE="/tmp/nemoclaw-e2e-strict-tool-call-probe.log"
exec > >(tee "$LOG_FILE") 2>&1

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'

pass() { echo -e "${GREEN}[PASS]${NC} $1"; }
info() { echo -e "${YELLOW}[INFO]${NC} $1"; }
diag() { echo -e "${YELLOW}[DIAG]${NC} $1"; }
fail() {
echo -e "${RED}[FAIL]${NC} $1" >&2
diag "strict tool-call probe log tail:"
tail -120 "$LOG_FILE" 2>/dev/null || true
exit 1
}

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
cd "$REPO_ROOT"

info "Preparing CLI build"
if [ ! -d node_modules ]; then
npm ci --ignore-scripts
fi
npm run build:cli

info "Running strict Chat Completions tool-call probe against a hermetic mock"
set +e
NEMOCLAW_TEST_NO_SLEEP=1 node <<'NODE' 2>&1 | tee /tmp/nemoclaw-e2e-strict-tool-call-probe-node.log
const assert = require("node:assert/strict");
const { spawn, spawnSync } = require("node:child_process");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
// @ts-nocheck
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// Process-level driver for the Local Ollama strict Chat Completions
// tool-call probe. Loaded by test/strict-tool-call-probe.test.ts via
// `tsx <driver>`; not picked up by Vitest's discovery (lives under
// test/fixtures/, which is excluded from the test glob).
//
// Mirrors the inline `node -e` block from the retired
// test/e2e/test-strict-tool-call-probe.sh, retained here so the
// caller-level behavior under test stays identical to production
// runtime conditions (subprocess curl probes, real env propagation,
// no Vitest worker shims). Refs #4537, #4349, #5098, #5119.
//
// CWD must be the repo root; cli build artifacts under dist/ are required.
//
// Authored as TypeScript (rather than .cjs) per the codebase-growth
// guardrail forbidding newly added .js/.cjs/.mjs files. Body is JS-shaped
// because the embedded `node -e` strings must remain plain CommonJS for
// the spawned children, and the dist/lib/* targets are CJS modules.
// `@ts-nocheck` keeps the surface unchanged from the retired bash heredoc.

import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

process.env.NEMOCLAW_TEST_NO_SLEEP = "1";
process.env.NO_PROXY = [process.env.NO_PROXY, "127.0.0.1", "localhost"].filter(Boolean).join(",");
process.env.no_proxy = [process.env.no_proxy, "127.0.0.1", "localhost"].filter(Boolean).join(",");

const {
createInferenceSelectionValidationHelpers,
} = require("./dist/lib/onboard/inference-selection-validation");
const localInference = require("./dist/lib/inference/local");
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, "..", "..");
const requireFromHere = createRequire(import.meta.url);
const { createInferenceSelectionValidationHelpers } = requireFromHere(
path.join(REPO_ROOT, "dist", "lib", "onboard", "inference-selection-validation"),
);
const localInference = requireFromHere(path.join(REPO_ROOT, "dist", "lib", "inference", "local"));

function assertStrictPayload(payload) {
assert.equal(payload.model, "mock-tool-model");
Expand Down Expand Up @@ -240,6 +226,9 @@ async function withMockEndpoint(mode, exercise) {

function runOnboardingCallerAgainstMock(endpoint) {
const port = new URL(endpoint).port;
// The child runs with cwd=REPO_ROOT (set via spawnSync below) so its
// `./dist/...` requires resolve consistently regardless of how this
// driver was launched.
const childScript = String.raw`
const assert = require("node:assert/strict");

Expand Down Expand Up @@ -308,7 +297,7 @@ console.error = (...args) => lines.push(args.join(" "));
`;

const result = spawnSync(process.execPath, ["-e", childScript], {
cwd: process.cwd(),
cwd: REPO_ROOT,
encoding: "utf8",
env: { ...process.env, NEMOCLAW_OLLAMA_PORT: port },
timeout: 15000,
Expand Down Expand Up @@ -339,7 +328,9 @@ console.error = (...args) => lines.push(args.join(" "));
assert.equal(requests[0].method, "POST");
assert.equal(requests[0].url, "/v1/chat/completions");
assertStrictPayload(requests[0].body);
console.log("[PASS] Local Ollama onboarding caller enforces strict Chat Completions validation");
console.log(
"[PASS] Local Ollama onboarding caller enforces strict Chat Completions validation",
);
});

await withMockEndpoint("transient-502", async (endpoint, readRequests) => {
Expand All @@ -366,12 +357,3 @@ console.error = (...args) => lines.push(args.join(" "));
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
NODE
NODE_EXIT=$?
set -e

if [ "$NODE_EXIT" -ne 0 ]; then
fail "strict Chat Completions tool-call probe harness failed"
fi

pass "strict Chat Completions tool-call probe E2E passed"
11 changes: 11 additions & 0 deletions test/regression-e2e-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,15 @@ describe("Regression E2E workflow contract", () => {
expect(selectorScript).not.toContain("docker-unreachable-gateway-start-e2e");
expect(selectorScript).not.toContain("docker_unreachable_gateway_start");
});

it("does not advertise or select the retired strict-tool-call-probe lane", () => {
const jobsDescription = workflow.on?.workflow_dispatch?.inputs?.jobs?.description ?? "";
const selectorScript =
workflow.jobs?.select_regression_jobs?.steps?.find((step) => step.id === "select")?.run ?? "";

expect(jobsDescription).not.toContain("strict-tool-call-probe-e2e");
expect(Object.keys(workflow.jobs ?? {})).not.toContain("strict-tool-call-probe-e2e");
expect(selectorScript).not.toContain("strict-tool-call-probe-e2e");
expect(selectorScript).not.toContain("strict_tool_call_probe");
});
});
87 changes: 87 additions & 0 deletions test/strict-tool-call-probe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { describe, it } from "vitest";

import { testTimeoutOptions } from "./helpers/timeouts";

// Coverage guard for #4537. The Local Ollama onboarding path is the only
// current caller that requires strict Chat Completions tool calls. This
// hermetic, caller-level Vitest test exercises that validation path against
// an OpenAI-compatible mock endpoint so payload-shape and retry regressions
// do not require a GPU/Ollama runner to catch.
//
// Replaces test/e2e/test-strict-tool-call-probe.sh per #5119 retirement
// pattern: caller-level mock-driven probes belong in test/, not in live E2E
// scenario/fixture surfaces or the regression-e2e bash workflow. Refs #5098, #4349.
//
// Why subprocess: the validation path drives `curl` via spawnSync with a
// tight process timeout. Driving the entire scenario set through a fresh
// `tsx <driver>` child mirrors the legacy script (and #5119's
// onboard-gateway-docker-unreachable.test.ts) and keeps the behavior under
// test identical to production runtime conditions — bypassing Vitest's
// worker pool, fetch shim, and signal handling, all of which can interfere
// with the in-process curl subprocess used by validateOpenAiLikeSelection.
//
// The driver is `.ts` (executed via tsx) rather than `.cjs` per the
// codebase-growth guardrail that forbids newly added .js/.cjs/.mjs files.

const REPO_ROOT = path.join(import.meta.dirname, "..");
const TSX = path.join(REPO_ROOT, "node_modules", ".bin", "tsx");
const DRIVER = path.join(import.meta.dirname, "fixtures", "strict-tool-call-probe-driver.ts");
const REQUIRED_DIST_MODULES = [
path.join(REPO_ROOT, "dist", "lib", "onboard", "inference-selection-validation.js"),
path.join(REPO_ROOT, "dist", "lib", "inference", "local.js"),
];

const EXPECTED_PASS_MARKERS = [
"[PASS] strict validation succeeds with structured tool_calls",
"[PASS] Local Ollama onboarding caller enforces strict Chat Completions validation",
"[PASS] strict validation retries a transient 502 and keeps bounded payloads",
"[PASS] strict validation fails closed when no structured tool_call is returned",
];

describe("strict Chat Completions tool-call probe (#4537)", () => {
it(
"validates Local Ollama strict tool-call enforcement against a hermetic mock",
testTimeoutOptions(120_000),
() => {
const missingDistModules = REQUIRED_DIST_MODULES.filter(
(modulePath) => !fs.existsSync(modulePath),
);
assert.deepEqual(
missingDistModules,
[],
`strict tool-call probe requires built CLI artifacts; run npm run build:cli first. Missing:\n${missingDistModules.join("\n")}`,
);

const result = spawnSync(TSX, [DRIVER], {
cwd: REPO_ROOT,
encoding: "utf8",
env: { ...process.env, NEMOCLAW_TEST_NO_SLEEP: "1" },
timeout: 110_000,
// Inherit stderr for diagnostic visibility on failure; capture stdout
// to assert the [PASS] markers below.
stdio: ["ignore", "pipe", "inherit"],
});

const stdout = result.stdout ?? "";
assert.equal(
result.status,
0,
`strict tool-call probe driver exited with ${result.status}; stdout:\n${stdout}`,
);

for (const marker of EXPECTED_PASS_MARKERS) {
assert.ok(
stdout.includes(marker),
`missing pass marker ${JSON.stringify(marker)} in driver stdout:\n${stdout}`,
);
}
},
);
});
Loading