mirror of
https://github.com/Heretek-AI/GDevelop-Unlocked.git
synced 2026-11-07 10:55:58 -10:00
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:
commit
68d06e2bc2
42 files changed
+7276
-88
No files matched your search
@@ -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
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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)."
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 : [''];
|
||||
}
|
||||
Executable
+65
@@ -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);
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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]
|
||||
);
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.`
|
||||
);
|
||||
}
|
||||
@@ -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');";
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user