diff --git a/.github/workflows/test-compiled-prompt.yml b/.github/workflows/test-compiled-prompt.yml new file mode 100644 index 000000000000..fb8d3ea97295 --- /dev/null +++ b/.github/workflows/test-compiled-prompt.yml @@ -0,0 +1,54 @@ +name: compiled prompt + +on: + pull_request: + paths: + - "packages/core/src/filesystem.ts" + - "packages/core/src/filesystem/**" + - "packages/core/src/effect/**" + - "packages/opencode/script/build.ts" + - "packages/opencode/script/test-compiled-prompt.*" + - "package.json" + - "bun.lock" + - ".github/workflows/test-compiled-prompt.yml" + workflow_dispatch: + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + compiled-prompt: + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 20 + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "24" + + # Exercise the proposed runtime independently of the production pin. + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.4.2" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build native executable with embedded web UI + env: + OPENCODE_CHANNEL: prod + OPENCODE_VERSION: 0.0.0-compiled-test + MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json + run: bun run --cwd packages/opencode script/build.ts --single --skip-install + + - name: Exercise compiled prompt, tool and second turn + run: >- + python3 packages/opencode/script/test-compiled-prompt.py + packages/opencode/dist/opencode-linux-x64/bin/opencode --expect-bun 1.4.2 diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index c7738388bcc0..6dcffc084ffb 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -5,7 +5,9 @@ import path from "path" import { Context, Effect, Layer, Scope } from "effect" import { Fff } from "#fff" import fuzzysort from "fuzzysort" -import { FileSystem } from "../filesystem" +// Keep the service module type-only: it depends on this module's node during initialisation. +import type { GlobInput, GrepInput } from "../filesystem" +import { FileSystem } from "@opencode-ai/schema/filesystem" import { FSUtil } from "../fs-util" import { Location } from "../location" import { Ripgrep } from "../ripgrep" @@ -14,8 +16,8 @@ import { Flag } from "../flag/flag" export interface Interface { readonly find: (input: FileSystem.FindInput) => Effect.Effect - readonly glob: (input: FileSystem.GlobInput) => Effect.Effect - readonly grep: (input: FileSystem.GrepInput) => Effect.Effect + readonly glob: (input: GlobInput) => Effect.Effect + readonly grep: (input: GrepInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/FileSystem/Search") {} diff --git a/packages/opencode/script/test-compiled-prompt.md b/packages/opencode/script/test-compiled-prompt.md new file mode 100644 index 000000000000..ddc117365952 --- /dev/null +++ b/packages/opencode/script/test-compiled-prompt.md @@ -0,0 +1,43 @@ +# Compiled prompt regression + +Source-level tests and a compiled `--version` or health check do not exercise +prompt preparation. This regression runs a supplied native executable through +an authenticated HTTP session, a real file-read tool call, completion and a +second turn retaining the tool result. + +From the repository root, with Bun 1.4.2 and dependencies installed: + +```sh +OPENCODE_CHANNEL=prod OPENCODE_VERSION=0.0.0-compiled-test \ + MODELS_DEV_API_JSON="$PWD/packages/opencode/test/tool/fixtures/models-api.json" \ + bun run --cwd packages/opencode script/build.ts --single --skip-install +python3 packages/opencode/script/test-compiled-prompt.py \ + packages/opencode/dist/opencode-linux-x64/bin/opencode --expect-bun 1.4.2 +``` + +Use the appropriate native output path on other POSIX platforms. Python 3's +standard library is sufficient. Windows process cleanup is not implemented. +Pass `--expect-bun` explicitly when testing a different embedded runtime. + +The runner creates fresh project, HOME, XDG, configuration and database +directories. It starts an ephemeral loopback provider that streams deterministic +responses; it needs no credentials or external model calls. Default plugins, +external skills, model fetching and auto-updates are disabled. A local test +plugin records the actual embedded Bun version and revision. Each HTTP request +has a 60-second timeout, and cleanup terminates only the test process group. + +The runner prints and retains its temporary artefact directory, including server +logs, synthetic provider requests and persisted messages, for diagnosis. Use +`--artifacts-dir` to choose an existing parent directory and remove the printed +directory when finished. Do not run Python with `-O`, which disables assertions. + +The filesystem service depends on the search node. Importing schema constructors +back through that service creates a runtime cycle: a compiled build can capture +an undefined search node in the filesystem dependency array. Health still passes, +but the first prompt fails in layer resolution before contacting the provider. +Importing the constructors directly from the schema package removes that cycle. + +The dedicated CI workflow builds and runs this regression on Linux with Bun +1.4.2 without changing the repository's production runtime pin. It complements +the runtime upgrade in #44946. It does not measure CPU usage or replace +cross-platform testing, real-provider testing or a soak test. diff --git a/packages/opencode/script/test-compiled-prompt.py b/packages/opencode/script/test-compiled-prompt.py new file mode 100644 index 000000000000..b558922960c4 --- /dev/null +++ b/packages/opencode/script/test-compiled-prompt.py @@ -0,0 +1,276 @@ +"""Exercise a supplied native binary using isolated state and a loopback-only provider.""" + +import argparse +import base64 +import hashlib +import json +import os +from pathlib import Path +import signal +import sys +import socket +import subprocess +import tempfile +import threading +import time +import urllib.request +import urllib.error +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +parser = argparse.ArgumentParser() +parser.add_argument("binary", type=Path) +parser.add_argument( + "--artifacts-dir", + type=Path, + help="Machine-local directory for retained test artefacts", +) +parser.add_argument("--expect-bun", default="1.4.2") +args = parser.parse_args() +if os.name != "posix": + parser.error("This regression currently requires a POSIX host.") +if sys.flags.optimize: + parser.error("Run without Python optimisation: this regression uses assertions.") +if not args.binary.is_file(): + parser.error("The compiled binary must exist.") +root = Path( + tempfile.mkdtemp(prefix="compiled-prompt-", dir=args.artifacts_dir) +).resolve() +# Report the runtime from inside the compiled server, not the build shell. +(root / "runtime.js").write_text( + "export default async () => { await Bun.write(" + + json.dumps(str(root / "runtime.json")) + + ", JSON.stringify({version:Bun.version,revision:Bun.revision})); return {}; };\n" +) +(root / "project").mkdir() +(root / "project" / "fixture.txt").write_text("COMPILED_TOOL_OK\n") +requests = [] + + +class Mock(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + requests.append(body) + (root / "provider-requests.json").write_text(json.dumps(requests, indent=2)) + assert self.path == "/v1/chat/completions", self.path + tool_results = [m for m in body["messages"] if m["role"] == "tool"] + if not tool_results: + delta = { + "role": "assistant", + "tool_calls": [ + { + "index": 0, + "id": "call_fixture", + "type": "function", + "function": { + "name": "read", + "arguments": json.dumps( + {"filePath": str(root / "project" / "fixture.txt")} + ), + }, + } + ], + } + reason = "tool_calls" + else: + assert "COMPILED_TOOL_OK" in json.dumps(tool_results), tool_results + delta = { + "role": "assistant", + "content": ( + "COMPILED_PROMPT_OK" if len(requests) == 2 else "SECOND_TURN_OK" + ), + } + reason = "stop" + chunk = { + "id": "chatcmpl_mock", + "object": "chat.completion.chunk", + "created": 1, + "model": "mock", + "choices": [{"index": 0, "delta": delta, "finish_reason": None}], + } + end = dict( + chunk, + choices=[{"index": 0, "delta": {}, "finish_reason": reason}], + usage={"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + ) + data = ( + "".join("data: " + json.dumps(x) + "\n\n" for x in (chunk, end)) + + "data: [DONE]\n\n" + ) + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Content-Length", str(len(data.encode()))) + self.end_headers() + self.wfile.write(data.encode()) + + +mock = ThreadingHTTPServer(("127.0.0.1", 0), Mock) +threading.Thread(target=mock.serve_forever, daemon=True).start() +config = { + "$schema": "https://opencode.ai/config.json", + "autoupdate": False, + "plugin": [(root / "runtime.js").as_uri()], + "mcp": {}, + "lsp": False, + "model": "mock/mock", + "small_model": "mock/mock", + "enabled_providers": ["mock"], + "permission": {"*": "allow"}, + "agent": {"title": {"disable": True}, "summary": {"disable": True}}, + "provider": { + "mock": { + "npm": "@ai-sdk/openai-compatible", + "name": "Local deterministic mock", + "options": { + "baseURL": f"http://127.0.0.1:{mock.server_port}/v1", + "apiKey": "local-test-not-a-secret", + }, + "models": { + "mock": {"name": "mock", "limit": {"context": 32000, "output": 2000}} + }, + } + }, +} +(root / "config.json").write_text(json.dumps(config)) +(root / "models.json").write_text("{}") +env = { + "PATH": "/usr/bin:/bin", + "HOME": str(root / "home"), + "OPENCODE_CONFIG": str(root / "config.json"), + "OPENCODE_DISABLE_PROJECT_CONFIG": "1", + "OPENCODE_DISABLE_DEFAULT_PLUGINS": "1", + "OPENCODE_DISABLE_EXTERNAL_SKILLS": "1", + "OPENCODE_DISABLE_MODELS_FETCH": "1", + "OPENCODE_MODELS_PATH": str(root / "models.json"), + "OPENCODE_DISABLE_AUTOUPDATE": "1", + "OPENCODE_SERVER_PASSWORD": "local-regression-only", +} +for key in ("DATA", "CONFIG", "CACHE", "STATE"): + env[f"XDG_{key}_HOME"] = str(root / key.lower()) +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] +url = f"http://127.0.0.1:{port}" +# Do not send local requests through proxies inherited from the caller. +http = urllib.request.build_opener(urllib.request.ProxyHandler({})) + + +def api(path, body=None): + request = urllib.request.Request( + url + path, + data=None if body is None else json.dumps(body).encode(), + headers={ + "Content-Type": "application/json", + "x-opencode-directory": str(root / "project"), + "Authorization": "Basic " + + base64.b64encode(b"opencode:local-regression-only").decode(), + }, + ) + try: + with http.open(request, timeout=60) as response: + return json.load(response) + except urllib.error.HTTPError as error: + raise RuntimeError(error.read().decode()) from error + + +print("ARTIFACTS", root, flush=True) +with (root / "server.log").open("wb") as log: + server = subprocess.Popen( + [ + str(args.binary.resolve()), + "serve", + "--hostname", + "127.0.0.1", + "--port", + str(port), + "--print-logs", + ], + cwd=root / "project", + env=env, + stdout=log, + stderr=log, + start_new_session=True, + ) + try: + for attempt in range(100): + try: + health = api("/global/health") + break + except OSError: + assert server.poll() is None, "server exited" + time.sleep(0.1) + else: + raise RuntimeError("health timeout") + print("HEALTH", health, flush=True) + assert health["healthy"] is True, health + # Initialisation runs custom plugins and must use only this isolated config. + effective = api("/config") + assert effective["enabled_providers"] == ["mock"], effective + runtime = json.loads((root / "runtime.json").read_text()) + assert runtime["version"] == args.expect_bun, runtime + session = api("/session", {}) + result = api( + "/session/" + session["id"] + "/message", + { + "model": {"providerID": "mock", "modelID": "mock"}, + "parts": [ + {"type": "text", "text": "Read fixture.txt then report the result."} + ], + }, + ) + (root / "first.json").write_text(json.dumps(result, indent=2)) + assert result["info"].get("error") is None, result + assert any( + p["type"] == "text" and p["text"] == "COMPILED_PROMPT_OK" + for p in result["parts"] + ), result + result2 = api( + "/session/" + session["id"] + "/message", + { + "model": {"providerID": "mock", "modelID": "mock"}, + "parts": [ + { + "type": "text", + "text": "Confirm that you retained the previous tool result.", + } + ], + }, + ) + (root / "second.json").write_text(json.dumps(result2, indent=2)) + assert result2["info"].get("error") is None, result2 + assert any( + p["type"] == "text" and p["text"] == "SECOND_TURN_OK" + for p in result2["parts"] + ), result2 + messages = api("/session/" + session["id"] + "/message") + (root / "messages.json").write_text(json.dumps(messages, indent=2)) + assert len(requests) == 3, len(requests) + tools = [p for m in messages for p in m["parts"] if p["type"] == "tool"] + assert len(tools) == 1 and tools[0]["tool"] == "read", tools + assert tools[0]["state"]["status"] == "completed", tools + assert "COMPILED_TOOL_OK" in tools[0]["state"]["output"], tools + print( + json.dumps( + { + "passed": True, + "health": health, + "runtime": runtime, + "provider_requests": len(requests), + "sha256": hashlib.sha256(args.binary.read_bytes()).hexdigest(), + "artifacts": str(root), + } + ) + ) + finally: + print("PROVIDER_REQUESTS", len(requests), flush=True) + if server.poll() is None: + os.killpg(server.pid, signal.SIGTERM) + try: + server.wait(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(server.pid, signal.SIGKILL) + server.wait() + mock.shutdown() + mock.server_close()