Merge integration/v0.2: v0.2.0 milestone (debug logging, AI Studio remediation #21-#24, OpenCode v2 integration #16-#19, hardening)

Suite 213/213 green (62 suites) + test:opencode 60/60 + test:e2e 5/5.
Seam gate + rebase gate CLEAN at upstream 8301cdcaa4. Fixture untouched.
This commit is contained in:
BillyOutlast committed 2026-10-12 01:00:41 -04:00
commit 68d06e2bc2
42 files changed
+7276 -88

No files matched your search

+5
View File
@@ -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
+57
View File
@@ -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 <path>` (or
`HERETEK_DEBUG_LOG=<path>`); inspect with
`node scripts/dump-debug-log.mjs <path>`. 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).
+47
View File
@@ -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.
+20 -5
View File
@@ -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 <upstream-checkout>
@@ -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 <path>` / `HERETEK_DEBUG_LOG`;
inspect with `node scripts/dump-debug-log.mjs <path>`)
- `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.
+39
View File
@@ -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.
+49
View File
@@ -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.
+22 -2
View File
@@ -60,7 +60,7 @@ node scripts/apply-patches.mjs --target <upstream-checkout>
```
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 <workdir> <app>/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 <path>` or
`HERETEK_DEBUG_LOG=<path>`; 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 <logfile> [--event N] [--level L] [--request ID] [--json]
```
## Test doubles vs production
`tests/launcher/` drives the hook with fake Electron objects. Two
+142
View File
@@ -0,0 +1,142 @@
// Client debug logging (T02): sync JSONL file appender for the launcher.
//
// Activated only by `--heretek-debug-log <path>` 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,
};
+127 -15
View File
@@ -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 <path>` / `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,
+155
View File
@@ -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,
+5 -1
View File
@@ -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"
},
+86 -2
View File
@@ -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<string> = [
'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<string> = [
'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<string> = ['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<string> = [
'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<Object>): Array<Object> =>
tools.filter(
tool =>
@@ -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.
}
};
@@ -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,
};
},
@@ -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:
@@ -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,
+14 -9
View File
@@ -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."
}
+53
View File
@@ -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.
+76
View File
@@ -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 }));
});
});
}
+37
View File
@@ -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);
+8
View File
@@ -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)."
}
+290
View File
@@ -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);
+261
View File
@@ -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 : [''];
}
+65
View File
@@ -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 <n> TCP port to listen on (0 = ephemeral, default 0)
--host <addr> bind address (default 127.0.0.1; keep loopback for zero egress)
--model <id> 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);
+14
View File
@@ -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"
}
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -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)
+593
View File
@@ -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:
* `<reference>.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'
);
});
});
}
+85 -29
View File
@@ -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<EditorFunctionCallResult>,
|}) => {
@@ -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 @@
/>
<Paper square background="dark" style={styles.paper}>
<div style={styles.chatContainer}>
+ {(() => {
+ // 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 (
+ <div
+ style={{
+ margin: 8,
+ padding: 12,
+ border: '1px solid',
+ }}
+ >
+ <div>{terminalStatus.badge}</div>
+ <div>{terminalStatus.reason}</div>
+ <div>{terminalStatus.timestamp}</div>
+ <button
+ onClick={() => {
+ if (!selectedAiRequest) return;
+ updateAiRequest(selectedAiRequest.id, currentRequest =>
+ buildResumeUpdate(
+ currentRequest || selectedAiRequest
+ )
+ );
+ onRetryAfterError();
+ }}
+ >
+ Resume/Retry
+ </button>
+ <button
+ onClick={() => {
+ clearStalledRequests();
+ }}
+ >
+ Clear Stalled
+ </button>
+ </div>
+ );
+ })()}
<AiRequestChat
aiConfigurationPresetsWithAvailability={getAiConfigurationPresetsWithAvailability(
{ limits, getAiSettings }
@@ -1795,7 +2015,9 @@
lastSendError={getLastSendError(selectedAiRequestId)}
quota={quota}
increaseQuotaOffering={
+175 -23
View File
@@ -17,13 +17,18 @@
} from './AiRequestUtils';
import { useEnsureExtensionInstalled } from './UseEnsureExtensionInstalled';
import { useGenerateEvents } from './UseGenerateEvents';
@@ -62,6 +63,31 @@
@@ -62,6 +63,39 @@
import { makeSimplifiedProjectBuilder } from '../EditorFunctions/SimplifiedProject/SimplifiedProject';
import { prepareAiUserContent } from './PrepareAiUserContent';
import { extractGDevelopApiErrorStatusAndCode } from '../Utils/GDevelopServices/Errors';
+import {
+ isCustomEndpointEnabled,
+ LOCAL_BYOK_USER_ID,
+ isOfflineDocsTool,
+ OFFLINE_DOCS_MESSAGE,
+ isLocalPlanTool,
+ isProjectDependentTool,
+ NO_PROJECT_MESSAGE,
+} from '../AI/CustomAIClient';
+import {
+ isSpawnAgentCall,
@@ -35,6 +40,9 @@
+import { createLoopGuard } from './Studio/LoopGuard';
+import {
+ mergePlanResultOutput,
+ mergePlanTasks,
+ validatePlanTasks,
+ buildPlanOutput,
+ patchPlanTaskAsDelegated,
+ replacePlanMessage,
+} from './Studio/PlanStore';
@@ -49,7 +57,7 @@
import UnsavedChangesContext from '../MainFrame/UnsavedChangesContext';
import {
type FileMetadata,
@@ -213,12 +239,10 @@
@@ -213,12 +247,10 @@
editorCallbacks: EditorCallbacks,
|}): React.Node => {
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<string>,
createdProject?: ?gdProject,
|}
@@ -93,7 +101,7 @@
getEditorFunctionCallResults: string => Array<EditorFunctionCallResult> | null,
addEditorFunctionCallResults: (
string,
@@ -319,6 +347,9 @@
@@ -319,6 +355,9 @@
onWillInstallExtension: (extensionNames: Array<string>) => void,
onExtensionInstalled: (extensionNames: Array<string>) => void,
isReadyToProcessFunctionCalls: boolean,
@@ -103,7 +111,7 @@
getIsAutoEditEnabled: () => boolean,
suspendAiRequest: (aiRequestId: string) => Promise<void>,
requestEditApproval: (request: EditApprovalRequest) => Promise<boolean>,
@@ -331,12 +362,30 @@
@@ -331,12 +370,30 @@
createdProject?: gdProject,
|}
) => Promise<?FileMetadata>,
@@ -134,7 +142,7 @@
} => {
const { ensureExtensionInstalled } = useEnsureExtensionInstalled({
project,
@@ -424,6 +473,14 @@
@@ -424,6 +481,14 @@
// `call:<callId>` (for a single direct modifying call like generate_events).
const approvedEditBatchKeysRef = React.useRef<Set<string>>(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<string>();
+ const noProjectResults: Array<EditorFunctionCallResult> = [];
+ 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<string>();
+ const offlineDocsResults: Array<EditorFunctionCallResult> = [];
+ const localPlanHandledCallIds = new Set<string>();
+ const localPlanResults: Array<EditorFunctionCallResult> = [];
+ 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]
);
+2
View File
@@ -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',
+105
View File
@@ -0,0 +1,105 @@
// Inspect a T02 client debug log (JSONL from launcher/debug-logger.cjs).
//
// node scripts/dump-debug-log.mjs <logfile>
// [--event <name>]... [--level <level>]... [--request <id>]...
// [--json] [--limit <n>]
//
// 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.`
);
}
+1
View File
@@ -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');";
+162
View File
@@ -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 <dir>]
//
// Steps (all real I/O, no mocks):
// 1. Resolve the upstream fixture (UPSTREAM_DIR, --upstream, or
// <root>/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 <scratch>`) 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
// <scratch>`), 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 <dir>]\n' +
' Upstream defaults to $UPSTREAM_DIR or <root>/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();
@@ -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));
}
});
});
+442
View File
@@ -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 });
}
});
});
@@ -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');
});
});
@@ -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');
});
});
@@ -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));
}
});
});
+368
View File
@@ -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 <path> 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);
});
});
+2 -1
View File
@@ -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'),
+476
View File
@@ -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');
});
});