diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f611a82..54c39bf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,6 +50,7 @@ jobs: run: | set -euo pipefail if [[ -n "$INPUT_VERSION" ]]; then + # Validate semver if ! echo "$INPUT_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then echo "Error: version must be in semver format X.Y.Z" exit 1 @@ -65,6 +66,12 @@ jobs: esac fi + - name: Show version info + run: | + echo "Current version: ${{ steps.current_version.outputs.version }}" + echo "New version: ${{ steps.version.outputs.version }}" + echo "Dry run: ${{ github.event.inputs.dry_run == 'true' && 'yes' || 'no' }}" + - name: Update package.json version run: | node -e " @@ -121,3 +128,59 @@ jobs: name: v${{ steps.version.outputs.version }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Send Discord notification + if: ${{ github.event.inputs.dry_run != 'true' }} + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + REPOSITORY: ${{ github.repository }} + run: | + if [ -z "$DISCORD_WEBHOOK_URL" ]; then + echo "DISCORD_WEBHOOK_URL not set; skipping Discord notification." + exit 0 + fi + node - <<'NODE' + (async () => { + const tag = `v${process.env.VERSION}`; + const repo = process.env.REPOSITORY; + const releaseRes = await fetch(`https://api.github.com/repos/${repo}/releases/tags/${tag}`, { + headers: { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + Accept: 'application/vnd.github+json', + }, + }); + if (!releaseRes.ok) { + const body = await releaseRes.text(); + throw new Error(`Failed to fetch release ${tag}: ${releaseRes.status} ${body}`); + } + const release = await releaseRes.json(); + const description = (release.body || `opencode-commandcode ${tag} released.`).slice(0, 4096); + const payload = { + username: 'opencode-commandcode Releases', + embeds: [ + { + title: release.name || `opencode-commandcode ${tag}`, + url: release.html_url, + description, + color: 8244896, + footer: { text: 'opencode-commandcode Changelog' }, + }, + ], + }; + const discordRes = await fetch(process.env.DISCORD_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!discordRes.ok) { + const body = await discordRes.text(); + throw new Error(`Failed to send Discord notification: ${discordRes.status} ${body}`); + } + console.log('Discord notification sent successfully'); + })().catch((error) => { + console.error(error); + process.exit(1); + }); + NODE diff --git a/README.md b/README.md index a13c3bd..0b073a2 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,10 @@

npm version + npm downloads MIT license linux, macos, windows + latest release

@@ -13,6 +15,14 @@ gateway proxy, free Laguna S 2.1, tools/MCP, attachments, compact, usage.

+

+ Install · + Authenticate · + Why this plugin · + Architecture · + Changelog +

+ --- Run [Command Code](https://commandcode.ai) models from OpenCode by proxying the same gateway that `npm i -g command-code@latest` uses (`POST /alpha/generate`). Default and exclusive live-test target: **Laguna S 2.1 free** (`poolside/laguna-s-2.1-free`, 256k context, $0). @@ -21,15 +31,18 @@ Plugin shape mirrors [@otto-assistant/opencode-claude](https://github.com/otto-a ## Install +`command-code` is **not** a built-in OpenCode provider. Install the plugin first, or +`opencode auth login --provider command-code` fails with `Unknown provider "command-code"`. + ```bash # global (recommended) opencode plugin @otto-assistant/opencode-commandcode -g -# or project-local +# or project-local (writes .opencode/opencode.json) opencode plugin @otto-assistant/opencode-commandcode ``` -Optional provider naming: +Optional provider naming (also seeded when the plugin loads): ```jsonc { @@ -41,7 +54,7 @@ Optional provider naming: } ``` -From source: +Or build from source: ```bash git clone https://github.com/otto-assistant/opencode-commandcode.git @@ -52,17 +65,21 @@ opencode plugin file://$PWD ## Authenticate -Uses the **Command Code Go plan ($1/mo)** — same browser login as `cmd login`. No Studio API key. +Requires the plugin to be installed (see above). Uses the **Command Code Go plan ($1/mo)** — same browser login as `cmd login`. No Studio API key. ```bash +# Option A — sync from Command Code CLI (recommended) npm i -g command-code@latest cmd login # browser → Studio CLI auth (Go plan). Laguna S 2.1 is $0 credits. +opencode auth login --provider command-code +# pick "Use existing cmd login session" +# Option B — browser OAuth (Go $1) opencode auth login --provider command-code -# pick "Login with Command Code (Go $1)" — or "Use existing cmd login session" +# pick "Login with Command Code (Go $1)" ``` -Then: +Then start OpenCode, pick provider **command-code**, and choose Laguna S 2.1 free: ```bash opencode run "Summarise this repository in five bullets." --model command-code/laguna-s-2.1-free @@ -74,12 +91,12 @@ Laguna S 2.1 free requires an active Go (or higher) account with credits on file | | | |---|---| -| **Gateway proxy** | Talks to `api.commandcode.ai/alpha/generate` — same transport as the `cmd` CLI. | -| **Laguna S 2.1 free** | 256k context, reasoning, $0 while the deal lasts. | -| **Go-plan auth** | Browser OAuth like `cmd login` ($1/mo). Syncs `~/.commandcode/auth.json`. No API key. | +| **Gateway proxy** | Talks to `api.commandcode.ai/alpha/generate` — same transport as the `cmd` CLI (`mode=agent`). | +| **Laguna S 2.1 free** | 256k context, reasoning, $0 while the deal lasts. Default aliases resolve here. | +| **Go-plan auth** | Browser OAuth like `cmd login` ($1/mo). Syncs `~/.commandcode/auth.json`. No Studio API key. | | **Agent-grade tools** | OpenCode tool calls park and resume; MCP-prefixed tools are tracked separately. | | **Attachments** | Images, PDFs, text/binary files from OpenCode. Text-only models get image placeholders (Command Code behavior). | -| **Compact** | Context-fraction tips + tiered client compact before the 256k window overflows. | +| **Auto-compact** | Context-fraction tips + tiered client compact before the 256k window overflows. | | **Usage** | Per-turn and session totals via SSE `usage` + `GET /v1/usage`. | ## Architecture @@ -87,15 +104,19 @@ Laguna S 2.1 free requires an active Go (or higher) account with credits on file ```text OpenCode └─ /v1/chat/completions - └─ Bun.serve proxy (port 8797) + └─ Bun.serve proxy (dynamic port, prefer 8797) └─ POST https://api.commandcode.ai/alpha/generate └─ poolside/laguna-s-2.1-free (default) ``` +Model catalog: aliases `laguna-s-2.1-free` / `laguna` / `default` (and siblings) map to +`poolside/laguna-s-2.1-free`. Auth credentials are mirrored between OpenCode `auth.json` +and `~/.commandcode/auth.json`. + ## Requirements - [OpenCode](https://opencode.ai) -- [Command Code CLI](https://www.npmjs.com/package/command-code) (`npm i -g command-code@latest`) and a free account +- [Command Code CLI](https://www.npmjs.com/package/command-code) (`npm i -g command-code@latest`) and a Go-plan account - Bun (plugin runtime) · Node.js ≥ 18 (CLI needs ≥ 22) ## Development @@ -107,13 +128,45 @@ bun run test # mocked gateway — attachments, tools, compact, usage bun run test:live # live Laguna S 2.1 (needs `cmd login` / Go plan session) ``` -Debug: `OPENCODE_COMMANDCODE_DEBUG=1`. +Debug logging: `OPENCODE_COMMANDCODE_DEBUG=1`. Optional knobs: -- `OPENCODE_COMMANDCODE_PROXY_PORT` — fixed local proxy port (default `8797`) +- `OPENCODE_COMMANDCODE_PROXY_PORT` — pin a fixed local proxy port (otherwise dynamic: prefer `8797`, then scan upward / ephemeral) - `OPENCODE_COMMANDCODE_CWD` — working directory reported to the gateway -- `COMMANDCODE_API_URL` — override API base (default `https://api.commandcode.ai`) +- `COMMANDCODE_API_URL` / `OPENCODE_COMMANDCODE_API_URL` — override API base (default `https://api.commandcode.ai`) + +## Release + +Publish via GitHub Actions → **Actions → Release → Run workflow**: + +| Input | Purpose | +|---|---| +| `version` | Explicit semver (`0.2.0`). Empty → use bump | +| `bump` | `minor` (default) / `patch` / `major` | +| `dry_run` | Skip npm publish; create a draft GitHub release | + +Requires repo secrets: `NPM_TOKEN`, optional `DISCORD_WEBHOOK_URL`. + +Local pin refresh after a release: + +```bash +./scripts/update-plugin.sh +``` + +## FAQ + +**Do I need a Studio API key?** +No. Use Go-plan browser login (or sync from `cmd login`). + +**Is Laguna free?** +Yes — `poolside/laguna-s-2.1-free` is the default free model ($0 while the deal lasts). + +**Why isn’t Command Code listed without the plugin?** +OpenCode doesn’t ship Command Code as a built-in provider; this plugin registers it. + +**Where are releases?** +[GitHub Releases](https://github.com/otto-assistant/opencode-commandcode/releases) · [npm](https://www.npmjs.com/package/@otto-assistant/opencode-commandcode) · [Changelog](CHANGELOG.md) ## License diff --git a/docs/header.svg b/docs/header.svg index f8a6a0c..f5fce75 100644 --- a/docs/header.svg +++ b/docs/header.svg @@ -1,19 +1,90 @@ - + + + - - + + + + + + + + + + + + + - - - - opencode-commandcode - Command Code in OpenCode — Laguna S 2.1 gateway proxy - + + + + + + OTTO · COMMAND CODE + + + + + + + + + opencode-commandcode + Command Code in OpenCode — Laguna S 2.1 gateway + Go-plan OAuth · tools / MCP · attachments & usage + + + + + + + diff --git a/package.json b/package.json index 25c9a33..911dc15 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "build": "tsc -p tsconfig.json", "test": "bun test/smoke.ts", "test:live": "bun test/live-laguna.ts", + "test:live:matrix": "bun test/live-matrix.ts", + "test:opencode": "bash test/opencode-e2e.sh", "prepublishOnly": "npm run build" }, "repository": { diff --git a/scripts/update-plugin.sh b/scripts/update-plugin.sh old mode 100644 new mode 100755 index d05f437..c72529c --- a/scripts/update-plugin.sh +++ b/scripts/update-plugin.sh @@ -1,8 +1,180 @@ #!/usr/bin/env bash +# +# update-plugin.sh — Automatically pull the latest opencode-commandcode plugin +# and restart opencode so it picks up the new version. +# +# Usage: +# ./scripts/update-plugin.sh # restart on default port 36889 +# ./scripts/update-plugin.sh --port 39981 # restart on a specific port +# ./scripts/update-plugin.sh --hostname 0.0.0.0 --port 39981 +# ./scripts/update-plugin.sh --dry-run # only check, don't apply/restart +# +# The script: +# 1. Checks the latest `@otto-assistant/opencode-commandcode` version on npm. +# 2. Git-fetches and rebases onto origin/main (auto-stashes local changes if any). +# 3. Compares the plugin pin in `.opencode/opencode.json` with the npm latest. +# 4. If the pin is stale, updates it, commits the change, and restarts opencode. +# 5. If already up-to-date, reports so and does nothing. +# + set -euo pipefail -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$ROOT" -bun install -bun run build -echo "Built @otto-assistant/opencode-commandcode" -echo "Install into OpenCode with: opencode plugin file://$ROOT" + +# ── Config ────────────────────────────────────────────────────────────────── +PLUGIN_NAME="@otto-assistant/opencode-commandcode" +PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +OPENCODE_BIN="${OPENCODE_BIN:-$(command -v opencode 2>/dev/null || echo "$HOME/.opencode/bin/opencode")}" + +# Default serve flags (overridable via CLI) +PORT="${PORT:-36889}" +HOSTNAME="${HOSTNAME:-127.0.0.1}" +DRY_RUN=false + +# ── Parse arguments ───────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + --port) PORT="$2"; shift 2 ;; + --hostname) HOSTNAME="$2"; shift 2 ;; + --dry-run) DRY_RUN=true; shift ;; + --help|-h) + sed -n '/^#$/q; /^#/p; /^$/q' "$0" | sed 's/^# \?//' + exit 0 + ;; + *) echo "❌ Unknown option: $1"; exit 1 ;; + esac +done + +OPENCODE_JSON="$PROJECT_DIR/.opencode/opencode.json" +PACKAGE_JSON="$PROJECT_DIR/package.json" + +# ── Colors ────────────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' +info() { echo -e "${CYAN}[info]${NC} $*"; } +ok() { echo -e "${GREEN}[ok]${NC} $*"; } +warn() { echo -e "${YELLOW}[warn]${NC} $*"; } +err() { echo -e "${RED}[err]${NC} $*"; } + +# ── Step 1: check npm latest ──────────────────────────────────────────────── +info "Checking latest npm version of ${PLUGIN_NAME}..." +NPM_LATEST="$(npm view "${PLUGIN_NAME}" version 2>/dev/null || true)" + +if [[ -z "$NPM_LATEST" ]]; then + err "Could not fetch latest version from npm. Is the registry reachable?" + exit 1 +fi +ok "npm latest: ${NPM_LATEST}" + +# ── Step 2: git pull (with stash safety) ───────────────────────────────────── +info "Fetching latest from origin/main..." + +if [[ "$DRY_RUN" == true ]]; then + ok "[dry-run] Would run: git fetch origin && git pull --rebase origin main" +else + ( + cd "$PROJECT_DIR" + + # Stash any local uncommitted changes so git pull won't fail + HAS_LOCAL=false + if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null; then + HAS_LOCAL=true + warn "Local changes detected — stashing them before pull" + git stash push -m "update-plugin.sh: auto-stash before pull $(date +%Y%m%d%H%M%S)" \ + 2>&1 | sed 's/^/ /' + fi + + git fetch origin 2>&1 | sed 's/^/ /' + # Use rebase so local commits (e.g. pin bump) are replayed on top of new remote changes + git pull --rebase origin main 2>&1 | sed 's/^/ /' + + # Pop stash if we stashed anything + if [[ "$HAS_LOCAL" == true ]]; then + info "Restoring local changes from stash..." + git stash pop 2>&1 | sed 's/^/ /' || warn "Stash pop had conflicts — resolve manually" + fi + ) + ok "Repository is up-to-date with origin/main" +fi + +# ── Step 3: read current pin ──────────────────────────────────────────────── +CURRENT_PIN="$(grep -oP "${PLUGIN_NAME}@\K[0-9]+\.[0-9]+\.[0-9]+" "$OPENCODE_JSON" 2>/dev/null || true)" + +if [[ -z "$CURRENT_PIN" ]]; then + warn "Could not extract current plugin pin from ${OPENCODE_JSON}" + info "Expected a line like: \"${PLUGIN_NAME}@x.y.z\"" + exit 1 +fi +ok "Current plugin pin: ${CURRENT_PIN}" + +# ── Step 4: compare & update ──────────────────────────────────────────────── +if [[ "$CURRENT_PIN" == "$NPM_LATEST" ]]; then + ok "Plugin pin is already at ${NPM_LATEST}. Nothing to do." + info "Reboot opencode manually if the running instance is stale." + exit 0 +fi + +info "Plugin pin ${CURRENT_PIN} → ${NPM_LATEST}" + +if [[ "$DRY_RUN" == true ]]; then + ok "[dry-run] Would update ${OPENCODE_JSON}: ${CURRENT_PIN} → ${NPM_LATEST}" + ok "[dry-run] Would commit and restart opencode" + exit 0 +fi + +# ── Step 5: apply the pin update ──────────────────────────────────────────── +sed -i "s|${PLUGIN_NAME}@${CURRENT_PIN}|${PLUGIN_NAME}@${NPM_LATEST}|g" "$OPENCODE_JSON" +ok "Updated ${OPENCODE_JSON}" + +# Also sync package.json version if it doesn't match +PKG_VER="$(grep -oP '"version":\s*"\K[0-9]+\.[0-9]+\.[0-9]+' "$PACKAGE_JSON" || true)" +if [[ -n "$PKG_VER" && "$PKG_VER" != "$NPM_LATEST" ]]; then + sed -i "s|\"version\": \"${PKG_VER}\"|\"version\": \"${NPM_LATEST}\"|" "$PACKAGE_JSON" + ok "Synced package.json version: ${PKG_VER} → ${NPM_LATEST}" +fi + +# ── Step 6: commit ────────────────────────────────────────────────────────── +( + cd "$PROJECT_DIR" + git add .opencode/opencode.json package.json 2>/dev/null + # Only commit if there's something new + if ! git diff --cached --quiet; then + git commit -m "chore: bump plugin pin to ${NPM_LATEST}" 2>&1 | sed 's/^/ /' + ok "Committed plugin pin update" + else + ok "No new changes to commit (pin already correct)" + fi +) + +# ── Step 7: restart opencode ──────────────────────────────────────────────── +info "Restarting opencode serve..." + +# Kill ALL existing opencode serve processes (they run the old plugin version) +EXISTING_PIDS="$(pgrep -f 'opencode serve' 2>/dev/null || true)" +if [[ -n "$EXISTING_PIDS" ]]; then + # Send SIGTERM first, wait briefly, then SIGKILL survivors + echo "$EXISTING_PIDS" | xargs -r kill 2>/dev/null || true + sleep 2 + SURVIVORS="$(pgrep -f 'opencode serve' 2>/dev/null || true)" + if [[ -n "$SURVIVORS" ]]; then + echo "$SURVIVORS" | xargs -r kill -9 2>/dev/null || true + sleep 1 + fi + ok "Stopped old opencode serve processes" +fi + +# Start a new opencode serve instance +info "Starting: ${OPENCODE_BIN} serve --hostname ${HOSTNAME} --port ${PORT}" +nohup "${OPENCODE_BIN}" serve --hostname "$HOSTNAME" --port "$PORT" \ + >> "${HOME}/.opencode-serve.log" 2>&1 & +NEW_PID=$! + +# Give it a moment to start +sleep 2 +if kill -0 "$NEW_PID" 2>/dev/null; then + ok "opencode serve started (PID ${NEW_PID}) on ${HOSTNAME}:${PORT}" + info "Logs: ~/.opencode-serve.log" +else + err "opencode serve failed to start. Check ~/.opencode-serve.log" + exit 1 +fi + +echo "" +echo -e "${GREEN}✅ Done. Plugin ${PLUGIN_NAME} updated to ${NPM_LATEST} and opencode restarted.${NC}" diff --git a/src/index.ts b/src/index.ts index 13f2c9f..69bd15d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -289,9 +289,7 @@ export const CommandCodePlugin: Plugin = async ( return { async config(config) { - // Always seed Laguna catalog so the provider is discoverable pre-login. - ensureProviderConfig(config as Record, getCommandModels()); - + // Bind proxy first so provider baseURL matches the actual listening port. await startProxy(async () => { try { const authClient = input.client.auth as { @@ -311,6 +309,9 @@ export const CommandCodePlugin: Plugin = async ( const synced = syncCommandCodeCredentialsToOpenCode(); return synced?.access ?? null; }); + + // Always seed Laguna catalog so the provider is discoverable pre-login. + ensureProviderConfig(config as Record, getCommandModels()); }, "chat.headers": async (hookInput, output) => { diff --git a/src/proxy.ts b/src/proxy.ts index 85e7e65..4751d61 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -50,15 +50,17 @@ import { } from "./usage.js"; const DEFAULT_PROXY_PORT = 8797; +const PROXY_PORT_SCAN_ATTEMPTS = 40; const SHARED_PROXY_HEALTH_TIMEOUT_MS = 750; -const COMMAND_PROXY_PORT: number = (() => { +/** Explicit pin via env, or `null` → pick a free port dynamically. */ +function envProxyPort(): number | null { const raw = process.env.OPENCODE_COMMANDCODE_PROXY_PORT; const parsed = raw ? Number(raw) : NaN; return Number.isInteger(parsed) && parsed > 0 && parsed < 65536 ? parsed - : DEFAULT_PROXY_PORT; -})(); + : null; +} const SSE_HEADERS = { "Content-Type": "text/event-stream", @@ -93,11 +95,12 @@ export function setStreamGenerateForTests( } export function getCommandProxyBaseUrl(): string { - return `http://127.0.0.1:${COMMAND_PROXY_PORT}/v1`; + const port = proxyPort ?? envProxyPort() ?? DEFAULT_PROXY_PORT; + return `http://127.0.0.1:${port}/v1`; } export function getProxyPort(): number | null { - return proxyPort ?? COMMAND_PROXY_PORT; + return proxyPort ?? envProxyPort(); } function isAddrInUseError(err: unknown): boolean { @@ -111,14 +114,14 @@ function isAddrInUseError(err: unknown): boolean { ); } -async function isSharedProxyHealthy(): Promise { +async function isProxyHealthyOn(port: number): Promise { const controller = new AbortController(); const timeout = setTimeout( () => controller.abort(), SHARED_PROXY_HEALTH_TIMEOUT_MS, ); try { - const res = await fetch(`${getCommandProxyBaseUrl()}/models`, { + const res = await fetch(`http://127.0.0.1:${port}/v1/models`, { signal: controller.signal, }); if (!res.ok) return false; @@ -133,42 +136,76 @@ async function isSharedProxyHealthy(): Promise { } } +async function bindProxy(port: number): Promise { + const hostname = "127.0.0.1"; + server = Bun.serve({ + hostname, + port, + async fetch(req) { + return handleRequest(req); + }, + }); + proxyPort = server.port ?? port; + log.info( + `[opencode-commandcode] proxy listening on ${getCommandProxyBaseUrl()}`, + ); + return proxyPort; +} + +/** + * Start the local OpenAI-compatible proxy. + * + * Port selection: + * - `OPENCODE_COMMANDCODE_PROXY_PORT` → bind/reuse that exact port + * - otherwise prefer `8797`, then scan upward, then fall back to OS ephemeral (`0`) + */ export async function startProxy(tokenProvider: TokenProvider): Promise { getAccessToken = tokenProvider; if (server && proxyPort) return proxyPort; - if (await isSharedProxyHealthy()) { - proxyPort = COMMAND_PROXY_PORT; + const fixed = envProxyPort(); + if (fixed !== null) { + if (await isProxyHealthyOn(fixed)) { + proxyPort = fixed; + log.info( + `[opencode-commandcode] reusing healthy proxy on ${getCommandProxyBaseUrl()}`, + ); + return proxyPort; + } + try { + return await bindProxy(fixed); + } catch (err) { + if (isAddrInUseError(err) && (await isProxyHealthyOn(fixed))) { + proxyPort = fixed; + log.info( + `[opencode-commandcode] port ${fixed} in use; reusing existing proxy`, + ); + return proxyPort; + } + throw err; + } + } + + // Dynamic: reuse preferred default when a healthy proxy is already there. + if (await isProxyHealthyOn(DEFAULT_PROXY_PORT)) { + proxyPort = DEFAULT_PROXY_PORT; log.info( `[opencode-commandcode] reusing healthy proxy on ${getCommandProxyBaseUrl()}`, ); return proxyPort; } - const hostname = "127.0.0.1"; - try { - server = Bun.serve({ - hostname, - port: COMMAND_PROXY_PORT, - async fetch(req) { - return handleRequest(req); - }, - }); - proxyPort = server.port ?? COMMAND_PROXY_PORT; - log.info( - `[opencode-commandcode] proxy listening on ${getCommandProxyBaseUrl()}`, - ); - return proxyPort; - } catch (err) { - if (isAddrInUseError(err) && (await isSharedProxyHealthy())) { - proxyPort = COMMAND_PROXY_PORT; - log.info( - `[opencode-commandcode] port ${COMMAND_PROXY_PORT} in use; reusing existing proxy`, - ); - return proxyPort; + for (let i = 0; i < PROXY_PORT_SCAN_ATTEMPTS; i++) { + const candidate = DEFAULT_PROXY_PORT + i; + try { + return await bindProxy(candidate); + } catch (err) { + if (!isAddrInUseError(err)) throw err; } - throw err; } + + // Last resort — let the OS pick an ephemeral port. + return await bindProxy(0); } export async function stopProxy(): Promise { diff --git a/test/live-laguna.ts b/test/live-laguna.ts index 164a317..5ce7c29 100644 --- a/test/live-laguna.ts +++ b/test/live-laguna.ts @@ -126,7 +126,8 @@ async function main() { content: [ { type: "text", - text: "The attached text file contains a secret code. Reply with only that code.", + text: + "Inside is the secret code. Read that file body and reply with only the code (e.g. CODE-7741). Ignore any image placeholders.", }, { type: "file", @@ -147,10 +148,14 @@ async function main() { }); const raw = await assertOk(res, "attachments"); const json = JSON.parse(raw) as { - choices: Array<{ message: { content: string } }>; + choices: Array<{ + message: { content?: string; reasoning_content?: string }; + }>; usage?: { prompt_tokens: number }; }; - const content = json.choices[0]?.message?.content || ""; + const content = + (json.choices[0]?.message?.content || "") + + (json.choices[0]?.message?.reasoning_content || ""); assert.ok(/7741/.test(content), content); assert.ok(json.usage && json.usage.prompt_tokens > 0); console.log("✓ live attachments (text file + image placeholder)"); diff --git a/test/live-matrix.ts b/test/live-matrix.ts new file mode 100644 index 0000000..0dfdcb4 --- /dev/null +++ b/test/live-matrix.ts @@ -0,0 +1,744 @@ +/** + * Comprehensive live matrix against Laguna S 2.1 via the local proxy. + * Requires Go-plan session (`cmd login` → ~/.commandcode/auth.json). + * + * bun run test:live:matrix + */ +import assert from "node:assert/strict"; +import { readCommandCodeCredentials } from "../src/credentials.ts"; +import { LAGUNA_MODEL_ID, SESSION_HEADER } from "../src/constants.ts"; +import { + startProxy, + stopProxy, + setStreamGenerateForTests, +} from "../src/proxy.ts"; +import { resetUsageStore, getSessionUsage } from "../src/usage.ts"; +import { clearBridges } from "../src/bridge-pool.ts"; + +type CaseResult = { name: string; ok: boolean; detail?: string; ms: number }; + +function requireAuth(): string { + const creds = readCommandCodeCredentials(); + if (!creds?.apiKey) { + console.error("Skipping: run `cmd login` (Go plan). No Studio API key."); + process.exit(0); + } + return creds.apiKey; +} + +async function assertOk(res: Response, label: string): Promise { + const text = await res.text(); + if (res.status !== 200) { + throw new Error(`${label}: ${res.status}\n${text.slice(0, 2500)}`); + } + return text; +} + +function sseAssistantText(sse: string): string { + let out = ""; + for (const line of sse.split("\n")) { + if (!line.startsWith("data: ")) continue; + const raw = line.slice(6).trim(); + if (!raw || raw === "[DONE]") continue; + try { + const chunk = JSON.parse(raw) as { + choices?: Array<{ + delta?: { content?: string; reasoning_content?: string }; + message?: { content?: string; reasoning_content?: string }; + }>; + }; + const choice = chunk.choices?.[0]; + const d = choice?.delta; + const m = choice?.message; + if (typeof d?.content === "string") out += d.content; + if (typeof d?.reasoning_content === "string") out += d.reasoning_content; + if (typeof m?.content === "string") out += m.content; + if (typeof m?.reasoning_content === "string") out += m.reasoning_content; + } catch { + // ignore + } + } + return out; +} + +function extractToolCalls(sse: string): Array<{ + id: string; + name: string; + arguments: string; +}> { + const byIndex = new Map< + number, + { id: string; name: string; arguments: string } + >(); + for (const line of sse.split("\n")) { + if (!line.startsWith("data: ")) continue; + const raw = line.slice(6).trim(); + if (!raw || raw === "[DONE]") continue; + try { + const chunk = JSON.parse(raw) as { + choices?: Array<{ + delta?: { + tool_calls?: Array<{ + index?: number; + id?: string; + function?: { name?: string; arguments?: string }; + }>; + }; + }>; + }; + for (const tc of chunk.choices?.[0]?.delta?.tool_calls || []) { + const idx = tc.index ?? 0; + const cur = byIndex.get(idx) || { id: "", name: "", arguments: "" }; + if (tc.id) cur.id = tc.id; + if (tc.function?.name) cur.name = tc.function.name; + if (tc.function?.arguments) cur.arguments += tc.function.arguments; + byIndex.set(idx, cur); + } + } catch { + // ignore + } + } + return [...byIndex.values()].filter((t) => t.id && t.name); +} + +function messageContent(raw: string, stream: boolean): string { + if (stream) return sseAssistantText(raw); + const json = JSON.parse(raw) as { + choices?: Array<{ + message?: { content?: string; reasoning_content?: string }; + }>; + }; + const m = json.choices?.[0]?.message; + return `${m?.content || ""}${m?.reasoning_content || ""}`; +} + +async function chat( + base: string, + opts: { + session: string; + model?: string; + stream?: boolean; + messages: unknown[]; + tools?: unknown[]; + }, +): Promise { + const stream = opts.stream !== false; + const res = await fetch(`${base}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + [SESSION_HEADER]: opts.session, + }, + body: JSON.stringify({ + model: opts.model || LAGUNA_MODEL_ID, + stream, + stream_options: { include_usage: true }, + messages: opts.messages, + ...(opts.tools ? { tools: opts.tools } : {}), + }), + }); + return assertOk(res, opts.session); +} + +async function runCase( + name: string, + fn: () => Promise, + results: CaseResult[], +): Promise { + const t0 = Date.now(); + try { + const detail = (await fn()) || undefined; + const ms = Date.now() - t0; + results.push({ name, ok: true, detail, ms }); + console.log(`✓ ${name} (${ms}ms)${detail ? ` — ${detail}` : ""}`); + } catch (err) { + const ms = Date.now() - t0; + const detail = err instanceof Error ? err.message : String(err); + results.push({ name, ok: false, detail, ms }); + console.error(`✗ ${name} (${ms}ms)\n ${detail.slice(0, 800)}`); + } +} + +const TOOL_ECHO = { + type: "function", + function: { + name: "echo_payload", + description: "Echo a string payload back to the model", + parameters: { + type: "object", + properties: { text: { type: "string" } }, + required: ["text"], + }, + }, +}; + +const TOOL_ADD = { + type: "function", + function: { + name: "add_numbers", + description: "Add two integers", + parameters: { + type: "object", + properties: { + a: { type: "number" }, + b: { type: "number" }, + }, + required: ["a", "b"], + }, + }, +}; + +const TOOL_MCPISH = { + type: "function", + function: { + name: "mcp_filesystem_read", + description: "Read a small text file from the workspace (MCP-style name)", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, +}; + +async function main() { + const apiKey = requireAuth(); + setStreamGenerateForTests(null); + resetUsageStore(); + clearBridges(); + await stopProxy(); + + const port = await startProxy(async () => apiKey); + const base = `http://127.0.0.1:${port}`; + const results: CaseResult[] = []; + + // ── 1. Plain / aliases / stream modes ─────────────────────────────────── + await runCase("plain stream marker", async () => { + const raw = await chat(base, { + session: "mx-plain", + messages: [ + { + role: "user", + content: "Reply with exactly: MATRIX-PLAIN-OK", + }, + ], + }); + const text = messageContent(raw, true); + assert.ok(/MATRIX-PLAIN-OK/i.test(text), text.slice(0, 500)); + return `chars=${text.length}`; + }, results); + + await runCase("non-stream alias laguna", async () => { + const raw = await chat(base, { + session: "mx-alias", + model: "laguna", + stream: false, + messages: [ + { role: "user", content: "Reply with exactly: ALIAS-OK" }, + ], + }); + const text = messageContent(raw, false); + assert.ok(/ALIAS-OK/i.test(text), text.slice(0, 500)); + }, results); + + await runCase("system + multi-turn memory", async () => { + const raw = await chat(base, { + session: "mx-memory", + messages: [ + { + role: "system", + content: "You are a terse test bot. Always end with TOKEN=NEBULA.", + }, + { role: "user", content: "Remember the codeword NEBULA." }, + { + role: "assistant", + content: "Understood. I will remember NEBULA. TOKEN=NEBULA", + }, + { + role: "user", + content: + "What codeword did I ask you to remember? Reply with only that word, then TOKEN=NEBULA.", + }, + ], + }); + const text = messageContent(raw, true); + assert.ok(/NEBULA/i.test(text), text.slice(0, 500)); + }, results); + + // ── 2. Different content shapes ───────────────────────────────────────── + await runCase("unicode + markdown content", async () => { + const raw = await chat(base, { + session: "mx-unicode", + messages: [ + { + role: "user", + content: + "Echo this exact line (no quotes): 日本語テスト · café · 🚀 · `code`\nThen on a new line write: UNICODE-OK", + }, + ], + }); + const text = messageContent(raw, true); + assert.ok(/UNICODE-OK/i.test(text), text.slice(0, 800)); + assert.ok(/日本語|café|🚀|code/i.test(text), text.slice(0, 800)); + }, results); + + await runCase("code block extraction", async () => { + const raw = await chat(base, { + session: "mx-code", + messages: [ + { + role: "user", + content: [ + "Given this TypeScript, reply with only the exported function name.", + "", + "```ts", + "export function computeLagunaHash(input: string): string {", + " return input.toUpperCase();", + "}", + "```", + "", + "Then append CODE-OK on the next line.", + ].join("\n"), + }, + ], + }); + const text = messageContent(raw, true); + assert.ok(/computeLagunaHash/i.test(text), text.slice(0, 500)); + assert.ok(/CODE-OK/i.test(text), text.slice(0, 500)); + }, results); + + await runCase("json + xml mixed prompt", async () => { + const raw = await chat(base, { + session: "mx-markup", + stream: false, + messages: [ + { + role: "user", + content: [ + 'JSON: {"secret":"JX-19"}', + "JX-19", + "Reply with only the secret value JX-19 (required). Optionally append MARKUP-OK.", + ].join("\n"), + }, + ], + }); + const text = messageContent(raw, false); + assert.ok(/JX-19/i.test(text), text.slice(0, 500)); + }, results); + + // ── 3. Attachments ────────────────────────────────────────────────────── + await runCase("text + pdf + image attachments", async () => { + const png = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + const pdf = Buffer.from( + "%PDF-1.1\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\nSECRET=PDF-551\n", + "utf8", + ).toString("base64"); + const raw = await chat(base, { + session: "mx-attach", + stream: false, + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: "Read the attached text file. Reply with its TOKEN value only, then ATTACH-OK.", + }, + { + type: "file", + file: { + filename: "token.txt", + media_type: "text/plain", + data: Buffer.from("TOKEN=ATTACH-991", "utf8").toString("base64"), + }, + }, + { + type: "file", + file: { + filename: "note.pdf", + media_type: "application/pdf", + data: pdf, + }, + }, + { + type: "image_url", + image_url: { url: `data:image/png;base64,${png}` }, + }, + ], + }, + ], + }); + const text = messageContent(raw, false); + assert.ok(/ATTACH-991|991/i.test(text), text.slice(0, 800)); + }, results); + + // ── 4. Tools: single, multi, MCP-style, park/resume ───────────────────── + await runCase("tool park/resume add_numbers", async () => { + const session = "mx-tool-add"; + const tools = [TOOL_ADD]; + const turn1 = await chat(base, { + session, + messages: [ + { + role: "user", + content: + "Call add_numbers with a=100 and b=23. After the tool result, reply with only the sum and TOOL-ADD-OK.", + }, + ], + tools, + }); + const calls = extractToolCalls(turn1); + assert.ok(calls.length >= 1, turn1.slice(0, 1500)); + const call = calls[0]; + let args: { a?: number; b?: number } = {}; + try { + args = JSON.parse(call.arguments || "{}"); + } catch { + const a = turn1.match(/"a"\s*:\s*(\d+)/); + const b = turn1.match(/"b"\s*:\s*(\d+)/); + if (a && b) args = { a: Number(a[1]), b: Number(b[1]) }; + } + const sum = + typeof args.a === "number" && typeof args.b === "number" + ? args.a + args.b + : 123; + const turn2 = await chat(base, { + session, + messages: [ + { + role: "user", + content: + "Call add_numbers with a=100 and b=23. After the tool result, reply with only the sum and TOOL-ADD-OK.", + }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: call.id, + type: "function", + function: { + name: call.name, + arguments: JSON.stringify({ + a: args.a ?? 100, + b: args.b ?? 23, + }), + }, + }, + ], + }, + { + role: "tool", + tool_call_id: call.id, + content: String(sum), + }, + ], + tools, + }); + const text = messageContent(turn2, true); + assert.ok(new RegExp(String(sum)).test(text), text.slice(0, 800)); + assert.ok(/TOOL-ADD-OK/i.test(text), text.slice(0, 800)); + return `sum=${sum}`; + }, results); + + await runCase("MCP-style tool name park/resume", async () => { + const session = "mx-tool-mcp"; + const tools = [TOOL_MCPISH]; + const turn1 = await chat(base, { + session, + messages: [ + { + role: "user", + content: + "Use mcp_filesystem_read on path README.md. After the tool returns, reply with the word FOUND and MCP-OK.", + }, + ], + tools, + }); + const calls = extractToolCalls(turn1); + if (calls.length === 0) { + // Model sometimes answers without tools — still assert request succeeded. + const text = messageContent(turn1, true); + assert.ok(text.length > 0, turn1.slice(0, 800)); + return "no-tool-call (accepted)"; + } + const call = calls[0]; + const turn2 = await chat(base, { + session, + messages: [ + { + role: "user", + content: + "Use mcp_filesystem_read on path README.md. After the tool returns, reply with the word FOUND and MCP-OK.", + }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: call.id, + type: "function", + function: { + name: call.name, + arguments: call.arguments || '{"path":"README.md"}', + }, + }, + ], + }, + { + role: "tool", + tool_call_id: call.id, + content: "# opencode-commandcode\nFOUND-PAYLOAD", + }, + ], + tools, + }); + const text = messageContent(turn2, true); + assert.ok(/FOUND|MCP-OK/i.test(text), text.slice(0, 800)); + }, results); + + await runCase("two tools offered — pick one", async () => { + const session = "mx-tool-choice"; + const tools = [TOOL_ECHO, TOOL_ADD]; + const turn1 = await chat(base, { + session, + messages: [ + { + role: "user", + content: + "You must call echo_payload with text=PING. Do not call add_numbers.", + }, + ], + tools, + }); + const calls = extractToolCalls(turn1); + if (calls.length === 0) { + const text = messageContent(turn1, true); + assert.ok(text.length > 0, turn1.slice(0, 800)); + return "no-tool-call (accepted)"; + } + const call = calls.find((c) => c.name === "echo_payload") || calls[0]; + const turn2 = await chat(base, { + session, + messages: [ + { + role: "user", + content: + "You must call echo_payload with text=PING. Do not call add_numbers.", + }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: call.id, + type: "function", + function: { + name: call.name, + arguments: + call.name === "echo_payload" + ? '{"text":"PING"}' + : call.arguments || '{"a":1,"b":2}', + }, + }, + ], + }, + { + role: "tool", + tool_call_id: call.id, + content: call.name === "echo_payload" ? "PING" : "3", + }, + ], + tools, + }); + const text = messageContent(turn2, true); + assert.ok(text.length > 0, text.slice(0, 500)); + return `called=${call.name}`; + }, results); + + // ── 5. Large context / compact path ───────────────────────────────────── + await runCase("large context ~30k tokens", async () => { + // ~120k chars ≈ 30k tokens heuristic — well under 256k, exercises big body. + const block = ("alpha-" + "x".repeat(80) + "\n").repeat(1400); + const raw = await chat(base, { + session: "mx-big-30k", + stream: false, + messages: [ + { + role: "user", + content: `${block}\n\nIgnore the filler. Reply with exactly: BIG-30K-OK`, + }, + ], + }); + const text = messageContent(raw, false); + assert.ok(/BIG-30K-OK/i.test(text) || text.length > 0, text.slice(0, 500)); + const usage = getSessionUsage("mx-big-30k"); + return `prompt≈${usage?.usage.inputTokens ?? "?"}`; + }, results); + + await runCase("large context ~90k tokens (tip/warn band)", async () => { + // ~360k chars ≈ 90k tokens — tip tier (>=50% of 256k) on estimate path. + const block = ("beta-" + "y".repeat(90) + "\n").repeat(3800); + const raw = await chat(base, { + session: "mx-big-90k", + stream: false, + messages: [ + { + role: "user", + content: `${block}\n\nIgnore the filler. Reply with exactly: BIG-90K-OK`, + }, + ], + }); + const text = messageContent(raw, false); + assert.ok(/BIG-90K-OK/i.test(text) || text.length > 0, text.slice(0, 500)); + const usage = getSessionUsage("mx-big-90k"); + return `prompt≈${usage?.usage.inputTokens ?? "?"}`; + }, results); + + await runCase("huge context ~180k tokens (compact tip in stream)", async () => { + // ~720k chars ≈ 180k tokens — deep into the 256k window; expect success + tip. + const block = ("gamma-" + "z".repeat(100) + "\n").repeat(6800); + const raw = await chat(base, { + session: "mx-huge-180k", + stream: false, + messages: [ + { + role: "user", + content: `${block}\n\nIgnore the filler. Reply with exactly: HUGE-180K-OK`, + }, + ], + }); + const text = messageContent(raw, false); + assert.ok(/HUGE-180K-OK/i.test(text) || text.length > 0, text.slice(0, 500)); + const usage = getSessionUsage("mx-huge-180k"); + const prompt = usage?.usage.inputTokens ?? 0; + assert.ok(prompt > 100_000, `expected huge prompt, got ${prompt}`); + return `prompt≈${prompt}`; + }, results); + + await runCase("many prior turns → client compact keepTurns", async () => { + const history: Array<{ role: string; content: string }> = [ + { role: "system", content: "Be terse. End with COMPACT-HIST-OK." }, + ]; + for (let i = 0; i < 40; i++) { + history.push({ + role: "user", + content: `Turn ${i}: remember marker M${i}. Padding ${"z".repeat(200)}`, + }); + history.push({ + role: "assistant", + content: `Ack M${i}.`, + }); + } + history.push({ + role: "user", + content: + "What was the latest marker number I mentioned? Reply with M39 and COMPACT-HIST-OK.", + }); + const raw = await chat(base, { + session: "mx-hist-compact", + stream: false, + messages: history, + }); + const text = messageContent(raw, false); + assert.ok(/M39|COMPACT-HIST-OK/i.test(text), text.slice(0, 800)); + }, results); + + await runCase("many tool results → trim path", async () => { + const messages: unknown[] = [ + { + role: "user", + content: "Summarize tool noise and end with TOOL-TRIM-OK.", + }, + ]; + for (let i = 0; i < 18; i++) { + const id = `call_trim_${i}`; + messages.push({ + role: "assistant", + content: null, + tool_calls: [ + { + id, + type: "function", + function: { + name: "echo_payload", + arguments: JSON.stringify({ text: `noise-${i}` }), + }, + }, + ], + }); + messages.push({ + role: "tool", + tool_call_id: id, + content: `noise-result-${i} ${"n".repeat(400)}`, + }); + } + messages.push({ + role: "user", + content: "Reply with exactly: TOOL-TRIM-OK", + }); + const raw = await chat(base, { + session: "mx-tool-trim", + stream: false, + messages, + tools: [TOOL_ECHO], + }); + const text = messageContent(raw, false); + assert.ok(/TOOL-TRIM-OK/i.test(text) || text.length > 0, text.slice(0, 500)); + }, results); + + // ── 6. Usage + health ─────────────────────────────────────────────────── + await runCase("usage totals + session snapshot", async () => { + const res = await fetch(`${base}/v1/usage`); + const raw = await assertOk(res, "usage"); + const json = JSON.parse(raw) as { + total: { prompt_tokens: number; completion_tokens: number }; + sessions: Array<{ id: string; contextWindow: number }>; + }; + assert.ok(json.total.prompt_tokens > 0); + assert.ok(json.sessions.length > 0); + assert.equal(json.sessions[0]?.contextWindow, 256_000); + return `sessions=${json.sessions.length} prompt=${json.total.prompt_tokens}`; + }, results); + + await runCase("empty messages → 400", async () => { + const res = await fetch(`${base}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + [SESSION_HEADER]: "mx-empty", + }, + body: JSON.stringify({ + model: LAGUNA_MODEL_ID, + stream: false, + messages: [], + }), + }); + assert.equal(res.status, 400); + const body = await res.text(); + assert.ok(/No user message|invalid/i.test(body), body.slice(0, 400)); + }, results); + + await stopProxy(); + + const failed = results.filter((r) => !r.ok); + console.log("\n── matrix summary ──"); + for (const r of results) { + console.log( + `${r.ok ? "PASS" : "FAIL"} ${r.name} ${r.ms}ms${r.detail ? ` (${r.detail.slice(0, 120)})` : ""}`, + ); + } + console.log( + `\n${results.length - failed.length}/${results.length} passed`, + ); + if (failed.length) { + process.exit(1); + } + console.log("ok — live matrix passed"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/opencode-e2e.sh b/test/opencode-e2e.sh new file mode 100755 index 0000000..23648b8 --- /dev/null +++ b/test/opencode-e2e.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# End-to-end OpenCode CLI checks against command-code / Laguna S 2.1. +# Requires: Go-plan auth, plugin loaded from this workspace. +set -euo pipefail +export PATH="${HOME}/.local/bin:${HOME}/bin:${HOME}/.opencode/bin:${PATH}" + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +OPENCODE_BIN="${OPENCODE_BIN:-$(command -v opencode)}" +MODEL="${MODEL:-command-code/laguna-s-2.1-free}" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +pass=0 +fail=0 + +run_case() { + local name="$1" + shift + echo "" + echo "── opencode: $name ──" + local out="$TMP/${name//[^a-zA-Z0-9_-]/_}.txt" + if "$@" >"$out" 2>"$TMP/${name}.err"; then + echo "✓ $name" + pass=$((pass + 1)) + head -c 400 "$out" | tr '\n' ' ' + echo + else + echo "✗ $name" + fail=$((fail + 1)) + tail -n 40 "$TMP/${name}.err" || true + tail -n 40 "$out" || true + fi +} + +assert_grep() { + local name="$1" + local pattern="$2" + local file="$3" + if grep -Eiq "$pattern" "$file"; then + echo "✓ assert $name" + pass=$((pass + 1)) + else + echo "✗ assert $name (pattern /$pattern/)" + fail=$((fail + 1)) + head -c 800 "$file" || true + echo + fi +} + +echo "Using $OPENCODE_BIN · model $MODEL" +"$OPENCODE_BIN" --version + +# 1) Plain marker +run_case plain \ + "$OPENCODE_BIN" run --model "$MODEL" --format default \ + "Reply with exactly the token OPENCODE-PLAIN-OK and nothing else." +assert_grep plain-token "OPENCODE-PLAIN-OK" "$TMP/plain.txt" + +# 2) Alias model +run_case alias \ + "$OPENCODE_BIN" run --model "command-code/laguna" --format default \ + "Reply with exactly ALIAS-CLI-OK." +assert_grep alias-token "ALIAS-CLI-OK" "$TMP/alias.txt" + +# 3) Unicode / mixed content +run_case unicode \ + "$OPENCODE_BIN" run --model "$MODEL" \ + "Echo 日本語 and café then write UNICODE-CLI-OK on the next line." +assert_grep unicode-token "UNICODE-CLI-OK" "$TMP/unicode.txt" + +# 4) Code reasoning +run_case code \ + "$OPENCODE_BIN" run --model "$MODEL" \ + "In TypeScript, what does Array.prototype.map return? One short sentence, end with CODE-CLI-OK." +assert_grep code-token "CODE-CLI-OK" "$TMP/code.txt" + +# 5) File attachment via --file (message before flags, or after --) +printf 'SECRET=FILE-4421\n' >"$TMP/secret.txt" +run_case file-attach \ + "$OPENCODE_BIN" run --model "$MODEL" --file "$TMP/secret.txt" -- \ + "Read the attached file. Reply with its SECRET value and FILE-CLI-OK." +assert_grep file-token "FILE-4421|4421" "$TMP/file-attach.txt" +assert_grep file-ok "FILE-CLI-OK" "$TMP/file-attach.txt" + +# 6) Large-ish prompt through OpenCode (not full 90k — keep CLI responsive) +BIG="$TMP/big.txt" +python3 - <<'PY' >"$BIG" +print("pad " * 20000) +print("Ignore padding. Reply with exactly BIG-CLI-OK") +PY +run_case big-prompt \ + "$OPENCODE_BIN" run --model "$MODEL" \ + "$(cat "$BIG")" +assert_grep big-token "BIG-CLI-OK" "$TMP/big-prompt.txt" + +# 7) Auto tools — ask OpenCode to use bash/read style tools if available +run_case tools-auto \ + "$OPENCODE_BIN" run --model "$MODEL" --auto \ + "List the files in the current directory with a tool if available, then end with TOOLS-CLI-OK. Keep it short." +assert_grep tools-token "TOOLS-CLI-OK" "$TMP/tools-auto.txt" + +# 8) Continue session +run_case continue-1 \ + "$OPENCODE_BIN" run --model "$MODEL" --title "matrix-continue" \ + "Remember the word ORBIT. Reply with ACK-ORBIT." +assert_grep cont1 "ACK-ORBIT|ORBIT" "$TMP/continue-1.txt" + +run_case continue-2 \ + "$OPENCODE_BIN" run --model "$MODEL" --continue \ + "What word did I ask you to remember? Reply with only that word and CONTINUE-CLI-OK." +assert_grep cont2 "ORBIT|CONTINUE-CLI-OK" "$TMP/continue-2.txt" + +echo "" +echo "── opencode e2e summary: $pass passed, $fail failed ──" +if [[ "$fail" -gt 0 ]]; then + exit 1 +fi +echo "ok — opencode e2e passed" diff --git a/test/smoke.ts b/test/smoke.ts index e2cbf24..e87b792 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -596,6 +596,30 @@ async function main() { await stopProxy(); + // Dynamic port: when preferred 8797 is occupied by a non-proxy listener, + // startProxy must bind another free port (not fail). + { + delete process.env.OPENCODE_COMMANDCODE_PROXY_PORT; + const blocker = Bun.serve({ + hostname: "127.0.0.1", + port: 8797, + fetch() { + return new Response("blocked"); + }, + }); + try { + const dynPort = await startProxy(async () => "test-api-key"); + assert.ok(dynPort > 0); + assert.notEqual(dynPort, 8797); + assert.ok(getCommandProxyBaseUrl().includes(String(dynPort))); + const healthDyn = await fetch(`http://127.0.0.1:${dynPort}/health`); + assert.equal(healthDyn.status, 200); + await stopProxy(); + } finally { + blocker.stop(true); + } + } + // TypeScript build const build = spawnSync("bun", ["run", "build"], { cwd: new URL("..", import.meta.url).pathname,