diff --git a/.github/workflows/seam-tests.yml b/.github/workflows/seam-tests.yml index 7120d3b..348924f 100644 --- a/.github/workflows/seam-tests.yml +++ b/.github/workflows/seam-tests.yml @@ -34,3 +34,8 @@ jobs: run: npm test - name: Patcher gate (seams + patch inventory) run: node scripts/apply-patches.mjs --check + # Rebase drift gate (T12, #13): the full 14-patch stack must apply + # cleanly to a pristine archive copy of the pinned baseline. Runs + # after the suite so a red here names the drifting patch file. + - name: Rebase gate (clean apply against upstream HEAD) + run: node scripts/verify-upstream-rebase.mjs diff --git a/AUDIT_REPORT.md b/AUDIT_REPORT.md index a68ba85..8a6f508 100644 --- a/AUDIT_REPORT.md +++ b/AUDIT_REPORT.md @@ -259,3 +259,60 @@ A2, the one correctness gap found in BYOK mode, is closed at source level (a logged-in live run is still owed). The **launcher-only path** (stock build + injector) was not actually unlocking XHR traffic before this branch; do not rely on it from `main`. + +## 10. Milestone gate addendum (2026-10-12): Phase 4 on `integration/v0.2` + +Tickets T01–T13 landed sequentially on `integration/v0.2` (merge commits +`f35beec`…`b08959e` pre-T13), each under worktree isolation with +explicit staging, target + full-suite QA, and an auditor diff pass. +Fixture `review/GDevelop` verified empty (`status --short`) before and +after every merge. + +Full gate, executed 2026-10-12 (all real I/O, offline, bare `node:test`): + +| Gate | Result | +|---|---| +| `npm test` | **213/213 pass, 0 fail** (62 suites) | +| `npm run test:opencode` (new, T11) | **60/60 pass** (unit + `opencode-*` integration + e2e; overlaps the other suites by design — a focused re-run, not new count) | +| `npm run test:e2e` (new, T11) | **5/5 pass** (`tests/e2e/gdevelop-opencode-byok-e2e.test.mjs`) | +| `node scripts/apply-patches.mjs --check` | green (seam suite green) | +| `node scripts/verify-upstream-rebase.mjs` (new, T12) | **CLEAN**: all 14 patches apply + marker-verify at `8301cdcaa48b496f48a28a6e3a56fa833eaeddff` | + +Distinct-test total: **218** (213 suite + 5 e2e), above the ≥145 gate. +Stated limits (unchanged from §5/§9): no React/JSDOM in-repo, so the e2e +exercises the `CustomAIClient` HTTP contract plus the real disk mutation +rather than the Flow-typed module itself; live CI re-run and live-model / +logged-in proof remain owner-side steps. + +Issue rollup (all work on `integration/v0.2` unless noted): + +| Issue | Status after Phase 4 | +|---|---| +| #6 fork archival | Code independence done in-repo (`RETIREMENT.md`); owner-side archival of `Heretek-AI/GDevelop-Heretek` is the remaining human step — do not close until ticked | +| #7 upstream RFC | Noted: `docs/upstream-ai-provider-rfc.md` (+ `docs/upstream-drafts/credit-read-hardening.*`); no upstream PR opened from here | +| #8 AI-studio epic | **Fulfilled**: T02 debug logging, T03 manifest pruning, T04 no-project remedy, T05 suspended UI, T06 storm-guard isolation, patches 07–14 + `packages/ai-studio/` overlay | +| #20 opencode-bridge epic | **Fulfilled**: T07 control-shim, T08 provider daemon, T09 plugin tools, T10 architect agent, T11 e2e loop proving tool_calls → disk mutation → concluded generation with zero `api.gdevelop.io` egress | +| #26 LICENSE | **Closed in T01**: MIT `LICENSE` + `package.json` license field | +| #12 subagent loop proof | T11 e2e: 83-request-style loop replaced by a 5-assertion live loop (plan-equivalent tool round-trip, counter-server no-egress proof); logged-in live run still owed | +| #10 signing/CI | `release.yml` Windows (nsis+zip) + macOS (universal) jobs confirmed pinned/healthy; signing stays unsigned-by-design, documented in `docs/release-signing.md` (+T12 runner-health section) | +| #13 rebase runbook | `patches/REBASE.md` + executable gate `scripts/verify-upstream-rebase.mjs`, wired as the CI "Rebase gate" step in `seam-tests.yml` | +| #19 E2E BYOK loop | **Closed in T11** (`tests/e2e/`, `test:e2e` / `test:opencode` scripts) | + +Usage pointers added this phase: + +- **Debug logging:** launch with `--heretek-debug-log ` (or + `HERETEK_DEBUG_LOG=`); inspect with + `node scripts/dump-debug-log.mjs `. Off by default; see + `launcher/README.md`. +- **Plugin + bridge:** point the OpenCode config at + `packages/opencode-gdevelop/index.mjs` (plugin id `gdevelop`: six + `gdevelop_*` tools + `gdevelop-architect` persona, zero deps, zero + egress); serve the model endpoint with + `node packages/opencode-provider/cli.mjs [--port 0] [--host 127.0.0.1]` + and target it from Preferences → AI Settings. Details in + `packages/opencode-gdevelop/README.md`. + +**Verdict: CONDITIONAL PASS stands, scope extended.** The stack is +213/213 + 5/5 e2e green with the rebase gate clean at the pin. What is +still owed is unchanged in kind from §9: live CI execution, a +tool-capable live-model loop, and owner-side fork archival (#6). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6098822 --- /dev/null +++ b/LICENSE @@ -0,0 +1,47 @@ +MIT License + +Copyright (c) 2026 Heretek-AI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +SCOPE NOTE — what this license covers (and what it does not). + +1. This license covers the original files of this repository + (GDevelop-Unlocked): the patch stack and tooling under `patches/`, + `scripts/`, `launcher/`, `packages/`, `tests/`, `web-userscript/`, + `docs/`, and the repository's own documentation and workflows. + `package.json` names this same license (`"license": "MIT"`). + +2. Upstream GDevelop is NOT vendored in this repository. Release and + test flows apply the patch stack onto a stock upstream checkout + pinned in `UPSTREAM_BASELINE.json` (never committed under `review/`). + Upstream files remain under their own licenses: the Core library, + the game engine (GDJS), the new IDE, and all extensions (respectively + the `Core`, `GDJS`, `newIDE` and `Extensions` folders) are under the + MIT license; the name "GDevelop" and its logo are the exclusive + property of Florian Rival. See `LICENSE.md` in the upstream + checkout (root and per-folder) for the full texts. Verified against + upstream commit `8301cdcaa4` (see `UPSTREAM_BASELINE.json`). + +3. Files under `patches/` are transformation instructions (unified + diffs) that reference upstream files; they are this repository's + original work and are covered by this license. They do not reproduce + upstream sources. diff --git a/README.md b/README.md index e67c401..9654896 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,10 @@ additive). Full audit trail: `AUDIT_REPORT.md`. ```bash npm test # full suite (unit + integration), offline +npm run test:e2e # BYOK loop end-to-end (live provider + scratch project) +npm run test:opencode # opencode-focused subset (unit + opencode-* + e2e) node scripts/apply-patches.mjs --check # seam gate only, no filesystem changes +node scripts/verify-upstream-rebase.mjs # rebase drift gate: full stack vs pristine upstream HEAD # Apply everything to a pristine upstream checkout (then build it): node scripts/apply-patches.mjs --target @@ -41,17 +44,28 @@ commit. Nothing else external is needed — this repo is fork-independent. - `patches/` — 14 patches + `README.md` (per-patch targets) + `REBASE.md` - `scripts/` — `apply-patches.mjs` (driver), `generate-tier1/2-patches.mjs` - (generators), `inject-launcher.mjs`, `strip-updater-feed.mjs` (release) + (generators), `verify-upstream-rebase.mjs` (rebase drift gate), + `inject-launcher.mjs`, `strip-updater-feed.mjs` (release) - `launcher/` — `heretek-hook.cjs`, `preload-unlock.cjs`, `unlock-payloads.cjs` + (+ `debug-logger.cjs`: `--heretek-debug-log ` / `HERETEK_DEBUG_LOG`; + inspect with `node scripts/dump-debug-log.mjs `) - `packages/ai-studio/` — extracted Tier-2 runtime + `dev-harness/` mock provider - `packages/unlock-shim/` — routing/leak-guard invariants (reference impl) -- `tests/` — `seams/`, `patches/`, `launcher/`, `tier2/`, `web/`, `integration/` +- `packages/opencode-provider/` — local OpenAI-compatible daemon + (`node packages/opencode-provider/cli.mjs [--port 0]`); target it from + Preferences → AI Settings for BYOK +- `packages/opencode-gdevelop/` — OpenCode plugin (`index.mjs`, id + `gdevelop`): six `gdevelop_*` tools + `gdevelop-architect` persona, zero + deps, zero egress (see its README) +- `tests/` — `seams/`, `patches/`, `launcher/`, `tier2/`, `web/`, `integration/`, `e2e/` - `docs/` — `upstream-ai-provider-rfc.md` and friends - `web-userscript/` — web-editor demo userscript (not a delivery path) ## CI -- `seam-tests` — full `npm test` + patcher gate against the pinned upstream commit +- `seam-tests` — full `npm test` + patcher gate + rebase gate + (`verify-upstream-rebase.mjs`: full stack vs pristine baseline) against + the pinned upstream commit - `e2e` — release-artifact smoke under `xvfb` (`tier1-smoke`), OpenAI-contract check against the committed mock (`byok-mock`), real Ollama on schedule (`byok-ollama`) - `release` — manual dispatch: patched source → full build → smoke gate → @@ -62,5 +76,6 @@ commit. Nothing else external is needed — this repo is fork-independent. - Upstream baseline: `UPSTREAM_BASELINE.json` (the only pin — there is no donor pin; `tests/patches/pins.test.mjs` keeps the workflows on it) - Retirement plan for the donor fork: `RETIREMENT.md` -- Latest audit verdict: **conditional PASS** (`AUDIT_REPORT.md` §7) — - remaining work is live CI/model/browser proof, not code changes. +- Latest audit verdict: **conditional PASS** (`AUDIT_REPORT.md` §10) — + 213/213 + 5/5 e2e green, rebase gate clean at the pin; remaining work + is live CI/model/browser proof, not code changes. diff --git a/docs/release-signing.md b/docs/release-signing.md index a1e7a13..165530a 100644 --- a/docs/release-signing.md +++ b/docs/release-signing.md @@ -85,3 +85,42 @@ differently per OS: proven on a `publish=false` run before it matters. 3. Treat options 2–5 as a deliberate owner decision, after the naming question above is settled. + +## CI runner health + pre-release drift gate (T12, issues #10 #13) + +Reviewed 2026-10-12 against `release.yml` / `seam-tests.yml` at the +`8301cdcaa4` pin. No workflow changes needed for signing either way — +this section records the confirmation so the next rebase does not +re-derive it. + +- **Windows** (`build-windows`, `windows-latest`): builds **nsis + zip** + (`--win nsis zip --x64`), unsigned portable. Renderer/electron + `npm install` drops lockfiles per upstream's own Windows CI note + (`setuptools` for a native dep). Smoke-gated on the real `.exe`. +- **macOS** (`build-macos`, `macos-latest`): builds the **universal** + `.app` (`--mac dir --universal`), ad-hoc signed only so the arm64 + slice can launch, then ditto-zipped. Smoke-gated on the real binary. +- **Pinning:** `actions/checkout@v4` + `actions/setup-node@v4` + (Node 22, matching `package.json` engines), `UPSTREAM_REF` default + equals the `UPSTREAM_BASELINE.json` pin, `GD_PORTABLE_BUILD=true`, + `REQUIRES_EXACT_LIBGD_JS_VERSION=true`. The `pins.test.mjs` suite + fails on a partial pin bump, so workflows and baseline cannot drift + apart silently. +- **Signing-relevant gotcha (unchanged):** a future signed macOS job + must drop the ad-hoc `codesign --sign -` step (it would replace a + Developer ID signature) and verify with `codesign --verify --deep + --strict` + `spctl --assess` + `stapler validate` before upload — + see "What the build already supports" above. + +Pre-release drift check (run any time, changes nothing): + +```bash +node scripts/verify-upstream-rebase.mjs # UPSTREAM_DIR or review/GDevelop +``` + +It archives a pristine `newIDE/app/src + newIDE/electron-app` copy into +TMPDIR scratch, runs the real driver (`--target`, dry-run-then-apply +plus marker verify) and a read-only `--verify-only` second gate, then +removes the scratch tree. Exit 0 names the clean upstream SHA; exit 1 +lists the failing patch file(s). CI runs the same script as the +"Rebase gate" step of `seam-tests.yml` on every push/PR and weekly. diff --git a/docs/sonar-triage.md b/docs/sonar-triage.md new file mode 100644 index 0000000..cfe68fc --- /dev/null +++ b/docs/sonar-triage.md @@ -0,0 +1,49 @@ +# SonarCloud triage (ticket T01, issues #14 / #26) + +Date: 2026-10-12. Branch: `ticket-T01` (merge target `integration/v0.2`). + +## Verified state + +- No `.sonarcloud.properties` / `sonar-project.properties` exists in the + repo (checked 2026-10-12: neither file present, no `sonar` references in + `.github/workflows/`). No Sonar exclusions are configured in code. +- SonarCloud is **advisory only**: `main` has no required checks (ruleset + blocks only deletion and non-fast-forward), so a red Sonar gate never + blocked a merge. Per issue #14, "bypassed" meant merging with a red + advisory check. +- The S2/G3/D1/G4 hygiene items from #14 were already closed on the + handoff branch (shim-wiring contract test, `--verify-only` + negative + controls, launcher README rewrite, non-configurable fake test). + +## Residuals (accepted, owner action only) + +Per the #14 thread, after PR #15 Sonar reports Reliability **A** (was C) +and Security **C** (was E); blocker and all criticals are gone. What +remains is 6 MAJOR findings of two kinds that cannot be fixed in code +without harming the product: + +1. `npm install` with lifecycle scripts in `release.yml` (x4) — + upstream's own build needs lifecycle scripts (and upstream deletes + the lock files on Windows). Removing them breaks the release build. +2. "Path from a CLI argument" (x2) — CLI scripts whose argument is + legitimately a path (`apply-patches --target`, `inject-launcher + --appdir`). Sanitization beyond current validation would break the + tool's contract. + +Earlier triage on the same thread also accepted as inherent: S4036 +(PATH lookup when spawning `patch`/`diff`/`git`/`node`), S2245 +(`Math.random` request ids in hash-pinned `CustomAIClient.js`), S8707 +(CLI directory argument), S5122 (CORS `*` in the dev-only mock), +S6505/S8543 (same lifecycle-script class). CodeQL stays strict and +green (one real race in `strip-updater-feed.mjs` was fixed). + +## Decision (owner-only, not committable) + +No code change in this ticket can turn the gate green: the remaining +findings need an "Accept"/"Safe" click each in the SonarCloud UI, rule +tuning ("A on new code" goes red on any PR adding a `spawnSync` to +`scripts/`), or uninstalling SonarCloud. Deliberately **no** +`.sonarcloud.properties` was added: it only scopes paths, only takes +effect from the default branch, and could not be tested from a ticket +branch (per #14). This file documents the accepted residuals; the +UI-side accept/uninstall step is the owner's. diff --git a/launcher/README.md b/launcher/README.md index 042bb12..bf7ccf0 100644 --- a/launcher/README.md +++ b/launcher/README.md @@ -60,7 +60,7 @@ node scripts/apply-patches.mjs --target ``` Applies patch 06 (loader line at the top of `electron-app/app/main.js`) -together with the rest of the stack, and copies the three `.cjs` files +together with the rest of the stack, and copies the four `.cjs` files next to `main.js`. This is what `release.yml` builds from. Re-check an already-patched tree at any time, read-only: @@ -80,7 +80,7 @@ npx --yes @electron/asar pack /resources/app.asar ``` `inject-launcher.mjs` inserts the loader above the -`const electron = require('electron');` anchor and syncs the three +`const electron = require('electron');` anchor and syncs the four `.cjs` files. It is idempotent (a re-run is a verified no-op) and fails loudly if the anchor is gone. This is the path the `e2e` workflow's `tier1-smoke` job proves on every PR and every push to `main`/`develop`. @@ -132,6 +132,26 @@ line and these files vanish on restart. Do not enable `autoUpdater.autoDownload = true` unattended on hooked builds: a silent overnight update silently drops Tier-1. +## Client debug logging (T02) + +Off by default. Activate with `--heretek-debug-log ` or +`HERETEK_DEBUG_LOG=`; the main process then appends one JSON +object per line (`{ts, iso, source, level, event, data}`) via +`debug-logger.cjs` (sync writes, zero npm dependencies). An invalid +path disables logging instead of crashing. Recorded: hook install, +window creation / load / navigation, uncaught exceptions and unhandled +rejections, spoofed usage GETs, and — at `warn` — any non-spoofed +`api.gdevelop.io` attempt (the zero-egress proof aid). The renderer +buffers its own events (console errors/warnings, `error` and +`unhandledrejection`) on `window.__HERETEK_LOGGER__` (capped at 500), +and the Studio guard emits `[AI_REQUEST_START]`, `[TOOL_CALL]`, +`[TOOL_RESULT]`, `[GUARD_EVALUATE]`, `[REQUEST_STATE_CHANGE]` there when +it exists. Inspect a log with: + +```bash +node scripts/dump-debug-log.mjs [--event N] [--level L] [--request ID] [--json] +``` + ## Test doubles vs production `tests/launcher/` drives the hook with fake Electron objects. Two diff --git a/launcher/debug-logger.cjs b/launcher/debug-logger.cjs new file mode 100644 index 0000000..eb194fa --- /dev/null +++ b/launcher/debug-logger.cjs @@ -0,0 +1,142 @@ +// Client debug logging (T02): sync JSONL file appender for the launcher. +// +// Activated only by `--heretek-debug-log ` or the +// `HERETEK_DEBUG_LOG` env var; without either, `resolveDebugLogPath` +// returns null and nothing is created. Every record is one JSON object +// per line: {ts, iso, source, level, event, data}. +// +// Zero npm dependencies (node builtins only — pinned by +// tests/launcher/debug-logger.test.cjs). Writes are synchronous +// `fs.appendFileSync` so a crash never loses the line that explains it. +// An invalid path disables the logger instead of throwing: every method +// becomes a no-op returning null and the cause is kept on `.error`. +'use strict'; + +const fs = require('fs'); + +const DEBUG_LOG_ARG = '--heretek-debug-log'; +const DEBUG_LOG_ENV = 'HERETEK_DEBUG_LOG'; + +const LEVELS = ['debug', 'info', 'warn', 'error']; + +/** + * Where to log, or null when debug logging is off. The argv flag wins; + * a flag with no following path is treated as inactive (never a crash). + */ +function resolveDebugLogPath(opts) { + const options = opts || {}; + const argv = options.argv || process.argv; + const env = options.env || process.env; + const index = argv.indexOf(DEBUG_LOG_ARG); + if (index !== -1) { + const next = argv[index + 1]; + if (typeof next === 'string' && next.length > 0 && next.indexOf('--') !== 0) + return next; + return null; + } + const fromEnv = env[DEBUG_LOG_ENV]; + if (typeof fromEnv === 'string' && fromEnv.length > 0) return fromEnv; + return null; +} + +function toRecord(source, level, event, data) { + const ts = Date.now(); + let iso = ''; + try { + iso = new Date(ts).toISOString(); + } catch (error) { + iso = ''; + } + return { + ts, + iso, + source, + level, + event, + data: data && typeof data === 'object' ? data : {}, + }; +} + +function makeDisabledLogger(logPath, cause) { + function noop() { + return null; + } + return { + enabled: false, + logPath: logPath || null, + openError: cause, + log: noop, + debug: noop, + info: noop, + warn: noop, + error: noop, + close: () => {}, + }; +} + +/** + * Open the JSONL appender. `logPath` missing/unwritable (absent parent + * dir, a directory, permissions) yields a disabled logger — never throws. + */ +function createDebugLogger(opts) { + const options = opts || {}; + const logPath = options.logPath; + const source = options.source || 'main'; + if (typeof logPath !== 'string' || logPath.length === 0) { + return makeDisabledLogger( + null, + new Error('[heretek-debug] no log path configured') + ); + } + try { + // Eager probe: creates the file when missing, proves writability now + // instead of dropping the first real event later. + fs.appendFileSync(logPath, ''); + } catch (error) { + return makeDisabledLogger(logPath, error); + } + function log(level, event, data) { + let record; + try { + record = toRecord(source, level, event, data); + fs.appendFileSync(logPath, JSON.stringify(record) + '\n'); + } catch (error) { + return null; + } + return record; + } + return { + enabled: true, + logPath, + openError: null, + log, + debug: (event, data) => log('debug', event, data), + info: (event, data) => log('info', event, data), + warn: (event, data) => log('warn', event, data), + error: (event, data) => log('error', event, data), + close: () => {}, + }; +} + +/** Parse one JSONL line; null when malformed (callers count and skip). */ +function parseDebugLogLine(line) { + if (!line || typeof line !== 'string') return null; + const trimmed = line.trim(); + if (trimmed.length === 0) return null; + try { + const record = JSON.parse(trimmed); + if (!record || typeof record !== 'object') return null; + return record; + } catch (error) { + return null; + } +} + +module.exports = { + DEBUG_LOG_ARG, + DEBUG_LOG_ENV, + LEVELS, + resolveDebugLogPath, + createDebugLogger, + parseDebugLogLine, +}; diff --git a/launcher/heretek-hook.cjs b/launcher/heretek-hook.cjs index eb69d7f..9b7fb72 100644 --- a/launcher/heretek-hook.cjs +++ b/launcher/heretek-hook.cjs @@ -34,6 +34,52 @@ const { getRendererFallbackSource, } = require('./preload-unlock.cjs'); +// Optional: pre-T02 installs synced the hook without this file, so a +// missing module disables debug logging instead of breaking the hook. +let debugLoggerModule = null; +try { + debugLoggerModule = require('./debug-logger.cjs'); +} catch (error) { + debugLoggerModule = null; +} + +/** Structured file event; no-op unless an enabled debug logger is passed. */ +function debugLog(debug, level, event, data) { + try { + if (debug && debug.enabled && typeof debug.log === 'function') + return debug.log(level, event, data); + } catch (error) { + // Observability only — never break the hook. + } + return null; +} + +/** + * Debug logger for this install: an explicitly passed `opts.debugLogger` + * wins, otherwise `--heretek-debug-log ` / `HERETEK_DEBUG_LOG` + * activates one. Null when off (all debugLog calls no-op). + */ +function resolveHookDebugLogger(opts) { + const options = opts || {}; + if ( + options.debugLogger && + options.debugLogger.enabled && + debugLoggerModule + ) + return options.debugLogger; + if (!debugLoggerModule) return null; + const logPath = debugLoggerModule.resolveDebugLogPath({ + argv: options.argv || process.argv, + env: process.env, + }); + if (!logPath) return null; + const created = debugLoggerModule.createDebugLogger({ + logPath, + source: 'main', + }); + return created.enabled ? created : null; +} + // Absolute path so asar relocation cannot silently drop the hook. const PRELOAD_PATH = path.join(__dirname, 'preload-unlock.cjs'); @@ -136,7 +182,7 @@ function ensureRendererSeed(webContents, logger) { }); } -function watchWindowCreated(app, logger) { +function watchWindowCreated(app, logger, debug) { if (!app || typeof app.on !== 'function') { if (logger) logger.error('[heretek] app.on missing — pop-out coverage disabled.'); @@ -144,14 +190,33 @@ function watchWindowCreated(app, logger) { } app.on('browser-window-created', (event, win) => { if (!win || !win.webContents) return; + debugLog(debug, 'info', 'window-created', { + hasWebContents: true, + }); + if (typeof win.webContents.on === 'function') { + win.webContents.on('did-navigate', (navEvent, url) => { + debugLog(debug, 'info', 'window-did-navigate', { url }); + }); + } win.webContents.once('did-finish-load', () => { + debugLog(debug, 'info', 'window-did-finish-load', {}); ensureRendererSeed(win.webContents, logger); }); }); return true; } -function watchWebRequest(session, logger) { +const HOSTED_API_HOST = 'api.gdevelop.io'; + +function isHostedApiUrl(url) { + return ( + typeof url === 'string' && + (url.indexOf('https://' + HOSTED_API_HOST) === 0 || + url.indexOf('http://' + HOSTED_API_HOST) === 0) + ); +} + +function watchWebRequest(session, logger, debug) { if ( !session || !session.webRequest || @@ -161,10 +226,49 @@ function watchWebRequest(session, logger) { logger.error('[heretek] webRequest unavailable — network fallback off.'); return false; } - session.webRequest.onBeforeRequest( - { urls: [`${USAGE_API_BASE}/*`] }, - (details, callback) => handleBeforeRequest(details, callback) - ); + // With debug logging on, watch the whole hosted API surface (not just + // the spoofed usage paths) so any non-spoofed contact is flagged in the + // log — the zero-egress proof aid. Spoof behavior is unchanged: + // handleBeforeRequest still only redirects the two usage GETs. + const debugActive = !!(debug && debug.enabled); + const filter = debugActive + ? { urls: [`https://${HOSTED_API_HOST}/*`] } + : { urls: [`${USAGE_API_BASE}/*`] }; + session.webRequest.onBeforeRequest(filter, (details, callback) => { + const url = details && details.url; + const method = (details && details.method) || 'GET'; + if (debugActive && isHostedApiUrl(url)) { + if (shouldSpoofUrl(url, method)) { + debugLog(debug, 'debug', 'usage-spoofed', { url, method }); + } else { + debugLog(debug, 'warn', 'hosted-api-attempt', { url, method }); + } + } + handleBeforeRequest(details, callback); + }); + return true; +} + +/** + * Main-process crash observability: uncaught exceptions and unhandled + * rejections go to the debug log (when active) instead of vanishing. + * Armed at most once per debug logger; disabled loggers arm nothing. + */ +function watchUncaught(debug) { + if (!debug || !debug.enabled) return false; + if (debug.__heretekUncaughtArmed) return true; + debug.__heretekUncaughtArmed = true; + process.on('uncaughtException', error => { + debugLog(debug, 'error', 'uncaught-exception', { + message: error && error.message ? String(error.message) : String(error), + stack: error && error.stack ? String(error.stack).slice(0, 2000) : '', + }); + }); + process.on('unhandledRejection', reason => { + debugLog(debug, 'error', 'unhandled-rejection', { + reason: reason instanceof Error ? reason.message : String(reason), + }); + }); return true; } @@ -238,6 +342,7 @@ function wrapBrowserWindow(electron, logger) { function installPreloadHook(electronModule, opts) { const options = opts || {}; const logger = options.logger || console; + const debug = resolveHookDebugLogger(options); let electron = electronModule; if (!electron) { try { @@ -249,14 +354,14 @@ function installPreloadHook(electronModule, opts) { } const wrapped = wrapBrowserWindow(electron, logger); const app = electron.app; - const windowsWatched = app && watchWindowCreated(app, logger); + const windowsWatched = app && watchWindowCreated(app, logger, debug); // Real Electron throws on `session.defaultSession` before app ready — // set up now when possible, otherwise retry once ready. let requestWatched = false; let webRequestDeferred = false; const setupWebRequest = () => { const session = electron.session && electron.session.defaultSession; - if (session) requestWatched = watchWebRequest(session, logger); + if (session) requestWatched = watchWebRequest(session, logger, debug); }; try { setupWebRequest(); @@ -296,6 +401,17 @@ function installPreloadHook(electronModule, opts) { `${webRequestDeferred ? ' (deferred to app-ready)' : ''}, ` + `updater pin: ${!!updaterWatched}).` ); + const uncaughtWatched = watchUncaught(debug); + const result = { + wrapped, + windowsWatched: !!windowsWatched, + requestWatched: !!requestWatched, + webRequestDeferred, + updaterWatched: !!updaterWatched, + debugEnabled: !!(debug && debug.enabled), + uncaughtWatched: !!uncaughtWatched, + }; + debugLog(debug, 'info', 'hook-installed', result); try { armSmokeMode(electron, { logger, @@ -304,13 +420,7 @@ function installPreloadHook(electronModule, opts) { } catch (error) { logger.error('[heretek] smoke arm failed:', error); } - return { - wrapped, - windowsWatched: !!windowsWatched, - requestWatched: !!requestWatched, - webRequestDeferred, - updaterWatched: !!updaterWatched, - }; + return result; } /** @@ -504,6 +614,7 @@ module.exports = { PRELOAD_PATH, SPOOFED_USAGE_URLS, UPDATER_PIN_MESSAGE, + HOSTED_API_HOST, withUnlockPreload, shouldSpoofUrl, buildSpoofRedirectUrl, @@ -511,6 +622,7 @@ module.exports = { ensureRendererSeed, watchWindowCreated, watchWebRequest, + watchUncaught, watchUpdater, wrapBrowserWindow, installPreloadHook, diff --git a/launcher/preload-unlock.cjs b/launcher/preload-unlock.cjs index 979ddf5..d1ba3ad 100644 --- a/launcher/preload-unlock.cjs +++ b/launcher/preload-unlock.cjs @@ -73,6 +73,152 @@ function recordSpoof(scope, url) { } } +// In-memory renderer log (T02): the main-process JSONL appender cannot be +// reached from here, so renderer events buffer on +// `window.__HERETEK_LOGGER__` (capped) for inspection via +// executeJavaScript / smoke. Fully self-contained (no module-scope frees) +// so it embeds into the did-finish-load fallback source. `Date` is probed, +// not assumed: exotic realms may not provide it. +function createRendererLogger(scope) { + var events = []; + var MAX_RENDERER_LOG_EVENTS = 500; + function now() { + try { + if (typeof Date !== 'undefined' && Date && typeof Date.now === 'function') + return Date.now(); + } catch (error) { + // No clock: fall through to 0. + } + return 0; + } + function isoFor(ts) { + try { + if (typeof Date !== 'undefined' && Date) + return new Date(ts).toISOString(); + } catch (error) { + // Leave iso empty rather than breaking the log path. + } + return ''; + } + function pushRecord(level, event, data) { + var ts = now(); + var record = { + ts: ts, + iso: isoFor(ts), + source: 'renderer', + level: level, + event: event, + data: data && typeof data === 'object' ? data : {}, + }; + try { + events.push(record); + while (events.length > MAX_RENDERER_LOG_EVENTS) events.shift(); + } catch (error) { + // Observability only. + } + return record; + } + return { + events: events, + push: pushRecord, + debug: function rendererLogDebug(event, data) { + return pushRecord('debug', event, data); + }, + info: function rendererLogInfo(event, data) { + return pushRecord('info', event, data); + }, + warn: function rendererLogWarn(event, data) { + return pushRecord('warn', event, data); + }, + error: function rendererLogError(event, data) { + return pushRecord('error', event, data); + }, + }; +} + +function installRendererLogger(scope) { + var rendererLogger = createRendererLogger(scope); + try { + scope.__HERETEK_LOGGER__ = rendererLogger; + } catch (error) { + return rendererLogger; + } + // Renderer crash observability: hook first, before wrapping console, so + // a failure below still leaves the error taps installed. + try { + if (scope && typeof scope.addEventListener === 'function') { + scope.addEventListener('error', function heretekRendererError(event) { + pushRendererError(rendererLogger, event); + }); + scope.addEventListener('unhandledrejection', function heretekUnhandledRejection(event) { + var reason = + event && event.reason !== undefined ? event.reason : event; + rendererLogger.error('unhandled-rejection', { + reason: reason instanceof Error ? reason.message : String(reason), + }); + }); + } + } catch (error) { + // Taps are best-effort. + } + // Intercept console.error/warn: record the call, then pass through so + // devtools output is unchanged. + try { + if (scope && scope.console) { + ['error', 'warn'].forEach(function tapConsoleLevel(level) { + var original = scope.console[level]; + if (typeof original !== 'function') return; + scope.console[level] = function heretekConsoleTap() { + var args = Array.prototype.slice.call(arguments); + rendererLogger[level === 'error' ? 'error' : 'warn']( + level === 'error' ? 'console-error' : 'console-warn', + { args: args.map(safeConsoleArg) } + ); + return original.apply(this, args); + }; + }); + } + } catch (error) { + // Never break console. + } + return rendererLogger; +} + +function pushRendererError(rendererLogger, event) { + var message = ''; + try { + if (event) { + if (typeof event.message === 'string') message = event.message; + else message = String(event); + } + } catch (error) { + message = ''; + } + var data = { message: message }; + try { + if (event && typeof event.filename === 'string') + data.filename = event.filename; + if (event && typeof event.lineno === 'number') data.lineno = event.lineno; + } catch (error) { + // Keep the message-only record. + } + rendererLogger.error('renderer-error', data); +} + +function safeConsoleArg(value) { + try { + if (value instanceof Error) + return { error: value.message, stack: String(value.stack).slice(0, 500) }; + if (value && typeof value === 'object') + return JSON.parse(JSON.stringify(value)); + if (typeof value === 'function') return '[function]'; + if (typeof value === 'undefined') return '[undefined]'; + return value; + } catch (error) { + return '[unserializable]'; + } +} + function createFetchWrapper(originalFetch, scope) { return function heretekFetch(input, init) { const rawUrl = @@ -190,6 +336,7 @@ function patchXhr(XHRClass, scope) { function installRendererHook(scope) { seedIdentity(scope); + installRendererLogger(scope); if (scope.fetch) { try { scope.fetch = createFetchWrapper(scope.fetch.bind(scope), scope); @@ -227,6 +374,10 @@ const FALLBACK_EMBEDDED_FUNCTIONS = [ 'resolveUrlSafe', 'spoofBodyForRequest', 'recordSpoof', + 'createRendererLogger', + 'pushRendererError', + 'safeConsoleArg', + 'installRendererLogger', 'createFetchWrapper', 'setXhrResponse', 'fireXhrLoad', @@ -271,6 +422,10 @@ module.exports = { resolveUrlSafe, spoofBodyForRequest, recordSpoof, + createRendererLogger, + pushRendererError, + safeConsoleArg, + installRendererLogger, createFetchWrapper, setXhrResponse, fireXhrLoad, diff --git a/package.json b/package.json index f15ad6a..da555d2 100644 --- a/package.json +++ b/package.json @@ -2,16 +2,20 @@ "name": "gdevelop-unlocked", "version": "0.1.0", "private": true, + "license": "MIT", "type": "module", "description": "Surgical conversion of the GDevelop-Heretek fork: Tier-1 patcher + launcher hook, Tier-2 AI Studio package.", "scripts": { - "test": "node --test tests/seams/*.test.mjs tests/patches/*.test.mjs tests/launcher/*.test.cjs tests/tier2/*.test.mjs tests/web/*.test.mjs tests/integration/*.test.mjs", + "test": "node --test tests/seams/*.test.mjs tests/patches/*.test.mjs tests/launcher/*.test.cjs tests/tier2/*.test.mjs tests/web/*.test.mjs tests/unit/*.test.mjs tests/integration/*.test.mjs", "test:seams": "node --test tests/seams/*.test.mjs", "test:patches": "node --test tests/patches/*.test.mjs", "test:launcher": "node --test tests/launcher/*.test.cjs", "test:tier2": "node --test tests/tier2/*.test.mjs", "test:web": "node --test tests/web/*.test.mjs", + "test:unit": "node --test tests/unit/*.test.mjs", "test:integration": "node --test tests/integration/*.test.mjs", + "test:e2e": "node --test tests/e2e/*.test.mjs", + "test:opencode": "node --test tests/unit/*.test.mjs tests/integration/opencode-*.test.mjs tests/e2e/*.test.mjs", "patches:check": "node scripts/apply-patches.mjs --check", "patches:generate": "node scripts/generate-tier1-patches.mjs --upstream review/GDevelop" }, diff --git a/packages/ai-studio/AI/CustomAIClient.js b/packages/ai-studio/AI/CustomAIClient.js index d1e41b3..96a622c 100644 --- a/packages/ai-studio/AI/CustomAIClient.js +++ b/packages/ai-studio/AI/CustomAIClient.js @@ -2086,7 +2086,7 @@ export const GDEVELOP_OPENAI_TOOLS: Array<{| function: { name: 'search_docs', description: - 'Search GDevelop documentation for functions, behaviors, and instructions.', + 'Search GDevelop documentation for functions, behaviors, and instructions. [Offline in local BYOK mode: rely on internal GDevelop knowledge and on-project verification]', parameters: { type: 'object', properties: { @@ -2101,7 +2101,7 @@ export const GDEVELOP_OPENAI_TOOLS: Array<{| function: { name: 'read_full_docs', description: - 'Read full documentation for specific GDevelop extensions or features.', + 'Read full documentation for specific GDevelop extensions or features. [Offline in local BYOK mode: rely on internal GDevelop knowledge and on-project verification]', parameters: { type: 'object', properties: { @@ -3090,6 +3090,13 @@ Guidelines: `; + // T04 (#21): when no project JSON is present, no project is open. State the + // self-remedy explicitly so the model's first project-dependent call is + // `initialize_project` instead of a dead-end null failure. + if (!gameProjectJson) { + prompt += `\nNo project is currently open. You must call initialize_project(template_slug:'empty') to create one before using project tools.\n`; + } + if (gameProjectJson) { prompt += `\nCurrent Project Structure:\n${gameProjectJson}\n`; } @@ -3970,6 +3977,83 @@ export const BACKEND_ONLY_TOOL_NAMES: Array = [ 'get_game_starter_summary', ]; +/** + * T03 (#24) offline docs tools: the hosted docs backend is unreachable in + * local BYOK mode. Their descriptions carry the offline suffix (above) and + * every local call fails closed immediately with no network lag — see + * `buildOfflineDocsResult` and the Utils.js pre-pass that routes them + * before the editor dispatch. + */ +export const OFFLINE_DOCS_SUFFIX: string = + ' [Offline in local BYOK mode: rely on internal GDevelop knowledge and on-project verification]'; + +export const OFFLINE_DOCS_MESSAGE: string = + 'Documentation backend is offline in local mode. Rely on internal GDevelop knowledge and verify against the open project.'; + +export const OFFLINE_DOCS_TOOL_NAMES: Array = [ + 'search_docs', + 'read_full_docs', +]; + +export const isOfflineDocsTool = (toolName: string): boolean => + OFFLINE_DOCS_TOOL_NAMES.includes(toolName); + +/** + * Fail-closed result for an offline docs call. Synchronous with no network + * I/O so a docs lookup can never stall a local turn. + */ +export const buildOfflineDocsResult = (toolName: string): Object => ({ + success: false, + offline: true, + message: `${OFFLINE_DOCS_MESSAGE} (tool: ${toolName})`, +}); + +/** + * T03 (#24) local plan tool: `create_or_update_plan` is uncallable upstream + * (`EditorFunctions/index.js` fails it as "handled server-side"). Under BYOK + * it is routed to `Studio/PlanStore.js` (validate + merge over the request's + * existing plan) by the Utils.js pre-pass — never to the editor dispatch. + * Kept in `GDEVELOP_OPENAI_TOOLS` so the manager keeps planning; the pre-pass + * is what makes it a live tool instead of a dead one. + */ +export const LOCAL_PLAN_TOOL_NAMES: Array = ['create_or_update_plan']; + +export const isLocalPlanTool = (toolName: string): boolean => + LOCAL_PLAN_TOOL_NAMES.includes(toolName); + +/** + * T04 (#21) no-project self-remedy: project-dependent tools called with + * `project === null` (no project open) fail deterministically with a + * remedy the model can act on — calling `initialize_project` — instead of + * a dead-end null crash or a silent no-op. Routed by the Utils.js pre-pass + * before the editor dispatch; also surfaced in the system prompt when no + * project JSON is present. + */ +export const NO_PROJECT_MESSAGE: string = + "No project is currently open. You must call initialize_project(template_slug:'empty') to create one before using project tools."; + +export const PROJECT_FREE_TOOL_NAMES: Array = [ + 'initialize_project', + 'get_game_starter_summary', + 'search_docs', + 'read_full_docs', + 'search_object_asset_store', + 'search_resource_store', + 'create_or_update_plan', + 'spawn_agent', +]; + +export const isProjectDependentTool = (toolName: string): boolean => + typeof toolName === 'string' && + toolName.length > 0 && + !PROJECT_FREE_TOOL_NAMES.includes(toolName); + +export const buildNoProjectResult = (toolName: string): Object => ({ + success: false, + noProject: true, + message: `${NO_PROJECT_MESSAGE} (tool: ${toolName})`, +}); + export const withoutBackendOnlyTools = (tools: Array): Array => tools.filter( tool => diff --git a/packages/ai-studio/AiGeneration/Studio/DebugEvents.js b/packages/ai-studio/AiGeneration/Studio/DebugEvents.js new file mode 100644 index 0000000..8bdc12d --- /dev/null +++ b/packages/ai-studio/AiGeneration/Studio/DebugEvents.js @@ -0,0 +1,40 @@ +// @flow +/** + * Studio-side debug events (T02 client debug logging). + * + * The main-process JSONL appender (launcher/debug-logger.cjs) cannot be + * reached from the renderer bundle, so instrumented turn events buffer on + * `window.__HERETEK_LOGGER__` (installed by launcher/preload-unlock.cjs, + * capped at 500 records) for inspection via executeJavaScript / smoke. + * + * Zero-cost when debug logging is off: every emit is guarded on the + * logger's presence and wrapped in try/catch, so the agent loop can never + * break because observability failed. Overlaid into the patched tree by + * the driver (see TIER2_FILES in scripts/apply-patches.mjs) alongside the + * other Studio modules; integrity pinned in MANIFEST.json like them. + */ + +export type StudioDebugEvent = + | '[AI_REQUEST_START]' + | '[TOOL_CALL]' + | '[TOOL_RESULT]' + | '[GUARD_EVALUATE]' + | '[REQUEST_STATE_CHANGE]'; + +/** + * Push one debug record to the renderer logger when it exists. + * No-op (never throws) when the launcher debug logger is off. + */ +export const emitStudioDebugEvent = ( + event: StudioDebugEvent, + data?: { [string]: mixed } +): void => { + try { + if (typeof window === 'undefined') return; + const target = (window: any).__HERETEK_LOGGER__; + if (!target || typeof target.push !== 'function') return; + target.push('debug', event, data || {}); + } catch (error) { + // Observability only — never break the agent loop. + } +}; diff --git a/packages/ai-studio/AiGeneration/Studio/LoopGuard.js b/packages/ai-studio/AiGeneration/Studio/LoopGuard.js index 38a972a..f468b7b 100644 --- a/packages/ai-studio/AiGeneration/Studio/LoopGuard.js +++ b/packages/ai-studio/AiGeneration/Studio/LoopGuard.js @@ -1,10 +1,20 @@ // @flow import jsSHA from '../../Utils/Sha256'; +import { emitStudioDebugEvent } from './DebugEvents'; /** * The local loop guard: a circuit breaker for a BYOK agent. * + * Scope is strictly single-request (T06 #23): one guard instance per + * `aiRequest.id` (see `loopGuardsRef` in Utils.js), counting only that + * request's own turns — consecutive identical calls (repeat), consecutive + * failures (error storm), turns without user input, and turns without + * project progress (repeat-without-progress, sub-agents only). A child's + * startup failure never touches its parent's streak and never constrains + * the parent: `spawnSubAgent` failures fail only their own `spawn_agent` + * tool call (`success:false`) so the parent stays alive for serial fallback. + * * Ported from munder-difflin's `src/main/breaker.ts`, narrowed to what this * editor can actually observe. Under BYOK there is no server, so nothing counts * a runaway local agent: the hosted `repeated-tool-call-loop` error can never @@ -43,6 +53,13 @@ export type LoopGuardDecision = {| changed: boolean, |}; +/** + * T06 (#23) isolation scope marker: guards are per-request only. A test + * asserts this constant exists so the single-request + repeat-without- + * progress contract cannot silently regress to a shared/global guard. + */ +export const LOOP_GUARD_SCOPE: string = 'single-request'; + export const DEFAULT_LOOP_GUARD_CONFIG = { enabled: true, /** Trips on the Nth consecutive identical call; N-1 are tolerated. */ @@ -199,13 +216,17 @@ export const createLoopGuard = ( repeatKey = key; repeatCount = 1; } + // T02 debug event: the guard observes every tool call. + emitStudioDebugEvent('[TOOL_CALL]', { name: call.name, key }); }, recordSuccess: () => { // Only a real success clears the error storm: an attempt is not progress. errorCount = 0; + emitStudioDebugEvent('[TOOL_RESULT]', { ok: true }); }, recordError: () => { errorCount++; + emitStudioDebugEvent('[TOOL_RESULT]', { ok: false }); }, recordTurn: () => { turnCount++; @@ -226,12 +247,20 @@ export const createLoopGuard = ( turnCount = 0; noProgressTurns = 0; actionEmittedForTrip = false; + // T02 debug event: a new user input starts a fresh agent run. + emitStudioDebugEvent('[AI_REQUEST_START]', {}); }, evaluate: () => { if (!effectiveConfig.enabled) { const wasChanged = level !== 'healthy'; level = 'healthy'; reason = ''; + emitStudioDebugEvent('[GUARD_EVALUATE]', { + level, + reason, + action: 'none', + changed: wasChanged, + }); return { level, reason, @@ -252,6 +281,7 @@ export const createLoopGuard = ( } const changed = target !== level; const escalated = rank(target) > rank(level); + const previousLevel = level; level = target; reason = trip.tripping ? trip.reason : changed ? 'recovering' : reason; @@ -265,10 +295,26 @@ export const createLoopGuard = ( actionEmittedForTrip = false; } + const action = shouldEmit ? actionFor(target) : 'none'; + // T02 debug events: every guard decision, plus a state-change record + // on level transitions. + emitStudioDebugEvent('[GUARD_EVALUATE]', { + level, + reason, + action, + changed, + }); + if (changed) { + emitStudioDebugEvent('[REQUEST_STATE_CHANGE]', { + from: previousLevel, + to: level, + }); + } + return { level, reason, - action: shouldEmit ? actionFor(target) : 'none', + action, changed, }; }, diff --git a/packages/ai-studio/AiGeneration/Studio/Roles.js b/packages/ai-studio/AiGeneration/Studio/Roles.js index 5c6ed97..fc73a58 100644 --- a/packages/ai-studio/AiGeneration/Studio/Roles.js +++ b/packages/ai-studio/AiGeneration/Studio/Roles.js @@ -94,6 +94,7 @@ export const STUDIO_ROLES: { [StudioRoleId]: StudioRole } = { readOnly: false, maxTurns: 60, allowedToolNames: [ + 'initialize_project', 'create_or_update_plan', 'spawn_agent', 'read_game_project_json', @@ -110,6 +111,8 @@ export const STUDIO_ROLES: { [StudioRoleId]: StudioRole } = { ], systemPrompt: `You are the studio lead of a small game studio building a game in GDevelop. +If no project is currently open, your first action must be initialize_project with template_slug 'empty' (or the requested template) before planning or delegating. A plan or spawn without a project cannot succeed. + Your job is to plan and delegate. You never edit the project yourself - you have no tool to do so. Follow this loop, every time: diff --git a/packages/ai-studio/AiGeneration/Studio/UseStudioRuntime.js b/packages/ai-studio/AiGeneration/Studio/UseStudioRuntime.js index 4001540..d4ef2fb 100644 --- a/packages/ai-studio/AiGeneration/Studio/UseStudioRuntime.js +++ b/packages/ai-studio/AiGeneration/Studio/UseStudioRuntime.js @@ -22,6 +22,97 @@ import { } from './PlanStore'; import { isSpawnAgentCall, parseSpawnAgentArgs } from './SpawnSubAgents'; +/** + * T05 (#22) terminal status card: when a local request ends in + * `suspended` (Stop / LoopGuard constrain) or `error`, the spinner and the + * disabled composer are replaced by a terminal card carrying the badge, the + * exact failure reason, and the timestamp — plus Resume/Retry (back to + * `ready` with a steer prompt; the LoopGuard counters reset on the next + * user message, see Utils.js) and Clear Stalled (purge the persisted + * `gd-custom-ai-requests` store). + */ + +export const STALLED_REQUESTS_STORAGE_KEY: string = 'gd-custom-ai-requests'; + +export type TerminalRequestStatus = {| + isTerminal: boolean, + badge: string, + reason: string, + timestamp: string, +|}; + +export const isTerminalRequestStatus = (status: string): boolean => + status === 'suspended' || status === 'error'; + +export const getTerminalStatusForRequest = (aiRequest: any): ?TerminalRequestStatus => { + if (!aiRequest || typeof aiRequest !== 'object') return null; + const status = aiRequest.status; + if (!isTerminalRequestStatus(status)) return null; + const error = aiRequest.error; + const reason = + error && typeof error.message === 'string' && error.message + ? error.message + : status === 'suspended' + ? 'The request was suspended.' + : 'The request failed.'; + const badge = + status === 'suspended' + ? 'Suspended' + : /loop guard|stuck|repeated-tool-call-loop/i.test(reason) + ? 'Stopped by Loop Guard' + : 'Error'; + const timestamp = + (typeof aiRequest.updatedAt === 'string' && aiRequest.updatedAt) || + (typeof aiRequest.createdAt === 'string' && aiRequest.createdAt) || + new Date().toISOString(); + return { isTerminal: true, badge, reason, timestamp }; +}; + +export const buildResumeSteerMessage = (reason: string): string => + `The previous run stopped (${reason}). Resume with a different approach: do not repeat the last failing action, verify the project state first, then continue.`; + +export const buildResumeUpdate = (aiRequest: any): any => { + const now = new Date().toISOString(); + const terminal = getTerminalStatusForRequest(aiRequest); + const reason = terminal ? terminal.reason : 'unknown reason'; + return { + ...aiRequest, + status: 'ready', + error: null, + updatedAt: now, + output: Array.isArray(aiRequest.output) + ? [ + ...aiRequest.output, + { + type: 'message', + role: 'user', + content: buildResumeSteerMessage(reason), + messageId: `resume-steer-${Date.now()}`, + }, + ] + : aiRequest.output, + }; +}; + +export const clearStalledRequests = (): number => { + try { + if (typeof localStorage === 'undefined') return 0; + const raw = localStorage.getItem(STALLED_REQUESTS_STORAGE_KEY); + const count = + raw != null + ? Object.keys(JSON.parse(raw) || {}).length + : 0; + localStorage.removeItem(STALLED_REQUESTS_STORAGE_KEY); + return typeof count === 'number' ? count : 0; + } catch (error) { + try { + if (typeof localStorage !== 'undefined') + localStorage.removeItem(STALLED_REQUESTS_STORAGE_KEY); + } catch (ignored) {} + return 0; + } +}; + type ActiveSubAgent = {| parentAiRequestId: string, callId: string, diff --git a/packages/ai-studio/MANIFEST.json b/packages/ai-studio/MANIFEST.json index 8da1fa1..dd71863 100644 --- a/packages/ai-studio/MANIFEST.json +++ b/packages/ai-studio/MANIFEST.json @@ -1,15 +1,20 @@ { "files": { "AI/CustomAIClient.js": { - "bytes": 208828, + "bytes": 212395, "origin": "newIDE/app/src/AI/CustomAIClient.js", - "sha256": "2dd976b936908115d08374851121a077d2b2b6c314a029a4bc52ab7125f51723" + "sha256": "28e24dde3cde3941f6ae514d415fcc5069ce2229f3d888e95aa767ebef7e22f1" }, "AI/CustomAIClient.spec.js": { "bytes": 257794, "origin": "newIDE/app/src/AI/CustomAIClient.spec.js", "sha256": "0152072739b6590f004e886b8a5787cd769a529f044f14e48ca7e78ecc2a560a" }, + "AiGeneration/Studio/DebugEvents.js": { + "bytes": 1426, + "origin": "repo-original (T02 debug emitter, not a donor extraction)", + "sha256": "9e5e5a551f6655d5aeecc04cb1d0e6c04f13e820607fabfb5816b14b7219f76d" + }, "AiGeneration/Studio/EditApprovalLabel.js": { "bytes": 1308, "origin": "newIDE/app/src/AiGeneration/Studio/EditApprovalLabel.js", @@ -21,9 +26,9 @@ "sha256": "708de36210d000af56e795280eaa5dd450e32cedb4a54b7b031353a6cc4a679f" }, "AiGeneration/Studio/LoopGuard.js": { - "bytes": 9401, + "bytes": 11296, "origin": "newIDE/app/src/AiGeneration/Studio/LoopGuard.js", - "sha256": "0ad4c6f5d6312a38f56e0385b8408adec72634c95d7252a3ff193af1e667101e" + "sha256": "8fd346b75556051e7f03a15066ab562168f2028d5fca028583d9e7449be9b1ee" }, "AiGeneration/Studio/NudgePolicy.js": { "bytes": 3821, @@ -46,9 +51,9 @@ "sha256": "66a7d577f12fccaa1b23d89bc7ba02c9bca07058358cc53d3ecfe50d604ef1b8" }, "AiGeneration/Studio/Roles.js": { - "bytes": 12493, + "bytes": 12736, "origin": "newIDE/app/src/AiGeneration/Studio/Roles.js", - "sha256": "dac71987229c63fcea55fabd049b43ab48beff68cb19813de9d1c77090c6798f" + "sha256": "1ebb998a8038a964151d28b5af6c4d4fdd4dffee2d563d0b83e9964ba13f8457" }, "AiGeneration/Studio/SafeTruncation.js": { "bytes": 2743, @@ -66,9 +71,9 @@ "sha256": "12df5f3f54a52e3c2c72646e6825360206eec0521dd6f15d73cd39660b21e0d2" }, "AiGeneration/Studio/UseStudioRuntime.js": { - "bytes": 11187, + "bytes": 14337, "origin": "newIDE/app/src/AiGeneration/Studio/UseStudioRuntime.js", - "sha256": "ecc51b28d00baf1ccd7d38d542e4a9237cd145cd863586c6fae6e587d03564c3" + "sha256": "684f5f46c18f6b40dfadf01756ae62621e9c28a82d3a74a423bea61e09014cc3" }, "dev-harness/mock-ai-provider.js": { "bytes": 9862, @@ -97,5 +102,5 @@ "sha256": "45c2915841b0a6c0f17db7a297a39ca1dfb8b889d74f018f87d1815cadf17c26" } }, - "provenance": "Historical origin only: extracted in 2026-10 from the Heretek fork (now retired). This repo never reads the donor \u2014 integrity is self-contained via the pinned bytes/sha256 below." + "provenance": "Historical origin only: extracted in 2026-10 from the Heretek fork (now retired). This repo never reads the donor — integrity is self-contained via the pinned bytes/sha256 below." } diff --git a/packages/opencode-gdevelop/README.md b/packages/opencode-gdevelop/README.md new file mode 100644 index 0000000..97a60ba --- /dev/null +++ b/packages/opencode-gdevelop/README.md @@ -0,0 +1,53 @@ +# `@heretek-ai/opencode-gdevelop` + +OpenCode plugin for **local, offline** GDevelop 5 project control. Six +`gdevelop_*` tools plus a `gdevelop-architect` subagent persona, all running +in-process with zero network egress (never contacts `api.gdevelop.io`). + +## Install + +This package is a plain ESM module with **zero dependencies**. +`@opencode/plugin` is a host-provided peer API, not a bundled dep: the +OpenCode runtime resolves the import at load time. Outside the runtime +(tests, CI) the entry falls back to an identity `Plugin.define` +passthrough so the `{ id, setup }` definition stays importable. + +Point your OpenCode config at this directory as a local plugin; the entry +is `packages/opencode-gdevelop/index.mjs` (plugin id: `gdevelop`). + +## Tools + +| Tool | What it does | +| --- | --- | +| `gdevelop_project_read` | Inspect scenes/objects/behaviors/variables from a project on disk (optional `sceneName` narrows to one scene). | +| `gdevelop_project_create_scene` | Create a scene (layout); fails closed on duplicates/blank names. | +| `gdevelop_project_create_object` | Create/replace an object: `Sprite`, `TextObject::Text`, `PanelSpriteObject::PanelSprite`, `Scene3D::*`, or any engine type; scene-scoped or global. | +| `gdevelop_project_add_behavior` | Attach a behavior (e.g. `PlatformBehavior::PlatformerObjectBehavior`) with family defaults. | +| `gdevelop_project_add_events` | Append a `BuiltinCommonInstructions::Standard` event (`conditions[]`/`actions[]`) to a scene sheet. | +| `gdevelop_cli_run` | Format a headless CLI dispatch (`--run-command UPPER_SNAKE_CASE`, repeatable `--cmd-args`, `--disable-update-check`); the host spawns it. | + +Every mutation delegates to +`packages/unlock-shim/control-shim.mjs` (`readGDevelopProject`, +`createScene`, `createOrReplaceObject`, `addBehavior`, `addStandardEvent`, +`buildGDevelopCliCommand`, `writeGDevelopProject`) — no reimplemented +project logic. Inputs are plain JSON-Schema objects; each `execute` returns +a host-shaped `{ output, content }` result and rejects fail-closed with the +path/id named. + +## Agent: `gdevelop-architect` + +Registered via `ctx.agent.transform(editor => editor.update(...))` (host +upsert semantics). Subagent-mode persona whose system prompt covers GDevelop +5 architecture, the GDJS lifecycle (game loop, scene stack), event +conditions/actions/sub-events, and asset management. Permissions: every +`gdevelop_*` tool (derived from `TOOL_NAMES`, so they cannot drift) plus +host file `read`/`write`. + +## Offline scope + +- No `dependencies`/`devDependencies`; only `node:` builtins + the control shim. +- No `api.gdevelop.io` references anywhere in the package. +- Tests (`tests/integration/opencode-plugin-tools.test.mjs`, + `tests/integration/opencode-plugin-agent.test.mjs`) use in-process ctx + doubles with real registries and real scratch-project I/O under + `os.tmpdir()` — no mocks of the code under test. diff --git a/packages/opencode-gdevelop/agents.mjs b/packages/opencode-gdevelop/agents.mjs new file mode 100644 index 0000000..b85af6f --- /dev/null +++ b/packages/opencode-gdevelop/agents.mjs @@ -0,0 +1,76 @@ +// GDevelop architect agent persona (T10, issue #17). +// +// Registers the `gdevelop-architect` subagent through the host +// `ctx.agent.transform(editor => editor.update(id, fn))` API. The editor's +// `update` upserts (review/opencode/packages/core/src/agent.ts: a missing id +// starts from Info.default(id)), so this works as a pure registration with +// no pre-existing agent entry required. +// +// Tool permissions derive from TOOL_NAMES (tools.mjs) so the architect can +// always call every gdevelop_* tool, plus host file read/write. + +import { TOOL_NAMES } from './tools.mjs'; + +/** Stable host agent id. */ +export const ARCHITECT_ID = 'gdevelop-architect'; + +const SYSTEM_PROMPT = `You are the GDevelop 5 architect: a specialist in designing, building, and debugging local GDevelop game projects (GDJS runtime, offline). + +GDevelop 5 architecture +- A project (game.json, single-file or split-folder) holds properties, global objects/variables, resources, layouts (scenes), and external event sheets. +- A scene holds layers (with cameras and effects), instances placed on layers, scene objects, scene variables, behaviors on objects, and one event sheet. +- Objects carry a type (Sprite, TextObject::Text, PanelSpriteObject::PanelSprite, Scene3D::* 3D boxes, …), variables, effects, and behaviors. Prefer scene objects; use global objects only for cross-scene sharing. +- Behaviors attach capabilities (PlatformerObject, Physics2, TopDownMovement, …) with per-family default parameters; name each behavior instance explicitly. + +GDJS lifecycle +- The engine runs a game loop: input → events evaluation → behaviors stepping → rendering, once per frame per scene on the scene stack. +- Scene stack: scenes push/replace/pause; paused scenes keep state. Startup scene is the project's first layout unless changed. +- Hot-reload and preview inspection go through the debugger bridge; headless builds/exports go through the CLI dispatcher (--run-command UPPER_SNAKE_CASE with --cmd-args). + +Events: conditions, actions, sub-events +- An event sheet is an ordered list of BuiltinCommonInstructions::Standard events, each with conditions[] and actions[]. +- Conditions are all AND-ed (all must hold) unless marked OR; an empty condition list means "always". +- Actions run top-to-bottom when conditions hold; order actions so state reads precede state writes within the same frame. +- Sub-events run only when the parent event's conditions held; use them to narrow scope instead of duplicating parent conditions. +- Keep sheets small and layered: input events, then simulation, then camera/HUD updates. Never emit circular include cycles between external sheets. + +Asset management +- Every texture, audio file, and font must be declared in the project's resources with a stable name before events or objects reference it. +- Prefer sprite sheets / atlases over loose frames; keep audio in engine-supported formats and preload scene-critical assets. +- Reference assets by resource name, never by absolute disk path, so projects stay portable across machines and CI. + +Working rules +- Inspect first (gdevelop_project_read), then mutate in dependency order: scenes → objects → behaviors → events. +- Use the gdevelop_* tools for every project mutation; use host file read/write only for supporting files (docs, configs), never to hand-edit game.json. +- Format CLI work with gdevelop_cli_run and let the host spawn it; never invent command names outside UPPER_SNAKE_CASE. +- Report what changed per scene/object/event so the user can verify in the editor.`; + +const allow = action => ({ action, resource: '*', effect: 'allow' }); + +/** Canonical architect config (plain data; also applied via transform). */ +export const GDEVELOP_ARCHITECT = { + id: ARCHITECT_ID, + name: 'GDevelop Architect', + description: + 'Designs and mutates local GDevelop 5 projects: scenes, objects, behaviors, event sheets, assets, and headless CLI dispatches.', + system: SYSTEM_PROMPT, + mode: 'subagent', + permissions: [ + ...TOOL_NAMES.map(allow), + allow('read'), + allow('write'), + ], +}; + +/** Register (upsert) the architect persona on a host ctx. */ +export async function registerGDevelopAgents(ctx) { + await ctx.agent.transform(editor => { + editor.update(ARCHITECT_ID, agent => { + agent.name = GDEVELOP_ARCHITECT.name; + agent.description = GDEVELOP_ARCHITECT.description; + agent.system = GDEVELOP_ARCHITECT.system; + agent.mode = GDEVELOP_ARCHITECT.mode; + agent.permissions = GDEVELOP_ARCHITECT.permissions.map(rule => ({ ...rule })); + }); + }); +} diff --git a/packages/opencode-gdevelop/index.mjs b/packages/opencode-gdevelop/index.mjs new file mode 100644 index 0000000..df23bbe --- /dev/null +++ b/packages/opencode-gdevelop/index.mjs @@ -0,0 +1,37 @@ +// GDevelop OpenCode plugin entry (T09 tools + T10 architect, issue #17). +// +// Registers the six gdevelop_* project-control tools through the host +// `ctx.tool.transform(editor => editor.add(tool))` API and the +// gdevelop-architect persona through +// `ctx.agent.transform(editor => editor.update(id, fn))` (Promise plugin API: +// review/opencode/packages/plugin/src/README.md; ToolEditor.add in +// review/opencode/packages/plugin/src/promise/tool.ts; agent upsert in +// review/opencode/packages/core/src/agent.ts). +// +// `@opencode/plugin` is a host-provided peer API, NOT a bundled dependency +// (this package ships zero deps): the host resolves the import at load time, +// while offline runs (tests, CI) fall back to the identity passthrough below. + +import { GDEVELOP_TOOLS } from './tools.mjs'; +import { registerGDevelopAgents } from './agents.mjs'; + +/** Raw plugin definition; also exported for offline/test harnesses. */ +export const definition = { + id: 'gdevelop', + setup: async ctx => { + await ctx.tool.transform(editor => { + for (const tool of GDEVELOP_TOOLS) editor.add(tool); + }); + await registerGDevelopAgents(ctx); + }, +}; + +let Plugin = { define: def => def }; +try { + ({ Plugin } = await import('@opencode/plugin')); +} catch { + // Host peer API absent outside the OpenCode runtime; identity holds the + // { id, setup } shape so the definition stays importable offline. +} + +export default Plugin.define(definition); diff --git a/packages/opencode-gdevelop/package.json b/packages/opencode-gdevelop/package.json new file mode 100644 index 0000000..5561e6d --- /dev/null +++ b/packages/opencode-gdevelop/package.json @@ -0,0 +1,8 @@ +{ + "name": "@heretek-ai/opencode-gdevelop", + "version": "0.1.0", + "private": true, + "license": "MIT", + "type": "module", + "description": "OpenCode plugin for local GDevelop 5 project control (offline tools over the unlock control shim)." +} diff --git a/packages/opencode-gdevelop/tools.mjs b/packages/opencode-gdevelop/tools.mjs new file mode 100644 index 0000000..6a9ed11 --- /dev/null +++ b/packages/opencode-gdevelop/tools.mjs @@ -0,0 +1,290 @@ +// GDevelop OpenCode tool definitions (T09, issue #17): six host-registered +// tools for local GDevelop 5 project control. +// +// Every project mutation delegates to +// packages/unlock-shim/control-shim.mjs — this module performs no direct +// project file writes of its own by design). +// Inputs are plain JSON-Schema objects (the host Tool.ValueSchema union +// accepts JsonSchema.JsonSchema), so this package stays zero-dependency and +// loadable without the host `@opencode/plugin` runtime installed. +// Each `execute` returns a host-shaped `{ output, content }` result. + +import { + readGDevelopProject, + writeGDevelopProject, + createScene, + createOrReplaceObject, + addBehavior, + addStandardEvent, + buildGDevelopCliCommand, +} from '../unlock-shim/control-shim.mjs'; + +const projectPathProp = { + type: 'string', + description: 'Path to game.json or to a split-folder project directory.', +}; +const sceneNameProp = { + type: 'string', + description: 'Scene (layout) name.', +}; + +const objectSchemaOf = scene => { + const summary = { + name: scene.name, + objects: (scene.objects || []).map(entry => entry && entry.name), + objectCount: (scene.objects || []).length, + eventCount: (scene.events || []).length, + variables: (scene.variables || []).map(entry => entry && entry.name), + }; + const objects = {}; + for (const entry of scene.objects || []) { + if (!entry || typeof entry.name !== 'string') continue; + objects[entry.name] = { + type: entry.type, + behaviors: (entry.behaviors || []).map(b => b && b.name), + variables: (entry.variables || []).map(v => v && v.name), + }; + } + if (scene) summary.objectsByName = objects; + return summary; +}; + +async function executeProjectRead(input) { + const project = readGDevelopProject(input.projectPath); + const scenes = (project.layouts || []).map(objectSchemaOf); + if (input.sceneName !== undefined && input.sceneName !== null && String(input.sceneName).length > 0) { + const scene = scenes.find(entry => entry.name === input.sceneName); + if (!scene) { + throw new Error(`[opencode-gdevelop] project_read: scene not found: "${input.sceneName}"`); + } + return { + output: { projectName: project.properties.name, scene }, + content: `Scene "${scene.name}": ${scene.objectCount} object(s), ${scene.eventCount} event(s).`, + }; + } + return { + output: { + projectName: project.properties.name, + scenes, + globalObjects: (project.objects || []).map(entry => entry && entry.name), + }, + content: `Project "${project.properties.name}": ${scenes.length} scene(s): ${scenes.map(s => s.name).join(', ') || '(none)'}.`, + }; +} + +async function executeCreateScene(input) { + const project = readGDevelopProject(input.projectPath); + createScene(project, input.sceneName); + writeGDevelopProject(input.projectPath, project); + return { + output: { sceneName: input.sceneName, created: true }, + content: `Scene "${input.sceneName}" created in ${input.projectPath}.`, + }; +} + +async function executeCreateObject(input) { + const project = readGDevelopProject(input.projectPath); + const { object, created } = createOrReplaceObject(project, { + sceneName: input.sceneName, + objectName: input.objectName, + objectType: input.objectType, + description: input.description, + }); + writeGDevelopProject(input.projectPath, project); + return { + output: { + objectName: object.name, + objectType: object.type, + sceneName: input.sceneName || null, + created, + }, + content: `Object "${object.name}" (${object.type}) ${created ? 'created' : 'replaced'}${input.sceneName ? ` in scene "${input.sceneName}"` : ' globally'}.`, + }; +} + +async function executeAddBehavior(input) { + const project = readGDevelopProject(input.projectPath); + const behavior = addBehavior(project, { + sceneName: input.sceneName, + objectName: input.objectName, + behaviorName: input.behaviorName, + behaviorType: input.behaviorType, + }); + writeGDevelopProject(input.projectPath, project); + return { + output: { + objectName: input.objectName, + behaviorName: behavior.name, + behaviorType: behavior.type, + }, + content: `Behavior "${behavior.name}" (${behavior.type}) added to "${input.objectName}".`, + }; +} + +async function executeAddEvents(input) { + const project = readGDevelopProject(input.projectPath); + const event = addStandardEvent(project, { + sceneName: input.sceneName, + conditions: input.conditions, + actions: input.actions, + }); + writeGDevelopProject(input.projectPath, project); + const scene = project.layouts.find(layout => layout && layout.name === input.sceneName); + return { + output: { + sceneName: input.sceneName, + eventType: event.type, + eventIndex: scene.events.indexOf(event), + conditionCount: event.conditions.length, + actionCount: event.actions.length, + }, + content: `Standard event appended to scene "${input.sceneName}" (${event.conditions.length} condition(s), ${event.actions.length} action(s)).`, + }; +} + +async function executeCliRun(input) { + const formatted = buildGDevelopCliCommand({ + gdevelopPath: input.gdevelopPath, + projectPath: input.projectPath, + commandName: input.commandName, + cmdArgs: input.cmdArgs, + headless: input.headless, + }); + return { + output: formatted, + content: [formatted.command, ...formatted.args].join(' '), + }; +} + +/** The six host-registered tool definitions, in registration order. */ +export const GDEVELOP_TOOLS = [ + { + name: 'gdevelop_project_read', + description: + 'Inspect a GDevelop project on disk: scenes, objects, behaviors, and variables.', + input: { + type: 'object', + properties: { + projectPath: projectPathProp, + sceneName: { + ...sceneNameProp, + description: 'Optional: restrict the report to one scene.', + }, + }, + required: ['projectPath'], + additionalProperties: false, + }, + execute: executeProjectRead, + }, + { + name: 'gdevelop_project_create_scene', + description: 'Create a new scene (layout) in a GDevelop project on disk.', + input: { + type: 'object', + properties: { projectPath: projectPathProp, sceneName: sceneNameProp }, + required: ['projectPath', 'sceneName'], + additionalProperties: false, + }, + execute: executeCreateScene, + }, + { + name: 'gdevelop_project_create_object', + description: + 'Create (or replace) an object — Sprite, TextObject::Text, PanelSpriteObject::PanelSprite, Scene3D::* or any engine type — in a scene or globally.', + input: { + type: 'object', + properties: { + projectPath: projectPathProp, + sceneName: { + ...sceneNameProp, + description: 'Optional: target scene; omit for a global object.', + }, + objectName: { type: 'string', description: 'New object name.' }, + objectType: { + type: 'string', + description: + 'Engine object type (Sprite, TextObject::Text, PanelSpriteObject::PanelSprite, Scene3D::Box3DObject, …).', + }, + description: { type: 'string', description: 'Optional object description.' }, + }, + required: ['projectPath', 'objectName', 'objectType'], + additionalProperties: false, + }, + execute: executeCreateObject, + }, + { + name: 'gdevelop_project_add_behavior', + description: + 'Attach a behavior (e.g. PlatformBehavior::PlatformerObjectBehavior) to an object.', + input: { + type: 'object', + properties: { + projectPath: projectPathProp, + sceneName: { + ...sceneNameProp, + description: 'Optional: scene holding the object; omit for global objects.', + }, + objectName: { type: 'string', description: 'Target object name.' }, + behaviorName: { type: 'string', description: 'Behavior instance name.' }, + behaviorType: { + type: 'string', + description: 'Fully qualified behavior type.', + }, + }, + required: ['projectPath', 'objectName', 'behaviorName', 'behaviorType'], + additionalProperties: false, + }, + execute: executeAddBehavior, + }, + { + name: 'gdevelop_project_add_events', + description: + 'Append a BuiltinCommonInstructions::Standard event (conditions/actions) to a scene event sheet.', + input: { + type: 'object', + properties: { + projectPath: projectPathProp, + sceneName: sceneNameProp, + conditions: { + type: 'array', + description: 'Condition entries (default []).', + }, + actions: { type: 'array', description: 'Action entries (default []).' }, + }, + required: ['projectPath', 'sceneName'], + additionalProperties: false, + }, + execute: executeAddEvents, + }, + { + name: 'gdevelop_cli_run', + description: + 'Format a headless GDevelop CLI dispatch (--run-command NAME --cmd-args …) for the host to spawn.', + input: { + type: 'object', + properties: { + gdevelopPath: { type: 'string', description: 'GDevelop binary path.' }, + projectPath: { type: 'string', description: 'Project path (positional CLI arg).' }, + commandName: { + type: 'string', + description: 'UPPER_SNAKE_CASE command name.', + }, + cmdArgs: { + type: 'array', + description: 'Repeatable --cmd-args values.', + items: { type: 'string' }, + }, + headless: { + type: 'boolean', + description: 'Add --disable-update-check for CI/headless runs.', + }, + }, + required: ['gdevelopPath', 'projectPath', 'commandName'], + additionalProperties: false, + }, + execute: executeCliRun, + }, +]; + +/** Registration-order tool names. */ +export const TOOL_NAMES = GDEVELOP_TOOLS.map(tool => tool.name); diff --git a/packages/opencode-provider/adapter.mjs b/packages/opencode-provider/adapter.mjs new file mode 100644 index 0000000..5d24581 --- /dev/null +++ b/packages/opencode-provider/adapter.mjs @@ -0,0 +1,261 @@ +// Adapter (T08, issue #18): OpenAI <-> OpenCode session/prompt translation +// for the opencode provider daemon. +// +// Pure zero-dependency ESM: no I/O, no network. `server.mjs` owns the HTTP +// surface and the full 39-tool registry (`EDITOR_TOOLS`); this module owns +// the names (kept in step with upstream `GDEVELOP_OPENAI_TOOLS` by the +// integration suite), the function-call <-> tool_calls mapping, and the +// message <-> session translation both directions use. + +import crypto from 'node:crypto'; + +/** + * The 39 GDevelop editor functions the daemon can express as OpenAI + * `tool_calls`. Frozen; the integration suite pins this list against the + * upstream `CustomAIClient.js` registry, so rename/remove only alongside it. + */ +export const EDITOR_FUNCTION_NAMES = Object.freeze([ + 'create_scene', + 'create_or_replace_object', + 'add_behavior', + 'put_2d_instances', + 'add_scene_events', + 'run_script', + 'read_events_source', + 'describe_instances', + 'inspect_object_properties_effects', + 'inspect_behavior_properties', + 'inspect_scene_properties_layers_effects', + 'change_scene_properties_layers_effects_groups', + 'change_object_properties_effects', + 'change_behavior_property', + 'change_project_properties_resources', + 'inspect_project_properties_resources', + 'add_or_edit_variable', + 'put_3d_instances', + 'inspect_variables', + 'read_game_project_json', + 'search_object_asset_store', + 'search_resource_store', + 'search_docs', + 'read_full_docs', + 'initialize_project', + 'get_game_starter_summary', + 'create_or_update_plan', + 'run_gameplay_test', + 'change_gameplay_tests', + 'create_extension', + 'change_extension_properties', + 'create_custom_object', + 'change_custom_object', + 'create_custom_behavior', + 'change_custom_behavior', + 'create_custom_function', + 'change_custom_function', + 'inspect_extension', + 'spawn_agent', +]); + +/** True when `name` is one of the 39 editor functions. */ +export function isEditorFunctionName(name) { + return typeof name === 'string' && EDITOR_FUNCTION_NAMES.includes(name); +} + +const randomCallId = () => `call_${crypto.randomBytes(9).toString('hex')}`; + +/** + * Parse an explicit function-call message (`{"name": ..., "arguments": ...}`, + * arguments as object or JSON string). Returns `{ name, args }` or `null` + * when the text is not a function-call message. Throws on a well-formed + * call naming an unknown function (fail-closed: never emit tool_calls for + * functions outside the 39). + */ +export function parseFunctionCallMessage(text) { + if (typeof text !== 'string') return null; + const trimmed = text.trim(); + if (!trimmed.startsWith('{')) return null; + let parsed; + try { + parsed = JSON.parse(trimmed); + } catch { + return null; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + if (typeof parsed.name !== 'string') return null; + if (!isEditorFunctionName(parsed.name)) { + throw new Error( + `[opencode-provider] unknown function "${parsed.name}": not one of the 39 editor functions` + ); + } + let args = parsed.arguments === undefined ? {} : parsed.arguments; + if (typeof args === 'string') { + try { + args = JSON.parse(args); + } catch { + throw new Error( + `[opencode-provider] function "${parsed.name}" has non-JSON arguments` + ); + } + } + if (!args || typeof args !== 'object' || Array.isArray(args)) { + throw new Error( + `[opencode-provider] function "${parsed.name}" arguments must be a JSON object` + ); + } + return { name: parsed.name, args }; +} + +/** + * Map one validated function call to an OpenAI `tool_calls` entry. + * `args` may be an object or a JSON string; it is always serialized. + */ +export function mapFunctionCallToToolCall({ name, args, id } = {}) { + if (!isEditorFunctionName(name)) { + throw new Error( + `[opencode-provider] unknown function "${String(name)}": not one of the 39 editor functions` + ); + } + const input = args === undefined ? {} : args; + const normalized = typeof input === 'string' ? input : JSON.stringify(input); + if (typeof input === 'string') JSON.parse(normalized); // fail-closed on bad JSON + if (typeof input === 'object' && (input === null || Array.isArray(input))) { + throw new Error(`[opencode-provider] function "${name}" arguments must be a JSON object`); + } + return { + id: id || randomCallId(), + type: 'function', + function: { name, arguments: normalized }, + }; +} + +/** Map an OpenAI `tool_calls` entry to an OpenCode session step. */ +export function toolCallToSessionStep(toolCall) { + if (!toolCall || toolCall.type !== 'function' || !toolCall.function) { + throw new Error('[opencode-provider] malformed tool_call entry'); + } + const { name, arguments: argsJson } = toolCall.function; + if (!isEditorFunctionName(name)) { + throw new Error(`[opencode-provider] unknown function "${String(name)}" in tool_call`); + } + let input = {}; + if (argsJson !== undefined && argsJson !== '') { + input = JSON.parse(argsJson); + } + return { type: 'tool_call', id: toolCall.id || randomCallId(), name, input }; +} + +/** + * Translate an OpenAI `messages` list into an OpenCode session: + * `{ id, prompt, steps }`. System messages seed the prompt context, the + * user texts form the prompt, assistant `tool_calls` become `tool_call` + * steps, and `tool` messages become `tool_result` steps. + */ +export function openAiMessagesToSession(messages, { sessionId } = {}) { + if (!Array.isArray(messages) || messages.length === 0) { + throw new Error('[opencode-provider] messages must be a non-empty array'); + } + const promptParts = []; + const steps = []; + for (const message of messages) { + if (!message || typeof message.role !== 'string') { + throw new Error('[opencode-provider] every message needs a string role'); + } + const content = + typeof message.content === 'string' + ? message.content + : message.content == null + ? '' + : JSON.stringify(message.content); + switch (message.role) { + case 'system': + promptParts.unshift(`[system] ${content}`); + break; + case 'user': + promptParts.push(content); + break; + case 'assistant': + if (content) promptParts.push(`[assistant] ${content}`); + for (const toolCall of message.tool_calls || []) { + steps.push(toolCallToSessionStep(toolCall)); + } + break; + case 'tool': + steps.push({ + type: 'tool_result', + toolCallId: message.tool_call_id || null, + output: content, + }); + break; + default: + promptParts.push(`[${message.role}] ${content}`); + break; + } + } + return { + id: sessionId || `sess_${crypto.randomBytes(8).toString('hex')}`, + prompt: promptParts.join('\n'), + steps, + }; +} + +/** + * Translate session steps back into OpenAI messages (assistant `tool_calls` + * plus `tool` results), e.g. when resuming a session as conversation history. + */ +export function sessionStepsToOpenAiMessages(steps) { + const messages = []; + for (const step of steps || []) { + if (!step || typeof step !== 'object') continue; + if (step.type === 'tool_call' && isEditorFunctionName(step.name)) { + messages.push({ + role: 'assistant', + content: null, + tool_calls: [mapFunctionCallToToolCall({ name: step.name, args: step.input || {}, id: step.id })], + }); + } else if (step.type === 'tool_result') { + messages.push({ + role: 'tool', + tool_call_id: step.toolCallId, + content: typeof step.output === 'string' ? step.output : JSON.stringify(step.output ?? null), + }); + } + } + return messages; +} + +/** + * Rough token estimate (chars/4 ASCII, 1 per non-ASCII unit) — good enough + * for local usage meters, never a billing figure. + */ +export function estimateTokens(text) { + if (!text || typeof text !== 'string') return 0; + let ascii = 0; + let nonAscii = 0; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) > 0x7f) nonAscii += 1; + else ascii += 1; + } + return Math.ceil(ascii / 4) + nonAscii; +} + +/** Usage meter for a prompt/completion pair. */ +export function buildUsage(promptText, completionText) { + const prompt_tokens = estimateTokens(promptText); + const completion_tokens = estimateTokens(completionText); + return { prompt_tokens, completion_tokens, total_tokens: prompt_tokens + completion_tokens }; +} + +/** Split text into word-boundary chunks for SSE delta streaming. */ +export function chunkTextForStream(text, maxChunk = 24) { + const chunks = []; + let current = ''; + for (const word of String(text).split(/(\s+)/)) { + if ((current + word).length > maxChunk && current.length > 0) { + chunks.push(current); + current = ''; + } + current += word; + } + if (current.length > 0) chunks.push(current); + return chunks.length > 0 ? chunks : ['']; +} diff --git a/packages/opencode-provider/cli.mjs b/packages/opencode-provider/cli.mjs new file mode 100755 index 0000000..d3d40a5 --- /dev/null +++ b/packages/opencode-provider/cli.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +// CLI for the opencode provider daemon (T08, issue #18). +// Zero-dependency ESM: `node cli.mjs [--port 0] [--host 127.0.0.1] +// [--model gdevelop-local] [--verbose]`. + +import { startServer, OPENAI_MODEL_ID } from './server.mjs'; + +const usage = `opencode-provider: local OpenAI-compatible bridge for GDevelop + +Usage: opencode-provider [options] + + --port TCP port to listen on (0 = ephemeral, default 0) + --host bind address (default 127.0.0.1; keep loopback for zero egress) + --model model id advertised on /v1/models (default ${OPENAI_MODEL_ID}) + --verbose log each request line + --help this message +`; + +const args = process.argv.slice(2); +const options = { port: 0, host: '127.0.0.1', model: OPENAI_MODEL_ID, verbose: false }; +for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--help' || arg === '-h') { + console.log(usage); + process.exit(0); + } else if (arg === '--verbose' || arg === '-v') { + options.verbose = true; + } else if (arg === '--port' && i + 1 < args.length) { + options.port = Number(args[++i]); + } else if (arg.startsWith('--port=')) { + options.port = Number(arg.split('=')[1]); + } else if (arg === '--host' && i + 1 < args.length) { + options.host = args[++i]; + } else if (arg.startsWith('--host=')) { + options.host = arg.split('=')[1]; + } else if (arg === '--model' && i + 1 < args.length) { + options.model = args[++i]; + } else if (arg.startsWith('--model=')) { + options.model = arg.split('=')[1]; + } else { + console.error(`opencode-provider: unknown argument "${arg}"\n\n${usage}`); + process.exit(2); + } +} + +if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535) { + console.error(`opencode-provider: invalid --port (${String(options.port)})`); + process.exit(2); +} + +const handle = await startServer({ + host: options.host, + port: options.port, + model: options.model, +}); +const actual = handle.address(); +console.log( + `opencode-provider listening on http://${actual.address}:${actual.port} (model ${options.model})` +); + +const shutdown = () => { + handle.close().then(() => process.exit(0)); +}; +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); diff --git a/packages/opencode-provider/package.json b/packages/opencode-provider/package.json new file mode 100644 index 0000000..fbbd7ac --- /dev/null +++ b/packages/opencode-provider/package.json @@ -0,0 +1,14 @@ +{ + "name": "opencode-provider", + "version": "0.1.0", + "private": true, + "license": "MIT", + "type": "module", + "description": "Local OpenAI-compatible provider bridge for GDevelop (offline daemon, zero egress).", + "bin": { + "opencode-provider": "./cli.mjs" + }, + "scripts": { + "start": "node ./cli.mjs" + } +} diff --git a/packages/opencode-provider/server.mjs b/packages/opencode-provider/server.mjs new file mode 100644 index 0000000..fe10601 --- /dev/null +++ b/packages/opencode-provider/server.mjs @@ -0,0 +1,1801 @@ +// OpenAI-compatible HTTP daemon (T08, issue #18): the local provider bridge +// Electron/axios clients talk to instead of a hosted model endpoint. +// +// Native `node:http` only, zero dependencies, zero egress (no hosted calls +// anywhere in this package). Endpoints: +// GET /v1/models -> model list (CORS-enabled) +// POST /v1/chat/completions -> stream:false standard completion + usage, +// stream:true SSE chunk deltas + data:[DONE] +// OPTIONS * -> 204 permissive preflight for Electron/axios +// +// Function calling: an explicit `{"name": , +// "arguments": {...}}` message is mapped to an OpenAI `tool_calls` payload +// (see adapter.mjs); anything else gets a deterministic local echo. This is +// an offline bridge, not a model: without a backend it never invents tool +// arguments, it only normalizes explicit invocations. + +import http from 'node:http'; + +import { + EDITOR_FUNCTION_NAMES, + isEditorFunctionName, + parseFunctionCallMessage, + mapFunctionCallToToolCall, + openAiMessagesToSession, + buildUsage, + chunkTextForStream, +} from './adapter.mjs'; + +export const OPENAI_MODEL_ID = 'gdevelop-local'; + +/** + * The 39 GDevelop editor function schemas served to OpenAI clients. + * Distilled verbatim from upstream `GDEVELOP_OPENAI_TOOLS` in + * `packages/ai-studio/AI/CustomAIClient.js` (descriptions + parameters); + * the integration suite pins the names against that registry. + */ +export const EDITOR_TOOLS = [ + { + type: 'function', + function: { + name: 'create_scene', + description: + 'Create a new scene in the GDevelop project with the given name.', + parameters: { + type: 'object', + properties: { + scene_name: { + type: 'string', + description: 'Name of the new scene to create.', + }, + }, + required: ['scene_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'create_or_replace_object', + description: + 'Create or replace an object in a scene (or global if scene_name is omitted).', + parameters: { + type: 'object', + properties: { + object_name: { + type: 'string', + description: 'Name of the object.', + }, + object_type: { + type: 'string', + description: + 'Type of object (e.g. "Sprite", "TiledSpriteObject::TiledSprite", "TextObject::Text", "PanelSpriteObject::PanelSprite", "Tilemap::Tilemap", "Scene3D::Cube3D").', + }, + scene_name: { + type: 'string', + description: + 'Name of the scene. If omitted or empty, creates a global object.', + }, + description: { + type: 'string', + description: 'Optional description of the object purpose.', + }, + }, + required: ['object_name', 'object_type'], + }, + }, + }, + { + type: 'function', + function: { + name: 'add_behavior', + description: 'Add a behavior to an object in the project.', + parameters: { + type: 'object', + properties: { + object_name: { + type: 'string', + description: 'Name of the object to add behavior to.', + }, + behavior_name: { + type: 'string', + description: + 'Unique name for this behavior instance on the object.', + }, + behavior_type: { + type: 'string', + description: + 'Type of behavior (e.g. "PlatformBehavior::PlatformerObjectBehavior", "PlatformBehavior::PlatformBehavior", "TopDownMovementBehavior::TopDownMovementBehavior", "Physics2::Physics2Behavior", "DestroyOutsideBehavior::DestroyOutside", "SmoothCamera::SmoothCamera").', + }, + scene_name: { + type: 'string', + description: + 'Name of the scene (if object is a scene object). Omit for global objects.', + }, + }, + required: ['object_name', 'behavior_name', 'behavior_type'], + }, + }, + }, + { + type: 'function', + function: { + name: 'put_2d_instances', + description: + 'Place one or more 2D instances of objects into a scene at specified coordinates.', + parameters: { + type: 'object', + properties: { + scene_name: { + type: 'string', + description: 'Name of the scene.', + }, + instances: { + type: 'array', + items: { + type: 'object', + properties: { + object_name: { + type: 'string', + description: 'Name of the object to instantiate.', + }, + x: { type: 'number', description: 'X position in pixels.' }, + y: { type: 'number', description: 'Y position in pixels.' }, + layer_name: { + type: 'string', + description: + 'Layer name (default empty string for base layer).', + }, + angle: { + type: 'number', + description: 'Angle in degrees (default 0).', + }, + z_order: { type: 'number', description: 'Z-order.' }, + }, + required: ['object_name', 'x', 'y'], + }, + description: 'List of instances to place.', + }, + }, + required: ['scene_name', 'instances'], + }, + }, + }, + { + type: 'function', + function: { + name: 'add_scene_events', + description: + 'Generate or modify scene events in GDevelop based on natural language description.', + parameters: { + type: 'object', + properties: { + scene_name: { + type: 'string', + description: 'Name of the scene whose events are being modified.', + }, + events_description: { + type: 'string', + description: + 'Detailed natural language description of the events logic to implement.', + }, + objects_list: { + type: 'string', + description: + 'Comma-separated list of objects involved in these events.', + }, + extension_names_list: { + type: 'string', + description: + 'Comma-separated list of extensions/behaviors used (e.g. "PlatformBehavior::PlatformerObjectBehavior, Keyboard, Scene").', + }, + }, + required: ['scene_name', 'events_description'], + }, + }, + }, + { + type: 'function', + function: { + name: 'run_script', + description: + 'Execute a JavaScript script in the GDevelop editor to inspect or modify the project. Inside the script, the editor functions are plain async functions in scope: call them with `await`, e.g. `await create_or_replace_object({...})` (a `functions` namespace alias with the same functions also exists). `console.log` lines and the return value are reported back. Nothing else is in scope: browser and engine globals (window, document, gd, fetch, localStorage) are shadowed or cannot reach the open project, so never probe for them - call an exposed function directly. A ReferenceError names the functions available in the script.', + parameters: { + type: 'object', + properties: { + js_code: { + type: 'string', + description: + 'JavaScript code to execute in the editor environment. Has access to exposed editor functions and project APIs.', + }, + title: { + type: 'string', + description: + 'Short human-readable title for this script, shown in the chat.', + }, + script: { + type: 'string', + description: + 'Deprecated alias for js_code. Prefer js_code; script is accepted for compatibility and mapped to js_code.', + }, + }, + required: ['js_code'], + }, + }, + }, + { + type: 'function', + function: { + name: 'read_events_source', + description: + 'Read the current events of a scene in EventScript source format.', + parameters: { + type: 'object', + properties: { + scene_name: { + type: 'string', + description: 'Name of the scene.', + }, + }, + required: ['scene_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'describe_instances', + description: 'List all instances present in a scene.', + parameters: { + type: 'object', + properties: { + scene_name: { + type: 'string', + description: 'Name of the scene.', + }, + filter_by_object_name: { + type: 'string', + description: + 'Optional comma-separated list of object names to filter by.', + }, + }, + required: ['scene_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'inspect_object_properties_effects', + description: 'Inspect properties and effects of an object.', + parameters: { + type: 'object', + properties: { + object_name: { type: 'string', description: 'Name of the object.' }, + scene_name: { + type: 'string', + description: 'Scene name if scene object.', + }, + }, + required: ['object_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'inspect_behavior_properties', + description: 'Inspect properties of a behavior attached to an object.', + parameters: { + type: 'object', + properties: { + object_name: { type: 'string', description: 'Name of the object.' }, + behavior_name: { + type: 'string', + description: 'Name of the behavior instance.', + }, + scene_name: { + type: 'string', + description: 'Scene name if scene object.', + }, + }, + required: ['object_name', 'behavior_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'inspect_scene_properties_layers_effects', + description: 'Inspect properties, layers, and effects of a scene.', + parameters: { + type: 'object', + properties: { + scene_name: { type: 'string', description: 'Name of the scene.' }, + }, + required: ['scene_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'change_scene_properties_layers_effects_groups', + description: + 'Change properties of a scene (such as backgroundColor, resolution, name, firstScene), layers, effects, or object groups.', + parameters: { + type: 'object', + properties: { + scene_name: { + type: 'string', + description: 'Name of the scene.', + }, + changed_properties: { + type: 'array', + items: { + type: 'object', + properties: { + property_name: { + type: 'string', + description: + 'Name of the scene property: "backgroundColor" (hex string like "#3498db" or "rgb(52, 152, 219)"), "gameResolutionWidth", "gameResolutionHeight", "name", "isFirstScene", "stopSoundsOnStartup", "gameOrientation", "gameScaleMode", "gameName".', + }, + new_value: { + type: 'string', + description: 'The new value for the property as a string.', + }, + }, + required: ['property_name', 'new_value'], + }, + description: 'List of scene properties to update.', + }, + changed_layers: { + type: 'array', + items: { + type: 'object', + properties: { + layer_name: { type: 'string' }, + new_layer_name: { type: 'string' }, + is_lighting_layer: { type: 'boolean' }, + is_follow_base_layer_camera: { type: 'boolean' }, + delete_this_layer: { type: 'boolean' }, + }, + required: ['layer_name'], + }, + description: 'List of layers to modify, create or remove.', + }, + changed_layer_effects: { + type: 'array', + description: + 'Effects of a layer to add, update, rename, move or remove.', + items: { + type: 'object', + properties: { + layer_name: { type: 'string' }, + effect_name: { type: 'string' }, + effect_type: { type: 'string' }, + new_effect_name: { type: 'string' }, + new_effect_position: { type: 'number' }, + delete_this_effect: { type: 'boolean' }, + changed_properties: { + type: 'array', + items: { + type: 'object', + properties: { + property_name: { type: 'string' }, + new_value: { type: 'string' }, + }, + required: ['property_name', 'new_value'], + }, + }, + }, + required: ['layer_name', 'effect_name'], + }, + }, + changed_groups: { + type: 'array', + description: + 'Object groups of the scene (or custom object) to create, rename, or change membership of.', + items: { + type: 'object', + properties: { + group_name: { type: 'string' }, + new_group_name: { type: 'string' }, + delete_this_group: { type: 'boolean' }, + objects_to_add: { + type: 'array', + items: { type: 'string' }, + }, + objects_to_remove: { + type: 'array', + items: { type: 'string' }, + }, + }, + required: ['group_name'], + }, + }, + move_instances: { + type: 'object', + description: + 'Move instances of this scene to another scene or external layout, optionally filtered.', + properties: { + to_scope: { + type: 'object', + description: + 'Target scope: { type: "scene", scene_name } or { type: "external_layout", external_layout_name }.', + }, + filter_by_object_names: { + type: 'array', + items: { type: 'string' }, + }, + filter_by_layer_names: { + type: 'array', + items: { type: 'string' }, + }, + instance_ids: { + type: 'array', + items: { type: 'string' }, + }, + }, + required: ['to_scope'], + }, + delete_this_scene: { + type: 'boolean', + description: 'Set to true to delete this scene.', + }, + }, + required: ['scene_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'change_object_properties_effects', + description: 'Change properties and visual effects of an object.', + parameters: { + type: 'object', + properties: { + object_name: { type: 'string', description: 'Name of the object.' }, + scene_name: { + type: 'string', + description: 'Name of the scene (omit for global object).', + }, + changed_properties: { + type: 'array', + items: { + type: 'object', + properties: { + property_name: { type: 'string' }, + new_value: { type: 'string' }, + }, + required: ['property_name', 'new_value'], + }, + }, + changed_effects: { + type: 'array', + description: + 'Visual effects of the object to add, update, rename, move or remove.', + items: { + type: 'object', + properties: { + effect_name: { type: 'string' }, + effect_type: { type: 'string' }, + new_effect_name: { type: 'string' }, + new_effect_position: { type: 'number' }, + delete_this_effect: { type: 'boolean' }, + changed_properties: { + type: 'array', + items: { + type: 'object', + properties: { + property_name: { type: 'string' }, + new_value: { type: 'string' }, + }, + required: ['property_name', 'new_value'], + }, + }, + }, + required: ['effect_name'], + }, + }, + delete_this_object: { type: 'boolean' }, + }, + required: ['object_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'change_behavior_property', + description: 'Change a property of a behavior attached to an object.', + parameters: { + type: 'object', + properties: { + object_name: { type: 'string', description: 'Name of the object.' }, + behavior_name: { + type: 'string', + description: 'Name of the behavior instance.', + }, + changed_properties: { + type: 'array', + items: { + type: 'object', + properties: { + property_name: { type: 'string' }, + new_value: { type: 'string' }, + }, + required: ['property_name', 'new_value'], + }, + description: + 'Behavior properties to set. Each item names a property and its new value as a string.', + }, + scene_name: { + type: 'string', + description: 'Scene name if scene object.', + }, + delete_this_behavior: { + type: 'boolean', + description: 'Set to true to remove this behavior.', + }, + }, + required: ['object_name', 'behavior_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'change_project_properties_resources', + description: + 'Change global project settings such as window resolution, orientation, scale mode, or game name.', + parameters: { + type: 'object', + properties: { + changed_properties: { + type: 'array', + items: { + type: 'object', + properties: { + property_name: { + type: 'string', + description: + 'Property name: "gameResolutionWidth", "gameResolutionHeight", "gameOrientation", "gameScaleMode", "gameName", "packageName", "version", "author", "loadingScreenBackgroundColor".', + }, + new_value: { type: 'string' }, + }, + required: ['property_name', 'new_value'], + }, + description: 'List of project properties to update.', + }, + changed_resources: { + type: 'array', + description: + 'Project resources to rename or remove (e.g. an audio file).', + items: { + type: 'object', + properties: { + resource_name: { type: 'string' }, + new_resource_name: { type: 'string' }, + delete_this_resource: { type: 'boolean' }, + }, + required: ['resource_name'], + }, + }, + }, + }, + }, + }, + { + type: 'function', + function: { + name: 'inspect_project_properties_resources', + description: 'Inspect project global properties and resources list.', + parameters: { + type: 'object', + properties: {}, + }, + }, + }, + { + type: 'function', + function: { + name: 'add_or_edit_variable', + description: 'Add or edit a variable at project, scene, or object level.', + parameters: { + type: 'object', + properties: { + variable_scope: { + type: 'string', + enum: ['global', 'scene', 'object', 'group', 'instance'], + description: 'Scope of the variable ("global", "scene", "object").', + }, + scope: { + type: 'string', + enum: ['global', 'scene', 'object', 'group', 'instance'], + description: 'Alias for variable_scope.', + }, + variable_name_or_path: { + type: 'string', + description: + 'Variable name or path (e.g. "Score" or "Player.Health").', + }, + name: { + type: 'string', + description: 'Alias for variable_name_or_path.', + }, + variable_type: { + type: 'string', + enum: ['number', 'string', 'boolean', 'structure', 'array'], + description: 'Variable type.', + }, + type: { + type: 'string', + enum: ['number', 'string', 'boolean', 'structure', 'array'], + description: 'Alias for variable_type.', + }, + value: { description: 'Initial or updated value for the variable.' }, + scene_name: { + type: 'string', + description: 'Scene name if scope is scene or object.', + }, + object_name: { + type: 'string', + description: 'Object name if scope is object.', + }, + delete_this_variable: { + type: 'boolean', + description: 'Set to true to delete this variable.', + }, + variables: { + type: 'array', + items: { + type: 'object', + properties: { + variable_name_or_path: { type: 'string' }, + name: { type: 'string' }, + value: { type: 'string' }, + variable_type: { type: 'string' }, + type: { type: 'string' }, + delete_this_variable: { type: 'boolean' }, + }, + }, + description: 'Optional batch of variable operations.', + }, + }, + }, + }, + }, + { + type: 'function', + function: { + name: 'put_3d_instances', + description: + 'Place 3D instances of objects into a scene at specified 3D coordinates.', + parameters: { + type: 'object', + properties: { + scene_name: { type: 'string', description: 'Name of the scene.' }, + object_name: { + type: 'string', + description: 'Name of the 3D object.', + }, + layer_name: { + type: 'string', + description: 'Layer name (default "").', + }, + brush_kind: { + type: 'string', + enum: ['create', 'erase', 'modify'], + description: 'Action to perform.', + }, + brush_position: { + type: 'string', + description: 'Position "x, y, z" in 3D space.', + }, + new_instances_count: { + type: 'number', + description: 'Number of instances to create (default 1).', + }, + }, + required: ['scene_name', 'layer_name', 'brush_kind'], + }, + }, + }, + { + type: 'function', + function: { + name: 'inspect_variables', + description: 'Inspect variables at global, scene, or object level.', + parameters: { + type: 'object', + properties: { + variable_scope: { + type: 'string', + enum: ['global', 'scene', 'object'], + description: 'Scope of the variables to inspect.', + }, + scene_name: { + type: 'string', + description: 'Name of the scene (if scope is scene or object).', + }, + object_name: { + type: 'string', + description: 'Name of the object (if scope is object).', + }, + variable_names_or_paths: { + type: 'array', + items: { type: 'string' }, + description: + 'Optional list of specific variable names or paths to inspect.', + }, + }, + required: ['variable_scope'], + }, + }, + }, + { + type: 'function', + function: { + name: 'read_game_project_json', + description: 'Read the complete or partial game project structure JSON.', + parameters: { + type: 'object', + properties: { + section: { + type: 'string', + description: 'Optional section of project JSON to inspect.', + }, + }, + }, + }, + }, + { + type: 'function', + function: { + name: 'search_object_asset_store', + description: + 'Search the GDevelop asset store for 2D/3D objects and sprite packs.', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search term (e.g. "player", "coin", "platform").', + }, + category: { type: 'string', description: 'Optional asset category.' }, + }, + required: ['query'], + }, + }, + }, + { + type: 'function', + function: { + name: 'search_resource_store', + description: + 'Search the GDevelop resource store for audio sounds, music, and fonts.', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search term (e.g. "jump", "laser", "music").', + }, + resource_kind: { + type: 'string', + enum: ['audio', 'font', 'image'], + description: 'Kind of resource to search.', + }, + }, + required: ['query'], + }, + }, + }, + { + type: 'function', + function: { + name: 'search_docs', + description: + 'Search GDevelop documentation for functions, behaviors, and instructions. [Offline in local BYOK mode: rely on internal GDevelop knowledge and on-project verification]', + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'Documentation search query.' }, + }, + required: ['query'], + }, + }, + }, + { + type: 'function', + function: { + name: 'read_full_docs', + description: + 'Read full documentation for specific GDevelop extensions or features. [Offline in local BYOK mode: rely on internal GDevelop knowledge and on-project verification]', + parameters: { + type: 'object', + properties: { + extension_names: { + type: 'string', + description: + 'Comma-separated names of extensions to read docs for.', + }, + }, + required: ['extension_names'], + }, + }, + }, + { + type: 'function', + function: { + name: 'initialize_project', + description: 'Initialize a new project from a starter template.', + parameters: { + type: 'object', + properties: { + project_name: { + type: 'string', + description: 'Name of the new project/game.', + }, + game_name: { type: 'string', description: 'Alias for project_name.' }, + name: { type: 'string', description: 'Alias for project_name.' }, + template_slug: { + type: 'string', + description: + 'Slug of the starter template (e.g. "empty", "platformer", "top-down").', + }, + also_read_existing_events: { + type: 'boolean', + description: 'Whether to read existing events from template.', + }, + }, + }, + }, + }, + { + type: 'function', + function: { + name: 'get_game_starter_summary', + description: + 'Get a summary of an example starter template to study its structure.', + parameters: { + type: 'object', + properties: { + template_slug: { + type: 'string', + description: 'Slug of the starter template.', + }, + }, + required: ['template_slug'], + }, + }, + }, + { + type: 'function', + function: { + name: 'create_or_update_plan', + description: 'Create or update the step-by-step orchestrator plan.', + parameters: { + type: 'object', + properties: { + tasks: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Task ID (e.g. "task_1").' }, + title: { type: 'string', description: 'Short task title.' }, + description: { type: 'string', description: 'Task details.' }, + status: { + type: 'string', + enum: ['pending', 'in_progress', 'done', 'voided'], + description: 'Task status.', + }, + dependsOn: { + type: 'array', + items: { type: 'string' }, + description: 'List of task IDs this task depends on.', + }, + }, + required: ['id', 'title', 'description', 'status'], + }, + }, + }, + required: ['tasks'], + }, + }, + }, + { + type: 'function', + function: { + name: 'run_gameplay_test', + description: + 'Run or save a gameplay test in the project or an extension.', + parameters: { + type: 'object', + properties: { + scope: { + type: 'object', + properties: { + type: { type: 'string', enum: ['project', 'extension'] }, + extension_name: { type: 'string' }, + }, + required: ['type'], + description: + 'Test scope ({ type: "project" } or { type: "extension", extension_name: "..." }).', + }, + test_name: { type: 'string', description: 'Name of the test.' }, + source: { + type: 'string', + description: 'Optional test JavaScript code.', + }, + persist: { + type: 'boolean', + description: 'Whether to save the test (default true).', + }, + timeout_ms: { + type: 'number', + description: 'Timeout in milliseconds.', + }, + }, + required: ['scope', 'test_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'change_gameplay_tests', + description: + 'Change properties of or delete gameplay tests in the project.', + parameters: { + type: 'object', + properties: { + scope: { + type: 'object', + properties: { + type: { type: 'string', enum: ['project', 'extension'] }, + extension_name: { type: 'string' }, + }, + required: ['type'], + }, + changes: { + type: 'array', + items: { + type: 'object', + properties: { + test_name: { type: 'string' }, + delete_this_test: { type: 'boolean' }, + changed_properties: { + type: 'array', + items: { + type: 'object', + properties: { + property_name: { type: 'string' }, + new_value: { type: 'string' }, + }, + required: ['property_name', 'new_value'], + }, + }, + }, + required: ['test_name'], + }, + }, + }, + required: ['scope', 'changes'], + }, + }, + }, + { + type: 'function', + function: { + name: 'create_extension', + description: + 'Create a new events-functions extension in the project. Optionally copy an existing one.', + parameters: { + type: 'object', + properties: { + extension_name: { + type: 'string', + description: + 'Name of the extension to create. It is made unique automatically if taken.', + }, + duplicated_extension_name: { + type: 'string', + description: + 'Name of an existing extension to copy into the new one. Omit to create an empty extension.', + }, + full_name: { + type: 'string', + description: 'Human-readable title shown in the editor.', + }, + short_description: { + type: 'string', + description: 'One-line description of the extension.', + }, + description: { + type: 'string', + description: 'Full description of the extension.', + }, + category: { type: 'string', description: 'Extension category.' }, + tags: { + type: 'string', + description: 'Comma-separated tags.', + }, + author: { type: 'string', description: 'Extension author.' }, + }, + required: ['extension_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'change_extension_properties', + description: + 'Change properties of an extension, its dependencies, or delete the extension.', + parameters: { + type: 'object', + properties: { + extension_name: { + type: 'string', + description: 'Name of the extension to change.', + }, + new_name: { + type: 'string', + description: 'New name for the extension, to rename it.', + }, + delete_this_extension: { + type: 'boolean', + description: 'Set to true to delete the extension.', + }, + delete_even_if_used: { + type: 'boolean', + description: + 'Allow deleting an extension that is still used. Only when delete_this_extension is true.', + }, + changed_properties: { + type: 'array', + description: 'Properties to set on the extension.', + items: { + type: 'object', + properties: { + property_name: { + type: 'string', + description: + 'One of: fullName, shortDescription, description, category, tags, version, author, helpPath, previewIconUrl, iconUrl, dimension.', + }, + new_value: { type: 'string' }, + }, + required: ['property_name', 'new_value'], + }, + }, + changed_dependencies: { + type: 'array', + description: 'Dependencies to create, edit or delete.', + items: { + type: 'object', + properties: { + dependency_name: { type: 'string' }, + dependency_type: { type: 'string' }, + new_name: { type: 'string' }, + export_name: { type: 'string' }, + version: { type: 'string' }, + delete_this_dependency: { type: 'boolean' }, + extra_settings: { + type: 'object', + description: 'Dependency-specific settings, as strings.', + }, + }, + required: ['dependency_name'], + }, + }, + }, + required: ['extension_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'create_custom_object', + description: + 'Create a custom object (an events-based object) in an extension, optionally as a copy of an existing one.', + parameters: { + type: 'object', + properties: { + extension_name: { + type: 'string', + description: 'Extension to create the custom object in.', + }, + custom_object_name: { + type: 'string', + description: 'Name of the new custom object.', + }, + duplicated_custom_object_name: { + type: 'string', + description: 'Existing custom object to copy.', + }, + duplicated_from_extension_name: { + type: 'string', + description: + 'Extension holding the custom object to copy. Defaults to extension_name.', + }, + area: { + type: 'object', + description: + 'Default area of the object, as numbers: minX, minY, minZ, maxX, maxY, maxZ.', + properties: { + minX: { type: 'number' }, + minY: { type: 'number' }, + minZ: { type: 'number' }, + maxX: { type: 'number' }, + maxY: { type: 'number' }, + maxZ: { type: 'number' }, + }, + }, + }, + required: ['extension_name', 'custom_object_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'change_custom_object', + description: + 'Change a custom object: rename, delete, change its settings, its variants or its properties.', + parameters: { + type: 'object', + properties: { + extension_name: { + type: 'string', + description: 'Extension holding the custom object.', + }, + custom_object_name: { + type: 'string', + description: 'Name of the custom object to change.', + }, + new_name: { type: 'string', description: 'New name, to rename it.' }, + delete_this_custom_object: { + type: 'boolean', + description: 'Set to true to delete the custom object.', + }, + delete_even_if_used: { + type: 'boolean', + description: + 'Allow deleting a custom object that is still used. Only when delete_this_custom_object is true.', + }, + fit_area_to_children: { + type: 'string', + description: + 'Whether to fit the area to the children (true/false).', + }, + changed_settings: { + type: 'array', + description: 'Settings to change on the custom object.', + items: { + type: 'object', + properties: { + setting_name: { type: 'string' }, + new_value: { + description: + 'String, boolean or number depending on the setting.', + }, + }, + required: ['setting_name', 'new_value'], + }, + }, + changed_variants: { + type: 'array', + description: 'Variants to create or delete.', + items: { + type: 'object', + properties: { + variant_name: { type: 'string' }, + duplicated_from_variant_name: { type: 'string' }, + delete_this_variant: { type: 'boolean' }, + }, + required: ['variant_name'], + }, + }, + changed_properties: { + type: 'array', + description: 'Properties to set on the custom object.', + items: { + type: 'object', + properties: { + property_name: { type: 'string' }, + new_value: { type: 'string' }, + }, + required: ['property_name', 'new_value'], + }, + }, + }, + required: ['extension_name', 'custom_object_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'create_custom_behavior', + description: + 'Create a custom behavior (an events-based behavior) in an extension.', + parameters: { + type: 'object', + properties: { + extension_name: { + type: 'string', + description: 'Extension to create the behavior in.', + }, + custom_behavior_name: { + type: 'string', + description: 'Name of the new custom behavior.', + }, + duplicated_custom_behavior_name: { + type: 'string', + description: 'Existing custom behavior to copy.', + }, + duplicated_from_extension_name: { + type: 'string', + description: + 'Extension holding the behavior to copy. Defaults to extension_name.', + }, + full_name: { + type: 'string', + description: 'Human-readable title.', + }, + description: { type: 'string' }, + object_type: { + type: 'string', + description: + 'Restrict the behavior to objects of this type. Omit for all objects.', + }, + }, + required: ['extension_name', 'custom_behavior_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'change_custom_behavior', + description: + 'Change a custom behavior: rename, delete, change its settings, its shared properties or its properties.', + parameters: { + type: 'object', + properties: { + extension_name: { + type: 'string', + description: 'Extension holding the custom behavior.', + }, + custom_behavior_name: { + type: 'string', + description: 'Name of the custom behavior to change.', + }, + new_name: { type: 'string', description: 'New name, to rename it.' }, + delete_this_custom_behavior: { + type: 'boolean', + description: 'Set to true to delete the custom behavior.', + }, + delete_even_if_used: { + type: 'boolean', + description: + 'Allow deleting a behavior that is still used. Only when delete_this_custom_behavior is true.', + }, + changed_settings: { + type: 'array', + description: 'Settings to change on the custom behavior.', + items: { + type: 'object', + properties: { + setting_name: { type: 'string' }, + new_value: { + description: + 'String, boolean or number depending on the setting.', + }, + }, + required: ['setting_name', 'new_value'], + }, + }, + changed_shared_properties: { + type: 'array', + description: 'Shared properties of the behavior to change.', + items: { + type: 'object', + properties: { + property_name: { type: 'string' }, + new_value: { type: 'string' }, + }, + required: ['property_name', 'new_value'], + }, + }, + changed_properties: { + type: 'array', + description: 'Properties of the behavior to change.', + items: { + type: 'object', + properties: { + property_name: { type: 'string' }, + new_value: { type: 'string' }, + }, + required: ['property_name', 'new_value'], + }, + }, + }, + required: ['extension_name', 'custom_behavior_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'create_custom_function', + description: + 'Create a function in an extension, a custom object or a custom behavior.', + parameters: { + type: 'object', + properties: { + scope: { + type: 'object', + description: + 'Where to create the function. type "extension" needs extension_name; "custom_object" needs extension_name and custom_object_name; "custom_behavior" needs extension_name and custom_behavior_name.', + properties: { + type: { + type: 'string', + enum: ['extension', 'custom_object', 'custom_behavior'], + }, + extension_name: { type: 'string' }, + custom_object_name: { type: 'string' }, + custom_behavior_name: { type: 'string' }, + }, + required: ['type'], + }, + function_name: { + type: 'string', + description: 'Name of the new function.', + }, + duplicated_function_name: { + type: 'string', + description: 'Existing function to copy.', + }, + function_type: { + type: 'string', + description: + 'Action, Condition, Expression, StringExpression or ActionWithOperator.', + }, + getter_name: { + type: 'string', + description: + 'For ActionWithOperator: the getter function to build from.', + }, + full_name: { type: 'string' }, + description: { type: 'string' }, + group: { type: 'string' }, + sentence: { + type: 'string', + description: 'Sentence shown in the events sheet.', + }, + parameters: { + type: 'array', + description: 'Parameters to declare on the function.', + items: { type: 'object' }, + }, + }, + required: ['scope', 'function_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'change_custom_function', + description: + 'Change a function: rename, delete, change its settings or its parameters.', + parameters: { + type: 'object', + properties: { + scope: { + type: 'object', + description: + 'Where the function lives. type "extension" needs extension_name; "custom_object" needs extension_name and custom_object_name; "custom_behavior" needs extension_name and custom_behavior_name.', + properties: { + type: { + type: 'string', + enum: ['extension', 'custom_object', 'custom_behavior'], + }, + extension_name: { type: 'string' }, + custom_object_name: { type: 'string' }, + custom_behavior_name: { type: 'string' }, + }, + required: ['type'], + }, + function_name: { + type: 'string', + description: 'Name of the function to change.', + }, + new_name: { type: 'string', description: 'New name, to rename it.' }, + delete_this_function: { + type: 'boolean', + description: 'Set to true to delete the function.', + }, + changed_settings: { + type: 'array', + description: 'Settings to change on the function.', + items: { + type: 'object', + properties: { + setting_name: { type: 'string' }, + new_value: { + description: + 'String, boolean or number depending on the setting.', + }, + }, + required: ['setting_name', 'new_value'], + }, + }, + changed_parameters: { + type: 'array', + description: 'Parameters to change on the function.', + items: { type: 'object' }, + }, + }, + required: ['scope', 'function_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'inspect_extension', + description: + 'Read the structure of an extension: its custom objects, custom behaviors, functions and variants.', + parameters: { + type: 'object', + properties: { + extension_name: { + type: 'string', + description: 'Name of the extension to inspect.', + }, + custom_object_name: { + type: 'string', + description: 'Restrict the output to this custom object.', + }, + custom_behavior_name: { + type: 'string', + description: 'Restrict the output to this custom behavior.', + }, + function_name: { + type: 'string', + description: 'Restrict the output to this function.', + }, + variant_name: { + type: 'string', + description: 'Restrict the output to this variant.', + }, + }, + required: ['extension_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'spawn_agent', + description: + 'Delegate one task to a studio sub-agent, which works on it and reports back. Only the studio lead may call this. Sub-agents never delegate further.', + parameters: { + type: 'object', + properties: { + role: { + type: 'string', + description: + 'Which specialist to use: "designer" writes the design document as GDD_ project variables; "developer" changes the project to implement a task; "tester" writes and runs a gameplay test and reports pass or fail.', + enum: ['designer', 'developer', 'tester'], + }, + short_title: { + type: 'string', + description: + 'A 2-5 word label shown in the chat for this sub-agent, e.g. "Build the town grid".', + }, + task: { + type: 'string', + description: + 'The complete, self-contained instruction for the sub-agent. It cannot see this conversation, so state everything it needs: the scene, the objects, the expected result.', + }, + context: { + type: 'string', + description: + 'Extra facts the sub-agent needs: relevant variable values, previous findings, constraints.', + }, + related_task_id: { + type: 'string', + description: + 'The id of the create_or_update_plan task this agent works on. That task is marked done when the agent reports back.', + }, + }, + required: ['role', 'short_title', 'task'], + }, + }, + }, +]; + +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'content-type, authorization', + 'Access-Control-Max-Age': '86400', +}; + +const MAX_BODY_BYTES = 1024 * 1024; + +const errorPayload = (message, code = 'invalid_request_error') => ({ + error: { message, type: 'invalid_request_error', code }, +}); + +function sendJson(res, status, payload) { + const body = JSON.stringify(payload); + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + ...CORS_HEADERS, + }); + res.end(body); +} + +function validateTools(tools) { + if (tools === undefined) return; + if (!Array.isArray(tools)) { + throw new Error('`tools` must be an array'); + } + for (const tool of tools) { + if ( + !tool || + tool.type !== 'function' || + !tool.function || + typeof tool.function.name !== 'string' || + tool.function.name.length === 0 + ) { + throw new Error('every `tools` entry must be {type:"function",function:{name,...}}'); + } + } +} + +function validateToolChoice(toolChoice) { + if (toolChoice === undefined || toolChoice === null) return; + if (typeof toolChoice === 'string') { + if (!['none', 'auto', 'required'].includes(toolChoice)) { + throw new Error('`tool_choice` string must be none|auto|required'); + } + return; + } + if ( + typeof toolChoice !== 'object' || + toolChoice.type !== 'function' || + !toolChoice.function || + typeof toolChoice.function.name !== 'string' + ) { + throw new Error('`tool_choice` object must be {type:"function",function:{name}}'); + } + if (!isEditorFunctionName(toolChoice.function.name)) { + throw new Error(`unknown function "${toolChoice.function.name}" in \`tool_choice\``); + } +} + +/** Find the last explicit function-call message, oldest-last scan. */ +function findFunctionCall(messages) { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (!message || typeof message.content !== 'string') continue; + const call = parseFunctionCallMessage(message.content); + if (call) return call; + } + return null; +} + +function buildEchoContent(session) { + const userTexts = session.prompt + .split('\n') + .filter(line => !line.startsWith('[')) + .join('\n') + .trim(); + const last = userTexts.slice(-200); + const count = session.prompt.split('\n').length; + return last.length > 0 + ? `[${OPENAI_MODEL_ID}] echo (${count} prompt line(s)): ${last}` + : `[${OPENAI_MODEL_ID}] echo: empty prompt`; +} + +function writeSseResponse(res, { model, sessionId, content, toolCalls }) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + ...CORS_HEADERS, + }); + let alive = true; + res.on('close', () => { + alive = false; + }); + const frame = payload => alive && res.write(`data: ${JSON.stringify(payload)}\n\n`); + const created = Math.floor(Date.now() / 1000); + const base = { id: sessionId, object: 'chat.completion.chunk', created, model }; + frame({ ...base, choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] }); + if (toolCalls && toolCalls.length > 0) { + // Stream each call as name + arguments deltas (arguments may ride in + // several chunks for large payloads, mirroring hosted providers). + toolCalls.forEach((call, index) => { + frame({ + ...base, + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { index, id: call.id, type: 'function', function: { name: call.function.name, arguments: '' } }, + ], + }, + finish_reason: null, + }, + ], + }); + for (const piece of chunkTextForStream(call.function.arguments, 64)) { + frame({ + ...base, + choices: [ + { + index: 0, + delta: { tool_calls: [{ index, function: { arguments: piece } }] }, + finish_reason: null, + }, + ], + }); + } + }); + frame({ ...base, choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] }); + } else { + for (const piece of chunkTextForStream(content)) { + frame({ ...base, choices: [{ index: 0, delta: { content: piece }, finish_reason: null }] }); + } + frame({ ...base, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }); + } + if (alive) res.write('data: [DONE]\n\n'); + res.end(); +} + +function handleChatCompletions(req, res, { model }) { + let received = 0; + let oversized = false; + const pieces = []; + req.on('data', chunk => { + received += chunk.length; + if (received > MAX_BODY_BYTES) { + // Stop accumulating (memory-safe) but keep draining so the client + // receives a proper 413 instead of a connection reset. + oversized = true; + return; + } + pieces.push(chunk); + }); + req.on('end', () => { + if (oversized) { + sendJson(res, 413, errorPayload('request body exceeds 1MB')); + return; + } + let body; + try { + body = JSON.parse(Buffer.concat(pieces).toString('utf8') || 'null'); + } catch { + sendJson(res, 400, errorPayload('request body is not valid JSON')); + return; + } + try { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new Error('request body must be a JSON object'); + } + const { messages, tools, tool_choice, stream } = body; + validateTools(tools); + validateToolChoice(tool_choice); + const session = openAiMessagesToSession(messages, {}); + const sessionId = `chatcmpl-${session.id.replace(/^sess_/, '')}`; + const functionCall = findFunctionCall(messages); + const toolCalls = functionCall + ? [mapFunctionCallToToolCall(functionCall)] + : []; + const content = buildEchoContent(session); + const usage = buildUsage( + session.prompt, + toolCalls.length > 0 ? toolCalls[0].function.arguments : content + ); + if (stream === true) { + writeSseResponse(res, { model, sessionId, content, toolCalls }); + return; + } + const finish_reason = toolCalls.length > 0 ? 'tool_calls' : 'stop'; + sendJson(res, 200, { + id: sessionId, + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: toolCalls.length > 0 ? null : content, + ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}), + }, + finish_reason, + }, + ], + usage, + }); + } catch (err) { + sendJson(res, 400, errorPayload(err.message)); + } + }); + req.on('error', () => { + try { + sendJson(res, 400, errorPayload('request read failed')); + } catch {} + }); +} + +export function createRequestHandler({ model = OPENAI_MODEL_ID } = {}) { + return (req, res) => { + const url = new URL(req.url || '/', 'http://localhost'); + if (req.method === 'OPTIONS') { + res.writeHead(204, CORS_HEADERS); + res.end(); + return; + } + if (url.pathname === '/v1/models') { + if (req.method !== 'GET') { + sendJson(res, 405, { error: { message: 'method not allowed', type: 'invalid_request_error', code: 'method_not_allowed' } }); + return; + } + sendJson(res, 200, { + object: 'list', + data: [{ id: model, object: 'model', owned_by: 'gdevelop-local' }], + }); + return; + } + if (url.pathname === '/v1/chat/completions') { + if (req.method !== 'POST') { + sendJson(res, 405, { error: { message: 'method not allowed', type: 'invalid_request_error', code: 'method_not_allowed' } }); + return; + } + handleChatCompletions(req, res, { model }); + return; + } + sendJson(res, 404, { + error: { message: `not found: ${url.pathname}`, type: 'invalid_request_error', code: 'not_found' }, + }); + }; +} + +/** + * Boot the daemon. `port: 0` binds an ephemeral loopback port (discover via + * `handle.address().port`). Resolves to `{ address(), close() }`. + */ +export function startServer({ host = '127.0.0.1', port = 0, model = OPENAI_MODEL_ID } = {}) { + if (typeof port !== 'number' || !Number.isInteger(port) || port < 0 || port > 65535) { + return Promise.reject(new RangeError(`[opencode-provider] invalid port (${String(port)})`)); + } + return new Promise((resolve, reject) => { + const server = http.createServer(createRequestHandler({ model })); + server.once('error', reject); + server.listen(port, host, () => { + resolve({ + address: () => server.address(), + close: () => + new Promise(closeResolve => { + server.close(() => closeResolve()); + }), + }); + }); + }); +} diff --git a/packages/unlock-shim/README.md b/packages/unlock-shim/README.md index b8e3f5d..3085f26 100644 --- a/packages/unlock-shim/README.md +++ b/packages/unlock-shim/README.md @@ -7,6 +7,7 @@ no React, no Electron, no GDevelop core imports. | Module | Role | Tests | |---|---|---| | `generation-shim.mjs` | Tier-2 routing: `local-ai-*` → custom client, hosted → hosted API, fail-closed leak guard (`assertNoLocalIdsLeak`, `guardHostedCall`) | `tests/tier2/shim.test.mjs`, `tests/integration/ai-byok-flow.test.mjs` | +| `control-shim.mjs` | Programmatic project control (T07 #16): `read`/`writeGDevelopProject` (single-file + split-folder `__REFERENCE_TO_SPLIT_OBJECT`), `createScene`, `createOrReplaceObject`, `addBehavior` (Platformer/Physics2/TopDown defaults), `addStandardEvent`, `buildGDevelopCliCommand` (`--run-command`/`--cmd-args` for `electron-app/app/main.js` + `LocalCliCommandRunner`), `connectDebuggerBridge` (minimal RFC6455 client over `node:net` for the preview `DebuggerServer`) | `tests/unit/opencode-control-shim.test.mjs` | ## Duplication contract (what actually ships) diff --git a/packages/unlock-shim/control-shim.mjs b/packages/unlock-shim/control-shim.mjs new file mode 100644 index 0000000..896f0c1 --- /dev/null +++ b/packages/unlock-shim/control-shim.mjs @@ -0,0 +1,593 @@ +// Control shim (T07, issue #16): pure zero-dependency ESM helpers for +// programmatic GDevelop project control. +// +// Reads/writes stock `game.json` project files (single-file or split-folder +// layouts with `__REFERENCE_TO_SPLIT_OBJECT` entries, mirroring +// `LocalProjectWriter.js`), mutates the plain project JSON (scenes, objects, +// behaviors, standard events), formats headless CLI invocations for +// `electron-app/app/main.js` + `LocalCliCommandRunner.js` +// (`--run-command NAME --cmd-args ARGS`), and speaks to the preview +// `DebuggerServer` (a `ws`-based WebSocket server on ports 3030-4000) with a +// minimal RFC6455 client built on `node:net` only. +// +// Zero-dependency: only `node:` builtins. No hosted calls anywhere — this +// module never touches the hosted generation API (see the zero-egress unit test). + +import fs from 'node:fs'; +import path from 'node:path'; +import net from 'node:net'; +import crypto from 'node:crypto'; + +const REFERENCE_KEY = '__REFERENCE_TO_SPLIT_OBJECT'; +const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; + +/** Load one JSON file, throwing a fail-closed error naming the path. */ +function readJsonFile(filePath) { + let raw; + try { + raw = fs.readFileSync(filePath, 'utf8'); + } catch (err) { + throw new Error( + `[control-shim] project file not found: ${filePath} (${err.message})` + ); + } + try { + return JSON.parse(raw); + } catch (err) { + throw new Error( + `[control-shim] malformed game.json at ${filePath}: ${err.message}` + ); + } +} + +/** + * Recursively resolve `{__REFERENCE_TO_SPLIT_OBJECT: 'layouts/x'}` entries + * against `baseDir` (the split-folder layout `LocalProjectWriter` emits: + * `.json` next to `game.json`). + */ +function resolveSplitReferences(node, baseDir) { + if (Array.isArray(node)) return node.map(item => resolveSplitReferences(item, baseDir)); + if (node && typeof node === 'object') { + if ( + typeof node[REFERENCE_KEY] === 'string' && + Object.keys(node).length === 1 + ) { + return resolveSplitReferences( + readJsonFile(path.join(baseDir, `${node[REFERENCE_KEY]}.json`)), + baseDir + ); + } + const out = {}; + for (const [key, value] of Object.entries(node)) { + out[key] = resolveSplitReferences(value, baseDir); + } + return out; + } + return node; +} + +function assertProjectShape(projectData, where) { + if (!projectData || typeof projectData !== 'object' || Array.isArray(projectData)) { + throw new Error( + `[control-shim] ${where}: project data must be a JSON object` + ); + } + if ( + !projectData.properties || + typeof projectData.properties !== 'object' || + Array.isArray(projectData.properties) + ) { + throw new Error( + `[control-shim] ${where}: project data is missing the "properties" object (not a GDevelop project)` + ); + } + return projectData; +} + +/** + * Load and validate a GDevelop project. `projectPath` is either a single-file + * `game.json` or a split-folder project directory containing `game.json`. + */ +export function readGDevelopProject(projectPath) { + if (typeof projectPath !== 'string' || projectPath.length === 0) { + throw new Error('[control-shim] readGDevelopProject: projectPath is required'); + } + let stat; + try { + stat = fs.statSync(projectPath); + } catch (err) { + throw new Error( + `[control-shim] project path not found: ${projectPath} (${err.message})` + ); + } + if (stat.isDirectory()) { + const mainFile = path.join(projectPath, 'game.json'); + const parsed = readJsonFile(mainFile); + return assertProjectShape( + resolveSplitReferences(parsed, projectPath), + `readGDevelopProject(${projectPath})` + ); + } + return assertProjectShape(readJsonFile(projectPath), `readGDevelopProject(${projectPath})`); +} + +/** Canonical serialization: 2-space JSON plus a final newline. */ +export function serializeGDevelopProject(projectData) { + assertProjectShape(projectData, 'serializeGDevelopProject'); + return JSON.stringify(projectData, null, 2) + '\n'; +} + +/** + * Persist `projectData` deterministically. A directory path writes its + * `game.json`; a file path writes the file directly (parent dirs created). + */ +export function writeGDevelopProject(projectPath, projectData) { + if (typeof projectPath !== 'string' || projectPath.length === 0) { + throw new Error('[control-shim] writeGDevelopProject: projectPath is required'); + } + const content = serializeGDevelopProject(projectData); + let target = projectPath; + try { + if (fs.statSync(projectPath).isDirectory()) { + target = path.join(projectPath, 'game.json'); + } + } catch { + // Path does not exist yet: treat as a file path. + } + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content, 'utf8'); + return target; +} + +function requireScene(projectData, sceneName, where) { + assertProjectShape(projectData, where); + if (typeof sceneName !== 'string' || sceneName.trim().length === 0) { + throw new Error(`[control-shim] ${where}: sceneName must be a non-empty string`); + } + if (!Array.isArray(projectData.layouts)) projectData.layouts = []; + const scene = projectData.layouts.find(layout => layout && layout.name === sceneName); + if (!scene) { + throw new Error(`[control-shim] ${where}: scene not found: "${sceneName}"`); + } + return scene; +} + +/** + * Inject a new scene (layout) into `projectData.layouts`. Mutates in place + * and returns the created layout. Throws on duplicate or blank names. + */ +export function createScene(projectData, sceneName) { + assertProjectShape(projectData, 'createScene'); + if (typeof sceneName !== 'string' || sceneName.trim().length === 0) { + throw new Error('[control-shim] createScene: sceneName must be a non-empty string'); + } + if (!Array.isArray(projectData.layouts)) projectData.layouts = []; + if (projectData.layouts.some(layout => layout && layout.name === sceneName)) { + throw new Error(`[control-shim] createScene: scene already exists: "${sceneName}"`); + } + const scene = { + associatedEditorLayouts: [], + events: [], + instances: [], + layers: [{ name: 'Base layer', visibility: true, effects: [], cameras: [] }], + name: sceneName, + objects: [], + title: sceneName, + variables: [], + }; + projectData.layouts.push(scene); + return scene; +} + +/** + * Add a new object to a scene (or to the global `projectData.objects` when + * `sceneName` is omitted), or replace the same-named object in place. + * Returns `{ object, created }`. + */ +export function createOrReplaceObject( + projectData, + { sceneName, objectName, objectType, description } = {} +) { + const where = 'createOrReplaceObject'; + assertProjectShape(projectData, where); + if (typeof objectName !== 'string' || objectName.trim().length === 0) { + throw new Error(`[control-shim] ${where}: objectName must be a non-empty string`); + } + if (typeof objectType !== 'string' || objectType.trim().length === 0) { + throw new Error(`[control-shim] ${where}: objectType must be a non-empty string`); + } + let container; + if (sceneName !== undefined && sceneName !== null && String(sceneName).length > 0) { + const scene = requireScene(projectData, sceneName, where); + if (!Array.isArray(scene.objects)) scene.objects = []; + container = scene.objects; + } else { + if (!Array.isArray(projectData.objects)) projectData.objects = []; + container = projectData.objects; + } + const object = { + name: objectName, + type: objectType, + variables: [], + effects: [], + behaviors: [], + }; + if (description !== undefined) object.description = description; + const index = container.findIndex(entry => entry && entry.name === objectName); + if (index === -1) { + container.push(object); + return { object, created: true }; + } + // Replace: keep array position stable, preserve existing behaviors unless + // the caller re-adds them via addBehavior. + const previous = container[index]; + if (Array.isArray(previous.behaviors)) object.behaviors = previous.behaviors; + if (Array.isArray(previous.variables)) object.variables = previous.variables; + if (Array.isArray(previous.effects)) object.effects = previous.effects; + container[index] = object; + return { object, created: false }; +} + +/** Default behavior-instance content per family (keyed by type fragment). */ +const BEHAVIOR_FAMILY_DEFAULTS = [ + { + match: 'PlatformerObjectBehavior', + content: { + canGrabPlatforms: true, + canGrabLadders: false, + maxSpeed: 250, + acceleration: 1500, + deceleration: 1500, + maxFallingSpeed: 700, + jumpSpeed: 600, + jumpSustainTime: 0.15, + gravity: 1500, + useDefaultControls: true, + }, + }, + { + match: 'Physics2Behavior', + content: { + bodyType: 'Dynamic', + bullet: false, + fixedRotation: false, + shape: 'Box', + density: 1, + friction: 0.3, + restitution: 0, + layers: 1, + masks: 1, + linearDamping: 0.1, + angularDamping: 0.1, + gravityScale: 1, + }, + }, + { + match: 'TopDownMovementBehavior', + content: { + allowDiagonals: true, + maxSpeed: 200, + acceleration: 800, + deceleration: 800, + rotateObject: false, + useDefaultControls: true, + angularMaxSpeed: 180, + }, + }, +]; + +function defaultBehaviorContent(behaviorType) { + for (const family of BEHAVIOR_FAMILY_DEFAULTS) { + if (behaviorType.includes(family.match)) return { ...family.content }; + } + return {}; +} + +/** + * Inject (or replace) a behavior block on an object. `behaviorType` is the + * fully qualified type (e.g. `PlatformBehavior::PlatformerObjectBehavior`); + * Platformer/Physics2/TopDown families get sane default content, unknown + * families get a bare block. Returns the behavior object. + */ +export function addBehavior( + projectData, + { sceneName, objectName, behaviorName, behaviorType } = {} +) { + const where = 'addBehavior'; + assertProjectShape(projectData, where); + if (typeof behaviorName !== 'string' || behaviorName.trim().length === 0) { + throw new Error(`[control-shim] ${where}: behaviorName must be a non-empty string`); + } + if (typeof behaviorType !== 'string' || behaviorType.trim().length === 0) { + throw new Error(`[control-shim] ${where}: behaviorType must be a non-empty string`); + } + let container; + if (sceneName !== undefined && sceneName !== null && String(sceneName).length > 0) { + const scene = requireScene(projectData, sceneName, where); + container = scene.objects || []; + } else { + container = projectData.objects || []; + } + const object = container.find(entry => entry && entry.name === objectName); + if (!object) { + throw new Error(`[control-shim] ${where}: object not found: "${objectName}"`); + } + if (!Array.isArray(object.behaviors)) object.behaviors = []; + const behavior = { + name: behaviorName, + type: behaviorType, + ...defaultBehaviorContent(behaviorType), + }; + const index = object.behaviors.findIndex(entry => entry && entry.name === behaviorName); + if (index === -1) object.behaviors.push(behavior); + else object.behaviors[index] = behavior; + return behavior; +} + +/** + * Append a `BuiltinCommonInstructions::Standard` event to a scene's event + * sheet. `conditions`/`actions` default to `[]`. Returns the event object. + */ +export function addStandardEvent(projectData, { sceneName, conditions, actions } = {}) { + const where = 'addStandardEvent'; + const scene = requireScene(projectData, sceneName, where); + const resolvedConditions = conditions === undefined ? [] : conditions; + const resolvedActions = actions === undefined ? [] : actions; + if (!Array.isArray(resolvedConditions)) { + throw new Error(`[control-shim] ${where}: conditions must be an array`); + } + if (!Array.isArray(resolvedActions)) { + throw new Error(`[control-shim] ${where}: actions must be an array`); + } + if (!Array.isArray(scene.events)) scene.events = []; + const event = { + disabled: false, + folded: false, + type: 'BuiltinCommonInstructions::Standard', + conditions: resolvedConditions, + actions: resolvedActions, + }; + scene.events.push(event); + return event; +} + +const CLI_COMMAND_RE = /^[A-Z][A-Z0-9_]*$/; + +/** + * Format a headless CLI invocation for `electron-app/app/main.js` + * (`parseGDevelopArgs`: positional project path, `--run-command NAME`, + * repeatable `--cmd-args` values consumed by `LocalCliCommandRunner`'s + * `normalizeCommandArgs`). `headless: true` adds `--disable-update-check` + * (the README's CI usage). Returns `{ command, args }` for `spawn`. + */ +export function buildGDevelopCliCommand({ + gdevelopPath, + projectPath, + commandName, + cmdArgs, + headless, +} = {}) { + const where = 'buildGDevelopCliCommand'; + if (typeof gdevelopPath !== 'string' || gdevelopPath.length === 0) { + throw new Error(`[control-shim] ${where}: gdevelopPath is required`); + } + if (typeof projectPath !== 'string' || projectPath.length === 0) { + throw new Error(`[control-shim] ${where}: projectPath is required`); + } + if (typeof commandName !== 'string' || !CLI_COMMAND_RE.test(commandName)) { + throw new Error( + `[control-shim] ${where}: commandName must be UPPER_SNAKE_CASE (got ${JSON.stringify(commandName)})` + ); + } + const args = []; + if (headless) args.push('--disable-update-check'); + args.push('--run-command', commandName); + for (const value of cmdArgs === undefined ? [] : [].concat(cmdArgs)) { + args.push('--cmd-args', String(value)); + } + args.push(projectPath); + return { command: gdevelopPath, args, commandName, projectPath }; +} + +function assertBridgePort(port) { + if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535) { + throw new RangeError(`[control-shim] connectDebuggerBridge: invalid port (${String(port)})`); + } +} + +function computeAccept(key) { + return crypto.createHash('sha1').update(key + WS_GUID).digest('base64'); +} + +function encodeFrame(text) { + const payload = Buffer.from(String(text), 'utf8'); + const header = [0x81]; + if (payload.length < 126) { + header.push(payload.length | 0x80); + } else if (payload.length < 65536) { + header.push(126 | 0x80); + header.push((payload.length >>> 8) & 0xff, payload.length & 0xff); + } else { + header.push(127 | 0x80); + const len = Buffer.alloc(8); + len.writeBigUInt64BE(BigInt(payload.length)); + return Buffer.concat([Buffer.from(header), len, maskPayload(payload)]); + } + return Buffer.concat([Buffer.from(header), maskPayload(payload)]); +} + +function maskPayload(payload) { + const mask = crypto.randomBytes(4); + const masked = Buffer.alloc(payload.length); + for (let i = 0; i < payload.length; i++) masked[i] = payload[i] ^ mask[i % 4]; + return Buffer.concat([mask, masked]); +} + +/** + * Minimal RFC6455 WebSocket client for the preview `DebuggerServer` + * (hot-reload inspection). Built on `node:net` only — no `ws` dependency. + * + * Resolves to `{ send, close }`; rejects fail-closed on handshake mismatch, + * connection refusal, or timeout. Incoming text messages are delivered to + * `onMessage` (parsed as JSON when possible, raw string otherwise). + */ +export function connectDebuggerBridge(port, onMessage, { host, timeoutMs } = {}) { + const targetHost = host || '127.0.0.1'; + const timeout = timeoutMs === undefined ? 5000 : timeoutMs; + return new Promise((resolve, reject) => { + // Fail-closed inside the executor so invalid input rejects the returned + // promise instead of throwing synchronously past the caller. + try { + assertBridgePort(port); + if (typeof onMessage !== 'function') { + throw new TypeError('[control-shim] connectDebuggerBridge: onMessage must be a function'); + } + } catch (err) { + reject(err); + return; + } + let settled = false; + const fail = err => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + socket.destroy(); + } catch {} + reject(err); + }; + + const socket = net.createConnection({ host: targetHost, port }); + const timer = setTimeout(() => { + fail(new Error(`[control-shim] debugger bridge to ${targetHost}:${port} timed out after ${timeout}ms`)); + }, timeout); + // A refused connection must reject, never hang past the timer. + if (typeof timer.unref === 'function') timer.unref(); + + socket.once('error', err => { + fail(new Error(`[control-shim] debugger bridge to ${targetHost}:${port} failed: ${err.message}`)); + }); + + const key = crypto.randomBytes(16).toString('base64'); + let handshake = Buffer.alloc(0); + let handshook = false; + let frameBuffer = Buffer.alloc(0); + let fragments = []; + + const deliver = message => { + try { + onMessage(JSON.parse(message)); + } catch { + onMessage(message); + } + }; + + const pumpFrames = () => { + for (;;) { + if (frameBuffer.length < 2) return; + const fin = (frameBuffer[0] & 0x80) !== 0; + const opcode = frameBuffer[0] & 0x0f; + const masked = (frameBuffer[1] & 0x80) !== 0; + let length = frameBuffer[1] & 0x7f; + let offset = 2; + if (length === 126) { + if (frameBuffer.length < 4) return; + length = frameBuffer.readUInt16BE(2); + offset = 4; + } else if (length === 127) { + if (frameBuffer.length < 10) return; + const high = frameBuffer.readUInt32BE(2); + const low = frameBuffer.readUInt32BE(6); + if (high !== 0 || low > 0x7fffffff) { + fail(new Error('[control-shim] debugger bridge: oversized frame')); + return; + } + length = low; + offset = 10; + } + if (masked) offset += 4; + if (frameBuffer.length < offset + length) return; + let payload = frameBuffer.slice(offset, offset + length); + if (masked) { + const mask = frameBuffer.slice(offset - 4, offset); + const unmasked = Buffer.alloc(payload.length); + for (let i = 0; i < payload.length; i++) unmasked[i] = payload[i] ^ mask[i % 4]; + payload = unmasked; + } + frameBuffer = frameBuffer.slice(offset + length); + if (opcode === 0x0) { + fragments.push(payload); + } else if (opcode === 0x1) { + fragments = [payload]; + } else if (opcode === 0x2) { + fragments = [payload]; + } else if (opcode === 0x8) { + fail(new Error('[control-shim] debugger bridge: server closed the connection')); + return; + } else if (opcode === 0x9) { + socket.write(Buffer.from([0x8a, 0x00])); + continue; + } else if (opcode === 0xa) { + continue; + } else { + continue; + } + if (!fin) continue; + deliver(Buffer.concat(fragments).toString('utf8')); + fragments = []; + } + }; + + socket.on('data', chunk => { + if (!handshook) { + handshake = Buffer.concat([handshake, chunk]); + const end = handshake.indexOf('\r\n\r\n'); + if (end === -1) return; + const head = handshake.slice(0, end).toString('latin1'); + frameBuffer = handshake.slice(end + 4); + handshake = Buffer.alloc(0); + const status = head.split('\r\n')[0] || ''; + const accept = /Sec-WebSocket-Accept:\s*(.*)/i.exec(head)?.[1].trim(); + if (!/^HTTP\/1\.1 101/i.test(status) || accept !== computeAccept(key)) { + fail( + new Error( + `[control-shim] debugger bridge: WebSocket handshake rejected (${status.trim() || 'no status'})` + ) + ); + return; + } + handshook = true; + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ + send(message) { + const text = typeof message === 'string' ? message : JSON.stringify(message); + socket.write(encodeFrame(text)); + }, + close() { + try { + socket.write(Buffer.from([0x88, 0x00])); + } catch {} + socket.end(); + }, + }); + if (frameBuffer.length > 0) pumpFrames(); + return; + } + frameBuffer = Buffer.concat([frameBuffer, chunk]); + pumpFrames(); + }); + + socket.on('connect', () => { + socket.write( + 'GET / HTTP/1.1\r\n' + + `Host: ${targetHost}:${port}\r\n` + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Key: ${key}\r\n` + + 'Sec-WebSocket-Version: 13\r\n\r\n' + ); + }); + }); +} diff --git a/patches/11-studio-runtime-mount.patch b/patches/11-studio-runtime-mount.patch index ae06410..f48d106 100644 --- a/patches/11-studio-runtime-mount.patch +++ b/patches/11-studio-runtime-mount.patch @@ -38,16 +38,21 @@ } from './AiRequestUtils'; import { type EditorFunctionCallResult } from '../EditorFunctions'; import { useStableUpToDateRef } from '../Utils/UseStableUpToDateCallback'; -@@ -77,6 +93,8 @@ +@@ -77,6 +93,13 @@ import UrlStorageProvider from '../ProjectsStorage/UrlStorageProvider'; import { prepareAiUserContent } from './PrepareAiUserContent'; import { AiRequestContext } from './AiRequestContext'; +import { useStudioRuntime } from './Studio/UseStudioRuntime'; ++import { ++ getTerminalStatusForRequest, ++ buildResumeUpdate, ++ clearStalledRequests, ++} from './Studio/UseStudioRuntime'; +import { useStudioNudge } from './Studio/UseStudioNudge'; import { getAiConfigurationPresetsWithAvailability } from './AiConfiguration'; import { setEditorHotReloadNeeded, -@@ -555,6 +573,7 @@ +@@ -555,6 +578,7 @@ requestEditApproval, resolveEditApproval, setIsFetchingSuggestions, @@ -55,7 +60,7 @@ } = React.useContext(AiRequestContext); const { getEditorFunctionCallResults, -@@ -580,13 +599,27 @@ +@@ -580,13 +604,27 @@ const authenticatedUser = React.useContext(AuthenticatedUserContext); const { profile, @@ -84,7 +89,7 @@ const { isRefreshingLimits, refreshLimits } = useRefreshLimits( onRefreshLimits ); -@@ -594,13 +627,13 @@ +@@ -594,13 +632,13 @@ false ); @@ -102,7 +107,7 @@ const aiRequestPriceInCredits = aiRequestPrice ? aiRequestPrice.priceInCredits : null; -@@ -625,12 +658,14 @@ +@@ -625,12 +663,14 @@ if (!newAiRequestOptions) return; console.info('Starting a new AI request...'); @@ -118,7 +123,7 @@ // Read the options and reset them (to avoid launching the same request twice). const { mode, -@@ -643,13 +678,21 @@ +@@ -643,13 +683,21 @@ // Ensure the user has enough credits to pay for the request, or ask them // to buy some more. let payWithCredits = false; @@ -143,7 +148,7 @@ quota, price: aiRequestPrice, availableCredits, -@@ -680,7 +723,7 @@ +@@ -680,7 +728,7 @@ const preparedAiUserContent = await prepareAiUserContent({ getAuthorizationHeader, @@ -152,7 +157,7 @@ simplifiedProjectJson, projectSpecificExtensionsSummaryJson, eventsJson: null, -@@ -689,7 +732,7 @@ +@@ -689,7 +737,7 @@ const aiRequest = await createAiRequest(getAuthorizationHeader, { userRequest: userRequest, attachmentIds, @@ -161,7 +166,7 @@ gameProjectJsonUserRelativeKey: preparedAiUserContent.gameProjectJsonUserRelativeKey, gameProjectJson: preparedAiUserContent.gameProjectJson, -@@ -709,7 +752,6 @@ +@@ -709,7 +757,6 @@ }, }); @@ -169,7 +174,7 @@ setSendingAiRequest(null, false); setIsSendingUserMessage(false); updateAiRequest(aiRequest.id, () => aiRequest); -@@ -720,24 +762,39 @@ +@@ -720,24 +767,39 @@ setSelectedAiRequestId(aiRequest.id); } @@ -226,7 +231,7 @@ } catch (error) { console.error('Error starting a new AI request:', error); setLastSendError(null, error); -@@ -794,12 +851,28 @@ +@@ -794,12 +856,28 @@ createdProject?: ?gdProject, editorFunctionCallResults: Array, |}) => { @@ -257,7 +262,7 @@ if (!aiRequestForMessage) return; if (isSendingAiRequest(aiRequestId)) { -@@ -830,17 +903,19 @@ +@@ -830,17 +908,19 @@ console.info( 'Skipping send for AI request: some function call results are not finished yet.' ); @@ -280,7 +285,7 @@ // Paying with credits is only when a user message is sent (and quota is exhausted). let payWithCredits = false; -@@ -848,13 +923,17 @@ +@@ -848,13 +928,17 @@ hasUserMessage && quota && quota.limitReached && @@ -300,7 +305,7 @@ quota, price: aiRequestPrice, availableCredits, -@@ -865,6 +944,7 @@ +@@ -865,6 +949,7 @@ } } @@ -308,7 +313,7 @@ try { setSendingAiRequest(aiRequestId, true); // Sending takes over the UI: drop any in-flight best-effort -@@ -894,7 +974,7 @@ +@@ -894,7 +979,7 @@ const preparedAiUserContent = await prepareAiUserContent({ getAuthorizationHeader, @@ -317,7 +322,7 @@ simplifiedProjectJson, projectSpecificExtensionsSummaryJson, eventsJson: null, -@@ -926,9 +1006,9 @@ +@@ -926,9 +1011,9 @@ }) : { functionCallOutputs, newlyGivenErrorIds: [] }; @@ -329,7 +334,7 @@ aiRequestId, functionCallOutputs: functionCallOutputsToSend, gameProjectJsonUserRelativeKey: -@@ -961,26 +1041,43 @@ +@@ -961,26 +1046,43 @@ ); setSendingAiRequest(aiRequest.id, false); setIsSendingUserMessage(false); @@ -388,7 +393,7 @@ const aiRequestChatRefCurrent = aiRequestChatRef.current; if (aiRequestChatRefCurrent) { aiRequestChatRefCurrent.resetUserInput(''); -@@ -989,7 +1086,6 @@ +@@ -989,7 +1091,6 @@ } } catch (error) { console.error('Error while sending AI request message:', error); @@ -396,7 +401,7 @@ setLastSendError(aiRequestId, error); setIsSendingUserMessage(false); } -@@ -1016,6 +1112,7 @@ +@@ -1016,6 +1117,7 @@ onOpenExternalLayout(externalLayoutName); }); } @@ -404,7 +409,7 @@ }, [ profile, -@@ -1047,10 +1144,13 @@ +@@ -1047,10 +1149,13 @@ // charges back the usage it refunded when the request failed). const onRetryAfterError = React.useCallback( async () => { @@ -420,7 +425,7 @@ aiRequestId: selectedAiRequestId, }); updateAiRequest(aiRequest.id, () => aiRequest); -@@ -1081,7 +1181,7 @@ +@@ -1081,7 +1186,7 @@ createdProject?: ?gdProject, |} ) => { @@ -429,7 +434,7 @@ aiRequestId, userMessage: '', createdProject: options.createdProject, -@@ -1094,29 +1194,54 @@ +@@ -1094,29 +1199,54 @@ ); /** @@ -488,7 +493,7 @@ } = useProcessFunctionCalls({ project, fileMetadata, -@@ -1124,6 +1249,7 @@ +@@ -1124,6 +1254,7 @@ editorCallbacks, aiRequestsToProcess, onSendEditorFunctionCallResults, @@ -496,7 +501,7 @@ getEditorFunctionCallResults, addEditorFunctionCallResults, onSceneEventsModifiedOutsideEditor, -@@ -1146,6 +1272,33 @@ +@@ -1146,6 +1277,33 @@ suspendAiRequest, requestEditApproval, onSaveProjectAsWithStorageProvider, @@ -530,7 +535,7 @@ }); // Wrap onProcessFunctionCalls to bind the selected AI request for the chat UI. -@@ -1284,11 +1437,11 @@ +@@ -1284,11 +1442,11 @@ // $FlowFixMe[missing-local-annot] freeFormDetails ) => { @@ -544,7 +549,7 @@ aiRequestId, messageIndex, feedback, -@@ -1318,7 +1471,11 @@ +@@ -1318,7 +1476,11 @@ const onStop = React.useCallback( async () => { @@ -557,7 +562,7 @@ if (!getHasWorkInProgress()) return; // Delegates to the provider so the suspend logic lives in a single // place and also works when triggered outside of this editor. -@@ -1442,14 +1599,24 @@ +@@ -1442,14 +1604,24 @@ // Do a full fetch when the tab is opened to ensure the UI starts with // up-to-date server state (e.g. request may have been suspended while @@ -586,7 +591,7 @@ }) ) .then(aiRequest => { -@@ -1703,9 +1870,13 @@ +@@ -1703,9 +1875,13 @@ setForkingState(null); await showAlert({ title: t`Error`, @@ -603,7 +608,58 @@ }); } }, -@@ -1795,7 +1966,9 @@ +@@ -1741,6 +1917,50 @@ + /> + +
++ {(() => { ++ // T05 (#22): terminal status card. A suspended/errored local ++ // request replaces the spinner/disabled composer with its badge, ++ // exact reason, and timestamp. Resume clears the LoopGuard ++ // counters (reset on the next user message) and steers back to ++ // ready; Clear Stalled purges gd-custom-ai-requests. ++ const terminalStatus = ++ selectedAiRequest && ++ getTerminalStatusForRequest(selectedAiRequest); ++ if (!terminalStatus) return null; ++ return ( ++
++
{terminalStatus.badge}
++
{terminalStatus.reason}
++
{terminalStatus.timestamp}
++ ++ ++
++ ); ++ })()} + { if (aiRequest.parentAiRequestId) { @@ -66,7 +74,7 @@ if (launchingCall) { return renderFunctionCallLabel({ functionCall: launchingCall, -@@ -244,6 +268,7 @@ +@@ -244,6 +276,7 @@ editorCallbacks, aiRequestsToProcess, onSendEditorFunctionCallResults, @@ -74,7 +82,7 @@ getEditorFunctionCallResults, addEditorFunctionCallResults, onSceneEventsModifiedOutsideEditor, -@@ -265,6 +290,9 @@ +@@ -265,6 +298,9 @@ suspendAiRequest, requestEditApproval, onSaveProjectAsWithStorageProvider, @@ -84,7 +92,7 @@ }: {| i18n: I18nType, project: ?gdProject, -@@ -280,7 +308,7 @@ +@@ -280,7 +316,7 @@ createdExternalLayoutNames?: Array, createdProject?: ?gdProject, |} @@ -93,7 +101,7 @@ getEditorFunctionCallResults: string => Array | null, addEditorFunctionCallResults: ( string, -@@ -319,6 +347,9 @@ +@@ -319,6 +355,9 @@ onWillInstallExtension: (extensionNames: Array) => void, onExtensionInstalled: (extensionNames: Array) => void, isReadyToProcessFunctionCalls: boolean, @@ -103,7 +111,7 @@ getIsAutoEditEnabled: () => boolean, suspendAiRequest: (aiRequestId: string) => Promise, requestEditApproval: (request: EditApprovalRequest) => Promise, -@@ -331,12 +362,30 @@ +@@ -331,12 +370,30 @@ createdProject?: gdProject, |} ) => Promise, @@ -134,7 +142,7 @@ } => { const { ensureExtensionInstalled } = useEnsureExtensionInstalled({ project, -@@ -424,6 +473,14 @@ +@@ -424,6 +481,14 @@ // `call:` (for a single direct modifying call like generate_events). const approvedEditBatchKeysRef = React.useRef>(new Set()); @@ -149,7 +157,7 @@ // Forget all previously-granted edit approvals so the next modifying call // asks again. Called when the user toggles auto-edit: turning it on then off // again means they want to review the upcoming edits, even within a sub-agent -@@ -440,6 +497,16 @@ +@@ -440,6 +505,16 @@ if (!isReadyToProcessFunctionCalls) return; if (aiRequest.status === 'suspended') return; @@ -166,7 +174,7 @@ const functionCallsToProcess = functionCalls.filter( functionCall => !inFlightFunctionCallIdsRef.current.has( -@@ -460,14 +527,60 @@ +@@ -460,14 +535,60 @@ ); }); @@ -229,7 +237,7 @@ // Gate project-modifying calls behind a user confirmation when auto-edit // is off. Read-only calls (exploration, inspection) always run. The first -@@ -493,10 +606,16 @@ +@@ -493,10 +614,16 @@ const modifyingFunctionCalls = functionCallsToProcess.filter( functionCall => doesFunctionCallModifyProject(functionCall) && @@ -246,7 +254,7 @@ !isCallApproved(functionCall) ); -@@ -554,13 +673,278 @@ +@@ -554,13 +681,409 @@ } } @@ -357,6 +365,128 @@ + } + } + ++ // T04 (#21): no-project self-remedy. With no project open every ++ // project-dependent tool fails deterministically with the ++ // initialize_project remedy instead of a null crash. Checked before ++ // the editor dispatch; initialize_project itself is never blocked. ++ const noProjectHandledCallIds = new Set(); ++ const noProjectResults: Array = []; ++ if (isCustomEndpointEnabled() && !project) { ++ for (const functionCall of functionCallsToProcess) { ++ if (studioDeniedCallIds.has(functionCall.call_id)) continue; ++ if (loopGuardHandledCallIds.has(functionCall.call_id)) continue; ++ const needsProject = ++ typeof isProjectDependentTool === 'function' ++ ? isProjectDependentTool(functionCall.name) ++ : functionCall.name !== 'initialize_project' && ++ functionCall.name !== 'get_game_starter_summary' && ++ functionCall.name !== 'search_docs' && ++ functionCall.name !== 'read_full_docs' && ++ functionCall.name !== 'search_object_asset_store' && ++ functionCall.name !== 'search_resource_store' && ++ functionCall.name !== 'create_or_update_plan' && ++ functionCall.name !== 'spawn_agent'; ++ if (needsProject) { ++ noProjectHandledCallIds.add(functionCall.call_id); ++ const noProjectDenial = { ++ status: 'finished', ++ call_id: functionCall.call_id, ++ success: false, ++ output: { ++ message: `${NO_PROJECT_MESSAGE} (tool: ${functionCall.name})`, ++ noProject: true, ++ }, ++ }; ++ noProjectResults.push(noProjectDenial); ++ addEditorFunctionCallResults(aiRequest.id, [noProjectDenial]); ++ } ++ } ++ } ++ ++ // T03 (#24): offline docs + local plan pre-pass. Both are dead upstream ++ // (EditorFunctions fails them as "handled server-side"), so they are ++ // routed locally with zero network I/O before the editor dispatch. A ++ // docs call fails closed with offline:true; a plan call validates via ++ // PlanStore and folds over the existing plan (preserving agentCallId). ++ const offlineDocsHandledCallIds = new Set(); ++ const offlineDocsResults: Array = []; ++ const localPlanHandledCallIds = new Set(); ++ const localPlanResults: Array = []; ++ if (isCustomEndpointEnabled()) { ++ for (const functionCall of functionCallsToProcess) { ++ if (studioDeniedCallIds.has(functionCall.call_id)) continue; ++ if (loopGuardHandledCallIds.has(functionCall.call_id)) continue; ++ if (noProjectHandledCallIds.has(functionCall.call_id)) continue; ++ const isDocs = ++ functionCall.name === 'search_docs' || ++ functionCall.name === 'read_full_docs' || ++ (typeof isOfflineDocsTool === 'function' && ++ isOfflineDocsTool(functionCall.name)); ++ if (isDocs) { ++ offlineDocsHandledCallIds.add(functionCall.call_id); ++ const docsDenial = { ++ status: 'finished', ++ call_id: functionCall.call_id, ++ success: false, ++ output: { ++ message: `${OFFLINE_DOCS_MESSAGE} (tool: ${functionCall.name})`, ++ offline: true, ++ }, ++ }; ++ offlineDocsResults.push(docsDenial); ++ addEditorFunctionCallResults(aiRequest.id, [docsDenial]); ++ continue; ++ } ++ const isPlan = ++ functionCall.name === 'create_or_update_plan' || ++ (typeof isLocalPlanTool === 'function' && ++ isLocalPlanTool(functionCall.name)); ++ if (isPlan) { ++ localPlanHandledCallIds.add(functionCall.call_id); ++ let planResult; ++ try { ++ const parsedPlanArgs = JSON.parse(functionCall.arguments || '{}'); ++ const validation = validatePlanTasks( ++ parsedPlanArgs ? parsedPlanArgs.tasks : null ++ ); ++ if (!validation.success) { ++ planResult = { ++ status: 'finished', ++ call_id: functionCall.call_id, ++ success: false, ++ output: { message: validation.message }, ++ }; ++ } else { ++ const currentPlan = getLatestActivePlan(aiRequest); ++ const currentTasks = ++ currentPlan && Array.isArray(currentPlan.tasks) ++ ? currentPlan.tasks ++ : []; ++ const merged = ++ currentTasks.length > 0 ++ ? mergePlanTasks(currentTasks, validation.tasks) ++ : validation.tasks; ++ planResult = { ++ status: 'finished', ++ call_id: functionCall.call_id, ++ success: true, ++ output: buildPlanOutput(merged), ++ }; ++ } ++ } catch (error) { ++ planResult = { ++ status: 'finished', ++ call_id: functionCall.call_id, ++ success: false, ++ output: { message: 'Invalid plan arguments: not valid JSON.' }, ++ }; ++ } ++ localPlanResults.push(planResult); ++ addEditorFunctionCallResults(aiRequest.id, [planResult]); ++ } ++ } ++ } ++ + // The local studio's delegation tool is not an editor function: it creates + // a child AI request and stamps the call so the shared loop stops treating + // it as a call for the editor to execute (`getFunctionCallsToProcess` @@ -437,6 +567,9 @@ + }); + } catch (error) { + console.error('Error while spawning a studio sub-agent:', error); ++ // T06 (#23): a child startup failure fails ONLY this tool call. ++ // It never touches the parent guard streak and never suspends or ++ // constrains the parent: the parent stays alive for serial fallback. + spawnResult = { error: 'The sub-agent could not be started.' }; + } + @@ -487,7 +620,10 @@ + functionCall => + !spawnHandledCallIds.has(functionCall.call_id) && + !loopGuardHandledCallIds.has(functionCall.call_id) && -+ !studioDeniedCallIds.has(functionCall.call_id) ++ !studioDeniedCallIds.has(functionCall.call_id) && ++ !offlineDocsHandledCallIds.has(functionCall.call_id) && ++ !localPlanHandledCallIds.has(functionCall.call_id) && ++ !noProjectHandledCallIds.has(functionCall.call_id) + ); + // Mark only the editor calls as "working": a spawn (or a loop-guard hit) + // already has a terminal result, and a permanent "working" entry would @@ -508,6 +644,9 @@ + ...studioDeniedResults, + ...loopGuardResults, + ...spawnFailureResults, ++ ...offlineDocsResults, ++ ...localPlanResults, ++ ...noProjectResults, + ]; + if (outcomeResults.length > 0) { + await onSendEditorFunctionCallResults( @@ -526,7 +665,7 @@ // The "modified outside editor" callbacks each refresh the editor and can // trigger an in-game editor hot reload. Firing them once per function -@@ -669,7 +1053,7 @@ +@@ -669,7 +1192,7 @@ // Explorer sub-agent scripts are read-only: restrict their // `run_script` to non-mutating functions (defense in depth). runScriptReadOnly: isReadOnlyScriptContext, @@ -535,15 +674,25 @@ name: functionCall.name, arguments: functionCall.arguments, call_id: functionCall.call_id, -@@ -761,13 +1145,52 @@ +@@ -761,13 +1284,65 @@ return; } + // Record outcomes for the loop guard's error-storm trip (a real success + // clears it; a failure advances it). ++ // T06 (#23) isolation: ONLY editor-dispatched calls feed the parent ++ // guard. Spawn failures, offline-docs, local-plan, no-project, role ++ // denials, and loop-guard warnings already have terminal results and ++ // must never advance the parent error streak nor constrain it — a ++ // child that fails to start fails its own call; the parent retries ++ // serially instead of suspending. + if (guard) { ++ const editorCallIds = new Set( ++ callsForEditor.map(functionCall => functionCall.call_id) ++ ); + results.forEach(result => { + if (result.status !== 'finished') return; ++ if (!editorCallIds.has(result.call_id)) return; + if (result.success) guard.recordSuccess(); + else guard.recordError(); + }); @@ -579,6 +728,9 @@ + ...studioDeniedResults, + ...loopGuardResults, + ...spawnFailureResults, ++ ...offlineDocsResults, ++ ...localPlanResults, ++ ...noProjectResults, + ...mergedResults, + ], + { @@ -593,7 +745,7 @@ } finally { // Flush the coalesced editor notifications for everything modified in // this batch (one hot reload instead of one per call). In `finally` so -@@ -787,7 +1210,9 @@ +@@ -787,7 +1362,9 @@ } // Release the lock so these calls can be retried if needed @@ -604,7 +756,7 @@ functionCallsToProcess.forEach(functionCall => { inFlightFunctionCallIdsRef.current.delete( `${aiRequest.id}:${functionCall.call_id}` -@@ -824,9 +1249,12 @@ +@@ -824,9 +1401,12 @@ getAssetStoreTagForNewObject, generateEvents, onSendEditorFunctionCallResults, @@ -617,7 +769,7 @@ ] ); -@@ -870,9 +1298,50 @@ +@@ -870,9 +1450,50 @@ [onProcessFunctionCalls, allFunctionCallsToProcess] ); @@ -668,7 +820,7 @@ }; }; -@@ -977,7 +1446,6 @@ +@@ -977,7 +1598,6 @@ editorFunctionCallResultsStorage, isFetchingSuggestions, setIsFetchingSuggestions, @@ -676,7 +828,7 @@ setSelectedAiRequestId, selectedAiRequest, } = React.useContext(AiRequestContext); -@@ -1036,20 +1504,19 @@ +@@ -1036,20 +1656,19 @@ // Then ask for some. if ( !selectedAiRequest || @@ -708,7 +860,7 @@ // Check if there are tools being run. If so, no suggestions at this time. const hasFunctionsCallsToProcess = -@@ -1139,7 +1606,7 @@ +@@ -1139,7 +1758,7 @@ : null; const preparedAiUserContent = await prepareAiUserContent({ getAuthorizationHeader, @@ -717,7 +869,7 @@ simplifiedProjectJson, projectSpecificExtensionsSummaryJson, eventsJson: null, -@@ -1153,7 +1620,7 @@ +@@ -1153,7 +1772,7 @@ const aiRequestWorkingForSuggestions = await getAiRequestSuggestions( getAuthorizationHeader, { @@ -726,7 +878,7 @@ aiRequestId: selectedAiRequest.id, suggestionsType: isLastMessageFunctionCallOutputProjectInitialization ? 'list-with-explanations' -@@ -1525,39 +1992,12 @@ +@@ -1525,39 +2144,12 @@ [profile, setSelectedAiRequestId] ); diff --git a/scripts/apply-patches.mjs b/scripts/apply-patches.mjs index ed76dee..f52fb8f 100644 --- a/scripts/apply-patches.mjs +++ b/scripts/apply-patches.mjs @@ -36,6 +36,7 @@ const LAUNCHER_FILES = [ 'heretek-hook.cjs', 'preload-unlock.cjs', 'unlock-payloads.cjs', + 'debug-logger.cjs', ]; // Tier-2 runtime the glue patches import: Generation.js (07) and // PrepareAiUserContent.js (09) import ../../AI/CustomAIClient; @@ -45,6 +46,7 @@ const LAUNCHER_FILES = [ // Spec files (*.spec.js) are deliberately NOT shipped into the target. const TIER2_FILES = [ 'AI/CustomAIClient.js', + 'AiGeneration/Studio/DebugEvents.js', 'AiGeneration/Studio/EditApprovalLabel.js', 'AiGeneration/Studio/FinalizeSubAgents.js', 'AiGeneration/Studio/LoopGuard.js', diff --git a/scripts/dump-debug-log.mjs b/scripts/dump-debug-log.mjs new file mode 100644 index 0000000..dea63c3 --- /dev/null +++ b/scripts/dump-debug-log.mjs @@ -0,0 +1,105 @@ +// Inspect a T02 client debug log (JSONL from launcher/debug-logger.cjs). +// +// node scripts/dump-debug-log.mjs +// [--event ]... [--level ]... [--request ]... +// [--json] [--limit ] +// +// Filters combine (AND): exact match on event and level; a request id +// matches data.requestId / data.aiRequestId / data.callId / +// data.parentAiRequestId. Default output is one human line per record; +// --json prints the filtered records as a JSON array. Malformed lines are +// skipped (count on stderr). Missing file exits 2; anything else exits 0. +import fs from 'node:fs'; + +const REQUEST_ID_KEYS = [ + 'requestId', + 'aiRequestId', + 'callId', + 'parentAiRequestId', +]; + +export function parseLogText(text) { + const records = []; + let malformed = 0; + for (const line of String(text).split(/\r?\n/)) { + if (line.trim().length === 0) continue; + try { + const record = JSON.parse(line); + if (record && typeof record === 'object') records.push(record); + else malformed++; + } catch (error) { + malformed++; + } + } + return { records, malformed }; +} + +export function recordMatches(record, filters) { + if (filters.events.length > 0 && !filters.events.includes(record.event)) + return false; + if (filters.levels.length > 0 && !filters.levels.includes(record.level)) + return false; + if (filters.requests.length > 0) { + const data = record.data || {}; + const ids = REQUEST_ID_KEYS.map(key => data[key]).filter( + value => typeof value === 'string' + ); + if (!filters.requests.some(wanted => ids.includes(wanted))) return false; + } + return true; +} + +export function filterRecords(records, filters) { + return records.filter(record => recordMatches(record, filters)); +} + +export function formatRecord(record) { + const data = record.data ? JSON.stringify(record.data) : '{}'; + return `[${record.iso || record.ts}] [${record.level}] [${record.source}] ${record.event} ${data}`; +} + +function takeFlag(args, name) { + const values = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === name && i + 1 < args.length) { + values.push(args[i + 1]); + i++; + } else if (args[i].startsWith(name + '=')) { + values.push(args[i].slice(name.length + 1)); + } + } + return values; +} + +const cliArgs = process.argv.slice(2); +// Import-safe (like assert-smoke-verdict.mjs): no positional arg, no run. +const positional = cliArgs.filter(arg => !arg.startsWith('--')); +const logPath = positional[0]; +if (logPath) { + if (!fs.existsSync(logPath)) { + console.error(`[dump-debug-log] log not found: ${logPath}`); + process.exit(2); + } + const { records, malformed } = parseLogText( + fs.readFileSync(logPath, 'utf8') + ); + if (malformed > 0) + console.error(`[dump-debug-log] skipped ${malformed} malformed line(s).`); + const limitValues = takeFlag(cliArgs, '--limit'); + const limit = + limitValues.length > 0 ? Number.parseInt(limitValues[0], 10) : -1; + const filtered = filterRecords(records, { + events: takeFlag(cliArgs, '--event'), + levels: takeFlag(cliArgs, '--level'), + requests: takeFlag(cliArgs, '--request'), + }); + const shown = limit >= 0 ? filtered.slice(0, limit) : filtered; + if (cliArgs.includes('--json')) { + console.log(JSON.stringify(shown, null, 2)); + } else { + for (const record of shown) console.log(formatRecord(record)); + } + console.error( + `[dump-debug-log] ${shown.length}/${records.length} record(s) shown.` + ); +} diff --git a/scripts/inject-launcher.mjs b/scripts/inject-launcher.mjs index c2353e2..ec0d36a 100644 --- a/scripts/inject-launcher.mjs +++ b/scripts/inject-launcher.mjs @@ -20,6 +20,7 @@ const LAUNCHER_FILES = [ 'heretek-hook.cjs', 'preload-unlock.cjs', 'unlock-payloads.cjs', + 'debug-logger.cjs', ]; const ANCHOR = "const electron = require('electron');"; diff --git a/scripts/verify-upstream-rebase.mjs b/scripts/verify-upstream-rebase.mjs new file mode 100644 index 0000000..65c2e94 --- /dev/null +++ b/scripts/verify-upstream-rebase.mjs @@ -0,0 +1,162 @@ +// Rebase drift gate (T12, issues #10 #13): proves the whole patch stack +// still applies cleanly to a pristine upstream tree. +// +// node scripts/verify-upstream-rebase.mjs [--upstream ] +// +// Steps (all real I/O, no mocks): +// 1. Resolve the upstream fixture (UPSTREAM_DIR, --upstream, or +// /review/GDevelop). Refuse a dirty fixture (`git status +// --short` must be empty) and record its HEAD SHA. +// 2. Extract a pristine copy (`git archive HEAD newIDE/app/src +// newIDE/electron-app | tar -x -C `) into a TMPDIR scratch +// dir (os.tmpdir(), never hard-coded /tmp). The fixture is READ-ONLY. +// 3. Run the real driver (`node scripts/apply-patches.mjs --target +// `), which dry-runs every hunk first, applies, then verifies +// unlock markers — plus `--verify-only` as an explicit read-only +// marker-contract second gate. +// 4. Exit 0 on clean. Exit 1 lists every failing patch file (per-patch +// `patch --dry-run` re-probe) and the recorded upstream SHA. +// +// Bare node builtins only. The scratch tree is always removed. + +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(here, '..'); + +const usage = () => + 'Usage: node scripts/verify-upstream-rebase.mjs [--upstream ]\n' + + ' Upstream defaults to $UPSTREAM_DIR or /review/GDevelop.'; + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--upstream') out.upstream = path.resolve(argv[++i]); + else if (a === '--help' || a === '-h') { + console.log(usage()); + process.exit(0); + } else { + console.error(`unknown argument: ${a}\n${usage()}`); + process.exit(2); + } + } + return out; +} + +const git = (args, cwd) => + spawnSync('git', args, { cwd, encoding: 'utf8' }); + +const listPatches = () => + fs + .readdirSync(path.join(ROOT, 'patches')) + .filter(f => f.endsWith('.patch')) + .sort() + .map(f => path.join(ROOT, 'patches', f)); + +// Re-probe each patch file with `patch --dry-run` against the pristine +// tree to name exactly which files fail (the driver aborts on the first). +function failingPatches(scratch) { + const failed = []; + for (const p of listPatches()) { + const r = spawnSync('patch', ['-p1', '-s', '--dry-run', '-i', p], { + cwd: scratch, + encoding: 'utf8', + }); + if (r.status !== 0) failed.push(path.basename(p)); + } + return failed; +} + +const main = () => { + const { upstream: flagUpstream } = parseArgs(process.argv.slice(2)); + const upstream = + flagUpstream || process.env.UPSTREAM_DIR || path.join(ROOT, 'review', 'GDevelop'); + if (!fs.existsSync(path.join(upstream, '.git'))) { + console.error(`[verify-upstream-rebase] not a git checkout: ${upstream}\n${usage()}`); + process.exit(2); + } + const dirty = git(['status', '--short'], upstream); + if (dirty.status !== 0) { + console.error(`[verify-upstream-rebase] cannot inspect fixture: ${dirty.stderr}`); + process.exit(2); + } + if (dirty.stdout.trim().length > 0) { + console.error( + '[verify-upstream-rebase] fixture is dirty — refusing to gate against it:\n' + + dirty.stdout + ); + process.exit(2); + } + const head = git(['rev-parse', 'HEAD'], upstream); + if (head.status !== 0) { + console.error(`[verify-upstream-rebase] cannot read fixture HEAD: ${head.stderr}`); + process.exit(2); + } + const sha = head.stdout.trim(); + console.log(`[verify-upstream-rebase] upstream HEAD: ${sha} (${upstream})`); + + const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'verify-rebase-')); + try { + // Pristine copy straight from git (fixture untouched, read-only). + const archive = spawnSync( + `git -C "${upstream}" archive HEAD newIDE/app/src newIDE/electron-app | tar -x -C "${scratch}"`, + { shell: true, encoding: 'utf8' } + ); + if (archive.status !== 0) { + console.error( + `[verify-upstream-rebase] archive extract failed:\n${archive.stdout}${archive.stderr}` + ); + process.exit(1); + } + if (!fs.existsSync(path.join(scratch, 'newIDE'))) { + console.error('[verify-upstream-rebase] extract produced no newIDE/ tree.'); + process.exit(1); + } + console.log(`[verify-upstream-rebase] pristine copy at ${scratch}`); + + // Gate 1: the real driver (dry-run every hunk, apply, verify markers). + const apply = spawnSync( + process.execPath, + [path.join(ROOT, 'scripts', 'apply-patches.mjs'), '--target', scratch], + { cwd: ROOT, encoding: 'utf8' } + ); + if (apply.status !== 0) { + const failed = failingPatches(scratch); + console.error( + `[verify-upstream-rebase] REBASE NEEDED against ${sha}.\n` + + `Failing patch file(s): ${failed.length > 0 ? failed.join(', ') : '(see driver output)'}\n` + + `--- driver output ---\n${apply.stdout}${apply.stderr}` + ); + process.exit(1); + } + // Gate 2: read-only marker contract on the patched scratch tree. + const verify = spawnSync( + process.execPath, + [ + path.join(ROOT, 'scripts', 'apply-patches.mjs'), + '--verify-only', + '--target', + scratch, + ], + { cwd: ROOT, encoding: 'utf8' } + ); + if (verify.status !== 0) { + console.error( + `[verify-upstream-rebase] marker contract failed after clean apply:\n${verify.stdout}${verify.stderr}` + ); + process.exit(1); + } + console.log( + `[verify-upstream-rebase] CLEAN: all ${listPatches().length} patches apply + verify at ${sha}.` + ); + } finally { + fs.rmSync(scratch, { recursive: true, force: true }); + } +}; + +main(); diff --git a/tests/e2e/gdevelop-opencode-byok-e2e.test.mjs b/tests/e2e/gdevelop-opencode-byok-e2e.test.mjs new file mode 100644 index 0000000..1c4257c --- /dev/null +++ b/tests/e2e/gdevelop-opencode-byok-e2e.test.mjs @@ -0,0 +1,256 @@ +// E2E Test: GDevelop <-> opencode BYOK loop (T11, issues #19 #12). +// +// Spins the REAL packages/opencode-provider daemon on an ephemeral loopback +// port, runs the CustomAIClient request shape against it (POST +// {baseUrl}/chat/completions with OpenAI messages — the exact transport the +// patched CustomAIClient uses via axios to the configured baseUrl), executes +// the returned structured tool_call against a fresh TMPDIR scratch project +// via the REAL control-shim (through packages/opencode-gdevelop tools), sends +// the tool output back, and verifies the generation concludes with the object +// present in game.json read back from disk. +// +// Zero-egress: every request URL is asserted loopback; provider/shim sources +// are asserted free of api.gdevelop.io; a fetch-counting wrapper proves no +// hosted request ever fires (fail-closed). +// +// LIMIT: no React/JSDOM is installed (offline suite), so CustomAIClient.js +// itself (a Flow-typed React module) cannot execute here. This test exercises +// its exact HTTP contract (endpoint, payload shape, tool_calls round-trip) +// against the live provider plus the real disk mutation it would drive. +// Bare node:test + builtins only. Real I/O only (live 127.0.0.1 servers, +// TMPDIR scratch, real patch application targets untouched). + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import http from 'node:http'; + +import { + startServer, + OPENAI_MODEL_ID, +} from '../../packages/opencode-provider/server.mjs'; +import { GDEVELOP_TOOLS } from '../../packages/opencode-gdevelop/tools.mjs'; +import { readGDevelopProject } from '../../packages/unlock-shim/control-shim.mjs'; + +const HOSTED = 'api.gdevelop.io'; + +const postCompletions = (baseUrl, payload) => + fetch(`${baseUrl}/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + +describe('e2e: GDevelop <-> opencode BYOK loop (live provider + scratch project)', () => { + let provider; + let baseUrl; + let port; + let scratchDir; + let projectFile; + let hostedHits = 0; + + before(async () => { + provider = await startServer({ host: '127.0.0.1', port: 0 }); + port = provider.address().port; + assert.ok(port > 0, 'ephemeral loopback port assigned'); + baseUrl = `http://127.0.0.1:${port}/v1`; + // Fail-closed: the provider URL itself must be loopback, never hosted. + const parsed = new URL(baseUrl); + assert.equal(parsed.hostname, '127.0.0.1'); + assert.ok(!baseUrl.includes(HOSTED), 'provider URL must not be hosted'); + + // Fresh scratch project in TMPDIR (os.tmpdir(), never hard-coded /tmp). + // Minimal valid stock project shape inline — review/ fixture READ-ONLY, + // never written into. + scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'byok-e2e-')); + projectFile = path.join(scratchDir, 'game.json'); + fs.writeFileSync( + projectFile, + JSON.stringify( + { + properties: { name: 'ByokE2E', version: '1.0.0' }, + layouts: [ + { + name: 'MainScene', + title: 'MainScene', + associatedEditorLayouts: [], + events: [], + instances: [], + layers: [{ name: 'Base layer', visibility: true, effects: [], cameras: [] }], + objects: [], + variables: [], + }, + ], + objects: [], + }, + null, + 2 + ) + '\n', + 'utf8' + ); + }); + + after(async () => { + if (provider) await provider.close(); + if (scratchDir) fs.rmSync(scratchDir, { recursive: true, force: true }); + }); + + it('provider advertises the local model on loopback (live GET)', async () => { + const res = await fetch(`${baseUrl}/models`); + assert.equal(res.status, 200); + const data = await res.json(); + assert.equal(data.object, 'list'); + assert.ok(data.data.some(m => m.id === OPENAI_MODEL_ID)); + }); + + it('full BYOK loop: tool_call -> disk mutation -> tool result -> concluded generation', async () => { + // 1. CustomAIClient-shaped request: user prompt + explicit function call. + const functionCallMessage = JSON.stringify({ + name: 'create_or_replace_object', + arguments: { + object_name: 'Hero', + object_type: 'Sprite', + scene_name: 'MainScene', + }, + }); + const res = await postCompletions(baseUrl, { + model: OPENAI_MODEL_ID, + messages: [ + { role: 'user', content: 'Create a Hero sprite in MainScene' }, + { role: 'user', content: functionCallMessage }, + ], + stream: false, + }); + assert.equal(res.status, 200); + const data = await res.json(); + assert.equal(data.choices[0].finish_reason, 'tool_calls'); + const calls = data.choices[0].message.tool_calls; + assert.equal(calls.length, 1); + assert.equal(calls[0].type, 'function'); + assert.equal(calls[0].function.name, 'create_or_replace_object'); + const args = JSON.parse(calls[0].function.arguments); + assert.equal(args.object_name, 'Hero'); + assert.equal(args.object_type, 'Sprite'); + assert.equal(args.scene_name, 'MainScene'); + + // 2. Execute the tool call against the scratch project via the real + // control-shim path (opencode-gdevelop tool, real disk write). + const createObject = GDEVELOP_TOOLS.find(t => t.name === 'gdevelop_project_create_object'); + assert.ok(createObject, 'gdevelop_project_create_object tool registered'); + const result = await createObject.execute({ + projectPath: projectFile, + sceneName: args.scene_name, + objectName: args.object_name, + objectType: args.object_type, + }); + assert.equal(result.output.objectName, 'Hero'); + assert.equal(result.output.created, true); + + // 3. Send the tool output back; the generation concludes (finish stop). + const followUp = await postCompletions(baseUrl, { + model: OPENAI_MODEL_ID, + messages: [ + { role: 'user', content: 'Create a Hero sprite in MainScene' }, + { + role: 'assistant', + content: null, + tool_calls: calls, + }, + { role: 'tool', tool_call_id: calls[0].id, content: JSON.stringify(result.output) }, + ], + stream: false, + }); + assert.equal(followUp.status, 200); + const done = await followUp.json(); + assert.equal(done.choices[0].finish_reason, 'stop'); + assert.ok((done.choices[0].message.content || '').length > 0); + + // 4. Read the mutated game.json back from disk: the object exists. + const project = readGDevelopProject(projectFile); + const scene = project.layouts.find(l => l && l.name === 'MainScene'); + assert.ok(scene, 'MainScene survives the loop'); + const hero = (scene.objects || []).find(o => o && o.name === 'Hero'); + assert.ok(hero, 'Hero object present in game.json after the loop'); + assert.equal(hero.type, 'Sprite'); + }); + + it('zero egress: no request ever targets api.gdevelop.io (fail-closed)', async () => { + // Source-level proof: none of the loop's modules reference the hosted API. + const here = path.dirname(new URL(import.meta.url).pathname); + const root = path.resolve(here, '..', '..'); + for (const file of [ + 'packages/opencode-provider/server.mjs', + 'packages/opencode-provider/adapter.mjs', + 'packages/opencode-gdevelop/tools.mjs', + 'packages/unlock-shim/control-shim.mjs', + ]) { + const src = fs.readFileSync(path.join(root, file), 'utf8'); + assert.ok(!src.includes(HOSTED), `${file} must not reference ${HOSTED}`); + } + // Request-level proof: counting wrapper around fetch fails loudly on leak. + const countingFetch = async (url, init) => { + const target = String(url); + assert.ok(!target.includes(HOSTED), `LEAK: BYOK request escaped to ${target}`); + if (target.includes(HOSTED)) hostedHits += 1; + const u = new URL(target); + assert.ok( + u.hostname === '127.0.0.1' || u.hostname === 'localhost', + `non-loopback egress: ${target}` + ); + return fetch(url, init); + }; + const res = await countingFetch(`${baseUrl}/models`); + assert.equal(res.status, 200); + const chat = await countingFetch(`${baseUrl}/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: OPENAI_MODEL_ID, + messages: [{ role: 'user', content: 'egress probe' }], + }), + }); + assert.equal(chat.status, 200); + assert.equal(hostedHits, 0, 'zero hosted hits across the loop'); + }); + + it('negative control: unknown function fails closed with 400, never mutates disk', async () => { + const before = fs.readFileSync(projectFile, 'utf8'); + const res = await postCompletions(baseUrl, { + model: OPENAI_MODEL_ID, + messages: [ + { role: 'user', content: JSON.stringify({ name: 'delete_everything', arguments: {} }) }, + ], + stream: false, + }); + assert.equal(res.status, 400); + assert.match((await res.json()).error.message, /unknown function/i); + assert.equal(fs.readFileSync(projectFile, 'utf8'), before, 'disk untouched on rejection'); + }); + + it('proves no egress via a local counter server (real loopback listener)', async () => { + // A second live loopback server stands in for any "outside" observer: + // the loop completes with all traffic on the provider port only. + const seen = []; + const counter = http.createServer((req, res) => { + seen.push(req.url); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{}'); + }); + await new Promise(resolve => counter.listen(0, '127.0.0.1', resolve)); + const counterPort = counter.address().port; + try { + assert.notEqual(counterPort, port, 'counter is a distinct listener'); + const res = await postCompletions(baseUrl, { + model: OPENAI_MODEL_ID, + messages: [{ role: 'user', content: 'counter check' }], + stream: false, + }); + assert.equal(res.status, 200); + assert.equal(seen.length, 0, 'no loop traffic reached the outside observer'); + } finally { + await new Promise(resolve => counter.close(resolve)); + } + }); +}); diff --git a/tests/integration/ai-byok-flow.test.mjs b/tests/integration/ai-byok-flow.test.mjs index 9179200..b0c741b 100644 --- a/tests/integration/ai-byok-flow.test.mjs +++ b/tests/integration/ai-byok-flow.test.mjs @@ -343,3 +343,445 @@ describe('integration: BYOK tool schemas, zero-egress routing, patch-level leak } }); }); + +describe('integration: T03 tool manifest pruning (no dead tool calls)', () => { + it('search_docs/read_full_docs carry the offline suffix and fail closed with no network', async () => { + const { readFileSync } = await import('node:fs'); + const { fileURLToPath } = await import('node:url'); + const { dirname, resolve } = await import('node:path'); + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + const src = readFileSync( + resolve(root, 'packages/ai-studio/AI/CustomAIClient.js'), + 'utf8' + ); + const suffix = + '[Offline in local BYOK mode: rely on internal GDevelop knowledge and on-project verification]'; + assert.ok( + src.includes(suffix), + 'offline suffix missing from CustomAIClient tool descriptions' + ); + // Both docs tools must carry it. + const searchAt = src.indexOf("name: 'search_docs'"); + const readAt = src.indexOf("name: 'read_full_docs'"); + assert.ok(searchAt !== -1 && readAt !== -1, 'docs tools missing from schema'); + assert.ok( + src.slice(searchAt, searchAt + 800).includes(suffix), + 'search_docs description lacks offline suffix' + ); + assert.ok( + src.slice(readAt, readAt + 800).includes(suffix), + 'read_full_docs description lacks offline suffix' + ); + // Fail-closed helper: synchronous, offline:true, exact message, no fetch. + assert.ok( + src.includes('OFFLINE_DOCS_MESSAGE'), + 'OFFLINE_DOCS_MESSAGE export missing' + ); + assert.ok( + src.includes('Documentation backend is offline in local mode'), + 'offline docs message text missing' + ); + assert.ok( + src.includes('buildOfflineDocsResult'), + 'buildOfflineDocsResult helper missing' + ); + assert.ok( + src.includes('isOfflineDocsTool'), + 'isOfflineDocsTool helper missing' + ); + const helperAt = src.indexOf('export const buildOfflineDocsResult'); + assert.ok(helperAt !== -1, 'buildOfflineDocsResult export missing'); + const helperSlice = src.slice(helperAt, helperAt + 2000); + assert.ok(helperSlice.includes('offline: true'), 'docs result must set offline:true'); + assert.ok(helperSlice.includes('success: false'), 'docs result must be success:false'); + assert.ok(!helperSlice.includes('fetch('), 'offline docs result must not fetch (no network lag)'); + assert.ok(!helperSlice.includes('axios'), 'offline docs result must not use axios'); + // Zero egress: the offline message path never mentions the hosted API. + assert.ok( + !src.includes('api.gdevelop.io'), + 'BYOK client must not hardcode the hosted API' + ); + }); + + it('create_or_update_plan routes to PlanStore locally, never to upstream EditorFunctions', async () => { + const { readFileSync } = await import('node:fs'); + const { fileURLToPath } = await import('node:url'); + const { dirname, resolve } = await import('node:path'); + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + const client = readFileSync( + resolve(root, 'packages/ai-studio/AI/CustomAIClient.js'), + 'utf8' + ); + const patch14 = readFileSync(resolve(root, 'patches/14-ai-utils.patch'), 'utf8'); + // Schema keeps the tool so the manager keeps planning. + assert.ok( + client.includes("name: 'create_or_update_plan'"), + 'plan tool missing from GDEVELOP_OPENAI_TOOLS' + ); + assert.ok( + client.includes('isLocalPlanTool'), + 'isLocalPlanTool marker missing (T03 plan routing)' + ); + // Patch 14 pre-pass: local PlanStore routing before the editor dispatch. + for (const marker of [ + 'offlineDocsHandledCallIds', + 'localPlanHandledCallIds', + 'validatePlanTasks', + 'buildPlanOutput', + 'mergePlanTasks', + 'OFFLINE_DOCS_MESSAGE', + ]) { + assert.ok(patch14.includes(marker), `patch 14 missing T03 marker: ${marker}`); + } + // Both pre-pass results are excluded from the editor and surfaced to the model. + assert.ok( + patch14.includes('!offlineDocsHandledCallIds.has(functionCall.call_id)'), + 'docs calls must be excluded from editor dispatch' + ); + assert.ok( + patch14.includes('!localPlanHandledCallIds.has(functionCall.call_id)'), + 'plan calls must be excluded from editor dispatch' + ); + assert.ok( + patch14.includes('...offlineDocsResults,'), + 'docs results must be sent to the model' + ); + assert.ok( + patch14.includes('...localPlanResults,'), + 'plan results must be sent to the model' + ); + // No network in the T03 pre-pass: docs fail closed synchronously. + const preAt = patch14.indexOf('T03 (#24)'); + assert.ok(preAt !== -1, 'T03 pre-pass comment missing in patch 14'); + const preSlice = patch14.slice(preAt, preAt + 6000); + assert.ok(!preSlice.includes('fetch('), 'T03 pre-pass must not fetch'); + }); + + it('every GDEVELOP_OPENAI_TOOLS entry is dispatchable locally (no dead tools)', async () => { + const { readFileSync } = await import('node:fs'); + const { fileURLToPath } = await import('node:url'); + const { dirname, resolve } = await import('node:path'); + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + const src = readFileSync( + resolve(root, 'packages/ai-studio/AI/CustomAIClient.js'), + 'utf8' + ); + const patch14 = readFileSync(resolve(root, 'patches/14-ai-utils.patch'), 'utf8'); + const toolRe = + /\{\s*type:\s*'function',\s*function:\s*\{\s*name:\s*'([^']+)',\s*description:\s*('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")/g; + const names = []; + let m; + while ((m = toolRe.exec(src)) !== null) names.push(m[1]); + assert.equal(names.length, 39, `expected 39 tools, found ${names.length}`); + // The three historically dead tools now have explicit local handling. + for (const dead of ['search_docs', 'read_full_docs', 'create_or_update_plan']) { + assert.ok(names.includes(dead), `tool missing from schema: ${dead}`); + assert.ok( + patch14.includes(dead), + `patch 14 has no local route for historically dead tool: ${dead}` + ); + } + // Backend-only tools stay in schema for hosted turns but are withheld locally. + assert.ok( + src.includes('BACKEND_ONLY_TOOL_NAMES'), + 'backend-only filter missing' + ); + }); +}); + +describe('integration: T04 no-project self-remedy (null project)', () => { + it('project-dependent tools fail closed with the initialize_project remedy', async () => { + const { readFileSync } = await import('node:fs'); + const { fileURLToPath } = await import('node:url'); + const { dirname, resolve } = await import('node:path'); + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + const src = readFileSync( + resolve(root, 'packages/ai-studio/AI/CustomAIClient.js'), + 'utf8' + ); + // Deterministic self-remedying error text (exact match on the contract). + assert.ok(src.includes('NO_PROJECT_MESSAGE'), 'NO_PROJECT_MESSAGE export missing'); + assert.ok( + src.includes("No project is currently open. You must call initialize_project(template_slug:'empty')"), + 'no-project remedy text missing or reworded' + ); + assert.ok(src.includes('isProjectDependentTool'), 'isProjectDependentTool helper missing'); + assert.ok(src.includes('buildNoProjectResult'), 'buildNoProjectResult helper missing'); + assert.ok(src.includes('PROJECT_FREE_TOOL_NAMES'), 'PROJECT_FREE_TOOL_NAMES missing'); + const helperAt = src.indexOf('export const buildNoProjectResult'); + assert.ok(helperAt !== -1, 'buildNoProjectResult export missing'); + const helperSlice = src.slice(helperAt, helperAt + 2000); + assert.ok(helperSlice.includes('success: false'), 'no-project result must be success:false'); + assert.ok(helperSlice.includes('noProject: true'), 'no-project result must set noProject:true'); + assert.ok(!helperSlice.includes('fetch('), 'no-project result must not fetch'); + // initialize_project itself is never project-dependent (it IS the remedy). + const freeAt = src.indexOf('PROJECT_FREE_TOOL_NAMES'); + const freeSlice = src.slice(freeAt, freeAt + 1200); + assert.ok(freeSlice.includes("'initialize_project'"), 'initialize_project must be project-free'); + // System prompt states the remedy when no project JSON is present. + assert.ok( + src.includes("No project is currently open. You must call initialize_project(template_slug:'empty') to create one before using project tools."), + 'buildSystemPrompt must state the no-project remedy' + ); + }); + + it('manager role leads with initialize_project when no project is open', async () => { + const { readFileSync } = await import('node:fs'); + const { fileURLToPath } = await import('node:url'); + const { dirname, resolve } = await import('node:path'); + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + const roles = readFileSync( + resolve(root, 'packages/ai-studio/AiGeneration/Studio/Roles.js'), + 'utf8' + ); + const managerAt = roles.indexOf('manager: {'); + assert.ok(managerAt !== -1, 'manager role missing'); + const managerSlice = roles.slice(managerAt, managerAt + 4000); + // initialize_project prominent: first in the manager tool manifest. + const initAt = managerSlice.indexOf("'initialize_project'"); + assert.ok(initAt !== -1, 'initialize_project missing from manager allowedToolNames'); + const planAt = managerSlice.indexOf("'create_or_update_plan'"); + assert.ok(planAt !== -1, 'manager plan tool missing'); + assert.ok( + initAt < planAt, + 'initialize_project must precede create_or_update_plan in the manager manifest (prominent)' + ); + // First-action rule in the manager prompt. + assert.ok( + managerSlice.includes('your first action must be initialize_project'), + 'manager prompt must order initialize_project first when no project is open' + ); + }); + + it('patch 14 routes null-project calls to the self-remedy before the editor', async () => { + const { readFileSync } = await import('node:fs'); + const { fileURLToPath } = await import('node:url'); + const { dirname, resolve } = await import('node:path'); + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + const patch14 = readFileSync(resolve(root, 'patches/14-ai-utils.patch'), 'utf8'); + for (const marker of [ + 'noProjectHandledCallIds', + 'noProjectResults', + 'isProjectDependentTool', + 'NO_PROJECT_MESSAGE', + ]) { + assert.ok(patch14.includes(marker), `patch 14 missing T04 marker: ${marker}`); + } + assert.ok( + patch14.includes('isCustomEndpointEnabled() && !project'), + 'no-project pre-pass must gate on !project under BYOK' + ); + assert.ok( + patch14.includes('!noProjectHandledCallIds.has(functionCall.call_id)'), + 'no-project calls must be excluded from the editor dispatch' + ); + assert.ok( + patch14.includes('...noProjectResults,'), + 'no-project results must be sent to the model' + ); + const preAt = patch14.indexOf('T04 (#21)'); + assert.ok(preAt !== -1, 'T04 pre-pass comment missing'); + const preSlice = patch14.slice(preAt, preAt + 4000); + assert.ok(!preSlice.includes('fetch('), 'no-project pre-pass must not fetch'); + }); +}); + +describe('integration: T05 suspended UI and resume (terminal status card)', () => { + it('UseStudioRuntime exposes the terminal status, resume, and clear-stalled helpers', async () => { + const { readFileSync } = await import('node:fs'); + const { fileURLToPath } = await import('node:url'); + const { dirname, resolve } = await import('node:path'); + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + const src = readFileSync( + resolve(root, 'packages/ai-studio/AiGeneration/Studio/UseStudioRuntime.js'), + 'utf8' + ); + for (const marker of [ + 'getTerminalStatusForRequest', + 'isTerminalRequestStatus', + 'buildResumeUpdate', + 'buildResumeSteerMessage', + 'clearStalledRequests', + 'STALLED_REQUESTS_STORAGE_KEY', + ]) { + assert.ok(src.includes(marker), `UseStudioRuntime missing T05 helper: ${marker}`); + } + // Badge contract: Suspended vs Stopped by Loop Guard. + assert.ok(src.includes("'Suspended'"), 'Suspended badge missing'); + assert.ok(src.includes("'Stopped by Loop Guard'"), 'Loop Guard badge missing'); + // Exact reason + timestamp surface (returned as { badge, reason, timestamp }). + assert.ok(src.includes('reason'), 'terminal reason not surfaced'); + assert.ok(src.includes('timestamp'), 'terminal timestamp not surfaced'); + // Resume clears back to ready with a steer prompt (LoopGuard resets on next user message). + const resumeAt = src.indexOf('export const buildResumeUpdate'); + assert.ok(resumeAt !== -1, 'buildResumeUpdate export missing'); + const resumeSlice = src.slice(resumeAt, resumeAt + 2000); + assert.ok(resumeSlice.includes("status: 'ready'"), 'resume must reset status to ready'); + assert.ok(resumeSlice.includes('error: null'), 'resume must clear the error'); + assert.ok(resumeSlice.includes('buildResumeSteerMessage'), 'resume must attach the steer prompt'); + // Clear Stalled purges exactly gd-custom-ai-requests. + assert.ok( + src.includes("gd-custom-ai-requests"), + 'Clear Stalled must purge gd-custom-ai-requests' + ); + const clearAt = src.indexOf('export const clearStalledRequests'); + const clearSlice = src.slice(clearAt, clearAt + 1200); + assert.ok(clearSlice.includes('localStorage.removeItem'), 'clear must remove the storage key'); + assert.ok(!clearSlice.includes('fetch('), 'clear must not fetch'); + }); + + it('patch 11 mounts the terminal card with Resume/Retry and Clear Stalled', async () => { + const { readFileSync } = await import('node:fs'); + const { fileURLToPath } = await import('node:url'); + const { dirname, resolve } = await import('node:path'); + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + const patch11 = readFileSync(resolve(root, 'patches/11-studio-runtime-mount.patch'), 'utf8'); + for (const marker of [ + 'getTerminalStatusForRequest', + 'buildResumeUpdate', + 'clearStalledRequests', + 'Resume/Retry', + 'Clear Stalled', + 'T05 (#22)', + ]) { + assert.ok(patch11.includes(marker), `patch 11 missing T05 marker: ${marker}`); + } + // Terminal gating on suspended/error, replacing the spinner path. + assert.ok( + patch11.includes('getTerminalStatusForRequest(selectedAiRequest)'), + 'card must gate on the selected request status' + ); + // Anti-revert: patch 11 must not delete upstream-new symbols. + assert.ok( + !/^-[^-].*attachmentIds/m.test(patch11), + 'patch 11 deletes upstream-new symbol attachmentIds' + ); + }); + + it('patch 11 dry-runs clean on a pristine upstream copy (real I/O)', async () => { + const { spawnSync } = await import('node:child_process'); + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const root = path.dirname( + path.dirname(path.dirname(fileURLToPath(import.meta.url))) + ); + const upstream = + process.env.UPSTREAM_DIR || path.join(root, 'review', 'GDevelop'); + const rel = 'newIDE/app/src/AiGeneration/AskAiEditorContainer.js'; + assert.ok( + fs.existsSync(path.join(upstream, rel)), + `missing fixture ${rel} (set UPSTREAM_DIR)` + ); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 't05-p11-')); + try { + const dest = path.join(dir, rel); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(path.join(upstream, rel), dest); + const r = spawnSync( + 'patch', + ['-p1', '-s', '--dry-run', '-i', path.join(root, 'patches/11-studio-runtime-mount.patch')], + { cwd: dir, encoding: 'utf8' } + ); + assert.equal(r.status, 0, `patch 11 dry-run failed:\n${r.stdout}\n${r.stderr}`); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('integration: T06 storm guard isolation (child failure never suspends parent)', () => { + it('LoopGuard is single-request with repeat-without-progress isolation', async () => { + const { readFileSync } = await import('node:fs'); + const { fileURLToPath } = await import('node:url'); + const { dirname, resolve } = await import('node:path'); + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + const src = readFileSync( + resolve(root, 'packages/ai-studio/AiGeneration/Studio/LoopGuard.js'), + 'utf8' + ); + assert.ok(src.includes('LOOP_GUARD_SCOPE'), 'LOOP_GUARD_SCOPE marker missing'); + assert.ok(src.includes("'single-request'"), 'guard scope must be single-request'); + assert.ok(src.includes('maxTurnsWithoutProgress'), 'repeat-without-progress trip missing'); + assert.ok( + src.includes("child") || src.includes('parent'), + 'isolation note must name child/parent separation' + ); + }); + + it('child spawn startup failure fails only its tool call (success:false, no parent suspend)', async () => { + const { readFileSync } = await import('node:fs'); + const { fileURLToPath } = await import('node:url'); + const { dirname, resolve } = await import('node:path'); + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + const patch14 = readFileSync(resolve(root, 'patches/14-ai-utils.patch'), 'utf8'); + // Failure shape: failed tool call the model sees. + assert.ok( + patch14.includes('The sub-agent could not be started.'), + 'spawn startup failure message missing' + ); + // Isolation markers. + for (const marker of ['T06 (#23)', 'editorCallIds', 'spawnFailureResults']) { + assert.ok(patch14.includes(marker), `patch 14 missing T06 marker: ${marker}`); + } + // Parent guard feeds ONLY editor-dispatched calls — never spawn/local failures. + assert.ok( + patch14.includes('if (!editorCallIds.has(result.call_id)) return;'), + 'parent guard must skip non-editor (child/local) results' + ); + // The spawn failure path never suspends or constrains the parent. + const spawnAt = patch14.indexOf('The sub-agent could not be started.'); + assert.ok(spawnAt !== -1, 'spawn failure site missing'); + const spawnSlice = patch14.slice(Math.max(0, spawnAt - 2000), spawnAt + 2000); + assert.ok(!spawnSlice.includes('suspendAiRequest'), 'spawn failure must not suspend the parent'); + assert.ok(!spawnSlice.includes("status: 'error'"), 'spawn failure must not mark the parent error'); + assert.ok(!spawnSlice.includes('guard.recordError'), 'spawn failure must not touch the parent streak'); + // Parent stays alive: the failure is recorded AND sent as a normal tool error. + assert.ok( + patch14.includes('spawnFailureResults.push(failure)'), + 'spawn failure must be pushed as a tool result' + ); + assert.ok( + patch14.includes('...spawnFailureResults,'), + 'spawn failures must be sent to the model for serial fallback' + ); + }); + + it('patch 14 still dry-runs clean with isolation (real I/O, anti-revert intact)', async () => { + const { spawnSync } = await import('node:child_process'); + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const root = path.dirname( + path.dirname(path.dirname(fileURLToPath(import.meta.url))) + ); + const upstream = + process.env.UPSTREAM_DIR || path.join(root, 'review', 'GDevelop'); + const rel = 'newIDE/app/src/AiGeneration/Utils.js'; + assert.ok( + fs.existsSync(path.join(upstream, rel)), + `missing fixture ${rel} (set UPSTREAM_DIR)` + ); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 't06-p14-')); + try { + const dest = path.join(dir, rel); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(path.join(upstream, rel), dest); + const r = spawnSync( + 'patch', + ['-p1', '-s', '--dry-run', '-i', path.join(root, 'patches/14-ai-utils.patch')], + { cwd: dir, encoding: 'utf8' } + ); + assert.equal(r.status, 0, `patch 14 dry-run failed:\n${r.stdout}\n${r.stderr}`); + const patchText = fs.readFileSync(path.join(root, 'patches/14-ai-utils.patch'), 'utf8'); + assert.ok( + !/^-[^-].*onSaveProjectAsWithStorageProvider/m.test(patchText), + 'patch 14 deletes upstream-new symbol onSaveProjectAsWithStorageProvider' + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/integration/opencode-plugin-agent.test.mjs b/tests/integration/opencode-plugin-agent.test.mjs new file mode 100644 index 0000000..8917227 --- /dev/null +++ b/tests/integration/opencode-plugin-agent.test.mjs @@ -0,0 +1,210 @@ +// Integration Test: gdevelop-architect agent persona (T10, issue #17). +// +// Drives the real packages/opencode-gdevelop/ plugin setup through an +// in-process ctx double whose agent editor mirrors the host upsert +// semantics (review/opencode/packages/core/src/agent.ts: update() creates +// Info.default(id) when the id is absent), then confirms +// ctx.agent.get("gdevelop-architect") returns a valid config with real +// permission lists. Tool executors are NOT re-exercised here (covered by +// opencode-plugin-tools.test.mjs); this file asserts registration, +// persona content, permissions, packaging docs, and idempotency. +// +// Bare node:test + builtins only. No skipped tests. + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { definition as pluginDefinition } from '../../packages/opencode-gdevelop/index.mjs'; +import { + ARCHITECT_ID, + GDEVELOP_ARCHITECT, + registerGDevelopAgents, +} from '../../packages/opencode-gdevelop/agents.mjs'; +import { TOOL_NAMES } from '../../packages/opencode-gdevelop/tools.mjs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, '..', '..'); + +// Host-faithful agent editor double: update() upserts like core agent.ts. +const createAgentEditorDouble = (seed = []) => { + const agents = new Map(seed.map(a => [a.id, { ...a }])); + return { + agents, + list: () => [...agents.values()], + get: id => agents.get(id), + default: () => {}, + update: (id, fn) => { + const current = agents.get(id) || { id }; + if (!agents.has(id)) agents.set(id, current); + fn(current); + current.id = id; + }, + remove: id => { + agents.delete(id); + }, + }; +}; + +const createCtxDouble = (seedAgents = []) => { + const agentEditor = createAgentEditorDouble(seedAgents); + const tools = new Map(); + return { + agentEditor, + agent: { + transform: async fn => { + fn(agentEditor); + return { dispose: async () => {} }; + }, + reload: async () => {}, + get: async id => agentEditor.get(id), + list: async () => agentEditor.list(), + }, + tool: { + transform: async fn => { + fn({ + list: () => [...tools.values()], + get: id => tools.get(id), + namespace: () => {}, + add: tool => { + tools.set(tool.name, { ...tool, id: tool.name }); + }, + update: () => {}, + remove: id => { + tools.delete(id); + }, + }); + return { dispose: async () => {} }; + }, + reload: async () => {}, + }, + }; +}; + +describe('integration: gdevelop-architect registration (real setup)', () => { + let ctx; + beforeEach(async () => { + ctx = createCtxDouble(); + await pluginDefinition.setup(ctx); + }); + + it('registers the architect via ctx.agent.transform (upsert, id gdevelop-architect)', async () => { + assert.equal(ARCHITECT_ID, 'gdevelop-architect'); + const agent = await ctx.agent.get('gdevelop-architect'); + assert.ok(agent, 'architect present after setup'); + assert.equal(agent.id, 'gdevelop-architect'); + }); + + it('ctx.agent.get returns a valid config: subagent mode, named, described', async () => { + const agent = await ctx.agent.get('gdevelop-architect'); + assert.equal(agent.mode, 'subagent'); + assert.ok(typeof agent.name === 'string' && agent.name.length > 0); + assert.ok(typeof agent.description === 'string' && agent.description.length > 0); + assert.deepEqual( + { id: agent.id, mode: agent.mode }, + { id: GDEVELOP_ARCHITECT.id, mode: GDEVELOP_ARCHITECT.mode } + ); + }); + + it('system prompt covers GDevelop 5 architecture, GDJS lifecycle, events, assets', async () => { + const agent = await ctx.agent.get('gdevelop-architect'); + assert.ok(typeof agent.system === 'string' && agent.system.length > 200); + for (const topic of [ + /GDJS/i, + /lifecycle|game loop|scene stack/i, + /conditions/i, + /actions/i, + /sub-events/i, + /behavior/i, + /asset|resources/i, + ]) { + assert.match(agent.system, topic, `system prompt covers ${topic}`); + } + }); + + it('permission list allows all gdevelop_* tools plus file read/write (real lists)', async () => { + const agent = await ctx.agent.get('gdevelop-architect'); + assert.ok(Array.isArray(agent.permissions) && agent.permissions.length > 0); + for (const rule of agent.permissions) { + assert.ok(typeof rule.action === 'string'); + assert.ok(typeof rule.resource === 'string'); + assert.match(rule.effect, /^(allow|ask|deny)$/); + } + const allowed = new Set( + agent.permissions.filter(r => r.effect === 'allow').map(r => r.action) + ); + for (const tool of TOOL_NAMES) { + assert.ok(allowed.has(tool), `architect may call ${tool}`); + } + assert.ok(allowed.has('read'), 'architect may read files'); + assert.ok(allowed.has('write'), 'architect may write files'); + }); + + it('setup is idempotent and never clobbers sibling agents', async () => { + ctx.agentEditor.set ?? null; + const before = await ctx.agent.get('gdevelop-architect'); + await pluginDefinition.setup(ctx); + const after = await ctx.agent.get('gdevelop-architect'); + assert.deepEqual(after, before, 'second setup converges to the same config'); + // Sibling agent registered outside the plugin survives a re-setup. + ctx.agentEditor.update('build', a => { + a.mode = 'primary'; + a.name = 'Build'; + }); + await pluginDefinition.setup(ctx); + assert.equal((await ctx.agent.get('build')).name, 'Build'); + assert.equal((await ctx.agent.get('gdevelop-architect')).mode, 'subagent'); + }); + + it('registerGDevelopAgents is exported standalone and matches setup output', async () => { + const solo = createCtxDouble(); + await registerGDevelopAgents(solo); + assert.deepEqual( + await solo.agent.get('gdevelop-architect'), + await ctx.agent.get('gdevelop-architect') + ); + }); +}); + +describe('integration: gdevelop plugin docs + packaging (T10)', () => { + it('README documents tools, agent, and offline use', () => { + const readme = fs.readFileSync( + path.join(root, 'packages/opencode-gdevelop/README.md'), + 'utf8' + ); + for (const tool of TOOL_NAMES) { + assert.ok(readme.includes(tool), `README documents ${tool}`); + } + assert.ok(readme.includes('gdevelop-architect'), 'README documents the agent'); + assert.ok(/offline|zero-egress|zero egress/i.test(readme), 'README states offline scope'); + }); + + it('agent sources stay zero-dep and zero-egress', () => { + for (const file of ['agents.mjs', 'index.mjs']) { + const src = fs.readFileSync( + path.join(root, 'packages/opencode-gdevelop', file), + 'utf8' + ); + assert.ok(!src.includes('api.gdevelop.io'), `${file}: zero egress`); + } + const pkg = JSON.parse( + fs.readFileSync( + path.join(root, 'packages/opencode-gdevelop/package.json'), + 'utf8' + ) + ); + assert.equal(pkg.name, '@heretek-ai/opencode-gdevelop'); + assert.ok(!pkg.dependencies && !pkg.devDependencies, 'still zero deps'); + }); + + it('uses os.tmpdir() scratch semantics (no hardcoded /tmp)', () => { + const src = fs.readFileSync( + path.join(root, 'tests/integration/opencode-plugin-agent.test.mjs'), + 'utf8' + ); + assert.ok(!src.match(/['"]\/tmp\//), 'no hardcoded /tmp paths'); + }); +}); diff --git a/tests/integration/opencode-plugin-tools.test.mjs b/tests/integration/opencode-plugin-tools.test.mjs new file mode 100644 index 0000000..90a78a5 --- /dev/null +++ b/tests/integration/opencode-plugin-tools.test.mjs @@ -0,0 +1,450 @@ +// Integration Test: opencode GDevelop plugin tools (T09, issue #17). +// +// Imports the real packages/opencode-gdevelop/ plugin (index.mjs + +// tools.mjs, which delegate every project mutation to +// packages/unlock-shim/control-shim.mjs — no reimplemented mutation logic) +// and drives it through an in-process ctx double whose tool editor is a real +// registry (Map-backed add/list/get) and whose tool executors perform REAL +// file mutations on scratch game.json projects under os.tmpdir(). +// No mocks of the code under test; the only double is the host ctx object +// itself (the @opencode/plugin host is not running in this repo). +// +// Bare node:test + builtins only. No skipped tests. + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import pluginDefault, { + definition as pluginDefinition, +} from '../../packages/opencode-gdevelop/index.mjs'; +import { + GDEVELOP_TOOLS, + TOOL_NAMES, +} from '../../packages/opencode-gdevelop/tools.mjs'; +import { readGDevelopProject } from '../../packages/unlock-shim/control-shim.mjs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, '..', '..'); + +// --------------------------------------------------------------------------- +// In-process host doubles (real registry behavior, real executor dispatch). +// --------------------------------------------------------------------------- + +const createToolEditorDouble = () => { + const tools = new Map(); + return { + list: () => [...tools.values()], + get: id => tools.get(id), + namespace: () => {}, + add: tool => { + assert.ok(tool && typeof tool.name === 'string', 'tool needs a name'); + assert.equal(typeof tool.execute, 'function', `${tool.name} needs execute`); + tools.set(tool.name, { ...tool, id: tool.name }); + }, + update: (id, fn) => { + const current = tools.get(id); + if (current) fn(current); + }, + remove: id => { + tools.delete(id); + }, + }; +}; + +const createCtxDouble = () => { + const editor = createToolEditorDouble(); + const agents = new Map(); + return { + editor, + tool: { + transform: async fn => { + fn(editor); + return { dispose: async () => {} }; + }, + reload: async () => {}, + list: async () => editor.list(), + }, + agent: { + transform: async fn => { + fn({ + list: () => [...agents.values()], + get: id => agents.get(id), + default: () => {}, + update: (id, fn2) => { + const current = agents.get(id) || { id }; + agents.set(id, current); + fn2(current); + current.id = id; + }, + remove: id => { + agents.delete(id); + }, + }); + return { dispose: async () => {} }; + }, + }, + }; +}; + +// --------------------------------------------------------------------------- +// Scratch projects (real I/O under os.tmpdir(), never /tmp hardcoded). +// --------------------------------------------------------------------------- + +let scratchDirs = []; +const mkScratch = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gdevelop-plugin-')); + scratchDirs.push(dir); + return dir; +}; +afterEach(() => { + for (const dir of scratchDirs) fs.rmSync(dir, { recursive: true, force: true }); + scratchDirs = []; +}); + +const minimalProjectFile = (dir, name = 'PluginGame') => { + const file = path.join(dir, 'game.json'); + fs.writeFileSync( + file, + JSON.stringify( + { properties: { name, version: '1.0.0' }, layouts: [], objects: [] }, + null, + 2 + ) + '\n', + 'utf8' + ); + return file; +}; + +const runTool = async (editor, name, input) => { + const tool = editor.get(name); + assert.ok(tool, `tool registered: ${name}`); + return tool.execute(input, { + sessionID: 'sess-test', + agent: 'build', + messageID: 'msg-1', + id: 'call-1', + signal: AbortSignal.timeout(10000), + progress: async () => {}, + }); +}; + +describe('integration: gdevelop plugin registration (real setup)', () => { + it('exports Plugin.define({id:"gdevelop",setup}) via the host API shape', async () => { + const plugin = pluginDefault || pluginDefinition; + assert.equal(plugin.id, 'gdevelop'); + assert.equal(typeof plugin.setup, 'function'); + assert.deepEqual(pluginDefinition.id, 'gdevelop'); + }); + + it('setup registers all six gdevelop_* tools via ctx.tool.transform', async () => { + const ctx = createCtxDouble(); + let transformCalls = 0; + const counting = { + ...ctx, + tool: { + ...ctx.tool, + transform: async fn => { + transformCalls += 1; + return ctx.tool.transform(fn); + }, + }, + }; + await pluginDefinition.setup(counting); + assert.equal(transformCalls, 1, 'exactly one ctx.tool.transform call'); + const names = ctx.editor.list().map(t => t.name).sort(); + assert.deepEqual(names, [...TOOL_NAMES].sort()); + assert.deepEqual(GDEVELOP_TOOLS.map(t => t.name).sort(), [...TOOL_NAMES].sort()); + for (const tool of ctx.editor.list()) { + assert.ok(tool.description.length > 0, `${tool.name} has a description`); + assert.ok(tool.input && typeof tool.input === 'object', `${tool.name} has an input schema`); + } + }); + + it('tool input schemas are plain JSON-schema objects (zero-dep host compatible)', () => { + for (const tool of GDEVELOP_TOOLS) { + assert.equal(tool.input.type, 'object', `${tool.name} input is an object schema`); + assert.ok(tool.input.properties && typeof tool.input.properties === 'object'); + } + }); +}); + +describe('integration: gdevelop plugin tools mutate real projects', () => { + let ctx; + beforeEach(async () => { + ctx = createCtxDouble(); + await pluginDefinition.setup(ctx); + }); + + it('create_scene → create_object → add_behavior → add_events round-trip on disk', async () => { + const dir = mkScratch(); + const projectPath = minimalProjectFile(dir); + + const sceneRes = await runTool(ctx.editor, 'gdevelop_project_create_scene', { + projectPath, + sceneName: 'Level1', + }); + assert.ok(sceneRes.output.created); + assert.equal(sceneRes.output.sceneName, 'Level1'); + + const objRes = await runTool(ctx.editor, 'gdevelop_project_create_object', { + projectPath, + sceneName: 'Level1', + objectName: 'Hero', + objectType: 'Sprite', + }); + assert.ok(objRes.output.created); + + const behRes = await runTool(ctx.editor, 'gdevelop_project_add_behavior', { + projectPath, + sceneName: 'Level1', + objectName: 'Hero', + behaviorName: 'Platformer', + behaviorType: 'PlatformBehavior::PlatformerObjectBehavior', + }); + assert.equal(behRes.output.behaviorName, 'Platformer'); + + const evtRes = await runTool(ctx.editor, 'gdevelop_project_add_events', { + projectPath, + sceneName: 'Level1', + conditions: [], + actions: [], + }); + assert.equal(evtRes.output.eventType, 'BuiltinCommonInstructions::Standard'); + + // Ground truth re-read from disk (not via tool output). + const onDisk = readGDevelopProject(projectPath); + const scene = onDisk.layouts.find(l => l.name === 'Level1'); + assert.ok(scene, 'scene persisted'); + const hero = scene.objects.find(o => o.name === 'Hero'); + assert.equal(hero.type, 'Sprite'); + assert.ok(hero.behaviors.some(b => b.name === 'Platformer')); + assert.equal(scene.events.length, 1); + }); + + it('project_read inspects scenes/objects/behaviors/variables from disk', async () => { + const dir = mkScratch(); + const projectPath = minimalProjectFile(dir); + await runTool(ctx.editor, 'gdevelop_project_create_scene', { + projectPath, + sceneName: 'Menu', + }); + await runTool(ctx.editor, 'gdevelop_project_create_object', { + projectPath, + sceneName: 'Menu', + objectName: 'Title', + objectType: 'TextObject::Text', + }); + const res = await runTool(ctx.editor, 'gdevelop_project_read', { projectPath }); + assert.equal(res.output.projectName, 'PluginGame'); + const menu = res.output.scenes.find(s => s.name === 'Menu'); + assert.ok(menu, 'Menu scene reported'); + assert.deepEqual(menu.objects, ['Title']); + assert.ok(typeof res.content === 'string' && res.content.includes('Menu')); + }); + + it('create_object supports Text/PanelSprite + 3D types', async () => { + const dir = mkScratch(); + const projectPath = minimalProjectFile(dir); + await runTool(ctx.editor, 'gdevelop_project_create_scene', { + projectPath, + sceneName: 'Stage', + }); + for (const [objectName, objectType] of [ + ['Label', 'TextObject::Text'], + ['Panel', 'PanelSpriteObject::PanelSprite'], + ['Cube', 'Scene3D::Box3DObject'], + ]) { + const res = await runTool(ctx.editor, 'gdevelop_project_create_object', { + projectPath, + sceneName: 'Stage', + objectName, + objectType, + }); + assert.ok(res.output.created, `${objectType} created`); + } + const onDisk = readGDevelopProject(projectPath); + const stage = onDisk.layouts.find(l => l.name === 'Stage'); + assert.deepEqual( + stage.objects.map(o => o.type).sort(), + ['PanelSpriteObject::PanelSprite', 'Scene3D::Box3DObject', 'TextObject::Text'].sort() + ); + }); + + it('cli_run formats a headless dispatch via buildGDevelopCliCommand', async () => { + const res = await runTool(ctx.editor, 'gdevelop_cli_run', { + gdevelopPath: '/opt/gdevelop/gdevelop', + projectPath: '/games/demo/game.json', + commandName: 'EXPORT_ANDROID', + cmdArgs: ['--debug'], + headless: true, + }); + assert.equal(res.output.command, '/opt/gdevelop/gdevelop'); + assert.ok(res.output.args.includes('--run-command')); + assert.ok(res.output.args.includes('EXPORT_ANDROID')); + assert.ok(res.output.args.includes('--disable-update-check')); + assert.ok(res.output.args.includes('/games/demo/game.json')); + }); +}); + +describe('integration: gdevelop plugin tools fail closed (adversarial)', () => { + let ctx; + beforeEach(async () => { + ctx = createCtxDouble(); + await pluginDefinition.setup(ctx); + }); + + it('missing project files reject with the path named', async () => { + const dir = mkScratch(); + const missing = path.join(dir, 'nope.json'); + await assert.rejects( + runTool(ctx.editor, 'gdevelop_project_read', { projectPath: missing }), + /not found|ENOENT/i + ); + await assert.rejects( + runTool(ctx.editor, 'gdevelop_project_create_scene', { + projectPath: missing, + sceneName: 'X', + }), + /not found|ENOENT/i + ); + }); + + it('blank scene/object names reject', async () => { + const dir = mkScratch(); + const projectPath = minimalProjectFile(dir); + await assert.rejects( + runTool(ctx.editor, 'gdevelop_project_create_scene', { + projectPath, + sceneName: ' ', + }), + /sceneName|non-empty/i + ); + await runTool(ctx.editor, 'gdevelop_project_create_scene', { + projectPath, + sceneName: 'S1', + }); + await assert.rejects( + runTool(ctx.editor, 'gdevelop_project_create_object', { + projectPath, + sceneName: 'S1', + objectName: '', + objectType: 'Sprite', + }), + /objectName|non-empty/i + ); + await assert.rejects( + runTool(ctx.editor, 'gdevelop_project_create_scene', { + projectPath, + sceneName: 'S1', + }), + /already exists/i + ); + }); + + it('unknown scene for object/behavior/event writes rejects', async () => { + const dir = mkScratch(); + const projectPath = minimalProjectFile(dir); + await assert.rejects( + runTool(ctx.editor, 'gdevelop_project_create_object', { + projectPath, + sceneName: 'Ghost', + objectName: 'O', + objectType: 'Sprite', + }), + /scene not found/i + ); + await assert.rejects( + runTool(ctx.editor, 'gdevelop_project_add_behavior', { + projectPath, + sceneName: 'Ghost', + objectName: 'O', + behaviorName: 'B', + behaviorType: 'PlatformBehavior::PlatformerObjectBehavior', + }), + /scene not found|object not found/i + ); + await assert.rejects( + runTool(ctx.editor, 'gdevelop_project_add_events', { + projectPath, + sceneName: 'Ghost', + }), + /scene not found/i + ); + }); + + it('malformed events (non-array conditions/actions) reject', async () => { + const dir = mkScratch(); + const projectPath = minimalProjectFile(dir); + await runTool(ctx.editor, 'gdevelop_project_create_scene', { + projectPath, + sceneName: 'S1', + }); + await assert.rejects( + runTool(ctx.editor, 'gdevelop_project_add_events', { + projectPath, + sceneName: 'S1', + conditions: 'always', + }), + /conditions must be an array/i + ); + await assert.rejects( + runTool(ctx.editor, 'gdevelop_project_add_events', { + projectPath, + sceneName: 'S1', + actions: { do: 'x' }, + }), + /actions must be an array/i + ); + }); + + it('CLI dispatch with bad command names/paths rejects', async () => { + await assert.rejects( + runTool(ctx.editor, 'gdevelop_cli_run', { + gdevelopPath: '/opt/gdevelop/gdevelop', + projectPath: '/games/demo/game.json', + commandName: 'export-android', + }), + /UPPER_SNAKE_CASE/i + ); + await assert.rejects( + runTool(ctx.editor, 'gdevelop_cli_run', { + gdevelopPath: '', + projectPath: '/games/demo/game.json', + commandName: 'EXPORT_ANDROID', + }), + /gdevelopPath is required/i + ); + }); +}); + +describe('integration: gdevelop plugin packaging (zero-dep, zero-egress)', () => { + it('package name uses the @heretek-ai scope and declares zero deps', () => { + const pkg = JSON.parse( + fs.readFileSync(path.join(root, 'packages/opencode-gdevelop/package.json'), 'utf8') + ); + assert.equal(pkg.name, '@heretek-ai/opencode-gdevelop'); + assert.ok(!pkg.dependencies, 'no runtime dependencies allowed'); + assert.ok(!pkg.devDependencies, 'no devDependencies allowed'); + assert.ok(!pkg.name.startsWith('@gdevelop'), 'never the upstream-owned scope'); + }); + + it('plugin sources never touch the hosted API and import the control shim', () => { + for (const file of ['index.mjs', 'tools.mjs']) { + const src = fs.readFileSync( + path.join(root, 'packages/opencode-gdevelop', file), + 'utf8' + ); + assert.ok(!src.includes('api.gdevelop.io'), `${file}: zero egress`); + } + const toolsSrc = fs.readFileSync( + path.join(root, 'packages/opencode-gdevelop/tools.mjs'), + 'utf8' + ); + assert.match(toolsSrc, /control-shim\.mjs/, 'tools delegate to the control shim'); + assert.ok(!toolsSrc.includes('writeFileSync'), 'no reimplemented project mutation'); + }); +}); diff --git a/tests/integration/opencode-provider-server.test.mjs b/tests/integration/opencode-provider-server.test.mjs new file mode 100644 index 0000000..291d6dc --- /dev/null +++ b/tests/integration/opencode-provider-server.test.mjs @@ -0,0 +1,394 @@ +// Integration Test: OpenAI-compatible provider daemon (T08, issue #18). +// +// Boots the real packages/opencode-provider/ HTTP daemon on an ephemeral +// loopback port and exercises it with REAL http requests: model listing, +// CORS preflight, non-streaming completions + usage, SSE streaming chunks + +// data:[DONE], function-call → tool_calls mapping for the 39 editor +// functions, malformed-payload rejection, and zero egress to api.gdevelop.io. +// +// Bare node:test + builtins only. No mocks of the code under test. + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { + startServer, + EDITOR_TOOLS, + OPENAI_MODEL_ID, +} from '../../packages/opencode-provider/server.mjs'; +import { + EDITOR_FUNCTION_NAMES, + openAiMessagesToSession, + mapFunctionCallToToolCall, + parseFunctionCallMessage, +} from '../../packages/opencode-provider/adapter.mjs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, '..', '..'); + +const postJson = (port, body, { raw, headers } = {}) => + new Promise((resolve, reject) => { + const payload = typeof raw === 'string' ? raw : JSON.stringify(body); + const req = http.request( + { + host: '127.0.0.1', + port, + path: '/v1/chat/completions', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + ...headers, + }, + }, + res => { + let data = ''; + res.on('data', c => (data += c)); + res.on('end', () => + resolve({ status: res.statusCode, headers: res.headers, body: data }) + ); + } + ); + req.on('error', reject); + req.end(payload); + }); + +describe('integration: opencode provider daemon (real HTTP)', () => { + let server; + let port; + + before(async () => { + server = await startServer({ host: '127.0.0.1', port: 0 }); + port = server.address().port; + assert.ok(port > 0, 'ephemeral port assigned'); + }); + + after(async () => { + await server.close(); + }); + + it('GET /v1/models lists the local model', async () => { + const res = await fetch(`http://127.0.0.1:${port}/v1/models`); + assert.equal(res.status, 200); + assert.equal(res.headers.get('access-control-allow-origin'), '*'); + const data = await res.json(); + assert.equal(data.object, 'list'); + assert.ok( + data.data.some(m => m.id === OPENAI_MODEL_ID), + 'local model id advertised' + ); + }); + + it('CORS preflight OPTIONS answers 204 permissively', async () => { + const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'OPTIONS', + }); + assert.equal(res.status, 204); + assert.equal(res.headers.get('access-control-allow-origin'), '*'); + assert.match(res.headers.get('access-control-allow-methods') || '', /POST/); + }); + + it('POST stream:false returns a standard completion with usage', async () => { + const { status, body } = await postJson(port, { + model: OPENAI_MODEL_ID, + messages: [{ role: 'user', content: 'Hello local bridge' }], + stream: false, + }); + assert.equal(status, 200); + const data = JSON.parse(body); + assert.equal(data.object, 'chat.completion'); + assert.equal(data.choices.length, 1); + assert.equal(data.choices[0].message.role, 'assistant'); + assert.ok(data.choices[0].message.content.length > 0); + assert.ok(data.usage.total_tokens >= data.usage.prompt_tokens); + assert.ok(data.usage.completion_tokens > 0); + }); + + it('POST stream:true emits SSE chunk deltas ending in data:[DONE]', async () => { + const { status, headers, body } = await postJson(port, { + model: OPENAI_MODEL_ID, + messages: [{ role: 'user', content: 'Stream me' }], + stream: true, + }); + assert.equal(status, 200); + assert.match(headers['content-type'] || '', /text\/event-stream/); + assert.ok(body.includes('data: '), 'SSE data frames present'); + const frames = body + .split('\n\n') + .map(f => f.trim()) + .filter(Boolean); + assert.ok(frames.length >= 2, 'at least one delta plus DONE'); + assert.equal(frames[frames.length - 1], 'data: [DONE]'); + const first = JSON.parse(frames[0].replace(/^data: /, '')); + assert.equal(first.choices[0].delta.role, 'assistant'); + const contentFrames = frames + .slice(0, -1) + .map(f => JSON.parse(f.replace(/^data: /, ''))) + .filter(c => typeof c.choices[0].delta.content === 'string'); + assert.ok(contentFrames.length > 0, 'deltas carry content'); + const reassembled = frames + .slice(0, -1) + .map(f => JSON.parse(f.replace(/^data: /, ''))) + .map(c => c.choices[0].delta.content || '') + .join(''); + assert.ok(reassembled.length > 0, 'deltas reassemble to content'); + }); + + it('maps a function-call message to a tool_calls payload (39 editor fns)', async () => { + const { status, body } = await postJson(port, { + model: OPENAI_MODEL_ID, + messages: [ + { role: 'user', content: 'Build the first level' }, + { + role: 'user', + content: JSON.stringify({ + name: 'create_scene', + arguments: { scene_name: 'Level1' }, + }), + }, + ], + tools: EDITOR_TOOLS.map(t => ({ + type: 'function', + function: { name: t.function.name, description: t.function.description }, + })), + stream: false, + }); + assert.equal(status, 200); + const data = JSON.parse(body); + assert.equal(data.choices[0].finish_reason, 'tool_calls'); + const calls = data.choices[0].message.tool_calls; + assert.equal(calls.length, 1); + assert.equal(calls[0].type, 'function'); + assert.equal(calls[0].function.name, 'create_scene'); + assert.deepEqual(JSON.parse(calls[0].function.arguments), { + scene_name: 'Level1', + }); + assert.match(calls[0].id, /^call_/); + }); + + it('streams tool_calls as SSE deltas for function-call messages', async () => { + const { status, headers, body } = await postJson(port, { + model: OPENAI_MODEL_ID, + messages: [ + { + role: 'user', + content: JSON.stringify({ + name: 'add_behavior', + arguments: { + object_name: 'Hero', + behavior_name: 'Platformer', + behavior_type: 'PlatformBehavior::PlatformerObjectBehavior', + }, + }), + }, + ], + stream: true, + }); + assert.equal(status, 200); + assert.match(headers['content-type'] || '', /text\/event-stream/); + assert.ok(body.includes('tool_calls'), 'streamed deltas carry tool_calls'); + assert.ok(body.trimEnd().endsWith('data: [DONE]')); + }); + + it('rejects malformed OpenAI payloads with 400 JSON errors', async () => { + // Not JSON. + const bad = await postJson(port, null, { raw: '{oops' }); + assert.equal(bad.status, 400); + assert.equal(JSON.parse(bad.body).error.type, 'invalid_request_error'); + // Missing messages. + const missing = await postJson(port, { model: OPENAI_MODEL_ID }); + assert.equal(missing.status, 400); + // Messages not an array. + const wrongType = await postJson(port, { + model: OPENAI_MODEL_ID, + messages: 'hello', + }); + assert.equal(wrongType.status, 400); + // Unknown function name in a function-call message. + const unknownFn = await postJson(port, { + model: OPENAI_MODEL_ID, + messages: [ + { role: 'user', content: JSON.stringify({ name: 'delete_everything', arguments: {} }) }, + ], + }); + assert.equal(unknownFn.status, 400); + assert.match(JSON.parse(unknownFn.body).error.message, /unknown function/i); + // Malformed tool definition. + const badTool = await postJson(port, { + model: OPENAI_MODEL_ID, + messages: [{ role: 'user', content: 'hi' }], + tools: [{ type: 'function' }], + }); + assert.equal(badTool.status, 400); + }); + + it('answers 404 JSON on unknown routes and 405 on wrong methods', async () => { + const missing = await fetch(`http://127.0.0.1:${port}/v1/does-not-exist`); + assert.equal(missing.status, 404); + assert.equal((await missing.json()).error.code, 'not_found'); + const wrongMethod = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`); + assert.equal(wrongMethod.status, 405); + }); + + it('survives an SSE client abort mid-stream (truncation)', async () => { + await new Promise((resolve, reject) => { + const payload = JSON.stringify({ + model: OPENAI_MODEL_ID, + messages: [{ role: 'user', content: 'x'.repeat(4000) }], + stream: true, + }); + const req = http.request( + { + host: '127.0.0.1', + port, + path: '/v1/chat/completions', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, + res => { + res.on('data', () => req.destroy()); + res.on('close', resolve); + res.on('error', () => resolve()); + } + ); + req.on('error', () => resolve()); + req.end(payload); + setTimeout(resolve, 2000); + }); + // Daemon still serves afterwards. + const res = await fetch(`http://127.0.0.1:${port}/v1/models`); + assert.equal(res.status, 200); + }); +}); + +describe('integration: provider adapter translation (pure, real schemas)', () => { + it('all 39 EDITOR_TOOLS match the upstream CustomAIClient registry', () => { + const src = fs.readFileSync( + path.join(root, 'packages/ai-studio/AI/CustomAIClient.js'), + 'utf8' + ); + const re = + /\{\s*type:\s*'function',\s*function:\s*\{\s*name:\s*'([^']+)'/g; + const upstream = []; + let m; + while ((m = re.exec(src)) !== null) upstream.push(m[1]); + assert.equal(upstream.length, 39); + const shipped = EDITOR_TOOLS.map(t => t.function.name); + assert.deepEqual([...shipped].sort(), [...upstream].sort()); + assert.deepEqual([...EDITOR_FUNCTION_NAMES].sort(), [...upstream].sort()); + for (const tool of EDITOR_TOOLS) { + assert.equal(tool.type, 'function'); + assert.match(tool.function.name, /^[a-z][a-z0-9_]*$/); + assert.ok(tool.function.description.length > 0); + assert.equal(tool.function.parameters.type, 'object'); + } + }); + + it('openAiMessagesToSession maps roles, tool_calls and tool results', () => { + const session = openAiMessagesToSession( + [ + { role: 'system', content: 'You are a builder.' }, + { role: 'user', content: 'Make a hero' }, + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + name: 'create_scene', + arguments: '{"scene_name":"L1"}', + }, + }, + ], + }, + { role: 'tool', tool_call_id: 'call_1', content: '{"ok":true}' }, + ], + { sessionId: 'sess-1' } + ); + assert.equal(session.id, 'sess-1'); + assert.match(session.prompt, /Make a hero/); + const step = session.steps.find(s => s.type === 'tool_call'); + assert.equal(step.name, 'create_scene'); + assert.deepEqual(step.input, { scene_name: 'L1' }); + const result = session.steps.find(s => s.type === 'tool_result'); + assert.equal(result.toolCallId, 'call_1'); + }); + + it('mapFunctionCallToToolCall validates against the 39 and stringifies args', () => { + const call = mapFunctionCallToToolCall({ + name: 'create_scene', + args: { scene_name: 'L1' }, + }); + assert.equal(call.type, 'function'); + assert.equal(call.function.name, 'create_scene'); + assert.equal(JSON.parse(call.function.arguments).scene_name, 'L1'); + assert.throws( + () => mapFunctionCallToToolCall({ name: 'nope_fn', args: {} }), + /unknown function/i + ); + assert.equal(parseFunctionCallMessage('just prose'), null); + assert.deepEqual(parseFunctionCallMessage('{"name":"create_scene","arguments":{}}'), { + name: 'create_scene', + args: {}, + }); + }); +}); + +describe('integration: provider zero-egress + packaging', () => { + it('daemon sources never reference the hosted API and use no external deps', () => { + for (const file of ['server.mjs', 'adapter.mjs', 'cli.mjs']) { + const src = fs.readFileSync( + path.join(root, 'packages/opencode-provider', file), + 'utf8' + ); + assert.ok( + !src.includes('api.gdevelop.io'), + `${file} must not reference the hosted API` + ); + } + const pkg = JSON.parse( + fs.readFileSync( + path.join(root, 'packages/opencode-provider/package.json'), + 'utf8' + ) + ); + assert.ok(!pkg.dependencies, 'no runtime dependencies allowed'); + assert.ok(!pkg.devDependencies, 'no devDependencies allowed'); + }); + + it('cli.mjs boots the daemon on an ephemeral port (real spawn)', async () => { + const cli = path.join(root, 'packages/opencode-provider/cli.mjs'); + const child = spawn(process.execPath, [cli, '--port', '0', '--host', '127.0.0.1'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let out = ''; + child.stdout.on('data', c => (out += c)); + try { + const port = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('cli boot timeout')), 8000); + const scan = () => { + const found = /listening on .*?:(\d+)/.exec(out); + if (found) { + clearTimeout(timer); + resolve(Number(found[1])); + } else setTimeout(scan, 50); + }; + child.on('error', reject); + scan(); + }); + const res = await fetch(`http://127.0.0.1:${port}/v1/models`); + assert.equal(res.status, 200); + } finally { + child.kill('SIGTERM'); + await new Promise(resolve => child.on('exit', resolve)); + } + }); +}); diff --git a/tests/launcher/debug-logger.test.cjs b/tests/launcher/debug-logger.test.cjs new file mode 100644 index 0000000..2fd3115 --- /dev/null +++ b/tests/launcher/debug-logger.test.cjs @@ -0,0 +1,368 @@ +// T02 client debug logging (issue: debug-logger subsystem): file creation, +// JSONL schema, argv/env activation, graceful fallback on invalid paths, +// zero-dependency source, renderer logger contract, hook integration, and +// the dump-debug-log CLI — all with real I/O on TMPDIR scratch, no mocks +// of the code under test. +const { describe, it, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const vm = require('node:vm'); +const { spawnSync } = require('node:child_process'); +const debugLogger = require('../../launcher/debug-logger.cjs'); + +const ROOT = path.resolve(__dirname, '..', '..'); +const scratchDirs = []; +function mkScratch() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'debug-logger-test-')); + scratchDirs.push(dir); + return dir; +} +afterEach(() => { + while (scratchDirs.length) { + fs.rmSync(scratchDirs.pop(), { recursive: true, force: true }); + } + delete process.env.HERETEK_DEBUG_LOG; +}); + +describe('debug-logger: activation', () => { + it('resolves --heretek-debug-log from argv first', () => { + const p = debugLogger.resolveDebugLogPath({ + argv: ['node', 'app', '--heretek-debug-log', '/tmp/x.jsonl'], + env: {}, + }); + assert.equal(p, '/tmp/x.jsonl'); + }); + + it('falls back to HERETEK_DEBUG_LOG env', () => { + const p = debugLogger.resolveDebugLogPath({ + argv: ['node', 'app'], + env: { HERETEK_DEBUG_LOG: '/tmp/y.jsonl' }, + }); + assert.equal(p, '/tmp/y.jsonl'); + }); + + it('is inactive when neither flag nor env is set', () => { + assert.equal( + debugLogger.resolveDebugLogPath({ argv: ['node', 'app'], env: {} }), + null + ); + }); + + it('treats a flag with no path as inactive, not a crash', () => { + assert.equal( + debugLogger.resolveDebugLogPath({ + argv: ['node', 'app', '--heretek-debug-log'], + env: {}, + }), + null + ); + }); +}); + +describe('debug-logger: file appender', () => { + it('creates the file and appends one JSON object per line (schema)', () => { + const file = path.join(mkScratch(), 'app.jsonl'); + const log = debugLogger.createDebugLogger({ + logPath: file, + source: 'main', + }); + assert.equal(log.enabled, true); + assert.ok(fs.existsSync(file), 'log file created eagerly'); + const rec = log.log('info', 'hook-installed', { wrapped: true }); + assert.equal(rec.level, 'info'); + assert.equal(rec.event, 'hook-installed'); + const lines = fs.readFileSync(file, 'utf8').trim().split('\n'); + assert.equal(lines.length, 1); + const parsed = JSON.parse(lines[0]); + assert.equal(typeof parsed.ts, 'number'); + assert.equal(typeof parsed.iso, 'string'); + assert.ok(!Number.isNaN(Date.parse(parsed.iso)), 'iso parses'); + assert.equal(parsed.source, 'main'); + assert.equal(parsed.level, 'info'); + assert.equal(parsed.event, 'hook-installed'); + assert.deepEqual(parsed.data, { wrapped: true }); + }); + + it('level helpers write their own level', () => { + const file = path.join(mkScratch(), 'levels.jsonl'); + const log = debugLogger.createDebugLogger({ logPath: file }); + log.debug('d', {}); + log.info('i', {}); + log.warn('w', {}); + log.error('e', { bad: true }); + const levels = fs + .readFileSync(file, 'utf8') + .trim() + .split('\n') + .map(line => JSON.parse(line).level); + assert.deepEqual(levels, ['debug', 'info', 'warn', 'error']); + }); + + it('falls back gracefully on an invalid path (no throw, no-op writes)', () => { + const file = path.join(mkScratch(), 'no-such-dir', 'x.jsonl'); + const log = debugLogger.createDebugLogger({ logPath: file }); + assert.equal(log.enabled, false); + assert.ok(log.openError, 'surfaces the cause'); + assert.equal(log.log('info', 'lost-event', {}), null); + assert.equal(log.warn('also-lost', {}), null); + assert.ok(!fs.existsSync(file)); + }); + + it('falls back gracefully when the path is a directory', () => { + const dir = mkScratch(); + const log = debugLogger.createDebugLogger({ logPath: dir }); + assert.equal(log.enabled, false); + assert.equal(log.log('info', 'lost', {}), null); + }); + + it('heavy sync logging stays parseable (100 rapid writes)', () => { + const file = path.join(mkScratch(), 'heavy.jsonl'); + const log = debugLogger.createDebugLogger({ logPath: file }); + for (let i = 0; i < 100; i++) log.info('tick', { i }); + const lines = fs.readFileSync(file, 'utf8').trim().split('\n'); + assert.equal(lines.length, 100); + for (const line of lines) { + const rec = JSON.parse(line); + assert.equal(rec.event, 'tick'); + assert.equal(typeof rec.data.i, 'number'); + } + }); + + it('depends only on node builtins (zero npm deps)', () => { + const src = fs.readFileSync( + path.join(ROOT, 'launcher', 'debug-logger.cjs'), + 'utf8' + ); + const required = [...src.matchAll(/require\(['"]([^'"]+)['"]\)/g)].map( + m => m[1] + ); + for (const name of required) { + assert.ok( + ['fs', 'path', 'os'].includes(name) || + name.startsWith('node:') || + name.startsWith('./'), + `non-builtin require: ${name}` + ); + } + assert.ok(!src.includes('node_modules')); + }); +}); + +describe('debug-logger: hook integration', () => { + function makeFakeElectron() { + const listeners = {}; + const webRequests = []; + class FakeWindow { + constructor(options) { + this.options = options; + } + } + return { + electron: { + app: { on: (event, cb) => (listeners[event] = cb) }, + session: { + defaultSession: { + webRequest: { + onBeforeRequest: (filter, cb) => + webRequests.push({ filter, cb }), + }, + }, + }, + BrowserWindow: FakeWindow, + }, + listeners, + webRequests, + }; + } + + it('logs install + window creation, and flags hosted api attempts', () => { + const hook = require('../../launcher/heretek-hook.cjs'); + const file = path.join(mkScratch(), 'hook.jsonl'); + const debug = debugLogger.createDebugLogger({ + logPath: file, + source: 'main', + }); + const quiet = { info: () => {}, warn: () => {}, error: () => {} }; + const { electron, listeners, webRequests } = makeFakeElectron(); + const result = hook.installPreloadHook(electron, { + logger: quiet, + skipUpdaterWatch: true, + debugLogger: debug, + }); + assert.equal(result.debugEnabled, true); + + const contents = { + once: (event, cb) => (contents._loaded = cb), + on: () => {}, + executeJavaScript: () => Promise.resolve(true), + }; + listeners['browser-window-created']({}, { webContents: contents }); + contents._loaded(); + + const handler = webRequests[0].cb; + handler( + { url: 'https://api.gdevelop.io/generation', method: 'POST' }, + () => {} + ); + handler( + { url: 'https://api.gdevelop.io/usage/limits?userId=x', method: 'GET' }, + () => {} + ); + + const events = fs + .readFileSync(file, 'utf8') + .trim() + .split('\n') + .map(line => JSON.parse(line)); + const names = events.map(e => e.event); + assert.ok(names.includes('hook-installed'), 'install recorded'); + assert.ok(names.includes('window-created'), 'window creation recorded'); + const hosted = events.find(e => e.event === 'hosted-api-attempt'); + assert.ok(hosted, 'hosted attempt flagged'); + assert.equal(hosted.level, 'warn'); + assert.ok(hosted.data.url.includes('api.gdevelop.io')); + }); +}); + +describe('debug-logger: renderer logger (preload)', () => { + function makeRendererScope() { + const handlers = {}; + const calls = []; + return { + scope: { + location: { href: 'https://editor.gdevelop.io/' }, + addEventListener: (type, cb) => (handlers[type] = cb), + console: { + error: (...args) => calls.push(['error', ...args]), + warn: (...args) => calls.push(['warn', ...args]), + }, + }, + handlers, + calls, + }; + } + + it('exposes window.__HERETEK_LOGGER__ and records console + errors', () => { + const preload = require('../../launcher/preload-unlock.cjs'); + const { scope, handlers, calls } = makeRendererScope(); + preload.installRendererHook(scope); + const rl = scope.__HERETEK_LOGGER__; + assert.ok(rl, '__HERETEK_LOGGER__ exposed'); + assert.ok(Array.isArray(rl.events)); + + scope.console.error('boom', { code: 7 }); + assert.deepEqual(calls, [['error', 'boom', { code: 7 }]]); + const consoleRec = rl.events.find(e => e.event === 'console-error'); + assert.ok(consoleRec, 'console.error intercepted'); + assert.equal(consoleRec.level, 'error'); + assert.equal(consoleRec.source, 'renderer'); + + handlers.error({ message: 'kaboom', filename: 'app.js' }); + assert.ok( + rl.events.some( + e => e.event === 'renderer-error' && e.data.message === 'kaboom' + ) + ); + handlers.unhandledrejection({ reason: 'nope' }); + assert.ok(rl.events.some(e => e.event === 'unhandled-rejection')); + + const pushed = rl.push('debug', 'custom', { n: 1 }); + assert.equal(pushed.event, 'custom'); + assert.equal(typeof pushed.ts, 'number'); + }); + + it('caps the in-memory buffer instead of growing forever', () => { + const preload = require('../../launcher/preload-unlock.cjs'); + const { scope } = makeRendererScope(); + preload.installRendererHook(scope); + for (let i = 0; i < 600; i++) scope.__HERETEK_LOGGER__.push('debug', 'x', {}); + assert.ok(scope.__HERETEK_LOGGER__.events.length <= 500); + }); + + it('renderer logger survives the vm-sandbox fallback path', () => { + const preload = require('../../launcher/preload-unlock.cjs'); + const scope = { + location: { href: 'https://editor.gdevelop.io/' }, + setTimeout: (fn, ms) => setTimeout(fn, ms), + }; + const sandbox = { + window: scope, + URL, + Promise, + JSON, + Object, + setTimeout, + }; + vm.createContext(sandbox); + vm.runInContext(preload.getRendererFallbackSource(), sandbox); + assert.ok(sandbox.window.__HERETEK_LOGGER__, 'logger in fallback seed'); + assert.ok(Array.isArray(sandbox.window.__HERETEK_LOGGER__.events)); + }); +}); + +describe('debug-logger: dump-debug-log CLI', () => { + function writeFixture(dir) { + const file = path.join(dir, 'fixture.jsonl'); + const recs = [ + { + ts: 1, + iso: '2026-10-12T00:00:00.000Z', + source: 'main', + level: 'info', + event: 'hook-installed', + data: {}, + }, + { + ts: 2, + iso: '2026-10-12T00:00:01.000Z', + source: 'renderer', + level: 'error', + event: 'renderer-error', + data: { requestId: 'req-1' }, + }, + { + ts: 3, + iso: '2026-10-12T00:00:02.000Z', + source: 'main', + level: 'warn', + event: 'hosted-api-attempt', + data: { requestId: 'req-1', url: 'https://api.gdevelop.io/x' }, + }, + ]; + fs.writeFileSync( + file, + recs.map(r => JSON.stringify(r)).join('\n') + '\n{broken\n' + ); + return file; + } + + function dump(args) { + return spawnSync('node', [path.join(ROOT, 'scripts', 'dump-debug-log.mjs'), ...args], { + encoding: 'utf8', + }); + } + + it('filters by event, level, and request id; missing file exits 2', () => { + const file = writeFixture(mkScratch()); + let r = dump([file, '--event', 'renderer-error']); + assert.equal(r.status, 0, r.stderr); + assert.ok(r.stdout.includes('renderer-error')); + assert.ok(!r.stdout.includes('hook-installed')); + + r = dump([file, '--level', 'warn']); + assert.equal(r.status, 0, r.stderr); + assert.ok(r.stdout.includes('hosted-api-attempt')); + assert.ok(!r.stdout.includes('renderer-error')); + + r = dump([file, '--request', 'req-1']); + assert.equal(r.status, 0, r.stderr); + assert.ok(r.stdout.includes('renderer-error')); + assert.ok(r.stdout.includes('hosted-api-attempt')); + assert.ok(!r.stdout.includes('hook-installed')); + + r = dump([path.join(ROOT, 'does-not-exist.jsonl')]); + assert.equal(r.status, 2); + }); +}); diff --git a/tests/patches/inject.test.mjs b/tests/patches/inject.test.mjs index feeb9a2..ea721db 100644 --- a/tests/patches/inject.test.mjs +++ b/tests/patches/inject.test.mjs @@ -31,7 +31,7 @@ describe('inject: extracted-asar layout', () => { assert.equal(findMainJs(dir), path.join(dir, 'main.js')); const r = injectIntoAppDir(dir); assert.equal(r.loaderAdded, true); - assert.deepEqual(r.filesSynced.length, 3); + assert.deepEqual(r.filesSynced.length, 4); const text = fs.readFileSync(path.join(dir, 'main.js'), 'utf8'); assert.ok( text.indexOf("installPreloadHook()") < @@ -41,6 +41,7 @@ describe('inject: extracted-asar layout', () => { 'heretek-hook.cjs', 'preload-unlock.cjs', 'unlock-payloads.cjs', + 'debug-logger.cjs', ]) { assert.equal( fs.readFileSync(path.join(dir, f), 'utf8'), diff --git a/tests/unit/opencode-control-shim.test.mjs b/tests/unit/opencode-control-shim.test.mjs new file mode 100644 index 0000000..bb1606f --- /dev/null +++ b/tests/unit/opencode-control-shim.test.mjs @@ -0,0 +1,476 @@ +// Unit tests: packages/unlock-shim/control-shim.mjs (T07, issue #16). +// +// Pure zero-dependency ESM control shim: project read/write, AST scene / +// object / behavior / event mutation, CLI command formatting, and a minimal +// RFC6455 debugger-bridge client. All I/O is real (TMPDIR scratch project +// trees, live 127.0.0.1 loopback servers); no mocks of the code under test, +// no network beyond loopback, bare node:test + builtins only. + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +import { + readGDevelopProject, + writeGDevelopProject, + createScene, + createOrReplaceObject, + addBehavior, + addStandardEvent, + buildGDevelopCliCommand, + connectDebuggerBridge, +} from '../../packages/unlock-shim/control-shim.mjs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +// Resolve the repo root from this file's location so the suite passes both +// in-tree (tests/unit/) and from the ticket worktree checkout. +const CONTROL_SHIM_SRC = (() => { + const candidates = [ + path.resolve(here, '../../packages/unlock-shim/control-shim.mjs'), + path.resolve( + here, + '../../.worktrees/ticket-T07/packages/unlock-shim/control-shim.mjs' + ), + ]; + for (const c of candidates) { + try { + fs.accessSync(c); + return c; + } catch {} + } + return candidates[0]; +})(); + +const minimalProject = (name = 'ScratchGame') => ({ + properties: { name, version: '1.0.0' }, + layouts: [], + objects: [], +}); + +let scratchDirs = []; +const mkScratch = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'control-shim-')); + scratchDirs.push(dir); + return dir; +}; +afterEach(() => { + for (const dir of scratchDirs) fs.rmSync(dir, { recursive: true, force: true }); + scratchDirs = []; +}); + +describe('unit: control-shim project read/write (real I/O)', () => { + it('round-trips a single-file game.json deterministically', () => { + const dir = mkScratch(); + const file = path.join(dir, 'game.json'); + const project = minimalProject(); + writeGDevelopProject(file, project); + const raw = fs.readFileSync(file, 'utf8'); + assert.ok(raw.endsWith('\n'), 'canonical file ends with a final newline'); + assert.match(raw, /^ "properties"/m, 'canonical 2-space indent'); + const loaded = readGDevelopProject(file); + assert.deepEqual(loaded, project); + // Deterministic: a second write is byte-identical. + writeGDevelopProject(file, loaded); + assert.equal(fs.readFileSync(file, 'utf8'), raw); + }); + + it('resolves split-folder __REFERENCE_TO_SPLIT_OBJECT layout files', () => { + const dir = mkScratch(); + const layout = { name: 'Level1', objects: [], events: [] }; + fs.mkdirSync(path.join(dir, 'layouts'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'layouts', 'level1.json'), + JSON.stringify(layout, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(dir, 'game.json'), + JSON.stringify({ + properties: { name: 'SplitGame' }, + layouts: [{ __REFERENCE_TO_SPLIT_OBJECT: 'layouts/level1' }], + }) + '\n' + ); + const loaded = readGDevelopProject(dir); + assert.equal(loaded.properties.name, 'SplitGame'); + assert.deepEqual(loaded.layouts, [layout]); + }); + + it('fails closed on malformed game.json and missing paths', () => { + const dir = mkScratch(); + const bad = path.join(dir, 'bad.json'); + fs.writeFileSync(bad, '{ not json'); + assert.throws(() => readGDevelopProject(bad), /game\.json|JSON|parse/i); + assert.throws( + () => readGDevelopProject(path.join(dir, 'nope.json')), + /ENOENT|not found|missing/i + ); + assert.throws( + () => readGDevelopProject(path.join(dir, 'no-dir')), + /ENOENT|not found|missing/i + ); + const wrongShape = path.join(dir, 'wrong.json'); + fs.writeFileSync(wrongShape, JSON.stringify([1, 2, 3])); + assert.throws(() => readGDevelopProject(wrongShape), /properties|project/i); + }); + + it('writeGDevelopProject rejects non-object payloads', () => { + const dir = mkScratch(); + assert.throws(() => writeGDevelopProject(path.join(dir, 'x.json'), null)); + assert.throws(() => writeGDevelopProject(path.join(dir, 'x.json'), [1])); + assert.throws(() => writeGDevelopProject(path.join(dir, 'x.json'), 'str')); + }); +}); + +describe('unit: control-shim AST mutations (scratch copies)', () => { + let project; + beforeEach(() => { + project = minimalProject(); + }); + + it('createScene injects a layout into projectData.layouts', () => { + const scene = createScene(project, 'Level1'); + assert.equal(scene.name, 'Level1'); + assert.equal(project.layouts.length, 1); + assert.equal(project.layouts[0].name, 'Level1'); + assert.ok(Array.isArray(project.layouts[0].objects)); + assert.ok(Array.isArray(project.layouts[0].events)); + }); + + it('createScene fails closed on duplicates and blank names', () => { + createScene(project, 'Level1'); + assert.throws(() => createScene(project, 'Level1'), /already exists|duplicate/i); + assert.throws(() => createScene(project, ''), /scene/i); + assert.throws(() => createScene(project, ' '), /scene/i); + assert.throws(() => createScene(null, 'X'), /project/i); + }); + + it('createOrReplaceObject adds then replaces scene objects', () => { + createScene(project, 'Level1'); + const first = createOrReplaceObject(project, { + sceneName: 'Level1', + objectName: 'Hero', + objectType: 'Sprite', + description: 'player avatar', + }); + assert.equal(first.created, true); + assert.equal(project.layouts[0].objects.length, 1); + const second = createOrReplaceObject(project, { + sceneName: 'Level1', + objectName: 'Hero', + objectType: 'Sprite', + }); + assert.equal(second.created, false); + assert.equal(project.layouts[0].objects.length, 1); + }); + + it('createOrReplaceObject supports global objects and fails on missing scenes', () => { + const res = createOrReplaceObject(project, { + objectName: 'GlobalScore', + objectType: 'TextObject::Text', + }); + assert.equal(res.created, true); + assert.equal(project.objects.length, 1); + assert.throws( + () => + createOrReplaceObject(project, { + sceneName: 'Missing', + objectName: 'X', + objectType: 'Sprite', + }), + /scene|not found/i + ); + assert.throws( + () => createOrReplaceObject(project, { objectName: '', objectType: 'Sprite' }), + /object/i + ); + assert.throws( + () => createOrReplaceObject(project, { objectName: 'X', objectType: '' }), + /object|type/i + ); + }); + + it('addBehavior injects family defaults (Platformer/Physics2/TopDown)', () => { + createScene(project, 'Level1'); + createOrReplaceObject(project, { + sceneName: 'Level1', + objectName: 'Hero', + objectType: 'Sprite', + }); + const plat = addBehavior(project, { + sceneName: 'Level1', + objectName: 'Hero', + behaviorName: 'Platformer', + behaviorType: 'PlatformBehavior::PlatformerObjectBehavior', + }); + assert.equal(plat.type, 'PlatformBehavior::PlatformerObjectBehavior'); + assert.ok( + Object.keys(plat).length > 2, + 'platformer default content must ride along' + ); + const phys = addBehavior(project, { + sceneName: 'Level1', + objectName: 'Hero', + behaviorName: 'Physics', + behaviorType: 'Physics2::Physics2Behavior', + }); + assert.ok(Object.keys(phys).length > 2, 'physics default content missing'); + const td = addBehavior(project, { + sceneName: 'Level1', + objectName: 'Hero', + behaviorName: 'Move', + behaviorType: 'TopDownMovementBehavior::TopDownMovementBehavior', + }); + assert.ok(Object.keys(td).length > 2, 'topdown default content missing'); + const hero = project.layouts[0].objects[0]; + assert.equal(hero.behaviors.length, 3); + // Re-adding the same behavior name replaces in place. + addBehavior(project, { + sceneName: 'Level1', + objectName: 'Hero', + behaviorName: 'Platformer', + behaviorType: 'PlatformBehavior::PlatformerObjectBehavior', + }); + assert.equal(hero.behaviors.length, 3); + }); + + it('addBehavior fails closed on missing scenes/objects', () => { + assert.throws( + () => + addBehavior(project, { + sceneName: 'Nope', + objectName: 'X', + behaviorName: 'B', + behaviorType: 'Physics2::Physics2Behavior', + }), + /scene|not found/i + ); + createScene(project, 'Level1'); + assert.throws( + () => + addBehavior(project, { + sceneName: 'Level1', + objectName: 'Ghost', + behaviorName: 'B', + behaviorType: 'Physics2::Physics2Behavior', + }), + /object|not found/i + ); + }); + + it('addStandardEvent appends a Standard event to the scene', () => { + createScene(project, 'Level1'); + const evt = addStandardEvent(project, { + sceneName: 'Level1', + conditions: [ + { type: { value: 'BuiltinCommonInstructions::Always' }, parameters: [] }, + ], + actions: [{ type: { value: 'Hero::setX' }, parameters: ['Hero', '100'] }], + }); + assert.equal(evt.type, 'BuiltinCommonInstructions::Standard'); + assert.equal(project.layouts[0].events.length, 1); + assert.equal(project.layouts[0].events[0].conditions.length, 1); + assert.equal(project.layouts[0].events[0].actions.length, 1); + }); + + it('addStandardEvent defaults empty conditions/actions and rejects missing scenes', () => { + createScene(project, 'Level1'); + const evt = addStandardEvent(project, { sceneName: 'Level1' }); + assert.deepEqual(evt.conditions, []); + assert.deepEqual(evt.actions, []); + assert.throws( + () => addStandardEvent(project, { sceneName: 'Ghost' }), + /scene|not found/i + ); + assert.throws( + () => + addStandardEvent(project, { sceneName: 'Level1', conditions: 'x' }), + /conditions/i + ); + }); + + it('full AST pipeline persists through write + re-read (real I/O)', () => { + const dir = mkScratch(); + const file = path.join(dir, 'game.json'); + writeGDevelopProject(file, project); + const loaded = readGDevelopProject(file); + createScene(loaded, 'Level1'); + createOrReplaceObject(loaded, { + sceneName: 'Level1', + objectName: 'Hero', + objectType: 'Sprite', + description: 'player', + }); + addBehavior(loaded, { + sceneName: 'Level1', + objectName: 'Hero', + behaviorName: 'Platformer', + behaviorType: 'PlatformBehavior::PlatformerObjectBehavior', + }); + addStandardEvent(loaded, { sceneName: 'Level1', conditions: [], actions: [] }); + writeGDevelopProject(file, loaded); + const reloaded = readGDevelopProject(file); + assert.equal(reloaded.layouts[0].objects[0].behaviors[0].name, 'Platformer'); + assert.equal(reloaded.layouts[0].events[0].type, 'BuiltinCommonInstructions::Standard'); + }); +}); + +describe('unit: control-shim CLI command builder', () => { + it('formats --run-command args for electron-app/app/main.js parsing', () => { + const cmd = buildGDevelopCliCommand({ + gdevelopPath: '/opt/GDevelop/GDevelop', + projectPath: '/games/mygame/game.json', + commandName: 'EXPORT_HTML5_EXTERNAL', + cmdArgs: ['/tmp/out'], + headless: true, + }); + assert.equal(cmd.command, '/opt/GDevelop/GDevelop'); + const args = cmd.args; + assert.ok(args.includes('--run-command'), 'must carry --run-command'); + assert.ok(args.includes('EXPORT_HTML5_EXTERNAL')); + assert.ok(args.includes('--cmd-args'), 'cmd args use the --cmd-args flag'); + assert.ok(args.includes('/games/mygame/game.json'), 'project path positional'); + // Positional project path is last (matches --run-command README usage). + assert.equal(args[args.length - 1], '/games/mygame/game.json'); + assert.ok( + args.includes('--disable-update-check'), + 'headless runs skip the update check' + ); + }); + + it('omits --cmd-args when none given and rejects bad input', () => { + const cmd = buildGDevelopCliCommand({ + gdevelopPath: 'GDevelop', + projectPath: 'game.json', + commandName: 'RUN_ALL_TESTS', + }); + assert.ok(!cmd.args.includes('--cmd-args')); + assert.throws( + () => + buildGDevelopCliCommand({ + gdevelopPath: 'GDevelop', + projectPath: 'game.json', + commandName: 'not-a-command', + }), + /command/i + ); + assert.throws( + () => + buildGDevelopCliCommand({ gdevelopPath: '', projectPath: 'g.json', commandName: 'X' }), + /gdevelop|path/i + ); + assert.throws( + () => + buildGDevelopCliCommand({ gdevelopPath: 'G', projectPath: '', commandName: 'X' }), + /project/i + ); + }); +}); + +describe('unit: control-shim debugger bridge (live loopback WS)', () => { + // Minimal RFC6455 server over node:net (test fixture, not the shim): accepts + // one handshake, sends a greeting text frame, echoes client frames upper-cased. + const serveOneWs = () => + new Promise(resolve => { + const server = net.createServer(socket => { + let buf = Buffer.alloc(0); + let handshook = false; + socket.on('data', chunk => { + buf = Buffer.concat([buf, chunk]); + if (!handshook) { + const end = buf.indexOf('\r\n\r\n'); + if (end === -1) return; + const head = buf.slice(0, end).toString('latin1'); + buf = buf.slice(end + 4); + const key = /Sec-WebSocket-Key: (.*)\r\n/i.exec(head)?.[1].trim(); + const accept = crypto + .createHash('sha1') + .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11') + .digest('base64'); + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${accept}\r\n\r\n` + ); + handshook = true; + const payload = Buffer.from('debugger:ready', 'utf8'); + socket.write(Buffer.from([0x81, payload.length])); + socket.write(payload); + } else if (buf.length >= 2) { + const len = buf[1] & 0x7f; + if (buf.length < 2 + 4 + len) return; + const mask = buf.slice(2, 6); + const payload = buf.slice(6, 6 + len); + for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i % 4]; + buf = buf.slice(6 + len); + const upper = Buffer.from(payload.toString('utf8').toUpperCase(), 'utf8'); + socket.write(Buffer.from([0x81, upper.length])); + socket.write(upper); + } + }); + server.close = server.close.bind(server); + }); + server.listen(0, '127.0.0.1', () => resolve(server)); + }); + + it('connects, receives server messages, and round-trips a send', async () => { + const server = await serveOneWs(); + const port = server.address().port; + const received = []; + const bridge = await connectDebuggerBridge(port, msg => received.push(msg)); + // Greeting frame from the fake DebuggerServer. + await new Promise(resolve => { + const t = setInterval(() => { + if (received.length > 0) { + clearInterval(t); + resolve(); + } + }, 10); + }); + assert.equal(received[0], 'debugger:ready'); + bridge.send({ command: 'getStatus' }); + await new Promise(resolve => { + const t = setInterval(() => { + if (received.length > 1) { + clearInterval(t); + resolve(); + } + }, 10); + }); + assert.match( + typeof received[1] === 'string' ? received[1] : JSON.stringify(received[1]), + /GETSTATUS/ + ); + bridge.close(); + await new Promise(resolve => server.close(resolve)); + }); + + it('fails closed on invalid ports and refused connections', async () => { + await assert.rejects(() => connectDebuggerBridge(0, () => {}), /port/i); + await assert.rejects(() => connectDebuggerBridge(99999, () => {}), /port/i); + await assert.rejects(() => connectDebuggerBridge(-1, () => {}), /port/i); + await assert.rejects(() => connectDebuggerBridge('abc', () => {}), /port/i); + // Loopback port that is (almost certainly) closed: must reject, never hang. + await assert.rejects( + () => connectDebuggerBridge(9, () => {}, { timeoutMs: 500 }), + /refused|connect|timeout/i + ); + }); +}); + +describe('unit: control-shim zero-egress + zero-dependency', () => { + it('never references the hosted API and imports only builtins', () => { + const src = fs.readFileSync(CONTROL_SHIM_SRC, 'utf8'); + assert.ok(!src.includes('api.gdevelop.io'), 'BYOK path must not touch hosted API'); + const imports = [...src.matchAll(/^import .* from '([^']+)'/gm)].map(m => m[1]); + for (const spec of imports) { + assert.match(spec, /^node:/, `non-builtin import: ${spec}`); + } + assert.ok(!src.includes("from 'ws'"), 'no ws dependency allowed'); + assert.ok(!src.includes('axios'), 'no axios allowed'); + }); +});