diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8713474..42dd24f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,7 @@ name: CI on: push: branches: [main] - pull_request: - branches: [main] + pull_request: {} workflow_dispatch: # The tag release calls these same gates for its exact commit. workflow_call: @@ -30,7 +29,7 @@ jobs: # binary against the mock API and otherwise skips silently. - name: Install pi run: | - npm install -g @earendil-works/pi-coding-agent@latest + npm install -g @earendil-works/pi-coding-agent@1.1.0 echo "PI_BIN=$(npm prefix -g)/bin/pi" >> "$GITHUB_ENV" - name: Verify pi starts run: '"$PI_BIN" --version' @@ -69,14 +68,16 @@ jobs: - run: npm ci - name: Install Oh My Pi run: | - npm install -g @oh-my-pi/pi-coding-agent@latest + npm install -g @oh-my-pi/pi-coding-agent@18.11.1 echo "OMP_BIN=$(npm prefix -g)/bin/omp" >> "$GITHUB_ENV" - name: Verify omp starts run: '"$OMP_BIN" --version' - name: Run OMP compatibility suite env: OMP_COMPAT_REQUIRED: "1" - run: node tests/test-omp-compat.mjs + run: | + node tests/test-omp-compat.mjs + COMMANDCODE_CONTRACT_HOST=omp npm run test:host-protocols # ────────────────────────────────────────────────────────── # Code-level vulnerability scanning (SAST) diff --git a/.github/workflows/latest-hosts.yml b/.github/workflows/latest-hosts.yml new file mode 100644 index 0000000..697eb9e --- /dev/null +++ b/.github/workflows/latest-hosts.yml @@ -0,0 +1,36 @@ +name: Latest host compatibility + +on: + schedule: + - cron: "43 7 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + latest: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - uses: oven-sh/setup-bun@v2.2.0 + with: + bun-version: 1.4.0 + - run: npm ci + - name: Install latest hosts (separate from pinned PR gates) + run: | + npm install -g @earendil-works/pi-coding-agent@latest @oh-my-pi/pi-coding-agent@latest + echo "PI_BIN=$(npm prefix -g)/bin/pi" >> "$GITHUB_ENV" + echo "OMP_BIN=$(npm prefix -g)/bin/omp" >> "$GITHUB_ENV" + - name: Record versions + run: | + { "$PI_BIN" --version; "$OMP_BIN" --version; node --version; bun --version; } | tee -a "$GITHUB_STEP_SUMMARY" + - run: npm test + env: + PI_LOCAL_REQUIRED: "1" + OMP_COMPAT_REQUIRED: "1" diff --git a/CHANGELOG.md b/CHANGELOG.md index e08df45..ad27d2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Preserve Chat Completions reasoning history on OMP with an explicit replay compatibility flag, verified by real Pi/OMP follow-up requests rather than only visible thinking deltas. +- Add shared mock protocol contracts for reasoning, system prompts, parallel tools and image results across Chat Completions, Responses and Anthropic Messages, including packed-package checks. Pin supported hosts in PR CI and check latest hosts weekly. Allow explicit multi-model live profiles without enabling paid CI calls. + ## 0.7.8 - 2026-10-15 - Redact sensitive text in direct stream errors and normalized context-overflow messages before returning them to the host. This closes two error-reporting paths that did not use the shared redactor; it is defensive hardening, not a claim of a confirmed credential leak ([#155](https://github.com/patlux/pi-commandcode-provider/pull/155)). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 931a69e..8416b62 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,6 +67,14 @@ npm install -g @oh-my-pi/pi-coding-agent OMP_BIN="$(npm prefix -g)/bin/omp" node tests/test-omp-compat.mjs ``` +### Protocol and host contracts + +`npm run test:host-protocols` runs both available hosts against loopback mocks. It verifies Chat Completions, Responses and Anthropic Messages, including system instructions, replayed reasoning/signatures, parallel tool-call/result pairing and image results on the follow-up wire. It uses temporary homes and a deterministic tool with no external side effects. Set `COMMANDCODE_CONTRACT_HOST=pi` or `omp` to select one host. Required-host environment variables make missing hosts fail rather than skip. + +PR gates pin Pi **1.1.0** and OMP **18.11.1**. The weekly `Latest host compatibility` workflow tests current releases separately; it does not silently move the supported baseline. Update pins only after both the complete suite and packed-package smoke pass. CI also runs for stacked PRs, not only PRs targeting main. + +Live model testing remains explicit and may incur costs. Set `COMMANDCODE_E2E_PROVIDER_MODELS` to a comma-separated list to test more than one model sequentially, for example `deepseek/deepseek-v4.1-flash,meta/muse-spark-1.3,claude-sonnet-4-6`. The selected models must be available to the account. Equivalent `_MODELS` variables work for each profile; do not combine `_MODEL` and `_MODELS`. No live credentials or paid calls are used by CI. + Before opening a PR, run: ```sh diff --git a/index.ts b/index.ts index da485c6..97ee3be 100644 --- a/index.ts +++ b/index.ts @@ -111,7 +111,11 @@ function commandCodeHeaders(): Record | undefined { */ type CommandCodeProviderConfig = ProviderConfig & { usage?: UsageProvider } -type CommandCodeModelCompat = NonNullable[number]["compat"] +// OMP requires an explicit opt-in to replay Chat Completions reasoning. +// Pi preserves the streamed signature itself and ignores this extra flag. +type CommandCodeModelCompat = NonNullable[number]["compat"] & { + replayReasoningContent?: boolean +} /** * Compatibility flags per wire. Chat Completions mirrors what the Provider API @@ -140,6 +144,7 @@ function compatForModel(model: CommandCodeModel): CommandCodeModelCompat { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: MODEL_EFFORTS[model.id] !== undefined, + replayReasoningContent: model.reasoning, maxTokensField: "max_tokens", } } diff --git a/package.json b/package.json index c9ae9bb..fdc4e20 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "LICENSE" ], "scripts": { - "test": "npm run typecheck && node --test tests/test-release.mjs tests/test-pi-child-cleanup.mjs && tsx tests/test-package-manifest.ts && tsx tests/test-api-key.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-model-metadata-check.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-pricing-check.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-quota.ts && tsx tests/test-quota-command.ts && tsx tests/test-usage.ts && tsx tests/test-retry.ts && tsx tests/test-transport.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs", + "test": "npm run typecheck && node --test tests/test-release.mjs tests/test-pi-child-cleanup.mjs && tsx tests/test-package-manifest.ts && tsx tests/test-api-key.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-model-metadata-check.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-pricing-check.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-quota.ts && tsx tests/test-quota-command.ts && tsx tests/test-usage.ts && tsx tests/test-retry.ts && tsx tests/test-transport.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs && node tests/test-host-protocols.mjs", "typecheck": "tsc --noEmit", "format:check": "prettier --check '**/*.{ts,mjs,json,md}'", "format": "prettier --write '**/*.{ts,mjs,json,md}'", @@ -54,6 +54,7 @@ "test:transport": "tsx tests/test-transport.ts", "test:pi-isolated": "node tests/test-pi-isolated.mjs", "test:pi-authenticated": "node tests/test-pi-authenticated.mjs", + "test:host-protocols": "node tests/test-host-protocols.mjs", "test:pi-local": "node tests/test-pi-local.mjs", "test:smoke": "node tests/test-smoke.mjs", "test:release": "node --test tests/test-release.mjs", diff --git a/scripts/live-e2e-profile.mjs b/scripts/live-e2e-profile.mjs index b2cca8d..f302aa0 100644 --- a/scripts/live-e2e-profile.mjs +++ b/scripts/live-e2e-profile.mjs @@ -38,16 +38,25 @@ async function credentialFor(profile) { throw new Error(`Set ${prefix}_API_KEY_FILE (recommended) or ${prefix}_API_KEY`) } -function runProfile(profile, apiKey) { +function modelsFor(profile) { const modelVariable = profile === "go" ? "COMMANDCODE_E2E_GO_MODEL" : profile === "goat" ? "COMMANDCODE_E2E_GOAT_MODEL" : "COMMANDCODE_E2E_PROVIDER_MODEL" - const model = + const multiple = process.env[`${modelVariable}S`] + if (multiple && process.env[modelVariable]) + throw new Error(`${modelVariable} and ${modelVariable}S are mutually exclusive`) + const models = multiple?.split(",").map((model) => model.trim()) ?? [ process.env[modelVariable] ?? - (profile === "goat" ? "xai/grok-4.6" : "deepseek/deepseek-v4-flash") + (profile === "goat" ? "xai/grok-4.6" : "deepseek/deepseek-v4-flash"), + ] + if (models.some((model) => !model)) throw new Error(`Empty model in ${modelVariable}S`) + return [...new Set(models)] +} + +function runProfile(profile, apiKey, model) { const env = { ...process.env, COMMAND_CODE_API_KEY: apiKey, @@ -79,7 +88,9 @@ function runProfile(profile, apiKey) { try { for (const profile of profiles) { - await runProfile(profile, await credentialFor(profile)) + const models = modelsFor(profile) + const credential = await credentialFor(profile) + for (const model of models) await runProfile(profile, credential, model) } } catch (error) { console.error(error instanceof Error ? error.message : String(error)) diff --git a/tests/fixtures/protocol-tools-extension.ts b/tests/fixtures/protocol-tools-extension.ts new file mode 100644 index 0000000..ba821ec --- /dev/null +++ b/tests/fixtures/protocol-tools-extension.ts @@ -0,0 +1,43 @@ +/** Deterministic test tools. No filesystem or network access. */ +interface FixtureTool { + name: string + label: string + description: string + parameters: unknown + execute: ( + id: string, + args: { image?: string | null }, + ) => Promise<{ + content: ({ type: "text"; text: string } | { type: "image"; data: string; mimeType: string })[] + details: undefined + }> +} +interface FixtureApi { + registerTool(tool: FixtureTool): void +} + +export default function (pi: FixtureApi) { + pi.registerTool({ + name: "contract_read", + label: "Contract read", + description: "Return a synthetic image and text for protocol contract tests", + parameters: { + type: "object", + properties: { image: { type: ["string", "null"] } }, + required: ["image"], + }, + async execute(_id, args) { + return { + content: [ + { type: "text", text: `contract-result-${args.image}` }, + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=", + mimeType: "image/png", + }, + ], + details: undefined, + } + }, + }) +} diff --git a/tests/test-host-protocols.mjs b/tests/test-host-protocols.mjs new file mode 100644 index 0000000..a7a172f --- /dev/null +++ b/tests/test-host-protocols.mjs @@ -0,0 +1,431 @@ +import assert from "node:assert/strict" +import { spawn } from "node:child_process" +import { accessSync, constants } from "node:fs" +import { createServer } from "node:http" +import { mkdir, mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { delimiter, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +const root = fileURLToPath(new URL("../", import.meta.url)) +const extension = resolve(process.env.COMMANDCODE_TEST_PACKAGE_DIR ?? root, "index.ts") +const fixture = join(root, "tests/fixtures/protocol-tools-extension.ts") +const models = [ + { id: "deepseek/deepseek-v4.1-flash", wire: "chat/completions" }, + { id: "meta/muse-spark-1.3", wire: "responses" }, + { id: "claude-sonnet-4-6", wire: "messages" }, +] +const selection = process.env.COMMANDCODE_CONTRACT_HOST ?? "all" +assert.ok( + ["all", "pi", "omp"].includes(selection), + "COMMANDCODE_CONTRACT_HOST must be all, pi or omp", +) +const hosts = (selection === "all" ? ["pi", "omp"] : [selection]).flatMap((host) => { + const explicit = process.env[host === "pi" ? "PI_BIN" : "OMP_BIN"] + const binary = + explicit ?? + (process.env.PATH ?? "") + .split(delimiter) + .map((entry) => resolve(entry, host)) + .find((candidate) => { + try { + accessSync(candidate, constants.X_OK) + return true + } catch { + return false + } + }) + if (binary) return [{ host, binary }] + assert.notEqual( + process.env[host === "pi" ? "PI_LOCAL_REQUIRED" : "OMP_COMPAT_REQUIRED"], + "1", + `${host} is required`, + ) + console.log(`[host-contract] SKIP ${host}: binary unavailable`) + return [] +}) +let active +let bodies = [] +let failure +const server = createServer(async (req, res) => { + try { + const path = new URL(req.url, "http://localhost").pathname + if (req.method === "GET" && path === "/provider/v1/models") { + res.setHeader("content-type", "application/json") + res.end( + JSON.stringify({ + object: "list", + data: models.map(({ id, wire }) => ({ + id, + name: id, + context_length: 200000, + supported_endpoints: [`/${wire}`], + })), + }), + ) + return + } + assert.equal(path, `/provider/v1/${active.wire}`) + assert.equal( + req.headers.authorization ?? req.headers["x-api-key"], + req.headers.authorization ? "Bearer contract-key" : "contract-key", + ) + let raw = "" + for await (const chunk of req) raw += chunk + const body = JSON.parse(raw) + bodies.push(body) + assert.equal(body.model, active.id) + assert.match(JSON.stringify(body), /contract-system-sentinel/) + assert.ok( + body.tools.some((tool) => (tool.function?.name ?? tool.name) === "contract_read"), + JSON.stringify(body.tools.map((tool) => tool.function?.name ?? tool.name)), + ) + const first = bodies.length === 1 + if (!first) { + const history = JSON.stringify(body.messages ?? body.input) + assert.match(history, /contract-result-A/) + assert.match(history, /contract-result-B/) + assert.match(history, /image|image_url/) + // Both tool calls and results must survive, including parallel image results. + if (active.wire === "chat/completions") { + const assistant = body.messages.find((message) => message.tool_calls?.length === 2) + assert.ok(assistant) + assert.equal( + assistant.reasoning_content ?? assistant.reasoning, + "contract-private-reasoning", + JSON.stringify(assistant), + ) + const results = body.messages.filter((message) => message.role === "tool") + assert.deepEqual( + results.map((result) => result.tool_call_id).sort(), + assistant.tool_calls.map((call) => call.id).sort(), + ) + } else if (active.wire === "responses") { + assert.ok( + body.input.some( + (item) => item.type === "reasoning" && item.encrypted_content === "contract-encrypted", + ), + ) + const calls = body.input.filter((item) => item.type === "function_call") + const results = body.input.filter((item) => item.type === "function_call_output") + assert.equal(calls.length, 2) + assert.deepEqual( + results.map((item) => item.call_id).sort(), + calls.map((item) => item.call_id).sort(), + ) + assert.ok(calls.every((call) => call.call_id.length <= 64 && !call.call_id.includes("|"))) + } else { + assert.ok( + body.messages + .flatMap((message) => message.content ?? []) + .some( + (part) => + part.type === "thinking" && + part.thinking === "contract-private-reasoning" && + part.signature === "contract-signature", + ), + ) + const calls = body.messages + .flatMap((message) => message.content ?? []) + .filter((part) => part.type === "tool_use") + const results = body.messages + .flatMap((message) => message.content ?? []) + .filter((part) => part.type === "tool_result") + assert.equal(calls.length, 2) + assert.deepEqual( + results.map((part) => part.tool_use_id).sort(), + calls.map((part) => part.id).sort(), + ) + } + } + res.writeHead(200, { "content-type": "text/event-stream" }) + const send = (data) => + res.write( + `${active.wire === "messages" ? `event: ${data.type}\n` : ""}data: ${JSON.stringify(data)}\n\n`, + ) + const calls = ["A", "B"].map((image, index) => ({ + id: `call_contract_${image}`, + name: "contract_read", + arguments: JSON.stringify({ image }), + index, + })) + if (active.wire === "chat/completions") { + send({ + id: "contract", + object: "chat.completion.chunk", + choices: [ + { + index: 0, + delta: first + ? { + role: "assistant", + reasoning_content: "contract-private-reasoning", + tool_calls: calls.map((call) => ({ + index: call.index, + id: call.id, + type: "function", + function: { name: call.name, arguments: call.arguments }, + })), + } + : { role: "assistant", content: "contract-complete" }, + finish_reason: null, + }, + ], + }) + send({ + id: "contract", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: {}, finish_reason: first ? "tool_calls" : "stop" }], + usage: { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 }, + }) + res.end("data: [DONE]\n\n") + } else if (active.wire === "responses") { + send({ + type: "response.created", + response: { id: "resp_contract", status: "in_progress", output: [] }, + }) + if (first) { + const item = { + type: "reasoning", + id: "rs_contract", + encrypted_content: "contract-encrypted", + summary: [{ type: "summary_text", text: "contract-private-reasoning" }], + } + send({ + type: "response.output_item.added", + output_index: 0, + item: { ...item, summary: [] }, + }) + send({ + type: "response.reasoning_summary_text.delta", + output_index: 0, + item_id: item.id, + summary_index: 0, + delta: "contract-private-reasoning", + }) + send({ type: "response.output_item.done", output_index: 0, item }) + } + const output = first + ? calls.map((call) => ({ + type: "function_call", + id: `fc_contract_${call.index}`, + call_id: call.id, + name: call.name, + arguments: call.arguments, + status: "completed", + })) + : [ + { + type: "message", + id: "msg_contract", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "contract-complete", annotations: [] }], + }, + ] + for (const [position, item] of output.entries()) { + const index = position + (first ? 1 : 0) + send({ + type: "response.output_item.added", + output_index: index, + item: { ...item, arguments: first ? "" : undefined, content: first ? undefined : [] }, + }) + if (first) + send({ + type: "response.function_call_arguments.delta", + output_index: index, + item_id: item.id, + delta: item.arguments, + }) + else + send({ + type: "response.output_text.delta", + output_index: index, + item_id: item.id, + content_index: 0, + delta: "contract-complete", + }) + send({ type: "response.output_item.done", output_index: index, item }) + } + send({ + type: "response.completed", + response: { + id: "resp_contract", + status: "completed", + output, + usage: { input_tokens: 20, output_tokens: 5, total_tokens: 25 }, + }, + }) + res.end() + } else { + send({ + type: "message_start", + message: { + id: "msg_contract", + type: "message", + role: "assistant", + model: active.id, + content: [], + stop_reason: null, + usage: { input_tokens: 20, output_tokens: 0 }, + }, + }) + if (first) { + send({ + type: "content_block_start", + index: 0, + content_block: { type: "thinking", thinking: "", signature: "" }, + }) + send({ + type: "content_block_delta", + index: 0, + delta: { type: "thinking_delta", thinking: "contract-private-reasoning" }, + }) + send({ + type: "content_block_delta", + index: 0, + delta: { type: "signature_delta", signature: "contract-signature" }, + }) + send({ type: "content_block_stop", index: 0 }) + } + const content = first + ? calls.map((call) => ({ type: "tool_use", id: call.id, name: call.name, input: {} })) + : [{ type: "text", text: "" }] + for (const [position, part] of content.entries()) { + const index = position + (first ? 1 : 0) + send({ type: "content_block_start", index, content_block: part }) + send({ + type: "content_block_delta", + index, + delta: first + ? { type: "input_json_delta", partial_json: calls[position].arguments } + : { type: "text_delta", text: "contract-complete" }, + }) + send({ type: "content_block_stop", index }) + } + send({ + type: "message_delta", + delta: { stop_reason: first ? "tool_use" : "end_turn" }, + usage: { output_tokens: 5 }, + }) + send({ type: "message_stop" }) + res.end() + } + } catch (error) { + failure = error + res.writeHead(400, { "content-type": "application/json" }) + res.end( + JSON.stringify({ + error: { message: "Contract assertion failed", type: "invalid_request_error" }, + }), + ) + } +}) +await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)) +const base = `http://127.0.0.1:${server.address().port}` +const temp = await mkdtemp(join(tmpdir(), "cc-host-contracts-")) +try { + for (const { host, binary } of hosts) { + for (const model of models) { + active = model + bodies = [] + failure = undefined + const home = join(temp, host, model.wire.replaceAll("/", "-")) + await mkdir(home, { recursive: true }) + const args = + host === "omp" + ? [ + "-e", + extension, + "-e", + fixture, + "--no-title", + "--no-extensions", + "--no-skills", + "--no-rules", + "--no-lsp", + "--tools", + "contract_read", + "-p", + "Read A and B using contract_read", + "--model", + `commandcode/${model.id}`, + ] + : [ + "--no-extensions", + "--no-skills", + "--tools", + "contract_read", + "-e", + extension, + "-e", + fixture, + "-p", + "Read A and B using contract_read", + "--provider", + "commandcode", + "--model", + model.id, + ] + const result = await new Promise((resolveRun, reject) => { + const child = spawn( + binary, + [...args, "--system-prompt", "contract-system-sentinel", "--thinking", "medium"], + { + cwd: home, + env: { + // Only OS/tool discovery variables cross into the mock-only host. + ...Object.fromEntries( + ["PATH", "TMPDIR", "TEMP", "TMP", "SYSTEMROOT", "LANG"] + .filter((key) => process.env[key] !== undefined) + .map((key) => [key, process.env[key]]), + ), + PI_SKIP_VERSION_CHECK: "1", + HOME: home, + USERPROFILE: home, + PI_CODING_AGENT_DIR: home, + PI_CODING_AGENT_SESSION_DIR: join(home, "sessions"), + COMMAND_CODE_API_KEY: "contract-key", + COMMANDCODE_API_KEY: "", + COMMANDCODE_API_BASE: `${base}/provider/v1`, + COMMANDCODE_MODELS_URL: `${base}/provider/v1/models`, + COMMANDCODE_MODELS_CACHE: join(home, "models-cache.json"), + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ) + let stdout = "", + stderr = "" + const timer = setTimeout(() => { + child.kill() + reject(new Error(`Contract host timed out: ${host}/${model.wire}`)) + }, 45000) + child.stdout.on("data", (chunk) => { + stdout += chunk + }) + child.stderr.on("data", (chunk) => { + stderr += chunk + }) + child.on("error", (error) => { + clearTimeout(timer) + reject(error) + }) + child.on("close", (code) => { + clearTimeout(timer) + resolveRun({ code, stdout, stderr }) + }) + }) + if (failure) throw new Error(`${failure.message}\n${result.stdout}\n${result.stderr}`) + assert.equal(result.code, 0, result.stdout + result.stderr) + assert.match(result.stdout, /contract-complete/, result.stderr) + assert.equal(bodies.length, 2) + console.log( + `[host-contract] PASS ${host} ${model.wire}: reasoning + system + parallel tools + images + follow-up history`, + ) + } + } +} finally { + server.closeAllConnections() + await new Promise((resolveClose) => server.close(resolveClose)) + await rm(temp, { recursive: true, force: true }) +} diff --git a/tests/test-release-package.mjs b/tests/test-release-package.mjs index 3a81f13..46f8eb9 100644 --- a/tests/test-release-package.mjs +++ b/tests/test-release-package.mjs @@ -66,7 +66,7 @@ try { console.log( `[release-package] Testing installed ${manifest.name}@${manifest.version} from ${packagePath}`, ) - for (const suite of ["test-pi-local.mjs", "test-omp-compat.mjs"]) { + for (const suite of ["test-pi-local.mjs", "test-omp-compat.mjs", "test-host-protocols.mjs"]) { run(process.execPath, [join(testsDir, suite)], { cwd: temp, env }) } console.log("[release-package] PASS: packed package loaded and exercised by Pi and OMP")